☰
智能控制小车实战:STM32+PID闭环与传感器调试指南
2026/10/14 18:26:24 网站建设 项目流程

简介:面向电子设计竞赛参赛者的智能控制小车C语言程序包,提供了可复用的控制与调试框架。压缩包共4个文件,均为.c源代码,整体仅4KB,程序按功能模块拆分,包括主程序、矩阵输入、液晶显示、串口通信等部分,便于快速理解控制流程并在现有硬件上移植修改。已有722人浏览学习,适合备赛初期对照代码结构、梳理控制逻辑。内容涵盖GPIO读取传感器信号、PWM调速、PID闭环控制等小车常见控制环节,可支撑自动寻迹、避障等赛题核心功能;模块化划分既方便自行扩展,也为后续加入路径规划算法(如A*)留出接口。通过研读原始驱动与调度代码,还能掌握传感器数据解析、状态显示和上位机交互的实现思路,同时涉及嵌入式驱动与算法设计,适合在此基础上二次开发,有助于提高现场调试验证效率,是电赛智能车方向一份轻量实用的参考资料。

1. 智能控制小车程序:电赛赛题里最值得先啃的一块硬骨头

不管你是第一次接触电子设计竞赛,还是已经搭过几辆车但跑不稳,智能控制小车这道赛题始终是绕不开的入场券。它的难点不在单个模块,而在把C语言程序、传感器采集、电机驱动和决策逻辑塞进同一颗MCU里,让它们在几十毫秒内协同工作。我见过太多队伍卡在“车能走但走不直”“能巡线但一加速就飞”这类问题上,说到底不是硬件不行,而是程序架构和参数处理出了问题。这篇文章从一个实际可跑的C语言工程出发,把主控选型、外围电路、PID闭环、状态机切换和现场调试讲透,适合准备电赛赛题的学生,也适合刚入手智能车开发、想快速跑通一套完整流程的从业者。

2. 硬件选型与控制架构:为什么主流方案是STM32加灰度传感器

2.1 主控、电机与传感器的选型逻辑

智能控制小车的硬件方案,看似五花八门,实际在电赛赛题场景下,真正被验证过的高性价比组合就那几套。主控方面,Cortex-M3或M4内核的MCU是绝对主力,原因很直接:电赛要求现场写代码、现场调参,芯片资料全、库函数丰富、下载调试方便,出了问题能快速查到解决方案。8位单片机不是不能用,但遇到多路传感器采集加PID运算加无线通信同时跑的时候,资源紧张会让你怀疑人生。

电机部分,常见的坑在于只看空载转速而忽略扭矩。普通TT马达适合练习,但真要上赛道,建议用带编码器的直流减速电机。编码器的意义不是让你炫技,而是让速度闭环有据可依——没有编码器反馈的PID纯粹是盲调,有了编码器你才知道当前实际转速和目标转速差了多少,这直接决定了小车能不能走直线。电机驱动芯片选TB6612或者DRV8833这类集成H桥方案,比离散MOS管搭建省心得多,电流余量至少要留出电机堵转时的一倍以上。

传感器配置是另一个关键决策点。灰度传感器阵列做循迹是电赛赛题的经典考法,常见的有四路、五路、八路三种规格。四路够用但前瞻性差,过交叉线容易误判;八路采集数据丰富,但对安装精度要求高,差一点就整体偏移。我一般推荐五路灰度加一个超声波模块的组合,灰度管循迹,超声波管避障,两者通过状态机切换,覆盖赛道上的绝大多数情况。在选择灰度传感器模块时,注意看有没有电位器可调阈值,这比固定阈值的模块灵活得多,现场适应不同光照条件的能力完全不一样。

2.2 引脚分配与基础驱动代码框架

选型定了之后,第一件事不是写逻辑,而是把引脚分配固定下来。这里的教训是:别在代码里到处硬编码引脚号,先在头文件里统一宏定义。不同电赛赛题现场提供的赛道环境不同,你很可能需要临时换传感器位置,到时候只改一个配置文件就能适配,比在几十个文件里找引脚定义要舒服得多。

// pin_config.h #ifndef PIN_CONFIG_H #define PIN_CONFIG_H // 电机PWM与方向引脚 #define MOTOR_LEFT_PWM TIM1_CH1 // PA8 #define MOTOR_LEFT_DIR1 GPIO_PIN_1 // PA1 #define MOTOR_LEFT_DIR2 GPIO_PIN_0 // PA0 #define MOTOR_RIGHT_PWM TIM1_CH2 // PA9 #define MOTOR_RIGHT_DIR1 GPIO_PIN_3 // PA3 #define MOTOR_RIGHT_DIR2 GPIO_PIN_2 // PA2 // 编码器输入引脚 #define ENC_LEFT_A GPIO_PIN_6 // PA6 #define ENC_LEFT_B GPIO_PIN_7 // PA7 #define ENC_RIGHT_A GPIO_PIN_6 // PB6 #define ENC_RIGHT_B GPIO_PIN_7 // PB7 // 五路灰度传感器 GPIO 输入 #define GRAY_S1 GPIO_PIN_0 // PC0 #define GRAY_S2 GPIO_PIN_1 // PC1 #define GRAY_S3 GPIO_PIN_2 // PC2 #define GRAY_S4 GPIO_PIN_3 // PC3 #define GRAY_S5 GPIO_PIN_4 // PC4 // 超声波模块 #define ULTRASONIC_TRIG GPIO_PIN_8 // PB8 #define ULTRASONIC_ECHO GPIO_PIN_9 // PB9 #endif

这段配置的逻辑是把所有硬件相关的引脚变化收敛到一个头文件里。逻辑说明很简单:后面的驱动代码、应用代码都只引用这些宏名,不直接跟寄存器或GPIO端口打交道。参数说明:上面这个分配假设主控引脚充裕,如果你用的是小封装芯片,优先保证编码器引脚靠近定时器通道,因为编码器模式依赖定时器的硬件捕获,换到普通IO口上软件模拟会占用大量CPU时间。

驱动代码框架上,我习惯分四层:底层硬件驱动、模块驱动、策略层、主循环调度。很多新手把所有代码堆在一个main.c里,几百行下来根本没法调试。分层的核心价值在于,灰度传感器模块出问题,你只需要查那一个文件,不需要把电机和超声波代码翻一遍。主循环里只做三件事:采集传感器数据、更新状态机、执行决策输出,每轮循环控制在5到10毫秒以内,保证响应速度。

3. 核心C语言模块:PWM电机控制、编码器测速与PID闭环

3.1 定时器PWM输出与电机驱动板控制

电机控制的底层是PWM,PWM的底层是定时器。你要做的不是把某个引脚电平拉高拉低,而是让定时器按照设定的占空比连续输出脉冲。使用标准库配置PWM输出有几个关键步骤:使能定时器时钟、配置GPIO复用功能、设置定时器周期和比较值、使能输出通道。占空比的计算方式为比较值除以周期值,例如周期设为1000,比较值为600,对应占空比60%。

// motor_pwm.c - 基于STM32标准库的PWM初始化与设置 #include "stm32f10x.h" #include "pin_config.h" void Motor_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); // 方向引脚配置为推挽输出 GPIO_InitStructure.GPIO_Pin = MOTOR_LEFT_DIR1 | MOTOR_LEFT_DIR2 | MOTOR_RIGHT_DIR1 | MOTOR_RIGHT_DIR2; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); } void Motor_PWM_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; OCInitTypeDef OC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM1, ENABLE); TIM_TimeBaseStructure.TIM_Prescaler = 71; // 72MHz/(71+1) = 1MHz TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 999; // PWM频率 = 1MHz/(999+1) = 1kHz TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM1, &TIM_TimeBaseStructure); OC_InitStructure.TIM_OCMode = TIM_OCMode_PWM1; OC_InitStructure.TIM_OutputState = TIM_OutputState_Enable; OC_InitStructure.TIM_Pulse = 0; // 初始占空比0 OC_InitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM1, &OC_InitStructure); // 左轮PWM TIM_OC2Init(TIM1, &OC_InitStructure); // 右轮PWM TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_Cmd(TIM1, ENABLE); TIM_CtrlPWMOutputs(TIM1, ENABLE); // 高级定时器必须开主输出使能 } void Motor_SetSpeed(int left_speed, int right_speed) { // left_speed/right_speed范围 -1000 ~ 1000,负数反转方向 if (left_speed >= 0) { GPIO_SetBits(GPIOA, MOTOR_LEFT_DIR1); GPIO_ResetBits(GPIOA, MOTOR_LEFT_DIR2); } else { GPIO_ResetBits(GPIOA, MOTOR_LEFT_DIR1); GPIO_SetBits(GPIOA, MOTOR_LEFT_DIR2); left_speed = -left_speed; } // 右轮逻辑与左轮相同,代码略 TIM_SetCompare1(TIM1, left_speed); TIM_SetCompare2(TIM1, right_speed); }

这段代码的逻辑说明:PWM频率选择1kHz,在电机控制里比较均衡——频率太低,电机转动会有一顿一顿的噪声,影响低速线性度;频率太高,驱动芯片的开关损耗会上升,而且对定时器分频配置也不友好。占空比直接映射到速度值,范围定为0到1000,方便后续PID直接在这个量纲上做运算。参数说明:TIM_Period设为999而不是1000,是因为定时器计数从0开始,所以实际脉冲周期为1000个计数单位。TIM_Prescaler分频系数71配合72MHz主频,得到1MHz计数频率,再除以1000得到1kHz的PWM频率,这套参数匹配比较成熟。

有一个特别容易忽略的坑:TIM1是高级定时器,初始化之后必须调用TIM_CtrlPWMOutputs使能主输出,否则PWM波形根本不会出现在引脚上。这个问题在调试时非常隐蔽,因为代码编译运行都不报错,只有用示波器或逻辑分析仪才能发现引脚一直为低电平。

3.2 编码器捕获与速度计算

编码器测速的底层原理是利用定时器的编码器模式,硬件自动根据A、B两相脉冲的相位关系判断旋转方向并计数。这样做的好处是CPU完全不需要参与脉冲计数过程,计数器的值由硬件累加,CPU只在固定时间间隔去读取一次计数值,然后换算成实际转速。

// encoder.c - 编码器模式初始化与速度读取 #include "stm32f10x.h" #include "pin_config.h" int16_t last_count_left = 0; int16_t last_count_right = 0; float speed_left = 0.0f; float speed_right = 0.0f; void Encoder_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2 | RCC_APB1Periph_TIM3, ENABLE); // 左轮编码器:PA6/PA7 -> TIM3_CH1/CH2 // 右轮编码器:PB6/PB7 -> TIM4_CH1/CH2(部分芯片映射) GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_InitStructure.GPIO_Pin = ENC_LEFT_A | ENC_LEFT_B; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Prescaler = 0; // 编码器模式不分频 TIM_TimeBaseStructure.TIM_Period = 65535; // 16位计数上限,自然溢出 TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); 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(TIM3, &TIM_ICInitStructure); // TIM_Channel_2 配置与上面类似,代码略 TIM_EncoderInterfaceConfig(TIM3, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_Cmd(TIM3, ENABLE); } void Speed_Calculate(uint16_t dt_ms) { int16_t count_left = TIM3->CNT; int16_t delta_left = count_left - last_count_left; last_count_left = count_left; // 减速比1:30,单圈脉冲数13,转换为mm/s // 轮胎直径65mm,周长约204mm // 每脉冲对应距离 = 204 / (13*30*4) ≈ 0.13mm speed_left = (float)delta_left * 0.13f / (float)dt_ms * 1000.0f; }

代码逻辑说明:编码器接口模式下,定时器的计数器值会随着电机转动自动增减,正转加、反转减。读取两次读数之间的差值,再乘以每个脉冲对应的物理距离,就得到这一段时间内的平均速度。参数说明:TIM_ICFilter设为0x0F是为了给输入信号加一个滤波窗口,防止电机换向产生的毛刺信号被误计为有效脉冲——这个在PWM占空比较大时尤其重要,电机电流突变会产生干扰。dt_ms是两次计算之间的时间间隔,通常和主循环周期保持一致,保证速度数据的时效性。

一个踩过的坑是:编码器计数器和PID用的是同一个定时器中断,如果PID运行频率高于编码器读取频率,速度反馈会滞后一拍,导致系统看起来在震荡。解决办法是把编码器读取和速度计算放在同一个10毫秒定时器中断里,PID也以同样的周期执行,确保数据同步。

3.3 增量式PID在C语言里的落地

速度环和方向环必然是PID控的,这是智能小车的核心逻辑。位置式PID需要累计所有历史误差,容易积分饱和;增量式PID只输出最近几次误差的差值,计算量小且天然抗积分饱和,更适合单片机场景。增量式的输出量是本次控制量的增量,最终的控制量需要在外部累加。

// pid.c - 增量式PID实现 #include "pid.h" typedef struct { float Kp; // 比例系数 float Ki; // 积分系数 float Kd; // 微分系数 float target; // 目标值 float error_last; // 上一次误差 float error_prev; // 上上次误差 float output; // 当前输出 float output_max; // 输出限幅 float integral_limit; // 积分分离阈值 } PID_TypeDef; void PID_Init(PID_TypeDef *pid, float Kp, float Ki, float Kd, float output_max) { pid->Kp = Kp; pid->Ki = Ki; pid->Kd = Kd; pid->output_max = output_max; pid->error_last = 0; pid->error_prev = 0; pid->output = 0; } float PID_Update(PID_TypeDef *pid, float current) { float error = pid->target - current; // 积分分离:误差过大时关闭积分项,防止积分饱和 if (fabs(error) > pid->integral_limit) { pid->Ki = 0.0f; } float p_term = pid->Kp * (error - pid->error_last); float i_term = pid->Ki * error; float d_term = pid->Kd * (error - 2 * pid->error_last + pid->error_prev); pid->output += p_term + i_term + d_term; // 输出限幅 if (pid->output > pid->output_max) pid->output = pid->output_max; else if (pid->output < -pid->output_max) pid->output = -pid->output_max; pid->error_prev = pid->error_last; pid->error_last = error; return pid->output; }

代码逻辑说明:增量式PID的三个分量分别对应——比例项Kp*(error - error_last)表示误差的变化趋势,积分项Ki*error用于消除稳态误差,微分项Kd*(error - 2*error_last + error_prev)表示误差变化加速度,对抖动有抑制作用。输出在外部累加,这样即使长时间达不到目标,输出也只会缓慢爬升而不至于直接冲到限幅值。参数说明:integral_limit这个阈值是经验值,通常设为目标值的20%左右,例如目标车速500mm/s时阈值设为100,误差超过100就切断积分,避免上坡或大弯道时积分疯狂累加。

PID调参的落地步骤我后面会专门讲,这里先说一个重要的认知:Kp调响应速度,Ki调稳态精度,Kd调阻尼。Kp太大,小车会高频震荡;Ki太大,低速时会来回冲;Kd太大,转向会迟钝甚至反向。实际调参顺序永远是先Kp再Kd最后微调Ki,不要三者同时动。

4. 感知与决策:灰度寻迹、超声波避障与状态机

4.1 灰度传感器阵列的数据采集与边界判断

灰度传感器的原理是不同颜色表面对红外光的反射率不同,传感器模块输出模拟电压或数字电平。模拟输出的传感器更灵活,因为你可以通过ADC读取具体的电压值来判断当前灰度等级,而不是只有黑和白两个离散状态。电赛赛题里,赛道背景和线条颜色通常是给定的,比如白底黑线或蓝底白线,你需要在现场根据实际传感器返回值重新标定阈值,而不能用固定的出厂值。

// gray_sensor.c - 五路灰度传感器读取与线位置计算 #include "stm32f10x.h" #include "pin_config.h" // 传感器结构体:原始值 + 二值化结果 typedef struct { uint16_t raw[5]; uint8_t bin[5]; uint16_t threshold[5]; } GraySensor_TypeDef; GraySensor_TypeDef gray; // 读取五路传感器模拟电压值 void Gray_ReadRaw(void) { for (int i = 0; i < 5; i++) { // 实际使用ADC多通道扫描,这里省略ADC配置细节 gray.raw[i] = ADC_GetConversionValue(ADC1); } } // 二值化与线位置提取 // 返回 -2~+2:正值表示线偏右,负值表示线偏左,0表示线居中 int Gray_GetLinePosition(void) { int weight_sum = 0; int bin_sum = 0; for (int i = 0; i < 5; i++) { gray.bin[i] = (gray.raw[i] > gray.threshold[i]) ? 1 : 0; weight_sum += gray.bin[i] * (i - 2); bin_sum += gray.bin[i]; } if (bin_sum == 0) { // 全黑或全白,保留上一次位置,进入丢线状态 return GRAY_LOST_LINE; } // 位置 = 加权和 / 激活传感器数量,归一化到 -2~+2 return (weight_sum * 2) / bin_sum; }

代码逻辑说明:Gray_GetLinePosition用加权平均法计算线的位置偏移,中间传感器的权重为0,左边为负,右边为正,激活数量作为分母归一化。这样即使不同路段激活的传感器数量不同,位置值依然有可比性。GRAY_LOST_LINE是一个特殊返回码,表示传感器阵列完全没检测到线,这时候小车不能按正常循迹逻辑走,需要切换到搜索模式。

参数说明:threshold数组需要现场标定,标定方式很简单——把小车放在纯白背景上读一次原始值,放在黑线上读一次原始值,取两者中点作为阈值。这个操作现场用串口打印数值做一遍只需要两分钟,但能避免后续大量调参时间。另外一个实用技巧:左右两端的传感器阈值可以单独调低一点灵敏度,因为在弯道处线可能只进入传感器视野的一部分,灵敏度太低会过早丢线。

4.2 超声波避障模块与测距滤波

超声波测距的基本原理是发射声波脉冲,测量回波返回的时间,乘以声速再除以2得到距离。这个原理不难,难的是声波会被环境噪声和多重反射干扰。电赛现场经常有多个队伍同时调试,超声波串扰严重,表现为距离值突然跳变到几米又跳回来。解决思路有两个方向:一是给发射信号编码,发一串特定模式的脉冲而不是单个脉冲;二是在软件里做滑动窗口滤波或中值滤波。

// ultrasonic.c - 超声波测距与中值滤波 #include "stm32f10x.h" #include "pin_config.h" #define SONIC_SPEED_MM_US 0.34f // 声速 mm/us,约340m/s #define MAX_DISTANCE_MM 4000 #define FILTER_WINDOW 5 uint16_t dist_history[FILTER_WINDOW]; uint8_t dist_index = 0; // 发送触发脉冲,测量回波高电平持续时间 uint16_t Ultrasonic_ReadRaw(void) { GPIO_SetBits(GPIOB, ULTRASONIC_TRIG); Delay_us(20); GPIO_ResetBits(GPIOB, ULTRASONIC_TRIG); // 等待ECHO变高,超时返回0 uint16_t timeout = 0; while (!GPIO_ReadInputDataBit(GPIOB, ULTRASONIC_ECHO) && timeout < 10000) timeout++; if (timeout >= 10000) return 0; // 测量高电平持续时间 uint16_t duration = 0; while (GPIO_ReadInputDataBit(GPIOB, ULTRASONIC_ECHO) && duration < 30000) { Delay_us(1); duration++; } return duration * SONIC_SPEED_MM_US; } // 中值滤波:消除单点异常值 uint16_t Ultrasonic_Filtered(void) { uint16_t window[FILTER_WINDOW]; for (int i = 0; i < FILTER_WINDOW; i++) { window[i] = dist_history[(dist_index + i) % FILTER_WINDOW]; } // 简单冒泡排序取中值 for (int i = 0; i < FILTER_WINDOW - 1; i++) { for (int j = i + 1; j < FILTER_WINDOW; j++) { if (window[i] > window[j]) { uint16_t temp = window[i]; window[i] = window[j]; window[j] = temp; } } } return window[FILTER_WINDOW / 2]; }

代码逻辑说明:Ultrasonic_ReadRaw的核心瓶颈在于Delay_us(1)是软件延时,占用CPU时间,所以超声波测距不适合和PWM控制放在同一高频中断里执行。常见做法是把超声波测距放到主循环里以50Hz的频率调用,或者在定时器中断里标记一个“需要测距”的标志位,主循环看到标志位再执行。中值滤波的实现思路是保留最近5次测量值,排序后取中位数,这样即使某次测量被噪声污染,也不会对决策产生剧烈影响。

参数说明:MAX_DISTANCE_MM设为4000是因为电赛赛道的避障距离一般在20到50厘米之间,超出这个范围认为障碍物不存在,避免远处无关物体触发误避障。FILTER_WINDOW窗口大小需要权衡,太大响应变慢,太小滤波效果差,5是一个比较折中的值。

4.3 状态机:从单任务到多任务切换

有了循迹和避障两个功能,就必须引入状态机来管理它们。不用状态机的做法是写一堆if else嵌套,代码一多就变成意大利面条,改一个逻辑要翻半天。状态机的核心思想是:任何时候小车处于一个明确的状态,只有特定条件满足时才能切换到另一个状态。对智能小车来说,典型的状态包括循迹、避障、搜索、停止。

// fsm.c - 小车运行状态机 typedef enum { STATE_LINE_FOLLOW, // 循迹模式 STATE_OBSTACLE_AVOID, // 避障模式 STATE_SEARCH, // 丢线搜索模式 STATE_STOP // 停止模式 } CarState_TypeDef; CarState_TypeDef current_state = STATE_LINE_FOLLOW; void FSM_Update(GraySensor_TypeDef *gray, uint16_t distance) { switch (current_state) { case STATE_LINE_FOLLOW: // 前方障碍物小于30cm,切换到避障 if (distance < 300) { current_state = STATE_OBSTACLE_AVOID; } // 丢线超过500ms,切换到搜索 else if (gray->lost_time > 500) { current_state = STATE_SEARCH; } break; case STATE_OBSTACLE_AVOID: // 障碍物已通过且重新找到线,回到循迹 if (distance > 500 && gray->line_found) { current_state = STATE_LINE_FOLLOW; } break; case STATE_SEARCH: // 搜索到线,立即回循迹 if (gray->line_found) { current_state = STATE_LINE_FOLLOW; } break; case STATE_STOP: // 手动恢复或外部指令唤醒 break; } }

状态机的逻辑说明:每个状态只看自己关心的条件,循迹状态关注“什么时候该去避障”和“什么时候丢线”,避障状态关注“障碍是否已通过”,搜索状态关注“线是否重新出现”。这样做的好处是状态切换的条件清晰,不会出现两个逻辑互相打架。参数说明:distance < 300是避障触发阈值,distance > 500是解除阈值,两个阈值之间留了200mm的滞回区间,作用是防止小车在障碍物边缘反复横跳。这个滞回的设计非常关键,如果没有它,小车会在障碍物边缘来回切换状态,表现为车身抖动然后停在原地。

5. 调试避坑指南:传感器、电机、PID三大高频翻车点

5.1 现象:PWM输出正常但电机不转

有段时间我调试一台车,示波器看PWM波形完全正常,占空比也是预期值,但电机就是纹丝不动。检查了供电、接线、驱动板使能脚,最后发现是方向引脚配置出了问题。某些电机驱动板的使能脚是独立引脚,需要额外拉高,不能只靠PWM信号驱动。具体来说,我用的驱动板要求EN引脚为高电平,而初始化代码漏掉了这个引脚的配置,导致驱动芯片内部输出级完全关闭。

解决方法是:确认驱动板的数据手册,把所有使能引脚加入初始化函数,用一个宏定义统一管理。从那以后我检查电机不转的固定顺序是——先量驱动板供电,再量EN引脚电平,最后量PWM引脚波形。这三步能覆盖大约九成的不转问题。

5.2 现象:PID调节不收敛,小车原地高频抖动

PID调参时最常遇到的情况是Kp调大之后,小车开始左右高频摆动,声音听起来像在颤抖。这不是电机问题,是比例增益太大导致过冲,每轮控制都在朝目标值猛冲然后越过,形成周期振荡。我见过有人调了一个下午,Kp越加越大,以为加大能更快到达目标,实际越调越糟。

解决方法是:先把Kp降回原来的一半,然后观察小车是否稳定,稳定后再逐步加Kd增加阻尼,Kd可以抑制过冲,让系统更快收敛。Kp和Kd要配合着调,不能只动一个。一个参考策略是Kp每次增加20%,Kd每次增加40%到60%,同时观察运动轨迹,直到临界稳定后再把两个值都乘以0.8留出余量。

5.3 现象:巡线小车高速冲出赛道

小车在直道上跑得很好,一进弯道就飞出去,这是循迹类小车最常见的问题。原因往往是控制频率跟不上弯道信息变化。灰度传感器的布局决定了前瞻距离,前瞻越短,越晚发现弯道,留给控制系统的反应时间越少。当车速超过某个值后,感知延迟和控制延迟叠加,小车根本来不及转弯。

解决思路是两个方向并行:一是程序上把控制周期从20ms缩短到10ms,让每个控制周期内速度变化更小,转向更平滑;二是物理上调整传感器支架,让灰度阵列更靠前,增加前瞻距离。如果硬件不方便改,就只能降低入弯速度,在检测到左右传感器激活时立即设置一个限速标志,限制最大车速。

5.4 现象:超声波距离值跳变,避障乱触发

超声波数据在电赛现场特别容易受干扰。一次调试中,小车距离障碍物还有40cm,超声波却返回了120cm的值,避障完全没触发,直接撞上了障碍物。排查后发现是多台小车同时工作,声波串扰导致测距错误。单次测量不可靠,必须做滤波,这是铁律。

解决方法是做了两级防护:软件上使用中值滤波加滑动平均的组合——先取5次测量值的中位数,再对连续3个中位数取平均。硬件上在超声波模块周围加了一层海绵包裹,减少侧面声波干扰。这样处理之后,距离数据基本稳定在±2cm以内,满足避障要求。

6. 参数整定与实跑验证:让小车稳下来的三个习惯

6.1 速度环和方向环分开调,不要同时动

很多同学喜欢在车没跑起来的时候同时调速度PID和转向PID,结果车又抖又偏,完全无从下手。我的习惯是先在直道上把速度环调稳,把Kp、Ki、Kd三个值确定下来,让小车直行时速度波动控制在±5%以内。速度环稳定后,再切换到转向调试。转向环的输入不再是固定的速度差,而是灰度传感器计算出的线位置偏差,输出是左右轮的附加速度补偿。转向环调参时只用直线段,看小车能否走直;然后增加一个小弯道,看转向响应是否及时;最后才上大弯和连续弯。

6.2 每组参数都做记录,形成调参表

调参最忌讳的是拍脑袋改参数,不记录结果。我以前的教训是:一次调参花了一个多小时,感觉找到了合适的参数,结果重启后忘了保存,全部白调。后来我强制自己每调一组参数就填一张表,记录Kp、Ki、Kd、车速、弯道表现、直道表现和备注。这张表看起来原始,但积累了几十组数据后,你能总结出规律——比如弯道飞线时应该优先加大Kd还是降车速,不同速度段的最佳Kp范围是多少。

6.3 实跑验证的固定套路

参数确定后,不要直接跑全场,按三段式验证。第一段是直道往返,验证速度稳定性和两轮同步性,记录实际跑完同一段路的时间,连续三次误差应在正负2%以内。第二段是S弯和直角弯组合,验证转向响应和丢线恢复能力,如果丢线后重新找到线的时间超过1秒,说明搜索策略太保守。第三段才是带障碍物的完整赛道,验证状态机切换的可靠性。每次验证后回到电脑前看串口打印的传感器数据和状态切换记录,这比盯着车跑更直观。能做到这三条,小车基本具备稳定完成一条电赛赛道的底子了。

从那以后,我每次调完参数都会强制走一遍这三步,哪怕只是改了0.1的Kp也不会偷懒跳过实跑验证。这个习惯帮我在比赛现场省下了大量时间——现场一般只有一到两小时测试机会,没有平时积累的调参表和验证流程,基本就是看运气。希望这份拆解和这些踩过的坑,能让你在调车路上少走一段弯路。

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

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

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

立即咨询