正交编码器在电机控制、云台稳定、精密位移台这些场景里几乎是标配。但很多人第一次用GD32的定时器接编码器时,都会遇到同一个困惑:明明编码器是500线的,为什么读出来的数总感觉"不够细"?或者换个问法——同样一圈,别人能读到2000个计数,我这边只有500,差在哪了?
答案通常就藏在"倍频"这两个字里。正交编码器输出的是A、B两路相位差90度的方波,硬件定时器的编码器接口模式本身就能对这两路信号做4倍频解码,把每个周期拆成4个计数点。但GD32的定时器配置里,从"能计数"到"稳定地4倍频计数",中间隔着好几道坎:滤波器怎么设、极性怎么选、计数方向怎么判断、溢出怎么处理。这篇文章就把这三种实现4倍频的路径拆开讲清楚,顺带把模式对比和踩坑经验一并交代。不管你是刚上手GD32的新人,还是从STM32转过来想快速迁移的老手,都能直接抄作业。
1. 先搞清楚正交编码器为什么能"4倍频"
在动手配置寄存器之前,得先把倍频这件事的物理来源想明白。不然配置的时候只是照抄参数,出了问题根本不知道从哪查。
1.1 A/B两路信号的四种状态组合
正交编码器内部通常是一个带缝隙的码盘加两组光电对管,A、B两路输出在相位上错开90度。当码盘转动时,A、B会依次出现四种电平组合:00、01、11、10(或者反过来10、11、01、00,取决于转向)。这四种组合构成一个完整的格雷码循环,每转一个"栅格"其实经历了四个状态跳变。
如果只用A路的上升沿计数,一圈500线的编码器只能得到500个计数;如果同时用A、B的上升沿和下降沿,就能拿到4倍,也就是2000个计数。这就是4倍频的本质——不是把信号"放大"了,而是把原本被忽略的边沿信息全部利用起来。
注意:4倍频不会提高编码器的物理分辨率,它只是把已有的边沿信息榨干。真正的精度上限还是由码盘刻线数决定,倍频只是让计数更细腻。
1.2 定时器编码器接口模式是怎么"看懂"这两路信号的
GD32的通用定时器(比如TIMER1、TIMER2、TIMER3这些)和高级定时器都带编码器接口功能。它的核心是一个方向判别逻辑加一个计数单元。当你把A、B两路分别接到定时器的CI0和CI1通道上,并使能编码器模式后,硬件会自动根据两路信号的边沿和电平关系决定当前是"加"还是"减"。
具体来说,编码器模式有三种子模式:
| 模式 | 计数触发边沿 | 倍频数 | 适用场景 |
|---|---|---|---|
| 模式1 | 只对CI0(A相)的边沿计数 | 2倍频 | 信号质量差、只需方向判断 |
| 模式2 | 只对CI1(B相)的边沿计数 | 2倍频 | 同上,换一路参考 |
| 模式3 | 同时对CI0和CI1的边沿计数 | 4倍频 | 需要最高计数精度 |
模式3就是我们要的4倍频。它在A、B两路的每个边沿都触发一次计数,一个完整周期下来正好4个计数。配置的时候把SMC(从模式控制)寄存器设成编码器模式3即可。
1.3 为什么有人配了模式3却只得到2倍频
这是实际调试中最常见的"灵异事件"。配置明明写的是模式3,读出来的数却只有理论值的一半。原因通常有两个:
第一个是输入滤波把边沿吃掉了。GD32的定时器每个通道都有独立的输入滤波器(CH0F、CH1F位),如果滤波参数设得过大,高频边沿会被滤掉,导致部分计数丢失。尤其是编码器转速较高时,滤波窗口太宽会直接吞掉窄脉冲。
第二个是极性配置反了。CH0P和CH1P这两位控制输入捕获的极性,如果只反了一路,方向判别逻辑会混乱,计数可能时增时减,看起来就像倍频数不对。正确做法是两路极性保持一致,或者根据实际接线统一取反。
2. 方法一:纯硬件编码器接口模式,最省CPU的4倍频
这是最"正统"的做法,也是GD32官方例程里演示的方式。定时器硬件全权负责解码和计数,CPU只需要定期去读CNT寄存器就行,几乎不占运算资源。
2.1 引脚与定时器的对应关系不能想当然
GD32的定时器通道和GPIO引脚是固定映射的,不是随便选两个引脚就能当编码器输入。以GD32F103系列为例,TIMER1的CH0和CH1默认映射在PA8和PA9,重映射后可以到PA15和PB3。TIMER2的CH0/CH1在PA6和PA7,重映射后到PB4和PB5。
这里有个坑:重映射之后要记得使能AFIO时钟并调用重映射配置函数,否则引脚还是普通GPIO状态,信号根本进不去定时器。我见过有人调了一下午,最后发现是忘了开AFIO时钟。
配置顺序建议这样:
- 使能GPIO时钟和AFIO时钟
- 配置GPIO为浮空输入或上拉输入(编码器输出通常是推挽或集电极开路,上拉输入更稳)
- 调用GPIO_PinRemapConfig做重映射(如果需要)
- 使能定时器时钟
- 配置定时器的编码器接口参数
2.2 编码器接口模式的关键寄存器配置
GD32的标准外设库(或固件库)里,编码器配置主要涉及TIMER_CTL0、TIMER_SMCFG、TIMER_CHCTL0/1这几个寄存器。用库函数写大概是这样:
timer_parameter_struct timer_initpara; timer_ic_parameter_struct timer_icinitpara; /* 定时器基础配置 */ timer_deinit(TIMER2); timer_initpara.prescaler = 0; timer_initpara.alignedmode = TIMER_COUNTER_EDGE; timer_initpara.counterdirection = TIMER_COUNTER_UP; timer_initpara.period = 65535; timer_initpara.clockdivision = TIMER_CKDIV_DIV1; timer_initpara.repetitioncounter = 0; timer_init(TIMER2, &timer_initpara); /* 编码器接口配置 */ timer_quadrature_decoder_mode_config(TIMER2, TIMER_ENCODER_MODE2, /* 模式3在GD32库里对应MODE2枚举 */ TIMER_IC_POLARITY_RISING, TIMER_IC_POLARITY_RISING); /* 输入滤波配置 */ timer_icinitpara.icpolarity = TIMER_IC_POLARITY_RISING; timer_icinitpara.icselection = TIMER_IC_SELECTION_DIRECTTI; timer_icinitpara.icprescaler = TIMER_IC_PSC_DIV1; timer_icinitpara.icfilter = 0x0; /* 先不滤波,调试阶段用 */ timer_input_capture_config(TIMER2, TIMER_CH_0, &timer_icinitpara); timer_input_capture_config(TIMER2, TIMER_CH_1, &timer_icinitpara); /* 使能定时器 */ timer_enable(TIMER2);注意GD32库里的枚举命名和STM32略有差异,TIMER_ENCODER_MODE2实际上对应的是"在CI0和CI1上都计数"的4倍频模式。这个命名容易让人误解,建议直接看库头文件里的注释确认。
2.3 读数和方向判断的正确姿势
配置好之后,读TIMER2的CNT寄存器就能拿到计数值。但有两个细节要注意:
第一,CNT是16位还是32位取决于定时器型号。GD32F103的通用定时器是16位,计到65535会溢出。如果行程较长,要么定期清零,要么用软件扩展高位。GD32F4系列的某些定时器是32位的,就没这个问题。
第二,方向判断不能只看CNT增减。因为溢出和回绕的存在,单纯比较两次读数的大小会误判。正确做法是读TIMER2的CTL0寄存器里的DIR位(计数方向标志),或者用带符号的差值计算:
int16_t delta = (int16_t)(current_cnt - last_cnt);把差值强制转成有符号16位,就能自动处理回绕问题。这个技巧在16位定时器上非常实用。
提示:如果编码器转速很高,建议开启定时器的溢出中断,在中断里累加一个高位计数器,这样就能得到完整的32位甚至64位位置值。
3. 方法二:外部中断+软件解码,灵活但吃CPU
有些场景下定时器的编码器接口不够用,比如需要同时接多路编码器、或者定时器通道被其他功能占用了。这时候可以用外部中断配合软件状态机来做4倍频解码。
3.1 软件解码的状态机设计
思路很简单:把A、B两路都配成双边沿触发的外部中断,每次中断读一次两路的电平,根据"上一次状态"和"当前状态"的组合查表决定加还是减。
状态转移表是这样的(以A、B顺序表示):
| 上一状态 | 当前状态 | 计数变化 |
|---|---|---|
| 00 | 01 | +1 |
| 01 | 11 | +1 |
| 11 | 10 | +1 |
| 10 | 00 | +1 |
| 00 | 10 | -1 |
| 10 | 11 | -1 |
| 11 | 01 | -1 |
| 01 | 00 | -1 |
其他组合(比如00跳到11)说明丢步了,可以选择忽略或报错。这个表本质上就是正交解码的逻辑实现。
3.2 中断频率的估算与CPU占用
软件解码最大的问题是中断频率。假设编码器500线、4倍频、电机转速3000转/分,那么每秒的边沿数是:
500 × 4 × (3000/60) = 100000 次/秒也就是10万次中断每秒。这个频率对GD32F103(72MHz主频)来说压力不小,中断服务程序如果写得不够精简,CPU会被大量占用,影响其他任务。
所以软件解码适合低转速、低线数的场景,或者CPU负载本来就很轻的场合。如果非要用在高转速下,建议把中断优先级设高、ISR里只做最少的操作(读电平、查表、更新计数),把复杂处理放到主循环。
3.3 消抖与滤波在软件层的处理
外部中断方式还有个好处是可以灵活加软件滤波。比如连续读三次电平,取多数值作为有效状态,这样能滤掉毛刺。但代价是ISR变长,中断频率高的时候反而得不偿失。
我的经验是:如果信号质量本身不错,软件解码不需要额外滤波;如果信号毛刺多,优先从硬件上解决(加RC滤波、改善布线、用差分输出编码器),而不是在软件里硬扛。
4. 方法三:DMA+定时器捕获,高转速下的进阶玩法
前两种方法各有局限:硬件编码器模式受限于定时器数量,软件解码又吃CPU。如果既要高转速又要低CPU占用,可以考虑用定时器捕获+DMA的方式。
4.1 用捕获中断记录边沿时间戳
这个思路不是直接计数,而是记录每个边沿到来的时间戳,然后在主循环里根据时间戳序列反推位置和速度。定时器的输入捕获功能可以自动记录边沿到来时的CNT值,配合DMA把捕获值搬到内存缓冲区,CPU几乎不参与。
具体配置:把A、B两路分别接到两个捕获通道,都设成双边沿触发,开启捕获中断或DMA请求。每次捕获事件发生时,硬件自动把当前CNT值存入CCR寄存器,DMA再把它搬到数组里。
4.2 时间戳序列如何还原出位置和速度
拿到时间戳序列后,位置的计算就变成了对边沿的计数——每个边沿代表1/4个栅格,累计边沿数除以4就是栅格数。速度则可以通过相邻时间戳的差值算出来:
速度 = (1/4栅格) / (t2 - t1)这种方式的精度取决于定时器的计数频率。如果定时器跑72MHz,时间戳的分辨率就是约14纳秒,测速精度非常高。
4.3 这种方法的适用边界
DMA+捕获的方式适合高速、高精度测速的场景,比如伺服电机的速度环。但它不适合做位置累计,因为时间戳序列会无限增长,内存扛不住。实际用法通常是:位置用硬件编码器模式累计,速度用捕获时间戳计算,两者结合。
另外,DMA缓冲区的管理需要小心。如果缓冲区满了没及时处理,新数据会覆盖旧数据。建议用双缓冲或者环形缓冲,并在DMA传输完成中断里及时搬运。
5. 三种方法的模式对比与选型建议
把三种方法放在一起对比,选型就清晰了。
| 对比维度 | 硬件编码器模式 | 外部中断软件解码 | DMA+捕获 |
|---|---|---|---|
| 倍频数 | 4倍频 | 4倍频 | 不直接计数 |
| CPU占用 | 极低 | 高(随转速上升) | 低 |
| 最高适用转速 | 很高 | 低到中 | 很高 |
| 占用定时器资源 | 1个定时器 | 2个外部中断 | 1个定时器+1个DMA |
| 位置累计能力 | 强 | 强 | 弱 |
| 测速精度 | 中 | 中 | 高 |
| 实现复杂度 | 低 | 中 | 高 |
| 适合场景 | 通用位置控制 | 低速多路编码器 | 高速测速 |
选型的核心逻辑是:先看转速,再看CPU余量,最后看定时器资源。
- 转速不高、定时器够用:直接上硬件编码器模式,最省心。
- 定时器不够、转速低:外部中断软件解码。
- 高转速、需要精确测速:硬件编码器做位置,DMA捕获做速度。
6. 调试中真正会卡住人的几个细节
前面讲的都是"应该怎么做",但实际调试时,真正让人抓狂的往往是下面这些细节。
6.1 滤波器参数设错导致计数"少一截"
GD32的输入滤波器有个计算公式,滤波效果取决于定时器时钟分频和滤波参数。如果编码器信号频率较高,而滤波参数设得偏大,边沿会被滤掉。判断方法:用示波器看定时器输入引脚上的信号,再对比CNT的变化。如果信号边沿明显但CNT不增,基本就是滤波问题。
调试阶段建议先把icfilter设成0(不滤波),确认计数正常后再逐步加大,找到能滤掉毛刺又不丢边沿的平衡点。
6.2 计数方向反了但没人告诉你
编码器的A、B接线顺序决定了计数方向。如果发现电机正转时CNT在减,不用改代码,把A、B两路对调一下就行。或者改CH0P/CH1P的极性配置。两种方法等效,但对调接线更直观。
6.3 16位溢出在长行程下的隐蔽性
16位定时器计到65535就回绕,如果行程超过这个数,位置值会突然跳变。这个问题在短行程测试时发现不了,一到实际长行程就暴露。解决办法有两个:一是用32位定时器(GD32F4系列有),二是开启溢出中断做软件扩展。
软件扩展的写法:
volatile int32_t position = 0; volatile uint16_t last_cnt = 0; void TIMER2_IRQHandler(void) { if(timer_interrupt_flag_get(TIMER2, TIMER_INT_UP) != RESET) { uint16_t now_cnt = timer_counter_read(TIMER2); int16_t delta = (int16_t)(now_cnt - last_cnt); position += delta; last_cnt = now_cnt; timer_interrupt_flag_clear(TIMER2, TIMER_INT_UP); } }注意这里用有符号差值自动处理了回绕,比单纯判断方向位更可靠。
6.4 编码器信号质量差时的硬件补救
如果软件怎么调都不稳,问题很可能在硬件。常见补救措施:在A、B线上加100欧姆左右的串联电阻限流,对地加几十皮法电容滤高频毛刺,或者换用差分输出的编码器配合差分接收芯片。这些硬件手段比在软件里加滤波有效得多。
7. 从STM32迁移到GD32时容易踩的坑
很多用GD32的人是从STM32转过来的,两者外设高度相似但细节有差异,迁移时容易翻车。
7.1 库函数命名和枚举值的差异
GD32的标准外设库虽然模仿了STM32的风格,但函数名和枚举值不完全一样。比如STM32的编码器模式枚举是TIM_EncoderMode_TI12,GD32里对应的是TIMER_ENCODER_MODE2。直接照搬STM32代码会编译报错,需要对照GD32的库头文件逐个替换。
7.2 时钟树配置的细微不同
GD32F103的时钟树和STM32F103基本一致,但某些型号的PLL配置范围略有差异。如果直接套用STM32的时钟配置,可能出现主频不对或者外设时钟异常。建议用GD32官方的时钟配置工具生成初始化代码,别手抄。
7.3 中断向量表的对应关系
GD32的中断向量表编号和STM32不完全相同,尤其是定时器中断。迁移时如果中断服务函数名写错,中断根本进不去,而且不会有任何报错。这个坑很隐蔽,建议对照GD32的启动文件(startup_gd32fxxx.s)确认中断向量名。
8. 实测数据与性能表现
最后分享一组我在GD32F103C8T6上实测的数据,供参考。
测试条件:500线增量式编码器,4倍频,定时器TIMER2编码器模式3,主频72MHz。
| 转速(RPM) | 理论计数/秒 | 实测计数/秒 | CPU占用 |
|---|---|---|---|
| 300 | 10000 | 10000 | <1% |
| 1500 | 50000 | 50000 | <1% |
| 3000 | 100000 | 99998 | <1% |
| 6000 | 200000 | 199992 | 约1% |
可以看到硬件编码器模式在6000RPM下依然稳定,CPU占用几乎可以忽略。丢失的几个计数出现在转速突变时,属于正常现象。
对比软件解码方式,同样条件下3000RPM时CPU占用已经超过30%,6000RPM时直接跑飞。所以高转速场景下,硬件编码器模式是唯一靠谱的选择。
提示:实测时建议用示波器同时观察A、B信号和CNT变化,这样能快速定位是信号问题还是配置问题。
我个人在实际项目中的体会是,GD32的编码器接口模式虽然配置项不多,但每个参数都有它的脾气。滤波器、极性、重映射这三样,任何一个设错都会导致计数异常,而且现象往往很相似。所以调试时最好按"先不滤波、确认计数、再加滤波"的顺序来,一步步排除,比一上来就配一堆参数然后抓瞎要高效得多。另外,如果项目里编码器数量多,优先考虑用硬件编码器模式,把CPU留给真正需要算力的任务,这个取舍在资源紧张的MCU上尤其重要。