优雅的操作系统构建:用现代 C++ 编写操作系统内核(不使用宏)。第一部分——HAL 为王。
大家好!我是大聪明-PLUS!
在本系列文章中,我们将使用 C++17 编写一个单内核,重点关注清晰的架构、可测试的代码以及尽可能少地使用汇编语言。我一直对操作系统开发很感兴趣,但这条路常常被大量的汇编语言、宏和底层技巧所阻碍。过去十年积累了丰富的 C++ 经验后,我决定以一种全新的方式重返这个领域——最大限度地利用现代语言特性来创建易于理解和维护的内核代码。
在本节中,我们将创建一个用于控制台输出的硬件抽象层 (HAL),实现两个内核版本(用于主机调试和裸机),并在 QEMU 中成功运行我们的内核。
为什么选择 C++17?我们将使用哪些组件?
我们将有意限制某些语言特性在内核环境中的使用:
毫无例外 ,它非常昂贵,而且需要专业的核心团队。
如果没有 RTTI, 那就只是成本高昂而已。
第一阶段标准库有限 -
C++ 标准库和 STL 将会到位。在早期阶段,我将使用不带动态内存的容器,例如 std::array。一旦内核支持动态内存,我将重新定义内核全局的 new 和 delete 方法。同时,在内核内部,STL 将可以无限制地使用,包括 std::vector、std::unordered_map 等等。
最少需要拐杖和自行车。
我们自己的实现
new/delete——最初这些只是占位符,之后我们会实现很多。
但与此同时,我们也积极使用:
类和继承用于抽象
多态性的虚拟方法
模板(将在后续章节中介绍)
constexpr和noexcept用于处理原始内存的新位置
安装工具
首先,我们需要一个交叉编译器和一个模拟器。我使用的是 WSL/Ubuntu,但任何 Linux 发行版都可以。
我个人使用WSL。
sudo apt update && sudo apt upgrade -y
sudo apt install build-essential make git nano -y
sudo apt install qemu-system-x86 -y
sudo apt install gcc-i686-linux-gnu g++-i686-linux-gnu binutils-i686-linux-gnu -y
完成这些终端步骤后,所有组件都将安装完毕。接下来我们开始开发。
我建议采取循序渐进的方式进行操作系统开发。第一步很简单,只需打印字符串“Running SimpleOS”,这是我给项目起的名字。
概念:硬件抽象层(HAL)
硬件抽象层 (HAL) 位于物理硬件和操作系统内核之间。它的目的是将硬件细节隐藏在统一的接口之后。这带来了以下几个优点:
可移植性 ——核心功能不依赖于特定的硬件。
可测试性 ——您可以创建模拟实现以进行调试。
清洁架构 ——关注点分离
让我们从最简单的事情开始——控制台输出的抽象。
也就是说,我们必须创建一个通用层,供内核访问;它不需要了解总线和端口的细节,只需调用抽象类的方法即可。
我建议从开发抽象的 HAL 开始,为了演示这个概念,我们将向常规 Windows 控制台、Linux 和裸机 x86 输出控制台输出。
我们开始吧。
#pragma once
#include
#include
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
#include
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
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");
}
}
我们将从主控端调用它。
#include
int main()
{
Kernel kernel;
kernel.Run();
return 0;
}

我猜你一定很想知道操作系统在哪儿?控制台输出……到底发生了什么?
这就是 HAL 的优势所在:我们可以用一个接口编写抽象类,然后通过模拟来简化调试或逻辑测试。我们会把实现提供给朋友。等到涉及到图形部分时,我们只需添加 SDL,在那里调试所有的图形输出和绘制逻辑,然后再实现硬件部分。是不是很棒?
对于我们的内核,我们创建一个单独的文件 X86Main.cpp
#include
extern "C" void KernelMain()
{
Kernel kernel;
kernel.Run();
}
代码完全相同,只是启动文件不同。KernelMain 调用加载器。
以下是我们对硬件控制台的实现。
#include
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;
}
这是一个适用于 80x25 VGA 模式的文本控制台驱动程序的实现。该代码将字符直接输出到计算机的视频内存,视频内存位于固定地址 0xB8000。
构造函数将初始光标位置设置为屏幕左上角(坐标 0,0)。该方法 Clear() 用空格填充屏幕的全部 80 列和 25 行,从而产生“空白屏幕”效果。
主要方法 Write() 处理字符打印:常规字符打印在当前光标位置,当收到换行符(\n)时,光标移动到下一行。
我们的代码直接运行在裸机上,并将字符打印到屏幕上。神奇吗?不,它是一种抽象。
内核头文件如下所示:
#pragma once
#include
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 类型设置默认值,并将所有可能出错的复杂逻辑移到单独的方法中,例如 `__init__` 或 `__init__` Initialize() 。 Setup()这样,我们就可以显式地检查初始化是否成功,并在继续内核操作之前正确地处理失败的情况。
Kernel::~Kernel()
{
if (_console)
{
_console->~IConsole();
}
}
在内核析构函数中,我们调用控制台的析构函数。它的析构函数目前为空。
void Kernel::Run()
{
while (true)
{
_console->Write("Running SimpleOS\n");
}
}
这是我们的主内核循环,它会无限循环地输出一个字符串。
当然,也有一些例外情况:
#pragma once
#include
#include
#include
[[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如果代码意外地尝试使用 `<include>` 运算符分配内存 new,系统将安全停止——这比出现不可预测的错误要好得多。
现在到了最有趣的部分——构建和运行。说实话,我一开始尝试用 CMake 来构建,但在折腾了几个小时的交叉编译和链接脚本之后,我意识到,对于最初的几个步骤来说,传统的 Makefile 会更简单直观。有时候,简单的工具反而更适合特定的系统编程任务。
构建解决方案及其重要性
使用 placement new
new- 在堆初始化之前, 我们不能使用常规放置 ,所以我们将对象放置在预分配的内存中。HAL 中的虚方法 便于实现方式的切换。想要输出到 UART 而不是 VGA?只需创建一个新类即可。
main()主机和KernelMain()裸机的 独立 入口点允许您使用同一套代码库来管理这两个环境。new/delete 存根 - 安全处理堆尚未准备就绪的情况。
已经启动,我们的代码正在裸机上运行。
浙公网安备 33010602011771号