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.

7 przemyśleń nt. „Tworzenie systemu operacyjnego – część 0×04: Kernel w C”

  1. U mnie ten kod nie działa – wywala błąd PE operation on non PE file przy linkowaniu

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