导言
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 种。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…(这是由传感器的物理排布和接线顺序共同决定的):
| 步骤 | 翻转的 bit | A B C | 十进制值 | 变化量 |
|---|---|---|---|---|
| 起始 | 1 0 0 | 4 | ||
| 1 | C 翻转 (权重1) | 1 0 1 | 5 | +1 |
| 2 | A 翻转 (权重4) | 0 0 1 | 1 | -4 |
| 3 | B 翻转 (权重2) | 0 1 1 | 3 | +2 |
| 4 | C 翻转 (权重1) | 0 1 0 | 2 | -1 |
| 5 | A 翻转 (权重4) | 1 1 0 | 6 | +4 |
| 6 | B 翻转 (权重2) | 1 0 0 | 4 | -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 += direction(Direction::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_interrupts和onSectorChange回调
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 = 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_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()几乎一模一样,三件套完全一致:
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_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_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,跨零回绕是±5,3 正好居中分割两种情况。 cpr = pp × 6中的cpr含义是"机械一圈内的扇区总数",而不是脉冲数;极对数越大,cpr越高,分辨率也越高。pulse_diff = 0是方向翻转时的速度清零保护,而"过去时间超过上次脉冲周期 2 倍"是停转时的速度归零保护——两道防线的触发时机不同。init()里主动读一次引脚电平再调updateState(),是为了在首次中断到来前就建立合理的初始状态,避免第一次跳变被误判为大幅度位置变化。- 三个中断回调都汇聚到
updateState()的设计,是由霍尔传感器"状态必须全局合并才可判定"的物理特性决定的,而不是代码风格选择。
你在用霍尔传感器做速度闭环时,有没有遇到过低速段速度反馈抖动、或者方向判断偶尔出错的问题?这类问题往往比调 PID 参数更难排查——接线顺序、上拉阻值、中断优先级都可能是根因。欢迎在评论区聊聊你的排查思路,说不定就帮到了下一个踩坑的人。
下一篇进入磁传感器系列,看基于 SPI/I2C 接口的MagneticSensorSPI是如何在绝对角度读取和速度估算之间做权衡的。