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 dzięki 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:
- Nagłówków o nie-stałych wielkościach (np. większość struktur w formacie ZIP nie ma stałych wielkości).
- 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. zwykle 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:
- Artykuł „Diabeł tkwi w szczegółach: C/C++, cz. 2” – sekcja „Padding”.
- Książka "Zrozumieć Programowanie" – rozdział 11 „Pliki binarne i tekstowe”, str 394.
- I dodatkowo monografia j00ru „Detecting Kernel Memory Disclosure with x86 Emulation and Taint Tracking” – rozdział 2.1.2 „Structure alignment and padding bytes”.