汇编基础语法

汇编基础语法

x86-64汇编核心语法包括:寄存器、寻址方式、指令系统、伪指令

寄存器(CPU数据容器)

x86-64有16个通用寄存器(64位):rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, r8, r9, r10, r11, r12, r13, r14, r15

rsi(源变址)、rdi(目的变址)是 pwn 里很重要的两个——负责传函数的第 1、2 个参数

r8–r15 是 x86-64 新增的,没有 16 位/8 位别名,只有r8d/r8w/r8b 这种小写后缀。

按功能可分为通用、段、指令指针寄存器,核心常用寄存器如下:

64位寄存器 32位别名 16位别名 8位别名(高/低) 核心用途
rax eax ax ah/al 累加器,存储运算结果、系统调用号
rbx ebx bx bh/bl 基址寄存器,存储内存地址
rcx ecx cx ch/cl 计数器,循环、函数参数
rdx edx dx dh/dl 数据寄存器,扩展累加器、函数参数
rsp esp sp spl 栈指针,指向当前栈帧的低地址段,(动态变化的“当前栈顶”)
rbp ebp bp bpl 基址指针,指向当前栈帧的高地址端,也就是这个函数栈帧的“基部/起点”
指令 执行后 rax 的值 发生了什么
mov rax, 0x123456789ABCDEF0 0x123456789ABCDEF0 64 位全写,正常
mov eax, 0x12345678 0x0000000012345678 高 32 位被自动清零 —— 不是”只改低 32 位”
mov al, 0x0F 0x123456789ABCDE0F 只改最低 8 位,其余 56 位保持不动

关键差异:写 32 位别名会清零高 32 位,写 8 位/16 位别名不会。这是 x86-64 的规则(32 位运算自动零扩展)。

78988395715

寄存器别名:x86-64 最反直觉的设计

rax \ eax \ ax \ al 不是四个寄存器,他们是同一个寄存器的四个不同宽度的“取景器”。rax是64位的本体,eax / ax / al是从它的最低位(右侧)开始截取的不同长度

78988234240

看图也知道一个常被误解的点:ah不是 rax 的高8位,而是 ax (低16位)里较高的那 8 位,也就是bit 8-15

寻址方式(访问数据的方式)

寻址方式(CPU怎么找到它要操作的那个数据)

前置名词

名词 人话解释
操作数 一条指令里被操作的对象。mov rbx, rax 里,rbx 和 rax 都是操作数。
源操作数 / 目标操作数 数据的来源和去向。Intel 语法是「目标在前」,所以 mov rbx, rax 是”把 rax 的值放进 rbx”。
寻址方式 一条指令告诉你”数据在哪”,但”在哪”有很多种表达方式。这些表达方式的总称就是寻址方式。
标签(label) var、arr 这种名字。汇编器在编译时把它替换成一个具体的内存地址,但它本身不是变量。
段(section) 程序被分块存放。.data 段放有初值的全局数据,.text 段放机器指令。
db define byte,定义 1 字节数据。
小端序(little-endian) 一个多字节的数在内存里低位字节放在低地址。x86 用的就是这个规则。这是理解后面错误的钥匙。
NASM 一种汇编器(把汇编文本翻译成机器码的程序)。你这段代码是 NASM 语法。

四种寻址方式

x86-64支持多种寻址方式,核心常用4种,决定了如何获取内存中的数据:

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

section .data
var db 0x10 ; 定义字节变量var,值为0x10
arr db 0x20, 0x30, 0x40 ; 定义字节数组arr

section .text
global _start

_start:
; 1. 立即数寻址:直接使用常量(仅作源操作数)
mov rax, 0x50 ; rax = 0x50

; 2. 寄存器寻址:访问寄存器中的数据
mov rbx, rax ; rbx = rax(0x50)

; 3. 直接寻址:通过内存地址访问数据
mov rcx, [var] ; rcx = var的值(0x10)

; 4. 寄存器间接寻址:通过寄存器中的地址访问数据
mov rdx, arr ; rdx = 数组arr的地址
mov rsi, [rdx] ; rsi = arr[0](0x20)
mov rdi, [rdx+1] ; rdi = arr[1](0x30)

; 退出程序
mov rax, 60
mov rdi, 0
syscall

方括号 = 去内存取

rdx 和 [rdx] 是两个完全不同的东西:

78988772539

理解了这个区别,四种寻址方式就都通了。逐行过一遍:

行 寻址方式 讲解
mov rax, 0x50 立即数寻址 0x50 是写在指令里的常量,不来自寄存器也不来自内存。所以它只能当源操作数——mov 0x50, rax 是非法的,常量不能被赋值。
mov rbx, rax 寄存器寻址 数据在 CPU 内部传,速度最快,不碰内存。
mov rcx, [var] 直接寻址 地址写死在指令里。[var] = “去 var 这个地址取数”。
mov rdx, arr 取地址 注意:这里没有方括号,所以是把 arr 的地址装进 rdx,不是取内容。这就是 C 语言里 char *p = arr; 的意思。
mov rsi, [rdx] 寄存器间接寻址 地址不在指令里,而是存在 rdx 里。等价于 C 的 *p。
mov rdi, [rdx+1] 间接 + 偏移 偏移 1 字节,等价于 C 的 *(p+1)。

这四种方式其实是一个公式的特例

x86-64的内存操作数有统一格式

[ base + index * sccle + disp ]

部分 含义
base 基址寄存器(任意通用寄存器)
index 变址寄存器(不能是 rsp)
scale 比例,只能是 1 / 2 / 4 / 8
disp 常数偏移

四种”寻址方式”就是这个公式删掉某些项的结果:

  • 直接寻址 [var] → base 和 index 都没有,只剩 disp
  • 寄存器间接 [rdx] → 只有 base
  • 带偏移 [rdx+1] → base + disp
  • 数组访问 [rbx+rcx*4] → base + index × scale

scale 只能取 1/2/4/8,正是为了匹配 1/2/4/8 字节的数据宽度(char/short/int/long)。

核心指令系统(常用)

前置名词

名词 人话解释 简单易懂类比
指令 CPU 能干的一个最小动作。mov、add、jmp 都是一条指令。 厨房菜谱上的一步:切、炒、盛。
操作数 一条指令涉及的数据。mov rax, 100 里,rax 和 100 都是操作数。 一步动作的原料和成品。
目的操作数 / 源操作数 数据要去哪 / 数据从哪来。Intel 语法里目的写在前。 “把牛奶(源)倒进杯子(目的)”。
立即数 直接写在指令里的数字常量。100、0x0F 都是立即数。 菜谱上直接写的”两勺盐”。
寄存器 CPU 内部极小的存储格子,速度最快,数量固定(几十个)。 厨师手边的调料碟,伸手就能拿到。
栈 内存里一块”后进先出”的区域,用来暂存数据。 一摞盘子,只能从顶上取放。
栈顶 栈里最后一个被放进去的位置。这台机器上栈是往下(低地址)长的。 那摞盘子的最上面那一个。
rsp 专门记录”栈顶在哪”的寄存器。 你手边的一个书签,标着盘子摞到第几层。
标志位(rflags) 一个专门寄存器,不存数据,只存最近一次运算的”结论”(相等吗?负数吗?)。 裁判举的牌,记录上一回合谁赢。
按位运算 不是把两个数当整体算,而是把每个二进制位单独拿出来算。 两排开关,逐个对齐后分别决定开或关。
移位 把所有二进制位整体往左或往右挪几格,挪出去的位丢掉,空出来的位补 0。 一排人整体往左平移一格,最左边的人被挤出队伍。
有符号 / 无符号 同一串二进制,你可以把它当正数,也可以允许它是负数。两种视角下”谁大谁小”的结论可能相反。 温度计上的 -5℃ 和 5℃,跟里程表上的”跑了 5 公里”是两码事。
128 位 / rdx:rax 两个 64 位寄存器拼起来当一个超大数用,专门伺候乘除法。 两个人的手接起来才够长,能抱住一个超大箱子。
汇编器(NASM) 把汇编文本翻译成机器码的程序。

数据传送指令(最常用)

mov

——赋值,但不是“移动”

它的全称是move,但他的行为是复制,不是剪切;把源的值抄给目的,源本身不变

  • mov rax, 100-> rax被写成100
  • mov rbx, rax -> 把rax的内容抄一份给 rbx。抄完之后 rax 还是100,不是被清空了

机器层发生了什么:CPU从源位置读出一个值,写进目的位置。mov不修改标志位(这点和add、cmp不同,逆向时很重要)

三个必知限制
  1. 不能内存直接传到内存。

    mov [a], [b]是非法的,原因很朴素:CPU的连接线设计上只能“寄存器 <-> 内存”或“寄存器 <-> 寄存器”,没有“内存 <-> 内存”。想搬必须中转:mov rax, [b]然后mov [a], rax

  2. 两端宽度必须一致。

    mov rax, bl(64位 <- 8位)是非法的,得写成movzx rax, bl

  3. mov的目标不能是立即数 或 rflags。

    不能给常量赋值

push

将数据压入栈(push操作数)

注意:它只有一个操作数,因为它操作的位置是固定的——永远是栈顶

只做两件事
  1. rsp = rsp - 8(栈往下长,所以是减)
  2. 把操作数的8字节写进新的 [rsp]

pop

将数据从栈弹出(pop操作数)

做两件事
  1. 把[rsp]处的8字节读出来,放进操作数
  2. rsp = rsp +8(缩回来)

78992285753

看上面的图,第三个状态里0xFF8位置上的100还在——pop只是把 rsp 移开了,并没有把数据擦掉。所以pop是“逻辑删除”,那块内存依然留着旧值。这个特性在pwn里非常关键:UAF(释放后利用)漏洞的本质,就是以程序以为内存“空了”,实际数据还在,而且被别人重新利用了。

顺序必须相反**:先push A 再 push B,取出来时必须先pop B(得到B)再pop A(得到A)。这就是”后进先出“

lea

只算地址,不开抽屉

全称Load Effective Address加载有效地址(lea寄存器)

它的写法长得像”访问内存“(有方括号),但执行时完全不碰内存:

指令 动作
mov rax, [rbx] 打开 rbx 指的抽屉,把里面的东西拿出来给 rax
lea rax, [rbx] 不打开抽屉,只是把”rbx 这个编号”抄给 rax

差别在 [rbx+8] 这种情况上最明显:lea rax, [rbx+8] 只是做了个加法(rbx 的值加 8),不会去读 rbx+8 处的内存

有两个实际用途:
  1. 算复杂地址。数组第 i 个元素的地址是 基址 + i × 元素大小,正好对应 [base + index*scale] 格式,一条 lea 就能算出来。
  2. 当加法器用。编译器非常爱用 lea rax, [rax+rax*2] 来实现”乘 3”。原因是 lea 不修改标志位、不经过算术逻辑单元、而且机器码里不会出现 0x00 空字节(这点在 shellcode 里是硬需求)。

所以逆向时看到 lea 不要条件反射认为它在取地址——它经常只是在做加法。

示例

1
2
3
4
5
mov rax, 100         ; rax = 100
mov rbx, rax ; rbx = 100。注意 rax 依然是 100,没被清空
push rbx ; rsp 减 8,把 100 写到新栈顶
pop rcx ; rcx = 100,rsp 加 8 退回原位
lea rdx, [rax+rbx] ; rdx = 100 + 100 = 200。rax、rbx 都没变,也没读内存

补充:rdx 拿到的 200 这个结果,add rdx, rax 之类也能做到,但 lea 不会动标志位,也更容易被编码成不含空字节的机器码。

算法运算指令

add / sub ——加减法

1
2
add 目的, 源       ; 目的 = 目的 + 源
sub 目的, 源 ; 目的 = 目的 - 源

特点:结果直接写回目的操作数(原地修改),同时会设置标志位(ZF、SF、OF、CF)。这是它们和 mov 最大的区别。

示例

78995579966

1
2
3
4
mov rax, 20
mov rbx, 10
add rax, rbx ; rax = 20 + 10 = 30,rbx 还是 10
sub rax, 5 ; rax = 30 - 5 = 25

mul / div

为什么乘除法要特殊对待

rax只有64位,能表示的最大值是 2^64 - 1。但两个64位数相乘,结果最大能到约 2^128,一个寄存器装不下。则除法反过来:被除数可能非常大,除法本身也需要”放不下的空间“来周转

而x86的解决方法很巧妙:把rdx和rax两个寄存器拼起来,当作一个 128 位的超大寄存器用,写作rdx:rax。其中 rdx负责高64位,rax负责低64位。

mul (无符号乘法)
1
mul 源操作数

只有一个操作数。另一个乘数是隐含的rax,所以mul rcx的真正意思是rax * rcx。执行结果:

  • rdx = 乘积的高 64 位
  • rax = 乘积的低 64 位
1
2
3
mov rax, 25
mov rcx, 2
mul rcx ; rdx = 0,rax = 50
mul用法
1
2
3
mov rax, 25
mov rcx, 2
mul rcx ; rdx:rax = 25 × 2,结果 rdx = 0,rax = 50

在实际写代码时,如果确定结果不会超过64位,更推荐用有符号乘法的双操作数形式:

1
imul rax, rbx       ; 结果只保留低 64 位,rdx 完全不受影响

imul这个名字是“有符号乘法”,但因为低64位的二进制乘法结果在有无符号两种视角下是完全相同的,所以它在只需要低64位时被广泛当成**“不会污染 rdx 的乘法”**来用,这是实际工程的一个使用技巧

检测乘法是否溢出,可以在乘法后立刻判断:

1
2
mul rcx
jo overflow_happened ; OF 被置 1 说明结果超出了 64 位

不过要注意 mul 只保证正确设置 CF 和 OF 这两个标志位,其他标志位的状态是未定义的,所以不要依赖乘法后的 ZF 或 SF 做判断。

div

用法:

1
div 源操作数

也是单操作数,除数自己写,被除数隐含

含义是:被除数不是rax一个,而是rdx:rax这个128位的整体。执行结果分两处存放——商进rax,余数进rdx

原理:为什么被除数要这么设计?因为除法要能处理“被除数比一个寄存器能存的最大值还大”的情况。如果要除的数是200位宽,CPU没法处理,所以它规定被除数最多128位,用rdx:rax拼出来。这个设计有个副作用:rdx对除法来说不是“辅助寄存器”,而是被除数的高位部分——它参与了运算,不是可有可无的。

78996167369

用法

第一步,动手之前必须先清空 rdx,写成 xor rdx, rdx。原因是 rdx 是 128 位被除数的高半部分,如果它里面残留着旧数据,那么被除数就变成了”旧数据 × 2 的 64 次方 + rax”,是个天文数字,除出来的商极可能超过 64 位,rax 装不下,CPU 就直接抛异常,Linux 下表现为程序被 SIGFPE(浮点异常信号)杀死,终端打印 Floating point exception。这个报错名字有点误导性,它跟浮点数没关系,是整数除法的异常。

第二步,把被除数放进 rax(低 64 位)。

第三步,写 div 除数,除数可以放 rcx 里,也可以直接是一个内存变量。

完整的写法是这样:

1
2
3
4
mov rax, 50
xor rdx, rdx ; 必须的一步
mov rcx, 2
div rcx ; rax = 25(商),rdx = 0(余数)

还有一条红线:除数不能是 0。div 遇到除零会直接触发异常,程序崩溃。所以如果你写的除数来自用户输入,必须先检查它是不是 0,这正是很多真实漏洞的直接成因。

实用场景上,div 有一个特别常用的副产品——取余数。C 语言里的 a % b 就编译成一条 div,然后从 rdx 里读余数。所以要算”17 除以 5 的余数”:

1
2
3
4
5
mov rax, 17
xor rdx, rdx
mov rcx, 5
div rcx
; 现在 rax = 3(商),rdx = 2(余数),想去哪拿就去哪拿

此外

有符号版本 idiv:如果被除数可能是负数,xor rdx, rdx 这一招就不好使了,因为负数的 128 位表示不是这样。比如 -50 的正确 128 位形式是高位全是 1、低位是 0xFFFFFFCE 这样的补码。这时候要用专门的指令 cqo(全称 convert quadword to octword,把 rax 的符号位复制着填满整个 rdx):

1
2
3
4
mov rax, -50
cqo ; 正数时 rdx 变 0,负数时 rdx 变成全 1
mov rcx, 2
idiv rcx ; rax = -25,rdx = 0

同理,mul 的有符号版本是 imul。这组指令的存在意义就是:mul/div 把操作数永远当正数看,而 imul/idiv 会考虑符号位。

逻辑运算指令

前置知识

名词 人话解释 例子 / 类比
位(bit) 最小的信息单位,只能取 0 或 1。8 位 = 1 字节,rax = 64 位。 一个开关,要么开要么关。
二进制 每位只能写 0 或 1 的计数方式,逢二进一。 十进制的 13 写作 1101。
权重 二进制每一位代表固定的数值大小,从右往左是 1、2、4、8、16…… 1101 = 1×8 + 1×4 + 0×2 + 1×1 = 13。这是理解移位的钥匙。
十六进制 二进制的简写,一个十六进制位 = 四个二进制位。 0xF = 1111,0x0F = 0000 1111,0xA = 1010。
按位运算 不把两个数当整体算,而是拆开让每一位单独和对应位运算。 3 & 5 是 011 与 101 逐位比得 001(1);普通加法 3+5=8 是当作整体。
掩码(mask) 专门用来筛选或控制某些位的数,本身往往没有数值含义。 一块打了孔的铁板:有孔的位留下,没孔的一律挡住。
补码 计算机表示负数的方式:正数按位取反再加一。 64 位下 -1 就是 64 个 1,即 0xFFFFFFFFFFFFFFFF。
标志位(rflags) 一个专门寄存器,不存数据,只存最近一次运算的”结论”,每个结论占 1 位。 裁判手里的记分牌,后文的条件跳转全靠它下判断。
ZF / SF / OF / CF 分别为:结果为零、结果为负(最高位是 1)、有符号溢出、无符号进位或借位。 四张判罚牌。
逻辑运算对标志位的影响 and、or、xor、shl、shr 会把 CF、OF 清零并更新 ZF、SF;not 谁都不碰。 所以 xor rax, rax 清零后能直接接 jz。

四种运算的逐位规则

A B A and B A or B A xor B not A
0 0 0 0 0 1
0 1 0 1 1 1
1 0 0 1 1 0
1 1 1 1 0 0

一句话抓住本质:

  • and 是”两边1才得1”;

  • or 是”有1就是1”;

  • xor 是”不同才留 1”;

  • not 是”逐位反色”,且翻转的是全部 64 位,不是只翻低 8 位。

把规则落到实际寄存器上就是下面这张图,起点统一是 rax = 0000 1111:

78996723420

and(逐位与)

用法:and 目的, 源

含义:“两边都是1才得1”

**原理:**源操作数里为0的位会把目的的对应位无条件清零,为1的位则原样保留(因为 0 & x = 0,1 & x = x)。所以and的本质是“按掩码筛掉某些位”

实际场景 写法 说明
取低位(截断) and rax, 0xFF 只留最低 1 字节,高 56 位清零
对 2 的幂取余 and rax, 0xF 等价于 rax % 16,编译器常用这招替代除法
判断奇偶 and rax, 1 后接 jz 最低位为 1 即奇数(其他位的权重都是偶数)
地址对齐 and rsp, -16 低 4 位清零,对齐到 16 字节边界(系统调用常要求)
检查某一位 test rax, 0x4 检查要用 test,它同样只设标志位,但不破坏原值

or (逐位或)

用法:or 目的, 源

**含义:**只要有一个 1 就是 1

**原理:**与and镜像互补,源里为1的位把目的对应位强行置 1,为 0 的位原样保留(0 | x = x,1 | x = 1),所以它是强行点亮

实际场景 写法 说明
置位 or rax, 0x08 bit3 无条件变 1,其他位不动
合并权限标志 or rax, 0x2 叠加权限;用 or 不会误关掉已开启的位,add 会被进位搅乱
构造全 1 or rax, -1 -1 的补码是 64 个 1,结果全 1

or 无法清位。要清掉 bit3 只能用 and rax, ~mask,~ 在汇编里对应的就是下文的 not。

xor(逐位异或)

用法:xor 目的, 源

**含义:**不同得 1

独有的性质是可逆:a xor b = c,则 c xor b = a。原因是异或记录的是”两个数在哪些位置不同”,再异或一次就把差异抹掉、还原原值。

实际场景 写法 说明
清零 xor rax, rax 3 字节(48 31 C0)且不含 0x00;mov rax, 0 是 7 字节且含多个空字节
加解密 xor byte [p], 0x37 同一段代码跑两遍即还原,恶意样本常用它藏字符串
交换两寄存器 三步 xor 交叉 逆向题常出现;现代编译器少用(三步骤串行依赖,更慢)
判断相等 xor rax, rbx 后接 jz 结果为 0 当且仅当两数每一位都相同,即完全相等

not (逐位取反)

用法:not 目的,单操作数,因为它不需要第二个数参照

**含义:**每一位 0 变 1、1 变 0,翻转全部64位()不是只翻低 8 位,not rax对0x0F得到0xFFFFFFFFFFFFFFF0

实际场景 写法 说明
生成清位掩码 not rax 再 and 清位的标准流程:先用 not 造掩码,再用 and 应用
求相反数 not rax + inc rax 等价于 neg rax(补码 = 取反加一),编译器有时这么生成
注意事项 — not 不修改任何标志位(与 neg 不同),后面接 jz 会读到更早那条指令的旧结论

shl / shr (移位)

用法:shl 目的, 位数,位数可以是立即数,也可以放在cl里(cl是 x86 历史上唯一允许指定移位次数得寄存器)。

**含义:**整体平移,移出去得位进 CF,空出去得位补0.

**原理:**每位权重都是2的幂,所以左移 n 位等于 2ⁿ,右移 n 位等于除以 2ⁿ 向下取整。

指令 空位补什么 用于 效果
shl 低位补 0 有符号、无符号都可以 shl rax, 3 = ×8
shr 高位补 0 仅无符号 对负数会把符号毁掉:1111 0100(-12)右移成 0111 1010(+122)
sar 高位补原符号位 有符号 同一个 -12 右移得 1111 1010(-6),符合数学预期
实际场景 写法 说明
快速乘除 shl rax, 3 移位比乘法指令快,编译器普遍这么做
提取高位 shr rax, 32 取原高 32 位(原低位被挤出丢弃)
拼接大常量 mov + shl + or 绕过 mov 立即数只能用 32 位的限制
捕获被移出的位 shl rax, 1 后接 jc 判断原最高位是否为 1(溢出检测常用)

两条限制要记牢:移位次数按 64 取模,shl rax, 64 实际是”移 0 位”,什么都不做,shl rax, 65 等价于移 1 位;负数右移必须用 sar,用 shr 会把负数变成大正数。

控制转移指令(实现分支/循环)

前置知识

名词 人话解释 在这里的作用
rip(指令指针) 一个寄存器,存着”下一条要执行的指令在哪”。程序每执行一条指令,rip 就自动走到下一条。谁能改 rip,谁就控制了程序往哪跑。 所有跳转指令的本质都是”往 rip 里写一个新地址”。
标志位(rflags) 一个专门寄存器,不存数据,只存最近一次运算留下的几个”结论”,每个结论只占 1 个二进制位。 它是 cmp 和条件跳转之间的传话筒:cmp 负责写,跳转负责读。
ZF(零标志) 结果为 0 时置 1。两数相减得 0 就意味着两数相等。 je/jz、jne/jnz 读的就是它。
SF(符号标志) 结果的最高位是 1 时置 1。在有符号视角下表示”结果是负数”。 jg/jl 判断有符号大小时要用它。
OF(溢出标志) 有符号运算结果越界(正+正=负之类)时置 1。 同样是 jg/jl 的判据之一。
CF(进位标志) 无符号运算产生进位或借位时置 1。 ja/jb 这套无符号跳转读它。
标签(label) 代码里的一个名字,汇编器在编译时把它替换成一个具体地址。它本身不是变量,只是一个”地名”。 jmp label1 里的 label1 就是跳转目的地。
cmp 一条”只比较、不留结果”的指令,内部做一次减法,算完把结果扔掉,只保留标志位。 它是条件跳转的前置动作,几乎总成对出现。
顺流而下(fall through) 如果没有任何指令改变 rip,CPU 就一条接一条往下执行。 这是汇编里最容易出逻辑错误的地方:忘写跳转,就会莫名其妙地执行到下一个标签的代码。
有符号 / 无符号视角 同一串二进制,按有符号读可能是负数,按无符号读是个巨大的正数。0xFF...FF 按有符号是 -1,按无符号是 18446744073709551615。 决定你该用 jl 还是 jb——用错方向,判断结论完全相反。

cmp

使用:cmp 目的操作数,源操作数

做的事:内部计算——目的 - 源,然后把结果丢掉,只把这次减法的特征(ZF、SF、OF、CF)写进标志位

向拿天平称两个包裹,目的只是知道”哪边重”,并不关心具体差多少克。

cmp rax rbx算的是rax - rbx,所以后面接jl表达的是“rax小于rbx“,也就是第一个操作数小于第二个操作数。

jmp

——无条件跳转

使用:jmp 标签

含义:直接把 rip 改成标签所在的地址,无条件、无理由

原理:它是最纯粹的”改路线“,不像条件跳转要看标志位脸色,jmp不读任何标志位,也不修改任何标志位

用处

第一个是做无条件分支,上面的分支执行完,必须跳到出口,不能顺流掉下去:

1
jmp finish      ; 直接去出口

第二个是做循环(向后跳转回上面的标签)。这是 jmp 配合条件跳转的经典组合:

1
2
3
4
5
    mov rcx, 5
loop_start:
; 循环体:做五次某件事
dec rcx ; rcx 减 1,同时设置标志位
jnz loop_start ; rcx 不为 0 就跳回去

注意这里的分工:jmp 管”跳得动”,条件跳转管”什么时候停”。向后跳转(目标在当前指令之前)通常就是循环,向前跳转(目标在后面)通常就是分支——这是逆向时快速判断代码结构的经验。

第三个是间接跳转,也就是跳转目标不是写死的标签,而是从寄存器或内存里读出来的:

1
2
3
jmp rax         ; 跳到 rax 里存的地址
jmp [rax] ; 跳到 rax 指向的位置里存的地址
jmp qword [rax+8] ; 带偏移

这一点在 pwn 里至关重要:jmp rax 意味着”跳到哪”由数据决定。如果攻击者能控制那个寄存器或那块内存,就能让程序跳到自己想要的地方——C++ 的虚函数调用、函数指针数组、GOT 表跳转,都是这个机制,也都是漏洞利用的常见落脚点。

条件跳转家族

条件跳转的写法是 j条件 标签,含义是”读指定的标志位,满足就跳,不满足就往下走“。

同一行里的两条指令机器码完全相同,只是取了不同的说法:

指令 判据 含义 别名
je ZF = 1 相等就跳 jz(零标志为 1)
jne ZF = 0 不相等就跳 jnz
jg ZF=0 且 SF=OF 有符号大于 jnle
jge SF = OF 有符号大于等于 jnl
jl SF ≠ OF 有符号小于 jnge
jle ZF=1 或 SF≠OF 有符号小于等于 jng
ja CF=0 且 ZF=0 无符号大于 jnbe
jae CF = 0 无符号大于等于 jnb、jnc
jb CF = 1 无符号小于 jnae、jc
jbe CF=1 或 ZF=1 无符号小于等于 jna
js / jns SF = 1 / SF = 0 结果为负 / 非负 —
jo / jno OF = 1 / OF = 0 有溢出 / 无溢出 —

别名为什么这么起?规则是”加 n 表示取反“。jl 的反面是”大于等于”,所以 jl 也叫 jnge(not greater or equal)。认名字的规律比死记整张表轻松。

必须记住的坑:jl 和 jb 判断的是完全不同的东西。

拿 rax = 0xFFFFFFFFFFFFFFFF 举例,按有符号它是 -1,按无符号它是 18446744073709551615。执行 cmp rax, 1 之后:

  • jl 会跳转,因为按有符号 -1 < 1;
  • jb 不会跳转,因为按无符号这个数远大于 1。

同一个数、同一条 cmp,两条指令给出相反结论。编译器生成的代码里这两套会混着出现(取决于源码里变量声明成 int 还是 unsigned int),逆向时判断错符号类型,就会得出完全错误的逻辑。

另一个重要性质:跳转指令自己不修改标志位。 jmp、je、jl 这些只管读和跳,不会碰 rflags。所以你可以一条 cmp 之后连续接好几个不同的条件跳转,它们看到的是同一份比较结果——这也是编译器愿意用 cmp + 多次跳转来组织代码的原因。

顺带一个工程细节:条件跳转有距离限制,跳得很近时汇编器用 1 字节的短跳转(机器码 2 字节),跳得远就用 4 字节的近跳转(机器码 6 字节)。写代码时不用管,但看机器码长度能判断跳转距离。

伪指令

简要

前面学的(mov、jmp 、syscall等等)是给CPU看的,伪指令是写给汇编器看的

名词 人话解释 例子 / 说明
机器指令 CPU 能直接执行的动作,最终会变成二进制机器码,装进可执行文件。 mov rax, 60、syscall 都是。
伪指令(汇编器指令) CPU 根本不认识它,它只是给汇编器的一条”排版说明”或”登记信息”。汇编完成后它自己就消失了,不留任何机器码。 section .text、global _start、len equ 10。
汇编器(NASM) 把汇编文本翻译成机器码的程序。它负责处理伪指令、把标签换成地址、把数据排进内存。 类比:翻译官。伪指令就是你对翻译官说的”这页用大标题、这页加脚注”,成品里不会留下这些嘱咐。
链接器(ld) 把汇编器产出的目标文件(.o)加工成可执行文件的程序,负责拼接各段、解析符号、确定入口地址。 nasm 出一个 .o,ld 把它变成可执行文件。
目标文件 / 可执行文件 前者是半成品(.o),后者是可以直接被系统加载运行的成品。 伪指令里的段信息先写进 .o,由链接器汇总成最终布局。
段(section) 程序被分块存放,每块承担不同职责,且加载到内存后拥有不同的访问权限。 代码段只读可执行,数据段可读可写。这是伪指令存在的最主要理由。
符号(symbol) 一个名字,代表一个地址或一个常量。链接器靠符号来把不同文件、不同位置连起来。 _start、msg、len 都是符号。
小端序(little-endian) 多字节的数在内存里低位字节放在低地址。x86 用的就是它。 dq 0x123456789ABCDEF0 在内存里从低到高是 F0 DE BC 9A 78 56 34 12。
对齐(alignment) 数据摆放位置要满足某种倍数要求,不满足时汇编器/编译器会插入空白字节(填充)。 为了让 CPU 一次就读到,多字节数据通常对齐到自己的宽度。

机器指令决定程序”做什么”,伪指令决定程序”长什么样”

section 段定义伪指令

使用:section 名字

作用是:告诉汇编器”接下来这段内容属于哪个段“。它本身不产生任何机器码,只是切换当前的工作台

原理上为什么非要分段?因为加载到内存后,每个段会被映射成有不同权限的内存区域。代码只需要读和执行、绝不允许改写(否则程序能改自己,也就意味着攻击者能改它);常量只读;变量要读写。这种”权限分离”是操作系统层面的保护机制,而它的前提就是——链接时程序必须明确告诉系统”哪块是什么”。

段 存什么 初值 加载后的权限
.text 机器指令(代码) 有 r-x(可读、可执行、不可写)
.rodata 只读常量(字符串字面量等) 有 r–(只能读)
.data 有初值的全局变量/常量 有 rw-(可读写、不可执行)
.bss 无初值的变量,只声明大小 无 rw-

78997195362

图里 .bss 用了虚线框,因为它在文件里不占空间——这是它和 .data 最本质的区别

具体怎么用:段声明是”分组开关”,写在哪一行,从这一行起的内容就归属那个段,直到遇到下一个 section。

1
2
3
4
5
6
7
8
9
10
11
12
section .data
var1 db 0x10 ; 属于 .data

section .bss
buf resb 64 ; 属于 .bss

section .text
global _start
_start: ; 属于 .text
mov rax, 60
xor rdi, rdi
syscall

两个实践要点:

第一,.data 和 .bss 的区别在于有没有初值:db 0x10 有初值,初值必须真实写进可执行文件,所以放 .data;而 resb 64 只是”预留 64 字节,内容不重要”,如果也在文件里存 64 个 0,纯属浪费空间,所以放 .bss——加载器在程序启动时直接把这片区域清零,文件里只记录”要 64 字节”这一个数字。

第二,.bss 里只能用 resb/resw/resd/resq 这类”预留”伪指令,不能写 db 1 这种带初值的数据,否则汇编器会报错(逻辑上矛盾:既说不占空间,又要求存初值)。

变量定义伪指令:db / dw / dd / dq

这四个伪指令的作用是在数据段里开辟一块空间并填入初值,区别只在于每份数据占几个字节。

伪指令 全称 宽度 名字来源
db define byte 1 字节 / 8 位 byte
dw define word 2 字节 / 16 位 word(x86 的”字”是 16 位)
dd define doubleword 4 字节 / 32 位 double word
dq define quadword 8 字节 / 64 位 quad word

写法的固定结构是:标签 + 伪指令 + 初值。这里讲清一个容易搞混的点——标签(如 var1)代表的是”地址”,不是”值”。汇编器把 var1 登记为”这块数据的起始地址”,mov rax, var1 得到的是地址,mov rax, [var1] 才是去取内容。这和前面讲寻址方式时说的”有方括号才是访问内存”一致。

具体怎么用,五种常见形态:

1
2
3
4
5
6
7
8
9
10
11
section .data
b1 db 0x10 ; 一个字节
w1 dw 0x1234 ; 两个字节
d1 dd 0x12345678 ; 四个字节
q1 dq 0x123456789ABCDEF0 ; 八个字节

arr db 0x20, 0x30, 0x40 ; 一次定义多个值,形成一个数组
nums dd 1, 2, 3, 4 ; 32 位数的数组,共 16 字节

msg db 'Hello', 0 ; 字符串,末尾的 0 是 C 语言的结束符
pad times 8 db 0 ; 重复 8 次,得到 8 个 0 字节

.bss 里则用”预留”版本,它们不带初值:

1
2
3
4
5
r1 resb 1     ; 预留 1 字节
r2 resw 1 ; 预留 2 字节
r3 resd 1 ; 预留 4 字节
r4 resq 1 ; 预留 8 字节
buf resb 4096 ; 预留 4 KB 缓冲区

关于 dq 0x123456789ABCDEF0 这一行,有个要掌握的:由于 x86 是小端序,这个数在内存里从低地址往高地址排是 F0 DE BC 9A 78 56 34 12——和我们书写时的顺序完全相反。这一点直接决定了两件事:

​ 一. 是以后用 gdb 查看内存时会看到”颠倒”的字节序列,不要以为出错了。

​ 二. 是写 payload 时,一个 8 字节地址必须按小端序排列才能正确覆盖返回地址。

还有一个延续:用多大的指针去读,决定了读到几个字节。var1 db 0x10 只定义了 1 字节,如果写 mov rax, [var1],汇编器会去读 8 个字节,把后面的 w1、d1 的头部字节一起卷进来。正确写法是 movzx rax, byte [var1]。

常量定义伪指令:equ

使用:名字 equ 值

**作用:**给一个值起个别名,这个别名在编译期就被替换掉

它和变量有本质区别:equ不占任何内存、没有地址、不是变量

更像是”文本替换“:汇编器看到len就替换成10,产出的机器码里只有10,没有len这个东西。

具体怎么用,三个典型场景:

1
2
3
4
5
6
7
8
9
10
SYS_WRITE equ 1          ; 给系统调用号起名,代码可读性大幅提升
SYS_EXIT equ 60
BUF_SIZE equ 4096

section .bss
buf resb BUF_SIZE ; 直接用常量当大小

section .data
msg db 'Hello', 0x0A
msg_len equ $ - msg ; 用当前位置减去起始位置,编译期算出字符串长度

第三行那个 $ - msg 是很实用的技巧。$ 是 NASM 里的特殊符号,表示当前这一行的地址;msg 是字符串起始地址。两者相减就是字符串占用的字节数,而这个计算在编译期就完成了,运行时不做任何运算,也不占用额外指令。写 write 系统调用时,长度参数经常就用这招自动算出来,改字符串就不必手动改长度了。

两点补充。第一,equ 定义的值不能在后面修改,因为它压根不是存储单元,不存在”修改”这回事。第二,NASM 里还有两个相关写法:%define 是真正的宏(可以做参数替换,比 equ 更灵活),%assign 定义的是可以重新赋值的数字常量。日常写小型汇编,equ 已经够用。

入口声明:global_start

使用:global 符号位

**作用:**把符号导出成“全局可见”,让链接器在处理其他文件时能看到它

原理要结合链接过程理解:汇编器把每个 .asm 文件翻译成独立的 .o 文件,此时文件里的标签都是”本地符号”,外面看不见。global 相当于在符号表上盖一个”对外公开”的章。链接器在拼接所有 .o 文件时,只能看到被公开的符号——程序入口也必须是公开符号,否则链接器找不到入口在哪儿。

具体怎么用:

1
2
global _start        ; 导出入口符号
extern printf ; 反向操作:声明"我要用别的文件里的 printf"

extern 是 global 的反向操作,用于引用外部符号(比如调用 C 库函数)。

如果不写 global _start,链接时会报 undefined reference to '_start' 或 cannot find entry symbol _start——这两个报错你早期一定会遇到,根因就是”入口符号没公开”。

顺便说明一个容易被误解的点:_start 这个名字不是随意起的。链接器 ld 默认就把 _start 当作程序入口,这个约定和 ELF 文件头里那个”入口地址”字段对应。你写 C 程序时用的是 main,那是因为 C 运行时库会先执行一段准备代码,再由它去调用 main;而在纯汇编里没有这层包装,所以直接以 _start 作为起点。


汇编基础语法
https://asleo814.github.io/2026/09/21/汇编基础语法/
作者
AsLeo
发布于
2026年9月21日
许可协议