Kod z przesunięciem a uzupełnienie do dwóch: dwa sposoby reprezentowania liczb ze znakiem
Liczby ze znakiem można reprezentować w binarnym na kilka sposobów, a każdy z nich idzie na inne kompromisy. Najbardziej znany to uzupełnienie do dwóch (two’s complement), które dziś stosuje praktycznie każdy typ całkowity w każdym języku. Ale warto znać jeszcze dwa schematy: kod znak-moduł (sign-magnitude — intuicyjne podejście „po prostu dodajmy bit znaku”, porzucone dekady temu, bo psuje arytmetykę) oraz kod z przesunięciem (offset binary, zwany też biased exponent albo offset-k, gdzie k oznacza przesunięcie), którego IEEE-754 używa do wykładnika każdej liczby zmiennoprzecinkowej. W tym artykule przejdziemy przez wszystkie trzy i dowiemy się, dlaczego kod znak-moduł nie wystarcza, jak uzupełnienie do dwóch naprawia jego problemy i dlaczego IEEE-754 mimo wszystko wybrał do wykładników kod z przesunięciem, a nie uzupełnienie do dwóch.
Naiwna pierwsza próba: kod znak-moduł
Najbardziej intuicyjny sposób rozszerzenia zapisu binarnego na liczby ze znakiem to zarezerwowanie skrajnego lewego bitu jako flagi znaku — 0 dla dodatnich, 1 dla ujemnych — i zakodowanie modułu pozostałymi bitami jako liczby bez znaku. Ten schemat nazywa się kodem znak-moduł.
Na przykładzie 4 bitów +5 i -5 wyglądają tak:
Ten sam moduł 101, różni się tylko górny bit. Czyta się to przyjemnie. Ale ten schemat ma dwie poważne wady, które czynią go nieodpowiednim dla prawdziwego sprzętu.
Dwa zera
Ponieważ bit znaku jest niezależny od modułu, istnieją dwie reprezentacje zera:
Zero powinno być jedną rzeczą. Dwa wzorce bitowe dla niego zmuszają procesor do specjalnego traktowania sprawdzania równości, gdzie 0000 == 1000 musi wciąż dawać prawdę.
Marnuje to również jeden z 16 możliwych wzorców na 4 bitach — w schemacie znak-moduł mamy tylko 15 różnych wartości w zakresie [-7; 7], a nie pełne 16.
Arytmetyka nie działa sama z siebie
W binarnym bez znaku a + b jest proste — dodajesz bit po bicie, propagujesz przeniesienia, gotowe. Kod znak-moduł to psuje. Rozważmy 3 + (-3) w 4-bitowym kodzie znak-moduł:
Czytając 1110 jako znak-moduł, dostajemy -6 — kompletnie błędnie.
Zwykłe dodawanie binarne nie wie, że górny bit ma być znakiem; dodaje go razem ze wszystkim innym.
Aby arytmetyka znak-moduł działała, procesor musi zbadać bity znaku obu argumentów,
zdecydować, czy dodawać, czy odejmować moduły, być może porównać moduły, by ustalić znak wyniku,
i osobno obsłużyć przypadek dwóch zer. Wszystko to przekłada się na dodatkowe układy i wolniejsze operacje.
To właśnie te problemy zmotywowały przejście do schematu, w którym znak nie jest osobną flagą, lecz arytmetyczną konsekwencją samego kodowania — do uzupełnienia do dwóch.
Szybkie wprowadzenie do uzupełnienia do dwóch
Uzupełnienie do dwóch to standardowe kodowanie liczb całkowitych ze znakiem w nowoczesnych procesorach, a jest prostsze, niż brzmi.
W uzupełnieniu do dwóch skrajny lewy bit (najbardziej znaczący) odgrywa podwójną rolę: uczestniczy w obliczeniach jak każdy inny bit, a także — gdy wartość interpretuje się jako liczbę całkowitą ze znakiem — mówi Ci, jaki jest znak (0 dla nieujemnych, 1 dla ujemnych).
Nie jest to osobna flaga, którą się odłupuje; znak wynika z arytmetyki, bo waga pozycyjna górnego bitu jest ujemna.
Weźmy liczbę binarną 1011 i założmy, że jest to liczba całkowita ze znakiem w uzupełnieniu do dwóch. Aby ją zdekodować, używamy podobnej notacji pozycyjnej jak w binarnym bez znaku, gdzie każdemu bitowi przypisuje się jako wagę potęgę dwójki — ale potęga górnego bitu jest zanegowana. Dla 4 bitów wygląda to tak:
Zwróć uwagę, co się zmieniło względem zwykłego binarnego bez znaku: trzy niższe bity zachowują swoje zwykłe dodatnie wagi (, , ), ale górny bit jest mnożony przez zamiast . Ta jedna zmiana znaku na najwyższej pozycji to cały schemat. Generalizując: dla n-bitowej liczby w uzupełnieniu do dwóch górny bit ma wagę zamiast zwykłego , a wszystkie pozostałe bity zachowują swoje normalne dodatnie wagi.
Podstawiając 1011 do wzoru, otrzymujemy — a nie 11 (co dostałbyś, czytając 1011 jako zwykłe binarne bez znaku: ) i nie -3 (co dałby kod znak-moduł: górny bit 1 oznaczający liczbę ujemną, niższe bity 011 kodujące moduł 3).
Jak zakodować liczbę ujemną
W poprzedniej sekcji zdekodowaliśmy wzorzec bitowy, który już siedział w pamięci. Ale jak przejść w drugą stronę — zacząć od liczby dziesiętnej takiej jak -5 i wyprodukować 4-bitowy wzorzec, który ją przechowuje? Wzór pozycyjny mówi, co znaczy dany wzorzec, ale nie mówi wprost, jak zbudować wzorzec dla zadanej wartości ujemnej.
Na szczęście istnieje prosty przepis, który za każdym razem daje właściwy wzorzec: weź dodatnią wersję liczby, zaneguj wszystkie bity (0 → 1, 1 → 0) i dodaj 1. Wynik jest zapisem tej ujemnej wartości w uzupełnieniu do dwóch. (Dlaczego ten przepis działa, zobaczymy później, w sekcji o arytmetyce modularnej — na razie potraktuj go jako procedurę mechaniczną.)
Użyjmy go, by znaleźć reprezentację -5 na 4 bitach:
- Zacznij od zapisu binarnego +5:
- Zaneguj wszystkie bity:
- Dodaj 1:
Zatem -5 jest przechowywane jako 1011 w 4-bitowym uzupełnieniu do dwóch. Sprawdźmy wzorem wag bitowych: .
Dlaczego stosuje się ten schemat
Uzupełnienie do dwóch pozwala procesorowi używać tego samego układu dodawania (fizycznego bloku sprzętowego z bramek logicznych, który wykonuje dodawanie bit po bicie z propagacją przeniesienia) zarówno dla arytmetyki ze znakiem, jak i bez znaku.
Wykonajmy dodawanie 5 + (-5):
Przeniesienie z górnego bitu jest odrzucane, pozostaje 0000 — dokładnie zero. Sprzęt nie musi sprawdzać, czy argumenty są ze znakiem, i nie potrzebuje osobnej ścieżki odejmowania. Zwykłe dodawanie binarne po prostu działa.
Z tego kodowania wynikają jeszcze dwie właściwości. Po pierwsze, zakres dla n bitów to — dla 4 bitów [-8; 7], dla 8 bitów [-128; 127].
Zauważ, że jest asymetryczny: o jedną wartość ujemną więcej niż dodatnich, bo slot, który w schemacie znak-moduł reprezentowałby -0, jest tu przeznaczony na najbardziej ujemną liczbę.
Po drugie, istnieje dokładnie jeden wzorzec zera 0000, a nie dwa: 0000 i 1000.
Zasada spajająca: arytmetyka modularna
Przyjrzyjmy się teraz, dlaczego uzupełnienie do dwóch działa tak, jak działa. Jedna idea, która wyjaśnia niemal wszystko w tym formacie, brzmi: arytmetyka n-bitowa to arytmetyka modularna na okręgu o pozycjach. Niemal każda inna właściwość formatu — przepis „zaneguj i dodaj 1”, argument o jednym układzie, zawijanie przy przepełnieniu — wynika bezpośrednio z tego jednego ujęcia.
Co znaczy „arytmetyka modularna”
Gdy mówimy, że „arytmetyka n-bitowa jest modularna”, mamy na myśli, że wszystkie wartości i wszystkie operacje dzieją się na pętli o pozycjach, a nie na nieskończonej osi liczbowej. Wyobraź sobie 4-bitowy zegar o 16 pozycjach, z których każda nosi dwie etykiety: wartość bez znaku (na zewnątrz) i wartość ze znakiem w uzupełnieniu do dwóch (wewnątrz):
Zwróć uwagę, jak etykiety ze znakiem dzielą okrąg na dwie połowy: prawa strona mieści wartości nieujemne 0–+7,
lewa strona wartości ujemne −1…−8 (przy czym sama pozycja 8 — wzorzec bitowy 1000 — mieści wartość najbardziej ujemną, −8).
Kilka rzeczy warto podkreślić w działaniu tego zegara:
- Każda pozycja mieści jeden wzorzec bitowy — etykiety bez znaku i ze znakiem to tylko dwie różne interpretacje tych samych 4 bitów.
- Dodanie
1przesuwa Cię o jedną pozycję zgodnie z ruchem wskazówek zegara. - Za
15zawijasz z powrotem do0. Na tym zegarze15 + 1 = 0,15 + 2 = 1,15 + 3 = 2i tak dalej — każda wartość za15idzie dalej po okręgu, trafiając na pozycję0, potem1, potem2.
Matematycy mają zwięzłą notację dla tego zawijania: a ≡ b (mod n) czyta się jako „a trafia na tę samą pozycję zegara co b na zegarze o n pozycjach”. Nasze przykłady zawijania z punktu powyżej można więc zapisać jako 16 ≡ 0 (mod 16), 17 ≡ 1 (mod 16), 18 ≡ 2 (mod 16) — każdy po prostu mówi, że liczba po lewej trafia na tę samą pozycję co liczba po prawej, gdy idziesz po okręgu o 16 pozycjach.
Zatem 18 ≡ 2 (mod 16) mówi tylko tyle, że 18 trafia na tę samą pozycję co 2 na 16-pozycyjnym zegarze — pełne okrążenie (16 kroków) plus 2 dodatkowe.
Zauważ, że a ≡ b (mod n) to relacja (zdanie prawda-fałsz porównujące dwie liczby), a nie operacja — więc nie odwzorowuje się wprost na %. Jej odpowiednikiem na poziomie kodu jest sprawdzenie a % n == b % n — oba wyrażenia są prawdziwe dokładnie wtedy, gdy a i b trafiają na tę samą pozycję zegara. Sam operator % odpowiada osobnej notacji, a mod n (zapisywanej bez ≡), która jest operacją: na przykład 18 mod 16 = 2 daje resztę z dzielenia, czyli dokładnie tę pozycję zegara, na którą trafiasz. Dla dowolnego n n-bitowa arytmetyka bez znaku działa dokładnie jak zegar o pozycjach.
Dlaczego przepis „zaneguj i dodaj 1” działa
Mamy już dość aparatu, by zobaczyć, dlaczego przepis z wcześniejszej części faktycznie produkuje właściwe kodowanie liczb ujemnych.
Centralnym pojęciem jest element odwrotny względem dodawania (additive inverse). Na zegarze modularnym „minus x” to wcale nie znak minus — to ta pozycja zegara, która dodana do x sprowadza Cię z powrotem na pozycję 0. Tę pozycję nazywa się elementem odwrotnym x względem dodawania i właśnie ją uzupełnienie do dwóch zapisuje w bitach. A przepis, który widzieliśmy wcześniej — weź wersję dodatnią, zaneguj wszystkie bity i dodaj 1 — robi dokładnie to: jest bitową procedurą obliczania elementu odwrotnego.
Znajdźmy element odwrotny 5 na naszym 16-pozycyjnym zegarze. Chcemy takiej pozycji y, że 5 + y trafia na 0. Startując z 5 i idąc 11 kroków zgodnie z ruchem wskazówek, trafiamy na 16, co zawija się do 0. Zatem y = 11:
Element odwrotny 5 na 16-pozycyjnym zegarze to więc 11. A 11 binarnie to 1011 — dokładnie ten wzorzec bitowy, który przepis „zaneguj i dodaj 1” dał wcześniej dla −5.
Możemy teraz uogólnić to, co zrobiliśmy z 5 i 11, na dowolną liczbę bitów. Na zegarze o pozycjach elementem odwrotnym dowolnej wartości x jest , ponieważ
Zatem uzupełnienie do dwóch przechowuje −x jako wzorzec bitowy liczby czytany bez znaku. To właśnie zaobserwowaliśmy na zegarze, gdzie ujemne wartości ze znakiem −1, −2, …, −8 siedzą na tych samych pozycjach co liczby bez znaku 15, 14, …, 8 — każda jest elementem odwrotnym swojego dodatniego odpowiednika.
Na koniec: „zaneguj bity, potem dodaj 1” to po prostu szybki, bitowy sposób obliczenia bez wykonywania faktycznego odejmowania:
- Zanegowanie każdego bitu daje (ta wartość pośrednia nazywa się uzupełnieniem do jedynki). Dla 4 bitów przy
0101: negacja daje1010, co bez znaku czyta się jako10, i faktycznie . - Dodanie 1 daje . Kontynuując przykład: — element odwrotny, który policzyliśmy powyżej.
Przepis nie jest więc sprytną sztuczką wymyśloną przez projektantów — jest bitowym skrótem do obliczania modularnego elementu odwrotnego.
Cały format w jednym zdaniu
Uzupełnienie do dwóch to arytmetyka modularna bez znaku z innym oznakowaniem górnej połowy zegara. To cały schemat. Każda właściwość — przepis, jeden układ, zawijanie, pojedyncze zero — jest konsekwencją tej jednej idei.
To wyjaśnia również, dlaczego jeden układ sumujący obsługuje arytmetykę i ze znakiem, i bez znaku. Sprzęt nie wie i nie dba o to, czy oznaczasz pozycje na zegarze jako „ze znakiem”, czy „bez znaku”. On po prostu dodaje modulo . Czy zinterpretujesz wzorzec 1101 jako 13 bez znaku, czy jako -3 ze znakiem, procesor traktuje oba argumenty jako pozycje zegara, idzie do przodu o tyle kroków, ile wynosi drugi argument, i trafia na jakąś pozycję. Obie interpretacje wyłaniają się z tego, jak czytasz wynik, a nie z tego, jak procesor go obliczył.
Kod z przesunięciem: schemat IEEE-754
Uzupełnienie do dwóch jest znakomite w tym, do czego zostało zaprojektowane: sprawia, że arytmetyka liczb całkowitych ze znakiem to ten sam układ co arytmetyka bez znaku. Ale gdy odejdziesz od „chcę dodawać i odejmować liczby całkowite ze znakiem” i zadasz reprezentacji ze znakiem inne pytania, zaczyna być niewygodne. Pytania w rodzaju:
- Jak porównać dwie wartości ze znakiem bit po bicie, tak samo jak procesor porównuje wartości bez znaku?
- Gdzie w przestrzeni wzorców bitowych naturalnie wypadną wzorce specjalne (najmniejszy, największy, zero)?
- Czy kodowanie czysto oddziela „małe” od „dużego” bez akrobatyki z flagą znaku?
Konwencja „najbardziej znaczący bit to znak” w uzupełnieniu do dwóch sprawia, że 1000...0000 (najmniejsza ujemna) i 0111...1111 (największa dodatnia) leżą na przeciwległych końcach przestrzeni wzorców bitowych, rozproszone, a nie ustawione wzdłuż granic. Dla liczb całkowitych ogólnego przeznaczenia jest to w porządku, ale przeszkadza formatom, które potrzebują przewidywalnych, monotonicznych wartości ze znakiem — na przykład polu wykładnika liczby zmiennoprzecinkowej, gdzie porównywanie dwóch liczb po wykładnikach jest gorącą ścieżką, którą sprzęt musi zrobić tanią.
Dlatego arytmetyka komputerowa używa innej reprezentacji ze znakiem, gdy te właściwości mają znaczenie: kodu z przesunięciem (offset binary, zwanego też biased binary albo excess-K). To schemat, którego IEEE-754 używa do wykładnika każdej liczby zmiennoprzecinkowej, a dokładnie dlaczego — zobaczymy później. Na razie przyjrzyjmy się, jak on właściwie działa.
Procedura kodowania w kodzie z przesunięciem jest prosta: obliczamy przesunięcie (bias), dodajemy je do liczby, którą chcemy zapisać, a wynikowa wartość jest tym, co faktycznie zostaje zapisane (w razie potrzeby przeliczone na binarne). Aby zademonstrować kroki, zobaczmy, jak liczbę 3 można zapisać na 4 bitach.
Najpierw znajdujemy przesunięcie za pomocą wzoru wspomnianego w standardzie IEEE-754:
gdzie n to liczba bitów. Przesunięcie dla 4 bitów to więc 7. Następnie dodajemy przesunięcie do liczby pierwotnej: 3 + 7 = 10.
Wynikowa liczba 10 to sposób, w jaki liczba 3 jest przechowywana w schemacie kodu z przesunięciem. Ponieważ do uzyskania wyniku 10 użyliśmy systemu dziesiętnego, musimy przeliczyć go na binarny:
Jeśli nie znasz algorytmu konwersji albo chcesz wiedzieć, dlaczego działa, zajrzyj do mojego artykułu o algorytmach konwersji dziesiętno-binarnej.
Definiowanie przesunięcia
Załóżmy, że mamy tylko 4 bity na przechowywanie liczb. Wzór na permutacje z powtórzeniami daje nam różnych wzorców bitowych. Pytanie brzmi: co te 16 liczb reprezentuje. Załóżmy, że interesuje nas przechowywanie wyłącznie liczb całkowitych nieujemnych. Wtedy zakres to:
Ale jeśli włączymy liczby ujemne, zakres może być różny:
Ciekawe jest tu to, że choć zmieniamy zakres w zapisie dziesiętnym, w binarnym pozostaje on ten sam — tylko że teraz najmniejsza liczba 0000 reprezentuje liczbę ujemną. To tak, jakby ta najmniejsza liczba była przesunięta w dół od zera o 1 w pierwszym przypadku, o 7 w drugim i o 8 w trzecim.
Innymi słowy, przesunięcie K to po prostu wybór gdzie podzielić ustalony zbiór wzorców bitowych między liczby ujemne i nieujemne — decyduje, ile slotów przypada każdej stronie zera. Nie ma jednego matematycznego standardu, jak je wybierać, jest tylko konwencja. Powszechne są dwie możliwości:
Dla 4 bitów K = 8 daje równo podzielony zakres [-8; 7], natomiast K = 7 (wybór IEEE-754) daje zakres [-7; 8] z jedną dodatkową wartością dodatnią.
Załóżmy, że musimy zapisać liczbę 3 na 4 bitach. Użyjemy wzoru na bias z IEEE-754 , który dla daje . To przesunięcie dzieli 16 wzorców bitowych na zakres [-7; 8], więc wzorzec 0000 reprezentuje -7, a 1111 reprezentuje 8. Skoro 0000 to -7, jaką liczbę musimy dodać, by dojść do 3? To 10.
Zobaczmy, co nam to daje:
Pokazuje to, że liczba 3 jest przechowywana jako 1010 binarnie przy przesunięciu 7. Powinno też być łatwo dostrzec, skąd bierze się operacja dodania przesunięcia w celu uzyskania reprezentacji liczby w kodzie z przesunięciem:
I w konsekwencji: jeśli dodawaliśmy przesunięcie, by uzyskać reprezentację liczby w kodzie z przesunięciem, to aby przeliczyć liczbę z powrotem, powinniśmy je odjąć.
Zalety wobec uzupełnienia do dwóch
Największą zaletą kodu z przesunięciem nad uzupełnieniem do dwóch jest to, że pozwala porównywać liczby tak, jak są, w porządku leksykograficznym, bez potrzeby dodatkowych operacji. Porównajmy na przykład dwie liczby, 3 i -3, reprezentowane na 4 bitach. W kodzie z przesunięciem mają następującą postać:
Porównując bit po bicie, komputerowi już po pierwszym bicie jest od razu jasne, że pierwsza liczba jest większa. Porządku leksykograficznego nie da się zastosować do liczb przechowywanych w uzupełnieniu do dwóch:
Aby je porównać, komputer musi wykonać dodatkowe operacje.
Monotoniczne uporządkowanie to główna zaleta, a wynika z niej kilka ważnych konsekwencji — i razem wyjaśniają one, dlaczego IEEE-754 wybrał do wykładnika zmiennoprzecinkowego właśnie kod z przesunięciem.
Największą korzyścią jest to, że cała dodatnia liczba zmiennoprzecinkowa — znak, wykładnik i mantysa odczytane jako jedna wielka liczba całkowita bez znaku — staje się monotoniczna względem reprezentowanej wartości. Oznacza to, że sprzętowe układy porównywania liczb zmiennoprzecinkowych mogą dosłownie użyć ponownie zwykłego komparatora całkowitoliczbowego. W uzupełnieniu do dwóch ta właściwość by się załamała, wymuszając dedykowaną logikę porównań dla liczb zmiennoprzecinkowych.
Schemat ładnie umieszcza też zarezerwowane wzorce bitowe na granicach. Pola wykładnika złożone z samych zer i samych jedynek leżą na dwóch skrajach zakresu, a IEEE-754 używa ich jako slotów na wartości specjalne — ±0 i liczby zdenormalizowane na dole, ±∞ i NaN na górze. Bias (np. 1023 dla fp64) jest dobrany tak, by użyteczne wykładniki wypadały między tymi zarezerwowanymi końcami, a liczby zdenormalizowane i stopniowy niedomiar działają gładko, bo mieszkają zaraz obok dokładnego zera na dolnym skraju. Przy uzupełnieniu do dwóch najmniejszy i największy wzorzec bitowy wypadłyby zamiast tego w środku przestrzeni wzorców, czyniąc wykrywanie wartości specjalnych niezgrabnym.
Wybór nie jest więc arbitralny — kod z przesunięciem to reprezentacja, przy której wszystkie te właściwości układają się na miejscu naraz.
Zakres jest zawsze asymetryczny
Jedna właściwość wspólna wszystkim trzem schematom: każda binarna reprezentacja obejmująca zero ma zakres asymetryczny, dokładnie o jedną wartość. To ograniczenie zliczania — mając n bitów, dysponujesz kodami (liczba parzysta), a symetryczny zakres wokół zera potrzebowałby kodów (liczba nieparzysta). Jedna strona zawsze nosi więc jedną wartość więcej.
Trzy schematy radzą sobie z tym inaczej. Uzupełnienie do dwóch przydziela dodatkową wartość stronie ujemnej — dla 8 bitów zakres to od -128 do +127, gdzie -128 (10000000) jest jedyną liczbą ujemną bez dodatniego odpowiednika. W kodzie z przesunięciem bias sam decyduje, której stronie przypadnie dodatkowy slot — K = 2^{n-1} - 1 z IEEE-754 daje jedną dodatkową wartość dodatnią, a K = 2^{n-1} odzwierciedla uzupełnienie do dwóch. Kod znak-moduł unika asymetrycznego zakresu tylko za cenę dwóch zer (+0 i -0), co psuje arytmetykę — więc w praktyce każdy schemat, w którym arytmetyka jest poprawna, musi przyjąć tę asymetrię.
Widać to bezpośrednio w stałych IEEE-754: dla fp64 użyteczny zakres wykładników (po rezerwacjach) to od -1022 do +1023, wciąż z przesunięciem o jeden. Dlatego największa skończona liczba podwójnej precyzji wynosi około , a najmniejsza znormalizowana dodatnia około — rzędy wielkości są bliskie, ale nie równe.
Przegląd tego, jak cztery podstawowe operacje (dodawanie, odejmowanie, mnożenie, dzielenie) faktycznie działają na liczbach całkowitych w uzupełnieniu do dwóch — i co dzieje się na poziomie bitów, gdy przepełniają się lub psują w inny sposób — znajdziesz w artykule Jak działa arytmetyka binarna: liczby całkowite w uzupełnieniu do dwóch i liczby zmiennoprzecinkowe IEEE-754.