【MIT CS6.828】Lab 1: Booting a PC - Part 1: PC Bootstrap

Part 1: PC Bootstrap

0. 前置知识

x86、i386、x86-64

x86,又称 IA-32(Intel Architecture, 32-bit),泛指一系列基于Intel 8086且向后兼容的中央处理器指令集架构。这个名称源于这个系列早期的处理器名称,它们都是80x86格式,如80868018680286等。

需要特别指出的是,x86系列的第一款芯片8086支持16位运算(即运算器一次最多能处理16位长的数据、寄存器最大长度为16位、寄存器和运算器之间的数据通路为16位),但有20位地址总线,即可寻址内存大小为 1MB。

i38680386处理器的别名,但一般来说”适用于 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,具体的地址范围为0x000000xFFFFF

  • 地址计算方式

    由于8086的寄存器是 16 位长的,要表示 20 位长的地址,要使用csip两个寄存器,地址计算方式如下:

    物理地址 = 段基址(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):附加段寄存器;

保护模式

808680186都是 16 位处理器、20位物理地址,没有虚拟内存的概念,每一个地址都能在存储芯片中一一对应地找到物理存储单元,必须也只能运行在实模式下。

80286(16位处理器)开始有了虚拟内存,为了进行虚拟内存管理,从早先的段基址寄存器演化出专门的段表,寻址方式也不再遵循实模式下的计算方式,需要从实模式中区分出来,因此有了 16 位保护模式

80386开始,处理器都为 32 位,对应引入了 32 位保护模式。Windows 2000 、Windows XP 都是在 32 位保护模式下运行的。

保护模式下,寻址范围不再受制于实模式下的 1MB,地址计算方式也有所不同。

1. 试运行 JOS

  1. 下载 Lab 1 源码:git clone https://pdos.csail.mit.edu/6.828/2018/jos.git lab

  2. 构建 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
    
  3. 运行JOS:make qemu(注:如果用的Linux是云服务器之类的没有图形界面只有命令行,使用make qemu-nox

    image

    Ctrl+a x退出 JOS

  4. 到这一步说明 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

其中,需要我们特别关注的是0x000F00000x000FFFFF这 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),说明第一条指令存放在这块内存区域的最顶端0xffff00xfffff,指令长度为16字节(注意,这里说的是机器码的长度,而非我们看见的汇编指令的长度)。
  • ljmp $0xf000,$0xe05b:汇编指令,ljmp为转移指令,l表示目标地址为16位地址,后面两个立即数中,$0xf000是给寄存器cs的值,$0xe05b是给寄存器ip的值。

csip这两个寄存器的值与实际地址之间是什么关系?

前面提到,第一条指令的地址之所以是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开始执行引导加载程序的指令)。

posted @ 2023-01-29 23:06  StreamAzure  阅读(89)  评论(0编辑  收藏  举报