Learning Platform
Глоссарий Troubleshooting
Урок 13.03 · 22 мин
Средний
SyscallsABIRegistersx86_64LinuxKernel

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.

x86_64 syscall: как разложены аргументы по регистрам
rax = syscall_nrНомер syscall: 0 = read, 1 = write, 2 = open, ...
rdi = arg1Первый аргумент. Для read: fd. Для open: pathname pointer
rsi = arg2Второй аргумент. Для read: buffer pointer
rdx = arg3Третий аргумент. Для read: count
r10 = arg4Четвёртый аргумент. НЕ rcx, потому что rcx занят CPU при syscall (туда кладётся user RIP)
r8 = arg5Пятый аргумент (редко больше 4)
r9 = arg6Шестой аргумент (очень редко)
syscallИнструкция CPU. Сохраняет user RIP в rcx, user RFLAGS в r11. Переключается в Ring 0. Прыгает в LSTAR (entry_SYSCALL_64)
rax = resultПосле возврата: rax содержит результат. Если отрицательное -- значит -errno. libc преобразует в errno=-rax и возвращает -1

Номера 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
# ...

Обзор популярных:

NumberSyscallЧто делает
0readЧтение из FD
1writeЗапись в FD
2openОткрыть файл (legacy, новые используют openat=257)
3closeЗакрыть FD
9mmapОтобразить файл/анонимную память
11munmapОсвободить mmap
12brkРасширить heap
16ioctlУправление устройством
22pipeСоздать pipe
39getpidСвой PID
56cloneСоздать процесс/тред
59execveЗаменить программу
60exitВыход (без cleanup треда)
62killПослать сигнал
65getuidUID
105setuidСменить UID
165mountСмонтировать FS
202futexFast user-space mutex
257openatСовременный open с FD-relative paths
318getrandomСлучайные байты из kernel CSPRNG
425io_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;
}

Зачем напрямую? Случаи:

  1. Новые syscalls. kernel поддерживает, а glibc ещё нет. До glibc 2.34 не было обёртки для getrandom.
  2. Specific use cases. io_uring имеет тонкий API, проще через syscall.
  3. Debugging. Хотите быть уверены, что обходите кэширование libc (PID кешируется, например).
  4. Безопасность. Некоторые 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_64syscallraxrdi, rsi, rdx, r10, r8, r9
i386int 0x80eaxebx, ecx, edx, esi, edi, ebp
ARM64svc 0x8 (входит), x0 (выход)x0, x1, x2, x3, x4, x5
ARM (32)svc 0r7, r0r0, r1, r2, r3, r4, r5
RISC-Vecalla7, a0a0, 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

Проверка знанийKnowledge check
Junior спрашивает: 'Программа использует syscall номер 257 (openat). Если я перенесу её на ARM64, будет ли тот же номер?'. Объясни, как это работает на самом деле и почему в коде на C/Python никто не пишет числа syscalls.
ОтветAnswer
Нет, на ARM64 номер openat другой -- 56. Каждая архитектура имеет СВОЙ syscall table с независимой нумерацией. Где-то номера общие (для портабельности), где-то совсем разные. Почему так? Linux был сначала на x86, потом портирован на десятки архитектур (i386, x86_64, ARM, ARM64, RISC-V, MIPS, PowerPC, SPARC и т.д.). Когда добавлялись syscalls, на каждой архитектуре они получали следующий свободный номер -- разные команды добавляли в разное время. Унифицировать ретроспективно нельзя -- это бы сломало бинарную совместимость существующих программ. Так что: - x86_64: read=0, write=1, openat=257, futex=202. - ARM64: read=63, write=64, openat=56, futex=98. - i386: read=3, write=4, openat=295. - RISC-V: ... Цифровые номера в исходном коде = баг для портабельности. Поэтому в C/Python никто и не пишет числа. Пишут имена: ```c #include <sys/syscall.h> syscall(SYS_openat, AT_FDCWD, "/etc/hostname", O_RDONLY); // SYS_openat -- макрос. На x86_64 разворачивается в 257, на ARM64 в 56. // Компилятор подставляет правильное число для целевой архитектуры. ``` В Python через ctypes -- то же самое, имена через библиотеку: ```python import os fd = os.open('/etc/hostname', os.O_RDONLY) # os.open в свою очередь делает syscall с правильным номером для платформы. ``` Это часть стабильного ABI Linux: имена syscall неизменны навеки, числа -- только для конкретной архитектуры. Когда числа важны: 1) eBPF/seccomp программы. Здесь работаем на уровне kernel, нужны конкретные номера. Но даже там используются макросы вроде BPF_PROG_SYSCALL, которые разрешаются в нужное число при загрузке. 2) Дебаг ассемблера. Когда читаете disassembled код или crash dump -- видите цифры, нужно знать. 3) strace без -e -- читаемые имена, потому что strace знает таблицу. Но в логах audit -- сырые числа, и преобразование через ausyscall. 4) Перенос кода на другую архитектуру -- если зашили число, оно не работает. Лекарство -- ВСЕГДА имена. Бонус: даже на x86 32-bit и x86_64 (64-bit) номера разные. Например, mmap на x86_64 = 9, а на i386 = 90. Это потому что x86_64 был сделан с нуля без оглядки на 32-bit совместимость. Главный takeaway: используйте имена (SYS_openat, os.open, libc.openat), а не числа. Числа -- только для debugging и audit-логов.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. На Linux x86_64 в каком регистре передаётся номер syscall и возвращается результат?

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

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

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

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