【问题标题】:Dealing with undefined behavior when using reinterpret_cast in a memory mapping在内存映射中使用 reinterpret_cast 时处理未定义的行为
【发布时间】:2019-07-28 19:30:09
【问题描述】:

为避免复制大量数据,最好将mmap 二进制文件并直接处理原始数据。这种方法有几个优点,包括将分页委托给操作系统。不幸的是,据我了解,显而易见的实现会导致未定义行为 (UB)。

我的用例如下:创建一个二进制文件,其中包含一些标识格式的标头并提供元数据(在这种情况下只是double 值的数量)。文件的其余部分包含我希望处理的原始二进制值,而不必先将文件复制到本地缓冲区(这就是我首先对文件进行内存映射的原因)。下面的程序是一个完整的(如果简单的话)示例(我相信所有标记为UB[X] 的地方都会导致UB):

// C++ Standard Library
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <fstream>
#include <iostream>
#include <numeric>

// POSIX Library (for mmap)
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

constexpr char MAGIC[8] = {"1234567"};

struct Header {
  char          magic[sizeof(MAGIC)] = {'\0'};
  std::uint64_t size                 = {0};
};
static_assert(sizeof(Header) == 16, "Header size should be 16 bytes");
static_assert(alignof(Header) == 8, "Header alignment should be 8 bytes");

void write_binary_data(const char* filename) {
  Header header;
  std::copy_n(MAGIC, sizeof(MAGIC), header.magic);
  header.size = 100u;

  std::ofstream fp(filename, std::ios::out | std::ios::binary);
  fp.write(reinterpret_cast<const char*>(&header), sizeof(Header));
  for (auto k = 0u; k < header.size; ++k) {
    double value = static_cast<double>(k);
    fp.write(reinterpret_cast<const char*>(&value), sizeof(double));
  }
}

double read_binary_data(const char* filename) {
  // POSIX mmap API
  auto        fp = ::open(filename, O_RDONLY);
  struct stat sb;
  ::fstat(fp, &sb);
  auto data = static_cast<char*>(
      ::mmap(nullptr, sb.st_size, PROT_READ, MAP_PRIVATE, fp, 0));
  ::close(fp);
  // end of POSIX mmap API (all error handling ommitted)

  // UB1
  const auto header = reinterpret_cast<const Header*>(data);

  // UB2
  if (!std::equal(MAGIC, MAGIC + sizeof(MAGIC), header->magic)) {
    throw std::runtime_error("Magic word mismatch");
  }

  // UB3
  auto beg = reinterpret_cast<const double*>(data + sizeof(Header));

  // UB4
  auto end = std::next(beg, header->size);

  // UB5
  auto sum = std::accumulate(beg, end, double{0});

  ::munmap(data, sb.st_size);

  return sum;
}

int main() {
  const double expected = 4950.0;
  write_binary_data("test-data.bin");

  if (auto sum = read_binary_data("test-data.bin"); sum == expected) {
    std::cout << "as expected, sum is: " << sum << "\n";
  } else {
    std::cout << "error\n";
  }
}

编译运行为:

$ clang++ example.cpp -std=c++17 -Wall -Wextra -O3 -march=native
$ ./a.out
$ as expected, sum is: 4950

在现实生活中,实际的二进制格式要复杂得多,但保留了相同的属性:基本类型以正确对齐方式存储在二进制文件中。

我的问题是:你如何处理这个用例?

我发现了许多我认为相互矛盾的答案。

一些answers 明确表示应该在本地构建对象。这很可能是这种情况,但会使任何面向数组的操作变得非常复杂。

评论 elsewhere 似乎同意这种构造的 UB 性质,但也存在一些分歧。

cppreference 中的措辞至少对我来说是令人困惑的。我会将其解释为“我正在做的事情是完全合法的”。特别是这一段:

每当尝试读取或修改存储的值时 DynamicType 类型的对象通过 AliasedType 类型的左值, 除非满足以下条件之一,否则行为未定义:

  • AliasedType 和 DynamicType 相似。
  • AliasedType 是 DynamicType 的(可能是 cv 限定的)有符号或无符号变体。
  • AliasedType 是 std::byte、(C++17 起)char 或 unsigned char:这允许将任何对象的对象表示检查为字节数组。

可能是 C++17 为 std::launder 带来了一些希望,或者我必须等到 C++20 才能看到类似于 std::bit_cast 的东西。

同时,您如何处理这个问题?

在线演示链接:https://onlinegdb.com/rk_xnlRUV

C 中的简化示例

我的理解是正确的,以下 C 程序没有表现出未定义的行为?我知道通过char 缓冲区的指针转换不参与严格的别名规则。

#include <stdint.h>
#include <stdio.h>

struct Header {
  char     magic[8];
  uint64_t size;
};

static void process(const char* buffer) {
  const struct Header* h = (const struct Header*)(buffer);
  printf("reading %llu values from buffer\n", h->size);
}

int main(int argc, char* argv[]) {
  if (argc != 2) {
    return 1;
  }
  // In practice, I'd pass the buffer through mmap
  FILE* fp = fopen(argv[1], "rb");
  char  buffer[sizeof(struct Header)];
  fread(buffer, sizeof(struct Header), 1, fp);
  fclose(fp);
  process(buffer);
}

我可以通过传递由原始 C++ 程序创建的文件来编译和运行这段 C 代码,并按预期工作:

$ clang struct.c -std=c11 -Wall -Wextra -O3 -march=native
$ ./a.out test-data.bin 
reading 100 values from buffer

【问题讨论】:

  • std::bit_cast 在这种情况下似乎没有用处。
  • 该标准没有说明mmap 的任何内容(尤其不是其中存储的对象的动态类型是什么,或者实际上是否有任何对象)所以你真的在编译器供应商做出的决定的领域。我认为一个明智的方法是假设 mmap 的数据包含相同的对象,并相应地编写您的代码

标签: c++ undefined-behavior reinterpret-cast memory-mapping


【解决方案1】:

std::launder 解决了严格别名的问题,但不能解决对象生命周期的问题。

std::bit_cast 制作一个副本(它基本上是std::memcpy 的包装器)并且不适用于从一个字节范围进行复制。

标准 C++ 中没有工具可以在不复制的情况下重新解释映射内存。已经提出了这样的工具:std::bless。直到/除非此类更改被纳入标准,您必须要么希望 UB 不会破坏任何东西,要么接受潜在的†† 性能影响并复制,或者用 C 语言编写程序。

虽然并不理想,但这并不一定像听起来那么糟糕。您已经通过使用mmap 限制了可移植性,并且如果您的目标系统/编译器承诺可以重新解释mmapped 内存(可能带有洗钱),那么应该没有问题。就是说,不知道是不是说,Linux上的GCC给了这样的保证。

†† 编译器可能会优化std::memcpy。可能不会对性能造成任何影响。这个SO answer 中有一个方便的函数,它被观察到已被优化掉,但确实会按照语言规则启动对象生命周期。它确实有一个限制,映射内存必须是可写的(因为它在内存中创建对象,并且在非优化构建中它可能会执行实际复制)。

【讨论】:

  • std::bless 描述的链接非常好。它描述了我的确切问题。
  • 是的,基本上。简而言之,你是对的,你将不得不处理它,直到你得到这个新的奇怪的功能来破解/修补这个 C++ 缺陷......但这没关系,因为过去 35 年来每个人都已经这样做了:)
  • 我认为标准委员会期望那些声称适合低级编程的实现将“以环境的文档化方式”处理代码,以维护委员会将 C 原则描述为(“不要阻止程序员做需要做的事情”),但不幸的是,一些编译器很难利用平台行为,除非大量禁用许多优化。
  • @Escualo 我确实发现了导致 UB 的一个原因:您没有对齐 buffer,因此不能保证具有 Header 所需的对齐。我不知道它是否没有严格的混叠违规。我比 C 更了解 C++。
  • 经过进一步研究,我认为 C 示例确实违反了严格的别名。可以通过别名为 char 来检查结构的位,但反过来做似乎是一种违规。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2017-01-18
  • 2015-04-07
  • 2019-04-19
  • 1970-01-01
  • 2016-09-04
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多