Работа с неопределенным поведением при использовании reinterpret_cast в отображении памяти - PullRequest
3 голосов
/ 07 марта 2019

Чтобы избежать копирования больших объемов данных, желательно 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 , по крайней мере для меня, сбивает с толку.Я бы интерпретировал это как «то, что я делаю, совершенно законно».В частности, этот абзац:

Всякий раз, когда делается попытка прочитать или изменить сохраненное значение объекта типа DynamicType с помощью glvalue типа AliasedType, поведение не определено, если не выполняется одно из следующих условий:

  • AliasedType и DynamicType похожи.
  • AliasedType является (возможно, cv-квалифицированным) подписанным или неподписанным вариантом DynamicType.
  • AliasedType является std :: byte, (начиная с C ++ 17) char или unsigned char: это разрешаетпроверка представления объекта любого объекта в виде массива байтов.

Возможно, C ++ 17 дает некоторую надежду с std::launder или что яПридется подождать до C ++ 20 чего-то вроде std::bit_cast.

А пока как вы решите эту проблему?

Ссылка на онлайн-демонстрацию: https://onlinegdb.com/rk_xnlRUV

Упрощенный пример на C

Насколько я понимаю, правильно, что следующая программа на C не демонстрирует Undefined Behavior?Я понимаю, что приведение указателя через буфер 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

1 Ответ

3 голосов
/ 07 марта 2019

std::launder решает проблему со строгим псевдонимом, но не с временем жизни объекта.

std::bit_cast делает копию (это в основном оболочка для std::memcpy) и не работает с копированием из диапазона байтов.

В стандарте C ++ нет инструмента для переосмысления отображенной памяти без копирования. Такой инструмент был предложен: std :: bless . До тех пор, пока такие изменения не будут приняты в стандарте, вам придется либо надеяться, что UB ничего не нарушит , принять и скопировать потенциальный удар производительности †† , либо написать программу на языке C.

Хотя это и не идеально, это не обязательно так плохо, как кажется. Вы уже ограничиваете переносимость с помощью mmap, и если ваша целевая система / компилятор обещает, что можно нормально интерпретировать mmap память педа (возможно, с отмыванием), тогда проблем быть не должно. Тем не менее, я не знаю, если, скажем, GCC на Linux дает такую ​​гарантию.

†† Компилятор может оптимизировать std::memcpy. Там может не быть никакого снижения производительности. В этом SO ответе есть удобная функция, которая, как было отмечено, оптимизирована, но запускает время жизни объекта в соответствии с правилами языка. У него есть ограничение: отображаемая память должна быть доступна для записи (так как она создает объекты в памяти, а в неоптимизированной сборке она может делать фактическую копию).

...