
Недавно я реализовал минимальный 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, но она должна работать так, как это запланировано. Хотя некоторые части кода упрощены, это не снижает образовательную ценность эксперимента.
В целом, этот эксперимент может служить отправной точкой для изучения операционных систем при наличии базовых знаний проектирования компьютеров и компьютерной архитектуры. Вероятно, при практическом применении может возникнуть множество недостатков, но мы здесь просто экспериментируем!
Надеюсь, объяснение оказалось полезным.
Zalechi
Короче я программист никакой, и решил разобраться с преоываниями, ибо с детства слышал за них, видел эти IRQи в BIOS, но шо за чем было не понятно.
И вот решил я по мере прочтения сего опуса, обратиться за разъяснениями к Гроку:
Скрытый текст
Я: Прерывания в х86
Грок:
Прерывания в x86 — механизмы обработки событий, временно приостанавливающие выполнение программы. Делятся на:
Аппаратные (внешние): от устройств (IRQ), управляются контроллером прерываний (PIC/APIC).
Программные: вызываются инструкцией INT n (например, системные вызовы).
Исключения: ошибки процессора (деление на 0, страничные сбои).
Механизм работы:
Процессор получает сигнал прерывания.
Сохраняет состояние (регистры, флаги).
Переходит к обработчику из таблицы векторов прерываний (IDT).
Выполняет обработчик, затем восстанавливает состояние (IRET).
IDT (Interrupt Descriptor Table):
Хранит адреса обработчиков.
Настраивается ОС (регистр IDTR).
Примеры:
IRQ0: системный таймер.
INT 0x80: системный вызов в Linux.
Режимы:
Реальный: 16-бит, векторы в первых 1 КБ памяти.
Защищённый: 32/64-бит, использует IDT.
Я: Так эти IRQ (pic/apic) — это контроллер внутри процессора?
Грок:
Нет, IRQ (PIC/APIC) — это не контроллер внутри процессора.
PIC (Programmable Interrupt Controller, например, 8259) — отдельная микросхема или часть чипсета, управляющая аппаратными прерываниями от устройств, перенаправляя их процессору.
APIC (Advanced Programmable Interrupt Controller) — более современный контроллер, интегрированный в чипсет или процессор (в новых системах), поддерживает многопроцессорность и сложные схемы маршрутизации IRQ.
Процессор только получает сигналы от PIC/APIC через линии прерываний и обрабатывает их согласно IDT.
Я: Окей. А когда процессору нужно сделать программные прерывание ?
Грок:
Программные прерывания (вызываемые инструкцией INT n) нужны процессору, когда:
Системные вызовы: Программа запрашивает услуги ОС (например, INT 0x80 в Linux для доступа к ядру).
Отладка: Вызов INT 3 для точек останова.
Эмуляция: Обработка устаревших или специфичных инструкций (например, INT 0x2F в DOS).
Управление задачами: Переключение контекста или вызов подпрограмм в определённых ОС.
Они инициируются программно, а не внешними устройствами, для передачи управления ОС или обработчику в IDT.
Я: Да, но почему в Risc-v этим занимается процессор?
Грок:
В RISC-V процессор напрямую обрабатывает прерывания, так как архитектура не использует отдельный контроллер прерываний (PIC/APIC), как в x86.
Почему:
Простота архитектуры: RISC-V минимизирует внешние компоненты, перенося управление прерываниями в процессор (встроенный механизм CLINT/PLIC).
CLINT (Core-Local Interruptor): Обрабатывает локальные прерывания (таймеры, софт-прерывания) для каждого ядра.
PLIC (Platform-Level Interrupt Controller): Управляет внешними прерываниями, но интегрирован ближе к процессору и настраивается через его регистры.
Гибкость: Процессор через CSR (Control and Status Registers) напрямую управляет приоритетами, маскировкой и векторизацией прерываний.
В отличие от x86, где PIC/APIC — отдельные модули, RISC-V встраивает логику обработки в ядро, упрощая систему и снижая зависимость от внешних чипов.
Я: Но теперь в х86 через apic поишли к этому же?
Грок:
Да, в современных x86-системах с APIC (особенно Local APIC, интегрированным в процессор) архитектура приближается к модели RISC-V, где управление прерываниями частично встроено в процессор.
Как это работает:
Local APIC (в каждом ядре x86) обрабатывает локальные прерывания (таймеры, термические события, межпроцессорные прерывания — IPI).
I/O APIC (в чипсете) управляет внешними прерываниями от устройств, но передаёт их в Local APIC.
Процессор настраивает и приоритизирует прерывания через регистры APIC, что схоже с CSR в RISC-V.
Сходство с RISC-V:
Встроенная логика в процессоре (Local APIC vs CLINT/PLIC).
Гибкая маршрутизация и векторизация прерываний.
Отличия:
x86 сохраняет разделение на Local APIC и I/O APIC, тогда как RISC-V чаще использует единый PLIC.
x86 сложнее из-за обратной совместимости с PIC (8259).
Таким образом, x86 с APIC движется к интеграции управления прерываниями в процессор, но сохраняет более сложную структуру по сравнению с RISC-V.
Я: А почему так сложилось исторически с х86?
Грок:
Историческая сложность управления прерываниями в x86 обусловлена:
Эволюция архитектуры:
x86 начался с 8086 (1978), где использовался простой PIC 8259 для управления IRQ. Это был внешний чип, так как процессоры того времени были ограничены по функционалу.
Обратная совместимость заставляла сохранять поддержку старых механизмов (реальный режим, PIC) при добавлении новых (APIC в 1990-х).
Рост сложности систем:
С появлением многопроцессорных систем и сложных периферийных устройств PIC стал недостаточным (ограничение на 15 IRQ, отсутствие поддержки SMP).
Введён APIC (Local и I/O) для поддержки многопроцессорности, приоритизации и большего числа прерываний, но он дополнял, а не заменял PIC.
Экосистема IBM PC:
x86 развивался в тесной связи с IBM PC, где стандарты (как ISA, PCI) диктовали использование внешних контроллеров.
Это привело к разделению функций между процессором и чипсетом (включая I/O APIC).
Контраст с RISC-V:
RISC-V создавался позже, с нуля, для простоты и модульности, избегая исторического багажа.
x86 же обрастал новыми слоями (реальный режим → защищённый → 64-бит), сохраняя старые механизмы для совместимости.
Итог: x86 усложнял систему прерываний из-за необходимости поддержки старых стандартов и постепенной адаптации к новым требованиям, в отличие от более чистой и интегрированной модели RISC-V.
Я: Окей. А теперь опиши мне саму суть прерываний.
Грок:
Прерывания — механизм, позволяющий процессору реагировать на события, временно останавливая текущую программу.
Суть:
Событие: Внешнее (устройство, IRQ) или внутреннее (ошибка, программный вызов).
Реакция: Процессор сохраняет состояние (регистры, флаги), переходит к обработчику из таблицы векторов (IDT в x86), выполняет его, затем восстанавливает состояние.
Цель: Быстрая обработка событий (ввод/вывод, ошибки, системные вызовы) без постоянного опроса устройств.
Ключ: Перенос управления на нужный код (обработчик) с минимальной задержкой, обеспечивая многозадачность и реактивность системы.
Я: В каких случаях требуется прерывать ход выполнения программы и как он это понимает?
Грок:
Когда требуется прерывание:
Аппаратные события: Устройства (клавиатура, таймер, диск) сигнализируют через IRQ (например, ввод данных, завершение операции).
Исключения: Ошибки процессора (деление на 0, доступ к недопустимой памяти).
Программные вызовы: Программа инициирует INT n для системных функций (например, доступ к ОС).
Многозадачность: Переключение задач или таймер для распределения процессорного времени.
Как процессор понимает:
Сигнал: Аппаратное прерывание (IRQ) через контроллер (PIC/APIC) или программное (INT n) активирует линию прерывания.
Таблица векторов: Процессор смотрит в IDT (x86) или аналог (например, CSR в RISC-V), где хранятся адреса обработчиков.
Контекст: Сохраняет текущие регистры и флаги, переходит к обработчику.
Флаги: Проверяет регистр флагов (например, IF в x86) — если прерывания разрешены, реагирует.
Процессор реагирует автоматически, если событие имеет приоритет и прерывания не замаскированы.
Я: Зачем вообще они нужны в логике?
Грок:
Прерывания нужны в логике процессора для:
Реактивности: Быстрая реакция на внешние (устройства) или внутренние (ошибки, вызовы) события без опроса.
Многозадачности: Переключение между задачами, управление временем (таймеры).
Эффективности: Освобождение процессора от постоянной проверки устройств, позволяя выполнять программу до нужного события.
Обработки ошибок: Коррекция или завершение при сбоях (деление на 0, защита памяти).
Системные вызовы: Доступ программ к функциям ОС (файлы, сеть).
Логика: Прерывания позволяют процессору гибко управлять событиями, сохраняя производительность и порядок выполнения.
Я: Ну а почему вообще от них не избавиться?
Грок:
Избавиться от прерываний нельзя, потому что:
Реактивность: Без прерываний процессор должен постоянно опрашивать устройства (поллинг), тратя ресурсы и замедляя выполнение.
Многозадачность: Прерывания (таймеры) необходимы для переключения задач в ОС.
Обработка событий: Аппаратные (ввод/вывод) и программные (системные вызовы) события требуют немедленной реакции, что без прерываний невозможно.
Ошибки: Исключения (например, деление на 0) требуют немедленного вмешательства для стабильности.
Эффективность: Прерывания минимизируют ожидание, позволяя процессору работать, пока событие не произойдёт.
Итог: Без прерываний система теряет гибкость, производительность и способность реагировать на события в реальном времени.
Я: Реактивность: зачем ему постоянно опрашивать кого-то.
Давай на примере с загруженной игрой, где есть нажатия одной кнопки
Грок:
Реактивность и почему нужен опрос без прерываний:
В контексте игры с нажатием кнопки (например, пробел для прыжка):
Без прерываний (поллинг):
Процессор постоянно проверяет состояние кнопки (например, порт ввода-вывода) в цикле.
Пример: Игра каждые несколько миллисекунд читает регистр клавиатуры, чтобы узнать, нажат ли пробел.
Проблема:
Процессор тратит время на проверку, даже если кнопка не нажата.
Задержки в обработке других задач (графика, физика игры), так как цикл опроса занимает ресурсы.
Если опрос редкий, нажатие может быть пропущено или обработано с задержкой.
С прерываниями:
Клавиатура отправляет сигнал IRQ при нажатии пробела через контроллер (PIC/APIC).
Процессор мгновенно прерывает игру, сохраняет состояние, вызывает обработчик (например, “персонаж прыгает”), затем возвращается.
Преимущество:
Процессор не тратит время на опрос — работает над игрой (рендеринг, логика).
Реакция на нажатие моментальная, без задержек.
Эффективное использование ресурсов.
Итог: Прерывания избавляют от необходимости постоянного опроса клавиатуры, позволяя игре быть отзывчивой и одновременно выполнять другие задачи (графику, звук), не теряя производительность.
Я: Вот тут я и не понимаю. Чего ему постоянно опрашивать клаву, а не просто ждать сигнала, что кнопка нажата?
Грок:
Без прерываний процессор не может “просто ждать” сигнала от клавиатуры, потому что:
Нет механизма уведомления: Без прерываний процессор не знает, когда кнопка нажата, пока сам не проверит (опрос порта ввода-вывода).
Поллинг как единственный способ: В отсутствие прерываний процессору приходится в цикле читать состояние клавиатуры (например, регистр порта), чтобы заметить нажатие. Это и есть опрос — активная проверка, тратящая ресурсы.
Почему нельзя “ждать”:
Процессор не имеет встроенного пассивного режима ожидания конкретного сигнала без прерываний.
Без IRQ (сигнал от клавиатуры через контроллер) процессор не получает уведомлений о событиях.
Пример с игрой:
С прерываниями: Клавиатура посылает IRQ при нажатии кнопки, процессор мгновенно реагирует, вызывая обработчик (прыжок персонажа).
Без прерываний: Игра должна постоянно проверять порт клавиатуры (например, 1000 раз в секунду), чтобы поймать нажатие. Это нагружает процессор и замедляет другие задачи (графику, физику).
Итог: Прерывания позволяют процессору “ждать” сигнала пассивно, реагируя только при событии, вместо активного опроса, который тратит ресурсы и снижает отзывчивость.
Я: Так почему вы назвацте прерываниями, то что является уведомлением/сигналом по сути?
Грок:
Прерывания называют так, потому что они прерывают текущий ход выполнения программы:
Суть: Сигнал (аппаратный или программный) заставляет процессор остановить текущую задачу, сохранить состояние и перейти к обработчику.
Почему “прерывание”: Это не просто уведомление, а активное вмешательство в работу процессора, вынуждающее его временно бросить текущую программу.
Механизм: Сигнал (IRQ, исключение, INT n) инициирует переключение контекста, что буквально “разрывает” выполнение кода.
Термин “прерывание” отражает процесс принудительной паузы и переключения, а не просто получение сигнала.
Я: А понял. Прерывание рутин на поступающий вызов i/o
Грок: Точно! Прерывания — это когда процессор ставит текущую задачу (рутину) на паузу, чтобы обработать входящий сигнал ввода-вывода (I/O), вроде нажатия клавиши, и затем возвращается обратно.
Я: Офигеть, как запутано мы пришли к простому определению.
Грок: Хех, да, путь был тернистый, но суть проста: прерывания — это пауза программы для обработки срочного I/O-сигнала с возвратом назад.
От туда же я понял, что такое контекст в терминологии процессора и еще там что-то про регистры и прочее. Мило. И главное, что относительно быстро можно получить исчерпывающую информацию, не имея под руками толкового специалиста готового помочь в принципе или за так…
Погнали дальше читать статью))))
Aodaliya_Ren
Сначала пойми, что не "от туда", а "оттуда". А про прерывания спроси у дедушки, он их весело юзал как только персональные компьютеры свалились нам на голову в конце 80х годов прошлого века.
Только не спутай "прерывания" с прерыванием беременности, ибо аборт и abort - таки две большие разницы.