1. 位置环规划为什么值得单独拎出来讲
搞过FOC的人都有一个共识:电流环是根基,速度环是骨架,位置环才是真正决定“手感”的那一层。很多朋友调电流环调得挺顺,波形也漂亮,一到位置环就发现要么响应慢半拍,要么到位时抖一下,要么走个小距离来回蹭。问题往往不在PID参数本身,而在于位置指令是怎么给出来的。
传统做法无非两种:阶跃给定和梯形/S形速度规划。阶跃给定响应最快,但加速度理论上无穷大,机械冲击大,到位后容易振荡;梯形规划稍微好点,但加速度突变点依然存在,对皮带、丝杆、联轴器这些柔性传动不友好。S形规划能解决加速度突变,但实现起来参数多、计算量大,在STM32F103这种资源有限的片子上跑起来有点吃力。
我这次要聊的方案,是用一个Sigmoid函数直接生成位置环的平滑过渡曲线。它的核心优势是:一个公式、两个参数、天然平滑、计算量小,而且非常适合用Q16定点数在MCU上实现。整套东西我在STM32F103RCT6驱动无刷电机的平台上实测过,从位置指令生成到最终转子运动,全程没有出现明显的抖动和过冲。
这篇文章适合谁看?如果你正在做PMSM/BLDC的FOC控制,已经跑通了电流环和速度环,正在为位置环的平滑性发愁;或者你对定点数运算在电机控制里的落地感兴趣,想找一个具体案例来理解Q格式的用法,那这篇内容应该能给你一些直接能抄的思路。我会从数学原理讲到Q16定点实现,再到代码结构和实测现象,尽量把每个“为什么”都说清楚。
2. Sigmoid函数到底凭什么能用来做位置规划
2.1 从生物神经元到电机位置曲线
Sigmoid函数的标准形式是:
[ \sigma(x) = \frac{1}{1 + e^{-x}} ]
它的形状是一条从0平滑上升到1的S形曲线,中间陡、两头缓。这个特性最早是在神经网络里当激活函数用的,因为它能把任意实数映射到(0,1)区间,而且导数连续。
把它搬到位置规划上,思路就很直接了:把归一化的时间映射到Sigmoid的自变量,输出就自然是一条从0到1的平滑位置曲线。假设我们要从位置A走到位置B,总行程Δ = B - A,那么任意时刻的位置可以写成:
[ pos(t) = A + \Delta \cdot \sigma(k \cdot (t - t_0)) ]
其中k控制曲线的陡峭程度,也就是“加速快慢”;t0是曲线中心点对应的时间。t从0到T的过程中,pos从A平滑过渡到B。
2.2 为什么它比梯形和S形规划更省事
我列一个对比表,把三种常见位置规划方式放在一起看:
| 规划方式 | 加速度连续性 | 参数数量 | 计算量 | 实现难度 | 适用场景 |
|---|---|---|---|---|---|
| 阶跃给定 | 不连续(冲击) | 0 | 极低 | 极低 | 点位要求极低 |
| 梯形速度 | 加速度突变 | 3(vmax, acc, dec) | 低 | 低 | 通用点位 |
| S形速度 | 加速度连续 | 5+(加加速度限制) | 中高 | 中高 | 高精度柔性传动 |
| Sigmoid位置 | 加速度连续 | 2(k, T) | 低 | 低 | 资源受限MCU |
关键点在于:Sigmoid曲线的一阶导和二阶导都是连续的,意味着速度和加速度都不会突变。而它的计算只需要一次指数运算,如果用定点数查表或者近似,连指数运算都能省掉。
2.3 参数k和T分别控制什么
很多人第一次用Sigmoid做规划,容易把k和T搞混。我用实际调试的经验来说:
- T决定总过渡时间。T越大,整个运动越慢,但曲线形状不变。
- k决定曲线的“陡峭度”。k越大,中间段越陡,起步和停止越突然;k越小,曲线越平缓,加速越柔和。
有一个经验公式可以参考:当k·T ≈ 10~14时,曲线两端已经足够平缓,起点和终点的速度接近零。如果k·T太小,比如小于6,起点速度不为零,会有明显的“弹射感”;如果太大,比如超过20,中间段几乎变成阶跃,失去平滑意义。
我在STM32F103上实测,对于一个小型云台电机,行程90度,T取0.3s,k取40,k·T=12,效果比较理想。这个值不是固定的,要根据负载惯量和机械柔性来调。
注意:k和T不是独立的,真正影响曲线形状的是它们的乘积。调参时先定T满足节拍要求,再调k满足平滑度要求。
3. Q16定点数:为什么不用浮点,又怎么落地
3.1 STM32F103的浮点困境
STM32F103RCT6是Cortex-M3内核,没有硬件浮点单元。所有float运算都是软件模拟,一次浮点乘法可能要几十个周期,一次指数运算更是上百周期。在电流环这种20kHz的场合,浮点还能勉强撑住;但位置环如果每1ms算一次,还要做指数运算,CPU负载会明显上升。
更麻烦的是,浮点运算在中断里执行时间不确定,可能导致控制周期抖动。对于电机控制这种对时序敏感的场景,定点数是更稳妥的选择。
3.2 Q16格式的基本规则
Q16的意思是:用一个32位有符号整数,低16位表示小数部分,高16位表示整数部分。实际值 = 整数存储值 / 65536。
比如:
- 1.0 存储为 65536
- 0.5 存储为 32768
- 0.1 存储为 6554(0.1×65536≈6553.6,取整)
- -2.5 存储为 -163840
Q16的精度是1/65536≈0.0000153,对于位置规划来说完全够用。范围是-32768到32767,对于归一化到0~1的Sigmoid输出,绰绰有余。
3.3 Q16下的乘法与溢出处理
两个Q16数相乘,结果是Q32格式,需要右移16位才能回到Q16。这里有个坑:如果直接乘,中间结果可能溢出32位。比如两个接近1.0的Q16数相乘,65536×65536=4294967296,已经超过int32的最大值2147483647。
解决办法有两种:
- 用int64中间变量,乘完再右移。STM32F103支持64位运算,但速度慢。
- 先右移再乘,牺牲一点精度。比如(a>>8)*(b>>8)>>0,相当于Q8×Q8=Q16。
我在实际代码里用的是第一种,因为位置环频率不高(1kHz),64位运算的开销可以接受。如果要在更高频率下跑,可以用第二种,但要注意精度损失。
3.4 Sigmoid的定点近似
直接算e^{-x}在定点上很麻烦。我的做法是查表+线性插值:
- 把x的范围(比如-8到8)分成256段,每段存一个Q16的Sigmoid值。
- 查表时根据x找到相邻两个表项,做线性插值。
这样一次Sigmoid计算只需要几次乘加和一次查表,在STM32F103上大概几十个周期就能完成。表的大小是256×4字节=1KB,对于RCT6的256KB Flash来说毫无压力。
如果你想要更高精度,可以分512段或1024段,但1KB的表已经能把误差控制在0.001以内,对位置环来说足够了。
实操心得:查表法的精度不仅取决于表长,还取决于x的范围。如果x超出表范围,直接取端点值。因为Sigmoid在|x|>8时已经非常接近0或1,误差可以忽略。
4. 从公式到代码:位置环规划的完整实现
4.1 整体控制结构
先理清楚位置环在整个FOC里的位置。我的结构是三层串级:
- 位置环(1kHz):输入目标位置和当前位置,输出目标速度。
- 速度环(5kHz):输入目标速度和当前速度,输出目标电流q轴分量。
- 电流环(20kHz):输入目标电流和采样电流,输出SVPWM占空比。
位置环的输出不是直接给速度环,而是经过Sigmoid规划后的位置指令再差分得到速度前馈。这样速度环跟踪的是一条平滑的速度曲线,而不是阶跃速度指令。
具体来说,每个位置环周期做这几件事:
- 计算当前规划时间t。
- 用Sigmoid函数算出归一化位置σ。
- 实际位置指令 = 起始位置 + 行程×σ。
- 位置指令差分得到速度前馈。
- 位置误差 = 位置指令 - 实际位置,经过P控制器得到速度修正。
- 最终速度指令 = 速度前馈 + 速度修正。
4.2 Q16定点Sigmoid查表实现
先定义表长和范围:
#define SIGMOID_TABLE_SIZE 256 #define SIGMOID_X_MIN (-8.0f) #define SIGMOID_X_MAX (8.0f) #define SIGMOID_X_STEP ((SIGMOID_X_MAX - SIGMOID_X_MIN) / (SIGMOID_TABLE_SIZE - 1)) static const int32_t sigmoid_table[SIGMOID_TABLE_SIZE] = { ... };表的生成可以在PC上算好,直接以常量数组形式放进代码。每个表项是Q16格式的Sigmoid值。
查表函数:
int32_t sigmoid_q16(int32_t x_q16) { // 把Q16的x转换成表索引 // x_q16 / 65536.0f 得到实际x // (x - X_MIN) / X_STEP 得到浮点索引 int32_t x_int = x_q16 >> 16; // 整数部分 int32_t x_frac = x_q16 & 0xFFFF; // 小数部分 // 计算索引,这里用定点运算避免浮点 // index = (x - X_MIN) / X_STEP // 预先算好 1/X_STEP 的Q16值 int32_t index_q16 = (x_q16 - (int32_t)(SIGMOID_X_MIN * 65536)) * INV_STEP_Q16 >> 16; int32_t idx = index_q16 >> 16; int32_t frac = index_q16 & 0xFFFF; if (idx < 0) return sigmoid_table[0]; if (idx >= SIGMOID_TABLE_SIZE - 1) return sigmoid_table[SIGMOID_TABLE_SIZE - 1]; // 线性插值 int32_t y0 = sigmoid_table[idx]; int32_t y1 = sigmoid_table[idx + 1]; return y0 + (((y1 - y0) * frac) >> 16); }这段代码里,INV_STEP_Q16是1/X_STEP的Q16表示。整个函数没有一次浮点运算,全部是整数乘加和移位。
4.3 位置规划器的状态机
位置规划器需要管理一次运动的生命周期:
typedef enum { PLAN_IDLE, PLAN_RUNNING, PLAN_DONE } plan_state_t; typedef struct { plan_state_t state; int32_t start_pos_q16; int32_t delta_pos_q16; int32_t k_q16; int32_t T_q16; int32_t t_q16; int32_t dt_q16; int32_t pos_cmd_q16; int32_t vel_ff_q16; } pos_planner_t;启动一次规划:
void planner_start(pos_planner_t *p, int32_t target_pos_q16) { p->start_pos_q16 = current_pos_q16; p->delta_pos_q16 = target_pos_q16 - current_pos_q16; p->t_q16 = 0; p->state = PLAN_RUNNING; }每个周期更新:
void planner_update(pos_planner_t *p) { if (p->state != PLAN_RUNNING) return; p->t_q16 += p->dt_q16; if (p->t_q16 >= p->T_q16) { p->pos_cmd_q16 = p->start_pos_q16 + p->delta_pos_q16; p->vel_ff_q16 = 0; p->state = PLAN_DONE; return; } // 计算Sigmoid自变量:k * (t - T/2) int32_t x_q16 = (p->k_q16 * (p->t_q16 - (p->T_q16 >> 1))) >> 16; int32_t sigma_q16 = sigmoid_q16(x_q16); int32_t new_pos = p->start_pos_q16 + ((p->delta_pos_q16 * sigma_q16) >> 16); // 速度前馈 = (new_pos - old_pos) / dt p->vel_ff_q16 = ((new_pos - p->pos_cmd_q16) << 16) / p->dt_q16; p->pos_cmd_q16 = new_pos; }这里有个细节:速度前馈用差分计算,而不是对Sigmoid求导。差分更简单,而且和位置指令严格一致,不会出现前馈和指令不匹配的问题。
4.4 参数整定的实操步骤
我调这套东西的步骤是这样的:
- 先定T。根据机械节拍要求,比如要求0.5秒内走完90度,那T就取0.5秒。注意T是过渡时间,不是总时间,实际到位时间会略大于T。
- 初选k。让k·T≈10,比如T=0.5,k=20。
- 观察起步和停止。如果起步有冲击,减小k;如果中间段太慢,增大k。
- 微调位置环P。位置环的P增益不要太大,因为前馈已经提供了主要的速度指令。P的作用只是消除稳态误差。
- 看电流波形。如果q轴电流在起步和停止时有尖峰,说明加速度还是太大,继续减小k。
我实测的一组参数:T=0.3s,k=40,位置环P=8,速度环PI=0.5/0.01,电流环PI=1.2/0.005。这套参数下,90度行程的到位时间约0.35秒,超调小于0.5度,电流峰值约为额定值的1.5倍。
常见问题:如果发现位置始终到不了目标,检查Sigmoid表的范围是否覆盖了k·T/2。当k·T=12时,x的最大值约为6,在-8到8范围内没问题。但如果k·T超过16,x会超出表范围,导致端点值被截断,位置出现偏差。
5. 实测现象与排查记录
5.1 起步抖动:多半是k太大
第一次跑的时候,我用的是k=80,T=0.2s,k·T=16。结果电机起步时“咯噔”一下,声音很明显。用示波器看q轴电流,起步瞬间有一个尖峰。
原因分析:k·T=16时,Sigmoid曲线在起点附近的变化率仍然较大,对应的加速度不为零。虽然理论上Sigmoid的导数在无穷远处才为零,但实际工程中要求起点加速度足够小。
解决:把k降到40,k·T=8,起步明显柔和。但k·T=8时,曲线两端不够平缓,停止时又有点慢。最后折中取k=50,T=0.25,k·T=12.5,效果比较平衡。
5.2 到位振荡:位置环P太大
平滑了起步之后,发现到位时电机在目标位置附近来回蹭,幅度大概1度左右。一开始以为是速度环的问题,调了半天没效果。
后来把位置环P从20降到8,振荡消失。原因是:Sigmoid规划已经提供了速度前馈,位置环P只需要修正很小的跟踪误差。P太大反而会把前馈的平滑性破坏掉。
5.3 定点溢出:乘法顺序很关键
在计算delta_pos_q16 * sigma_q16时,如果delta_pos_q16比较大(比如对应360度),sigma_q16接近65536,乘积可能超过int32范围。
我的处理是:先把delta_pos_q16右移8位,乘完再左移8位。这样损失一点精度,但避免了溢出。对于位置控制来说,1/256的精度损失完全可以接受。
int32_t delta_shifted = p->delta_pos_q16 >> 8; int32_t pos_offset = (delta_shifted * sigma_q16) >> 8;5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决方法 |
|---|---|---|---|
| 起步冲击大 | k太大或T太小 | 看k·T是否>14 | 减小k或增大T |
| 到位振荡 | 位置环P太大 | 观察位置误差符号 | 减小P,依赖前馈 |
| 位置到不了 | Sigmoid表范围不够 | 检查k·T/2是否>8 | 扩大表范围或减小k |
| 运动中间卡顿 | 查表插值精度不够 | 看表长是否<128 | 增加表长到256或512 |
| 速度前馈噪声大 | 差分周期太短 | 检查dt是否<0.5ms | 增大dt或加低通滤波 |
| 定点溢出 | 乘法中间结果超32位 | 检查大数相乘 | 先移位再乘 |
5.5 一个容易被忽略的细节:初始位置对齐
位置环规划的前提是当前转子位置已知。对于有感FOC,编码器或霍尔能提供绝对位置,没问题。但对于无感FOC,转子初始位置检测本身就有误差,位置环规划再平滑,起点错了后面全错。
我的做法是:在位置环使能之前,先做一次转子初始位置检测,把检测结果作为位置环的起始位置。如果检测误差较大,可以在低速下先跑一段速度环,让观测器收敛后再切位置环。
6. 还能怎么扩展:几个实用的改进方向
6.1 变参数Sigmoid:起步柔、中间快、停止稳
固定k的Sigmoid曲线是对称的,但实际机械往往希望起步更柔、中间更快、停止更稳。可以把k做成随时间变化的:
- 前1/3段用较小的k,让起步柔和。
- 中间1/3段用较大的k,加快中间段。
- 后1/3段再用较小的k,让停止平稳。
实现上就是根据t/T的值查不同的k,或者用分段函数。代价是参数变多,调参复杂度上升。对于大多数应用,固定k已经够用。
6.2 前馈加加速度限制
Sigmoid规划的速度前馈是平滑的,但加速度前馈(速度前馈的差分)在起点和终点仍然有较小突变。如果机械对加加速度(jerk)敏感,可以对速度前馈再做一次Sigmoid平滑,相当于两级串联。这样加加速度也连续,但响应会变慢。
6.3 多段连续运动的位置拼接
如果要做连续轨迹,比如从A到B再到C,可以在第一段规划还没完全结束时就开始第二段。关键是保证位置和速度在拼接点连续。我的做法是:在PLAN_DONE之前,如果收到新目标,就把当前位置作为新起点,重新启动规划。由于Sigmoid在终点速度接近零,拼接时的速度不连续很小,实测几乎看不出来。
6.4 在FPGA上实现定点Sigmoid
如果位置环要在FPGA上跑,Q16定点数同样适用。FPGA的优势是可以做全并行查表和插值,一个时钟周期就能出一个Sigmoid值。表可以放在Block RAM里,256×16bit只占一小块。对于多轴联动,每个轴一个独立的规划器,互不干扰。
我在实际项目里对比过:STM32F103软件查表,一次Sigmoid计算约2微秒;FPGA硬件查表,一次约20纳秒。对于1kHz的位置环,两者都绰绰有余,但FPGA能支持更高的环路频率。
6.5 和MPTA控制的配合
如果电机要跑MPTA(最大转矩电流比),位置环输出的速度指令最终会变成iq和id的参考值。Sigmoid规划只负责位置和速度的平滑,不改变MPTA的电流分配逻辑。两者是正交的,可以独立调试。
我个人在实际操作中的体会是:位置环的平滑性,七分靠规划,三分靠PID。规划做得好,PID参数可以放得很宽,系统依然稳定;规划做得糙,PID再精细也补不回来。Sigmoid加Q16这套组合,最大的价值就是把复杂的轨迹规划压缩成了一个公式和一张表,在资源受限的MCU上也能跑出接近伺服驱动器的运动品质。
最后再分享一个小技巧:调参时把位置指令、实际位置、速度前馈、q轴电流四个量同时用串口传到上位机画波形。光看电机转是看不出问题的,波形一摆出来,哪里不平滑、哪里有过冲,一目了然。我习惯用1kHz的采样率传,波特率115200,一帧8个字节,四个量刚好32字节,传一秒钟的数据也就3.6KB,上位机用Python的matplotlib实时画,调试效率比盲调高十倍不止。