stm32/mcu/timer-interrupt.md

定时器与中断:从 1 秒心跳到五阶段状态机

知识库 · STM32 📅 初版 2026-09-15

定时器与中断:从 1 秒心跳到五阶段状态机

2026-09-15 实测验证:洋桃 1 号开发板(STM32F103C8T6),TIM3 每 1 秒中断一次计秒,五阶段状态机驱动 LED 按阶段亮灭切换(整周期 30 秒),OLED 显示 STAGE: n。Keil MDK 5.25.3.0 编译 0 Error 0 Warning,STM32CubeProgrammer CLI 2.23.0 SWD 烧录通过。

#一、元信息

字段值
芯片/平台STM32F103C8T6(洋桃 1 号开发板,标准库)
外设类型TIM3 更新中断(1s 心跳)+ GPIO 推挽输出(LED)+ OLED(SH1106,软件 I2C)
验证状态✅ 已验证(2026-09-15)
验证环境洋桃1号开发板 / Keil MDK 5.25.3.0(0 Error 0 Warning)/ STM32CubeProgrammer CLI 2.23.0(SWD 烧录)/ 2026-09-15
驱动方式TIM3 中断里计秒并维护状态机(volatile 共享变量),主循环只在阶段变化时刷新 OLED

#二、TIM3 心跳:1 秒是怎么算出来的

本工程的心跳配置就一行:

cTIM3_Init(9999, 7199);   /* ARR = 9999,PSC = 7199 */

72MHz 主频下,定时器溢出时间用这个公式算:

txtTout = ((ARR + 1) × (PSC + 1)) / 72MHz
     = ((9999 + 1) × (7199 + 1)) / 72,000,000
     = 10,000 × 7,200 / 72,000,000
     = 72,000,000 / 72,000,000
     = 1 秒
  • PSC(预分频):把 72MHz 先分频 7200 倍,计数器每 0.1ms 加 1;
  • ARR(自动重装载):数到 9999(共 10000 个计数)就溢出并触发更新中断;
  • 两个数一凑,正好 1 秒一次中断——这就是全板最准的"表"。

#三、中断驱动 vs 延时轮询

同样是"每秒干一件事",两种写法的差距是结构性的:

对比项延时轮询(delay_s())中断驱动(TIM3)
CPU 占用阻塞:delay 期间 CPU 原地空转数数,啥也干不了每秒只进一次中断、几微秒就出来,占用百万分之二以下
时序精度受主循环里其他代码影响,功能越加越不准硬件计数器独立数数,与主循环忙不忙无关
程序结构主循环被拖死,只能顺序等中断计秒 + 主循环干重活,各干各的
可扩展性再加一个任务就得重排时间表多开一个定时任务就是多一个中断/比较通道

delay_s() 最隐蔽的代价不是"慢",而是阻塞:主循环卡在数数上,按键、通信、刷屏全部跟着停摆。中断驱动把"数时间"这个活彻底交给硬件。

#四、五阶段状态机实战

#4.1 设计规则

  • TIM3 每 1 秒中断一次,中断里 g_tim_sec 计秒;
  • 阶段内秒数 g_tim_sec < g_tim_stage 则 LED 亮,否则灭——每阶段前半段亮、后半段灭;
  • 累计满 2×阶段号 秒切换阶段:1→2→3→4→5→1 循环;
  • 阶段 1 亮 1s 灭 1s、阶段 2 亮 2s 灭 2s……阶段 5 亮 5s 灭 5s,整周期 2×(1+2+3+4+5) = 30 秒;
  • OLED 显示 STAGE: n:在主循环里刷新、只在阶段变化时刷一次。

#4.2 核心代码

c/* 共享变量:中断写、主循环读,必须 volatile */
volatile u8 g_tim_stage = 1;   /* 当前阶段 1~5 */
volatile u8 g_tim_sec   = 0;   /* 本阶段已过秒数 */

/* TIM3 更新中断:每 1 秒进一次 */
void TIM3_IRQHandler(void)
{
    if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET)
    {
        TIM_ClearITPendingBit(TIM3, TIM_IT_Update);

        /* 阶段内:前 g_tim_stage 秒亮灯,之后灭灯 */
        if (g_tim_sec < g_tim_stage)
        {
            GPIO_WriteBit(LEDPORT, LED1, (BitAction)1);
        }
        else
        {
            GPIO_WriteBit(LEDPORT, LED1, (BitAction)0);
        }
        g_tim_sec++;

        /* 满 2×阶段号 秒 → 切换阶段:1→2→3→4→5→1 循环 */
        if (g_tim_sec >= g_tim_stage * 2)
        {
            g_tim_sec = 0;
            g_tim_stage++;
            if (g_tim_stage > 5) g_tim_stage = 1;
        }
    }
}

#4.3 OLED 显示:STAGE: n

OLED 刷屏是毫秒级重活,放中断里会拖垮时序,所以放主循环,并且只在阶段变化时刷一次(函数名以工程 oled.c 为准):

cu8 stage_show = 0;   /* 上一次刷上屏幕的阶段号 */

while (1)
{
    if (g_tim_stage != stage_show)        /* 阶段变了才刷 */
    {
        stage_show = g_tim_stage;
        OLED_ShowString(0, 2, "STAGE: ");
        OLED_ShowNum(56, 2, stage_show, 1, 16);
    }
    /* 主循环还可以干其他毫秒级重活,互不影响 */
}

#五、中断编程铁律

  1. 中断里只做微秒级轻活:置/清引脚、加减变量、存一个字节,几十个时钟周期内出来;
  2. 毫秒级重活一律放主循环:OLED 刷屏、串口收发、浮点运算,进了中断就是把全板时序押上去;
  3. 中断与主循环用共享变量"传话":变量必须加 volatile,防止编译器把主循环里的读取优化掉;
  4. 多字节共享数据注意原子性:主循环先读到局部变量再用,避免读到"改了一半"的值。

本页状态机就是范本:中断只做"计秒 + 换灯 + 切阶段"(微秒级),"把阶段画上屏"这种重活留给主循环,两者靠 g_tim_stage 一句话沟通。

#六、工程教训专栏

#6.1 教训一:补丁"成功"了,行为没变

用 PowerShell 做多行字符串替换打补丁时,锚点因缩进差 4 个空格而静默跳过:命令不报错,编译照样"成功",但目标代码根本没被替换,固件行为毫无变化。字符串替换找不到目标时是"零次匹配、零声不吭",不是错误。

对策:补丁必须改完验证文件内容——替换之后把文件读回来,确认新代码真的在文件里,再编译烧录。

#6.2 教训二:在 hex 里搜字符串是假验证

想在固件里确认"我的新代码进去了",去 hex 文件里搜源码字符串?搜不到是正常的:hex 是十六进制编码的记录文本(地址、长度、校验俱全),代码和数据都被编码过,明文在里面根本不存在。搜不到 ≠ 没烧进去。

正确做法:搜 .axf——axf 是真实的二进制镜像(也可配合 map 文件),里面的常量字符串、函数符号是可直接定位的。

#七、参考库/链接

  • STM32F103 参考手册(RM0008):TIM 定时器章节(PSC / ARR / 更新事件与中断)
  • 洋桃1号官方例程:TIM 定时器中断示例(TIM3_Init 所在工程)
  • STM32CubeProgrammer CLI 烧录:SWD 接口下载 / 校验

#八、相关阅读

#九、更新日志

📝共 1 条记录
日期类型更新内容
2026-09-15新增首次建档(实测验证版)