【C语言】 第三课 函数与栈帧机制详解

发布于:2025-09-05 ⋅ 阅读:(18) ⋅ 点赞:(0)

1 函数的基本概念

在C语言中,函数是程序的基本执行单元。一个函数的定义包括返回类型、函数名、参数列表和函数体。例如:

int add(int x, int y) { // 函数定义
    int z = x + y;
    return z;
}

在使用函数前,通常需要声明( declaration)其类型,让编译器知道函数的存在和格式:

int add(int, int); // 函数声明

调用(Call)函数时,需要传递参数(Argument)。C语言默认采用传值调用(Pass by Value),即函数内部操作的是实参值的一份临时拷贝(形参),修改形参不会影响原始实参。

2 栈与栈帧的核心概念

2.1 什么是栈 (Stack)

栈是程序运行时内存中的一块特殊区域,遵循 “后进先出” (LIFO, Last In First Out) 的原则。它主要用于支持函数调用。栈的生长方向是从高地址向低地址扩展。

2.2 什么是栈帧 (Stack Frame)

每次函数调用,操作系统都会在栈上为其分配一块独立的连续内存区域,称为栈帧 (Stack Frame) 或活动记录 (Activation Record)。函数执行结束后,其对应的栈帧被销毁。

栈帧的核心作用包括:

  • 存储函数的参数
  • 保存返回地址:函数执行完后需要回到调用者的位置
  • 存储函数的局部变量
  • 保存调用函数的栈帧基址:以便被调函数返回后能恢复调用函数的栈帧
  • 提供临时数据的存储空间:如表达式计算的中间结果

2.3 寄存器的作用

在x86架构下,有两个关键寄存器用于管理栈帧:

  • ESP (Extended Stack Pointer):栈指针寄存器,始终指向当前栈帧的顶部
  • EBP (Extended Base Pointer):基指针寄存器,指向当前函数栈帧的底部。通过EBP,可以稳定地访问栈帧内的参数和局部变量(因为ESP在函数执行过程中会随着push/pop操作而变化)。

3 函数调用过程中栈帧的详细布局与变化

我们以下面的简单代码为例,深入剖析栈帧的创建和销毁过程:

#include <stdio.h>

int Add(int x, int y) {
    int z = 0;
    z = x + y;
    return z;
}

int main() {
    int a = 10;
    int b = 20;
    int c = 0;
    c = Add(a, b);
    printf("%d\n", c);
    return 0;
}

3.1 main 函数栈帧的创建

main 函数被调用之初(其内部代码执行前),会先建立自己的栈帧:

push   ebp        ; 将调用main函数的函数(如invoke_main)的ebp压栈保存
mov    ebp, esp   ; 将当前esp的值赋给ebp,此刻ebp成为main函数栈帧的新基址
sub    esp, 0E4h  ; 为main函数的局部变量、临时数据等预留空间(0E4h字节)

此时栈空间的布局大致如下(地址从高到低增长):

地址 内容 说明
ebp + 4
ebp 旧的ebp值 调用者的栈基址
ebp - 4 可能保存的寄存器(如edi)
ebp - 8 int a (值为10) main的局部变量
ebp - 14h int b (值为20) main的局部变量
ebp - 20h int c (值为0) main的局部变量
… (可能包含对齐填充)
esp 栈顶

3.2 调用 Add 函数:参数传递与栈帧变化

执行 c = Add(a, b); 时,会发生以下步骤:

  1. 参数从右向左压栈

    mov    eax, dword ptr [ebp-14h] ; 将变量b的值(20)存入eax
    push   eax                       ; 将参数y (b的值) 压栈
    mov    ecx, dword ptr [ebp-8]    ; 将变量a的值(10)存入ecx
    push   ecx                       ; 将参数x (a的值) 压栈
    

    此时栈顶附近增加了两个新的值(yx),ESP 随之减小。

  2. 执行 call 指令

    call   00D110B9          ; 调用Add函数(地址由编译器决定)
    

    call 指令做了两件事:

    • 返回地址(即call指令下一条指令的地址,例如00D11917)压入栈。
    • 跳转到 Add 函数的地址开始执行。
      此时,在参数之上又压入了返回地址,ESP 再次更新。

    此时栈的布局变为:

    地址 内容 说明

| … | … | … |
| ebp + 4 | … | |
| ebp | 旧的ebp值 | 调用者的栈基址 |
| … | … | |
| ebp - 20h | c (0) | |
| … | … | |
| ← ESP指向这里 | 返回地址 | 00D11917 |
| | 参数 x (10) | a的拷贝 |
| | 参数 y (20) | b的拷贝 |

3.3 Add 函数栈帧的创建与执行

进入 Add 函数后,它会立即建立自己的栈帧:

push   ebp        ; 将main函数的ebp压栈保存
mov    ebp, esp   ; 将当前esp设为Add函数栈帧的基址(ebp)
sub    esp, 0CCh  ; 为Add的局部变量等开辟空间

现在栈的布局是:

地址 内容 说明
ebp + 8 参数 x (10) 通过[ebp+8]访问
ebp + 0Ch 参数 y (20) 通过[ebp+0Ch]访问
ebp + 4 返回地址 00D11917
ebp main的ebp 保存的main函数基址
ebp - 4 可能保存的寄存器
ebp - 8 int z (0) Add的局部变量
ESP Add栈帧顶部

接着执行 Add 函数体:

mov    dword ptr [ebp-8], 0    ; int z = 0;
mov    eax, dword ptr [ebp+8]  ; 将参数x的值(10)存入eax
add    eax, dword ptr [ebp+0Ch] ; 加上参数y的值(20),结果在eax
mov    dword ptr [ebp-8], eax  ; z = x + y; (结果30存入z)
mov    eax, dword ptr [ebp-8]  ; 将z的值(30)存入eax,作为返回值

返回值通常存放在EAX寄存器中

3.4 Add 函数栈帧的销毁与返回

函数返回前,需要销毁其栈帧:

mov    esp, ebp   ; 将esp移回ebp处,释放Add函数的所有局部变量空间
pop    ebp        ; 弹出栈顶值到ebp,此时栈顶值正是之前保存的main函数的ebp,从而恢复main函数的栈帧基址
ret               ; 从栈顶弹出返回地址(00D11917)并跳转回去

ret 指令执行后,ESP 会指向之前压入的参数 xy 所在的位置。回到 main 函数后,它会清理栈上的参数:

add    esp, 8     ; 将栈顶指针esp向上移动8字节,清理掉两个4字节的参数
mov    dword ptr [ebp-20h], eax ; 将eax中的返回值(30)存储到变量c中

4 使用GDB调试器观察栈帧

理论结合实践是关键。你可以使用GDB(GNU Debugger)来动态跟踪函数调用过程,直观地观察栈帧、寄存器和内存的变化。

4.1 准备工作

  1. 编译带调试信息的程序:使用 gcc-g 选项编译你的C程序,以便GDB能显示源代码和调试信息。
    gcc -g -o my_program my_program.c
    
  2. 启动GDB
    gdb my_program
    

4.2 基本调试命令与观察点

在GDB中,你可以使用以下命令来观察栈帧(以分析上面的 Add 函数调用为例):

命令 说明
break mainb main main 函数入口处设置断点
break addb add add 函数入口处设置断点
runr 开始运行程序,直到遇到第一个断点
nextini 执行一条汇编指令(Step instruction)
stepisi 执行一条汇编指令,如果是call指令则会进入函数(Step into instruction)
info registers 显示所有寄存器的当前值(重点关注 eax, ebp, esp, eip
print $ebp 打印ebp寄存器的值
print $esp 打印esp寄存器的值
x/20xw $esp 以十六进制字(4字节)格式检查栈指针($esp)附近20个字的内存内容
disassembledisas 反汇编当前函数的汇编代码
backtracebt 显示当前的函数调用栈回溯(call stack)
continuec 继续运行程序,直到下一个断点或程序结束

4.3 实践观察步骤

  1. main 函数开始和 Add 函数入口处设置断点:(gdb) b main, (gdb) b add
  2. 运行程序:(gdb) run
  3. 程序会在 main 开始处停下。使用 (gdb) disas 查看 main 函数的反汇编代码。
  4. 单步执行(ni)直到 call add 指令之前。
  5. 记录下此时 ESPEBP 的值:(gdb) info registers esp ebp
  6. 查看此时栈的内容(例如,ESP指向的位置):(gdb) x/10xw $esp
  7. 执行 call 指令(ni)。观察 ESP 的变化(减少了4字节,因为压入了返回地址),并再次查看栈顶内容,应该能看到返回地址。
  8. 现在进入 Add 函数。再次查看反汇编 (gdb) disas
  9. 单步执行 Add 函数开头的 push ebpmov ebp, espsub esp, ... 等指令。每执行一条,观察 ESP 和 EBP 寄存器值的变化,并结合 x/xw $espx/xw $ebp 查看栈内存内容。
  10. 重点关注 mov eax, dword ptr [ebp+8] 这样的指令,它正是在通过 EBP + 偏移 来访问参数。你可以用 (gdb) print *(int*)($ebp+8) 来验证是否拿到了参数x的值。
  11. 当执行到 ret 指令前,观察 EAX 寄存器的值,它应该保存着返回值。
  12. 执行 ret 后,注意程序流是否跳回了 main 函数,以及 ESP 如何变化(指向参数所在位置)。
  13. 回到 main 后,执行 add esp, 8,再次观察 ESP 的变化,确认参数被清理。

通过以上步骤,你可以非常直观地看到栈帧的创建、使用和销毁全过程,以及参数和返回值是如何传递的。

5 C代码与汇编代码对比

理解汇编代码是逆向工程的基石。下面是一个简单的对比,展示了C代码和它可能对应的x86汇编代码(使用GCC风格):

C代码 汇编代码 (x86) 说明
int a = 10; mov DWORD PTR [ebp-0x4], 0xa 将立即数10(0xa)存入[ebp-4]的地址(局部变量a)
int b = 20; mov DWORD PTR [ebp-0x8], 0x14 将立即数20(0x14)存入[ebp-8]的地址(局部变量b)
c = add(a, b); mov eax, DWORD PTR [ebp-0x8]``push eax``mov eax, DWORD PTR [ebp-0x4]``push eax``call <add>``add esp, 0x8``mov DWORD PTR [ebp-0xc], eax 参数从右向左(b then a)压栈,调用函数,清理栈空间,保存返回值到c
int z = x + y; (在add函数内) mov eax, DWORD PTR [ebp+0x8]``add eax, DWORD PTR [ebp+0xc]``mov DWORD PTR [ebp-0x4], eax 通过EBP+偏移访问参数(x在[ebp+8], y在[ebp+12]),结果存入局部变量z([ebp-4])
return z; mov eax, DWORD PTR [ebp-0x4] 将返回值放入eax寄存器

这种对比能帮助你建立高级语言与低级机器指令之间的直观联系,是逆向分析中不可或缺的能力。


网站公告

今日签到

点亮在社区的每一天
去签到