汇编语言:数据标号和直接定址表
数据标号
之前的程序中使用的标号,如 s:、start: 等基本上只承担地址标识符的功能,用于告诉编译器偏移地址。至于这个地址对应的内存单元是字节、字还是双字,编译器并不知情,必须由程序员显式使用 byte ptr、word ptr 或 dword ptr 来指定。例如如下这几条指令,可见这种方式在频繁访问数据时会显得冗余且易错。
assume cs:code
code segment
a: db 1,2,3,4,5,6,7,8
b: dw 0
start:
mov si, offset a
mov bx, offset b
mov cx, 8
s: mov al, cs:[si] ; 需手动指定段前缀和索引
mov ah, 0
add cs:[bx], ax ; 需手动指定段前缀和长度
inc si
loop s
mov ax, 4c00h
int 21h
code ends
end start
与仅表示地址的地址标号不同,数据标号在定义时不仅确定了内存单元的偏移地址,还隐式定义了该单元的长度属性,如字节、字或双字。这种标号可以令编译器能够在编译阶段自动推断操作数的尺寸,从而生成正确的机器码。例如以下程序在 code 段中使用的标号 a、b 后面没有 “:”,此时它们是同时描述内存地址和单元长度的标号。
assume cs:code
code segment
a db 1,2,3,4,5,6,7,8
b dw 0
start:
mov si, 0
mov cx, 8
s: mov al, a[si] ; 编译器自动推断 a 为字节单元
mov ah, 0
add b, ax ; 编译器自动推断 b 为字单元
inc si
loop s
mov ax, 4c00h
int 21h
code ends
end start
数据标号 a 和 b 将地址与长度信息封装为一个逻辑整体。当 a 定义为 db 时,它不仅描述了地址 code:0,还表示从这个地址开始以后的内存单元都是字节单元,任何引用 a 的指令(如 a[si]、a[3])都会被编译器解释为对字节单元的访问;同理,b 定义为 dw 时,它不仅描述了地址 code:8,还表示从这个地址开始以后的内存单元都是字单元,所有对 b 的操作都会被解释为字操作。如果试图将字数据送入字节寄存器(如 mov al,b),编译器会直接报错,避免了运行时错误。

考虑一个将将 a 处的 8 个字数据累加,结果存储到 b 处的双字中的程序。这里两个数据标号指向的数据长度的不匹配,a 是字数组,每次读取一个字到 AX;b 是双字,必须拆分成高字和低字处理。因此 add b,ax 是错误的,因为 b 是双字而 ax 是字,且双字加法需要考虑进位。所以需要先用 add 加低字,再用 adc 加高字,同时处理来自低字的进位。由于 b 是双字,初始化为 0,第一次累加时 DX 应为 0。但在循环中,每次累加后都需要更新 b 的高字和低字。
assume cs:code
code segment
a dw 1,2,3,4,5,6,7,8
b dd 0
start:
mov si, 0
mov cx, 8
s: mov ax, a[si]
add word ptr b[0], ax ; 累加低字
adc word ptr b[2], 0 ; 累加高字
add si, 2
loop s
mov ax, 4c00h
int 21h
code ends
end start
程序执行后,b 处的双字值为 00000024H,即十进制 36。

数据标号的概念在现代高级语言中极为常见,如 C 语言中的数组名和变量名。在汇编语言层面,通过让标号携带长度信息,程序员可以将更多的精力集中在算法逻辑上,是后续构建复杂数据结构(如结构体、对象)的基础。
标号的跨段使用
通常来说,数据应当集中定义在数据段中,以保持程序结构的清晰。形如 s: 的带冒号的地址标号仅能在代码段中使用,如 a db 1,2... 的数据标号则可以定义在任何段中。当在代码段中引用定义在其他段的数据标号时,需要通过 assume 伪指令建立段寄存器与目标段的逻辑关联。
例如以下程序中,assume ds:data 的作用是告知编译器:在后续编译中,涉及 a、b 等数据标号的指令的默认段地址是 ds 寄存器。编译器据此将 mov al,a[si] 编译为 mov al,[si+0],将 add b,ax 编译为 add [8],ax。
assume cs:code, ds:data
data segment
a db 1,2,3,4,5,6,7,8
b dw 0
data ends
code segment
start:
mov ax, data
mov ds, ax
mov si, 0
mov cx, 8
s: mov al, a[si] ; 编译器自动推断 a 为字节单元
mov ah, 0
add b, ax ; 编译器自动推断 b 为字单元
inc si
loop s
mov ax, 4c00h
int 21h
code ends
end start
其中 assume 仅是编译期的指示,并不生成任何机器码。它告诉编译器用到 data 段里的标号时需要再 ds 里找段地址,但 ds 中到底有没有正确的段地址,编译器并不负责。因此,程序中必须包含 mov ax,data 和 mov ds,ax 这两条指令来完成段寄存器的初始化。

数据标号除了用于访问内存,其本身的值(偏移地址和段地址)也可以作为一种特殊的数据被存储和操作。例如以下指令在数据段中定义 c dw a, b 时,标号 a 和 b 不再仅仅代表内存单元,它们的偏移地址被当作立即数存入了 c 处,等价于 c dw offset a, offset b。
data segment
a db 1,2,3,4,5,6,7,8
b dw 0
c dw a,b
data ends
更进一步,如下指令中 c dd a, b 会存储两个双字,分别是 a 的偏移地址与段地址、以及 b 的偏移地址与段地址。等价于 c dw offset a, seg a, offset b, seg b,其中 seg 操作符可以获取标号的段地址,因此 。
data segment
a db 1,2,3,4,5,6,7,8
b dw 0
c dd a,b
data ends
这使得程序的编写更为灵活,我们可以将一组函数的入口地址、或一组数据的起始地址组织成一张表(即定址表),通过查表而非硬编码的方式调用函数或访问数据。不仅能减少了条件判断语句,如大量的 cmp 和 je,还使得程序结构更加规整、易于扩展。
将标号作为数据存储的思想,在现代计算机体系结构中演化为指针(Pointer)和虚函数表(vtable)。在 C 语言中,函数名本身就是指向代码段的指针;在 C++ 中,虚函数表本质上就是一张存储了函数入口地址的数组。8086 汇编中的
offset和seg操作符,则是这些高级语言特性的底层实现原型。
直接定址表
直接定址表指的是依据数据,直接计算出所要找的元素的位置的表。考虑一个以十六进制形式在屏幕中间显示给定的字节型数据的程序。一个字节需要用两个十六进制数码来表示,最朴素的想法是使用一连串的比较转移指令。例如 cmp 和 je 来处理 0~15 到 “0”~“F” 字符的映射。这种方法逻辑直接,但会导致程序冗长、结构混乱且难以维护。虽然也可以利用 ASCII 码的数学规律,0~9 在 ASCII 码上加 30H,A~F 加 37H。虽然可以在一定程度上精简代码,但仍需引入条件判断(cmp al, 9 / ja)来区分两种计算规则。
较好的方式是建立一张字符映射表,在数据段定义中 table db '0123456789ABCDEF',即令数值 N 即为表中第 N 个字符的偏移量。编写的 showbyte 子程序如下所示,首先通过移位和掩码操作,将 AL 中的字节型数据拆分为高 4 位(AH)和低 4 位(AL)。接着利用 mov ah, table[bx] 指令,以拆分后的值作为偏移量直接访问字符表,最后将查得的 ASCII 字符直接写入显存。
assume cs:code
code segment
start:
mov al, 5ah ; 设置待显示的字节
call showbyte
mov ax, 4c00h
int 21h
; 在屏幕中间显示 AL 中的字节数据的十六进制形式
; AL = 待显示字节
showbyte:
jmp short show
table db '0123456789ABCDEF' ; 映射表
show:
push bx
push es
mov ah, al
shr ah, 1
shr ah, 1
shr ah, 1
shr ah, 1 ; AH = 高4位值
and al, 00001111b ; AL = 低4位值
; 处理高4位
mov bl, ah
mov bh, 0
mov ah, table[bx] ; 查表
mov bx, 0b800h
mov es, bx
mov es:[160*12+39*2], ah
mov byte ptr es:[160*12+39*2+1], 02h
; 处理低4位
mov bl, al
mov bh, 0
mov al, table[bx] ; 查表
mov es:[160*12+39*2+2], al
mov byte ptr es:[160*12+39*2+3], 02h
pop es
pop bx
ret
code ends
end start
该程序运行后,将在屏幕的第 12 行,第 39 列显示绿色的 5A。

直接定址表的思想体现在计算机科学的各个层面。在操作系统内核中,用户程序提供一个系统调用号(索引),CPU 通过查系统调用表(System Call Table) 直接跳转到对应的内核处理函数。在编译原理中,跳转表(Jump Table) 是
switch-case语句在底层的高效实现形式。在游戏开发中,状态机、物品属性表等也广泛采用这种设计。
程序入口地址的定址表
直接定址表可以将子程序入口地址作为表项,从而方便地实现的子程序调用。考虑实现实现一个名为 setscreen 的多功能显示控制子程序,支持清屏、设置前景色、设置背景色和向上滚动一行四种操作。入口参数通过 ah 寄存器传递功能号(0~3),对于颜色设置功能,再通过 al 传递颜色值。
最直接的实现方式是使用一系列比较转移指令依次判断 ah 的值,跳转到对应的功能子程序。这种方法虽然直观,但程序流程被大量的 cmp 和 je 指令打断,可读性差。更重要的是,当需要新增功能时,必须修改核心调度逻辑,插入新的比较分支,违反了软件工程的开放—封闭原则。
setscreen:
cmp ah, 0
je do1
cmp ah, 1
je do2
cmp ah, 2
je do3
cmp ah, 3
je do4
jmp short sret
do1: call sub1
jmp short sret
do2: call sub2
jmp short sret
...
更优的解决方案利用直接定址表,将四个功能子程序 sub1、sub2、sub3、sub4 的偏移地址依次存储在 table 中,使其索引与功能号建立映射关系。由于每个地址在 8086 中是 16 位字,因此功能号 ah 到表偏移的计算公式为:偏移 = ah × 2。通过 call word ptr table[bx] 指令,可以直接调用目标子程序。
setscreen:
jmp short set
table dw sub1, sub2, sub3, sub4
set:
push bx
cmp ah, 3
ja sret ; 功能号越界检查
mov bl, ah
mov bh, 0
add bx, bx ; 功能号 × 2 = 地址表偏移
call word ptr table[bx]
sret:
pop bx
ret
四个功能子程序将对显存 B800:0000 至 B800:0F9F 区域操作实现各自功能。清屏(sub1)遍历显存中 2000 个字符位置(25×80),将偶地址的 ASCII 码字节设为空格符 20H,保留奇地址的属性字节不变。
; 功能 0:清屏,将显存字符设为空格
sub1:
push bx
push cx
push es
mov bx, 0b800h
mov es, bx
mov bx, 0
mov cx, 2000
sub1s:
mov byte ptr es:[bx], ' '
add bx, 2
loop sub1s
pop es
pop cx
pop bx
ret
使用 mov ah, 0 和 call setscreen 指令进行测试,效果图下图所示。

设置前景色(sub2)功能遍历显存中 2000 个属性字节(奇地址),通过 or es:[bx], al 将 al 的低 3 位(0~2 位)写入属性字节的低 3 位,同时保持高 5 位(背景色与闪烁位)不变。
; 功能1:设置前景色,修改属性字节第0、1、2位
sub2:
push bx
push cx
push es
mov bx, 0b800h
mov es, bx
mov bx, 1
mov cx, 2000
sub2s:
and byte ptr es:[bx], 11111000b
or byte ptr es:[bx], al
add bx, 2
loop sub2s
pop es
pop cx
pop bx
ret
使用 mov ah, 1、mov al, 02h 和 call setscreen 指令进行测试,效果图下图所示。

设置背景色(sub3)功能与前景色类似,但需先将 al 的颜色值左移 4 位(shl al, cl),使其对应属性字节的第 4~6 位,再通过 or 操作写入,并保留低 4 位不变。
; 功能2:设置背景色,修改属性字节第4、5、6位
sub3:
push bx
push cx
push es
mov cl, 4
shl al, cl
mov bx, 0b800h
mov es, bx
mov bx, 1
mov cx, 2000
sub3s:
and byte ptr es:[bx], 10001111b
or byte ptr es:[bx], al
add bx, 2
loop sub3s
pop es
pop cx
pop bx
ret
使用 mov ah, 2、mov al, 01h 和 call setscreen 指令进行测试,效果图下图所示。

向上滚动一行(sub4)功能主要利用了串传送指令 rep movsb,将第 2 行至第 25 行的共 24 行数据复制到第 1 行起始处。随后清空最后一行(第 25 行),将其所有字符设为空格符。
; 功能3:向上滚动一行
sub4:
push cx
push si
push di
push ds
push es
mov si, 0b800h
mov ds, si
mov es, si
mov si, 160 ; 第 n+1 行
mov di, 0 ; 第 n 行
cld
mov cx, 24
sub4s:
push cx
mov cx, 160
rep movsb ; 复制一行
pop cx
loop sub4s
; 清空最后一行
mov cx, 80
mov si, 0
sub4s1:
mov byte ptr [160*24+si], ' '
add si, 2
loop sub4s1
pop es
pop ds
pop di
pop si
pop cx
ret
lp7cend:
nop
使用 mov ah, 3 和 call setscreen 指令进行测试,效果图下图所示。

在现代软件架构中,用数据(表)驱动行为(函数调用)的思想已有多种成熟的模式。虚函数表(vtable) 是面向对象语言中多态的实现基础,事件监听器表 是 GUI 框架响应用户输入的机制,微内核操作系统中的消息分发机制 也依赖于类似的查表逻辑。
实验 16
实验目标是安装一个新的 int 7ch 中断例程,向用户程序提供一组屏幕控制功能:清屏、设置前景色、设置背景色以及向上滚动一行。功能号通过 ah 寄存器传递,颜色值通过 al 寄存器传递。实验的难点在于中断例程的正确安装,由于中断例程运行在独立的上下文中,CS 指向中断向量指向的段。因此需要解决两个问题:一是子程序入口地址在直接定址表中的正确计算;二是中断向量表中段地址与偏移地址的正确设置。
直接定址表中的偏移地址是相对的,在 table dw sub1, sub2, sub3, sub4 中,sub1 等标号代表的是相对于当前代码段(CS)的偏移地址。如果将这段中断代码放在 start 标号之后,或者将其作为普通子程序调用,这些偏移地址都是相对于安装程序代码段的。然而,当中断例程被复制到 0:200H 处执行时,它的 CS 变成了 20H。此时如果 table 中的地址仍然是相对于安装程序计算的,CPU 就会跳转到错误的地址。因此需要将中断例程的代码放在 start 标号之前(或独立的段中),确保汇编器在编译时,是根据中断例程自身的起始位置来计算 sub1 等的偏移量。此时无论这段代码被移动到内存的哪个位置,只要 CS 正确,直接定址表就能正常工作。
中断向量表中,每个表项占 4 个字节,低字是 IP(偏移地址),高字是 CS(段地址)。代码被安装在物理地址 0:200H,其逻辑地址为 20H:0H。因此 IP 应设为 0,CS 应设为 20H。一种常见错误是将 CS 设为 0、IP 设为 200H,这会导致 CPU 在响应中断时以 0:200H 作为段地址,跳转到 0:200H * 10H 的物理地址,这显然是错误的。
assume cs:code
code segment
; ==================== 直接定址表方式入口 ====================
setscreen:
jmp short set
table dw sub1, sub2, sub3, sub4
set:
push bx
cmp ah, 3
ja sret ; 功能号 > 3,直接返回
mov bl, ah
mov bh, 0
add bx, bx ; 功能号 × 2 = 表内偏移
call word ptr table[bx]
sret:
pop bx
iret
; 功能 0:清屏,将显存字符设为空格
sub1:
push bx
push cx
push es
mov bx, 0b800h
mov es, bx
mov bx, 0
mov cx, 2000
sub1s:
mov byte ptr es:[bx], ' '
add bx, 2
loop sub1s
pop es
pop cx
pop bx
ret
; 功能1:设置前景色,修改属性字节第0、1、2位
sub2:
push bx
push cx
push es
mov bx, 0b800h
mov es, bx
mov bx, 1
mov cx, 2000
sub2s:
and byte ptr es:[bx], 11111000b
or byte ptr es:[bx], al
add bx, 2
loop sub2s
pop es
pop cx
pop bx
ret
; 功能2:设置背景色,修改属性字节第4、5、6位
sub3:
push bx
push cx
push es
mov cl, 4
shl al, cl
mov bx, 0b800h
mov es, bx
mov bx, 1
mov cx, 2000
sub3s:
and byte ptr es:[bx], 10001111b
or byte ptr es:[bx], al
add bx, 2
loop sub3s
pop es
pop cx
pop bx
ret
; 功能3:向上滚动一行
sub4:
push cx
push si
push di
push ds
push es
mov si, 0b800h
mov ds, si
mov es, si
mov si, 160 ; 第n+1行
mov di, 0 ; 第n行
cld
mov cx, 24
sub4s:
push cx
mov cx, 160
rep movsb ; 复制一行
pop cx
loop sub4s
; 清空最后一行
mov cx, 80
mov si, 0
sub4s1:
mov byte ptr [160*24+si], ' '
add si, 2
loop sub4s1
pop es
pop ds
pop di
pop si
pop cx
ret
lp7cend:
nop
start:
; 安装 7Ch 中断例程
mov ax, cs
mov ds, ax
mov si, offset setscreen
mov ax, 0
mov es, ax
mov di, 200h
mov cx, offset lp7cend - offset setscreen
cld
rep movsb
; 设置中断向量表
mov ax, 0
mov es, ax
cli
mov word ptr es:[7ch*4], 0
mov word ptr es:[7ch*4+2], 20h
sti
; 测试中断例程功能
mov ah, 1
mov al, 02h
int 7ch
mov ah, 2
mov al, 01h
int 7ch
mov ah, 3
int 7ch
mov ax, 4c00h
int 21h
code ends
end start
程序安装完成后,通过 int 7ch 指令进行测试:设置前景色为绿色、设置背景色为蓝色、令屏幕内容向上滚动一行。执行后,屏幕显示效果会按照调用顺序发生变化。

现代操作系统中的系统调用(System Call)机制与本实验相似,用户程序通过一条特殊的指令(如
int 0x80或syscall)陷入内核,内核根据传递的功能号(系统调用号)在系统调用表中查找对应的内核函数地址并调用。对功能号的越界检查、直接定址表的使用,以及中断上下文的保护与恢复,都是现代操作系统内核编程的基础技能。
参考资料
《汇编语言(第四版)》,王爽 编著,清华大学出版社

浙公网安备 33010602011771号