Приветствую, Хабравчане!
В этой серии статей мы будем писать монолитное ядро на C++17 с упором на чистую архитектуру, тестируемый код и минимальное использование ассемблера. Меня всегда привлекала разработка операционных систем, но на пути к ней часто стояли барьеры: тонны ассемблерных вставок, макросы и низкоуровневые хаки. За последние 10 лет набравшись опыта в C++, я решил вернуться к этой теме с новым подходом — максимально использовать современные возможности языка для создания понятного, поддерживаемого кода ядра.
В этой части мы создадим Hardware Abstraction Layer (HAL) для консольного вывода, реализуем две версии ядра (для отладки на хосте и для "голого железа") и успешно запустим наше ядро в QEMU.
Почему C++17 и что мы будем использовать
Мы сознательно ограничим некоторые возможности языка для работы в среде ядра:
Без исключений — очень дорого и требует проф ядра.
Без RTTI — просто дорого.
Без стандартной библиотеки — всё пишем сами, велосипедостроение на кончиках пальцев.
Своя реализация
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
Буду рад, советам, критике и предложениям. Расскажите о своем опыте.
