7. x86 汇编语言元素
现在我们带你讲解一下一个汇编语言程序是怎么写的。x86 汇编语言由语句 (statement) 构成,一条语句可能有以下元素:
7.1 常量整数和常量整数表达式
一个常量整数表示为 [{ + | - }] 数字 { 基数字符 },例如 -10、11010011b、0A3h。
标识说明:
[]的内容是可选的{ A | B }说明必须在A和B中选择一项
基数字符说明了此整数使用的数制,如下表所示:
| 基数字符 | 数制 |
|---|---|
空置/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 其他常量
常量实数表示为 [{ + | - }]数字.[数字][指数],其中[指数]为科学记数法中的 部分,表示为 E[{ + | - }]integer。2.、-44.2E+05、26.E5 都是合法的十进制实数。常量实数按照 IEEE 浮点数格式转换存储。
编码实数 (encoded real) 以十六进制表示,用 r 结尾。对应的十六进制从 IEEE 浮点数格式的二进制数转换得到,如 +1.0 的 IEEE 浮点数格式为 0011 1111 1000 0000 0000 0000 0000 0000,编码实数表示为 3F800000r
字符和字符串常量以 ''/"" 包围,按照 ASCII 码存储。
7.3 保留字
保留字主要有以下类型:
- 指令助记符,如
mov、add、mul等 - 寄存器名称
- 汇编器指令
- 类型属性,如
byte和word - 运算符
- 预定义的符号
保留字大小写不敏感,如 MOV 和 mov 是一样的,但预定义符号除外。
7.4 标识符
标识符用于标识变量、常量、函数、标签,有以下规则:
- 长度在 1 ~ 247 之间
- 大小写不敏感(通过汇编器的
-Cp选项可使之大小写敏感) - 首字符必须是字母、下划线、
@、?或$,后续的字符才可以取数字。
作为建议,请尽量不要以下划线或者@开头,减少违反下一条规则的概率。 - 不能和保留字撞名
7.5 指令
指令在汇编时转换成运行时可执行的语句(机器语言字节)。一条指令包含四个部分:[标签:] [助记符] [操作数] [;注释]
-
标签
标签用于标识数据 / 代码的位置。 -
助记符
助记符标识指令功能。 -
操作数
操作数用于指令数据的输入输出。操作数可以是寄存器、内存地址或者整数表达式。一条指令可以有 0 到 3 个操作数。
对于有两个操作数的指令,第一个操作数作为目标操作数 (dest),第二个操作数作为源操作数 (src)。指令一般修改的是目标操作数。
对于有三个操作数的指令,后两个操作数作为源操作数执行指令,结果存储到目标操作数。
常见助记符及其操作数:助记符 操作数 说明 MOVdest, srcsrc的值赋值到destADDdest, srcdest = dest + srcINCopop递增 1DECopop递减 1SUBdest, srcdest = dest - srcMULop无符号乘法,结果储存在主寄存器中,即 EAX = EAX * opIMULdest, src1, src2有符号乘法, dest = src1 * src2JMPlabel无条件跳转至标签位置 CALLfunc调用函数 NOP无 无操作指令,占用 1 字节,常被汇编器用于字节对齐 Note无论何时,我们都建议优先以寄存器作为操作数,然后是常量值,最后才是内存地址标识,因为它们的读取效率是依次递减的。
-
注释
单行注释以;开头
多行注释使用COMMENT指令,语法为COMMENT 结束符。从COMMENT到第一个结束符之间的代码块将被忽略。
7.6 段式存储
正如 4.2 所述,x86 处理器底层依然属于段式存储模型。在大部分操作系统上,采用的模型和各位在 C / C++ 学的内存布局是一样的。
现在这个阶段,需要我们设置的内存段只有 .data(数据段)和 .code(代码段)。
7.7 示例 1
下面是一个进行加减运算的 MASM 汇编程序,操作数是写死在程序中的。
1 | INCLUDE Irvine32.inc |
我们详细分析一下这个程序。第一部分是汇编器指令:
INCLUDE Irvine32.inc调用了教学用库Irvine32.inc
接下来是代码区,我们在这里写函数代码:
.code表示下面的所有代码均属于代码段的内容main PROC定义main过程(Procedure,即函数)a:定义了一个标签a,指向mov eax, 30000hmov eax, 30000h将十六进制值30000h赋值给eaxadd eax, 80000h将80000h和eax的值相加,结果保存到eax。call DumpRegs调用Irvine32.inc的DumpRegs函数,作用是打印当前的寄存器数值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 | CHAR TYPEDEF BYTE |
使用数据类型在 .data 段定义数据的语法为 [标识符] 类型 [初始化值, ...]
1 | count dword 114514 |
数据定义时必须有至少一个初始化值,如果不想自己设定,可以使用 ? 表示随机赋值。所有的初始化值,无论其格式,都会被汇编器转换为二进制数据。如果初始化值是有规律重复的,可以使用 DUP 命令:
1 | BYTE 20 DUP(0) ; 20 字节,均初始化为 0 |
可以有多个初始化值,这实际上就声明了一个数组:
1 | list byte 10,20,30,40 |
数据 10 位于 list 的偏移 0 位置,以此类推。
字符串也是一种数组,但作为例外,每个字节间不都需要逗号分隔。正如各位在 C 中所学的那样,所有的字符串都需要加一个 \0 终止符(当然,是手动的,没有编译器兜底了)。对于多行字符串,只需要一整块字符串的末尾终止即可,如果需要换行,需要使用 0dh, 0ah (CR/LF):
1 | greeting1 BYTE "Welcome to the Encryption Demo program " |
计算数组长度可以使用 $ - Array,例如:
1 | list BYTE 10,20,30,40 |
注意采用这种方式计算的,表达式必须紧跟在数组之后,因为 ($ - list) 的语义是当前地址 ($) 减去 list 的地址。由于数据在段内是连续的,因此以偏移值计算元素个数。如果中间再插入了其他数据,偏移值就计算错误了。
由于 BYTE 只占 1 字节,因此偏移值 = 元素数。如果是 WORD 等更大的数据类型,就需要除以它的单位长度。
7.9 操作数和寻址方式
正如我们在 7.5 所述,操作数可以是寄存器、内存地址或者整数表达式。我们将这些操作数分类为:立即数 (imm)、寄存器 (reg) 和内存 (mem),由此得到几种寻址模式。
7.10 示例 2
1 | INCLUDE Irvine32.inc |
此处我调用了 Irvine32.inc 的 WriteString 函数。它以 edx 作为参数,接受字符串地址。
AP. CTF 中常见汇编知识
了解了一些基本的汇编指令后,下面我们来看看 CTF 中常见的一些指令:
loop循环指令:
1 | mov rax,0 |
上面的指令执行的是对 123 累加 236 次,从以上代码中我们可以看到,rcx 寄存器保存的是循环次数,loop 指令每执行一次,rcx 的数值就会减少 1,当其数值减少到 0 时,loop 指令就会停止,继续执行下面的指令。
- 无条件跳转指令:
1 | lable: |
无条件跳转指令为 jmp,其意思从字面上就可以看出来,只要是执行到 jmp 这里,无论什么情况,都会直接跳转到 lable 的代码块中继续往下执行指令。
- 条件跳转:
1 | lable: |
以上代码将 ebx 的值与 0 对比,如果相等,则会跳转到 lable 处,条件跳转指令 je 代表相等则跳转,还有其他与之条件不一样的条件跳转指令,一般条件跳转指令是与 cmp、test 指令混在一起用的。以上代码也可以用 test 来写:
1 | lable: |
test 是逻辑与指令,以上代表的即为 ebx&0,jnz 代表标志位为 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,BX将AX与BX进行逻辑与运算,并将结果保存到AX寄存器中OR:或运算,OR AX,BX将AX与BX进行逻辑或运算,并将结果保存到AX寄存器中XOR:异或运算,XOR AX,BX将AX与BX进行异或运算,并将结果保存到AX寄存器中NOT:取反操作,NOT CX将CX进行取反,并将结果保存到CX寄存器中TEST:逻辑与运算,TEST AX,BX将AX与BX进行与运算,并设置标志位,结果不保存
- 函数传参:
在汇编中,我们有时候需要知道参数被保存在那个寄存器里,这里我列举一般情况:
通常函数的返回值保存在 ax 寄存器里,比如 eax,rax 等; 函数的参数一般按顺序保存在 cx、dx、si 中,在 64 位汇编中,一般按 rcx, rdx, r8, r9, r10, r11, … 的顺序保存参数。
有时候根据函数调用约定以及编译器和平台的不同,这个储存规律也会发生改变,我们需要根据当时情况动态调整。
lea地址加载:
其使用格式为 lea rdx,[my_var],将 my_var 的地址而不是内容赋值给 rdx 寄存器。
xchg数值交换指令:
其使用格式为 xchg ax,bx 将 ax 和 bx 的数值交换。
