Механизм системных вызовов
Предпосылки: режимы CPU и системные вызовы (ring 0/3, syscall снаружи, LSTAR, vDSO), процессы (task_struct, kernel stack).
← Межпроцессное взаимодействие | Прерывания →
IPC, сокеты, mmap — всё это API пользовательского пространства. Каждый из них в итоге вызывает syscall: pipe(), sendto(), mmap() кладут номер в rax и передают управление ядру. До сих пор мы видели syscall снаружи — номер, аргументы, результат. Теперь посмотрим, что происходит между инструкцией syscall и sysret внутри ядра.
Программа положила номер вызова в rax, выполнила инструкцию syscall, получила результат — всё это видно из пользовательского кода. Между этими двумя моментами управление пересекло аппаратную границу: сменились привилегии, ядро переключило стек, а на машинах с KPTI — ещё и таблицу страниц. Базовый путь стоит 50–100 нс; на машинах с защитой от Meltdown (KPTI, Kernel Page Table Isolation) добавляется ещё 50–100 нс. strace и ptrace перехватывают именно этот путь, аудит безопасности проверяет, что вход в ядро контролируем — поэтому стоит понимать, что происходит внутри. Проследим вызов read(fd=3, buf, 4096) от инструкции syscall до возврата в пользовательский код.
LSTAR: точка входа, записанная при загрузке
При старте системы ядро выполняет wrmsrl(MSR_LSTAR, entry_SYSCALL_64) — записывает в модельно-специфический регистр (Model-Specific Register, MSR) адрес функции entry_SYSCALL_64, определённой в arch/x86/entry/entry_64.S. Этот регистр доступен только из кольца 0: пользовательский код не может его изменить. Инструкция syscall не принимает адрес — она безусловно прыгает туда, куда указывает LSTAR. Точка входа одна и та же для всех ~450 системных вызовов.
Адрес записывается один раз при загрузке и не меняется до перезагрузки. Это значит, что инструкция syscall — не вызов по адресу, а аппаратный переход в единственную доверенную точку. Программа выбирает только номер вызова, но не может повлиять на то, куда попадёт управление.
Переключение стека
Инструкция syscall переключает CPL (Current Privilege Level) с 3 на 0 и загружает rip из LSTAR, но не переключает стек. Это отличает её от аппаратных прерываний, где процессор автоматически загружает rsp из TSS (Task State Segment — структура процессора с указателями на стеки привилегированных режимов). После syscall регистр rsp по-прежнему указывает на пользовательский стек — область памяти, контролируемую программой. Выполнять код ядра на чужом стеке нельзя: пользовательский код мог записать туда любые данные, включая адреса возврата, ведущие в произвольные места. Кроме того, пользовательский стек ограничен 8 МБ по умолчанию и может быть заполнен.
Первое, что делает entry_SYSCALL_64, — сохраняет пользовательский rsp и загружает указатель на стек ядра. Доступ к данным текущего процессора осуществляется через сегментный регистр gs: инструкция swapgs переключает базу gs с пользовательской на ядерную, и через смещение от этой базы ядро находит per-CPU структуру с указателем на стек ядра текущего потока. На многопроцессорной машине у каждого CPU своя per-CPU область — поэтому два ядра, обрабатывающие системные вызовы одновременно, не конфликтуют.
Ядро при запуске каждого потока выделяет отдельный стек ядра размером 16 КБ (4 страницы, THREAD_SIZE) — это поле stack в task_struct. Стек маленький по сравнению с пользовательским: код ядра написан с расчётом на минимальную глубину вызовов, а рекурсия в ядре — ошибка. Если стек ядра переполнится, последствия катастрофические: данные перезапишут соседние структуры в памяти, и система упадёт в panic. Начиная с ядра 4.9, для обнаружения таких ситуаций между стеком и соседними страницами размещается guard page — неотображённая страница, обращение к которой немедленно вызывает page fault.
до syscall после переключения стека
rsp --> +------------------+ rsp --> +------------------+
| пользоват. стек | | стек ядра |
| (до 8 МБ) | | (16 КБ) |
| контролируется | | выделен ядром |
| программой | | для этого потока |
+------------------+ +------------------+
user rsp сохранён
в per-CPU область
Сохранённый пользовательский rsp нужен для возврата: при выходе из системного вызова ядро восстановит его, и программа продолжит работу на своём стеке, ничего не заметив.
pt_regs: снимок состояния процессора
После переключения стека entry_SYSCALL_64 сохраняет все пользовательские регистры на стеке ядра в структуру pt_regs (определена в arch/x86/include/asm/ptrace.h). Это снимок состояния процессора (snapshot) на момент входа в ядро: rax (номер вызова), rdi, rsi, rdx, r10, r8, r9 (аргументы — регистровая конвенция System V AMD64 ABI), rcx (адрес возврата, сохранён процессором), r11 (флаги, сохранены процессором), rsp (пользовательский стек) и оставшиеся регистры общего назначения.
Для вызова read(fd=3, buf, 4096) на стеке ядра окажется:
pt_regs на стеке ядра
+----------+---------+
| rax | 0 | <-- номер read
| rdi | 3 | <-- fd
| rsi | 0x7ff..| <-- buf (адрес в user space)
| rdx | 4096 | <-- count
| r10 | ... |
| r8 | ... |
| r9 | ... |
| rcx | 0x4012..| <-- адрес возврата (rip до syscall)
| r11 | 0x0202 | <-- RFLAGS
| rsp_user | 0x7ffd..| <-- пользовательский стек
| ... | |
+----------+---------+
Зачем сохранять все регистры, а не только аргументы? Ядро должно вернуть процессору ровно то состояние, которое было до syscall, кроме rax (результат вызова). Если ядро испортит rbx или r12, программа получит повреждённые данные в переменных — отладить такую ошибку почти невозможно. Кроме того, pt_regs используют ptrace (отладка и strace) и обработчик сигналов — им нужен полный контекст.
sys_call_table: массив обработчиков
Регистры сохранены, стек переключён — ядро готово выполнить запрошенную операцию. Обработчик извлекает номер вызова из pt_regs->rax и проверяет его: номер должен быть неотрицательным и меньше NR_syscalls (~450 на x86-64 в ядре 6.x). Если номер за пределами диапазона, ядро возвращает -ENOSYS (Function not implemented) — программа получит ошибку, но не упадёт.
Таблица системных вызовов sys_call_table (определена в arch/x86/entry/syscall_64.c) — массив из ~450 указателей на функции. Поиск обработчика — одна операция индексации: sys_call_table[nr]. Это O(1) по номеру вызова: никаких хеш-таблиц, деревьев или цепочек сравнений. Массив заполняется при компиляции ядра на основе таблицы arch/x86/entry/syscalls/syscall_64.tbl, где каждая строка связывает номер с именем функции.
sys_call_table (массив указателей на функции)
[0] --> __x64_sys_read
[1] --> __x64_sys_write
[2] --> __x64_sys_open
[3] --> __x64_sys_close
...
[9] --> __x64_sys_mmap
...
[~450] --> конец таблицы
nr=0, read(fd=3, buf, 4096):
sys_call_table[0] --> __x64_sys_read(regs)
Номер 0 указывает на __x64_sys_read. Префикс __x64_sys_ — результат макроса SYSCALL_DEFINE3(read, ...), который генерирует обёртку, извлекающую аргументы из pt_regs и вызывающую реальную функцию ksys_read(fd, buf, count). Макрос также проверяет типы аргументов на этапе компиляции — защита от ошибок в определении системных вызовов.
Номера системных вызовов стабильны: read — всегда 0, write — всегда 1. Новые вызовы получают следующий свободный номер, старые никогда не удаляются и не переназначаются. Эта стабильность — часть ABI ядра Linux: бинарник, скомпилированный для ядра 2.6, продолжит работать на ядре 6.x, потому что номера и семантика существующих вызовов не меняются.
Путь выполнения: от sys_read до данных
ksys_read(fd=3, buf, count=4096) начинает работу с проверки файлового дескриптора. Ядро обращается к таблице файловых дескрипторов текущего процесса (task_struct->files), берёт элемент с индексом 3 и получает структуру struct file — она описывает открытый ресурс: текущую позицию чтения (f_pos), флаги (f_flags), права доступа и, главное, указатель на struct file_operations.
file_operations (fops) — таблица указателей на функции, специфичные для типа ресурса. Для обычного файла на ext4 это будут функции файловой системы ext4. Для сокета — функции сетевого стека. Для /dev/null — функция, которая просто возвращает 0 байт. Ядро не знает и не хочет знать, что стоит за дескриптором 3 — оно вызывает fops->read_iter(), а конкретная реализация решает, откуда брать данные.
task_struct
|
+--> files (fdtable)
|
+--[0]--> struct file (stdin)
+--[1]--> struct file (stdout)
+--[2]--> struct file (stderr)
+--[3]--> struct file
|
+--> f_op (file_operations)
| |
| +--> read_iter = ext4_file_read_iter
| +--> write_iter = ext4_file_write_iter
| +--> open = ext4_file_open
|
+--> f_pos = 0
+--> f_flags = O_RDONLY
Этот механизм — полиморфизм через таблицу функций. Один и тот же read() обрабатывает файлы, сокеты, пайпы, устройства — код ksys_read не меняется, меняется только fops, привязанная к конкретному дескриптору. Это тот же паттерн, что и vtable в C++, только реализованный вручную через указатели на функции в структуре.
Для нашего read(fd=3) на обычном файле ext4 путь продолжается: ext4_file_read_iter() проверяет, есть ли запрошенные данные в page cache (кеш страниц файла в оперативной памяти). Если да — данные копируются из page cache в пользовательский буфер через copy_to_user(). Если нет — ядро планирует чтение с диска, процесс засыпает (TASK_INTERRUPTIBLE), и планировщик переключает процессор на другую задачу. Когда данные поступят с диска, процесс проснётся и завершит копирование.
copy_to_user() — критическая функция: она копирует данные из адресного пространства ядра в адресное пространство пользователя. Перед копированием ядро проверяет, что целевой адрес buf действительно принадлежит пользовательскому процессу и доступен для записи. Без этой проверки злонамеренный процесс мог бы передать адрес из пространства ядра и прочитать его содержимое.
Обратный путь: от результата до sysret
Обработчик завершился. Результат — количество прочитанных байт или отрицательный код ошибки — записывается в pt_regs->rax. Для успешного read(fd=3, buf, 4096), если в файле было 4096 байт, regs->rax = 4096.
Перед возвратом в пользовательский код ядро проверяет два флага в thread_info текущего процесса.
Первый — TIF_SIGPENDING: есть ли необработанные сигналы. Если процессу отправили SIGTERM, пока он был внутри read(), обработка произойдёт именно сейчас, на выходе из системного вызова. Ядро не прерывает обработчик системного вызова для доставки сигнала — оно дожидается естественной точки выхода. Вот почему kill -9 не действует мгновенно на процесс в uninterruptible sleep (D state): он не доходит до проверки TIF_SIGPENDING, пока не завершит ожидание I/O.
Второй — TIF_NEED_RESCHED: нужно ли переключить процессор на другую задачу. Планировщик устанавливает этот флаг, когда процесс исчерпал свой квант времени или когда проснулся процесс с более высоким приоритетом. Аппаратные прерывания (например, таймерный тик) прерывают пользовательский код между любыми двумя инструкциями, но решение о переключении задачи принимается не в момент прерывания, а в точках вытеснения: возврат из syscall и возврат из прерывания в userspace. Проверка TIF_NEED_RESCHED на выходе из системного вызова — одна из таких точек; другая — ret_from_intr, выполняемая при возврате из аппаратного прерывания.
entry_SYSCALL_64 (вход) exit to usermode (выход)
save user rsp regs->rax = результат
load kernel stack |
push pt_regs v
| TIF_SIGPENDING?
v да --> доставить сигналы
sys_call_table[rax] |
| v
v TIF_NEED_RESCHED?
__x64_sys_read(regs) да --> schedule()
| |
v v
ksys_read(fd, buf, count) restore pt_regs
| load user rsp
v sysret --> ring 3
результат в regs->rax
После проверок ядро восстанавливает регистры из pt_regs, загружает пользовательский rsp и выполняет sysret. Процессор устанавливает CPL = 3, загружает rip из rcx (адрес инструкции после syscall в пользовательском коде) и восстанавливает RFLAGS из r11. Программа продолжает выполнение, видя результат в rax.
Инструкция sysret быстрее альтернативы iret (которая используется для возврата из прерываний), но предъявляет требования к значениям в rcx и r11. Если пользовательский rip оказывается в неканоническом адресном пространстве (адреса, не принадлежащие ни пользователю, ни ядру), sysret генерирует General Protection Fault уже в кольце 0, но с пользовательским rsp — это создавало уязвимость на ранних ядрах. Современное ядро проверяет rcx перед sysret и в сомнительных случаях использует более безопасный, но медленный путь через iret.
Стоимость входа и выхода
Весь путь — от инструкции syscall до sysret — без учёта времени работы самого обработчика занимает 50-100 наносекунд на современных процессорах. Из чего складывается эта цена: swapgs и сохранение/восстановление ~15 регистров (запись/чтение на стеке ядра), переключение стека (две записи в память), проверка номера вызова и индексация в таблице, проверка флагов при выходе, pipeline flush (сброс конвейера) при смене уровня привилегий. Для сравнения: обычный вызов функции через call/ret стоит 1-5 наносекунд — системный вызов в 20-50 раз дороже даже без учёта самой работы.
На машинах с включённой защитой KPTI (Kernel Page Table Isolation, заплатка от Meltdown) стоимость возрастает. Уязвимость Meltdown (2018) позволяла пользовательскому коду читать память ядра через спекулятивное выполнение: процессор начинал выполнять инструкцию чтения привилегированной памяти, откатывал её при проверке прав, но данные оставались в кеше — и через side-channel атаку (атаку по побочному каналу) их можно было извлечь.
KPTI решает проблему радикально: вместо одной таблицы страниц на процесс ядро поддерживает две. Полная таблица содержит маппинги и пользовательской памяти, и памяти ядра — она используется, когда процессор выполняет код ядра. Урезанная таблица содержит только маппинги пользовательской памяти и минимальный набор trampoline-страниц (страниц-трамплинов для обработки входа в ядро) — она используется, когда процессор выполняет пользовательский код.
ring 3 (user mode) ring 0 (kernel mode)
CR3 --> +-------------------------+ CR3 --> +-------------------------+
| user code : mapped | | user code : mapped |
| user data : mapped | | user data : mapped |
| user heap : mapped | | user heap : mapped |
| user stack : mapped | | user stack : mapped |
|-------------------------| |-------------------------|
| kernel memory : UNMAPPED| | kernel memory : mapped |
| (trampoline : mapped) | | kernel code : mapped |
+-------------------------+ +-------------------------+
урезанная таблица полная таблица
При каждом системном вызове ядро переключает таблицу страниц: на входе — с урезанной на полную, на выходе — обратно. Переключение — запись в регистр CR3 — инвалидирует часть TLB (Translation Lookaside Buffer, кеш трансляции адресов). Процессор теряет закешированные трансляции, и последующие обращения к памяти требуют полного прохода по таблице страниц — это дополнительные 50-100 наносекунд на некоторых конфигурациях. Технология PCID (Process-Context Identifier) смягчает удар: она позволяет процессору хранить в TLB записи от нескольких таблиц страниц одновременно, помечая каждую идентификатором. Без PCID переключение CR3 сбрасывает весь TLB; с PCID — только записи с другим идентификатором.
На процессорах Intel, подверженных Meltdown (большинство моделей до 2019 года), KPTI включён по умолчанию. На AMD (не подвержены Meltdown) и новых Intel (с аппаратным исправлением) его можно отключить. Проверить можно через cat /proc/cpuinfo | grep pti или dmesg | grep "page tables isolation". Для типичного серверного приложения, делающего 50 000 системных вызовов в секунду, KPTI добавляет 50000 * 100 нс = 5 мс накладных расходов в секунду — заметно, но не катастрофично. Для приложений с миллионами вызовов в секунду (высоконагруженные сетевые сервисы) разница ощутимее.
vDSO: когда системный вызов не нужен
Весь описанный путь — LSTAR, стек, pt_regs, таблица, обработчик, sysret — существует потому, что ядро должно выполнить привилегированную операцию. Но некоторые «системные вызовы» только читают данные, которые можно получить без привилегий. clock_gettime() — типичный пример: текущее время вычисляется из базовой метки и аппаратного счётчика (TSC, Time Stamp Counter). Тратить 100-200 наносекунд на переход в ядро расточительно, когда приложение вызывает clock_gettime() тысячи раз в секунду.
Механизм vDSO (virtual Dynamic Shared Object) полностью исключает переход в ядро для таких случаев. Ядро отображает в адресное пространство каждого процесса страницу с кодом и timekeeping-данными (базовое время, множитель, сдвиг). vDSO содержит userspace-реализацию clock_gettime(), которая читает эти данные и вычисляет текущее время через TSC (timestamp counter) процессора — аппаратный регистр, инкрементируемый на каждом такте. Ядро обновляет базовые данные при тике таймера и при корректировке NTP (Network Time Protocol). Вызов clock_gettime() через vDSO — userspace-функция, ~20-50 наносекунд вместо 100-200 через syscall. Подробнее — в режимах CPU и системных вызовах.
vDSO работает только для операций, удовлетворяющих двум условиям: данные обновляются ядром редко, а читаются пользователем часто; и операция не требует привилегий (только чтение). Для read(), write(), open() — то есть для подавляющего большинства системных вызовов — полный путь через LSTAR и обратно остаётся единственным вариантом.
Sources
- Robert Love, 2010, Linux Kernel Development — Chapter 5: System Calls: https://www.oreilly.com/library/view/linux-kernel-development/9780768696974/
- Linux kernel source:
arch/x86/entry/entry_64.S,arch/x86/entry/syscall_64.c: https://elixir.bootlin.com/linux/latest/source/arch/x86/entry/entry_64.S - Jonathan Corbet, 2018, The current state of kernel page-table isolation: https://lwn.net/Articles/741878/