LC-3键盘中断实验:从轮询到事件驱动的CPU效率革命

LC-3键盘中断实验:从轮询到事件驱动的CPU效率革命

1. 项目概述:从“轮询”到“中断”的思维跃迁

搞过单片机或者写过底层代码的朋友,对“中断”这个词一定不陌生。但很多初学者,包括当年刚接触计算机体系结构的我,都容易陷入一个误区:把中断理解成一个孤立的、神秘的“功能”。实际上,中断机制是现代计算机系统设计的基石之一,它彻底改变了CPU与外部设备交互的方式。这次我们以经典的LC-3教学计算机为平台,动手实现一个键盘中断实验,目的不是简单地让一个灯闪烁或者打印一行字,而是要亲手搭建起从硬件信号触发,到CPU响应,再到软件处理这一整套流程,深刻理解“事件驱动”是如何取代低效的“忙等待”,从而释放CPU算力的。

想象一下,在没有中断的系统中,CPU如果想知道键盘有没有被按下,它只能不停地去问:“你按了吗?你按了吗?你按了吗?”——这就是轮询(Polling)。CPU宝贵的计算周期大量浪费在这种无意义的询问上。而中断机制,相当于给键盘装了一个门铃。CPU可以安心处理自己的任务(主程序),当键盘被按下(事件发生),门铃响起(中断请求),CPU暂时停下手中的活,去门口(中断服务程序)处理一下快递(读取按键值),然后回来继续刚才的工作。这个“按下暂停键-处理紧急事务-恢复现场”的过程,就是中断处理的核心。LC-3架构清晰,指令集精简,是理解这一过程的绝佳模型。通过这个实验,你将能回答:中断向量是什么?PSR(程序状态寄存器)中的优先级字段如何工作?中断服务程序为何要首先保存R7?这些知识是通往操作系统、实时系统等更高级领域的必经之路。

2. 实验核心原理与LC-3中断机制拆解

2.1 LC-3中断硬件基础:从信号到向量

LC-3的中断机制依赖于一套清晰的硬件协作。首先,外部设备(如键盘)有一个独立的“中断使能”位(例如,键盘状态寄存器的最高位)。当这个位被软件设置为1,且设备就绪(如键盘缓冲区有数据)时,设备会向CPU的“中断控制逻辑”发出一个高电平的中断请求(IRQ)信号。

CPU在每个指令周期的末尾,都会检查是否有中断请求到来。但CPU不会立即响应,它要先进行一场“优先级仲裁”。LC-3的CPU有一个当前的优先级(Privilege Mode),存储在PSR寄存器的第10-8位。同时,发出请求的设备自身也有一个硬件优先级(例如,键盘可能优先级较高,定时器优先级中等)。只有当外部设备的优先级高于CPU当前优先级时,这个中断请求才会被CPU受理,否则会被暂时挂起。

一旦中断被受理,硬件会自动执行一系列“幕后工作”:

  1. 保存状态:将当前PC(程序计数器)的值存入一个特殊的寄存器——中断返回地址寄存器(在LC-3仿真器中,这通常是R7在中断时刻的一个隐式副本,但概念上更准确的是硬件自动保存PC)。
  2. 提升权限:将PSR中的优先级字段设置为中断设备的优先级。
  3. 获取入口:CPU根据一个预设的“中断向量表”来找到处理程序。LC-3的中断向量表起始地址是固定的(例如0x0180)。每个设备的中断类型对应一个“向量号”,向量号乘以2(因为每个入口是16位地址)加上基地址,就得到了该设备中断服务程序(ISR)的起始地址。例如,键盘中断的向量号可能是0x80,那么其ISR入口地址就是0x0180 + (0x80 * 2) = 0x0280
  4. 跳转执行:CPU将计算得到的ISR入口地址加载到PC,下一条指令就开始执行ISR的代码。

注意:这里有一个关键点常被混淆。硬件自动保存的是返回地址,即被中断指令的下一条指令的地址。而通用寄存器(R0-R7)的内容需要由ISR软件来保存和恢复,这是编写ISR时的首要任务。

2.2 软件视角:中断服务程序的设计契约

硬件完成了“敲门”和“引导”,剩下的“处理事务”就交给软件——中断服务程序。编写一个正确的ISR,必须遵守与硬件之间的“契约”:

  1. 现场保存(Prologue):ISR的第一要务是保存它将要使用的所有寄存器的原始值到内存(栈)中。最重要的是R7,因为LC-3的RET(从子程序返回)指令隐式使用R7作为返回地址。如果ISR中调用了其他子程序(JSRJSRR),R7会被修改,导致无法正确返回。因此,标准的开头是:

    ISR_START ST R0, SAVE_R0 ; 保存R0 ST R1, SAVE_R1 ; 保存R1 ... ; 保存其他需要使用的寄存器 ST R7, SAVE_R7 ; 必须保存R7!

    你需要为每个寄存器在ISR数据区预留存储空间(.BLKW 1)。

  2. 核心处理:执行实际的中断处理工作。对于键盘中断,就是读取键盘数据寄存器(如内存映射地址0xFE02)的值,存储到程序定义的缓冲区或进行即时处理。

  3. 现场恢复(Epilogue):在处理完成后,必须按相反的顺序将寄存器的值从内存中恢复回来。

    LD R0, SAVE_R0 ; 恢复R0 LD R1, SAVE_R1 ; 恢复R1 ... LD R7, SAVE_R7 ; 恢复R7
  4. 中断返回:使用RTI(Return from Interrupt)指令结束ISR。RTI指令会做两件事:首先从栈中恢复之前的PSR(从而恢复CPU优先级),然后恢复PC,CPU由此跳转回被中断的主程序继续执行。在LC-3的简单模型中,有时也用RET,但这要求R7已被正确恢复。

2.3 键盘设备的内存映射I/O与中断使能

LC-3通常采用内存映射I/O。键盘状态寄存器(KBSR,例如地址0xFE00)和键盘数据寄存器(KBDR,例如地址0xFE02)被映射到特定的内存地址。

  • KBSR:最高位(第15位)是“就绪位”(Ready Bit)。当键盘有按键输入时,硬件会自动将此位置1。第14位是“中断使能位”(Interrupt Enable Bit),由软件控制。只有当就绪位和中断使能位同时为1时,键盘才会向CPU发出中断请求。
  • KBDR:低8位(第7-0位)存储按键的ASCII码。

因此,初始化键盘中断的软件步骤是:

  1. 将KBSR的中断使能位置1。这通常通过向0xFE00写入一个最高两位为1的值来实现(例如0x8000用于同时置位就绪位和使能位,但更安全的做法是只操作使能位,不过LC-3的简单模型常直接写0x8000)。
  2. 主程序进入一个循环或处理其他任务。
  3. 当按键按下,硬件置位KBSR就绪位,因使能位已开,故发出中断。
  4. CPU响应,跳转到ISR。在ISR中,读取KBDR(0xFE02)以获取按键值,读取KBDR这个动作本身,会由硬件自动将KBSR的就绪位清零,为下一次按键中断做好准备。

3. 实验详细步骤与代码实现剖析

3.1 实验环境与初始化代码

假设我们使用LC-3仿真器(如LC-3 Edit或基于Web的仿真器)。实验目标是:编写一个主程序,使能键盘中断后,在一个循环中持续递增一个计数器并显示(模拟CPU正在工作)。当任何按键被按下时,中断发生,ISR读取按键字符,并将其存储到一个缓冲区中,同时在屏幕某个固定位置显示该字符,然后返回主程序。主程序的计数器递增不应被中断破坏。

步骤一:主程序框架与中断初始化

.ORIG x3000 ; 主程序起始地址 MAIN LEA R6, STACK_TOP ; 初始化栈指针R6,为ISR保存现场做准备 ; 初始化键盘中断:将KBSR的中断使能位置1 LD R0, KBSR_ADDR LD R1, KBSR_MASK ; R1 = 0x8000 (使能位+就绪位掩码) STR R1, R0, #0 ; 写入KBSR,开启中断 ; 初始化计数器和其他主程序变量 AND R2, R2, #0 ; R2作为计数器,清零 MAIN_LOOP ADD R2, R2, #1 ; 计数器递增(模拟主程序工作) ; 这里可以添加将R2显示到屏幕某处的代码(例如,转换为ASCII输出到显示器) BRnzp MAIN_LOOP ; 无限循环,等待中断 ; 数据区 STACK_BASE .BLKW #100 ; 分配栈空间 STACK_TOP .FILL x0000 ; 栈顶初始位置(实际会指向STACK_BASE+100) KBSR_ADDR .FILL xFE00 ; 键盘状态寄存器地址 KBSR_MASK .FILL x8000 ; 中断使能掩码 BUFFER_PTR .FILL BUFFER ; 缓冲区指针 BUFFER .BLKW #10 ; 预留10个字的键盘缓冲区 .END

实操心得:栈的初始化至关重要。虽然LC-3的中断机制不一定强制使用栈,但使用栈来保存/恢复寄存器是最规范、最安全的方式,尤其适用于嵌套中断或ISR中调用子程序的情况。将栈指针(R6)初始化为一个安全的内存区域是编写健壮程序的第一步。

3.2 中断服务程序(ISR)完整实现

接下来,在另一个代码段(通常起始于中断向量指向的地址,如x0280)编写ISR。

.ORIG x0280 ; 假设键盘中断向量指向此地址 KEYBOARD_ISR ; === 1. 保存现场 === ADD R6, R6, #-1 ; 栈指针下移,预留空间 STR R0, R6, #0 ; 将R0压栈 ADD R6, R6, #-1 STR R1, R6, #0 ; 将R1压栈 ; ... 保存其他可能使用的寄存器,如R2, R3, R4 ADD R6, R6, #-1 STR R7, R6, #0 ; !!! 必须保存R7 !!! ; === 2. 中断处理核心 === LDI R0, KBDR_ADDR ; 读取键盘数据寄存器,自动清除KBSR就绪位 ; R0现在存放按键的ASCII码 ; 将按键存入缓冲区 LD R1, BUFFER_PTR_ISR ; 获取主程序定义的缓冲区指针 STR R0, R1, #0 ; 存储字符 ADD R1, R1, #1 ; 指针后移 ST R1, BUFFER_PTR_ISR ; 更新指针(此处未处理缓冲区溢出) ; 在屏幕特定位置显示字符(例如,通过TRAP x21 OUT陷阱程序) ; 为了不破坏R0,可以先保存到其他寄存器,或直接使用OUT ; TRAP x21 (OUT) 会使用R0,但我们已经保存了R0,所以可以安全使用 ; OUT指令需要将字符放在R0,且会修改R0,但此时R0的值已使用完毕 TRAP x21 ; 调用OUT,显示刚按下的字符 ; === 3. 恢复现场 === LDR R7, R6, #0 ; 恢复R7 ADD R6, R6, #1 ; ... 恢复其他寄存器 LDR R1, R6, #0 ADD R6, R6, #1 LDR R0, R6, #0 ADD R6, R6, #1 ; === 4. 中断返回 === RTI ; 关键!恢复PSR和PC,返回主程序 ; ISR私有数据区(或使用主程序共享数据) KBDR_ADDR .FILL xFE02 BUFFER_PTR_ISR .FILL BUFFER ; 指向主程序缓冲区的指针 .END

关键点解析

  1. LDI R0, KBDR_ADDR:这条指令是中断处理的核心。LDI是“间接加载”,它从KBDR_ADDR存放的地址(即xFE02)中读取数据。这个读取操作会触发硬件逻辑,自动将KBSR的就绪位清零。如果使用LD指令直接加载xFE02的值,在某些模型下可能不会自动清零就绪位,导致中断持续触发,陷入死循环。
  2. 栈操作对称性:保存和恢复寄存器的顺序必须严格相反(后进先出,LIFO)。这是使用栈的基本原则,任何错位都会导致程序状态混乱。
  3. RTIvsRET:在完整的LC-3中断模型中,必须使用RTIRTI从系统栈(或特定寄存器)恢复中断前的处理器状态(包括优先级)。而RET仅从R7跳转,不处理PSR。在简单的教学实验中,如果中断不涉及优先级切换,有时用RET也能工作,但这是一种不规范的取巧行为,应养成使用RTI的习惯。

3.3 主程序与ISR的协作与数据共享

主程序中的计数器R2在中断发生时,其值必须被ISR保护。在我们的ISR中,我们保存了R0和R1,但没有保存R2。这意味着如果ISR使用了R2,就会破坏主程序的状态。因此,在ISR中,我们只使用了R0和R1,并妥善保存/恢复了它们。这是一种设计选择:ISR应尽可能使用最少的寄存器,并对它们全部进行保存/恢复

对于共享数据BUFFERBUFFER_PTR,主程序和ISR都能访问。这里存在潜在的“竞态条件”(Race Condition),虽然在这个单次按键的简单例子中不明显。例如,如果主程序正在读取缓冲区指针,同时发生中断,ISR修改了指针,主程序可能读到错误的值。在复杂的系统中,这需要通过关中断、信号量等机制保护,但在基础LC-3实验中,我们通常假设中断处理非常快,且主程序在访问共享数据时是安全的。

4. 调试技巧与常见问题实录

4.1 典型问题与排查清单

在LC-3仿真器中调试中断程序,经常会遇到一些令人困惑的现象。下面是一个快速排查表:

现象可能原因排查步骤与解决方案
按下键盘,程序毫无反应1. 键盘中断未使能。
2. ISR入口地址设置错误。
3. 仿真器键盘输入未连接或设置问题。
1. 检查主程序是否成功向KBSR写入了0x8000。单步执行,查看xFE00内存值是否变为0x8000
2. 确认键盘中断向量号(如0x80)和ISR的.ORIG地址(如x0280)计算是否正确。
3. 在仿真器中确认键盘输入设备已启用,并尝试在非中断模式下用TRAP x20(GETC)测试键盘是否正常。
程序进入中断后死循环,无法返回1. ISR中未正确保存/恢复R7
2. 使用了RET而不是RTI,但PSR状态未恢复。
3. ISR中修改了栈指针(R6)后未正确恢复。
1.这是最常见错误!仔细检查ISR开头是否将R7入栈,结尾是否从栈中恢复R7。在LC-3中,JSR/JSRR会修改R7,如果ISR中调用了任何子程序或陷阱,必须事先保存R7。
2. 确保使用RTI指令结束ISR。检查仿真器是否支持完整的RTI语义。
3. 单步调试ISR,观察每条指令执行后R6和栈内存的变化,确保压栈和出栈操作成对且平衡。
第一次按键正常,后续按键无反应1. ISR中读取KBDR的方式不正确,未能自动清除KBSR就绪位。
2. 中断处理完成后,设备就绪位未被清除,导致无法产生新的中断边沿。
1.必须使用LDI指令读取KBDRLDI通过内存映射机制会触发硬件清除就绪位。直接使用LD加载xFE02可能不会。
2. 检查KBSR的值。在ISR执行后,其就绪位(第15位)应被清零。如果仍是1,说明清除机制未生效。
主程序的寄存器值在中断返回后被改变ISR未保存/恢复所有它使用过的寄存器。遵循“最小破坏”原则:在ISR开头,将所有你计划使用的寄存器(除了可能用作临时、可破坏的)都压栈。在结尾,按相反顺序弹出。如果主程序使用了R3,而ISR也用了但没保存,主程序的R3就会被覆盖。
中断似乎随机发生,或主程序显示乱码栈溢出或栈指针初始化错误,破坏了其他数据或代码。确保为栈分配了足够大且独立的内存区域(如.BLKW #50)。初始化R6指向栈顶(最高地址)。在ISR中,通过单步观察R6的值,确保其始终在栈空间内移动。

4.2 高级调试:利用仿真器工具

现代LC-3仿真器通常提供强大的调试功能:

  • 中断状态查看:查看PSR寄存器的优先级位,确认CPU当前优先级。查看KBSRKBDR等内存映射寄存器的实时值。
  • 单步执行(Step Into/Over):在中断发生时,仿真器应能自动跳转到ISR并允许你单步调试。注意观察PCR7的跳变。
  • 内存与寄存器监视:设置对关键地址(如栈区、缓冲区)和寄存器(R7, R6)的监视,观察其变化。
  • 断点:在ISR入口(x0280)和RTI指令处设置断点,可以清晰捕捉中断的进入和返回过程。

4.3 从LC-3到现代体系结构的思考

完成这个实验后,你不应只停留在LC-3。可以思考其在现代系统中的映射:

  • 中断向量表(IVT)-> 操作系统中的中断描述符表(IDT),结构更复杂,包含门描述符。
  • 简单的现场保存->上下文切换,需要保存所有通用寄存器、段寄存器、控制寄存器等,开销更大。
  • 单一的设备中断->中断嵌套中断优先级分组中断屏蔽,管理更精细。
  • 轮询 vs 中断-> 在极高实时性要求或极简系统中,轮询(忙等待)可能比中断上下文切换的开销更小,这是工程上的权衡。

这个实验就像一把钥匙,打开了理解计算机如何高效管理异步事件的大门。当你未来在STM32上用CubeMX配置一个按键中断,或者在x86平台上编写一个设备驱动时,底层逻辑是相通的:配置使能、等待请求、保存现场、处理事务、恢复返回。理解了这个流程,那些复杂的配置寄存器和驱动框架,就不再是黑盒,而是一个个可以推演和理解的逻辑模块。