先说点大实话:TSR内存驻留程序这六个字,放在今天依然能劝退一堆人。但如果你在DOS/VGA时代写过几行汇编,或者现在想弄明白中断向量、内存控制块、驻留退出这些底层机制是怎么协同工作的,那这个主题绝对是性价比最高的一条路。这篇博文就用一个非常典型的例子——汇编实现内存驻留时钟,把“驻留”“热键设置”“删除”三件事完整拆开讲清楚,顺便把那些文档里不写、但你踩一次就头疼的坑也一起倒出来。
这个项目适合三类人:一是刚学完8086汇编、想找个实战练手的人;二是搞嵌入式或系统底层、需要理解中断和内存管理的开发者;三是纯粹怀旧、想在自己的DOSBox里跑点有意思东西的老玩家。无论哪一类,看完都应该能自己动手写一个可驻留、可热键控制、可干净卸载的TSR程序。
1. 项目概览与TSR设计思路
1.1 TSR到底是什么,为什么值得手写一遍
TSR全称Terminate and Stay Resident,翻译过来是“终止但驻留”。普通程序跑完就退出,内存被回收;TSR程序则是在退出后留下自己的一部分代码和数据在内存里,通过接管某个中断来获得后续的执行机会。最典型的应用就是DOS时代的中文输入法、杀毒监控、磁盘缓存工具,还有那些桌面小闹钟。
用生活化点的方式理解:普通程序像临时工,干完活领钱走人;TSR像住在大楼里的保安,平时你看不见他,但有按键或事件发生时,他立刻冒出来处理,处理完又回到暗处。保安的“住处”就是程序驻留在内存里的那部分代码,而“呼叫保安的方式”就是中断。
自己在汇编层面手写TSR,最大的价值不是写出一个多好用的时钟,而是能把中断机制彻底弄明白。中断向量怎么保存、怎么恢复、怎么在中断服务程序里做最少的操作、怎么让DOS安全地把内存还给你——这些知识放到今天的RISC-V异常处理、RTOS中断嵌套、Linux内核驱动开发里,思路依然是通用的。
1.2 整体模块划分与选型考量
实现一个TSR时钟,按功能可以切成四块:
- 初始化部分:保存旧中断向量、安装新中断向量、驻留退出。
- 时钟刷新部分:挂到定时器中断上,周期性更新时间并送到屏幕。
- 热键检测部分:挂到键盘中断上,识别组合键并执行对应动作。
- 卸载部分:恢复中断向量、释放环境块和驻留内存块。
这里最关键的选型是“接哪个中断”。常规选择有两个方向:一个是接管INT 08h(硬定时器中断,约每秒触发18.2次),另一个是接管INT 1Ch(BIOS在INT 08h处理过程中调用的用户钩子,同样每秒触发18.2次,但不是硬件中断的原始入口)。
我建议示例程序接管INT 1Ch或INT 08h链的末尾。直接替换INT 08h意味着你要完全接管硬件定时器中断,表面上省事,实际上一旦你没有妥善调用旧的INT 08h,整个系统的时钟节拍、软驱马达超时、BIOS时间计数都会出问题。挂INT 1Ch则安全得多:BIOS已经完成了硬件应答,它额外调你一下,你只需要做自己的事情,做完就返回。时钟显示和键盘热键检测都通过这条链驱动。
键盘则接INT 09h(硬件键盘中断)。这里必须保留旧向量并转发扫描码,否则用户键盘输入会全部失效。
1.3 为什么选汇编而不是C
这个问题在项目选型时必须想清楚。TSR的难点在于控制“精确的内存布局”:你希望驻留的代码段到哪里结束、哪些部分可以被DOS回收。用C也可以,但编译器会塞入运行库初始化、堆栈初始化等一堆额外逻辑,驻留长度和入口行为没那么直观;而汇编可以精确到字节控制,你能看到每一个字节去了哪里。
另一个原因是中断服务程序要求尽量轻量,不能在中断里调用不可重入的DOS功能。汇编让你对每条指令的开销有直观感受,也会倒逼你用最简单的逻辑完成任务。比如后续要讲的“直接写显存”“直接读键盘端口”,用C也能做,但汇编写出来会自然带上“这套逻辑为什么能跑在中断上下文里”的清醒感。
2. 中断接管与热键设置的实现
2.1 中断向量表:从读表到写表的关键步骤
8086的中断向量表固定在内存地址0000:0000处,每个中断向量占4字节:低字是偏移,高字是段地址。INT 08h的向量在0000:0020h,INT 09h的向量在0000:0024h,INT 1Ch在0000:0070h。
接管一个中断的标准流程是先用INT 21h的AH=35h功能获取旧向量,保存到自己的数据区;再用AH=25h功能设置新向量。下面是从启动代码里截取的安装片段:
; 安装新中断处理例程 install: mov ax, 351Ch ; AH=35h, AL=1Ch,获取旧INT 1Ch向量 int 21h mov [old_int1c_offset], bx mov [old_int1c_seg], es mov ax, 3509h ; 获取旧INT 09h向量 int 21h mov [old_int09_offset], bx mov [old_int09_seg], es cli ; 关中断,防止安装过程被打断 mov ax, 251Ch mov dx, offset int1c_handler push ds mov ds, cs ; DS指向当前代码段 int 21h mov ax, 2509h mov dx, offset int09_handler int 21h pop ds sti ret这里有几个细节值得重点说明。
第一,保存旧向量必须要保存完整的4字节(段地址和偏移),恢复的时候也要同时恢复两者。很多人只保存偏移,恢复时用当前DS去拼凑段地址,看起来能跑,但一旦TSR驻留程序被加载到不同段位置,或系统中其他程序也接管过这个中断,恢复就会出错。
第二,设置向量时最好用CLI关掉中断。虽然INT 21h的25h功能本身会临时关中断,但两个向量相继安装期间,如果刚好来个键盘中断,你的INT 09h处理例程可能还没安装完整,系统就可能跑飞。稳妥做法是安装前CLI,安装后STI。
2.2 热键检测:扫描码、Ctrl状态与旧中断链
键盘硬件中断INT 09h触发时,键盘控制器会把当前按键的扫描码放到60h端口。你需要在处理例程里读60h,判断是不是你要的热键,然后把控制权交给旧INT 09h,让BIOS继续完成按键入缓冲区的工作。
设计这版时钟程序,我计划三个热键:
- Ctrl+F12:显示或隐藏时钟。
- Ctrl+F11:切换显示颜色(黑底白字、蓝底黄字、黑底绿字循环)。
- Ctrl+End:卸载整个TSR程序。
F12的扫描码是58h,F11是57h,End是4Fh。Ctrl键的扫描码是1Dh(左右Ctrl共用同一个make code,但重复码不一样,简化处理就都当成Ctrl)。判断组合键有两种思路:一是每次读键盘状态字节0x40:0x17,检查Ctrl位是否置位;二是在自己的中断例程里维护Ctrl键按下/释放状态。
我建议用第二种,因为INT 09h最开始触发时,BIOS的键盘状态字节可能还没来得及更新,直接查0x40:0x17会偶发漏判。维护状态也简单:从60h读到1Dh,先不管它,把它传给旧INT 09h,同时把自己维护的ctrl_held标志置1;读到1Dh的break码(9Dh)时清标志。这样F12的make码到达时,ctrl_held已经是1。
热键判断的参考逻辑大概长这样:
int09_handler: push ax in al, 60h ; 读取扫描码 cmp al, 1Dh ; Ctrl按下(make) jne check_ctrl_break mov byte ptr [ctrl_held], 1 jmp forward_to_bios check_ctrl_break: cmp al, 9Dh ; Ctrl释放(break) jne check_hotkey mov byte ptr [ctrl_held], 0 jmp forward_to_bios check_hotkey: cmp byte ptr [ctrl_held], 0 je forward_to_bios cmp al, 58h ; F12 je do_toggle_display cmp al, 57h ; F11 je do_toggle_color cmp al, 4Fh ; End je do_unload jmp forward_to_bios forward_to_bios: pop ax jmp dword ptr cs:[old_int09_offset] ; 跳回旧INT 09h请注意最后一步。所有热键判断完成后,只要按键不是你要处理的组合键,或者即使是组合键但你已经做了自己的处理,也要继续调用旧的INT 09h。否则BIOS的键盘缓冲区永远收不到这个扫描码,回车、字母、方向键等功能全都会失效。实战中很多人刚写完TSR,发现“系统按键没反应”,八个原因里九个都是忘记转发旧中断。
组合键的另一个细节是去抖或防重复触发。按住F12不松,键盘会自动重复发送make码,你的切换逻辑就会疯狂闪动。解决方法是设置一个“按键已处理”标志,等break码来了再清除,或者干脆在触发后把ctrl_held和按键状态都临时忽略,直到检测到对应break码。示例程序里我采用“等break再解锁”的方式,简单有效。
2.3 时钟节拍中断的刷新逻辑
时钟部分挂在INT 1Ch上。因为INT 1Ch本身由BIOS在INT 08h链里调用,硬件中断已经被BIOS应答,所以你的处理例程不需要写EOI命令到8259A,也没必要做复杂的硬件操作,只需要轻量更新数据。
INT 1Ch的触发频率在经典PC上是18.20648193Hz,也就是每秒大约18.2次。你可以在每次触发时递增tick计数,但真正要做的是“每秒刷新一次时间”。为了代码简单,我采用累计计数的方式:每累计182个tick,就给总秒数加10秒。这样一秒不多不少正好换算成十进制,误差大概在每秒0.03%级别。这种精度对演示钟表足够了;如果你要做准到秒的频标时钟,那需要按65536个tick等于1小时来精确累计,或者直接读取BIOS数据区0040:006Ch的32位计数器,那个才和硬件时钟严格对齐。
示例里的时钟刷新逻辑:
int1c_handler: push ax inc word ptr [tick_count] cmp word ptr [tick_count], 182 jb int1c_done mov word ptr [tick_count], 0 call update_clock ; 更新时分秒变量 call refresh_display ; 如果秒值变化,写显存 int1c_done: pop ax iretupdate_clock里维护三个变量:hours、minutes、seconds。每秒加一次秒,满60进位到分钟,满60分钟进位到小时,满24小时归零。这些变量都放在驻留数据段里,不依赖任何DOS调用,也不依赖BIOS调用,完全自洽。这也是TSR中断处理例程的通用原则:在中断里尽量不调用BIOS/DOS服务,所有状态自己维护,所有输出直接操作硬件或显存。
另外注意,如果clock显示是“每秒刷新”的,那么每秒只需要写一次显存。不要在每次INT 1Ch里都重绘8个字符,那会带来轻微闪烁。判断“秒值变了没有”最简单,就是记住上次显示的秒值,当前秒值不同才触发刷新。
3. 时钟显示、驻留启动与内存释放
3.1 直接写显存:文本模式下的时钟显示
在80x25彩色文本模式下,显存从B800:0000h开始。每个字符占2字节:第一个字节是该字符的ASCII码,第二个字节是颜色属性。要对屏幕右上角输出时钟,就计算好偏移地址,直接用MOV写显存。比如第0行第68列开始显示,偏移量就是(0 * 80 + 68) * 2 = 136。
颜色属性高四位是背景色,低四位是前景色。黑底白字是07h,蓝底黄字是1Eh,黑底绿字是0Ah。这个可以存到一个变量里,切换颜色时只改这个变量,然后重新刷一遍当前时间。
刷新显示不能只更新变化的那一位,因为进位会导致分钟或小时变化。最简单的实现是把时分秒格式化到缓冲区,一次写8个字符加8个属性字节。如果时钟要显示在右下角,还要注意避免覆盖命令行输入区域。我的示例选择右上角,因为那里被输入内容干扰的概率低。
中断里直接写显存不需要任何系统调用,所以非常安全。唯一要注意的是不要破坏光标位置。直接写显存不移动光标,但如果你用INT 10h的Teletype方式来显示字符,光标就会乱跑。这也是TSR显示模块里最常见的错误之一。
3.2 驻留退出:AH=31h与驻留长度计算
初始化完成后,程序不能像普通程序那样用INT 20h或AH=4Ch退出,那样整个内存块都会被释放。必须用AH=31h(结束并驻留)或INT 27h。AH=31h比INT 27h更灵活,推荐使用。
驻留的关键是给DX传入“要保留的节数”。1节=16字节。DX是从程序段起始算起的,所以必须包含PSP、数据段以及你希望继续留在内存里的所有中断处理例程。一种经典的安全写法是:
terminate_and_stay: mov ah, 31h mov al, 0 ; 退出码 mov dx, offset resident_end add dx, 15 shr dx, 4 ; 换算成节 int 21hresident_end是你期望的驻留区域末尾的标号,通常放在中断处理例程之后。加上15再右移4位是向上取整到节。如果你忘了把中断处理例程包含进驻留区,程序驻留后第一次按热键就会跳到已释放内存,轻则No Response,重则直接死机。这是TSR新手最容易踩的深坑,我最初调试时就因为少算了一个节,折腾了一晚上。
另外注意,AX=3100h执行后不会再返回,所以后面不需要跟任何指令。但驻留前一定要确保新中断向量已经安装,否则驻留的程序不会被任何事件激活,等于白驻留。
3.3 热键卸载:恢复中断向量、释放环境块与PSP内存块
“删除”TSR是完整项目里技术含量最高的部分。它要求把之前驻留的代码和数据所占用的内存还给DOS,同时恢复所有被接管的中断向量。卸载流程可以拆成四步:
第一步,恢复中断向量。把保存的旧INT 1Ch和旧INT 09h地址用AH=25h写回去。这个顺序很重要:先恢复向量,再释放内存。如果顺序反了,恢复向量的过程可能正好触发一次中断,而处理例程所在的内存已经被释放,系统直接就崩了。
第二步,释放环境块。每个DOS进程启动时,DOS会为它分配一个环境块,用来存放PATH、COMSPEC等环境变量,这个块的段地址保存在PSP偏移2Ch处。TSR驻留时,这个环境块也一直占着内存,不释放的话会泄漏。释放方法是对环境块段执行AH=49h。
第三步,释放程序占用的内存块。这里要非常清楚一个概念:DOS的AH=49h释放内存时,ES必须指向“要释放内存块的段”,也就是程序PSP所在段,而不是MCB的段。MCB(内存控制块)在PSP段地址减1的位置。直接对自身PSP段执行AH=49h,DOS会通过ES向低地址方向找到MCB,然后把这块内存标记为空闲。
第四步,返回中断现场。释放完成后,你还在中断处理例程里,还要用IRET返回被中断的程序。因为你已经释放了自己所在的代码段,从释放到IRET这条路径之间,你的代码还在执行但内存已经被标记为空闲,存在极小概率被其他内存申请覆盖。实际中这个窗口只有几条指令,在DOS环境下基本安全;但如果你追求绝对稳妥,可以先把剩余的指令拷贝到堆栈中执行,不过那就超出了这篇示例的范畴。
卸载逻辑的骨架代码:
do_unload: ; 先恢复中断向量 push ds mov ax, [old_int1c_seg] mov dx, [old_int1c_offset] mov ds, ax mov ah, 25h mov al, 1Ch int 21h mov ax, [old_int09_seg] mov dx, [old_int09_offset] mov ds, ax mov ah, 25h mov al, 09h int 21h pop ds ; 释放环境块 mov es, [env_block_seg] mov ah, 49h int 21h ; 释放自身PSP内存块 mov es, cs mov ah, 49h int 21h ; 中断现场返回 iret这里有一个非常隐蔽的坑:如果当前中断发生在DOS正在处理系统功能调用的时候(比如用户正在执行DIR命令的瞬间按了卸载热键),你在中断里调用INT 21h释放内存,可能导致DOS内部状态混乱,甚至死机。严谨的TSR会通过INT 21h的AH=34h拿到InDOS标志地址,卸载前检查这个字节是否为0,如果不为0就延迟卸载。示例程序为了保持可读性,走的是“立即卸载”路线,但我会在下一节专门讲这个问题的现象和解决办法。
还有环境块地址,入口处就要保存:PSP偏移2Ch处是一个环境块的段地址。可以用如下代码在初始化时读取:
mov es, [psp_seg] ; 初始化时DS也指向PSP mov ax, es:[002Ch] mov [env_block_seg], ax在卸载时先释放环境块,再释放自身PSP块,顺序不能反。环境块的MCB和PSP块的MCB是两个独立的块,如果顺序颠倒,只要段地址不变通常也能释放成功,但保持“先子块后主块”的习惯更安全,也更容易排查内存泄漏。
3.4 关于“内存驻留程序删除”的另一种手法
除了上面这种“在中断例程里自我释放”的严格方案,还有一种更简单的卸载思路:把卸载逻辑单独做成一个独立小程序,这个小程序负责找到TSR驻留时保存的中断向量和PSP段,然后恢复向量、释放内存。这种方式不需要在中断上下文中执行INT 21h,安全性高很多,但需要TSR向外部暴露一个“标志区”,通常是段地址加偏移的组合,而且要在系统里留下查找机制。如果不做文件系统关联,最常用的就是通过指定的中断向量来传递信息,比如让TSR接管一个空闲中断,把内部参数首地址存进中断向量里,卸载程序通过读这个向量来定位TSR。
这个方案更符合工程实践,但代码量也更长。这篇博文的示例选择在TSR内部卸载,是因为它能让读者看到最完整的内存控制块操作过程,对一个学习型项目来说更通透。你在实际生产工具里,除非特殊需要,最好还是用独立卸载器。
4. 常见问题与实测排坑
4.1 热键没反应,或者系统按键失灵
热键没反应,先排查三件事。
第一,是否安装了正确的INT 09h处理例程。用DEBUG工具查看内存地址0000:0024h处的向量是否指向你的处理例程段地址。如果指向的是0000:xxxx这种BIOS默认地址,说明安装函数根本没执行成功。
第二,组合键判断条件是否太严格。Ctrl的扫描码判断只写了1Dh,有些扩展键盘会发送E0 1D这种双字节序列,如果你的处理例程没处理E0前缀,就会漏掉右Ctrl。简单方案是只支持左Ctrl,或者把E0前缀也考虑进来。我的示例约定使用左Ctrl。
第三,是否忘了转发旧INT 09h。如果系统里其他程序也接管了键盘中断,你的“转发到旧向量”不一定转发到BIOS默认处理器,而是转发到更早的接管者。只要整个链上每个程序都遵守“处理完调用旧向量”,链条就是通的。任何一个环节在没调用旧向量的情况下直接IRET,链就断了。这也是为什么TSR程序卸载时必须恢复旧向量,否则链条无法恢复。
另外一个非常常见的误解是使用0x60h端口读扫描码,读完一定要再转发给旧中断。如果不转发,键盘状态字节和缓冲区都不会更新,热键看似被“吃掉”了,但系统里其他程序也失去这个键,直观感受就是“键盘失灵”。
4.2 时钟不刷新,或屏幕闪烁
时钟不刷新,九成是INT 1Ch没有被触发。检查是不是安装中断向量时参数反了:AH=25h要求AL放中断号,DX放偏移,DS放段地址。不少人写成DS=offset、DX=seg,结果装了一根指向错误地址的向量,一旦触发系统就会跳飞。
屏幕闪烁,多半是刷新频率太高。在中断例程里每18次写一次显存,也就是每秒18次,虽然每次只写几个字符,但人眼已经能察觉到抖动。正确做法是维护“上次显示秒数”(last_displayed_second),只有当秒值不同才重绘。时钟每秒变化一次是合理的人眼感知频率,屏幕会很干净。
还有一点,如果被中断的程序刚好也在操作显存(比如正在运行EDIT全屏编辑器),你的时钟写入会跟编辑器画面互相干扰。这属于TSR的固有限制。真要解决,可以在写显存前检查当前视频模式是不是文本模式,并尽量选择屏幕边缘位置写入。但无论如何,TSR类工具在图形程序里显示都需要更复杂的协作机制,这个超出本文范围。
4.3 卸载后系统崩溃或内存泄漏
卸载后崩溃,先看是不是中断向量恢复不全。只恢复了INT 09h没恢复INT 1Ch,或者是恢复后某个中断又触发了,而处理例程已经被释放,系统自然崩。排查方法是卸载后立刻执行命令,如果第一条命令就卡死,多半是键盘中断没恢复干净。如果过一会才崩,可能是定时器中断或另一个链上的程序受影响。
内存泄漏的麻烦更隐蔽。如果你卸载时没有释放环境块,每次加载一次TSR再卸载一次,环境块就会漏一块。表现出来就是反复加载卸载几十次后,DOS报告“内存不足”。检查方法是用MEM命令看可用内存量变化。释放环境块这个问题,很多老代码都忽略,但它对长期运行的DOS系统影响很大。
这里我必须提醒一个严重问题:在DOS的Norton Commander或Windows 3.x的DOS窗口里按卸载热键,可能触发重入导致崩溃。这是因为这些环境里被中断的当前程序本身就在调用DOS功能,而你的中断里又调用了AH=49h。如果你要写一个能放进工具箱长期使用的TSR,请务必加上InDOS检查,或者干脆把卸载功能独立成外部小程序。测试阶段在纯DOS命令行下按卸载热键是安全的,但也别开着磁盘高速缓存去试。
4.4 运行环境:DOSBox、虚拟机与真机差异
现代环境里跑这个程序首选DOSBox。DOSBox的中断模拟比较完整,8250/8254/8259这些硬件的时序跟原机接近,跑TSR时钟这类程序很合适。但要注意,DOSBox默认CPU速度很高,它内部的定时器中断频率还是按兼容逻辑模拟的,所以你的时钟走时不会因为CPU主频高而变快,这点不用担心。
虚拟机里测试另一个好处是方便“一键还原”,如果卸载逻辑写崩了,直接重启虚拟机就行。真机上测试风险最高,尤其是修改中断向量后如果崩溃,只能冷启动,而且某些BIOS对INT 1Ch链的调用方式跟老PC有微妙差异。我个人的建议是:先用DOSBox把功能逻辑跑通,然后到虚拟机里验证内存释放和卸载,最后有兴趣再上真机。千万别第一次写就直接在真机上折腾,除非你旁边有第二台设备随时准备查资料。
说一个调试心得:排查TSR类问题时,直接打印“运行到哪行”是不现实的,因为程序可能在任意时刻被中断触发。我习惯在数据段里放一个“调试状态字”,每执行一个关键步骤就写一个特征值,然后让TSR在某个安全时刻把状态字输出。死机后重启,看最后的状态字落在哪一步,基本就能定位问题。这个方法虽然土,但比眼睛盯着屏幕等崩溃点可靠得多。
4.5 热键设置的扩展场景
如果你想把“热键设置”做得更灵活,可以在TSR里开放一个配置区,比如让用户通过命令行参数指定热键的扫描码,或者允许运行时按两下按键来“录制热键”。这不算复杂,核心就是把我上面维护ctrl_held和主扫描码的逻辑再加一层变量。时钟显示位置也可以用热键在四个屏幕角落之间轮换,原理就是把显存偏移计算改成查表。
再延伸一步,你可以做“弹出式计算器”“屏幕截图”这类经典TSR,整体框架一模一样:接管键盘+定时器中断,中断里轻量处理,通过热键触发主功能。框架一旦跑通,后面换功能只是换中断处理例程和显示逻辑的事。我在写中文输入法的挂接模块时,用的也还是这套思路,只是把显存直写换成了更复杂的IME状态管理。
最后再分享一个小技巧:卸载热键务必选一个“平时几乎不会用到”的组合。我之前图省事用F12作为卸载键,结果某次调试别的软件时用户习惯性按F12,直接把后台挂着的时钟卸载了,还连带让另一个驻留程序崩溃。后来我统一用左Ctrl+左Alt+End这种三重组合作为卸载键,误按概率低很多。组合键判定代码也就是多一层标志位判断,值得多写两行。