x86 汇编语言详解-2

9952 字
50 分钟
x86 汇编语言详解-2

7. x86 汇编语言元素#

现在我们带你讲解一下一个汇编语言程序是怎么写的。x86 汇编语言由语句 (statement) 构成,一条语句可能有以下元素:

7.1 常量整数和常量整数表达式#

一个常量整数表示为 [{ + | - }] 数字 { 基数字符 },例如 -1011010011b0A3h

标识说明:

  • [] 的内容是可选的
  • { A | B } 说明必须在 AB 中选择一项

基数字符说明了此整数使用的数制,如下表所示:

基数字符数制
空置/d十进制 (Dec)
q/o八进制 (Oct)
h十六进制 (Hex)
b二进制 (Bin)

如果一个十六进制值以字母开头,如 A4,则必须加上前缀 0 以免和变量名混淆。

整数字面量间的运算使用以下运算符:

运算符优先级备注
()1
+, -2正负号
*, /3
mod4
+, -5加减号

每个表达式的计算结果必须为可存储为32位的整型(0 ~ FFFFFFFF),此结果在编译时确定,运行时不可变。

EQU 指令可以设置符号整数常量,相当于 C 中的 #define,格式为 标识符 EQU 常量整数表达式EQU 定义的常量在汇编时直接替换为字面量,因此运行时固定不可变。更宽松的版本是 = 指令,允许常量重定义,但依然是汇编时确定,运行时不可更改。

7.2 其他常量#

常量实数表示为 [{ + | - }]数字.[数字][指数],其中[指数]为科学记数法中的 ×10n×10^{n} 部分,表示为 E[{ + | - }]integer2.-44.2E+0526.E5 都是合法的十进制实数。常量实数按照 IEEE 浮点数格式转换存储。

编码实数 (encoded real) 以十六进制表示,用 r 结尾。对应的十六进制从 IEEE 浮点数格式的二进制数转换得到,如 +1.0 的 IEEE 浮点数格式为 0011 1111 1000 0000 0000 0000 0000 0000,编码实数表示为 3F800000r

字符和字符串常量以 ''/"" 包围,按照 ASCII 码存储。

7.3 保留字#

保留字主要有以下类型:

  • 指令助记符,如 movaddmul
  • 寄存器名称
  • 汇编器指令
  • 类型属性,如 byteword
  • 运算符
  • 预定义的符号

保留字大小写不敏感,如 MOVmov 是一样的,但预定义符号除外。

7.4 标识符#

标识符用于标识变量、常量、函数、标签,有以下规则:

  • 长度在 1 ~ 247 之间
  • 大小写不敏感(通过汇编器的 -Cp 选项可使之大小写敏感)
  • 首字符必须是字母、下划线、@?$,后续的字符才可以取数字。 作为建议,请尽量不要以下划线或者 @ 开头,减少违反下一条规则的概率。
  • 不能和保留字撞名

7.5 指令#

指令在汇编时转换成运行时可执行的语句(机器语言字节)。一条指令包含四个部分:[标签:] [助记符] [操作数] [;注释]

  • 标签 标签用于标识数据/代码的位置。

  • 助记符 助记符标识指令功能。

  • 操作数 操作数用于指令数据的输入输出。操作数可以是寄存器 (reg)、内存地址 (mem) 或者整数表达式 (imm)。一条指令可以有 0 到 3 个操作数。 对于有两个操作数的指令,第一个操作数作为目标操作数 (dest),第二个操作数作为源操作数 (src)。指令一般修改的是目标操作数。 对于有三个操作数的指令,后两个操作数作为源操作数执行指令,结果存储到目标操作数。 常见助记符及其操作数:

    助记符操作数说明
    MOVdest, srcsrc 的值赋值到 dest
    ADDdest, srcdest = dest + src
    INCopop 递增 1
    DECopop 递减 1
    SUBdest, srcdest = dest - src
    MULop无符号乘法,结果储存在主寄存器中,即 EAX = EAX * op
    IMULdest, src1, src2有符号乘法,dest = src1 * src2
    JMPlabel无条件跳转至标签位置
    CALLfunc调用函数
    NOP无操作指令,占用 1 字节,常被汇编器用于字节对齐
    Note

    无论何时,我们都建议优先以寄存器作为操作数,然后是常量值,最后才是内存地址标识,因为它们的读取效率是依次递减的。

  • 注释 单行注释以 ; 开头 多行注释使用 COMMENT 指令,语法为 COMMENT 结束符。从 COMMENT 到第一个结束符之间的代码块将被忽略。

7.6 段式存储#

正如 4.2 所述,x86 处理器底层依然属于段式存储模型。在大部分操作系统上,采用的模型和各位在 C/C++ 学的内存布局是一样的。

现在这个阶段,需要我们设置的内存段只有 .data(数据段)和 .code(代码段)。

7.7 示例 1#

下面是一个进行加减运算的 MASM 汇编程序,操作数是写死在程序中的。

INCLUDE Irvine32.inc
.code
main PROC
a:
mov eax,30000h ; EAX = 10000h
add eax,80000h ; EAX = 50000h
sub eax,20000h ; EAX = 30000h
; jmp a ; jump to label 'a'
call DumpRegs
exit
main ENDP
END main

我们详细分析一下这个程序。第一部分是汇编器指令:

  • INCLUDE Irvine32.inc 调用了教学用库 Irvine32.inc
Warning

我们要求:在本课程中,必须 INCLUDE Irvine32.inc,因为它预设置了有用的函数和汇编器指令。

接下来是代码区,我们在这里写函数代码:

  • .code 表示下面的所有代码均属于代码段的内容
  • main PROC 定义 main 过程(Procedure,即函数)
  • a: 定义了一个标签 a,指向 mov eax, 30000h
  • mov eax, 30000h 将十六进制值 30000h 赋值给 eax
  • add eax, 80000h80000heax 的值相加,结果保存到 eax
  • call DumpRegs 调用 Irvine32.incDumpRegs 函数,作用是打印当前的寄存器数值
  • jmp a 表示无条件跳转到 a 指向的代码。由于有个 bug 故注释,注意到了么?
  • exit 是系统调用 (system call),表示程序的退出。注意不要对主函数使用 ret,因为 ret 需要显式调用方
  • main ENDP 表示结束此过程的定义
  • END main 表示整个源文件结束,并且指定了入口点函数 main

7.8 数据类型与数据操作#

MASM 内建以下数据类型:BYTE, SBYTE(有符号字节), WORD, SWORD, DWORD, SDWORD, FWORD(六字节), QWORD, TBYTE(十字节)。主要使用 BYTE, WORD, DWORD, QWORD 类型,可分别简写为 db, dw, dd, dq

使用 TYPEDEF 可以定义新的类型,包括指针,语法为 类型名 TYPEDEF [PTR] 已注册的类型。已注册的类型包括内建类型和已使用 TYPEDEF 注册过的类型,例如:

CHAR TYPEDEF BYTE
PCHAR TYPEDEF PTR CHAR

使用数据类型在 .data 段定义数据的语法为 [标识符] 类型 [初始化值, ...]

count dword 114514
homo word ?

数据定义时必须有至少一个初始化值,如果不想自己设定,可以使用 ? 表示随机赋值。所有的初始化值,无论其格式,都会被汇编器转换为二进制数据。如果初始化值是有规律重复的,可以使用 DUP (Duplicate) 命令:

BYTE 20 DUP(0) ; 20 字节,均初始化为 0
BYTE 20 DUP(?) ; 20 字节,均随机初始化
BYTE 4 DUP("STACK") ; 20 字节,一个字符串:"STACKSTACKSTACKSTACK"

可以有多个初始化值,这实际上就声明了一个数组:

list byte 10,20,30,40
byte 50,60,70,80

数据 10 位于 list 的偏移 0 位置,以此类推。

字符串也是一种数组,但作为例外,每个字节间不都需要逗号分隔。正如各位在 C 中所学的那样,所有的字符串都需要加一个 \0 终止符(当然,是手动的,没有编译器兜底了)。对于多行字符串,只需要一整块字符串的末尾终止即可,如果需要换行,需要使用 0dh, 0ah (CR/LF):

greeting1 BYTE "Welcome to the Encryption Demo program "
BYTE "created by Kip Irvine.",0dh,0ah
BYTE "If you wish to modify this program, please "
BYTE "send me a copy.",0dh,0ah,0

计算数组长度可以使用 $ - Array,例如:

list BYTE 10,20,30,40
ListSize = ($ - list) ; ListSize = 4

如果是字符串长度,需要减去使用的转义字符的长度:

hello BYTE "Hello",0
StringSize = ($ - hello) - 1 ; StringSize = 5

注意采用这种方式计算的,表达式必须紧跟在数组之后,因为 ($ - list) 的语义是当前地址 ($) 减去 list 的地址。数据在段内是连续的,因此可以用偏移值计算元素个数。如果中间再插入了其他数据,偏移值就计算错误了。

BYTE 只占 1 字节,因此偏移值 = 元素数。如果是 WORD 等更大的数据类型,就需要除以它的单位长度,例如:

data WORD 1,1,4,5,1,4
dataSize = ($ - data) / 2 ; $ - data = 12, dataSize = 6

可以在代码段中间夹杂数据段。数据的作用域从声明位置开始:

.code
mov eax,ebx
.data
temp DWORD ?
.code
mov temp,eax

在进行数据的转移时,数据的宽度必须匹配目标寄存器的宽度。由于寄存器的宽度可以“分割”,会出现数值覆盖 (Overlapping Values) 的情况:

.data
oneByte BYTE 78h
oneWord WORD 1234h
oneDword DWORD 12345678h
.code
mov eax,0 ; EAX = 00000000h
mov al,oneByte ; EAX = 00000078h
mov ax,oneWord ; EAX = 00001234h
mov eax,oneDword ; EAX = 12345678h
mov ax,0 ; EAX = 12340000h

数值仅覆盖对应位数,而其他位不变。对于有符号数,这种情况更加复杂:

.data
signedVal SWORD -16 ; FFF0h (-16)
.code
mov ecx,0
mov cx,signedVal ; ECX = 0000FFF0h (+65,520)

明明存储的是负数,结果读 ECX 的时候却读出了正数!除了尽量保证使用 CX,我们也可以先将 ECX 赋值为 FFFFFFFFh,这样数值就是正确的 FFFFFFF0h 了,这被称为符号位扩展 (Sign Extension)。为了简便较小宽度的数据向较大宽度的寄存器的转移, x86 设计了两个指令:

  • MOVZX (Move with Zero eXtension)

适用于无符号数,将扩展的位数设置为 0。

  • MOVSX (Move with Sign eXtension)

适用于有符号数,将扩展的位数设置为 1。

.data
byteVal BYTE 10001111b
.code
movzx ax,byteVal ; AX = 0000000010001111b
movsx bx,byteVal ; BX = 1111111110001111b
Warning

以上两个指令不得使用常量作为操作数。它们仅接受以下操作数组合:

reg32, reg/mem8
reg32, reg/mem16
reg16, reg/mem8

不要滥用符号位扩展,请优先确认操作数的宽度。例如:三个数据 10h20h30h 定义为 BYTE 类型,其中 30h 和前两个的用途不同。

这是中间一个步骤的内存视图:

Terminal window
10 00 00 00 30 00

如果将 20h 位扩展到 32 位(和 EAX 对齐)存进内存,那么:

Terminal window
10 20 00 00 00 00

之前存的 30h 被扩展的 0 覆盖了!

你如果还记得 C 语言的话,估计会把交换数据写成三个存储位置的中转交换,但 x86 提供了 XCHG (eXchange data),可以直接交换数据:

XCHG reg,reg
XCHG reg,mem
XCHG mem,reg

对于两个内存数据的交换,需要使用寄存器中转:

mov ax,val1
xchg ax,val2
mov val1,ax

7.9 操作数和寻址方式#

正如我们在 7.5 所述,操作数可以是寄存器、内存地址或者整数表达式。我们将这些操作数分类为:立即数、寄存器和内存,由此得到几种寻址模式:

寻址方式典型语句关键含义C 场景记忆点
寄存器寻址mov ecx, eax寄存器中的数据传送中间结果[]
立即数寻址add eax, 5常量写在指令中常量赋值数直接给出
直接寻址mov ecx, count变量名给出地址普通变量名字就能定位
间接寻址mov edx, [ebx]按寄存器地址取值指针 *p寄存器里装地址
相对寻址mov esi, [ebx+4]基址 + 常量偏移arr[1]偏移按字节
相对寻址mov eax, count[esi]符号地址 + 寄存器偏移count[i]基址 + 位移
变址寻址mov eax, [ebx+edx+80h]基址 + 变址 + 位移buf[32+i]多一个动态偏移
比例变址mov eax, [ebx+esi*4-80h]基址 + 下标×大小 + 位移arr[i-32]自动乘元素大小
  • 立即数寻址

立即数就是内存中的字面量,因此立即数可以直接写在指令中:

mov eax, 10h
; View in Hex:
; B8 10000000
  • 直接内存寻址

地址需要解引用以访问对应数据。不过相比直接写地址值,我们一般都使用变量名,因为变量名携带了相对与段基地址的偏移信息。

mov eax, var1
; Suppose var1 were located at offset 10400h
; View in Hex:
; A1 00040100
操作码

发现了吗?同样是 mov,两者在 Hex dump 中的表示却不一样!我们将 B8A1 称为操作码 (Opcode),可以参见 MOV — Move Data。 不同的 Opcode 对应不同的操作数类型,但在汇编中我们统一写成 mov。这就是上层虚拟机对底层虚拟机功能的封装。

B8 表示将 imm 复制到 reg,而 A1 表示将位于 seg:offset 的值复制到 EAX。这样你就明白为什么 mov eax, var1 是将 var1 指向的值复制到 eax,而不是将 var1 的地址赋值给 eax 了吧,因为此时操作码 A1 表示对随后的地址值解引用。

Warning

正如在 4.2 中所说的,x86 的大多数指令不支持两个内存操作数。每条 mov 指令的操作码中,用于指定操作数的位域有限,无法同时编码两个完整的内存地址;并且早期 CPU 的寄存器才是真正进行移动操作的场所,内存只是数据的“仓库”,需要使用寄存器作为中转:

mov eax, var1
mov var2, eax

现代 CPU 的存储转发机制可能会直接将 var1 的数据从 L1 数据缓存转发到 var2 的存储操作中,甚至不需要寄存器物理上“持有”数据。但指令集层面仍然要求程序员(或编译器)显式写出这个寄存器。

  • 直接寄存器寻址

7.10 示例 2#

INCLUDE Irvine32.inc
.data
greeting byte "Hello, World!", 0
.code
main PROC
mov edx, OFFSET greeting
call WriteString
exit
main ENDP
END main

此处我调用了 Irvine32.incWriteString 函数。它以 edx 作为参数,接受字符串地址,将含终止符的字符串打印到标准输出。

7.11 基本运算#

基本运算指令分为算术运算和逻辑运算。所有运算都会同步修改标志寄存器。

7.11.1 加减运算#

INCDEC 指令的作用是对寄存器/内存的值递增 (Increase) 和递减 (Decrease) 1,操作数就一个,运算结果保存到操作数上。

ADDSUB 指令分别为加法 (Add) 和减法 (Substraction),操作数是 dest,src,运算结果保存到 dest,不修改 src

NEG 是取反 (Negate) 指令,就是将寄存器/内存的值的符号位取反。

7.11.2 乘除运算#

MUL 是无符号数乘法 (unsigned Multiply),只有一个操作数。AX 寄存器作为被乘数,操作数作为乘数。结果写回低位寄存器,若位数不足则溢出至高位寄存器 (AH / DX)。两个操作数的位数和是结果的位数,如两个 8 位宽度的操作数相加,结果为 16 位。

CF 标志用于检查高位进位。

mov al,5h
mov bl,10h
mul bl ; AX = 0050h, CF = 0

IMUL 是有符号乘法 (signed multiply),可选 1、2、3 个操作数。

一个操作数时,行为类似 MUL;两个操作数时,第一个操作数作为被乘数和目标,第二个操作数作为乘数;三个操作数时,第一个操作数作为目标,第二个操作数作为被乘数,第三个操作数作为乘数。

IMUL 需要注意 CFOF 标志。如果结果的高位不能解释为低位的符号扩展,那么 OF = 1

Tip

OF 标志主要看乘法结果是否在低位的有符号数表示范围内。

DIV 是无符号数除法 (unsigned Divide)。除法比较特殊——带余数的整除法。

DIV 只有一个操作数。DXAX 作为被除数,操作数为除数。结果中,商写回低位寄存器,余数写回高位寄存器 (AH / DX)。

7.11.3 位运算#

8. 分支与循环程序设计#

默认情况下,CPU 按顺序加载和执行程序。但条件指令会根据 CPU 状态标志的值(ZeroSignCarry 等)将控制权转移到程序中的新位置。控制转移分为两种:无条件转移和条件转移。

循环在这里视为分支,因为它也可以使用条件转移指令实现。

8.1 无条件转移#

使用 JMP 指令:

JMP label

CPU 将自动将 label 转换成偏移值,赋值给 IP 以在新位置执行代码。

JMP 是创建循环的最简单方法,结合标签可以形成一个无限循环:

top:
; ...
jmp top

8.2 条件转移#

只有判断标志寄存器的值满足条件时,条件指令才跳转。由于标志寄存器不能直接设定,因此需要搭配 cmp (compare) 或 test 指令使用。

cmp 的底层逻辑是 A - B,用于判断两个数的大小。test 的底层逻辑是 A & B,用于检查特定位数的状态(test A,A 可以判断 A 是否为 0)。两个指令均只修改 FLAGS 寄存器而不破坏元数据。

常见条件转移指令:

指令英文全称含义判断标志位
JE, JZjump equal,jump zero结果为零则跳转(相等时跳转)ZF=1
JNE, JNZjump not equal,jump not zero结果不为零则跳转(不相等时跳转)ZF=0
JSjump sign结果为负则跳转SF=1
JNSjump not sign结果为非负则跳转SF=0
JP, JPEjump parity,jump parity even结果中1的个数为偶数则跳转PF=1
JNP, JPOjump not parity,jump parity odd结果中1的个数为偶数则跳转PF=0
JOjump overflow结果溢出了则跳转OF=1
JNOjump not overflow结果没有溢出则跳转OF=0
JB, JNAEjump below,jump not above equal小于则跳转 (无符号数)CF=1
JNB, JAEjump not below,jump above equal大于等于则跳转 (无符号数)CF=0
JBE, JNAjump below equal,jump not above小于等于则跳转 (无符号数)CF=1 or ZF=1
JNBE, JAjump not below equal,jump above大于则跳转(无符号数)CF=0 and ZF=0
JL, JNGEjump less,jump not greater equal小于则跳转 (有符号数)SF≠ OF
JNL, JGEjump not less,jump greater equal大于等于则跳转 (有符号数)SF=OF
JLE, JNGjump less equal,jump not greater小于等于则跳转 (有符号数)ZF=1 or SF≠ OF
JNLE, JGjump not less equal,jump greater大于则跳转(有符号数)ZF=0 and SF=OF

其实记忆这些指令并不难,因为它们都是 JMP + 条件缩写的形式。

条件转移可以形成一个有限循环:

; ...
mov ecx, a_len
copy:
mov eax, a[esi]
mov b[esi], eax
add esi, 4
dec ecx
cmp ecx, 0
jne copy
; ...

有一个专门的指令 LOOP 同样实现了有限循环,以 ECX 作为循环次数,当其数值减少到 0 时,LOOP 指令就会停止,继续执行下面的指令。

; ...
mov ecx, a_len
copy:
mov eax, a[esi]
mox b[esi], eax
add esi, 4
loop copy
; ...
Warning

在进入 LOOP 循环体前,务必显式初始化 ECX!在循环体内,我们也不建议再修改 ECX。如果要修改,请先将 ECX 代表的循环次数保存,然后在退出一次循环前将次数恢复到 ECX。 对于嵌套循环,你同样需要先保存外层循环次数,再设置内层循环次数;内层循环结束后,及时将外层循环次数恢复到 ECX

.data
count dword ?
.code
mov ecx,100 ; set outer loop count
L1:
mov count,ecx ; save outer loop count
mov ecx,20 ; set inner loop count
L2:
; ...
loop L2
mov ecx,count ; restore outer loop count
loop L1

9. 函数#

通俗地说,我们可以将函数(Procedure,本义为过程,有人也称为子程序)定义为一个返回语句结束的语句块。函数是使用 PROCENDP 指令声明的。它必须被赋予一个合法的标识符。

对于非入口点函数(即 main),使用 RET 作为返回语句:

sample PROC
; ...
ret
sample ENDP

函数内标签的作用域仅限于此函数内,这主要影响跳转与循环指令。虽然可以通过标签名后标识 :: 来声明一个全局标签,但这样做容易破坏程序的运行时栈 (Runtime Stack)。关于运行时栈我们会更详细地介绍。

这是一个简单的加法函数,约定参数和返回值都通过寄存器传递,比较简单:

SumOf PROC
add eax,ebx
add eax,ecx
ret
SumOf ENDP

现在你可以使用 CALL 指令调用函数了!Irvine32.inc 实现了一些简单的输入输出函数。你可以直接打开此文件查看此库声明了哪些函数。

9.1 运行时栈#

运行时栈用于存储函数执行期间的必要的临时信息。和数据结构中的“栈”一样,运行时栈同样遵循 LIFO 规则。运行时栈通过 ESP 寄存器管理地址。一般来讲,我们不会直接修改 ESP,而是通过 CALLRETPUSHPOP 指令间接修改。

ESP 永远指向最后一个压入栈的值,即栈顶。这里需要注意一下,在操作系统中,栈是“向下生长”的,即栈的地址是从高到低的。每当我们 PUSH 一个值进入栈,ESP 的值会递减,然后将操作数复制到栈里。一个 16 bits 操作数会使 ESP 递减 2,32 bits 则递减 4。PUSH 的操作数可以是寄存器、内存地址或立即数。

POP 会将 ESP 指向的栈内容复制到操作数中,然后递增 ESPPOP 的操作数可以是寄存器或内存地址。

9.2 函数调用#

CALL 指令可以让 CPU 跳转到函数内存位置以执行指令。与之配套的是函数内的 RET 语句。

具体来讲,CALL 将返回地址压入栈中,然后将目标函数地址赋值给 EIP。返回地址指向 CALL 的下一条语句。当函数 RET 时,栈中存放的返回地址赋值给 EIP,之后弹出。

因此,对于一个正常的程序,其调用函数前后的堆栈是平衡的,即在 RETESP 的值应该和 CALL 时返回地址压入后的 ESP 值相等,这样 RET 才能将正确的返回地址写入 EIP

对于函数的嵌套调用同样如此。每 CALL 一个子函数,对应的返回地址就会压入栈中;系统在同一时间内只会执行位于运行时栈栈顶的那个函数;然后 RET 时不断“溯源”,直到返回主函数。

9.3 保存和恢复寄存器#

现在我们准备写一个更复杂的函数:

;----------------------------------------------------
; ArraySum
;
; Calculates the sum of an array of 32-bit integers.
; Receives: ESI = the array offset
; ECX = number of elements in the array
; Returns: EAX = sum of the array elements
;----------------------------------------------------
ArraySum PROC
push esi ; save ESI, ECX
push ecx
mov eax,0 ; set the sum to zero
L1:
add eax,[esi] ; add each integer to sum
add esi,TYPE DWORD ; point to next integer
loop L1
pop ecx ; restore ECX, ESI
pop esi
ret
ArraySum ENDP

此函数通过寄存器传递参数和返回值,不使用特定标识符以提高兼容性。

不过我们不是想聊这个。看这个程序的开头,它将 ESIECX 的值压入栈中,而返回前又将其 POP 出来。这是大多数修改寄存器的函数的典型做法:始终保存并恢复被函数修改的寄存器,以确保调用者自身的寄存器值不会被覆盖。该规则的一个例外是用作返回值的寄存器,一般是 EAX

Warning

注意遵循堆栈的“后进先出”原则!

你可以通过 USES 指令让汇编器自动生成这类代码:

ArraySum PROC USES esi ecx
mov eax,0 ; set the sum to zero
L1:
add eax,[esi] ; add each integer to sum
add esi,TYPE DWORD ; point to next integer
loop L1
ret
ArraySum ENDP

USES 紧跟在 PROC 之后,寄存器列表中间无逗号。

Warning

USES 生成的代码会先于用户代码执行。例如:

ArraySum PROC USES esi ecx
push ebp
mov ebp,esp
; ...

生成的是:

push esi
push ecx
push ebp
mov ebp,esp
; ...

在后面章节有需要使用 ebp 间接寻址堆栈内容的地方。有时只看到显式 push 的寄存器,结果把 USES 忘了,脑子里想的堆栈和实际堆栈不一样

或者使用 pushadpopad (ad 代表 All Double)将所有的通用寄存器都 push/pop。

9.4 用栈传递参数#

本节开头我们展示了如何用寄存器传递参数。然而,大多数现代语言中函数参数是通过堆栈传递的:在 32 位下,Windows API 的参数也通过栈传递。因此,必须学会如何用栈传递参数。

为什么不继续使用寄存器?

和通过栈传递参数相比,寄存器传参更快(对于较早期 CPU)。然而,一旦程序规模开始膨胀,寄存器传参出现 bug 的概率就越高;寄存器数量有限,多传几个参数就不够用了

实际上,正由于寄存器及其有限,fastcall 只允许前两个参数使用寄存器(ecxedx),之后的参数使用栈传参。对于可变数量参数,全部的参数都使用栈传递。

因此,在 x86 下,我们非常建议各位使用栈传参。如果使用寄存器传参,最好确保参数个数不超过 2 个,否则管理寄存器资源会变成噩梦 :(

9.4.1 栈帧#

当函数被调用时,运行时栈会预留一块区域,称为栈帧(Stack Frame,或活动记录 Activation Record)。栈帧存放着函数的实参、返回地址、局部变量和保存的寄存器。一个栈帧由以下步骤建立:

  1. 传递的实参被压入堆栈。
  2. 调用函数,使函数返回地址被推送到栈上。
  3. 当函数开始执行时,EBP 会被推送到堆栈上。
  4. EBP 设置成和 ESP 相等。从此,EBP 作为函数形参的基址参考。
  5. 如果存在局部变量,需要递减 ESP 以预留堆栈上的变量空间。
  6. 需要保护的寄存器被推送到堆栈中。
Tip

即使使用 PUSHAD,我们依然建议在此之前先单独 push 和设置 EBP

使用 ESP 实现栈上数据的存储和逻辑删除。EBP 在当前栈帧内位置固定,因此函数中对大部分数据的访问都基于 EBP 进行。

汇编的传参形式有两种:值传参和引用传参。

压栈顺序

需要注意的是,两种传参的参数压栈顺序都是倒序的。参考等效 C 语言格式:int some_func(int var1, int var2),则 var2 先压入,var1 后压入,从右往左。

数组必定使用引用传参,因为值传参代表它需要将每个元素都压入栈。使用 OFFSET 指令。

9.4.2 栈参数的调用和清理#

现在我们来一个示例:

AddTwo PROC
push ebp
mov ebp,esp
mov eax,[ebp + 12]
add eba,[ebp + 8]
pop ebp
ret
AddTwo ENDP

使用栈传参时,需要将 EBP 压入栈,ESP 由系统自动管理,不需要我们操心。

如果我们调用了 AddTwo(5,6),即:

push 6
push 5
call AddTwo

那么它的栈结构大概长这样:

AddTwo(5,6)的栈结构
AddTwo(5,6)的栈结构

如果我们想访问参数,那么用相对寻址。像 [ebp + 12] 这样用表达式引用实参的,我们称为显式栈参数 (Explicit Stack Parameter)。

有一个问题不知道你发现了没有:既然返回地址在实参下面,而当返回地址被弹出时,函数也退出了,那么剩下的实参怎么处理?

很遗憾,操作系统并不会在程序运行过程中自动回收它们。忽略它们?如果只用一层调用还好,但如果是嵌套调用呢?内层函数残留的栈实参在外层函数返回地址的下面,而 ESP 会读取实参作为地址!还记得我们在 C/C++ 所说的吗?标准结局就是内存访问冲突。

清理的理由有了,但怎么清理?POP 可不能把立即数 POP 出去啊。那么,既然栈实参不能物理移除,可不可以直接跳过它呢……

9.4.3 调用约定#

调用约定 (Calling Convention) 确定了函数传参的传参形式、压栈顺序、返回值存储和清除栈方式。在 x86 Windows 下有两个主要的调用约定:C 调用约定 (CDECL) 和 STDCALL 调用约定。

C 调用约定和 STDCALL 的压栈顺序一致,即从右到左倒序压栈。而清除栈方式不一样:

  • C 调用约定是调用者清除 (Caller Cleanup),在调用函数 CALL 的下一行对 ESP 进行加法,其值等于被调用函数参数的总大小。
  • STDCALL 是被调用者清除 (Callee Cleanup),给予被调用函数 RET 一个操作数,其值等于被调用函数参数的总大小。此时系统会自动向 ESP 增加值。

STDCALL 可以确保调用者不会忘记清理栈,而 C 调用约定允许可变数量参数。

FASTCALL 使用 ECXEDX 传递前两个参数,之后的参数使用栈传递。

所有调用约定的整数返回值均通过 EAX 返回。

FASTCALL 的翻身

随着 CPU 的进步,寄存器的数量增加,FASTCALL 也迎来了升级。x86-64 Windows 允许前 4 个、x86-64 Linux 允许前 6 个参数使用寄存器传参(从左到右顺序),其他参数使用栈传参(从右往左)。

FASTCALL 成为 x86-64 的唯一调用约定。不少函数实现了纯寄存器传参,因为长参数的函数还是少见的。

Tip

虽然你的程序你做主,但最好和汇编器一样遵守调用约定……

如果压栈参数在寄存器里,将寄存器压入栈,那么由调用者恢复寄存器:

mov ecx,eax
push ecx
push OFFSET Array
call some_func
add esp,4
pop ecx

压栈的参数长度必须为 4 字节(32 bit 下)。如果值传参发现参数长度不是 4 字节,则应该换用引用传参。

9.5 引用传参的使用#

引用参数通常使用间接寻址访问。比如将一个数组的引用传递给 ESI

mov esi,[ebp+12] ; offset of array
mov ecx,[ebp+8] ; array length
; ...
mov [esi],ax ; insert value in array
add esi,TYPE WORD ; move to next element
; ...

9.6 综合示例#

对于计科的同学,课程要求就到这里。

函数调用时的具体步骤如下:

  1. 主调函数将被调函数所要求的参数,根据相应的函数调用约定,保存在运行时栈中。该操作会改变程序的栈指针。
  2. 主调函数将控制权移交给被调函数(使用 CALL 指令)。函数的返回地址(待执行的下条指令地址)保存在程序栈中(压栈操作隐含在 CALL 指令中)。
  3. 被调函数会设置帧基指针 ESP
  4. 被调函数通过修改栈顶指针的值,为自己的局部变量在运行时栈中分配内存空间,并从帧基指针的位置处向低地址方向存放被调函数的局部变量和临时变量。
  5. 如有必要,被调函数会保存希望调用前后保持不变的寄存器值。
  6. 被调函数执行自己任务,此时可能需要访问由主调函数传入的参数。若被调函数返回一个值,该值通常保存在一个指定寄存器中(如EAX)。
  7. 一旦被调函数完成操作,为该函数局部变量分配的栈空间将被释放。这通常是步骤4的逆向执行。
  8. 恢复步骤3中保存的寄存器值,包含主调函数的帧基指针寄存器。
  9. 被调函数将控制权交还主调函数(使用ret指令)。根据使用的函数调用约定,该操作也可能从程序栈上清除先前传入的参数。
  10. 主调函数再次获得控制权后,可能需要将先前的参数从栈上清除。在这种情况下,对栈的修改需要将帧基指针值恢复到步骤1之前的值。

步骤 3 与步骤 4 在函数调用之初常一同出现,统称为函数序 (prologue);步骤 6 到步骤 8 在函数调用的最后常一同出现,统称为函数跋 (epilogue)。

; lab5_2.asm - Watermelonabc 2026/4/18
; 本程序用于求给定有符号数列中,绝对值比目标数 X 更大的最小负奇数
; 若存在,输出此数的绝对值;若不存在,输出 0
include Irvine32.inc
.data
TAB dword 5 dup(?)
datSize = ($ - TAB)/4
X dword 9
Min dword ?
.code
; dword _cdecl getMin(dword* src,dword goal,dword leng) [ebp+8,ebp+12,ebp+16]
; src 地址、goal 和 leng 通过堆栈传参
; 返回值通过 eax 返回,表示 Min 的绝对值
getMin PROC
push ebp
mov ebp,esp
push esi
push edx
push ebx
mov esi,[ebp+8]
mov ebx,[ebp+12]
mov ecx,[ebp+16]
mov eax,0
mov edx,0
isMin:
mov edx,[esi]
cmp edx,0
jge notMin ; is below 0?
and edx,1h
cmp edx,1
jne notMin ; is odd number?
mov edx,[esi]
neg edx
cmp edx,ebx
jle notMin ; is bigger than X?
cmp eax,edx
jg notMin ; is bigger than current Min?
mov eax,edx
notMin:
add esi,4
loop isMin
pop ebx
pop edx
pop esi
pop ebp
ret
getMin ENDP
main PROC
mov esi,OFFSET TAB
mov ecx,datSize
input:
call ReadInt ; read a signed 32 bits integer from console and put into EAX
mov [esi],eax
add esi,4
loop input
push datSize ; leng
push X ; goal
push OFFSET TAB ; *src
call getMin
add esp,12
mov Min,eax
call WriteDec ; get an unsigned 32 bits integer from EAX and write to console
exit
main ENDP
END main

9.7 局部变量#

和参数不一样,局部变量是顺序“入栈”的。局部变量的值不是 PUSH 入栈的,而是先在栈上用 ESP 预留空间,然后直接向预留的栈空间写入局部变量。比如,一个 C 函数:

void MySub()
{
int X = 10;
int Y = 20;
}

在 x86 汇编中呈现为:

MySub PROC
push ebp
mov ebp,esp
sub esp,8 ; reserve space for locals in stack
mov dword PTR [ebp-4],10 ; X
mov dword PTR [ebp-8],20 ; Y
mov esp,ebp ; remove locals from stack
pop ebp
ret
MySub ENDP

栈帧大致表现为:

MySub()的栈帧
MySub()的栈帧

注意此处由于只保存了 ebp 寄存器,而它又属于栈底,因此直接将 esp 移到 ebp 就好了。如果你还使用了其他寄存器,那么还请你手动清理,再移动。

LEAVE 指令等效于清理局部变量的那两条指令。

如果是一个数组呢?同样预留空间,但是预留的空间应该符合字节对齐,宜大不宜小。并且用 LEA (Load Effective Address) 指令获取地址。OFFSET 对栈上地址没有用:

makeArray PROC
push ebp
mov ebp,esp
sub esp,32
lea esi,[ebp-30] ; load address of myString
mov ecx,30
L1:
mov BYTE PTR [esi],'*'
inc esi
loop L1
add esp,32 ; remove the array (restore ESP)
pop ebp
ret
makeArray ENDP

同样有 ENTERLEAVE 指令可以为我们预留局部变量的栈空间。

ENTER 会 push EBP、设置 EBPESP 相等并减少 ESP 以预留栈空间。它有两个操作数:numbytes, nestinglevelnumbytes 是你希望预留的字节数,nestinglevel 决定了从前一帧复制到新栈帧中的栈帧指针数量。

Note

nestinglevel 中的 nesting 指的是嵌套函数。在嵌套函数中,函数作用域是既包含又独立的。如果不将外层函数的帧复制到内层函数,那么万一内层函数需要外层函数的变量,就没处找了!

在这里,我们将 nestinglevel 固定为 0,只修改 numbytes

LEAVE 没有操作数,它将 ESP 设置为和 EBP 相等并弹出 EBP 以恢复栈。

而 MASM 提供了一个 ENTER 的上位替代指令:LOCAL。它更接近我们使用的参数表:

LOCAL temp:DWORD, SwapFlag:BYTE ; Two locals var: temp and SwapFlag
LOCAL pArray:PTR WORD ; A pointer to a 16 bits integer
LOCAL TempArray[10]:DWORD ; An array of 10 DWORD

AP. CTF 中常见汇编知识#

了解了一些基本的汇编指令后,下面我们来看看 CTF 中常见的一些指令:

  1. loop 循环指令:
mov rax,0
mov rcx,236
s:
add rax,123
loop s
leave
ret

上面的指令执行的是对 123 累加 236 次,从以上代码中我们可以看到,rcx 寄存器保存的是循环次数,loop 指令每执行一次,rcx 的数值就会减少 1,当其数值减少到 0 时,loop 指令就会停止,继续执行下面的指令。

  1. 无条件跳转指令:
lable:
mov edx,0
jmp lable

无条件跳转指令为 jmp,其意思从字面上就可以看出来,只要是执行到 jmp 这里,无论什么情况,都会直接跳转到 lable 的代码块中继续往下执行指令。

  1. 条件跳转:
lable:
mov ebx,1
cmp ebx,0
je lable

以上代码将 ebx 的值与 0 对比,如果相等,则会跳转到 lable 处,条件跳转指令 je 代表相等则跳转,还有其他与之条件不一样的条件跳转指令,一般条件跳转指令是与 cmptest 指令混在一起用的。以上代码也可以用 test 来写:

lable:
mov ebx,1
test ebx,0
jnz lable

test 是逻辑与指令,以上代表的即为 ebx&0jnz 代表标志位为 0 就跳转,其实现效果与 cmp 相同。

下面是一些常见的条件跳转指令:

指令英文全称含义判断标志位
JE, JZjump equal,jump zero结果为零则跳转(相等时跳转)ZF=1
JNE, JNZjump not equal,jump not zero结果不为零则跳转(不相等时跳转)ZF=0
JSjump sign结果为负则跳转SF=1
JNSjump not sign结果为非负则跳转SF=0
JP, JPEjump parity,jump parity even结果中1的个数为偶数则跳转PF=1
JNP, JPOjump not parity,jump parity odd结果中1的个数为偶数则跳转PF=0
JOjump overflow结果溢出了则跳转OF=1
JNOjump not overflow结果没有溢出则跳转OF=0
JB, JNAEjump below,jump not above equal小于则跳转 (无符号数)CF=1
JNB, JAEjump not below,jump above equal大于等于则跳转 (无符号数)CF=0
JBE, JNAjump below equal,jump not above小于等于则跳转 (无符号数)CF=1 or ZF=1
JNBE, JAjump not below equal,jump above大于则跳转(无符号数)CF=0 and ZF=0
JL, JNGEjump less,jump not greater equal小于则跳转 (有符号数)SF≠ OF
JNL, JGEjump not less,jump greater equal大于等于则跳转 (有符号数)SF=OF
JLE, JNGjump less equal,jump not greater小于等于则跳转 (有符号数)ZF=1 or SF≠ OF
JNLE, JGjump not less equal,jump greater大于则跳转(有符号数)ZF=0 and SF=OF

下面是一些常见的位运算指令:

  • AND:与运算,AND AX,BXAXBX 进行逻辑与运算,并将结果保存到 AX 寄存器中
  • OR:或运算,OR AX,BXAXBX 进行逻辑或运算,并将结果保存到 AX 寄存器中
  • XOR:异或运算,XOR AX,BXAXBX 进行异或运算,并将结果保存到 AX 寄存器中
  • NOT:取反操作,NOT CXCX 进行取反,并将结果保存到 CX 寄存器中
  • TEST:逻辑与运算,TEST AX,BXAXBX 进行与运算,并设置标志位,结果不保存
  1. 函数传参:

在汇编中,我们有时候需要知道参数被保存在那个寄存器里,这里我列举一般情况:

通常函数的返回值保存在 ax 寄存器里,比如 eaxrax 等; 函数的参数一般按顺序保存在 cxdxsi 中,在 64 位汇编中,一般按 rcx, rdx, r8, r9, r10, r11, … 的顺序保存参数。

有时候根据函数调用约定以及编译器和平台的不同,这个储存规律也会发生改变,我们需要根据当时情况动态调整。

  1. lea 地址加载:

其使用格式为 lea rdx,[my_var],将 my_var 的地址而不是内容赋值给 rdx 寄存器。

  1. xchg 数值交换指令:

其使用格式为 xchg ax,bxaxbx 的数值交换。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

x86 汇编语言详解-2
https://blog.watermelonabc.top/p/57f94527/
作者
Watermelonabc
发布于
2026-03-24
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Watermelonabc
追寻各色世界
🎉 抓到你啦
欢迎来到 Watermelonabc 的 Blog!这个博客主要是技术向的内容,现在也有生活元素了 OwO
分类
文章目录