6.828笔记 Lab 1: Booting a PC

答辩结束后,终于开始入坑MIT6.828,正儿八经地学一下操作系统。使用的是2018年秋季的课程,现在好像改了,不叫这个名字了,这些都无所谓了。这里是官网。写这个东西也只是想让自己每次做完后记录一下,不然以后时间长了又忘了。

由于网上已经有很多人做了这个课程,也写了笔记,所以在我自己的笔记里,只会按着Lab,写一些我自己遇到的坑,或者自己感觉重要的东西,不会每个问题都记录。这篇文章会记录实验环境的准备和Lab1里的一些内容。在我做的过程中,大量参考此大神的笔记,在此表达感谢!

实验环境

本来不想开虚拟机,就在网上找了一篇使用WSL2的教程,结果做着做着出问题了,所以最后还是换成了虚拟机。用的是ubuntu18.04,虚拟机的话就比较好弄,按着官网给出的操作做,或者自己在网上找教程都行。主要是下载他的QEMU模拟器,下载了以后运行make && make install的时候会报各种错,直接网上搜,缺啥东西直接下载就行了,其中有两个是要修改源文件,include一点东西。都可以直接搜报错信息,网上都有。

Lab 1

下载jos到lab后,就可以运行了。先记录几个常用的命令。

  • 开机:在lab目录下,终端输入make qemu
  • 调试:在lab目录下,一个终端输入make qemu-gdb,另一个终端输入make gdb
  • 关机:在开启的操作系统里先输入ctrl+a,然后再按x

PC的物理地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

+------------------+ <- 0xFFFFFFFF (4GB)
| 32-bit |
| memory mapped |
| devices |
| |
/\/\/\/\/\/\/\/\/\/\

/\/\/\/\/\/\/\/\/\/\
| |
| Unused |
| |
+------------------+ <- depends on amount of RAM
| |
| |
| Extended Memory |
| |
| |
+------------------+ <- 0x00100000 (1MB)
| BIOS ROM |
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display |
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000

从0x00000000到0x0009FFFF的640KB区域为Low memory,是早期PC可以使用的RAM。硬件保留的从 0x000A0000到0x000FFFFF的384KB区域用于特殊用途,例如视频显示缓冲区和非易失性存储器中保存的固件。其中最重要的是从0x000F0000到0x000FFFFF的64KB的BIOS。

BIOS

  • 单步调试:si

开始调试后,输入si,进行第一步,显示

1
2
3
4
5
6
7
The target architecture is assumed to be i8086
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel
(gdb) si
[f000:e05b] 0xfe05b: cmpl $0x0,%cs:0x6ac8
0x0000e05b in ?? ()

可以看出,第一条指令的地址是0x000ffff0。在上图中,就是BIOS ROM中的一部分。QEMU 模拟了8088处理器的启动,启动电源时,处理器进入实模式并且将CS设置为0xf000,将IP设置为0xfff0。机器要运行的地址为CS*16+IP。也就是CS左移一位,再加上IP,即0x000ffff0。这样一开机,BIOS就取得了机器的控制权。BIOS 运行时,检查硬件,进行各种初始化,例如VGA显示。完成后,搜索可引导设备,例如软盘,硬盘驱动器或CD-ROM。 最终,BIOS在找到可引导磁盘时,会从磁盘读取boot loader并将控制权转移给boot loader。

The Boot Loader

当BIOS找到可引导的软盘或硬盘时,它将512字节的引导扇区加载到物理地址0x7c00至0x7dff的内存中,这部分在上图中的Low Memory部分,然后使用jmp指令将CS:IP设置为0000:7c00,地址为0x00007c00。

  • 设置断点:b *addraddr是以0x开头的地址;或者是b functionfunction是函数名
  • 跳转到断点:打了断点后,输入c

boot loader包括两部分,分别是boot/boot.Sboot/main.cobj/boot/boot.asm是编译boot loader 后的反汇编。obj/boot/boot.asm里一开始就是00007c00 <start>:,说明地址确实是0x7c00。
boot/boot.S里前面进行了一系列的操作,目的是将处理器从实模式切换到32位保护模式,这样能访问大于1MB 的物理地址空间。最后会call bootmain,即进入boot/main.c/bootmain
boot/main.c的主要作用是从硬盘中读取内核。首先将磁盘中0地址开始的512*8个byte,即4k的elf header读入0x10000地址。注意,此地址还是在Low Memory中。然后再根据elf header将kernel的每个segment读入内存。最后通过ELFHDR->e_entry进入ELF文件的进入点,即内核。

boot/main.c做的事情:

  • 从硬盘中将kernel的elf header读入内存
  • 根据elf header和program header table提供的信息,将kernel的每个segment读入内存
  • 进入kernel

操作:

  • 查看某个内存地址开始的N个字的十六进制内容:x/Nx addrN缺省时,为查看addr处1个字的十六进制。把第二个x换成i时,为查看指令。把第二个x换成s时,为查看字符串

在刚进入boot/main.c时,还没有加载elf header时,查看0x10000处的内容,为全0。跳转到加载了elf header但没有加载segment的位置后,查看0x10000,有内容。但是0x100000处为全0。跳转到加载完segment后的地方,查看0x100000,已经有内容了。最后进入内核的汇编代码为

1
call   *0x10018

即跳转到地址为0x10018的内存里面的内容。查看0x10018的内容,为:

1
2
(gdb) x/x 0x10018
0x10018: 0x0010000c

即入口在0x100000后一点的地方。

总结:从开机到进入操作系统的过程:
1. 运行BIOS里面的内容,检查硬件,最后BIOS会加载boot loader到Low Memory处
2. 进入boot loader,将处理器从实模式切换到32位保护模式,并把elf header读入Low Memory
3. 根据elf header和program header table提供的信息,将kernel的每个segment读入Extended Memory,最后通过ELFHDR->e_entry进入内核

The Kernel

在本文中主要需要看的是kern/entry.Skern/init.cobj/kern/kernel.asm是kernel的反汇编。

虚拟地址映射

操作系统内核通常被链接到非常高的虚拟地址(查看obj/kern/kernel.asm,就可以发现kernel被链接到0xf0100000)下运行,以便留下处理器虚拟地址空间的低地址部分供用户程序使用。 但是实际上无法达到0xf0100000,所以使用地址映射,将0xf0100000(内核代码期望运行的链接地址)映射到物理地址0x00100000。总体上会把0xf0000000到0xf0400000映射到物理地址0x00000000到0x00400000。
kern/entry.S前面一部分就是做的这个事情。执行mov %eax,%cr0后,0xf0100000的数据从0变成了与0x100000的数据一致,这说明,虚拟地址0xf0100000已经被映射到了0x100000。

调用栈

这里先记录一下调用栈的过程。
内存中有一个栈空间,栈底在上面,地址大,栈顶在下面,地址小。每一个函数都有自己的栈空间。介绍三个寄存器,分别是eip(Instruction Pointer)ebp(Base Pointer)esp(Stack Pointer),意思是下一条指令的地址、栈底地址和栈顶地址。
函数调用时,比如a函数调用b函数,b的声明为void b(int x, int y),则会在a的栈空间内反向把参数压入栈中,即先压入y,再压入x。调用b的时候,也就是call b,根据课程提供的文件call的时候先把eip中的值放入栈中,再把要call的地址放入eip,则下一条指令就跳过去了。进入b函数后,先把此时的ebp的值压入栈中,即a函数的栈底的地址,然后把esp的值赋给ebp,即让ebp指向b函数的基址。类似于这样:

1
2
3
4
5
6
7
...
a上面的栈空间
y
x
调用前eip的值,即返回a函数后的下一条指令的地址(2)
之前的ebp值,即a函数的栈底的地址(1)
<----------------------------------(esp)(ebp)

此时,espebp指向相同的地址。如果继续执行,则esp减小,ebp不动。执行完返回时,会出栈到ebp的位置,即栈顶为(1),然后pop %ebp,即把当前栈顶的内容(1)给ebp并弹出此内容,此时ebp就是a函数的栈底的地址。最后ret,即pop %eip,意思是把(2)的内容给eip,现在就能返回了。返回后的下一条地址就是(2),ebp仍然指向之前a函数的栈底。

回到Lab1。上面讲到虚拟地址映射,映射后,kern/entry.S又做了一些操作后call i386_init,即进入kern/init.c/i386_init。在kern/init.c/i386_init里面有一句test_backtrace(5);test_backtrace的代码为:

1
2
3
4
5
6
7
8
9
10
11
// Test the stack backtrace function (lab 1 only)
void
test_backtrace(int x)
{
cprintf("entering test_backtrace %d\n", x);
if (x > 0)
test_backtrace(x-1);
else
mon_backtrace(0, 0, 0);
cprintf("leaving test_backtrace %d\n", x);
}

可以看出,分别会递归执行5,4,3,2,1,0,在0的时候调用mon_backtrace
每个函数会在自己的栈空间内做一些操作。前5次每次调用的函数栈空间为:(从上往下地址减少)

1
2
3
4
5
6
7
8
上一个ebp地址
esi
ebi
不知道是啥,直接空出来的
不知道是啥,直接空出来的
当前调用的数字
当前调用的数字-1
返回值,即调用下个函数结束后的返回地址

实验里b *0x10000c,经过几次si后,进入虚拟地址。打断点b *0xf010006a,此时54321走完了,0不需要跳了,需要执行进入mon_backtrace的代码。前5次每次函数使用了8个栈空间,5*8=40,现在调试的时候0用了3个。然后:

1
2
3
4
5
6
7
8
9
10
11
12
13
(gdb) x/45x $esp
0xf010ff30: 0xf0111308 0x00000001 0xf010ff58 0xf01000a1
0xf010ff40: 0x00000000 0x00000001 0xf010ff78 0xf010004a
0xf010ff50: 0xf0111308 0x00000002 0xf010ff78 0xf01000a1
0xf010ff60: 0x00000001 0x00000002 0xf010ff98 0xf010004a
0xf010ff70: 0xf0111308 0x00000003 0xf010ff98 0xf01000a1
0xf010ff80: 0x00000002 0x00000003 0xf010ffb8 0xf010004a
0xf010ff90: 0xf0111308 0x00000004 0xf010ffb8 0xf01000a1
0xf010ffa0: 0x00000003 0x00000004 0x00000000 0xf010004a
0xf010ffb0: 0xf0111308 0x00000005 0xf010ffd8 0xf01000a1
0xf010ffc0: 0x00000004 0x00000005 0x00000000 0xf010004a
0xf010ffd0: 0xf0111308 0x00010094 0xf010fff8 0xf01000f4
0xf010ffe0: 0x00000005
  • 查看寄存器表示的内容作为地址的值:x/x $eaxeax为寄存器的名字,也可以为ebpesp等。但是要注意,此命令是查看寄存器内的值作为地址,在内存中的值
  • 查看寄存器的值:info registers,查看好多寄存器的值,info registers eax只查看eax寄存器的值。简写分别为i ri r eax

前面的是地址,后面是值。上面讲到,调试时0已经用了3个,其他54321分别使用了8个,所以地址低的0xf010ff58是调用0时上一个ebp地址,即1的ebp。再往上数8个就是真实的那个地方。这个地方存的内容为0xf010ff78,即2的ebp。再根据前面的地址0xf010ff50可以算出0xf010ff78的地址为0xf010ff58,和前面的0的放的内容一致。

这里记录一个坑,在实验里面让补充一个函数,使得能够在mon_backtrace中看到已有的ebp地址之类的。里面调用了ebp = read_ebp();read_ebp()的代码:

1
2
3
4
5
6
7
static inline uint32_t
read_ebp(void)
{
uint32_t ebp;
asm volatile("movl %%ebp,%0" : "=r" (ebp));
return ebp;
}

里面是有一句汇编代码,意思是把寄存器ebp的值给自己定义的ebp。我一开始搞混了,以为的是栈空间内存上此函数的ebp的值,即上一个调用它的函数的栈地址。我就说这不是跑到上一个调用它的函数去了吗,最后才发现,是当前寄存器ebp的值,即真正指向此函数栈基址的寄存器,而不是栈空间内的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
上一个ebp地址(2)
esi
ebi
不知道是啥,直接空出来的
不知道是啥,直接空出来的
当前调用的数字
当前调用的数字-1
返回值,即调用下个函数结束后的返回地址
------------------上面是a函数,下面是b函数,a函数调用b函数-----------------------
上一个ebp地址(1)
esi
ebi
不知道是啥,直接空出来的
不知道是啥,直接空出来的
当前调用的数字
当前调用的数字-1
返回值,即调用下个函数结束后的返回地址

此时得到的ebp是(1)的地址,通过prev = (uint32_t *)ebp;得到指向(1)的指针,prev[0]就是(1)的内容,prev[1]是b函数结束后的返回地址,prev[2,3,4]之类的就是a函数传给b函数的参数。
最后写debug的代码没咋看懂,基本是抄的,但是结果看懂了。里面两行比较重要的代码:

1
2
ebp = *prev;
prev = (uint32_t*)ebp;

ebp本是(1)的地址,是uint32_t,数值型。prev是指向(1)的指针,ebp = *prev;使得ebp得到了(1)的内容,为(2)的地址。然后循环。最后debug结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Stack backtrace:
ebp f010ff18 eip f0100078 args 00000000 00000000 00000000 f010004a f0111308
kern/init.c:18: test_backtrace+56 //mon_backtrace
ebp f010ff38 eip f01000a1 args 00000000 00000001 f010ff78 f010004a f0111308
kern/init.c:16: test_backtrace+97 //0
ebp f010ff58 eip f01000a1 args 00000001 00000002 f010ff98 f010004a f0111308
kern/init.c:16: test_backtrace+97 //1
ebp f010ff78 eip f01000a1 args 00000002 00000003 f010ffb8 f010004a f0111308
kern/init.c:16: test_backtrace+97 //2
ebp f010ff98 eip f01000a1 args 00000003 00000004 00000000 f010004a f0111308
kern/init.c:16: test_backtrace+97 //3
ebp f010ffb8 eip f01000a1 args 00000004 00000005 00000000 f010004a f0111308
kern/init.c:16: test_backtrace+97 //4
ebp f010ffd8 eip f01000f4 args 00000005 00001aac 00000640 00000000 00000000
kern/init.c:39: i386_init+78 //5
ebp f010fff8 eip f010003e args 00000003 00001003 00002003 00003003 00004003
kern/entry.S:83: <unknown>+0 //i386_init

0的ebp的地址为0xf010ff38,和上面的能够对应起来。执行到mon_backtrace后:

1
2
(gdb) x/x 0xf010ff18
0xf010ff18: 0xf010ff38

测试一下i386_initebp

1
2
(gdb) x/x 0xf010fff8
0xf010fff8: 0x00000000

说明整个程序的基地址为0。这也符合在kern/entry.S调用i386_initmovl $0x0,%ebp

最后在lab目录下运行make grade,得到打分结果。可能运行会出错,大神的笔记里面也有解决方案。最后终于得到了5个OK,Lab1应该算是结束了。