Pisanie OS/Wyjątki i przerwanie zegarowe - gdzie wykorzystać?

Wstęp edytuj

Po przeczytaniu 2 poprzednich części nauczyłeś się bardzo dużo. Wystarczyło by to do napisania najbardziej debilnego OSa, no ale qrde, łi łant mor :).

W tej części będzie dość dużo teorii na temat wyjątków, przerwania zegarowego oraz dodamy obsługę wyjątków procesora do naszego kernela. W 2 części zapomniałem opisać, które przerwania sprzętowe służą do czego. Na początek zajmę się przerwaniami sprzętowymi.

Przerwania sprzętowe edytuj

Jak wspomniałem wyżej, nie napisałem, które przerwania sprzętowe służą do czego. Śpieszę z wyjaśnieniem (opiszę tylko najczęściej wykorzystywane przerwania):

Kod: numer przerwania krótki opis
0 przerwanie zegarowe
1 klawiatura
3 UART
4 UART
7 port równoległy
13 FPU
14 IDE0
15 IDE1


Klawiatura Jest to domyślne przerwanie, które może być użyte wyłącznie przez klawiaturę. Nawet jeśli masz klawiaturę na USB, to i tak używa ona przerwania 1.

UART (3) i UART (4) Są to przerwania używane przez porty szeregowe

port równoległy Chyba nie trzeba wyjaśniać...

FPU Przerwanie to jest używane przez koprocesor matematyczny do raportowania błędów. Jeśli przełączymy zadanie i użyjemy koprocesora matematycznego, wygeneruje on to przerwanie, aby system mógł np. zmienić kontekst koprocesora (tzn. zapisać jego stan w danych ostatniego zadania, które używało procesor i załadować kontekst z aktualnego zadania).

IDE0 i IDE1 Są to przerwania używane przez kontroler IDE. Są one bardzo pomocne przy obsłudze twardych dysków i transferów DMA (o tym będzie kiedy indziej), ponieważ nie zmuszają do czekania na określony stan dysku przez system operacyjny.

Przerwanie zegarowe edytuj

Jest to chyba najczęściej wykorzystywane przerwanie w komputerze ;). Linia przerwania zegara jest kontrolowana przez PIT (Programmable Interrupt Timer). Normalnie po starcie komputera BIOS ustawia częstotliwość ok. 16Hz (czyli jest wywoływane 16 razy na sekundę, jakby ktoś nie wiedział co to są Hertze :)). To trochę wolno, no ale cóż. IBM tak zrobił w swoich PC, i tak zostało do dzisiaj ze względu na kompatybilność wsteczną. My w naszym OSie zmienimy tę częstotliwość na 100 Hz, tak jak jest to robione we wszystkich innych OSach. Nasze przerwanie będzie wywoływane 100x na sekundę, więc jego procedura musi być dość szybka. Ale najpierw mały kod jak zmienić częstotliwość tego przerwania.

void ustaw_czestotliwosc_pit(unsigned long hz) 
{ 
unsigned long val=1193180/hz; 
outb(0x36,0x43); 
outb(val&0xff,0x40); 
outb((val>>8)&0xff,0x40); 
}

Wpisanie wartości 0x36 do portu 0x43, powie PITowi, że chcemy mu przeprogramować częstotliwość przerwania. Może cię zdziwić, dlaczego dzielimy 1193180 przez Hz. Już wyjaśniam: znowu względy kompatybilności (aaargh!). 1193180 to była prawdopodobnie (nie jestem pewien) częstotliwość jakiej karta CGA używała do synchronizacji. Narzekanie nic nie pomoże :).

Wklej tą procedurę do swojego OSa, można do pliku main.c. Po kbd_init(); w start_kernel() (main.c) wstaw ustaw_czestotliwosc_pit(100). Ustawi to nasz zegar systemowy na 100Hz.


Zastosowanie przerwania zegarowego edytuj

Ciekawe do czego można zastosować przerwanie wykonywane 100x na sekundę?. Oczywiście do napisania schedulera (czyli najprościej mówiąc podprogramu, który decyduje jakie zadanie ma być wykonane w danym momencie). Można też wykonywać jakąś inną procedurę, np. wyświetlanie literek na ekranie. Jest to może zbytnio wyszukane, ale trzeba od czegoś zacząć.


Piszemy procedurę obsługi przerwania zegarowego edytuj

Nasza procedura będzie identyczna jak procedura obsługi klawiatury.

GLOBAL irq0 
irq0: 
     push gs 
     push fs 
     push es 
     push ds 
     pusha 
     mov ax,0x10 
     mov ds,ax 
     mov es,ax 
     mov al,0x60 
     out 0x20,al 
EXTERN do_irq0 
     call do_irq0 
     popa 
     pop ds 
     pop es 
     pop fs 
     pop gs 
     iret

Skopiuj ją zaraz po kodzie procedury irq1. W dołączonych plikach jak zwykle czeka gotowa wersja, aby ją skompilować i odpalić :) (lenistwo rulez ;) ).

Teraz musimy "zainstalować" tę procedurę do IDT. Najpierw piszemy w main.c po includach:

extern void irq0(void);


Po ustaw_czestotliwosc .... i przed sti() dajemy:

set_intr_gate(0x20,&irq0);

Teraz zróbmy sobie plik sched.c. Tam będzie się znajdował nasz przyszły scheduler. Na razie napiszemy tylko kretyńską procedurę, która będzie wyświetlała kolejne znaki ASCII z lewym górnym rogu ekranu. Ma ona postać:

void do_irq0(void) 
{ 
__asm__ __volatile__("incb 0xb8000"); 
}

Zastosowałem asembler AT&T. W NASMie wygląda to tak:

inc byte [0xb8000]

Teraz dodajemy przed sti() w start_kernel():

enable_irq(0);

Kompilujemy kernel i odpalamy. Zapomniałem dodać, że do listy OBJS w Makefile dodajemy także sched.o. Piszemy make i naciskamy enter. Jak odpalisz kernela, w lewym górnym rogu ekranu będą migać literki. Jeśli tego nie widzisz, to znaczy, że albo kod jest skopany, albo musisz kupić sobie okulary ;). Najbardziej prawdopodobna jest ta pierwsza możliwość (kod zawsze testuję na moim PC i pod emulatorem, więc nie powinno być problemów).

Wyjątki procesora edytuj

Teraz będzie kupa teorii. Nie ma lekko ;). Teoria też jest potrzebna w niektórych przypadkach. Na początek pod obróbkę idzie lista wyjątków (na podstawie manuala do 386. W procesorach Pentium i nowszych jest jeszcze kilka innych wyjątków.

Numer                  Opis 
0                  Błąd dzielenia 
1                  Wyjątek debug 
2                  NMI 
3                  Breakpoint (int 3) 
4                  Przepełnienie (instrukcja INTO) 
5                  Sprawdzenie granic (BOUND) 
6                  Błędna instrukcja 
7                  Koprocesor nie dostępny 
8                  Podwójny błąd 
9                  (zarezerwowane) 
10                  Błędny TSS 
11                  Segment nie obecny 
12                  Wyjątek stosu 
13                  Generalny błąd ochrony 
14                  Błąd strony 
15                  (zarezerwowane) 
16                  Błąd koprocesora 


Stos po wywołaniu wyjątku edytuj

Wyjątki dzielą się na takie co posiadają kod błędu i nie posiadają kodu błędu. Kod błędu jest to po prostu wartość na stosie. W [esp] jest kod błędu, w [esp+4] jest EIP, w [esp+8] jest CS, itd. Zawartość stosu jest dokładnie taka sama jak po wykonaniu przerwania.


Typy wyjątków edytuj

Wyróżniamy 3 typy wyjątków:

Faults

Wartości CS i EIP na stosie przerwania wskazują _NA INSTRUKCJĘ_, która wywołała błąd.

Traps

CS i EIP wskazują zaraz po instrukcji, która wywołała błąd. Jeśli ten typ wyjątku zostanie wykonany podczas instrukcji, która zmienia bieg programu CS i EIP wskazują miejsce docelowe. Na przykład instrukcja jmp narusza bieg programu. CS i EIP będą wskazywać na miejsce gdzie miałby być wykonany skok.

Aborts

Wyjątek typu Abort nie wskazuje dokładnego miejsca gdzie mógł wystąpić błąd. Wyjątki tego typu są używane, np. gdy procesor chce przekazać do programu gdy wystąpił błąd sprzętowy, np. w nowych procesorach wyjątek Machine Check może mówić, że procesor się przegrzał.


Opisy wyjątków edytuj

Wyjątek 0

Błąd dzielenia może wystąpić, gdy wykonamy instrukcję DIV lub IDIV a dzielnikiem będzie liczba 0.

Wyjątek 1

Procesor wywołuje ten wyjątek z różnych powodów. To przerwanie jest typu Exception albo Fault. Procesor nie kładzie na stos kodu błędu. Program może sprawdzić, co wywołało wyjątek za pomocą rejestrów debugujących.

Wyjątek 3

Jest on wywoływany przez instrukcję int 3. Ma ona kod jednobajtowy. CS:EIP wskazuje na bajt przed tą instrukcją. Instrukcja ta jest używana przeważnie przez debuggery.

Wyjątek 4

Ten wyjątek jest wywoływany, jeżeli wykonamy instrukcję INTO oraz flaga OF (overflow) jest ustawiona w rejestrze FLAGS.

Wyjątek 5

Ten wyjątek zostanie wywołany, gdy wykonamy instrukcję BOUND, a jej parametry będą poza granicami ustalonymi przez programistę.

Wyjątek 6

Ten wyjątek zostanie wywołany gdy jednostka wykonawcza procesora napotka niewłaściwą instrukcję. Wyjątek zostanie wykonany gdy procesor spróbuje wykonać taką instrukcję. Wczesne pobieranie (ang. prefetch) nie powoduje tego wyjątku. Procesor nie kładzie kodu błędu na stos. Wyjątek zostanie także wywołany, gdy parametry instrukcji będą niepoprawne.

Wyjątek 7

Ten wyjątek może zostać wykonany w dwóch przypadkach:
  • Procesor wykona instrukcję ESC (escape), a flaga EM (emulate) jest ustawiona w rejestrze sterowania 0 (CR0).
  • Procesor wykona instrukcję ESC lub WAIT oraz flagi MP (monitor coprocessor) i TS (task switched) są ustawione w CR0.

Wyjątek 9

Wyjątek ten zostanie wykonany, gdy koprocesor matematyczny będzie próbował uzyskać dostęp do pamięci w niewłaściwym obszarze.

Wyjątek 10

Wyjątek ten zostanie wykonany gdy wykonamy skok do błędnego TSS.

Wyjątek 11

Wyjątek ten zostanie wykonany, gdy spróbujemy uzyskać dostęp do nieistniejącego segmentu.

Wyjątek 12

Wyjątek ten zostanie wykonany, gdy załadujemy do SS niewłaściwą wartość lub stos przekroczy rozmiar segmentu.

Wyjątek 13

Ten wyjątek zostanie wywołany, gdy zrobimy błąd, który nie wywołałby żadnego innego wyjątku, np.
  • przekroczenie limitu segmentu, gdy używany CS, DS, ES, FS, GS
  • przekroczenie limitu segmentu, gdy odwołujemy się do tablicy deskryptorów
  • zapis do segmentu kodu lub segmentu danych, który ma flagę read-only
  • ładowanie CR0 z flagami PG=1 i PE=0

Wyjątek 14

Ten wyjątek, to tzw. błąd strony. (zob. opis stronicowania, część 1). Adres błędu strony jest zapisywany w rejestrze CR2. Na stosie zapisywany jest kod błędu. O tym wyjątku będzie więcej, gdy będziemy pisać menedżer pamięci.

Wyjątki z kodem błędu i bez kodu błędu

Wyjątek                  Kod błędu 
0                  NIE 
1                  NIE 
3                  NIE 
4                  NIE 
5                  TAK 
6                  NIE 
7                  NIE 
8                  TAK 
9                  NIE 
10                  TAK 
11                  TAK 
12                  TAK 
13                  TAK 
14                  TAK 

Dodajemy obsługę wyjątków edytuj

Wystarczy tej teorii. Jak zwykle polecam manual Intela do 386 :) (dla tych, którzy chcą dokładne opisy).

Teraz dodamy obsługę wyjątków do naszego mini-kernela. Na razie jądro tylko wyświetli błąd i się zawiesi. Więcej nam nie potrzeba ;) (na razie nie mamy zaprogramowanej wielozadaniowości, więc lepsza jakościowo obsługa błędów na nic nam się nie przyda). Stworzymy plik exc.asm, który będzie zawierał kod procedur obsługi błędów napisany w assemblerze. Będzie on wywoływał procedury napisane w C. Od razu będziemy mieli przygotowaną podstawę pod trudniejsze rzeczy, bez późniejszego przepisywania kodu na nowo.

Procedury obsługi wyjątków będą identyczne jak te, które służą do obsługi przerwań, będą tylko bardziej zaawansowane :). Na początek będzie coś takiego (oczywiście w pliku exc.asm) :

[section .text] 
[bits 32] 

EXTERN do_exc0 
EXTERN do_exc1 
EXTERN do_exc2 
EXTERN do_exc3 
EXTERN do_exc4 
EXTERN do_exc5 
EXTERN do_exc6 
EXTERN do_exc7 
EXTERN do_exc8 
EXTERN do_exc9 
EXTERN do_exc10 
EXTERN do_exc11 
EXTERN do_exc12 
EXTERN do_exc13 
EXTERN do_exc14 

exception_table: 
dd do_exc0,do_exc1,do_exc2,do_exc3,do_exc4 
dd do_exc5,do_exc6,_o_exc7,do_exc8,do_exc9 
dd do_exc10,do_exc11,do_exc12,do_exc13,do_exc14

Co to jest EXTERN, chyba nie muszę już tłumaczyć (to są podstawy :)). Nasze procedury w C będą się nazywać do_exc0,do_exc1,do_exc2,...,do_exc14.


Następnie zapisujemy wskaźniki do tych wszystkich _exc_ w tablicy o nazwie exception_table. Przyda się to nam później, gdy napiszemy właściwy program obsługi wyjątków (czyli za chwilę). Teraz będzie trochę długa lista:

GLOBAL exc0 
exc0: 
   push dword 0 
   push dword 0 
   jmp handle_exception 


GLOBAL exc1 
exc1: 
   push dword 0       
   push dword 1 
   jmp handle_exception 

GLOBAL exc2 
exc2: 
   push dword 0 
   push dword 2 
   jmp handle_exception 

GLOBAL exc3 
exc3: 
   push dword 0 
   push dword 3 
   jmp handle_exception 

GLOBAL exc4 
exc4: 
   push dword 0 
   push dword 4 
   jmp handle_exception 

GLOBAL exc5 
exc5: 
   push dword 5 
   jmp handle_exception 

GLOBAL exc6 
exc6: 
   push dword 0 
   push dword 6 
   jmp handle_exception 

GLOBAL exc7 
exc7: 
   push dword 0 
   push dword 7 
   jmp handle_exception 

GLOBAL exc8 
exc8: 
   push dword 8 
   jmp handle_exception 

GLOBAL exc9 
exc9: 
   push dword 0 
   push dword 9 
   jmp handle_exception 

GLOBAL exc10 
exc10: 
   push dword 10 
   jmp handle_exception 

GLOBAL exc11 
exc11: 
   push dword 11 
   jmp handle_exception 

GLOBAL exc12 
exc12: 
   push dword 12 
   jmp handle_exception 

GLOBAL exc13 
exc13: 
   push dword 13 
   jmp handle_exception 

GLOBAL exc14 
exc14: 
   push dword 14 
   jmp handle_exception

Umieszczony tutaj kod zapoda procedurze handle_exception (która będzie obsługiwać wyjątki) numer wyjątku. Jeśli wyjątek nie posiadał kodu błędu na stos zostanie umieszczona liczba 0. Jeśli wyjątek posiadał kod błędu, na stos położony zostanie wyłącznie jego numer. Dalej sterowanie przechodzi do handle_exception.

Procedury exc* musimy ustawić w kodzie c przy użyciu set_trap_gate oraz set_system_gate (tylko te dla debugowania).

Teraz pora na handle_exception:

handle_exception: 
   xchg eax,[esp] 
   xchg ebx,[esp+4] 
   push gs 
   push fs 
   push es 
   push ds 
   push ebp 
   push edi 
   push esi 
   push edx 
   push ecx 
   push ebx 
   mov ecx,0x10 
   mov ds,cx 
   mov es,cx 
   call dword [exception_table+eax*4] 
   pop eax 
   pop ecx 
   pop edx 
   pop esi 
   pop edi 
   pop ebp 
   pop ds 
   pop es 
   pop fs 
   pop gs 
   pop eax 
   pop ebx 
   iret

Procedura ta jest trochę bardziej skomplikowana niż procedury obsługi przerwania w start.asm.

Jak wiadomo po wywołaniu handle_exception mamy 2 wartości na stosie. My zrobimy małą podmianę. Zmienimy wartości ([esp] z eax oraz [esp+4] z ebx). Teraz mamy w ebx kod błędu a w eax numer wyjątku (sprytne... ;)). Potem jak zwykle rejestry gs, fs, es, ds, ebp, edi, esi, edx, ecx na stos. Ktoś może zapytać po co dajemy drugi raz ebx na stos. Odpowiadam: edx jest teraz parametrem dla funkcji w C.

Następnie wywołujemy wskaźniki z  , czyli czytamy wskaźniki do procedur po numerach wyjątków i wykonujemy procedurkę w C. Potem dlaczego zdejmujemy ze stosu najpierw eax? Ponieważ procedura w C i tak zmodyfikuje ten rejestr (w eax procedury w C zwracają wartości), a nie jest jego wartość nam do szczęścia potrzebna. Dalej zdejmujemy ze stosu rejestry ecx, edx, esi, edi, ebp, ds, es, fs, gs. Na końcu eax oraz ebx, ponieważ "podmieniliśmy" ich wartości na początku naszej procedurki. Teraz standardowo iret i wracamy do programu.

.

Piszemy procedury w C edytuj

Teraz przyszła kolej na procedury w C. Nie będę ich tu opisywał, ponieważ nie ma sensu, a wszystko będzie w pliku traps.c.

TSS edytuj

TSS to skrót od Task State Segment. Procesor zapisuje w nim stan aktualnie wykonywanego procesu. TSS zawiera praktycznie wszystkie rejestry procesora oraz kilka innych rzeczy. Struktury tej używa się głównie, aby zaprogramować wielozadaniowość. Procesor korzysta z zawartych w niej danych np. przy przejściach między różnymi poziomami uprzywilejowania.

Pola TSS (wszystkie wartości typu dword):

back_link 
esp0 
ss0 
esp1 
ss1 
esp2 
ss2 
cr3 
eip 
eflags 
eax,ecx,edx,ebx 
esp,ebp,esi,edi 
es,cs,ss,ds,fs,gs 
ldt 
trace (word) 
bitmap (word)

Teraz opiszę po kolei pola: back_link

Pole to jest ustawiane jedynie przez procesor. Wskazuje ono na poprzednio użyty TSS. Gdy flaga NT (Nested Task) jest ustawiona, procesor przy wykonaniu instrukcji iret skoczy do TSS używając pola back_link.

esp0,ss0

Stos dla poziomu uprzywilejowania 0. Są to rejestry stosu, czyli ss i esp, które zostaną załadowane po przejściu na poziom uprzywilejowania 0 z poziomu niższego. Jest to bardzo przydatne, ponieważ gdy pracujemy na poziomie 3 i nie mamy ustawionego stosu, wywołanie przerwania nie wysypie nam programu.

esp1,ss1 i esp2,ss2

Podobnie jest z esp0 i ss0. Ktoś może się zapytać gdzie esp3 i ss3 ? Odpowiadam: nie ma niższego poziomu niż 3, więc nie można przejść na poziom 3 z poziomu 4 :), więc w procesorze nie zaimplementowano esp3 i ss3.

cr3

Adres naszego katalogu stron.

eip,eflags,eax,ecx,edx,ebx,esp,ebp,esi,edi

To chyba każdy się domyśli :).

bitmap

Zawiera wskaźnik do tzw. I/O permission bitmap. Offset ten musi być względem początku TSS a nie adres fizyczny w pamięci! Gdy nie chcemy, żeby procesor używał bitmapy I/O, to ustawiamy offset WIĘKSZY niż rozmiar segmentu TSS ustawiony w GDT.