大家好!我是大聪明-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 <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"`);
}
}`
我们将从主控端调用它。
#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`;
}
`
这是一个适用于 80x25 VGA 模式的文本控制台驱动程序的实现。该代码将字符直接输出到计算机的视频内存,视频内存位于固定地址 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 类型设置默认值,并将所有可能出错的复杂逻辑移到单独的方法中,例如 `init` 或 `init` 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如果代码意外地尝试使用 `<include>` 运算符分配内存 new,系统将安全停止------这比出现不可预测的错误要好得多。
现在到了最有趣的部分------构建和运行。说实话,我一开始尝试用 CMake 来构建,但在折腾了几个小时的交叉编译和链接脚本之后,我意识到,对于最初的几个步骤来说,传统的 Makefile 会更简单直观。有时候,简单的工具反而更适合特定的系统编程任务。
构建解决方案及其重要性
-
使用 placement new
new- 在堆初始化之前, 我们不能使用常规放置 ,所以我们将对象放置在预分配的内存中。 -
HAL 中的虚方法 便于实现方式的切换。想要输出到 UART 而不是 VGA?只需创建一个新类即可。
-
main()主机和KernelMain()裸机的 独立 入口点允许您使用同一套代码库来管理这两个环境。 -
new/delete 存根 - 安全处理堆尚未准备就绪的情况。
已经启动,我们的代码正在裸机上运行。