当前位置: 首页 > news >正文

SimpleFOC源码学习07(v2.3.2) - 增量式编码器Encoder.cpp与Encoder.h,从一对 A、B 信号,到速度、方向、绝对位置的完整解法

导言


github 源码:

  • https://github.com/simplefoc/Arduino-FOC/blob/v2.3.2/src/sensors/Encoder.h
  • https://github.com/simplefoc/Arduino-FOC/blob/v2.3.2/src/sensors/Encoder.cpp

你有没有在调 FOC 时遇到电机转向和预期相反,或者速度读数在低速时抖个不停?这两个问题,根子都在增量式编码器的信号处理上。

刚学完Sensor基类后,可以把它理解为一个抽象契约:“任何传感器都必须告诉我当前的机械角度getSensorAngle(),然后由我(基类)负责累加圈数、计算速度。”Encoder就是这个契约的第一个真实实现者——它用正交编码器(Quadrature Encoder)这种硬件来回答"轴现在转到哪了?"

一、硬件原理——什么是正交编码器?


在读代码之前,必须先理解硬件产生的信号长什么样,否则代码里的handleA()pulse_counter += ...会像天书。
一个增量式正交编码器最基本有 2 根信号线 A/B;有些型号还会额外提供 1 根 Z(Index) 信号线。再算上电源与地,常见接线会是 4 根或 5 根:

信号作用
A方波脉冲
B方波脉冲,相位比 A 超前或滞后 90°
Z (Index)可选信号,每转一圈只出现一次的脉冲,用来"对零"
关键点:A 和 B 之所以相差 90°,就是为了判断方向。现在让我用一张图帮你看清 A/B 信号和方向判断的关系:
一个电机转一圈,A 和 B 各产生 PPR 个完整方波(PPR = Pulses Per Revolution)。但每个方波有4 个可识别的边沿:A↑、B↑、A↓、B↓。如果我们把每个边沿都当作一次"计数事件",那么一圈就有4×PPR次计数 —— 这就是CPR = 4×PPR(Counts Per Revolution),也就是正交模式(Quadrature::ON)带来的4 倍分辨率提升。这是理解整个Encoder代码的地基。

二、头文件Encoder.h的结构解剖


classEncoder:publicSensor{...};

一句话就说明了一切:Encoder继承Sensor,要履行 Sensor 留下的合同。让我把头文件的成员按"角色"分类:
1. 硬件配置(构造时确定,之后不变)

  • pinA,pinB,index_pin—— 3 根线接到 MCU 的哪几个引脚
  • cpr—— 字段名虽然叫cpr,但刚构造完成时先暂存用户传入的_ppr;等init()执行后,它才真正表示"每转多少计数"
  • pullup—— 用 MCU 内部上拉还是外部上拉电阻
  • quadrature—— 正交模式开/关

2.中断里会被修改的"共享变量"(注意volatile)

volatilelongpulse_counter;// 累计脉冲数(带符号,方向就是符号)volatilelongpulse_timestamp;// 最后一次脉冲到达的时刻(微秒)volatileintA_active,B_active,I_active;// 当前 A/B/Z 电平的缓存volatileboolindex_found;// 是否已经见过 Z 脉冲

volatile关键字是C/C++ 告诉编译器:“这个变量随时可能被中断改写,别把它优化成寄存器缓存,每次都要从内存真实地读。”这是嵌入式"主循环 vs 中断"共享数据的基本要求。你会在update()里看到配套的noInterrupts()/interrupts()临界区保护。

3.速度计算相关的"历史变量"

floatprev_Th,pulse_per_second;volatilelongprev_pulse_counter,prev_timestamp_us;

其中prev_Thpulse_per_second确实只在主循环里使用,不需要volatile。但prev_pulse_counter之所以仍然是volatile,是因为handleIndex()在做 index 对齐时会顺手修正它,避免速度计算出现假跳变;prev_timestamp_us则主要是主循环中的历史时间戳。

4.履行 Sensor 契约的函数

floatgetSensorAngle()override;// 核心!Sensor 基类强制要求floatgetVelocity()override;// 可选重写(Encoder 选择重写)voidupdate()override;// 可选重写intneedsSearch()override;// 绝对位置支持

5.中断服务函数(ISR 回调)

voidhandleA();// A 线边沿触发时调用voidhandleB();// B 线边沿触发时调用voidhandleIndex();// Z 线边沿触发时调用

这里有一个嵌入式世界的通用模式要强调:Arduino 的attachInterrupt()要求注册一个无参普通函数,不能直接注册类成员函数(因为成员函数隐藏地带着this指针)。所以 SimpleFOC 的使用模式是:用户在自己的.ino里写三个全局薄函数:

Encoderenc(2,3,500);voiddoA(){enc.handleA();}voiddoB(){enc.handleB();}enc.enableInterrupts(doA,doB);

这就是enableInterrupts()接收 3 个函数指针(*doA)()的原因。

三、构造函数与init()—— 对象的出生


构造函数基本就是字段初始化,没有惊喜。但init()里有一行值得专门标注:

// initial cpr = PPR// change it if the mode is quadratureif(quadrature==Quadrature::ON)cpr=4*cpr;

这是一个"语义切换":用户传进来的_ppr参数先被存在cpr字段里,然后根据模式决定是否放大 4 倍。从这一刻起,cpr在整个类里都代表真正的"每转计数值",后续所有/cpr的归一化都不用再区分模式了——非常干净的设计。

init()另一个关键动作是根据pullup配置决定用INPUT_PULLUP还是INPUT光电编码器通常需要上拉电阻,因为它的输出常常是开集(open-collector)—— 它只能把线"拉到地",松开时线是浮空的,必须靠上拉电阻拉回高电平。

四、中断回调 —— 本文件的技术核心


这是Encoder最精妙、也最容易看懵的部分。我们看handleA():

voidEncoder::handleA(){boolA=digitalRead(pinA);// 读当前 A 电平switch(quadrature){caseQuadrature::ON:if(A!=A_active){// 确认是真变化(过滤伪触发)pulse_counter+=(A_active==B_active)?1:-1;pulse_timestamp=_micros();A_active=A;}break;...

看起来只有 4 行,但每一行都有含义。让我用一张表和一张图彻底展开(A_active == B_active) ? 1 : -1这条方向判断的精髓。
handleA()被触发时,说明 A 刚发生了边沿跳变。A_active存的是上一次记录的 A 电平,也就是跳变前的 A。此时观察 A_active 和 B_active(此刻的 B)的关系:

这就是正交编码器的全部方向秘密:通过比较"A 刚才的电平"和"B 现在的电平"是否相同,一次比较就得出方向。handleB()里条件反过来(!=),原因是 B 的边沿相对 A 错开了 90°,奇偶关系正好翻转。
另外注意if ( A != A_active )这道防线——attachInterrupt(..., CHANGE)模式下偶尔会因为毛刺或去抖问题产生"重复触发",这个判断相当于一个软件去抖/幂等保护:如果读出来的 A 跟记录的一样,说明是假触发,直接忽略。

Index 脉冲handleIndex()的对齐逻辑:

longtmp=pulse_counter;pulse_counter=round((double)pulse_counter/(double)cpr)*cpr;prev_pulse_counter+=pulse_counter-tmp;

这三行解决一个实际问题:累计过程中可能产生丢脉冲(比如中断没及时响应,或信号抖动),导致pulse_counter漂移几个计数。Z 脉冲是一圈一次的物理基准,每次触发时把pulse_counter“四舍五入到最近的整圈”——这样长期运行下误差不会累积。

最后那行prev_pulse_counter += pulse_counter - tmp;很精巧:速度计算会用到pulse_counter - prev_pulse_counter这个差,如果只修正了当前值,下一次算速度会出现一个虚假的巨大跳变。所以把校正量同步加到prev_pulse_counter,让速度计算看不到这个阶跃,保证速度曲线的平滑性。这体现了"维护不变量(invariant)"的思维。

五、update()—— 中断世界和主循环世界的桥梁


voidEncoder::update(){noInterrupts();angle_prev_ts=pulse_timestamp;longcopy_pulse_counter=pulse_counter;interrupts();full_rotations=copy_pulse_counter/(int)cpr;angle_prev=_2PI*((copy_pulse_counter)%((int)cpr))/((float)cpr);}

这里就是你在 Sensor 基类里看到的那个update()契约的具体实现。它做三件事:

1. 临界区拷贝 volatile 数据
noInterrupts()暂时关中断 → 把pulse_counterpulse_timestamp快速拷贝到本地变量 →interrupts()立刻开中断。为什么?因为long在 8 位 AVR 上是 4 字节,CPU 没办法"一次"读完——如果正读到一半,中断冲进来把变量改了,主循环就会读到一个"半旧半新"的鬼畜值(这叫torn read,撕裂读)。关中断是最粗暴但最可靠的方案。关中断时间必须极短,所以这里只做简单拷贝和赋值,不做任何运算——运算都放在临界区之外。

是否一定要关中断,取决于update()会不会和修改这些变量的上下文并发执行。本文按 Arduino 常见用法来理解:它和编码器 ISR 并发,所以这里需要临界区保护。

2. 计算full_rotationsangle_prev
还记得 Sensor 基类里的angle_prevfull_rotations吗?基类的getAngle()就是用full_rotations * 2π + angle_prev拼出的绝对角度。Encoder 在这里填上这两个字段的值:

  • full_rotations = copy_pulse_counter / cpr—— 整数除法,直接得圈数
  • angle_prev = 2π × (counter % cpr) / cpr—— 把当前计数在"这一圈内"对应成机械角分量

注意:C++11 规定负数取模结果向零截断,若copy_pulse_counter为负,则angle_prev可能也是负值。这是刻意的——负值表示从零点往反转方向偏移,Sensor 基类的getAngle()full_rotations × 2π + angle_prev能正确处理这个情况,不会产生 bug。

3.angle_prev_ts = pulse_timestamp记录这个角度是"什么时候"测到的,基类的getVelocity()默认实现会用到这个时间戳(虽然 Encoder 还重写了自己的版本)。

六、getVelocity()—— 混合测频/测周期法


速度计算是整个Encoder最有工程味的部分。理论上你有两种经典方法测角速度:

  • M 法(测频):固定时间窗Ts,数窗内来了几个脉冲dN。高速时好用,低速时dN经常是 0 或 1,分辨率很差。
  • T 法(测周期):数相邻两个脉冲的时间差。低速时好用,高速时时间差太短,计时分辨率不够。
    SimpleFOC 用的是M/T 混合法。关键一行:
floatdt=Ts+prev_Th-Th;pulse_per_second=(dN!=0&&dt>Ts/2)?dN/dt:pulse_per_second;

dt的含义一开始容易看混,用一条时间轴来拆解它:

上次调用 上次脉冲 本次脉冲 本次调用 |←─ prev_Th ─→| |←── Th ──→| |←──────────────────── Ts ───────────────────→| dt = Ts + prev_Th - Th ≈ 上次脉冲 → 本次脉冲 的真实间距

这里的dt不是简单的timestamp - prev_timestamp,而是"从上一次真正的脉冲,到这一次真正的脉冲"的时间——用Th(本次调用时刻距最后一个脉冲的时间)修正掉了尾部的空闲时间。这样dN/dt得到的速度更准确,尤其在低速情况下避免了"脉冲落在采样点附近"导致的抖动。

其余几个细节:

  • if ( Th > 0.1f) pulse_per_second = 0;—— 100ms 都没来新脉冲,认为电机停了,直接清零,避免速度"挂"在旧值上。
  • if(Ts <= 0 || Ts > 0.5f) Ts = 1e-3f;—— 处理_micros()溢出异常或者长时间没调用的边界情况。还记得在time_utils篇里分析过的吗?_micros()unsigned long大约71.6 分钟溢出回绕一次(详见第4篇),这里就是在防御这种时刻。
  • velocity = pulse_per_second / cpr * 2π—— 从"脉冲/秒"换算成"弧度/秒",这是整个类的输出。

七、needsSearch()—— 对 Sensor 契约的另一个答复


intEncoder::needsSearch(){returnhasIndex()&&!index_found;}

Sensor 基类里的needsSearch()是问"你是不是需要 FOC 启动阶段做一次开环搜索来找到零点?"。这里用了一个三段式的逻辑,清晰地覆盖了三种状态:

  • 没有 Z 线→ 不需要搜,反正也找不到绝对零点
  • 有 Z 线但还没见过 Z 脉冲→ 需要搜,让电机转一下触发 Z
  • 已经见过 Z 脉冲→ 不需要搜,已经对齐了

这个函数会被BLDCMotor::initFOC()调用,决定是否触发开环旋转去找 index。

八、总结


Encoder 是如何填满 Sensor 合同的?
几个值得记住的要点:

  1. cpr = 4 × pprinit()里一次性完成语义转换,之后全类都以 CPR 为单位思考,是很干净的设计模式。
  2. volatile+noInterrupts()/interrupts()临界区 + 本地拷贝是嵌入式"ISR 与主循环共享数据"的标准三件套,后面看HallSensor时你会看到几乎一样的模式。
  3. 方向判断的精髓是:某一路发生边沿时,比较"边沿前的本相信号"和"另一相当前信号"的关系来判断方向。其中handleA()判断相等,handleB()判断不等,而A_active/B_active保存的正是这个"边沿前"的历史状态。
  4. Index 脉冲的四舍五入对齐 + 同步修正prev_pulse_counter,体现了"修正绝对量时必须同步修正差分量以免产生虚假跳变"的思维,跟你在 PID 里看到的 anti-windup 精神是相通的——保护状态变量的一致性
  5. 混合 M/T 测速法通过dt = Ts + prev_Th - Th这个小技巧,用脉冲实际到达的时刻替代采样边界,在低速下显著提高精度。

如果把前面几篇串起来看,已经形成了一条完整的底层传感器链路:

  • time_utils(第4篇)解决的是"时间基准"
  • foc_utils(第5篇)解决的是"高频数学运算"
  • Sensor基类(第6、7篇)定义了"传感器契约"
  • Encoder(本篇)是这个契约的第一个完整实现,集成了中断驱动、临界区保护、M/T 混合测速的全套工程实践

下一篇进入HallSensor,看基于霍尔传感器的位置感知是如何实现的——你会发现它和Encoder的代码结构高度相似,但在方向判断和速度计算上有自己的特殊处理。


你在使用增量式编码器时有没有遇到过方向读反、低速速度抖动或 Index 对齐失效的问题?欢迎在评论区聊聊你的排查过程——这类问题排查起来现象很玄学,但原因往往就藏在这几行中断代码里。

http://www.cnnetsun.cn/news/1913539.html

相关文章:

  • PyTorch 2.1 编译优化:TorchScript到AOT
  • Python 并发编程:asyncio vs threading vs multiprocessing
  • TDesign Vue Next 表格虚拟滚动深度解析:如何实现万级数据秒级渲染?
  • CVPR 2026 | 提速100倍!首个端到端Real-to-Sim物体级感知与重建框架
  • 海南省乡镇界SHP数据实战:从ArcGIS加载到WGS84坐标解析
  • 2025届必备的五大AI辅助写作神器解析与推荐
  • 瑞萨RZN2L固件加密指南:利用OTP和UID实现安全升级
  • 避开宝塔强制绑定:我为什么选择降级到7.4.5而非最新版,以及背后的版本安全考量
  • Go语言的反射机制
  • C#怎么实现SignalR实时通信 C#如何用SignalR实现服务端向客户端推送实时消息通知【框架】
  • 爱毕业aibiye等七家专业团队凭借在线论文辅导服务,在行业内树立了标杆地位
  • 大麦网Python自动化抢票脚本终极指南:告别手速比拼
  • Pandas数据合并完全指南:merge、concat、join从入门到精通
  • 2025届毕业生推荐的五大AI辅助写作方案推荐
  • Synopsys DW_apb_i2c实战:从零配置到多主机仲裁避坑指南
  • 3分钟快速上手:VideoDownloadHelper视频下载助手完整指南
  • Gitee CodePecker SCA:构筑企业数字化安全防线的智能卫士
  • 为什么你的神经网络训练效果差?可能是激活函数没选对!
  • 基于增强大气散射模型的图像去雾与曝光优化实践
  • 终极指南:如何免费解锁Cursor AI编程助手Pro功能完全教程
  • 终极指南:3步实现无VR设备观看VR视频的完整解决方案
  • 纺织厂选啥降温设备?蒸发冷省电空调或是最优解!
  • QT上位机实战:STM32串口烧录BIN文件的完整流程与常见问题排查
  • 你的 Vue 3 defineSlots(),VuReact 会编译成什么样的 React?
  • MySQL如何限制触发器递归调用的深度_防止触发器死循环方法
  • 如何判断坐标点所在的象限?
  • [具身智能-372]:动态环境中的主动适应,直面“动态”与“不确定性”。:具身智能与传统机器人的范式跃迁
  • 2026最权威的十大AI写作平台横评
  • PX4飞控固件编译调试避坑实录:从GCC版本冲突到Python模块缺失的完整解决流程
  • 3分钟学会AI音频修复:让模糊录音重获清晰生命的完整指南