Операционные системы
Системные вызовы
Каждую секунду программа делает тысячи системных вызовов - даже простой printf() в итоге вызывает write(). Но как user-программа просит ядро выполнить операцию, если она не имеет права обращаться к hardware? Как CPU переключается между user mode и kernel mode? Почему некоторые syscalls занимают 150 наносекунд, а другие - 20? За этим стоит фундаментальный механизм взаимодействия с операционной системой.
- **Production оптимизация:** В высоконагруженных системах (NGINX, Redis, PostgreSQL) количество syscalls - ключевая метрика. strace показывает узкие места: избыточные open/close, лишние read/write. Батчинг syscalls через io_uring даёт прирост x2-x3.
- **Observability:** strace, perf, eBPF - основа debugging production проблем. Замедление на 100ms может быть вызвано одним медленным syscall (например, fsync()). Понимание syscall overhead критично для анализа latency.
- **Security:** Seccomp (Secure Computing Mode) позволяет ограничить набор syscalls для процесса - это база sandboxing (Docker, Chrome, systemd). Каждый syscall - потенциальная точка атаки, kernel должен валидировать все аргументы.
Цели урока
- Объяснить mode switch: int 0x80 / syscall instruction, переход через IDT
- Знать cost: ~100ns на современном CPU после Meltdown, ~50ns без KPTI
- Понимать vDSO: gettimeofday/clock_gettime без syscall через mapped page
- Применять strace для observability syscalls процесса
- Различать blocking vs non-blocking syscall, errno, EINTR
Механизм системных вызовов
**Системный вызов (syscall)** - это единственный легальный способ для программы пользовательского режима (user mode) попросить ядро операционной системы выполнить привилегированную операцию. Это мост между пользователем и ядром.
**Зачем нужны системные вызовы?** • **Изоляция:** Прямой доступ к hardware запрещён в user mode - только ядро может управлять устройствами, памятью, процессами • **Безопасность:** Ядро проверяет права доступа перед выполнением операции • **Абстракция:** Приложение не знает деталей железа - работает через унифицированный API • **Стабильность:** Ошибка в user mode не может сломать систему
Каждый раз, когда программа читает файл, выделяет память, создаёт поток или отправляет данные по сети - она делает системный вызов. Даже простой `printf()` в конечном счёте вызывает `write()`.
Пример cat file.txt
При выполнении `cat file.txt` происходит: 1. Shell делает `fork()` - **syscall #57** 2. Дочерний процесс делает `execve("/bin/cat", ...)` - **syscall #59** 3. cat делает `open("file.txt", O_RDONLY)` - **syscall #2** 4. cat читает данные `read(fd, buf, count)` - **syscall #0** 5. cat выводит в stdout `write(1, buf, count)` - **syscall #1** 6. cat завершается `exit(0)` - **syscall #60** Каждая строка - переход из user mode в kernel mode и обратно.
В Linux x86-64 есть около **400+ системных вызовов**. Каждый имеет уникальный номер (syscall number). Программа помещает номер syscall в регистр `%rax`, аргументы в другие регистры, и выполняет инструкцию `syscall`.
**Интересный факт:** В Linux syscall numbers разные для разных архитектур: • x86-64: `write` = 1, `read` = 0, `exit` = 60 • x86-32: `write` = 4, `read` = 3, `exit` = 1 • ARM64: `write` = 64, `read` = 63, `exit` = 93 Поэтому бинарники несовместимы между архитектурами!
Большинство программистов никогда не вызывают syscall напрямую - они используют **обёртки в libc** (glibc, musl). Например, `open()`, `read()`, `write()` в C - это функции glibc, которые внутри делают syscall.
Почему приложения в user mode не могут напрямую обращаться к hardware?
Переход User -> Kernel
Переход из user mode в kernel mode - это **смена контекста выполнения**, подобная context switch между процессами, но более лёгкая. CPU переключает privilege level с Ring 3 на Ring 0 и передаёт управление ядру.
**Критический момент:** При переходе в kernel mode CPU автоматически: • Сохраняет `RIP` (указатель на следующую инструкцию в user mode) • Сохраняет `RSP` (user stack pointer) • Сохраняет `RFLAGS` (флаги процессора) • Загружает kernel `RSP` из MSR (Model-Specific Register) • Переходит на адрес обработчика syscall из MSR
Overhead системного вызова
**Почему syscall медленный?** Переход user → kernel включает: • Сохранение состояния user-space (~10 регистров) • TLB flush (сброс кеша виртуальных адресов) - в некоторых случаях • Переключение стека (user stack → kernel stack) • Проверку прав доступа • Обратный переход kernel → user Всё это занимает **50-200 ns** на современных CPU. Для сравнения: обычный вызов функции - **1-2 ns**.
В старых версиях Linux (до 2.6) для syscall использовалась инструкция **int 0x80** (software interrupt). Это было ещё медленнее (~1000 ns), потому что CPU обрабатывал interrupt gate, сохранял больше состояния.
**Intel vs AMD:** • Intel ввела инструкцию `sysenter/sysexit` для быстрых syscalls • AMD ввела `syscall/sysret` • Linux x86-64 использует `syscall/sysret` (AMD-стиль) • Windows x86-64 использует `syscall` для x64, `int 0x2e` для x86 Современные CPU от Intel поддерживают обе инструкции.
strace пример
**Реальная трассировка:** ```bash $ strace -c cat /etc/hostname % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 35.71 0.000050 50 1 execve 21.43 0.000030 10 3 openat 14.29 0.000020 10 2 read 14.29 0.000020 10 2 close 7.14 0.000010 10 1 write 7.14 0.000010 10 1 fstat ------ ----------- ----------- --------- --------- ---------------- 100.00 0.000140 10 total ``` Для простого `cat` - **10 системных вызовов**, каждый ~10 мкс (включая kernel work).
Что происходит с User Stack Pointer (RSP) при переходе в kernel mode через syscall?
Таблица системных вызовов
**Syscall table** - это массив указателей на функции-обработчики в ядре. Когда программа делает syscall с номером `N`, ядро ищет `sys_call_table[N]` и вызывает эту функцию.
**Соглашение вызова syscall на x86-64:** • `%rax` - syscall number (вход) и return value (выход) • `%rdi` - arg1 • `%rsi` - arg2 • `%rdx` - arg3 • `%r10` - arg4 (не `%rcx`, т.к. `rcx` используется CPU для сохранения RIP!) • `%r8` - arg5 • `%r9` - arg6 Максимум 6 аргументов - если нужно больше, передаётся указатель на структуру.
Каждый syscall в ядре имеет префикс `sys_`. Например, `open()` в libc вызывает `sys_open()` в kernel. Kernel функция проверяет права доступа, работает с VFS (Virtual File System), взаимодействует с драйверами.
glibc wrapper
**Как glibc оборачивает syscall:** ```c // glibc: sysdeps/unix/sysv/linux/write.c ssize_t __write(int fd, const void *buf, size_t count) { return INLINE_SYSCALL_CALL(write, fd, buf, count); } // Развернётся в: // asm volatile( // "syscall" // : "=a"(ret) // : "a"(1), "D"(fd), "S"(buf), "d"(count) // : "rcx", "r11", "memory" // ); ``` glibc добавляет: • Обработку ошибок (преобразование отрицательных значений в `errno`) • Thread cancellation points • Signal safety checks • Buffering (для stdio)
**Важное замечание:** Syscall numbers - часть **ABI (Application Binary Interface)**, а не API. Они не могут меняться между версиями kernel - это сломало бы совместимость с существующими бинарниками.
strace raw mode
**Трассировка с номерами syscalls:** ```bash $ strace -e trace=write -e raw=write cat /etc/hostname write(0x1, 0x7ffde4b2e000, 0xd) = 13 │ │ │ │ │ │ │ └─ return value (13 bytes) │ │ └─ arg3: count = 13 │ └─ arg2: buffer address └─ arg1: fd = 1 (stdout) ``` `raw=write` показывает syscall в виде чисел (hex), а не символьных имён.
**Syscall hooking:** В прошлом rootkit'ы модифицировали `sys_call_table`, подменяя указатели на свои функции. Современные Linux kernel защищены: • Таблица в read-only памяти (Write Protection enabled) • Модули не могут экспортировать `sys_call_table` • Kernel lockdown mode блокирует изменения Но всё ещё возможно через `/dev/mem` или kernel modules с помощью kprobes.
Почему в x86-64 syscall convention аргумент #4 передаётся в %r10, а не в %rcx?
vDSO - syscalls без kernel transition
**vDSO (virtual Dynamic Shared Object)** - это гениальная оптимизация Linux: kernel внедряет в адресное пространство каждого процесса небольшую shared library с реализацией **некоторых syscalls в user mode**, без перехода в kernel.
**Зачем vDSO?** Некоторые syscalls очень частые и дешёвые: • `gettimeofday()` - чтение текущего времени • `clock_gettime()` - чтение монотонных часов • `getcpu()` - узнать номер CPU Переход в kernel mode (50-200 ns) дороже, чем сама операция (5-10 ns). vDSO выполняет их **напрямую в user space**, читая данные из shared memory, которую ядро обновляет.
Kernel **автоматически маппит** vDSO в адресное пространство при создании процесса через `execve()`. Программа может использовать vDSO через динамический линкер, но это прозрачно - libc сама находит символы в vDSO.
Производительность vDSO
**Бенчмарк: syscall vs vDSO** ```c #include <time.h> #include <sys/time.h> // С vDSO (libc использует vDSO автоматически) struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); // ~20 ns // Без vDSO (прямой syscall) syscall(SYS_clock_gettime, CLOCK_MONOTONIC, &ts); // ~150 ns ``` vDSO даёт ускорение в **7-10 раз** для таких операций!
**Как kernel обновляет vDSO data?** Kernel и user space видят одну и ту же страницу памяти (shared mapping). Kernel периодически обновляет timestamp и коэффициенты, а vDSO функции читают их без syscall.
**Какие syscalls в vDSO (Linux x86-64)?** • `__vdso_gettimeofday` - текущее время • `__vdso_clock_gettime` - монотонные часы • `__vdso_time` - секунды с epoch • `__vdso_getcpu` - номер CPU На других архитектурах (ARM, PowerPC) список может отличаться.
Ограничения vDSO
**Почему не все syscalls в vDSO?** Только syscalls, которые: 1. **Read-only** - не изменяют состояние kernel 2. **Быстрые** - можно реализовать чтением shared memory или CPU-инструкций (TSC) 3. **Частые** - вызываются очень часто (профилирование, timing) Например, `read()/write()` не могут быть в vDSO - они меняют состояние файлов, буферов, требуют проверки прав.
vDSO использует **RDTSC (Read Time-Stamp Counter)** - CPU инструкцию для чтения счётчика циклов процессора. Это очень быстрый (~3 cycles) способ получить монотонное время.
perf stat vDSO
**Реальный замер:** ```bash $ perf stat -e 'syscalls:sys_enter_*' date ср 25 дек 2025 22:43:15 MSK Performance counter stats for 'date': 0 syscalls:sys_enter_clock_gettime ← 0 syscalls! ``` `date` использует `clock_gettime()`, но **0 syscalls** - всё через vDSO!
Все функции libc, которые обращаются к ядру, делают системный вызов с переходом в kernel mode
Некоторые частые syscalls (gettimeofday, clock_gettime) оптимизированы через vDSO и выполняются в user mode без перехода в kernel
vDSO (virtual Dynamic Shared Object) - это shared library, которую kernel внедряет в адресное пространство процесса. Она содержит реализацию некоторых syscalls, которые работают с read-only данными из shared memory. Kernel обновляет эту память (timestamp, коэффициенты TSC), а vDSO функции читают их напрямую. Переход в kernel не нужен, что даёт ускорение в 7-10 раз (20 ns vs 150 ns). Это прозрачно для программы - libc автоматически использует vDSO, если она доступна.
Ключевые идеи
- **Системный вызов** - единственный легальный способ программы попросить kernel выполнить привилегированную операцию. CPU переключается из Ring 3 (user mode) в Ring 0 (kernel mode), выполняет обработчик из syscall table, и возвращается обратно.
- **Переход user → kernel** включает: сохранение регистров (RIP, RSP, RFLAGS), переключение стека на kernel stack, поиск обработчика в sys_call_table[syscall_number]. Overhead: 50-200 ns на современных CPU через syscall/sysret (против ~1000 ns через int 0x80).
- **Syscall table** - массив указателей на kernel функции (sys_read, sys_write, ...). Каждый syscall имеет уникальный номер (часть ABI). На x86-64: номер в %rax, аргументы в %rdi, %rsi, %rdx, %r10, %r8, %r9. glibc оборачивает syscalls в удобные функции (open, read, write).
- **vDSO** - оптимизация для частых read-only syscalls (gettimeofday, clock_gettime). Kernel внедряет shared library в user space с реализацией syscalls, которые читают данные из shared memory. Ускорение в 7-10 раз (20 ns vs 150 ns), без перехода в kernel mode.
Связанные темы
Системные вызовы - фундамент взаимодействия с ОС, связанный со многими концепциями:
- Context Switch — Переключение между процессами похоже на syscall transition - сохранение/загрузка регистров, смена адресного пространства. Но context switch дороже (1-10 мкс vs 50-200 нс), т.к. меняет Page Tables.
- Virtual Memory — Syscall переключает не только стек, но и Page Tables (CR3 register на x86). Kernel space всегда маппится в верхнюю половину адресного пространства (0xFFFF...), user space - в нижнюю.
- File Systems — Syscalls open, read, write, close - основа работы с файлами. Kernel реализует VFS (Virtual File System) для абстракции над разными FS (ext4, btrfs, NFS).
- Interrupts — Hardware interrupts используют похожий механизм перехода в kernel mode через IDT (Interrupt Descriptor Table). Старый способ syscall (int 0x80) был software interrupt.
Вопросы для размышления
- Почему в x86-64 максимум 6 аргументов для syscall? Как передать больше аргументов (например, для sys_clone с 7+ флагами)?
- Как kernel защищает syscall table от модификации (например, от rootkit'ов)? Какие механизмы есть в современных Linux (W^X, KASLR)?
- Почему vDSO не может реализовать write() или open() в user mode, даже если данные в shared memory? Что фундаментально отличает read-only syscalls от модифицирующих?
- Как Spectre/Meltdown атаки используют спекулятивное выполнение для обхода изоляции user/kernel mode? Почему после этих атак syscalls стали медленнее (из-за KPTI - Kernel Page Table Isolation)?
Связанные уроки
- os-02-processes — Процессы делают syscalls для доступа к ресурсам ядра
- os-07-memory — mmap и brk - syscalls для управления памятью
- ca-12 — Trap/interrupt механизм на уровне CPU реализует переключение в ядро
- net-15-tcp-basics — Сетевые операции - syscalls send/recv на каждый пакет
- net-13-ports