🚧 本文于近日进行更新,换用更 “友好” 的王爽《汇编语言》,因为 Intel 的教程过于古早并且较难阅读。如你对后者仍有兴趣,请从 An Introdution to ASM86 处获取。
做 GHCTF 2025 新生赛 的 ASM?Signin! 时提示需要 8086 汇编基础,然而我没有基础…… 所以来学一学了!
我们现在学习汇编语言不是为了实际编写,仅是为了学会阅读它。本笔记介绍的是 16 位汇编,虽然过时但却是 x86 架构的基石,有很多东西被后继的 32/64 为汇编所继承。
请注意,有些教程所编写的示例程序是用于构建 .COM 文件的。根据 NASM 汇编器手册,DOS 系统下的大型程序和 Windows 程序应构建为 .EXE 文件。在选择网络教程时,请注意这一点。
0. ASM86 概述
ASM86 是 Intel 8086/8080 汇编语言的名字。使用本语言写成的语句将用于在 8086/8080 CPU 上生成特定的机器指令或分配程序内存空间。
将汇编语句转化为机器语言的程序称为 ASM86 汇编器 (assembler)。输入汇编器的文件称为源文件 (source file)。汇编器将生成两个文件:目标文件 (object file) 和列表文件 (listing file)。目标文件保存了转化得到的机器语言,列表文件则以十六进制表示这些机器语言,并附上对应的汇编代码。
生成的 .obj 目标文件需要经过链接器 (linker) 的处理才能生成 .EXE 文件,因为较大规模程序往往会分割为多个模块,而每个模块源文件都会生成对应的目标文件。
我们初学 C/C++ 时,会使用 “编译” 来描述将源代码转换成可执行文件的过程,这实际上是不准确的。编译实际只负责整个过程中的语法检查和目标文件生成,而目标文件到可执行文件还需要经过链接。这整个过程实际上称为构建 (build)。
1. ASM86 语言元素
ASM86 源码由语句 (statement) 构成。一条语句的组成元素有:关键字 (keyword)、标识符 (identifier)、数值 (number)、字符串 (string)、特殊字符 (special character) 和注释 (comment)。
- 关键字:在汇编时有特殊意义的符号,例如指令助记符或者其他保留字。
- 标识符:用户定义的符号,常用于表示变量、标签和数值常量。标识符可以由字母、数字、下划线、
@
和?
组成,但不能以数字开头。一些标识符示例:COUNT
、@1
和A_BYTE
。 - 数值:可以是十进制、十六进制、八进制或二进制数字。数值必须以一个十进制数字开头,对于除十进制以外的数制,需要在结尾加上表示当前数制的字母。例如:
0ABCh
(h
代表十六进制);1776q
(q
代表八进制,现代汇编器也支持o
);10100110b
(b
表示二进制)。 - 字符串:被单引号
‘’
包围的一串字符。 - 特殊字符:
‘’$&?;:=,[].+-()*/<>
,包括作为分隔符的空格字符和制表符。 - 注释:仅用于撰写程序文档的一串字符,会被编译器忽略。注释以
;
开头。
在 ASM86 汇编中,一个语句应仅占一行(即不允许跨行语句)。
1 | mov ax, |
大部分情况下,ASM86 汇编不要求语句的纵向排列。这有两个例外:一个是上述的 &
符号,另一个是声明控制行的 $
符号。
以语句为单位,一份 ASM86 源码可以分为三个主要部分:指令语句、数据分配语句和汇编器指令。
- 指令语句 (instruction statements) 由助记符(和操作数)组成,生成特定的机器码。Intel 标准规定助记符应为全大写。
- 数据分配语句 (data allocation statements) 为程序数据保留并选择性初始化内存空间。
- 汇编器指令 (assembler directive) 向汇编器传递特定指令。这些指令的汇编结果可能会在目标文件(object file,源码汇编后得到的产物)中出现,但不会影响程序原本的内存数据。
2. 寄存器
寄存器 (register) 是 CPU 内部用来存储指令、数据和地址的存储器,在计算机的存储器体系中处于最高层次。寄存器的存储容量有限,但读写速度非常快。一般认为,访问寄存器中的数据不需要时间,是存储计算临时数据的绝佳位置。大多数寄存器都有特殊用途。
8086 CPU 拥有三类寄存器:通用寄存器 (general purpose register)、段寄存器 (segement register) 和两个特殊用途的寄存器。这些寄存器均为 16 位宽度。
2.1 通用寄存器
8086 CPU 有 8 个主要的通用寄存器:
- AX - primary register /accumulator(主寄存器,累加器),分为 AH/AL
- BX - base register(基址寄存器),分为 BH/BL
- CX - count register(计数寄存器),分为 CH/CL
- DX - data register(数据寄存器),分为 DH/DL
- SI - source index register(源变址寄存器)
- DI - destination index register(目的变址寄存器)
- BP - base pointer(基址指针寄存器)
- SP - stack pointer(堆栈指针寄存器)
其中 AX、BX、CX、DX 虽然是 16 位寄存器,但也可以各自拆成两个 8 位寄存器使用 [1],因为这四个通用寄存器本来就是两个独立的 8 位寄存器拼起来得到的。两个 8 位存储器中的 “H” 或 “L” 分别表示这个寄存器处于内存中的高位或低位。
如果 AX=0011000000111001b
,则 AH=00110000b
,AL=00111001b
。因此,当修改任一 8 位寄存器时,16 位寄存器也会更新,反之亦然。
因此,像 AX
这样的寄存器也称为通用字寄存器 (general purpose word register),像 AH/AL
这样的寄存器也称为通用字节寄存器 (general purpose byte register)。
2.2 段寄存器
8086 CPU 有四个段寄存器:
- CS - 代码段寄存器,指向包含当前程序的段地址。
- DS - 数据段寄存器,通常指向包含变量定义的段地址。
- ES - 附加段寄存器,其使用由程序员定义。
- SS - 堆栈段寄存器,指向包含堆栈的段地址。
总结起来,段寄存器都有一个目的 —— 指向可访问的内存块。
尽管可以在段寄存器中存储数据,但这从来不是一个好主意。
段寄存器与通用寄存器协同工作以访问任意内存值。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 位寄存器进行内存寻址。
2.3 特殊用途寄存器
- IP - 指令指针 (the Instruction Pointer)
- FLAGS - 标志寄存器,用于确定微处理器的当前状态。分为 FLAGSH/FLAGSL
IP 寄存器始终与 CS 段寄存器协同工作,并指向当前正在执行的指令(内存位置)。
标志寄存器在数学运算后由 CPU 自动修改,用于确定结果类型,并确定将控制权转移到程序其他部分的条件。
标志寄存器是按位起作用的,也就是说,它的每一位都有专门的含义,记录特定的信息。
8086 CPU 标志寄存器的结构:
1 | 15 14 13 12 11 10 09 08 07 06 05 04 03 02 01 00 |
12 - 15 位不是没有对应的标志,只是在 8086 上数值固定,对于一般开发者没有用处。参考 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 中常见汇编知识
Source: Quick Start:汇编语言 - Hello CTF 和 [调试逆向] 逆向基础笔记六 汇编跳转和比较指令 - 52pojie
这些知识不仅限于 ASM86,有些可能是后继架构的东西。
了解了一些基本的汇编指令后,下面我们来看看 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 的影响。

左侧是编辑器,右侧是调试器,可以看出还是很完善的。
在学习 Late binding 时发现一个很好的在线编译器平台 Compiler Explorer
该平台提供了丰富的编译器选择,有助于学习和比较编译器对源码的编译方式。