Pull to refresh

Создание своего ядра на Rust. Часть 2

Level of difficultyHard
Reading time9 min
Views1.7K

Системные вызовы: что это, зачем нужны ядру и как они работают

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

  • что такое системные вызовы и зачем они вообще нужны

  • как работает передача управления от программы к ядру

  • как реализовать syscall в собственном ядре на Rust

Если вы ещё не читали первую часть статьи, где мы создавали минимальное ядро на Rust с VGA-выводом и обработкой прерываний — настоятельно рекомендую начать с неё: 👉 Создание своего ядра на Rust. Часть 1

Во второй части мы шагнём дальше — позволим пользовательскому коду вызывать функции ядра безопасно и управляемо. Это важный рубеж: после реализации syscall ваше ядро превращается из "просто программы" в полноценную ОС, где ядро и пользователь взаимодействуют через определённые правила.

Готовы? Погнали.


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

Системные вызовы (system calls, или syscalls) — это механизм, с помощью которого пользовательские программы получают доступ к функциям операционной системы, таким как:

  • вывод на экран,

  • чтение с диска,

  • работа с файлами,

  • получение времени и т. д.

Пользовательский код не может напрямую обращаться к оборудованию или к памяти ядра. Это необходимо по соображениям безопасности и стабильности: одна ошибка в пользовательской программе не должна повредить остальную систему.

Почему нельзя просто вызвать функцию из ядра?

В большинстве ОС память разделена на режим ядра (kernel mode) и режим пользователя (user mode).
Когда запускается пользовательская программа, она работает в «песочнице», не имея прямого доступа к аппаратным ресурсам и ядру.

Поэтому, чтобы, например, вывести символ на экран, программа не может написать напрямую в видеопамять. Она должна попросить ядро: «пожалуйста, выведи символ H по адресу (10, 5)».

Вот тут и вступают в игру системные вызовы — специально выделенные пути связи с ядром. Они:

  • обеспечивают контролируемое переключение в режим ядра;

  • передают аргументы ядру (например, координаты и символ);

  • вызывают конкретную функцию ядра по системному номеру;

  • возвращают результат (если он есть) обратно в пользовательский код.

Простая аналогия

Можно представить системный вызов как окно на стойке регистрации. Пользователь (программа) не может просто зайти в серверную (ядро) и покопаться — он подходит к окну, формулирует запрос, и система выполняет его за него.
Это окно охраняется, фильтрует неправильные запросы, и гарантирует безопасность всей системы.

Примеры системных вызовов в Linux

Syscall

Номер

Назначение

write

1

Записать данные в stdout

read

0

Прочитать данные из stdin

exit

60

Завершить процесс

getpid

39

Получить ID текущего процесса


Как работает передача управления от программы к ядру

Чтобы программа могла попросить у ядра что-то сделать, например, вывести символ на экран или получить системное время — она должна передать управление внутрь ядра. Но как это происходит?

Всё начинается с системного вызова — специального механизма взаимодействия между пользовательским кодом и ядром.

Для работы системных вызовов нам нужен самый низкоуровневый инструмент — ассемблер (assembly), ведь только через него можно напрямую обращаться к регистрам процессора, генерировать прерывания и управлять потоками выполнения.

Что такое регистры процессора?

Регистр — это крошечная ячейка памяти внутри процессора, в которую можно быстро записывать и считывать данные. При вызове системного вызова мы записываем нужные параметры прямо в эти регистры.

Вот как распределяются аргументы системного вызова по регистрам (x86_64 ABI):

Регистр

Значение

rax

Номер системного вызова

rdi

Первый аргумент (arg1)

rsi

Второй аргумент (arg2)

rdx

Третий аргумент (arg3)

rcx

Четвёртый аргумент (arg4)

r8

Пятый аргумент (arg5)

r9

Шестой аргумент (arg6)

Процесс системного вызова — шаг за шагом

Системный вызов — это своего рода "запрос" от программы к ядру. Механизм выглядит так:

  1. Подготовка аргументов
    Программа загружает в регистры значения, которые хочет передать ядру:

    • Что сделать? (номер вызова в rax)

    • С какими параметрами? (в остальных регистрах)

  2. Вызов прерывания
    Выполняется инструкция int 0x80 — это программное прерывание. Процессор приостанавливает выполнение текущей программы и передаёт управление в ядро.

  3. Переход в ядро
    На этапе инициализации ядра была настроена IDT (таблица прерываний), и 0x80-я ячейка указывает на функцию ядра, которая обрабатывает системные вызовы. Эта функция называется, например, syscall_entry.

  4. Сохранение состояния
    Ядро сначала сохраняет все регистры на стек, чтобы не потерять данные пользователя.

  5. Вызов обработчика
    Ядро извлекает значения из регистров и вызывает основную функцию-обработчик (например, syscall_handler), передавая туда номер вызова и аргументы.

  6. Обработка и возврат
    После выполнения нужной логики ядро:

    • Возвращает результат в 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, потребуется:

  1. Настроить IDT и зарегистрировать обработчик прерывания int 0x80.

  2. Написать точку входа на Assembly (syscall_entry) — она вызовется при int 0x80.

  3. Реализовать функцию syscall_handler() на Rust — логика обработки вызовов.

  4. Вызывать 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) выбирается нужная логика:

  • 0sys_print_char:
    Вывод одного символа по координатам x и y, с цветом color.
    Используется write_char(x, y, ch, color).

  • 1sys_print_string:
    Вывод строки, переданной по указателю (arg3) на позицию x, y с цветом color.
    Строка считывается из памяти до нулевого байта (0x00) и передаётся в write_string(...).

  • 2sys_get_ticks:
    Возвращает текущее количество системных тиков с момента загрузки ядра.
    Это значение считывается из атомарной переменной TICKS.

  • 3sys_get_last_key:
    Возвращает код последней нажатой клавиши с клавиатуры.
    Используется переменная LAST_KEYCODE, которая обновляется по прерыванию клавиатуры.

  • 0x10sys_get_heap_size:
    Возвращает общий размер доступной кучи (HEAP_SIZE), полезно для отладки и оценки.

  • 0x11sys_alloc(size):
    Пытается выделить участок памяти размером size байт и вернуть указатель (адрес).
    В случае ошибки возвращается 0.

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

Tags:
Hubs:
+6
Comments5

Articles