Системный вызов Linux: путь от пользовательского процесса до кода ядра

Что такое системный вызов и зачем он нужен​


Процесс в Linux работает в пользовательском режиме (Ring 3 на x86-64) и не имеет прямого доступа к аппаратным ресурсам: дискам, сети, памяти других процессов, таймерам. Единственный легитимный способ попросить ядро выполнить привилегированную операцию — системный вызов (syscall). Это фундаментальный интерфейс между приложением и ядром Linux.

Каждый syscall идентифицируется целочисленным номером. Ядро хранит таблицу соответствия номеров функциям-обработчикам. Пользовательский процесс передаёт номер и аргументы, переключает CPU в режим ядра, ядро находит обработчик, выполняет его и возвращает результат.

Полный путь: от строки кода до возврата​


Последовательность событий при вызове, например, write():

  1. Приложение вызывает библиотечную функцию-обёртку (glibc wrapper).
  2. Обёртка помещает номер syscall и аргументы в регистры CPU.
  3. Выполняется инструкция перехода в режим ядра (syscall на x86-64).
  4. CPU переключается в Ring 0, управление передаётся точке входа ядра.
  5. Ядро сохраняет регистры, извлекает номер syscall, находит обработчик в таблице.
  6. Обработчик выполняется, результат записывается в регистр возврата.
  7. Ядро восстанавливает контекст и выполняет инструкцию возврата в userspace (sysret на x86-64).
  8. Обёртка 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Номер системного вызова
rdi1-й аргумент
rsi2-й аргумент
rdx3-й аргумент
r104-й аргумент
r85-й аргумент
r96-й аргумент

Максимум шесть аргументов передаются через регистры. Если syscall требует больше (что редкость), остальные передаются через стек или структуру в памяти, на которую указывает один из регистровых аргументов.

Результат возвращается в rax. Значение 0 или положительное означает успех. Значения в диапазоне от −4095 до −1 (то есть −4095 ≤ rax ≤ −1) интерпретируются как код ошибки: модуль значения равен номеру errno.

На других архитектурах конвенция отличается: на ARM64 используется x8 для номера и x0x5 для аргументов, на RISC-V — a7 и a0a5.

Инструкция перехода: syscall и sysret​


На x86-64 переход в ядро выполняется инструкцией syscall. Она делает следующее:

  • Загружает rip из MSR LSTAR (адрес точки входа ядра).
  • Загружает cs и ss из MSR STAR.
  • Сохраняет старый rip в rcx, старый rflags в r11.
  • Маскирует флаги согласно FMASK MSR.
  • Переключает 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. Она выполняет:

  1. Переключение на стек ядра (через per-CPU данные).
  2. Сохранение пользовательских регистров в структуру pt_regs на стеке ядра.
  3. Извлечение номера syscall из rax.
  4. Проверку номера на допустимость.
  5. Переход к обработчику через таблицу 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_setup5.1Асинхронный I/O через кольцевые буферы
openat25.6Расширенное открытие файлов с флагами resolve
clone35.3Создание процессов/потоков с расширенной структурой аргументов
landlock_create_ruleset5.13Песочница на уровне файловой системы
memfd_secret5.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) и аргументы.

Источники​


 
Назад
Верх Низ