【学习xv6】从实模式到保护模式

前言

这是一系列 xv6 代码学习的总结。对于还不知道 xv6 是什么的同学,我这里只简单说一下:xv6 是一个教学用的操作系统,基于 unix v6,再具体的请大家自行 Google 一下 wiki 什么的。

配合这个系列的文章,我在我的 GitHub上建立了一个叫xv6_learn的项目,这个项目就是在 clone 自 xv6 官方源代码的基础上在源码文件中加入了我学习过程中的大量注释。所以在大家看这一系列文章的同时结合着源代码文件中我加入的注释来看可能效果更好一些。下面言归正传讲 xv6 之前先预习一下用到的一些知识。

预备知识

程序 = 数据 + 指令

无论是操作系统还是运行在操作系统上的软件,对于计算机来说他们都是程序。而程序的组成我们可以简单的理解为:数据加上指令就是程序。当一个程序被从硬盘加载到内存后,CPU 从内存读取程序中的指令执行,执行过程中需要从内存中读取程序的数据,配合指令计算出结果之后还需要放回到内存中。这就是简化后的程序执行过程。

如何从内存读取指令和数据

x86 使用“段基址 + 偏移量”的方式来读写内存。这就好比问路,当你向一个人问路时,一般人们回这么回答你:“从前面那个路口开始,往前再走三个路口就到了”。x86 CPU 对内存的寻址也是这个思路,“前面那个路口”就指的是“段基址”,“往前再走三个路口”指的就是“偏移量”,有了这两个线索,CPU 也可以顺利到达内存中的目的地写入或取走数据或指令。

为什么有个“段”字

有人可能会问“段基址”里面的“段”代表什么呢?前面说了,程序是由数据和指令组成的,一个程序要运行就先要加载到内存中。而程序中的数据和指令是两个相互独立的部分,CPU 从内存读取他们的时候也是将他们看作是不同的“段”。这里还要插一句,程序中的数据还要分很多种类型,所以 CPU 针对一个程序的不同部分准备了 4 个寄存器来分别存储他们的“段基址”。这 4 个寄存器分别是用于程序指令的 CS 代码段寄存器、用于程序数据的 DS 数据段寄存器、用于程序堆栈(也是数据的一种)的 SS 堆栈段寄存器和 ES 附加段寄存器(也是数据的一种)。

有了这 4 个寄存器存储“基地址”(数据的存放起始点),再配合“偏移量” CPU 就可以从内存读写数据和指令了。例如 CPU 在从内存中读取一个程序的指令准备执行的时候就可以说:“从 CS 指向的地方开始向后读取 2 个位置”,内存收到 CPU 给的“指路信息”后就会把相应位置的指令发给 CPU,CPU 拿到指令就可以开始执行了。

“段基址” + “偏移量” 寻址方式的由来

了解了 x86 的内存寻址方式,不禁要问:“为什么要这么设计?”这得从英特尔的 8086 CPU 开始讲起。我们有时说起计算机硬件配置的时候经常会说:“我的电脑是 32 位的”。这里的 32 位起始指的是 CPU 内部的“数据总线”宽度,也叫 AUL ALU(算数逻辑单元,感谢 Zongren Zhang 同学找到错误并指正)的宽度。说白了就是 CPU 一次性传递数据的宽度。

英特尔的 8086 CPU 是 16 位的,如果直接用来表示内存地址的话,16 位最大可以表示的内存地址是 216 = 65536 个地址,每个地址代表一字节的内存数据的话,16 位最多只能支持 64KB 内存,这显然是不够用的。于是英特尔在保持数据线宽为 16 位的同时将地址线的宽度增大到 20 位,也就是说内存地址是 20 位的,这样就可以拥有 220 = 1048576 个地址,最多支持 1MB 的内存,当时的人们认为这样就足够了。

现在问题来了,16 位的数据线宽(寄存器自然也是 16 位的)如何能表示 20 位的地址呢?答案是用两个 16 位的寄存器来表示。这就是“段基址” + “偏移量”寻址方式的由来。一个 16 位的寄存器来表示“段基址”(CS、DS、SS、ES四个寄存器),具体的做法是先将 16 位的“段基址”左移 4 位,然后加上 16 位的“偏移量”最终得到 20 位的内存地址送入地址线。

地址卷绕

用两个 16 位的寄存器左移相加来得到 20 位的内存地址这里还是有问题。那就是两个 16 位数相加所得的最大结果是超过 20 位的。例如段基址 0xffff 左移变成 0xffff0 和偏移量 0xffff 相加得到 0x10ffef 这个内存地址是“溢出”的,怎么办?这里 CPU 被设计出来一个“卷绕”机制,当内存地址超过 20 位则绕回来。举个例子你拿 0x100001 来寻址,我就拿你当作 0x00001 。你超出终点我就把你绕回起点。

向下兼容的现代 x86 计算机

8086 的年代已经远去。现在的 x86 已早经是 32 位的了(目前 32 位基本已经没有了,64 位是主流了)。但无论位数如何增加,寻址能力如何增大,x86 一直保持着向下兼容的良好传统。即便是当初为 8086 这种 16 位机器写的软件或操作系统(如 DOS)仍能够在现在的 x86 计算机上顺利运行。

那么这种良好的向下兼容性是如何实现的呢?答案是:“开关”。现代的 x86 计算机,无论你是 32 位的还是 64 位的,在开机的那一刻 CPU 都是以模拟 16 位模式运行的,地址卷绕机制也是有效的,所以无论你的电脑内存有多大,开机的时候 CPU 的寻址能力只有 1MB,就好像回到 8086 时代一样。

那么什么时候才结束 CPU 的 16 位模式运行呢?这由你(操作系统)说了算,现代的计算机都有个“开关”叫 A20 gate,开机的时候 A20 gate 是关闭的,CPU 以 16 位模式运行,当 A20 gate 打开的时候“卷绕”机制失效,内存寻址突破 1MB 限制,我们就可以切换到正常的模式下运行了。具体如何打开 A20 gate,下面分析 xv6 的源代码时我会详细说明。

再说说把程序加载到内存

我们编写程序代码,编译器将我们的程序代码变成 CPU 可以理解的指令(二进制可执行程序)。在运行我们写的程序前,需要将程序先加载进内存,而我们的程序应该加载到内存的什么位置这应该是由操作系统来负责的,我们程序本身是不能决定这一切的。

这里就产生了一个“矛盾”。在程序真正运行前我们是不知道我们会被放在内存的什么地方,但是我们的程序本身还有数据,代码也含有对数据的访问(例如我们的代码中使用的各种变量),我们不知道我们的数据会被操作系统放在哪,但我们还要在代码里写访问这些数据的逻辑,这是一个矛盾,要怎么办?

解决上述矛盾的办法就是使用相对地址访问。我们的程序在运行前不知道会被操作系统放在内存的什么地方,所以我们在编写程序的时候会做个假设,假设我们的程序会被放在从内存地址 N 开始向后的地方。这个时候我们的程序在访问我们的变量时都继续这个假设,加入我们想要读取我们的变量 a 时,我们就编写指令说我们要访问 N + X 的内存地址,那里存放着我们的变量 a,当然这些假设和生成每个数据的相对访问地址的工作都由编译器代劳了,对于我们程序的编写来说不用为这些事情而烦恼。

所以我们的每一个程序都会基于一个统一的假设:“我们会被从内存地址 N 开始放置”,至于到真正运行时这个 N 对应的内存地址具体是多少无所谓,因为我们对我们程序数据的访问都是相对于 N 的偏移。这就好比说:“我在距离你左边 20 米的地方”,无论你在哪,在火星上也罢,向左走 20 米,你总能找到我。

程序是“假设”的,操作系统要动“真格”的

上面说了,所有的程序都基于一个相同的“假设”,但是当程序真正运行的时候,操作系统将程序加载到内存时就不能对程序的这个“假设”听之任之了。当操作系统把程序放置到真正的内存位置后,程序运行起来,程序基于假设 N + X 计算出的内存地址就需要操作系统“翻译”成真正的内存地址后才能真的从内存中读取到想要的数据,而这个“翻译”的过程就需要操作系统和 CPU 来配合实现了。

程序基于“假设”计算出的地址叫做“虚拟地址”也叫做“逻辑地址”(他们是一样的,只是叫法不同),与之对应的内存的真实地址叫做“物理地址”,从“虚拟地址”到“物理地址”的转换是通过一个叫做 MMU(内存管理单元)的硬件实现的,当然这里还少不了操作系统的配合。

从“虚拟地址”到“物理地址”,计算机硬件与操作系统的配合为在操作系统上运行的各种程序提供了“智能”、“安全”、“高效”的运行环境,好处多多。比如程序通过这种假设,统一了虚拟的内存布局,从程序开发层面屏蔽了内存规划的复杂性,运行环境的差异性等,程序只需要关系自己的逻辑,内存布局的事情交给操作系统来负责。另一方面,每个程序运行在各自的内存空间上,彼此处于相互隔离的状态,程序之间无法操作自己内存空间以外的内存,这也增加了程序运行的安全性。

实模式与保护模式

罗马不是一天建成的。上面所说的系统硬件和操作系统配合建立的“智能”、“安全”、“高效”的运行环境也是后来才逐渐完善的。所以为了区分这两种环境,在“智能”、“安全”、“高效”的运行环境建立之前计算机是运行在“实模式”下的,在“实模式”下没有“虚拟地址”到“物理地址”的转换,“虚拟地址”就相当于是“物理地址”,而想要这些特性就需要对应的把计算机的运行环境切换到“保护模式”下。

就像之前我们讲到的 A20 gate 从 1MB 的内存寻址模式切换到更大的寻址能力一样。x86 架构的计算机为了向下兼容,开机的时候不仅运行在 1MB 内存寻址环境下,这时候也是运行在“实模式”环境下的。同样有一个开关控制着从“实模式”到“保护模式”的切换,这个开关叫“控制寄存器”。

保护模式下的分段与分页

前面说道“保护模式”是由硬件和操作系统配合来提供的。“保护模式”涉及的知识非常多,不仅仅只有对内存的管理,还有诸如进程管理、硬件管理等诸多方面,这里只简单介绍一下“保护模式”下的内存管理。“保护模式”实现的两种内存管理方式:“分段式和分页式”。分页式是目前主流操作系统(Windows、Linux、FreeBSD等)所采取的内存管理方式。

“分页式”技术的出现要比“分段式”晚一些,碰上 x86 这样历史悠久的硬件架构就不得不再提“向下兼容”了。所以 x86 的分页式的实现是继续分段式基础上的。所以想要在 x86 上建立起分页式的内存管理就先要建立分段式内存管理,分页式我们暂且不说,先说说分段式。

分段式简单来说就是将内存规划出不同的“片段”来分配给不同的程序(也包含操作系统自己)使用。分页式则是将内存规划成大小相同的“页”,再将这些页分配给各个程序使用。

这里有两个“段”字非常让人容易迷糊。分段式里的段与之前讲过的“段基址”完全是两码事儿。实模式下的段寄存器里的“段基址”实际上还可以算作内存物理地址,它指向的是内存中的一个位置,而在分段式的保护模式下段寄存器里的“段基址”的意义已经发生里改变,它不再是内存的物理地址,而是指向一个内存分段的段索引。在分段模式下,内存被划分为很多个“片段”,程序数据以及指令就放在这些片段中,当要读取内存中具体的数据时,首先要直到这个数据在哪个“片段”里,这时段寄存器里的“段基址”指向某一个内存片段的下标,而这时的“偏移量”则相应的表示为具体的数据在它所在的内存“片段”里的偏移量。

所以在分段模式下,内存里会有一个“表”,这个“表”里存放了每个内存“片段”的信息(如这个“片段”在内存中的地址,这个“片段”多长等),比如我们现在将内存分成 10 个片段,则这时我们有一个“表”,这个“表”有 10 项分别存放着对应这 10 个内存片段的描述信息。这时我有个数据存放在第 5 个片段中,在第 5 个片段的第 6 个位置上,所以当我们想要读取这个数据的时候,我们的数据段寄存器里存放的“段基址”是 5 这个数,代表要去第 5 个片段上找,对应的这时候的“偏移量”就是 6 这样我们就可以顺利的找到我们想要的数据里。

而要想实现在分段式保护模式下成功的寻址,操作系统需要做的就是在内存中建立这个“表”,“表”里放好内存分段的描述信息,然后把这个“表”在内存的什么位置,以及这个“表”里有多少个内存分段的描述信息告诉 CPU。这个“表”有个学名叫 GDT 全局描述符表,这个我们后面还会有介绍。

分段式的“段基址” + “偏移量”寻址方式

在“实模式”下我们讲到内存的寻址方式是“段基址” + “偏移量”,他们生成的结果就是直接可用的内存物理地址,但是到了分段式的保护模式下我们有了 GDT,GDT 里面有了段描述符,段描述符里存储的才是真正的内存物理地址,所以这里我们的“段基址”和“偏移量”的意义都发生了变化。

在分段式的保护模式下,16 位的“段基址”不再表示内存的物理地址,而是表示 GDT 表的下标,用来根据“段基址”从 GDT 表中取得对应下标的“段描述符”,从“段描述符”中取得真实的内存物理地址后在配合“偏移量”来计算出最终的内存地址。

一个简单的比喻

说了那么多内存寻址的事儿,说到底无论是程序还是操作系统(其实也是程序),最后到计算机那里都会变成 CPU 从内存通过寻址读取指令和数据执行而已。无论是实模式下的“段基址”+“偏移量”还是保护模式下的“段基址”+“偏移量”,寻址的过程都是十分类似的。为了不让大家脑子里那么乱,这里我在打一个比喻来帮助大家理解“内存寻址”的过程。

内存就好比一个大仓库,这个仓库里有好多好多货架用于存放货物(指令和数据)。我们的操作系统就是这个仓库的管理员,而 CPU 就是这个仓库的小工,这时我们送来一个货物(程序),这个货物有两个大箱子,一个箱子贴着“代码”的标签,另一个贴着“数据”的标签。贴着“代码”标签的箱子里按顺序放着一张一张写着字的纸条(指令),另一个贴着“数据”标签的箱子里放着我们自己按照自己想要的顺序码放好的物品(数据)。这时我们把这个货物(程序)交给仓库管理员(操作系统),看看会发生什么。

管理员(操作系统)拿到我们的货物(程序),先将贴着“代码”标签的箱子放到仓库的某一个货架上,比如放在了 3 号货架上,并在小本本上(代码段寄存器)记下这个箱子放在了 3 号货架上。然后又将贴着“数据”标签的箱子放到 5 号货架上,并在小本本上(数据段寄存器)记录下这个箱子放在了 5 号货架上。接下来就该仓库小工(CPU)工作了。

仓库小工按照小本本上(代码段寄存器)记录的地址跑到 3 号货架上找到那个贴着“代码”标签的箱子, 按顺序先抽出了箱子里的第一章小纸条(指令),上面写着“我要贴着数据箱子里的第 6 个物品”,这时仓库小工跑去看了一眼量外一个小本本(数据段寄存器),直到贴着“数据”标签的箱子是放在 5 号货柜的,于是仓库小工到了 5 号货柜找到了那个箱子,从箱子里数到第 6 个物品(偏移量)把它拿了出来。

这就是一次内存寻址的过程。我们在写程序的时候,也就是我们准备我们的货物时,我们可以按照我们想要的顺序来码放我们的物品到箱子里(只关心偏移量),当我们把我们的程序写好准备真正去执行的时候,也就是货物准备好交给仓库管理员的时候,仓库管理员按照他自己的想法把我们的货物放在货柜上,并记下我们的箱子都放在哪个货柜(只关心段寄存器里的段基址),等到仓库小工忙活起来的时候拿着货柜号和我们想要的物品在箱子里的相对位置就能够顺利找到我们想要的东西了,这就是“段基址”+“偏移量”的寻址方式。

而什么保护模式之流无非是仓库小工在按照“段基址”+“偏移量”取货的前额外的验证了一下要去的东西到底是不是你的(程序要读取的数据是否属于该程序),你说要箱子里的第 6 个物品,取货前在额外看看你箱子里到底是不是真的有 6 个以上的物品,而取货的流程本质上是没有发生变化的。

物理地址、线性地址、逻辑地址(虚拟地址)、虚拟内存

关于内存寻址和内存管理方式已经说了一大堆里,这里通过帮助大家彻底理清上面这四个概念来让大家对内存这块有个整体的认识。

  • 物理地址

这个没什么可说的,非常好理解,物理地址就是内存从硬件角度上真正的地址。所有对内存的寻址最终都要转换到物理地址上才能被识别。

  • 逻辑地址(虚拟地址)

这两种叫法说的是一种东西。就是我们上面讲的程序基于统一的“假设”通过 N + X 计算出的内存地址。

  • 线性地址

线性地址的概念是保护模式下才有的,在实模式下逻辑地址就是物理地址,在保护模式下还要根据分段和分页分开说。在分段模式下逻辑地址通过 GDT 转换成线性地址,此时如果没有分页机制那么线性地址就是物理地址,如果有分页机制,那么线性地址要通过 MMU 再一次转换之后才能变成物理地址。

  • 虚拟内存

我们以 32 位计算机为例,在 32 位计算机上支持的最大内存寻址是 4GB,但是每个计算机上真正有多少内存却是不一定的。同样的 32 位计算机,有的可能只有 1GB 内存,有的只有 2GB 内存,而对于程序来说不应该收到这种硬件配置的影响,无论有多少内存,程序都应该正常运行。这就提出里虚拟内存的概念,就像我们之前说的程序的统一假设一样,对于每个程序来说,我们都统一假设只要你的寻址位宽是 32 位,那我就假设我有 4GB 内存可以利用。而具体有多少内存,如何和逻辑地址对应,这是操作系统需要考虑的事情了。

这里多说两句。有的人会有疑问:“我的32位计算机确实只有 1BG 内存,而你说程序当我是 4GB 内存,多余的 3GB 从哪来?”。其实很简单,还是从你的 1GB 物理内存上来。首先要确定的是在你的程序运行的某一时刻你不可能把 1BG 内存完全占用,即便你的程序真的把 1BG 物理内存全部占用里,在某一时刻你需要再向内存写入数据的时候,CPU 会先去内存中找到一个你这一刻不会用到的数据,将这个数据从内存换出到硬盘上,然后将你要写入的数据放入内存中。等之后的某一个时刻你的程序又想从内存中读取刚刚传出到硬盘的那个数据时,CPU 会再次通过同样的办法把一个不用的数据换出到硬盘再把你要的数据换回到内存中来。

大小端模式

在准备往下看的时候你会发现我在下面放了几个表格用来表示数据在寄存器或内存中的存储结构,这些表格都是按位来排列的。看这些表格的时候你可能会奇怪这些表格的位序号为什么都是从高到低的,这是因为 x86 是“小端模式”。

我们直到计算机中的数据就按照“字节”位单位存放的,就好像我们写字,当你写一个字的时候没什么问题,但是当你要写一句话的时候就有是“从做往右”还是“从右往左”写的问题。而计算机也一样,当内存或寄存器存储的数据超过 1 字节的时候也会有一个数据摆放顺序的问题。这就是所谓的大小端模式。

  • 大端模式 : 地址的增长顺序与值的增长顺序相同
  • 小端模式 : 地址的增长顺序与值的增长顺序相反

比如我们有一个 16 位(两字节)的数据 0x2345,要存放在内存地址 0x00000010 这个位置上,如果按照大端模式存储就是下面这个样子

内存地址0x000000100x00000011
数据0x230x45


如果是小端模式则是

内存地址0x000000100x00000011
数据0x450x23


而我们书写代码的习惯是从左往右写,则 x86 的小端模式如果按照内存地址位从高到低的方式来看,数据就是从左往右的正常顺序,这样我们看上去比较直观。

预备知识总结

说了一大堆,该铺垫的知识基本准备的差不多里,接下来我们就要具体分析 xv6 的代码实现里。这里我们总结一下上面介绍的预备知识,来说说作为一个操作系统在计算机启动后到底应该做些什么:

  • 计算机开机,运行环境为 1MB 寻址限制带“卷绕”机制
  • 打开 A20 gate 让计算机突破 1MB 寻址限制
  • 在内存中建立 GDT 全局描述符表,并将建立好的 GDT 表的位置和大小告诉 CPU
  • 设置控制寄存器,进入保护模式
  • 按照保护模式的内存寻址方式继续执行

好了,下面我们正式进入 xv6 启动阶段的代码学习。

从 Makefile 开始

从一个操作系统的角度来说,xv6 的代码量并不大,总共不到一万行,分散在众多的源文件中。一上来可能觉得很迷茫,这么多文件,该从哪个开始看起?Makefile 则是这些文件的“目录”,通过它可以很容易找到头绪。

什么是 Makefile?如果你问起这个,那你还不适合看这个系列的文章,还是那句话,多 Google 吧。继续言归正传。

上一篇《【学习 Xv6 】在 Mac OSX 下运行 Xv6》中说道 xv6 编译成功后会生成两个文件:xv6.img 和 fs.img 我们从 xv6.img 开始。

从 Makefile 中可以看到 xv6.img 的生成条件:

1
2
3
4
xv6.img: bootblock kernel fs.img
  dd if=/dev/zero of=xv6.img count=10000
  dd if=bootblock of=xv6.img conv=notrunc
  dd if=kernel of=xv6.img seek=1 conv=notrunc

fs.img 这里暂且不说,通过字面不难看出 bootblock 应该是系统一开始引导阶段的逻辑,kernel 当然就是内核了。所以第一步先研究 bootblock。我们接着在 Makefile 里找 bootblock 的生成条件:

1
2
3
4
5
6
7
bootblock: bootasm.S bootmain.c
  $(CC) $(CFLAGS) -fno-pic -O -nostdinc -I. -c bootmain.c
  $(CC) $(CFLAGS) -fno-pic -nostdinc -I. -c bootasm.S
  $(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o bootblock.o bootasm.o bootmain.o
  $(OBJDUMP) -S bootblock.o > bootblock.asm
  $(OBJCOPY) -S -O binary -j .text bootblock.o bootblock
  ./sign.pl bootblock

bootblock 的生成只需要两个文件,一个汇编一个 C 源码。那我们就准备讲讲 bootasm.S 文件了。

x86 的启动

看具体的代码前先说说 x86 架构开机引导的相关知识。从给 x86 通电的一刻开始,CPU 执行的第一段指令是 BIOS 固化在 ROM 上的代码,这个过程是硬件定死的规矩,就是这样。

而 BIOS 在硬件自检完成后(你会听到“滴”的一声)会根据你在 BIOS 里设置的启动顺序(硬盘、光驱、USB)读取每个引导设备的第一个扇区 512字节的内容,并判断这段内容的最后 2 字节是否为 0xAA55,如果是说明这个设备是可引导的,于是就将这 512 字节的内容放到内存的 0x7C00 位置,然后告诉 CPU 去执行这个位置的指令。这个过程同样是硬件定死的规矩,就是这样。

有了上面的介绍我们再回到 xv6 如果你看一下编译生成的 bootblock 二进制文件,你会惊喜的发现它的文件大小刚好是 512 字节。用十六进制编辑器(我在 Mac OSX 下用的是 0xED 这个软件)打开 bootblock 这个二进制文件,你又会发现这个 512 字节的文件的最后两字节正好是 0xAA55。

再回过头看上面 Makefile 中 xv6.img 生成条件的代码中也可以看出 xv6.img 就是通过 dd 命令讲编译好的 bootblock 和 kernel 拼接而成,这也再一次印证了 bootblock 是负责引导逻辑的结论。

有了这个结论,我们可以开始“放心大胆”的开始看 bootasm.S 这个汇编源文件的代码了。

bootasm.S 文件

看 bootasm.S 文件需要你有一定的汇编基础。没有也没关系,我尽量解释的清楚一些。

还是再看一眼 Makefile 里 bootblock 生成那段有这么一句

1
$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o bootblock.o bootasm.o bootmain.o

这段说明 bootblock 的代码段加载到内存 0x7C00 处,代码从 start 处开始执行(可以理解为相当于 C 中的 main 这样的入口函数)。所以 bootasm.S 一上来就是入口 start

1
2
3
4
5
6
7
8
.code16
.global start
start:
    cli
    xorw    %ax,%ax
    movw    %ax,%ds
    movw    %ax,%es
    movw    %ax,%ss

先讲 start:下面的这 5 行代码。

cli 指令关闭了中断响应,意味着从这一刻开始你的计算机将不再响应任何中断事件(比如这时候你敲个键盘点个鼠标啥的,CPU 就不再理你了)。之所以要关闭中断响应是因为要保证引导代码的顺利执行(总不能执行到一半被 CPU 给中断了吧,那直接就挂了)。

接下来的 4 行代码显示用异或将 %ax 寄存器的值置成 0,然后在用 %ax 寄存器的值将 %ds、%es、%ss 三个寄存器的值全部置 0,相当于初始化了。

然后我们再看 .code16 这句。这告诉 CPU 我们目前是在 16 位模式下执行代码,此时内存寻址能力只有 1MB,并且是“实模式”下。

打开 A20 gate

在预备知识那段我们讲里要想计算机突破 1MB 内存寻址的限制我们要把 A20 gate 打开,我们接着往下看 xv6 bootasm.S 的代码。在初始化好寄存器后,xv6 bootasm.S 接下来要做的事情就是打开 A20 gate 突破 1MB 内存寻址的限制。

控制 A20 gate 的方法有 3 种:

  • 804x 键盘控制器法
  • Fast A20 法
  • BIOS 中断法

xv6 用了第一种 804x 键盘控制器法,这也是最古老且效率最慢的一种。当然因为硬件的不同,这三种方法可能不会被硬件都支持,正确的做法应该是这三种都尝试一下,每尝试一个就验证一下 A20 gate 是否被正确打开以保证兼容各种硬件。但是 xv6 作为一款教学用的操作系统就没必要做的这么复杂里。只用了一种最古老的方法(保证兼容大多数硬件)而且没有对打开成功与否做验证。像诸如 Linux 这样的操作系统就把三种方法的实现都做好里,并且加上了验证机制。

我们具体来看 xv6 的实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
seta20.1:  
  inb     $0x64,%al
  testb   $0x2,%al
  jnz     seta20.1 

  movb    $0xd1,%al
  outb    %al,$0x64

seta20.2:
  inb     $0x64,%al
  testb   $0x2,%al
  jnz     seta20.2 

  movb    $0xdf,%al
  outb    %al,$0x60

这里 bootasm.S 用了两个方法 seta20.1 和 seta20.2 来实现通过 804x 键盘控制器打开 A20 gate。 这个办法确实是分两步来搞的:

第一步是向 804x 键盘控制器的 0x64 端口发送命令。这里传送的命令是 0xd1,这个命令的意思是要向键盘控制器的 P2 写入数据。这就是 seta20.1 代码段所做的工作(具体的解释可以参看我在代码中写的注释)。

第二步就是向键盘控制器的 P2 端口写数据了。写数据的方法是把数据通过键盘控制器的 0x60 端口写进去。写入的数据是 0xdf,因为 A20 gate 就包含在键盘控制器的 P2 端口中,随着 0xdf 的写入,A20 gate 就被打开了。

接下来要做的就是进入“保护模式”了。

xv6 准备 GDT

在进入保护模式前需要将 GDT 准备好。什么是 GDT ?它的中文名称叫“全局描述符表”,前面的“预备知识”里已经做里介绍,想要在“保护模式”下对内存进行寻址就先要有 GDT,GDT 表里的每一项叫做“段描述符”,用来记录每个内存分段的一些属性信息,每个“段描述符”占 8 字节,我们先来看一眼这个段描述符的具体结构:

31302928272625242322212019181716151413121110 9 8 7 6 5 4 3 2 1 0
基地址 GDBXXAALimit PDPL S EEDRWA基地址
基地址Limit


三块“基地址”组装起来正好就是 32 位的段起始内存地址,两块 Limit 组成该内存分段的长度,接下来依次解释一下其他位所代表的意义:

  • P:       0 本段不在内存中
  • DPL:     访问该段内存所需权限等级 00 — 11,0为最大权限级别
  • S:       1 代表数据段、代码段或堆栈段,0 代表系统段如中断门或调用门
  • E:       1 代表代码段,可执行标记,0 代表数据段
  • ED:      0 代表忽略特权级,1 代表遵守特权级
  • RW:      如果是数据段(E=0)则1 代表可写入,0 代表只读;
             如果是代码段(E=1)则1 代表可读取,0 代表不可读取
  • A:       1 表示该段内存访问过,0 表示没有被访问过
  • G:       1 表示 20 位段界限单位是 4KB,最大长度 4GB;
             0 表示 20 位段界限单位是 1 字节,最大长度 1MB
  • DB:      1 表示地址和操作数是 32 位,0 表示地址和操作数是 16 位
  • XX:      保留位永远是 0
  • AA:      给系统提供的保留位

有了上述的解释,我们再来看看 xv6 是怎样准备自己的 GDT 的,代码在 bootasm.S 文件最底部:

1
2
3
4
gdt:
  SEG_NULLASM                             # 空
  SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)   # 代码段
  SEG_ASM(STA_W, 0x0, 0xffffffff)         # 数据(堆栈)段

这里用到了几个宏,具体的宏定义在 asm.h 文件中,为了方便大家直观的感受一下 xv6 的 GDT 我把宏计算出来的值直接翻译过来,代码应该是下面这个样子:

1
2
3
4
5
6
7
gdt:
  .word 0, 0;
  .byte 0, 0, 0, 0                             # 空
  .word 0xffff, 0x0000;
  .byte 0x00, 0x9a, 0xcf, 0x00                 # 代码段
  .word 0xffff, 0x0000;
  .byte 0x00, 0x92, 0xcf, 0x00                 # 数据段

然后我们再把代码段和数据段的段描述符具体每一位的对应值表展示出来,首先是代码段:

31302928272625242322212019181716151413121110 9 8 7 6 5 4 3 2 1 0
基地址 GDBXXAALimit PDPL S EEDRWA基地址
0x0011000xf100110100x00
基地址Limit
0x00000xffff


然后是数据段:

31302928272625242322212019181716151413121110 9 8 7 6 5 4 3 2 1 0
基地址 GDBXXAALimit PDPL S EEDRWA基地址
0x0011000xf100100100x00
基地址Limit
0x00000xffff


我们来一步步解释一下。首先说说这两个内存段的共同点,DB = 1,G = 1,基地址都是 0x00000000,内存分段长度都是 0xfffff,这说明他们都是用于 32 位寻址,所使用的内存是从 0 开始到 4GB 结束(全部内存)。这里是这么算出来的,段长度是 0xfffff = 220,G = 1 表示段界限单位是 4k,所以 4k * 220 = 4GB。

再说说他们的不同点,代码段的 E = 1 而数据段的 E = 0 这表名了他们的身份,身份不同 RW 的值虽然相同,但代表的意义也就不相同了,代码段的 RW = 1 代表可读取,数据段的 RW = 1 表示可读可写。这也和我们上面解释的保护模式所能够达到的目的相吻合。

当然作为一款教学为目的的操作系统,xv6 这里的 GDT 设置还是以简单容易理解为目的。诸如“权限位”这样的安全机制就直接被忽略了,而对内存的规划也没有做到真正的“分段”,而是代码段和数据段都启用了从 0 到 4GB 的全部内存寻址。其实这种内存规划方法叫做“平坦内存模型”,即便是 Linux 也是用的这样的方式规划内存的,并没有做到真正的“分段”。这是因为 x86 的分页机制是基于分段的,Linux 选用了更先进的分页机制来管理内存,所以在分段这里只是走一个必要的形式罢了。而 xv6 后面到底是否也启用了分页机制,我们目前还不得而知。

xv6 正式进入保护模式

GDT 也搞定了,接下来我们就要把我们刚刚在内存中设定好的 GDT 的位置告诉 CPU,然后就“万事俱备,只欠东风”了。CPU 单独为我们准备了一个寄存器叫做 GDTR 用来保存我们 GDT 在内存中的位置和我们 GDT 的长度。GDTR 寄存器一共 48 位,其中高 32 位用来存储我们的 GDT 在内存中的位置,其余的低 16 位用来存我们的 GDT 有多少个段描述符。 16 位最大可以表示 65536 个数,这里我们把单位换成字节,而一个段描述符是 8 字节,所以 GDT 最多可以有 8192 个段描述符。不仅 CPU 用了一个单独的寄存器 GDTR 来存储我们的 GDT,而且还专门提供了一个指令用来让我们把 GDT 的地址和长度传给 GDTR 寄存器,来看 xv6 的代码:

1
lgdt   gdtdesc

而这个 gdtdesc 和 gdt 一起放在了 bootasm.S 文件的最底部,我们看一眼:

1
2
3
gdtdesc:
  .word   (gdtdesc - gdt - 1)             # 16 位的 gdt 大小sizeof(gdt) - 1
  .long   gdt                             # 32 位的 gdt 所在物理地址

不多不少,正好 48 位传给了 GDTR 寄存器,到此 GDT 就准备好了,接下来我们进入保护模式!

前面预备知识中讲到,就如同 A20 gate 这个开关负责打开 1MB 以上内存寻址一样,想要进入“保护模式”我们也需要打开一个开关,这个开关叫“控制寄存器”,x86 的控制寄存器一共有 4 个分别是 CR0、CR1、CR2、CR3,而控制进入“保护模式”的开关在 CR0 上,这四个寄存器都是 32 位的,我们看一下 CR0 上和保护模式有关的位

31302928272625242322212019181716151413121110 9 8 7 6 5 4 3 2 1 0
PG其他控制位PE




  • PG    为 0 时代表只使用分段式,不使用分页式
             为 1 是启用分页式

  • PE    为 0 时代表关闭保护模式,运行在实模式下
             为 1 则开启保护模式

然后我们继续看 xv6 打开保护模式的代码:

1
2
3
movl    %cr0, %eax
orl     $CR0_PE, %eax
movl    %eax, %cr0

因为我们无法直接操作 CR0,所以我们首先要用一个通用寄存器来保存当前 CR0 寄存器的值,这里第一行就是用通用寄存器 eax 来保存 cr0 寄存器的值;然后 CR0_PE 这个宏的定义在 mmu.h 文件中,是个数值 0x00000001,将这个数值与 eax 中的 cr0 寄存器的值做“或”运算后,就保证将 cr0 的第 0 位设置成了 1 即 PE = 1 保证打开了保护模式的开关。而 cr0 的第 31 位 PG = 0 表示我们只使用分段式,不使用分页,这时再将新的计算后的 eax 寄存器中的值写回到 cr0 寄存器中就完成了到保护模式的切换。

准备迎接 .code32

到这里我们关于 xv6 从实模式到保护模式的讲解就接近尾声了。我们已经进入到保护模式了,接下来可以将代码彻底以 32 位的保护模式来运行了。所以这时我们的 xv6 也要准备跳转了,再来看一行代码:

1
ljmp  $(SEG_KCODE<<3) $start32

这是一个跳转语句,通知 CPU 跳转到指定位置继续执行指令。 xv6 在这时就准备跳转到用 C 写成的代码处去继续运行了。这个跳转语句的两个参数就是我们之前一直再讲的典型的“基地址” + “偏移量”的方式告诉 CPU 要跳转到内存的什么位置去继续执行指令。

而这时我们已经在分段式的保护模式下了,所以我们通过这句跳转语句来直观的感受一下分段式保护模式下的内存寻址。

前面预备知识里说道在分段式保护模式下“段基址”(基地址)不再是内存地址,而是 GDT 表的下标。上面我们也说过 GDT 表最大可以有 8192 个表项(段描述符),213 = 8192,所以保存着“段基址”的 16 位段寄存器只需要其中的 13 位就可以表示一个 GDT 表的下标,其余的 3 位可用作他用。

按照这个思路我们看看这个 $(SEG_KCODE<<3) 生成的“段基址”是什么?SEG_KCODE 是个宏定义,具体的定义在 mmu.h 文件中,我们翻译过来就是 $(1<<3),再将它运算出来得到

151413121110 9 8 7 6 5 4 3 2 1 0
0000000000001000


这里这个 16 位的“段基址”的高 13 位代表 GDT 表的下标(学名应该叫“段选择子”),这里高 13 位刚好是 1,而我们的 GDT 里下标位 1 的内存段正好是我们的“代码段”,而“代码段”我们在 GDT 的“段描述符”中设置了它的其实内存地址是 0x00000000 ,内存段长度是 0xfffff,这是完整的 4GB 内存。

所以这里的跳转语句选择了“代码段”,由于“代码段”的起始内存地址是 0x00000000 ,长度是完整的 4GB,所以后面的“偏移量”仍然相当于是实际的内存地址,所以这里“偏移量”直接用了 $start32,也就是 start32 直接对应的代码位置。通过这个跳转实际上 CPU 就会跳转到 bootasm.S 文件的 start32 标识符处继续执行了。


Comments