
DOS那个年代程序员的电脑桌上摆着一本翻旧了的《PC汇编语言程序设计》大家嘴里聊得最多的一个词就是TSRTerminate and Stay Resident终止并驻留程序。那时候我刚学会在DEBUG里改一段字节把程序“不进不出”地钉在内存里然后按一个热键让它在屏幕角落蹦出个时钟这种成就感哪怕是后来写出了完整的GUI程序都没再出现过。TSR的核心虽然不是多高深的数据结构、算法但它的门槛恰恰在于程序不退出而是把你的代码、数据、中断处理程序全部留在内存中随时响应系统事件。这篇文章就用一个“屏幕角落时钟”的完整例子把TSR拆开讲透程序怎么驻留热键怎么拦截显示怎么刷新最后怎么把自己从内存里干净地删除。全程16位汇编DOS平台代码用MASM或TASM都能编译。如果你想了解后台常驻程序的生命周期或者想试试“操作系统的中断向量表到底怎么被人改来改去”这篇可以当一份入门地图。1. 为什么现在还有人研究TSR一段代码背后的系统视野1.1 一个传说级的“后台程序”当时是怎么实现的现在写一个后台服务Linux里是daemonWindows里是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 1CHBIOS在每次系统定时器中断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寄存器是不是这个IDnew_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这里如果返回AL0FFH表示TSR已经在内存里了安装程序检测到后应该提示“程序已驻留”并退出如果AL0那就正常执行安装流程。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 ; AH31HAL0退出码 int 21h ; 不返回程序驻留下来为什么这里用31H而不是更老的INT 27HINT 27H是CP/M时代的老功能它有一个著名限制最多只驻留64KB而且把驻留大小写死在DX寄存器里是“字节数-1”这样的怪逻辑。31H是DOS 2.0以后提供的现代方法大小更灵活而且可以用AL传递退出码给父进程。凡是正经的TSR都应该用31H。4.2 热键拦截的核心读取键盘扫描码而不是字符码键盘中断处理程序里你从端口60H读到的不是字符码ASCII而是扫描码。F12的扫描码是58H它的按下和释放分别对应按下58H释放D8H0x58加上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 事件地也就是用段寄存器指向B800HDI寄存器指向对应的偏移地址然后把时间字符串的字符一个个写进去。一个简单的显示片段; 准备把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是否真的驻留。如果查询结果AL0FFH且ES:BX指向有效的控制块说明在。第二步发送卸载请求也就是INT 2FH AH0A0H AL01H。驻留侧的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处是一个独立的段要单独调用AH49H释放。释放TSR主内存块。主内存块包括PSP、数据段、代码段。释放前要把段地址放入ES然后调用AH49H。顺序为什么不能反因为环境块的地址是记录在PSP偏移2CH里的而PSP又位于主内存块的开头。如果先释放主内存块PSP就不复存在环境块地址自然找不到了。哪怕你早就把环境块段号存到了自己的变量里先释放主内存后释放环境块也不是不行但一旦PSP没了DOS对这块区域的记录就乱了不规范。标准习惯是先释放环境块再释放主内存块。这里还藏着一个深坑如果卸载动作是在INT 1CH的刷新循环里执行的我们选用的方案就是它那么“释放主内存块”这条指令本身也位于这块内存中。理论上AH49H执行完内存块已经归还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读写时如果硬件中断又把控制权交给了你的TSRTSR再调INT 21HDOS就发现“内部忙”然后死等等到天荒地老也不会返回。后来我用的办法就是彻底绕开DOS调用读BIOS tick、直接写显存、只在极少数安全点才动DOS功能调用。这也给我的后续所有底层开发定下一条规矩在中断处理器里能吃“硬件”的就别吃“系统服务”入口里能缩短的执行路径就拼命缩短。7.2 忘记发EOI的后果键盘永久“按不动”调试热键功能时我还干过一件“成就非凡”的事在INT 09H里识别到F12后直接跑了iret忘记向8259A发送EOIEnd Of Interrupt中断结束指令。结果就是第一次按键后系统就再也收不到任何键盘输入了只能重启。这个现象的原因每次想起来都觉得恍如隔世键盘IRQ1由8259A主片管理当键盘中断被响应后8259A的ISR寄存器里对应位就置1告诉CPU“当前键盘中断正在处理”。必须在中断处理完毕后向端口20H发送一个EOI命令mov al, 20h; out 20h, al8259A才会清除这个状态位后续中断才能发给CPU。如果你选了不链接旧处理程序、自己收尾热键处理那么EOI必须自己发否则键盘控制器就一直认为“中断没处理完”从此罢工。如果选择链接旧INT 09H则不需要自己发EOI因为BIOS中断处理程序末尾会发。这个选择题没有标准答案完全看你想不想让这个热键进入键盘缓冲区。我的建议是独立功能键F12这种直接吞掉并自付EOI组合键比如CtrlF12要谨慎避免影响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链确认驻留块确实被释放了而不是只把代码移出中断向量就算完事。这招看起来原始但它能帮你验证卸载流程是不是真的干净避免留下一个“看起来没了、实际还占着内存”的驻留幽灵。