news 2026/10/20 1:10:03

编码器测速原理与M/T法实战:从脉冲计数到速度重建

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
编码器测速原理与M/T法实战:从脉冲计数到速度重建

1. 从一个反直觉的现象说起:为什么脉冲数不等于速度

很多人第一次接触编码器测速,脑子里会有一个非常自然的想法:编码器每转一圈输出固定数量的脉冲,那我只要数一秒内收到多少个脉冲,再除以每圈的脉冲数,不就得到转速了吗?这个思路本身没错,但如果你真把它直接写进代码里,大概率会得到一个抖得没法看的数值,甚至电机一慢下来就直接归零。

我在一个运动控制项目里就吃过这个亏。当时用的是一款增量式编码器,标称每转2500线,四倍频之后是10000个计数。电机额定3000转每分钟,算下来满速时每秒有50万个脉冲,听起来很充裕。但实际调试时把转速降到每分钟30转,一秒内只有500个脉冲,再考虑采样窗口的边界效应,读数就开始一跳一跳的。更麻烦的是,当转速低到每分钟几转的时候,固定时间窗口内可能一个脉冲都数不到,速度直接显示为零,可电机明明在转。

这个现象背后其实藏着一个核心问题:编码器直接输出的是位置增量,不是速度。速度是位置对时间的导数,而求导这个操作对噪声极其敏感。你数脉冲的那个"一秒",本质上是在做一个粗糙的差分运算,窗口越长越平滑但延迟越大,窗口越短越实时但噪声越大。这就是为什么工业上从来不会用"数固定时间脉冲"这种原始方法,而是用M法、T法或者M/T法这些经过设计的测速方案。

所以这篇文章想做的事情很明确:把"编码器读到的到底是什么"这件事从头讲清楚,从最底层的正交脉冲信号,到计数器如何累加,再到怎么把这个累加值变成可用的速度量。适合正在做电机控制、机器人底盘、CNC或者任何需要闭环调速的开发者,也适合那些被编码器读数折磨过、想搞明白底层原理的人。我会尽量用生活化的类比把原理讲透,同时给出可以直接抄的代码和参数配置。

2. 拆开编码器的输出:A相、B相和Z相到底在说什么

2.1 正交脉冲的物理本质

增量式编码器内部通常有一个带狭缝的码盘和一个光电对管。码盘转动时,光线被狭缝交替遮挡,接收端就产生方波。为了判断方向,厂家会做两路信号,也就是A相和B相,它们的方波在相位上错开90度,这就是"正交"这个词的来源。

为什么非要错开90度?因为只有错开相位,你才能从两路信号的先后顺序判断出转动方向。如果A相上升沿到来时B相是低电平,说明是正转;如果A相上升沿到来时B相是高电平,说明是反转。这个逻辑用硬件或者软件都能实现,硬件上就是一个D触发器,软件上就是读两个引脚的状态做判断。

我习惯用一个类比来解释:想象两个人并排走路,A走在前面一点,B走在后面一点。如果队伍往右走,你总是先看到A再看到B;如果往左走,就先看到B再看到A。编码器就是靠这个"谁先谁后"来判断方向的。

2.2 四倍频:把分辨率榨干

单看A相的上升沿,一圈能数出2500个脉冲。但既然A和B都有上升沿和下降沿,而且它们相位错开,那实际上有四个边沿可以用来计数:A上升、A下降、B上升、B下降。每捕捉一个边沿就计一个数,一圈就能数出10000个计数,这就是四倍频。

四倍频的好处很直接:分辨率提高了四倍,低速时能捕捉到更细的位置变化。但代价是计数中断的频率也高了四倍,对MCU的中断处理能力有要求。我在一个项目里用STM32的定时器编码器模式做四倍频,满速时中断频率到了2MHz,CPU基本被吃满,后来改成只在需要读位置的时候去读计数器寄存器,才把负载降下来。

这里有个容易踩的坑:四倍频计数时,如果A、B两相的信号有抖动或者毛刺,会直接导致计数错误。因为每个边沿都被当作有效事件,毛刺会被当成真实的转动。所以硬件上一定要在编码器输出端加RC滤波,软件上如果条件允许,用定时器的输入滤波功能,把滤波参数设到能滤掉毛刺又不影响正常脉冲的程度。

2.3 Z相:每圈一次的绝对参考

Z相也叫零位信号,码盘每转一圈只输出一个脉冲。它的作用不是用来测速的,而是用来做位置校准的。因为增量式编码器上电时不知道自己在哪,只能从零开始计数,但实际机械位置可能是任意角度。有了Z相,系统可以在第一次遇到Z相时把计数器清零,这样每次上电后的零点就是一致的。

我在做机械臂关节的时候,Z相的作用特别明显。每次上电后先让关节慢速转一圈找Z相,找到之后把位置计数器归零,之后所有角度都是相对于这个零点的。如果没有Z相,每次上电的零点都不一样,绝对位置就无从谈起。

注意:Z相脉冲很窄,有些编码器的Z相宽度只有一个计数周期。如果用软件轮询的方式去读,很容易漏掉。建议把Z相接到有中断功能的引脚上,用边沿触发中断来捕获。

3. 计数器读到的数字,为什么不能直接当速度用

3.1 位置与速度的数学关系

计数器寄存器里存的是一个不断累加的整数,它代表的是位置,单位是"计数"。要得到速度,你需要做的是:在时间t1读一次计数C1,在时间t2读一次计数C2,然后速度等于(C2减C1)除以(t2减t1),再除以每转的计数总数,就得到每秒转数。

这个公式看起来简单,但魔鬼在细节里。首先,C2减C1这个差值在低速时可能很小,比如只有几个计数,而计数器本身可能有正负1的量化误差,算出来的速度误差就很大。其次,t2减t1这个时间间隔如果由软件定时器决定,会有中断响应延迟和抖动,这个抖动在高速时影响不大,但在低速时占比就上来了。

我做过一个实测:用1ms的定时中断去读计数器,电机稳速在每分钟60转,理论每秒1转,每毫秒应该走10个计数。但实际读出来的差值在8到12之间跳,算出来的速度就在每分钟48到72转之间波动。这个波动不是电机真的在抖,而是量化误差和定时抖动叠加的结果。

3.2 固定时间窗口的致命缺陷

固定时间窗口测速,也就是M法,它的本质是用"数脉冲"来近似"测频率"。在高速时,窗口内脉冲多,相对误差小;在低速时,窗口内脉冲少,相对误差大。当转速低到窗口内只有几个脉冲时,误差可能超过50%。

更极端的情况是,如果窗口内一个脉冲都没有,速度读数就是零。但电机可能在以极低的速度转动,只是脉冲还没攒够一个。这时候如果控制器看到速度为零,可能会加大输出试图"启动"电机,结果电机突然加速,造成震荡。

这个缺陷在需要宽调速范围的场合是致命的。比如一个云台电机,既要在高速时快速跟踪,又要在低速时精细微调,固定窗口根本覆盖不了这么大的范围。所以必须引入T法,也就是测周法。

3.3 量化误差的直观估算

假设编码器四倍频后每转10000个计数,定时窗口1ms。在每分钟1转时,每秒走10000个计数,每毫秒走10个计数。量化误差正负1个计数,相对误差就是10%。在每分钟0.1转时,每毫秒走1个计数,量化误差正负1个计数,相对误差就是100%。这个估算说明,M法的相对误差与转速成反比,转速越低误差越大。

反过来,如果改用T法,测两个相邻脉冲之间的时间间隔,用定时器的高频时钟去数这个间隔。在高速时,间隔很短,计数值小,相对误差大;在低速时,间隔很长,计数值大,相对误差小。所以T法适合低速,M法适合高速,两者正好互补。

4. M法、T法和M/T法:三种测速方案的取舍逻辑

4.1 M法:高速段的可靠选择

M法的实现最简单:开一个固定周期的定时器,比如1ms,在中断里读计数器,减去上次的值,得到增量,再除以时间和每转计数,就得到速度。它的优点是实现简单、低速时也能输出(虽然误差大),缺点是低速精度差、有量化误差。

我在高速场合基本都用M法,比如电机额定转速几千转的场合,1ms窗口内脉冲数成百上千,量化误差可以忽略。参数选择上,窗口时间要满足:最低转速下窗口内至少有几十个脉冲。比如最低转速每分钟60转,每转10000计数,每秒10000计数,1ms窗口有10个计数,还是偏少。如果改成10ms窗口,就有100个计数,误差降到1%左右,但速度环的延迟也增加到10ms,可能影响动态响应。

所以M法的窗口时间是一个权衡:窗口越长,精度越高但延迟越大;窗口越短,响应越快但精度越差。我的经验是,速度环的采样周期如果是1ms,测速窗口可以用2到5ms,用滑动平均的方式既保证精度又不引入太大延迟。

4.2 T法:低速段的精度保障

T法的思路完全不同:不去数固定时间内的脉冲,而是测两个脉冲之间的时间。具体做法是,每当编码器产生一个脉冲(或者一个计数),就记录下当前的高频定时器计数值,两次记录之差就是脉冲间隔。速度等于每转计数除以(间隔乘以定时器周期)。

T法在低速时精度极高,因为间隔很长,高频定时器的计数值很大,相对误差很小。但在高速时,间隔很短,计数值可能只有几个,相对误差就大了。而且T法在极低速时,如果电机停转,就没有脉冲,也就没有速度输出,需要额外的超时判断。

我在一个需要极低速平稳运行的转台项目里,用的就是T法。高频定时器跑在10MHz,最低转速每分钟0.1转时,每转10000计数,每秒约16.7个计数,脉冲间隔约60ms,计数值约60万,相对误差可以忽略。但转速升到每分钟1000转时,脉冲间隔只有6微秒,计数值只有60,误差就上来了。所以那个项目里我做了自动切换:低速用T法,高速切回M法。

4.3 M/T法:全速段的折中方案

M/T法试图结合两者的优点:在一个固定的较长时间窗口内,既数编码器脉冲,又数高频时钟脉冲。速度等于编码器脉冲数除以高频时钟数,再乘以时钟频率和每转计数的关系。这样在高速时,编码器脉冲多,精度由脉冲数保证;在低速时,高频时钟数多,精度由时钟数保证。

M/T法的实现比前两者复杂,需要同时开两个计数器,一个数编码器脉冲,一个数高频时钟,而且窗口的起止要和编码器脉冲边沿对齐,否则会有半个脉冲的误差。我在一个宽调速范围的伺服项目里用过M/T法,效果确实好,全速段误差都能控制在1%以内。但代码复杂度也上去了,调试的时候要特别注意两个计数器的同步问题。

方法适用转速段精度特点实现复杂度典型窗口
M法高速低速误差大低1到10ms
T法低速高速误差大中脉冲间隔
M/T法全速段全段均衡高10到50ms

4.4 怎么选:一个实用的决策流程

我的经验是,先看你的调速范围。如果最高转速和最低转速之比小于10,用M法就够了,窗口时间按最低转速下至少有50个脉冲来选。如果比值在10到100之间,考虑T法或者M/T法。如果比值超过100,基本只能用M/T法或者做分段切换。

另外还要看动态响应要求。如果速度环带宽要求高,窗口时间就不能太长,这时候M/T法的长窗口可能不合适,反而要用短窗口的M法加滤波。如果对平稳性要求高、对响应要求低,可以用长窗口加滑动平均。

还有一个容易被忽略的点:编码器的线数要和调速范围匹配。如果最低转速很低,又想要好的精度,要么选高线数编码器,要么用T法,要么加机械减速比。我见过一个项目,电机最低转速每分钟1转,编码器只有100线,四倍频后400计数,每秒才6.7个计数,M法根本没法用,最后换了2500线的编码器才解决。

5. 从计数器到速度值的完整代码实现

5.1 硬件定时器编码器模式的配置要点

以常见的STM32为例,定时器的编码器模式可以自动处理A、B两相的正交解码和四倍频计数,CPU只需要读计数器寄存器就行。配置的时候有几个关键点:

第一,定时器的计数模式要设成"编码器模式",并且选择"在TI1和TI2边沿都计数",这就是四倍频。第二,输入捕获滤波器要开,滤波参数根据编码器信号质量来定,一般设到能滤掉几十纳秒的毛刺。第三,自动重装载值设成最大,比如65535,让计数器自由累加,软件里做溢出处理。

// STM32 HAL库配置编码器模式的简化示例 TIM_Encoder_InitTypeDef encoder_config = {0}; encoder_config.EncoderMode = TIM_ENCODERMODE_TI12; // 四倍频 encoder_config.IC1Polarity = TIM_ICPOLARITY_RISING; encoder_config.IC2Polarity = TIM_ICPOLARITY_RISING; encoder_config.IC1Filter = 0x0F; // 滤波参数,根据信号质量调整 encoder_config.IC2Filter = 0x0F; HAL_TIM_Encoder_Init(&htim3, &encoder_config); HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL);

读计数器的时候要注意,16位定时器会溢出,32位定时器基本不会。如果是16位,需要在软件里维护一个高位累加值,每次读的时候判断当前值是否跨越了0或者65535。

5.2 M法测速的代码骨架

M法的核心就是一个定时中断,在中断里读计数器、算差值、更新速度。下面是一个简化的实现:

#define ENCODER_CPR 10000.0f // 四倍频后每转计数 #define SPEED_WINDOW_S 0.002f // 2ms窗口 volatile int32_t last_count = 0; volatile float speed_rpm = 0.0f; void SpeedTimer_ISR(void) { int32_t current_count = read_encoder_count(); int32_t delta = current_count - last_count; last_count = current_count; // 速度 = 增量 / 每转计数 / 窗口时间 * 60 speed_rpm = (float)delta / ENCODER_CPR / SPEED_WINDOW_S * 60.0f; }

这段代码看起来简单,但有几个细节要注意。第一,read_encoder_count要处理溢出,如果是16位定时器,delta的计算要考虑回绕。第二,delta是有符号的,正转正、反转负,这正好符合速度的方向。第三,如果窗口内delta很小,速度值会抖,可以在后面加一个一阶低通滤波。

5.3 T法测速的代码骨架

T法需要捕获编码器脉冲的边沿,用输入捕获功能记录时间戳。下面是一个思路:

volatile uint32_t last_capture = 0; volatile uint32_t capture_delta = 0; volatile float speed_rpm = 0.0f; void EncoderCapture_ISR(uint32_t capture_value) { capture_delta = capture_value - last_capture; last_capture = capture_value; if (capture_delta > 0) { // 速度 = 定时器频率 / 每转计数 / 间隔 * 60 speed_rpm = (float)TIMER_CLK / ENCODER_CPR / capture_delta * 60.0f; } }

T法的关键参数是定时器的时钟频率。频率越高,低速时计数值越大,精度越好,但高速时计数值太小会溢出或者误差大。一般选几十MHz,配合预分频器调整。另外,T法在电机停转时没有脉冲,速度值会保持上一次的值,需要加一个超时判断,超过一定时间没有捕获就把速度置零。

5.4 滤波:让速度值能看

不管用哪种方法,原始速度值都会有噪声。我一般用一阶低通滤波,公式是:滤波值等于系数乘以新值加上(1减系数)乘以旧值。系数越小越平滑但延迟越大,系数越大响应越快但噪声越大。

#define ALPHA 0.3f filtered_speed = ALPHA * speed_rpm + (1.0f - ALPHA) * filtered_speed;

这个系数怎么选?我的经验是,如果速度环周期是1ms,测速窗口是2ms,滤波系数取0.2到0.5之间比较合适。如果发现速度响应太慢,就加大系数;如果发现速度抖动影响电流环,就减小系数。有时候我会用滑动平均代替一阶滤波,效果更平滑但计算量稍大。

提示:滤波会引入相位延迟,在闭环控制里可能影响稳定性。如果速度环的带宽要求高,滤波系数不能太小,或者改用更高级的滤波方法比如卡尔曼滤波。

6. 那些只有实际调试才会遇到的坑

6.1 计数器溢出与回绕处理

16位定时器最大计数值是65535,正转超过65535会回绕到0,反转低于0会回绕到65535。如果软件里直接做减法,回绕的时候会得到一个巨大的错误值。我见过一个项目,电机正转时速度正常,反转时速度突然变成几万转,就是因为回绕没处理。

正确的做法是用有符号的差值计算,并且利用整数溢出的特性。在C语言里,如果两个无符号数相减,结果会自动按模运算,只要差值在半个计数范围以内,结果就是正确的。比如65535减0等于65535,但按有符号解释就是负1,正好是反转一个计数。所以只要保证两次采样之间的增量不超过32767,直接用有符号减法就是安全的。

int16_t delta = (int16_t)(current_count - last_count);

这一行代码就解决了回绕问题,前提是增量不超过正负32767。如果窗口内增量可能超过这个值,就要用32位变量并且手动处理溢出。

6.2 编码器信号抖动导致的计数错误

编码器信号抖动是另一个常见问题。表现是电机静止时计数器也在缓慢变化,或者低速时速度读数有尖峰。原因可能是信号线太长、没有屏蔽、电源噪声大,或者编码器本身质量不好。

我在一个项目里遇到过电机静止时计数器每秒跳几十个计数的情况,查了半天发现是编码器电缆和电机动力线捆在一起走线,动力线的PWM噪声耦合到了编码器信号上。后来把编码器线单独走,并且加了屏蔽层接地,问题就解决了。

软件上也能做一些补救:定时器的输入滤波参数调大一些,或者在速度计算后加一个死区,增量绝对值小于某个阈值就当作零。但这些都是治标,根本还是要从布线和屏蔽上解决。

6.3 低速时的速度归零与震荡

前面提到过,M法在极低速时窗口内可能没有脉冲,速度读数为零。这时候如果速度环在运行,控制器会以为电机没转,加大输出,电机突然加速,然后速度读数又有了,控制器又减小输出,造成震荡。

解决这个问题有几个思路。一是改用T法,低速时也能测出速度。二是设置一个最小速度阈值,低于这个阈值时速度环开环或者用电流环维持。三是用位置环代替速度环,在极低速时直接控制位置增量。我在一个云台项目里用的是第二种,低速时切到开环,靠电流控制维持平稳转动,效果不错。

6.4 多圈计数与绝对位置

增量式编码器只能测相对位置,上电后需要找Z相归零。但如果系统需要多圈绝对位置,比如升降机构要知道当前在第几层,就需要在软件里维护一个多圈计数器,并且掉电保存。

我的做法是:每次Z相中断时,把当前的单圈计数清零,同时多圈计数加一或者减一(根据方向)。多圈计数存在EEPROM或者Flash里,掉电前保存,上电后恢复。但要注意,如果掉电时电机还在转动,恢复后的位置可能不准,需要重新找Z相校准。

6.5 采样周期与速度环的匹配

测速窗口和速度环周期不匹配也会出问题。如果速度环周期是1ms,测速窗口是10ms,那速度环每次用的都是10ms前的平均速度,相位滞后很大,容易震荡。反过来,如果测速窗口太短,速度噪声大,速度环也会震荡。

我的经验是,测速窗口和速度环周期最好一致,或者测速窗口是速度环周期的整数倍,并且用滑动平均的方式让每次速度环都能拿到最新的平均速度。比如速度环1ms,测速窗口5ms,可以用一个5点的滑动平均,每1ms更新一次,这样既有5ms的平滑效果,又有1ms的更新率。

7. 从脉冲到速度,本质是一次信息重建

回过头看,编码器读到的原始信息只有脉冲和方向,速度是我们通过差分运算重建出来的。这个重建过程的质量,取决于你对采样定理、量化误差、噪声和延迟的理解。M法、T法、M/T法不是三个孤立的技巧,而是同一个问题在不同转速段的不同解法。

我在实际项目里最深的体会是:不要指望一种方法包打天下。宽调速范围的项目,要么做分段切换,要么用M/T法,要么在机械上想办法(比如加减速比让编码器转速不要太低)。另外,编码器的选型和安装往往比算法更重要,线数够不够、信号干不干净、Z相可不可靠,这些底层问题不解决,再好的算法也白搭。

最后分享一个调试小技巧:如果你怀疑速度读数有问题,先把原始计数器的值通过串口或者DAC输出出来,用示波器或者上位机看波形。很多时候,你看一眼计数器的变化曲线,就能判断是编码器信号问题、计数溢出问题还是算法问题。这比盯着速度值猜要高效得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 1:09:20

载波芯片选型避坑指南:排行榜解读与实测方法论

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 1:08:44

内存SPD详解与Thaiphoon Burner实操:读取、修改XMP及救砖指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 1:08:42

STM32+华为云IoT+Qt构建跨平台智能家居远程控制系统

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 1:07:32

STM32+华为云IoT+Qt:跨平台智能家居控制系统全链路实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 1:07:07

ESP32 Modbus TCP分片缓存设计:解决粘包、阻塞与数据一致性

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/20 1:07:01

FlexiTac:面向工业规模化的柔性触觉系统架构

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华