简介:本资源是面向电子设计竞赛(电赛)参赛学生的智能控制小车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/CH2,AB相) │ ├── pwm_driver.c // 双路电机PWM输出(TIM3_CH1/CH2)+ 舵机PWM(TIM4_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.h",printf被重定向至ITM_SendChar仅用于调试阶段,正式固件中已注释。编译选项启用-O2优化且关闭浮点运算(-mfloat-abi=soft),所有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相电平(PA6=CH1, PA7=CH2) 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采样,换算为RPM:delta * 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 i=0; i<5; 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 i=0; i<4; 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 i=0; i<4; 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 i=0; i<4; 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 i=0; i<4; i++) { weighted_sum += weights[i] * ir_binary[i]; } // 归一化到-100~+100范围 g_line_deviation = (weighted_sum * 100) / 6; // 最大权重和为6(3+3) }这个设计的精妙之处在于:当黑线居中时,左右两路同时触发,-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的数值已乘以采样周期dt=0.02s,所以代码中直接使用pid->Ki * pid->integral。这是嵌入式PID的常见做法,避免每次计算都乘以浮点数。电赛中我们实测发现,Kd=3.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 i=0; i<4; 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寄存器,确认bit2(TIM3EN)为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频率设为50Hz(20ms周期),但TIM4的ARR值计算错误,导致实际周期偏离。例如TIM4->ARR=999时,若时钟为36MHz,则周期=1000/36e6=27.8us,远小于20ms。
解决:
- 重新计算TIM4预分频:
PSC = (SystemCoreClock / 50) / 1000 - 1,即PSC = (72e6/50)/1000 - 1 = 1439; ARR = 1000(1ms~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,本例中为(1800+2200)/2=2000; - 权重调整:若发现小车总向右偏,说明右侧传感器灵敏度高,将权重数组改为
{-3, -1, 1.2, 2.8},微调平衡。
我的习惯是:每次换赛道,先用万用表测各路传感器输出电压,再换算AD值。比盲调参数快10倍,且避免因环境光突变导致的误判。
5.2 必改参数二:PID参数的阶梯式整定法
别信网上“Kp=10,Ki=0.1,Kd=2”这种万能公式。真实整定流程:
- 先调Kp:设
Ki=Kd=0,从小值(如2.0)开始,逐步增大直到小车在直道轻微振荡; - 再加Ki:固定Kp,从0.01开始加Ki,观察振荡是否收敛,若出现缓慢爬升则减小Ki;
- 最后Kd:当Kp/Ki已稳定,加Kd抑制超调,注意Kd过大会引入高频噪声。
表格记录实测数据(某次校准):
| Kp | Ki | Kd | 表现 |
|---|---|---|---|
| 8.0 | 0 | 0 | 直道缓慢蛇形 |
| 8.0 | 0.08 | 0 | 转弯后回正变慢 |
| 8.0 | 0.08 | 1.5 | 转弯干脆,直道稳定 |
| 8.0 | 0.08 | 2.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%,但保证了控制逻辑不死锁——电赛规则里,停在终点线前比冲过线扣分少得多。
本文还有配套的精品资源,点击获取