=========================== Zajęcia 2: interfejsy jądra =========================== Data: 09.03.2021 .. contents:: .. toctree:: :hidden: zadanie Materiały dodatkowe =================== - :ref:`01-zadanie` 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`` i ``r11`` jest niszczona przez jądro jako skutek uboczny syscalla - wynik 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 konwencje - syscall 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) i ``getuid32`` (32-bitowy uid) oraz ``lseek`` (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`` zawiera ``MAP_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śli ``flags`` zawiera ``MAP_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 flaga ``MAP_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żej ``val`` procesów czekających przez ``FUTEX_WAIT`` na adresie ``uaddr``. - ... 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 mutexa - mutex 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ęci - ``SIGILL``: informuje o wykonaniu nieprawidłowej instrukcji maszynowej - ``SIGBUS``: 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łanie - ``SIGKILL``: siłowo kończy działanie procesu - ``SIGSTOP``: zatrzymuje działanie procesu (z możliwością kontynuacji) - ``SIGCONT``: kontynuuje wykonanie procesu - ``SIGCHLD``: 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-C - ``SIGQUIT``: informuje o wciśnięciu Ctrl-\\ - ``SIGTSTP``: informuje o wciśnięciu Ctrl-Z - ``SIGTTIN``: informuje o próbie czytania z terminala kontrolującego bez bycia w grupie pierwszego planu - ``SIGTTOU``: informuje o próbie pisania do terminala kontrolującego bez bycia w grupie pierwszego planu - ``SIGWINCH``: 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ęcia - ``SIGPIPE``: informuje o próbie pisania do pipe'a lub socketa, którego drugi koniec został zamknięty - ``SIGUSR1``, ``SIGUSR2``: bez ustalonego przeznaczenia, przeznaczone dla użytkownika - ``SIGIO``: 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 ---------- 1. Sekcja 2 manuala, szczególnie: ``syscall``, ``futex``, ``sigaction`` 2. Żródła VDSO/vsyscall w ``arch/x86/vdso`` 3. Lista syscalli w ``asm/unistd*.h`` 4. Ulrich Drepper "Futexes Are Tricky", 2011 - http://www.akkadia.org/drepper/futex.pdf 5. ``man 7 signal`` .. Marcelina Kościelnicka (mwk@mimuw.edu.pl) .. 24.02.2013, 25.02.2013