☰
汇编实战:用INT 1Ch定时中断与显存直写实现DOS屏幕保护程序
2026/10/18 11:49:15 网站建设 项目流程

写屏幕保护程序,是我当年学汇编时觉得最像“软件”的一个练习。它不是那种在黑框里打印几行字符就结束的demo,而是一个有动画、有按键响应、能自己退出的小程序——放在DOS时代,这东西已经算桌面应用了。今天这篇就围绕“用汇编编一个屏幕保护程序”这件事,把底层的定时器中断、显存直写、中断向量装卸这些硬骨头一块儿啃干净。

这个程序最终做成这样:一个彩色字符串在80×25的文本屏幕上匀速移动,碰到四周边界会自动反弹,全程没有任何DOS功能调用参与动画,纯粹靠BIOS时钟中断驱动;只要你敲任意键,程序立刻恢复系统时钟向量并退出。适合刚学完汇编基本指令、想找个综合项目练手的人,也适合那些想搞懂“中断到底怎么用”“显存到底怎么映射”的老同学。我会一步步把代码拆开讲,最后给出一个可以直接编译运行的完整版本。

1. 先搞清楚屏保到底在“保护”什么

1.1 一个被忽略的老问题

很多人以为屏幕保护程序是“为了保护屏幕”,其实早期CRT显示器的荧光粉长时间被同一束电子持续轰击,会留下不可逆的残影,也就是俗称的“烧屏”。屏保的本质,是让屏幕上显示的内容不断变化,避免同一组像素长期保持高亮度。搞清楚这个原理,你就明白为什么屏保程序必须是“动画”而不是“静止画”——静止画面起不到任何保护作用。

DOS时代的屏保就更直白:系统根本没有内置屏保机制,你想让屏幕在空闲后自动进入保护状态,就得自己写一个程序挂进中断链里。这个需求放到汇编语言里,实践意义非常具体——你被迫去操作显存、控制中断、管理键盘状态,每一项都是汇编入门的硬功夫。

1.2 程序运行机制全景

这个屏保程序的工作流程不复杂,我提前画个路线图:程序启动后先保存原来的定时器中断向量,再把自写的处理函数挂到INT 1Ch上;随后主程序进入一个死循环,不停用INT 16h查询键盘缓冲区;每次系统时钟滴答,INT 1Ch就会被自动调用一次,我们的处理函数在里面累加计数器,够了一定次数就执行“擦掉旧位置—计算新坐标—绘制新位置”三个动作;一旦主循环查到有按键,就恢复原中断向量,清屏退出。

这里有个关键设计:动画逻辑放在定时器中断处理函数里,键盘轮询放在主循环里,两者各自独立。这样做的好处很明显,就算你一直按着键盘不放,动画也不会卡死——因为键盘检测只是“看”缓冲区,不会阻塞时钟中断。

2. 开工前需要补齐的几个底层知识点

2.1 文本模式下屏保的“画布”:B800:0000

写文本模式屏保,最痛快的方式不是调用INT 10h的字符输出功能,而是直接往显存里写数据。DOS的80×25彩色文本模式,显存物理地址固定在B800:0000,每个屏幕字符占两个字节:第一个字节是ASCII码,第二个字节是显示属性。一行80个字符就是160字节,整个屏幕一共4000字节。

属性字节的高4位是背景色,低4位是前景色,07h代表黑底白字,1Eh代表蓝底黄字。所以如果你想把字符串“Hello”的第3个字符位置改成黄底红字,就找到对应的显存偏移,写两个字节:字符码和属性码。我们在屏保里反复做“清旧位、写新位”,本质上就是在显存区域进行直接的读写,这比调用BIOS中断一次性整屏重绘要快得多,也不会造成光标位置被意外改动。

坐标转偏移的公式是:偏移 = 行 × 160 + 列 × 2。例如第10行第5列,偏移就是10×160+5×2=1610,注意列要乘2,因为每个字符占两字节,第5列的字符在显存里的起始偏移是1610,共两个字节。这个公式后面要反复用,我建议直接背下来。

2.2 让动画跑起来的动因:INT 1Ch定时器

DOS系统的硬件定时器每秒大约触发18.2次,每次触发都会执行INT 08h,紧接着INT 08h处理末尾会自动调用一次INT 1Ch——这个中断本来是给用户预留的“钩子”,相当于系统定时节拍的一个分支出口。默认向量只是简单的iret,什么都不做,我们写屏保就是把这个空壳换成自己的逻辑。

为什么要用它而不是自己写一个死循环延迟?因为死循环会独占CPU,而且延迟时间很难精确匹配系统的滴答节奏,更致命的是它把按键检测也拖累了。挂在INT 1Ch上的代码由BIOS定时器自动调用,哪怕主程序正在轮询键盘,时间一到它照样被触发,这就实现了“后台动画、前台响应按键”的并行效果。需要注意,INT 1Ch的处理函数必须用iret返回,而且不能在里面调用DOS的INT 21h——因为中断可能发生在任意时刻,DOS自身的重入问题会直接导致系统崩溃。

2.3 检测按键的两种思路

检测按键最传统的方式是轮询INT 16h:AH=01h检查键盘缓冲区是否有键,有则ZF=0并保留按键内容;AH=00h读取并按下一个键。但是在定时器中断处理函数里直接调用INT 16h是有风险的,因为BIOS键盘服务本身涉及缓冲区状态,嵌套中断容易破坏数据。

所以我的做法是分工式:主程序循环里调用INT 16h查键盘,中断处理函数只负责动画。主循环发现按键后,再调用AH=00h把键读走,顺便清空缓冲区。这样中断里不存在键盘服务调用,稳定性高得多。还有一条更“底层”的路子,就是直接检查BIOS键盘缓冲区指针0040:001A和0040:001C是否相等,相等表示无键,但这个在实模式程序里写起来稍繁琐,教学上先用INT 16h即可。

2.4 中断向量的装卸规矩

在DOS下,中断向量表放在内存最低端,每个向量4字节:偏移在前,段址在后。INT 1Ch的向量地址是1Ch×4=70h。修改向量最规范的办法不是直接写70h地址,而是调用DOS的INT 21h功能号35h和25h:35h获取旧向量(返回在ES:BX),25h设置新向量(DS:DX指向新处理函数)。

为什么必须用DOS功能而不是自己写内存?因为DOS内部可能有自己维护的缓存信息,直接写向量表在某些环境下会出现不一致。安装时要在CLI(关中断)和STI(开中断)之间包裹,避免定时器恰好在我们改了一半向量时触发;恢复旧向量时同样处理。注意,在CLI状态里调用INT 21h完全没问题,INT指令本身会压栈并清中断标志,DOS功能内部需要开中断时会自己处理。

3. 核心代码一步一步实现

3.1 程序骨架和数据区

我们使用COM格式,这样编译链接最简单。COM程序的特点是所有段寄存器初始指向同一个段,代码、数据、栈都挤在一个64KB段里,所以数据可以放心放在代码段内,中断处理函数用CS段前缀访问变量也最安全。程序开头先jmp跳过数据区,这是老COM程序的经典布局。

数据区每个变量都要解释清楚:old_1c_off和old_1c_seg保存旧1Ch向量;x_pos和y_pos是字符串左上角当前的列和行;x_step和y_step是移动方向,每帧要么加1要么减1;counter是滴答计数器,控制动画刷新频率;msg是我们要移动的字符串,msg_len在汇编时由$计算得出,不用手数。

code SEGMENT ASSUME cs:code, ds:code, ss:code ORG 100h start: jmp install ; ===== 数据区 ===== old_1c_off dw 0 old_1c_seg dw 0 x_pos dw 5 y_pos dw 10 x_step dw 1 y_step dw 1 counter dw 0 msg db 'Hello, Screen Saver!' msg_len equ $ - msg

变量采用“与代码同段”方式安置,原因很直接:中断处理函数被调用的瞬间,DS很可能不是我们的数据段,而CS必然是我们的代码段。所以处理函数开头只要设置DS=CS,就能安全访问所有变量,省去了大量段寄存器的切换和保护逻辑。这对新手来说是最不容易出错的方案。

3.2 第一步:安装定时器钩子

安装逻辑放在install标签后。先调用INT 21h的35h功能获取旧1Ch向量,把ES:BX分别存入old_1c_off和old_1c_seg。然后在CLI保护下,用25h功能把DS:DX设置为new_int_1c的偏移。因为COM程序的DS本来就等于CS段,所以offset new_int_1c配合当前DS就是完整地址。

装好向量后,调用BIOS的INT 10h把视频模式重置为03h——80×25彩色文本模式。这一步顺手也完成了清屏,屏幕干干净净。随后进入main_loop:反复执行INT 16h的01h功能,ZF=1就继续循环。等待按键期间,系统定时中断照样触发,动画照跑不误。

install: mov ax, 351Ch int 21h mov old_1c_off, bx mov old_1c_seg, es cli mov dx, offset new_int_1c mov ax, 251Ch int 21h sti mov ax, 0003h int 10h main_loop: mov ah, 01h int 16h jz main_loop mov ah, 00h int 16h

主循环里的jz判断看起来简单,但有个微观问题:假如定时器中断恰好在INT 16h执行完毕和jz读取标志位之间触发,会怎样?不会有事。硬件中断会自动保存FLAGS到栈,中断处理完再用iret恢复,ZF在中断前后一致,所以jz的判断结果是准确的。这正是中断机制严谨性的一个缩影。

3.3 第二步:在定时器里移动字符串

new_int_1c是整个程序的核心。它先把用到的寄存器全部压栈,因为中断处理函数必须保证返回时所有寄存器保持进入时的样子,否则正在运行的主程序就可能莫名其妙被搞乱。然后设置DS=CS,让后续变量访问都落在我们自己的段内。

计数器counter每次中断加1,当达到阈值4时执行一次移动动作。每秒18.2次滴答,除以4大约就是每秒4.5帧,文本字符串移动速度看起来比较舒缓;想更快就改成2或3,想更慢就改成5以上。这个参数完全看个人审美。

动画三连:clear_old把旧位置上的字符串用空格覆盖清掉,update_pos计算新坐标并处理边界反弹,draw_string把字符串写到新位置。三个子程序串联执行完毕,本次中断处理就结束了。

new_int_1c: push ds push ax push bx push cx push dx push si push di mov ax, cs mov ds, ax inc counter cmp counter, 4 jb int_done mov counter, 0 call clear_old call update_pos call draw_string int_done: pop di pop si pop dx pop cx pop bx pop ax pop ds iret

有人会问:为什么不在一次中断里就移动一次,非要加计数器?因为定时器每秒18.2次对文本动画来说太频繁了,直接每帧移动会让字符串像飞一样在屏幕上乱窜,观感很差。用计数器降频,相当于给动画加了一个“视频帧率控制”,这是所有屏保类程序最常见的调参点。

3.4 第三步:写显存的两个子程序

clear_old和draw_string的代码结构高度对称。clear_old先计算旧坐标对应的显存偏移,在es段指向B800h,di指向偏移位置,然后把msg_len个“空格+属性07h”字写入;draw_string则把msg中的字符逐个取出来,配合属性07h写入新位置。

这两个子程序里的坐标转偏移代码完全一样:y_pos乘160,加上x_pos乘2。之所以乘160,是因为一行80列,每列2字节,80×2=160。x_pos乘2是因为列到字节偏移要乘2。mul bx会同时改动DX寄存器,所以两个子程序在栈里都要保存和恢复DX,否则会破坏外层中断处理函数压栈保存的DX原值。这是新手非常容易踩的坑。

clear_old PROC push ax push bx push cx push dx push es push di mov ax, 0B800h mov es, ax mov ax, y_pos mov bx, 160 mul bx mov bx, x_pos shl bx, 1 add ax, bx mov di, ax mov cx, msg_len mov ax, 0720h clear_loop: mov es:[di], ax add di, 2 loop clear_loop pop di pop es pop dx pop cx pop bx pop ax ret clear_old ENDP
draw_string PROC push ax push bx push cx push dx push es push si push di mov ax, 0B800h mov es, ax mov ax, y_pos mov bx, 160 mul bx mov bx, x_pos shl bx, 1 add ax, bx mov di, ax mov si, offset msg mov cx, msg_len mov ah, 07h draw_loop: lodsb mov es:[di], ax add di, 2 loop draw_loop pop di pop si pop es pop dx pop cx pop bx pop ax ret draw_string ENDP

属性字节07h是黑底白字,看起来比较素。你可以改成1Eh(蓝底黄字)、4Eh(红底黄字)或者任何喜欢的组合,整个屏保立刻变风格。这也是直写显存的好处,不用改其他逻辑,只改一个立即数。

3.5 update_pos的边界反弹逻辑

边界反弹其实是个简单的算术题。x_pos每帧加上x_step,如果算出来的新位置大于“最右允许列”80-msg_len,或者小于0,就把x_step取反(neg指令),再把x_pos修正回边界内的新位置。y方向同理,上限是24行。

为什么最右列要减去msg_len?因为字符串占用msg_len个字符宽度,如果x_pos可以到79,那么字符串右端就会溢出到下一行开头,出现一条竖线错位。最右允许列=80-msg_len,刚好让最后一个字符落在第79列。这个细节不处理好,屏幕边缘就会出现一个莫名其妙乱跑的小尾巴。

update_pos PROC mov ax, x_pos add ax, x_step cmp ax, 80 - msg_len jg reverse_x cmp ax, 0 jl reverse_x mov x_pos, ax jmp do_y reverse_x: neg x_step mov ax, x_pos add ax, x_step mov x_pos, ax do_y: mov ax, y_pos add ax, y_step cmp ax, 24 jg reverse_y cmp ax, 0 jl reverse_y mov y_pos, ax ret reverse_y: neg y_step mov ax, y_pos add ax, y_step mov y_pos, ax ret update_pos ENDP

反向之后的修正也很重要。比如x_pos已经到0,x_step是-1,加完等于-1,触发反向,x_step变成+1,再把x_pos修正为0+1=1。这样字符串不会卡在边缘,也不会在边界处抖动,而是平滑弹回来。

3.6 主程序退出逻辑的完整细节

当main_loop检测到有按键,先调用INT 16h AH=00h把它读走,然后进入flush_key循环:反复检查键盘缓冲区是否还有残留按键,有就继续读走,直到缓冲区清空。这一步非常容易被忽略——如果你想退出屏保,结果按了好几下键,退出后这些多余按键会留在缓冲区,回到DOS命令行后就像你自己敲了一串命令,轻则屏幕上出现乱字符,重则可能误执行出奇怪的操作。

恢复向量时,依然用CLI包裹,先把DS切换到old_1c_seg段,再用25h功能把old_1c_off恢复给1Ch。注意这里必须使用保存下来的原段址,而不是想当然的清零;否则中断向量变成空指针,下一次定时器滴答直接就死机。恢复完向量后再重置一次视频模式清屏,最后用4C00h回到DOS。

; ===== 检测到按键后的清理 ===== mov ah, 00h int 16h flush_key: mov ah, 01h int 16h jz flush_done mov ah, 00h int 16h jmp flush_key flush_done: restore: cli push ds mov dx, old_1c_off mov ax, old_1c_seg mov ds, ax mov ax, 251Ch int 21h pop ds sti mov ax, 0003h int 10h mov ax, 4C00h int 21h

退出前恢复向量这件事,是所有中断挂钩程序的生命线。很多人在学习时觉得“反正程序要退出了,不恢复也无所谓”——大错特错。程序一旦退出,它占用的内存段就被DOS回收,可能被其他程序复用;可系统中1Ch向量还指向这块随时可能被覆盖的地址,下次定时器滴答就会跳到一个乱七八糟的地方,系统直接卡死。这也是为什么我反复把恢复向量这段放在最后讲,它不是可有可无的善后,是必须执行的安全操作。

3.7 完整源码与编译运行方式

把前面所有片段拼起来,就是一个可直接编译的完整程序。我使用的是TASM/MASM兼容语法,用COM格式输出。

编译方式两种任选:MASM系列用ml /AT screensaver.asm;TASM系列用tasm screensaver.asm配合tlink /t screensaver.obj。生成screensaver.com后,放到DOS环境、DOSBox或者Windows 9x的DOS窗口里直接运行。

我把最终完整源码统一再列一次,方便直接复制:

code SEGMENT ASSUME cs:code, ds:code, ss:code ORG 100h start: jmp install ; ===== 数据区 ===== old_1c_off dw 0 old_1c_seg dw 0 x_pos dw 5 y_pos dw 10 x_step dw 1 y_step dw 1 counter dw 0 msg db 'Hello, Screen Saver!' msg_len equ $ - msg ; ===== 安装 ===== install: mov ax, 351Ch int 21h mov old_1c_off, bx mov old_1c_seg, es cli mov dx, offset new_int_1c mov ax, 251Ch int 21h sti mov ax, 0003h int 10h main_loop: mov ah, 01h int 16h jz main_loop mov ah, 00h int 16h ; 清掉缓冲区残留按键 flush_key: mov ah, 01h int 16h jz flush_done mov ah, 00h int 16h jmp flush_key flush_done: restore: cli push ds mov dx, old_1c_off mov ax, old_1c_seg mov ds, ax mov ax, 251Ch int 21h pop ds sti mov ax, 0003h int 10h mov ax, 4C00h int 21h ; ===== 新 1Ch 中断 ===== new_int_1c: push ds push ax push bx push cx push dx push si push di mov ax, cs mov ds, ax inc counter cmp counter, 4 jb int_done mov counter, 0 call clear_old call update_pos call draw_string int_done: pop di pop si pop dx pop cx pop bx pop ax pop ds iret ; ===== 清除旧位置 ===== clear_old PROC push ax push bx push cx push dx push es push di mov ax, 0B800h mov es, ax mov ax, y_pos mov bx, 160 mul bx mov bx, x_pos shl bx, 1 add ax, bx mov di, ax mov cx, msg_len mov ax, 0720h clear_loop: mov es:[di], ax add di, 2 loop clear_loop pop di pop es pop dx pop cx pop bx pop ax ret clear_old ENDP ; ===== 更新坐标 ===== update_pos PROC mov ax, x_pos add ax, x_step cmp ax, 80 - msg_len jg reverse_x cmp ax, 0 jl reverse_x mov x_pos, ax jmp do_y reverse_x: neg x_step mov ax, x_pos add ax, x_step mov x_pos, ax do_y: mov ax, y_pos add ax, y_step cmp ax, 24 jg reverse_y cmp ax, 0 jl reverse_y mov y_pos, ax ret reverse_y: neg y_step mov ax, y_pos add ax, y_step mov y_pos, ax ret update_pos ENDP ; ===== 绘制字符串 ===== draw_string PROC push ax push bx push cx push dx push es push si push di mov ax, 0B800h mov es, ax mov ax, y_pos mov bx, 160 mul bx mov bx, x_pos shl bx, 1 add ax, bx mov di, ax mov si, offset msg mov cx, msg_len mov ah, 07h draw_loop: lodsb mov es:[di], ax add di, 2 loop draw_loop pop di pop si pop es pop dx pop cx pop bx pop ax ret draw_string ENDP code ENDS END start

4. 调试与常见坑速查

4.1 一按就退出,或者字符串完全不动

如果程序一启动光标就跳回DOS,多半是安装向量时出了问题——最常见的可能是DS没指向正确段,导致25h功能把错误地址装进向量表。COM程序里DS初始就是代码段,理论上不会错,但如果你改动过段寄存器,务必检查。

字符串完全不动,先看counter阈值是不是设得太大,比如设成10或20,动画慢到像静止;再检查counter是否每次都被清零、是否有地方误改了x_pos。我调试时曾犯过低级错误:把mov counter,0写成了mov x_pos,0,动画自然原地踏步。这种问题靠读代码很难发现,建议在int_done处临时加一个显存写入观察counter值,或者用调试工具单步看。

4.2 屏幕闪烁、残影和字符拖尾

闪烁的主要原因是清旧位和写新位之间间隔太长,屏幕刷新正好卡在中间。文本模式4000字节的写显存操作本身很快,但如果你把counter阈值设得过高,比如每10次滴答才动一次,动画会有明显的“跳变感”。调低阈值到2~4,肉眼几乎看不出闪烁。

拖尾更常见:clear_old写的空格属性如果不对,比如写成了07h而原字符是1Eh,那么即使字符被清掉,背景色还留在屏幕上,看起来就像一串彩色方块拖尾。解决办法是记住当前字符串的属性,清旧位时用同样属性写空格。我们代码里固定07h,不存在这个问题;但如果你想改成彩色属性,就一定要配套修改clear_old里的属性字节。

4.3 退出后死机或DOS命令行为何乱掉

死机九成就死在恢复向量这一步。要么没恢复,要么恢复时DS:DX写错了。恢复代码里有一行mov dx, old_1c_off,必须确保它是从我们自己的数据段读取的。如果恢复之前在CLI状态下改了DS,那就完蛋了——old_1c_off读不到正确的值,恢复出来的向量就是垃圾。

DOS命令行乱掉,是残留按键没清干净。我在代码里加了flush_key循环,但只能清掉“进入flush之前”的残留键;如果在flush过程中你又按住方向键不放,可能还会有键进来。解决也很简单,flush循环多跑几轮,或者直接把INT 16h的缓冲区分段清零。对教学程序来说,flush_key的循环已经足够稳定。

4.4 在DOSBox和虚拟机里的运行差异

DOSBox对定时器中断的模拟并不完全精确,每秒滴答数可能和真实硬件有出入,所以你在DOSBox里调好的速度,到了真实DOS机器上可能偏快或偏慢。这不是程序bug,把counter阈值调一下就行。

Windows 9x的DOS窗口下,INT 1Ch依然可用,但32位保护模式下的部分底层硬件访问受限制,B800:0000直写通常没问题,I/O操作要小心。虚拟机里如果开了加速,定时器节奏也会变化。这个程序本身很轻量,在任何能跑DOS的模拟环境里都能运行,只是速度感需要现场微调。

问题现象可能原因处理方法
程序一运行就退出中断向量安装地址错误检查安装段寄存器,确认DS=CS
字符串不动counter阈值过大或坐标被误改缩小阈值,逐段检查变量变化
屏幕闪烁残影清旧位与绘制间隔过长、属性不一致调低counter阈值,统一属性值
退出后死机未恢复旧向量或恢复参数错误检查restore流程的DS/DX取值
DOS命令行乱键盘缓冲区残留按键增加flush_key清空循环

5. 接着还能怎么玩

这个纯文本版屏保是很好的地基,往上加东西非常顺手。你可以先试着把字符串改成你名字,或者把属性字节改成1Eh和4Eh交替闪烁;比如隔4个滴答切换一次属性,屏幕上就有一种彩色呼吸的感觉。更进阶的玩法是把方向改成45度斜向移动,只需要在更新坐标时同时调整x_step和y_step,让它们形成固定比例,就能实现斜线弹射。

如果想彻底跳出文本模式,可以直接升级到VGA 13h图形模式:把视频模式设为320×200×256色,显存段改为A000:0000,每个字节对应一个像素。13h的像素操作比文本模式直白得多,甚至还能用循环画渐变背景。你只需要在程序开头多一段模式切换代码,把显存基址从B800h换成A000h,动画区域从80×25换成320×200——那就是一款真正的图形屏保雏形。

我当时在写完这个文本版后,又花了一个晚上给它加了个功能:按空格键切换颜色,按S键暂停动画,按Q键退出。扩展起来一点都不难,无非是在主循环的按键处理分支里多写几个case。汇编学习最大的乐趣就在这种阶段,底层你能管到的资源就那么多,但组合方式却千变万化。这个屏保程序里用到的中断挂钩、显存直写、坐标状态维护,几乎可以平移去做任何DOS下的小游戏或工具。把这些练熟,你对计算机如何处理“时间”“输入”“输出”这三件事的理解,会比只学高级语言的人扎实得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询