0xcafebabe -- per aspera ad astra

Dokument pobrany z: http://www.anadoxin.org/blog/podsystem-binfmt-w-kernelu-linux

Podsystem binfmt w kernelu Linux
Tagi:  •    •    •    •    •  
Post nie będzie zawierał informacji szczegółowych na temat implementacji tego systemu, ale będzie mniej lub więcej praktycznym wprowadzeniem do pisania konkretnych modułów kernela Linux.

Szczegóły implementacji dostępne są w Internecie na wielu krokach ;), jak też i sam kod źródłowy jądra Linux'a zaopatruje w wiele interesujących szczegółów (właściwie, to kod źródłowy zaopatruje we wszystkie szczegóły, choć nie wszystkie są interesujące), dlatego teorię zdecydowałem się przesunąć na nieco dalszy plan.

Źródła (tar.bz2) [5kb]

Binfmt

binfmt jest mechanizmem, który pozwala kernelowi na rozszerzanie swoich możliwości rozumienia wykonywalnych plików binarnych. Jest używany wtedy, gdy użytkownik uruchomi jakiś plik binarny z flagą +x; aby program mógł się uruchomić, kernel musi najpierw zrozumieć jego treść, zobaczyć, w których miejscach jest kod a w których są dane, powiązać to w zrozumiały dla siebie sposób i poprawnie zinterpretować.

Pliki wykonywalne na systemach z Unix'owej rodziny przeszły pewną ewolucję, podobnie jak formaty plików wykonywalnych na systemach Windows. Na Unixach, jednym ze starszych formatów był format a.out, który został zastąpiony przez bardziej funkcjonalny COFF, który z kolei został wyparty przez format ELF (Executable and Linkable Format, lub też — w innym przedziale czasowym — Extensible Linking Format), który "króluje" do dziś. Różnorodność formatów wykonywalnych jest dodatkowo bardziej zauważalna po zorientowaniu się, że format ELF wygląda też nieco inaczej dla 32-bitowych architektur, a inaczej dla 64-bitowych. Problem jest taki, że należałoby zapewnić rozsądną kompatybilność wsteczną, jeśli nie dla wszystkich formatów, to przynajmniej dla tych "ważniejszych" ;). Do tego przydaje się właśnie binfmt; jest to prosty sposób na pozwolenie kernelowi wybrania, który interpretator wybrać do uruchomienia programu, który chce uruchomić użytkownik.

Aby nieco przybliżyć temat: funkcja uruchamiania pliku wykonywalnego zaczyna się w praktyce od wywołania funkcji z rodziny exec*, których implementacje znajdują się w standardowej bibliotece libc, dostępnej na każdym systemie. Każda funkcja z tej rodziny, po wejściu do kodu w libc, w pewnym momencie napotyka wywołanie systemowe o nazwie execve. Jest ono realizowane poprzez instrukcję int 0x80 (lub instrukcję sysenter, lub syscall), która jest standardowym mechanizmem wywoływania funkcji systemowych. Przerwanie 0x80, inaczej syscall, wraz z argumentem, który jest liczbą 11 (jest to numer porządkowy, symbolizujący funkcję execve), pozwala na wyjście z trybu użytkownika (ring 3), oraz zmianę uprawnień kodu wykonywalnego (z punktu widzenia procesora) na uprawnienia systemowe - czyli ring 0. Przerwanie 0x80, gdy argumentem jest liczba 11, jest implementowane przez kernel w funkcji sys_execve, która znajduje się w pliku arch/x86/kernel/process.c. Tak więc, robiąc mały krok wstecz: po wejściu do funkcji execve w libc, kod przechodzi przez swojego rodzaju bramę, ustawioną w strukturze IDT i wychodzi już w trybie ring 0, do funkcji sys_execve (dla kompletności: w przypadku korzystania z instrukcji sysenter lub syscall, brama z IDT zastępowana jest odczytem rejestrów MSR i przekazaniem kontroli adresowi, który znajduje się w konkretnym rejestrze). Funkcja sys_execve zajmuje się kopiowaniem części argumentów z trybu ring 3 do trybu ring 0 i wywołuje inną funkcję: do_execve, która pełni rolę jedynie funkcji przejściowej. Tworzy ona jedynie struktury wspomagające kopiowanie dalszej części danych z trybu 3 do trybu 0, po czym wywołuje funkcję do_execve_common. Ta w końcu zajmuje się jakimiś konkretami ;)

Między innymi, funkcja inicjuje kontekst bezpieczeństwa procesu, upewnia się, że niechciane uchwyty aktualnego procesu nie zostaną przekazane procesowi potomnemu, inicjuje strukturę managera pamięci, oraz, co z punktu widzenia tego posta jest najważniejsze — wywołuje funkcję search_binary_handler, która zajmuje się szukaniem odpowiedniego interpretatora, który będzie zdolny poprawnie zinterpretować wybrany plik wykonywalny.

Każdy interpretator jest zaimplementowany w swoim, oddzielnym od innych, pliku źródłowym, np.:

  • fs/binfmt_aout.c,
  • fs/binfmt_elf.c,
  • fs/binfmt_script.c,
  • etc.

Każdy trzyma się też kilku reguł; po pierwsze, rejestruje się przy użyciu funkcji register_binfmt, przekazuje do niej wypełnioną przez siebie strukturę linux_binfmt, która zawiera pole load_binary zawierające wskaźnik do funkcji, która zajmuje się próbą załadowania, zgodnie ze swoją logiką, pliku do pamięci. Jeśli struktura pliku nie jest zgodna z tym, co dany interpretator ma zaprogramowane, zwraca on błąd -ENOEXEC, aby podsystem binfmt wołający funkcję ładującą wiedział, że to nie jest prawidłowy interpretator tego formatu, i powinien spróbować użyć innego interpretatora, jeśli jeszcze jakiś w ogóle został.

Podsumowując: podczas ładowania jądra, interpretatory plików binarnych, które są włączone w kernel, wywołują funkcję register_binfmt, aby się zarejestrować w podsystemie binfmt. Moduły załadowane później również wywołują tą funkcję, w tym samym celu; wniosek jest taki, że kernel w każdej chwili prowadzi rejestr (tablicę) rejestracji wszystkich interpretatorów, które są testowane pod kątem możliwości załadowania jakiegoś pliku binarnego. Pomyślałem więc, że byłoby interesujące zobaczyć na własne oczy, jakie pliki binarne aktualnie może uruchomić kernel — dlatego też przystąpiłem do napisania prostego sterownika, który moim oczom pomógłby tą informację ukazać ;).

Sterownik

Najpierw jednak należałoby dokładnie zbadać, co konkretnie sterownik miałby robić. Szkic intencji został już nakreślony, trzeba jednak jasno sprecyzować które struktury należy odczytać, aby znaleźć potrzebne informacje. Do tego celu przyda się szybka analiza funkcji search_binary_handler.

Poza licznymi warunkami, pełniącymi rolę sanity check'ów, następuje wywołanie pętli for, w której znajduje się odwołanie do makra list_for_each_entry. Makro to jest popularnym rozwiązaniem stosowanym w całej rozciągłości kodu jądra, które służy do iterowania po liście dwukierunkowej. Pierwszy argument (fmt), to iterator; do tej zmiennej będą odwoływać się instrukcje wewnątrz scope'a stworzonego przez nawiasy makra. Drugi argument, to wskaźnik do listy (&formats), po której zmienna fmt ma iterować, natomiast trzeci argument wynika ze specyfiki języka C i makra container_of, i jest to nazwa pola, które pełni rolę "nakierowania" kompilatora, podczas gdy ten stara się wyprowadzić żądany typ struktury z żądanej listy. Nie jest on w tej chwili ważny — stanowi jedynie kod, sklejający w całość interesującą logikę, starając się zachować bezpieczność typów, więc zostanie po prostu pominięty. Ważne jest to, że pętla wywołana przez makro list_for_each_entry zaczęła iterować po liście formats, która zawiera elementy o typie linux_binfmt. Każda iteracja polega na odczytaniu pola load_binary struktury fmt, wywołaniu jej i sprawdzeniu, czy wywołanie się powiodło — zatem, formats jest tablicą, która przetrzymuje rejestr aktualnie zainstalowanych w systemie interpretatorów plików wykonywalnych. Wyświetlając zawartość tej tablicy w zrozumiały dla człowieka sposób, odczytamy informacje, jakie pliki wykonywalne nasz Linux jest w stanie uruchomić.

Napiszmy więc sterownik, który — po załadowaniu — odnajdzie drogę do zmiennej formats i wyświetli na ekranie jej zawartość, w postaci takiej, aby można było ją po prostu odczytać gołym okiem. Pojawia się tu jednak pewien problem; sterownik nie może po prostu wyświetlić czegoś na ekranie. Taka interakcja z użytkownikiem stoi na zbyt wysokim poziomie abstrakcji. Otrzymywanie informacji z modułów robi się w nieco inny sposób; stosuje się połączenie programu ring 3 z modułem ring 0. Zwykły program wysyła zapytania do modułu, po czym otrzymuje od niego odpowiedź; i to zwykły program zajmuje się wyświetlaniem danych na ekranie, nie moduł. Linux (jak też i inne Unixy) posiada pewien mechanizm (jeden z wielu), który może w tym pomóc: nazywa się procfs i służy do przekazywania informacji z trybu ring 0 do trybu ring 3, aby każdy program ring 3 mógł odczytać i zinterpretować informacje na swój sposób. W katalogu /proc znajdują się liczne pliki, które mogą być odczytane przez np. program cat. Napisy, które wyświetlane są na ekranie, generowane są przez odpowiednie moduły w trybie ring 0, przesyłane są przez standardowe mechanizmy transferu danych do trybu ring 3, aby być zwrócone do programu przez standardowe funkcje I/O typu read(), i w wyniku wyświetlone na ekranie.

Notabene, jest pewien skrót, którym można się udać, przy pokazywaniu kernelowych informacji zwykłemu użytkownikowi; nazywa się on printk, i działa prawie jak zwyczajny printf. Należy tylko dodać, że działa on zwykle tylko na pierwszej "fizycznej" konsoli (czyli Ctrl+Alt+F1), oraz, aby działał, należy najpierw wpisać np. "9 9 9 9" do pliku /proc/sys/kernel/printk. Po więcej informacji odsyłam do Wielkiego Brata ;). Czasami, przy wykorzystaniu takich demonów systemowych jak np. klogd, treść printk'ów kierowana jest do odpowiedniego pliku z logiem, np. /var/log/kern.log, ale metoda ta jest dobra raczej tylko w celach diagnostycznych, a nie jako "produkcyjna" strategia interakcji z użytkownikiem. Jako przykład użycia tej funkcji, możesz zerknąć np. na przykład enumeracji urządzeń fizycznych na szynie PCI; jest on mały i nieskomplikowany, więc nie powinno być trudno go zrozumieć.

Podsumowując, oto plan działań:

1. Napisanie szkieletu sterownika, który jest poprawnie kompilowany do formatu binarnego, akceptowalnego przez loader modułów insmod,
2. Dostanie się do tablicy globalnej formats i zinterpretowanie tego, co tablica zawiera,
3. Stworzenie własnego, specjalnego pliku w systemie plików /proc, który będzie zawierał zbudowane przez nas informacje o treści tablicy formats.

Tutaj znajduje się prosta demonstracja, o co konkretnie mi chodzi, bardziej przyjazna dla oka i wyobraźni:

Przeprowadzanie punktu pierwszego było przeze mnie opisane w mojej poprzedniej notce, która znajduje się tutaj. Uznam więc, że ten temat mamy już za sobą ;). Przed przejściem do punktu drugiego, proponuję jednak najpierw przyjrzenie się punktowi trzeciemu, ponieważ charakterystyka transferu danych przy pomocy systemu /proc może zmienić nieco podejście do odczytywania informacji z tablicy formats. Nie aż tak, żeby rozwiązanie było zupełnie inne, ale na tyle, aby wymagać dedykowanego pod procfs podejścia.

System plików /proc

Pisałem już jakiś czas temu o tym systemie plików, przy okazji kilku faktów na temat dumpowania procesów w całości w trybie ring 3. Teraz jednak pora na kilka faktów z drugiej strony, ring 0, czyli z miejsca, które ten system plików implementuje.

Sterownik, który chce utworzyć plik w systemie /proc, wywołuje najpierw funkcję create_proc_entry. Posiada ona trzy argumenty: pierwszy to nazwa pliku, który zostanie utworzony. Drugi argument to prawa dostępu, zapisane w standardowej, liczbowej notacji (np. 666 dla praw rwxrwxrwx, lub 755 dla praw rwxr-xr-x). Trzeci, ostatni, argument to wskaźnik do elementu nadrzędnego; jest on wymagany tylko w przypadku, gdy zamiast pliku sterownik chce stworzyć katalog (np. gdy ma w zamyśle stworzenie wielu plików, dobrze jest je zgrupować właśnie w katalog). Nasz przykład nie będzie wymagał większej ilości plików, dlatego wystarczy stworzyć jeden plik, np. /proc/kformats:

  1. proc_kformats = create_proc_entry("kformats", 755, NULL);

Do zmiennej globalnej proc_kformats, która jest zmienną o typie struct proc_dir_entry *, powędruje wskaźnik do deskryptora nowego pliku, lub NULL w przypadku, gdy jego utworzenie się nie powiedzie. Zanim nowoutworzony plik będzie możliwy do użycia, należy jeszcze powiadomić kernel o pewnej liście akcji, które sterownik chciałby skojarzyć z tym nowym plikiem kformats. Lista akcji jest listą, która zawiera informacje o reakcji na pewne standardowe w systemie akcje, przykładowo: otwarcie pliku poprzez funkcję open(), czytanie z pliku poprzez read(), ustawianie wskaźnika czytania poprzez lseek(), itd. Lista akcji precyzuje, jakie funkcje sterownika należy uruchomić jako reakcję na wywołania wyżej wymienionych funkcji przez jakiś program, np. cat.

Posługując się przykładem — standardowa metoda działania programu cat może wyglądać następująco: po uruchomieniu programu za pomocą polecenia cat /proc/kformats, plik kformats jest otwierany przez funkcję open(), ustawiany jest wskaźnik pliku na adres 0 poprzez lseek(), czytana jest zawartość pliku, aż do napotkania znacznika końca pliku, poprzez funkcję read(), a na koniec następuje zamknięcie pliku przy pomocy close(). Kojarząc teraz listę akcji w sterowniku, można powiedzieć kernelowi, że, gdy program uruchomi funkcję open, kernel powinien wywołać pewną funkcję sterownika, np. my_driver_proc_file_open(). Ta funkcja powinna być już napisana przez nas, i powinna zawierać kod np. inicjujący źródło danych, z którego zamierzamy czytać. Gdy cat wywoła lseek(), lista akcji skieruje kernel na funkcję np. my_driver_proc_file_seek(), która powinna zawierać kod ustawiania pozycji czytania ze źródła danych na żądany offset (oczywiście to w przypadku, gdy źródło danych umożliwia taką operację).

Lista akcji jest więc strukturą o nazwie file_operations. Jej definicja może wyglądać przykładowo tak:

  1. static struct file_operations kformats_file_ops = {
  2.         .owner = THIS_MODULE,
  3.         .open = kformats_open,
  4.         .read = kformats_read,
  5.         .llseek = kformats_lseek,
  6.         .release = kformats_release
  7. };

Podane funkcje nie zostaną jednak nigdy uruchomione, jeśli struktura kformats_file_ops nie zostanie skojarzona z deskryptorem pliku, który zwróciła funkcja create_proc_entry. Podpinanie tej struktury to prosta operacja przyporządkowania do jej pola proc_fops adresu do kformats_file_ops; po tej operacji kernel będzie kierował "ruch" operacji wejścia/wyjścia adresowanego do pliku /proc/kformats do funkcji w module, który stworzył ten plik. Takie podejście to zalążek obiektowej reprezentacji struktur danych w kernelu; struktura file_operations, jak też i inne, podążają podobną konwencją przyporządkowania funkcji do składowych o stałych nazwach. W ten sposób struktury stają się obiektami, które posiadają własne metody, gotowe do wywołania. Puryści programowania obiektowego prawdopodobnie będą zapierać się rękami i nogami twierdząc, że to nieprawda i że nie jest to prawdziwe programowanie obiektowe; jednak możemy ich bezpiecznie zignorować, ponieważ każdy, kto wymaga programowania obiektowego od języka C, zasługuje na taką reakcję ;).

Głównym problemem podczas implementowania funkcji read() jest fakt, że trzeba ręcznie manipulować buforami danych, które mają przetrzymywać nasze dane. Innymi słowy, posiadając np. strukturę, którą chcemy przekazać do trybu ring 3, nie możemy jej po prostu przepisać w podane miejsce, ponieważ funkcja read() przyjmuje w argumencie ilość danych, które maksymalnie można zapisać do bufora. Nie ma też gwarancji ani żadnej "domyślnej" wartości na tą ilość bajtów — czasami aby otrzymać np. 512 bajtów, program ring 3 może wywołać funkcję read() 512 razy, czytając z urządzenia po jednym bajcie, może zrobić to czytając dwa razy po 250 bajtów i raz 12 bajtów, etc. Przy każdorazowym odczycie zwiększa się też wskaźnik aktualnego miejsca (ustawiany np. lseek()). To powoduje sytuację, że niektóre typy danych dość ciężko przekopiować między trybami ring 0 a ring 3, a już szczególnie, jeśli na początku odczytu nie wiemy dokładnie, ile tych danych będziemy mieć. Ciężko też cache'ować wszystkie dane w całości w pamięci, ponieważ jeśli każdy sterownik wykorzystywałby to rozwiązanie, system zajmowałby prawie całą pamięć, której brakowałoby normalnym programom użytkowym.

Z tego też powodu, kernel oferuje specjalną warstwę, którą implementuje się nieco wyżej niż niskopoziomowa struktura file_operations. Warstwa ta implementuje specjalne callbacki, których używa się zamiast pól open, read, llseek i close, za to implementuje się serię funkcji o podobnych znaczeniach, ale operujących na wierszach, nie danych binarnych. Znaczy to, przykładowo, że np. implementując funkcję read struktury file_operations, w głowie należy mieć rozpisany plan, jakie dane binarne zwrócić. Jeśli chcemy zwrócić dane tesktowe, należy je traktować i tak jak dane binarne. Warstwa seq o której piszę w tym paragrafie nieco upraszcza ten interfejs, pozwalając funkcji read zwracać po prostu linijkę tekstu i nie obawiać się o to, że może zabraknąć pamięci po zebraniu np. 100 linijek tekstu. Nas interesuje tylko jedna linijka, a sama funkcja read wywoływana jest wtedy, gdy program ring 3 wysunie żądanie o linijkę, a nie o konkretną ilość bajtów.

Nasza deklaracja więc nieco się zmieni na taką postać:

  1. static struct file_operations kformats_file_ops = {
  2.         .owner = THIS_MODULE,
  3.         .open = kformats_open,
  4.         .read = seq_read,
  5.         .llseek = seq_lseek,
  6.         .release = seq_release
  7. };

Widać tutaj, że funkcję read w naszym przypadku zastąpi zaimplementowana już w innym miejscu kernela funkcja seq_read (więc nie trzeba jej reimplementować), funkcję llseek zastąpi seq_lseek, funkcję release zaimplementuje seq_release, jedyną funkcją ze "starego" interfejsu, jaką trzeba zaimplementować (choć też tylko po części) jest funkcja implementująca open. Oto jej definicja:

  1. static int kformats_open(struct inode *inode, struct file *file) {
  2.         return seq_open(file, & kformats_seq_ops);
  3. }

Nie jest więc zbyt skomplikowana, ale jest dość ważna: jedyną jej rolą jest wywołanie funkcji seq_open, z argumentami, z których jeden wskazuje na strukturę seq_operations (tutaj definiowana przez zmienną kformats_seq_ops), która jest po prostu znowu listą akcji, tyle, że dla interfejsu seq. Zerknijmy więc na zmienną kformats_seq_ops:

  1. static struct seq_operations kformats_seq_ops = {
  2.         .start = seq_file_start,
  3.         .next = seq_file_next,
  4.         .stop = seq_file_stop,
  5.         .show = seq_file_show
  6. };

Lista akcji interfejsu seq jest następująca: rozróżnia się akcję start, czyli rozpoczęcie strumieniowania pliku, next, czyli ustawienie rekordu na następny, stop, czyli zakończenie strumieniowania pliku, oraz show, czyli akcję renderowania danych. Argumenty implementowanych funkcji będą przyjmować między innymi obiekt, dzięki któremu funkcje będą wiedziały, na jakim obiekcie operować, oraz iterator, dzięki któremu będą wiedzieć, w którym miejscu danych się znajdują i którą część przygotować do wyświetlenia.

Można teraz pomyśleć o sposobie odczytu naszej struktury formats z punktu widzenia interfejsu strumieniowania danych. Akcja start może inicjować dostęp do struktury, np. lockując odpowiednie mutexy (aby struktura nie zmieniła się podczas naszego chodzenia po niej), akcja next może zwiększać iterator o 1, akcja show może odczytywać kolejny element z tej struktury, formatować jego wskaźnik, ewentualnie robić wszystkie operacje, które spowodują zbudowanie stringa, który będzie wyświetlony użytkownikowi, natomiast stop może zwalniać wszystkie zalockowane wcześneij mutexy i np. zwalniać wszystkie zaalokowane tymczasowo bufory danych. Taki plan wydaje się działać w naszym przypadku, dlatego proponuję wdrożyć w życie sterownik który korzysta właśnie z tego podejścia. Ale, zanim przejdziemy do szczegółów implementacyjnych, proponuję wyjaśnić jeszcze dwa zagadnienia, bez których sterownika zrobić się jeszcze nie da. Pierwsze zagadnienie to szukanie konkretnego offsetu w pamięci dla struktury formats, czyli lokalizacja dynamicznych symboli, natomiast druga sprawa to synchronizacja — czyli wspomniane wcześniej lockowanie. Na pytanie "po co" odpowiem w odpowiednich krótkich podrozdziałach.

Lokalizowanie dynamicznych symboli — kallsyms

Wsparcie dla dynamicznego lokalizowania offsetów symboli musi być wkompilowane w kernel. Na szczęście większość kernelów implementuje ten interfejs. Łatwo sprawdzić, czy Twój kernel posiada support dla podsystemu kallsyms; wystarczy sprawdzić, czy posiadasz "plik" /proc/kallsyms (przy okazji, teraz powinno być wiadomo, co to za "plik"; do pojawienia się wykorzystuje mechanizmy opisane w tym poście):

  1. (2:1003)$ ls -la /proc/kallsyms
  2. -r--r--r-- 1 root root 0 09-13 21:58 /proc/kallsyms

Treść tego "pliku" jest tym samym, czym jest plik /boot/System.map (lub podobny), czyli jest to mapa nazw zmiennych do adresów w pamięci, w których te zmienne aktualne się znajdują. Adresy są generowane dynamicznie przez kompilator podczas kompilowania kernela, a same adresy zależą od wielu rzeczy, ale można założyć, że są to głównie: włączone optymalizacje oraz ilość rzeczy wkompilowanych w kernel. Są to jednak na tyle osobliwe ustawienia, że bezpieczniej jest przyjąć, że każdy system Linux posiada inne wartości (mimo, że różnice pojawiają się raczej między dystrybucjami, a nie indywidualnymi instalacjami systemu — no chyba, że rozpatruje się Gentoo, lub podobne). Z tego właśnie powodu nie można zapisać sobie po prostu adresu zmiennej w kodzie, ponieważ ten kod będzie działał tylko na naszym systemie, tylko na aktualnej wersji kernela, którą posiadamy. Potrzebny jest bardziej uniwersalny system, który zgodnie z mapami wygenerowanymi przez kompilator, będzie w stanie przetłumaczyć nazwę zmiennej, możliwą do odczytania i zapamiętania przez człowieka, na adres, który zmienia się z wersji na wersję kernela.

Taki interfejs to właśnie kallsyms. Główna (używana w tym przykładzie) funkcja nosi nazwę kallsyms_lookup_name i przyjmuje argument w postaci stringa przedstawiającego nazwę zmiennej. W odpowiedzi, na wyjściu, otrzymujemy offset do danej zmiennej w pamięci kernela, jeśli taka zmienna istnieje w mapie System.map.

Dostanie się do tablicy formats jest więc bardzo łatwe:

  1. g_formats_list = (struct list_head *) kallsyms_lookup_name("formats");

podczas, gdy g_formats_list jest po prostu zmienną globalną o typie standardowej listy dwukierunkowej w kernelu Linux (o której można znaleźć sporo informacji w sieci).

Synchronizacja

Pisanie modułów do kernela jest w pewnym sensie jak pisanie pluginów do jakiegoś większego systemu (niekoniecznie operacyjnego). Wiele aspektów z punktu widzenia których uruchamiany jest nasz kod jest oparta na tzw. architekturze zdarzeniowej (event-driven architecture) i istnieje wiele przypadków, które w wyniku różnych sytuacji wywołają w końcu nasz kod. Czasami będzie on wywołany z jednego wątku, czasami z drugiego, zdarzy się też tak, że będzie on wywoływany przez dwa wątki jednocześnie — na maszynie z jednym CPU nasz kod zostanie przerwany, by na jego miejsce wskoczył znowu nasz kod, ale z innymi parametrami, by ten znów został przerwany, aby poprzedni mógł dokończyć pracę, lub też na maszynie wieloprocesorowej (lub wielordzeniowej) kod będzie po prostu wykonywany w tym samym czasie na wielu procesorach. Najbezpieczniej założyć jest, że nieskończenie wiele kopii kodu jest uruchomionych na nieskończenie wielu procesorach, jednak wszystkie instancje korzystają z tego samego zestawu zmiennych (globalnych). Jak więc to wszystko ze sobą pogodzić?

Właściwie, mechanizmy są takie same, jak zwykłe mechanizmy synchronizacji wątków, w zwykłych programach. Stosuje się tutaj więc głównie mutexy i spinlocki (z różnymi wariacjami, tzn. np. mutex tylko do zapisu), atomowe liczniki (atomic_t, atomowe w sensie: niepodzielne, nie wybuchowe :>), jak też i bardziej skompilowane mechanizmy synchronizacyjne, np. read-copy update (RCU, które kopiują dane do innego miejsca przy zapisie i aktualizują wszystkie referencje, aby wskazywały nowy obszar; aby zachować starą pamięć, która właśnie może być przez kogoś odczytywana),

Tutaj znajduje się nieco więcej informacji o solucjach synchronizacyjnych wykorzystywanych w kernelu Linux. Artykuł jest już jednak trochę nie na czasie, ponieważ Big Kernel Lock został z jądra już usunięty, ale nadal stanowi dobre wprowadzenie w temat.

Jak to się ma więc do naszego projektu? Jest to bardzo ważne ;). Wyobraź sobie sytuację, w której pobierasz adres tablicy formats. Następnie zaczynasz iterować po każdym elemencie tej tablicy, wyświetlając na ekranie każdy jej element. Mało miejsca, aby coś poszło nie tak? Nie, jeśli do swojego wyobrażenia dołączysz drugi wątek. Jeśli podczas wyświetlania danego elementu przez Twój kod, drugi wątek zapragnie usunąć (z jakiegokolwiek powodu) ten element z tablicy i zwolnić jego pamięć, możesz wylądować w sytuacji, w której Twój kod będzie próbował odczytać pamięć, która została już zdealokowana. Oczywiście, w chwili "wejścia" do tego elementu pamięć była zaalokowana i poprawna, ale chwilę później drugi wątek ją usunął. Dlaczego mógł tak zrobić? Ponieważ nie wiedział, że ktoś właśnie czyta tablicę formats. Jeśli by to wiedział, mógłby odczekać chwilę, aż ten "ktoś" przestanie ją czytać, i wtedy — wiedząc, że już nikt jej nie odczytuje — samemu rozgłosić wszystkim, że będzie tablicę tą modyfikował (aby nikt nie zaczął jej odczytywać) — następnie usunąć element i powiedzieć, że znowu można tablicę odczytywać.

Do takiego "informowania" innych, że chce się mieć daną rzecz na wyłączność, służą właśnie np. mutexy, a proces "informowania" nazywa się po prostu blokowaniem, wejściem w sekcję krytyczną, lub lockowaniem. Wiele struktur, tablic, lub innych zmiennych w kernelu posiada związane ze sobą takie mutexy (lub inne metody synchronizacji), w przypadku, gdy istnieje szansa, że dana struktura będzie modyfikowana przez więcej niż jeden wątek. Nie inaczej jest w przypadku tablicy formats, której akompaniament w postaci obiektu rwlock_t gra zmienna binfmt_lock. Suma sumarum: przed czytaniem lub modyfikacją tablicy formats należy zablokować obiekt binfmt_lock, aby żaden wątek nie mógł zmodyfikować tej tablicy podczas naszego jej czytania, a więc jednocześnie nie sprzątnął nam nic sprzed nosa. Po naszej skończonej pracy z tablicą, należy odblokować ten obiekt. Wyprzedzając Twoje potencjalne pytanie na ten temat: w sumie to nie zawsze jasno jest określone, który element jest blockerem którego obiektu. Zwykle trzeba do tego dojść samodzielnie, czytając źródła kernela i interpretując kod.

Adres obiektu binfmt_lock można uzyskać w taki sam sposób, co adres samej tablicy formats, czyli przy pomocy kallsyms_lookup_name:

  1. g_binfmt_lock = (rwlock_t *) kallsyms_lookup_name("binfmt_lock");

Jego blokowanie odbywa się za pomocą funkcji read_lock, natomiast odblokowywanie za pomocą read_unlock.

Spinanie wszystkiego w całość

To chyba już wszystkie teoretyczne informacje, które były wymagane, aby napisać ten sterownik: wiadomo czym jest podsystem binfmt, i co chcemy osiągnąć. Wiadomo też, w którym konkretnie miejscu znajdują się interesujące informacje i wiadomo też, w jaki sposób się do nich dostać. Ostatni rozdział opisywał też w jaki sposób przeprowadzić odczytywanie, oraz początek posta zaopatrzył w informacje jak wyciągnięte informacje przekazać naszym oczom.

Teraz w nieco większych szczegółach, po kolei.

1. Pobierane są adresy tablicy formats oraz elementu serializującego dostęp do tej tablicy, czyli binfmt_lock. W przypadku, gdy nie będą mogły zostać odnalezione (np. podsystem kallsyms nie zostanie wkompilowany w jądro), sterownik nie pozwoli się załadować, zwracajć błąd podczas ładowania. Program insmod pokaże ten błąd użytkownikowi.

  1. static int __init kefdump_init(void) {
  2.         g_formats_list = (struct list_head *) kallsyms_lookup_name("formats");
  3.         g_binfmt_lock = (rwlock_t *) kallsyms_lookup_name("binfmt_lock");
  4.  
  5.         if(! g_formats_list || ! g_binfmt_lock)
  6.                 return -EFAULT;

2. Sterownik tworzy nowy element w wirtualnym systemie plików procfs: plik o nazwie kformats.

  1.         proc_kformats = create_proc_entry("kformats", 755, NULL);
  2.         if(! proc_kformats) {
  3.                 printk(KERN_ERR "can't create_proc_entry\n");
  4.                 return -ENOMEM;
  5.         }

3. Lista akcji pliku kformats jest inicjowana na listę kformats_file_ops. Lista ta używa podsystemu seq, oddając mu w kontrolę swoje akcje read, llseek i release.

  1.         proc_kformats->proc_fops = & kformats_file_ops;

4. Inicjacja jest zakończona, sterownik jest załadowany.

  1.         TRACE0("kformats installed");
  2.         return 0;
  3. }

5. Trzeba teraz odczytać plik /proc/kformats, aby móc dalej analizować, co robi sterownik. Polecenie cat /proc/kformats powinno załatwić sprawę. Polecenie cat najpierw otwiera plik (robi to funkcją np. fopen, która wewnątrz wywołuje syscall open), potem czyta z niego dane, aż do napotkania końca pliku, następnie zamyka plik (syscall close) i wychodzi. Skonsultujmy to, ponownie, z aktualnie używaną listą akcji:

  1. static struct file_operations kformats_file_ops = {
  2.         .owner = THIS_MODULE,
  3.         .open = kformats_open,
  4.         .read = seq_read,
  5.         .llseek = seq_lseek,
  6.         .release = seq_release
  7. };

Najpierw zostanie wywołana funkcja kformats_open, ponieważ najpierw cat otwiera plik:

  1.  
  2. static int kformats_open(struct inode *inode, struct file *file) {
  3.         return seq_open(file, & kformats_seq_ops);
  4. }

Nasz kformats_open() wywołuje więc funkcję podsystemu seq, przekazując mu listę akcji podsystemu seq, które dla tego pliku ma stosować:

  1. static struct seq_operations kformats_seq_ops = {
  2.         .start = seq_file_start,
  3.         .next = seq_file_next,
  4.         .stop = seq_file_stop,
  5.         .show = seq_file_show
  6. };

Znaczy to, że kontrolę nad plikiem /proc/kformats w całości sprawuje podsystem seq, dlatego stosujemy się do jego zasad: wyświetlanie pliku staje się z naszego punktu widzenia prostą pęlta. Start oznacza początek pętli, i jest to zwykle inicjowanie dostępu do danych, które chcemy czytać. U nas będzie to zablokowanie obiektu binfmt_lock, aby podczas naszego czytania, inne obiekty mogły zorientować się, jak wygląda sprawa dostępu do zapisu do tej tablicy (a więc: aby nikt nic stamtąd nie usunął), ale miał dostęp do czytania (ponieważ czytanie nie zmodyfikuje nam obiektu, więc nam nie szkodzi):

  1.  
  2. static void *seq_file_start(struct seq_file *s, loff_t *pos) {
  3.         TRACE0("lock");
  4.         read_lock(g_binfmt_lock);
  5.  
  6.         if(* pos >= 1)
  7.                 return 0;
  8.         else
  9.                 return g_formats_list->next;
  10. }

Funkcja zwraca adres do pierwszego elementu tablicy. Przy okazji, jeśli ktoś wyrazi chęć czytania z naszego pliku /proc/kformats od np. środka, funkcja zwróci błąd; prosty wniosek jest taki, że nasz sterownik nie obsługuje czytania tablicy formats od elementu innego niż 0 (sam początek).

Program cat, po otwarciu pliku, zacznie z niego czytać. Spowoduje to wywoływanie po kolei akcji next i show, aż do momentu, gdy funkcja next zwróci NULL, sygnalizując tym samym koniec danych. Oto funkcja next, której zadaniem jest inkrementacja iteratora (i jednocześnie zwiększanie wkaźnika aktualnego elementu w tablicy formats):

  1. static void *seq_file_next(struct seq_file *s, void *iterator, loff_t *pos) {
  2.         struct list_head *next = NULL;
  3.  
  4.         (* pos)++;
  5.         next = ((struct list_head *) iterator)->next;
  6.  
  7.         if(next != g_formats_list) {
  8.                 return next;
  9.         } else
  10.                 return NULL;
  11. }

Tablica formats korzysta ze standardowej struktury tablicowej, używanej w całym rozgległym kernelu. Jedna cecha charakterystyczna tej tablicy jest taka, że ostatni element połączony jest z pierwszym; tzn. stojąc na ostatnim elemencie, zwięszając iterator o 1, wrócimy do pierwszego elementu. Stąd ten warunek powyzej: jeśli next jest g_formats_list, to znaczy, że wróciliśmy w ten sposób do początku, a co za tym idzie, tablica się nam już skończyła; więc zwracamy NULL, aby zasygnalizować, że przeszliśmy już całą tablicę i nie mamy co czytać.

Następnie, funkcja, której zadaniem jest dereferencja iteratora i interpretacja danych:

  1. static int seq_file_show(struct seq_file *s, void *iterator) {
  2.         struct linux_binfmt *fmt = NULL;
  3.         char namebuf[512] = { 0 };
  4.  
  5.         fmt = container_of(iterator, struct linux_binfmt, lh);
  6.         sprint_symbol(namebuf, (unsigned long) fmt->load_binary);
  7.         seq_printf(s, "%s\n", namebuf);
  8.  
  9.         return 0;
  10. }

sprint_symbol() jest to dokładnie odwrotna funkcja do znanej nam już kallsyms_lookup_name — czyli zamienia adres na nazwę symbolu, który "zawiera" dany adres. Makro container_of to sposób dereferencji iteratora tablicy. Polecam poczytać o nim w otchłaniach internetu :)

To chyba właściwie wszystko. W razie wytknięcia błędów polecam wypróbowac funkcję "komentuj" kilkadziesiąt pikseli niżej.