-
Notifications
You must be signed in to change notification settings - Fork 0
Systemova volani
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 a na souborový systém. Předpokládá jádro a uživatelský režim - bez rozdílu ring 0 a ring 3 z toho nedává smysl nic.
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 - jen tam si o vstup neříká program.
sequenceDiagram
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.
Čí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.
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.
Instrukce, kterou dnes použiješ, je jedna. Zbylé dvě potkáš v návodech - a nejrychlejší cesta do jádra nevede vůbec.
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.
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.
cat /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.
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 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.
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.
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.
strace -c ls # sečte volání podle typu a čas strávený v nich
strace -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ádrastrace 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.
seccomp je filtr, který procesu dovolí jen vyjmenovaná volání. Nastaví se jednou, dědí se do potomků a nedá se vypnout.
grep Seccomp /proc/self/status # 0 = vypnuto, 2 = běží filtrNa tomhle stojí izolace v Dockeru, jehož výchozí profil blokuje desítky volání, i sandbox v prohlížečích - podrobněji u kontejnerů a práv.
| 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.
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.
- Jádro a uživatelský režim - co se při vstupu přepíná a proč to hlídá hardware
- Přerušení a výjimky - druhá cesta do jádra, bez tvého přičinění
-
Vstup a výstup - co se s
writeděje potom -
Nástroje a diagnostika -
strace,perfa/procv souvislostech - Slovníček - ABI, vDSO a další zkratky
Našel jsi chybu nebo něco chybí? Založ issue nebo pošli pull request.
Základy
- Co dělá operační systém
- Režim jádra a uživatelský režim
- Systémová volání
- Přerušení a výjimky
- Architektury jádra
Procesy
Souběh
Paměť
Soubory a zařízení
Systém v provozu
Praxe