Wyobraź sobie pamięć jako ciąg następujących po sobie bitów, np. pamięć o pojemności dokładnie 8GiB (GiB = Gibibajt = 1024 MiB = 1024 * 1024 KiB = 1024 * 1024 * 1024B) zawiera 8 [GiB] * 1024 [MiB] * 1024 [KiB] * 1024 [B] * 8 bitów. Pojedyncze bity, jako że mogą przechowywać tylko jeden z dwóch stanów - 0 lub 1 na niewiele się przydają komputerowi. Przy użyciu 8 bitów (czyli jednego bajta) możemy już zapisać więcej informacji bo pozwala to na zapisanie 256 różnych stanów - od 0 do 255 włącznie. To już może się przydać, więc pamięć zorganizowana jako bardzo długi ciąg bajtów obsługuje adresowanie przynajmniej bajtu - nie możemy wziąć adresu poszczególnego bitu. Adres jest po prostu jego numerem licząc od zera. Teraz kiedy tworzysz zmienną, np. typu int to system operacyjny (to uproszczona wersja) przydziela Ci jakiś adres i począwszy od tego zakresu Twoja zmienna ma do dyspozycji 4 bajty (zazwyczaj, bo rozmiar typu int zależy od platformy). Oto rozmiary, które najpopularniejsze typy zazwyczaj (w przeważającej większości przypadków) przyjmują:
- int - 4 bajty = 32 bity
- short - 2 bajty = 16 bitów
- float - 4 bajty = 32 bity
- double - 8 bajtów = 64 bity
- char - 1 bajt = 8 bitów (akurat rozmiar typu char jest zagwarantowany - nie ma opcji, żeby miał inny rozmiar niż 8 bitów)
- long - 4 bajty = 32 bity (wbrew pozorom long bardzo często ma taki sam rozmiar co int)
- long long - 8 bajtów = 64 bity
I teraz zależnie od tego jakiego typu stworzysz zmienną tyle system operacyjnych Ci przyzna kolejnych bajtów. I zależnie od platformy kolejność zapisu tych bajtów będzie różna. Załóżmy, że stworzymy zmienną typu int (4 bajty), której nadamy wartość 0x12AB34CD (zapis 16-stkowy bo jest łatwiej - 2 kolejne znaki odpowiadają jednemu bajtowi). Możemy teraz te dane zapisać w pamięci na dwa sposoby - załóżmy, że zaczynamy od adresu 0, kolejne adresy 1, 2, 3 itd to numery bajtów:
Liczba do zapisania: 0x12AB34CD
Numer bajtu:
0 | 1 | 2 | 3 |
-------------------------------------
Little endian:
CD | 34 | AB | 12 |
Big endian:
12 | AB | 34 | CD |
I teraz zależnie od platformy zostaną one zapisane w jednym z tych sposobów - kolejność bitów poszczególnych bajtów się nie zmienia, tylko kolejność całych bajtów.
Wracamy do sposobu zapisu danych w pamięci.
Bez względu na to, czy zapisujesz znak, czy tekst, czy liczbę, wszystko jest reprezentowane przez ciąg bitów.
Wbrew temu, co często się mówi char jest tak samo jak short czy int typem liczb całkowitych - jedynie ze względu na rozmiar dokładnie jednego bajta używa się go często do zapisania znaku z tablicy ASCII (tablicy, która odpowiednim liczbom całkowitym przyporządkowuje odpowiednie znaki graficzne). Każdy znak ASCII jest po prostu liczbą, tylko std::cout wyświetla je jako znak, nie jako liczbę. Przykład jak to wygląda po zamianie na int:
http://ideone.com/9ba97U <- link do kompilatora online z przykładem.
String (ciąg znaków) to po prostu tablica następujących po sobie znaków, czyli tablica następujących po sobie liczb, które też umieszcza się w pamięci.
Ze względu na to, by uniezależnić rozmiar int, float, long itp od platformy czy kompilatora (bo on też na to wpływa) stworzono specjalne typy danych jak np:
int8_t, int16_t, int32_t, int64_t
uint8_t, uint16_t, uint32_t, uint64_t
Dodatkowo, skracają one zapis. Żeby ich używać, wystarczy dodać:
#include <cstdint>
Zwykłe typy int8_t, int16_t itd... oznaczają, że jest to liczba całkowita ze znakiem (dodatnie i ujemne), która ma rozmiar dokładnie N bitów, przy czym N jest podane przy "int", czyli:
int8_t ma dokładnie 8 bitów
int16_t ma dokładnie 16 bitów itd itd...
Do tego dochodzą typy bez znaku (tylko liczby dodatnie i zero - czyli po prostu unsigned) - uint8_t, uint16_t, uint32_t itd...
Bardzo wygodne jest korzystanie z nich, bo jeśli chcesz zapisać do pliku dokładną ilość bajtów to możesz śmiało tego użyć.
Problem zapisu danych.
Teraz to o co konkretnie pytałeś - niektóre systemy zapisują w inny sposób dane i trzeba się z tym liczyć. Raz zapisując dane będziesz miał kolejność big endian, raz little endian. Jeśli chcesz sprawdzić jakiego sposobu używa Twój procesor najlepiej gdybyś stworzył dwubajtową liczbę (std::int16_t lub std::uint16_t). Następnie zapisz do niej na dwóch bajtach konkretną liczbę, np:
0x1234 <--- teraz pierwszy bajt przyjmie wartość 0x12 a drugi 0x34.
Ale tylko w systemie BigEndian, w systemie LittleEndian kolejność bajtów będzie odwrotna, więc teraz wystarczy to sprawdzić castując wskaźnik na tą liczbę na wskaźnik na char (bo jest jednego bajtu) i wyłuskać wartość.
Potem zależnie od tego, czy zmienił się system tego zapisu musisz zadecydować, czy zmieniać kolejność bajtów czy nie (o tym na końcu). Najważniejsze jest także to, żebyś zwracał uwagę na strukturę pliku, jeśli zapisujesz w ten sposób:
| Liczba - 4 bajty | Liczba - 2 bajty | Liczba 2 bajty | Znak - 1 bajt | Liczba - 4 bajty |
To musisz w ten sposób też te dane wczytać, jeśli wczytasz 4 bajty tam, gdzie zapisywałeś 2 to zepsujesz sobie te dane.
Zapis i odczyt danych z pliku
Standardowo pliki otwierane przy użyciu std::fstream są otwierane w trybie ASCII, nie binarnym. Oznacza to, że jeśli zapiszesz fizycznie w notatniku do pliku dane:
123456
I spróbujesz wczytać je tak z pliku:
std::ifstream file("plik.txt");
int liczba = 0;
file >> liczba;
To liczba przechowa wartość "123456". Tego nie chcemy - dlaczego? Bo tak naprawdę ta liczba jest zapisana jako następujące po sobie znaki ASCII '1', '2', '3', '4', '5', '6' i zajmuje teraz 6 bajtów. Jeśli zapiszesz w trybie ASCII liczbę, która ma 9 cyfr to zajmie ona 9 bajtów! To pozwala się uchronić przed konsekwencją zmiany systemu Endianness, ale za to dostajesz o wiele większe rozmiary plików i jest to nieprofesjonalne. Nie chcemy tego. Żeby poprawnie zapisać dane binarne w pliku otworzymy go w trybie binarnym:
std::ofstream plik("plik.bin", std::ios::binary);
std::uint32_t liczba = 123456; // upewniamy sie, ze zajmie ona dokladnie 4 bajty
plik.write(reinterpret_cast<char*>(&liczba), sizeof(liczba));
Na pierwszy rzut oka wygląda to dosyć skomplikowanie ale jest to proste.
Metoda plik.write, zapisuje pojedyncze ciąg pojedynczych znaków, bo tak jest najłatwiej - każdy znak zajmuje dokładnie 8 bitów. Pierwszy argument to const char*, który oznacza tablicę następujących po sobie bajtów, których ilość z kolei określa drugi parametr.
Bierzemy adres tej liczby i konwertujemy go reinterpret_cast-em tak, żeby pasował do przyjmowanego typu, teraz ta liczba będzie brana po prostu jako ciąg czterech bajtów w pamięci. W drugim argumencie używamy operatora sizeof(liczba), który wskaże ile bajtów ma liczba (chociaż w tym przypadku śmiało możemy sami wstawić tam po prostu czwórkę, bo uint32_t gwarantuje nam taki rozmiar).
Jeśli teraz chcemy poprawnie wczytać te dane to użyjemy:
std::ifstream plik("plik.bin", std::ios::binary);
std::uint32_t liczba;
plik.read(reinterpret_cast<char*>(&liczba), sizeof(liczba));
Czyli robimy to w ten sam sposób, tylko metodą read. Po wczytaniu uzyskamy taką samą liczbę jak zapisaliśmy.
Chyba że...
Zamiana systemów Little Endian i Big Endian.
Jest to banalnie proste. Przytoczę tutaj kod z stackoverflowa, który dobrze do tego podchodzi. Zakładam, że nie chcesz się w to sam bawić bo kto chciałby od nowa wynaleźć koło:
#include <climits>
template <typename T>
T swap_endian(T u)
{
static_assert (CHAR_BIT == 8, "CHAR_BIT != 8");
union
{
T u;
unsigned char u8[sizeof(T)];
} source, dest;
source.u = u;
for (size_t k = 0; k < sizeof(T); k++)
dest.u8[k] = source.u8[sizeof(T) - k - 1];
return dest.u;
}
Jeśli chcesz zmienić system z Big Endian na Little Endian lub na odwrót to tu masz przykład:
uint32_t little = 123;
uint32_t big = swap_endian<uint32_t>(little);
Ufff... dużo tego jest, mam nadzieję, że pomogłem.
Tutaj źródła:
Koniec :)