Komputer nie zna liter. Zna tylko napięcie — jest albo go nie ma — które umawiamy się czytać jako zero i jedynkę. Wszystko, co widzisz na tym ekranie, musiało więc najpierw zostać zamienione na liczby, a liczby na ciągi bitów. Litera „A" to w tej układance liczba 65. Ale polskie „ł" to już dwa bajty, symbol euro „€" — trzy, a emoji rakiety „🚀" — aż cztery. Ta opowieść jest o tym, jak i dlaczego pismo staje się bajtami, oraz o pułapkach, które po drodze potrafią zepsuć tekst albo otworzyć serwer na atak.
Dlaczego tekst to bajty
Najmniejsza porcja informacji, bit, przechowuje jeden z dwóch stanów. Osiem bitów grupujemy w bajt, który mieści jedną z 2⁸ = 256 wartości — od 0 do 255. Żeby maszyna mogła zapisać, przesłać i wyświetlić ludzki tekst, ktoś musiał ustalić reguły przypisujące znakom konkretne liczby. Taki zestaw reguł nazywamy kodowaniem.
Warto od razu rozdzielić dwie warstwy, bo mylenie ich jest źródłem większości nieporozumień. Sam wybór kodowania jest umowny — decyzja, że „A" to akurat 65, a nie 41, wynika z historii i kompromisów, nie z żadnego prawa natury. Ale kiedy standard już przyjmiemy, zamiana znaku na bity staje się procesem ściśle deterministycznym: przesunięcia bitów i operacje logiczne nie zostawiają miejsca na interpretację. Umowa jest arbitralna, wykonanie umowy — matematyczne.
Zanim był komputer: pięć bitów Baudota
Problem jest starszy od tranzystora. Już telegrafy z XIX wieku musiały reprezentować litery sygnałami. Wynaleziony w latach 70. XIX wieku kod Baudota (w ustandaryzowanej wersji znany jako ITA2) był zapisem pięciobitowym, co daje zaledwie 2⁵ = 32 kombinacje — za mało, by pomieścić litery, cyfry i interpunkcję naraz. Émile Baudot obszedł ten limit rejestrami: specjalny kod „liter" i kod „cyfr" przełączał znaczenie kolejnych sekwencji, trochę jak klawisz Shift.
Rozwiązanie miało wadę, która wróci do nas przy UTF-8. Jeśli zakłócenie na linii zgubiło znak przełączający rejestr, cała reszta wiadomości zamieniała się w bełkot, aż do następnego kodu sterującego. Pojedynczy błąd kaskadował na cały dalszy tekst.
ASCII: siedem bitów, które wystarczały (tylko) Ameryce
W 1963 roku powstał ASCII — kodowanie siedmiobitowe, a więc 2⁷ = 128 pozycji, uporządkowanych w czytelne przedziały:
- 0–31 oraz 127 — znaki sterujące (Enter, tabulator, Backspace), pozostałość po sterowaniu dalekopisami;
- 32 — spacja;
- 48–57 — cyfry
0–9; - 65–90 — wielkie litery
A–Z; - 97–122 — małe litery
a–z.
Ten porządek nie jest przypadkowy i do dziś ułatwia życie programistom: skoro A to 65, a a to 97, różnica wielkości liter to zawsze te same 32. ASCII miało jednak wbudowany defekt — było amerykańskie. W 128 pozycjach nie zmieściło się miejsce na „ł", „ä" czy „ń".
Kiedy komputery rozeszły się po świecie, każdy kraj dorabiał własne rozwiązanie, rozszerzając kod do pełnych 8 bitów (256 znaków) i upychając lokalne litery w górnej połowie. Problem w tym, że robiono to niezależnie: powstały dziesiątki niekompatybilnych „stron kodowych". W Europie Środkowej rywalizowały ISO-8859-2 i windowsowa Windows-1250, a w polskim DOS-ie krążyła jeszcze rodzima Mazovia — z lat 80., stworzona dla krajowego komputera o tej samej nazwie. Umieszczono w niej nawet ligaturę „zł", której międzynarodowy Unicode nigdy nie przejął, przez co konwersja archiwalnych dokumentów potrafi do dziś wymagać ręcznej poprawki.
Gdy plik zapisany jedną stroną kodową otwierano w programie zakładającym inną, litery zamieniały się w losowe symbole. To zjawisko ma fachową nazwę mojibake, a w Polsce swojską — „krzaczki". Każdy, kto w latach 90. dostał e-mail pełen ¹æê³, wie, o czym mowa.
Unicode: jeden katalog dla wszystkich pism
Wyjściem z tego chaosu był Unicode — projekt, który postanowił nadać każdemu znakowi każdego pisma świata jeden, niepowtarzalny numer. Ten numer to punkt kodowy (ang. code point), zapisywany szesnastkowo z przedrostkiem U+ — na przykład U+0142 to litera „ł", a U+20AC to „€".
Kluczowa subtelność: punkt kodowy nie jest bajtem. To abstrakcyjny identyfikator, oderwany od sposobu zapisu w pamięci. Unicode mówi „ł ma numer 322", ale milczy o tym, jak zapisać 322 w pliku. Tę robotę biorą na siebie osobne formaty — UTF-8, UTF-16 i UTF-32 — które dopiero przekładają punkty kodowe na konkretne bajty. Można powiedzieć, że Unicode prowadzi katalog, a formaty UTF decydują o opakowaniu.
UTF-8: zmienna długość i samo-synchronizacja
Wśród nich wygrał UTF-8, zaprojektowany we wrześniu 1992 roku przez Kena Thompsona i Roba Pike'a z Bell Labs. Jego genialność polega na tym, że jest kodowaniem o zmiennej długości: znak zajmuje od 1 do 4 bajtów, w zależności od tego, jak wysoki jest jego punkt kodowy. Bajty rozdziela się według sztywnych szablonów:
| Bajty | Zakres punktów kodowych | Szablon bitowy | Bity na dane |
|---|---|---|---|
| 1 | U+0000 – U+007F | 0xxxxxxx | 7 |
| 2 | U+0080 – U+07FF | 110xxxxx 10xxxxxx | 11 |
| 3 | U+0800 – U+FFFF | 1110xxxx 10xxxxxx 10xxxxxx | 16 |
| 4 | U+10000 – U+10FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | 21 |
Z tej tabeli płyną dwie wspaniałe konsekwencje. Po pierwsze, pełna zgodność z ASCII: każdy znak z zakresu U+0000–U+007F zapisuje się jednym bajtem zaczynającym się od zera, dokładnie tak jak w ASCII. Stary plik ASCII jest binarnie identyczny z plikiem UTF-8 — nie trzeba niczego konwertować.
Po drugie — i tu UTF-8 rozwiązuje odwieczny problem Baudota — kodowanie potrafi się samo synchronizować. Każdy bajt „kontynuacji" zaczyna się od bitów 10, a żaden bajt startowy tak nie wygląda (te zaczynają się od 0, 110, 1110 lub 11110). Dzięki temu dekoder, który wskoczy w środek strumienia albo zgubi kawałek danych, natychmiast pozna, gdzie kończy się jeden znak, a zaczyna następny. Utrata jednego bajtu psuje jeden znak, a nie — jak w Baudocie czy UTF-16 — całą resztę tekstu.
Trzy litery, trzy długości
Zobaczmy tę mechanikę na żywo. Wszystkie trzy przykłady możesz sprawdzić w konwerterze kodowań tekstu, który pokazuje bajty w hex, dziesiętnie i binarnie naraz.
Polskie „ł" (U+0142, dziesiętnie 322). Wartość mieści się w przedziale dwubajtowym. Zapisujemy 322 na 11 bitach: 00101 000010. Pięć starszych bitów wpada do szablonu pierwszego bajtu, sześć młodszych do drugiego:
110+00101→11000101= 0xC5
10+000010→10000010= 0x82
Litera „ł" to więc bajty C5 82.
Euro „€" (U+20AC, dziesiętnie 8364) wchodzi w przedział trzybajtowy: 0010 000010 101100 rozkłada się na E2 82 AC.
Rakieta „🚀" (U+1F680, dziesiętnie 128 640) leży poza podstawową płaszczyzną Unicode i wymaga czterech bajtów: F0 9F 9A 80.
Ta arytmetyka jest bezwzględna — nie ma tu żadnego zaokrąglenia ani miejsca na „mniej więcej". Emoji zajmuje cztery bajty nie z kaprysu, tylko dlatego, że jego numer nie mieści się w krótszym szablonie.
Base64: bajty, które przejdą przez kanał tekstowy
Skoro potrafimy już zamienić tekst na bajty, pojawia się odwrotny kłopot: jak przesłać surowe bajty kanałem, który akceptuje wyłącznie zwykły tekst? Tak działa poczta e-mail (standard MIME) czy część nagłówków HTTP — bajt o wartości sterującej mógłby zostać wzięty za koniec wiadomości. Rozwiązaniem jest Base64.
Pomysł opiera się na potęgach dwójki. Ponieważ 2⁶ = 64, jeden znak Base64 przenosi dokładnie 6 bitów. Algorytm bierze bajty po trzy (3 × 8 = 24 bity) i tnie je na cztery grupy po 6 bitów, a każdą grupę zamienia na jeden z 64 bezpiecznych znaków: A–Z, a–z, 0–9 oraz + i /. Ponieważ z 3 bajtów zawsze robią się 4 znaki, Base64 powiększa dane o stałe ⅓ (≈ 33%). Gdy na końcu braknie do pełnej trójki, puste miejsca uzupełnia znak wypełnienia =: litera „a" koduje się jako YQ==, a dwubajtowe „ł" jako xYI=.
Tu pada najważniejsze zdanie tego tekstu: Base64 to nie szyfrowanie. Algorytm jest publiczny i w pełni odwracalny bez żadnego klucza — każdy pośrednik odczyta zawartość w ułamku sekundy. Base64 zmienia tylko reprezentację danych, nie ich poufność. Hasło „zabezpieczone" Base64 w nagłówku HTTP jest tak samo jawne jak wypisane wprost.
Istnieje jeszcze odmiana URL-safe: ponieważ + i / mają w adresach własne znaczenie, zastępuje się je przez - i _, a wypełnienie = zwykle się pomija.
Kodowanie URL: procenty w adresie
Adres URL też może się składać wyłącznie z wąskiego zestawu znaków ASCII. Litery, cyfry i garść znaków „bezpiecznych" (-, ., _, ~) wolno wpisać wprost; całą resztę trzeba zapisać jako sekwencję procentową — znak % i dwie cyfry szesnastkowe wartości bajtu (w UTF-8). Dlatego spacja staje się %20, a sam znak procentu — %25. To właśnie robi konwerter, gdy wybierzesz format URL: Cześć świecie zamienia na Cze%C5%9B%C4%87%20%C5%9Bwiecie.
Jest tu klasyczna pułapka. W adresach strona %20 oznacza spację, ale w danych formularzy HTML (application/x-www-form-urlencoded) spację koduje się jako +. Ta drobna niespójność między dwoma standardami potrafi wysypać podpisy cyfrowe i uwierzytelnianie, gdy jedna biblioteka użyje +, a druga oczekuje %20.
Gdy kodowanie staje się luką
Na koniec przestroga, bo kodowanie bywa mylone z rzeczami, którymi nie jest. Nie jest kompresją — Base64 i kodowanie URL zawsze powiększają dane. Nie jest szyfrowaniem — o czym już wiemy. A źle zaimplementowane, bywa luką bezpieczeństwa.
Klasyczny przykład to nadmiarowe kodowanie UTF-8 (overlong). Ukośnik / ma numer U+002F i zgodnie ze specyfikacją musi być zapisany jednym bajtem 0x2F. Ale gdyby napastnik złośliwie wcisnął go w szablon dwubajtowy, dostałby C0 AF — i naiwny, niezgodny ze standardem dekoder i tak odczytałby z tego ukośnik. Filtry bezpieczeństwa sprawdzające, czy w żądaniu nie ma ciągu ../, przepuszczały wersję zakodowaną ..%C0%AF, a serwer rozwijał ją z powrotem do ../ — i pozwalał czytać pliki spoza katalogu strony. Ta podatność (masowo wykorzystywana m.in. na serwerach IIS) jest powodem, dla którego dzisiejsze specyfikacje nakazują odrzucać sekwencje overlong jako błąd, a nie grzecznie je „naprawiać".
Tabela reprezentacji
| Znak | Punkt kodowy | UTF-8 (hex) | UTF-8 (binarnie) | Base64 |
|---|---|---|---|---|
a | U+0061 | 61 | 01100001 | YQ== |
ł | U+0142 | C5 82 | 11000101 10000010 | xYI= |
€ | U+20AC | E2 82 AC | 11100010 10000010 10101100 | 4oKs |
🚀 | U+1F680 | F0 9F 9A 80 | 11110000 10011111 10011010 10000000 | 8J+agA== |
Te same cztery znaki, a każda kolumna to inna umowa o tym, jak zapisać dokładnie tę samą treść.
Jedna treść, wiele ubrań
Historia kodowania tekstu to opowieść o oddzielaniu znaczenia od zapisu. Litera „ł" jest jedna, ale jej numer (U+0142), jej bajty (C5 82), jej Base64 (xYI=) i jej postać w adresie (%C5%82) to cztery różne ubrania tej samej idei. Wybór ubrania jest umowny; sama zamiana — matematyczna i bezstratna, dopóki nadawca i odbiorca umówią się, w którym standardzie właśnie rozmawiają. Gdy się nie umówią, dostajemy krzaczki. Gdy dekoder jest zbyt pobłażliwy — dostajemy dziurę w serwerze. A gdy wszystko zagra, tekst po prostu dociera na drugi koniec świata dokładnie taki, jaki był.
Dalsza lektura
- Rob Pike, The UTF-8 history — wspomnienie współtwórcy o narodzinach formatu w Bell Labs.
- IETF, RFC 3629 — UTF-8 — oficjalna specyfikacja UTF-8 wraz z obsługą błędów.
- IETF, RFC 4648 — Base16, Base32, Base64 — pełna definicja Base64 i wariantu URL-safe.
- IETF, RFC 3986 — Uniform Resource Identifier — składnia adresów i reguły kodowania procentowego.
- Unicode Consortium, The Unicode Standard — źródłowy katalog punktów kodowych wszystkich pism.
