0. Intro 1. O co chodzi z tymi trybami? 2. Po co mi pmode? 3. Czy pmode ma jakieś wady? 4. Deskryptory segmentowa - czyli przechodzeń do meritum :-) 5. Tablice deskryptorów i selektory. 6. Jak przebiega adresowanie w PM? 7. Przerwania w trybie chronionym. 8. Instrukcje specyficzne dla pmode i instrukcje uprzywilejowane. 9. Przełączanie procesora w pmode. 10. Dos Protected Mode Interface = DPMI. 11. Outro
0. Intro
Zdecydowałem się napisać ten tutorial mając na uwadze fakt, ze w polskich zasobach sieci jest stosunkowo mało informacji na temat trybu chronionego - przynajmniej takich, które mogłyby być pomocne beginnerom. Również trudno znaleźć cokolwiek na ten temat w powszechnie dostępnej literaturze IT.
W tym tutorialu będę się starał wyjaśniać wszystko od absolutnego początku z pominięciem wielu "dobrodziejstw" trybu chronionego typu wielozadaniowość czy tez stronicowanie. Będzie to spojrzenie na pmode oczami kogoś, komu po prostu brakuje pamięci lub prędkości przy pisaniu własnych programów. Na razie naszym celem będzie napisanie poprawnie działającego klienta DPMI, który umożliwi prace w trybie chronionym.
Zaznaczam, ze zajmiemy się tutaj wyłącznie 32-bitowym trybem chronionym (ma ktoś jeszcze 286? ;-) Ale do rzeczy...
1. O co chodzi z tymi trybami?
Zasadniczo istnieją 4 tryby pracy procesora 386+. Oto one:
tryb rzeczywisty (real mode, dalej zwany RM)
tryb chroniony (protected mode, w skrócie pmode lub PM)
tryb wirtualny V86 - kombinacja 2 powyższych (nie będziemy się nim zajmować; używany np. przez Windows ;)
tryb nierzeczywisty (tzw. unreal mode - procesor pracuje w RM, ale można używać adresowania 32-bitowego; to jest raczej sztuczka programistyczna)
Każdy procesor z rodziny x86 po rozpoczęciu pracy (włączeniu zasilania ;-) działa w trybie rzeczywistym. W takim trybie procek "widzi" tylko pierwszy megabajt RAM'u i dopiero system operacyjny (typu Winda lub Linux) przełącza go w pmode podczas bootowania. W dodatku pamięć w RM jest podzielona na segmenty po 64kB co np. bardzo utrudnia używanie w programach tablic o rozmiarach przekraczających ten limit.
W jaki sposób procesor lokalizuje komórkę pamięci w RM? Zobaczmy następujący przykład:
mov es:[si + 2], ax
Procesor robi tak: pobiera wartość rejestru segmentowego ES, mnoży ja przez 16 (10h) i dodaje przesuniecie (tutaj wartość rejestru indeksowego SI oraz +2). W ten sposób powstaje adres fizyczny komórki pamięci i dopiero wtedy kopiowana jest tam zawartość akumulatora. Jak się później okaże, w PM adresowanie przebiega zupełnie inaczej.
W RM każdy segment ma długość 64kB (nie można jej zmieniać) i zaczyna się na granicy 16 bajtów (tzw. paragraf). Oznacza to, że kolejne segmenty częściowo zachodzą na siebie. Na przykład: bajt 16 segmentu 0 i bajt 0 segmentu 1 to jest to samo.
Tryb rzeczywisty oprócz podanych wyżej wad ma jeszcze jedna: każdy program ma możliwość manipulowania obszarami pamięci nie należącymi do niego. Np. w systemie DOS możliwe jest nadpisanie handlera jakiegoś ważnego przerwania i tym samym, przy najbardziej optymistycznej wersji, zawieszenie komputera.
2. Po co mi pmode?
Przede wszystkim tryb chroniony stwarza możliwość użycia naprawdę dużej ilości pamięci (do 4 GB na 386, w przeciwieństwie do dotychczasowego 1 MB). Nie trzeba już używać powolnych EMS i XMS by dostać się do pamięci rozszerzonej. Ponieważ rozmiar segmentu nie jest ograniczony do 64kB, można w praktyce wyeliminować rejestry segmentowe i operować w tzw. trybie flat - jest to ważne w przypadku kiedy potrzebujemy dużej prędkości działania programu, DS jest zajęty a my nie chcemy prefixowac ES'em, FS'em itd. Inne zastosowanie: operacje na buforach ekranowych przy dużych rozdzielczościach i/lub dużej liczbie kolorów, które to bufory wymagają więcej niż 64kB pamięci (spróbujcie napisać procke rysującą linie w trybie 640x480x256 pod RM - wooooolne...).
3. Czy pmode ma jakieś wady?
Tak. Wszystkie przerwania BIOS lub DOS'a, których używaliśmy, przykładowo, do obsługi plików, staja się bezużyteczne bo nie mogą działać w PM (oczywiście można to obejść poprzez chwilowe wychodzenie z pmode do rmode na czas wykonywania przerwania, ale nie jest to już takie łatwe). Dlatego trzeba będzie pisać własne handlery do klawiatury, zegara etc.
Nie można także beztrosko używać samomodyfikujacego się kodu - czytanie lub zapis w segmencie wykonywalnym spowoduje wygenerowanie wyjątku GP.
4. Deskryptory segmentów - czyli przechodzeń do meritum :-)
Wprowadzenie mechanizmów ochrony pamięci wymagało podjęcia specjalnych środków. Każdy segment (kod, dane, stos) opisywany jest przez tzw. deskryptor. Zadaniem deskryptora jest określenie BAZY SEGMENTU (jego fizycznego adresu w pamięci, np. dla bufora VGA adres bazowy wynosi 0A0000h), LIMITU (rozmiaru segmentu - może wynosić od 1B do 4GB) oraz jego TYPU/FLAG (czy segment zawiera dane, kod, etc.)
Technicznie rzecz biorąc deskryptor jest to 8-bajtowa struktura danych, która rezyduje gdzieś w pamięci operacyjnej i ma następujący format:
Znaczenie kolejnych bajtów jest takie: 0, 1 - młodsze słowo długości segmentu (limitu) 2, 3 - młodsze słowo adresu bazowego segmentu 4 - młodszy bajt starszego słowa adresu bazowego 5 - flagi segmentu 6 - starszy półbajt limitu i tzw. flagi dostępu 7 - starszy bajt starszego słowa adresu bazowego
Jak powiedziałem wcześniej, rozmiar segmentu w pmode może być dowolny (a wiec nie tylko 64kB, ale także każda inna wielkość od 1B do 4GB). Jak wynika z opisu limit w deskryptorze jest to liczba 20-bitowa (8 + 8 + 4) co daje maksymalnie 1048576 czyli 1MB. Hmm... a miało być 4 giga? O tym w jaki sposób zostaje wykorzystana ta liczba napisze za chwile.
Adres bazowy to 32-bitowa liczba wyznaczająca początek segmentu w pamięci. Zazwyczaj pisząc typowy DOS'owy extender bazę możemy wyznaczyć poprzez pomnożenie przez 16 numeru segmentu, w którym znajduje się nasz kod 32-bitowy. Wartość ta będzie dla nas bardzo ważna jeśli będziemy chcieli np. dobrać się do pamięci ekranu. W pmode bowiem, jak się okaże, pod adresem 0A0000h może się niekiedy znajdować coś zupełnie innego niż bufor VGA... O tym później.
Teraz czas na flagi segmentu (bajt 5):
Bit 7 - flaga P (Presence - obecność). Używana przy pamięci wirtualnej. Ustawiajmy ja zawsze na "1". Bity 5 i 6 - Descriptor Privilege Level czyli poziom uprzywilejowania segmentu. Ponieważ mamy tylko 2 bity, istnieją 4 poziomy: 0, 1, 2 i 3 przy czym 0 to najwyższy poziom uprzywilejowania. O co w tym chodzi? Omówię to na przykładzie: system operacyjny przydzielił procesowi obszar pamięci i opisał go deskryptorem, który określa, ze ten obszar (segment) ma, załóżmy, 3 poziom uprzywilejowania (DPL = 3). Ten proces nie może się odwoływać do segmentów, których DPL jest mniejsze od 3. W praktyce oznacza to, ze ten proces nie nadpisze sobie jądra systemu, bo ono najprawdopodobniej działa na poziomie 0. Osobiście sugerowałbym w DOS extenderze ustawić DPL na 0 (na maksa, a co?). Bity 5 i 6 będą wiec równe 0. W przypadku aplikacji będącej klientem hosta DPMI wymagane jest DPL = 3. Bit 4 - Descriptor Type. Jeśli równy "1" to mamy do czynienia z segmentem aplikacji. "0" jest dla segmentów systemowych. Bity 0, 1, 2 i 3 wyznaczają tzw. typ segmentu. Myślę, ze nas interesować będą na razie tylko 0010 - segment danych (ew. stosu) do odczytu i zapisu oraz 1010 - segment kodu do wykonywania i odczytu.
Bajt 6 deskryptora jest chyba najbardziej skomplikowany:
Bit 7 - ziarnistość (Granularity). Bit ten decyduje o sposobie traktowania podanego limitu segmentu. Jeśli bit jest ustawiony na "1" wtedy rozmiarem segmentu jest limit pomnożony przez 4096. A wiec segment o rozmiarze 4GB będzie miał limit równy 0FFFFFh i G = 1. Kiedy G = 0 to segment ten zostanie potraktowany jako 1-megabajtowy. Bit 6 - określa czy segment jest 16 czy 32-bitowy. Jeśli równy "1" to wówczas instrukcje 32-bitowe (typu mov eax, ebx) nie będą poprzedzone przedrostkiem rozmiaru (66h). Natomiast do instrukcji 16-bitowych (np. add cx, ax) zostaną dodane przedrostki. Bit 5 - nieużywany? Bit 4 - określa czy segment jest "dostępny dla systemu". Co to w praktyce znaczy? Nie wiem ;-) Tak jak bit 5, lepiej ustawić na "0". Bity 3, 2, 1, 0 stanowią najstarszy połbajt limitu.
Istnieje 18 różnych typów deskryptorów, ale w praktyce część z nich jest nieużywana (zarezerwowane lub dostępne tylko dla 286). Oprócz deskryptorów segmentów, zajmiemy się tu także deskryptorami przerwań.
5. Tablice deskryptorów i selektory.
Pojawia się pytanie: skąd procesor wie, gdzie w pamięci znajduje się dany deskryptor? Postanowiono grupować deskryptory w tablice tzn. kolejno następujące po sobie deskryptory. Np:
desc_null db 0, 0, 0, 0, 0, 0, 0, 0 desc_code32 db 0FFh, 0FFh, 0h, 0h, 0h, 0FAh, 0CFh, 0h desc_data32 db 0FFh, 0FFh, 0h, 0h, 0h, 0F2h, 0CFh, 0h
Tablica ta zawiera 3 deskryptory: tzw. Null Descriptor, deskryptor segmentu kodu oraz deskryptor segmentu danych. Ten pierwszy pojawia się tylko w tablicach GDT i zawsze zawiera same zera.
Wyróżniamy 3 typy tablic: GDT (Globalna Tablica Deskryptorów), LDT (Lokalna Tablica Deskryptorów; używana przez pojedynczy proces w systemie) oraz IDT (Tablica Deskryptorów Przerwań).
Procesor musi wiedzieć, gdzie takie tablice się znajdują i dlatego dysponuje specjalnymi rejestrami, które zawierają 32-bitowy fizyczny adres danej tablicy oraz jej długość. Są 3 rejestry: GDTR (przechowuje adres i długość GDT) oraz, analogicznie, LDTR i IDTR. Każdy z tych rejestrów jest 48-bitowy i ma następujący format:
4 pierwsze bajty to adres, 2 kolejne to długość tablicy. Ponieważ długość jest liczba 16-bitowa a rozmiar deskryptora liczy 8 bajtów, maksymalnie można używać 8192 deskryptorów w jednej tablicy.
Jednak sama znajomość położenia tablicy w RAM nie wystarczy. Trzeba poinformować procesor, który segment używa którego deskryptora. Np. jaki deskryptor opisuje aktualny segment danych? W pmode rejestry segmentowe NIE zawierają numerów segmentów jak miało to miejsce w RM. Zawierają za to selektory. Oto taki selektor:
Bity 0 i 1 zawierają tzw. RPL czyli wymagany poziom uprzywilejowania. Poziom ten musi być większy lub równy od DPL'a deskryptora, do którego selektor się odnosi. Bit 2 informuje o tym czy selektor wskazuje na deskryptor w GDT (wtedy ustawiony na "1") lub w LDT ("0"). Reszta bitów to NUMER danego deskryptora w tablicy deskryptorów (przy czym pierwszy z nich ma numer 0). Na jego podstawie procesor lokalizuje dany deskryptor. W powyższym przykładzie selektor umieszczony w rejestrze DS będzie wyglądał tak: 0000000000010 0 00 - DS wskazuje na drugi deskryptor w tablicy GDT na poziomie 0.
6. Jak przebiega adresowanie w PM?
W tym miejscu celowo pominę mechanizm stronicowania i role jednostki MMU bo na razie nas to nie interesuje (stronicowanie traktujemy jako wyłączone). Najlepiej omawiać to na przykładzie:
mov [esi + 2], eax
W trybie chronionym wpisanie akumulatora pod wskazany adres odbywa się tak: procesor pobiera selektor z DS (ponieważ dla (E)SI domyślny jest właśnie DS - wiadomo to już z RM). Następnie bierze z tego selektora numer deskryptora i mnoży go przez 8 (rozmiar deskryptora). Po dodaniu do tego adresu tablicy GDT/LDT pochodzącego z rejestru GDTR/LDTR powstaje lokalizacja konkretnego deskryptora. Teraz procek może wykonać szereg czynności związanych z ochrona pamięci - sprawdza czy RPL nie jest większy od DPL, czy segment opisywany w tym deskryptorze istnieje, czy segment ma typ "data" i ma ustawione prawa do zapisu etc. Kiedy cos jest nie tak to wywoływane jest stosowne przerwanie (najczęściej 13h - General Protection Fault; o tym później). W przeciwnym razie można kontynuować. Z deskryptora pobierana jest BAZA segmentu, do której procesor dodaje PRZESUNIECIE (u nas ESI oraz np. +2). W ten sposób powstaje adres FIZYCZNY i można już tam zapisać 4 bajty z EAX. Tak to wszystko wygląda w pewnym uproszczeniu.
Zwrócić należy uwagę, ze podawane przesuniecie to jest LOGICZNY adres w RAM. Po co nam to wiedzieć? Np. dostanie się do pamięci ekranu (fizycznie 0A0000h) jest odrobinę problematyczne. Możemy zrobić sobie specjalny deskryptor, który opisze nam segment o adresie bazowym = 0A0000h i limicie 64kB. No tak, ale mięliśmy pracować w trybie flat zapominając o rejestrach segmentowych! Jeśli ustalimy sobie jeden duży segment o maksymalnym rozmiarze 4GB obejmujący cala teoretyczna przestrzeń adresowa to adres prawdziwego bufora VGA obliczymy tak:
mov esi, 0A0000h sub esi, [segment_base]
Kiedy do tak zmniejszonego 0A0000h procek doda bazę segmentu to wszystko będzie OK. Inaczej trafilibyśmy na obszar pamięci o adresie 0A0000h + segment_base. Nie można przewidzieć, co tam się znajduje.
W pmode istnieje możliwość adresowania nie tylko rejestrami indeksowymi (ESI, EDI) oraz bazowymi (EBP, EBX). Można także używać wszystkich rejestrów ogólnego przeznaczenia (EAX, EBX, ECX i EDX) i dodatkowo stosować skalowanie poprzez mnożenie ich przez 2, 4, 8 itd. Np:
mov eax, [edi + edx*2]
Dygresja: w połączeniu z instrukcja LEA można tego użyć do szybkiego mnożenia (1 cykl na Pentium). Poniższą instrukcja mnoży EAX przez 5:
lea eax, [eax + eax*4]
7. Przerwania w trybie chronionym.
W RM pojawienie się przerwania (wywołanego przez instrukcje INT lub urządzenie zewnętrzne typu klawiatura) powodowało, ze procesor, po odłożeniu na stos rejestru flag oraz CS i IP, skakał pod adres określony w tablicy wektorów przerwań. Tablica ta znajduje się zawsze na początku pamięci pod adresem 0000:0000 i ma długość 1kB (256 przerwań * 4 czyli rozmiar wektora CS:IP). W pmode, jak się można domyślić, ta tablica jest już bezużyteczna.
Rejestr IDTR, podobnie jak omawiany GDTR, zawiera adres fizyczny i długość nowej tablicy. Jest to tzw. Interrupt Descriptor Table czyli Tablica Deskryptorów Przerwań. W przeciwieństwie do RM, tablica ta może znajdować się w dowolnym miejscu w pamięci. Maksymalnie zawiera 256 deskryptory. Gdy w PM ma miejsce przerwanie, procek odszukuje dany deskryptor mnożąc numer przerwania przez 8 i dodając adres tablicy zawarty w IDTR. Format deskryptora przerwania jest nieco inny od formatu segmentowego:
Bajt 0 i 1 (młodsze słowo) oraz 6 i 7 (starsze słowo) zawierają przemieszczenie (offset) handlera przerwania w danym segmencie. Jest to po prostu miejsce gdzie znajduje się kod przerwania. Bajt 2 i 3 to miejsce na selektor danego deskryptora tzn. tego, który opisuje segment zawierający handler. Bajt 4 jest nieużywany i zawsze pusty. Bajt 5 zawiera flagi deskryptora przerwania:
Najmłodsze 4 bity zawsze są równe 1110. Jest to identyfikator deskryptora przerwania. Bity 5 i 6 stanowią DPL czyli poziom uprzywilejowania handlera. Bit 7 czyli Presence - zawsze równy "1".
Zwrócić należy uwagę, ze deskryptor przerwania jest to przypadek tzw. bramki wywołania służącej do wywoływania funkcji znajdujących się w różnych segmentach o różnych poziomach uprzywilejowania.
Przerwanie w trybie chronionym zwyczajowo nazywane jest wyjątkiem (exception). Są 3 typy wyjątków: trap, fault i abort. Wyjątki abort wywoływane są na skutek błędów w tablicach systemowych (GDT, LDT, IDT) lub w przypadku wystąpienia problemów sprzętowych. Nie ma możliwości powrotu do naszego programu, nie jest zwracany także kod błędu. Wyjątki trap to zwykle przerwania generowane przez instrukcje INT lub podczas debuggowania (wywoływanie przerwania po każdej instrukcji). Ostatni typ, fault, to wyjątki spowodowane przewidywalnymi, udokumentowanymi błędami np. słynny General Protection Fault wywalający w Windows "niebieskie ekrany śmierci". Na stosie umieszczany jest kod błędu, a rejestry CS:EIP (odłożone na stos) zawsze wskazują na instrukcje, która spowodowała błąd. Oto tabelka z wyjątkami:
Wywoływane po każdej instrukcji
Używane przy debuggowaniu
Wywoływane po INTO jeśli CF = 1
|
Błędny opcode dla danego CPU
Błąd podczas wykonywania przerwania
Coprocessor Segment Overrun
Flaga P segmentu ustawiona na "0"
Przekroczony limit stosu lub błędny SS
Dostęp do chronionego segmentu
Przypominam, ze handler przerwania w pmode powinien być zakończony instrukcja IRETD (pobranie 32-bitowych EIP oraz EFLAGS oraz selektora CS).
Na razie tyle wiedzy o przerwaniach w PM nam wystarczy.
8. Instrukcje specyficzne dla pmode i instrukcje uprzywilejowane.
Programy działające na niskim poziomie uprzywilejowania (np. 3) nie mogą mieć dostępu do niektórych instrukcji. Dowolny proces w systemie nie powinien zmieniać zawartości tablicy GDT lub wychodzić z trybu chronionego do RM. Użycie takich instrukcji przez zwykły program spowoduje wygenerowanie wyjątku. Typowym przykładem jest pojawiający się w Windows komunikat, który informuje, ze program należy uruchomić w środowisku DOS - to po prostu efekt działania wyjątku gdy procesor jest zmuszony np. wyzerować bit trybu chronionego w rejestrze MSW. Oto niektóre instrukcje, które będą nas interesować:
LGDT/LIDT mem48 Ładuje 48-bitowy rejestr GDTR/IDTR pod wskazany adres.
SGDT/SIDT mem48 Zapisuje rejestr GDTR/IDTR pod wskazanym adresem.
LMSW/SMSW reg16/mem16 Ładuje/zapisuje rejestr stanu procesora. Najmłodszy bit tego rejestru decyduje o trybie pracy procka. Jeśli ustawiony na "1" to jesteśmy w pmode. W przeciwnym razie w real mode.
Pozostale instrukcje: ARPL, CLTS, HLT, LTR, STR, LSL, VERR, VERW.
9. Przełączanie procesora w pmode.
Potrzebujemy teraz kodu, który ustawi wszystkie niezbędne tablice i przełączy tryb pracy procesora z RM na PM. Po wykonaniu tych czynności będzie można operować już w trybie chronionym.
Metoda, która tu przedstawię zadziała wyłącznie w DOS w trybie "raw" (bez zainstalowanych himem.sys lub qemm). Program potrzebuje 3 segmentowa: 16-bitowego segmentu kodu, 32-bitowego segmentu kodu i danych (będą to jednocześnie dane dla 16-bit) oraz stosu. Nasz program startuje w trybie rzeczywistym pod kontrola DOS'a. Rozpoczynamy od kodu 16-bit (dla TASM 4.1):
; udostepnienie instrukcji 386 trybu chronionego
code16 segment para public use16
assume cs:code16, ds:code32
; punkt wejscia naszego programu
; ustawienie segmentu danych (patrz dalej)
W tym miejscu wypadałoby sprawdzić czy jesteśmy na pewno w RM i w czystym DOS (żeby zabezpieczyć się przed sytuacja gdy nasz program zostanie odpalony w V86 np. pod Windows):
; ok, można przelaczac...
W tym celu pobraliśmy zawartość rejestru cr0 (jest to de facto Machine Status Word, które można pobrać przy pomocy LMSW, np: lmsw ax). Jeśli najmłodszy bit jest ustawiony to znaczy, ze jesteśmy już w trybie chronionym i musimy wyjść z błędem.
Dygresja: na początku kodu można oczywiście sprawdzić czy program uruchamiany jest na procesorze 386+ ale tutaj, z uwagi na maksymalne uproszczenie kodu, pominę to.
Jeśli jesteśmy już pewni, ze można bezpiecznie przełączyć tryb, znajdujemy adres bazowy naszego segmentu code32. Zadeklarowany wcześniej code32 wygląda tak:
; to jest segment dla danych i kodu jednocześnie (możliwa
code32 segment para public use32
assume cs:code32, ds:code32
; tutaj tablice deskryptorów i inne dane
; tutaj wlasciwy 32-bitowy kod, do którego
; skoczymy zaraz po przelaczeniu się do pmode
Jak znaleźć bazę segmentu? Pomnożymy numer segmentu code32 jaki przydzieli mu DOS przez 16:
mov small dword ptr [code32_base], eax
; "small" jest dlatego, ze dane są w segmencie 32-bitowym
; a my operujemy w 16-bit. Musimy zatem używać tylko najmlodszych
Tak otrzymana wartość zapamiętaliśmy sobie w zmiennej code32_base (przyda się do obliczania adresów logicznych, np. bufora VGA). Adres ten musi zostać także dodany do deskryptorów. Robimy to tak:
mov di, small offset desc_code32
mov word ptr [di + 2], ax
mov byte ptr [di + 4], bl
mov byte ptr [di + 7], bh
mov di, small offset desc_data32
mov word ptr [di + 2], ax
mov byte ptr [di + 4], bl
mov byte ptr [di + 7], bh
Co to robi? Najpierw przenosimy do BX starsze słowo adresu bazowego. W AX jest młodsze. Nasze deskryptory (dwóch segmentowa - dane i kod 32-bit) są w segmencie code32. Musimy wpisać do nich adres bazowy segmentu, który opisują. Robimy to zgodnie z formatem deskryptora.
Deskryptory segmentu code32 wyglądają następująco:
; tzw. Null Descriptor (MUSI być obecny w tablicy GDT)
desc_null db 0, 0, 0, 0, 0, 0, 0, 0
; kod 32-bit, limit 4GB, DPL = 0
desc_code32 db 0ffh, 0ffh, 0h, 0h, 0h, 10011010b, 11001111b, 0h
; dane 32-bit, limit 4GB, DPL = 0
desc_data32 db 0ffh, 0ffh, 0h, 0h, 0h, 10010010b, 11001111b, 0h
Puste bajty wypełnione zerami to właśnie miejsce na adres bazowy, który właśnie został wyliczony i tam przepisany. Jedyne co nam pozostaje do zrobienia z deskryptorami segmentowymi to przygotowanie i załadowanie rejestru GDTR. Definiujemy sobie zmienna:
Do adresu bazowego segmentu code32 dodajemy przesuniecie tablicy GDT. Zapisujemy także jej rozmiar:
mov eax, small offset desc_null
add eax, small dword ptr [code32_base]
mov small dword ptr [gdt_reg + 0], eax
mov small word ptr [gdt_reg + 4], 3 * 8
Ładujemy GDTR i przy okazji wyłączamy przerwania (w tym najprostszym przykładzie nie będziemy używać przerwań):
lgdt small fword ptr [gdt_reg]
Teraz czas przejść do trybu chronionego. Uzyskamy to poprzez ustawienie pierwszego bitu w rejestrze MSW (czyli cr0):
Znajdujemy się już w trybie chronionym, ale to nie wszystko. Musimy natychmiast załadować CS selektorem deskryptora segmentu kodu, a EIP przesunięciem etykiety start32 (początku kodu 32-bit). Jedyna instrukcja, która to umożliwia to daleki skok JMP lub powrót RETF. Skorzystamy z drugiej metody:
push large offset start32
Ale co to za "8"? Jest to selektor pierwszego (nie zerowego bo ten jest pusty!) deskryptora w GDT - 0000000000001 0 00. Selektor ma RPL = 0 i znajduje się właśnie w GDT. Teraz kontynuujemy działanie od punktu start32 w segmencie code32:
; ładujemy selektory wskazujące na deskryptor 2 w GDT
; inicjujemy stos: obliczamy jego adres logiczny w segmencie code32
; i ustawiamy szczyt stosu
Ten program nie potrafi nic więcej zrobić. Wszystkie przerwania są zablokowane i nie można ich używać. W przeciwnym razie nastąpi zresetowanie procesora (automatyczny restart komputera). Stanie się tak także wtedy gdy nastąpi dowolny błąd wywołujący w normalnych warunkach przerwanie (np. General Protection Fault). Dlaczego? Ponieważ nie ma zdefiniowanej tablicy IDT i tym samym nie ma obsługi wyjątków. Najlepsze co procesor może zrobić w takiej sytuacji to reset.
A oto nasz program w całej okazałości (dodatkowo po ustawieniu stosu wypisuje na ekranie standardowe "Hello World"):
;------------------------------------------------------------
;------------------------------------------------------------
code16 segment para public use16
assume cs:code16, ds:code32
mov small dword ptr [code32_base], eax
mov di, small offset desc_code32
mov word ptr [di + 2], ax
mov byte ptr [di + 4], bl
mov byte ptr [di + 7], bh
mov di, small offset desc_data32
mov word ptr [di + 2], ax
mov byte ptr [di + 4], bl
mov byte ptr [di + 7], bh
mov eax, offset desc_null
add eax, small dword ptr [code32_base]
mov small dword ptr [gdt_reg + 0], eax
mov small word ptr [gdt_reg + 4], 3 * 8
lgdt small fword ptr [gdt_reg]
push large offset start32
;------------------------------------------------------------
;------------------------------------------------------------
code32 segment para public use32
assume cs:code32, ds:code32
desc_null db 0, 0, 0, 0, 0, 0, 0, 0
desc_code32 db 0ffh, 0ffh, 0h, 0h, 0h, 10011010b, 11001111b, 0h
desc_data32 db 0ffh, 0ffh, 0h, 0h, 0h, 10010010b, 11001111b, 0h
txt_hello db 'Hello World!'
TXT_HELLO_LEN equ $ - txt_hello
mov esi, offset txt_hello
sub edi, dword ptr [code32_base]
;------------------------------------------------------------
stack_seg segment para stack 'stack'
10. Dos Protected Mode Interface = DPMI.
Potrafimy już uruchomić program w pmode startując z czystego DOS'a. Istnieje jednak prosty sposób na używanie pmode'a w programach dosowych uruchamianych pod Windows lub w formie tzw. klientów DPMI pod DOS-extenderami. W tych przypadkach sam proces przełączania jest bardzo uproszczony. Nie musimy się także martwic o przerwania - odpada wiec pisanie handlerów, budowanie tablicy IDT i przekierowywanie przerwań. Dodatkowo większość hostow DPMI posiada wbudowane funkcje alokowania pamięci, badania typu procesora etc.
DPMI jest zbiorem norm określających np. numery przerwań i funkcji oferowanych przez DOS-extendery. Standard ten powstał w czasach kiedy piwo było mocniejsze a kobiety ładniejsze. Jednym słowem wtedy gdy pojawił się procesor 386 a rożni koderzy tworzyli własne DOS-extendery (tj. programy przełączające się do pmode i pełniące usługi na rzecz programow-klientow). Te extendery były ze sobą niekompatybilne dlatego postanowiono żeby do poważnych zastosowań używać ściśle określonego zestawu funkcji i jednego przerwania - 31h. Teraz pisząc DOS-extender kompatybilny z DPMI sprawiamy, ze rożni klienci naszego 'hosta' będą działać poprawnie jeśli korzystają z 31h i np. używają funkcji AX = 100h do alokowania bloków pamięci podstawowej. Z kolei pisząc klienta mamy pewność, ze pod danymi numerami znajdują się właściwe funkcje.
Istnieje wiele powszechnie używanych i sprawdzonych extenderów np. cwsdpmi (używał go Quake I). Także w środowisku Windows zainstalowany jest rezydentnie serwer DPMI. Co zrobić żeby uruchomić dosowy program-klient pracujący w trybie chronionym pod Winda? Podobnie jak w rozdziale 9 potrzebować będziemy 3 segmentowa plus jeden dodatkowy dla tak zwanego "prywatnego" użytku serwera:
dpmi_area segment para public use16
Zaczynamy w segmencie code16 od sprawdzenia czy serwer DPMI jest obecny w pamięci. Odpowiada za to funkcja AX = 1687h multipleksowego przerwania naprzemiennego 2fh:
; sprawdzamy czy host DPMI jest zainstalowany w pamięci
Bezpośrednio po tym sprawdzamy najmłodszy bit rejestru BL. Jeśli jest ustawiony - host obsługuje 32-bitowy pmode. W przeciwnym razie mamy do czynienia z 16-bitowym pmode ala 286.
; 32-bit pmode obslugiwany
Teraz możemy zapamiętać adres procedury DPMI, pod która należy skoczyć celem "przełączenia" trybu (w cudzysłowie gdyż np. pod Windows cały czas jesteśmy w V86 i nie trzeba nic przełączać). Skąd wziąć ten adres? Zwróciła go w rejestrach ES:DI funkcja z początku programu. Zapamiętujemy do zmiennej dpmi_switch:
mov small word ptr dpmi_switch[0], di
mov small word ptr dpmi_switch[2], es
Następnie, tak jak w przykładzie z rozdziału 9, obliczamy adres fizyczny segmentu code32 i uzupełniamy deskryptory. UWAGA! Klienci hosta DPMI musza działać na poziomie uprzywilejowania DPL = 3, dlatego nasze deskryptory wymagają malej zmiany:
; kod 32-bit, limit 4GB, DPL = 3
desc_code32 db 0ffh, 0ffh, 0h, 0h, 0h, 11111010b, 11001111b, 0h
; dane 32-bit, limit 4GB, DPL = 3
desc_data32 db 0ffh, 0ffh, 0h, 0h, 0h, 11110010b, 11001111b, 0h
Jak łatwo zauważyć nie ma tutaj null-deskryptora gdyż używamy tablicy LDT (GDT posiada host DPMI, a jego klienci tylko tablice lokalne czyli LDT).
Pozostaje nam przypisanie segmentu "prywatnego" do ES i skok do procedury przełączającej:
; ax = 1 tzn. chcemy 32-bitowego pmode
call small dword ptr dpmi_switch
; błąd - nie można przełączyć trybu
Jesteśmy już w pmode. Przed wykonaniem skoku do segmentu code32 musimy jeszcze poinformować hosta jakich deskryptorów używamy i otrzymać od niego odpowiednie selektory (nie możemy ustalać ich samemu jak w czystym DOS). Zrealizujemy to za pomocą funkcji przerwania 31h (to przerwanie jest dostępne wyłącznie w trybie chronionym):
; jesteśmy już w trybie chronionym, ale 16-bit
; alokujemy deskryptor segmentu kodu
mov small word ptr [sel_code32], ax
; ustawiamy deskryptor segmentu kodu
mov edi, offset desc_code32
; alokujemy deskryptor segmentu danych
mov small word ptr [sel_data32], ax
; ustawiamy deskryptor segmentu danych
mov edi, offset desc_data32
Selektory, jako ze nie pochodzą od nas, musimy sobie zapamiętać. Do tego posłużyły nam zmienne:
Należy teraz przeskoczyć do segmentu 32-bitowego i tam kontynuować wykonywanie programu. Skaczemy podobnie jak poprzednio:
push small dword ptr sel_code32
push large offset start32
Tak wygląda pełny kod opisanego właśnie programu inicjującego środowisko pracy klienta DPMI:
;------------------------------------------------------------
;------------------------------------------------------------
code16 segment para public use16
assume cs:code16, ds:code32
; sprawdzamy czy host DPMI jest zainstalowany w pamięci
mov dx, small offset dpmi_err1
; sprawdzamy czy jest obslugiwany 32-bitowy pmode
mov dx, small offset dpmi_err2
; zapamiętujemy adres procedury, która przełączy nas
mov small word ptr dpmi_switch[0], di
mov small word ptr dpmi_switch[2], es
; znajdujemy adres fizyczny segmentu code32
mov small code32_base, eax
; uzupełniamy deskryptory o obliczony adres
mov di, small offset desc_code32
mov word ptr [di + 2], ax
mov byte ptr [di + 4], bl
mov byte ptr [di + 7], bh
mov di, small offset desc_data32
mov word ptr [di + 2], ax
mov byte ptr [di + 4], bl
mov byte ptr [di + 7], bh
; przypisujemy ES do "prywatnego" segmentu hosta DPMI
; "przelaczamy" się w pmode (jeśli odpalamy proga pod Windows
; to nie trzeba naprawde się przełączać - cały czas bylismy w V86).
call small dword ptr dpmi_switch
mov dx, small offset dpmi_err3
; jesteśmy już w trybie chronionym, ale 16-bit
; alokujemy deskryptor segmentu kodu (poprzez funkcje DPMI)
; ustawiamy deskryptor segmentu kodu (dopisujemy go do tablicy
mov edi, offset desc_code32
; alokujemy deskryptor segmentu danych
; ustawiamy deskryptor segmentu danych
mov edi, offset desc_data32
; skaczemy do segmentu code32 - ładujemy CS selektorem segmentu
; kodu, a EIP przemieszczeniem etykiety start32
push small dword ptr sel_code32
push large offset start32
dpmi_area segment para public use16
;------------------------------------------------------------
;------------------------------------------------------------
code32 segment para public use32
assume cs:code32, ds:code32
dpmi_err1 db 'DPMI host not installed!', 10, 13, '$'
dpmi_err2 db '32-bit PMODE unsupported!', 10, 13, '$'
dpmi_err3 db 'Unable to switch mode!', 10, 13, '$'
; fa = 11111010b code read / exec
; f2 = 11110010b data read / write
; 32-bitowy segment kodu, DPL = 3, limit = 4GB
desc_code32 db 0ffh, 0ffh, 0h, 0h, 0h, 0fah, 0cfh, 0h
; 32-bitowy segment danych, DPL = 3, limit = 4GB
desc_data32 db 0ffh, 0ffh, 0h, 0h, 0h, 0f2h, 0cfh, 0h
; ustawiamy selektory na deskryptor segmentu data32
; skok do glownej procedury programu
; powracamy do rmode i wychodzimy do DOS'a
;------------------------------------------------------------
stack_seg segment para stack 'stack'
Jakkolwiek przejście do PM nawet w DPMI nie jest proste, to wyjście do RM sprowadza się do wywołania dobrze znanej funkcji DOSa - 4ch. Jest to możliwe ponieważ serwer DPMI przejął przerwanie 21h. Supportowana jest jednak tylko ta (i jedyna!) funkcja, która zamyka program i usuwa go z pamięci.
Spośród wielu funkcji przerwania 31h oferowanych przez hosta DPMI dość ważna jest AX = 501h alokująca blok pamięci. Rozmiar bloku w bajtach podajemy w rejestrach BX:CX. Oto napisana przeze mnie procedura, która upraszcza alokacje pamięci:
;------------------------------------------------------------
; in: eax = size of block in bytes
; out: eax = handle (0 if error occured)
;------------------------------------------------------------
W EAX otrzymujemy uchwyt bloku, który należy zapamiętać do późniejszego zwolnienia pamięci. W EBX znajduje się logiczny adres bloku. Tak można zdealokować pamięć:
;------------------------------------------------------------
;------------------------------------------------------------
Pod DPMI możemy także wywoływać przerwania DOS i BIOS. Wprawdzie większość z nich posiada swoje odpowiedniki przepisane pod pmode, ale bezpieczniejsze wydaje mi się skorzystanie z funkcji AX = 300h. W BX podajemy numer przerwania, które chcemy wywołać, w EDI adres struktury danych "symulującej" rejestry procesora (dzięki niej będziemy przekazywać do przerwania argumenty). Struktura taka ma następującą postać:
Oto przykład pokazujący włączenie trybu graficznego 13h pod DPMI:
mov ecx, (size dpmi_regs) / 4
mov dword ptr [edi._eax], 000013h
11. Outro
Mam nadzieje, ze przybliżyłem Wam nieco zagadnienie trybu chronionego. Oczywiście wielu rzeczy nie byłem w stanie tutaj wyjaśnić. Oto zbiór linków, pod którymi możesz znaleźć więcej informacji na temat pmode:
http://www.ps.nq.pl/ - serwis Programmers' Sun. W dziale "grafika" znajdują się efekty graficzne i intra mojego autorstwa. Część z nich napisana w PM z użyciem omawianego w tym tutorialu kernela DPMI.
http://www.execpc.com/~geezer/os/index.htm oraz http://www.nondot.org/sabre/os/articles - sporo informacji na temat trybu chronionego i programowania OS'ów.
http://www.x86.org - znajduje się tam, jeden z najlepszych jakie czytałem, tutorial o podstawach pmode.
http://www.delorie.com/djgpp/doc/dpmi/ - dokumentacja DPMI, opis wszystkich funkcji.
Na zakończenie pragnę podziękować członkom grupy NAAG, do której de facto należę, za konstruktywna krytykę mojego tutoriala. Bez ich sugestii nigdy bym go nie ukończył.
|