W szóstej odsłonie serii zajmę się małymi porządkami, które zaprocentują w najbliższej przyszłości. Przede wszystkim uporządkuję kilka spraw, wrócę raz jeszcze do GDT oraz napiszę kilka funkcji, które będą pomocne przy wypisywanu komunikatów diagnostycznych, których nigdy za wiele ;).
Raczkująca przenośność
Twórcy niektórych systemów operacyjnych stawiają sobie za zadanie dotarcie do możliwe (potencjalnie!) najszerszej rzeszy użytkowników. Tworzą oni implementacje dla różnych architektur procesorów i starają się, aby dodanie kolejnych było w miarę łatwe. Inni całkowicie na to leją, gdyż biznesowo patrząc – jest to nieopłacalne. Przykłady jednych i drugich łatwo wymyślić, więc nie będę ich tu podawał ;). W swoim systemie postanowiłem zrobić kroczek w stronę stworzenia przenośnego systemu operacyjnego – maksymalnie wykorzystuję język C, który jest przenośny (od mikrokontrolerów aż do dużych, „poważnych” procesorów), a minimalizuję użycie assemblera, który tej cechy nie posiada.
Magia typedef
Każdy, kto programował trochę w C i C++ wie jak bardzo przydatnym i potężnym narzędziem jest słowo kluczowe typedef. Każdy znany mi system operacyjny często i gęsto wykorzstuje „typedefowanie” – mam więc i ja :).
Wielokrotnie podczas tworzenia oprogramowania poziomu jądra potrzebne są nam typy, które posiadają ściśle określoną liczbę bitów – 8, 16, 32 itd. Typy o ustalonej długości służą do tworzenia flag, pól bitowych itp. Niestety specyfika architektur i co gorsza – kompilatorów powoduje, iż dany typ (int, short, long) ma często nieznaną z góry bitową długość. Można sobie jednak z tym poradzić tworząc odpowiednie definicje typów jak np. moje:
typedef unsigned int u32int;
typedef signed int s32int;
typedef unsigned short u16int;
typedef signed short s16int;
typedef unsigned char u8int;
typedef signed char s8int;
Dzięki temu, dla każdej architektury można stworzyć taki plik i podczas kompilacji włączać odpowiedni. Takie podejście ma jeszcze jedną zaletę – nazwa s8int (s od signed, 8 od liczby bitów i int od typu całkowitoliczbowego) jest bardziej wymowna niż char :). To pierwszy, malutki krok ku przenośności – wymaga on jednak konsekwencji w stosowaniu.
Opakowanie
W języku C nie da się zaprogramować wszystkiego – czasem trzeba skorzystać z instrukcji assemblera specyficznych dla danej architektury. Bywa jednak, że pewna instrukcja występuje na niemal każdej architekturze, jednak pod inną postacią. Warto wtedy opakować taką instrukcję w odpowiednią funkcję w języku C. Przykładem takiej instrukcji jest instrukcja zatrzymująca prace procesora – dla x86 jest to instrukcja hlt:
inline void hlt()
{
asm volatile("hlt");
}
Opakowanie jak widać jest banalne, a może w przyszłości oszczędzić wiele kłopotów. Co więcej, możemy ją teraz swobodnie wykorzystywać w wielu miejscach w kodzie w C!
Warto jeszcze zauważyć, że używam składni tzw. inline assembly, która dla GCC jest bardzo specyficzna (czyt. paskudna). Jest jednak o tym dużo artykułów, więc ja się rozpisywać nie będę.
GDT – znowu…
Jakiś czas temu pisałem o multiboot, który ustawia odpowiednio tablicę GDT. Wszystko byłoby pięknie – jest jednak pewien problem – nie wiemy dokładnie gdzie znajduje się ta tablica, nie wiemy również, co tak naprawdę się tam znajduje. Warto więc ustawić ją jeszcze raz – po swojemu i w znanym miejscu, a do tego – używając głównie C. Na początek struktura deskryptora:
typedef struct gdt_descr
{
u16int limit_low;
u16int base_low;
u8int base_middle;
u8int access;
u8int granularity;
u8int base_high;
} __attribute__((packed)) gdt_descr_t;
oraz wskaźnika na samą tablicę:
typedef struct gdt_ptr
{
u16int limit;
u32int base;
} __attribute__((packed)) gdt_ptr_t;
W obu przypadkach używam atrybutu packed, który instruuje kompilator, aby nie stosował wyrównania naturalnego dla architektury (to pewien rozdzaj optymalizacji) – tylko aby bajty były ciasno upakowane koło siebie ;). Struktura powyższych typów powinna być jasna. Została ona szeroko omówiona w poprzenich częściach kursu. Pozostałe kwestie są już trywialne, na początek deklaracja tablicy i wskaźnika na nią:
#define GDT_LEN 5
static gdt_descr_t gdt[GDT_LEN];
static gdt_ptr_t gdt_ptr;
funkcja ustawiająca pojedynczy deskryptor:
static void gdt_set_desc(gdt_descr_t* descr, u32int base, u32int limit, u8int access, u8int granularity)
{
descr->base_low = (base & 0xFFFF);
descr->base_middle = (base >> 16) & 0xFF;
descr->base_high = (base >> 24) & 0xFF;
descr->limit_low = (limit & 0xFFFF);
descr->granularity = ((limit >> 16) & 0x0F) | (granularity & 0xF0);
descr->access = access;
}
oraz funkcja ustawiająca poszczególne deskryptory:
void gdt_init()
{
gdt_set_desc(&gdt[0], 0, 0, 0, 0); // null
gdt_set_desc(&gdt[1], 0, 0xFFFFFFFF, 0x9A, 0xCF); // ring0 code
gdt_set_desc(&gdt[2], 0, 0xFFFFFFFF, 0x92, 0xCF); // ring0 data
gdt_set_desc(&gdt[3], 0, 0xFFFFFFFF, 0xFA, 0xCF); // ring3 code
gdt_set_desc(&gdt[4], 0, 0xFFFFFFFF, 0xF2, 0xCF); // ring3 data
gdt_ptr.base = (u32int) &gdt;
gdt_ptr.limit = sizeof(gdt_descr_t) * GDT_LEN - 1;
gdt_set(&gdt_ptr);
}
Warto zauważyć, że tym razem tworzymy dokładnie 5 deskryptorów. Pierwszy z nich to tradycyjnie deskryptor NULL, następnie mamy deskryptory kodu i danych dla przestrzeni jądra oraz deskryptory kodu i danych dla przestrzeni użytkownika. Wszystkie, oprócz NULL, rozciągają się oczywiście na całe 4GiB pamięci – różnią się tylko typem i prawami dostępu. Pozostała już tylko funkcja ustawiająca nową tablicę GDT, czyli gdt_set(), niestety tym razem należy skorzystać z kodu assemblera:
[GLOBAL gdt_set]
gdt_set:
mov eax, [esp+4] ; get passed pointer
lgdt [eax] ; load new GDT pointer
mov ax, 0x10 ; load all data segment registers
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
jmp 0x08:.flush ; far jump to code segment
.flush:
ret
kod ten jest jednak bardzo prosty i powinien być bez problemu zrozumiały.
VGA
Najstarsza metoda debuggowania programów (podobno odnaleziono ją na ścianach jaskini Lascaux) polega na wypisywaniu otrzymywanych wartości i „ręcznej” ich analizie. Niestety, w przestrzeni jądra jest to często jedyna dostępna możliwość. Warto więc zadbać o taką możliwość od samego początku. W swoim systemie (przynajmniej na razie) wykorzystuję podstawowe możliwości kart zgodnych z VGA, tak jak robiłem to już wcześniej. Funkcja inicjująca wykrywa typ monitora i ustawia odpowiedni adres pamięci zamapowanej na pamięć VGA. Przy wykrywaniu korzystam z pomocnych informacji, które pozostawił BIOS. Ustawiam też wirtualną pozycję kursora:
#define SCREEN_HEIGHT 25
#define SCREEN_WIDTH 80
static u16int* vga_mem;
static int cursor;
void vga_init()
{
cursor = 0;
if ((*((volatile u16int*) 0x410) & 0x30) == 0x30) // detecting monochrome monitor
vga_mem = (u16int*) 0xB0000;
else
vga_mem = (u16int*) 0xB8000; // it's color
}
Powyżej widać też definicje wysokości i szerokości typowego monitora VGA, czyli 80×25 znaków.
Pierwszą „piszącą” funkcją jest funkcja czyszcząca ekran:
void vga_cls()
{
int i;
for (i = 0; i < SCREEN_WIDTH * SCREEN_HEIGHT; ++i)
*(vga_mem + i) = (u16int) 3872; // ((((0 << 4) | (15 & 0xFF)) << 8) | 0x20) // white spaces on black background
}
Jak widać, po prostu piszę 80*25 spacji na czarnym tle ;). Jeśli sposób pisania po ekranie nadal jest niejasny, to proponuję spojrzeć na ten wpis.
Najważniejszą funkcją, jest jednak funkcja produkująca napisy na ekranie. Jest ona dosyć skomplikowana, gdyż sterownik VGA nie potrafi wypisywać znaków nowej linii \n, tabulacji \t oraz powrotu karetki \r - są one obsługiwane oddzielnie:
void vga_puts(const char* str)
{
// white letters on black background
const u16int attribute = 3840; // ((((0 << 4) | (15 & 0x0F)) << 8))
int i = 0;
while (str[i] != '\0')
{
if (cursor == SCREEN_WIDTH * SCREEN_HEIGHT)
{
vga_scroll();
cursor = SCREEN_WIDTH * (SCREEN_HEIGHT - 1);
}
switch (str[i])
{
case '\n':
cursor = cursor + 80 - cursor % 80;
break;
case '\r':
cursor = cursor - cursor % 80;
break;
case '\t':
// increment to align to 8
while ((cursor % 80) % 8 != 0)
++cursor;
break;
default:
vga_mem[cursor] = (u16int) (attribute | str[i]);
++cursor;
}
++i;
}
}
W przypadku typowego znaku po prostu wpisuję odpowiednią wartość do pamięci pod adresem vga_mem[cursor] i inkrementuję pozycję kursora. Dla \n dodaję 80 (długość linii) a następnie odejmuję tyle, aby powstała liczba była podzielna bez reszty przez 80 - czyli po prostu przechodzę do kolejnej linii. Dla \r robię operację podobną - tyle, że nie dodaję 80, co powinno być oczywiste. Dla \t dodaję do pozycji kursora 1 dopóki pozycja w aktualnym wierszu jest niepodzielna przez 8 (typowe zachowanie tabulacji w systemach UNIX). Na początku funkcji widać jeszcze obsługę sytuacji, gdy zapełnimy cały ekran znakami - przenoszę wtedy nasz wirtualny kursor na początek ostatniej linijki i wywołuję wtedy scroll(), która przepisuje wiersze "o jeden do góry ekranu" - pierwszy wiersz zostaje nadpisany drugim itd., ostatni wiersz zostaje wyczyszczony:
static void vga_scroll()
{
int i;
// rewrite lines one up
for (i = 0; i < SCREEN_WIDTH * (SCREEN_HEIGHT - 1); ++i)
vga_mem[i] = vga_mem[i + SCREEN_WIDTH];
// clear last line
for(i = 0; i < SCREEN_WIDTH; ++i)
vga_mem[SCREEN_WIDTH * (SCREEN_HEIGHT - 1) + i] = 3872; // ((((0 << 4) | (15 & 0xFF)) << 8) | 0x20) // white spaces on black background
}
Do boju
Mając pod kontrolą najważniejszą strukturę danych - GDT oraz odpowiednie (acz niekompletne) narzędzia do debuggowania możemy śmiało zagłębiać się w kolejne mechanizmy architektury x86. W końcu, za każdym razem, gdy coś nie zadziała - będziemy mogli sprawdzić dlaczego :).
Typowo, kompletny kod, w którego organizacji zaszły spore zmiany, jest dostępny tu:
git clone git://github.com/luksow/OS.git --branch 0x06
oraz bezpośrednio do obejrzenia tu.