В этой статье мы пройдём путь создания простого, но функционального ядра операционной системы на языке Rust.
Разумеется, мы не будем собирать полноценную альтернативу Linux, но сосредоточимся на ключевых компонентах, которые лежат в основе любого ядра.
Мы реализуем:
Примитивный терминал — вывод текста прямо на экран.
Обработку команд — базовый ввод и реакция на команды пользователя.
Получение времени и даты — извлечение из прерываний RTC.
Динамическую память (кучу) — простую реализацию для хранения данных.
Цель статьи — показать, что даже с нуля можно создать ядро, способное выполнять базовые задачи низкоуровневого взаимодействия с оборудованием. Всё это — на современном, безопасном языке Rust.

Подготовка окружения
Для написания ядра мы будем использовать библиотеку bootloader
, так как она обладает хорошей документацией и идеально подходит для таких low-level проектов.
Установка необходимых компонентов (для Linux)
Сначала установим все необходимые утилиты:
sudo apt update
sudo apt install -y qemu qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils virt-manager
Установим Rust
и нужные инструменты:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Затем установим нужную версию Rust toolchain (важно использовать nightly):
rustup toolchain install nightly-2024-11-08
rustup default nightly-2024-11-08
rustup update nightly
Добавим необходимые компоненты для компиляции ядра:
rustup component add llvm-tools-preview
rustup component add rust-src
Проверим версию компилятора:
rustc --version
Установим bootloader
:
cargo install bootimage
Начало
Главный вопрос почему без std? Объяснение #![no_std] и точки входа
Одной из ключевых особенностей написания ядра операционной системы на Rust является отказ от стандартной библиотеки (std
). Но зачем?
Почему нельзя использовать std?
Стандартная библиотека Rust (std
) зависит от операционной системы — она использует:
системные вызовы (например, для работы с файлами или потоками),
аллокацию через ОС (вроде
malloc
,read
,write
),функции стандартной C-библиотеки.
А теперь главный вопрос:
Что, если никакой операционной системы ещё нет?
Верно — мы её только пишем! Поэтому:
std просто не может работать в среде, где нет ядра, системных вызовов и libc.
Что делать? Используем #![no_std]
Чтобы отключить стандартную библиотеку, в корневом файле проекта (обычно main.rs
или lib.rs
) указываем:
#![no_std]
#![no_main]
Это означает, что мы будем писать полностью автономный код, без зависимостей от ОС.
Точка входа в ядро: _start
После настройки #![no_std]
нам нужно вручную определить точку входа в программу, потому что main()
работать не будет.
Мы пишем:
#[no_mangle]
pub extern "C" fn _start() -> ! {
// здесь начинается выполнение нашего ядра
}
Пояснение:
#[no_mangle]
— отключает автоматическое изменение имени функции компилятором (чтобы оно осталось_start
и было видно загрузчику).extern "C"
— говорит, что мы используем соглашение о вызовах C (важно для совместимости на уровне ABI).-> !
— означает, что функция никогда не возвращает управление (что логично — это бесконечно работающее ядро).
Обработка ошибок: panic_handler
При возникновении ошибки компилятор вызывает panic!()
. Но без std
мы обязаны сами реализовать обработчик паники:
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
Это минимальный обработчик, который просто зацикливает выполнение (на практике туда добавляют вывод информации о панике в терминал или лог).
Скрытый текст
Код целиком
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[no_mangle]
pub extern "C" fn _start() -> ! {
// здесь начинается выполнение нашего ядра
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
На данный момент наше ядро ничего не выводит, оно в принципе ничего не делает, а просто висит.
Надо это исправлять.
Для начала добавим вывод текста.
Вывод текста
Так как мы уже поняли, что std
не работает, а значит, и стандартный println!()
работать не будет. Вместо этого мы будем подавать байт-код по адресу для вывода на экран.
Для вывода текста используется VGA-текстовый буфер с адресом 0xb8000
.
Что такое VGA-текстовый режим?
VGA (Video Graphics Array) в текстовом режиме отображает 80 столбцов × 25 строк, т.е. 2000 символов.
Для начала разберём небольшой пример.
static HELLO: &[u8] = b"Hello World!";
#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
let vga_buffer = 0xb8000 as *mut u8;
for (i, &byte) in HELLO.iter().enumerate() {
unsafe {
*vga_buffer.offset(i as isize * 2) = byte;
*vga_buffer.offset(i as isize * 2 + 1) = 0xb;
}
}
loop {}
}
Где мы выведем тест Hello World! на экран синим цветом.
Скрытый текст
Как это работает?
мы создаём массив байт с текстом Hello World!
Создаём ссылку на адрес 0xb8000.
Перебираем наш массив на байты и позиции этих байтов (enumerate).
*vga_buffer.offset(i as isize 2) = byte; - записывает в буфер по позиции i код символа.
*vga_buffer.offset(i as isize * 2 + 1) = 0xb; - записываем на позицию + 1 от символа его цвет.
Скрытый текст
Далее мы заменим этот код вывод на отдельные функции для удобного вызова
use crate::constants::{COLOR_STATUS_BAR, COLS, ROWS};
pub fn write_char(row: usize, col: usize, character: u8, color: u8) {
let vga_buffer = 0xb8000 as *mut u8; // Адрес VGA буфера
// Рассчитываем смещение, используя строки и столбцы
let offset = (row * 80 + col) * 2;
// Записываем символ в VGA буфер
unsafe {
*vga_buffer.offset(offset as isize) = character;
*vga_buffer.offset(offset as isize + 1) = color;
}
}
pub fn clear_screen(width: u16, height: u16) {
let vga_buffer = 0xb8000 as *mut u8;
for i in 0..(width as usize * height as usize * 2) {
unsafe {
*vga_buffer.offset(i as isize) = 0;
}
}
}
pub fn print_buffer(buffer: *mut [[u8; COLS]; ROWS]) {
let width = COLS;
let vga_buffer = 0xb8000 as *mut u8;
unsafe {
for row in 0..ROWS {
for col in 0..COLS {
if row == 24 {
*vga_buffer.offset((24 as isize * 80 as isize + col as isize) * 2) = b'_';
*vga_buffer.offset((24 as isize * 80 as isize + col as isize) * 2 + 1) =
COLOR_STATUS_BAR;
}
if (*buffer)[row][col] != 0 {
*vga_buffer.offset((row as isize * width as isize + col as isize) * 2) =
(*buffer)[row][col];
*vga_buffer.offset((row as isize * width as isize + col as isize) * 2 + 1) =
0x07;
}
}
}
}
}
pub fn write_string(row: usize, col: usize, s: &str, color: u8) {
for (i, byte) in s.bytes().enumerate() {
write_char(row, col + i, byte, color);
}
}
В этом коде мы сразу добавили ручную установку позиции ввода символа — это понадобится нам для реализации полноценного терминала.
Мы также рассмотрели вывод текста на экран.
Ввод текста
Ввод текста — неотъемлемая часть терминала.
Для того чтобы реализовать ввод текста с клавиатуры и отображение его на экране, необходимо использовать прерывания.
Теория: что такое прерывание?
Прерывание — это способ, с помощью которого устройства (например, клавиатура, таймер, сетевая карта и т.д.) могут прервать основной поток выполнения и попросить процессор выполнить обработчик события.
Пример:
Пользователь нажимает клавишу.
Клавиатура отправляет сигнал (IRQ1) в контроллер прерываний (PIC).
PIC уведомляет процессор.
Процессор приостанавливает текущее выполнение и вызывает обработчик прерывания клавиатуры.
use crate::datetime::{CURRENT_TIME, TICKS};
use crate::pic::{ChainedPics, PIC_1_OFFSET, PIC_2_OFFSET};
use core::sync::atomic::Ordering;
use x86_64::instructions::port::Port;
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();
static PICS: spin::Mutex<ChainedPics> =
spin::Mutex::new(unsafe { ChainedPics::new(PIC_1_OFFSET, PIC_2_OFFSET) });
#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
Timer = PIC_1_OFFSET,
Keyboard = PIC_1_OFFSET + 1,
}
impl InterruptIndex {
fn as_u8(self) -> u8 {
self as u8
}
fn as_usize(self) -> usize {
usize::from(self.as_u8())
}
}
extern "x86-interrupt" fn pit_interrupt_handler(_stack_frame: InterruptStackFrame) {
TICKS.fetch_add(1, Ordering::Relaxed);
if TICKS.load(Ordering::Relaxed) % 1000 == 0 {
let mut time = CURRENT_TIME.lock();
time.update();
}
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
}
}
extern "x86-interrupt" fn keyboard_interrupt_handler(_stack_frame: InterruptStackFrame) {
unsafe {
let mut port = Port::new(0x60);
let _scancode: u8 = port.read();
// Здесь можно добавить обработку кода клавиши
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
pub fn init_idt() {
unsafe {
IDT[InterruptIndex::Timer.as_usize()].set_handler_fn(pit_interrupt_handler);
IDT[InterruptIndex::Keyboard.as_usize()].set_handler_fn(keyboard_interrupt_handler);
let idt = &raw mut IDT;
idt.as_ref().expect("IDT is None").load();
PICS.lock().initialize();
}
}
pub fn enable_interrupts() {
x86_64::instructions::interrupts::enable();
}
use x86_64::instructions::port::Port;
pub const PIC_1_OFFSET: u8 = 32;
pub const PIC_2_OFFSET: u8 = PIC_1_OFFSET + 8;
pub struct ChainedPics {
master_command: Port<u8>,
master_data: Port<u8>,
slave_command: Port<u8>,
slave_data: Port<u8>,
}
impl ChainedPics {
pub const unsafe fn new(_offset1: u8, _offset2: u8) -> Self {
ChainedPics {
master_command: Port::new(0x20),
master_data: Port::new(0x21),
slave_command: Port::new(0xA0),
slave_data: Port::new(0xA1),
}
}
pub unsafe fn initialize(&mut self) {
let icw1 = 0x11; // Начальная команда
let icw4 = 0x01; // 8086/88 (MCS-80/85) mode
self.master_command.write(icw1);
self.slave_command.write(icw1);
self.master_data.write(PIC_1_OFFSET);
self.slave_data.write(PIC_2_OFFSET);
self.master_data.write(4); // Указывает на подключение Slave PIC на IRQ2
self.slave_data.write(2); // Указывает на линию Slave PIC
self.master_data.write(icw4);
self.slave_data.write(icw4);
}
pub unsafe fn notify_end_of_interrupt(&mut self, irq: u8) {
if irq >= 8 {
self.slave_command.write(0x20);
}
self.master_command.write(0x20);
}
}
На Timer пока не обращайте внимания — мы поговорим о нём позже в блоке, посвящённом дате и времени.
Данный код реализует прерывание через IDT.
Скрытый текст
IDT (Interrupt Descriptor Table) — это таблица дескрипторов прерываний в архитектуре x86. Она сообщает процессору, какую функцию нужно вызвать при возникновении определённого прерывания или исключения.
В данной таблице мы добавили таймер и нажатие клавиатуры.
Контролируем прерывания мы через PIC.
В данном случае с двумя PIC:
Master PIC | 0x20–0x21 |
Slave PIC | 0xA0–0xA1 |
Далее мы задаём адреса для наших PIC_1_OFFSET = 32
, PIC_2_OFFSET = 40
и сообщаешь PIC, какому IRQ какому вектору IDT соответствует.
Настраиваем master ↔ slave связь.
self.master_data.write(4); // Slave на IRQ2
self.slave_data.write(2); // Подтверждаем на slave
Принцип работы данного прерывания
Когда пользователь нажимает клавишу:
Клавиатура вызывает IRQ1 (вектор
33 = 32 + 1
)CPU вызывает
keyboard_interrupt_handler
extern "x86-interrupt" fn keyboard_interrupt_handler(_stack_frame: InterruptStackFrame) {
let mut port = Port::new(0x60);
let _scancode: u8 = port.read(); // Считываем код клавиши
...
}
Порт
0x60
— это входной порт клавиатуры, откуда мы считываем scancode (код клавиши).После обработки ты вызываешь
PICS.lock().notify_end_of_interrupt(...)
— это обязательно, чтобы сообщить PIC: "я обработал прерывание, можешь отправлять следующее".
Читаем scancode клавиатуры
Для чтения кодов реализовываем функцию get_key()
#![feature(abi_x86_interrupt)]
fn get_key() -> Option<u8> {
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
static mut LAST_SCANCODE: u8 = 0;
unsafe {
if scancode == 0x0E {
delay(200000);
Some(scancode)
} else if scancode != LAST_SCANCODE {
LAST_SCANCODE = scancode;
Some(scancode)
} else {
None
}
}
}
В данном коде мы получаем коды с адреса 0x60
(клавиатура) и обрабатываем.
loop {
if let Some(key) = get_key() {
print_key(key, screen_width, screen_height);
}
}
Таким образом, мы реализовали получение и обработку нажатых клавиш на клавиатуре.
Реализация терминала
Полноценный терминал — это неотъемлемая часть ядра и любой операционной системы.
В этом блоке мы рассмотрим, как создать полноценный ввод-вывод терминала.
Для терминала используем матрицу 80x25
(разрешение VGA).
use constants::{COLS, ROWS};
static mut BUFFER: [[u8; COLS]; ROWS] = [[0; COLS]; ROWS];
Мы будем заполнять её вводом и обновлять экран после каждого ввода.
Также создадим значок ввода $:
и реализуем сдвиг курсора:
на +1 вправо после ввода символа,
на +1 вниз с возвратом каретки в начало строки, когда достигается конец строки.
Кроме того, добавим скроллинг: когда курсор дойдёт до конца терминала, экран будет подниматься, создавая новую строку.
pub const ROWS: usize = 25;
pub const COLS: usize = 80;
pub const MSG: &[u8; 3] = b"$: ";
pub static mut CURRENT_ROW: usize = 0;
pub static mut CURRENT_COL: usize = 0;
use constants::{
COLOR_INFO, COLS, CURRENT_COL, CURRENT_ROW, MSG, ROWS,
};
static mut BUFFER: [[u8; COLS]; ROWS] = [[0; COLS]; ROWS];
static mut CURSOR_POSITION_ROW: usize = 0;
static mut CURSOR_POSITION_COL: usize = 0;
static mut INPUT_BUFFER: String = String::new();
unsafe {
loop {
scroll_status();
if let Some(key) = get_key() {
print_key(key, screen_width, screen_height);
}
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
fn delay(a: u32) {
for _ in 0..a {
unsafe { core::ptr::read_volatile(&0) };
}
}
fn get_key() -> Option<u8> {
let mut port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
static mut LAST_SCANCODE: u8 = 0;
unsafe {
if scancode == 0x0E {
delay(200000);
Some(scancode)
} else if scancode != LAST_SCANCODE {
LAST_SCANCODE = scancode;
Some(scancode)
} else {
None
}
}
}
fn print_key(key: u8, width: u16, height: u16) {
unsafe {
if key == 0x0E {
// Обработка Backspace
if CURRENT_COL > MSG.len() {
BUFFER[CURRENT_ROW][CURRENT_COL] = 0;
CURRENT_COL -= 1;
BUFFER[CURRENT_ROW][CURRENT_COL] = 0;
INPUT_BUFFER.pop();
}
} else if let Some(character) = SCANCODE_MAP[key as usize] {
if character == '\n' {
// Выполнение команды и отображение текущей строки
let stat: bool = commands::command_fn(&raw mut BUFFER, CURRENT_ROW, &INPUT_BUFFER);
if !stat {
CURRENT_ROW += 2;
}
// Очистка буфера после выполнения команды
INPUT_BUFFER.clear();
CURRENT_COL = 0;
if CURRENT_ROW >= 24 {
scroll();
CURRENT_ROW -= 1;
CURRENT_COL = 0;
}
// Печать приглашения
CURRENT_COL = print_prompt(CURRENT_ROW, CURRENT_COL);
} else {
if CURRENT_COL < COLS {
if CURRENT_COL > 78 {
CURRENT_COL = 0;
CURRENT_ROW += 1;
}
BUFFER[CURRENT_ROW][CURRENT_COL] = character as u8;
INPUT_BUFFER.push(character);
CURRENT_COL += 1;
}
}
}
// Обновление текущей позиции курсора
CURSOR_POSITION_ROW = CURRENT_ROW;
CURSOR_POSITION_COL = CURRENT_COL;
// Очищаем экран
vga::clear_screen(width, height);
// Печать буфера на экране
vga::print_buffer(&raw mut BUFFER);
// Отображение курсора на текущей позиции
let cursor_row = CURSOR_POSITION_ROW;
let cursor_col = CURSOR_POSITION_COL;
let vga_buffer = 0xb8000 as *mut u8;
*vga_buffer.offset((cursor_row as isize * width as isize + cursor_col as isize) * 2) = b'_';
*vga_buffer.offset((cursor_row as isize * width as isize + cursor_col as isize) * 2 + 1) =
0x07;
}
}
fn scroll() {
unsafe {
for i in 0..24 {
BUFFER[i] = BUFFER[i + 1];
}
BUFFER[24] = [0; COLS];
}
}
fn scroll_status() {
unsafe {
if CURRENT_ROW == 24 {
scroll();
CURRENT_ROW -= 1;
CURRENT_COL = 0;
CURRENT_COL = print_prompt(CURRENT_ROW, CURRENT_COL);
}
}
}
На данный момент код работать не будет, так как в нём используется
String
, который требует кучу для работы. Кучу и аллокацию мы рассмотрим позже. После части по созданию кучи вернитесь сюда и проверьте всё ещё раз.
Таким образом, у нас есть полноценный терминал, который принимает текст с клавиатуры и выводит его на экран.
Но теперь было бы хорошо не просто вводить текст, а ещё и получать нужный ответ.
Для этого добавим обработчик команд.
Команды
Для того чтобы создать обработчик команд, мы должны получать вводимый с клавиатуры текст, записывать его в переменную, парсить и выполнять соответствующие действия (например, выводить результат в терминал).
Так как у нас уже есть получение команд с клавиатуры и запись их в строку (после части про кучу и аллокацию), мы можем обрабатывать текст в этой строке и выдавать ответ на соответствующую команду.
Создадим обработчик команд.
use crate::constants::{COLS, CURRENT_COL, CURRENT_ROW, ROWS};
use crate::datetime::{get_date, get_time, set_date, set_time};
use crate::vga::{clear_screen, write_char};
use core::arch::asm;
use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;
struct Command<'a> {
name: &'a str,
action: fn(*mut [[u8; COLS]; ROWS], usize) -> bool,
}
impl<'a> Command<'a> {
fn new(name: &'a str, action: fn(*mut [[u8; COLS]; ROWS], usize) -> bool) -> Self {
Command { name, action }
}
}
fn hello_action(buffer: *mut [[u8; COLS]; ROWS], row: usize) -> bool {
unsafe {
let msg = b"HELLO!";
for (i, &byte) in msg.iter().enumerate() {
write_char(row + 1, i, byte, 0x07); // Печатает на строке row + 1
(*buffer)[row + 1][i] = byte; // Записываем в буфер
}
false
}
}
fn time_action(buffer: *mut [[u8; COLS]; ROWS], row: usize) -> bool {
unsafe {
let time = get_time();
let time_str = format!("{:02}:{:02}:{:02}", time.0, time.1, time.2);
for (i, byte) in time_str.bytes().enumerate() {
write_char(row + 1, i, byte, 0x07); // Печатает на строке row + 1
(*buffer)[row + 1][i] = byte; // Записываем в буфер
}
false
}
}
fn date_action(buffer: *mut [[u8; COLS]; ROWS], row: usize) -> bool {
unsafe {
let date = get_date();
let date_str = format!("{:02}.{:02}.{:04}", date.0, date.1, date.2);
for (i, byte) in date_str.bytes().enumerate() {
write_char(row + 1, i, byte, 0x07); // Печатает на строке row + 1
(*buffer)[row + 1][i] = byte; // Записываем в буфер
}
false
}
}
fn date_set_action(buffer: *mut [[u8; COLS]; ROWS], row: usize) -> bool {
unsafe {
let command: &[u8] = &(*buffer)[row][12..22]; // Извлекаем аргументы после `time_add`
let command_str = core::str::from_utf8(command).unwrap_or("").trim();
let mut parts = command_str.split('.');
if let (Some(d), Some(m), Some(y)) = (parts.next(), parts.next(), parts.next()) {
if let (Ok(day), Ok(month), Ok(year)) =
(d.parse::<u8>(), m.parse::<u8>(), y.parse::<u16>())
{
set_date(day, month, year);
let msg = b"Date set!";
for (i, &byte) in msg.iter().enumerate() {
write_char(row + 1, i, byte, 0x07); // Печатает на строке row + 1
(*buffer)[row + 1][i] = byte; // Записываем в буфер
}
return false;
}
}
let msg = b"Invalid date format!";
for (i, &byte) in msg.iter().enumerate() {
write_char(row + 1, i, byte, 0x07); // Печатает на строке row + 1
(*buffer)[row + 1][i] = byte; // Записываем в буфер
}
false
}
}
fn time_set_action(buffer: *mut [[u8; COLS]; ROWS], row: usize) -> bool {
unsafe {
let command: &[u8] = &(*buffer)[row][12..20]; // Извлекаем аргументы после `time_add`
let command_str = core::str::from_utf8(command).unwrap_or("").trim();
let mut parts = command_str.split(':');
if let (Some(h), Some(m), Some(s)) = (parts.next(), parts.next(), parts.next()) {
if let (Ok(hours), Ok(minutes), Ok(seconds)) =
(h.parse::<u8>(), m.parse::<u8>(), s.parse::<u8>())
{
set_time(hours, minutes, seconds);
let msg = b"Time set!";
for (i, &byte) in msg.iter().enumerate() {
write_char(row + 1, i, byte, 0x07); // Печатает на строке row + 1
(*buffer)[row + 1][i] = byte; // Записываем в буфер
}
return false;
}
}
let msg = b"Invalid time format!";
for (i, &byte) in msg.iter().enumerate() {
write_char(row + 1, i, byte, 0x07); // Печатает на строке row + 1
(*buffer)[row + 1][i] = byte; // Записываем в буфер
}
false
}
}
fn error_action(buffer: *mut [[u8; COLS]; ROWS], row: usize) -> bool {
unsafe {
let msg = b"Error: command";
for (i, &byte) in msg.iter().enumerate() {
write_char(row + 1, i, byte, 0x07); // Печатает на строке row + 1
(*buffer)[row + 1][i] = byte; // Записываем в буфер
}
false
}
}
fn reboot_action(buffer: *mut [[u8; COLS]; ROWS], row: usize) -> bool {
unsafe {
let msg = b"Rebooting...";
for (i, &byte) in msg.iter().enumerate() {
write_char(row + 1, i, byte, 0x07); // Печатает на строке row + 1
(*buffer)[row + 1][i] = byte; // Записываем в буфер
}
asm!(
"cli", // Отключаем прерывания
"out 0x64, al", // Отправляем команду на контроллер клавиатуры
"2: hlt", // Метка 2: останавливаем процессор
"jmp 2b", // Переход к метке 2, чтобы создать бесконечный цикл
in("al") 0xFEu8 // Значение 0xFE для команды перезагрузки
);
false
}
}
fn shutdown_action(buffer: *mut [[u8; COLS]; ROWS], row: usize) -> bool {
unsafe {
let msg = b"Shutting down...";
for (i, &byte) in msg.iter().enumerate() {
write_char(row + 1, i, byte, 0x07); // Печатает на строке row + 1
(*buffer)[row + 1][i] = byte; // Записываем в буфер
}
asm!(
"cli", // Отключаем прерывания
"mov ax, 0x5301", // Подключаемся к APM API
"xor bx, bx",
"int 0x15",
"mov ax, 0x530E", // Устанавливаем версию APM на 1.2
"xor bx, bx",
"mov cx, 0x0102",
"int 0x15",
"mov ax, 0x5307", // Выключаем систему
"mov bx, 0x0001",
"mov cx, 0x0003",
"int 0x15",
"hlt", // Останавливаем процессор
options(noreturn, nostack)
);
}
}
fn clear(buffer: *mut [[u8; COLS]; ROWS], _: usize) -> bool {
let screen_width = 80;
let screen_height = 25;
clear_screen(screen_width, screen_height);
unsafe {
for row in (*buffer).iter_mut() {
for cell in row.iter_mut() {
*cell = 0;
}
}
CURRENT_COL = 0;
CURRENT_ROW = 0;
}
true // Возвращаем true
}
pub fn command_fn(buffer: *mut [[u8; COLS]; ROWS], row: usize, command: &String) -> bool {
let (cmd, _) = match command.find(' ') {
Some(pos) => command.split_at(pos),
None => (command.as_str(), ""),
};
let comm = cmd.trim();
// Фильтруем только непустые и ненулевые байты
let mut comm_filtered: Vec<u8> = Vec::new();
for &byte in comm.as_bytes().iter() {
if byte != 0 && !byte.is_ascii_whitespace() {
comm_filtered.push(byte);
}
}
let commands: [Command; 9] = [
Command::new("hello", hello_action),
Command::new("time", time_action),
Command::new("time_set", time_set_action),
Command::new("date", date_action),
Command::new("date_set", date_set_action),
Command::new("error", error_action),
Command::new("reboot", reboot_action),
Command::new("shutdown", shutdown_action),
Command::new("clear", clear),
];
for cmd in commands.iter() {
let cmd_name_bytes: Vec<u8> = cmd.name.bytes().collect();
if comm_filtered == cmd_name_bytes {
let result = (cmd.action)(buffer, row);
if result {
return true;
}
return false; // Завершите цикл, если команда найдена, но не вернула true
}
}
error_action(buffer, row);
false // Возвращаем false, если команда не найдена
}
В данном коде мы получаем строку и парсим из неё данные для понимания, что это за команда и что с ней нужно делать.
Таким образом можно добавлять новые системные команды.
Время и дата
Теперь добавим поддержку даты и времени.
Для этого будем использовать прерывание, как и для ввода текста.
use crate::datetime::{CURRENT_TIME, TICKS};
use crate::pic::{ChainedPics, PIC_1_OFFSET, PIC_2_OFFSET};
use core::sync::atomic::Ordering;
use x86_64::instructions::port::Port;
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();
static PICS: spin::Mutex<ChainedPics> =
spin::Mutex::new(unsafe { ChainedPics::new(PIC_1_OFFSET, PIC_2_OFFSET) });
#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
Timer = PIC_1_OFFSET,
Keyboard = PIC_1_OFFSET + 1,
}
impl InterruptIndex {
fn as_u8(self) -> u8 {
self as u8
}
fn as_usize(self) -> usize {
usize::from(self.as_u8())
}
}
extern "x86-interrupt" fn pit_interrupt_handler(_stack_frame: InterruptStackFrame) {
TICKS.fetch_add(1, Ordering::Relaxed);
if TICKS.load(Ordering::Relaxed) % 1000 == 0 {
let mut time = CURRENT_TIME.lock();
time.update();
}
unsafe {
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
}
}
extern "x86-interrupt" fn keyboard_interrupt_handler(_stack_frame: InterruptStackFrame) {
unsafe {
let mut port = Port::new(0x60);
let _scancode: u8 = port.read();
// Здесь можно добавить обработку кода клавиши
PICS.lock()
.notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
}
}
pub fn init_idt() {
unsafe {
IDT[InterruptIndex::Timer.as_usize()].set_handler_fn(pit_interrupt_handler);
IDT[InterruptIndex::Keyboard.as_usize()].set_handler_fn(keyboard_interrupt_handler);
let idt = &raw mut IDT;
idt.as_ref().expect("IDT is None").load();
PICS.lock().initialize();
}
}
pub fn enable_interrupts() {
x86_64::instructions::interrupts::enable();
}
use core::sync::atomic::AtomicUsize;
use spin::Mutex;
#[derive(Debug, Clone, Copy)]
pub struct DateTime {
pub day: u8,
pub month: u8,
pub year: u16,
pub hours: u8,
pub minutes: u8,
pub seconds: u8,
}
pub static TICKS: AtomicUsize = AtomicUsize::new(0);
pub static CURRENT_TIME: Mutex<DateTime> = Mutex::new(DateTime {
day: 1,
month: 1,
year: 2023,
hours: 12,
minutes: 0,
seconds: 0,
});
impl DateTime {
pub fn update(&mut self) {
self.seconds += 1;
if self.seconds >= 60 {
self.seconds = 0;
self.minutes += 1;
if self.minutes >= 60 {
self.minutes = 0;
self.hours += 1;
if self.hours >= 24 {
self.hours = 0;
self.day += 1;
if self.day > days_in_month(self.month, self.year) {
self.day = 1;
self.month += 1;
if self.month > 12 {
self.month = 1;
self.year += 1;
}
}
}
}
}
}
}
fn days_in_month(month: u8, year: u16) -> u8 {
match month {
1 => 31,
2 => {
if is_leap_year(year) {
29
} else {
28
}
}
3 => 31,
4 => 30,
5 => 31,
6 => 30,
7 => 31,
8 => 31,
9 => 30,
10 => 31,
11 => 30,
12 => 31,
_ => 30,
}
}
fn is_leap_year(year: u16) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
pub fn get_time() -> (u8, u8, u8) {
let time = CURRENT_TIME.lock();
(time.hours, time.minutes, time.seconds)
}
pub fn get_date() -> (u8, u8, u16) {
let time = CURRENT_TIME.lock();
(time.day, time.month, time.year)
}
pub fn set_time(hours: u8, minutes: u8, seconds: u8) {
let mut time = CURRENT_TIME.lock();
time.hours = hours;
time.minutes = minutes;
time.seconds = seconds;
}
pub fn set_date(day: u8, month: u8, year: u16) {
let mut time = CURRENT_TIME.lock();
time.day = day;
time.month = month;
time.year = year;
}
use x86_64::instructions::port::Port;
pub fn init_pit() {
let frequency: u16 = 1193; // Частота таймера ~1мс (1193182 / 1000)
unsafe {
let mut command_port = Port::new(0x43);
command_port.write(0x34 as u8); // Управление PIT: канал 0, режим 2
let mut data_port = Port::new(0x40);
data_port.write((frequency & 0xFF) as u8); // Младший байт
data_port.write((frequency >> 8) as u8); // Старший байт
}
}
В этом коде мы вызываем прерывание времени каждые 1000 тактов и обновляем время.
Активируем таймер:
pub extern "C" fn _start() -> ! {
init_idt();
init_pit();
enable_interrupts();
unsafe {
loop {
scroll_status();
date_status();
time_status();
if let Some(key) = get_key() {
print_key(key, screen_width, screen_height);
}
}
}
}
Чтобы получить время, мы реализуем две функции:
одна — для получения времени (hh:mm:ss),
другая — для получения даты (dd:mm:yyyy).
fn time_status() {
let time = get_time();
let time_str = format!("{:02}:{:02}", time.0, time.1);
for (i, byte) in time_str.bytes().enumerate() {
write_char(24, i + 74, byte, COLOR_INFO); // Печатает на строке row + 1
}
}
fn date_status() {
let date = get_date();
let date_str = format!("{:02}.{:02}.{:04}", date.0, date.1, date.2);
for (i, byte) in date_str.bytes().enumerate() {
write_char(24, i + 62, byte, COLOR_INFO); // Печатает на строке row + 1
}
}
Таким образом, мы реализовали время через прерывания процессора и получение данных с помощью функций.
В данный момент вывод в функциях работать не будет (например,
format!
), так как для этого нужна куча и аллокатор.
Куча и аллокация
Так как мы не можем заранее рассчитать, сколько байт выделить для той или иной переменной или массива — это может оказаться недостаточным для некоторых пользователей и неудобно.
Для этого нам нужна куча.
Скрытый текст
Куча (heap) — это область памяти, предназначенная для динамического (вручную управляемого) выделения памяти во время выполнения программы.
Куча выглядит примерно так:
Heap (память):
[____] [####] [____] [####]
128 64 256 128 ← байты
_ = свободно
# = занято
Это выделенная свободная память в ОЗУ.
Обычно в куче зарезервирован достаточно большой объём памяти, чтобы хватило для всех данных.
Как работает куча?
Когда нам нужно поместить новые данные, например, в векторный массив Vec!
и его данные, выделяется нужное место, данные перемещаются туда, и возвращается ссылка на начало выделенной области памяти, которую получает переменная для работы с Vec
.
При добавлении новых данных в этот массив размер выделенной памяти увеличивается, и новые данные добавляются туда.
Что будет, если при выделении места перед текущей ячейкой уже начинается новая область памяти?
Для решения этой ситуации мы будем использовать аллокатор.
Скрытый текст
Аллокатор (от англ. allocator) — это механизм, который управляет динамической памятью: он выделяет и освобождает участки памяти из области, называемой кучей (heap).
В данной ситуации, когда наша память выглядит примерно так:
+----------------+----------------+---------------------------------+
| Занято I | Занято II | Свободно |
| (128 байт) | (64 байта) | (512 байт) |
+----------------+----------------+---------------------------------+
Так как мы не можем продолжать выделять память дальше, чтобы не затереть данные другой части кучи, мы используем аллокацию памяти.
Как это?
Аллокатор просматривает всю память и ищет новое пространство, которое больше текущего и в которое поместятся старые данные плюс новая информация. Когда он находит такое место, аллокатор выделяет новую ячейку нужного размера, копирует туда старые данные, освобождает старую ячейку памяти, очищает её и дописывает новые данные в конец новой области памяти.
При этом переменной передаётся новый адрес начала этой ячейки.
Таким образом, после работы аллокатора у нас будет новая картина:
+----------------+----------------+------------------+---------------+
| Свободно | Занято II | Занято I | Свободно |
| (128 байт) | (64 байта) | (256 байт) | (256 байт) |
+----------------+----------------+------------------+-------------- +
Без кучи мы не можем использовать такие типы данных, как String
, Vec
и другие, которые поддерживают динамическое выделение памяти для хранения и добавления новых данных.
В предыдущей части мы уже использовали String
, но он не заработает без кучи.
Создадим кучу.
Для её создания используем готовые библиотеки:
use core::mem::MaybeUninit;
use linked_list_allocator::LockedHeap;
Выделим кучу в памяти размером 1 МБ.
pub const HEAP_SIZE: usize = 1024 * 1024; // 1 MiB
pub const PARTITION_OFFSET: usize = 1048576; // 1 MiБ
#![feature(global_allocator)]
#![feature(alloc_error_handler)]
extern crate alloc;
#[global_allocator]
static ALLOCATOR: LockedHeap = LockedHeap::empty();
fn init_heap() {
static mut HEAP_MEMORY: MaybeUninit<[u8; HEAP_SIZE]> = MaybeUninit::uninit();
unsafe {
let heap_start = HEAP_MEMORY.as_mut_ptr() as *mut u8;
ALLOCATOR.lock().init(heap_start, HEAP_SIZE);
}
}
#[alloc_error_handler]
fn alloc_error_handler(_layout: core::alloc::Layout) -> ! {
loop {}
}
Теперь у нас есть полноценная куча размером 1 МБ (можно увеличить), в которую мы можем помещать данные.
То есть теперь у нас работают все типы данных, для которых нужна куча:
String
Vec
Box
и другие.
Теперь приведённый выше код будет работать, так как у него есть всё необходимое для запуска.
Вывод
В этой статье мы не затронули такие важные аспекты, как управление памятью, файловую систему, планировщик задач, драйверы и многие другие элементы полноценного ядра. Тем не менее, мы прошли через основы — с нуля реализовали терминал, обработку команд, таймер, отображение времени и простую реализацию кучи.
Это уже достаточно, чтобы понять, как устроено ядро ОС на низком уровне, и с чего начинается путь системного программирования на Rust.
📎 Полный исходный код проекта, а также пошаговые инструкции по сборке и запуску доступны здесь:
👉 https://github.com/Elieren/NeonForge
💬 Спасибо, что дочитали до конца! Если статья оказалась полезной, интересной или вдохновляющей — значит, я всё сделал не зря. До новых встреч на пути системного программирования и Rust-магии! 🦀✨
P.S. Дополнение из будущего:
Вышла вторая часть статьи, посвящённая реализации системных вызовов в ядре на Rust.
В ней вы узнаете, как устроены syscall
, как они передают данные, как происходит переход из пользовательского режима в режим ядра и многое другое.
Ознакомиться можно здесь: Создание своего ядра на Rust. Часть 2.