DOS那个年代,程序员的电脑桌上摆着一本翻旧了的《PC汇编语言程序设计》,大家嘴里聊得最多的一个词就是TSR(Terminate and Stay Resident,终止并驻留程序)。那时候我刚学会在DEBUG里改一段字节,把程序“不进不出”地钉在内存里,然后按一个热键让它在屏幕角落蹦出个时钟,这种成就感哪怕是后来写出了完整的GUI程序都没再出现过。TSR的核心虽然不是多高深的数据结构、算法,但它的门槛恰恰在于:程序不退出,而是把你的代码、数据、中断处理程序全部留在内存中,随时响应系统事件。
这篇文章就用一个“屏幕角落时钟”的完整例子,把TSR拆开讲透:程序怎么驻留,热键怎么拦截,显示怎么刷新,最后怎么把自己从内存里干净地删除。全程16位汇编,DOS平台,代码用MASM或TASM都能编译。如果你想了解后台常驻程序的生命周期,或者想试试“操作系统的中断向量表到底怎么被人改来改去”,这篇可以当一份入门地图。
1. 为什么现在还有人研究TSR:一段代码背后的系统视野
1.1 一个传说级的“后台程序”当时是怎么实现的
现在写一个后台服务,Linux里是daemon,Windows里是Windows Service,框架都帮你把生命周期管好了。但在DOS那个单任务、无保护模式、无API没封装的年代,“后台运行”完全是靠程序员自己跟硬件抢出来的。
当时的做法是:让程序调用DOS中断的31H功能,请求“终止但驻留”。它和普通程序退出的最大区别是,普通程序退出后DOS会把它的内存全部回收,而驻留退出意味着:你把一段代码放在了DOS管理的内存池里,DOS依然认为这块内存归你,而你这段代码里有几个中断处理程序,平时不说话,等中断来了就替系统跑一段活。
Borland的SideKick就是最典型的TSR,它提供了计算器、日历、电话本,按一个热键就浮在屏幕上。DOSKEY、中文输入法、病毒扫描器,全是TSR。当年用户判断一个程序员水平如何,就看你能不能写出一个“装了不崩溃、删除不残留”的TSR。
1.2 时钟TSR的完整目标与本文配套环境
这篇文章要做的时钟TSR,目标行为很简单:
- 程序执行一次后返回DOS,但内存中保留自己的代码和数据。
- 按F12键,屏幕右下角显示当前时间;再按一次,时钟消失。
- 在DOS提示符下可以用命令带参数的方式,把这个已经驻留的程序从内存中卸载,删除干净。
这个例子覆盖了TSR的全部生命周期:安装、驻留、热键响应、显示刷新、安全卸载。比单纯打一个“Hello World串口输出”有价值得多,因为它逼着你处理中断重入、内存块释放顺序、键盘扫描码这些实实在在的问题。
环境方面,我用的是16位实模式汇编,编译器选择MASM 6.11或TASM 5.0都可以,连接器用LINK或TLINK。运行环境可以是DOSBox、VMware里装FreeDOS/MS-DOS 6.22,或者在Windows的DOS虚拟机里跑。如果只想验证逻辑,用DOSBox最方便,因为它对老程序兼容性好,调试起来也不怕把宿主机搞崩。
2. 中断服务程序与中断向量表:TSR的生存基础
2.1 中断向量表里存的到底是什么
8086实模式下,物理内存最低地址的1024字节(0000:0000到0000:03FF)是中断向量表。这张表一共256个表项,每项4个字节,存的是一个完整的远指针(段地址:偏移地址)。CPU遇到某个中断时,比如键盘硬件中断INT 09H,会从这张表里找到编号09H对应的远指针,然后跳过去执行。
把中断向量表想象成饭店前台的总机分机表:CPU是个打内线电话的人,它报一个分机号“09”,电话系统就自动转到对应的分机——也就是那段中断处理程序。TSR要做的事情,就是悄悄修改总机表,把你自己的分机号填到原本系统分机的位置上。修改之后,以后所有拨到09H的电话都会先接到你这里。
2.2 时钟TSR需要挂钩的中断
不同的TSR需要抢不同的中断,这完全取决于功能。我们的时钟TSR最少要碰三个:
- INT 09H:键盘硬件中断。每次有按键按下或释放,8259A中断控制器会给CPU发IRQ1请求,随后CPU执行INT 09H。这是热键拦截的核心。
- INT 1CH:BIOS在每次系统定时器中断(INT 08H)时,会调用INT 1CH作为“用户可接管”的软中断。它约每55毫秒触发一次,适合用来刷新时钟显示。
- INT 2FH:多路复用中断。这个中断在当年是TSR社区里的“总线和消息中心”,不同程序通过不同的功能号(AH)和子功能号(AL)互不干扰地传递信息。我们用它来判断“是否已经驻留”和接收“卸载请求”。
时钟显示功能放在INT 1CH里刷新,热键拦截放在INT 09H里做,两者都由硬件中断触发,工作节奏稳定,不会因为DOS正在运行某个程序而停止。
2.3 修改中断向量:改之前先保存
修改中断向量,核心就两个DOS功能调用:
- INT 21H的35H功能:获取指定中断号的当前向量。
- INT 21H的25H功能:设置指定中断号的中断向量。
代码逻辑如下:
; 保存旧的INT 09H向量 mov al, 09h mov ah, 35h int 21h mov WORD PTR cs:[old_int09h + 0], bx mov WORD PTR cs:[old_int09h + 2], es ; 设置新的INT 09H向量 push ds mov ax, cs mov ds, ax lea dx, new_int09h ; DS:DX 指向新中断处理程序 mov al, 09h mov ah, 25h int 21h pop ds“改之前一定是保存,改之后一定要记下旧地址”这句话值得写十遍。因为卸载的时候,你需要把旧向量填回去。如果安装时没保存,卸载时就麻了——你根本不知道原来的中断处理函数在哪,系统也没法变魔术帮你找回备份。
另一点容易被忽略:中断向量保存和恢复时,要处理的是“远指针”,所以保存时es和bx都要保存,恢复时用ds:dx传给25H。很多第一次写TSR的人只保存了bx,忘了es,卸载一恢复就花屏、卡死。
3. 时钟TSR的模块设计与数据布局
3.1 分模块设计:安装部分、驻留主体、卸载控制
一个TSR程序从代码结构上看,至少要分两部分:安装部分和驻留部分。
- 安装部分:程序第一次执行时运行的代码。它负责解析命令行、判断是否已经驻留、装好中断向量、然后用31H功能驻留退出。这部分代码在驻留退出后就可以丢弃,但为了简单我们也一并驻留,不刻意节省那几百字节。
- 驻留部分:一旦程序驻留后留在内存里的部分,包含了新的中断处理程序、数据变量、卸载服务例程。它才是TSR的主体。
还有一个“卸载器”的角色。它可以是同一个程序第二次运行时,通过命令行参数UNLOAD触发,走一个“面向已经驻留的那份程序”发卸载请求的路径。所谓“同一个程序”,实际跑的时候是两份不同的进程:一份已经驻留,一份只是来传递指令的。
3.2 驻留数据结构:所有状态必须放进TSR段
写普通汇编程序时,变量可以随便放在数据段里,程序退出后内存就释放了。但TSR不一样:驻留之后没有“自己的进程执行上下文”,所有变量和状态位都必须在驻留段内长期保留。
我习惯定义一块统一的数据区,用结构体组织:
TSR_Block struc dwSignature dw 0A55Ah ; 签名,防止误判 bInstalled db 0 ; 是否已安装 bVisible db 0 ; 时钟显示开关 bDrawBusy db 0 ; 防重入标志 bUnloadReq db 0 ; 是否收到卸载请求 wHotKeyMake db 58h ; F12的make code wHotKeyDown db 0 ; 热键是否处于按下状态 oldInt09 dd 0 ; 保存的旧INT 09H向量 oldInt1C dd 0 ; 保存的旧INT 1CH向量 oldInt2F dd 0 ; 保存的旧INT 2FH向量 pEnvBlock dw 0 ; 环境块段地址 wSegment dw 0 ; 驻留段地址(即PSP段) bcdH db 0 bcdM db 0 bcdS db 0 TSR_Block ends把数据结构这么设计的原因很实际:
- 签名(dwSignature)用来防止“程序没驻留,卸载器误发请求”这种乌龙。在检查时,只有签名匹配才响应。
- 防重入标志(bDrawBusy)非常关键。时钟显示在INT 1CH里做,而INT 1CH本身是嵌套在INT 08H里的,如果显示逻辑里发生了一点停顿、再来一次INT 08H打断了上一次显示,两个显示函数就会跑重。我们要靠这个标志位保证同一时刻只有一份刷新逻辑。
3.3 用INT 2FH做“我已驻留”的标记与多实例检测
TSR的第一个大问题就是:程序被用户不小心执行了两次,装了两份副本进内存,MODIFY原有中断向量链都被二次改写,最后卸载时局面混乱到无法收拾。
怎么避免?答案就是INT 2FH。TSR社区约定了一个通用协议:每个TSR程序选择一个未被使用的“多路复用ID”(常见的范围是0A0H~0BFH之间),然后它的INT 2FH处理函数检查AH寄存器是不是这个ID:
new_int2f PROC FAR cmp ah, 0A0h ; 检查是不是我的ID jne pass_to_old_2f cmp al, 00h ; 查询是否已安装 jne check_unload mov al, 0FFh ; 返回0FFh表示已被安装 push bx push es mov bx, cs mov es, bx lea bx, TSR_Block ; ES:BX指向驻留控制块 iret check_unload: cmp al, 01h ; 卸载请求 jne pass_to_old_2f or byte ptr cs:[TSR_Block.bUnloadReq], 1 iret pass_to_old_2f: jmp cs:[TSR_Block.oldInt2F] new_int2f ENDP这里如果返回AL=0FFH,表示TSR已经在内存里了,安装程序检测到后应该提示“程序已驻留”并退出;如果AL=0,那就正常执行安装流程。
INT 2FH最初的本职工作其实是打印假脱机管理,后来因为它的机制足够简单可靠,被各种TSR当成“总线消息通道”来用。只要每个程序选不同的ID,互相之间就不会干扰。如果你在DOSBox里跑其他TSR程序,遇到ID冲突时,需要换一个可用ID。以前还真的有程序因为ID选得不好,跟打印缓存驱动打架。
4. 安装与热键拦截:驻留时必须处理的两个入口
4.1 安装流程:从命令行到驻留退出
安装部分做的事情非常直接,依次执行:
- 解析命令行。如果带UNLOAD参数,就进入卸载流程;否则继续安装。
- 通过INT 2FH查询该ID是否已有程序驻留。已驻留则提示并退出。
- 保存旧INT 09H、INT 1CH、INT 2FH向量。
- 设置新的INT 09H、INT 1CH、INT 2FH向量。
- 记录环境块段地址(从PSP的2CH偏移处读取)。
- 调用INT 21H的31H功能驻留退出。
驻留退出的代码是这个样子的:
; 计算驻留大小:从TSR数据开始到TSR_END mov dx, OFFSET TSR_END - OFFSET TSR_DataStart add dx, 0Fh ; 向上对齐到16字节段落 mov cl, 0 ; 退出码 mov ax, 3100h ; AH=31H,AL=0退出码 int 21h ; 不返回,程序驻留下来为什么这里用31H而不是更老的INT 27H?INT 27H是CP/M时代的老功能,它有一个著名限制:最多只驻留64KB,而且把驻留大小写死在DX寄存器里是“字节数-1”这样的怪逻辑。31H是DOS 2.0以后提供的现代方法,大小更灵活,而且可以用AL传递退出码给父进程。凡是正经的TSR都应该用31H。
4.2 热键拦截的核心:读取键盘扫描码而不是字符码
键盘中断处理程序里,你从端口60H读到的不是字符码(ASCII),而是扫描码。F12的扫描码是58H,它的按下和释放分别对应:
- 按下:58H
- 释放:D8H(0x58加上0x80的break位)
所以判断一个热键是否被按下的瞬间,逻辑是:
new_int09h PROC FAR in al, 60h ; 读键盘扫描码 cmp al, 58h ; 是F12的按下码? jne @F ; 不是则交给旧处理程序 ; 处于按下沿 cmp byte ptr cs:[TSR_Block.wHotKeyDown], 0 jne @F ; 已经处理过按下,跳过 mov byte ptr cs:[TSR_Block.wHotKeyDown], 1 xor byte ptr cs:[TSR_Block.bVisible], 1 ; 切换显示开关 ; 这里选择“吞掉”F12,不让它进键盘缓冲区 mov al, 20h out 20h, al ; 发送EOI给8259A pop ax iret @@: ; 处理F12的释放码D8H,清除wHotKeyDown标志 cmp al, 0D8h jne continue_chain mov byte ptr cs:[TSR_Block.wHotKeyDown], 0 mov al, 20h out 20h, al pop ax iret continue_chain: jmp cs:[TSR_Block.oldInt09H] new_int09h ENDP这里有个关键选择:识别到F12时,我只发送EOI(结束中断命令)给8259A,但没有链接旧INT 09H,因此F12不会进入系统的键盘缓冲区,也不会被正在运行的程序读到。如果希望按键同时透传给前台程序,那就必须调用旧处理程序,并且不允许自己发EOI。这个“吞掉还是传递”的选择,直接决定了热键会不会“泄漏”到其他程序里。
为什么只在按下沿触发?因为如果按下和释放都触发切换,那用户按一次F12,屏幕上时钟会闪两下(一下显示、一下消失)。真实键盘总是先产生按下再产生释放,所以必须严格区分边沿。
4.3 为什么不能直接在键盘ISR里刷新时钟
你可能会想:既然都在键盘中断里检测到F12了,直接在这里把时钟画上去不就行了?
理想很丰满,但实际不行,原因有两层:
第一,INT 09H是硬件中断,它运行在中断上下文里,此时屏幕显示要访问显存、读取系统时间,这些操作如果耗时过长,会延迟后续按键的处理,用户会感觉键盘“卡”。更危险的是,如果此时前台程序正在调用DOS功能,而你在这个中断里又调用INT 21H的2CH去取时间,DOS内部状态机立刻乱掉,轻则返回错误时间,重则死机。
第二,键盘中断处理中,如果调用了DOS功能而DOS目前不可重入,整个系统会因为等待一个永远不会释放的信号量而挂起,这种崩溃现象非常经典。
所以正确做法是:键盘ISR只负责任务标记——检测到热键就翻转bVisible标志,然后让INT 1CH里的刷新逻辑慢慢地去重绘屏幕。ISR越小,系统越稳。
5. 时钟显示与刷新:ISR里最怕的事情
5.1 更安全的时间来源:BIOS tick计数
在TSR里读取系统时间,最安全的方式不是调用DOS,而是直接读BIOS数据区的tick计数器。BIOS从开机起,在内存0040:006C处维护了一个32位的整数,记录系统定时器中断(INT 08H)发生的次数,大约每秒增加18.2065次。同时在0040:0070处还有一个字节标志,表示是否已经过了午夜零点。
读取它的代码:
; 读取BIOS tick计数器 mov ax, 0040h mov es, ax cli ; 先读高两位,再读低两位,防止读出时发生进位 mov dx, es:[006Eh] mov ax, es:[006Ch] sti这个cli/sti组合有必要吗?很有必要。因为tick计数器是32位的,如果你的第一条MOV刚读完低16位,此时恰好来了一个tick中断,把低16位进位到高16位,你读到的两个半截数据就对不上,导致时间偶尔瞬间跳到几十分钟后。关中断虽然不能完全消除,但能把窗口缩到极小。
拿到tick之后转换成时分秒,通常做法是拿tick总数除以每秒tick数得到总秒数,再除以60得到分钟,等等。为了简化,完全可以直接用32位乘除法。在演示代码里,也可以用一个近似公式:把tick计数先除以182,得到十分之一秒的整数部分,再换算成时分秒。精确度足够一个屏幕时钟使用。
这里我刻意回避了INT 21H的2CH功能(读取DOS系统时间)。原因前面提过:ISR里调用DOS,等于在地震时进入一栋正在震的楼,楼塌不塌纯看运气。真实TSR项目里,只有你的代码运行在“DOS安全点”时,才敢调DOS。这个安全点不是靠感觉,而是要检查DOS内部忙标志,太麻烦,不如绕开。
5.2 直接写显存显示时间
DOS字符屏幕在彩色文本模式下,显存段是B800H,每个字符占两个字节:低字节是字符ASCII码,高字节是属性(前景色和背景色)。要显示一个字符到第25行第71列,偏移地址就是:
(24 * 80 + 70) * 2 = 事件地也就是用段寄存器指向B800H,DI寄存器指向对应的偏移地址,然后把时间字符串的字符一个个写进去。
一个简单的显示片段:
; 准备把HH:MM:SS显示在右下角 mov ax, 0B800h mov es, ax mov di, (24 * 80 + 70) * 2 ; 第25行第71列 ; 此处可以从BCD变量逐字节转换,或者直接用字符表映射要注意:此时屏幕的行列计算是基于常见的80x25彩色文本模式。如果当前显示模式不是标准80x25,直接写显存很容易花屏。所以更严谨的做法是在安装时检查INT 10H的0FH功能返回的显示模式,不是标准文本模式就不要启动时钟显示,或者先切到标准模式再显示。不过当年的TSR为了简单,多数直接假设是标准模式。
5.3 加上防重入保护和避免闪烁的细节
INT 1CH每55毫秒会触发一次。如果单纯每次触发都重画整个时间串,屏幕会闪得很厉害,尤其当主程序正在高速刷新屏幕时,还会产生很难看的拖影。
避免闪烁的稳妥策略是:每次进入刷新逻辑,先读取一次新的tick,换算成新的三要素(时、分、秒);如果和上一次显示完全相同,直接返回,一个字符都不写;只有变化时才重绘。另外,为了显示不干扰屏幕原有内容,在“显示时钟”和“隐藏时钟”切换时,需要保存或恢复右下角区域原有的字符。
保存区域的实现可以这样:bVisible从0变1时,先把右下角的16个字符存到TSR自己的缓冲区里,然后往上写时钟;bVisible从1变0时,先把之前保存的字符回写显存,再清掉时间串。这样时钟的显示隐藏就像抽屉一样,不会在屏幕上留残影。
刷新函数开头的防重入逻辑也别忘了:
cmp byte ptr cs:[TSR_Block.bDrawBusy], 0 jnz skip_draw ; 上一次还没画完,直接跳过 mov byte ptr cs:[TSR_Block.bDrawBusy], 1 ; ... 实际刷新逻辑 ... mov byte ptr cs:[TSR_Block.bDrawBusy], 0 skip_draw: ret有了这个标志,即使INT 1CH在极极端情况下进入嵌套,也不会出现两份刷新逻辑同时操作显存的局面。
6. 安全卸载TSR:恢复中断向量与释放内存的次序
6.1 卸载指令怎么传达给驻留部分
用户想卸载时钟TSR时,重新执行一次TSR程序并附带UNLOAD参数。此时进程不再是安装流程,而是专属的卸载器。它要做的事情分三步:
第一步:通过INT 2FH查询确认目标TSR是否真的驻留。如果查询结果AL=0FFH且ES:BX指向有效的控制块,说明在。
第二步:发送卸载请求,也就是INT 2FH AH=0A0H AL=01H。驻留侧的INT 2FH处理器收到请求后,不是立刻释放内存,而是设置bUnloadReq标志,然后自己继续运行。
第三步:卸载器程序正常退出。剩下的收尾动作由驻留TSR自己在后续的INT 1CH刷新循环里完成。
有人会问,为什么不在INT 2FH收到请求时当场就恢复向量、释放内存?这里的核心障碍是:你现在正运行在“驻留代码本身”的中断处理里,栈指针、CS:IP都在驻留块内存范围内。如果你当场释放这块内存,那么还没执行完的return指令、iret、后续指令都可能踩在已经释放的内存上。就像你正站在船上砍船底,砍完之前船已经沉了,你自己也掉水里。这个逻辑任何TSR都绕不开。
6.2 恢复中断向量:越关键的越要放在前面
卸载时恢复中断向量的顺序,原则上要先恢复“可能被再次调用”的那个。我们的三个中断里,INT 09H和INT 1CH都可能在任意时刻被触发,卸载时不能随便先卸一个再卸另一个。稳妥操作流程是:
- 设置一个bUnloading标志,告诉所有中断处理函数:不要响应热键、不要刷新显示,直接链到旧处理程序。
- 把INT 09H恢复成旧向量。这一步之后,F12按键将回到系统默认处理。
- 把INT 1CH恢复成旧向量。这一步之后,时钟刷新停摆。
- 把INT 2FH恢复成旧向量。这一步之后,TSR在中断层面完全“消失”。
为什么“越关键越早恢复”?因为如果在恢复INT 09H之前先恢复了INT 2FH,依然驻留的INT 09H还会响应热键、还可能重绘屏幕,但新中断向量可能是半新的组合,系统处于不稳定态。先恢复硬件中断相关的、再恢复软件多路复用中断,是一种习惯上的稳健策略。
恢复向量的代码很简单:
; 恢复INT 09H push ds lds dx, cs:[TSR_Block.oldInt09] mov ax, 2509h int 21h pop ds ; 恢复INT 1CH、INT 2FH同理6.3 释放环境的正确顺序与不可重入的隐患
中断向量恢复之后,内存还没释放。此时TSR的代码其实已经不参与任何系统事件了,但它占用的内存块仍然挂在DOS内存控制块链上。下一步就是释放:
- 释放环境块。环境块地址在PSP偏移2CH处,是一个独立的段,要单独调用AH=49H释放。
- 释放TSR主内存块。主内存块包括PSP、数据段、代码段。释放前要把段地址放入ES,然后调用AH=49H。
顺序为什么不能反?因为环境块的地址是记录在PSP偏移2CH里的,而PSP又位于主内存块的开头。如果先释放主内存块,PSP就不复存在,环境块地址自然找不到了。哪怕你早就把环境块段号存到了自己的变量里,先释放主内存后释放环境块也不是不行,但一旦PSP没了,DOS对这块区域的记录就乱了,不规范。标准习惯是先释放环境块,再释放主内存块。
这里还藏着一个深坑:如果卸载动作是在INT 1CH的刷新循环里执行的(我们选用的方案就是它),那么“释放主内存块”这条指令本身也位于这块内存中。理论上,AH=49H执行完,内存块已经归还DOS,可当前代码段的所有数据都无效了。但因为紧接着执行的是IRET,它从硬件栈里弹出的是被中断的进程的CS:IP、FLAGS,而不是驻留代码的地址,所以不会跳回已释放的内存。释放完成后,TSR代码最后一次被执行的位置,恰好就是那条“释放内存”的调用处,之后CPU不会再碰这块内存。这个方案跑起来是非常安全的,只要你不是在TSR的普通调用里执行释放,而是在中断上下文里做收尾。
6.4 为什么很多TSR干脆不支持卸载
写到这里你就明白,TSR卸载不是简单几条INT 21H调用,而是要精确控制“异步中断什么时候会碰到正在释放的内存”这个竞态条件。DOS没有提供“内存块锁定”或“引用计数”这种现代OS机制,TSR只能自己评估安全点。
很多商业TSR早期的版本干脆不支持卸载,提示“你需要重启机器才能清除”。这不是懒,是保守;因为他们无法确定用户机器上还有别的TSR正在使用他们的中断,比如中文系统、鼠标驱动、光盘驱动。当外部TSR和你的TSR之间形成了中断链时,你擅自卸载、恢复向量,可能把别人的中断链弄断,导致后续系统崩溃。
实际工程里,大多数TSR会在安装时把自己注册进某个系统级的TSR管理器(比如EMM386、QEMM或者TSR工具包里),由管理器统一协调卸载顺序。但咱这种演示程序不需要那么重,只要做到:仅在自己独占、且确认没有其他TSR改过我们挂钩的中断时,才允许卸载。要做到这一点,一个笨办法是在安装时记录旧向量,卸载前再通过35H读出来的当前向量与自己填的新向量比对,如果发现当前向量不是自己,说明有别的程序截胡了,直接拒绝卸载并警告。
7. 调试中踩过的坑和几条实用建议
7.1 在中断里调用DOS功能,系统为什么直接卡死
我第一次写时钟TSR时,直接在INT 1CH里调了INT 21H的2CH读取系统时间,结果只要时钟一显示,整个DOS就“冻”住,鼠标也不动,键盘也没反应。折腾了一整个下午,才从汇编论坛的老帖子里意识到问题:DOS的INT 21H是不可重入的。所谓不可重入,就是同一时刻只允许一个程序在使用DOS内部的数据结构。前台程序执行DOS读写时,如果硬件中断又把控制权交给了你的TSR,TSR再调INT 21H,DOS就发现“内部忙”然后死等,等到天荒地老也不会返回。
后来我用的办法就是彻底绕开DOS调用:读BIOS tick、直接写显存、只在极少数安全点才动DOS功能调用。这也给我的后续所有底层开发定下一条规矩:在中断处理器里,能吃“硬件”的就别吃“系统服务”,入口里能缩短的执行路径就拼命缩短。
7.2 忘记发EOI的后果:键盘永久“按不动”
调试热键功能时,我还干过一件“成就非凡”的事:在INT 09H里识别到F12后,直接跑了iret,忘记向8259A发送EOI(End Of Interrupt,中断结束指令)。结果就是:第一次按键后系统就再也收不到任何键盘输入了,只能重启。
这个现象的原因每次想起来都觉得恍如隔世:键盘IRQ1由8259A主片管理,当键盘中断被响应后,8259A的ISR寄存器里对应位就置1,告诉CPU“当前键盘中断正在处理”。必须在中断处理完毕后向端口20H发送一个EOI命令(mov al, 20h; out 20h, al),8259A才会清除这个状态位,后续中断才能发给CPU。如果你选了不链接旧处理程序、自己收尾热键处理,那么EOI必须自己发,否则键盘控制器就一直认为“中断没处理完”,从此罢工。
如果选择链接旧INT 09H,则不需要自己发EOI,因为BIOS中断处理程序末尾会发。这个选择题没有标准答案,完全看你想不想让这个热键进入键盘缓冲区。我的建议是:独立功能键(F12这种)直接吞掉并自付EOI;组合键(比如Ctrl+F12)要谨慎,避免影响Ctrl状态。
7.3 调试TSR的方法:日志通道比断点可靠
普通程序调试可以用断点,但TSR运行在中断上下文里,你用DEBUG或Turbo Debugger往驻留代码里下断点,经常会遇到“断点一触发、系统就崩”的情况。因为中断处理现场非常敏感,断点中断(INT 03H)自身也会改变栈,很容易干扰8259A的EOI状态。
我后来最常用的调试手段是“日志通道”:在TSR里维护一个环形日志缓冲区,把一个元素(比如事件类型、时间值、显存地址)塞进去,然后在某个固定位置显示最后一个错误码,或者定时把日志写到磁盘文件。这比断点更符合TSR的运行本质:它是异步的,你要记录它到底发生了什么,而不是在某一行强行暂停它。
写日志这个习惯,后来用到Linux内核模块、Windows驱动开发里也依然好用。凡是和硬件中断沾边的代码,第一件事就是设计好日志输出通道。
7.4 我的一点个人经验
TSR整个学习曲线里,最陡的不是语法,而是“从同步思维切换到异步思维”。普通程序是你调用它、它返回结果;TSR是它被系统调用、它在后台偷偷运行、它要在任意时刻安全地出现在系统里又安全地退场。想明白这一层,你去看现在操作系统的各种钩子、驱动、后台服务,都会觉得熟悉,因为底层逻辑还是同一套:中断、向量、重入保护、生命周期管理。
顺带提一句,如果你后来接触到Go语言里的plan9汇编,或者写一些需要极致性能的汇编优化,你会发现当年在TSR里养成的习惯依然有用:对CPU现场敏感、对重入敏感、知道什么时候该关中断、什么时候不能依赖系统服务。这种“贴着机器思考”的能力,正是TSR这个老古董留给现代程序员最珍贵的东西。
最后分享一个我在测试卸载功能时用的小技巧:在卸载完成后,用MEM或DEBUG查看内存控制块链(在DEBUG里输入D命令查看MCB链),确认驻留块确实被释放了,而不是只把代码移出中断向量就算完事。这招看起来原始,但它能帮你验证卸载流程是不是真的干净,避免留下一个“看起来没了、实际还占着内存”的驻留幽灵。