Czas kompleksowo ogarnąć reprezentację binarną
W dwóch poprzednich wpisach rozważałem odczyt i zapis danych, które były odzwierciedleniem (obrazem) pamięci w tzw. programach Win32 (czyli pracujących w środowisku 32 bitowych Windows, z bezpośrednim, niezarządzanym dostępem do pamięci). Wypadałoby postawić „kropkę nad i” tj. wspomnieć jeszcze o klasie Buffer oraz opisać związane z nią i klasami Encoding i BitConverter niuanse, które, gdybym umieścił je w poprzednich wpisach, niepotrzebne utrudniły by lekturę.
Co jest takiego w klasie Buffer, że warto jej się bliżej przyjrzeć? Otóż bywa, że dane zapisane w postaci binarnej, są prostymi tablicami wielowymiarowymi (najczęściej dwuwymiarowymi). Niestety pobranie ich z pliku (jak pokazywałem) jest możliwe jedynie do jednowymiarowej tablicy (w dodatku bajtowej). W tym jednakże przypadku nie jest konieczne szatkowanie takiej tablicy w celu transformacji na tablicę wielowymiarową (dowolnego, liczbowego typu prostego), z pomocą przychodzi właśnie ta klasa i jej metoda BlockCopy. Jako pierwsze dwa parametry podaje się tablicę źródłową i indeks, od którego ma nastąpić kopiowanie ciągu bajtów, następnie tablicę docelową i liczbę bajtów, jakie mają podlegać transformacji. W przypadku ostatniego parametru klasa Buffer udostępnia metodę ByteLength pozwalającą określić rozmiar tablicy źródłowej.
Oczywiście istotną kwestią jest ustalenie, jakiego typu jest element tablicy, aby transformacja była prawidłowa. Jeśli bowiem dokonamy transformacji np. współrzędnych kartezjańskich (x, y, z), które są typu int na tablicę o elementach short, otrzymany zupełnie bezsensowne wartości (tak samo będzie w odwrotnej sytuacji: z short na int). Czyli tablica docelowa musi posiadać elementy odpowiedniego typu. Kolejna kwestia, to znajomość wymiarów tablicy, aby możliwe było jej prawidłowe odwzorowanie. Jeśli np. tablica miała pierwotnie trzy kolumny (odpowiadające współrzędnym kartezjańskim: x, y, z) to transformacja na tabelę dwuwymiarową, choć się powiedzie – nie będzie miała sensu.
Poniżej przykład prawidłowej transformacji z tablicy bajtów zawierających w rzeczywistości tablicę dwuwymiarową o typie elementów ushort i rozmiarze 5 na 2.
static public void TestOneDimToTwoDim() { byte[] OneDim = { 0xE8, 0x03, 0xF1, 0x03, 0xE9, 0x03, 0xF0, 0x03, 0xEA, 0x03, 0xEF, 0x03, 0xEB, 0x03, 0xEE, 0x03, 0xEC, 0x03, 0xED, 0x03 }; ushort[,] TwoDim = OneDimToTwoDim(OneDim); Present(TwoDim); } static public ushort[,] OneDimToTwoDim(byte[] aArray) { int size = Buffer.ByteLength(aArray), cols = 2, rows = size / (sizeof(ushort) * cols); ushort[,] Result = new ushort[rows, cols]; Buffer.BlockCopy(aArray, 0, Result, 0, size); return Result; } static public void Present(ushort[,] aArray) { for (int row = 0; row < aArray.GetLength(0); row++) { for (int col = 0; col < aArray.GetLength(1); col++) Console.Write(aArray[row, col].ToString() + ' '); Console.WriteLine(); } }
Wynikiem działania metody TestOneDimToTwoDim powinno być 5 wierszy zawierających pary następujących liczb:
1000 1009 1001 1008 1002 1007 1003 1006 1004 1005
Wszystkie wspomniane klasy znakomicie radzą sobie z danymi w postaci binarnej – niestety do czasu… Jeśli bowiem dane zostaną wygenerowane w innej architekturze procesora, sprawa się komplikuje. Dokładnie dotyczy to danych liczbowych, które zajmują w pamięci więcej niż jeden bajt (czyli m.in. short, int, long). Otóż są dwa sposoby przechowywania takich liczb w pamięci. Jeśli jako przykład weźmiemy liczbę 1001 to hexadecymalnie wygląda ona następująco: 0x03E9. Jak widać składa się ona z dwóch bajtów, które mogą być ułożone w kolejnych komórkach pamięci (licząc od lewej) w takiej kolejności: 03 E9, albo w kolejności przeciwnej: E9 03. Pierwszy porządek bajtów to tzw. Big Endian, w którym najbardziej znaczący bajt liczby umieszczany jest w komórce o najniższym adresie, drugi porządek to Little Endian, który robi dokładnie na odwrót – umieszcza w komórce o najniższym adresie bajt najmniej znaczący. Więcej na ten temat można poczytać chociażby na Wikipedii. Procesory w architekturze x86 (czyli między innymi Intel i AMD) stosują architekturę Little Endian, inne procesory (np. SPARC lub Motorola) stosują Big Endian. Jeśli dane binarne są przenoszone pomiędzy takimi odmiennymi architekturami, ich interpretacja będzie zafałszowana. Aby się o tym przekonać zastosujmy powyższy kod na danych typu Big Endian:
static public void TestOneDimToTwoDimBigEndian() { byte[] OneDim = { 0x03, 0xE8, 0x03, 0xF1, 0x03, 0xE9, 0x03, 0xF0, 0x03, 0xEA, 0x03, 0xEF, 0x03, 0xEB, 0x03, 0xEE, 0x03, 0xEC, 0x03, 0xED }; ushort[,] TwoDim = OneDimToTwoDim(OneDim); Present(TwoDim); }
Uzyskujemy całkowicie inne wartości:
59395 61699 59651 61443 59907 61187 60163 60931 60419 60675
Dlatego czytając dane binarne pochodzące z innej architektury, koniecznie musimy dokonać ich przestawienia i – co jest bardzo istotne – jedynie w zakresie zajmowanym przez dany typ liczbowy (2 bajty dla short, 4 dla int, itd.) – nie można odwracać całej tabeli. Poniżej kod, który radzi sobie z binariami w formacie Big Endian:
static public void TestOneDimToTwoDimTransformBigEndian() { byte[] OneDim = { 0x03, 0xE8, 0x03, 0xF1, 0x03, 0xE9, 0x03, 0xF0, 0x03, 0xEA, 0x03, 0xEF, 0x03, 0xEB, 0x03, 0xEE, 0x03, 0xEC, 0x03, 0xED }; SwitchEndianness(ref OneDim, sizeof(ushort)); ushort[,] TwoDim = OneDimToTwoDim(OneDim); Present(TwoDim); } static public void SwitchEndianness(ref byte[] aArray, int aSizeOfElement) { int idx = 0, size = aArray.Length; while (idx < size) { Array.Reverse(aArray, idx, aSizeOfElement); idx += aSizeOfElement; } }
Ten kod wygeneruje poprawne wyniki, takie jak w pierwszym przykładzie.
Należy zdawać sobie sprawę, że dane binarne zazwyczaj nie posiadają żadnych oznaczeń informujących o architekturze, z której pochodzą. Sposób ich interpretacji jest więc kwestią umowną, np. wspominany już we wcześniejszym wpisie format dBase (DBF) zapisuję liczby w postaci Little Endian, ale nie jest to w żaden sposób zakomunikowane – np. w jego nagłówku. Po prostu z definicji jest on formatem w postaci Little Endian.
Problem Big Endian vs Little Endian dotyczy także klas BitConverter i Encoding. W tym wypadku istnieją jednak pewne ułatwienia. Jeśli chodzi o klasę BitConverter, to posiada ona właściwość IsLittleEndian informującą, czy obecna platforma używa architektury Little Endian. Dzięki temu można dostosować się do danych binarnych otrzymanych w przeciwnym formacie i dokonać stosownej konwersji. Oczywiście nic nie stoi na przeszkodzie, aby wykorzystać tę właściwość także przy okazji używania klasy Buffer, wówczas podana powyżej metoda TestOneDimToTwoDimTransformBigEndian mogłaby przyjąć postać:
static public void TestOneDimToTwoDimWithCorrectEndian() { byte[] OneDim = { 0x03, 0xE8, 0x03, 0xF1, 0x03, 0xE9, 0x03, 0xF0, 0x03, 0xEA, 0x03, 0xEF, 0x03, 0xEB, 0x03, 0xEE, 0x03, 0xEC, 0x03, 0xED }; if (BitConverter.IsLittleEndian) SwitchEndianness(ref OneDim, sizeof(ushort)); ushort[,] TwoDim = OneDimToTwoDim(OneDim); Present(TwoDim); }
W przypadku klasy Encoding kolejność bajtów będzie miała jedynie znaczenie w zapisie Unicode, ponieważ używa on do reprezentacji znaku liczby dwubajtowej, więc – w zależności od architektury – inaczej taką liczbę trzeba interpretować. Na szczęście w tym wypadku (jako że Unicode jest w miarę młody) wzięto pod uwagę ten niuans i pliki zawierające zakodowany w nim tekst mają na początku stosowną sygnaturę, która pozwala określić, jakiego układu bajtów – Big Endian czy Little Endian użyto do zapisu pliku. Sygnatura ta to dwubajtowa liczba 0xFEFF, która znajduje się na początku pliku. Jeśli czytając dwa pierwsze bajty nie otrzymamy takiej liczby (a otrzymamy 0xFFFE) – oznacza to, że w pliku wykorzystano układ bajtów przeciwny do używanego przez bieżącą platformę sprzętową. Dla popularnego tekstu zawierającego wszystkie polskie litery (czyli „Zażółć gęślą jaźn”) odpowiednie postaci (hexadecymalnie) plików wyglądałyby następująco:
Big Endian
FE FF 00 5A 00 61 01 7C 00 F3 01 42 01 07 00 20 00 67 01 19 01 5B 00 6C 01 05 00 20 00 6A 00 61 01 7A 00 6E
Little Endian
FF FE 5A 00 61 00 7C 01 F3 00 42 01 07 01 20 00 67 00 19 01 5B 01 6C 00 05 01 20 00 6A 00 61 00 7A 01 6E 00
Jeśli zatem natrafimy na plik typu Big Endian, to możemy podczas jego odczytu albo użyć gotowej instancji klasy Encodingtj. BigEndianUnicode, albo pobrać go do tablicy bajtów i dokonać jej przestawienia (choćby zaprezentowaną w tym wpisie metodą SwitchEndianness), po czym przekonwertować na tekst.
Myślę, że ta dodatkowa garść informacji dopełnia całości zagadnienia reprezentacji danych w postaci binarnej. Ci którzy nadal czują niedosyt, mogą go wyrazić w komentarzach. Podejrzewam, że do tematu jeszcze powrócę, więc będzie to dla mnie dodatkowa do tego motywacja.