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

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

    首先,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函数的实现内容。