4. 汇编语言入门(下):条件跳转、循环结构与函数调用约定
好,咱们接着往下聊。上一节我们把寄存器、栈、基本指令都过了一遍,算是把汇编的“单词”和“语法”搞清楚了。今天要聊的,是让程序真正“活”起来的东西——控制流。
说白了,就是让CPU能根据条件做决定,能反复执行某段代码,还能优雅地调用函数。这些在高级语言里就是if、for、while、函数调用,但在汇编层面,它们是怎么实现的?嗯,今天我们就把它扒开看看。
核心知识点一览:
- 条件跳转指令:jmp、je、jne、jg、jl 等
- 循环结构的汇编实现
- 函数调用约定:cdecl 与 stdcall
4.1 条件跳转:让CPU学会“做选择”
高级语言里的if-else,到了汇编层面,其实就是两条指令的组合:比较指令 + 条件跳转指令。
比较指令通常是 cmp,它干的事很简单——做减法,但不保存结果,只影响标志寄存器。标志寄存器里有几个关键位:ZF(零标志)、CF(进位/借位标志)、SF(符号标志)、OF(溢出标志)。
然后条件跳转指令根据这些标志位决定是否跳转。举个例子:
; 假设 eax = 5, ebx = 3
cmp eax, ebx ; 计算 5 - 3,结果不为0,ZF=0;无借位,CF=0
jg greater ; 如果 eax > ebx(有符号),则跳转到 greater 标签
; 不跳转则继续往下执行
常用的条件跳转指令我列个表,你逆向分析时基本就靠它们了:
| 指令 | 含义 | 判断条件 |
|---|---|---|
| jmp | 无条件跳转 | 直接跳,不依赖任何标志 |
| je / jz | 相等则跳转 | ZF = 1 |
| jne / jnz | 不相等则跳转 | ZF = 0 |
| jg / jnle | 大于则跳转(有符号) | ZF=0 且 SF=OF |
| jl / jnge | 小于则跳转(有符号) | SF ≠ OF |
| ja / jnbe | 高于则跳转(无符号) | CF=0 且 ZF=0 |
| jb / jnae | 低于则跳转(无符号) | CF = 1 |
我的小经验:逆向分析时,看到 cmp eax, 0 后面跟着 je 或 jne,十有八九是在判断某个值是否为零。这在检查函数返回值时特别常见。我曾经在分析一个恶意软件样本时,就是靠着一连串的 cmp ... je 理清了它的决策树逻辑。
4.2 循环结构:汇编里的“重复劳动”
高级语言里的 for、while、do-while,在汇编里其实没有专门的“循环指令”。你可能会想,那循环怎么实现?
答案很简单:用条件跳转自己搭。本质上就是一段代码,执行完后跳回开头,直到某个条件满足才跳出。
来看一个典型的 do-while 循环的汇编实现:
; C语言: do { ... } while (ecx != 0);
mov ecx, 10 ; 循环计数器
loop_start:
; 这里放循环体代码
; ...
dec ecx ; ecx--
cmp ecx, 0 ; 比较 ecx 是否为0
jne loop_start ; 不为0则继续循环
; 循环结束,继续往下执行
for 循环也类似,只不过多了初始化、条件判断、更新三个部分:
; C语言: for (i = 0; i < 10; i++) { ... }
mov ecx, 0 ; i = 0
for_loop:
cmp ecx, 10 ; i < 10 ?
jge loop_end ; 如果 i >= 10,跳出
; 循环体代码
; ...
inc ecx ; i++
jmp for_loop ; 跳回继续判断
loop_end:
; 循环结束
注意:逆向分析时,如果你看到一段代码末尾有 jmp 或 jne 跳回前面的地址,而且中间有 cmp 和计数器增减操作,那基本可以断定这是一个循环。我曾经在分析一个加密算法时,就是通过识别这种循环模式,找到了它的密钥扩展逻辑。
4.3 函数调用约定:谁负责清理栈?
函数调用,说白了就是“跳转过去执行一段代码,执行完再跳回来”。但这里有个关键问题:参数怎么传?谁负责清理栈?
不同的编译器、不同的操作系统,约定不一样。最常见的两种是 cdecl 和 stdcall。
4.3.1 cdecl 调用约定
这是 C 语言默认的调用方式。特点是:调用者负责清理栈。
; 调用 func(1, 2, 3)
push 3 ; 参数从右向左入栈
push 2
push 1
call func ; 调用函数
add esp, 12 ; 调用者清理栈(3个参数 × 4字节 = 12字节)
你看,调用完函数后,调用者手动把栈指针加回去。这样做的好处是支持可变参数(比如 printf),因为只有调用者才知道到底传了多少个参数。
4.3.2 stdcall 调用约定
这是 Windows API 的默认方式。特点是:被调用者负责清理栈。
; 调用 func(1, 2, 3)
push 3
push 2
push 1
call func ; 调用函数
; 这里不需要 add esp,因为函数内部会清理
; 函数内部会这样返回:
ret 12 ; ret 12 表示返回并弹出 12 字节的参数
stdcall 的好处是生成的代码更紧凑,因为每个调用点少了一条 add esp 指令。但缺点是不支持可变参数。
| 特性 | cdecl | stdcall |
|---|---|---|
| 参数入栈顺序 | 从右到左 | 从右到左 |
| 栈清理者 | 调用者 | 被调用者 |
| 支持可变参数 | 是 | 否 |
| 常见场景 | C 语言默认、Linux | Windows API |
| 函数名修饰 | 下划线前缀(_func) | 下划线前缀+@+参数大小(_func@12) |
逆向分析实用技巧:
- 看到
call后面紧跟着add esp, X,就是 cdecl。 - 看到
ret X(X > 0),就是 stdcall。 - Windows 的 DLL 导出函数基本都是 stdcall,所以你在分析 Windows 程序时,看到
ret 8或ret 12非常正常。
避坑指南:我曾经在分析一个加壳程序时,因为没注意调用约定,把 stdcall 的函数当成了 cdecl 来解析,结果栈指针算错了,整个反汇编结果全乱套。后来我养成了一个习惯:先看函数返回时的指令,是 ret 还是 ret X,一眼就能判断调用约定。
4.4 综合示例:一个完整的函数调用
把今天学的串起来,看一个完整的例子。假设有这样一个 C 函数:
int add(int a, int b) {
return a + b;
}
void main() {
int result = add(3, 5);
}
用 cdecl 编译后,汇编大概长这样:
; add 函数
_add:
push ebp ; 保存旧的基址指针
mov ebp, esp ; 设置新的基址指针
mov eax, [ebp+8] ; 取第一个参数 a
add eax, [ebp+12] ; 加上第二个参数 b
pop ebp ; 恢复基址指针
ret ; 返回,cdecl 由调用者清理栈
; main 函数
_main:
push 5 ; 参数 b
push 3 ; 参数 a
call _add ; 调用 add
add esp, 8 ; 调用者清理栈
; 此时 eax 中就是返回值 8
你看,整个流程非常清晰。参数通过栈传递,返回值通过 eax 传递。这就是函数调用的底层真相。
嗯,今天的内容就到这。条件跳转、循环、调用约定,这三样东西是逆向分析时最常碰到的。你如果能熟练识别这些模式,看反汇编代码就会像看高级语言一样自然。