STM32F103C8T6+L298N+MG513P30电机PID速度闭环控制实战
简介这份资源面向嵌入式初学者与电机控制方向的开发者提供一套基于STM32F103C8T6、L298N双H桥驱动与MG513P30直流电机的PID闭环调速程序可用于课程设计、毕业设计或小型运动控制项目。内容围绕定时器PWM输出、编码器测速、误差计算与PID参数整定展开帮助读者理解从采样、限幅到控制输出的完整反馈流程。压缩包为zip格式大小约47.69MB包内文件以工程源码、头文件及必要的配置说明为主便于直接编译与二次修改。目前已有5756人学习下载说明该方案在同类电机控制实践中具有一定参考价值。读者可据此掌握Kp、Ki、Kd的调试思路理解积分限幅与死区处理对稳定性的影响并对照L298N驱动逻辑完成速度闭环验证为后续加入上位机调参或更复杂控制算法打下基础。1. 从“转不起来”到“稳如磐石”STM32F103C8T6L298NMG513P30的PID控制到底在控什么很多人第一次把MG513P30直流电机、L298N驱动模块和STM32F103C8T6最小系统板连在一起时都会遇到同一个尴尬上电后电机要么纹丝不动要么满速狂转要么一加载就掉速。你明明写了PWM输出也接了编码器可转速就是不听使唤。问题不在接线而在于你还没有建立“反馈闭环”这个概念。这套组合的核心不是让电机转起来而是让电机按照你设定的转速稳定地转下去无论电池电压从8.4V掉到7.2V还是轮子突然压到地毯上阻力变大。PID控制器就是干这个的它读取MG513P30尾部霍尔编码器返回的脉冲频率换算出实际转速再和你的目标值比较用比例、积分、微分三项算出一个新的PWM占空比通过L298N的使能脚输出给电机。整个过程每几毫秒循环一次形成一个负反馈系统。适合谁适合正在做智能小车、平衡车、云台或任何需要精确速度控制的嵌入式初学者和中级开发者。如果你只会写HAL_GPIO_WritePin点灯这篇笔记能让你少走至少两周的弯路。2. 硬件链路与定时器资源分配为什么你的编码器读数总是跳变2.1 先理清信号流向从MG513P30到STM32的每一根线MG513P30是一款带增量式霍尔编码器的直流减速电机减速比通常为1:30输出轴每转一圈编码器A相或B相会输出一定数量的脉冲具体脉冲数看电机手册常见的是11线或13线乘以减速比后得到每转数百个脉冲。L298N是双H桥驱动逻辑电平5V兼容但STM32F103C8T6的GPIO是3.3V所以L298N的输入引脚IN1、IN2、ENA接3.3V逻辑时高电平识别阈值大约在2.3V以上通常可以直接驱动但如果你买的L298N模块带光耦或电平转换就要看具体电路。接线时最容易翻车的地方是编码器电源MG513P30的霍尔编码器一般需要5V供电而STM32的GPIO只能承受3.3V所以编码器A/B相输出是5V方波直接进STM32的定时器输入捕获引脚会长期损伤IO。血泪经验要么用电阻分压比如10kΩ20kΩ要么用74LVC245电平转换要么选编码器供电为3.3V的电机型号。我一般会先拿示波器看A相波形确认高电平不超过3.3V再往下做。2.2 定时器分配一个出PWM一个读编码器STM32F103C8T6有4个通用定时器其中TIM1是高级定时器TIM2、TIM3、TIM4是通用定时器。常见做法是用TIM1的通道1PA8输出PWM给L298N的ENA用TIM2的通道1PA0和通道2PA1做编码器接口模式读取MG513P30的A/B相。为什么不用TIM3或TIM4因为TIM2是32位定时器在编码器模式下计数范围更大不容易溢出。配置编码器模式时把TIM2的SMCR寄存器的SMS位设为0x03编码器模式3在A和B的上升沿和下降沿都计数这样可以得到四倍频计数提高分辨率。下面是一个最小初始化代码基于标准外设库HAL库逻辑类似。// TIM1 PWM输出初始化频率10kHz占空比可调 void PWM_Init(u16 arr, u16 psc) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_8; // PA8 - TIM1_CH1 GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; // 复用推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period arr; // 自动重装载值 TIM_TimeBaseStructure.TIM_Prescaler psc; // 预分频 TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM1, TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 0; // 初始占空比0 TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM1, TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE); TIM_CtrlPWMOutputs(TIM1, ENABLE); // 高级定时器必须开 } // TIM2 编码器接口初始化 void Encoder_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; // PA0A, PA1B GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_Init(GPIOA, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period 65535; TIM_TimeBaseStructure.TIM_Prescaler 0; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_EncoderInterfaceConfig(TIM2, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter 0x0F; // 滤波抗抖动 TIM_ICInit(TIM2, TIM_ICInitStructure); TIM_ICInitStructure.TIM_Channel TIM_Channel_2; TIM_ICInit(TIM2, TIM_ICInitStructure); TIM_Cmd(TIM2, ENABLE); }逻辑说明PWM频率设为10kHz是因为L298N的开关频率上限大约在20kHz~40kHz10kHz既能避开音频噪声又能保证电机电流连续。arr和psc根据系统时钟72MHz计算比如arr7199, psc0得到10kHz。编码器接口模式配置为TI12即A和B都计数四倍频。TIM_ICFilter设为0x0F是最大滤波能滤掉电机换向时产生的尖峰噪声但会引入微小延迟对低速控制影响不大。参数怎么改如果你发现编码器读数偶尔跳变先把滤波值调到0x0F再检查A/B相是否加了上拉电阻STM32内部上拉较弱建议外部4.7kΩ上拉。2.3 测速方法M法、T法还是M/T法在STM32上测速有三种常见做法。M法固定时间比如10ms读一次编码器计数值除以时间得到速度。低速时计数值很小分辨率差。T法测量两个编码器脉冲之间的时间适合低速但高速时定时器频繁中断。M/T法结合两者低速用T法高速用M法。对于MG513P30这种减速电机输出轴转速通常在100~500rpm编码器四倍频后每转脉冲数可能在几百到一千多10ms内计数值足够大所以M法完全够用。我一般用TIM4做一个1ms中断每10次中断即10ms读取一次TIM2的计数值然后清零。注意读计数值时要先停止定时器或使用原子操作否则可能读到半更新的值。简单做法是cnt TIM_GetCounter(TIM2); TIM_SetCounter(TIM2, 0);在10ms周期内不会溢出。3. 位置式PID与增量式PID的代码落地参数整定不是玄学3.1 为什么速度环我选增量式PID位置式PID的输出直接对应PWM占空比公式是u(k) Kp*e(k) Ki*Σe(j) Kd*(e(k)-e(k-1))。增量式PID输出的是占空比的增量Δu(k) Kp*(e(k)-e(k-1)) Ki*e(k) Kd*(e(k)-2e(k-1)e(k-2))。对于电机速度控制增量式有两个好处一是没有积分饱和问题因为不需要累加历史误差二是手动切换自动时冲击小。但增量式需要保存前两次误差且对传感器噪声更敏感。我的习惯是速度环用增量式位置环用位置式。下面是一个增量式PID的实现结构体里保存prev_error和prev_prev_error。typedef struct { float Kp, Ki, Kd; float prev_error; float prev_prev_error; float output; float out_max; float out_min; } IncPID; float IncPID_Calc(IncPID *pid, float target, float actual) { float error target - actual; float delta; // 增量式公式 delta pid-Kp * (error - pid-prev_error) pid-Ki * error pid-Kd * (error - 2 * pid-prev_error pid-prev_prev_error); pid-output delta; // 输出限幅 if (pid-output pid-out_max) pid-output pid-out_max; if (pid-output pid-out_min) pid-output pid-out_min; // 更新历史误差 pid-prev_prev_error pid-prev_error; pid-prev_error error; return pid-output; }逻辑说明target是目标转速单位可以是脉冲数/10msactual是实际转速。out_max和out_min对应PWM占空比的最大最小值比如0到7200对应arr7199。注意增量式PID的输出是累加的所以初始化时output要设为一个合理的起始值比如0。参数整定顺序先只加Kp从小到大调直到电机响应快但略有超调然后加Ki消除静差Ki太大会振荡最后加Kd抑制超调但Kd对噪声敏感如果编码器噪声大Kd要很小或不用。常见翻车点Ki累加导致输出饱和虽然增量式本身不会积分饱和但如果输出限幅后delta还在累加实际输出会卡在限幅值这时需要做抗饱和处理比如当输出达到限幅时停止累加。3.2 定时器中断里的完整控制循环把测速和PID计算放在同一个定时中断里保证控制周期严格固定。我用TIM4产生1ms中断每10ms执行一次速度环计算。中断服务函数里先读编码器再算PID最后更新PWM比较值。volatile int16_t encoder_cnt 0; volatile float actual_speed 0; volatile float target_speed 300.0; // 目标300个脉冲/10ms IncPID speed_pid {1.2, 0.8, 0.05, 0, 0, 0, 7200, 0}; void TIM4_IRQHandler(void) { static uint8_t tick 0; if (TIM_GetITStatus(TIM4, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM4, TIM_IT_Update); tick; if (tick 10) { // 每10ms tick 0; encoder_cnt TIM_GetCounter(TIM2); TIM_SetCounter(TIM2, 0); actual_speed (float)encoder_cnt; // 直接作为速度反馈 float out IncPID_Calc(speed_pid, target_speed, actual_speed); // 限制PWM在0~7200 if (out 0) out 0; if (out 7200) out 7200; TIM_SetCompare1(TIM1, (uint16_t)out); } } }逻辑说明encoder_cnt是10ms内的四倍频脉冲数直接作为速度量。target_speed设为300对应电机输出轴转速约为300/(四倍频每转脉冲数)*100转/秒具体换算看电机手册。IncPID结构体初始化时Kp1.2, Ki0.8, Kd0.05这是针对MG513P30在12V供电、空载条件下的经验值负载变化时需要重新整定。注意TIM_SetCompare1会立即更新占空比但PWM波形在下一个周期才生效所以控制延迟大约一个PWM周期0.1ms可以忽略。如果电机出现啸叫说明PWM频率落在音频范围把arr改小提高频率到15kHz以上。3.3 参数整定的实操步骤与典型波形不要一上来就调三个参数。按这个顺序来第一步Kp0, Ki0, Kd0给一个固定占空比比如30%记录电机从静止到稳定的转速上升曲线作为参考。第二步只加Kp从0.1开始每次增加0.1观察转速响应。当Kp1.0左右时转速能快速接近目标但会有静差实际值低于目标值。第三步加Ki从0.1开始每次增加0.1直到静差消失。如果Ki太大转速会围绕目标值上下振荡振荡周期大约等于控制周期的几倍。第四步加Kd从0.01开始抑制超调。如果编码器噪声大Kd会引起高频抖动这时要么减小Kd要么在测速环节加滑动平均滤波。典型波形Kp1.2, Ki0.8, Kd0.05时转速从0到300的上升时间约200ms超调量小于5%稳态误差在±2个脉冲以内。如果超调超过20%先减Kp再减Ki。4. 避坑与排查L298N压降、编码器抖动和PID积分饱和4.1 电机不转或转速远低于目标现象PWM占空比已经给到80%但电机只是嗡嗡响不转或者转速只有目标的一半。原因L298N内部有达林顿管饱和压降约1.4V~2V如果电池电压是7.4V电机实际只能得到5.4V~6V转速自然低。另外L298N的使能脚ENA如果接的是3.3V高电平可能达不到逻辑高阈值2.3V以上通常可以但有些模块光耦输入需要5V。解决先测电机两端电压如果比电池电压低2V以上换用MOSFET驱动模块如TB6612或DRV8833或者提高电池电压到12V。如果ENA电压不足加一级电平转换或直接接5V但注意STM32的IO不能输出5V所以要用三极管或光耦隔离。4.2 编码器计数偶尔跳变或方向错误现象电机正转时编码器计数偶尔减少或者静止时计数缓慢增加。原因A/B相受到电机换向火花干扰或者A/B相接反导致方向判断错误。解决第一在编码器A/B相和地之间各接一个0.1μF电容滤高频噪声。第二检查TIM2的编码器模式极性配置如果正转时计数减少把TIM_ICPolarity_Rising改成TIM_ICPolarity_Falling或者交换A/B相接线。第三如果静止时计数漂移说明A/B相有毛刺把TIM_ICFilter从0x0F再加大最大0x0F同时检查电机外壳是否接地良好。4.3 PID输出振荡或电机发出刺耳啸叫现象转速围绕目标值大幅振荡电机声音忽高忽低或者有高频尖叫声。原因Kp太大或Ki太大导致系统不稳定PWM频率落在音频范围2kHz~10kHz引起机械共振。解决先减小Kp到原来的70%再减小Ki到50%。如果啸叫来自PWM频率把TIM1的arr改小比如从7199改成3599频率从10kHz提高到20kHz同时注意L298N的开关损耗会增加摸一下驱动芯片温度如果烫手就加散热片。另外如果用了增量式PID检查输出限幅是否合理限幅太窄会导致输出卡在边界引起低频振荡。4.4 电池电压下降后转速明显变慢现象刚上电时转速稳定跑几分钟后电池从8.4V降到7.2V转速跟着下降。原因PID只根据转速误差调整PWM但PWM占空比乘以电池电压才是电机实际电压。如果电池电压下降同样的占空比对应更低的电机电压转速自然下降。解决在PID输出后乘以一个电池电压补偿系数或者直接测电池电压做前馈。简单做法用ADC读电池分压计算compensation nominal_voltage / actual_voltage把PID输出乘以这个系数再限幅。注意补偿系数不要超过1.5否则低压时占空比过大导致电流激增。4.5 中断里读编码器导致程序卡死现象电机转起来后主循环不执行了或者串口无输出。原因TIM4中断优先级高于其他中断如果中断服务函数里执行时间过长比如浮点运算会阻塞其他中断。另外TIM_GetCounter和TIM_SetCounter之间如果发生编码器脉冲可能丢失计数。解决把PID计算里的浮点运算改成定点数或者把控制周期从10ms放宽到20ms。读编码器时先关TIM2的计数器TIM_Cmd(TIM2, DISABLE)读完后清零再使能但这样会丢失关断期间的脉冲所以更好的做法是用DMA把计数值搬到内存或者用定时器的从模式自动复位。对于F103C8T6没有DMA请求来自编码器接口所以只能接受微小误差或者用外部中断计数。5. 进阶技巧用串口示波器看PID波形以及双环控制的引入调PID最痛苦的是看不见波形。我习惯用串口把目标转速和实际转速按固定周期发出来格式是target,actual\n然后用VOFA或SerialPlot这类串口示波器工具实时显示。这样Kp、Ki、Kd每改一次波形立刻变化比盲调快十倍。具体做法在10ms控制循环里每5次即50ms通过串口发送一次printf(%.1f,%.1f\n, target_speed, actual_speed);注意串口波特率设为115200以上否则发送时间会挤占控制周期。如果串口发送太慢可以只发实际转速目标值在示波器里画一条水平线。当你把速度环调稳之后可以往上加位置环做成双环控制。位置环用编码器累计脉冲数作为反馈输出作为速度环的目标值。位置环用位置式PID输出限幅在速度环的目标范围内。这样小车就能精确走直线或定距离。双环的调试顺序是先调好内环速度环再调外环位置环外环的Kp从很小开始比如0.5因为内环已经很快外环不需要太激进。注意位置环的积分项容易饱和一定要加抗饱和比如当输出达到限幅时停止积分累加。最后说一个我踩过的坑曾经用L298N驱动MG513P30做平衡车速度环调得很好但一加位置环就抖。查了两天发现是编码器四倍频后每转脉冲数太多10ms内计数值超过int16_t范围溢出后变成负数PID直接疯掉。后来把编码器模式改成二倍频只计A相上升沿和下降沿或者把控制周期缩短到5ms问题才解决。所以先算清楚你的编码器在最高转速下10ms内最多产生多少脉冲确保变量类型够用。这个习惯帮我省下了很多后悔药。希望帮到你。本文还有配套的精品资源点击获取