Приветствую, Хабравчане!
В текущей статье, реализуем поддержку памяти и аллокатора в ядре, а так же перегрузим new и delete используя новый механизм памяти. На последок напишем контейнер OS::String для работы со строками, интерфейс API будет совпадать с интерфейсом std::string. Что бы в будущем просто сделать using на std контейнеры. Когда получится их завести.
Теперь пришло время перейти к управлению памятью. В мире операционных систем память - это всё. Если вы не контролируете её, вы не контролируете ничего. Наша задача: создать инфраструктуру, которая позволит нам использовать new, delete, std::string OS::String прямо в ядре, где нет привычных нам malloc или free.
Кстати, вдохновением для архитектуры SimpleOS (в частности, для идеи многоуровневого HAL) послужила знаменитая архитектура Windows NT. NT мастерски разделяет аппаратно-зависимый код от аппаратно-независимого, что позволяет легко портировать систему на разные платформы. Я стремлюсь к той же элегантности.
1. Сначала конфиг.
Прежде чем писать код, давайте определимся с параметрами нашей виртуальной вселенной. Мы фокусируемся на платформе x86 (32-битная архитектура), но благодаря HAL мы можем легко переключиться на другую, если потребуется. Нам нужно знать, сколько у нас памяти и где она начинается. Для этого у нас есть HalConfig.hpp
namespace HAL
{
// Адрес, с которого ядро начинает управлять памятью
constexpr size_t StartAddress = 0x1700000;
// Размер страницы памяти (стандартные 4 КБ)
constexpr size_t MapPage = 4096;
// Сколько всего памяти мы "видим" (например, 4 МБ в QEMU)
constexpr size_t TotalMbs = 4;
// Итого: общее количество байт и страниц
constexpr size_t TotalBytes = (TotalMbs * 1024 * 1024);
constexpr size_t TotalBits = TotalBytes / MapPage;
}
Почему сделано именно так, объясню чуть позже.
2. PMM - Генератор Эдемских Кущ Компактный.
Физическая память - это просто большой линейный массив байтов, пронумерованных адресами от нуля и выше. Ядро ОС должно решать, какие куски этой памяти свободны, а какие уже заняты программами. Чтобы эффективно управлять памятью, ОС делит ее на страницы фиксированного размера. На x86 стандартный размер страницы - 4 КБ. Мы используем этот подход, так как это эффективно и поддерживается аппаратным обеспечением (блоком MMU - Memory Management Unit). Нам нужен менеджер, который будет знать, какие страницы заняты, а какие свободны. Это Physical Memory Manager (PMM). Мы используем std::bitset для отслеживания состояния: 1 бит = 1 страница. Это эффективно, потому что позволяет отслеживать 4 МБ памяти, используя всего 128 байт данных. Да нашему ядро доступно всего 640 кб 4 мб. Вы можете поставить любое другое значение, при текущем функционале ядра, не вижу смысла выделять больше.
#include <SimpleOS/Pmm.hpp>
using namespace HAL;
Pmm::Pmm(uintptr_t address) :
_address(address)
{
_map.reset();
}
Pmm::~Pmm()
{
}
void* Pmm::Allocate()
{
size_t index = Find();
if (index == SIZE_MAX)
{
return nullptr;
}
_map.set(index);
uintptr_t address = _address + (index * MapPage);
return reinterpret_cast<void*>(address);
}
void Pmm::Deallocate(void* ptr)
{
if (ptr)
{
uintptr_t address = reinterpret_cast<uintptr_t>(ptr);
size_t index = (address - _address) / MapPage;
if (index < TotalBits)
{
_map.reset(index);
}
}
}
size_t Pmm::Find()
{
for (size_t i = 0; i < _map.size(); i++)
{
if (!_map.test(i))
{
return i;
}
}
return SIZE_MAX;
}
Этот код - простой менеджер физической памяти, который отслеживает, какие блоки оперативной памяти свободны, а какие заняты.
При запуске создается карта _map, которая хранит состояние каждого блока (бит true - занят, false - свободен). Когда программе нужна память, функция Allocate() ищет первый свободный блок, помечает его как занятый и возвращает его адрес. Когда память больше не нужна, функция Deallocate() помечает соответствующий блок как свободный, чтобы его мог использовать кто-то другой.
А вот теперь пояснение почему я завел HalConfig.hpp
Дело в том, что я хочу использовать побольше стандартных вещей из С++. К примеру без изменений из коробки доступен std::array, алгоритмы.
namespace HAL
{
class Pmm : public IPmm
{
public:
Pmm(uintptr_t address);
~Pmm();
void* Allocate();
void Deallocate(void* address);
private:
size_t Find();
uintptr_t _address;
std::bitset<TotalBits> _map;
};
}
В основе _map лежит std::bitset, удобный контейнер по работе с битами, на ст��ке размещает столько сколько тебе нужно, это плюс, вбил размер и пользуешься. Но обычно все устроено сложнее, загрузчик передает эти данные, и заводится массив байтов нужного размера + интерпретируется и работает как массив бит. Так же размещается в самой памяти, сначала этот массив бит, потом идет доступная память. Естественно, стандартный контейнер невозможно впихнуть именно в нужный мне адрес. Поэтому я для нашего с вами удобства и простоты, передвинул адрес на 22 мегабайт, а адрес начала нами используемой памяти сдвинул еще на 23 мегабайт.
В итоге, ядро грузится с 22, озу идет с 23. Qemu по умолчанию запускается со 128 мб озу. Нам хватит. Но конечно в будущих статьях, нужно будет его заменить на свою реализацию.
3. Bump Allocator для простоты концепции
BumpAllocator::BumpAllocator(HAL::IPmm* pmm) :
_pmm(pmm),
_start(0),
_end(0),
_current(0)
{
_start = HAL::BaseAddress();
_end = _start + HAL::TotalBits * HAL::MapPage;
_current = _start;
}
BumpAllocator::~BumpAllocator()
{
}
void* BumpAllocator::Allocate(size_t size)
{
if (size % 8 != 0)
{
size = (size / 8 + 1) * 8;
}
if (_current + size > _end)
{
return nullptr;
}
void* ptr = (void*)_current;
_current += size;
return ptr;
}
void BumpAllocator::Deallocate(void* address)
{
if (address)
{
}
}
Этот код реализует простой менеджер памяти, называемый "bump-аллокатором". Принцип его работы заключается в последовательной выдаче блоков памяти из большого заранее выделенного куска.
Аллокатор просто перемещает внутренний указатель вперед каждый раз, когда запрашивается новый блок памяти, гарантируя при этом, что размер блока выровнен. Особенностью этого подхода является то, что освобождение памяти невозможно. Метод Deallocate просто заглушка. Выделяем пока не дойдем до лимита в 4 мб, потом начинаются глюки на qemu, так как никакого механизма на этот случай нет.
Выравнивание размера блока по 8 байт в BumpAllocator::Allocate нужно для повышения производительности (процессоры эффективнее работают с выровненными данными), обеспечения совместимости с некоторыми инструкциями (например, SSE) и предотвращения ошибок на архитектурах, требующих строгого выравнивания. В перспективе можно сделать выравнивание настраиваемым через шаблонный параметр.
Да данный аллокатор уж очень простой, но для второй статьи и демонстрации подходит. Идем шагами короткими, но быстро:)
extern HAL::IAllocator* MainAllocator;
void* operator new(size_t size)
{
return MainAllocator->Allocate(size);
}
void* operator new[](size_t size)
{
return MainAllocator->Allocate(size);
}
void operator delete(void* ptr) noexcept
{
return MainAllocator->Deallocate(ptr);
}
void operator delete[](void* ptr) noexcept
{
return MainAllocator->Deallocate(ptr);
}
void operator delete(void* ptr, size_t size) noexcept
{
(void)ptr;
(void)size;
}
void operator delete[](void* ptr, size_t size) noexcept
{
(void)ptr;
(void)size;
}
Этот код заменяет стандартные операции C++ по выделению и освобождению памяти на собственный внешний менеджер. Это позволяет получить полный контроль над тем, как и когда выделяется и освобождается память. Данные перегрузки позволяют написать STL подобные контейнеры для использования в ядре. На данном этапе, не получается использовать STL контейнеры из коробки, так как при отсутствии исключений и заглушек для них, компилятор ругается. Заглушки тоже не особо помогают. Нужно разбираться. Я обязательно втяну STL, но в следующих статьях.
Идея в том, что бы написать свои контейнеры с интерфейсом из STL, к примеру OS::String, методы калька из std::string и аналогично для vector, unordered_map и т.д
Когда получится использовать STL контейнеры из коробки, просто сделать using на std и не нужно менять код. Вполне, прагматичный подход. Код OS::String написан в спешке на основе других моих проектов, он будет полностью переписан, добавлена move семантика, улучшен. Но мне очень не терпелось закончить статью и поделиться прогрессом.
4.Что там с ядром то?
Сейчас оно выглядит так:
using namespace HAL;
HAL::IAllocator* MainAllocator = nullptr;
Kernel::Kernel() :
_pmm(nullptr),
_allocator(nullptr),
_console(nullptr)
{
_pmm = new (_pmmBuffer) Pmm(BaseAddress());
_allocator = new (_allocatorBuffer) BumpAllocator(_pmm);
MainAllocator = _allocator;
_console = std::unique_ptr<HAL::IConsole, NoDelete>(new Console());
}
Kernel::~Kernel()
{
if (_allocator)
{
_allocator->~IAllocator();
}
if (_pmm)
{
_pmm->~IPmm();
}
}
void Kernel::Run()
{
OS::String str1 = "Running ";
str1 += "SimpleOS!";
while (true)
{
_console->Write(str1.c_str());
_console->Write('\n');
}
}
Я завел глобальную переменную MainAllocator для удобства, потом подумаю, как это облагородить, выбора особого нет, переделать в singleton. Но пока оставим.
Как теперь инициализируется ядро, сначала запускается PMM, после чего BumpAllocator и уже аллокатор присваивается нашей глобальной переменной. Все теперь память есть, можно перегружать new и delete и их вариации.
Для примера я вывожу строку, но не просто char buffer[256], а STL совместимую строку, которая знает размер, умеет конкатенироваться и т.д Доведя до ума, добавив итераторы, можем использовать std алгоритмы и это для всех контейнеров которые мы реализуем. Ну как вам такое? Можно сказать прикладной код в ядре.
Конечно для такого функционала, нужен будет надежный и быстрый аллокатор, более разнообразный ассортимент контейнеров. Но первый шаг уже сделан.
Выводим строку. Считайте OS::String = std::string
void Kernel::Run()
{
OS::String str1 = "Running ";
str1 += "SimpleOS!";
while (true)
{
_console->Write(str1.c_str());
_console->Write('\n');
}
}
Ещё в строке 15 я использую std::unique_ptr для создания консоли, с кастомным делитером. Делитер пуст. Пока это только концепция, но после нужных исправлений и доработок, можно будет использовать в ядре умные указатели.
struct NoDelete
{
void operator()(void* ptr)
{
(void)ptr;
}
};
5.Опять эта твоя консоль в Windows
Да, в каждой статье я буду пропагандировать HAL, так как именно в зависимой части x86 я добавил только одну функцию. Код ядра подходит для сборки как на x86, так и для хост реализации.
constexpr size_t StartAddress = 0x1700000;
uintptr_t HAL::BaseAddress()
{
return StartAddress;
}
В BumpAllocator'е в конструкторе есть строчка _start = HAL::BaseAddress();
Для x86 я передаю адрес начала 23 мб, но для хост версии
uintptr_t HAL::BaseAddress()
{
uint8_t* ptr = (uint8_t*)malloc(HAL::TotalBytes);
return reinterpret_cast<uintptr_t>(ptr);
}
Я выделяю тоже количество памяти и так же перегружаю new и delete и использую на этой памяти тоже PMM, так как ядро собирается под хост и x86, без ifdef.
Запускаем в qemu

Запускаем под Windows

Код так же работает и выводит информацию, используя весь общий функционал ядра.
Почему я так делаю. Практичность, тестирование и прототипирование.
Это позволяет отлаживать код логики, писать тесты на хост системе и без изменений собирать для железа. Не нужно запускать каждый раз qemu, просто собрал программу и можешь дебажить, привычным способом.
Это банально проще. В следующих статья я заменю наш простой аллокатор, на нормальный. Который будет учитывать размеры, им можно будет выделить произвольный размер, он будет уметь реально освобождать память и уметь ее переиспользовать. Естественно я напишу тесты, но когда будет ошибка я смогу запустить отладку в привычной windows и посмотреть, что сломалось. Огромное преимущество.
Ссылка на код: Step_02_PhysicalMemory
Хочу выразить благодарность ТГ каналу https://t.me/ProCxx
За советы и обсуждение моих статей, а так же что навели на мысль использовать unique_ptr с кастомным делитером.
Буду рад, советам, критике и предложениям. Рад буду общению в комментариях.
P.S. В следующей статье, я пока не решил или мы добавим базовую графику или все таким добавлю работу с прерываниями. И через урок добьем память, добавим поддержку виртуальной памяти, нормальный аллокатор.
Я понимаю, возможно не хватает динамики в статье, пока нет ассемблера, мы ещё не используем multi boot. Но я это делаю специально и поступательно. Я так же как и вы это изучаю шаг, за шагом. И стараюсь это все систематизировать. Да, получаются не такие большие статьи, но зато они влазят в голову и она не кружится от огромного количества информации, пока дочитал до конца забыл что было в начале. Это важно.
А с другой стороны, сделано не так мало, мы выводим в консоль STL подобную строку, которая дергает перегруженные new и delete и опирается на аллокатор. Вполне достаточно для второй статьи.
Предлагаю вам поиграться с кодом. Хост версию можете собрать через cmake, работает Windows, Linux.