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.

Tworzenie systemu operacyjnego – część 0×05: Multiboot Specification

Multiboot Specification

Multiboot Specification jest próbą stworzenia ustandaryzowanego sposobu bootowania systemów operacyjnych. Chodzi o to, aby każdy bootloader zgodny z Multiboot Specification był w stanie bootować każdy system operacyjny również zgodny z Multiboot Specification. Ma to ułatwić tworzenie środowisk z wieloma systemami operacyjnymi.

Multiboot Specification nie definiuje tego, jak ma być napisany bootloader, a jedynie odpowiedni interfejs. Referencyjną implementacją Multiboot Specifciation jest GNU GRUB. Wiele systemów operacyjnych (np. Linux), bootloaderów i maszyn wirtualnych (np. QEMU) jest zgodnych z tą specyfikacją, więc nie jest ona tylko teoretycznym dokumentem. Niezwykle istotną cechą bootloaderów zgodnych z Multiboot Specification jest to, że są one w stanie bootować kernel skompilowany do popularnych formatów plików wykonywalnych np. ELF. Dzięki temu możemy korzystać ze wszelkich dobrodziejstw, które formaty oferują.

O konkretnych cechach Multiboot Specification wspomnę przy okazji kodu, poniżej.

Multiboot Specification mam i ja!

W swoim projekcie systemu operacyjnego postanowiłem porzucić własny bootloader na rzecz bootloadera zgodnego z Multiboot Specification. Może się to wydawać dziwne, bo napisanie całego kodu związanego z bootowaniem zajęło mi sporo czasu, ale tak naprawdę ma to głębokie uzasadnienie. Przede wszystkim, mój bootloader jest bardzo ubogi i prosty – nie potrafi bootować żadnych formatów plików wykonywalnych, a jedynie płaskie binarki, co jest niezwykle uciążliwe, gdyż muszę polegać na magicznych stałych. Ponadto nie dostarcza on żadnych informacji systemowi operacyjnemu – wszystko trzeba zrobić samodzielnie. Oczywiście, można napisać ten kod samodzielnie… Jednak uważam, że nie jest on wystarczająco pasjonujący, aby się nim zajmować :). Dzięki Multiboot Specification będziemy mogli przestać zajmować się szczegółami, a przejść do rzeczy :).

Kod – czyli co my musimy zrobić dla Multiboot Specification, a co on zrobi dla nas?

Na chwilę musimy powrócić do assemblera, aby dostarczyć kilku informacji wymaganych przez bootloader i od razu możemy skoczyć do kodu w C.

global loader				; set visible to linker
extern main				; main from main.c
 
; some useful macro values
FLAGS		equ	0		; this is the multiboot 'flag' field
MAGIC		equ	0x1BADB002	; 'magic number' lets bootloader find the header
CHECKSUM	equ	-(MAGIC + FLAGS); checksum required
STACKSIZE	equ	0x4000		; 16 KiB for stack
 
section .text
align 4
; setting multiboot header
multiboot_header:
	dd	MAGIC
   	dd	FLAGS
   	dd	CHECKSUM
 
loader:
	mov	esp, stack + STACKSIZE	; set up the stack
	push	eax			; pass multiboot magic number as second parameter
	push	ebx			; pass multiboot info structure as first parameter
 
	call	main			; call C code
 
section .bss
align 4
stack:
   	resb 	STACKSIZE		; reserve stack space

Najbardziej istotne są makra z linii 5-7. Pierwsze z nich określa flagi, które informują bootloader, czego od niego oczekujemy. Wśród możliwych opcji jest: wyrównanie modułów do rozmiaru strony, dołączenie mapy pamięci oraz dostępnych trybów video. Po więcej informacji zapraszam tu. Póki co, nie potrzebujemy niczego, stąd wartość 0. Drugie to wartość magiczna, która pozwala bootloaderowi zidentyfikować nagłówek. Liczba 0x1BADB002 jest urocza, prawda? :) Trzecia wartość, to suma kontrolna, która powinna mieć wartość, taką że dodana do pól: FLAGS i MAGIC daje zero.

Kolejne linijki są oczywiste. W sekcji kodu musimy zamieścić kolejno wartość magiczną, flagi oraz sumę kontrolną, co dzieje się w liniach 14-16. Etykieta loader to rzeczywisty punkt wejściowy naszego jądra. W wierszu 19 ustalamy początek stosu, na którego miejsce rezerwujemy w sekcji bss (linia 28). Istotne jest, aby miejsce rezerwowane było w sekcji bss, bo inaczej rezerwacja będzie polegała na stworzeniu dużego pliku z wieloma zerami, a przecież nie o to nam chodzi. Etykieta bss załatwia sprawę – kompilator „wie”, że tylko rezerwujemy przestrzeń w odpowiednim miejscu w pamięci. Następnie w liniach 20 i 21 odkładamy zawartość dwóch rejestrów na stos (istotna jest kolejność – jeśli masz wątpliwości czemu pierwsza instrukcja odpowiada drugiemu parametrowi, spójrz do konwencji wołania) tak, aby przekazać je do funkcji main, którą wołamy w linii 23. Znaczenie przekazanych parametrów omówię poniżej.

Spójrzmy teraz na kod jądra w C:

char hello[] = "Hello from kernel!";

int main(void* mbd, unsigned int magic)
{
	int count = 0;
	int i = 0;
	unsigned char *videoram = (unsigned char *) 0xB8000; /* 0xB0000 for monochrome monitors */

	if ( magic != 0x2BADB002 )
	{
		/* something went wrong.. */
		while(1); /* .. so hang! :) */
	}

	/* clear screen */
	for(i=0; i<16000; ++i)
	{
		videoram[count++] = 'A';
		videoram[count++] = 0x00; /* print black 'A' on black background */
	}

	/* print string */
	i = 0;
	count = 0;
	while(hello[i] != '\0')
	{
		videoram[count++] = hello[i++];
		videoram[count++] = 0x07; /* grey letters on black background */
	}

	while(1); /* just spin */

	return 0;
}

Widzimy, że tym razem do funkcji main przekazane są dwa parametry, które włożyliśmy na stos. Pierwszy z nich to struktura informacyjna dostarczona przez bootloader, która zawiera informacje zarządane za pomocą flag. Druga, to kolejna wartość magiczna, infomująca nas o tym, czy wszystko poszło prawidłowo. Tym razem jest to 0x2BADB002. W liniach 41-45 widzimy bardzo prymitywną :) obsługę sytuacji błędnej. Reszta kodu jest taka, jak w odcinku poprzednim.

Co tak właściwie się stało? Specyfikacja Multiboot zapewnia nam że:

  • rejestr EAX będzie zawierał magiczną wartość 0x2BADB002, jeśli jądro zostało prawidłowo załadowane (stąd wkładanie rejestru EAX na stos w pierwszym przedstawionym kodzie)
  • rejestr EBX będzie zawierał adres struktury informacyjnej z danymi zażądanymi we fladze (stąd wkładanie rejestru EBX na stos w pierwszym przedstawionym kodzie)
  • linia A20 będzie aktywowana
  • rejestry segmentowe będą ustawione tak, aby realizowany był płaski model pamięci
  • będzie aktywowany tryb chroniony procesora
  • bit 17 i 9 rejestru EFLAGS będzie zgaszony

Jak widać, Multiboot Specification zapewnia nam wszystko, co poprzedni bootloader oraz sporo więcej. Więcej na temat struktur i gwarantowanego stanu tutaj.

Skrypt linkera pozostaje prawie bez zmian. Istotne jest, że tym razem musimy zdefiniować punkt wejściowy naszego programu (jądra), czyli etykietę loader oraz to, że możemy zażądać, aby nasz kernel został załadowany daleeeeeko za granicą 1MiB! Ja ładuję go zaraz za tą granicą:

ENTRY (loader)

SECTIONS {
    . = 0x00100000;

    .text : {
        *(.text)
    }

    .rodata ALIGN (0x1000) : {
        *(.rodata)
    }

    .data ALIGN (0x1000) : {
        *(.data)
    }

    .bss : {
        *(.bss)
    }
}

Uważny czytelnik zauważy ustawienie wyrównań (polecenia ALIGN), które jednak aktualnie nie mają dużego znaczenia, więc nie będę ich omawiał.

Pozostała nam kompilacja do formatu elf w wariancie dla architektury i386 (x86):

nasm -f elf -o ./bin/loader.o loader.asm
gcc -o ./bin/main.o -c main.c -m32 -nostdlib -nostartfiles -nodefaultlibs
ld -melf_i386 -T linker.ld -o ./bin/kernel.bin ./bin/loader.o ./bin/main.o

Oraz uruchamianie:

qemu -kernel ./bin/kernel.bin

Użycie QEMU może być zdziwieniem, gdyż nie używamy tu żadnego bootloadera typu GRUB. QEMU jednak, tak jak wspominałem, posiada wbudowany bootloader zgodny z Multiboot Specification. Włącza się go za pomocą przełącznika -kernel. Jeśli ktoś jednak bardzo chce może użyć GRUBa.

To tyle, nasz stuningowany kernel powinien działać.

NIH

Jestem zwolennikiem unikania syndromu NIH, stąd decyzja o użyciu dobrze napisanego i przetestowanego bootloadera zgodnego z Multiboot Specification. Stan naszej wiedzy nie ucierpiał na tej decyzji, gdyż mamy już za sobą napisanie prostego bootloadera :). W przyszłości będzie można do niego wrócić i wzbogacić go o ładowanie plików w formacie ELF i jeszcze kilka ficzerów. Na razie jednak, zajmijmy się tym co najważniejsze, czyli jądrem naszego systemu operacyjnego.

Tradycyjnie, pełen kod odcinka można pobrać tak:

git clone git://github.com/luksow/OS.git --branch 0x05

Lub obejrzeć go tu.

Tworzenie systemu operacyjnego – część 0×04: Kernel w C

Assemblerowi już dziękujemy

Assembler, mimo swego ukrytego piękna, jest dosyć męczącym językiem jeśli chodzi o programowanie dużych projektów. Brak jakiegokolwiek poziomu abstrakcji pozwala na pełną kontrolę wydajności, kosztem dużego nakładu pracy na pisanie i poprawianie kodu ;) Idealnym następcą assemblera zdaje się być język C, który oferuje przyzwoitą wydajność, oferując przy tym podstawowe i wystarczające mechanizmy do programowania projektów wymagających niskopoziomowego podejścia. Niebezpodstawnie najważniejsze i największe projekty powstały właśnie w tym języku – UNIX (i wszystkie pochodne), Windows, MacOS X oraz Quake III – to wszystko projekty stworzone (głównie) w C.

Mój system operacyjny będzie również w zdecydowanej większości napisany w C. Lubię ten język za prostotę, a jednocześnie ogrom możliwości. Na C++ i inne bajery przyjdzie jeszcze czas :)

Ostatnie pożegnanie

Nim porzucimy assemblera, musimy ustawić jeszcze kilka rzeczy. Wróćmy więc do kodu z wcześniejszego odcinka serii i odpowiednio go zmodyfikujmy:

[BITS 32]
clear_pipe:
	mov	ax, 0x10	; GDT address of data segment
	mov	ds, ax		; set data segment register
	mov	ss, ax		; set stack segment register
	mov	esp, 0x9000	; set stack
	mov	ebp, esp	; set bracket pointer
	jmp	0x7F00		; jump to code
	times 256-($-$$) db 0	; fill rest with zeros for alignment

W liniach 74-76 po raz kolejny przypomina nam o sobie segmentacja. Musimy ustawić rejestry DS i SS (data segment i stack segment) na odpowiedni adres w tablicy GDT. Przypominam, że trzeci deskryptor (po code i null) ustawiliśmy jako segment danych, stąd adres 0x10 (wynika to z budowy selektora!). Kolejnym krokiem jest ustawienie stosu i ramki stosu – robimy to nieco „na pałę” wybierając fragment wolnej pamięci i ustawiając tam początek stosu. Na koniec pozostaje tylko skoczyć do kodu. Tu jednak należy się ważna uwaga. Wyrównanie w architekturze x86 to zdecydowanie pożądana sprawa – zapewnia poprawność i optymalność. Stąd wypełnienie do 256 bajtów w linii 80. Dzięki temu możemy łatwo obliczyć, gdzie znajdzie się nasz kod. Bootloader załadowany jest pod adres 0x7C00, zajmuje 512 bajtów, ustawienie środowiska zajmuje 256 bajtów, stąd 0x7C00 + 512 + 256 = 0x7F00, co ma swoje odzwierciedlenie w linii 79. To tyle, możemy się pożegnać z asmem.

Jego wysokość – C

Od teraz możemy pisać w C. Napiszemy więc prosty kod, który wyczyści ekran i wypisze na nim przykładowy napis, a nastepnie zawiśnie w nieskończonej pętli. Pisanie na ekran w trybie chronionym wygląda inaczej niż w trybie rzeczywistym, ale jest równie łatwe. Aby wypisać coś na ekranie używamy zamapowanego obszaru pamięci dla karty graficznej – dla kolorowych monitorów obszar ten zaczyna się od adresu 0xB8000, a dla monitorów monochromatycznych od adresu 0xB0000. Każda literka składa się z dwóch bajtów – pierwszy z nich to znak w ASCII, drugi to kolor i tło (podobnie do trybu rzeczywistego). Zapisując takie pary pod kolejne adresy począwszy od wskazanych powyżej – otrzymujemy napis na ekranie. Więcej na temat można przeczytać np. tu. Spójrzmy na kod:

char hello[] = "Hello from kernel!";

int main()
{
	int count = 0;
	int i = 0;
	unsigned char *videoram = (unsigned char *) 0xB8000; /* 0xB0000 for monochrome monitors */

	/* clear screen */
	for(i=0; i<16000; ++i)
	{
		videoram[count++] = 'A';
		videoram[count++] = 0x00; /* print black 'A' on black background */
	}

	/* print string */
	i = 0;
	count = 0;
	while(hello[i] != '\0')
	{
		videoram[count++] = hello[i++];
		videoram[count++] = 0x07; /* grey letters on black background */
	}

	while(1); /* just spin */

	return 0;
}

Myślę, że nie wymaga on większego komentarza. Najpierw czyścimy ekran pisząc czarne literki na czarnym tle - wypełniamy tak całą pamięć video, która powinna wynosić 16000 bajtów. Następnie przystępujemy do wypisania napisu, a na końcu kręcimy się w nieskończonej pętli. Proste, prawda?

Linkowanie

Pozostał jeszcze problem kompilacji. Jak skompilować ten kod, aby zadziałał? Należy skłonić kompilator, aby wygenerował kod w postaci płaskiej binarki, ale.. skąd będzie wiedział, gdzie zaczyna się kod i poszczególne dane? Za to odpowiedzialny jest linker (konsolidator). Nie będę tutaj omawiał do czego konsolidator służy, gdyż myślę, że jest to wiedza dosyć elementarna. W razie czego można się doszkolić tutaj. Po kolei - zacznijmy od kompilacji:

gcc -m32 -c -o ./bin/main.o main.c

Wygeneruje nam to plik obiektowy w formacie ELF, lecz nie jest to dla nas istotne. Należy pamiętać o przełączniku -m32  (w przypadku gdy używamy systemu 64 bitowego), który kompiluje kod z myślą o architekturze x86.

Kolejnym krokiem jest odpowiednie przekształcenie pliku obiektowego do postaci binarnej. W tym celu musimy użyć linkera z odpowiednim skryptem:

OUTPUT_FORMAT("binary")
OUTPUT_ARCH ("i386")
ENTRY (main)

SECTIONS
{
	. = 0x7F00;
	.text : { *(.text) }
	.data : { *(.data) }
	.bss : { *(.bss) }
	.rodata : { *(.rodata) }
}

Pisanie skryptów linkera jest trudne i nie będę póki co omawiał szczegółów. Nieco informacji w przyzwoitej formie można znaleźć tu. W linii 1 wskazujemy typ pliku jaki chcemy uzyskać (płaska binarka). Linia 2 to docelowa architektura, natomiast w linii 3 informujemy konsolidator, że punktem wejścia do naszego programu jest funkcja main. Od linii 5 zaczynają się schody - deklaracja poszczególnych sekcji programu. W linii 7 deklarujemy, iż cały program będzie załadowany pod adresem 0x7F00 i od tego adresu mają zaczynać się kolejne sekcje (linie 8 - 11) w kolejności text (kod), data (dane), bss (dane niezainicjalizowane), (rodata) dane tylko do odczytu. Linker jest na tyle "inteligentny", iż potrafi obliczyć sobie odpowiednie przesunięcia na podstawie długości poszczególnych sekcji, zaczynając od adresu wskazanego w linii 7.

Teraz pozostaje już tylko uruchomić konsolidator:

ld -T linker.ld ./bin/main.o -o ./bin/main.bin

I gotowe! Możemy uruchamiać nasz zaczątek kernela, napisany w C :)

Niemiły zapach...

Odwaliliśmy kawał dobrej roboty. Możemy od teraz swobodnie pisać kod kernela w C. Jak pewnie zauważyliście, mamy w kodzie sporo "magicznych" wartości, które co gorsza uzależnione są od rozmiarów poszczególnych fragmentów kodu. Dodatkowo, nie mamy kontroli co, gdzie i jak ładowane jest do pamięci - wszystko leży gdzieś koło siebie. Postaramy się rozwiązać (dosyć drastycznie) ten problem w nastepnym odcinku serii.

Pełen kod odcinka można pobrać tak:

git clone git://github.com/luksow/OS.git --branch 0x04

Lub obejrzeć go tu.

Tworzenie systemu operacyjnego – część 0×03: Przejście do trybu chronionego

Potrzebna wiedza

W końcu nadszedł dzień, w którym nasz system operacyjny będzie działał w trybie chronionym :) Do zrozumienia odcinka 0x03 potrzebna będzie wiedza na temat używania sprzętu poprzez porty, linii A20, segmentacji i jej realizacji w x86 czyli GDT oraz tego, czym jest sam tryb chroniony. Po zdobyciu tych nietrywialnych informacji, możemy przejść do sedna.

Skok

W poprzednim odcinku ładowaliśmy zawartość dysku do pamięci. Wykorzystamy teraz tę umiejętność do załadowania kodu przejścia. Musimy zatem zamienić linijkę w której haltowaliśmy procesor na linijkę:

jmp	0x0000:0x7E00	; jump to setup code

która skoczy pod adres, do którego załadowaliśmy zawartość dysku.

Halo? A20?

Teraz, zgodnie z arkanami sztuki powinniśmy odblokować linię A20.., ale zaraz! Może producent sprzętu zrobił to za nas? Poniższy kod sprawdzi czy linia A20 jest gotowa do użycia:

[ORG 0x7E00]			; here is where our code will be loaded
[BITS 16]

check_a20:			; check if A20 is not enabled, it's all about comparing 0xFFFF:0x7E0E with 0xAA55 - for wrapping memory
	mov	ax, 0xFFFF
	mov	es, ax
	mov	di, 0x7E0E
	mov	al, byte[es:di]
	cmp	al, 0x55	; comparing with 0x55 (first part)
	jne	enter_pmode	; if first byte is not wrapping around then second of course too
	; now we have to check if it not happened by chance
	mov	al, 0x69	; therefore we will set first byte and check results
	mov	byte[es:di], al	; load new value
	mov	bl, byte[0x7DFE]	; load old value
	cmp	bl, 0x69
	jne	enter_pmode

Pamiętamy, że ostatnie 2 bajty bootloadera, załadowane do pamięci to zawsze 0xAA55, wiemy też, że jeśli A20 nie jest aktywna, to po granicy 1MiB pamięć zawija się. Wykorzystajmy więc sprytnie ten fakt – jeśli A20 jest nieaktywna, to nasze bajty bootloadera odnajdziemy pod adresem o 1MiB dalej, niż rzeczywiście są. 0xAA55 znajduje się pod adresem 0x7DFE, a więc odpowiednik powinien być pod adresem FFFF:7E0E.

Nasz kod robi proste porównanie. Jeśli pierwszy bajt nie jest równy temu 1 MiB dalej, to znaczy, że A20 jest aktywna (linia 10). Z tego, że jest równy, nie możemy niestety wnioskować, że A20 jest nieaktywna – bo może być to przypadek. Musimy więc pamięć za granicą 1MiB, odpowiadającą bootloaderowi, ustawić na pewną wartość (linia 13), a następnie odczytać zawartość adresu 0x7DFE (linia 14) i sprawdzić, czy są równe (linia 15). Jeśli są równe, musimy spróbować aktywować linię A20. Może wydawać się to skomplikowane, ale tak naprawdę to dosyć prosty trik.

Aktywacja A20

Linię A20 spróbujemy aktywować na 2 sposoby – przerwaniem BIOS-u oraz klasycznie – kontrolerem klawiatury. Oczywiście nie wyczerpuje to szerokiego repertuaru sposobów i w profesjonalnym systemie operacyjnym musielibyśmy postarać się bardziej. Więcej metod tu.

Spójrzmy na kod:

enable_a20:			; let's enable A20 line first, we'll use only basic methods
	; first, try enabling it via BIOS
	mov	ax, 0x2401
	int	0x15
	jnc	enter_pmode

	; then try conventional way - keyboard controller
	call	wait_a20
	mov	al, 0xD1 	; proper value
	out	0x64, al 	; proper port
	call	wait_a20 	; wait
	mov	al, 0xDF	; proper value
	out	0x60, al	; proper port
	call	wait_a20	; wait
	jmp	enter_pmode	; not so safe assumption that we enabled A20 line...


wait_a20:			; waits for keyboard controller to finish
	in	al, 0x64
	test	al, 2
	jnz	wait_a20
	ret

Linijki 20 – 21 to standardowe wywołanie przerwania. Przerwanie to ustawia flagę carry, jeśli się nie powieidzie, stąd kontrola w linii 22. Więcej o tym przerwaniu można przeczytać tutaj.

Druga metoda wymaga o wiele więcej kodu. Funkcja wait_a20 jest funkcją aktywnego oczekiwania na kontroler klawiatury. Musimy ją wywoływać po każdej próbie zapisu. Sama procedura zapisu do kontrolera klawiatury też jest nietrywialna – najpierw musimy wysłać do portu 0x64 wartość 0xD1 (linia 27), a następnie wysłać do portu 0x60 wartość 0xDF (linia 30). Po wykonaniu tych operacji zakładamy, że linia A20 jest aktywna, choć tak naprawdę, nie możemy być tego pewni.

GDT

Przed przejściem do trybu chronionego musimy ustawić tablicę GDT:

gdt:
gdt_null:
	dq	0		; it's just null..
gdt_code:
	dw 	0xFFFF		; limit (4GB)
	dw	0		; base (0)
	db	0		; base (still 0)
	db	10011010b	; [present][privilege level][privilege level][code segment][code segment][conforming][readable][access]
	db	11001111b	; [granularity][32 bit size bit][reserved][no use][limit][limit][limit][limit]
	db 	0		; base again
gdt_data:
   	dw	0xFFFF		; it's just same as above
   	dw	0		; it's just same as above
	db	0		; it's just same as above
	db	10010010b	; [present][privilege level][privilege level][data segment][data segment][expand direction][writeable][access]
	db 	11001111b	; it's just same as above
	db 	0		; it's just same as above
gdt_end:

gdt_desc:
   	dw 	gdt_end - gdt	; it's size
   	dd	gdt		; and location

Tablica zawiera 3 deskryptory. Pierwszy z nich to null descriptor, który jest zawsze wymagany (niektóre emulatory, np. BOCHS narzekają na jego brak). Drugi deskryptor to deskryptor kodu, który rozciąga się na całą 4GiB przestrzeń adresową. Ostatni deskryptor, to deskryptor danych, który również rozciąga się na całe 4GiB przestrzeni adresowej. W ten sposób otrzymujemy płaski model pamięci, w którym nie widać segmentacji. Ustawień poszczególnych bitów nie będę tłumaczył, gdyż są one opisane w kodzie, a dokładniejsze informacje można znaleźć w moim artykule. Warto zwrócić uwagę na sprytne oznaczenie deskryptorów poprzez etykiety gdt oraz gdt_end, co umożliwia nam eleganckie i poprawne ustawienie rozmiaru (linia 69) oraz adresu (linia 70) w strukturze opisującej GDT.

pmode!!!

Uff, teraz możemy już naprawdę przejść do trybu chronionego.

enter_pmode:
	cli
	lgdt 	[gdt_desc]
	mov 	eax, cr0
	or 	eax, 1
	mov 	cr0, eax
	jmp 	0x8:clear_pipe	; do the far jump, to clear instruction pipe

Aby przejść do trybu chronionego musimy upewnić się, że przerwania są wyłączone (linia 42). Zalecane jest także wyłączenie przerwań niemaskowalnych (NMI), ale nie jest to konieczne. W linii 43 ładujemy strukturę opisującą GDT to rejestru gdtr. Aby aktywować tryb chroniony musimy ustawić najmłodszy bit rejestru CR0 – dzieje się to w liniach 44, 45 oraz 46. Po wykonaniu instrukcji z linii 46 jesteśmy już w trybie chronionym! Pięknie, prawda? :) Niestety, w kolejce instrukcji pozostały śmieci po trybie rzeczywistym. Musimy ją oczyścić robiąc skok długi. Dokonujemy tego w linii 47. Pierwszy człon adresu wskazuje nam, którego deskryptora z GDT chcemy użyć – 0x8 odpowiada pierwszemu indeksowi w tablicy GDT (inne pola selektora zajmują bity 0 – 2, 3 bit, to pierwszy bit indeksu); drugi człon, to po prostu adres w naszym kodzie. Załadowanie CS odpowiednią wartością powoduje faktyczne przejście do trybu chronionego.

No i jesteśmy!

Ostatni fragment kodu to po prostu zatrzymanie się w, mlekiem i miodem płynącej, krainie 32 bitowej:

[BITS 32]
clear_pipe:
	hlt			; in pmode :)
	jmp clear_pipe

Co teraz?

Świat stoi teraz przed nami otworem! Możemy korzystać ze wszystkich dobrodziejstw architektury x86, które opisywałem tutaj. Następnym krokiem powinno być prawdopodobnie ustawienie zarządzania pamięcią i obsługa przerwań, ale wcale nie musimy tego robić. Możemy równie dobrze w tym miejscu napisać kod obliczający kolejne liczby pierwsze i w taki sposób być autorem jedynego systemu operacyjnego na świecie, który takie coś robi ;). Tak więc – do dzieła! Aha, jeśli tu dotarłeś, to należą Ci się gratulacje – większość początkujących osdevowców nie osiąga trybu chronionego.

W następnym odcinku porzucimy trudny i męczący assembler i spróbujemy coś napisać w zgrabnym C!

Pełny kod odcinka można pobrać tak:

git clone git://github.com/luksow/OS.git --branch 0x03

Lub po prostu obejrzeć go tu.

Tworzenie systemu operacyjnego – część 0×02: Dobrodziejstwa BIOS-u

512 bajtów (tak samo jak 640K) nie wystarczy każdemu

W poprzednim odcinku załadowaliśmy 512 bajtów zawartości dysku do pamięci. W taki sposób uruchomiliśmy nasz bootloader, który niestety nic nie robił. Teraz wypadałoby, aby zmienił on tryb operacyjny, wyświetlił kilka diagnostycznych komunikatów no i załadował jądro za pomocą sterownika ATA… tylko to wszystko nie zmieści się w 512 bajtach! Jeśli postawimy na minimum, tj. załadowanie jądra z dysku – również może być ciężko, sterownik jednak swoje waży. Z pomocą przychodzi nam jednak BIOS!

Funkcje BIOS-a – przerwania

BIOS udostępnia nam bardzo wiele funkcji poprzez przerwania. Wśród oferowanego asortymentu są np.: pisanie na ekran, wczytywanie z dysku, sprawdzanie stanu sprzętu, uzyskiwanie mapy pamięci, aktywowanie dodatkowej pamięci i wiele, wiele innych. My zajmiemy się dla przykładu tylko dwoma. Pełna ich lista znajduje się tutaj i tutaj. Widzicie funkcje opisane jako „???”? Uważajcie na nie! Skoro nikt, nawet twórcy BIOS-ów nie wiedzą do czego służą, to możecie nimi przez przypadek przejąć władzę nad światem lub wyłączyć prąd u sąsiada w domu.. :)

Dla niezaznajomionych z programowaniem systemowym: przerwanie wywołuje się poleceniem int numer_przerwania, natomiast argumenty przekazuje się za pomocą odpowiednich rejestrów.

int 0x10

Na początek postaramy się wypisać coś na ekran. W tym celu skorzystamy z przerwania 0x10, którego dokładny opis jest tutaj. Oto kod:

	mov	ax, 0x0000
	mov	ds, ax		; round way to do that...
	mov	si, msg

	mov	ah, 0x0E	; mode - teletype (advance and scroll)
	mov	bh, 0x00	; page number
	mov	bl, 0x07	; colors

.next_char:
	lodsb
	or	al, al		; letter here
	jz	.continue
	int	0x10		; BIOS video interrupt
	jmp	.next_char

msg:
	db	'Hello from bootloader!', 13, 10, 0

Idąc od początku, warto zwrócić uwagę na linie 5 i 6 i na dziwną, okrężną drogę ładowania rejestru DS (data segment). Niestety – takie są ograniczenia architektury – nie można bezpośrednio ładować rejestrów segmentowych. Do rejestru SI ładujemy adres pamięci pierwszej litery (czyli mówiąc najprościej – napisu) i tak uzyskana para (DS:SI) wskazuje nam jednoznacznie, gdzie jest napis. Pora na kolejne ustawienia. W rejestrze AH, zgodnie z opisem musi znaleźć się wartość 0xE, która definiuje tryb wyświetlania liter – jest to przewijanie i przeskakiwanie na następną pozycję (w dokumentacji nazwany teletype). W rejestrze BH przechowujemy numer strony – u nas jest to strona zerowa. Rejestr BL zawiera natomiast kolory (tła i liter) – białe litery na czarnym tle.

Możemy teraz przejść do pętli wypisującej literę po literze. Instrukcja lodsb ładuje literę wskazywaną przez DS:SI do rejestru AL, a następnie przesuwa wskaźnik do następnej litery. Polecenie o tyle przydatne,  gdyż oszczędzamy w ten sposób cenne miejsce w naszych 512 bajtach. Kolejnym krokiem jest sprawdzenie, czy przypadkiem nie dotarliśmy do końca napisu i jeśli tak, to opuszczenie pętli (linie 15 i 16), następnie wywołanie samego przerwania (linia 17) oraz skok zamykający pętle. Bajecznie proste prawda? Po wykonaniu tego kawałka kodu powinniśmy ujrzeć na ekranie napis „Hello from bootloader!”. Czas na trochę bardziej przydatne i skomplikowane przerwanie.

int 0x13

Szczęśliwa „trzynastka” ;) pozwala nam na wczytywanie danych z dysku za pomocą adresowania typu LBA (jeśli nie wiesz co to, przeczytaj koniecznie podlinkowaną stronę Wikipedii). Rzut oka na kod:

	mov	si, read_pocket
	mov	ah, 0x42	; extension
	mov	dl, 0x80	; drive number (0x80 should be drive #0)
	int	0x13

read_pocket:
	db	0x10		; size of pocket
	db	0		; const 0
	dw	1		; number of sectors to transfer
	dw	0x7E00, 0x0000	; address to write
	dd	1		; LBA
	dd	0		; upper LBA

Tym razem w rejestrze SI znajduje się adres „kieszeni” (o której szerzej – zaraz) zawierającej informacje o tym co i gdzie wczytać. Rejestr AH zawiera numer identyfikujący tę właśnie funkcję (wśród dostępnych funkcji znajdują się również takie, które pozwalają na dostęp CHS). Natomiast rejestr DL zawiera numer dysku z którego chcemy czytać… zupełnie spodziewanie, dysk numer 0 znajduje się pod numerem 0x80 ;> Przerwanie wywołujemy w linii 24 i o ile wszystko poszło gładko, powinniśmy mieć dane załadowane pod wskazany adres. Co do samej kieszeni, to komentarze chyba mówią wszystko. Pierwszy bajt zawiera rozmiar kieszeni, drugim powinno być zawsze 0, kolejne dwa to liczba sektorów do wczytania, następne dwa słowa to człony adresu (u nas to 0x0000:0x7E00), potem następuje adres sektora startowego na dysku (zaczynamy od sektora numer 1, czyli efektywnie od drugiego) i jego „górna” część. Prosto, łatwo i przyjemnie! :) W prawdziwym, profesjonalnym systemie powinniśmy jakoś obsłużyć błędy wczytywania, których kody zwracane są w odpowiednich rejestrach (opis tu).

Wszystko do kupy

Finalnie nasz kod wygląda tak:

[ORG 0x7C00]			; here is where our code will be loaded by BIOS
[BITS 16]

bootloader:
	mov	ax, 0x0000
	mov	ds, ax		; round way to do that...
	mov	si, msg

	mov	ah, 0x0E	; mode -> teletype (advance and scroll)
	mov	bh, 0x00	; page number
	mov	bl, 0x07	; colors

.next_char:
	lodsb
	or	al, al		; letter here
	jz	.continue
	int	0x10		; BIOS video interrupt
	jmp	.next_char

.continue:
	mov	si, read_pocket
	mov	ah, 0x42	; extension
	mov	dl, 0x80	; drive number (0x80 should be drive #0)
	int	0x13
	cli			; turn off maskable interrupts, we don't need them now
	hlt
.halt:	jmp	.halt

msg:
	db	'Hello from bootloader!', 13, 10, 0
read_pocket:
	db	0x10		; size of pocket
	db	0		; const 0
	dw	1		; number of sectors to transfer
	dw	0x7E00, 0x0000	; address to write
	dd	1		; LBA
	dd	0		; upper LBA 

times 510-($-$$) db 0		; fill rest with zeros
dw 0xAA55			; bootloader indicator, used by BIOS

Do celów testowych musimy stworzyć dalszą część naszego wirtualnego dysku, jako że ma on tylko 512 bajtów i wczytanie kolejnego sektora zaowocuje błędem. Proponuję zrobić to tak:

echo 'Ala ma kota' > ./bin/text
dd if=/dev/zero of=./bin/zeros bs=1  count=500
cat ./bin/boot.bin ./bin/text ./bin/zeros > ./bin/hda.img

Kolejno: tworzę plik z zawartością „Ala ma kota” (plus nowa linia!), tworzę plik 500 bajtowy z samymi zerami, a następnie wszystko łącze w kolejności: kod + tekst + zera, co nam daje 1024 bajty.

Po uruchomieniu naszego bootloadera w QEMU zobaczymy na ekranie „Hello from bootloader!”, natomiast w pamięci pod adresem 0x7E00 powinien być napis „Ala ma kota”. Dla pewności, w trybie monitora sprawdźmy to komendą xp /12bc 0x7E00. Wspaniale!

Single stage

Nasz bootloader aktualnie posiada tylko jeden etap (ang. stage) działania. Bardziej złożone bootloadery, jak np. GNU GRUB posiadają kilka etapów działania (najczęściej 2) ze względu na wspomniane ograniczenie 512 bajtów. W kolejnej części cyklu spróbujemy napisać stage 2 dla naszego bootloadera, który odpowiednio skonfiguruje środowisko pracy dla naszego jądra. Tymczasem zachęcam do zapoznania się z pełnymi źródłami części 0x02:

git clone git://github.com/luksow/OS.git --branch 0x02

Jeśli chcesz go tylko przejrzeć, wejdź tutaj.

Tworzenie systemu operacyjnego – część 0×01: Włączam komputer i…, bootloader

Power on!

W życiu każdego młodego informatyka nastaje moment, gdy zaczyna się zastanawiać, co tak właściwie się dzieje, gdy włącza komputer. Na ten temat można by mówić długo i na różnym poziomie szczegółowości. Ja nie będę zajmował się jednak skaczącymi elektronami i zmieniającymi się poziomami energetycznymi w krzemie, a tym co interesuje programistę systemów operacyjnych – poziomem oprogramowania.

Włączenie komputera rozpoczyna się od naciśnięcia przycisku (genialne spostrzeżenie, prawda?), który włącza zasilanie komputera, a konkretniej – zasila płytę główną. Ta z kolei, w pierwszej chwili uruchamia swój własny firmware. Ciężko określić jak dokładnie proces ten przebiega, gdyż dla każdej płyty głównej może być to trochę inne. Tak naprawdę chodzi o to, że płyta główna potrzebuje programu, który uruchomi procesor. Jeśli w tym momencie coś pójdzie nie tak, będziemy oglądać ciemny ekran, słyszeć kręcące się wiatraczki i piski z głośniczka na płycie głównej. Załóżmy jednak, że udało się uruchomić procesor. Co ciekawsi zastanawiają się pewnie – „a co jeśli komputer posiada dwa lub więcej procesorów?”. Otóż w systemach wieloprocesorowych jeden z procesorów jest tzw. bootstrap processor (BSP), który odpowiedzialny jest za uruchomienie BIOS-u oraz zainicjowanie jądra. Drugi z procesorów pozostaje w stanie zatrzymania (halt), aż do momentu kiedy explicite zostanie obudzony przez jądro systemu operacyjnego. Wracając jednak do naszej sekwencji – nasz świeżo uruchomiony procesor znajduje się w dobrze zdefiniowanym stanie – tj. znane są zawartości części jego rejestrów, w szczególności rejestru wskaźnika instrukcji CS:IP. Rejestr ten przechowuje magiczny adres nazwany reset vector. W tym momencie płyta główna musi się upewnić, że instrukcja znajdująca się pod tym adresem, to skok do miejsca w pamięci, w którym znajduje się zmapowany BIOS – jest to obszar adresów nieco poniżej magicznej granicy 1MB (np. 0x9FC00 – 0xFFFFF). Warto zwrócić uwagę na słowo zmapowany – tzn. dokładnie w tym miejscu w pamięci RAM znajdują się śmiecie, a płyta główna tworzy iluzję, jakoby znajdował się tam BIOS (wie jak to zrobić dzięki przechowywanej przez nią mapy pamięci). Od momentu gdy zacznie wykonywać się BIOS, ciężko powiedzieć co dzieje się dalej, gdyż to zależy od konkretnego BIOS-u – a więc od producenta płyty głównej. Standardowo jest to wykonanie „Power-on self-test”, czyli tzw. POST-u, który testuje – integralność samego BIOS-u, niektóre podzespoły, pamięć itp. Kolejnym krokiem po POST jest przejście do sekwencji bootowania. Pierwszym krokiem bootowania jest przejrzenie (predefiniowanej przez użytkownika w ustawieniach BIOS-u) listy urządzeń do wystartowania i odszukaniu urządzenia (np. dysku), którego pierwszy (a w zasadzie zerowy) sektor, czyli 512 bajtów, kończy się bajtami 0x55 oraz 0xAA. Bajty te oznaczają, iż urządzenie jest bootowalne – zawiera bootloader. Następnie, wspomniane 512 bajtów jest kopiowane pod adres 0x7C00, a procesor zaczyna wykonywać instrukcje spod tegoż adresu. Warto wspomnieć, że istnieje pewien standard, jak owe 512 bajtów powinno wyglądać – tzn. powinien być tam MBR. Nie jest to jednak istotne, gdyż w tym momencie pełną władzę przejmujemy my – programiści! :)

Przed dalszą lekturą upewnij się, że zapoznałeś się informacjami zawartymi w:

QEMU – podstawy

Tryby operacyjne – tryb rzeczywisty

Bootloader

Pierwsza wersja naszego bootloadera będzie robiła.. nic :) a dokładniej nasz bootloader „zatrzyma” procesor. W kolejnej części nieco go rozbudujemy. W tym miejscu trzeba zaznaczyć, że komputer pracuje w trybie rzeczywistym. Spójrzmy na kod naszego bootloadera:

[ORG 0x7C00]			; here is where our code will be loaded by BIOS
[BITS 16]

bootloader:
	cli			; turn off maskable interrupts, we don't need them now
	hlt			; halt CPU
	jmp	bootloader	; this should not happen, but.. ;)

times 510-($-$$) db 0		; fill rest with zeros
dw 0xAA55			; bootloader indicator, used by BIOS

Teraz będą objaśnienia. Pierwsza linia to wskazówka dla naszego kompilatora, żeby przyjął odpowiednie przesunięcie w adresach. Gdyby tej linii nie było (co możecie sprawdzić) kompilator „myślałby”,  że kod wskazywany przez etykietę bootloader znajduje się pod adresem 0x0000, a co za tym idzie skok z linii 7 by się nie powiódł.

Druga linia, to kolejna wskazówka, mówiąca tym razem, że chcemy kod wynikowy 16 bitowy – pamiętacie o trybie rzeczywistym?

Linie 5, 6 i 7 należy rozpatrywać razem. Instrukcja cli wyłącza maskowalne przerwania. O tym co to dokładniej znaczy będzie innym razem, ważne żeby zapamiętać iż przerwania wybudzają procesor, ze stanu halt, aktywowanego poleceniem hlt. W stanie halt procesor zatrzymuje się i nie wykonuje następującej instrukcji, aż do wznowienia. Tak więc brak instrukcji cli spowodowałby chwilowe wstrzymanie procesora, a następnie (gdyż przerwań w tle dzieje się dużo!) wykonanie instrukcji z linii 7, która jest swoistą „ostatnią deską ratunku” :) Polecam poeksperymentować usuwając linie 5 i 7 i zobaczyć dokąd zabrnie nasz IP i czy nie skończy się to restartem.

Linia 9 to makro NASM-a mówiące, aby wypełnić następujące bajty, aż do 510, zerami.

Linia 10 to wspomniany indykator świadczący o tym, iż jest to kod bootloadera.

W ten sposób otrzymujemy 512 bajtów bootloadera.

Nasz kod kompilujemy poleceniem:

nasm boot.asm -f bin -o ./bin/boot.bin

Przełącznik -f bin mówi, iż chcemy, aby wynikowy kod był tzw. płaską binarką (ang. flat binary), czyli ma nie zawierać żadnych nagłówków, ani informacji pomocniczych, a całość kodu źródłowego jest bezpośrednio zamieniana na kod. Możemy to sprawdzić używając narzędzia hexdump – zobaczymy, iż pierwszym bajtem wynikowego pliku boot.bin jest 0xFA, które jest kodem operacji cli :)

Pozostaje nam teraz uruchomienie naszego bootloadera za pomocą polecenia:

qemu -hda ./bin/boot.bin

I voila! Nasz bootloader działa i nic nie robi :) Będąc w QEMU polecam pobawić się trybem monitora, w szczególności wyświetlić fragmenty pamięci.

Końcowe słowa

To tyle w dzisiejszym odcinku. W następnym postaramy się rozbudować nasz bootloader w sposób, który pozwoli załadować nam nasz przyszły kernel. W repozytorium znajduje się pełen kod wraz z plikiem Makefile (niezbyt pięknym, swoją drogą), który pozwala na automatyzację kompilacji i uruchamiania kodu.

Aby ściągnąć kod części 0x01 wykonaj:

git clone git://github.com/luksow/OS.git --branch 0x01

Jeśli chcesz go tylko przejrzeć, wejdź tutaj.

Tworzenie systemu operacyjnego – część 0x00: Wstęp i formalizmy

Skąd pomysł?

Po ponad 6 miesiącach od powstania idei serii postów, pierwsza część ujrzała właśnie światło dzienne. Pomysł na cykl notek zrodził mi się zaraz po ukończeniu kursu „Systemy operacyjne” na mojej ulubionej uczelni. Według (genialnego!) Prowadzącego, około 15% studentów po tym kursie przystępuje do napisania własnego systemu operacyjnego i wygląda na to, że ja jestem w grupie tych ~19 osób. Zapowiada się świetna zabawa :)

Co to będzie?

Seria będzie dokumentować i opowiadać o moich zmaganiach z próbą napisania (od zera) systemu operacyjnego o pewnej funkcjonalności. W postach postaram się zawrzeć wiedzę i kod, który krok po kroku będzie układał się w pełnowartościowy system operacyjny.

Po co to?

Cykl postów o tematyce programowania systemu operacyjnego będzie dokumentacją moich poczynań. Posty będą zdecydowanie miały charakter poznawczy, gdyż w żadnym przypadku nie jestem autorytetem w tej dziedzinie, a (na razie!) tylko początkującym amatorem. Proponuję więc nie polegać na zawartej tu wiedzy podczas kolokwiów z architektury komputerów ;) Proszę też o regularne komentarze z poprawkami do prezentowanych przeze mnie zagadnień – z pewnością będę je wtedy poprawiał. Przy okazji serii mam nadzieję zachęcić czytelników do zagłębienia się w fascynujący świat programowania systemowego, odkryć go nieco, a może nawet skłonić do napisania własnego systemu operacyjnego? Ponadto, nie znalazłem w internecie publikacji w języku polskim, które prowadziłyby krok po kroku (no dobrze, kilka by się znalazło, jednak kroki kończyły się zwykle na 3 odcinkach) po niezwykle zawiłej tematyce tworzenia systemu operacyjnego – będę więc pionierem ;> Przypominam również, że powstający system operacyjny nie ma za zadanie przewyższyć udziału rynkowego takich świetnych systemów jak Microsoft Windows, GNU/Linux czy Mac OS X, gdyż byłoby to niedorzeczne. To po prostu zwykły projekt eksperymentalny.

Co będzie potrzebne?

Najważniejszą rzeczą jest wytrwałość i chęć zdobywania wiedzy :) Posiadając te dwie cechy, nawet całkowity nowicjusz sporo może wynieść z tego cyklu. W swoich wypocinach postaram się zgrabnie balansować między nadmierną gęstością tekstu, a przesadną zwięzłością. Trudniejsze rzeczy postaram się dokładnie objaśniać, łatwiejsze będę traktował po macoszemu, dodając odnośniki, z których będzie można doczytać więcej. Zachęcam do komentowania rzeczy niejasnych i skomplikowanych tak, abym z czasem mógł dostosować poziom.

System operacyjny, przynajmniej z początku, będzie napisany w języku C z małym dodatkiem assemblera x86 (będę używał „dialektu” NASM-a), zatem znajomość C, jak również nieco assemblera zdecydowanie się przyda.

Z całą pewnością potrzebny będzie jakiś działający system operacyjny z dostępnymi narzędziami programistycznymi. W swoich postach będę opisywał tworzenie systemu operacyjnego w środowisku GNU/Linux w połączeniu z typowymi dla tej platformy narzędziami takimi jak gcc czy binutils. Przedstawione przykłady, da się jednak bez problemu skompilować pod Windowsem, czy Mac OS X, wymagałoby to jednak nieco więcej pracy (jeśli będzie taka potrzeba, mogę opisać techniki w oddzielnym poście). Zwolenników systemów innych niż Linux zachęcam do zainstalowania jakiejś łatwej i przyjemnej dystrybucji, choćby na maszynie wirtualnej i programowania w takim środowisku. Gwarantuję, że inicjalny trud się opłaci.

Kolejna rzeczą, która nie jest konieczna, ale znacząco ułatwi nam programowanie i testowanie naszego systemu będzie emulator platformy x86 (gdyż na tą platformę będzie powstawał nasz system). Mój wybór padł na qemu. Emulator przyda się, aby uruchamiać świeżo skompilowany system – choć oczywiście dla wytrwałych pozostaje opcja odpalania systemu na prawdziwej maszynie.

Formalizmy

Koniec gadania, przystępujmy do rzeczy. Chcemy napisać system operacyjny, no dobrze… tylko co to tak właściwie jest? W literaturze dałoby się znaleźć setki mniej lub bardziej zmyślnych/złożonych/obszernych definicji, wymyślić nowych można by było kilka kolejnych, a więc… która jest dobra? Odpowiedzi na to pytanie nie ma, gdyż brakuje obiektywnych kryteriów oceny, czy dana definicja jest poprawna, zła, ładna, zgrabna itd. Mi najbardziej do gustu przypadła następująca:

System operacyjny jest to zbiór programów i procedur spełniających dwie podstawowe funkcje:

– zarządzanie zasobami systemu komputerowego,

– tworzenie maszyny wirtualnej.

Natomiast najtrafniejsza definicja zasobu systemu to wg mnie:

Zasobem systemu jest każdy jego element sprzętowy lub programowy, który może być przydzielony danemu procesowi.

Definicją procesu zajmiemy się przy innej okazji.

Teraz kilka słów wyjaśnień. Jak widać z definicji systemu operacyjnego – nie jest on sam w sobie programem, a zbiorem programów i procedur. System, który będziemy implementować za jakiś czas będzie wyglądał z punktu widzenia czysto technicznego jakby był jednym programem, jednak to tylko decyzja implementacyjna – w ogólności tak być nie musi. Pierwsza funkcja, którą pełni system operacyjny wydaje się być jasna – zarządza on zasobami, które oferuje. Zarządza, czyli udziela dostępu użytkownikowi, czy też raczej programowi użytkowemu. Zasoby sprzętowe to na przykład dysk twardy, pamięć, czy też komputery podłączone w sieci, do stacji na której działa system operacyjny. Zasoby programowe to natomiast wszelkiego rodzaju tablice, czy też semafory. Druga funkcja, czyli tworzenie maszyny wirtualnej może brzmieć nieco tajemniczo. Sama maszyna wirtualna jest raczej kojarzona z procesem wirtualizacji, stawianiem odseparowanych serwerów itp., tym razem chodzi jednak po prostu o tworzenie warstwy abstrakcji łatwej do oprogramowania, użytkowania i tę właśnie warstwę nazywamy maszyną wirtualną. Nieformalnie reasumując: system operacyjny ma za zadanie oferować to, co posiada, w przystępnej formie ;)

Rozważmy teraz takie pytanie: Czy program działający na mikrokontrolerze np. mikrofalówki, lodówki, czy pralki jest systemem operacyjnym? Odpowiedź na to pytanie (według podanej wyżej definicji) to: nie. Program ten jest zadany odgórnie, nie ma możliwości oprogramowywania go – nie posiada żadnej warstwy abstrakcji i o zasobach tutaj też trudno mówić. Z kolei, zastanówmy się nad takim telewizorem, który działa w oparciu o jądro Linux. Tu sprawa nie jest już taka prosta. W końcu wiadomo, że Linux to system operacyjny (puryści mogliby się doczepić do tego stwierdzenia.. ;)), tylko że ten system dla użytkownika końcowego jest całkowicie przezroczysty – nie ma on bezpośredniego dostępu do zasobów, szczególnej maszyny wirtualnej też tu nie widać. Zatem, czy patrząc na telewizor w kontekście pudełka,  w którego środku coś się dzieje i otrzymujemy obraz, należy mówić o systemie operacyjnym? Tak jak już mówiłem, z definicjami nie jest łatwo.

Na koniec

To tyle na pierwszy odcinek serii. W kolejnym odcinku zajmiemy się już konkretami, czyli własnościami architektury x86. Poniżej załączam listę stron z materiałami, które zdecydowanie będą się przydawać.

OSDev.org – bardzo aktywny serwis typu wiki, traktujący o tematyce programowania systemów operacyjnych. Polecam od niego zaczynać szukanie odpowiedzi na pojawiające się wątpliwości.

Bona Fide OS Developer – strona zawiera pokaźną ilość przydatnych tutoriali, niestety nie jest zbyt często uaktualniana

Operating System Resource Center – skarbnica wiedzy o wszelakich zasobach systemów operacyjnych. Panuje tam trochę bałaganu, nie mniej jednak, można tam znaleźć masę przydatnych informacji.

Intel® 64 and IA-32 Architectures Software Developer’s Manuals – darmowe (!) podręczniki Intela do architektury x86 (i x86-64), zawierające 2 grube tomy na temat programowania systemowego. Przystępnie napisane, zwięźle objaśniają subtelności architektoniczne. Da się tam chyba znaleźć odpowiedź na każde pytanie, choć tego nikt nie jest pewien, bo nikt nie dał rady ich przeczytać ;)

Into the Void – skrócony (jakby ten intelowski okazał się za długi, ciężko dostępny) opis instrukcji architektury x86

W porządku, to tyle. Do usłyszenia w następnej części!