Interrupts
Concept:
Interrupt 中断:CPU 正在运行主程序时,某个事件需要立即处理,于是 MCU 暂停主程序,跳到 ISR 执行。
ISR / Interrupt Handler:中断服务程序。每个中断发生后,MCU 会执行对应的 ISR。
IVT / Interrupt Vector Table:中断向量表,存放 ISR 地址。PIC18 有两个中断向量:高优先级 0008h,低优先级 0018h
IF bit:Interrupt Flag,中断请求标志位。事件发生时先置 1。
IE bit:Interrupt Enable,中断允许位。只有 IE=1 时,该中断才真正被响应。
GIE bit:Global Interrupt Enable,全局中断允许位。一般要开某个中断,需要同时打开局部 IE 和全局 GIE。
中断发生时,MCU 不会立刻打断当前指令,而是:先完成当前正在执行的 instruction,然后跳转到 ISR。
ISR
ISR = Interrupt Service Routine,也叫 Interrupt Handler。
它就是中断发生后 MCU 自动执行的函数。
比如 Timer0 溢出后进入 ISR:
void interrupt() {
if (TMR0IF_bit == 1) {
// do something
TMR0IF_bit = 0; // clear interrupt flag
}
}
这里最重要的是最后一句:TMR0IF_bit = 0;因为中断标志位必须由软件清除。
中断执行流程
中断发生后,MCU 大致会经历这些步骤:
- 完成当前 instruction;
- 把下一条 instruction 的地址,也就是 PC,保存到 stack;
- 保存当前中断状态;
- 跳到 Interrupt Vector Table;
- 根据中断向量找到 ISR 地址;
- 执行 ISR;
- 执行
RETFIE; - 从 stack 恢复 PC,回到原来的程序继续执行。
Interrupt Vector Table, IVT
每个中断都有一个固定的入口地址,这些地址组成了:
Interrupt Vector Table,中断向量表。
对于 PIC18:
| Vector | Address |
|---|---|
| Reset vector | 0000h |
| High priority interrupt vector | 0008h |
| Low priority interrupt vector | 0018h |
所以在 PIC18 中,如果使用优先级中断:
- 高优先级中断跳到
0008h; - 低优先级中断跳到
0018h; - 高优先级中断可以打断正在执行的低优先级 ISR。
Hardware Interrupt vs Software Interrupt
| 类型 | 来源 | 例子 |
|---|---|---|
| Hardware interrupt | 外部硬件或片上外设 | 按键、Timer overflow、UART receive |
| Software interrupt | 软件指令或异常 | divide by zero、特殊 interrupt instruction |
嵌入式实验中最常见的是 hardware interrupt,比如:
- Timer0 overflow interrupt;
- RB0 external interrupt;
- PORTB-change interrupt;
- UART receive interrupt。
IF bit、IE bit、GIE bit
这是本部分最重要的逻辑。
IF bit: Interrupt Flag
表示中断事件是否发生。
例如 Timer0 溢出:
TMR0IF_bit = 1;
代表 Timer0 overflow 发生了。
IE bit: Interrupt Enable
表示这个中断源是否被允许。
例如:
TMR0IE_bit = 1;
代表允许 Timer0 interrupt。
GIE bit: Global Interrupt Enable
表示全局中断是否打开。
例如:
GIE_bit = 1;
代表 MCU 总开关打开。
中断真正发生的条件
记住这个判断:
IF = 1, IE = 1, GIE = 1
三个条件都满足,中断才会真正被响应。
也就是说:
if (Interrupt_Flag == 1 && Interrupt_Enable == 1 && Global_Enable == 1) {
execute_ISR();
}
如果 IF=1 但 IE=0,说明事件发生了,但 MCU 不会处理。
INTCON 寄存器重点位
Lecture 中重点用了 INTCON。
常见位如下:
| Bit | Name | Meaning |
|---|---|---|
| bit 7 | GIE/GIEH |
Global interrupt enable / high priority enable |
| bit 6 | PEIE/GIEL |
Peripheral interrupt enable / low priority enable |
| bit 5 | TMR0IE |
Timer0 interrupt enable |
| bit 4 | INT0IE |
INT0 external interrupt enable |
| bit 3 | RBIE |
PORTB change interrupt enable |
| bit 2 | TMR0IF |
Timer0 interrupt flag |
| bit 1 | INT0IF |
INT0 external interrupt flag |
| bit 0 | RBIF |
PORTB change interrupt flag |
例如启用 RB0 外部中断:
ANSELB = 0; // PORTB digital
TRISB_bit = 1; // RB0 input
INTCON.INT0IE = 1; // enable INT0 interrupt
INTCON.GIE = 1; // enable global interrupt
ISR 中的典型结构
中断服务程序里通常先判断是哪一个中断源触发的:
void interrupt() {
if (INTCON.INT0IF == 1) {
// handle RB0 external interrupt
INTCON.INT0IF = 0; // clear flag
}
}
如果有多个中断源,可以写成:
void interrupt() {
if (TMR0IF_bit == 1) {
// handle Timer0 interrupt
TMR0IF_bit = 0;
}
if (INT0IF_bit == 1) {
// handle RB0 external interrupt
INT0IF_bit = 0;
}
}
Timers/Counters
Timer 和 Counter 的区别
Timer 定时器:用 MCU 内部时钟计数,用来测量时间间隔、产生 delay、周期性触发中断。
Counter 计数器:用外部引脚输入的脉冲计数,用来统计外部事件发生了多少次。
| 对比 | Timer | Counter |
|---|---|---|
| Clock source | Internal clock | External signal |
| 用途 | 测时间、产生延时 | 统计外部事件 |
| 例子 | 每 1 秒更新时间 | 统计按钮按下次数 / 外部脉冲数 |
一句话:Timer counts internal clock ticks; Counter counts external events.
Timer Overflow 是什么?
Timer 本质上是一个寄存器,它会不断加 1。
如果是 8-bit timer,最大能数:
2^8 = 256
也就是从:
00h → 01h → 02h → ... → FFh → 00h
当它从 FFh 回到 00h 时,就叫 overflow 溢出。
对于 Timer0:
- 8-bit mode:
FFh → 00h触发 overflow; - 16-bit mode:
FFFFh → 0000h触发 overflow; - overflow 会把
TMR0IF置 1; - 如果
TMR0IE = 1且GIE = 1,就会进入 ISR。
Prescaler 预分频器
Prescaler 放在 Timer 前面,用来降低 Timer 的计数速度。
比如 prescaler = 1:256:
内部时钟 tick 256 次,Timer 才加 1 次。
所以 Prescaler 的作用是:
- 让 Timer 变慢;
- 产生更长的时间间隔;
- 不改变主时钟频率的情况下调整定时精度。
可以理解成:
Internal clock → Prescaler → Timer register
如果没有 prescaler,Timer 太快,很快就 overflow。
Postscaler 后分频器
Postscaler 放在 Timer 后面。
它不是让 Timer 变慢,而是:
Timer overflow 多次之后,才真正产生一次 interrupt。
比如 postscaler = 1:4:
Timer overflow 1 次,不中断
Timer overflow 2 次,不中断
Timer overflow 3 次,不中断
Timer overflow 4 次,产生 interrupt
Lecture 02 里重点强调:
- Prescaler 控制 Timer 每隔多少 clock tick 加 1;
- Postscaler 控制 Timer overflow 多少次后产生 interrupt。
PIC18F 里的 7 个 Timers
Lecture 02 总结了 PIC18F 的 7 个 timer:
| Timer | 位数 | 特点 |
|---|---|---|
| Timer0 | 8-bit / 16-bit | 有 8-bit prescaler |
| Timer1 / Timer3 / Timer5 | 16-bit | 有 2-bit prescaler |
| Timer2 / Timer4 / Timer6 | 8-bit | 有 prescaler 和 postscaler |
本讲数字时钟实验重点用的是 Timer0。
Timer0 Interrupt 相关寄存器
Timer0 中断主要看 INTCON 里的两个位:
| Bit | Name | Meaning |
|---|---|---|
| bit 5 | TMR0IE |
Timer0 interrupt enable |
| bit 2 | TMR0IF |
Timer0 interrupt flag |
逻辑是:
TMR0IF_bit = 1; // Timer0 overflow happened
TMR0IE_bit = 1; // Timer0 interrupt enabled
GIE_bit = 1; // Global interrupt enabled
三个条件满足,MCU 进入 ISR。
ISR 里必须清除 flag:
void interrupt() {
if (TMR0IF_bit == 1) {
// Timer0 interrupt task
TMR0IF_bit = 0; // clear Timer0 interrupt flag
}
}
T0CON: Timer0 Control Register
Timer0 的核心配置寄存器是 T0CON。
重要位如下:
| Bit | Name | Meaning |
|---|---|---|
| bit 7 | TMR0ON |
Timer0 on/off |
| bit 6 | T08BIT |
8-bit / 16-bit mode |
| bit 5 | T0CS |
clock source select |
| bit 4 | T0SE |
edge select for counter mode |
| bit 3 | PSA |
prescaler assignment |
| bit 2-0 | T0PS2:T0PS0 |
prescaler value |
重点记:
T0CS = 0 → Timer mode, internal instruction clock
T0CS = 1 → Counter mode, external T0CKI pin
例子:T0CON = 0xC4
Lecture 02 代码中出现:
T0CON = 0xC4;
把它转成二进制:
0xC4 = 1100 0100
对应:
| Bit | Value | Meaning |
|---|---|---|
bit 7 TMR0ON |
1 | Timer0 enabled |
bit 6 T08BIT |
1 | 8-bit mode |
bit 5 T0CS |
0 | internal clock, Timer mode |
bit 4 T0SE |
0 | not important in Timer mode |
bit 3 PSA |
0 | prescaler assigned to Timer0 |
bit 2-0 T0PS |
100 | prescaler = 1:32 |
所以:
T0CON = 0xC4;
意思是:
开启 Timer0,使用 8-bit mode,内部时钟,prescaler 分配给 Timer0,prescaler = 1:32。
Time Delay Calculation
Lecture 02 的经典计算题是:Timer0 要 overflow 多少次才能达到 1 second?
假设:
Fosc = 8 MHz
PIC 内部 instruction frequency 是:
FMCU = Fosc / 4 = 8 MHz / 4 = 2 MHz
Instruction period:
Tinst = 1 / FMCU = 1 / 2 MHz = 0.5 µs
如果 prescaler = 1:256:
Ttick = Tinst × prescaler
= 0.5 µs × 256
= 128 µs
如果 Timer0 是 8-bit mode:
Toverflow = Ttick × 256
= 128 µs × 256
= 32.768 ms
所以 1 秒需要的 overflow 次数:
Noverflow = 1000 ms / 32.768 ms
≈ 30.52
也就是说,大约 31 次 overflow 接近 1 秒。
数字时钟里 Timer 的作用
在 Digital Clock 里,Timer 通常有两个作用:
第一,周期性刷新 4-digit 7-segment display。 因为四位数码管不是同时亮,而是快速轮流点亮。
第二,产生时间基准。 例如累计一定次数 Timer0 overflow 后,认为过去了 1 秒,然后:
second++;
if (second == 60) {
second = 0;
minute++;
}
Timer0 初始化常见代码
Lecture 02 中的代码结构类似:
void main() {
ANSELA = 0; // PORTA digital
ANSELD = 0; // PORTD digital
TRISA = 0; // PORTA output, digit select
TRISD = 0; // PORTD output, segment data
LATA = 0;
LATD = 0;
T0CON = 0xC4; // Timer0 enabled, 8-bit mode, prescaler
TMR0L = 0; // clear Timer0 low register
GIE_bit = 1; // enable global interrupt
TMR0IE_bit = 1; // enable Timer0 interrupt
while(1) {
// main program
}
}
ISR:
void interrupt() {
if (TMR0IF_bit == 1) {
// refresh display or update timer counter
TMR0L = 0;
TMR0IF_bit = 0;
}
}
Digital Clock Display
4位七段数码管显示机制:这一部分解释数字时钟为什么能显示 22.00、06.17 这种四位数字
4-digit 7-segment display 是什么?
一个 4位七段数码管 可以显示 4 个数字,每一位数字由 7 个段组成:
a
f b
g
e c
d
通常还有一个小数点 dp,所以实际是:
7 segments + decimal point = 8 lines
因此每一位数字可以通过点亮不同 segment 来显示 0–9。
例如:
| 数字 | 点亮的段 |
|---|---|
| 0 | a b c d e f |
| 1 | b c |
| 2 | a b d e g |
| 8 | a b c d e f g |
为什么 4 个数字不是分别接 4 套线?
如果每一位数字都单独接线,那么 4 位数码管需要:
8 segment lines × 4 digits = 32 lines
这对 MCU 来说太浪费 I/O pin。
所以 EasyPIC 上采用的是 shared data lines:
四个数字共用同一组 segment data lines。
Lecture 02 里说明:不同 digit 中对应的 segment 是共享的,也就是所有数字的 a 段接在一起,b 段接在一起,依此类推。
在实验板上:
| 线路 | 作用 |
|---|---|
| PORTD | segment data lines,控制显示什么数字 |
| RA0–RA3 / LATA | digit select lines,控制点亮哪一位 |
核心问题:怎么同时显示 4 个数字?
实际上它们不是同时显示。
正确理解是:
MCU 很快地一个一个点亮每一位,因为刷新速度足够快,人眼会以为四位同时亮着。
这叫:
Persistence of Vision,视觉暂留。
例如想显示:
22.00
MCU 会快速循环:
点亮第 1 位,显示 2
点亮第 2 位,显示 2 + dot
点亮第 3 位,显示 0
点亮第 4 位,显示 0
重复……
只要循环足够快,人眼看到的就是稳定的:
22.00
Multiplexing 多路扫描显示
这个显示方法叫 multiplexing,中文可以理解成“动态扫描显示”。
基本流程是:
turn off all digits;
put segment pattern on PORTD;
select one digit using LATA;
wait a very short time;
move to next digit;
所以每次 ISR 或刷新函数只负责显示其中一位。
关键代码
这一讲的示例代码里,ISR 负责刷新 4 位数码管:
void interrupt() {
LATA = 0; // Turn off all 7seg displays
LATD = portd_array[portd_index]; // Send segment data to PORTD
LATA = shifter; // Turn on one digit
shifter <<= 1;
if (shifter > 8u)
shifter = 1;
portd_index++;
if (portd_index > 3u)
portd_index = 0;
TMR0L = 0;
TMR0IF_bit = 0;
}
我们逐句看。
LATA = 0
LATA = 0;
作用:
先关闭所有 digit。
这一步很重要,因为如果直接改变 LATD,可能会出现残影或错误显示。
LATD = portd_array[portd_index]
LATD = portd_array[portd_index];
作用:
把当前要显示的数字编码送到 PORTD。
portd_array[] 里面存的是四位数字对应的 segment pattern。
例如如果当前数字是 2,那么 portd_array[i] 里就是显示 2 所需的七段编码。
LATA = shifter
LATA = shifter;
作用:
选择当前要点亮的 digit。
shifter 的值会依次变化:
0001 → 0010 → 0100 → 1000 → 0001 ...
也就是:
| shifter | 点亮位 |
|---|---|
| 1 | 第 1 位 |
| 2 | 第 2 位 |
| 4 | 第 3 位 |
| 8 | 第 4 位 |
所以这段代码:
shifter <<= 1;
if (shifter > 8u)
shifter = 1;
意思是:
每次中断后切换到下一位;超过第 4 位后回到第 1 位。
portd_index
portd_index++;
if (portd_index > 3u)
portd_index = 0;
portd_index 用来选择当前显示数组中的哪一个数字。
它依次变化:
0 → 1 → 2 → 3 → 0 ...
所以:
portd_array[0]
portd_array[1]
portd_array[2]
portd_array[3]
分别对应四位显示内容。
如何把数字拆成四位?
Lecture 02 示例中有这样的代码:
digit = number / 1000u;
portd_array[3] = mask(digit);
digit = (number / 100u) % 10u;
portd_array[2] = mask(digit);
digit = (number / 10u) % 10u;
portd_array[1] = mask(digit);
digit = number % 10u;
portd_array[0] = mask(digit);
假设:
number = 1234;
那么:
| 表达式 | 结果 | 含义 |
|---|---|---|
number / 1000 |
1 | 千位 |
(number / 100) % 10 |
2 | 百位 |
(number / 10) % 10 |
3 | 十位 |
number % 10 |
4 | 个位 |
然后用:
mask(digit)
把普通数字 0–9 转换成七段数码管编码。
数字时钟如何显示 HH.MM
左边两位显示 hour,右边两位显示 minute,中间有一个 dot。
所以如果时间是:
22:00
显示成:
22.00
如果时间是:
06:17
显示成:
06.17
可以拆成:
hour_tens = hour / 10;
hour_ones = hour % 10;
min_tens = minute / 10;
min_ones = minute % 10;
然后放进数组:
portd_array[3] = mask(hour_tens);
portd_array[2] = mask(hour_ones) + dot;
portd_array[1] = mask(min_tens);
portd_array[0] = mask(min_ones);
其中 dot 取决于实际七段编码方式,有的板子可能是 OR 某一位,有的可能是清某一位。
为什么用 Timer interrupt 刷新显示?
如果在 while(1) 里手动刷新显示,主程序会被显示代码占用。
更好的做法是:
Timer0 定期触发中断,每次 ISR 刷新一位数码管。
这样主程序可以继续处理:
- 按键 RB0 / RB1 / RB2;
- clock setting mode;
- alarm setting mode;
- 时间更新;
- 蜂鸣器控制。
所以结构一般是:
void interrupt() {
refresh_display_one_digit();
clear_timer0_flag();
}
void main() {
init_ports();
init_timer0();
init_interrupts();
while(1) {
handle_buttons();
update_clock_state();
handle_alarm();
}
}
13. 这一部分最容易混淆的点
不要以为四位数码管是真的四位同时亮。
实际上是:
同一时刻只亮一位
但刷新速度很快
人眼看到四位都亮
也不要忘记刷新顺序:
关闭所有位 → 写入段码 → 打开当前位 → 下一位
如果顺序反了,容易出现 ghosting,也就是残影
小结
4位七段数码管通过 shared data lines 和 digit select lines 工作;MCU 每次只点亮一位,但通过 Timer interrupt 快速轮流刷新,人眼看到的是四位同时显示。