GGOS 诞生记

不觉已经两个月没写过什么博文,甚至日记也搁置了一段时间——而这一个月来基本上大量时间就投入到了操作系统的开发,因此不如就写篇文章记录一下吧。

说起来,写操作系统真的是一件十分上头的事情呢……

今年伊始,彼时的我听闻一些传言,说我们的操作系统课程会是一个基于16位实模式的、很难找到资料、脱离实际且无聊的实验规划,同时也看到了学长自己两年前实现的操作系统 rust-xos,顿生起了自己按照自己的节奏实现一个自己的操作系统的想法。那时候只是定好了要用 Rust 去进行开发——虽然这个语言我也只是看过几次文档罢了,同时也并未想好架构,大概决定要写个 rCore 一样的基于 riscv 的,而后来在假期里并没有做很多工作,同时也低估了操作系统的工作量,因此开学时并没有这样一个成型的项目。

待到三月份,我们正在正式的实验中要开始进行操作系统的开发了,我才终于捡起这份想法。那时候也知道了操作系统课程的老师进行了更换,我们的实验内容也变成了基于 C/C++ 和 x86 的小规模还算现代化的操作系统的开发。由于 riscv 的 VGA 显示制作起来尚有困难,而这又是我们最初的课程实验要求——于是我便选择了和 Rust-xos 相同的架构,基于 x86_64 和 UEFI 来进行后面的实验。

需要注意的是,我的实现过程并没有参考我们所用的操作系统教材,更多的是出于一种"我下一步需要什么"的思考来进行的,因此部分的开发流程可能会看起来怪怪的。

关于 GGOS 的取名,其实是借鉴 tuna 的递归定义——虽然我并不记得是谁第一次告诉我 tuna 的命名方式,而且现在也想不起来他的全称了……不过也就是将 GZTime's GG OS 取了首字母,作为了这一项目的名字。不过他们都愿意称它为"狗狗OS",我觉得也挺可爱的hhhh

之后就是历时整整两个月的开发了,由于开学后我基本上天天在实验室泡着,学长也就在我前面的工位,因此这两个月的开发也受到了学长极大的帮助和推动,解答了我不少问题,并且指导了我很多方向性的抉择,在此也一并对 GGOS 的诞生表示感谢。

关于 Rust

也有人问过我如果没学过 Rust 那用它来写操作系统会不会很困难,也算是我自己的实际经历:我的第一个 Rust 项目便是 GGOS,在它之前我也只是随意翻看了两遍文档、做了一遍 Rustlings 罢了,只要能找到相关资料和代码示例,其实觉得这样一个项目并没有大部分人最开始预期的那么难,更多的在于你愿不愿意去学和尝试了——更何况 Rust 的编译器绝对是一位很好的老师。

所以如果你有这样的一个计划,我只想告诉你:尽管大胆去试吧!你会收获到的一定会超越你能想象。

关于学习资料

这里我列出部分比较重要的学习资料和相关的链接,同时你也可以通过我部分源码中最上方的引用链接找到相关内容我参考了哪些文件。

  1. Writing an OS in Rust 这份资料对于我所选择的架构十分有用,同时它的作者也是 x86_64 和其他一些 crate 的作者,同时它也会给出一些实现的建议,譬如一些数据结构的使用和语言层面的基本问题需要如何解决等等……
  2. OS Dev 提供了大量的有关于开发操作系统的资料,从指令集到各种硬件端口的定义、文件系统的实现、正确的做法等等,这里的资料也是十分有用的。
  3. Github 就不用说了,我从它上面找到了很多有用的源码,他们为我的实现提供了不少的灵感,你可以对比每个人对于一个功能实现的不同做法,选择你所喜欢的实现方式、也可以自己创新,对比各种实现的优缺点,并对自己的代码进行优化。

关于开发平台

我目前的硬件设施主要是一台 Win 11 笔记本电脑,其中装有 kali 的 WSL2 子系统虚拟机。由于 kali 可以直接安装最新版的 qemu,如果使用其他发行版的新版本 qemu 可能需要自己进行编译。同时多亏了 WSLg 的出现,我也可以在 windows 中直接看到 linux 应用的窗体,这些都为后期的开发提供了很多的帮助。

同时由于需要部分功能,我使用 nightly 版本的 Rust toolchain 进行开发。

GGOS 的开发历程

从 Bootloader 开始

基于 UEFI 进行开发的好处之一是可以避开启动初期的大量汇编,最开始我们的目标就是写一个可以被 UEFI 引导的 EFI 程序,Rust 也提供了这样一个 target: x86_64-unknown-uefi,这方面的内容 A Minimal Rust Kernel 提供了大量的讲解,基本顺着实现就好,不过要注意时效性,毕竟距今已有近四年,不论是 Rust 的语言特性还是一些实现方式都可能会有很大的变动。

UEFI 在启动初期提供了大量了的启动期服务,不过我们终究是要与他告别,退出 Boot Service。不过利用这些服务我们可以完成很多事情,因此我们对于实现 Bootloader 的目标其实是很清楚的:

  1. 获取硬件信息:比如VGA信息、内存布局、APIC信息等等
  2. 加载内核文件,初始化当前页表,并进行ELF的内存映射
  3. 保存启动信息,退出启动服务,之后调整栈指针并递交控制权给操作系统内核

由于有前车之鉴,这部分的开发是比较迅速的,很快便结束了,大概用时一天便实现了内核的基本图形绘制。

被保护的全局静态对象

在 Rust 中对全局变量的访问是一个 unsafe 操作,因为这是线程不安全的,如果直接使用的话编译器会报错,但是对于显示器等静态全局对象我们确实需要存储,这时候就会不可避免的引入了互斥锁来进行保护。

虽然互斥锁的概念基本上会在线程和相关内容结束后才会被提到,笔者校内实验的第六次才涉及到这一内容,但出于编译器的要求,我们只管在此阶段进行使用即可。并且由于我们目前没有中断和线程这种异步的实现,互斥锁也在此处作为一种访问方式理解就好。在真正遇到的时候再进行进一步的学习。

互斥锁在裸机中一般使用自旋锁实现,可以直接选择 spin 这一 crate 使用,相关操作方式你可以在我代码中的 marcos 中找到这类全局变量的声明方式和使用方式。

在实现中断后,你可能会多次在调试中遇到死锁而报出的 panic,这时候需要仔细想想代码中哪些资源被竞争,并解决相关的问题。笔者有个建议是在输出的地方进行 try_get 操作,不要获取不到资源使用权就直接 panic 退出,这样也会为你在遇到了显示输出问题时候留一条 debug 的后路。

拿到 Console

进入内核我考虑的第一件事情便是做个基础的终端输入输出,于是开始用 Rust 嵌入式库 embedded-graphics 进行输出显示的开发,做了下绘制、换行、退格等基本控制操作的实现,这方面可以参考代码中 gop.rsconsole.rs 的制作。

之后一段时间我研究了一下给这个库自定义字体的方式,通过它的字体绘制接口嵌入了一下自己很喜欢终端字体。

不过站在现在的角度,我更建议你在这个阶段就将基本的串口输出进行实现,通过串口将 OS 的输出重定向到你自己的终端应用——这很有利于 debug,虽然我在近半个月后才进行了实现……(毕竟模拟的终端哪有已经实现好的终端好看和实用呢……你还能从中复制出来文字,也能查看历史什么的)

UART 的开发还是比较容易的,而且你可以通过它很简单的接口定义来对利用端口进行硬件控制和初始化的方式进行学习并动手实践,这是个很好的机会,在代码 uart16550.rs 顶部的注释中你可以找到部分我用到的文档——不过别忘了在写完 init 函数后将它添加到内核的启动流程中(我因为这个失误对着正确的接口定义看了好久……

日志与 debug

使用 Rust 进行开发的好处之一便是:一旦你打好了地基,就可以使用上方的万丈高楼。这种语言层面的基于类似于"接口"的特征能够为我们的开发提供大量的辅助。

在实现了对 Console 的 Write trait 后,我们便可以参考标准库对 println 的实现,利用我们自己的输出终端进行格式化输出、panic 输出乃至日志输出了——而无需关系参数是如何被格式化成字符串的,这一点可以省下大量的工作量。相较而言基于 C/C++ 的开发还需要自己对 printf 进行实现,日志相关的包管理也远没有 Rust 的灵活易用——我们只需要 40 行代码和一个对 log crate 的引用罢了。

而掌握了这些简单的"调包"技术后,我们下一步要来点硬核的了。基于 QEMU 的调试、挂远程调试器、利用调试信息进行断点调试。

首先是关于 QEMU,你可以使用 -s -S 的参数使得它 1234 端口监听等待调试器,同时在启动 GUI 后,你可以通过 compatmonior 来在 QEMU 层面进行调试,包括打印完整的寄存器等等。

在后期进行中断处理但是无故退出、或者发生 triple fault 的时候,你可以通过添加 -no-reboot -d int,cpu_reset 来对退出时候的错误信息进行详细的排查。

其次是关于 gdb 的使用。由于在 CTF 中有对二进制调试的需求,我了解到了 pwndbg 这一 gdb 插件,相信有了它的帮助你的调试之路会变得更加顺畅。

然后是关于调试信息的使用,在生成的过程中,你可以选择在 cargo 的 profile 定义中添加 debug=true 的选项,或者使用 dev 模式编译,使得程序可以精确到函数和地址的进行断点操作,这时候你可以安装 CodeLLDB 的 VSCode 扩展,使用远程连接到 WSL 中,利用下面的调试配置直接将 VSCode 作为调试工具,局部变量什么的也可以正常显示:

{
  "type": "lldb",
  "name": "Attach",
  "request": "custom",
  "targetCreateCommands": ["target create esp/KERNEL.ELF"],
  "processCreateCommands": ["gdb-remote localhost:1234"]
}

GDT 与时钟中断

时钟中断多是在写操作系统的 IDT 时候需要实现的第一个中断,它是后期实现抢占式多任务的重要一环。

这部分其实和很多操作系统的教程内容是比较契合的,直接进行相关学习和实现就好。不过我建议在这里看脆将操作系统内部的绝大部分异常中断一并进行处理,做一个 panic 的提示,并输出一些栈帧等相关内容——至少也要实现下对 double fault 的处理。这对于后期的调试也有着很重要的意义。

不论你使用 8259 PIC 还是 APIC 都是可以的,但是在这一步你也需要对 GDT 进行一些初步的操作,比如要为中断处理留足合适的处理栈,比如 double fault 的栈一般来说是独立的。

到这步的任务基本上就是利用时钟中断绘制一个时钟啊、跑马灯之类的小功能,也不妨一试。到这里我的开发过程基本上进行了一周左右。

内核堆与帧分配

由于在忙一些别的开发工作,GGOS 闲置了约莫一两周的时间,而我的下一个任务便是实现帧分配器与基本的内核堆——虽然彼时操作系统课程实验已经在实现基本的 PCB 了,但是个人觉得用一页作为一个 PCB 并且将线程栈放在 PCB 中的实现方式实在是又怪又丑,因而通过动态数组的实现便成了我的选择。

好在 Rust 中实现一个基本的内存堆分配是很简单的,只需实现 GlobalAlloc 并在项目中声明一个 global_allocator 即可——我们也可以使用一个简单基础的 linked_list_allocator 实现,毕竟因为 CTF 而简单了解了 malloc 机制之后我对自己写一个内存分配和管理的信心也并不是很足。

有了分配器,但我们需要给他指定哪块地址是可以使用的。我选择在虚存的 0xFFFFFF8000000000 开始为其分配一个 32MB 的堆——虽然实际实验下来现在知道了 1MB 都已经算得上很大了——关于为什么我已经有了虚存,由于开发的是长模式下的,其实在 Bootloader 阶段就可以看到已经启用了四级页表来进行内存映射,但是相关内容操作系统课程一直等到学期末才进行讲解,我属实觉得这有些不太合理。

但我毕竟看的相关课程和书籍资料还是偏少,也因为这事情和老师有过讨论。老师说一般的操作系统书籍都是把内存分配放在了比较后期的位置,而带有实践意味的教程才会将其放在前面——这也算合理吧,虽然我还是不太清楚这样设计的原因,读者或许可以给我进行一些解释。

话题有些远了,回到帧分配器。我们从 Bootloader 传来的 mmap 中可以拿到可用的内存区域,我们在帧分配器中可以将它们切成 4KiB 的一块一块,在我们需要的时候进行分配。同时,我们也可以给帧分配器附带一个动态数组,用于存储被释放的物理帧,以供再次使用——读者可以思考下动态数组创建的时机:先有帧分配器还是先有堆分配器呢?这里可以给个提示,Vecnew 函数是 const 的。

关于帧、页、页表映射、通过线性映射访问物理地址等内容可以通过 Writing an OS in Rust 的相关章节和其他渠道进行更细致的了解,这里就不详谈了。

在拥有了堆区域后,我们将它传给堆分配器,我们便可以使用 alloc 中的各种动态数据结构了。从这个角度,若想通过 C/C++ 来提供可以使用 vectorstring 的条件……那算得上任重道远了。

键盘、串口与输入

拥有了输出功能,我们自然而然的想到支持输入功能,其实这一过程可以说是为键盘和串口写驱动的过程,通过 ioapic 传来的中断,我们进行处理、与接口通信、然后获取数据。

输入的实现一般会有一个循环队列作为缓存,用来保存输入的键值,在需要输出或使用的时候取出,这样就可以保证输入的正常顺序,并且提供了一个统一输入的方法——我们可以将从键盘和串口输入的数据统统放进同一个队列,而需要输入的地方只需要从队列中取用、并在队列为空时等待即可。

需要注意的是,更多时候我们的输入并不是简单的字符,可能一串字节流在解析后是一个控制字符等等,因此我们也需要一个全局的键盘解释器,这样一个静态解释器也需要从两方同时获取输入,具体实现可以参照我的 keyboard.rsserial.rs 中的实现。

在实现了基本的用户输入输出功能和动态内存管理后,我们便可以构建字符串、实现一个基础的复读机了——将解析后的输入重新吐出来。

至此,GGOS 从创建仓库开始大概过去了四周的时间。

进程与线程

其实进程和线程的实现和键盘输入输出功能是实现是同步进行的,部分工作一并便开始做了,所以也还是第四周左右的时间,基础的进程管理也被实现了。

在 GGOS 的简易实现中,将线程视为一种特殊的进程——它与父进程共享大部分页表,但是栈在自己独立的部分。不过在最开始的实现过程中,我们只需要关心内核线程的创建,其实大可以直接为它分配一个物理帧作为栈,不过超过一个物理帧的栈便会直接触发缺页中断了。

由于我们已经实现了内核堆堆分配,我们可以直接使用诸如 VecBTreeMap 的动态数据结构来管理进程信息与进程数据,并通过进程管理器来实现调度和管理功能。关于 PID 的一致性和唯一性,可以用类似于下方的线程安全的方式进行实现——不过这样大概是没有什么 PID 的回收机制了.jpg

impl ProcessId {
    pub fn new() -> Self {
        static NEXT_PID: AtomicU16 = AtomicU16::new(0);
        ProcessId(NEXT_PID.fetch_add(1, Ordering::Relaxed))
    }
}

这部分的实验也终于与课程实验的内容相对应了,关于 PCB 的设计以及更多的实现也可以参考教材和实验指南进行。对于 GGOS 的进程来说,最重要的就是保存 PID、当前寄存器、当前栈帧以及当前的页表了。

而后是通过时钟中断进行线程的切换和调度,GGOS 保存寄存器的方式是写一大段的汇编将每个寄存器弹到栈上,通过函数的调用约定让它作为函数的参数传入,最后在处理结束后将其弹出——这样就可以在中断处理程序中获取寄存器的值了并进行修改了。

进程管理是操作系统中的一个大头,基本上从 process 文件夹创立开始之后的每个有关内核执行逻辑的版本迭代都会涉及到它相关文件的更改。

不过内核线程的实现还是很方便的,传入函数指针、放到指令指针中,创建新的帧并且将其作为线程的栈,然后将构造好的 PCB 放入进程队列,等待调度即可。

文件系统的实现

在进程的相关工作之后,我终于来到了我的最终目标:让 OS 可以执行用户程序。为了实现它,我还有两个大部分没有实现:文件系统及系统调用。

实现文件系统是很头疼的,当时 GGOS 也因此停更了一段时间,我阅读相关资料两晚也没产生很大的突破性进展——一方面是参考的三方代码中有些抽象我当时没有理解,一方面是这类人为定下的规范的实现也需要更精确到每个 byte 的解析……这还是很容易出错的。

也因此我的 fs 作为一个单独的 crate 存在于项目中,因为如此我便可以在拥有 std 测试框架支持的情况下对一些抽象和解析代码进行调试及测试了。

为了避免一些抽象理解的弯路,我这里留下一些关于块设备的笔记。

首先,磁盘是一个块设备,它可能含有多个分区,而组织这些分区的方式一般是通过分区表。分区表一般目前有两种,一种是 MBR,一种是 GPT,这两种分区表的实现方式不同,但是都可以通过解析来获取磁盘大小、名称、分区的大小等信息。

不过对它的读写一般要看底层的实现,这里我们将它后方的具体设备也抽象为一个块设备即可。

而后,分区也是个块设备,它取决于其内部的文件系统,不同文件系统的解析和读取方式不同,但是对它的读写一般要加上一个固定的偏移量,再由它提供给磁盘进行。

这一层有人称之为分区 Partition, 也有的称之为卷 Volume,这两个名字或许有一些差距,但基本上都对应了一个抽象层。为了和代码统一,我后面使用卷这一名称。

GGOS 实现了简单的 FAT16 文件系统的只读访问,我们可以把一个 FAT16 卷也抽象为块设备,只不过对它的读写是交由内部卷来执行,但是它可以提供很多基于文件系统的复杂功能,包括列出文件列表、列出文件等等,这些功能都需要逐一的进行实现,也需要对诸如 DirEntry 等数据结构进行定义和解析。

可以通过读取 BPB(Boot Parameter Block) 来获取 FAt16 文件系统的信息,具体可以通过对其的代码实现及 FAT 来获取。在我的代码引用部分也有其他很多代码实现可供参考。

在我的实现过程中,一度因为 MBR 和 BPB 同样使用的 0xAA55 这一 Magic Number 而一度陷入混乱,搞不清层级关系和抽象方式,最后通过 Disk Genius 缕了缕实际的硬盘的内存布局才最终分清,这里提个醒,避免读者被坑……

读取硬盘

有了对 uart16550 的实现,对 ata 设备的读写也便不是那么困难,我当时直到这里才意识到我一直没有实现这一在操作系统实验早期就做过的硬盘读取,对其进行封装后,作为文件系统最内部的块设备,便可以套进我们的一层层的文件系统抽象层——从而得到文件系统了。

这部分代码参考了各路神仙实现的 ata.rs,同时也参考了 ATA PIO Mode 的内容——以及部分操作系统实验的教程内容,将端口的定义和读写绑定好,并且把读写的数据转换为一个个数据块,这样就可以将它视为块设备了。

终于,在项目开始 44 天后,我完成了文件系统和硬盘读取的攻关,并在内核里实现了 lscdcat的基本执行——一切就绪,距离执行用户程序更近了一步。

系统调用

实现系统调用的过程是比较轻松的,因为大部分服务已经在内核中有了实现,这里就只是定义调用约定,并解析参数,传入相关服务、然后获取返回值。

中断的相关知识也早在课程很早的时候就进行了阐述,在实现了基本的调用过程后,我兴冲冲的搞好了用户程序的调用——也就是 int 0x80 软中断,但是随之而来的一个问题又让我折腾了两天有余——

有些系统调用正常的退出了,而有些则会在调用时直接崩溃,导致系统 reset。

这里应该是我最大规模的一次 debug 了。首先我先通过调整日志到 trace 等级,观察中断的调用过程,发现中断处理的内容都被正确的执行了,随后挂上了 gdb 进行逐指令调试,发现 程序总是在执行到 iretq 指令的时候跳转到 0xfff3

我们随后对栈帧的完整性、寄存器的完整性等可能问题进行了排查,寄存器、栈指针均没有问题,这让我和学长十分不解,因为我对全部的错误中断进行了处理,并且没有任何栈和寄存器中存有 0xfff3——以至于学长掏出了他的 OS,把系统调用改为了和我一致的调用规范,证明了这样的调用过程理应没有问题。

我们看 gdb 不成,于是转而向 QEMU 求助,通过 -no-reboot -d int,cpu_reset 参数,我们成功打印出了最后重置前的中断信息:

check_exception old: 0xffffffff new 0xd
  1207: v=0d e=0008 i=0 cpl=0 IP=0008:ffffff00000a6485 pc=ffffff00000a6485 SP=0000:ffffff0000160c98 env->regs[R_EAX]=0000000000000009
...
check_exception old: 0xd new 0xd
  1208: v=08 e=0000 i=0 cpl=0 IP=0008:ffffff00000a6485 pc=ffffff00000a6485 SP=0000:ffffff0000160c98 env->regs[R_EAX]=0000000000000009
...
check_exception old: 0x8 new 0xd
Triple fault

终于问题被定位到了连续两次的 GPF(General protection fault),通过参考 Intel 指令集手册 IRET/IRETD — Interrupt Return 我们试图寻找原因,但当天还是无功而返。

当晚因为耿耿于怀,于是搜了些系统调用的实现的文章。当我看到一个关于用户态系统调用的实现文章 Rust-OS Kernel - To userspace and back! 时候偶然发现他对于每个系统调用,都是直接动态分配一片内存作为中断的处理调用栈,于是我突发奇想,想试着换一个调用栈试试——因为我现在的调用栈是和 double fault 共用的一块 4KiB 的静态区域。

我将内核在 GDT 中分配的调用栈的始末地址输出,想要与退出时候的栈顶指针做了对比:

[+] Double Fault IST: 0xffffff0000170d58-0xffffff0000171d58

而我的发生异常时候:

check_exception old: 0xffffffff new 0xd
  1207: v=0d e=0008 i=0 cpl=0 IP=0008:ffffff00000a6485 pc=ffffff00000a6485 SP=0000:ffffff0000160c98 env->regs[R_EAX]=0000000000000009

其中 rsp 已经到了 ffffff0000160c98 —— 这明显意味着我的中断处理栈已经写爆了,而这也很好的解释了为什么我设置好的 GPF 处理函数和 double fault 处理函数都没能捕获这一异常……因为它们所共用的栈已经炸了……

于是乎,我给系统调用分配了四倍于原来的中断栈空间,并于 double fault 的中断栈做一定隔离——你确实应该在很早期就做了这件事情,不然留下的隐患就会导致这般几天的苦苦 debug 了。

用户程序与内存优化

在进行系统调用的开发过程中,用户库的实现也便着手推进了,因此,在系统调用完善之后,终于在 5 月 14 号,我发布了 v0.7.2 版本——它已经可以执行基本的用户程序了。

而后我便着手完善了进程的 file handler、实现了文件的读取、随机数的生成、一些继承自内核的动态内存分配(因为其实并没有到用户态,所谓的用户进程依然有完整的内核页表,所以就共用内核的堆分配器了),进而实现了一个基本的 shell,并且可以在真正的 shell 里进行 lscdcat 等操作了。

而后的一段时光,我基于以前的设计实现了 fork 系统调用、实现了物理帧的回收和再分配、改变了用户程序 ELF 的加载方式:以前是直接读取到一片连续的物理内存,然后对这块物理内存进行整体映射,而现在是先读取到内核堆中、开辟足够大的连续虚拟内存空间、并将 ELF 的内容拷贝过去,再进行执行。

这种解决方案是为了配合物理帧的重分配而提出的,幸运的是这方面的实现并没有遇到什么bug,之后对用户进程的栈进行重分配、利用虚拟内存的特性使得每个线程的栈都有 4GiB、也保留了足够的线程数可用。同时,去除了线程创建时都分配 1MiB 的栈分配设计,转而使用每个线程最开始都只有 4KiB、当触发缺页中断的时候再进行补全的策略。

以上的内容大大减少了整体的内存使用,从运行起来需要几十 MiB 的"巨无霸",变成了自始至终没超过 2MiB 的小巧玩意。

在实现了信号量系统调用、用户进程互斥锁之后,我觉得 GGOS 已经达到了它最初的实现目标,虽然还有很大的优化空间可走,但主要开发可以说是告一段落了,这半年学到很多,也做了很多,很爽。

进入用户模式

在经过了以上的开发过程后,进入 Ring3 便是一件很容易的事情了,具体的内容和过程在大部分的实验参考和他人的代码中都有记录,这里不再赘述,简单讲讲 GGOS 进入用户模式的经历。

首先是做映射,在用户模式下进程可访问的页表是有限的,如果需要映射能被用户进程所访问的内存区域的话,要给它增加一个 USER_ACCESSIBLE 的 flag,因此我稍稍改进了一下以前用户映射栈的代码,将其改为映射一段内存。

除了栈和代码段需要改之外,还需要为GDT添加一下段描述符,并在构建用户进程的时候将他们置于栈帧之中,后续调试的时候也能看到对应的 CPL 和 DPL 的变化。

之后的问题就是堆了,在 linux 中,进程的堆是通过 mmap 系统调用向操作系统申请的内存,并通过 malloc/free 自主控制。而由于我们之前的设计是用户与内核共享堆空间,基于少改动基本逻辑的想法,我的实现方式是在内核里搞个用户进程所共用的堆分配器,并把它交给系统调用。

而后是关于一些在 Ring3 不能执行的一些特权指令,比如 hlt,如果使用会导致 GPF。此外,不要忘记为 TSS 的特权栈表中添加 Ring0 所对应的表项,如果没有添加,可能会导致 Cr2 = 0xfffffffffffffff8 的类似 Page Fault,这是由于表项处默认为全0,当 rsp 减小的时候负数溢出,导致到了个最高的地址处。

这个问题大概困惑了我一下午,大半夜突然想起,进而更改、并完成了用户模式。遂发布了 v0.9.4 版本,也算是没有留下遗憾了。

你好,世界

 _______  _______  _______  _______
|       ||       ||       ||       |
|    ___||    ___||   _   ||  _____|
|   | __ |   | __ |  | |  || |_____
|   ||  ||   ||  ||  |_|  ||_____  |
|   |_| ||   |_| ||       | _____| |
|_______||_______||_______||_______|
                                v0.9.4 by GZTime
[+] Serial Initialized.
[+] Current log level: DEBUG
[+] Logger Initialized.
[+] Privilege Stack : 0xffffff0000171ce0-0xffffff0000172ce0
[+] Double Fault IST: 0xffffff0000172ce0-0xffffff0000173ce0
[+] Syscall IST     : 0xffffff0000173ce0-0xffffff0000177ce0
[+] Page Fault IST  : 0xffffff0000177ce0-0xffffff0000178ce0
[+] Kernel IST Size : 28 KiB
[+] GDT Initialized.
[+] Display: 1280x800
[+] VGA Display Initialized.
[+] Console Initialized.
[D] XApic support = true.
[D] Keyboard IRQ enabled.
[D] Serial0(COM1) IRQ enabled.
[+] Interrupts Initialized.
[+] Physical Memory Size: 127.625 MiB
[D] Kernel Heap start: Page[4KiB](0xffffff8000000000)
[D] Kernel Heap end  : Page[4KiB](0xffffff800007f000)
[D] User Heap start  : Page[4KiB](0x400000000000)
[D] User Heap end    : Page[4KiB](0x400000020000)
[+] Heap Initialized.
[+] Process Manager Initialized.
[+] Keyboard Initialized.
[+] Input Initialized.
[+] Initialized ATA Buses.
[+] Opening disk device...
[+] Identifying drive 0
[+] Drive QEMU HARDDISK QM00001 (504 MB) opened
[+] Mounting filesystem...
[+] Initialized Filesystem.
[+] Interrupts Enabled.
[+] GGOS initialized.
            <<< Welcome to GGOS shell >>>
                                 type `help` for help
[/APP/]

项目链接:GZTime’s GG OS - Github

修订与更改

  • 2022.05.25, 添加用户模式(Ring3)相关开发过程

一些结语

这半年也过的飞快,转眼我也即将变成一个大三的学生。对于选择走一条自己的路开发一个属于自己的 OS 我是毫不后悔的,有问题也罢、遇到困难也罢,很久没有遇到类似的能使我如此兴奋的开发经历了。相比起来我倒是觉得人工智能的实验着实有那么些逊色,少了些魅力。

如今还要面临 OS 比赛的开发,其实有些不是滋味。一面说着"写吐了",一面对把 k210 开发板跑起来有着格外的兴奋——虽然现在有些真不太想碰 OS 的开发了,但毕竟任务在那里,也不得不做。何况再过一周——5月29号,还有 CTF 国赛需要打——我也不知道我大概多久没碰 CTF 了……

说到 CTF,开发 OS 的间隔我便是去写了自己一直想做的 GZCTF 的后端,如今后端和最初的接口设计已经成型,只是前端的设计和开发目前推进缓慢。某种意义上最近有点不怎么想写代码的感觉了,需要休息一段时间。哦还有……为了科研训练课程目标还得去攻一攻区块链和智能合约安全相关内容……而且下周还需要做英语课的 Pre,略头大。

这篇文章写的匆忙,感谢你读到这里,如果有笔误的地方还请评论斧正,不甚感激。

时间总是过的飞快呢……

祝你早安、午安和晚安,最近我在追《间谍过家家》,真的很不错,如果你没看过的话欢迎去尝试一下(强行安利

GZTime
2022.05.22