Что такое системный вызов и зачем он нужен
Процесс в Linux работает в пользовательском режиме (Ring 3 на x86-64) и не имеет прямого доступа к аппаратным ресурсам: дискам, сети, памяти других процессов, таймерам. Единственный легитимный способ попросить ядро выполнить привилегированную операцию — системный вызов (syscall). Это фундаментальный интерфейс между приложением и ядром Linux.
Каждый syscall идентифицируется целочисленным номером. Ядро хранит таблицу соответствия номеров функциям-обработчикам. Пользовательский процесс передаёт номер и аргументы, переключает CPU в режим ядра, ядро находит обработчик, выполняет его и возвращает результат.
Полный путь: от строки кода до возврата
Последовательность событий при вызове, например,
write():- Приложение вызывает библиотечную функцию-обёртку (glibc wrapper).
- Обёртка помещает номер syscall и аргументы в регистры CPU.
- Выполняется инструкция перехода в режим ядра (
syscallна x86-64).
- CPU переключается в Ring 0, управление передаётся точке входа ядра.
- Ядро сохраняет регистры, извлекает номер syscall, находит обработчик в таблице.
- Обработчик выполняется, результат записывается в регистр возврата.
- Ядро восстанавливает контекст и выполняет инструкцию возврата в userspace (
sysretна x86-64).
- Обёртка glibc интерпретирует результат: при ошибке устанавливает
errnoи возвращает −1.
Пользовательская сторона: обёртка glibc
Системные вызовы почти никогда не вызываются напрямую. Приложение использует функции из glibc (или другой libc), которые служат тонкими обёртками. Типичная обёртка делает три вещи:
- Копирует аргументы в нужные регистры.
- Выполняет инструкцию
syscall.
- После возврата проверяет результат: если ядро вернуло отрицательное число в диапазоне ошибок, обёртка инвертирует знак, записывает значение в
errnoи возвращает −1 вызывающему коду.
Иногда обёртка выполняет дополнительную логику. Например,
truncate() в glibc проверяет, какой из двух syscall доступен в текущем ядре — truncate или truncate64 — и вызывает подходящий.Для syscall, у которых нет обёртки в glibc, существует функция
syscall() из <sys/syscall.h>. Она принимает номер и аргументы, выполняет те же шаги, что и обычная обёртка.Передача аргументов: конвенция x86-64
На архитектуре x86-64 используется следующая схема:
| Регистр | Назначение |
|---|---|
rax | Номер системного вызова |
rdi | 1-й аргумент |
rsi | 2-й аргумент |
rdx | 3-й аргумент |
r10 | 4-й аргумент |
r8 | 5-й аргумент |
r9 | 6-й аргумент |
Максимум шесть аргументов передаются через регистры. Если syscall требует больше (что редкость), остальные передаются через стек или структуру в памяти, на которую указывает один из регистровых аргументов.
Результат возвращается в
rax. Значение 0 или положительное означает успех. Значения в диапазоне от −4095 до −1 (то есть −4095 ≤ rax ≤ −1) интерпретируются как код ошибки: модуль значения равен номеру errno.На других архитектурах конвенция отличается: на ARM64 используется
x8 для номера и x0–x5 для аргументов, на RISC-V — a7 и a0–a5.Инструкция перехода: syscall и sysret
На x86-64 переход в ядро выполняется инструкцией
syscall. Она делает следующее:- Загружает
ripиз MSRLSTAR(адрес точки входа ядра).
- Загружает
csиssиз MSRSTAR.
- Сохраняет старый
ripвrcx, старыйrflagsвr11.
- Маскирует флаги согласно
FMASKMSR.
- Переключает CPL на 0 (Ring 0).
Возврат в userspace выполняется инструкцией
sysret, которая восстанавливает rip из rcx, rflags из r11 и переключает CPL обратно на 3.До появления
syscall/sysret (на 32-битных x86) использовалось прерывание int 0x80, которое медленнее из-за обращения к таблице IDT.Вход в ядро: entry_SYSCALL_64
Точка входа ядра на x86-64 — функция
entry_SYSCALL_64, определённая в arch/x86/entry/entry_64.S. Она выполняет:- Переключение на стек ядра (через per-CPU данные).
- Сохранение пользовательских регистров в структуру
pt_regsна стеке ядра.
- Извлечение номера syscall из
rax.
- Проверку номера на допустимость.
- Переход к обработчику через таблицу
sys_call_table.
Таблица
sys_call_table — массив указателей на функции, индексированный номером syscall. Определена в arch/x86/entry/syscalls/syscall_64.tbl на этапе сборки ядра.Выполнение обработчика
Обработчик syscall — обычная функция ядра с сигнатурой вида
SYSCALL_DEFINEn(name, ...), где n — число аргументов (от 0 до 6). Макрос разворачивается в функцию с префиксом __x64_sys_ (на x86-64), которая принимает структуру pt_regs и извлекает аргументы из регистров.Пример:
write имеет номер 1 на x86-64 и обработчик __x64_sys_write, который принимает fd, буфер и размер.Обработчик выполняет проверку прав, валидацию аргументов, основную логику и возвращает результат (положительное число при успехе, отрицательный код ошибки при неудаче).
Обработка ошибок и возврат
Ядро возвращает результат в
rax. Конвенция:rax >= 0— успех, значение является результатом.
−4095 ≤ rax ≤ −1— ошибка, модуль значения равен кодуerrno(например, −2 означаетENOENT).
Обёртка glibc проверяет, попадает ли возвращаемое значение в диапазон ошибок. Если да — инвертирует знак, записывает в
errno, возвращает −1. Если нет — возвращает значение как есть.Важно: само ядро не использует глобальную переменную
errno. errno — механизм userspace, реализуемый через thread-local storage в glibc.Прямой вызов без glibc
Если нужно вызвать syscall без libc (например, в минимальном runtime или shellcode-исследовании), можно использовать встроенный ассемблер или функцию
syscall():
C:
#include <unistd.h>
#include <sys/syscall.h>
int main(void) {
const char msg[] = "hello\n";
/* Прямой вызов write(1, msg, 6) через syscall(2) */
long ret = syscall(SYS_write, 1, msg, sizeof(msg) - 1);
return (ret < 0) ? 1 : 0;
}
На чистом ассемблере (x86-64, NASM-синтаксис):
Код:
section .data
msg db "hello", 10
section .text
global _start
_start:
mov rax, 1 ; SYS_write
mov rdi, 1 ; stdout
lea rsi, [rel msg] ; буфер
mov rdx, 6 ; длина
syscall
mov rax, 60 ; SYS_exit
xor rdi, rdi ; код 0
syscall
Наблюдение за syscall в реальном времени
strace
Утилита
strace перехватывает все syscall процесса через ptrace и выводит их с аргументами и возвращаемыми значениями:
Bash:
strace -e trace=write,openat -p <PID>
Флаг
-e trace= фильтрует по именам или категориям (file, network, memory). Флаг -f отслеживает дочерние процессы./proc/<pid>/syscall
Файл показывает номер и аргументы syscall, который процесс выполняет прямо сейчас (или
running, если процесс не в syscall):
Bash:
cat /proc/self/syscall
perf и ftrace
Для профилирования на уровне ядра доступны
perf trace (аналог strace с меньшим overhead) и ftrace-события sys_enter_* / sys_exit_*.Сколько syscall существует
По состоянию на Linux 5.14 доступно более 400 системных вызовов. Список постоянно растёт: новые syscall добавляются в каждом мажорном релизе. Например:
| Syscall | Версия ядра | Назначение |
|---|---|---|
io_uring_setup | 5.1 | Асинхронный I/O через кольцевые буферы |
openat2 | 5.6 | Расширенное открытие файлов с флагами resolve |
clone3 | 5.3 | Создание процессов/потоков с расширенной структурой аргументов |
landlock_create_ruleset | 5.13 | Песочница на уровне файловой системы |
memfd_secret | 5.14 | Выделение памяти, недоступной даже ядру |
Номера syscall зависят от архитектуры. Один и тот же вызов
write имеет номер 1 на x86-64 и номер 64 на ARM64. Таблицы номеров хранятся в arch/<arch>/entry/syscalls/.Типичные ошибки при работе с syscall
- Прямой вызов без обёртки без проверки возврата. Ядро возвращает отрицательный код, а не устанавливает
errno. Если вы используетеsyscall()напрямую, проверяйтеret < 0и интерпретируйте-retкак код ошибки.
- Неправильный номер syscall. Номера различаются между архитектурами. Используйте макросы
SYS_*из<sys/syscall.h>, а не хардкод.
- Предположение об атомарности. Syscall не гарантирует атомарность операции в многопоточном контексте. Например,
read()может вернуть меньше байт, чем запрошено.
- Игнорирование
EINTR. Если процесс получил сигнал во время блокирующего syscall, ядро может вернутьEINTR. Корректный код должен обрабатывать этот случай и повторять вызов.
Проверка: убедитесь, что понимаете путь
- Вызовите
strace -c lsи убедитесь, что видите список syscall с количеством вызовов и временем.
- Напишите программу на C, которая делает
syscall(SYS_getpid)и сравнивает результат сgetpid()из glibc.
- Откройте
/proc/self/syscallв момент, когда процесс заблокирован вread(), и убедитесь, что видите номер 0 (x86-64) и аргументы.
