优雅的操作系统开发:用现代 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 存根 - 安全处理堆尚未准备就绪的情况。

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

相关推荐
qq_455760854 小时前
Docker - 镜像
linux·运维·docker
m0_534875054 小时前
Ditto局域网同步功能实现宿主机和VMware虚拟机之间的复制粘贴共享
linux·运维·服务器
RisunJan4 小时前
Linux命令-hdparm命令(获取和设置硬盘参数)
linux·运维·服务器
骄傲的心别枯萎4 小时前
RV1126 NO.58:ROCKX+RV1126人脸识别推流项目之读取人脸数据库并保存到map
linux·数据库·计算机视觉·音视频·rv1126
羑悻的小杀马特4 小时前
【Linux篇章】再续传输层协议TCP:用技术隐喻重构网络世界的底层逻辑,用算法演绎‘网络因果律’的终极推演(通俗理解TCP协议,这一篇就够了)!
linux·网络·后端·tcp/ip·tcp协议
博语小屋4 小时前
Socket 编程TCP:多线程远程命令执行
linux·网络·c++·网络协议·tcp/ip
列逍4 小时前
Linux 动静态库深度解析:原理、制作与实战
linux·运维·服务器·动态库·静态库
云和数据.ChenGuang4 小时前
欧拉(openEuler)和CentOS
linux·运维·centos
qq_589568104 小时前
centos打开文件之后怎么退出 ,使用linux命令
linux·运维·centos