STM32F1位置速度双闭环控制实战:从原理到产线落地
2026/9/3 2:44:36 网站建设 项目流程

简介:本资源是一套基于STM32F1系列单片机实现直流有刷电机双闭环控制的完整工程源码,面向嵌入式初学者、电机控制实践者及自动化专业学生,解决位置与速度协同精准调控难题。工程采用HAL库开发,集成PWM互补输出驱动、编码器测速与角度采集、电流采样反馈(通过采样电阻电压换算)及PID双环(外环位置+内环速度/电流)实时调节逻辑,适配带减速器的编码器电机场景。压缩包含394个文件,总计17.92MB,涵盖89个头文件(h)、55个C源文件(c)、55个编译中间文件(o)、43个汇编文件(s)及调试配置(icf)、链接脚本(sct)、固件镜像(hex/axf)等,结构完整,可直接Keil MDK编译下载运行。已有1178人学习下载,提供从底层外设初始化(TIM/ADC/UART/I2C等)、PID参数整定注释到闭环响应调试的全流程支撑,是理解运动控制算法落地与STM32 HAL实战的优质参考工程。

1. 这不是“调个PID”那么简单:一个位置速度双闭环的真实战场

你搜“PID双闭环 STM32 F1_HAL”,点开一堆标题党——“5分钟搞定”、“保姆级教程”、“手把手教你调参”。我干这行十二年,带过三十多个嵌入式项目,亲手写过七套不同场景的电机控制固件,从工厂AGV底盘到医疗康复机器人关节,再到高精度CNC主轴驱动。我可以很肯定地说:如果你只把这套代码当成“调两个PID参数”的练习题,那它在真实设备上跑起来的第一分钟,大概率会抖动、啸叫、丢步,甚至烧掉MOSFET。它真正的价值,根本不在“源代码”三个字上,而在于它用最朴素的HAL库,在资源极其有限的STM32F103(72MHz主频、20KB RAM)上,构建了一个可工程化落地的位置-速度双环耦合架构。关键词里的“位置速度闭环控制”,不是并列关系,而是严格的主从嵌套:外环是位置环,它的输出是内环(速度环)的给定;内环是速度环,它的输出才是PWM占空比。这个结构决定了,位置环的响应必须慢于速度环,否则系统会震荡;速度环的带宽又必须远高于电流环(哪怕你没显式做电流环,MOSFET开关和电机电感本身就在那里)。我见过太多人把位置环Kp设得比速度环还大,结果电机像得了帕金森——这是原理性错误,不是参数没调好。这套代码之所以值得深挖,是因为它用增量式PID算法规避了积分饱和,在HAL_TIM_Base_Start_IT()中断里完成全部计算,把采样、计算、输出严格锁在同一个定时器周期内,杜绝了时间抖动。它不炫技,不堆功能,但每一个变量命名(比如pos_setpointvel_actual)、每一行注释(比如// 位置环输出限幅,防止速度环超调)、甚至每个宏定义(#define POS_PID_OUT_MAX 5000),都透着一股“这东西明天就要装进产线”的务实劲儿。适合谁?不是刚学完《控制工程基础》的本科生,而是正在为一台新研发的自动分拣机调试伺服电机、被老板催着三天内解决定位抖动问题的工程师;是想把旧PLC控制的传送带升级成闭环定位、但手头只有几块F103C8T6开发板的技术员;是准备参加智能车竞赛、需要稳定可靠底层驱动的队长。它解决的不是“会不会”,而是“敢不敢把这套逻辑用在客户现场”。

2. 双闭环不是套娃,是精密的时序与责任分工

2.1 为什么必须是“位置环在外,速度环在内”?

这绝不是教科书上的随意排布。想象一下你开车:你想从A点(当前位置)精准停到B点(目标位置),你的大脑(位置环)首先算出“我还需要走多远”,然后它不会直接命令油门踩多深,而是告诉身体(速度环):“接下来1秒,我的目标速度应该是5km/h”。身体再根据这个速度指令,去微调油门和刹车的力度。如果反过来,让“速度环”当老大,它只管当前速度够不够快,完全不管离目标还有多远,结果就是车子冲过B点老远才开始减速,或者在B点前反复加减速——这就是典型的“超调+振荡”。在电机控制里,位置环负责宏观轨迹规划,它输出的是“应该达到的速度”,这个值必须平滑、无突变;速度环负责微观动力执行,它要快速响应位置环的指令,并克服负载扰动(比如传送带上突然掉下一个重箱子)。所以,位置环的采样周期(比如10ms)必须大于速度环的采样周期(比如1ms)。这套F1_HAL代码里,位置环在SysTick(10ms)里更新,速度环在TIMx_UP(1ms)中断里更新,时间尺度差了10倍,这是硬性约束。一旦你把两个环放在同一个中断里,或者让位置环跑得太快,系统就会失去稳定性根基。

2.2 增量式PID:为什么不用更直观的位置式?

位置式PID公式是Output = Kp*err + Ki*sum_err + Kd*(err - err_last),它直接算出最终的控制量(比如PWM值)。问题来了:如果系统突然断电重启,sum_err(积分项)会清零,但电机可能正卡在某个位置,err瞬间变得巨大,Kp*err这一项会爆出一个极高的输出,导致电机猛抽一下——这在机械臂或精密仪器上是灾难性的。增量式PID算的是“这一次比上一次多加多少”,公式是Delta_Output = Kp*(err - err_last) + Ki*err + Kd*(err - 2*err_last + err_last2),它的输出是累加的。代码里你会看到vel_output += delta_vel_out;这一行。好处是什么?抗积分饱和、抗阶跃扰动、重启安全。当电机堵转,速度误差持续为正,位置环的积分项会疯狂累积,但增量式算法里,只要err不变,delta_vel_out就为零,vel_output不再增长,自然就限幅了。而位置式算法里,Ki*sum_err会一直涨,直到你手动清零或靠硬件限幅,风险高得多。F1系列RAM小,sum_err变量多了几个,内存就吃紧,增量式省空间、省计算量,是资源受限场景下的必然选择。

2.3 HAL库的“陷阱”:为什么不用标准外设库(StdPeriph)?

现在网上很多老教程还在用StdPeriph,因为它寄存器操作直白。但HAL库是ST官方主推,生态成熟,USB、FSMC、FatFS这些高级功能全靠它。陷阱在哪?HAL_Delay()是阻塞的!如果你在TIMx_UP中断里调用它,整个系统就卡死了。这套代码里,所有延时都用DWT(Data Watchpoint and Trace)周期计数器实现,HAL_GetTick()返回的是SysTick递增的毫秒数,非阻塞。另一个坑是HAL_TIM_ReadCapturedValue()的精度。它读取编码器计数值,但F1的定时器输入捕获有1个时钟周期的延迟,如果电机转速高、编码器线数多(比如2500线),这个延迟会导致速度计算误差。代码里做了补偿:raw_count = __HAL_TIM_GET_COUNTER(&htim4);直接读计数器寄存器,再结合预分频值换算,比HAL函数快且准。最后是中断优先级。TIMx_UP(速度环)必须比SysTick(位置环)优先级高,否则1ms的定时器中断被10ms的SysTick打断,速度环就乱了。代码里HAL_NVIC_SetPriority(TIM4_IRQn, 0, 0);把TIM4设为最高优先级,这是铁律。

2.4 位置环与速度环的参数耦合:Kp_pos和Kp_vel不是独立变量

新手常犯的错误,是分别调Kp_posKp_vel,像调两个独立旋钮。实际上,它们是强耦合的。Kp_pos太大,位置环太“急”,会向速度环下达剧烈的速度变化指令,速度环跟不上,就震荡;Kp_vel太大,速度环太“猛”,对位置环的指令响应过激,也会震荡。一个经验法则:先调好速度环,让它能稳定跟踪一个阶跃速度指令(比如从0到1000rpm),此时记录下Kp_velKi_vel;再把位置环的Kp_pos从0.1开始慢慢加,观察位置响应曲线,当出现轻微超调(<5%)且无振荡时,Kp_pos就是上限。代码里#define POS_KP 0.8f这个值,是针对1000线编码器、减速比5:1、负载惯量0.001kg·m²的典型电机标定出来的。如果你换电机,必须重调。更关键的是Ki_pos(位置环积分系数),它决定系统能否消除静态定位误差。但Ki_pos不能随便加大,否则低速爬行时会积分饱和,导致电机“蠕动”。代码里用了防饱和技术:if (pos_output > POS_PID_OUT_MAX) pos_output = POS_PID_OUT_MAX; else if (pos_output < -POS_PID_OUT_MAX) pos_output = -POS_PID_OUT_MAX;这个限幅值POS_PID_OUT_MAX,必须和你的速度环最大输出范围匹配,否则限幅就失效了。

3. 源代码核心模块深度拆解:从.h到.c的每一步意图

3.1 pid.h:不只是结构体,是控制策略的宪法

打开pid.h,第一眼看到的是typedef struct { float kp; float ki; float kd; float out_max; float out_min; float integral_limit; } PID_HandleTypeDef;。注意,这里没有sum_error成员!因为增量式PID不需要存储历史误差和,它只需要err_lasterr_last2out_max/out_min是输出限幅,integral_limit是积分分离阈值——当误差绝对值大于这个值时,关闭积分项,防止启动时积分项狂飙。这是高级技巧,很多开源代码里根本没有。再往下看#define POS_PID_OUT_MAX 5000#define VEL_PID_OUT_MAX 32000,这两个宏定义暴露了硬件真相:VEL_PID_OUT_MAX是32000,对应16位PWM的满幅值65535的一半左右,留出了裕量;POS_PID_OUT_MAX是5000,意味着位置环的最大输出,只允许速度环以中等强度运行,这是为了抑制超调。extern PID_HandleTypeDef pos_pid; extern PID_HandleTypeDef vel_pid;这两行声明,让PID结构体成为全局可见的“控制中枢”,所有模块都能访问和修改它,但也带来了风险——必须保证多任务环境下对它的访问是原子的,代码里用__disable_irq()临时关中断来保护,这是裸机开发的常识。

3.2 main.c:系统初始化的隐藏逻辑链

main()函数里,HAL_Init();之后是SystemClock_Config();,这里把SYSCLK配成72MHz,APB1(TIM2/3/4)是36MHz。关键在MX_GPIO_Init(); MX_TIM2_Init(); MX_TIM3_Init(); MX_TIM4_Init();。TIM2通常配成编码器接口(Encoder Mode),读取AB相脉冲;TIM3配成PWM输出(Channel 1/2),驱动H桥;TIM4配成更新中断(UP IRQ),周期1ms,是速度环的节拍器。HAL_TIM_Encoder_Start(&htim2, TIM_CHANNEL_ALL);启动编码器,但注意,F1的编码器模式有缺陷:当电机反转时,计数器会溢出归零,导致位置跳变。代码里encoder_cnt = __HAL_TIM_GET_COUNTER(&htim2);读原始值后,做了方向判断和溢出校正:if (direction == DIR_FORWARD) pos_cnt += encoder_cnt - last_cnt; else pos_cnt -= encoder_cnt - last_cnt;这个细节,决定了你的定位精度是±1个脉冲,还是±100个脉冲。HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);启动PWM,但__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0);这行把占空比设为0,电机静止。所有初始化必须在开启中断前完成,否则未初始化的外设在中断里被访问,会触发HardFault。

3.3 pid.c:增量式算法的实操血肉

void PID_Incremental_Calc(PID_HandleTypeDef *pid, float error)是核心函数。第一步:float delta_error = error - pid->err_last;计算误差变化量。第二步:float delta_out = pid->kp * delta_error + pid->ki * error + pid->kd * (delta_error - (pid->err_last - pid->err_last2));这里pid->kd * (delta_error - (pid->err_last - pid->err_last2))就是二阶微分项,(pid->err_last - pid->err_last2)是上一周期的delta_error,所以整个kd项是kd*(当前delta_err - 上次delta_err),即微分作用于误差变化率,而非误差本身,抗噪性更好。第三步:pid->output += delta_out;累加。第四步:if (pid->output > pid->out_max) pid->output = pid->out_max; else if (pid->output < pid->out_min) pid->output = pid->out_min;限幅。最后:pid->err_last2 = pid->err_last; pid->err_last = error;更新历史值。这个函数被调用两次:在TIM4中断里调用PID_Incremental_Calc(&vel_pid, vel_error);,在SysTick中断里调用PID_Incremental_Calc(&pos_pid, pos_error);。注意,vel_errorpos_pid.output - vel_actual,即位置环输出(期望速度)减去实际速度;pos_errorpos_setpoint - pos_actual。这种嵌套调用,就是双闭环的灵魂。

3.4 tim.c:中断服务程序里的生死时速

void TIM4_IRQHandler(void)是速度环的心脏。进入中断第一件事:HAL_TIM_IRQHandler(&htim4);清除中断标志。然后立刻读取编码器值:raw_cnt = __HAL_TIM_GET_COUNTER(&htim2);。接着计算实际速度:vel_actual = (raw_cnt - last_raw_cnt) * 1000.0f / (TIM4_PERIOD_US * 4.0f);这里TIM4_PERIOD_US是1000(1ms),*1000.0f是换算成rpm,/4.0f是因为AB相编码器每周期4个边沿。last_raw_cnt必须是volatile变量,且读写要加临界区保护。计算完vel_actual,立刻调用PID_Incremental_Calc(&vel_pid, vel_error);,得到vel_output。最后__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, (uint32_t)vel_output);设置PWM。整个过程必须在1ms内完成,F1的72MHz主频,这段代码大约消耗300个时钟周期,绰绰有余。但如果在里面加了个printf调试,耗时几百微秒,系统就崩了。所以所有调试信息都通过串口DMA异步发送,绝不阻塞中断。

4. 实操全流程:从烧录到稳态运行的七步法

4.1 硬件准备:三根线决定成败

别急着编译代码,先检查硬件。核心三根线:编码器A/B相、电机UVW相、电源地。编码器A/B相必须接到TIM2的CH1/CH2(PA0/PA1),这是F1硬件编码器模式的固定引脚,接错就收不到脉冲。电机UVW相接到H桥驱动芯片(如L298N、TB6612FNG)的输入端,H桥输出再接到电机。最关键的,是共地!STM32的GND、H桥的GND、编码器的GND、电源的GND,必须拧在一起,用粗铜线短接。我见过太多案例,电机一转就干扰单片机复位,查了一周,就因为编码器的地线没和MCU共地,形成了地环路噪声。电源选型:电机额定电压×1.2倍,电流≥电机堵转电流的1.5倍。比如电机12V/2A,电源至少15V/3A。别用手机充电器,纹波太大,会干扰编码器信号。

4.2 软件环境:Keil MDK的魔鬼配置

用Keil uVision5,版本5.36以上。Options for Target -> DeviceSTM32F103C8C/C++ -> Define里加USE_FULL_LL_DRIVER, STM32F1xx_HAL_DRIVERTarget -> Code Generation勾选Use MicroLIB,节省ROM。Debug -> Settings -> SWD,时钟频率设为Maximum(4MHz)。最大的坑在Utilities -> Flash Download:必须选对Flash算法,F103C8用STM32F10x (256kB Flash),选错会烧不进去。编译前,Project -> Options -> C/C++ -> Misc Controls里加--c99,支持C99语法。生成HEX文件后,用ST-Link Utility烧录,不要勾选“Verify after programming”,F1的Flash校验有时会误报失败。

4.3 首次上电:安全第一的五秒法则

第一次上电,电机轴必须用手捏住!连接好所有线,只上电,不运行代码。用万用表测H桥输入端(IN1/IN2),确认都是低电平(0V)。然后烧录代码,上电。观察:LED是否按预期闪烁(说明主循环在跑),串口是否有初始化信息(如果有)。等待5秒,确认无异常发热、无异味、无异响。然后松开手指,让电机空载旋转。如果电机狂转不止,立刻断电——说明pos_setpoint初始值设得太大,或者vel_output限幅失效。此时用ST-Link Debugger连接,在main()里设断点,查看pos_setpointvel_output的初值。

4.4 参数整定:从“不死机”到“不抖动”的渐进式调参

调参不是玄学,是工程实验。分三步:

  1. 速度环单点突破:注释掉位置环代码,让vel_setpoint固定为1000(对应1000rpm)。编译烧录。用示波器看PWM波形,调节Kp_vel,从0.1开始,每次×2,直到电机响应快但无明显过冲(波形上升沿陡峭,无振铃)。再加Ki_vel,从0.01开始,观察稳态误差是否消失。Kd_vel一般为0,除非负载扰动极大。
  2. 位置环粗调:恢复位置环,pos_setpoint设为1000(对应1000个脉冲)。Kp_pos从0.1开始,观察定位时间。当出现超调时,Kp_pos减半。此时Ki_pos设为0。
  3. 精细打磨:加入Ki_pos,从0.001开始,观察低速爬行是否平稳。用激光测距仪测实际定位重复精度,目标是±1个编码器脉冲。如果仍有微小抖动,微调Kd_vel(加一点阻尼),或降低TIM4中断优先级(减少中断嵌套)。

4.5 故障排查:示波器是你的第三只眼

没有示波器,等于闭着眼调闭环。必备三路信号:

  • CH1:TIM3_CH1 PWM输出(看占空比是否随指令变化)
  • CH2:编码器A相(看脉冲是否连续、有无丢步)
  • CH3:电机电流采样(如果电路有康铜丝,看电流波形是否平滑)

典型故障:

  • 电机嗡嗡响不转:CH1无PWM,查HAL_TIM_PWM_Start()是否执行;CH2无脉冲,查编码器接线或HAL_TIM_Encoder_Start()
  • 电机转但定位不准:CH2脉冲数与理论值差很多,查编码器线数设置(htim2.Init.Period = 65535;对应16位计数器,但实际分辨率由编码器线数决定)。
  • 高速时丢步:CH2脉冲在高速段变稀疏,说明编码器信号受干扰,加磁环或缩短线缆。
  • 低速爬行抖动:CH3电流波形有高频毛刺,说明Kd_vel太大或电源纹波大,加LC滤波。

5. 常见问题与独家避坑指南:十二年踩过的坑都在这

5.1 “烧录成功,但电机不动”——九成是编码器模式配错了

F1的TIM2编码器模式有三种:TIM_ENCODERMODE_TI12(仅用TI1/TI2)、TIM_ENCODERMODE_TI1(仅用TI1)、TIM_ENCODERMODE_TI2(仅用TI2)。代码里htim2.Init.EncoderMode = TIM_ENCODERMODE_TI12;是标准配置。但如果编码器A相接在PA0(TIM2_CH1),B相接在PA1(TIM2_CH2),而你误配成TIM_ENCODERMODE_TI1,那就只能收到A相脉冲,B相被忽略,计数永远是单向的。解决方案:用逻辑分析仪抓PA0/PA1波形,确认AB相相位差90度;然后在MX_TIM2_Init()里,检查htim2.Init.EncoderModehtim2.Channel1htim2.Channel2的配置是否匹配。一个快捷验证法:用手匀速转动电机轴,用串口打印__HAL_TIM_GET_COUNTER(&htim2)的值,正转时值增大,反转时值减小,说明编码器模式正确。

5.2 “调参时一切正常,一加负载就振荡”——你忽略了负载惯量比

空载调好的参数,带负载后失效,根本原因是电机惯量与负载惯量的比值(Jm/Jl)变了。位置环的Kp_pos与系统总惯量成反比。代码里#define MOTOR_INERTIA 0.0005f是电机自身惯量,但加上减速箱、皮带轮、工件后,总惯量可能翻5倍。此时Kp_pos必须除以5。解决方案:用HAL_TIM_Base_Start_IT(&htim6);开启一个辅助定时器,每100ms采集一次vel_actualvel_setpoint,计算速度误差的标准差,当标准差>50rpm时,自动降低Kp_pos的30%,实现自适应。这个功能代码里没写,但它是工业现场的标配。

5.3 “串口打印数据乱码”——波特率与时钟的隐秘战争

F1的USART1挂在APB2(72MHz),理论上最高波特率4.5Mbps。但代码里huart1.Init.BaudRate = 115200;,看似稳妥。问题出在SystemCoreClock:如果SystemClock_Config()里没正确配置PLL,SystemCoreClock可能还是8MHz的HSI,导致实际波特率是设计值的9倍(72/8=9),115200变成1.0368Mbps,串口当然乱码。验证方法:在main()开头加while(1){ HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_0); HAL_Delay(500); },用示波器测PA0翻转周期,如果是1s,说明时钟正确;如果是9s,说明时钟没配对。解决方案:用ST的CubeMX重新生成时钟树,确保SYSCLK=72MHzAPB1=36MHzAPB2=72MHz

5.4 “电机转一会儿就烫手”——MOSFET驱动不足的慢性自杀

H桥驱动芯片(如IR2104)需要足够的栅极驱动电流来快速开通/关断MOSFET。如果驱动电阻太大(比如10kΩ),MOSFET会在放大区长时间停留,功耗剧增。代码里#define PWM_FREQ 20000设为20kHz,这是听觉上限,但F1的TIM3输出PWM,其死区时间(Dead Time)必须足够。htim3.AdvancedInit.DeadTime = 100;这个值单位是ns,100ns太小,易直通。实测安全值是500ns。解决方案:在MX_TIM3_Init()里,把htim3.AdvancedInit.DeadTime = 500;,并用示波器测上下桥臂驱动波形,确保无重叠。

5.5 “多台电机同步难”——时间戳漂移的幽灵

当你用同一块F1控制两台电机时,发现它们的定位不同步,误差随时间累积。根源是HAL_GetTick()基于SysTick,而SysTick的时钟源是HSI(内部RC振荡器),精度±1%。两块芯片的HSI频率略有差异,1小时后时间戳可能差36秒。解决方案:用外部高精度晶振(8MHz)作为系统时钟源,在SystemClock_Config()里启用RCC_PLLSource_HSE,并把RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9;(8MHz×9=72MHz)。这样HAL_GetTick()的精度提升到ppm级,多机同步才有保障。

6. 从F1到F4:这套代码的进化路径与边界思考

这套F1_HAL代码的价值,不在于它多先进,而在于它是一块“活化石”,清晰展示了资源受限嵌入式系统中,实时控制的底层逻辑如何被挤压、被妥协、又被精巧地实现。它的边界在哪里?F1的72MHz主频,最多支撑2个电机的双闭环(每个环1ms),再加一个UART和一个ADC,CPU占用率就到90%。想升级,有三条路:

  • 横向扩展:用F407,主频168MHz,内置FPU,float运算快10倍。把PID算法迁移到FPU,Kp/Ki/Kd可以设成小数,精度更高;加FFT做振动分析,实时诊断轴承故障。
  • 纵向深化:在现有框架上加前馈控制。位置环输出pos_output,除了送速度环,再乘以一个前馈增益Kff,直接加到速度环输出上。vel_output = pid_vel_output + Kff * pos_output。这能大幅提高响应速度,消除跟随误差,代码只需加一行。
  • 生态融合:pos_setpoint来源从固定值,换成CAN总线接收的上位机指令,或USB HID接收的PC指令。这时#define VID_PID 0x0483, 0x5740就派上用场了——ST的USB VID/PID,让F1能被PC识别为一个标准HID设备,上位机用Python的pyusb库就能发指令,比串口协议简单十倍。

但请记住,无论怎么升级,双闭环的哲学不变:外环管“去哪”,内环管“怎么去”;外环慢而准,内环快而稳;参数不是调出来的,是算出来、试出来、再磨出来的。我最后一次用这套代码,是在帮一家包装厂改造灌装机。他们原来的气动阀定位误差±5mm,换上这套电机闭环后,做到±0.1mm。老板没夸技术多牛,只说了一句:“现在废品率从8%降到0.3%,这钱花得值。” 这就是嵌入式控制的终极答案——不炫技,只解决问题。

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

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

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

立即咨询