简介:面向嵌入式电机控制开发者与高校学生的STM32 FOC无刷电机控制程序,聚焦三相无刷直流电机的磁场定向控制实现,可用于无人机、电动车、机器人等场景的算法学习与工程移植。压缩包共184个文件,约3.79MB,以140个h头文件和40个c源文件为主体,配合2个汇编启动文件、1个Keil uvprojx工程及1个lib库,覆盖外设驱动、任务调度与电机控制模块的组织方式。程序涉及初始化配置、电机参数计算、霍尔与无感位置估计、PID调节及PWM调制等环节,工程结构便于对照理解FOC从采样到输出的完整链路。已有4081人学习,适合具备STM32基础、希望深入掌握BLDC控制与调试优化的读者参考。
1. 从方波六步换相到 FOC:这套 STM32 无刷电机程序补的是哪一段
很多人第一次接触无刷电机,都是焊好三根相线,烧进一个方波六步换相的例程,电机嗡一声转起来,那一刻挺爽。可一旦加上负载,转速往下掉、电流嗷嗷叫,六步换相固有的转矩脉动就藏不住了,噪声和发热一起上来,低速时更明显。这套基于 STM32 的 FOC 无刷电机控制程序,补的正是「能转」到「转得稳、转得安静、带得动负载」这一段路。
它把磁场定向控制这条链路完整铺开:三电阻电流采样、Clarke/Park 变换、电流环与速度环双闭环、SVPWM 输出、死区补偿,外面再套一层过流、过压、堵转保护和一套无感启动状态机。代码按算法层、外设层、应用层切开,寄存器操作全压在 BSP 里,算法函数只吃结构体、吐结构体。适合手上已经有 STM32 开发环境、想把 FOC 从课本公式真正落到定时器和 ADC 上的开发者;也适合拿它当产品电控的起点,改改采样方式、换换观测器接着往下做。
2. 工程骨架拆解:三环级联怎么落到 STM32 的外设分工上
拿到一份电机控制代码,先看目录往往比先看 main 函数更有用。原因很实在——FOC 的难点不在单个公式,而在于「谁在什么时刻算、算完的东西交给谁」。目录结构决定了这条数据通路有没有被硬件打断。
2.1 目录分层:算法、外设、应用三层怎么切开
这类工程我一般会切成四层,下面这套结构是常见做法,也是读代码时最好上手的一种:
FOC_MotorCtrl/ ├── Core/ # 芯片厂商生成的启动与中断向量 │ ├── Src/main.c │ └── Src/stm32xx_it.c # 中断入口只做转发,不写业务 ├── BSP/ # 只认识寄存器,不认识电机 │ ├── bsp_pwm.c # TIM1 三相互补 + 死区 + 刹车 │ ├── bsp_adc.c # 注入组电流采样、偏置校准 │ ├── bsp_encoder.c # 正交编码器 / 霍尔接口 │ ├── bsp_uart.c # 上位机通信 │ └── bsp_flash.c # 参数固化,双备份 + CRC ├── Algorithm/ # 纯数学,可放到 PC 上单测 │ ├── foc_math.c # Clarke / Park / 逆变换 │ ├── svpwm.c # 七段式 + 中点平移 │ ├── pid_regulator.c # 位置式 PID + 抗饱和 │ └── observer_smo.c # 滑模观测器(无感方案用) ├── Motor/ # 胶水层,负责状态机和调度 │ ├── mc_interface.c # 启动、停止、故障处理 │ ├── mc_tasks.c # 高频任务、中频任务 │ └── speed_pos_fdbk.c # 速度估算、角度来源切换 └── App/ # 业务:协议、参数、上位机 ├── protocol.c └── param_store.c分层的关键约束只有一条:Algorithm/里的函数不允许出现任何HAL_前缀的调用,也不允许直接读全局的 ADC 缓冲。所有输入走参数,所有输出走结构体。这条守住了,后面想在 PC 上用一段录下来的电流波形回放验证算法,直接把这几个 .c 拷出去就能编译,不用为了跑个数学函数去搭一整套硬件环境。
反过来,BSP/里也不该出现「电角度」「q 轴」这种词。它只负责一件事:给定三个比较值,输出三相 PWM;给定触发时刻,把两相电流读回来。这种「谁都不越界」的写法前期啰嗦,后期改动时能省下大量时间——尤其是你想从三电阻采样换成单电阻采样的时候,只需要重写 BSP 和电流重构,算法层一行不动。
2.2 外设分工:一颗芯片上谁干什么活
FOC 对时序的要求是硬性的,所以外设分配必须一次定死。下面这张表是这类工程的典型分工:
| 外设 | 承担的角色 | 关键配置 | 容易忽略的点 |
|---|---|---|---|
| TIM1 | 三相互补 PWM | 中心对齐、死区 500ns~1us、刹车输入使能 | 死区太小会直通,太大会拉低有效占空比 |
| ADC1/ADC2 | 相电流 + 母线电压 | 注入组、由 TIM1 TRGO 触发、双 ADC 同步 | 采样时刻必须落在下桥全开的窗口内 |
| 内置 OPAMP | 采样电阻信号放大 | 跟随或同相放大、偏置抬到 VREF/2 | 增益算错会导致电流量程对不上 |
| COMP + DAC | 硬件过流保护 | 阈值略高于软件保护、输出直连 TIM1 BKIN | 这条通路要能绕过 CPU 独立动作 |
| TIM2/TIM3 | 增量编码器接口 | 四倍频、16 位自动重装 | 溢出处理不做,跨零点会飞车 |
| USART/CAN | 上位机调参 | DMA 收发、环形缓冲 | 在高频中断里直接 printf 必翻车 |
| Flash 末页 | 参数固化 | 双区轮换 + CRC16 | 单区写,掉电一次参数全丢 |
中心对齐计数不是为了好看,而是为了让电流采样点固定在计数器谷底。谷底时刻三相下桥臂同时导通,采样电阻上的电压就是真实相电流;如果换成边沿对齐,采样点会随着占空比漂移,轻载时还能凑合,重载时采到的就是开关噪声。
提示:死区时间和采样窗口是一对矛盾。死区越大,最小脉宽越宽,占空比接近 0 或 100% 时采样窗口被压得越窄。调试时先把死区设到数据手册推荐值,再回头验证极限占空比下的采样是否还有效。
2.3 时序预算:50 微秒里塞下多少活
20kHz 的 PWM 载波意味着每个周期只有 50us。这 50us 里,FOC 主中断要完成 ADC 读数、偏置扣除、Clarke、Park、两个 PID、逆 Park、SVPWM、占空比写入,还要留出余量给通信和状态机。常见的做法是把任务按频率分档:
/* mc_tasks.c —— 中断入口只做分流,避免在 ISR 里写业务逻辑 */ void TIM1_UP_TIM16_IRQHandler(void) /* 每个 PWM 周期触发一次,20kHz */ { if (__HAL_TIM_GET_FLAG(&htim1, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(&htim1, TIM_FLAG_UPDATE); mc_high_freq_task(); /* 电流环 + SVPWM,必须在本周期内跑完 */ } } void mc_high_freq_task(void) { /* 1. 取本周期在谷底采到的两相电流 */ int16_t ia = mc_adc_get_phase_a(); int16_t ib = mc_adc_get_phase_b(); /* 2. 电流环:给定来自速度环或上位机 */ foc_ab_dq_t i_ab_dq; clarke_park(ia, ib, mc_ctx.sin_th, mc_ctx.cos_th, &i_ab_dq); int32_t vd = pid_run(&pid_id, mc_ctx.id_ref, i_ab_dq.d); int32_t vq = pid_run(&pid_q, mc_ctx.iq_ref, i_ab_dq.q); /* 3. 逆变换 + SVPWM,直接写比较寄存器 */ svpwm_out_t duty; svpwm_calc((int16_t)vd, (int16_t)vq, mc_ctx.sin_th, mc_ctx.cos_th, PWM_PERIOD, &duty); bsp_pwm_set_duty(duty.ta, duty.tb, duty.tc); /* 4. GPIO 翻转,用来在示波器上量中断耗时 */ BSP_DEBUG_PIN_TOGGLE(); }这段代码里最值得盯的是最后那行 GPIO 翻转。中断耗时不靠感觉,靠示波器量——把调试脚接上探头,宽度就是主中断的执行时间。经验值是控制在 10us 以内,留出 80% 的余量;如果量出来超过 20us,先别急着优化算法,大概率是某个函数里偷偷调用了浮点除法,或者 SVPWM 里用了__aeabi_fdiv这种软浮点实现。
参数说明:PWM_PERIOD是定时器 ARR 的值,决定占空比的分辨率;mc_ctx.sin_th/cos_th每周期由角度来源更新一次,用编码器时直接查表,用观测器时在上一个周期算好。角度和电流必须是同一时刻的,差一个周期就会在高速时表现为 dq 电流耦合、转矩掉一截——这个坑后面还会提到。
中频任务(1kHz 左右)负责速度环、状态机流转和故障判定;低频任务(10~100Hz)跑通信、参数保存、上位机波形打包。三档频率分开,中断嵌套就简单了,主中断优先级最高且不允许被打断,其余的都放到主循环里轮询。
3. 核心算法链路:一次电流采样到三相占空比的完整通路
把这套代码跑起来之后,最有价值的阅读顺序是顺着数据流走一遍:ADC 注入组在什么时刻触发,读回来的两个数怎么变成 dq 轴的两个数,PID 输出又怎么变回三相占空比。这一整条链路上,每一环都有一个「看起来能跑、实际带载就出问题」的细节。
3.1 采样时刻与注入组触发:窗口只有几个微秒
电流采样是整条链路里最容易出问题的一环,因为它的正确性依赖于前面 PWM 的状态。三电阻方案在三个下桥臂各串一个采样电阻,只有在三相下桥全开的时候,电阻上的电压才等于相电流;其他任何时刻,采到的都是续流二极管或者开关管的压降。
/* bsp_adc.c —— 注入组采样,触发源绑定 TIM1 的 TRGO */ static void current_sense_adc_init(void) { /* 内置运放先把采样电阻上的毫伏级信号抬到 VREF/2 附近 */ HAL_OPAMP_Start(&hopamp1); HAL_OPAMP_Start(&hopamp2); ADC_InjectionConfTypeDef inj = {0}; inj.InjectedNbrOfConversion = 2; /* 只采两相,第三相算出来 */ inj.InjectedChannel = ADC_CHANNEL_1; /* A 相 */ inj.InjectedRank = ADC_INJECTED_RANK_1; inj.InjectedSamplingTime = ADC_SAMPLETIME_12CYCLES_5; /* 够运放建立即可 */ inj.ExternalTrigInjecConv = ADC_EXTERNALTRIGINJEC_T1_TRGO; inj.ExternalTrigInjecConvEdge = ADC_EXTERNALTRIGINJECCONV_EDGE_RISING; HAL_ADCEx_InjectedConfigChannel(&hadc1, &inj); /* 注入组转换完成中断里,电流已经躺在 JDR1/JDR2 */ __HAL_ADC_ENABLE_IT(&hadc1, ADC_IT_JEOC); }逻辑上分三步:运放做前置放大,把几十毫伏的采样信号抬到 ADC 有效量程内;注入组只配置两路,第三相靠基尔霍夫定律反推,省一个通道也省转换时间;触发源选 TIM1 的 TRGO,而 TRGO 的更新事件配置在计数器下溢时刻,正好对应谷底。
参数说明:ADC_SAMPLETIME_12CYCLES_5不是随便填的,运放的建立时间和采样电容的充电时间加起来必须小于这个窗口,换运放或者换采样电阻时要重算;InjectedNbrOfConversion = 2意味着 ADC 在 2 个转换周期后出结果,转换期间 CPU 不该去读 JDR,否则拿到的是上一拍的数据。
注意:上电后第一件事是偏置校准,不是启动电机。把三相下桥关断、上桥关断,此时采样电阻上电流为零,读回来的 AD 值就是零点偏置。这个值必须存下来并在每次采样时扣除,否则温漂会让 q 轴电流缓慢漂移,表现为电机空载时也在慢慢加速。
3.2 Clarke 与 Park:定点化之后才知道的溢出风险
从静止坐标系到旋转坐标系的变换,公式书上只有两行,但落到 MCU 上要处理的是精度和溢出。带 FPU 的型号可以直接用 float,成本不高;不带 FPU 或者想省中断时间的,一般走 Q15 定点。
/* foc_math.c —— Q15 定点实现,全部用 int32 做中间量防溢出 */ #define ONE_BY_SQRT3_Q15 18919 /* 1/√3 ≈ 0.57735,Q15 定标 */ typedef struct { int16_t alpha; int16_t beta; int16_t d; int16_t q; } foc_ab_dq_t; void clarke_park(int16_t ia, int16_t ib, int16_t sin_th, int16_t cos_th, foc_ab_dq_t *out) { int32_t ia32 = ia; int32_t ib32 = ib; /* Clarke:等幅值变换,ic 用基尔霍夫定律反推,不单独采 */ out->alpha = (int16_t)ia32; out->beta = (int16_t)(((ia32 + 2 * ib32) * ONE_BY_SQRT3_Q15) >> 15); /* Park:投影到随转子旋转的 dq 轴 */ int32_t alpha = out->alpha; int32_t beta = out->beta; out->d = (int16_t)(( alpha * cos_th + beta * sin_th) >> 15); out->q = (int16_t)((-alpha * sin_th + beta * cos_th) >> 15); }三个细节值得说清楚。第一,这里用的是等幅值变换而不是等功率变换,区别在系数 2/3 还是 √(2/3);等幅值变换下,dq 电流的幅值就等于相电流峰值,整定时更直观,代价是功率计算要多乘一次系数。第二,ia32 + 2 * ib32这一步必须先升到 int32 再乘 18919,如果直接在 int16 里算,电流稍大就会溢出,表现出来的是大电流时 q 轴读数突然反号,电机瞬间反转。第三,sin_th/cos_th来自查表,表长一般是 1024 或 4096 点,表长太短会在低速时表现为角度量化噪声,太长则占 Flash。
角度来源需要单独说一下。带编码器的方案,角度是机械角乘极对数再取模,简单可靠;用霍尔的话,60 度一个扇区,扇区内靠插值补出来;用无感观测器的话,角度来自反电动势的积分或滑模面。三种来源在同一份代码里通常通过一个函数指针切换,状态机负责决定什么时候用哪一种——启动阶段必然是开环角度,切闭环的那一刻才换成观测器角度。
3.3 逆变换与 SVPWM:中点平移比扇区判断更好写
逆 Park 回到 αβ 之后,常见的写法是判断扇区、算相邻矢量的作用时间、再拼七段式。这套流程教科书上是标准答案,但代码里要处理六个扇区的边界,还有过调制的情况,写起来啰嗦还容易出错。
我一般用中点平移法,数学上等价,代码量少一半:
/* svpwm.c —— 逆 Park + 逆 Clarke + 中点平移,直接得三相占空比 */ #define SQRT3_BY_2_Q15 28378 /* √3/2 ≈ 0.86603 */ void svpwm_calc(int16_t ud, int16_t uq, int16_t sin_th, int16_t cos_th, uint16_t pwm_period, svpwm_out_t *out) { /* 1. 逆 Park:把 dq 上的电压指令转回静止坐标系 */ int32_t u_alpha = ((int32_t)ud * cos_th - (int32_t)uq * sin_th) >> 15; int32_t u_beta = ((int32_t)ud * sin_th + (int32_t)uq * cos_th) >> 15; /* 2. 逆 Clarke:得到三相电压指令的相对值 */ int32_t half_neg = -u_alpha / 2; int32_t sq_beta = ((int32_t)u_beta * SQRT3_BY_2_Q15) >> 15; int32_t ua = u_alpha; int32_t ub = half_neg + sq_beta; int32_t uc = half_neg - sq_beta; /* 3. 中点平移:把 [min, max] 整体挪到以 0 为中心,等效注入零序分量 */ int32_t umax = MAX3(ua, ub, uc); int32_t umin = MIN3(ua, ub, uc); int32_t uofs = -(umax + umin) / 2; /* 4. 归一化到定时器计数范围,中点为 50% 占空 */ int32_t scale = pwm_period; out->ta = (uint16_t)(((ua + uofs) * scale) / VDC_BASE + pwm_period / 2); out->tb = (uint16_t)(((ub + uofs) * scale) / VDC_BASE + pwm_period / 2); out->tc = (uint16_t)(((uc + uofs) * scale) / VDC_BASE + pwm_period / 2); }逻辑说明:逆 Park 得到的是 αβ 平面上的电压矢量,逆 Clarke 把它分解到三相;此时三个值里必然有一个最大一个最小,取它们的平均值做偏移,整体平移之后三相的中心就落在 50% 占空比上,母线电压的利用率自动达到线性调制区的上限,不需要单独判断扇区。
参数说明:VDC_BASE是母线电压的基准值,与 dq 电压指令同一量纲,改母线电压采样后这个值要跟着改,不然占空比整体偏大或偏小;pwm_period是 ARR 值,决定了占空比的分辨率,10kHz 以下可以给到 3000 以上,20kHz 时一般只剩 1500 左右。
提示:中点平移法在过调制区(电压指令超出六边形内切圆)会自然削顶,表现为波形顶部变平而不是代码崩溃。如果应用场景需要进入过调制,得另外加一套幅值限幅,把 dq 电压矢量长度钳在内切圆半径上。
死区补偿放在这一层做。死区导致实际占空比与指令之间存在偏差,偏差方向由相电流方向决定。常见做法是根据电流符号给每相加一个固定补偿量:
/* 死区补偿:td 为死区时间,ts 为 PWM 周期,方向由相电流符号决定 */ int32_t comp = (int32_t)(DEADTIME_NS * pwm_period / (PWM_PERIOD_NS)); if (ia > CURRENT_DEADZONE) out->ta += comp; else if (ia < -CURRENT_DEADZONE) out->ta -= comp;CURRENT_DEADZONE这个门限不能省。电流过零附近符号会来回跳,没有门限的话补偿量跟着抖,听到的就是低频啸叫。门限一般取额定电流的 3%~5%。
3.4 电流环与速度环:两个环的脾气完全不同
电流环和速度环用的是同一份 PID 代码,但整定思路差得远。
/* pid_regulator.c —— 位置式 + 积分钳位,不用微分项 */ typedef struct { int32_t kp; /* Q10 定标 */ int32_t ki; /* 已把采样周期 Ts 打包进去 */ int32_t i_acc; /* 积分累加器 */ int32_t out_max; int32_t out_min; int32_t i_max; /* 积分饱和上限,一般取 out_max 的 0.8 */ } pid_t; int32_t pid_run(pid_t *p, int32_t ref, int32_t fdb) { int32_t err = ref - fdb; int32_t p_term = (p->kp * err) >> 10; p->i_acc += (p->ki * err) >> 10; if (p->i_acc > p->i_max) p->i_acc = p->i_max; if (p->i_acc < -p->i_max) p->i_acc = -p->i_max; int32_t out = p_term + p->i_acc; if (out > p->out_max) out = p->out_max; if (out < p->out_min) out = p->out_min; return out; }逻辑说明:先算比例项,再累加积分项,积分项单独钳位之后再和比例项相加、整体再钳一次。这两次钳位是抗积分饱和的核心——只钳输出不钳积分,误差长时间存在时积分器会一路涨到天上,等到反馈追上来了还要花很久才退饱和,表现出来就是超调后长时间振荡。
参数说明:ki里已经把采样周期打包进去了,所以电流环和速度环虽然调用同一个函数,ki的数量级完全不同,不能直接照搬。kp用 Q10 定标是为了避免浮点,整定时按「实际增益 = kp / 1024」换算。
电流环的带宽一般做到 1~2kHz,是速度环的 5~10 倍,两个环才不会互相打架。电流环不加微分项,因为电流采样本身噪声就大,微分会把噪声放大成占空比抖动。速度环可以考虑加微分,但在有减速箱或者弹性负载的场合,微分很容易激发机械共振,多数工程里干脆不加,靠降低带宽换稳定性。
整定的顺序是固定的:先只跑电流环,把 d 轴给定一个固定值、观察实际 d 轴电流的阶跃响应,调到 5% 超调、上升时间在几百微秒;再合上速度环,从小增益往上加,直到阶跃响应出现轻微超调再回退一点。反过来的顺序一定调不出来。
4. 跑起来:从编译烧录到电机第一次闭环
代码读完就得动手了。这一节按实际操作顺序走一遍,从工程配置到电机真正闭环转起来,中间有几个地方特别容易卡住。
4.1 编译配置与上电前的硬件检查
工程一般是 CMake 或者芯片厂商的 IDE 工程,编译选项里有几个必须确认的点:
# 关键编译选项(以 GCC 为例) -mcpu=cortex-m4 -mfpu=fpv4-sp-d16 # 有 FPU 的型号必须开,否则浮点全部走软实现 -mfloat-abi=hard -O2 # FOC 主中断对时间敏感,-O0 会让中断耗时翻倍 -Wall -Wextra-O0调试版本跑 FOC 是经典翻车点:单精度乘法在 -O0 下不开内联,一个 Park 变换能跑出几十微秒,主中断直接超时。调试时如果非要用 -O0,就把 PWM 频率降到 5kHz 先验证逻辑,逻辑通了再切 -O2 提频率。
上电之前先用万用表做三件事:量三相之间的电阻是否大致相等(不等说明绕组或接线有问题)、量母线到地的电阻(几十欧以下说明有短路)、量采样电阻两端对地的电压是否在 VREF/2 附近(偏离太多说明运放偏置没配对)。这三步花不了两分钟,能挡掉大部分「一上电就冒烟」的情况。
注意:第一次上电务必用限流电源,限流值设在额定电流的 20% 左右。真出问题时电源会限流而不是炸管,代价小得多。
4.2 启动流程:预定位、开环强拖、切闭环
无感方案没有编码器,上电时不知道转子在哪。这时候直接给 q 轴电流没有任何意义,因为 dq 轴本身就建立在一个未知的角度上。所以启动必然分三段:
/* mc_interface.c —— 启动状态机,三段式 */ typedef enum { MC_IDLE = 0, MC_ALIGN, /* 预定位:d 轴给固定电流,把转子拉到已知位置 */ MC_OPENLOOP, /* 开环强拖:电流闭环 + 角度按斜坡递增 */ MC_CLOSEDLOOP, /* 观测器收敛,切速度闭环 */ MC_FAULT } mc_state_t; void mc_state_machine(void) { switch (mc_ctx.state) { case MC_ALIGN: mc_ctx.id_ref = ALIGN_CURRENT; /* 例如额定电流的 30% */ mc_ctx.iq_ref = 0; mc_ctx.theta = 0; /* 角度固定,转子被拉到 d 轴 */ if (++mc_ctx.align_cnt > ALIGN_TICKS) { /* 一般 200~500ms */ mc_ctx.state = MC_OPENLOOP; mc_ctx.theta = 0; mc_ctx.speed_ramp = START_SPEED; } break; case MC_OPENLOOP: mc_ctx.id_ref = 0; mc_ctx.iq_ref = START_CURRENT; mc_ctx.theta += mc_ctx.speed_ramp; /* 角度自己往前推 */ mc_ctx.speed_ramp += RAMP_STEP; /* 缓慢加速 */ /* 切换条件:转速够高 + 观测器角度误差够小 */ if (mc_ctx.speed_ramp > SWITCH_SPEED && observer_angle_err() < ANGLE_ERR_MAX) { mc_ctx.state = MC_CLOSEDLOOP; } break; case MC_CLOSEDLOOP: mc_ctx.theta = observer_get_angle(); /* 角度交给观测器 */ mc_ctx.iq_ref = pid_run(&pid_spd, mc_ctx.speed_ref, speed_fdbk_get()); break; } }逻辑说明:预定位阶段把 d 轴电流给定一个不算小的值,转子会被磁力拉到 d 轴对齐的位置,这一步不转,只是「定住」;开环阶段角度自己递增,电机被拖着转,转速靠RAMP_STEP缓慢加;切闭环的条件必须同时满足转速阈值和角度误差阈值,只满足一个就切,轻则抖一下,重则直接失步。
参数说明:ALIGN_CURRENT太小拉不动负载,太大会让电机发热甚至触发过流,取额定电流的 30% 是比较稳的起点;ALIGN_TICKS按 1ms 一次算,200~500 次就是 200~500ms;SWITCH_SPEED一般取额定转速的 5%~10%,太低反电动势太小,观测器根本算不准角度。
4.3 上位机联调:看波形比看代码快十倍
电机能转之后,剩下的工作基本就是调参和排故,这时候有没有波形工具差别巨大。工程里的协议一般是简单帧格式:
/* protocol.c —— 0xAA 0x55 + len + cmd + payload + crc16 */ #define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 typedef enum { CMD_GET_WAVE = 0x01, /* 上位机要一帧波形 */ CMD_SET_PARAM = 0x02, /* 改 PID 参数、限流值 */ CMD_SAVE_PARAM = 0x03, /* 存 Flash */ CMD_FAULT_READ = 0x04, /* 读故障码 */ } proto_cmd_t; void protocol_parse(const uint8_t *buf, uint16_t len) { if (buf[0] != FRAME_HEAD1 || buf[1] != FRAME_HEAD2) return; uint8_t cmd = buf[3]; uint16_t payload = len - 6; /* 去掉帧头、长度、命令、CRC */ uint16_t crc = (buf[len - 2] << 8) | buf[len - 1]; if (crc16(buf, len - 2) != crc) return; /* CRC 不过直接丢,不重传 */ switch (cmd) { case CMD_SET_PARAM: param_apply(buf + 4, payload); break; case CMD_SAVE_PARAM: param_save_to_flash(); break; case CMD_GET_WAVE: wave_pack_and_send(); break; } }波形帧里装的是 id、iq、速度给定、速度反馈、母线电压、故障码这几个通道。采样率不用高,1kHz 就够看清电流环的阶跃响应,打包发走占的带宽也不多。
参数保存这一块要单独说。单区写 Flash 是所有新手都会踩的坑:写到一半掉电,整片参数就废了,下次上电读到一堆 0xFF,PID 全零,电机要么不动要么满速。常见的做法是双区轮换 + 每次写之前先擦另一区:
/* param_store.c —— 双区轮换,带 magic 和 CRC */ #define PARAM_MAGIC 0x5A5A1234u #define PARAM_ADDR_A 0x0801F800u #define PARAM_ADDR_B 0x0801FC00u typedef struct { uint32_t magic; uint16_t kp_cur, ki_cur; uint16_t kp_spd, ki_spd; int16_t align_offset; /* 编码器零位与电角度的偏差 */ uint16_t deadtime_ns; uint16_t crc; } motor_param_t; void param_save_to_flash(void) { motor_param_t p = mc_ctx.param; p.magic = PARAM_MAGIC; p.crc = crc16((uint8_t *)&p, sizeof(p) - 2); /* 写到「当前没在用」的那一区,写完再改指针 */ uint32_t target = (mc_ctx.active_bank == 0) ? PARAM_ADDR_B : PARAM_ADDR_A; flash_erase_page(target); flash_write(target, (uint8_t *)&p, sizeof(p)); mc_ctx.active_bank ^= 1; }上电时两区都读一遍,谁的 magic 对、CRC 对,就用谁;两个都坏了才回落到默认参数。这套写法的成本只是多占一页 Flash,换来的是掉电不会再丢参数,算是性价比很高的一笔投入。
5. 避坑与排查:电机原地抖、管子发烫、上电就炸的那些事
FOC 调试里有一批问题反复出现,表现形式相似但根因完全不同。下面这几条都是实际踩过的,按「现象 → 原因 → 解决」记下来,比翻手册快。
5.1 采样与角度类问题
现象一:电机不转,只在原地高频抖动,手推一下能转但很快又停。原因八成是电角度方向反了。编码器计数方向与三相相序方向不一致时,Park 变换用的是一个「镜像」的角度,转子受到的力矩方向随机,表现为原地抖。解决办法很简单:把三相线中任意两根对调,或者把编码器计数方向取反,两者只改一个。判断方向对不对有个笨办法——开环强拖阶段给一个固定的正转角度斜坡,看电机是不是真的朝一个方向转,转反了就先改这里,别急着调 PID。
现象二:空载电流正常,一加负载 q 轴电流突然反号,电机瞬间反转一下再恢复。原因是 Clarke 变换的中间量在 int16 里溢出了。ia + 2*ib这一项在相电流超过量程三分之一时就可能越界,越界后符号翻转。解决是把中间量升到 int32 再乘系数,也就是前面代码里写的(ia32 + 2 * ib32) * ONE_BY_SQRT3_Q15,而不是(int16_t)(ia + 2*ib) * ...。这类问题在空载时完全看不出来,一上负载就发作,属于典型的血泪经验。
现象三:低速时转速波动明显,高速反而很稳。角度量化精度不够。查表法如果表长只有 256 点,低速时一个 PWM 周期内角度增量很小,量化误差占比就大。解决办法是把表长加到 1024 或 4096,或者低速段改用线性插值。同时检查编码器溢出的处理——16 位计数器在低速长时间运行时也会溢出,没做回绕处理的,跨零点那一下转速会算出一个巨大的负值。
5.2 功率级与保护类问题
现象四:空载运行正常,带载几分钟后 MOSFET 明显发烫,效率比预期低。先查死区。死区设得过大,有效占空比被压缩,同样的电流需要更大的电压指令,损耗就上去了。用示波器看下桥臂驱动波形和相电流的过零点,能明显看到过零附近的波形畸变。另一种可能是死区补偿没做或者补偿方向反了,表现是相电流波形在过零处出现台阶。补偿方向的判断依据是电流符号,符号取反会导致补偿变成「加倍的误差」。
现象五:偶尔炸管,炸的都是同一桥臂。检查刹车输入是否真的接到了 TIM1 的 BKIN 引脚并且使能。很多工程的过流保护只做了软件比较,在中断里判断电流超限再关 PWM,这条路径要走完 ADC 中断、比较、写寄存器,最快也要几微秒,短路电流早就上去了。硬件保护必须独立:采样信号进比较器,比较器输出直连 BKIN,一旦触发硬件自动把三相关到安全状态,CPU 只负责事后记录故障码。
现象六:上电瞬间电机猛地一转再停。上电时 GPIO 状态未初始化,驱动芯片的输入悬空,可能被拉高导致某一桥上管误导通。解决办法是在初始化最开始就把所有 PWM 引脚配成推挽输出并输出低电平,或者给驱动芯片加上拉/下拉电阻,让它在 MCU 未接管之前保持关断状态。这个顺序不能颠倒,先配时基后配引脚是常见的错误写法。
5.3 启动与切换类问题
现象七:开环能转,一切闭环就失步,切回去又能转。切换条件太宽松。只判断转速够不够,没判断观测器角度是否收敛。观测器在刚启动时角度误差可能有一百多度,这时候切过去等于给了一个错误的角度,转矩方向随机。解决办法是加角度误差判据,同时把切换过程做成渐进式——不是一下子把角度源切过去,而是在若干个周期内做加权过渡。
现象八:带重载启动时,预定位阶段就触发过流保护。ALIGN_CURRENT给太大,或者定位时间太长导致绕组发热、电阻上升。重载场合要降低定位电流、延长定位时间,或者改用「两次定位」——先小电流定位一次确定大概方向,再用稍大的电流精定位。另外定位期间应该是电流闭环,不是直接给占空比,开环给占空比在母线电压波动时电流完全不可控。
5.4 通信与参数固化类问题
现象九:接上上位机之后电机就开始抖,拔掉就正常。通信处理放在了高频中断里,或者通信中断的优先级高于 FOC 主中断。串口每收到一个字节就进一次中断,如果优先级比 FOC 高,主中断被推迟,占空比更新不及时,表现在电机上就是抖动。解决办法是把通信全部挪到主循环轮询,中断只往环形缓冲里塞字节,优先级设到最低。
现象十:调好的参数断电再上电就没了,或者读到一堆乱值。单区写 Flash 的典型后果。另外还有一种情况是魔术字校验通过但 CRC 没校验,参数区被擦了一半,读出来全是 0xFF,PID 增益变成 65535,上电直接满速。双区 + magic + CRC 三件套缺一不可,读的时候三个都过了才认。
6. 二次开发:把一套能转的 FOC 改成能交付的电控
能把电机转起来只是起点,真正的活是从「实验室能转」到「产品能交付」这一段。这套代码的分层结构在这里体现出价值了,改哪一块基本不用动其他部分。
几个常见的改造方向,投入和风险差别很大:
| 改造方向 | 主要改动位置 | 工作量 | 风险点 |
|---|---|---|---|
| 三电阻改单电阻采样 | BSP + 电流重构 | 中 | 最小脉宽限制,低速时采样窗口不够 |
| 有感改无感(SMO) | Algorithm/observer_smo.c | 中高 | 零速和极低速无法观测,必须靠开环兜底 |
| 增加 CAN 通信 | App/protocol.c | 低 | 帧率与主循环周期的匹配 |
| 参数自整定 | Algorithm + App | 高 | 整定过程中的过流保护 |
| 故障录波 | App + Flash | 低 | 录波写入不能阻塞主循环 |
| 产线标定(编码器零位) | Motor + App | 中 | 标定结果必须双备份 |
单电阻采样是最值得改的一项,成本能省两个采样电阻和两路运放,但代价是最小脉宽受限——占空比太窄时采样窗口被压缩到不足以完成转换,低速轻载时尤其明显。改的时候要重写电流重构,用母线电流在特定矢量下的值反推三相电流,这部分逻辑和三相采样完全不同,别指望改几个宏就完事。
无感改造的关键不在观测器本身,而在启动策略。滑模观测器在中高速很稳,零速和极低速反电动势太小,信噪比不够,根本无法收敛。所以必然要保留开环强拖那一段,并且切换逻辑要比有感方案更保守。我一般会把切换点设在额定转速的 10% 以上,宁可开环段长一点,也不要切早了失步。
验证方法上,别只靠看电机转不转。有效的做法是录一段完整工况的数据:从启动、加速、稳态、加载、卸载到停机,把 id、iq、速度给定、速度反馈、母线电压五个通道全录下来。然后逐段看——启动段看 iq 有没有冲击,稳态段看 id 是不是在零附近小幅波动(偏离零说明角度有偏差),加载段看恢复时间,停机段看有没有能量回灌导致母线电压抬升。这五个通道对着看,绝大部分问题都能定位到具体是哪一环。
有个习惯我一直保持着:每改完一处参数或者一段算法,先不急着上整机,而是用之前录下来的那段波形在 PC 上回放一遍。算法层不依赖硬件,把 .c 文件拷出去写个小 main 就能跑,输入是录好的 ia/ib 和角度,输出是三相占空比,和当时录的实际占空比对一下,差异一目了然。这个流程拦住过好几次「改完看着能转、实际带载就崩」的情况。
从那以后我每次动 FOC 的核心链路,哪怕只改了一行系数,都强制走一遍「录波回放 + 限流上电 + 分段提速」。多花二十分钟,比炸一对管子划算太多。希望帮到你。
本文还有配套的精品资源,点击获取