Приветствую, Хабравчане!
Элегантный OSDev: Пишем ядро ОС на modern C++ без макросов. Часть 2 — PMM + Allocator
В этой серии статей мы будем писать монолитное ядро на C++17 с упором на чистую архитектуру, тестируемый код и минимальное использование ассемблера. Меня всегда привлекала разработка операционных систем, но на пути к ней часто стояли барьеры: тонны ассемблерных вставок, макросы и низкоуровневые хаки. За последние 10 лет набравшись опыта в C++, я решил вернуться к этой теме с новым подходом — максимально использовать современные возможности языка для создания понятного, поддерживаемого кода ядра.
В этой части мы создадим Hardware Abstraction Layer (HAL) для консольного вывода, реализуем две версии ядра (для отладки на хосте и для "голого железа") и успешно запустим наше ядро в QEMU.
Почему C++17 и что мы будем использовать
Мы сознательно ограничим некоторые возможности языка для работы в среде ядра:
Без исключений — очень дорого и требует проф ядра.
Без RTTI — просто дорого.
С ограниченной стандартной библиотеки на первом этапе -
Стандартной библиотеке С++ и STL быть. Для раннего этапа буду использовать контейнеры, без динамической памяти, к примеру std::array. Когда будет готова работа с динамической памятью в ядре, я глобально для ядра переопределю, new и delete. И в самом ядре, можно будет использовать STL без ограничений, std::vector, std::unordered_map и т.д
Минимум костылей и велосипедостроения.
Своя реализация
new/delete— на первых порах это будут заглушки, позже реализуем кучу
Но при этом активно используем:
Классы и наследование для абстракций
Виртуальные методы для полиморфизма
Шаблоны (в будущих частях)
constexprиnoexceptPlacement new для работы с сырой памятью
Установка инструментария
Для работы нам понадобится кросс-компилятор и эмулятор. Я использую WSL/Ubuntu, но подойдёт любой Linux-дистрибутив.
Лично я использую WSL.
# Обновляем пакеты sudo apt update && sudo apt upgrade -y # Ставим инструменты для сборки sudo apt install build-essential make git nano -y # Устанавливаем эмулятор QEMU sudo apt install qemu-system-x86 -y # Устанавливаем кросс-компилятор для x86 sudo apt install gcc-i686-linux-gnu g++-i686-linux-gnu binutils-i686-linux-gnu -y
После портянок в терминале, все будет установлено. Переходим к разработке.
Я предлагаю маленькими шагами идти по пути разработки ОС. Первый шаг, это просто вывести строку "Running SimpleOS", да так я назвал проект.
Концепция: Hardware Abstraction Layer (HAL)
HAL — это слой между физическим железом и ядром ОС. Его цель — скрыть аппаратные особенности за унифицированными интерфейсами. Это даёт нам несколько преимуществ:
Переносимость — ядро не зависит от конкретного железа
Тестируемость — можно создавать mock-реализации для отладки
Чистая архитектура — разделение ответственности
Начнём с самого простого — абстракции для консольного вывода.
То есть мы должны создать универсальный слой, к которому будет обращаться наше ядро, оно не знает подробности о шинах и портах, а просто вызывает методы абстрактных классов.
Я предлагаю начать с разработки абстрактного HAL, а так же для демонстрации концепции, мы напишем консольный вывод в обычную консоль Windows, Linux и для голого железа x86.
Поехали.
#pragma once #include <new> #include <cstdint> namespace HAL { class IConsole { public: virtual ~IConsole() = default; virtual void Clear() = 0; virtual void Write(char c) = 0; virtual void Write(const char* src) = 0; }; }
Абстрактный класс консоли, позволяет выводить информацию.
Теперь перейдем к реализации для использования с iostream
#include <iostream> #include <SimpleOS/Console.hpp> using namespace HAL; Console::Console() { } Console::~Console() { } void Console::Clear() { } void Console::Write(char c) { std::cout << c; } void Console::Write(const char* src) { std::cout << src; }
Так же создадим ядро нашей ос.
#include <SimpleOS/Kernel.hpp> using namespace HAL; Kernel::Kernel() : _console(nullptr) { _console = new (_consoleBuffer) Console(); } Kernel::~Kernel() { if (_console) { _console->~IConsole(); } } void Kernel::Run() { while (true) { _console->Write("Running SimpleOS\n"); } }
И будем вызывать из main
#include <SimpleOS/Kernel.hpp> int main() { Kernel kernel; kernel.Run(); return 0; }

Уверен, у вас возникает вопрос и где ОС? Вывод в консоль... Что происходит?
А в этом и есть преимущество HAL, мы можем написать абстрактные классы с единым интерфейсом, но для упрощения отладки или тестировании логики замокать их. Предоставив другу реализацию. Когда мы дойдем до графики, то добавим просто SDL и всю логику вывода и рисования графики будем отлаживать на ней, а уже потом просто реализуем железную реализацию. Ну клево же?
Для нашего ядра создаем отдельный файл X86Main.cpp
#include <SimpleOS/Kernel.hpp> extern "C" void KernelMain() { Kernel kernel; kernel.Run(); }
Код абсолютно тот же, только его запуск происходит из другого файла. KernelMain вызывает загрузчик.
Вот наша реализация железной консоли
#include <SimpleOS/Console.hpp> using namespace HAL; Console::Console() : _cursorX(0), _cursorY(0), _buffer((uint16_t*)0xB8000) { } Console::~Console() { } void Console::Clear() { for (size_t i = 0; i < 80 * 25; i++) { Write(' ', i % 80, i / 80); } _cursorX = 0; _cursorY = 0; } void Console::Write(char c) { if (c == '\n') { _cursorX = 0; _cursorY++; } else { Write(c, _cursorX, _cursorY); _cursorX++; if (_cursorX >= 80) { _cursorX = 0; _cursorY++; } } if (_cursorY >= 25) { _cursorY = 24; } } void Console::Write(const char* src) { while (*src) { Write(*src++); } } void Console::Write(char c, uint8_t x, uint8_t y) { _buffer[y * 80 + x] = (0x0F << 8) | c; }
Это реализация драйвера текстовой консоли для VGA-режима 80×25. Код управляет выводом символов прямо в видеопамять компьютера, которая находится по фиксированному адресу 0xB8000.
Конструктор настраивает начальную позицию курсора в левом верхнем углу экрана (координаты 0,0). Метод Clear() заполняет все 80 столбцов и 25 строк экрана пробелами, создавая эффект "чистого экрана".
Основной метод Write() обрабатывает печать символов: обычные символы выводятся в текущую позицию курсора, а при получении символа перевода строки (\n) курсор перемещается на следующую строку.
Наш код выполняется на голом железе и выводит символы на экран. Магия? Нет абстракция.
Заголовочный файл ядра выглядит так:
#pragma once #include <SimpleOS/Console.hpp> class Kernel { public: Kernel(); ~Kernel(); void Run(); private: alignas(HAL::Console) uint8_t _consoleBuffer[sizeof(HAL::Console)]; HAL::IConsole* _console; };
Здесь мы сознательно избегаем обычного new и выделяем память вручную _consoleBuffer это просто массив байтов нужного размера. Директива alignas гарантирует, что этот буфер будет расположен в памяти с правильным выравниванием для класса HAL::Console. Позже, в конструкторе ядра, мы создадим объект консоли прямо в этом буфере с помощью placement new. Такой подход позволяет нам контролировать размещение объектов в памяти, что критически важно в среде ядра, где менеджер памяти ещё не готов.
Kernel::Kernel() : _console(nullptr) { _console = new (_consoleBuffer) Console(); }
Прямо в конструкторе ядра мы создаём экземпляр консоли. На данном этапе это допустимое упрощение, но с архитектурной точки зрения здесь есть проблема: конструкторы не должны выполнять сложную инициализацию с возможностью сбоя.
Дело в том, что в конструкторах невозможно корректно обработать ошибки без механизма исключений, а исключения в ядре нет и на данном этапе не возможно встроить в ядро.
Более правильный подход — разделить создание объекта и его инициализацию. В конструкторе следует лишь обнулить память или установить POD-типам значения по умолчанию, а всю сложную логику с возможными ошибками вынести в отдельный метод, например Initialize() или Setup(). Тогда мы сможем явно проверить успешность инициализации и корректно отреагировать на сбой, прежде чем продолжить работу ядра.
Kernel::~Kernel() { if (_console) { _console->~IConsole(); } }
В деструкторе ядра вызываем деструктор для консоли. Деструктор сейчас у нее пустой.
void Kernel::Run() { while (true) { _console->Write("Running SimpleOS\n"); } }
Это наш главный цикл ядра, просто бесконечно выводим строку.
Конечно обошлось не без но:
#pragma once #include <new> #include <cstddef> #include <cstdint> [[nodiscard]] inline void* operator new(size_t size) noexcept { (void)size; asm volatile("cli; hlt"); __builtin_unreachable(); } inline void operator delete(void* ptr) noexcept { (void)ptr; } inline void operator delete(void* ptr, size_t size) noexcept { (void)ptr; (void)size; }
Это временная заглушка для операторов работы с памятью. Поскольку в нашей ОС пока нет полноценного менеджера памяти, мы переопределяем стандартные new и delete. Если код случайно попытается выделить память через new, система безопасно остановится — это лучше, чем получить непредсказуемые ошибки.
Теперь самое интересное — сборка и запуск. Честно признаюсь: я начал с попыток настроить сборку через CMake, но после пары часов борьбы с кросс-компиляцией и скриптами линковки понял, что для первых шагов старый добрый Makefile будет проще и нагляднее. Иногда простые инструменты лучше справляются со специфичными задачами системного программирования.
Скрытый текст
TARGET = i686-linux-gnu CC = $(TARGET)-gcc CXX = $(TARGET)-g++ AS = $(TARGET)-as LD = $(TARGET)-ld SRCDIR = source INCDIR = include X86DIR = source/x86 SIMPLEOSDIR = source/SimpleOS X86SIMPLEOSDIR= source/x86/SimpleOS CFLAGS = -ffreestanding -O2 -Wall -Wextra CXXFLAGS = -ffreestanding -O2 -Wall -Wextra -fno-exceptions -fno-rtti CXXFLAGS += -I$(INCDIR) -I$(SRCDIR) -I$(X86DIR) -I$(X86SIMPLEOSDIR) # Добавляем путь к ExtNew.hpp ASFLAGS = --32 OBJS = boot.o X86Main.o Kernel.o Console_x86.o KERNEL = myos.bin .PHONY: all clean run all: $(KERNEL) $(KERNEL): $(OBJS) $(LD) $(OBJS) -T $(X86DIR)/linker.ld -o $@ boot.o: $(X86DIR)/boot.asm $(AS) $(ASFLAGS) $< -o $@ X86Main.o: X86Main.cpp $(SIMPLEOSDIR)/Kernel.hpp $(X86SIMPLEOSDIR)/ExtNew.hpp $(CXX) $(CXXFLAGS) -c $< -o $@ Kernel.o: $(SIMPLEOSDIR)/Kernel.cpp $(SIMPLEOSDIR)/Kernel.hpp $(CXX) $(CXXFLAGS) -c $< -o $@ Console_x86.o: $(X86SIMPLEOSDIR)/Console.cpp $(X86SIMPLEOSDIR)/Console.hpp $(X86SIMPLEOSDIR)/ExtNew.hpp $(CXX) $(CXXFLAGS) -c $< -o $@ clean: rm -f $(OBJS) $(KERNEL) *.o run: $(KERNEL) qemu-system-i386 -kernel $(KERNEL)
Таким многословным мэйк файлом собирается наш проект.
Естественно без асма никуда, нам нужно написать свой загрузчик.
.section .multiboot .align 4 .long 0x1BADB002 .long 0x00 .long - (0x1BADB002 + 0x00) .section .text .global _start _start: call KernelMain halt: hlt jmp halt
В двух словах этот загрузчик вызывает нашу функцию KernelMain и позволяет загрузиться в qemu - эмулятор.
Для линковки есть другой файл
ENTRY(_start) OUTPUT_FORMAT(elf32-i386) SECTIONS { . = 0x100000; .text : { *(.text) } .data : { *(.data) } .bss : { *(.bss) } }
Он связывает наш код и добавляет информацию с какого адреса будем грузиться.
Сборка стандартная.
make
Есть немного ворнингов, но работает:)
i686-linux-gnu-as --32 source/x86/boot.asm -o boot.o
i686-linux-gnu-g++ -ffreestanding -O2 -Wall -Wextra -fno-exceptions -fno-rtti -Iinclude -Isource -Isource/x86 -Isource/x86/SimpleOS -c X86Main.cpp -o X86Main.o
i686-linux-gnu-g++ -ffreestanding -O2 -Wall -Wextra -fno-exceptions -fno-rtti -Iinclude -Isource -Isource/x86 -Isource/x86/SimpleOS -c source/SimpleOS/Kernel.cpp -o Kernel.o
i686-linux-gnu-g++ -ffreestanding -O2 -Wall -Wextra -fno-exceptions -fno-rtti -Iinclude -Isource -Isource/x86 -Isource/x86/SimpleOS -c source/x86/SimpleOS/Console.cpp -o Console_x86.o
i686-linux-gnu-ld boot.o X86Main.o Kernel.o Console_x86.o -T source/x86/linker.ld -o myos.bin
i686-linux-gnu-ld: warning: boot.o: missing .note.GNU-stack section implies executable stack
i686-linux-gnu-ld: NOTE: This behaviour is deprecated and will be removed in a future version of the linker
i686-linux-gnu-ld: warning: myos.bin has a LOAD segment with RWX permissions
Последний шаг, вызываем qemu с загрузкой нашего ядра.
make run
И брюки превращаются...

Архитектурные решения и почему они важны
Использование placement new — мы не можем использовать обычный
newдо инициализации кучи, поэтому размещаем объекты в предварительно выделенной памяти.Виртуальные методы в HAL — позволяют легко подменять реализации. Хотите выводить в UART вместо VGA? Просто создайте новый класс.
Отдельные точки входа —
main()для хоста иKernelMain()для bare-metal позволяют иметь одну кодовую базу для двух сред.Заглушки для new/delete — безопасная обработка ситуаций, когда куча ещё не готова.
Начало положено, наш код выполняется на голом железе.
Задавайте вопросы в комментариях! Я сам только начинаю погружаться в разработку ОС, поэтому мой опыт всего на шаг впереди вашего.
Конечно, мы не создадим следующую Windows или Linux, но это и не цель. Гораздо ценнее то, что мы своими руками разберёмся в основах, поймём, как устроены операционные системы изнутри, и получим уникальный опыт работы с настоящим железом. А там, кто знает, куда нас заведёт этот интерес...
Ссылка на проект: https://github.com/JordanCpp/SimpleOS
Буду рад, советам, критике и предложениям. Расскажите о своем опыте.
Обновил информацию в части использования стандартной библиотеки С++.
Стандартной библиотеке С++ и STL быть. Для раннего этапа буду использовать контейнеры, без динамической памяти, к примеру std::array. Когда будет готова работа с динамической памятью в ядре, я глобально для ядра переопределю, new и delete. И в самом ядре, можно будет использовать STL без ограничений, std::vector, std::unordered_map и т.д