
Недавно я реализовал минимальный proof of concept ядра операционной системы с разделением времени для RISC-V. В этом посте я расскажу о том, как работает прототип. Целевая аудитория поста — все, кому интересно понимание низкоуровневого системного ПО, драйверов, системных вызовов и так далее. Надеюсь, он окажется особенно полезным для студентов, изучающих архитектуру ПО и компьютеров.
Это новый подход к упражнению, которое я выполнил на моём студенческом курсе по операционным системам; функционально он должен напоминать типичный проект по ОС. Однако в этом эксперименте сделан упор на современный инструментарий, а также на современную архитектуру RISC-V. RISC-V — это потрясающая технология, в которой проще разобраться быстрее, чем в других архитектурах CPU; в то же время она остаётся популярным выбором для многих новых систем, а не только архитектурой для обучения.
Отличия от моего предыдущего проекта заключаются в том, что я реализовал это упражнение на Zig вместо традиционного C. Это не просто интересный эксперимент: я считаю, что Zig повышает возможность его воссоздания на вашей машине, потому что его очень легко настроить и он не требует установки.
Репозиторий GitHub
Готовый код этого эксперимента выложен на GitHub. В процессе я буду ссылаться на этот код.
GitHub должен считаться источником истины и может быть немного рассинхронизирован с кодом ниже.
Рекомендуемое чтение
Предполагается, что читатель знает основы проектирования компьютеров и, в частности, компьютерной архитектуры. Необходимы знание регистров, понимание того, как CPU адресует память, и прерываний.
Прежде, чем углубляться в этот эксперимент, рекомендую также изучить следующие тексты:
Дополнительно: Making a micro Linux distro — в основном для общего понимания философии разделения на ядро и пользовательское пространство
Специализированное ядро
Мы будем разрабатывать разновидность специализированного ядра. Эта структура компонует код приложений напрямую с ядром ОС, от которого она зависит. По сути, всё объединено в двоичный исполняемый файл, а пользовательский код загружается в память вместе с ядром.
Благодаря этому не нужно отдельно загружать пользовательский код в среде исполнения, что само по себе было бы сложной областью знаний (связанной с компоновщиками, загрузчиками и так далее).
Слой SBI
RISC-V поддерживает модель слоёв разрешений. Система загружается в машинном режиме (M), который общается напрямую с «железом», а также поддерживает пару менее привилегированных режимов. Подробности можно узнать в текстах по ссылкам выше; приведу краткое описание:
M-режим может делать практически всё; он имеет полный доступ к оборудованию.
Посередине находится S-режим (супервайзер), обычно содержащий ядро операционной системы.
Внизу находится U-режим (user, пользователь), в котором выполняется код приложений.
Уровни с низкими привилегиями могут отправлять запросы уровням с более высокими привилегиями.
Мы предполагаем, что внизу нашего программного стека находится слой SBI, а именно OpenSBI. Изучите необходимую информацию, потому что мы будем использовать слой SBI для управления консольным выводом и оборудованием таймера. Хоть возможна и ручная реализация, я решил повысить ценность этого текста, продемонстрировав более портируемое решение на основе OpenSBI.
Цель ядра
Для простоты мы хотим поддерживать ограниченный список ключевых функций:
Статическое определение потоков перед исполнением; то есть динамическое создание потоков не поддерживается. Кроме того, для простоты потоки реализованы в виде бесконечных функций.
Потоки работают в пользовательском режиме и способны отправлять системные вызовы ядру, работающему в S-режиме.
Время разделяется и распределяется среди потоков. Системный таймер идёт с шагом в пару миллисекунд, в этот момент поток может быть выключен.
Кроме того, целевой платформой разработки будет одноядерная машина.
Виртуализация и что такое поток
Прежде, чем приступать к реализации потоков, нам нужно решить, что же это такое. Концепция потоков в среде с разделением времени позволяет нескольким рабочим нагрузкам выполняться на одном ядре (как сказано выше, мы создаём систему для одноядерных машин), а модель разработки программ для каждого потока остаётся практически такой же, как будто это было бы единственное ПО на машине. Это общее определение, которое мы постепенно будем уточнять.
Чтобы понять концепцию разделения времени, давайте вкратце рассмотрим её противоположность: кооперативное планирование/потокообразование. При кооперативном планировании/потокообразовании поток добровольно предоставляет другой рабочей нагрузке время CPU. При этом ожидается, что другой поток в конечном итоге передаст управление обратно первому.
function thread():
operation_1();
operation_2();
YIELD();
operation_3();
YIELD();
...
Уточню, что это не «устаревшая» методика, хоть она и более старая. На самом деле, она вполне жива и хорошо себя чувствует во многих языках программирования и их средах исполнения (часто абстрагированных от программистов). Хорошим примером этого может служить Go, использующий горутины для выполнения множественных рабочих нагрузок поверх одного потока операционной системы. Хотя программисты необязательно добавляют явные операции yield, компилятор и среда исполнения могут инъецировать их в рабочую нагрузку.
Теперь вам должно быть понятнее, что означает «модель разработки программ остаётся практически такой же» в контексте разделения времени. Поток будет выглядеть так:
function thread():
operation_1();
operation_2();
operation_3();
...
В нём попросту нет явных операторов yield; вместо них ядро использует таймеры и прерывания для бесшовного переключения потоков на одном и том же ядре. Именно это мы и реализуем в нашем эксперименте.
Когда множественные рабочие нагрузки выполняются на одном ресурсе и каждая сохраняет такую же модель разработки программ, как будто это единственная рабочая нагрузка, можно сказать, что ресурс виртуализирован. Иными словами, если на одном ядре работают пять потоков, каждый поток «ощущает», что имеет собственное ядро, по сути, работая на пяти маленьких ядрах вместо одного большого. Если говорить более формально, то каждый поток сохраняет собственное представление архитектурных регистров ядра (в RISC-V это x0-x31
и некоторые CSR; подробнее об этом ниже) и… часть памяти! Давайте разберём это подробнее.
Виртуализация стека и памяти
Начнём с того, что по причинам, которые мы проанализируем ниже, у потока есть собственный стек. Остальная часть памяти становится «общей» с другими потоками, но это требует дополнительного объяснения.
Важно понимать, что виртуализация «железа» — это спектр, а не несколько жёстко заданных вариантов. Вот некоторые из вариантов для виртуализации:
Потоки: виртуализация архитектурных регистров и стеков, но больше ничего; например, разные потоки могут иметь общие данные где-то в другом месте памяти.
Процесс: более тяжеловесный, чем потоки; память виртуализируется так, чтобы каждый процесс «ощущал», что у него как будто есть отдельное ядро CPU и собственная память, неприкосновенная для других процессов; кроме того, процесс включает в себя множественные потоки.
Контейнер: ещё более глубокая виртуализация — у каждого контейнера есть собственная файловая система и потенциально собственное множество сетевых интерфейсов; контейнеры используют общее ядро и оборудование.
VM: виртуализирует всё.
Между этими вариантами существует гораздо больше разных оттенков, и каждый из этих вариантов имеет различные подтипы. Смысл в том, что все эти подходы позволяют выполнять разные рабочие нагрузки с разной степенью изолированности, то есть имеют разные представления о машине и своём окружении.
Интересно, что если вы изучите исходный код Linux, то не найдёте в нём конструкцию, явным образом названную контейнером. То, что мы обычно называем контейнерами — это не встроенный в ядро механизм, а множество механизмов ядра, совместно образующее определённое представление окружения для рабочей нагрузки. Например, механизм chroot
ограничивает видимость файловой системы, а cgroups
накладывает ограничения на рабочие нагрузки; все вместе они образуют то, что мы называем контейнером.
Кроме того, я считаю (но не ссылайтесь в этом на меня), что границы между потоками и процессами в Linux довольно размыты. Насколько я знаю, и те, и другие реализованы поверх задач (task) в ядре, но при создании задачи API позволяет указывать различные ограничения.
В конечном итоге, мы всегда определяем рабочую нагрузку с варьирующимися ограничениями того, что она может видеть и к чему получать доступ. Когда и почему применяются различные ограничения — это уже отдельная тема. При написании приложения возникает множество вопросов, от сложности решения до его безопасности.
Виртуализация потока
В этом эксперименте мы реализуем минимальную виртуализацию с очень простыми потоками на основе разделения времени. Следовательно, цели наши будут такими:
Модель разработки программ для потока должна по большей мере оставаться неизменной. Пока поток не взаимодействует с содержимым памяти, используемым другими потоками, его модель разработки программ должна оставаться согласованной и основываться на разделении времени.
Поток должен иметь собственное защищённое представление архитектурных регистров, в том числе некоторых CSR RISC-V.
Потоку должен быть присвоен его собственный стек.
Должно быть очевидно, почему потоку нужно собственное представление регистров. Если другие потоки смогут свободно вмешиваться в регистры этого потока, то он не сможет выполнять никакой полезной работы. Все (насколько я знаю) команды RISC-V работают хотя бы с одним регистром, поэтому защита представления регистров потока обязательна.
Более того, необходимо назначать потоку приватный стек, хоть это чуть менее очевидно. Дело в том, что для управления разными контекстами исполнения необходимы разные стеки. В частности, при вызове функции для распределения приватных переменных функции используется стек. Кроме того, в стек могут быть записаны регистры наподобие ra
для сохранения корректного адреса возврата из функции (на случай, если внутри неё вызывается другая функция). Если вкратце, по разным причинам, согласно соглашению RISC-V, стек необходим для хранения контекста исполнения. Подробности соглашения о вызовах RISC-V в этой статье описываться не будут.
Контекст прерываний
Критически важно понимать, как выполняется код прерываний и из чего он может состоять, так как этот механизм будет активно использоваться для реализации бесшовного разделения времени между потоками. Подробный практический пример можно посмотреть в моём предыдущем тексте.
Я приведу краткую выдержку ассемблерного кода процедуры прерывания таймера из этого текста:
s_mode_interrupt_handler:
addi sp,sp,-144
sd ra,136(sp)
sd t0,128(sp)
sd t1,120(sp)
sd t2,112(sp)
sd s0,104(sp)
sd a0,96(sp)
sd a1,88(sp)
sd a2,80(sp)
sd a3,72(sp)
sd a4,64(sp)
sd a5,56(sp)
sd a6,48(sp)
sd a7,40(sp)
sd t3,32(sp)
sd t4,24(sp)
sd t5,16(sp)
sd t6,8(sp)
addi s0,sp,144
call clear_timer_pending_bit
call set_timer_in_near_future
li a1,33
lla a0,.LC0
call debug_print
nop
ld ra,136(sp)
ld t0,128(sp)
ld t1,120(sp)
ld t2,112(sp)
ld s0,104(sp)
ld a0,96(sp)
ld a1,88(sp)
ld a2,80(sp)
ld a3,72(sp)
ld a4,64(sp)
ld a5,56(sp)
ld a6,48(sp)
ld a7,40(sp)
ld t3,32(sp)
ld t4,24(sp)
ld t5,16(sp)
ld t6,8(sp)
addi sp,sp,144
sret
Этот ассемблерный код, полученный при написании функции на C, помеченной, как прерывание уровня S в RISC-V. В рамках этого тэга компилятор GCC знает, как генерировать пролог и эпилог процедуры прерывания. Пролог сохраняет архитектурные регистры в стеке, а эпилог восстанавливает их (а также специально выполняет возврат из S-режима). Всё это сгенерировано благодаря корректной разметке стандарта вызова функции C.
Это чем-то напоминает вызов функций и, по сути, им и является. Прерывания можно воспринимать (в крайне упрощённом смысле), как функции, вызываемые каким-то системным эффектом. Следовательно, используемые регистры должны аккуратно сохраняться в стек, а затем восстанавливаться при выходе из процедуры; в противном случае асинхронные прерывания, например, прерывания таймера будут произвольным образом повреждать значения архитектурных регистров, полностью препятствуя работе практического ПО!
Реализация (высокоуровневое объяснение)
Мы исследуем реализацию, сначала разобравшись в высокоуровневом принципе, а затем погрузившись в код.
Использование стандарта стека прерываний
Добавление прерывания в каком-то смысле уже добавляет некую разновидность поточности в код приложения. В системе с прерыванием таймера код основного приложения выполняется, время от времени перемежаясь экземплярами вызовов прерываний таймера. Когда таймер подаёт сигнал, ядро переходит к процедуре этого прерывания, а затем аккуратно восстанавливает архитектурное состояние перед возвращением потока управления «основному потоку». Здесь конкурентно выполняются два потока управления:
Код основного приложения.
Повторения процедуры прерывания.
Такое чередование прерываний таймера можно использовать для реализации дополнительных потоков управления; ниже будет описан основной принцип.
Ядро процедуры прерывания заключено в «сэндвич» между прологом и эпилогом. Здесь прерывание обрабатывается до возврата управления потоку основного приложения при восстановлении регистров из стека.
Однако обязательно ли нужно восстанавливать регистры из того же стека? Если логика прерывания заменит указатель стека на какую-то другую область памяти, то восстановится другое множество значений архитектурных регистров, войдя таким образом в совершенно другой поток. Иными словами, мы создаём переключение контекста, и именно так он реализован в моём эксперименте. Вскоре мы увидим это в коде.
Разделение на ядро и пользовательское пространство
Теперь мы можем провести черту между пространством ядра и пользовательским пространством. В RISC-V это естественным образом преобразуется в то, что ядро кода работает в режиме супервизора (S), а код пользовательского пространства — в U-режиме.
Машина загружается в машинный режим (M), а поскольку мы хотим использовать здесь слой SBI, то разрешим в нём запуститься OpenSBI. Затем ядро выполняет первоначальную настройку в S-режиме, после чего запускает выполнение потоков пользовательского пространства в U-режиме. Периодические прерывания таймера обеспечивают переключения контекста, а код прерываний исполняется в S-режиме. Также пользовательские потоки могут выполнять системные вызовы к ядру.
Реализация (код)
Полный код можно посмотреть в репозитории GitHub; ниже мы рассмотрим только самые важные блоки.
Ассемблерный запуск
Как обычно, в Zig необходим короткий ассемблерный блок кода для запуска кода S-режима и входа в «основную программу». Он находится в startup.S
.
...
done_bss:
# Переход к main Zig
call main
...
Остальная часть ассемблерного запуска в основном касается очистки раздела BSS и подготовки указателя стека для начального кода ядра.
Основной файл ядра и драйверы ввода-вывода
Теперь мы изучим kernel.zig
, содержащий функцию main
.
Сначала мы проверяем в слое OpenSBI консольные возможности. Мы допускаем запуск на относительно новой версии OpenSBI (последние несколько лет), имеющей консольные возможности. В противном случае, ядро приостанавливает работу и сообщает об ошибке.
export fn main() void {
const initial_print_status = sbi.debug_print(BOOT_MSG);
if (initial_print_status.sbi_error != 0) {
// Отладочная консоль SBI недоступна, откатываемся к прямому UART
const error_msg = "ERROR: OpenSBI debug console not available! You need the latest OpenSBI.\n";
const fallback_msg = "Falling back to direct UART at 0x10000000...\n";
uart.uart_write_string(error_msg);
uart.uart_write_string(fallback_msg);
uart.uart_write_string("Stopping... We rely on OpenSBI, cannot continue.\n");
while (true) {
asm volatile ("wfi");
}
unreachable;
}
main
помечена, как export
для соответствия C ABI.
Здесь у нас есть легковесная реализация пары драйверов ввода-вывода. Как видите, запись может выполняться одним из двух способов: мы или работаем через слой SBI (sbi.zig
), или, если этого сделать не удаётся, используем прямой MMIO (uart_mmio.zig
). Способ с SBI, теоретически, должен быть более портируемым, поскольку он делегирует подробности управления выводом слою M-уровня (по сути, то, что мы делаем с MMIO), позволяя нам не беспокоиться о точных адресах пространства памяти.
Давайте взглянем на sbi.zig
:
// Struct, содержащая статус возврата OpenSBI
pub const SbiRet = struct {
sbi_error: isize,
value: isize,
};
pub fn debug_print(message: []const u8) SbiRet {
var err: isize = undefined;
var val: isize = undefined;
const msg_ptr = @intFromPtr(message.ptr);
const msg_len = message.len;
asm volatile (
\\mv a0, %[len]
\\mv a1, %[msg]
\\li a2, 0
\\li a6, 0x00
\\li a7, 0x4442434E
\\ecall
\\mv %[err], a0
\\mv %[val], a1
: [err] "=r" (err),
[val] "=r" (val),
: [msg] "r" (msg_ptr),
[len] "r" (msg_len),
: .{ .x10 = true, .x11 = true, .x12 = true, .x16 = true, .x17 = true, .memory = true });
return SbiRet{
.sbi_error = err,
.value = val,
};
}
Здесь всё понятно; мы просто выполняем системный вызов точно так, как это описано в документации OpenSBI. Стоит отметить, что когда я писал этот код, то не знал всех возможностей Zig по обработке ошибок, поэтому он довольно неидиоматичный.
Однако это можно считать первым драйвером в этом ядре, потому что он непосредственно управляет выводом на устройство.
Теперь рассмотрим uart_mmio.zig
:
// Адрес UART MMIO (стандартный для виртуальной машины QEMU)
pub const UART_BASE: usize = 0x10000000;
pub const UART_TX: *volatile u8 = @ptrFromInt(UART_BASE);
// Функция непосредственной записи UART (откат к ней выполняется тогда, когда SBI недоступен)
pub fn uart_write_string(message: []const u8) void {
for (message) |byte| {
UART_TX.* = byte;
}
}
Вернёмся к kernel.zig
и функции main
: мы создаём три пользовательских потока, каждый из которых выводит слегка различающиеся сообщения (меняется ID потока). На этом этапе подготовка ядра почти завершена.
Первыми шагами становятся настройка и запуск прерывания таймера. После этого код ядра будет выполняться только тогда, когда таймер выполнит прерывание системы, или когда код пользовательского пространства запросит системный вызов.
interrupts.setup_s_mode_interrupt(&s_mode_interrupt_handler);
_ = timer.set_timer_in_near_future();
timer.enable_s_mode_timer_interrupt();
Мы можем запросить переключение контекста немедленно, но ради простоты ждём, пока таймер активируется и начнёт свою работу в системе.
Обработчик и переключение контекста S-режима
Хоть компилятор Zig способен генерировать адекватный пролог и эпилог для обработчика S-режима, мы будем делать это вручную. Причина в том, что мы также хотим перехватывать некоторые CSR в контексте, которые иначе бы не были перехвачены сгенерированной процедурой.
Именно поэтому мы используем в Zig соглашение о вызове naked
. Оно заставляет нас писать всю функцию на ассемблере, однако это ограничение можно обойти, вызывая функцию Zig в случаях, когда требуется логика Zig.
Я не буду копипастить сюда весь пролог и эпилог, потому что они очень похожи на то, что мы делали в предыдущем эксперименте на C с прерываниями RISC-V. Вместо этого мы рассмотрим ту часть, которая отличается от него:
...
// Сохраняем CSR S-уровня (используем x5 в качестве временного регистра)
\\csrr x5, sstatus
\\sd x5, 240(sp)
\\csrr x5, sepc
\\sd x5, 248(sp)
\\csrr x5, scause
\\sd x5, 256(sp)
\\csrr x5, stval
\\sd x5, 264(sp)
// Вызов handle_kernel
\\mv a0, sp
\\call handle_kernel
\\mv sp, a0
// Эпилог: восстановление контекста
// Восстанавливаем CSR S-уровня (используем x5 в качестве временного регистра)
\\ld x5, 264(sp)
\\csrw stval, x5
\\ld x5, 256(sp)
\\csrw scause, x5
\\ld x5, 248(sp)
\\csrw sepc, x5
\\ld x5, 240(sp)
\\csrw sstatus, x5
...
Как видите, наряду с архитектурными регистрами к прологу и эпилогу добавилась ещё пара регистров.
Далее в этом «сэндвиче» из пролога и эпилога мы вызываем функцию Zig handle_kernel
. Она выполняет перенаправление к нужной логике в зависимости от того, что было источником прерывания: синхронный системный вызов из пользовательского пространства или асинхронное прерывание таймера. Причина этого в том, что мы оказываемся в одной и той же процедуре прерывания S-уровня вне зависимости от источника прерывания, а затем проверяем подробности в CSR scause
.
Для успешной работы с функцией handle_kernel
нам нужно учитывать соглашения о вызовах на ассемблерном уровне. Эта функция принимает единственный целочисленный параметр и возвращает тоже единственный целочисленный параметр. Так как сигнатура функции мала, она выполняет следующую простую работу:
Единственный параметр функции передаётся через архитектурный регистр
a0
.Тот же регистр также хранит результат функции при возврате.
Всё довольно просто. Давайте вкратце рассмотрим сигнатуру этой функции:
export fn handle_kernel(current_stack: usize) usize {
...
Она немного некрасивая, но со своей работой справляется. Входящими данными для этой логики Zig служит вершина стека до вызова логики Zig (что неизбежно приводит к добавлению в стек каких-то данных). Вывод функции — это информация о том, где должна находиться вершина стека после завершения логики Zig. Если она отличается от входных данных, то мы выполняем переключение контекста. Если они совпадают, то после прерывания продолжится выполнение того же потока рабочей нагрузки.
Остальная часть логики очень проста. Она изучает источник прерывания (системный вызов из пользовательского пространства или прерывания таймера) и выполняет соответствующие действия.
В случае прерывания таймера выполняется переключение контекста. Вызывается функция schedule
из scheduling.zig
; потенциально она возвращает другой стек, на который мы должны переключиться:
const build_options = @import("build_options");
const sbi = @import("sbi");
const std = @import("std");
const thread = @import("thread");
pub fn schedule(current_stack: usize) usize {
const maybe_current_thread = thread.getCurrentThread();
if (maybe_current_thread) |current_thread| {
current_thread.sp_save = current_stack;
if (comptime build_options.enable_debug_logs) {
_ = sbi.debug_print("[I] Enqueueing the current thread\n");
}
thread.enqueueReady(current_thread);
} else {
if (comptime build_options.enable_debug_logs) {
_ = sbi.debug_print("[W] NO CURRENT THREAD AVAILABLE!\n");
}
}
const maybe_new_thread = thread.dequeueReady();
if (maybe_new_thread) |new_thread| {
// TODO: программное прерывание, передаваемое пользовательскому потоку
if (comptime build_options.enable_debug_logs) {
_ = sbi.debug_print("Yielding to the new thread\n");
}
thread.setCurrentThread(new_thread);
if (comptime build_options.enable_debug_logs) {
var buffer: [256]u8 = undefined;
const content = std.fmt.bufPrint(&buffer, "New thread ID: {d}, stack top: {x}\n", .{ new_thread.id, new_thread.sp_save }) catch {
return 0; // Возвращает фальшивый стек, в реальности должен быть более надёжным
};
_ = sbi.debug_print(content);
}
return new_thread.sp_save;
}
_ = sbi.debug_print("NO NEW THREAD AVAILABLE!\n");
while (true) {
asm volatile ("wfi");
}
unreachable;
}
Код из модуля thread
очень прост, он служит в качестве бойлерплейта для базовой очереди, управляющей struct, которые представляют потоки. Я не буду его сюда копировать, потому что по большей мере он сгенерирован ИИ. Однако стоит отметить, что стеки статически распределяются в памяти, а максимальное количество работающих потоков имеет жёсткое ограничение.
Также в модуле thread
есть логика для подготовки нового потока. В ней данные передаются в стек до запуска потока, потому что при возврате обработчика исключений и прерываний S-уровня нам нужно, чтобы что-то указало стеку, куда идти. Эту задачу выполняют начальные данные. Здесь мы можем задать нужные нам начальные значения регистров. На самом деле, в этом эксперименте мы демонстрируем передачу одного целочисленного параметра в функцию потока, генерируя значение регистра a0
(по соглашению о вызовах) в стеке, которое мгновенно может использовать функция потока.
Потоки пользовательского пространства
Как говорилось во введении к статье, мы объединим код пользовательского пространства и пространства ядра в единый двоичный блоб, чтобы избежать динамической загрузки, компоновки и других сложностей. Поэтому код нашего пользовательского пространства состоит из обычных функций:
/// Пример: создание простого потока ожидания
pub fn createPrintingThread(thread_number: usize) !*Thread {
const thread = allocThread() orelse return error.NoFreeThreads;
// Поток ожидания просто работает
const print_fn = struct {
fn print(thread_arg: usize) noreturn {
while (true) {
var buffer: [256]u8 = undefined;
const content = std.fmt.bufPrint(&buffer, "Printing from thread ID: {d}\n", .{thread_arg}) catch {
continue;
};
syscall.debug_print(content);
// Симуляция задержки
var i: u32 = 0;
while (i < 300000000) : (i += 1) {
asm volatile ("" ::: .{ .memory = true }); // Memory barrier to prevent optimization
}
}
unreachable;
}
}.print;
initThread(thread, @intFromPtr(&print_fn), thread_number);
return thread;
}
Дополнительно, как говорилось выше, мы предварительно заполняем стек таким образом, чтобы при восстановлении a0
из стека при возврате первого прерывания для указанного потока принимался аргумент функции. Именно так функция print
получает доступ к значению thread_arg
и использует его в своей логике.
Для демонстрации границы между пользовательским пространством и ядром мы вызываем syscall.debug_print(content);
. Концептуально она ведёт себя более-менее аналогично printf
из stdio.h
на C: подготавливает аргументы для ядра и вызывает системный вызов с этими аргументами, что должно приводить к выводу контента на устройство вывода. Вот, как выглядит библиотека вывода (из syscall.zig
):
// Функция debug_print пользовательского уровня
pub fn debug_print(message: []const u8) void {
const msg_ptr = @intFromPtr(message.ptr);
const msg_len = message.len;
// Допустим, выполняем syscall номер 64
// a7 = номер syscall
// a0 = указатель сообщения
// a1 = длина сообщения
asm volatile (
\\mv a0, %[msg]
\\mv a1, %[len]
\\li a7, 64
\\ecall
:
: [msg] "r" (msg_ptr),
[len] "r" (msg_len),
: .{ .x10 = true, .x11 = true, .x17 = true, .memory = true });
// Ради простоты игнорируем возвращаемое значение
}
Системный вызов 64 передаётся из обработчика S-режима в kernel.zig
.
Выполнение ядра
Мы будем развёртывать ядро на голом «железе», то есть в виртуальной машине. Теоретически, это должно сработать и на реальной машине, при условии, если при запуске ядра присутствует слой SBI и адаптированы скрипт компоновщика, «драйверы» ввода-вывода и другие специфичные для машины константы.
Для выполнения сборки достаточно запустить команду
zig build
Теперь, чтобы запустить ядро, мы выполняем такую команду
qemu-system-riscv64 -machine virt -nographic -bios /tmp/opensbi/build/platform/generic/firmware/fw_dynamic.bin -kernel zig-out/bin/kernel
Подробности о сборке OpenSBI см. в предыдущем тексте про OpenSBI. Рекомендуется применять строго свежесобранный OpenSBI, так как если не передать флаг -bios
, QEMU может использовать устаревшую версию.
Вывод должен начиться с большого логотипа OpenSBI и данных OpenSBI:
OpenSBI v1.7
____ _____ ____ _____
/ __ \ / ____| _ \_ _|
| | | |_ __ ___ _ __ | (___ | |_) || |
| | | | '_ \ / _ \ '_ \ \___ \| _ < | |
| |__| | |_) | __/ | | |____) | |_) || |_
\____/| .__/ \___|_| |_|_____/|____/_____|
| |
|_|
Platform Name : riscv-virtio,qemu
Platform Features : medeleg
Platform HART Count : 1
Platform IPI Device : aclint-mswi
Platform Timer Device : aclint-mtimer @ 10000000Hz
Platform Console Device : uart8250
Platform HSM Device : ---
Platform PMU Device : ---
Platform Reboot Device : syscon-reboot
Platform Shutdown Device : syscon-poweroff
Platform Suspend Device : ---
Platform CPPC Device : ---
Firmware Base : 0x80000000
Firmware Size : 317 KB
Firmware RW Offset : 0x40000
Firmware RW Size : 61 KB
Firmware Heap Offset : 0x46000
Firmware Heap Size : 37 KB (total), 2 KB (reserved), 11 KB (used), 23 KB (free)
Firmware Scratch Size : 4096 B (total), 400 B (used), 3696 B (free)
Runtime SBI Version : 3.0
Standard SBI Extensions : time,rfnc,ipi,base,hsm,srst,pmu,dbcn,fwft,legacy,dbtr,sse
Experimental SBI Extensions : none
Domain0 Name : root
....
После вводного экрана OpenSBI идёт вывод ядра:
Booting the kernel...
Printing from thread ID: 0
Printing from thread ID: 0
Printing from thread ID: 0
Printing from thread ID: 1
Printing from thread ID: 1
Printing from thread ID: 1
Printing from thread ID: 2
Printing from thread ID: 2
Printing from thread ID: 2
Printing from thread ID: 0
Printing from thread ID: 0
Printing from thread ID: 1
Printing from thread ID: 1
Printing from thread ID: 2
Printing from thread ID: 2
Printing from thread ID: 0
Printing from thread ID: 0
Printing from thread ID: 0
Printing from thread ID: 1
Printing from thread ID: 1
Printing from thread ID: 1
Printing from thread ID: 2
Printing from thread ID: 2
Printing from thread ID: 2
Вывод продолжит работать, пока не завершится выполнение QEMU.
Если вы хотите собрать ядро в режиме очень подробного вывода для отладки и экспериментов, то используйте следующую команду:
zig build -Ddebug-logs=true
После запуска ядра той же командой QEMU вывод будет выглядеть так:
Booting the kernel...
DEBUG mode on
Interrupt source: Timer, Current stack: 87cffe70
[W] NO CURRENT THREAD AVAILABLE!
Yielding to the new thread
New thread ID: 0, stack top: 80203030
Interrupt source: Ecall from User mode, Current stack: 80202ec0
Printing from thread ID: 0
Interrupt source: Ecall from User mode, Current stack: 80202ec0
Printing from thread ID: 0
Interrupt source: Ecall from User mode, Current stack: 80202ec0
Printing from thread ID: 0
Interrupt source: Timer, Current stack: 80202ec0
[I] Enqueueing the current thread
Yielding to the new thread
New thread ID: 1, stack top: 80205030
Interrupt source: Ecall from User mode, Current stack: 80204ec0
Printing from thread ID: 1
Interrupt source: Ecall from User mode, Current stack: 80204ec0
Printing from thread ID: 1
Interrupt source: Ecall from User mode, Current stack: 80204ec0
Printing from thread ID: 1
Interrupt source: Timer, Current stack: 80204ec0
[I] Enqueueing the current thread
Yielding to the new thread
New thread ID: 2, stack top: 80207030
Interrupt source: Ecall from User mode, Current stack: 80206ec0
Printing from thread ID: 2
Interrupt source: Ecall from User mode, Current stack: 80206ec0
Printing from thread ID: 2
Interrupt source: Ecall from User mode, Current stack: 80206ec0
Printing from thread ID: 2
Interrupt source: Timer, Current stack: 80206ec0
...
Заключение
Существует множество образовательных ядер ОС, но в этом эксперименте сочетаются RISC-V, OpenSBI и Zig, что позволяет посмотреть на них по-новому по сравнению с традиционными реализациями на C.
Получившийся код выполняется в виртуальной машине QEMU, которую легко настроить даже при сборке QEMU из исходников.
Ради краткости объяснения отчёты об ошибках оставлены минималистичными. На случай, если вы захотите модифицировать код и вам потребуется отладка, подсказок будет достаточно, несмотря на то, что некоторые части кода упрощены (например, анонимные результаты после вызовов вывода SBI вида _ = ...
). Основная часть кода в этом примере для экономии времени сгенерирована Claude, но она должна работать так, как это запланировано. Хотя некоторые части кода упрощены, это не снижает образовательную ценность эксперимента.
В целом, этот эксперимент может служить отправной точкой для изучения операционных систем при наличии базовых знаний проектирования компьютеров и компьютерной архитектуры. Вероятно, при практическом применении может возникнуть множество недостатков, но мы здесь просто экспериментируем!
Надеюсь, объяснение оказалось полезным.