Linux i Systemy Wbudowane
Udostępnione na licencji GNU Free Documentation License
Podziękowania
edytujNa wstępie chciałem serdecznie podziękować profesorowi Januszowi Chwastowskiemu oraz profesorowi Piotrowi Maleckiemu. Przede wszystkim dziękuję za przekazaną na studiach motywację do rozwijania się i w późniejszej pracy do nauczania innych i dzielenia się wiedzą. Pragnę też podziękować moim rodzicom za zainteresowanie mnie już od małego naukami technicznymi, pozwolenie na zepsucie pod pretekstem “ja naprawie”: niezliczonej ilości budzików, stacji dyskietek, magnetofonu, adaptera i całej masy innych sprzętów oraz za niezbyt częste odciąganie od komputera :) Duże podziękowania kieruję także do mojej Żony za cierpliwość i wyrozumiałość, że pozwoliła na to abym sporo czasu spędzał nad poznawaniem tych wszystkich rzeczy i motywowała mnie do kontynuowania doktoratu. Mojej Żonie chciałbym też bardzo podziękować za pomoc w opracowaniu tego tekstu i sprawieniu, że jest “bardziej po polsku”.
Wstęp
edytujZasiadając do lektury tego opracowania warto wcześniej poznać podstawowe zagadnienia związane z używaniem dowolnej dystrybucji Linuksa w trybie tekstowym (konsola). Opisywane zagadnienia zakładają, że znasz język C lub C++ oraz podstawowe polecenia Basha. Polecaną dystrybucją, na której powinny zadziałać wszystkie opisywane mechanizmy jest Debian 7 lub Linux Mint LMDE.
Android
edytujAndroid jest systemem operacyjnym opartym na jądrze Linuksa przeznaczonym głównie dla systemów wbudowanych, gdzie istotną rolę odgrywa łatwa interakcja z użytkownikiem i możliwość łatwego rozszerzania funkcjonalności. Używany jest najczęściej w telefonach komórkowych i tabletach, ale coraz chętniej korzystają z niego producenci sprzętu takiego jak telewizory, aparaty cyfrowe lub dekodery.
Tak duże spektrum zastosowań jest możliwe dzięki otwartym źródłom projektu na licencji Apache 2 oraz wykorzystaniu istniejących już dość długo na rynku rozwiązań. Z jednej strony pisanie aplikacji dla Androida w Javie znacznie ułatwia rozwijanie programu przez samego użytkownika, natomiast z drugiej strony mamy dostęp do kodu źródłowego jądra systemu Linux, które wspiera różnego rodzaju architektury i jest dobrze udokumentowane jeśli chodzi o tworzenie nowych sterowników.
Powodów, dla których na komórkach do tej pory nie zagościły popularne dystrybucje Linuksa jest zapewne kilka. Najbardziej rozsądnym z nich mogłyby się wydawać problemy z przenośnością kodu pomiędzy różnymi wersjami dystrybucji, samymi dystrybucjami oraz różnymi architekturami. W przypadku wykorzystania Javy do tworzenia aplikacji dla użytkownika, wszystkie powyższe problemy znikają i są ukryte pod wspólnym API dostępnym dla programistów. Jest ono wspólne, niezależnie od wersji kernela, zainstalowanych bibliotek i sprzętu na jakim uruchamiamy aplikację. Jedyną różnicą może być wersja samego API, jednak twórcy Androida starają się zachowywać kompatybilność wsteczną.
Organizacja systemu
edytujO ile publikacji w internecie na temat organizacji systemu od strony użytkownika (oraz API) jest całe mnóstwo, to opisy wnętrza systemu jest już znaleźć trudniej. Mimo wszystko coś musi pośredniczyć pomiędzy programem napisanym w Javie a sprzętem na którym go uruchamiamy. Możliwe, że jest to spowodowane dobrze (zależy też z czym porównamy) udokumentowanym API Androida, co zwalnia użytkownika z obowiązku sięgania w głąb systemu. Ponadto mocne ograniczenia w dostępie do niższych warstw systemu narzucane przez API, skutecznie zapobiegają modyfikacjom systemu. Takie zmiany w systemie mogą czasami spowodować utratę gwarancji na urządzenie. Jednak bez wykonania tychże modyfikacji praktycznie niemożliwe jest głębsze poznanie i ingerowanie w struktury systemu. Co więcej, standardowo, użytkownik nigdy nie ma uprawnień administratora znanego z systemu Linux.
Jak wcześniej wspomniałem, cały system oparty został na jądrze Linuksa. Na tym kończy się podobieństwo Androida do innych dystrybucji. Cała otoczka towarzysząca kernelowi została prawie w całości zmieniona. Jedyne podobieństwo, które możemy dostrzec na pierwszy rzut oka to powłoka. Jednak po chwili stwierdzimy, że większość poleceń działa nieco inaczej niż w Linuksie. Całkowite odejście od modelu znanego z Linuksa sprawiło, że w dość innowacyjny sposób wykorzystane zostały mechanizmy udostępniane przez ten system.
Pierwsze, na co trzeba zwrócić uwagę, to metoda wykonywania programów instalowanych przez użytkownika. Nie są to programy kompilowane w C/C++ do formatu binarnego tak jak pliki z katalogów /bin lub /usr/bin, tylko aplikacje pisane w Javie. Kompiluje się je do kodu pośredniego i pakuje w pakiety APK. Kod pośredni wykonywany jest na urządzeniu przez wirtualną maszynę Dalvik . Udostępnia ona jednolite API niezależnie od architektury i urządzenia, na jakim jest uruchamiana aplikacja. Dzięki temu możliwe jest również uruchomienie tych samych pakietów APK na różnych procesorach, sprzęcie i wersjach systemu.
Drugą rzeczą, o której warto wspomnieć jest wykorzystanie identyfikatorów użytkowników i grup. W standardowych dystrybucjach mamy dostępne konto administratora (root) z UID równym 0, który ma dostęp do wszystkiego w systemie. Konta o większych UID z przedziału od 1 do 999 to konta systemowe, które mogą być przypisane usługom działającym w systemie. Konta od 1000 w górę należą do użytkowników. Aplikacje uruchamiane przez zwykłego użytkownika mają nieograniczony dostęp do wszystkich jego danych. Niesie to ze sobą ryzyko, że jakaś złośliwa albo wadliwa aplikacja wykradnie dane innych aplikacji danego użytkownika lub uszkodzi je. Chodzi tu głównie o pliki z katalogu domowego. Android został zaprojektowany jako system umożliwiający pracę jednemu użytkownikowi (trudno mi sobie wyobrazić każdorazowe logowanie się na moją komórkę lub do telewizora). Stąd też bezsensowne byłoby wykorzystanie takiego modelu zarządzania kontami użytkowników. W zamian za to projektanci Androida postanowili przypisywać identyfikatory użytkowników, znane z Linuksa, do poszczególnych aplikacji instalowanych na urządzeniu. W ten sposób wykorzystanie różnych UID pozwala na dość naturalne zarządzanie uprawnieniami w obrębie całego systemu. Na przykład jeżeli chcemy dać aplikacji możliwość korzystania z urządzeń video, to użytkownik przypisany do niej powinien znaleźć się w grupie video. Odpowiednie zarządzanie prawami dostępu do plików powoduje, że aplikacje nie mogą odczytywać danych innych aplikacji. Dodając natomiast dany UID do wybranych grup dajemy aplikacji dostęp do odpowiednich składników systemu. Co więcej, każda instalowana aplikacja ma swój odpowiednik katalogu domowego dla użytkownika. Przez ograniczenie dostępu do tego katalogu tylko dla UID odpowiadającemu takiej aplikacji, nawet po uzyskaniu dostępu do wewnętrznej powłoki systemu, nie mamy dużych możliwości podglądania co się dzieje w systemie (bez dostępu do konta root).
Różnic jest oczywiście o wiele więcej i ciężko byłoby je tu wszystkie wymienić. Niestety odejście od standardów znanych i przetestowanych w *nixach i Linuksie może wpłynąć na stabilność i bezpieczeństwo całego systemu. Jest to jednak cena, jaką płaci się za mobilność, odchudzenie systemu i ujednolicenie go.
Warstwy systemu
edytujKolejną rzeczą znacznie odróżniającą Androida od innych dystrybucji Linuksa jest bardzo wyraźny podział na warstwy. Standardowo, procesy użytkowników i usługi działają w jednej przestrzeni i są rozgraniczone za pomocą identyfikatorów użytkowników, którzy je uruchomili. W przypadku Androida możemy wyróżnić kilka warstw:
- Aplikacje użytkownika - archiwa/paczki APK, które zawierają kod wykonywany przez wirtualną maszynę Dalvik. Jak wcześniej zostało wspomniane, kod ten jest uniwersalny i niezależny od niższych warstw systemu
- Wirtualna maszyna Dalvik - działa na równi z innymi procesami systemu Linux i ma za zadanie udostępniać jednolite API dla progamów uruchamianych przez użytkownika oraz zapewniać im dostęp do sprzętu lub usług. Częściowo dba też o uprawnienia dostępu do wybranych części API
- Usługi działające w przestrzeni systemu Linux - programy, które muszą być uruchomione dla poprawnego działania Linuksa. Są to m.in. daemony sieciowe, program init oraz usługi typowe dla Androida i niezbędne do jego prawidłowego działania
- Jądro systemu - tutaj podobnie jak w dystrybucjach, odpowiada ono za bezpośrednią komunikację ze sprzętem i udostępnia jego funkcje. Dba również o zarządzanie procesami oraz zabezpiecza dostęp do poszczególnych części systemu
[[Image:./graphics/android-layers|image]]
Warstwy Androida
Przykłady pokazane w kolejnych rozdziałach części dotyczącej Androida przedstawiają krok po kroku jak zacząć modyfikować Androida oraz sposób w jaki możemy pisać własne aplikacje dla warstwy leżącej tuż nad jądrem. Warto pamiętać, że aplikacje uruchomione w tej warstwie systemu mogą mieć znaczący wpływ na jego stabilność. Można pokusić się o stwierdzenie, że im niższa warstwa ulega przez nas modyfikacji, tym większe jest prawdopodobieństwo, że coś popsujemy.
System plików
edytujJak każdy większy system operacyjny Android udostępnia programom system plików. W tym rozdziale zostaną pokrótce opisane podstawowe katalogi istotne dla całego systemu operacyjnego.
W większości urządzeń cały system plików jest podzielony na kilka partycji. Pierwsza, główna partycja, często znajduje się w pamięci RAM. Jest to specjalny system plików rootfs. Podobnie jak ext3 lub xfs udostępnia on pliki, katalogi i cały szereg uprawnień do plików. Odróżnia go jednak to, że jest on na ogół zawarty w pliku initrd kopiowanym z pamięci flash urządzenia do pamięci RAM podczas ładowania systemu operacyjnego (jeszcze przez bootloader). Kolejne partycje to /system, na której znajdują się aplikacje niezbędne do działania wyższych warstw systemu, część aplikacji oraz pliki konfiguracyjne. Przeniesiona do niej została większość plików, które normalnie znajdują się w podkatalogach /. Tak więc w Androidzie mamy katlaog /system/bin, /system/etc oraz /system/lib.
Kolejnym ważnym katalogiem w Androidzie jest /data. Znajdziemy w nim wszystkie ustawienia aplikacji oraz ważne rzeczy, które mogą się zmieniać w trakcie ich działania. W odróżnieniu od /system, partycja /data jest zamontowana zawsze w trybie do odczytu i zapisu. Kody aplikacji (pakiety APK) są trzymane na karcie SD lub w /system (te preinstalowane na urządzeniu). Wszystkie ustawienia, zarówno aplikacji jak i całego systemu możemy znaleźć właśnie w katalogu /data. Pozwala to oddzielić “stałą” część systemu, która nie powinna sie zmieniać od danych użytkownika. Cały katalog jest podzielony na różne podkatalogi:
- /data/data - dane i pliki konfiguracyjne aplikacji użytkownika
- /data/app - paczki APK (aplikacje użytkownika ) instalowane w pamięci telefonu
- /data/system - pliki systemowe, na ogół konfiguracyjne. Przykładowo w pliku /data/system/accounts.db możemy znaleźć konfigurację kont zapamiętanych przez telefon (np. do google), a w packages.list listę zainstalowanych aplikacji wraz z przypisanymi do nich UID’ami Linuksowymi.
Podobną do /data partycją jest /cache. Również jest montowana do odczytu i zapisu, jednak na niej lądują jedynie rzeczy tymczasowe. Jest to odpowiednik znanego /tmp, jednak z nieco bardziej zorganizowaną strukturą.
Innymi wartymi wspomnienia miejscami z katalogu / są:
- /mnt/sdcard, gdzie zwykle montowana jest karta SD
- /mnt/assec - zmapowane przez device mappera paczki APK. Rozpakowywaniem i wyświetleniem struktury pliku zajmuje się specjalna część kernela - device mapper
- /init.rc, /init.goldfish.rc oraz /init.[nazwa producenta].rc - pliki konfiguracyjne odpowiadające skryptom Debiana z /etc/init.d. Korzysta z nich program /init, który przygotowuje cały system do działania
Przygotowanie środowiska
edytujDo rozwijania aplikacji dla Androida niezbędne są dwa zestawy narzędzi. Source Development Kit (SDK) to pakiet składający się m.in. z emulatora wirtualnych urządzeń Androida, narzędzi umożliwiających łączenie się z urządzeniami (w tym wirtualnymi) i instalatora wybranych wersji API tego systemu. Można go znaleźć pod nazwą Android Developer Tools (ADT). Drugi zestaw narzędzi - Native Development Kit (NDK) - zawiera zestaw kompilatorów pozwalających wygenerować kod natywny dla Andorida, który będzie działał w przestrzeni systemu Linux (warstwa 3).
Aby poprawnie wykonać kolejne kroki może być konieczne doinstalowanie dodatkowych pakietów w systemie. Są to biblioteki systemu w wersji 32-bitowej oraz środowisko uruchomieniowe Javy. Można je zainstalować poleceniem:
sudo apt-get install ia32-libs openjdk-7-jre
ia32-libs zawiera zestaw bibliotek dla architektury x86 (32-bitowej). W przypadku, gdy pracujemy pod taką, pakiet ten nie jest potrzebny. Do dalszych kroków niezbędne będzie też doinstalowanie gcc, /textitmake oraz tar, które instalujemy analogicznie jak poprzednio:
sudo apt-get install make gcc tar
SDK i NDK
edytujSDK oraz NDK należy pobrać ze strony internetowej Androida:
http://developer.android.com/sdk/index.html
http://developer.android.com/tools/sdk/ndk/index.html
Po pobraniu paczki rozpakowujemy do wybranego katalogu. Aby mieć dostępne polecenia zawarte w SDK i NDK bezpośrednio z lini poleceń (bez konieczności podawania pełnej ścieżki) należy zmodyfikować zmienną środowiskową PATH. Określa ona ścieżki do katalogów, w których powłoka wyszukuje plików z programami. Jej zawartość możemy zobaczyć wykonując w bashu polecenie:
$ echo $PATH /usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:/sbin:/usr/sbin
Na ogół są w niej katalogi /bin, /usr/bin, /sbin, /usr/sbin oraz /usr/local/bin. Może się to różnić w zależności od dystrybucji .
Po rozpakowaniu SDK należy dodać do zmiennej PATH dwa katalogi. Pierwszy z nich to zestaw narzędzi umożliwiających stworzenie i zarządzanie wirtualnymi urządzeniami Androida. W drugim znajdują się narzędzia służące do łączenia się i zarządzania już uruchomionymi urządzeniami (nie tylko wirtualnymi). Oba dołączamy do już istniejących poleceniem:
export PATH=$PATH:<SDK>/sdk/platform-tools
export PATH=$PATH:<SDK>/sdk/tools
<SDK> należy zamienić na pełną ścieżkę do katalogu, gdzie rozpakowane jest SDK.
Analogicznie dodajemy katalog <NDK>/toolchains/arm-linux-androideabi-4.6/prebuilt/linux-x86/bin/ z NDK. Można w nim znaleźć m.in. narzędzia służące do kompilacji niskopoziomowych programów dla Androida.
Aby każdorazowo, po zamknięciu konsoli nie było konieczne ponowne ustawianie zmiennej PATH, polecenia te można wpisać w pliku /.bashrc. Jest on ładowany za każdym razem, gdy włączamy konsole.
Tworzenie AVD - Android Virtual Device
edytujZa pomocą polecenia android instalujemy wybraną wersję SDK. Można to zrobić za pomocą polecenia android z odpowiednimi parametrami:
android update sdk --no-ui
Pobrane zostaną wtedy wszystkie wersje SDK. Można też wykorzystać graficzny interfejs i ręcznie wybrać wersje SDK do zainstalowania. W tym celu uruchamiamy polecenie android bez żadnych parametrów. Powinno ukazać się podobne okno:
[[Image:./graphics/android-avd-manager.png|image]]
Na liście należy zaznczyć wybraną wersję, z której chcemy skorzystać i kliknąć guzik “Install ... packages”. Przed tym można odznaczyć niektóre składniki, takie jak dokumentacja lub przykłady, jeśli zależy nam na miejscu na dysku. Po kliknięciu powinno pojawić się okienko z licencjami poszczególnych fragmentów SDK do zaakceptowania:
[[Image:./graphics/android-api-installation.png|image]]
Po kliknięciu Accept rozpocznie się ściąganie składników SDK. Może to zająć kilka minut.
Ostatnim krokiem jest stworzenie nowego emulatora urządzenia z systemem Android (AVD). Najłatwiej wykonać to przez graficzny interfejs polecenia android, klikając w menu Tools pozycję Manage AVDs. Powinna się wtedy pojawić pusta lista maszyn. Po prawej stronie należy kliknąć guzik “New...” w celu utworzenia nowej wirtualnej maszyny z Androidem. Kreator powinien wyświetlić nowe okienko z pytaniem o podstawowe rzeczy:
[[Image:./graphics/android-create-new.png|image]]
W odpowiednich polach można wpisać nazwę nowo tworzonej maszyny, emulowany sprzęt - do wyboru jest lista kilku gotowych urządzeń a także rozmiar dysku, karti SD i pamięci ram.
Oprócz graficznego interfejsu istnieje też możliwość zarządzania AVD z poziomu konsoli. Aby stworzyć nowe urządzenie należy wpisać:
android create avd -n [NazwaUrzadzenia] -t [WersjaAndroida]
gdzie jako wersję podajemy wersję wcześniej zainstalowanego API, np. android-17 dla wersji 4.2. Listę dostępnych w naszym systemie targetów można otrzymać wpisując:
$ android list targetsAvailable Android targets:
----------
id: 1 or "android-17"
Name: Android 4.2
Type: Platform
API level: 17
Revision: 1
Skins: WQVGA432, WSVGA, WXGA720, HVGA, WXGA800-7in, WXGA800, WVGA800 (default), WQVGA400, QVGA, WVGA854
ABIs : armeabi-v7a
Opcjonalne parametry to:
- -c ścieżka do karty sd
- -p ścieżka do urządzenia
- -s skin
Stworzoną maszyne uruchamiamy za pomocą polecenia:
emulator -avd [nazwa urządzenia]
Komunikacja z (wirtualnym) urządzeniem przez adb
edytujPo stworzeniu wirtualnego urządzenia lub podpięciu telefonu do komputera możemy połączyć sie z nim przez polecenie adb (Android Debug Bridge). Narzędzie to daje kilka przydatnych funkcji, które można użyć także podczas rozwijania standardowych programów Androida. Podstawowe funkcje to shell, push oraz pull. Wpisując w konsoli:
maciek@dijo $ adb shell
$ id
uid=2000(shell) gid=2000(shell) groups=1003(graphics),1004(input),
1007(log), 1009(mount),1011(adb),1015(sdcard_rw),3001(net_bt_admin),
3002(net_bt),3003(inet)
otrzymujemy dostęp do powłoki w systemie Android. Można się w niej poruszać podobnie jak w standardowym shellu w Linuksie. W zależności od posiadanego urządzenia może to być konto administratora (root) lub zwykłego użytkownika, tak jak w powyższym przykładzie. Rozróżnić to można poprzez znak zachęty. Dolar odpowiada zwykłemu użytkownikowi, natomiast znak kratki (#) koncie root. Można też po prostu wydać polecenie id, które wyświetli nasz UID. Rozłączamy się za pomocą polecenie exit lub wciskając Ctrl+D.
Kolejne dwa polecenia - push i pull - umożliwiają wgrywanie/pobieranie plików na/z systemu plików Androida. Dla programistów piszących aplikacje dla Androida przydatne może też być polecenie logcat umożliwiające podglądanie logów oraz install, za pomocą którego można zainstalować pakiet Apk z programem.
Cross-kompilacja
edytujPrzygotowane wcześniej środowisko składające się z zestawu kompilatorów dla platformy ARM, bibliotek i plików nagłówkowych niezbędnych w systemie Android, umożliwia przeprowadzenie tak zwanej cross-kompilacji. Kompilując kod programów standardowymi metodami lub bezpośrednio przez dostarczone z systemem gcc, generowany jest zawsze plik wykonywalny dla architektury i systemu, na którm pracujemy.
Proces cross-kompilacji (kompilacji skośnej) umożliwia wygenerowanie plików wykonywalnych dla innej architektury lub systemu operacyjnego niż ten, na którym pracujemy aktualnie. Dzięki temu nie jest konieczne instalowanie całego środowiska do kompilacji wewnątrz urządzenia, na które chcemy stworzyć program (często też nie jest to możliwe).
Warto pamiętać, że NDK dostarczone dla Androida nie posiada standardowych bibliotek z C++ (w tym iostream) i niezbędne jest korzystanie z funkcji z bibliotek standardowych C lub dostarczonych przez system Linux (np. open/write).
Konfiguracja kompilatorów
edytujŚrodowisko, w którym wykonujemy cross-kompilację należy wcześniej przygotować. Przede wszystkim kompilator będzie musiał wiedzieć, gdzie znaleźć biblioteki i pliki nagłówkowe dołączane podczas kompilacji. Standardowo, w Linuksie znajdują się one w /lib, /usr/lib (biblioteki) oraz /usr/include. Wykonując cross-kompilację należy przekazać kompilatorowi informację o innym położeniu przez parametr –sysroot. Katalog z powyższymi plikami w ndk, dla wersji Androida 4.1 (android-14) znajduje się w <NDK>/platforms/android-14/arch-arm/. Pełną ścieżkę można zapisać w zmiennej środowiskowej, np. SYSROOT podobnie jak to robiliśmy w przypadku zmiennej PATH. Dopisanie tego do pliku .bashrc umożliwi wykorzystywanie tej zmiennej także przy kolejnych logowaniach.
Hello World dla niskich warstw Androida
edytujNa początek spróbujmy stworzyć prosty program, który otworzy plik /system/etc/vold.fstab, wczyta jego pierwsze 100 bajtów do pamięci i wyświetli je. Plik ze źródłami tworzymy oczywiście u siebie lokalnie. Przykładowy kod programu:
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
int main(int argc, char *argv[])
{
int fd;
char buf[100];
int bytes;
// Open file /etc/fstab in read-only mode
fd = open("/system/etc/vold.fstab", O_RDONLY);
// Check, wheater file is opened successfully
if (fd < 0)
{
perror("open");
return -1;
}
// Read bytes from fd to address in memory pointed by buf
bytes = read(fd, buf, 100);
// Write to standard output number of readed bytes from memory bufer
write(1, buf, bytes);
}
Zapiszmy kod w pliku o nazwie read_fstab.c. Następnie taki program należy skompilować. Standardowo wykonalibyśmy polecenie gcc -o read_fstab read_fstab.c. W przypadku cross-kompilacji należy zamiast standardowego gcc wykorzystać kompilator dostarczony razem z NDK dla Androida. Ponieważ wcześniej dodaliśmy ścieżkę do katalogu z kompilatorami do zmiennej środowiskowej PATH, to nie ma konieczności podawania pełnej ścieżki do kompilatora. Wyszukaniem jej powinna zająć się powłoka, w której pracujemy (np. Bash). Poza wskazaniem pliku źródłowego oraz wynikowego niezbędne jest też wskazanie kompilatorowi gdzie ma szukać bibliotek, z którymi ma zostać zlinkowany podczas kompilacji program oraz gdzie znajdują się pliki nagłówkowe dla Androida. Wykonuje się to przez parametr –sysroot. Wartością dla tego parametru powinna być ścieżka do katalogu ustawionego w poprzednim podrozdziale. Można też odwołać się do ustawionej wtedy zmiennej środowiskowej SYSROOT. Teraz wystarczy wywołać polecenie kompilatora z odpowiednimi parametrami:
arm-linux-androideabi-gcc -o read_fstab --sysroot $SYSROOT read_fstab
Jeżeli cross-kompilacja przebiegła pomyślnie, powinien zostać utworzony plik wykonywalny read_fstab. Możemy spróbować wgrać go na nasze urządzenie poleceniem:
adb push ./read_fstab /read_fstab
W przypadku niektórych urządzeń partycje / i /system są zamontowane w trybie tylko do odczytu. Niezbędne jest wtedy połączenie się z powłoką urządzenia i przemontowanie danej partycji w trybie read-write:
maciek@dijo $ adb shell
# mount -o remount /dev/rootfs / -o remount,rw
Po przemontowaniu partycji głównej należy ponownie spróbować skopiować plik.
Następnie logujemy się na urządzenie przez adb shell i uruchamiamy nasz program.
Kompilacja z użyciem configure i Makefile
edytuj(teoretycznie) umiemy już skompilować prosty program, który składa się z jednego pliku. Co jednak, gdy dostaniemy jakiś bardziej złożony program lub bibliotekę, która korzysta na przykład z Makefile? Dzisiaj, w czasach gdy na znaczna część dystrybucji korzysta z systemów pakietów, kompilowanie pakietów ze źródeł spotyka się rzadko. Czasem jednak zdarzy się, że jakiegoś programu nie mamy w repozytorium lub potrzebujemy go skompilować na przykład dla innej architektury. Taka potrzeba może zajść podczas kompilacji programów dla niższych warstw Androida. Na ogół kompilacja programów ze źródeł składa się z trzech etapów:
tar zxf program.tar.gz
cd program
./configure
make
sudo make install
Sam do niedawna używałem tych trzech kroków, niewiele wiedząc o tym co one dokładnie robią. A przecież cała kompilacja to coś więcej, niż tylko przetłumaczenie kodu z C na kod, który rozumie procesor. Popatrzmy co się dokładnie tutaj dzieje. Większość dużych programów lub bibliotek składa się z więcej niż jednego pliku źródłowego .c lub .cpp. Dlaczego nie powinno się wrzucać wszystkiego do jednego pliku .c chyba nie trzeba tłumaczyć. Połączenie wszystkich takich plików w jeden przez dyrektywę include byłoby strzałem w kolano. Po pierwsze kompilator miałby do przetworzenia ogromny plik, który wygenerował preprocesor, a po drugie musielibyśmy zadbać o dobrą kolejność funkcji, klas, typów danych i ich wykorzystywanie. Skryptem configure, który standardowo jest w większości pakietów źródłowych możemy doprecyzować różne parametry kompilacji, takie jak:
- dodatkowe biblioteki, z których może skorzystać kompilowany program
- włączyć lub wyłączyć funkcjonalności
- ustawić opcje cross-kompilacji
Oczywiście w zależności od pakietu znajdziemy tam tylko niektóre z powyższych lub całą masę innych opcji. Odpowiednio wywołany skrypt configure powinien sprawdzić czy są w systemie wszystkie potrzebne narzędzia, biblioteki i pliki nagłównowe. Następnie tworzy on plik Makefile, który jest instrukcją do działania programu make. Program ten wykonuje krok po kroku kompilacje wszystkich plików .c przekształcając je w pliki .o ze skompilowanym kodem oraz łączy je w jeden plik wykonywalny. Tutaj także nie musi to być regułą i w zależności od tego co kompilujemy cały proces może się znacznie różnić. Przykładem jest plik Makefile (dokładniej zestaw plików Makefile) dla kernela Linuksa. Z ich użyciem da się skompilować jądro, wywołać graficzny, konsolowy lub tekstowy konfigurator, skompilować moduły i zainstalować je. Wszystko przez jedno polecenie make
Pliki Makefile
edytujZałóżmy, że nie chcemy tego całego mechanizmu ze skryptem configure, a jedynie chcielibyśmy usprawnić proces kompilowania całego naszego prorgamu, który rozrósł się do kliku lub kilkunastu plików źródłowych. Tutaj, jeśli nie korzystamy z gotowego IDE, z pomocą przyjdzie make oraz Makefile. Spójrzmy na przykładową zawartość pliku Makefile:
CFLAGS=--sysroot=/home/dijo/C++/andorid/headers/ all: program program: main.o gui.o functions.o gcc -o program main.o gui.o functions.o main.o: main.c gcc $(CFLAGS) -c main.c -o main.o gui.o: gui.c gui_qt.c gcc $(CFLAGS) -c gui.c gui_qt.c -o gui.o functions.o: functions.c gcc $(CFLAGS) -c functions.c -o functions.o clean: rm -rf *.o program
W pierwszej części pliku możemy zdefiniować zmienne dla make. Można je wykorzystać później i ułatwia to zmienianie parametrów kompilacji, na przykład parametrów kompilatora. W dalszej części widać wyraźny podział na sekcje. Każda z nich określa jaki plik będzie w niej stworzony (nazwa sekcji przed dwukropkiem) oraz jakie pliki są dla niej potrzebne. Wewnątrz sekcji (wcięte tabulatorem wiersze) można umieścić polecenia, jakie mają zostać wykonane do stworzenia danego pliku. Na przykład:
main.o: main.c gcc $(CFLAGS) -c main.c -o main.o
do stworzenia pliku main.o, który później będzie linkowany z innymi, wymagany jest plik main.c i należy go skompilować poleceniem gcc $(CFLAGS) -c main.c -o main.o. W przypadku większych projektów widać w takim procesie kompilacji też jedną, bardzo dużą zaletę: jeżeli data ostatniej modyfijacki któregoś z zależnych plików jest starsza, niż pliku wynikowego (przed dwukropkiem), to nie jest on ponownie kompilowany. W skrócie prowadzi to do tego, że jeżeli nie zmieniamy nic w danym pliku .c, do podczas kompilacji nie jest kompilowany (o ile istnieje plik .o z wcześniejszej kompilacji). Tak przygotowany plik Makefile możemy wykonać poleceniem make. Opcjonalnie można jako parametr podać nazwę sekcji, którą chcemy wywołać, na przykład clean
Kompilacja biblioteki curl
edytujZnamy więc już podstawowe narzędzia, z jakich będziemy korzystać podczas kompilacji natywnych programów dla systemu Android. Jak jednak połączyć skrypty configure, Makefile z cross-kompilacją? Wszystko to jest już zawarte w powyższych skryptach, wystarczy odpowiednio skonfigurować. Wpiszmy w konsoli:
$ ./configure --help `configure' configures curl - to adapt to many kinds of systems. Usage: ./configure [OPTION]... [VAR=VALUE]... To assign environment variables (e.g., CC, CFLAGS...), specify them as VAR=VALUE. See below for descriptions of some of the useful variables. [...] System types: --build=BUILD configure for building on BUILD [guessed] --host=HOST cross-compile to build programs to run on HOST [BUILD] Optional Features: --disable-option-checking ignore unrecognized --enable/--with options --disable-FEATURE do not include FEATURE (same as --enable-FEATURE=no) --enable-FEATURE[=ARG] include FEATURE [ARG=yes] --enable-maintainer-mode [...] Some influential environment variables: CC C compiler command CFLAGS C compiler flags LDFLAGS linker flags, e.g. -L<lib dir> if you have libraries in a nonstandard directory <lib dir> LIBS libraries to pass to the linker, e.g. -l<library> CPPFLAGS (Objective) C/C++ preprocessor flags, e.g. -I<include dir> if you have headers in a nonstandard directory <include dir> CPP C preprocessor Use these variables to override the choices made by `configure' or to help it to find libraries and programs with nonstandard names/locations. Report bugs to <a suitable curl mailing list: http://curl.haxx.se/mail/>.
Najważniejsze opcje z powyższych to:
- zmienne środowiskowe CC oraz CFLAGS
- opcja –host
- opcje –without-[pakiet], którymi możemy wyłączyć niektóre zależności
Do zmiennej CFLAGS należy przypisać wartość –sysroot=$SYSROOT lub z pełną ścieżką, według tego, co zostało opisane w poprzednich rozdziałach. Spowoduje to, że podczas kompilacji do każdego wywołania kompilatora zostanie dołączona nasza opcja, która określa gdzie są pliki nagłówkowe i biblioteki dla docelowej platformy. Ostatecznie wykonujemy skrypt configure następjąco:
export CFLAGS=--sysroot=$SYSROOT ./configure --host=arm-linux-androideabi
Po chwili configure może jednak zwrócić błąd, informując nas, że nie znalazł nagłówków biblioteki zlib. Można ją wtedy dokompilować lub, gdy tego nie chcemy, wyłączyć dodając opcję –without-zlib. Po ponownym włączeniu confugure z poprawnymi opcjami wszystko powinno przejść. Ostatecznie możemy wykonać polecenie make. Make install nie wykonujemy, ponieważ instalować będziemy na innej maszynie. Gdyby “po drodze” pojawiły się inne błędy, należy
Linux dla systemów wbudowanych
edytujCoraz częściej jądro systemu Linux jest wykorzystywane w urządzeniach mobilnych i wbudowanych. Przykładem może być opisany w poprzednim rozdziale system Android, który zdobywa coraz większą popularność. Ta część książki ma na celu omówienie podstaw obsługi urządzeń w Linuksie na różne spososoby, począwszy od poziomu użytkownika, a kończąc na dedykowanych sterownikach w przestrzeni jądra. Ta umiejętność może się przydać zwłaszcza gdy dostajemy jakiś nowy sprzęt, który nie jest obsługiwany natywnie przez Linuksa i chcemy dołożyć jego obsługę to tego systemu. Znajomość metod, jakie można wykorzystać do oprogramowania go może zaoszczędzić dużo czasu (i pieniędzy). Drugą rzeczą, dla której warto (moim zdaniem) przeczytać ten rozdział to lepsze poznanie tego jak działa Linux, w jaki sposób programy komunikują się ze sprzętem i jak z grubsza działa zarządzanie pamięcią w tym systemie.
Dostęp do urządzeń
edytujSystem Linux przyjął zasadę znaną dla innych systemów Unixowych mówiącą, że wszystko w systemie jest plikiem . Dzięki temu dostęp do gniazd sieciowych, potoków nazwanych i nienazwanych i standardowe wejście/wyjście obsługuje się tymi samymi funkcjami co zwykłe pliki. Ta sama zasada tyczy się również dostępu i interakcji z urządzeniami w systemie. Cała droga od kawałka sprzętu, który możemy pomacać aż do programu, w którym możemy poklikać wygląda mniejwięcej następująco. Urządzenie, które obsługujemy jest w jakiś sposób połączone z procesorem, na którym uruchomione jest jądro Linuksa. Mogą to być porty ogólnego przeznaczenia (GPIO) lub też jakieś bardziej zaawansowane metody, na przykład DMA. Dalej kawałek kodu zwany sterownikiem, działający w jądrze Linuksa odczytuje lub ustawia zawartosć odpowiednich komórek pamięci albo rejestrów procesora i za ich pomocą komunikuje się ze sprzętem. Jeżeli korzystamy na przykład z magistrali USB, to mamy wtedy do dyspozycji gotowy zestaw funkcji, które potrafią już obsłużyć samą komunikację USB, natomiast sterownik danego urządzenia musi przekazać do niego odpowiednie dane. Dalej, w zależności od tego, w jaki sposób udostępnimy takie urządzenie użytkownikowi, sterownik musi w jakiś sposób wystawić “pokrętło” dla programu działającego już jako zwykła aplikacja. Za pomocą tego pokrętła program będzie porozumiewał się ze sterownikiem i mówił mu co ma wykonać. Tym pokrętłem jest zazwyczaj plik specjalny z katalogu /dev. Co się stanie dalej, zależy już od wyobraźni programisty i/lub potrzeb jakim musi sprostać. Gotowe klocki działające ze sobą zwykle są już napisane i tworząc coś nowego musimy wstrzelić się w jedno z powyższych miejsc z naszym kodem. Dobrym przykładem może być komunikacja z myszką:
- Myszka jako taka musi być podłączana do komputera przez port USB, co wymusza już na samym sprzęcie zachowanie odpowiednich wtyków, zakresów napięć oraz komunikatów wymienianych z komputerem (usb host)
- Podsystem jądra odpowiedzialny za USB obsługuje obecną w komputerze magistralę USB, przekazując dane odebrane od urządzeń odpowiednim sterownikom
- Ponieważ myszka jest urządzeniem wejścia, to podsystem wejścia w jądrze Linuksa przechwytuje dane odebrane z magistrali i tłumaczy je na odpowiednie ciągi bajtów, które pojawią się jako ruchy myszy w pliku /dev/input/eventX (gdzie X to numer odpowiadający myszce). Format tych danych jest ściśle ustalony i niezależnie od tego jakie urządzenie mamy pod spodem, sterownik przetłumaczy je dokładnie na ten format. Weźmy na przykład port szeregowy - komunikacja po USB będzie wyglądała zupełnie inaczej niż po porcie szeregowym
- Dalej, już w przestrzeni użytkownika, jakiś program musi otworzyć taki plik i zacząć odczytywać z niego dane interpretując je odpowiednio. Zwykle jest to serwer Xorg, który wyświetla kursor i przekazuje informacje o zdarzeniach do poszczególnych aplikacji
- Pojedyncze aplikacje okienkowe odbierają od serwera grafiki pojedyncze zdarzenia z informacjami o ruchach myszy i kliknięciach
Jeśli więc zdecydujemy się na zrobienie myszki, to będziemy potrzebowali stworzyć jedynie kawałek sprzętu. Powinniśmy się dopasować z jego komunikacją do już istniejących standardów. Jeśli jednak przyjdzie nam do głowy interpretowanie gestów z myszki w programie użytkownika, to nie musimy się też zajmować przepisywaniem całego serwera Xorg na nowo, tylko dodajemy kolejną, włąsną cegiełkę w powyższym stosie.
Większość urządzeń jakie można znaleźć w systemie jest reprezentowana przez pliki w katalogu /dev. Przyjrzyjmy się dokładniej ich właściwościom wykonując polecenie ls -l /dev:
maciek@dijo ~ $ ls -l /dev
...
crw------- 1 root root 5, 1 lut 2 15:07 console
...
brw-rw---- 1 root disk 7, 0 lut 2 15:06 loop0
brw-rw---- 1 root disk 7, 1 lut 2 15:06 loop1
brw-rw---- 1 root disk 7, 2 lut 2 15:06 loop2
brw-rw---- 1 root disk 7, 3 lut 2 15:06 loop3
...
brw-rw---- 1 root disk 1, 0 lut 2 15:06 ram0
brw-rw---- 1 root disk 1, 1 lut 2 15:06 ram1
brw-rw---- 1 root disk 1, 2 lut 2 15:06 ram2
brw-rw---- 1 root disk 1, 3 lut 2 15:06 ram3
...
brw-rw---- 1 root disk 8, 0 lut 2 15:06 sda
brw-rw---- 1 root disk 8, 1 lut 2 15:06 sda1
brw-rw---- 1 root disk 8, 2 lut 2 15:06 sda2
brw-rw---- 1 root disk 8, 3 lut 2 15:06 sda3
brw-rw---- 1 root disk 8, 16 lut 2 15:06 sdb
...
crw-rw-rw- 1 root tty 5, 0 lut 2 16:11 tty
crw--w---- 1 root tty 4, 0 lut 2 15:06 tty0
crw-rw---- 1 root tty 4, 1 lut 2 15:07 tty1
crw--w---- 1 root tty 4, 10 lut 2 15:06 tty2
crw--w---- 1 root tty 4, 11 lut 2 15:06 tty3
crw--w---- 1 root tty 4, 12 lut 2 15:06 tty4
...
crw-rw----+ 1 root video 81, 0 lut 2 15:06 video0
...
Patrząc na pierwszą kolumnę możemy zauważyć, że większość z tych plików ma przed uprawnieniami literkę c lub b. Zwykłe pliki i katalogi nie mają żadnej literki w tym miejscu lub d (directories - katalogi). C lub B oznacza mniej więcej tyle, że jest to plik specjalny urządzenia i zapis/odczyt do/z tych plików odbywa się pojedynczymi znakami (char, c) lub całymi blokami (b, block). Urządzenia znakowe to na przykład urządzenia terminala i portu szeregowego, gdzie konieczne jest przesyłanie każdej, nawet małej ilości danych. W przypadku dysków odczyt/zapis odbywa się większymi partiami, dlatego ich struktura wymusza zapis całych bloków danych. Z punktu widzenia procesu piszącego do takiego urządzenia nie ma to większego znaczenia (musi potwierdzić ktoś kto się bardziej zna).
Kolejne istotne dla nas kolumny w plikach z /dev to w powyższym listingu 5 i 6 kolumna. Są to tak zwane liczby major i minor. Pierwsza z nich określa wraz z typem (char, block) rodzaj urządzenia, a co za tym idzie jakiego typu sterownik ma ją obsługiwać. Druga natomiast mówi systemowi operacyjnemu które to jest urządzenie. Patrząc na powyższą listę plików skupmy się na urządzeniach dysków (sda - sdb). Każde z nich ma liczbę major równą 8, natomiast liczby minor określają odpowiednio cały dysk lub konkretną partycję. W przypadku gdy minor jest podzielny przez 16 to jest to numer dysku, a w przeciwnym wypadku numer partycji. Kolejna dość istotna dla plików urządzeń kolumna to właściciel i grupa do jakiej należy urządzenie. Przez odpowiednie manipulowanie nimi można dać dostęp zwykłym użytkownikom lub grupom do danego urządzenia.
Standard systemu Linux (LSB, link) określa w jaki sposób powinny być zorganizowane wszystkie pliki i katalogi w systemie plików. Nic nie stoi jednak na przeszkodzie, abyśmy my sami mogli stworzyć plik wybranego przez nas urządzenia w dowolnym miejscu. Wbrew temu, co mogło by się wydawać, wszystkie pliki urządzeń nie są jakimś abstrakcyjnym bytem w systemie plików, który musi mieć odpowiednio spreparowane dane. Jedyną różnicą są tu wcześniej wspomniane liczby minor, major i typ pliku (char, dev). Poza tym pliki takie nie przechowują żadnych danych i ich istnienie ma na celu jedynie poinformowanie systemu operacyjnego o jakie urządzenie nam chodzi, wywołując na danym pliku funkcję open. Dzięki takiemu założeniu możemy w prosty sposób stworzyć własny plik odpowiadający na przykład urządzeniu pierwszej kamerki (c, major 81, minor 0) wpisując:
$ sudo mknod moja_kamerka c 81 0
$ sudo chown maciek:maciek moja_kamerka
$ ls -lh moja_kamerka
crw-r--r-- 1 maciek maciek 81, 0 lut 2 18:00 moja_kamerka
Oba pliki moja_kamerka i /dev/video0 będą wskazywały na to samo urządzenie. Nic nie stoi też na przeszkodzie, aby plik moja_kamerka miał inne uprawnienia niż /dev/video0, tak jak w powyższym przykładzie.
Przydługa uwaga Warto w tym miejscu nadmienić, że wszystkie funkcje systemowe w Linuksie operują na wartościach liczbowych lub wskaźnikach (które jakby nie patrzeć - też są liczbami). Patrząc na niektóre funkcje rozwiązanie takie może wydawać się dziwne, jednak zagłębiając w mechanizmy systemu dojdziemy do momentu w którym nasz program za pośrednictwem funkcji bibliotecznych wywołuje funkcje udostępniane bezpośrednio przez system. Większość z tych funkcji wywołuje się za pośrednictwem wrapperów z biblioteki standardowej C. Funkcje te (wrappery) muszą się w jakiś sposób komunikować z jądrem Linuksa. Do tego celu przewidziane zostało przerwanie 80h, które obsługuje więszkość operacji jakie można zażądać od systemu. Przed wywołaniem przerwania program (lub funkcja z biblioteki) wpisuje do odpowiednich rejestrów procesora następujące wartości:
- EAX - Numer funkcji systemowej
- EBX - Pierwszy parametr
- ECX - Drugi parametr
- ...
W przypadku architektury 64-bitowej są to odpowiednio RAX, RDI, RSI, ... . Po przejściu do obsługi przerwania Linux sprawdza jakiej funkcji zarządał proces. Odczytując wartość rejestru EAX/RAX może rozpocząć wykonywanie właściwego fragmentu kodu obsługującego dane przerwanie. Wracając do wspomnianych wcześniej parametrów - w rejestrach procesora nie da się przechowywać całych struktur. W związku z tym logicznym jest wykorzystanie w parametrach funkcji systemowych jedynie wartości liczbowych i wskaźników do struktur przechowywanych w pamięci procesu.
Wirtualny system plików /sys
edytujNiektóre sterowniki w Linuksie umożliwiają zmianę ich parametrów poprzez odpowiednie pliki w katalogu /sys. Jest to specjalny system plików, podobny do procfs montowanego zwykle w /proc. Jednak w odróżnieniu od niego pozwala on sterować urządzeniami, a nie samym systemem operacyjnym i jego procesami. Metoda ta dotyczy głównie urządzeń, których nie znajdziemy w katalogu /dev takich jak karty sieciowe lub niektóre urządzenia magistrali PCI, np. kontroler USB. Patrząc na zawartość katalogu /sys można dostrzec podział znajdujących się tam plików na kilka podkatalogów (grup). Najistotniejsze z nich to:
- block - lista urządzeń blokowych, takich jak dyski i inne nośniki pamięci, w tym urządzenia ramdisku
- bus - urządzenia podzielone według magistrali, do której są wpięte, np. usb, i2c...
- class - urządzenia rozdzielone według ich typu
- dev - rozdzielenie urządzeń blokowych i znakowych. Wewnątrz znajdziemy głównie katalogi określające urządzenie po liczbie major i minor.
- fs - obsługa sterowników systemu plików
- module - poszczególne sterowniki systemu (moduły jądra)
W tym rozdziale szczegółowo zajmiemy się katalogiem /sys/class, gdyż z punktu widzenia użytkownika najłatwiej jest znaleźć w nim interesujące nas urządzenie patrząc jedynie na ścieżkę. Przykładowo kamerka internetowa i pliki służące do zmiany jej ustawień powinny znajdować się w katalogu /sys/class/video4linux/video0.
W przypadku, gdybyśmy chcieli wykorzystać sysfs w naszym programie i zaszłaby konieczność modyfikacji parametrów powyższej kamerki, to można wykorzystać katalog /sys/dev/. Należy wykonać wtedy funkcję stat na pliku urządzenia, tj. /dev/video0. Następnie sprawdzamy jakiego typu jest to urządzenie (char, block), jego liczbę minor i major. W przypadku kamerki będzie to urządzenie znakowe o liczbie major 81 i minor 0. Z takimi informacjami łatwo jest już znaleźć strukturę plików odpowiadającą kamerce w katalogu /sys. Będzie to /sys/dev/char/81:0.
W tym przykładzie zajmiemy się jednak nie urządzeniami video lecz konfiguracją kart sieciowych. W Linuksie zmiana niektórych parametrów z poziomu programu jest dość złożona. Mając do dyspozycji dostęp do /sys znacznie upraszczamy sprawę. Załóżmy, że chcemy przełączyć kartę sieciową eth0 w tryb PROMISCOUS, który umożliwia akceptowanie przez nią wszystkich pakietów, również tych nie przeznaczonych dla niej. Operacje taką wykonują zazwyczaj sniffery aby podsłuchać ruch w sieci. Oczywiście można to też zrobić za pomocą polecenia:
# ifconfig eth0 promisc
My jednak uparliśmy się na korzystanie z sysfs. Patrzymy więc co znajduje się w katalogu /sys/class. Interesująco wygląda podkatalog net/, więc zaglądamy do niego. Wewnątrz znajdziemy wszystkie interfejsy sieciowe z naszego systemu. Wybieramy więc katalog /sys/class/net/eth0/ i znów patrzymy co w nim jest:
maciek@dijo /sys/class/net/eth0 $ ls
addr_assign_type device ifalias netdev_group statistics
address dev_id ifindex operstate subsystem
addr_len dormant iflink power tx_queue_len
broadcast duplex link_mode queues type
carrier flags mtu speed uevent
I znów interesująco wygląda plik flags. Zawiera on na ogół wartość zbliżoną do 0x1003. Rozkładając to na sumę potęg liczby 2 dostaniemy (w zapisie szesnastkowym) wartości 0x1 + 0x2 + 0x1000. Czym są powyższe wartości? Tutaj można poprosić o pomoc wujka-[ulubioną wyszukiwarkę] (nie robiąc nikomu kryptoreklamy ;) ) poszukać co oznaczają poszczególne flagi interfejsów. Innym sposobem jest poproszenie o pomoc wujka-grepa. Zwykle różnego rodzaju flagi, przełączniki i wartości znajdziemy w plikach nagłówkowych w /usr/include. Wpisujemy więc:
$ grep -R "PROMISC" /usr/include/
...
/usr/include/net/if.h: IFF_PROMISC = 0x100, /* Receive all packets. */
/usr/include/net/if.h:# define IFF_PROMISC IFF_PROMISC
Jednym z wyników jest flaga IFF_PROMISC z pliku /usr/include/net/if.h. Zaglądając do niego możemy sprawdzić wszystkie flagi ustawione dla interfejsu eth0. Są to:
- IFF_UP = 0x1 - interfejs jest włączony
- IFF_BROADCAST = 0x2 - interfejs akceptuje pakiety przesyłane na adres rozgłoszeniowy
- IFF_MULTICAST = 0x1000 - interfejs akceptuje pakiety typu multicast.
My natomiast chcemy dodać flagę IFF_PROMISC (wartość 0x100) odpowiadającą ptrybowi PROMISCOUS. W tym celu obliczamy nową wartość pliku flag składającą się z sumy ich wartości: 0x1 + 0x2 + 0x100 + 0x1000. Tak obliczoną wartość można wpisać bezpośrednio do pliku /sys/class/net/eth0/flags, bezpośrednio z konsoli lub na przykład funkcją fprintf.
Odczyt i zapis z plików urządzeń w /dev
edytujKolejną możliwością jaką daje Linux na dostęp do urządzeń jest wykorzystanie plików z katalogu /dev. Jest to najprostsza metoda interakcji z urządzeniami z poziomu użytkownika systemu. Aby móc komunikować się z urządzeniem wystarczą trzy funkcje: open, read i write. Dobrą manierą jest też zamykanie wcześniej otwartych plików, więc potrzebujemy oprócz powyższych funkcję close. Metoda komunikacji z urządzeniami przez odczyt/zapis jest używana głównie w przypadku dysków, portów szeregowych/równoległych i wszędzie tam gdzie podstawową funkcją urządzenia jest gromadzenie lub przesyłanie danych. Nie ma tu też znaczenia czy urządzenie jest blokowe czy znakowe.
Aby dokładniej zrozumieć tą metodę spróbujmy odczytać kody aktualnie wciskanych klawiszy. Można to uzyskać przez odczyt danych z pliku /dev/input/eventX. W w tym przypadku jest numerem urządzenia, które naraze będziemy musieli znaleźć eksperymentalnie (o tym w kolejnych rozdziałach). Pliki urządzeń z katalogu /dev/input/ reprezentują poszczególne urządzenia wejścia takie jak myszka, klawiatura, akcelerometr i dodatkowe guziki (np. włącznik). O ile wewnątrz zwykłego procesu można odczytywać wpisywany przez użytkownika tekst, to odczytywanie wciśnięcia klawisza takiego jak SHIFT jest już dużo trudniejsze. Staje się to praktycznie niemożliwe w momencie gdy nasze okno jest nieaktywne. Urządzenia /dev/input reprezentują sterowniki systemu i są pozbawione tych mankamentów. Jedynym problemem pozostają tutaj uprawnienia do plików - ze względów bezpieczeństwa dostęp ma tylko root.
Nasz program zacznijmy od dołączenia niezbędnych plików nagłówkowych:
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <linux/input.h>
Pierwsze dwa zawierają deklaracje standardowych Linuksowych funkcji potrzebnych do operacji na plikach, m.in open i read. Ostatni z plików zawiera definicję struktury input_event. Jest to opis danych generowanych przez urządzenie z katalogu /dev/input, które reprezentują pojedyncze zdarzenie w systemie. Dokładny opis struktury można zobaczyć w pliku /usr/include/linux/event.h:
struct input_event {
struct timeval time;
__u16 type;
__u16 code;
__s32 value;
};
Jak widać w strukturze znajdziemy takie informacje jak znacznik czasu kiedy zdarzenie miało miejsce, jego typ kod oraz wartość. Aby rozpocząć odczyt danych generowanych przez klawiaturę należy otworzyć plik i wywołać funkcję read:
int fd;
struct input_event ev;
fd = open("/dev/input/event5", O_RDONLY);
if (fd < 0) {
perror("Cannot open file");
return 1;
}
...
read(fd, &ev, sizeof(struct input_event));
printf("Event type: %d\n"
"\tcode: %d\n"
"\tValue: %d\n", ev.type, ev.code, ev.value);
Czasami sterowniki urządzeń generują po kilka zdarzeń dla jednej akcji, tak jak na przykład wciśnięcie klawisza na klawiaturze. Należy wtedy sprawdzić pole struktury type czy ma oczekiwaną wartość. Gotowy program zczytujący zdarzenia z pliku /dev/input/event5 (odpowiada mojej klawiaturze) może wyglądać następująco:
#include <linux/input.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
int main(int argc, char *argv[]) {
int fd;
struct input_event ev;
fd = open("/dev/input/event5", O_RDONLY);
if (fd < 0) {
perror("Cannot open file");
return 1;
}
while (1) {
if (read(fd, &ev, sizeof(struct input_event)) < 0) {
perror("Cannot read");
return 1;
}
if (ev.type == EV_KEY) {
printf("Event type: %d\n"
"\tcode: %d\n"
"\tValue: %d\n", ev.type, ev.code, ev.value);
fflush(stdout);
}
}
}
Aplikację kompilujemy poleceniem gcc -o input input.c. Po skompilowaniu i uruchomieniu program powinien wypisywać kody wciskanych klawiszy:
maciek@dijo:~$ sudo ./a.out
Event type: 1
code: 103
Value: 0
Event type: 1
code: 28
Value: 0
Event type: 1
code: 29
Value: 1
Event type: 1
code: 46
Value: 1
Pole value w tym przypadku oznacza czy klawisz został wciśnięty, czy zwolniony. Analogicznie możemy spróbować obsłużyć myszkę i akcelerometr (jest widziany przez system jako urządzenie joystick). Inne typy zdarzeń można znaleźć przeglądając plik nagłówkowy /usr/include/linux/input.h lub w dokumentacji jądra Linuksa, m.in. pod adresem: http://www.kernel.org/doc/Documentation/input/event-codes.txt
ioctl i pliki w /dev
edytujJak można się spodziewać, nie wszystkie operacje na urządzeniach da się wykonać za pomocą tylko i wyłącznie odczytu/zapisu. Dobrym przykładem może być sprawdzenie jakie urządzenie reprezentuje plik /dev/input/eventX. Po nazwie cięzko jest odgadnąć, że plik, który używamy to akurat klawiatura. Linux dostarcza na pozór małą funkcję ioctl, która niesie ze sobą bardzo dużo możliwości:
#include <sys/ioctl.h>
...
int ioctl(int d, int request, ...);
Mając otwarty plik urządzenia, oprócz operacji takich jak omówione w poprzednim punkcie: read, write lub seek nierzadko istnieje potrzeba przekazania lub pobrania od sterownika dokładniejszych informacji dot. urządzenia. Wykonując odczyt nie bylibyśmy w stanie rozróżnić czy dane, które otrzymujemy to kolejne zdarzenie urządzenia wyjściowego czy też może jego nazwa. Jedne i drugie dane można mapować na strukturę input_event lub ciąg binarnych danych. Podobna sytuacja miałaby miejsce w przypadku zapisu - sterownik nie byłby w stanie rozróżnić czy dane, które są przesyłane do niego są informacją sterującą, czy też chodzi o wysłanie właśnie takiego ciągu bajtów do urządzenia. Z pomocą przychodzi tutaj funkcja ioctl, za pomocą której można wywołać funkcje na otwartym pliku urządzenia. Nie są to jednak funkcje podobne do tych z programowania. Chodzi bardziej o wywołanie wcześniej zdefiniowanej w sterowniku operacji, dla której można też przekazać parametr.
Każdy ze sterowników urządzeń ma zdefinoiwany własny zestaw operacji, które można wywołać przez ioctl. Numery tych operacji mogą się powtarzać w urządzeniach różnego typu, jednak w obrębie danego sterownika są unikalne. Tak na przykład operacja 0x01 może w urządzeniu portu szeregowego ustawiać kontrolę parzystości, a 0x02 ustawiać prędkość transmisji. Te same numery funkcji mogą wykonywać zupełnie inne zadania jeśli wykonamy je na otwartym pliku kamerki wideo. Jak widać każda operacja jest definiowana przez unikalną dla sterownika liczbę. Czasami operacje wymuszają podanie w trzecim parametrze funkcji ioctl wskaźnika na obszar pamięci, na której operacja zostanie wykonana. Możemy się spotkać z tym na przykład przy odczytywaniu konfiguracji portu szeregowego. Trzeci parametr ioctl powinien wskazywać na obszar pamięci zarezerwowany przez strukturę przechowującą parametry. W takim wypadku system wypełni całą tą strukturę (definicja dla programu i sterownika w jądrze jest identyczna) odpowiednimi danymi. Niektóre z operacji zdefiniowanych dla ioctl są stworzone w formie makr i przyjmują parametr. Przykładem może być omówione w kolejnym przykładzie odczytywanie nazwy urządzenia z /dev/input/*. Odczytując w standardowy sposób dane podajemy wskaźnik do określonej struktury. Nie jest natomiast określone jak długa jest nazwa urządzenia reprezentowanego przez plik /dev/input/eventX. Podając w tym przypadku wskaźnik do początku tablicy znaków system operacyjny nie będzie wiedział jaki rozmiar ma ona. W tym przypadku makro generuje odpowiedni numer funkcji, która wczytuje odpowiednią ilość danych do wskazanego przez programistę miejsca.
Powracając do poprzedniego przykładu spróbujemy odczytać nazwę urządzenia z jakiego pobieramy zdarzenia. W tym celu należy wywołać funkcję ioctl z parametrami:
- deskryptor otwartego pliku urządzenia
- nazwa operacji, jaką chcemy wykonać - EVIOCGNAME
- wskaźnik na obszar pamięci, gdzie system ma zapisać nazwę urządzenia
int fd;
char name[256];
fd = open("/dev/input/event5", O_RDONLY);
ioctl(fd, EVIOCGNAME(256), (void *)name);
Po wykonaniu tak przygotowanego żądania, sterownik powinien wpisać w podany przez nas obszar pamięci maksymalnie 256 znaków określających jak nazywa się urządzenie. Spójrzmy na kod całego programu:
#include <linux/input.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/ioctl.h>
int main(int argc, char *argv[]) {
int fd;
struct input_event ev;
char name[256];
fd = open("/dev/input/event5", O_RDONLY);
if (fd < 0) {
perror("Cannot open file");
return 1;
}
ioctl(fd, EVIOCGNAME(256), (void *)name);
printf("Device name: %s\n", name);
close(fd);
}
Ponieważ funkcja ioctl należy do standardowych funkcji systemowych, to kompilacja wygląda tak jak w poprzednim przykładzie:
$ gcc -o input input.c
$ sudo ./input
Device name: Jabłko Inc. Jabło Internal Keyboard / Trackpad
Nazwy innych operacji znajdziemy, podobnie jak strukturę input_event w pliku /usr/include/linux.input.h.
Mapowanie pamięci
edytujKolejną, chyba najnowszą metodą dostępu do urządzeń i komunikacji z nimi jest bezpośrednie mapowanie pamięci. Metoda ta ma swój cały podrozdział, ponieważ sam mechanizm związany z zarządzaniem pamięcią jest dość skomplikowany. Aby dokładniej to zrozumieć należy wcześniej poznać jak działa mapowanie pamięci w zwykłych procesach, współdzielenie oraz mapowanie jej na pliki. W podrozdziale będziemy zajmowali się głównie funkcją mmap. W zależności od sposobu w jaki jej używamy jest możliwe alokowanie za jej pomocą dużych obszarów pamięci, tworzenie pamięci współdzielonej pomiędzy procesami, odczyt i zapis plików oraz interakcja z urządzeniami.
Alokowanie pamięci przez mmap
edytujWiększość programistów C i C++ powinna znać dwie główne metody alokowania pamięci (chociaż jak praktyka nauczyciela akademickiego pokazuje - nawet to nie jest pewne). Chodzi o funkcję malloc i operator new. W dużym przybliżeniu obie z nich przesuwają znacznik brk o co najmniej ilość alokowanej pamięci i zwracają wskaźnik do nowo zaalokowanego obszaru. Świadomie piszę tutaj co najmniej - wywołanie systemowe brk trwa dużo dłużej niż samo odwołanie się do funkcji bibliotecznej malloc/new. Alokując od razu większy obszar pamięci biblioteka standardowa unika częstszych wywołań systemowych skracając czas działania funkcji. Dzieje się to kosztem “zjadania” za jednym razem dużego kawałka ramu na dzień dobry - coś za coś.
W niektórych przypadkach, na przykład gdy chcemy zaalokować duży obszar, można wykorzystać do tego funkcję mmap. Nie jest to jej główne przeznaczenie, ale warto wiedzieć, że da się to zrobić. Oto deklaracja funkcji mmap:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
Funkcja podobnie jak malloc zwraca wskaźnik na obszar pamięci. Przyjmuje jednak znacznie więcej parametrów niż malloc i new. Na razie interesująco dla nas wygląda parametr lenght oraz prot. Określają one kolejno rozmiar alokowanej pamięci oraz sposób w jaki będziemy z niej korzystać. W przeciwieństwie do malloc’a i operatora new, w mmap można określić czy pamięć będzie dostępna tylko do zapisu, zapisu i odczytu czy też tylko odczytu. Można też określić czy w danym obszarze jest kod, który można wykonywać. Tak więc przykładowy program, który alokuje pamięć za pomocą mmap wygląda tak:
#include <sys/mman.h>
#include <stdio.h>
int main() {
void *ptr;
ptr = mmap(NULL, 1024, PROT_READ | PROT_WRITE, MAP_ANONYMOUS, 0, 0);
...
}
Czwarty parametr (flags) określa czy dany kawałek pamięci ma być współdzielony z innymi procesami. W naszym przypadku nie chcemy tego i wybraliśmy mapowanie anonimowe (nie reprezentowane przez żaden plik).
Mapowanie pamięci na plik
edytujKolejnym ciekawym zastosowaniem funkcji mmap jest dostęp do plików z systemu plików. Standardowo wykonuje się to przez funkcję open i read lub write. Za każdym razem gdy chcemy dostać się do pliku (zwłaszcza przy odczycie), system sięga do danych zgromadzonych na dysku i odczytuje je nam. Najbardziej popularna metoda jest więc dość wolna przez częste wywołania systemowe. Nieco szybszym rozwiązaniem jest użycie funkcji writev i readv, do których podajemy większe obszary danych do odczytu/zapisu. Za każdym razem jednak są to odwołania do funkcji systemowych. W przypadku “popularnych” w systemie operacyjnym plików, takich jak na przykład libc wiązałoby się to z dużym narzutem czasu podczas odczytu oraz alokowaniem pamięci na każdy tak otwarty plik.
Dzięki funkcji mmap mamy możliwość zmapowania zawartości pliku znajdującego się na dysku bezpośrednio na obszar pamięci operacyjnej. Dzięki temu od strony programu użytkownika dostajemy od funkcji mmap wskaźnik na obszar pamięci, w którym powinniśmy mieć już dostępne dane z naszego pliku.
W praktyce jest to jednak nieco bardziej skomplikowane. System po wywołaniu mmap zwraca wskaźnik na nie zaalokowany obszar pamięci wirtualnej procesu, który też nie jest wpisany nigdzie w TLB. Program próbując odwołać się do zwróconego przez mmap obszaru generuje sygnał PAGE FAULT. W przypadku tak zaalokowanej pamięci system jednak nie zwraca błędu, tylko wstrzymuje działanie programu. Następnie alokuje odpowiedni obszar pamięci dla danego procesu i kopiuje tam odpowiednią ilość danych z pliku. Ta czynność może być wykonywana kilkakrotnie dla różnych stron zaalokowanego przez mmap obszaru. Ponieważ w tym przypadku to system obsługuje transfer danych z pliku do pamięci, to możliwe jest wykorzystanie raz już odczytanego pliku wielokrotnie (o ile nie został w trakcie zmodyfikowany). Wracając do przykładu z libc, dane pliku biblioteki mogą wędrować z dysku do pamięci tylko raz. Każdy kolejny proces odczytujący libc przez mmap libc może w takim wypadku korzystać z tych samych stron pamięci. Musimy w takiej sytuacji jednak zapewnić, aby żaden proces nie zmodyfikował danych zawartych na obszarze pamięci zajmowanym przez libc. Uzyskuje się to wywołując mmap z odpowiednimi flagami (docelowo przed zapisem w takim obszarze powinien chronić procesor). W przypadku, gdy użytkownik (jego program) będzie chciał zmodyfikować dane wczytane w ten sposób, to system powinien wczytać plik na nowo z dysku w inne miejsce pamięci albo utworzyć kopię odpowiednich stron w momencie zapisu (copy-on-write).
Spójrzmy na przykładowy kod otwierający plik i odczytujący zawartość pliku /etc/passwd:
#include <fcntl.h>
#include <stdio.h>
#include <sys/mmap.h>
...
int fd;
char *ptr;
fd = open("/etc/passwd", O_RDONLY);
if (fd < 0) {
perror("Open");
return 1;
}
ptr = mmap(NULL, 1024, PROT_READ, MAP_PRIVATE, fd, 0);
W stosunku do poprzedniego programu zmieniły się tutaj dwa parametry. Zamiast flagi MAP_ANONYMOUS wykorzystujemy MAP_PRIVATE. Mapowanie anonimowe oznaczało, że obszar pamięci nie odwzorowywał żadnego pliku. Mmap robiło poprostu to co malloc. W przypadku mapowania prywatnego obszar jest wypełniany przez system zawartością pliku. Po tym wszystkim można odczytać kawałek danych:
printf("%s\n", ptr);
W przypadku otwarcia pliku do zapisu synchronizacja zmian pamięci z plikiem odbywa się podczas zamykania pliku. Należy o tym pamiętać, gdy inne procesy będą używały zwykłych funkcji read i write. W przypadku zapisu lub modyfikacji pliku mapowanie musi być ustawione jako współdzielone za pomocą flagi MAP_SHARED. Ma to też swoją dużą wadę - podczas awarii systemu lub zaniku zasilania żadne zmiany nie zostaną zapisane w pliku otwartym w ten sposób.
Wszystko może wydawać się zbędną komplikacją przy odczycie i zapisie plików. Spójrzmy na to z innej strony - większość programów do uruchomienia potrzebuje załadować różne biblioteki, najczęściej już wcześniej użyte. Taka obsługa tych plików daje znaczne oszczędności w czasie działania.
Pamięć współdzielona
edytujAby utworzyć kawałek współdzielonej pamięci z wykorzystaniem mmap musimy użyć zamiast zwykłego open funkcję shm_open. Tworzy ona wirtualny plik rezyduujący jedynie w pamięci ram. Plik taki (za pośrednictwem jego deskryptora) możemy wykorzystać do mapowania go na pamięć. Oto jak wygląda funkcja shm_open:
#include <sys/mman.h>
#include <sys/stat.h> /* For mode constants */
#include <fcntl.h> /* For O_* constants */
int shm_open(const char *name, int oflag, mode_t mode);
Działa ona podobnie do zwykłego open, z drobną różnicą. Nazwa pliku jest identyfikatorem (nazwą) obszaru pamięci, a nie ścieżką do rzeczywistego pliku. Spróbujmy zatem stworzyć prosty program, który będzie zwiększał wartość zmiennej int w pamięci współdzielonej:
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
int main() {
int fd;
int *ptr = NULL;
// Open shared memory
fd = shm_open("/shm_test", O_RDWR | O_CREAT, S_IWUSR | S_IRUSR);
if (fd < 0) {
perror("shm_open");
return 1;
}
// Extend shared memory to size of int
ftruncate(fd, sizeof(int));
// Map shared memory to our virtual memory
ptr = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// Increment value keept in shared memory
ptr[0] = ptr[0] + 1;
printf("Zawartosc komorki: %d\n", ptr[0]);
fflush(stdout);
// Unmap memory and close file descriptor
munmap(ptr, sizeof(int));
close(fd);
}
Aby kompilacja i linkowanie programu powiodły się, należy dołączyć bibliotekę rt. Program kompilujemy poleceniem gcc linux_shm.c -o linux_shm -lrt Po uruchomieniu program powinien wypisać na ekran jakąś wartość. Każde kolejne wywołanie go zwiększy tą wartość o jeden (pierwotnej wartości nie ustawiamy, w porządnym rozwiązaniu powinniśmy to zrobić). Co ważne, tak stworzony obszar istnieje w systemie operacyjnym nawet po zakończeniu procesu, aż do restartu komputera lub zwolnienia go funkcją shm_unlink.
Moduły jądra Linuksa
edytujModuły jądra systemu pozwalają rozszerzyć jego funkcjonalność. Może to być obsługa nowych urządzeń, dodanie wsparcia dla nowych systemów plików lub też coś innego, co przyjdzie programiście do głowy. Jednak przed rzuceniem się na pisanie własnych modułów “do wszystkiego” warto wcześniej pomyśleć czy na pewno potrzebujemy takowe pisać. Polityka Linuksa sprowadza się do przenoszenia wszystkich operacji do przestrzeni użytkownika. Przykładowo dzięki plikom /dev/mem i funkcji mmap mamy możliwość napisania programu, który używa rejestrów odpowiedzialnych za porty GPIO bez ingerencji jądra systemu. Czasami jednak trzeba napisać kawałek kodu, który będzie działał w przestrzeni jądra.
Poszukiwanie funkcji, typów danych i pomocy
edytujRozpoczynając swoją “przygodę” z jądrem systemu Linux znalazłem gdzieś w internecie zdanie brzmiące mniejwięcej tak:
“Po co nam dokumentacja jądra systemu, skoro mamy jego źródła i grep’a...”
Trochę zdziwiło mnie ono, jednak po dłuższym obcowaniu z tym systemem sam zacząłem doceniać grepa i podobne narzędzia. Niezastąpioną pomocą, którą szczerze polecam przy bardziej zaawansowanych modułach są książki Linux Device Drivers (w trakcie pisania skryptu wydana jest wersja trzecia) oraz stronę lxr.free-electrons.com - Linx Cross Reference. Można na niej dość szybko znaleźć interesujące nas funkcje oraz typy danych (sekcja identifier search). Dużą zaletą w porównaniu do grepa jest możliwość kliknięcia w dane wywołanie funkcji lub typ danych i wyszukanie jego definicji. Kolejną przydatną rzeczą jest nasza-ulubiona-wyszukiwarka i serwis stackoverflow, na którym można znaleźć dużo już rozwiązanych problemów, na które można się natknąć podczas nauki programowania dla jądra Linuksa.
Pierwszy moduł Hello World
edytujDo napisania pierwszego modułu potrzebujemy trzy rzeczy:
- kompilator, np. gcc
- pliki nagłówkowe aktualnie działającego jądra
- przygotowany plik Makefile i program make
Sam program pisany jako moduł jądra nieco różni się od tych standardowych. Piszemy w czystym C, jednak z małymi zmianami. Pierwszą i chyba najbardziej rzucającą się w oczy różnicą jest brak funkcji main od której zawsze rozpoczynał się program*. W przypadku modułów jądra musimy napisać dwie funkcje odpowiedzialne za przygotowanie modułu podczas załadowania go do pamięci oraz za wyłączenie go:
#include <linux/module.h>
#include <linux/init.h>
int say_hello() {
printk(KERN_INFO "Hello world!\n");
return 0;
}
int say_goodbye() {
printk(KERN_INFO "Bye world!\n");
return 0;
}
Jak widać korzystamy też z innych funkcji do wypisywania komunikatów. Kernel nie ma czegoś takiego jak wszystkie procesy w systemie - standardowego wejścia, wyjścia i wyjścia błędów. Zamiast tego mamy możliwość wysłania komunikatu do logu systemowego za pomocą funkcji printk. Przed właściwym stringiem z tekstem do wyświetlenia powinniśmy dopisać poziom wiadomości. Może to być informacja (KERN_INFO), ostrzeżenie (KERN_ALERT) lub błąd (KERN_ERROR). Aby zobaczyć aktualne wiadomości od kernela należy wpisać w lini poleceń komende dmesg. Wyświetli ona wszystkie komunikaty od czasu uruchomienia systemu. Pierwsza wartość w każdej lini oznacza czas (względem startu systemu) w jakim wiadomość została wypisana.
Jak można zauważyć w przykładzie, nasz prosty moduł ma funkcje nazwane dość dziwnie. Aby kernel wiedział, których funkcji ma użyć do inicjalizacji modułu oraz usunięcia go musimy zarejestrować je:
module_init(say_hello);
module_exit(say_bye);
Teraz pozostaje tylko skompilować moduł. Potrzebujemy do tego plik Makefile:
obj-m += hello.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
Kompilujemy przez make oraz utworzony plik .ko ładujemy do pamięci jądra:
sudo insmod hello.ko
Po załadowaniu go, w wyjściu polecenia dmesg powinniśmy zobaczyć naszą wiadomość wypisaną przez printk.
Parametry dla modułów
edytujCzasami potrzeba aby podczas ładowania modułu do pamięci przekazać też jakieś parametry. W tym celu można zadeklarować zmienną na początku pliku modułu i wykorzystać module_param:
int zmienna;
module_param(zmienna, int, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
Pierwsze dwa parametry określają zmienną, która będzie parametrem oraz jej typ. Ostatni parametr określa kto może modyfikować te wartości. Przypisując wartości tylko podczas ładowania modułu nie jest to istotne. Można jednak zmieniać wartości parametrów przez wpisy w katalogu /sys/module/<nazwa modułu>/parameters/<nazwa parametru>. Podane na końcu uprawnienia mają wpływ na uprawnienia plików w tym katalogu. Przez to można dać możliwość odczytywania wartości danego parametru zwykłym użytkownikom, a jego modyfikacji tylko rootowi.
Aby parametr miał też jakiś opis można wykorzystać makro MODULE_PARAM_DESC:
MODULE_PARM_DESC(zmienna, "Opis parametru zmienna");
Następnie poleceniem modinfo możemy sprawdzić listę parametrów dostępnych dla danego pliku z modułem:
# modinfo task.ko
filename: /home/dijo/C++/kernel/hello/hello.ko
license: GPL
depends:
vermagic: 3.2.0-4-amd64 SMP mod_unload modversions
parm: zmienna:Opis parametru zmienna (int)
Gotowy kod modułu powinien wyglądać mniejwięcej tak:
#include <linux/module.h>
#include <linux/init.h>
int zmienna;
module_param(zmienna, int, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
MODULE_PARM_DESC(zmienna, "Opis parametru zmienna");
// Initialize module
int say_hello() {
printk(KERN_INFO "Hello world! Wartosc zmiennej: %d\n", zmienna);
return 0;
}
// Cleanup module
int say_goodbye() {
printk(KERN_INFO "Bye world!\n");
return 0;
}
module_init(say_hello);
module_exit(say_bye);
Tworzenie wpisu w /proc
edytujCzęść informacji z tego podrozdziału dotyczy starszych wersji kernela (do 3.10). W późniejszych wersjach zmieniono API. Dokładne zmiany są opisane poniżej przykładu.
Potrafimy zatem przygotować prosty moduł, który... nie robi nic :) Co możemy zrobić dalej? Na przykład możemy chcieć dołożyć jakąś komunikację z naszym modułem. Pierwszą opcją jest obsłużenie dedykowanego urządzenia, które obsługiwane będzie przez nasz moduł (o tym kolejne podrozdziały). Drugą i nieco prostszą opcją jest stworzenie pliku w katalogu /proc, przez który będziemy mogli komunikować się z naszym modułem lub odczytywać od niego dane. Na początku będziemy potrzebowali dorzucić kilka nowych plików nagłówkowych:
#include <linux/proc_fs.h>
#include <asm/uaccess.h>
W pliku proc_fs.h znajdziemy wszystkie “zabawki” niezbędne do manipulowania /proc. Drugi plik uaccess.h pozwoli nam na dostęp do pamięci procesów użytkownika, które aktualnie chcą pisać/czytać nasz plik w /proc. Dla czego do tego potrzebujemy dedykowanych funkcji, napisze później. Na razie wystarczy pamiętać, że kernel nie może operować na pamięci procesów użytkownika w taki sam sposób jak na swojej.
Zatem co nam więcej trzeba? Na pewno podczas ładowania kernela (lub w odpowiadającym nam momencie) musimy poinformować kernel o tym, że chcemy utworzyć plik. Zróbmy to funkcją create_proc_entry, która rejestruje takie urządzenie i zwraca strukturę opisującą je:
struct proc_dir_entry *plik_proc;
plik_proc = create_proc_entry("moj_wspanialy_plik", 0644, NULL);
Pierwszym parametrem funkcji jest nazwa pliku, jaki chcemy stworzyć. Kolejny to domyślne uprawnienia, z jakimi pojawi się ten plik w katalogu /proc i nadrzędny element. Może się to przydać gdy będziemy tworzyć cały katalog w /proc i dopiero w nim pojedyncze pliki.
Kolejnym krokiem jest wypełnienie odpowiednich pól w strukturze zwróconej przez create_proc_entry:
plik_proc->read_proc = odczyt;
plik_proc->write_proc = zapis;
plik_proc->mode = S_IFREG | S_IRUGO;
plik_proc->uid = 0;
plik_proc->gid = 0;
plik_proc->size = 37;
Pierwsze dwa pola z powyższych są wskaźnikami na funkcje obsługujące odczyt i zapis z naszego pliku. Dalej określamy czym będzie ten plik. W powyższym przypadku jest to zwykły plik (S_IFREG). Można w tym miejscu podać na przykład katalog. Dalej mamy odpowiedni id właściciela, grupy oraz widziany w systemie plików rozmiar naszego pliku. Warto tu na chwilę skupić swoją uwagę - pliki wirtualne (urządzeń, /proc, /sys) są obsługiwane przez moduły. Często będzie to nasz kod. W takim przypadku to nasz moduł będzie generować dane “widoczne wewnątrz pliku”. Tak więc wpisywanie tutaj rozmiaru jest jak najbardziej sensowne, ponieważ to my musimy później zwracać taką a nie inną ilość danych. Wszystkie powyższe parametry można później, w trakcie “życia” modułu modyfikować. Warto więc, aby struktura proc_entry nie była tylko lokalna.
Na koniec pozostało nam dopisanie dwóch funkcji obsługujących odczyt i zapis z tego pliku. Należy w tym celu popatrzeć jak wygląda struktura proc_entry, a dokładniej jej pola wskazujące na te funkcje. Mamy więc do zaimplementowania dwie metody:
int proc_read(char *buffer,
char **buffer_location,
off_t offset,
int buffer_length,
int *eof,
void *data);
int proc_write(struct file *plik,
const char *buffer,
unsigned long int count,
void *data);
Zaimplementujmy tylko funkcję proc_read, która będzie odpowiedzialna za “pokazanie” programom użytkownika zawartości danego pliku z /proc. Jej parametry to po kolei:
- buffer - miejsce w pamięci kernela, do którego należy wpisać zwracane przez funckję dane
- offset - pozycja w pliku, z jakiej program użytkownika odczytuje
- buffer_length - rozmiar bufora, do którego wpisywane będą dane
- eof - znacznik końca pliku. Po odczytaniu wszystkich danych można ustawić jego wartość na 1
Parametry buffer_location oraz data nie zawsze są używane. Aby zapewnić najprostszą obsługę takiego pliku, wystarczy skopiować jakiś string do adresu wskazywanego przez parametr buffer. Należy przy tym pamiętać, aby nie przekroczyć wartości buffer_length.
char my_string[] = "Ala ma kota"; int odczyt(char *buffer, char **buffer_location, off_t offset, int buffer_length, int *eof, void *data) { printk(KERN_ALERT "Odczyt z proc\n"); if (offset > 0) { return 0; } else if (buffer_length > strlen(my_string)) { int bytes; bytes = sprintf(buffer, strlen(my_string), my_string); *eof = 1; return bytes; } else { return 0; } }
Po połączeniu wszystkich powyższych rzeczy, kod obsługujący odczyt i zapis z pliku w /proc powinien wyglądać następująco:
#include<linux/init.h>
#include<linux/module.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/proc_fs.h>
#include <asm/uaccess.h>
MODULE_LICENSE("GPL");
struct proc_dir_entry *plik_proc;
char text[512] = "Ala ma kota";
int odczyt(char *buffer,
char **buffer_location,
off_t offset,
int buffer_length,
int *eof,
void *data)
{
if (offset > 0) {
return 0;
} else if (strlen(text) < buffer) {
int bytes;
bytes = snprintf(buffer, strlen(text), text);
*eof = 1;
return bytes;
} else {
return 0;
}
}
static int proc_init(void)
{
plik_proc = create_proc_entry("my_proc_entry", 0444, NULL);
if (plik_proc == NULL) {
printk(KERN_ALERT "Failed to create proc entry\n");
remove_proc_entry("my_proc_entry", NULL);
return -ENOMEM;
}
plik_proc->read_proc = odczyt;
plik_proc->mode = S_IFREG | S_IRUGO;
plik_proc->uid = 0;
plik_proc->gid = 0;
plik_proc->size = 37;
printk(KERN_ALERT "Procentry created\n");
return 0;
}
static int proc_exit(void)
{
remove_proc_entry("my_proc_entry", NULL);
return 0;
}
module_init(proc_init);
module_exit(proc_exit);
Po skompilowaniu i załadowaniu modułu, powinniśmy zobaczyć nowy plik w katalogu /proc. Po wykonaniu polecenia cat na nim, wyświetli się tekst skopiowany przez funkcję odczyt.
Od wersji kernela 3.10 zamiast create_proc_entry używana jest funkcja o nazwie proc_create, która przyjmuje jako dodatkowy parametr strukturę file_operations opisaną przy obsłudze plików urządzeń. Dodatkowo struktura proc_entry zamiast pól proc_read i proc_write zawiera wskaźnik na strukturę file_operations o nazwie fops, który przechowuje wszystkie funkcje obsługujące plik (dodatkowo jest to open, release - zamknięcie pliku, mmap itd.). Dokładne implementowanie tej struktury jest opisane w późniejszych rozdziałach przy okazji obsługi plików urządzeń.
Moduł obsługujący plik urządzenia (/dev/...)
edytujJednym z częstszych zastosowań modułów w jądrze systemu jest obsługa urządzeń. Na ogół odbywa się to przez pliki specjalne z katalogu /dev. Jak się do nich dostać można było przeczytać w poprzednim rozdziale, natomiast w tym miejscu poznamy co się dzieje “od drugiej strony”, czyli od strony systemu operacyjnego. Operacje na plikach od strony użytkownika (np. open, read, ioctl) mogą wydawać się troche skomplikowane i zagmatwane. Warto jednak uświadomić sobie, że Linux udostępnia jednakowe funkcje do obsługi zarówno zwykłych plików, plików urządzeń jak i gniazd sieciowych (oraz wielu innych). Patrząc na ich obsługę od strony systemu operacyjnego warto być świadomym, że każdy deskryptor, nawet do zwykłego pliku tekstowego, może być obsługiwany w odmienny sposób, innymi funkcjami. Weźmy na przykład deskryptory takich plików:
/dev/video0 (kamerka internetowa)
/dev/sda (pierwszy dysk twardy)
/home/dijo/.bashrc (partycja /home, xfs)
/etc/passwd (partycja /, ext3)
socket: localhost:22 (gniazdo sieciowe)
Na każdym z nich możemy wywołać funkcje read. Co jednak zrobi w takim przypadku system? Powinien przekazać obsługę takiej funkcji do odpowiedniego sterownika. W przypadku dwóch pierwszych plików urządzeń, powinno to być oczywiste - obsługę konkretnych urządzeń rozpoznaje się przez liczbę major oraz typ urządzenia. W przypadku plików tekstowych na ogół nie myślimy o tym. W rzeczywistości żądanie odczytu takiego pliku trafia w końcu do odpowiedniego modułu jądra, odpowiedzialnego na przykład za system plików ext3 dla pliku /etc/passwd. Gdy odczytujemy dane z gniazda sieciowego, system powinien przekazać takie żądanie do funkcji obsługującej takie gniazdo, dla odpowiedniego protokołu.
Jeżeli chcemy dodać w module obsługę własnego pliku urządzenia do systemu, musimy też poinformować jądro o tym fakcie. Przede wszystkim musimy zdecydować jaki zbiór funkcji (operacji na pliku) będzie nam potrzebny do jego obsługi i zaimplementować je. Dostępne funkcje można sprawdzić w strukturze file_operations, w pliku nagłówkowym linux/fs.h:
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t,
loff_t *);
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned
long, loff_t);
ssize_t (*aio_write) (struct kiocb *, const struct iovec *,
unsigned long, loff_t);
int (*readdir) (struct file *, void *, filldir_t);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned
long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*aio_fsync) (struct kiocb *, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t,
loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long,
unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *,
loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct
pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **);
long (*fallocate)(struct file *file, int mode, loff_t offset,
loff_t len);
int (*show_fdinfo)(struct seq_file *m, struct file *f);
};
Na początku zaimplementujmy funkcje odpowiedzialne za otwarcie i zamknięcie pliku. Standardowo nie muszą one robić niczego poza zwróceniem wartości zero. Funkcje jednak muszą zostać zaimplementowane, ponieważ mogą zaistnieć sytuacje, gdy musimy wykonać jakąś akcje już podczas otwarcia takiego pliku i jest to miejsce właśnie na to. Taką akcją może być na przykład samo zalogowanie o fakcie otwarcia pliku urządzenia lub ograniczenie liczby otwartych plików danego urządzenia do jednego.
Patrząc na strukturę file_operations zdefiniujmy dwie funkcje zgodnie z wskaźnikami open i release:
int mydev_open(struct inode *inode, struct file *filp) {
printk(KERN_INFO "Somebody opened file\n");
return 0;
}
int mydev_release(struct inode *inode, struct file *filp) {
printk(KERN_INFO "Somebody closed file\n");
return 0;
}
Następnie musimy stworzyć obiekt struktury file_operations, w którym przypisane zostaną odpowiednim polom wskaźniki do powyższych funkcji:
struct file_operations mydev_fops {
.open = mydev_open,
.release = mydev_release
};
Jednak samo stworzenie i wypełnienie struktury (która jest znana tylko lokalnie, w naszym module) nie wystarczy do poinformowania kernela o obsłudze nowego typu pliku. Musimy w jakiś sposób poinformować system o nowym typie. Służy do tego funkcja register_chrdev, której przekazujemy w parametrach informację o numerze major, nazwie sterownika oraz listę funkcji do obsługi danego pliku w formie wskaźnika do struktury file_operations:
int register_chrdev(123, "moje_urzadzenie", &mydev_fops);
Liczba major określa jaki numer urządzenia będzie skojarzony z naszym sterownikiem. Można też w tym miejscu podać wartość 0, wtedy system automatycznie przydzieli wolną liczbę major. Listę użytych majorant można sprawdzić wyświetlając zawartość pliku /proc/devices. Warto też wspomnieć, że liczba minor, która też określa urządzenie, jest informacją jedynie dla sterownika i nie musi być w ogóle brana pod uwagę. Jeśli jednak zajdzie taka potrzeba, to należy zatroszczyć się o to na własną rękę, wewnątrz pisanego modułu. Kolejny parametr funkcji register_chrdev to nazwa naszego urządzenia. Wybierając ją mamy tutaj dowolność, jednak lepiej aby sensownie odzwierciedlała to, do czego dane urządzenie służy. Ostatni parametr to wskaźnik do struktury file_operations. Dzięki niemu jądro będzie wiedziało jak obsłużyć podstawowe operacje na pliku, który będziemy obsługiwać.
Po zarejestrowaniu obsługi naszego urządzenia znakowego możemy sprawdzić jego numer major (jeśli podaliśmy jako pierwszy parametr 0) w pliku /proc/devices. Powinna się tam pokazać nazwa naszego urządzenia i jego liczba major.
Obsługa read i write
edytujPierwszą rzeczą, którą chciało by się zrobić z plikiem po otwarciu to odczytanie lub zapisanie z/do niego danych. Oczywiście kernel musi potrafić obsługiwać takie operacje i to za pomocą naszych funkcji. Powinniśmy zatem przygotować kolejne (po open i release) funkcje, które przypiszemy do odpowiednich wskaźników w strukturze file_operations. Funkcje te powinny mieć następujące deklaracje:
ssize_t (*read) (struct file *filp, char __user *ptr, size_t size, loff_t
*offset);
ssize_t (*write) (struct file *filp, const char __user *ptr, size_t size, loff_t
*offset);
czyli dokładnie takie, jak w strukturze file_operations. Parametry kolejno oznaczają:
- Wskaźnik na strukturę opisującą plik, na którym wykonywana jest operacja odczytu/zapisu
- Wskaźnik na obszar danych w programie użytkownika z/do którego kopiowane będą dane
- Maksymalny rozmiar danych jakie możemy przekopiować
- Przesunięcie wewnątrz pliku (nie każdy odczyt/zapis jest wykonywany od początku pliku!)
Należy zwrócić szczególną uwagę na wskaźnik na obszar pamięci w programie użytkownika. Na ogół programiści alokują jakiś bufor w pamięci, do którego funkcja read wpisuje odczytane z pliku dane. W tym przypadku to my “jesteśmy” tą funkcją (read) i my musimy wkopiować użytkownikowi dane z pliku, który obsługujemy. Należy zatem pamiętać, aby pod żadnym pozorem nie przekroczyć maksymalnej ilości danych i nie nadpisać pamięci za buforem. Druga ważna i nie zawsze oczywista rzecz - dane, które program użytkownika widzi “w pliku” wcale nie muszą być nigdzie zapisane. Podobnie jak w przypadku plików w /proc, może to być fragment naszego bufora w pamięci. Użytkownik zobaczy wtedy jako zawartość pliku to, co mu wkopiujemy podczas wywołania funkcji read. Analogicznie jest z obsługą zapisu do pliku. To, czy dane faktycznie zostaną zapisane nie ma znaczenia. Równie dobrze możemy zrobić z nimi cokolwiek innego. Wyobraźmy sobie, że implementujemy sterownik, z którym komunikacja następuje przez zapis danych XML do pliku urządzenia. Można? Można (tylko po co...)! W takim wypadku wystarczyłoby, aby funkcja write, obsługująca zapis parsowała te dane i wykonywała jakąś akcję. Nic więcej.
Wróćmy do obsługi funkcji read i write. Wskaźnik na obszar pamięci użytkownika należy obsługiwać w specjalny sposób (o tym czemu, później). Nie możemy go potraktować jak zwykłego wskaźnika. Z tego powodu potrzebujemy użyć funkcje copy_to_user i copy_from_user. Przykładowa funkcja obsługująca odczyt z pliku (którego zawartością jest bufor w pamięci):
int mydev_read(struct file *filp, char __user *ptr, size_t size, loff_t
*offset) {
char str[] = "Ala ma kota";
if (*offset == 0) {
copy_to_user(ptr, str, strlen(str));
// update offset
*offset = *offset + strlen(str);
return strlen(str);
} else {
return 0;
}
}
Każde wywołanie funkcji obsługującej odczyt powinno zwrócić ilość odczytanych bajtów lub 0, w przypadku gdy więcej nie ma. Należy też pamiętać o zmodyfikowaniu aktualnej pozycji w pliku o ilość odczytanych danych. Połączmy wszystko powyższe:
#include <linux/slab.h>
#include <linux/init.h>
#include <linux/module.h>
#include <asm/uaccess.h>
#include <linux/types.h>
#include <linux/fs.h>
#include <linux/mm.h>
#include <linux/fs.h>
#include <linux/debugfs.h>
#include <linux/kernel.h>
#include <linux/string.h>
MODULE_LICENSE("GPL");
// Device operations
int mydev_open(struct inode *inode, struct file *filp) {
printk(KERN_ALERT"Somebody opened file\n");
return 0;
}
int mydev_release(struct inode *inode, struct file *filp) {
printk(KERN_ALERT"Somebody closed file\n");
return 0;
}
int mydev_read(struct file *filp, char __user *ptr, size_t size, loff_t
*offset) {
char str[] = "Ala ma kota\n";
if (*offset == 0) {
copy_to_user(ptr, str, strlen(str));
// update offset
*offset = *offset + strlen(str);
return strlen(str);
} else {
return 0;
}
}
struct file_operations mydev_ops = {
open: mydev_open,
release: mydev_release,
read: mydev_read
};
static int device_init(void)
{
int r = register_chrdev(60, "mydev", &mydev_ops);
if (r < 0) {
printk(KERN_ALERT"Error - register device\n");
}
return 0;
}
static int device_exit(void)
{
unregister_chrdev(60, "mydev");
return 0;
}
module_init(device_init);
module_exit(device_exit);
Na koniec należałoby jeszcze stworzyć plik urządzenia znakowego o wybranej przez nas liczbie major:
# mknod my_dev c 60 0
Następnie możemy wyświetlić cat’em zawartość pliku urządzenia:
# cat my_dev Ala ma kota #
To co widzimy jako zawartosć pliku to dane skopiowane przez funkcję mydev_read z naszej tablicy znaków.
Mapowanie pamięci od strony kernela
edytujKolejną rzeczą, która coraz częciej jest wykorzystywana przez sterowniki to mapowanie pamięci programu użytkownika na obszar pamięci sterownika lub rejestry urządzeń. Jak wygląda to od strony zwykłego programu można było zobaczyć w rozdziale 3.2. Tutaj, podobnie jak w poprzednio, poznamy obsługę tej operacji od strony jądra systemu Linux. Organizacja pamięci nie zmienia się, ponieważ działamy na tej samej maszynie co proces. Jednak sam kod i pamięć widziane operacyjna obsługiwana od strony jądra znacznie się różnią.
Typy pamięci, wskaźniki i mmu
edytujDziałając wewnątrz jądra możemy mieć styczność z co najmniej czterema typami wskaźników. Dzieje się tak przez wykorzystanie Memory Management Unit (MMU), czyli jednostki procesora odpowiedzialnej za sprzętowe tłumaczenie adresów pamięci podczas dostępu do niej. Z MMU nierozłącznie związane jest stronnicowanie pamięci, które najczęściej kojarzone jest z przestrzenią wymiany - swap. Cała pamięć operacyjna podzielona jest na małe bloczki (4KB lub 4MB), które mogą być w razie potrzeby przenoszone z pamięci RAM na dysk twardy. Jednak jest to tylko wierzchołek góry lodowej - możliwości idące za wykorzystaniem przez nowe systemy operacyjne MMU są ogromne.
Standardowo każdy proces użytkownika ma swoją pamięć, po której może się poruszać. Rzadko kiedy jednak programista musi wiedzieć jak ta pamięć jest zorganizowana, a tym bardziej gdzie się ona mieści w fizycznej pamięci RAM. Układ pamięci dla pojedynczego procesu dobrze obrazuje poniższa grafika:
[[Image:./graphics/linux-process-memory.png|image]]
O ile pierwsze dwa fragmenty pamięci nie wymagają chyba obszerniejszego opisu, to warto przyjrzeć się trzeciemu blokowi pamięci procesu. Wbrew temu, co mogło by się wydawać, wywołując operator new lub funkcję malloc nie zawsze prosimy system o przydzielenie nowego obszaru pamięci do naszego procesu. Obie funkcje są obsługiwane przez bibliotekę libc. Przykładowo, żądając od systemu zaalokowania 1KB pamięci, funkcje te mogą zarządać na raz nawet kilka megabajtów. Dzięki temu, przy kolejnym wywołaniu tych funkcji funkcja malloc lub new nie poporosi systemu o kolejny kawałek, tylko zwróci kolejny fragment z otrzymanego wcześniej obszaru pamięci. Warto też mieć na uwadze to, że obszar pamięci zarządzany w ten sposób przez new i malloc jest zawsze ciągły i ograniczony przez znacznik brk. Znacznik ten mówi systemowi operacyjnemu ile faktycznie program zajmuje pamięci. Wiąże się to oczywiście z powstawaniem niewykorzystywanych dziur, jednak przy obecnych ilościach dostępnego RAM’u zostało to pominięte.
Cały obszar zaznaczony na powyższym schemacie obejmuje około 3GB dla architektury 32-bitowej. Gdybyśmy zatem chcieli przydzielić tyle każdemu procesowi, to już prawdopodobnie dla drugiego zabrakoby ramu. Z pomocą przychodzi jednak wspomniane wcześniej stronnicowanie pamięci. Cały zaalokowany dla procesu obszar od początku, aż do znacznika brk jest odwzorowany w fizyczną pamięć RAM (lub przeniesiony do przestrzeni wymiany):
[[Image:./graphics/linux-process-paging.png|image]]
Nieużywana pamięć nie jest w takim wypadku mapowana w fizyczną. Proces odwołując się do komórki własnej pamięci wymusza na procesorze (i jego jednostce MMU) przetłumaczenie jego lokalnego adresu na fizyczną komórkę pamięci RAM. Procesor wykorzystuje do tego TLB (Translation Lookaside Buffer) - tablicę która przechowują wszystkie mapowania dla procesów w systemie operacyjnym (w praktyce jest to wiele mniejszych katalogów z wpisami ). Cały ten proces jest transparentny dla programów użytkownika i mało kiedy trzeba mieć świadomość, że takie coś jest wykonywane. Przenosząc się jednak do obszaru jądra i pisząc sterowniki warto zdawać sobie z tego sprawę. Przede wszystkim otrzymując w naszym sterowniku jakiś wskaźnik musimy wiedzieć o nim nieco więcej, niż gdzie wskazuje i na jaki typ danych. Dodatkowymi informacjami w tym przypadku są:
- rodzaj przestrzeni adresowej - wskaźnik używany w procesie, przekazany na przykład do funkcji write będzie wskazywał na zupełnie co innego w przestrzeni jądra- w kernelu operuje się z pominięciem stronnicowania
- jeśli wskaźnik pochodzi od procesu użytkownika, to musimy wiedzieć o który proces chodziło. Wskaźniki o wskazujące na ten sam adres w różnych procesach będą na ogół przetłumaczone przez MMU na różne strony pamięci fizycznej.
Całe to zamieszanie daje możliwość całkiem sporego “poczarowania” przy TLB. Mamy możliwość stworzenia dzięki temu czegoś takiego jak pamięć współdzielona między procesami lub mapowania rejestrów lub pamięci sterującej urządzeniami na obszary pamięci procesu użytkownika. Wykonuje się to przez stworzenie w TLB wpisów wskazujących na te same strony pamięci dla różnych procesów (współdzielenie pamięci) lub przez zmapowanie odpowiedniego obszaru fizycznej pamięci (np. zawierającego rejestr) na pamięć użytkownika.
grafika?
Rodzaje pamięci
edytujTak więc w obrębie pamięci RAM możemy wyróżnić co najmniej cztery rodzaje adresacji:
- Wirtualna przestrzeń procesu (User Space) - adresy rozpoznawane wewnątrz procesu użytkownika, to z nich korzystamy najczęściej pisząc zwykłe programy. Dają złudzenie ciągłego obszaru pamięci, mimo fragmentacji
- Wirtualna przestrzeń dla jądra (Kernel Space) - ciągły obszar pamięci dostępny dla kernela, działa podobnie jak User Space
- Adresy fizyczne - określają bezpośrednie położenie danych w pamięci RAM. Domyślnie takim typem adresów dysponujemy pisząc sterowniki w jądrze Linuksa
- Adresy logiczne - w większości architektur są one mapowane 1:1 na adresy fizyczne
Jak obsłużyć funkcję mmap?
edytujW poprzednich rozdziałach można było zobaczyć jak wykonać mapowanie pliku lub urządzenia od strony użytkownika. W przypadku obsługi takiej operacji od strony systemu operacyjnego, w najprostszym wypadku musimy wymusić dodanie nowego wpisu do TLB. Służy do tego m.in. funckja remap_pfn_range. Jednocześnie, aby system potrafił obsłużyć wywołanie mmap na naszym pliku urządzenia, to musimy taką obsługę zapewnić ze strony naszego modułu. Oprócz standardowych open i release będziemy potrzebować trzecią funkcję - mmap. Oczywiście zgodnie z typem wskaźnika ze struktury file_operations. Przypomnijmy, jak wygląda nagłówek funkcji mmap dla programów działających w przestrzeni użytkownika:
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
W skrócie: podajemy adres jaki chcemy zmapować (lub NULL), długość mapowania, opcje dostępu i współdzielenia (prot i flags) oraz plik, którego to dotyczy. Obsługa takiego żądania wbrew pozorom jest znacznie prostsza. System automatycznie wydziela z pamięci procesu jednolity obszar pamięci opisany przez strukturę vm_area_struct. Na naszych barkach spoczywa jedynie obowiązek poprawnego przypisania tego obszaru do fizycznej pamięci RAM. Możemy wykorzystać do tego funkcję remap_pfn_range, która odpowiednio ustawia wpisy TLB tłumaczące adresy wirtualne programu użytkownika na podane przez nas fizyczne strony pamięci.
Do modułu z poprzedniego przykładu możemy dodać kolejną funkcję:
char *mydev_buffer;
int mydev_mmap(struct file *filp, struct vm_area_struct *vma) {
if ((vma->vm_end - vma->vm_start) > BUF_SIZE) {
printk(KERN_ERR "Error: sizes don't match (buffer size = %d, requested
size = %lu)\n", BUF_SIZE, vma->vm_end - vma->vm_start);
return -EAGAIN;
}
int ret = remap_pfn_range(vma, // VMa
vma->vm_start, // Address in user memory
virt_to_phys(mydev_buffer) >> PAGE_SHIFT,
vma->vm_end - vma->vm_start, // Size
vma->vm_page_prot);
if (ret != 0) {
printk(KERN_ERR "Error in calling remap_pfn_range: returned %d\n", ret);
return -EAGAIN;
}
return 0;
}
// Somewhere in module initialization...
...
mydev_buffer = kmalloc(BUF_SIZE, GFP_KERNEL);
...
Funkcja remap_pfn_range przyjmuje kolejno parametry:
- wskaźnik na obszar pamięci, który ma zostać zmapowany (wskaźnik na vm_area_struct)
- początkowy adres, który mapujemy w pamięci użytkownika (można pobrać to z vm_area_struct)
- numer strony pamięci (pfn, Page Frame Number) w fizycznej pamięci. Można to otrzymać wywołując funkcję virt_to_phys, która zwraca adres w fizycznej pamięci RAM. Należy wziąć z niego pierwsze n bajtów, na przykład wykonując przesunięcie bitowe o PAGE_SHIFT. Zmienna ta określa ile bitów z adresu każdego wskaźnika jest przeznaczane na adres strony pamięci, a ile na przesunięcie wewnątrz tej strony
- rozmiar mapowania będący wielokrotnością rozmiaru strony pamięci (PAGE_SIZE)
- flagi dot. ochrony tego obszaru pamięci, podobnie jak te podawane w mmap
Przez odpowiednie wykorzystanie mapowania pamięci programista ma możliwość udostępnienia programom użytkownika fragmentów pamięci odpowiedzialnych na przykład za obsługę dostępu do karty grafiki lub innych urządzeń. W ten sposób programy użytkownika mogą bezpośrednio “rozmawiać” ze sprzętem, z pominięciem obsługi systemu.
Wyobraźmy sobie taką sytuację: mamy jakiś mikrokomputer, na którym możemy uruchomić Linuksa. Niech jakiś specyficzny obszar pamięci będzie rejestrami odpowiedzialnymi za obsługę portu GPIO. Mamy w takiej sytuacji co najmniej trzy możliwości obsłużenia takiego portu:
- Zmapowanie interesującego nas fragmentu pliku /dev/mem (odpowiada on całej pamięci fizycznej w komputerze) na naszą pamięć i pisanie po niej, według tego, co wymaga urządzenie
- Napisanie modułu jądra, który obsługuje to urządzenie i udostępnia API do sterowania nim (na przykład przez plik w /dev)
- Napisanie modułu jądra, który obsługuje urządzenie w /dev i pozwala wykonać mmap mapując odpowiedni fragment pamięci RAM na nasz plik i pamięć procesu użytkownika
Z powyższych opcji pierwsza wymaga dostępu jako root (z wszystkimi przywilejami) do pliku zaiwerającego całą pamięć ram. Z tego powodu nie jest to zbyt bezpieczne. Drugie rozwiązanie może powodować niestabilność systemu jeśli tylko popełnimy jakieś błędy w funkcjach obsługujących nasz moduł. Ostatnie rozwiązanie z mapowaniem odpowiednich fragmentów pliku wydaje się najlepsze. Nie potrzebujemy tutaj dostępu do konta root’a do otrzymania potrzebnego kawałka pamięci RAM oraz nie przenosimy całej logiki do części działającej w jądrze. Zostaje tam tylko to, co niezbędne.
Semafory i mutexy
edytujW 90% programów jakie pisałem nie miałem potrzeby używania mutexów, semaforów lub innych podobnych mechanizmów wspomagających programowanie współbieżne. Wyjątkiem były zadania wykonywane na przedmiocie Systemy Operacyjne oraz obsługa transakcji w bazie danych. Pierwsze jakoś się zrobiło i zapomniało, a drugie koniec końców zrobił ktoś inny :) Dla przypomnienia, cały problem polega na tym, aby dwie funkcje wykonywujące się równolegle potrafiły “bez kolizyjnie” pracować na jednej masznie. Może tu chodzić o dostęp do jakiś zasobów lub korzystanie z pamięci współdzielonej. Wyobraźmy sobie sytuację, w której dwa programy trzymają jakiś licznik (zmienną int) w pamięci współdzielonej. Nagle obu zachciało się zwiększyć taki licznik. W kodzie jest to operacja jednolinijkowa: zmienna++. W praktyce jednak procesor musi wykonać co najmniej dwie operacje: odczytać wartość komórki pamięci i zwiększyć ją o jeden. Wydawało by się, że jeżeli wykonają tą operację dwa procesy (zmienna++), to finalnie zmienna będzie miała wartość o 2 większą niż początkowo. W 99% przypadków to przejdzie. W testach też przejdzie. Ale oddając program użytkownikowi lub pokazując go na zaliczenie na 100% będziemy mieć to szczęście, że sie wysypie w takim miejscu ;) Jeśli taką operacje wykonują dwa wątki lub procesy i jeden z nich w połowie zostanie wywłaszczony, to wynik może być następujący:
- Proces A odczytuje wartosć zmiennej (zmienna = 1)
- Proces A jest wywłaszczany i wykonywanie rozpoczyna proces B
- Proces B odczytuje wartość zmiennej (zmienna = 1)
- Proces B oblicza i ustawia nową wartosć zmiennej (zmienna = 2)
- Proces B jest wywłaszczony i wykonywanie kontynuuje proces A
- Proces A oblicza i ustawia nową wartosć zmiennej (zmienna = 2)
Czyli jeśli spodziewaliśmy się, że początkowa wartość zmienna=1 będzie zmieniona na zmienna=3, to w takim przypadku moglibyśmy się pomylić. Całość może się wydawać bardzo mało prawdopodobna. Korzystając z narzędzi jakie dają bibloteki, na przykład Qt lub całe środowiska Javy i C#, na ogół nie ma konieczności martwienia się o to że taka sytuacja wystąpi. Albo biblioteka z której korzystamy sama wystartuje nowy wątek lub obsłuży “konkurencyjność” po jej stronie, albo użytkownik nie będzie w stanie tak szybko kliknąć aby udało mu się coś popsuć w ten sposób. Oba założenia są oczywiście jak najbardziej błędne i warto zabezpieczyć się po swojej stronie. W przypadku programowania w przestrzeni jądra na ogół piszemy kod w czystym C, gdzie nikt nie zapewni nas o tym, że inny wątek kernela albo scheluder nie wymyśli czegoś nagle i nie przerwie nam w połowie funkcji. Tym bardziej, że obszary pamięci przechowujące informacje o procesach w systemie zmieniają się bardzo często. Druga ważna sprawa, to różnego rodzaju poziomy pamięci, cache oraz stronicowanie, które w przypadku kernela nie zawsze musi być jednolite, tak jak w przypadku procesów użytkownika. Wyobraźmy sobie, że chcemy zwolnić kawałek pamięci procesu użytkownika. W trakcie tej operacji nasze działanie przerywane jest przez inną operację jądra lub jego wątek. W jakim stanie jest taki obszar pamięci? Gwarancji tego nie mamy. Tym bardziej obsługiwany wątek nie będzie o tym wiedział, czy można skorzystać z takiego obszaru.
Aby uniknąć takich sytuacji, w kernelu stosuje się całą masę blokad i semaforów, przy każdej możliwej okazji. Na początku może się to wydawać uciążliwe i zaciemniać kod, jednak w docelowym kodzie modułu, jeśli zdarzy się nam taki pisać, nie powinniśmy ignorować takich blokad.
Chibios
edytujRóżnice pomiędzy Chibios a “dużymi” systemami operacyjnymi
edytujKonfiguracja środowiska
edytujDo rozpoczęcia zabawy z ChibiOS potrzebne będą nam trzy rzeczy:
- Źródła systemu Chibios
- Podstawowe narzędzia wspomagające kompilację w Linuksie, takie jak make i edytor C/C++
- Zestaw kompilatorów
Instalacja niezbędnych narzędzi
edytujDo kolejnych kroków potrzebne będą nam narzędzia umożliwiające pobranie źródeł, edycję kodu oraz kompilację. Są to m.in. pakiety <em>make</em>, <em>git</em> i jakiś (ulubiony) edytor tekstu, najlepiej z kolorowaniem składni :) W debiano-pochodnych dystrybucjach instalujemy je poleceniem:
sudo apt-get install git make gcc g++ automake gedit gdb libusb-1.0-0-dev
Apt-get powinien zapytać o zgodę na zainstalowanie wszystkich zależnych pakietów i po chwili pierwszą część przygotowania środowiska powinniśmy mieć z głowy.
Pobranie źródeł
edytujNa oficjalnej stronie <a href=“http://chibios.org” title=“Chibiob”>Chibios</a> można znaleźć link do repozytorium Git ze źródłami całęgo systemu wraz z przykładowymi programami. Repozytorium pobieramy poleceniem:
git clone git://github.com/mabl/ChibiOS.git
W katalogu ChibiOS znajdziemy trzy podkatalogi:
- boards - lista gotowych płytek wspieranych przez Chibios i definicji wszystkich portów oraz peryferiów dostępnych na nich
- demos - przykładowe projekty dla różnych procesorów. Większość z nich jest kompilowana przez z użyciem Makefile lub ma utworzone projekty dla popularnych środowisk
- os - źródła właściwego systemu operacyjnego
- ...oraz kilka innych
W kolejnych krokach będziemy bazować na jednym z projektów z katalogu ChibiOS/demos, odpowiadającego naszej wersji STM.
Pobranie zestawu kompilatorów
edytujDla STM32 będziemy potrzebowali specjalny zestaw kompilatorów dla ARM, który można pobrać z Launchpad, z adresu: https://launchpad.net/gcc-arm-embedded/+download
Po pobraniu paczki odpowiedniej dla naszego systemu należy ustawić odpowiednią ścieżkę w zmiennej środowiskowej PATH przez polecenie:
export PATH=$PATH:/home/moje_konto/katalog_z_kompilatorem
Można to też dopisać do pliku .bashrc, aby po ponownym włączeniu konsoli nie trzeba było ustawiać ścieżek na nowo.
Jeśli ktoś pamięta, to w przypadku cross-kompilacji dla Androida ustawiało się też ścieżkę do bibliotek systemowych (parametr –sysroot). Chibios jest małym systemem operacyjnym i kompilowane programy mają postać pojedynczych plików binarnych, zawierających już cały system. W przeciwieństwie do uruchamiania programów pod Linuksem, nasz kod będzie wykonywał się odrazu po włączeniu zasilania procesora i nic nie będzie go (software’owo) ładowało do pamięci. Z tego powodu odpada cały proces linkowania z dynamicznymi bibliotekami systemu i wszystkie niezbędne struktury systemu są dołączone do naszego pliku wykonywalnego.
Programator
edytujWiększość płytek STM32 Discovery posiada wbudowany programator. Jest to niewątpliwie zaleta tego sprzętu, ponieważ nie trzeba dokupować zewnętrznych programatorów. Jedyne co trzeba zrobić, to ściągnąć ze Githuba źródła sterownika do programatora oraz skompilować je. Link do repozytorium można znaleźć na stronie https://github.com/texane/stlink lub wpisać odrazu w konsoli:
git clone git://github.com/texane/stlink.git cd stlink ./autoconf.sh ./configure make
Po wydaniu ostatniego polecenia w katalogu powinien pojawić się plik wykonywalny st-util, który umożliwia połączenie gdb z programatorem na płytce STM32
Przykładowy projekt
edytujZałóżmy, że mamy do dyspozycji płytkę STM32F4 Discovery, na którą chcemy napisać prosty program migający diodami. Przechodzimy zatem do katalogu Chibios/demos i kopiujemy cały podkatalog z przykładowym projektem o nazwie <em>ARMCM4-STM32F407-DISCOVERY</em> pod nową nazwę <em>blink-f4</em>. W przypadku innych płytek można znaleźć odpowiedni katalog z przykładowym projektem szukając według:
- Architektura procesora - w powyższym przykładzie Arm Cortex M4, w skrócie ARMCM4
- Nazwa procesora - STM32F407 - płytka STM32F4 posiada właśnie taki procesor. W przypadku innych zestawów należy sprawdzić swój własny
- Nazwa zestawu - DISCOVERY. Dla F4 stworzone są także projektu dla G++ (niestety nie udało mi się go uruchomić) oraz z obsługą akcelerometru lub innych peryferiów. Na początek dobrze jest wybrać podstawowy projekt, bez żadnych dodatkowych “bajerów”
W skopiowanym katalogu blink-f4 pozostawiamy na razie w spokoju wszystkie pliki poza main.c. Podmieniamy jego zawartość na:
#include "ch.h"
#include "hal.h"
int main() {
halInit();
chSysInit();
palSetPadMode(GPIOB, GPIOB_LED4, PAL_MODE_OUTPUT_PUSHPULL);
while (1) {
palSetPad(GPIOB, GPIOB_LED4);
chThdSleepMilliseconds(1000);
palClearPad(GPIOB, GPIOB_LED4);
chThdSleepMilliseconds(1000);
}
return 0;
}
Funkcje halInit oraz chSysInit włączają odpowiednio: obsługę sprzętu podłączonego do danego zestawu oraz działanie całego systemu Chibios. Dokładny opis portów i związanych z nimi funkcji można znaleźć w katalogu Chibios/boards/<nazwa płytki>/board.h
Kompilacja i uruchomienie
edytujProjekt kompilujemy poleceniem make. Jeśli poprawnie ustawiliśmy zmienną PATH, to cały proces powinien przebiegać bez błędów. Następnie włączamy jako root program st-util z katalogu z programatorem st-link. W drugiej konsoli należy uruchomić gdb i wpisać poniższe polecenia:
$ gdb ... (gdb) target extended localhost:4242 Remote debugging using localhost:4242 0x00000000 in ?? ()
Powoduje to połączenie się z programatorem. W kolejnym kroku ładujemy plik ze skompilowanym Chibios i naszym programem i uruchamiamy go na procesorze płytki STM:
(gdb) load /home/maciek/ChibiOS/demos/sw/build/ch.elf Loading section startup, size 0xf4 lma 0x8000000 Loading section .text, size 0x1948 lma 0x8000100 Start address 0x8000100, load size 6716 Transfer rate: 1 KB/sec, 3358 bytes/write. (gdb) run
Jeżeli wszystko przebiegło pomyślnie, to program powinien się uruchomić i diody na płytce zaczną migać.
Co dalej?
edytujGdybyś miał(a) jakieś sugestie i uwagi co do tej lektury - nie wahaj się i pisz: maciej.nabozny@cloudover.io.
Jeśli jesteś zainteresowany warsztatami z tych tematów, to zapraszamy do zapisania się na naszą grupę meetup CloudOver KrakCloud
Bibliografia http://source.android.com/source/licenses.html http://www.androidcentral.com/android-z-what-dalvik http://developer.android.com/training/backward-compatible-ui/index.html http://www.cs.purdue.edu/homes/cs348/unix_path.html http://www.thelinuxdaily.com/2010/05/grab-raw-keyboard-input-from-event-device- n ode-devinputevent/ http://wiki.fedora.pl/wiki/Podstawy_Linuksa#Wszystko_jest_plikiem Dokumentacja podręcznika systemowego mmap (man 2 mmap) Dokumentacja podręcznika systemowego shm_open (man 2 shm_open) https://www.kernel.org/doc/gorman/html/understand/understand006.html