Zajęcia 2: interfejsy jądra¶
Data: 09.03.2021
Materiały dodatkowe¶
Wywołania systemowe w systemie Linux¶
Co to jest wywołanie systemowe?¶
Wywołania systemowe (syscalle) są mechanizmem pozwalającym procesom działającym w przestrzeni użytkownika zażądać wykonania pewnych funkcji przez jądro systemu.
Syscalle są głównym mechanizmem komunikacji procesów użytkownika ze światem zewnętrznym - jedyne, co może zrobić program nie używający syscalli to zawieszenie się lub spowodowanie błędu wykonania.
Mechanizm wywoływania syscalli z poziomu użytkownika jest różny dla każdej architektury, a czasem nawet dla procesorów i/lub wersji jądra w ramach jednej architektury. Zazwyczaj polega to na wykonaniu specjalnie do tego celu zaprojektowanej instrukcji assemblera.
Syscalle identyfikowane są numerami. Ich dokładna lista oraz numerowanie
zależy od architektury - można ją obejrzeć w pliku /usr/include/asm/unistd.h
lub arch/*/include/asm/unistd.h
Przed wywołaniem syscalla, należy umieścić jego numer oraz parametry w pewnych ustalonych rejestrach procesora. Po wywołaniu, wynik lub kod błędu jest również dostępny w rejestrze.
Wartość zwracana z jądra w zakresie [-4096,-1] oznacza błąd i jest
zanegowanym standardowym kodem błędu (lista w asm-generic/errno.h
i asm-generic/errno-base.h
). Pozostałe wartości oznaczają sukces, a ich
znaczenie zależy od syscalla.
Wywołania systemowe wykonywane przez program można podejrzeć używając
programu strace (np. strace ls
). Należy zauważyć, że strace pokazuje
wszystkie syscalle wykonywane przez proces - w tym te używane przez
dynamiczny linker w celu załadowania programu i jego bibliotek.
Wywołania systemowe na architekturze x86_64¶
Jedynym natywnym mechanizmem wywołań systemowych na architekturze
x86_64 jest instrukcja syscall
. Wywołanie odbywa się następująco:
numer syscalla przekazywany jest w
rax
parametry przekazywane są w:
rdi
,rsi
,rdx
,r10
,r8
,r9
(w tej kolejności)wywoływana jest instrukcja
syscall
zawartość
rcx
ir11
jest niszczona przez jądro jako skutek uboczny syscallawynik znajduje się w
rax
Wywołania systemowe na architekturze i386¶
Istnieją 3 mechanizmy wywołań systemowych na architekturze i386:
przerwanie
0x80
(dostępne na wszystkich procesorach)instrukcja
sysenter
(dostępne na procesorach intela od Pentium Pro)instrukcja
syscall
(dostępne na procesorach AMD od K6)
Instrukcje sysenter/syscall zostały wprowadzone w późniejszych procesorach ze względu na złą wydajność przerwań na procesorach x86.
W przypadku użycia przerwania 0x80, wywołanie systemowe wygląda następująco:
numer syscalla przekazywany jest w
eax
parametry przekazywane są w rejestrach:
ebx
,ecx
,edx
,esi
,edi
,ebp
(w tej kolejności); syscalle wymagające więcej parametrów mają specjalne konwencjesyscall jest wywoływany przez instrukcję
int $0x80
wynik syscalla znajdzie się w rejestrze
eax
Wywołania przez syscall
/sysenter
są dość podobne, lecz trochę bardziej
skomplikowane.
Mechanizmy VDSO oraz vsyscall¶
Ze względu na istnienie wielu mechanizmów syscalli na architekturze i386
i potrzebę wybrania odpowiedniego dla danej maszyny, wprowadzono mechanizm
VDSO. VDSO jest małą biblioteką dzieloną dostarczaną przez jądro,
zawierającą odpowiednią dla danego procesora funkcję wywołującą syscalla.
Jądro ma przygotowane kilka wersji tej biblioteki (int 0x80
, syscall
,
sysenter
) i wybiera odpowiednią w czasie działania.
Architektura x86_64 nie wymaga wyboru mechanizmu syscalli, lecz wprowadzono
na niej ulepszony mechanizm wykonania pewnych syscalli (clock
, time
,
get_cpu
). Te syscalle mają zoptymalizowane wersje nie wymagające przejścia
procesora w tryb jądra (czytają one jedynie zmienne globalne jądra,
w specjalny sposób udostępnione do odczytu przestrzeni użytkownika).
Ten mechanizm również używa bloku kodu eksportowanego przez jądro do
przestrzeni użytkownika, nazwanego vsyscall.
Kod VDSO oraz vsyscall zawiera również implementację funkcji sigreturn
oraz rt_sigreturn
, używanych przy powracaniu z funkcji obsługi sygnałów
w przestrzeni użytkownika.
Wywołania systemowe w libc¶
Większość wywołań systemowych ma swoje “opakowania” (wrappery)
w standardowej bibliotece C (libc). Są to funkcje, których jedynym
zadaniem jest przeniesienie parametrów w odpowiednie miejsce, wywołanie
odpowiedniego syscalla, i zwrócenie wyniku. Należy zauważyć, że jądro
i libc mają różne konwencje przekazywania informacji o błędzie - jądro
zwraca zanegowany kod błędu (np. -EINVAL
) bezpośrednio z syscalla,
podczas gdy funkcje biblioteczne w przypadku błędu zwracają zawsze -1,
a kod błędu przekazują w zmiennej globalnej errno
(kod błędu w errno
nie jest zanegowany).
Uproszczona wersja opakowania syscalla write (pomijam vdso, wielowątkowość errno, cancellation point) może wyglądać na przykład tak:
.global write
write:
movl $1, %eax # numer syscalla
syscall
cmpq $-4096, %rax # czy błąd?
jna out # jak nie błąd, to wyjście
neg %rax # -EINVAL -> EINVAL etc.
movl %eax, errno # ustawienie errno
movl $-1, %eax # wartość zwracana to -1
out:
ret
Nie wszystkie wywołania systemowe odpowiadają bezpośrednio funkcjom bibliotecznym, z wielu powodów:
wiele syscalli ma kilka wersji z parametrami różnych wielkości (głównie te dotyczące uidów/gidów, pidów, offsetów w plikach, etc.). Starsze wersje z mniejszymi parametrami są zachowywane w ramach zgodności ze starszymi wersjami libc. Przykładami są syscall
getuid
(16-bitowy uid) igetuid32
(32-bitowy uid) orazlseek
(32-bitowy offset) i_llseek
(64-bitowy offset). Istniejące wersje syscalli zależą mocno od architektury - np. 64-bitowe architektury nigdy nie miały syscalli z 32-bitowymi offsetami.niektóre syscalle (
ipc
,socketcall
) w rzeczywistości mają wiele podfunkcji z różnymi parametrami (shmat
,shmctl
,msgctl
,socket
,connect
,bind
,listen
…). Każda z tych podfunkcji ma swoją własną funkcję w libc.wiele syscalli ma semantykę zmodyfikowaną przez bibliotekę wątków (o tym poniżej)
syscall wymaga specjalnej interwencji ze strony libc (
vfork
,clone
, etc.)bo tak
Istnieje również funkcja syscall
, pozwalająca wykonać bezpośrednio dowolne
wywołanie systemowe. Przydaje się ona przy wywoływaniu syscalli nie
posiadających własnych wrapperów w libc. Implementacja write
używająca
tej funkcji może wyglądać na przykład tak:
ssize_t write(int fd, const char *buf, size_t len) {
return syscall(SYS_write, fd, buf, len);
}
Przegląd ważniejszych syscalli¶
Kontrola procesów¶
Te syscalle związane są z zarządzaniem procesami. Należy zauważyć, że jądro Linuxa oraz standard POSIX (w tym biblioteka wątków pthreads) używają różnych definicji procesu: procesy w jądrze odpowiadają POSIXowym wątkom. Tutaj używamy POSIXowej definicji.
noreturn void _exit()
kończy wątek
noreturn void exit_group()
kończy proces
pid_t getpid()
zwraca identyfikator obecnego procesu
pid_t gettid()
zwraca identyfikator obecnego wątku
int fork()
tworzy nowy proces będący kopią obecnego (ale tylko z jednym wątkiem); jest to specjalny przypadek syscalla clone
int clone(int (*fn)(void *), void *newstack, int flags, void *arg, ...)
tworzy nowy proces lub wątek, dość skomplikowana funkcja
pid_t waitpid(pid_t pid, int *stat_loc, int options)
czeka na zdarzenie (wyjście, zatrzymanie, etc.) w procesie potomnym
int execve(const char *path, char *const *argv, char *const *envp)
uruchamia nowy program w obecnym procesie, zastępując obecny
long ptrace(int request, pid_t pid, void *addr, void *data)
wykonuje wiele operacji związanych ze śledzeniem innych procesów: pozwala zatrzymać dany proces, wykonywać go instrukcja po instrukcji, czytać i pisać jego przestrzeń adresową oraz rejestry, itp.. Używany m.in. przez gdb oraz strace.
Obsługa plików¶
Na poziomie syscalli, otwarte pliki są identyfikowane tzw. deskryptorami plików, czyli małymi nieujemnymi liczbami całkowitymi. Deskryptory 0-2 standardowo odpowiadają standardowemu wejściu, wyjściu, oraz wyjściu błędów. Pozostałe deskryptory rzadko mają konkretnie zdefiniowane role.
Ważniejsze syscalle z tej grupy:
ssize_t read(int fd, void *buf, size_t len)
czyta z pliku
fd
do bufora; zwraca ilość przeczytanych bajtów. 0 oznacza koniec pliku (nie jest to uznawane za błąd). Liczba dodatnia, ale mniejsza niż len oznacza częściowy odczyt – może to być spowodowane błędem lub końcem pliku na tej pozycji, lub po prostu brakiem większej ilości dostępnych danych w danym momencie.ssize_t write(int fd, const void *buf, size_t len)
pisze do pliku, działa podobnie do read.
int open(const char *fname, int flags, mode_t mode)
otwiera plik, zwraca deskryptor.
int close(int fd)
zamyka plik; może zwrócić błąd w przypadku problemu w opróżnianiu buforów jądra.
int ioctl(int fd, int request, void *arg)
wykonanie specjalnej operacji na pliku. Zestaw dostępnych operacji jest bardzo zależny od pliku. W większości stosuje się tylko do plików będących urządzeniami. Przykładowe operacje specjalne:
zmiana ustawień terminala (wykonywane na pliku terminala)
zmiana głośności (wykonywane na pliku urządzenia karty dźwiękowej)
przeczytanie informacji o producencie i parametrach fizycznych (wykonywane na pliku urządzenia dysku twardego)
int poll(struct pollfd *fds, int nfds, int timeout)
czeka na zajście jednego z podanych zdarzeń na jednym z podanej listy deskryptorów, przydatne gdy program może dostawać wejście z wielu źródeł
Obsługa pamięci¶
void *mmap(void *addr, size_t len, int prot, int flags, int fildes, off_t off)
tworzy nowy obszar pamięci. Jeśli
flags
zawieraMAP_ANONYMOUS
, jest to po prostu nowy blok pamięci. W przeciwnym wypadku, obszar będzie podłączony do podanego pliku - czytanie z obszaru da nam zawartość pliku. Jeśliflags
zawieraMAP_SHARED
, zapis do obszaru spowoduje również zapis do pliku; w przeciwnym przypadku (MAP_PRIVATE
), zapis do obszaru spowoduje stworzenie nowej kopii danych z pliku i modyfikację tylko tej kopii. Jeśli przekazana jest flagaMAP_FIXED
, obszar znajdzie się pod zadanym adresem; w przeciwnym przypadku jądro poszuka jakiegoś wolnego adresu.void munmap(void *addr, size_t len)
odmapowuje (niszczy) podany obszar pamięci
int mprotect(void *addr, size_t len, int prot)
zmienia prawa dostępu do danego obszaru pamięci
void *brk(void *addr)
skraca lub rozszerza segment sterty procesu
Należy zauważyć, że w systemie Linux (jak i wielu innych UNIXach) istnieją
dwie metody na alokację “zwykłej” pamięci: mmap
z opcją MAP_ANONYMOUS
oraz
brk
. Zwykły malloc()
z libc w standardowej konfiguracji używa tej drugiej dla
małych alokacji i tych pierwszych dla dużych alokacji.
futex¶
Tradycyjne metody implementacji mutexów w przestrzeni użytkownika wymagały, dla uniknięcia aktywnego oczekiwania, utworzenia np. nienazwanego pipe’a dla każdego mutexu, używanego do obudzenia czekających procesów. Takie podejście ma szereg wad:
mutexy są drogie: użycie dwóch deskryptorów plików na każdy mutex wymagający aktywnego oczekiwania, sama struktura mutex również musi być całkiem duża
implementacja mutexów międzyprocesowych (np. w pamięci współdzielonej) jest bardzo ciężka
W jądrze 2.6 dodano syscall futex
(fast userspace mutex), pozwalający na
znaczne uproszczenie implementacji mutexów:
int futex(int *uaddr, int op, int val, const struct timespec *timeout, int *uaddr2, int val3);
Ten syscall, podobnie jak socketcall
, jest w rzeczywistości opakowaniem
na kilka podfunkcji (wybieranych przez parametr op
):
FUTEX_WAIT
: atomowo sprawdza, czy*uaddr == val
i zasypia, jeśli tak. Jeśli timeout jest podany, zasypia na co najwyżej taki okres czasu, w przeciwnym wypadku zasypia na czas nieokreślony.FUTEX_WAKE
: budzi co najwyżejval
procesów czekających przezFUTEX_WAIT
na adresieuaddr
.… i kilka innych, bardziej skomplikowanych
Użycie futexów pozwala na uniknięcie problemów z tradycyjną implementacją:
syscall
futex
jest wywoływany tylko, gdy mutex jest już zajęty. Nie ma stałego zużycia zasobów: zasoby jądra są wykorzystywane tylko wtedy, gdy wątek faktycznie czeka na zwolnienie mutexamutex jest bardzo małą strukturą (dla podstawowego wariantu wystarczy pojedynczy
int
)mutexy działają między procesami bez żadnej specjalnej obsługi – jądro używa fizycznego adresu przy porównaniach i poprawnie obsłuży odwołania do tego samego miejsca przez różne adresy w różnych procesach itp.
Sygnały¶
Sygnały są mechanizmem przekazywania informacji o asynchronicznych lub synchronicznych zdarzeniach do procesu użytkownika. Mechanizm ten jest bardzo podobny do mechanizmu przerwań na poziomie jądra.
Istnieje około 32 sygnałów o ustalonym przeznaczeniu (lista jest w pewnym stopniu zależna od architektury), oraz 32 sygnałów czasu rzeczywistego, które mogą być wykorzystane przez użytkownika do dowolnego celu. Ważniejsze sygnały to:
Sygnały wysyłane przez jądro, powodowane przez błędy procesora:
SIGSEGV
: informuje o naruszeniu mechanizmów ochrony, najczęściej odwołanie do złego obszaru pamięciSIGILL
: informuje o wykonaniu nieprawidłowej instrukcji maszynowejSIGBUS
: informuje o błędzie dostępu do pamięci z innego powodu niż nieprawidłowy adres czy brak uprawnień. Dość ciężko go otrzymać na x86. Na innych architekturach często powodowany np. przez dostęp do niewyrównanego adresu słowa.SIGFPE
: floating point exception, oryginalnie informował o błędzie w obliczeniach zmiennoprzecinkowych, później wykorzystany również do błędów arytmetycznych na liczbach całkowitych (dzielenie przez 0)SIGTRAP
: informuje o trafieniu w breakpoint, wykorzystywany przy debugowaniu programów
Sygnały służące sterowaniu procesami (wysyłane przez inne procesy):
SIGTERM
: informuje proces, że ma zakończyć działanieSIGKILL
: siłowo kończy działanie procesuSIGSTOP
: zatrzymuje działanie procesu (z możliwością kontynuacji)SIGCONT
: kontynuuje wykonanie procesuSIGCHLD
: informuje o zmianie stanu procesu potomnego
Sygnały związane z obsługą terminali:
SIGHUP
: informuje o odłączeniu się terminala (np. zamknięcie okna xterm, przerwanie sesji ssh)SIGINT
: informuje o wciśnięciu Ctrl-CSIGQUIT
: informuje o wciśnięciu Ctrl-\SIGTSTP
: informuje o wciśnięciu Ctrl-ZSIGTTIN
: informuje o próbie czytania z terminala kontrolującego bez bycia w grupie pierwszego planuSIGTTOU
: informuje o próbie pisania do terminala kontrolującego bez bycia w grupie pierwszego planuSIGWINCH
: informuje o zmianie rozmiaru terminala
Inne sygnały:
SIGABRT
: informuje o wystąpieniu nieprzewidzianego błędu w programie (nieudany assert itp.) i konieczności jego siłowego zamknięciaSIGPIPE
: informuje o próbie pisania do pipe’a lub socketa, którego drugi koniec został zamkniętySIGUSR1
,SIGUSR2
: bez ustalonego przeznaczenia, przeznaczone dla użytkownikaSIGIO
: informuje o zakończeniu asynchronicznego IO, lub możliwości wykonania IO, jeśli program zażądał wcześniej takiej informacji
Każdy sygnał ma przypisaną akcję, która będzie wykonana gdy zostanie on dostarczony. Jest to jedna z:
ignorowanie: nic się nie stanie
wykonanie funkcji: zostanie wykonana funkcja dostarczona przez użytkownika
akcja domyślna, zależna od sygnału: - ignorowanie (
SIGCHLD
,SIGWINCH
) - zatrzymanie procesu (SIGSTOP
,SIGTSTP
,SIGTTIN
,SIGTTOU
) - kontynuacja procesu (SIGCONT
) - zabicie procesu (SIGTERM
,SIGKILL
,SIGINT
, …) - zabicie procesu ze zrzutem pamięci (SIGSEGV
,SIGQUIT
, …)
Akcja przypsana większości sygnałów może być zmieniona syscallami
signal
(prosty interfejs, lecz małe możliwości) lub sigaction
(znacznie
większe możliwości). Sygnały, których akcji nie można zmienić, to SIGKILL
oraz SIGSTOP
. Ponadto, choć można zmienić akcję SIGCONT
, i tak będzie on
powodował kontynuację procesu jako dodatek do wywołania przypisanej akcji.
Oprócz zmiany akcji przypisanej sygnałom, można również zablokować ich
dostarczanie syscallem sigprocmask
. Sygnał blokowany nie jest tym samym
co sygnał ignorowany - sygnał ignorowany zostanie wyrzucony, podczas gdy
sygnał blokowany będzie czekał w kolejce do momentu odblokowania.
Oprócz sygnałów wysyłanych przez jądro, każdy sygnał może być również
ręcznie wysłany przez użytkownika. Sygnały mogą być wysyłane do całych
procesów (lub grup procesów) przez syscall kill
, lub do pojedynczych
wątków wewnątrz własnego procesu przez syscall tkill
(lub odpowiadającą
mu funkcję pthread_kill
). Wysłanie sygnału do procesu powoduje
dostarczenie go do dowolnie wybranego wątku w tym procesie.
Obsługa sygnałów¶
Funkcje obsługi sygnałów są ustawiane przez syscall sigaction
i mają
jeden z następujących typów:
void func(int signum)
void func(int signum, siginfo_t *info, ucontext_t *ctx)
signum
jest numerem sygnału, który przyszedł. W przypadku użycia drugiego
typu, info
jest strukturą zawierającą informację o sygnale - np. jego
źródło (błąd procesora, wysłanie przez użytkownika, terminal, itp.)
i szczegóły (adres błędu w przypadku SIGSEGV
, pid procesu wysyłającego,
itp.). ctx
jest wskaźnikiem na strukturę zawierającą pełen stan rejestrów
procesora przed wejściem do procedury obsługi sygnału.
W momencie nadejścia sygnału z przypisaną funkcją obsługi, jądro przerywa
pracę danego procesu. Jeśli proces wykonuje akurat blokujące wywołanie
systemowe i jest w stanie przerywalnego snu, zostaje ono przerwane.
W zależności od semantyki wybranej przy ustawianiu funkcji obsługi,
tak przerwane wywołanie albo zwraca kod błędu EINTR
, albo zostanie
zrestartowane przy powrocie z obsługi sygnału.
Po przerwaniu pracy procesu i ewentualnego syscalla, jądro zapisuje stan
rejestrów procesora na stos użytkownika (zazwyczaj jest to “zwykły” stos
programu, ale możliwe jest ustawienie oddzielnego stosu na sygnały przez
syscall sigaltstack
). Po zapisaniu stanu rejestrów, jądro zapisuje na
stosie (lub w rejestrach) parametry do funkcji obsługi przerwania oraz
adres powrotny. Ze względu na konieczność posprzątania po obsłudze sygnału
i przywrócenia dokładnego stanu procesora sprzed jej wywołania, ten adres
powrotu wskazuje na specjalną pseudo-funkcję sigreturn
będącą częścią
bloku VDSO/vsyscall. Po powrocie z funkcji obsługi sygnału, sigreturn
wywołuje syscall sigreturn
, który zajmuje się właściwym sprzątaniem.
Zwykły powrót z funkcji obsługi sygnału nie jest jedyną metodą jej
opuszczenia - niekiedy przydatne jest użycie wywołania siglongjmp
lub
wykorzystanie informacji ze struktury ucontext do odwinięcia stosu.
Obsługa sygnałów to bardzo delikatny mechanizm, gdyż bardzo ciężko
jest kontrolować, w którym miejscu kodu przyjdzie sygnał (np. nie możemy
zagwarantować, że wolno nam użyć malloc
w obsłudze sygnału - ten sygnał
mógł przerwać wywołanie malloc
z głównego programu). Z tego powodu
funkcje obsługi sygnałów często ograniczają się do ustawienia pojedynczej
zmiennej, która jest regularnie sprawdzana przez główny program.
Literatura¶
Sekcja 2 manuala, szczególnie:
syscall
,futex
,sigaction
Żródła VDSO/vsyscall w
arch/x86/vdso
Lista syscalli w
asm/unistd*.h
Ulrich Drepper “Futexes Are Tricky”, 2011 - http://www.akkadia.org/drepper/futex.pdf
man 7 signal