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 |
|
从0x00000000到0x0009FFFF的640KB区域为Low memory,是早期PC可以使用的RAM。硬件保留的从 0x000A0000到0x000FFFFF的384KB区域用于特殊用途,例如视频显示缓冲区和非易失性存储器中保存的固件。其中最重要的是从0x000F0000到0x000FFFFF的64KB的BIOS。
BIOS
- 单步调试:
si
开始调试后,输入si
,进行第一步,显示
1 | The target architecture is assumed to be i8086 |
可以看出,第一条指令的地址是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 *addr
,addr
是以0x开头的地址;或者是b function
,function
是函数名 - 跳转到断点:打了断点后,输入
c
boot loader包括两部分,分别是boot/boot.S
和boot/main.c
。obj/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 addr
,N
缺省时,为查看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 | (gdb) x/x 0x10018 |
即入口在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.S
和kern/init.c
,obj/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 | ... |
此时,esp
和ebp
指向相同的地址。如果继续执行,则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 | // Test the stack backtrace function (lab 1 only) |
可以看出,分别会递归执行5,4,3,2,1,0,在0的时候调用mon_backtrace
。
每个函数会在自己的栈空间内做一些操作。前5次每次调用的函数栈空间为:(从上往下地址减少)
1 | 上一个ebp地址 |
实验里b *0x10000c
,经过几次si
后,进入虚拟地址。打断点b *0xf010006a
,此时54321走完了,0不需要跳了,需要执行进入mon_backtrace
的代码。前5次每次函数使用了8个栈空间,5*8=40,现在调试的时候0用了3个。然后:
1 | (gdb) x/45x $esp |
- 查看寄存器表示的内容作为地址的值:
x/x $eax
,eax
为寄存器的名字,也可以为ebp
,esp
等。但是要注意,此命令是查看寄存器内的值作为地址,在内存中的值 - 查看寄存器的值:
info registers
,查看好多寄存器的值,info registers eax
只查看eax
寄存器的值。简写分别为i r
和i 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 | static inline uint32_t |
里面是有一句汇编代码,意思是把寄存器ebp
的值给自己定义的ebp
。我一开始搞混了,以为的是栈空间内存上此函数的ebp
的值,即上一个调用它的函数的栈地址。我就说这不是跑到上一个调用它的函数去了吗,最后才发现,是当前寄存器ebp
的值,即真正指向此函数栈基址的寄存器,而不是栈空间内的值。
1 | 上一个ebp地址(2) |
此时得到的ebp
是(1)的地址,通过prev = (uint32_t *)ebp;
得到指向(1)的指针,prev[0]
就是(1)的内容,prev[1]
是b函数结束后的返回地址,prev[2,3,4]
之类的就是a函数传给b函数的参数。
最后写debug的代码没咋看懂,基本是抄的,但是结果看懂了。里面两行比较重要的代码:
1 | ebp = *prev; |
ebp
本是(1)的地址,是uint32_t
,数值型。prev
是指向(1)的指针,ebp = *prev;
使得ebp
得到了(1)的内容,为(2)的地址。然后循环。最后debug结果为:
1 | Stack backtrace: |
0的ebp
的地址为0xf010ff38,和上面的能够对应起来。执行到mon_backtrace
后:
1 | (gdb) x/x 0xf010ff18 |
测试一下i386_init
的ebp
:
1 | (gdb) x/x 0xf010fff8 |
说明整个程序的基地址为0。这也符合在kern/entry.S
调用i386_init
前movl $0x0,%ebp
。
最后在lab目录下运行make grade
,得到打分结果。可能运行会出错,大神的笔记里面也有解决方案。最后终于得到了5个OK,Lab1应该算是结束了。