STM32F103ZET6循迹小车竞速方案:灰度传感器与PID控制实战 简介面向智能车竞速与STM32嵌入式学习的循迹小车完整工程以72MHz主频的STM32F103ZET6作为核心控制器机械结构采用舵机加双编码电机通过一字排列的灰度传感器阵列识别黑线从而检测车身在直道、环岛、S弯和连续直角弯等赛道元素中的位置。程序以位置式PID作为主要控制算法同时驱动舵机转向与双电机差速实现稳定过弯与循迹配套的官方库与工程配置可帮助理解定时器、ADC、PWM输出等模块的综合应用。压缩包共207个文件约5.48MB核心文件包含C源码、H头文件、Keil工程配置及编译产物axf、hex、o、crf、d另有编译批处理与说明文本便于直接打开或对比分析。已有8435人学习下载适合竞赛备赛、课程设计或想深入掌握PID运动控制与灰度传感器逻辑的开发者参考。1. 循迹小车做到能竞速STM32F103ZET6、灰度传感器和PID一个都不能少做循迹小车的人很多但大多数作品在能走和能跑之间差了整整一个PID。我之前用红外对管做过一台减速电机配51单片机结果只能在白底黑线的慢速赛道上勉强跑完一圈换个光照环境就直接冲出赛道。后来换成基于STM32F103ZET6的循迹小车方案灰度传感器做多路采样PID控制全程介入再加上舵机配合电机的转向驱动分离设计才算真正摸到循迹竞速的门道入弯前减速、出弯加速、直道全速这些不是靠蛮力调出来的而是控制算法一点点试出来的。这个项目适合两类人一类是学完单片机想做完整项目的嵌入式爱好者另一类是准备参加循迹竞速、想把车调得更稳更快的参赛队伍。2. 硬件与工程结构先选对传感器再谈跑得快2.1 为什么是灰度传感器而不是红外对管这里有一个选型问题。红外对管和灰度传感器都能检测黑线但原理完全不同。红外对管靠红外发射管照射地面接收管看反射回来的红外光强弱黑线吸收红外光白色地面反射红外光输出的是数字量有线和无线之间跳变。灰度传感器的核心是光电二极管或光敏电阻检测可见光的反射强度输出模拟量能区分浅灰、深灰、灰底黑线这种渐变场景。在循迹竞速的场景下赛道可能是浅灰底、深灰底甚至是表面反光的PVC地面红外对管的数字量只有0和1遇到灰色地面就容易误判。灰度传感器输出的是0~4095的ADC值软件里可以做归一化处理用阈值把线和非线区分开还能按环境光做动态校准。所以我最后选了8路灰度传感器。没选5路是因为覆盖宽度不够车身稍有偏摆就会整体丢线没选16路是因为ADC轮询时间会拉长控制周期被拖慢。8路刚好覆盖车身宽度既能提供位置偏移信息又不会因为路数太多导致采样时间失控。2.2 STM32F103ZET6的资源和引脚分配很多人一上来就选F407甚至H7实际上对循迹小车来说F103ZET6已经是非常合适的选择。它主频72MHz内置512KB Flash和64KB SRAM跑一个PID控制循环、处理8路ADC采样、输出PWM到舵机和电机CPU占用率不到30%。ZET6是144引脚的大容量型号GPIO资源非常充裕8路灰度传感器可以全部挂在PA0~PA7上舵机接TIM1的PWM输出电机接TIM3的PWM输出还剩下一堆IO用来接按键、OLED屏幕、蓝牙模块和超声波传感器。引脚分配如下表这是整个工程的基础改任何一路都要回来核对这里。外设引脚定时器/外设资源说明灰度传感器PA0~PA7ADC1 通道0~78路模拟输入DMA循环采样舵机PA8TIM1_CH150Hz PWM周期20ms电机PWMPA6、PA7TIM3_CH1、CH210kHz PWM直流通断控制编码器PB6、PB7TIM4_CH1、CH2AB相正交脉冲输入OLEDPB8、PB9I2C1128x64调试用实时数据显示按键PE0GPIO 输入模式切换、触发标定蜂鸣器PE1GPIO 输出上电提示、丢线报警最关键的是F103的生态成熟。整个驱动层的代码全部基于标准外设库编写网上资料一抓一大把遇到问题好查。用更高级的芯片对最终圈速没有本质提升反而会引入DMA配置、时钟树复杂化这些额外成本。我的建议是F103ZET6起步硬件瓶颈真到了再换芯片不要一开始就把板子换成自己完全不熟的平台。2.3 舵机加电机转向与驱动分离的竞速选型关键词里提到舵机加电机这是循迹竞速里一个很有争议的选型。双电机差速方案也很常见两个电机分别驱动左右轮通过调节两边转速差来转弯。它的优点是结构简单、造价低缺点是转弯时车身重心会偏移而且电机调速的响应速度有限高速入弯时容易转向不足甚至打滑。舵机加电机方案则是转向和驱动分离一个舵机控制转向轮转角一个电机独立驱动车身前进。这样转弯时车身姿态更稳定。舵机的响应速度远快于电机调速在高速过弯时能精确控制转角代价是机械结构复杂一点需要一个转向机构调试时也多了一个舵机中位标定的步骤。我做这个项目时用的就是舵机加电机方案这也是很多竞速车队的标准配置。前轮是舵机控制的转向轮后轮是电机驱动的传动轮前后重量分配大约4:6保证加速时车头不抬、刹车时不点头。另外注意选择金属舵机臂和球头拉杆塑料臂在高速转向时容易变形转向角度就会不准确。2.4 工程目录结构硬件和算法分开管理整个工程基于STM32标准外设库在Keil MDK下编译。核心目录结构如下CarProject/ ├── Core/ │ ├── main.c │ └── stm32f10x_it.c ├── HARDWARE/ │ ├── LED/ │ ├── OLED/ │ ├── Motor/ │ ├── Servo/ │ ├── GraySensor/ │ └── Encoder/ ├── CONTROL/ │ ├── pid.c │ └── pid.h ├── SYSTEM/ │ ├── delay/ │ ├── sys/ │ └── usart/ └── USER/ ├── main.c └── stm32f10x_conf.h这个目录把硬件驱动和算法分成两个大块。HARDWARE下面是底层驱动每个外设一个文件夹CONTROL下面单独放PID算法互不依赖。main.c里做初始化然后进主循环调用。实际工程里还有中值滤波和OLED显示调试信息的代码OLED非常重要——调PID的时候没有实时数据输出全靠猜是调不出来的。我一般把灰度原始值、位置偏移、PID输出、速度这四个量在OLED上轮换显示按一下按键切一页。2.5 电源设计电机供电与逻辑供电分开电源是整个项目里最容易翻车的地方却经常被忽略。电机启动瞬间电流很大如果和MCU共用一路电源瞬间压降会把STM32的3.3V拉低到复位门槛以下表现为车一动就重启。这个项目的电源方案是两路独立7.4V锂电池出来后一路经过稳压降压到5V给电机驱动模块和舵机供电另一路再降压到3.3V给STM32和传感器供电。电机的大电流完全不走逻辑电路的地线地线在电池负极处单点汇合避免形成地环路。灰度传感器的供电最好单独从3.3V稳压器的输出端取不要和舵机共一份5V。舵机在高速转向时会有明显的电流尖峰这个尖峰会在5V上制造纹波如果传感器的参考电压跟着抖动ADC采集到的灰度值就会漂移位置计算也会跟着出错。我在这上面吃过亏后来给传感器电源加了一个100uF电解电容和一个0.1uF陶瓷电容并联纹波才压下去。3. 核心代码拆解从灰度采样到PID输出3.1 灰度传感器DMA采样与位置归一化灰度传感器读取是最基础的一层。8路传感器接在PA0~PA7上这8个引脚正好是ADC1的通道0~7可以轮询采样也可以开DMA。我最终采用的是DMA循环采样因为8路轮询在72MHz主频下虽然只要几十微秒但在主循环里频繁调用会让PID周期抖动而DMA采样全程不占用CPU时间。// gray.c - 灰度传感器DMA采样初始化 #include gray.h #define GRAY_ADC_CH_NUM 8 static uint16_t gray_raw[8] {0}; // DMA搬运的原始ADC值 void Gray_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; DMA_InitTypeDef DMA_InitStructure; // 使能ADC1和DMA1时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // 配置PA0~PA7为模拟输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStructure); // ADC1通道0~7扫描模式连续采样 ADC_InitStructure.ADC_Mode ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode ENABLE; ADC_InitStructure.ADC_ContinuousConvMode ENABLE; ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel GRAY_ADC_CH_NUM; ADC_Init(ADC1, ADC_InitStructure); // DMA1通道1从ADC1数据寄存器搬运到gray_raw数组 DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(ADC1-DR); DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)gray_raw; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize GRAY_ADC_CH_NUM; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; // 循环模式 DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel1, DMA_InitStructure); // 设定8个通道的采样顺序和采样周期 ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 4, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_4, 5, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_5, 6, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_6, 7, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_7, 8, ADC_SampleTime_55Cycles5); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); }这段代码的关键是DMA循环模式。ADC1每转换完一轮8个通道DMA自动把数据搬运到gray_raw数组主循环里随时读最新值不需要等待转换完成。这里的DMA_PeripheralInc_Disable表示外设地址不自增始终指向ADC1-DR内存地址自增依次填入8个数组元素。采样周期配55.5个周期在72MHz下大约0.77微秒一个通道8路一轮不到7微秒对主循环毫无压力。位置归一化是下一步的关键。灰度传感器在黑色线上ADC值偏低黑线吸收光在白色地面偏高。把8个值变成一个有符号的位置偏移量范围-100到100负值代表偏左正值代表偏右0就是正中。// gray.c - 计算加权位置偏移 int8_t Gray_GetPosition(void) { const uint16_t threshold 1500; // 阈值上电标定得到 int16_t weighted_sum 0; int8_t active_count 0; for (int i 0; i 8; i) { if (gray_raw[i] threshold) { // 这一路检测到黑线 // 权重左侧为负右侧为正 weighted_sum (i - 3) * 10; active_count; } } if (active_count 0) { return -128; // 丢线标志 } return (int8_t)(weighted_sum / active_count); }这里有个重要细节位置偏移用加权平均而不是简单取检测到黑线的那一路索引。如果只是拿索引当作位置在两条线之间的过渡区域数值会跳变PID输入信号不连续输出就会抖动。加权平均之后车身稍微偏左时左侧传感器权重更大位置偏移平滑变化PID的控制量也稳定。threshold这个阈值不用死记上电按一下按键让小车原地转一圈采集白底和黑线的ADC值以两者中间偏黑线一侧的25%分位点作为阈值这样给噪声流出裕量后续在4.5节还会细说。3.2 双闭环PID位置环调速、舵机直接映射PID控制是核心中的核心。循迹竞速的PID控制我采用双闭环结构内环是速度环用编码器反馈实际车速控制电机PWM输出外环是位置环输入是灰度位置偏移输出的是目标速度。位置环的输出不是直接去转舵机而是去修改速度环的期望速度。这样设计有一个直接的好处当车偏离赛道时系统不是立刻打死方向而是通过减速给舵机留出足够的响应时间完成转向。// pid.h - PID结构体 typedef struct { float kp; float ki; float kd; float target; // 目标值 float feedback; // 反馈值 float error; // 误差 float last_error; // 上次误差 float integral; // 积分累加 float output; // 输出 float out_max; // 输出限幅 } PID_t; extern PID_t pid_position; // 位置环 extern PID_t pid_speed; // 速度环// pid.c - 位置式PID计算 float PID_Calc(PID_t *pid, float target, float feedback) { pid-target target; pid-feedback feedback; pid-error target - feedback; // 积分分离误差大时不积分防止积分饱和 if (pid-error 50 pid-error -50) { pid-integral pid-error; } else { pid-integral 0; } // 积分限幅 if (pid-integral pid-out_max) pid-integral pid-out_max; if (pid-integral -pid-out_max) pid-integral -pid-out_max; // PID公式比例 积分 微分 pid-output pid-kp * pid-error pid-ki * pid-integral pid-kd * (pid-error - pid-last_error); // 输出限幅 if (pid-output pid-out_max) pid-output pid-out_max; if (pid-output -pid-out_max) pid-output -pid-out_max; pid-last_error pid-error; return pid-output; }这个PID实现有几个细节值得说明。第一积分分离误差超过50时不对积分项累加这是防止小车在起点或丢线状态下积分值一路飘到限幅值回到赛道时系统剧烈震荡。第二输出限幅和积分限幅用的是同一个值即使积分饱和输出也不会超过执行器能承受的范围。第三用位置式PID而不是增量式因为执行器是舵机和电机需要的本来就是绝对PWM占空比不是变化量。主循环里的整体调用逻辑// main.c - 主循环中的控制流程 while (1) { // 1. 获取灰度位置偏移 int8_t pos Gray_GetPosition(); if (pos -128) { // 丢线处理保持上一次舵机角度降低车速等待恢复 SetMotorSpeed(30); delay_ms(5); continue; } // 2. 位置环PID输出用于调整速度目标 float pos_out PID_Calc(pid_position, 0, pos); // 位置偏离越大目标速度越低 float speed_target SPEED_MAX - fabs(pos_out) * SPEED_GAIN; if (speed_target 0) speed_target 0; // 3. 速度环PID float current_speed Encoder_GetSpeed(); float pwm_out PID_Calc(pid_speed, speed_target, current_speed); // 4. 转向位置偏移直接映射舵机角度 uint16_t servo_angle SERVO_MID (int16_t)pos * SERVO_GAIN; SetServoAngle(servo_angle); // 5. 输出PWM驱动电机 SetMotorPWM(pwm_out); delay_ms(5); // 控制周期5ms }这个控制策略是典型的竞速思路位置环PID的输出不是直接转舵机而是去调整速度目标。车偏离线时位置环输出大速度目标被压低车减速过弯车在正中央时位置环输出接近0速度目标接近最大值直道全速。舵机角度直接由位置偏移映射不经过PID——这是很多竞速车队采用的简化做法因为舵机响应本来就快再加PID反而产生相位滞后车头会出现持续摆动。真正的高速过弯靠的是位置环PID参数和舵机映射系数的配合。3.3 电机与舵机驱动PWM频率的选择细节电机驱动用的是直流有刷电机加TB6612模块PWM频率选10kHz。这个频率的选择有两方面考虑高于20kHz会进入人耳的可听范围产生刺耳噪音低于10kHz时电机电流纹波大低速时会嗡嗡作响。舵机是9g数字舵机PWM频率固定50Hz周期20ms占空比0.5ms~2.5ms映射0~180度。// motor.c - 电机PWM初始化频率10kHz void Motor_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // PA6、PA7复用为TIM3的CH1、CH2 GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 10kHz PWM72MHz / 7200 10kHz TIM_TimeBaseStructure.TIM_Period 7200 - 1; TIM_TimeBaseStructure.TIM_Prescaler 1 - 1; TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 0; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM3, TIM_OCInitStructure); TIM_OC2Init(TIM3, TIM_OCInitStructure); TIM_Cmd(TIM3, ENABLE); }注意TIM_Period设为7200-1TIM_Prescaler设为1-1意味着预分频器不分频计数器直接在72MHz下计数每计7200个数触发一次更新事件PWM频率正好10kHz。这里我特意把周期做大到7200占空比的调节分辨率是1/7200远高于常规做法里周期为100的分辨率。电机调速需要精细调节占空比分辨率不够的话速度会一顿一顿的尤其在低速阶段特别明显。舵机控制部分同样有讲究// servo.c - 舵机初始化与角度设置 #define SERVO_MID 150 // 中位对应1.5ms #define SERVO_MIN 50 // 0.5ms - 0度 #define SERVO_MAX 250 // 2.5ms - 180度 void Servo_Init(void) { 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; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 20ms周期10us x 2000 20ms TIM_TimeBaseStructure.TIM_Period 2000 - 1; TIM_TimeBaseStructure.TIM_Prescaler 720 - 1; // 72MHz/720 100kHz, 即10us 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 SERVO_MID; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM1, TIM_OCInitStructure); // 高级定时器必须先开启主输出 TIM_CtrlPWMOutputs(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE); } void SetServoAngle(uint16_t pulse) { // pulse直接是脉宽折算值50~250 TIM_SetCompare1(TIM1, pulse); }舵机有两个坑。第一个坑是TIM1是高级定时器初始化之后默认不输出PWM必须手动调TIM_CtrlPWMOutputs(TIM1, ENABLE)开启主输出很多人在这里翻车示波器看PA8引脚永远是低电平。第二个坑是舵机实际关心的是高电平持续时间而非占空比百分比所以代码里SERVO_MID150对应的脉宽是150×10us1.5ms这是舵机的中位。写代码时一定要换算成脉宽值别直接用百分数替代否则舵机角度会完全对不上。4. 循迹调试中的避坑指南参数标定和PID调参的血泪经验4.1 直接照搬PID参数车头左右狂摆现象从网上查了一套PID参数按原样填进代码跑起来车头左右狂摆要么甩出赛道要么原地抽搐。原因PID参数完全由系统特性决定。不同车重、不同电机扭矩、不同舵机响应速度同一套参数的表现天差地别。网上的参数是别人在特定环境和车架下标定出来的直接照搬等于盲调。解决先做系数整定把Ki和Kd清零只留Kp从很小的值开始加每加一次在直道上跑一遍。观察到车在直线上做等幅振荡时说明Kp到了临界值。然后把Kp取临界值的0.6倍再加入Kd消除振荡最后加Ki消除稳态误差。我在这个项目上花了整个下午才从Kp1.8、Kd0.6、Ki0.01调到单圈15秒的成绩。别指望一次调好PID调参是个迭代过程一次只改一个参数改完就在同一段赛道上重复测试三轮取平均成绩。4.2 丢线之后小车放飞自我现象过急弯时车冲出赛道或者赛道接口处有断线车像失去理智一样直线狂奔怎么叫都不回头。原因灰度传感器丢线后8路全检测不到黑线Gray_GetPosition返回-128。如果没有做丢线处理主循环就会一直按丢线标志做事电机按上一轮的逻辑输出车自然失控。解决加入丢线检测和保持逻辑。实际实现是当8路灰度值全部高于阈值的连续计数超过10次判定为真正丢线然后保持上一次有效的舵机角度同时把电机速度降到一个安全的直行值。注意区分正常丢线和弯道丢线弯道丢线通常发生在高速过弯车头已经摆出赛道保持上一次舵机角度反而能让车自己绕回来。测试丢线逻辑的方法是拿手在传感器上方挡一下模拟丢线看车是保持方向还是乱转。提示丢线判断一定要加连续计数单次丢线很可能是阈值附近的传感器抖动造成的假丢线直接响应会导致控制不平滑。4.3 OLED上位置值跳变但车在直道上走得好好的现象OLED上显示的位置偏移在-20到20之间跳来跳去但车在直道上运行稳定没看出异常。原因这是正常噪声不是bug。灰度传感器的ADC值本身有波动阈值附近的传感器偶发翻转会让位置值有小幅跳动。但如果跳动幅度超过30甚至更大那就是阈值标定有问题。解决给原始ADC值加中值滤波窗口大小5能有效滤掉单点尖峰噪声。同时注意阈值不要取在白底和黑线ADC值的中间点要稍微偏向黑线一侧一些给白底方向的噪声留出裕量。我标定阈值的方法是把车放在黑线上记录所有路的ADC值再放在白底上记录一次取两个值的25%分位点作为阈值而不是简单取平均值。这样即使白底反射率有些波动也不至于误判。4.4 PWM有输出但电机不转现象程序跑起来后用示波器看TIM3的PA6引脚有PWM波形但电机就是不转用手摸电机壳也没震动。原因先说电源问题。电机的启动电流很大瞬时可达几安培会把3.3V逻辑电压拉低MCU复位或者电机驱动模块的输入逻辑电平低于高电平门槛驱动模块直接不动作。电池电压不足时多发表现为车上电瞬间OLED闪一下或蜂鸣器响一声然后整机重启。如果电压正常再看使能引脚TB6612的STBY引脚必须拉高EN引脚的高电平使能也不能省。解决排查时先用万用表量电池电压7.4V锂电池空载不低于7.0V再用示波器量3.3V的纹波开关电源的纹波应控制在100mV以内。电压没问题就查驱动模块的使能脚我在这上面浪费了半小时——代码里GPIO配置写了一大堆但忘了GPIO_SetBits把STBY拉高电机自然是纹丝不动的。从那以后每次初始化外设我都要先检查所有使能引脚是否被正确置位。4.5 室外强光下灰度传感器全部失效现象把车拿到户外阳光下跑灰度传感器输出的ADC值整体上移黑线检测不到车直接在白色地面上乱走。原因灰度传感器对环境光敏感。户外阳光直射时黑色线的反射率也会升高ADC值整体抬升原来标定的阈值就失效了。部分传感器模块自带环境光抑制电路但低成本模块通常没有。解决硬件上给传感器加一个黑色遮光罩只留底部一条缝看地面减少侧向入射光的干扰。软件上改用动态阈值标定——上电后让小车原地转一圈采集一段ADC数据取最大值和最小值的中间偏黑线分位作为本次运行的阈值。这个动态标定不只解决户外问题在室内不同色温的灯管下也适用。代码里加一个Calibrate()函数按下按键开始自动标定OLED显示OK后才允许发车。5. 二次开发从循迹到竞速的三步改造思路项目跑通之后下一步怎么改如果奔着循迹竞速去核心就两个字提速。当前这套灰度传感器加位置环PID方案速度上限大约在1.5m/s再快就压不住弯道。很多人第一反应是换更强的电机我的经验是先别急着动硬件。先做一件事把主循环控制周期从5ms降到2ms。控制周期越短系统越早感知弯道转向响应就越及时。我实测把周期从5ms压到2ms弯道通过速度上限提升了三成左右代价只是CPU占用率高了几个点完全在F103的承受范围内。第二件事是给舵机加前馈。低速时位置偏移直接映射舵机角度没有问题但高速过弯时舵机转向产生的侧向惯性会让位置偏移瞬间反向纯PID反馈追不回来。常见做法是加前馈控制用灰度位置的一阶微分作为前馈量// 前馈控制位置变化率叠加到舵机角度 static float last_pos 0; float pos_diff (float)(pos - last_pos); last_pos pos; uint16_t servo_angle SERVO_MID (int16_t)pos * SERVO_GAIN (int16_t)(pos_diff * FEEDFORWARD_GAIN);位置变化率代表车正在以多快的速度偏离中心线在入弯之前就给舵机一个额外转角帮助车头提前切入弯道。FEEDFORWARD_GAIN先在直道上验证不会产生额外转向因为直道位置变化率接近0然后在标准弯道上逐步加大直到入弯姿态变得顺滑。第三件事是加编码器闭环。如果车上还没有编码器建议加装一对用STM32的定时器编码器接口模式读AB相脉冲不需要外部中断。有了编码器速度环从开环变成真正的闭环车在上下坡、电池电压跌落时不会因为PWM不变而降速这个差距在长赛道上会被放大得很明显。硬件方面别忘了检查电池和稳压器高速跑的电流远大于匀速巡航稳压器瞬间压降会把MCU复位我最后把电机供电和逻辑供电分开成两路独立电源才解决了高速偶发死机的问题。最后一个习惯每次调参前先把旧参数备份尤其是PID三个系数和舵机中位值。把它们集中在config.h里改动前用注释保留旧值赛道成绩变差就一键切回。有一次我把Kp从2.0改到3.5单圈成绩从18秒掉到23秒只花10秒就回滚了。从那以后我每次调参都强制走一遍备份-修改-测试-回滚的流程不管改动看起来多小。希望帮到你。本文还有配套的精品资源点击获取