Продолжаем серию статей, посвящённую написанию собственной минималистичной ОС. В прошлой части мы познакомились со всеми вводными компонентами проекта и поставили общие цели. В этой же мы реализуем загрузку ядра, вывод строки Hello World!, механизм паники ядра, а также некоторые функции управления памятью и работы со строками.
▍ Навигация по вышедшим частям
- Часть 1
- Часть 2 < — вы здесь.
Загрузка ядра
При включении компьютера происходит инициализация процессора, и он начинает выполнять код операционной системы. В свою очередь, система инициализирует аппаратное обеспечение и запускает приложения. Весь этот процесс называется «загрузкой».
А что происходит до запуска ОС? В персональных компьютерах подсистема BIOS (в современных ПК UEFI) инициализирует оборудование, отображает экран заставки и загружает ОС с диска. В случае же виртуальной машины QEMU эквивалентом BIOS/UEFI выступает OpenSBI.
▍ Supervisor Binary Interface (SBI)
Двоичный интерфейс Supervisor (SBI) — это API для ядра ОС, который определяет, что именно будет предоставлять прошивка (OpenSBI) операционной системе.
Спецификация SBI опубликована на GitHub. В ней определена полезная функциональность вроде отображения символов в отладочной консоли (например, последовательный порт), перезагрузки/выключения и настройки таймера.
Наиболее распространённой реализацией SBI является OpenSBI. В QEMU OpenSBI запускается по умолчанию, выполняет аппаратную инициализацию и загружает ядро.
▍ Загрузим OpenSBI
Для начала посмотрим, как запускается OpenSBI. Создайте скрипт оболочки с названием
run.sh
:$ touch run.sh
$ chmod +x run.sh
run.sh
#!/bin/bash
set -xue
# Путь QEMU
QEMU=qemu-system-riscv32
# Запуск QEMU
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot
QEMU получает различные опции, определяющие запуск виртуальной машины. В нашем скрипте используются такие:
-
-machine virt
: запуск машиныvirt
. Проверить, какие ещё машины поддерживаются, можно через-machine '?'
. -
-bios default
: использование предустановленной прошивки (в нашем случае OpenSBI). -
-nographic
: запуск QEMU без окна GUI. -
-serial mon:stdio
: подключение стандартного ввода/вывода QEMU к последовательному порту виртуальной машины. Указаниеmon:
позволяет переключаться на монитор QEMU нажатием Ctrl+A и затем C. -
--no-reboot
: если виртуальная машина даст сбой, остановить эмулятор без перезагрузки (полезно для отладки).
Подсказка
В MacOS проверить путь к QEMU можно следующей командой:
$ ls $(brew --prefix)/bin/qemu-system-riscv32 /opt/homebrew/bin/qemu-system-riscv32
Теперь запустите скрипт. Отобразится такой баннер:
$ ./run.sh
OpenSBI v1.2
____ _____ ____ _____
/ __ \ / ____| _ \_ _|
| | | |_ __ ___ _ __ | (___ | |_) || |
| | | | '_ \ / _ \ '_ \ \___ \| _ < | |
| |__| | |_) | __/ | | |____) | |_) || |_
\____/| .__/ \___|_| |_|_____/|____/_____|
| |
|_|
Platform Name : riscv-virtio,qemu
Platform Features : medeleg
Platform HART Count : 1
Platform IPI Device : aclint-mswi
Platform Timer Device : aclint-mtimer @ 10000000Hz
...
OpenSBI показывает версию OpenSBI, имя платформы, функции, количество HART (ядер процессора) и другие параметры для отладочных целей.
Сейчас нажатие любой клавиши ни к чему не приведёт. Дело в том, что стандартный ввод/вывод QEMU подключен к последовательному порту виртуальной машины, и вводимые вами символы отправляются в OpenSBI, но входящие символы никто не считывает.
Нажмите Ctrl+A и затем C, чтобы перейти в консоль отладки QEMU (монитор QEMU). Выйти из QEMU можно командой
q
:QEMU 8.0.2 monitor - type 'help' for more information
(qemu) q
Подсказка
Ctrl+A, помимо переключения на монитор QEMU (клавиша C), имеет ещё несколько функций. Например, нажатие X приведёт к выходу из QEMU.
- C-a h печать этой справки.
- C-a x выход из эмулятора.
-
C-a s сохранение данных с диска обратно в файл (
if -snapshot
). - C-a t включение временных меток консоли.
-
C-a b отправка
Break
(магический ключsysrq
). - C-a c переключение между консолью и монитором.
- C-a C-a отправляет C-a в эмулируемую систему.
▍ Скрипт компоновщика
Скрипт компоновщика — это файл, который определяет структуру памяти исполняемых файлов. Исходя из этой структуры, компоновщик назначает адреса функциям и переменным.
Создадим файл
kernel.ld
:kernel.ld
ENTRY(boot)
SECTIONS {
. = 0x80200000;
.text :{
KEEP(*(.text.boot));
*(.text .text.*);
}
.rodata : ALIGN(4) {
*(.rodata .rodata.*);
}
.data : ALIGN(4) {
*(.data .data.*);
}
.bss : ALIGN(4) {
__bss = .;
*(.bss .bss.* .sbss .sbss.*);
__bss_end = .;
}
. = ALIGN(4);
. += 128 * 1024; /* 128KB */
__stack_top = .;
}
Вот основные части скрипта компоновщика:
- точка входа ядра — функция
boot
; - базовый адрес —
0x80200000
; - секция
.text.boot
всегда размещается в начале; - все секции размещаются в последовательности
.text
,.rodata
,.data
и.bss
; - стек ядра идёт после секции
.bss
и имеет размер 128 КБ.
Упомянутые секции
.text
, .rodata
, .data
и .bss
являются областями данных, выполняющими конкретные роли:Секция | Описание |
.text |
Содержит код программы. |
.rodata | Содержит постоянные данные только для чтения. |
.data | Содержит данные для чтения/записи. |
.bss | Содержит данные для чтения/записи с изначально нулевым значением. |
ENTRY(boot)
объявляет, что точкой входа в программу является функция boot
. Далее в блоке SECTIONS
определяется расположение каждой секции.Директива
*(.text .text.*)
размещает по этому адресу секцию .text
и все секции из всех файлов (*
), начинающиеся с .text.
.Символ
.
представляет текущий адрес. По мере размещения данных, например, посредством *(.text)
, он автоматически инкрементируется. Инструкция . += 128 * 1024
означает «продвинуть текущий адрес на 128 КБ». При этом директива ALIGN(4)
обеспечивает, чтобы текущий адрес выравнивался по границе 4 байта.Наконец,
__bss = .
присваивает текущий адрес символу __bss
. В языке С обращение к заданному символу производится с помощью extern char symbol_name
.Подсказка
Скрипты компоновщика предоставляют множество полезных возможностей, особенно для разработки ядра. Реальные примеры их использования легко найти на GitHub.
▍ Минималистичное ядро
Теперь мы готовы к написанию ядра. Пожалуй, начнём с минимального. Создайте файл исходного кода С под именем
kernel.c
:kernel.c
typedef unsigned char uint8_t;
typedef unsigned int uint32_t;
typedef uint32_t size_t;
extern char __bss[], __bss_end[], __stack_top[];
void *memset(void *buf, char c, size_t n) {
uint8_t *p = (uint8_t *) buf;
while (n--)
*p++ = c;
return buf;
}
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
for (;;);
}
__attribute__((section(".text.boot")))
__attribute__((naked))
void boot(void) {
__asm__ __volatile__(
"mv sp, %[stack_top]\n" // Устанавливаем указатель стека
"j kernel_main\n" // Переходим к функции main ядра
:
: [stack_top] "r" (__stack_top) // Передаём верхний адрес стека в виде %[stack_top]
);
}
Разберём его основные фрагменты.
▍ Точка входа ядра
Выполнение ядра начинается с функции
boot
, которая в скрипте компоновщика указана в качестве точки входа. В этой функции указатель стека (sp
) установлен на адрес стека, определённый в скрипте компоновщика. Далее он переключается на функцию kernel_main
. Имейте в виду, что стек растёт в сторону нуля, то есть по мере использования декрементируется. В связи с этим задан должен быть конечный адрес стека, а не начальный.▍ Атрибуты функции boot
Функция
boot
содержит два особых атрибута. Атрибут
__attribute__((naked))
говорит компилятору не генерировать до и после тела функции необязательный код, такой как инструкция возврата. Это гарантирует, что встроенный код ассемблера будет представлен именно телом функции.В функции
boot
также есть атрибут __attribute__((section(".text.boot")))
, управляющий её размещением в скрипте компоновщика. Поскольку OpenSBI просто переходит к адресу 0x80200000
, не зная о точке входа, функцию boot
необходимо разместить по адресу 0x80200000
.▍ extern char
для получения символов скрипта компоновщика
В начале файла каждый символ, определённый в скрипте компоновщика, объявляется с помощью
extern char
. Здесь нас интересует лишь получение адресов символов, поэтому использование типа char
не столь важно.Можно объявить его и как
extern char __bss;
, но атрибут __bss
гласит «значение 0-го байта секции .bss
», а не «начальный адрес секции .bss
». Поэтому рекомендуется добавлять [],
чтобы __bss
возвращала адрес и исключала возможные ошибки по невнимательности.▍ Инициализация секции .bss
В функции
kernel_main
секция .bss
сначала через функцию memset
инициализируется как нулевая. И хотя некоторые загрузчики могут распознать и обнулить секцию .bss
, мы на всякий случай инициализируем её вручную. Наконец, эта функция входит в бесконечный цикл, и на этом активная работа ядра завершается.▍ Пора запускать!
Добавьте в
run.sh
команду сборки ядра и новую опцию QEMU (-kernel kernel.elf
):run.sh
#!/bin/bash
set -xue
QEMU=qemu-system-riscv32
# Путь к clang и его флагам
CC=/opt/homebrew/opt/llvm/bin/clang # Для Ubuntu: используйте CC=clang
CFLAGS="-std=c11 -O2 -g3 -Wall -Wextra --target=riscv32 -ffreestanding -nostdlib"
# Сборка ядра
$CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf \
kernel.c
# Запуск QEMU
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
-kernel kernel.elf
Подсказка
Проверить путь к Homebrew-версии Clang в macOS можно так:
$ ls $(brew --prefix)/opt/llvm/bin/clang /opt/homebrew/opt/llvm/bin/clang
В качестве опций Clang (переменная
CFLAGS
) установлены следующие:Опция | Описание |
-std=c11 | Использовать C11. |
-O2 | Включить оптимизации для генерации эффективного машинного кода. |
-g3 | Генерировать максимальный объём отладочной информации. |
-Wall | Включить основные предупреждения. |
-Wextra | Включить дополнительные предупреждения. |
--target=riscv32 | Компилировать для 32-битной RISC-V. |
-ffreestanding | Не использовать стандартную библиотеку среды хоста (вашей среды разработки). |
-nostdlib | Не линковать стандартную библиотеку. |
-Wl,-Tkernel.ld | Указать скрипт компоновщика. |
-Wl,-Map=kernel.map | Вывести карту ядра (результат произведённых компоновщиком аллокаций). |
-Wl
означает, что опции нужно передавать в компоновщик, а не компилятор С. Команда clang
производит компиляцию кода С и выполняет компоновку внутренне.▍ Первая отладка ядра
Когда выполняется
run.sh
, ядро входит в бесконечный цикл. При этом никаких показателей корректности его работы нет. Но не волнуйтесь, при низкоуровневой разработке это типичная ситуация. Здесь нам как раз поможет отладочная функциональность QEMU.Чтобы получить больше информации о регистрах процессора, откройте монитор QEMU и выполните команду
info registers
:QEMU 8.0.2 monitor - type 'help' for more information
(qemu) info registers
CPU#0
V = 0
pc 80200014 ← Адрес выполняемой инструкции (счётчик программы)
...
x0/zero 00000000 x1/ra 8000a084 x2/sp 80220018 x3/gp 00000000 ← Значения каждого регистра
x4/tp 80033000 x5/t0 00000001 x6/t1 00000002 x7/t2 00000000
x8/s0 80032f50 x9/s1 00000001 x10/a0 80220018 x11/a1 87e00000
x12/a2 00000007 x13/a3 00000019 x14/a4 00000000 x15/a5 00000001
x16/a6 00000001 x17/a7 00000005 x18/s2 80200000 x19/s3 00000000
x20/s4 87e00000 x21/s5 00000000 x22/s6 80006800 x23/s7 8001c020
x24/s8 00002000 x25/s9 8002b4e4 x26/s10 00000000 x27/s11 00000000
x28/t3 616d6569 x29/t4 8001a5a1 x30/t5 000000b4 x31/t6 00000000
Подсказка
В зависимости от версии Clang и QEMU конкретные значения могут отличаться.
Здесь
pc 80200014
показывает текущее значение счётчика программы, адрес исполняемой инструкции. Далее мы путём дизассемблинга (llvm-objdump
) рассмотрим эту конкретную строку кода:$ llvm-objdump -d kernel.elf
kernel.elf: file format elf32-littleriscv
Disassembly of section .text:
80200000 <boot>: ← функция boot
80200000: 37 05 22 80 lui a0, 524832
80200004: 13 05 85 01 addi a0, a0, 24
80200008: 2a 81 mv sp, a0
8020000a: 6f 00 60 00 j 0x80200010 <kernel_main>
8020000e: 00 00 unimp
80200010 <kernel_main>: ← функция kernel_main
80200010: 73 00 50 10 wfi
80200014: f5 bf j 0x80200010 <kernel_main> ← pc здесь
Каждая строка здесь соответствует инструкции, и каждый столбец представляет:
- адрес инструкции,
- шестнадцатеричный дамп машинного кода,
- дизассемблированные инструкции.
Значение
pc 80200014
говорит о том, что сейчас исполняется инструкция j 0x80200010
. Это подтверждает, что QEMU корректно достигла функции kernel_main
.Давайте также проверим, установлен ли указатель стека (регистр
sp
) на значение __stack_top
, определённое в скрипте компоновщика. Дамп регистра показывает x2/sp 80220018
. Чтобы узнать, куда компоновщик поместил __stack_top
, загляните в файл kernel.map
: VMA LMA Size Align Out In Symbol
0 0 80200000 1 . = 0x80200000
80200000 80200000 16 4 .text
...
80200016 80200016 2 1 . = ALIGN ( 4 )
80200018 80200018 20000 1 . += 128 * 1024
80220018 80220018 0 1 __stack_top = .
В качестве альтернативы адреса функций/переменных также можно проверять с помощью
llvm-nm
:$ llvm-nm kernel.elf
80200010 t .LBB0_1
00000000 N .Lline_table_start0
80220018 T __stack_top
80200000 T boot
80200010 T kernel_main
Первый столбец указывает, где были размещены функции (VMA). Здесь мы видим, что
__stack_top
размещена по адресу 0x80220018
. Это подтверждает, что указатель стека корректно установлен на функцию boot
. Прекрасно!По мере выполнения выдача
info registers
будет меняться. Если вы хотите временно прекратить эмуляцию, выполните в мониторе QEMU команду stop
:(qemu) stop ← Процесс останавливается
(qemu) info registers ← Можно наблюдать состояние на момент остановки
(qemu) cont ← Процесс возобновляется
Вот вы и написали своё первое ядро!
Hello World!
В предыдущем разделе мы успешно загрузили наше первое ядро. И хотя мы смогли убедиться в его работоспособности через дамп регистров, этого маловато.
В текущем разделе мы сделаем это более очевидным, реализовав в ядре вывод строки.
▍ Скажите SBI «Hello»
Ранее мы узнали, что SBI — это своеобразный «API для ОС». Чтобы вызвать SBI для использования его функции, мы применяем инструкцию
ecall
:kernel.c
#include "kernel.h"
extern char __bss[], __bss_end[], __stack_top[];
struct sbiret sbi_call(long arg0, long arg1, long arg2, long arg3, long arg4,
long arg5, long fid, long eid) {
register long a0 __asm__("a0") = arg0;
register long a1 __asm__("a1") = arg1;
register long a2 __asm__("a2") = arg2;
register long a3 __asm__("a3") = arg3;
register long a4 __asm__("a4") = arg4;
register long a5 __asm__("a5") = arg5;
register long a6 __asm__("a6") = fid;
register long a7 __asm__("a7") = eid;
__asm__ __volatile__("ecall"
: "=r"(a0), "=r"(a1)
: "r"(a0), "r"(a1), "r"(a2), "r"(a3), "r"(a4), "r"(a5),
"r"(a6), "r"(a7)
: "memory");
return (struct sbiret){.error = a0, .value = a1};
}
void putchar(char ch) {
sbi_call(ch, 0, 0, 0, 0, 0, 0, 1 /* Console Putchar */);
}
void kernel_main(void) {
const char *s = "\n\nHello World!\n";
for (int i = 0; s[i] != '\0'; i++) {
putchar(s[i]);
}
for (;;) {
__asm__ __volatile__("wfi");
}
}
Кроме того, мы также создаём новый файл
kernel.h
и определяем структуру возвращаемого значения:kernel.h
#pragma once
struct sbiret {
long error;
long value;
};
Мы снова добавили функцию
sbi_call
. Согласно спецификации SBI, эта функция создана для вызова OpenSBI, и далее мы разберём её соглашение о вызовах.Глава 3. Двоичная кодировка
Все функции SBI используют одну двоичную кодировку, упрощая совмещение расширений SBI. При этом спецификация SBI оперирует следующим соглашением о вызовах.
ECALL
используется в качестве команды передачи управления между супервизором и SEE (Supervisor Execution Environment).a7
кодирует ID расширения SBI (EID).a6
кодирует ID функции SBI (FID) для любого расширения, закодированного вa7
и определённого в версии SBI 0.2 и выше.- В процессе вызова SBI вызываемый код должен сохранять содержимое всех регистров кроме
a0
иa1
.- Функции SBI должны возвращать пару значений в
a0
иa1
, гдеa0
содержит код ошибки. Этот механизм аналогичен возврату структуры С.
struct sbiret { long error; long value; };
— «RISC-V Supervisor Binary Interface Specification» v2.0-rc1
Подсказка
Формулировка «В процессе вызова вызываемый код должен сохранять содержимое всех регистров кромеa0
иa1
» означает, что вызываемый код (сторона OpenSBI) не должен менять значения никаких регистров за исключениемa0
иa1
. Таким образом со стороны ядра гарантируется, что содержимое регистровa2
—a7
после вызова останется прежним.
Используемые в каждом объявлении переменнойregister
и__asm__("register name")
говорят компилятору поместить значения в указанные регистры. Это типичная идиома системных вызовов (например, в Linux).
После подготовки аргументов во встроенном ассемблере выполняется инструкция
ecall
. При её вызове режим работы процессора переключается с ядра (S-Mode) на OpenSBI (M-Mode), и вызывается обработчик OpenSBI. После этого происходит переключение на режим ядра, и выполнение возобновляется после инструкции ecall
.Инструкция
ecall
также используется, когда приложения вызывают ядро (через системные вызовы). Она действует как вызов функции к более привилегированному режиму процессора.Для вывода символов можно использовать функцию
Console Putchar
:5.2. Расширение: Console Putchar (EID #0x01)
long sbi_console_putchar(int ch)
Выведите данные изch
в консоль отладки.
В отличие отsbi_console_getchar()
, этот вызов SBI будет блокироваться, если есть какие-либо ожидающие вывода символы, или получающий терминал ещё не готов к получению байта. Как бы то ни было, если консоли вообще не существует, символы просто теряются.
Этот вызов SBI возвращает 0 в случае успеха или код ошибки, соответствующий конкретной реализации.
— «RISC-V Supervisor Binary Interface Specification» v2.0-rc1
Console Putchar
— это функция, которая выводит символ, передаваемый в отладочную консоль в виде аргумента.▍ Попробуйте
Пора испытать вашу реализацию. Если всё сделано правильно, вы увидите фразу
Hello World!
$ ./run.sh
...
Hello World!
Подсказка: рождение Hello World:
При вызове SBI отображение символов происходит по следующему алгоритму:
- Ядро выполняет инструкцию
ecall
. Процессор переключается на обработчик прерываний M-mode (регистрmtvec
), который устанавливается OpenSBI при запуске.- После сохранения регистров вызывается обработчик прерываний, написанный на С.
- Исходя из
eid
, вызывается соответствующая функция обработки SBI.- Драйвер устройства для 8250 UART (Wikipedia) отправляет символ в QEMU.
- Сэмулированная QEMU реализация 8250 UART получает символ и отправляет его в стандартный вывод.
- Эмулятор терминала отображает символ.
То есть вызов функцииConsole Putchar
никакой не магический — просто в нём используется драйвер устройства, реализованный в OpenSBI.
▍ Функция printf
Итак, мы успешно вывели немного символов. Следующим этапом будет реализация функции
printf
. Функция
printf
получает строку формата и значения для встраивания в вывод. Например, printf("1 + 2 = %d", 1 + 2)
отобразит 1 + 2 = 3
.И хотя
printf
из стандартной библиотеки C имеет очень широкую функциональность, мы начнём с её минимальной версии. В частности, мы реализуем printf
с поддержкой трёх спецификаторов формата: %d
(десятичный), %x
(шестнадцатеричный) и %s
(строка).Поскольку мы будем также использовать эту функцию в приложениях, нужно создать файл
common.c
для кода, совместно используемого ядром и пространством пользователя.Вот реализация функции
printf
:common.c
#include "common.h"
void putchar(char ch);
void printf(const char *fmt, ...) {
va_list vargs;
va_start(vargs, fmt);
while (*fmt) {
if (*fmt == '%') {
fmt++; // Skip '%'
switch (*fmt) { // Считываем следующий символ
case '\0': // '%' в конце строки формата.
putchar('%');
goto end;
case '%': // Выводим '%'
putchar('%');
break;
case 's': { // Выводим NULL-терминированную строку.
const char *s = va_arg(vargs, const char *);
while (*s) {
putchar(*s);
s++;
}
break;
}
case 'd': { // Выводим целое число в десятичном формате.
int value = va_arg(vargs, int);
if (value < 0) {
putchar('-');
value = -value;
}
int divisor = 1;
while (value / divisor > 9)
divisor *= 10;
while (divisor > 0) {
putchar('0' + value / divisor);
value %= divisor;
divisor /= 10;
}
break;
}
case 'x': { // Выводим целое число в шестнадцатеричном формате.
int value = va_arg(vargs, int);
for (int i = 7; i >= 0; i--) {
int nibble = (value >> (i * 4)) & 0xf;
putchar("0123456789abcdef"[nibble]);
}
}
}
} else {
putchar(*fmt);
}
fmt++;
}
end:
va_end(vargs);
}
На удивление лаконично, не так ли? Код проходит через строку формата символ за символом, и если мы встречаем
%
, то смотрим на следующий символ и выполняем соответствующую операцию форматирования. При этом все символы, отличные от %
, выводятся как есть.В случае десятичных чисел, если
value
отрицательно, мы сначала выводим -
и затем получаем его абсолютное значение. Далее мы вычисляем делитель для получения знака старшего разряда и выводим эти знаки один за другим. В случае шестнадцатеричных чисел мы делаем вывод от старшего полубайта (шестнадцатеричное значение размером 4 бита) до младшего. Здесь
nibble
— это целое число от 0 до 15, поэтому мы используем его в строке "0123456789abcdef"
в качестве индекса для получения соответствующего символа.va_list
и сопутствующие макросы определены в <stdarg.h>
стандартной библиотеки. В этом руководстве мы будем использовать встроенные функции компилятора напрямую без привлечения стандартной библиотеки. Говоря конкретнее, мы определим их в common.h
так:common.h
#pragma once
#define va_list __builtin_va_list
#define va_start __builtin_va_start
#define va_end __builtin_va_end
#define va_arg __builtin_va_arg
void printf(const char *fmt, ...);
Мы определяем их просто как псевдонимы для версий с префиксом
__builtin_
. Это встроенные функции, предоставляемые самим компилятором (документация Clang). Компилятор соответствующим образом обработает всё остальное, так что нам об этом беспокоиться не нужно.Вот мы и реализовали
printf
. Теперь добавим «Hello World» из ядра:kernel.c
#include "kernel.h"
#include "common.h"
void kernel_main(void) {
printf("\n\nHello %s\n", "World!");
printf("1 + 2 = %d, %x\n", 1 + 2, 0x1234abcd);
for (;;) {
__asm__ __volatile__("wfi");
}
}
Также добавим
common.c
к целям компиляции:run.sh
$CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf \
kernel.c common.c
Попробуем, что у нас получилось! Вы должны увидеть
Hello World!
и 1 + 2 = 3, 1234abcd
:$ ./run.sh
Hello World!
1 + 2 = 3, 1234abcd
Теперь наша ОС расширилась мощной возможностью отладки с помощью
printf
!Стандартная библиотека C
Далее мы реализуем базовые типы и операции с памятью, а также функции работы со строками. При этом для лучшего понимания мы создадим эти функции с нуля без использования стандартной библиотеки.
Подсказка
Представленные в этом разделе принципы вполне типичны для программирования на С, так что по ним ChatGPT сможет дать вам неплохие комментарии. Если у вас возникнут сложности с реализацией или пониманием каких-то деталей, также можете написать мне.
▍ Базовые типы
Для начала определим в
common.h
ряд простых типов и облегчающих жизнь макросов:common.h
typedef int bool;
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long long uint64_t;
typedef uint32_t size_t;
typedef uint32_t paddr_t;
typedef uint32_t vaddr_t;
#define true 1
#define false 0
#define NULL ((void *) 0)
#define align_up(value, align) __builtin_align_up(value, align)
#define is_aligned(value, align) __builtin_is_aligned(value, align)
#define offsetof(type, member) __builtin_offsetof(type, member)
#define va_list __builtin_va_list
#define va_start __builtin_va_start
#define va_end __builtin_va_end
#define va_arg __builtin_va_arg
void *memset(void *buf, char c, size_t n);
void *memcpy(void *dst, const void *src, size_t n);
char *strcpy(char *dst, const char *src);
int strcmp(const char *s1, const char *s2);
void printf(const char *fmt, ...);
Большинство из них доступны в стандартной библиотеке, но мы добавили и некоторые другие, которые нам тоже пригодятся:
- paddr_t: тип, представляющий адреса в физической памяти.
- vaddr_t: тип, представляющий адреса в виртуальной памяти. Равноценен uintptr_t из стандартной библиотеки.
-
align_up: округляет
value
до ближайшего кратногоalign
. При этомalign
должно быть равно степени 2. -
is_aligned: Проверяет, является ли
value
кратнымalign
. При этомalign
должно быть равно степени 2. - offsetof: возвращает смещение члена внутри структуры (количество байтов от начала структуры).
align_up
и is_aligned
пригождаются при выравнивании памяти. Например, align_up(0x1234, 0x1000)
возвращает 0x200
0. Кроме того, is_aligned(0x2000, 0x1000)
возвращает true
, а is_aligned(0x2f00, 0x1000)
— false
.Начинающиеся с
__builtin_
функции, используемые в каждом макросе, являются расширениями Clang (встроенными функциями). Подробнее в документации.Подсказка
Эти макросы также можно реализовать на С без встроенных функций. Реализацияoffsetof
на чистом С особенно интересна ?
▍ Операции с памятью
Далее мы реализуем пару функций работы с памятью.
Функцию
memcpy
, которая копирует n
байтов из src
в dst
:common.c
void *memcpy(void *dst, const void *src, size_t n) {
uint8_t *d = (uint8_t *) dst;
const uint8_t *s = (const uint8_t *) src;
while (n--)
*d++ = *s++;
return dst;
}
И функцию
memset
, которая заполняет первые n
байтов buf
содержимым c
. Эту функцию мы уже реализовывали ранее для инициализации секции bss
. Так что давайте просто переместим её из kernel.c
в common.c
:common.c
void *memset(void *buf, char c, size_t n) {
uint8_t *p = (uint8_t *) buf;
while (n--)
*p++ = c;
return buf;
}
Подсказка
*p++ = c;
в одном выражении и разыменовывает указатель, и управляет им. По сути, это равноценно:
*p = c; // Разыменовывание указателя. p = p + 1; // Продвижение указателя после присваивания.
В С это является идиомой.
▍ Операции со строками
Начнём со
strcpy
. Эта функция копирует строку из src
в dst
:common.c
char *strcpy(char *dst, const char *src) {
char *d = dst;
while (*src)
*d++ = *src++;
*d = '\0';
return dst;
}
Предупреждение
Функцияstrcpy
продолжает копирование, даже еслиsrc
больше области памятиdst
. Это может легко привести к багам и уязвимостям, поэтому рекомендуется использовать вместоstrcpy
альтернативные функции. Никогда не применяйте её в продакшене!
В этом же руководстве для простоты мы будем придерживаться именноstrcpy
. Но! Если у вас есть возможность, попробуйте реализовать её альтернативу (strcpy_s
).
Следующей функцией будет
strcmp
. Она сравнивает s1
с s2
и возвращает результат:Условие | Результат |
s1 == s2 | 0 |
s1 > s2 | Положительное значение |
s1 < s2 | Отрицательное значение |
common.c
int strcmp(const char *s1, const char *s2) {
while (*s1 && *s2) {
if (*s1 != *s2)
break;
s1++;
s2++;
}
return *(unsigned char *)s1 - *(unsigned char *)s2;
}
Подсказка
Приведение кunsigned char *
в процессе сравнения нужно для соответствия спецификации POSIX.
Функция
strcmp
часто используется для проверки идентичности двух строк. Это может показаться не интуитивным, но строки признаются идентичными, когда !strcmp(s1, s2)
является истинным (то есть, когда функция возвращает нуль):if (!strcmp(s1, s2))
printf("s1 == s2\n");
else
printf("s1 != s2\n");
▍ Паника ядра
Паника ядра происходит при возникновении фатальной ошибки. Этот механизм аналогичен принципу
panic
в языках Go и Rust. Знакомы с синим экраном смерти в Windows? Та же история. Далее мы реализуем подобный механизм в нашем ядре, и сделаем это в виде макроса
PANIC
:kernel.h
#define PANIC(fmt, ...) \
do { \
printf("PANIC: %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__); \
while (1) {} \
} while (0)
Этот код выводит точку возникновения сбоя и входит в бесконечный цикл для остановки выполнения. Здесь мы определяем его в виде макроса, чтобы корректно выводить имя соответствующего файла (
__FILE__
) и номер строки (__LINE__
). Если прописать этот механизм в виде функции, то __FILE__
и __LINE__
будут показывать имя файла и номер строки, где PANIC
определена, а не где была вызвана.Этот макрос также строится на двух идиомах:
Первая представлена выражением
do-while
. Так как здесь у нас while (0)
, цикл выполняется лишь раз. Это типичный способ определения макросов, состоящих из нескольких выражений. Если просто заключить их в { ...}
, то при совмещении с выражениями вроде if
может возникнуть неопределённое поведение (вот пример). Также обратите внимание на обратный слэш (\
) в конце каждой строки. Благодаря ему, символы переноса строки при раскрытии макроса игнорируются.Второй идиомой выступает
##__VA_ARGS__
. Это полезное расширение компилятора для определения макросов, получающих переменное число аргументов (документация GCC). Здесь ##
удаляет предшествующую ,
, когда переменных аргументов нет. Это позволяет успешно производить компиляцию, даже при наличии всего одного аргумента, например, PANIC("booted!")
.▍ Пробуем!
Проверим, как работает наша
PANIC
. Использовать её можно аналогично printf
:kernel.c
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
PANIC("booted!");
printf("unreachable here!\n");
}
Попробуйте это в QEMU и убедитесь, что выводятся правильные имя файла и номер строки, а обработка после
PANIC
не продолжается (то есть "unreachable here!"
не отображается):$ ./run.sh
PANIC: kernel.c:46: booted!
Синий экран смерти в Windows и паники ядра в Linux очень пугают, но разве этот механизм не станет приятным дополнением для нашего ядра? Можете рассматривать его как способ «изящной обработки» сбоя, дающий понятную для человека подсказку.
На этом вторая часть серии завершается. В следующей мы реализуем механизм аллокации памяти, переключение контекста, планировщика и ряд других фундаментальных компонентов ОС.
До скорой встречи!
Telegram-канал со скидками, розыгрышами призов и новостями IT ?