简介:这份文档围绕小米创始人雷军早年的汇编作品整理而成,是一份代码赏析资料,面向对底层编程、汇编语言与计算机系统感兴趣的技术人员、学生及技术史爱好者。文档以1994年7月创建的RI.ASM(V2.12)为主线,展示这段常被提及收入教科书的代码如何借助热键卸载后台驻留的TSR程序,并列出可被移除的SPDOS、WPS、Game Busters等软件清单,同时呈现8259初始化、XMS/EMS内存申请、鼠标驱动重置等实现细节。压缩包内共有1个PDF文件,体积约116KB,是排版后的阅读版本,篇幅精炼,便于通读与摘录。代码中的V2.04至V2.12修订记录,还原了Windows DOS提示符下无法正常工作、热键设置报错、高位内存释放等问题的排查过程,读者可从中体会早期DOS环境下内存管理与兼容性调试的思路,对理解寄存器操作与中断调用的写法也有直接参考价值。目前已有2367人学习。
1. 一份 1994 年的 TSR 卸载器源码:为什么值得花时间读汇编代码
翻硬盘翻出一份 1994 年 7 月的汇编代码,文件名RI.ASM,头部标着Revision 2.12、RAMinit Release 2.0,作者署名只有几个字母。它做的事很朴素:常驻内存,按一个热键,把在它之后加载的 TSR 程序连同 PSP、MCB 链、中断向量、8259 掩码、显示模式、软驱参数全部还原,相当于给 640KB 时代的 DOS 环境准备了一次「后悔药」。抛开「这段代码的作者后来去做消费电子」这层流传背景,它本身就是一份少见的完整样本:INT 9 键盘钩子、INT 1C 定时钩子、INT 2F 自定义功能号、键盘状态字节、PSP 回溯、MCB 遍历,一个不落。想补 16 位实模式底子、想搞懂 TSR 怎么和中断向量表掰手腕的人,都值得把它跑一遍。
2. 环境搭起来:DOSBox 挂载与 MASM 6.11 完整编译链路
拿到一份.ASM文件之后第一件事是确认它面向什么运行环境。这份代码开头就是.model tiny、.code、org 100h、jmp Main,最后是int 27h——标准的 16 位实模式 COM 程序骨架,运行在 MS-DOS 上。你不可能拿 Visual Studio 或者 64 位 gcc 直接吃下它,因为里面到处是mov es, ax、out 21h, al、int 2fh这类直接操作段寄存器和 I/O 端口的指令,现代工具链在保护模式下的段检查会把这些全部拦下来。
2.1 为什么绕不开 MASM + DOSBox 这条路
可选方案其实有三个,我按实际踩过的顺序排一下:
第一种是用 NASM 写BITS 16+org 0x100+-f bin直接产出 COM。这条路干净,但 MASM 的locals @@局部标签语法、db LeftCtrl or RightCtrl这种带操作符的数据定义、RET与iret混用的地方都要逐处改写,改错一个标签名就是「链接通过、运行乱跳」。第二种是开一台 FreeDOS 虚拟机,用真实硬件环境跑,最贴近原始行为,但 8259 中断控制器初始化那段在虚拟机里可能被 hypervisor 吞掉一部分效果,调起来反而更绕。第三种就是我最后选的:DOSBox 挂载宿主机目录 + MASM 6.11 在虚拟 C 盘里编译。理由很实在——DOSBox 对 16 位实模式的模拟足够老实,int 27h、键盘缓冲区0040:001A、BIOS 数据区都按真机布局映射,而目录可以挂到宿主机上,源码用任意编辑器改完保存,切回 DOSBox 重新编译就行,不用来回拷文件。
提示:MASM 6.11 和 5.0 都能吃这份代码,6.11 的
/AT选项能一步生成 COM,省掉exe2bin那一环。如果你手上只有 5.0,走masm→link /TINY→exe2bin的三步也行,只是中间产物要多管一次。
2.2 目录布局与 DOSBox 挂载
先按下面这个结构把文件放好。DOSBox 的老版本对长文件名支持一般,路径里别带中文、空格和超过 8 个字符的主文件名,否则masm那一步会报cannot open file,而这种报错十有八九不是语法问题,是路径问题。
# 宿主机目录(示意) # D:/asm-lab/ # ├─ src/ri.asm <- 原始源码 # ├─ bin/ <- 编译产物落这里 # └─ tools/masm/ <- MASM 6.11 的 ml.exe / link.exe进 DOSBox 之后依次挂载。mount的第一个参数是 DOS 里看到的盘符,第二个参数是宿主机的绝对路径;-t cdrom之类的类型参数不要乱加,这里就是普通目录。
mount c "D:/asm-lab" c: cd tools\masm set path=%path%;c:\tools\masm cd ..\..\src dir执行完dir应该能看到RI.ASM和它的字节数。如果dir里显示的是RI ASM这种被截断的名字,说明挂载时文件名被 8.3 规则处理过,编译时要用截断后的名字,别照抄源码里的长名。
2.3 编译、链接、产出 COM 并首次加载
MASM 6.11 的ml支持/AT(assemble tiny),配合/c只汇编不链接,再手工 link,能看到每一步的中间产物,出问题时好定位。如果你的源码里保留了原作者的org 100h和.model tiny,/AT会自动处理成 COM 的地址偏置。
# 1. 汇编:/AT 生成 tiny 模型,/Zi 带调试符号,/Fl 输出列表文件 ml /AT /c /Zi /Flri.lst ri.asm # 2. 链接:/TINY 强制 tiny 模式,输出直接是 .com link /TINY ri.obj,ri.com; # 3. 校验产物 dir ri.com这里三个参数值得记一下:/AT告诉汇编器按.model tiny处理并且假设代码从100h开始;/TINY是链接器的开关,它会让链接器在输出时去掉 EXE 头,直接吐出二进制镜像;/Fl生成的.lst文件是把每条指令、每个偏移量、每个宏展开都列出来的宝贝,后面排查跳转错位全靠它。编译成功之后ri.com的大小一般在一千多字节量级,具体数字取决于你改了多少内容,跟原始版本对不上是正常的,不用慌。
加载前先做一次干净启动,DOSBox 里可以直接跑。执行ri之后如果程序正常常驻,屏幕上会打印一行提示(源码里用int 10h的0Eh功能做字符输出,配合clsStr那张字符串表),然后回到命令行提示符——这个「回到提示符」恰恰是常驻成功的标志,因为int 27h会把进程挂起但保留在内存里。
# 加载 RI 并观察是否常驻 ri # 如果提示符回来了,说明 TSR 已经挂在中断链上 # 此时再加载一个测试用的 TSR,然后按热键验证清理效果首次运行时最好只验证「能不能跑起来并常驻」,不要急着按热键。热键逻辑里有一段会重写 8259 的掩码寄存器,如果在 DOSBox 里触发时机不对,可能出现键盘直接失去响应,这时候只能关掉 DOSBox 重来。养成习惯:第一次跑只测加载,第二次跑才测触发。
3. 拆解 RI.ASM 的三个核心结构:钩子、热键、链回溯
把代码读薄以后会发现,整个程序真正撑起功能的只有三块:故障点最少但最容易写错的中断挂钩、看起来简单实则坑最多的热键判定、以及决定成败的 TSR 链回溯。剩下的两千多行,绝大部分是「还原现场」的收尾工作——恢复显卡模式、恢复软驱参数、恢复打印机口、恢复喇叭状态,一件件都是当年真实踩出来的。
3.1 INT 9 / INT 1C / INT 2F 三条钩子的分工
程序开头保留了几个原始向量地址槽位:oldInt9_Addr、oldInt1C_addr、oldInt2F_addr。这三个中断各自负责一件事。
INT 9 是键盘硬件中断,每次按键产生一次扫描码时 CPU 都会跳到这里。原作者把 INT 9 换成了自己的newINT9,在里面先调一次原始 INT 9 让 BIOS 把扫描码塞进键盘缓冲区,然后再检查缓冲区里的内容是不是热键。这一步的顺序不能反,先让 BIOS 处理再判断,否则会把按键吞掉,用户会觉得「键盘失灵」。
newINT9: pushf db 9ah ; call far ptr oldInt9_Addr 的手工编码 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, '!' ; 置忙这里有两个设计细节值得单独说。第一,db 9ah后面跟两个dw是在手工拼一条call far ptr指令的操作数,因为目标地址在运行时才会被填进那个dw槽位。用 MASM 的call dword ptr cs:oldInt9_Addr也能写,但当年很多汇编器对这条的编码不统一,手写字节最保险。第二,Flag那个忙标志是必须的——INT 9 处理过程中如果又来了一个按键中断,递归进入函数会把栈搅烂,典型症状是「按快一点就死机」。
INT 1C 是 BIOS 定时器 tick 钩子,每秒大约 18.2 次。这里挂它的用途不是计时,而是做「窗口 DOS」的检测:newINT1C里比对当前 INT 9 向量的段地址,如果发现 INT 9 已经被别的程序(比如某个国产排版系统)换掉了,就把自己的StopFlag置 1,暂时让位。这是一种很聪明的妥协——两个都想接管键盘的程序硬碰硬只会两败俱伤,让一步反而都能活。
INT 2F 是多路复用中断,这里用了C0D7h到C0DAh四个自定义功能号,分别对应「返回 RI 段地址」「卸载 RI 之后的所有 TSR」「连同 RI 一起卸载」「删除 RI 的所有副本」。选这个号段是因为当年C0xx这一片基本没人抢,属于事实上的自由领地。
newINT2F: cmp ax, 0c0d7h ; 返回 RI 段地址 jnz @@1 push cs pop ax iret @@1: cmp ax, 0c0d7h+1 ; 保留自身,卸载后加载的 jnz @@2 jmp KeepSelf把这四条钩子串起来看,整个程序的协作模型就清楚了:INT 9 负责感知按键,INT 1C 负责感知环境变化(别的程序抢了键盘),INT 2F 负责对外暴露控制接口,Flag和StopFlag两个字节负责在它们之间做状态同步。这套写法在今天的驱动开发里换个名字还在用,无非是自旋锁和状态机。
3.2 热键判定:[40:17h]状态字节加扫描码双保险
热键逻辑是这个程序里最容易改坏的部分。原作者的做法是「状态位比对 + 扫描码计数」两层。
第一层看 BIOS 数据区的0040:0017h,这个字节按位记录了左右 Shift、左右 Ctrl、左右 Alt 的按下状态,还有 ScrollLock / NumLock / CapsLock 的锁定状态。源码里的宏定义很直白:
LeftAlt equ 00101000b RightAlt equ 10001000b LeftCtrl equ 00010100b RightCtrl equ 01000100b LeftShift equ 00000010b RightShift equ 00000001b HotKey db LeftCtrl or RightCtrl ; 默认热键 = 左右 Ctrl 同时按但真正比较的时候并不是拿整个字节硬碰,而是先跟0F0Fh做与运算,把高四位的锁定状态位全部清掉:
mov ah, es:[17h] ; 当前按下状态 mov al, cs:HotKey ; 期望的按键组合 push ax and ax, 0f0fh ; 屏蔽 Caps/Num/Scroll 锁定位 cmp al, ah pop ax jnz @@10 ; 不匹配,直接退出为什么要屏蔽?因为如果用户开着 CapsLock,[40:17h]的第 6 位是 1,硬比整个字节的话热键就永远触发不了。这是血泪经验型的细节,注释里没写理由,但少这一行就是「有时候能按出来、有时候按不出来」的玄学故障。
第二层是AuxHotKey这个辅助键。它不检查状态位,而是直接翻键盘缓冲区0040:001Ah(头指针)到0040:001Ch(尾指针)之间的扫描码,找到匹配的就连续两次inc bx把尾指针往后推,等于把这个键从缓冲区里「吃掉」,不让它传给前台程序。
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 inc bx inc bx ; 头尾指针各推一格,吃掉该键 cmp bx, 3eh jb @@_2 mov bx, 1eh ; 环形缓冲区,越界回绕 @@_2: mov es:[1ah], bx3eh是缓冲区尾端的边界值,1eh是起始偏移,超出就回绕——键盘缓冲区是个标准的环形队列,回绕逻辑写错会导致键盘彻底不响应,这是第二个容易翻车的点。
3.3 RemoveTSR:PSP 回溯与 MCB 链倒着拆
真正决定这个程序价值的,是RemoveTSR那段。它的思路是:从当前 PSP 出发,沿着 MCB(Memory Control Block)链往前找,每找到一个属于 TSR 的内存块就还原它改过的东西,然后释放。
MCB 的结构在 DOS 里是固定的:块首字节'M'表示后面还有块、'Z'表示这是最后一块;偏移 1 处的字是拥有者 PSP 段地址;偏移 3 处的字是以 16 字节为单位的块大小。核心循环大致是这样的:
@@_0: mov ax, ds:NextDataSeg ; 上一个 TSR 的 PSP 段 cmp ax, -1 jz @@_1 ; 链到头了 mov cs:PrevDataSeg, ds mov ds, ax jmp @@_0 ; 继续往前滚每进入一个 TSR 的 PSP,就依次调用一串还原函数,顺序很有讲究:
| 调用顺序 | 函数 | 还原内容 |
|---|---|---|
| 1 | RestoreEnvStr | 环境块字符串 |
| 2 | RestoreMCB | 内存控制块链的拥有者字段 |
| 3 | CloseFiles | 该进程打开的文件句柄(从 5 号开始扫 15 个) |
| 4 | RestorePort | 0040:0000起的 8 个端口字 |
| 5 | RestoreLEDs | 键盘 LED 状态与修饰键位 |
| 6 | RestoreVecList | 中断向量表中的标记项 |
| 7 | RestoreFloppyParam | 软驱参数表[0000:0525h] |
| 8 | RestoreCVTchain | CVT 链 |
| 9 | RestoreMemoryManager | XMS / EMS 句柄 |
| 10 | RestoreBiosData | [40:10h]、[40:A8h]、[40:F0h]三块 BIOS 数据 |
| 11 | Enable8259 | 8259 中断控制器的掩码寄存器 |
| 12 | RestoreClockSpeed | CPU 时钟速度 |
| 13 | ResetDisk | 磁盘系统复位 |
这个顺序不能随便调。比如RestoreMCB必须在CloseFiles之前,因为文件句柄表是挂在 PSP 上的,如果先把 MCB 的拥有者改回去了,再去找 PSP 就可能拿到一块已经被标记为自由的内存。RestoreBiosData放在靠后也是同理——它要拷[40:F0h]那个用户数据区,而这块区域在前面几步里可能还被临时占用着。
还有一段容易看漏的:进入RemoveTSR时第一件事是重建栈。
RemoveTSR: pop ax cli ; 关中断,改栈期间不能被打断 mov sp, cs mov ss, sp mov sp, 100h ; 用自己段内的 100h 当新栈顶 sti push ax为什么要重建栈?因为要卸载的 TSR 可能有自己的栈段,你现在正踩在别人的栈上,一旦把对方的内存块释放掉,返回地址就没了。换成自己的段内栈之后再动别人的内存,才安全。cli/sti夹住这一段也是必须的,改ss:sp的瞬间如果来了中断,CPU 会往一个半新半旧的栈上压现场,直接 triple fault。
4. 参数与配置:可改变量清单和改完必做的回归
代码读完就该动手改了。这份源码的好处是把几乎所有可调项都提在文件头部,用db/dw定义成一串具名变量,改起来不用满文件找魔数。但改完之后有几处联动必须一起动,漏一个就是「编译通过、运行没反应」。
4.1 可改参数一览表
先把这个头部区域里真正影响行为的变量挑出来。表格里「联动项」那一列是我实际改的时候踩出来的,原注释里没有。
| 变量名 | 默认值 | 作用 | 改动时的联动项 |
|---|---|---|---|
HotKey | LeftCtrl or RightCtrl | 主热键的修饰键组合 | 改完要同步改Kbd102分支里的位移逻辑 |
AuxHotKey | 0(关闭) | 附加热键的扫描码 | 非 0 时必须同时设AuxHotKeyName |
AuxHotKeyName | 'X$ ' | 附加热键的显示字符 | 长度要跟ColorPrintStr的格式匹配 |
Copies | '1' | 允许同时驻留的副本数 | 改成'1'以上要重算tsrLength |
Power | True | 是否执行 8259 重初始化 | 在虚拟机上建议先设False观察 |
Flag | '!' | 忙标志初值 | 不要改,改了重入保护就失效 |
StopFlag | 1 | 是否暂停接管键盘 | 首次调试设 1,确认钩子挂上再放开 |
Kbd102 | 0 | 是否按 101/102 键盘布局处理 Ctrl | 笔记本外接键盘场景需要设 1 |
Copies这个变量值得多说一句。它控制的是「同时能驻留几份 RI」,源码里通过 INT 2F 的C0DAh功能号把所有副本一起删掉。默认'1'表示只允许一份。如果你把它改成'2'以上,KeepSelf里那段用tsrLength计算常驻长度的逻辑就要重新核对,因为每多一份就多占一段内存,而tsrLength是在链接时由$符号算出来的固定值,不跟着变。改错了的症状很隐蔽:程序能加载、能触发,但卸载之后内存并没有真正释放,下次加载就报内存不足。
4.2 把热键改成左右 Alt 的完整流程
拿一个具体改动走一遍全流程,比看表格直观。目标是把默认的「左右 Ctrl」改成「左右 Alt」。
第一步,改头部定义。LeftAlt是00101000b,RightAlt是10001000b,或起来是10101000b,也就是0A8h:
; 改前 HotKey db LeftCtrl or RightCtrl ; 改后 HotKey db LeftAlt or RightAlt第二步,检查Kbd102分支。源码里有一段对 101/102 键盘的特殊处理,会把HotKey右移四位再跟[40:18h]的303h掩码比较:
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这段是给右 Ctrl / 右 Alt 在 102 键布局下位置不同的机器做补偿的。改成 Alt 之后,如果你在笔记本上跑,Kbd102建议保持0,先在标准布局下验证;确认能触发再考虑打开。
第三步,重新编译并验证。验证不要一上来就按热键,先看两件事:编译产物大小有没有突变(突变说明某个宏展开多了一层)、加载后提示符是否正常返回。
# 重新编译 ml /AT /c /Zi /Flri.lst ri.asm link /TINY ri.obj,ri.com; # 加载,先不按热键 ri # 观察提示符是否返回,再按左右 Alt 测试如果按下去没反应,先去翻ri.lst,搜HotKey找到它被引用的那几处,看and ax, 0f0fh之后al的值是不是你预期的0A8h & 0F0Fh = 0A08h。手算一遍比在 DOSBox 里反复重启快得多。
4.3 改完必做的三项回归
参数改完不是「能触发」就完事了。有三项回归我每次都会跑一遍,因为这三处坏了在正常操作下都不容易发现。
第一项,重入测试。按住热键不放,或者快速连按十几次。Flag那套忙标志如果被绕过,表现是程序卡死或者屏幕花掉。这一项在慢速机器上不明显,DOSBox 默认的 CPU 周期数下反而容易复现。
第二项,多副本测试。先加载一份ri,再加载一个无关的测试 TSR,然后触发热键,确认只有测试 TSR 被清掉、ri自己还在。接着执行ri的第二次加载,看它是否正确提示「已存在」并退出。这一步验证的是NextDataSeg那条链有没有接对。
第三项,退出 DOSBox 前的现场检查。触发一次热键之后直接执行mem命令看内存占用,再执行一次dir看磁盘访问是否正常。这两条命令分别压到了 MCB 链和磁盘复位两段逻辑,能跑通说明还原顺序没乱。
5. 避坑与排查:五个真实翻车现场
汇编这东西的调试体验跟高级语言完全不是一个量级。高级语言报个栈溢出至少给你一行行号,汇编翻车往往是「屏幕黑了」「键盘没了」「重启了」,连个错误码都没有。下面五条是我在这份代码上实际撞过的,按「现象 → 原因 → 解决」记下来。
5.1 编译与链接阶段的两个坑
坑一:link报fixup overflow,但代码里明明没有跨段引用。
现象是汇编通过、链接直接失败,错误信息指向某条mov或jmp。原因是.model tiny下所有代码和数据都挤在同一个 64KB 段里,一旦你加了几张字符串表或者多定义一个几百字节的缓冲区,段内偏移就可能超过0FFFFh,链接器算不出 16 位的偏移就报溢出。解决办法不是去调链接参数,而是老老实实看ri.lst里的段内偏移分布,把不必要的数据表挪走或者压缩。我当时的做法是把那张clsStr提示字符串表砍掉了三分之一,问题立刻消失。
坑二:生成的 COM 文件能加载但一执行就重启。
现象是敲ri回车,DOSBox 直接回到启动画面。这类故障九成出在org 100h那行。COM 程序被 DOS 加载时,代码段的起点固定在 PSP 之后,也就是段内偏移100h的位置,所有绝对地址都要以这个为基准。如果源码里org写的是0,或者被某些编辑器悄悄改成了别的值,jmp Main就会跳到错误位置,执行到一片随机数据上,重启是必然的。核对方法很土但有效:用十六进制编辑器打开ri.com,第一个字节应该是E9(jmp near)或者EB(jmp short),紧接着是跳转偏移。如果不是,先查org。
5.2 运行阶段的三次键盘事故
坑三:按了热键之后键盘失灵,只能强制关 DOSBox。
现象很吓人。原因是 8259 重初始化那段被触发了。Init8259里会根据MachineID判断是 AT 还是 PC/XT 机器,然后往20h/21h/A0h/A1h四个端口写 ICW1 到 ICW4 四个初始化字。这四个字写错任何一个,中断控制器的优先级或者屏蔽字就会错乱,键盘中断可能被永久屏蔽。解决办法是首次调试时把Power设成False,跳过整个 8259 流程,只验证其他还原逻辑;确认其他部分没问题了再把它打开,并且把old_8259里保存的原始掩码打印出来核对。源码里Enable8259最后那句out 021h, al/out 0a1h, ah就是把原始掩码写回去,这句如果漏了,症状同样是键盘失联。
坑四:Windows 下的 DOS 窗口里加载后热键完全无反应。
现象是在真实 DOS 下正常,进 Windows 的 DOS 窗口就不行。原因出在IsWinDOS那段检测。它用了两个 INT 2F 功能号:AX=1600h查是否运行在增强模式的 Windows 下,AX=4680h查版本号。检测到 Windows 就把StopFlag置 1,主动让位,因为 Windows 自己也接管了键盘。这个设计是对的,但如果你在虚拟机的「DOS 兼容模式」里跑,返回值可能落在00h到80h之间的灰色地带,导致判断走错分支。解决方式是临时把IsWinDOS的两个cmp都改成直接jmp @@9(返回 1),强制走「非 Windows」路径,先把功能跑通再回头细调判定。
坑五:卸载之后内存没有真正释放,第二次加载报内存不足。
现象是第一次加载、触发、卸载都正常,第二次加载时说内存不够。原因是RemoveTSR里RestoreMCB那段对 MCB 拥有者字段的处理不完整。MCB 链上每个块的第 8 个字节(偏移8)在某些 DOS 版本里还有额外含义,源码里有一句mov byte ptr es:[8], 0后面跟着日期注释,说明原作者也在这里打过补丁。如果你在 DOSBox 的某个特定版本上跑,这个字节的语义可能跟 1994 年不完全一致,导致块虽然被标记为自由,但链没接上。排查方法是卸载之后立刻执行mem /c,看那一段内存的归属;如果是Free但总量对不上,就是链断了,得手动在RestoreMCB里加一句把上一块的'M'标志重新对齐。
6. 两个可以带走的硬功夫:现场保护和内存核算
前面几章讲的都是「怎么让它跑起来」,最后说两个真正能从这份代码里带走、换到别的项目上也管用的东西。
第一个是中断现场保护的完整套路。这份代码里几乎每个钩子函数都以pushf开头、以iret结尾,中间必然有一句call far ptr old_xxx。这个模式的本质是:你只是链上的一环,不是链的主人。凡是替换别人向量的地方,都要先把原向量存下来,在自己的处理逻辑前后原样转发一次,最后用iret让 CPU 自动弹出 IP、CS、FLAGS 三个字。少弹一个字就是栈不平衡,多弹一个字就是跳飞。我后来写任何中断或回调钩子,都先按这个模板起手:
; 通用的向量钩子骨架(16 位实模式) newHandler: pushf call far ptr oldHandler ; 先转发,让原处理者做它该做的事 push ax ; 保护自己要用的寄存器 push ds ; ---- 自己的逻辑 ---- pop ds pop ax iret ; 恢复 IP/CS/FLAGS 并返回注意这里只保护了ax和ds,因为其他寄存器我没用。多保护寄存器不犯错,但会多吃栈空间;少保护一个就是随机崩溃。判断标准很简单:函数体里出现过哪个寄存器,就在开头 push 哪个,一个都别省。
第二个是内存占用的核算方法。TSR 程序的内存账目必须自己算清楚,因为 DOS 不会帮你。这份代码用tsrLength这个符号来标记常驻边界,它由链接器在$处求出,指的是从段首到当前指令的字节数。但真正驻留的不只是代码,还有数据区和栈区:
| 占用块 | 归属 | 是否随代码增长 |
|---|---|---|
| 代码段 + 已初始化数据 | tsrLength之前 | 是 |
未初始化数据(.data?) | tsrLength之后 | 是 |
运行时栈(100h起) | 段内固定位置 | 否 |
| PSP 本身 | DOS 分配 | 否,固定 256 字节 |
| 环境块 | DOS 分配 | 视环境变量数量 |
核算方式是在KeepSelf里int 27h之前,把tsrLength加上 PSP 的 256 字节、环境块长度、再留一点余量,写进dx寄存器。int 27h会保留CS:DX以内的内容并把其余部分还给 DOS。源码里mov dx, es:tsrLength后面跟着的那句mov al, 0h加rep stosb,是主动把超出常驻边界的内存清零,避免把上一份程序的残留数据带进新副本——这个动作在当年是防止「卸载后还能读到旧数据」的必需品。
说实话,第一次跑通这份代码的时候我按了热键,看着屏幕上那行红色提示弹出来、内存里的测试 TSR 被清掉,那种感觉跟现在跑通一个分布式服务完全不是一回事。后来我改过好几次HotKey,有一次忘了改Kbd102的联动,在笔记本上折腾了整整两个晚上才定位到。从那以后我每次动这类老代码的参数,都强制先跑一遍ri.lst的手工偏移核对,确认每个改动的宏都在列表文件里落了正确的字节,再进 DOSBox。这份 1994 年的源码,代码量不大,但该有的坑一个都不少,跑通一遍比读十篇中断向量表的教程都实在。希望帮到你。
本文还有配套的精品资源,点击获取