定时器中断实验代码2

Tutorial: 电子电路 Category: 嵌入式 Published: 2026-10-08 01:26:37 Views: 1 Likes: 0 Comments: 0

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。整个设计的重点不是某个具体余数,而是“硬件定时器产生稳定节拍,中断快速记录事件,主循环处理耗时任务”。