Jedną z najciekawszych i najczęściej używanych koncepcji w architekturze x86 jest tryb chroniony (Protected mode) i jego obsługa w 4 trybach (aka rings):
To była trudna idea do uchwycenia i postaram się ją wyjaśnić w tym poście tak jasno jak to możliwe. Omówimy następujące pojęcia:
GDT, LDT, IDT.
Translacja pamięci wirtualnej.
ASLR i Kernel ASLR (KASLR).
Zacznijmy od podstaw, każdy komputer ma przynajmniej (miejmy nadzieję) następujące komponenty: CPU, Disk i RAM. Każdy z tych komponentów posiada kluczową rolę w przepływie systemu. Procesor wykonuje polecenia i operacje na pamięci (RAM), RAM przechowuje dane, których używamy i umożliwia szybki i niezawodny dostęp do nich, dysk przechowuje trwałe dane, których potrzebujemy, aby istnieć nawet po restarcie lub wyłączeniu. Zaczynam od tego, ponieważ mimo że jest to podstawa, ważne jest aby o tym pamiętać i czytając ten artykuł zadać sobie pytanie, o którym komponencie mówimy w danym momencie.
System operacyjny jest oprogramowaniem, które zarządza tym wszystkim, a także tym, które umożliwia szybki, wygodny, spójny i wydajny interfejs, aby uzyskać dostęp do wszystkich jego możliwości – niektóre z nich to dostęp do sprzętu, a inne to zwiększenie wygody i wydajności.
Jak każde dobre oprogramowanie, system operacyjny działa w warstwach, jądro jest pierwszą warstwą i – moim zdaniem – najważniejszą. Aby zrozumieć znaczenie jądra, musimy najpierw zrozumieć jego działanie i wyzwania, przed którymi stoi, więc spójrzmy na niektóre z jego obowiązków:
Obsługa wywołań systemowych (ten sam interfejs, o którym mówiliśmy).
Przydzielanie zasobów (RAM, CPU i wiele innych) procesom/wątkom w ręku.
Zabezpieczanie wykonywanych operacji.
Pośredniczy pomiędzy sprzętem a oprogramowaniem.
Wiele z tych czynności wykonywanych jest z hojną pomocą procesora, w przypadku x86, tryb chroniony (Protected mode) jest trybem, który umożliwia nam ograniczenie mocy (zestawu instrukcji) aktualnie uruchomionego kontekstu wykonania.
Załóżmy, że mamy dwa światy – świat użytkownika i świat nadzorcy. W danym momencie możesz być tylko w jednym z tych światów. Kiedy jesteś w świecie użytkownika, widzisz świat takim, jakim chce go widzieć nadzorca. Zobaczmy, co mam przez to na myśli:
Powiedzmy, że jesteś procesem. Proces jest kontenerem jednego lub więcej wątków. Wątek jest kontekstem wykonania, jest to logiczna jednostka, w której wykonywane są instrukcje maszynowe. Oznacza to, że gdy wątek wykonuje, powiedzmy, odczyt z pamięci o adresie 0x80808080, to faktycznie odwołuje się do wirtualnego adresu 0x80808080 bieżącego procesu. Jak można się domyślić, zawartość adresu będzie różna pomiędzy dwoma procesami. Teraz, wirtualna przestrzeń adresowa jest na poziomie procesu, co oznacza, że wszystkie wątki tego samego procesu mają tę samą przestrzeń adresową i mogą uzyskać dostęp do tej samej pamięci wirtualnej. Aby podać przykład zasobu, który jest na poziomie wątku, użyjmy słynnego stosu.
Mam więc wątek, który wykonuje następujący kod:
Nasz wątek wykonuje funkcję main, która wywoła naszą funkcję „func”. Załóżmy, że przerwiemy wątek w linii 9. Układ stosu będzie następujący:
zmienna_a.
parametr.
adres powrotu – adres linii 20.
zmienna_b.
Dla zobrazowania:
W podanym kodzie tworzymy 3 wątki dla naszego procesu i każdemu z nich wypisujemy jego id, segment stosu i wskaźnik stosu.
Możliwe wyjście tego programu to:
Jak widać wszystkie wątki miały ten sam segment stosu, ponieważ mają tę samą wirtualną przestrzeń adresową. Wskaźnik stosu dla każdego z nich jest inny, ponieważ każdy z nich ma swój własny stos, w którym przechowuje wartości.
Uwaga uboczna na temat segmentu stosu – wyjaśnię więcej na temat rejestrów segmentu w sekcji GDT/LDT – na razie uwierz mi na słowo.
Dlaczego to jest ważne? W dowolnym momencie procesor może zamrozić wątek i przekazać kontrolę dowolnemu innemu wątkowi, który chce. Jako część jądra, scheduler jest tym, który przydziela CPU do aktualnie istniejących (i „gotowych”) wątków. Aby wątki mogły działać niezawodnie i wydajnie, konieczne jest, aby każdy z nich miał swój własny stos, na którym może zapisać swoje istotne wartości (zmienne lokalne i adresy powrotne na przykład).
Aby zarządzać swoimi wątkami, system operacyjny utrzymuje specjalną strukturę dla każdego wątku zwaną TCB (Thread Control Block), w tej strukturze zapisuje – między innymi – kontekst tego wątku i jego stan (uruchomiony / gotowy / itp…). Kontekst zawiera – ponownie – między innymi, wartości rejestrów procesora:
EBP -> Adres bazowy stosu, każda funkcja używa tego adresu jako adresu bazowego, z którego przesuwa się, aby uzyskać dostęp do zmiennych lokalnych i parametrów.
ESP -> Aktualny wskaźnik do ostatniej wartości (pierwszej do pop) na stosie.
Rejestry ogólnego przeznaczenia -> EAX, EBX, itd…
Rejestr flag.
C3 -> zawiera lokalizację katalogu stron (zostanie omówiony później).
EIP – Następna instrukcja do wykonania.
Poza wątkami system operacyjny musi śledzić wiele innych rzeczy, w tym procesy. Dla procesów OS zapisuje strukturę PCB (Process Control Block), powiedzieliśmy, że dla każdego procesu istnieje izolowana przestrzeń adresowa. Na razie załóżmy, że istnieje tabela, która mapuje każdy adres wirtualny na fizyczny i ta tabela jest zapisana w PCB, OS odpowiedzialny za aktualizację tej tabeli i utrzymanie jej w prawidłowym stanie pamięci fizycznej. Za każdym razem, gdy scheduler przełącza wykonanie do danego wątku, tabela, która została zapisana dla procesu tego wątku jest stosowana do procesora, więc będzie on w stanie poprawnie przetłumaczyć adresy wirtualne.
To tyle jeśli chodzi o koncepcje, zrozummy jak to jest faktycznie zrobione. W tym celu spójrzmy na świat z perspektywy procesora:
Global Descriptor Table
Wszyscy wiemy, że procesor posiada rejestry, które pomagają mu wykonywać obliczenia, niektóre rejestry bardziej niż inne (;)). Z założenia x86 obsługuje wiele trybów pracy, ale najważniejsze z nich to tryb użytkownika i nadzorcy, procesor posiada specjalny rejestr zwany gdtr (Global Descriptor Table Register), który przechowuje adres do bardzo ważnej tabeli. Tabela ta mapuje każdy wirtualny adres do odpowiadającego mu trybu pracy procesora, zawiera również uprawnienia dla tego adresu (READ | WRITE | EXECUTE). oczywiście rejestr ten może być zmieniany tylko z trybu nadzorcy. W ramach wykonywania instrukcji procesor sprawdza, którą instrukcję wykonać jako następną (i pod jakim adresem), sprawdza ten adres z GDT i w ten sposób wie, czy jest to ważna instrukcja na podstawie jej pożądanego trybu (dopasowanie aktualnego trybu procesora do trybu w GDT) i uprawnień (jeśli nie jest wykonywalna – nieważna). Przykładem może być instrukcja 'lgdtr’, która ładuje wartość do rejestru gdtr i może być wykonana tylko z trybu nadzorowanego. Kluczową kwestią do podkreślenia jest to, że wszelkie zabezpieczenia operacji pamięciowych (wykonywanie instrukcji / zapis do nieprawidłowej lokalizacji / odczyt z nieprawidłowej lokalizacji) są wykonywane przez GDT i LDT (o tym za chwilę) na poziomie procesora przy użyciu tych struktur, które zostały zbudowane przez OS.
Tak wygląda zawartość wpisu w GDT / LDT:
http://wiki.osdev.org/Global_Descriptor_Table
Jak widać ma ona zakres adresów, których wpis dotyczy, a jego atrybuty (uprawnienia) są takie, jakich można się spodziewać.
Local Descriptor Table
Wszystko co powiedzieliśmy o GDT jest również prawdziwe dla LDT z małą (ale dużą) różnicą. Jak sama nazwa wskazuje GDT jest stosowany globalnie w systemie, podczas gdy LDT jest stosowany lokalnie, co mam na myśli mówiąc globalnie i lokalnie? GDT śledzi uprawnienia dla wszystkich procesów, dla każdego wątku i nie zmienia się między przełączaniem kontekstu, LDT z drugiej strony jest. Ma to sens tylko wtedy, gdy każdy proces ma swoją własną przestrzeń adresową, możliwe jest, że dla jednego procesu adres 0x10000000 jest wykonywalny, a dla innego tylko do odczytu/zapisu. Jest to szczególnie prawdziwe przy włączonym ASLR (będzie to omówione później). LDT jest odpowiedzialny za utrzymanie uprawnień, które rozróżniają każdy proces.
Jedną rzeczą do zauważenia jest to, że wszystko co zostało powiedziane jest celem struktury, ale w rzeczywistości niektóre OS mogą lub nie mogą używać niektórych struktur w ogóle. na przykład możliwe jest używanie tylko GDT i zmienianie go między przełączaniem kontekstu i nigdy nie używać LDT. To wszystko jest częścią projektowania systemu operacyjnego i kompromisów. Wpisy w tej tabeli wyglądają podobnie jak w GDT.
Selektory
Skąd procesor wie, gdzie szukać w GDT lub LDT, gdy wykonuje konkretną instrukcję? Procesor posiada specjalne rejestry zwane rejestrami segmentowymi:
- Stack Segment (SS). Pointer to the stack. - Code Segment (CS). Pointer to the code. - Data Segment (DS). Pointer to the data. - Extra Segment (ES). Pointer to extra data ('E' stands for 'Extra'). - F Segment (FS). Pointer to more extra data ('F' comes after 'E'). - G Segment (GS). Pointer to still more extra data ('G' comes after 'F').
Każdy rejestr ma długość 16 bitów, a jego struktura jest następująca:
Mamy więc indeks do GDT/LDT, mamy też bit mówiący o tym czy jest to LDT czy GDT, oraz w jakim trybie jest on potrzebny (RPL 0 to supervisor, 4 to user).
Interrupt Descriptor Table
Poza GDT i LDT mamy także IDT (Interrupt Descriptor Table), IDT jest po prostu tabelą, która przechowuje adresy bardzo ważnych funkcji, niektóre z nich należą do OS, inne do sterowników i urządzeń fizycznych podłączonych do PC. Podobnie jak gdtr mamy idtr, który, jak się zapewne domyślasz, jest rejestrem przechowującym adres IDT. Co czyni IDT tak wyjątkowym? Kiedy inicjujemy przerwanie, procesor automatycznie przełącza się w tryb nadzorowany, co oznacza, że każda funkcja wewnątrz IDT działa w trybie nadzorowanym. Każdy wątek z każdego trybu może wywołać przerwanie poprzez wydanie instrukcji 'int’, po której następuje liczba mówiąca procesorowi, na jakim indeksie znajduje się docelowa funkcja. Mając to na uwadze, jest teraz oczywiste, że każda funkcja wewnątrz IDT jest potencjalną bramą do trybu nadzorowanego.
Wiemy więc, że mamy GDT/LDT, który mówi CPU o uprawnieniach dla każdego adresu wirtualnego i mamy IDT, który wskazuje funkcje „bramy” do naszego ukochanego jądra (które oczywiście rezyduje wewnątrz nadzorowanej sekcji pamięci). Jak te struktury zachowują się w działającym systemie?
Pamięć wirtualna
Zanim zrozumiemy jak to wszystko razem gra, musimy omówić jeszcze jedno pojęcie – pamięć wirtualną. Pamiętasz, kiedy powiedziałem, że istnieje tabela, która mapuje każdy adres pamięci wirtualnej do jego fizycznej? W rzeczywistości jest to trochę bardziej skomplikowane niż to. Po pierwsze nie możemy po prostu mapować każdy adres wirtualny, jak to zajmie więcej miejsca niż faktycznie mamy, i odkładając potrzebę bycia wydajnym na bok, OS również może zamienić strony pamięci na dysku (dla wydajności i wydajności), jest możliwe, że strona pamięci potrzebnego adresu wirtualnego nie jest w pamięci w tej chwili, więc oprócz tłumaczenia adresu wirtualnego do fizycznego musimy również zapisać, czy pamięć jest w pamięci RAM, a jeśli nie, gdzie to jest (może być więcej niż jeden plik strony). MMU (Memory Management Unit) jest komponentem odpowiedzialnym za tłumaczenie pamięci wirtualnej na fizyczną.
Jedną z rzeczy naprawdę ważnych do zrozumienia jest to, że każda instrukcja w każdym trybie przechodzi przez proces tłumaczenia adresu wirtualnego, nawet kod w trybie nadzorowanym. Kiedy procesor jest w trybie chronionym, każda wykonywana przez niego instrukcja używa adresu wirtualnego – nigdy fizycznego (istnieją pewne sztuczki, które powodują, że rzeczywisty adres wirtualny zawsze będzie przekładał się na dokładnie tę samą pamięć wirtualną, ale to jest poza zakresem tego wpisu).
Więc kiedy procesor jest już w trybie chronionym, skąd wie gdzie szukać kiedy musi przetłumaczyć adres wirtualny? Odpowiedzią jest rejestr CR3, rejestr ten przechowuje adres do struktury, która zawiera wymagane informacje – katalogu stron. Jego wartość zmienia się wraz z aktualnie uruchomionym procesem (znowu inna wirtualna przestrzeń adresowa).