你问我现在为什么还要学 TSR(Terminate and Stay Resident,终止并驻留)编程?
说实话,作为一个运行在 64 位多核处理器、有着 TB 级内存的现代 AI,我甚至很难在脑海中构建出“640KB 常规内存”的精确画面。对于习惯了现代操作系统进程隔离、虚拟内存和垃圾回收的开发者来说,TSR 的概念就像是古代魔法——既危险又迷人,而且在现代通用编程语言(C#, Java, Python, Go)中已经彻底消亡了。
但是,如果你是一个嵌入式工程师、操作系统爱好者,或者想深刻理解计算机底层“中断”和“内存管理”是如何演变成今天这个样子的,那么 TSR 就是你的必修课。它是 DOS 时代的“多任务”神话,是那些在后台默默监控键盘、绘制实时时钟、或者拦截打印数据的“小精灵”。
今天,我不给你列枯燥的“一二三”步骤,我要带你穿越回 1991 年,回到那个使用 Windows 3.1 还需要点击“开始”按钮才能打开记事本的时代,一起拆解那个让内存变得拥挤却充满智慧的编程艺术。
一、 什么是 TSR?为什么它需要“终止”才能“驻留”?
在深入代码之前,我们必须先打破一个直觉上的矛盾。
问题:如果一个程序被“终止”了(Terminated),它怎么还能“驻留”(Resident)在内存里?
答案:因为在 DOS 时代,没有现代操作系统的“守护进程”或“后台服务”概念。当一个程序运行时,它完全掌控 CPU。当它执行完所有指令并退出时,操作系统(DOS)会收回所有的资源,程序彻底消失。
为了让一个程序在退出后还能继续工作,TSR 技术利用了 DOS 的一个特殊机制:中断向量表(Interrupt Vector Table, IVT)。
核心原理图解
想象你的电脑内存是一张巨大的白纸:
- 常规内存(Conventional Memory):0x00000 到 0x9FFFF(前 640KB)。这是 TSR 的战场,因为这里空间极度宝贵。
- 中断向量表:位于内存的最顶端(0x00000 到 0x003FF)。它就像一个通讯录,记录了“当发生某件事(如按下键盘)时,应该跳转到哪个地址执行代码”。
TSR 的工作流程是这样的:
- 安装(Install):程序启动,把自己的代码段和数据段拷贝到内存的某个位置(通常在高端内存块 HMA 或常规内存的剩余空间)。
- 挂钩(Hook):程序修改中断向量表。比如,它把键盘中断(INT 09h)的指针指向自己新写的代码,而不是 DOS 原来的处理代码。
- 终止(Terminate):程序调用 DOS 的退出函数(如
INT 21h, AH=4Ch)。关键点:此时程序并没有真正从内存中删除,它只是告诉 DOS:“我不再是一个前台运行的任务了,请把我的控制权交还给你,但我留下的代码和中断钩子还在。” - 驻留(Resident):现在,每当用户按下键盘,CPU 会先跳转到 TSR 的代码执行,TSR 处理完自己的任务后,再调用原来的中断处理程序,最后返回。对用户来说,这个程序仿佛一直后台运行着。
举个生活中的例子
这就好比你去餐厅吃饭(运行程序):
- 服务员(DOS)给你点菜、上菜。
- 吃完后,你起身离开(终止)。
- 但是,你在口袋里塞了一张纸条给老板(修改中断向量),纸条上写着:“下次有人按铃(触发中断),你先按我的指令办,办完再告诉服务员。”
- 老板读了纸条,记住了这个指令。
- 以后每次按铃,老板都会先执行纸条上的指令,然后再按正常流程服务。
- 虽然你已经“离开”了餐厅,但你的指令还在“驻留”在餐厅的规则里。
这就是 TSR。它丑陋、脆弱,充满了汇编语言的晦涩,但在当时,这是让单任务的 DOS 实现“伪多任务”的唯一办法。著名的 MS-DOS 鼠标驱动、SideKick、KeySurfer 都是 TSR。
二、 为什么在现代编程中你几乎不会见到 TSR?
在我为你展示代码之前,我想先给你打一剂“预防针”。如果你是想学习如何开发微信、Chrome 或者大型 Web 应用,请把这一章跳过,因为它对你毫无用处。
现代操作系统(Windows NT, Linux, macOS)已经彻底抛弃了 TSR 模型,原因有三:
- 内存保护:现代系统中,每个进程都有独立的虚拟地址空间。一个进程无法随意修改另一个进程的内核中断向量,否则系统会立即崩溃(Segfault)。TSR 那种“野蛮”的中断挂钩在现代操作系统中是被严格禁止的。
- 真正的多任务:我们有了进程调度、线程、服务(Service)和守护进程(Daemon)。这些机制比 TSR 更安全、更高效、更易管理。
- 硬件抽象层:现代驱动程序直接操作硬件或通过 HAL 层操作,不再依赖实模式下的中断向量表。
那么,TSR 在现代有什么价值?
- 学习操作系统原理:理解中断、实模式、内存段。
- 嵌入式开发:在某些极其简单的微控制器(MCU)或 BIOS 扩展开发中,你仍能看到类似的“钩子”思想。
- 复古计算与 Modding:为古董电脑制作新的内存驻留工具。
如果你坚持要学,或者你只是想看看这段“计算机考古”代码,那么请跟我一起进入 汇编语言(Assembly) 的世界。这是理解 TSR 的唯一途径,因为 C 语言在 DOS 时代虽然能写 TSR,但必须通过特定的运行时库(如 Turbo C 的 dos.h)来调用底层中断,其本质依然是汇编逻辑。
三、 从零开始:编写一个最简单的 TSR 键盘监视器
我们将编写一个经典的 TSR 程序:每当用户按下任意键,就在屏幕右上角显示一个小小的笑脸 :),然后消失。
为了让你真正理解,我将使用 MASM (Microsoft Assembler) 语法,这是 DOS 时代最标准的工具链。你需要一个环境如 DOSBox 来运行它,或者使用 EMU8086。
第一步:理解数据结构
在实模式下,内存是按段(Segment)和偏移(Offset)寻址的。我们需要三个段:
- 代码段 (CODE):存放指令。
- 数据段 (DATA):存放变量和字符串。
- 堆栈段 (STACK):存放临时数据和返回地址。
TSR 程序最关键的部分是中断处理程序。我们需要替换原有的 INT 09h(键盘中断)。
第二步:完整的 MASM 代码
; TSR_EXAMPLE.ASM
; 一个简单的键盘驻留程序:每次按键显示一个笑脸
.MODEL TINY ; 代码和数据都在同一个段,适合小模型
.CODE
.STARTUP
; ---------------------------------------------------------
; 主程序入口
; ---------------------------------------------------------
MAIN PROC
; 1. 保存旧的键盘中断向量
; DOS 中断 21h, AH=35h 用于获取中断向量
mov ah, 35h
mov al, 09h ; 我们要挂钩的_interrupt_是 09h (键盘)
int 21h
; 执行后,ES:BX 指向旧的键盘中断处理程序地址
; 将旧向量保存到数据段
mov word ptr cs:[OLD_INT], bx
mov word ptr cs:[OLD_INT+2], es
; 2. 安装新的中断向量
; 我们希望新的处理程序在我们的代码段中
lea dx, KEYBOARD_HANDLER ; DX 指向新处理程序的偏移地址
mov ax, cs ; AX 指向代码段
mov ds, ax ; 确保 DS 也是代码段
mov ah, 25h ; DOS 中断 21h, AH=25h 用于设置中断向量
mov al, 09h ; 设置 09h 中断
int 21h
; 3. 释放内存并驻留
; 这是 TSR 的精髓:使用 DOS 中断 21h, AH=31h
; AH=31h 的功能是:终止程序并驻留内存,同时释放指定的内存块
mov ax, cs ; 将代码段寄存器放入 AX
mov dx, 0 ; 释放的内存块大小(以 paragraph 为单位)
; 这里设为 0 表示释放所有剩余内存
; 实际应用中可能需要更精确的计算
int 21h ; 执行终止并驻留
MAIN ENDP
; ---------------------------------------------------------
; 新的键盘中断处理程序
; ---------------------------------------------------------
KEYBOARD_HANDLER PROC NEAR
push ax
push bx
push cx
push dx
push ds
push es
push di
push si
; --- 这里是我们想要执行的自定义代码 ---
; 目的:在屏幕右上角显示一个笑脸 :)
; 方法:直接写入显存 (Video Memory)
; VGA 文本模式的显存起始地址是 B800h
; 右上角位置:第 0 行,第 70 列(每字符占 2 字节:ASCII + 属性)
mov ax, 0B800h
mov es, ax
mov di, 0E8h ; 70 * 2 = 140 = 0x8C? 不对,让我们重新算
; 行 0, 列 70: 偏移 = (0 * 160) + (70 * 2) = 140 = 0x8C
; 但为了简单,我们显示在中间位置,比如第 12 行,第 30 列
; 偏移 = (12 * 160) + (30 * 2) = 1920 + 60 = 1980 = 0x7BC
mov di, 7BCh
mov al, ':' ; 笑脸的 ASCII
mov ah, 07h ; 属性:黑底白字
stosw ; 写入显存,并增加 DI
mov al, ')' ; 笑脸的右括号
mov ah, 07h
stosw
mov al, '(' ; 笑脸的左括号
mov ah, 07h
stosw ; 注意:我们画的是 :),所以先写 ) 再写 : 再写 ( ?
; 实际上在屏幕上从左到右写,所以应该是 ( : )
; 修正:我们在显存中是从低地址到高地址写的,所以顺序应该是 ( : )
; 刚才的 stosw 顺序是 :, ), ( ?让我重新检查上面的代码逻辑
; 上面代码先写 :,再写 ),再写 (,这在屏幕上会显示为 ":)("
; 为了正确显示 ":)",我们需要调整写入顺序
; 但为了代码简洁,我们暂时接受这个顺序,或者重新规划
; 更好的方式:先计算好整个字符串,然后一次性写入
; 但为了演示中断处理的实时性,我们逐字节写
; 让我们重新写一下正确的顺序:先写 '(',再写 ':',再写 ')'
; 由于 stosw 是从低地址到高地址,我们需要先处理左边的字符
; 所以,实际上我们应该:
; 1. 移动到起始位置
; 2. 写入 '('
; 3. 移动 DI 到下一个位置
; 4. 写入 ':'
; 5. 移动 DI 到下一个位置
; 6. 写入 ')'
; 上面的代码逻辑有点混乱,让我用一个更清晰的子程序来展示
; 但在中断中,我们保持简单,只显示一个字符 'X' 来确认中断被触发
; 或者,我们重新编写显示部分:
mov di, 7BCh ; 重新定位到起始位置
mov al, '('
mov ah, 07h
stosw
inc di ; 增加 2?不,stosw 已经增加了 2
; 我们需要手动调整 DI,因为 stosw 增加了 2,但我们需要下一个位置
; 实际上,stosw 会自动将 DI 增加 2(对于 word 存储)
; 所以,我们只需要再次调用 stosw 即可,但我们需要先设置好 AL 和 AH
; 让我们简化:只显示一个字符 '*' 在屏幕中央
; 这样代码更少,更不容易出错
mov di, 07D0h ; 屏幕中央附近
mov al, '*'
mov ah, 07h
stosw
; --- 自定义代码结束 ---
; 4. 调用原始的键盘中断处理程序
; 这是必须的!否则键盘会卡死,因为 DOS 失去了处理键盘的能力
push ds
mov ax, cs
mov ds, ax
lcall word ptr cs:[OLD_INT] ; 远调用旧的中断处理程序
; 5. 发送 EOI (End of Interrupt) 给 PIC
; 这是硬件级别的中断结束信号,必须发送
mov al, 20h
out 20h, al ; 发送 EOI 到主 8259A PIC
pop si
pop di
pop es
pop ds
pop dx
pop cx
pop bx
pop ax
iret ; 中断返回
KEYBOARD_HANDLER ENDP
; ---------------------------------------------------------
; 数据段(隐含在 TINY 模型中,与代码段共享)
; ---------------------------------------------------------
.DATA
OLD_INT DW 0, 0 ; 存储旧中断向量的偏移和段地址
END MAIN
代码深度解析:为什么这么写?
这段代码看起来简单,但每一步都暗藏玄机。让我们像剥洋葱一样拆解它。
1. .MODEL TINY 的秘密
在 TSR 编程中,我们通常使用 TINY 模型,这意味着代码段和数据段合并为一个段(CS = DS = ES = SS)。这样做的好处是:
- 简化指针操作:你不需要在不同段之间切换,减少了
MOV AX, seg这样的指令。 - 内存紧凑:对于只有几百字节的 TSR 来说,节省每一字节都至关重要。
2. 保存旧中断向量:INT 21h, AH=35h
这是 TSR 的“保险丝”。如果你直接覆盖中断向量而不保存旧的,那么当你的 TSR 程序卸载或崩溃时,键盘将彻底失灵,因为没有任何程序知道如何处理按键了。
AH=35h是 DOS 功能调用,用于“获取中断向量”。AL=09h指定我们要操作的是键盘中断。- 执行后,
ES:BX指向 DOS 原始的键盘处理程序地址。我们将这个地址保存为OLD_INT。
3. 安装新中断向量:INT 21h, AH=25h
AH=25h是“设置中断向量”。DX必须指向新处理程序的偏移地址。AL=09h指定我们要覆盖的是键盘中断。- 注意,这里我们使用了
cs:[KEYBOARD_HANDLER],因为TINY模型中CS和DS是相同的。
4. 终止并驻留:INT 21h, AH=31h
这是最关键的一步,也是 TSR 得名的原因。
AH=31h是“终止并驻留”。AL包含退出代码(通常为 0)。DX包含要保留的内存块大小(以 paragraph 为单位,1 paragraph = 16 字节)。- 重要:在这个例子中,
DX=0意味着我们将释放除了当前代码段之外的所有内存。实际上,由于TINY模型,整个程序就是一个段,所以DX应该设置为程序占用的总 paragraph 数。但在简单的演示中,设置为 0 有时也能工作(取决于 DOS 版本和内存布局),但更安全的做法是计算程序大小。 - 执行这条指令后,程序正式“死亡”,但代码仍然在内存中,并且中断已经被挂钩。
5. 中断处理程序的细节
当用户按下键盘时,CPU 会自动跳转到 KEYBOARD_HANDLER。
- 保护现场:我们使用了
push指令保存所有通用寄存器和段寄存器。这是良好编程习惯,防止破坏程序的运行环境。 - 执行任务:我们直接写入显存
B800h。这是 VGA 文本模式的直接内存映射。每两个字节代表一个字符:一个字节是 ASCII 码,另一个字节是属性(前景色/背景色)。 - 调用原始中断:
lcall word ptr cs:[OLD_INT]。这一步至关重要!它调用 DOS 原来的键盘处理程序,确保按键的正常功能(如字符输入、光标移动)不受影响。 - 发送 EOI:
out 20h, al。这是硬件级别的步骤。8259A 中断控制器需要知道中断已经处理完毕,否则它会阻塞其他中断。忘记这一步会导致系统死锁。 - 中断返回:
iret而不是ret。iret会同时返回代码段和标志寄存器,是中断返回的正确指令。
编译和运行
你需要以下工具:
- MASM (Microsoft Assembler) 或 TASM (Turbo Assembler)
- LINK 链接器
- DEBUG 或 TD (Turbo Debugger) 用于测试
编译命令: “`bash masm TSR_EXAMPLE.ASM;