Tworzenie systemu operacyjnego – część 0×06: Porządki, GDT, VGA…

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.

6 przemyśleń nt. „Tworzenie systemu operacyjnego – część 0×06: Porządki, GDT, VGA…”

  1. Wiesz, tak się zastanawiam czy nie lepiej byłoby rozbić wypisywanie na ekranie znaków na dwie warstwy. Pierwsza warstwa, to taka która zajmie się umieszczaniem tych znaków uwzględniając całą specyfikację techniczną urządzenia, z którego korzystasz – w tym wypadku VGA. Druga zaś to tak, np. printf, który siedział by na górze tej pierwszej. Myślę, że takie rozwiązanie jest o wiele bardziej przenośne, bo dla każdych nowych urządzeń będziesz zmieniał/dodawał jedynie tą dolną warstwę zostawiając printfa nienaruszonego i w ten sposób ominiesz problem przepisywania każdego miejsca, w którym tego printfa będziesz używał. Ale mogę nie mieć racji i jeśli masz na to inne spojrzenie, to chętnie wysłucham :)

    1. Pomysł jak najbardziej ok i bardzo słuszny. To o czym mówisz, to ewidentny podział na warstwę sterownika oraz niezależne (kernelowe lub nawet userspace’owe) API, prawda? Z tym się wiążą jednak następujące sprawy:
      1) interfejs sterowników należy dogłębnie przemyśleć tak, aby potem nie było problemów (lub zastosować sprawdzony np. UNIX-owy)
      2) przedstawione przeze mnie funkcje są prymitywne – do printfa im daleko.. i póki co nie mam ochoty się tym zajmować ;)
      3) to co jest teraz służy wyłącznie do debuggowania w kolejnych krokach, także nie rozwodziłem się nad eleganckim interfejsem tudzież architekturą, tak aby nie tracić sił na szczegóły; myślę, że w momencie, gdy wszystkie niskopoziomowe mechanizmy „staną”, wtedy będzie trzeba zadbać o wszystko o czym piszesz. Tak czy inaczej – kudos za czujność i przemyślność :)

  2. Bardzo fajny projekt, można się bardzo wiele nauczyć. I związku z tym ma pytanie, czy będzie to kontynuowane jeszcze w przyszłości? Nie ukrywam że bardzo bym się cieszył z tego powodu :) Naprawdę niezłe, bardziej przystępne niż na wikibooks.

    1. Też bardzo bym chciał – wszystko zależy od tego jaką ilością czasu będę dysponował, a z tym bywa dramatycznie :/

  3. Zdaje mi się, że na GitHubie brakuje części kodu z rozdzału „GDT – znowu…”

    1. Znalazłem – chcąc pobrać najnowszą wersję pobrałem branch master, a tam tego nie ma :)

Możliwość komentowania jest wyłączona.