使用reinterpret_cast在内存映射中遇到未定义行为的处理方式

10

为了避免复制大量数据,最好使用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

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

我的问题是:如何处理这种情况?

我发现很多答案存在冲突。

有些答案明确表示应该在本地构建对象。虽然这可能是正确的,但会严重复杂化任何面向数组的操作。

其他评论则认为这种构造的UB性质是一致的,但也存在一些争议。

cppreference中的措辞,至少对我来说令人困惑。我会将其解释为“我正在做的是完全合法的”。尤其是这个段落:

通过类型为 AliasedType 的 glvalue 读取或修改 DynamicType 类型对象的存储值时,除非以下情况之一成立,否则行为未定义:

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

可能C++17提供了一些希望,使用std::launder,或者只能等到类似于std::bit_cast的C++20。

在此期间,您如何处理此问题?

在线演示链接: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

3
std::bit_cast似乎对这种情况没有用处。 - eerorika
1
标准文件对于mmap没有任何说明(尤其是没有说明里面存储的对象的动态类型,或者是否存在任何对象),因此你真正处于编译器供应商所做决策的领域。我认为一个明智的方法是假设mmap的数据包含了之前被写入的相同对象,然后根据此编写代码。 - M.M
1个回答

8

std::launder可以解决严格别名的问题,但无法解决对象生命周期的问题。

std::bit_cast会进行复制(基本上是std::memcpy的包装),并且不能用于从字节范围内复制。

在标准C++中没有工具可以重新解释映射内存而不进行复制。已经提出了这样的工具:std::bless。在此类更改被采纳到标准中之前,您必须希望UB不会破坏任何东西,承受潜在的††性能损失和复制,或者用C语言编写程序。

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

††编译器可能会优化掉std::memcpy。没有任何性能损失的可能。在这个SO答案中有一个方便的函数,它被观察到被优化掉了,但是遵循语言规则启动对象生命周期。它有一个限制,映射内存必须是可写的(因为它在内存中创建对象,并且在非优化构建中可能会实际复制)。


2
std::bless的描述链接非常好。它描述了我的确切问题。 - Escualo
2
没错,基本上就是这样。简而言之,你是对的,你只能处理它,直到你获得这个新的奇怪功能来修补这个C++的缺陷...但没关系,因为每个人在过去的35年里都已经这样做了 :) - Lightness Races in Orbit
1
我认为标准委员会期望声称适用于低级编程的实现将以“环境特征的记录方式”处理代码,以在必要时维护委员会所描述的C语言精神原则(“不要阻止程序员完成需要完成的工作”),但不幸的是,一些编译器使得除了通过整体禁用许多优化之外,难以利用平台行为。 - supercat
1
@Escualo 我发现了UB的一个原因:你没有对buffer进行对齐,因此不能保证其具有Header所需的对齐方式。我不知道它是否违反了严格别名规则。我对C++比C更熟悉。 - eerorika
1
经过进一步研究,我认为这个 C 示例确实违反了严格别名规则。一个人可以通过将别名设置为 char 来检查结构体的位,但反过来做似乎是违规的。 - Escualo
显示剩余21条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接