汇编语言:存储器和寄存器
汇编语言是直接面向硬件的编程语言,其所有语法和编程都建立在计算机硬件结构之上,因此需要先理解 CPU、内存与总线之间的协作逻辑。
机器语言和汇编语言
机器语言
最早的计算机程序完全由机器语言编写,也就是一串串由 0 和 1 组成的二进制代码,这些代码对应着 CPU 能够直接识别的电平脉冲。由于每种微处理器的内部结构设计不同,它们各自拥有一套独立的机器指令集。直接使用二进制编码不仅难以记忆,而且极难排查错误。哪怕只是将一个 1 误写为 0,定位问题也会变得异常困难。无论是简单的算术运算,还是在屏幕输出字符串,对应的机器码都显得晦涩冗长。

汇编语言
为了解决这一问题,汇编语言应运而生。汇编语言的核心是汇编指令,它是机器指令的助记符。例如,将寄存器 BX 的内容复制到 AX 中,对应的机器码是 1000100111011000,而汇编指令则写作 mov ax, bx。这种写法更接近人类语言,降低了阅读和编写的难度。然而,CPU 只能识别机器码,因此需要编译器(实际上是汇编器和链接器)将汇编指令翻译成二进制机器码。汇编程序的工作流程为:程序员编写文本格式的源程序,经编译连接后生成可执行文件,最终由 CPU 执行。

汇编语言由三类指令共同构成:
| 汇编语言指令 | 说明 |
|---|---|
| 汇编指令 | 汇编语言的主体,每一条指令都有对应的机器码 |
| 伪指令 | 如 assume 和 end,它们没有对应的机器码,仅由编译器识别并执行相关操作,用于指导编译过程而非参与 CPU 运算 |
| 其他符号 | 如算术运算符 +、- 等,同样由编译器识别 |
一个重要概念是指令和数据在物理层面没有任何区别,即无论是存储在内存还是磁盘上,它们本质上都是二进制信息。CPU 根据当前的上下文逻辑,赋予这些二进制信息不同的意义。例如,二进制串 1000100111011000,既可以作为一个普通的数据 89D8H 参与运算,也可以被 CPU 解释为指令 mov ax, bx 加以执行。
存储器
主板与外设控制
在 PC 机内部,有一个核心载体叫作主板。CPU、存储器、芯片组和扩展插槽等关键部件都集成在主板上,并通过地址总线、数据总线和控制总线相互连接。值得注意的是,CPU 并不能直接控制显示器、打印机或音箱等外部设备。对于这些外设,CPU 通过总线向插在扩展插槽上的接口卡发送命令,再由接口卡根据 CPU 的指令直接驱动外设工作。例如,显卡负责控制显示器,网卡负责处理网络通信。该方式使 CPU 无需关心外设的具体电气特性,只需通过统一的总线接口发出控制指令即可。

存储器与总线
存储器(即内存)是计算机中仅次于 CPU 的核心部件。磁盘等外部存储设备中的数据,必须先加载到内存中,才能被 CPU 使用。

内存被划分为若干个存储单元,每个单元从 0 开始编号,通常一个存储单元存储 1 字节(8 位)的数据。微机存储器的容量通常以字节为基本单位,向上延伸为 KB、MB、GB 和 TB。CPU 对存储器的读写操作的过程如下:
- CPU 必须通过地址总线发出目标存储单元的地址,以确定操作对象;
- CPU 通过控制总线发出具体的控制信号,例如是读还是写;
- 通过数据总线传输实际的数据内容。
这一过程类似于寄信:地址总线决定了寄到哪里,控制总线决定了寄信还是收信,而数据总线则是承载信件的邮路。总线是连接 CPU 与其他芯片的物理导线集合,根据传输信息的类型,逻辑上分为三类:
| 总线类型 | 说明 |
|---|---|
| 地址总线 | 若 CPU 有 N 根地址总线,则其最多能寻找 \(2^N\) 个内存单元。其宽度决定了 CPU 的寻址能力,地址总线的数量限制了系统的物理内存上限。 |
| 数据总线 | 8086 CPU 拥有 16 根数据总线,可以一次性传送 16 位数据。数据总线越宽,单次传输的数据量越大,CPU 与外界的数据交换效率也就越高。 |
| 控制总线 | 反映 CPU 对外部器件的控制能力,是一系列不同控制信号的集合。例如读信号和写信号就是通过控制总线中的特定线路发出的。控制总线的根数越多,CPU 能够提供的控制手段就越丰富。 |
王爽版汇编所描述的 1MB 寻址空间、三总线分离结构以及直接操作硬件的方式,属于 8086 实模式的典型特征。在现代 x86 架构中,CPU 启动后会迅速切换到保护模式,采用分段与分页机制管理内存,应用程序无法直接访问物理地址或控制硬件总线,这需要通过操作系统内核和驱动程序来间接完成。

存储器类型
一台 PC 机中包含多种存储器芯片,它们在物理上是互相独立的器件。从读写属性上划分,主要分为两类:断电后数据丢失的随机存储器(RAM)和断电后数据不丢失的只读存储器(ROM)。从功能连接上看,这些存储器又承担着不同的角色:
| 存储器类型 | 说明 |
|---|---|
| 主随机存储器 | 用于存放运行时的程序和数据,通常由主板上的 RAM 和扩展插槽上的内存条共同组成。 |
| 存有 BIOS 的 ROM | BIOS 是基本输入输出系统,这类 ROM 不仅存在于主板上(系统 BIOS),也存在于显卡、网卡等接口卡上(如显卡 BIOS)。 |
BIOS 是厂商固化的基础输入输出程序,是硬件与软件之间的第一层桥梁。此外,某些接口卡上还装有专用的 RAM,最典型的就是显卡上的显存。显存用于存储即将显示的图像数据,显卡会持续不断地将显存中的比特信息刷新到显示器上。因此,只要 CPU 将数据写入显存对应的地址,屏幕上就会立刻呈现出相应的画面。

内存地址空间
尽管上述存储器在物理上是独立的,但它们在连接上有两点共性:
- 都通过总线与 CPU 相连;
- CPU 对它们进行读写时都发出内存读写命令。
基于这两点,CPU 在操作这些设备时,会将它们统一视为一个整体的、由若干存储单元组成的逻辑存储器,就是所谓的内存地址空间。CPU 并不关心某个数据是被写入了物理内存条,还是被写入了显存或网卡缓冲区,它只关注地址。在 8086 的逻辑视图中,所有的物理存储器被映射到一个线性的地址范围内,每个物理存储器占据其中的一段地址空间。例如,在典型的 8086 PC 机中,地址范围 00000~9FFFF 这 640KB 的空间被分配给主随机存储器(常规内存);A0000~BFFFF 这 128KB 的空间被分配给显存;而 C0000~FFFFF 这 256KB 的空间则被分配给各类 ROM(包括系统 BIOS 和显卡 BIOS 等)。

统一编址带来了一个重要的编程现象:向某些地址写入数据是无效的。由于 ROM 是只读的,如果尝试向 C0000~FFFFF 范围内的地址写入数据,数据不会被保存,原有的 BIOS 代码也不会被破坏。相反,如果我们希望控制显示器,就必须向 A0000~BFFFF 显存地址写入数据。
内存地址空间的大小受限于 CPU 地址总线的宽度。8086 CPU 的地址总线宽度为 20 位,因此其内存地址空间最大为 \(2^{20}\) 字节,即 1MB。同理,32 位的 80386 CPU 拥有 4GB 的地址空间。汇编语言编程时需要站在 CPU 的角度思考问题,即面对的不是杂乱无章的物理硬件,而是一个统一的、连续的内存地址空间。理解这个空间中哪些区域是可写的 RAM,哪些是只读的 ROM,哪些是映射到外设的端口。
8086 的这种将物理设备映射到内存地址的做法,被称为内存映射 I/O(MMIO)。在现代 x86 保护模式中,操作系统通过页表机制重新定义了地址空间,应用程序看到的“内存地址”通常是虚拟地址,不再直接对应物理地址或硬件寄存器。但在实模式(如 DOSBox 环境)下,物理地址与逻辑地址空间的对应关系依然如上所述。
寄存器
CPU 内部由运算器、控制器、寄存器和内部总线构成,对于汇编而言能直接通过指令读写的只有寄存器。例如 8086 CPU 的结构如下,内部采用并行工作的执行部件(EU)和总线接口部件(BIU)。BIU 包含段寄存器(CS、DS、SS、ES)、指令指针寄存器(IP)以及地址加法器,负责通过地址总线从内存中取指令并放入指令队列。EU 包含运算器、通用寄存器(AX~DX等)和标志寄存器,负责执行 BIU 取回的指令。

通用寄存器
8086 CPU 共有 14 个寄存器,这些寄存器均为 16 位,其中 AX、BX、CX、DX 被称为通用寄存器,通常用于存放一般性数据。

为了兼容上一代 8 位 CPU(如 8088),这四个 16 位寄存器都可以拆分为两个独立的 8 位寄存器使用:AX 可分为 AH(高 8 位)和 AL(低 8 位),BX、CX、DX 以此类推。

字节(Byte) 是 8 位,字(Word) u 是 16 位(即两个字节)。遵循惯例,在十六进制数后加 H(如 4E20H),二进制数后加 B,十进制数不加后缀。一个 16 位寄存器可以存放一个字(Word,2 字节)的数据。例如,数值 20000(十六进制为 4E20H)存入 AX 后,高 8 位 4E 位于 AH 中,低 8 位 20 位于 AL 中。此时,AH 和 AL 既可以合起来看作一个 16 位的数(4E20H),也可以分开看作两个独立的 8 位数(4EH 和 20H)。
MOV 与 ADD 指令
程序通过汇编指令指挥 CPU 工作,例如 MOV 和 ADD 两个指令。mov 指令用于数据传输,例如 mov ax, 18 是将数值 18 送入 AX 寄存器,mov ax, bx 则是将 BX 中的数据复制到 AX 中。add 指令用于加法运算,例如 add ax, 8 是将 AX 中的数值加上 8 结果存回 AX;add ax, bx 是将 AX 与 BX 中的数值相加,结果存于 AX。几个注意事项如下:
- 汇编指令不区分大小写,
MOV AX, 18与mov ax, 18等价; - 数据的存储是覆盖式的,当执行
mov bx, ax时,BX 中原有的值会被 AX 的值完全替换; - 加法运算可能导致进位,例如在
add ax, bx之后,如果结果超过了 16 位寄存器的最大容纳范围(FFFFH),超出的高位将被丢弃(严格来说,进位会存入标志寄存器中的 CF 位)。
在 DOSBox 中使用 Debug 时,寄存器窗口(R 命令)显示的正是这些通用寄存器的值。当修改 AL 的值时,AX 的低 8 位会随之改变,但高 8 位不变;反之,修改 AX 的值,AH 和 AL 会同时联动。
物理地址与分段机制
8086 CPU 是 16 位结构,其运算器一次最多能处理 16 位数据,寄存器的最大宽度为 16 位,寄存器与运算器之间的内部总线宽度也是 16 位。然而,8086 CPU 配备了 20 位的地址总线,理论寻址能力达到 1MB。如果直接将内部 16 位的地址发送到 20 位的地址总线上,CPU 最多只能访问 64KB(\(2^{16}\))的内存。
为此,8086 将两个 16 位的地址在 CPU 内部合成为一个 20 位的物理地址。具体而言,8086 CPU 在访问内存时,相关部件会提供两个 16 位的段地址和偏移地址。这两个地址通过内部总线送入地址加法器,按照如下公式计算得到 20 位物理地址。
物理地址 = 段地址 × 16 + 偏移地址
这里的“×16”在计算机中通常通过左移 4 位(二进制)来实现。十六进制的一位对应二进制的四位,所以段地址左移一位(十六进制视角)即完成了乘以 16 的操作。例如若段地址为 1230H,偏移地址为 00C8H,则物理地址为 12300H + 00C8H = 123C8H。

内存的分段管理
段的划分源于 CPU 的逻辑设计,内存本身在物理上是连续的并没有分段,我们可以人为地将地址连续的一组内存单元划分为一个段。由于段地址必须是 16 的倍数,所以每个段的起始物理地址也必定是 16 的倍数。同时,由于偏移地址是 16 位,其寻址范围为 0~FFFFH,这决定了一个段的最大长度为 64KB。
在描述内存地址时,汇编语言通常采用“段地址:偏移地址”的形式。例如 2000:1F60 表示段地址为 2000H、偏移地址为 1F60H,合成后的物理地址即为 21F60H。在编程时,可以根据需要将不同功能的代码或数据安排在不同的逻辑段中,但这仅仅是逻辑上的划分,物理内存始终是一维的线性空间。
注意,在现代 x86 处理器中,虽然为了保持兼容依然保留了分段机制,但操作系统(如 Windows、Linux)在保护模式下通常使用平坦内存模型(Flat Memory Model),即将段基址设为 0,段限长设为最大,从而使得逻辑地址直接映射为线性地址,简化了内存管理。
CS 和 IP 寄存器
8086 CPU 依靠段寄存器来提供物理地址计算所需的段地址,8086 共有四个段寄存器 CS、DS、SS、ES。其中,CS 是代码段寄存器,用于存放代码段的段地址,IP 则存放偏移地址,它们决定了 CPU 当前正在执行的指令位于内存的哪个位置。在 8086 PC 中,任意时刻 CPU 都会将 CS:IP 指向的内存单元中的内容视为指令来执行,物理地址的计算公式为:CS × 16 + IP。例如,当 CS=2000H,IP=0000H 时,CPU 会从物理地址 20000H 处读取指令。CPU 执行指令的流程是一个循环:
- 从 CS:IP 指向的内存单元读取指令,送入指令缓冲器;
- IP 自动增加,增量等于刚刚读取的指令的字节长度,从而指向下一条指令;
- 执行指令;
- 返回步骤 1。
因此内存中的指令和数据在物理上没有区别,只有被 CS:IP 指向的二进制信息,才被 CPU 解释为指令。例如开机启动时 8086 CPU 加电复位后,CS 被强制初始化为 FFFFH,IP 为 0000H。CPU 从 FFFF0H 这个物理地址开始执行,那里通常存放着 BIOS 的开机自检程序。
可以把 CPU 想象成一个极度死板的图书管理员,而 CS:IP 就是它手里拿着的当前书签。无论书架上放的是什么,只要书签(CS:IP)夹在那一行,管理员就把那一行当成指令读出来执行。假设我们通过 Debug 向内存中写入了如下数据:
| 内存地址 | 机器码 (Hex) | 对应汇编指令 |
|---|---|---|
| 2000:0000 | B8 23 01 | mov ax, 0123H |
| 2000:0003 | BB 03 00 | mov bx, 0003H |
| 2000:0006 | 01 D8 | add ax, bx |
CPU 的工作流程如下:
- 初始时手动设置
CS = 2000H、IP = 0000H。 - 物理地址为
2000H * 16 + 0000H = 20000H。CPU 走到20000H位置,看到了B8 23 01并将其读入指令缓冲器。因为这条指令长 3 个字节,IP 自动变为0000H + 3 = 0003H。CPU 执行mov ax, 0123H,此时AX = 0123H。 - 执行结束后
CS依然是2000H,但IP变成了0003H。现在 CS:IP 为2000:0003。CPU 直接在20003H看到了BB 03 00。读取指令后 IP 增加 3 字节变为0006H,mov bx, 0003H后BX = 0003H。
虽然 CPU 依靠 CS:IP 自动取指,但程序员需要在逻辑上对内存进行规划。我们可以将一段长度不超过 64KB、起始地址是 16 的倍数的连续内存单元定义为一个代码段,专门用来存放程序指令。例如一段包含 mov ax, 0000、add ax, 0123H 等指令的机器码,存放在 123B0H 到 123B9H 之间就可以认为这是一个代码段,段地址为 123BH。然而,仅仅把内存定义为代码段是不够的,CPU 不会因为你把数据写进了某个段就自动去执行它。要让这段代码运行,程序员必须手动将 CS 设置为 123BH,将 IP 设置为 0000H。
jmp 指令
程序的执行流由 CS:IP 控制,因此改变这两个寄存器的值就能改变程序的走向。mov 指令虽然能修改大多数通用寄存器,但不能用于修改 CS 和 IP。8086 提供了专门的转移指令来实现这一目的,其中最基础的就是 jmp。jmp 指令有两种常用形式:
| 作用 | jmp 指令形式 | 样例 |
|---|---|---|
| 同时修改 CS 和 IP | 使用 jmp 段地址:偏移地址 |
jmp 2AE3:3,执行后 CS=2AE3H,IP=0003H,程序将跳转到 2AE33H 处执行。 |
| 仅修改 IP | 使用 jmp 某一合法寄存器 |
jmp ax,执行后 IP 的值会被修改为 AX 中的值,这在逻辑上类似于 mov IP, ax。 |
假设在 2000:0006 处执行的是 jmp ax(机器码 FF E0),含义是把 AX 的值赋给 IP。执行后IP 不再自动加 2,而是直接变成 0123H。结果是 CPU 下一条读取的指令将是 2000:0123 处的代码,而不是紧随其后的 2000:0008。
参考资料
《汇编语言(第四版)》,王爽 编著,清华大学出版社
Wikipedia 主板
Wikipedia Intel 8086

浙公网安备 33010602011771号