X86汇编调用框架浅析与CFI简介

[阅读本文仅需要一点x86汇编的知识。另, 本文的汇编使用AT&T语法]

在内核代码调试中, 或是漏洞分析中, 学会看懂backtrace或是熟悉汇编, 都是基础之功。这中间都牵涉到一个叫调用框架(call frame)的概念, 这个名词也叫栈帧(stack frame)活动过程记录(activation record)。所谓调用框架就是指称一个函数(或过程,或方法)被调用时位于内存中的一块区域, 其中保存着该函数体运行所需要的信息。这其中涉及到几点重要的考量:

  • 函数拥有明显的生存周期界限。在被调用进入执行第一条函数体指令时开始存在, 在返回调用者时消亡。
  • 函数可以输入参数以改变具体行为, 而具体参数值在运行时确定。
  • 函数可以被多次调用, 比如以循环或递归方式。

综合这些考量, 现代的硬件与操作系统一齐合作, 提供了一种方案, 就是调用框架:

 
  
  1. 操作系统在进程的地址空间中提供一块区域, 每当一个函数被调用, 就在其中开辟一段区域, 这段区域存放着函数活动过程的重要信息的记录, 当其返回时, 这块区域就被销毁, 这是`活动过程记录`得名的缘由。
  2. 函数调用链是一个`先入后出`的结构: A调用B, A的生命流程总比B长, 这块区域也体现了这种结构: 调用链上每个函数的活动记录都以先入后出的方式组织在这个区域中, 所以这块区域被叫做`栈`, 每个活动记录被叫做`栈帧`
  3. 现代的CPU, 几乎都提供了实现这种栈的硬件支持: 有一个`寄存器SP`(stack pointer), 指向当前活动记录的顶端, 还有一个`寄存器BP`(base pointer),指向栈底。

下面就x86架构的调用框架进行分析。

x86的调用框架

下面是一个函数示例代码:

 
  
  1. /* demo.c */
  2. int demo(int a, int b) {
  3. long l1 = 1;
  4. int l2 = 2;
  5. return l1 + l2 + a + b;
  6. }
  7. int main(void) {
  8. demo(37, 42);
  9. return 0;
  10. }

编译成汇编代码, 用-O0选项, 表示不优化, 以生成可以和原始代码逐条对应的目标代码:

 
  
  1. $ gcc --version
  2. gcc (Ubuntu/Linaro 4.7.2-2ubuntu1) 4.7.2
  3. $ gcc -O0 -o demo.s -S demo.c

结果如下, main函数只取一部分:

 
  
  1. 原始版 简化版
  2. demo: demo:
  3. .LFB0: pushl %ebp
  4. .cfi_startproc movl %esp, %ebp
  5. pushl %ebp
  6. .cfi_def_cfa_offset 8 subl $16, %esp
  7. .cfi_offset 5, -8 movl $1, -8(%ebp)
  8. movl %esp, %ebp movl $2, -4(%ebp)
  9. .cfi_def_cfa_register 5
  10. subl $16, %esp movl -4(%ebp), %eax
  11. movl $1, -8(%ebp) movl -8(%ebp), %edx
  12. movl $2, -4(%ebp) addl %eax, %edx
  13. movl -4(%ebp), %eax ----- > movl 8(%ebp), %eax
  14. movl -8(%ebp), %edx
  15. addl %eax, %edx addl %eax, %edx
  16. movl 8(%ebp), %eax movl 8(%ebp), %eax
  17. addl %eax, %edx addl %eax, %edx
  18. movl 12(%ebp), %eax movl 12(%ebp), %eax
  19. addl %edx, %eax addl %edx, %eax
  20. leave
  21. .cfi_restore 5 leave
  22. .cfi_def_cfa 4, 4 ret
  23. ret
  24. .cfi_endproc
  25. main:
  26. ...
  27. movl $42, 4(%esp) // 把42赋给%esp指向地址再加4字节的位置
  28. movl $37, (%esp)
  29. call demo

左边为原始版本, 掺杂了许多包含.cfi_*的指令, 这部分放在本文最后讲述。 所以, 现在就上图右边的简化版本来进行讨论。

传参

对于x86架构, 前述的SPBP寄存器分别是espebp。包括x86在内的几乎所有现代的机器, 栈都是从高地址向低地址生长的, 一个例外是HP的PA-RISC机器[1]。

main函数中, 有两条mov指令, 明显是执行传参的动作。在调用demo函数的指令call demo执行前, 栈是如此的形态:

 
  
  1. 高地址 : :
  2. | | 42 |
  3. | +----------+ <--- %esp + 4
  4. | | 37 |
  5. 低地址 +----------+ <--- %esp

注意参数是以从右到左的方式传入的, 至于其原因, 后文再解释。

前序

call demo指令执行后, 就进入了demo函数的活动范围, 在其运行结束后, 控制流程又会返回到main函数的活动范围。这种嵌套结构, 本质地要求要记录下两层结构交汇点的信息, 以便在底一层结构消除后, 返回到上一层。

从CPU的观点来看, 由于它的指令寄存器eip存放的是下一条指令的地址, 这就要求: 在函数最后一条指令执行后,eip中能正确存放函数之后的下一条指令的位置, 术语叫返回地址, 这意味着这个返回地址必须存在某处以方便获取。由于一个CPU执行单元只有一个eip, 而在函数执行过程中, eip会被不断改变, 所以, 返回地址显然不能放在其中。通用的解决方案是: 把返回地址放在调用框架中, 作为其保存信息的一部分。

所以, call demo的效果是: 返回地址会被CPU自动压到栈上, 然后, eip中被装入demo函数第一条指令地址, 由此, 实现了函数调用。此时, 栈的形态是:

 
  
  1. 高地址 : :
  2. | | 42 |
  3. | +----------+ <--- %esp + 8
  4. | | 37 |
  5. | +----------+ <--- %esp + 4
  6. | | 返回地址 |
  7. 低地址 +----------+ <--- %esp

demo函数的前两条指令, 执行了很奇怪的操作: 先把旧的ebp压栈, 再把当前的esp赋给ebp, 此时两个寄存器都指向同一位置。 栈形态如下:

 
  
  1. 高地址 : :
  2. | | 42 |
  3. | +----------+ <--- %esp + 12
  4. | | 37 |
  5. | +----------+ <--- %esp + 8
  6. | | 返回地址 |
  7. | +----------+ <--- %esp + 4
  8. | | 旧的ebp |
  9. 低地址 +----------+ <--- %esp(%ebp)

这两步操作是个规范化步骤, 叫做前序(prologue), 它有两个作用 :

  • 标记一个新的调用框架。保存前一个函数的调用框架的基址(旧的ebp), 使ebp指向当前函数的调用框架基址。

  • 在函数的执行过程中, 函数的局部变量将会是在返回地址之下的区域开辟空间来存放, 由于ebp是固定的, 可以用它作标杆, 标示参数与局部变量的位置。比如第一个参数位于%ebp + 8, 第二个参数位于%ebp + 12。也正是这个原因, 参数采用从右到左传递, 对实现可变参数有利: 通过%ebp + 8获取第一个参数后, 可从中获知参数个数, 然后, 依次偏移, 即可获取各个参数。

局部变量

上面的汇编代码中, 在前序后, 开始第一条指令, 它把栈顶esp向下推了16字节。这一步的作用是为局部变量开辟空间。本例中只有两个局部变量, 且两者在x86上都分别是4字节, 按理说, 只要开辟8字节的空间即可, 本例中却开辟了16字节。这是因为GCC的栈上默认对齐是16字节[2]。

之后, 局部变量也可以由ebp来访问。比如本例中, l1与l2分别由%ebp - 8%ebp - 4来表示。如图:

 
  
  1. 高地址 : :
  2. | | 42 |
  3. | +----------+ <--- %esp + 12
  4. | | 37 |
  5. | +----------+ <--- %esp + 8
  6. | | 返回地址 |
  7. | +----------+ <--- %esp + 4
  8. | | 旧的ebp |
  9. | +----------+ <--- %esp(%ebp)
  10. | | l2 |
  11. | +----------+ <--- %ebp - 4
  12. | | l1 |
  13. 低地址 +----------+ <--- %ebp - 8

尾声

在函数执行完, 就可以ret指令返回。注意前面有一条leave指令, 它等效于以下两条指令:

 
  
  1. movl %ebp, %esp
  2. pop %ebp

这两条指令叫尾声(epilogue), 可以看出, 它与前序相照应。执行完这两条指令后, ebp恢复为旧的ebp, 即指向调用者的基址, esp则指向返回地址。最后的ret指令, 弹出返回地址eip中, 由此, 便完成了从被调用函数到调用函数的返回。

CFI: 调用框架指令

在了解了调用框架之后, 就能明白CFI的作用了。CFI全称是Call Frame Instrctions, 即调用框架指令

CFI提供的调用框架信息, 为实现堆栈回绕(stack unwiding)异常处理(exception handling)提供了方便, 它在汇编指令中插入指令符(directive), 以生成DWARF[3]可用的堆栈回绕信息。这里列有gas(GNU Assembler)支持的CFI指令符

接下来分析上面的例子中出现了几个CFI指令符

在一个汇编函数的开头和结尾, 分别有.cfi_startproc.cfi_endproc标示着函数的起止。被包围的函数将在最终生成的程序的.eh_frame段中包含该函数的CFI记录, 详细请看这里。

.cfi_def_cfa_offset 8一句。该指令表示: 此处距离CFA地址为8字节。这有两个信息点:

  • CFA(Canonical Frame Address)是标准框架地址, 它被定义为在前一个调用框架中调用当前函数时的栈顶指针。结合本例看, CFA如图所示:

     
        
    1. 高地址 : :
    2. | | 42 |
    3. | +----------+ <--- %esp + 12
    4. | | 37 |
    5. | +----------+ <--- %esp + 8 <--- CFA
    6. | | 返回地址 |
    7. | +----------+
    8. | | 旧的ebp |
    9. 低地址 +----------+ <--- %esp(%ebp)
  • 至于此处指的是esp寄存器指向的位置。在push %ebp后, esp显然距离CFA为8字节。

.cfi_offset 5, -8一句。

把第5号寄存器[4]原先的值保存在距离CFA - 8的位置, 结合上图, 意义明显。

.cfi_def_cfa_register 5一句。

该指令是位于movl %esp, %ebp之后。它的意思是: 从这里开始, 使用ebp作为计算CFA的基址寄存器(前面用的是esp)。

.cfi_restore 5.cfi_def_cfa 4, 4这两句结合起来看。注意它们位于leave指令之后。

前一句意思是: 现在第5号寄存器恢复到函数开始的样子。第二句意思则是: 现在重新定义CFA, 它的值是第4号寄存器(esp)所指位置加4字节。这两句其实表述的就是尾声指令所做的事。意义亦很明显。

* * * * * * 全文完 * * * * * *

[1]: 关于栈生长方向的讨论, 可以看看这个帖子。

[2]: 关于GCC的栈对齐值, 查看GCC文档-mpreferred-stack-boundary选项。

[3]: DWARF是一种调试信息格式。

[4]: x86的8个通用寄存器依次分别是: eax, ebx, ecx, edx, esp, ebp, esi, edi

你可能感兴趣的:(Linux,杂文)