4. 汇编语言入门(下):条件跳转、循环结构与函数调用约定

好,咱们接着往下聊。上一节我们把寄存器、栈、基本指令都过了一遍,算是把汇编的“单词”和“语法”搞清楚了。今天要聊的,是让程序真正“活”起来的东西——控制流。

说白了,就是让CPU能根据条件做决定,能反复执行某段代码,还能优雅地调用函数。这些在高级语言里就是if、for、while、函数调用,但在汇编层面,它们是怎么实现的?嗯,今天我们就把它扒开看看。

核心知识点一览:

  • 条件跳转指令:jmp、je、jne、jg、jl 等
  • 循环结构的汇编实现
  • 函数调用约定:cdecl 与 stdcall
汇编控制流核心结构 条件跳转 jmp / je / jne / jg / jl 循环结构 cmp + jxx 实现 函数调用约定 cdecl / stdcall 标志寄存器 EFLAGS ZF、CF、SF、OF 循环模式 do-while / for 模拟 栈帧管理 push / pop / ret 逆向分析核心:看懂跳转逻辑 = 看懂程序骨架 条件跳转决定分支,循环决定重复,调用约定决定参数传递

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 后面跟着 jejne,十有八九是在判断某个值是否为零。这在检查函数返回值时特别常见。我曾经在分析一个恶意软件样本时,就是靠着一连串的 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:
    ; 循环结束

注意:逆向分析时,如果你看到一段代码末尾有 jmpjne 跳回前面的地址,而且中间有 cmp 和计数器增减操作,那基本可以断定这是一个循环。我曾经在分析一个加密算法时,就是通过识别这种循环模式,找到了它的密钥扩展逻辑。

4.3 函数调用约定:谁负责清理栈?

函数调用,说白了就是“跳转过去执行一段代码,执行完再跳回来”。但这里有个关键问题:参数怎么传?谁负责清理栈?

不同的编译器、不同的操作系统,约定不一样。最常见的两种是 cdeclstdcall

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 8ret 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 传递。这就是函数调用的底层真相。

嗯,今天的内容就到这。条件跳转、循环、调用约定,这三样东西是逆向分析时最常碰到的。你如果能熟练识别这些模式,看反汇编代码就会像看高级语言一样自然。


公众号:蓝海资料掘金营,微信 deep3321