【CODE.0x03】现代 64 位 OS 开发手记 I:Cmake构建、UEFI 启动、GRUB 引导、Frame buffer 文字输出
本文最后更新于:2024年6月19日 下午
遥遥领先!遥遥领先!遥遥领先!
0x00.一切开始之前
很久以前在知乎上曾经有过一个“程序员的三大浪漫”的讲法:「编译原理」、「操作系统」、「图形学」——当然这个似乎是某些知名答主杜撰的并没有任何的由头神必说法,笔者自己也不认为所谓的“程序员的浪漫”就仅是这几个东西,但对于笔者而言,“自己动手编写一个可以正常运行的操作系统”确乎是一件非常炫酷的事情
笔者此前在大二下学期的时候曾参照《操作系统真象还原》编写过一个非常简陋的只有打印功能的 32 位操作系统——严格来说笔者仅仅完成了一个高度客制化的 boot loader 加上一点点的 kernel(因为懒而不是时间不够,天天摆烂躺平打游戏😥),也没有在真机上测试过,完全算不上一个能用的操作系统;在大三寒假期间笔者花了将近一周的时间刷完了 MIT 6.828 的前四个 lab,但那严格意义上来说也并不是笔者从零开始写的一个内核——大框架基本都是 MIT 搭好的,笔者仅仅只用补足非常微小的一部分;在大三下学期笔者想与曾经喜欢过的妹子一起参加一个写操作系统内核的比赛,但是由于一些缘故笔者一直拖到了比赛结束都还没有开始动手写第一行代码,最后也就不了了之了;在大四上学期笔者又重新写了一个用 Grub2 引导的 64 位内核,但是写完内存分配之后也烂尾了 😅
仔细想来对于笔者来说似乎很少存在“万事开头难”的阶段,但往往一切事物都会滑向无底的大坑永久无法填上:)
因此趁着现在时间尚且充足,笔者想要真正地从零写一个可用的 64 位操作系统内核,算是满足自己多年来的一个梦想吧,名字的话笔者决定叫 ClosureOS
——这个名字的由来比较简单,笔者一直很难想出比较好听的名字,看了看市面上有各种操作系统都叫 Open*
,那笔者就叫 Close
好了,但是 Close
这个单词长得又不好看,于是笔者最终选择了 Closure
作为这个操作系统的名字——虽然似乎不是特别好听,但反正是否能写完都还是个未知数,所以也无所谓了(笑)
本项目代码开源在 https://github.com/arttnba3/ClosureOS
0x01. Boot Firmware
现代计算机的上电过程比较复杂,不过对于操作系统开发而言我们其实只需要关注当我们按下开机键之后所发生的事情,实际上无论是古老的 Legacy BIOS 启动还是逐渐成为主流的 UEFI 启动而言,其不外乎都遵循以下三个大阶段:
- ROM Stage:经历了一些基本的初始化工作后 CPU 被重置,主核心被唤醒,指令指针寄存器指向
reset vector
(固件入口点)并由此开始执行,此时尚未进行内存探测,需要直接在 ROM 上执行 - RAM Stage:内存探测完成,此时可以进行主板上各芯片组、CPU 等模块的初始化等工作
- Boot Stage:找到启动设备,完成启动设备前的依赖项的准备,将控制权移交给设备上的下一阶段的启动器
现有的固件通常分为两类:BIOS 与 UEFI
Legacy BIOS
基本输入输出系统(Basic Input/Output System,BIOS)是用来为计算机提供初始化服务与运行时服务的一组固件,其被预装在主板的 ROM/FLASH 芯片上,不同的 BIOS 通常仅能在特定的主板型号上运行
当计算机启动后 BIOS 为第一个被运行的软件,此时计算机处于实模式下,仅能访问 1MB 内存的空间,其中物理内存 0xF0000 ~ 0xFFFFF
这 64KB 空间被映射到 BIOS ROM 当中,并以 0xFFFF0
处作为 BIOS 程序的入口点开始执行
对于支持且开启了 BIOS shadowing 特性的计算机而言,BIOS 会被先从固件当中拷贝到内存中,而非直接在 ROM 空间上执行
BIOS 会从南桥的 CMOS 芯片中读取 BIOS 程序的设置值、硬件参数侦测值等信息,在完成加电自检、设备测试等工作之后,会从启动设备中读取第一个扇区到物理内存 0x7c00
的位置,该扇区被称为主引导记录(Master Boot Recode,MBR),随后 BIOS 会跳转到 0x7c00
处继续执行,控制权转交给 MBR
MBR 的结构如下图所示,其中不仅包含有 440 字节的第一阶段引导代码,同时还包含有磁盘的分区表信息,MBR 以末尾的两个字符 0x55, 0xaa
作为其标识:
这里我们可以看到 MBR 分区表仅支持不超过四个主分区,多余的分区则需要依赖操作系统在其上建立虚拟分区,此外 MBR 也不支持管理硬盘 2TB 以外的存储空间,因此这种分区方式 其实已经正在逐渐地被淘汰
由于 MBR 仅有 512 字节,无法完成过多的任务,因此通常的设计是由 MBR 从硬盘上读取第二阶段的 boot loader,由其来完成后续的系统环境初始化、载入操作系统内核等工作
Unified Extensible Firmware Interface
统一可扩展固件接口(Unified Extensible Firmware Interface,UEFI)为一套固件接口规范,用以初始化硬件、引导操作系统,并向操作系统提供一套统一的功能接口,解决不同厂牌 BIOS 分裂的现状,其最初起源于 Intel 开发的 EFI,在 2005 年由 Intel 交由 UEFI 论坛进行推广,UEFI 固件本质上与 BIOS 固件没有区别(都是封装在 ROM/FLASH 固件里的程序)
例如 BIOS 厂商 A 提供的某个功能接口的使用方式是 X,BIOS 厂商 B 提供的相似功能接口使用方式是 Y,那操作系统就得为不同厂商的不同功能编写多套代码
而有了 UEFI 规范,厂商 A、B 的 UEFI 固件都需要向上层提供统一的接口,从而使得操作系统可以用相同的方式调用某个功能,避免了代码分裂的情况
这里引用一张非常经典的图片简述 UEFI 启动的基本过程:
相应地,UEFI 启动不再使用老旧的 MBR 分区表,而是使用 GUID Partion Table (GPT 分区表),以 512 字节为单位作为一个逻辑块(Logic Block,相对应的区块地址便称为 LBA),前 34 个 LBA 用来记录分区信息,其中 LBA0 为了兼容性保留给 MBR 使用,LBA1 记录分区表自身的信息、备份用 GPT 分区(最后 34 个 LBA 的位置)、分区的 CRC 校验码等,LBA2 ~ LBA 33 则用来记录分区信息,每个 LBA 可以记录四个条目:
扩展阅读:兼容支持模块(Compatibility Support Module)
在 UEFI 逐渐替换掉计算机底层固件的风潮涌动之时,尚有大量的设备仍旧使用传统的 MBR 分区表,为了进行兼容,CSM 这一兼容支持模块会模拟传统 BIOS 的功能,为这些设备系统按照传统的 BIOS + MBR 方式进行引导,并提供传统 BIOS 的 0x10 等中断服务
Coreboot
Coreboot 起源于 LinuxBIOS,最初的思路是 既然 Linux 有比较好的硬件支持,那计算机启动以后直接跳 Linux就完事了 , 于是 使用 20 行汇编完成初始化并将 Flash 中的 Linux 拷贝至内存后直接跳过去 的 LinuxBIOS 诞生于 1999年,随后经过不断发展,引导 Linux 所用的程序越来越大,于是项目在 2008 年改名为 coreboot,项目结构变为 coreboot + payload
,Linux 则成为了 可选的一段 payload ,通过这样的模式,Coreboot 可以通过引入不同的 payload 来支持多种不同的启动规范,包括 Legacy BIOS 和 UEFI
目前市面上正在使用 Coreboot 的主流产品有 Google Chromebook 和 System76 旗下的笔记本等
0x02. 多重引导规范 & GNU GRUB
Why GRUB?
现今的大部分所谓“教你自行编写操作系统”的无论是教程也好书籍也好,都存在着一个小小的问题:对 Legacy BIOS 的内核引导阶段大书特书——诚然,了解一台计算机从启动开始到内核真正运作这段期间的实现细节无疑是十分重要的一件事情,对于操作系统学习而言或许也有不小的帮助,但很容易让初学者陷入到与各种硬件博弈的苦战当中,同时对于实际的开发而言手动编写一个仅适用于我们自己的内核的客制化 MBR + boot loader
意义并不算特别大
再说都什么时代了还在用 Legacy BIOS,Wintel 联盟都宣布这玩意已经彻底成为历史了,👴🚪就没有必要再深究了,大概了解一下差不多得了
而 UEFI 规范虽然给了我们更为方便地通过 UEFI 的各种接口实现不同的功能,但UEFI 的大部分功能在 Runtime 阶段是不可用的,同时这也少不了编写设备识别、文件系统解析等工作,再配上编写各种基础设施,一套写下来一个EFI 程序其实差不多就已经是一个完整的小内核了——当然, 直接用 EFI 程序作为操作系统内核不是不行,看着也确实像个样子 ,就是不太优雅,也不太现代
因此,对于内核引导阶段,我们暂时选择直接复用现有的成熟的方案——例如「GNU GRUB」,其同时支持 Legacy BIOS 与 UEFI 引导,让我们不用在一开始就陷入到与各种存储设备斗争的泥潭当中
当然,如果说仅从「学习」的角度而言自己亲手写一个
MBR + boot loader
/EFI
并亲身体会到其载入内核的整个过程其实是一件非常有益处的事情(笑)先挖个坑:我们将在操作系统内核开发完成之后的后续补充文章中自行开发一个 EFI 程序以引导符合 multiboot2 规范的内核
也有人会问:那为什么不用 Limine 或是 BOOTBOOT 这样更加现代的 boot loader 呢?一个原因就是因为 GRUB 相对有着更好的兼容性,能够在更多设备上运行,教程资料也比较多
Multiboot2 规范
那么我们如何让 GNU GRUB 知道他该怎么引导一个什么样的内核呢?答案是通过多重引导规范(Multiboot Specification),该规范制定的目的是使得遵循该规范的操作系统可以被同样遵循该规范的 boot loader 引导,而无需编写特定于 OS 的 boot loader
GNU GRUB 第二版进行了完全的重写,多重引导规范也有个第二版,不过好在 GRUB2 同时支持两版引导规范——这里我们使用第二版的规范
然而 Linux kernel 使用的并不是 multiboot 规范,而是其自定义的协议
多重引导规范要求我们的内核映像的前 32768
字节中一个任意的 64位对齐的位置 必须要有一个 multiboot2 header
来记录相应的信息,格式如下:
Offset | Type | Field Name | Note |
---|---|---|---|
0 | u32 | magic | required |
4 | u32 | architecture | required |
8 | u32 | header_length | required |
12 | u32 | checksum | required |
16-XX | tags | required |
magic
:multiboot2 header 的标识,必须为0xE85250D6
architecture
:标识指令集架构,0 表示 32 位 i386 保护模式,4 表示 32 位 MIPSheader_length
:包含 tags 在内的整个 multiboot2 header 的大小checksum
:该域与前三个域相加的和为无符号 0tags
:补充域,其格式通常如下,以类似数组的形式跟在后边,每个 tag 的起始地址8 字节对齐,整个 tag 数组以一个 type 为 0 及 size 为 8 的 tag 结尾,关于不同类型的 tag 格式,参见此处:1
2
3
4
5+-------------------+
u16 | type |
u16 | flags |
u32 | size |
+-------------------+
在 U 盘上安装 GRUB2
虽然很多操作系统编写教程都是在虚拟机当中运行的,毕竟 对于操作系统初学者而言更重要的是了解整个操作系统的运行机理 ,但是在物理机上运行自己写的操作系统是非常令人感到愉悦的一件事情,所以这里我们将会介绍如何在 U 盘上安装 GRUB 来引导自己的操作系统内核
如果你不想弄这一部分,也可以跳转到下一节,直接开始安全地使用 QEMU,我们后面的各种开发调试其实主要也是在 QEMU 上完成的 :)
如果你在物理机上使用 Linux 作业系统,请找到你的 U 盘对应的设备节点,通常情况下,如果你的计算机仅使用
nvme m.2
固态硬盘,则新插入的 U 盘 通常 是/dev/sda
,如果你则计算机仍在使用 SATA 接口的硬盘,请注意自行确定设备路径如果你在物理机上使用 Windows 操作系统,出于易用性考虑我们并不使用 WSL,而是在 Vmware 虚拟机中安装一个 Linux 操作系统,并通过如下方式将 U 盘连接到虚拟机中(请先确定好你的 U 盘对应的设备名称),在你的虚拟机处在默认配置且不存在外部存储设备的情况下,U 盘对应的设备节点通常是
/dev/sdb
:
也可以通过物理重新拔插可移动设备以让 Vmware 自行截获:
如果你是其他情况,请自行进行判断 :)
首先安装一些你可能会需要的依赖:
1 |
|
接下来我们对 U 盘进行分区,请确保你已经将所有重要数据完成备份,这里笔者选择使用 GParted
进行分区,我们首先通过 Device→Create Partion Table...
建立一个 GPT 分区表:
此时可能会提示无法重建分区表,这是因为操作系统可能偷偷帮你把分区挂载在
/run/你的用户名
,请使用umount
卸载所有活动分区,之后重新启动GParted
然后右键新建分区,这里笔者选择建立一个大小为 512MB 的 EFI 分区,注意该分区必须为 fat32 格式,剩余的空间作为一个文件系统分区, 我们将在后续开发文件系统时用到它 ,划分好后点绿色的✅然后 Apply
:
这里 GParted 会在 U 盘末尾留下 1MB 的空间,用来放 MBR 分区表,主要是出于兼容目的
接下来我们从源码编译 GRUB,首先从 GNU GRUB 的 FTP 服务器进行下载源码,然后在单独的文件夹中进行编译,这里我们三种 GRUB 都编译上:
1 |
|
注:如果你使用的是 Arch/Fedora/openSUSE 这样更新比较快的系统,在编译的时候可能会出错(
GCC 背大锅),那么这个时候可能就需要使用更新版本的源码,笔者物理机此前使用的是 Fedora Workstation 38,编译 2.06 时爆了莫名其妙的问题,所以后来笔者选择了 2.12 rc1 版本的 GRUB2
然后将 GRUB2 安装到 U 盘的 EFI 分区上:
1 |
|
这里直接用系统自带的
grub-install
也可以直接安装,但是笔者在 Fedora 系统上使用自带的grub2-install
时出现了这样一个错误:
1
grub2-install: error: this utility cannot be used for EFI platforms because it does not support UEFI Secure Boot.
网上也没有找到什么比较好的解决方案,笔者只好从源码进行编译
但是 Ubuntu/openSUSE 自带的
grub-install/grub2-install
就能正常使用,怎么回事呢
接下来我们新建一个文件 /mnt/boot/grub/grub.cfg
(假设你的 U 盘 EFI 分区和笔者一样挂载在 /mnt
下),其为 GRUB 的配置文件,编写内容如下:
1 |
|
卸载 U 盘,重新启动计算机,进入你的 BIOS/UEFI 配置界面,关闭安全启动(Secure Boot
),将 U 盘配置为第一个启动项(通常开头会有一个 UEFI:
的标识),重新启动计算机,接下来——
GNU GRUB,启动!!!
当然,现在我们还没有开始编写操作系统内核,所以想要直接启动会报错,不过后面我们编写的内核直接放到 U 盘 EFI 分区的boot/kernel.bin
这个位置就可以直接启动了:
创建包含 GRUB2 的启动镜像文件并使用 QEMU UEFI 启动
首先安装一些可能需要的依赖:
1 |
|
然后使用如下脚本在 QEMU 中从 U 盘启动 GRUB,注意替换成你自己的 U 盘设备节点路径:
1 |
|
各参数说明如下:
-bios
:指定使用的启动固件,这里是 OVMF,源自于 EDK2 的 UEFI 固件-cpu
:指定 CPU 类型及特性,kvm64
是一种常规的 CPU 类型,+smep
和+smap
表示开启阻止内核空间执行/访问用户空间数据的保护-smp
:指定 CPU 插槽数、单个插槽上 DIE 的数量、每个 DIE 的核心数、每个核心的线程数-m
:内存大小--machine
:机器设备类型,QEMU 支持两种设备,另外一种是比较老的i440fx
-drive
:添加一个设备,这里添加了/dev/sdc
设备-s
:支持通过使用 gdb 连接0.0.0.0:1234
进行调试
简单测试一下,成功进入 GRUB 界面:
如果未指定
-bios
参数,则默认会使用 SeaBIOS 进行启动,此时便是传统的 BIOS + MBR 启动方式:
不过可能也有同学手上暂时没有闲置的 U 盘或其他外部存储设备,此时我们也可以使用 grub-mkrescue
创建一个专门用来调试的镜像,首先创建如下目录结构:
1 |
|
接下来使用 grub-mkrescue
创建镜像:
在部分发行版上,这可能叫
grub2-mkrescue
,固件路径也可能不一样,请自行分辨
1 |
|
把启动脚本中的设备节点路径改成文件路径即可成功启动:
1 |
|
0x03. 启动一个空白内核
接下来我们终于要正式开始进行内核的编写了,过去绝大部分的操作系统内核都是用 汇编 + C 语言
编写的,不过最近也有使用 Rust 替换 C 语言的内核实现(例如国产操作系统 DragonOS ,与绝大多数 Linux 系统调用兼容,目前已经完成了 musl-gcc 的移植,笔者觉得非常🐂🍺),Rust 也在逐渐进入 Linux 内核,包括计算机系统能力大赛 主推的也是 Rust 内核
但是对于新手而言 Rust 终归是有些难以让人绷得住,在笔者看来不能像 C 语言那样提供足够贴近于硬件底层的直接抽象,因此笔者这里还是先选择自己最喜欢的 C 语言编写内核最核心部分的代码,不过后面我们也可能引入一部分 C++ 代码 :)
算下来已经是笔者写的第四个内核了,希望这次能够达到比较高的一个完成度
代码基本结构
最初的代码结构如下所示,包含一个空白内核:
1 |
|
我们使用 CMake
来进行项目管理,相比起传统的 Makefile
,这是一种更加方便、更加自动化、更加规范的现代编译工具
什么,你不知道如何编写 CMake?还不赶快学!
根目录的 CMakeLists.txt
编写如下,主要作用就是准备统一的编译参数、进入不同文件夹进行 make、链接所有的目标文件,这里我们去掉了标准库支持、去掉了调试段、指定了静态编译……因为在裸金属环境下能依赖的只有我们自己:)
1 |
|
include
目录下是用于各个子系统的各种头文件,与 multiboot2 规范相关的一些定义放在 include/boot/multiboot2.h
中,该头文件来自于Multiboot 2 spec,比较长,这里就不贴出来了
内核主体放在 kernel
目录下,其中 kernel/CMakeLists.txt
编写如下,目前暂时就只是添加当前文件夹下文件链接为 Kernel
:
1 |
|
kernel/main.c
暂时就先放一个空的函数, 本篇博客暂时还用不到这块 :
1 |
|
arch
目录下是与架构相关的代码,目前暂时还是只支持 x86,不过后续如果有机会的话笔者希望能够让他在更多架构上跑起来,所以这里设计了一个通用的 CMakeLists.txt
:
1 |
|
arch/x86/CMakeLists.txt
主要就是编译汇编和 C 文件以及启动阶段临时用的字体文件放到 out/arch
目录下,这里我们将启动阶段所需的代码都放在 arch/x86/boot
目录下:
1 |
|
arch/x86/boot/CMakeLists.txt
则就只是简单地编译当前目录的代码:
1 |
|
linker.lds:链接脚本
什么,你不知道什么事链接脚本?还不赶快学!
链接脚本用来指示我们的内核可执行文件各个段的布局,例如不同的段的加载地址,在操作系统开发中有个不成文的约定就是将内核加载到物理内存 1M 起始,因此我们需要在链接脚本中将我们的引导部分放到这个位置,同时作为一个不成文的规范,内核应当被装载到高地址处,那么我们的内核应当分为如下两大部分:
- boot:由 GRUB 引导,负责进行页表重映射、内存管理初始化等预备工作,完成后跳转至内核
- kernel:实际的内核主体,位于虚拟地址的高地址处
笔者选择将 boot 阶段的所有代码全都放在开头为 .boot
的段当中,将 kernel 的 .text
等段重新从高地址处计算起始地址,因此链接脚本如下:
1 |
|
boot.S:BIOS & UEFI 兼容 32 位汇编入口,跳转进入 64 位 C 语言
arch/x86/boot.S
中则是我们实际的内核入口点,因为对于实现 multiboot 规范的内核而言 没必要在汇编下进行绝大部分系统功能的实现 ,所以这个文件的核心功能就 仅是完成部分必须的准备工作并快速进入 C 语言部分
我们的 multiboot2 header 也可以放在这个地方,这里除了最基本的结构以外笔者还引入了一个指示入口点的 tag,以及一个指示让 GRUB 帮我们设置好指定大小的 frame buffer 的 tag,GRUB2 会根据这个 tag 自动帮我们设置显示模式:
1 |
|
ELF 文件默认的入口点是 _start
函数,因此我们在这里声明一个 _start
函数并导出该符号,从而使得 GRUB 在完成内核的装载之后会从此处开始执行,不过我们也可以通过在 header 中添加一个 entry address tag
来为 GRUB 指定我们的内核入口点:
不过在正式开始之前,我们首先看看当前的机器状态,这里笔者打算同时兼容 Legacy BIOS 启动与 UEFI 启动,因此这两种机器状态我们都得看看如何处理
① Legacy BIOS 启动
当我们使用 Legay BIOS 启动遵循 Multiboot2 规范的 32 位内核时,在进入内核时机器应当有如下状态:
eax
:必定为 Magic Number0x36d76289
,该值的存在表明其为符合 multiboot2 标准的引导程序加载的ebx
:必定为引导加载程序提供的 Multiboot2 信息结构的 32 位物理地址(参见这里)cs
:权限为读|执行
,偏移为0
,界限为0xFFFFFFFF
ds、es、fs、gs、ss
:权限为读|写
,偏移为0
,界限为0xFFFFFFFF
A20 gate
:已开启cr0
:分页(PG)关闭,保护模式(PE)开启eflags
:VM、IF 两个位清空
剩下的工作都需要我们的内核自行完成,包括段描述符表的设置、堆栈、中断描述符表的设置等
② UEFI 启动
当我们使用 UEFI 启动遵循 Multiboot2 规范的 32 位内核时,在进入内核时机器应当有如下状态:
eax
:必定为 Magic Number0x36d76289
,该值的存在表明其为符合 multiboot2 标准的引导程序加载的ebx
:必定为引导加载程序提供的 Multiboot2 信息结构的 32 位物理地址
根据 UEFI 规范 v2.6 第 2.3.2 节,此时机器有如下状态:
- 单处理器模式(Uniprocessor,仅有一个核心被唤醒,参见 Intel SDM 卷 3)
- 处在保护模式下
- 可能开启了分页,若是,则 UEFI 内存映射定义的任何内存空间都是恒等映射的(虚拟地址等于物理地址),对其他区域的映射是未定义的,可能因实现而异
- 选择子(selector)设为“平坦模式”(flat model)或未使用
- 中断开启,不过仅支持 UEFI 引导服务计时器(所有加载的设备驱动都通过“轮询”进行同步服务)
- EFLAGS 中的方向标志位被清除
- 其他通用标志寄存器未定义
- 128 KB 或更多的可用栈空间
- 栈为 16 字节对齐,可能在页表中被标记为不可执行
floating-point control word
被初始化为0x027F
(all exceptions masked, double-extended-precision, round-to-nearest)Multimedia-extensions control word
被初始化为0x1F80
(all exceptions masked, roundto-nearest, flush to zero for masked underflow)CR0.EM == 0
CR0.TS == 0
选择子的平坦模式示意如下:
③ 初始化栈与临时页表
我们不难看出 BIOS 启动与 UEFI 启动后的机器状态在 32 位下差别并不大,大家都在保护模式下,只是 UEFI 启动可能会额外多一个栈和页表的配置,不过我们完全可以 抛弃 UEFI 帮我们预设好的页表与栈,自己从头开始初始化一个
首先是页表的初始化,由于现在尚未完成内存管理器的构建,因此笔者选择仅构建一个临时的页表,待到进入 64 位长模式完成内存探测与内存分配器的建立之后再重新建立一个正式的新页表,64 位模式下所用的通常是四级页表,虚拟地址有效长度为 48 位,在控制寄存器组(control registers)中的 CR3 寄存器中存放顶层页表的地址:
页表所使用的模式由 CR4 寄存器决定,具体可以参见 Intel SDM 的 3102 页 4.1.1 Four Paging Modes
不过启动阶段若是我们的临时页表也采用 4 级页表的结构的话,或许会需要占用过多的内存空间来保证对所有内存的映射,因为这个页表只是在内存管理器建立起来之前临时一用,因此这里我们使用 1GB 的大页,这样只需要两张页面组成的二级页表便能撑起我们初期所需的所有的内存空间,待到完成最基本的内存管理器的初始化之后再重新进行页表的动态初始化
1 GB 大页的开启需要我们进入 64 位,并在页表项中设置 PS
位,此外,进入 64 位模式要求我们启用物理地址扩展(Physical Address Extension),这项特性将页表项从 4 字节扩展为 8 字节,我们需要在进入 64 位之前通过设置 CR4 寄存器的 PAE 位来启用该特性,并在提前在预先准备好的二级页表中设置 PS
位
1 |
|
④ 进入 64 位模式
64 位运行模式(Intel 称为 IA-32e mode
,AMD 称为 long mode
)是进入 64 位时代后 x64 处理器引入的新的运行模式,其有着两个子模式:
- 兼容模式(Compatibility mode):传统的 16/32 位应用程序仍能正常运行,类似于 32 位保护模式,其使用 16/32 位地址与操作数,仅能访问线性地址空间的前 4 GB,通过 PAE 可以访问更多的物理内存
- 纯 64 位模式(64-bit mode):该模式下可以访问 64 位线性地址空间, 通常 不再使用分段,通用寄存器与 SIMD 扩展寄存器从 8 个扩展至 16 个,通用寄存器扩展至 64 位,默认地址大小为 64 位,默认操作数大小为 32 位,新增的 opcode 前缀
REX
用以进行 64 位下的扩展访问
参见 Intel SDM 卷 1 Chapter 3、卷 3A Chapter 2 与 AMD64 PM 卷 1 Chapter 2、卷 2 Chapter 1
翻大砖头全英手册真给👴整麻了
简而言之就是进入 64 位模式之后正常运行 64 位应用就在纯 64 位模式,但也可以通过兼容模式来像 32 位保护模式那样运行以前的 32 位应用,这两种子模式间的切换通过 CS 段选择子对应的段描述符的 L 位决定:
我们的 64 位操作系统自然是运行在纯 64 位这一子模式下的,而 GRUB 引导进入我们的内核时我们仍处于 32 位模式下,因此我们需要编写 32 位汇编来将处理器切换到 64 位模式,具体需要进行如下工作:
- 将 **Model Specific Register **这一寄存器组中的 Extended Feature Enable Register 寄存器的 LME 位置为 1(参见 Intel SDM 卷 3 A Chapter 2 的 2.2.1 节,Table 2-1)
- 配置好相应的全局段描述符表,并将 CS 段选择子对应的段描述符的 L 位置为 1
- 开启控制寄存器组中 CR4 寄存器的 物理地址扩展(Physical Address Extension)
- 开启控制寄存器组中 CR0 寄存器的 分页(Paging),这要求我们预先装载一份页表
1 |
|
此时我们便来到了 64 位兼容模式,而要进入纯 64 位模式,则需要我们手动更新 CS 段选择子,这里笔者通过一个远跳转 jmp
指令手动指定段选择子的方式来刷新 CS 段选择子
以及别忘了刷新数据段选择子和其他段选择子,这些选择子可以直接通过寄存器进行重新赋值,但是 CS 段选择子必须通过指令进行刷新
从 boot_main()
开始我们就可以进入 C 语言的世界了:)
1 |
|
注:除了通过
jmp 选择子:目标地址
的方式以外,我们也可以通过lretq
指令刷新 CS 段选择子:
1
2
3
4.code64
pushq $(1 << SELECTOR_INDEX)
pushq $boot_main
lretq
Extra. 分段内存简介
在古老的 16 位与 32 位运行模式下,x86 有一种管理内存的办法叫做分段(Segment),一个段便是一段连续内存,相应地有 cs、ds 等段寄存器用来指示不同用途的段,经过分段映射的地址称为逻辑地址(logical address)
16 位下段寄存器中直接存放段基址与段界限信息,32 位下段描述符扩展为 8 字节,存放在内存中一个名为段描述符表的结构中,GDTR 寄存器用来存放全局段描述符表的地址,相应地段寄存器中存放的变为段选择子(segment selector),指示了该段寄存器对应的段在段描述符表中的索引、权限等信息
不过分段内存模式过于鸡肋,现代操作系统通常会选择把所有段都初始化为整个内存,因此 CPU 厂商更改了设计,在 64 位下默认不使用分段特性,不过 也不是完全弃用分段这一特性 (尾大不掉属于是)
boot_tty.c:读写 frame buffer 进行文字输出
熟悉各类操作系统开发教程的小伙伴肯定知道非常经典的读写 0xB8000
这块内存便能在屏幕上进行字体输出, 但是在 UEFI 启动下这个方法已经不在可用 ,那么我们该怎么进行字体输出呢?最简单的办法自然是——把像素点直接画在屏幕上
① Frame Buffer
帧缓冲区 ( frame buffer )为内存/显存中的一块自定义区域,可以简单理解为屏幕上所显示内容的缓存,显卡会定期从这块区域搬运数据到显示设备上,因此我们可以通过读写 frame buffer 的方式来在显示器的指定位置显示指定的像素点
有了 Multiboot2 规范,我们可以指示 GRUB2 帮我们准备 Frame buffer 的相关信息并传递给我们的内核,接下来我们便能通过直接读写 frame buffer 对应内存的方式来直接在屏幕上进行显示:
1 |
|
② 字体解析绘制
虽然我们现在可以通过直接操作 Frame buffer 来绘制图形,但是每个字体都要从零开始绘制的话未免就太麻烦了一点,因此我们选择载入现有的 PC Screen Font 格式的字体文件,从中读取相应的字体信息进行绘制
这里字体笔者选择了 solarize-12x29,clone 到本地后将
Solarize.12x29.psf
拷贝为arch/x86/boot/font.psf
即可
由于我们还没建立文件系统,因此我们需要将字体文件直接链接到内核当中,这里可以使用 objcopy
这一工具来将字体文件转换为可链接文件:
需要注意的是,objcopy 会非常 sb 地把路径名也放进去,因此比较可行的解决方案就是提前将字体文件转换为 .o 文件
1 |
|
现在我们来看如何解析这一格式的字体,PSF 有两版规范,由一个 header 来指示字体基本信息, header 之后便是字体的位图信息:
1 |
|
字体位图有着其固定的宽度与高度,虽然字体的宽度并不一定对齐到 8 bit,但是存储空间需要对齐到 8 bit 也就是 1 字节,因此位图数据中会有空数据填充段,以一个 12x12 的 PSF 位图为示例:
1 |
|
由此我们可以通过如下代码来解析字体文件并在屏幕上显示文字(二代规范):
1 |
|
Extra: 串口输出初探
注,因为我们目前处在 boot 部分,因此笔者仅会实现一个最简陋的串口输出代码,在后续的 real kernel 部分的代码中我们会重新实现一个更加完备的串口驱动
串口输出是以前老 IBM 机喜欢用的方法,包括 Linux kernel 也支持串口输出功能(当你使用 qemu 运行时若添加了 -nographic
参数 QEMU 会将虚拟机的串口 0 的输出重定向到当前终端,也可以直接在 qemu 的图形界面切换到 serial 0
),目前这个输出方式已经很少被使用了, 毕竟都什么年代了还在抽传统香烟? 不过在一些特殊场景这个方法还是偶尔会被用到的(例如 headless console),因此这一节我们简单讲讲如何通过串口进行字符输出
串行通讯端口 (Serial Port,aka COM)通常由 通用异步收发传输器 (Universal Asynchronous Receiver/Transmitter, UART)进行控制,其内部时钟波特率通常是 115200,目前比较常用的是 EIA-RS-232 标准,针脚数通常是 25 针或 9 针(后者用的比较多)
更多的微机原理知识这里不再赘叙,我们接下来简单讲讲串口如何使用
单个串口的 寄存器组 会被映射到指定的 IO port 起始的区域,我们通过访问串口的不同寄存器进行相应的操作,通常情况下串口映射到端口的基地址如下:
需要注意的是 仅有前两个串口的端口地址是固定不变的,其余串口的端口地址未必如下表所示
对于守旧的 BIOS 用户,BIOS Data Area 可以帮助你获取包括 COM 地址在内的各种信息
对于
不抽传统香烟的现代 UEFI 用户,
COM Port | IO Port Base |
---|---|
COM1 | 0x3F8 |
COM2 | 0x2F8 |
COM3 | 0x3E8 |
COM4 | 0x2E8 |
COM5 | 0x5F8 |
COM6 | 0x4F8 |
COM7 | 0x5E8 |
COM8 | 0x4E8 |
不同偏移的寄存器含义如下(来自 OSDEV wiki):
IO Port Offset | Setting of DLAB | I/O Access | Register mapped to this port |
---|---|---|---|
+0 | 0 | Read | Receive buffer. |
+0 | 0 | Write | Transmit buffer. |
+1 | 0 | Read/Write | Interrupt Enable Register. |
+0 | 1 | Read/Write | With DLAB set to 1, this is the least significant byte of the divisor value for setting the baud rate. |
+1 | 1 | Read/Write | With DLAB set to 1, this is the most significant byte of the divisor value. |
+2 | - | Read | Interrupt Identification |
+2 | - | Write | FIFO control registers |
+3 | - | Read/Write | Line Control Register. The most significant bit of this register is the DLAB. |
+4 | - | Read/Write | Modem Control Register. |
+5 | - | Read | Line Status Register. |
+6 | - | Read | Modem Status Register. |
+7 | - | Read/Write | Scratch Register. |
方便起见,在启动阶段我们就不编写太过于复杂的串口驱动了,简单来说,要通过串口进行字符输出,我们首先需要往一部分寄存器中写入特定数据进行串口初始化:
1 |
|
之后直接往串口里 outb()
即可输出字符:
1 |
|
这里我们为 QEMU 附加 -nographic
的参数进行测试,可以看到串口输出的数据一切正常:
Ctrl + A
后再C
进入 QEMU 调试台,q
退出
真机启动
接下来我们使用 make
命令编译代码,将 out
目录下的 kernel.bin
文件放到 U 盘 EFI 分区中的 boot
目录下,重启计算机并选择 U 盘启动,接下来——
ClosureOS,启动——!!!
我们成功制作完成了一个能够在真机上运行的操作系统,而并非大部分教科书上的那些只能在虚拟机里跑的小玩具,虽然目前仅有最基本的雏形 :)
我们将在后续博客当中逐步完善这个操作系统(🕊🕊🕊)
0xFF. Reference
Multiboot2 Specification version 2.0
OSDev wiki ← 非常好维基,使我OS运行,爱来自瓷器❤
https://pendrivelinux.com/install-grub2-on-usb-from-ubuntu-linux/
PC16550D Universal Asynchronous Receiver/Transmitter with FIFOs