嵌入式技术应用设计实训 | 定时器:定时中断
本课程开源地址(Gitee) :https://gitee.com/fujianxinxi/qianrushishixundianzi.git
课件、示例代码与验收脚本都在该仓库,可直接
git clone或下载 ZIP 使用。B站配套视频合集 :https://space.bilibili.com/572740825/lists?sid=9228122
合集《嵌入式技术应用设计实训|STM32F103 从点灯到智能家居》:每课配一节视频,可先看现象演示,再照着做。
开发板资料A盘(百度网盘) :https://pan.baidu.com/s/1_EXrjRUewwmGMgcy38zEZg(提取码:
zbri)正点原子官方资料下载 :http://www.openedv.com/docs/boards/stm32/zdyz_stm32f103_nano.html
模块一 基础外设开发 · 2 学时 · 环境:Keil MDK5 + VS Code + 正点原子 STM32F103 Nano V1.3
一句话概述 :从"延时不准、还占着 CPU"入手,把 TIM3 这条链路整条写在 main.c 里(开时钟 → 配 arr/psc → 开中断通道 → 启动),讲清定时时间怎么算(Tout=(arr+1)×(psc+1)/Ft,TIM3 的 Ft = 72MHz);中断入口与回调沿用课时02/03 的套路,中断里只加一个 volatile 计秒变量、翻一个灯,主循环照跑 LED 花样并用课时03 的 printf 打印秒数。
本节目标
- 能算出定时时间:说清
Tout=(arr+1)×(psc+1)/Ft里三个量各是什么,并算出本课的 500 ms 是怎么来的(TIM3 的 Ft = 72MHz) - 能照着
TIM3_Init()说出四步(开时钟 → 配参数 → 开中断通道 → 启动),并把现象跑通:DS1 每 0.5 秒翻转一次、串口每秒打印一行秒数 - 能说出"中断入口 + 回调"这套结构和课时02/03 是同一个套路,并解释为什么中断里只加一个数、为什么计秒变量必须加
volatile
一、原理说明
课时01 用的 delay_ms() 是让 CPU 原地数数:数够时间才往下走,这段时间它什么也干不了;换个芯片、改个主频,同样的参数还不准了。定时器不一样------它是硬件里的一个计数器,由时钟自己数,数满了主动"敲门"(产生中断),CPU 在此之前爱干什么干什么。所以用定时器只要回答两个问题:"数多快、数多少个" (预分频 psc 和自动重装值 arr,两者一起决定定时时间),和**"敲了门之后干什么"**(中断回调里那一小段代码)。本课把这条链路整条写在 main.c 里,一眼能看全;中断入口与回调刻意沿用课时02 按键、课时03 串口那套"入口 + 回调"写法------这已经是你第三次见到它了,该记住它的样子了。
二、讲解内容
2.1 知识讲解
1)为什么不能一直用 delay_ms()
软件延时 delay_ms()(课时01 用过) |
硬件定时器 + 中断(本课) | |
|---|---|---|
| 谁在数时间 | CPU 自己原地数 | 硬件计数器,时钟自己在数 |
| 这段时间 CPU 能干什么 | 什么都不能干 | 想干什么干什么,到点被"敲门" |
| 准不准 | 跟主频、编译器优化有关,改一处就可能偏 | 由时钟频率决定,准 |
| 同时干几件事 | 一件都干不了 | 主循环一件、定时器一件,并行 |
| 典型用途 | 闪灯、等器件稳定这种"短而糙"的等待 | 计秒、采样、刷新显示这种"要准、要周期"的活 |
2)定时时间是怎么算出来的
text
Tout = (arr + 1) × (psc + 1) / Ft
| 符号 | 是什么 | 本课取值 |
|---|---|---|
Ft |
定时器的时钟频率 | 72 MHz(见下面的"为什么是 72M") |
psc |
预分频(Prescaler):先把时钟"降速" | 7200 - 1 |
arr |
自动重装值(Period):降速后数多少个数 | 5000 - 1 |
代入:Ft / (psc+1) = 72MHz / 7200 = 10kHz(每 0.1 ms 计一个数);(arr+1) = 5000 → 5000 × 0.1ms = 500ms。这就是本课"0.5 秒"的来历------课堂上要求你自己把这行算式算一遍。
**为什么
Ft是 72 MHz 而不是 36 MHz?**TIM3 挂在 APB1 上,本板 PCLK1 = 36 MHz;但 STM32 有一条规则:当 APB 的分频不为 1 时,接在它上面的定时器时钟 = 2 × PCLK1,所以 TIM3 拿到的是 72 MHz。这条规则不看图也能记:定时器总线上的定时器,时钟永远是 72 MHz。厂家的注释写着"定时器时钟为 84M,分频系数为 8400-1",是错的 (那是别的芯片/别的例程抄来的),代码里传的是
7200-1。以代码和上面的算式为准------这也是提醒你:注释和代码不一致时,先自己算一遍。
3)定时器要配哪几样
| 字段 | 本课取值 | 说明 |
|---|---|---|
Instance |
TIM3 |
用哪个定时器;F103 的 TIM2~TIM7 都是通用定时器 |
Prescaler |
psc |
预分频:0 表示不分频,n 表示 n+1 分频 |
CounterMode |
TIM_COUNTERMODE_UP |
向上计数:0 → arr |
Period |
arr |
自动重装值:数到它就溢出(并产生更新中断) |
ClockDivision |
TIM_CLOCKDIVISION_DIV1 |
时钟分频,只影响采样与死区,不影响计数周期 |
AutoReloadPreload |
TIM_AUTORELOAD_PRELOAD_ENABLE |
使能自动重载 |
配完这六样,HAL_TIM_Base_Init() 把它们写进寄存器;再 HAL_TIM_Base_Start_IT() 启动计数并打开"更新中断"。
4)中断的"入口 + 回调",你已经见过两次了
| 课时 | 中断入口(写在 main.c) |
干活的地方(回调) |
|---|---|---|
| 课时02 按键 | EXTI0/2/9_5_IRQHandler() |
HAL_GPIO_EXTI_Callback() |
| 课时03 串口 | USART1_IRQHandler() |
HAL_UART_RxCpltCallback() |
| 课时04 定时器 | TIM3_IRQHandler() |
HAL_TIM_PeriodElapsedCallback() |
三者是同一个套路,分三步走:
text
硬件到点(或引脚变化、或收到字节)
│ 硬件自动
▼
TIM3_IRQHandler() ← 中断入口,向量表按这个名字找它
│ 我们调用
▼
HAL_TIM_IRQHandler(&TIM3_Handler) ← HAL 帮你判断是哪种中断、清掉标志位
│ 它再调用
▼
HAL_TIM_PeriodElapsedCallback() ← 我们干活的地方(本课:计一个数 + 翻一个灯)
HAL_TIM_PeriodElapsedCallback() 在 HAL 库里是弱函数 (__weak),我们在 main.c 里重写一份就被调用,不用注册。
5)中断里为什么只能干"最少的活"
中断一进来,主循环就停在原地等着 :这里干得越久,整个系统响应越迟钝。本课的回报里只有两件事------g_tick++ 和 LED1 = !LED1,加起来几微秒,主循环几乎感觉不到。
反过来说,下面这些绝对不能放进回调:
| 别放进去 | 为什么 |
|---|---|
printf(...) |
一个字符在 115200 下要 ~87 µs,打印 20 个字符就是 1.7 ms,而这 1.7 ms 里主循环全停着 |
delay_ms() |
直接在中断里"睡"几百毫秒,主循环彻底卡死(课时02 已经强调过一次) |
| 复杂计算 / 大数组搬运 | 中断里应该"只置标志、只记账",重活留给主循环 |
6)volatile:被中断改过的变量必须加
g_tick 的定义是这样的:
c
volatile u32 g_tick = 0; // 中断里 +1、主循环里读 → 必须 volatile
道理很直白:编译器的优化是"假设这个变量没别人动过"。在它眼里,主循环里的 g_tick 从来没被赋值,于是可能只在第一次把它读进寄存器,之后一直用那个旧值 ------现象就是:灯在闪(中断确实在跑),但串口打印的秒数永远不动。
volatile 就是告诉编译器:"这个变量可能被别人(中断)改,每次用都要老老实实回内存里重新读 "。记住一条规矩:中断里写、主循环里读的变量,一律加 volatile。
7)中断优先级
| 课时 | 外设 | 抢占优先级 | 子优先级 |
|---|---|---|---|
| 课时02 | 按键 EXTI | 2 | 0 |
| 课时03 | 串口 USART1 | 3 | 3 |
| 课时04 | 定时器 TIM3 | 1 | 3 |
数值越小,优先级越高。本课串口只用来发送(阻塞式等发完,不用中断),所以 TIM3 的中断独占,不用担心被谁抢。等以后真的要用两个中断干活,就要回来想清楚"谁可以打断谁"。
8)分工:中断负责"数拍子",主循环负责"换算和显示"
text
中断(每 0.5 秒一次):g_tick++ ← 只记账,一个数
主循环(每 200 ms 一圈):g_tick / 2 → 秒 → printf ← 换算 + 显示
为什么不让中断直接把秒算出来、甚至直接打印?因为中断里越少干活越好(见第 5 条)。这个"中断只置标志/只记账,主循环取数据干活"的分工,是嵌入式里最常见的写法之一,实验四的加分项就是让你亲手体会一遍。
9)本课为什么把定时器代码全部写在 main.c
| 看哪一处 | 厂家写法 | 本课写法 |
|---|---|---|
| 定时器初始化 | HARDWARE/TIMER/timer.c 的 TIM3_Init() |
main.c 的 TIM3_Init() |
| 开时钟、设中断优先级 | 藏在 HAL_TIM_Base_MspInit()(HAL 的隐式回调)里 |
TIM3_Init() 里明写 |
| 中断入口 | timer.c 的 TIM3_IRQHandler() |
main.c 的 TIM3_IRQHandler() |
| 到点回调 | timer.c 的 HAL_TIM_PeriodElapsedCallback() |
main.c 的回调 |
打开 main.c 能看到什么 |
一句 TIM3_Init() + 主循环 |
整条链路:初始化 → 中断入口 → 回调 → 主循环 |
代价是 HARDWARE/TIMER/timer.c 暂时空了(只留一段说明),好处是一节课就能看全,而且与课时02、课时03(中断都写在 main.c)保持一致。实验四如果想把定时器再封装成自己的库,正好练一次"分层"。
2.2 代码示例
本课改两个文件,另外两个文件"搬空" (都在 课时文档/代码/课时04-定时器:定时中断/定时器中断实验/ 下):
text
USER/main.c ← 本课主角:UART1_Init / TIM3_Init / TIM3_IRQHandler / 回调 / main
SYSTEM/usart/usart.c、usart.h ← 沿用课时03 的"只发送"版本,供 printf 使用
HARDWARE/TIMER/timer.c、timer.h ← 搬空了,只留一段说明(代码已全部进 main.c)
工程里其余文件(HARDWARE/LED、HARDWARE/KEY、SYSTEM/sys、SYSTEM/delay、HALLIB、CORE)都是现成的,不用动。
SYSTEM/usart/usart.h(完整代码)
c
#ifndef __USART_H
#define __USART_H
#include "stdio.h"
#include "sys.h"
//////////////////////////////////////////////////////////////////////////////////
// 《嵌入式技术应用设计实训》课时04 定时器:定时中断 ------ 串口(只用发送)
// 本文件基于正点原子 ALIENTEK NANO STM32F103 厂家例程改写,
// 原例程版权归广州市星翼电子科技有限公司所有。
//
// 本课只把课时03 里"发送"那一条路搬过来,用来 printf 打印秒数:
// printf ──> fputc() ──> uart_putc() ──> USART1
// uart_send() ──────────────┘
// 课时03 的"接收 + 二进制命令"本课用不上,已经删掉;串口初始化写在 USER/main.c。
//////////////////////////////////////////////////////////////////////////////////
extern UART_HandleTypeDef UART1_Handler; // USART1 句柄:usart.c 里定义、main.c 里初始化
void uart_send(const u8 *buf, u16 len); // 本课唯一的发送出口
#endif
SYSTEM/usart/usart.c(完整代码)
c
#include "sys.h"
#include "usart.h"
//////////////////////////////////////////////////////////////////////////////////
// 《嵌入式技术应用设计实训》课时04 定时器:定时中断 ------ 串口底层(只用发送)
// 本文件基于正点原子 ALIENTEK NANO STM32F103 厂家例程改写,
// 原例程版权归广州市星翼电子科技有限公司所有。
//
// 只留一条发送路径(沿用课时03 的做法):
// ① uart_putc() 最底层:等上一个字节发完,再把一个字节写进数据寄存器
// ② uart_send() 对外唯一出口
// ③ fputc() 把 printf 接到 uart_putc() 上
// 串口初始化不在这里,写在 USER/main.c 的 UART1_Init() 里,看得见、不是黑盒。
//////////////////////////////////////////////////////////////////////////////////
UART_HandleTypeDef UART1_Handler; // USART1 句柄
// 最底层:发 1 个字节
// SR 的 bit6 是 TC(发送完成标志),等它为 1 说明上一个字节发完了,再写 DR 才不会冲掉数据
static void uart_putc(u8 ch)
{
while ((USART1->SR & 0x40) == 0); // 等 TC = 1
USART1->DR = ch; // 写数据寄存器,硬件自动一个位一个位发出去
}
// 对外唯一出口:发一串字节
void uart_send(const u8 *buf, u16 len)
{
while (len--) uart_putc(*buf++);
while ((USART1->SR & 0x40) == 0); // 等最后一个字节真正发完再返回
}
// printf 重定向:printf 自己不懂串口,它把内容拼成字符串后,一个字符一个字符地调 fputc()
// Keil 里必须勾选 Options for Target → Target → Use MicroLIB,否则 printf 不出声
#pragma import(__use_no_semihosting) // 不使用半主机模式,免得卡在 BKPT 上
struct __FILE // 标准库要求有这个类型
{
int handle;
};
FILE __stdout;
void _sys_exit(int x) // 屏蔽半主机相关的退出函数
{
x = x;
}
int fputc(int ch, FILE *f)
{
uart_putc((u8)ch); // 和 uart_send() 走同一条路
return ch;
}
USER/main.c(完整代码)
c
#include "sys.h"
#include "delay.h"
#include "usart.h"
#include "led.h"
//////////////////////////////////////////////////////////////////////////////////
// 《嵌入式技术应用设计实训》课时04 定时器:定时中断
// 本文件基于正点原子 ALIENTEK NANO STM32F103 厂家例程(定时器中断实验-HAL库版本)改写,
// 原例程版权归广州市星翼电子科技有限公司所有。
//
// 硬件:正点原子 STM32F103 Nano V1.3(全部板载,不用额外接线)
// DS0~DS7:PC0~PC7,低电平点亮
// USART1 :PA9 = TX、PA10 = RX,板上已连到板载 ST-Link 虚拟串口,本课只用来 printf 打印秒数
//
// 本课把"定时器"这条链路全部写在 main.c 里,一节课看得全,不用在文件之间跳:
// UART1_Init() 串口初始化(沿用课时03,只为 printf 打印)
// TIM3_Init() 定时器初始化:开时钟 → 配参数 → 开中断通道 → 启动
// TIM3_IRQHandler() 中断入口(向量表按名字找它)
// HAL_TIM_PeriodElapsedCallback() 到点后干活的地方(本课:计一个数 + 翻一个灯)
//
// 现象:
// DS1 每 0.5 秒翻转一次 ------ 硬件定时器中断驱动,CPU 不用等
// DS0 每 0.2 秒翻转一次 ------ 主循环 delay_ms() 驱动,CPU 原地等 200ms
// 串口每 1 秒打印一行"已经过 n 秒",秒数由中断在后台累加
//////////////////////////////////////////////////////////////////////////////////
TIM_HandleTypeDef TIM3_Handler; // TIM3 句柄
volatile u32 g_tick = 0; // 定时器节拍:中断里 +1、主循环里读
// 被中断改的变量必须加 volatile,
// 否则编译器可能认为它"永远不会变"而优化掉
// ------------------------------------------------------------------------------
// 串口 1 初始化(沿用课时03):本课只用来 printf 打印秒数
// ① 开时钟 ② 配 PA9/PA10 复用 ③ 配 115200-8-N-1
// ------------------------------------------------------------------------------
static void UART1_Init(u32 bound)
{
GPIO_InitTypeDef GPIO_Initure;
/* ① 开时钟(不开时钟,后面写寄存器全部无效,而且不报错) */
__HAL_RCC_GPIOA_CLK_ENABLE(); // PA9 / PA10 在 GPIOA
__HAL_RCC_USART1_CLK_ENABLE(); // USART1 自己
__HAL_RCC_AFIO_CLK_ENABLE(); // STM32F1 的复用功能要经过 AFIO
/* ② 配引脚:把 PA9、PA10 交给 USART1 去管 */
GPIO_Initure.Pin = GPIO_PIN_9; // PA9 = TX
GPIO_Initure.Mode = GPIO_MODE_AF_PP; // 复用推挽输出
GPIO_Initure.Pull = GPIO_PULLUP;
GPIO_Initure.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOA, &GPIO_Initure);
GPIO_Initure.Pin = GPIO_PIN_10; // PA10 = RX
GPIO_Initure.Mode = GPIO_MODE_AF_INPUT; // 复用输入
HAL_GPIO_Init(GPIOA, &GPIO_Initure);
/* ③ 配参数:两边必须一模一样,否则收到的全是乱码 */
UART1_Handler.Instance = USART1;
UART1_Handler.Init.BaudRate = bound; // 115200
UART1_Handler.Init.WordLength = UART_WORDLENGTH_8B; // 8 位数据位
UART1_Handler.Init.StopBits = UART_STOPBITS_1; // 1 位停止位
UART1_Handler.Init.Parity = UART_PARITY_NONE; // 无校验
UART1_Handler.Init.HwFlowCtl = UART_HWCONTROL_NONE; // 无硬件流控
UART1_Handler.Init.Mode = UART_MODE_TX_RX; // 收发都开
HAL_UART_Init(&UART1_Handler);
}
// ------------------------------------------------------------------------------
// 定时器 3 初始化
//
// 定时时间怎么算: Tout = (arr + 1) * (psc + 1) / Ft
// Ft = 定时器的时钟。TIM3 挂在 APB1 上,而当 APB1 的分频不为 1 时,
// 定时器时钟 = 2 × PCLK1 ------ 本板 PCLK1 = 36MHz,所以 Ft = 72MHz
// 本课取 psc = 7200 - 1:72MHz / 7200 = 10kHz,即每 0.1ms 计一个数
// arr = 5000 - 1:数满 5000 个数 = 5000 × 0.1ms = 500ms = 0.5 秒
// 想改快改慢:只动 arr 就行(arr 减半 = 时间减半)
// ------------------------------------------------------------------------------
static void TIM3_Init(u16 arr, u16 psc)
{
/* ① 开时钟 */
__HAL_RCC_TIM3_CLK_ENABLE(); // 给 TIM3 使能时钟
/* ② 配参数:预分频、计数方向、自动重装值、时钟分频、自动重载 */
TIM3_Handler.Instance = TIM3; // 用通用定时器 3
TIM3_Handler.Init.Prescaler = psc; // 预分频
TIM3_Handler.Init.CounterMode = TIM_COUNTERMODE_UP; // 向上计数
TIM3_Handler.Init.Period = arr; // 自动重装值
TIM3_Handler.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; // 时钟分频:不分
TIM3_Handler.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; // 使能自动重载
HAL_TIM_Base_Init(&TIM3_Handler); // 把上面参数写进寄存器
/* ③ 开中断:放行 TIM3 的中断通道(抢占 1、子 3,数值越大优先级越低) */
HAL_NVIC_SetPriority(TIM3_IRQn, 1, 3); // 抢占 1、子 3(数值越大优先级越低)
HAL_NVIC_EnableIRQ(TIM3_IRQn); // 放行中断通道
/* ④ 启动:定时器开始计数,数满了就产生"更新中断" */
HAL_TIM_Base_Start_IT(&TIM3_Handler);
}
// ------------------------------------------------------------------------------
// TIM3 中断入口:向量表按这个名字找它
// (和课时02 的 EXTIx_IRQHandler、课时03 的 USART1_IRQHandler 是同一个套路)
// ------------------------------------------------------------------------------
void TIM3_IRQHandler(void)
{
HAL_TIM_IRQHandler(&TIM3_Handler); // HAL 判断是哪种中断、清标志位,再调回调
}
// ------------------------------------------------------------------------------
// 到点回调:中断里只干"最少的活"
// 只加一个数、翻一个灯;绝不在这里 printf、绝不 delay_ms------
// 中断一进来,主循环就停在原地了,这里干得越久,整个系统响应越迟钝。
// ------------------------------------------------------------------------------
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM3) // 确认是 TIM3 的更新中断(多个定时器会共用这个回调)
{
g_tick++; // 0.5 秒一个"节拍",真正的秒数在 main 里换算
LED1 = !LED1; // 翻一下 DS1,让人"看见"中断在跑
}
}
int main(void)
{
u32 sec = 0; // 已经打印过的秒数
HAL_Init(); // 初始化 HAL 库:顺带配好 1ms 时基
Stm32_Clock_Init(RCC_PLL_MUL9); // 系统时钟:倍频到 72MHz
delay_init(72); // 初始化延时函数
LED_Init(); // 初始化 8 个 LED(PC0~PC7),上电默认全灭
UART1_Init(115200); // 串口 1:只用来 printf 打印
TIM3_Init(5000 - 1, 7200 - 1); // TIM3:72MHz/7200=10kHz,数满 5000 个 → 0.5 秒一次中断
printf("\r\n");
printf("嵌入式技术应用设计实训 | 课时04 定时器:定时中断\r\n");
printf("DS1 由 TIM3 中断每 0.5 秒翻转;DS0 由主循环 delay_ms(200) 翻转\r\n\r\n");
while (1)
{
LED0 = !LED0; // DS0:靠软件延时闪(CPU 原地等 200ms)
delay_ms(200);
if (g_tick / 2 != sec) // 中断每 0.5 秒 +1,所以 2 个节拍 = 1 秒
{
sec = g_tick / 2; // 主循环只负责"把节拍换算成人看的秒"
printf("已经过 %u 秒\r\n", sec); // printf → fputc → uart_putc,和课时03 同一条路
}
}
}
HARDWARE/TIMER/timer.c、timer.h(本课为"空壳")
厂家把定时器代码放在这两个文件里,本课把它们搬进了 main.c,所以这两个文件只剩一段说明(原文见附录 B)。工程里保留它们,是为了不动 .uvprojx;实验四若想把定时器封装成自己的库,就从这里开始写。
代码里最值得盯住的 4 处
| 位置 | 为什么这么写 |
|---|---|
TIM3_Init() 的四步 |
开时钟 → 配参数 → 开中断通道 → 启动;和课时01 点灯、课时03 串口一个套路,看得见 |
volatile u32 g_tick |
中断里 +1、主循环里读;不加 volatile 可能永远读到旧值(秒数不动) |
HAL_TIM_PeriodElapsedCallback() 里只有两行 |
中断里只加一个数、翻一个灯,几微秒就出来;printf/delay_ms 一律不许进 |
g_tick / 2 放在主循环 |
中断只"数拍子",换算和打印交给主循环------中断里越少干活越好 |
三、实验操作与现象
3.1 接线与操作步骤
本课不用接杜邦线:LED 在板上,串口走板载 ST-Link 的虚拟串口(和课时03 一样,下载与串口共用那根 USB 线)。
| 设备 | 接到哪 | 说明 |
|---|---|---|
| 板载 ST-Link(下载 + 串口一体) | 板上标着 ST-Link 的 USB 口 → 电脑 USB 口 | 一根线管两件事:下载程序 + 串口收发;本课只用串口看 printf 打印的秒数 |
| 板上的 SWD 排针 | (本课程不接) | 备用口:只有换外接 下载器时才接 SWDIO / SWCLK / GND |
操作步骤(照着做就能跑):
- 上电接线 :板上标着 ST-Link 的 USB 口用一根线插到电脑(供电 + 下载 + 串口都走它),不用再接别的线
- Keil 打开工程 :
课时文档/代码/课时04-定时器:定时中断/定时器中断实验/USER/TIMER.uvprojx;Options for Target → Debug选ST-Link Debugger;到Target勾上Use MicroLIB(不勾,printf不出声) - 先自己算一遍 (这是本课的重点练习):
TIM3_Init(5000 - 1, 7200 - 1)→72MHz / 7200 = 10kHz→5000 × 0.1ms = 500ms - 编译 :
Build(F7)确认0 Error(s), 0 Warning(s) - 下载并连串口 :
Download(F8);串口助手选课时03 记下的那个 COM 号(STLink Virtual COM Port),115200-8-N-1,打开串口 - 按复位键看现象 :对照 3.2 逐条核对------重点看两个灯频率不一样 、秒数在递增
3.2 观察到的现象
本课现象以随课视频演示为准;文档不逐条配截图,你自己做完请拍一张或录一段,作为提交物。
| 看哪里 | 看到什么算成功 | 怎么核对 |
|---|---|---|
| 板上 | DS1 每 0.5 秒翻转一次 (约每秒明暗各一次);DS0 每 0.2 秒翻转一次(闪得快得多) | 两个灯频率明显不同、互不影响------这就是"中断在后台跑、主循环在台上跑" |
| 串口 | 上电两行欢迎语,随后每秒一行 :已经过 0 秒、已经过 1 秒、已经过 2 秒...... |
秒数连续递增、不跳号、不重复;哪怕主循环在忙着闪灯,秒数照样走 |
复位后的时间轴:
| 时刻 | 看到 |
|---|---|
| 0 ms | 串口出现两行欢迎语;DS0、DS1 都开始翻转 |
| 每 200 ms | DS0 翻转一次(软件延时驱动) |
| 每 500 ms | DS1 翻转一次(TIM3 中断驱动) |
| 每 1 s | 串口多一行"已经过 n 秒"(主循环把节拍换算出来打印) |

图注:串口每秒打印一行秒数,秒数由定时器中断在后台累加(截图待补)
看不到现象怎么办(按最可能的顺序查):
| 现象 | 最可能的原因 | 怎么处理 |
|---|---|---|
| 两个灯都不动、串口也不打印 | 定时器没启动,或中断通道没放行 | 检查 TIM3_Init() 里 HAL_NVIC_EnableIRQ(TIM3_IRQn) 和 HAL_TIM_Base_Start_IT() 都在 |
| DS1 不动,但 DS0 在闪 | 中断根本没进来 | 核对入口函数名 TIM3_IRQHandler 拼写(向量表按名字找它,写错就永远进不来) |
| 灯在闪,但秒数一直停在 0 | g_tick 没加 volatile,被编译器优化成"只读一次" |
补上 volatile 重新编译 |
| 串口一个字都没有 | 没勾 Use MicroLIB;或 COM 口 / 波特率选错 |
先确认 MicroLIB,再核对 COM 与 115200-8-N-1(详见课时03 排查表) |
| 灯闪的频率和算出来的不一样 | arr / psc 算错,或改完没重新编译下载 |
按 Tout=(arr+1)×(psc+1)/Ft 重算,Build+Download |
| 程序像卡住了、串口输出断断续续 | 在回调里加了 printf 或 delay_ms |
回调里只留 g_tick++ 和翻灯 |
执行前后对比(改一个数,看一眼差别)
| 操作 | 改之前 | 改之后 |
|---|---|---|
TIM3_Init(5000 - 1, 7200 - 1) → (10000 - 1, 7200 - 1) |
DS1 每 0.5 秒翻一次 | 每 1 秒翻一次(慢一倍)------证明"时间是算出来的" |
→ (1000 - 1, 7200 - 1) |
0.5 秒 | 0.1 秒(快得几乎看不清) |
psc 从 7200 - 1 改成 720 - 1(arr 不动) |
计数频率 10kHz | 计数频率 100kHz → 同样 5000 个数只要 50ms |
把 volatile 去掉、优化等级调到 Level 3 |
秒数正常递增 | 秒数可能一直不动 (灯照闪)------这就是 volatile 的作用 |
四、学生练习
课堂上按顺序做,一条一条改、一条一条看现象:
| 编号 | 任务 | 提示 | 预期结果 |
|---|---|---|---|
| P4.1 | 改时间 | 把 TIM3_Init(5000 - 1, 7200 - 1) 改成 (10000 - 1, 7200 - 1),再改成 (1000 - 1, 7200 - 1),各编译下载一次 |
DS1 分别是 1 秒、0.1 秒翻一次;能用公式把两次都算通 |
| P4.2 | 自己算一遍 | 要求 DS1 每 2 秒 翻转一次,arr 该填多少?(psc 仍用 7200 - 1) |
算得 arr = 20000 - 1(10kHz 下数 20000 个数 = 2 秒),改完现象对上 |
| P4.3 | 亲眼见证 volatile |
把 volatile 去掉,再把 Options for Target → C/C++ → Optimization 调到 Level 3,重新编译下载;看完记得改回来 |
灯还在闪,但串口秒数可能一直是 0 ------这就是不加 volatile 的后果 |
| P4.4 | 改成毫秒表 | 把 g_tick / 2 改成 g_tick * 500,打印改成"已经过 n 毫秒" |
串口每 0.5 秒打印一个 500 的倍数:0 / 500 / 1000 / 1500... |
| P4.5 | 亲手体验"中断里不能干重活" | 在回调里加一句 printf("tick\r\n");,重新编译下载,盯住主循环打印的节奏和灯闪 |
主循环明显被拖慢、秒数开始"跳号",灯闪也变怪;看完把这句删掉 |
| P4.6 | 挑战:中断只置标志 | 加 volatile u8 g_flag = 0;,回调里只写 g_flag = 1;(不再直接 g_tick++),主循环里 if (g_flag) { g_flag = 0; g_tick++; } |
现象和原来一样,但"中断只置标志、主循环干活"的分工更彻底------实验四的加分项就是这个 |
五、大作业(实验四:定时器秒表与 8LED 花样)
本作业对应课程大纲里的实验四「定时器秒表与 8LED 花样」,当堂完成、当堂验收;做不完的课后接着做,下次课开课先收。
5.1 任务书
把课上的"一个秒表 + 两个灯",做成"一个秒表 + 8 个灯的花样",两件事同时进行:
- TIM3 每 1 秒 进一次中断:
arr/psc自己算,把算式写在注释里 ;中断里只做sec++(可以顺便翻一个灯),不许printf、不许delay_ms - 主循环跑 8LED 花样 :DS0→DS7 依次点亮(每步 200 ms),再从 DS7→DS0 折回来,来回不停------就是课时01 大作业里那个
led_show(dir, ms) - 每跑完一个来回,用串口
printf打印一行sec = n(秒数来自中断里累加的那个变量) - 两件事互不干扰:花样照跑(不被秒表打断),秒数也照走(不因花样变慢,±1 秒以内)
- 中断里改、主循环里读的变量必须加
volatile - 编译输出
0 Error(s), 0 Warning(s)
加分项(任选,做几项算几项):
- "中断只置标志"写法 :回调里只写
g_flag = 1;,主循环看到g_flag才取数据、才打印(也就是 P4.6 那一套) - 用课时02 的按键做清零 / 暂停秒表:按 KEY0 把秒表归零,再打印一行"秒表已清零"
- 中断周期改成 0.1 秒 ,打印带一位小数的秒表:
10.3 秒这种
挑战项:
- 把 8LED 花样搬进中断(每 10 ms 中断换一个灯),主循环只剩打印------然后观察串口打印是否被打断、是否有字符丢失,并写一句话说明**"为什么重活不能放进中断"**
5.2 分步提示
- 先把秒表单独做出来 :把
TIM3_Init(5000 - 1, 7200 - 1)改成(10000 - 1, 7200 - 1)(1 秒),确认串口每秒打印一行 - 再接花样 :把课时01 大作业写的
led_show(dir, ms)放进主循环(led_show(0, 200); led_show(1, 200);),一圈来回约 3.2 秒 - 打印时机放在主循环 :每跑完一个来回打印一次
sec;千万不要在回调里打印 - 按键清零(加分) :把课时02 的
KEY_EXTI_Init()和HAL_GPIO_EXTI_Callback()一起搬过来------注意它和HAL_TIM_PeriodElapsedCallback()是两个不同名字的弱函数,不会打架;回调里只做sec = 0; - 最后自检 :
volatile加了没有?回调里是不是只有一两行?0 Warning有没有守住?
5.3 现象与验收标准
| 验收项 | 标准 |
|---|---|
| 秒表 | 串口每秒打印一行 sec = n,n 连续递增、不跳号、不错乱 |
| 花样 | DS0→DS7→DS0 来回流水,任意时刻只有 1 个灯亮,每步 200 ms |
| 并行 | 花样不停顿、秒数不变慢(两个来回之间秒数应增加 3~4 秒) |
| 中断里干净 | 回调里只有 sec++(和可选的翻灯 / 置标志),没有 printf、没有 delay_ms |
volatile |
中断里改、主循环里读的变量都加了 volatile |
| 编译下载 | 0 Error(s), 0 Warning(s);Programming Done. Verify OK. |
| 加分 / 挑战 | 按 5.1 里的规定逐项现场演示(做多少算多少) |
5.4 提交物
- 代码:
课时文档/代码/课时04-定时器:定时中断/定时器中断实验/USER/main.c - 现象:一段 20 秒以内的视频(能同时看到 8 灯流水和串口秒数)
- 一句话说明:"你的秒表为什么准?中断里为什么不打印?"
六、本节小结与下节预告
本节把定时器这条线完整走了一遍:先看清 delay_ms() 与硬件定时的区别 (一个让 CPU 原地数、一个让硬件自己数);再学会算定时时间 ------Tout = (arr+1)×(psc+1)/Ft,本课 psc = 7200-1 得 10 kHz、arr = 5000-1 得 500 ms ,而 TIM3 的 Ft 之所以是 72 MHz,是因为"APB1 分频不为 1 时,定时器时钟 = 2 × PCLK1";然后把中断入口 + 回调 这套写法第三次用了一遍(TIM3_IRQHandler → HAL_TIM_IRQHandler → HAL_TIM_PeriodElapsedCallback);最后把"中断只数拍子、主循环读数据干活 "这个分工跑成了现象:DS1 每 0.5 秒由中断翻转、DS0 每 0.2 秒由主循环翻转、串口每秒打印一行秒数。三句话记住本课:时间是算出来的 (arr/psc/Ft 三个量);中断里要短 (只加一个数,绝不 printf、绝不 delay_ms);中断改的变量要 volatile。下节进入 PWM:用定时器的比较输出,让灯的亮度"随手可调"(呼吸灯)。
本节函数与宏速查
| 名称 | 一句话作用 |
|---|---|
TIM3_Init(arr, psc) |
定时器初始化(本课写在 main.c):开时钟 → 配参数 → 开中断通道 → 启动 |
__HAL_RCC_TIM3_CLK_ENABLE() |
给 TIM3 使能时钟(不开时钟,后面全白写) |
HAL_TIM_Base_Init(&htim) |
把 Init.* 里的参数(psc / arr / 计数方向...)写进寄存器 |
HAL_TIM_Base_Start_IT(&htim) |
启动计数,并打开"更新中断"(到点就来敲门) |
HAL_NVIC_SetPriority(TIM3_IRQn, 1, 3) |
给 TIM3 中断排优先级:抢占 1、子 3(数值越大越低) |
HAL_NVIC_EnableIRQ(TIM3_IRQn) |
放行 TIM3 的中断通道 |
TIM3_IRQHandler() |
中断入口函数(向量表按名字找它),里面调 HAL_TIM_IRQHandler() |
HAL_TIM_IRQHandler(&htim) |
HAL 提供的"判断中断来源 + 清标志 + 转回调" |
HAL_TIM_PeriodElapsedCallback(htim) |
弱函数,定时器数满一圈时被调用;本课在这里 g_tick++ 和翻灯 |
volatile |
告诉编译器"这个变量可能被中断改,每次都要回内存重新读" |
delay_ms(n) |
软件延时(CPU 原地等),不能放进中断 |
附录
A. 怎么编译下载(照着做就能跑)
text
1. 接线:板子上标着 ST-Link 的 USB 口用一根线插电脑(供电 + 下载 + 串口都走它)
2. Keil 打开:课时文档/代码/课时04-定时器:定时中断/定时器中断实验/USER/TIMER.uvprojx
3. 选下载器:Options for Target → Debug → ST-Link Debugger → OK
4. 勾 MicroLIB:Options for Target → Target → 勾 Use MicroLIB(printf 才出声)
5. 编译 :Build(F7),确认 0 Error(s), 0 Warning(s)
6. 下载 :Download(F8),确认 Programming Done. Verify OK.
7. 看现象 :串口助手选对 COM 口、115200-8-N-1 并打开串口,按 RST:
DS1 每 0.5 秒翻转(TIM3 中断);DS0 每 0.2 秒翻转(主循环 delay_ms);
串口每秒打印一行"已经过 n 秒"
B. 本课代码
本课改两个文件、搬空两个文件(完整代码见 2.2 节)。工程里其余文件都是厂家例程现成的,不用动:
| 文件 | 作用 |
|---|---|
USER/TIMER.uvprojx |
Keil 工程文件(用 Keil 打开这个) |
USER/main.c |
本课主角 :UART1_Init()、TIM3_Init()、TIM3_IRQHandler()、HAL_TIM_PeriodElapsedCallback()、main() |
SYSTEM/usart/usart.h |
串口(只发送)接口:uart_send() 声明、UART1_Handler 外部声明 |
SYSTEM/usart/usart.c |
串口底层:uart_putc() / uart_send() / fputc()(沿用课时03 的"只发送"版本) |
HARDWARE/TIMER/timer.h、timer.c |
搬空了 ,只剩一段说明(代码已进 main.c);保留是为了不动 .uvprojx |
HARDWARE/LED/led.c、led.h |
LED 驱动:PC0~PC7 配成推挽输出并上电置全灭,提供 LED0~LED7 宏 |
HARDWARE/KEY/ |
厂家按键驱动(本课不用;实验四的加分项要按键清零时可以用课时02 的写法) |
SYSTEM/sys、SYSTEM/delay |
时钟配置、SysTick 延时(delay_ms) |
HALLIB/、CORE/ |
STM32F1 HAL 库与启动文件 |
OBJ/ |
编译输出(已加入 .gitignore,不进仓库) |
HARDWARE/TIMER/timer.h(完整代码)
c
#ifndef _TIMER_H
#define _TIMER_H
//////////////////////////////////////////////////////////////////////////////////
// 《嵌入式技术应用设计实训》课时04 定时器:定时中断
//
// 说明:本课把定时器的代码全部写在 USER/main.c 里------
// TIM3_Init() 定时器初始化(开时钟 → 配参数 → 开中断 → 启动)
// TIM3_IRQHandler() 中断入口
// HAL_TIM_PeriodElapsedCallback() 到点后干活的地方
// 一节课就能看全"初始化 → 中断 → 干什么",不用在文件之间跳。
//
// 所以本文件暂时只留这段说明,没有任何声明。
// 工程里保留 HARDWARE/TIMER 目录,是为了和厂家例程的工程结构一致(.uvprojx 不用改);
// 做实验四时若想把定时器封装成自己的库,就从这里开始加。
//////////////////////////////////////////////////////////////////////////////////
#endif
HARDWARE/TIMER/timer.c(完整代码)
c
//////////////////////////////////////////////////////////////////////////////////
// 《嵌入式技术应用设计实训》课时04 定时器:定时中断
//
// 本文件是厂家例程(定时器中断实验-HAL库版)里放定时器代码的地方,厂家写法是:
// TIM3_Init() 初始化
// HAL_TIM_Base_MspInit() 开时钟、设中断优先级(藏在 HAL 回调里)
// TIM3_IRQHandler() 中断入口
// HAL_TIM_PeriodElapsedCallback() 到点后翻 LED1
// 四个东西全在这个文件里,学生打开 main.c 只能看到一句 TIM3_Init(),
// "中断在哪、中断里干什么"完全看不见------课时02、课时03 已经踩过这个坑。
//
// 本课改法(与课时02、课时03 一致):把它们全部搬进 USER/main.c,看得见全链路:
// USER/main.c:UART1_Init() / TIM3_Init() / TIM3_IRQHandler() /
// HAL_TIM_PeriodElapsedCallback() / main()
//
// 所以本文件暂时为空,只留这段说明。工程里保留它,是为了不动 .uvprojx;
// 做实验四时如果想把定时器封装成自己的库(比如加 TIM3_GetTick()),就从这里开始写。
//////////////////////////////////////////////////////////////////////////////////
C. 定时器参数与时间速查(TIM3,Ft = 72 MHz)
psc |
计数频率 | arr |
定时时间 |
|---|---|---|---|
7200 - 1 |
10 kHz(每个数 0.1 ms) | 1000 - 1 |
100 ms |
7200 - 1 |
10 kHz | 2000 - 1 |
200 ms |
7200 - 1 |
10 kHz | 5000 - 1 |
500 ms(本课) |
7200 - 1 |
10 kHz | 10000 - 1 |
1 s |
7200 - 1 |
10 kHz | 20000 - 1 |
2 s |
720 - 1 |
100 kHz(每个数 0.01 ms) | 5000 - 1 |
50 ms |
36000 - 1 |
2 kHz(每个数 0.5 ms) | 2000 - 1 |
1 s |
提醒 :
psc和arr都是 16 位 (0 ~ 65535),也就是"最多数 65536 个数"。要定更长的时间,先用psc把频率降下来(本课要 1 秒,10 kHz 下arr = 10000 - 1就够了,不需要硬撑到 65535)。
相关文档
- 课程大纲(课时04 在"一、基础外设开发模块",实验四同名)
- 课程文档写作规范(0.1 教学环境约定)
- 上一课:课时03 串口通信:USART 收发与 printf
- 下一课:课时05 PWM 输出:频率与占空比