
Всем привет! В этой небольшой книге (серии статей, — прим. пер.) мы с нуля, шаг за шагом, напишем скромную ОС.
▍ Навигация по частям
Вы можете насторожиться, услышав, что разработка ОС или ядра, в частности, их базовых функций на удивление проста. Даже система Linux, которая воспринимается как масштабный опенсорсный проект, на стадии версии 0.01 включала всего 8 413 строк кода. Сегодня ядро Linux действительно огромно, но начиналось оно, как и типичный хобби-проект, с крохотной базы кода.
В рамках предстоящей серии статей мы на языке С реализуем базовое переключение контекста, страничное распределение памяти, режим пользователя, командную оболочку, драйвер дискового устройства и операции чтения/записи. И хотя такой объём работы может показаться масштабным, всё это уместится всего в 1 000 строк кода.
Но сразу предупрежу — процесс окажется не так прост, как выглядит на первый взгляд. Самой сложной частью создания собственной ОС является отладка. И мы не сможем использовать для этого
printf, пока её не реализуем. Здесь вам потребуется освоить различные техники и приёмы отладки, которые в разработке ПО вы никогда не использовали. В частности, начиная «с нуля», вы будете встречать сложные этапы вроде процесса загрузки и страничной организации памяти. Но не пугайтесь, «отлаживать ОС» мы тоже научимся!Чем сложнее отладка, тем больше радости от получения рабочего продукта. Так что приглашаю вас погрузиться в захватывающий мир разработки операционных систем!
- Все примеры кода доступны на GitHub.
- Это руководство доступно под лицензией CC BY 4.0, а примеры реализации и приводимый в тексте исходный код публикуются под лицензией MIT. Для успешного воплощения проекта потребуется знание языка С и UNIX-подобной рабочей среды. Если вам знакома команда
gcc hello.c && ./a.out, то вы вполне справитесь! - Изначально это руководство писалось в качестве приложения к моей книге «Design and Implementation of Microkernels» (написана на японском).
Успехов в создании собственной ОС!
Начало
Это руководство предполагает, что вы используете UNIX-подобную систему вроде macOS или Ubuntu. Если вы работаете под Windows, установите соответствующую подсистему для Linux (WSL2) и следуйте инструкциям Ubuntu.
▍ Установка инструментов разработки
▍ macOS
Установите Homebrew и выполните следующую команду для получения всех необход��мых инструментов:
brew install llvm lld qemu▍ Ubuntu
Установите пакеты с помощью
apt:sudo apt update && sudo apt install -y clang llvm lld qemu-system-riscv32 curlТакже скачайте OpenSBI (рассматривайте его как BIOS/UEFI для ПК):
curl -LO https://github.com/qemu/qemu/raw/v8.0.4/pc-bios/opensbi-riscv32-generic-fw_dynamic.binПредупреждение
При запуске QEMU убедитесь, чтоopensbi-riscv32-generic-fw_dynamic.binрасположен в вашем текущем каталоге. Если это не так, возникнет ошибка:
"qemu-system-riscv32: Unable to load the RISC-V firmware "opensbi-riscv32-generic-fw_dynamic.bin
▍ Для пользователей других ОС
Если вы используете другую ОС, установите следующие инструменты:
bash: оболочка командной строки. Обычно установлена по умолчанию.tar: как правило, установлена по умолчанию. Отдавайте предпочтение версии GNU, а не BSD.clang: компилятор C. Убедитесь, что он поддерживает 32-битную архитектуру RISC-V (см. ниже).lld: компоновщик LLVM, связывающий компилируемые объектные файлы в исполняемый файл.llvm-objcopy: редактор объектных файлов. Поставляется с пакетом LLVM (обычно пакетомllvm).llvm-objdump: дизассемблер, аналогиченllvm-objcopy.llvm-readelf: программа для чтения ELF-файлов, аналогиченllvm-objcopy.qemu-system-riscv32: эмулятор 32-битной архитектуры процессоров RISC-V. Является частью пакета QEMU (обычно пакетаqemu).
Подсказка
Чтобы проверить, поддерживает ли вашclang32-битную архитектуру RISC-V, выполните:
$ clang -print-targets | grep riscv32 riscv32 - 32-bit RISC-V
Должно отобразитьсяriscv32. Имейте в виду, чтоclang, предустановленный на macOS, этого не покажет. Именно поэтому вам нужно установить ещё одинclangв пакетllvm.
▍ Настройка репозитория Git (по желанию)
Если вы будете работать в репозитории Git, используйте файл
.gitignore с таким содержимым:/disk/*
!/disk/.gitkeep
*.map
*.tar
*.o
*.elf
*.bin
*.log
*.pcapНа этом подготовка завершена. Приступим к созданию вашей первой операционной системы!
RISC-V
Аналогично тому, как браузеры скрывают отличия между Windows/macOS/Linux, операционные системы скрывают отличия между процессорами. Иными словами, операционная система — это программа, которая управляет процессором, обеспечивая дополнительный слой абстракции для выполнения приложений.
В этом руководстве я выбрал в качестве архитектуры процессоров RISC-V, и вот почему:
- Она имеет простую и понятную для начинающих спецификацию.
- В последние годы эта ISA (Instruction Set Architecture, архитектура системы команд) наряду с x86 и Arm становится всё более популярной.
- В спецификации хорошо и довольно интересно прописаны все основные решения её дизайна.
Мы напишем операционную систему для 32-битной RISC-V. Естественно, можно написать её и под 64-битную версию, внеся лишь несколько изменений. Однако более высокая битность несколько усложнит процесс, плюс читать более длинные адреса куда утомительнее.
▍ Виртуальная машина QEMU
Компьютеры состоят из целого набора устройств: процессора, модулей памяти, сетевых карт, жёстких дисков и так далее. Например, и iPhone, и Raspberry Pi работают на процессорах Arm, но для нас вполне естественно рассматривать их как разные компьютеры.
В этом руководстве мы пойдём путём реализации
virt машины QEMU (документация), так как:- Несмотря на свою «нереальность», она проста и очень похожа на реальные устройства.
- Её можно свободно эмулировать на QEMU, никакое оборудование покупать не потребуется.
- Сталкиваясь с проблемами отладки, вы можете обратиться к исходному коду QEMU или закрепить отладчик за процессом QEMU, чтобы определить неисправность.
▍ Ассемблер RISC-V 101
Архитектура RISC-V определяет инструкции, которые может выполнять процессор. Можно сравнить это с API или спецификацией языка программирования для разработчиков. Когда вы пишете программу на С, компилятор переводит её в код ассемблера RISC-V. К сожалению, для создания ОС вам потребуется писать, в том числе, код на ассемблере. Но спешу вас успокоить — ассемблер не так сложен, как может показаться.
Подсказка: ознакомьтесь с Compiler Explorer
Полезным инструментом для освоения ассемблера станет онлайн-компилятор Compiler Explorer. Набирая в нём код на С, вы тут же видите соответствующий код ассемблера.
По умолчанию Compiler Explorer использует язык ассемблера для процессоров x86-64, поэтому в правой панели нужно указатьRISC-V rv32gc clang (trunk), чтобы компилятор генерировал код для ассемблера RISC-V.
Также будет полезно поиграться в настройках с опциями оптимизации вроде-O0(оптимизация отключена) или-O2(оптимизация уровня 2) и посмотреть, как это влияет на итоговый код ассемблера.
▍ Основы ассемблера
Язык ассемблера в значительной степени представляет машинный код. Разберём простой пример:
asm
addi a0, a1, 123Как правило, каждая строка ассемблера соответствует одной инструкции. Первый столбец (
addi) — это имя инструкции, также именуемое кодом операции. Последующие столбцы (a0, a1, 123) представляют операнды, то есть аргументы для инструкции. В данном случае инструкция addi добавляет в регистр a1 значение 123 и сохраняет результат в регистре a0.▍ Регистры
Регистры подобны временным переменным в процессоре, и работают они намного быстрее памяти. Процессор считывает данные из памяти в регистры, производит в них арифметические операции и записывает результаты обратно в память или регистры.
Вот наиболее распространённые регистры в RISC-V:
| Регистр | Имя в ABI (псевдоним) | Описание |
| pc | pc | Счётчик команд (указывает, какую команду выполнять следующей) |
| x0 | zero | Жёстко установленный нуль (всегда считывается как нуль) |
| x1 | ra | Адрес возврата |
| x2 | sp | Указатель стека |
| x5 — x7 | t0 — t2 | Временные регистры |
| x8 | fp | Указатель фрейма стека |
| x10 — x11 | a0 — a1 | Аргументы функции/возвращаемые значения |
| x12 — x17 | a2 — a7 | Аргументы функций |
| x18 — x27 | s0 — s11 | Временные регистры, сохраняемые в процессе вызовов |
| x28 — x31 | t3 — t6 | Временные регистры |
Подсказка. Соглашение о вызовах.
Как правило, вы можете использовать регистры процессора на своё усмотрение, но для лучшей интероперабельности с другим ПО способ их использования предопределён и называется «соглашение о вызовах».
Например, регистрыx10—x11используются для аргументов функций и возвращаемых значений. Для удобства восприятия человеком им в ABI присваиваются псевдонимы вродеa0—a1. Подробнее читайте в спецификации.
▍ Доступ к памяти
Регистры работают очень быстро, но их число ограничено. Основная часть данных сохраняется в памяти, и программа считывает/записывает данные в/из неё с помощью инструкций
lw (загрузить слово) и sw (сохранить слово):asm
lw a0, (a1) // Считать слово (32 бита) из адреса в a1
// и сохранить его в a0. На C это будет: a0 = *a1;asm
sw a0, (a1) // Сохранить слово из a0 по адресу из a1.
// На C это будет: *a1 = a0;Вы можете рассматривать
(...) как разыменовывание указателя в языке С. В этом случае a1 является указателем на 32-битное значение.▍ Инструкции ветвления
Инструкции ветвления изменяют поток выполнения программы. Они используются для реализации выражений
if, for и while:asm
bnez a0, <label> // Если a0 не нулевой, перейти в <label> a0
// Если a0 нулевой, продолжить здесь
<label>:
// Если a0 не нулевой, продолжить здесьbnez означает «branch if not equal to zero» (переключиться, если не равно нулю). Другими популярными инструкциями ветвления являются beq (branch if equal, переключиться, если равно) и blt (branch if less than, переключиться, если меньше). Эти инструкции аналогичны goto в C, но содержат условия.▍ Вызовы функций
Инструкции
jal (jump and link, переход и связывание) и ret (return, возврат) используются для вызова функций и возврата их результатов:asm
li a0, 123 // Загрузить 123 в регистр a0 (аргумент функции)
jal ra, <label> // Перейти к <label> и сохранить адрес возврата в регистре ra
// После вызова функции продолжить здесь...
// int func(int a) {
// a += 1;
// return a;
// }
<label>:
addi a0, a0, 1 // Инкрементировать a0 (первый аргумент) на 1
ret // Вернуть по адресу, сохранённому в ra.
// Регистр a0 содержит возвращённое значение.В соответствии с соглашением о вызовах аргументы функций передаются в регистры
a0 — a7, а возвращённое значение сохраняется в регистре a0.▍ Стек
Стек — это пространство памяти, заполняемое по принципу LIFO (Last In First Out, последним пришёл-первым вышел) и используемое для вызовов функций и хранения локальных переменных. Растёт он сверху вниз, и указатель
sp указывает на его вершину. Для сохранения значения в стеке, мы декрементируем указатель и записываем это значение (та же операция
push):asm
addi sp, sp, -4 // Перемещаем указатель стека вниз на 4 байта (то есть аллоцируем память в стеке).
sw a0, (sp) // Сохраняем a0 в стеке.Для загрузки значения из стека, загружаем его и инкрементируем указатель (та же операция
pop):asm
lw a0, (sp) // Загружаем a0 из стека.
addi sp, sp, 4 // Перемещаем указатель стека вверх на 4 байта (то есть освобождаем память в стеке).Подсказка
В C операции со стеком генерируют��я компилятором, так что вручную писать их не нужно.
▍ Режимы процессора
У процессора есть несколько рабочих режимов, каждый со своим уровнем привилегий. В случае RISC-V их три:
| Режим | Описание |
| M-mode | Режим, в котором работает OpenSBI (то есть BIOS). |
| S-mode | Режим, в котором работает ядро, он же «режим ядра». |
| U-mode | Режим, в котором работают приложения, он же «режим пользователя». |
▍ Привилегированные инструкции
Среди инструкций процессора есть такие, которые приложения (в режиме пользователя) исполнять не могут. В данном руководстве мы будем использовать следующие привилегированные инструкции:
| Код операции и операнды | Описание | Псевдокод |
| csrr rd, csr | Считывание из CSR | rd = csr; |
| csrw csr, rs | Запись в CSR | csr = rs; |
| csrrw rd, csr, rs | Одновременное чтение из/запись в CSR | tmp = csr; csr = rs; rd = tmp; |
| sret | Возврат из обработчика прерываний (восстановление счётчика команд, режима работы и так далее) | |
| sfence.vma | Очистка буфера ассоциативной трансляции (Translation Lookaside Buffer, TLB) |
Подсказка
Некоторые инструкции, вродеsret, выполняют сложные операции. В этом случае понять, что конкретно происходит, поможет чтение исходного кода эмулятора RISC-V. В частности, rvemu написан интуитивным и понятным образом (вот пример реализации в нёмsret).
▍ Встроенный ассемблер
В последующих главах вы будете встречать специальный синтаксис С вроде такого:
uint32_t value;
__asm__ __volatile__("csrr %0, sepc" : "=r"(value));Это так называемый «встроенный ассемблер», синтаксис для встраивания кода ассемблера в код С. И хотя вы можете писать код ассемблера в отдельном файле (с расширением
.s), использование его встраиваемого варианта обычно предпочтительнее, и вот почему:- Это позволяет использовать в коде ассемблера переменные С, а также присваивать этим переменным его результаты.
- Это позволяет доверить процесс распределения регистров компилятору С. То есть вам не нужно вручную прописывать на ассемблере сохранение содержимого регистров и их освобождение.
▍ Как писать встроенный ассемблер
Встроенный код ассемблера прописывается в следующем формате:
__asm__ __volatile__("assembly" : output operands : input operands : clobbered registers);| Часть | Описание |
| __asm__ | Указывает, что это встроенный код ассемблера. |
| __volatile__ | Говорит компилятору не оптимизировать код «assembly» |
| «assembly» | Код ассемблера записывается в виде строкового литерала. |
| Выходные операнды | Переменные C для сохранения результатов кода ассемблера. |
| Входные операнды | Выражения C (например, 123, x), используемые в коде ассемблера. |
| Переписываемые регистры | Регистры, чьё содержимое в коде ассемблера уничтожается. Если о них забыть, компилятор С не сохранит содержимое этих регистров, и возникнет баг. |
constraint (C expression). Ограничения (сonstraints) используются для указания типа операнда; обычно это =r (регистр) для выходных операндов, и r для входных.К операндам вывода и ввода в ассемблере можно обращаться, используя
%0, %1, %2 и так далее — в порядке, начинающемся с операндов вывода.▍ Примеры
uint32_t value;
__asm__ __volatile__("csrr %0, sepc" : "=r"(value));Этот код считывает значение
sepc CSR, используя инструкцию csrr, и присваивает его переменной value. Здесь %0 соответствует переменной value.__asm__ __volatile__("csrw sscratch, %0" : : "r"(123));Этот код записывает
123 в sscratch CSR, используя инструкцию csrw. Здесь %0 соответствует регистру, содержащему 123 (ограничение r) и по факту будет выглядеть так:li a0, 123 // Установить 123 в регистр a0.
csrw sscratch, a0 // Записать значение регистра a0 в регистр sscratch.И хотя во встроенный код ассемблера записывается только инструкция csrw, инструкция
li автоматически вставляется компилятором, чтобы удовлетворить ограничение "r" (значение в регистре). Очень удобно!Подсказка
Встроенный ассемблер — это собственное расширение компилятора, которого в спецификации языка С нет. Подробнее о его использовании можете почитать в документации GCC. Тем не менее для понимания этого принципа потребуется время, так как синтаксис ограничений отличается в зависимости от архитектуры процессоров, плюс в нём есть много сложной функциональности.
Для начинающих рекомендую поискать реальные случаи использования встроенного ассемблера. Хорошим примером для этого послужат HinaOS и xv6-riscv.
Что мы будем реализовывать
Прежде чем перейти к созданию ОС, вкратце пробежимся по той функциональности, которую мы в ней создадим.
▍ Функциональность ОС из 1 000 строк кода
В рамках этого руководства мы реализуем следующие основные функции:
- Многозадачность: переключение между процессами, позволяющее нескольким приложениям совместно использовать один процессор.
- Обработку исключений: обработку событий, требующих вмешательства ОС, например, недопустимых инструкций.
- Страничное распределение памяти: предоставление каждому приложению отдельного адреса памяти.
- Системные вызовы: чтобы приложения могли вызывать функции ядра.
- Драйверы устройств: абстрактная аппаратная функциональность, такая как операции чтения и записи диска.
- Файловую систему: управление файлами на диске.
- Командную оболочку: пользовательский интерфейс.
▍ Каких функций не будет
Ниже перечислены важные функции, которые в этом руководстве реализованы не будут:
- Обработка прерываний: вместо этого мы будем использовать метод опроса (периодически проверять наличие на устройствах новых данных), также известный как холостой цикл.
- Обработка таймера: приоритетную многозадачность реализовывать не будем. Вместо этого мы используем кооперативную многозадачность, при которой каждый процесс добровольно уступает процессорное время.
- Межпроцессное взаимодействие: такой функциональности, как каналы (pipe), сокет межпроцессного взаимодействия и общая память у нас тоже не будет.
- Мультипроцессорная поддержка: реализуем поддержку только одного процессора.
▍ Структура исходного кода
Постепенно создавая систему с нуля, в итоге мы получим следующую структуру файлов:
├── disk/ - содержимое файловой системы
├── common.c - общая библиотека режима ядра/пользователя: printf, memset, ...
├── common.h - общая библиотека режима ядра/пользователя: определения структур и констант
├── kernel.c - ядро: управление процессами, системные вызовы, драйверы устройств, файловая система
├── kernel.h - ядро: определения структур и констант
├── kernel.ld - ядро: скрипт компоновщика (определение структуры памяти)
├── shell.c - командная оболочка
├── user.c - библиотека режима пользователя: функции для системных вызовов
├── user.h - библиотека режима пользователя: определения структур и констант
├── user.ld - режим пользователя: скрипт компоновщика (определение структуры памяти)
└── run.sh - скрипт сборкиПодсказка
В этом руководстве «пространство пользователя» иногда обозначается кратко как «пользователь». В таких случаях его нужно воспринимать как «приложения» и не путать с «учётной записью пользователя».
На этом первая часть серии завершается. В следующей части мы уже загрузим наше ядро, напечатаем строку «Hello world!», реализуем базовые типы и операции с памятью, а также механизм «паники» ядра.
До скорой встречи!
Telegram-канал со скидками, розыгрышами призов и новостями IT 💻

