„Daj się poznać” – finał!

Udało się! Dostałem się do finałowej siedemnastki konkursu Daj się poznać. Dziękuje wszystkim współzawodnikom, którzy oddali na mnie swój głos :) Jednocześnie zwracam się do wszystkich Czytelników, którym moje posty przypadły do gustu, z prośbą o oddanie na mnie głosu. Możecie to zrobić tutaj. Wasze uznanie dla mojej pracy, to najlepsza zachęta do dalszego, intensywnego pisania. Z góry dzięki! :)

„Daj się poznać” – sukces!

Statystyka

W zeszłym tygodniu minął 10 tydzień pracy nad moim projektem. Na blogu, łącznie z tym, wiszą 23 posty otaggowane „daj się poznać”. 20 z wymienionych, to posty stricte konkursowe, 6 z nich to posty dotykające projektu bezpośrednio, pozostałe 14, to potężna dawka wiedzy przydatnej podczas tworzenia systemu operacyjnego. 3 posty trafiły na dotnetomaniaka, generując znaczny ruch :) Najbardziej popularnym postem, był nieco kontrowersyjny wpis Edytor tekstu zamiast wypasionego IDE?. Skandalizowanie zawsze w cenie? ;>

Kod?

Zabrzmi to śmiesznie, ale projekt ma w obecnym stadium poniżej 100 linii. To dużo i mało jednocześnie. Dużo, gdyż wiedza potrzebna i zalecana do napisania tych <100 linii jest naprawdę obszerna. Mało, gdyż to wciąż tylko <100 linii, gdy inne projekty konkursowe mają ich tysiące. Tak jak wspominałem, projekt nie stanowi pełnoprawnego „produktu”, a jest przedmiotem swoistych badań – i w takiej formie idealnie się sprawdza.

Słodki smak sukcesu

Swój udział w konkursie uważam za ogromny sukces, niezależnie od miejsca, które zajmę. Dzięki Maćkowi, zmobilizowałem się do regularnego pisania postów (choć bywało ciężko). Pokazałem sam sobie, że się da. Jak się okazało – najtrudniej jest zacząć. Bolączką mojego bloga były naprawdę sporadyczne i nietematyczne posty – teraz to się zmieniło. Kolejnym aspektem sukcesu jest pozytywna informacja zwrotna, którą dostałem. Wszystkim, którzy pozytywnie wyrazili się na temat mojej pracy serdecznie dziękuję – to niesamowita motywacja do dalszego działania.

Co dalej?

Projekt i seria postów na temat tworzenia systemu operacyjnego na pewno nie skończy się wraz z konkursem. Całość mam zamiar jeszcze dłuuuuugo kontynuować. Nie mogę zagwarantować, że posty dalej będą pojawiać się tak regularnie, jak przez czas trwania konkursu, postaram się jednak pisać jak najczęściej.

Na koniec

Jeśli seria postów konkursowych przypadła Ci do gustu drogi Czytelniku, nie zapomnij zagłosować na mnie po 15 listopada! Tak czy inaczej, zachęcam do subskrypcji mojego bloga i regularnego zaglądania tutaj! Będzie coraz lepiej – gwarantuję!

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.

Pliki wykonywalne/obiektowe

Co to?

Uruchomienie programu to czynność oczywista, która polega, z grubsza (w zależności od systemu operacyjnego), na dwukliku na ikonę „pliczek.exe”, wykonania w terminalu polecenia „./kodzik” lub wybrania odpowiedniej pozycji z menu telefonu. Niewiele osób zastanawia się, co tak naprawdę kryje się za wspomnianymi plikami, które powodują uruchomienie programu – nie jest to też wcale takie oczywiste.

Pliki te nazywa się najczęściej wykonywalnymi. Niestety, jak to często bywa, nie ma ogólnoprzyjętego zestawu nazewnictwa i definicji, stąd liczne nieporozumienia między określeniem „plik wykonywalny”, a „plik obiektowym” (polska nazwa jest dla mnie dziwna, po angielsku to object file) – spróbujmy to jakoś ogarnąć. Plikiem obiektowym, najwygodniej jest nazywać każdy plik, który zawiera uporządkowane sekcje kodu maszynowego (składającego się z instrukcji). Dodatkowo, w takim pliku mogą, ale nie muszą, znajdować się dane oraz metadane. Plik wykonywalny, to natomiast rodzaj pliku obiektowego, który może być bezpośrednio wykonany pod kontrolą systemu operacyjnego. Innym przykładem pliku obiektowego może być np. biblioteka dynamiczna/współdzielona.

Wszystkie pliki obiektowe są zapisane na dysku w pewnym ustalonym formacie, zależnym od systemu operacyjnego, w którym mają być uruchamiane. Format ten definiuje w jaki sposób w pliku zapisane są instrukcje, dane i metadane. Instrukcje to po prostu kod maszynowy, dane to wszelkie zmienne i struktury, które są zdefiniowane bezpośrednio w źródle (np. zmienne statyczne, łańcuchy znaków). Metadane to natomiast bardziej skomplikowana sprawa. To, co wchodzi w ich skład zależy przede wszystkim od samego formatu – jego przeznaczenia. Metadanymi są np.: identyfikator formatu (który często jest po prostu „magicznymi liczbami”) umieszczony w nagłówku, wskazówki na temat relokacji, informacje dla debuggera, tablica symboli. To na podstawie metadanych pliku wykonywalnego system operacyjny „wie”, jak rozmieścić kod i dane programu w pamięci i skąd w zasadzie zacząć jego wykonywanie. Metadane pliku wykonywalnego powinny być kompletne, tzn. powinny zawierać pełen opis, pozwalający na uruchomienie programu tak, aby przeniesienie go na analogiczny system operacyjny nie wymagało dodatkowych czynności. Można powiedzieć, iż metadane są instrukcją obsługi do załączonego kodu i danych, dla systemu operacyjnego.

Przykłady

Jak to w życiu bywa – standardy – każdy ma własne ;) Stąd formatów plików obiektowych jest bardzo wiele, może nawet tyle co formatów audio, czy obrazów. Poniżej przedstawiłem króciótką charakterystykę kilku z nich:

  • a.out – pierwotny format plików wykonywalnych UNIXa, który pojawił się tam już w pierwszej wersji, jeszcze na komputery PDP-7. Jest niezwykle prosty i popularny, ale w nowych rozwiązaniach raczej nieużywany. Teraz nietrudno zgadnąć, skąd wzięła się domyślna nazwa programów kompilowanych pod UNIXem :)
  • COFF (Common Object File Format) – format COFF zastąpił a.out na pozycji domyślnego formatu plików  obiektowych (sam został już jednak zastąpiony przez format ELF). W formacie COFF można przechowywać pojedyncze funkcje, symbole, fragmenty programów, biblioteki oraz może być on bezpośrednio wykonywany.
  • ELF (Executable and Linking Format) – ELF zastąpił natomiast COFF na pozycji lidera w systemach uniksopodobnych (i nie doczekał się jeszcze zdetronizowania). Mimo kilku wad, jest dobrze udokumentowany i bardzo popularny. Został opracowany w Unix System Laboratories podczas prac nad sławnym Systemem V R4.
  • COM – wyjątkowo prosty format używany przez MS-DOS. Można powiedzieć, że jest on „płaską binarką” – nie zawiera żadnych nagłówków. W związku z tym, iż pliki o formacie COM wykonywane były w trybie rzeczywistym, są ograniczone co do rozmiaru do wielkości jednego segmentu trybu rzeczywistego.
  • MZ – format MZ zastąpił COM w MS-DOS 2.0 i jest zdecydowanie bardziej zaawansowany ;) Na uwagę zasługuje fakt, że jego nazwa (i jednocześnie identyfikator zawarty w pierwszych dwóch bajtach pliku – 0x4D (M) 0x5A (Z)) wzięła się od inżyniera Marka Zbikowskiego – rodzimo brzmiące nazwisko, prawda? :)
  • NE (New Executable) – format NE zastąpił natomiast MZ i został wprowadzony w Windowsie 3.x. Niestety posiada on limity rozmiaru takie jak MZ oraz również jest 16 bitowy.
  • PE (Portable Executable) – w końcu, współczesny format plików wykonywalnych dla systemu Windows, wprowadzony wraz z wersją 95. Oczywiście jest on 32 bitowy i posiada wiele udogodnień w stosunku do poprzedników. Warto też wspomnieć, iż jest on kontenerem dla binarek .NETa.

Podsumowanie

Celem stosowania formatów jest ustandaryzowanie pewnych kwestii. Jak widać na powyższych przykładach, nie jest to łatwe – każdy chce mieć coś do powiedzenia :) Stosowanie różnych formatów plików obiektowych na różnych systemach operacyjnych sprawia iż „binarki” (pliki wykonywalne) nie są przenośne – uruchomienie skompilowanego, nawet prostego programu typu hello world, nie jest możliwe na obu systemach. Stąd powstały koncepcje wielu emulatorów, jak choćby WINE czy liczne klony „pegasusa” :), które starają się ten problem usunąć.

Nawiązując do projektowania systemów operacyjnych z formatami plików obiektowych spotkać można się np. przy tworzeniu interfejsu binarnego aplikacji czy też kompilując jądro do formatu łatwego do przeczytania przez konkretny bootloader.