
DOS时代写后台程序绕不开TSRTerminate and Stay Resident这个词。当年没有“服务”“守护进程”这些说法想让一个程序在退出去之后继续干活唯一靠谱的方式就是TSR。这几天我把一个老项目翻出来重写了一遍——用汇编实现一个驻留内存的时钟程序带热键开关还能安全卸载干脆整理成一篇完整的实操记录。这个示例选得很有代表性时钟功能本身简单但它触及了TSR的全部核心机制——驻留退出、中断向量挂钩、热键响应、多路复用识别、以及最麻烦的删除与内存释放。哪怕你以后不写TSR这一套中断与内存管理的思路对理解现代操作系统的内核机制也很有帮助。适合正在学汇编、对DOS环境下编程感兴趣、或者想挑战“如何让程序退而不死”这类问题的读者。1. 先把TSR的底层逻辑掰开揉碎1.1 TSR是什么不是退出是“换一种活法”普通程序退出是把控制权交还给DOS内存释放一切结束。TSR完全不同它告诉DOS“我要退出了但请保留我占用的内存我留下的中断处理代码还需要在后台响应事件”。DOS会保留从PSP开始指定大小的内存代码段与数据段仍然活着只是“前台执行流”没了剩下的是通过中断向量表被唤醒的各个处理函数。实现TSR的关键在于理解“谁是入口”。一个普通程序只有一个入口跑完就结束TSR则有多个入口——时钟中断来了走时钟入口按键中断来了走键盘入口别的程序发来“你还在吗”的查询走多路复用入口。程序“驻留后”的形态更像一个插件包挂在中断链上等待触发。1.2 实现TSR必须回答的五个问题写一个能用的TSR代码量不大但每个环节都有坑怎么驻留用INT 21H功能31H设定驻留节数paragraph1节16字节DOS会保留相应内存。怎么被唤醒改中断向量表把自己的处理函数挂到目标中断上例如INT 1CH用户时钟中断、INT 09H键盘硬件中断。怎么不破坏链每个中断都可能被多个程序钩过必须保存旧向量处理完自己的逻辑后调用旧处理程序。怎么避免重复加载用INT 2FH多路复用协议程序加载时先发一个查询已经驻留的同名程序会应答。怎么安全卸载恢复被修改的全部中断向量释放驻留内存块。其中释放“正在执行的自己”是最考验细节的一步。1.3 为什么拿时钟开刀最合适时钟非常适合做TSR入门。一是效果直观屏幕上出现一个走动的HH:MM:SS有没有生效一眼便知二是它天然由中断驱动每秒刷新一次正好覆盖BIOS时钟中断的挂钩场景三是一个完整的时钟驻留程序会用到INT 1CH、INT 09H、INT 2FH三种中断正好把TSR的三大入口全部练一遍。我用的是COM格式TINY模型把数据、代码、堆栈全部塞进同一个64KB段。选择COM不是偷懒而是TSR释放内存时计算保留大小的逻辑会变得非常简洁——所有驻留内容从PSP段开始连续存放驻留结束地址减去段基址就能直接换算成节数。2. 核心机制拆解中断、热键、卸载协议2.1 中断向量与链式调用别做断链的罪人DOS下中断向量表位于内存最低端每4字节一项存放中断处理程序的偏移和段地址。读取用INT 21H功能35H设置用功能25H。TSR安装时先用35H读出旧向量保存起来再用25H把新向量指向自己的处理函数。这里最容易犯的错误是“断链”。如果某个中断之前已经被另一个TSR钩过而你在自己的处理函数里不调用旧向量等于把前面那个TSR的钩子藏起来了。轻则对方功能失效重则系统出现不可预期的问题。正确的做法是链式调用; 方式一调用旧处理程序后自己IRET pushf call dword ptr cs:[old_1ch_ofs] ; 方式二直接把控制权交给旧处理程序由它完成最终的IRET jmp dword ptr cs:[old_09_ofs]两种方式适用场景不同。INT 1CH这种“广播型”中断通常用第一种把控制权拿回来继续做事INT 09H这种硬件键盘中断用第二种更自然因为旧BIOS处理程序会负责完整的键盘扫描码处理、缓冲区写入和IRET返回。2.2 热键是怎么被“热”起来的写热键就是在键盘硬件中断INT 09H里做“过滤”。每次按键键盘控制器把扫描码送到端口60H触发IRQ1CPU跳转到INT 09H处理程序。我们在自己的处理程序里读取60H端口判断扫描码再配合键盘状态字节判断是否按了CtrlAlt组合键是的话就切换显示开关。键盘状态字节存在BIOS数据区0040:0017Hbit2表示Ctrl按下bit3表示Alt按下。组合键判断只需测试这两位。扫描码的选择有个技巧——直接用C键扫描码2EH因为它与“Clock”的首字母对应语义清晰。2.3 时间数据从哪里来读CMOS实时时钟DOS的TSR不能依赖前台程序的环境获取时间最可靠的是直接读CMOS实时时钟。CMOS芯片通过端口70H索引和71H数据访问寄存器00H是秒、02H是分钟、04H是小时。读出的是BCD码例如0x45代表45秒需要拆成高四位与低四位加0x30转成ASCII字符。读取时要注意I/O时序。旧机器上连续访问70H和71H需要一点延迟常见做法是在两条指令之间插入一个短跳转或NOP确保端口稳定。DOSBox与虚拟机里这种时序问题不明显但真机环境下最好保留。2.4 显示方式为什么直接写显存而不调用INT 10H后台中断程序里调用BIOS的INT 10H功能不是不行但存在两个隐患一是INT 10H在文本模式下相对安全但在图形模式下可能改变光标或寄存器状态干扰前台程序二是调用BIOS时可能触发其他钩子在中断上下文里层层嵌套容易出问题。稳妥的做法是直接写文本显存。80×25文本模式下显存段地址为B800H每个字符占2字节字符码属性字节偏移(行×80列)×2。我把时钟放在第0行第72列占用8个字符位置这个位置在屏幕右上角不太容易与命令行输入区冲突。属性用02H黑底绿字在既有的文本界面里不突兀。2.5 多路复用ID与卸载请求TSR之间的“暗号”多个TSR可能同时驻留卸载请求怎么准确找到目标DOS提供了INT 2FH多路复用机制。每个TSR约定一个自定义ID通常取AH0FFHAL自己选定的字节值。加载时发查询mov ax, 0FF00h ID ; AH0FFh, ALID int 2Fh cmp al, 0FFh ; 返回0FFh表示已有相同ID的程序驻留卸载请求用的是AH01H一组的约定AL00H表示“请求卸载”AL01H表示“询问是否允许卸载”。被询问的TSR需要检查DOS忙标志通过INT 21H功能34H获取InDOS标志地址如果为0说明DOS内核空闲可以安全调用DOS功能做清理。3. 完整代码实现与关键段解析3.1 程序框架常驻部分与初始化部分的划分COM格式的TSR结构很清晰入口先跳转到初始化代码把真正的“驻留体”——数据区、中断处理函数、卸载逻辑全部放在入口跳转之后、初始化代码之前。初始化代码执行完用功能31H驻留退出只保留从程序开头到real_end标签的内存。.model tiny .486 .code org 100h ID EQU 0C5h ; 多路复用ID避开常用值 start: jmp short init ; 跳过驻留部分 ; ---------- 常驻数据区 ---------- old_1ch_ofs dw 0 old_1ch_seg dw 0 old_09_ofs dw 0 old_09_seg dw 0 old_2f_ofs dw 0 old_2f_seg dw 0 indos_ofs dw 0 indos_seg dw 0 psp_seg dw 0 display_on db 1 time_ascii db HH:MM:SS, 0选用COM格式还有一个原因CS、DS、ES在程序加载时指向同一个段中断处理程序里访问数据区不用来回切段。但为了稳妥读数据区时我仍然显式加cs:前缀防止某些前台程序修改了DS后进入中断。3.2 时钟中断处理每秒刷新一次右上角INT 1CH由BIOS的INT 08H硬件时钟中断调用频率约18.2次/秒。我们没必要每秒刷18次简单起见直接每次刷新显示内容每秒变一次不会产生闪烁感。处理程序先调用旧的INT 1CH钩子再读CMOS时间最后把ASCII字符写入显存。new_1ch proc far push ds push es push ax push bx push cx push dx push si push di push cs pop ds ; 链式调用旧钩子保持中断链完整 pushf call dword ptr cs:[old_1ch_ofs] test cs:[display_on], 0FFh jz skip_draw ; 读秒寄存器 mov al, 0 out 70h, al jmp short io_delay io_delay: in al, 71h call bcd_to_ascii mov word ptr ds:[time_ascii 6], ax ; 读分钟寄存器 mov al, 2 out 70h, al jmp short io_delay2 io_delay2: in al, 71h call bcd_to_ascii mov word ptr ds:[time_ascii 3], ax ; 读小时寄存器 mov al, 4 out 70h, al jmp short io_delay3 io_delay3: in al, 71h call bcd_to_ascii mov word ptr ds:[time_ascii], ax ; 写显存第0行第72列偏移144 mov ax, 0B800h mov es, ax mov si, offset time_ascii mov di, 144 mov cx, 8 next_char: lodsb mov ah, 02h stosw loop next_char skip_draw: pop di pop si pop dx pop cx pop bx pop ax pop es pop ds iret new_1ch endp ; 输入ALBCD输出AX两个ASCII字符高位十位低位个位 bcd_to_ascii proc mov ah, al shr ah, 4 and al, 0Fh add ax, 3030h ret bcd_to_ascii endp这里有几个细节值得说明。test cs:[display_on], 0FFh是判断显示开关热键切换这个字节后时钟就会消失或重现。bcd_to_ascii把BCD数拆成十位和个位加上0x30变成字符正好放进AX一次写入两个字符位置。显存写入用stoswAX高8位是属性、低8位是字符码一条指令完成一个字符的显示。3.3 键盘中断处理CtrlAltC切换显示热键处理里最关键的是“别把所有按键都吞掉”。我们的处理程序只判断组合键是否被按下无论是否匹配最终都要把控制权交给旧的INT 09H处理程序否则键盘缓冲区不会写入字符整台机器的键盘就“失灵”了。new_09 proc far push ax ; 读扫描码 in al, 60h ; 只处理C键按下沿Bit70表示按下 cmp al, 2Eh jne int09_pass test al, 80h jnz int09_pass ; 检测CtrlAlt是否按下 push ds mov ax, 0040h mov ds, ax mov al, ds:[17h] pop ds test al, 00001100b jz int09_pass ; 满足条件切换显示标志 push bx push ds push cs pop ds xor byte ptr cs:[display_on], 1 pop ds pop bx int09_pass: pop ax jmp dword ptr cs:[old_09_ofs] new_09 endp按下C键会产生两个中断按下时扫描码为2EH松开时为AEH最高位置1。如果对两次都响应开关会被切两次看起来就像没反应。所以必须判断Bit7只在按下沿切换。这个坑非常经典我在后面的常见问题里也会专门提。3.4 多路复用与卸载逻辑最难啃的骨头卸载是整个项目难度最高的部分。它既要恢复三个中断向量又要释放驻留内存还要保证在释放“正在执行的自己”之后代码依然能安全返回。我的做法是把清理动作全部放在新INT 2FH处理程序的卸载分支里由二次运行的程序通过INT 2FH触发卸载请求。new_2f proc far ; 保留调用者的寄存器 push ds push ax push bx push cx push dx push si push di push cs pop ds cmp ah, 0FFh je chk_id cmp ah, 01h je uninstall_route jmp chain_old chk_id: cmp al, ID je found jmp chain_old found: mov al, 0FFh jmp restore_ret uninstall_route: cmp al, 00h je do_free cmp al, 01h je check_safe jmp chain_old check_safe: ; 检查DOS忙标志0表示DOS空闲 mov es, cs:[indos_seg] mov bx, cs:[indos_ofs] cmp byte ptr es:[bx], 0 jne not_safe mov al, 0FFh jmp restore_ret not_safe: mov al, 00h jmp restore_ret do_free: ; 卸载前再次确认DOS空闲 mov es, cs:[indos_seg] mov bx, cs:[indos_ofs] cmp byte ptr es:[bx], 0 jne not_safe ; 关闭显示并清除屏幕上的时钟 mov byte ptr cs:[display_on], 0 call clear_clock ; 恢复 INT 1CH push ds mov ax, cs:[old_1ch_seg] mov ds, ax mov dx, cs:[old_1ch_ofs] mov ax, 251Ch int 21h pop ds ; 恢复 INT 09H push ds mov ax, cs:[old_09_seg] mov ds, ax mov dx, cs:[old_09_ofs] mov ax, 2509h int 21h pop ds ; 恢复 INT 2FH最后恢复自己 push ds mov ax, cs:[old_2f_seg] mov ds, ax mov dx, cs:[old_2f_ofs] mov ax, 252Fh int 21h pop ds ; 释放驻留内存 mov es, cs:[psp_seg] mov ah, 49h int 21h mov al, 0FFh jmp restore_ret chain_old: ; 不属于本程序的中断交给旧处理程序 pop di pop si pop dx pop cx pop bx pop ax pop ds jmp dword ptr cs:[old_2f_ofs] restore_ret: pop di pop si pop dx pop cx pop bx pop ax pop ds iret new_2f endp卸载顺序有讲究先恢复INT 1CH和INT 09H最后恢复INT 2FH。因为恢复INT 2FH之后即使再有程序发出INT 2FH查询也不会再进入我们的处理程序了。但此刻我们仍处于自己的处理函数中所以这个“最后恢复自己”的动作必须放在清理动作里最后执行。清屏函数clear_clock也值得一提。它把time_ascii内容改成8个空格再用同样的写显存逻辑刷新一次右上角区域。很多人卸载时忽略清屏结果驻留程序卸掉了屏幕右上角却留下一串“HH:MM:SS”观感很业余。clear_clock proc push ax push cx push si push di push es push ds push cs pop ds mov ax, 0B800h mov es, ax mov si, offset time_ascii mov di, 144 mov cx, 8 clr_next: mov al, mov ah, 02h stosw loop clr_next pop ds pop es pop di pop si pop cx pop ax ret clear_clock endp3.5 初始化逻辑检查驻留、保存向量、计算驻留大小初始化部分负责四件事用多路复用协议检查是否已驻留、根据命令行参数决定“安装”还是“请求卸载”、保存旧中断向量并安装新向量、计算驻留大小后退出。init: ; 检查是否已驻留 mov ax, 0FF00h ID int 2Fh cmp al, 0FFh je already_running ; 保存PSP段地址卸载时要用 mov cs:[psp_seg], es ; 获取DOS忙标志地址 mov ah, 34h int 21h mov cs:[indos_seg], es mov cs:[indos_ofs], bx ; 保存旧中断向量 mov ax, 351Ch int 21h mov cs:[old_1ch_ofs], bx mov cs:[old_1ch_seg], es mov ax, 3509h int 21h mov cs:[old_09_ofs], bx mov cs:[old_09_seg], es mov ax, 352Fh int 21h mov cs:[old_2f_ofs], bx mov cs:[old_2f_seg], es ; 安装新中断向量 push ds mov ax, 251Ch mov dx, offset new_1ch push cs pop ds int 21h pop ds push ds mov ax, 2509h mov dx, offset new_09 push cs pop ds int 21h pop ds push ds mov ax, 252Fh mov dx, offset new_2f push cs pop ds int 21h pop ds ; 打印安装提示 mov dx, offset msg_installed mov ah, 09h int 21h ; 驻留退出计算驻留节数 (offset real_end 0x10F) 4 mov dx, offset real_end add dx, 10Fh shr dx, 4 mov ax, 3100h int 21h already_running: ; 已驻留检查命令行参数是否为 /U mov si, 0081h cmp byte ptr [si], / jne show_running inc si mov al, [si] and al, 0DFh cmp al, U jne show_running ; 发送卸载请求 mov ax, 0100h int 2Fh ; 重新检测驻留状态 mov ax, 0FF00h ID int 2Fh cmp al, 0FFh je uninstall_failed mov dx, offset msg_uninstalled jmp print_msg uninstall_failed: mov dx, offset msg_cannot_uninstall jmp print_msg show_running: mov dx, offset msg_running print_msg: mov ah, 09h int 21h mov ax, 4C00h int 21h msg_installed db TSR clock installed. CtrlAltC toggle. /U uninstall.$ msg_running db TSR clock already running. Use /U to remove.$ msg_uninstalled db TSR clock uninstalled.$ msg_cannot_uninstall db Uninstall failed: DOS busy or not safe.$ real_end: end start驻留大小那句add dx, 10Fh是很多初学者最容易算错的地方。COM程序的段寄存器指向PSP段代码从偏移100H开始。offset real_end是real_end相对代码段的偏移。所以驻留字节数 0x100PSP部分 offset real_end向上取整到16字节边界就加0xF最终除以16。合并起来就是add dx, 10Fh; shr dx, 4。少了0x100这一加卸载时释放的内存块就会缺一块轻则驻留代码残留重则破坏内存分配链。4. 编译、运行与实测记录4.1 编译与运行环境写DOS汇编我习惯用TASM配合TLINKCOM格式一条命令搞定TASM TSRCLK.ASM TLINK /t TSRCLK.OBJ如果用的是MASM可以这样ML /AT TSRCLK.ASM拿到TSRCLK.COM后运行一次即驻留。屏幕上打印“TSR clock installed. CtrlAltC toggle. /U uninstall.”。此时左上角会不断刷新时间。推荐在DOSBox 0.74、386MAX或真机DOS下测试。Windows 2000/XP的DOS窗口对TSR兼容性很差尤其是中断向量挂钩和实模式显存访问都很容易失败不建议作为调试环境。4.2 实测步骤与预期结果我按这个顺序做了完整验证运行TSRCLK.COM屏幕出现安装提示右上角出现绿色时间。按下CtrlAltC时间消失再按一次恢复。整个过程其他程序照常运行键盘输入无异常。再次运行TSRCLK.COM提示“already running”说明多路复用检测生效没有重复驻留。运行TSRCLK.COM /U提示“uninstalled”右上角时间被清空驻留内存释放。再运行TSRCLK.COM /U因为已无驻留实例进入安装流程再次驻留。这里有一个细节如果还想卸载必须先卸载再执行/U或者直接用普通参数运行会提示已驻留。第5步其实是我故意测试的一个边界行为程序不知道自己是“第二次运行”还是“第一次运行”它只知道“是否已有驻留实例”。已驻留时带/U就发卸载请求未驻留时直接执行安装流程。如果你在未驻留状态下执行/U它会直接安装一个实例这算是一个可用性小瑕疵但演示原理足够。4.3 调试心得两个实用的辅助手段写TSR时中断处理程序一旦挂上出问题很难动态调试。我常用的办法是在new_1ch和new_09入口处暂时写一段显存调试信息确认真进入了处理函数再逐步增加业务逻辑。比如先只显示一个固定字符串“OK”确认中断链正常后再换成读CMOS的代码。第二个手段是“半驻留调试法”。先把驻留退出改成正常退出4C00H用调试器单步跟踪初始化部分确认所有向量保存、设置、InDOS标志获取都正确。等初始化逻辑全部验证无误再把退出方式改回31H。很多莫名其妙的问题都是初始化阶段某个寄存器被覆盖导致的提前把这一段跑通能省大量时间。5. 常见问题与排查技巧实录5.1 现象与原因速查表现象可能原因排查方向加载后屏幕无时间显示显示开关未打开显存段不对CMOS读取出错检查display_on初值在new_1ch里写固定字符测试热键按下无反应扫描码判断错误键盘状态字节读取失败按下/松开都触发导致开关被切回确认C键扫描码为2EH检查Bit7按下沿判断卸载后屏幕残留时间字符卸载流程缺少清屏动作在恢复向量前调用clear_clock二次运行提示已驻留正常现象带/U参数卸载后可重新安装卸载后内存未释放InDOS标志非0拒绝卸载驻留节数计算错误在DOS提示符下执行卸载检查驻留大小公式卸载后系统出现异常中断向量恢复顺序错误未恢复全部钩子确认1CH、09H、2FH都恢复先恢复非自身中断再恢复2FH5.2 最常见的坑C键按下与松开都会触发键盘中断在按键按下和松开时都会触发这是硬件特性。按下C键扫描码是2EH松开时是AEH2EH加80H。如果处理程序不判断Bit7用户每次按键都会触发两次切换结果就是显示状态来回翻转看起来像热键没生效。这个坑在真机上尤为明显因为真机的按键触发时序更规范按下/松开间隔清晰。我最初在DOSBox里测试时bug没暴露换到真机后才发现热键时灵时不灵排查了半天才定位到是这个原因。5.3 DOS忙标志卸载失败的头号元凶卸载时需要调用INT 21H的25H和49H功能而DOS内核是单线程的不可重入。如果卸载请求恰好发生在某个程序调用DOS功能的中途TSR的卸载流程再调用INT 21H就会破坏DOS内部状态轻则卸载失败重则整个系统死机。防御手段是检查InDOS标志。INT 21H功能34H返回ES:BX指向一个字节这个字节非0就表示DOS正忙。卸载流程必须确认它为0才继续。我的代码里在check_safe和do_free各检查了一次双保险。实测发现如果在命令提示符下立即输入/U卸载InDOS标志几乎总是0卸载很顺但如果在某些批处理脚本执行过程中触发卸载就可能遭到拒绝这是正常的保护行为。5.4 与其他TSR的冲突中断链的伦理问题DOS时代的TSR生态混乱很多程序不遵守“链式调用”的规则直接覆盖中断向量导致其他人的钩子失效。我们自己写的TSR要学会与它们共存。判断标准只有一个进入自己的处理程序后凡是自己不处理的中断都必须转发给旧向量凡是自己处理完后需要旧程序继续处理的也必须转发。一个典型例子如果先加载了我们的时钟TSR再加载另一个也挂钩INT 1CH的程序另一个程序保存的旧向量是我们的new_1ch它执行完后会调用我们的new_1ch我们执行完再调用最初的BIOS处理程序整个链是通的。反过来如果后加载的程序粗暴覆盖INT 1CH而不保存旧向量我们的时钟就“掉线”了。这不是我们能控制的但理解这个机制能帮你快速判断问题出在谁身上。5.5 进一步扩展方向这个时钟程序只是个骨架想深入可以继续加功能。比如在INT 1CH里计数18次再刷新一次把显示频率从18.2Hz降到1Hz减少显存写入比如增加秒表或闹钟功能到点通过扬声器发声比如用INT 28HDOS空闲中断在DOS空闲时做更复杂的文件操作任务。最实用的扩展是给热键增加“按下后弹出菜单”的能力这已经接近当年那些商业TSR工具软件的核心交互模式了。我个人在实际操作中最深的体会是TSR程序的难点从来不在业务逻辑本身而在于你写的每一行中断处理代码都必须假设“当前环境完全未知”。你不知道DS指向哪里不知道栈还剩多少空间不知道DOS是不是正忙。所有资源都要自己保存、自己恢复所有调用都要先确认安全。这种“处处设防”的编程思路放到今天的嵌入式开发和驱动开发里依然完全适用。写这个项目前我自认为对中断已经够熟真正把整个卸载流程调通之后才发现之前那些“熟了”其实只是“知道”离“掌握”还差了整整一个TSR的时间。