Системные вызовы: что это, зачем нужны ядру и как они работают
В данной статье мы поговорим о системных вызовах (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. Огромное спасибо за тёплые и добрые комментарии под первой частью — ваша поддержка действительно вдохновляет продолжать работу над этой серией и делиться знаниями в такой непростой, но увлекательной теме.
Комментарии (5)

MinimumLaw
25.06.2025 02:51А зачем вы тянете с операционную систему на мега крутом и защищенном языке ABI от пропахшего нафталином С? Может если этого не делать, то и ассемблерные вставки не понадобятся, и unsafe блоков будет поменьше?
По итогу получается, что по сути то же самое, но с самого начала и на новом языке. Все заявленные плюсы сразу куда-то теряются и цели смены языка становятся ну совсем не очевидны.
Вопрос, в целом, риторический. Ответа особенно не требует.

Cvostr
25.06.2025 02:51Почему используется старый способ входа в kernelspace, есть же syscall/sysret

Elieren Автор
25.06.2025 02:51Добрый день.
Я использую
int 0x80, потому что этот способ проще и понятнее для реализации системных вызовов на раннем этапе написания ядра. Он более старый и поэтому по нему больше документации, примеров и исходников в интернете. Особенно когда в ядре ещё нет разграничения между пользовательским и привилегированным режимами или поддержки сегментов.
          
 
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 — он помог разобраться и структурировать трудный материал.