MINIX 笔记 1 —— x86 架构简介

Jul 18, 2022 / 3 min read

最近在读一个小型操作系统 MINIX 的源码,第一步开机阶段涉及到了非常多底层硬件的知识。在这里记录总结了一下,希望能给同样对此感兴趣的朋友们带来一点小小的帮助。

操作系统作为工作在用户程序和底层硬件之间的"代理人",核心功能是管理系统资源和为用户提供访问硬件的接口。我们要理解操作系统的代码在做什么,首先需要熟悉计算机的核心硬件—— CPU 的工作流。

CPU 架构是个非常复杂的的东西,要介绍完全部的功能不太现实。既然目标是学习 MINIX 的源码,这里着重介绍 MINIX 中使用到的最重要的一些功能来介绍。什么是最重要呢,如果你因为不知道一个东西导致看代码如同看天书,那这个东西就属于最重要的范畴。

这篇文章假定你至少熟悉 CPU 的基本知识,比如什么是寄存器。最好知道代码最终是如何被 CPU 读取执行的,能读懂简单的汇编并且有扎实的 C 语言基础。

这里所有内容都站在软件开发者的角度,不涉及电气工程半导体制造。

x86 总览

目前 PC 上使用最广泛的 CPU 架构是英特尔的 x86。理所当然地,这里的描述都针对 x86 架构。

x86 架构总览如下图所示:

An Overview of IA-32

图片来自 Intel 的官方文档,文章末尾附有下载链接。在图中找一下 GDT LDT IDT TSS 这些词语,会发现几乎整个图的上半部分区域都围绕着它们,这些数据结构服务于两个重要功能:内存管理和中断处理(多任务切换)。下面也着重介绍它们。图片下半部分是关于虚拟内存的 Paging 功能。

Segment, GDT/LDT

时光倒退回学生时代,假设你因充值游戏点卡导致剩下的钱只够买一个作业本,但却同时要写语文数学英语三个科目的作业,你会怎么做呢?我能想到最简单的方案是混在一起写,写完数学写英语,写完英语写语文。突然某一天,英语老师怀疑你从来没写过作业,要你上交整个学期的英语作业进行检查。这时候如果把唯一的作业本直接交上去,你今晚就没法写其他作业了。最初如果不混在一起写,而是把前 100 页拿来写数学,101 页到第 200 页之间拿来写英语,问题是不是得到了解决?你把作业本从第 101 页到第 200 页裁下来交上去就行了。如果不想显得自己太放纵不羁,还可以自己粘个封面。

这就是 Segmentation

计算机内存就面临上面的作业本问题,物理内存只有一个,所有的进程都要使用它。假定现在有 4096 bytes 的内存,全局地址范围为 0 ~ 4095,我们将其分成了 4 块区域用来存储不同的数据,每个 Segment 内可以用自己的相对地址来访问,例如 Segment1 这个分段内的第 10 个字节,相对地址为 10,转换成全局地址就是分段的BaseAddress 2048 加上相对地址 10 等于 2058。每个分段都有 Limit 限制,即分段大小也就是最大的相对地址,如果试图访问相对地址 1025,通过 Limit 能判断出来越界了,这也是 Segmentation 的设计目的之一,对内存提供了部分保护功能。 Segmentation

同样的x86 CPU 在 Protected-Mode 下访问一个内存地址,是由 SegmentOffset 两部分来确定的。GDT/LDT 都服务于内存分段。

///

Protected-Mode 是 x86 的一种工作模式,现如今除了开机 boot 阶段处于 Real-Mode,大部分操作系统都运行在这个模式下。Protected-Mode 比 Real-Mode 新增了很多功能,比如 Paging。最显著的一个差别是 Real-Mode 为 16bits,Protected-Mode 为 32bits(这里不讨论 64bits)。

Descriptor Table

GDT/LDT (Global Descriptor Table/Local Descriptor Table) 是保存 Segment Descriptor 的内存区域,可以看成一个数组。GDT为全局 Descriptor Table,只有一个,LDT 可以有多个。每个 LDT 本身也有一个 Descriptor 存在 GDT 中。

Segment Descriptor

SegmentationDescriptor

Segment Descriptor 包含了 Segment 的各种信息,包括 BaseAddress,Limit,权限等。 结构很复杂,但我们一眼望过去可以看到关键信息,BaseAddress Segment LimitBaseAddress分成三个部分去存储,加起来 32bits。Limit只有 20bits,意味着如果按 byte 为单位,每个 Segment 最大只能有 1M,显然不够,所以有 G 这个标志位,如果 G 位被设置,Limit 会变成 4KB 为单位,最大支持 4GB LimitTYPE 用来指定该内存块存储的是代码,还是数据或者是其他类型。 DPL 为权限位,这个在其他很多地方会见到,用来隔离不同层的程序,内核的权限一般为 0 即最高权限,用户程序一般为 3

Segment Selector

SegmentationSelector

Segment SelectorSegment DescriptorGDT/LDT 中的“下标”。 TI 位告诉 CPU 应该去 GDT 还是 LDT 中寻找 Segment DescriptorRPL 为权限位,类似上面 DPL。第 3~15 位为 Index,意味着 Index 一定是 $2^3=8$ 的倍数,这是有意设计成这样的,因为每一个Segment Descriptor的长度刚好为 8 bytes。

了解了上面的术语,我们再来看整个内存访问过程。以指令为例,指令段的 Segment RegisterCS ,里面保存了代码段的 Segment SelectorOffset 存在 EIP 寄存器中。CPU 根据 CS寄存器去 GDT/LDT 里加载 Segment Descriptor,利用其中的 BaseAddress加上 EIP 中的 Offset 得到最终的物理内存地址或者虚拟地址——取决于是否启用了 Paging

x86 addr

最后列举几个最重要的 Segment RegisterOffset:

  • Stack Segment (SS). SS:ESP 当前栈顶地址
  • Code Segment (CS). CS:EIP 下一条指令地址
  • Data Segment (DS). DS:[ADDRESS] 数据地址

数据地址需要额外用代码举例说明,例如如下的汇编代码:

asm ///
mov eax, (0x000f)

是将 DS:0x000f 处的内存内容读取到 eax 寄存器中。

完整的 x86 内存访问如下图所示,分为 SegmentationPaging 两个阶段 x86-seg-paging

MINIX 在 3.1.4 版本才支持 Virtual Memory,3.1.0 使用的是纯 Segmentation 模式,没有使用到 Paging。所以这里就不介绍 Paging 了。

值得注意的是 Paging 是个非常重要的功能 ,而 Segmentation 实际上已经废弃,包括 Linux 在内的大部分现代操作系统使用的是 Flat Memory Model,一种纯 Paging模式,不使用 Segmentation。我最喜欢的 Paging 介绍是 The Paging Game

Interrupt, IDT, TSS

要理解操作系统怎样在多任务之间切换,系统调用怎样实现从用户空间进入 kernel 空间,必须要理解 Interrupt 中断和涉及到的重要数据结构 IDT(Interrupt Descriptor Table) 和TSS(Task-State Segment)。

把你自己看成一个 CPU,现在正在写代码,这时候来电话了,你停下手中的活去接电话——这就是中断 InterruptInterrupt 来源可能是硬件也可能是软件。硬件中断比如时钟中断,每隔一小段时间 CPU 会收到该中断,CPU 利用它在不同任务之间切换。软件中断也叫 trap,最典型的软件中断是 sytem call 系统调用。interrupt 是异步方式的中断,电话铃声"主动"响起来,你才去接电话,是电话先动的手,这就是异步方式。反过来trap是同步方式,手机就躺在那里啥也没干,你主动停下手中的活拿起手机刷抖音,你先动的手,就是同步。

Interrupt Descriptor Table

GDT``LDT一样,IDT也是个存储 8-byte Descriptor 的数组,保存着中断处理函数interrupt-handler的信息,发生中断时 CPU 会根据IDT找到中断处理代码位置。

Interrupt Descriptor

Interrupt Descriptor

又是熟悉的 Descriptor,显然里面放了上面一节提到的 Segment 相关的信息。Interrupt GateTrap Gate几乎一模一样。 Segment SelectorOffset 占去了大部分空间,这两个字段组合起来指定了中断处理函数的地址。DPL 仍然是权限位。Task Gate是 下面要说明的 TSSSegment Selector,是给 hardware task switch 功能使用的,也就是 CPU 原生支持的任务切换,包括 MINIX 在内,大部分操作系统都没有用这个功能,而是自己实现的 software task switch。为什么呢,因为这是 x86 的独有功能 ,如果用了它,去 arm 上又得重现实现一遍任务切换。

Interrupt 处理函数的地址是根据Interrupt的编号计算出来的 IDTR-IDT

观察上图的右下角区域,Interrupt #n 的 GateIDT 中的下标地址刚好是 $n \times8$。这里有几个英特尔取的高大上名词:每个Gate就是一个 Interrupt DescriptorIDTR(Interrupt Descriptor Table Register)是保存 IDT信息的寄存器,从图中的 BaseAddressLimit应该可以联想到刚刚介绍的 SegmentIDT 就存在这个 Segment 里。

TSS

TSS

TSS 是用来支持任务切换的数据结构,乍一看很复杂,好消息是绝大部分是给硬件任务切换功能用的,而如同上面所说,MINIX 并没有用到。只有 SS0ESP0 两个字段,这两个字段对应着中断发生时权限等级为 0 (也就是 kernel 的权限等级)的栈地址,CPU 会根据这两个字段设置 SSESP 寄存器的值。直接这么说很抽象,在实际代码中会意识到这是个非常关键的步骤,因为它将运行栈切换到了内核空间,当然这最终还是取决的于操作系统的设计者,如果你非要在 SS0ESP0 中放一些跟 kernel 毫无关系的地址,英特尔也不会拦你。

介绍完了两个关键名词,现在可以看中断发生时 CPU 的处理流程了:

TSS

Interrupt Vector ,又是个看起来非常高端的名词,实际上,它就是个数字编号……还记得上面的 Interrupt #n 吗?n 就是它的 Vector。比如除零会引发一个 Divide Error 中断,它的 Interrupt Vector 是 0。 CPU 根据 Interrupt 的编号找到 IDT 中的 Interrupt Descriptor,使用其中的 Segment SelectorGDT 或者 LDT 取到处理函数的 BaseAddresss ,加上 Offset计算出处理函数的内存地址。

如果处理函数的权限等级(位于内核空间)大于(数值上小于,数值越小权限越大)中断发生时代码的权限(位于用户空间),则从当前 TSS(虽然是当前,但 MINIX 以及 Linux 中都只有一个 TSS) 中取出跟处理函数权限等级对应的 SSESP,例如 MINIX 中断处理函数都位于 kernel 空间,权限等级为 0,意味着会使用 SS0ESP0 来设置寄存器。处理函数的权限是由什么决定的呢,回顾 Segment Descriptor 中的DPL字段。

接下来 CPU 将当前的 SSESP 压入栈中,注意这里很容弄混淆,是将中断发生前的 SS:ESP,即被中断的程序的 SS:ESP 压入到刚刚重新设置过的 SS:ESP所指向的栈。想象你有一堆作业本,SS 里面存着作业本的名字,ESP 保存你正在用这个作业本的第几页。你正在数学作业本(SS=数学)的第 10 页(ESP=10)写作业,此时闹钟响了提醒你要换科目(中断),你根据先前定的计划翻开了语文作业本(SS=语文)的第 20 页(ESP=20),并在该页写下了旧的 SS:ESP,即数学:10。相信你足够聪明会意识到,写完语文作业后,根据记录下来的信息,你能直接打开数学作业本的第 10 页继续写作数学作业。

接下来继续压入 EFLAGS(状态寄存器,记录一些标志位),CS,EIP(当前指令地址)。当中断处理函数执行结束后,这些信息拿来恢复中断发生前的执行状态。

如果权限等级一致,则不走第一步设置 SS:ESP 的流程,直接在当前栈操作。但 MINIX 中,在 kernel 空间执行时禁止了中断,不会发生这种情况。

两种情况的栈状态下图所示: interrupt-stack

中断处理完后使用 IRET 指令继续执行被中断的任务,IRET会从栈中恢复中断发生时保存下来的寄存器状态。

试想一下平时写代码,中途突然遇到紧急需求要处理,会先用 git stash 保存一下当前的工作进度。处理完后用 git stash apply 恢复先前的工作环境。CPU 也是如此处理中断的。

总结

以上站在一个非常抽象的角度窥探了 x86,没有深究细节。这里没有列举代码,可能会造成一定程度的迷惑,在阅读操作系统代码时,我们会看到实际中内核是怎样利用这些数据结构的。如果对更多细节感兴趣,可以进一步选读 Intel 文档中的有关的章节: Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3A: System Programming Guide, Part 1