ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

汇编实现内存驻留时钟:TSR中断向量与热键卸载全解析

汇编实现内存驻留时钟:TSR中断向量与热键卸载全解析 先说点大实话TSR内存驻留程序这六个字放在今天依然能劝退一堆人。但如果你在DOS/VGA时代写过几行汇编或者现在想弄明白中断向量、内存控制块、驻留退出这些底层机制是怎么协同工作的那这个主题绝对是性价比最高的一条路。这篇博文就用一个非常典型的例子——汇编实现内存驻留时钟把“驻留”“热键设置”“删除”三件事完整拆开讲清楚顺便把那些文档里不写、但你踩一次就头疼的坑也一起倒出来。这个项目适合三类人一是刚学完8086汇编、想找个实战练手的人二是搞嵌入式或系统底层、需要理解中断和内存管理的开发者三是纯粹怀旧、想在自己的DOSBox里跑点有意思东西的老玩家。无论哪一类看完都应该能自己动手写一个可驻留、可热键控制、可干净卸载的TSR程序。1. 项目概览与TSR设计思路1.1 TSR到底是什么为什么值得手写一遍TSR全称Terminate and Stay Resident翻译过来是“终止但驻留”。普通程序跑完就退出内存被回收TSR程序则是在退出后留下自己的一部分代码和数据在内存里通过接管某个中断来获得后续的执行机会。最典型的应用就是DOS时代的中文输入法、杀毒监控、磁盘缓存工具还有那些桌面小闹钟。用生活化点的方式理解普通程序像临时工干完活领钱走人TSR像住在大楼里的保安平时你看不见他但有按键或事件发生时他立刻冒出来处理处理完又回到暗处。保安的“住处”就是程序驻留在内存里的那部分代码而“呼叫保安的方式”就是中断。自己在汇编层面手写TSR最大的价值不是写出一个多好用的时钟而是能把中断机制彻底弄明白。中断向量怎么保存、怎么恢复、怎么在中断服务程序里做最少的操作、怎么让DOS安全地把内存还给你——这些知识放到今天的RISC-V异常处理、RTOS中断嵌套、Linux内核驱动开发里思路依然是通用的。1.2 整体模块划分与选型考量实现一个TSR时钟按功能可以切成四块初始化部分保存旧中断向量、安装新中断向量、驻留退出。时钟刷新部分挂到定时器中断上周期性更新时间并送到屏幕。热键检测部分挂到键盘中断上识别组合键并执行对应动作。卸载部分恢复中断向量、释放环境块和驻留内存块。这里最关键的选型是“接哪个中断”。常规选择有两个方向一个是接管INT 08h硬定时器中断约每秒触发18.2次另一个是接管INT 1ChBIOS在INT 08h处理过程中调用的用户钩子同样每秒触发18.2次但不是硬件中断的原始入口。我建议示例程序接管INT 1Ch或INT 08h链的末尾。直接替换INT 08h意味着你要完全接管硬件定时器中断表面上省事实际上一旦你没有妥善调用旧的INT 08h整个系统的时钟节拍、软驱马达超时、BIOS时间计数都会出问题。挂INT 1Ch则安全得多BIOS已经完成了硬件应答它额外调你一下你只需要做自己的事情做完就返回。时钟显示和键盘热键检测都通过这条链驱动。键盘则接INT 09h硬件键盘中断。这里必须保留旧向量并转发扫描码否则用户键盘输入会全部失效。1.3 为什么选汇编而不是C这个问题在项目选型时必须想清楚。TSR的难点在于控制“精确的内存布局”你希望驻留的代码段到哪里结束、哪些部分可以被DOS回收。用C也可以但编译器会塞入运行库初始化、堆栈初始化等一堆额外逻辑驻留长度和入口行为没那么直观而汇编可以精确到字节控制你能看到每一个字节去了哪里。另一个原因是中断服务程序要求尽量轻量不能在中断里调用不可重入的DOS功能。汇编让你对每条指令的开销有直观感受也会倒逼你用最简单的逻辑完成任务。比如后续要讲的“直接写显存”“直接读键盘端口”用C也能做但汇编写出来会自然带上“这套逻辑为什么能跑在中断上下文里”的清醒感。2. 中断接管与热键设置的实现2.1 中断向量表从读表到写表的关键步骤8086的中断向量表固定在内存地址0000:0000处每个中断向量占4字节低字是偏移高字是段地址。INT 08h的向量在0000:0020hINT 09h的向量在0000:0024hINT 1Ch在0000:0070h。接管一个中断的标准流程是先用INT 21h的AH35h功能获取旧向量保存到自己的数据区再用AH25h功能设置新向量。下面是从启动代码里截取的安装片段; 安装新中断处理例程 install: mov ax, 351Ch ; AH35h, AL1Ch获取旧INT 1Ch向量 int 21h mov [old_int1c_offset], bx mov [old_int1c_seg], es mov ax, 3509h ; 获取旧INT 09h向量 int 21h mov [old_int09_offset], bx mov [old_int09_seg], es cli ; 关中断防止安装过程被打断 mov ax, 251Ch mov dx, offset int1c_handler push ds mov ds, cs ; DS指向当前代码段 int 21h mov ax, 2509h mov dx, offset int09_handler int 21h pop ds sti ret这里有几个细节值得重点说明。第一保存旧向量必须要保存完整的4字节段地址和偏移恢复的时候也要同时恢复两者。很多人只保存偏移恢复时用当前DS去拼凑段地址看起来能跑但一旦TSR驻留程序被加载到不同段位置或系统中其他程序也接管过这个中断恢复就会出错。第二设置向量时最好用CLI关掉中断。虽然INT 21h的25h功能本身会临时关中断但两个向量相继安装期间如果刚好来个键盘中断你的INT 09h处理例程可能还没安装完整系统就可能跑飞。稳妥做法是安装前CLI安装后STI。2.2 热键检测扫描码、Ctrl状态与旧中断链键盘硬件中断INT 09h触发时键盘控制器会把当前按键的扫描码放到60h端口。你需要在处理例程里读60h判断是不是你要的热键然后把控制权交给旧INT 09h让BIOS继续完成按键入缓冲区的工作。设计这版时钟程序我计划三个热键CtrlF12显示或隐藏时钟。CtrlF11切换显示颜色黑底白字、蓝底黄字、黑底绿字循环。CtrlEnd卸载整个TSR程序。F12的扫描码是58hF11是57hEnd是4Fh。Ctrl键的扫描码是1Dh左右Ctrl共用同一个make code但重复码不一样简化处理就都当成Ctrl。判断组合键有两种思路一是每次读键盘状态字节0x40:0x17检查Ctrl位是否置位二是在自己的中断例程里维护Ctrl键按下/释放状态。我建议用第二种因为INT 09h最开始触发时BIOS的键盘状态字节可能还没来得及更新直接查0x40:0x17会偶发漏判。维护状态也简单从60h读到1Dh先不管它把它传给旧INT 09h同时把自己维护的ctrl_held标志置1读到1Dh的break码9Dh时清标志。这样F12的make码到达时ctrl_held已经是1。热键判断的参考逻辑大概长这样int09_handler: push ax in al, 60h ; 读取扫描码 cmp al, 1Dh ; Ctrl按下make jne check_ctrl_break mov byte ptr [ctrl_held], 1 jmp forward_to_bios check_ctrl_break: cmp al, 9Dh ; Ctrl释放break jne check_hotkey mov byte ptr [ctrl_held], 0 jmp forward_to_bios check_hotkey: cmp byte ptr [ctrl_held], 0 je forward_to_bios cmp al, 58h ; F12 je do_toggle_display cmp al, 57h ; F11 je do_toggle_color cmp al, 4Fh ; End je do_unload jmp forward_to_bios forward_to_bios: pop ax jmp dword ptr cs:[old_int09_offset] ; 跳回旧INT 09h请注意最后一步。所有热键判断完成后只要按键不是你要处理的组合键或者即使是组合键但你已经做了自己的处理也要继续调用旧的INT 09h。否则BIOS的键盘缓冲区永远收不到这个扫描码回车、字母、方向键等功能全都会失效。实战中很多人刚写完TSR发现“系统按键没反应”八个原因里九个都是忘记转发旧中断。组合键的另一个细节是去抖或防重复触发。按住F12不松键盘会自动重复发送make码你的切换逻辑就会疯狂闪动。解决方法是设置一个“按键已处理”标志等break码来了再清除或者干脆在触发后把ctrl_held和按键状态都临时忽略直到检测到对应break码。示例程序里我采用“等break再解锁”的方式简单有效。2.3 时钟节拍中断的刷新逻辑时钟部分挂在INT 1Ch上。因为INT 1Ch本身由BIOS在INT 08h链里调用硬件中断已经被BIOS应答所以你的处理例程不需要写EOI命令到8259A也没必要做复杂的硬件操作只需要轻量更新数据。INT 1Ch的触发频率在经典PC上是18.20648193Hz也就是每秒大约18.2次。你可以在每次触发时递增tick计数但真正要做的是“每秒刷新一次时间”。为了代码简单我采用累计计数的方式每累计182个tick就给总秒数加10秒。这样一秒不多不少正好换算成十进制误差大概在每秒0.03%级别。这种精度对演示钟表足够了如果你要做准到秒的频标时钟那需要按65536个tick等于1小时来精确累计或者直接读取BIOS数据区0040:006Ch的32位计数器那个才和硬件时钟严格对齐。示例里的时钟刷新逻辑int1c_handler: push ax inc word ptr [tick_count] cmp word ptr [tick_count], 182 jb int1c_done mov word ptr [tick_count], 0 call update_clock ; 更新时分秒变量 call refresh_display ; 如果秒值变化写显存 int1c_done: pop ax iretupdate_clock里维护三个变量hours、minutes、seconds。每秒加一次秒满60进位到分钟满60分钟进位到小时满24小时归零。这些变量都放在驻留数据段里不依赖任何DOS调用也不依赖BIOS调用完全自洽。这也是TSR中断处理例程的通用原则在中断里尽量不调用BIOS/DOS服务所有状态自己维护所有输出直接操作硬件或显存。另外注意如果clock显示是“每秒刷新”的那么每秒只需要写一次显存。不要在每次INT 1Ch里都重绘8个字符那会带来轻微闪烁。判断“秒值变了没有”最简单就是记住上次显示的秒值当前秒值不同才触发刷新。3. 时钟显示、驻留启动与内存释放3.1 直接写显存文本模式下的时钟显示在80x25彩色文本模式下显存从B800:0000h开始。每个字符占2字节第一个字节是该字符的ASCII码第二个字节是颜色属性。要对屏幕右上角输出时钟就计算好偏移地址直接用MOV写显存。比如第0行第68列开始显示偏移量就是(0 * 80 68) * 2 136。颜色属性高四位是背景色低四位是前景色。黑底白字是07h蓝底黄字是1Eh黑底绿字是0Ah。这个可以存到一个变量里切换颜色时只改这个变量然后重新刷一遍当前时间。刷新显示不能只更新变化的那一位因为进位会导致分钟或小时变化。最简单的实现是把时分秒格式化到缓冲区一次写8个字符加8个属性字节。如果时钟要显示在右下角还要注意避免覆盖命令行输入区域。我的示例选择右上角因为那里被输入内容干扰的概率低。中断里直接写显存不需要任何系统调用所以非常安全。唯一要注意的是不要破坏光标位置。直接写显存不移动光标但如果你用INT 10h的Teletype方式来显示字符光标就会乱跑。这也是TSR显示模块里最常见的错误之一。3.2 驻留退出AH31h与驻留长度计算初始化完成后程序不能像普通程序那样用INT 20h或AH4Ch退出那样整个内存块都会被释放。必须用AH31h结束并驻留或INT 27h。AH31h比INT 27h更灵活推荐使用。驻留的关键是给DX传入“要保留的节数”。1节16字节。DX是从程序段起始算起的所以必须包含PSP、数据段以及你希望继续留在内存里的所有中断处理例程。一种经典的安全写法是terminate_and_stay: mov ah, 31h mov al, 0 ; 退出码 mov dx, offset resident_end add dx, 15 shr dx, 4 ; 换算成节 int 21hresident_end是你期望的驻留区域末尾的标号通常放在中断处理例程之后。加上15再右移4位是向上取整到节。如果你忘了把中断处理例程包含进驻留区程序驻留后第一次按热键就会跳到已释放内存轻则No Response重则直接死机。这是TSR新手最容易踩的深坑我最初调试时就因为少算了一个节折腾了一晚上。另外注意AX3100h执行后不会再返回所以后面不需要跟任何指令。但驻留前一定要确保新中断向量已经安装否则驻留的程序不会被任何事件激活等于白驻留。3.3 热键卸载恢复中断向量、释放环境块与PSP内存块“删除”TSR是完整项目里技术含量最高的部分。它要求把之前驻留的代码和数据所占用的内存还给DOS同时恢复所有被接管的中断向量。卸载流程可以拆成四步第一步恢复中断向量。把保存的旧INT 1Ch和旧INT 09h地址用AH25h写回去。这个顺序很重要先恢复向量再释放内存。如果顺序反了恢复向量的过程可能正好触发一次中断而处理例程所在的内存已经被释放系统直接就崩了。第二步释放环境块。每个DOS进程启动时DOS会为它分配一个环境块用来存放PATH、COMSPEC等环境变量这个块的段地址保存在PSP偏移2Ch处。TSR驻留时这个环境块也一直占着内存不释放的话会泄漏。释放方法是对环境块段执行AH49h。第三步释放程序占用的内存块。这里要非常清楚一个概念DOS的AH49h释放内存时ES必须指向“要释放内存块的段”也就是程序PSP所在段而不是MCB的段。MCB内存控制块在PSP段地址减1的位置。直接对自身PSP段执行AH49hDOS会通过ES向低地址方向找到MCB然后把这块内存标记为空闲。第四步返回中断现场。释放完成后你还在中断处理例程里还要用IRET返回被中断的程序。因为你已经释放了自己所在的代码段从释放到IRET这条路径之间你的代码还在执行但内存已经被标记为空闲存在极小概率被其他内存申请覆盖。实际中这个窗口只有几条指令在DOS环境下基本安全但如果你追求绝对稳妥可以先把剩余的指令拷贝到堆栈中执行不过那就超出了这篇示例的范畴。卸载逻辑的骨架代码do_unload: ; 先恢复中断向量 push ds mov ax, [old_int1c_seg] mov dx, [old_int1c_offset] mov ds, ax mov ah, 25h mov al, 1Ch int 21h mov ax, [old_int09_seg] mov dx, [old_int09_offset] mov ds, ax mov ah, 25h mov al, 09h int 21h pop ds ; 释放环境块 mov es, [env_block_seg] mov ah, 49h int 21h ; 释放自身PSP内存块 mov es, cs mov ah, 49h int 21h ; 中断现场返回 iret这里有一个非常隐蔽的坑如果当前中断发生在DOS正在处理系统功能调用的时候比如用户正在执行DIR命令的瞬间按了卸载热键你在中断里调用INT 21h释放内存可能导致DOS内部状态混乱甚至死机。严谨的TSR会通过INT 21h的AH34h拿到InDOS标志地址卸载前检查这个字节是否为0如果不为0就延迟卸载。示例程序为了保持可读性走的是“立即卸载”路线但我会在下一节专门讲这个问题的现象和解决办法。还有环境块地址入口处就要保存PSP偏移2Ch处是一个环境块的段地址。可以用如下代码在初始化时读取mov es, [psp_seg] ; 初始化时DS也指向PSP mov ax, es:[002Ch] mov [env_block_seg], ax在卸载时先释放环境块再释放自身PSP块顺序不能反。环境块的MCB和PSP块的MCB是两个独立的块如果顺序颠倒只要段地址不变通常也能释放成功但保持“先子块后主块”的习惯更安全也更容易排查内存泄漏。3.4 关于“内存驻留程序删除”的另一种手法除了上面这种“在中断例程里自我释放”的严格方案还有一种更简单的卸载思路把卸载逻辑单独做成一个独立小程序这个小程序负责找到TSR驻留时保存的中断向量和PSP段然后恢复向量、释放内存。这种方式不需要在中断上下文中执行INT 21h安全性高很多但需要TSR向外部暴露一个“标志区”通常是段地址加偏移的组合而且要在系统里留下查找机制。如果不做文件系统关联最常用的就是通过指定的中断向量来传递信息比如让TSR接管一个空闲中断把内部参数首地址存进中断向量里卸载程序通过读这个向量来定位TSR。这个方案更符合工程实践但代码量也更长。这篇博文的示例选择在TSR内部卸载是因为它能让读者看到最完整的内存控制块操作过程对一个学习型项目来说更通透。你在实际生产工具里除非特殊需要最好还是用独立卸载器。4. 常见问题与实测排坑4.1 热键没反应或者系统按键失灵热键没反应先排查三件事。第一是否安装了正确的INT 09h处理例程。用DEBUG工具查看内存地址0000:0024h处的向量是否指向你的处理例程段地址。如果指向的是0000:xxxx这种BIOS默认地址说明安装函数根本没执行成功。第二组合键判断条件是否太严格。Ctrl的扫描码判断只写了1Dh有些扩展键盘会发送E0 1D这种双字节序列如果你的处理例程没处理E0前缀就会漏掉右Ctrl。简单方案是只支持左Ctrl或者把E0前缀也考虑进来。我的示例约定使用左Ctrl。第三是否忘了转发旧INT 09h。如果系统里其他程序也接管了键盘中断你的“转发到旧向量”不一定转发到BIOS默认处理器而是转发到更早的接管者。只要整个链上每个程序都遵守“处理完调用旧向量”链条就是通的。任何一个环节在没调用旧向量的情况下直接IRET链就断了。这也是为什么TSR程序卸载时必须恢复旧向量否则链条无法恢复。另外一个非常常见的误解是使用0x60h端口读扫描码读完一定要再转发给旧中断。如果不转发键盘状态字节和缓冲区都不会更新热键看似被“吃掉”了但系统里其他程序也失去这个键直观感受就是“键盘失灵”。4.2 时钟不刷新或屏幕闪烁时钟不刷新九成是INT 1Ch没有被触发。检查是不是安装中断向量时参数反了AH25h要求AL放中断号DX放偏移DS放段地址。不少人写成DSoffset、DXseg结果装了一根指向错误地址的向量一旦触发系统就会跳飞。屏幕闪烁多半是刷新频率太高。在中断例程里每18次写一次显存也就是每秒18次虽然每次只写几个字符但人眼已经能察觉到抖动。正确做法是维护“上次显示秒数”last_displayed_second只有当秒值不同才重绘。时钟每秒变化一次是合理的人眼感知频率屏幕会很干净。还有一点如果被中断的程序刚好也在操作显存比如正在运行EDIT全屏编辑器你的时钟写入会跟编辑器画面互相干扰。这属于TSR的固有限制。真要解决可以在写显存前检查当前视频模式是不是文本模式并尽量选择屏幕边缘位置写入。但无论如何TSR类工具在图形程序里显示都需要更复杂的协作机制这个超出本文范围。4.3 卸载后系统崩溃或内存泄漏卸载后崩溃先看是不是中断向量恢复不全。只恢复了INT 09h没恢复INT 1Ch或者是恢复后某个中断又触发了而处理例程已经被释放系统自然崩。排查方法是卸载后立刻执行命令如果第一条命令就卡死多半是键盘中断没恢复干净。如果过一会才崩可能是定时器中断或另一个链上的程序受影响。内存泄漏的麻烦更隐蔽。如果你卸载时没有释放环境块每次加载一次TSR再卸载一次环境块就会漏一块。表现出来就是反复加载卸载几十次后DOS报告“内存不足”。检查方法是用MEM命令看可用内存量变化。释放环境块这个问题很多老代码都忽略但它对长期运行的DOS系统影响很大。这里我必须提醒一个严重问题在DOS的Norton Commander或Windows 3.x的DOS窗口里按卸载热键可能触发重入导致崩溃。这是因为这些环境里被中断的当前程序本身就在调用DOS功能而你的中断里又调用了AH49h。如果你要写一个能放进工具箱长期使用的TSR请务必加上InDOS检查或者干脆把卸载功能独立成外部小程序。测试阶段在纯DOS命令行下按卸载热键是安全的但也别开着磁盘高速缓存去试。4.4 运行环境DOSBox、虚拟机与真机差异现代环境里跑这个程序首选DOSBox。DOSBox的中断模拟比较完整8250/8254/8259这些硬件的时序跟原机接近跑TSR时钟这类程序很合适。但要注意DOSBox默认CPU速度很高它内部的定时器中断频率还是按兼容逻辑模拟的所以你的时钟走时不会因为CPU主频高而变快这点不用担心。虚拟机里测试另一个好处是方便“一键还原”如果卸载逻辑写崩了直接重启虚拟机就行。真机上测试风险最高尤其是修改中断向量后如果崩溃只能冷启动而且某些BIOS对INT 1Ch链的调用方式跟老PC有微妙差异。我个人的建议是先用DOSBox把功能逻辑跑通然后到虚拟机里验证内存释放和卸载最后有兴趣再上真机。千万别第一次写就直接在真机上折腾除非你旁边有第二台设备随时准备查资料。说一个调试心得排查TSR类问题时直接打印“运行到哪行”是不现实的因为程序可能在任意时刻被中断触发。我习惯在数据段里放一个“调试状态字”每执行一个关键步骤就写一个特征值然后让TSR在某个安全时刻把状态字输出。死机后重启看最后的状态字落在哪一步基本就能定位问题。这个方法虽然土但比眼睛盯着屏幕等崩溃点可靠得多。4.5 热键设置的扩展场景如果你想把“热键设置”做得更灵活可以在TSR里开放一个配置区比如让用户通过命令行参数指定热键的扫描码或者允许运行时按两下按键来“录制热键”。这不算复杂核心就是把我上面维护ctrl_held和主扫描码的逻辑再加一层变量。时钟显示位置也可以用热键在四个屏幕角落之间轮换原理就是把显存偏移计算改成查表。再延伸一步你可以做“弹出式计算器”“屏幕截图”这类经典TSR整体框架一模一样接管键盘定时器中断中断里轻量处理通过热键触发主功能。框架一旦跑通后面换功能只是换中断处理例程和显示逻辑的事。我在写中文输入法的挂接模块时用的也还是这套思路只是把显存直写换成了更复杂的IME状态管理。最后再分享一个小技巧卸载热键务必选一个“平时几乎不会用到”的组合。我之前图省事用F12作为卸载键结果某次调试别的软件时用户习惯性按F12直接把后台挂着的时钟卸载了还连带让另一个驻留程序崩溃。后来我统一用左Ctrl左AltEnd这种三重组合作为卸载键误按概率低很多。组合键判定代码也就是多一层标志位判断值得多写两行。
返回列表