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.

Moja praca inżynierska

Pierwszy semestr działań związanych z pracą inżynierską za mną, pora więc na małe podsumowanie. Tak jak narzekam na wiele aspektów związanych z moimi studiami, to tym razem zdecydowanie są powody do zadowolenia. Poniżej skrótowy opis rozwój „sytuacji” w moim wypadku.

Prolog

Na koniec poprzedniego semestru należało zadeklarować opiekuna i temat pracowni inżynierskiej. Zadanie zdecydowanie niełatwe, szczególnie gdy ma się tylko garstkę własnych doświadczeń i cudzych opinii na temat poszczególnych ludzi. Temat też należy dobrać z głową, jednak tu już prościej – zwykle wiadomo co kogo interesuje :). Ja szczęśliwie nie miałem problemu z doborem prowadzącego – oczywiście chciałem, aby opiekunem pracy został dr inż. Tomasz Jordan Kruk. Wiązało się to z niemałym, papierkowym rajdem po dziekanatach i sekretariatach, jednak bez większych problemów. Problem był natomiast z precyzyjnym tematem pracy. Wiedziałem tylko, że chcę aby dotyczył on zagadnień niskopoziomowych, związanychz  systemami operacyjnymi. Od słowa do słowa, dostałem propozycję zajęcia się systemem Google Chrome OS… i zgodziłem się bez większych oporów :). Pełne brzmienie tematu zostało wybrane jako Budowa systemu Chrome OS – uroczo prawda? Temat zdecydowanie nieoklepany tzn. nieznany, co mogło się wiązać z pewnymi trudnościami.

Zawiązanie akcji

Kolejne tygodnie po wybraniu tematu wiązały się z przekopywaniem Internetu w poszukiwaniu informacji na temat tegoż tworu. Martwiące było to, iż odnalazłem pełno marketingowego bełkotu, a rzetelnych informacji – jak na lekarstwo. W zasadzie jedyne sensowne źródło wiedzy, to strona projektu z dosyć ubogą dokumentacją. Wyzwania – to lubię! Długa i dokładna analiza materiałów przekonały mnie, że najciekawszym elementem systemu Chrome OS są mechanizmy bezpieczeństwa. Za namową Prowadzącego, przez kolejne tygodnie pracowałem nad pierwszym rozdziałem mojej pracy, który nosi tytuł Mechanizmy bezpieczeństwa systemu Google Chrome OS. Muszę przyznać, że to dopiero było wyzwanie! Myśli i treści wiele, jednak ubranie ich w techniczno-naukowy język to prawdziwa sztuka. Uważam się za osobę, która dba o język polski, dlatego każde zdanie składałem z wielkim namaszczeniem. To nie to samo, co blog, gdzie mogę czasem strzelić emotkę, tudzież całkiem kolokwialne stwierdzenie, czy ohydny anglicyzm ;). Ostatecznie, tempo pisania sięgało jednego (sic!) słowa na minutę. Rewelacyjnie, prawda? Efektem tygodni pracy było 16 stron tekstu, z którego naprawdę jestem zadowolony. Mój Opiekun również docenił moją pracę i podsunął mi pomysł, aby zrobić z tego rodziału materiał konferencyjny bądź artykuł – czemu nie!

W tym miejscu wtrącę słówko na temat składania tekstu. Na mojej uczelni (jak zapewne na wielu innych) lansuje się system składu tekstu o intrygującej nazwie LaTeX. Przeczytanie książeczki o tym narzędziu zajęło mi 2 dni, które przepełniły mnie lekką obawą przed czymś z goła innym niż Word ;). Ostatecznie jednak, praca z LateXem okazała się prawdziwą przyjemnością i zdecydowanie ułatwiła mi pracę. Po prostu mogłem skoncentrować się na samym pisaniu, a  nie na ustawianiu marginesów. Z całego serca polecam wszystkim, do każdego rodzaju pisania (prace, dokumentacje, korespondencja, a nawet CV).

Rozwój akcji

Po napisanym rozdziale przyszła pora na wybranie części praktycznej (bo i z tego powinna się składać praca inżynierska). Tu zaczęły się niemałe problemy, bo rozszerzanie Chrome OS może być trudne i niewykonalne w przewidzianym czasie inżynierki. Szczęśliwie, podczas pisania poprzedniego rozdziału natrafiłem na bardzo ciekawą rzecz: koncepcję kontenerów procesów (jest to mechanizm parawirtualizacji o którym niebawem napiszę więcej). Zrodził się więc pomysł, aby rozszerzyć części składowe kontenerów. Ucieszyło mnie to niezmiernie, gdyż oznacza to dużo grzebania w Linuksie! Kolejne 4 tygodnie spędziłem na poznawaniu budowy Linuksa i oswajaniu się z procesem rozwoju jądra. Temat mnie naprawdę wciągnął i z trudem mogłem oderwać się od czytania artykułów i kodu oraz oglądania nagrań z różnych konferencji o tematyce jądra Linuksa. Finalnie, udało mi się zdecydować na rozwijanie mechanizmu cgroups i nad tym będę pracował w najbliższych tygodniach.

Uważny czytelnik może zauważyć, że od chwili wymyślenia tematu zacząłem dosyć mocno się od niego oddalać – i to jest właśnie najlepsza część mojej pracy inżynierskiej – mogę dowolnie zmieniać jej kształt i prowadzić rzeczywiste, odkrywcze badanie, a nie odtwórcze kompilowanie XX źródeł wiadomości. Aktualnie, wygląda na to, że temat będzie brzmiał Mechanizmy zabezpieczeń w nowoczesnych dystrybucjach Linuksa na przykładzie Chrome OS , ale kto wie co przyniesie przyszłość.

I co dalej?

Tak jak pisałem, będę teraz pracował nad praktyczną częścią pracy. Później przyjdzie pora na kolejne rozdziały – jakie? To się jeszcze okaże.

Mała porada

Wszystkim osobom, które dopiero szykują się do pisania pracy inżynierskiej polecam bardzo dobrze zastanowić się nad doborem opiekuna. Nieważne jak zdolni jesteście, dobry mentor może Wam wiele pomóc. Nie chodzi przecież o to, aby prowadził za rękę i pokazywał co, kiedy i ile napisać, tylko o to, aby odpowiednio kierunkował, podpowiadał i ostrzegał przed potencjalnymi zagrożeniami. Temat natomiast, przy współpracy z odpowiednią osobą, wyklaruje się sam.