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?
- DJGPP (www.delorie.com/djgpp)
- Netwide Assembler (NASM) >=0.98 (http://nasm.sourceforge.net/)
- bootloader GRUB
- emulator PC, np. Bochs, VMWare, VirtualPC (opcjonalnie)
Adresy: http://bochs.sourceforge.net http://www.vmware.com http://www.connectix.com
Zestaw DJGPP+NASM+GRUB w zupełności wystarczy do pisania OSa. Emulator
PC jest opcjonalny. Jeśli masz mocny sprzęt (procesor min. 800MHz,
256MB RAM) to możesz użyć emulatora PC. Pozwoli to pisać np. pod
Windows czy Linuxem 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 assemblerze.
Dobra, dosyć wstępu, zabieramy się do roboty. Na początku jednak trzeba
będzie opisać trochę sprzętu, aby wiedzieć co się robi. Dla chcących
więcej polecam dokumentację Intela do procesora 386 ;) (ja
przeczytałem).
2. Sprawy organizacyjne
Jeśli chcesz pisać OSa, musisz sobie zadać kilka ważnych pytań:
a) Po co piszę system operacyjny?
- aby nauczyć się, jak programować wielozadaniowość, pamięć wirtualną, drivery, system plików, itp.
- dostępne systemy mi nie wystarczą
- aby napisać OSa, który próbuje nauczyć projektowania OSów
- sława i chwała, władza nad światem :)
b) 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
Gdy piszesz pierwszego OSa, nie stawiaj sobie dużych wymagań. Jest to
bardzo niedobre. Wiele OSów właśnie padło przez tzw. słomiany zapał
twórców.
3. Tryby pracy procesorów
3.1. Tryb rzeczywisty
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.
3.2. Tryb chroniony
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.
4. Opis trybu rzeczywistego
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.
4.1. Inicjalizacja komputera
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.
4.2. Pamięć w trybie rzeczywistym
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
4.3. Przerwania w trybie rzeczywistym
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ływanie 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.
5. Opis trybu chronionego
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.
UWAGA: Opisywane tutaj rzeczy są w większości przypadków prawdziwe tylko dla procesorów 386 i lepszych.
5.1 Rejestry
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ę.
5.2 Stos
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.
5.3 Rejestry dodatkowe
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.
5.4 Poziomy uprzywilejowania
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.
5.5 Pamięć w trybie chronionym
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 na 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.
i już mamy załadowaną nową tablicę. Oczywiście w trybie rzeczywistym
musimy przeliczyć sobie adres naszego gdt, aby załadować. Przykład:
Kod:
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.:
Kod:
lgdt [gdt_descr]
push dword po_gdt
ret
po_gdt:
; Tutaj dalszy ciąg programu
Przykładowe funkcje w C:
Kod:
/* 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]|=(base&0x0000FFFF);
desc[1]&=0xFFFFFFF0;
desc[1]|=(base>>16)&0xF;
}
5.6 Stronnicowanie
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 0x500123 przetłumaczy to na adres fizyczny
0xB8123. Adres aktualnego katalogu stron pamiętany jest z rejestrze
CR3. Rejestr ten musi zawierać FIZYCZNY adres katalogu stron.
UWAGA: Wszytkie adresy używane przez tablice stronnicowania oraz
rejestr CR3 muszą mieć dolne 4kB zerowe, np. adres 0x12345000 jest
poprawny. Musi tak być, ponieważ dolne 12 bitów jest wykorzystane przez
procesor na kilka bitów definiujących prawa dostępu do strony.
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:
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).
UWAGA: Mówimy tutaj o rzeczach, które będą chodzić na procesorach od
386SX. W nowych procesorach jest specjalne rozszerzenie, które pozwala
mieć strony o rozmiarze 4MB, ale nie będę go tutaj omawiał.
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:
Kod:
int pobierz_adres_w_pgd(unsigned long addr)
{
return addr>>22;
}
int pobierz_adres_w_pte(unsigned long addr)
{
return (addr>>12)&4095;
}
Uwaga: Liczymy tutaj adresy względem początków tych tablic, więc są to tylko offsety. Teraz bardziej zaawansowany przykład:
Kod:
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ć:
Kod:
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):
Kod:
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
Kod:
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).
5.7 Przerwania
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:
Kod:
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 :)
Kod:
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.
6. Trochę informacji o programowaniu sprzętu
Dość tej teorii, teraz kolej na praktykę. W tym podpunkcie od razu będę
prezentował gotowe, działające procedury (tzw. code snippets).
6.1 Odblokowywanie linii A20
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:
Kod:
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
6.2 Przekierowywanie przerwań sprzętowych pod ich właściwy adres
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:
Kod:
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:( 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ę Linusa Torvaldsa "This is how REAL
programmers do it".
6.3. Skrypt LD
Do naszego prostego systemu możemy sobie napisać skrypt dla linkera LD. Ma on następującą postać:
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
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.
7. Piszemy proste jądro systemu!!!
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):
Kod:
; ------------------------ UTNIJ TU ------------------------------
I teraz w pliku kernel.bin mamy najbardziej debilne i proste jądro systemu :) Teraz tylko przy użyciu GRUBa uruchamiamy je.
7. Zakończenie i wyjaśnienia
Część druga pojawi się niedługo. W drugiej części napiszemy obsługę ekranu i klawiatury.
Cały kurs planuję rozłożyć na kilka części:
1. From zero do hero :) (to co czytacie teraz)
2. Tu nauczymy się obsługi przerwań sprzętowych, wyjątków procesora
oraz jak zaprogramować obsługę konsoli tekstowej (czyli
ekran+klawiatura).
3. W tej części opiszę przerwanie zegarowe oraz do czego można je wykorzystać.
4. Tu nauczymy się programować wielozadaniowość z użyciem setjmp i longjmp.
5. Tutaj bardziej rozbudowana rzecz: wielozadaniowość z zamianą stosu
oraz napiszemy prosty scheduler. Będzie też o kolejkach oczekiwania i
zaimplementujemy proste procedury sleep_on i wake_up.
6. ... Na razie nie mam pomysłu. Czekam na sugestie.
Ilość części będzie zależała od tego, na ile lekcji rozłożę całą moją
wiedzę (może nawet napiszemy stos TCP/IP, ale do tego jeszcze bardzo
daleko, conajmniej kilkanaście lekcji).
UWAGA: Nie będziemy zajmować się żadnymi wzorami matematycznymi, bo po
co. Chyba tylko wykłady na studiach są takie powalone, gdzie przy
omawianiu np. lexa czy bisona, podane są wzory matematyczne. Po co mi
to potrzebne ?
Jak będę znał nawet 88273489237489234798234 wzorów to i tak nie napiszę
kompilatora czy systemu operacyjnego. Tutaj liczy się tylko praktyka.
UWAGA 2: Do wszystkich "wielkich algorytmików": bez takich jak my,
którzy piszą systemy operacyjne i kompilatory, to byście wiele nie
narobili. Dla tego proszę nie przysyłać bluzgów, że algorytmy są lepsze.
UWAGA 3: Nie myśl, że wiesz wszystko. Trzeba naprawdę dużo wiedzieć, żeby zrozumieć jak mało się wie.
8. Pliki
Tutaj zamieszczam archiwum .ZIP, zakodowane programem UUencode z Windows Commandera.
Linii z napisem "UTNIJ TU" oczywiście nie przenosimy do nowego pliku.
Zawartość po linii z napisem "UTNIJ TU" przenosimy do jakiegoś pliku i
dekodujemy. Najlepiej przy użyciu Windows Commandera.
; ------------------------ UTNIJ TU ------------------------------
sum -r/size 33514/1123