Jak plik z obrazem pamięci odczytać i nic nie pokręcić
Jak wiadomo wszystkie dane platformy .NET są przechowywane w postaci obiektów (oraz struktur – szczególnego przypadku obiektów), więc siłą rzeczy sposób przechowywania tychże danych jest ukryty przed programistą (obiekty są wszak hermetyczne).
Jest to całkowicie odmienne podejście do przechowywania danych w stosunku do stosowanego przed erą .NET. Tam typy proste były zwyczajnie obszarem pamięci, który był odpowiednio interpretowany przez oprogramowanie (dbał o to kompilator). Tworząc strukturę, de facto umieszczało się kolejno w pamięci określone porcje bajtów, które były interpretowane stosownie do odpowiadającego im typu. Z punktu widzenia kodu programu, nic nie stało mu na drodze do struktury (w .NET staje na drodze właśnie kod obiektu).
Taka struktura (w pseudokodzie):
struct Product { public int Identifier; public char[8] Symbol; public char[20] Name; public short Quantity; }
ułożona była w pamięci w ten sposób, że pierwsze cztery bajty zajmowała wartość identyfikatora, następne 10 bajtów zajmowały znaki wchodzące w skład symbolu, kolejne 40 bajtów obejmowało znaki reprezentujące nazwę, a na końcu dwa bajty określały ilość. W przypadku tablicy 5 produktów, wyglądała on w pamięci mniej więcej tak:
indeks tablicy | przesunięcie w pamięci | pole struktury | zawartość pamięci hex | zinterpretowana zawartość pamięci |
---|---|---|---|---|
0 | 00 | Identifier | 00 01 01 00 | 65792 |
0 | 04 | Symbol | 53 79 6D 62 6F 6C 41 20 | „SymbolA „ |
0 | 0C | Name | 4E 61 7A 77 61 20 70 69 65 72 77 73 7A 61 20 20 20 20 20 20 | „Nazwa pierwsza „ |
0 | 20 | Quantity | 01 00 | 256 |
1 | 22 | Identifier | 01 01 01 00 | 65793 |
1 | 26 | Symbol | 53 79 6D 62 6F 6C 42 20 | „SymbolB „ |
1 | 2E | Name | 4E 61 7A 77 61 20 64 72 75 67 61 20 20 20 20 20 20 20 20 20 | „Nazwa druga „ |
1 | 42 | Quantity | 01 01 | 257 |
2 | 44 | Identifier | 02 01 01 00 | 65794 |
2 | 48 | Symbol | 53 79 6D 62 6F 6C 43 20 | „SymbolC „ |
2 | 50 | Name | 4E 61 7A 77 61 20 74 72 7A 65 63 69 61 20 20 20 20 20 20 20 | „Nazwa trzecia „ |
2 | 64 | Quantity | 01 02 | 258 |
3 | 66 | Identifier | 03 01 01 00 | 65795 |
3 | 6A | Symbol | 53 79 6D 62 6F 6C 44 20 | „SymbolD „ |
3 | 72 | Name | 4E 61 7A 77 61 20 63 7A 77 61 72 74 61 20 20 20 20 20 20 20 | „Nazwa czwarta „ |
3 | 86 | Quantity | 01 03 | 259 |
4 | 88 | Identifier | 04 01 01 00 | 65796 |
4 | 8C | Symbol | 53 79 6D 62 6F 6C 45 20 | „SymbolE „ |
4 | 94 | Name | 4E 61 7A 77 61 20 70 69 A5 74 61 20 20 20 20 20 20 20 20 20 | „Nazwa piąta „ |
4 | A8 | Quantity | 01 04 | 260 |
Tu mała dygresja. Ze względów wydajnościowych, tj. szybkiego dostępu do pamięci, bywało, że pomiędzy poszczególnymi polami były puste miejsca (niewykorzystana pamięć). Niemniej można było zażądać ścisłego upakowania, tak jak w powyższym przykładzie.
Niestety w .NET tak już nie jest, nie można traktować struktury jako spójnego obszaru pamięci. Rodzi to problemy, kiedy np. odczytujemy dane z pliku generowanego za pomocą oprogramowania nie napisanego w .NET (np. tabela w formacie dBase) lub uruchamiamy kod systemu operacyjnego, który oczekuje struktur w ściśle określonym układzie. O ile ten drugi przypadek został wzięty w .NET pod uwagę i istnieją odpowiednie mechanizmy, aby go obsłużyć (przetaczanie – marshaling), o tyle nie bardzo można zastosować je do interpretacji wspomnianych wcześniej plików.
Na czym polega problem w przypadku plików? Otóż kiedyś wystarczało następujące wywołanie (pseudokod):
Product[5] Products; // ... FileStream fs; // ... fs.Read(Products, sizeof(Products)); // sizeof(Products) zwracał rozmiar tablicy jako sumę rozmiaru jej elementów
aby wybrana zawartość pliku została umieszczona w tablicy Products. Równie łatwe było jej zapisywanie do pliku:
fs.Write(Products, sizeof(Products));
W .NET takie podejście jest niemożliwe. Jedyne co można odczytać za pomocą strumienia z pliku to tablica bajtów. A potrzebny jest odczyt struktury (lub ciągu struktur). Oczywiście jest na to sposób, w przeciwnym wypadku .NET miałoby mniejszą funkcjonalność niż jego poprzednicy. Z pomocą przychodzi klasa BitConverter. Posiada ona metody, które pozwalają przeprowadzać szatkowanie tablicy bajtów na odpowiednie typy danych. Wystarczy zatem pobrać zawartość pliku do tablicy, a następnie dokonać konwersji na właściwe typy (takimi metodami jak: ToBoolean, ToByte, ToChar, ToInt??, ToUInt??, ToString, ToDouble, ToSingle), wskazując w tablicy indeksy odpowiadające położeniu poszczególnych pól struktury (będą one zgodne wartościowo z podanymi w powyższej tabeli przesunięciami w pamięci). Innym wyjściem jest czytanie pliku w blokach o rozmiarze równym rozmiarowi (liczonym wg starych zasad) zajmowanym przez strukturę w pamięci. Wówczas podczas wyłuskiwania pól z takiej tablicy wystarczy podawać jako indeksy ich przesunięcia wewnątrz struktury (czyli trzymając się powyższego przykładu, będą to indeksy odpowiadające przesunięciom w pamięci dla pierwszych pięciu wierszy – odpowiadających indeksowi 0 przykładowej tablicy).
Niestety okazuje się, że sama klasa BitConverter jest niewystarczająca, nie potrafi bowiem przetransformować tekstów na typ string. Jak wiadomo przechowuje on teksty w postaci Unicode (gdzie jeden znak reprezentują dwa bajty), a w rozpatrywanych plikach teksty najczęściej przechowywane są w postaci jednobajtowej, zaś znaki narodowe przyjmują wybrane wartości z zakresu od 128 do 255, w zależności od wybranej do ich interpretacji strony kodowej (np. dla strony kodowej polskiego Windows kod ASCII odpowiadająca znakowi ‚Ą’ to 165).
W tym przypadku z pomocą przychodzi kolejna klasa tj. Encoding, a dokładnie jej metoda GetString, która jako jeden z argumentów przyjmuje tablicę bajtów mającą podlegać konwersji na tekst. Dodatkowo konieczne jest wybranie takiej instancji klasy Encoding, aby używała ona strony kodowej, wg której mają być interpretowane znaki powyżej kodu ASCII 127. Na szczęście klasa Encoding posiada metodę GetEncoding, która zwraca odpowiednią instancję, obsługującą kodowanie zgodnie z podaną w jej parametrze, tekstową nazwą strony kodowej (np. dla kodowania polskich znaków w Windows jest to „windows-1250”).
A oto przykład, demonstrujący pobieranie pliku zawierającego obraz pamięci i wykorzystującego wspomniane klasy:
class BinaryIO { class Product // klasa odzwierciedlająca pobieraną strukturę { public int Identifier { get; set; } public string Symbol { get; set; } public string Name { get; set; } public short Quantity { get; set; } public Product(int aIdentifier, string aSymbol, string aName, short aQuantity) { Identifier = aIdentifier; Symbol = aSymbol; Name = aName; Quantity = aQuantity; } } private List<Product> Load(string aFileName) { List<Product> Products = new List<Product>(); Encoding CurrentEncoding = Encoding.GetEncoding("windows-1250"); // instancja kodowania dla Windows PL byte[] Sizes = { sizeof(int), 8, 20, sizeof(short) }; // długości poszczególnych pól struktury starego typu int BlockSize = Sizes[0] + Sizes[1] + Sizes[2] + Sizes[3]; // rozmiar struktury starego typu - rozmiar bloku pamięci byte[] bytes = new byte[BlockSize]; // tablica na blok pamięci odpowiadający strukturze starego typu using (FileStream Reader = new FileStream(aFileName, FileMode.Open, FileAccess.Read)) { while (Reader.Read(bytes, 0, BlockSize) > 0) Products.Add( new Product( BitConverter.ToInt32(bytes, 0), CurrentEncoding.GetString(bytes, Sizes[0], Sizes[1]).Trim(), CurrentEncoding.GetString(bytes, Sizes[0] + Sizes[1], Sizes[2]).Trim(), BitConverter.ToInt16(bytes, Sizes[0] + Sizes[1] + Sizes[2]) ) ); } return Products; } public void Presentation() { List<Product> Products = Load(@"d:\memory.bin"); foreach (Product item in Products) { Console.WriteLine(item.Identifier.ToString() + '|' + item.Symbol + '|' + item.Name + '|' + item.Quantity.ToString()); } } }
Jak widać, tym razem kodu jest o wiele więcej – taki niestety urok tego, że jest on zarządzany ;).
Dla chcących pobawić się powyższym przykładem udostępniam plik, który zawiera dokładnie takie dane, jak ujęte w powyższej tabeli. Oczywiście należy zmienić ścieżkę do niego w wywołaniu metody Load ;).
W następnym wpisie pokażę, jak zapisywać dane, aby wyglądały tak, jakby utworzyło je oprogramowanie niezarządzane (czyli spreparuję plik z obrazem pamięci tak, aby takie oprogramowanie potrafiło go odczytać).
Tym którzy zastanawiają się po co w ogóle robić takie, rzeczy śpieszę wyjaśnić, że wbrew pozorom wciąż używane jest mnóstwo oprogramowania, które zapisuje i odczytuje dane w takiej postaci. Podobne pliki eksportują także różnego rodzaju urządzenia pomiarowe. Zatem prędzej czy później (u mnie zdecydowanie prędzej) przyjdzie się zetknąć z tego typu formatem danych.