☰
STM32+HAL移植DengFOC闭环位置控制实战:编码器、PWM与级联PID整定
2026/10/21 3:57:45 网站建设 项目流程

简介:这是一套基于STM32与HAL库的DengFOC闭环位置控制移植工程,面向嵌入式运动控制初学者、物联网及机器人项目开发者,也可作为高校电机控制课程设计的参考资料。资源围绕无刷电机位置闭环控制的核心步骤展开,解决DengFOC算法在HAL库环境下无法直接运行、需要自行适配板卡的问题,内容覆盖定时器PWM输出、编码器或I2C位置反馈读取、位置环参数设置以及中断与通信模块的接法。压缩包内共包含183个文件,总大小约1.4MB,以C源文件和H头文件为主,辅以uvprojx、uvoptx、ioc等工程与初始化配置文件,还有axf、hex、map、o、d等编译过程产生的中间文件,使用者可直接在Keil中打开工程进行重新编译或烧录测试。目前该资源已有583人学习浏览,说明它在同类移植资料中有一定热度。通过这套工程,读者能够拿到一套完整的HAL库闭环位置控制例程,理解DengFOC从算法到硬件映射的每一步,同时可根据文件结构与构建产物快速定位移植中出现的链接错误、引脚冲突等问题,为后续开发速度环、力矩控制或多电机协同提供可扩展的基础。

1. 在STM32+HAL上移植DengFOC做闭环位置控制:先把跑通再谈精度

把 DengFOC 从 Arduino 环境挪到 STM32+HAL 库上,最直接的问题不是电机不转,而是你根本不知道它在哪个环节断的。我前后调了两周才跑通闭环位置控制:给一个目标角度,电机转过去并保持住。这套方案的真正价值在于,你不用从零写 SVPWM 和电流采样,DengFOC 把 FOC 核心算法封装成了黑匣子,你要做的是用 HAL 库把它的底层接口喂饱。适合手头有 STM32 板子、想低成本验证 FOC 位置控制,又不打算在数学推导上耗太多时间的开发者。下面从硬件准备到整定踩坑,按我实际走过的流程讲。

2. 移植前的准备:硬件选型、工程骨架与底层接口对齐

2.1 硬件选型:编码器线数、驱动板和电源顺序决定后面好不好调

DengFOC 本身不挑硬件,但闭环位置控制对编码器和驱动板的依赖比开环大得多。我用的是某公司产的 42 步进电机改装的伺服方案,额定电压 24V,编码器是 1024 线正交增量式,4 倍频后每圈 4096 个计数。这个分辨率做位置控制够用,再低的话位置环会有可见抖动。

电源顺序是第一个坑:先给控制板供电并完成编码器初始化,再给驱动板母线电。如果反过来,电机上电瞬间会因为你没有清零编码器计数而误判当前角度,导致位置环以错误零点自锁,轻则异响,重则顶到限位。我一般用一个船型开关控制母线电源,代码里加一个等待母线电压稳定的延时。

驱动板方面,3.3V 逻辑电平的板子最省事,可以直接用 STM32 的定时器互补输出驱动。如果你的驱动板是 5V 逻辑电平,串个电阻或做电平转换,别直接怼引脚。电流采样环节,DengFOC 常见做法是采母线电流配合矢量重构,也有直接采两相低边的,取决于你的驱动板。无论哪种,ADC 采样的基准电压要和驱动板电流采样输出的偏置对齐,否则你看到的不只是零点漂移,而是电流环正反馈发散。

2.2 工程骨架:用 CubeMX 生成基础工程,再挂载 DengFOC 源码

我的做法是用 CubeMX 生成 STM32F103C8T6 的 HAL 工程,时钟配到 72MHz,定时器和 ADC 先在 CubeMX 里定义好。DengFOC 迁移过来的文件保持原目录结构,只改移植层文件,不要动核心算法文件,这样后续拉新版本方便。

工程挂载后的目录结构大致如下:

Project/ ├── Core/ │ ├── Inc/ │ └── Src/ │ └── main.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ ├── DengFOC/ │ ├── DengFOC.cpp # 核心算法,保持不变 │ ├── DengFOC.h │ └── port/ │ └── stm32_port.c # 移植层,需要自己实现 └── User/ ├── pid.c ├── encoder.c └── ramp.c

这里的关键是把 DengFOC 核心文件从编译范围内排除掉 Arduino 特有的依赖,比如Arduino.h和delay()。我一般会在移植层写一组与核心算法约定的回调函数,把millis()替换为 HAL 的HAL_GetTick(),把analogRead()替换为 ADC 转换的 HAL 函数。

注意:CubeMX 生成的定时器配置默认是 PWM 输出,如果同时要用编码器模式,同一个定时器接口上不能既接编码器又接 PWM,脚位会冲突。编码器用独立定时器的 Encoder Mode,PWM 用另一组通用定时器的互补输出。

2.3 底层接口对齐:编码器读取、PWM 输出与母线电压采样

DengFOC 核心算法最后要的是三个东西:当前电气角度、两相电压或 PWM 占空比、母线电压。在移植层我把它们封装成了三个函数。编码器部分用 STM32 的硬件正交编码器,软件里只读计数器值:

/* encoder.c —— 基于 STM32 硬件编码器接口 */ #include "encoder.h" static TIM_HandleTypeDef *htim_enc; void encoder_init(TIM_HandleTypeDef *htim) { htim_enc = htim; __HAL_TIM_SET_COUNTER(htim_enc, 0); /* 清零计数 */ HAL_TIM_Encoder_Start(htim_enc, TIM_CHANNEL_ALL); } int32_t encoder_get_raw_count(void) { return (int32_t)__HAL_TIM_GET_COUNTER(htim_enc); } float encoder_get_angle_deg(void) { int32_t raw = encoder_get_raw_count(); return (float)raw / 4096.0f * 360.0f; /* 1024线*4倍频=4096 */ }

这里4096是每圈计数,如果你的电机是 360 线或者 2048 线,把倍频后的总数替换掉即可。读取计数时用__HAL_TIM_GET_COUNTER比HAL_TIM_GetCounter少一层封装,在 20kHz 中断里能省下几十个周期。

PWM 输出层我用定时器三互补通道,直接在stm32_port.c里写占空比:

/* stm32_port.c —— DengFOC 底层接口适配 */ void port_set_phase_voltage(float u_alpha, float u_beta, float bus_voltage) { /* 内部由 DengFOC 核心算法计算三相占空比 */ uint32_t cc1 = pwm_deadzone_ccr(calc_duty_a(u_alpha, u_beta, bus_voltage)); uint32_t cc2 = pwm_deadzone_ccr(calc_duty_b(u_alpha, u_beta, bus_voltage)); uint32_t cc3 = pwm_deadzone_ccr(calc_duty_c(u_alpha, u_beta, bus_voltage)); __HAL_TIM_SET_COMPARE(&htim_pwm, TIM_CHANNEL_1, cc1); __HAL_TIM_SET_COMPARE(&htim_pwm, TIM_CHANNEL_2, cc2); __HAL_TIM_SET_COMPARE(&htim_pwm, TIM_CHANNEL_3, cc3); }

calc_duty_a/b/c是 DengFOC 核心算法里算好的 SVPWM 结果,我不在移植层重写。这里要确认的是定时器周期和 PWM 频率。我配置为 20kHz,中心对齐模式。频率太低会听见明显的绕组啸叫,太高则功率管开关损耗上涨,STM32F103 在 72MHz 主频下 20kHz 是性价比比较高的点。

母线电压采样用 ADC 多通道扫描:

float port_get_bus_voltage(void) { uint32_t adc_raw = adc_read_once(); return (float)adc_raw * 3.3f / 4095.0f * bus_voltage_divider; }

bus_voltage_divider要按你的分压电阻实际计算,不能照抄。我吃过这个亏:分压电阻标称值算出来是 11.2,实际焊上去测只有 10.6,结果电压环输出一直偏高,电机到达目标位置后还在小幅蠕动。后来用万用表实测校准了这个系数才算消停。

3. 闭环位置控制的逐级联调:让电流环、速度环、位置环按顺序工作

3.1 先让电机转起来:开环旋转方向与零点标定

不先开环验证就上位置环,是新手最容易翻车的地方。位置环一旦方向反了,就是正反馈,电机会朝目标角度相反方向加速,直到撞限位。我一般先把位置环整个禁用,给固定电压矢量让电机开环旋转。

/* 开环测试:给定一个固定电角度,让电机匀速旋转 */ void openloop_test(float speed_deg_per_s, float bus_voltage) { static float angle = 0.0f; angle += speed_deg_per_s * 0.001f; /* 中断周期 1ms */ if (angle > 360.0f) angle -= 360.0f; float u_alpha = 3.0f * cosf(angle * M_PI / 180.0f); float u_beta = 3.0f * sinf(angle * M_PI / 180.0f); port_set_phase_voltage(u_alpha, u_beta, bus_voltage); }

这一步要确认三件事:电机是否按预期方向旋转、旋转速度是否平稳、电机电流有没有异常大。如果电机不转只抖,先看电角度频率和机械转速是否匹配,也就是极对数设置。DengFOC 里一般有极对数参数,我这个电机是 4 对极,设置成 2 时扭矩方向来回跳,开环就转不起来。

开环旋转同时还要做零点标定。增量式编码器没有绝对零点,上电后计数从 0 开始,这个 0 对应的机械位置是随机的。位置控制里我通常让电机以一个固定方向慢慢旋转,检测编码器 Z 相脉冲回零,回零后把当前计数设为 0。如果没有 Z 相,就手动把电机转到一个物理限位处标记零点,但这样断电重启就要重新标定。

3.2 级联 PID 参数怎么给:电流环、速度环、位置环的先后顺序

DengFOC 的原生框架偏电流环的直接控制。做位置控制时,我不建议直接把位置环输出接到电压上,那样负载扰动一上来位置就会偏。更稳的结构是位置环输出作为速度环给定,速度环输出作为电流环给定,级联起来。

调试顺序也按链路反着来:先只跑电流环,给定一个小电流看响应;再上速度环;最后套位置环。

/* pid.c —— 支持输出限幅与积分分离的 PID */ typedef struct { float kp, ki, kd; float integral; float prev_error; float out_limit; float integral_limit; } pid_controller_t; float pid_update(pid_controller_t *pid, float setpoint, float feedback, float dt) { float err = setpoint - feedback; /* 积分分离:误差过大时暂停累计,防止积分饱和 */ if (fabsf(err) < pid->integral_limit) { pid->integral += err * dt; pid->integral = clamp(pid->integral, -pid->out_limit, pid->out_limit); } float derivative = (err - pid->prev_error) / dt; pid->prev_error = err; float out = pid->kp * err + pid->ki * pid->integral + pid->kd * derivative; return clamp(out, -pid->out_limit, pid->out_limit); }

这个 PID 有三个关键参数要调:

  • out_limit:位置环输出限幅对应最大速度,速度环输出限幅对应最大电流。开始先把限幅放小,比如位置环输出限幅只给额定速度的三分之一,防止误整定时飞车。
  • integral_limit:误差异常时强制积分不累计。这个值设成稳态允许误差的 2 倍基本合理。我见过一些人直接把积分限幅设成输出限幅,结果每次启停都过冲,不是 PID 参数不好,是积分项把瞬态误差都存进去了。
  • derivative项:编码器反馈差分对噪声很敏感,我一般先设 0,等 P 和 I 调稳后再一点点加,加了还抖就换低通滤波。

参数初值我是这样给的:电流环 Kp 取一个能让电流跟随给定而不发散的起点,Ki 很小;速度环 Kp 先调,直到电机能稳定匀速旋转;位置环 Kp 从 1 开始,逐步加。每次只动一个参数,改了之后至少跑一遍正反转才能判断。

3.3 位置给定与斜坡规划:避免阶跃给定直接砸向位置环

位置环调试时最容易踩的就是直接给阶跃目标角度。电机像被踢了一脚,要么过冲来回震荡,要么触发限幅保护。原因是位置环面对一个瞬间跳变几百个编码器计数的误差,PID 输出直接饱和,电机全速冲过去再刹停,无论后面参数怎么调都会过冲。

我通常在位置环前面加一个斜坡生成器,让目标角度按最大速度逼近真实给定:

/* ramp.c —— 一阶速度限幅斜坡 */ #define RAMP_MAX_STEP 5.0f /* 每毫秒最多允许变化 5 个编码器计数 */ #define RAMP_LOOP_DT_MS 1.0f static float ramp_target = 0.0f; float ramp_update(float goal) { float delta = goal - ramp_target; float max_step = RAMP_MAX_STEP * RAMP_LOOP_DT_MS; if (delta > max_step) delta = max_step; if (delta < -max_step) delta = -max_step; ramp_target += delta; return ramp_target; }

RAMP_MAX_STEP对应最大运动速度,5 个编码器计数每毫秒就是每秒 5000 计数,换算约每秒 1.22 圈。这个值要比位置环输出限幅对应的速度稍低,否则斜坡速度上限会先触发位置环饱和,斜坡就失去意义了。

斜坡给的其实是位置环的输入,不是速度环的输入。合理做法是位置环给定用斜坡输出,让位置误差始终在一个可控范围,位置环输出再给速度环。这样调出来的系统对阶跃目标响应平稳,对连续轨迹也友好。

4. DengFOC 移植避坑与排查:五个让我返工的实际问题

4.1 编码器计数方向反了,位置环越调越偏

现象: 电机朝目标方向转,但误差不但不减小反而增大,速度越来越快,最终触发限位或者过流保护。

原因: 位置环使用的是编码器反馈角度,如果编码器方向和电机旋转方向相反,反馈值变化方向与真实运动相反。误差放大后,控制器以为还没到位,继续加大输出,构成正反馈。

解决: 开环状态下手动旋转电机,看串口回传的编码器计数是否随正转单调递增。如果是反的,不需要换线,直接在编码器初始化里把计数方向反转。STM32 定时器编码器模式可以设置计数方向,或者把 A/B 相接反。我用的是软件取反,不改硬件:

encoder_set_direction(-1); /* 方向系数,取反反馈计数 */

注意这个取反只针对反馈,不要取反目标角度,否则逻辑混乱。

4.2 PWM 频率和死区不匹配,电机啸叫且相电流波形畸变

现象: 电机静止时发出高频啸叫,电流波形上有明显的台阶和断续,位置保持时扭矩忽大忽小。

原因: PWM 频率与驱动板最小死区时间不匹配。我最初照着 Arduino 例程的配置用 PWM 频率 10kHz,但驱动板要求死区至少 1us,定时器重装值算出来死区不够,上下桥臂直通,电流波形直接糊了。DengFOC 核心算法输出的占空比是理想值,最终脉宽和死区是移植层的事。

解决: 按驱动板手册算死区,我用的是中心对齐模式,死区寄存器值按定时器时钟算:72MHz 下死区 1us 约等于 72 个计数。同时把 PWM 频率提到 20kHz,电感纹波更小,啸叫也落到人耳不太敏感的频段。

提示:改频率后要重新检查定时器重装值和各通道占空比映射,不要只改频率不管周期。占空比千分比和比较寄存器值不匹配会导致输出失衡,电机转起来一卡一卡的。

4.3 在 FOC 控制中断里调用 HAL_Delay 导致控制时序崩溃

现象: 电机偶尔疯转一下,或者停机时位置突然跳变,用调试器看时发现中断里执行时间远超预期。

原因: 我在速度环和位置环里插了HAL_Delay(1)做周期性执行,HAL_Delay依赖 SysTick 中断,而 SysTick 优先级如果低于 FOC 的定时器中断,FOC 中断会反复抢占 SysTick 计时,延迟形同虚设。如果 FOC 中断里又有阻塞式 ADC 读取,整个控制循环时长可能从 20us 膨胀到几百 us,电流环时序完全错乱。

解决: 控制中断里统一不用阻塞延时。周期调度用一个递增的毫秒计数器判断,毫秒基准来自独立定时器或者 DWT。把 ADC 改成 DMA 转换,转换完成标志在中断里查询,而不是HAL_ADC_PollForConversion死等。

4.4 上电就自锁异响,零点没有完成标定

现象: 每次上电,还没发指令电机就嗡嗡响,转子被强行吸到某个角度,用手掰不动。

原因: 增量编码器上电读数是 0,但真实机械位置随机。位置环以 0 为反馈角度,给定也是 0,初始误差看起来是 0,但实际电机不在 0 点。当 I 项从 0 开始累计,或者转子被外力扰动一点,位置环就会朝错误方向纠偏,表现为上电自锁异响。

解决: 上电后先强制 PWM 输出为禁用状态,完成编码器 Z 相回零或手动归位后再使能闭环。我用的是回零流程:

void homing_routine(void) { HAL_GPIO_WritePin(BRAKE_GPIO_Port, BRAKE_Pin, GPIO_PIN_SET); HAL_Delay(200); /* 低速开环旋转,等待 Z 相脉冲 */ while (!encoder_get_z_flag()) { openloop_step(10.0f); } encoder_zero(); HAL_GPIO_WritePin(BRAKE_GPIO_Port, BRAKE_Pin, GPIO_PIN_RESET); }

如果没有 Z 相,就一定加一个上电使能前的延时,给操作者留出去手动对零位的时间。别省这一步,它省下的时间会在后边十倍赔回去。

4.5 位置环输出直接当电压用,带负载后位置偏移严重

现象: 空载时位置控制正常,手一捏电机轴就偏,松开后回不到原点,或者出现缓慢的漂移。

原因: 只做了位置环到电压的直连。位置环输出本质上是一个扭矩请求,但电机启动和克服负载都需要足够的电流支撑。负载扰动时位置误差变大,位置环输出变大,但因为没有速度环和电流环的限幅与滤波,扭矩请求直接被电压映射成 PWM,实际电流却没跟上,表现为刚性差、抗扰弱。

解决: 改造成位置环→速度环→电流环的级联结构。位置环输出作为速度环给定,速度环输出作为电流给定,电流环最终控制 PWM。代价是多两级整定,但换来的是带负载能力和动态响应明显改善。这个改造在 DengFOC 移植层里做并不难,核心算法不动,只是把每一级的输出接到下一级的给定上。

5. 验证与进阶:用串口曲线看阶跃响应,把位置环增益调到边界

5.1 串口回读与阶跃响应分析

位置环调好后,我习惯用串口把三个量打出来:目标位置、斜坡输出、实际反馈。用定时器每 5ms 发一帧,上位机保存成 CSV,再用绘图工具看曲线。

/* 串口回读代码段 */ void debug_position_report(void) { static uint16_t tick = 0; if ((tick++ % 5) != 0) return; printf("POS %.1f %.1f %.1f\r\n", position_goal, /* 目标位置 */ ramp_target, /* 斜坡实际输出 */ encoder_get_angle_deg() /* 编码器反馈角度 */); }

看曲线的时候我关心的指标就三个:上升时间、超调量、稳态误差。阶跃给定下,超调超过 5% 就说明位置环 Kp 偏大或速度环限幅太低;稳态误差不为 0 则检查积分项;如果电机到位后一直小幅摆动,多半是编码器量化误差被微分项放大了,此时优先降 Kd 而不是加滤波。

5.2 位置环增益边界与一套收尾习惯

位置环 Kp 的边界是怎么找的?我从 1.0 开始,每次加 0.5,跑一次 90 度阶跃,直到系统出现等幅振荡,记下这个值记为 Kcrit。实际使用取 Kcrit 的 40% 到 50%,然后微调 Ki 消除稳态误差。这是我反复验证过比较省时间的路径,比看公式盲算快得多。

进阶玩法是把位置环输出限幅改成按轨迹动态调整。比如点到点运动时,前 30% 路程允许更高的速度限幅,接近目标时逐步收窄,这样在不触发超调的前提下缩短了到位时间。DengFOC 核心算法不限制你在这层做手脚,控制链路是你在移植层依据该接口约定的框架实现的。

另外建议每次改参数前先记录一次整定基线。我吃过亏:连续改了五六个参数,电机开始抖动,想回退却忘了最初稳定的一组值。后来强制自己每改一轮就把三个环的 PID 参数、限幅、斜坡步长记录在代码头部的注释里,关键版本保留一份param_backup.h。从那以后我每次上电都强制走一遍回零、开环方向确认、串口曲线确认,再跑位置阶跃,整套流程固定下来,调试效率提升不少。希望帮到你。

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

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

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

立即咨询