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:
- 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. 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.
By the way...
If want to improve your binary file and protocol skills, check out the workshop I'll be running between April and June → Mastering Binary Files and Protocols: The Complete Journey
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”.
Comments:
Add a comment: