Pisanie systemów operacyjnych cz. III - przerwania, wyjątki, GRUB.

Wolverine

Długo trwało zanim się zdecydowałem napisać kolejną część tej paplaniny ale pomyślalem sobie, że im wiecej opublikuje swoich "dzieł" tym lepiej. Jeśli zrozumiałeś wszystko z poprzednich części to gratulacje bo nie wszystkim początkującym deweloperom OSów to się udaje. Nie ukrywam, że poniższy tekst może zostać potraktowany jako napisany na "odwal się", być może tak jest, jeśli wyjdzie tak źle to daj znać ;).

0x01. Założenia.

Standardowo najpierw przedstawie to co zrobimy w tej części kursu. Nasz 32 bitowy system wzbogacimy o przerwań i wyjątków, nauczymy się też korzystać ze stronnicowania i w końcu dowiemy się jak załadować nasz system za pomocą GRUBa. Jako źródła udostępnie okrojąną wersje systemu nad którym pracuje - Draco.

0x02. Co nam będzie potrzebne.

Tym razem wystarczy zestaw z drugiej części kursu, czyli kompilator C (lekko odchudzoną wersje DJGPP możesz pobrać z http://www.daminet.pl/~wolverine/djgpp.rar), Netwide Assembler (http://nasm.sourceforge.net/), Bochs (http://bochs.sourceforge.net/) i GRUB (jeśli nie chcesz sam go kompilować polecam obraz znajdujący się w załączniku do kursu).

0x03. Organizacja folderów.

Każdy z nas chyba wie, jak ważny jest porządek w plikach źródłowych i ich wzajemne położenie. My w głownym katalogu projektu umieścimy pliki źródłowe C, ASM i plik link.ld, w katalogu bin znajdować się będzie obraz dyskietki do nagrania lub uruchomienia w emulatorze, folder include zawierać będzie pliki nagłowkowe projektu, a w scripts umieścimy skrypty do kompilacji (make jakos mi nie podchodzi, dlatego do źródeł dołącze plik bat), mape pamięci i plik konfiguracyjny Bochsa. W każdym pliku C dołączać będziemy główny plik nagłówkowy o nazwie draco.h.

0x04. Dynamika, dynamika.

Większość deweloperów tablice GDT i IDT tworzy gdzies w kodzie systemu, przez co jego objętość wzrasta, ja przedstawie sposób w jakim jest t zrobione w moim systemie Draco. Sprawa jest prosta, po prostu umawiamy się, że w danym miejscu w pamięci bedzię GDT i IDT, tworzymy do tego miejsca wskaźnik i zapisujemy co trzeba, powiedzmy, że GDT będzie pod adresem 0x2000 a IDT pod 0x2500, co nam da po 2 KiB na każde z nich. Od razu zdefiniujemy miejsce na katalog i tablice stron (o tym później), które powinny zajmować po 4 KiB.

#define KERNEL_PAGEDIR        0x0
#define KERNEL_PAGETAB        0x1000
#define KERNEL_GDT            0x2000
#define KERNEL_IDT            0x2500

Aby poprawnie załadować GDT i IDT musimy jeszcze stworzyć nagłowek dla naszych tablic. Możemy je umieścić w pliku entry.asm.

gdt_descr:
  .size: dw 2048
  .addr: dd 0x2000

idt_descr:
  .size: dw 2048
  .addr: dd 0x2500

Nie przejmuj się, że nie stworzymy po 255 wpisów w GDT, jeśli nie użyjemy błędnego selektora nic złego się nie stanie.

Oprócz tego będziemy musieli je załadować/przeładować. Do tego również napiszemy funkcje w assemblerze.

[GLOBAL load_gdt]
load_gdt:
  lgdt [gdt_descr]
  ret
  
[GLOBAL load_idt]
load_idt:
  lidt [idt_descr]
  ret

Od razu podam struktury, które będziemy za chwilę używać.

typedef struct {
  unsigned short offset_0;
  unsigned short selector;
  unsigned short type;
  unsigned short offset_16;
} gate_desc;

typedef struct  {
  unsigned short limit;
  unsigned short base_0_15;
  unsigned char base_16_23;
  unsigned char dpl_type;
  unsigned char gav_lim;
  unsigned char base_24_31;
} sys_desc;

Pierwsza jest wpisem w IDT, druga w GDT, którego format już znasz.

0x05. Format IDT.

Format wpisu w IDT jest bardzo prosty. Pierwsze 16 bitów to młodsze bity adresu ISRa, następne 2 bajty określają selektor kodu w GDT, następne typ i na końcu starsze bity adresu ISRa.

typedef struct {
  unsigned short offset_0;
  unsigned short selector;
  unsigned short type;
  unsigned short offset_16;
} gate_desc;

Gdy już znamy format takiej tablicy możemy napisać funkcje w C do podczepiania funkcji przerwań (ISRów).

void setup_int(int i, unsigned long p_hand, unsigned short type)
{
   gate_desc *idt = (gate_desc*)KERNEL_IDT;
   idt[i].offset_0  = p_hand;
   idt[i].selector  = 0x08;
   idt[i].type      = type;
   idt[i].offset_16 = (p_hand >> 16);
}

Funkcja podczepi nam ISRa podanego w p_hand do przerwania i, który jest typem type.

0x06. Dynamiczne GDT.

Gdy już wiemy jak modyfikować IDT z poziomu kodu C możemy to samo zrobic z GDT.

void setup_seg(int i, unsigned long base, unsigned long limit, char type)
{
   sys_desc *desc = (sys_desc*)KERNEL_GDT;
   desc[i].limit      = limit & 0xFFFF;
   desc[i].base_0_15  = base << 16;
   desc[i].base_16_23 = (base & 0xFF0000) >> 16;
   desc[i].dpl_type   = 0x90 | type;
   desc[i].gav_lim    = ((limit & 0xF0000) >> 16) | 0xC0;
   desc[i].base_24_31 = 0;
}

Funkcja zawsze ustawia bit granularity jako zapalony, więc nie możemy jej użyć do ustawienia NULL Descriptor, musimy również zapamiętać, że limit jest mnożony przez 4KiB (lub jak kto woli podajemy ilość stron w segmencie).

0x07. ISR.

ISR jest funkcją wywoływaną przez procesor w momencie przerwania. Nie jest ona skomplikowana ale musi uwzględniać pewną rzecz, otóż przerwanie jak nazwa wskazuje przerywa wykonywanie aktualnego kodu, wykonuje kod w ISR i powraca w miejsce gdzie zostało ono wywołane (nie koniecznie przez nas). Cały proces musi być nieodczuwalny więc na początku przerwania musimy zapisać stan procesora a po zakończeniu go przywrócić. Przykładowy ISR może wyglądać tak:

[GLOBAL keyb_isr]
keyb_isr:
   pusha
   push gs
   push fs
   push es
   push ds

   [EXTERN keyb]
   call keyb ;jakas funkcja w C

   pop ds
   pop es
   pop fs
   pop gs
   popa
   iret

W naszym OSie będziemy obsługiwać kilka przerwań, większość wyjątków i testowe przerwanie 0x80.

0x08. PIC.

W trybie rzeczywistym przerwania sprzętowe były numerowane od 1 do 16, jednak w trybie chronionym nachodzą one na przerwania wyjątków procesora, więc powstaje kolizja. Aby to naprawić musimy przekierować przerwania sprzętowe pod jakieś inne numery. Możemy to zrobić dzięki urządzeniu zwanemu PIC (programowalny kontroler przerwań), który możemy zaprogramować poniższą funkcją:

#define PIC1 0x20
#define PIC2 0xA0
#define ICW1 0x11
#define ICW4 0x01

void init_irq(int pic1, int pic2)
{
   outb(PIC1, ICW1);
   outb(PIC2, ICW1);
   outb(PIC1 + 1, pic1);
   outb(PIC2 + 1, pic2);
   outb(PIC1 + 1, 4);
   outb(PIC2 + 1, 2);
   outb(PIC1 + 1, ICW4);
   outb(PIC2 + 1, ICW4);
   outb(PIC1 + 1, 0);
   outb(PIC2 + 1, 0);
}

Funkcja ta odblokuje wszystkie przerwania sprzętowe lecz lepiej będzie jak w przyszłości odblokujesz tylko te przerwania, których będziesz używał. Aby to zrobić musisz wysłać odpowiednią maske do portu PIC1 + 1 lub PIC2 + 1 w zależności od numeru przerwania, po więcej odsyłam do manuala Intela.

0x09. Przerwanie klawiatury

W źródlach nie ma obsługi klawiatury lecz wyjaśnie pokrótce o co chodzi. Pisanie drivera do klawiatury jest dosyć przyjemne i nie trudne, więc myśle, że powinieneś sobie z tym poradzić.

W pierwszym kursie "obsługiwaliśmy" klawiature wywołując przerwanie i czekając na rezultat, w trybie chronionym powinniśmy to zrobić troche inaczej i myśle, że wygodniej. Przerwanie sprzetowe jest bardzo podobne do Eventów w vCLu, gdy zostanie wciśnięty przycisk, wywoła nam się funkcja i będziemy mogli odczytać scancode, przerobić go na kod ASCII i ewentualnie zapisać do jakiegos bufora lub po prostu jakoś zareagować (to już zależy od koncepcji). Tak więc wszystko co musimy zrobić to podpiąć się pod IRQ klawiatury (IRQ1 - przerwanie 0x21) i przy jego wywołaniu przetworzyć interesujące nas dane. Pierw zrobimy tablice, której użyjemy do przekonwertowania scancode na kod ASCII.

#define KBD_SPECIAL 200
#define ENTER 10
#define F1 201
#define F2 202
#define F3 203
#define F4 204
#define F5 205
#define F6 206
#define F7 207
#define F8 208
#define F9 209
#define F10 210
#define F11 211
#define F12 212
#define PAUSE 213

char scancode_ascii[0x100] = {
   KBD_SPECIAL, KBD_SPECIAL,
   '1','2','3','4','5','6','7','8','9','0','-','=',
   KBD_SPECIAL, KBD_SPECIAL,
   'q','w','e','r','t','y','u','i','o','p','[',']','\n',KBD_SPECIAL,
   'a','s','d','f','g','h','j','k','l',';','\'',KBD_SPECIAL,'\\',
   '<','z','x','c','v','b','n','m',',','.','/',KBD_SPECIAL,KBD_SPECIAL,KBD_SPECIAL,
   ' ',KBD_SPECIAL,F1,F2,F3,F4,F5,F6,F7,F8,F9,F10,F11,F12,PAUSE,KBD_SPECIAL,KBD_SPECIAL,
};

Następnie napiszemy bardzo prostą funkcje do odczytu tych danych.

void keyb()
{
   char key;
   char kbd_scancode;
   kbd_scancode = inb(0x60);
   key = scancode_ascii[kbd_scancode];
   if (key == 'f') 
   {
      print("nacinales f!");
   }
   outb(0x20, 0x20);
}

Powyższy kod jest bardzo prymitywny bo nie ma w nim obsługi SHIFTa ani CAPS LOCKa ale przynajmniej wiesz od czego zacząć. Zwróce jeszcze uwage na to, że przerwanie wykonuje się zarówno przy przycisnięciu jakiegoś klawisza jak i przy jego zwolnieniu, więc musisz to odpowiednio zinterpretować.

Dla takiej funkcji musisz stworzyć ISRa i dodać odpowiednią linijke w pliku ints.c, nic trudnego.

0x0A. Wyjątki procesora.

Tutaj sprawa jest banalna, po prostu tworzymy jedną funkcje, która wyświetli jakiś komunikat i zatrzyma system.

void exception_handler( unsigned long nr )
{
   print("System halted.", 14);

   __asm__ __volatile__ ("cli\n"
                         "hlt");
}

Makro tworzące ISRy dla wyjątków (patrz plik isr.asm) podaje w parametrze handlera numer wyjątku, więc możesz to odpowiednio zainterpretować, np

if (nr == 1)
{
   print("nie dziel cholero przez zero");
}

Inicjatywa należy oczywiście do Ciebie.

0x0B.Użycie GRUBa - multiboot

Dzięki GRUBowi nie musimy się marwić o przejście w tryb chroniony, odblokowywanie lini A20, systemem plików i kilkoma innymi rzeczami od których jedynie boli głowa, dzięki niemu po prostu kopiujemy 32 bitowy kernel na dyskietke i gotowe, lecz nie ma nic za darmo. GRUB jest napisany w standardzie multiboot, co wymaga od nas, żebyśmy stworzyli dla niego odpowiedni nagłowek, wystarczy zaglądnąć do dokumentacji lub jakiegoś kursu i sprawa staje prosta.

MULTIBOOT_PAGE_ALIGN   equ 1<<0
MULTIBOOT_MEMORY_INFO  equ 1<<1
MULTIBOOT_AOUT_KLUDGE  equ 1<<16
MULTIBOOT_HEADER_MAGIC equ 0x1BADB002
MULTIBOOT_HEADER_FLAGS equ MULTIBOOT_PAGE_ALIGN | MULTIBOOT_MEMORY_INFO | MULTIBOOT_AOUT_KLUDGE
CHECKSUM               equ -(MULTIBOOT_HEADER_MAGIC + MULTIBOOT_HEADER_FLAGS)

align 4

; wartosci z linkera
[extern code]
[extern edata] 
[extern end]

mboot:           
  dd MULTIBOOT_HEADER_MAGIC
  dd MULTIBOOT_HEADER_FLAGS
  dd CHECKSUM
  ;MULTIBOOT_AOUT_KLUDGE
  dd mboot ;
  dd code  ; start of kernel .text (code) section
  dd edata ; end of kernel .data section
  dd end   ; end of kernel BSS
  dd start ; kernel entry point (initial EIP)

Możemy zrezygnować z części MULTIBOOT_AOUT_KLUDGE jeśli nasz kernel jest w formacie elf. wartości poszczególnych elementów podajemy z etykiem assemblera lub wartości linkera (patrz, plik link.ld). Proponuje nie zwracać na format tego nagłówka większej uwagi, jednak jeśli koniecznie chcesz wiedzieć co to wszystko oznacza to polecam dokumentacje GRUBa lub inne materiały dotyczące standardu multiboot.

0x0C. Inicjowanie.

Gdy już mamy wszystkie potrzebne funkcje musimy to wszystko zainicjować, czyli ustawić GDT, IDT, załadować je i przekierować IRQ. Zrobimy to w pliku kernel.c

#include "draco.h"

void k_main(multiboot_info_t *multiboot_info)
{
   print("Draco (minix) compilation "__DATE__" "__TIME__" is starting.", 8);

#ifdef DEBUG
   print("System is in DEBUG mode.", 8);
#endif

   print_cpuid();
   init_gdt();
   init_idt();
   init_irq(0x20, 0x28);
   sti();
   init_paging();
   
   /* testujemy przerwania */
   __asm__ __volatile__ ("int $0x80");

   print("System is running.", 8);

   while(1);
}

Powinieneś zauważyć pewną rzecz, która nie była wyjaśniona. Tak więc jak wiesz lub nie, ilość pamięci najlepiej sprawdzać w 16 bitowym trybie rzeczywistym gdzie mamy łatwy dostęp do BIOSa poprzez przerwania, nasz system od samego początku jest 32 bitowy więc tego zrobić nie może, z pomocą przychodzi GRUB i standard multiboot. GRUB tworzy dla nas strukture zdefiniowaną przez nas jako multiboot_info_t i po uruchomieniu kernela podaje jej adres w rejestrze EBX.

Aby przekazać go jako parametr do k_main wystarczy, że umieścimy go na stosie (patrz, standard przekazywania parametrów w języku C).

0x0D. Czas na czary, czyli brak _ w GCC pod Win

Długo się naszukałem takiego parametru który by sprawiał, że nie trzeba wstawiać _ na początku nazw łącząc NASMa i GCC pod Windows, i w końcu znalazłem, jest to

-fno-leading-underscore

Niech ktos tylko powie, że żadna nowość... pytałem o to :>

0x0E. Zakończenie.

Od razu wyprzedzam pytania o następnych częściach - nie wiem, może to być ostatnia część, może jedna z pierwszych, jak ktoś chce to może dopytywać się na gg czy IRCu (będe szczęśliwy wiedząc, że ktoś to czyta). Dużo rzeczy zostało jeszcze do obgadania więc trzymajcie kciuki :). Oczywiście licze na to, że pochwalicie się swoimi OSami.

11 komentarzy

gdzie jest ten załącznik?

Genialny Art!!!
Tylko siąść i pisać własnego OS-a!

Gratuluje bardzo dobry art
Jak napiszesz książkę to zarobisz węcej niż na tym OSie, ale bardzo borze przedstawiles zasade dzialania :-)

Jezeli naprawde chcecie poczytac o pisaniu OSow to zapraszam na POLSKI portal dla OS Developerow:

www.areoos.com/osdevpl/

MrKaktus

Super ART gratuluje

Super! Jedyny art z którego dowiedziałem się jak przemapować tego pica paskudnego

bardzo dobre !

Mialo byc jeszcze o stronnicowaniu, jak moze znajde kiedys chwile to dopisze (w zrodlach jest ono zaimplementowane).

Może tak książke bys napisał jakąś ;]

Super ART!!!!!!!!!!!!!!!!!!!!! :)