平台:STM32F103ZET6 @ 72MHz · LED=PB5(低电平点亮)· 工程名 tim_led 三个阶段是同一条思路的递进:每一步都把上一课软件干的活移交给硬件
阶段一:时基——1ms 定时,LED 1Hz 闪烁(软件计数版)
思路
先解决"HAL_Delay 会阻塞 CPU"的问题:让定时器硬件自己数数,数满 1ms 敲一次门(中断),CPU 平时自由,只在被敲门时干一小点活。LED 要 1Hz 闪(亮 500ms 灭 500ms),但定时器只会每 1ms 响一次铃——"500ms"这个大时间尺度由软件在回调里数数实现:攒 500 个 1ms 就翻转一次引脚。
CubeMX 配置
| 项 | 值 | 算法 |
|---|---|---|
| TIM2 Clock Source | Internal Clock | 用内部 72MHz |
| Prescaler (PSC) | 71 | 72M ÷ (71+1) = 1MHz,每格 1µs |
| Counter Period (ARR) | 999 | 1000 格 × 1µs = 1ms 溢出一次 |
| NVIC | TIM2 global interrupt 打勾 | 放行中断线 |
注意 +1 陷阱:PSC/ARR 都从 0 数起,所以 71 是"72 档"、999 是"1000 格"。TIM2 挂 APB1(名义 36M),但定时器走 ×2 后门实际吃 72MHz。
完整代码
/* USER CODE BEGIN 2 */
HAL_TIM_Base_Start_IT(&htim2); // 拧发条:CEN=1 开始数数,UIE=1 数满允许敲门
/* USER CODE END 2 */
/* USER CODE BEGIN 4 */
// TIM2 每 1ms 进来一次(中断上下文:快进快出,只做加减和翻转)
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2) {
static uint16_t ms = 0; // static:跨中断保留进度
if (++ms >= 500) { // 攒满 500 个 1ms
ms = 0;
HAL_GPIO_TogglePin(led1_GPIO_Port, led1_Pin); // 翻转 → 1Hz 闪烁
}
}
}
/* USER CODE END 4 */
逐行讲解
HAL_TIM_Base_Start_IT(&htim2):CubeMX 的MX_TIM2_Init只负责"装填参数"(PSC、ARR 写进寄存器),此时计数器是静止的(CEN=0)。这一行干两件事:CEN=1(开始数)+ UIE=1(允许 Update 事件触发中断)。不调它,配置再对也是死表。if (htim->Instance == TIM2):HAL_TIM_PeriodElapsedCallback是全局弱符号回调——将来你开 TIM3、TIM4,它们的 Update 事件也进这同一个函数。不判断是谁,就会串台。static uint16_t ms = 0:本阶段的灵魂。普通局部变量每次函数返回就消失,static让它活过函数返回——每次中断进来,ms 都是"上次的值"。没有 static,ms 永远从 0 数起,永远到不了 500,灯永远不闪。if (++ms >= 500):前缀自增(先加再比)。500 个 1ms = 500ms。HAL_GPIO_TogglePin:翻转 PB5。亮 500ms、灭 500ms,1Hz。- 中断纪律:回调里只做加减和写引脚——中断上下文必须快进快出(拖久了下一次 1ms 到点就堆积)。
现象与本质
LED 1Hz 闪烁。本质:硬件管"1ms 这个小周期",软件数"500 次这个大尺度"。缺点也明显:每秒 1000 次中断,只为数一个数。
阶段二:输出比较——双门槛定点亮灭(零计数版)
思路
阶段一里"500ms"靠软件数——能不能让硬件直接在 500ms 处响铃?输出比较(OC)就是给 CNT 再加一道门槛 CCR:CNT 数到 CCR 的瞬间,Compare 事件爆发。于是周期里有两道硬件门槛:
- Update 事件(CNT 溢出归零,t=0s)→ 点亮
- Compare 事件(CNT 数到 CCR1,t=0.5s)→ 熄灭
回调里一个计数器都不需要——两道门槛把时间点定死,软件只负责"听到铃声做动作"。这就是 PWM 的手工版。
CubeMX 配置
| 项 | 值 | 算法 |
|---|---|---|
| TIM2 Channel1 | Output Compare No Output | 只比较不占引脚(TIM2_CH1=PA0 是 WKUP 按键脚,不能碰) |
| Prescaler (PSC) | 7199 | 72M ÷ 7200 = 10kHz,每格 0.1ms |
| Counter Period (ARR) | 9999 | 10000 格 = 1s 周期 |
| Pulse(CCR1 的界面名) | 5000 | 第 5000 格 = 500ms 处响铃 |
为什么 PSC 从 71 改成 7199?ARR 是 16 位(最大 65535),1µs 粒度最多数 65.5ms——凑 1 秒周期必须先降频。这就是 (PSC, ARR) 的因式分解自由度:先定粒度,再凑周期。
完整代码
/* USER CODE BEGIN 2 */
HAL_TIM_Base_Start_IT(&htim2); // 数数 + Update中断(周期起点事件)
HAL_TIM_OC_Start_IT(&htim2, TIM_CHANNEL_1); // 再开CH1比较中断(CCR门槛事件)
/* USER CODE END 2 */
/* USER CODE BEGIN 4 */
// Update事件:CNT溢出归零的瞬间 = 周期起点 → 点亮
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2)
HAL_GPIO_WritePin(led1_GPIO_Port, led1_Pin, GPIO_PIN_RESET); // 低电平=亮
}
// Compare事件:CNT数到CCR1的瞬间 → 熄灭
void HAL_TIM_OC_DelayElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1)
HAL_GPIO_WritePin(led1_GPIO_Port, led1_Pin, GPIO_PIN_SET); // 高电平=灭
}
/* USER CODE END 4 */
逐行讲解
- 两声令:
HAL_TIM_Base_Start_IT开 Update 中断许可(UIE=1);HAL_TIM_OC_Start_IT开 CH1 的 Compare 中断许可(CC1IE=1)+ 通道使能。两个 Start 各管各的许可灯,缺一个就少一种铃声。 - 一个入口,两个回调:两种事件走同一个
TIM2_IRQHandler,HAL 在HAL_TIM_IRQHandler里查标志分家——UIF 走PeriodElapsedCallback,CC1IF 走OC_DelayElapsedCallback。 - OC 回调多判一个
htim->Channel:4 个通道共用一个 OC 回调,Channel告诉你是哪个通道在响(这里必须确认是 CH1)。 - 两个回调名字都是弱符号:名字一字不差才构成覆盖。笔误一个字母 = 函数成孤儿 = 编译零警告、现象纹丝不动(真实踩过:
HLA_TIM_PeriodElapsedClallback)。防御:从 HAL 源码抄、用 VSCode 补全敲、写完 grep 核对。 - 写引脚用 SET/RESET 而不是 Toggle:亮灭动作分别绑定在两道门槛上,语义清晰——这正是 PWM"起点置高、比较处置低"的雏形。
现象与本质
现象和阶段一一样是 1Hz 闪烁,但内核换了引擎:中断从 1000 次/秒降到 2 次/秒(CPU 占用差 500 倍),定时精度从"受中断延迟影响"变成"硬件门槛精准到格"。
阶段三:PWM——硬件方波 + 呼吸灯(最终版)
思路
两个硬件事实决定了方案:
- 硬件 PWM 的规则:通道配成 PWM 模式后,硬件自动执行"CNT < CCR → 引脚高,CNT ≥ CCR → 引脚低"——零中断零 CPU。用 TIM3_CH3(PB0)做硬件 PWM 演示(PA6/PA7 被板载 Flash 占用,选脚先查板载外设)。
- LED 在 PB5——不属于任何定时器通道,硬件 PWM 够不着它 → 呼吸灯必须软件 PWM。
软件 PWM 的架构是载波与包络分层:
- 载波层:阶段二的双门槛机器,把周期从 1s 缩到 10ms(100Hz)——这个频率不能慢,慢了眼睛看见闪烁
- 包络层:Update 回调每个载波周期推一下占空比 duty(±1,在 0↔100 折返),用
__HAL_TIM_SET_COMPARE动态写进 CCR1 - 眼睛+LED 是低通滤波器:100Hz 快速亮灭只留平均亮度,正比占空比 → duty 扫 0↔100,灯就呼吸
CubeMX 配置
| 项 | 值 | 算法 |
|---|---|---|
| TIM2 Prescaler | 7199(不变) | 每格 0.1ms |
| TIM2 Counter Period | 99 | 100 格 × 0.1ms = 10ms = 100Hz 载波 |
| TIM2 Pulse | 0 | 代码每周期重写,初值无所谓 |
| TIM3 Clock Source | Internal Clock | |
| TIM3 Channel3 | PWM Generation CH3 | 引脚自动分配 PB0 |
| TIM3 PSC / ARR / Pulse | 7199 / 9999 / 5000 | 1Hz、50% 占空比(人眼级慢动作,方便调试器采样验证) |
| TIM3 NVIC | 不勾 | 硬件 PWM 零中断——和 TIM2 软件版的本质区别 |
关键刻度对齐:duty 是 0~100 的百分数,直接写进 CCR1,所以 ARR 必须=99——在 ARR=99 的世界里 100/100=100%,在 ARR=9999 的世界里 100/10000 只有 1%(真实踩过:PSC/ARR 改反,呼吸在 1% 亮度里扫,肉眼等于没亮)。占空比是比值,duty 的尺子必须和 ARR 同刻度。
完整代码
/* USER CODE BEGIN PV */
uint8_t duty = 0; // 当前占空比 0~100(呼吸的"肺活量")
int8_t dir = 1; // 呼吸方向:+1 渐亮,-1 渐暗
/* USER CODE END PV */
/* USER CODE BEGIN 2 */
HAL_TIM_Base_Start_IT(&htim2); // TIM2:呼吸灯的软件PWM机器(阶段二原样)
HAL_TIM_OC_Start_IT(&htim2, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3); // TIM3:PB0 硬件PWM,零中断
/* USER CODE END 2 */
/* USER CODE BEGIN 4 */
// Update事件 = 每个PWM周期(10ms)的起点:推进呼吸坡度,装填本周期占空比
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim){
if(htim->Instance == TIM2){
duty += dir; // 坡度每周期 ±1
if(duty >= 100){ duty = 100; dir = -1; } // 亮到头,折返渐暗
if(duty <= 0) { duty = 0; dir = +1; } // 暗到头,折返渐亮
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, duty); // 门槛=本周期占空比
if(duty == 0)
HAL_GPIO_WritePin(led1_GPIO_Port,led1_Pin,GPIO_PIN_SET); // 0%:直接灭
else
HAL_GPIO_WritePin(led1_GPIO_Port,led1_Pin,GPIO_PIN_RESET); // >0%:先亮,等Compare来灭
}
}
// Compare事件 = CNT数到duty处:熄灭(与阶段二一字不差)
void HAL_TIM_OC_DelayElapsedCallback(TIM_HandleTypeDef *htim){
if(htim->Instance == TIM2 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1){
HAL_GPIO_WritePin(led1_GPIO_Port,led1_Pin,GPIO_PIN_SET);
}
}
/* USER CODE END 4 */
逐行讲解
- PV 区两个变量:
duty是当前占空比(呼吸的"肺活量"),dir是方向(+1 渐亮 / −1 渐暗)。两个变量合起来就是一个微型呼吸状态机。 HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3):硬件 PWM 的启动只要一行,连 NVIC 都不用配——波形完全由硬件按"CNT<CCR3 输出高"的规则自己画,CPU 零参与。duty += dir+ 两个折返 if:三角波坡度。duty 到 100 掉头向下、到 0 掉头向上,0↔100 来回扫。呼吸节奏 = 200 步 × 10ms = 2 秒一个呼吸。__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, duty):呼吸的全部秘密——CCR 不是死的,运行中随时可改。这个宏把新 duty 写进 CCR1,本周期后半段 Compare 事件就按新门槛触发。if (duty == 0)特判:duty=0 时 CCR1=0,Compare 事件和 Update 在 CNT=0 同一瞬间爆发,而 HAL 先处理 Compare 后处理 Update——不特判,0% 会变成常亮(阶段二调试时那个"常亮 bug"的缩小版)。所以 0% 时 Update 回调里直接写灭,不指望 Compare。else分支先亮:>0% 时周期起点先点亮,熄灭的活儿留给 Compare 事件在"数到 duty"处干——亮多久完全由 duty 说了算。- Compare 回调:与阶段二一字未改——这就是分层的红利,载波机器不用动,只换包络逻辑。
现象与本质
- PB5 呼吸灯:约 2 秒一个渐亮渐暗循环,亮度平滑变化
- PB0 硬件 PWM:1Hz、50% 方波(调试器实时采样引脚电平验证:6 次采样 0/1 完美交替,PSC/ARR/CCR 寄存器读数 7199/9999/5000 与计算一致)
三阶段演进总览
| 阶段一(时基) | 阶段二(输出比较) | 阶段三(PWM 呼吸灯) | |
|---|---|---|---|
| 事件源 | Update × 1 | Update + Compare | 同阶段二 + 动态改 CCR |
| 回调里干的事 | 数 500 个 ms | 直接写引脚,零计数 | 推坡度 + 写引脚 |
| 中断频率 | 1000 次/秒 | 2 次/秒 | 200 次/秒(载波)+ 0(硬件 PWM) |
| 定时精度 | 受中断延迟影响 | 硬件门槛,精准到格 | 同左,占空比运行中可调 |
| 本质 | 硬件数周期,软件数精细 | 硬件记两个时间点 | 载波/包络分层,CPU 只管节奏 |
一句话总结整个模块:定时器的学习史就是一部"移交史"——把"等"从 CPU 移交给硬件(阶段一),把"数"移交给门槛(阶段二),把"开关引脚"移交给比较器(阶段三的硬件 PWM);CPU 最后只剩一件体面的活:决定呼吸的节奏。