Tryb chroniony, tryb Virtual 8086

Na każdego przyjdzie pora

Pisałem już o trybie rzeczywistym. Przyszła więc pora napisać o trybie chronionym (ang. protected mode, nazywany też w skrócie pmode) i o powiązanym quasi-trybie Virtual 8086. Nie muszę chyba kolejny raz wspominać, iż tryb chroniony jest najważniejszym i najpowszechniejszym trybem architektury x86. Warto zatem wiedzieć, co tak naprawdę nam oferuje.

Historia

Tryb chroniony został wprowadzony w 1982 roku wraz z procesorem Intel 80286. Niestety nie został on przyjęty zbyt ciepło, głównie z powodu braku wsparcia aplikacji napisanych dla trybu rzeczywistego. Większość programistów postanowiła pozostać więc w trybie rzeczywistym, dostępnym bezpośrednio po uruchomieniu jednostki 80286 i nie włączać trybu chronionego. Architekci Intela wyciągnęli wnioski z tego zdarzenia i sytuacja uległa zmianie w 1985, gdy na rynek wszedł procesor Intel 80386, wnoszący wiele udogodnień, w tym możliwość powrotu z trybu chronionego do trybu rzeczywistego. Od tej pory tryb chroniony króluje we wszystkich systemach operacyjnych na platformę x86.

Ochrona

Nazwa trybu chronionego wzięła się z faktu, iż służy on szeroko rozumianej ochronie :). O ogólnym zarysie zdarzyło mi się już pisać. Każdy z mechanizmów opisanych poniżej podlega owej ochronie i jest to absolutnie pozytywna cecha. Zdecydowanie najważniejszą cechą trybu chronionego jest to, że pozwala on pisać bezpieczne systemy operacyjne – takie, w których można dokładnie zdefiniować prawa dostępu dla użytkownika, poza które nie może wykraczać. Można zadać pytanie – z jakiego powodu systemy operacyjne posiadają wciąż tyle błędów, skoro udostępnione mechanizmy są poprawne? Odpowiedź na to pytanie jest prosta – są to błędy programistów, którzy owych mechanizmów korzystają w sposób niepoprawny. Tryb chroniony jest dopracowanym i przemyślanym przez lata mechanizmem sprzętowym – wykorzystuj go dobrze! :)

Dostępne mechanizmy

To co możliwe jest w trybie rzeczywistym wyszczególniłem w swoim artykule. Wszystko pozostałe dostępne jest wyłącznie z poziomu trybu chronionego. Mechanizmy, o których zdecydowanie warto pamiętać to:

  • Segmentacja
  • Stronicowanie
  • Możliwość powrotu do trybu rzeczywistego oraz tryb V8086
  • Fizyczna i wirtualna 4 gigowa przestrzeń adresowa – jeśli posiadamy 4GiB RAM-u – możemy korzystać z niego do woli! Jeśli posiadamy trochę mniej, możemy zbudować pewien poziom abstrakcji „udający” owe 4GiB i to per proces!
  • Wielozadaniowość (z wywłaszczaniem) – możliwość współbieżnego uruchamiania aplikacji, wspierana sprzętowo, możliwa do realizacji programowo.
  • Rozbudowana obsługa wszelakich wyjątków – poprzez IDT możliwa jest obsługa wszystkich rodzjaów wyjątków: przerwań, błędów oraz pułapek.
  • Możliwość korzystania z dobrodziejstw takich jak: wieloprocesorowość, kontrola cache’u, kontrola stanu procesora, w tym zarządzanie energią czy debuggowanie, dostęp do jednostek specjalnych takich jak MMX czy SSE, wydajny dostęp do sprzętu.
  • I sporo innych ;).

To czego natomiast nie mamy, to dostęp do przerwań BIOS-u. Cały sprzęt musimy oprogramować sami.

Virtual 8086

Quasi-tryb V8086, to już raczej przeszłość. Umożliwia on uruchamianie aplikacji 16 bitowych w trybie zgodności i co istotne, działają one współbieżne z innymi aplikacjami. Niestety zgodność nie jest stuprocentowa – programy uruchomione w V8086 nie mogą korzystać z instrukcji systemowych, nie mogą manipulować segmentami, używać bezpośrednio sprzętu i uruchamiać kodu samomodyfikującego się. To, do czego może się przydać V8086 to wywoływanie funkcji BIOS-u, np. zmiana trybu graficznego. Używając tego trybu należy jednak uważać – ma on dostęp do pierwszego MiB pamięci, gdzie często znajduje się kernel (a nie powinien).

Gdzie i jak?

Wejście do trybu chronionego jest proste i wymaga ustawienia odpowiedniego bitu w rejestrze CR0 – dokładniej opiszę to w następnym poście. Wyjście natomiast, nie jest już takie proste. Można o tym przeczytać tu.

Z Virtual 8086 jest jeszcze trudniej, całościowe ujęcie tematu tutaj.

3..2..1..START!

Teraz wiemy już wszystko co potrzebne nam do przejścia i oprogramowania trybu chronionego. A więc do dzieła! Wszystko to w najbliższym odcinku!

GDT

Czy to konieczne?

Do czego służy struktura GDT w systemie operacyjnym, pisałem już wcześniej. Przed przejściem do trybu chronionego, zdecydowanie przyda się nam zdefiniowanie owej tablicy ;) Dziś napiszę o budowie deskryptorów w niej zawartych.

Deskryptor

Deskryptor składa się z 64 bitów i ma dość skomplikowaną budowę. Powyżej widać jego zarysową strukturę. Teraz przyjrzyjmy się poszczególnym elementom:

  • Segment limit – długość segmentu, wyrażona w wielokrotności granularności (patrz poniżej). Limit służy w naturalny sposób ochronie. Warto zauważyć, że poszczególne bity znajdują się w dwóch miejscach struktury.
  • Base address – rozrzucone po strukturze pola składają się na adres bazowy (procesor składa je w jeden, 32 bitowy adres), czyli adres pierwszego bajta segmentu. Adres bazowy powinien być wyrównany do 16 bajtów w celu maksymalizacji wydajności.
  • Type – definiuje własności segmentu (czy do odczytu, zapisu itp.) w zależności czy segment ten jest segmentem systemowym (LDT, TSS, call-gate, interrupt-gate, trap-gate, task-gate), czy segmentem kodu/danych.  Ustawienia poszczególnych pól można znaleźć w tabelkach w podręcznikach Intela. W pewien sposób są to magiczne wartości, których sensu omawiać nie ma.
  • S flag – gdy S jest ustawione, to mamy do czynienia z segmentem kodu/danych. W przeciwnym razie jest to segment systemowy.
  • DPL – poziom uprzywilejowania, potrzebny do dostępu do segmentu. 0 to poziom najbardziej uprzywilejowany, 3 najmniej – zgodnie z tym, co opisałem tu.
  • P flag – stanowi o tym, czy segment jest ważny (ang. present). Nie można uzyskać dostępu do segmentu z wyczyszczoną flagą P – powoduje to wyjątek #NP. Pozwala na kontrolowanie załadowanych do pamięci segmentów.
  • D/B flag – mówiąc najkrócej, 1 oznacza segment 32 bitowy, a 0 – 16 bitowy. Dociekliwi mogą przeczytać więcej na ten temat w podręczniku Intela.
  • G flag – definiuje granularność – gdy flaga nieustawiona, limit wyrażony jest w 1 bajtowych blokach (segment ma maksymalnie 1 MiB), gdy ustawiona, limit wyrażony jest w jednostkach po 4 KiB (segment ma maksymalnie 4 GiB – w taki sposób uzyskuje się właśnie płaski model pamięci)
  • L flag – gdy flaga ustawiona, stanowi o tym, iż segment zawiera kod wykonywalny 64 bitowy. W przeciwnym wypadku mamy do czynienia z kodem 32 bitowym, wykonywanym w trybie zgodności. Gdy flaga L jest ustawiona, flaga D musi być nieustawiona.
  • AVL – bit nieużywany, dostępny dla oprogramowania systemowego

Ładowanie

Do załadowania GDT służy instrukcja lgdt. Instrukcja oczekuje pewnej struktury, która składa się z dwubajtowego rozmiaru tablicy pomniejszonego o 1 oraz czterobajtowego adresu samej tablicy.

Na koniec

Wszystko to może wyglądać na dość skomplikowane. W części 0x03 serii tworzenie systemu operacyjnego zobaczymy jednak, że nie taki diabeł straszny. Warto się już teraz zaprzyjaźnić z deskryptorami, gdyż są one bardzo typowym elementem architektury x86, z którym spotkamy się jeszcze wielokrotnie np. przy okazji wektora przerwań.

Ogólne spojrzenie na architekturę x86

Złożoność

Architektura x86 dojrzewała przez lata. Inżynierowie przez cały czas starali się zachować zgodność wsteczną, wysoką przystosowalność do potrzeb programistów oraz oczywiście dodawać nowe i ulepszać stare funkcje. Z tych powodów IA-32 jest dosyć skomplikowana. Nim zanurzymy się w pełen dobrodziejstw tryb chroniony, warto poznać podstawowe elementy architektoniczne, które będziemy oprogramowywali :)

W tym celu posłużymy się schematem dostępnym w polecanych przeze mnie podręcznikach Intela:

GDT

Znajdująca się w centralnym punkcie obrazka struktura danych zwana Global Descriptor Table (GDT) jest „sercem” architektury x86. Zawiera ona deskryptory segmentów (struktury opisujące segmenty), na które podzielona jest pamięć. Tak jak wspominałem, segmentacja jest zawsze obecna w architekturze x86. Oprócz „zwykłych” segmentów w pamięci, zawierających dane czy kod, w GDT znajdują się również deskryptory TSS oraz LDT. Warto jednak wspomnieć, że bitowo patrząc, wszystkie deskryptory mają taką samą strukturę. Z każdym deskryptorem skojarzony jest odpowiedni selektor, czyli przesunięcie w tablicy GDT wskazujące na dany deskryptor, lokalność/globalność, czyli to, czy mamy do czynienia z wpisem GDT czy LDT oraz prawa dostępu. Adres (liniowy) tablicy GDT, wraz z limitem (długością) znajduje się w rejestrze GDTR.

Odwołując się w jakikolwiek sposób do pamięci, musimy więc podać selektor segmentu oraz przesunięcie wewnątrzsegmentowe. Z selektora pobierany jest numer deskryptora, a z deskryptora następnie adres bazowy, do którego następnie dodawane jest przesunięcie dając adres liniowy – w taki sposób następuje odwołanie do elementu w pamięci.

LDT

Local Descriptor Table (LDT) jest siostrzaną tablicą do GDT. Aktualnie jest już prawie nieużywana. Jej użycie jest istotne w systemach ze starymi procesorami np. 80826, w których nie było jednostki stronicowania – a więc nie było możliwości realizowania separacji, tym samym ochrony pamięci poszczególnych procesów. Aktualnie LDT używane jest wyłącznie do uruchomiania 16 bitowych programów. Niegdyś, każdy proces użytkownika, posiadał własną tablicę LDT, której pozycja znajdowała się w rejestrze LDTR. LDT oferowała ochronę pamięci polegającą na możliwości dostępu tylko przez deskryptory znajdujące się w tejże tablicy. Kernel oraz współdzielona pamięć opisana była w GDT.

TSS

Task State Segment (TSS) to segment opisujący zadanie (ang. task). Używany jest głównie przy sprzętowej wielozadaniowości, w której z każdym procesem skojarzony jest pojedynczy TSS. W strukturze TSS znajdują się wszystkie informacje potrzebne do „odtworzenia” zadania – rejestry ogólnego przeznaczenia i te bardziej szczególne, takie jak EIP. W przypadku programowej obsługi wielozadaniowości potrzebna tylko jednego lub dwóch segmentów TSS. Selektor segmentu TSS jest trzymany w rejestrze TR – Task Register.

IDT

Interrupt Descriptor Table (IDT), to trzecia, po GDT i LDT tablica systemowa. Wpisy w tej tablicy nazywamy bramkami (ang. gates) i mogą być to bramki: przerwań (interrupt), zadań (task) oraz pułapek (trap).  Powstrzymam się z omawianiem konkretnych bramek – zrobię to przy okazji implementowania IDT. Na tę chwilę powiem, że wpisy w IDT służą wywołaniu odpowiedniej procedury obsługi konkretnego zdarzenia, np. przerwania, pułapki. Adres liniowy IDT znajduje się w rejestrze IDTR.

Stronicowanie

Na obrazku, stronicowanie (ang. paging) przedstawione jest na dole (dla pamięci „płaskiej”, rozmiaru strony 4KiB). Jest to bardzo istotny element architektury x86, o którym napiszę więcej w przyszłości. Na chwilę obecną przedstawię ogólną ideę działania jednostki stronicowania. Po przejściu przez wszystkie zawiłości związane z GDT „na wyjściu” otrzymujemy adres liniowy zbudowany, tak jak wspomniałem, z adresu bazowego znajdującego się w deskryptorze segmentu i przesunięcia wewnątrzsegmentowego. Gdy jednostka stronicowania jest wyłączona, adres ten jest bezpośrednim adresem w pamięci liniowej. W przeciwnym wypadku, adres interpretowany jest jako wirtualny. W adresie tym można wydzielić 3 części: dir, table oraz offset. Dir wskazuje wpis w katalogu stron, który z kolei wskazuje na odpowiednią tablicę. Następnie table wskazuje wpis w tablicy stron, który wskazuje konkretną stronę. Offset to przesunięcie w tej konkretnej stronie, które jest już elementem, który chcemy pobrać/zapisać. Jak widać, pojedynczy dostęp do pamięci, np. mov [eax], 42 nie oznacza fizycznie jednego dostępu do pamięci, ale sporo więcej. Stronicowanie ma więc istotny wpływ na wydajność systemu operacyjnego, dając nam jednak elegancką ochronę pamięci.

Rejestry systemowe

Oprócz wspomnianych wyżej, architektura x86 posiada 5 rejestrów kontrolnych (CR0, CR1, CR2, CR3, CR4, XCR0), rejestr flag EFLAGS, rejestry debuggowania oraz rejestry specyficzne dla konkretnej platformy. Myślę, że nie ma co opisywać ich zawiłych funkcji – zrobię to przy okazji używania ich.

Podsumowanie

Jak widać, architektura x86 jest dosyć bizantyjska. Posiada wiele funkcjonalności, których aktualnie się nie używa, które jednak znalazły swoje zastosowanie w przeszłości. Jest z tego powodu bardzo ciekawa z punktu widzenia tworzenia systemów operacyjnych – można tworzyć bardzo skomplikowane twory, całkowicie zgodne z wizją programisty, łącząc old school z tym co trendy ;).

O wspomnianych wyżej elementach architektury x86 będę jeszcze dokładniej pisał przy okazji ich używania. Na pierwszy ogień pójdzie GDT.

Ogólna koncepcja ochrony w x86

Pierścienie

Tryb chroniony, jak sama nazwa wskazuje, ma oferować ochronę. W związku z tym, do architektury x86 wprowadzono pewien podział – na poziomy uprzywilejowania. Poziomów tych jest 4 i zwyczajowo nazywa się je ring 0, ring 1, ring 2 i ring 3, gdzie ring 0 to poziom najbardziej uprzywilejowany, a ring 3, to poziom najmniej uprzywilejowany.

Architektura nie narzuca w żaden sposób, który poziom za co ma odpowiadać – wiadomo tylko, że z danego poziomu mamy dostęp do elementów z poziomów o wyższych numerach (i oczywiście do elementów z danego poziomu). Przykładowo role można podzielić tak:

  • Ring 0 – jądro systemu
  • Ring 1 – wywołania systemowe
  • Ring 2 – biblioteki współdzielone
  • Ring 3 – aplikacje użytkownika

W praktyce często używa się tylko dwóch poziomów – dla jądra i użytkownika.

Zakończenie

Z poziomami ochrony będziemy się stykać wielokrotnie podczas tworzenia systemu operacyjnego. Na razie wystarczy nam tyle informacji. Na różne sztuczki typu call gates przyjdzie jeszcze czas.

Segmentacja – nielubiana siostra stronicowania

Co to?

Segmentacja, to obok stronicowania, popularny sposób organizacji pamięci. W kwestii samej pamięci nic się nie zmieniło – nadal rozważamy liniową przestrzeń, której komórki posiadają adresy (dla przypomnienia – adres do konkretnej komórki pamięci nazywamy adresem fizycznym). Mechanizm segentacji dzieli liniową pamięć na segmenty – segmentem, mówiąc najprościej, jest pewien (najczęściej ciągły) obszar pamięci. W taki sposób do odwołania się do naszej „płaskiej”, jednowymiarowej pamięci potrzebujemy dwuwymiarowego adresu (adres logicznego – czyli pewnego, jednoznacznego odpowiednika adresu fizycznego) – jest to nazwa (najczęściej numer) segmentu, oraz pozycja wewnątrz tego segmentu. Przeanalizujmy poniższy rysunek:

Pamięć adresowana jest od 0x0000 do 0xFFFF. Zawiera ona 3 segmenty o numerach (nazwach) 0, 1 i 2. Jak widzimy w każdym segmencie adresowanie zaczyna się od 0x0000, a kończy się na pewnym adresie, nazywanym popularnie limitem, który jednocześnie wyznacza rozmiar segmentu. Teraz, aby odwołać się do elementu 1 o adresie 0x3000 należy podać adres segmentu czyli 1 oraz odpowiednie przesunięcie wewnątrz tego segmentu, czyli 0x3000 (takie samo jak adres fizyczny, gdyż segment zaczyna się na adresie 0x0000) – ostatecznie otrzymujemy adres (1, 0x3000) (notacja: (segment, przesunięcie wewnątrzsegmentowe)), dla elementu 2 o adresie fizycznym 0xA123 otrzymujemy w posegmentowanej pamięci adres (0, 0x2124), natomiast element 3 o adresie fizycznym 0xFFFF ma adres logiczny (2, 0x1FFF). Wszystko powinno być już teraz jasne. Trzeba przyznać, że dosyć to udziwnione – liniowa pamięć jest wygodna, po co więc używać dwuwymiarowego odpowiednika i w ogóle skąd się te segmenty biorą?

Zalety

Segmentację opracowano w celu umożliwienia podziału programu i danych na logicznie niezależne przestrzenie adresowe oraz aby wprowadzić łatwy mechanizm bezpieczeństwa i współdzielenia.

Segmentacja jest widoczna w modelu programowym – programista może swodobnie definiować nowe, własne segmenty – w ten sposób dostajemy logiczny, elegancki podział informacji. Przykładowe segmenty to: kod, dane, stałe, bss (statycznie alokowane dane), stos itd. To, co się w nich znajdzie zależy tak naprawdę od inwencji programisty.

Segmentacja wprowadza sprytne zabezpiecznie programów przed niebezpiecznymi błędami typu przepełnienie bufora, gdyż poszczególne segmenty mogą być oznaczone jako kod lub jako dane. Próba „wykonania” segmentu z danymi lub nadpisania załadowanego kodu zakończy się błędem. Ponadto segmenty mogą posiadać zabezpiecznia ze względu na poziom uprawnień – tj. czy to kod/dane systemu operacyjnego, sterowników, czy użytkownika.

Kolejną zaletą jest łatwość relokacji danych. Wyobraźmy sobie, że chcemy mieć tablicę o zmiennym rozmiarze – taką do której możemy „doalokować” pewną liczbę bajtów. W pewnym momencie, przy zmianie długości może się okazać, że bezpośrednio po naszej tablicy znajdują się inne, potrzebne dane. Musimy wtedy zaalokować naszą nową, większą tablicę w innym, odpowiednio dużym miejscu – a co się z tym wiąże – zmienić jej adres. Korzystając z segmentacji możemy umieścić taką dynamiczną tablicę w odpowiednim segmencie i w miarę potrzeb zwiększać jego limit. Gdy okaże się, że rozszerzający się segment zacznie zachodzić na inny, możemy go wtedy relokować w inne miejsce, ale tym razem adres się nie zmieni, gdyż segment nadal będzie miał ten sam numer, a elementy w jego wnętrzu to samo przesunięcie (choć oczywiście wiąże się to z kopiowaniem danych).

Ostatnią zaletą, o której warto wspomnieć to możliwość łatwego współdzielenia kodu oraz danych. Wystarczy w tym celu utworzyć odpowiednie, współdzielone segmenty i pozwolić na korzystanie z nich odpowiednim procesom. W taki sposób uruchomienie kilku instancji danego programu nie powoduje zapełnienia dostępnej pamięci wieloma identycznymi wersjami kodu.

Wady

Wady mechanizmu segmentacji są oczywiste. Segmentacja wymusza na programiście używania nieliniowej przestrzeni adresowej co może być kłopotliwe. Ponadto, tworzenie wielu segmentów, a nastepnie ich usuwanie może doprowadzić do powstawania „dziur” pomiędzy kolejnymi segmentami, które będą zbyt małe, aby pomieścić nowe segmenty. Takie zjawisko nazywamy szachownicowaniem lub (popularniej) zewnętrzną fragmentacją. Problem ten można rozwiązać za pomocą bardzo kosztownego kompaktowania – czyli upychania segmentów tak, aby następowały po sobie.

Realia

Architektura x86 posiada wsparcie dla segmentacji. Można powiedzieć nawet więcej – segmentacja działa zawsze i nie da się jej wyłączyć (w przeciwieństwie do stronicowania), czego większość informatyków jest nieświadoma. Sprytnym, programowym obejściem tego faktu jest utworzenie dwóch segmentów, rozciągających się na całą pamięć – kodu i danych. Będą one wtedy oczywiście na siebie zachodzić, ale niczemu to nie szkodzi. Tej techniki używają wszystkie nowoczesne systemy operacyjne w tym Linux, Windows oraz Mac OS X. Dlaczego segmentacja jest tak nielubiana? Cóż, widocznie wszyscy programiści kochają liniową przestrzeń adresową ;). Warto jednak zauważyć, że we wspomnianych systemach operacyjnych segmentacja realizowana jest niejako programowo – tj. istnieje możliwość tworzenia własnych segmentów (słówka kluczowe segment albo section w NASM) w assemblerze. Nie istnieje jednak szczególny powód aby to robić, dlatego z tej możliwości korzystają tylko kompilatory języków wyższego poziomu tak, aby ułożyć logicznie kod (w sekcje code, data, bss).

Jeśli interesują Was systemy korzystające z segmentacji, polecam poczytać o systemie MULTICS (z którego wywodzi się UNIX),  w którym mechanizmu tego użyto.

A20 – historia nieodpowiedzialności

A20?

W mojej serii postów zbliżam się coraz bardziej do momentu, w którym opuszczę tryb rzeczywisty i przejdę do trybu chronionego. Wspominałem, że w trybie rzeczywistym jesteśmy w stanie zaadresować do 1MiB pamięci RAM, a tryb chroniony daje nam dostęp do całej 4GiB przestrzeni. Jak się jednak okazuje, samo fizyczne przejście (o którym niebawem) nie wystarcza – trzeba wcześniej „odblokować” linię A20, czyli 21 linię adresową (A20 – od address line 20 – numeracja od 0 ;)) dostępu do pamięci. Przypomnijmy – 1 MiB = 2^20, a więc potrzebujemy 20 fizycznych linii, które wyznaczają adres. Odblokowanie 21 linii powoduje również aktywowanie pozostałych – ile ich jest, zależy tak naprawdę od konkretnego sprzętu. Dla sprzętu obsługującego 4GiB pamięci RAM będzie ich oczywiście 32.

Zgodność wsteczna…

Dociekliwi mogą zastanawiać się, dlaczego 21 linia adresowa jest domyślnie nieaktywna. Odpowiedź na to pytanie jest śmieszna i jednocześnie przykra – mówiąc najkrócej – zgodność wsteczna. W dawnych czasach procesora 8086 nie było potrzebnych więcej niż 20 linii – 1MiB było szczytem technologicznych marzeń. Powstało wtedy bardzo wiele programów współpracujących z tą architekturą, które tworzone były przez bardziej albo mniej rozgarniętych i odpowiedzialnych informatyków. Ci drudzy wykorzystywali fakt, iż przekroczenie granicy 1MiB (co było możliwe w związku z podziałem na segmenty – patrz tryb rzeczywisty) powodowało dostęp do początkowych bajtów pamięci. Mówiąc obrazowo – pamięć „zawijała się” – z końca do początku. Czujecie już jak bardzo bezmyślnym pomysłem było wykorzystanie tego „bugo-ficzera”? W końcu nastały piękne czasy IBM PC/AT wraz z Intelem 286, który umożliwiał zaadresowanie do 16 MiB pamięci. Rewolucja, prawda? Nie jest dziwnym, iż twórcy nowoczesnej technologii chcieli zachować 100% zgodności wstecznej z poprzednią architekturą, tak aby klienci wraz z wymianą sprzętu nie musieli wymieniać oprogramowania. Wszystko było pięknie do czasu, aż testrzy zaczęli zgłaszać bugi w pewnym oprogramowaniu – wykorzystujący zawijającą się pamięć! Prawa rynku są brutalne, więc zmartwieni, ważni ludzie pracujący nad 286 postanowili zaimplementować tego „buga” w swoim nowym procesorze blokując linię A20 i uzyskując pełną zgodność wsteczną :) Najwidoczniej poprawianie starych błędów mogło okazać się zbyt kosztowne i lepiej było je „obejść”. Z resztą, takie decyzje są w naszej branży bardzo powszechne. W taki sposób do dzisiejszego dnia, komputery architektury x86 mają (a raczej miewają) zablokowaną linię A20. Niestety, to nie koniec problemów.

Standardy, eee?

Okej, skoro już mamy tę linię zablokowaną, to warto by było ją aktywować, gdy już przekonamy się do nie-16bitowych aplikacji. Niestety w czasach 286 standardy nie były tak trendy jak teraz, więc nie opracowano ustandaryzowanego sposobu na dokonanie aktywacji. Koncepcji było wiele. Najpopularniejsą stała się koncepcja, aby do odblokowania A20 używać wolnego pina… w kontrolerze klawiatury! Czujecie zapach absurdu? Klawiatura, pamięć – gdzie tu związek? Wśród innych koncepcji warto wspomnieć o tzw. FAST A20 Gate pozwalającej na odblokowanie za pomocą portów, czy odpowiednich przerwaniach BIOS-u. Oczywiście różne sposoby działają lub nie na różnych płytach głównych różnych producentów, a jedyny sposób aby mieć jako taką pewność, iż udało się nam aktywować A20 to spróbować kilku sposobów. Jakby mało było złego, niektórzy producenci postanowili być na tyle dobroczynni, że dostarczają nam środowisko z już odblokowaną A20. Miło z ich strony, prawda? Dzięki temu, zanim przystąpimy do aktywowania A20, należy dodatkowo sprawdzić, czy ktoś nie zrobił tego za nas. Wspaniale!

Bolesna lekcja

Wnioski z powyższej historii są banalne. Nie bądźmy krótkowzroczni. Czasem nasze decyzje mogą mieć wpływ na rozwój spraw wiele lat później – w szczególności mogą go zahamować. Nie starajmy się również pomagać na siłę – czasem łatwo tym zaszkodzić. Przede wszystkim jednak, pamiętajmy o standardach – o tworzeniu ich i pielęgnowaniu.

W którymś z następnych postów zaimplementujemy sprawdzanie czy linia A20 jest aktywna oraz w razie potrzeby spróbujemy ją aktywować którymś z popularnych sposobów. Tymczasem zachęcam do poczytania na temat tu i tu.

Systemy operacyjne – książki

Wiedza, wiedza, wiedza…

Tworzenie systemów operacyjnych to niszowa dziedzina informatyki. Zajmują się nią nieliczni. Osobiście uważam jednak, że posiadanie wiadomości z zakresu systemów operacyjnych, to absolutny obowiązek każdego szanującego się informatyka. Nie myślę tu oczywiście o umiejętności obsługi Linuksa, czy też administracji Windowsem, tylko o zagadnieniach typu wieloprocesowość i wielowątkowość, synchronizacja procesów i wątków, szeregowanie, zarządzanie pamięcią, czy choćby znajomość zarysu historycznego początków informatyki. Wielu osobom może się wydawać, iż wiedza ta jest całkowicie zbędna „bo przecież ja programuję w .NET i nie robię żadnych wywołań systemowych i nie zarządzam pamięcią, tylko tworzę aplikacje webowe!”. Jasne, tak może być, tylko kompletna nieświadomość tego, co dzieje się „pod spodem”, może doprowadzić niejednokrotnie do katastrofy, a w innych sytuacjach wyjaśnienie takiego, a nie innego zachowania naszej aplikacji leży właśnie w systemie operacyjnym i jego „bebechach”. Jeśli to mimo wszystko nie przemawia, to po prostu powiem, że temat jest bardzo ciekawy i warto go zgłębić, choćby dla przyjemności :)

W dobie internetu, w którym wszystko można znaleźć, może się wydawać, że książki to przeżytek. Sam preferuję uczyć się nowych technologii z tutoriali i dokumentacji, gdyż zapewniają one absolutną aktualność zawartych informacji. Systemy operacyjne, jak już wspominałem wielokrotnie, to inna działka. Wiele wiadomości w tym zakresie to niezmienne od lat fundamenty, pozostałe natomiast zmieniają się bardzo powoli. Książki sprawdzają się tu bardzo dobrze, bo zapewniają całościowe i wyczerpujące podejście. Poza tym, kto nie lubi zapachu farby drukarskiej ponad świecący monitor?

Niekwestionowani liderzy

Rynek książek na temat systemów operacyjnych jest bardzo mały – tak jak mówiłem – nisza. Jak to się jednak mówi – nie ilość, ale jakość! Na polskim rynku niepodzielnie królują dwa tytuły i są to „Systemy operacyjne”, której autorem jest Andrew Stuart Tanenbaum, twórca kultowego systemu MINIX, od którego wiele się zaczeło oraz „Podstawy systemów operacyjnych” autorstwa Abrahama Silberschatza, Petera Baera Galvina oraz Grega Gagne’a. Najnowsze wydanie (trzecie) pierwszej z nich ukazało się w tym roku za sprawą wydawnictwa helion i pokrywa się z najnowszą, angielską wersją, natomiast druga z wymienionych książek wydawana jest przez WNT – najnowsze wydanie z 2005 roku, to wydanie szóste, podczas gdy na świecie mamy już wydanie ósme. Dodatkowo niepokojącym jest fakt, że już od długiego czasu na stronie wydawnictwa widnieje napis „Książka czasowo niedostępna”. Może to oznaczać, że nowe wydanie jest w drodze, albo niestety – jakieś problemy. Dość już jednak suchych faktów, przejdźmy do tego, co książki te oferują i która z nich jest lepsza.

Obie książki oferują całościowe ujęcie tematu. Znajdziemy w nich dużo wyczerpujących informacji na tematy takie jak historia systemów operacyjnych; podstawowe pojęcia; procesy i wątki – IPC, szeregowanie, synchronizacja; zarządzanie pamięcią – stronicowanie i segmentacja, algorytmy alokacji pamięci; system plików; wejście-wyjście; bezpieczeństwo. Dodatkowo „Podstawy systemów operacyjnych” zawierają obszerny rozdział poświęcony rozproszonym systemom operacyjnym, natomiast w ramach rekompensaty „Systemy operacyjne” posiadają duży rozdział o implementacji systemu operacyjnego. Na uwagę zasługują też rozdziały opisujące konkretne systemy operacyjne – co i jak zostało w nich zrealizowane. Oczywiście w książce Tanenbauma informacje dotyczą nowszych wydań konkretnych systemów.

Język obu książek nie jest szczególnie lekki, ale nie powiedziałbym, że czyta się je ciężko – tematyka wymaga (niestety, bądź stety) używania języka formalnego, jednak wszystko daje się zrozumieć. Ciężko też znaleźć fragmenty, w których autorzy „laliby wodę”. Moim zdaniem, udało się im zachować równowagę pomiędzy niejasną zwięzłością, a przesadną obszernością.

Żadna z książek nie wyczerpuje tematu, bo jest to raczej niemożliwe. Obie natomiast prezentują bardzo obszerny kawałek tematu na swoich ponad 1000 stronach. Która z nich jest zatem trafniejszym zakupem? Na dzień dzisiejszy odpowiedź jest prosta – jest to książka „Systemy operacyjne”, ze względu na łatwą dostępność (księgarnia helion 99 zł (!) na dzień dzisiejszy) i większą aktualność (mimo wszystko). „Podstawy systemów operacyjnych” daje się czasem trafić w antykwariatach, na kiermaszach, bądź na allegro, jednak jej cena sięga nierzadko… 300 zł ze względu na swoistą unikalność. Cena zabójcza. Aha, muszę jeszcze wspomnieć, iż obie książki posiadają niebanalne okładki :) Powiedziałbym nawet, że okładka „Systemów operacyjnych” to najciekawiej zaprojektowana okładka, jaką w życiu widziałem. Polecam zobaczyć ;>

Na zakończenie

Naprawdę gorąco zachęcam do przeczytania jednego z tych tytułów – i nieważne czy jesteś programistą, administratorem, architektem, serwisantem, czy reinstalatorem Windowsów – zawsze znajdziesz tam wiele ciekawych informacji, które – nóż, widelec – mogą się kiedyś przydać. A nawet jeśli nie – wiedza jest zawsze sexy i w modzie :)

EDIT 11.10.2010:

Na rynku brak książek rodzimych autorów o tematyce tworzenia czy budowy systemów operacyjnych, jednak właśnie dostałem informację, o pozycji, która niebawem się ukaże – Programowanie systemowe mikroprocesorów rodziny x86 autorstwa Włodzimierza Stanisławskiego i Damiana Raczyńskiego. Myślę, że warto spróbować!

Dostęp do sprzętu peryferyjnego – podstawy

Zarys

W życiu każdego projektanta systemów operacyjnych nadchodzi moment, w którym chciałby się on skontaktować z urządzeniami zewnętrznymi, gdyż samotny procesor przestaje spełniać jego wygórowane oczekiwania :) Jak to bywa prawie ze wszystkim, istnieje kilka sposobów dostępu do sprzętu peryferyjnego, co wynika oczywiście z naturalnej ewolucji hardware’u – w tym poście zajmę się jednak tylko dwoma, najprostszymi metodami – port I/O oraz memory-mapped I/O, co dałoby się przetłumaczyć jako odpowiednio – wejście/wyjście za pomocą portów oraz wejście wyjście odwzorowane w pamięci. Wśród bardziej zaawansowanych metod, na które jeszcze przyjdzie czas, warto wspomnieć o DMA oraz dostępie przez przerwania.

Memory-mapped I/O

Memory-mapped I/O to bardzo prosty sposób na dostęp do urządzeń zewnętrznych polegających na tym, iż w dostępnej pamięci operacyjnej – a dokładniej w dostępnej przestrzeni adresowej – posiadamy zarezerwowany zakres adresów pozwalających na odwoływanie się do urządzeń. Rezerwacja może być zarówno stała, jak i tymczasowa. Technicznie, w uproszczeniu rzecz ujmując, realizacja wygląda tak, iż każde z urządzeń monitoruje szynę adresową procesora i reaguje na każdy jego dostęp do odpowiedniego zakresu. Zaletami takiego podejścia są oczywiście – prostota i jasność w korzystaniu (łatwo programuje się to w C), gdyż dostęp do pamięci jest prosty; procesor oparty o memory-mapped I/O jest tańszy, mniejszy, prostszy i mniej energochłonny; dostęp do pamięci jest szybki, a więc dostęp do urządzeń poprzez szynę adresową też – również dzięki możliwości wykorzystania różnych trybów adresowania. Wady? Takie podejście „przerywa” ciągłość pamięci w konwencjonalnym ujęciu, a więc zmniejsza też ilość dostępnej pamięci. Dla formalności, przykładowy kod:

unsigned char *videoram = (unsigned char *) 0xB8000;
videoram[0] = 65; /* character 'A' */
videoram[1] = 0x07; /* forground, background color */

W ten sposób wypiszemy literkę ‚A’ na ekranie, odwołując się do karty graficznej. Wygląda prosto, prawda? I o to właśnie chodzi :) Pozostaje jeszcze pytanie, na które nie ma odpowiedzi – skąd wiedzieć która cześć pamięci za co odpowiada? Rozwiązania należy szukać w mapie pamięci, która dla każdej architektury może być inna. Warto też pamiętać, że istnieje możliwość dynamicznego przydzielania adresów dla urządzeń.

Port I/O

Port I/O to kolejna, starsza i generalnie gorsza metoda dostępu do urządzeń, która ciągle jest obecna w architekturze x86 z powodu.. tak tak, zgodności wstecznej. Sposób ten polega na istnieniu dodatkowej, obok RAM-u, przestrzeni adresowej, w której każde urządzenie ma swój adres. Dostęp do portów odbywa się za pomocą specjalnych instrukcji, charakterystycznych dla architektury, które nie mają swojego odpowiednika w języku C. W assemblerze x86 są to: IN, INS/INSB/INSW/INSD, OUT, OUTS/OUTSB/OUTSW/OUTSD pozwalające wczytać/pisać odpowiednią porcję danych z/do wskazanego portu. Technicznie, transfer danych odbywa się za pomocą specjalnego I/O pina w CPU lub za pomocą przeznaczonej do tego szyny adresowej, która nieco przyspiesza ten proces. Zaletami tego rozwiązania jest oszczędność przestrzeni adresowej,  a więc i samej pamięci oraz fakt, iż dostęp przez odpowiednie instrukcje, w jasny sposób wskazuje czytającemu kod programiście, kiedy odbywa się komunikacja ze sprzętem. Wadą, jak już wspomniałem, jest powolność oraz brak wsparcia dla operacji 64 bitowych. Przykładowy kod:

a20wait:
        in      al, 0x64
        test    al, 2
        jnz     a20wait
        ret

Kod ten, jak widać, wczytuje do rejestru al (ax) bajt z urządzenia o adresie 0x64, o którym będzie jeszcze mowa ;) Warto zwrócić uwagę, że używany jest tu rejestr (e)ax, gdyż… jest to jedyny rejestr dostępny do użycia z portami w architekturze x86 – niestety. Podobnie – adres może być stałą natychmiastową lub wartością wyłącznie z rejstru DX. Pozostaje nam jeszcze odpowiedź na pytanie zadane dla dostępu przez memory-mapped I/O – czyli skąd wiedzieć, który port połączony jest z którym urządzeniem. Odpowiedź i tym razem jest niełatwa. Niektóre urządzenia takie jak timery, kontrolery przerwań, porty PS/2, serial, parallel, dyskietka, dysk twardy, karta graficzna mają przypisane konkretne adresy z powodów kompatybilności. Pozostałe urządzenia typu plug-and-play mają adresy przypisane przez BIOS i aby je otrzymać, należy poprosić o to szynę PCI :) Inne urządzenia (np. karty ISA) posiadają zworki, którymi da się ustawić konkretny port. Pamiętacie ustawianie portów Soundblastera w grach za czasów DOS-a? Tak – to właśnie dlatego :)

Podsumowanie

Nowoczesne architektury – 32 i 64 bitowe odchodzą od modelu z portami na rzecz zunifikowanej przestrzeni adresowej – ze względu na wysoką wydajność i elegancję takiego rozwiązania. W obecnych czasach, mała ilość pamięci nie jest już przeszkodą. Są jednak chwile, w których dostęp za pomocą portów jest słuszną (lub jedyną ;>) metodą dostępu do urządzeń – należy więc o niej pamiętać. Konkretne przykłady użycia – już wkrótce.

Po co branchuję?

Ludzie branche tworzą…

Motywów do tworzenia gałęzi w projektach jest co nie miara. W nie tak prehistorycznych czasach (które chyba z resztą trwają do dziś) królowania CVS, a później SVN większość rzeczy trafiała bezpośrednio do trunka – głównej gałęzi. Rozgałęzienia były tworzone w momentach, gdy projekt rzeczywiście obierał dwa, dosyć odmienne biegi rozwoju (np. v1 i v2 – obie rozwijane). Inną strategią (nie wykluczającą pierwszej!) było tworzenie gałęzi – stabilnej, rozwojowej, a czasem eksperymentalnej i odpowiednie „code promotion”, czyli „przenoszenie” kodu między gałęziami: stabilna <- rozwojowa <- eksperymentalna. Jeszcze inny sposób wykorzystywania gałęzi, to oddzielny branch dla każdego podzespołu. Tak stworzone gałęzie były stopniowo (zwykle po zakończeniu pracy nad modułem) włączane do wspomnianego trunka. Powodem stosunkowo niedużej liczby rozgałęzień była największa bolączka wspomnianych scentralizowanych systemów kontroli wersji – nieprzyjemny merge.

W nowoczesnych-trendy-lux-czasach rozproszonych systemów kontroli wersji takich jak git, hg, bzr promuje się podejście „branch-per-feature” – czyli tworzenie nowej gałęzi dla każdego, nowego „ficzera”, każdego naprawianego buga itd. Nic co jest „work-in-progress” nie powinno trafić do gałęzi głównej. Wszystko to jest możliwe dzięki mniej bolesnemu merge’owaniu gałęzi, a takie podejście jest samo w sobie niezwykle wygodne i efektywne.

Nieco kreatywności

Przy okazji serii postów którą tworzę, potrzebowałem prostego i efektywnego sposobu udostępniania kodów dla poszczególnych odcinków serii. W grę wchodziło uploadowanie paczek z aktualnym kodem, dla danego odcinka, na serwer i linkowanie ich w notce. Szczerze powiedziawszy – nie znoszę takiego podejścia. Wydaje mi się, że łatwo doprowadza to do bałaganu, nieaktualnych wersji i niedziałających linków. Potrzebowałem lepszego sposobu i do głowy przyszedł mi pomysł.. aby każdy odcinek serii był oddzielnym branchem. Takie rozwiązanie ma szereg zalet: Czytelnicy mogą z łatwością pobrać aktualny, dla danego odcinka, kod; mogę bezproblemowo wnosić zmiany do kodu dla konkretnej części; struktura tego podejścia jest bardzo przejrzysta, a ponadto wyjątkowo łatwo się tym wszystkim zarządza. Z dodatkowych zalet warto wspomnieć o przeglądarce kodu na githubie, która umożliwia Czytelnikom natychmiastowy podgląd kodu dla danej części, bez potrzeby ściągania go. Jak dla mnie – rewelacja :) Przedstawione przeze mnie podejście ma oczywiście sens tylko w serii postów ze stopniowo przyrastającym kodem. Puryści mogliby jednak powiedzieć, że takie rzeczy powinno załatwiać się labelami, ale jak już wspomniałem – mam zamiar w miarę potrzeb aktualizować kod dla konkretnych odcinków, a zarządzanie etykietami, to nic przyjemnego.

Nowa, lepsza przyszłość? ;)

Cieszy mnie, że era linkowania paczek z kodem dobiega końca, a repozytoria wchodzą w coraz to nowe obszary działalności – ludzie za pomocą systemów kontroli wersji rozpowszechniają najróżniejsze rzeczy – począwszy od configów, aż do dokumentów. Niewątpliwa w tym zasługa ogólnodostępnych, darmowych, świetnie przygotowanych serwisów takich jak github, gitorious, czy bitbucket. Wydaje mi się, że używanie systemów kontroli wersji jest po prostu efektywne, łatwe, szybkie i pewne, zatem ich (nadchodząca) wszechobecność jest rzeczą naturalną. Koniec broken linków! :)

Tworzenie systemu operacyjnego – część 0×02: Dobrodziejstwa BIOS-u

512 bajtów (tak samo jak 640K) nie wystarczy każdemu

W poprzednim odcinku załadowaliśmy 512 bajtów zawartości dysku do pamięci. W taki sposób uruchomiliśmy nasz bootloader, który niestety nic nie robił. Teraz wypadałoby, aby zmienił on tryb operacyjny, wyświetlił kilka diagnostycznych komunikatów no i załadował jądro za pomocą sterownika ATA… tylko to wszystko nie zmieści się w 512 bajtach! Jeśli postawimy na minimum, tj. załadowanie jądra z dysku – również może być ciężko, sterownik jednak swoje waży. Z pomocą przychodzi nam jednak BIOS!

Funkcje BIOS-a – przerwania

BIOS udostępnia nam bardzo wiele funkcji poprzez przerwania. Wśród oferowanego asortymentu są np.: pisanie na ekran, wczytywanie z dysku, sprawdzanie stanu sprzętu, uzyskiwanie mapy pamięci, aktywowanie dodatkowej pamięci i wiele, wiele innych. My zajmiemy się dla przykładu tylko dwoma. Pełna ich lista znajduje się tutaj i tutaj. Widzicie funkcje opisane jako „???”? Uważajcie na nie! Skoro nikt, nawet twórcy BIOS-ów nie wiedzą do czego służą, to możecie nimi przez przypadek przejąć władzę nad światem lub wyłączyć prąd u sąsiada w domu.. :)

Dla niezaznajomionych z programowaniem systemowym: przerwanie wywołuje się poleceniem int numer_przerwania, natomiast argumenty przekazuje się za pomocą odpowiednich rejestrów.

int 0x10

Na początek postaramy się wypisać coś na ekran. W tym celu skorzystamy z przerwania 0x10, którego dokładny opis jest tutaj. Oto kod:

	mov	ax, 0x0000
	mov	ds, ax		; round way to do that...
	mov	si, msg

	mov	ah, 0x0E	; mode - teletype (advance and scroll)
	mov	bh, 0x00	; page number
	mov	bl, 0x07	; colors

.next_char:
	lodsb
	or	al, al		; letter here
	jz	.continue
	int	0x10		; BIOS video interrupt
	jmp	.next_char

msg:
	db	'Hello from bootloader!', 13, 10, 0

Idąc od początku, warto zwrócić uwagę na linie 5 i 6 i na dziwną, okrężną drogę ładowania rejestru DS (data segment). Niestety – takie są ograniczenia architektury – nie można bezpośrednio ładować rejestrów segmentowych. Do rejestru SI ładujemy adres pamięci pierwszej litery (czyli mówiąc najprościej – napisu) i tak uzyskana para (DS:SI) wskazuje nam jednoznacznie, gdzie jest napis. Pora na kolejne ustawienia. W rejestrze AH, zgodnie z opisem musi znaleźć się wartość 0xE, która definiuje tryb wyświetlania liter – jest to przewijanie i przeskakiwanie na następną pozycję (w dokumentacji nazwany teletype). W rejestrze BH przechowujemy numer strony – u nas jest to strona zerowa. Rejestr BL zawiera natomiast kolory (tła i liter) – białe litery na czarnym tle.

Możemy teraz przejść do pętli wypisującej literę po literze. Instrukcja lodsb ładuje literę wskazywaną przez DS:SI do rejestru AL, a następnie przesuwa wskaźnik do następnej litery. Polecenie o tyle przydatne,  gdyż oszczędzamy w ten sposób cenne miejsce w naszych 512 bajtach. Kolejnym krokiem jest sprawdzenie, czy przypadkiem nie dotarliśmy do końca napisu i jeśli tak, to opuszczenie pętli (linie 15 i 16), następnie wywołanie samego przerwania (linia 17) oraz skok zamykający pętle. Bajecznie proste prawda? Po wykonaniu tego kawałka kodu powinniśmy ujrzeć na ekranie napis „Hello from bootloader!”. Czas na trochę bardziej przydatne i skomplikowane przerwanie.

int 0x13

Szczęśliwa „trzynastka” ;) pozwala nam na wczytywanie danych z dysku za pomocą adresowania typu LBA (jeśli nie wiesz co to, przeczytaj koniecznie podlinkowaną stronę Wikipedii). Rzut oka na kod:

	mov	si, read_pocket
	mov	ah, 0x42	; extension
	mov	dl, 0x80	; drive number (0x80 should be drive #0)
	int	0x13

read_pocket:
	db	0x10		; size of pocket
	db	0		; const 0
	dw	1		; number of sectors to transfer
	dw	0x7E00, 0x0000	; address to write
	dd	1		; LBA
	dd	0		; upper LBA

Tym razem w rejestrze SI znajduje się adres „kieszeni” (o której szerzej – zaraz) zawierającej informacje o tym co i gdzie wczytać. Rejestr AH zawiera numer identyfikujący tę właśnie funkcję (wśród dostępnych funkcji znajdują się również takie, które pozwalają na dostęp CHS). Natomiast rejestr DL zawiera numer dysku z którego chcemy czytać… zupełnie spodziewanie, dysk numer 0 znajduje się pod numerem 0x80 ;> Przerwanie wywołujemy w linii 24 i o ile wszystko poszło gładko, powinniśmy mieć dane załadowane pod wskazany adres. Co do samej kieszeni, to komentarze chyba mówią wszystko. Pierwszy bajt zawiera rozmiar kieszeni, drugim powinno być zawsze 0, kolejne dwa to liczba sektorów do wczytania, następne dwa słowa to człony adresu (u nas to 0x0000:0x7E00), potem następuje adres sektora startowego na dysku (zaczynamy od sektora numer 1, czyli efektywnie od drugiego) i jego „górna” część. Prosto, łatwo i przyjemnie! :) W prawdziwym, profesjonalnym systemie powinniśmy jakoś obsłużyć błędy wczytywania, których kody zwracane są w odpowiednich rejestrach (opis tu).

Wszystko do kupy

Finalnie nasz kod wygląda tak:

[ORG 0x7C00]			; here is where our code will be loaded by BIOS
[BITS 16]

bootloader:
	mov	ax, 0x0000
	mov	ds, ax		; round way to do that...
	mov	si, msg

	mov	ah, 0x0E	; mode -> teletype (advance and scroll)
	mov	bh, 0x00	; page number
	mov	bl, 0x07	; colors

.next_char:
	lodsb
	or	al, al		; letter here
	jz	.continue
	int	0x10		; BIOS video interrupt
	jmp	.next_char

.continue:
	mov	si, read_pocket
	mov	ah, 0x42	; extension
	mov	dl, 0x80	; drive number (0x80 should be drive #0)
	int	0x13
	cli			; turn off maskable interrupts, we don't need them now
	hlt
.halt:	jmp	.halt

msg:
	db	'Hello from bootloader!', 13, 10, 0
read_pocket:
	db	0x10		; size of pocket
	db	0		; const 0
	dw	1		; number of sectors to transfer
	dw	0x7E00, 0x0000	; address to write
	dd	1		; LBA
	dd	0		; upper LBA 

times 510-($-$$) db 0		; fill rest with zeros
dw 0xAA55			; bootloader indicator, used by BIOS

Do celów testowych musimy stworzyć dalszą część naszego wirtualnego dysku, jako że ma on tylko 512 bajtów i wczytanie kolejnego sektora zaowocuje błędem. Proponuję zrobić to tak:

echo 'Ala ma kota' > ./bin/text
dd if=/dev/zero of=./bin/zeros bs=1  count=500
cat ./bin/boot.bin ./bin/text ./bin/zeros > ./bin/hda.img

Kolejno: tworzę plik z zawartością „Ala ma kota” (plus nowa linia!), tworzę plik 500 bajtowy z samymi zerami, a następnie wszystko łącze w kolejności: kod + tekst + zera, co nam daje 1024 bajty.

Po uruchomieniu naszego bootloadera w QEMU zobaczymy na ekranie „Hello from bootloader!”, natomiast w pamięci pod adresem 0x7E00 powinien być napis „Ala ma kota”. Dla pewności, w trybie monitora sprawdźmy to komendą xp /12bc 0x7E00. Wspaniale!

Single stage

Nasz bootloader aktualnie posiada tylko jeden etap (ang. stage) działania. Bardziej złożone bootloadery, jak np. GNU GRUB posiadają kilka etapów działania (najczęściej 2) ze względu na wspomniane ograniczenie 512 bajtów. W kolejnej części cyklu spróbujemy napisać stage 2 dla naszego bootloadera, który odpowiednio skonfiguruje środowisko pracy dla naszego jądra. Tymczasem zachęcam do zapoznania się z pełnymi źródłami części 0x02:

git clone git://github.com/luksow/OS.git --branch 0x02

Jeśli chcesz go tylko przejrzeć, wejdź tutaj.