Appearance
中断系统与定时器
概念
上一章把 CPU、存储器、接口和总线接起来了,但还缺一样东西:外设怎么告诉 CPU"我这儿有事"。有两种办法——CPU 反复去问,或者外设主动来敲。中断就是"主动来敲"。
一句话说清它是什么:中断是让 CPU 能在任意两条指令之间被打断、转去处理一件急事、再原路回到断点继续跑的那套机制。
它由两半组成,缺一不可:
| 半 | 角色 | 回答的问题 |
|---|---|---|
| 中断请求与响应 | 谁在敲门、CPU 开不开门 | 什么时候打断 |
| 定时器 | 按固定周期敲门的那位常客 | 每隔多久打断一次 |
为什么这两件事写在同一章:嵌入式的定时、延时、采样、任务调度,全靠一个定时器周期性地产生中断;学会了中断响应,定时器就只是"一个会按固定频率敲门的设备"。
这一层回答了上一层什么问题:上一章说清了部件之间的线怎么连,但没让外设"说话"。中断补的就是这条反向的控制通路——从外设回到 CPU。
原理
一、为什么需要中断:轮询的三个代价
不用中断,就只能轮询:CPU 在一个循环里不停地读外设的状态位,问"好了没"。
| 方式 | 外设就绪的响应速度 | CPU 占用 | 适合场景 |
|---|---|---|---|
| 程序查询(轮询) | 取决于循环周期,最坏要等一整圈 | 100%(空转也在跑) | 极简、无中断资源 |
| 程序中断 | 几个时钟周期内 | 只在处理时占用 | 外设慢、事件稀疏 |
轮询的代价是三重的:CPU 被绑死(不能干别的)、响应有延迟(轮询周期决定上限)、扩展性差(加一个设备就多一路查询)。中断把这三件事一次解决:CPU 正常跑主程序,外设就绪时拉一根线。
二、中断的完整链路:从引脚到 ISR
一次中断从发生到返回,走的是同一条路,六个阶段:
外设就绪
|
v
[1] 拉高 IRQ 引脚 中断请求
|
v
[2] 中断控制器判优先级 谁的优先级高先报谁
|
v
[3] 向 CPU 发 INT 信号 CPU 在"当前指令执行完之后"才响应
|
v
[4] CPU 保存断点与现场 PC、PSW 入栈(这一步叫中断隐指令)
|
v
[5] 取中断向量 -> 跳 ISR 入口地址来自向量表
|
v
[6] ISR 执行完,恢复现场返回 回到断点继续四个必须记住的细节:
- 响应时机:CPU 在一条指令执行完才响应中断,不会在指令中间停。所以中断看起来是"瞬间"的,其实最多等一个指令周期。
- 现场要保存什么:至少 PC 和状态字,通常还有被 ISR 用到的寄存器。
- 返回靠什么:ISR 末尾的返回指令把栈里的 PC 弹回,程序从断点继续。
- 重入:如果 ISR 执行期间又来了中断,CPU 是"处理完再理"还是"插进去优先处理",取决于中断嵌套是否开放。
三、中断向量表:类型号 → 入口地址
CPU 收到 INT 之后要知道"跳到哪去"。答案是一张表:用中断类型号当索引,查出一个入口地址。
x86 实模式的表叫 IVT(Interrupt Vector Table),位置固定在物理地址 0x00000 起:
| 项 | 值 |
|---|---|
| 表项数 | 256(类型号 0 到 255) |
| 每项大小 | 4 字节(2 字节偏移 + 2 字节段) |
| 表总大小 | 256 x 4 = 1024 B = 1 KB |
| 占据地址 | 0x00000 ~ 0x003FF |
换算公式只有一条:
text
表项地址 = 类型号 x 4类型号 0x08 的表项就在 0x20,0x21 的在 0x84。注意是乘 4 不是乘 1:因为每项 4 字节,这个"乘 4"和上一章段式寻址的"乘 16"一样,是必须条件反射写对的常数。
表里存的是地址,不是代码:改表就是改挂接的 ISR,这是 DOS 时代以及今天所有裸机程序"挂中断"的基本手法——把函数地址写进表项。
四、中断控制器 8259A:把 8 根线变成 8 个向量
CPU 只有一个 INT 引脚,可外设有很多个。中间要有个"汇总员":可编程中断控制器 8259A。
它干三件事:
- 汇总:接 8 条 IRQ 线,任一条有效就向 CPU 发 INT。
- 排优先级:同时来多个请求时,先报优先级高的那个。
- 给向量号:CPU 响应后,8259A 把自己算出的中断类型号送到数据总线,CPU 拿它去查表。
一片只有 8 条,不够用就级联:从片的 INT 接到主片的某一条 IRQ 上。
| 片 | IRQ | 端口地址 | 说明 |
|---|---|---|---|
| 主片 | IRQ0 ~ IRQ7 | 0x20 / 0x21 | 从片占掉其中一条 |
| 从片 | IRQ8 ~ IRQ15 | 0xA0 / 0xA1 | 接在主的 IRQ2 上 |
于是 8 + 8 - 1 = 15 条可用。类型号由初始化命令 ICW2 决定:写进 ICW2 的那个字节就是该片第一条 IRQ 对应的类型号,后面的依次加一。把 ICW2 写成 0x20,主片的 IRQ0 就是类型 0x20,IRQ1 是 0x21——这正是 PC 上"键鼠中断是 0x21"的由来。
五、定时器 8253/8254:把时钟变成周期
定时器的本质是一个减法计数器:给一个固定的输入时钟,数够 N 个就输出一个脉冲。N 就叫初值(也叫分频系数)。
8253 有 3 个独立的 16 位计数器,每个的输入输出关系是:
text
f_out = f_in / N
T_out = N / f_in = N x T_in三个关键数字:
- 初值范围 1 ~ 65536:16 位计数器最大数 65535,但写 0 表示 65536(因为数满一圈才回绕)。
- 最大分频 = 65536:所以给定输入频率,能产生的最低输出频率是
f_in / 65536。 - 初值只能取整:要的频率除不尽就得四舍五入,于是实际频率与目标频率有偏差,这是必须回代校核的地方。
它还有若干工作方式(模式),最常用的是模式 3(方波)——输出占空比 50% 的方波,正好当时钟源。
定时器 + 中断 = 系统的节拍器:定时器输出接到中断控制器的一条 IRQ 上,就能每隔固定时间产生一次中断,这就是"系统时钟滴答(tick)"。操作系统的任务调度、嵌入式的心跳灯、ADC 的周期采样,全都建立在这个节拍上。
六、ISR 的三条纪律
中断服务程序(ISR)跑在"随时被打断的主程序"之上,所以有三条铁律:
- 要短。ISR 里不做耗时计算、不打印、不延时。该做的事塞给主循环(置一个标志位就走)。
- 要保护现场。用到的寄存器要先压栈、退出前恢复,否则主程序的数据会被悄悄改掉。
- 临界区要关中断。主程序与 ISR 都会访问的变量(尤其是多字节变量),在读写时要临时关中断,否则可能读到"改了一半"的值——这就是原子性问题。
示例
例 1:向量表地址换算
查类型 0x08(定时器)和 0x21(键盘)的入口地址在哪:
0x08 x 4 = 0x20 -> 表项占 0x20 ~ 0x23
0x21 x 4 = 0x84 -> 表项占 0x84 ~ 0x87整个表只占 1 KB,落在 0x00000~0x003FF。这就是为什么实模式下的低端 1 KB 内存不能被程序乱用。
例 2:8253 定时的完整账
设输入时钟 f_in = 1 MHz,写不同的初值:
初值 N = 1000 -> f_out = 1000000 / 1000 = 1000 Hz,周期 1.0000 ms
初值 N = 10000 -> f_out = 1000000 / 10000 = 100 Hz,周期 10.0000 ms
初值 N = 65536 -> f_out = 1000000 / 65536 = 15.2588 Hz,周期 65.5360 ms最后一行就是最大分频的结果:初值填 0,实际代表 65536。想再低就要先降低输入时钟,或者级联两个计数器(两个上限相乘 = 65536²)。
再算一个"要方波"的例子:输入 2 MHz,要 1 kHz 方波:
N = f_in / f_out = 2000000 / 1000 = 2000,正好整除
实际输出 = 2000000 / 2000 = 1000.0000 Hz,误差 +0.0000 %整除只是运气。若输入是 1.5 MHz、目标是 1 kHz,N 应是 1500(也整除);但目标改成 700 Hz,N = 2142.857 只能取 2143,实际频率 699.9533 Hz,偏差 -0.0067%——取整之后一定要回代算实际值,这是定时器题的固定步骤。
例 3:定时中断的开销上限
中断不是免费的。设系统时钟 72 MHz,每 1 ms 产生一次中断(即 1 kHz),ISR 平均用 600 个时钟周期:
每秒中断次数 = 1000
每秒消耗周期 = 1000 x 600 = 600000
占 CPU = 600000 / 72000000 = 0.8333 %0.83% 是"白交的税"。 中断频率越高、ISR 越长,这笔税越重。当它超过百分之几时,就必须换思路(延长中断周期、把活推给 DMA、或降低系统时钟频率)。
例 4:C 实现——向量表 + 定时中断一轮
用一个函数指针数组当向量表,跑满 2 秒,比对"主循环次数"与"ISR 次数"。
/* irq.c —— 用 C 模拟中断向量表与定时中断的一轮 */
#include <stdio.h>
#define IVT_ENTRIES 256
#define TICK_HZ 1000
static int tick_count; /* ISR 里累加 */
static int main_loops; /* 主循环累加 */
/* 中断服务程序:只做一件事,累加计数 */
static void isr_timer(void)
{
tick_count++;
}
/* 中断向量表:类型号 -> 处理函数 */
static void (*ivt[IVT_ENTRIES])(void);
int main(void)
{
int i;
int seconds = 2;
ivt[0x08] = isr_timer; /* 类型 08H 挂上定时器 ISR */
printf("IVT = %d entries x 4 B = %d B, at 0x00000-0x%05X\n",
IVT_ENTRIES, IVT_ENTRIES * 4, IVT_ENTRIES * 4 - 1);
printf("vector 08H -> entry address 0x%04X\n", 0x08 * 4);
/* 8253:输入 1 MHz,初值 1000 -> 1 kHz 中断 */
printf("divisor %d @ %d Hz -> %d Hz interrupt\n", 1000, 1000000, 1000000 / 1000);
for (i = 0; i < seconds * TICK_HZ; i++) {
main_loops++; /* 主程序在干活 */
ivt[0x08](); /* 硬件跳到向量表指向的 ISR */
}
printf("main loop %d iters, ISR %d times, ratio %d:%d\n",
main_loops, tick_count, main_loops / tick_count, tick_count / tick_count);
printf("after %d s: count = %d (expected %d)\n",
seconds, tick_count, seconds * TICK_HZ);
return 0;
}
c 本站为静态站,不提供在线运行;可复制到本地用 gcc / python 执行
预期输出:
IVT = 256 entries x 4 B = 1024 B, at 0x00000-0x003FF
vector 08H -> entry address 0x0020
divisor 1000 @ 1000000 Hz -> 1000 Hz interrupt
main loop 2000 iters, ISR 2000 times, ratio 1:1
after 2 s: count = 2000 (expected 2000)ivt[0x08]() 这一行就是"硬件跳转"的全部:向量表里存的是函数地址,调用它就是跳到 ISR。真实硬件上这一步是 CPU 从表项读出段:偏移、刷新 PC 完成的,代码里把它简化成了一次函数调用。
例 5:Python——向量表、分频与开销验算
# 中断系统与定时器:向量表、8259A 级联、8253 分频
print("=== 一、x86 实模式中断向量表(IVT)===")
n = 256
print(f"256 个中断类型 x 4 B/项(偏移 2 + 段 2)= {n * 4} B = {n * 4 // 1024} KB")
print("落在物理地址 00000H ~ 003FFH")
for t in (0x00, 0x08, 0x10, 0x21):
print(f"类型号 {t:02X}H -> 表项地址 {4 * t:04X}H(第 {t} 项)")
print("\n=== 二、8259A 可编程中断控制器 ===")
print(f"每片 8 条 IRQ;主片 8 + 从片 8 = 16 条,其中 1 条用于级联 -> 可用 {16 - 1} 条")
print("主片口地址 20H/21H,从片 A0H/A1H;ICW2 决定起始向量号")
print("\n=== 三、8253/8254 定时器:分频与频率 ===")
f_in = 1_000_000 # 1 MHz 时钟输入
print(f"输入时钟 f_in = {f_in} Hz")
for k in (1000, 10000, 65536):
f_out = f_in / k
print(f"初值 n = {k:6d} -> f_out = {f_out:10.4f} Hz,周期 {1000 / f_out:10.4f} ms")
print(f"16 位计数器,初值 0 代表 65536 -> 最低输出频率 {f_in / 65536:.4f} Hz")
print("\n=== 四、要 1 kHz 方波,输入 2 MHz,初值取多少 ===")
f_in2, f_target = 2_000_000, 1000.0
n_exact = f_in2 / f_target
n_int = round(n_exact)
print(f"n = f_in/f_out = {n_exact:.1f} -> 取整 {n_int}")
f_real = f_in2 / n_int
print(f"实际频率 {f_real:.4f} Hz,误差 {(f_real - f_target) / f_target * 100:+.4f} %")
print("\n=== 五、初值除不尽时(1.5 MHz 要 700 Hz)===")
f_in3, f_t3 = 1_500_000, 700.0
n3 = round(f_in3 / f_t3)
print(f"n = {f_in3 / f_t3:.3f} -> 取整 {n3} -> 实际 {f_in3 / n3:.4f} Hz,"
f"误差 {(f_in3 / n3 - f_t3) / f_t3 * 100:+.4f} %")
print("\n=== 六、定时中断的开销账 ===")
f_clk = 72_000_000
f_tick = 1000
cyc_isr = 600
per_sec = f_tick * cyc_isr
print(f"每秒中断 {f_tick} 次 x {cyc_isr} 周期 = {per_sec} 周期")
print(f"占 CPU = {per_sec / f_clk * 100:.4f} %")
python 本站为静态站,不提供在线运行;可复制到本地用 gcc / python 执行
预期输出:
=== 一、x86 实模式中断向量表(IVT)===
256 个中断类型 x 4 B/项(偏移 2 + 段 2)= 1024 B = 1 KB
落在物理地址 00000H ~ 003FFH
类型号 00H -> 表项地址 0000H(第 0 项)
类型号 08H -> 表项地址 0020H(第 8 项)
类型号 10H -> 表项地址 0040H(第 16 项)
类型号 21H -> 表项地址 0084H(第 33 项)
=== 二、8259A 可编程中断控制器 ===
每片 8 条 IRQ;主片 8 + 从片 8 = 16 条,其中 1 条用于级联 -> 可用 15 条
主片口地址 20H/21H,从片 A0H/A1H;ICW2 决定起始向量号
=== 三、8253/8254 定时器:分频与频率 ===
输入时钟 f_in = 1000000 Hz
初值 n = 1000 -> f_out = 1000.0000 Hz,周期 1.0000 ms
初值 n = 10000 -> f_out = 100.0000 Hz,周期 10.0000 ms
初值 n = 65536 -> f_out = 15.2588 Hz,周期 65.5360 ms
16 位计数器,初值 0 代表 65536 -> 最低输出频率 15.2588 Hz
=== 四、要 1 kHz 方波,输入 2 MHz,初值取多少 ===
n = f_in/f_out = 2000.0 -> 取整 2000
实际频率 1000.0000 Hz,误差 +0.0000 %
=== 五、初值除不尽时(1.5 MHz 要 700 Hz)===
n = 2142.857 -> 取整 2143 -> 实际 699.9533 Hz,误差 -0.0067 %
=== 六、定时中断的开销账 ===
每秒中断 1000 次 x 600 周期 = 600000 周期
占 CPU = 0.8333 %考点
- 中断向量表只考一条公式:
表项地址 = 类型号 x 4(实模式)。每项 4 字节、共 256 项、占 1 KB、位于0x00000~0x003FF这四个数字要连在一起记住。 - 响应时机:CPU 在当前指令执行完毕之后才响应中断,所以"中断立即响应"是错的,正确说法是"最多等一条指令的时间"。
- 现场保护:至少保存 PC 和 PSW;PC 入栈是为了"能回来",PSW 入栈是为了"回来时状态正确"。这两个是必答项。
- 8259A 级联算可用线数:
8 + 8 - 1 = 15。从片占掉主片一条线,所以是 15 不是 16。 - 8253 三件事:① 初值
N = f_in / f_out;② 写 0 代表 65536;③ 最大分频 65536。写 0 不是"不分频",这是最常被扣分的点。 - 初值取整后必须回代:
实际频率 = f_in / round(f_in / f_out),再算误差。步骤漏了回代就丢一半分。 - 中断开销:
占用率 = 中断频率 x ISR 周期数 / 时钟频率。别把"ISR 执行时间"直接当开销率。 - 易错:把中断服务程序写成"长函数"(原则上是错的);忘了在临界区关中断(多字节变量会被改一半);把 8259A 的端口地址与 8253 的弄混。
小结
- 中断解决了轮询的三个代价:CPU 不再空转、响应快、加设备不必改主循环。
- 一次中断走六步:请求 → 判优先 → CPU 响应 → 保存现场 → 查表跳转 → 恢复返回。响应只在指令边界发生。
- 向量表是"类型号 × 4"的一张 1 KB 表,里面存的是 ISR 的地址,改表就是改挂接。
- 8259A 把 8 根 IRQ 汇总成一个 INT 引脚,级联后可用 15 条,类型号由 ICW2 定基址。
- 定时器就是"输入频率除以初值"的计数器,初值 0 代表 65536;精度受初值取整限制,必须回代校核。
- ISR 要短、要保护现场、临界区要关中断——这三条是嵌入式代码的日常纪律。
回到主线:这一章给"由硅到 C"补上了反向的控制通路。上一章的微机原理讲的是 CPU 怎么去访问外部(地址、端口、总线),这一章讲的是外部怎么打断 CPU(IRQ、向量表、ISR)。它直接接的是 异常与中断机制 与 I/O 系统;而它交出去的东西是系统节拍——下一章换一个处理器家族(ARM),看同一件事在另一种指令集下怎么落地。
这一章只答了"CPU 怎么被外设打断",还没答"换一个 CPU 家族,寄存器和调用约定长什么样"。下一章把 x86 换掉,看 ARM。
下一篇:ARM 体系结构
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。