
简介STM32F103ZET6智能小车PID闭环速度控制完整工程源码面向嵌入式初学者、智能车爱好者与课程设计开发者主要解决小车电机速度闭环控制中测速、PID参数整定及PWM输出配合的问题。工程基于KEIL5开发适配STM32F103ZET6主控搭配L293D电机驱动、1602液晶显示模块与TT直流减速电机必须外接测速模块支持按键操作使用户可在运行中动态调整PID比例、积分、微分参数适用于智能车竞赛练习、毕业设计或科创项目参考。压缩包共173个文件大小约3.47MB核心包含37个头文件和36个C源代码文件同时提供编译生成的hex、axf、map等调试映射文件可直接烧录验证也方便按需修改代码按外设与主控逻辑分层组织涵盖定时器编码器测速、PWM调速、PID控制器、1602液晶刷新、按键扫描等关键模块已在作者自己的STM32F103ZET6智能小车上实测通过。目前已有1053人学习下载适合想要参考完整PID闭环工程、快速搭建小车控制平台或深入学习STM32定时器与PWM调速机制的开发者。1. 先谈速度采样口径再谈PID参数「STM32F103ZET6智能小车PID闭环速度控制程序源代码.rar」这类压缩包解压之后先别急着找main里的PWM赋值去看速度采样那块代码。编码器接口有没有初始化对、采样周期和轮径是不是和实物一致直接决定后面Kp、Ki、Kd的整定方向。PID闭环的前提是先有可信的测量测量错了越调越乱。这篇以竞赛小车里最常见的STM32F103ZET6为例把编码器四倍频测速、增量式PID输出、串口在线调参这一套串起来看完能直接在板子上跑通一个最小速度闭环也顺手解决「小车走走停停」「一给目标速度就振荡」这类常见现象。2. 编码器接线与定时器模式先拿到可信的轮速测量用ZET6做双轮差速小车我一般把TIM3、TIM4分配给左右轮编码器TIM1、TIM2用来输出PWM。这样不会和USART1调试串口、SWD下载口抢引脚排查起来也清晰。不要用普通GPIO中断去数编码器脉冲电机转速一高中断频率能到几十kHz主循环直接被拖死ZET6的定时器编码器模式是硬件在计数不占用CPU这是做闭环速度控制的第一条资源红线。2.1 引脚分配与4倍频计数编码器输出A、B两相相位差90°定时器工作在编码器模式下可以通过A相和B相的边沿组合判断方向并计数。常见接法如下表具体引脚以自己板子的原理图为准。功能左轮右轮说明编码器A相PA6TIM3_CH1PB6TIM4_CH1输入模式编码器B相PA7TIM3_CH2PB7TIM4_CH2输入模式电机PWMPA8TIM1_CH1PA1TIM2_CH2硬件PWM输出方向控制PE0/PE1PE2/PE3驱动板IN1/IN2编码器模式有TIM_EncoderMode_TI1和TIM_EncoderMode_TI12两种。TI1只统计通道1的边沿单圈脉冲数等于编码器线数TI12同时识别A、B相的上升沿和下降沿也就是4倍频。竞赛小车常用的13线编码器配合4倍频电机轴转一圈能数到52个脉冲再算上常见的1:30减速箱驱动轮转一圈就是1560个计数。脉冲密度越高低速时的速度分辨率越好这就是为什么我建议能开4倍频就不开2倍频。2.2 编码器模式的定时器配置细项标准外设库的配置逻辑是先初始化GPIO再配时基然后用TIM_EncoderInterfaceConfig把定时器切到编码器模式。一段最小配置如下GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef timebase; TIM_ICInitTypeDef ic; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Mode GPIO_Mode_IN_FLOATING; gpio.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); TIM_DeInit(TIM3); timebase.TIM_Period 0xFFFF; timebase.TIM_Prescaler 0; timebase.TIM_CounterMode TIM_CounterMode_Up; timebase.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, timebase); ic.TIM_Channel TIM_Channel_1; ic.TIM_ICPolarity TIM_ICPolarity_Rising; ic.TIM_ICSelection TIM_ICSelection_DirectTI; ic.TIM_ICPrescaler TIM_ICPSC_DIV1; ic.TIM_ICFilter 0; TIM_ICInit(TIM3, ic); ic.TIM_Channel TIM_Channel_2; TIM_ICInit(TIM3, ic); TIM_EncoderInterfaceConfig(TIM3, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_SetCounter(TIM3, 0); TIM_Cmd(TIM3, ENABLE);这里有两个关键参数要看懂。TIM_Prescaler必须为0预分频一旦大于0计数脉冲会被分频速度值系统性偏小而且不是固定比例查起来很难受。TIM_CounterMode用向上计数模式编码器模式下正转时CNT增加、反转时CNT减少方向信息就体现在cnt的正负上所以读取时要用int16_t而不是uint16_t否则负速度会被读成很大的正数。提示如果发现正转时CNT递减不用改接线把TIM_EncoderInterfaceConfig的最后两个极性参数改成TIM_ICPolarity_Falling或者干脆把A、B相的GPIO定义互换计数方向就会反过来。2.3 采样周期与脉冲到转速的换算速度不是随时算的而是固定每隔一段时间读一次计数差值这个时间就是采样周期。我用10ms、20ms、50ms都跑过20ms是速度和分辨率都比较折中的值。换算公式是Nrpm ΔCNT × 60 /PPR × TsPPR是驱动轮转一圈的总脉冲数等于编码器线数×4×减速比。以13线、1:30减速、Ts20ms为例PPR1560如果在20ms内读到ΔCNT260转速就是260×60/1560×0.02500rpm。#define SAMPLE_TIME_MS 20 #define ENCODER_LINES 13 #define REDUCTION_RATIO 30 #define PPR_PER_WHEEL (ENCODER_LINES * 4 * REDUCTION_RATIO) volatile uint8_t sample_flag 0; int32_t Pulse2Rpm(int16_t delta_cnt) { return (int32_t)((float)delta_cnt * 60.0f / ((float)PPR_PER_WHEEL * (float)SAMPLE_TIME_MS / 1000.0f)); }脉冲到转速的换算在STM32F103上不要用doubleM3没有FPUdouble的软浮点开销比float大好几倍。这个函数每20ms才执行两次用float完全足够。如果小车的目标是控制线速度还需要乘上轮子周长但轮径要实测而不是用标称值胎压、磨损都会让标称值偏大。3. 增量式PID与PWM输出转速环最小可运行代码测速口径统一之后下一步是把PID控制律落到代码里。速度环我用增量式而不是位置式原因是增量式输出的是PWM的调整量Δu天然不存在积分饱和把PWM顶满的问题而且正反转切换时也不会因为积分累积出现大跳变。位置式适合舵机这类输出有物理零位的对象电机调速从工程习惯上增量式更省事。3.1 增量式PID的结构体与核心函数增量式PID的公式是Δu Kp×(e0-e1) Ki×e0 Kd×(e0-2e1e2)其中e0是本次误差e1是上次e2是上上次。这里的Ki是积分增益不是积分时间注意不要和连续域PID的KiKp/Ti搞混两者的数值范围差很多。typedef struct { float kp; float ki; float kd; int32_t target; int32_t actual; int32_t err[3]; int32_t delta_u_max; int32_t pwm_max; int32_t out; } PidTypeDef; int32_t PID_Tick(PidTypeDef *pid) { float p_term, i_term, d_term; float delta_u; pid-err[2] pid-err[1]; pid-err[1] pid-err[0]; pid-err[0] pid-target - pid-actual; p_term pid-kp * (float)(pid-err[0] - pid-err[1]); i_term pid-ki * (float)pid-err[0]; d_term pid-kd * (float)(pid-err[0] - 2 * pid-err[1] pid-err[2]); delta_u p_term i_term d_term; if (delta_u pid-delta_u_max) delta_u pid-delta_u_max; if (delta_u -pid-delta_u_max) delta_u -pid-delta_u_max; pid-out (int32_t)delta_u; if (pid-out pid-pwm_max) pid-out pid-pwm_max; if (pid-out -pid-pwm_max) pid-out -pid-pwm_max; return pid-out; }函数里有三级保护。err数组用int32_t存误差避免int16_t在误差溢出时符号错乱这是老项目容易埋雷的地方。delta_u_max是增量限幅用来限制单次PWM跳变幅度一般取pwm_max的10%到20%防止起步时转速猛冲。pwm_max是输出限幅对应占空比100%的计数值和定时器ARR挂钩。每次计算前先做err数组移位再放本次误差顺序不能反。kp、ki、kd用float是因为1000rpm量级的速度误差除以PWM的7200计数中间数会带不少小数纯整数运算在调参时会因为截断误差感到「参数不动没反应」。20ms跑一次PID每秒才50次浮点运算M3完全扛得住不用在这个位置纠结效率。3.2 硬件PWM初始化与电机正反转换向ZET6的TIM1是高级定时器输出PWM比通用定时器多一步主输出使能。少了TIM_CtrlPWMOutputsTIM1 ENABLETIM1_CH1引脚上只能量到高电平电机全速转这是新手和老手都会踩的坑。TIM_TimeBaseInitTypeDef timebase; TIM_OCInitTypeDef oc; RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1 | RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Pin GPIO_Pin_8; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); timebase.TIM_Period 7200 - 1; timebase.TIM_Prescaler 10 - 1; timebase.TIM_CounterMode TIM_CounterMode_Up; timebase.TIM_ClockDivision 0; TIM_TimeBaseInit(TIM1, timebase); oc.TIM_OCMode TIM_OCMode_PWM1; oc.TIM_OutputState TIM_OutputState_Enable; oc.TIM_Pulse 0; oc.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM1, oc); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_CtrlPWMOutputs(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE);PWM频率由72MHz/预分频1/ARR1决定上面配置算出来是1000Hz。L298N这类驱动芯片建议不超过20kHz1000Hz能听到轻微啸叫但不影响控制如果换了TB6612可以预分频改为1、ARR改为7200-1频率提到10kHz噪音会小很多。TIM_SetCompare1的第二个参数写0到7199的任意值对应0到100%占空比这个值就是PID的out。电机正反转通过H桥的IN1/IN2控制。方向切换之间要加死区判断PWM绝对值小于起转值的时候就完全断电否则电机在零点附近抖动电流很大还不出力。#define MOTOR_DEAD_ZONE 400 void Motor_SetPwm(int32_t l_out, int32_t r_out) { if (l_out MOTOR_DEAD_ZONE) { L_IN1 1; L_IN2 0; TIM_SetCompare1(TIM1, l_out); } else if (l_out -MOTOR_DEAD_ZONE) { L_IN1 0; L_IN2 1; TIM_SetCompare1(TIM1, -l_out); } else { L_IN1 0; L_IN2 0; TIM_SetCompare1(TIM1, 0); } /* 右轮逻辑相同略 */ }死区值我一般先开环测固定给PWM从0开始每次加50看到轮子刚刚转动时的值就是起点再留20%余量。死区设太小零点附近电流声很大设太大低速跟随会出现一段「平台」目标转速从正到负切换时会明显停顿。3.3 主循环与采样标志中断里只置位主循环里算PID我见过不少把编码器读取、PID计算、printf全塞进定时器中断里的写法中断服务程序做到最后能超过1ms系统响应反而变差。我的做法是用Systick或者TIM7做20ms时基中断里只办两件事读CNT、清零CNT然后把sample_flag置1。主循环看到标志位再去算PID、更新PWM。void TIM7_IRQHandler(void) { if (TIM_GetITStatus(TIM7, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM7, TIM_IT_Update); g_cnt_l (int16_t)TIM_GetCounter(TIM3); g_cnt_r (int16_t)TIM_GetCounter(TIM4); TIM_SetCounter(TIM3, 0); TIM_SetCounter(TIM4, 0); sample_flag 1; } } while (1) { if (sample_flag) { sample_flag 0; pid_l.actual Pulse2Rpm(g_cnt_l); pid_r.actual Pulse2Rpm(g_cnt_r); pid_l.target target_speed; pid_r.target target_speed; Motor_SetPwm(PID_Tick(pid_l), PID_Tick(pid_r)); } }中断里只做采样和标记PID计算在主循环里跑。这样采样时刻和计算时刻完全对齐20ms的节奏不会因为主循环里其他任务被偶尔打乱。要特别说明的是PID的采样周期不能变一旦某次循环被OLED刷新或者串口阻塞拖慢那次的计算周期就不是20msKp的等效增益就变了。主循环里如果还有显示、串口这类阻塞操作建议把PID计算的优先级提到最前面。4. 串口在线调参与参数整定先开环标定再闭环stm32串口调试pid是最常用的调参手段比反复烧录改参数效率高出一个数量级。做法是把printf重定向到USART1把目标转速和实际转速按固定频率打出来在上位机里画成两条曲线再用串口发命令直接改PID参数。边看曲线边改参数一轮下来比盲目烧录靠谱得多。4.1 重定向printf到USART1并打印目标/转速曲线printf重定向在标准外设库工程里通常改fputc配合USART1的初始化把Printf直接指向串口。串口波特率115200打印20Hz的调试帧数据量完全够。int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; } void Debug_Periodic(void) { static uint32_t last_time 0; if (HAL_GetTick() - last_time 200) { /* 200ms打一帧 */ last_time HAL_GetTick(); printf(T:%d L:%d R:%d\r\n, pid_l.target, pid_l.actual, pid_r.actual); } }T是目标速度L、R是左右轮实际速度。上位机用串口助手自带的波形控件或者Vofa选CSV格式一帧三个字段直接能看到跟随曲线。打印频率不用太高200ms一帧足够看出趋势打太快会占CPU且刷屏。串口调参在做定点或斜坡跟随测试时还可以把误差累计量打出来看稳态误差是收敛还是发散。为了在线改参数需要在串口接收中断里加一段简单的命令解析。格式尽量简短比如P开头表示KpI开头表示KiD开头表示Kd后面跟浮点数。void USART1_IRQHandler(void) { static uint8_t buf[16]; static uint8_t idx 0; uint8_t ch; if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { ch (uint8_t)USART_ReceiveData(USART1); if (ch \r || ch \n) { buf[idx] 0; if (sscanf((char*)buf, P%f, pid_l.kp) 1) pid_r.kp pid_l.kp; if (sscanf((char*)buf, I%f, pid_l.ki) 1) pid_r.ki pid_l.ki; if (sscanf((char*)buf, D%f, pid_l.kd) 1) pid_r.kd pid_l.kd; idx 0; } else if (idx sizeof(buf) - 1) { buf[idx] ch; } } }串口中断里使用sscanf有开销但接收一帧顶多十几个字节按调试频率算完全可以接受。实际调参时往串口发「P20」「I1.5」「D0.2」就能改变参数不用重新编译烧录。要注意的是左右轮参数被一并设置了实际两轮电机特性略有差异精调阶段应分别设置。4.2 三段式整定流程开环标定→纯P→加IPID调参的第一步不是Kp而是开环标定。把PID旁路直接给PWM固定值记录稳态转速画出一条PWM-转速曲线。这条曲线告诉两件事死区在哪以及PWM变化多少转速能变化多少。增益Kp的初始值就可以拿转速变化量除以PWM变化量来估算比如PWM从3000升到4000转速从500升到800那这段的增益大约是0.3Kp从增益的30%到50%起步比较安全。开环标定完成后按下面流程整定每步都看曲线变化再决定下一步。阶段操作观察点下一步纯PKp从1到10逐步加目标变化时是否过冲过冲明显就减半加IKi从Kp的1/10开始加稳态误差是否消除误差不消除继续加I加DKd从0到0.1试探噪声毛刺是否变大毛刺变大就退回0综合回看超调量和响应时间跟随性能是否可接受微调Kp和Ki纯P阶段的目标是让实际速度能跟上目标速度允许有静差但必须收敛不振荡。如果一给目标PWM直接顶满、转速来回冲先检查两个方向编码器计数方向是否和PWM方向一致不一致的话闭环必炸Kp是否过大试着减半再看。加I是消除稳态误差的关键速度环的Ki通常只有Kp的十分之一到五分之一。Ki加太大曲线会出现低频等幅振荡1到2秒一个周期这是积分作用过强的典型特征。积分作用的效果有延迟每次调Ki后至少要等3到5个振荡周期再判断不要看着刚改完的1秒曲线就下结论。4.3 采样周期、滤波器与常见失败现象排查采样周期和PID增益是耦合的。同样的Kp10ms采样和50ms采样表现完全不同。采样周期缩短调节频度变高等效增益变大采样周期拉长调节反应变慢但抗量化噪声更好。我一般固定20ms不变只动PID三参数这样每次只引入一个变量。编码器在低速或低分辨率下计数可能出现±1的跳变这个量化噪声会被D放大。遇到这种情况可以给测量值做滑动平均或者限幅滤波但滤波会引入相位延迟20ms采样周期下建议最多取3点平均再多就会让速度环看起来「迟钝」。现象直接原因检查方向速度发散、PWM封顶Kp符号反或编码器方向反开环给定PWM看计数方向稳态静差一直存在Ki过小或未加积分看目标与实际曲线差值低频等幅振荡Ki偏大Ki减为当前值的一半零点抖动加电流声死区太浅或Kp太大加大死区或降低Kp高频毛刺明显量化噪声被D放大Kd归零加3点滑动平均排错的顺序有讲究先确认编码器方向和PWM方向一致再开环验证PWM到转速的关系最后才动PID参数。跳过前两步直接调参会把方向接反的问题误判成Kp过大越调越乱。每次只改一个参数改完记录曲线再动下一个。5. 进阶在速度环上叠加位置环附带堵转判定与验收清单速度环跑顺之后最常见的扩展是把位置环叠在外面形成串级PID。内环继续用20ms采样控制转速外环用100ms到200ms的周期把当前位置和目标位置的距离差换算成内环的目标速度。位置环只给目标速度不直接碰PWM。void PosLoop_100ms(void) { int32_t pos_err target_pos - current_pos; int32_t speed_ref (int32_t)(Kp_pos * (float)pos_err); if (speed_ref MAX_SPEED) speed_ref MAX_SPEED; if (speed_ref -MAX_SPEED) speed_ref -MAX_SPEED; pid_l.target speed_ref; pid_r.target speed_ref; }串级控制的关键是内环响应速度要比外环快5倍以上所以内环20ms、外环100ms到200ms是比较稳妥的比例。外环参数Kp_pos可以用纯P先试位置误差一般用编码器累计计数来表示一圈1560个脉冲Kp_pos从0.5起步比较合适。走线阶段如果每次都冲过头试着在外环里加一点微分在内环能跟随的情况下外环的D项不会像单环那样被噪声放大。实际项目中还建议加一个堵转保护判断当目标速度绝对值大于100rpm而实际速度连续2秒小于20rpm说明电机被卡住应该立即切断PWM并置错误标志避免长时间堵转烧坏驱动芯片。这个逻辑放在20ms控制周期里每40次检查一次即可代价很小。整套闭环是否达标我给一套自测方法让目标速度从0阶跃到800rpm抓实际速度曲线超调不超过10%也就是880rpm以内、上升时间小于1秒、稳态误差在±20rpm以内说明参数基本合格。再让目标速度做±200rpm的斜坡变化看曲线是否平滑跟随、是否有明显滞后。最后用手捏住轮子模拟负载突变观察速度下降后能否在0.5秒内回到目标值。如果负载变化后恢复很慢加一点积分或者降低采样周期会有帮助。速度环调试的终点是让系统在任何工况下都能回到目标速度而不是只在一个固定转速下好看。本文还有配套的精品资源点击获取