简介这份PDF整理的是雷军早年写下的汇编代码及相关说明面向对底层编程、DOS历史、内存管理与系统优化感兴趣的开发者和计算机专业学习者。内容围绕一个通过热键卸载后台常驻内存程序的汇编实现展开涉及当时常见应用与驱动软件的兼容处理也记录了部分程序不可取消或不可移除的特殊情形。阅读时可以看到鼠标驱动初始化、命令行环境兼容修正、扩展内存分配、缓冲区设置以及内存管理工具适配等细节有助于理解汇编语言如何与硬件和操作系统交互也能体会早期软件在版本迭代中发现问题、修正缺陷的排错思路对研究技术史和底层优化都有参考价值。资源包共1个PDF文件约116KB已有2364人学习浏览适合收藏后结合代码片段慢慢研读。1. 从一次“内存被占用”说起在 DOS 模拟器里敲下RI屏幕上闪一行白字回车之后就回到了提示符——再敲内存查看命令可用常规内存多出四十多 KB。这段能一次性清掉十几种后台常驻程序的汇编代码全部逻辑压在一个.model tiny的 COM 文件里没有构建脚本、没有第三方库、没有测试用例只有一个.ASM和一段从 V2.04 写到 V2.12 的版本变更记录。这份源码出自 1994 年作者当时在一个小软件工作室写底层工具。它的价值不在于“名人”滤镜而在于它是一个完整可运行的 16 位实模式程序中断向量挂钩、TSR 驻留、内存控制块遍历、8259A 中断控制器重编程、BIOS 数据区备份还原这些平时只在教材里出现的东西在这里被一个 700 多行的文件全部串起来了。适合两类人想把 x86 底层和 DOS 内存模型真正搞明白的开发者以及想找一个“小而完整”的实模式样板来改自己工具的人。2. 定位与技术选型它到底在管理什么为什么非汇编不可2.1 它解决的问题后台常驻程序把 640KB 占满了九十年代的 PC 上常规内存只有 640KB而一堆程序要在你干活的时候赖着不走中文平台的字库和输入法、磁盘缓存、鼠标驱动、打印台、屏幕截取工具、内存管理器的高端部分。这类程序统称 TSRTerminate and Stay Resident结束但仍驻留它的工作方式是挂钩中断——比如挂 INT 9 抢键盘、挂 INT 1Ch 抢时钟、挂 INT 21h 拦截文件操作——然后把代码留在内存里不释放。问题在于卸载。一个程序挂了 INT 9另一个又挂在它上面你手动改向量表很容易把整条中断链改成无效状态。我这里踩过的坑是只要钩子的先后顺序错了键盘就直接没有响应只能重启。RI 的思路很讨巧——它不挨个去卸谁而是在自己驻留之前把当时的现场完整抄一份中断向量表、8259A 的掩码、BIOS 数据区、DOS 内存控制块链、打开的文件句柄、环境块、端口状态、LED 状态、软驱参数。等你想清理的时候按一下热键它把这些现场原样写回去等于把整台机器的时间倒回自己加载那一刻。前面提到的那些 TSR因为它们的钩子在新向量表里已经不存在了代码所在的内存块又被 MCB 链回收就一起失效了。这相当于给自己留了一次撤回操作。源码头部那段注释里列了它明确支持清理的程序类型中文 DOS 平台、字处理软件、几款游戏外挂、网络重定向器、磁盘缓存、屏幕截取工具、鼠标驱动、单色模拟 CGA 的显示补丁、内存管理扩展等同时也标了“不能清理”的例外清单。这份清单本身就是一份九十年代 PC 软件生态的样本比任何教材都具体。2.2 技术选型为什么是汇编、为什么是 COM翻开源码前几行就能看懂作者的取舍dosseg .model tiny .code locals org 100h Start: jmp Main org 103h True equ 1 False equ 0 MaxHandles equ 100h INT3 macro out 0ffh, al endmorg 100h是 COM 格式的铁律——文件加载到段首偏移 100h 处开始执行前面 256 字节是 PSP程序段前缀。.model tiny把所有段合并成一个代码数据全在一个 64KB 段内CSDSESSS这在挂钩中断时极其方便钩子里push cs / pop ds就能直接访问自己的变量不用来回算段址。Start: jmp Main三字节跳过紧接着org 103h把后面的数据强制对齐到 103h这样数据区在文件里的偏移就是固定可算的。为什么不写 C因为这几件事在 C 里全都做不顺要做的事汇编里的写法高层语言里的坑挂中断直接改0000:09h*4四字节编译器可能插栈检查、生成远调用序言换栈cli后mov ss,sp/mov sp,100h编译器不知道你换了栈局部变量全乱端口 I/Oout 21h, al需要内联汇编或驱动还得处理权限驻留int 27h或AH31h需要拿到程序实际长度编译器帮你加了什么不好算重入保护自己一个字节标志位中断里再进中断运行时库不可重入dosseg让段在文件里的顺序按代码、数据排避免链接器自作主张排序。locals 是给局部标号加前缀这样同一个文件里能反复用01这种标号不用为每个跳转起一个不重名的名字——700 多行代码里这个技巧省了大量命名负担。2.3 源码骨架一个文件里的三块东西整个文件其实只有三层从后往前读反而更快理解第一层是版本变更记录压在文件头注释里从 V2.04 一路记到 V2.12。每一条都是一次真实故障V2.04 改成用鼠标驱动的软复位函数来初始化鼠标V2.05 记的是不能在图形外壳的 DOS 窗口里跑V2.06 修的是 XMS 分配不到 1K 内存时程序直接挂死以及 EMS 内存重复释放V2.07 修热键设置错误V2.10 加释放高端内存块和标志位拷贝V2.12 修的是初始化 8259 时的一个严重错误并补上[40:F0--FF]用户数据区的保存。这份记录读起来像一份故障复盘比源码本身还有信息量。第二层是数据区全在org 104h之后; ---- 热键位定义对应 BIOS 40h:17h 的位布局 ---- LeftAlt equ 00101000b RightAlt equ 10001000b LeftCtrl equ 00010100b RightCtrl equ 01000100b LeftShift equ 00000010b RightShift equ 00000001b HotKey db LeftCtrl or RightCtrl ; 默认 Ctrl 左右都认Alt/Shift 不参与 ; ---- 副本链与现场 ---- DataBegin dw 0 ; 本副本数据区起始标记配 XX 签名用 NextDataSeg dw 0FFFFh ; 下一份 RI 副本的段址FFFF 为链尾 oldInt2F_addr dw 0, 0 ; 原 INT 2Fh 向量 XMS_control dw 0, 0 Handle_begin dw 0 tsrLength dw 0 ; 驻留长度int 27h 的输入 MachineID db 0FCh ; 大于 0FCh 判定为 XT 档机器 AuxHotKey db 0 ; 附加热键扫描码0 表示不启用 AuxHotKeyName db X$ Power db True ; 是否重编程 8259A Flag db ! ; 重入标志! 表示正在处理 StopFlag db 1 ; 1 钩子有效0 被别的 TSR 覆盖了 Copies db 1 ; 同时驻留的副本数量 old_8259 db 0, 0 ; 21h / A1h 端口的原掩码 Status dw 0 ; bit0XMS 已占, bit1EMS 已占, bit7钩子有效热键不是简单的“哪个键按下”而是一组位掩码BIOS 在40h:17h维护一个字节bit0/bit1 是左右 Shiftbit2/bit3 是左右 Ctrlbit3 那半字节还有 Alt。作者把要匹配的组合直接写成常量运行时and ax,0F0Fh把“要匹配的 Ctrl 位”和“实际的 Ctrl 位”对齐比较一行就完成了组合键判定。第三层是代码区可以按调用关系排成一张表入口触发时机干什么Start/Main首次执行自检、保存现场、挂钩、int 27h驻留newINT1C每次时钟滴答约 18.2Hz看钩子有没有被抢被抢就重新挂newINT9/newINT9_2每次按键判热键命中就进卸载流程newINT2F任何程序调 INT 2Fh提供 C0D7~C0DA 私有功能号RemoveTSR热键命中 / INT 2Fh 请求换栈、走副本链、逐项还原现场Restore*系列RemoveTSR内部分别还原向量、MCB、端口、BIOS 区等理解了这三层后面所有细节都是在填这三层的肉。3. 核心链路拆解从按下一个键到十几段内存被回收3.1 热键判定INT 9 钩子里的位运算newINT9_proc是整个程序里唯一每次按键都要跑的代码所以作者把它写得极短newINT9_proc: cmp cs:Flag, ! ; 重入保护上一轮还没跑完就直接返回 jnz 0 iret 0: mov cs:Flag, ! ; 置忙标志 push ax push bx push es mov ax, 40h ; BIOS 数据区段址 mov es, ax ; --- 附加热键比对键盘缓冲区里的扫描码 --- 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: ; --- 主热键比对 40h:17h 的修饰键状态 --- mov ah, es:[17h] mov al, cs:HotKey push ax and ax, 0F0Fh ; 高半字节比 Ctrl低半字节比 Shift cmp al, ah pop ax jnz 10 ; 修饰键不匹配放行几个容易漏掉的点。第一Flag这个忙标志是后来才加的。原作者的解释是如果热键被按住不放INT 9 会在极短时间内连续触发而卸载过程要遍历 MCB 链耗时很长重入一次就会把内存改乱。所以进来先看标志位忙就直接iret。第二cmp cs:AuxHotKey, 0那一段不是“附加键”而是扫描码级的热键。BIOS 数据区的1Ah/1Ch是键盘缓冲区的头尾指针[bx1]那个位置是按键的扫描码。这段代码还顺手做了一件事命中附加热键之后把es:[1ah]往后挪一格并且做了 3Eh 回绕到 1Eh 的判断等于把这个按键从缓冲区里提前取走防止它被后面的程序读到。第三Kbd102这个开关处理的是 101/102 键键盘的标志位布局差异——这类键盘把右 Ctrl 和右 Alt 的标志放在了高 4 位所以要shr al,1连做四次再比一次。紧跟着的es:[96h]是扩展键盘的额外标志字节右 Ctrl 和右 Alt 在 101 键盘上被挪到了这里。这三个分支合起来才是“任意 Ctrl 都能触发”。第四命中之后不是立刻动手而是先call IsWinDOS。这个子过程连着调了两次 INT 2FhIsWinDOS: mov ax, 1600h int 2fh cmp al, 01h jz 9 ; 检测到图形外壳的 DOS 窗口直接返回 1 cmp al, 0FFh jz 9 cmp al, 00h jz 1 cmp al, 80h jnz 9 1: mov ax, 4680h ; 再问一次内存管理器在不在 int 2fh cmp al, 80h jnz 9 xor ax, ax jmp 10 9: mov ax, 1 10: retAX1600h是“告诉我跑在什么环境里”AX4680h是内存管理器的存在性检查。只要判定在图形外壳的窗口里就只响一声蜂鸣器然后退出——因为那个环境下的 BIOS 数据区和中断向量表全是虚拟化过的硬还原现场会让宿主的窗口直接崩掉。这条逻辑对应的就是版本记录里 V2.05 那条教训。3.2 私有 INT 2Fh 接口给别的程序留一扇门热键只能靠人按但批处理脚本没法按键。所以作者在 INT 2Fh 上开了四个私有功能号从 C0D7h 开始——这个段位是当时公认的“民间自定义”区间撞车概率低newINT2F: cmp ax, 0C0D7h ; 返回 RI 的驻留段址 jnz 1 push cs pop ax iret 1: cmp ax, 0C0D7h1 ; C0D8h卸掉 RI 之后加载的所有 TSR自己留着 jnz 2 jmp KeepSelf 2: cmp ax, 0C0D7h2 ; C0D9h连自己一起卸 jnz 3 jmp NoKeepSelf 3: cmp ax, 0C0D7h3 ; C0DAh把所有 RI 副本清干净只留一份 jnz 9 mov cs:NextDataSeg, -1 mov cs:Copies, 1 jmp NoKeepSelf 9: jmp dword ptr cs:oldInt2F_addr ; 不是自己的号原样传给上一个钩子这里有两个设计值得借鉴。一是功能号用加法而不是四个独立常量。0C0D7h 1、2、3是可读的而且一眼能看出这是一组。二是默认分支必须是下传。jmp dword ptr cs:oldInt2F_addr是远跳直接跳到上一个处理器中间不压栈不留痕返回值原样保留。INT 2Fh 是 DOS 里最拥挤的一条中断任何一个钩子忘了下传后面所有程序的判断都会失效——这类问题排查起来是灾难因为现象是别的程序出错而不是你的程序出错。C0D7h那条push cs / pop ax / iret也很讲究直接改 AX 返回段址不碰别的寄存器。调用方拿到段址后可以自己去读那份副本链实现“我只卸我自己之后加载的东西”。3.3 RemoveTSR换栈、走链、逐项还原现场这是整个程序的核心也是最难读的一段。它第一件事就是把栈换掉RemoveTSR: pop ax ; 把返回地址弹出来因为下面要换栈 cli mov sp, cs ; 注意这里先把段址塞进 SP mov ss, sp ; 再 SSSP等价于 SSCS mov sp, 100h ; 栈顶回到 PSP 之后 sti push ax ; 返回地址重新压回新栈这段的写法值得单独说。正常思路是mov ax,cs / mov ss,ax / mov sp,100h但作者用了一个更短的写法mov sp,cs先把 CS 的值塞进 SP此时 SS 还是旧的但两条mov之间 CPU 不会响应中断因为前面cli了然后mov ss,sp。这样 SS 和 SP 一起变成了目标值省了一个寄存器。为什么要换栈因为要卸载的那些 TSR 里有些程序把自己的栈段留在内存中甚至有些程序改过 SS。如果继续用调用者的栈往下走一旦还原过程释放了那块内存栈就悬空了。切到自己段内的 100h 位置这块内存在整个卸载过程中不会被回收。换完栈就开始走副本链1: mov si, ds:DataBegin lodsw cmp ax, XX ; 数据区签名校验 jz _2 call Beep ; 对不上就响一声直接放弃 ret _2: call RestoreEnvStr ; 环境块 call RestoreMCB ; 内存控制块链 call CloseFiles ; 打开的文件句柄 call RestorePort ; 40h:00 起的端口记录 call RestoreLEDs ; 键盘 LED 与修饰键状态 call RestoreVecList ; 中断向量表 call RestoreFloppyParam cmp cs:Power, True jnz 2 call RestoreCVTchain ; 内存管理器的 CVT 链 call RestoreMemoryManager 2: call RestoreBiosData ; BIOS 数据区 call Enable8259 ; 8259A 掩码恢复 ...XX这个签名校验看起来像随手写的其实是关键防线。它防的是“内存被别的东西覆盖后再来卸载”——如果签名不对说明数据区已经被写坏再往下还原只会把整机改成不可恢复状态这时候响一声退出反而是最安全的选择。每一个Restore*做的事情都很具体我按“改了什么、怎么改、失败会怎样”整理成一张表子过程还原对象关键实现漏掉的后果RestoreVecList256 个中断向量遍历保存的向量表遇到ELI标记做压缩展开中断指向已释放内存下一次中断就崩RestoreMCBDOS 内存控制块链顺M/Z签名走链把偏移 8 的 owner 和偏移 10h 的名字清零内存块永远不释放可用内存不增加RestoreBiosDataBIOS 数据区若干段40h:10h、40h:A8h、40h:49h1Dh 字节、40h:F0h8 个字键盘状态、磁盘参数错乱RestoreEnvStr环境块字符串从保存的段址逐字节复制回DosEnv后续程序读 PATH 读到乱码CloseFiles5~14 号文件句柄循环AH3Eh int 21h文件句柄泄漏再开文件报“打开文件过多”Enable82598259A 中断掩码从old_8259写回 21h/A1h 端口键盘、时钟中断被屏蔽机器假死RestoreLEDs键盘指示灯保留高 4 位掩掉 Ctrl/Alt/Shift 位大写锁指示灯与实际状态不一致RestoreFloppyParam软驱参数es:[525h] 2换盘检测失效RestoreMCB那段值得展开看一眼因为它是“内存到底有没有被释放”的直接证据RestoreMCB: push ds push es lodsw ; MZ 0: lodsw cmp ax, MM jz 1 mov es, ax ; ES 当前 MCB 段址 xor di, di movsb movsw movsw ; 复制三个字段到当前 MCB inc ax mov bx, ds cmp ax, bx jz 10 mov byte ptr es:[8], 0 ; 清掉 owner块变成“自由” 10: cmp byte ptr es:[0], Z jnz 0 ; 不是最后一个就继续走 mov byte ptr es:[10h], 0 jmp 0MCB 链的判定全靠块首那个字节M表示后面还有Z表示这是链尾。每个块的偏移 8 是拥有者 PSP 段址把它清 0DOS 下次扫描内存时就会把这个块当空闲块分配出去。偏移 0 后面的段址就是下一块的位置inc ax是因为块头本身占一个节16 字节。这套结构和 DOS 的内存管理逻辑完全一致所以改完立刻生效——不需要重启也不会有残留。最后RemoveTSR会做一串收尾AH50h int 21h把 PSP 切回原主程序、int 10h设回 03h 文本模式、重新初始化打印机和鼠标、打印一个*表示回收了几份副本、最后清空键盘缓冲区。这些动作的顺序不能乱比如必须在恢复显示模式之后再初始化鼠标否则鼠标指针会画到错误的显存页上。4. 编译与运行把这份 1994 年的源码跑起来4.1 环境准备源码是 16 位实模式程序现代 Windows 上直接编译是不现实的得先搭一个 DOS 环境。我一般用 DOS 模拟器好处是宿主机不会被 8259A 重编程搞出问题——这段代码会直接往端口写在真机上是高危操作。目录按下面摆模拟器里挂成 C 盘# 宿主机目录 ~/dosbox/ ├── BIN/ # 汇编器与链接器需放进 PATH ├── SRC/ │ └── RI.ASM └── TEST/ # 后面自己写的 TSR 测试桩模拟器里挂载并切盘mount c ~/dosbox c: set PATHC:\BIN cd \SRC提示模拟器的 CPU 周期数别设太高卸载过程里有jcxz $2这种短延时循环跑太快某些版本的模拟器在端口写时序上会有偏差表现为偶发挂死。4.2 编译链接从 .ASM 到 .COMorg 100h加上.model tiny目标产物就是 COM 文件。两条命令# 第一步汇编产出 OBJ tasm ri.asm # 第二步链接并直接输出 COM/t 是关键告诉链接器生成 tiny 模型的可执行文件 tlink /t ri.obj # 确认产物 dir ri.com如果手头是另一套工具链等价流程是masm ri.asm; link ri.obj; exe2bin ri.exe ri.com几个参数必须说清楚。/t是/tiny的简写它让链接器不生成重定位表、不加 EXE 头直接从偏移 0 输出原始映像——这正是 COM 需要的格式。如果忘了/t会得到一个.exe加载时 CS 和 DS 不相等源码里所有push cs / pop ds全部失效一跑就死。org 100h不能删删了之后所有数据偏移都会差 256 字节表现为变量读写到 PSP 里去了。编译成功后看文件大小应该在几 KB 量级。如果超过 64KB说明哪里写错了段声明COM 格式装不下。4.3 运行与验证验证顺序很重要别一上来就跑完整流程。我的做法是先确认“装上去了”再确认“能卸干净”。第一步装之前先记基线mem /c before.txt把当前常规内存、最大的可用块、已加载的驱动列表都存下来后面好对比。第二步先加载几个可控的 TSR 做实验别拿系统关键驱动试。自己写一个最简单的驻留程序第 6 章会给占几 KB 常规内存挂一个无关紧要的中断号。第三步加载 RIri如果一切正常会看到它打印一行安装成功的提示并返回提示符常规内存减少几 KB这是 RI 自己占的。第四步按热键。默认是左 Ctrl 或右 Ctrl——注意是单独按不是组合键因为它把 Alt 和 Shift 的位掩码都设成 0 了。按下之后应该听到一声蜂鸣屏幕闪一下颜色然后回到提示符。第五步再跑一次mem /c after.txt跟基线对比。差值应该在“RI 自己占的几 KB”这个量级上——如果差得很少说明 MCB 链根本没释放去看RestoreMCB那段。还有个更方便的验证方式用 INT 2Fh 的私有接口问一句。写一个几行的探针程序编译成 COM 跑一下; probe.asm —— 查询 RI 是否驻留并取段址 org 100h mov ax, 0C0D7h int 2Fh ; 此时 AX 应该是 RI 的段址打印出来或者用 debug 看 mov ah, 4Ch int 21h如果返回的 AX 是个合理的段址比如 0A00h 附近说明钩子挂上了。这个探针在排查“热键不响应”时特别有用——它能把“没装上”和“装上了但热键判定失败”两种情况区分开。4.4 调试手段没有源码级调试器时怎么看DOS 自带的debug是唯一可靠的工具虽然简陋但够用。三个常用姿势# 看中断向量表确认 09h / 2Fh / 1Ch 指向哪里 debug -d 0:24 l 4 ; INT 9 向量四个字节 -d 0:BC l 4 ; INT 2F 向量 -d 0:70 l 4 ; INT 1C 向量 -q装上 RI 前后各看一次向量里的段址应该从 BIOS 的 F000 段变成 RI 的驻留段。如果没变说明Main里的挂钩代码没走到回头查Start那几行。# 反汇编看自己的钩子有没有被优化掉 debug ri.com -u 100 l 10u反汇编是最直接的验证——汇编器有时候会因为org和标号的组合把跳转算错反汇编出来一看就知道。特别是Start: jmp Main这条跳转目标必须落在Main标号处如果跳到一个奇怪的位置说明org 103h之后的数据区把代码区挤歪了。# 在钩子入口下断点 debug ri.com -g 1A2 ; 换成你反汇编出来 newINT9_proc 的实际偏移断点停下来之后用r看寄存器、d ds:0 l 20看数据区最常看的是Flag和StopFlag那两个字节Flag应该是!忙或 闲StopFlag是 1 表示钩子有效。如果StopFlag变成 0 了说明有别的 TSR 后来居上把 INT 9 抢走了源码里newINT1C那段重挂钩逻辑就是为这个准备的。5. 避坑与排查这份源码最容易失效的五个点5.1 汇编直接报错说org之后有重复定义现象汇编器报“symbol already defined”或者段属性冲突位置在org 104h附近。原因.model tiny下代码和数据合在一个段里org是在同一个段内来回跳偏移。如果在中途插了.data或.stack声明段被拆开org就失去意义了汇编器会把后面的数据当成重新从头算偏移。解决整个文件只允许.code不写.data所有变量用db/dw直接在代码后面定义。我一般会在文件头加一行注释写死这个约束免得后来接手的人手一抖加个.data进去。5.2 生成的是 EXE 不是 COM一跑就死机现象编译没报错运行后花屏、重启或者直接回到提示符。原因链接时漏了/t产出的是 EXE。EXE 加载时 DS 和 CS 分属不同段而源码里到处是push cs / pop ds和直接寻址变量段基址一变所有变量读写全部错位。更麻烦的是int 27h驻留时用的是 CS 长度EXE 格式下这个长度是错的驻留进去的就是一部分损坏的代码。解决tlink /t或者exe2bin。生成后检查文件大小和头两个字节——COM 文件没有MZ头第一二个字节应该直接是代码比如E9开头的近跳转。5.3 热键按了没反应现象程序提示安装成功mem也确认内存减少了但按 Ctrl 什么都没发生。原因有三种按概率排一是 INT 9 被后加载的 TSR 抢走了RI 的钩子不在链首二是键盘不是 101/102 规格Kbd102那个分支的判断条件设错了修饰键位对不上三是AuxHotKey被误设成了非 0导致程序要求“必须先按过某个键”。解决先用第 4 章的探针程序确认钩子还在然后在debug里看0:24处的向量是不是指向 RI 的段最后把AuxHotKey改成 0 排除附加键干扰。如果确实是钩子被抢newINT1C里那段“检测到被抢就重新挂”的逻辑应该会起作用——它每 55ms 检查一次检查方式是看 INT 9 向量是否还等于offset NewInt9。不过这段逻辑本身有个隐患如果抢钩子的程序也是每次都在 INT 1Ch 里重挂两个程序会互相覆盖形成死循环表现为系统变慢。这种情况只能靠调整加载顺序解决。5.4 卸载之后键盘或鼠标失灵现象热键按下去了屏幕也闪了但是键盘彻底没响应或者鼠标指针卡在屏幕中间。原因Init8259在卸载开始时就把中断控制器重编程了如果后面某个Restore*中途失败比如签名校验通过但 MCB 链走到一半遇到异常块Enable8259就永远执行不到中断掩码停在“全屏蔽”状态。解决把Init8259的调用条件收紧。源码里是用Power这个开关控制的调试阶段建议先设成False跳过 8259 重编程确认其它还原步骤都对之后再打开。另外检查old_8259这两个字节的保存时机——必须在第一次改端口之前保存源码里是在Main早期保存的这个顺序不能动。5.5 内存没变多但程序确实卸掉了现象热键响应正常回显也对但mem /c显示的可用内存几乎没变化。原因RestoreMCB只清了 owner 字段没有合并相邻空闲块。DOS 的 MCB 链在下次分配时会自己合并但mem显示的“最大可用块”不会立即变大。另一种可能是被卸载的那些 TSR 本身就没占多少常规内存它们的内存早就搬到高端去了。解决换一个指标看——用mem /c里的“最大可用块”而不是“总可用内存”或者干脆跑一个需要大块连续内存的程序来验证。如果确实一个字节都没回来用debug手工走一遍 MCB 链看每个块的 owner 是不是都变成 0 了debug # 从 PSP 段开始第一个 MCB 通常在 PSP 段址处 -d 段址:0 l 10块首的M/Z、偏移 1 的字owner PSP、偏移 3 的字块大小单位节三个字段对着看就能定位是哪一块没释放。注意RestoreBiosData里有一段恢复[40:F0--FF]用户数据区的操作这是 V2.12 才补上的。如果用的是更早的版本某些程序尤其是自带输入法的中文平台会在卸载后出现字符显示错乱原因就是这 16 个字节没还原。改代码的时候别把这段删了。6. 二次开发改热键、加日志、把它变成自己的工具这份源码最适合拿来练手的地方是它的结构已经把“挂钩—判定—还原”三件事分得很干净改任何一块都不太会牵动其它部分。下面三条改造思路从易到难。6.1 换热键从单键改成真正的组合键默认是“任意 Ctrl 单按”误触率极高——打字的时候右手小指蹭一下 Ctrl 就触发了。改成Ctrl Alt只需要动一处; 改之前只匹配 CtrlAlt 和 Shift 位都清零 HotKey db LeftCtrl or RightCtrl ; 改之后要求 Ctrl 和 Alt 同时按下 HotKey db LeftCtrl or RightCtrl or LeftAlt or RightAlt但光改常量不够。源码里那个and ax,0F0Fh只比较了高半字节Ctrl 区和低半字节Shift 区Alt 的位在高半字节里LeftAlt是00101000b高 4 位是0010而LeftCtrl是00010100b高 4 位是0001。这两个值做or之后高半字节变成0011跟实际按下的0011刚好匹配——所以and ax,0F0Fh这一句不用改常量改了就能生效。真正要改的是判定时机。INT 9 在按下和松开时都会触发如果只在按下时判定按住不放会反复触发。Flag忙标志能挡住一部分但更干净的做法是比较40h:17h的位变化只在“从无到有”的那一刻响应。加几行; 保存上一次的修饰键状态只在上升沿响应 mov al, es:[17h] and al, 0F0h cmp al, cs:LastMod mov cs:LastMod, al jz 10 ; 状态没变说明是松开或者重复放行6.2 加一份卸载日志把“发生了什么”记下来原程序在卸载完成后只打印一个*回收了几份副本都靠猜。我在改的时候加了一段最简单的日志在RemoveTSR的每个Restore*调用前后各记一个时间戳写到固定的内存位置最后用debug读出来。不需要文件系统因为那个年代写文件的风险比读高得多。; 在数据区加一个日志缓冲区每条记录 4 字节步骤号 状态 时间低字 LogBuf db 64 dup(0) LogPtr dw 0 LogStep macro step, st push ax push di mov di, cs:LogPtr mov byte ptr cs:LogBuf[di], step mov byte ptr cs:LogBuf[di1], st mov ax, es:[6Ch] ; BIOS 时钟低位粗略计时够用 mov word ptr cs:LogBuf[di2], ax add cs:LogPtr, 4 pop di pop ax endm然后在每个还原步骤前后插一条LogStep 1, 0 call RestoreMCB LogStep 1, 1 LogStep 2, 0 call CloseFiles LogStep 2, 1es:[6Ch]是 BIOS 数据区的时钟滴答计数每次约 55ms 加一。用它计时不需要额外硬件精度对排查“哪一步卡住了”完全够用。跑完之后在debug里d一下日志区谁最慢、谁没返回一目了然。这套东西我后来在好几个嵌入式项目里都复用过思路是一样的在关键路径上打点比事后猜快得多。6.3 加一条安全检查卸载前先看有没有人在用最危险的情况是某个 TSR 正在处理一个文件这时候被强行卸掉文件句柄没关、缓冲区没落盘数据就丢了。原程序的做法是CloseFiles硬关句柄等于放弃了缓冲内容。我一般会加一道前置检查CheckBusy: mov ax, 5 ; 从 5 号句柄开始扫 1: push ax mov bx, ax mov ah, 3Eh ; 先试关关了说明没在用 int 21h pop ax jc next ; 关失败说明句柄有效且被占跳过去 ...说实话这段我写得不太满意——AH3Eh一旦成功就真把句柄关了没法“先问后关”。更稳的做法是用AH51h/AH50h拿当前 PSP再比对句柄表的拥有者但那是另一套结构了。折腾了两天之后我的结论是在 640KB 的机器上做这种检查收益不如直接要求用户“先存盘再按热键”来得实在。改造完怎么验证我的做法是准备三个测试桩按顺序加载第一个只占内存不挂钩子验证 MCB 释放、第二个挂 INT 1Ch验证向量还原、第三个挂 INT 9 并带自己的重入标志验证钩子链顺序。三个都卸干净了再去碰真实的驱动。6.4 一个具体的技巧用int 27h还是AH31h源码用的是int 27h它根据 CS 和 DX 计算驻留长度最大只能驻留 64KB而且不会释放环境块。如果改造后代码超过这个限制就得换成AH31h int 21hmov dx, offset Main 16 ; 驻留长度含 PSP单位是节 mov ax, 3100h int 21h注意这个长度是“从 PSP 开始算的节数”不是字节数算错了会截断代码或多占内存。我第一版就写错了驻留进去之后热键还能响应但一进卸载流程就跳到空白内存——因为RemoveTSR那段代码根本没被保留下来。这个错误的现象特别有迷惑性看起来“装上了”其实是装了一半。从那以后我每次改驻留长度都强制走一遍“反汇编确认 卸载本文还有配套的精品资源点击获取