2020-04-02:

Użycie struct a padding przy zapisie do pliku w C/C++

warsztaty dla początkujących

Od zeszłego tygodnia prawie codziennie prowadzę "wirtualne warsztaty dla początkujących z jakości kodu" - w dużym skrócie: analizuje i sugeruje rzeczy do poprawienia / zrobienia inaczej w kodzie, który otrzymuje od widzów. Wczorajszy (#6) odcinek był o dość krótkim i ciekawym kodzie w C++, który między innymi zapisywał ("manualnie") plik BMP. Ponieważ sam nagłówek BMP był tworzony/trzymany bezpośrednio w tablicy bajtów - co nie jest specjalnie czytelne - zasugerowałem użycie struktury. To z kolei spotkało się z zaskoczeniem jednego z widzów (w komentarzach pod wideo) z uwagi na dopełnienie (padding) / wyrównanie pól (alignment). Delikatnie rozbudowaną wersję mojej odpowiedzi zamieszczam poniżej.

Klasyczną metodą odczytu/zapisu nagłówków w C/C++ są/były zapisy/odczyty całych instancji struct'ów. A co z paddingiem? Ten się po prostu wyłącza dla danej struktury za pomocą jednego z dwóch sposobów:

  • __attribute__ ((__packed__))
  • #pragma pack(push, 1) i później #pragma pack(pop)

Przykładowy kod:

#include <cstdio> #include <cstdint> struct __attribute__((__packed__)) SomeFileHeader { uint8_t version; uint16_t width; uint16_t height; uint8_t compression; }; int main() { SomeFileHeader header; header.version = 2; header.width = 123; header.height = 78; header.compression = 0; FILE *f = fopen("out.bin", "wb"); if (f == nullptr) { perror("Failed to create file:"); return 1; } fwrite(&header, 1, sizeof(header) /* 6 bytes */, f); fclose(f); }

Formalnie dowolny obiekt w C/C++ może zostać odczytany jako seria bajtów (unsigned char), oraz może zostać odtworzony z serii bajtów – standardy C/C++ zawierają porozrzucane informacje o tym – więc to nie jest problemem. Drobny druk dotyczy oczywiście wskaźników i czasu życia obiektów na które te wskazują, ale ten problem nie dotyczy nagłówków plików, które pointerów nie mają - co najwyżej offsety.

Powyższe podejście oczywiście nie działa w przypadku:

  1. Nagłówków o nie-stałych wielkościach (np. większość struktur w formacie ZIP nie ma stałych wielkości).
  2. Użycie typów danych, które nie są natywne dla C/C++ i danej architektury (np. pola kodowane Big Endian podczas gdy architektura jest Little Endian, albo po prostu inne kodowania pól - np. LEB128 zamiast signed/unsigned intów).

Natomiast na streamie nie chodziło mi o bezpośredni zapis struktury, tylko o wrzucenie wszystkich pól do struktury dla czytelności, a potem zapis pola po polu. Zazwyczaj tworzy się do tego zestaw metod/funkcji wspomagających typu write_int8, write_int16, write_int32, etc - takie patterny można znaleźć w wielu encoderach (lub analogiczne z "read" w wielu parserach).

Najlepiej byłoby opakować to w klasę typu "BMPInfoHeader", która oprócz pól tego nagłówka będzie miała również metodę typu "build", "bake", "make", "serialize", "toBinaryData" (zwał jak zwał), która zwróci (albo wypełni podany) vector<uint8_t> (lub ananalogiczną strukturę danych) na podstawie zawartości pól. Alternatywnie może mieć metodę "write", która dostanie wskaźnik do pliku i pozapisuje pola do niego (korzystając ze wspomnianych write_int8, ...) - tu by można się zastanowić czy to nie byłaby zbytnia specjalizacja klasy, ale to kwestia projektu.

Przykładowy kod:

#include <cstdio> #include <cstdint> #include <vector> #include <cassert> // Helper class (normally it would go to a different file). // It's a bit simplified too. class HelperBinaryWriter { private: std::vector<uint8_t> data_; public: const std::vector<uint8_t>& get_data() const { return data_; } void write_uint8(uint8_t v) { data_.push_back(v); } void write_uint16le(uint16_t v) { write_uint8((uint8_t)v); write_uint8((uint8_t)(v >> 8)); } }; class SomeFileHeader { public: // Or private + getters/setters if you prefer. static const size_t HEADER_SIZE = 1 + 2 + 2 + 1; uint8_t version; uint16_t width; uint16_t height; uint8_t compression; std::vector build() const { HelperBinaryWriter writer; writer.write_uint8(version); writer.write_uint16le(width); writer.write_uint16le(height); writer.write_uint8(compression); auto data = writer.get_data(); assert(data.size() == SomeFileHeader::HEADER_SIZE); return data; } }; int main() { SomeFileHeader header; header.version = 2; header.width = 123; header.height = 78; header.compression = 0; auto data = header.build(); FILE *f = fopen("out.bin", "wb"); if (f == nullptr) { perror("Failed to create file:"); return 1; } fwrite(data.data(), 1, data.size(), f); fclose(f); }

Powyższy pomysł można by zaimplementować również na kilka innych sposobów, ale ostatecznie powinniśmy otrzymać czytelny kod, który przy okazji jest przenośny pomiędzy platformami/kompilatorami, a także całkiem łatwy do rozszerzenia.

Dodatkowe materiały:

Comments:

2020-04-16 19:37:03 = Grubeo
{
Od C++11 na nowszych wersjach gcc i clang zamiast __attribute__((packed)) można użyć [[gnu::packed]]
}

Add a comment:

Nick:
URL (optional):
Math captcha: 1 ∗ 8 + 10 =