7. x86 汇编语言元素

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

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

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

标识说明:

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

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

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

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

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

运算符 优先级 备注
() 1
+, - 2 正负号
*, / 3
mod 4
+, - 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)。指令一般修改的是目标操作数。
    对于有三个操作数的指令,后两个操作数作为源操作数执行指令,结果存储到目标操作数。
    常见助记符及其操作数:

    助记符 操作数 说明
    MOV dest, src src 的值赋值到 dest
    ADD dest, src dest = dest + src
    INC op op 递增 1
    DEC op op 递减 1
    SUB dest, src dest = dest - src
    MUL op 无符号乘法,结果储存在主寄存器中,即 EAX = EAX * op
    IMUL dest, src1, src2 有符号乘法,dest = src1 * src2
    JMP label 无条件跳转至标签位置
    CALL func 调用函数
    NOP 无操作指令,占用 1 字节,常被汇编器用于字节对齐
    Note

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

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

7.6 段式存储

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

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

7.7 示例 1

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 注册过的类型,例如:

1
2
CHAR    TYPEDEF BYTE
PCHAR TYPEDEF PTR CHAR

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

1
2
count dword 114514
homo word ?

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

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

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

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

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

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

1
2
3
4
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,例如:

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

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

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

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

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

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

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

1
2
3
4
5
6
.code
mov eax,ebx
.data
temp DWORD ?
.code
mov temp,eax

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

1
2
3
4
5
6
7
8
9
10
11
.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

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

1
2
3
4
5
.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。

1
2
3
4
5
.data
byteVal BYTE 10001111b
.code
movzx ax,byteVal ; AX = 0000000010001111b
movsx bx,byteVal ; BX = 1111111110001111b
Warning

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

1
2
3
4
5
6
7
8
9
10
reg32, reg/mem8
reg32, reg/mem16
reg16, reg/mem8

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

```x86asm
XCHG reg,reg
XCHG reg,mem
XCHG mem,reg

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

1
2
3
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] 自动乘元素大小
  • 立即数寻址

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

1
2
3
4
mov eax, 10h

; View in Hex:
; B8 10000000
  • 直接内存寻址

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

1
2
3
4
5
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 的寄存器才是真正进行移动操作的场所,内存只是数据的 “仓库”,需要使用寄存器作为中转:

1
2
mov eax, var1
mov var2, eax

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

  • 直接寄存器寻址

7.10 示例 2

1
2
3
4
5
6
7
8
9
10
11
12
13
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 基本运算

基本运算指令分为算术运算和逻辑运算。

8. 分支与循环程序设计

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

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

8.1 无条件转移

使用 JMP 指令:

1
JMP label

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

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

1
2
3
top:
; ...
jmp top

8.2 条件转移

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

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

常见条件转移指令:

指令 英文全称 含义 判断标志位
JE, JZ jump equal,jump zero 结果为零则跳转 (相等时跳转) ZF=1
JNE, JNZ jump not equal,jump not zero 结果不为零则跳转 (不相等时跳转) ZF=0
JS jump sign 结果为负则跳转 SF=1
JNS jump not sign 结果为非负则跳转 SF=0
JP, JPE jump parity,jump parity even 结果中 1 的个数为偶数则跳转 PF=1
JNP, JPO jump not parity,jump parity odd 结果中 1 的个数为偶数则跳转 PF=0
JO jump overflow 结果溢出了则跳转 OF=1
JNO jump not overflow 结果没有溢出则跳转 OF=0
JB, JNAE jump below,jump not above equal 小于则跳转 (无符号数) CF=1
JNB, JAE jump not below,jump above equal 大于等于则跳转 (无符号数) CF=0
JBE, JNA jump below equal,jump not above 小于等于则跳转 (无符号数) CF=1 or ZF=1
JNBE, JA jump not below equal,jump above 大于则跳转 (无符号数) CF=0 and ZF=0
JL, JNGE jump less,jump not greater equal 小于则跳转 (有符号数) SF≠ OF
JNL, JGE jump not less,jump greater equal 大于等于则跳转 (有符号数) SF=OF
JLE, JNG jump less equal,jump not greater 小于等于则跳转 (有符号数) ZF=1 or SF≠ OF
JNLE, JG jump not less equal,jump greater 大于则跳转 (有符号数) ZF=0 and SF=OF

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

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

1
2
3
4
5
6
7
8
9
10
; ...
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 指令就会停止,继续执行下面的指令。

1
2
3
4
5
6
7
8
; ...
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

1
2
3
4
5
6
7
8
9
10
11
12
13
.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 作为返回语句:

1
2
3
4
sample PROC
; ...
ret
sample ENDP

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

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

1
2
3
4
5
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,之后弹出。

对于函数的嵌套调用同样如此。每 CALL 一个子函数,对应的返回地址就会压入栈中;然后 RET 时不断 “溯源”,直到返回主函数。

9.3 保存和恢复寄存器

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
;----------------------------------------------------
; 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

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

1
2
3
4
5
6
7
8
9
10
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 之后,寄存器列表中间无逗号。

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

9.4 用栈传递参数

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

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

微软定义,使用寄存器传递参数的情况在 32 位下称为 fastcall 调用约定。顾名思义,和通过栈传递参数相比,寄存器传参更快。然而,一旦程序规模开始膨胀,寄存器传参出现 bug 的概率就越高,并且将寄存器反复弹压的操作所造成的代价也会抵消 fastcall 带来的效率优势。

9.4.1 栈帧

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

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

理想情况下,相关寄存器应在将 EBP 设置为 ESP 、预留局部变量空间之推送到栈中。这有助于避免更改现有栈参数的偏移量。即使使用 PUSHAD,我们依然建议在此之前先单独 push 和设置 EBP

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

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

压栈顺序

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

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

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

1
2
3
4
5
6
mov ecx,eax
push ecx
push OFFSET Array
call some_func
add esp,4
pop ecx

9.4.2 栈参数的调用和清理

现在我们来一个示例:

1
2
3
4
5
6
7
8
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),即:

1
2
3
push 6
push 5
call AddTwo

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

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 调用约定允许可变数量参数。

x86 汇编允许混用寄存器传参和栈传参,即其他调用约定和 fastcall 的混合。但这不属于任何调用规定。

9.5 局部变量

和参数不一样,局部变量是顺序压栈的。比如,一个 C 函数:

1
2
3
4
5
void MySub()
{
int X = 10;
int Y = 20;
}

在 x86 汇编中呈现为:

1
2
3
4
5
6
7
8
9
10
MySub PROC
push ebp
mov ebp,esp
sub esp,8
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()的栈帧

9.6 引用传参的使用

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

1
2
3
4
5
6
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
; ...

AP. CTF 中常见汇编知识

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

  1. loop 循环指令:
1
2
3
4
5
6
7
mov rax,0
mov rcx,236
s:
add rax,123
loop s
leave
ret

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

  1. 无条件跳转指令:
1
2
3
4
lable:
mov edx,0

jmp lable

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

  1. 条件跳转:
1
2
3
4
5
lable:
mov ebx,1

cmp ebx,0
je lable

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

1
2
3
4
5
lable:
mov ebx,1

test ebx,0
jnz lable

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

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

指令 英文全称 含义 判断标志位
JE, JZ jump equal,jump zero 结果为零则跳转 (相等时跳转) ZF=1
JNE, JNZ jump not equal,jump not zero 结果不为零则跳转 (不相等时跳转) ZF=0
JS jump sign 结果为负则跳转 SF=1
JNS jump not sign 结果为非负则跳转 SF=0
JP, JPE jump parity,jump parity even 结果中 1 的个数为偶数则跳转 PF=1
JNP, JPO jump not parity,jump parity odd 结果中 1 的个数为偶数则跳转 PF=0
JO jump overflow 结果溢出了则跳转 OF=1
JNO jump not overflow 结果没有溢出则跳转 OF=0
JB, JNAE jump below,jump not above equal 小于则跳转 (无符号数) CF=1
JNB, JAE jump not below,jump above equal 大于等于则跳转 (无符号数) CF=0
JBE, JNA jump below equal,jump not above 小于等于则跳转 (无符号数) CF=1 or ZF=1
JNBE, JA jump not below equal,jump above 大于则跳转 (无符号数) CF=0 and ZF=0
JL, JNGE jump less,jump not greater equal 小于则跳转 (有符号数) SF≠ OF
JNL, JGE jump not less,jump greater equal 大于等于则跳转 (有符号数) SF=OF
JLE, JNG jump less equal,jump not greater 小于等于则跳转 (有符号数) ZF=1 or SF≠ OF
JNLE, JG jump 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 的数值交换。