Системные вызовы: что это, зачем нужны ядру и как они работают
В данной статье мы поговорим о системных вызовах (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. Огромное спасибо за тёплые и добрые комментарии под первой частью — ваша поддержка действительно вдохновляет продолжать работу над этой серией и делиться знаниями в такой непростой, но увлекательной теме.
Комментарии (3)
MinimumLaw
25.06.2025 02:51А зачем вы тянете с операционную систему на мега крутом и защищенном языке ABI от пропахшего нафталином С? Может если этого не делать, то и ассемблерные вставки не понадобятся, и unsafe блоков будет поменьше?
По итогу получается, что по сути то же самое, но с самого начала и на новом языке. Все заявленные плюсы сразу куда-то теряются и цели смены языка становятся ну совсем не очевидны.
Вопрос, в целом, риторический. Ответа особенно не требует.
Vorono4ka
Рад видеть продолжение статьи! Пока не прочитал до конца, но хотелось спросить: какие книжки/статьи по теме вы читали?
Elieren Автор
Добрый день.
Спасибо большое, рад, что вам нравится серия статей.
Для создания материала я использовал следующие ресурсы:
https://os.phil-opp.com/ru/
https://wiki.osdev.org/Interrupts#System_Calls
https://wiki.osdev.org/Memory_Allocation
и многие другие, которые я уже, честно говоря, не смогу найти — потерялись среди большого объёма материалов.
Поскольку я не очень силён в ассемблере, для изучения и понимания низкоуровневых деталей использовал вот эти статьи:
https://metanit.com/assembler/nasm/5.1.php
https://hackware.ru/?p=8654
В целом, статей по этой теме, к сожалению, не так много, как хотелось бы. Конечно, есть классические и фундаментальные книги, например,
"Operating Systems: Three Easy Pieces" Ремзи Арпаци-Дуссеау и Андреа Арпаци-Дуссеау,
а также множество других источников.
Однако я в основном опирался на материалы, более ориентированные на современную работу с Rust и более высокоуровневый подход, так как классические книги зачастую глубже погружаются в низкоуровневые детали, которые больше подходят для C-разработчиков.
Для уточнения мелких деталей и объяснения сложных моментов, которых не хватало в открытых источниках, я также обращался к ChatGPT — он помог разобраться и структурировать трудный материал.