FOC算法笔记
无刷电机结构
基本航模无刷电机结构图:

由前盖、中盖、后盖、磁铁、硅钢片、漆包线、轴承、转轴组成.
宏观上,分为2部分:
- 定子:有线圈绕组,固定不动的部分;
- 转子:电机旋转的部分,用于输出电机的转动和扭矩
无刷电机,分为内转子、外转子2种:
- 内转子无刷电机:转子在内部;
- 外转子无刷电机:转子在外面,最常见.
内转子:

外转子:

直流无刷电机驱动:依靠改变电机定子线圈电流交变频率和波形,在定子周围形成磁场,驱动转子永磁体转动,进而带动电机转动起来.
要让电机转动,研究如何改变定子线圈的电流交变频率、波形,是工作重点.
可分为2部分进行研究:硬件电路、软件控制.
无刷电机硬件控制原理
无刷电机与有刷电机区别
有刷电机可通过碳刷换相,实现电流换向.
无刷电机没有碳刷,需要通过MOS管实现电子换向.
MOS管本质是一种开关,可通过MCU IO口控制通断.
场景3种封装MOS管:

无刷电机控制的硬件原理
通过控制MOS管通断的组合,电机线圈中电流大小、方向就能改变.
如下图Q1、Q2导通,电流从DC+➡Q1➡A相流入➡B相流出➡DC-.

根据右手螺旋定则,定子A线圈通电,产生感应磁场,方向由S➡N;同时,B线圈,也会产生该用磁场,方向S➡N.
此时,可简单将转子抽象成一块磁铁.
如何让电机动起来?

由于感应磁场的影响,定子线圈会吸引转子的磁铁转动. 但是,转子磁铁旋转一定角度后,磁铁会停止不动,必须改变电磁场状态(感应磁场方向),才能让转子继续转动.
如何让电机继续转动?

接下来,打开Q1、Q4管. 根据右手螺旋定则,磁场方向改变,转子磁场进而旋转.
因此,只需要交替开关不同的MOS管,就能实现电机磁极的交替运动.
—— 这就是 无刷电机旋转运动的原理.

如上图,是转子磁铁旋转一周时,各个相的通电情况. 只需要交替开关各个相的MOS管即可,从而实现电机的转动. 当MOS管开关速度变快,就可以加速转子的转动;开关速度变慢,转子转动变慢.
∴电机控制 == 对MOS管开关规律的控制
∴ MOS管的开关,需要用到MCU程序控制
这就引出了FOC控制算法.
FOC算法
FOC控制算法(磁场定向控制算法):对电机运动模型进行抽象化和简化,进而有规律的控制各个MOS管开关和通断的过程.
克拉克变换、帕克变换,是FOC算法2个核心.
FOC控制原理
FOC的过程,就是将需求的电机力矩,转化成最终的3相电力输出,并且让电机物理输出所需的力矩的过程.
这也被称为电机控制三环中的力矩环. 后面的位置闭环、速度闭环,都得基于这个力矩环.
这个力矩环的算法,就是一个无刷电机旋转状态的数学模型.

克拉克变换
之前,已经知道交替开关MOS,可实现电机转动. 交替开关的MOS,是以极快的周期进行的.
将这些周期性开启、关断的过程联系起来,对其各个相进行单独观察,可得到3个相——A、B、C相的电流随时间变化的曲线.
3个相的曲线,存在120°相位差;3个相电流,分别对应3个定子线圈上的电流.

如何调制出相位差120°的sin波形?
极难控制、改变参数. 因为
1)相与相之间,相互耦合,MOS管一旦打开,则至少打开2个相.
2)如果只想改变一个相的波形,来实现电机控制,不可行. 需要多个相位协作,一起改变,才能实现电机控制
这个问题涉及3相相位差120°的波形,很复杂,能否降维度?
可用克拉克变换.
克拉克变换(Clarke Transform),实际是降维、解耦的过程,将难以辨明、控制的三相相位差120°电机波形降维为二维矢量.
步骤:
1)矢量化:三相随时间变换的,相位差为120°的电流波形抽象化为三个间隔120°的矢量
三相电流\(I_A,I_B,I_C\),对应平面上基的夹角为120°的坐标系\([O;\bm{I_a},\bm{I_b},\bm{I_c}]\)上3个基向量上的矢量(有大小、方向)

2)投影:利用三角函数对矢量进行降维,降维到两个坐标轴
三维坐标系\(ABC\)(非直角坐标系)降为二维坐标系\(αβ\)(直角坐标系),这个过程称为投影(不同于图形学中的透视投影)
下面推导投影过程:

α与A方向相同,将A、B、C方向上的矢量\(i_a,i_b,i_c\)均投影到α、β方向上,有
写成矩阵形式,即克拉克变换的基本形式
\[\begin{bmatrix} I_α\\I_β \end{bmatrix} =\begin{bmatrix} 1 & -\frac{1}{2} & -\frac{1}{2}\\ 0 & \frac{\sqrt{3}}{2} & -\frac{\sqrt{3}}{2} \end{bmatrix} \begin{bmatrix} i_a\\i_b\\i_c \end{bmatrix} \]
等幅值变换形式
很多文献上,克拉克变换对应形式不一样,而是加上一个系数,如\(\frac{2}{3}\)(等幅值变换系数)、\(\sqrt{\frac{2}{3}}\)(等功率变换系数),分别对应不同变换方式:等幅值变换形式、等功率变换形式.
等幅值变换形式:
\[\begin{bmatrix} I_α\\I_β \end{bmatrix} =\frac{2}{3}\begin{bmatrix} 1 & -\frac{1}{2} & -\frac{1}{2}\\ 0 & \frac{\sqrt{3}}{2} & -\frac{\sqrt{3}}{2} \end{bmatrix} \begin{bmatrix} i_a\\i_b\\i_c \end{bmatrix} \]
什么是等幅值变换?
克拉克变换前后,电流、电压的幅值不变. 下面以电压证明为例.
∵3相电压、电流是相位差120°的正弦(或余弦)波形
∴可设三相电压、电流表示:
其中,\(V_m、I_m\)是相电压、相电流的幅值,\(θ=ωt\)是电角度
相关概念,可参见电路基础:交流电路
设变换T,将ABC坐标系下坐标\((u_a,u_b,u_c)\)变换到αβ坐标系下\((u_α,u_β)\)坐标
于是,
∵变换前后幅值不变
∴\(\sqrt{i_α^2+i_β^2}=I_m\)
∵T是克拉克变换的特殊形式
∴
由基尔霍夫定律,\(i_a+i+i_c=0\)
∴
∴\(i_b-i_c=I_m[cos(θ+\frac{2π}{3})-cos(θ-\frac{2π}{3})]=-\sqrt 3 I_m sinθ\)
∴
∵\(i_a\)和\(i_α\)方向相同
∴取\(k=\frac{2}{3}\)
用等幅值变换有什么好处?
可以确保变换前后,\(i_a\to i_α\)不变.
等功率变换形式
等功率变换形式:
\[\begin{bmatrix} I_α\\I_β \end{bmatrix} =\sqrt{\frac{2}{3}}\begin{bmatrix} 1 & -\frac{1}{2} & -\frac{1}{2}\\ 0 & \frac{\sqrt{3}}{2} & -\frac{\sqrt{3}}{2} \end{bmatrix} \begin{bmatrix} i_a\\i_b\\i_c \end{bmatrix} \]
等功率变换特点:变换前后,功率不变,即\(P_{abc}=P_{αβ}\)
其中,
设变换T,使得
其中,
∴
∴\(i_b-i_c=-\sqrt 3 I_m sinθ, u_b-u_c=-\sqrt 3 V_m sinθ\)
有
∵\(i_α,i_a\)同方向
∴取\(k=\sqrt \frac{2}{3}\)
克拉克逆变换
克拉克变换,是将三相时域上的复杂问题(电流随时间变化的正弦波,相位差120°)转化为二维矢量表示,降维、简化问题.
即:
克拉克逆变换,是克拉克变换的逆过程,由二维矢量形式重新升维变回原来的三相电流波形:
为简化计算,后面我们不用克拉克变换基本形式,而是用等幅值变换形式,即
求逆变换:
于是,
克拉克逆变换:
\[\begin{bmatrix} i_a\\i_b\\i_c \end{bmatrix} = \begin{bmatrix} 1 & 0\\ -\frac{1}{2} & \frac{\sqrt 3}{2}\\ -\frac{1}{2} & -\frac{\sqrt 3}{2} \end{bmatrix} \begin{bmatrix} i_α\\i_β \end{bmatrix} \]
帕克变换
克拉克变换将电机的正弦驱动的三相曲线,从ABC坐标系转换到αβ坐标系,进行了降维,但是没有将其与电机的旋转对应起来. 我们需要建立电机旋转时的数学模型,也就是旋转时的\(i_α,i_β\)电流规律,来解决该问题.
帕克变换(Park Transform),就是这样一个数学模型,能帮我们求得各种旋转情况下的\(i_α,i_β\).
思路:
1)将电机的定子线圈上建立\(αβ\)坐标系(局部坐标系),在坐标系右边放一个转子(SN极对)
如果转子不动,那么\(αβ\)坐标系中,肯定有一对\(i_α,i_β\)对应此时转子状态;然而,实际应用中,转子是时刻转动的,于是,想到在转子上也建立局部坐标系.

2)在转子上建立\(dq\)坐标系(局部坐标系)
下图是将2个坐标系画到一起,原点重合:

其中,\(dq\)坐标系随转子转动,D轴指向电机N级,\(αβ\)坐标系跟定子固定不动. \(dq\)坐标轴因转动而造成的与\(αβ\)坐标轴的差角\(θ\),称为电角度.
于是,我们可以2个坐标系的关系,求出\(i_d,i_q\)与\(i_α,i_β\)的变换关系:

电流映射到D轴上α、β分量:
电流映射到Q轴上α、β分量:
对应变换矩阵:
帕克变换:
\[\begin{bmatrix} i_d\\i_q \end{bmatrix} =\begin{bmatrix} cos θ & sin θ\\ -sin θ & cos θ \end{bmatrix} \begin{bmatrix} i_α\\i_β \end{bmatrix} \]
这个形式,是不是跟计算机图形:三维几何变换 绕主轴旋转很像?为什么sin项的符号相反呢?
答:
帕克变换中的旋转,是将坐标轴旋转,求出同一个点在旋转后坐标系下的坐标,即坐标轴的旋转;
而图形学中的旋转,通常指将点绕坐标轴旋转,求出旋转后的点在原坐标系下的坐标,即点的旋转;
也就是说,它们衡量坐标的坐标系不同,2个变换互为逆变换.
图形学中,点\((x,y)\)绕z轴旋转θ角,得到\((x',y')\)坐标,对应旋转变换\(R(θ,z)\):
克拉克变换,对应旋转变换\(R(-θ,z)\):
注意:
1)二维旋转,可看成三维\(xyz\)坐标系下,绕z轴旋转,θ正方向通常规定为逆时针方向.
2)坐标系变换 = 原点的平移变换 + 坐标轴(或基)的旋转变换,由于原点重合,所以忽略平移变换
根据旋转的逆变换特性,很容易得到帕克变换的逆变换\(R(θ,z)=R(-θ,z)^{-1}\):
帕克逆变换:
\[\begin{bmatrix} i_d\\i_q \end{bmatrix} =\begin{bmatrix} cos θ & -sin θ\\ sin θ & cos θ \end{bmatrix} \begin{bmatrix} i_α\\i_β \end{bmatrix} \]
实际应用中,如何应用帕克变换(逆变换)?
答:实际FOC应用中,电角度可由编码器(如AS5047P,AS5600)求出,\(i_q, i_d\)可合成一个矢量,通过帕克逆变换求出\(i_α, i_β\),进而通过克拉克逆变换求出\(i_a,i_b,i_c\).
简单FOC应用中,只需要控制\(i_q\)电流,而将\(i_d=0\). 而\(i_q\)决定电机力矩. 当我们所需力矩确定时,就能确定\(i_q\),进而决定\(i_a,i_b,i_c\)三相电流大小.
什么是简单FOC应用,可设\(i_d=0\)?
答:
适合场景:
1)PMSM(永磁同步电机)在基速以下,且无需弱磁时,如伺服电机、无人机电调;
2)对控制复杂度敏感的FOC应用,需要简化FOC实现.
不适合场景:
1)高速运行,需要弱磁;
2)感应电机,或IPMSM,需利用磁阻转矩或励磁.
这里不细说,后面如有需要会继续研究.
小结

FOC控制电机过程:输入需求力矩,最后得到真实世界输出力矩的过程. 其中,用帕克逆变换将\(i_q,i_d\)转换为\(i_α,i_β\),克拉克逆变换将\(i_α,i_β\)转化为三相电流\(i_a,i_b,i_c\).
MCU在其中,扮演通过程序将\(i_a,i_b,i_c\)转化为对MOS管的开关信号.
FOC开环速度
开环速度原理
三相电压矢量

前面推导,都是针对电流的,而MCU能直接控制的,是MOS管通断,即对电压信号\(u_a,u_b,u_c\)的控制. 而\(i_a,i_b,i_c\)的控制,必须在控制\(u_a,u_b,u_c\)的基础上,通过各相电流传感器作闭环,才能进一步实现电流的控制.
因此,需要知道FOC过程中,电压的转换关系.
三相电路中,每一相线圈都有相同的相电阻R,根据欧姆定律,相电流\(i_a,i_b,i_c\)×R,可得到相电压\(u_a,u_b,u_c\)
∴电压矢量和电流矢量推导过程相同

上图展示了电流、电压形式的 帕克逆变换、克拉克逆变换.
开环速度代码编写
总流程:

开环速度控制过程:由用户输入期望速度,然后通过Uq和旋转电角度生成器得到Uq,Ud(Ud=0),θ,再通过帕克逆变换得到Uα,Uβ,接着通过克拉克逆变换得到Ua,Ub,Uc,最后通过驱动器(MOS管)对三相电压进行PWM控制,从而实现对电机速度的控制.
关键函数:
-
setPhaseVoltage:给定\(U_q, θ\),实现帕克逆变换、克拉克逆变换,计算出电机控制需要的\(u_a,u_b,u_c\),并通过setPWM传递给电机驱动器硬件,硬件就会产生三相电压波形控制电机 -
velocityOpenloop:根据用户输入的目标电机转速target_velocity,算出该速度下的\(U_q\)、电角度\(θ\),然后传递给setPhaseVoltage -
setPwm: 给定\(U_a,U_b,U_c\),计算出PWM占空比,并设置PWM硬件电路产生对应PWM波
setPhaseVoltage
setPhaseVoltage实现:

注意:简单FOC应用中,\(i_d=0,u_d=0\)
float voltage_power_supply = 12.6;
// angle_el 电角度
void setPhaseVoltage(float Uq, float Ud, float angle_el) {
angle_el = _normalizeAngle(angle_el + zero_electric_angle);
// 帕克逆变换
Ualpha = -Uq * sin(angle_el);
Ubeta = Uq * cos(angle_el);
// 克拉克逆变换
Ua = Ualpha + voltage_power_supply / 2;
Ub = (sqrt(3) * Ubeta - Ualpha) / 2 + voltage_power_supply / 2;
Uc = (-Ualpha - sqrt(3) * Ubeta) / 2 + voltage_power_supply / 2;
setPwm(Ua, Ub, Uc);
}
// 角度归一化到 [0, 2π]
float _normalizeAngle(float angle) {
float a = fmod(angle, 2 * M_PI); // 取余运算: a = angle % 2π
return a >= 0 ? a : (a + 2 * M_PI);
}
代码中,我们给每个\(u_a,u_b,u_c\)都加了voltage_power_supply/2偏移(voltage_power_supply是板子的电源电压),这是因为原始\(u_a,u_b,u_c\)是正弦曲线输出形式,会有电压 < 0部分,而实际电路中,很难产生负电压. 为了避免产生电压 < 0的部分,我们将\(u_a,u_b,u_c\)加上voltage_power_supply/2偏移.
如果不加电压偏移voltage_power_supply/2,实际ua,ub,uc曲线的0点从0V开始,后续会产生负向电压,而负电压是不可接受的:

如果加上电压偏移voltage_power_supply/2,实际ua,ub,uc曲线0点从6V开始,ua,ub,uc的变化范围就是±6V:

setPwm
setPwm实现:

通过PWM占空比,控制一个PWM波形周期内的平均电压,从而实现期望的\(U_a,U_b,U_c\).
如何计算一个PWM波形周期内的平均电压与占空比关系?
设MCU电压(PWM高电平)\(Vcc=5V\),低电平0V,周期T,占空比为d(\(d\in[0,1]\)),那么输出平均电压:
例如,
1)占空比为 50%, 那么平均电压 $ V_{avg}=5V\cdot 0.5=2.5V $
2)占空比为 75%, 那么平均电压 $ V_{avg}=5V\cdot 0.75=3.75V $
3)占空比为 20%,那么平均电压 $ V_{avg}=5V\cdot 0.20=1V $
// 约束函数, 限制值范围
#define _constrain(amt, low, high) ((amt) < (low) ? (low) : ((amt) > (high) ? (high) : (amt)))
// 设置PWM到控制器输出
void setPwm(float Ua, float Ub, float Uc) {
// 限制上限
Ua = _constrain(Ua, 0.0f, voltage_limit);
Ub = _constrain(Ub, 0.0f, voltage_limit);
Uc = _constrain(Uc, 0.0f, voltage_limit);
// 计算占空比
// 限制占空比从0到1
dc_a = _constrain(Ua / voltage_power_supply, 0.0f, 1.f);
dc_b = _constrain(Ub / voltage_power_supply, 0.0f, 1.f);
dc_c = _constrain(Uc / voltage_power_supply, 0.0f, 1.f);
// 写入PWM到PWM 0,1,2通道
ledcWrite(0, dc_a * 255); // 8位精度PWM, 范围0-255 (2^8-1)
ledcWrite(1, dc_b * 255);
ledcWrite(2, dc_c * 255);
}
如何理解占空比dc_a,b,c 限制到范围[0,1],然后×255?
答:归一化到范围[0,1]是为了避免占空比溢出正常范围,因为可能受到环境干扰,会有一些Ua,Ub,Uc抖动值超过voltage_power_supply.
后面x255,类似于将RGB颜色各通道值限制到[0,1]范围后,再x255恢复至原来的[0,255]范围. [0,1]和[0,255]是RGB颜色的不同表现形式,前者更适合数学计算场景,后者更直观,x255是将[0,1]形式转换为[0,255]形式.
当然,这里x255并不是乱设的,而是由前面初始化时调用ledcSetup(0, 30000, 8),将PWM通道占空比精度设为8,最多支持256个占空比等级,而非我们通常理解的100个等级(0%~100%).
velocityOpenloop
velocityOpenloop实现:

velocityOpenloop 负责接收用户输入的目标速度值,然后计算出Uq、电角度θ.
float voltage_limit = 10;
float shaft_angle = 0, open_loop_timestamp = 0;
// 开环速度函数
// target_velocity 目标速度(rad/s)
float velocityOpenloop(float target_velocity) {
unsigned long now_us = micros(); // 获取从开启芯片以来的微秒数, 精度4us.
// 计算当前每个Loop的运行时间间隔
float Ts = (now_us - open_loop_timestamp) * 1e-6f;
// 由于 micros() 函数返回的时间戳会在大约 70 分钟后重新开始计数, 在由70分钟跳变到0时,
// Ts会出现异常, 因此需要修正. 如果时间间隔 <= 0 或 > 0.5s, 则将其设置为一个较小默认值, 即1e-3f
if (Ts <= 0 || Ts > 0.5f) Ts = 1e-3f;
// 通过乘以时间间隔和目标速度来计算需要转动的机械角度, 存储在 shaft_angle 变量中
// 在此之前, 还需要对轴角度归一化, 以确保其值在0-2π之间
shaft_angle = _normalizeAngle(shaft_angle + target_velocity * Ts);
// 以目标速度为 10 rad/s 为例, 如果时间间隔1s, 则在每个循环中需要增加 10 * 1 = 10 rad, 才能使得电机转动到目标速度
// 如果时间间隔是 0.1s, 那么在每个循环中, 需要增加的角度变化量是 10 * 0.1 = 1 rad, 才能实现相同的目标速度.
// 因此, 电机轴的转动角度取决于目标速度和时间间隔的乘积
// 使用早期设置的voltage_limit作为Uq值, 这个值会直接影响输出力矩
float Uq = voltage_limit;
setPhaseVoltage(Uq, 0, _electricalAngle(shaft_angle, 7));
open_loop_timestamp = now_us;
return Uq;
}
// 电角度求解
// 电角度 = 机械角度 x 极对数
float _electricalAngle(float shaft_angle, int pole_pairs) {
return (shaft_angle * pole_pairs);
}
注意:定时器精度,是指硬件计时的精度(可靠性),不是表示精度,如now_us=1,表示1us,但可靠性是4us.
如何生成电角度\(θ\)?
分2步:
1)生成机械角度haft_angle
shaft_angle 描述了转子与定子因旋转而形成的角度差,即转子的位置信息(相对于定子). 由于我们会在main函数中,不断循环调用velocityOpenloop,电机也会随着时间变化按期望的速度旋转,因此,
其中,\(Δt\)是2次相邻计算shaft_angle的时间差,shaft_angle是机械角度,需要归一化到\([0,2π)\)范围内(否则,角度不确定).
2)根据电角度与机械角度关系,求出电角度θ
电角度 = 机械角度 x 极对数
电角度与机械角度的关系

假设我们单独看\(U\)相,如果将电动机看作一个发电机,那么转子磁极N在磁场中旋转一圈,切割磁感线,再回到初始位置. 所发的电,就是一个发电周期,有正半轴、负半轴的部分(分别对应1个波峰、波谷).

现在将磁极对扩展2,即有4个磁极. 那么,转子旋转一周,N极会产生2个发电周期.
同样地,磁极对为n时,转子旋转一周,N极会产生n个发电周期,对应n个相位周期.
也就是说,我们的转子选择一周,对应机械角度\(shaft\_angle=2π\),产生了n个相位周期,我们将每个相位周期用电角度\(θ=2π\)来表示,n个相位周期,产生n个电角度周期. 就出现了电角度和机械角度对应关系:
电角度 = 机械角度 x 极对数
即:
其中,n是极对数.
初始化
//PWM输出引脚定义
int pwmA = 32;
int pwmB = 33;
int pwmC = 25;
//初始变量及函数定义
// 其他宏函数、全局变量
...
float zero_electric_angle=0, Ualpha, Ubeta=0, Ua=0, Ub=0, Uc=0, dc_a=0,dc_b=0,dc_c=0;
void setup() {
Serial.begin(115200);
//PWM设置
pinMode(pwmA, OUTPUT);
pinMode(pwmB, OUTPUT);
pinMode(pwmC, OUTPUT);
ledcAttachPin(pwmA, 0);
ledcAttachPin(pwmB, 1);
ledcAttachPin(pwmC, 2);
ledcSetup(0, 30000, 8); //pwm频道, 频率, 精度
ledcSetup(1, 30000, 8); //pwm频道, 频率, 精度
ledcSetup(2, 30000, 8); //pwm频道, 频率, 精度
Serial.println("完成PWM初始化设置");
delay(3000);
}
主循环
主循环很简单,
void loop() {
// put your main code here, to run repeatedly:
velocityOpenloop(5);
}
ESP32库函数
本文FOC实现用到了部分EPS32 Arduino 核心库函数,虽然不是FOC重点,但是涉及硬件控制,挑选部分重要函数讲解.
ledcSetup
ledcSetup 用于初始化 LEDC(LED PWM Control)硬件 PWM 通道,其任务是配置定时器频率、占空比分辨率,并绑定到指定通道
double ledcSetup(uint8_t channel, double freq, uint8_t resolution_bits);
- channel:PWM 通道号(0~15)
- freq:PWM 频率(单位:Hz)
- resolution_bits:占空比分辨率位数(1~16,常用 8/10/12 位)
返回值:实际设置的频率(取决于具体硬件)
ledcSetup内部实现原理:
- 选择定时器和模式
- channel 0-7确定用 高速模式,使用高速定时器(LEDC_HIGH_SPEED_MODE);
- channel 8-15确定用 低速模式,使用低速定时器(LEDC_LOW_SPEED_MODE).
- 计算分频系数
根据输入的频率freq、分辨率resolution_bits,计算分频系数divider:
e.g. freq = 5000Hz, resolution_bits = 8, 那么\(divider = 80M/(5000-255)≈62.74\),四舍五入取整63
- 配置定时器寄存器
设置定时器的分频系数和计数器位数
- 返回实际频率
因为计算分频系数时取整,所以实际频率与输入频率可能会存在误差
e.g. divider = 63,那么实际频率 \(= 80M/(63\cdot 255)≈4993Hz\)
ledcWrite
ledcWrite 用于调节PWM占空比,其任务是控制LED亮度、电机速度等. 需要先调用ledcSetup初始化配置.
ledcWrite简化版实现:
void ledcWrite(uint8_t channel, uint32_t duty) {
// 1. 检查通道有效性
if (channel >= LEDC_CHANNEL_MAX) return;
// 2. 获取通道对应的寄存器地址
volatile uint32_t *duty_reg = &LEDC_DUTY_REG(channel);
// 3. 写入占空比值(硬件自动生效)
*duty_reg = duty << LEDC_DUTY_SHIFT; // 根据分辨率左移对齐
}
其中,LEDC_DUTY_SHIFT在初始化时,调用ledcSetup会设置其值
FOC闭环位置
闭环位置控制的原理

当电机转子位置(如偏差位置45°)并非期望位置(0°)时,即转子因旋转与定子形成一定夹角,我们需要控制电机产生一个力矩,让转子回到初始位置. 力矩跟Uq是等效的,要产生力矩,我们需要给一个合适的Uq,让电机回到0位.
所谓闭环位置控制,就是解决如何检测该角度偏差,怎么产生合适的旋转力矩Uq的问题.
假设逆时针方向旋转为负方向,顺时针旋转为正方向. 电机误差位置用e表示,所以,误差位置计算:
问题转换为:根据偏差位置(如45°)求出力矩Uq,使得Uq让电机产生力矩,从而让转子回到0位置.
如何将误差位置e转换成Uq值?
以车载12V供电系统为例,驱动电机电源电压12V,于是,Uq的变化范围Uqmax=±6V. 想让电机在45°偏差时,产生最大回正力矩,那么该系数为:
于是,可计算出当电机处于误差位置e时,需要让电机回正所需要的力矩Uq:
这个Kp就是PID控制中的P环系数.
式子中,\(Kp\)可用最大力矩的限制计算得到,而误差位置e可通过编码器(如as5600)测量转子角度得到.
下图是PID位置控制器示意图:
这里的闭环位置控制,处于简单考虑,只用了P环,没有用I环、D环.

闭环位置控制的整个过程可以总结为:

实现位置闭环有多种方式,常见有:
- 方式1:位置-力控制,通过控制力矩实现位置闭环
- 方式2:位置-速度-力控制,通过控制力矩、速度,实现位置闭环
闭环位置控制代码编写
在开环速度控制代码基础上,实现方式2的位置闭环.
从编码器读取角度
从编码器读取角度,分为3种类型:
1)从编码器读取原始的角度值,MCU通过i2c与编码器通信
2)将从编码器读取的角度值,转换为弧度值
3)累积从编码器读取的角度值,用圈数 + 读取的角度值
int _raw_ang_hi = 0x0c;
int _raw_ang_lo = 0x0d;
int _ams5600_Address = 0x36;
...
int full_rotations = 0; // 软件实现圈数累计
float angle_prev = 0; // 上一次读取的角度值
// 从编码器读取的原始角度值
word getRawAngle() {
return readTwoBytes(_raw_ang_hi, _raw_ang_lo);
}
// 读取单圈的角度值(不累计)
float getAngle_Without_track() {
return getRawAngle() * 0.08789 * M_PI / 180; // 角度 => 弧度
}
// 读取累计角度值(累计圈数)
float getAngle() {
float val = getAngle_Without_track();
float d_angle = val - angle_prev;
// 计算旋转的总圈数
// 通过判断角度变化是否大于一圈的80%(0.8*2π ≈ 0.8f * 6.28318530718f)判断是否发生溢出,
// 如果发生溢出, 则full_rotations-1(如果d_angle > 0);否则, full_rotations+1.
if (abs(d_angle) > (0.8f * 6.28318530718f) )
full_rotations += ( d_angle > 0) ? -1 : 1;
angle_prev = val;
return (float)full_rotations * 6.28318530718f + angle_prev;
}
// 从I2C设备(编码器)读取2byte数据, 并合并成16 bits数据作为角度值. 具体方式取决于所用编码器
// in_adr_hi: 高字节数据地址
// in_adr_lo: 低字节数据地址
// 返回读取的角度值
word readTwoBytes(int in_adr_hi, int in_adr_lo)
{
word retVal = -1;
/* 读低位 */
Wire.beginTransmission(_ams5600_Address);
Wire.write(in_adr_lo);
Wire.endTransmission();
Wire.requestFrom(_ams5600_Address, 1);
while(Wire.available() == 0);
int low = Wire.read();
/* 读高位 */
Wire.beginTransmission(_ams5600_Address);
Wire.write(in_adr_hi);
Wire.endTransmission();
Wire.requestFrom(_ams5600_Address, 1);
while(Wire.available() == 0);
int high = Wire.read();
retVal = (high << 8) | low;
return retVal;
}
计算电角度
读取机械角度,用以计算电角度时,使用的是真实的机械角度getAngle_Without_track(),而不用软件累计的角度getAngle().
开环速度控制提到,电角度 = 机械角度 x 极对数.
这里求电角度时,为什么将 DIR * 极对数(PP) * 机械角度
// 电角度求解
float _electricalAngle() {
return _normalizeAngle((float)(DIR * PP) * getAngle_Without_track() - zero_electric_angle);
}
(DIR * PP) * getAngle_Without_track() 就是计算出来的电角度,通常与真是电角度有偏差;
zero_electric_angle 是电角度0位偏差. 通常测出来电角度会与真实电角度有一个偏差,zero_electric_angle就是这个偏差值. 可通过初始化时0位偏差进行校准.
校准过程:
void setup() {
...
// 通过0位偏差, 校准电角度
setPhaseVoltage(3, 0, _3PI_2);
delay(3000);
zero_electric_angle = _electricalAngle();
setPhaseVoltage(0, 0, _3PI_2);
...
}
主循环
void loop() {
Serial.println(getAngle());
float Sensor_Angle = getAngle();
float Kp = 0.133; // Kp=|Uqmax|÷|±45|
// Uq = Kp * e
setPhaseVoltage(_constrain(Kp * (motor_target - DIR * Sensor_Angle) * 180 / M_PI, -6, 6), 0, _electricalAngle());
serialReceiveUserCommand(); // 从串口接收用户输入的 目标位置值, 会写入motor_target
}
motor_target - DIR * Sensor_Angle就是误差位置e;DIR 编码器方向,需要实测得到.
serialReceiveUserCommand()从串口接收用户输入的 目标位置值,写入全局变量motor_target.
为什么要用_constrain将Kp*e限制在[-6,6]之间?
前面讲过,Uqmax=±6V,Uq的最大变化范围就是[-6,6]. 如果不加以限制,Uq超限,电机可能会卡顿,导致不正常旋转.
为什么读取传感器角度Sensor_Angle用getAngle(),而不是用getAngle_Without_track()?
因为这里为了计算 误差位置e = 期望位置 - 偏差位置,其中,期望位置由用户输入(motor_target),偏差位置是转子从初始位置顺时针旋转到定子位置所需的角度. 实际偏转位置,可能是用户(负载)导致转子旋转而形成的角度差,可能超过一圈,因此需要考虑累积角度.
完整代码参见:5 FOC闭环位置代码的撰写
FOC闭环速度
可以单独控制速度,也可以作为“闭环位置原理”中,方式2 的位置闭环:位置-速度-力闭环的中间环节
如下图,是闭环位置框图,可通过编码器测出转子位置:

如果用方式2的位置闭环:位置-速度-力闭环,用到了速度闭环算法.
使用速度环,需要解决的问题:
1)如何用编码器获取电机转子速度?
2)为何对速度值进行滤波?如何滤波?
3)为什么用PI控制器对速度环进行控制?有什么效果?
通过编码器获取转子速度,经低通滤波器后,得到编码器测出速度.
获取速度
其中,期望速度由用户输入,测量速度由编码器采集到的角度值,经过计算、低通滤波得到.
编码器得到的测量速度(未滤波):
从编码器获取角度时,我们用的是累计了圈数的角度. 计算得来的测量速度,其实是角速度.
在获取编码器角度时,由于编码器角度在0.6~6.28(2*3.14=6.28)之间循环,也就是说,我们无法通过 角度变化 > 2π,来判断圈数+1;具体需要 角度变化 > ?,才能判断圈数+1,需要结合实际测量数据得出.
这里,我们采用 角度变化 > 0.8 * 360°,判断圈数+1

主要代码如下:
float angle_prev = 0; // 最后一次调用getSensorAngle()的结果,用于得到完整的圈数和速度
long angle_prev_ts = 0; // 上次调用 getAngle 的时间戳
float vel_angle_prev = 0; // 最后一次调用 getVelocity 时的角度
long vel_angle_prev_ts = 0; // 最后速度计算时间戳
int32_t full_rotations = 0; // 总圈数计数
int32_t vel_full_rotations = 0; //用于速度计算的先前完整旋转圈数
float Sensor_AS5600::getVelocity() {
// 计算采样时间
float Ts = (angle_prev_ts - vel_angle_prev_ts)*1e-6;
// 快速修复奇怪的情况(微溢出)
if(Ts <= 0) Ts = 1e-3f;
// 速度计算
float vel = ( (float)(full_rotations - vel_full_rotations)*_2PI + (angle_prev - vel_angle_prev) ) / Ts;
// 保存变量以待将来使用
vel_angle_prev = angle_prev;
vel_full_rotations = full_rotations;
vel_angle_prev_ts = angle_prev_ts;
return vel;
}
// 计算圈数
void Sensor_AS5600::Sensor_update() {
float val = getSensorAngle(); // 从传感器直接读取角度值
angle_prev_ts = micros();
float d_angle = val - angle_prev;
// 圈数检测
if(abs(d_angle) > (0.8f*_2PI) ) full_rotations += ( d_angle > 0 ) ? -1 : 1;
angle_prev = val;
}
速度的低通滤波
滤波原理:
数学角度看,就是一个线性插值公式(参见解析几何笔记:仿射坐标系)
其中,
- 滤波后的速度,就是最终测量速度;
- 平滑因子α
\(α=alpha = T_f / (T_f + dt)\) ,基于指数衰减滤波器. 公式目的:根据时间常数\(T_f\)、时间间隔\(dt\)的比例来控制滤波器对当前输入信号和前一次滤波结果的加权比例.
时间常数\(T_f\)由开发者决定,它与dt关系:
1)当\(dt \ll T_f\)时,\(α≈1\),滤波器主要依赖于前一次滤波结果,当前输入信号的影响足够小;
2)当\(dt \gg T_f\)时,\(α≈0\),滤波器主要依赖于当前输入信号,前次滤波结果影响较小
主要代码如下:
float Tf = time_constant; //!< 低通滤波时间常数
unsigned long timestamp_prev = micros(); //!< 最后执行时间戳
float y_prev = 0.0f; //!< 上一个循环中的过滤后的值
float LowPassFilter::operator() (float x)
{
unsigned long timestamp = micros();
float dt = (timestamp - timestamp_prev)*1e-6f;
if (dt < 0.0f ) dt = 1e-3f;
else if(dt > 0.3f) {
y_prev = x;
timestamp_prev = timestamp;
return x;
}
float alpha = Tf / (Tf + dt);
float y = alpha * y_prev + (1.0f - alpha) * x;
y_prev = y;
timestamp_prev = timestamp;
return y;
}
//无滤波
float DFOC_M0_Velocity_WithoutFilter()
{
return DIR * S0.getVelocity();
}
//有滤波
float DFOC_M0_Velocity()
{
//获取速度数据并滤波
float vel_M0_ori = S0.getVelocity();
float vel_M0_flit = M0_Vel_Flt(DIR*vel_M0_ori);
return vel_M0_flit; //考虑方向
}
速度的PI控制
整体流程与闭环位置类似,不过我们这次处理输入时,使用PI控制器,而不用单独的P控制器.
PID控制(离散形式):
其中,\(e_k\)就是我们所说的error,
对于PI控制器,我们忽略微分项.
为什么要使用PI控制器,而不用P控制器?
因为单独的速度环P控制器有缺陷. 如果只有P环,对于有负载和无负载的情形,P环只会根据 速度误差 输出相同力矩,导致无负载的更快达到目标速度;但是,我们希望有负载时能增大力矩,从而提高达到目标速度的效率.
如果e(t)长时间没有被P环调节过来,I环积分就会不断增大e(t),使得电机的输出力越来越大,让电机实现更快的纠偏.

主要代码如下:
// 构造函数
PIDController::PIDController(float P, float I, float D, float ramp, float limit)
: P(P)
, I(I)
, D(D)
, output_ramp(ramp) // PID控制器加速度限幅, 受控对象的输出斜率
, limit(limit) // PID控制器输出限幅
, error_prev(0.0f)
, output_prev(0.0f)
, integral_prev(0.0f)
{
timestamp_prev = micros();
}
float PIDController::operator() (float error){
// 计算两次循环中间的间隔时间
unsigned long timestamp_now = micros();
float Ts = (timestamp_now - timestamp_prev) * 1e-6f;
if(Ts <= 0 || Ts > 0.5f) Ts = 1e-3f;
// P环
float proportional = P * error;
// Tustin 散点积分(I环)
float integral = integral_prev + I*Ts*0.5f*(error + error_prev);
integral = _constrain(integral, -limit, limit);
// D环(微分环节)
float derivative = D*(error - error_prev)/Ts;
// 将P,I,D三环的计算值加起来
float output = proportional + integral + derivative;
output = _constrain(output, -limit, limit);
if(output_ramp > 0){
// 对PID的变化速率进行限制
float output_rate = (output - output_prev)/Ts;
if (output_rate > output_ramp)
output = output_prev + output_ramp*Ts;
else if (output_rate < -output_ramp)
output = output_prev - output_ramp*Ts;
}
// 保存值(为了下一次循环)
integral_prev = integral;
output_prev = output;
error_prev = error;
timestamp_prev = timestamp_now;
return output;
}
为什么计算积分项integral时,要用integral_prev + I*Ts*0.5f*(error + error_prev)?
答:积分项本质是计算从系统启动(0时刻)到目前为止的累积误差,相当于求error-t曲线与t轴围成的区域面积,每一个Ts时间宽度对应的面积,可看成一个梯形,error和error_prev就是梯形的高,因此,
那么,对于I项,
为什么用output_ramp限制PID输出值的变化率?
答:output_ramp是由调用者设置的,用来限制PID输出值的斜率,用于避免输出值太太从而导致系统振荡.
输出值斜率计算:
速度闭环
用户传入P、I参数,经由PID控制器(PIDController)根据误差速度,计算出PID输出值,将其作为Uq,与电角度一起传入setTorque,从而计算出Ua, Ub, Uc值,并控制产生对应占空比的PWM波形.
// PID
PIDController vel_loop_M0 = PIDController{.P = 2, .I = 0, .D = 0, .ramp = 100000, .limit = voltage_power_supply/2};
//=================PID 设置函数=================
//速度PID
void DFOC_M0_SET_VEL_PID(float P, float I, float D, float ramp) //M0角度环PID设置
{
vel_loop_M0.P=P;
vel_loop_M0.I=I;
vel_loop_M0.D=D;
vel_loop_M0.output_ramp=ramp;
}
// M0速度PID接口
float DFOC_M0_VEL_PID(float error) //M0速度环
{
return vel_loop_M0(error);
}
// ====== User Interface =======
void DFOC_M0_setVelocity(float Target)
{
setTorque(DFOC_M0_VEL_PID((serial_motor_target() - DFOC_M0_Velocity()) * 180 / PI), _electricalAngle()); //速度闭环
}
void loop()
{
// 设置速度环PID参数
DFOC_M0_SET_VEL_PID(0.005, 0.00, 0,0);
// 设置目标速度
DFOC_M0_setVelocity(serial_motor_target());
...
}
位置闭环
类似于速度闭环,
PIDController angle_loop_M0 = PIDController{ .P = 2, .I = 0, .D = 0, .ramp = 100000, .limit = 100 };
//角度PID
void DFOC_M0_SET_ANGLE_PID(float P,float I,float D,float ramp) //M0角度环PID设置
{
angle_loop_M0.P=P;
angle_loop_M0.I=I;
angle_loop_M0.D=D;
angle_loop_M0.output_ramp=ramp;
}
//M0角度PID接口
float DFOC_M0_ANGLE_PID(float error)
{
return angle_loop_M0(error);
}
// ====== User Interface =======
void DFOC_M0_set_Velocity_Angle(float Target)
{
setTorque(DFOC_M0_VEL_PID(DFOC_M0_ANGLE_PID((Target - DFOC_M0_Angle()) * 180 / PI)), _electricalAngle()); //角度闭环
}
// 本质是前面讲的位置闭环
void DFOC_M0_set_Force_Angle(float Target) //力位
{
setTorque(DFOC_M0_ANGLE_PID((Target-DFOC_M0_Angle()) * 180 / PI), _electricalAngle());
}
设置力矩
因为力矩等效于相电压(Ua,Ub,Uc),所以设置力矩setTorque其实就是在设置相电压. 因此,setTorque的内容,与前面的setPhaseVoltage基本一致.
不同点:
1)对角度的处理,setTorque需要先更新传感器数据,进行圈数判定;setPhaseVoltage没有这一步;
2)setTorque内置Ud=0,setPhaseVoltage由用户传入.
// 设置力矩 <=> 设置相电压
void setTorque(float Uq, float angle_el) {
S0.Sensor_update(); //更新传感器数值
Uq = _constrain(Uq, -(voltage_power_supply)/2, (voltage_power_supply)/2);
float Ud = 0;
angle_el = _normalizeAngle(angle_el);
// 帕克逆变换
Ualpha = -Uq * sin(angle_el);
Ubeta = Uq * cos(angle_el);
// 克拉克逆变换
Ua = Ualpha + voltage_power_supply / 2;
Ub = (sqrt(3) * Ubeta - Ualpha) / 2 + voltage_power_supply / 2;
Uc = (-Ualpha - sqrt(3) * Ubeta) / 2 + voltage_power_supply / 2;
setPwm(Ua, Ub, Uc);
}
初始化与主循环
主循环中,可以设置目标速度,或者改成想要控制的目标是速度 or 位置.
int Sensor_DIR = -1; // 传感器方向
int Motor_PP = 7; // 电机极对数
void setup() {
Serial.begin(115200);
DFOC_Vbus(12.6); // 设定驱动器供电电压
DFOC_alignSensor(Motor_PP,Sensor_DIR);
}
void loop()
{
// 设置速度环PID参数
DFOC_M0_SET_VEL_PID(0.005, 0.00, 0,0);
// 设置目标速度
DFOC_M0_setVelocity(serial_motor_target());
// 接收串口
serialReceiveUserCommand();
}
FOC闭环电流
通常,FOC环节涉及3个环:速度环、位置环、电流环.
之前,FOC力矩环是通过设置Uq实现电机力矩输出,因为没有传感器检测电机力矩,因此FOC力矩环是开环的.
要进一步精确控制电机,就需要对力矩进行精确的闭环控制. 电流传感器的电流输出,经过换算后,可以转换成力矩值,因此,可用反馈的电流求出电机力矩,进行力矩闭环.
原理
q轴电流\(Iq\)一定程度上能代表电机力矩,只需要电机的KV值,就能将\(I_q\)转换成电机力矩:
电机的KV值是直流电机(尤其无刷电机)的一个重要参数,表示电机在空载状态下,每伏特(V)电压对应的转速(RPM),单位为 RPM/V. 反映了电机的速度特性,是选型和控制的关键指标之一.
想控制力矩,只需要控制检测出来的电流值\(I_q\)达到我们设定的期望值\(I\_q\_ref\) 即可.
类似于速度闭环,我们可以用PID算法对电流闭环,从而实现力矩闭环:
其中,\(I\_q\)是q轴电流(即观测力矩),\(I\_q\_ref\)是我们期望的q轴电流(即期望力矩). 经过PID算法后,能输出对电机进行控制的Uq值.
1)当\(I\_q\_ref - I\_q < 0\)时,实际力矩 > 期望力矩,此时Uq会变小;
2)当\(I\_q\_ref - I\_q > 0\)时,实际力矩 < 期望力矩,此时Uq会变大.
在有反馈的情况下(PID),电机能稳定在设定的力矩中. 这就是电流闭环原理.
而根据前面的克拉克变换,可以将\(i_a,i_b,i_c\)转化成\(i_α,i_β\)(等幅值形式):
再由帕克变换,可将\(i_α,i_β\)转换成\(i_q,i_d\):
于是,如何测量\(i_q,i_d\),转化成如何测量\(i_a,i_b,i_c\).
再由基尔霍夫电流定律,\(i_a+i_b+i_c=0\),我们只需要测量其中2相电流,即可得到\(i_q\)
如何测量A、B、C相电流?

答:可以利用欧姆定律,在A、B、C这3相回路上分别串联3个等值小电阻\(R_s\)、接入一个电流传感器(如INA240A2). 当相线中有电流经过时,传感器就能采集到电阻2端电压值,MCU读取这个电压值后,除以\(R_s\),就能得到相电流.
闭环电流代码编写
电流采集
电路中,采样电阻\(R_s=0.01Ω\)
∵电流传感器INA240A2将检测到的电流放大了50倍(amp_gain=50)
∴真实电流:
float _shunt_resistor = 0.01; // Rs, unit: Ω
volts_to_amps_ratio = 1.0f /_shunt_resistor / amp_gain; // 将电压转化为电流
gain_a = volts_to_amps_ratio * (-1);
gain_b = volts_to_amps_ratio * (-1);
gain_c = volts_to_amps_ratio;
// 电流读取
current_a = (readADCVoltageInline(pinA) - offset_ia) * gain_a; // unit: A
current_b = (readADCVoltageInline(pinB) - offset_ib) * gain_b; // unit: A
current_c = (!_isset(pinC)) ? 0 : (readADCVoltageInline(pinC) - offset_ic)*gain_c; // unit: A
∵电流传感器的方向都是负方向
∴需要根据实际情况规整\(i_a,i_b,i_c\)的方向,volts_to_amps_ratio乘以-1,就是将电流反向.
电流ADC转换
readADCVoltageInline是从电流传感器读取的ADC值,代表电阻两端电压值. 当读取到ADC值后,需要将其换算成电阻电压. 具体的换算方法,取决于ADC电压、分辨率,实现如下:
#define _ADC_VOLTAGE 3.3f //ADC 电压
#define _ADC_RESOLUTION 4095.0f //ADC 分辨率
// ADC 计数到电压转换比率求解
#define _ADC_CONV ( (_ADC_VOLTAGE) / (_ADC_RESOLUTION) )
float CurrSense::readADCVoltageInline(const int pinA){
uint32_t raw_adc = analogRead(pinA);
return raw_adc * _ADC_CONV;
}
0点漂移
offset_ia, offset_ib 代表A、B相电流传感器的0点漂移,电流传感器不是理想器件,存在0点漂移. 在计算真实电压时,需要消除0点漂移.
current_a = (readADCVoltageInline(pinA) - offset_ia) * gain_a;
如何求得0点漂移值offset_ia, offset_ib呢?
上电后,初始化时,可读取电流传感器的电压值1000遍,然后求平均得到就是0点漂移:
// 查找 ADC 零偏移量的函数
void CurrSense::calibrateOffsets(){
const int calibration_rounds = 1000;
// 查找0电流时候的电压
offset_ia = 0;
offset_ib = 0;
offset_ic = 0;
// 读数1000次
for (int i = 0; i < calibration_rounds; i++) {
offset_ia += readADCVoltageInline(pinA);
offset_ib += readADCVoltageInline(pinB);
if(_isset(pinC)) offset_ic += readADCVoltageInline(pinC);
delay(1);
}
// 求平均,得到误差
offset_ia = offset_ia / calibration_rounds;
offset_ib = offset_ib / calibration_rounds;
if(_isset(pinC)) offset_ic = offset_ic / calibration_rounds;
}
电流闭环
类似于速度闭环,电流闭环也是使用PI控制器(D项为0).
PIDController current_loop_M0 = PIDController{.P = 1.2, .I = 0, .D = 0, .ramp = 100000, .limit = 12.6};
void DFOC_M0_SET_CURRENT_PID(float P,float I,float D,float ramp) //M0电流环PID设置
{
current_loop_M0.P=P;
current_loop_M0.I=I;
current_loop_M0.D=D;
current_loop_M0.output_ramp=ramp;
}
void DFOC_M0_setTorque(float Target) //电流力矩环
{
setTorque(current_loop_M0(Target-DFOC_M0_Current()),_electricalAngle());
}
void runFOC()
{
//====传感器更新====
S0.Sensor_update();
CS_M0.getPhaseCurrents();
//====传感器更新====
}
int counter = 0;
void loop()
{
runFoc();
DFOC_M0_SET_CURRENT_PID(5, 200, 100000);
DFOC_M0_setTorque(serial_motor_target());
counter++;
if (counter > 30) {
counter = 0;
Serial.printf("%f\n", DFOC_M0_Current());
}
// 接收串口
serialReceiveUserCommand();
}

浙公网安备 33010602011771号