Syscall table — как kernel находит обработчик по номеру
Когда вы вызываете read(fd, buf, 4096), ваш процесс на самом деле не «зовёт функцию read». Он кладёт число 0 (номер syscall read) в регистр rax, аргументы в rdi, rsi, rdx, и выполняет инструкцию syscall. Kernel принимает эстафету, смотрит в свою таблицу sys_call_table, по индексу 0 находит указатель на функцию sys_read, и вызывает её.
Это и есть x86_64 syscall ABI — набор правил, по которому процессы и kernel договариваются на самом низком уровне. В этом уроке: что в каком регистре, какие номера у популярных syscall, как этот низкоуровневый интерфейс выглядит из C/Python/Rust, и зачем нам, не системным программистам, это знание.
Зачем системному инженеру знать ABI
«Я не пишу ассемблер». Согласен. Но:
- Чтение perf/eBPF профилей. Они показывают syscalls по номерам и регистрам.
- Понимание seccomp profiles. Seccomp фильтр работает на уровне номеров syscall + значений регистров.
- Дебаг низкоуровневых багов. Когда библиотека ведёт себя странно, иногда быстрее посмотреть, что ушло в kernel, чем что вернула библиотека.
- Migration между архитектурами. ARM, RISC-V используют те же концепции, но другие регистры и номера — если вы переносите код или образ, это всплывает.
- Образование. Самые мощные оптимизации (io_uring, vDSO) опираются на эту базу. Без неё непонятно, почему они работают.
x86_64 syscall ABI: регистры
На Linux x86_64 calling convention для syscall:
| Регистр | Назначение |
|---|---|
rax | Номер syscall (вход); возвращаемое значение (выход) |
rdi | Первый аргумент |
rsi | Второй аргумент |
rdx | Третий аргумент |
r10 | Четвёртый аргумент (НЕ rcx, см. ниже!) |
r8 | Пятый аргумент |
r9 | Шестой аргумент |
rcx | Сохраняется CPU: туда кладётся user RIP при syscall |
r11 | Сохраняется CPU: туда кладётся user RFLAGS |
Заметили: 4-й аргумент идёт через r10, не через rcx, как было бы для обычной функции (System V AMD64 ABI). Это потому что rcx используется самим CPU при выполнении инструкции syscall — туда записывается RIP. Поэтому Linux переопределил: 4-й аргумент через r10.
Номера syscall
Каждый syscall имеет уникальный номер. На x86_64 они определены в <asm/unistd_64.h>:
grep '__NR_' /usr/include/asm/unistd_64.h | head -20
# #define __NR_read 0
# #define __NR_write 1
# #define __NR_open 2
# #define __NR_close 3
# #define __NR_stat 4
# #define __NR_fstat 5
# #define __NR_lstat 6
# #define __NR_poll 7
# #define __NR_lseek 8
# #define __NR_mmap 9
# #define __NR_mprotect 10
# #define __NR_munmap 11
# #define __NR_brk 12
# ...
Обзор популярных:
| Number | Syscall | Что делает |
|---|---|---|
| 0 | read | Чтение из FD |
| 1 | write | Запись в FD |
| 2 | open | Открыть файл (legacy, новые используют openat=257) |
| 3 | close | Закрыть FD |
| 9 | mmap | Отобразить файл/анонимную память |
| 11 | munmap | Освободить mmap |
| 12 | brk | Расширить heap |
| 16 | ioctl | Управление устройством |
| 22 | pipe | Создать pipe |
| 39 | getpid | Свой PID |
| 56 | clone | Создать процесс/тред |
| 59 | execve | Заменить программу |
| 60 | exit | Выход (без cleanup треда) |
| 62 | kill | Послать сигнал |
| 65 | getuid | UID |
| 105 | setuid | Сменить UID |
| 165 | mount | Смонтировать FS |
| 202 | futex | Fast user-space mutex |
| 257 | openat | Современный open с FD-relative paths |
| 318 | getrandom | Случайные байты из kernel CSPRNG |
| 425 | io_uring_setup | Создать io_uring (современный async I/O) |
Числа НЕ являются логически связанными — они просто исторические идентификаторы. Никогда не меняются (стабильный ABI).
Важно: номера разные для разных архитектур. На ARM64 read = 63, openat = 56, etc. Поэтому в C/Python вы используете имена, не числа — библиотека сама подставит правильный номер для вашей архитектуры.
# Номера для текущей архитектуры:
ausyscall --dump
# Или:
grep '__NR_' /usr/include/asm-generic/unistd.h | head
Как kernel находит обработчик
Внутри kernel есть массив sys_call_table[N] — N указателей на функции. По номеру syscall kernel просто индексирует массив:
// Упрощённый псевдокод entry_SYSCALL_64:
long ret = sys_call_table[regs->rax](regs->rdi, regs->rsi, regs->rdx, regs->r10, regs->r8, regs->r9);
regs->rax = ret;
sys_call_table физически существует в kernel memory:
# Посмотреть символ (если включён CONFIG_KALLSYMS):
sudo grep sys_call_table /proc/kallsyms
# ffffffff820016c0 R sys_call_table
# Адрес доступен только root по понятным причинам -- это потенциальный target для эксплойтов.
Сами обработчики — функции типа:
// Упрощённо, реальная sys_read сложнее:
long sys_read(unsigned int fd, char __user *buf, size_t count) {
struct file *f = fget(fd);
if (!f) return -EBADF;
long ret = vfs_read(f, buf, count, &f->f_pos);
fput(f);
return ret;
}
Каждый syscall имеет такую функцию-обработчик. В исходниках Linux они в fs/, kernel/, net/, и т.д.
Возвращаемое значение и errno
В rax возвращается результат. Конвенция:
- Положительное число или 0 — успех. Для
readэто число прочитанных байт. Дляopen— FD. - Отрицательное значение — ошибка. Конкретно:
rax = -errno. Например,-ENOENT = -2,-EACCES = -13.
libc после возврата из syscall проверяет: если значение отрицательное и больше -4096 (зарезервированный диапазон), то это -errno. libc делает:
if (rax < 0 && rax > -4096) {
errno = -rax;
return -1;
}
return rax;
Поэтому в C вы видите read() возвращающий -1 при ошибке, а errno — через библиотеку. На уровне kernel это было одно отрицательное число в rax.
# Посмотреть errno в strace:
strace -e trace=openat cat /nonexistent 2>&1
# openat(AT_FDCWD, "/nonexistent", O_RDONLY) = -1 ENOENT (No such file or directory)
# Под капотом: rax вернул -2, libc проверила, выставила errno=2, вернула -1
Raw syscall vs libc wrapper
Обычно вы вызываете syscall через libc-обёртку (read, write функции). Это удобно: библиотека делает errno, аргументы, форматирование.
Но можно и напрямую — через syscall(NR, args...) функцию:
#include <sys/syscall.h>
#include <unistd.h>
int main() {
// Через libc-обёртку:
write(1, "hello\n", 6);
// Напрямую (тот же эффект):
syscall(SYS_write, 1, "world\n", 6);
return 0;
}
Зачем напрямую? Случаи:
- Новые syscalls. kernel поддерживает, а glibc ещё нет. До glibc 2.34 не было обёртки для
getrandom. - Specific use cases. io_uring имеет тонкий API, проще через syscall.
- Debugging. Хотите быть уверены, что обходите кэширование libc (PID кешируется, например).
- Безопасность. Некоторые seccomp profiles работают только на syscall numbers, не на функциях.
В Python тоже можно через ctypes:
import ctypes
libc = ctypes.CDLL("libc.so.6", use_errno=True)
# Прямой syscall (399 = sched_getattr на x86_64):
result = libc.syscall(399, 0, ctypes.c_void_p(0), 48, 0)
Пример на ассемблере: hello world
Минимальный hello-world на x86_64 без libc:
section .data
msg db "hello", 10
msglen equ $ - msg
section .text
global _start
_start:
; write(1, msg, msglen)
mov rax, 1 ; syscall number = 1 (write)
mov rdi, 1 ; FD = 1 (stdout)
mov rsi, msg ; buffer pointer
mov rdx, msglen ; count
syscall ; CPU выполняет syscall
; exit(0)
mov rax, 60 ; syscall number = 60 (exit)
mov rdi, 0 ; exit code
syscall
# Скомпилировать:
# nasm -f elf64 hello.asm && ld hello.o -o hello && ./hello
# Размер бинарника: ~600 байт. Без libc.
Это самое голое, что можно написать — только инструкции CPU и два syscall. Любая «hello world» в C/Python в итоге делает то же самое, просто через много слоёв.
Различия с другими ABI
Каждая архитектура имеет свой syscall ABI:
| Архитектура | Инструкция | rax-эквивалент | Регистры аргументов |
|---|---|---|---|
| x86_64 | syscall | rax | rdi, rsi, rdx, r10, r8, r9 |
| i386 | int 0x80 | eax | ebx, ecx, edx, esi, edi, ebp |
| ARM64 | svc 0 | x8 (входит), x0 (выход) | x0, x1, x2, x3, x4, x5 |
| ARM (32) | svc 0 | r7, r0 | r0, r1, r2, r3, r4, r5 |
| RISC-V | ecall | a7, a0 | a0, a1, a2, a3, a4, a5 |
И сами номера syscall тоже разные. Linux пытается унифицировать имена, но числа исторически развивались независимо.
seccomp: фильтрация по номерам
Seccomp (secure computing) — механизм фильтрации syscall. Можно сказать «процесс может только эти syscall, остальные — kill».
// Псевдо-конфигурация seccomp:
allow(SYS_read);
allow(SYS_write);
allow(SYS_close);
allow(SYS_exit);
deny_all();
// Любой другой syscall -> процесс убивается с SIGKILL.
Так работает Chrome sandbox: renderer-процессы имеют seccomp-фильтр, который разрешает только минимум syscall. Если эксплойт в JS-движке захочет открыть файл — kernel прервёт процесс.
Docker по умолчанию имеет seccomp-профиль, блокирующий ~50 опасных syscalls (mount, swapon, init_module и т.п.).
Namespaces и cgroups как дополнение к seccomp# Посмотреть seccomp профиль контейнера:
docker run --rm alpine cat /proc/self/status | grep Seccomp
# Seccomp: 2 (filter mode active)
# Seccomp_filters: 1
# Запустить контейнер без seccomp (опасно!):
docker run --security-opt seccomp=unconfined ...
Реальное использование номеров
Где могут пригодиться знания номеров syscall:
# В audit logs:
sudo grep "type=SYSCALL" /var/log/audit/audit.log | head
# type=SYSCALL msg=audit(...): arch=c000003e syscall=257 success=yes exit=3 ...
# 257 = openat. arch=c000003e = x86_64.
# Преобразовать число в имя:
ausyscall x86_64 257
# openat
# В eBPF/bcc:
# Подписаться на конкретный syscall по имени или номеру.
# В perf:
sudo perf trace -e syscalls:sys_enter_openat -- ls
Попробуй сам
# 1. Все номера syscall для вашей архитектуры:
ausyscall --dump | head -20
# Или:
grep '__NR_' /usr/include/asm/unistd_64.h | head -30
# 2. Конкретный номер по имени:
ausyscall x86_64 read
# Покажет: 0
# 3. Имя по номеру:
ausyscall x86_64 257
# openat
# 4. Текущий syscall любого процесса:
cat /proc/$$/syscall
# Покажет: номер_syscall args... 0x.. (адрес инструкции)
# 5. Системный архив всех syscall:
man 2 syscalls
# Большая страница со списком и краткими описаниями
# 6. Сделать syscall напрямую из Python:
python3 -c "
import ctypes
libc = ctypes.CDLL('libc.so.6', use_errno=True)
# write(1, 'hello\\n', 6) -- syscall 1
result = libc.syscall(1, 1, b'hello\n', 6)
print(f'syscall returned: {result}')
"
# 7. То же из Rust (концептуально):
# unsafe { libc::syscall(libc::SYS_write, 1, b'hello\n'.as_ptr(), 6) };
# 8. ABI компании сравнить:
file /usr/bin/ls
# Покажет архитектуру -- x86-64, ARM64, etc.
# 9. Скомпилировать и запустить minimal asm hello-world:
cat > hello.asm << 'EOF'
section .text
global _start
_start:
mov rax, 1
mov rdi, 1
mov rsi, msg
mov rdx, 6
syscall
mov rax, 60
xor rdi, rdi
syscall
section .data
msg db "hello", 10
EOF
nasm -f elf64 hello.asm && ld hello.o -o hello_asm && ./hello_asm
ls -la hello_asm # ~600 байт, без libc