操作系统 实验报告
Lab1:系统软件启动过程
实验目的:
操作系统是一个软件,也需要通过某种机制加载并运行它。在这里我们将通过另外一个 更加简单的软件-bootloader 来完成这些工作。为此,我们需要完成一个能够切换到 x86 的保护模式并显示字符的 bootloader,为启动操作系统 ucore 做准备。lab1 提供了一个非常小的 bootloader 和 ucore OS,整个 bootloader 执行代码小于 512 个字节,这样才能放到硬盘的主引导扇区中。通过分析和实现这个 bootloader 和 ucore OS,可以了解到:
> 计算机原理
- CPU 的编址与寻址:基于分段机制的内存管理
- CPU 的中断机制
- 外设:串口/并口/CGA,时钟,硬盘
> Bootloader 软件
- 编译运行 bootloader 的过程
- 调试 bootloader 的方法
- PC 启动 bootloader 的过程
- ELF 执行文件的格式和加载
- 外设访问:读硬盘,在 CGA 上显示字符串
> ucore OS 软件
- 编译运行 ucore OS 的过程
- ucore OS 的启动过程
- 调试 ucore OS 的方法
- 函数调用关系:在汇编级了解函数调用栈的结构和处理过程
- 中断管理:与软件相关的中断处理
- 外设管理:时钟
实验内容与结果: lab1 中包含一个 bootloader 和一个 OS。这个 bootloader 可以切换到 X86 保护模式, 能够读磁盘并加载 ELF 执行文件格式,并显示字符。而这 lab1 中的 OS 只是一个可以处理时钟中断和显示字符的幼儿园级别 OS。
练习 1:理解通过 make 生成执行文件的过程
1. 操作系统镜像文件 ucore.img 是如何一步一步生成的
[1] 首先执行指令 make “V=” 可以得到如下结果:

从上图可以看出,整个 Makefile 的过程主要是三部分:
> 调用 gcc,将 C 的源代码编译成.o 目标文件
> 调用 ld,将一系列目标文件链接为可执行程序
> 调用 dd,将 bootblock 和 kernel 的内容放入虚拟硬盘 ucore.img 内备注[三条 dd 指令解析]:
> dd if=/dev/zero of=bin/ucore.img count=10000 此处的 zero 是绝对路径,是将申请的 10000 个 block 内全部存放 0 来初始化,if 和 of 分别是读入和写出路径;
> dd if=bin/bootblock of=bin/ucore.img conv=notrunc 是 指 将 bootblock 这 一个 512 字节的 block 存放到 ucore.img 的开头,conv 指定了文件转换的方式为不截短输出文件;
> dd if=bin/kernel of=bin/ucore.img seek=1 conv=noturnc 是指跳过 seek(1)个块之后将 kernel 中的内容写进 ucore.img,即将内核写入虚拟镜像;
[2] 再查看 Makefile 中的关键部分可得:
> 此处的几条指令即对应着[1]中所述的 3 条 dd 指令,但这 3 条指令执行时需要有 bootblock 和 kernel,故需要先生成 bootcheck 和 kernel;

> 此处的两部分即为生成 bootblock 和 kernel 的部分,每一部分生成时都需要用
到若干.o 文件或其他文件,具体的每条命令及跟随的命令参数在 report 当中已经很完备,就不再赘述,下面再看一下在生成 bootblock 时的 sign 工具;
> 通过 sign.c 也可以间接了解到硬盘主引导扇区的规范格式,在 2 中也会提到。
2. 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么
主引导扇区位于整个硬盘的 0 磁头 0 柱面 1 扇区,包括硬盘主引导记录 MBR
(Master Boot Record)和分区表 DPT(Disk Partition Table)。规范的主引导扇区特征如下:
[1] 总大小为 512 字节,由主引导程序、分区表、结束标志三部分构成;
[2] 引导程序,从 0x0 位置起共 446 字节(隐含 windows 磁盘签名);
[3] 分区表,占用 64 字节,是 MBR 中的重要结构;
[4]
结束标志,扇区的最后两个字节“55AA”是 MBR 的结束标志。
> 上图为主引导扇区的 512 个字节存放的内容,结合之前的 sign.c 可以看到,末尾的两个字节存放的为 0xAA55,实际上即为 0x55AA 的小尾存放方式,高字节存放在高地址。
练习 2:使用 qemu 执行并调试 lab1 中的软件
1. 从 CPU 加电后执行的第一条指令开始,单步跟踪 BIOS 的执行
[1] 首先修改 gdbinit 文件如上所示,由于 BIOS 启动过程是从实模式(16 位模式) 开始的,故需要将此时的架构修改为 i8086 方能正常调试;

[2] 修改完成后执行 make debug 进入 gdb 后即可开始调试,由于当前是实模式我
们直接执行 x/1i $pc 看到的指令并不是实际执行的指令,所以我们可以通过执行x/1i 0xfffffff0 来看第一条指令,即长跳转指令,后面跳转完成后同样可以查看多行指令,但由长跳转指令可得此处的指令同样不是实际执行的指令。
2.
在初始化位置 0x7C00 设置实地址断点,测试断点正常
[1] Report 中给出的方法略显繁琐,可以不通过修改 gdbinit 而是直接在 gdb 中执行指令的方式来设置断点并设置当前调试的 CPU,同时执行 x/10i $pc 也可以看到后面执行的 10 条汇编指令,可在 3 中进行比对。
3. 从 0x7C00 开始跟踪代码运行,将单步跟踪反汇编得到的代码与 bootasm.S 和
bootblock.asm 进行比较

[1] 由 2 中的反汇编代码与上面两图中的汇编代码比对,可得二者一致。
4.
自己找一个 bootblock 或内核中的代码位置,设置断点并进行测试
[1] 将断点设置在 kernel 中的 0x00100860 的位置并 continue 执行,到达断点后查看代码可得反汇编结果与原汇编指令一致。
练习 3:分析 bootloader 进入保护模式的过程
1. 为何开启 A20,以及如何开启 A20
[1] 首先根据实验指导书中 Lab1 的附录 A 可得,A20 的存在是为了保持向下兼容性,一开始时 A20 地址线控制是被屏蔽的,直到系统软件通过一定的 IO 操作打开它,开启之后才能访问高端内存,即保护模式下开关必须开启,且虽然使用的控制
芯片为 8042 键盘控制器,但实际与键盘并无关联。
[2] 由附录 A 进一步可得打开 A20 Gate 的具体步骤大致如下:
- 等待 8042 Input buffer 为空
- 发送 Write 8042 Output Port(P2)命令到 8042 Input buffer
- 等待 8042 Input buffer 为空
- 将 8042 Output Port(P2)得到字节的第 2 位置 1,然后写入 8042 Input buffer
[3] 由 bootasm.S 中的 Enable A20 部分代码可得,开启过程与附录 A 中描述相同。
2.
如何初始化 GDT 表
[1] 初始化 GDT 直接通过加载全局描述符的 LGDT 指令来直接完成,解释出的汇编指令如上面第一张图所示,而 gdtdesc 对应着一个内存地址,由于 GDTR 寄存器为 6 个字节,存放 GDT 表的内存起始地址和表长度,故执行此指令时会将gdtdesc 指向的内存地址开始的连续 6 个字节的数据存入 GDTR 寄存器。
3.
如何使能和进入保护模式
[1] 做好上述准备后,使能并从实模式切换到保护模式并不难,只需要将控制寄存 器 CR0 中的 PE 位置 1 即可,置 1 过程如上图指令所示。

[2] 但执行完成之后,处理器虽然转入了保护模式,但 CS 中的内容仍然为实模式下代码段的段值而非保护模式下代码段的选择子,故取指令前应把代码段的选择子 装入 CS,故紧接着会执行如上图所示长跳转指令及其他指令。同时后面还分配了堆栈空间,完成操作后调用 bootmain 将 kernel 加载进内存之中。
练习 4:分析 bootloader 加载 ELF 格式的 OS 的过程
1. bootloader 如何读取硬盘扇区的
[1]
首先要明确本实验中的 ELF 类型为用于执行的可执行文件(executable file),用于提供程序的进程映像,加载到内存执行,读取硬盘扇区关联的函数即为bootmain.c 中的 readsect 函数,而整个加载过程即对应 bootmain 函数,在 2 中会进一步分析。
[2] 同时实验指导书中给出了磁盘 IO 地址和对应功能,如上图所示,也给出了一个扇区的流程如下所示:
- 等待磁盘准备好
- 发出读取扇区的命令
- 等待磁盘准备好
- 把磁盘扇区数据读到制定内存
[3] 可以看到 bootmain.c 中的 readsect 函数如上所示,开启过程与上面的描述完全相符。对 0x1F2 的操作指定了每次读取 1 个扇区,对 0x1F3-0x1F6 的操作共同指定了读取的扇区号,对 0x1F7 的操作中 0x20 的指令用来读取扇区。
2. bootloader 是如何加载 ELF 格式的 OS
[1] 分析完读取硬盘扇区后可得,加载整个 kernel 的过程本质上就是循环读取扇区的过程,涉及到的函数即为 bootmain.c 中的 readseg 函数和 bootmain 函数。
[2] readseg 函数如上所示,需要注意的是 secno 的设定是从 1 开始,原因是 0 扇区对应的是主引导扇区,从 1 扇区开始才是 kernel 部分。


[3] bootmain 主函数如上所示,首先是读取 ELF 的头部,然后由 ELF 文件头格式要求可得要先比对 magic 是否等于 ELF_MAGIC 来确认 ELF 合法性,确认后按照头格式中加载位置、入口信息等一系列值来将 ELF 加载进内存之中并找到内核的入口,全部完成后加载过程即结束,转入内核执行。
练习 5:实现函数调用堆栈跟踪函数
[1] 首先看 print_stackframe 函数,即从 0-当前深度将调用的所有的函数全部打印出来,包括 ebp、eip 和参数列表 args。

[2]
然后执行 make qemu 之后,可以看到关于堆栈调用部分的代码如上所示,每一层调用都会输出 ebp、eip 以及 args 的值,且指明调用函数的位置和关系。
[3] 对于最后一行实际上为最开始的调用即 call bootmain 语句,由于初始化后堆栈为空,栈顶在 0x7C00 位置,故调用后压栈,栈顶指针变为 0x7BF8,即为 ebp 所示值,而 call 语句所在的位置为 0x7D70,如图所示,故 eip 指向下一条会执行的语句即 0x7D72。
练习 6:完善中断初始化和处理
1. 中断描述符表(保护模式下的中断向量表)中一个表项占多少字节,其中哪几位代表 中断处理代码的入口
[1] 由实验指导书中断与异常部分的介绍可得,中断描述符表 IDT 是一个 8 字节的描述符数组,故可得其中每一个表项占 8 字节,CPU 把中断异常号乘 8 作为 IDT 的索引。
[2] 由实验指导书中给出的三种 Descriptor 可得,除去 Task-gate 此处未使用外, 剩下两种均可统一成如下形式:
- 2-3 字节为段选择子
- 6-7 字节和 0-1 字节拼接成 4 字节的偏移量
- 4-5 字节用来决定 Descriptor 类型
- 可得除去 4-5 字节外剩余的 6 个字节联合给出中断处理代码的入口
2. 请编程完善 kern/trap/trap.c 中对中断向量表进行初始化的函数 idt_init。在 idt_init 函数中,依次对所有中断入口进行初始化。使用 mmu.h 中的 SETGATE 宏,填充 idt 数组内容。每个中断的入口由 tools/vectors.c 生成,使用 trap.c 中声明的 vectors 数组即可。

[1] 按照题目要求给出的代码如上图所示,循环对 idt 内所有的中断入口进行初始化,完成后即可通过 LIDT 指令来加载 IDT。
3.
请编程完善 trap.c 中的中断处理函数 trap,在对时钟中断进行处理的部分填写 trap 函数中处理时钟中断的部分,使操作系统每遇到 100 次时钟中断后,调用 print_ticks 子程序,向屏幕上打印一行文字”100 ticks”。
[1] 按照题目要求给出的代码如上图所示,当面临中断时要判断是属于哪一类中断, 在实验中容易尝试出的为时钟中断和键盘中断,串口中断因为没有相关设备连接所 以没有触发。

[2] 触发结果如上图所示,可以看到当没有键盘输入时每隔 100ticks 会自动触发中断,有键盘输入时会立刻触发键盘中断。
扩展练习 Challenge1:
扩展 proj4,增加 syscall 功能,即增加一用户态函数(可执行一特定系统调用:获得时钟计数值),当内核初始完毕后,可从内核态返回到用户态的函数,而用户态的函数又通过系统调用得到内核态的服务。
stati.c vot ,d
l a b1_swi.t c h_t o_ us e「 ( v ol d ) {
//ILAB1 CHALLENGE 1 : 巨 。 四
asl'I volatlle (
, ' s u b $0x8, %%es p \ n "
, ' i.nt %0 \ n "
, ' fllovl %%e bp , %%es p "
:
. "• 1. II ( T S WIT CH—fO U)
}) ; • —
statlc void
l a b1_swi.t c:h_t o_ke rn e l ( vol d)
//LAB1 CHALLENGE 1 : TOD0 1
asl'I volatlle ( " i.nt %0 \ n "
, ' fllovl %%e bp , %%es p \ n "
:
: " l " ( T SWITCH TOK)
,)
}
statlc vold
lab1 ws i.t c::h_t es t ( vol d ) {
lab1_pri..nt_c:ur_status();
c:p「 i.nt f ( " +++ swi.tc:h to us e「 l'lode +++ \ n " ) ; l a b1_sw i..t c:h_t o_l!s e「 () ; lab1_pri..nt_c:ur_status();
c:p 「 i..nt f ( " +++ swi.tc:h to kernel l'lode +++ \ n " ) ; lab1_swi..tc:h_to_kernel(); lab1_pri..nt_c:ur_status();



* 。
= 8
= 10
= 10
= 10
+++ switch to u s 「 rr1od e +++
1: @九ng 3
1: c s = lb
1: ds = 23
1: es = 23
1: ss = 23
+++ switch t o k 「 nel rr1od e +++
2: @九ng 0
2: c s = 8
2: ds
2: es
2: ss
[1] 内核初始完毕后会执行 lab1_switch_test 函数,在其中会发生两次切换,首先输出当前(内核)状态,随后切换到用户态,输出状态后再切换回内核态并输出当 前状态。
[2]
此处需要注意的是在 lab1_switch_to_kernel 和 lab1_switch_to_user 这两个函数中采用了内嵌汇编的写法,同时加入了 volatile 限定符确保汇编指令不被优化修改,经查阅资料了解,内嵌汇编中的:是用来分隔不同部分用的,在此处的两个例 子中分隔的分别是汇编语句模板和输入部分,同时在模板中用%0 作为占位符,在输入部分用”i”代表立即数将中断号填入 INT 中使其发生中断。
[3] 上图所示即为中断处理切换状态时的代码,两段代码总体上比较对称,都需要 在一开始判断是否已经处于目标状态,如果不是目标状态,那么需要修改相应的寄 存器,根据 trapframe 的定义,需要修改的寄存器有 CS,DS,ES,ESP 等,同时根据
trapframe 中为补齐 4 字节加入的 padding 也可以验证之前的 55AA 的小尾存储的问题。
[4] 其中比较关键的还有对 EFLAGS 寄存器的修改,EFLAGS 寄存器的第 12/13 位为 IOPL(I/O privilege level field),指示当前运行任务的 I/O 特权级,只有在运行任务的当前特权级(CPL)小于等于 I/O 特权级才允许访问 I/O 地址空间,而代码中|=0x3000 和&=~0x3000 的作用则分别是置 11 和置 00。
扩展练习 Challenge2:
用键盘实现用户模式内核模式切换。具体目标是:“键盘输入 3 时切换到用户模式,键盘输入 0 时切换到内核模式”。 基本思路是借鉴软中断(syscall 功能)的代码,并且把
trap.c 中软中断处理的设置语句拿过来。
[1] 代码实现如上所示,实现时采用的方法为考虑键盘中断的分支,但不影响原中 断的效果,先输出当前键入字符,如果输入为 0/3 且需要发生模式切换就会按照
T_SWITCH_TOU/TOK 的方式来进行切换,实验结果如下图所示。
[2] 可以看出,在不需要状态切换时只是单纯的键盘中断,需要状态切换时会调用
print_trapframe 打印出各类寄存器的状态,可以通过 EFLAGS 寄存器的值看出状态切换的结果,并且多次切换时可保证仍然正常工作。
实验感想:
1. 本实验和以往的实验都不太一样,因为总体实验的难度比较大,所以大部分时间都 是以阅读给出的代码和实验结果为主,更多的是去分析和理解,很容易让自己陷入我好 像看起来都懂了的样子,但是事实上要深入挖掘实验内容的话还是有很多要认真学习的 地方。因为实验给了很多参考资料,所以在实验过程中也有着非常多的细节,其实很多 都已经在实验参考书或者一些地方给出了,比如:
[1] 关于进入保护模式时的 A20 – 操作系统实验指导书 Page 105
[2] 80386 的任务/中断/陷阱门描述符 – 操作系统实验指导书 Page 99
2. 实验过程要有充分的耐心,因为大部分内容都是之前未接触过的,再加上直接接触 一个完整的项目文件,不可避免的会产生阅读上的困难,这种时候就要不厌其烦的百度 和 google,不管是 OS 还是 xv6 还是 ucore 都有大量前人做过的工作,可以从中吸取经验和教训作为自己的收获,比如搜索如下指令:
[1] x/2i $pc 为 gdb 调试过程中的一条指令,查阅资料可得其中的含义:
- x 为 examine 的缩写
- 2 表示要查看的内存单元个数
- i 指令地址格式
- pc 即 program counter,程序计数器
[2] asm volatile(…:…:…:…); 为 gcc 在 c 语言中内嵌汇编的方式,具体含义:
- asm 表示后面的代码为内嵌汇编
- volatile 表示编译器不优化代码,后面的指令保留原样
- ()内由三个:分隔四部分分别为汇编语句模板/输出/输入/破坏描述部分
- 汇编语句模板内用%0-%9 可以最多指定 9 个占位符
对于每一条指令都可以深入挖掘学到相关的很多知识,不仅仅在 OS 这门课或者这些实验当中可以用到,更多的是积累解决问题的能力。
3. 实验串联课程较多,可以当做对于过往课程(数据结构、微机原理)的整理复习和 对于将来需要补充课程(编译原理、嵌入式)的积累,需要认真学习体会