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 指令

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

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

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

  • 操作数
    操作数用于指令数据的输入输出。操作数可以是寄存器、内存地址或者整数表达式。一条指令可以有 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

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

  • .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 命令:

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

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

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

7.9 操作数和寻址方式

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

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 作为参数,接受字符串地址。


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 的数值交换。