Operační systémy
Obsah Soubory
markdown

Systemova-volani.md

12 kB 167 řádků Změněno Zobrazit na GitHubu Stáhnout
markdown
# Systémová volání, čili jediné dveře do jádra Napíšeš `printf("ahoj\n")` a vypadá to jako jedna funkce. Jsou to tři vrstvy: `printf` je formátovač, ten zavolá `write`, `write` je obálka v libc a teprve ta provede jedinou instrukci procesoru, po které přestane běžet tvůj program a začne běžet jádro. Mezi první a druhou vrstvou sedí vyrovnávací paměť. Na terminál posílá libc výstup po řádcích, do souboru a do roury si drží celý blok (typicky 4 KiB) a odešle ho, až se naplní nebo až program regulérně skončí. Proto ti výpis zmizí, když program spadne dřív. `./program > log.txt`, uprostřed segfault, a v `log.txt` není nic, i když jsi „vypisoval" tisíc řádků. Ta data nikdy neopustila paměť procesu. Rozdíl mezi „zavolal jsem funkci" a „požádal jsem jádro" je celá tahle stránka: jak přechod do jádra na x86-64 vypadá, co stojí a jak se sleduje. Co jádro s požadavkem dělá dál, patří na [vstup a výstup](Vstup-a-vystup) a na [souborový systém](Souborovy-system). Předpokládá [jádro a uživatelský režim](Jadro-a-uzivatelsky-rezim) - bez rozdílu ring 0 a ring 3 z toho nedává smysl nic. ## Proč nejde prostě zavolat funkci jádra Jádro sídlí ve své vlastní části adresního prostoru a její stránky jsou označené jako privilegované. Procesor v ring 3 na ně nesmí skočit ani je přečíst. Kdyby to šlo, ochrana paměti by neexistovala. I kdybys adresu znal a směl na ni skočit, běžel bys pořád v uživatelském režimu - bez privilegovaných instrukcí. Tohle je nejdůležitější věc na celé stránce: **systémové volání není volání funkce jádra. Je to řízený vstup do jádra na jediném předem daném místě.** Jádro nikdy neskočí tam, kam mu řekneš. Procesor sám přepne režim a sám nastaví ukazatel instrukcí na adresu, kterou si jádro zaregistrovalo při startu. Ty dodáváš jen číslo požadavku a argumenty. Stejným mechanismem vstupují do jádra [přerušení a výjimky](Preruseni-a-vyjimky) - jen tam si o vstup neříká program. ## Co se stane při jednom `write` ```mermaidsequenceDiagram    participant P as Program    participant L as libc    participant J as Jádro    participant O as Ovladač    P->>L: volání printf    L->>L: uložení do vyrovnávací paměti    L->>J: instrukce syscall, v rax číslo 1    Note over L,J: přechod z ring 3 do ring 0, zásobník jádra    J->>J: kontrola argumentů a deskriptoru    J->>O: zápis na zařízení    O-->>J: počet zapsaných bajtů    J-->>L: sysret, výsledek v rax    L-->>P: návratová hodnota, případně -1 a errno``` Zbytek sekce rozebírá dva kroky ze schématu: předání argumentů a cestu zpátky. ### Mechanika na x86-64 Číslo volání se dá do `rax`, argumenty do `rdi`, `rsi`, `rdx`, `r10`, `r8`, `r9` v tomhle pořadí. Pak se provede `syscall` a jádro se vrátí instrukcí `sysret`, která obnoví uživatelský režim. Všimni si `r10` na čtvrtém místě. Volací konvence jazyka C tam má `rcx`, jenže do `rcx` ukládá `syscall` návratovou adresu. Proto **obálka v libc není kosmetika** - přerovnává registry. Argumentů je nejvýš šest, protože registrů je šest. Sedmý by musel jádro číst z uživatelského zásobníku, což jde, ale je to dražší a musí se to ověřovat. `mmap` se do šesti přesně vejde; na 32bitovém x86 se do tehdy použitelných registrů nevešel, a proto tam vznikl `mmap2`. **Když se něco do šesti nevejde, předává se ukazatel na strukturu.** Novější volání k němu přidávají i její velikost, aby šlo přidat položku bez nového čísla volání - tak vzniklo `clone3` i `openat2`. ### Návratová hodnota a errno Jádro vrací jedno číslo v `rax`. Nezáporné znamená úspěch - u `write` počet zapsaných bajtů. Záporné v rozsahu -1 až -4095 je přímo záporný kód chyby: `-EINVAL` je `-22`, `-ENOENT` je `-2`. Obálka v libc to přeloží: zápornou hodnotu z chybového rozsahu otočí, uloží do `errno` a vrátí `-1`. **`errno` tedy nastavuje knihovna, ne jádro.** Je to obyčejná proměnná v paměti procesu, kterou jádro nikdy nevidělo. Z toho plyne pravidlo, na kterém padají hodiny ladění: **`errno` má smysl číst jen po volání, které ohlásilo chybu.** Po úspěšném v něm může být cokoli - obálka ho nenuluje a zůstane tam hodnota z něčeho, co selhalo dřív uvnitř libc. ## Jak se do jádra vstupuje a jak se do něj nechodí vůbec Instrukce, kterou dnes použiješ, je jedna. Zbylé dvě potkáš v návodech - a nejrychlejší cesta do jádra nevede vůbec. ### Historie, která pořád straší v návodech Původní cestou na 32bitovém x86 bylo softwarové přerušení `int 0x80`. Procesor při něm prochází tabulku deskriptorů přerušení jako u hardwarového přerušení, což je výrazně dražší než dnešní specializované instrukce. Intel proto přidal `sysenter`, AMD `syscall`, a to je dnes na x86-64 jediná správná odpověď. `int 0x80` v návodech pořád je, protože textů z doby 32bitového Linuxu je na internetu víc a nikdo je nemaže. Na x86-64 ti dokonce může projít, má-li jádro zapnutou 32bitovou emulaci - a to je horší, než kdyby spadlo. Trefí **32bitovou tabulku s jinými čísly**: `write` je tam čtyřka, ne jednička, a argumenty se osekají na 32 bitů. Číslo volání není nic univerzálního, je to položka v tabulce architektury - na ARM64 má `write` číslo 64. ### vDSO: volání, které do jádra vůbec nedojde Zavolej `clock_gettime` v cyklu a změř to. Vyjdou ti desítky nanosekund, míň než vstup do jádra. Není v tom podvod - do jádra se nešlo. Jádro namapuje do každého procesu malou sdílenou knihovnu jménem vDSO (virtual dynamic shared object) a k ní stránku dat, do které samo průběžně zapisuje čas. Funkce ve vDSO ta data jen přečtou. Vejde se tam `clock_gettime`, `gettimeofday` a od jádra 6.11 (rok 2024) i `getrandom`. ```bashcat /proc/self/maps | grep vdso   # ukáže, kam je vDSO v procesu namapované``` Uvidíš jeden řádek s právy `r-xp` - kód mapovaný přímo do tvého procesu. ## Kolik jedno volání stojí Přechod tam a zpět stojí zhruba 50 až 100 ns. Pro srovnání: přístup do RAM je asi 80 ns a [přepnutí kontextu](Prepinani-kontextu) jednotky mikrosekund. Volání je drahé proti běžné funkci a levné proti všemu, co sáhne na disk. **To číslo ale není konstanta daná hardwarem.** Jak se dá změnit, ukazuje následující sekce. Cesta ven je nevolat tolikrát. `io_uring` (od jádra 5.1, rok 2019) jsou dvě kruhové fronty sdílené mezi procesem a jádrem: požadavky se do jedné zapisují, výsledky se z druhé čtou a jedním vstupem do jádra se jich odbaví klidně sto. Dřív se to řešilo přes `epoll`, jenže ten šetří jen dotazování na připravenost - `read` a `write` se volaly dál po jednom. **Doporučení: neřeš to, dokud ti to neukáže měření.** Když `strace -c` ukáže v jádře jednotky procent času, je přepis na `io_uring` jen přidaná složitost. Když leze z programu milion `read` po 512 bajtech, oprav velikost bloku. ## Rok 2018 a účet, který platíš dodnes V lednu 2018 byly zveřejněné Meltdown a Spectre - chyby ve spekulativním provádění, kvůli kterým šlo z uživatelského režimu číst paměť jádra. Netýkaly se jednoho výrobce, ale procesorů, které byly tehdy všude. Oprava Meltdownu v Linuxu se jmenuje KPTI: jádro už není namapované v tabulkách stránek běžícího procesu, takže se při každém vstupu a návratu musí přepnout tabulka stránek. Přesně to, čemu se návrh systémových volání dlouhá léta vyhýbal. Škoda nebyla v uniklých datech, ale ve výkonu - volání se na některých strojích prodloužilo ke 500 ns a zátěže s velkým počtem volání ztratily i desítky procent. Poučení: **cena vstupu do jádra není daná fyzikou, je to rozhodnutí, které ti může někdo změnit bezpečnostní záplatou.** ## „Nerozbíjíme uživatelský prostor" Rozhraní systémových volání je nejtvrdší závazek v Linuxu. Vnitřek jádra se přepisuje volně, ale to, co vidí uživatelský program, se rozbít nesmí. Rozdíl mezi API a ABI je přesně tady. **API je `fopen`** - dohoda na úrovni zdrojového kódu, kterou drží libc. **ABI je to, že `write` má na x86-64 číslo 1** - dohoda na úrovni strojového kódu. Kdyby se čísla přerovnala, každá přeložená binárka na světě by při `write` trefila něco jiného. Proto se stará volání nemění a přidávají se nová s vlastním číslem: `openat` má 257, jeho novější varianta `openat2` dostala číslo úplně jinde. ## Jak se na to díváš v praxi ```bashstrace -c ls                        # sečte volání podle typu a čas strávený v nichstrace -f -e trace=openat ./program # jen otevírání souborů, včetně potomků (-f)ltrace ./program                    # volání do sdílených knihoven, ne do jádra``` `strace` se připojí přes `ptrace` a vidí přesně to, co prošlo hranicí do jádra. `ltrace` odchytává volání do sdílených knihoven - uvidíš v něm `printf` a `malloc`, které do jádra nejdou, a u staticky slinkovaného programu neukáže nic. Obojí zpomaluje program řádově, takže **`strace` je nástroj na zjištění co, ne na měření jak dlouho**. Na to je `perf` z [nástrojů a diagnostiky](Nastroje-a-diagnostika). ### seccomp jako seznam povolených volání `seccomp` je filtr, který procesu dovolí jen vyjmenovaná volání. Nastaví se jednou, dědí se do potomků a nedá se vypnout. ```bashgrep Seccomp /proc/self/status   # 0 = vypnuto, 2 = běží filtr``` Na tomhle stojí izolace v Dockeru, jehož výchozí profil blokuje desítky volání, i sandbox v prohlížečích - podrobněji u [kontejnerů](Virtualizace-a-kontejnery) a [práv](Prava-a-uzivatele). ## Když se to rozbije | Příznak | Kde je problém ||---|---|| Program „nic nedělá", `strace` stojí v `read` | nic není rozbité, volání čeká na data || `EACCES` | nesedí práva na souboru nebo na adresáři v cestě || `EPERM` | práva sedí, procesu chybí oprávnění || `ENOENT` u souboru, který existuje | chybí interpret nebo sdílená knihovna || `EINTR` uprostřed čtení | přišel signál, volání se přerušilo - opakuj ho || Stovky `openat` při startu | hledání knihoven po zbytečně dlouhé cestě | **`EACCES` versus `EPERM`.** U `EACCES` rozhodly bity `rwx` - špatný vlastník nebo chybějící `x` na adresáři v cestě. `EPERM` znamená, že práva sedí a zakázal to někdo jiný: chybějící capability, seccomp filtr, operace jen pro roota. **`ENOENT` na soubor, který vidíš.** Klasika je `./program: No such file or directory` u binárky, která tam je. Volání `execve` neselhalo na ní, ale na dynamickém linkeru nebo na interpretu ze shebangu. `file` a `ldd` to ukážou hned. ## Co si odnést **Systémové volání je řízený vstup na jediné pevné místo.** Adresu si nevybíráš, dodáváš číslo a argumenty. **Mezi `printf` a jádrem je vyrovnávací paměť.** Když program spadne, její obsah je pryč. **Číslo v `rax`, argumenty v `rdi rsi rdx r10 r8 r9`, nejvýš šest.** Víc se předává ukazatelem na strukturu. **Jádro vrací záporný kód chyby, `errno` nastavuje libc.** Po úspěšném volání ho nečti. **vDSO je důvod, proč čtení času skoro nic nestojí.** Do jádra se vůbec nejde. **Cena volání se může změnit záplatou.** KPTI to v roce 2018 předvedla naráz na celém světě. **Čísla volání se nepřečíslují.** To je celý rozdíl mezi API a ABI. ## Kam dál - **[Jádro a uživatelský režim](Jadro-a-uzivatelsky-rezim)** - co se při vstupu přepíná a proč to hlídá hardware- **[Přerušení a výjimky](Preruseni-a-vyjimky)** - druhá cesta do jádra, bez tvého přičinění- **[Vstup a výstup](Vstup-a-vystup)** - co se s `write` děje potom- **[Nástroje a diagnostika](Nastroje-a-diagnostika)** - `strace`, `perf` a `/proc` v souvislostech- **[Slovníček](Slovnicek)** - ABI, vDSO a další zkratky