一句话结论:高速用 M 法(数脉冲),低速用 T 法(测脉宽),全速域都在用就用 M/T 法——它在低速和高速下误差都最小。
一、完整工程下载
压缩包内含全部源码、platformio.ini、Makefile、README.md,解压即用,不需要额外配置。
下载 encoder-speed.zip (8.9 KB,共 7 个文件)
.gitignore
Makefile
README.md
include/
encoder.h
platformio.ini
src/
encoder.c
test/
test_encoder.c编码器给的是什么
增量式编码器(光电/霍尔/磁编)输出 A、B 两路正交方波:
正转: A ──┐ ┌──┐ ┌──┐ ┌──
└──┘ └──┘ └──┘
B ──┐ ┌──┐ ┌──┐ ┌── B 比 A 滞后 90°
└──┘ └──┘ └──┘
反转: A/B 相位关系反过来- 数脉冲 得到位移(配合四倍频,分辨率 ×4)
- 测频率 得到速度(单位时间内的脉冲数)
- 判相位 得到方向
解码:为什么建议四倍频
只数 A 的上升沿 = 一倍频;用上 A、B 的上升沿和下降沿 = 四倍频:
A/B 组合的 4 个状态:00 -> 10 -> 11 -> 01 -> 00 循环
每跳变一次计数 ±1,一圈的计数 = 编码器线数 × 4好处:分辨率 ×4,而且在极低速(甚至单步)时也能分辨运动。
三种测速方法的本质区别
设编码器一圈 PPR 个脉冲(四倍频后为 4·PPR),测速周期 T 秒。
| 方法 | 公式 | 适用 | 缺点 |
|---|---|---|---|
| M 法 | n = 60·ΔM/(PPR_x4·T) | 高速 | 低速时 ΔM 只有几个数,量化误差巨大 |
| T 法 | n = 60/(PPR_x4·Δt) | 低速 | 高速时 Δt 只有几十 μs,定时器分辨率不够 |
| M/T 法 | n = 60·(M−1)/(PPR_x4·(t_M − t_0)) | 全速域 | 实现稍复杂 |
量化误差直观感受
M 法在 60rpm、测速周期 10ms、PPR_x4 = 1300 时:
ΔM = 60·1300·0.01/60 = 13 个脉冲±1 个脉冲就是 ±7.7% 的误差!而 T 法在同样条件下测的是脉宽, 定时器 1μs 分辨率下脉宽约 46ms,误差只有 ±0.002%。
反过来在 3000rpm 时,T 法脉宽只有 0.9ms,μs 定时器误差 ±0.1%, 而 M 法 ΔM = 650,误差 ±0.15%,两者接近;但再快 T 法就撑不住了。
M/T 法为什么准
M/T 法的精髓:用两个信号交叉计时。
- 用一个已知周期
T的门控信号和脉冲序列做与运算 - 门控结束时:数出这段时间里的脉冲数
M;同时用一个高频时钟测出
「从第一个脉冲到第 M 个脉冲之间的精确时间 t」
n = 60·(M−1) / (PPR_x4 · t)
这样分母不是「近似 T」,而是实测的精确时间, 低速时 M 小但 t 测得准,高速时 M 大误差被平均掉,全速域误差都在 ±0.1% 量级。
工程内容
encoder.c/h:四倍频正交解码(含非法跳变检测)+ M/T/M-T 三种测速 + 一阶滤波test/test_encoder.c:用仿真脉冲流跑 30rpm / 300rpm / 3000rpm 三档,
打印三种方法的实测值和相对误差,直接看出该选哪个
调试要点
- A/B 接反了:现象是正转时转速为正但方向标志为负。不必重接线,
在解码表里交换 A/B 即可
- 计数丢脉冲:读取
count时如果中断里也在改它,会读到「半个」更新值。
用 int32_t 一次原子读取,或 __disable_irq()/__enable_irq() 包一下
- 机械安装偏心会导致一圈内测速周期性波动,这不是算法问题,
用一圈低通(如 10Hz)能压下去
- 别用主循环计时:用定时器硬件做测速窗口,否则窗口抖动直接变成速度噪声
- 编码器电源也要干净:霍尔编码器对电机换向干扰很敏感,加 0.1μF 陶瓷电容
进阶方向
- 用 STM32 定时器的编码器模式(TI1/TI2 双通道),硬件自动四倍频计数,CPU 零开销
- 把测速接到 PID 里做速度环(配合本站 PID 一文)
- 加方向死区与断线检测(A/B 长时间不变 → 判定传感器故障)
完整代码
Makefile
CC ?= gcc
CFLAGS ?= -std=c99 -Wall -Wextra -O2 -Iinclude
LDLIBS ?= -lm
SRC = src/encoder.c
TEST = test/test_encoder.c
ifeq ($(OS),Windows_NT)
EXT = .exe
endif
BIN = build/test$(EXT)
all: run
$(BIN): $(SRC) $(TEST)
@mkdir -p build
$(CC) $(CFLAGS) $(SRC) $(TEST) -o $(BIN) $(LDLIBS)
run: $(BIN)
@$(BIN)
clean:
rm -rf build
.PHONY: all run cleaninclude/encoder.h
/**
* encoder.h - 增量式正交编码器解码 + 三种测速方法
*/
#ifndef ENCODER_H
#define ENCODER_H
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
ENC_M = 0, /* M 法:固定时间数脉冲 */
ENC_T, /* T 法:测两个脉冲之间的时间 */
ENC_MT /* M/T 法:两者交叉 */
} enc_method_t;
typedef struct {
/* 配置 */
uint32_t ppr; /* 编码器线数(一圈脉冲数,未四倍频) */
uint16_t gear; /* 减速比(分子),如 30:1 填 30 */
uint32_t tick_hz; /* 时基频率,例如定时器 1MHz = 1000000 */
uint32_t window_ms; /* M 法与 M/T 法的测速窗口 */
/* 解码状态 */
uint8_t ab; /* 上一次 A/B 状态 */
int32_t count; /* 四倍频累积计数(带方向) */
int8_t dir; /* +1 正转,-1 反转 */
uint32_t illegal; /* 非法跳变次数(丢步/干扰指示) */
/* M 法状态 */
int32_t last_count;
uint32_t last_window_tick;
/* T 法状态 */
uint32_t last_pulse_tick;
uint32_t last_period; /* 上一次两脉冲间隔(tick) */
bool have_pulse;
uint32_t pulse_timeout; /* 长时间无脉冲 -> 速度归零 */
/* M/T 法状态 */
uint32_t mt_first_tick; /* 窗口内第一个脉冲的时刻 */
uint32_t mt_last_tick;
int32_t mt_first_count;
bool mt_started;
/* 输出 */
float rpm_m;
float rpm_t;
float rpm_mt;
float rpm_filt; /* 对 rpm_mt 做一阶低通的输出 */
} encoder_t;
void encoder_init(encoder_t *e, uint32_t ppr, uint16_t gear,
uint32_t tick_hz, uint32_t window_ms);
/** 编码器 A/B 任一跳变时调用;a、b 为当前电平(0/1) */
void encoder_update_ab(encoder_t *e, uint8_t a, uint8_t b);
/**
* 一次脉冲边沿到达(在 A/B 变化后调用,用于 T 法与 M/T 法记时刻)
* 真实项目里通常放在 EXTI 中断的最后一行。
*/
void encoder_on_edge(encoder_t *e);
/** 由定时器以 tick_hz 频率调用(通常放在 1MHz 的定时器中断里) */
void encoder_tick(encoder_t *e, uint32_t now_tick);
/** 取当前转速(rpm,已按减速比折算到输出轴) */
float encoder_get_rpm(const encoder_t *e);
#ifdef __cplusplus
}
#endif
#endif /* ENCODER_H */platformio.ini
[platformio]
default_envs = bluepill
[env:bluepill]
platform = ststm32
board = bluepill_f103c8
framework = arduino
upload_protocol = stlink
monitor_speed = 115200
build_flags =
-Wall
-Wextra
-Isrc
lib_ldf_mode = deep+src/encoder.c
#include "encoder.h"
#include <string.h>
/* 四倍频状态转移表:索引 = (prev_ab << 2) | cur_ab,值 = ±1 / 0(非法) */
static const int8_t s_trans[16] = {
0, -1, 1, 0, /* prev=00 */
1, 0, 0, -1, /* prev=01 */
-1, 0, 0, 1, /* prev=10 */
0, 1, -1, 0 /* prev=11 */
};
void encoder_init(encoder_t *e, uint32_t ppr, uint16_t gear,
uint32_t tick_hz, uint32_t window_ms)
{
memset(e, 0, sizeof(*e));
e->ppr = (ppr == 0) ? 1 : ppr;
e->gear = (gear == 0) ? 1 : gear;
e->tick_hz = (tick_hz == 0) ? 1000000u : tick_hz;
e->window_ms = (window_ms == 0) ? 10u : window_ms;
e->dir = 1;
}
void encoder_update_ab(encoder_t *e, uint8_t a, uint8_t b)
{
uint8_t cur = (uint8_t)(((a & 1u) << 1) | (b & 1u));
uint8_t idx = (uint8_t)((e->ab << 2) | cur);
int8_t d = s_trans[idx & 0x0Fu];
if (e->ab == cur) {
return; /* 没有变化 */
}
if (d == 0) {
e->illegal++; /* 两路同时跳变 = 干扰或丢步 */
e->ab = cur;
return;
}
/* 记录 M/T 法需要的时刻由 encoder_tick 里的 s_now 提供 */
e->count += d;
e->dir = (d > 0) ? 1 : -1;
e->ab = cur;
}
/* 当前 tick,由 encoder_tick 更新;供脉冲边沿记录使用 */
static uint32_t s_now = 0;
void encoder_tick(encoder_t *e, uint32_t now_tick)
{
uint32_t dt;
uint32_t win_ticks;
uint32_t ppr4;
float rev;
s_now = now_tick;
ppr4 = e->ppr * 4u; /* 四倍频后一圈的计数 */
win_ticks = (e->tick_hz / 1000u) * e->window_ms;
if (win_ticks == 0) {
win_ticks = 1;
}
/* ---------- T 法:靠脉冲边沿更新,这里只做「超时归零」 ---------- */
if (e->have_pulse) {
if ((uint32_t)(now_tick - e->last_pulse_tick) > (win_ticks * 4u)) {
e->rpm_t = 0.0f;
e->last_period = 0;
e->have_pulse = false;
}
}
/* ---------- 窗口到期:算 M 法与 M/T 法 ---------- */
dt = (uint32_t)(now_tick - e->last_window_tick);
if (dt < win_ticks) {
return;
}
e->last_window_tick = now_tick;
rev = (float)e->gear; /* 输出轴转数 = 电机轴转数 / 减速比 */
/* M 法 */
{
int32_t dm = e->count - e->last_count;
e->last_count = e->count;
e->rpm_m = ((float)dm / (float)ppr4) * (60.0f * (float)e->tick_hz / (float)dt) / rev;
}
/* M/T 法 */
if (e->mt_started && e->mt_last_tick > e->mt_first_tick) {
int32_t dm = e->count - e->mt_first_count;
uint32_t t = e->mt_last_tick - e->mt_first_tick;
if (dm != 0 && t > 0) {
/* t 是「第一个边沿到最后一个边沿」的实测时间,这期间的间隔数正好是 dm,
* 所以用 dm 而不是 (M-1):分母用的是实测精确时间,不是近似的窗口 T */
e->rpm_mt = ((float)dm / (float)ppr4) *
(60.0f * (float)e->tick_hz / (float)t) / rev;
/* 一阶低通,抑制机械偏心引起的周期性波动 */
if (e->rpm_filt == 0.0f) {
e->rpm_filt = e->rpm_mt;
} else {
e->rpm_filt = 0.3f * e->rpm_mt + 0.7f * e->rpm_filt;
}
}
}
e->mt_started = false;
e->mt_first_tick = e->mt_last_tick = now_tick;
}
/**
* 脉冲边沿到达(由你的中断在 A/B 变化时调用,内部会记时刻)
* 说明:真实项目里可以直接在 encoder_update_ab 后调用它。
*/
void encoder_on_edge(encoder_t *e)
{
uint32_t period;
/* T 法 */
if (e->have_pulse) {
period = (uint32_t)(s_now - e->last_pulse_tick);
if (period > 0) {
e->last_period = period;
/* 一个脉冲对应 1/(ppr*4) 转 */
e->rpm_t = (60.0f * (float)e->tick_hz) /
((float)e->ppr * 4.0f * (float)period * (float)e->gear);
}
}
e->last_pulse_tick = s_now;
e->have_pulse = true;
/* M/T 法:窗口内第一个脉冲记下起点,之后持续更新终点 */
if (!e->mt_started) {
e->mt_started = true;
e->mt_first_tick = s_now;
e->mt_first_count = e->count;
}
e->mt_last_tick = s_now;
}
float encoder_get_rpm(const encoder_t *e)
{
return e->rpm_filt;
}test/test_encoder.c
/**
* 主机端测试:用仿真的正交脉冲流对比三种测速方法
* gcc -std=c99 -Wall -Wextra -Iinclude src/encoder.c test/test_encoder.c -o build/test -lm
*/
#include <math.h>
#include <stdio.h>
#include "encoder.h"
#define TICK_HZ 1000000u /* 1MHz 时基 */
#define PPR 1300u /* 13 线霍尔编码器 × 100 减速比 = 1300 线/输出轴圈 */
#define GEAR 1u
void encoder_on_edge(encoder_t *e);
/* 仿真:以 rpm 转速跑 seconds 秒,按合法正交(格雷)序列翻转 A/B
*
* 注意这里必须是格雷码序列 00 -> 10 -> 11 -> 01 -> 00,
* 相邻状态只能有 1 位变化;如果写成 00->01->10->11,
* 就会出现「两路同时跳变」的非法状态,解码器会把它当成丢步。
* 这是本工程写测试时踩过的坑。
*/
static void simulate(encoder_t *e, float rpm, float seconds, enc_method_t which)
{
static const uint8_t seq[4] = {0u, 2u, 3u, 1u}; /* 00,10,11,01 */
double counts_per_sec = (double)rpm * (double)PPR * 4.0 / 60.0;
double step = 0.0;
double acc = 0.0;
uint32_t total = (uint32_t)((double)TICK_HZ * seconds);
int dir = (counts_per_sec >= 0.0) ? 1 : -1;
uint8_t idx = 0;
(void)which;
encoder_init(e, PPR, GEAR, TICK_HZ, 10);
if (counts_per_sec < 0.0) {
step = -counts_per_sec / (double)TICK_HZ;
} else {
step = counts_per_sec / (double)TICK_HZ;
}
for (uint32_t t = 0; t < total; t++) {
uint8_t ph;
acc += step;
while (acc >= 1.0) {
acc -= 1.0;
idx = (uint8_t)((idx + dir + 4) & 3);
ph = seq[idx];
encoder_update_ab(e, (uint8_t)((ph >> 1) & 1u), (uint8_t)(ph & 1u));
encoder_on_edge(e); /* 真实项目里由 EXTI 中断调用 */
}
encoder_tick(e, t);
}
}
int main(void)
{
encoder_t e;
const float speeds[] = {30.0f, 300.0f, 3000.0f};
printf("===== 编码器测速方法对比(PPR=%u 线,四倍频,窗口 10ms,时基 1MHz)=====\n\n",
(unsigned)PPR);
printf(" 真实转速 M 法 T 法 M/T 法(滤波后)\n");
printf(" 实测 误差 实测 误差 实测 误差\n");
printf(" ----------------------------------------------------------------\n");
for (unsigned i = 0; i < sizeof(speeds) / sizeof(speeds[0]); i++) {
float rpm = speeds[i];
float em;
float et;
float emt;
simulate(&e, rpm, 3.0f, ENC_MT);
em = e.rpm_m;
et = e.rpm_t;
emt = e.rpm_filt;
printf(" %6.0f rpm %8.1f %6.2f%% %8.1f %6.2f%% %8.1f %6.2f%%\n",
rpm,
em, 100.0f * (em - rpm) / rpm,
et, 100.0f * (et - rpm) / rpm,
emt, 100.0f * (emt - rpm) / rpm);
}
printf("\n[低速细看] 30rpm 时 M 法为什么不准\n");
{
simulate(&e, 30.0f, 1.0f, ENC_M);
printf(" 窗口 10ms 内脉冲数 = %.2f 个\n",
30.0f * (float)PPR * 4.0f / 60.0f * 0.01f);
printf(" ±1 个脉冲 -> 相对误差 ±%.2f%%\n",
100.0f / (30.0f * (float)PPR * 4.0f / 60.0f * 0.01f));
printf(" (本例表中 M 法显示 0.00%% 是因为仿真转速刚好使每个窗口都是整数个脉冲;\n");
printf(" 真实系统中窗口边界与脉冲不同步,误差就按上面这个数值量级出现)\n");
}
printf("\n[反向] 反转 300rpm,方向与速度符号\n");
{
simulate(&e, -300.0f, 2.0f, ENC_MT);
printf(" rpm_mt = %.1f,direction = %d,累积计数 = %d\n",
e.rpm_mt, (int)e.dir, (int)e.count);
}
printf("\n[静止] 30rpm 后立刻停车,T 法的超时归零\n");
{
simulate(&e, 300.0f, 0.5f, ENC_T);
printf(" 停车瞬间 rpm_t = %.1f\n", e.rpm_t);
for (uint32_t t = 0; t < 200000u; t += 1000u) {
encoder_tick(&e, t);
}
printf(" 超过 4 个窗口无脉冲后 rpm_t = %.1f(应归零)\n", e.rpm_t);
}
printf("\n[非法跳变] 模拟 A/B 两路同时变化的干扰(机械抖动/丢步)\n");
{
encoder_init(&e, PPR, GEAR, TICK_HZ, 10);
encoder_update_ab(&e, 0, 0); /* 基准态 00 */
encoder_update_ab(&e, 0, 1); /* 00 -> 01,合法 */
for (int i = 0; i < 5; i++) {
encoder_update_ab(&e, 1, 0); /* 01 -> 10,两路同时跳 = 非法 */
encoder_update_ab(&e, 0, 1); /* 10 -> 01,两路同时跳 = 非法 */
}
printf(" 非法跳变计数 = %u(>0 说明有干扰或丢步,正常应为 0)\n", e.illegal);
}
printf("\n结论(按上表实测数据):\n");
printf(" * 高速端:T 法在 3000rpm 时误差已达 28%%(脉宽只剩 0.9ms,\n");
printf(" 1MHz 时基下 ±1 个计数就放大成几十转),高速必须用 M 法或 M/T 法\n");
printf(" * 低速端:M 法的量化误差正比于 1/ΔM(30rpm 时 ±1 个脉冲 = ±3.85%%),\n");
printf(" 低速应该用 T 法或 M/T 法\n");
printf(" * 全速域:M/T 法,三档误差都 ≤0.01%% —— 这是它唯一多花的那点代码换来的\n");
printf("\n===== 以上数据由本机 gcc 实编译实运行产生 =====\n");
return 0;
}实测输出
下面这段输出是把上面的核心算法用 本机 gcc 真编译、真运行得到的(不含任何硬件依赖):
===== 编码器测速方法对比(PPR=1300 线,四倍频,窗口 10ms,时基 1MHz)=====
真实转速 M 法 T 法 M/T 法(滤波后)
实测 误差 实测 误差 实测 误差
----------------------------------------------------------------
30 rpm 30.0 0.00% 30.0 0.16% 30.0 0.00%
300 rpm 300.0 0.00% 303.6 1.21% 300.0 0.01%
3000 rpm 3000.0 0.00% 3846.2 28.21% 3000.0 0.00%
[低速细看] 30rpm 时 M 法为什么不准
窗口 10ms 内脉冲数 = 26.00 个
±1 个脉冲 -> 相对误差 ±3.85%
(本例表中 M 法显示 0.00% 是因为仿真转速刚好使每个窗口都是整数个脉冲;
真实系统中窗口边界与脉冲不同步,误差就按上面这个数值量级出现)
[反向] 反转 300rpm,方向与速度符号
rpm_mt = -300.0,direction = -1,累积计数 = -52000
[静止] 30rpm 后立刻停车,T 法的超时归零
停车瞬间 rpm_t = 303.6
超过 4 个窗口无脉冲后 rpm_t = 0.0(应归零)
[非法跳变] 模拟 A/B 两路同时变化的干扰(机械抖动/丢步)
非法跳变计数 = 10(>0 说明有干扰或丢步,正常应为 0)
结论(按上表实测数据):
* 高速端:T 法在 3000rpm 时误差已达 28%(脉宽只剩 0.9ms,
1MHz 时基下 ±1 个计数就放大成几十转),高速必须用 M 法或 M/T 法
* 低速端:M 法的量化误差正比于 1/ΔM(30rpm 时 ±1 个脉冲 = ±3.85%),
低速应该用 T 法或 M/T 法
* 全速域:M/T 法,三档误差都 ≤0.01% —— 这是它唯一多花的那点代码换来的
===== 以上数据由本机 gcc 实编译实运行产生 =====