MIT6.828-Lab1

实验地址:https://pdos.csail.mit.edu/6.828/2018/schedule.html

理论的东西就不多说了,另一篇专门讲操作系统的博客已经讲的很清楚了,做这个主要是想上手调试一下。

环境搭建

Ubuntu 18.04 环境还是要再配置一下:https://zhuanlan.zhihu.com/p/58143429

然后就是将课程克隆到本地

1
2
3
4
mkdir ~/6.828
cd ~/6.828
git clone https://pdos.csail.mit.edu/6.828/2018/jos.git lab
cd lab

Exercise 1

1
2
熟悉 6.828 参考页上提供的汇编语言材料。您现在不必阅读它们,但在阅读和编写 x86 程序集时,您几乎肯定会想参考其中的一些材料。
我们建议您阅读 Brennan 的内联汇编指南中的“语法”部分。它对我们将在 JOS 中与 GNU 汇编器一起使用的 AT&T 汇编语法给出了很好的(并且非常简短的)描述。

要求了解基本的汇编,这个就不多说了。

Exercise 2

ROM BIOS

利用 QEMU 来调试计算机如何启动

在实验目录下打开两个终端,一个输入make qemu-gdb,另一个输入make gdb

可以看到

image-20220331215958368

其中

1
[f000:fff0]    0xffff0:	ljmp   $0xf000,$0xe05b

是 GDB 对要执行的第一条指令的反汇编,可以看出当开机的一瞬间,PC 会在 0xffff0 处执行(CS = 0xf000 | IP = 0xfff0 | 实模式下的寻址方式为:物理地址= 16 * 段+偏移量),也就是 BIOS

而该地址处又表示要跳到分段地址 0xfe05b 处

然后就是exer2的任务描述:

1
使用GDB的'si'命令,去追踪ROM BIOS几条指令,并且试图去猜测,它是在做什么。但是不需要把每个细节都弄清楚。
1
2
[f000:e05b]    0xfe05b:	cmpl   $0x0,%cs:0x6ac8
[f000:e062] 0xfe062: jne 0xfd2e1

比较 0x0 和 %cs:0x6ac8 的值,此时 CS 是 0xf000,jne:上一个 cmp 指令不为 0 时跳转,即两值不相等时跳转。

1
[f000:e066]    0xfe066:	xor    %dx,%dx

这一条指令的地址是 0xfe066,说明之前 %cs:0x6ac8 和 0x0 相等,这条指令是指把 dx 置 0

1
2
3
4
5
6
7
[f000:e068]    0xfe068:	mov    %dx,%ss       //把dx赋给ss
[f000:e06a] 0xfe06a: mov $0x7000,%esp //sp = 0x7000
[f000:e070] 0xfe070: mov $0xf34c2,%edx //edx = 0xf34c2
[f000:e076] 0xfe076: jmp 0xfd15c //jmp to 0xfd15c
[f000:d15c] 0xfd15c: mov %eax,%ecx //把eax赋给ecx
[f000:d15f] 0xfd15f: cli
[f000:d160] 0xfd160: cld

cli:关闭中断指令,毕竟还在启动,肯定不能被别的中断了

cld:设置方向标识位为0,表示后续的串操作比如MOVS操作,内存地址的变化方向,如果为 0 代表从低地址值变为高地址。

1
2
3
[f000:d161]    0xfd161:	mov    $0x8f,%eax    //把eax赋值为0x8f
[f000:d167] 0xfd167: out %al,$0x70
[f000:d169] 0xfd169: in $0x71,%al

out 和 in 指令用于操作 IO 端口,CPU 与外设通讯,通常是通过访问、修改设备控制器中的寄存器来实现的,这些位于设备控制器当中的寄存器也叫做 IO 端口。为了方便管理,80x86CPU采用 IO 端口单独编址的方式,即所有设备的端口都被命名到一个 IO 端口地址空间中。这个空间是独立于内存地址空间的。所以必须采用和访问内存的指令不一样的指令来访问端口。

所以这里引入 in,out 操作:

in %al, PortAddress 向端口地址为 PortAddress 的端口写入值,值为 al 寄存器中的值

out PortAddres,%al 把端口地址为 PortAddress 的端口中的值读入寄存器al中

标准规定端口操作必须要用 al 寄存器作为缓冲。

所以这几条命令就是操作端口 0x70 和 0x71,对应的设备是 CMOS,是一种可读写的存储设备,会在计算机关机时存储一些信息。

CMOS 可以控制很多功能,其中有一个是控制**不可屏蔽中断(NMI)**。不可屏蔽中断是什么呢?是一种优先级很高的中断,比如内存损坏问题出现就会触发,用于解决紧急问题。

操作 CMOS 存储器 0x70 和 0x71 这两个端口都需要,其中 0x70 叫做索引寄存器,这个 8 位存储器的最高位能设置不可屏蔽中断是否能发生,设置成 1,不发生,设置成 0 ,发生。低 7 位用于指定 CMOS 存储器中的存储单元地址

举个例子,假如现在要访问第 1 号存储单元,且开启不可屏蔽中断,那么就需要将 AL 寄存器赋值为 0x81 (对应二进制 10000001),即

1
2
mov $0x81, %al
out %al, 0x70

读和写由 0x71 端口完成,比如现在想从 1 号存储单元读值,则需要

1
in $0x71, %al 

我们会到之前的代码

1
2
3
[f000:d161]    0xfd161:	mov    $0x8f,%eax    //把eax赋值为0x8f
[f000:d167] 0xfd167: out %al,$0x70
[f000:d169] 0xfd169: in $0x71,%al

此时 eax 为 0x8f,那么 al 也为 0x8f (AX是EAX的低16位,AL又是AX的低8位,所以相当于AL是AX的最低4位(×),对应二进制是 10001111,最高位为 1 ,说明会关闭不可屏蔽中断,之后的七位转化成十六进制是 0xf,所以会访问 CMOS 的第 0xf 号存储单元,并把值读到 al 中。

接着往下看

1
2
3
[f000:d16b]    0xfd16b:	in     $0x92,%al
[f000:d16d] 0xfd16d: or $0x2,%al
[f000:d16f] 0xfd16f: out %al,$0x92

也是对端口进行操作,就不多说了,具体实现了什么好像也不是很重要。

1
2
[f000:d171]    0xfd171:	lidtw  %cs:0x6ab8
[f000:d177] 0xfd177: lgdtw %cs:0x6a74

加载中断向量表寄存器(IDTR)和全局描述符表寄存器(GDTR)

1
2
3
[f000:d17d]    0xfd17d:	mov    %cr0,%eax
[f000:d180] 0xfd180: or $0x1,%eax
[f000:d184] 0xfd184: mov %eax,%cr0

只是测试,不重要。

这一部分将就着看,毕竟不是很懂,感觉也不是重点。

Exercise 3

1
2
3
4
5
查看实验室工具指南,尤其是 GDB 命令部分。即使您熟悉 GDB,这也包括一些对操作系统工作有用的深奥 GDB 命令。

在地址 0x7c00 处设置断点,该地址将加载引导扇区。继续执行直到该断点。跟踪boot/boot.S中的代码,使用源代码和反汇编文件 obj/boot/boot.asm跟踪您的位置。还可以在 GDB 中使用x/i命令来反汇编引导加载程序中的指令序列,并将原始引导加载程序源代码与obj/boot/boot.asm 和 GDB 中的反汇编进行比较。

追踪到boot/main.c中的bootmain(),然后追踪到readsect()。确定与readsect()中的每个语句相对应的确切汇编指令。跟踪readsect()的其余部分 并返回bootmain() ,并确定从磁盘读取内核剩余扇区的for循环的开始和结束。找出循环结束时将运行的代码,在此处设置断点,然后继续执行该断点。然后逐步完成引导加载程序的其余部分。

将断点下在 0x7c00 处,查看具体代码

1
2
[   0:7c00] => 0x7c00:	cli 
[ 0:7c01] => 0x7c01: cld

cli 将所有中断关闭

cld 指定之后发生的串处理操作的指针移动方向,大概了解就行

1
2
3
4
[   0:7c02] => 0x7c02:	xor    %ax,%ax
[ 0:7c04] => 0x7c04: mov %ax,%ds
[ 0:7c06] => 0x7c06: mov %ax,%es
[ 0:7c08] => 0x7c08: mov %ax,%ss

初始化 ax、ds、es、ss 寄存器

1
2
3
[   0:7c0a] => 0x7c0a:	in     $0x64,%al
[ 0:7c0c] => 0x7c0c: test $0x2,%al
[ 0:7c0e] => 0x7c0e: jne 0x7c0a

又是对 IO 设备进行操作,0x64 端口对应键盘控制器,test $0x2,%al表示检测 al 的第二位,也就是 bit1,查找资料发现,bit1 对应的输入的缓冲区是否已满

image-20220402171027456

所以这三条指令的意思就是:一直等到 al 的 bit1 为 0

1
2
[   0:7c10] => 0x7c10:	mov    $0xd1,%al
[ 0:7c12] => 0x7c12: out %al,$0x64

当 al 的 bit1 为 0 时,说明可以写新数据了,这两句指令的意思是将 0xd1 这个数据写入到 0x64 的端口。

当 0xd1 被写入 0x64 端口时会发生什么?

image-20220402192734391

首先向 0x64 端口写数据,代表向键盘控制器 804x 发送指令,这个指令将会被送给0x60端口。0xd1 指令代表下一次写入 0x60 端口的数据将被写入给 804x 控制器的输出端口。可以理解为下一次写入 0x60 端口的数据是一个控制指令。

1
2
3
[   0:7c14] => 0x7c14:	in     $0x64,%al
[ 0:7c16] => 0x7c16: test $0x2,%al
[ 0:7c18] => 0x7c18: jne 0x7c14

这三行是再一次等待刚刚写入的指令 0xd1,是否完全读取。

1
2
[   0:7c1a] => 0x7c1a:	mov    $0xdf,%al
[ 0:7c1c] => 0x7c1c: out %al,$0x60

将 0xdf 写入到 0x60 端口,也就是将 0xdf 指令写入 804x 控制器(简单理解成键盘的一个控制器就行)

image-20220402194308080

可以看到 A20 被打开了,什么是 A20呢?

我们知道的是 8086 的 CPU 地址总线是 20位,所以如果程序给出 21 位的 内存地址,多出来的一位就会呗忽略,举个例子就是:1 0000 00000000 00000000。那个 1 因为处在第 21 位,所以会被忽略,整个内存地址算下来就是 0,如今 CPU 已经有 32位 和 64位,但是因为兼容性,还是要保持只能用 20 位地址线的模式,所以打开 A20 地址线就相当于让地址总线突破 20 位变为 32位。

打开了 A20 说明可以进入保护模式了。

1
2
3
4
[   0:7c1e] => 0x7c1e:	lgdtw  0x7c64
[ 0:7c23] => 0x7c23: mov %cr0,%eax
[ 0:7c26] => 0x7c26: or $0x1,%eax
[ 0:7c2a] => 0x7c2a: mov %eax,%cr0

将 0x7c64 的地址加载到全局描述符表GDT,查看 0x7c64 地址为 pop %ss

1
2
(gdb) x/i 0x7c64
0x7c64: pop %ss

后面三行代码是把 cr0 寄存器赋值为 0 ,正式进入保护模式。

1
2
3
4
5
6
[   0:7c2d] => 0x7c2d:	ljmp   $0x8,$0x7c32
The target architecture is assumed to be i386
=> 0x7c32: mov $0x10,%ax
--------------------------------------------------
对应 boot.s 里的
ljmp $PROT_MODE_CSEG, $protcseg

16 位模式切换成 32 位

1
2
3
4
5
6
=> 0x7c32:	mov    $0x10,%ax
=> 0x7c36: mov %eax,%ds
=> 0x7c38: mov %eax,%es
=> 0x7c3a: mov %eax,%fs
=> 0x7c3c: mov %eax,%gs
=> 0x7c3e: mov %eax,%ss

初始化段寄存器为 0x10,因为现在处在保护模式,段寄存器中存的都是段选择子,所以 0x10 对应的是代码段描述符

至于为什么是 0x10 对应的代码段,卧槽我还真看不出为啥,只知道 gdt 是这样定义的:

第一个是NULL,第二个是代码段,第三个是数据段

1
2
3
4
5
6
7
8
9
10
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
gdt:
SEG_NULL # null seg
SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg
SEG(STA_W, 0x0, 0xffffffff) # data seg

gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt

mmu.h 里定义了 SEG 宏

1
2
3
4
#define SEG(type, base, lim, dpl) 					\
{ ((lim) >> 12) & 0xffff, (base) & 0xffff, ((base) >> 16) & 0xff, \
type, 1, dpl, 1, (unsigned) (lim) >> 28, 0, 0, 1, 1, \
(unsigned) (base) >> 24 }

笑死,更看不懂了。

还是记这个图要好点,把 0x10 转化为二进制就是 10000,从描述符索引那看起,第一个是NULL,对应的下标是 3,第二个是 code,对应的下标是 4,10000 正好对应 4

1
2
3
4
5
6
=> 0x7c40:	mov    $0x7c00,%esp
=> 0x7c45: call 0x7d15
---------------------------------------------
# Set up the stack pointer and call into C.
movl $start, %esp
call bootmain

对应着 boot.s 的代码可以知道,这两行代码是在设置堆栈指针,同时跳到 main 中的 bootmain

1
2
3
4
5
6
7
8
9
=> 0x7d15:	push   %ebp
=> 0x7d16: mov %esp,%ebp
=> 0x7d18: push %esi
=> 0x7d19: push %ebx
#readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
=> 0x7d1a: push $0x0 //参数3
=> 0x7d1c: push $0x1000 //参数2
=> 0x7d21: push $0x10000 //参数1
=> 0x7d26: call 0x7cdc //readseg

栈环境的设置,调用 readseg 函数,readseg((uint32_t) ELFHDR, SECTSIZE*8, 0)

bootmain 函数里有它的定义

1
void readseg(uchar *pa, uint count, uint offset);

它的功能从注释上来理解应该是,把距离内核起始地址offset个偏移量存储单元作为起始,将它和它之后的count字节的数据读出送入以pa为起始地址的内存物理地址处。

所以结合之前的传参说明这个函数把磁盘的第一个页(0x1000)的内容读入了内存地址为 0x10000 的地方。