定时器中断实验代码2
131.5.3定时器中断实验代码(2)
/*
* 整体目的:在上一节 1 秒 TIM1 中断的基础上统计上电时间,并控制两只 LED 与周期串口输出。
* 设计重点:中断回调只做计数、GPIO 翻转和置标志;主循环检测标志后执行较慢的 printf。
* 时间关系:LED1 每 1 秒翻转一次;LED2 每 2 秒翻转一次;打印发生在 1、6、11……秒。
* 代码范围:以下是 CubeIDE/06TIM 中本节新增和修改的 main.c 业务代码;
* TIM1 初始化沿用上一节,HAL 库函数内部不重复展开。
*/
#include "main.h" /* 引入 HAL 公共定义和 LED 引脚别名。 */
#include "tim.h" /* 引入 htim1 和 MX_TIM1_Init()。 */
#include "usart.h" /* 引入 USART1 初始化及课程工程的 printf 重定向。 */
#include "gpio.h" /* 引入两只 LED 的 GPIO 初始化接口。 */
uint8_t UART_Buf[200]; /* 配套工程预留的串口格式化缓冲区,本段 printf 宏可能使用其他缓冲区。 */
volatile uint32_t G_TIM_Count = 0U; /* 保存上电后的 1 秒节拍数;由 ISR 写、主循环读。 */
volatile uint8_t G_UART_Flag = 0U; /* 串口打印事件标志;volatile 保证两处都实际访问内存。 */
int main(void) /* 程序入口,初始化后在前台循环消费中断事件。 */
{ /* main() 函数体开始。 */
HAL_Init(); /* 初始化 HAL 和 1 ms SysTick。 */
SystemClock_Config(); /* 配置系统时钟为本工程使用的 72 MHz。 */
MX_GPIO_Init(); /* 初始化 PB5/LED1 与 PE5/LED2,并使其初始熄灭。 */
MX_TIM1_Init(); /* 配置 TIM1 为 1 秒更新周期。 */
MX_USART1_UART_Init(); /* 初始化板载 CH340C 连接的 USART1。 */
HAL_TIM_Base_Start_IT(&htim1); /* 启动 TIM1 计数并打开更新中断。 */
while (1) /* 主循环持续检查是否有待处理的串口事件。 */
{ /* 一次主循环开始。 */
if (G_UART_Flag == 1U) /* ISR 每隔 5 秒把标志置 1,主循环只处理一次。 */
{ /* 打印事件处理开始。 */
G_UART_Flag = 0U; /* 先清标志,避免慢速打印期间旧事件被重复消费。 */
printf( /* 通过 USART1 输出当前累计运行时间。 */
"MCU stm32 run time is %lus \n", /* %lu 对应 unsigned long,末尾 s 表示秒。 */
(unsigned long)G_TIM_Count); /* 显式转换以匹配格式参数类型。 */
} /* 打印事件处理结束。 */
} /* 返回 while 顶部继续轮询;没有事件时循环很快。 */
} /* main() 语法边界,正常运行不会返回。 */
void HAL_TIM_PeriodElapsedCallback( /* 每次 HAL 处理完 TIM1 更新中断后调用。 */
TIM_HandleTypeDef *htim) /* 参数可用于区分共享此回调的不同定时器。 */
{ /* 定时器周期回调开始。 */
if (htim == &htim1) /* 确认事件来自本实验的 TIM1。 */
{ /* TIM1 事件分支开始。 */
HAL_GPIO_TogglePin( /* 每次 1 秒更新事件都翻转 LED1。 */
LED1_GPIO_Port, /* 选择 LED1 所在 GPIOB。 */
LED1_Pin); /* 选择 PB5;低电平为点亮状态。 */
G_TIM_Count++; /* 每个 1 秒节拍将累计秒数加 1。 */
if ((G_TIM_Count % 5U) == 1U) /* 在 1、6、11……秒产生事件,相邻两次相隔 5 秒。 */
{ /* 打印定时条件成立。 */
G_UART_Flag = 1U; /* 通知主循环打印,不在中断里执行阻塞式 I/O。 */
} /* 打印定时条件结束。 */
if ((G_TIM_Count % 2U) == 0U) /* 在 2、4、6……秒进入一次 LED2 分支。 */
{ /* LED2 定时条件成立。 */
HAL_GPIO_TogglePin( /* 每 2 秒翻转一次 LED2。 */
LED2_GPIO_Port, /* 选择 LED2 所在 GPIOE。 */
LED2_Pin); /* 选择 PE5;完整亮灭周期为 4 秒。 */
} /* LED2 定时条件结束。 */
} /* TIM1 事件分支结束。 */
} /* 回调结束,CPU 随后退出中断并恢复前台代码。 */
一、定时器中断实验
1.1 统计单片机上电时间
TIM1 已配置为每秒产生一次更新中断,因此可在周期回调中把 32 位全局变量加一:
volatile uint32_t G_TIM_Count = 0;
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM1)
{
G_TIM_Count++;
}
}
变量单位是“秒”,前提是定时器实际更新周期确实为 1 秒。uint8_t 只能表示 0~255,约 255 秒就会回绕;uint32_t 会在 2^32 秒后回绕,约为 136 年。整数回绕通常不会让单片机“死机”,但会让累计时间从 0 重新开始,所以长时间运行的系统仍应设计溢出处理。
G_TIM_Count 同时被中断和主循环访问,应使用 volatile,避免编译器把主循环看到的值长期缓存在寄存器中。volatile 只保证每次都实际读写内存,不等同于线程安全;本例 Cortex-M3 对对齐的 32 位读写可作为单次访问完成,复杂共享结构仍需要临界区。
1.2 打印单片机上电时间
先用缓冲区格式化,再调用阻塞式串口发送:
uint8_t UART_Buf[200];
int len = snprintf((char *)UART_Buf,
sizeof(UART_Buf),
"MCU stm32 run time is %lus\r\n",
(unsigned long)G_TIM_Count);
if (len > 0)
{
uint16_t send_len = (len < (int)sizeof(UART_Buf))
? (uint16_t)len
: (uint16_t)(sizeof(UART_Buf) - 1U);
HAL_UART_Transmit(&huart1, UART_Buf, send_len, 1000);
}
snprintf 比 sprintf 多一个缓冲区上限,可避免输出超过 200 字节。若已经正确生成以 '\0' 结尾的字符串,也可用 strlen((char *)UART_Buf) 取得实际长度。不能使用 sizeof(UART_Buf) 作为有效字符串长度,否则会把数组中未使用的尾部字节也发送出去。
HAL_UART_Transmit 是阻塞调用,发送期间 CPU 会等待,因此它应位于主循环,不应放在定时器中断回调中。
1.3 编译并观察效果
若把格式化与发送直接写在 while (1) 中,主循环会以 CPU 和串口允许的最高速度反复打印,并不是每秒打印一次。即使主循环判断:
if ((G_TIM_Count % 5U) == 0U)
{
printf(...);
}
也仍有逻辑问题:G_TIM_Count 在一整秒内都保持 5、10、15 等值,而主循环在这一秒内会运行成千上万次,于是相同时间会被重复打印。
取余运算只说明“当前秒数能被 5 整除”,不能自动把代码限制为只执行一次。需要检测一次性事件,或使用由中断置位、主循环清零的标志。
1.4 观察效果
重复输出的真实时序如下:
中断时刻: 4s 5s 6s
G_TIM_Count: 4 ───────── 5 ─────────────────────── 6
主循环判断: 真 真 真 真 真 ... 假
串口输出: 连续输出许多次“5s”
定时器仍然只是一秒中断一次;重复打印来自这一秒内主循环多次满足相同条件,不是定时器一秒触发了很多次。解决办法是让 ISR 在目标时刻只产生一个通知。
1.5 更换思路
第一步可以先把“五秒到了”的判断移到定时器回调中:
G_TIM_Count++;
if ((G_TIM_Count % 5U) == 0U)
{
/* 五秒事件只在这次中断里出现一次 */
}
因为回调每秒只进入一次,所以 5、10、15 秒各命中一次。但不要因此直接在回调里发送串口;判断可以留在 ISR,耗时工作仍交给主循环。
1.6 注释主函数
调试时可以暂时注释主循环里的打印代码,先确认计数和中断判断能正常编译运行。更可靠的验证方式是:
- 在调试器 Watch 窗口观察
G_TIM_Count。 - 在回调入口设置断点,但不要长时间停住,以免改变系统时序。
- 临时翻转一个 GPIO,用示波器或逻辑分析仪测量周期。
注释代码只是一种定位手段,最终实现仍应保留“ISR 通知、主循环处理”的完整通路。
1.7 观察效果
如果把打印直接放进回调,确实能得到 5、10、15 秒各一次的表面效果,但阻塞式 UART 会延长 ISR:
TIM1更新中断
├─ 秒计数与条件判断 很短
└─ 格式化 + UART阻塞发送 较慢,不适合ISR
ISR 执行期间,同级或更低优先级中断可能无法及时响应;如果 HAL 超时依赖 SysTick,而当前中断优先级又阻止 SysTick 运行,还可能出现超时计数不前进的问题。因此回调只保留计数、GPIO 和标志操作。
1.8 问题优化
增加一个全局事件标志:
volatile uint8_t G_UART_Flag = 0;
职责划分为:
sequenceDiagram
participant T as TIM1中断回调
participant F as G_UART_Flag
participant M as 主循环
T->>T: G_TIM_Count++
T->>F: 到指定秒数时置1
M->>F: 轮询标志
F-->>M: 标志为1
M->>M: printf/HAL_UART_Transmit
M->>F: 清0
这与操作系统里“快速中断处理 + 延后处理”的思想相似,不过本例没有真正创建两个 Linux 式上下半部,只是用标志把耗时工作移出硬件中断上下文。
1.9 增加变量
全局变量放在用户代码区域,并标明其含义:
volatile uint32_t G_TIM_Count = 0U; /* 上电秒数 */
volatile uint8_t G_UART_Flag = 0U; /* 串口打印事件 */
uint8_t 足够保存 0/1 标志。变量名应表达用途,避免使用 j、y1 等离开课堂上下文后难以理解的名称。
单一标志只能表示“至少发生过一次事件”。如果主循环可能阻塞十几秒,期间多个五秒事件会合并成一次。对不能丢失的事件,应改用递增的待处理计数器、环形队列或 RTOS 消息机制。
1.10 初始化变量
静态存储期的全局变量即使不显式赋值也会被初始化为 0,但写出 = 0U 能让意图更清楚。每次复位后:
G_TIM_Count = 0
G_UART_Flag = 0
TIM1启动后,每次更新中断令 G_TIM_Count 加1
在中断回调中,到达打印时刻只置位:
if ((G_TIM_Count % 5U) == 0U)
{
G_UART_Flag = 1U;
}
不要在主循环每次迭代开头把标志清零,否则中断刚产生的事件可能在检查前就被覆盖。
1.11 注释之前判断
主循环不再直接根据 G_TIM_Count % 5 判断,而是消费标志:
if (G_UART_Flag == 1U)
{
G_UART_Flag = 0U;
printf("MCU stm32 run time is %lus\r\n",
(unsigned long)G_TIM_Count);
}
先清标志再执行慢速打印,可缩短“旧事件尚未清除”的窗口。如果必须让打印值严格对应产生事件时的 5、10、15,而不是主循环稍后读到的时间,可在 ISR 中把当时的计数值复制到另一个共享变量,再置位标志。
1.12 观察效果
中断与主循环的最终分工如下:
| 位置 | 适合执行 | 不适合执行 |
|---|---|---|
| TIM1 回调 | 加计数、置标志、短 GPIO 操作 | printf、阻塞串口、HAL_Delay、复杂计算 |
| 主循环 | 格式化、串口发送、业务处理 | 长时间关中断或无界阻塞 |
这样既能保证每五秒只产生一次打印事件,也能让中断尽快返回。主循环每消费一次标志就将其清零,为下一次事件做准备。
1.13 观察窗口
调试器 Watch/Expressions 窗口可以同时观察:
G_TIM_Count:每秒递增
G_UART_Flag:目标时刻由0变1,主循环处理后恢复0
TIM1->CNT:在0到9999之间循环
TIM1->SR 的 UIF:更新时置位,HAL中断处理后清除
复位后计数回到 0;启动 TIM1 后约一秒变为 1。调试器暂停 CPU 时,定时器是否继续计数取决于调试冻结配置,所以断点期间的观察结果可能与脱机运行不同。
1.14 加小人化更改
配套最终代码把条件写成:
if ((G_TIM_Count % 5U) == 1U)
{
G_UART_Flag = 1U;
}
这样输出时刻是 1、6、11、16……秒,而不是 1 秒后再等到 5 秒输出。它提供了更快的首次反馈,但相邻两次输出仍相隔 5 秒。若业务定义要求严格在 5、10、15……秒输出,就应使用 % 5U == 0U,而不是把两种时间序列混称为同一行为。
1.15 让第二个灯每隔两秒翻转一次
在同一个每秒回调中增加:
if ((G_TIM_Count % 2U) == 0U)
{
HAL_GPIO_TogglePin(LED2_GPIO_Port, LED2_Pin);
}
LED2 在第 2、4、6……秒翻转。由于一次翻转只改变一个状态,灯保持亮 2 秒、再保持灭 2 秒,完整亮灭周期为 4 秒。
LED 板载电路为低电平有效:
3.3 V ── LED ── 1 kΩ ── PE5
│
GPIO输出级
PE5=0:GPIO灌电流,LED2亮
PE5=1:没有足够压差,LED2灭
这不影响 TogglePin 的时间规律,只会影响哪个逻辑电平对应“亮”。
1.16 问题优化
多个定时器共用 HAL_TIM_PeriodElapsedCallback 时必须区分句柄:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM1)
{
/* TIM1任务 */
}
else if (htim->Instance == TIM2)
{
/* TIM2任务 */
}
}
不能只因为当前工程只有 TIM1 就永久忽略参数,否则以后增加定时器会让所有分支重复执行。
串口输出可从“snprintf + HAL_UART_Transmit”简化为 printf,但 printf 并不会天然知道要使用哪个 UART,必须实现底层字符输出重定向。
1.17 粘贴内容并更改
常见的 GCC/newlib 重定向方式是在 usart.c 或独立文件中实现 _write:
#include <stdio.h>
int _write(int file, char *ptr, int len)
{
(void)file;
if (HAL_UART_Transmit(&huart1,
(uint8_t *)ptr,
(uint16_t)len,
1000) == HAL_OK)
{
return len;
}
return -1;
}
若使用 Keil/Arm Compiler,重定向入口可能是 fputc;应根据当前工具链选择一种实现,不要同时复制多套互相冲突的桩函数。还要确认:
huart1在当前源文件中可见,必要时使用extern UART_HandleTypeDef huart1;。- 头文件声明、函数签名和工具链匹配。
- 重定向函数不在 ISR 中被调用。
- 量产代码按需要处理超时与返回错误。
1.18 回到主函数里更改
完成重定向后,主循环可直接写:
if (G_UART_Flag == 1U)
{
G_UART_Flag = 0U;
printf("MCU stm32 run time is %lus\r\n",
(unsigned long)G_TIM_Count);
}
配套 06TIM 工程的完整逻辑可以概括为:
volatile uint32_t G_TIM_Count = 0U;
volatile uint8_t G_UART_Flag = 0U;
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM1)
{
HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
G_TIM_Count++;
if ((G_TIM_Count % 5U) == 1U)
G_UART_Flag = 1U;
if ((G_TIM_Count % 2U) == 0U)
HAL_GPIO_TogglePin(LED2_GPIO_Port, LED2_Pin);
}
}
/* 初始化完成后 */
HAL_TIM_Base_Start_IT(&htim1);
while (1)
{
if (G_UART_Flag == 1U)
{
G_UART_Flag = 0U;
printf("MCU stm32 run time is %lus\r\n",
(unsigned long)G_TIM_Count);
}
}
编译、烧录并打开串口后,应看到 1、6、11……秒的运行时间;LED1 每秒翻转,LED2 每两秒翻转。若希望输出 5、10、15……秒,只需把打印条件改为 G_TIM_Count % 5U == 0U。整个设计的重点不是某个具体余数,而是“硬件定时器产生稳定节拍,中断快速记录事件,主循环处理耗时任务”。