本文部分段落借助 AI 辅助整理,主体思路、示例、表述为作者原创。
后续会逐步重写,统一风格,敬请期待。
前言
经过上一章的学习, 大家也对寄存器有了初步的认识
并且可以一眼看出来 rsi 是源索引寄存器(register source index)意思
接下来我们学习一些基础的指令
段声明 | 汇编语言的基本结构
一个汇编程序并不是从头到尾把指令堆在一起,
它和 C 语言一样,有自己的组织方式。
在 NASM(Intel 语法)下,这种结构用 section 来划分。
我们沿用前言的 Hello World 来举例:
; 数据
section .data
msg db "Hello World", 0xA ; 0xA换行符
len equ $ - msg ; 当前地址 - msg 地址 = 字符串长度
section .text
global _start
_start:
; sys_write
mov rax, 1 ; 系统调用号 1 = sys_write
mov rdi, 1 ; 文件描述符 1 = stdout
mov rsi, msg ; 字符串地址
mov rdx, len ; 字符串长度
syscall
; sys_exit
mov rax, 60 ; 系统调用号 60 = sys_exit
xor rdi, rdi ; 退出码 0
syscall
这里的 section .data 和 section .text 就是段声明,它们分别声明了数据段
.data 和代码段 .text。我们在
.data 里定义了需要用到的 msg 和 len,然后在
.text 里通过它们来引用这些数据。除此以外还有
.bss, 这是未初始化数据段, 用来预留空间, 程序启动时会被清零。
退出程序
在C++里, main 函数 return 0; 就表示程序正常结束
Python程序运行完自动退出或者使用 sys.exit()
在汇编里,没有 return,也没有自动退出的机制,
你得自己告诉操作系统:“我跑完了,可以结束了。”
这个“告诉操作系统”的方式,就是系统调用。
在 x86-64 Linux 下,退出程序的系统调用号是 60
mov rax, 60 ; 系统调用号 60 = sys_exit
xor rdi, rdi ; 退出码 0
syscall
这里的 rax 寄存器用来存放系统调用号。
现在有人就会问了:
诶?不是说 rax 处理算术运算和函数的返回值吗?
为什么现在这里就可以是系统调用号?
对,rax 确实处理算术运算和函数的返回值,
但在这里它是系统调用号。
因为 CPU 运行的时候一看:
诶?这里是 syscall,
那得看看 rax 里面的系统调用号是多少。
结果一看
"60 号……退出? 行吧, 今天活少, 昨天可累坏了, 下班!"
这个xor rdi, rdi是归零退出码也就是echo $?显示的东西
在 x86-64 Linux 下,rdi 是系统调用的第一个参数寄存器
对程序来说,这个参数就是退出码
内存数据
定义内存数据是在.data里面
像这样msg db "Hello World"
就是在内存里面定义一个标签是msg内容是"Hello World"的数据
msg是标签, 指向这块数据的地址, 你可以把它当成变量名来理解
db 表示按字节存放,"Hello World"
里的每个字符占 1 字节
"Hello World"一共11个字符
所以这一行一共定义了 11 个字节
常见的类型有:
db (define byte): 1字节
dw (define word): 2字节
dd (define doubleword): 4字节
dq (define quadword): 8字节
举个例子
section .data
a db 1 ; 标签a存1
b dw 1 ; 标签b大小为2个字节
未初始化数据 | .bss
不是所有数据一开始就有值。
比如你想开一块缓冲区,先占个位置,等程序跑起来再往里写,
这时候就轮到 .bss 出场了。
.bss 是未初始化数据段,用来预留空间。
它和 .data 最大的区别是:
.data 里的数据有初始值,会写进可执行文件;
.bss 只记录“要预留多少空间”,不占可执行文件体积。
写法上,用 resb / resw / resd / resq:
resb(reserve byte):预留 1 字节
resw(reserve word):预留 2 字节
resd(reserve doubleword):预留 4 字节
resq(reserve quadword):预留 8 字节
section .bss
buffer resb 64 ; 预留 64 字节
count resd 1 ; 预留 4 字节
注意 .bss 里不能给初始值,
所以 buffer resb 64 是对的,
但 buffer db 0 这样的应该在section .data后面。
代码段 | .text
这里放要用运行的代码
section .text
global _start
_start:
; 你的代码
global _start是 NASM 的伪指令意思是把 _start 这个符号导出为全局符号,让链接器(linker)能看见它。
这里说到的伪指令,顾名思义就是假的指令,这些指令不会变成机器码、不由 CPU 执行,而是由汇编器执行
如果各位学过 C,那肯定会这么想,"那这不就是预处理操作吗?"对,可以这样理解
(但是严格的说,伪指令的范围比 C 预处理更广,像 db 定义数据这类,预处理里就没有直接对应)
这时候有些读者一看就会说:
你看,还要专门写个 _start: 和 global _start,多麻烦啊,
就好比写 Python,本来直接写就能跑的小脚本,非要套一层 main
对,我在写这篇文章的时候也这么想过
但是啊,是可以这样写,程序就不知道从哪开始跑了
这时候汇编是通过的但是链接会报错
warning: cannot find entry symbol _start; defaulting to 0000000000401000它不知道从哪里开始,只能猜一个地址,结果只能靠运气
恰好最近作者原神抽卡运气差到 50 连都没有出过一个金,自然运行不了(笑)
所以还是老老实实写上 _start: 和 global _start 吧。
寻址
寻址其实初学者只要知道[a]是取内存标签 a 里的值就可以了
以及知道绝对寻址和相对寻址的区别
绝对寻址是这样的
section .data
a dq 1 ; 用 dq,占 8 字节,和 rax 匹配
section .text
; 绝对寻址
mov rax, [a]
机器码里写死了 a 的绝对地址比如 0x0000000000402000
现代 64 位程序(PIE/共享库)里不推荐
nasm更推荐使用相对寻址
section .data
a dq 1 ; 用 dq,占 8 字节,和 rax 匹配
section .text
; 相对寻址
mov rax, [rel a]
Mov指令
这是以后最经常用的指令mov
mov rax, 1
他的作用是将逗号后面里面的东西放在前面
例如上面这个,就是指把1放进rax寄存器里面
不过这里不推荐用 rax,因为 a db 1 只占 1 字节
用 64 位的 rax 去读会连后面 7 字节一起读进来
数据多大就用多宽的寄存器,读 1 字节用 al 正好
mov al, 1
除此以外还可以将内存中的数据存入寄存器
像这样
section .data
a db 1 ; 定义一个标签为a, 一字节的数据
section .text
global _start ; 入口
_start:
mov al, [rel a]
或将寄存器的东西搬移内存mov [rel a], al但是内存和内存是不合法的
mov [rel a], [rel b] ; 会报错ADD指令
ADD,顾名思义就是小学学的1+1=2。
指令是 add,用法和 mov 很像:add 目标, 源,
把「源」加到「目标」上,结果存回「目标」。
当然你也可以认为是
add 第一个数字, 第二个数字
接下来两个寄存器相加是这样的:
section .text
global _start
_start:
mov al, 1
mov bl, 1
; 用 rax、rbx 的低 8 位 al、bl 相加
add al, bl ; al = al + bl = 2
和 mov 一样,数据多大就用多宽的寄存器。
加 1 这种小数字,用低 8 位的 al、bl 就够了。
那如果两个数都在内存里呢?
你可能会想直接写 add [rel a], [rel b]。
同样不行,x86 不允许两个操作数都是内存,这样写会报错。
所以得找个寄存器当中转:先读进来,加完再写回去。
写出来就是这样:
section .data
a db 1
b db 1
section .text
global _start
_start:
; 加法
; add [rel a], [rel b] 是非法的(两个操作数不能都是内存)
; 所以第一步是把 a 读进 al 中转
mov al, [rel a]
; 把内存 b 的值加到 al 上,结果存回 al
add al, [rel b]
; 把 al 的值写回内存 a
mov [rel a], al
mov rax, 60
xor rdi, rdi
syscall
顺一遍这三步:
mov al, [rel a]:al = a = 1
add al, [rel b]:al = al + b = 2
mov [rel a], al:把 2 写回 a
这里结果是 2,al 装得下。
但如果结果超过 255,al 就溢出了,那就得换更宽的寄存器。
记住一句话:数据多大,就用多宽的寄存器。
SUB指令
减法和加法几乎一样,指令是 sub 语法: sub 目标, 源,
把「源」从「目标」里减掉,结果存回「目标」。
规则也一样:只有「内存和内存」做减法是非法的,其他都合法。
section .data
a db 1
b db 1
section .text
global _start
_start:
; 和 add 一样,只有内存和内存做减法是非法的
mov al, [rel a]
sub [rel b], al ; b = b - a
mov al, 1
mov bl, 1
sub al, bl ; al = al - bl = 0
mov [rel a], al ; 写回 a
mov rax, 60
xor rdi, rdi
syscall
这里 sub [rel b], al 是合法的:
一个操作数是内存,另一个是寄存器,没问题。
所以减法的规则可以记成:内存和内存不行,其余都行。
INC指令
inc 是自增,把操作数加一。
相当于Cpp的a++
它只有一个操作数,所以不用考虑“两个内存”的问题。
麻烦的地方在于:[a] 只是一个地址,
汇编器不知道你要操作几个字节——1 字节?4 字节?8 字节?
所以对内存操作数,得用「大小限定符」告诉它。
section .data
a db 1
section .text
global _start
_start:
; inc 大小限定符 内存操作数
; 不写 byte,汇编器不知道 a 是几字节
inc byte [rel a] ; a = a + 1
; 寄存器本身宽度明确,不用写限定符
mov al, 1
inc al ; al = al + 1
mov rax, 60
xor rdi, rdi
syscall
大小限定符有 byte、word、dword、qword,
分别对应 1、2、4、8 字节,要和数据的定义(db/dw/dd/dq)对得上。
寄存器因为宽度是固定的,所以不用写。
DEC指令
dec 和 inc 正好相反,是自减,把操作数减一。
规则一模一样:内存操作数要写大小限定符,寄存器不用。
section .data
a db 1
section .text
global _start
_start:
; dec 大小限定符 内存操作数
dec byte [rel a] ; a = a - 1
; 寄存器不用写限定符
mov al, 1
dec al ; al = al - 1
mov rax, 60
xor rdi, rdi
syscall
MUL指令
乘法指令是 mul,它和前面几个不太一样:
只能写一个操作数,另一个乘数是隐式的,固定在 rax 家族里。
具体是哪个宽度,取决于你写的大小限定符:
mul byte [x]:被乘数是 al,结果放 ax
mul word [x]:被乘数是 ax,结果放 dx:ax
mul dword [x]:被乘数是 eax,结果放 edx:eax
mul qword [x]:被乘数是 rax,结果放 rdx:rax
section .text
global _start
_start:
; mul 类型(byte, word, dword) 乘数
; 被乘数隐式地写在 al 里
mov al, 6
mov bl, 2
; 寄存器可以不写类型
mul bl ; ax = al * bl = 12
mov rax, 60
xor rdi, rdi
syscall
几个要注意的点:
1. mul 不能操作立即数,mul byte 1 这种写法是错的。
2. 操作数是内存时要写大小限定符,寄存器不用。
3. mul 是无符号乘法,imul 是有符号乘法,别混。
4. 结果会占用两倍宽度的寄存器对(比如 byte 乘法结果在 ax,word 乘法在 dx:ax),这是为了防止溢出。
DIV指令
div 是除法,和 mul 一样只能写一个操作数。
不过它用起来更绕一点:被除数和结果用的都是固定的寄存器对,
取决于操作数的宽度:
div byte [x]:被除数是 ax,商放 al,余数放 ah
div word [x]:被除数是 dx:ax,商放 ax,余数放 dx
div dword [x]:被除数是 edx:eax,商放 eax,余数放 edx
div qword [x]:被除数是 rdx:rax,商放 rax,余数放 rdx
先看 byte 除法。这里有个最容易踩的坑:
div byte [x] 的被除数是 ax,是 16 位的 ax,不是 8 位的 al。
就算你的被除数很小,也得放进 ax。
如果只往 al 里塞,ah 里可能是脏数据,结果就错了。
section .data
b db 9
section .text
global _start
_start:
; 和 mul 差不多,只能写单操作数
; 但是参数前面得写类型
mov ax, 6 ; byte 除法的被除数是 ax,不能只放 al
div byte [rel b] ; ax / b,商放 al,余数放 ah
; 6 / 9 = 0 余 6,所以 al = 0,ah = 6
; 有符号除法用 idiv
mov ax, 9
mov bl, 3
idiv bl ; 9 / 3 = 3,商放 al,余数放 ah
mov rax, 60
xor rdi, rdi
syscall
注意这几点:
1. div 的操作数不能是立即数,div byte 1 是错的。
2. 除之前一定要把被除数放对位置:byte 除法要先放进 ax,不能只放 al。
3. 如果结果太大装不下(比如 byte 除法商超过 255),会触发「除法溢出」异常,程序直接崩。
4. div 是无符号除法,idiv 是有符号除法。
5. 有符号数用补码表示,idiv 出来的商和余数也遵循有符号规则。这里先用正数演示,等后面讲补码再回来看负数。
LEA指令
前面讲寻址的时候,我们一直在说:
[a] 是取 a 地址里的值,也就是加方括号就是「取值」。
那问题来了——如果我只想要这个地址,不想取值呢?
比如以后要调用函数,得把字符串的地址告诉它,
而不是把字符串的内容一个字节一个字节传过去。
这时候就需要把「地址」本身取出来。
干这件事的指令,就是 lea。
lea 的全称是 Load Effective Address,
翻译过来就是「加载有效地址」,其实就是把地址取出来。
它不访问内存,只是把算出来的地址放进寄存器。
举个例子:
section .data
a dq 1 ; 用 dq,占 8 字节,和 rax 匹配
section .text
global _start
_start:
; mov + [] :去 a 的地址里取值,放进 rax
mov rax, [rel a] ; rax = 1(a 里存的值)
; lea :把 a 的地址本身放进 rax
lea rax, [rel a] ; rax = a 的地址,比如 0x0000000000402000
mov rax, 60
xor rdi, rdi
syscall
这两行看着很像,其实差别就在用的是 mov 还是 lea:
mov rax, [rel a]:去 a 的地址,把里面的值读出来(1)
lea rax, [rel a]:把 a 的地址本身放进 rax(0x0000000000402000)
这里就有人会问了:
诶?lea 后面也带方括号,怎么就不取值了?
不是说方括号就是取值吗?
对,方括号本身确实是「算一个地址」的意思。
差别在于 mov 和 lea 接下来怎么做:
mov:算完地址,再去这个地址里读,把读到的值放进寄存器
lea:算完地址,不读了,直接把这个地址放进寄存器
所以可以记成:
mov 会「进去拿东西」,lea 只「记下门牌号」。
最后总结一下:
mov rax, [rel a]:取 a 里的值
lea rax, [rel a]:取 a 的地址
mov 会读内存,lea 不会
记住一句:lea 只记地址,不取内容。
PUSH / POP 指令
前面讲 mov 的时候,我们说数据可以在寄存器和内存之间搬。
但有时候你只是想临时存一下某个寄存器的值,
等会儿还要用,又不想专门在 .data 里开个变量。
这时候就轮到 push 和 pop 出场了。
push 是压栈,把值存到栈上;
pop 是出栈,把栈上的值取回来。
先说栈是什么。
你可以把栈想象成一摞盘子:
你只能从最上面放盘子,也只能从最上面拿盘子。
后放进去的,先被拿出来,这就是「后进先出」。
在 x86-64 里,栈顶的地址存在 rsp 寄存器里。
而且栈是往低地址方向增长的:
每 push 一次,rsp 就减小一点;
每 pop 一次,rsp 就增大回来。
这个方向记一下,后面讲函数调用会经常用到。
看个最简单的例子:
section .text
global _start
_start:
mov rax, 0xFFFF
; 压栈:把 rax 的值存到栈上,rsp 减小
push rax
; 出栈:把栈顶的值取出来,放进 rbx,rsp 增大
pop rbx
; 返回结果(把 rbx 当退出码)
mov rdi, rbx
mov rax, 60
syscall
call fun 其实做了两件事:
1. 把 call 下一条指令的地址压栈
2. 跳到 fun
而 ret 做的正好相反:
1. 从栈顶弹出一个地址
2. 跳过去
所以也可以理解成:
call = push 返回地址 + jmp
ret = pop 返回地址 + jmp
栈的第一次正经用途,就是干这个。
要注意的是:
1. push 和 pop 都是单操作数,
只能写一个东西,不能像 mov 那样写两个。
而且 push 后面不能接立即数太大(超过 32 位会报错),
但寄存器、内存、小立即数都可以。
2. push 和 pop 的宽度要匹配。
在 64 位模式下,push rax 压的是 8 字节,
pop rbx 取的也是 8 字节。
如果你 push rax 之后 pop ebx,只取低 4 字节,
rsp 就恢复不到原来的位置,后面会出问题。
最后总结一下:
push 值:把值压到栈上,rsp 减小
pop 寄存器:把栈顶的值取出来,放进寄存器,rsp 增大
栈是后进先出,rsp 指向栈顶,栈往低地址增长
记住一句:push 进去的,要用同样宽度的 pop 取回来。
CALL / RET 指令
到目前我们写的程序,代码都是从 _start 一路往下跑,
跑完就退出,中间没有「跑一段再回来」的过程。
但真实的程序肯定不是这样:
你会有很多重复的代码,希望写一次、到处调用;
也可能想把一段逻辑单独拎出来,方便复用。
这时候就需要 call 和 ret。
call 是调用一段代码,ret 是从这段代码返回。
它俩几乎就是为「函数」而生的。
先看最简单的例子:
section .text
global _start
_start:
call fun ; 跳到 fun,并把下一条指令的地址记下来
mov rax, 60 ; fun 返回后会从这里继续
syscall
fun:
mov rdi, 1
; 结束回到 call 下一条
ret
注意:
1. call 和 ret 是成对的。
进去一次,就要回来一次。
如果 call 了没 ret,程序会一直往下跑,
最后不是跑到别的数据上,就是直接崩。
2. ret 不检查栈上弹出来的地址对不对。
如果中间你 push 了别的东西没 pop 掉,
ret 就会弹出一个错误地址,直接飞到不知道哪里去。
这个后面讲栈帧的时候会重点说。
关于代码?
部分代码已开源到
alittleshark-dev/Asm-Learn-Note
注意:有些代码只是为了演示,不能直接运行。
仓库持续更新中...