Binary system - fundamentals

6 april 2023
Jakub Rojek Jakub Rojek
Photo by cottonbro studio from Pexels (https://www.pexels.com/pl-pl/zdjecie/kobieta-powazny-kodowanie-programista-5473956/)
Categories: IT fundamentals, Guides

Z pewnością zauważyliście, że informatycy są jacyś dziwni - rozmawiają na jakieś niepokojące tematy (znam przypadek oburzenia współpasażera w tramwaju, gdy studenci zaczęli rozmawiać o Javie), używają technicznego żargonu i nawet inaczej liczą. Prosisz takiego o 100 zł, a ten zaokrągla to do 128 zł...

Tak zupełnie poważnie, to dzisiaj skupimy się właśnie na systemie liczbowym, jaki jest używany w informatyce, a więc systemie binarnym. Większość osób wie o tym, że w dziedzinach związanych z obliczeniami komputerowymi używa się zer oraz jedynek i kolejnych potęg liczby 2. Natomiast nie dla wszystkich jest jasne, dlaczego tak właściwie jest oraz jakie są tego konsekwencje. A tym bardziej w jaki sposób przełączać się pomiędzy naturalnym dla człowieka systemem dziesiętnym a dwójkowym. Nie jest to wcale aż takie trudne i zrozumienie tego pozwala pojąć nie tylko pewne działania, ale nawet żarty w rodzaju "na świecie istnieje 10 rodzajów ludzi...". W dodatku po dowiedzeniu się, o co chodzi z tymi dwójkami, jest prosta droga do poznania jeszcze jednej formy zapisywania liczb - systemu szesnastkowego (zwanego też heksadecymalnym, od czego pochodzi określenie "heks").

Dzisiaj wytłumaczymy sobie, jak poruszać się w potęgach dwójkowych. Postaram się to zrobić "na chłopski rozum", a więc tak, jak w praktyce to sam czynię i rozumiem. W końcu ważniejsze jest to, aby posiąść pewne umiejętności i wiedzę na dany temat, aniżeli umieć ładnie wygłosić definicję przeczytaną w książkach.

Czym jest system dwójkowy?

Jak możecie się domyślić po nazwie, ten sposób liczenia opiera się wyłącznie na dwóch liczbach - jest to 0 oraz 1. Lub "fałsz" oraz "prawda", ponieważ dwustanowa wartość jest silnie związana z tym, czy dany bit jest włączony, czy nie. Ale moment - jakie bity i bajty?

Z pewnością wiecie o tym, że komputery przetwarzają informacje w postaci bitów, a więc pojedycznych, niepodzielnych porcji danych, które mogą być włączone lub wyłączone. Można to sobie wyobrazić jako zapaloną lub zgaszoną żarówkę - w zależności od jej stanu program może inaczej reagować, zinterpretować wartość jako inną liczbę lub wykonać inną instrukcję procesora. Oczywiście, jeden bit to bardzo mało informacji możliwych do zapisania, dlatego stosuje się zbiory bitów. I tak osiem bitów składa się na jeden bajt, który w praktyce jest najmniejszą jednostką informacji, jaką posługuje sie system informacyjny. Jeden bajt może zatem posiadać nawet osiem "zapalonych żarówek", co jednak nie oznacza wartości 8 - to by było za proste. Jeden bajt może przechować wartość maksymalnie równą 255. Dlaczego tak, a nie inaczej - to dzisiaj wyjaśnimy.

Oczywiście, jeden bajt to nadal bardzo mało informacji. Dlatego komputery przetwarzają ich ogromną liczbę i łączy je w jednostki z odpowiednimi prefiksami - "kilo", "mega", "giga" itd. Stąd mamy kilobajty, megabajty, gigabajty, terabajty itd. Dotyczy to zarówno pamięci RAM, jak i dysku twardego czy danych przesyłanych siecią.

Jak więc widać, ten system jest mniej intuicyjny z punktu widzenia człowieka, ale dużo lepszy z punktu widzenia elektroniki, która opiera się na tym, czy jest ładunek, czy nie lub czy istnieje przepływ prądu w danym momencie. Oczywiście, od strony technologicznej jest to dużo bardziej skomplikowane i absolutnie nie zamierzam wchodzić w takie szczegóły. Zresztą - przyznam Wam szczerze - programiści bardzo rzadko "schodzą" na tak niski, elektroniczny poziom, a nawet nie rozumieją do końca, jak to wszystko działa na poziomie tranzystorów (oczywiście, pomijając osoby, które pracują "niskopoziomowe", bo dla nich to chleb powszechni). Natomiast poziom obliczeń w systemie dwójkowym to zdolność, którą większość informatyków powinna posiąść, nawet jeśli nie użyje jej każdego dnia.

Jak czytać liczbę zapisaną dwójkowo?

Liczba w systemie binarnym zapisana jest jako ciąg bitów, które należy czytać od lewej do prawej. Mówimy, że po lewej znajduje się najbardziej znaczący bit, czyli taki o największej wadze. Zresztą podobnie jest w systemie dziesiętnym - jeśli mamy 67, to wiemy, że to "6" ma większą wagę, gdyż oznacza liczbę dziesiątek, a "7" to "tylko" jedności. Zresztą, widać to w momencie, kiedy pokażemy, w jaki sposób oblicza się te wartości (co zwykle robimy automatycznie w głowie):

67(10) → 6 * 10 + 7 * 1 = 67

Możemy to też inaczej zapisać, używając potęg liczby 10:

67(10) → 6 * 101 + 7 * 100 = 67

Wyraźnie widzimy, że to, co nazywamy jednościami, dziesiątkami, setkami itd., to matematycznie kolejne potęgi pewnej liczby, poczynając od 0 (a jak wiadomo, 100 to 1). W tym przypadku jest to 10 i z tego powodu mówimy o systemie dziesiętnym. Jeśli zamiast 10 podstawimy np. 8, to mamy system ósemkowy. Jeśli 3 - system trójkowy. A jeśli 2, to wychodzi nam właśnie system binarny. Zapiszmy 67 w taki sposób i zobaczmy, jak przekłada się to na "dziesiątki".

1000011(2) → 1 * 26 + 0 * 25 + 0 * 24 + 0 * 23 + 0 * 22 + 1 * 21 + 1 * 20 = 67(10)

Widzimy, co sie stało - po prostu inaczej rozpisaliśmy tę samą liczbę zapisaną dwójkowo. Nie użyliśmy kolejnych potęg liczby 10 (a więc 1, 10, 100, 1000 itd.), tylko liczby 2 co daje ciąg 1, 2, 4, 8, 16, 32... Oczywiście, przez to zapis samej wartości jest dłuższy i w tym przypadku musimy użyć aż 7 znaków, a nie tylko 2. Jednak komputer dzięki temu będzie potrafił przeczytać liczbę 67 zapisaną w taki sposób i co więcej - mieści się ona w jednym bajcie (przypomnijmy, że maksymalnie mozna zapisać w nim 8 bitów, a tutaj mamy 7).

A jakby to wyglądało np. w systemie ósemkowym?

103(8) → 1 * 82 + 0 * 81 + 3 * 80 = 67(10)

Widzimy, że tutaj sytuacja jest już trochę bardziej skomplikowana, ponieważ na jednym miejscu może być nie tylko 0 lub 1, ale nawet od 0 do 7. Wprawne oko zauważy dlaczego - jeśli system jest ósemkowy, to oznacza, że jeden znak może stać się jedną z ośmiu cyfr. W tym przypadku od 0 do 7. W dziesiątkowym może być to jedna z zakresu od 0 do 9. Dlatego też system binarny jest taki wygodny w obliczeniach, gdyż możliwości są dwie - albo 0, albo 1.

Jaką wartość może maksymalnie pomieścić jeden bajt?

Wróćmy do kwestii, którą poruszyliśmy już wcześniej - jeden bajt może maksymalnie pomieścić wartość 255. W tym momencie niektórych może zastanowić, że przecież nie jest to potęga dwójki. Ba - nie jest to nawet liczba parzysta. Z kolei 256 jest, więc czemu to ona nie jest "maksem"? Odpowiedź tkwi w innej naczelnej zasadzie informatyki - liczymy nie od 1, tylko od 0 (słownie: od zera).

Ponieważ jeden bajt składa się z ośmiu bitów, a maksymalną wartość uzyskamy, gdy wszędzie zapisane zostaną jedynki (poza przypadkiem liczb ujemnych, ale o tym znacznie później), to rozłóżmy sobie taką liczbę na czynniki pierwsze:

11111111(2) → 1 * 27 + 1 * 26 + 1 * 25 + 1 * 24 + 1 * 23 + 1 * 22 + 1 * 21 + 1 * 20 = 128 + 64 + 32 + 16 + 8 + 4 + 2 + 1 = 255(10)

A zatem maksymalną wartością jest 255, ponieważ zapisanie cyfry 1 na wszystkich bitach da nam właśnie taką sumę - nie można dopisać nic więcej do jednego bajtu. Jeśli chcielibyśmy uzyskać wartość 256, to musielibyśmy poświęcić już dziewiąty bit na jedynkę (resztę po prawej dopełniając zerami), a więc w konsekwencji musimy "uruchomić" drugi bajt. Z kolei na jednym bajcie śmiało możemy zapisać zero - wystarczy wpisać tę cyfrę we wszystkich dostępnych bitach.

Mamy sytuację, w której w jednym bajcie możemy wpisać 256 różnych wartości (czyli od 0 do 255), ale maksymalna liczba jest przez to o 1 niższa. Matematycznie możemy uogólnić to tak, że na n bitach możemy zapisać maksymalną wartość wynoszącą 2n - 1. Np. dla trzech bitów maksymalna wartość to 23 - 1 = 7 i to się zgadza - zapiszcie trzy jedynki i policzcie wartość.

Przy okazji - ponieważ łatwo pomylić liczby zapisane w różnych systemach liczbowych, widzicie, że przy poszczególnych wartościach dopisuję oznaczenie (n), gdzie n jest podstawą danego systemu liczbowego, w którym zapisana jest wartość. Ułatwia to orientację przy konwersjach.

Jak generować kolejne liczby binarne?

Zanim przejdziemy dalej, warto wspomnieć, że dosyć często istnieje potrzeba wypisania kolejnych liczb binarnych. W przypadku systemu dziesiętnego jest to proste, bo dla nas naturalne - 1, 2, 3, 4, 5 itd. A jak to wygląda w "dwójkach"?

  1. Ustalamy, jaki zakres nas interesuje (czyli ile bitów chcemy zapisać).
  2. Zaczynamy od zera (czyli wszystkie bity są ustawione na zero).
  3. Na ostatnim bicie (najmniej znaczącym, znajdującym się najbardziej po prawej) ustawiamy na przemian 0 i 1 (czyli zmiana następuje co liczbę).
  4. Na drugim bicie od końca ustawiamy na zmianę 0 i 1, ale co drugą liczbę.
  5. Na trzecim bicie od końca ustawiamy na zmianę 0 i 1 co czwartą liczbę (czyli widzimy, że częstotliwość zmian też wynika z potęgi dwójki).
  6. I tak dalej, aż do najbardziej znaczącego bitu.

Może brzmieć to skomplikowanie, ale wszystko się wyjaśni, gdy zobaczycie przykład dla trzech bitów:

000 - zaczynamy od zera
001 - na ostatnim bicie było 0, więc zmieniamy na 1
010 - na ostatnim bicie znowu przełączamy cyfrę, natomiast to trzecia wartość z kolei, więc zmienia sie też na drugim od końca
011
100
101
110
111

Jak przeliczać liczby pomiędzy systemem dziesiętnym a binarnym?

Wyżej pokazaliśmy, w jaki sposób przechodzić z liczby zapisanej w systemie binarnym do takiej w systemie "ludzkim", a więc dziesiątkowym. Widzimy zatem, że w żartach o "10 rodzajach ludzi" to "10" należy czytać w systemie dwójkowym, a więc 1 * 21 + 0 * 20 = 2. A jak konwertować liczby w drugą stronę, tj. na system binarny?

Kluczowe w tym jest zapamiętanie (lub zapisanie na marginesie) kolejnych potęg liczby 2. Mamy zatem po kolei:

20 = 1
21 = 2
22 = 4
23 = 8
24 = 16
25 = 32
26 = 64
27 = 128
28 = 256

Nałatwiej pokazać ten sposób na przykładzie - weźmy liczbę 198(10). Pierwsze, co musimy zrobić, to znaleźć najwyższą potęgę dwójki, która jest mniejsza lub równa naszej wartości. Sprawdzamy po kolei i dochodzimy do wniosku, że taką granicą jest 128 - jest to wartość mniejsza od 198. Jednocześnie następna potęga, a więc 256, jest już większa. A zatem bierzemy 128 i widzimy, że ponieważ jest to siódma potęga, to 198 zapisane w systemie dwójkowym będzie miało 8 bitów (pamiętajmy o potędze zerowej). Zapiszmy sobie:

1_______
198 - 128 = 70

Odejmujemy też przy tym wartość danego bitu od naszej liczby - 198 odjąć 128 to 70. Powtarzamy teraz to sprawdzenie dla liczby 70 dla kolejnych bitów. Widzimy, że na szóstym bicie mamy 64, co jest mniejsze od 70. A zatem ten bit również dostanie jedynkę - uzupełniamy nasze rozwinięcie:

11______
70 - 64 = 6

Z szóstką już nie jest tak prosto - kolejna potęga to 5, ale 25 to 32, a więc wartość większa niż 6. A zatem na tym bicie będziemy mieli 0:

110_____

Podobnie sytuacja będzie wyglądała dla czwartego i trzeciego bitu, czyli odpowiednio wartości 16 oraz 8:

11000___

Drugi bit ma wartość 4, a to jak najbardziej mniejsza wartość niż nasze 6. A zatem tutaj pojawi się jedynka. Podobnie sytuacja wygląda dla kolejnego bitu, czyli pierwszego (a więc... drugiego od prawej), gdyż 2 ≤ 6 - 4.

110001__
6 - 4 = 2

1100011_
2 - 2 = 0

Mamy liczbę 0, a więc wiemy, że na ostatnim bicie będzie również zero. Wyszła nam zatem liczba 11000110(2). Jeśli przeliczymy ją sobie na system dziesiętny, to wyjdzie nam początkowe 198(10). Upewnijmy się.

1 * 27 + 1 * 26 + 0 * 25 + 0 * 24 + 0 * 23 + 1 * 22 + 1 * 21 + 0 * 20 = 128 + 64 + 0 + 0 + 0 + 4 + 2 + 0 = 198(10)

Algorytm może wydawać się na początku trochę skomplikowany, ale jeśli spokojnie przeanalizujecie cały przykład i rozpiszecie go sobie na kartce, to szybko pojmiecie, o co chodzi. W skrócie - określamy, który bit będzie tym najbardziej znaczącym, a następnie poruszamy się o bit po bicie w prawo, sprawdzając, czy jego wartość jest mniejsza lub równa aktualnej liczbie, która wychodzi nam z odejmowania. Jeśli tak, to na tym bicie pojawia się 1, a jeśli nie, to 0.

Oczywiście, jeśli do skonwertowania mamy jakąś potęgę liczby 2, np. 32, to widzimy od razu, że w systemie binarnym ta liczba będzie miała ustawiony tylko jeden bit, a reszta z prawej strony zostanie uzupełniona zerami - w tym wypadku będzie to 100000(2).

System heksadecymalny

Wspomniałem o tym, że bardzo blisko systemu dwójkowego jest system szesnastkowy, czyli heksadecymalny. W tym przypadku zamiast liczby 2 podstawą jest liczba 16, co - jak łatwo zauważyć - jest jedną z potęg dwójki. Można zatem podejrzewać, że te sposoby zapisywania wartości numerycznych są ze sobą powiązane i tak jest w istocie.

Szesnaście różnych cyfr oznacza, że do standardowego zakresu 0-9 dochodzą pierwsze litery alfabetu - A(16) to 10(10), B(16) to 11(10), C(16) to 12(10), D(16) to 13(10), E(16) to 14(10), a F(16) to 15(10).

A jak to się ma do systemu dwójkowego? Skorzystajmy z poprzednio opisanej metody do wymienienia wszystkich kombinacji i przekonwertujmy to na liczby heksadecymalne:

0000(2) = 0(16)
0001(2) = 1(16)
0010(2) = 2(16)
0011(2) = 3(16)
0100(2) = 4(16)
0101(2) = 5(16)
0110(2) = 6(16)
0111(2) = 7(16)
1000(2) = 8(16)
1001(2) = 9(16)
1010(2) = A(16)
1011(2) = B(16)
1100(2) = C(16)
1101(2) = D(16)
1110(2) = E(16)
1111(2) = F(16)

Widziemy zatem, że możemy "skracać" cztery bity do jednego znaku, ale to nie koniec atrakcji. Z uwagi na to, że system szesnastkowy jest rozwinięciem systemu binarnego, działa to też na większej liczbie bitów, które wówczas zamienia się czwórkami. Ponownie lepiej będzie na przykładzie.

Weźmy teraz nieco większą liczbę, np. 5643(10), którą binarnie zapisalibyśmy w postaci 16 bitów, a więc dwóch bajtów (dla jasności dodałem dopełniające zera na początku):

0001011000001011

Podzielmy to teraz na grupy po cztery bity:

0001 0110 0000 1011

A teraz po prostu zamieńmy każde cztery bity na odpowiednią liczbę heksadecymalną:

0001(2) = 1(16)
0110(2) = 6(16)
0000(2) = 0(16)
1011(2) = B(16)

Razem: 160B

I już - 160B(16) jest odpowiednikiem 5643(10) w systemie szesnastkowym. Możemy to jeszcze obliczyć, aby upewnić się, że wszystko się zgadza:

1 * 163 + 6 * 162 + 0 * 161 + 11 * 160 = 4096 + 1536 + 11 = 5643

Z uwagi na to, że ta forma jest dużo poręczniejsza, jest wykorzystywana przez edytory pozwalające podejrzeć wnętrze plików binarnych. Poniżej przykład dla pliku wykonywalnego gry VALORANT:

Fragment wnętrza pliku valorant.exe w postaci heksadecymalnej

Przy okazji widzimy, że niektóre znaki zostały zdekodowane w postaci normalnego tekstu. Nie jest to przypadek - pliki tekstowe składają się ze znaków, ale każdemu przypisana jest odpowiednia wartość bajtu (czyli wartość od 0x00 do 0xFF). W najprostszym przypadku na jeden znak przypada jeden bajt i tak jest w kodowaniu ASCII. W bardziej nowoczesnych zastosowaniach, gdzie należy uwzględnić także np. polskie litery, potrzebnych jest więcej bajtów i kodowanie takie jak np. UTF-8.

Mogliście zauważyć jeszcze dziwny zapis, który zastosowałem - 0xFF. To "0x" na początku oznacza, że jest to liczba heksadecymalna i to format uznawany ogólnie przez języki programowania. Analogicznie dla systemu ósemkowego (również wykorzystywanego w informatyce, np. przy prawach dostępu, na początku dodajemy "0", czyli mamy np. 0713. Z kolei przedrostek "0b" często oznacza liczbę binarną (np. 0b101).

Typy zmiennych

Analizując to, co do tej pory napisaliśmy, możecie się pewnie zorientować, skąd zróżnicowanie typów zmiennych np. w C++ - programista powinien zadeklarować liczbę bajtów, którą chce zarezerwować na daną zmienną. Im większą liczbę chce przechowywać, tym oczywiście więcej bajtów zajmie dana zmienna. W ten sposób mamy klasyczne:

  • char - 1 bajt
  • short - 2 bajty
  • int (integer) - 4 bajty (32 bity)
  • long - 8 bajtów (64 bity)

Oczywiście, nie w każdym języku programowania dostępne są wszystkie typy i nie we wszystkich liczba bajtów jest taka, jak przedstawiłem powyżej. Ba - w nowszych paradygmatach oprogramowania programista przy wybieraniu typu zmiennej często zdaje się na kompilator lub interpreter i nie ma potrzeby zamartwiania się bajtami. Tym niemniej, warto wiedzieć, że istnieją pewne ograniczenia.

Ktoś może powiedzieć, że w takim razie w typie short na 2 bajtach maksymalnie możemy zapisać wartość dziesiętną 65 535, a w typie integer aż 4 294 967 295. I jak najbardziej jest to prawda, o ile uwzględnimy taki drobny fakt, że są to wersje beznakowe (ang. unsigned) tych typów. Co to oznacza? Otóż, zapomnieliśmy jeszcze o tym, że w matematyce istnieją liczby ujemne. Powyższe typy również to muszą uwzględniać i w ten sposób choćby C++ wprowadza dodatkowe modyfikatory - na przykładzie short będą to:

  • unsigned short - 2 bajty, zakres od 0 do 65 535 (65 536 możliwe wartości do zapisania)
  • signed short - 2 bajty, zakres od -32 768 do 32 767 (nadal 65 536 możliwe wartości do zapisania)

Czyli można domniemywać, że mimo iż szerokość zakresu zostaje bez zmian, to jeden bit musimy poświęcić na określenie, czy jest to wartość dodatnia, czy ujemna. To, w jaki sposób to się dzieje, omówimy za chwilę.

Operacje na liczbach binarnych

Oczywiście, na liczbach binarnych można wykonywać operacje matematyczne. Najczęściej stosowanych jest pięć:

  • suma bitowa
  • iloczyn bitowy
  • alternatywa rozłączna (XOR)
  • przesunięcie bitowe w lewo
  • przesunięcie bitowe w prawo

Całkiem dobrze graficznie operacje na bitach zostały omówione w tym artykule. Spróbujmy jednak omówić je sobie krótko tutaj.

Aby zrozumieć pierwsze dwa, należy uświadomić (lub przypomnieć) sobie pojęcia koniunkcji (AND) oraz alternatywy (OR), czyli dwóch operacji logicznych, które tutaj są bardzo ważne. W skrócie - koniunkcja wynosi 1 tylko i wyłącznie wtedy, kiedy oba czynniki są równe 1 (w reszcie przypadków rezultatem jest 0). Z kolei alternatywa wynosi 0 tylko i wyłącznie wtedy, kiedy oba czynniki są równe 0 (inne przypadki oznaczają 1).

Zobaczmy zatem, jak wygląda suma bitowa liczb 1011(2) oraz 1001(2). W tym celu najpierw musimy zadbać o wyrównanie długości liczb poprzez dodanie zer na początku (tutaj akurat nie ma takiej potrzeby), a następnie sprawdzamy, na których pozycjach choć raz wystąpiła jedynka - mamy tutaj zatem alternatywę dla odpowiadających sobie bitów. W ten sposób wynikiem będzie 1011(2) (jedynie na drugim najbardziej znaczącym bicie nie możemy znaleźć wspólnej jedynki).

Podobnie, choć odwrotnie przedstawia się sprawa z iloczynem bitowym, gdzie wykonujemy koniunkcję na każdej pozycji. Iloczyn bowiem oznacza, że szukamy wspólnych jedynek w poszczególnych bitach. W tym przypadku mamy takie dwa miejsca - pierwsze i ostatnie miejsce w obu liczbach zajmuje jedynka. A zatem wynikiem działania będzie 1001(2).

Odwrotnie przedstawia się popularny XOR, który szuka odpowiadających sobie bitów o różnych wartościach. W tym przypadku mamy 0010(2), czyli w skrócie 10(2), gdyż tylko na drugiej najmniej znaczącej pozycji mamy różnicę bitów.

Z kolei przesunięcia bitowe można rozumieć dosłownie - przesuwamy bity o jeden w lewo lub w prawo i w razie potrzeby dopełniamy od prawej zerami. Działa to podobnie do przesuwania przecinka przy mnożeniu lub dzieleniu przez dziesięć w systemie dziesiętnym. Tutaj, oczywiście, tym mnożnikiem staje się 2. I tak przesunięcie bitowe w lewo liczby 1011(2) (a więc 11(10)) daje nam 10110(2)) (22(10) - dwukrotność 11), a w prawo - 0101(2) (5(10)). W tym drugim przypadku mamy ciekawą sytuację, gdyż 11 podzielone 2 powinno nam dać 5,5, a nie 5 - gdzie zatem uciekła połówka? Otóż, w standardowy sposób system binarny nie przechowuje ułamków, wszystko zaokrąglając w dół. Co nie znaczy, że nie istnieje "niestandardowy" sposób.

Liczby ujemne i zmiennopozycyjne (ułamkowe)

Wspomnieliśmy już o tym, że system dwójkowy umożliwia przechowywanie liczb ujemnych i czas wyjaśnić, w jaki sposób to się dzieje. Aczkolwiek nie ukrywam, że jest to trochę bardziej skomplikowane niż myślicie.

Istnieją aż trzy sposoby zapisu liczb mniejszych od zera i wszystkie opierają się na założeniu, że najbardziej znaczący bit jest poświęcany na oznaczenie znaku. Mówiąc wprost, jeśli pierwszy bit jest ustawiony na 0, to mamy do czynienia z liczbą dodatnią (nie ma znaku). Jeśli jest to 1 - mamy liczbę ujemną (jest znak). Pozostałe bity opisują już normalnie liczbę, przy czym z uwagi na to, że mamy o jeden bit mniej do dyspozycji, możemy zapisać mniejsze wartości.

Wspomniałem o trzech sposobach zapisu liczby. Najprostszy z nich to znak-moduł, który "działa" identycznie jak standardowy zapis za wyjątkiem przeznaczenia tego pierwszego bitu na znak. Niestety, o ile jest prosty z perspektywy człowieka, o tyle z punktu widzenia komputera potrzebne było coś bardziej wydajnego (ze względu na czas trwania operacji), w związku z czym wymyślono kod uzupełnień do 1, który opiera się na zamianie bitów na przeciwne. Jednak i to nie zadowoliło inżynierów, więc najpopularniejszą metodą zapisu stał się kod uzupełnień do 2, który pokażemy sobie tutaj na przykładzie. Pominę przy tym opis tego, dlaczego jest to najwydajniejszy sposób i jakie są jego matematyczne zawiłości, zwłaszcza, że w praktyce programiści systemów webowych mają z tym wyjątkowo mało do czynienia. Zamiast tego skupimy się na tym, jak w praktyce wygląda zamiana liczby.

A jeśli pominąć aparat matematyczny, to wygląda... dość prosto. Weźmy jako przykład (-45)(10) i zamieńmy ją na postać binarną. Jak to najłatwiej zrobić?

  1. Zamieniamy dodatnią "wersję" tej liczby na system binarny - a zatem 45(10) to będzie 00101101(2).
  2. Najbardziej znaczący bit najbardziej znaczącego bajtu zamieniamy na 1 - powstaje 10101101(2). Przy okazji, widzimy, że daje nam to 173(10), a jeśli odejmiemy od tego 45(10), to wychodzi 128(10), czyli wartość najwyższego bitu. Nie jest to przypadek.
  3. Teraz każdy pozostały bit zamieniamy na przeciwny. A zatem z 10101101(2) robi nam się 11010010(2).
  4. Na końcu zwiększamy liczbę o 1 - powstaje 11010011(2).

I voilà - to liczba -45 zapisana w postaci binarnej. Jeśli nie wierzymy, to obliczmy to w drugą stronę i przełóżmy na system dziesiętny znanym nam sposobem. Jest tylko jedna rzecz, o której trzeba pamiętać - jedynka na przodzie oznacza liczbę ujemną. A zatem musimy dla niej policzyć wagę nie 27, tylko (-2)7.

11010011(2) = 1 * (-2)7 + 1 * 26 + 0 * 25 + 1 * 24 + 0 * 23 + 0 * 22 + 1 * 21 + 1 * 20 = -128 + 64 + 16 + 2 + 1 = -128 + 83 = -45(10)

Przy tej okazji można wyjaśnić, dlaczego zmienna mieszcząca się w jednym bajcie ze znakiem mieści wartości od -128 do 127, a więc zakres ujemny wydaje się większy. "Wydaje się" jest tutaj słowem kluczowym - pamiętajmy, że są to zakresy od -128 do -1 oraz od 0 do 127, a więc szerokość obu przedziałów jest identyczna. Natomiast w systemie binarnym wygląda to tak:

(-128)(10) = 10000000(2)
(-127)(10) = 10000001(2)
(-1)(10) = 11111111(2)
0(10) = 00000000(2)
127(10) = 01111111(2)

Logika dość pokrętna, ale jeśli obliczycie to sobie na spokojnie, to zobaczycie, że tak jest w istocie - najniższe wartości ujemne mają prawie same zera w systemie binarnym. Jeśli nie zadowala Was praktyka i chcecie poczytać o matematycznych podstawach tych zamian, to w sieci znajdziecie np. takie prezentacje, które to tłumaczą.

Natomiast w jakiś sposób musimy też zapisywać liczby zmiennopozycyjne (czyli zmiennoprzecinkowe, ułamkowe) w postaci binarnej i tutaj przyznaję - jest to mocno skomplikowane. Zapomnijcie o zapisie, w którym wartości przed oraz po przecinku są przechowywane w osobnych bajtach - przywitajcie się za to z pojęciami cechy, mantysy oraz znanej ze szkoły notacji naukowej lub wykładniczej, a także standardem IEEE 754, bo to on wskazuje, w jaki sposób powinny następować obliczenia. Pozwolicie, że pominę tutaj tłumaczenie, w jaki sposób dokładnie przebiega rozwijanie liczby w obie strony i zamiast tego polecę artykuł w serwisie Samouczek Programisty, w którym zostało to opisane w sposób praktyczny. A także wytłumaczone, dlaczego czasem liczba 0,2 to tak naprawdę 0,20000000298023224.

Czy tego się używa w praktyce?

Rzeczywiście, jest to słuszne pytanie. Wcześniej wspomniałem o tym, że informatycy powinni znać podstawy systemu binarnego, ale czy obecnie, przy takich technologiach programistycznych, które posiadamy, nie jest to trochę sztuka dla sztuki i bardziej niechciana wiedza obowiązkowa aniżeli rzeczywiście przydatna umiejętność? Nie będę ukrywał - przy tworzeniu aplikacji webowych przez 99% czasu nie będziecie mieli kontaktu z liczbami binarnymi. Podobnie rzecz się ma z większością aplikacji mobilnych, a nawet desktopowych. Tym niemniej, są sytuacje, w których wiedza o tym sposobie zapisu bardzo się przydaje.

Pierwszą z nich są oczywiście systemy wbudowane i inne programy pisane na tzw. niskim poziomie. Przykładowo, wierzę, że osoby mające na co dzień kontakt z kodem napisanym w języku C, mają też od czasu do czynienia z operacjami na bitach, gdyż często stanowią czynnik optymalizacyjny. Tak samo osoby kodujące programy (nawet w Pythonie) na Raspberry Pi muszą liczyć się z koniecznością sięgnięcia po tę wiedzę. Nie mówiąc już o programowaniu mikrokontrolerów i innych systemów związanych ściśle ze sprzętem.

Zdarza się też, że konieczne jest parsowanie pewnych plików, w tym także binarnych. W tym momencie pojawia się konieczność liczenia bajtów, interpretowania poszczególnych wartości czy konwertowania ich na system dziesiętny lub na znaki tekstowe. Może się też okazać, że wymagana będzie wiedza o kodowaniu gdyż np. najbardziej znaczący bajt (nie bit) znajdzie się niekiedy nie po lewej, ale prawej stronie. W zależności od osoby, są to zadania bardzo przyjemne lub wręcz niesamowicie drażniące, ale się zdarzają i wówczas wiedza o podstawach informatyki bardzo się wówczas przydaje.

Wbrew pozorom, także w aplikacjach webowych zdarzają się operacje na bitach, np. jeśli mówimy o sposobie zapisu pewnych flag sterujących wykonaniem danej operacji (w PHP znajdziecie kilka takich funkcji, jak choćby json_decode). Ale w niektórych projektach zdarzało nam się w formie bitów zapisywać i przesyłać informacje o uprawnieniach. Oczywiście, moglibyśmy to robić w czytelniejszy sposób, jednak czasem zależy także na wydajności oraz pewnym ukryciu tej informacji przed użytkownikiem. Wszystko należy rozpatrywać w kontekście konkretnego przypadku.

Wreszcie, jeśli kiedykolwiek dzieliliście sieć na podsieci, to być może również zetknęliście się w systemem binarnym, zwłaszcza przy masce podsieci niebędącej wielokrotnością ósemki (np. 16 czy 24). Wówczas określanie zakresów poszczególnych adresów wiąże się właśnie z rozpisaniem adresu na system dwójkowy. Zresztą - czy zastanawiało Was kiedyś, dlaczego adres IPv4 ma wartości od 0.0.0.0 do 255.255.255.255, a nie np. 999.999.999.999? Po tym artykule możecie się domyślić, że każdy składnik tego czteroczęściowego numeru to osobny bajt, a w każdym maksymalnie możemy zapisać 255. Nie bez powodu również adresy MAC kart sieciowych czy adresy IPv6 zawierają wyłącznie cyfry oraz litery od A do F - są one podawane w systemie szesnastkowym.

Podsumowanie

Zdaję sobie sprawę, że dla wielu z Was przeprawa przez system binarny może nie być aż tak pasjonująca. Tym niemniej liczę na to, że osoby, które dopiero uczą się informatyki, mają sprawdziany z tego zagadnienia (patrzę na Was, uczniowie techników informatycznych) lub zwyczajnie lubią matematykę, znajdą tutaj coś ciekawego dla siebie, a w konsekwencji pojmą ten obszar i ruszą do dalszych badań. Czasem warto wiedzieć, jakie są fundamenty działania maszyn elektronicznych, z którymi mamy do czynienia na co dzień i spotykamy je na każdym kroku.

Pozdrawiam i dziękuję - Jakub Rojek.

We write not only blog articles, but also applications and documentation for our clients. See who we have worked with so far.

About author

Jakub Rojek

Lead programmer and co-owner of Wilda Software, with many years of experience in software creation and development, but also in writing texts for various blogs. A trained analyst and IT systems architect. At the same time he is a graduate of Poznan University of Technology and occasionally teaches at this university. In his free time, he enjoys playing video games (mainly card games), reading books, watching american football and e-sport, discovering heavier music, and pointing out other people's language mistakes.

Jakub Rojek