汇编语言:数据处理的位置与长度
无论指令如何编写,归根结底都在解决数据在哪以及数据多大这两个问题。
寻址寄存器的限制
用于内存寻址的寄存器
8086CPU 对内存寻址可用的寄存器有严格限制,只有 BX、SI、DI、BP 这四个可以用于 [...] 中。这 4 个寄存器不能任意混搭,合法的搭配只有:BX+SI、BX+DI、BP+SI、BP+DI。例如 [bx+bp] 是两个基址寄存器的冲突,[si+di] 是两个变址寄存器的冲突。
BP(Base Pointer) 主要用作栈指针,当使用 [bp] 且未显式指定段前缀时,CPU 默认段地址在 SS(栈段)而非 DS。即 [bx+idata] 默认段地址在 DS,用于访问普通数据;[bp+idata]
默认段地址在 SS,用于访问栈内局部变量。
使用限制的原因
这 4 个寄存器的合法搭配之有 4 种的原因是:8086 芯片内部的电路设计就是这样物理连接的,它没有提供其他组合的解码路径。
从硬件的角度看,CPU 要计算内存地址需要一个叫地址合成器(Address Adder) 的硬件部件。它的任务是把段寄存器左移 4 位,再加上偏移地址。在 8086 中,偏移地址(Effective Address, EA)的计算逻辑可以表示为:
为了实现这个加法,CPU 内部需要物理线路连接寄存器到这个加法器。Base(基址)候选只有 BX 和 BP,Index(变址)候选只有 SI 和 DI。而如果还要增加一条内部连线,在芯片制造上就是增加成本、增加复杂度、增加发热。Intel 的设计者们觉得,这 4 个寄存器的 4 种组合已经足够应付当时所有的编程需求,没必要为了理论上的完备性去增加硬件成本。
另一个原因是指令格式的字节限制,在机器码层面,使用寄存器间接寻址时需要一个 ModR/M 字节来告诉 CPU 接下来的操作数要用的寄存器。在 8086 的设计中,ModR/M 字节的某些位组合被预留给了这 4 种组合。如果要支持 [bx+bp],就需要额外的位组合来表示它,这会挤占其他指令的编码空间,或者迫使指令变长。为了保持指令尽可能短小(大多是单字节或双字节),Intel 选择了牺牲灵活性来保证效率。
从逻辑上讲,这些寄存器的都用通常的角色。BX 和 BP 都是基址寄存器,如果同时用两个基址则无法令 CPU 决定以哪个段为基础。BP 默认指向 SS,BX 默认指向 DS。如果允许 [bx+bp] 则段地址说不清楚默认用 DS 还是 SS,所以硬件设计上直接禁止了这种组合。SI 和 DI 都是变址寄存器,它们通常用于字符串操作和数组遍历,逻辑上是平行的。如果允许 [si+di],虽然数学上没问题,但违背了它们实现索引偏移的定位。而且既然可以用 [bx+si],何必再浪费硬件去支持 [si+di]。
| 寄存器 | 通常的用途 |
|---|---|
| BX | 数据指针 (Data Pointer) |
| BP | 栈帧指针(Frame Pointer)。 |
| SI | 源索引(Source Index) |
| DI | 目的索引(Destination Index) |
因此在 8086 的时代,这 4 种组合足以表达当时常见的数据结构,但是也限制了编程的灵活性。这种限制在后来的 CPU 中被打破了,在 80386 及以后的 32 位处理器中,几乎所有的通用寄存器(EAX, EBX, ECX...)都可以作为基址或变址寄存器,并且支持 Scale(比例因子)。
寻址方式总结
指令执行前,数据存在于以下三个地方之一:
| 数据来源组件 | 特点 |
|---|---|
| CPU 内部(寄存器) | 速度最快 |
| 内存 | 速度次之,需要寻址 |
| 端口 | 与外设通信 |
汇编语言通过三种方式来告知 CPU 数据的位置:
| 汇编中定位数据的方式 | 说明 |
|---|---|
| 立即数 (idata) | 数据直接包含在指令中,如 mov ax, 1,在 CPU 读取指令时就被加载到指令缓冲器。 |
| 寄存器 | 数据在指定寄存器中,如 mov ax, bx。 |
| 内存地址 (SA:EA) | 数据在内存中,需要给出偏移地址(EA),段地址(SA)可以默认(DS 或 SS),也可以显式指定(如 es:[bx])。 |
寻址方式的总结如下表所示:
| 寻址方式分类 | 含义描述 | 常用格式示例 | 典型应用场景 |
|---|---|---|---|
| 直接寻址 | EA = idata SA = DS |
[idata] |
访问单个全局变量 |
| 寄存器间接寻址 | EA = (bx/si/di) SA = DS EA = (bp) SA = SS |
[bx][bp] |
访问一维数组(指针) |
| 寄存器相对寻址 | EA = (bx/si/di) + idata SA = DS EA = (bp) + idata SA = SS |
[bx+idata]idata[si][bp].idata |
访问结构体字段或一维数组(带偏移) |
| 基址变址寻址 | EA = (bx/bp) + (si/di) SA = DS/SS |
[bx+si][bp+di] |
访问二维数组(双指针) |
| 相对基址变址寻址 | EA = (bx/bp) + (si/di) + idata SA = DS/SS |
[bx+si+idata]idata[bx][si] |
访问结构体数组或复杂嵌套数据 |
这种分类在现代汇编器(如 NASM 或 GAS)中依然通用,只是语法更统一。现代 x86-64 架构引入了 RIP 相对寻址(RIP-Relative Addressing),即指令可以使用当前程序计数器(RIP)作为基址,在位置无关代码(PIC,如 Linux 下的 .so 动态库)中很重要,但是 8086 由于没有保护模式和复杂的链接器支持,必须手动管理段地址。
数据的长度
8086CPU 只能处理 byte(字节)和 word(字) 两种长度的数据,汇编器需要通过某种方式告知 CPU 当前指令操作的是哪种长度。主要有三种判定方式:
- 通过寄存器名:寄存器名本身就隐含了数据长度。
mov ax, 1指令中 AX 是 16 位寄存器,所以是字操作;mov al, 1指令中 AL 是 8 位寄存器,所以是字节操作。 - 通过指令默认属性:某些指令本身规定了操作长度,例如在 8086 中 push 和 pop 指令只进行字操作,无需额外指明。
- 通过操作符
X ptr:当指令中没有使用寄存器,例如直接操作内存单元[bx],则需要显式使用byte ptr或word ptr来指明长度。mov word ptr [bx], 1表示 CPU 访问的是一个字(2 字节);mov byte ptr [bx], 1:表示 CPU 访问的是一个字节。
假设 2000:1000 FF FF。执行 mov byte ptr [1000H], 1 后,因为只改低字节所以内存变为 2000:1000 01 FF。

执行 mov word ptr [1000H], 1 后,因为修改两个字节所以变为 2000:1000 01 00。

实现结构化数据寻址
通过综合运用寻址方式,可以把内存中的字节看成是有逻辑的结构体。考虑修改 DEC 公司年报的任务,在 CPU 中内存 seg:60 开始的地方,只是一连串的十六进制数 44 45 43 4B 65 6E 20 4F...,没有“公司”、“排名”或“产品”这些概念。如果不进行组织,要修改“排名”就需要把 seg:6C 的值改成 38;要修改产品要把 seg:70、seg:71、seg:72 改成 ‘V’,‘A’,‘X’”。不仅容易出错,而且难以维护。
因此需要引入基地址(BX)来定位整个结构体,这个案例中的数据的起始地址是 seg:60,因此将 60h 放入 BX 寄存器作为结构体基址(Base Address)。此时对于 [bx] 进行访问,就相当于是在访问 DEC 公司的记录。
mov bx, 60h
接着引入常量偏移(idata)来定位字段。“排名”位于 seg:6C,相对于基地址 60h 的偏移量是 0Ch;“收入”位于 seg:6E,相对于基地址 60h 的偏移量是 0Eh;“产品”位于 seg:70,相对于基地址 60h 的偏移量是 10h。此时使用 [bx+idata],就可以用 [bx+0Ch] 定位到“排名字段”,[bx+0Eh] 定位到“收入字段”。此时修改排名的逻辑就变成了向 DEC 结构体的排名成员写数据,代码如下:
mov word ptr [bx+0Ch], 38
接着引入变址寄存器(SI)来遍历数组。“产品”字段是一个字符串 "PDP",即一个字符数组。如果需要逐个修改这三个字符,挨个赋值看上去很不灵活:
mov byte ptr [bx+10h], 'V'
mov byte ptr [bx+11h], 'A'
mov byte ptr [bx+12h], 'X'
但如果将 SI 设为 0,然后通过 [bx+10h+si] 来访问,就能实现索引(Index)的效果,此时 SI 的作用就是遍历数组的下标。
mov si, 0
mov byte ptr [bx+10h+si], 'V'
inc si
mov byte ptr [bx+10h+si], 'A'
inc si
mov byte ptr [bx+10h+si], 'X'
为了进一步增强可读性,汇编编译器允许像 C 语言一样书写地址表达式。例如 [bx+0Ch] 和 [bx].0Ch 是等价的,[bx+10h+si] 和 [bx].10h[si] 是等价的。这个程序中的汇编语言和 C 语言的对应关系如下:
| 汇编 | C 语言 |
|---|---|
| BX | 结构体指针 dec |
| 0Ch | 结构体成员偏移 pm |
| SI | 数组下标 i |
div 指令
div 指令的格式为 div reg 或 div 内存单元,除数有 8 位和 16 位两种,在一个 reg 或内存单元中。被除数涉及 AX、DX 两个寄存器的配合,其计算方式有两种:
| div 指令计算方式 | 说明 |
|---|---|
| 8 位除数 | 被除数默认在 AX 中,16 位。计算结果的商在 AL,余数在 AH。 |
| 16 位除数 | 被除数默认在 DX:AX 中,32 位,DX 存高 16 位,AX 存低 16 位。计算结果的商在 AX,余数在 DX。 |
例如计算 100001/100 时,100001 (186A1H) 超过 AX 的最大值 65535,所以必须使用 16 位除法。100001 的高 16 位是 0001H,低 16 位是 86A1H。因此指令为:
mov dx, 1
mov ax, 86A1H
mov bx, 100
div bx

计算 1001/100 时,由于 1001 在 AX 范围内,使用 div bl 进行 8 位除法即可(注意此时 dx 需要置为 0)。

伪指令 dd
之前接触过的 db 用于定义字节,dw用于定义字,此处再引入一个伪指令 dd 用于定义 dword(Double Word,双字,32 位)。例如在如下程序中,我们使用 3 条伪指令分别定义了 3 个数据。通过 debug 可以看到 db 1 对应 1 字节 01,dw 1 对应 2 字节 01 00, dd 1 对应 4 字节 01 00 00 00。
assume cs:code, ds:data
data segment
db 1
dw 1
dd 1
data ends
code segment
start:
mov ax, data
mov ds, ax
mov ax, 4c00h
int 21h
code ends
end start

例如考虑如下一个用 div 计算 data 段中第一个数据除以第二个数据后的结果,商存在第三个数据的存储单元中的程序。需要从内存中读取一个 dd 定义的数据进行 32 位除法,由于 8086 是 16 位 CPU,无法直接读取 32 位,因此需要先用 mov ax, ds:[0] 读低 16 位,再用 mov dx, ds:[2]读高 16 位。
assume cs:code, ds:data
data segment
dd 100001
dw 100
dw 0
data ends
code segment
start:
mov ax, data
mov ds,ax
mov ax,ds:[0]
mov dx,ds:[2]
div word ptr ds:[4]
mov ds:[6],ax
mov ax, 4c00h
int 21h
code ends
end start

dup 操作符
dup 操作符用于配合 db/dw/dd 进行数据批量定义,语法为 重复次数 dup (重复内容),例如db 3 dup ('ab') 等价于 db 'ababab'。dup 在定义大容量缓冲区(如栈、数组)时非常简洁,例如定义一个容量为 200 个字节的栈段的代码如下:
stack segment
db 200 dup (0)
stack ends
实验 7
该程序中,data 段中存在三个数组:
- 年份数组:
db '1975','1976'...,类型为字符串(ASCII 码),长度为 4 字节/年,总共 21 年 × 4 字节 = 84 字节(54H)。虽然是字符,但在内存中是连续的字节流。 - 收入数组:
dd 16,22,382...,类型为双字(DWORD),长度为 4 字节/年,总量为 21 年 × 4 字节 = 84 字节(54H)。这部分为 32 位数据,读取时需要DX:AX。 - 雇员数组:
dw 3,7,9...,类型为字(WORD),长度为 2 字节/年,总量为 21 年 × 2 字节 = 42 字节(2AH)。由于它们是 16 位数据,读取时只需AX。
data segment
db '1975','1976','1977','1978','1979','1980','1981','1982','1983'
db '1984','1985','1986','1987','1988','1989','1990','1991','1992'
db '1993','1994','1995'
dd 16,22,382,1356,2390,8000,16000,24486,50065,97479,140417,197514
dd 345980,590827,803530,1183000,1843000,2759000,3753000,4649000,5937000
dw 3,7,9,13,28,38,130,220,476,778,1001,1442,2258,2793,4037,5635,8226
dw 11542,14430,15257,17800
data ends
接着定义一个 table 段作为需要构建的数据表,它是一个包含 21 条记录的数组,每条记录(一行)固定 16 字节。
| 偏移 (idata) | 长度 | 内容 | 来源 |
|---|---|---|---|
| 00H ~ 03H | 4B | 年份 | 年份数组 |
| 04H ~ 07H | 4B | 收入 | 收入数组 |
| 08H ~ 09H | 2B | 雇员数 | 雇员数组 |
| 0AH ~ 0BH | 2B | 人均收入 | 需计算 |
| 0CH ~ 0FH | 4B | 空格填充 | - |
我以 bp 作为数据索引指针,bx 作为固定的行基址指针。共设置了 4 个循环,前 3 个循环分别将字符串、双字及字类型数据按偏移量写入 Table 段,第四个循环采用 div word ptr es:[bx+10] 的内存操作数形式取除数。完整程序如下:
assume cs:codes, ds:data, es:table
data segment
db '1975','1976','1977','1978','1979','1980','1981','1982','1983'
db '1984','1985','1986','1987','1988','1989','1990','1991','1992'
db '1993','1994','1995'
dd 16,22,382,1356,2390,8000,16000,24486,50065,97479,140417,197514
dd 345980,590827,803530,1183000,1843000,2759000,3753000,4649000,5937000
dw 3,7,9,13,28,38,130,220,476,778,1001,1442,2258,2793,4037,5635,8226
dw 11542,14430,15257,17800
data ends
table segment
db 21 dup ('year summ ne ?? ')
table ends
codes segment
start: mov ax, data
mov ds, ax
mov ax, table
mov es, ax
mov bp, 0
mov cx, 21
mov bx, 0
s0: mov ax, ds:[bp]
mov es:[bx], ax
mov ax, ds:[bp+2]
mov es:[bx].2, ax
add bp, 4
add bx, 16
loop s0
mov cx, 21
mov bx, 0
s1: mov ax, ds:[bp]
mov es:[bx].5, ax
mov ax, ds:[bp+2]
mov es:[bx].7, ax
add bp, 4
add bx, 16
loop s1
mov cx, 21
mov bx, 0
s2: mov ax, ds:[bp]
mov es:[bx].10, ax
add bp, 2
add bx, 16
loop s2
mov cx, 21
mov bx, 0
s3: mov ax, es:[bx].5
mov dx, es:[bx].7
div word ptr es:[bx].10
mov es:[bx].13, ax
add bx, 16
loop s3
mov ax, 4c00h
int 21h
codes ends
end start
使用 Debug 进行调试的过程如下:





参考资料
《汇编语言(第四版)》,王爽 编著,清华大学出版社

浙公网安备 33010602011771号