Back to all posts
Notes

Digital Clock

Interrupts trigger ISRs when IF, IE, and GIE bits are set. Timers count internal clock ticks; counters count external events. A 4-digit display uses rapid multiplexing to create persistence of vision.

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 大致会经历这些步骤:

  1. 完成当前 instruction;
  2. 把下一条 instruction 的地址,也就是 PC,保存到 stack;
  3. 保存当前中断状态;
  4. 跳到 Interrupt Vector Table;
  5. 根据中断向量找到 ISR 地址;
  6. 执行 ISR;
  7. 执行 RETFIE
  8. 从 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 = 1GIE = 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.0006.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 快速轮流刷新,人眼看到的是四位同时显示。

Share this post

Back to home

Comments