定时器与中断:从 1 秒心跳到五阶段状态机
定时器与中断:从 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);
}
/* 主循环还可以干其他毫秒级重活,互不影响 */
}
#五、中断编程铁律
- 中断里只做微秒级轻活:置/清引脚、加减变量、存一个字节,几十个时钟周期内出来;
- 毫秒级重活一律放主循环:OLED 刷屏、串口收发、浮点运算,进了中断就是把全板时序押上去;
- 中断与主循环用共享变量"传话":变量必须加
volatile,防止编译器把主循环里的读取优化掉; - 多字节共享数据注意原子性:主循环先读到局部变量再用,避免读到"改了一半"的值。
本页状态机就是范本:中断只做"计秒 + 换灯 + 切阶段"(微秒级),"把阶段画上屏"这种重活留给主循环,两者靠 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 接口下载 / 校验
#八、相关阅读
- PWM 与电机控制原理——定时器管电平时间的另一大用场
- 软件 I2C:用 GPIO 敲出一条总线
- STM32 实战复盘:一次烧录验证串起的整条工具链
#九、更新日志
| 日期 | 类型 | 更新内容 |
|---|---|---|
| 2026-09-15 | 新增 | 首次建档(实测验证版) |