做电机转速测量,我踩过最深的坑,就是用外部中断去数编码器脉冲。转速一起来,中断风暴直接拖垮了串口打印和屏幕刷新,明明编码器是 200 线,测出来的转速却一卡一顿,甚至白丢几十个脉冲。后来换成 STM32CubeMX 直接配置定时器的编码器模式(Encoder Interface Mode),转速测量才真正变成“读寄存器”这种级别的操作。
这篇文章把编码器模式从硬件原理、CubeMX 配置、代码实现到避坑清单完整串一遍。适合正在做小车、云台、转台闭环,或者任何需要稳定测量电机转速的嵌入式玩家。尤其是对定时器只有基础认识、想搞明白“为什么这样配”的新手,这篇应该能帮你省下至少一个星期的弯路。
1. 为什么测速不要再去数外部脉冲:编码器模式解决的三个核心问题
1.1 外部中断丢脉冲:一次真实的过载现场
先算一笔账。增量式编码器常见的规格是 200 线,意思是电机轴每转一圈,A 相和 B 相各输出 200 个完整方波周期。一般直流减速电机空载转速可能到 3000rpm,也就是每秒 50 圈。如果只数 A 相上升沿,每秒进入中断的次数就是:
50 × 200 = 10000 次/秒
也就是 10kHz 的中断频率。这还不算四倍频。如果要做四倍频,每秒边沿是 50 × 200 × 4 = 40000 次,40kHz。每次外部中断里都要读 GPIO、判断方向、累加计数,还要考虑抖动过滤,实际每次中断耗时普遍在 2~5us。简单乘一下,40kHz 的中断,光中断服务函数就占掉 CPU 8%~20% 的资源。更麻烦的是,中断里工作没做完的同时,下一次边沿已经到了,如果 MCU 没有及时响应,脉冲就丢了。
方向判定的问题更隐蔽。在外部中断里判方向,常见写法是:检测到 A 相上升沿时,读 B 相的电平,B 为高是正转,B 为低是反转。但四倍频模式下每个边沿都要判方向,如果中断响应有延迟,读 B 相时它正好处于电平变化的前后沿,读到的既不是高也不是低,方向就错了。对,我知道可以加消抖,但消抖一多,高速脉冲又被滤掉,这是一个怎么调都别扭的死循环。
1.2 硬件自动判向与倍频计数:编码器模式的底层逻辑
STM32 定时器的编码器接口模式,本质上把上面这些乱七八糟的事全部搬进了硬件。编码器 A/B 两路信号直接进定时器捕获通道 CH1/CH2,定时器根据两路信号的相位关系自动确定方向:相位领先的信号决定计数方向,正转时计数器向上加,反转时向下减。
在 CubeMX 里选择编码器模式后,有一个 Encoder Mode 下拉框,选项是 TI1、TI2、TI1 and TI2。这三个选项的分辨率不一样:
- TI1:只在 TI1(通常对应 A 相)的每个边沿计数,每转的计数脉冲数 = 编码器线数 × 2,等效二倍频。
- TI2:同理,只在 B 相每个边沿计数,也是二倍频。
- TI1 and TI2:A、B 两相每个边沿都计数。200 线编码器每转就是 200 × 4 = 800 个计数脉冲,等效四倍频。
看到这里你应该明白,硬件四倍频不需要你写任何中断判断代码,计数器自己就会根据两个输入信号的电平和边沿组合去加减。CPU 只要在需要的时候读一下计数值,其他时间可以安心去跑 PID、刷 OLED,完全不用担心丢脉冲。
顺带说一句:编码器模式下计数器时钟不再来自内部时钟树,而是由输入信号边沿直接驱动。所以这项工作的本质不是“定时器在计时”,而是“定时器在数信号沿”。理解了这一点,后面很多配置选项就不会误解。
1.3 方向信息和机械零位是白送的
软件数脉冲时,方向判断是有额外成本的。编码器模式下,方向判断完全由硬件完成,你随时可以读 TIMx->CR1 的 DIR 位,甚至用宏直接判断:
if (__HAL_TIM_IS_TIM_COUNTING_DOWN(&htim3)) { // 当前是反转 } else { // 当前是正转 }这对那些需要检测反转、统计正反转圈数、或者做堵转保护的项目来说,算是白送的额外收益。编码器本身就是增量式的,没有绝对位置,但结合这个方向位,至少能准确知道当前轴往哪转、从某个参考点开始转了多少度。如果需要绝对零点,再加一根 Z 相索引信号,这里先不展开。
理解了这个底层逻辑,你就知道为什么网上几乎所有靠谱教程都会说“编码器模式是工业测速的正确打开方式”。不是因为它炫,而是因为它把软件里最难处理的边沿响应问题,交给了一个永远不会丢边沿的硬件逻辑单元。
2. STM32CubeMX 配置实操:从选定时器到生成工程的完整姿势
2.1 定时器选型:不是所有 TIM 都能当编码器口
编码器模式不是所有定时器都支持。基本定时器 TIM6、TIM7 只有时基功能,没有捕获通道,想都不要想。通用定时器 TIM2/3/4/5 是最常见的选择,高级定时器 TIM1/8 也支持,但高级定时器还带着刹车输入、死区补偿这些复杂功能,如果工程里并不需要这些,没必要非用它们,反而容易在 CubeMX 里多出一些无关配置。
以最常用的 STM32F103C8T6 为例,我一般用 TIM3,编码器信号接 PA6 和 PA7,对应的是 TIM3_CH1 和 TIM3_CH2。也可以选 TIM4,对应 PB6、PB7。选引脚时注意不要和下载接口、外部晶振、板上的按键 LED 冲突,否则后面调试起来很闹心。
CubeMX 里操作方法:在左侧 Categories 找到 Timers → TIM3,Mode 里 Combined Channels 选 Encoder Mode。此时下方 Parameter Settings 会自动出现编码器相关选项,同时 PA6/PA7 自动变成复用功能状态。如果你用的是别的芯片,引脚会自动分配,别急着改,先生成看看好不好布线,再决定要不要换。
2.2 关键选项逐项解释:Encoder Mode、Polarity 和 Filter
进入 Encoder Mode 后,有三个选项是你必须理解清楚的。
第一个是 Encoder Mode 下拉框,就是上一节说的 TI1、TI2、TI1 and TI2。日常做电机测速,我强烈建议选 TI1 and TI2,直接四倍频吃满分辨率。只有在某些特定场景,比如编码器线数非常高、转速也非常高,计数脉冲频率已经逼近你的采样或处理能力时,才考虑退回 TI1 或 TI2 用二倍频降一降数据量。
第二个是 Polarity。它的本质是决定触发边沿的有效极性。当你发现电机正转时计数值莫名其妙在往下减、反过来却在往上加,不必去拆电机换线,直接在 CubeMX 里把某一个通道的 Polarity 反转一下即可。但要注意:不要两个通道同时反,那样方向又会被翻回去。
第三个是输入滤波 ICF。CubeMX 里在 GPIO 配置处可以给两个通道都设置 Input Filter,对应的是定时器里的 IC1F/IC2F 字段。这个滤波器的原理是以内部时钟为基准对输入信号连续采样,只有连续 N 次采样到同一电平才算有效,从而滤掉短毛刺。电机电刷、驱动器 PWM 干扰都可能产生毛刺,有滤波会安心很多。
但滤波不是越大越好。采样窗口越长,允许通过的最高信号频率就越低。高速电机如果用了一个过大的滤波系数,计数值会偏差,转速曲线像被钳住了一样上不去,你还以为是机械问题,其实是滤波器在丢边沿。我的经验是:先用 0 跑通,确认波形干净再加滤波;需要滤波时,从 2~4 这类小值开始试,不要一上来就拉到最大档。
2.3 PSC 与 ARR 的设定,为什么网上教程都让你填 0 或 65535
新建 CubeMX 编码器配置时,Prescaler 默认是 0,这没有错,千万别乱改。编码器模式下计数器时钟由外部边沿直接驱动,预分频器在这里的分频行为和你做 PWM 时完全不是一回事。如果你给 PSC 填了个 71,你会发现计数器要么几乎不动,要么计数行为完全超出预期,排查半天才发现是分频系数搞错了。记住:正常测速项目里,PSC 就保持 0。
自动重装载 ARR 就有讲究了。常见做法分两派。
做法一是保持 ARR 为默认最大值 65535(16 位定时器),每次采样读当前计数值、和上一次值做差。这种情况下计数器就是个“长了腿”的累加器,正转增加,反转减少,差值天然反映位移。配合 int16_t 类型转换,还能自动处理计数溢出产生的回绕。
做法二是把 ARR 设为“每转一圈的计数脉冲数减一”。比如 200 线编码器、四倍频、每转 800 个计数脉冲,ARR 就设 800 - 1 = 799。这样计数器从 799 溢出回到 0,每次溢出都严格表示“电机转了一圈”,在计圈数场景下非常直观。
我自己的项目里,大部分时候用做法一。原因是做法二在电机频繁正反转来回摆动时,溢出中断里还得判断方向才能确定是加一圈还是减一圈,逻辑容易绕糊涂。做法一在读差值时不关心它是不是刚好整圈,数值量大,配合软件协议随便截取。唯一要注意的是采样间隔内差值不能超过 int16_t 范围,也就是下一次采样前计数值变化不能超过 ±32767。以四倍频 800 脉冲/转算,两次采样之间转了 40 圈之内都不会出问题,绝大多数应用根本够用。
2.4 生成代码后第一件事:核对 GPIO 和初始化顺序
CubeMX 生成工程后,不要急着写业务代码,先做两件小事。
第一件,核对 GPIO 是不是复用功能模式。正确状态下,两个编码器输入引脚应该是 Alternate Function,TIM3 的 AF 映射对应 GPIO_AF1_TIM3(F103 上是 AF2,不同系列不一样)。如果你看到引脚变成了普通的 Input,那是配置没生效,计数一定不正常。
第二件,看一下生成的 MX_TIM3_Init 里 htim3.Init.Period 是否是你想要的 ARR 值。F1 系列的定时器寄存器是 16 位,如果你在 CubeMX 里填了个超过 65535 的数,写入寄存器时会静默截断,这个坑特别隐蔽。生成完代码后在 CubeMX 里看到 ARR 是 100000,实际运行却每次到 34464 就溢出,这种情况我碰到过不止一次。
还要检查 NVIC 里 TIM3 global interrupt 是否勾选。如果你后面要用溢出中断扩展 32 位计数,这一步漏了,代码里再怎么调回调函数都无效,因为中断根本没进。
3. 转速计算实战:从计数值到稳定 RPM 的代码链路
3.1 读取当前计数值的两种方式
完成上面的配置,启动编码器接口只需要一行:
HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL);注意第二个参数,官方推荐在编码器模式下传 TIM_CHANNEL_ALL,让两个通道同时接入。如果只传 TIM_CHANNEL_1,某些系列上另一路信号是断开的,四倍频模式会变成不可用状态。这是我在升级 HAL 库版本后踩到的一个暗坑,旧版本可能没问题,新版本严格了。
启动之后,读取当前计数:
uint16_t current = __HAL_TIM_GET_COUNTER(&htim3);清零计数:
__HAL_TIM_SET_COUNTER(&htim3, 0);就这么简单。但实际测速时,不能只读一次就完事,你得设计好“怎么读才能计算转速”。
3.2 M法与T法:低速和高速场景的取舍
测转速通常有两类主流方法。
M 法(测频法):固定一个时间窗口,统计窗口内计数脉冲的变化量。比如每 100ms 读一次,两次差值就是 100ms 内的脉冲数,转速公式为:
RPM = 差值 / (编码器线数 × 倍频) × (60 / 采样周期秒)200 线编码器、四倍频,每转 800 个计数脉冲,采样周期 0.1s:
RPM = 差值 / 800 × 600 = 差值 × 0.75窗口 100ms、脉冲差值为 0 时说明转速是 0,但低速时更致命的是分辨率问题:如果电机转速只有 0.1rpm,100ms 窗口内轴只转过 0.06°,对应不到 1 个计数脉冲,M 法基本测不出来。
T 法(测周法):测量两个相邻计数脉冲之间的时间,用时间倒数换算转速。低速时两个脉冲间隔很长,测量精度高,但高速时脉冲间隔只有几微秒,如果没有硬件捕获支持,软件测量很难做准。
一般项目里的实用策略是:中高速用 M 法,低速用 T 法,想要全速域准确就用 M/T 法。但多数小车、云台项目不需要这么极限,一个 M 法就够用了。我的实际做法是,主循环里按 100ms 周期采样,同时在做闭环时优先保证控制频率,把采样窗口压缩到 20~50ms。窗口越短,实时性越好,但低速分辨率变差,这个取舍根据应用场景定,不要照搬。
3.3 32位扩展计数器:用溢出中断把16位计数器“变大”
16 位计数器即使 ARR 拉满 65535,在长时间累计位移时也一定会溢出。解决办法不是去清零,而是在溢出中断里扩展成一个 32 位计数变量。
CubeMX 里勾选 TIM3 全局中断后,在 stm32f1xx_it.c 的 TIM3_IRQHandler 里会自动调用 HAL_TIM_IRQHandler,接着在 main.c 中覆盖回调:
volatile int32_t g_encoder_count = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { if (__HAL_TIM_IS_TIM_COUNTING_DOWN(&htim3)) { g_encoder_count -= (int32_t)(htim3.Init.Period + 1); } else { g_encoder_count += (int32_t)(htim3.Init.Period + 1); } } }这里的核心是判断计数器溢出时的方向。虽然 ARR 设为 65535 时溢出周期看起来很长,但电机高速正转时,65535 / 800 ≈ 81.9 转,也就是 81 转就溢出一次。如果方向和符号不处理,累计位移就会在溢出瞬间凭空减少 65535。上面的代码在向上计数溢出时加 65536,向下计数溢出时减 65536,相当于把计数范围扩成了 32 位,足够记录几百万转。
需要注意,不同 STM32 系列对方向位的宏命名有差异,有些老版本 HAL 里__HAL_TIM_IS_TIM_COUNTING_DOWN可能没有定义。遇到编译报错,直接用寄存器操作替代:
if ((TIM3->CR1 & TIM_CR1_DIR) != 0) { /* 向下计数 */ }3.4 封装一个可复用的测速模块
刚开始调测速时会到处写读计数器的代码,后面会后悔。建议早点把它封装成一个独立模块。我通常提供四个接口:
void Encoder_Init(void); // 初始化并启动编码器定时器 int32_t Encoder_GetCount(void); // 获取累计计数值(32位扩展后) void Encoder_Clear(void); // 清零累计计数值 float Encoder_GetRPM(uint16_t sample_ms); // 采样测速最基础的阻塞测速版长这样:
float Encoder_GetRPM(uint16_t sample_ms) { int32_t start = g_encoder_count; HAL_Delay(sample_ms); int32_t delta = g_encoder_count - start; // 假设 200 线编码器、四倍频,每转 800 脉冲 return (float)delta / 800.0f * (60000.0f / sample_ms); }但阻塞版会卡住主循环,真实项目里我更喜欢配合一个 1ms 或 10ms 的时基,在周期里采样计数差值,然后做一阶滤波或者滑动平均,再交给显示或控制用。转速曲线能不能稳定下来,往往不是计数器的问题,而是你是否做了滤波。关于滤波系数,我的经验是滑动平均窗口 5~10 个点比较均衡,太大会让转速反馈滞后,闭环 PID 容易振荡。
4. 避坑清单:这些问题我几乎全部踩过一遍
4.1 接线与电平类:A/B 相接错与不上拉
第一个坑,也是新手的第一个错误:把编码器输出线直接按颜色接到单片机上,结果 A 相接到了 CH2、B 相接到了 CH1,代码照抄网上的,一上电计数方向就是反的。
这种问题不用怀疑人生,检查一下接线对应关系就行。A 相对的是 TIMx_CH1,B 相对的是 TIMx_CH2,前后一定要对上。如果电路上不好改,就在 CubeMX 的 Polarity 里把 CH1 或 CH2 反向,方向就纠回来了。
第二个坑:编码器模块和单片机没有共地。编码器输出信号是相对于它自己的电源地而言的,如果你的编码器电源地没和 STM32 的 GND 连在一起,信号电平就是悬空的,读到的东西在零点几伏到电源电压之间胡乱漂移,计数值静止时也会乱跳。这不算高级故障,但排查起来很费时间。接一根共地,问题立刻消失。
第三个坑是引脚内部上拉。很多编码器模块是开漏输出,需要外部上拉才能输出高电平;还有的模块信号是推挽输出,上不上拉无所谓。CubeMX 里给两个编码器输入引脚开上拉是个保险做法:
- GPIO 模式选 Alternate Function
- GPIO Pull-up/Pull-down 选 Pull-up
如果开漏输出的编码器没配上拉,大概率出现的情况是:低电平正常,高电平电压爬不上去,结果计数乱跳。这一点在 F103 上尤其常见,因为它的大部分引脚默认不带内部上拉,必须手动配。
4.2 配置类:PSC 乱填与滤波器系数
配置 PSC 乱填,是我见过最频繁的问题。很多人习惯把一个定时器的 Prescaler 填成 72-1,以为所有定时器模式都需要分频。编码器模式下,计数器时钟是外部边沿信号,不是内部时钟,你填 PSC=71,行为立刻异常。到了论坛提问,别人一看截图就看到了 PSC 不是 0,直摇头。
滤波系数的问题前面说过,这里再强调一个实际场景。我接过一个客户的电机,驱动器 PWM 频率 20kHz,编码器信号线上串进来不少毛刺,转速曲线有一圈一圈的规律性抖动。我加了 ICF 滤波值 4,抖动明显改善;后来为了追求干净,把 ICF 拉到了 15,结果电机 3000rpm 时计数值明显偏小,因为滤波器的采样窗口把高频脉冲滤掉了。最终调到 8 才平衡。这个调试过程一共花了小半天,所以写在这提醒你:滤波系数不是越大越好,要在示波器或逻辑分析仪下边看边调。
4.3 代码与调试类:DIR 方向假设与计数器清零时机
代码里的坑集中在计数读取和清零时机上。最典型的错误是在采样时先把计数器清零,再去读它,但两次操作之间已经丢掉了几个脉冲。正确做法是:先读当前值保存,再把计数器清零(或者用累加差值),不要反过来。
另一个坑是直接用 uint16_t 存差值。电机正转 100 个脉冲、反转 200 个脉冲,期望结果是 -100,但用 uint16_t 得到的是 0xFF9C,上层逻辑如果把它当无符号数处理,转速就是一串正数乱跳。解决方案很简单,差值用 int16_t 去接:
int16_t delta = (int16_t)(__HAL_TIM_GET_COUNTER(&htim3) - last_count);利用补码回绕特性,正负方向会自动修正。
还要提醒一点:如果用了多个定时器同时开编码器,或者工程里有多个中断,一定要在回调里先判断是谁触发的:
if (htim->Instance == TIM3) { ... } else if (htim->Instance == TIM4) { ... }别在回调里默认只有一个定时器在跑,这是多路电机项目里非常常见的逻辑错误。
4.4 避坑汇总表:问题 / 原因 / 处理办法
| 问题现象 | 根本原因 | 处理办法 |
|---|---|---|
| 静止时计数值持续跳变 | 编码器未共地、无上拉或存在毛刺 | 加入共地线、配置内部上拉、适当增加 ICF |
| 转速方向与预期相反 | A/B 相接反 | 调换接线,或在 CubeMX 中反转某一个通道的 Polarity |
| 高速时计数值偏小 | ICF 滤波过大,滤掉了有效边沿 | 逐步减小 ICF,在示波器下确认 |
| 转速结果是一个巨大正数 | 差值用无符号类型读取 | 用 int16_t 接收差值 |
| 累计计数在某个值附近跳变 | 未处理 16 位计数器溢出 | 开启溢出中断,扩展 32 位计数 |
| 编码器模式没反应 | 引脚未配置为复用功能 | 检查 GPIO Mode,应为 AF |
| 填了 PSC 后计数异常 | 编码器模式不需要预分频 | 将 PSC 设回 0 |
| 中断回调不触发 | NVIC 里未使能定时器中断 | 勾选 TIM3 global interrupt |
| 启动后没有计数 | HAL_TIM_Encoder_Start 只启用了单通道 | 使用 TIM_CHANNEL_ALL |
5. 从测速到闭环控制的几个扩展点
5.1 反馈滤波与 PID 的配合
测速数据拿到之后,最常见的下一步就是做闭环控制。这时有个容易被忽略的细节:显示用的转速可以狠狠滤波,但反馈进 PID 的转速不能过度滤波。速度反馈太“肉”,PID 看到的转速永远慢半拍,系统容易振荡;太“贼”,量化噪声又被放大,电机声音会变糙。
更好的做法是在控制中断里直接读 32 位扩展计数,每 1ms 读一次差值,通过一阶低通滤波输出到 PID。一阶系数根据控制周期和编码器分辨率去推,比如 200 线四倍频、1ms 控制周期,低速时一个控制周期里可能只有 0~1 个脉冲,这时候单纯靠当前差值做 PID 是很不稳的。可以考虑在 PID 前累加一个“微脉冲缓冲”,或者把控制周期拉长到 5ms,效果都比硬调 P 参数好。
5.2 编码器线数不同时的计算差异
很多教程默认只讲 200 线编码器,但实际项目里你可能会碰到 11 线的空心杯电机编码器、360 线或者 500 线的伺服编码器。计算公式不变,变的只是“每转脉冲数”:
每转计数脉冲 = 编码器线数 × 倍频如果编码器是 11 线,四倍频后每转只有 44 个计数脉冲,这时做 M 法测速的分辨率会非常差,低速下基本没法用,必须改用 T 法或者提高采样窗口。如果编码器是 500 线,四倍频后每转 2000 个脉冲,M 法分辨率很高,但同样的采样窗口里差值很大,要注意别让 16 位计数器在窗口内溢出。
所以代码里千万别把“800”写死,建议在模块里留一个参数:
#define ENCODER_LINES 200 // 编码器线数 #define ENCODER_MULT 4 // 倍频 1/2/4 #define PULSES_PER_REV (ENCODER_LINES * ENCODER_MULT)这样换编码器时只改一个宏,不用到处翻代码。
5.3 Z 相零点与多定时器资源统筹
如果你的项目不只是测转速,还要求获取电机轴的绝对参考位置,那就必须用上编码器的 Z 相。Z 相每转一圈只输出一个脉冲,通常接到任意一个外部中断引脚,在中断里把累计计数清零或记下参考值。这样就能知道“从零点开始转了多少圈多少度”,做云台归零、机械臂关节复位都靠它。
还有一个资源统筹问题值得提前规划:一个定时器做了编码器,就不能同时输出 PWM 了。所以同时需要测速和驱动时,建议编码器占一个通用定时器,PWM 占另一个定时器,两者在硬件上天然分开,避免互相干扰。我习惯把 PWM 放在高级定时器,编码器放在通用定时器,PCB 布局上再把电机驱动线和编码器信号线拉开距离,这套组合在多次项目中都非常稳定。
文章写到这里,编码器模式的内容算是比较完整了。最后再分享一点个人调试习惯:新板子第一次上电,我一定是先用手慢慢拨电机,在调试器里盯着 TIM3->CNT 的数值看,确认它随转动平滑加减、方向正确,再写转速公式;如果数值跳变,先别怀疑代码,去检查接线和电平。这个过程我反复用了很多年,一直都稳。