1.Lab1
刚刚入门,难度确实很大,大量的参考资料,知识点涉及的较深,好在每个实验,作者都给出了很多提示,让繁琐的实验变得有趣起来。逐个exercise进行,知识点有些断片,所以特意花点时间,做了这篇笔记,对Lab1仔细推敲,综合理解。
Lab1总共分成了三个部分。第一部分是为了熟悉x86汇编语言、QEMU x86仿真器、GDB调试和PC的开机引导启动程序,安排了很多阅读任务。第二部分是为了检测6.828内核的引导加载程序,主要在boot目录下的操作。第三部分探讨6.828内核本身的初始模板——JOS,主要在kernel目录下进行操做。
1.1 Part1:PC引导程序
1.1.1 汇编语言
熟悉汇编语言相关语法,能够区分Intel语法和AT&T语法的不同。关于汇编语言基础知识在《PC Assembly Language》进行了详细的解读,作者推荐的《Brennan's Guide to Inline Assembly》对两种不同语法的差异进行了对比说明。同时,《深入理解计算机系统》第三章中也对汇编语言相关用法进行了较为全面的解读(书中是64位,Lab1中为32位)。
1.1.2 PC的物理地址空间
- 早期基于16位Intel 8088处理器的PC只能寻址1MB的物理内存,0x00000000~0x000FFFFF()。标记为低内存的640KB(0x00000000~0x0009FFFF)区域是早期PC可以使用的唯一随机存取存储器(RAM)。
- 地址范围0x000A000~0x000FFFFF的384KB区域由由硬件保留,用于特殊用途(视频显示缓存、非易失性存储器中保存的固件)。其中,最重要的当属BIOS,地址范围为0x000F0000~0x000FFFFF,共64KB区域(早期的BIOS保存在真正的ROM,当前PC将BIOS保存在可更新闪存)。BIOS的作用是:执行基本的系统初始化(激活显卡、检查安装的内存量)(BIOS运行时会建立中断描述符表并初始化各种设备),初始化结束后,BIOS从适当位置(软盘、硬盘、CD-ROM)加载操作系统,并将控制权交给操作系统(开机或重启后BIOS首先获得对机器的控制权)。
- 80286支持16MB物理地址空间 80386支持4GB物理地址空间。但,当今PC架构师仍保留了低1MB物理地址空间的原始布局。因此,现代PC在物理内存中存在一个从 0x000A0000 到 0x00100000 的“空洞”,将RAM分为“低”或“常规内存”(前 640KB)和“扩展内存”(其他所有内容)。此外,位于PC32位物理地址空间最顶端的一些空间,首先是物理RAM,现在通常由BIOS保留供32位PCI设备使用。
- JOS只能使用PC物理内存的前256MB(实验中把PC物理地址空间的整个底部256MB,从物理地址0x00000000到 0x0FFFFFFF,分别映射到虚拟地址0xF0000000到0xFFFFFFFF),因此,假设所有实验PC只有32 位物理地址空间。
- 系统启动后,执行第一条指令为跳转指令,跳转到指定地址,CS=0xF000和IP=0xE05B。
- 处理器复位时,模拟处理器进入实模式,设置CS=0xF000,IP=0xFFF0。(CS:IP)实模式下,地址的转换公式:物理地址=(段寄存器CS<<4)+偏移地址(IP)。即,PC从0x000FFFF0处开始执行,该地址位于为ROM BIOS保留的64KB区域的顶部。
1.2 Part2:引导加载程序
PC的软盘和硬盘分为512字节的区域,称为扇区。扇区是磁盘的最小传输粒度:每个读取或写入操作的大小必须是一个或多个扇区,并且在扇区边界上对齐。如果磁盘是可引导的,则第一个扇区称为引导扇区,因为这是引导加载程序代码所在的位置。当BIOS找到可引导软盘或硬盘时,它会将512字节的引导扇区加载到物理地址 0x7c00到0x7dff的内存中,然后使用jmp指令将CS:IP设置为0000:7c00,将控制权交给引导装载机。与BIOS加载地址一样,这些地址是相当随意的——但它们对于PC来说是固定和标准化的。
现代BIOS从CD-ROM启动的方式有点复杂(也更强大)。CD-ROM使用2048字节而不是512字节的扇区大小,并且BIOS可以在将控制权转移到内存之前将更大的引导映像从磁盘加载到内存(不仅仅是一个扇区)中。
1.2.1 引导加载程序的功能
引导加载程序包含一个汇编语言源文件boot/boot.S和一个C源文件boot/main.c 。主要功能如下:
- 将处理器从16位实模式切换到32位保护模式,因为只有在这种模式下,软件才能访问处理器物理地址空间中 1MB 以上的所有内存。该过程主要由boot/boot.S完成。
- 通过x86的特殊I/O指令直接访问IDE磁盘设备寄存器,从硬盘读取内核。该过程主要由boot/main.c完成。
1.2.2 调试
文件obj/boot/boot.asm是GNUmakefile在编译引导加载程序后创建的引导加载程序的反汇编,可以轻松查看所有引导加载程序代码驻留在物理内存中的确切位置,并且可以更轻松地跟踪 GDB 中单步执行引导加载程序时发生的情况。
当启动QEMU时,有这样一句:boot block is 390 bytes (max 510),表示存放在第一个扇区的引导程序块占用390KB,最大510KB,这就保证它不会超过512KB。
练习3中指出0x7c00是引导程序将被加载的位置。使用GDB进行单步调试时,在这里设置断点直接跳转到这里会发现QEMU打印出Booting from Hard Disk....。如下图所示:
练习3之后的问题:
Q1:处理器在哪一点上开始执行32位代码,什么导致了从16位到32位的转换?
A1:开启保护模式后,就从16位转换到了32位。
Q2:Boot Loader执行的最后一条指令是什么?内核被加载第一条指令是什么,地址在哪里?Boot Loader如何决定它必须读取多少扇区才能从磁盘获取整个内核?它在哪里找到这些信息?
A2:Boot Loader执行的最后一条指令是跳转到Kernel入口的call指令。打开obj/boot/boot.asm查看:
在0x7d6b设置断点查看0x10018处的信息(从下方两张图片可以看出,执行call指令后,eip的值变成了0x10000c)
因此,内核被加载的第一条指令地址位于0x0010000c,在obj/kern/kernel.asm中查看
最后,Boot Loader是通过ELF头中e_phoff知道需第一个段的位置,通过e_phnum知道需要加载几个段。
练习4:按照要求分析以下源码的执行结果:
#include <stdio.h>
#include <stdlib.h>
void
f(void){
int a[4];
int *b = malloc(16);
int *c;
int i;
printf("1: a = %p, b = %p, c = %p\n", a, b, c);
c = a;
for (i = 0; i < 4; i++)
a[i] = 100 + i;
c[0] = 200;
printf("2: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",
a[0], a[1], a[2], a[3]);
c[1] = 300;
*(c + 2) = 301;
3[c] = 302;
printf("3: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",
a[0], a[1], a[2], a[3]);
c = c + 1;
*c = 400;
printf("4: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",
a[0], a[1], a[2], a[3]);
c = (int *) ((char *) c + 1);
*c = 500;
printf("5: a[0] = %d, a[1] = %d, a[2] = %d, a[3] = %d\n",
a[0], a[1], a[2], a[3]);
b = (int *) a + 1;
c = (int *) ((char *) a + 1);
printf("6: a = %p, b = %p, c = %p\n", a, b, c);
}
int
main(int ac, char **av){
f();
return 0;
}
- line 1:打印a,b,c的地址。其中,a是数组首址,b、c是指针。数组首址是常量,不可改变,b和c是变量,所以虽然数组首址和指针都能表示地址,却存放在程序段的不同位置(从地址可看出,a相对于bc,存放的段不同)
- line 2:a表示数组首址,c指向a,则指针c可以用来访问数组a
- line 3:代码中使用了三种不同的寻址格式。对于第二种,*(c + 2) = 301,这是c[2]=301的编译预处理之后的格式,比c[2]=301这样写运行更快;对于第三种,这是个很有趣的写法,符合语法,相当于c[3]=302
- line 4 :将指针c向后移了一个int单位,相当于后移4字节
- line 5:稍微有些复杂,先看源码:
c = (int *) ((char *) c + 1);
按照运算顺序,先把c强制转为char型指针(char型变量占1字节,int型变量占4字节)所以之后的+1只向前移动了1字节,之后再强转为int型指针。为了便于理解,使用下面的表格来说明这个问题:
a[0] | a[1] | a[2] | a[3] |
XXXX(200) | XXXX(400) | XXXX(301) | XXXX(302) |
表格中每个X相当于1字节。在line 4那一组执行结束时,c指向a[1],强转后,指向a[1]的第一个字节,再加1,指向a[1]的第二个字节,强转int,则指向从当前内存地址开始的四个字节,就是图中红色的字节,将这作为一整个int型数据格式,改为500,则破坏了a1和a2,就出现了line 5的结果。need to verify
- line 6:观察三个变量的地址变化,可以看到c(char)和a差1,b(int)和a差4,单位都是字节
1.2.3 ELF 文件结构
可执行可连接格式(Executable and Linkable Format),简称ELF。
《深入理解计算机系统》给出了典型ELF的可重定位目标文件:
Sections | 描述 |
.text | 已编译的程序的代码 |
.rodata | 只读数据,如:printf中的格式字符串以及开关语句的跳转表 |
.data | 已初始化的全局和静态C变量,静态C变量在运行时被保存在栈中,既不出现在.data中,也不出现在.bss节中 |
.bss | 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量,在目标文件中.bss节不占据实际空间,它仅仅是一个占位符。目标文件格式化区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际磁盘空间。运行时,在内存中分配这些变量,初始值为0 |
.symtab | 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。与编译器中的符号表不同,.symtab符号表不包含局部变量的条目 |
.rel.text | 一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时需要修改这些位置 |
.rel.data | 被模块引用或定义的所有全局变量的重定位信息 |
.debug | 一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译器驱动程序时才能得到这张表 |
.line | 原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时才能得到这张表 |
.strtab | 一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字,字符串表,就是以null结尾的字符串和序列 |
节头部表 | 描述目标文件的节 |
通常,链接地址和加载地址是相同的。例如:引导加载程序的.text部分:
Boot Loader的链接和加载地址是一样的,都是0x7c00,而Kernel的链接和加载地址却是不一样的。
查看内核可执行文件中所有部分的名称、大小和链接地址的完整列表:
.text部分的VMA:链接地址。
.text部分的LMA:加载地址。
可以看出,链接地址是 0xf0100000,加载地址是0x00100000,也就是说Kernel加载到了内存中的 0x00100000 这个低地址处,但是却期望在一个高地址 0xf0100000 执行。因为,内核通常想链接和运行在一个高的虚拟地址,把低位的虚拟地址空间让给用户程序使用。
BIOS将引导程序加载到内存中从地址0x7c00开始,因此,这是引导程序的加载地址。也是引导扇区开始执行的地方,也是它的链接地址。通过将-Ttext 0x7c00传递给boot/Makefrag 中的链接器来 设置链接地址,因此链接器将在生成的代码中添加正确的内存地址。
引导加载程序使用ELF文件来决定如何使用和加载各个部分。程序头指定ELF对象哪些部分要加载到内存中,以及每个部分应该占用的目标地址。
需要加载到内存中的 ELF 对象的区域是那些标记为“LOAD”的区域。给出了每个程序头的其他信息,例如虚拟地址(“vaddr”)、物理地址(“paddr”)和加载区域的大小(“memsz”和“filesz”)。
根据练习5要求修改链接地址
再次进行调试,在0x7c00处设置断点,跳转过去之后单步执行,当执行到0x7c2d处,程序出现错误:
练习6中,从BIOS进入到Boot Loader的时候,还没有加载内核,0x100000处的内存还没有内容,进行x /8x 0x100000 后均显示0。当从Boot Loader进入Kernel时,内核已经加载在内存中。结果如下:
1.3 Part3:内核
1.3.1 使用虚拟地址解决位置依赖问题
BIOS负责加载Boot Loader,而Boot Loader负责加载内核。编译好的内核位于obj/kern/kernel(obj目录下编译好的目标文件),之后需要将其写入到镜像文件 obj/kern/kernel.img中。
从kern/kernel.ld 中可以看到内核的链接地址设置的是 0xf0100000,而加载地址设置的是0x00100000。 因为,内核通常想链接和运行在一个高的虚拟地址,把低位的虚拟地址空间让给用户程序使用。以前的计算机通常没有那么大的内存,解决这一问题的主要部分是借助地址映射,将0xf0100000(内核代码预期运行的链接地址)映射到0x00100000(引导加载程序将内核加载到物理内存的位置)。这样,虽然内核的虚拟地址足够高,可以为用户进程留下足够的地址空间,但它会被加载到PC RAM中1MB点的物理内存中,就在BIOS ROM上方。
在kern/entry.S设置CR0_PG标志之前,内存引用被视为物理地址(严格来说,是线性地址,但boot/boot.S设置了从线性地址到物理地址的映射)。一旦CR0_PG设定,内存引用是得到由虚拟内存硬件到物理地址转换的虚拟地址。
练习7是为了理解开启分页的效果,mov %eax, %cr0(kern/entry.S文件中)指令开启分页,将虚拟地址[KERNBASE, KERNBASE+4MB)内容映射到物理地址[0,4MB) ,开启分页前,0xf0000000开始的内容为0,开启分页后,0xf0100000与0x00100000内容相同。
在obj/kern/kernel.asm中查找mov %eax, %cr0的地址:
调试:
注释掉kern/entry.S文件中的mov %eax, %cr0之后,当执行到0xf010002c出现致命错误(超出内存范围),qemu窗体直接关闭退出。
1.3.2 格式化打印到控制台
练习8要求在lib/printfmt.c补充使用“%o”形式的模式打印八进制数所必需的代码,直接对照"%u"部分代码进行编写:
Q1:解释printf.c和 console.c之间的接口。具体来说,console.c导出什么函数 ?printf.c如何使用此函数 ?
A1:console.c导出了getchar,cputchar等函数,printf.c中的cprintf函数调用了同一文件中的vcprintf函数,vcprintf函数调用了同一文件中的putch函数,putch函数调用了console.c中的cputchar函数。
Q2:解释代码意义?
A2:为了解释这个问题需要先看一下kern/console.h中对于QEMU显示窗体大小的设置,如下:
如上所示,限制窗体打印限制为25行,80列。为了了解代码段的作用,先把它注释掉,查看效果,如下:
QEMU仅仅打印了CRT_SIZE(CRT_ROWS*CRT_COLS)大小的信息后不在打印其余信息。当去掉注释,使代码段正常执行,打印结果如下:
对比同步打印在终端的信息:
可以对比得出结论,该段代码的作用就是当显示缓冲区需要打印的数据超出显示规定的大小时,覆盖之前打印的信息,打印最新的显示缓冲区信息(滚动打印)。
注:这是在完成整个Lab1后,打印出的信息,若按照顺序进行实验,则Stack backtrace:部分不会打印。
Q3:在调用cprintf函数时,fmt指向什么?ap指向什么?按照执行顺序列出对cons_putc, va_arg, and vcprintf的调用。对于cons_putc,也列举出他的所有参数。对于va_arg,列举出调用前后ap指向的内容,对于vcprintf,列举出它的两个参数的值。
A3:fmt指向参数中的格式化字符串,ap指向fmt的后一个参数。cprintf中调用了函数vcprintf,在vcprintf中调用了vprintfmt((void*)putch, &cnt, fmt, ap);,其中,putch是输出函数,它调用了cputchar函数,cputchar调用cons_putc,cons_putc又调用了cga_putc(向显示器输出一个字符),最终,完成显示功能。va_list ap是一个指针,va_start(ap, fmt)使ap指向fmt参数的下一个参数。然后,就可以用va_arg宏依次读取之后的可变参数。在对参数指针进行了初始化后,程序接着调用了vcprintf 函数,在得到vcprintf函数的返回值后,最后,使用va_end宏结束了对可变参数的获取,在函数返回前调用va_end。
为便于观察实验结果,将需要执行的程序段放到kern/init.c中,重新编译后("make clean"--->"make"--->"make qemu")便可看到执行结果。
Q4&Q5:运行代码,打印输出结果并解释原因。
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);
A4&A5:同样,将代码段放到kern/init.c中,重新编译执行,结果如下:
Q4中57616转换位16进制是0xe110,与H拼接,输出“Hell0”,x86采用小端模式,低位数字在低地址处,所以0x00646c72对应的字符分别为0dlr,拼接Wo,打印输出“World”。
Q5中程序,指定的格式化字符串数目大于实际参数数目,因为缺少参数,由可变参数的方式可知会打印第一个参数之上的栈里面的4字节内容。
Q6:改变打印的颜色。
A6:根据A3中分析的cprintf的调用函数顺序,然后根据cga_putc注释,分析得知,c开始的几行负责控制打印的颜色
// if no attribute given, then use black on white
/**
0~7:ascii
8~15:字符属性,其中8~11:前景色,12~15:背景色,
*/
/// if (!(c & ~0xFF))
/// c |= 0x0700;
if(!(c&~0xFF)){
if(c>64&&c<91){
c|=0x400;///背景黑,前景红
}else if(c>96&&c<123){
c|=0x1700;///背景蓝,前景灰
}else{
c|=0xf00;///背景黑,前景白
}
}
运行QEMU效果:
1.3.3 栈
《深入理解计算机系统》中指出,栈通常用来传递参数、存储返回信息、保存寄存器以及局部存储。中有一段对于运行时栈的描述感觉讲的很细,具体如下:
练习 9. 确定内核初始化栈的位置,以及栈在内存中的确切位置。内核如何为其栈保留空间?栈指针初始化指向这个保留区域的哪个“末端”?
内核在kern/entry.S中分配了栈空间,栈的大小为KSTSIZE。具体的大小,在inc/memlayout.h中给出。具体参数如下:
PGSIZE位于inc/mmu.h,大小为4096B
所以,实验中栈的大小为8*4KB=32KB
分配时栈向低地址生长,栈顶信息在obj/kern/kernel.asm中进行查看,如下:
与栈有关的寄存器:
首先是esp寄存器,指向正在使用的栈的最低位置。将值压入堆栈涉及减少堆栈指针,然后将值写入堆栈指针指向的位置。从堆栈中弹出一个值涉及读取堆栈指针指向的值,然后增加堆栈指针。在32位模式下,堆栈只能容纳32位值,并且esp始终可以被4整除。
其次是ebp(基指针)寄存器。在进入C函数时,函数的序言代码通常通过将前一个函数的基指针压入堆栈来保存它,然后 在函数运行期间将当前esp值复制到ebp中。
练习10是为了熟悉x86里面的C语言函数调用规则,查看ebp,eip等寄存器的值,调试内容有点多,直接粘贴调试信息。调试过程是根据对函数的调用顺序,在每个函数设置断点,然后查看寄存器信息。
(gdb) b i386_init
Breakpoint 1 at 0xf01000e5: file kern/init.c, line 36.
(gdb) b test_backtrace
Breakpoint 2 at 0xf0100040: file kern/init.c, line 13.
(gdb) b mon_backtrace
Breakpoint 3 at 0xf01007c4: file kern/monitor.c, line 60.
(gdb) c
Continuing.
The target architecture is assumed to be i386
=> 0xf01000e5 <i386_init>: push %ebp
Breakpoint 1, i386_init () at kern/init.c:36
36 {
(gdb) si
=> 0xf01000e6 <i386_init+1>: mov %esp,%ebp
0xf01000e6 36 {
(gdb) si
=> 0xf01000e8 <i386_init+3>: sub $0xc,%esp
0xf01000e8 in i386_init () at kern/init.c:36
36 {
(gdb) i r
eax 0xf010002f -267386833
ecx 0x0 0
edx 0x9d 157
ebx 0x10094 65684
esp 0xf010fff8 0xf010fff8
ebp 0xf010fff8 0xf010fff8
esi 0x10094 65684
edi 0x0 0
eip 0xf01000e8 0xf01000e8 <i386_init+3>
eflags 0x86 [ PF SF ]
cs 0x8 8
ss 0x10 16
ds 0x10 16
es 0x10 16
fs 0x10 16
gs 0x10 16
(gdb) c
Continuing.
=> 0xf0100040 <test_backtrace>: push %ebp
Breakpoint 2, test_backtrace (x=5) at kern/init.c:13
13 {
(gdb) si
=> 0xf0100041 <test_backtrace+1>: mov %esp,%ebp
0xf0100041 13 {
(gdb) si
=> 0xf0100043 <test_backtrace+3>: push %ebx
0xf0100043 13 {
(gdb) i r
eax 0x0 0
ecx 0x3d4 980
edx 0x3d5 981
ebx 0x10094 65684
esp 0xf010ffd8 0xf010ffd8
ebp 0xf010ffd8 0xf010ffd8
esi 0x10094 65684
edi 0x0 0
eip 0xf0100043 0xf0100043 <test_backtrace+3>
eflags 0x46 [ PF ZF ]
cs 0x8 8
ss 0x10 16
ds 0x10 16
es 0x10 16
fs 0x10 16
gs 0x10 16
(gdb) d 2
(gdb) c
Continuing.
=> 0xf01007c4 <mon_backtrace>: push %ebp
Breakpoint 3, mon_backtrace (argc=0, argv=0x0, tf=0x0) at kern/monitor.c:60
60 {
(gdb) si
=> 0xf01007c5 <mon_backtrace+1>: mov %esp,%ebp
0xf01007c5 60 {
(gdb) si
=> 0xf01007c7 <mon_backtrace+3>: push %edi
0xf01007c7 60 {
(gdb) i r
eax 0x0 0
ecx 0x3d4 980
edx 0x3d5 981
ebx 0x0 0
esp 0xf010ff18 0xf010ff18
ebp 0xf010ff18 0xf010ff18
esi 0x10094 65684
edi 0x0 0
eip 0xf01007c7 0xf01007c7 <mon_backtrace+3>
eflags 0x92 [ AF SF ]
cs 0x8 8
ss 0x10 16
ds 0x10 16
es 0x10 16
fs 0x10 16
gs 0x10 16
(gdb)
练习11&练习12
Q1:__STAB_*部分来自于结构体debuginfo_eip的哪一部分?
A1:按照提示步骤寻找答案:
step1:查看kern/kernel.ld文件
由上图可知,__STAB_BEGIN__,__STAB_END__,__STABSTR_BEGIN__,__STABSTR_END__等符号均在kern/kern.ld文件定义,它们分别代表.stab和.stabstr这两个段开始与结束的地址。
step2:运行objdump -h obj/kern/kernel
由上图分析可知,__STAB_*具体信息:
-
- __STAB_BEGIN__= 0xf01022b0
- __STAB_END__= 0xf01022b0 + 0x00003d5d(Size) -1
- __STABSTR_BEGIN__= 0xf010600d
- __STABSTR_END__= 0xf010600d + 0x00001988 -1
step3:运行objdump -G obj/kern/kernel
由上图可知,上述指令输出了目标文件的stab信息。每一列的详细含义如下:
-
- Symnum是符号索引,意味着,将整个符号表看作一个数组,Symnum是当前符号在数组中的下标
- n_type是符号类型,FUN指函数名,SLINE指在text段中的行号
- n_othr目前没被使用,其值固定为0
- n_desc表示在文件中的行号
- n_value表示地址
step4:运行gcc -pipe -nostdinc -O2 -fno-builtin -I. -MD -Wall -Wno-format -DJOS_KERNEL -gstabs -c -S kern/init.c
在目录下生成init.d和init.s两个文件。
其中,init.d是由-MD生成,表示目标文件的依赖信息,如下图所示意:
init.s是生成的.stab部分的详细数据:
Q2:boot loader在加载内核时是否把符号表也加载到内存中?
A2:根据step2的输出结果可知,.stabstr段的加载内存地址为0xf010600d,借助gdb调试查看0xf010600d处的数据,是否含有符号表信息:
由上图可知,加载内核时符号表也被一起加载到内存中。
Q3:借助C函数实现栈回溯。补充kern/monitor.c,完成mon_backtrace程序按照指定格式打印栈中信息。打印mon_backtrace中对应的每个eip的函数名、文件名和行号。
注:eip的值是函数的返回指令指针,函数返回时控制将要返回的指令地址。
step1:补充debuginfo_eip缺失部分
//kern/kdebug.c
// Search within [lline, rline] for the line number stab.
// If found, set info->eip_line to the right(正确的) line number.
// If not found, return -1.
//
// Hint:
// There's a particular stabs type used for line numbers.
// Look at the STABS documentation and <inc/stab.h> to find
// which one.
// Your code here.
stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
if(lline<=rline){
info->eip_line=stabs[lline].n_desc;
}else{
return -1;
}
要理解为什么要选N_SLINE和n_desc,就要看一下inc/stab.h最开始的链接里的内容,尤其是2.4节,关键内容如下:
step2:补充commands和mon_backtrace
static struct Command commands[] = {
{ "help", "Display this list of commands", mon_help },
{ "kerninfo", "Display information about the kernel", mon_kerninfo },
{ "backtrace", "Display debug infomormation of stack", mon_backtrace },
};
int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
cprintf("Stack backtrace:\n");
uint32_t eip;///保存函数调用完后要执行的地址
/**
根据exercice10之后的导读内容,inc/x86.h中的read_ebp()是一个静态内联函数用于返回当前的ebp寄存器的内容。寄存器ebp 保存调用该函数的ebp地址
当发生函数调用时,由exercise10调试过程可以知道,会执行以下指令:
push %ebp #将调用函数栈基址压入栈中
move %esp,%ebp #将栈顶指针保存在基址寄存器
sub $0x28,%esp #栈顶指针下移,为被调用函数开辟栈帧
*/
///cprintf("%d\n",argc);
uint32_t *ebp=(uint32_t *)read_ebp();///保存调用函数的ebp地址,
int i;
while(ebp){///如果存在调用继续
eip=*(ebp+1);
cprintf("ebp %x eip %x args ",ebp,eip);
uint32_t *args=ebp+2;///读取参数列地址
for(i=0;i<5;i++){///按要求仅输出前五个参数即可
cprintf("%08x ",(uint32_t)args[i]);
}
struct Eipdebuginfo eip_debug_info;///kern/kdebug.h
debuginfo_eip(eip,&eip_debug_info);///kern/kdebug.c 填充debuginfo
cprintf("\n");
///cprintf("\t%.*s\n",5,eip_debug_info.eip_fn_name);
cprintf("\t%s:%d: %.*s+%d\n",eip_debug_info.eip_file,
eip_debug_info.eip_line,
eip_debug_info.eip_fn_namelen,
eip_debug_info.eip_fn_name,
eip-eip_debug_info.eip_fn_addr);
///文件名:行号: (预留函数名长度)函数名+函数起始地址!!!!
//第二个:后的空格不能省略,否则grade_lab1不通过!!!!
ebp=(uint32_t *)*ebp;///取调用地址(上一层)
}
return 0;
}
//------------printf字符串打印的一种用法------------
//printf("%.*s\n",num,string);
//打印字符串string前num个字符
最后通过,grade_lab1检测结果:
1.4 Comments
XV6.828 Lab1加深了自己对内核启动过程的理解,实验中涉及特别多的函数调用,因为不够理解,有时候在找一个函数中用到了某一个常量时,需要找遍预处理信息。在练习中作者给出了很多提示性信息,进一步减少了实验的工作量。另外,实验代码量不大,但涉及知识点很广。对一个地方的理解可能涉及众多个“.c”“.h”文件。