手拿一套刚焊好的FOC驱动板,程序烧进去,转动给定旋钮,电机没有按照预想流畅地转起来,而是“嗡”地抖了一下,然后卡在一个角度不停颤动。这是我在群里见过太多次的新手现场。排查下来,大部分情况并不是电流环PID参数没调好,而是增量式编码器的零位没有校准,导致电角度基准是乱的。
对FOC来说,电角度就是命根子。Park变换、逆Park变换全靠这个角度做坐标映射,角度错了,电压矢量方向就错,电流环根本闭环不起来。增量式编码器天生只能输出相对位置,上电那一刻你不知道转子在哪,所以必须通过一种可靠的手段,把编码器的计数位置和电机真实的电角度零点“绑定”起来。这篇文章就把我自己反复在STM32平台上使用的这套零位校准方法拆开讲,一共5个关键步骤,每一步为什么要这么做、代码怎么写、实际调试中会踩什么坑,一起说清楚。刚接触FOC、正在为初始位置对齐发愁的朋友,可以直接按这个流程走一遍。
1. 增量式编码器只能测“相对位置”,FOC却需要“绝对电角度”
1.1 先搞清“电角度”和“机械角度”的关系
很多新手第一反应是:编码器已经能测角度了,直接用它不就行了?问题就出在FOC需要的是电角度,不是机械角度。
电机转一圈是360度机械角度,但如果是4对极的PMSM,定子磁场旋转一圈,转子只需要转过90度机械角度。也就是说,机械一圈内电角度会重复变化4圈,每圈360度。写成公式就是:
电角度 = 机械角度 × 极对数
这里“极对数”指的是转子永磁体的磁极对数,不是极数。常见的小型航模电机有些是7对极、14对极,伺服电机有4对极、5对极。如果不把这个参数搞对,后面算出来的一切电角度都是错的。
控制上,我们只关心一个机械周期内电角度的相对变化,因为多圈重复不影响FOC运行。但关键是:你至少要确定“当前转子位置对应哪个电角度”。增量式编码器给不了这个信息,它只能告诉你“转子从上次位置移动了多少”,上电瞬间转子的绝对电角度是未知的。
1.2 增量式编码器缺什么:一个绝对基准
绝对式编码器上电就能直接读出机械角度,配合极对数就能算出电角度,所以不需要零位校准。增量式编码器只有A相、B相、Z相三路信号,A相和B相用来测相对位移和方向,Z相每机械一圈只输出一个脉冲,理论上可以作为机械零点参考。
但问题在于:仅仅知道“Z信号出现的位置”还不够,你得知道“Z信号出现时,电角度是多少”。这个值跟电机绕组接法、编码器安装相位、驱动器SVPWM矢量角度定义都有关系,每一套硬件都不一样,所以必须现场校一次。
另外,Z信号只在电机转到特定位置时才出现。上电时转子可能停留在任意角度,如果你依赖Z信号来找零点,就必须先把电机拖到Z信号附近,这在某些应用场景(比如低速直驱、负载惯性大的设备)会很麻烦。更通用、更省事的办法是“强制对齐”:利用电流让转子自己转到已知电角度,然后记录编码器计数值,把“编码器值域”和“电角度值域”的偏差一次性算出来并保存。
2. 校准前先做这3件事:硬件接线、线数确认、电流环预检
2.1 编码器接线与Z信号的正确连接
校零前,先把编码器接线确认一遍。增量式编码器常见接口是A+、A-、B+、B-、Z+、Z-六线差分输出,也有集电极开路输出的。接到STM32时,我习惯用定时器的编码器模式来处理A、B相,用另一个外部中断或者定时器从模式来处理Z信号。
比如用TIM3的CH1、CH2接A、B相,编码器模式下计数器会自动4倍频计数,方向也自动处理。Z信号可以接到TIM3的CH3,配置为从模式复位,这样每经过一次Z信号,计数器就会自动清零,省得软件里反复判断。
接线时有个高频问题:A、B相接反了,计数器方向会反,正转变成反转。这个在校准时不会立刻暴露,因为对齐过程中转子是被电流拖着走的,编码器方向反了只会在后续闭环运行时出现角度“来回跳”的怪象。建议上电后用示波器或逻辑分析仪确认一下AB相位关系,或者写个小函数手动转动电机轴,观察计数增减是否和转向一致。这一步多花两分钟,后面能省两小时。
2.2 确认编码器线数和极对数,写进宏定义
校准前必须知道自己手里这套硬件的两个关键数字:
- 编码器分辨率:比如2500线的编码器,4倍频后一圈会有2500×4=10000个计数脉冲。
- 电机极对数:比如电机标注是“2204 KV1400 12N14P”,14极就是7对极。
这两个参数直接参与零位偏移的计算。我的习惯是在工程头部用宏定义写清楚,避免后面到处魔法数:
#define ENCODER_CPR (2500U * 4U) /* 编码器4倍频后每圈计数 */ #define MOTOR_POLE_PAIRS 7U /* 电机极对数 */ #define COUNTS_PER_ELEC (ENCODER_CPR / MOTOR_POLE_PAIRS) /* 每个电周期对应的编码器计数 */注意,COUNTS_PER_ELEC不一定是整数。比如2500×4=10000,7对极,10000/7约等于1428.57。直接用整数除法会累积误差,后面代码里我会用“先乘极对数再取模”的办法来避免这个精度损失。
2.3 电流环能不能锁住转子,其实是可以先验的
强制对齐的本质是:给一个固定的电流矢量,产生确定的电磁力矩,把转子拉到一个机械平衡位置。如果电流环根本没调通,或者电流采样方向反了,那“给定一个电流”就是一句空话。
所以在做零位校准之前,我会先做一个非常简单的开环测试:在电流环里给定一个固定的电角度(比如0度),同时给定一个较小的Iq目标值,然后慢慢加大Iq,看电机轴能不能稳定在一个位置。如果电流一加大,转子就往一个方向跑个不停,说明电流环方向或者编码器方向有问题,先解决掉,不要急着做校准。
另外还要确认电流环的PI参数能稳住。对齐过程本质是让电流快速建立并保持恒定,如果PI参数太软,电流建立慢,转子会在原地发抖;如果PI参数太激进,会有明显的电流过冲,转子和机构也会被冲得“咯噔”一下。通常我用一个折中的PI参数,先保证电流稳态误差小,再考虑动态响应。
3. 零位校准的5个关键步骤,从强迫对齐到偏移固化
3.1 第一步:把电机置于“待对齐”状态,禁用速度环和位置环
开始校准前,程序里要保证只有电流环在工作,速度环和位置环全部旁路。原因很简单:对齐过程希望转子自由运动到电磁平衡位置,如果速度环或位置环还在干预,就会跟校准逻辑打架。
校准通常放在伺服使能之前,也就是刚上电、主回路母线电压建立之后。此时PWM输出默认封锁,编码器初始计数值可以直接读取。我习惯先把编码器计数器清零或者读一个初始值保存,但注意初始值后来不会被直接用到,真正重要的是对齐完成后的读取值。
3.2 第二步:给d轴施加正向电流,让转子强迫锁到0度
关键操作来了:给定电流环目标为Id = I_cal,Iq = 0,同时把SVPWM的电压角度固定为0度。这样定子产生的合成磁动势方向就固定在你定义的“电角度0”方向,转子会在电磁力矩作用下转动,直到永磁体磁极方向和定子磁动势方向重合。
为什么是d轴正方向而不是q轴?因为d轴对应磁链方向,永磁体转子会自己“找”到和定子磁场对齐的位置,这是一个无自转力矩的稳定平衡点。如果给q轴电流,产生的是切向力矩,转子会一直转下去,根本停不住。
这里有个细节:I_cal取多大?太小了,电磁力矩克服不了摩擦力,转子停不准;太大了,线圈发热严重,甚至可能退磁。我常用的经验值是额定电流的10%~20%。比如额定电流5A的电机,用0.5A~1A做对齐。如果机构摩擦力比较大,适当加大到30%额定电流。校准是在上电瞬间完成的,持续时间很短,几十毫秒到一两百毫秒就行,所以发热风险不大。
代码示意:
foc_set_angle(0); /* SVPWM电压角度固定在0度 */ foc_set_current(0, I_CAL); /* Id = I_CAL, Iq = 0 */ HAL_Delay(200); /* 等待转子稳定 */3.3 第三步:等待转子稳定后,记录编码器计数值
给电流之后,转子会经历一个衰减振荡过程,最后停在平衡位置。等待时间取决于转动惯量和阻尼,轻载小电机100ms基本够;大惯量负载可能需要更久。最可靠的做法不是盲目等固定时间,而是连续读取编码器计数值,判断连续几次读数变化小于阈值后才认为稳定。
硬实时控制中,我一般把“等待+采样”放在主循环外的独立状态机里:
uint32_t last_cnt = 0; uint32_t stable_cnt = 0; while (stable_cnt < 20) { uint32_t cur_cnt = TIM3->CNT; if (abs_diff(cur_cnt, last_cnt) < 3) { stable_cnt++; } else { stable_cnt = 0; } last_cnt = cur_cnt; HAL_Delay(1); }判断稳定后,马上读取编码器计数值,记为enc_offset_cnt。这个值就是“真实电角度0度对应的编码器位置”。注意在这个过程中不要再次复位编码器计数器,否则就没法算偏差了。
3.4 第四步:计算零位偏移并保存到非易失存储
拿到enc_offset_cnt之后,就可以定义任意时刻的真实电角度了。核心思想是:以编码器零点为基准,先用极对数把机械位置换算成“假电角度”,再减去校准位置对应的假电角度,就能修正为“真电角度”。
假电角度α的计算:
α = (当前编码器计数 × 极对数) mod 编码器每圈总计数,再归一化到0~359.99度
真实电角度θ = α - α_offset
其中α_offset就是校准时的假电角度:
α_offset = (enc_offset_cnt × 极对数) mod ENCODER_CPR
为了避免浮点和除法精度损失,我实际代码里用的是纯整数运算,见第4节代码。
偏置值需要保存。存到哪里?常见选择是STM32内部Flash的最后一个扇区或备份寄存器。内部Flash要注意擦写寿命和扇区大小,不能频繁写;备份寄存器掉电会丢数据,除非你有后备电池。如果产品允许每次上电都重新校准,那就不需要保存,这也是最简单可靠的方式。大多数伺服驱动器默认上电做一次整定,就是这个道理。
3.5 第五步:上电自动加载或重新校准,闭环验证
最后一步是把整个流程串起来。我习惯写一个calibrate_and_boot()函数,流程是:
- 初始化电流环、编码器
- 执行强制对齐
- 读取enc_offset_cnt
- 如果需要保存则写入Flash
- 切换到正常闭环模式
校准完成后,先施加速度环或位置环小指令,确认电机能平滑跟随,没有卡顿、反转、来回抖的问题。如果一切正常,再把负载接上做最终验证。实际项目里,有些场合要求上电快速启动,比如电动工具、泵类负载,没时间慢慢对齐,那就必须把偏置保存到Flash,下次上电直接读。我会在Flash里存一个魔数校验(比如0xA5A5A5A5)和CRC,防止读到无效数据。
4. 关键代码怎么组织:对齐、读值、计算、验证、存储
4.1 编码器初始化:TIM编码器模式
以下以STM32F405为例,TIM3的CH1/CH2接编码器AB相,TIM3的CH3接Z信号并配置为从模式复位。HAL库初始化代码如下:
TIM_Encoder_InitTypeDef enc = {0}; TIM_SlaveConfigTypeDef sSlaveConfig = {0}; enc.EncoderMode = TIM_ENCODERMODE_TI12; enc.IC1Polarity = TIM_ICPOLARITY_RISING; enc.IC2Polarity = TIM_ICPOLARITY_RISING; enc.IC1Filter = 0x0F; enc.IC2Filter = 0x0F; HAL_TIM_Encoder_Init(&htim3, &enc); sSlaveConfig.SlaveMode = TIM_SLAVEMODE_RESET; sSlaveConfig.InputTrigger = TIM_TS_TI1FP1; sSlaveConfig.TriggerPolarity = TIM_TRIGGERPOLARITY_RISING; sSlaveConfig.TriggerFilter = 0x0F; HAL_TIM_SlaveConfigSynchro(&htim3, &sSlaveConfig); HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL);把Z信号配置为从模式复位,意味着每转一圈计数器会自动清零,这样编码器读数永远落在0~ENCODER_CPR-1范围内,方便后续计算,也天然消除了重装载溢出问题。
4.2 电角度计算:纯整数实现
这是整个零位校准的落地点。给定任意编码器计数enc_cnt,计算0~65535范围内的电角度(便于SVPWM查表):
uint16_t enc_to_elec_angle(uint32_t enc_cnt, uint32_t enc_offset_cnt) { int32_t delta = (int32_t)(enc_cnt - enc_offset_cnt); /* 归一化到 [0, ENCODER_CPR) */ if (delta < 0) { delta += ENCODER_CPR; } /* 把机械差值放大到电角度域:delta * POLE_PAIRS mod ENCODER_CPR */ uint32_t elec_cnt = (uint32_t)((uint64_t)delta * MOTOR_POLE_PAIRS % ENCODER_CPR); /* 电角度映射到 0~65535 */ return (uint16_t)((uint64_t)elec_cnt * 65536U / ENCODER_CPR); }这里每次运行时都传入enc_offset_cnt,好处是每次上电就算重新校准,也只需要换一个偏置值,不用改其他逻辑。注意int32_t做差时,要考虑两个计数都在0~9999之间,差值不会超过±9999,所以不会溢出;如果ENCODER_CPR超过32767,就要用int32_t甚至int64_t,这点在选定时器位宽时要留意。
4.3 校准函数整体实现
uint32_t foc_zero_calibrate(void) { /* 关闭PWM输出,禁止速度环/位置环 */ foc_set_control_mode(CURRENT_MODE_ONLY); /* 强迫对齐到电角度0 */ foc_set_angle(0); foc_set_current(I_CAL, 0); /* Id = I_CAL, Iq = 0 */ foc_enable_output(1); /* 等待转子稳定,连续50次读数变化小于3 */ uint32_t last_cnt = TIM3->CNT; uint32_t stable_cnt = 0; while (stable_cnt < 50) { uint32_t cur_cnt = TIM3->CNT; uint32_t diff = (cur_cnt > last_cnt) ? (cur_cnt - last_cnt) : (last_cnt - cur_cnt); if (diff < 3) { stable_cnt++; } else { stable_cnt = 0; } last_cnt = cur_cnt; HAL_Delay(1); } uint32_t enc_offset_cnt = TIM3->CNT; foc_enable_output(0); /* 可选:保存到Flash,写入魔数+校验 */ save_offset_to_flash(enc_offset_cnt); return enc_offset_cnt; }这是一个函数级别的封装,主程序里调用一次即可。后面正常运行时,只需要获得当前TIM3->CNT,调用enc_to_elec_angle()就能得到实时电角度,送给Park变换和逆Park变换。
4.4 从Flash加载偏置
如果不想每次上电都花时间做对齐,可以上电先检查Flash里是否有合法偏置:
uint32_t read_offset_from_flash(void) { uint32_t *magic = (uint32_t *)FLASH_OFFSET_ADDR; if (*magic == 0xA5A5A5A5) { return *(uint32_t *)(FLASH_OFFSET_ADDR + 4); } return 0xFFFFFFFF; /* 无效标志 */ }注意Flash写入前需要先擦除整个扇区,而且擦写次数有限制。如果设备频繁上下电且每次都要更新偏置,建议改用外部EEPROM或者限制写次数。我做的产品里,通常只在出厂时写入一次,平时上电直接读取,不重复擦写。
Flash缺失时,程序会自动转入对齐校准流程,校准完成后重新写Flash。这就形成了一种“有偏置用偏置,没偏置就现校”的健壮策略。
5. 我在实际调试中踩过的4个坑
5.1 极对数填错,零位校准“成功”了但电机根本转不起来
用对极数14的电机,极对数却填成了7,这倒没错。容易错的是:看到电机规格书上写“14P”,直接填14,把极数当极对数。极对数应该是7。错误填成14之后,电角度变化速度会变成真实值的两倍,电流环给定角度的超前/滞后关系全乱,表现就是电机在某一个角度范围内转得动,换一个方向就堵转、啸叫。排查办法很简单:手动缓慢旋转电机轴,同时上位机看电角度变化,转一圈下来,电角度应该变化“极对数×360”度。如果数值比理论值大一倍或小一半,十有八九是极对数填错了。
5.2 电流环没先调通,对齐时转子乱跑
零位校准依赖电流环建立可控的定子磁动势。如果电流环没调好,实际电流和给定电流完全对不上,那么就算你对齐了“假0度”,实际转子停的也不是真0度。更麻烦的是,如果电流采样方向反了,负反馈变成正反馈,电流会暴涨,转子剧烈抖动,这时候一定不要急着校准,先把电流环“驯服”。
我的建议是:做零位校准前,先用示波器看电流波形,确认电流能快速跟踪给定,并且稳态误差很小。如果电流环是乱的,后面对齐、验证都是白搭。这也是很多项目“零位校准明明做了,电机还是不行”的根本原因。
5.3 编码器A/B相接反,计算结果像个“弹簧”
A/B反接后,编码器计数器方向是反的,但强制对齐时转子是被电流拖过去的,所以校准时enc_offset_cnt还是能读到一个值,看起来一切正常。可一旦进入闭环运行,正向转动时编码器计数在减小,电角度却在往回退,电机就会表现出很强的“弹簧感”和振荡。检测A/B是否反接的土办法是:校准时,让程序在电角度0度逐步加载一个递增的微小Iq偏置,观察转子转向;或者更简单,进入闭环后给一个很小很小的速度指令,看电机实际转向是不是和指令方向一致。不一致就交换A/B接线,或者用软件把TIM编码器方向配置反过来。
5.4 Z信号没接或毛刺严重,每次上电校准结果都不一样
在使用“计数器在Z信号处清零”的方案时,Z信号毛刺多、滤波不够,会导致编码器计数随机被复位,校准时读到错误的enc_offset_cnt。另一个常见问题是Z信号根本没接,但你又配了从模式复位,那么每次转到某个干扰位置就可能清零一次,久而久之偏置就漂了。
排查方法:把TIM3->CNT放到上位机上实时看,手动缓慢转轴一圈,观察计数值是在0~CPR-1内连续变化,还是有额外跳变。如果有跳变,先加强输入滤波(增大IC1Filter、IC2Filter和TriggerFilter),或者检查接线、加200欧到1K欧串联电阻和100nF对地电容。Z信号不是本次算法的必要条件,所以如果你确定用不上Z信号,就别开从模式复位,干脆让计数器自由累加,在读值时统一取模,也能正常工作。
5.5 校验一下“对齐方向”和“运行方向”是否一致
这条虽然不是“坑”,但很容易被忽略:强制对齐时你固定SVPWM电压角度为0度,而运行时你用的电角度是从编码器读出来、经过偏置修正后的值。这里的两套角度坐标系必须完全一致。极端情况下,如果两套坐标不一致(比如代码里一个角度用弧度,一个用度,或者SVPWM实现里角度方向反了),校准时转子确实能锁住,但一闭环就跑飞。
所以每次完成校准,我都习惯做一个“电角度一致性验证”:给定一个0度电流矢量方向,然后人为给定一个90度的电流矢量,观察转子是否往前转过90度电角度。如果方向不对,很可能就是角度坐标系反了,需要统一处理。
6. 校准精度验证:让数据说话
6.1 用手转轴连续观察电角度波形
校准完成后,最简单的验证方式是:断开负载,用手缓慢匀速转动电机输出轴,同时通过串口或上位机把实时电角度打包发出来,在虚拟示波器里看波形。正常情况下,电角度应该是0~65535连续变化的锯齿波,转机械一圈,会看到N个完整锯齿波,N等于极对数。画出来像一排整齐的斜坡,没有突变、反向回跳,说明基础映射关系正确。
这一步能暴露非常多问题:如果有跳变,多半是取值没做归一化;如果锯齿波中间出现平顶或者反向,多半是A/B方向或极对数设置问题。
6.2 位置闭环阶跃与方向验证
接下来让电机进入位置环,给定一个小角度阶跃,比如30度、90度、180度。观察响应曲线,位置应该平滑收敛,没有超调振荡到离谱的程度,更不应该出现“先反方向冲一下再回来”的情况。如果出现反向预冲,先不要想着整定位置环,回头检查电角度映射方向和电流环方向。
同样的测试再做一次反向阶跃,确认正反转方向都正常。零位校准的误差往往表现为“正方向准,反方向偏差大”,因为偏置角误差在正反转时产生的转矩角误差方向相反。
6.3 Z信号二次校验
如果你的系统硬件里有Z信号,可以做一个更精确的二次校验:手动转动电机轴,等待Z信号触发,在Z信号中断里读取当前电角度。因为Z信号对应的是一个固定机械位置,所以每次触发时电角度应该是一个固定值(允许微小波动)。如果每次读取结果都漂移很大,说明编码器联轴器打滑、Z信号抗干扰差,或者机械装配有问题。
这个固定值还能反过来帮你推演偏置是否准确:如果Z信号触发时的电角度理论值和你期望的一致,那校准精度就很有把握。偏差超过几度电角度时,我才会重新审视整个校准流程。
6.4 精度量化:怎么估零位校准误差
增量式编码器零位校准的精度,本质上取决于对齐过程中转子停位的重复性。用同一个流程在校准后重新执行10次,每次记录enc_offset_cnt,看最大值和最小值的差。这个差值折合成电角度,就是你的重复精度。工业伺服一般要求零点重复精度在0.1度机械角以内,折算到电角度就是0.1×极对数度。对于7对极电机,就是0.7度电角度以内。
如果重复精度很差,优先怀疑:对齐电流太小、机构摩擦力不均匀、编码器分辨率不够、等待稳定时间太短。把对齐电流从10%额定提到20%额定,通常能明显改善重复性。
按我个人习惯,做量产出厂校准的时候,会连续校准3次,取平均值作为最终的enc_offset_cnt,能进一步消除随机误差。这个技巧在成本敏感、编码器装配公差较大的项目里特别实用。
最后分享一个小技巧:调试FOC时,别把“零位校准”和“电角度方向”两件事混在一起排查。零位校准负责建立偏置,电角度方向负责正反转一致性。如果电机动起来发飘、抖动,先用软件把Iq设为0、手动给定一个固定角度试转子反应,再做整机闭环调试。这样拆开定位问题,比一头扎进校准代码里改来改去要高效得多。编码器零位这件事,说穿了就是一个“对齐→记录→补偿”的流程,把每一步的物理意义想清楚了,代码怎么写都不会乱。