Pisanie systemów operacyjnych cz. II - tryb chroniony

Wolverine

Witam wszystkich deweloperów systemów operacyjnych w drugim kursie, w którym przedstawie w jaki sposób zainicjować tryb chroniony, załadować i uruchomić kernela napisanego w języku C. Ta część wydaje mi się krótsza z tego powodu, że czytając poprzednią masz już jako takie pojęcie w tym temacie. Jeśli nie odechciało Wam się tworzenia systemu (ew. nauczenie się tego) to zapraszam do lektury!

0x01. Założenia

Z pozoru nasz nowy system będzie robił o wiele mniej od tego przedstawionego w poprzednim kursie (http://4programmers.net/article.php/id=567), bo tym razem jedyne co zrobi to wyświetli napis na ekranie (nie będzie ESC i restartowania komputera), lecz będzie to 32 bitowy system pracujący w trybie chronionym stanowiący podstawe dla jądra pisanego w C.

0x02. Co nam będzie potrzebne tym razem?

Prócz rzeczy z poprzedniego kursu, czyli kompilator assemblera (np NASM - http://nasm.sourceforge.net/) i programu merge, potrzebować będziemy kompilatora C. Ja wybrałem GCC w pakiecie DJGPP (http://www.delorie.com/djgpp/). W tym tekście dowiemy się również jak korzystać z emulatora Bochs (http://bochs.sourceforge.net/). Oczywiście możesz wybrać inny, lepszy dla Ciebie zestaw narzędzi.

0x03. Czym jest tryb chroniony?

Tryb chroniony poraz pierwszy pojawił się w procesorze 80286 (16 bitowy), jednak nas interesuje 80386 i wyżej które są 32 bitowe. Głównym założeniem trybu chronionego, co można wywnioskować po nazwie, jest ochrona pamięci. W tym trybie nie mamy na sztywno ustawionych segmentów jak w trybie rzeczywistym, tylko tworzymy je sami. Każdy segment ma specjalne atrybuty, które określają czy znajduje się w nim kod czy dane, czy możemy go modyfikować, czy nie itp. Możemy ustawić również poziom uprzywilejowania, dzięki czemu możemy niedopuścić do używania pewnego segmentu przez inny. W trybie chronionym mamy dostęp do całej pamięci zainstalowanej w komputerze (patrz http://4programmers.net/faq.php/id=702), podczas gdy tryb rzeczywisty pozwalał na adresowanie tylko pierwszego megabajta.

0x04. Na początek załadujemy kernela w trybie rzeczywistym.

Ponieważ w trybie chronionym nie możemy (a przynajmniej nie tak łatwo) korzystać z usług BIOSa, załadujemy jądro będąc jeszcze w trybie rzeczywistym. W praktyce wykonujemy to samo, co robiliśmy w przypadku poprzedniej części kursu, czyli:

; tak jak poprzednio BIOS nasz bootsektor zaladowal pod 0x7C00
[ORG 0x7C00]

; w tybie rzeczywistym uzywamy 16 bitowego kodu
[BITS 16]
start:
  ; ustawiamy stos dla trybu rzeczywistego
  mov ax, 0x1000
  mov ss, ax
  xor esp, esp

  ; inicjujemy tryb wideo 80x25 (tekstowy)
  xor ah, ah
  mov al, 3
  int 0x10

  ; ladujemy jadro pod adres 0x1000
  xor ah, ah
  int 0x10

  mov ah, 2
  mov al, 10
  xor ch, ch
  mov cl, 2
  mov dh, 0
  mov bx, 0x1000
  mov es, bx
  mov bx, 0
  int 0x13

Po wykonaiu tego kodu następne 5kb z dyskietki znajdzie się w pamięci pod adresem 0x10000, bedziemy mieli stos z wierzchołkiem 0x1000 i tryb wideo 80x25 (0x03).

0x05. Tworzenie segmentów, czyli ładowanie GDT.

Ta część należy do najtrudniejszej w tym kursie, więc przygotuj się na to, że bedziesz ją powtarzał wiele razy.

GDT (Global Descriptors Table - Globalna tablica deskryptorów) jest tablicą w ktorej przechowywane są informacje o segmentach. To właśnie dzięki niej możemy utworzyć segmenty dla naszego programu. Aby to zrobić musimy wiedzieć jak taka tablica jest zbudowana, a więc tablica składa się z nagłowka i deskryptorów, format dekryptowa wygląda tak:

[ 16B | młodsze słowo limitu              ]
[ 16B | młodsze słowo bazy                ]
[ 8B  | młodszy bajt starszego słowa bazy ]
[ 8B  | _                                 ]
        |
        |- [ 1B | P   ]
        |- [ 2B | DPL ]
        |- [ 1B | DT  ]
        '- [ 4B | typ ]

[ 8B  | _                                 ]
        |
        |- [ 1B | G           ]
        |- [ 1B | D           ]
        |- [ 1B | L           ]
        |- [ 1B | AVL         ]
        '- [ 4B | bity limitu ] 

[ 8B  | najstarszy bajt bazy              ]

Tera troche wyjaśnień, bazą nazywamy miejsce w pamięci gdzie zaczyna się dany segment, natomiast limit jest jego długością.

P - obecność, jeśli bit jest zapalony, segment jest obecny i poprawny, bit ten jest często używany do pisania obsługi pamięci wirtualnej
DPL - jest to poziom uprzywilejowania, przyjmuje on wartości od 0 do 3, gdzie 0 jest najwyższym poziomem. Sprowadza się to do tego, że niższy poziom (wyższy DPL) nie ma dostępu do segmentu z wyższym poziomem (niższym DPL)
DT - typ deskryptora, nie wiem dokładnie na czym polega jego wykorzystanie, lecz najczęściej ustawia się go jako zapalony (kod lub dane)
G - jeśli bit jest zapalony, wartość limitu mnożona jest prze 4k (4096)
D - domyślna wielkość elementu (0 - 16B, 1 - 32B)
L - Ustawiony na 1 oznacza kod 64 bitowy, a na 0 kod 32 bitowy. W systemach 32 bitowych musi być ustawiony na 0
AVL - wartość nieużywana w procesorach 80296, jeśli jesteś pewien, że procesor dla Twojego systemu to 286 możesz użyć tego bitu jak chcesz, jeśli to 80386 lub wyższy ustaw bit na zgaszony (0).

Nadszedł szas na nagłowek tablicy GDT (tu już z górki), składa się on ze słowa i podwójnego słowa, w pierwszym jest wielkość całej tablicy w bajtach, drugi wskazuje fizyczne połorzenie tablicy.

Przykładowa tablica deksryptorów kodu o bazie 0 i limicie 4GB i danych o tej samej bazie i limicie:

gdt:
  ; NULL Descriptor
  dd 0
  dd 0
  
  ; kod, baza: 0, limit: 4GB, DPL: 0
  dw 0xFFFF    ; mlodsze slowo limitu
  dw 0         ; mlodsze slowo bazy
  db 0         ; wlodszy bajt starszego slowa bazy
  db 10011010b ; kod / exec-read
  db 11001111b ; flagi i 4 bity limitu
  db 0         ; najstarszy bajt bazy
  
  ; dane (odczyt/zapis), baza: 0, limit: 4GB, DPL: 0
  dw 0xFFFF
  dw 0        
  db 0         
  db 10010010b 
  db 11001111b 
  db 0         
gdt_end:    
  
; naglowek
gdt_descr:
  dw gdt_end - gdt - 1    ; rozmiar gdt
  dd gdt                  ; adres pierwszego deskryptora

Zerowy deskryptor jednym z zabezpieczen trybu chronionego i po prostu musimy go uwzględnić, nie warto się w to zagłębiać, więc po dodatkowe info odsyłłam do manuali.

0x06. Przełączanie na PMODE.

Samo przełaczenie się do trybu chronionego jest proste i nie wymaga wielkiej filozofii. Jedyne co musimy zrobić to załadować globalną tablice deskryptorów, zapalić bit jedności w rejestrze cr0. Tyle teorii a teraz kod:

lgdt [gdt_descr]

mov eax, cr0
or eax, 1
mov cr0, eax

Teraz należy ustawić rejestry segmentowe z naszej nowej tablicy. Tym razem nie dzielimy adresu liniowego przez 16 i zapisujemy w rejestrach, tylko mnożymy pozycję deksryptora (zaczynając od zera) przez 8. Ustawiamy tylko rejestry danych ponieważ CS sam wybierze prawidłową wartość po wykonaniu skoku.

mov ax, 0x10 ; deksryptor danych (8 * 2 = 16 = 0x10)
mov ss, ax
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax

Warto zauważyć, że jeśli przed przejściem do trybu chronionego ustawimy jakiś rejestr segmentowy, bedzię on prawidłowy w trybie chronionym i będziemy mogli go używać, natomiast już w pmode musimy ładować rejestry segmentowe wartościami z GDT.

Po tych czynnościach należy wykonać długi skok aby wyczyścić cache procesora w którym są instrukcje 16-bitowe.

jmp 0x08:start32 ; uzywamy segmentu kodu z GDT

[BITS 32]
start32:

Po tym zabiegu możemy skoczyć do jądra.

jmp 0x08:0x10000

0x07. Szkielet jądra w języku C.

Chociaż możemy już korzystać w wysokopoziomowego języka należy pamiętać, że wszystkie funkcje, których używaliśmy przy normalnym programowaniu w C są niedostępne (lub prościej - nie istnieją), musimy sami je zaimplementować. My zrobimy tylko prymitywną wersje funkcji print.

void print(unsigned char *str, unsigned char color)
{
  unsigned char *vga = (unsigned char *)0xb8000; //wskaznik do pamieci ekranu

  while(*str != 0)        //dopoki nie napotkamy na koniec - #0
  {
    *vga = *str;          //zapisz znak
    *(vga + 1) = color;   //zapisz kolor
    str++;                //nastepny znak
    vga += 2;             //jak wyzej (znaki w pamieci ekranu maja 2 bajty)
  }
}

Teraz z grubsza co ona robi. Jak można sie domyśleć wyświetla tekst na ekranie, lecz z haczykiem - jeśli wywołamy ją dwa razy tekst zostanie nadpisany, dlatego należałoby utworzyć jakąś zmienna z aktualną pozycją, ale to pozostawiam Wam.

Przyszła pora żeby zdecydować gdzie nasz kernel ma się zaczynać (zupełnie jak main w normalnym programie C), będzie to funkcja k_main:

void k_main()
{
  print("Krzeslo PMODE operating system :)", 3);
}

Aby było wiadomo, że ta funkcja ma się wyknać jako pierwsza będziemy musieli napisać kod startowy w assemblerze.

[BITS 32]
[EXTERN _k_main] ; wyjasnienie nizej
call _k_main     ; uruchamiamy kernela
hlt              ; po wyjsciu z kernela zatrzymujemy komputer

Wyjaśnienie będzie dotyczyło dwóch rzeczy, słowa kluczowego extern i znaku _ (podkreślenie) przed nazwą funkcji. Dzięki extern nasza funkcja jest widoczna dla linkera, którym otrzymamy plik binarny z skompilowanego kodu C i ASM. Znak podkreślenia dodawany jest do zewnętrznych funkcji przez kompilator GCC pod systemami Windows (nie wiem dlaczego tak się dzieje, jakby ktoś to wiedział i potrafił temu zapobiec prosze o kontakt), pod innymi systemami nie ma takich niespodzianek.

0x08. Kompilowanie i linkowanie.

Do skompilowania zrobimy sobie prymitywny plik *.bat i skrypt linkera *.ld. Plik link.ld będzie wyglądał tak:

OUTPUT_FORMAT("binary")
ENTRY("_k_main")
SECTIONS {
 .text 0x10000 : {
  code = . ; _code = . ;
  *(.text)
 }
 .data : {
  *(.data)
 }
 .bss : {
  bss = . ; _bss = . ;
  *(.bss)
  *(.COMMON)
 }
 end = . ; _end = . ;
} 

Natomiast w pliku make.bat umieścimy:

nasm -f coff entry.asm -o entry.o
gcc -c kernel.c -O2 -o kernel.o
ld -Map kernel.map entry.o kernel.o -Tlink.ld -o kernel.bin
merge.exe boot.bin kernel.bin image.img

0x09. Użycie Bochsa.

Bochs (czyt. boks :>) jest darmowym i OpenSourcowym emulatorem komputera PC, podobnie jak vmware (ten już nie jest darmowy). Dzięki niemu nie będziemy musieli nagrywać naszego systemu na dyskietke, restartować komputera itp. Poza tym jak coś się nie uda to prawdziwy komputer się po prostu zrestartuje (chyba, że naprawde coś się nie uda jeśli wiadomo o czym mowa), bochs natomiast wygeneruje śliczny plik log w którym wypisane będa wartości rejestrów i rodzaj błedu (bardzo często jest to triple fault, ale niekiedy inne). Ale do rzeczy, użycie bochsa wiąże się z utworzeniem pliku konfiguracyjnego (bochsrc.txt), bez opisywania po prostu podam jak to wygląda u mnie:

romimage: file=bios/BIOS-bochs-latest, address=0xf0000
megs: 128
vgaromimage: bios/VGABIOS-elpin-2.40

floppya: 1_44={ TUTAJ SCIEZKA DO TWOJEGO OBRAZU Z OSEM }, status=inserted

ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14
ata1: enabled=0, ioaddr1=0x170, ioaddr2=0x370, irq=15
ata2: enabled=0, ioaddr1=0x1e8, ioaddr2=0x3e8, irq=11
ata3: enabled=0, ioaddr1=0x168, ioaddr2=0x368, irq=9
ata0-slave: type=cdrom, path=E:, status=inserted
boot: floppy
floppy_bootsig_check: disabled=0
log: bochsout.txt
panic: action=ask
error: action=report
info: action=report
debug: action=ignore
debugger_log: bochs_debug.txt
parport1: enabled=1, file="parport.out"
vga_update_interval: 300000
keyboard_serial_delay: 250
keyboard_paste_delay: 100000
floppy_command_delay: 500
ips: 1000000
mouse: enabled=0
private_colormap: enabled=0
fullscreen: enabled=0
screenmode: name="sample"
keyboard_mapping: enabled=0, map=
i440fxsupport: enabled=0

Warto zapoznać się z domyślnym plikiem bochsrc.txt w którym wszystko jest ładnie skomentowane i opisane. Tak więc gdy mamy już skonfigurowanego bochsa wystarczy go uruchomić, możemy np dodać do naszego pliku make.bat

cd { SCIEZKA Z BOCHSEM }
bochs.exe -q

Dzięki parametrowi q nasz "komputer" odrazu się właczy bez wyświetlania menu.

0x0A. Co z przerwaniami?

W trybie chronionym nie odrazu będziemy mogli korzystać z przerwań, to dlatego, że od teraz przestaje obowiązywać coś co nazywamy tablicą wektorów przerwań z trybu rzeczywistego. Zastępuje ją IDT (Interrupt Descriptor Table), do tego musimy zaprogramować tzw PIC, który przekieruje przerwania sprzetowe (IRQ) pod inne adresy, ponieważ w rmode pokrywają się one z przerwaniami wyjątków procesora. Nie będe jednak tu tego opisywał bo to temat równie duży jak to co przed chwilą przeczytałeś.

0x0B. Na zakończenie

Jeśli tutaj jest wszystko jasne to gratulacje ponieważ jesteś dalej niż wielu niedoszłych deweloperów OSow, jednak dalej sa to jeszcze początki i jeśli wciąż jesteś zdecydowany to najtrudniejsze przed Tobą (mną również :>).

W załączniku podaje cały kod źródłowy z tego kursu z plikami do kompilacji.

18 komentarzy

SuPeR.
a jak zrobić funkcje scanf?

Jest tu parę niedostatków. Po pierwsze, tłumaczenie "nie wiem do czego służy flaga" jest trochę nie na miejscu. Jak się pisze artykuł to warto wszystko najpierw wiedzieć. Potem może się okazać że coś się dzieje za naszymi plecami, jak też pominięta kwestia linii A20. Po drugie, aktualizacja rejestrów segmentowych musi być przed zmianą zawartości CR0, albo po dalekim skoku, gdyż zgodnie z dokumentacją daleki skok musi odbyć się NATYCHMIAST po włączeniu trybu chronionego (czyli po "mov cr0, eax" od razu "jmp", a segmenty przed albo po, nie pomiędzy. To niby szczegóły, ale zaoszczędzą dużo nerwów np. dlaczego program działa na emulatorze ale nie działa na prawdziwym komputerze, albo odwrotnie.

Napisałem sobie bootloader'a (2/4 sam i z jakiegoś kursu o pisaniu OS'ów (nie z tego)) i kernela. Teraz nie wiem jak to uruchomić pod bosch'em. Uważam, że to świetny artykuł.

Mógłby ktoś udostępnić źródła bootloadera i kernela z wklepanym już kodem GDT i PMODE? Ogółem, kod do rozdziału 0x06.

Przydała by się obsługa plików, a tak to super!

Jezeli naprawde chcecie poczytac o pisaniu OSow to zapraszam na POLSKI portal dla OS Developerow:

www.areoos.com/osdevpl/

MrKaktus

"Brakuje tylko odblokowania lini A20. Poza tym SUPER"

W nastepnych czesciach (o ile bedzie mi sie chcialo konczyc) skorzystamy z gruba, wiec pm nawet ustawiac nie bedziemy musieli ;)

Brakuje tylko odblokowania lini A20. Poza tym SUPER :)

"Jak można sie domyśleć wyświetla tekst na ekranie, lecz z haczykiem - jeśli wywołamy ją dwa razy tekst zostanie nadpisany, dlatego należałoby utworzyć jakąś zmienna z aktualną pozycją, ale to pozostawiam Wam."

Oto i rozwiązanie z bonusem :)
http://althalus.republika.pl/newkernel.rar (plik zaginiony :(

Kiedys nie mialem z tym "problemu", ladnie byly przyjmowane stringi jako wskaznik do uchar, a teraz wymaga podpisanego bajta, wywal unsigned z parametrow dla print. Poza tym to nie blad, tylko warning ;)

gcc -c kernel.c -o2 -o kernel.o {enter}
kernel.c: In function 'k_main':
kernel.c: warning: pointer targets on pasing argument 1 of 'print' differ in signedness

co to znaczy non stop wyskakuje mi ten problem...

a dało by się to samo w Pascalu? znaczy się jąderko :D poprostu narazie jeszcze chcę bardziej poglębić tajemnice Delphi niż uczyć się nowego języka (C) :P

dawaj nastepny :)

hmmmm.... Następny super ART :)

Super, nasunął mi się ekstra pomysł. Dzięki.

Świetne. Brawo :D

Zalezy, w TP - nie (16 bitowy i na DOSie jedzie (int 21h)), Delphi - nie (32 bity ale generuje PE, najwyzej aplikacje dla twojego osa, ale tu i tak nie do konca, o WinAPI, tym bardziej o VCLu zapomnij no i jeszcze obsluz PE), FreePascal - tutaj chyba tak ale nie recze za to, nie widzialem kodu wynikowego, wiec sam nie wiem, ale slyszalem o takich projektach, inne kompilatory - pewnie jakis istnieje, ze tworzy odpowiedni kod ale takiego nie znam. Jesli chcesz mojej rady to olej pascala i pisz w C, nie musisz znac go idealnie jezyka, zeby pisac osa.