Pisanie OS/From zero to hero

Z Wikibooks, biblioteki wolnych podręczników.

Wstęp[edytuj]

UWAGA TEN ARTYKUŁ ZAKŁADA ŻE :

- Znasz język programowania C.

- W razie ewentualnych problemów będziesz umiał sobie z nimi poradzić.


Pisanie OSa jest sprawą dość skomplikowaną. Nie wystarczy wiedza o programowaniu, trzeba także rozumieć, w jaki sposób działa procesor, przerwania, karta graficzna, itp. W tym kursie postaram się pokazać, jak napisać prostego OSa oraz wytłumaczę, w jaki sposób programować sprzęt i jak on działa. Co będzie nam potrzebne?

  • GCC, czyli kompilator C(i nie tylko)
  • Netwide Assembler (NASM) >=0.98 (http://nasm.sourceforge.net/), czyli kompilator asemblera
  • bootloader GRUB, uruchomi nam nasz system
  • emulator PC, np. VirtualBox, Bochs (opisany w podręczniku Think inside Bochs), VMWare, VirtualPC (opcjonalnie)


Zestaw GCC+NASM+GRUB w zupełności wystarczy i jest niezbędny do pisania OSa. Emulator PC jest opcjonalny. Pozwoli to pisać np. pod Windows czy Linuksem bez ciągłego restartowania komputera, aby spróbować, czy to, co piszemy działa. Jeszcze zaznaczam, że kod tutaj prezentowany będzie wyłącznie w C i asemblerze. Dobra, dosyć wstępu, zabieramy się do roboty. Na początku jednak trzeba będzie niestety opisać trochę sprzętu, aby wiedzieć co się robi. Dla chcących więcej polecam dokumentację Intela do procesora 386 ;) (ja przeczytałem).

Sprawy organizacyjne[edytuj]

Jeśli chcesz pisać OSa, musisz sobie zadać kilka ważnych pytań:

  1. Po co piszę system operacyjny?
  • aby nauczyć się, jak programować wielozadaniowość, pamięć wirtualną, drivery, system plików, itp.
  • dostępne systemy mi nie wystarczają
  • aby napisać OSa, który próbuje nauczyć projektowania OSów
  • sława i chwała, władza nad światem :)
  1. Decyzje dotyczące budowy
  • OS przenośny czy tylko dla architektury x86: 4 poziomy uprzywilejowania segmentów, ochrona pamięci, przełączanie zadań przy użyciu TSS (lub zamianę stosów)
  • architektura: kernel monolityczny, mikrojądro, inne
  • wielozadaniowość: brak, "udawana", prawdziwa
  • wątki
  • wieloprocesorowość: jeden procesor, SMP lub clustering
  • wieloużytkowość: wielozadaniowość+zabezpieczenia
  • OS używany do pisania naszego OSa: DOS, Windows, Linux, BeOS, inne
  • język użyty do implementacji: C, C++, Pascal, assembler
  • format plików wykonywalnych: czy ma obsługiwać biblioteki dynamiczne
  • biblioteki dla języków wysokiego poziomu: GNU glibc, własna biblioteka, inne
  • kompatybilność: te same wywołania, co inne OSy, emulacja innego OSa

Tryby pracy procesorów[edytuj]

Tryb rzeczywisty[edytuj]

Jest to tryb bardzo "okrojony". Pamięć jest podzielona na segmenty. Każdy segment ma wielkość 64kB. W trybie rzeczywistym mamy dostępny tylko 1MB pamięci, ale i tak jest mniej, bo pamięć od 0xA0000-0xFFFFF jest zarezerwowana dla pamięci video i BIOSu. Mamy więc tylko ok. 640kB wolnych. Tryb ten pozostał nawet w najnowszych procesorach Athlon XP ze względu na kompatybilność ze starymi programami pisanymi dla procesora 8086. Do pisania prawdziwego, dobrego OSa tryb rzeczywisty nas w ogóle nie obchodzi.

Tryb chroniony[edytuj]

Tryb ten został wprowadzony dopiero w procesorze 286. Jest to specjalny tryb pracy procesora. Pamięć jest podzielona na segmenty, ale to ty decydujesz gdzie się segment zaczyna i jaką ma wielkość. Dla procesora 286 wynosiła ona max. 16MB, a dla procesorów od 386 wzwyż do 4GB (!). W opisach będziemy się opierać tylko na architekturze procesorów od 386 w górę. Nie ma sensu opisywać 286, bo nikt już go dziś nie używa.

Tryb długi(Long mode)[edytuj]

Tryb ten jest podstawowym trybem działania procesora w architekturze AMD64, jest kombinacją 64-bitowego trybu pracy i trybu zgodności wstecz (kompatybilności) pozwalającego na uruchamianie 16- i 32-bitowego kodu w trybie chronionym. Nie posiada on obsługi dla trybu rzeczywistego ani dla trybu wirtualnego 8086. W trybie long pracują 64-bitowe systemy operacyjne. Dla szerszego grona odbiorców został wprowadzony dopiero z procesorem AMD Opteron. Umożliwia on teorytyczny dostęp aż do 256 TB pamięci RAM, ale obecnie zwykle nie można użyć więcej niż 1TB. Tryb ten ze względu na skomplikowanie nie zostanie opisany.

Opis trybu rzeczywistego[edytuj]

Chcąc, nie chcąc, trzeba wiedzieć jak programować w trybie rzeczywistym. Potrzebne nam to będzie m.in. do napisania bootloadera oraz kodu inicjalizującego nasz OS.

Inicjalizacja komputera[edytuj]

Po włączeniu komputera procesor "budzi się" w trybie rzeczywistym. Jest to potrzebne, aby załadować BIOS, który jest przecież kodem 16-bitowym i używa specyficznych właściwości trybu rzeczywistego. Procesor skacze do specjalnego adresu w pamięci, gdzie znajduje się procedura skoku do właściwego punktu wejścia BIOSu.

Pamięć w trybie rzeczywistym[edytuj]

Jak już wspomniałem pamięć w RMODE (skrót od real mode - tryb rzeczywisty) jest podzielona na segmenty. Każdy segment ma limit 64kB. W trybie rzeczywistym jest tylko szesnaście segmentów: 0x0000,0x1000,0x2000,0x3000,...,0xF000. Segment 0x0000 jest zarezerwowany dla tablicy przerwań oraz niektórych danych komputera. Aby obliczyć fizyczne położenie takiego segmentu w pamięci, wystarczy wymnożyć numer segmentu przez 16, czyli w zapisie szesnastkowym dodać jedno 0 na koniec. Np. segment 0x4000 ma adres fizyczny 0x40000. Aby obliczyć adres fizyczny pary segment: offset wystarczy wykonać działanie:

adres_liniowy = (segment*16)|offset

Czasami widzisz numer segmentu, np. 0x5188. W rzeczywistości jest to segment 0x5000, tylko po przeliczeniu adresu segmentu segment: offset z powyższego równania wychodzi, że np. adres 0x5100:0x0010 to to samo, co 0x5000:0x1010.

Poniżej przedstawiam skróconą mapę pamięci w RMODE.

  • 0x0000:0x0000 - tablica wektorów przerwań
  • 0x0000:0x7C00 - tu zostaje załadowany boot-sector przez BIOS
  • 0x1000:0x0000-0x9000:0xFFFF - pamięć użytkownika (najlepiej używać z tego przedziału)
  • 0xA000:0x0000 - pamięć video karty VGA (tylko dla trybu graficznego)
  • 0xB000:0x0000 - pamięć video karty Hercules Monochrome
  • 0xB800:0x0000 - pamięć trybu tekstowego karty VGA
  • 0xC000:0x0000-0xF000:0xFFFF - pamięć BIOSu i inne


Na samym początku pamięci RAM (adres 0x0000:0x0000) znajduje się tablica wektorów przerwań. Zostaje zainicjalizowana przez BIOS. Zawiera ona punkty wejścia do różnych procedur systemowych. Np. przerwanie 0x10 służy do obsługi karty graficznej, przerwanie 0x13 do obsługi dysków. Tablica wektorów przerwań to po prostu tablica par offset: segment. Przerwań może być tylko 256. Przerwania dzielą się na programowe i sprzętowe. Programowe są wywoływane jedynie przez użytkownika, a sprzętowe może wywołać procesor. W dokumentacji Intela do procesora 386 jest jak byk napisane, że przerwania od 0x00 do 0x2F są zarezerwowane na wyjątki procesora oraz przerwania sprzętowe to i tak jakiś matoł projektujący BIOS umieścił tam przerwania użytkownika, przekierowując przerwania sprzętowe pod inne numery. I teraz jeśli w trybie chronionym chcesz obsługiwać przerwania sprzętowe i wyjątki procesora to musisz wysłać do PICa (Programmable Interrupt Controller) żądanie, aby przekierował przerwania na właściwe miejsce (ok. 15 linijek w C używając outportb).

Kiedy procesor wykonuje przerwanie (sprzętowe, lub przy użyciu intrukcji int) na stos kładzione są następujące rejestry (w kolejności): ss, sp, flags, cs, ip. Znaczenie rejestrów:

  • SS - segment stosu
  • SP - aktualna pozycja stosu
  • FLAGS - tu zawarte są flagi procesora
  • CS - segment kodu
  • IP - licznik programu (pozycja aktualnie wykonywanej instrukcji)

Gdy procesor otrzyma żądanie przerwania, liczy sobie (w RMODE) adres przerwania w następujący sposób:

  • Segment = wartość przy 0x0000:numer_przerwania*4
  • Offset = wartość przy 0x0000:(numer_przerwania*4)+2

Potem zapisuje stan ww. rejestrów i skacze do procedury obsługi przerwania.

Opis trybu chronionego[edytuj]

O trybie rzeczywistym już wystarczy. Teraz zajmijmy się trybem chronionym, ponieważ jego opis będzie trochę dłuższy. Na początek trochę nudnych rzeczy.

Rejestry[edytuj]

W trybie chronionym wszystkie rejestry procesora są rejestrami 32-bitowymi. Mamy więc rejestry: eax, ebx, ecx, edx, esi, edi, ebp, esp oraz cs, ds, es, fs, gs. W trybie rzeczywistym raczej nie używa się rejestrów fs i gs. Mimo, że rejestry segmentowe są 32-bitowe, to i tak procesor używa tylko ich "dolną" połowę.

Stos[edytuj]

Tutaj stos jest 32-bitowy. Jest to rozmiar domyślny elementu kładzionego na stos. Jeśli chcesz położyć element 16-bitowy, to musisz użyć rejestru 16-bitowego, lub wartości 16-bitowej (np. push word 1 lub push word [wartosc]), jednak prawie nigdy nie używa się argumentów 16-bitowych.

Rejestry dodatkowe[edytuj]

W trybie chronionym dostępne są także rejestry do kontroli procesora (są 32-bitowe). Są to rejestry:

  • CR0 - podstawowy rejestr kontroli procesora. Można go używać także w RMODE, ale to on właśnie służy do przełączania CPU w tryb chroniony i do kilku innych rzeczy
  • CR1 - rejestr zarezerwowany
  • CR2 - tutaj jest zapisany fizyczny adres, w którym wystąpił ostatni błąd strony (ang. Page Fault), ale o tym będzie później
  • CR3 - tutaj zapisany jest fizyczny adres katalogu stron (o tym też później)
  • CR4 - rejestr sterowania dostępny tylko dla nowych procesorów (o ile się nie mylę, jest on dostępny dla procesorów o architekturze 80686 i lepszych). Na razie nie będziemy o nim mówić, gdyż nie jest on nam potrzebny.

Pominąłem tutaj m.in. rejestry debugowania, ale na razie to się nam i tak nie przyda.

Poziomy uprzywilejowania[edytuj]

W trybie chronionym istnieją tzw. poziomy uprzywilejowania (DPL). Dla architektury x86 są 4 poziomy uprzywilejowania od 0 do 3. Poziom 0 jest przewidziany dla jądra systemu, na tym poziomie można robić wszystko. Poziom 3 jest dla aplikacji użytkownika, ponieważ ma duże ograniczenia, np. nie można dokonywać operacji na portach czy na rejestrach sterowania procesora.

Pamięć w trybie chronionym[edytuj]

Tutaj jest znacznie lepiej niż w trybie rzeczywistym, gdzie wszystko było ustalone na "sztywno" (jednak są sposoby, aby to ominąć). Najważniejsze jest to, że TY decydujesz, jakie segment ma położenie (mówimy: bazę) i rozmiar (mówimy: limit). W trybie chronionym bazę i limit segmentu definiujemy w Globalnej Tablicy Deskryptorów (GDT - Global Descriptor Table). Jest to tablica o 64-bitowych wpisach. Wpisów tych może być max. 8192, jednak zazwyczaj używa się tylko kilku. GDT składa się z 2 części:

  • nagłówka
  • tablica właściwa

Nagłówek ma format:

word - 8*ilosc_wpisow-1
dword - fizyczne położenie GDT w pamięci

Tablica właściwa jest po prostu listą wartości. Uwaga: pierwszy wpis musi być 0, ponieważ jest on zarezerwowany, tzw. NULL descriptor. Nie można go używać. Format GDT jest trochę skomplikowany. Każdy wpis ma po 64 bity. Najpierw jednak wyjaśnię format selektora. Selektor jest to po prostu numer segmentu w GDT lub LDT (wyjaśnię później) zawierający dodatkowe informacje. Numer selektora liczy się następująco:

selektor = (numer_w_GDT*8)+poziom_uprzywilejowania+(4 jeśli to selektor z LDT)

Więc np. nasz segment kodu ma poziom uprzywilejowania 3 a numer deskryptora w tablicy GDT to selektor=2*8+3=19=0x13. Gdyby to był selektor w LDT, to jego wartość wynosiłaby 0x17. Adres tablicy GDT jest pamiętany w specjalnym rejestrze procesora GDTR. Rejestr ten nie jest dostępny bezpośrednio. Można go załadować rozkazem asemblerowym:

lgdt rejestr lub adres w pamięci

np.:

lgdt [eax]
lgdt [tablica_gdt]

Aby zapamiętać wartość GDTR, należy użyć rozkazu sgdt, np.:

sgdt edi
sgdt [stara_tablica_gdt]

Teraz pora na format wpisu do GDT. Pokażę wpis jako parę liczb 32-bitowych. Format dla segmentów KODU i DANYCH

Kod:

31 0 
----------------------------------------------------------------  --------------- 
| Baza 31..24 | G | X | O | A | Limit | P | DPL | 1 | TYP | A | BAZA 23..16   | 
|             |   |   |   | V | 19..16|   |     |   |     |   |               | 
|             |   |   |   | L |       |   |     |   |     |   |               | 
----------------------------------------------------------------  --------------- 
| Baza                        | Limit                                         | 
| 15..0                       | 15..0                                         | 
----------------------------------------------------------------  --------------- 

Format dla specjalnych segmentów systemowych

Kod:

31 0 
----------------------------------------------------------------  --------------- 
| Baza 31..24 | G | X | O | A | Limit | P | DPL | 0 | TYP | A | BAZA 23..16   | 
|             |   |   |   | V | 19..16|   |     |   |     |   |               | 
|             |   |   |   | L |       |   |     |   |     |   |               | 
----------------------------------------------------------------  --------------- 
| Baza                        | Limit                                         | 
| 15..0                       | 15..0                                         | 
----------------------------------------------------------------  --------------- 
  • A - czy odwołano się do segmentu
  • AVL - czy segment jest dostępny dla programisty
  • DPL - poziom uprzywilejowania deskryptora
  • G - granularność
  • P - segment dostępny

Teraz wyjaśnię, o co chodzi w bicie G. Jak widać na limit jest tylko 20 bitów. Jeśli bit ten ma wartość 0, to limit segmentu liczymy taki, jaki jest w tablicy. Jeśli bit ten jest zapalony, to procesor mnoży sobie tę liczbę przez 4096 i dopiero w tedy uzyskuje prawdziwy limit segmentu. Jeśli granularność jest wyłączona to można ustalić limit na max. 1MB, a jeśli włączona to na 4GB.

Bit A jest zapalany, gdy odczytano lub zapisano do segmentu. Może być to używany gdy chcemy np. "wykopać" cały segment do swapa na dysk i zwolnić miejsce dla innego procesu. Jest to używane w 99% przypadków tylko i wyłącznie ze stronnicowaniem (o tym później) aby nie tracić czasu.

Bit P mówi, czy segment jest dostępny, czyli czy można na nim wykonać jakąś operację: zapis, odczyt, wykonanie kodu.

DPL na to 2 bity. Zobacz punkt 5.4.

LDT różni się tylko tym od GDT, że musi mieć swój wpis w GDT, tzn. w GDT musi być wydzielony selektor dla LDT. Tablicę LDT ładuje się rozkazem lldt, np.

mov ax,0x18 
lldt ax

Teraz przykładowa tablica GDT (w assemblerze):

gdt_descr: 
dw 5*8-1 
dd gdt
gdt: 
dd 0,0 ; NULL Descriptor 
dd 0x0000FFFF,0x00CF9A00 ; Deskryptor kodu (baza: 0, limit: 4GB, DPL:0) 
dd 0x0000FFFF,0x00CF9200 ; Deskryptor danych (baza: 0, limit: 4GB, DPL:0) 
dd 0x0000FFFF,0x00CFFA00 ; Deskryptor kodu (baza: 0, limit: 4GB, DPL:3) 
dd 0x0000FFFF,0x00CFF200 ; Deskryport danych (baza: 0, limit: 4GB: DPL:3)

Można teraz napisać:

lgdt [gdt_descr]

i już mamy załadowaną nową tablicę. Oczywiście w trybie rzeczywistym musimy przeliczyć sobie adres naszego gdt, aby załadować. Przykład:

xor eax,eax 
mov ax,ds 
shl eax,4 
or ax,gdt_descr 
lgdt [eax]

Teraz jeszcze jedna ważna rzecz: gdy chcemy ustawić limit segmentu, pamiętajmy, że limit np. 4GB to 0xFFFFF, 1MB to 0xFFF. Po załadowaniu GDT należy opróżnić, tzw. prefetch queue w procesorze. Można to zrobić, np. instrukcją ret. Można napisać np.:

lgdt [gdt_descr] 
push dword po_gdt 
ret 
po_gdt: 
; Tutaj dalszy ciąg programu

Przykładowe funkcje w C:

/* Ta funkcja ustawia adres bazowy segmenty. Jako desc należy podać wskaźnik do wpisu w GDT, jake base, należy podać adres bazowy segmentu 
*/ 
void set_descriptor_base(unsigned long * desc,unsigned long base) 
{ 
desc[0]&=0x0000FFFF; 
desc[0]|=(base<<16); 
base&=0xFFFF0000; 
desc[1]|=((base>>16)&0xFF)|(base&0xFF000000); 
} 

/* Ta funkcja ustawia limit segmentu */ 
void set_descriptor_limit(unsigned long * desc,unsigned long limit) 
{ 
desc[0]&=0xFFFF0000; 
desc[0]|=(limit&0x0000FFFF); 
desc[1]&=0xFFFFFFF0; 
desc[1]|=limit>>16)&0xF; 
}

Stronnicowanie[edytuj]

Wraz z procesorem i386, Intel wprowadził, tzw. stronnicowanie pamięci (ang. paging). Jest to specjalny tryb pracy procesora, który daje bardzo duże możliwości zarządzania pamięcią. Pamięć w tym trybie jest podzielona na tzw. strony. Każda strona ma rozmiar 4096 bajtów (4kB). Przy stronnicowaniu mamy tzw. katalog stron (page directory, w skrócie pgdir lub PGD) oraz tablice stron (tzw. page tables). Katalog stron jest strukturą o rozmiarze 1 strony (4kB). Zawiera on 1024 wpisy typu dword. Każdy wpis do takiego katalogu może zawierać adres to 1 tablicy stron. Tablice stron znowu zawierają po 1024 wpisy, które są używane do tzw. mapowania pamięci, tzn. każdemu adresowi rzeczywistemu, możesz przypisać adres wirtualny, np. gdy adresom 0x500000-0x501000 przypiszesz adres wirtualny 0xB8000-0xB900 (czyli jedna strona), to procesor przy odwołaniach, np. pod adres 0xB8123 przetłumaczy to na adres fizyczny 0x500123. Adres aktualnego katalogu stron pamiętany jest z rejestrze CR3. Rejestr ten musi zawierać FIZYCZNY adres katalogu stron.

Dostęp do rejestru CR3 (jak i innych rejestrów sterowania) można uzyskać tylko za pomocą innego rejestru, więc aby zmienić wartość CR3, należy załadować tą wartość do, np. eax a potem z eax do cr3. Aby odblokować stronnicowanie można użyć poniższej procedury:

mov eax,adres_do_katalogu_stron 
mov cr3,eax 
mov eax,cr0 
or eax,0x80000000 
mov cr0,eax 
jmp PgEnabled 
PgEnabled:

Wyjaśnienia dot. procedury: Najpierw należy załadować rejestr CR3, ponieważ CR3 może zawierać niewłaściwą wartość i procesor może wykonać tzw. triple fault. Procesor, gdy 3 razy pod rząd wykona błędną operację, to sam się resetuje, np. gdy żadąna strona nie istnieje a my wykonamy na niej operacją, procesor zanotuje pierwszy błąd i skoczy do procedury obsługi błędu strony. Procedura obsługi błędu strony może nie istnieć (2 błąd). Procesor skoczy do jakiegoś przypadkowego adresu i wykona błędna instrukcję (3 błąd). Wtedy nastąpi reset procesora. I właśnie dla tego najlepiej używać emulatora PC do testowania OSa, ponieważ emulator wyświetli błąd, jeśli emulowany procesor się zresetuje. Wtedy w logach emulatora powinien być podany stan rejestrów procesora; Później trzeba ustawić najstarszy bit w rejestrze CR0. Teraz wystarczy tylko wykonać skok, aby unieważnić cache wewnętrzny procesora (procesor przeładowuje w cache wszystkie rejestry oraz "zauważy", że odblokowaliśmy stronnicowanie). Sam Intel zaleca w swojej dokumentacji, aby po operacji odblokowania stronnicowania wykonać krótki skok (tzw. near jump).

Teraz wyjaśnię, jak obliczać adres do wpisu w katalogu stron i tablic stronnicowania, który ma każda strona. Przykładowy program w C:

int pobierz_adres_w_pgd(unsigned long addr) 
{ 
return addr>>22; 
} 

int pobierz_adres_w_pte(unsigned long addr) 
{ 
return (addr>>12)&4095; 
}

Teraz bardziej zaawansowany przykład:

extern unsigned long pgdir[]; /* Tutaj jest nasz katalog stron */ 

unsigned long wskaznik_na_pte(unsigned long addr) 
{ 
return pgdir[addr>>24]; /* (addr>>22)>>2 bo tablica pgdir ma elementy typu long o rozmiarze 4 */ 
} 

unsigned long * wskaznik_w_pte(unsigned long addr) 
{ 
unsigned long * pte; 
pte=(unsigned long *)wskaznik_na_pte(addr); /* Tutaj pobieramy wskaźnik z katalogu stron do interesującej nas tablicy stron */ 
if(!((*pte)&1)) return NULL; /* Sprawdzamy, czy tablica stron istnieje */ 
return pte+((addr>>12)&4095); /* Tutaj pobieramy wskaźnik do wpisu w PTE */ 
}

Pobieramy wskaźnik do wpisu w tablicy stron, aby móc później zmodyfikować zawartość wpisu. Jeśli chcielibyśmy tylko pobrać zawartość wpisu, to funkcja miałaby postać:

unsigned long wpis_w_pte(unsigned long addr) 
{ 
unsigned long * pte; 
pte=(unsigned long *)wskaznik_na_pte(addr); /* Tutaj pobieramy wskaźnik z katalogu stron do interesującej nas tablicy stron */ 
if(!((*pte)&1)) return NULL; /* Sprawdzamy, czy tablica stron istnieje */ 
return pte[((addr>>12)&4095)]; /* Tutaj pobieramy wartość wpisu w PTE */ 
}

Jak już wspominałem, bity 0-11 we wpisach są zarezerwowane dla informacji, które wykorzysta procesor. Dla wpisu do katalogu stron przeważnie używa się tylko 3 bity (tutaj podaję od razu wartości 1<<bit):

0x01 - czy strona jest obecna 
0x02 - czy stronę można zapisać 
0x04 - poziom uprzywilejowania strony (przeważnie ustawia się ten bit także dla kernela)

Dla wpisu do katalogu stron są ww. bity oraz jest kilka dodatkowych:

0x20 - czy na stronie wykonano jakąś operację (zapis, odczyt, wykonanie kodu) 
0x40 - czy do strony coś zapisano

Bity 0x200,0x400,0x800 są dostępne dla użytkownika. Reszta nieomównionych bitów musi mieć wartość zero (czyli bity 0x08,0x10,0x80,0x100); tak pisze Intel w swojej dokumentacji.

Gdy np. zapiszemy do strony zabezpieczonej przed zapisem lub odwołamy się do strony nieistniejącej procesor wykonuje tzw. Page Fault (Błąd Strony), czyli wywołuje przerwanie nr 14 (o przerwaniach w następnym podpunkcie).

Przerwania[edytuj]

Podobnie jak w trybie rzeczywistym, w trybie chronionym również są przerwania. Jednak sposób ich definiowania różni się znacząco. Nie ma takiego czegoś jak tablica wektorów przerwań w trybie rzeczywistym, która ma ustaloną pozycję. W trybie chronionym jest tzw. Tablica Deskryptorów Przerwań (IDT - Interrupt Descriptor Table). Ma ona podobny format jak GDT. Tak samo jest nagłówek, który zawiera informacje o tablicy i jej położeniu oraz tablica właściwa, w której zapisane są adresy oraz flagi przerwania, więc procedura obsługi przerwania może w ogóle nie istnieć, czyli procesor nie wykona żadnego kodu (w trybie rzeczywistym skoczyłby pod zdefiniowany w tablicy wektorów, prawdopodobnie losowy adres i by wykonał potrójny błąd). W trybie chronionym rozróżniamy kilka typów przerwań: przerwania sprzętowe, przerwania użytkownika oraz wyjątki procesora. Wpisy od 0x00-0x1F są zarezerwowane na wyjątki procesora, od 0x20-0x2F na przerwania sprzętowe, oraz od 0x30-0xFF na przerwania użytkownika (tutaj mogłem się pomylić czy do 0xFF, ponieważ w systemach wieloprocesorowych (SMP) wpisy gdzieś pod koniec są używane przez procesor oraz APIC, ale systemy SMP nas tutaj nie interesują). Rejestr IDTR przechowuje liniowy adres i wielkość IDT. IDT można załadować rozkazem:

lidt [eax] 
lidt [polozenie]

Pewnie już zauważyłeś(aś), że istnieje także instrukcja sidt, która ma podobne działanie do sgdt.

W trybie chronionym każdy typ przerwań ma swój priorytet, czyli te z większym priorytetem są najpierw wykonane przez procesor.

  • Priorytet Klasa przerwania lub wyjątku
  • NAJWYŻSZY Wyjątki, oprócz tych służących do debugowania
  • Instrukcje "pułapki" INTO, INT n, INT 3
  • Pułapki debugujące dla AKTUALNIE WYKONYWANEJ instrukcji
  • Pułapki debugujące dla NASTĘPNEJ instrukcji
  • Przerwania niemaskowalne (NMI)
  • NAJNIŻSZY Zwykłe przerwanie

Istnieją 3 rodzaje wpisów w IDT: TASK GATE, INTERRUPT GATE i TRAP GATE. INTERRUPT GATE jest dla zwykłych przerwań. TRAP GATE są dla przerwań z kodem błędu (procesor kładzie taki kod na stos). TASK GATE jest dla bramki przełączającej zadania. Teraz przykładowy kod, jak zresetować komputer :)

cli 
lidt [temp_gdt] 
sti 
int 0 

temp_gdt: 
dw 0 
dd 0

Nazywa się to Deliberate Triple Fault. Procesor sam się resetuje. Gdy jest wykonywane przerwanie w trybie chronionym, podobnie jak w trybie rzeczywistym, na stos kładzione są rejestry: ss, esp, eflags, cs, eip tylko, że te rejestry są 32-bitowe, a nie 16-bitowe, jak ma to miejsce w trybie rzeczywistym.

Trochę informacji o programowaniu sprzętu[edytuj]

Dość tej teorii, teraz kolej na praktykę. W tym podpunkcie od razu będę prezentował gotowe, działające procedury (tzw. code snippets).

Odblokowywanie linii A20[edytuj]

Linia A20 służy do odblokowywania dostępu do całej pamięci. Jeśli byśmy tego nie zrobili, pamięć byłaby dostępna tylko, co 1MB. To rozwiązanie istnieje jeszcze, ze względu na zaszłości historyczne. Swoją drogą, myślę, że w nowych płytach głównym mogliby wykurzyć to rozwiązanie. Ktoś może powiedzieć, że jest to potrzebne, aby się uruchamiały stare programy. Ciekawe jakie? No, ale nic. Trzeba wklepać kod, aby wejść w tryb chroniony. Podaję kod w assemblerze:

setup_a20: 
call empty_8042 
mov al,0xD1 
out 0x64,al 
call empty_8042 
mov al,0xDF 
out 0x60,al 
call empty_8042 

empty_8042: 
dw 0xEB,0xEB 
in al,0x64 
test al,2 
jnz empty_8042

Przekierowywanie przerwań sprzętowych pod ich właściwy adres[edytuj]

Tę procedurę trzeba wykonać, jeśli chcemy w trybie chronionym użyć przerwań sprzętowych. Jak wam się to nie podoba, to zwalcie na IBM, bo to wszystko ich wina :) Podaję kod w assemblerze. Zamiana na C jest bardzo prosta:

mov al,0x11 
out 0x20,al 
dw 0xEB,0xEB 
out 0xA0,al 
dw 0xEB,0xEB 
mov al,0x20 
out 0x21,al 
dw 0xEB,0xEB 
out 0xA1,al 
dw 0xEB,0xEB 
mov al,0x04 
out 0x21,al 
dw 0xEB,0xEB 
mov al,0x02 
out 0x21,al 
dw 0xEB,0xEB 
mov al,0x01 
out 0x21,al 
dw 0xEB,0xEB 
out 0xA1,al 
dw 0xEB,0xEB 
mov al,0xFF 
out 0x21,al 
dw 0xEB,0xEB 
out 0xA1,al

Rozkaz EB to nie piwo :( To tylko instrukcja, która mówi procesorowi, żeby skoczył instrukcję do przodu. W C nie trzeba dawać tych opóźnień, ponieważ gdy używasz instrukcji outportb, to i tak występuje opóźnienie przy jej wywoływaniu. 4 ostatnie linijki służą do zablokowania wszystkich przerwań sprzętowych. Istnieje też procedura BIOSu, aby odblokować linię A20, ale zacytuję:

This is how real programmers do it!

Linus Torvalds

Skrypt LD[edytuj]

Do naszego prostego systemu możemy sobie napisać skrypt dla linkera LD. Ma on następującą postać:

OUTPUT_FORMAT("binary") 
OUTPUT_ARCH("i386")
ENTRY("start") 
SECTIONS { 
.text 0x100000 : { 
code = . ; _code = . ; 
*(.text) 
} 
.data : { 
*(.data) 
} 
.bss : { 
bss = . ; _bss = . ; 
*(.bss) 
*(.COMMON) 
} 
end = . ; _end = . ; 
}

Skorzystamy z niego przy użyciu bootloadera GRUB.

Użycie GRUBa[edytuj]

Jest to bardzo dobry bootloader. Gdy załaduje nasze jądro, to odblokuje A20, wejdzie w tryb chroniony i skoczy do punktu wejścia jądra, więc odpada nam wiele roboty. GRUB jest kompatybilny z tzw. standardem Multiboot, tzn. nasz kernel musi zawierać specjalny nagłówek (chyba max. w pierwszych 4kB pliku). Teraz nasz przykładowy nagłówek

EXTERN code,bss,end 
mboot: 
dd 0x1BADB002 ; Sygnatura 
dd 0x10001 ; Flagi dla bootloadera 
dd -(0x1BADB002+0x10001) ; suma kontrolna nagłówka 
dd mboot ; Pozycja nagłówka w pliku 
dd code 
dd bss 
dd end 
dd _start 

_start: 
; Tutaj jest początek kodu jądra

Tutaj właśnie korzystamy z symboli, jakie zdefiniowaliśmy w skrypcie linkera ld. Oczywiście moglibyśmy napisać swojego bootloadera, ale po co skoro GRUB jest bardzo dobry.

Piszemy proste jądro systemu![edytuj]

Najpierw zapisz sobie skrypt ld z podpunktu 6.3 do jakiegoś pliku, np. kernel.ld.

Teraz piszemy tzw. startup code w assemblerze (plik start.asm):

[BITS 32] 
[SECTION .text] 
EXTERN code,bss,end 
mboot: 
dd 0x1BADB002 ; Sygnatura 
dd 0x10001 ; Flagi dla bootloadera 
dd -(0x1BADB002+0x10001) ; suma kontrolna nagłówka 
dd mboot ; Pozycja nagłówka w pliku 
dd code 
dd bss 
dd end 
dd start 

GLOBAL start 
start: 
cli 
mov esp,kstack+4096 
mov ax,0x10 
mov ds,ax 
mov es,ax 
mov fs,ax 
mov gs,ax 
lgdt [gdt_descr] 
jmp .1 
.1: 
push dword 0 
push dword 0 
push dword 0 
push dword L6 
EXTERN start_kernel 
push dword start_kernel 
ret 
L6: 
jmp L6 

[SECTION .bss] 
kstack: resd 1024 

[SECTION .data] 
gdt_descr: 
dw 256*8-1 
dd gdt 

GLOBAL gdt 
gdt: 
dd 0,0 
dd 0x0000FFFF,0x00CF9A00 
dd 0x0000FFFF,0x00CF9200 
dd 0,0 
times 254 dd 0,0

Teraz kod prostego jądra systemu (plik kernel.c) :

static char * video_fb=(char *)0xb8000; 
void putc(char c) 
{ 
*video_fb++=c; 
video_fb++; 
} 

void puts(char * s) 
{ 
for(;*s;) putc(*s++); 
} 

void start_kernel(void) 
{ 
puts("Hello World !!!"); 

for(;;); 
}

Teraz kompilujemy:

nasm start.asm -f elf32 -o start.o 
gcc kernel.c -m32 -O2 -fno-pie -fno-builtin -fomit-frame-pointer -c -o kernel.o 
ld -Tkernel.ld -o kernel.bin start.o kernel.o

I teraz w pliku kernel.bin mamy najbardziej debilne i proste jądro systemu :) Teraz tylko przy użyciu GRUBa uruchamiamy je.

Uruchamianie w VirtualBox[edytuj]

Zakładam że udało ci się skompilować jądro, czyli że masz pliczek kernel.bin. VirtualBox najprościej mówiąc służy do uruchomienia systemu w systemie. Do ściagniecia masz tutaj, nie powinno być z tym problemu. Zainstaluj i uruchom.

Pewnie pojawi Ci się jakieś okienko, klikaj dalej dalej, nie powinno być problemu. Będziesz miał okno menadżera, naciśnij "Nowa". Dalej, wpisz jakaś nazwę, może to być "Mój pierwszy system", a w typie (poniżej) Other oraz Other/Unknown i Dalej. Tutaj można określić rozmiar pamięci, zostaw domyślnie i dalej. Teraz ustawia się wirtualny dysk, pozostaw domyślną opcję (Stwórz nowy wirtualny dysk twardy) chociaż chyba to można pominąć, i dalej. Teraz będzie określanie typu tego dysku, pozostaw domyślnie VDI i dalej. Dalej. W tym miejscu można ustawić rozmiar tego dysku oraz lokalizacje. Jak chcesz to możesz coś pozmieniać albo nie i dalej. No i Create. No i masz w menadżerze nowy wirtualny system, chociaż może bardziej pasuje komputer...

Teraz by pasowało wsadzić w wirtualny napęd obraz płyty z systemem. No to teraz trzeba zrobić sobie ten obraz płyty. Przedstawię tutaj metodę która działa na Ubuntu, wraz z GRUB-em. W swoim katalogu domowym, utwórz folder iso, w nim boot, a w nim grub, całość powinna wyglądać następująco: ~/iso/boot/grub/. Do katalogu grub skopiuj kernel.bin. Zrób plik grub.cfg w którym zapisz:

menuentry 'Moj pierwszy OS' {
    multiboot /boot/grub/kernel.bin
}

Teraz do terminalu przepisz (albo skopiuj ;)):

sudo apt install xorriso
grub-mkrescue -o kernel.iso iso

W ten sposób otrzymasz plik kernel.iso, już coś z nim można zrobić :)

Przejdź do menadżera VirtualBox'a. Zaznacz swój system, i naciśnij Ustawienia. Przejdź do Nośniki, będziesz miał tam drzewko nośników. Wybierz Kontroler IDE i naciśnij przycisk po prawej z pojedynczą płytą z plusikiem (Dodaj płytę CD/DVD). Następnie Choose disk, i wybierz obraz płyty który otworzyliśmy przed chwilka, czyli kernel.iso. Naciśnij OK u dołu aby zamknać okno Ustawień. Świetnie! Teraz naciśnij na przycisk Uruchom. Otworzy ci się okno z wirtualnym systemem, po chwili będziesz miał "okno" GNU GRUB, naciśnij Enter aby uruchomić system. no i powinno Ci się pojawić piękny napis "Hello World !!!". Mam nadzieje że pomogłem ;)

Uruchamianie w qemu[edytuj]

Qemu to otwarty i szybki emulator. Jest on przez wielu ludzi używany jako pierwszy do testu danego jądra. W ubuntu instalujemy go tak:

sudo apt install qemu

Gdy implementujemy multiboot w wersji 1(jest też druga, bardziej zaawansowana) mamy możliwość testowania pliku wygenerowanego bezpośrednio przez ld. Aby tego dokonać wydajemy polecenie:

qemu-system-i386 -kernel kernel.bin

Niestey qemu nie czyści ekranu w momencie bootowania, więc nasz tekst będzie najprawdopodobniej zlepiony z resztkami, które tam zostały.