做 GHCTF 2025 新生赛 的 ASM?Signin! 时提示需要 8086 汇编基础,然而我没有基础…… 所以来学一学了!
正如 WikiBooks 里面写的 “用汇编语言开发程序可能是一个非常耗时的过程。虽然用汇编语言编写新项目可能不是个好主意……” 我们现在学习汇编语言不是为了实际编写,更多是为了学会阅读它。MC 笑传之常常崩!神秘崩溃牵扯出了计算机底层原理呈现了从汇编层面找到导致 JVM 出现 SIGNSEGV 的原因的过程。
0. x86 汇编概述
x86 架构以 1978 年的 Intel 8086 处理器为开端,覆盖 Intel 从 16 位到 64 位的处理器。由于汇编语言非常依赖硬件,因此目前将 x86 汇编语言分为 IA-32 (x86-32) 和 x86-64 两种 [1]
将汇编语句转化为机器语言的程序称为汇编器 (assembler)。输入汇编器的文件称为源文件 (source file)。汇编器将生成两个文件:目标文件 (object file) 和列表文件 (listing file)。目标文件保存了转化得到的机器语言和对应的汇编代码,列表文件提供的信息会更加详细,一般用于辅助分析调试。
生成的 .obj 目标文件还需要经过链接器 (linker) 的处理才能生成 .EXE 文件,这是因为较大规模程序往往会分割为多个模块,而每个模块的源文件都会生成对应的目标文件,所以需要将它们链接起来。
graph LR
A(源文件) --> B{汇编器}
B -->|汇编| C(目标文件)
B -->|汇编| D(列表文件)
C --> E{链接器}
E -->|链接| F(可执行文件)
我们初学 C / C++ 时,会使用 “编译” 来描述将源代码转换成可执行文件的过程,这实际上是不准确的。编译实际只负责整个过程中的语法检查和目标文件生成,而目标文件到可执行文件还需要经过链接。这整个过程实际上称为构建 (build)。
简化的语言变化过程:
> graph LR A[高级语言] --> B[汇编语言] B --> C[机器指令]
然而,现代编译器还会对源码做优化工作,再加上一些编译器需要同时支持多语言、多平台,于是现在的语言变化流程是这样的:
> graph LR A[高级语言] --> B[抽象语法树 AST] B --> C[中间表示 IR] C --> D[汇编指令] D --> E[机器指令]
编译器前端 (frontend) 负责高级语言到 IR 的转换,后端 (backend) 负责 IR 到机器指令的转换。
现在我们暂时打住,不再继续深究。
2. 从 C 语言认知汇编
本节假设读者已经有 C 语言基础
前文说过,源文件经由汇编器得到目标文件,里面就存放着我们想看到的汇编代码。
如果你的网络条件较好,那么有一个很好的在线编译器平台 Compiler Explorer。该平台提供了丰富的编译器选择,有助于学习和比较编译器对源码的编译方式。
如果你需要本地查看汇编,就要让构建工具生成目标文件。对于 GCC,输入:
1 | gcc –c example.c # -c 表示仅编译不链接 |
在当前目录下生成 example.o。此时的 example.o 不能直接查看,需要使用 objdump:
1 | objdump -h example.o # 查看文件段信息 |
对于一个简单的 Hello World:
1 |
|
Compiler Explorer 在 x86_64 GCC 15.2 下的输出:
1 | .LC0: |
通过 objdump 输出的汇编:
1 |
|
Compiler Explorer 输出的是 Intel 语法,而 objdump 默认输出 AT & T 语法。
我们看的更多的是 Intel 语法。使用 -M 选项可以控制 objdump 的输出格式:
1 | objdump -s -d -M intel hello.o > hello.s # 以 intel 语法显示汇编指令 |
3. 汇编语言本地开发环境搭建
如果不追求完全的汇编环境的话,可以直接使用 C / C++ 的内联汇编。
Assembly Environment Setup 记载了这位作者的开发环境搭建方案。
也有一个类似于 Dev-C++ 的轻量 IDE——SASM。它具有语法突出显示和调试器,且开箱即用,非常适合初学者学习汇编语言。
SASM 附带一组汇编示例。例如,这是 NASM 汇编器的 Hello World:
1 | .data |
在继续之前,我们需要重申:汇编语言非常依赖于特定软硬件平台。在参考其他教程时,请先查看它用了什么编译器。
本文将采用跨平台的 NASM。
4. x86 汇编语言元素
x86 汇编语言由语句 (statement) 构成。一条语句的组成元素有:关键字 (keyword)、标识符 (identifier)、数值 (number)、字符串 (string)、特殊字符 (special character) 和注释 (comment)。
-
关键字:在汇编时有特殊意义的符号,例如指令助记符或者其他保留字。
-
标识符:用户定义的符号,常用于表示变量、标签和数值常量。标识符可以由字母、数字、下划线、
@和?组成,但不能以数字开头。一些标识符示例:COUNT、@1和A_BYTE。 -
数值:可以是十进制、十六进制、八进制或二进制数字。数值必须以一个十进制数字开头,对于除十进制以外的数制,需要在结尾加上表示当前数制的字母。例如:
0ABCh(h代表十六进制);1776q(q代表八进制,现代汇编器也支持o);10100110b(b表示二进制)。 -
字符串:被单引号
‘’包围的一串字符。 -
特殊字符:
‘’$&?;:=,[].+-()*/<>,包括作为分隔符的空格字符和制表符。 -
注释:仅用于撰写程序文档的一串字符,会被编译器忽略。注释以
;开头。
在 x86 汇编中,一个语句应仅占一行(即不允许跨行语句)。
1 | mov ax, |
以语句为单位,一份 x86 源码可以分为三个主要部分:指令语句、数据分配语句和汇编器指令。
- 指令语句 (instruction statements) 由助记符(和操作数)组成,生成特定的机器码。
- 数据分配语句 (data allocation statements) 为程序数据保留并选择性初始化内存空间。
- 汇编器指令 (assembler directive) 向汇编器传递特定指令。这些指令的汇编结果可能会在目标文件中出现,但不会影响程序原本的内存数据。
5. 寄存器
寄存器 (register) 是 CPU 内部用来存储指令、数据和地址的存储器,在计算机的存储器体系中处于最高层次。寄存器的存储容量有限,但读写速度非常快。一般认为,访问寄存器中的数据不需要时间,是存储计算临时数据的绝佳位置。大多数寄存器都有特殊用途。
现代 CPU 有很多寄存器,我们主要了解这些种类:通用寄存器 (general purpose register)、指令指针寄存器 (instruction pointer)、段寄存器 (segement register) 和标志寄存器 (FLAGS)。前三种寄存器长度为当前架构的字长,即 64 bits、32 bits 及 16 bits。标志寄存器的长度固定为 16 bits。
在汇编语言中,这些寄存器的名字是大小写无关的,既可以用 EAX,也可以写 eax。
5.1 通用寄存器
现代 CPU 有 16 个通用寄存器,其中 8 个拥有特定用途:
| 64-bit | 32-bit | 16-bit | 8 high bits of lower 16 bits | 8-bit | 描述 |
|---|---|---|---|---|---|
| RAX | EAX | AX | AH | AL | 主寄存器 / 累加器 |
| RBX | EBX | BX | BH | BL | 基址寄存器 |
| RCX | ECX | CX | CH | CL | 计数寄存器 |
| RDX | EDX | DX | DH | DL | 数据寄存器 (通常作为主寄存器的扩充) |
| RSI | ESI | SI | - | SIL | 源索引寄存器(用于字符串操作) |
| RDI | EDI | DI | - | DIL | 目标索引寄存器(用于字符串操作) |
| RSP | ESP | SP | - | SPL | 堆栈指针寄存器 |
| RBP | EBP | BP | - | BPL | 基址指针寄存器(用于堆栈帧) |
| R8 | R8D | R8W | - | R8B | 通用 |
| R9 | R9D | R9W | - | R9B | 通用 |
| R10 | R10D | R10W | - | R10B | 通用 |
| R11 | R11D | R11W | - | R11B | 通用 |
| R12 | R12D | R12W | - | R12B | 通用 |
| R13 | R13D | R13W | - | R13B | 通用 |
| R14 | R14D | R14W | - | R14B | 通用 |
| R15 | R15D | R15W | - | R15B | 通用 |
AX、BX、CX、DX 虽然是 16 位寄存器,但也可以拆成两个 8 位寄存器使用,因为这四个通用寄存器本来就是两个独立的 8 位寄存器拼起来得到的。两个 8 位存储器中的 “H” 或 “L” 分别表示这个寄存器处于内存中的高位或低位。
如果 AX=0011000000111001b,则 AH=00110000b,AL=00111001b。因此,当修改任一 8 位寄存器时,16 位寄存器也会更新,反之亦然。
因此,像 AX 这样的寄存器也称为通用字寄存器 (general purpose word register),像 AH/AL 这样的寄存器也称为通用字节寄存器 (general purpose byte register)。
5.2 指令指针寄存器
IP 寄存器指向当前正在执行的指令(内存位置),用于 RIP 相对寻址模式。
| 64-bit | 32-bit | 16-bit |
|---|---|---|
| RIP | EIP | IP |
5.3 段寄存器
| 名称 | 描述 |
|---|---|
| CS | 代码段寄存器,指向包含当前程序的段地址。 |
| DS | 数据段寄存器,通常指向包含变量定义的段地址。 |
| SS | 堆栈段寄存器,指向包含堆栈的段地址。 |
| ES | 附加段寄存器(用于字符串操作) |
| FS | 通用段寄存器 |
| GS | 通用段寄存器 |
总结起来,段寄存器都有一个目的 —— 指向可访问的内存块。
不建议在段寄存器中存储数据。
段寄存器与通用寄存器协同工作以访问任意内存值。2 个寄存器形成的地址称为有效地址 (effective address)。
例如,如果我们想访问物理地址 12345h(十六进制),则应设置 DS = 1230h 和 SI = 0045h。
CPU 通过将段寄存器存储的值乘以 10h(偏移值,十进制下为 16) 并加上通用寄存器存储的值来计算物理地址,即:1230h * 10h + 45h = 12345h。
这样做的话,我们可以访问比只使用单个 16 位寄存器多得多的内存。
默认情况下,BX、SI 和 DI 寄存器与 DS 段寄存器一起工作;BP 和 SP 与 SS 段寄存器协同工作。其他通用寄存器则无法形成有效地址。此外,尽管 BX 可以用于形成有效地址,但 BH 和 BL 不能,因为 x86 CPU 必须使用 16 位寄存器进行内存寻址。
5.4 标志寄存器
标志寄存器通常反映算术运算的结果以及有关当前时间对 CPU 作施加的限制的信息。
标志寄存器是按位起作用的,也就是说,它的每一位都有专门的含义,记录特定的信息。
CPU 标志寄存器的结构:
1 | ... 15 14 13 12 11 10 09 08 07 06 05 04 03 02 01 00 |
有些位不是没有对应的标志,只是在设计上数值固定,对于一般开发者没有用处。参考 FLAGS register - Wikipedia
CF标志
进位、借位标志位 (Carry Flag)。在进行无符号数运算的时候,它记录了运算结果的最高有效位向更高位的进位值,或从更高位的借位值。PF标志
奇偶标志位 (Parity Flag)。记录相关指令执行后,其结果的所有 bit 位中 1 的个数是否为偶数。如果为偶数,PF= 1;如果为奇数,那么PF= 0。AF标志
辅助进位标志位 (Auxiliary Carry Flag)。记录在四个最低有效位或低四位中是否产生了进位或借位。它主要用于二进制编码的十进制 (BCD) 算术。ZF标志
零标志位 (Zero Flag)。记录相关指令执行后,其结果是否为 0。如果结果为 0,那么ZF= 1;如果结果不为 0,那么ZF= 0。SF标志
符号标志位 (Sign Flag)。在进行有符号数运算的时候,它记录相关指令执行后,其结果是否为负。如果结果为负,SF= 1;如果非负,SF= 0。TF标志
自陷标志位 (Trap Flag)。如果此标志可用,调试器可以使用它来逐步执行计算机程序。CPU 在执行完一条指令后,如果检测到标志寄存器的TF位为 1,则会产生一个 type-1 中断,然后再将TF置为 0,后进行 type-1 中断后继续执行。
8086 CPU 没有直接设置或重置自陷标志位的指令。这些操作可以通过将标志寄存器的内容压入堆栈,改变内存内容以设置或重置自陷标志,并将内存内容弹出堆栈以保存回标志寄存器来完成。用到的指令是pushf和popf。IF标志
中断标志位 (Interrupt Flag)。用于确定 CPU 是否将立即响应可屏蔽硬件中断。如果标志设置为 1 ,则中断被启用。如果清除(设置为 0 ),则中断被禁用。DF标志
方向标志位 (Direction Flag)。此标志用于确定将数据从内存的一个位置复制到另一个位置的方向(正向或反向)。
如果设置为 0(使用清除方向标志指令CLD),则表示从最低地址到最高地址处理字符串。这种指令模式称为自动递增模式。
如果设置为 1(使用设置方向标志指令STD),则表示从最高地址到最低地址处理字符串。这称为自动递减模式。OF标志
溢出标志位 (Overflow Flag)。对于有符号数运算,运算结果超出补码表示范围(8 位:-128~+127,16 位:-32768~+32767)就是溢出。若溢出,OF置 1,否则OF清 0。
6. 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 的数值交换。
Ad
今天在查找资料的过程中看到了一个交互式教程 Ketman Assembly Language Tutorial 。根据作者的介绍,该教程原先是为了聚焦于学习语言本身而开发的,之后发展成为一个完整的开发环境。
它的一大特点就是汇编代码的即时运行与调试,因此可以快速明确每条指令对 CPU 的影响。
左侧是编辑器,右侧是调试器,可以看出还是很完善的。
ACTF 提供的 x86 汇编入门文件,包含文件内注释指导和课后作业 asm_tour_1
你也许还听说有 AMD64、Intel 64 和 IA-64 。首先,AMD64 就是 x86-64,Intel 64 是对 AMD64 的兼容,也可以视作属于 x86-64;其次,IA-64 是 Intel 推出的仅 64 位指令集,但没有流行开来。 ↩︎