上个月把实验室那台老小车主控换成了 STM32F103C8T6,需求不复杂:两路 PWM 驱动 L298N 的两个电机,一路接红外接收头解 NEC 协议,另外还要读左右两个霍尔编码器测速。第一版代码写得很顺,想着 C8T6 上有四个定时器,一个功能分一个就够了。结果上车跑了十分钟,问题全来了——电机转速忽快忽慢,遥控十次里有两三次没反应,最要命的是里程数越跑越偏,直行两百米能歪出去半米。
一开始我以为是电机和编码器的机械问题,换了轮子、紧了联轴器都没用。后来把逻辑分析仪挂上去才看明白:不是代码逻辑写错了,而是 PWM、捕获、编码器这三种模式对定时器和引脚的要求完全不一样,我把它们当成三个可以随意摆放的模块,结果通道和引脚撞在了一起。
这篇把这件事拆开讲:三种模式在硬件上到底做了什么,HAL 该怎么写,以及各自有哪些不看手册就想不明白的坑。
一、三种模式共用一套时基,只是信号链不同
先建立一个统一的心智模型。一个定时器由计数单元和通道两部分组成:
- 计数单元:PSC 分频、CNT 计数、ARR 决定数到多少归零;
- 通道:每个通道有一个 CCR 寄存器,它和 CNT 比较,或者被外部边沿写入。
三种模式的区别,只是”谁在驱动谁”:
| 模式 | 数据方向 | 谁触发 | 典型用途 |
|---|---|---|---|
| PWM 输出 | CNT/CCR → 引脚 | 计数器自己 | 电机调速、LED 调光、舵机 |
| 输入捕获 | 引脚 → CCR 快照 | 外部边沿 | 测频率、测脉宽、解红外 |
| 编码器接口 | 引脚 → CNT 加减 | A/B 相边沿 | 测转速、测位移 |
所以配置顺序永远是先时基(PSC/ARR)、再通道(CCR/极性),最后才是启动。很多人(包括当时的我)一上来就抄通道配置,忘了时基决定了后面所有数字的含义。
二、PWM:频率和占空比是两个独立的旋钮
先看公式,这三个式子决定了所有参数:
- 计数频率
f_cnt = f_timer / (PSC + 1) - PWM 频率
f_pwm = f_cnt / (ARR + 1) - 占空比
= CCR / (ARR + 1)
我要 1 kHz 的 PWM 驱动 L298N。这里第一个坑就来了:F103 的 APB1 总线是 36 MHz,但挂在上面的定时器时钟会自动乘 2,是 72 MHz。如果按 36 MHz 去算分频,实际输出会差一倍,电机听起来会有一点点啸叫,用示波器一量才发现是 2 kHz。
#include "stm32f1xx_hal.h"
TIM_HandleTypeDef htim1; // TIM1 是高级定时器,CH1/CH2 = PA8/PA9
TIM_OC_InitTypeDef sConfigOC = {0};
void MotorPWM_Init(void)
{
htim1.Instance = TIM1;
htim1.Init.Prescaler = 71; // 72MHz / 72 = 1MHz 计数
htim1.Init.CounterMode = TIM_COUNTERMODE_UP;
htim1.Init.Period = 999; // 1MHz / 1000 = 1kHz
htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim1.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
HAL_TIM_PWM_Init(&htim1);
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 0;
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
HAL_TIM_PWM_ConfigChannel(&htim1, &sConfigOC, TIM_CHANNEL_1);
HAL_TIM_PWM_ConfigChannel(&htim1, &sConfigOC, TIM_CHANNEL_2);
HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); // 内部会顺带打开 MOE
HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_2);
}
/* 0~100 的百分比,转成 0~1000 的比较值 */
void Motor_SetSpeed(uint8_t percent)
{
if (percent > 100) percent = 100;
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, percent * 10);
}
void Motor_Stop(void)
{
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 0);
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_2, 0);
}
把 PWM 放在 TIM1 上不是随手选的。高级定时器有主输出使能(MOE)和刹车、死区这些功能,HAL 的 HAL_TIM_PWM_Start 会顺带把 MOE 打开;哪天要换成 H 桥做互补输出,不用换定时器也不用改引脚,加上死区时间就行。
第二个坑藏在 __HAL_TIM_SET_COMPARE 里。HAL 在配置通道时默认打开了 CCR 预装载(OCxPE 位),也就是说你写进 CCR 的值不会立刻生效,而是等下一个更新事件(计数器归零)才搬进影子寄存器。好处是不会在周期中间改出半个脉冲的毛刺,坏处是:如果你在一个周期里连写两次,实际只有最后一次算数。我在加减速算法里连续调了两次占空比,还以为第一次被”吃掉”是硬件坏了。
还有一点,L298N 是双极性驱动,方向靠另外两根 IO 控制,PWM 只负责大小。如果直接给 0% 就相当于”滑行”,急停要改成两边都拉低或者用刹车模式,这个和定时器无关,但确实是我第一次调试时”电机停不下来”的原因。
三、输入捕获:轮询一定会漏,因为你的主循环不够快
红外接收头解 NEC,一帧大约 67.5 ms,其中最短的位间隔是 560 µs。我最初图省事,在主循环里读 GPIO 电平、用 HAL_GetTick() 算时间。只要主循环里加一句 OLED 刷新或者串口打印,一次就是几毫秒,560 µs 的位宽根本记不住,遥控自然时灵时不灵。
捕获模式的思路完全不同:边沿到来时,硬件把当前 CNT 的值直接抄进 CCR,CPU 只需要事后去取。误差只取决于定时器时钟,和主循环忙不忙毫无关系。
static volatile uint16_t lastEdge = 0;
static volatile uint8_t phase = 0; // 0: 等引导码下降沿,1: 已见到引导码
static volatile uint8_t bitCount = 0;
void IR_Init(void)
{
htim2.Instance = TIM2;
htim2.Init.Prescaler = 71; // 同样 1MHz,1 个计数 = 1us
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 0xFFFF;
HAL_TIM_IC_Init(&htim2);
TIM_IC_InitTypeDef sIC = {0};
sIC.ICPolarity = TIM_INPUTCHANNELPOLARITY_FALLING;
sIC.ICSelection = TIM_ICSELECTION_DIRECTTI;
sIC.ICPrescaler = TIM_ICPSC_DIV1;
sIC.ICFilter = 10; // 滤掉接收头上的毛刺
HAL_TIM_IC_ConfigChannel(&htim2, &sIC, TIM_CHANNEL_1);
HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1);
}
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance != TIM2) return;
uint16_t now = (uint16_t)__HAL_TIM_GET_CAPTURE1(htim);
uint16_t width = (uint16_t)(now - lastEdge); // 无符号减法自动处理回绕
lastEdge = now;
if (phase == 0) { // 9ms 低电平引导码
phase = 1;
} else {
NecBit_OnPulse(width); // 560us 为 0,1690us 为 1
if (++bitCount >= 32) {
bitCount = 0;
phase = 0;
}
}
/* F103 的通用定时器没有 CCxNP 位,硬件双边沿只在 TIM1 上有,
所以这里在中断里手动切换下一次要捕获的边沿 */
__HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1,
(phase & 1) ? TIM_INPUTCHANNELPOLARITY_RISING
: TIM_INPUTCHANNELPOLARITY_FALLING);
}
这段代码有三个值得说的地方。为了把重点放在捕获上,位判断只保留了骨架,实际解码时还要用宽度区分 4.5 ms 的引导高电平和 560/1690 µs 的数据位,这里用 NecBit_OnPulse 概括了。
第一,uint16_t 的无符号减法天然处理了 16 位计数器回绕。now - lastEdge 即使跨过了 0xFFFF 也是对的,不需要额外判断方向。
第二,切换极性是在中断里做的,这要求两次边沿之间的间隔大于中断响应时间。NEC 最短 560 µs,完全够用;但如果拿这套代码去解 38 kHz 载波或者更快的信号,就得上 TIM1 的硬件双边沿,或者干脆用 DMA 采样。
第三,帧与帧之间有 40 ms 以上的空闲。如果不处理,下一帧的第一个下降沿会被当成”上一帧最后一位的延续”,宽度算出来是个荒唐的大数。我的做法是开更新中断,在 HAL_TIM_PeriodElapsedCallback 里看计数是否超过某个阈值,超了就复位状态机。这也是我一开始漏掉的,表现是”按一次键出两个码”。
四、编码器模式:把计数交给硬件,别用 EXTI 硬扛
编码器最初我是用外部中断做的:A 相上升沿进一次中断,读 B 相电平判断方向,自己加一减一。台面上手拨轮子一切正常,装到车上跑起来里程就偏小,而且速度越快偏得越多。
原因很直接。第一,中断太频繁,两个轮子同时转,每转一圈几十个脉冲乘四倍频,CPU 大量时间在进出中断;第二,中断响应不及时就会漏掉边沿,而漏掉的计数补不回来。这类”低速准、高速偏小”的现象,基本可以直接怀疑是软件计数丢脉冲。
编码器模式是另一条路:A/B 两相的边沿直接驱动 CNT 加减,全程不经过 CPU。
void Encoder_Init(void)
{
TIM_Encoder_InitTypeDef sEnc = {0};
htim4.Instance = TIM4;
htim4.Init.Prescaler = 0; // 编码器不要分频
htim4.Init.CounterMode = TIM_COUNTERMODE_UP;
htim4.Init.Period = 0xFFFF;
htim4.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim4.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
sEnc.EncoderMode = TIM_ENCODERMODE_TI12; // 4 倍频
sEnc.IC1Polarity = TIM_ICPOLARITY_RISING;
sEnc.IC1Selection = TIM_ICSELECTION_DIRECTTI;
sEnc.IC1Prescaler = TIM_ICPSC_DIV1;
sEnc.IC1Filter = 10;
sEnc.IC2Polarity = TIM_ICPOLARITY_RISING;
sEnc.IC2Selection = TIM_ICSELECTION_DIRECTTI;
sEnc.IC2Prescaler = TIM_ICPSC_DIV1;
sEnc.IC2Filter = 10;
HAL_TIM_Encoder_Init(&htim4, &sEnc);
HAL_TIM_Encoder_Start(&htim4, TIM_CHANNEL_ALL);
}
/* 每 10ms 调一次,返回这 10ms 内的增量 */
int16_t Encoder_ReadDelta(void)
{
int16_t delta = (int16_t)__HAL_TIM_GET_COUNTER(&htim4); // 16 位读是单条指令
__HAL_TIM_SET_COUNTER(&htim4, 0);
return delta;
}
TIM_ENCODERMODE_TI12 是四倍频:A、B 两相各两个边沿都计数,所以一圈的计数是编码器线数的四倍。分辨率上去了,代价是低速时更容易被抖动误触发,所以滤波器 IC1Filter 不能省——我在电机静止时看到 CNT 偶尔自己跳一两个数,就是引脚上的噪声。
读计数这里也有个细节:读数用 int16_t 强制转换,是因为反向旋转时 CNT 会从 0 减到 65535,按有符号解释就是 -1。不清零、只算两次读数之差也完全可以,而且不会在”读”和”清”之间丢掉一个计数;但只要周期固定,每次最多丢 ±1,对测速几乎没影响。真正要小心的是长时间不清零导致 16 位回绕,跑了很久之后增量会突然变成一个大数,我一开始没做类型转换,看到速度表瞬间爆表就是这么来的。
五、我的定时器分配与选型对照
三个功能都理清之后,分配就变成了资源问题。F103C8T6 上能用的定时器就那几个,还要和引脚复用一起考虑。
| 功能 | 定时器 | 默认引脚 | 为什么选它 |
|---|---|---|---|
| 双电机 PWM | TIM1 CH1/CH2 | PA8 / PA9 | 高级定时器,两通道同频,以后加死区不用换 |
| 红外 NEC 捕获 | TIM2 CH1 | PA0 | 16 位、1 MHz 计数足够,单独占用便于调状态机 |
| 左轮编码器 | TIM3 CH1/CH2 | PA6 / PA7 | 通用定时器,两通道刚好够一对 A/B 相 |
| 右轮编码器 | TIM4 CH1/CH2 | PB6 / PB7 | 同上,两个轮子必须各占一个定时器 |
两条硬约束必须记住:
- 编码器模式占用的是”通道对”。你不可能用 TIM3 的 CH1 输出 PWM、同时用 CH1 和 CH2 做编码器,因为 CH1 的 CCR 只能有一个用途。两个轮子也同理,一套 A/B 相就要吃掉一个定时器,这就是三个功能和四个定时器刚好对上的原因。这也是我第一版失败的直接原因——PWM 和左轮编码器都在 TIM3 上,编译没问题,运行起来就是互相抢通道。
- PB6/PB7 默认还是 I2C1 的 SCL/SDA。如果板子上还挂着 OLED,要么把 I2C1 重映射到 PB8/PB9,要么把编码器换到 TIM2 的 CH1/CH2(PA0/PA1),然后红外改用别的口。这张引脚表建议在画板子之前就画完,别等焊完了才发现冲突。
调完之后还有一个不是定时器的问题值得提一句:电机在很低的占空比下不转。不是 PWM 配错了,而是这个占空比下电机绕组里的平均电流还克服不了静摩擦和 L298N 自身的压降。所以调速要给一个死区补偿,低于某个阈值直接给最小值,否则摇杆推了一小段车不动,推过某个点突然窜出去,手感非常差。
小结
三种模式看起来是三个外设功能,本质上是同一条信号链的三种方向:PWM 是计数器写引脚,捕获是引脚写寄存器,编码器是引脚直接驱动计数器。配置前先把时基算清楚(尤其别忘了 APB1 定时器时钟乘 2),再把”谁独占哪个通道”分配好,剩下的都是细节。至于那些不看手册绝对想不到的点——CCR 预装载、通用定时器没有 CCxNP、16 位 CNT 的回绕——踩过一次,下次画原理图的时候就会先想起来了。
评论与讨论
使用 GitHub 账号登录即可留言(首次使用需在 giscus 中授权)