这一章从“函数能调用”推进到“函数到底怎么调用”。后面学逆向、调试、栈回溯、崩溃分析、Hook 参数读取时,调用栈和调用约定会反复出现。

2.0 汇编指令速览

这节先把常见汇编指令当作调试语言来认识,不急着背全。最重要的是能在反汇编窗口里看懂大概:谁把数据放进寄存器,谁压栈,谁跳转,谁调用函数,谁返回。

指令 含义 复盘重点
mov 数据传送 看清源操作数和目的操作数
push / pop 压栈 / 出栈 观察参数、返回地址、寄存器保存
call 调用函数 会压入返回地址并跳到目标函数
ret 函数返回 从栈中取回返回地址
add / sub 加减运算 常用于调整栈顶和整数计算
cmp / jcc 比较和条件跳转 对应 if、循环和分支

2.1 函数调用堆栈

函数调用时,栈里保存了返回地址、参数、局部变量和部分寄存器现场。调试时如果能看懂栈,就能知道“现在从哪里来、准备回哪里去、参数大概是什么”。

栈中内容 作用
返回地址 当前函数执行完后回到调用者的位置
参数 调用者传给被调函数的数据
局部变量 函数内部临时使用的数据
保存寄存器 保证调用前后的上下文一致

最小复盘代码:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int add(int a, int b) {
int sum = a + b;
return sum;
}

int main(void) {
int ret = add(10, 20);
printf("ret = %d\n", ret);
return 0;
}

复盘时在 add 里打断点,看参数、局部变量和返回地址,尝试把源码、反汇编和栈窗口对上。

2.2 函数调用约定

调用约定规定参数怎么传、谁清理栈、返回值放在哪里。x86 下常见 cdeclstdcallfastcall;x64 下通常使用统一调用约定,前几个参数走寄存器。

调用约定 参数传递 栈清理 复盘点
cdecl 多数参数经栈传递 调用者清理 支持可变参数
stdcall 多数参数经栈传递 被调用者清理 WinAPI 中常见
fastcall 部分参数走寄存器 约定决定 参数读取要结合寄存器
x64 前几个参数走寄存器 调用者维护栈空间 注意栈对齐和 shadow space

安全开发里看 Hook、回调和函数指针时,调用约定错了很容易参数错位、栈不平衡或直接崩溃。

2.3 函数堆栈平衡

堆栈平衡指函数调用前后栈顶能回到合理位置。栈不平衡常见原因包括调用约定不匹配、参数个数错误、函数指针类型写错、手写汇编保存/恢复不完整。

问题 表现 复盘方式
参数个数不对 返回后崩溃或变量异常 对比函数声明和调用点
调用约定不对 ret 后栈顶错位 看调用者/被调者谁在清栈
保存现场不完整 调用后寄存器值异常 检查保存和恢复是否成对

2.4 宏函数

宏函数是预处理阶段的文本替换,不是真正函数。它没有类型检查,也不会生成函数调用栈,但写得好可以减少重复,写得差会制造非常隐蔽的 bug。

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

#define SQUARE_BAD(x) x * x
#define SQUARE_OK(x) ((x) * (x))

int main(void) {
printf("%d\n", SQUARE_BAD(1 + 2)); // 1 + 2 * 1 + 2
printf("%d\n", SQUARE_OK(1 + 2));
return 0;
}

复盘重点:宏参数要加括号,宏整体也要加括号;复杂逻辑尽量用函数或内联函数。

2.5 数组参数与返回

数组作为函数参数时会退化为指针,所以函数里拿不到原数组长度。安全开发里这点非常重要:只传数组指针、不传长度,后面很容易出现越界。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

void print_array(const int *arr, int count) {
for (int i = 0; i < count; ++i) {
printf("%d ", arr[i]);
}
printf("\n");
}

int main(void) {
int nums[] = {1, 2, 3, 4};
print_array(nums, 4);
return 0;
}
写法 风险
只传 arr 函数不知道边界
arr + count 更容易写遍历区间
传数组引用/模板 C++ 中可以保留长度信息

2.6 结构体参数样式

结构体可以按值传递,也可以按指针传递。按值会复制整个结构体;按指针效率高,但要注意空指针、生命周期和是否允许修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

typedef struct Student {
int id;
int score;
} Student;

void print_student(const Student *stu) {
if (stu == NULL) {
return;
}
printf("id=%d score=%d\n", stu->id, stu->score);
}

int main(void) {
Student s = {1001, 90};
print_student(&s);
return 0;
}

复盘时优先使用 const Student * 表达“只读引用”,能减少误修改。

2.7 可变参数

可变参数函数的典型例子是 printf。它灵活,但也危险:调用者传入的格式字符串必须和后续参数类型严格对应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdarg.h>

int sum_ints(int count, ...) {
va_list args;
va_start(args, count);

int sum = 0;
for (int i = 0; i < count; ++i) {
sum += va_arg(args, int);
}

va_end(args);
return sum;
}

int main(void) {
printf("%d\n", sum_ints(3, 10, 20, 30));
return 0;
}

安全联系:格式化字符串和可变参数不匹配,是 C 语言里非常经典的风险来源。

2.8 printf 实现原理解析

printf 的核心不是“打印”,而是根据格式字符串逐个解析参数。格式字符串相当于一份说明书,告诉函数后面应该读几个参数、按什么类型解释。

格式 解释方式 常见问题
%d 按整数读取 传错类型会错读
%f 按浮点读取 默认提升规则要注意
%s 按字符串地址读取 空指针或非法地址可能崩溃
%p 按指针输出 调试有用,对外输出要谨慎

2.9 变量内存布局

变量放在哪里,决定了它的生命周期和访问方式。安全开发里要把“变量名”翻译成“内存区域 + 地址 + 大小 + 生命周期”。

区域 内容 生命周期
栈区 局部变量、函数参数 函数调用期间
堆区 手动申请的动态内存 申请后到释放前
全局/静态区 全局变量、静态变量 程序运行期间
只读区 字符串常量、常量数据 程序运行期间
代码区 机器指令 程序运行期间

本章最小复盘代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <stdio.h>
#include <stdarg.h>

typedef struct Packet {
int id;
int length;
} Packet;

int add_all(int count, ...) {
va_list args;
va_start(args, count);
int sum = 0;

for (int i = 0; i < count; ++i) {
sum += va_arg(args, int);
}

va_end(args);
return sum;
}

void print_packet(const Packet *packet) {
if (packet == NULL) {
return;
}
printf("packet id=%d length=%d\n", packet->id, packet->length);
}

int main(void) {
Packet packet = {1, 128};
print_packet(&packet);
printf("sum=%d\n", add_all(3, 10, 20, 30));
return 0;
}

学习检查清单

检查项 状态
能在调试器里看懂一次函数调用的栈变化 待复盘
能说明调用约定和栈平衡为什么重要 待复盘
能写出带长度参数的数组处理函数 待复盘
能解释结构体按值传递和指针传递的区别 待复盘
能说明可变参数和格式化字符串的安全风险 待复盘