Приветствую, Хабравчане!
Это третья часть цикла, где мы строим операционную систему, используя исключительно современный C++. В прошлых частях мы создали основу HAL и научились управлять физической памятью. Наше ядро уже умеет аллоцировать память, но оно пока что абсолютно не интерактивной. Оно не знает, про нажатие клавиши на клавиатуре, или что тикает системный таймер. Сегодня исправим и добавим в ядро поддержку прерываний. Очень показательно, что даже такая сложная тема, как прерывания x86, может быть обернута в чистые C++ классы. Цель ни одного макроса, только C++.
Ближе к телу :-)
Если совсем просто: прерывание - это способ аппаратуры привлечь внимание процессора. Когда происходит важное событие (нажата клавиша, сработал таймер, пришли данные по сети), устройство посылает специальный сигнал. Процессор бросает текущую работу, сохраняет контекст и переходит к обработчику прерывания. После обработки, возвращается к тому, что делал раннее.
Наш подход VS классический OSDev
Возможно, кто-то из читателей уже пробовал писать ОС по старым туториалам, которые легко найти в интернете. И вы, скорее всего, заметили разительный контраст с тем, что мы делаем здесь. Когда я систематизирую информацию из старых источников, я вижу не стройную архитектуру, а комок кода и макросов: Засилье #define : Все константы, адреса портов, даже вставки ассемблера всё спрятано за макросами. Это удобно писать, но невозможно читать и отлаживать. Макросы не знают о типах данных. Глобальный хаос: Логика инициализации PIC, GDT, IDT обычно свалена в одну гигантскую функцию kmain() . Нет классов, нет инкапсуляции. Мы же используем мощность языка C++ для создания абстракций, которые скрывают низкоуровневую сложность. Мы пишем код ядра так, как будто пишем прикладной код. Это и есть Элегантный OSDev на практике.
HAL Портов Ввода-Вывода (I/O Ports)
Для общения с большинством старых устройств на x86 мы используем I/O порты через инструкции in (чтение) и out (запись). В новых устройствах, обычно само устройство отображает себя в ОЗУ, пишем по этому адресу, а устройство само читает, записывает. Это частности, пока до таких устройств мы не дошли.
Для этого нужно реализовать работу с портами:
Первая реализация была такая
namespace Port
{
template <typename T>
inline void Write(uint16_t port, T data)
{
static_assert(std::is_same_v<T, uint8_t> ||
std::is_same_v<T, uint16_t> ||
std::is_same_v<T, uint32_t>,
"Unsupported data type. Use uint8_t, uint16_t, uint32_t.");
if constexpr (std::is_same_v<T, uint8_t>)
{
asm volatile("outb %0, %1" : : "a"(data), "dN"(port));
}
else if constexpr (std::is_same_v<T, uint16_t>)
{
asm volatile("outw %0, %1" : : "a"(data), "dN"(port));
}
else if constexpr (std::is_same_v<T, uint32_t>)
{
asm volatile("outl %0, %1" : : "a"(data), "dN"(port));
}
}
template <typename T>
inline T Read(uint16_t port)
{
static_assert(std::is_same_v<T, uint8_t> ||
std::is_same_v<T, uint16_t> ||
std::is_same_v<T, uint32_t>,
"Unsupported data type. Use uint8_t, uint16_t, uint32_t.");
T result;
if constexpr (std::is_same_v<T, uint8_t>)
{
asm volatile("inb %1, %0" : "=a"(result) : "dN"(port));
}
else if constexpr (std::is_same_v<T, uint16_t>)
{
asm volatile("inw %1, %0" : "=a"(result) : "dN"(port));
}
else if constexpr (std::is_same_v<T, uint32_t>)
{
asm volatile("inl %1, %0" : "=a"(result) : "dN"(port));
}
return result;
}
inline void Wait()
{
Read<uint16_t>(0x80);
}
}
Высокоуровневый код, типобезопасный и сначала реализация обработки прерываний была именно такой, но потом я заменил реализацию на абстрактный класс, что бы была возможность написания юнит тестов.
Интерфейс даёт возможность тестировать. Мы можем создать мок-объект для юнит-тестов, который не трогает реальное железо, а только записывает, что мы вызвали.
Я понимаю, что вызов виртуального метода накладен, для его избежания я использую возможности языка, к примеру все классы который наследуются от абстрактных, я помечаю словом final, это дает подсказку, что больше наследников нет и данный класс можно девиртуализировать. Уж слишком они удобны в использовании, лучше напрячь компилятор, доп флагами или улучшить код, чем вообще лишаться такой возможности. Всё это позволяет снизить количество запусков qemu.
Первый делом добавляем класс IPort
namespace HAL
{
class IPort
{
public:
virtual ~IPort() = default;
virtual void Write(uint16_t port, uint8_t data) = 0;
virtual void Write(uint16_t port, uint16_t data) = 0;
virtual void Write(uint16_t port, uint32_t data) = 0;
virtual uint8_t Read8(uint16_t port) = 0;
virtual uint16_t Read16(uint16_t port) = 0;
virtual uint32_t Read32(uint16_t port) = 0;
virtual void Wait() = 0;
};
}
Конкретная реализация Port для x86.
namespace HAL
{
class Port final: public HAL::IPort
{
public:
void Write(uint16_t port, uint8_t data);
void Write(uint16_t port, uint16_t data);
void Write(uint16_t port, uint32_t data);
uint8_t Read8(uint16_t port);
uint16_t Read16(uint16_t port);
uint32_t Read32(uint16_t port);
void Wait();
};
}
После реализации можем потрогать железо.
Intel 8259A (Master и Slave)
Реализуем класс для работы с PIC. Так как я бэкенд разработчик, то естественно я люблю создавать имена классов, как Manager, Controller, Presenter:)
Контроллер прерываний (PIC) содержит два чипа master и slave. Их нужно правильно инициализировать, иначе они будут мешать друг другу и отправлять не те вектора.
PicManager::PicManager(HAL::IPort* port) :
_port(port)
{
}
void PicManager::Remap(uint8_t master, uint8_t slave) const
{
_port->Write(MASTER_PIC_COMMAND, ICW1_INIT);
_port->Wait();
_port->Write(SLAVE_PIC_COMMAND, ICW1_INIT);
_port->Wait();
_port->Write(MASTER_PIC_DATA, master);
_port->Wait();
_port->Write(SLAVE_PIC_DATA, slave);
_port->Wait();
_port->Write(MASTER_PIC_DATA, static_cast<uint8_t>(0x04));
_port->Wait();
_port->Write(SLAVE_PIC_DATA, static_cast<uint8_t>(0x02));
_port->Wait();
_port->Write(MASTER_PIC_DATA, ICW4_8086);
_port->Wait();
_port->Write(SLAVE_PIC_DATA, ICW4_8086);
_port->Wait();
_port->Write(MASTER_PIC_DATA, static_cast<uint8_t>(0xFF));
_port->Write(SLAVE_PIC_DATA, static_cast<uint8_t>(0xFF));
}
void PicManager::Eoi(uint8_t number) const
{
if (number >= 0x28)
{
_port->Write(SLAVE_PIC_COMMAND, static_cast<uint8_t>(0x20));
}
_port->Write(MASTER_PIC_COMMAND, static_cast<uint8_t>(0x20));
}
Отображаем эту аппаратную структуру на классы C++.
class InterruptRegister
{
public:
InterruptRegister();
uint16_t limit;
uint32_t base;
} __attribute__((packed));
class Interrupt
{
public:
enum
{
Timer = 0x20,
Keyboard = 0x21
};
Interrupt();
void Handler(void* handler);
private:
uint16_t _offsetLow;
uint16_t _selector;
uint8_t _zero;
uint8_t _typeAttr;
uint16_t _offsetHigh;
} __attribute__((packed));
attribute((packed)) гарантирует, что структуры в памяти будут иметь ровно тот размер и расположение.
IDT — это массив дескрипторов, которые говорят процессору: "если пришло прерывание номер X, выполни код по адресу Y". В классическом подходе это сырой массив структур, которые заполняются вручную.
Данный класс управляет всей таблицей IDT, использует std::array и загружает таблицу в процессор.
extern "C" void InterruptHandler(int number);
InterruptManager::InterruptManager()
{
_register.limit = sizeof(_table) - 1;
_register.base = reinterpret_cast<uint32_t>(&_table);
for (auto& i : _table)
{
i.Handler(reinterpret_cast<void*>(InterruptHandler));
}
}
void InterruptManager::Load()
{
asm volatile("lidt %0" : : "m"(_register));
}
void InterruptManager::Enable()
{
asm volatile("sti");
}
void InterruptManager::Set(uint8_t index, void* handler)
{
_table[index].Handler(handler);
}
Метод InterruptHandler - вызывается при наступ��ении прерывания, для этого нужно создать функции на ассемблере, которые нужно добавить в таблицу IDT
.macro ISR_NO_ERRCODE number
.global isr\number
isr\number:
pushal
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushl $\number
call InterruptHandler
popl %eax
popl %gs
popl %fs
popl %es
popl %ds
popal
iretl
.endm
Я привел не полный листинг асма, он в раза три больше. Но для реализации поддержки прерываний нам этого достаточно, остальной код это С++.
#include <SimpleOS/Context.hpp>
extern "C" void InterruptHandler(int number)
{
GetContext().GetPicManagerHandler()->Eoi(number);
switch (number)
{
case 0x21:
if (GetContext().GetKeyboardHandler())
{
GetContext().GetKeyboardHandler()->Handle();
}
break;
default:
break;
}
}
Идёт проверка, что наступило определенное прерывание, в конкретном случае от клавиатуры.
class Context
{
public:
Context();
HAL::IKeyboard* GetKeyboardHandler();
void SetKeyboardHandler(HAL::IKeyboard* keyboard);
PicManager* GetPicManagerHandler();
void SetPicHandler(PicManager* picManager);
HAL::IAllocator* GetAllocatorHandler();
void SetAllocatorHandler(HAL::IAllocator* allocator);
private:
PicManager* _picManager;
HAL::IKeyboard* _keyboard;
HAL::IAllocator* _allocator;
};
Context& GetContext();
Конечно не все идеально в нашем элегантном коде, я собрал все глобальные переменные в один класс. Он нужен, так как требуется соединить прерывания и клавиатуру, pic контроллер и его обработку. Потому, я сделал пока так, этот вариант работает, он прост.
Keyboard::Keyboard(HAL::IPort* port) :
_port(port),
_release(false),
_key(0)
{
}
void Keyboard::Handle()
{
_key = _port->Read8(static_cast<uint8_t>(0x60));
}
int Keyboard::ReadKey()
{
if (_key != 0)
{
int result = _key;
_key = 0;
return result;
}
return 0;
}
Это реализация клавиатуры, для x86. Работа с ps/2 клавиатурой.
Теперь, что бы скрыть детали x86. Создаем класс Platform. Класс полностью скрывает в себе все шероховатости. В нем, созданный PicManager устанавливаем в глобальный контекс, теперь прерывания о нем знают, и так же добавляем клавиатуру.
После чего обязательно нужно сделать ремап.
Platform::Platform() :
_keyboard(&_port),
_picManager(&_port)
{
GetContext().SetPicHandler(&_picManager);
_picManager.Remap(0x20, 0x28);
GetContext().SetKeyboardHandler(&_keyboard);
_interruptManager.Set(Interrupt::Timer, (void*)isr32);
_interruptManager.Set(Interrupt::Keyboard, (void*)isr33);
_interruptManager.Load();
_interruptManager.Enable();
//_picManager.Unmask(0);
_picManager.Unmask(1);
}
HAL::IKeyboard* Platform::GetKeyboard()
{
return &_keyboard;
}
В x86-архитектуре есть два контроллера прерываний (PIC) - главный (master) и подчинённый (slave). По умолчанию они настроены так, что прерывания приходят на номера 0-15. Проблема: Номера 0-7 уже используются процессором для исключений (деление на ноль, ошибки страниц и т.д.) Если мы оставим всё как есть, то: Прерывание от клавиатуры (номер 1) будет конфликтовать с исключением отладки (тоже номер 1) Мы не сможем отличить аппаратное прерывание от программного исключения Решение: Мы делаем remap переносим аппаратные прерывания в другую область.
Kernel::Kernel() :
_pmm(nullptr),
_allocator(nullptr),
_console(nullptr),
_keyboard(nullptr)
{
_pmm = new (_pmmBuffer) Pmm(BaseAddress());
_allocator = new (_allocatorBuffer) BumpAllocator(_pmm);
GetContext().SetAllocatorHandler(_allocator);
_console = std::unique_ptr<HAL::IConsole, NoDelete>(new Console());
_keyboard = _platform.GetKeyboard();
}
Kernel::~Kernel()
{
if (_allocator)
{
_allocator->~IAllocator();
}
if (_pmm)
{
_pmm->~IPmm();
}
}
void Kernel::Run()
{
OS::String str1 = "Running ";
str1 += "SimpleOS!";
_console->Write(str1.c_str());
_console->Write('\n');
while (true)
{
if (_keyboard->ReadKey() != 0)
{
_console->Write("Press key");
_console->Write('\n');
}
}
}
Ядро претерпевает минимальные изменения. Никакой специфичный код хоста или x86 не должен в него просочиться. Иначе вся наша элегантная концепция пойдет лесом.
Теперь консоль приобрела чуточку интерактивности, при нажатии любой клавиши выводится сообщение. Press key
Конечно же, я так же добавил функционал и для хостовой версии.
Keyboard::Keyboard()
{
}
void Keyboard::Handle()
{
}
int Keyboard::ReadKey()
{
int ch = 0;
#if defined(WIN32)
if (kbhit())
{
ch = getch();
}
#endif
return ch;
}
Теперь скрины
x86

Хост Windows

Расскажу о тестировании.
Мокаем для тестирования IPort
struct Value
{
enum
{
Read,
Write
};
Value(uint16_t port, uint32_t data, uint8_t operation) :
_operation(operation),
_port(port),
_data(data)
{
}
uint8_t _operation;
uint16_t _port;
uint32_t _data;
};
namespace HAL
{
class Port final : public HAL::IPort
{
public:
void Write(uint16_t port, uint8_t data)
{
Values.push_back(Value(port, data, Value::Write));
}
void Write(uint16_t port, uint16_t data)
{
Values.push_back(Value(port, data, Value::Write));
}
void Write(uint16_t port, uint32_t data)
{
Values.push_back(Value(port, data, Value::Write));
}
uint8_t Read8(uint16_t port)
{
return 0;
}
uint16_t Read16(uint16_t port)
{
return 0;
}
uint32_t Read32(uint16_t port)
{
return 0;
}
void Wait()
{
Values.push_back(Value(0x80, 0, Value::Read));
}
std::vector<Value> Values;
};
}
Сам тест. Происходит следующее.
Я вызываю метод picManager.Remap(0, 1) наш замоканый порт, записывает все данные во внутренний массив. Далее я создаю вектор TrueValues, который содержит правильную последовательность того, что должно реально отработать в устройстве PIC. И после того как метод отработал, сравниваю два вектора.
Пока тестовая система ещё не реализована нормально, assert использую для того, что бы показать общую концепцию. Следующий шаг, это написать свой мини тестовый фреймворк, из двух методов. Который бы давал больше информации, а не просто что две строки не совпадают. Не хочется зависеть от сложных и огромных фреймворков типа Google Test потому своя простая реализация будет предпочтительнее.
int main()
{
HAL::Port testPort;
PicManager picManager(&testPort);
picManager.Remap(0, 1);
std::vector<Value> TrueValues;
TrueValues.push_back(Value(PicManager::MASTER_PIC_COMMAND, PicManager::ICW1_INIT, Value::Write));
TrueValues.push_back(Value(0x80, 0, Value::Read));
TrueValues.push_back(Value(PicManager::SLAVE_PIC_COMMAND, PicManager::ICW1_INIT, Value::Write));
TrueValues.push_back(Value(0x80, 0, Value::Read));
TrueValues.push_back(Value(PicManager::MASTER_PIC_DATA, 0, Value::Write));
TrueValues.push_back(Value(0x80, 0, Value::Read));
TrueValues.push_back(Value(PicManager::SLAVE_PIC_DATA, 1, Value::Write));
TrueValues.push_back(Value(0x80, 0, Value::Read));
TrueValues.push_back(Value(PicManager::MASTER_PIC_DATA, 0x04, Value::Write));
TrueValues.push_back(Value(0x80, 0, Value::Read));
TrueValues.push_back(Value(PicManager::SLAVE_PIC_DATA, 0x02, Value::Write));
TrueValues.push_back(Value(0x80, 0, Value::Read));
TrueValues.push_back(Value(PicManager::MASTER_PIC_DATA, PicManager::ICW4_8086, Value::Write));
TrueValues.push_back(Value(0x80, 0, Value::Read));
TrueValues.push_back(Value(PicManager::SLAVE_PIC_DATA, PicManager::ICW4_8086, Value::Write));
TrueValues.push_back(Value(0x80, 0, Value::Read));
TrueValues.push_back(Value(PicManager::MASTER_PIC_DATA, 0xFF, Value::Write));
TrueValues.push_back(Value(PicManager::SLAVE_PIC_DATA, 0xFF, Value::Write));
for (size_t i = 0; i < TrueValues.size(); i++)
{
assert(TrueValues[i]._operation == testPort.Values[i]._operation);
assert(TrueValues[i]._port == testPort.Values[i]._port);
assert(TrueValues[i]._data == testPort.Values[i]._data);
}
return 0;
}
Пока я добавил только обработку одного прерывания клавиатуры. В следующих статьях, буду добавлять другие, к примеру мышь и таймер при работе с графикой, прерывания ошибок, при реализации поддержки виртуальной памяти.
Очень надеюсь, что вы как читатели видите, что не такой уж С++ и кусачий, сложный. Код прямолинейный и высокоуровневый. По крайней мере я пытаюсь его таким сделать. Я использую мощность языка C++ для создания абстракций, которые скрывают низкоуровневую сложность.
Спасибо за внимание, буду рад советам, критике и предложениям.
Ссылка на код: https://github.com/JordanCpp/SimpleOS/tree/Step_03_Interrupts
SilverTrouse
А когда на с++23 перейдете? Хотелось бы видеть код на модулях
JordanCpp Автор
Переход в планах. Уже переводил хост версию.
https://github.com/JordanCpp/SimpleOS/tree/Step_03_InterruptsAndModules