- 显示缓冲区
- 属性字节格式
- 实验 9
- 显示字符串子程序
- 十进制数值显示
- 除法溢出问题
- 课程设计 1
- 参考资料
显示缓冲区
在 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' 的黑底绿色显示效果。

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,屏幕上的显示效果发生了变化。

在现代操作系统中,应用程序早已不能直接写 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,每次执行以下操作:
- 从数据段取一个字符到
AL; - 绿色表示为
00000010B,设置AH为第一行属性,写入ES:[BX]; - 绿底红字表示为
00100100B,修改AH为第二行属性,写入ES:[BX+32]; - 白底蓝字表示为
01110001B,修改AH为第三行属性,写入ES:[BX+64]; BX += 2,SI++,准备下一个字符。
assume cs:code, ds:datadata segment
db 'welcome to masm!'
data endscode 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 列的显示区域中心位置。

显示字符串子程序
直接向显存 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:datadata segmentdb 'Welcome to masm!', 0
data endscode segment
start: mov dh, 8 ; 指定的行号mov dl, 3 ; 指定的列号mov cl, 2 ; 指定的颜色mov ax, datamov ds, axmov si, 0call show_strmov ax, 4c00hint 21hshow_str:; 保存寄存器状态push axpush espush bxpush sipush dipush cx; 计算显存偏移地址mov al, 0A0Hmul dh ; 计算行偏移mov bx, axmov al, 02Hmul dl ; 计算列偏移add bx, ax ; 计算总偏移量mov di, cx; 初始化寄存器mov ax, 0b800hmov es, ax; 写入字符串s0: mov cl, [si]mov ch, 0jcxz okmov al, [si]mov cx, dimov ah, clmov es:[bx], axadd bx, 2inc sijmp short s0; 恢复寄存器状态ok: pop cxpop dipop sipop bxpop espop axret
code ends
end start
程序运行后,屏幕第 8 行第 3 列处稳定显示绿色字符串 Welcome to masm!。

十进制数值显示
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:datadata segmentdb 10 dup (0) ; 预留 10 字节空间存放转换后的字符串
data endscode segment
start:mov ax, 12666mov bx, datamov ds, bxmov si, 0call dtoc ; 将 12666 转换为十进制字符串mov dh, 8mov dl, 3mov cl, 2call show_str ; 在第 8 行第 3 列以绿色显示mov ax, 4c00hint 21h; 子程序:dtoc
; 功能:将 word 型数据转换为十进制字符串,以 0 结尾
; 参数:(ax) = 待转换的 word 型数据
; ds:si = 字符串存放起始地址
; 返回:无
dtoc:push sipush dipush axpush cxpush dxmov di, 0 ; 用 di 记录压栈的次数,即数字的位数mov bx, 10 ; bx 设置为除数 10d0:mov cx, ax ; 将 ax 复制到 cxjcxz d1 ; 如果 ax 为 0,说明所有位数都已处理完毕,跳转至 d1mov dx, 0 ; 清空 dx,为 32 位除法做准备div bx ; ax = ax / 10,dx = ax % 10add dx, 30H ; 将数值转换为 ASCII 码push dx ; 将 ASCII 码压栈,利用 LIFO 特性实现逆序inc di ; 位数加 1jmp short d0 ; 继续处理下一个高位
d1:mov cx, di ; 令 cx = 数字位数,用于控制出栈的循环
d2:pop ax ; 从栈中弹出 ASCII 码,顺序已从低位变为高位mov [si], al ; 将 ASCII 码存入字符串缓冲区inc siloop d2 ; 循环直到所有字符处理完毕; 恢复寄存器pop dxpop cxpop axpop dipop siretshow_str:…………retcode ends
end start
程序运行后,屏幕第 8 行第 3 列处稳定显示绿色字符串 12666。通过 Debug 查看 data 段缓冲区,可以看到内存中依次存放 31H、32H、36H、36H、36H、00H。

除法溢出问题
在 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 的异常处理代码。

当前的目标是在不触发异常的前提下,完成 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 时的计算顺序是:
- 先看百位 9,9 / 2 = 4 … 1
- 把余数 1“落”下来和十位 5 拼成 15,再算 15 / 2 = 7 … 1
- 再把余数 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 指令实现。具体步骤如下:
- 先计算
H / N,得到高 16 位商int(H/N); - 取余数
rem(H/N)左移 16 位,等价于放到 DX 中; - 拼接低 16 位
L,再做一次L / N,得到低 16 位商; - 余数即为第二次除法的余数。
将该子程序命名为 divdw,实现的代码为:
assume cs:codecode segment
start:mov ax, 4240H ; 被除数低 16 位mov dx, 000FH ; 被除数高 16 位 (F4240H = 1000000)mov cx, 0AH ; 除数call divdwmov ax, 4c00hint 21h; 功能:32位除法,防止除法溢出
; 参数:
; (dx) = 被除数高16位
; (ax) = 被除数低16位
; (cx) = 除数
; 返回:
; (dx) = 结果高16位
; (ax) = 结果低16位
; (cx) = 余数
divdw:push bx ; 保存寄存器状态push ax ; 保存 L(低 16 位)mov ax, dx ; AX = Hmov dx, 0 ; DX = 0div cx ; AX = int(H/N),DX = rem(H/N)pop bx ; BX = Lpush ax ; 暂存高 16 位商mov ax, bx ; AX = Ldiv 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。

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

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

课程设计 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 segmentdb '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,197514dd 345980,590827,803530,1183000,1843000,2795000,3753000,4649000,5937000dw 3,7,9,13,28,38,130,220,476,778,1001,1442,2258,2793,4037,5635,8226dw 11542,14430,15257,17800dw 5,3,42,104,85,210,123,111,105,125,140,136,153,211,199,209,224,239dw 260,304,333
table ends; 需要显示的字符串
data segmentdb 21 dup ('1 2 3 4 ', 0, 0)
data endscode segment
start:; 初始化数据段和附加段mov ax, datamov ds, axmov ax, tablemov es, axmov bp, 0 ; bp 指向 table 段当前数据位置; 第 1 列年份,原本就是字符串可以直接拷贝mov bx, 0 ; bx 指向 data 段当前行mov cx, 21
m1:mov ax, es:[bp] ; 拷贝年份的前 2 字节mov ds:[bx], axmov ax, es:[bp+2] ; 拷贝年份的后 2 字节mov ds:[bx+2], axadd bp, 4 ; 年份占 4 字节,移动指针add bx, 40 ; 移动到下一行loop m1; 第 2 列总收入,dword 需调用 dtocmov 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, 4add si, 40loop m2; 第 3 列雇员数mov si, 20mov cx, 21
m3:mov ax, es:[bp]mov dx, 0 ; word 型数据,高 16 位清零call dtocadd bp, 2add si, 40loop m3; 第 4 列人均收入mov si, 30mov cx, 21
m4:mov ax, es:[bp]mov dx, 0 ; word 型数据,高 16 位清零call dtocadd bp, 2add si, 40loop m4; 调用 show_str 输出 21 行mov cx, 21mov dh, 2 ; 从第 2 行开始显示mov dl, 20 ; 从第 20 列开始显示mov si, 0
m5:push cxmov ch, 0mov cl, 2 ; 绿色call show_strpop cxadd si, 40inc dh ; 行号递增loop m5mov ax, 4c00hint 21h
相比 word 版 dtoc,需要修改参数接口使其能够接收一个 dword 型数据 (dx:ax),并在循环中使用 divdw 进行除法。
; 子程序:dtoc(dword)
; 功能:将 dword 型数据转换为十进制字符串(以 0 结尾)
; 参数:(dx) = 数据高 16 位
; (ax) = 数据低 16 位
; ds:si = 字符串存放起始地址
; 返回:无
dtoc:push sipush dipush axpush cxpush dxmov di, 0 ; di 记录数字位数(压栈次数)d0:mov cx, ax ; 判断 ax 是否为 0jcxz d1 ; 若 ax=0 且 dx=0,则转换结束mov cx, 0AH ; 除数为 10call divdw ; 调用不溢出除法:dx:ax / cx; 返回:dx:ax = 商,cx = 余数add cx, 30H ; 余数转换为 ASCII 码push cx ; 压栈保存(逆序)inc dijmp short d0d1:mov cx, di
d2:pop axmov [si], al ; 出栈,写入字符串缓冲区(正序)inc siloop d2mov byte ptr [si], 0 ; 添加字符串结束符pop dxpop cxpop axpop dipop siretdivdw:…………retshow_str:…………retcode ends
end start
程序在 DOSBox 中运行后,屏幕第 2 行第 20 列开始显示出一个 21 行 4 列的报表。

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

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