【实现】分析内核函数调用关系

首先,ucore需要建立一个空的栈空间,然后才能进行函数调用、参数传递等处理工作。ucore是在哪里建立的栈呢?其实ucore是借用了bootloader的栈空间,而bootloader在bootasm.S中的如下语句建立的栈空间:

  1. # Set up the stack pointer and call into C.
  2. movl $0x0, %ebp
  3. movl $start, %esp

可以看到bootloader把栈底设置到了$start地址处,正好是bootloader的起始地址0x7c00。不过由于入栈操作中的esp是向下增长的,所以不会覆盖bootloader的内容。那ebp有何作用呢?我们先暂时放在一边,继续跟踪代码的执行。

接下来,bootloader会调用bootmain()函数,而bootmain()函数会在加载完ucore后,调用ucore的起始函数kern_init()。在ucore的继续执行过程中,还将有如下的函数调用过程:

  1. kern_init-->monitor-->runcmd-->mon_backtrace-->print_stackframe

这通过看源代码或执行monitor中的backtrace命令都可以了解到。

我们可以结合前面的实验来说明ucore是如何分析出这样的调用关系的。

操作系统中的中断(也称异常)技术是操作系统的重要功能,是计算机硬件和软件相配合产生的重要技术。简单地说,中断处理是指由于有紧急事件产生,需要打断当前CPU的正常执行,转而处理紧急事件,处理完毕后,恢复到被打断的地方继续执行。通过中断机制,计算机系统可以高效地处理外设请求,可以快速响应应用软件的异常或请求,也可以有规律地打断应用程序的执行,把执行CPU控制权还到操作系统手中,从而使得整个计算机系统的资源可控。但单纯的操作系统原理书籍很难深入分析中断的处理细节。我们希望通过后续的proj4/4.1.1等的实验,让读者了解到ucore操作系统如何完成上述事情。

首先我们需要了解GCC生成的C函数调用过程:

  1. 调用函数为了传递参数给被调用函数,需要执行0到n个push指令把函数参数入栈,然后会执行一个call指令,在call指令内部执行过程中,还把返回地址(即CALL指令下一条指令的地址)也入栈了。
  2. GCC编译器会在每个函数的起始部分插入类似如下指令(可参看obj/kernel.asm文件内容):

    1. push %ebp
    2. mov %esp,%ebp
    3. sub $NUM,%esp

在ucore执行到一个函数的函数体时,已经有以下数据顺序入栈:调用函数的参数,函数返回地址。由此得到类似如下的栈结构(参数入栈顺序跟调用方式有关, 这里以C语言默认的CDECL为例):

  1. 高地址方向
  2. |------------------------|
  3. |------------------------| <-----------
  4. | ......... |
  5. | argument 3 | Caller's stack frame
  6. | argument 2 |
  7. | argument 1 |
  8. | return address |<---------- esp
  9. |------------------------|
  10. | ......... |
  11. 低地址方向

“push %ebp”和“mov %esp,%ebp”这两条指令实在隐含了对函数调用关系链的建立:首先将ebp入栈,然后将栈顶指针esp赋值给ebp,此时的栈结构如下所示:

高地址方向
|————————————|
|————————————<—————-
| ……… |
| argument 3 | Caller’s stack frame
| argument 2 |
| argument 1 |
| return address |<—————-
|——previous ebp——-|<——— ebp, esp
|————————————|
| ……… |
低地址方向

“mov %esp,%ebp”这条指令表面上看是用esp把ebp的旧值覆盖了,但在这条语句之前,ebp旧值已经被压栈(位于栈顶),而新的ebp又恰恰指向栈顶。第三条语句“sub $NUM,%esp”把esp减少了NUM个值,这其实是建立了函数的局部变量、寄存器保存的空间。此时的栈结构如下所示:

  1. 高地址方向
  2. |------------------------|
  3. |------------------------<-----------
  4. | ......... |
  5. | argument 3 | Caller's stack frame
  6. | argument 2 |
  7. | argument 1 |
  8. | return address |<-----------
  9. |----previous ebp-----|<------ ebp, esp
  10. |------------------------|
  11. | ......... |
  12. | saved registers |
  13. | ......... | Current(Callee's) stacl frame
  14. | local variables |
  15. | …...... |
  16. |----------------------- |<-------- esp
  17. |------------------------|
  18. | ......... |
  19. 低地址方向

到此时为止,ebp寄存器处于函数调用关系链中一个非常重要的地位。ebp寄存器中存储着栈中的一个地址(栈帧分界处),此地址是“老”ebp入栈后的栈顶。那么以该该地址为基准,向高地址方向(即栈底方向)能获取返回地址、参数值,向低地址方向(栈顶方向)能获取函数局部变量值,而该地址处又存放着上一层函数调用时的ebp值。由于ebp中的地址处总是“上一层函数调用时的ebp值”,这样,就能通过把ebp的内容作为寻找上一个调用函数的栈帧的指针,如此形成递归,直至到达栈底。这就可找到整个的函数调用栈。这也是kdebug.c中print_stackframe函数的实现内容。