优雅的操作系统开发:用现代 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 ------最初这些只是占位符,之后我们会实现很多。

但与此同时,我们也积极使用:

  • 类和继承用于抽象

  • 多态性的虚拟方法

  • 模板(将在后续章节中介绍)

  • constexprnoexcept

  • 用于处理原始内存的新位置

    安装工具

    首先,我们需要一个交叉编译器和一个模拟器。我使用的是 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) 位于物理硬件和操作系统内核之间。它的目的是将硬件细节隐藏在统一的接口之后。这带来了以下几个优点:

  1. 可移植性 ------核心功能不依赖于特定的硬件。

  2. 可测试性 ------您可以创建模拟实现以进行调试。

  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"`);
	}
}`

我们将从主控端调用它。

复制代码
#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`;
}`

这是一个内存管理运算符的临时存根。由于我们的操作系统目前还没有功能齐全的内存管理器,我们覆盖了默认的内存管理运算符 newdelete如果代码意外地尝试使用 `<include>` 运算符分配内存 new,系统将安全停止------这比出现不可预测的错误要好得多。

现在到了最有趣的部分------构建和运行。说实话,我一开始尝试用 CMake 来构建,但在折腾了几个小时的交叉编译和链接脚本之后,我意识到,对于最初的几个步骤来说,传统的 Makefile 会更简单直观。有时候,简单的工具反而更适合特定的系统编程任务。

构建解决方案及其重要性

  1. 使用 placement new new - 在堆初始化之前, 我们不能使用常规放置 ,所以我们将对象放置在预分配的内存中。

  2. HAL 中的虚方法 便于实现方式的切换。想要输出到 UART 而不是 VGA?只需创建一个新类即可。

  3. main() 主机和 KernelMain() 裸机的 独立 入口点允许您使用同一套代码库来管理这两个环境。

  4. new/delete 存根 - 安全处理堆尚未准备就绪的情况。

已经启动,我们的代码正在裸机上运行。

相关推荐
HLC++9 小时前
Linux的进程间通信
android·linux·服务器
华清远见IT开放实验室10 小时前
实验室建设案例 | 石家庄科技信息职业学院嵌入式实验室——从底层硬件到系统应用,一所应用型高校的嵌入式人才培养这样落地
linux·arm开发·stm32·嵌入式硬件·高校·实验室建设
groundhappy13 小时前
idalib安装和codex ida-mcp配置
linux·开发语言·python
通信小小昕14 小时前
Ubuntu 26.04 中文输入法安装
linux·运维·ubuntu
张小姐的猫15 小时前
【Linux】网络编程 —— HTTP协议(上)
linux·运维·服务器·网络·http·单例模式·策略模式
栩栩云生15 小时前
AI 写代码犯的错,早被写进了错题集
linux·安全·ai编程
imc.1116 小时前
linux基础IO
linux·运维·服务器
BelongPanda18 小时前
Linux Nginx 纯手动 Let‘s Encrypt 泛域名证书配置教程
linux·nginx
酷可达拉斯18 小时前
Linux操作系统-shell编程(0)
linux·运维·服务器·python·云计算
2301_7779983419 小时前
Linux中断机制:操作系统如何高效运行
linux·运维·服务器