简介:这份资源面向正在学习 STM32 并尝试将 DengFOC 无刷电机控制方案移植到 HAL 库工程中的开发者,重点解决闭环位置控制的工程落地问题。压缩包共 183 个文件,约 1.4MB,以 76 个 .h 头文件、28 个 .c 源文件为核心,配合 Keil 工程文件(uvprojx、uvoptx)、启动汇编、链接脚本与 map 映射文件,以及编译产物和辅助脚本,构成一套可直接打开、编译与调试的完整工程。内容围绕 STM32F4 系列 HAL 驱动展开,涉及定时器、I2C、UART、DMA、RCC 等外设配置,便于读者对照理解闭环位置控制中传感器采样、PWM 输出与位置环调节的代码组织方式。已有 584 人学习下载,适合具备一定 STM32 基础、希望从开源 FOC 方案过渡到 HAL 工程实践的中级开发者参考,也可作为课程设计或电机控制入门的对照工程。
1. 把 DengFOC 闭环位置控制搬进 STM32:一份能直接跑起来的移植包
手上有个云台项目,电机用的是无刷直流,驱动器板子自己画的,主控 STM32,原本打算用现成的 FOC 库,结果发现要么绑死特定硬件,要么位置环调起来像开盲盒。后来翻到 DengFOC 这套开源实现,结构清爽、算法链路完整,但它是跑在特定平台上的,直接搬到 STM32 + HAL 上会遇到一堆底层适配问题。这份资源就是把这个移植过程拆开:从 PWM 与 ADC 的 HAL 配置、编码器/霍尔接口对接,到电流环、速度环、位置环三级串起来的闭环位置控制,最后落到一个能给定目标角度、电机稳稳转过去停住的最小工程。适合已经会用 CubeMX 点灯、想真正把 FOC 位置控制跑通的人,也适合调过速度环但位置环总是抖或者过冲的熟手。下面按“先讲清链路、再给可抄的配置和代码、最后说坑”的顺序走。
2. 闭环位置控制的三环链路:为什么不能跳过电流环直接给角度
2.1 位置环、速度环、电流环各自管什么
很多人第一次做位置控制,想法很直接:读编码器角度,和目标角度做差,PID 输出直接给 PWM 占空比。这么干在小功率、低惯量、空载的场合能转,一上负载就开始振荡或者爬行。原因是无刷电机的转矩和绕组电流近似成正比,你给占空比并不等于给转矩,中间隔着反电动势、相电阻、电感这些变量。DengFOC 的链路是标准三级串联:
- 位置环:输入目标角度与编码器实测角度的偏差,输出是速度指令。它管“到没到、到得多快”。
- 速度环:输入速度指令与实测速度的偏差,输出是 q 轴电流指令。它管“加速平不平稳”。
- 电流环:输入 iq 指令与实测 iq 的偏差,输出是 q 轴电压,再经反 Park 变换成 alpha/beta,最后 SVPWM 出六路 PWM。它管“转矩给得准不准”。
三级里电流环带宽最高,通常几 kHz;速度环低一个量级;位置环再低一个量级。这个带宽递减关系是稳定的前提,移植时最容易翻车的地方就是把三个环的周期设成一样,或者位置环跑得比速度环还快。
2.2 移植到 STM32 HAL 需要动哪几块
DengFOC 原实现的抽象层把 PWM 输出、ADC 采样、编码器读取都封装成了接口。搬到 STM32 上,对应关系大致是:
| DengFOC 抽象接口 | STM32 HAL 对应 | 关键点 |
|---|---|---|
| setPwm(a,b,c) | TIM 三路互补 PWM + 死区 | 中心对齐计数,死区按驱动芯片手册给 |
| getPhaseCurrent() | ADC 注入组或规则组 + 触发源 | 采样时刻必须对齐 PWM 中心 |
| getSensorAngle() | TIM 编码器模式 / SPI 绝对值编码器 | 注意多圈与电角度换算 |
| getVelocity() | 由角度差分或编码器测速 | 差分要滤波,别直接除 |
真正要改的代码量不大,难的是时序对齐:ADC 什么时候触发、电流采样在 PWM 周期的哪个点、编码器读数在控制中断里读还是 DMA 搬。这三件事没对齐,后面 PID 参数怎么调都是玄学。
2.3 最小可跑工程的文件组织
我一般把移植后的工程拆成这么几层,方便替换硬件:
Core/ Inc/ foc_config.h # 极对数、采样电阻、增益、死区等宏 foc_math.h # Clarke/Park、SVPWM 声明 Src/ foc_math.c # 坐标变换与 SVPWM 实现 foc_control.c # 三环 PID 与状态机 bsp_pwm.c # TIM 互补 PWM 初始化与占空比写入 bsp_adc.c # 电流采样触发与读取 bsp_encoder.c # 角度读取与电角度换算 main.c # 初始化 + 控制中断回调foc_config.h里最关键的几个宏:POLE_PAIRS(极对数,填错电角度直接错位)、CURRENT_GAIN(采样电阻与运放增益换算成 A/LSB)、PWM_PERIOD(与 TIM ARR 一致)、DEAD_TIME_NS。这几个值错一个,电机要么不转要么狂抖。
3. 用 CubeMX 配好 PWM、ADC 与编码器:参数怎么填
3.1 TIM1 互补 PWM 与死区设置
以常见的 20kHz 开关频率、72MHz 主频为例,中心对齐模式下 ARR = 72e6 / (2 × 20e3) = 1800。CubeMX 里 TIM1 选 PWM Generation CH1/CH2/CH3 加互补通道,Counter Mode 选 Center Aligned,Dead Time 按驱动芯片给,比如 500ns 对应寄存器值约 36(具体看时钟分频)。生成后确认HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1)三路都启动,互补通道由硬件自动输出。
// bsp_pwm.c 关键片段 void BSP_PWM_Init(void) { HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_2); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_3); HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1); // 互补通道 HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_2); HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_3); } // 写入三相占空比,输入为 0~1 归一化值 void BSP_PWM_SetDuty(float da, float db, float dc) { __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, (uint32_t)(da * PWM_PERIOD)); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_2, (uint32_t)(db * PWM_PERIOD)); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_3, (uint32_t)(dc * PWM_PERIOD)); }逻辑说明:中心对齐计数下,比较值在 0 到 ARR 之间变化,占空比 = CCR/ARR。互补通道必须显式启动,否则只有上桥有波形,下桥常闭,电机只会“咯噔”一下。死区在 CubeMX 的 Break and Dead Time 里配,别在代码里手动插延时。
3.2 ADC 电流采样与 PWM 中心对齐触发
电流采样最稳的做法是用 TIM1 的 TRGO 在计数到 ARR(中心点)时触发 ADC 注入组,此时下桥全开,采样电阻上的电流就是相电流。CubeMX 里 ADC 选 Injected Conversion,External Trigger 选 TIM1_TRGO,采样时间给足(比如 28.5 cycles),别为了快把采样时间压太短,运放输出还没建立就采,读数全是噪声。
// bsp_adc.c 采样完成回调里取两相电流 volatile float ia_raw, ib_raw; void HAL_ADCEx_InjectedConvCpltCallback(ADC_HandleTypeDef *hadc) { ia_raw = (float)HAL_ADCEx_InjectedGetValue(hadc, ADC_INJECTED_RANK_1); ib_raw = (float)HAL_ADCEx_InjectedGetValue(hadc, ADC_INJECTED_RANK_2); // 转成安培:减去零漂,乘增益 ia_raw = (ia_raw - CURRENT_OFFSET) * CURRENT_GAIN; ib_raw = (ib_raw - CURRENT_OFFSET) * CURRENT_GAIN; }参数说明:CURRENT_OFFSET是零电流时的 ADC 读数,上电后电机不转时采几百次取平均得到,别写死;CURRENT_GAIN= 1 / (采样电阻 × 运放增益 × ADC 满量程对应电压)。第三相电流由 ic = -ia - ib 算出,不用单独采。
3.3 编码器接口与电角度换算
增量式编码器用 TIM 的 Encoder Mode,省 CPU;绝对值编码器走 SPI 或 SSI。关键是电角度 = 机械角度 × 极对数,再对 2π 取模。DengFOC 里角度统一用弧度,移植时别混用度和弧度。
// bsp_encoder.c float BSP_GetMechAngle(void) { int32_t cnt = (int32_t)__HAL_TIM_GET_COUNTER(&htim3); return (float)cnt / ENCODER_CPR * 2.0f * PI; // 机械弧度 } float BSP_GetElecAngle(void) { float mech = BSP_GetMechAngle(); float elec = fmodf(mech * POLE_PAIRS, 2.0f * PI); return elec < 0 ? elec + 2.0f * PI : elec; }ENCODER_CPR是编码器每转计数,填错角度全错。多圈应用还要处理计数器溢出,简单做法是开 TIM 更新中断累加圈数,或者用 32 位计数器减少溢出频率。
4. 三环 PID 与 SVPWM 落地:代码怎么串
4.1 Clarke/Park 与反变换
电流环每周期要做一次 Clarke(三相到 alpha/beta)、Park(到 dq)、PID、反 Park、SVPWM。这套变换在foc_math.c里实现,注意用弧度、注意符号约定。
// foc_math.c void ClarkePark(float ia, float ib, float ic, float theta, float *id, float *iq) { float ialpha = ia; float ibeta = (ia + 2.0f * ib) * ONE_BY_SQRT3; float ct = cosf(theta), st = sinf(theta); *id = ialpha * ct + ibeta * st; *iq = -ialpha * st + ibeta * ct; } void InvPark(float ud, float uq, float theta, float *ualpha, float *ubeta) { float ct = cosf(theta), st = sinf(theta); *ualpha = ud * ct - uq * st; *ubeta = ud * st + uq * ct; }逻辑说明:Clarke 用等幅值变换,系数是 2/3 还是 1 取决于约定,DengFOC 用等幅值,ONE_BY_SQRT3= 0.57735。Park 的 theta 必须是电角度,用机械角度会得到完全错误的 dq 分量,表现为电流环怎么调都震荡。
4.2 SVPWM 七段式实现
反 Park 得到 ualpha/ubeta 后,用 SVPWM 生成三相占空比。七段式比正弦调制电压利用率高约 15%,是 FOC 标配。
// foc_math.c SVPWM void SVPWM(float ualpha, float ubeta, float vbus, float *da, float *db, float *dc) { float v1 = ubeta; float v2 = -0.5f * ubeta + SQRT3_BY_2 * ualpha; float v3 = -0.5f * ubeta - SQRT3_BY_2 * ualpha; // 扇区判断与作用时间计算(略去中间变量,按标准七段式) // 最终输出归一化到 0~1 的三相占空比 float t1 = v1, t2 = v2, t3 = v3; float tmax = fmaxf(fmaxf(t1, t2), t3); float tmin = fminf(fminf(t1, t2), t3); float tcom = (tmax + tmin) * 0.5f; *da = (t1 - tcom) / vbus + 0.5f; *db = (t2 - tcom) / vbus + 0.5f; *dc = (t3 - tcom) / vbus + 0.5f; }参数说明:vbus是母线电压实测值,用固定值会在电压波动时引入误差;输出占空比要限幅到 0.05~0.95,留出死区和采样窗口,别打到 0 或 1。
4.3 三环 PID 的周期与参数起点
控制中断建议放在 TIM1 更新中断里,20kHz 跑电流环;速度环每 10 次电流环跑一次(2kHz);位置环每 10 次速度环跑一次(200Hz)。这个比例不是死的,但带宽必须递减。
// foc_control.c 中断回调 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { // 电流环 20kHz float id, iq; ClarkePark(ia_raw, ib_raw, -ia_raw - ib_raw, BSP_GetElecAngle(), &id, &iq); float ud = PID_Update(&pid_d, 0.0f - id); float uq = PID_Update(&pid_q, iq_ref - iq); float ua, ub; InvPark(ud, uq, BSP_GetElecAngle(), &ua, &ub); float da, db, dc; SVPWM(ua, ub, vbus_measured, &da, &db, &dc); BSP_PWM_SetDuty(da, db, dc); // 速度环 2kHz if (++speed_div >= 10) { speed_div = 0; float vel = BSP_GetVelocity(); iq_ref = PID_Update(&pid_speed, vel_ref - vel); } // 位置环 200Hz if (++pos_div >= 100) { pos_div = 0; float pos = BSP_GetMechAngle(); vel_ref = PID_Update(&pid_pos, target_angle - pos); } } }参数起点:电流环 Kp 从 0.5、Ki 从 0.01 试起,看 iq 阶跃响应有没有过冲;速度环 Kp 从 0.05、Ki 从 0.001 起;位置环 Kp 从 2.0、Ki 从 0 起,先纯 P 看能不能停住,再加 I 消静差。每个环单独调,调好一个再闭合下一个,别三个一起上。
5. 移植避坑:五个让电机抖成筛子的常见问题
5.1 现象:上电电机原地高频抖动,电流很大
原因:电角度偏移没标定,或者极对数填错。Park 变换用的 theta 和实际转子位置差 90 度电角度,iq 和 id 完全错位,电流环拼命纠正但方向是错的。
解决:做一次电角度对齐。给 d 轴一个固定电压(ud = 小值,uq = 0),让转子被拉到 d 轴方向,读此时编码器角度作为 offset,之后所有电角度都加这个 offset。极对数用万用表或手动转一圈数编码器计数验证。
5.2 现象:低速爬行,位置环输出一直在正负之间跳
原因:编码器分辨率不够,或者速度由角度差分算,差分噪声被速度环放大。低速时一个控制周期角度变化可能只有几个 LSB,差分出来全是量化噪声。
解决:速度改用编码器测速模式(M/T 法)或对差分值做一阶低通;位置环输出加死区,误差小于阈值时输出 0,别让它在零点附近反复积分。
5.3 现象:给定角度后冲过头再回来,来回好几次才停
原因:位置环 Kp 太大或 Ki 太大,或者速度环带宽不够,位置环指令速度变化时速度环跟不上。
解决:先把位置环 Ki 设 0,Kp 减半看还过不过冲;如果还过,说明速度环响应慢,回头把速度环 Kp 提上去。位置环的积分只在稳态误差持续存在时才加,加之前确认速度环已经稳。
5.4 现象:电流采样值随占空比变化,不转时也有读数
原因:采样时刻没对齐 PWM 中心,采到了开关瞬间的振铃;或者零漂没标定,运放偏置随温度漂。
解决:确认 ADC 触发源是 TIM1_TRGO 且触发点在中心对齐的 ARR 处;上电后电机使能前采 500 次零漂取平均,运行中定期在零矢量时更新零漂。
5.5 现象:CubeMX 重新生成代码后自己写的回调被覆盖
原因:HAL 的回调是弱函数,用户代码写在stm32f1xx_it.c或生成文件里,重新生成就没了。
解决:所有用户回调写到单独的foc_control.c,在main.c的 USER CODE 区调用初始化;中断服务函数里只调HAL_TIM_IRQHandler,业务逻辑全放回调。生成前确认 USER CODE 段没被手动删掉。
6. 位置环调参的验证套路:用阶跃响应和频响把参数钉死
调位置环最怕“看着差不多就收工”,负载一变又得重来。我习惯用两个手段验证:阶跃响应看时域指标,扫频看带宽和相位裕度。阶跃响应好做,给目标角度一个 90 度跳变,用串口把目标、实际角度、速度、iq 以 1kHz 打出来,画图看上升时间、过冲量、调节时间。经验值:过冲小于 5%,调节时间在 200ms 以内,稳态误差接近 0,这组参数基本能上负载。
扫频稍微麻烦,但值得。给位置指令叠加一个小幅正弦(比如 5 度、频率从 1Hz 扫到 50Hz),记录实际角度幅值和相位,画出伯德图。位置环带宽一般做到 10~20Hz 就够用,相位裕度留 45 度以上。如果带宽上不去,先查速度环,再查电流环采样延迟——ADC 采样到 PWM 更新之间有一个周期的延迟,这个延迟在电流环里会吃掉相位裕度,采样时刻越靠近周期末尾,延迟越大。
还有一个容易被忽略的点:位置环的输出是速度指令,必须限幅。不限幅的话,大角度阶跃时速度指令会冲到很高,速度环饱和,积分 windup,回来时又是一轮过冲。限幅值按电机最高安全转速给,比如额定转速的 1.2 倍。同理,速度环输出 iq 指令也要限幅到电流上限,保护驱动。
最后说个习惯:每次改完参数,先把电机空载跑一遍阶跃,再挂上实际负载跑一遍,两组曲线叠在一起看差异。空载调好的参数上负载大概率要降 Kp 或加滤波,别指望一套参数通吃。从那以后我每次移植 FOC 工程,都强制先做电角度对齐、再标零漂、最后才碰 PID,这三步顺序反了,后面全是白调。希望帮到你。
本文还有配套的精品资源,点击获取