Приветствую, Хабравчане!

В этой серии статей мы будем писать монолитное ядро на C++17 с упором на чистую архитектуру, тестируемый код и минимальное использование ассемблера. Меня всегда привлекала разработка операционных систем, но на пути к ней часто стояли барьеры: тонны ассемблерных вставок, макросы и низкоуровневые хаки. За последние 10 лет набравшись опыта в C++, я решил вернуться к этой теме с новым подходом — максимально использовать современные возможности языка для создания понятного, поддерживаемого кода ядра.

В этой части мы создадим Hardware Abstraction Layer (HAL) для консольного вывода, реализуем две версии ядра (для отладки на хосте и для "голого железа") и успешно запустим наше ядро в QEMU.

Почему C++17 и что мы будем использовать

Мы сознательно ограничим некоторые возможности языка для работы в среде ядра:

  • Без исключений — очень дорого и требует проф ядра.

  • Без RTTI — просто дорого.

  • Без стандартной библиотеки — всё пишем сами, велосипедостроение на кончиках пальцев.

  • Своя реализация new/delete — на первых порах это будут заглушки, позже реализуем кучу

Но при этом активно используем:

  • Классы и наследование для абстракций

  • Виртуальные методы для полиморфизма

  • Шаблоны (в будущих частях)

  • constexpr и noexcept

  • Placement 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 — это слой между физическим железом и ядром ОС. Его цель — скрыть аппаратные особенности за унифицированными интерфейсами. Это даёт нам несколько преимуществ:

  1. Переносимость — ядро не зависит от конкретного железа

  2. Тестируемость — можно создавать mock-реализации для отладки

  3. Чистая архитектура — разделение ответственности

Начнём с самого простого — абстракции для консольного вывода.

То есть мы должны создать универсальный слой, к которому будет обращаться наше ядро, оно не знает подробности о шинах и портах, а просто вызывает методы абстрактных классов.

Я предлагаю начать с разработки абстрактного 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

И брюки превращаются...

Архитектурные решения и почему они важны

  1. Использование placement new — мы не можем использовать обычный new до инициализации кучи, поэтому размещаем объекты в предварительно выделенной памяти.

  2. Виртуальные методы в HAL — позволяют легко подменять реализации. Хотите выводить в UART вместо VGA? Просто создайте новый класс.

  3. Отдельные точки входа — main() для хоста и KernelMain() для bare-metal позволяют иметь одну кодовую базу для двух сред.

  4. Заглушки для new/delete — безопасная обработка ситуаций, когда куча ещё не готова.

Начало положено, наш код выполняется на голом железе.

Задавайте вопросы в комментариях! Я сам только начинаю погружаться в разработку ОС, поэтому мой опыт всего на шаг впереди вашего.

Конечно, мы не создадим следующую Windows или Linux, но это и не цель. Гораздо ценнее то, что мы своими руками разберёмся в основах, поймём, как устроены операционные системы изнутри, и получим уникальный опыт работы с настоящим железом. А там, кто знает, куда нас заведёт этот интерес...

Ссылка на проект: https://github.com/JordanCpp/SimpleOS

Буду рад, советам, критике и предложениям. Расскажите о своем опыте.