Pisanie OS/Wyjątki i przerwanie zegarowe - gdzie wykorzystać?
Wstęp
edytujPo 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
edytujJak 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
edytujJest 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
edytujCiekawe 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
edytujNasza 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]
Uwaga!
|
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
edytujTeraz 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
edytujWyją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
edytujWyróż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
edytujWyją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
edytujWystarczy 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.
UWAGA! WARNING! ACHTUNG!
Zapamiętajcie to raz na zawsze, bo nie będę powtarzać ;) Kompilatory pod Linuxa generują nazwy bez prefixów "_". Kompilatory pod DOS/Win (ogólnie, które używają formatów COFF lub PE-COFF, OBJ) generują nazwy w assemblerze, które zawierają dodatkowo prefix "_". Czyli DJGPP wygeneruje z Dla Linuxa:
W Makefile zmieniamy |
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.
Uwaga!
|
.
Piszemy procedury w C
edytujTeraz 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
edytujTSS 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.