2006.07_Jądro systemu operacyjnego _[Inzynieria Oprogramowania].pdf

(491 KB) Pobierz
441663947 UNPDF
Inżynieria
oprogramowania
Grzegorz Pełechaty
Jądro systemu operacyjnego
mów operacyjnych stanowi z całą pewno-
ścią jedną z bardziej rozwojowych dziedzin
informatyki. Na rynku pojawiają się nowe typy pro-
cesorów, które oferują coraz to bardziej zaawanso-
wane możliwości. Obecnie największą popularno-
ścią wśród zastosowań domowych cieszą się pro-
cesory z rodziny x86. Ich rozwój jest ściśle powią-
zany z rosnącymi wymaganiami użytkowników.
Pierwsze procesory 80186 działały jedynie w try-
bie rzeczywistym (ang. real mode/real address mo-
de ), który ograniczał się do możliwości adresowa-
nia megabajta pamięci operacyjnej (20 bitowa szy-
na adresowa). Począwszy od procesora 80286 te
restrykcyjne ograniczenia powoli zmniejszały się,
i tak w procesorze 80286 po raz pierwszy wprowa-
dzono tryb chroniony (ang. protected mode ) oraz
umożliwiono adresowanie 16 megabajtów pamięci
(24 bitowa szyna adresowa, ale procesor pozostał
nadal 16 bitowy). Jednak prawdziwe zmiany wniósł
procesor 386DX, który w pewnym sensie zrewo-
lucjonizował rynek komputerowy, a wykorzystane
w nim rozwiązania są powszechnie stosowane do
dziś. Główną zaletą tego procesora było wprowa-
dzenie możliwości 32-bitowego, chronionego trybu
pracy. Umożliwiało to adresowanie do 4GB pamię-
ci. 32-bitowy tryb chroniony wniósł szereg innych
udogodnień. Stary sposób zarządzania pamięcią
poprzez segmentację został wyparty przez stron-
nicowanie (ang. paging ), które jest bardziej dosto-
sowane do wymagań programisty oraz nie zawie-
ra tylu ograniczeń co segmentacja (chodzi głównie
o limit wpisów w lokalnej tablicy deskryptorów).
Jednym z ważniejszych udogodnień, jakie wpro-
wadził tryb chroniony jest możliwość tworzenia wie-
lu procesów, z których każdy wykonuje pewien pro-
gram. W architekturze x86 do tych celów stworzono
specjalne segmenty w globalnej tablicy deskrypto-
rów, które określają stan każdego procesu (ang. Task
State Segment ). Wszystkie te rozwiązania są jednak
bezużyteczne bez odpowiedniego programu, któ-
ry mógłby je rozsądnie wykorzystać, przez co praca
z komputerem stała by się prostsza i mniej zawod-
na. W tym miejscu pojawia się miejsce na system
operacyjny, który jest programem mającym za za-
danie zarządzanie sprzętem i udostępnianie w pro-
stej formie zestawu funkcji, dzięki którym przeciętny
człowiek odnajdzie się binarnym świecie.
Zarządzanie pamięcią
w trybie rzeczywistym
Tak jak już wspomniałem, w trybie rzeczywistym mo-
żemy zaadresować jedynie 1MB pamięci, a całe za-
rządzanie tym obszarem sprowadza się do odgórne-
go podzielenia go na segmenty. Do dyspozycji pro-
gramisty oddano 64K segmentów, każdy segment
zaczyna się co 16-ty bajt. Jako że limit segmentu wy-
nosi 64KB, muszą one nachodzić na siebie. Takie
rozwiązanie umożliwia adresowanie każdej komórki
pamięci na wiele sposobów. W trybie rzeczywistym
adresowanie odbywa się zawsze poprzez podanie
dwóch 16bitowych wartości: numeru segmentu oraz
przesunięcia w nim. Aby obliczyć adres liniowy nale-
ży użyć wzoru: segment*16+przesunięcie.
Przerwania w trybie rzeczywistym
W trybie rzeczywistym mamy do dyspozycji 256 prze-
rwań. Wliczamy w to przerwania programowe oraz
sprzętowe. Przerwania programowe, jak sama na-
zwa wskazuje dają nam możliwość zaprogramowania
się, czyli ustalenia gdzie procesor ma przeskoczyć po
wywołaniu instrukcji INT. Przerwania sprzętowe – IRQ
różnią się tylko tym, że oprócz możliwości wywołania
ich bezpośrednio z kodu programu, mogą być również
wywołane przez izyczne urządzenie. Każda linia IRQ
jest przypisana do innego wektora przerwań w IVT
(ang. Interrupt Vectors Table ).
IVT jest tablicą, która zawiera 256 wpisów typu
segment:offset. Każdy taki wpis jest nazywany wek-
torem przerwania. Kiedy procesor otrzymuje polece-
nie wykonania przerwania, musi znać nowy CS i IP
punktu wejścia programu obsługi.
Wartości te pobiera z IVT wykonując następują-
ce obliczenia:
Autor jest od 7 lat programistą języka C. Interesuje się
zagadnieniami systemów operacyjnych, elektroniką i sie-
ciami neuronowymi. Obecnie pracuje nad projektem dar-
mowego systemu sieciowego, opartego o jądro monoli-
tyczne, oraz w pełni zgodnego ze standardami POSIX
( http://www.netcorelabs.org ). System jest rozpowszech-
niany na warunkach licencji General Public License v2.
Kontakt z autorem: grzegorz.pelechaty@areoos.com
Segment=IVT[int*4]
Offset=IVT[int*4+2]
Tak więc jeden wektor zajmuje w IVT dokładnie 4baj-
ty. Przed przejściem do programu obsługi przerwa-
nia, procesor odkłada na stos następujące rejestry:
SS - segment stosu,
SP – obecne przesunięcie w segmencie stosu,
48
www.sdjournal.org Software Developer’s Journal 7/2006
P rojektowanie oraz programowanie syste-
441663947.018.png
Programowanie systemów operacyjnych
Mapa pierwszego megabajtu pamięci
Listing 1. Struktura deskryptora segmentu w Globalnej
Tablicy Deskryptorów
00000000 – 000003FF Tablica wektorów przerwań
00000400 – 000004FF Obszar danych biosu
00000500 – 0009FBFF Pamięć konwencjonalna (640KB)
00007C00 – 00007DFF Program rozruchowy
0009FC00 – 0009FFFF Rozszerzony obszar danych biosu
(EBDA)
000A0000 – 000BFFFF Pamięć VGA (128KB)
000A0000 – 000AFFFF Bufor ramki VGA(64KB)
000B0000 – 000B7FFF Pamięć dla kart monochromatycznych
(32KB)
000B8000 – 000BFFFF Pamięć dla kart kolorowych (32KB)
000C0000 – 000C7FFF BIOS karty graicznej (32KB – ROM)
000F0000 – 000FFFFF BIOS płyty głównej (64KB – ROM)
struct gdt_seg_desc {
unsigned short len15_0 ;
unsigned short base15_0 ;
unsgined char base23_16 ;
unsigned char lags1 ;
unsigned char lags2 ;
unsigned char base31_24 ;
} ;
Zarządzanie pamięcią
w trybie chronionym
W trybie chronionym istnieją dwa mechanizmy zarządzania
pamięcią. Poprzez segmentację oraz stronicowanie. Naj-
pierw postaram się przybliżyć pojęcie segmentacji, ponie-
waż wygląda ona trochę inaczej, niż miało to miejsce w try-
bie rzeczywistym.
Znana z trybu rzeczywistego zamiana zawartości reje-
strów segmentowych i przesunięcia na adres izyczny tra-
ci sens w trybie chronionym. Tutaj segmenty są od sie-
bie odseparowane i chociaż nadal są dostępne programo-
wo, interpretacja ich zawartości jest zupełnie inna. Rejestr
segmentowy przechowuje teraz selektor segmentu, a nie
wprost jego adres. 13 najstarszych bitów tego rejestru sta-
nowi adres 8bajtowej struktury opisującej dany segment
(ang. Segment Descriptor ). Z pozostałych trzech bitów dwa
poświęcone zostały na implementację czteropoziomowe-
go systemu praw dostępu do segmentu, a jeden określa
czy wspomniany powyżej adres odnosi się do tzw. tablicy
lokalnej czy globalnej . Rekordami w tych tablicach są wła-
śnie deskryptory segmentów. Każdy z nich zawiera jedno-
znaczną informację o lokalizacji segmentu w pamięci i jego
rozmiarach. W ten sposób zdeiniowany jest spójny obszar
o adresie początkowym wyznaczonym przez liczbę 32-bi-
tową. Na liczbę określającą rozmiar takiego bloku przezna-
czone zostało pole 20-bitowe. Istnieją dwie możliwości in-
FLAGS – lagi procesora,
CS - segment kodu,
IP – obecne przesunięcie w segmencie kodu (licznik in-
strukcji).
Rejestry są odkładane po to, aby po powrocie z przerwa-
nia, program mógł dalej kontynuować swoje działanie. Stos
wraca do poprzedniej wartości, ponieważ dane odłożone
na nim przez program obsługi przerwania są teraz bezu-
żyteczne.
Tryb chroniony i pierścienie ochrony
Pierścienie ochrony (ang. Protection rings ) są to pozio-
my uprzywilejowania, jakie zastosowano w procesorach IA-
286p+. System uprawnień jest dosyć rozbudowany i opiera
się o czteropoziomowy układ zabezpieczeń, w którym pier-
ścień zerowy jest najbardziej uprzywilejowany, a trzeci posia-
da znaczne ograniczenia. Uprawnienia te obowiązują w pra-
wie wszystkich elementach trybu chronionego. Jedynym wy-
jątkiem jest stronicowanie, które będzie dokładniej omówione
w kolejnej części cyklu.
Spis wektorów linii IRQ w trybie
rzeczywistym
Listing 2. Funkcja tworząca nowy segment w Globalnej
Tablicy Deskryptorów
• Linia IRQ Wektor Urządzenie generujące syngnał IRQ
0 08h Zegar systemowy
1 09h Klawiatura
2 0Ah Wyjście kaskadowe do układu Slave
3 0Bh Port COM2
4 0Ch Port COM1
5 0Dh Port LPT2
6 0Eh Kontroler napędu dysków elastycznych
7 0Fh Port LPT1
8 70h Zegar czasu rzeczywistego (RTC)
9 71h Wywołuje przerwanie IRQ2
10 72h Zarezerwowane
11 73h Zarezerwowane
12 74h Zarezerwowane
13 75h Koprocesor arytmetyczny
14 76h Kontroler dysku twardego
15 77h Zarezerwowane
struct gdt_seg_desc * gdt_table =
( struct gdt_seg_desc ) GDT_ADDRESS ;
void createSegment (
int pos , unsigned long base , unsigned long len ,
unigned char lags1 , unsigned char lags2 ) {
gdt_table [ pos ] . len15_0 = ( unsigned short )(
len & 0xFFFF );
gdt_table [ pos ] . base15_0 = ( unssigned short )(
base & 0xFFFF );
gdt_table [ pos ] . base23_16 = ( unsigned char )(
( base >> 16 ) & 0xFF );
gdt_table [ pos ] . lags1 = lags1 ;
gdt_table [ pos ] . lags2 = lags2 | (( len >> 16 ) & 0xf );
gdt_table [ pos ] . base31_24 = ( unsigned char )(
( base & 0xF000 ) >> 24 );
}
Software Developer’s Journal 7/2006
www.sdjournal.org
49
441663947.019.png 441663947.020.png
Inżynieria
oprogramowania
Listing 3. Deinicje poszczególnych bitów we lagach
deskryptora segmentu
bitowy, budowany jest ze złożenia zawartości 16bitowe-
go rejestru segmentowego i 32bitowego rejestru przesu-
nięcia. W przypadku ziarnistości 4KB maksymalny roz-
miar segmentu wynosi 4GB. Liczba możliwych segmen-
tów to 2^14(2^13 deskryptorów lokalnych i tyle samo glo-
balnych), co daje w sumie astronomiczną objętość 64TB
(2^14*2^32). Właściwie już jeden taki segment stanowi
wielkość optymalną – 4GB przestrzeni adresowej zaspo-
kaja przy obecnym rozwoju techniki PC dość wygórowa-
ne wymagania. Rozwiązanie takie, określane jako “płaski
model pamięci” (ang. lat memory model ), stosowane jest
w systemie Windows NT.
Opis deinicji poszczeg ó lnych lag segmentu :
// FLAGS1 (P + DPL + SYS/APP + TYPE)
#deine GDT_PRESENT 0x80
#deine GDT_DPL3 0x60
#deine GDT_DPL1 0x20
#deine GDT_DPL2 0x40
#deine GDT_DPL0 0x00
// GDT_SYS będzie poruszone podczas omawiania
// wielozadaniowości. Obecnie interesuje nas
// tylko GDT_APP, które przeznaczone jest dla segmentu
// danych lub kodu.
#deine GDT_SYS 0x00
#deine GDT_APP 0x10
Zarządzanie
Globalną Tablicą Deskryptorów
Listing 2 zawiera funkcję, która tworzy nowy segment w
GDT. Struktura opisująca pojedynczy deskryptor segmen-
tu znajduje się na Listingu 1. Ustalmy jeszcze raz jak obli-
czyć selektor danego segmentu. Na selektor składa się ad-
res deskryptora względem początku Globalnej Tablicy De-
skryptorów oraz poziom uprzywilejowania i informacja o
tym czy segment znajduje się w tablicy lokalnej , czy global-
nej .
// Dodatkowe lagi dla segmentów innych niż data lub
// code (GDT_SYS)
#deine GDT_RESERVED 0x0
# deine GDT_TSS16 0x1 // 0001 16 bitowy TSS (dostępny)
# deine GDT_LDT 0x2 // 0010 LDT
# deine GDT_TSS16_BUSY 0x3 // 0011 16 bitowy TSS (zajęty)
# deine GDT_CALL16 0x4 // 0100 16 bitowa bramka wywołań
# deine GDT_TASK 0x5 // 0101 Bramka zadania
# deine GDT_INT16 0x6 // 0110 16 bitowa bramka
// przerwania
# deine GDT_TRAP16 0x7 // 0111 16 bitowa bramka pułapki
# deine GDT_TSS32 0x9 // 1001 32 bitowy TSS (dostępny)
# deine GDT_TSS32_BUSY 0xB // 1011 32 bitowy TSS (zajęty)
# deine GDT_CALL32 0xC // 1100 32 bitowa bramka wywołań
# deine GDT_INT32 0xE // 1110 32 bitowa bramka
// przerwania
# deine GDT_TASK_GATE 0xF // 1111 32 bitowa bramka pułapki
// Dla GDT_APP
#deine GDT_DATA 0x00
#deine GDT_WRITE 0x02
#deine GDT_EXP_DOWN 0x04
#deine GDT_CODE 0x08
#deine GDT_READ 0x02
#deine GDT_CONF 0x04
selector=numer_segmentu*sizeof(struct gdt_desc)+DPL+ (
4 –- jeżeli deskryptor jest w LDT)
Sama tablica GDT jest opisana specjalnym, 48bitowym de-
skryptorem. Struktura opisująca ten deskryptor wygląda na-
stępująco:
struct gdt_desc {
unsigned short gdt_size;
unsigned long gdt_address;
} __atribute__((packed));
Pierwsze 16 bajtów powinno zawierać rozmiar tablicy GDT mi-
nus 1bajt, czyli 8192*8-1.
Tablicę ładujemy poleceniem lgdt , które przyjmuje izycz-
ny adres deskryptora w pamięci (w postaci bezpośredniego
adresu, bądź też rejestru). Musimy pamiętać, że pierwszy de-
skryptor jest zawsze pusty. Nie ma możliwości, aby został on
wykorzystany w jakikolwiek sposób przez system.
// FLAGS2 (G + D/B + 0 + AVL)
// Ziarnistość danego segmentu
#deine GDT_GRANULARITY 0x80
// Rodzaj segmentu – 32 lub 16 bitowy
#deine GDT_USE32 0x40
#deine GDT_USE16 0x00
// Segment do dowolnego użytku przez system
# deine GDT_AVAIL 0x00
Jądro systemu
Teraz spróbujemy zebrać te wszystkie informacje w spójną
całość i napiszemy proste jądro systemu operacyjnego. Nie
będzie to oczywiście jądro, które byłoby w stanie zrobić co-
kolwiek, ale po uruchomieniu zobaczymy napis hello world i to
powinno nam na razie wystarczyć.
Listing 4. Nagłówek multiboot dla wykonywalnych plików
ELF
terpretowania liczby w tym polu. W trybie 1:1 (ziarnistość
1B) rozmiar maksymalny wynosi po prostu 2^20 = 1MB.
Gdyby jednak przyjąć jednostkę 4KB (ziarnistość 4KB), roz-
miar segmentu może sięgać do 2^20*2^12 = 2^32 = 4GB.
Informacja o tym, która z konwencji aktualnie obowiązuje,
zawarta jest w deskryptorze.
Adres logiczny, do którego odwołuje się procesor 32-
struct multiboot_header {
unsigned long magic ;
unsigned long lags ;
unsigned long checksum ;
} ;
50
www.sdjournal.org Software Developer’s Journal 7/2006
441663947.021.png 441663947.001.png 441663947.002.png 441663947.003.png 441663947.004.png 441663947.005.png
 
Programowanie systemów operacyjnych
Listing 5. Kod inicjalizacyjny jądra
.text
.globl _start
_start :
jmp multiboot_entry
.align 4
multiboot_header :
. long 0x1BADB002
. long 0x00000003
. long - ( 0x1BADB002 + 0x00000003 )
multiboot_entry :
movl $ ( stack +100 ) ,% esp
call setup_gdt
call __main
mbi :
. long 0x0
setup_gdt :
movl $ gdt_table , % esi
movl $0xA000, % edi
movl $8, % ecx
rep
movsl
movl $0xA000+8*8, % edi
movl $0x2000-8, % ecx
ill_gdt :
movl $0, ( % edi )
movl $0, 4 ( % edi )
addl $8, % edi
dec % ecx
jne ill_gdt
1:
lgdt gdt
ljmp $ ( 0x10 ) , $ go
go :
movl $ ( 0x18 ) , % eax
movl % eax , % ds
movl % eax , % es
movl % eax , % fs
movl % eax , % gs
movl % eax , % ss
ret
.data
gdt : . word 0x2000*8-1
. long 0xA000
gdt_table :
. quad 0x0000000000000000 # pusty deskryptor
. quad 0x0000000000000000 # nie u ż ywamy
. quad 0x00cf9a000000ffff # 0x10 kernel 4 GB code at
0x00000000
. quad 0x00cf92000000ffff # 0x18 kernel 4 GB data at
0x00000000
.comm stack , 0x500
Zestaw Narzędzi
Do rozpoczęcia prac nad pisaniem własnego systemu ope-
racyjnego niezbędny będzie nam pewien zestaw narzędzi,
który w znacznej mierze ułatwi nam to zadanie:
języka wysokiego poziomu, musimy również posiadać kompi-
lator asemblera. Szereg programów z pakietu binutils pomo-
że nam w skonsolidowaniu całego obrazu. Użycie emulatora
PC jest wysoce wskazane, ponieważ dzięki niemu nie będzie-
my zmuszeni za każdym razem restartować komputera w ce-
lu sprawdzenia poprawności naszego kodu. Ostatnim progra-
mem jaki musimy posiadać jest program rozruchowy zgodny
ze standardem multiboot. Przykładem takiego programu jest
GRUB, który staje się coraz bardziej powszechny. Oczywiście
możemy napisać własny program rozruchowy, jednak mija się
to z celem. GRUB zagwarantuje nam zgodność z większością
sprzętu oraz przejdzie za nas w tryb chroniony tym samym
oddając w nasze ręce w pełni 32-bitowe środowisko pracy.
Wszystkie wymienione powyżej narzędzia są rozpowszech-
• edytor tekstu
• GCC & GAS
• GNU binutils (ld, make)
• opcjonalnie emulator (qemu/vmware)
• program rozruchowy zgodny ze standardem multiboot
Edytor tekstu będzie nam oczywiście potrzebny do pisania ko-
du. Kompilatory gcc i gas posłużą do jego skompilowania. Po-
nieważ nie jest możliwe napisanie jądra jedynie przy użyciu
R E K L A M A
441663947.006.png 441663947.007.png 441663947.008.png 441663947.009.png
 
Inżynieria
oprogramowania
Listing 6. Przykładowe jądro systemu, wypisujące napis
“hello world” w lewym górnym rogu ekranu
ustawiony na 4GB, a co za tym idzie ziarnistości segmentu mu-
si wynosić 4KB. Segment ten ma uprawnienia pierścienia 0. Ko-
lejny segment jest przeznaczony na dane. Ma on taki sam adres
bazowy i limit jak segment kodu, jednak różni się typem. Po wy-
pełnieniu GDT, wywołujemy funkcję _ main() , która będzie po-
czątkiem naszego właściwego jądra.
static char * video =( char *) 0xB8000 ;
void clrscr ( void ) {
int i ;
for ( i = 0 ; i < 80 * 50 ; i += 2 ) {
video [ i ]= 32 ;
video [ i + 1 ]= 0x7 ;
}
}
void puts ( char * msg ) {
char * ptr = video ;
while (* msg ) {
* ptr ++=* msg ++;
* ptr ++= 0x7 ;
}
}
void __main ( void ) {
clrscr ();
puts ( "hello world!" );
for (;;);
}
Kernel
Na razie nasze jądro nie będzie zbytnio rozbudowane. Napi-
szemy prostą funkcję czyszczącą ekran w tekstowym trybie
VGA oraz funkcję wypisującą ciąg znaków w lewym górnym
rogu ekranu
Jak widać na Listingu 6, po wypisaniu komunikatu koń-
czymy funkcję nieskończoną pętlą. Jest to jedyne wyjście po-
nieważ nie mamy systemu do którego moglibyśmy powrócić.
Gdybyśmy jednak kontynuowali działanie, licznik instrukcji (re-
jestr EIP) wskazywałby na pamięć, nie zawierającą instruk-
cji procesora, co spowodowałoby wywołanie 6 wyjątku. Pro-
cesor próbując wywołać przerwanie nr 6 natraiłby na kolejny
problem, ponieważ nie mamy załadowanej tablicy IDT (ang.
Interrupt Descriptors Table ). Wtedy wystąpiłby potrójny błąd
(ang. Triple fault ), który wiąże się z natychmiastowym restar-
tem procesora.
niane na warunkach General Public License , więc są całkowi-
cie darmowe.
Kompilacja
Przy kompilacji tak dużych projektów, jakimi są systemy ope-
racyjne bardzo dobrym rozwiązaniem jest zastosowanie pli-
ków mak e. N a Listingu 7 przedstawiono sposób użycia tych
plików, w oparciu o źródłowe pliki, które stworzyliśmy wcze-
śniej. Mowa o kodzie inicjacyjnym, który powinniśmy zapi-
sać w pliku init.S oraz źródle jądra, które powinno nosić na-
zwę main.c
Listing 7 zapisujemy pod nazwą makeile w katalogu ze
źródłami.
Kod inicjacyjny jądra
Aby GRUB mógł załadować jądro, musi ono posiadać specjalny
nagłówek informacyjny. Adres tego nagłówek musi być wyrów-
nany do czterech bajtów. Struktura opisująca nagłówek multi-
boot dla wykonywalnych plików ELF znajduje się na Listingu 4.
Standardowe wartości jakie powinny być użyte w naszym
wypadku to:
Listing 7. Plik makeile dla jądra
magic=0x1BADB002
lags=0x00000003
checksum=-(0x1BADB002 + 0x00000003)
CC=gcc
LD=ld
OBJS=init.o main.o
Na Listingu 5 wypełniamy GDT czterema deskryptorami. De-
skryptor numer 2 wskazuje na segment kodu, którego limit jest
CFLAGS = -fno-builtin -nostdlib -nostdinc -Wno-main -O2
all: $(OBJS)
$(LD) -Tkernel.lds -S -X -o kernel --start-group $(OBJS)
--end-group
.c.o:
$(CC) $(CFLAGS) -c $ < -o $@
.S.o:
$(CC) $(CFLAGS) -traditional -c $ < -o $@
Rysunek 1. Jądro systemu uruchomione pod emulatorem
Vmware
CLEAN_FILES = $(OBJS)
clean:
rm -rf $(CLEAN_FILES)
dep:
ind . -name '*.c' -o -name '*.S' |xargs gcc -M $(CFLAGS)
> .depend
ifeq (.depend,$(wildcard .depend))
include .depend
endif
52
www.sdjournal.org Software Developer’s Journal 7/2006
441663947.010.png 441663947.011.png 441663947.012.png 441663947.013.png 441663947.014.png 441663947.015.png 441663947.016.png 441663947.017.png
 
Zgłoś jeśli naruszono regulamin