Что такое syscall — физический переход из userspace в kernel
Когда ваш процесс зовёт read(fd, buf, 4096), происходит нечто странное: код, бежавший до этого в вашем pid и под вашим UID, внезапно оказывается в коде ядра Linux, в режиме CPU с другим уровнем привилегий, с правом обращаться к любой памяти и устройству. Через ~100 наносекунд он возвращается обратно к вам с прочитанными байтами, и снова не может делать ничего опасного. Это и есть syscall — легальный, контролируемый способ передать управление kernel.
В этом уроке разберём, что физически происходит в этот момент: режимы CPU, инструкции syscall/sysenter, как kernel находит обработчик, и почему «syscall стоит ~100-500 нс» — это значительная цифра, ради которой существует buffering и vDSO.
Зачем понимать syscalls
«Я пишу Python, мне это нужно?» Нужно, если:
- Профилирование.
strace -cпокажет, на каких syscall ваше приложение тратит время. Без понимания этого — бесполезный шум. - Безопасность. Seccomp — whitelist разрешённых syscall для процесса (Docker, Chrome sandbox). Понимать seccomp = понимать syscalls.
- Производительность.
io_uring,vDSO, eBPF — модерн оптимизации обходят традиционную модель syscall. Без базы — непонятно. - Отладка. Половина системных багов — это «что-то на границе userspace/kernel». strace показывает именно это.
Userspace и kernel space — два разных мира
CPU x86_64 имеет несколько «колец привилегий»:
- Ring 0 — максимум, исполняет kernel. Может всё: trap’ить interrupts, обращаться к любой памяти, говорить с устройствами.
- Ring 3 — минимум, исполняет userspace. Не может выполнять привилегированные инструкции, обращаться к kernel memory, общаться с железом напрямую.
(Ring 1 и 2 на x86 существуют, но не используются в Linux/Windows.)
Каждый процесс работает в Ring 3. Когда вы пишете read(fd, ...), ваш код в Ring 3 не может сам прочитать с диска — у него нет прав, его инструкции упадут с #GP fault. Нужно «попросить kernel» — это и есть syscall.
Между этими мирами стоит чёткая граница. Любой переход контролируется — userspace не может «прыгнуть в kernel» в произвольную точку. Только в обозначенные обработчики.
Что физически происходит при syscall
Шаг за шагом, когда ваш Python зовёт os.read(fd, 4096):
- Python -> libc. Python-runtime вызывает
readиз libc (или syscall напрямую через ctypes). - libc подготавливает аргументы. На x86_64 syscall использует регистры (об этом подробно в уроке про syscall table). Номер syscall в
rax, аргументы вrdi,rsi,rdx,r10,r8,r9. - Инструкция
syscall. CPU выполняет специальную инструкциюsyscall(на современном x86_64). Это и есть момент перехода. - CPU переключается в Ring 0. Меняет CPL (current privilege level), сохраняет user RIP/RFLAGS в специальные регистры (rcx, r11). Прыгает в kernel-точку (LSTAR MSR).
- Kernel entry point. В Linux это
entry_SYSCALL_64. Сохраняет регистры пользователя в стек (kernel stack процесса), переключается на kernel page table. - Lookup в syscall table. По номеру в rax kernel находит обработчик. Для
readэтоsys_read. - Выполнение.
sys_readделает свою работу: проверяет права на FD, читает данные, копирует в user buffer. - Возврат. Результат кладётся в rax. Инструкция
sysretqвосстанавливает Ring 3, прыгает на сохранённый user RIP. - Управление возвращается в libc. libc проверяет возвращаемое значение (отрицательное -> errno), возвращает в Python.
Этот переход стоит времени — около 100-500 наносекунд только на сам switch (не считая работы внутри). На горячих syscall (read/write/recv/send) это становится bottleneck’ом высоконагруженных систем.
Эволюция инструкций syscall
На разных эпохах x86 syscall делался разными способами:
int 0x80— старый способ, software interrupt. Медленный (~1000 циклов). Использовался до Pentium III.sysenter/sysexit— Intel, начиная с Pentium II (1997). Быстрее. Использовался в i386 Linux до 2010-х.syscall/sysret— AMD, потом Intel. Современный x86_64 стандарт. ~100-200 циклов.
Linux x86_64 использует syscall. Старые программы могут всё ещё использовать int 0x80, но это совместимость.
# Посмотреть, какой механизм использует процесс:
strace -e raw=read python3 -c "open('/etc/hostname').read()" 2>&1 | head -5
# Без raw -- читаемые имена. С raw -- регистры в hex.
# Посмотреть в /proc -- какой syscall сейчас в процессе:
cat /proc/$$/syscall
# 0 0x7 0x7fff... 0x100 ... -- 0 = sys_read, аргументы в hex
Почему syscall медленный
Cost syscall’а складывается из:
- Сама инструкция syscall/sysretq. ~50-100 cycles на современном CPU.
- Сохранение/восстановление регистров. CPU сохраняет user state, kernel должен сохранить свой при возврате.
- Переключение page table. На современных kernel (после Meltdown) включён KPTI (Kernel Page Table Isolation), который ОТДЕЛЯЕТ kernel и user page tables. Переключение между ними — инвалидация TLB, ~50-200 циклов.
- Cache pollution. Kernel-код вытесняет ваши данные из L1/L2 кеша. Когда вернётесь в user, кеш-промахи.
- Branch predictor flush (после Spectre).
Все эти штрафы добавились с 2018 года из-за уязвимостей Meltdown/Spectre. Раньше syscall был дешевле (~50 нс). Сейчас 100-500 нс реальная цена.
Это и объясняет, зачем нужен buffering: 1000 syscall * 200 нс = 200 микросекунд оверхеда только на switching. С буфером в 4KB — один syscall = 200 нс. В 1000 раз меньше.
vDSO и syscall-free «syscalls»
Некоторые «syscall»-функции на самом деле работают БЕЗ перехода в kernel. Это vDSO (virtual Dynamic Shared Object) — библиотека, которую kernel мapпит в адресное пространство каждого процесса.
Примеры:
gettimeofday()— читает время из shared memory page, обновляемой kernel’ом.clock_gettime()— то же самое.getcpu()— читает CPU ID из специальной структуры.
# Посмотреть vDSO у процесса:
cat /proc/self/maps | grep vdso
# 7ffd...-7ffd... r-xp 00000000 00:00 0 [vdso]
# Эта страница доступна всем процессам для быстрых псевдо-syscall
# Без vDSO clock_gettime был бы реальным syscall:
strace -e clock_gettime python3 -c "import time; time.time()"
# Если ничего не показалось -- значит ушло через vDSO
Подробно про vDSO в уроке 4. Пока запомните: не каждый «syscall» = реальный syscall.
SYSCALL vs CALL vs INT
Чтобы прочувствовать разницу, сравним стоимости:
| Операция | Cycles | Время на 3 GHz |
|---|---|---|
Обычный call функции в user space | ~10 | 3 нс |
| Indirect call (vtable, function pointer) | ~10-20 | 5 нс |
syscall instruction (минимум) | ~100-200 | 50 нс |
| Полный syscall (read 4KB из page cache) | ~3000-10000 | 1-3 мкс |
| Полный syscall (read 4KB с SSD) | ~50000-200000 | 50 мкс |
| Полный syscall (read 4KB с HDD) | ~10-30 млн | 10 мс |
Простая функция в user space почти бесплатна. Syscall в 30-50 раз дороже даже в идеальном случае. Реальные I/O syscalls — ещё на 2-3 порядка медленнее, но это уже не switch overhead, а работа.
Безопасность: что kernel проверяет на каждом syscall
Когда kernel получает syscall, он не доверяет процессу:
- Валидирует аргументы. Если pointer указывает в kernel memory или несуществующую страницу user memory — EFAULT.
- Проверяет права. Process UID/EUID должен иметь доступ к запрашиваемой операции.
- Проверяет seccomp filter. Если у процесса есть seccomp profile, нет ли запрета на этот syscall.
- Проверяет capabilities (для привилегированных операций).
- Проверяет LSM (AppArmor, SELinux).
Это огромная защитная стена. В прошлом ошибка в kernel позволяла userspace процессу прочитать или записать kernel memory — это серьёзная уязвимость. Современный kernel параноидальный.
# Посмотреть seccomp profile процесса:
cat /proc/$$/status | grep Seccomp
# Seccomp: 0 (без фильтра)
# Seccomp: 2 (с активным filter mode)
# Docker контейнер обычно с фильтром:
docker run --rm alpine cat /proc/self/status | grep Seccomp
# Seccomp: 2
Docker seccomp-профиль: syscall whitelist в действии
Реальный пример: profiling через strace
Самый практичный способ увидеть syscalls — strace. Подробно об этом в следующем уроке, пока пример:
# Сколько и каких syscall сделал python3 -c "print('hi')":
strace -c python3 -c "print('hi')" 2>&1 | tail -30
# % time seconds usecs/call calls errors syscall
# ------ ----------- ----------- --------- --------- ----------------
# 21.45 0.000125 0 253 rt_sigaction
# 16.42 0.000095 0 137 mmap
# 15.40 0.000089 0 96 openat
# 12.20 0.000071 0 85 read
# ...
# Всего ~1000 syscall, прежде чем print(hi) выполнится.
# Python грузит интерпретатор, библиотеки, инициализируется -- много openat, mmap, read.
Эта цифра — ~1000 syscall на запуск Python — объясняет, почему startup time важен для CLI инструментов.
Попробуй сам
# 1. Посмотреть, сколько syscall в простой команде:
strace -c ls / 2>&1 | tail -20
# 2. Сравнить разные программы:
strace -c true 2>&1 | tail -5 # минимум (~30 syscall)
strace -c date 2>&1 | tail -5 # средне (~100)
strace -c python3 -c "" 2>&1 | tail -5 # много (~1000)
# 3. Текущий syscall процесса:
cat /proc/$$/syscall
# Покажет: номер syscall и аргументы в hex
# 4. Все syscall, в которых сейчас «застряли» процессы:
for pid in $(ps -eo pid --no-headers | head -20); do
echo -n "PID $pid: "
cat /proc/$pid/syscall 2>/dev/null | awk '{print $1}'
done | sort | uniq -c | sort -rn
# Часто увидите 0 (read), 7/270 (epoll_wait, select), 35 (nanosleep)
# 5. Эволюция: сколько было int 0x80 vs syscall:
strace -e raw=read,write python3 -c "
import os
os.write(1, b'hello\n')
" 2>&1 | head -10
# Покажет реальные регистры
# 6. vDSO mapping:
cat /proc/self/maps | grep -E 'vdso|vsyscall'
# 7. Время syscall vs функции:
python3 -c "
import time, os
N = 100000
# Просто функция Python:
t = time.time()
for i in range(N):
abs(i)
print(f'function call: {(time.time()-t)*1e9/N:.0f} ns each')
# Syscall (getpid):
t = time.time()
for i in range(N):
os.getpid()
print(f'getpid syscall: {(time.time()-t)*1e9/N:.0f} ns each')
# vDSO (clock_gettime):
t = time.time()
for i in range(N):
time.time()
print(f'time.time (vDSO): {(time.time()-t)*1e9/N:.0f} ns each')
"