MIT 6.828 JOS学习笔记10. Lab 1 Part 3: The kernel
Lab 1 Part 3: The kernel
现在我们将开始具体讨论一下JOS内核了。就像boot loader一样,内核开始的时候也是一些汇编语句,用于设置一些东西,来保证C语言的程序能够正确的执行。
使用虚拟内存
在运行boot loader时,boot loader中的链接地址(虚拟地址)和加载地址(物理地址)是一样的。但是当进入到内核程序后,这两种地址就不再相同了。
操作系统内核程序在虚拟地址空间通常会被链接到一个非常高的虚拟地址空间处,比如0xf0100000,目的就是能够让处理器的虚拟地址空间的低地址部分能够被用户利用来进行编程。
但是许多的机器其实并没有能够支持0xf0100000这种地址那么大的物理内存,所以我们不能把内核的0xf0100000虚拟地址映射到物理地址0xf0100000的存储单元处。
这就造成了一个问题,在我们编程时,我们应该把操作系统放在高地址处,但是在实际的计算机内存中却没有那么高的地址,这该怎么办?
解决方案就是在虚拟地址空间中,我们还是把操作系统放在高地址处0xf0100000,但是在实际的内存中我们把操作系统存放在一个低的物理地址空间处,如0x00100000。那么当用户程序想访问一个操作系统内核的指令时,首先给出的是一个高的虚拟地址,然后计算机中通过某个机构把这个虚拟地址映射为真实的物理地址,这样就解决了上述的问题。那么这种机构通常是通过分段管理,分页管理来实现的。
在这个实验中,首先是采用分页管理的方法来实现上面所讲述的地址映射。但是设计者实现映射的方式并不是通常计算机所采用的分页管理机构,而是自己手写了一个程序lab\kern\entrygdir.c用于进行映射。既然是手写的,所以它的功能就很有限了,只能够把虚拟地址空间的地址范围:0xf0000000~0xf0400000,映射到物理地址范围:0x00000000~0x00400000上面。也可以把虚拟地址范围:0x00000000~0x00400000,同样映射到物理地址范围:0x00000000~0x00400000上面。任何不再这两个虚拟地址范围内的地址都会引起一个硬件异常。虽然只能映射这两块很小的空间,但是已经足够刚启动程序的时候来使用了。
Exercise 7:
使用Qemu和GDB去追踪JOS内核文件,并且停止在movl %eax, %cr0指令前。此时看一下内存地址0x00100000以及0xf0100000处分别存放着什么。然后使用stepi命令执行完这条命令,再次检查这两个地址处的内容。确保你真的理解了发生了什么。
如果这条指令movl %eax, %cr0并没有执行,而是被跳过,那么第一个会出现问题的指令是什么?我们可以通过把entry.S的这条语句加上注释来验证一下。
解答:
我们可以首先设置断点到0x10000C处,因为我们在之前的练习中已经知道了,0x10000C是内核文件的入口地址。 然后我们从这条指令开始一步步运行,直到碰到movl %eax, %cr0指令。在这条指令运行之前,地址0x00100000和地址0xf0100000两处存储的内容是:
可见当前这两地址处的值是不一样的。
然后输入stepi命令(其实就是si命令),再查看两个位置:
我们会发现两处存放的值已经一样了! 可见原本存放在0xf0100000处的内容,已经被映射到0x00100000处了。
第二问需要我们把entry.S文件中的%movl %eax, %cr0这句话注释掉,重新编译内核。我们需要先make clean,然后把%movl %eax, %cr0这句话注释掉,重新编译。 再次用qemu仿真,并且设置断点到0x10000C处,开始一步步执行。通过一步步查询发现了出现错误的一句。
其中在0x10002a处的jmp指令,要跳转的位置是0xf010002C,由于没有进行分页管理,此时不会进行虚拟地址到物理地址的转化。所以报出错误,下面是make qemu-gdb这个窗口中出现的信息。
可见你当前访问的逻辑地址超出内存了。
格式化输出到控制台(屏幕)
我们经常会在编程时使用到printf子程序,这个子程序是在操作系统的内核中实现的。这一小部分就是要探究一下这种格式化输出子程序的实现方式。
Exercise 8:http://www.cnblogs.com/fatsheep9146/p/5066690.html
回答下试验报告中Exercise 8后面的问题:
1. 解释一下printf.c和console.c两个之间的关系。console.c输出了哪些子函数?这些子函数是怎么被printf.c所利用的?
答:在Exercise 8的解答中我们已经很具体的分析了两个文件,在console.c中除了被static修饰符修饰的函数之外,都可以被外部所使用,其中被printf所使用的函数就是cputchar子函数。
2. 解释一下console.c文件中,下面这段代码的含义:
1 if (crt_pos >= CRT_SIZE) { 2 int i; 3 memcpy(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t)); 4 for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++) 5 crt_buf[i] = 0x0700 | ' '; 6 crt_pos -= CRT_COLS; 7 }
答:首先看下里面的几个变量:
crt_buf:这是一个字符数组缓冲区,里面存放着要显示到屏幕上的字符
crt_pos:这个表示当前最后一个字符显示在屏幕上的位置,在介绍这个变量前我们还要知道一些知识,这是我在网上自己查询的。
早期的计算机如果想显示信息给用户只能通过文字模式,比如当你现在打开电脑时,进入桌面之前,所有的信息都是通过文字显示在屏幕上的。那么这种模式就叫做文字模式,那么这个console.c源程序中考虑的就是一种非常常见的文字模式,80x25文字模式,即整个屏幕上允许显示最多25行字符,每行最多显示80个字符。所以一共代表了80x25个位置。当我们要显示某个特定字符到屏幕某个位置上面时,我们必须要指定显示的位置,和显示字符给屏幕驱动器cga。
而在console.c文件中,子程序cga_putc(int c)就是完成这项功能,把字符c显示到屏幕当前显示的下一个位置。比如当前屏幕中已经显示了三行数据(0号行,1号行,2号行),并且第三行已经显示了40个字符,此时执行cga_putc(0x65),那么就会把0x65对应的字符'A'显示到2号行第41个字符处。所以cga_putc需要两个变量,crt_buf,这个一个字符数组指针,该字符数组就是当前显示在屏幕上的所有字符。crt_pos则表示下一个要显示的字符存放在数组中的位置,其实通过这个值也可以推导出它显示在屏幕上的位置。比如crt_pos = 85,那么它就应该显示在第2行(即1号行),第6字符(5号字符)处。所以crt_pos的取值范围应该是从0~(80*25-1)。
上面题目中要分析的这段代码位于cga_putc中,cga_putc的分为三部分,第一部分是根据字符值int c来判断到底要显示成什么样子。而第二部分就是上述代码。第三部分则是把你决定要显示的字符显示到屏幕的指定位置上。咱们具体分析第二部分,
当crt_pos >= CRT_SIZE,其中CRT_SIZE = 80*25,由于我们知道crt_pos取值范围是0~(80*25-1),那么这个条件如果成立则说明现在在屏幕上输出的内容已经超过了一页。所以此时要把页面向上滚动一行,即把原来的1~79号行放到现在的0~78行上,然后把79号行换成一行空格(当然并非完全都是空格,0号字符上要显示你输入的字符int c)。所以memcpy操作就是把crt_buf字符数组中1~79号行的内容复制到0~78号行的位置上。而紧接着的for循环则是把最后一行,79号行都变成空格。最后还要修改一下crt_pos的值。
3. 观察下面的一串代码:
int x = 1, y = 3, z = 4; cprintf("x %d, y %x, z %d\n", x, y, z);
回答下列问题:
* 当调用cprintf时,fmt指向的是什么内容,ap指向的是什么内容。
* 按照执行的顺序列出所有对cons_putc, va_arg,和vcprintf的调用。对于cons_putc,列出它所有的输入参数。对于va_arg列出ap在执行完这个函数后的和执行之前的变化。对于vcprintf列出它的两个输入参数的值。
答:
观察cprintf函数:
1 int 2 cprintf(const char *fmt, ...) 3 { 4 va_list ap; 5 int cnt; 6 7 va_start(ap, fmt); 8 cnt = vcprintf(fmt, ap); 9 va_end(ap); 10 11 return cnt; 12 }
回答第一个问题,首先fmt自然指向的是显示信息的格式字符串,那么在这段代码中,它指向的就是"x %d, y %x, z %d\n"字符串。而ap是va_list类型的。我们之前已经介绍过,这个类型专门用来处理输入参数的个数是可变的情况。所以ap会指向所有输入参数的集合。
继续观察,发现cprint中调用了vcprintf函数,并且把格式字符串fmt,所有的参数列表ap(包含x,y,z)作为输入参数传给了vcprintf,然后vcprintf调用在\lib\printfmt.c中的vprintfmt子程序,并且传递给它4个参数。第1个参数是一个显示字符的子程序:这里采用的是printf.c文件中自己定义的putch函数。这个函数可以把字符显示到屏幕上。然后在传递一个值为0的变量的引用给第2个参数。原本第2个参数的含义是一个内存地址,并且第1个参数函数指针所指向的函数应该能够把字符写入到第2个参数所指定的地址处。但是由于我们的第1个参数是显示数据到屏幕。所以这里不需要第2个参数了。所以此时我们把一个变量引用作为第2个参数,是把它当做计数器,记录显示了多少字符。第3,4字符的含义没有变,和cprintf的参数一样。
然后进入vprintfmt子程序。这个子程序我们已经分析过。这里就不再赘述了。这个子程序的工作过程就是,不停的分析格式字符串fmt。分析采取的方式是把格式字符串划分成多个部分,每个部分都至多带有一个待显示的参数,比如我们这道题中的格式字符串就可以被划分为4个部分:
"x %d", ", y %x" , ", z %d", "\n"
然后先分析每个部分中%号前面的字符串,并且直接输出。比如"x %d"中"x "。然后分析%号后面的内容,比如"x %d"中分析的结果就是要按照10进制显示一个参数。每当分析完%号后面的内容,程序就会按照分析的结果来进行不同的操作。在分析完"x %d"后,代码开始执行下面这个分支:
1 case 'd': 2 num = getint(&ap, lflag); //根据你的整数类型到底是int,还是long,还是long long,从参数列表ap中取出相应类型的参数 3 if ((long long) num < 0) { //如果输入参数是负数,先输出一个负号 4 putch('-', putdat); 5 num = -(long long) num; 6 } 7 base = 10; 8 goto number;
这个分支中首先是一个子函数getint,这个子函数的内容如下:
1 static long long 2 getint(va_list *ap, int lflag) 3 { 4 if (lflag >= 2) 5 return va_arg(*ap, long long); 6 else if (lflag) 7 return va_arg(*ap, long); 8 else 9 return va_arg(*ap, int); 10 }
可见它是根据不同的参数类型,利用va_arg方法从ap参数列表中取出下一个参数,在我们的例子中会执行第9行的代码。这里对va_arg进行了一次调用,调用前ap中包括x,y,z三个参数的内容:1,3,4。调用完成后只剩下y,z的内容:3,4.
回到vprintfmt,现在num中存放的是待显示的值1。下一步先判断这个待显示的值是否是负数,如果是负数应该先调用putch函数,显示一个负号在屏幕上。之后跳转到number处。
number处是一个子程序 printnum(putch, putdat, num, base, width, padc),这个子程序会按照指定的进制,以及格式显示你刚刚取到的参数1。在这个子程序中我们可以看到它会把你取到的参数值(num = 1)按照你所指定的进制(base = 10),一位一位的显示出来。所以每得到一位的值它都会调用一次putch,把它显示到屏幕上。另外这句代码putch(padc, putdat);是为了实现当显示需要右对齐时,应该先把左边补上空格。
所以这样第1个参数x=1就是显示在屏幕上了,后面的两个也是同样的道理。
为了能够真实的运行这段代码,我们可以找到\lab\kern\monitor.c文件,用vim编辑它,把这两句指令加在monitor子程序中,如下:
重新编译整个内核,然后在lab目录下运行 make qemu 指令,就会打印出结果了:
4. 运行下面的代码:
unsigned int i = 0x00646c72; cprintf("H%x Wo%s", 57616, &i);
输出是什么?解释一下为什么是这样的输出?
答:
首先,我们还是采用和上一题一样的方法,把这两句代码加到moniter.c文件中。并且最后得到的运行结果如下:
为什么会输出这样的值,首先看下第一个%x,指的是要按照16进制输出第一个参数,第一个参数的值是57616,它对应的16进制的表示形式为e110,所以前面就变成的He110。
然后看下一个%s,输出参数所指向的字符串。参数是&i,是变量i的地址,所以应该输出的是变量i所在地址处的字符串。
而在cprintf之前我们把i定义为一个int类型变量,所以现在我们要把它们进行拆分,按照一个字节一个字节来进行输出。
由于x86是小端模式,代表字的最高位字节存放在最高位字节地址上。假设i变量的地址为0x00,那么i的4个字节的值存放在0x00,0x01,0x02,0x03四处。由于是小端存储,所以0x00处存放0x72('r'),0x01处存放0x6c('l'),0x02处存放0x64('d'),0x03处存放0x00('\0').
所以在cprintf将会从i的地址开始一个字节一个字节遍历,正好输出 "World"
5. 看下面的代码,在'y='后面会输出什么?为什么会这样?
cprintf("x=%d y=%d", 3);
答:
输出的结果如下
由于y并没有参数被指定,所以会输出一个不确定的值。
堆栈
在本实验的最后一部分,我们将探讨一下C语言是如何在x86机器上使用堆栈的。并且我们还会重新编写一个新的kernel monitor子程序。这个程序可以记录堆栈的变化轨迹:轨迹是由一系列被保存到堆栈的IP寄存器的值组成的,之所以会产生这一系列被保存的IP寄存器的值,是因为我们执行了一个程序,程序中包括一系列嵌套的call指令。
Exercise 9:
判断一下操作系统内核是从哪条指令开始初始化它的堆栈空间的,以及这个堆栈坐落在内存的哪个地方?内核是如何给它的堆栈保留一块内存空间的?堆栈指针又是指向这块被保留的区域的哪一端的呢?
关于这个问题的解答,请看链接:http://www.cnblogs.com/fatsheep9146/p/5079177.html
X86堆栈指针寄存器(%esp)指向的是整个堆栈中正在被使用的部分的最低地址。在这个地址之下的更低的地址空间都是还没有被利用的堆栈空间。当计算机要完成把一个值压入堆栈的动作时,通常它需要先把堆栈指针寄存器中的值减1(有时候是减4,由机器字长决定),然后把需要压入的值存放到当前堆栈指针寄存器所指向的新的内存单元。而从堆栈中弹出一个值的操作,则需要计算机首先从堆栈寄存器所指向的内存单元读取一个数据,然后把堆栈寄存器的值加1(有时候是加4)。在32bit模式下,每一次对堆栈的操作都是以32bit为单位的,所以%esp中的值永远都是可以被4整除的。
而ebp寄存器则是记录每一个程序的栈帧的相关信息的一个非常重要的寄存器。每一个程序在运行时都会分配给它一个栈帧,用于实现存放一些临时变量,传递参数给它调用的子函数等等功能。当现在进入某个子程序时,最先要运行的代码就是先把之前调用这个子程序的程序的ebp寄存器的值压入堆栈中保存起来,然后把ebp寄存器的值更新为当前esp寄存器的值。此时就相当于为这个子程序定义了它的ebp寄存器的值,也就是它栈帧的一个边界。只要所有的程序都遵循这样的编程规则,那么当我们运行到程序的任意一点时。我们可以通过在堆栈中保存的一系列ebp寄存器的值来回溯,弄清楚是怎样的一个函数调用序列使我们的程序运行到当前的这个点。
Exercise 10:
为了能够更好的了解在x86上的C程序调用过程的细节,我们首先找到在obj/kern/kern.asm中test_backtrace子程序的地址,设置断点,并且探讨一下在内核启动后,这个程序被调用时发生了什么。对于这个循环嵌套调用的程序test_backtrace,它一共压入了多少信息到堆栈之中。并且它们都代表什么含义?
解答链接:http://www.cnblogs.com/fatsheep9146/p/5079930.html
上述练习已经给了你足够的信息,让你能够实现一个堆栈回溯函数,mon_backtrace。在kern/monitor.c中已经为你声明了这个函数。你可以用C语言实现它。不仅如此,你还要把它加入到kernel moniter的命令集合中,这样用户就可以通过moniter的命令行调用它了。
这个函数应该能够展示出下面这种格式的信息:
Stack backtrace:
ebp f0109358 eip f0100a62 args 00000001 f0109e80 f0109e98 f0100ed2 00000031
ebp f0109ed8 eip f01000d6 args 00000000 00000000 f0100058 f0109f28 00000061
...
其中第一行 "Stack backtrace"表明现在正在执行的就是mon_backtrace子程序。第二行展示的就是调用mon_backtrace的程序a,第三行展示的就是调用程序a的程序b,依次类推,直到最外层。
在每一行中,ebp后面的值代表的是被这个函数所使用的ebp寄存器的值,这个值也是这个函数的栈帧的最高地址。而eip后面的值代表的是函数的返回地址。最后的五个列在args之后的16进制的值,是传递给这个函数的头五个输入参数,当然,有可能输入参数不到五个。
Exercise 11:
实现我们在上面详细说明的backtrace子程序。
解答:
这个子程序的功能就是要显示当前正在执行的程序的栈帧信息。包括当前的ebp寄存器的值,这个寄存器的值代表该子程序的栈帧的最高地址。eip则指的是这个子程序执行完成之后要返回调用它的子程序时,下一个要执行的指令地址。后面的值就是这个子程序接受的来自调用它的子程序传递给它的输入参数。
我们可以看一下下面这张图,是对栈帧的结构一个非常好的解释。
从图中可以看出,当前的内存中包含两个栈帧,一个是当前栈帧,即被调用者的栈帧;另一个是调用者的栈帧。其中我们这个函数的功能就是要得到当前栈帧ebp寄存器的值,以及调用者栈帧中的返回地址,传递给当前栈帧的输入参数。
所以根据图中这些数据的分布位置情况,我们可以知道,寄存器ebp的值是当前栈帧的最高地址,而且这个最高地址对应的内存单元里面存放的值恰好是调用者栈帧的最高地址处。
调用者的返回地址就存放在ebp+4地址单元处。存放完返回地址,紧挨着的高位地址处存放的就是调用者传递给被调者的输入参数(ebp+8, ebp+12....)。
所以综上所述,只要我们知道当前运行程序的ebp寄存器的值就可以,之后至于其他的我们都可以根据ebp寄存器的值推导出来。
代码已经完成,可以到github上具体查看~
到目前为止,你所编写的backtrace函数应该能够把导致mon_backtrace()函数执行的所有函数的地址信息打印出来了。但是在实际情况中,你经常会向弄清楚这些地址对应的到底是哪个函数。
为了达到这个目的,我们已经提供给你一个函数 debuginfo_eip(),这个函数将会标识表(symbol table)中查找eip的值,然后显示出来关于这个eip的值相关的调试信息。这个函数定义在kern/kdebug.c文件中。
Exercise 12
关于这个Exercise,我还没有完成,我会在后面对这个部分进行补充。