Системные вызовы: что это, зачем нужны ядру и как они работают
В данной статье мы поговорим о системных вызовах (syscall) — важнейшем механизме взаимодействия между пользовательским кодом и ядром операционной системы.
Мы разберём:
что такое системные вызовы и зачем они вообще нужны
как работает передача управления от программы к ядру
как реализовать syscall в собственном ядре на Rust

Если вы ещё не читали первую часть статьи, где мы создавали минимальное ядро на Rust с VGA-выводом и обработкой прерываний — настоятельно рекомендую начать с неё: 👉 Создание своего ядра на Rust. Часть 1
Во второй части мы шагнём дальше — позволим пользовательскому коду вызывать функции ядра безопасно и управляемо. Это важный рубеж: после реализации syscall ваше ядро превращается из "просто программы" в полноценную ОС, где ядро и пользователь взаимодействуют через определённые правила.
Готовы? Погнали.
Что такое системные вызовы и зачем они вообще нужны
Системные вызовы (system calls, или syscalls) — это механизм, с помощью которого пользовательские программы получают доступ к функциям операционной системы, таким как:
вывод на экран,
чтение с диска,
работа с файлами,
получение времени и т. д.
Пользовательский код не может напрямую обращаться к оборудованию или к памяти ядра. Это необходимо по соображениям безопасности и стабильности: одна ошибка в пользовательской программе не должна повредить остальную систему.
Почему нельзя просто вызвать функцию из ядра?
В большинстве ОС память разделена на режим ядра (kernel mode) и режим пользователя (user mode).
Когда запускается пользовательская программа, она работает в «песочнице», не имея прямого доступа к аппаратным ресурсам и ядру.
Поэтому, чтобы, например, вывести символ на экран, программа не может написать напрямую в видеопамять. Она должна попросить ядро: «пожалуйста, выведи символ H по адресу (10, 5)».
Вот тут и вступают в игру системные вызовы — специально выделенные пути связи с ядром. Они:
обеспечивают контролируемое переключение в режим ядра;
передают аргументы ядру (например, координаты и символ);
вызывают конкретную функцию ядра по системному номеру;
возвращают результат (если он есть) обратно в пользовательский код.
Простая аналогия
Можно представить системный вызов как окно на стойке регистрации. Пользователь (программа) не может просто зайти в серверную (ядро) и покопаться — он подходит к окну, формулирует запрос, и система выполняет его за него.
Это окно охраняется, фильтрует неправильные запросы, и гарантирует безопасность всей системы.
Примеры системных вызовов в Linux
Syscall | Номер | Назначение |
|---|---|---|
| 1 | Записать данные в stdout |
| 0 | Прочитать данные из stdin |
| 60 | Завершить процесс |
| 39 | Получить ID текущего процесса |
Как работает передача управления от программы к ядру
Чтобы программа могла попросить у ядра что-то сделать, например, вывести символ на экран или получить системное время — она должна передать управление внутрь ядра. Но как это происходит?
Всё начинается с системного вызова — специального механизма взаимодействия между пользовательским кодом и ядром.
Для работы системных вызовов нам нужен самый низкоуровневый инструмент — ассемблер (assembly), ведь только через него можно напрямую обращаться к регистрам процессора, генерировать прерывания и управлять потоками выполнения.
Что такое регистры процессора?
Регистр — это крошечная ячейка памяти внутри процессора, в которую можно быстро записывать и считывать данные. При вызове системного вызова мы записываем нужные параметры прямо в эти регистры.
Вот как распределяются аргументы системного вызова по регистрам (x86_64 ABI):
Регистр | Значение |
|---|---|
| Номер системного вызова |
| Первый аргумент ( |
| Второй аргумент ( |
| Третий аргумент ( |
| Четвёртый аргумент ( |
| Пятый аргумент ( |
| Шестой аргумент ( |
Процесс системного вызова — шаг за шагом
Системный вызов — это своего рода "запрос" от программы к ядру. Механизм выглядит так:
Подготовка аргументов
Программа загружает в регистры значения, которые хочет передать ядру:Что сделать? (номер вызова в
rax)С какими параметрами? (в остальных регистрах)
Вызов прерывания
Выполняется инструкцияint 0x80— это программное прерывание. Процессор приостанавливает выполнение текущей программы и передаёт управление в ядро.Переход в ядро
На этапе инициализации ядра была настроена IDT (таблица прерываний), и 0x80-я ячейка указывает на функцию ядра, которая обрабатывает системные вызовы. Эта функция называется, например,syscall_entry.Сохранение состояния
Ядро сначала сохраняет все регистры на стек, чтобы не потерять данные пользователя.Вызов обработчика
Ядро извлекает значения из регистров и вызывает основную функцию-обработчик (например,syscall_handler), передавая туда номер вызова и аргументы.Обработка и возврат
После выполнения нужной логики ядро:Возвращает результат в
rax.Восстанавливает все регистры.
Выполняет
iretq— специальную инструкцию возврата из прерывания.Возвращает управление обратно программе.
ASCII-схема вызова
Вот схема передачи данных:
Пользовательский код Ядро (syscall_entry) Обработчик ---------------------- ------------------------- ------------------- rax = syscall_number → сохраняем регистры → match syscall_number rdi = arg1 → копируем rdi → rsi → выполняем действие rsi = arg2 → копируем rsi → rdx → (например, вывод текста) rdx = arg3 → ... rcx = arg4 int 0x80 ────────────────▶ [переключение на ядро] ─▶ вызов syscall_handler() ◀─ результат в rax ◀──────────────────────────── iretq (возврат в пользовательский код)
Таким образом, каждый системный вызов — это небольшой "мостик" между пользовательским кодом и ядром. Через него мы можем вызывать любую функцию ядра, строго контролируя, что именно можно делать. Это фундамент безопасности, изоляции и взаимодействия в любой операционной системе.
В следующем разделе мы уже разберём, как реализовать свои собственные системные вызовы на ��зыке Rust + Assembly.
Как реализовать syscall в собственном ядре на Rust
Теперь, когда мы разобрались с тем, что такое системные вызовы и как работает передача управления от программы к ядру, давайте реализуем полноценную поддержку syscall в своём ядре на Rust.
Что нам нужно?
Чтобы реализовать syscall, потребуется:
Настроить IDT и зарегистрировать обработчик прерывания
int 0x80.Написать точку входа на Assembly (
syscall_entry) — она вызовется приint 0x80.Реализовать функцию
syscall_handler()на Rust — логика обработки вызовов.Вызывать syscall из пользовательского/ядрового кода через
asm!.
1) Настройка IDT: регистрация обработчика
Для начала мы должны указать ядру, что делать при вызове прерывания int 0x80. Это делается через IDT:
IDT[0x80].set_handler_addr(x86_64::VirtAddr::new(syscall_entry as u64));
Здесь syscall_entry — это функция, которую мы опишем на Assembly. Она будет вызвана при int 0x80.
2) Assembly: syscall_entry
Это самый низкоуровневый слой — здесь мы сохраняем регистры, подготавливаем аргументы и вызываем syscall_handler.
global_asm!( r#" .att_syntax .globl syscall_entry .text syscall_entry: // Сохраняем регистры push %rax // [rsp+0] push %rdi // [rsp+8] push %rsi // [rsp+16] push %rdx // [rsp+24] push %rcx // [rsp+32] push %r8 // [rsp+40] push %r9 // [rsp+48] push %r10 push %r11 // Распаковываем аргументы по стеку mov 64(%rsp), %rdi // syscall number (из RAX) mov 56(%rsp), %rsi // arg1 (из RDI) mov 48(%rsp), %rdx // arg2 (из RSI) mov 40(%rsp), %rcx // arg3 (из RDX) mov 32(%rsp), %r8 // arg4 (из RCX) mov 24(%rsp), %r9 // arg5 (из R8) // Вызов обработчика mov $syscall_handler, %rax call *%rax // Восстанавливаем регистры pop %r11 pop %r10 pop %r9 pop %r8 pop %rcx pop %rdx pop %rsi pop %rdi pop %rax iretq "# );
Давай подробно разберём блок syscall_entry, написанный на Assembly. Это критически важная часть механизма системных вызовов, потому что именно здесь происходит переключение с пользовательского кода (или кода уровня приложения) на ядро, и мы должны сделать это аккуратно и безопасно.
Что делает syscall_entry
global_asm!( r#" .att_syntax .globl syscall_entry .text syscall_entry:
Это директивы компилятора
.att_syntax— переключает синтаксис на AT&T (принят в GCC)..globl syscall_entry— объявляетsyscall_entryглобальной функцией, доступной извне..text— указывает, что дальше идёт исполняемый код.
Сохраняем регистры
push %rax // [rsp+0] push %rdi // [rsp+8] push %rsi // [rsp+16] push %rdx // [rsp+24] push %rcx // [rsp+32] push %r8 // [rsp+40] push %r9 // [rsp+48] push %r10 push %r11
Зачем: Программа, которая вызывает int 0x80, может рассчитывать, что после возврата из системного вызова все её регистры будут такими же, как до него. Поэтому мы обязаны сохранить их содержимое в стеке.
Это важно для сохранения контекста: если мы этого не сделаем, syscall может повредить данные, с которыми работает программа.
Извлекаем аргументы
mov 64(%rsp), %rdi // syscall number (из RAX) mov 56(%rsp), %rsi // arg1 (из RDI) mov 48(%rsp), %rdx // arg2 (из RSI) mov 40(%rsp), %rcx // arg3 (из RDX) mov 32(%rsp), %r8 // arg4 (из RCX) mov 24(%rsp), %r9 // arg5 (из R8)
Объяснение: Аргументы были переданы в регистры перед int 0x80, но мы уже их перезаписали push-ами, поэтому теперь достаём старые значения из стека:
%rdi←%rax(номер системного вызова)%rsi←%rdi(1-й аргумент)и так далее…
Стек растёт вниз, поэтому 64(%rsp) — это верхняя точка, где лежит изначальный rax.
Вызов обработчика
mov $syscall_handler, %rax call *%rax
Здесь мы просто вызываем функцию syscall_handler, написанную на Rust. Все аргументы уже лежат в регистрах, как требует соглашение о вызовах System V ABI (Linux):
%rdi→ 1-й аргумент%rsi→ 2-й%rdx→ 3-й%rcx→ 4-й%r8→ 5-й%r9→ 6-й
Восстановление регистров
pop %r11 pop %r10 pop %r9 pop %r8 pop %rcx pop %rdx pop %rsi pop %rdi pop %rax
Теперь, когда syscall_handler закончил работу и вернул результат (в rax), мы восстанавливаем все сохранённые ранее регистры в обратном порядке.
Завершаем прерывание
iretq "# );
iretq — специальная инструкция возврата из прерывания на x86_64. Она:
вытаскивает старый RIP, CS и RFLAGS из стека,
возвращает управление обратно программе, которая вызвала
int 0x80.
Если бы мы использовали ret, то программа бы просто упала — iretq нужен именно для возврата из прерывания, чтобы восстановить права доступа и флаги.
3) Rust: syscall_handler
Это основная логика. Функция получает номер вызова и аргументы, обрабатывает их и возвращает результат:
#[no_mangle] pub extern "C" fn syscall_handler( num: u64, arg1: u64, arg2: u64, arg3: u64, arg4: u64, arg5: u64, arg6: u64, ) -> u64 { match num { 0 => { // sys_print_char(x, y, ch, color) write_char(arg1 as usize, arg2 as usize, arg3 as u8, arg4 as u8); 0 } 1 => { // sys_print_string(x, y, ptr, color) unsafe { let ptr = arg3 as *const u8; let color = arg4 as u8; let mut len = 0; while *ptr.add(len) != 0 { len += 1; } let slice = core::slice::from_raw_parts(ptr, len); if let Ok(s) = core::str::from_utf8(slice) { write_string(arg1 as usize, arg2 as usize, s, color); } } 0 } 2 => { // sys_get_ticks() TICKS.load(core::sync::atomic::Ordering::Relaxed) }, 3 => { ... }, // sys_get_last_key 0x10 => { ... }, // sys_get_heap_size 0x11 => { ... }, // sys_alloc 0x12 => { ... }, // sys_dealloc _ => 0, } }
syscall_handler: как работает и зачем нужен
Функция syscall_handler — это ядро всей системы системных вызовов. Она вызывается каждый раз, когда пользовательский код выполняет int 0x80, и в неё автоматически попадают значения из регистров процессора. Это позволяет ядру обработать запрос и вернуть результат.
Входные данные
При вызове системного вызова из пользовательского кода, параметры помещаются в определённые регистры.
После этого, ассемблерный код (syscall_entry) передаёт эти значения в syscall_handler как обычные аргументы функции.
Логика выбора действий
match num { 0 => { ... }, // sys_print_char 1 => { ... }, // sys_print_string 2 => { ... }, // sys_get_ticks 3 => { ... }, // sys_get_last_key 0x10 => { ... }, // sys_get_heap_size 0x11 => { ... }, // sys_alloc 0x12 => { ... }, // sys_dealloc _ => 0, // неизвестный вызов — вернуть 0 }
По номеру вызова (num) выбирается нужная логика:
0—sys_print_char:
Вывод одного символа по координатамxиy, с цветомcolor.
Используетсяwrite_char(x, y, ch, color).1—sys_print_string:
Вывод строки, переданной по указателю (arg3) на позициюx,yс цветомcolor.
Строка считывается из памяти до нулевого байта (0x00) и передаётся вwrite_string(...).2—sys_get_ticks:
Возвращает текущее количество системных тиков с момента загрузки ядра.
Это значение считывается из атомарной переменнойTICKS.3—sys_get_last_key:
Возвращает код последней нажатой клавиши с клавиатуры.
Используется переменнаяLAST_KEYCODE, которая обновляется по прерыванию клавиатуры.0x10—sys_get_heap_size:
Возвращает общий размер доступной кучи (HEAP_SIZE), полезно для отладки и оценки.0x11—sys_alloc(size):
Пытается выделить участок памяти размеромsizeбайт и вернуть указатель (адрес).
В случае ошибки возвращается 0.0x12—sys_dealloc(ptr, size):
Освобождает ранее выделенную область памяти по адресуptrс размеромsize.
4) Вызов syscall из кода
Для вызова системного вызова из ядра или пользовательского режима, используем inline-assembly:
#[inline(always)] pub fn sys_print_char(x: u64, y: u64, ch: u8, color: u8) { unsafe { core::arch::asm!( "int 0x80", in("rax") 0, // syscall number in("rdi") x, // x in("rsi") y, // y in("rdx") ch as u64, // character in("rcx") color as u64, // color options(nostack) ); } }
Дисклеймер:
В полноценной операционной системе подобные функции (например,
sys_print_char) вызываются только из пользовательских программ, а не из самого ядра. Однако в демонстрационных целях такую функцию реализуем прямо в ядре — для упрощения тестирования и отладки работыsyscall.При реализации пространств памяти (user/kernel space) такие вызовы должны находиться в пользовательской части и взаимодействовать с ядром через публичный интерфейс API.
Результат
Теперь ядро может:
Обрабатывать вызовы от приложений.
Изолировать функциональность (вывод текста, получение времени, работа с памятью).
Гарантировать безопасность: приложения могут обращаться только к тем возможностям, которые предоставляет
syscall_handler.
Вывод
Теперь мы разобрались, как работают системные вызовы в ядре операционной системы. Мы увидели, как данные передаются через регистры, как вызывается программное прерывание int 0x80, зачем оно нужно и как это прерывание обрабатывается внутри самого ядра.
Мы также изучили, как реализовать простейшие syscall-функции на языке Rust, используя inline-assembly, и как обеспечить корректную передачу управления от пользовательского кода к ядру и обратно.
Надеюсь, эта статья оказалась для вас полезной и интересной. Спасибо за прочтение!
📎 Полный исходный код проекта, а также пошаговые инструкции по сборке и запуску доступны здесь: 👉 https://github.com/Elieren/NeonForge
P.S. Огромное спасибо за тёплые и добрые комментарии под первой частью — ваша поддержка действительно вдохновляет продолжать работу над этой серией и делиться знаниями в такой непростой, но увлекательной теме.
