SimpleFOC源码学习08(v2.3.2) - 霍尔编码器HallSensor.cpp与HallSensor.h,背后的状态机—6个扇区是怎么驱动 FOC 的?
导言github 源码https://github.com/simplefoc/Arduino-FOC/blob/v2.3.2/src/sensors/HallSensor.hhttps://github.com/simplefoc/Arduino-FOC/blob/v2.3.2/src/sensors/HallSensor.cpp在第 8 篇分析了增量式编码器Encoder之后这篇来看另一类在 BLDC 电机上极为常见的位置传感器——霍尔传感器。为什么 BLDC 电机要用霍尔传感器在学Encoder时你看到的是一种外挂式的测量方案用户额外购买一个独立的光电编码器再把它安装到电机轴末端。但很多 BLDC 电机比如平衡车、航模、云台、hoverboard 电机本身就在定子里集成了 3 个霍尔传感器几乎不需要额外成本。它们最初并不是为 FOC 准备的而是为更早期的六步换向trapezoidal commutation提供转子位置反馈。这也决定了HallSensor的几个核心特点分辨率很低一个电周期只有 6 个离散位置每档对应 60° 电角度它天然测的是电角度不是机械角度因此必须结合极对数pp才能换算不需要外部 ADC/SPI3 根数字输入引脚就够了不适合高精度位置控制但通常足够做速度闭环如果需要提升分辨率也可以在此基础上加插补算法后续的《源码改进》系列会专门讨论一、硬件原理——3 个霍尔的 6 个状态定子里的 3 个霍尔传感器 A、B、C在电角度上彼此错开 120°。当带永磁体的转子旋转一个电周期时每个霍尔都会输出一个方波三路信号之间的相位差正好也是 120°。如果把 ABC 这 3 个二进制位拼成一个 3-bit 数理论上有 8 种组合000~111。但在理想工作状态下真正会稳定出现的只有 6 种。000和111通常被视为非法状态它们往往意味着接线异常、信号噪声或者边沿过渡时的瞬态异常。这 6 种合法状态按顺序循环正好把一个电周期切成 6 份每份 60° 电角度称为一个sector扇区。1.1、霍尔码的本质3 个独立方波拼出来的二进制数ABC 三个霍尔传感器各自输出一个方波彼此错开 120° 电角度。把它们拼成 3-bit 数时每一位的权重是固定的hall_state A×4 B×2 C×1。但问题在于每次只有一个 bit 翻转这和 Gray code 的性质相似而翻转的是哪一位取决于当前物理位置和二进制权重大小没有直接关系。从下面这个实际序列可以看到在这里采用的相序约定下顺时针旋转时的翻转顺序是C、A、B、C、A、B…这是由传感器的物理排布和接线顺序共同决定的步骤翻转的 bitA B C十进制值变化量起始1 0 041C 翻转 (权重1)1 0 1512A 翻转 (权重4)0 0 11-43B 翻转 (权重2)0 1 1324C 翻转 (权重1)0 1 02-15A 翻转 (权重4)1 1 0646B 翻转 (权重2)1 0 04-2差值序列是1, -4, 2, -1, 4, -2。可以看到如果直接看hall_state的数值增减序列并不连续。你可以试着自己把这列差值排出来看看——规律一出来就会明白为什么 SimpleFOC 选择了查表而不是直接比较数值大小。1.2、ELECTRIC_SECTORS[]到底是一张什么样的表下面这张图把 8 个索引位置全部展开帮你看清它的结构// seq 1 5 4 6 2 3 1 000 001 010 011 100 101 110 111constint8_tELECTRIC_SECTORS[8]{-1,0,4,5,2,1,3,-1};1.3、通过ELECTRIC_SECTORS[]将hall_state变成扇区 n源码注释写的是seq 1 5 4 6 2 3 1。这里表达的是按库中约定的顺时针方向hall_state会沿着这条序列循环。完整映射如下第 1~5 步中new - old始终是1因此可以直接判定为 CW。第 6 步里sector 从 5 回绕到 0差值变成0 - 5 -5。此时触发 -3条件程序将其识别为overflow也就是跨零回绕而不是一次真正的 CCW 跳变同时执行electric_rotations directionDirection::CW 1表示又走完了一个电周期。它本质上是一张手工构造的查找表用来把看起来不连续的hall_state重新映射成连续的 0~5 扇区编号。这样一来updateState()里的方向判断就会非常简单if(new_electric_sector-electric_sector1)→ CWif(new_electric_sector-electric_sector-1)→ CCW// 溢出/下溢用 3 / -3 处理跨零二、cpr的全新含义看构造函数HallSensor::HallSensor(int_hallA,int_hallB,int_hallC,int_pp){...cpr_pp*6;// hall has 6 segments per electrical revolution }这行非常值得停下来琢磨。在Encoder里cpr通常可以直接理解为机械一圈内的计数数目。但在HallSensor里它的含义稍微绕一点一个电周期有 6 个 sector一个机械周期包含pp个电周期pp pole pairs极对数所以机械一圈内的 sector 总数6 × pp这就是这里的cpr举个具体例子一个 hoverboard 电机如果pp 15那么cpr 90。也就是说电机机械转一圈时你最多只能得到 90 个离散位置点每个点之间相差360° / 90 4°机械角度。和光电编码器动辄 1000~10000 的 PPR 相比这个分辨率至少低了一个数量级。这也解释了为什么HallSensor更适合做速度反馈而不是高精度位置反馈。三、中断回调霍尔状态更新// A channelvoidHallSensor::handleA(){A_activedigitalRead(pinA);updateState();}// B channelvoidHallSensor::handleB(){B_activedigitalRead(pinB);updateState();}// C channelvoidHallSensor::handleC(){C_activedigitalRead(pinC);updateState();}每个函数都只有两行代码非常简洁。核心区别在于状态信息到底是局部可判定的还是必须全局合并后才能判定。在Encoder里每次 A 相或 B 相跳变时方向信息已经局部可得例如可以通过比较另一相当前电平来判断方向所以两个回调可以各自独立处理计数。在HallSensor里单独看某一根线的跳变还不够。你必须把(A, B, C)三位状态合起来才能判断当前转子处于哪个 sector。因此这三个回调函数的职责都一样先更新自己对应的那一位电平再统一调用updateState()做整体处理。四、updateState() - 本文件的心脏/** * Updates the state and sector following an interrupt */voidHallSensor::updateState(){longnew_pulse_timestamp_micros();int8_tnew_hall_stateC_active(B_active1)(A_active2);// glitch avoidance #1 - sometimes we get an interrupt but pins havent changedif(new_hall_statehall_state){return;}hall_statenew_hall_state;int8_tnew_electric_sectorELECTRIC_SECTORS[hall_state];if(new_electric_sector-electric_sector3){//underflowdirectionDirection::CCW;electric_rotationsdirection;}elseif(new_electric_sector-electric_sector(-3)){//overflowdirectionDirection::CW;electric_rotationsdirection;}else{direction(new_electric_sectorelectric_sector)?Direction::CW:Direction::CCW;}electric_sectornew_electric_sector;// glitch avoidance #2 changes in direction can cause velocity spikes. Possible improvements needed in this areaif(directionold_direction){// not oscilating or just changed directionpulse_diffnew_pulse_timestamp-pulse_timestamp;}else{pulse_diff0;}pulse_timestampnew_pulse_timestamp;total_interrupts;old_directiondirection;if(onSectorChange!nullptr)onSectorChange(electric_sector);}4.1、拼接 3-bit 状态int8_tnew_hall_stateC_active(B_active1)(A_active2);把三个独立的 0/1 位拼成一个 3-bit 整数A 在最高位C 在最低位。于是new_hall_state ∈ {0..7}正好对应ELECTRIC_SECTORS[]的索引。4.2、毛刺防御#1if(new_hall_statehall_state){return;}这和你在Encoder里看到的if (A ! A_active)本质类似硬件中断系统偶尔会出现虚假触发比如电磁干扰、边沿不干净或者输入信号抖动。软件层面最直接的防御就是先判断状态到底有没有变化如果没变就立刻返回。这种幂等性检查是嵌入式代码里的常见写法。4.3、查表获取新 sectorint8_tnew_electric_sectorELECTRIC_SECTORS[hall_state];这是一次O(1)的查表没有额外算术。不过这里暗含一个小隐患如果因为噪声、接线问题或者采样到了瞬时非法状态000/111那么new_electric_sector就会变成-1后面的方向判断也会被带偏。SimpleFOC 这里没有显式处理这种情况因此这是实战中值得补防御的一个点。如果你在移植时发现方向判断偶尔出错可以考虑在查表之后加一行守卫int8_tnew_electric_sectorELECTRIC_SECTORS[hall_state];// 建议补充过滤非法状态000 或 111 对应 sector -1if(new_electric_sector0){return;// 忽略噪声导致的非法状态不更新 direction/sector}对应这张表4.4、方向判断 圈数累积if(new_electric_sector-electric_sector3){directionDirection::CCW;// underflow: e.g. 0 → 5electric_rotationsdirection;}elseif(new_electric_sector-electric_sector(-3)){directionDirection::CW;// overflow: e.g. 5 → 0electric_rotationsdirection;}else{direction(new_electric_sectorelectric_sector)?Direction::CW:Direction::CCW;}这段逻辑值得重点理解。sector 的合法变化一次只能跨 1 格所以在正常情况下new - old只可能是1或-1。但在边界处也就是从 sector 5 回到 sector 0或者从 sector 0 退回 sector 5 时差值会突然变成-5或5。这时就不能再按数值大小粗暴判断而必须把它识别为一次wraparound环绕回跳。这里有一个细节值得单独说清楚electric_rotations direction并不是把枚举值赋给整数的魔法。SimpleFOC 中Direction是普通枚举非enum class其定义如下enumDirection:int8_t{CW1,CCW-1,UNKNOWN0};所以electric_rotations direction等价于CW 时 1CCW 时 -1。理解electric_rotations的含义非常关键electric_rotations 电周期的累计圈数不是机械圈数electric_sector 当前电周期内的 sector 编号(0~5)总位置 electric_rotations × 6 electric_sector单位是第几个 sector所以electric_rotations只会在5↔0 的 wraparound处变化这正是检测|diff| 3的意义。为什么阈值取 3因为正常跳变是±1跨零回绕是±53 正好把这两种情况分开。也就是说在理想情况下合理的 diff 只会是±1或±5如果出现±2、±3、±4那通常意味着丢中断、噪声或者状态采样异常。4.5、毛刺防御 #2方向翻转时清空速度if(directionold_direction){pulse_diffnew_pulse_timestamp-pulse_timestamp;}else{pulse_diff0;}这是一个很实用的工程防御。想象一下如果电机刚才还在 CW 转随后因为抖动或者真的开始减速并反向那么第一个 CCW 的pulse_diff会是什么它实际上会包含上一次 CW 脉冲到这一次 CCW 脉冲的整段时间。但这段时间对应的物理过程往往是减速、停下、再反向加速显然不是一个稳定方向下的速度测量值。如果直接拿它算速度曲线上就很容易出现明显尖峰。所以代码的处理策略是只要检测到方向刚发生变化就先把pulse_diff清零。这样下一次getVelocity()会返回 0等到再下一次脉冲到来、方向稳定下来之后再恢复正常测速。这就是注释里那句 “changes in direction can cause velocity spikes” 的含义作者知道这是一个实际存在的问题而这里给出的是一种比较保守的缓解方法。4.6、total_interrupts和onSectorChange回调total_interrupts;if(onSectorChange!nullptr)onSectorChange(electric_sector);total_interrupts是一个调试计数器。注释里提到它有时可以用来识别中断异常比如弱上拉导致一秒钟触发大量中断。实战中如果你发现这个数字异常暴涨通常说明信号质量有问题。onSectorChange是一个用户可选的回调钩子。sector 变化时用户可以立即收到通知并据此实现最基础的六步换向而不一定非要走 FOC 这条路径。这是 SimpleFOC 留出的一个扩展接口。五、getSensorAngle()—— 从 sector 到弧度floatHallSensor::getSensorAngle(){return((float)(electric_rotations*6electric_sector)/(float)cpr)*_2PI;}这一行把当前累计经过了多少个 sector映射成累计机械角位置弧度。拆开看electric_rotations * 6 electric_sector→ 从上电到现在累计经过的 sector 总数/ cpr→ 归一化到转过了多少个机械圈* _2PI→ 换成弧度记住cpr pp × 6。举个例子如果pp 7那么cpr 42。假设当前electric_rotations 3、electric_sector 4那么总 sector 数就是22对应的机械角位置为22 / 42 × 2π ≈ 3.29 rad ≈ 188°。注释里那句TODO: numerical precision issue here if the electrical rotation overflows the angle will be lost提醒的是一个长期运行时的精度问题。这和 Sensor 基类里提到的float很难同时兼顾很大的圈数和很细的小角度其实是同一个问题。六、update()—— 填充 Sensor 基类字段voidHallSensor::update(){noInterrupts();angle_prev_tspulse_timestamp;longlast_electric_rotationselectric_rotations;int8_tlast_electric_sectorelectric_sector;interrupts();angle_prev((float)((last_electric_rotations*6last_electric_sector)%cpr)/(float)cpr)*_2PI;full_rotations(int32_t)((last_electric_rotations*6last_electric_sector)/cpr);}结构和Encoder::update()几乎一模一样三件套完全一致noInterrupts()/interrupts()临界区内拷贝 volatile 数据把累积值拆成圈数部分(/) 和圈内角度部分(%)填充 Sensor 基类的full_rotations、angle_prev、angle_prev_ts这正是 Sensor 基类设计的价值所在无论底层是光电编码器还是霍尔传感器update()最后填充的都是同一组字段。因此基类里的getAngle()、getPreciseAngle()等接口就可以对所有子类使用统一逻辑。七、getVelocity()—— 测周期法T 法这里和Encoder不一样。Encoder用的是混合 M/T 法而HallSensor用的是更纯粹的 T 法floatHallSensor::getVelocity(){noInterrupts();longlast_pulse_timestamppulse_timestamp;longlast_pulse_diffpulse_diff;interrupts();if(last_pulse_diff0||((long)(_micros()-last_pulse_timestamp)last_pulse_diff*2)){return0;}else{returndirection*(_2PI/(float)cpr)/(last_pulse_diff/1000000.0f);}}为什么要用 T 法因为 Hall 的分辨率太低M 法固定时间窗内数脉冲在低速时经常会遇到一个脉冲都数不到的情况。T 法则直接测相邻两次 sector 切换之间的时间差再反推速度。由于每次 sector 切换都对应一个固定角度增量2π / cpr所以这种做法在低分辨率传感器上更实用。这里有两个关键防御①last_pulse_diff 0这说明方向刚刚翻转过回忆 4.5 节当前测量不可信所以直接返回 0。②_micros() - last_pulse_timestamp last_pulse_diff * 2这是一个很典型的速度过期检测。它实际上在问“从上一次脉冲到现在已经过去的时间是否超过了上一个脉冲周期的 2 倍”把这句话翻译成物理意义就是如果电机在平稳减速那么脉冲间隔应该逐渐变长但如果距离上次脉冲的时间已经变成上次脉冲间隔的 2 倍以上说明当前转速已经明显低于之前那次测量值。这时如果还沿用旧的pulse_diff来算速度就会明显高估所以程序干脆直接返回 0。这可以看作是Encoder::getVelocity()里if (Th 0.1f) pulse_per_second 0的 Hall 版本只不过这里使用的是相对过期而不是绝对超时因此更自适应。最后的速度公式direction*(_2PI/cpr)/(last_pulse_diff/1e6)_2PI / cpr 每个 sector 对应的机械角度增量弧度last_pulse_diff / 1e6 两个 sector 之间的时间差秒两者相除 角速度rad/s乘direction得到带符号速度八、init()—— 一个容易被忽略的小细节A_activedigitalRead(pinA);B_activedigitalRead(pinB);C_activedigitalRead(pinC);updateState();init()结尾这四行很重要它会主动读取一次当前三个引脚的电平并立即调用一次updateState()。这样一来在用户调用enableInterrupts()之前electric_sector和hall_state就已经有了合理初值。如果不做这一步第一次中断到来时electric_sector可能还停留在默认值从而导致一次虚假的大跳变判断。九、整体架构对比Encoder vs HallSensor结合上图可以把Encoder和HallSensor的差异归纳为三个层面中断触发模式不同。Encoder的两个回调handleA/handleB各自能独立判断方向——拿到 A 相跳变时读一下 B 相当前电平就够了局部信息即可决策。HallSensor则不行任何一路跳变都只给了信息的三分之一必须把三位状态合并才能确定扇区所以三个回调全部汇聚到同一个updateState()来统一处理。位置精度与速度估算的取舍不同。Encoder每个机械圈可以产生数千到数万个脉冲M/T 混合法可以在较宽的速度范围内保持良好的精度。HallSensor每机械圈最多6 × pp个离散点低速时脉冲极度稀疏因此只能依赖纯 T 法并配合方向翻转清零和速度过期检测这两道软件防线来维持基本可用的速度反馈。和 Sensor 基类的对接方式相同但数据来源不同。两者的update()最终都填充同一组字段full_rotations、angle_prev、angle_prev_ts让上层控制器可以无差别地调用。区别在于数据的来源Encoder靠增量脉冲计数HallSensor靠electric_rotations × 6 electric_sector的累积扇区数。十、这一篇可以记住的几个结论ELECTRIC_SECTORS[]是一张手工构造的查找表把看起来不连续的hall_state重新映射为连续的 0~5 扇区编号是整个方向判断逻辑能够简洁运作的基础。方向判断阈值 3 的选取不是随意的正常跳变是±1跨零回绕是±53 正好居中分割两种情况。cpr pp × 6中的cpr含义是机械一圈内的扇区总数而不是脉冲数极对数越大cpr越高分辨率也越高。pulse_diff 0是方向翻转时的速度清零保护而过去时间超过上次脉冲周期 2 倍是停转时的速度归零保护——两道防线的触发时机不同。init()里主动读一次引脚电平再调updateState()是为了在首次中断到来前就建立合理的初始状态避免第一次跳变被误判为大幅度位置变化。三个中断回调都汇聚到updateState()的设计是由霍尔传感器状态必须全局合并才可判定的物理特性决定的而不是代码风格选择。你在用霍尔传感器做速度闭环时有没有遇到过低速段速度反馈抖动、或者方向判断偶尔出错的问题这类问题往往比调 PID 参数更难排查——接线顺序、上拉阻值、中断优先级都可能是根因。欢迎在评论区聊聊你的排查思路说不定就帮到了下一个踩坑的人。下一篇进入磁传感器系列看基于 SPI/I2C 接口的MagneticSensorSPI是如何在绝对角度读取和速度估算之间做权衡的。