汇编语言教程
会 C 语言就会汇编 — 变量、栈、条件跳转
目录
一、寄存器:8 个预定义的"变量" 1. 数据寄存器 2. 栈相关寄存器 3. 程序指针 二、最常见的指令 1. MOV 赋值 2. LEA 取地址 3. 算术逻辑 三、栈与函数调用 3.1 最简函数栈帧 3.2 带参数调用 3.3 函数标准头尾 四、条件判断 4.1 CMP 比较 4.2 条件跳转 4.3 switch 跳转表 五、汇编代码骨架 六、栈帧与 PWN逆向与二进制基础|汇编:变量、栈、条件跳转,会 C 语言就会汇编
学过 C 语言再来看汇编,把寄存器当变量,栈还是你熟悉的那个栈, 比较、条件跳转选分支——入门真的很简单。
cmp
很多人一听到“汇编语言”,脑海里浮现的就是满屏的十六进制,还没开始学腿就先软了。
不过先别慌。 汇编不是逆向专家才能看的,是“懂一点点代码的人”都可以随便看的。
如果你学过 C 语言,知道指针、变量、函数调用、、,那你完全具备了看懂汇编的底色。 今天我们不管 CPU 怎么造出来的,不管内存颗粒的物理结构,甚至不用深究寄存器的硬件原理。if-elseswitch
我们只做三件事:
- 1. 把寄存器当变量用。
- 2. 把栈直接当成你 C 语言里那个栈,看它在汇编里怎么长出来的。
- 3. 把 当成 的条件比较,把条件跳转当成分支选择。
cmpif
本文以 32 位汇编为主,也会混着讲点 64 位(原理一样,只是名字前面多了个 ,如 → )。
REAXRAX
一、寄存器:8 个预定义的“变量”
在 C 语言里,你定义变量:
int a = 10;
int *p = &a;在汇编里,没法随便新建变量,CPU 只给你有限的几个“变量”
我们把常用寄存器分成三组,这样方便理解。
1. 数据寄存器(存值、存地址、存指针)
EAX EBX ECX EDX ESI EDI
你就当它们是六个全局变量。
- • 经常兼职“返回值”也就是return
EAX - • 经常兼职“计数器”
ECX - • / 常用于字符串或数组操作
ESIEDI
2. 栈相关寄存器(管函数调用和栈帧)
ESP EBP
由于栈的重要性,后面会用一整节来细讲,现在只需要记住:
- • :栈顶指针,永远指向当前栈顶。
ESP - • :栈基址指针,用来固定一个函数的栈帧底部。
EBP
3. 程序指针寄存器(不能直接写,只能借指令改)
EIP (64 位叫 )RIP
它永远指向下一条要执行的指令地址,像程序的行号光标。
地址: 0x401000 mov eax, 60 ; 执行这行时,EIP = 0x401005
地址: 0x401005 mov edi, 42 ; 执行这行时,EIP = 0x40100AEIP 不能直接 mov eip, 1234,只能通过 jmp、call、ret 等指令间接修改。
二、最常见的指令,其实就三个逻辑
1. —— 就是 C 语言里的 (赋值)MOV=
int a = 10;
int b = a;汇编:
mov eax, 10 ; eax = 10
mov ebx, eax ; ebx = eax2. —— 取地址,不是取值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 testcall 做了两件事:
- 1. 把返回地址( 下一条指令的地址)压入栈中。
call - 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, 10cmp 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 = 1 | if (a == b) |
jne | 不等就跳 | ZF = 0 | if (a != b) |
ja | 无符号大于就跳 | CF=0 且 ZF=0 | if (a > b) |
jb | 无符号小于就跳 | CF=1 | if (a < b) |
jae | 无符号大于等于 | CF=0 | >= |
jbe | 无符号小于等于 | CF=1 或 ZF=1 | <= |
jg | 有符号大于就跳 | ZF=0 且 SF=OF | if (a > b) |
jl | 有符号小于就跳 | SF != OF | if (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. : 是 ,跟 3 比较。
cmp rdi, 3rdiindex - 2. :如果 (无符号比较),直接跳到 。 跳转表只有 0~3 四项,超出下标必须保护。
ja defaultrdi > 3default - 3. :核心。 是跳转表基地址,每个目标地址占 8 字节。 就是索引乘以 8。 从表里取出 8 字节的目标地址,然后 过去。
jmp [rsi + rdi*8]rsirdi*8[rsi + rdi*8]jmp - 4. 的地址在 (前 4 项各 8 字节 = 32),就是表的第 5 项。
defaultrsi+32
这就是 switch 的高效实现,比一个个 if 快得多。
下次在反汇编里看到 这类语句,马上反应过来:它在查跳转表。jmp [rax + rcx*8]
五、画龙点睛:汇编代码的骨架长这样
抛开所有细节,大多数汇编程序都是由以下积木搭起来的:
- 1. 变量搬运:、、、 清零。
movleamovzxxor - 2. 算术逻辑:、、、、...
addsubimulandshl - 3. 栈帧操作(函数调用):
push ebp
mov ebp, esp
sub esp, 0x20 ; 分配局部变量空间
...
mov esp, ebp
pop ebp
ret - 4. 条件判断: 设灯,然后 、、、 等看灯跳转,就是 。
cmpjejnejajgif - 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了吗 !