汇编语言教程

会 C 语言就会汇编 — 变量、栈、条件跳转

寄存器 栈帧 CMP 条件跳转 函数调用
写在前面:本文以 32 位汇编为主,也会混着讲点 64 位(原理一样,只是名字前面多了个 R,如 EAX → RAX)。如果你学过 C 语言,知道指针、变量、函数调用、if-else、switch,那你完全具备了看懂汇编的底色。

逆向与二进制基础|汇编:变量、栈、条件跳转,会 C 语言就会汇编

学过 C 语言再来看汇编,把寄存器当变量,栈还是你熟悉的那个栈, 比较、条件跳转选分支——入门真的很简单。cmp

很多人一听到“汇编语言”,脑海里浮现的就是满屏的十六进制,还没开始学腿就先软了。

不过先别慌。 汇编不是逆向专家才能看的,是“懂一点点代码的人”都可以随便看的。

如果你学过 C 语言,知道指针、变量、函数调用、、,那你完全具备了看懂汇编的底色。 今天我们不管 CPU 怎么造出来的,不管内存颗粒的物理结构,甚至不用深究寄存器的硬件原理。if-elseswitch

我们只做三件事:

  1. 1. 把寄存器当变量用。
  2. 2. 把栈直接当成你 C 语言里那个栈,看它在汇编里怎么长出来的。
  3. 3. 把 当成 的条件比较,把条件跳转当成分支选择。cmpif

本文以 32 位汇编为主,也会混着讲点 64 位(原理一样,只是名字前面多了个 ,如 → )。REAXRAX


一、寄存器:8 个预定义的“变量”

在 C 语言里,你定义变量:


    
    
    
  int a = 10;
int
 *p = &a;

在汇编里,没法随便新建变量,CPU 只给你有限的几个“变量”

我们把常用寄存器分成三组,这样方便理解。

1. 数据寄存器(存值、存地址、存指针)

EAX EBX ECX EDX ESI EDI

你就当它们是六个全局变量。

  • • 经常兼职“返回值”也就是returnEAX
  • • 经常兼职“计数器”ECX
  • • / 常用于字符串或数组操作ESIEDI

2. 栈相关寄存器(管函数调用和栈帧)

ESP EBP

由于栈的重要性,后面会用一整节来细讲,现在只需要记住:

  • • :栈顶指针,永远指向当前栈顶。ESP
  • • :栈基址指针,用来固定一个函数的栈帧底部。EBP

3. 程序指针寄存器(不能直接写,只能借指令改)

EIP (64 位叫 )RIP

它永远指向下一条要执行的指令地址,像程序的行号光标。


    
    
    
  地址: 0x401000   mov eax, 60    ; 执行这行时,EIP = 0x401005
地址: 0x401005   mov edi, 42    ; 执行这行时,EIP = 0x40100A

EIP 不能直接 mov eip, 1234,只能通过 jmp、call、ret 等指令间接修改。


二、最常见的指令,其实就三个逻辑

1. —— 就是 C 语言里的 (赋值)MOV=


    
    
    
  int a = 10;
int
 b = a;

汇编:


    
    
    
  mov eax, 10    ; eax = 10
mov ebx, eax   ; ebx = eax

2. —— 取地址,不是取值LEA

mov知道了lea就不难了对比看一下:


    
    
    
  mov eax, [0x400000]   
把地址 0x400000里面存的值取出来,放进 eax
lea eax, [0x400000]   
把 0x400000 这个数本身(地址常数)放进 eax

这就跟 C 语言里的:


    
    
    
  int x = *p;     // mov  取内容
int
 *q = &x;    // lea  取地址

注:有中括号 [ ] 基本代表“去指针指向的地方拿东西”。 也就是解指针

3. 加减乘除、移位、逻辑


    
    
    
  add eax, 1           ; 加
sub eax, 1           ; 减
mul ecx              ; 无符号乘
div ecx              ; 无符号除
shl eax, 1           ; 左移
shr eax, 1           ; 逻辑右移
and eax, 0xFF        ; 按位与
or eax, 1            ; 按位或
xor eax, eax         ; 清空 eax 的经典写法
movzx eax, byte ptr [地址]   ; 读单字节并零扩展,避免高位污染

这些基本都是见名知意,碰到不认识的查一下即可,无需死记硬背。


三、栈与函数调用:从 C 代码到汇编的完整拆解

栈,你学 C 语言时就知道:函数调用时,局部变量、返回地址、参数都放在栈上。 汇编里的栈就是同一个东西。 另外栈的结构和c语言中的stack结构是一样的(先进后出)

栈向低地址方向生长。 数据时 减小, 时 增大。pushESPpopESP

3.1 一个最简单的函数,看看栈帧怎么搭起来的

C 代码:


    
    
    
  void test() {
    int
 val = 0;
    // do something...

}

int
 main() {
    test();
    return
 0;
}

汇编层面, 调用 时发生了什么?maintest

第一步:call test 指令


    
    
    
  call test

call 做了两件事:

  1. 1. 把返回地址( 下一条指令的地址)压入栈中。call
  2. 2. 跳转到 函数的地址开始执行。test

此时栈上多了一层返回地址, 减 4。ESP

第二步:进入 test,标准开头


    
    
    
  push ebp          ; 保存 main 函数的 EBP(栈基址)
mov ebp, esp      ; 把当前栈顶设为 test 函数的栈基址
sub esp, 4        ; 为局部变量 val 分配 4 字节空间

这一步是每个函数必做的栈帧搭建:

  • • 把调用者的 存起来,以便返回时恢复。push ebpEBP
  • • 把 当前值赋给 ,从此 固定指向 栈帧的底部。mov ebp, espESPEBPEBPtest
  • • 把 往下移 4 字节,空出的位置就是局部变量 。sub esp, 4ESPval

此时栈的布局(从高地址到低地址):


    
    
    
  [ebp+4]   返回地址
[ebp]     保存的 main 的 EBP
[ebp-4]   局部变量 val    ← ESP 指向这里

函数内访问 就是 。 如果还有更多局部变量,就 ,然后用 、 等访问。val[ebp-4]sub esp, 更大的数[ebp-8][ebp-12]

第三步:函数返回,标准结尾


    
    
    
  mov esp, ebp      ; 释放所有局部变量,ESP 指回保存的 EBP 处
pop ebp           ; 恢复 main 的 EBP,ESP 自动指向返回地址
ret               ; 弹出返回地址到 EIP,程序回到 main 继续执行

这三步是函数收尾的标准动作,与开头严格对应:

  • • 撤销 分配的空间。mov esp, ebpsub esp, N
  • • 恢复调用者的 。pop ebpEBP
  • • 弹出返回地址,跳回调用者。ret

3.2 带参数的函数调用

C 代码:


    
    
    
  int add(int x, int y) {
    int
 sum = x + y;
    return
 sum;
}

int
 main() {
    int
 result = add(3, 5);
    return
 0;
}

调用者 的准备工作(cdecl 调用约定)main


    
    
    
  push 5            ; 参数 y 先入栈(参数从右向左压入)
push 3            ; 参数 x 入栈
call add          ; 压入返回地址,跳转到 add
add esp, 8        ; 调用者负责清理栈上的 8 字节参数

add 函数的汇编实现


    
    
    
  push ebp          ; 保存 main 的 EBP
mov ebp, esp      ; 建立 add 的栈帧
sub esp, 4        ; 为局部变量 sum 分配空间

mov eax, [ebp+8]  ; 取出参数 x (3)
add eax, [ebp+12] ; 加上参数 y (5)
mov [ebp-4], eax  ; 存入局部变量 sum

; 返回值通常放在 eax 中
mov eax, [ebp-4]  ; sum 的值放入 eax

mov esp, ebp      ; 释放 sum 的空间
pop ebp           ; 恢复 main 的 EBP
ret               ; 返回,main 的 add esp, 8 随后清理参数

进入 后的栈布局:add


    
    
    
  [ebp+12]  参数 y (5)
[ebp+8]   参数 x (3)
[ebp+4]   返回地址
[ebp]     保存的 main 的 EBP
[ebp-4]   局部变量 sum   ← ESP 指向这里

栈平衡规则

函数用了多少栈空间,一定要释放多少。 和 要配对, 后必须有 或 来恢复。 参数要么由调用者清理(如 ),要么由被调用函数用 清理(stdcall 约定)。pushpopsub esp, Nmov esp, ebpadd esp, Nadd esp, 8ret 8

3.3 函数标准头尾模板(32 位)

任何一个函数在汇编里都有这个骨架,看到它就认出了函数边界:


    
    
    
  push ebp
mov ebp, esp
sub esp, 0x20       ; 分配 32 字节局部变量空间
... 函数体 ...
mov esp, ebp
pop ebp
ret

这就是栈帧的完整生命周期:搭台子 → 用台子 → 拆台子


四、条件判断: 做比较,条件跳转选分支cmp

汇编没有 关键字,没有 关键字。 它的条件判断就两步:先用 cmp 做比较(相当于 if 括号里的条件),然后根据结果用条件跳转指令选择走哪条路。 ifswitch

4.1 —— 做减法,但不保存结果,只设标志位CMP


    
    
    
  cmp eax, 10

cmp eax, 10 就是偷偷算了一下 eax - 10,然后根据减出来的结果改变 CPU 内部的几个标志,结果本身直接丢掉。

这组标志存在 标志寄存器。 减法后会自动更新几个关键位:EFLAGS

  • • ZF(零标志):减出来是 0,ZF 置为1。 (说明两个数相等)
  • • CF(进位/借位标志):不够减,发生借位,CF 置为1。 比如 。cmp 2, 5
  • • SF(符号标志):减出来是负数,SF 置为1。
  • • OF(溢出标志):有符号数结果超出范围,OF 置为1。

乘法也会影响 CF 和 OF。 比如 (有符号乘法),如果乘积的结果高位不是符号位的简单扩展,说明结果超出寄存器能表示的范围了,CF 和 OF 都会置为1。 这在检查乘法溢出时有用。imul

4.2 后面跟条件跳转,就是完整的 cmpif

cmp 把灯设好之后,后面一定跟一个条件跳转指令。 这些跳转指令就是看灯决定往哪走。

C 语言: if (a == b) { ... }

汇编就是:


    
    
    
  cmp eax, ebx      ; 相当于 if (eax == ebx) 的条件比较
je  somewhere     ; 如果 ZF 亮(相等),就跳到大括号里的代码

常用跳转指令就是一张表,看过一遍有印象就行:

指令什么意思看哪个灯C 语言等价
je相等就跳ZF = 1if (a == b)
jne不等就跳ZF = 0if (a != b)
ja无符号大于就跳CF=0 且 ZF=0if (a > b)
jb无符号小于就跳CF=1if (a < b)
jae无符号大于等于CF=0>=
jbe无符号小于等于CF=1 或 ZF=1<=
jg有符号大于就跳ZF=0 且 SF=OFif (a > b)
jl有符号小于就跳SF != OFif (a < b)
jge有符号大于等于SF=OF>=
jle有符号小于等于ZF=1 或 SF!=OF<=

重点区分一下 ja 和 jg:

  • • :把你比的两个数当无符号数(全是正数,0 到 42 亿)。ja
  • • :把你比的两个数当有符号数(可以有负数)。jg

看例子:


    
    
    
  mov eax, -1    ; 0xFFFFFFFF
cmp eax, 0
ja  somewhere   
0xFFFFFFFF 作为无符号数是 42 亿,大于 0,会跳!
jg  somewhere   ; 作为有符号数,-1 小于 0,不跳。

逆向时看到条件跳转,要留个心眼:编译器到底在比有符号数还是无符号数?

4.3 实战: 的跳转表switch-case

C 语言里:


    
    
    
  switch(index) {
    case
 0: ... break;
    case
 1: ... break;
    case
 2: ... break;
    case
 3: ... break;
    default
: ...
}

编译器往往不会翻译成一长串 ,而是用跳转表,效率更高。cmp/je

来看这段经典 x64 汇编:


    
    
    
  .intel_syntax noprefix
.global _start
_start:
    cmp     rdi, 3
    ja      default           ; rdi > 3(无符号),直接去 default
    jmp     [rsi + rdi*8]     ; rdi 在 0~3,查跳转表
default:
    jmp     [rsi + 32]        ; 32 = 4*8,跳转表第 5 项

逐行解释:

  1. 1. : 是 ,跟 3 比较。cmp rdi, 3rdiindex
  2. 2. :如果 (无符号比较),直接跳到 。 跳转表只有 0~3 四项,超出下标必须保护。ja defaultrdi > 3default
  3. 3. :核心。 是跳转表基地址,每个目标地址占 8 字节。 就是索引乘以 8。 从表里取出 8 字节的目标地址,然后 过去。jmp [rsi + rdi*8]rsirdi*8[rsi + rdi*8]jmp
  4. 4. 的地址在 (前 4 项各 8 字节 = 32),就是表的第 5 项。defaultrsi+32

这就是 switch 的高效实现,比一个个 if 快得多。

下次在反汇编里看到 这类语句,马上反应过来:它在查跳转表。jmp [rax + rcx*8]


五、画龙点睛:汇编代码的骨架长这样

抛开所有细节,大多数汇编程序都是由以下积木搭起来的:

  1. 1. 变量搬运:、、、 清零。movleamovzxxor
  2. 2. 算术逻辑:、、、、...addsubimulandshl
  3. 3. 栈帧操作(函数调用):
    
        
        
        
      push ebp
    mov ebp, esp
    sub esp, 0x20     ; 分配局部变量空间
    ...
    mov esp, ebp
    pop ebp
    ret
  4. 4. 条件判断: 设灯,然后 、、、 等看灯跳转,就是 。cmpjejnejajgif
  5. 5. 跳转表: 实现 。jmp [base + index*scale]switch

当你下次看到一大片十六进制,心里就能浮现出这些结构:

“这里在设标志位…… 那里在看灯跳转…… 这是个函数头,push ebp; mov ebp, esp 建栈帧…… 它要 ret 回家了。 ”

汇编不再是天书,而是由几个简单部件组装成的精密机器。


六、栈帧结构为什么是 PWN 的基础

如果你将来要学习二进制漏洞利用(PWN),尤其是栈溢出,那么今天讲的栈帧结构就是你绕不开的地基。

栈溢出的本质非常简单:局部变量的空间是有限的,但程序没有检查输入的长度,导致数据越界写到了栈帧的其它区域。

回顾一下栈帧布局:


    
    
    
  [ebp+8]   参数
[ebp+4]   返回地址
[ebp]     保存的 EBP
[ebp-4]   局部变量 buf[8]
[ebp-12]  ...

假设 只有 8 个字节,但你强行打了 20 个字节 的台子,多出的数据会一路向上覆盖掉 (保存的 EBP),再覆盖掉 (返回地址)。 当函数执行 时,就会把被篡改的值弹进 ,从而跳转到攻击者控制的地址上。buf[ebp][ebp+4]retEIP

这就是经典的栈溢出劫持控制流。 你学到的每一个细节—— 怎么压返回地址、 怎么保存栈基址、局部变量在 的位置——都是漏洞利用的发力点。callpush ebp[ebp-xx]

很多 CTF 的 pwn 题,第一步就是 IDA 里看栈帧大小,找到缓冲区到返回地址的偏移,然后构造 payload 精准覆盖返回地址。 今天你在这里打下的栈帧基础,正是后面搞懂 ROP、ret2libc、canary 绕过等高阶技巧的基石。

把今天的内容啃透,以后看到 sub esp, 0x28 时,你不是在背指令,而是直接能想到:这函数向上搭了 40 字节的台子,buf 可能在 ebp-0x20 处,返回地址就在 ebp+4,我如果能控制输入,就能改变这个台子原本的作用。


下篇预告

有了这些基础,我们就可以一起拆一个真正的程序,分析它的控制流,找出隐藏的逻辑。

准备开始PWN了吗 !