Emacs dla C/C++

Co się przydaje?

To czego potrzebuję do pełni szczęścia podczas programowania w C/C++ to:

  • Szybkie działanie edytora
  • Kolorowanie składni
  • Automatyczne formatowanie kodu
  • Inteligentne uzupełnianie kodu
  • Wsparcie dla debuggera
  • Przeglądanie kodu
  • Możliwość rozszerzenia IDE o własne snippety, generację kodu

Wydaje mi się, że moje wymagania nie jest zbyt wygórowane. Niestety, nie jest łatwo o edytor spełniający wszystkie powyższe życzenia. Na szczęście Emacs, po odpowiednim tuningu spełnia wszystkie moje wymagania.

Out of the box

Świeża instalacja Emacsa, w miarę nowej wersji (co do starszych nie mam rozeznania) zapewnia kolorwanie składni, automatyczne formatowanie kodu, wsparcie dla debuggera (gdb) oraz możliwość tworzenia własnych rozszerzeń. Przyzwoicie prawda? Oczywiście wszystko to jest, do granic możliwości, konfigurowalne. Począwszy od zmiany kolorów, poprzez wybór predefiniowanych/własnych stylów formatowania kodu aż do tworzenia własnych skrótów i (mini)skryptów – wszystko to jest oczywiście możliwe! Ponadto, Emacs doskonale integruje się z powłoką systemu, pozwalając na obsługę buildów nie opuszczając edytora.

Na oddzielny akapit zasługuje wspomnienie o tym, że Emacs działa naprawdę szybko, nawet na starych komputerach. Mówiąc szybko, mam na myśli SZYBKO, a nie tak jak Eclipse, czy VS :)

Jest jeszcze kilka cech, których brakuje, ale to załatwiają zgrabne pluginy, o których poniżej.

CEDET

CEDET, czyli Collection of Emacs Development Environment Tools, to kombajn, który obowiązkowo należy doinstalować do Emacsa. Niezależnie jakiego języka programowania używasz, zawsze znajdziesz tu coś dla siebie. Aktualnie, wsparcie dla C/C++ jest najlepiej rozwinięte. Wtyczka oferuje m. in. uzupełnianie kodu, które spokojnie może konkurować z Intellisense z Visual Studio (np. w szybkości działania). Nie są to „naiwne” ctags (dostępne np. w vimie), tylko uzupełnianie z prawdziwego zdarzenia! Niestety, C++ nie jest językiem stworzonym do łatwej analizy, stąd CEDET czasem się myli, ale to raczej wyjątki, niż reguły. Wtyczka posiada również wsparcie dla „code templates” oraz szukanie odniesień w kodzie (do funkcji, metod, klas itd.), które działają całkiem przyzwoicie.

Inne ficzery, których jednak nie używam na co dzień to generacja diagramów UML (!) oraz wsparcie dla zarządzania projektami. Wśród obsługiwanych (lepiej lub gorzej) języków znajdują się: C, C++, Java, Emacs Lisp, Make, Scheme, Erlang, Texinfo, HTML, CEDET, Awk,  Simula, JavaScript, Python, C#, PHP, Ruby, SRecode Templates, Bourne Shell, Scala, dot. Niestety, wsparcie dla wielu z nich, pozostawia wiele do życzenia.

ECB

ECB – Emacs Code Browser, to wtyczka, wymagająca do działania wspomnianego wyżej dodatku CEDET, która zapewnia wygodne i sprawne przeglądanie kodu. Dzięki ECB zyskujemy, okienko (oczywiście w pełni konfigurowalne) umożliwiające nawigację po strukturze katalogów. Niby to nic takiego, ale jeśli rozważymy dowolną możliwość konfiguracji, to da się dzieki temu pluginowi uzyskać naprawdę ciekawe efekty.

Dla wymagających

Jeśli powyższy zestaw funkcjonalności to dla Was za mało, możecie poszperać i znaleźć tysiące, setki tysięcy rozszerzeń do Emacsa wszelkiej maści. Wszak legenda głosi, że do Emacsa napisano już wszstko :) Będzie więc bardzo łatwo o narzędzia dodające obsługę systemów kontroli wersji, czy generacje dokumentacji na podstawie kodu. Szukanie można rozpocząć np. tu.

Na koniec

Mój poprzedni post o edytorach tekstu wzbudził bardzo duże zainteresowanie, można więc spodziewać się więcej postów w tym temacie. W przyszłości m. in. zaprezentuję swój config i ujawnię cały zestaw dodatków, których używam :)

Poniżej zamieszczam screena prezentującego trio Emacs + CEDET + ECB w akcji:

Po lewej widać okienko ECB, z drzewem katalogów. Górne okienko zawiera kod, natomiast dolne – propozycje uzupełnienia kodu. Oczywiście okienko z kodem podczas pisania nie jest takie małe. Normalnie okienka ECB oraz „code completion” są ukryte. Screen natomiast nie oddaje nawet połowy pozytywnych wrażeń z użytkowania dobrze skonfigurowanego Emacsa. Polecam spróbować!

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.

Tryb chroniony, tryb Virtual 8086

Na każdego przyjdzie pora

Pisałem już o trybie rzeczywistym. Przyszła więc pora napisać o trybie chronionym (ang. protected mode, nazywany też w skrócie pmode) i o powiązanym quasi-trybie Virtual 8086. Nie muszę chyba kolejny raz wspominać, iż tryb chroniony jest najważniejszym i najpowszechniejszym trybem architektury x86. Warto zatem wiedzieć, co tak naprawdę nam oferuje.

Historia

Tryb chroniony został wprowadzony w 1982 roku wraz z procesorem Intel 80286. Niestety nie został on przyjęty zbyt ciepło, głównie z powodu braku wsparcia aplikacji napisanych dla trybu rzeczywistego. Większość programistów postanowiła pozostać więc w trybie rzeczywistym, dostępnym bezpośrednio po uruchomieniu jednostki 80286 i nie włączać trybu chronionego. Architekci Intela wyciągnęli wnioski z tego zdarzenia i sytuacja uległa zmianie w 1985, gdy na rynek wszedł procesor Intel 80386, wnoszący wiele udogodnień, w tym możliwość powrotu z trybu chronionego do trybu rzeczywistego. Od tej pory tryb chroniony króluje we wszystkich systemach operacyjnych na platformę x86.

Ochrona

Nazwa trybu chronionego wzięła się z faktu, iż służy on szeroko rozumianej ochronie :). O ogólnym zarysie zdarzyło mi się już pisać. Każdy z mechanizmów opisanych poniżej podlega owej ochronie i jest to absolutnie pozytywna cecha. Zdecydowanie najważniejszą cechą trybu chronionego jest to, że pozwala on pisać bezpieczne systemy operacyjne – takie, w których można dokładnie zdefiniować prawa dostępu dla użytkownika, poza które nie może wykraczać. Można zadać pytanie – z jakiego powodu systemy operacyjne posiadają wciąż tyle błędów, skoro udostępnione mechanizmy są poprawne? Odpowiedź na to pytanie jest prosta – są to błędy programistów, którzy owych mechanizmów korzystają w sposób niepoprawny. Tryb chroniony jest dopracowanym i przemyślanym przez lata mechanizmem sprzętowym – wykorzystuj go dobrze! :)

Dostępne mechanizmy

To co możliwe jest w trybie rzeczywistym wyszczególniłem w swoim artykule. Wszystko pozostałe dostępne jest wyłącznie z poziomu trybu chronionego. Mechanizmy, o których zdecydowanie warto pamiętać to:

  • Segmentacja
  • Stronicowanie
  • Możliwość powrotu do trybu rzeczywistego oraz tryb V8086
  • Fizyczna i wirtualna 4 gigowa przestrzeń adresowa – jeśli posiadamy 4GiB RAM-u – możemy korzystać z niego do woli! Jeśli posiadamy trochę mniej, możemy zbudować pewien poziom abstrakcji „udający” owe 4GiB i to per proces!
  • Wielozadaniowość (z wywłaszczaniem) – możliwość współbieżnego uruchamiania aplikacji, wspierana sprzętowo, możliwa do realizacji programowo.
  • Rozbudowana obsługa wszelakich wyjątków – poprzez IDT możliwa jest obsługa wszystkich rodzjaów wyjątków: przerwań, błędów oraz pułapek.
  • Możliwość korzystania z dobrodziejstw takich jak: wieloprocesorowość, kontrola cache’u, kontrola stanu procesora, w tym zarządzanie energią czy debuggowanie, dostęp do jednostek specjalnych takich jak MMX czy SSE, wydajny dostęp do sprzętu.
  • I sporo innych ;).

To czego natomiast nie mamy, to dostęp do przerwań BIOS-u. Cały sprzęt musimy oprogramować sami.

Virtual 8086

Quasi-tryb V8086, to już raczej przeszłość. Umożliwia on uruchamianie aplikacji 16 bitowych w trybie zgodności i co istotne, działają one współbieżne z innymi aplikacjami. Niestety zgodność nie jest stuprocentowa – programy uruchomione w V8086 nie mogą korzystać z instrukcji systemowych, nie mogą manipulować segmentami, używać bezpośrednio sprzętu i uruchamiać kodu samomodyfikującego się. To, do czego może się przydać V8086 to wywoływanie funkcji BIOS-u, np. zmiana trybu graficznego. Używając tego trybu należy jednak uważać – ma on dostęp do pierwszego MiB pamięci, gdzie często znajduje się kernel (a nie powinien).

Gdzie i jak?

Wejście do trybu chronionego jest proste i wymaga ustawienia odpowiedniego bitu w rejestrze CR0 – dokładniej opiszę to w następnym poście. Wyjście natomiast, nie jest już takie proste. Można o tym przeczytać tu.

Z Virtual 8086 jest jeszcze trudniej, całościowe ujęcie tematu tutaj.

3..2..1..START!

Teraz wiemy już wszystko co potrzebne nam do przejścia i oprogramowania trybu chronionego. A więc do dzieła! Wszystko to w najbliższym odcinku!

GDT

Czy to konieczne?

Do czego służy struktura GDT w systemie operacyjnym, pisałem już wcześniej. Przed przejściem do trybu chronionego, zdecydowanie przyda się nam zdefiniowanie owej tablicy ;) Dziś napiszę o budowie deskryptorów w niej zawartych.

Deskryptor

Deskryptor składa się z 64 bitów i ma dość skomplikowaną budowę. Powyżej widać jego zarysową strukturę. Teraz przyjrzyjmy się poszczególnym elementom:

  • Segment limit – długość segmentu, wyrażona w wielokrotności granularności (patrz poniżej). Limit służy w naturalny sposób ochronie. Warto zauważyć, że poszczególne bity znajdują się w dwóch miejscach struktury.
  • Base address – rozrzucone po strukturze pola składają się na adres bazowy (procesor składa je w jeden, 32 bitowy adres), czyli adres pierwszego bajta segmentu. Adres bazowy powinien być wyrównany do 16 bajtów w celu maksymalizacji wydajności.
  • Type – definiuje własności segmentu (czy do odczytu, zapisu itp.) w zależności czy segment ten jest segmentem systemowym (LDT, TSS, call-gate, interrupt-gate, trap-gate, task-gate), czy segmentem kodu/danych.  Ustawienia poszczególnych pól można znaleźć w tabelkach w podręcznikach Intela. W pewien sposób są to magiczne wartości, których sensu omawiać nie ma.
  • S flag – gdy S jest ustawione, to mamy do czynienia z segmentem kodu/danych. W przeciwnym razie jest to segment systemowy.
  • DPL – poziom uprzywilejowania, potrzebny do dostępu do segmentu. 0 to poziom najbardziej uprzywilejowany, 3 najmniej – zgodnie z tym, co opisałem tu.
  • P flag – stanowi o tym, czy segment jest ważny (ang. present). Nie można uzyskać dostępu do segmentu z wyczyszczoną flagą P – powoduje to wyjątek #NP. Pozwala na kontrolowanie załadowanych do pamięci segmentów.
  • D/B flag – mówiąc najkrócej, 1 oznacza segment 32 bitowy, a 0 – 16 bitowy. Dociekliwi mogą przeczytać więcej na ten temat w podręczniku Intela.
  • G flag – definiuje granularność – gdy flaga nieustawiona, limit wyrażony jest w 1 bajtowych blokach (segment ma maksymalnie 1 MiB), gdy ustawiona, limit wyrażony jest w jednostkach po 4 KiB (segment ma maksymalnie 4 GiB – w taki sposób uzyskuje się właśnie płaski model pamięci)
  • L flag – gdy flaga ustawiona, stanowi o tym, iż segment zawiera kod wykonywalny 64 bitowy. W przeciwnym wypadku mamy do czynienia z kodem 32 bitowym, wykonywanym w trybie zgodności. Gdy flaga L jest ustawiona, flaga D musi być nieustawiona.
  • AVL – bit nieużywany, dostępny dla oprogramowania systemowego

Ładowanie

Do załadowania GDT służy instrukcja lgdt. Instrukcja oczekuje pewnej struktury, która składa się z dwubajtowego rozmiaru tablicy pomniejszonego o 1 oraz czterobajtowego adresu samej tablicy.

Na koniec

Wszystko to może wyglądać na dość skomplikowane. W części 0x03 serii tworzenie systemu operacyjnego zobaczymy jednak, że nie taki diabeł straszny. Warto się już teraz zaprzyjaźnić z deskryptorami, gdyż są one bardzo typowym elementem architektury x86, z którym spotkamy się jeszcze wielokrotnie np. przy okazji wektora przerwań.

Ogólne spojrzenie na architekturę x86

Złożoność

Architektura x86 dojrzewała przez lata. Inżynierowie przez cały czas starali się zachować zgodność wsteczną, wysoką przystosowalność do potrzeb programistów oraz oczywiście dodawać nowe i ulepszać stare funkcje. Z tych powodów IA-32 jest dosyć skomplikowana. Nim zanurzymy się w pełen dobrodziejstw tryb chroniony, warto poznać podstawowe elementy architektoniczne, które będziemy oprogramowywali :)

W tym celu posłużymy się schematem dostępnym w polecanych przeze mnie podręcznikach Intela:

GDT

Znajdująca się w centralnym punkcie obrazka struktura danych zwana Global Descriptor Table (GDT) jest „sercem” architektury x86. Zawiera ona deskryptory segmentów (struktury opisujące segmenty), na które podzielona jest pamięć. Tak jak wspominałem, segmentacja jest zawsze obecna w architekturze x86. Oprócz „zwykłych” segmentów w pamięci, zawierających dane czy kod, w GDT znajdują się również deskryptory TSS oraz LDT. Warto jednak wspomnieć, że bitowo patrząc, wszystkie deskryptory mają taką samą strukturę. Z każdym deskryptorem skojarzony jest odpowiedni selektor, czyli przesunięcie w tablicy GDT wskazujące na dany deskryptor, lokalność/globalność, czyli to, czy mamy do czynienia z wpisem GDT czy LDT oraz prawa dostępu. Adres (liniowy) tablicy GDT, wraz z limitem (długością) znajduje się w rejestrze GDTR.

Odwołując się w jakikolwiek sposób do pamięci, musimy więc podać selektor segmentu oraz przesunięcie wewnątrzsegmentowe. Z selektora pobierany jest numer deskryptora, a z deskryptora następnie adres bazowy, do którego następnie dodawane jest przesunięcie dając adres liniowy – w taki sposób następuje odwołanie do elementu w pamięci.

LDT

Local Descriptor Table (LDT) jest siostrzaną tablicą do GDT. Aktualnie jest już prawie nieużywana. Jej użycie jest istotne w systemach ze starymi procesorami np. 80826, w których nie było jednostki stronicowania – a więc nie było możliwości realizowania separacji, tym samym ochrony pamięci poszczególnych procesów. Aktualnie LDT używane jest wyłącznie do uruchomiania 16 bitowych programów. Niegdyś, każdy proces użytkownika, posiadał własną tablicę LDT, której pozycja znajdowała się w rejestrze LDTR. LDT oferowała ochronę pamięci polegającą na możliwości dostępu tylko przez deskryptory znajdujące się w tejże tablicy. Kernel oraz współdzielona pamięć opisana była w GDT.

TSS

Task State Segment (TSS) to segment opisujący zadanie (ang. task). Używany jest głównie przy sprzętowej wielozadaniowości, w której z każdym procesem skojarzony jest pojedynczy TSS. W strukturze TSS znajdują się wszystkie informacje potrzebne do „odtworzenia” zadania – rejestry ogólnego przeznaczenia i te bardziej szczególne, takie jak EIP. W przypadku programowej obsługi wielozadaniowości potrzebna tylko jednego lub dwóch segmentów TSS. Selektor segmentu TSS jest trzymany w rejestrze TR – Task Register.

IDT

Interrupt Descriptor Table (IDT), to trzecia, po GDT i LDT tablica systemowa. Wpisy w tej tablicy nazywamy bramkami (ang. gates) i mogą być to bramki: przerwań (interrupt), zadań (task) oraz pułapek (trap).  Powstrzymam się z omawianiem konkretnych bramek – zrobię to przy okazji implementowania IDT. Na tę chwilę powiem, że wpisy w IDT służą wywołaniu odpowiedniej procedury obsługi konkretnego zdarzenia, np. przerwania, pułapki. Adres liniowy IDT znajduje się w rejestrze IDTR.

Stronicowanie

Na obrazku, stronicowanie (ang. paging) przedstawione jest na dole (dla pamięci „płaskiej”, rozmiaru strony 4KiB). Jest to bardzo istotny element architektury x86, o którym napiszę więcej w przyszłości. Na chwilę obecną przedstawię ogólną ideę działania jednostki stronicowania. Po przejściu przez wszystkie zawiłości związane z GDT „na wyjściu” otrzymujemy adres liniowy zbudowany, tak jak wspomniałem, z adresu bazowego znajdującego się w deskryptorze segmentu i przesunięcia wewnątrzsegmentowego. Gdy jednostka stronicowania jest wyłączona, adres ten jest bezpośrednim adresem w pamięci liniowej. W przeciwnym wypadku, adres interpretowany jest jako wirtualny. W adresie tym można wydzielić 3 części: dir, table oraz offset. Dir wskazuje wpis w katalogu stron, który z kolei wskazuje na odpowiednią tablicę. Następnie table wskazuje wpis w tablicy stron, który wskazuje konkretną stronę. Offset to przesunięcie w tej konkretnej stronie, które jest już elementem, który chcemy pobrać/zapisać. Jak widać, pojedynczy dostęp do pamięci, np. mov [eax], 42 nie oznacza fizycznie jednego dostępu do pamięci, ale sporo więcej. Stronicowanie ma więc istotny wpływ na wydajność systemu operacyjnego, dając nam jednak elegancką ochronę pamięci.

Rejestry systemowe

Oprócz wspomnianych wyżej, architektura x86 posiada 5 rejestrów kontrolnych (CR0, CR1, CR2, CR3, CR4, XCR0), rejestr flag EFLAGS, rejestry debuggowania oraz rejestry specyficzne dla konkretnej platformy. Myślę, że nie ma co opisywać ich zawiłych funkcji – zrobię to przy okazji używania ich.

Podsumowanie

Jak widać, architektura x86 jest dosyć bizantyjska. Posiada wiele funkcjonalności, których aktualnie się nie używa, które jednak znalazły swoje zastosowanie w przeszłości. Jest z tego powodu bardzo ciekawa z punktu widzenia tworzenia systemów operacyjnych – można tworzyć bardzo skomplikowane twory, całkowicie zgodne z wizją programisty, łącząc old school z tym co trendy ;).

O wspomnianych wyżej elementach architektury x86 będę jeszcze dokładniej pisał przy okazji ich używania. Na pierwszy ogień pójdzie GDT.

Ogólna koncepcja ochrony w x86

Pierścienie

Tryb chroniony, jak sama nazwa wskazuje, ma oferować ochronę. W związku z tym, do architektury x86 wprowadzono pewien podział – na poziomy uprzywilejowania. Poziomów tych jest 4 i zwyczajowo nazywa się je ring 0, ring 1, ring 2 i ring 3, gdzie ring 0 to poziom najbardziej uprzywilejowany, a ring 3, to poziom najmniej uprzywilejowany.

Architektura nie narzuca w żaden sposób, który poziom za co ma odpowiadać – wiadomo tylko, że z danego poziomu mamy dostęp do elementów z poziomów o wyższych numerach (i oczywiście do elementów z danego poziomu). Przykładowo role można podzielić tak:

  • Ring 0 – jądro systemu
  • Ring 1 – wywołania systemowe
  • Ring 2 – biblioteki współdzielone
  • Ring 3 – aplikacje użytkownika

W praktyce często używa się tylko dwóch poziomów – dla jądra i użytkownika.

Zakończenie

Z poziomami ochrony będziemy się stykać wielokrotnie podczas tworzenia systemu operacyjnego. Na razie wystarczy nam tyle informacji. Na różne sztuczki typu call gates przyjdzie jeszcze czas.

Segmentacja – nielubiana siostra stronicowania

Co to?

Segmentacja, to obok stronicowania, popularny sposób organizacji pamięci. W kwestii samej pamięci nic się nie zmieniło – nadal rozważamy liniową przestrzeń, której komórki posiadają adresy (dla przypomnienia – adres do konkretnej komórki pamięci nazywamy adresem fizycznym). Mechanizm segentacji dzieli liniową pamięć na segmenty – segmentem, mówiąc najprościej, jest pewien (najczęściej ciągły) obszar pamięci. W taki sposób do odwołania się do naszej „płaskiej”, jednowymiarowej pamięci potrzebujemy dwuwymiarowego adresu (adres logicznego – czyli pewnego, jednoznacznego odpowiednika adresu fizycznego) – jest to nazwa (najczęściej numer) segmentu, oraz pozycja wewnątrz tego segmentu. Przeanalizujmy poniższy rysunek:

Pamięć adresowana jest od 0x0000 do 0xFFFF. Zawiera ona 3 segmenty o numerach (nazwach) 0, 1 i 2. Jak widzimy w każdym segmencie adresowanie zaczyna się od 0x0000, a kończy się na pewnym adresie, nazywanym popularnie limitem, który jednocześnie wyznacza rozmiar segmentu. Teraz, aby odwołać się do elementu 1 o adresie 0x3000 należy podać adres segmentu czyli 1 oraz odpowiednie przesunięcie wewnątrz tego segmentu, czyli 0x3000 (takie samo jak adres fizyczny, gdyż segment zaczyna się na adresie 0x0000) – ostatecznie otrzymujemy adres (1, 0x3000) (notacja: (segment, przesunięcie wewnątrzsegmentowe)), dla elementu 2 o adresie fizycznym 0xA123 otrzymujemy w posegmentowanej pamięci adres (0, 0x2124), natomiast element 3 o adresie fizycznym 0xFFFF ma adres logiczny (2, 0x1FFF). Wszystko powinno być już teraz jasne. Trzeba przyznać, że dosyć to udziwnione – liniowa pamięć jest wygodna, po co więc używać dwuwymiarowego odpowiednika i w ogóle skąd się te segmenty biorą?

Zalety

Segmentację opracowano w celu umożliwienia podziału programu i danych na logicznie niezależne przestrzenie adresowe oraz aby wprowadzić łatwy mechanizm bezpieczeństwa i współdzielenia.

Segmentacja jest widoczna w modelu programowym – programista może swodobnie definiować nowe, własne segmenty – w ten sposób dostajemy logiczny, elegancki podział informacji. Przykładowe segmenty to: kod, dane, stałe, bss (statycznie alokowane dane), stos itd. To, co się w nich znajdzie zależy tak naprawdę od inwencji programisty.

Segmentacja wprowadza sprytne zabezpiecznie programów przed niebezpiecznymi błędami typu przepełnienie bufora, gdyż poszczególne segmenty mogą być oznaczone jako kod lub jako dane. Próba „wykonania” segmentu z danymi lub nadpisania załadowanego kodu zakończy się błędem. Ponadto segmenty mogą posiadać zabezpiecznia ze względu na poziom uprawnień – tj. czy to kod/dane systemu operacyjnego, sterowników, czy użytkownika.

Kolejną zaletą jest łatwość relokacji danych. Wyobraźmy sobie, że chcemy mieć tablicę o zmiennym rozmiarze – taką do której możemy „doalokować” pewną liczbę bajtów. W pewnym momencie, przy zmianie długości może się okazać, że bezpośrednio po naszej tablicy znajdują się inne, potrzebne dane. Musimy wtedy zaalokować naszą nową, większą tablicę w innym, odpowiednio dużym miejscu – a co się z tym wiąże – zmienić jej adres. Korzystając z segmentacji możemy umieścić taką dynamiczną tablicę w odpowiednim segmencie i w miarę potrzeb zwiększać jego limit. Gdy okaże się, że rozszerzający się segment zacznie zachodzić na inny, możemy go wtedy relokować w inne miejsce, ale tym razem adres się nie zmieni, gdyż segment nadal będzie miał ten sam numer, a elementy w jego wnętrzu to samo przesunięcie (choć oczywiście wiąże się to z kopiowaniem danych).

Ostatnią zaletą, o której warto wspomnieć to możliwość łatwego współdzielenia kodu oraz danych. Wystarczy w tym celu utworzyć odpowiednie, współdzielone segmenty i pozwolić na korzystanie z nich odpowiednim procesom. W taki sposób uruchomienie kilku instancji danego programu nie powoduje zapełnienia dostępnej pamięci wieloma identycznymi wersjami kodu.

Wady

Wady mechanizmu segmentacji są oczywiste. Segmentacja wymusza na programiście używania nieliniowej przestrzeni adresowej co może być kłopotliwe. Ponadto, tworzenie wielu segmentów, a nastepnie ich usuwanie może doprowadzić do powstawania „dziur” pomiędzy kolejnymi segmentami, które będą zbyt małe, aby pomieścić nowe segmenty. Takie zjawisko nazywamy szachownicowaniem lub (popularniej) zewnętrzną fragmentacją. Problem ten można rozwiązać za pomocą bardzo kosztownego kompaktowania – czyli upychania segmentów tak, aby następowały po sobie.

Realia

Architektura x86 posiada wsparcie dla segmentacji. Można powiedzieć nawet więcej – segmentacja działa zawsze i nie da się jej wyłączyć (w przeciwieństwie do stronicowania), czego większość informatyków jest nieświadoma. Sprytnym, programowym obejściem tego faktu jest utworzenie dwóch segmentów, rozciągających się na całą pamięć – kodu i danych. Będą one wtedy oczywiście na siebie zachodzić, ale niczemu to nie szkodzi. Tej techniki używają wszystkie nowoczesne systemy operacyjne w tym Linux, Windows oraz Mac OS X. Dlaczego segmentacja jest tak nielubiana? Cóż, widocznie wszyscy programiści kochają liniową przestrzeń adresową ;). Warto jednak zauważyć, że we wspomnianych systemach operacyjnych segmentacja realizowana jest niejako programowo – tj. istnieje możliwość tworzenia własnych segmentów (słówka kluczowe segment albo section w NASM) w assemblerze. Nie istnieje jednak szczególny powód aby to robić, dlatego z tej możliwości korzystają tylko kompilatory języków wyższego poziomu tak, aby ułożyć logicznie kod (w sekcje code, data, bss).

Jeśli interesują Was systemy korzystające z segmentacji, polecam poczytać o systemie MULTICS (z którego wywodzi się UNIX),  w którym mechanizmu tego użyto.

A20 – historia nieodpowiedzialności

A20?

W mojej serii postów zbliżam się coraz bardziej do momentu, w którym opuszczę tryb rzeczywisty i przejdę do trybu chronionego. Wspominałem, że w trybie rzeczywistym jesteśmy w stanie zaadresować do 1MiB pamięci RAM, a tryb chroniony daje nam dostęp do całej 4GiB przestrzeni. Jak się jednak okazuje, samo fizyczne przejście (o którym niebawem) nie wystarcza – trzeba wcześniej „odblokować” linię A20, czyli 21 linię adresową (A20 – od address line 20 – numeracja od 0 ;)) dostępu do pamięci. Przypomnijmy – 1 MiB = 2^20, a więc potrzebujemy 20 fizycznych linii, które wyznaczają adres. Odblokowanie 21 linii powoduje również aktywowanie pozostałych – ile ich jest, zależy tak naprawdę od konkretnego sprzętu. Dla sprzętu obsługującego 4GiB pamięci RAM będzie ich oczywiście 32.

Zgodność wsteczna…

Dociekliwi mogą zastanawiać się, dlaczego 21 linia adresowa jest domyślnie nieaktywna. Odpowiedź na to pytanie jest śmieszna i jednocześnie przykra – mówiąc najkrócej – zgodność wsteczna. W dawnych czasach procesora 8086 nie było potrzebnych więcej niż 20 linii – 1MiB było szczytem technologicznych marzeń. Powstało wtedy bardzo wiele programów współpracujących z tą architekturą, które tworzone były przez bardziej albo mniej rozgarniętych i odpowiedzialnych informatyków. Ci drudzy wykorzystywali fakt, iż przekroczenie granicy 1MiB (co było możliwe w związku z podziałem na segmenty – patrz tryb rzeczywisty) powodowało dostęp do początkowych bajtów pamięci. Mówiąc obrazowo – pamięć „zawijała się” – z końca do początku. Czujecie już jak bardzo bezmyślnym pomysłem było wykorzystanie tego „bugo-ficzera”? W końcu nastały piękne czasy IBM PC/AT wraz z Intelem 286, który umożliwiał zaadresowanie do 16 MiB pamięci. Rewolucja, prawda? Nie jest dziwnym, iż twórcy nowoczesnej technologii chcieli zachować 100% zgodności wstecznej z poprzednią architekturą, tak aby klienci wraz z wymianą sprzętu nie musieli wymieniać oprogramowania. Wszystko było pięknie do czasu, aż testrzy zaczęli zgłaszać bugi w pewnym oprogramowaniu – wykorzystujący zawijającą się pamięć! Prawa rynku są brutalne, więc zmartwieni, ważni ludzie pracujący nad 286 postanowili zaimplementować tego „buga” w swoim nowym procesorze blokując linię A20 i uzyskując pełną zgodność wsteczną :) Najwidoczniej poprawianie starych błędów mogło okazać się zbyt kosztowne i lepiej było je „obejść”. Z resztą, takie decyzje są w naszej branży bardzo powszechne. W taki sposób do dzisiejszego dnia, komputery architektury x86 mają (a raczej miewają) zablokowaną linię A20. Niestety, to nie koniec problemów.

Standardy, eee?

Okej, skoro już mamy tę linię zablokowaną, to warto by było ją aktywować, gdy już przekonamy się do nie-16bitowych aplikacji. Niestety w czasach 286 standardy nie były tak trendy jak teraz, więc nie opracowano ustandaryzowanego sposobu na dokonanie aktywacji. Koncepcji było wiele. Najpopularniejsą stała się koncepcja, aby do odblokowania A20 używać wolnego pina… w kontrolerze klawiatury! Czujecie zapach absurdu? Klawiatura, pamięć – gdzie tu związek? Wśród innych koncepcji warto wspomnieć o tzw. FAST A20 Gate pozwalającej na odblokowanie za pomocą portów, czy odpowiednich przerwaniach BIOS-u. Oczywiście różne sposoby działają lub nie na różnych płytach głównych różnych producentów, a jedyny sposób aby mieć jako taką pewność, iż udało się nam aktywować A20 to spróbować kilku sposobów. Jakby mało było złego, niektórzy producenci postanowili być na tyle dobroczynni, że dostarczają nam środowisko z już odblokowaną A20. Miło z ich strony, prawda? Dzięki temu, zanim przystąpimy do aktywowania A20, należy dodatkowo sprawdzić, czy ktoś nie zrobił tego za nas. Wspaniale!

Bolesna lekcja

Wnioski z powyższej historii są banalne. Nie bądźmy krótkowzroczni. Czasem nasze decyzje mogą mieć wpływ na rozwój spraw wiele lat później – w szczególności mogą go zahamować. Nie starajmy się również pomagać na siłę – czasem łatwo tym zaszkodzić. Przede wszystkim jednak, pamiętajmy o standardach – o tworzeniu ich i pielęgnowaniu.

W którymś z następnych postów zaimplementujemy sprawdzanie czy linia A20 jest aktywna oraz w razie potrzeby spróbujemy ją aktywować którymś z popularnych sposobów. Tymczasem zachęcam do poczytania na temat tu i tu.