Всем привет! В этой небольшой книге (серии статей, — прим. пер.) мы с нуля, шаг за шагом, напишем скромную ОС.

Вы можете насторожиться, услышав, что разработка ОС или ядра, в частности, их базовых функций на удивление проста. Даже система 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).

Подсказка

Чтобы проверить, поддерживает ли ваш clang 32-битную архитектуру 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 Временные регистры
Подсказка. Соглашение о вызовах.

Как правило, вы можете использовать регистры процессора на своё усмотрение, но для лучшей интероперабельности с другим ПО способ их использования предопределён и называется «соглашение о вызовах».

Например, регистры x10x11 используются для аргументов функций и возвращаемых значений. Для удобства восприятия человеком им в ABI присваиваются псевдонимы вроде a0a1. Подробнее читайте в спецификации.

▍ Доступ к памяти


Регистры работают очень быстро, но их число ограничено. Основная часть данных сохраняется в памяти, и программа считывает/записывает данные в/из неё с помощью инструкций 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 содержит возвращённое значение.

В соответствии с соглашением о вызовах аргументы функций передаются в регистры a0a7, а возвращённое значение сохраняется в регистре 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)
CSR (Control and Status Register, регистр управления и состояний) — это регистр, в котором сохраняются настройки процессора. Список CSR можно найти в спецификации RISC-V.

Подсказка

Некоторые инструкции, вроде 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 ?

Комментарии (4)


  1. martyncev
    19.01.2025 09:17

    Опять.. Зачем?


    1. Rio
      19.01.2025 09:17

      Так интересно же. Вот для меня тут вроде ничего нового, но всё равно интересно было прочитать. А ещё, кто-то может увидит, вдохновится, и заинтересуется темой. Как ещё новичков заинтересовывать, если не так?


  1. 3263927
    19.01.2025 09:17

    вау! супер! прямо как цивилизация с нуля! очень интересно, жду продолжения!


  1. LexD1
    19.01.2025 09:17

    Плох тот программист, который не мечтает написать свою ОС.