Part 1: PC Bootstrap
0. 前置知识
x86、i386、x86-64
x86,又称 IA-32(Intel Architecture, 32-bit),泛指一系列基于Intel 8086且向后兼容的中央处理器指令集架构。这个名称源于这个系列早期的处理器名称,它们都是80x86
格式,如8086
、80186
、80286
等。
需要特别指出的是,x86系列的第一款芯片8086
支持16位运算(即运算器一次最多能处理16位长的数据、寄存器最大长度为16位、寄存器和运算器之间的数据通路为16位),但有20位地址总线,即可寻址内存大小为 1MB。
i386 是80386
处理器的别名,但一般来说”适用于 i386“等同于"适用于i386采用的架构 x86",因此在相关语境下将 i386 视为指代 x86 架构也是可以的
x86-64 是基于 x86 的 64 位扩展架构。一般说 x86 是 32 位架构(虽然最初两代CPU是16位的),将该架构扩展至 64 位是AMD先于Intel完成的,并命名为 AMD 64,Intel随后才推出与 AMD 64 架构兼容的处理器,并命名为 Intel 64,即 x86-64。
(注:x86=IA-32,但 x86-64≠IA-64,IA-64 是全新的架构,与 x86 完全没有相似性)
汇编指令格式:AT&T
实验中 GDB 调试显示的汇编代码都是 AT&T 格式。对于本实验而言,有汇编基础再好不过,但如果没有,也没必要完整学一遍再来,对下列常用语法格式有个大致印象就够用了,出现陌生语法再查。
pushb %eax
# 将寄存器eax的值压栈。寄存器名前要加%,后缀b表示操作数字长为低8位
pushb $1 # 将十六进制数1压栈。直接给出数值(称为立即数)时要在前面加$
addl $1, %eax
#将寄存器eax的值+1后结果存入eax,后缀l表示操作数字长为全部32位
movw %ebx, %eax
#将寄存器ebx的值赋给寄存器eax,后缀w表示操作数字长为低16位
实模式与保护模式
实模式
x86 系列的第一代芯片8086
是 16 位处理器,有 20 根地址线,只支持1MB
内存空间寻址。 x86 架构是向后兼容的,为了保证当初基于8086
设计的程序在如今普遍内存大小为4GB
的 x86 计算机中还能正常运行,必须将这初始的1MB
保留下来,且当年8086
在这1MB
内存空间里遵循的一些规则,后续的 x86 系列芯片也必须遵循。
这1MB
空间以及在这空间内采用的寻址方式、地址长度等等当年8086
遵循的规定,就是实模式。
显然当年8086
加电执行的第一条指令只能存储在这1MB
中,因此后来的 x86 芯片加电执行的第一条指令也必然存储在这1MB
空间中,这已成为设计规范以保证向后兼容性。也就是说,x86 系列芯片加电后都首先进入实模式,完成与当年8086
开机引导类似的操作后,再打开某个开关,启用这1MB
以外的地址空间,从实模式切换到保护模式。
-
寻址空间
实模式下,寻址空间为
1MB
,具体的地址范围为0x00000
到0xFFFFF
; -
地址计算方式
由于
8086
的寄存器是 16 位长的,要表示 20 位长的地址,要使用cs
和ip
两个寄存器,地址计算方式如下:物理地址 = 段基址(cs) × 16 + 段内偏移量(ip)
即使后来的 x86 芯片的寄存器有 32 位长,足够存储完整地址,在实模式下也必须按照上述方式进行地址存储和计算。
-
寄存器
-
通用寄存器
x86 系列芯片的通用寄存器都有 8 个,名称及特定用途都沿用自初代芯片
8086
。后来芯片位数扩展到 32 位,通用寄存器也相应扩展到 32 位,在原来名称的基础上开头加了E
,与8086
的 16 位寄存器相区别。- EAX:一般用作累加器(Add)
- EBX:一般用作基址寄存器(Base)
- ECX:一般用来计数(Count)
- EDX:一般用来存放数据(Data)
- ESP:一般用作堆栈指针(Stack Pointer)
- EBP:一般用作基址指针(Base Pointer)
- ESI:一般用作源变址(Source Index)
- EDI:一般用作目标变址(Destinatin Index)
-
状态寄存器
- EFLAGS:状态标志寄存器。分为CF、ZF、SF、OF等状态位。
- DF:Direction Flag。设置DF标志使得串指令自动递减(从高地址向低地址方向处理字符串),清除该标志则使得串指令自动递增。STD以及CLD指令分别用于设置以及清除DF标志。
-
段寄存器
- CS (Code Segment):代码段寄存器;
- DS (Data Segment):数据段寄存器;
- SS (Stack Segment):堆栈段寄存器;在16位下与SP寄存器组合使用,SS:SP作为栈顶指针;在32位下ESP寄存器足够作为栈顶指针,SS就无关紧要了。
- ES (Extra Segment):附加段寄存器;
-
保护模式
8086
、80186
都是 16 位处理器、20位物理地址,没有虚拟内存的概念,每一个地址都能在存储芯片中一一对应地找到物理存储单元,必须也只能运行在实模式下。
从80286
(16位处理器)开始有了虚拟内存,为了进行虚拟内存管理,从早先的段基址寄存器演化出专门的段表,寻址方式也不再遵循实模式下的计算方式,需要从实模式中区分出来,因此有了 16 位保护模式。
从80386
开始,处理器都为 32 位,对应引入了 32 位保护模式。Windows 2000 、Windows XP 都是在 32 位保护模式下运行的。
保护模式下,寻址范围不再受制于实模式下的 1MB
,地址计算方式也有所不同。
1. 试运行 JOS
-
下载 Lab 1 源码:
git clone https://pdos.csail.mit.edu/6.828/2018/jos.git lab
-
构建 JOS 镜像:
$ cd lab && make # 应显示以下信息 + as kern/entry.S + cc kern/entrypgdir.c + cc kern/init.c + cc kern/console.c + cc kern/monitor.c + cc kern/printf.c + cc kern/kdebug.c + cc lib/printfmt.c + cc lib/readline.c + cc lib/string.c + ld obj/kern/kernel ld: warning: section `.bss' type changed to PROGBITS + as boot/boot.S + cc -Os boot/main.c + ld boot/boot boot block is 396 bytes (max 510) + mk obj/kern/kernel.img
-
运行JOS:
make qemu
(注:如果用的Linux是云服务器之类的没有图形界面只有命令行,使用make qemu-nox
)按
Ctrl+a x
退出 JOS -
到这一步说明 Lab 1 的环境配置已经完成了,我们接下来的任务就是以这个只有简单功能的 OS 作为研究对象,分析 CPU 从加电执行第一条指令开始到加载 JOS 的过程。
2. 使用 GDB 研究 PC 启动的第一步
打开一个shell窗口,执行
cd lab && make qemu-gdb #如果没有图形界面,使用make qemu-nox-gdb
再开一个shell窗口,执行
cd lab && make gdb
应显示以下信息
$ cd lab && make gdb
gdb -n -x .gdbinit # GDB 的启动配置文件,由 lab 提供
... # 一堆 GDB 的版本及介绍信息
The target architecture is set to "i8086".
# JOS 系统程序的第一条指令
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel
注意!如果你的第一条指令是0xffff0: ljmp $0x3630,$0xf000e05b,说明 GDB 以32位而不是16位来解释,这个问题的出现可能是因为你使用的是apt-get安装的QEMU而非6.828课程提供的定制版QEMU,强烈建议使用后者。
在分析第一条指令在做什么之前,需要先搞清楚我们正在什么样的硬件环境里。
我们用QEMU模拟了一个与 i386 相同的硬件环境,并在此硬件环境上加载 JOS。而 i386 采用 x86 架构。因为 x86 架构向后兼容,所以有一些”设定“继承自 x86 系列最早的处理器 8086
。
(下面的地址都用8位16进制数表示,可表示的内存大小为4GB,这是目前<呃,好像也过时了>我们所用计算机的普遍内存大小,也是 i386 的物理寻址空间大小)
前面提过,8086
的寻址空间大小为1MB
。因此在这里需要理解的第一个古老”设定“是,从0x00000000
开始,到0x00FFFFFF
结束的这1MB
大小空间是特殊的,保留用作特定用途。(这样一来,当年基于 8086 这 1MB 内存而编写的程序,在如今的最新 x86 架构上也能正常运行,因为这 1MB 空间依然是和当年一样的用途)
这1MB
被分为各个区域,分别用作特定用途,如下图所示:
+------------------+ <- 0x00100000 (1MB) (注:1MB空间最后一个地址0x000FFFFF)
| BIOS ROM |
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display |
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000
其中,需要我们特别关注的是0x000F0000
到0x000FFFFF
这 64 KB 空间。对于早期 PC ,BIOS 存储在 ROM 中,这 64 KB 就是在 ROM 中的寻址空间。
BIOS 是 CPU 加电后执行的第一个程序,前面显示的[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
就是 BIOS 的第一条指令。
我们目前所处的位置及要分析的内容,就是QEMU模拟出来的 80386 芯片加电后启动的第一步:执行BIOS。
现在来分析第一条指令:
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
[f000:fff0]
:f000
是寄存器cs
的值,fff0
是寄存器ip
的值。0xffff0
:指令的起始地址。我们知道 BIOS 所在区域的地址最大为0xfffff
(这里省略了高位的0),说明第一条指令存放在这块内存区域的最顶端0xffff0
到0xfffff
,指令长度为16字节(注意,这里说的是机器码的长度,而非我们看见的汇编指令的长度)。ljmp $0xf000,$0xe05b
:汇编指令,ljmp
为转移指令,l
表示目标地址为16位地址,后面两个立即数中,$0xf000
是给寄存器cs
的值,$0xe05b
是给寄存器ip
的值。
cs
、ip
这两个寄存器的值与实际地址之间是什么关系?
前面提到,第一条指令的地址之所以是0xffff0
,是因为第一代 x86 CPU 8086
就是如此。实际上,在这 1MB
内存区域内进行的操作,都得遵循当年8086
的规则(此时所处的就是所谓的实模式) 。而8086
是 16 位处理器,却有 20 根地址线,地址长度为 20 位。为了能用 16 位的寄存器表示 20 位的地址,8086
使用以下转换公式进行转换:
物理地址 = 段基址(cs) × 16 + 段内偏移量(ip)
事实上,乘法对于CPU来说是很复杂的,所以 ×16 的操作实际是通过将值左移4位来实现的,即:
物理地址 = 段基址(cs) << 4 + 段内偏移量(ip)
f000
左移4位就变成了f0000
(别忘了十六进制和二进制之间是如何转换的),再加上fff0
,就变成了 GDB 显示的地址 ffff0
。
同理,我们可以计算出这条ljmp
指令的跳转目标地址:f0000
+e05b
=fe05b
。
综上,我们知道了QEMU模拟的这个i386
机器在按下电源键之后干的第一件事:CPU读取并执行起始地址为0xffff0
处的(这个地址初始设定是由硬件完成的)、属于 BIOS 程序的第一条指令:跳转到地址0xfe05b
。
练习2:使用 GDB 的 si(步骤指令)命令跟踪 ROM BIOS 以获取更多指令,并尝试猜测它可能在做什么。您可能需要查看 Phil Storrs I/O 端口说明以及 6.828 参考资料页面上的其他资料。无需了解所有细节 - 只需大致了解 BIOS 首先要做什么。
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
# 第一条指令,跳转至地址0xfe05b
[f000:e05b] 0xfe05b: cmpl $0x0,%cs:0x6ac8
# 判断地址0xf6ac8处的值是否等于0,若等于0则置状态字寄存器中的ZF位为1
[f000:e062] 0xfe062: jne 0xfd2e1
# 若上一条指令的结果不为0/不相等(jne = jump not equal)则跳转至地址0xfd2e1
[f000:e066] 0xfe066: xor %dx,%dx
#(由地址可以看出上条指令没有跳转)将寄存器dx清零
[f000:e068] 0xfe068: mov %dx,%ss
[f000:e06a] 0xfe06a: mov $0x7000,%esp
# 将寄存器ss清零
# 将寄存器esp的值置为0x7000
# 类似于cs:ip的地址表示方法,实模式下ss:sp表示栈顶地址,此时被初始化为0x70000
# esp的e只是表明这是个32位寄存器,与sp是同一个东西
[f000:e070] 0xfe070: mov $0xf34c2,%edx
# 将寄存器edx的值设为0xf34c2
[f000:e076] 0xfe076: jmp 0xfd15c
# 跳转到地址0xfd15c
[f000:d15c] 0xfd15c: mov %eax,%ecx
# ?
[f000:d15f] 0xfd15f: cli
# 关中断
[f000:d160] 0xfd160: cld
# 将DF置0,使得字符串指针在每次字符串操作后自动递增
...
BIOS程序执行时,会建立一个中断描述符表并初始化各种设备,例如 VGA 显示器。这就是 QEMU 窗口中看到的“ Starting SeaBIOS
”消息的来源。初始化 PCI 总线和 BIOS 知道的所有重要设备后,它会搜索可引导设备(即操作系统存储的设备),如软盘(已经淘汰了的远古存储设备)、硬盘或 CD-ROM(光盘)。最终,当它找到可引导磁盘时,BIOS 从磁盘读取引导加载程序(Boot Loader)并将CPU控制权转交给它(即CPU开始执行引导加载程序的指令)。