过了个年,好久没碰专业内的东西了,之前做的JOS相关的东西都快忘了,还是看了前面两篇日志才想起来。
当进入内核后基本都是比较简单的代码了,我也并没有全部分析,根据讲义要求只分析了一下printf函数和堆栈的backtrace,所以这篇日志也就写这两个方面吧。
1、printf函数。
进入kernel后从i386_init函数开始,首先做一些初始化工作,包括部分内存的清零,初始化显示器串口等(无非是判断一下地址使光标闪动正确的位置等),然后调用了cprintf,尝试讲一个10进制的数字用8进制来表示,而这个函数是需要我们完成的。
进入cprintf函数(printf.c文件)后,首先是使用va_前缀的函数(也许是宏)来取出参数,这是标准的c语言可变长度参数的实现形式。这个函数其实是一些_buildin前缀的函数的别名,而_buildin函数则是gcc内置的函数,并不在任何的JOS代码中有定义,当GCC进行编译的时候会自动将这些函数名与相应的函数体连接。之后则调用vcprintf函数。
vcprintf函数定义在同一个文件里,直接调用vprintfmt函数,值得注意的是传入一个函数指针,指向了本文件的putch函数,这个函数之后再讲。
进入vprintfmt(printfmt.c文件)函数,发现这里实现的控制打印格式的逻辑,然后调用传进来的函数指针,进行具体的打印工作。找到含有case 'o'的代码,仿照case 'd'的代码完成8进制数字显示即可,也就是原封不动的复制过来,将base改成8,如下:
之前的putch函数会调用console.c里的cons_putc函数,而cons_putc函数又调用了cga_putc /serial_putc/lpt_putc,分别对应写显示器,写串口和并口,之所以我们不仅在qemu里面看到了kernel打印的文字,还在我们的控制台里看到了打印文字,就是因为其写了串口或者并口的原因,再由qemu将串口或并口输出信息打印到控制台(具体哪个口我没有深究)。
一言以蔽之,console.c完成“如何打印”的逻辑,而printfmt.c完成“打印什么”的逻辑,它们的链接纽带就是printf.c。
2、堆栈的backtrace
JOS中所有函数的调用公用一个堆栈,在之前已经分析过了堆栈是如何初始化以及位于内存中的位置,现在的任务是要从堆栈中找到函数的嵌套调用的关系,并显示出来。
首先想一想,函数调用是如何使用堆栈的。抛开JOS不谈,一个函数在调用时,肯定要压入参数给函数体传值(当然有时候也使用寄存器传值,比如windows c里面的fast call,这里暂且不论),然后要压入函数结束后的下一条指令的地址,以便函数可以正确的返回,其次因为公用一个堆栈所以要压入BP也就是基址寄存器的值,和在函数体中使用到的寄存器的值,以便返回时可以恢复现场。但是这些值压入的顺序和规则目前还是不知道的,需要一些额外的资料。
通过读kernel.asm的代码或者看讲义文件,我们可以知道JOS中的函数调用后堆栈的结构是这样的:
esp的含义是“这个地址以下的空间是未被使用的堆栈控件”,ebp的含义是“这个地址以下至esp的空间是属于目前所执行函数的堆栈空间”,所以图中saved%ebp和 ret%eip就是属于调用此函数的函数的ebp和eip。
通过阅读汇编代码我们可以发现,一个函数在调用之前,其调用者会将参数压栈(顺序没深究,和编译器有关),也就是压入arg2 和arg1,然后调用call,call的动作会把ret%eip压栈,同时转到函数体执行,在函数体执行的开头有一段预处理代码,也就是图中的prologue,会将ebp寄存器(call指令不改变ebp的值,此时的ebp还是上一个函数的)内容压栈,然后将当前esp赋值给ebp,随后进行现场保存的工作,存储在local variables空间里,值得注意的是,在预处理时会一下申请足够的空间,包括保存现场所需空间,局部变量所需空间(这大概也就是标准C的变量声明需要放在函数开头的原因吧,为了方便编译器),调用其它函数所压入变量的空间,换句话说图中arg1,arg2是属于上一个函数的local variables空间,这也就是backtrace不能准确的判断出函数所传参数个数而统一要求打印出5个参数的原因。
因此,通过ebp不断寻找上层的ebp,直到回溯所有的函数,在entry.S中可以看到,在调用i386_init之前,将ebp置0了,因此当ebp为0的时候就是函数返回的时候,按这个逻辑代码如下:
需要注意的是,因为是32位环境,所以esp指针的变化每次+4(32位是4字节)。
执行结果如下:
bingo!
可以看到,第一个函数参数为3个0,后面的函数参数依次是0 1 2 3 4 5,和代码逻辑相同,说明implement应该没什么大问题。
最后说一下read_eip()函数的技巧。
为什么这里read_eip()是个普通函数而不是一个inline函数,众所周知,inline函数会在编译的时候直接嵌入其调用者的代码里,这样尝试读取eip寄存器值这个动作本身就会改变eip寄存器的值,使read_eip()方法变的毫无意义。
因此作为一个普通函数,在调用的时候会把其调用者的eip入栈,然后在从栈中找到这个eip返回,栈中确切的位置之前分析过是ebp的上4个字节处,所以使用嵌入汇编高效的完成这个功能。