汇编语言:数据处理的位置与长度


无论指令如何编写,归根结底都在解决数据在哪以及数据多大这两个问题。

寻址寄存器的限制

用于内存寻址的寄存器

8086CPU 对内存寻址可用的寄存器有严格限制,只有 BX、SI、DI、BP 这四个可以用于 [...] 中。这 4 个寄存器不能任意混搭,合法的搭配只有:BX+SIBX+DIBP+SIBP+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)的计算逻辑可以表示为:

\[EA = \text{Base} + \text{Index} + \text{Displacement} \]

为了实现这个加法,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 当前指令操作的是哪种长度。主要有三种判定方式:

  1. 通过寄存器名:寄存器名本身就隐含了数据长度。mov ax, 1 指令中 AX 是 16 位寄存器,所以是字操作;mov al, 1 指令中 AL 是 8 位寄存器,所以是字节操作。
  2. 通过指令默认属性:某些指令本身规定了操作长度,例如在 8086 中 push 和 pop 指令只进行字操作,无需额外指明。
  3. 通过操作符 X ptr:当指令中没有使用寄存器,例如直接操作内存单元 [bx],则需要显式使用 byte ptrword 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
image

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

实现结构化数据寻址

通过综合运用寻址方式,可以把内存中的字节看成是有逻辑的结构体。考虑修改 DEC 公司年报的任务,在 CPU 中内存 seg:60 开始的地方,只是一连串的十六进制数 44 45 43 4B 65 6E 20 4F...,没有“公司”、“排名”或“产品”这些概念。如果不进行组织,要修改“排名”就需要把 seg:6C 的值改成 38;要修改产品要把 seg:70seg:71seg: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 regdiv 内存单元,除数有 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

image

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

伪指令 dd

之前接触过的 db 用于定义字节,dw用于定义字,此处再引入一个伪指令 dd 用于定义 dword(Double Word,双字,32 位)。例如在如下程序中,我们使用 3 条伪指令分别定义了 3 个数据。通过 debug 可以看到 db 1 对应 1 字节 01dw 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

image

例如考虑如下一个用 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

image

dup 操作符

dup 操作符用于配合 db/dw/dd 进行数据批量定义,语法为 重复次数 dup (重复内容),例如db 3 dup ('ab') 等价于 db 'ababab'。dup 在定义大容量缓冲区(如栈、数组)时非常简洁,例如定义一个容量为 200 个字节的栈段的代码如下:

stack segment
  db 200 dup (0)
stack ends

实验 7

该程序中,data 段中存在三个数组:

  1. 年份数组:db '1975','1976'...,类型为字符串(ASCII 码),长度为 4 字节/年,总共 21 年 × 4 字节 = 84 字节(54H)。虽然是字符,但在内存中是连续的字节流。
  2. 收入数组:dd 16,22,382...,类型为双字(DWORD),长度为 4 字节/年,总量为 21 年 × 4 字节 = 84 字节(54H)。这部分为 32 位数据,读取时需要 DX:AX
  3. 雇员数组: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 进行调试的过程如下:
image
image
image
image
image

参考资料

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

posted @ 2026-08-11 02:04  乌漆WhiteMoon  阅读(3)  评论(0)    收藏  举报