简介这是一份聚焦雷军早年汇编作品的欣赏与研读材料面向对底层编程、DOS时代软件环境及技术人物成长轨迹感兴趣的开发者与计算机史爱好者。压缩包内含1个PDF文件约116KB以代码截图与注释文本为主便于直接阅读RI.ASM的关键片段、版本修订记录和功能说明。资源呈现了1994年发布的RI.ASM 2.12如何通过热键移除TSR程序并涉及SPDOS、WPS、鼠标驱动、Windows DOS提示符兼容性及QEMM、386MAX内存管理等细节可帮助读者理解汇编语言与硬件、操作系统之间的紧密联系。已有2364人学习适合用作底层编程案例、老式DOS环境排错思路参考以及观察雷军技术背景的补充材料。1. 一段 1994 年的汇编为什么「卸载 TSR」比写一个 TSR 难十倍很多人第一次翻到那段汇编代码注意力都放在署名上我盯着看的却是第 415 行到 435 行那二十几条movsw。那是一段 1994 年 7 月写成的 DOS 常驻程序文件名 RI.ASM版本号 V2.12功能用一句话说完按下热键把它之后加载的所有 TSR 从内存里抹掉让机器回到干净状态。写一个 TSR 只需要挂一个中断向量几十行就够。卸载一个 TSR 却要把中断向量表、内存控制块链、环境块、8259 中断控制器掩码、键盘状态字节、软驱参数、XMS/EMS 句柄一件件还原回加载前的样子任何一件漏掉机器就得重启。这段代码的价值不在「谁写的」而在于它把「如何安全地把一台机器倒带」这件事拆成了二十几个可验证的步骤每一步都留下了校验用的暗号。搞懂它等于搞懂了 DOS 内存管理的一整套黑匣子。2. TSR 的遗迹中断向量、PSP 与环境块怎么连成一条可回收的链2.1 三类钩子INT 9 抓按键INT 1C 做自愈INT 2F 开接口RI 一共只挂了三个中断但三个的职责完全不同。INT 9 是键盘硬件中断IRQ1 触发只要有键按下就会进。热键必须在「任何程序都没有主动读键盘」的时候也能被抓到所以只能挂在这里——挂 INT 16h 是不行的那是 BIOS 的键盘服务只有程序主动调用时才执行一个正在算数的程序永远不会调用它。INT 1C 是 BIOS 定时器 tick 钩子每秒约 18.2 次由 INT 8 内部调用。RI 把它当成「心跳巡检」每次 tick 检查 INT 9 的向量是否还指向自己如果不是说明被别的程序覆盖了重新挂回去。INT 2F 是多路复用中断RI 用它开了一个编程接口功能号 0C0D7h 到 0C0DAh查询自身段址、卸载自身之后的 TSR、卸载包括自身在内的全部、清除所有 RI 副本。四个功能号构成了一个极小的「进程管理 API」。这套分工解决了一个很实际的问题热键判定和内存回收是两件事前者要低延迟、跑在中断上下文里后者要极其小心、不能被打断。放进同一个中断处理程序里任何一处出错都是死机。2.2 自愈逻辑里那个 IE 签名INT 1C 里的巡检逻辑值得单独看一眼因为它是整份代码里最有意思的一段。newINT1C: test cs:Status, SKbit jnz GoINT1C cmp cs:StopFlag, 0 jz 0 ; 检查 INT 9 是否仍指向本程序 push ds push ax xor ax, ax mov ds, ax mov ax, ds:[9*4] ; INT 9 向量偏移 cmp ax, offset NewInt9 pop ax pop ds jnz GoINT1C ; 不是自己直接走原 INT 1C mov cs:StopFlag, 0 0: push ax push ds push es xor ax, ax mov ds, ax mov es, ds:[9*42] ; INT 9 向量段址 cmp word ptr es:[101h], IE ; 段内偏移 100h 处是否有签名 jz 1 cli mov cs:StopFlag, 1 mov ax, ds:[9*4] mov cs:oldINT9_addr2, ax mov ax, ds:[9*42] mov cs:oldINT9_addr2[2], ax mov ds:[9*4], offset newINT9_2 mov ds:[9*42], cs sti 1: pop es pop ds pop ax jmp GoINT1C先看校验方式。它没有去比对完整的 4 字节向量值而是把向量里的段址取出来看那个段偏移 0x101 处的两个字节是不是I、E。因为 COM 程序加载时org 100h就是代码起点作者把自己名字的前三个字母放在代码段最开头形成L E I三字节签名0x101 正好是后两个。为什么不比对完整向量因为 TSR 可能被重定位、可能被内存整理工具搬走段址会变签名不会。这是 1994 年在没有句柄、没有 UUID 的环境下能做的最稳的身份验证。用今天的说法这就是一个手写的 magic number 校验。再看oldINT9_addr2的保存。第一次挂钩时保存的是「原始 INT 9」第二次挂钩时保存的是「当前 INT 9」——也就是另一个程序的钩子。这是典型的链式钩子每个 TSR 只记住它前一任调用时顺着链往回走谁也不破坏谁。2.3 PSP、环境块与 MCB 链卸载要还原的三张地图内存回收真正的难点不在这三个中断而在于 DOS 没有提供任何「卸载」原语。DOS 只提供「分配」和「释放」释放的粒度是内存控制块MCB而 MCB 的所有权信息在加载时写进去卸载时得手动改回来。三张地图必须同时还原。第一张是 PSP。每个程序加载时 DOS 分配一个 256 字节的 PSP偏移 0 处是CD 20int 20h 指令偏移 2 处是「本程序可用的内存顶端段址」偏移 2Ch 是环境块段址偏移 50h 是int 21h / retf。卸载流程末尾那句mov ah, 50h / int 21h设置当前 PSP 段就是在还原这张图因为后面CloseFiles关句柄、int 10h设显示模式DOS 都要查「当前进程是谁」。第二张是环境块。RI 在数据区里存了一个DosEnv指向环境段RestoreEnvStr会把保存的环境字符串逐字节写回去遇到连续两个 0 停止。被卸载的程序多半改过环境变量比如追加自己的路径不还原的话后面启动的批处理会继承一堆脏数据。第三张是 MCB 链这是最要命的一张。RestoreMCB: push ds push es lodsw ; 读签名 0: lodsw cmp ax, MM ; 块标记校验 jz 1 mov es, ax ; es 本块的拥有者段 xor di, di movsb ; 写回 1 字节类型标记 movsw ; 写回 2 字节拥有者 PSP movsw ; 写回 2 字节块大小 inc ax mov bx, ds cmp ax, bxjz 10 mov byte ptr es:[8], 0 ; 清掉块内残留标记 10: cmp byte ptr es:[0], Z ; Z 表示链尾 jnz 0 mov byte ptr es:[10h], 0 jmp 0 1: pop es pop ds retMCB 的结构是 5 字节头1 字节类型M表示后面还有块Z表示这是最后一块2 字节拥有者 PSP 段址2 字节块大小以 16 字节为单位的段数。后面跟数据区整体按 16 字节对齐。movsbmovswmovsw正好写回 5 字节这是整段代码里最紧凑的一句。cmp byte ptr es:[0], Z是遍历终止条件——MCB 链是一条单向链表没有长度字段只能靠Z标记找到尾巴。被卸载的 TSR 会把它的块标记成M、把拥有者写成自己的 PSPDOS 于是认为这块内存有人用。卸载时必须把类型改回M/Z、把拥有者改回空闲块应有的值、把大小写回去否则下一次加载程序就会报「内存不足」哪怕实际有几百 KB 空着。这种坑在没有内存查看工具的年代极难定位属于典型的血泪经验。3. 热键判定为什么要在 INT 9 里读 0040:0017 而不是 INT 16h3.1 先转发、后判断钩子的调用顺序RI 的 INT 9 处理程序第一步不是判断热键而是把控制权交给原来的 INT 9。newINT9: pushf db 9ah ; call far ptr 的操作码 oldInt9_Addr dw 0, 0 cmp cs:NoFlag, 0 jz newINT9_proc mov cs:NoFlag, 0 iret newINT9_proc: cmp cs:Flag, ! ; 忙标志防止重入 jnz 0 iret 0: mov cs:Flag, ! ; 置忙 push ax push bx push es mov ax, 40h mov es, ax ; es BIOS 数据段db 9ah那句是老汇编里的必备技巧。8086 汇编器不允许直接call dword ptr cs:oldInt9_Addr这种形式不同汇编器支持程度不一只能手工把call far ptr的操作码9Ah写出来后面紧跟 4 字节的偏移和段址让 CPU 把它当成一条完整指令执行。这种做法能保证在几乎所有汇编器下都能编过代价是可读性极差。先转发的原因很实在BIOS 的 INT 9 做的事是把扫描码翻译成 ASCII、放进 40:001E 开始的键盘缓冲区、更新 40:0017 的状态字节。RI 想读的正是这些结果所以必须让 BIOS 先干完活自己再读。顺序反了读到的是上一个按键的状态。NoFlag和Flag是两个不同的门闩。Flag是重入保护防止热键处理过程中又被键盘中断打断——卸载流程会关中断但恢复 8259 之后中断重新打开此刻如果键盘又进来一次栈就乱了。NoFlag是「这次按键已经处理过了」的标记配合newINT9_2的二次挂钩使用避免同一次按键触发两遍卸载。3.2 0040:0017 的位图比较与 101 键盘分支热键判定的核心是读取 BIOS 键盘状态字节cmp cs:AuxHotKey, 0 jz _1 mov bx, es:[1ah] ; 缓冲区尾指针 cmp bx, es:[1ch] ; 缓冲区头指针 jz 10 ; 空缓冲区跳过 push bx mov bl, es:[bx1] ; 取扫描码 cmp bl, cs:AuxHotKey pop bx jnz 10 _1: mov ah, es:[17h] ; KB_FLAG mov al, cs:HotKey push ax and ax, 0f0fh ; 低 4 位对低 4 位比较 cmp al, ah pop ax jnz 10HotKey的定义在数据段里LeftAlt equ 00101000b RightAlt equ 10001000b LeftCtrl equ 00010100b RightCtrl equ 01000100b LeftShift equ 00000010b RightShift equ 00000001b HotKey db LeftCtrl or RightCtrl ; 0x54左右 Ctrl 任一and ax, 0f0fh这一句是整个判定逻辑的关键它把 16 位寄存器拆成两个独立的 8 位比较——低字节只保留 bit0~3高字节只保留 bit12~15对应 ah 的 bit0~3。这样的掩码让 ScrollLock、NumLock、Insert 这些无关位不参与判定用户按不按锁定键都不影响热键触发。HotKey默认只要求 Ctrl不需要 Alt、Shift 配合这是刻意的0x54的低 4 位是 4左 Ctrl 位高 4 位的低 4 位是 5。只要两个 nibble 各自匹配就认为热键命中。默认热键是「左右 Ctrl 一起按」——单手按不到双手同时按很难误触。然后是 101 键盘的补丁分支cmp cs:Kbd102, True jnz 1 shr al, 1 shr al, 1 shr al, 1 shr al, 1 push ax mov ah, es:[18h] ; 扩展状态字节 and ax, 303h cmp al, ah pop ax jnz 10 mov ah, es:[96h] ; 另一个扩展状态 shr ax, 1 shr ax, 1 and ax, 303h cmp al, ah jnz 10101 键盘比 84 键盘多了右 Ctrl、右 Alt、以及一组扩展光标键。这些键的状态不放在40:0017而是分散在40:0018扩展标志和40:0096某些 BIOS 的额外状态字节里。四句连续shr al, 1是为了把低位的内容移到高位再用and ax, 303h提取两组 2 位字段做比较。这种「移位对齐 掩码提取」的写法在 8086 上没有bt、test之外的位操作指令时是标准套路。再看辅助热键吃掉按键的那一段cmp cs:AuxHotKey, 0 jz _3 inc bx inc bx cmp bx, 3eh jb _2 mov bx, 1eh _2: mov es:[1ah], bx ; 回退键盘缓冲区尾指针 _3: call IsWinDos or ax, ax jz 1 call Beep40:001A是键盘缓冲区的尾指针写指针40:001C是头指针读指针。把尾指针往回退两个字节等于把刚才那个扫描码从缓冲区里「抹掉」后续任何调用 INT 16h 的程序都再也读不到它。这是一个非常简洁的按键吞噬实现——不需要钩住 INT 16h直接改数据结构。缓冲区大小是 32 字节从40:001E到40:003D所以cmp bx, 3eh判断回绕越界就回到1eh。这种环形队列的边界处理在 1994 年的代码里几乎是肌肉记忆。4. RemoveTSR 的执行序列切栈、关中断与把机器倒带4.1 第一件事是把 SP 切回自己的段RemoveTSR: pop ax ; 取出返回地址 cli mov sp, cs ; SS 指向本程序段 mov ss, sp mov sp, 100h ; SP 指向 PSP 之后 sti push ax ; 把返回地址塞回新栈这是整份代码里最关键的四行也是最容易被后人忽略的四行。问题出在上下文RemoveTSR是被热键从 INT 9 里调进来的此刻 SS:SP 指向的是「当前正在运行的那个程序」的栈。而 RI 要做的正是把这个程序的内存回收掉。一旦内存被标记为空闲栈上的内容随时可能被下一次加载覆盖——你在一个即将消失的地基上盖房子。所以第一步必须是搬家pop ax把返回地址取到寄存器里cli关中断防止搬到一半被打断mov ss, sp/mov sp, 100h把栈切到自己段内偏移 100h 处COM 程序 PSP 之后的第一字节也是默认栈顶sti开中断push ax把返回地址压进新栈。后面所有的call、push、ret都发生在自己段内不受被卸载程序影响。cli和sti中间这两条mov也不能省。8086/8088 有一个著名的硬件缺陷执行mov ss, reg之后的那条指令不能被中断否则会用一个未更新的 SP 去响应中断导致栈指针错乱。后来的 CPU 修了这个 bug但惯例保留了下来——在切换栈的时候关中断是那个年代的强制动作。4.2 顺着 NextDataSeg 走到链尾_0: mov ax, ds:NextDataSeg cmp ax, -1 ; -1 表示链尾 jz _1 mov cs:PrevDataSeg, ds ; 记住前一个副本 mov ds, ax jmp _0 _1: mov si, ds:DataBegin mov cs:WorkSeg, ds lodsw cmp ax, XX ; 数据段签名校验 jz _2 call Beep retNextDataSeg是每个 RI 实例在自己数据段里记录的下一个实例的段址形成一条单向链表-1是链尾。为什么需要这条链因为用户可能启动多个 RI比如在批处理里多次加载每个实例都要知道自己后面还有没有别人。功能号0C0DAh就是顺着这条链把所有副本清掉。遍历的终点是链尾也就是最后加载的那个实例。卸载顺序是后进先出这是 DOS 内存模型的必然内存块是一条从低地址到高地址的链只有最后分配的块才能被安全释放中间的块释放后会在链上留下空洞DOS 不合并的话后续分配照样失败。cmp ax, XX是一个防御性校验。DataBegin指向数据段里存放XX的位置如果这个位置的内容不是XX说明数据段已经被别的程序覆盖了可能上一个 RI 副本已经把它清空。此时唯一的正确动作是响一声退回去绝不做任何写操作。这种「先验证再动手」的顺序在会直接改内存的代码里是硬性要求。后面那一大串Restore*调用顺序不是随意的顺序调用作用1RestoreEnvStr写回环境块字符串2RestoreMCB还原 MCB 链3CloseFiles关闭句柄 5~204RestorePort还原 40:0000 起 8 个字5RestoreLEDs还原键盘指示灯状态位6RestoreVecList还原中断向量表7RestoreFloppyParam还原 40:0525 软驱参数8RestoreCVTchain / RestoreMemoryManager还原 XMS/EMS 句柄9RestoreBiosData还原 BIOS 数据区若干字段10Enable8259写回 21h/0A1h 掩码11InitPRN / InitMouse重初始化打印机与鼠标先释放软件资源环境、内存、文件句柄再还原硬件状态端口、8259 掩码最后重初始化外设。顺序一旦颠倒就会出现「句柄已关但 PSP 已失效」或者「中断已恢复但掩码还关着」的中间状态机器要么卡死要么行为诡异。4.3 8259 重编程为什么必须自己发 ICWInit8259: cmp cs:MachineID, 0fch ja pc_xt ; 大于 FCh 认为是 PC/XT AT: mov bx, 870h ; bh08 ICW2, bl70 ICW2 从片 mov al, 11h ; ICW1: 边沿触发 需要 ICW4 out 0A0h, al jcxz $2 jcxz $2 out 20h, al jcxz $2 jcxz $2 mov al, bl ; ICW2: 主片中断向量基址 out 0A1h, al jcxz $2 jcxz $2 mov al, bh out 21h, al jcxz $2 jcxz $2 mov al, 2 ; ICW3: 从片接在主片 IRQ2 out 0A1h, al jcxz $2 jcxz $2 mov al, 4 ; ICW4: 8086 模式 out 21h, al jcxz $2 jcxz $2 mov al, 0FFh ; OCW1: 暂时屏蔽所有中断 out 0A1h, al jcxz $2 jcxz $2 out 21h, al jcxz $2 jcxz $2 ret PC_XT: mov al, 13h ; ICW1: 单片无需 ICW4 out 20h, al jcxz $2 jcxz $2 mov al, 8 ; ICW2 out 21h, al jcxz $2 jcxz $2 mov al, 9 ; ICW4 out 21h, al jcxz $2 jcxz $2 mov al, 0FFh ; OCW1 out 21h, al jcxz $2 jcxz $2 ret这段是标准的 8259A 初始化序列但有几个细节值得说。jcxz $2是 ISA 总线上的经典延时。老式 ISA 外设的 I/O 恢复时间较长连续两条out指令之间如果不等一下第二條可能被外设忽略。用jcxz做延时是因为它只有两个字节、执行快而且不改变标志位的语义取决于 CX 的值是那个年代最常见的 NOP 替代品。cmp cs:MachineID, 0fch / ja pc_xt是机型判定。BIOS 数据区40:00F0处存放机型字节FCh是 IBM PC/AT比它大的值是 PS/2 系列都比 AT 新带从片小于等于FCh的老机器按 PC/XT 单片刻画。这个判定方法不严谨不同 BIOS 厂商的取值不完全一致但在 1994 年的兼容机市场上够用。ICW3 写2表示从片通过主片的 IRQ2 级联。这个值写错从片上的中断通常是 IRQ8~15就永远进不来。最后 OCW1 写0FFh把全部中断屏蔽掉是为了让后续的还原动作不受中断干扰。等所有还原完成Enable8259再把加载时保存的old_8259两个字节写回21h和0A1h恢复原来的掩码。4.4 中断向量表的压缩格式RestoreVecList里有一个很有年代感的设计向量表不是逐条存储的而是压缩成「重复计数 向量值」的流。RestoreVecList: xor ax, ax mov di, ax mov es, ax ; es 0000中断向量表段 mov cx, 100h ; 256 个向量 0: lodsw xchg dx, ax lodsw cmp dx, EL ; 重复标记L E 倒序 jnz 1 cmp al, I jnz 1 sub cl, ah ; ah 重复次数 push cx mov cl, ah mov ax, es:[di-4] ; 取上一个向量的值 mov dx, es:[di-2] a: stosw xchg ax, dx stosw xchg ax, dx loop a pop cx or cx, cx jz 9 jmp 0 1: xchg ax, dx stosw xchg ax, dx stosw loop 0 9: ret正常情况是两条lodsw读出一个完整的 4 字节向量偏移 段址两次stosw写回向量表。遇到E、L、I组合时说明后面跟的是一个重复计数接下来的 N 个向量都等于上一个。256 个向量占 1KB全部原样存下来当然可以但 1994 年的 TSR 内存是按 KB 抠的压缩一下能省几百字节。用重复计数的前提是向量表大部分条目在加载前后没变——这个假设在单任务 DOS 下基本成立。EL这个常量的写法也有讲究。汇编器里EL在内存中是小端存放低字节是E0x45高字节是L0x4C所以实际内存里读到的是45 4C。代码里的比较逻辑看起来是「先比较 EL 再比较 I」实际读的是E、L、I三字节。跟前面IE那个签名一样都是靠倒序存放的字符串做标记。4.5 BIOS 数据区的还原RestoreBiosData: lodsw cmp ax, -- jz 1 call Beep ret 1: push es push di mov di, 40h mov es, di mov di, 10h movsw ; 40:0010 设备标志 mov di, 0a8h movsw movsw ; 40:00A8 显示相关 mov di, 49h mov cx, 1dh rep movsb ; 40:0049 起 29 字节视频状态 mov di, 0f0h mov cx, 8 rep movsw ; 40:00F0 起 16 字节机型/配置 pop di pop es retBIOS 数据区40:0000到40:00FF是 DOS 时代所有程序共享的「全局状态」存着设备标志、视频模式、光标位置、键盘状态、机型、硬盘参数等。TSR 常常会改这里的字段尤其改显示模式的程序会动40:0049附近的视频状态卸载后必须还原否则后续程序会基于错误的状态运行。这里同样用了--作为「数据块存在」的签名读不到就只 Beep 一声退出。这已经是第三处用字符串当校验位的地方——XX校验数据段、IE校验代码段、--校验 BIOS 快照、MM/Z校验 MCB 链。在没有异常机制、没有返回值约定的环境下这是唯一的错误检测手段。5. 常见问题排查五条在真机上踩过的坑5.1 按下热键整机定格屏幕还在但键盘没反应现象按下 Ctrl 组合键的瞬间画面冻结鼠标如果有也不动了只能按机箱复位键。原因热键处理程序在中断上下文里直接执行了内存回收此刻 SS:SP 仍然指向即将被释放的那个程序的栈段。RestoreMCB一执行那块内存就被标记成空闲紧接着call RemoveTSR内部的调用会把返回地址压到已经失效的内存上等下一次内存分配覆盖它ret就跳到了随机地址。解决RemoveTSR的第一条指令必须是切栈把 SP 挪到自己的段内偏移 100h并且整个切换过程用cli/sti夹住。切完栈再关中断、再动手任何破坏性操作都不能发生在别人的栈上。5.2 卸载成功后键盘失灵但 CtrlAltDel 还能用现象内存看着是释放了MEM显示的可用空间也回来了但打字完全没反应。热启动有效说明 BIOS 还活着。原因某个被卸载的 TSR鼠标驱动、网络驱动最常见的在加载时重新初始化过 8259屏蔽了 IRQ1。RI 卸载时如果没有把21h端口的掩码写回去键盘中断就永远进不来。CtrlAltDel走的是 BIOS 的键盘复位路径不依赖 IRQ1所以还能用。解决加载时用in al, 21h/in al, 0A1h各存一个字节到old_8259卸载完成前通过Enable8259原样写回。这个「加载时存、卸载时还」的配对是所有碰硬件的 TSR 的通用要求。5.3 第二次运行时直接闪退或者报「程序已在内存中」现象第一次用正常卸载后再运行程序一闪就退回 DOS 提示符什么都不做。原因没做「自身是否已驻留」的检测。TSR 的传统检测方法是调用 INT 2F 的某个约定功能号看返回的段址是不是自己。RI 用的是0C0D7h返回CS。如果调用后 AX 被改成了自己的段址说明已经有实例在内存里此时应该直接退出而不是再挂一遍中断向量。解决程序入口先发一次mov ax, 0C0D7h / int 2Fh比对返回值。已经驻留就直接int 20h退出。这也是为什么newINT2F里第一件事就是cmp ax, 0c0d7h。5.4 在 Windows 的 DOS 窗口里按热键整个系统卡死现象在图形界面里的 DOS 窗口跑这个程序按热键的一瞬间窗口冻结严重时整个桌面失去响应。原因Windows 跑在保护模式下DOS 窗口是一个 V86 虚拟机。RI 在 V86 里改中断向量、直接对21h/0A1h端口做 I/O、写40:0000段的 BIOS 数据区这些操作都要经过虚拟设备驱动拦截和模拟。有些模拟不完整尤其是 8259 的 ICW 序列一旦写入VMM 的状态和真实硬件寄存器就对不上整个虚拟机崩溃。解决加载时和触发时各做一次「当前是否在 Windows 下」的检测。int 2Fh的AX1600h返回 AL01h是标准模式80h是增强模式FFh是 Windows/386 2.xAX4680h返回 AL80h表示正在 Windows 3.x 增强模式里运行。检测到就只响一声绝不执行卸载。IsWinDos这个函数在代码里被调用两次一次在热键处理里一次在入口初始化时。5.5 从某个文字处理软件退出后再进热键失效了现象启动 RI 正常热键正常进某个编辑软件退出再按热键毫无反应。原因这类软件通常会自己接管 INT 9 做按键预处理退出时它把 INT 9 向量恢复成「它加载之前」的值。如果 RI 在它之前加载那么它保存的「原值」就是 RI 的钩子地址——退出时恢复回去理论上没问题。但有些软件退出时恢复的不是保存值而是它自己算出来的 BIOS 原始地址直接跳过了 RI。结果 RI 的钩子被静默覆盖热键就此失踪。解决把 INT 9 的巡检挂到 INT 1C 上每次 tick 检查一次。检查时不比完整向量重定位后会失效而是取向量里的段址看偏移101h处的签名。签名不在就重新挂钩并把「当前这个钩子」保存成新的前一任。这就是newINT1C那段自愈逻辑的由来也是版本历史里「WPS 退出重入后 RI 无法控制键盘」那条修正记录的真正含义。6. 设计取舍这段老代码留下的三个判断标准第一个取舍是「只卸载自己之后加载的程序」。RI 从不尝试卸载比自己更早驻留的东西哪怕用户明确要求。原因不难推DOS 内存是一条从低到高的单向链只有后加载的才能被安全回收前面的块释放后会在链上留下无法合并的空洞。这个限制看起来很保守但0C0D8h保留自身和0C0D9h连自己一起走两个功能号把选择权交给了调用者而不是在实现里偷偷越界。第二个取舍是「把不能碰的写死在名单里」。代码头部明确列了不处理的对象DOS 的 fastopen、某些内存管理器管理的缓冲区和文件表、某个磁盘缓存。这些不是程序员的疏忽是明确的边界——它们是 DOS 内核或者内存管理器自己管理的结构从外面拆只会拆出问题。工程上一份可信的「不做清单」比一份炫技的「能做清单」值钱得多。第三个取舍是「先校验再动手」。整份代码里XX、IE、--、MM、Z五处校验全部出现在写操作之前。在没有任何异常保护机制的环境里一个字节写错就是死机校验位就是唯一的后悔药。用今天的说法这些都是手写的断言只不过断言失败时没有调用栈只能响一声喇叭mov ax, 0e07h / int 10h然后退回去。想验证这类工具的真实行为办法其实很简单在一台能跑 DOS 的环境里用内存查看工具在加载前后各统计一次常驻程序列表和可用内存再用调试器看0000:0000起的 1KB 中断向量表改动。你会看到每次加载都精确地改了 3 个向量每次卸载都精确地改回去——如果哪次卸载后向量表没回到原始状态那就是某个Restore*漏掉了。我后来写任何涉及资源分配和释放的代码都强制走一遍「保存快照 → 按相反顺序还原 → 校验快照」的流程哪怕多写几十行。这个习惯就是从盯着那段 1994 年的movsw序列开始的——它没有一行是多余的也没有一行是在赌运气。希望帮到你。本文还有配套的精品资源点击获取