汇编语言:直接向显存写数据

显示缓冲区

在 8086 的实模式内存中,B8000H~BFFFFH 这 32KB 的空间被映射为 80×25 彩色字符模式的显示缓冲区。向这块内存写入数据时不需要调用 BIOS 或 DOS 中断,数据会立即反映在显示器上。通常情况下系统只使用第 0 页,也就是 B8000H~B8F9FH 这 4000 字节。

整块缓冲区被组织成一个二维表:一共 25 行,编号 0~24;每行 80 个字符,每个字符占用 2 个字节,因此每一行占用 160 个字节;偶地址字节存放字符的 ASCII 码,奇地址字节存放 属性(颜色)。屏幕坐标 (行, 列) 与显存偏移地址的换算公式是:

偏移地址 = (行号 × 160) + (列号 × 2)

例如,第 12 行第 40 列,即屏幕中央附近的第一个字符位置是:

12 × 160 + 40 × 2 = 1E00H + 50H = 1E50H

因此在 B800:1E50 处写入一个 ASCII 码和一个属性字节,就能控制屏幕中央的字符显示。

属性字节格式

每个字符的样式由一个 8 位属性字节控制,它只决定颜色样式而不影响字符本身,字符是否显示、显示什么由 ASCII 码决定。

7    6 5 4    3 2 1 0
BL   R G B    I R G B
│    │ │ │    │ │ │ │
│    └─┬─┘    └─┬─┘
│     背景色      前景色
└────────────── 闪烁位

属性字节格式如下:

位 属性字节
位 0~2 前景(字符)蓝、绿、红
位 3 前景高亮(I)
位 4~6 背景蓝、绿、红
位 7 闪烁(Blink)

例如,以十六进制表示的几个组合如下:

效果 二进制 十六进制
黑底绿色 00000010 02H
绿底红色 00100100 24H
白底蓝色 01110001 71H
红底高亮闪烁绿字 11001010 CAH

例如 02H 的二进制形式为 00000010B,按照属性字节格式来看对应:背景色为黑色(位 4~6 全 0),前景色为绿色(位 1 置 1),无闪烁、无高亮。在 debug 中写入以下内容:

41 02 42 02 43 02 44 02 45 02 46 02

缓冲区 B800:0000 对应显示器第 0 行第 0 列开始的显存单元,字符 'A' 的 ASCII 码 41H 存放在偶地址 0000H,奇地址 0001H 中存放的是属性字节 02H。因此,当 CPU 向该位置写入这些数据后,显示器会在第 0 行第 0 列显示出一个黑底绿色的字符 'A'。后续字节 42 02、43 02 等依次对应字符 'B'、'C' 的黑底绿色显示效果。
image

CAH 的二进制形式为 11001010B,最高位(位 7)为 1 表示启用闪烁效果;位 4~6 为 001B,表示红色背景;位 3 为 1,表示字符高亮显示;位 0~2 为 010B,表示绿色前景。在 debug 中写入以下内容:

41 CA 42 CA 43 CA 44 CA 45 CA 46 CA

此时字符 'A' 到 'F' 的 ASCII 码仍在偶地址中,而奇地址中的属性字节全部为 CAH,屏幕上的显示效果发生了变化。
image

在现代操作系统中,应用程序早已不能直接写 B8000H 了。图形界面由显卡驱动和窗口管理器管理,用户态程序只能通过系统调用或图形 API 输出文字。

实验 9

要求在屏幕中间显示三个字符串 'welcome to masm!',第一行为绿色,第二行为绿底红色,第三行为白底蓝色。程序使用 ES 指向显示缓冲区,屏幕第 12 行的起始偏移是 12 × 160 = 0780H,把段地址抬高 0780H ÷ 16 = 078H 后段地址变为 0B800H + 078H = 0B878H;再考虑到列偏移 32 × 2 = 0040H。最终段地址为 0B878H + 00AH = 0B882H,ES:BX 中的 BX 就可以直接从 32 开始。

行列布局方面,BX = 32 指向第 12 行第 32 列,即屏幕中央偏左一点的起始位置;因为一行 160 字节,32 × 2 = 64,但这里用 BX + 32 指向第 13 行同一列来简化计算;BX + 64 指向第 14 行同一列。接着通过循环写入三个字符串,设置循环 CX = 16,每次执行以下操作:

  1. 从数据段取一个字符到 AL;
  2. 绿色表示为 00000010B,设置 AH 为第一行属性,写入 ES:[BX];
  3. 绿底红字表示为 00100100B,修改 AH 为第二行属性,写入 ES:[BX+32];
  4. 白底蓝字表示为 01110001B,修改 AH 为第三行属性,写入 ES:[BX+64];
  5. BX += 2,SI++,准备下一个字符。
assume cs:code, ds:data

data segment
    db 'welcome to masm!'
data ends

code segment
start:  mov ax, data 
        mov ds, ax
        mov ax, 0b882h
        mov es, ax
        mov bx, 32

        mov si, 0
        mov cx, 16
s:      mov al, ds:[si]
        mov ah, 00000010b
        mov es:[bx], ax
        mov ah, 00100100b
        mov es:[bx+32], ax
        mov ah, 01110001b
        mov es:[bx+64], ax
        
        add bx, 2
        inc si
        loop s

        mov ax, 4c00h
        int 21h
code ends 
end start

程序在 DOSBox 中运行后,屏幕中部显示出三个字符串 welcome to masm!。字符串起始于屏幕第 12 行第 32 列,处于 25 行 × 80 列的显示区域中心位置。
image

显示字符串子程序

直接向显存 B8000H~B8F9FH 写数据来显示字符虽然直观,但每次显示字符串都得重新计算偏移地址、重新设置段寄存器,通用性不好。因此可以将显示字符串的功能封装成一个子程序 show_str,这样调用时就不需要关心显存结构,只需要向子程序传递行、列、颜色、显示的字符串即可。程序调用时需要传递的参数如下:

参数 含义
(dh) 行号(0~24)
(dl) 列号(0~79)
(cl) 颜色属性
ds:si 字符串首地址,以 0 结尾

程序的代码如下。在 80×25 彩色字符模式下,显存中一个字符占 2 字节,一行共 160 字节。因此,屏幕坐标 (dh, dl) 对应的显存偏移地址计算公式为:偏移地址 = (dh × 160) + (dl × 2),可以使用 mul 指令分别完成行偏移和列偏移的计算。子程序会使用 ax、bx、cx、si 等寄存器,为了避免破坏主程序的上下文,在子程序入口将所有用到的寄存器依次压栈,在返回前按相反顺序弹出。字符串以 0 作为结尾符,因此可以通过判断 (cx) 是否为 0 来决定是否结束循环。在遍历过程中需要将颜色属性保存在栈中,在每次写入显存前临时恢复 cx。

assume cs:code, ds:data

data segment
	db 'Welcome to masm!', 0
data ends

code segment
start:  mov dh, 8    ; 指定的行号
        mov dl, 3    ; 指定的列号
        mov cl, 2    ; 指定的颜色
        mov ax, data
        mov ds, ax
        mov si, 0
        call show_str

        mov ax, 4c00h
	    int 21h

show_str:
        ; 保存寄存器状态
        push ax
        push es
        push bx
        push si
        push di
        push cx

        ; 计算显存偏移地址
        mov al, 0A0H
        mul dh        ; 计算行偏移
        mov bx, ax
        mov al, 02H
        mul dl        ; 计算列偏移
        add bx, ax    ; 计算总偏移量
        mov di, cx

        ; 初始化寄存器
        mov ax, 0b800h
        mov es, ax

        ; 写入字符串
    s0: mov cl, [si]
        mov ch, 0
        jcxz ok
        mov al, [si]
        mov cx, di
        mov ah, cl
        mov es:[bx], ax
        add bx, 2
        inc si
        jmp short s0

        ; 恢复寄存器状态
    ok: pop cx
        pop di
        pop si
        pop bx
        pop es
        pop ax
        ret
code ends 
end start

程序运行后,屏幕第 8 行第 3 列处稳定显示绿色字符串 Welcome to masm!。
image

十进制数值显示

CPU 中数字 12666 是以二进制形式存在的 0011000101111010B。但对人的习惯来说二进制并不直观,想要在屏幕上显示“12666”,需要将其转换为 ASCII 字符串:'1'、'2'、'6'、'6'、'6'。字符 '0'~'9' 在 ASCII 码中对应 30H~39H,因此具体来说,要把二进制数 12666 拆成十进制各位:1、2、6、6、6,在每一位加上 30H 使其变成对应的 ASCII 码。可以通过除 10 取余法进行拆分,以 12666 为例,把余数从下往上读,就是 1、2、6、6、6。

12666 ÷ 10 = 1266 ... 6
1266  ÷ 10 = 126  ... 6
126   ÷ 10 = 12   ... 6
12    ÷ 10 = 1    ... 2
1     ÷ 10 = 0    ... 1

但是汇编里没有从下往上读的指令,CPU 执行指令是顺序的,只能从上往下运算。可以利用栈的后进先出特性实现逆序,每算出一个余数就把它压入栈中,全部算完后再从栈中依次弹出,弹出的顺序就是从高位到低位的顺序。该程序的代码如下:

assume cs:code, ds:data

data segment
    db 10 dup (0)        ; 预留 10 字节空间存放转换后的字符串
data ends

code segment
start:
    mov ax, 12666
    mov bx, data
    mov ds, bx
    mov si, 0
    call dtoc            ; 将 12666 转换为十进制字符串

    mov dh, 8
    mov dl, 3
    mov cl, 2
    call show_str        ; 在第 8 行第 3 列以绿色显示

    mov ax, 4c00h
    int 21h

; 子程序:dtoc
; 功能:将 word 型数据转换为十进制字符串,以 0 结尾
; 参数:(ax) = 待转换的 word 型数据
;       ds:si = 字符串存放起始地址
; 返回:无
dtoc:
    push si
    push di
    push ax
    push cx
    push dx

    mov di, 0            ; 用 di 记录压栈的次数,即数字的位数
    mov bx, 10           ; bx 设置为除数 10

d0:
    mov cx, ax           ; 将 ax 复制到 cx
    jcxz d1              ; 如果 ax 为 0,说明所有位数都已处理完毕,跳转至 d1
    mov dx, 0            ; 清空 dx,为 32 位除法做准备
    div bx               ; ax = ax / 10,dx = ax % 10
    add dx, 30H          ; 将数值转换为 ASCII 码
    push dx              ; 将 ASCII 码压栈,利用 LIFO 特性实现逆序
    inc di               ; 位数加 1
    jmp short d0         ; 继续处理下一个高位
d1:
    mov cx, di           ; 令 cx = 数字位数,用于控制出栈的循环
d2:
    pop ax               ; 从栈中弹出 ASCII 码,顺序已从低位变为高位
    mov [si], al         ; 将 ASCII 码存入字符串缓冲区
    inc si
    loop d2              ; 循环直到所有字符处理完毕

    ; 恢复寄存器
    pop dx
    pop cx
    pop ax
    pop di
    pop si
    ret

show_str:
    ……
    ……
    ret

code ends
end start

程序运行后,屏幕第 8 行第 3 列处稳定显示绿色字符串 12666。通过 Debug 查看 data 段缓冲区,可以看到内存中依次存放 31H、32H、36H、36H、36H、00H。
image

除法溢出问题

在 8086 中,div 指令进行除法运算时,如果结果的商超过了目标寄存器能表示的范围,就会发生除法溢出。例如以下指令计算的是 011000H / 1H,也就是 69632 / 10 = 11000H,这个结果超出了 16 位寄存器 AX 的表示范围,执行 div 时 CPU 会触发内部错误。

mov ax,1000H
mov dx,1
mov bx,1
div bx

使用 debug 运行到 div 指令时,CPU 直接触发 INT 0 中断,此时所以 CS 跳到 F000、指令变乱码,说明 DEBUG 直接进入 BIOS 的异常处理代码。
image

当前的目标是在不触发异常的前提下,完成 32 位被除数除以 16 位除数的运算,并得到 32 位结果。可以使用拆分被除数的方式进行计算,公式如下:

X = (dx << 16) + ax   ; 32 位被除数
X/N = int(H/N) * 65536 + [rem(H/N) * 65536 + L] / N

公式涉及到的符号及其含义如下:

符号 含义
H 被除数的高 16 位(dx)
L 被除数的低 16 位(ax)
N 除数(cx)

这个公式的本质是十进制里的“竖式除法”思想,比如计算 952 / 2 时的计算顺序是:

  1. 先看百位 9,9 / 2 = 4 … 1
  2. 把余数 1“落”下来和十位 5 拼成 15,再算 15 / 2 = 7 … 1
  3. 再把余数 1 落下来,和个位 2 拼成 12,最后算 12 / 2 = 6

把“百位/十位/个位”对应到“高 16 位/低 16 位”,这个 32 位除法公式做的就是一样的事情。8086 使用 div 指令计算 16 位除数的除法时,一个 32 位整数 X 被拆成了两个 16 位寄存器 X = H * 65536 + L。这就像把一个三位数拆成“百位”和“后两位”,H 相当于“百位”,但它不是乘以 10 而是 65536(即 2^16),L 相当于“后两位”。

使用该公式计算时,等号右边每一步的商都不会超过 16 位,因此可以直接使用 div 指令实现。具体步骤如下:

  1. 先计算 H / N,得到高 16 位商 int(H/N);
  2. 取余数 rem(H/N) 左移 16 位,等价于放到 DX 中;
  3. 拼接低 16 位 L,再做一次 L / N,得到低 16 位商;
  4. 余数即为第二次除法的余数。

将该子程序命名为 divdw,实现的代码为:

assume cs:code

code segment
start:
    mov ax, 4240H     ; 被除数低 16 位
    mov dx, 000FH     ; 被除数高 16 位 (F4240H = 1000000)
    mov cx, 0AH       ; 除数
    call divdw

    mov ax, 4c00h
    int 21h

; 功能:32位除法,防止除法溢出
; 参数:
;   (dx) = 被除数高16位
;   (ax) = 被除数低16位
;   (cx) = 除数
; 返回:
;   (dx) = 结果高16位
;   (ax) = 结果低16位
;   (cx) = 余数
divdw:
    push bx            ; 保存寄存器状态

    push ax            ; 保存 L(低 16 位)
    mov ax, dx         ; AX = H
    mov dx, 0          ; DX = 0
    div cx             ; AX = int(H/N),DX = rem(H/N)

    pop bx             ; BX = L
    push ax            ; 暂存高 16 位商
    mov ax, bx         ; AX = L
    div cx             ; AX = [rem(H/N)<<16 + L] / N,DX = 最终余数

    mov cx, dx         ; CX = 余数
    pop dx             ; DX = 高16位商,AX 中已是低 16 位商
                       
    pop bx             ; 恢复寄存器状态
    ret
code ends
end start

以 1000000 / 10 为例,初始 DX = 000FH、AX = 4240H、CX = 000AH。第一次 div cx 计算 000FH / 000AH,得到高 16 位商 AX = 0001H 和第一次余数 DX = 0005H。
image

第二次 div cx 计算 (DX << 16 + L) / N = (00050000H + 4240H) / 0AH,得到低 16 位商 AX = 86A0H 和最终余数 DX = 0000H。
image

最终的返回值是 DX = 0001H、AX = 86A0H、CX = 0000H,结果与手算一致且没有触发除法溢出。
image

课程设计 1

教材给出的课程设计 1 的任务是:将实验 7 中整理好的 Power Idea 公司数据,按照 21 行 4 列的形式显示在屏幕上。数据包括:年份、公司总收入(dword)、雇员人数(word)、人均收入(word),其中难的地方在于:总收入是 dword 型数据,最大值达到 5937000,超出 16 位寄存器的表示范围。因此需要对上面写的 word 型 dtoc 进行修改,在转换过程中调用 divdw 来避免除法溢出。

首先在 table 段中存放原始二进制数据,然后在 data 段分配 21 行,每行 40 字节,用于存放即将显示的字符串。主程序的任务是按列从 table 段读取数据,转换成字符串写入 data 段,最后通过 show_str 将 data 段中的字符串批量输出到屏幕指定位置。实现的程序如下:

assume cs:code, ds:data, es:table

; Power Idea 公司原始数据
table 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,2795000,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
    dw 5,3,42,104,85,210,123,111,105,125,140,136,153,211,199,209,224,239
    dw 260,304,333
table ends

; 需要显示的字符串
data segment
    db 21 dup ('1         2         3         4       ', 0, 0)
data ends

code segment
start:
    ; 初始化数据段和附加段
    mov ax, data
    mov ds, ax
    mov ax, table
    mov es, ax
    mov bp, 0              ; bp 指向 table 段当前数据位置

    ; 第 1 列年份,原本就是字符串可以直接拷贝
    mov bx, 0              ; bx 指向 data 段当前行
    mov cx, 21
m1:
    mov ax, es:[bp]        ; 拷贝年份的前 2 字节
    mov ds:[bx], ax
    mov ax, es:[bp+2]      ; 拷贝年份的后 2 字节
    mov ds:[bx+2], ax
    add bp, 4              ; 年份占 4 字节,移动指针
    add bx, 40             ; 移动到下一行
    loop m1

    ; 第 2 列总收入,dword 需调用 dtoc
    mov si, 10             ; si 指向 data 段中第 2 列起始位置(偏移 10)
    mov cx, 21
m2:
    mov ax, es:[bp]        ; ax = 收入低 16 位
    mov dx, es:[bp+2]      ; dx = 收入高 16 位
    call dtoc              ; 将 dword 转换为十进制字符串
    add bp, 4
    add si, 40
    loop m2

    ; 第 3 列雇员数
    mov si, 20
    mov cx, 21
m3:
    mov ax, es:[bp]
    mov dx, 0              ; word 型数据,高 16 位清零
    call dtoc
    add bp, 2
    add si, 40
    loop m3

    ; 第 4 列人均收入
    mov si, 30
    mov cx, 21
m4:
    mov ax, es:[bp]
    mov dx, 0              ; word 型数据,高 16 位清零
    call dtoc
    add bp, 2
    add si, 40
    loop m4

    ; 调用 show_str 输出 21 行
    mov cx, 21
    mov dh, 2              ; 从第 2 行开始显示
    mov dl, 20             ; 从第 20 列开始显示
    mov si, 0
m5:
    push cx
    mov ch, 0
    mov cl, 2              ; 绿色
    call show_str
    pop cx
    add si, 40
    inc dh                 ; 行号递增
    loop m5

    mov ax, 4c00h
    int 21h

相比 word 版 dtoc,需要修改参数接口使其能够接收一个 dword 型数据 (dx:ax),并在循环中使用 divdw 进行除法。

; 子程序:dtoc(dword)
; 功能:将 dword 型数据转换为十进制字符串(以 0 结尾)
; 参数:(dx) = 数据高 16 位
;       (ax) = 数据低 16 位
;       ds:si = 字符串存放起始地址
; 返回:无
dtoc:
    push si
    push di
    push ax
    push cx
    push dx

    mov di, 0              ; di 记录数字位数(压栈次数)

d0:
    mov cx, ax             ; 判断 ax 是否为 0
    jcxz d1                ; 若 ax=0 且 dx=0,则转换结束
    mov cx, 0AH            ; 除数为 10
    call divdw             ; 调用不溢出除法:dx:ax / cx
                           ; 返回:dx:ax = 商,cx = 余数
    add cx, 30H            ; 余数转换为 ASCII 码
    push cx                ; 压栈保存(逆序)
    inc di
    jmp short d0

d1:
    mov cx, di
d2:
    pop ax
    mov [si], al           ; 出栈,写入字符串缓冲区(正序)
    inc si
    loop d2
    mov byte ptr [si], 0   ; 添加字符串结束符

    pop dx
    pop cx
    pop ax
    pop di
    pop si
    ret

divdw:
    ……
    ……
    ret

show_str:
    ……
    ……
    ret

code ends
end start

程序在 DOSBox 中运行后,屏幕第 2 行第 20 列开始显示出一个 21 行 4 列的报表。
image

通过 Debug 查看 data 段,可见二进制数据均已转换为 ASCII 字符串,并以 0 结尾,验证了 dtoc 子程序的正确性。
image

参考资料

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

posted @ 2026-08-16 21:26  乌漆WhiteMoon  阅读(11)  评论(0)    收藏  举报