汇编语言:数据标号和直接定址表

数据标号

之前的程序中使用的标号,如 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),编译器会直接报错,避免了运行时错误。
image

考虑一个将将 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。
image

数据标号的概念在现代高级语言中极为常见,如 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 这两条指令来完成段寄存器的初始化。
image

数据标号除了用于访问内存,其本身的值(偏移地址和段地址)也可以作为一种特殊的数据被存储和操作。例如以下指令在数据段中定义 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。
image

直接定址表的思想体现在计算机科学的各个层面。在操作系统内核中,用户程序提供一个系统调用号(索引),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 指令进行测试,效果图下图所示。
image

设置前景色(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 指令进行测试,效果图下图所示。
image

设置背景色(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 指令进行测试,效果图下图所示。
image

向上滚动一行(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 指令进行测试,效果图下图所示。
image

在现代软件架构中,用数据(表)驱动行为(函数调用)的思想已有多种成熟的模式。虚函数表(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 指令进行测试:设置前景色为绿色、设置背景色为蓝色、令屏幕内容向上滚动一行。执行后,屏幕显示效果会按照调用顺序发生变化。
image

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

参考资料

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

posted @ 2026-08-25 18:12  乌漆WhiteMoon  阅读(11)  评论(0)    收藏  举报