☰
STM32 FOC无刷电机控制工程:ADC采样到SVPWM双闭环实战
2026/10/26 4:29:45 网站建设 项目流程

简介:面向从事电机控制开发的嵌入式工程师与高校学习者,这套STM32 FOC无刷电机控制程序围绕三相无刷直流电机的磁场定向控制展开,覆盖从底层外设驱动到上层控制任务调度的完整实现,可用于无人机、电动车、机器人等场景的算法学习与工程移植。包内共184个文件,以140个.h头文件与40个.c源文件为主体,配合2个汇编启动文件、1个Keil工程文件与1个库文件,压缩包约3.79MB;代码涉及ADC采样、TIM与HRTIM高级定时器PWM输出、USART、CAN、SPI、RCC时钟配置等驱动模块,以及MCTasks、UserInterfaceClass等控制与交互任务文件,便于按模块梳理工程结构。已有4081人学习下载。读者可从中获得FOC电流环与速度环PID调节、霍尔与无感位置估算、PWM调制及电机参数辨识的参考实现,也能借鉴其任务划分与调试排错思路,适合作为入门FOC的对照工程与二次开发基础。

1. 从一块抖两下就过流的驱动板说起:STM32 FOC 工程到底解决了什么

上电,电机“嗡”一声抽两下,过流保护直接跳闸;示波器探头夹在采样电阻上,看到的相电流是一条毛刺乱窜的锯齿,不是正弦。这种场面在自研三相驱动板上太常见了,八成不是电机坏了,而是 FOC 链路里某一环没对齐——采样时刻踩在开关噪声上、相序接反、电角度零点偏了 90 度,任何一条都能让波形变成这团乱麻。

这份 STM32 FOC 无刷电机控制程序,就是把这条链路完整跑通的一套参考工程:TIM1 三相互补 PWM 带可调死区、ADC 注入组由 PWM 触发同步采三相电流、Clarke/Park 变换、电流环与速度环串级双闭环、共模注入式 SVPWM 输出,位置反馈同时留了 SPI 磁编码器和霍尔两套接口。它不适合想找“一键驱动库”的人,适合已经能把电机勉强转起来、却搞不清电流环到底怎么调的人,也适合刚拿到驱动板、需要一个能逐行对照的参照工程的新手。

2. 选型与目录结构:为什么这套 FOC 用三电阻采样 + 双闭环

2.1 控制链路:从 24V 母线到 20kHz 电流环

整条链路的时间节拍是理解这个工程的第一步。母线电压进来,三相半桥把直流斩成三路占空比可调的方波,采样电阻把相电流转成毫伏级电压,运放抬升到 ADC 量程中点后送进 MCU;MCU 在每个 PWM 周期的中点触发一次注入转换,拿到三相电流,做 Clarke/Park 变换转到 dq 轴,两个 PI 环算出 ud/uq,反变换回 alpha-beta,最后用 SVPWM 转成三路占空比写回定时器比较寄存器。

关键在于“中点”两个字。中心对齐模式下,计数器从 0 数到 Period 再数回 0,中间那次下溢对应的时刻正好落在三个下管同时导通的窗口里,此刻采样电阻上的电流才是真实的相电流,而不是开关瞬间的振铃。工程里把 TIM1 的 CH4 配成只做触发、不做功率输出的比较通道,它的上升沿就是 ADC 注入组的启动信号——这一步做对了,后面所有算法的输入才是干净的。

环节运行频率所在位置备注
电流环 / SVPWM20 kHzADC 注入完成中断与 PWM 同频,优先级最高
速度环1 kHzTIM6 基本定时器中断每 20 次电流环执行一次
位置环1 kHz与速度环共用节拍位置模式才使能
上位机上报100 Hz ~ 1 kHz主循环 + DMA 串口降频上报,避免占用中断

2.2 三大件选型:MCU、功率级、位置反馈

MCU 这层的硬性要求不多,但每一条都不能省:必须有至少一个高级定时器(带互补输出和死区插入)、至少一个支持注入组且能与定时器联动的 ADC、主频够跑 20kHz 的双 PI 加三角函数。工程里默认按 Cortex-M4 带 FPU 的型号配置,168MHz 主频下一次电流环的中断执行时间在 3 微秒上下,20kHz 周期 50 微秒,占用不到 7%,还有余量塞观测器。如果换到 M3 无 FPU 的型号,三角函数和浮点乘加会拖到 8 微秒以上,那时候就得把 sin/cos 换成查表加线性插值。

功率级是三相半桥加栅极驱动,母线 24V 的场景最常见。采样方式选了双电阻下桥臂采样:A、B 两相各串一只 1mΩ 采样电阻,运放做 20 倍同相放大并抬到 1.65V 中点,C 相电流由基尔霍夫定律反推。为什么不上单电阻?单电阻要在每个 PWM 周期里做两次不同时刻的采样再重构三相电流,窗口窄、时序敏感,新手调起来会非常痛苦;三电阻最省心但要多两路运放和 ADC 通道。双电阻是折中,代价是占空比接近 100% 时下管导通时间太短、采不到真实值,所以工程里把最大占空比钳在 95%。

位置反馈留了两套接口。SPI 磁编码器走 14 位分辨率,机械角直接读寄存器,乘极对数就是电角度,低转速下非常稳;霍尔接口走 TIM 的捕获,六步换向的原始信号经插值得到连续角度,成本低但低速时角度分辨率差。工程用一个feedback_type枚举在初始化时切换,两套读角函数签名完全一致,上层算法不用改一行。

2.3 目录结构:CubeMX 生成层与用户层怎么切

新手最容易被这个工程“劝退”的地方,是把 CubeMX 自动生成的代码和自己的算法混在一起改,改到最后重新生成一次全丢了。这里的切法是硬隔离:

foc_bldc/ ├── Core/ │ ├── Inc/ │ │ ├── foc_math.h # Clarke/Park/反Park 的 inline 实现 │ │ ├── foc_pi.h # PI 控制器结构体与抗饱和实现 │ │ ├── foc_svpwm.h # SVPWM 输出接口 │ │ └── bsp_current.h # 相电流读取、偏置校准 │ ├── Src/ │ │ ├── main.c # 只做初始化与主循环,不放算法 │ │ ├── stm32f4xx_it.c # 中断入口,仅调用 foc 层函数 │ │ └── app_foc.c # 电流环/速度环调度,中断里调它 │ └── Mdk-ARM/ 或 build/ # 工程文件与产物 ├── BSP/ │ ├── bsp_pwm.c # TIM1 互补输出 + 死区 │ ├── bsp_adc.c # 注入组 + 与 TIM1_CH4 联动 │ ├── bsp_encoder_spi.c # 磁编码器读角 │ ├── bsp_hall.c # 霍尔捕获 + 插值 │ └── bsp_uart.c # 上位机协议帧收发(DMA) └── App/ ├── motor_ctrl.c # 状态机:IDLE / ALIGN / OPENLOOP / CLOSEDLOOP / FAULT ├── motor_param.c # 极对数、电阻电感、保护阈值集中配置 └── host_proto.c # 波形上报与参数下发

一条原则:Core/Src里除了main.c和中断向量表,其他文件都当成“生成物”,不放业务逻辑;BSP只碰寄存器外设,不做数学;App只做调度和状态机,不直接写寄存器。这样重新用 CubeMX 生成代码时,只要中断服务函数里那几行FOC_xxx()调用重新贴一遍就能恢复,不用满仓库找自己的代码。血泪经验是:一旦把 PI 参数写进main.c的 while(1) 里,后面加个串口调参就得改到怀疑人生。

提示:motor_param.c里集中放极对数、相电阻、相电感、母线电压标定系数这几个值,极对数填错会让电角度整体偏,转子照样转但电流大得离谱,这是新手最常翻的车。

3. 核心链路拆解:一次电流环从 ADC 注入中断到 SVPWM 输出的完整路径

3.1 节拍来源:把 FOC 塞进 20kHz 的中心对齐 PWM

一切从这个定时器开始。中心对齐模式是为了让 PWM 波形对称、电流纹波小、采样点落在中点;重复计数器设成 1,是为了让更新事件在上溢和下溢各触发一次,避免同一个中断被重复计入。死区时间按栅极驱动器和 MOS 的开关速度给,24V 小功率场景 500ns 到 1μs 都算常见。

/* TIM1:中心对齐 20kHz,CH1~CH3 互补输出,CH4 只做 ADC 触发源 */ void BSP_PWM_Init(uint32_t freq_hz, uint16_t dead_time_ns) { uint32_t timer_clk = 168000000UL; /* 中心对齐模式下,一个 PWM 周期计两遍数,所以周期值 = clk / (2 * freq) */ uint32_t period = timer_clk / (2UL * freq_hz); htim1.Instance = TIM1; htim1.Init.Prescaler = 0; htim1.Init.CounterMode = TIM_COUNTERMODE_CENTERALIGNED1; htim1.Init.Period = period - 1; /* 20kHz -> 4200 */ htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter = 1; /* 上下溢各一次更新 */ HAL_TIM_PWM_Init(&htim1); /* 死区:按 ns 换算成定时器 tick,上升沿与下降沿都插入 */ TIM_BreakDeadTimeConfigTypeDef sBDT = {0}; sBDT.OffStateRunMode = TIM_OSSR_ENABLE; sBDT.OffStateIDLEMode = TIM_OSSI_ENABLE; sBDT.LockLevel = TIM_LOCKLEVEL_OFF; sBDT.DeadTime = (timer_clk / 1000000UL) * dead_time_ns / 1000UL; sBDT.BreakState = TIM_BREAK_ENABLE; /* 刹车输入接驱动故障脚 */ sBDT.BreakPolarity = TIM_BREAKPOLARITY_HIGH; sBDT.AutomaticOutput = TIM_AUTOMATICOUTPUT_ENABLE; HAL_TIMEx_ConfigBreakDeadTime(&htim1, &sBDT); TIM_OC_InitTypeDef sOC = {0}; sOC.OCMode = TIM_OCMODE_PWM1; sOC.Pulse = 0; sOC.OCNPolarity = TIM_OCNPOLARITY_HIGH; sOC.OCIdleState = TIM_OCIDLESTATE_RESET; sOC.OCNIdleState = TIM_OCNIDLESTATE_RESET; HAL_TIM_PWM_ConfigChannel(&htim1, &sOC, TIM_CHANNEL_1); HAL_TIM_PWM_ConfigChannel(&htim1, &sOC, TIM_CHANNEL_2); HAL_TIM_PWM_ConfigChannel(&htim1, &sOC, TIM_CHANNEL_3); /* CH4 不输出电压,只在中点产生一次上升沿,用来触发 ADC 注入组 */ sOC.Pulse = period / 2; HAL_TIM_PWM_ConfigChannel(&htim1, &sOC, TIM_CHANNEL_4); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1); /* CH2 / CH3 同理 */ }

RepetitionCounter = 1这个参数值得单独说一句:它设成 0 时,更新事件只在每次溢出时产生;设成 1 时上下溢都算一次,PWM 频率会变一半、触发节拍也会乱。我在两块不同的板子上都见过把这个值随手写成 0、结果电流环实际跑在 10kHz 的情况,波特图看着死活不对,查了两天才定位到这一行。BreakState建议一定打开,把驱动芯片的故障脚接进刹车输入,过流时硬件直接封锁六路输出,比软件在中断里判断快几十微秒,这几十微秒往往就是保 MOS 和炸 MOS 的差别。

ADC 注入组的配置必须和 CH4 严格对齐,触发边沿选上升沿,采样时间给到 28.5 个周期以上,让采样保持电容充分充电:

/* ADC1 注入组:J1=相电流 A,J2=相电流 B,J3=母线电压,由 TIM1_CH4 触发 */ void BSP_ADC_CurrentInit(void) { ADC_InjectionConfTypeDef sInj = {0}; sInj.InjectedNbrOfConversion = 3; sInj.InjectedSamplingTime = ADC_SAMPLETIME_28CYCLES_5; sInj.ExternalTrigInjecConv = ADC_EXTERNALTRIGINJECCONV_T1_CC4; sInj.ExternalTrigInjecConvEdge = ADC_EXTERNALTRIGINJECCONVEDGE_RISING; sInj.AutoInjectedConv = DISABLE; /* 不连续转换,只在触发时采一次 */ sInj.InjectedDiscontinuousConvMode = DISABLE; HAL_ADCEx_InjectedConfigChannel(&hadc1, &sInj); /* 通道 A */ HAL_ADCEx_InjectedConfigChannel(&hadc1, &sInj); /* 通道 B */ HAL_ADCEx_InjectedConfigChannel(&hadc1, &sInj); /* 母线电压 */ /* 使能注入转换完成中断,优先级设为最高 */ __HAL_ADC_ENABLE_IT(&hadc1, ADC_IT_JEOC); }

3.2 相电流重构与 Clarke/Park:把偏置扣干净再变换

进入中断后的第一件事不是算 PI,是读电流。读的时候必须减掉上电时标定的零电流偏置,否则静止时 iq 就不是 0,速度环一使能就往外冲。

/* 上电前电机必须静止:连续采 2000 次求均值作为零电流偏置 */ void BSP_CurrentOffsetCalib(FOC_HandleTypeDef *foc) { int32_t sum_a = 0, sum_b = 0; for (uint16_t i = 0; i < 2000; i++) { sum_a += ADC1->JDR1; /* 相电流 A 的注入结果寄存器 */ sum_b += ADC1->JDR2; HAL_Delay(1); /* 拉开间隔,让噪声被平均掉 */ } foc->offset_a = (int16_t)(sum_a / 2000); foc->offset_b = (int16_t)(sum_b / 2000); foc->offset_valid = 1; } /* 采样值 -> 安培:12bit ADC、3.3V 参考、1mΩ 采样电阻、20 倍放大 */ static inline float adc_to_amp(int16_t raw, int16_t offset) { const float v_per_cnt = 3.3f / 4096.0f; return ((float)(raw - offset) * v_per_cnt) / (0.001f * 20.0f); }

标定的 2000 次不是拍脑袋定的,取的是 2 的整数次附近的经验值,够把开关噪声和运放失调平均到一个比较稳的值。这里有个细节:标定期间不能开 PWM,也不能用手去碰电机轴,转子一动反电动势就会串进采样回路,偏置直接标歪。

变换部分全部写成static inline,放在头文件里让编译器内联,省掉函数调用开销:

/* Clarke:等幅值变换,只用 A、B 两相,C 相由基尔霍夫定律反推 */ static inline void FOC_Clarke(FOC_HandleTypeDef *foc) { foc->ic = -(foc->ia + foc->ib); foc->i_alpha = foc->ia; foc->i_beta = (foc->ia + 2.0f * foc->ib) * ONE_BY_SQRT3; /* 0.57735 */ } /* Park:旋转坐标系,theta_e 为电角度(弧度) */ static inline void FOC_Park(FOC_HandleTypeDef *foc) { foc->id = foc->i_alpha * foc->cos_t + foc->i_beta * foc->sin_t; foc->iq = -foc->i_alpha * foc->sin_t + foc->i_beta * foc->cos_t; } /* 反 Park:把 PI 算出的 ud/uq 转回静止坐标系 */ static inline void FOC_InvPark(FOC_HandleTypeDef *foc, float *u_alpha, float *u_beta) { *u_alpha = foc->ud * foc->cos_t - foc->uq * foc->sin_t; *u_beta = foc->ud * foc->sin_t + foc->uq * foc->cos_t; }

ONE_BY_SQRT3出现在这里是因为用了等幅值变换而不是等功率变换。两种变换差一个系数,只要 ud/uq 到占空比的换算跟着一起改就都成立,但混用必翻车——现象是电机能转、力矩偏小、电流环怎么调都软绵绵。工程统一约定走等幅值,所有系数按等幅值给,别中途换。

电角度由磁编码器给出:读回 14 位原始值,乘 360/16384 得机械角,再乘极对数取模 2π。sin_t/cos_t用sinf/cosf现算,M4 的 FPU 加单精度库下大约 1.2 微秒,能接受;如果主频吃紧,改成 256 点查表加一阶线性插值,误差控制在 0.3% 以内,速度能快三倍。

3.3 电流环 PI:抗饱和和积分分离比参数本身更重要

两个电流环结构完全一样,d 轴参考固定给 0(表贴式电机 id=0 控制),q 轴参考来自速度环输出。真正决定这套代码能不能用的,不是 Kp/Ki 取多少,而是饱和了怎么退出来。

typedef struct { float kp; float ki; float integral; float out_max; float out_min; } PI_HandleTypeDef; /* 带反算抗饱和的 PI:输出被限幅时同步修正积分项 */ static inline float FOC_PI_Calc(PI_HandleTypeDef *pi, float ref, float fb) { float err = ref - fb; float p = pi->kp * err; /* 积分分离:误差大时先靠比例项顶上去,避免积分狂积累 */ if (fabsf(err) < PI_INTEGRAL_BAND) { pi->integral += pi->ki * err; } float out = p + pi->integral; if (out > pi->out_max) { out = pi->out_max; pi->integral = pi->out_max - p; /* 反算:把积分项拉回到刚好不饱和 */ } else if (out < pi->out_min) { out = pi->out_min; pi->integral = pi->out_min - p; } return out; }

三个参数各管一段:kp决定响应快慢,给大了电流波形上会叠一层高频振荡;ki决定稳态误差消除速度,给大了阶跃响应过冲明显、恢复慢;PI_INTEGRAL_BAND是积分分离阈值,按额定电流的 20%~30% 给,作用是启动瞬间或大幅阶跃时不积累一堆用不掉的积分。out_max / out_min一般钳在母线电压除以根号三再留 5% 余量,钳太松会导致过调制、波形削顶,钳太紧又浪费母线利用率。

参数整定的路子我一般按这个顺序走:先把 ki 清零,只留 kp,从小往大加,加到电流阶跃响应刚好出现轻微过冲就退回来一点;再固定 kp 加 ki,观察稳态误差收敛速度,出现明显过冲就把 ki 减半;最后把积分分离阈值调一下,让大阶跃时不至于顶到限幅太久。整个过程配合上位机看 iq 的阶跃波形,比盲调快得多。

3.4 SVPWM:共模注入等效实现与占空比钳位

从 ud/uq 到三路比较值,用的是共模注入法。它和查扇区表算矢量作用时间的传统写法数学上完全等价,但代码短、没有分支,跑在中断里更友好,母线利用率同样比正弦调制高 15.47%。

/* 共模注入式 SVPWM:输入 ud/uq 与母线电压,输出三路占空比写进 CCR */ static void FOC_SVPWM_Output(FOC_HandleTypeDef *foc, float vbus) { float u_alpha, u_beta; FOC_InvPark(foc, &u_alpha, &u_beta); /* 反 Clarke,得到三相相对中点电压(未归一化) */ float ua = u_alpha; float ub = -0.5f * u_alpha + 0.8660254f * u_beta; float uc = -0.5f * u_alpha - 0.8660254f * u_beta; /* 注入零序分量:取三相最大最小值的平均,等效于 SVPWM */ float u_max = fmaxf(ua, fmaxf(ub, uc)); float u_min = fminf(ua, fminf(ub, uc)); float u_com = 0.5f * (u_max + u_min); /* 转成占空比并钳位:下限留给下管导通窗口,上限防过调制 */ float duty_a = 0.5f + (ua - u_com) / vbus; float duty_b = 0.5f + (ub - u_com) / vbus; float duty_c = 0.5f + (uc - u_com) / vbus; duty_a = fminf(fmaxf(duty_a, DUTY_MIN), DUTY_MAX); /* 0.05 ~ 0.95 */ duty_b = fminf(fmaxf(duty_b, DUTY_MIN), DUTY_MAX); duty_c = fminf(fmaxf(duty_c, DUTY_MIN), DUTY_MAX); uint32_t period = __HAL_TIM_GET_AUTORELOAD(&htim1) + 1; __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, (uint32_t)(duty_a * period)); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_2, (uint32_t)(duty_b * period)); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_3, (uint32_t)(duty_c * period)); }

DUTY_MIN = 0.05不是保守,是被采样方式逼出来的:双电阻采样要等下管导通才能测到电流,占空比太高时下管导通窗口只有几百纳秒,ADC 采样保持根本来不及建立,测出来的就是噪声。钳在 5% 意味着有效调制比上限大约 90%,配合前面说的电流环输出限幅,日常跑不到这个边界。DUTY_MAX = 0.95同理,同时防止上下管直通风险。

3.5 速度环与位置环:降频执行,输出级联给 q 轴

三个环不在同一频率上跑。电流环 20kHz,速度环 1kHz,位置环 1kHz,原因是机械时间常数比电气时间常数大一到两个数量级,速度环跑太快只会把编码器的量化噪声放大成抖动。

/* ADC 注入完成中断:20kHz 电流环,这是整个工程执行频率最高的函数 */ void ADC_IRQHandler(void) { if (__HAL_ADC_GET_FLAG(&hadc1, ADC_FLAG_JEOC)) { __HAL_ADC_CLEAR_FLAG(&hadc1, ADC_FLAG_JEOC); foc.ia = adc_to_amp(ADC1->JDR1, foc.offset_a); foc.ib = adc_to_amp(ADC1->JDR2, foc.offset_b); foc.vbus = ADC1->JDR3 * VBUS_COEF; BSP_EncoderUpdate(&foc); /* 刷新 theta_e / sin_t / cos_t */ FOC_Clarke(&foc); FOC_Park(&foc); /* q 轴参考由速度环在 1kHz 中断里更新,这里只读不算 */ foc.ud = FOC_PI_Calc(&pi_d, 0.0f, foc.id); foc.uq = FOC_PI_Calc(&pi_q, foc.iq_ref, foc.iq); FOC_SVPWM_Output(&foc, foc.vbus); FOC_FaultCheck(&foc); /* 过流/过压/堵转判断,置故障标志 */ } } /* TIM6 中断 1kHz:速度环 + 位置环 */ void TIM6_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim6, TIM_FLAG_UPDATE)) { __HAL_TIM_GET_CLEAR_FLAG(&htim6, TIM_FLAG_UPDATE); float speed_rpm = BSP_EncoderGetSpeed(); /* 差分测速,带一阶低通 */ float speed_ref = (mode == MODE_SPEED) ? target_rpm : pos_pi_out; pos_pi_out = FOC_PI_Calc(&pi_pos, target_pos, foc.angle_mech); foc.iq_ref = FOC_PI_Calc(&pi_speed, speed_ref, speed_rpm); /* 堵转保护:速度上不去但 iq 已经顶到限幅,持续 200ms 判故障 */ FOC_StallDetect(&foc, speed_rpm); } }

速度环的反馈值用差分法算:每毫秒取一次机械角,累加增量再除以时间,比直接对角度做微分稳得多。增量累加时要处理过零点跳变,14 位编码器从 16383 跳到 0 的那一帧如果没做补偿,会算出一个负的巨大瞬时速度,速度环直接被带飞。FOC_StallDetect是保护逻辑,堵转判断看的是“速度反馈小于阈值且 iq 长时间处于限幅状态”,两个条件同时满足才判故障,只看一个容易误报。

4. 跑起来:从静态检查到空载自转的四步联调

4.1 上电前:不用示波器也能查出八成问题

接电源之前先做三件事,顺序不能乱。第一,万用表二极管档量六路 MOS 的体二极管,正常应该读到 0.4~0.6V 的压降,如果有短路或者开路,直接换管,别急着上电。第二,母线并一只限流电阻或者用可调电源限流到 0.5A,第一次上电就靠它保命——真炸管的时候限流会立刻把电压拉下来,比听到“啪”一声再心疼便宜得多。第三,光看代码不够,用调试器把 PWM 输出使能去掉,先确认母线电压采样的换算系数对:万用表量到的 24.0V 和foc.vbus变量里的读数差不超过 0.3V,才算采样链路没问题。

这一步里最容易漏的是驱动芯片的欠压锁定。有些驱动在 12V 栅极供电没建立起来时会一直输出低电平,看起来就像“程序没跑起来”,实际上是硬件没就绪。上电顺序建议是控制板先起、栅极供电后起,中间留 100ms 以上。

4.2 开环强拖:验证相序和电角度零点

闭环之前一定要先开环。做法是给一个固定的电角度增量,比如每秒转 5 个电周期,让 ud 给一个小电压、uq 给 0,看转子能不能顺着预设方向平稳转起来。

/* 开环强拖:theta 由程序累加,不看编码器,用来验证相序 */ void Motor_OpenLoopRun(float vq_target, float elec_hz, float dt) { static float theta_open = 0.0f; theta_open += 2.0f * PI * elec_hz * dt; /* 人为累加电角度 */ if (theta_open > 2.0f * PI) theta_open -= 2.0f * PI; foc.theta_e = theta_open; foc.sin_t = sinf(theta_open); foc.cos_t = cosf(theta_open); foc.ud = 0.0f; foc.uq = vq_target; /* 从小往大加,0.5V 起步 */ FOC_SVPWM_Output(&foc, foc.vbus); }

vq_target从 0.5V 起步,每次加 0.2V,加到电机能连续转就停。如果加了电压电机只是抖不动,或者转起来电流明显偏大、声音发闷,八成是相序接反了——把任意两相的输出线对调再试。相序对了之后,再把编码器读到的电角度和开环的theta_open做差,这个差值就是电角度零点偏移,写进motor_param.c的angle_offset里。测零点有个小技巧:让电机停在某个固定位置,手动把转子正反各拨一点点,读出编码器值取平均,这样能消掉齿槽转矩带来的几度误差。

注意:开环强拖期间电流不受控,只受vq_target和绕组电阻限制。一定从最小电压起步,全程盯着母线电流,超过额定值一半立刻停,别抱着“再大一点就转起来了”的侥幸。

4.3 上位机协议:波形比打印更可信

调 FOC 不看波形等于闭着眼睛开车。工程里用一条最简单的串口协议做上报和参数下发,帧格式固定,DMA 发送,不占 CPU:

字段长度说明
帧头2 字节0xAA 0x55
长度1 字节payload 字节数
命令1 字节0x01 波形上报 / 0x02 参数下发 / 0x03 状态查询
数据N 字节上报时为 iq_ref/iq/id/speed 各 2 字节小端
CRC162 字节查表法,覆盖长度到数据段

上报频率默认 500Hz,也就是每 40 个电流环周期发一帧,每帧 4 个通道 8 字节,波特率 921600 刚好够用。上位机那边把 iq_ref 和 iq 画在同一张图上,阶跃响应有没有过冲、稳态有没有静差,一眼就看出来了,比在中断里printf靠谱一百倍——真在中断里printf的后果是中断被阻塞几十微秒,PWM 输出直接乱掉,然后你就开始怀疑算法有问题,这是最经典的自坑。

参数下发走同一条协议,0x02命令后面跟参数 ID 和 float 值,可以在线改 Kp/Ki,边看波形边调,不用每次改完编译下载。工程里给参数 ID 留了 16 个槽位,够放两组 PI 加保护阈值。

5. 避坑与排查:炸管、抖动、偏置漂移的五条血泪记录

5.1 现象:一使能就炸下管,限流都来不及

原因:死区时间不够,或者互补通道根本没使能。HAL 库的HAL_TIM_PWM_Start只启动主通道,互补通道必须额外调HAL_TIMEx_PWMN_Start,漏了这一句的后果是上管永远关断、下管一直斩波,看起来能转但力矩只剩一半;反过来如果死区配得太小,上下管在切换瞬间直通,母线电流会瞬间顶到几十安。还有一种更隐蔽的情况:AutomaticOutput没开,刹车事件触发一次之后输出就永久封锁,重新上电才好,让人误以为是随机炸管。

解决:死区按“驱动器传输延迟 + MOS 关断时间”再留一倍余量给,24V 场景直接给 1μs 起步,调通了再往下减;六路启动函数一个都不能少;BreakState使能的同时把AutomaticOutput打开,但还是建议故障后走软件重启流程,别指望自动恢复。

5.2 现象:电机静止时 iq 读数不是 0,一闭环就慢慢往外冲

原因:零电流偏置漂了。运放的失调电压随温度漂,标定时的温度和工作温度差十度,偏置就能差十几个 ADC 码;另一种是标定时 PWM 已经在跑,开关噪声混进平均值里,标出来的偏置本身就不对。

解决:标定必须在 PWM 关闭、电机静止时做,标完把offset_valid置位,闭环入口处检查这个标志;运行中再做一次慢速跟踪校正,电机使能但转速为零且持续时间超过 200ms 时,用一阶低通把当前采样值慢慢往偏置里带,时间常数给到秒级,就不会影响正常控制。

5.3 现象:能转,但同转速下电流比预期大一倍,电机发烫

原因:电角度零点偏了。偏 90 度电角度是最典型的,此时 q 轴电流实际作用在 d 轴上,只发热不出力。还有一种情况是极对数填错,比如 7 对极填成 6 对极,角度误差随转子位置线性累积,转速越高电流越大。

解决:按 4.2 的方法重新测零点偏移;极对数不要靠数磁钢猜,用手转一圈数编码器读数的变化量,除以 16384 再取整,这个值就是极对数。测完把pole_pairs和angle_offset一起写进参数文件,并在启动时打印出来核对一遍。

5.4 现象:低速时电流波形有明显锯齿,速度环持续小幅抖动

原因:采样时刻和 PWM 不同步,或者编码器角度更新滞后。前者多半是 ADC 触发源配错,落在了开关瞬间;后者是磁编码器 SPI 读取放在了电流环中断里,一次读 16 位要几微秒,把电流环的执行时间挤到边界,抖动就出来了。

解决:确认 ADC 触发源是 TIM1_CH4 且边沿为上升沿,用示波器同时看 PWM 下管波形和 ADC 的转换开始标志;编码器读取改成在 1kHz 的速度环中断里读,电流环只做角度插值(用上次角度加角速度乘周期),延迟能压到一个电流环周期以内。

5.5 现象:接上上位机开始收波形,电机立刻失控

原因:在中断里做串口发送。printf或者阻塞式HAL_UART_Transmit一调用就是几百微秒,20kHz 的电流环被拖成几百赫兹,控制彻底失稳。

解决:所有上报走 DMA,主循环里组帧、填缓冲区、启动 DMA,发完中断里置标志;中断里只更新一个环形缓冲区的写指针,绝不做任何阻塞操作。缓冲区深度给 8 帧以上,主循环忙的时候丢帧也比阻塞强。

6. 二次开发:把霍尔换成磁编、给电流环加解耦的改造与验证

这套工程用到最后,多数人都会动两处:换反馈方式,或者给电流环补上解耦项。两件事都不难,难的是改完怎么确认自己没改坏。

先说换反馈。工程里feedback_type已经留了枚举,bsp_encoder_spi.c和bsp_hall.c导出的是同一组函数签名,改起来只涉及三处:初始化时选类型、BSP_EncoderUpdate里的分支、motor_param.c里的分辨率与零点参数。霍尔换成 14 位磁编,角度分辨率从约 60 度电角度提升到 0.02 度,最直接的变化是低速转矩脉动消失,但速度环的 Kp 得往下调——原来的低分辨率把噪声滤掉了,换成高分辨率之后,编码器的量化噪声会实实在在地进到速度环里。我一般先把速度环 Kp 砍到原来的 0.5 倍,再往上加。

再说解耦。表贴式电机在低速时 Ld≈Lq,交叉耦合项可以忽略;一旦转速上到额定的一半以上,ωe·Lq·iq这一项就顶上来了,表现为带载加速时 id 被拉偏、动态响应变钝。补法很直接,在 PI 输出后面加前馈:

/* 电流环解耦前馈:需要相电感 Ls 与电角速度 omega_e */ static inline void FOC_Decouple(FOC_HandleTypeDef *foc, float Ls, float flux) { float we = foc->omega_e; /* 电角速度,rad/s */ float ud_pi = FOC_PI_Calc(&pi_d, 0.0f, foc->id); float uq_pi = FOC_PI_Calc(&pi_q, foc->iq_ref, foc->iq); /* d 轴补偿 -we*Ls*iq,q 轴补偿 +we*Ls*id + we*flux */ foc->ud = ud_pi - we * Ls * foc->iq; foc->uq = uq_pi + we * Ls * foc->id + we * flux; }

Ls用万用表电感档量相间电感再除以 2 得到,flux拿反电动势常数换算,两个值给不准也不会炸,只是补偿过头会让电流环变得敏感。改完必须验证:给一个 0 到额定转速的斜坡,看 id 的波动幅度,解耦前 id 会跟着转速往上飘几十毫安,解耦后应该压在十毫安量级;再看同样负载下的加速时间,能缩短 10%~20% 就算补对了。

改造的验证方法我总结成一个固定套路,每次都走一遍:先空载低速看电流波形是不是干净的正弦,再空载阶跃看 iq 的超调和恢复时间,然后带载低速看会不会失步,最后带载全速跑五分钟摸 MOS 温度。四步里有任何一步不对就退回去,别在带载状态下改参数。

最后说个习惯。这套代码里的每一个魔数——死区 1μs、占空比钳位 0.05、积分分离阈值 0.3 倍额定、偏置标定 2000 次——我都要求自己在旁边写一行注释说明“这个值是在什么条件下测出来的”。因为半年后再看,你会完全不记得为什么是 0.05 而不是 0.03,然后手一抖改成 0.03,板子重新开始炸。从那以后我每次动这类参数前,都强制走一遍空载阶跃加带载五分钟的验证,哪怕只改了一个小数点。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询