news 2026/4/15 23:04:17

SimpleFOC源码学习08(v2.3.2) - 霍尔编码器HallSensor.cpp与HallSensor.h,背后的状态机—6个扇区是怎么驱动 FOC 的?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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.h
  • https://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/SPI,3 根数字输入引脚就够了
  • 不适合高精度位置控制,但通常足够做速度闭环(如果需要提升分辨率,也可以在此基础上加插补算法,后续的《源码改进》系列会专门讨论)

一、硬件原理——3 个霍尔的 6 个状态


定子里的 3 个霍尔传感器 A、B、C在电角度上彼此错开 120°。当带永磁体的转子旋转一个电周期时,每个霍尔都会输出一个方波,三路信号之间的相位差正好也是 120°。

如果把 ABC 这 3 个二进制位拼成一个 3-bit 数,理论上有 8 种组合(000~111)。但在理想工作状态下,真正会稳定出现的只有 6 种000111通常被视为非法状态,它们往往意味着接线异常、信号噪声,或者边沿过渡时的瞬态异常。
这 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 04
1C 翻转 (权重1)1 0 15+1
2A 翻转 (权重4)0 0 11-4
3B 翻转 (权重2)0 1 13+2
4C 翻转 (权重1)0 1 02-1
5A 翻转 (权重4)1 1 06+4
6B 翻转 (权重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_sector==1)→ 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_active=digitalRead(pinA);updateState();}// B channelvoidHallSensor::handleB(){B_active=digitalRead(pinB);updateState();}// C channelvoidHallSensor::handleC(){C_active=digitalRead(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_state=C_active+(B_active<<1)+(A_active<<2);// glitch avoidance #1 - sometimes we get an interrupt but pins haven't changedif(new_hall_state==hall_state){return;}hall_state=new_hall_state;int8_tnew_electric_sector=ELECTRIC_SECTORS[hall_state];if(new_electric_sector-electric_sector>3){//underflowdirection=Direction::CCW;electric_rotations+=direction;}elseif(new_electric_sector-electric_sector<(-3)){//overflowdirection=Direction::CW;electric_rotations+=direction;}else{direction=(new_electric_sector>electric_sector)?Direction::CW:Direction::CCW;}electric_sector=new_electric_sector;// glitch avoidance #2 changes in direction can cause velocity spikes. Possible improvements needed in this areaif(direction==old_direction){// not oscilating or just changed directionpulse_diff=new_pulse_timestamp-pulse_timestamp;}else{pulse_diff=0;}pulse_timestamp=new_pulse_timestamp;total_interrupts++;old_direction=direction;if(onSectorChange!=nullptr)onSectorChange(electric_sector);}

4.1、拼接 3-bit 状态

int8_tnew_hall_state=C_active+(B_active<<1)+(A_active<<2);

把三个独立的 0/1 位拼成一个 3-bit 整数:A 在最高位,C 在最低位。于是new_hall_state ∈ {0..7},正好对应ELECTRIC_SECTORS[]的索引。

4.2、毛刺防御#1

if(new_hall_state==hall_state){return;}

这和你在Encoder里看到的if (A != A_active)本质类似:硬件中断系统偶尔会出现虚假触发,比如电磁干扰、边沿不干净,或者输入信号抖动。
软件层面最直接的防御就是先判断"状态到底有没有变化",如果没变,就立刻返回。这种幂等性检查是嵌入式代码里的常见写法。

4.3、查表获取新 sector

int8_tnew_electric_sector=ELECTRIC_SECTORS[hall_state];

这是一次O(1)的查表,没有额外算术。
不过这里暗含一个小隐患:如果因为噪声、接线问题,或者采样到了瞬时非法状态000/111,那么new_electric_sector就会变成-1,后面的方向判断也会被带偏。SimpleFOC 这里没有显式处理这种情况,因此这是实战中值得补防御的一个点。如果你在移植时发现方向判断偶尔出错,可以考虑在查表之后加一行守卫:

int8_tnew_electric_sector=ELECTRIC_SECTORS[hall_state];// 建议补充:过滤非法状态(000 或 111 对应 sector = -1)if(new_electric_sector<0){return;// 忽略噪声导致的非法状态,不更新 direction/sector}

对应这张表:

4.4、方向判断 + 圈数累积

if(new_electric_sector-electric_sector>3){direction=Direction::CCW;// underflow: e.g. 0 → 5electric_rotations+=direction;}elseif(new_electric_sector-electric_sector<(-3)){direction=Direction::CW;// overflow: e.g. 5 → 0electric_rotations+=direction;}else{direction=(new_electric_sector>electric_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{CW=1,CCW=-1,UNKNOWN=0};

所以electric_rotations += direction等价于:CW 时+= 1,CCW 时+= -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,跨零回绕是±5,3 正好把这两种情况分开。也就是说,在理想情况下,合理的 diff 只会是±1±5;如果出现±2、±3、±4,那通常意味着丢中断、噪声,或者状态采样异常。

4.5、毛刺防御 #2:方向翻转时清空速度

if(direction==old_direction){pulse_diff=new_pulse_timestamp-pulse_timestamp;}else{pulse_diff=0;}

这是一个很实用的工程防御。想象一下:如果电机刚才还在 CW 转,随后因为抖动,或者真的开始减速并反向,那么第一个 CCW 的pulse_diff会是什么?
它实际上会包含"上一次 CW 脉冲到这一次 CCW 脉冲"的整段时间。
但这段时间对应的物理过程,往往是"减速、停下、再反向加速",显然不是一个稳定方向下的速度测量值。如果直接拿它算速度,曲线上就很容易出现明显尖峰。

所以代码的处理策略是:只要检测到方向刚发生变化,就先把pulse_diff清零。这样下一次getVelocity()会返回 0,等到再下一次脉冲到来、方向稳定下来之后,再恢复正常测速。
这就是注释里那句 “changes in direction can cause velocity spikes” 的含义:作者知道这是一个实际存在的问题,而这里给出的,是一种比较保守的缓解方法。

4.6、total_interruptsonSectorChange回调

total_interrupts++;if(onSectorChange!=nullptr)onSectorChange(electric_sector);
  • total_interrupts是一个调试计数器。注释里提到,它有时可以用来识别中断异常,比如弱上拉导致一秒钟触发大量中断。实战中如果你发现这个数字异常暴涨,通常说明信号质量有问题。
  • onSectorChange是一个用户可选的回调钩子。sector 变化时,用户可以立即收到通知,并据此实现最基础的六步换向,而不一定非要走 FOC 这条路径。这是 SimpleFOC 留出的一个扩展接口。

五、getSensorAngle()—— 从 sector 到弧度


floatHallSensor::getSensorAngle(){return((float)(electric_rotations*6+electric_sector)/(float)cpr)*_2PI;}

这一行把"当前累计经过了多少个 sector"映射成累计机械角位置(弧度)。拆开看:

  • electric_rotations * 6 + electric_sector→ 从上电到现在累计经过的 sector 总数
  • / cpr→ 归一化到"转过了多少个机械圈"
  • * _2PI→ 换成弧度

记住cpr = pp × 6。举个例子:如果pp = 7,那么cpr = 42。假设当前electric_rotations = 3electric_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_ts=pulse_timestamp;longlast_electric_rotations=electric_rotations;int8_tlast_electric_sector=electric_sector;interrupts();angle_prev=((float)((last_electric_rotations*6+last_electric_sector)%cpr)/(float)cpr)*_2PI;full_rotations=(int32_t)((last_electric_rotations*6+last_electric_sector)/cpr);}

结构和Encoder::update()几乎一模一样,三件套完全一致:

  1. noInterrupts()/interrupts()临界区内拷贝 volatile 数据
  2. 把累积值拆成"圈数部分"(/) 和"圈内角度部分"(%)
  3. 填充 Sensor 基类的full_rotationsangle_prevangle_prev_ts

这正是 Sensor 基类设计的价值所在:无论底层是光电编码器还是霍尔传感器,update()最后填充的都是同一组字段。
因此,基类里的getAngle()getPreciseAngle()等接口,就可以对所有子类使用统一逻辑。

七、getVelocity()—— 测周期法(T 法)


这里和Encoder不一样。Encoder用的是混合 M/T 法,而HallSensor用的是更纯粹的 T 法:

floatHallSensor::getVelocity(){noInterrupts();longlast_pulse_timestamp=pulse_timestamp;longlast_pulse_diff=pulse_diff;interrupts();if(last_pulse_diff==0||((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_active=digitalRead(pinA);B_active=digitalRead(pinB);C_active=digitalRead(pinC);updateState();

init()结尾这四行很重要:它会主动读取一次当前三个引脚的电平,并立即调用一次updateState()。这样一来,在用户调用enableInterrupts()之前,electric_sectorhall_state就已经有了合理初值。
如果不做这一步,第一次中断到来时,electric_sector可能还停留在默认值,从而导致一次虚假的大跳变判断。

九、整体架构对比:Encoder vs HallSensor



结合上图,可以把EncoderHallSensor的差异归纳为三个层面:

中断触发模式不同。Encoder的两个回调(handleA/handleB)各自能独立判断方向——拿到 A 相跳变时,读一下 B 相当前电平就够了,局部信息即可决策。HallSensor则不行,任何一路跳变都只给了信息的三分之一,必须把三位状态合并才能确定扇区,所以三个回调全部汇聚到同一个updateState()来统一处理。

位置精度与速度估算的取舍不同。Encoder每个机械圈可以产生数千到数万个脉冲,M/T 混合法可以在较宽的速度范围内保持良好的精度。HallSensor每机械圈最多6 × pp个离散点,低速时脉冲极度稀疏,因此只能依赖纯 T 法,并配合"方向翻转清零"和"速度过期检测"这两道软件防线来维持基本可用的速度反馈。

和 Sensor 基类的对接方式相同,但数据来源不同。两者的update()最终都填充同一组字段(full_rotationsangle_prevangle_prev_ts),让上层控制器可以无差别地调用。区别在于数据的来源:Encoder靠增量脉冲计数,HallSensorelectric_rotations × 6 + electric_sector的累积扇区数。

十、这一篇可以记住的几个结论


  1. ELECTRIC_SECTORS[]是一张手工构造的查找表,把看起来"不连续"的hall_state重新映射为连续的 0~5 扇区编号,是整个方向判断逻辑能够简洁运作的基础。
  2. 方向判断阈值 3 的选取不是随意的:正常跳变是±1,跨零回绕是±5,3 正好居中分割两种情况。
  3. cpr = pp × 6中的cpr含义是"机械一圈内的扇区总数",而不是脉冲数;极对数越大,cpr越高,分辨率也越高。
  4. pulse_diff = 0是方向翻转时的速度清零保护,而"过去时间超过上次脉冲周期 2 倍"是停转时的速度归零保护——两道防线的触发时机不同。
  5. init()里主动读一次引脚电平再调updateState(),是为了在首次中断到来前就建立合理的初始状态,避免第一次跳变被误判为大幅度位置变化。
  6. 三个中断回调都汇聚到updateState()的设计,是由霍尔传感器"状态必须全局合并才可判定"的物理特性决定的,而不是代码风格选择。

你在用霍尔传感器做速度闭环时,有没有遇到过低速段速度反馈抖动、或者方向判断偶尔出错的问题?这类问题往往比调 PID 参数更难排查——接线顺序、上拉阻值、中断优先级都可能是根因。欢迎在评论区聊聊你的排查思路,说不定就帮到了下一个踩坑的人。

下一篇进入磁传感器系列,看基于 SPI/I2C 接口的MagneticSensorSPI是如何在绝对角度读取和速度估算之间做权衡的。

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

Photon光影包:Minecraft视觉革命的完整指南

Photon光影包&#xff1a;Minecraft视觉革命的完整指南 【免费下载链接】photon A gameplay-focused shader pack for Minecraft 项目地址: https://gitcode.com/gh_mirrors/photon3/photon Photon光影包是一款专注于游戏体验的Minecraft着色器包&#xff0c;通过先进的…

作者头像 李华
网站建设 2026/4/15 22:58:13

提升你的编码效率,Claude-Mem 插件带来无缝记忆体验!

Claude-Mem 是为 Claude Code 提供的一个持久内存压缩系统,该插件自动捕捉您在编码会话中的所有操作,并利用 AI(结合 Claude 的 agent-sdk)压缩信息,将相关上下文注入到未来的会话中。这意味着即使会话结束或断开连接,Claude 也能保持对项目的知识连续性。 快速开始 安…

作者头像 李华
网站建设 2026/4/15 22:56:13

如何快速掌握跨平台资源下载工具:res-downloader实用指南

如何快速掌握跨平台资源下载工具&#xff1a;res-downloader实用指南 【免费下载链接】res-downloader 视频号、小程序、抖音、快手、小红书、直播流、m3u8、酷狗、QQ音乐等常见网络资源下载! 项目地址: https://gitcode.com/GitHub_Trending/re/res-downloader res-dow…

作者头像 李华
网站建设 2026/4/15 22:54:37

资源配置化技术中的资源规划资源调度资源优化

资源配置化技术&#xff1a;规划、调度与优化的核心逻辑 在数字化与智能化快速发展的今天&#xff0c;资源配置化技术已成为企业提升效率、降低成本的关键手段。资源规划、资源调度与资源优化作为其核心环节&#xff0c;通过科学分配与动态调整&#xff0c;实现资源利用的最大…

作者头像 李华
网站建设 2026/4/15 22:53:27

多模态大模型偏见消除不是调参——而是重构对齐范式:详解因果干预+反事实增强+跨模态对抗解耦三重技术栈

第一章&#xff1a;多模态大模型偏见检测与消除 2026奇点智能技术大会(https://ml-summit.org) 多模态大模型在图像理解、语音生成与文本推理的联合建模中展现出强大能力&#xff0c;但其训练数据固有的社会性偏差会跨模态传播并放大——例如&#xff0c;将“护士”图像高频关…

作者头像 李华
网站建设 2026/4/15 22:53:18

面试前一晚,只看这一页

文章目录前言一、2026面试核心变天&#xff1a;告别八股文&#xff0c;实战才是硬通货1.1 面试官最看重的3个核心维度1.2 2026年必踩的3个面试雷区二、后端开发必背&#xff1a;2026高频核心考点&#xff08;附实战思路&#xff09;2.1 Java后端高频考点&#xff08;2026年更新…

作者头像 李华