ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

1994年DOS实模式TSR卸载源码:热键恢复中断向量与MCB内存回收

1994年DOS实模式TSR卸载源码:热键恢复中断向量与MCB内存回收

简介:这份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,而一堆程序要在你干活的时候赖着不走:中文平台的字库和输入法、磁盘缓存、鼠标驱动、打印台、屏幕截取工具、内存管理器的高端部分。这类程序统称 TSR(Terminate 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 endm

org 100h是 COM 格式的铁律——文件加载到段首偏移 100h 处开始执行,前面 256 字节是 PSP(程序段前缀)。.model tiny把所有段合并成一个,代码数据全在一个 64KB 段内,CS=DS=ES=SS,这在挂钩中断时极其方便:钩子里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或AH=31h需要拿到程序实际长度,编译器帮你加了什么不好算
重入保护自己一个字节标志位中断里再进中断,运行时库不可重入

dosseg让段在文件里的顺序按代码、数据排,避免链接器自作主张排序。locals @@是给局部标号加前缀,这样同一个文件里能反复用@@0@@1这种标号,不用为每个跳转起一个不重名的名字——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 ; bit0=XMS 已占, bit1=EMS 已占, bit7=钩子有效

热键不是简单的“哪个键按下”,而是一组位掩码:BIOS 在40h:17h维护一个字节,bit0/bit1 是左右 Shift,bit2/bit3 是左右 Ctrl,bit3 那半字节还有 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:[bx+1] ; 取缓冲区里那个字符的扫描码 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是键盘缓冲区的头尾指针,[bx+1]那个位置是按键的扫描码。这段代码还顺手做了一件事:命中附加热键之后,把es:[1ah]往后挪一格(并且做了 3Eh 回绕到 1Eh 的判断),等于把这个按键从缓冲区里提前取走,防止它被后面的程序读到。

第三,Kbd102这个开关处理的是 101/102 键键盘的标志位布局差异——这类键盘把右 Ctrl 和右 Alt 的标志放在了高 4 位,所以要shr al,1连做四次再比一次。紧跟着的es:[96h]是扩展键盘的额外标志字节,右 Ctrl 和右 Alt 在 101 键盘上被挪到了这里。这三个分支合起来,才是“任意 Ctrl 都能触发”。

第四,命中之后不是立刻动手,而是先call IsWinDOS。这个子过程连着调了两次 INT 2Fh:

IsWinDOS: 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: ret

AX=1600h是“告诉我跑在什么环境里”,AX=4680h是内存管理器的存在性检查。只要判定在图形外壳的窗口里,就只响一声蜂鸣器然后退出——因为那个环境下的 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, 0C0D7h+1 ; C0D8h:卸掉 RI 之后加载的所有 TSR,自己留着 jnz @@2 jmp KeepSelf @@2: cmp ax, 0C0D7h+2 ; C0D9h:连自己一起卸 jnz @@3 jmp NoKeepSelf @@3: cmp ax, 0C0D7h+3 ; 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 ; 再 SS=SP,等价于 SS=CS 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:49h(1Dh 字节)、40h:F0h(8 个字)键盘状态、磁盘参数错乱
RestoreEnvStr环境块字符串从保存的段址逐字节复制回DosEnv后续程序读 PATH 读到乱码
CloseFiles5~14 号文件句柄循环AH=3Eh 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 @@0

MCB 链的判定全靠块首那个字节:'M'表示后面还有,'Z'表示这是链尾。每个块的偏移 8 是拥有者 PSP 段址,把它清 0,DOS 下次扫描内存时就会把这个块当空闲块分配出去。偏移 0 后面的段址就是下一块的位置,inc ax是因为块头本身占一个节(16 字节)。这套结构和 DOS 的内存管理逻辑完全一致,所以改完立刻生效——不需要重启,也不会有残留。

最后RemoveTSR会做一串收尾:AH=50h 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 PATH=C:\BIN cd \SRC

提示:模拟器的 CPU 周期数别设太高,卸载过程里有jcxz $+2这种短延时循环,跑太快某些版本的模拟器在端口写时序上会有偏差,表现为偶发挂死。

4.2 编译链接:从 .ASM 到 .COM

org 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 常规内存,挂一个无关紧要的中断号。

第三步,加载 RI:

ri

如果一切正常,会看到它打印一行安装成功的提示并返回提示符,常规内存减少几 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 10

u反汇编是最直接的验证——汇编器有时候会因为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只需要动一处:

; 改之前:只匹配 Ctrl,Alt 和 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[di+1], st mov ax, es:[6Ch] ; BIOS 时钟低位,粗略计时够用 mov word ptr cs:LogBuf[di+2], 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, 1

es:[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 ; 关失败说明句柄有效且被占,跳过去 ...

说实话这段我写得不太满意——AH=3Eh一旦成功就真把句柄关了,没法“先问后关”。更稳的做法是用AH=51h/AH=50h拿当前 PSP,再比对句柄表的拥有者,但那是另一套结构了。折腾了两天之后我的结论是:在 640KB 的机器上做这种检查,收益不如直接要求用户“先存盘再按热键”来得实在。

改造完怎么验证?我的做法是准备三个测试桩,按顺序加载:第一个只占内存不挂钩子(验证 MCB 释放)、第二个挂 INT 1Ch(验证向量还原)、第三个挂 INT 9 并带自己的重入标志(验证钩子链顺序)。三个都卸干净了,再去碰真实的驱动。

6.4 一个具体的技巧:用int 27h还是AH=31h

源码用的是int 27h,它根据 CS 和 DX 计算驻留长度,最大只能驻留 64KB,而且不会释放环境块。如果改造后代码超过这个限制,就得换成AH=31h int 21h:

mov dx, offset Main + 16 ; 驻留长度,含 PSP,单位是节 mov ax, 3100h int 21h

注意这个长度是“从 PSP 开始算的节数”,不是字节数,算错了会截断代码或多占内存。我第一版就写错了,驻留进去之后热键还能响应,但一进卸载流程就跳到空白内存——因为RemoveTSR那段代码根本没被保留下来。这个错误的现象特别有迷惑性:看起来“装上了”,其实是装了一半。

从那以后我每次改驻留长度,都强制走一遍“反汇编确认 + 卸载

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进