Learning Platform
Глоссарий Troubleshooting
Урок 13.01 · 22 мин
Средний
SyscallsKernelCPURing 0Linuxx86_64

Что такое 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 vs kernel
Userspace (Ring 3)Ваш процесс. Может: считать, запускать обычные инструкции, обращаться к своей памяти. НЕ может: общаться с железом, читать чужую память, выполнять привилегированные инструкции
syscall
Kernel (Ring 0)Linux kernel. Может всё: обращаться к любой памяти, говорить с устройствами через port I/O или MMIO, обрабатывать interrupts, переключать процессы
Process memoryВиртуальная память процесса. Нижние ~128TB на x86_64. Доступна процессу. Каждый процесс видит свою
Kernel memoryВерхняя половина адресного пространства. Защищена -- userspace не может туда писать или читать. Одна на всю систему

Между этими мирами стоит чёткая граница. Любой переход контролируется — userspace не может «прыгнуть в kernel» в произвольную точку. Только в обозначенные обработчики.


Что физически происходит при syscall

Шаг за шагом, когда ваш Python зовёт os.read(fd, 4096):

  1. Python -> libc. Python-runtime вызывает read из libc (или syscall напрямую через ctypes).
  2. libc подготавливает аргументы. На x86_64 syscall использует регистры (об этом подробно в уроке про syscall table). Номер syscall в rax, аргументы в rdi, rsi, rdx, r10, r8, r9.
  3. Инструкция syscall. CPU выполняет специальную инструкцию syscall (на современном x86_64). Это и есть момент перехода.
  4. CPU переключается в Ring 0. Меняет CPL (current privilege level), сохраняет user RIP/RFLAGS в специальные регистры (rcx, r11). Прыгает в kernel-точку (LSTAR MSR).
  5. Kernel entry point. В Linux это entry_SYSCALL_64. Сохраняет регистры пользователя в стек (kernel stack процесса), переключается на kernel page table.
  6. Lookup в syscall table. По номеру в rax kernel находит обработчик. Для read это sys_read.
  7. Выполнение. sys_read делает свою работу: проверяет права на FD, читает данные, копирует в user buffer.
  8. Возврат. Результат кладётся в rax. Инструкция sysretq восстанавливает Ring 3, прыгает на сохранённый user RIP.
  9. Управление возвращается в libc. libc проверяет возвращаемое значение (отрицательное -> errno), возвращает в Python.
Полный цикл syscall: путь read(fd, buf, 4096)
App (Python)
libc / glibc
CPU
Kernel
read(fd, buf, 4096)mov rax,0; mov rdi,fd; syscallswitch to Ring 0, jump to entry_SYSCALL_64lookup sys_call_table[rax]sys_read: check FD, read data, copy to user bufrax = bytes_read; sysretqback to Ring 3, RIP = post-syscallreturn value (or set errno)

Этот переход стоит времени — около 100-500 наносекунд только на сам switch (не считая работы внутри). На горячих syscall (read/write/recv/send) это становится bottleneck’ом высоконагруженных систем.


Эволюция инструкций syscall

На разных эпохах x86 syscall делался разными способами:

  1. int 0x80 — старый способ, software interrupt. Медленный (~1000 циклов). Использовался до Pentium III.
  2. sysenter/sysexit — Intel, начиная с Pentium II (1997). Быстрее. Использовался в i386 Linux до 2010-х.
  3. 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’а складывается из:

  1. Сама инструкция syscall/sysretq. ~50-100 cycles на современном CPU.
  2. Сохранение/восстановление регистров. CPU сохраняет user state, kernel должен сохранить свой при возврате.
  3. Переключение page table. На современных kernel (после Meltdown) включён KPTI (Kernel Page Table Isolation), который ОТДЕЛЯЕТ kernel и user page tables. Переключение между ними — инвалидация TLB, ~50-200 циклов.
  4. Cache pollution. Kernel-код вытесняет ваши данные из L1/L2 кеша. Когда вернётесь в user, кеш-промахи.
  5. 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~103 нс
Indirect call (vtable, function pointer)~10-205 нс
syscall instruction (минимум)~100-20050 нс
Полный syscall (read 4KB из page cache)~3000-100001-3 мкс
Полный syscall (read 4KB с SSD)~50000-20000050 мкс
Полный syscall (read 4KB с HDD)~10-30 млн10 мс

Простая функция в user space почти бесплатна. Syscall в 30-50 раз дороже даже в идеальном случае. Реальные I/O syscalls — ещё на 2-3 порядка медленнее, но это уже не switch overhead, а работа.


Безопасность: что kernel проверяет на каждом syscall

Когда kernel получает syscall, он не доверяет процессу:

  1. Валидирует аргументы. Если pointer указывает в kernel memory или несуществующую страницу user memory — EFAULT.
  2. Проверяет права. Process UID/EUID должен иметь доступ к запрашиваемой операции.
  3. Проверяет seccomp filter. Если у процесса есть seccomp profile, нет ли запрета на этот syscall.
  4. Проверяет capabilities (для привилегированных операций).
  5. Проверяет 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')
"

Проверка знанийKnowledge check
Python-приложение очень много раз вызывает 'os.getpid()' (для логирования). Профилирование показывает, что 30% времени уходит на это. В то же время 'time.time()' (vDSO) почти бесплатна. Почему такая разница и как ускорить getpid?
ОтветAnswer
os.getpid() -- это реальный syscall, time.time() -- через vDSO. getpid: процесс зовёт getpid в libc, libc подготавливает регистры, выполняет инструкцию syscall, CPU переключается в Ring 0, kernel: проверяет аргументы (их нет), читает PID из task_struct процесса, возвращает значение, CPU переключается обратно в Ring 3. Стоимость ~100-300 ns на switch + KPTI page table swap + cache pollution. Реальная работа -- чтение одного int -- занимает менее 10 ns, но почти всё время это overhead перехода. time.time(): процесс зовёт clock_gettime, который через vDSO. Kernel заранее mapпил специальную страницу в адресное пространство процесса. На этой странице kernel периодически (каждый tick) обновляет timestamp. clock_gettime через vDSO просто читает timestamp из этой страницы прямо в user mode. НЕТ syscall, нет switch, нет KPTI. Только обычное memory read. Стоимость ~5-30 ns. Разница в 10-30 раз -- именно поэтому getpid в горячем цикле дорогая операция, а time.time дешёвая. Как ускорить getpid: 1) Закешировать. PID процесса не меняется за время жизни. Достаточно зайти ОДИН раз: pid = os.getpid() for i in range(N): log(pid, msg) 2) Если используется в multi-thread, всё равно один PID для всех threads (PID = process ID, не thread). Кешировать на уровне модуля. 3) После fork PID меняется в ребёнке -- нужно инвалидировать кеш. Python's logging Formatter решает это через os.fork() detection или просто пересоздаёт значение в новом процессе. 4) В Linux 5.x+ getpid тоже работает через vDSO в некоторых случаях, но не всегда -- зависит от kernel и архитектуры. Не полагаться. Логирование PID -- классический случай этой ошибки. Многие Python-логгеры внутри делают os.getpid() на каждое сообщение. Решение -- LoggerAdapter с pre-fetched PID, или прямое использование переменной. Этот же принцип работает с getuid, getgid, gethostname -- всё это псевдо-неизменное, можно кешировать. С нынешними vDSO/optimization-усилиями kernel'а часть этих syscall тоже становится быстрой, но осознанное кеширование надёжнее.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Что такое 'кольцо привилегий' (Ring) на x86_64?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4