关于这套课程的介绍:https://www.cnblogs.com/fatsheep9146/p/5060292.html
获取实验1代码:
$ mkdir ~/6.828
$ cd ~/6.828
$ git clone https://pdos.csail.mit.edu/6.828/2018/jos.git lab
$ cd lab
1、Part : PC Bootstrap
这一部分学习计算机引导程序,计算机开机之后,首先运行 BIOS(基本输入输出系统),然后从启动盘的引导扇区加载操作系统启动程序,这是操作系统启动前的所做的必要准备。早期的计算机采用实模式,一个程序可以访问所有的内存,这样操作系统很容易被恶意程序破坏,也不易实现分时共享。自从 x80386 之后,CPU 开始有了保护模式,即采用页表来管理内存,一个 32 位系统,每个进程都有一个页表,可以使用整整 4GB 内存。每个进程都有一个页表,页表决定一个进程可以访问哪些内存。而且一个进程不能随意访问其他进程的内存,因而容易实现时分共享。
BIOS 加载了引导程序,然后通过修改 EIP 的值,把控制权交给引导程序。
1. Exercise
主要内容是了解基本汇编语言。
使用 QEMU 可以模拟 PC 启动操作系统。
$ make qemu
qemu-system-i386 -drive file=obj/kern/kernel.img,index=0,media=disk,format=raw -serial mon:stdio -gdb tcp::25226 -D qemu.log
6828 decimal is XXX octal!
entering test_backtrace 5
entering test_backtrace 4
entering test_backtrace 3
entering test_backtrace 2
entering test_backtrace 1
entering test_backtrace 0
leaving test_backtrace 0
leaving test_backtrace 1
leaving test_backtrace 2
leaving test_backtrace 3
leaving test_backtrace 4
leaving test_backtrace 5
Welcome to the JOS kernel monitor!
Type 'help' for a list of commands.
K>
这样就启动操作系统了。
PC 物理内存空间
+------------------+ <- 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
早期 PC 采用 16位地址线,只能使用 1MB 内存空间,0x00000 到 0xFFFFF。早期 PC 只能使用前 640KB 内存。
0x000A0000 到 0x000FFFFF 的384KB,被保留给了硬件使用,例如视频输出缓冲。最重要的部分是 BIOS。早期 BIOS 烧录在只读内存中,一经写入就不可更改,优点是断电数据不会丢失,缺点是不能升级。现代 PC 把 BIOS 存储在可更新闪存中。BISO 负责初始化系统,例如激活显卡,检查已经安装的内存。初始化完成后,BIOS 从一些适当的位置(例如软盘,硬盘,CD-ROM或网络)加载操作系统引导程序,并将机器的控制权交给引导程序。
现在 X86 CPU 已经支持超过 4GB 内存,CPU 寻址可以达到 0xFFFFFFFF,在这种情况下,BIOS 必须安排在系统 RAM 中 32 位可寻址区域的顶部留出第二个孔,为了给 32 位设备作映射。
The ROM BIOS
在这部分,我们将使用 QEMU的调试工具来研究一个 IA-32 兼容计算机如何启动。
启动两个终端,两个终端都进入 lab 目录。第一个终端执行命令make qemu-gdb
,另一个终端执行命令make gdb
。
terminal 1:
$ make qemu-gdb
***
*** Now run 'make gdb'.
***
qemu-system-i386 -drive file=obj/kern/kernel.img,index=0,media=disk,format=raw -serial mon:stdio -gdb tcp::25226 -D qemu.log -S
terminal 2:
$ make gdb
......
Type "apropos word" to search for commands related to "word".
+ target remote localhost:25226
warning: No executable has been specified and target does not support
determining executable automatically. Try using the "file" command.
The target architecture is set to "i8086".
[f000:fff0] 0xffff0: ljmp $0x3630,$0xf000e05b
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel
(gdb)
可能是 bug,倒数第 4 行,官方网站为:[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
上述指令是计算机执行的第一条指令,从指令本身可以得出以下信息:
- 这台计算机从物理地址 0x000ffff0 开始执行,处在 ROM BIOS 所在内存区域的顶部。
- PC 执行时,CS = 0xf000,IP = 0xfff0
- 第一条指令是跳转指令,跳转到 cs = 0xf000, IP = 0xe05b
BIOS 是硬接线到内存的 0x000f0000-0x000fffff,这个设计保证了BIOS 在计算机开机的时候能接管计算机。当计算机重置的时候,计算机将进入实模式,并设置 CS = 0xf000 ,IP = 0xfff0
物理地址=段地址*16+偏移地址
2. Exercise
使用 GDB 的
si
命令来逐指令跟踪 ROM BIOS 的执行过程。BIOS 主要的功能:设置中断描述符表,初始化设置(比如 VGA 显示器)。初始化完 PCI 总线以及所有重要的设备,BIOS 从磁盘中读取引导程序,然后把控制权交给它。
2、Part: The Boot Loader(引导程序)
不管是软盘还是硬盘,都由一个个 512 字节的扇区(sector)组成。每个读取或写入操作必须是一个或多个扇区,并在扇区边界对齐。如果磁盘是启动盘,则第一个扇区就是引导扇区(boot sector),就是引导程序存储的位置。当 BIOS 识别了启动盘后,它就会把第一个扇区的 512 字节装载到内存地址 0x7c00 到 0x7dff,并且使用跳转指令(jmp) 设置 CS:IP 为 0000:7c00,这是 PC 的普遍标准。
从 CD-ROM 启动是在后期产生的,这种方式更复杂,功能也更强大,CD-ROM 的扇区大小是 2048 字节,BIOS 可以加载一个更大的引导程序。
XV6使用传统方式,从磁盘中启动系统,意味着它的引导程序只能是 512 字节。这个引导程序包含两个文件,一个是汇编语言文件: boot/boot.S,一个 c 语言文件:boot/main.c。
引导程序必须完成两个功能:
- 将 CPU 从实模式(real model) 转换为 32位保护模式,因为只有这样软件才能使用所有超过 1MB 内存空间。
- 从通过x86 的 特殊 I/O 指令从直接从磁盘加载内核程序。
3. Exercise
看看这个网页: lab tools guide,熟悉下一些工具的使用,特别是一些 GDB 命令。以下是一些重要 GDB 指南:在 0x7c00 设置断点,使用
c
运行到该断点处,boot/boot.S 从这开始运行。根据源文件和反汇编文件obj/boot/boot.asm 来追踪自己运行到哪。使用x/i
来反汇编指令序列。比较引导程序源文件(boot/boot.S),对应的反汇编文件(obj/boot/boot.asm) 和 GDB。
加载内核
4. Exercise
阅读一本C 编程书,特别是关于指针部分。推荐《The C Programming Language》,这本书是由 C语言作者编写的, 阅读该书的5.1到5.5。下载并运行代码:pointers.c,并确保理解所有打印内容。
了解一些 ELF 文件的内容,参考链接:https://pdos.csail.mit.edu/6.828/2018/readings/elf.pdf,https://www.cnblogs.com/gatsby123/p/9750187.html
一个 ELF 文件以一个变长的ELF头部开始,随后是一个变长的程序头,列出要加载的每个程序段。关于 ELF 的头部定义信息在 inc/elf.h。比较重要的程序段如下:
- .text 包含可执行指令
- .rodata 包含只读数据,包括只读变量(const修饰的变量和字符串常量)
- .data 包含全局变量和局部静态变量
当连接器计算程序的布局的时候,它会给未初始化变量预留空间。.bss 段在 .data 段之后。c 语言中未初始化全局变量变量的值为0。不需要在 ELF 二进制文件中存储 .bss 的内容; 相反,链接器只记录 .bss 部分的地址和大小。 加载程序或程序本身必须将 .bss 部分归零。
使用以下命令可以查看 kernel 所有的节点表的名字,大小和连接地址:
objdump -h obj/kern/kernel
特别注意 .text 部分的“VMA”(或链接地址)和“LMA”(或加载地址)。段的加载地址是该段应加载到内存的内存地址。段的链接地址是该段期望执行的内存地址。链接器以各种方式对二进制文件中的链接地址进行编码,例如当代码需要全局变量的地址时,结果是,如果从未链接的地址执行二进制文件,则它通常无法工作 . (可以生成不包含任何此类绝对地址的与位置无关的代码。这被现代共享库广泛使用,但它具有性能和复杂性成本,因此我们不会在 6.828 中使用它。)
通常情况下连接地址和加载地址是相同的,如objdump -h obj/boot/boot.out
通过查看ELF program headers 可以决定将哪些段加载进内存和应该拷贝到什么地址。使用命令objdump -x obj/kern/kernel
查看内核ELF program headers。需要加载到内存中的 ELF 对象的区域是那些标记为“LOAD”的区域。 给出了每个程序头的其他信息,例如虚拟地址(“vaddr”)、物理地址(“paddr”)和加载区域的大小(“memsz”和“filesz”)。
回到 boot/main.c,每个程序头的 ph->p_pa 字段包含了段的目标物理地址(在这种情况下,它确实是一个物理地址,尽管 ELF 规范对这个字段的实际含义很模糊) 。
使用 -Ttext 0x7C00
指定链接地址。例如:boot/Makefrag。
5. Exercise
再跟踪一些引导程序,将 boot/Makefrag里的链接地址改为其他值,再重新编译,看发生了什么?记得改回来。
$ make clean
$ make
$ make qemu
objdump -f obj/kern/kernel
查看kernel ELF文件头
您现在应该能够理解 boot/main.c 中的最小 ELF 加载程序。 它将内核的每个部分从磁盘读取到该部分加载地址的内存中,然后跳转到内核的入口点。
6. Exercise
在BIOS启动boot loader和进入内核时,地址0x00100000处的连续8个字有什么不同?为什么?回答这个问题不需要运行qemu,请思考这个问题。
BIOS启动boot loader 还处于实模式,0x00100000 处还不存在内容,都为0。
(gdb) x/8x 0x00100000
0x100000: 0x00000000 0x00000000 0x00000000 0x00000000
0x100010: 0x00000000 0x00000000 0x00000000 0x00000000
启动内核时,已经是 32 位保护模式,0x00100000 已经有了代码。
(gdb) x/8x 0x00100000
0x100000: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
0x100010: 0x34000004 0x2000b812 0x220f0011 0xc0200fd8
3、Part : The Kernel
和引导程序一样,内核首先执行一段汇编代码,然后执行 C 代码。
虚拟内存
操作系统内核往往喜欢被链接并运行在非常高的虚拟地址,例如0xf0100000,以便将处理器虚拟地址空间的较低部分留给用户程序使用。 这种安排的原因将在下一个实验中变得更加清晰。
许多机器在地址 0xf0100000 处没有任何物理内存,因此我们不能指望能够在那里存储内核。 相反,我们将使用处理器的内存管理硬件将虚拟地址 0xf0100000(内核代码预期运行的链接地址)映射到物理地址 0x00100000(引导加载程序将内核加载到物理内存的位置)。 这样,虽然内核的虚拟地址足够高,可以为用户进程留下足够的地址空间,但它会被加载到 PC RAM 中 1MB 点的物理内存中,就在 BIOS ROM 的上方。 这种方法要求 PC 至少有几兆字节的物理内存(这样物理地址 0x00100000 才能工作),但这可能适用于大约 1990 年以后制造的任何 PC。
在下一个实验中,我们将映射所有 256MB 内存。从0x00000000 到 0x0fffffff,将以上内存空间映射到虚拟内存0xf0000000 到 0xffffffff。
在 kern/entry.S 设置 CR0_PG 标志之前,内存引用被视为物理地址(严格来说,它们是线性地址,但 boot/boot.S 设置了从线性地址到物理地址的身份映射,我们永远不会改变这一点)。一旦设置了 CR0_PG,内存引用就是由虚拟内存硬件转换为物理地址的虚拟地址。 entry_pgdir 将 0xf0000000 到 0xf0400000 范围内的虚拟地址转换为物理地址 0x00000000 到 0x00400000,以及将虚拟地址 0x00000000 到 0x00400000 转换为物理地址 000000 到 0x00400000。任何不在这两个范围内的虚拟地址都会导致硬件异常。
7. Exercise
(1) 使用 QEMU 和 GDB 调试 JOS kernel,停在指令
movl %eax, %cr0
处,查看内存0x00100000 和 0xf0100000处的值,使用si
指令跳到下一条指令, 再次查看内存0x00100000 和 0xf0100000处的值。确保你理解这些内容。(2) 尝试注释掉这条指令:
movl %cr0, %eax
,看看会发生什么?
(1)
=> 0x100025: mov %eax,%cr0
0x00100025 in ?? ()
(gdb) x/4x 0x00100000
0x100000: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
(gdb) x/4x 0xf0100000
0xf0100000 <_start-268435468>: 0x00000000 0x00000000 0x00000000 0x00000000
(gdb) si
=> 0x100028: mov $0xf010002f,%eax
0x00100028 in ?? ()
(gdb) x/4x 0x00100000
0x100000: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
(gdb) x/4x 0xf0100000
0xf0100000 <_start-268435468>: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
(gdb)
从上述输出可以看到,movl %eax, %cr0
指令执行前,0x00100000 里有数据,0xf0100000 没有数据。执行movl %eax, %cr0
后,0x00100000 和 0xf0100000具有相同的数据。说明,虚拟地址 0xf0100000 映射到了物理地址 0x00100000 。
(2)
=> 0xf010002c <relocated>: add %al,(%eax)
relocated () at kern/entry.S:74
74 movl $0x0,%ebp # nuke frame pointer
(gdb)
Remote connection closed
(gdb)
注释之后,执行的第一条指令是mov $0xf010002c,%eax
,可以看到此时地址已经映射到了虚拟地址0xf010002c。当页表没法正常开启,0xf0100000之后的地址都是无效的,所以再往后执行系统就崩溃了。
格式化输出到控制台
在 kernel 中不能再使用像 printf 这样的库函数,我们必须自己实现所有的 I/O。
8. Exercise
补充代码,使其能支持八进制数输出?
需要修改的代码是:printfmt.c 文件中 printfmt()函数,修改 case ‘o’: 之后的代码如下:
case 'o':
// Replace this with your code.
num = getuint(&ap,lflag);
base = 8;
goto number;
回答以下问题?
(1) 解释 printf.c 和 console.c 之间的接口,特别是 console.c 提供了什么接口? printf.c 怎么使用这些接口?
console.c 提供了了接口cputchar(int c),参数是 ASCII 码,printf 将需要输出的字符传递给 cputchar, cputchar 通过调用 cons_putc 输出。
(2) 解释下面这段来自console.c的代码片段。
if (crt_pos >= CRT_SIZE) {
int i;
memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
crt_buf[i] = 0x0700 | ' ';
crt_pos -= CRT_COLS;
}
crt_pos:当前输出位置指针,指向内存区中对应输出映射地址。
CRT_SIZE:是CRT_COLS和CRT_ROWS的乘积,即2000=80*25,是不翻页时一页屏幕最大能容纳的字数
crt_buf:输出缓冲区内存映射地址
CRT_COLS:默认输出格式下整个屏幕的列数,为80
CRT_ROWS:默认输出格式下整个屏幕的行数,为25
unit16_t:typedef unsigned short 正好两字节,可以分别用来表示当前要打印的字符ASCII码和打印格式属性。
函数:
memmove(): memmove(void *dst, const void *src, size_t n).意为将从src指向位置起的n字节数据送到dst指向位置,可以在两个区域重叠时复制。
当前位置指针的值大于屏幕能容纳的最大范围,将屏幕的所有内容向前移动一行,之后把关闭移动最后一行的行首。
(3) 单步调试以下代码:
int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);
在对 cprintf() 的调用中,fmt 指向什么? ap 指向什么?
fmt指向字符串"x %d, y %x, z %d\n",它本省就是一个指向字符串的指针。ap指向后面的变参。
按照执行顺序列出每一个对cons_putc,va_arg,和vcprintf的调用。对于cons_putc,列出它的变量。对于va_arg,列出调用前和调用后ap指向什么。对于vcprintf,列出它的两个参数的变量。
调用顺序为:cprintf->vcprintf->vprintfmt->putch->cputchar->cons_putc。
cons_putc 它的变量为 待输出字符 ASCII 码。
va_arg 调用前指向x,调用后指向y。va_start宏识别并指向第一个变参,va_arg一个一个依次指向接下来的变参。关于可变参数的相关内容可以参考:va_list/va_start/va_arg/va_end深入分析
(4) 运行以下代码
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);
输出是什么?用上一个练习的方法,解释输出是怎样实现的。需要ASCII码表。
将该代码放入 monitor.c 文件中,如下
cprintf("Welcome to the JOS kernel monitor!\n");
cprintf("Type 'help' for a list of commands.\n");
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s\n", 57616, &i);
输出如下:
Welcome to the JOS kernel monitor!
Type 'help' for a list of commands.
He110 World
运行 cprintf("H%x Wo%s\n", 57616, &i);
,接着调用可变参数函数cprintf(const char *fmt, ...)
,此函数调用 va_start(ap, fmt);
完成 ap 的初始化,之后使用va_arg便可获得每一个参数。接着调用 vcprintf(fmt, ap);
此函数初始化输出字符数,接着调用vprintfmt((void*)putch, &cnt, fmt, ap);
,该函数通过%x 可知要输出十六进制数,先使用va_arg(*ap, unsigned int);
获得unsigned int 值。调用 printnum(putch, putdat, num, base, width, padc);
输出十六进制值。采用递归的方式倒叙输出即可。“0123456789abcdef”[n],该表达式返回字符串"0123456789abcdef"下标为 n 的字符。
0x646c72,x86 CPU 按小端序存储,从低地址到高地址存储为 72 6c 64,以字符串形式输出,即为rld
输出取决于 x86 是小端的这一事实。 如果 x86 是 big-endian,您会将 i 设置为什么以产生相同的输出? 您是否需要将 57616 更改为不同的值?
如果 x86 是 big-endian,i 必须设置为0x00726c64。不需要改变57616,因为它作为一个立即数,和字节序无关。
(5) 在以下的代码中,‘y=’ 之后会打印什么?(提示:答案不是一个具体的数) 为什么会这样?
cprintf("x=%d y=%d", 3);
字符串需要两个参数,但只提供了 1 个,再获取第 2 个参数的时候,将会是一个随机值。
(6) GCC改变了它调用习惯,它将变量按声明顺序压栈,所以最后一个变量被最后压栈。你应该怎样改变cprintf或者它的接口,才可以仍能传递可变个数个参数?
将所有参数倒置即可。例如要使用 cprintf(“x=%d y=%d”, 3,4); 改为 cprintf(“x=%d y=%d”, 4,3);
The Stack
9 Exercise
找出内核初始化内核栈的地方,和内核栈加载到主存的位置。内核是如何为栈保存这个区域的?
确定内核初始化其堆栈的位置(代码),以及其堆栈在内存中的确切位置。 内核如何为其堆栈保留空间? 这个栈指针初始化指向的保存区域的终点是哪里?
内核在entry.S 中初始化栈,根据obj/kernel/kernel.asm 可知
# Set the stack pointer
movl $(bootstacktop),%esp
f0100034: bc 00 00 11 f0 mov $0xf0110000,%esp
栈顶的地址为虚拟地址 0xf0110000,该逻辑地址的物理地址映像是0x00110000。
根据 entry.S
bootstack:
.space KSTKSIZE
.globl bootstacktop
bootstacktop:
可以看出栈的最低地址是 bootstacktop - KSTKSIZE。
10. Exercise
熟悉 x86 C 的调用约定,找到obj/kern/kernel.asm 文件中 test_backtrace 函数的地址,设置该地址为断点,看看它每次被调用会发生什么,test_backtrace 的每个递归嵌套向堆栈推送多少个 32 位字,这些 32 位字是什么?
调用约定:
- 过程(这是 C 调用函数的更为通用的术语)必须保存 EBX,ESP,EBP,ESI和EDI这些32位寄存器,这些寄存器必须与调用之前相同。
- 如果返回值是 32 位或者更小位数,这些值将被返回到 EAX 中。如果是 64 位整数值,则返回到 EDX 和 EAX 中,其中低 32 位放到 EDX 中,高 32 位放在 EAX 中。如果是浮点型返回值,则返回到浮点堆栈的顶部。如果是字符串,结构或者其他位数大于 32 位的数据项,则通过引用返回,即该过程返回一个指向它们的 32 位指针到 EAX 中。
- 传递给程序的参数被以相反的顺序压入堆栈中。例如,给定 MyFunc(foo, bar, bas),则 bas 被第一个压入堆栈,bar 第二个压入堆栈,foo 第三个压入堆栈。
- 过程本身并不从堆栈中移除参数。主调程序必须在过程返回之后做这件事情。最常见的方法是向堆栈指针寄存器 ESP 上添加一个偏移地址。
test_backtrace
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);
}
调用方式test_backtrace(5)
递归调用结果:
entering test_backtrace 5
entering test_backtrace 4
entering test_backtrace 3
entering test_backtrace 2
entering test_backtrace 1
entering test_backtrace 0
leaving test_backtrace 0
leaving test_backtrace 1
leaving test_backtrace 2
leaving test_backtrace 3
leaving test_backtrace 4
leaving test_backtrace 5
test_backtrace(int x) 的反汇编代码如下:
// Test the stack backtrace function (lab 1 only)
void
test_backtrace(int x)
{
f0100040: 55 push %ebp
f0100041: 89 e5 mov %esp,%ebp
f0100043: 56 push %esi
f0100044: 53 push %ebx
f0100045: e8 84 01 00 00 call f01001ce <__x86.get_pc_thunk.bx>
f010004a: 81 c3 be 12 01 00 add $0x112be,%ebx
f0100050: 8b 75 08 mov 0x8(%ebp),%esi
cprintf("entering test_backtrace %d\n", x);
f0100053: 83 ec 08 sub $0x8,%esp
f0100056: 56 push %esi
f0100057: 8d 83 78 07 ff ff lea -0xf888(%ebx),%eax
f010005d: 50 push %eax
f010005e: e8 29 0a 00 00 call f0100a8c <cprintf>
if (x > 0)
f0100063: 83 c4 10 add $0x10,%esp
f0100066: 85 f6 test %esi,%esi
f0100068: 7e 29 jle f0100093 <test_backtrace+0x53>
test_backtrace(x-1);
f010006a: 83 ec 0c sub $0xc,%esp
f010006d: 8d 46 ff lea -0x1(%esi),%eax
f0100070: 50 push %eax
f0100071: e8 ca ff ff ff call f0100040 <test_backtrace>
f0100076: 83 c4 10 add $0x10,%esp
else
mon_backtrace(0, 0, 0);
cprintf("leaving test_backtrace %d\n", x);
f0100079: 83 ec 08 sub $0x8,%esp
f010007c: 56 push %esi
f010007d: 8d 83 94 07 ff ff lea -0xf86c(%ebx),%eax
f0100083: 50 push %eax
f0100084: e8 03 0a 00 00 call f0100a8c <cprintf>
}
f0100089: 83 c4 10 add $0x10,%esp
f010008c: 8d 65 f8 lea -0x8(%ebp),%esp
f010008f: 5b pop %ebx
f0100090: 5e pop %esi
f0100091: 5d pop %ebp
f0100092: c3 ret
mon_backtrace(0, 0, 0);
f0100093: 83 ec 04 sub $0x4,%esp
f0100096: 6a 00 push $0x0
f0100098: 6a 00 push $0x0
f010009a: 6a 00 push $0x0
f010009c: e8 da 07 00 00 call f010087b <mon_backtrace>
f01000a1: 83 c4 10 add $0x10,%esp
f01000a4: eb d3 jmp f0100079 <test_backtrace+0x39>
由 上述代码可知,test_backtrace 的第一次被调用,首先是调用者的返回地址入栈,其次进行关键寄存器的保护,因为要保证这些积存器的值调用前和调用和相同,分别是ebp,esi,ebx。
其次就是为了调用其他函数而进行的下一条指令地址入栈和相关函数的参数入栈:(这里将call 指令之后的地址称为返回地址)
调用 __x86.get_pc_thunk.bx,返回地址入栈,
调用 cprintf(“entering test_backtrace %d\n”, x):返回地址,x,“entering test_backtrace %d\n” 依次入栈
调用自己: 返回地址入栈,参数 x 入栈
当 test_backtrace(int x) 中 x 的值为零时,进行mon_backtrace(0, 0, 0) 调用,返回地址入栈,3个参数依次入栈
最后调用 cprintf(“leaving test_backtrace %d\n”, x);,返回地址,x,"leaving test_backtrace %d\n"的地址 依次入栈
综上:每次递归除了参数值 x 等于 0 之外,将进行的入栈次数为 15 次(push 次数,不包括手动调整esp),分别是:返回地址1(调用test_backtrace),ebp, esi, ebx, 返回地址(调用 __x86.get_pc_thunk.bx), 返回地址(调用cprintf), x,"entering test_backtrace %d\n"的地址, 返回地址(test_backtrace自调用) ,x,返回地址(调用cprintf),x, "leaving test_backtrace %d\n"的地址。
以下是一些指针技巧:
- int *p = (int *)100,(int)p+1 和 (int)(p+1)的值不一样,前者是101,后者是 104
- p[i] 被定义为 *(p+i),指的是 p 指向的内存中的第 i 个对象。
- &p[i] 与 相同(p+i),产生 p 指向的内存中第 i 个对象的地址。
11. Exercise
debuginfo_eip函数中的__STAB_*来自哪里?
在kernel.ld 中,节选以下内容:
/* Include debugging information in kernel memory */
.stab : {
PROVIDE(__STAB_BEGIN__ = .);
*(.stab);
PROVIDE(__STAB_END__ = .);
BYTE(0) /* Force the linker to allocate space
for this section */
}
.stabstr : {
PROVIDE(__STABSTR_BEGIN__ = .);
*(.stabstr);
PROVIDE(__STABSTR_END__ = .);
BYTE(0) /* Force the linker to allocate space
for this section */
}
__STAB_BEGIN__
,__STAB_END__
和__STABSTR_BEGIN__
,__STABSTR_END__
分别表示.stab段和.stabstr段的开始和结束地址。
执行命令:objdump -h obj/kern/kernel
$ objdump -h obj/kern/kernel
obj/kern/kernel: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00001a6f f0100000 00100000 00001000 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rodata 000006e4 f0101a80 00101a80 00002a80 2**5
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .stab 00004309 f0102164 00102164 00003164 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .stabstr 0000198a f010646d 0010646d 0000746d 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .data 00009300 f0108000 00108000 00009000 2**12
CONTENTS, ALLOC, LOAD, DATA
5 .got 00000008 f0111300 00111300 00012300 2**2
CONTENTS, ALLOC, LOAD, DATA
6 .got.plt 0000000c f0111308 00111308 00012308 2**2
CONTENTS, ALLOC, LOAD, DATA
7 .data.rel.local 00001000 f0112000 00112000 00013000 2**12
CONTENTS, ALLOC, LOAD, DATA
8 .data.rel.ro.local 00000060 f0113000 00113000 00014000 2**5
CONTENTS, ALLOC, LOAD, DATA
9 .bss 00000648 f0113060 00113060 00014060 2**5
CONTENTS, ALLOC, LOAD, DATA
10 .comment 00000029 00000000 00000000 000146a8 2**0
CONTENTS, READONLY
可以得出,.stab 的起始虚拟地址为0xf0102164, .stabstr段的起始虚拟地址为0xf010646d,使用 gdb 来查看它们的内容。
指向命令 objdump -G obj/kern/kernel
,由于内容过长,只节选部分
$ objdump -G obj/kern/kernel
obj/kern/kernel: file format elf32-i386
Contents of .stab section:
Symnum n_type n_othr n_desc n_value n_strx String
-1 HdrSym 0 1429 00001989 1
0 SO 0 0 f0100000 1 {standard input}
1 SOL 0 0 f010000c 18 kern/entry.S
2 SLINE 0 44 f010000c 0
3 SLINE 0 57 f0100015 0
102 LSYM 0 0 00000000 2930 va_list:t(2,1)=(2,2)=*(0,2)
103 EINCL 0 0 00000000 0
104 EINCL 0 0 00000000 0
105 BINCL 0 0 00000000 2958 ./inc/string.h
106 EXCL 0 0 000060d4 968 ./inc/types.h
107 EINCL 0 0 00000000 0
108 FUN 0 0 f0100040 2973 test_backtrace:F(0,25)
109 PSYM 0 0 00000008 2996 x:p(0,1)
110 SLINE 0 14 00000000 0
111 SLINE 0 15 00000013 0
112 SLINE 0 16 00000023 0
113 SLINE 0 17 0000002a 0
114 SLINE 0 20 00000039 0
115 SLINE 0 21 00000049 0
116 SLINE 0 19 00000053 0
Symnum 是符号索引
n_type是符号类型:FUN 是函数类型,SLINE 是在在 .text 段中行号,SO 表示主函数的文件名,SOL 表示包含进的文件名,SLINE 表示代码段的行号,
n_other 未被使用,固定为0
n_desc 表示在文件中的行号
n_value表示地址。只有f开头的地址是绝对地址,SLINE符号的地址是偏移量,其实际地址为函数入口地址加上偏移量。比如Symnum=111那行,地址为(0xf0100040+0x13)=0xf0100053,对应文件的15行。
有关stab类型的更多信息参考该文档
执行命令gcc -pipe -nostdinc -O2 -fno-builtin -I. -MD -Wall -Wno-format -DJOS_KERNEL -gstabs -c -S kern/init.c
以下是部分内容:
.file "init.c"
.stabs "kern/init.c",100,0,2,.Ltext0
.text
.Ltext0:
.stabs "gcc2_compiled.",60,0,0,0
.stabs "int:t(0,1)=r(0,1);-2147483648;2147483647;",128,0,0,0
.stabs "char:t(0,2)=r(0,2);0;127;",128,0,0,0
.stabs "long int:t(0,3)=r(0,3);-9223372036854775808;9223372036854775807;",128,0,0,0
.stabs "unsigned int:t(0,4)=r(0,4);0;4294967295;",128,0,0,0
.stabs "long unsigned int:t(0,5)=r(0,5);0;-1;",128,0,0,0
.stabs "__int128:t(0,6)=r(0,6);0;-1;",128,0,0,0
.stabs "__int128 unsigned:t(0,7)=r(0,7);0;-1;",128,0,0,0
.stabs "long long int:t(0,8)=r(0,8);-9223372036854775808;9223372036854775807;",128,0,0,0
.stabs "long long unsigned int:t(0,9)=r(0,9);0;-1;",128,0,0,0
.stabs "short int:t(0,10)=r(0,10);-32768;32767;",128,0,0,0
.stabs "short unsigned int:t(0,11)=r(0,11);0;65535;",128,0,0,0
.stabs "signed char:t(0,12)=r(0,12);-128;127;",128,0,0,0
.stabs "unsigned char:t(0,13)=r(0,13);0;255;",128,0,0,0
问题1 确认符号表是否在内容中?
根据objdump -h obj/kern/kernel
命令,可知 .stabstr段的加载地址为:f010646d,使用 GDB查看此处的字符串信息。
(gdb) b *0xf0100040
Breakpoint 1 at 0xf0100040: file kern/init.c, line 14.
(gdb) c
Continuing.
The target architecture is set to "i386".
=> 0xf0100040 <test_backtrace>: push %ebp
Breakpoint 1, test_backtrace (x=5) at kern/init.c:14
14 {
(gdb) x/8s 0xf010646d
0xf010646d: ""
0xf010646e: "{standard input}"
0xf010647f: "kern/entry.S"
0xf010648c: "kern/entrypgdir.c"
0xf010649e: "gcc2_compiled."
0xf01064ad: "int:t(0,1)=r(0,1);-2147483648;2147483647;"
0xf01064d7: "char:t(0,2)=r(0,2);0;127;"
0xf01064f1: "long int:t(0,3)=r(0,3);-2147483648;2147483647;"
(gdb)
问题2 debuginfo_eip函数实现根据地址寻找行号的功能
使用命令objdump -G obj/kern/kernel | grep -v SOL | grep SO
,可以筛选出所有包含 SO 的行
0 SO 0 0 f0100000 1 {standard input}
14 SO 0 2 f0100040 31 kern/entrypgdir.c
72 SO 0 0 f0100040 0
73 SO 0 2 f0100040 2889 kern/init.c
156 SO 0 0 f01001ce 0
157 SO 0 2 f01001d2 3159 kern/console.c
456 SO 0 0 f010073f 0
457 SO 0 2 f0100743 3847 kern/monitor.c
600 SO 0 0 f0100ac5 0
601 SO 0 2 f0100ac5 4459 kern/printf.c
656 SO 0 0 f0100b32 0
657 SO 0 2 f0100b32 4609 kern/kdebug.c
819 SO 0 0 f0100e2a 0
820 SO 0 2 f0100e2e 4999 lib/printfmt.c
1071 SO 0 0 f0101441 0
1072 SO 0 2 f0101441 5725 lib/readline.c
1135 SO 0 0 f010153e 0
1136 SO 0 2 f010153e 5849 lib/string.c
1445 SO 0 0 f01018ad 0
根据debuginfo_eip ,该函数首先调用stab_binsearch 搜索 eip所在源文件
lfile = 0;
rfile = (stab_end - stabs) - 1;
stab_binsearch(stabs, &lfile, &rfile, N_SO, addr);
该函数第一次调用后,lfile=109,rfile=118,此时 addr = 0x01000a1, 可知源文件搜索正确
...
109 PSYM 0 0 00000008 2996 x:p(0,1)
110 SLINE 0 14 00000000 0
111 SLINE 0 15 00000013 0
112 SLINE 0 16 00000023 0
113 SLINE 0 17 0000002a 0
114 SLINE 0 20 00000039 0
115 SLINE 0 21 00000049 0
116 SLINE 0 19 00000053 0
117 RSYM 0 0 00000006 3005 x:r(0,1)
118 FUN 0 0 f01000a6 3014 i386_init:F(0,25)
...
给内核添加如下命令backtrace,打印所有的栈帧:
首先注册该命令,添加格式如下:
static struct Command commands[] = {
{"help", "Display this list of commands", mon_help},
{"kerninfo", "Display information about the kernel", mon_kerninfo},
{"backtrace", "Display a backtrace of the function stack", mon_backtrace},
};
mon_backtrace 函数如下:
int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
uint32_t *ebp;
struct Eipdebuginfo info;
int result;
ebp = (uint32_t *)read_ebp();
cprintf("Stack backtrace:\r\n");
while (ebp)
{
// ebp 当前堆栈帧栈顶 ebp[1] 当前函数执行完的下一条指令地址
cprintf(" ebp %08x eip %08x args %08x %08x %08x %08x %08x\r\n", ebp, ebp[1], ebp[2], ebp[3], ebp[4], ebp[5], ebp[6]);
memset(&info, 0, sizeof(struct Eipdebuginfo));
result = debuginfo_eip(ebp[1], &info);
if (0 != result)
{
cprintf("failed to get debuginfo for eip %x.\r\n", ebp[1]);
}
else
{
cprintf("\t%s:%d: %.*s+%u\r\n", info.eip_file, info.eip_line, info.eip_fn_namelen, info.eip_fn_name, ebp[1] - info.eip_fn_addr);
}
ebp = (uint32_t *)*ebp;
}
return 0;
// Your code here.
}
关于栈帧部分的输出如下:
ebp f0110f18 eip f01000a1 args 00000000 00000000 00000000 f010004a f0112308
kern/init.c:0: test_backtrace+97
ebp f0110f38 eip f0100076 args 00000000 00000001 f0110f78 f010004a f0112308
kern/init.c:0: test_backtrace+54
ebp f0110f58 eip f0100076 args 00000001 00000002 f0110f98 f010004a f0112308
kern/init.c:0: test_backtrace+54
ebp f0110f78 eip f0100076 args 00000002 00000003 f0110fb8 f010004a f0112308
kern/init.c:0: test_backtrace+54
ebp f0110f98 eip f0100076 args 00000003 00000004 00000000 f010004a f0112308
kern/init.c:0: test_backtrace+54
ebp f0110fb8 eip f0100076 args 00000004 00000005 00000000 f010004a f0112308
kern/init.c:0: test_backtrace+54
ebp f0110fd8 eip f0100106 args 00000005 f0110ff8 00000640 00000000 00000000
kern/init.c:0: i386_init+96
ebp f0110ff8 eip f010003e args 00000003 00001003 00002003 00003003 00004003
{standard input}:0: <unknown>+0
Exercise 11 部分掌握的不是很好,特别是 函数 stab_binsearch,我还不明白它是怎么工作的。
关于 Exercise 11:可以参考以下博客:
《MIT 6.828 Lab 1 Exercise 12》实验报告
https://sunuslee.github.io/lab1-backtrace-finish