0xcafebabe -- per aspera ad astra

Dokument pobrany z: http://www.anadoxin.org/blog/x86-wejscie-w-tryb-chroniony-openbsd

x86: tryb chroniony na przykładzie OpenBSD v2.8
Tagi:  •    •    •    •  

Post przedstawia przykład, w jaki sposób działa prosty bootloader instalowany w sektorach MBR dysków – jakie ma zadanie, w jaki sposób przygotowuje procesor do rozmowy z kernelem (przełączanie w tryb chroniony) i dlaczego tak się dzieje.

Wybrana stara wersja bootloadera OpenBSD jest skutkiem trochę przypadku, być może trochę lenistwa, nie da się jednak zaprzeczyć, że plusem takiego rozwiązania jest mniej rzeczy, które trzeba na początku ogarnąć. Nowsze bootloadery korzystają z trochę innych metod adresowania danych na dysku, tj. linearnych adresów nazywanych LBA. Próbka bootloadera którą postaram się opisać używa starszej wersji: CHS, która do lokalizowania danych na dysku wykorzystuje takie parametry jak ścieżka, sektor i głowica. Swoją drogą był to standardowy mechanizm odczytu danych z dyskietki za pomocą int 13h, jeśli ktoś z czytających zajmował się kiedyś programowaniem w DOS'ie ;).

MBR jest skrótem od Master Boot Record i mieści się w pierwszym sektorze nośnika danych. W naszym przypadku nośnikiem danych jest dyskietka a.img dostępna na stronie emulatora Bochs. Jako, że jest to obraz dyskietki zapisany w pliku, nie można w 100% zachować zgodności wyglądu danych, z tego samego powodu dla którego na kartce papieru nie można narysować trójwymiarowej figury geometrycznej (i nie mam na myśli tutaj żadnego rodzaju rzutów prostokątnych). Dlatego też w późniejszej części posłużymy się prostym skryptem do tłumaczeń parametrów CHS na adres LBA, który pozwoli na odczytanie odpowiednich danych z odpowiednich miejsc obrazu dyskietki. Ale po kolei...

Na początku wiadomo już, że MBR to pierwsze 512 bajtów nośnika. Obraz pliku na dysku w tej kwestii wcale się nie różni, dlatego kod MBR można znaleźć pod offsetem 0.

Najłatwiej wyciągnąć kod z pliku za pomocą narzędzia dd, czyli należy odczytać 512 pierwszych bajtów nośnika i skierować dane do oddzielnego pliku:

  1. $ dd if=a.img of=stage1 count=1 bs=512

Posiadając wyjęty plik można zacząć analizować co w nim się znajduje. Ale najpierw, trochę szybkiej teorii (dłuższa teoria będzie dostępna w formie dłuższego posta niebawem). BIOS po odczytaniu pierwszego sektora umieszcza go w pamięci fizycznej o adresie 07C0h:0000h (lub 0000h:7C00h – tak naprawdę zależy to od BIOSu jaki to jest adres, mimo tego, ze wskazuje na tą samą komórkę pamięci fizycznej, to stanowi to dość dużą różnicę, ponieważ tak naprawdę nie wiadomo jaka wartość znajduje się w rejestrze segmentowym CS, więc nie wiadomo z kolei czy np. będziemy w stanie wykonywać skoki bliskie, które mogą być wykonane jedynie w granicach tego samego segmentu). Kod wykonywalny działa w trybie rzeczywistym, więc domyślne operacje instrukcji będą używały trybu 16-bitowego, czy to podczas odwoływania się do pamięci, czy operacji na liczbach. Nie znaczy to, że w trybie rzeczywistym nie można korzystać z 32-bitowych rejestrów; wręcz przeciwnie – można to robić i ten bootloader w pewnym punkcie z nich skorzysta – jednak tryb rzeczywisty jest dość okrojonym trybem, w którym większość funkcji, z których korzystają dzisiejsze systemy operacyjne (GDT, uprzywilejowanie kodu, etc), jest wyłączona. Spowodowane jest to powodami historycznymi i polityką firmy Intel która nastawiona jest na zapewnienie kompatybilności wstecznej dla nowych procesorów. Nowe procesory zaczynają od starych funkcji, oferując możliwości zmiany trybów ze starszych na nowsze.

Adres linearny 07C0h:0000h (lub 0000h:7C00h) to inaczej adres fizyczny 0x7C00 i jest to czterysta dziewięćdziesiąty szósty kilobajt pamięci konwencjonalnej (posługując się nazewnictwem DOS). Dlaczego to jest akurat ten adres, nie inny, tego niestety nie wiem, ale decyzja zapadła w czasach procesorów typu Intel 8088 i maszyn IBM PC-1, które – z punktu widzenia dnia dzisiejszego – miały dość mocne ograniczenia, np. limitowanie pamięci do 544 kilobajtów. Jakkolwiek by nie było, musiał to być któryś adres ;), toteż zapewne padło na 0x7C00. Procesor uruchamiając kod MBR niektóre swoje mechanizmy ma już zainicjowane przez BIOS, aby program MBR nie był zdany tylko na siebie. Tak więc, przed uruchomieniem pierwszej instrukcji, MBR ma do dyspozycji tablicę wektorów przerwań (Interrupt Vector Table - IVT) trybu rzeczywistego (która różni się znacząco od tablicy przerwań trybu chronionego - IDT), funkcje BIOS (które potrafią rozmawiać ze sprzętem w taki sposób, jakby miały do niego „sterowniki” - oczywiście mam tutaj na myśli bardziej podstawowy sprzęt typu dysk twardy, a nie twój najnowszy tablet Wacom na USB ;>), oraz pewne ustandaryzowane (mniej więcej) struktury danych BIOS'u, o których należy pamiętać, choć głównie dlatego, aby ich nie nadpisać ;). Wszystko, co zrobimy w tym kodzie, musi być wykonalne przy użyciu 512 bajtów i ani jednego bajta więcej – w tą liczbę wchodzi zarówno kod jak i dane, więc wyświetlanie napisów łatwych do zrozumienia przez człowieka jest dość kosztowne (w miejsce, nie czas). Ciężko jest w 512 bajtach upchać kod, który odczyta z dysku o nieznanym systemie plików kernel, następnie zaopatrzy go w jego środowisko, być może również konfigurację i odda mu kontrolę, dlatego zdecydowana większość kodów MBR stosuje mechanizm „stage'owania”, czyli podziału kodu MBR na dwie części. Pierwsza znajduje się w pierwszym sektorze nośnika, a druga część w dalszej części nośnika. Druga część nie ma już ograniczenia 512 bajtów i dlatego generalnie pracą części pierwszej (nazywanej stage1) jest po prostu załadowanie części drugiej (stage2) przy użyciu funkcji dostępu do dysku, w które zaopatrzy BIOS.

Wracając do praktyki, sprawdźmy co znajduje się w pliku stage1. Sprawdźmy offset 0:

  1. (1:545)$ objdump -D -b binary -mi386 -Maddr16,data16,suffix --insn-width 10 stage1
  2.  
  3. stage1:     file format binary
  4.  
  5.  
  6. Disassembly of section .data:
  7.  
  8. 00000000 <.data>:
  9. 0:      eb 3c                           jmp    0x3e
  10. [...]
  11. 3e:     66 ea 53 00 00 00 c0 07         ljmpl  $0x7c0,$0x53

Znajduje się tutaj skok niżej, do offsetu 0x3E. Wstawiony jest po to, aby przestrzeń (oznaczona na powyższym listingu jako […]) mogła zawierać jakieś binarne dane, które po prostu wyglądają na dane binarne ;), a konkretnie są zgodne ze standardową notacją struktury BIOS Parameter Block (BPB). Nie są one wykorzystywane w stage1, prawdopodobnie kod stage2 z nich korzysta, być może korzystać z nich BIOS, więc zostaną one pominięte. Być może też niektóre wersje BIOSu sprawdzają występowanie tego skoku, więc dodany jest on tutaj zapewne w celu zwiększenia kompatybilności z tymi wersjami.

Offset 0x3E zawiera skok daleki. Można zauważyć tutaj różnicę w ilości bajtów wykorzystywanych przez daleki skok i skok bliski, spod offsetu zerowego. Skok daleki różni się od bliskiego ilością bajtów wykorzystanych do enkodowania instrukcji (skok bliski – 2 bajty, daleki – 7 bajtów), jak i też tym, że tylko skok daleki potrafi skoczyć poza granice aktualnego segmentu. Dodatkowo, tutaj instrukcja skoku zawiera też prefiks zmieniający rozmiar adresowania argumentu – prefix 0x66 – który w tym przypadku pozwala na skok do adresu 0x00000053 segmentu 0x07C0. Nie brzmi to logicznie ;), ponieważ przecież kod działa w trybie 16-bitowym, więc co najwyżej można skoczyć pod adres IP 0x0053. Tryb 16-bitowy nie uniemożliwia jednak korzystania z 32-bitowych rejestrów, więc ustalenie przy pomocy skoku rejestru EIP na wartość 0x00000053 jest całkowicie możliwe. Zapewne jednak nie chodzi tutaj o wyzerowanie górnej części rejestru EIP, ale o wymuszenie dłuższej notacji zapisu skoku, aby w pliku znajdowało się więcej bajtów, które w prostu sposób można spatchować przy użyciu programu instalującego MBR. Przy okazji daleki skok ustawia rejestr segmentowy kodu (CS) na 0x07C0, dzięki czemu adres bazowy kodu w pamięci będzie zgodny z adresem bazowym załadowanego przez nas pliku, który liczony jest od zera. Dzięki temu nie trzeba wykorzystywać relokacji podczas adresowania bezwzględnego, ponieważ dokładnie wiemy pod którym adresem będzie która wartość w pamięci. Skok taki jest też częścią konwencji „dobrego wychowania” kodu MBR, ponieważ teraz wiadomo, jaką wartość ma rejestr segmentowy CS. To, co znalazło się tam wcześniej (0x7C00? Czy może 0x0000?), wynikało z dobrej chęci BIOS'u, lecz to, co dobre dla jednego nie musi być dobre dla drugiego ;), dlatego więc zamiast ślepo wierzyć, że w CS znajduje się wartość taka jak w środowisku testowym twórcy MBR, należy się po prostu upewnić wrzucając tam swoją wartość, czyli 0x7C0.

  1. 53:   fa                              cli    
  2. 54:   31 c0                           xorw   %ax,%ax
  3. 56:   8e d0                           movw   %ax,%ss
  4. 58:   66 bc fc ff 00 00               movl   $0xfffc,%esp
  5. 5e:   fb                              sti    

Są dwie wersje interpretacji powyższego kawałka kodu. Pierwsza będzie taka, w jaki sposób myślał twórca, druga natomiast będzie krótkim wyjaśnieniem, dlaczego się mylił. ;)

CLI i STI to instrukcje odpowiedzialne za odpowiednio: wyłączanie i włączanie obsługi przerwań. Temat przerwań to swój własny temat i wychodzi nieco poza ramy tego posta, być może uda mi się go opisać nieco bliżej w przyszłości, choć na chwilę obecną wystarczy fakt, że włączone przerwania nie gwarantują ciągłości uruchomienia kodu. Ten brak gwarancji nie wpływa na gwarancję wykonania kodu (czyli wszystkie instrukcje na pewno zostaną uruchomione w podanej kolejności, oczywiście), ale przy włączonych przerwaniach kod może być wykonany nieco później niż natychmiast. Jedno z przerwań jest wywoływane wiele razy na sekundę i często przerywa działanie aktualnie wykonywanego kodu (stąd pochodzi nazwa przerwań). Struktury danych wskazujące na kod odpowiedzialny za jego obsługę został zainicjowany przez BIOS (pod adresem fizycznym 0 – czyli IVT) i po zakończeniu obsługi przerwania procesor wraca w miejsce, od którego został brutalnie odciągnięty. Problem w tym, że podczas rozpoczęcia mechanizmu oderwania się od pierwotnego kodu, gdy za chwilę ma rozpocząć się proces odczytu IVT i decyzji który adres ma zostać uruchomiony następnie aby móc obsłużyć przerwanie, procesor na aktualny stos wpycha aktualną wartość rejestru segmentowego CS i rejestru EIP. Można sobie wyobrazić więc, co się może stać gdy nadejdzie jakieś żądanie przerwania podczas gdy procesor właśnie skończy wykonywać instrukcję spod adresu 56 – żądanie przerwania zmusi go do odłożenia CS i EIP na stos, ale który stos? Rejestr SS został właśnie wyzerowany, więc para SS:SP wskazuje na błędny adres. Operacja umieszczania CS i EIP na stos nie powiedzie się więc i kolejny słodki kociak straci życie. :P

Para instrukcji CLI i STI rozwiązuje ten problem, ponieważ gwarantuje, że większość przerwań nie zostanie obsłużona przez procesor (zostaną zakolejkowane i obsłużone gdy już będzie można). Te, które zgłoszą żądanie obsługi nazywane są przerwaniami niemaskowalnymi, non-maskable interrupts (NMI) i po części nie trzeba się przejmować uszkodzonym stosem podczas gdy takie przerwanie nadejdzie, ponieważ prawdopodobnie procesor wykrył problem z hardware, więc problem ze stosem jest najmniejszym aktualnym problemem ;).

Tego, czego nie przewidział twórca (lub nie doczytał), to fakt, że inżynierowie w Intelu przewidzieli problem zmiany segmentu stosu i to, że zmiana ta wiąże się z koniecznością wykonania dwóch operacji w atomowy (nieprzerwany) sposób. Dlatego też procesor automatycznie wyłącza żądania przerwań po napotkaniu instrukcji MOV, gdy rejestrem docelowym jest SS. Zaraz potem, po wykonaniu kolejnej instrukcji, żądania przerwań włącza ponownie. Dlatego kod powyżej wygląda tak, gdyby miał dwie pary instrukcji CLI prawie zaraz po sobie (rozdzielone instrukcją xorw %ax,%ax), a niżej dwie pary instrukcji STI. Pomijam zupełnie fakt, że sam Intel do zmiany segmentu stosu przygotował instrukcję LSS, która w ogóle nie musi wyłączać żądań przerwań. Dobra strona jest tego taka, że dodatkowe pary CLI/STI nie szkodzą.

Pomijając powyższe wywody, ustawiany jest wskaźnik stosu na adres 0000h:FFFCh, który znajduje się w przedziale pamięci – zgodnie z organizacją mapy pamięci – gwarantowanej dla takich programów, jak kod MBR (lub DOS).

  1. 5f:   b8 c0 07                        movw   $0x7c0,%ax
  2. 62:   8e d8                           movw   %ax,%ds
  3. 64:   8e c0                           movw   %ax,%es
  4. 66:   8e e0                           movw   %ax,%fs
  5. 68:   8e e8                           movw   %ax,%gs

Kolejne instrukcje to ustalanie stałej wartości dla wszystkich rejestrów segmentowych (oprócz CS, który jest już ustawiony przez daleki skok pod offsetem 0x3E). Widać tutaj, że kod tego MBR kierowany jest do procesorów 386 lub wyższych, z uwagi na wykorzystanie rejestrów segmentowych FS i GS, których w oryginalnym trybie rzeczywistym 8086 nie ma. Tak czy inaczej, ustalenie rejestru DS na odpowiednią wartość będzie gwarantować poprawne działanie wszystkich operacji odczytu z pamięci, ponieważ będą odwoływały się do segmentu, w którym istnieje kod MBR.

  1. 6a:   66 be 46 00 00 00               movl   $0x46,%esi
  2. 70:   e8 bd 00                        callw  0x130

Następuje tutaj ustawienie liczby 0x46 do rejestru ESI (swoją drogą, kolejny dowód na to, że kod jest kierowany pod 386. Takich przykładów będzie więcej) i wywołanie pewnej funkcji spod adresu 0x130.

  1. 130:   66 50                           pushl  %eax
  2. 132:   fc                              cld    
  3. 133:   ac                              lodsb  %ds:(%si),%al
  4. 134:   84 c0                           testb  %al,%al
  5. 136:   74 12                           je     0x14a
  6. 138:   e8 02 00                        callw  0x13d
  7. 13b:   eb f6                           jmp    0x133
  8. 13d:   66 50                           pushl  %eax
  9. 13f:   66 53                           pushl  %ebx
  10. 141:   b4 0e                           movb   $0xe,%ah
  11. 143:   31 db                           xorw   %bx,%bx
  12. 145:   43                              incw   %bx
  13. 146:   cd 10                           int    $0x10
  14. 148:   66 5b                           popl   %ebx
  15. 14a:   66 58                           popl   %eax
  16. 14c:   c3                              retw  

W końcu jakiś kawałek kodu który posiada więcej niż kilka linijek :). Przede wszystkim, pushl %eax odkłada na stos rejestr EAX, który będzie przywrócony przy wyjściu. Dzieje się tak, by funkcja wróciła do poprzedniego miejsca oddając rejestr EAX w takim stanie, w jakim go dostała. Następnie instrukcja CLD zeruje flagę w procesorze o nazwie direction flag, która wpływa na działanie takich instrukcji jak LODSB poniżej. Przeciwieństwem instrukcji CLD jest STD, która ustawia tą flagę. LODSB do rejestru AL wczytuje bajt spod adresu pamięci, na który wskazuje rejestr SI z segmentu DS, a następnie zwiększa SI o jeden, gdy flaga direction flag jest wyzerowana, lub zmniejsza SI o jeden, gdy flaga direction flag jest ustawiona.

Offsety 0x134 i 0x136 sprawdzają, czy wczytany bajt jest równy zeru. Jeśli tak, zostaje wykonany skok pod adres 0x14A, który zdejmuje EAX ze stosu i wychodzi z funkcji.

Offset 0x138 to wywołanie kolejnej funkcji spod adresu 0x13D, a zaraz po tym wywołaniu następuje skok w górę (czyli jest to pętla), do adresu 0x133, który ponownie wczytuje kolejny bajt z pamięci wskazywanej przez DS:SI, który niedawno został zwiększony o 1.

Z tego wynika, że funkcja wyjdzie tylko wtedy, gdy w pamięci wskazywanej przez DS:SI znajdzie się w końcu 0. Dla każdej innej wartości wywoływana jest funkcja z adresu 0x13D, należałoby sprawdzić więc, co robi.

Na początku zachowywane są rejestry EAX i EBX, aby mogły być przywrócone pod koniec funkcji. Następnie, offsety 0x141, 0x143 i 0x145 ustawiają odpowiednio rejestr AH na 0x0E i BX na 1. Instrukcja spod adresu 0x146 wywoła przerwanie programowe o numerze 0x10. Aby dowiedzieć się, co robi to przerwanie, najlepiej zajrzeć do jakichś zasobów w internecie z listą przerwań i ich opisem. Najbardziej znana jest lista przerwań Ralpha Browna, która mówi o tym, że przerwanie software'owe 0x10 przy zawartości rejestru AH=0x0E oznacza wypisanie znaku na ekranie. Tym też zajmuje się ta funkcja, będąca usługą oferowaną przez BIOS – wyświetlaniem znaku z argumentu AL na ekran. Razem z poprzednią funkcją wypisuje na ekran ciąg bajtów zakończony zerem. Ciąg bajtów precyzowany jest przy pomocy rejestru ESI, który w momencie wywołania tej funkcji zawierał adres 0x46. Należałoby sprawdzić więc, co znajduje się pod tym adresem:

  1. (1:512)$ cat stage1 | xxd | grep 0000[45]0
  2. 0000040: 5300 0000 c007 7265 6164 696e 6720 626f  S.....reading bo
  3. 0000050: 6f74 00fa 31c0 8ed0 66bc fcff 0000 fbb8  ot..1...f.......

Chodzi więc o wyświetlenie napisu „reading boot” :). Wróćmy więc do adresu 0x70, a raczej do tego, co znajduje się za nim.

  1. 73:   b8 00 40                        movw   $0x4000,%ax
  2. 76:   8e c0                           movw   %ax,%es
  3. 78:   66 31 db                        xorl   %ebx,%ebx

Te trzy instrukcje zajmują się inicjowaniem rejestrów do dalszych operacji. Rejestr segmentowy ES jest ustawiany na 0x4000, natomiast EBX jest całkowicie zerowany. W tej chwili operacje mogą sprawiać wrażenie mało ważnych, ale przyjmą na wartości w dalszej części kodu – ponieważ tutaj zaczyna się ładowanie kodu stage2 z innych sektorów na nośniku.

  1. 7b:   67 8a 0d 6e 01 00 00            addr32 movb 0x16e,%cl
  2. 82:   0f b6 c9                        movzbw %cl,%cx

Pierwsza instrukcja do rejestru CL kopiuje wartość, która znajduje się w pamięci pod adresem 0x16E z segmentu wskazywanego przez rejestr DS (czyli ustawione wcześniej 0x7c0, pod offsetem 0x62). Znajduje się tam liczba 7. Liczba ta stanie się ilością powtórzeń pętli, do której zaraz wejdzie program, o czym zaraz będzie można się dowiedzieć. Ciekawe jest użycie tej instrukcji przy wykorzystaniu prefiksu 0x67, który działa podobnie jak prefix 0x66 przy instrukcji ljmpl pod offsetem 0x3E – wydłuża ilość bajtów wskazujących na adres, do którego instrukcja ma się odwoływać. Sugerowałoby to kolejne miejsce, które jest patchowane przez instalator kodu MBR, a więc zapewne owa liczba 7 jest pewnym parametrem charakterystycznym dla aktualnego nośnika. Następnie, MOVZBW zeruje górną część rejestru CX; w tym przypadku instrukcja jest równoznaczna z MOV CH, 0.

  1. 85:   be 6f 01                        movw   $0x16f,%si

Do rejestru SI trafia liczba 0x16F.

  1. 88:   51                              pushw  %cx

Jak się okaże w późniejszej części, instrukcja odłożenia na stos CX rozpoczyna pętlę ładowania kolejnej części kodu MBR.

  1. 89:   b0 2e                           movb   $0x2e,%al
  2. 8b:   e8 af 00                        callw  0x13d

Znowu jest tutaj wywołanie funkcji spod adresu 0x13d, a wiadomo już, że ta funkcja wyświetla na ekranie znak z argumentu AL. W tym przypadku jest to kropka i zapewne jest ona wyświetlana na ekranie jako swojego rodzaju pasek postępu :).

  1. 8e:   fc                              cld    
  2. 8f:   ad                              lodsw  %ds:(%si),%ax
  3. 90:   89 c1                           movw   %ax,%cx
  4. 92:   ac                              lodsb  %ds:(%si),%al
  5. 93:   88 c6                           movb   %al,%dh
  6. 95:   ac                              lodsb  %ds:(%si),%al
  7. 96:   b4 02                           movb   $0x2,%ah
  8. 98:   50                              pushw  %ax
  9. 99:   cd 13                           int    $0x13
  10. 9b:   73 1a                           jae    0xb7

Kolejny dłuższy wycinek kodu rozpoczyna zerowanie flagi direction flag. Za tym znajdują się trzy wywołania instrukcji lods – jedna z sufiksem w, dwie kolejne z sufiksami b. lodsw odczytuje dwa bajty pamięci z DS:SI, zwiększając SI o 2, wynik zapisując w AX. Innymi słowy, w pierwszej iteracji, do AX ładowana jest zawartość pamięci spod adresu 07C0h:016Fh – jest to liczba 0x209. Następne wywołanie lodsb będzie odczytywało pamięć spod adresu 07C0h:0171h i odczyta bajt do adresu AL – czyli zero, który zostanie przeniesiony do rejestru DH. Kolejne wywołanie lodsb odczyta bajt z adresu 07C0h:0172h i zapisze wynik (liczbę 10) do rejestru AL. Offset 0x96 to instrukcja zapisująca wartość 2 do rejestru AH, następnie następuje zachowanie AX na stosie i wykonanie programowego przerwania numer 0x13. Zgodnie z listą przerwań Ralpha Browna, przerwanie programowe 0x13 przy wartości rejestru AH=2 oznacza odczyt z nośnika, przy wykorzystaniu adresowania CHS.

Tutaj wchodzi do gry mój komentarz, o translacji CHS na LBA. Jako, że posiadamy jedynie obraz dyskietki, mamy dwie opcje, aby dowiedzieć się, jakie dane są odczytywane. Pierwsza opcja to nagranie obrazu na dyskietkę i posłużenie się tą samą funkcją BIOS, lub odpowiednimi narzędziami, do odczytania danych ze sprecyzowanego miejsca. Druga opcja to przekształcenie adresowania CHS na LBA i odczytanie danych bezpośrednio z pliku. Druga opcja wbrew pozorom jest zdecydowanie szybsza, dlatego poświęcę na nią kilka słów.

Adresowanie CHS polega na lokalizowaniu danych na urządzeniu przy pomocy kilku argumentów: ścieżki, sektora, głowicy i urządzenia. Każdy sektor ma 512 bajtów, każda ścieżka ma 18 sektorów, każdy sektor może mieć dwie głowice (lub dwie strony). Programowe przerwanie 0x13, czyli funkcja BIOS, gdzie AH=2, oczekuje konkretnych wartości w różnych rejestrach. I tak, np. przy pierwszej iteracji pętli stan rejestrów prezentuje się w taki sposób:

AH ← 2, AL ← 10, CH ← 2, CL ← 9, DH ← 0, DL ← 0

AL jest to ilość sektorów do przeczytania (10), AH to numer funkcji (2), CL to numer sektora (9), CH to numer ścieżki (2), DH to numer głowicy (0), DL to numer urządzenia (0), natomiast ES:BX wskazuje na miejsce w pamięci, która ma otrzymać odczytane dane. Dla przypomnienia, rejestr ES zawiera 0x4000, natomiast BX jest wyzerowany. Ilość danych do odczytania w bajtach to iloczyn ilości sektorów do przeczytania (czyli rejestr AL przed wywołaniem int 13h) i liczby 512, bo każdy sektor ma 512 bajtów.

Natomiast LBA jest to po prostu numer, tak jak adres w pliku, adres w pamięci, numer do danych które chcą być przeczytane. Zamiana CHS na LBA następuje przy pomocy wzoru:

LBA ← (ścieżka * ilość głowic + głowica) * ilość sektorów + sektor – 1

Niektóre dane to stałe, za które w naszym przypadku można wstawić liczby:

LBA ← (ścieżka * 2 + głowica) * 18 + sektor - 1

Pozostałe trzy zmienne posiadamy, więc konwersja na LBA ogranicza się do wykonania kilku mnożeń i sumowań. Wynikiem będzie liczba, będąca adresem LBA. Offset w pliku można uzyskać mnożąc LBA przez 512, ponieważ 1 LBA = 512 bajtów. Wyciąganiem pliku stage2 zajmiemy się potem, teraz wróćmy do poprzedniego kodu, do offsetu 0x9b. Instrukcja warunkowego skoku JAE zostanie wykonana, gdy flaga C będzie zgaszona. Funkcja int 13h AH=2 ustawia flagę C w przypadku, gdy napotka błąd (np. bad sector na nośniku), co spowoduje wykonanie kodu:

  1. 9d:   66 be a8 00 00 00               movl   $0xa8,%esi
  2. a3:   e8 8a 00                        callw  0x130
  3. a6:   eb 3a                           jmp    0xe2
  4. […]
  5. e2:   fa                              cli    
  6. e3:   f4                              hlt    

Napis pod adresem 0xA8 to napis „Read error.” - wyświetlany w przypadku napotkania błędu działania funkcji 13h AH=2. Po wyświetleniu napisu następuje skok do offsetu 0xE2, wyłączenie obsługi przerwań i wykonanie instrukcji HLT. Instrukcja ta zatrzymuje działanie procesora aż do momentu w którym nie wystąpi jakieś przerwanie. Poprzednia instrukcja wyłącza większość przerwań (z wyjątkiem niemaskowalnych, jak wspomniałem wyżej), dlatego też HLT nigdy nie będzie przerwane. Kombinacja instrukcji CLI/HLT skutecznie „zawiesza” komputer, bo w przypadku, gdy nie można załadować MBR stage2 nie ma sensu kontynuować działania, bo co wtedy robić? ;) Problem z kombinacją takich instrukcji jest taki, że nie będzie można zrestartować komputera za pomocą Ctrl+Alt+Del. Aby ominąć tą niedogodność, należałoby ustawić tablicę IVT aby móc pobierać przerwania klawiatury i odpowiednio zareagować. Wtedy działająca w pętli instrukcja HLT pełniłaby rolę blokady procesora w czasie, gdy nie ma żadnego przerwania.

W przypadku gdy błąd odczytu nie nastapi, zostanie wykonany ten kod.

  1. b7:   58                              popw   %ax
  2. b8:   66 0f b6 c0                     movzbl %al,%eax
  3. bc:   c1 e0 09                        shlw   $0x9,%ax
  4. bf:   01 c3                           addw   %ax,%bx
  5. c1:   59                              popw   %cx
  6. c2:   e2 c4                           loopw  0x88

Najpierw zostaje odzyskane AX, zachowane w offsecie 0x98, następnie cały rejestr EAX zostaje wyzerowany z wyjątkiem zawartości AL. W EAX zostaje więc sam rozmiar danych (czyli liczba 10), które ostatni int 13h AH=2 odczytał z nośnika. Instrukcja shlw spod offsetu 0xBC jest w tym przypadku równoznaczna z mnożeniem przez 512; w tym przypadku rozmiar danych jest mnożony przez 512 aby obliczyć rozmiar bajtowy dziesięciu sektorów. W końcu rejestr BX jest zwiększany o tą liczbę. Następuje odzysk CX (zachowany pod offsetem 0x88) i wykonuje się instrukcja LOOPW, która zmniejsza CX o 1 i w przypadku, gdy nie jest zerem, skacze do offsetu 0x88.

Kolejna iteracja pętli odczyta kolejne dane z pamięci wskazywanej przez rejestr SI; te dane są parametrami CHS do odczytania kolejnych partii kodu stage2 z nośnika startowego. Jest razem 7 partii, które odczytywane są w pamięci jeden za drugim (po odczytaniu danych rejestr BX zwiększa się o odczytane dane; po zwiększeniu BX wskazuje na koniec danych, które zostały przed chwilą przeczytane. Kolejny odczyt umieści dane w miejscu końca poprzednich danych, i tak 6 razy). Po wyjęciu danych z SI i zinterpretowaniu ich, widać dokładnie, że stage2 ładowany jest z tych miejsc w nośniku:

  1. 0x209, 0, 10 – ścieżka 2, sektor 9, głowica 0, rozmiar 10,
  2. 0x201, 1, 18 – ścieżka 2, sektor 1, głowica 1, rozmiar 18,
  3. 0x301, 0, 18 – ścieżka 3, sektor 1, głowica 0, rozmiar 18,
  4. 0x301, 1, 18 – ścieżka 3, sektor 1, głowica 1, rozmiar 18,
  5. 0x401, 0, 18 – ścieżka 4, sektor 1, głowica 0, rozmiar 18,
  6. 0x401, 1, 14 – ścieżka 4, sektor 1, głowica 1, rozmiar 14.

Dla ułatwienia sobie pracy z CHS postanowiłem napisać szybki skrypt w Ruby, który obliczy mi wszystkie dane potrzebne do wyjęcia z pliku a.img odpowiedniej porcji danych będących kodem stage2 przy pomocy programu dd. Oto kod skryptu (lub też link do pliku):

Oto z kolei wynik jego działania:

  1. (1:514)$ ruby chs.rb
  2. track 2 sector 9 head 0 size 10 -- LBA 0080-0089 (offset 0x0000A000-0x0000B200)
  3. track 2 sector 1 head 1 size 18 -- LBA 0090-0107 (offset 0x0000B400-0x0000D600)
  4. track 3 sector 1 head 0 size 18 -- LBA 0108-0125 (offset 0x0000D800-0x0000FA00)
  5. track 3 sector 1 head 1 size 18 -- LBA 0126-0143 (offset 0x0000FC00-0x00011E00)
  6. track 4 sector 1 head 0 size 18 -- LBA 0144-0161 (offset 0x00012000-0x00014200)
  7. track 4 sector 1 head 1 size 14 -- LBA 0162-0175 (offset 0x00014400-0x00015E00)
  8. skip=80 count=96

Przekonwertowane offsety wykazują charakterystykę ciągłą, co by sugerowało, że metoda konwersji jest poprawna ;). Ostatnia linijka, skip=80 count=96 to argumenty programu dd, które pomogą wyciągnąc stage2 z a.img:

  1. (1:515)$ dd if=a.img of=stage2 skip=80 count=96
  2. 96+0 przeczytanych recordów
  3. 96+0 zapisanych recordów
  4. skopiowane 49152 bajty (49 kB), 0,0164003 s, 3,0 MB/s
  5.  
  6. (1:516)$ ls -la stage2
  7. -rw-r--r-- 1 antek antek 49152 2011-02-19 23:48 stage2

Plik stage2 ma 49 kilobajtów. Mamy więc plik stage2, ale kod stage1 nie jest jeszcze zakończony, dlatego wróćmy do kodu. Po zakończonej pętli, działanie dochodzi do tego momentu:

  1. c4:   66 be ed 00 00 00               movl   $0xed,%esi
  2. ca:   e8 63 00                        callw  0x130

Jest to wyświetlanie na ekranie pustej linii. Poprzednia pętla z każdą iteracją wyświetlała kropkę, która zapewne słuzyła w celach debugowych, gdyby stage1 napotkał błędny sektor podczas odczytu stage2. Ilość kropek na ekranie mówiłaby o tym, który sektor jest błędny. Stawiając znak końca linii po kropkach autorzy widocznie kierowali się estetyką ;).

  1. cd:   31 f6                           xorw   %si,%si
  2. cf:   fc                              cld    
  3. d0:   26 ad                           lodsw  %es:(%si),%ax
  4. d2:   26 ad                           lodsw  %es:(%si),%ax
  5. d4:   3d 01 0b                        cmpw   $0xb01,%ax
  6. d7:   74 17                           je     0xf0

Następuje zerowanie rejestru SI, zerowanie flagi direction flag (aby można było bez problemu używać instrukcji pokroju LODS) i nastepuje odczyt czterech bajtów z adresu ES:SI, czyli z początku danych stage2: najpierw dwóch do rejestru AX, potem znowu dwóch, znowu do rejestru AX. Skutek tego jest taki, że pierwsze dwa bajty zostają pominięte, a w rejestrze AX znajdują się kolejne dwa bajty, czyli trzeci i czwarty. Bajty te porównywane są z wartością 0x10B. Jeśi wartość ta jest inna, następuje przejście do offsetu 0xD9, gdzie znajduje się kod wyświetlający na ekranie „Bad magic.”, po czym kombinacja instrukcji CLI/HLT zawiesza komputer:

  1. d9:   66 be e4 00 00 00               movl   $0xe4,%esi
  2. df:   e8 4e 00                        callw  0x130
  3. e2:   fa                              cli    
  4. e3:   f4                              hlt    

Jest to krótkie i prymitywne sprawdzanie integralności stage2, aby nie załadować nieznanych danych. Krótkie, bo złożone tylko z jednego sprawdzenia, i prymitywne, ponieważ nie ma gwarancji, że dalsza część danych jest równie poprawna co sam początek. Lepsze byłoby sprawdzenie sumy kontrolnej wszystkich odczytanych danych, ale z drugiej strony każdy kod ładujący MBR można napisać na milion różnych sposobów ;) (choć te cechy charakterystyczne dla danej architektury muszą oczywiście zostać te same).

  1. f0:   fa                              cli    

Offset 0xF0 jest osiągany, gdy magiczna wartość 0x130 znajduje się na swoim miejscu. Można łatwo sprawdzić, czy posiadamy właściwy stage2:

  1. (1:517)$ cat stage2 | xxd | head -n 1
  2. 0000000: 0086 010b 00b0 0000 0020 0000 f803 0000  ......... ......

Bajt trzeci i czwarty rzeczywiście buduje wartość 0x10b, więc wygląda na to, że plik jest prawidłowy. Wracając do instrukcji CLI – wyłącza ona obsługę przerwań. Jako, że za chwilę znajdą się instrukcje przełączające tryb na tryb chroniony, należy upewnić się, że proces nie zostanie przerwany w połowie i procesor nie znajdzie się w nieokreślonym stanie. Bez tej instrukcji prawdopodobnie proces bootowania byłby w pewnym sensie grą w rosyjską ruletkę, raz komputer wstanie, raz będzie trupem, aż do wykonania resetu oczywiście ;).

  1. f1:   67 66 0f 01 15 68 01 00 00      addr32 lgdtl 0x168

Wywoływana jest instrukcja LGDTL. Instrukcja ta zaopartuje wewnętrzny rejestr procesora GDTR w adres do struktury nazywanej GDT. Jest to struktura wykorzystywana w trybie chronionym, zawierająca definicje (adresy i ich rozmiary) regionów pamięci, które tworzą segmenty trybu chronionego. Te z kolei są nieco wyższą formą segmentacji pamięci niż segmenty trybu rzeczywistego, oferującymi lepsze metody izolacji pamięci i precyzowanie stopnia uprzywilejowania kodu wykonywanego w danym segmencie (czyli pojawia się tutaj podział na pierścienie; pierścień zerowy dla jądra, pierścień trzeci dla aplikacji). Opis jest dość krótki, na chwilę obecną, ponieważ temat GDT jest nieco szerszy, aby zmieścił się w ramach tego posta. Planuję opisać GDT jak i siostrzane struktury, LDT i IDT, kolejnym razem, aby nieco szerzej opisać sposób działania trybu chronionego. W chwili obecnej ciekawe jest wykorzystanie instrukcji charakterystycznej dla trybu chronionego, w trybie rzeczywistym. Okazuje się, że rejestr GDTR nie jest zablokowany przez procesor, gdy znajduje się w trybie rzeczywistym, tylko jest po prostu niewykorzystany, dlatego możliwe jest zapisanie do niego odpowiednich danych, przygotowując jego zawartość na przełączenie trybu.

Adres 0x168 wskazuje na strukturę, która jest złożeniem adresu bazowego konkretnej struktury GDT i jej rozmiaru (jest to struktura GDTR procesora w trybie IA-32; podczas trybu IA-32e, lub Long Mode w AMD, zmienna 'base' staje się zmienną 64-bitową, uint64_t):

  1. struct gdtr32_t {
  2. uint16_t size;
  3. uint32_t base; /* lub uint64_t w IA-32e/Long mode */
  4. }

Pytanie brzmi dlaczego wykorzystywana jest struktura trybu chronionego IA-32, skoro nie jesteśmy w tym trybie, zamiast tego jesteśmy w trybie rzeczywistym? Odpowiedzią jest prefiks 0x67, który pozwala na zwiększenie rozmiaru adresowania argumentu instrukcji do 32-bitów (poczas pracy w kodzie 16-bitowym; gdyby użyć tego prefiksu na instrukcji podczas pracy w kodzie 32-bitowym, prefiks zmniejszyłby tryb adresowania na 16-bitowy), jak też i prefiks 0x66, który zmienia „długość” instrukcji z 16-bitów na 32-bity.

Są to więc te dane 18 00 50 7d 00 00 spod offsetu 0x168:

  1. (1:519)$ cat stage1 | xxd | grep 00001[67]0
  2. 0000160: ffff 0000 0093 cf00 1800 507d 0000 0709  ..........P}....
  3. 0000170: 0200 0a01 0201 1201 0300 1201 0301 1201  ................

Zgodnie z wyglądem struktury powyżej, pierwsze dwa bajty do wielkość tablicy GDT, kolejne 4 bajty to wskaźnik do struktury GDT. Zgodnie z notacją little-endian, bajty zostają zapisane do wartości w sposób odwrotny do tego, jak wyglądają w pamięci, dlatego też do rozmiaru wskakuje liczba 0x18, a do bazy adresu adrse 0000h:7D50h. Po konwersji tego adresu na coś bardziej kompatybilnego ze środowiskiem, z którego odczytujemy struktury danych (plik), czyli na segment 0x07C0, otrzymujemy adres 07C0h:0150h:

  1. (1:521)$ printf "%X" $((0x7d50-0x7c00))
  2. 150

Pod tym offsetem w pliku znajduje się struktura tablicy GDT, którą możemy podejrzeć:

  1. realmode:0150                             ; GDT
  2. realmode:0150
  3. realmode:0150                             ; Selektor 0x0000 - pusty wpis, zgodnie z wymogiem architektury
  4. realmode:0150
  5. realmode:0150 00 00 00 00 00 00 00 00     dq 0
  6. realmode:0158
  7. realmode:0158                             ; Selektor 0x0008 - segment kodu
  8. realmode:0158
  9. realmode:0158 FF FF                       dw 0FFFFh               ; Niski limit
  10. realmode:015A 00 00                       dw 0                    ; Niska baza adresu
  11. realmode:015C 00                          db    0                 ; Srodkowa baza adresu
  12. realmode:015D 9F                          db  9Fh ;               ; RW+Execute, ring 0, dostepny
  13. realmode:015E CF                          db 0CFh ;               ; Wysoki limit, 32bit, granularity 4kb
  14. realmode:015F 00                          db    0                 ; Wysoka baza adresu
  15. realmode:0160
  16. realmode:0160                             ; Selektor 0x0010 – segment danych
  17. realmode:0160
  18. realmode:0160 FF FF                       dw 0FFFFh               ; Niski limit
  19. realmode:0162 00 00                       dw 0                    ; Niska baza adresu
  20. realmode:0164 00                          db    0                 ; Srodkowa baza adresu
  21. realmode:0165 93                          db  93h ;               ; RW, ring 0, dostepny
  22. realmode:0166 CF                          db 0CFh ;               ; Wysoki limit, 32bit, granularity 4kb
  23. realmode:0167 00                          db    0                 ; Wysoka baza adresu

Mamy więc tutaj ustawione dwa segmenty (nie licząc segmentu o selektorze zerowym, który musi być wyzerowany i nie można się do niego odnieść): segment o selektorze 0x0008 i 0x0010. Ten pierwszy posiada charakterystykę trybu 32-bitowego kodu o DPL (descriptor privillege level) równym 0, co oznacza kod najbardziej uprzywilejowany – ring 0 – oraz prawa oznaczone flagami: do odczytu, zapisu i do wykonania. Jako, że istnieje flaga „do wykonania”, sugeruje to segment który będzie pozwalał na wykonywanie kodu, dlatego został przeze mnie oznaczony jako „segment kodu”. Kolejny segment posiada prawie identyczne wartości (nawet bazy adresowe), z wyjątkiem praw dostępu – tutaj nie ma już praw „do wykonania”, jedynie są „do odczytu” i „do zapisu”. To sugeruje segment danych, i tak też został przeze mnie oznaczony w komentarzu. Skoro wiadomo już jak wygląda tablica GDT, możemy wrócić do kodu MBR.

Ustawienie rejestru GDTR na wskaźnik do prawidłowej struktury GDT jest jedną z rzeczy, które należy zrobić, aby poprawnie przełączyć tryb działania procesora do trybu chronionego. Zwykle kod ładujący (typu właśnie kod MBR, stage którykolwiek), oddzielny moduł rozruchowy systemu operacyjnego, czy nawet sam kernel, mają w konwencji podczas swojego uruchamiania (lub działania) budować wiele tablic GDT, precyzując przy tym wiele różnych konfiguracji segmentów. Pierwsze struktury GDT nazywane są wtedy tymczasowymi strukturami, które ustępują w późniejszym czasie tym głównym, zbudowanym do pracy przy nastawieniu się na inny wymóg pracy kernela – z trybu ładującego na tryb użytkowy.

  1. fa:   0f 20 c0                        movl   %cr0,%eax
  2. fd:   66 83 c8 01                     orl    $0x1,%eax
  3. 101:   0f 22 c0                        movl   %eax,%cr0
  4. 104:   66 ea 0c 7d 00 00 08 00         ljmpl  $0x8,$0x7d0c

Powyższy kod ustawia bit 1 rejestru kontrolnego, CR0, na 1. Rejestry kontrolne to standardowy mechanizm kontroli wielu aspektów działania procesora w trybie chronionym i innych trybach (np. trybie długim, czyli 64bit). Bit pierwszy w rejestrze CR0 oznacza włączenie trybu chronionego. Długi skok zaraz po uaktualnieniu rejestru CR0 służy do trzech rzeczy: zmiany trybu adresowania (zamiast rejestru IP będzie wykorzystany już rejestr EIP), zaktualizowania rejestru CS zmieniając istniejący tam do tej pory segment 0x07C0 na selektor 0x0008, który od momentu skoku będzie indeksem do deskryptora w tablicy GDT, oraz do odświeżenia kolejki prefetch, do tej pory obładowanej instrukcjami real-mode. Nie można użyć tutaj drugiego selektora, 0x0010, ponieważ nie udostępnia on praw wykonywania kodu w swoim kontekście, z tego powodu wybrany jest selektor 0x0008.

Wykonując skok pod 0008h:00007D0Ch zostaniemy przełączeni w tryb 32-bitowego adresowania instrukcji. Wielkość adresu przestanie być reprezentowana przez 16-bitowe wartości segment:przemieszczenie, będzie za to adresowana jako 16-bitowy selektor i 32-bitowy adres. Tryb instrukcji również uległ zmianie – poprzednio każda instrukcja była wywoływana w kontekście 16-bitowym, a teraz – zgodnie z odpowiednią flagą „32bit” w charakterystyce selektora 0x0008, będzie to w końcu kod 32-bitowy. Zmienią się więc też funkcjonalności prefiksów 0x66 i 0x67, których kod MBR wykorzystuje kilka razy w swojej logice, tzn. wcześniej zamieniały one adresowanie 16-bit na 32-bit, a teraz – jeśli będą użyte – zmienią adresowanie 32-bit na 16-bit. Można więc wrócić do kodu, jednak trzeba uruchomić nową deasemblację (lub utworzyć nowy 32-bitowy segment, jeśli korzysta się z programu IDA), która będzie deasemblowała kod w trybie 32-bit, nie 16-bit, jak do tej pory.

Skok następuje do adresu 0x7D0C. Aby zamienić ten offset na offset w pliku, należy wyobrazić sobie, że plik załadowany jest pod offset 0x7C00. Wtedy wystarczy prosta operacja odejmowania, by przekonać się o tym, że offset w pliku odpowiadający offsetowi 0x7D0C to 0x10C.

  1. (1:524)$ objdump -D -b binary -mi386 -Maddr32,data32,suffix --insn-width 10 stage1
  2. […]
  3. 10c:   b8 10 00 00 00                  movl   $0x10,%eax
  4. 111:   8e d8                           movl   %eax,%ds
  5. 113:   8e d0                           movl   %eax,%ss
  6. 115:   8e c0                           movl   %eax,%es
  7. 117:   8e e0                           movl   %eax,%fs
  8. 119:   8e e8                           movl   %eax,%gs

Powyższy kod ustawia selektor 0x0010 do wszystkich rejestrów segmentowych (z wyjątkiem CS, który ma już 0x0008): DS, SS, ES, FS i GS. Rejestr segmentowy SS może być aktualizowany bez obaw o występujące przerwanie zaraz po jego modyfikacji, a przed modyfikacją ESP, ponieważ offset 0xF0 nieco wcześniej ułatwia nam zadanie wyłączając obsługę przerwań.

  1. 11b:   bc fc ff 00 00                  movl   $0xfffc,%esp
  2. 120:   0f b6 c2                        movzbl %dl,%eax
  3. 123:   50                              pushl  %eax
  4. 124:   68 0d d0 01 c0                  pushl  $0xc001d00d
  5. 129:   ea 20 00 04 00 08 00            ljmpl  $0x8,$0x40020

Ustalenie adresu stosu, przeniesienie wartości rejestru DL do EAX (rejestr DL przez cały ten czas zawiera informacje o aktualnym napędzie, z którego został uruchomiony kod MBR), ułożenie na stosie rejestrów EAX i wartości 0xC001D00D :), która zapewne pełni rolę jakiejś magicznej wartości sprawdzanej przez stage2, być może po to, by upewnić się, że załadował ją dokładnie ten kod MBR, a nie jakiś generyczny. Następnie zostaje wykonany skok do adresu 0x40020 selektora 0x0008 (czyli nadal kontekst kodu wykonywalnego 32bit, ring 0), czyli do załadowanego wcześniej stage2. Wcześniej ładowanie odbywało się do pamięci o adresie trybu rzeczywistego 4000h:0000h, co można przetłumaczyć na adres liniowy pamięci płaskiej 0x40000 (segment << 4 + offset → 4000h << 4 + 0000h = 40000h), co by znaczyło, że kod zostaje przeniesiony pod offset 0x20 do stage2.

Ile tekstu można napisać na temat mniej niż 512 bajtów? :) Zapewniam, że nie ma tu wszystkich informacji, które można znaleźć jedynie wtedy, gdy się tego bardzo chce, czyli np. czytając manuale Intela lub AMD, których linki podane są na samym końcu posta (czyli za chwilę). Kod stage2 ma prawie 50 kilobajtów, więc można sobie wyobrazić, by opisać go na takim samym poziomie szczegółów co kod stage1, trzeba by napisać dość grubą książkę, na co niestety nie mam aż tyle czasu ;). Zerknijmy jednak na sam początek kodu stage2, pod adres 0x40020 (czyli w pliku 0x20):

  1. 20:       e8 eb 5b 00 00                  calll  0x5c10

Jako pierwsza rzecz, zostaje wykonana funkcja 0x5C10. Sprawdźmy więc, co robi:

  1. 5c10:       0f 01 15 f8 5e 04 00            lgdtl  0x45ef8
  2. 5c17:       ea 1e 5c 04 00 08 00            ljmpl  $0x8,$0x45c1e
  3. 5c1e:       b8 10 00 00 00                  movl   $0x10,%eax
  4. 5c23:       8e d8                           movl   %eax,%ds
  5. 5c25:       8e d0                           movl   %eax,%ss
  6. 5c27:       8e c0                           movl   %eax,%es
  7. 5c29:       8e e0                           movl   %eax,%fs
  8. 5c2b:       8e e8                           movl   %eax,%gs
  9. 5c2d:       0f 01 1d b8 5e 04 00            lidtl  0x45eb8
  10. 5c34:       c3                              retl  

Wynika z tego, że ustawia nowy GDT za pomocą lgdtl, wykonuje daleki skok, który trzeba wykonać po ustawieniu nowego GDT (by wyczyścić cache dotyczącego różnych aspektów różnych prac procesora – ten skok skacze do adresu 0x5C1E, czyli do następnej instrukcji), ustawia selektor 0x10 do rejestrów segmentowych oprócz CS (ponieważ CS ustawiony jest przy skoku z offsetu 0x5C17) i używa instrukcji lidtl, aby załadować do rejestru IDTR adres, który wskazuje na tablicę IDT. Tablica IDT jest dość podobna do GDT, jej funkcja jest jednak zupełnie inna – jest to tablica służąca do tego samego co IVT w trybie rzeczywistym, jednak działa ona w trybie chronionym. Zajmuje się więc zapisywaniem informacji o funkcjach, które będą reagować na przerwania.

Znajdujemy się teraz w kodzie, który może nie mieć ustawionej tablicy przerwań, ponieważ instrukcja CLI spod offsetu 0xF0 ze stage1 zaopatrzyła początek kodu stage2 w luksus wyłączonej obsługi przerwań. Niebawem zapewne przerwania zostaną włączone ponownie, skoro stan trybu chronionego jest w większości już zainicjowany poprawnie. Brakuje tylko ustawienia aktualnego Task'a i inicjacji rejestru TR (Task Register), i włączenia stronicowania w kodzie, który znajduje się gdzieś w głębinach kodu stage2 ;).

  1. 25:       58                              popl   %eax
  2. 26:       3d 0d d0 01 c0                  cmpl   $0xc001d00d,%eax
  3. 2b:       74 00                           je     0x2d

Z ciekawości co robi liczba 0xCOO1D00D dołączam też ten kod tutaj, który – jak się okazuje – nic nie robi ;), ponieważ sprawdza występowanie tego argumentu, a zaraz potem wykonuje skok do następnej instrukcji. Stąd prosty wniosek jest taki, że wartość ta prawdopodobnie znaczy coś w kompilacjach debug kodu stage2, który być może uaktywnia pewne powolne, ale przydatne dla debugowania podsystemy, lub wypisuje na ekranie jakieś informacje. Jako, że kod który analizuję teraz jest skompilowany w postaci RELEASE, nie DEBUG (ponieważ jest to normalny kod normalnej dystrybucji, faktem jest że starej, ale nawet w 1998 roku wiedzieli na czym polega różnica pomiędzy RELEASE a DEBUG :>), kod prawdopodobnie nie został wkompilowany, więc trzeba obejść się bez zjedzenia tej intelektualnej czekolady :P.

  1. 2d:       31 c0                           xorl   %eax,%eax
  2. 2f:       b9 f8 d3 04 00                  movl   $0x4d3f8,%ecx
  3. 34:       81 e9 ec c2 04 00               subl   $0x4c2ec,%ecx
  4. 3a:       bf ec c2 04 00                  movl   $0x4c2ec,%edi

Kolejne instrukcje już pominę, aby nie wgłębiać się zbytnio w ten kod. Procesor działa już w trybie chronionym, a to, co zostało do zrobienia opiszę w kolejnym poście na podobny temat za jakiś czas. Nie jest to krytyczna funkcjonalność, ponieważ zawsze zostanie coś do zrobienia póki kernel nie zostanie całkowicie uruchomiony, co może czasem zając tysiące linii kodu ;), więc wystarczy napisać, że podstawę przełączania trybu chronionego mamy już za sobą. Teraz należałoby włączyć obsługę Tasków, stronicowania i być może zacząć inicjowanie kolejnych core'ów w procesorze, by mieć potem podstawę do rozpoczęcia inicjacji schedulera procesów, wykonywania context switchów, etc. ;)

Polecane materiały: AMD64 Architecture Programmer's Manual Volume 2: System Programming, Intel® 64 and IA-32 Architectures Software Developer's Manual Volume 3A: System Programming Guide, Part 1 & Part 2, OSDEV wiki, źródła bootloaderów, np. GRUB, lub innych (nawet tego który masz aktualnie zainstalowany w sektorze 0), Ralph Brown Interrupt List, i Google lub Bing ;)