W tym poście postaram się opowiedzieć trochę o kolejności bitów w pamięci.
Zacznijmy od trzęsienia ziemi
int main()
{
long int x=0x4142434445464748;
}
$ gcc -g main.c -o main
$ gdb main
(gdb)$ break main
(gdb)$ layout src
(gdb)$ r
(gdb)$ s
Na systemie 64-bitowym, nasza zmienna ma 8 bajtów.
Dlatego ją wypiszmy:
(gdb)$ x/1gx &x
0x7fffffffdca8: 0x4142434445464748
ale 64 bity, to również 2x32 bit
(gdb)$ x/2wx &x
0x7fffffffdca8: 0x45464748 0x41424344
ale to także, 4x16 bit
(gdb)$ x/4hx &x
0x7fffffffdca8: 0x4748 0x4546 0x4344 0x4142
ale również 8x8 bit
(gdb)$ x/8bx &x
0x7fffffffdca8: 0x48 0x47 0x46 0x45 0x44 0x43 0x42 0x41
Co jest nie tak z kolejnością?
a teraz niech napięcie rośnie...
Aby to zrozumieć, należy wiedzieć, jak system zapisuje dane w pamięci.
Istnieją dwa sposoby przechowywania danych: big endiang oraz little endian.
Zapis big endian charakteryzuje się tym, ze kolejność zapisu bitów jest analogiczna do tego jak my zapisujemy, czyli najbardziej znaczący bit jest zapisywany jako pierwszy, a najmniej znaczący, jako ostatni. W konwencji little endian jest natomiast odwrotnie. Najmniej znaczący bit jest zapisywany jako pierwszy, a najbardziej znaczący jako ostatni.
Bit endian używany jest m.in w procesorach SPARC bądź PowerPC, jednak w większości powszechnie używanych procesorów króluje little endian. Dlatego dzisiaj się mu przyjrzymy bliżej.
Od początku? Od końca? Czy na przemian?
Jeśli spojrzymy na wyniki wypisania wartości z gdb, zauważymy, że wypisując całą liczbę na raz, otrzymamy ją w takiej formie w jakiej zapisaliśmy. Natomiast wypisując po bajcie, otrzymamy zapis od końca. Ale dlaczego czy wypisywaniu po słowie bądź pół słowie mamy przemieszane bajty?
Całość łatwo zrozumieć gdy zapiszemy liczbę bitowo.
Liczba 0x4142434445464748 zapisana bitowo, ma wartość
0100000101000010010000110100010001000101010001100100011101001000
ponieważ, w konwencji little-endian bit najważniejszy jest na końcu, zapiszmy tą wartość od tył:
0001001011100010011000101010001000100010110000100100001010000010
tak ta liczba będzie przechowywana w pamięci.
To dlaczego raz widzimy ją poprawnie, raz mieszanie a raz od tył?
Dla prostoty podzielmy sobie tą liczbę wizualnie na bajty
00010010 11100010 01100010 10100010 00100010 11000010 01000010 10000010
Gdy odczytujemy liczbę, jako jedna dużą 64 bitową wartość, komputer wie jak ją odczytać i dostajemy oczekiwaną wartość.
Natomiast, gdy odczytujemy 2x32 bity, komputer oczyta pierwsze 32 bity, zinterpretuje i wypisze, a następnie zrobi to samo z kolejnymi. Wygląda to mniej więcej tak:
(00010010 11100010 01100010 10100010) (00100010 11000010 01000010 10000010)
Każda z tych dwóch liczb jest interpretowana osobo, dlatego dla każdej z nich kompilator odwraca kolejność bitów:
(01000101 01000110 01000111 01001000) (01000001 01000010 01000011 01000100)
a następnie wyświetla podane liczby. W powyższym przypadku będzie to:
(0x45464748) (0x41424344)
czyli wynik jaki otrzymaliśmy w gdb.
Podobna sytuacja występuje, gdy chcemy odczytać 4x16 bit
(00010010 11100010) (01100010 10100010) (00100010 11000010) (01000010 10000010)
po odwróceniu:
(01000111 01001000) (01000101 01000110) (01000011 01000100) (01000001 01000010)
i w zapisie heksadecymalnym:
(0x4748) (0x4546) (0x4344) (0x4142)
i ostatni krok dla formalności - przy zapisie po jednym bajcie
(00010010) (11100010) (01100010) (10100010) (00100010) (11000010) (01000010) (10000010)
odwrócenie:
(01001000) (01000111) (01000110) (01000101) (01000100) (01000011) (01000010) (01000001)
i interpretacja:
(0x48) (0x47) (0x46) (0x45) (0x44) (0x43) (0x42) (0x41)
Przykład
W zadaniu col musieliśmy skonstruować liczbę całkowitą, mając jedynie możliwość wprowadzenia wejścia w postaci łańcucha znaków.
Oczekiwaną wartością była liczba 0x6c5cec8, a zapis był możliwy za pośrednictwem tablicy znaków, czyli po bajcie. Dlatego musimy wiedzieć, jak zostanie w pamięci zapisana szukana liczba.
0x6c5cec8 zostanie zapisana jako poniższy ciąg bajtów:
(0xc8) (0xce) (0xc5) (0x06)
dlatego, aby należało przekazać następującą sekwencję
$ echo -ne "\xc8\xce\xc5\x06"