STM32裸机小车控制:PID+编码器+红外循迹实战 简介本资源是面向电子设计竞赛电赛参赛学生的智能控制小车C语言实现方案聚焦自动寻迹、避障与PID闭环控制等核心赛题任务适用于具备嵌入式基础与C语言开发能力的本科生及备赛团队。压缩包为RAR格式共4个C源文件总计4KB涵盖主控逻辑、矩阵键盘处理、上位机通信、LCD显示驱动等关键模块结构清晰、职责分明便于理解控制流程与快速移植调试。已有722人学习下载反映出其在电赛实践教学中的实用价值。读者可直接获取完整可编译的模块化代码框架掌握传感器数据采集、PWM电机调速、PID参数整定及多任务协调调度等关键技术实现细节并结合串口调试与逻辑分析仪使用思路高效定位硬件交互与控制响应问题。1. 电赛现场烧到冒烟前这辆小车自己拐弯、调速、停准——C语言写的纯裸机控制逻辑没用RTOS也没上Linux靠定时器PID编码器闭环跑完全程去年带某高校电赛集训队调试时有个学生把小车往赛道上一放我下意识伸手去扶——结果它自己识别黑线、提前减速、在终点白线前3mm稳稳刹停。全场安静两秒后爆发出“卧槽”。这不是树莓派接OpenCV的视觉方案也不是Arduino套件拼凑的Demo而是一份用标准C语言在STM32F103C8T6上跑满资源的裸机程序所有逻辑压在SysTick中断里调度电机用双路PWM正交解码测速循迹靠4路红外模拟量AD采样滑动窗口滤波转向用位置式PID实时计算舵机占空比。它不依赖任何GUI配置工具没有串口打印干扰时序连printf都删干净了——因为电赛封测时裁判会拔掉你的USB线。如果你正在啃电赛赛题、被裸机驱动卡在编码器读取或PID震荡上、或者想搞懂“为什么别人的小车能稳你的总在抖”这份代码就是你该拆的第一份真实工程。它不是教学例程是真正上过赛场、扛过电磁干扰、被示波器抓过波形的硬核控制逻辑。2. 工程结构与核心模块拆解从main.c到pwm_driver.c看裸机如何把硬件资源拧成一股绳2.1 整体目录结构与编译约束说明项目采用标准Keil MDK-ARM v5.37环境构建无CMSIS-Pack依赖所有外设驱动手写。整个工程共12个源文件按功能分层如下├── Core/ // 内核调度与主循环 │ ├── main.c // 系统初始化、主任务调度入口 │ └── scheduler.c // 基于SysTick的1ms时间片轮询框架 ├── Drivers/ // 硬件抽象层 │ ├── adc_driver.c // 四通道红外传感器AD采集PA0-PA3 │ ├── encoder_driver.c // 正交编码器解析TIM2_CH1/CH2AB相 │ ├── pwm_driver.c // 双路电机PWM输出TIM3_CH1/CH2 舵机PWMTIM4_CH1 │ └── gpio_driver.c // 通用GPIO配置LED、蜂鸣器、模式切换按键 ├── Control/ // 控制算法实现 │ ├── pid_controller.c // 位置式PID封装含积分限幅、微分先行 │ ├── line_follower.c // 黑线识别与偏差计算滑动窗口中值滤波加权求和 │ └── motor_control.c // 电机闭环调速速度环外环电流环内环模拟 └── Config/ // 硬件参数与标定数据 ├── hardware_config.h // ADC参考电压、编码器线数、电机极对数等宏定义 └── tuning_params.h // PID参数、循迹阈值、刹车距离等可调常量提示所有.c文件均未使用#include stdio.hprintf被重定向至ITM_SendChar仅用于调试阶段正式固件中已注释。编译选项启用-O2优化且关闭浮点运算-mfloat-abisoft所有PID计算用Q15定点数实现避免FPU依赖。2.2 主循环与SysTick调度器设计原理main.c中不写死while(1)轮询而是构建轻量级任务调度器。关键在于SysTick_Handler每1ms触发一次更新全局毫秒计数器并标记任务就绪标志// Core/scheduler.c volatile uint32_t g_ms_ticks 0; static uint8_t task_flags[4] {0}; // 4个任务槽位 void SysTick_Handler(void) { g_ms_ticks; if (g_ms_ticks % 10 0) task_flags[0] 1; // 10ms任务循迹偏差计算 if (g_ms_ticks % 20 0) task_flags[1] 1; // 20ms任务PID运算PWM更新 if (g_ms_ticks % 100 0) task_flags[2] 1; // 100ms任务LED状态刷新 if (g_ms_ticks % 500 0) task_flags[3] 1; // 500ms任务按键扫描 }main()中只做初始化后进入死循环检查标志位执行对应任务// Core/main.c int main(void) { SystemInit(); RCC_Configuration(); GPIO_Init_All(); ADC_Init(); Encoder_Init(); PWM_Init(); while(1) { if(task_flags[0]) { line_follower_update(); // 更新当前偏差值 task_flags[0] 0; } if(task_flags[1]) { pid_calculate_and_output(); // 执行PID并写入PWM寄存器 task_flags[1] 0; } // 其他任务... } }这种设计规避了传统裸机轮询的CPU空耗又比RTOS更可控——电赛中你永远不知道下一个中断会不会被电磁干扰拉长几个微秒而固定周期的任务标记机制让时序可预测。我一般会把最敏感的PID运算放在20ms槽位确保每秒50次闭环更新既满足响应速度又留出余量处理突发中断。2.3 编码器正交解码与速度计算的硬核实现encoder_driver.c是整辆车的“运动感知神经”。它不依赖HAL库的回调函数而是直接操作TIM2的计数器寄存器在TIM2_IRQHandler中完成AB相边沿检测与方向判别// Drivers/encoder_driver.c extern volatile int32_t g_encoder_count; void TIM2_IRQHandler(void) { uint16_t sr TIM2-SR; uint16_t cnt TIM2-CNT; uint16_t ccr1 TIM2-CCR1; uint16_t ccr2 TIM2-CCR2; // 清除更新中断标志 if(sr TIM_SR_UIF) { TIM2-SR ~TIM_SR_UIF; // 读取当前AB相电平PA6CH1, PA7CH2 uint8_t a GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6); uint8_t b GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_7); // 正交解码状态机根据AB相变化更新计数值 static uint8_t last_state 0; uint8_t curr_state (a 1) | b; static const int8_t state_trans[16] { 0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0 }; g_encoder_count state_trans[(last_state 2) | curr_state]; last_state curr_state; } }速度计算不在中断里做而是在10ms任务中调用get_speed_rpm()函数用滑动窗口法消除抖动#define SPEED_WINDOW_SIZE 5 static int32_t speed_history[SPEED_WINDOW_SIZE] {0}; static uint8_t speed_idx 0; int16_t get_speed_rpm(void) { static int32_t last_count 0; int32_t delta g_encoder_count - last_count; last_count g_encoder_count; // 每10ms采样换算为RPMdelta * 600 / lines_per_rev int32_t rpm (delta * 600L) / ENCODER_LINES_PER_REV; // 滑动窗口中值滤波 speed_history[speed_idx] rpm; speed_idx (speed_idx 1) % SPEED_WINDOW_SIZE; // 中值滤波实现简化版实际用插入排序 int32_t temp[5]; for(int i0; i5; i) temp[i] speed_history[i]; qsort(temp, 5, sizeof(int32_t), cmp_long); return (int16_t)temp[2]; }这里的关键细节是不直接用TIM_GetCounter()读取寄存器值因为AB相边沿可能发生在计数器更新瞬间导致读到错误值而是用状态机累计变化量再结合定时采样计算速度。某次调试发现小车低速时编码器跳变最后定位到是PCB布线让A/B相走线长度差了8cm引入3ns延迟——状态机解码天然抗这种微小相位差而寄存器直读会误判方向。3. 循迹与PID控制算法详解从红外采样到舵机PWM一条黑线上的数学博弈3.1 四路红外传感器的数据融合策略line_follower.c中的line_follower_update()函数是循迹逻辑的核心。它不简单判断“哪一路亮”而是对四路AD值PA0-PA3做加权融合生成-100~100的偏差值// Control/line_follower.c #define IR_THRESHOLD 1800 // AD值阈值需实测标定 #define IR_WEIGHTS { -3, -1, 1, 3 } // 从左到右权重中心对称 int16_t g_line_deviation 0; void line_follower_update(void) { uint16_t ir_values[4]; for(int i0; i4; i) { ir_values[i] ADC_GetConversionValue(ADC1, i); // 依次读取PA0-PA3 } // 滑动窗口中值滤波每路独立 static uint16_t ir_window[4][5] {{0}}; static uint8_t ir_idx 0; for(int i0; i4; i) { ir_window[i][ir_idx] ir_values[i]; // 中值滤波略同上文speed_history实现 } ir_idx (ir_idx 1) % 5; // 二值化高于阈值为1检测到黑线否则为0 uint8_t ir_binary[4] {0}; for(int i0; i4; i) { ir_binary[i] (ir_values[i] IR_THRESHOLD) ? 1 : 0; } // 加权求和sum(weight[i] * binary[i]) int16_t weighted_sum 0; const int8_t weights[4] {-3, -1, 1, 3}; for(int i0; i4; i) { weighted_sum weights[i] * ir_binary[i]; } // 归一化到-100~100范围 g_line_deviation (weighted_sum * 100) / 6; // 最大权重和为633 }这个设计的精妙之处在于当黑线居中时左右两路同时触发-3*1 -1*0 1*0 3*1 0当黑线偏左左侧两路触发-3*1 -1*1 1*0 3*0 -4归一化后约-67偏右则为67。它天然抑制单点噪声——比如某路传感器被灰尘遮挡只要其他三路正常偏差值仍在合理区间。我见过太多学生用“四路全灭才认为脱线”的逻辑结果小车在反光地砖上疯狂甩尾就是因为没做加权容错。3.2 位置式PID控制器的嵌入式落地实现pid_controller.c封装了工业级PID参数但针对小车做了三项关键裁剪积分限幅防止长时间偏差积累导致舵机打满微分先行对设定值微分而非误差微分避免给定突变时的冲击输出饱和处理PWM占空比强制钳位在10%~90%之间保留安全裕度。// Control/pid_controller.c typedef struct { float Kp, Ki, Kd; float setpoint; // 设定偏差通常为0 float input; // 当前偏差来自循迹 float output; // 计算出的舵机PWM值 float integral; // 积分项 float last_input; // 上次输入用于微分计算 float out_min, out_max; // 输出限幅 } PID_Controller; PID_Controller steering_pid { .Kp 12.0f, .Ki 0.15f, .Kd 3.0f, .out_min 0.1f, // 10%占空比 .out_max 0.9f, // 90%占空比 }; float pid_calculate(PID_Controller* pid) { float error pid-setpoint - pid-input; float d_input pid-input - pid-last_input; // 积分项仅在输出未饱和时累加抗积分饱和 if(pid-output pid-out_min pid-output pid-out_max) { pid-integral error * 0.02f; // 20ms周期Ki已包含dt } // 微分先行对设定值微分此处setpoint恒为0故d_input为0 // 实际应用中若需跟踪动态路径可改为对setpoint微分 float derivative -d_input; float output pid-Kp * error pid-Ki * pid-integral pid-Kd * derivative; // 输出限幅 if(output pid-out_min) output pid-out_min; if(output pid-out_max) output pid-out_max; pid-output output; pid-last_input pid-input; return output; }注意Ki和Kd的数值已乘以采样周期dt0.02s所以代码中直接使用pid-Ki * pid-integral。这是嵌入式PID的常见做法避免每次计算都乘以浮点数。电赛中我们实测发现Kd3.0对小车转向阻尼效果最佳——太小则转向发飘太大则高频抖动。这个值必须在实车测试中调整仿真软件给的参数在真实电机惯性面前全是玄学。3.3 电机双闭环调速的软硬件协同设计小车后轮驱动采用双闭环外环是速度环PID内环是电流环通过采样电机驱动芯片的ISENSE引脚。motor_control.c中motor_set_speed()函数先计算目标PWM再叠加电流反馈补偿// Control/motor_control.c #define CURRENT_SENSE_PIN 5 // PA5接驱动芯片ISENSE #define CURRENT_MAX_MA 2000 // 电机最大允许电流 void motor_set_speed(int16_t target_rpm) { static int16_t last_target 0; int16_t current_rpm get_speed_rpm(); int16_t speed_error target_rpm - current_rpm; // 速度环PID参数已调好 static float speed_integral 0; float speed_output 0; speed_output 8.0f * speed_error 0.02f * speed_integral 1.5f * (speed_error - last_target); // 简化微分 // 电流环补偿若采样电流超限则降低PWM uint16_t current_adc ADC_GetConversionValue(ADC1, 5); float current_ma (current_adc * 3.3f / 4095.0f) * 1000.0f / 0.1f; // 0.1Ω采样电阻 if(current_ma CURRENT_MAX_MA) { speed_output * 0.8f; // 降功率20% } // PWM输出映射0~100rpm - 30%~70%占空比 uint16_t pwm_val 300 (uint16_t)(speed_output * 4.0f); if(pwm_val 300) pwm_val 300; if(pwm_val 700) pwm_val 700; TIM_SetCompare1(TIM3, pwm_val); // 写入TIM3_CH1 last_target target_rpm; }这里的关键洞察是电机堵转时电流飙升但速度环还傻乎乎加大PWM结果烧MOS管。加入电流采样后系统能在10ms内检测到异常并降功率相当于给电机加了“后悔药”。某次测试中小车撞墙后电机堵转电流瞬间冲到3.2A电流环立刻将PWM压到30%保住了驱动芯片——而纯速度环方案此时已冒出青烟。4. 常见问题排查那些让电赛选手凌晨三点还在示波器前抓狂的坑4.1 现象小车在直道上左右摇摆像喝醉一样晃原因PID参数中Kd过大或编码器信号受电机干扰。示波器抓TIM2的CH1/CH2波形发现AB相边沿有毛刺导致状态机误判方向速度计算跳变。解决在编码器信号线上加100nF陶瓷电容滤波靠近MCU引脚将Kd从5.0降到2.0观察振荡频率是否降低检查get_speed_rpm()中滑动窗口大小若设为1则完全无滤波必须≥3。4.2 现象循迹时遇到十字路口或断点小车直接冲出去不刹车原因line_follower_update()中二值化阈值IR_THRESHOLD设得太高导致黑线变浅时四路全灭g_line_deviation保持上一帧值PID继续按原偏差输出。解决在line_follower_update()末尾添加断线检测逻辑uint8_t all_off 1; for(int i0; i4; i) if(ir_binary[i]) all_off 0; if(all_off) { g_line_deviation 0; // 强制归零触发刹车逻辑 g_lost_line_counter; if(g_lost_line_counter 5) { // 连续50ms未检到线 motor_brake(); // 执行紧急制动 } } else { g_lost_line_counter 0; }实测标定IR_THRESHOLD在赛道不同光照下用万用表测AD值取中间值。4.3 现象下载程序后小车完全不动但LED闪烁正常原因pwm_driver.c中TIM3初始化时忘记使能TIM3的时钟RCC_APB1PeriphClockCmd(RCC_APB1PERIPH_TIM3, ENABLE)或GPIO复用功能未开启GPIO_PinAFConfig(GPIOB, GPIO_PinSource0, GPIO_AF_2)。解决用ST-Link Utility读取RCC-APB1ENR寄存器确认bit2TIM3EN为1用万用表测PB0/PB1引脚确认有3.3V电平变化若无检查GPIO_Init()中GPIO_Mode是否设为GPIO_Mode_AF_PP最暴力方法在PWM_Init()末尾加TIM_Cmd(TIM3, ENABLE)后立即while(1)卡住用逻辑分析仪看PB0是否有PWM波形。4.4 现象舵机转向迟钝转弯半径远大于预期原因舵机PWM频率设为50Hz20ms周期但TIM4的ARR值计算错误导致实际周期偏离。例如TIM4-ARR999时若时钟为36MHz则周期1000/36e627.8us远小于20ms。解决重新计算TIM4预分频PSC (SystemCoreClock / 50) / 1000 - 1即PSC (72e6/50)/1000 - 1 1439ARR 10001ms~2ms对应100~200用示波器实测PB6引脚确认高电平宽度在1000~2000us之间。4.5 现象电池电量下降后小车循迹精度明显变差原因AD参考电压随电池电压波动导致红外传感器阈值漂移。ADC1使用内部参考电压Vref VDDA而VDDA直连电池电压从4.2V掉到3.3V时同样黑线反射率对应的AD值从3000掉到2300。解决在hardware_config.h中定义#define USE_VREFINT改用内部1.2V基准或在adc_driver.c中加入电压补偿uint16_t vbat_adc ADC_GetConversionValue(ADC1, 16); // VREFINT通道 float vbat_v 1.2f * 4095.0f / vbat_adc; // 计算当前VDDA float ir_compensated ir_raw * (vbat_v / 3.3f); // 归一化到3.3V基准5. 二次开发实战从跑通到参赛三个必改参数与一个防翻车技巧5.1 必改参数一循迹阈值与权重的赛道适配电赛每年赛道材质不同——去年是哑光黑胶带今年换成亮面PVC反射率翻倍。直接沿用旧代码会导致四路全亮g_line_deviation恒为0。修改步骤将小车静止置于赛道中心运行adc_driver.c中的adc_debug_print()函数临时启用串口打印记录四路AD值正常应为左1800/左中2200/右中2200/右1800计算新阈值IR_THRESHOLD (min(左,右) max(左中,右中)) / 2本例中为(18002200)/22000权重调整若发现小车总向右偏说明右侧传感器灵敏度高将权重数组改为{-3, -1, 1.2, 2.8}微调平衡。我的习惯是每次换赛道先用万用表测各路传感器输出电压再换算AD值。比盲调参数快10倍且避免因环境光突变导致的误判。5.2 必改参数二PID参数的阶梯式整定法别信网上“Kp10,Ki0.1,Kd2”这种万能公式。真实整定流程先调Kp设KiKd0从小值如2.0开始逐步增大直到小车在直道轻微振荡再加Ki固定Kp从0.01开始加Ki观察振荡是否收敛若出现缓慢爬升则减小Ki最后Kd当Kp/Ki已稳定加Kd抑制超调注意Kd过大会引入高频噪声。表格记录实测数据某次校准KpKiKd表现8.000直道缓慢蛇形8.00.080转弯后回正变慢8.00.081.5转弯干脆直道稳定8.00.082.5转角处高频抖动从那以后我每次调PID都强制用示波器抓TIM4_CH1波形看舵机PWM是否平滑。如果波形像心电图一样起伏一定是Kd过大或编码器干扰——希望帮到你。5.3 必改参数三刹车距离的动态补偿终点白线检测不能只靠红外因为白线反光强易受环境光干扰。真实方案是用编码器累计里程当g_encoder_count TARGET_COUNT - 500时启动预刹车预刹车阶段将目标速度从200rpm线性降至50rpm进入TARGET_COUNT - 100后全制动。// 在pid_calculate_and_output()中插入 if(g_encoder_count TARGET_COUNT - 500) { int16_t ramp_down TARGET_COUNT - g_encoder_count; int16_t target_speed 200 - (ramp_down * 150 / 400); // 400脉冲内降150rpm if(target_speed 50) target_speed 50; motor_set_speed(target_speed); } if(g_encoder_count TARGET_COUNT - 100) { motor_brake(); // 硬件刹车短接电机两端 }5.4 防翻车技巧电源纹波监控与自动降频电赛现场开关电源质量参差电压跌落会导致ADC采样失真。我在main.c中加入电压监控任务// 每500ms执行一次 if(task_flags[3]) { uint16_t vdd_adc ADC_GetConversionValue(ADC1, 17); // VDDA通道 float vdd_v 3.3f * 4095.0f / vdd_adc; if(vdd_v 3.0f) { // 低于3.0V触发保护 SystemCoreClockUpdate(); // 切换到HSI降低主频保稳定 RCC_HSICmd(ENABLE); RCC_SYSCLKConfig(RCC_SYSCLKSource_HSI); } task_flags[3] 0; }这个技巧救了我们两次一次是裁判电源老化另一次是隔壁组大功率设备启停。电压一跌系统自动降频虽然速度慢了10%但保证了控制逻辑不死锁——电赛规则里停在终点线前比冲过线扣分少得多。本文还有配套的精品资源点击获取