☰
STM32智能小车实战:从PWM调速到模块协同开发
2026/10/21 7:25:44 网站建设 项目流程

简介:基于STM32F103ZET6单片机的智能小车设计文档,系统梳理了从硬件电路到软件编程的完整流程,覆盖PWM调速、红外循迹、红外避障、超声波避障、障碍物跟随、红外遥控等核心功能,并配有硬件电路设计说明、软件代码解析和程序流程图。全文共65页、约15820字,以单个doc文件打包,压缩包约34.15MB;文档先介绍STM32F103ZET6的组成特点与引脚功能,再按方案设计、程序设计、实验验证的顺序展开,整体结构清晰,便于按模块查阅。作者根据实际开发过程撰写,不仅讲解了电机驱动电路、传感器接口与电源管理等硬件设计要点,还结合Keil开发环境和mcuisp下载工具介绍了程序编写、烧录与功能验证的完整流程,对PWM占空比控制、多传感器协同判断、避障与跟随策略等细节也做了展开。目前已有1357人学习下载,尤其适合单片机初学者、智能小车爱好者以及需要系统完成同类项目的学生对照学习。

1. 光会点灯不会开车:这辆STM32F103ZET6小车真正的难度在模块协同

如果说点灯是单片机的 Hello World,那智能小车就是把 GPIO、定时器、中断、串口全部拉出来遛一遍的综合演练。这辆以 STM32F103ZET6 为主控的小车,功能清单列出来很唬人:PWM 调速、循迹、避障、跟随、红外遥控。但真正动手做过的朋友都知道,单个模块单独调都好好的,一合到一起就出各种幺蛾子——电机一转单片机复位、循迹阈值上午能用下午失灵、超声波偶尔读一个离谱距离、遥控按一下触发两次。这篇文章顺着这套小车的完整方案,从硬件选型、PWM 调速、四个功能模块的实现,一路写到调车现场最常见的坑,最后给一套用串口日志做整机验证的方法。适合还在纠结器件选型、或者已经焊好板子但功能合不到一起的开发者。

2. 硬件架构与器件选型:先盘清楚一块 ZET6 和它的外设怎么分工

2.1 主控资源盘点:512KB Flash、64KB SRAM、8 个定时器够怎么分

STM32F103ZET6 属于增强型系列,72MHz 主频,512KB Flash,64KB SRAM,LQFP144 封装,外设资源在小车这个量级里属于“富余但不乱花”的状态。我的习惯是先画一张资源分配表,把每个功能用到的外设钉死,再开始写代码,否则后面经常会遇到定时器冲突、引脚被复用这种低级但折磨人的问题。

跑这辆小车,实际用到的资源其实很克制:TIM3 输出两路 PWM 驱动左右电机,TIM2 给超声波测距做微秒级计时,TIM4 给红外遥控解码测脉冲宽度,USART1 接串口模块打印调试日志,剩下的 GPIO 分配给灰度循迹传感器、红外接收头、电机方向控制引脚。Flash 和 SRAM 在这里根本用不满,主要余量消耗在调试阶段的 printf 重定向和可能加的 OLED 显示驱动上。

有个容易被忽略的点是,STM32F103ZET6 的引脚非常多,但很多引脚默认被 JTAG 占用。PB3、PB4 默认是 JTDO、NJTRST 功能,如果直接把灰度传感器接到这四个脚,会发现输入怎么读都是异常。要么在代码里先关掉 JTAG 释放引脚,要么一开始选型时就避开这几个引脚。我一般直接用 GPIO_Remap(SWJ_Disable) 关掉,省事。

2.2 电机驱动与供电:L298N 和 TB6612 怎么选,电源怎么分

PWM 调速最终作用在电机驱动芯片上。L298N 和 TB6612 是最常见的两个选择,它们的区别对小车这种应用影响很大。L298N 便宜、堵转电流大、耐操,但内部是三极管 H 桥,饱和压降有 2V 左右,意味着 7.4V 电池电压到电机两端只剩 5V 多;TB6612 是 MOS 管 H 桥,压降小很多,同样电压下电机转速更接近空载值,而且体积小、支持更高的 PWM 频率。

从调试体验来说,我建议直接上 TB6612,L298N 留给只做开关控制的项目。TB6612 的逻辑电源和电机电源分开,VM 接电池正极,VCC 接 3.3V 或 5V 逻辑电平,STBY 引脚拉高才能工作,AIN1/BIN1 控制方向,PWMA/PWMB 接 PWM 信号。值得注意的细节是,TB6612 的逻辑输入可以直接用 3.3V 电平,不需要额外电平转换,接线比 L298N 干净很多。

供电是这辆车最容易翻车的部分。我的做法是动力电源和逻辑电源彻底分开:7.4V 锂电池直接给电机驱动模块供电,降压到 5V 后给单片机系统供电,整个系统共地。电机启动瞬间电流可以到 2A 以上,如果单片机和电机共用一包电源,电压跌落会直接触发单片机复位,这个现象在第五章展开讲。降压方案我常用 MP1584 这类 DC-DC 模块,效率高发热小,AMS1117 在这种场合容易发热掉压。

2.3 传感器选型:循迹、避障、跟随、遥控各自用什么模块

循迹模块常用 TCRT5000 反射式光电传感器,市面上有集成的 4 路或 5 路循迹模块,每一路输出模拟量也输出经过比较器处理的数字量,板上电位器可以调黑白阈值。选集成模块比自己搭运放比较电路省心很多,误差主要在传感器离地高度——1cm 左右是最稳的,太高会识别不到黑线,太低会刮到地面。

避障模块最常见的是 HC-SR04 超声波,测距范围 2cm 到 400cm,精度在 3mm 量级,对小车避障完全够用。它有两个坑,后面详细说:一个是 ECHO 回波引脚是 5V 电平,直接接 STM32 引脚有风险;另一个是测距没有超时保护时,前方无障碍会让程序卡死在等待回波上。

跟随功能有两种常见做法:一种是继续用超声波,在车头左右各装一个,通过两个距离的差值判断偏航;另一种用 GP2Y0A21 这类模拟量输出的红外测距模块,输出 0~3V 电压,接 STM32 的 ADC 读取。我更推荐双超声波方案,因为红外测距模块在阳光下会受环境光干扰,室内还好,在教室窗边调车时读数会飘。

红外遥控部分用一个 HS0038B 接收头,它是集成的 38kHz 载波接收解调模块,输出引脚平时为高,收到有效载波信号时拉低。STM32 的 EXTI 外部中断配合一个定时器就能解出 NEC 协议的按键码。

3. PWM 调速落地:TIM3 输出两路 PWM 与调速曲线的三个坑

3.1 PWM 调速的基本逻辑:占空比控制的是平均电压

直流电机的转速近似正比于电枢两端的平均电压,PWM 就是通过高频开关电源让电机获得一个等效电压。占空比 50% 意味着电机两端平均电压是电源电压的一半,但因为电机有内阻、摩擦和惯性,实际转速并不是严格的 50% 空载转速。这里引出第一个概念:最小启动占空比。很多直流电机在占空比低于某个值(常见在 15% 到 25% 之间)时,静摩擦力大于电磁驱动力,转子根本不动。

另一个被忽略的参数是 PWM 频率。频率太低,比如 100Hz,电机会发出明显的“嗡嗡”声,转速还会周期性波动;频率太高,比如 100kHz 以上,电机驱动芯片的开关损耗增大,驱动芯片会明显发热。常用的范围是 1kHz 到 20kHz。TB6612 驱动下我用 10kHz 左右,安静且发热可控。对应的定时器配置就是:72MHz 主频,PSC=7 分频得到 9MHz,ARR=899 计数 900 次,输出 10kHz PWM。

PWM 调速真正的价值不在让车跑得多快,而在差速转向。循迹转弯时左右轮给不同的占空比,一个轮子降速一个轮子保持,车就能平滑过弯。这也是标题里把“PWM 调速”放在最前面的原因——它是循迹、避障、跟随所有上层功能的速度执行底座。

3.2 最小 PWM 驱动代码:TIM3 输出两路 PWM 带左右电机

下面这套初始化代码用的标准外设库风格,PA6 复用为 TIM3_CH1,PA7 复用为 TIM3_CH2,分别接 TB6612 的 PWMA 和 PWMB。

void Motor_PWM_Init(void) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tim; TIM_OCInitTypeDef oc; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // PA6=TIM3_CH1, PA7=TIM3_CH2,配置为复用推挽输出 GPIO_StructInit(&gpio); gpio.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); // 分频 7+1=8,计数 899+1=900:10kHz PWM TIM_TimeBaseStructInit(&tim); tim.TIM_Prescaler = 7; // 72MHz / 8 = 9MHz tim.TIM_Period = 899; // 9MHz / 900 = 10kHz tim.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &tim); // TIM3 是通用定时器,直接输出 PWM,不需要 MOE 主输出使能 TIM_OCStructInit(&oc); oc.TIM_OCMode = TIM_OCMode_PWM1; // 计数小于 CCR 时输出高 oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_Pulse = 0; TIM_OC1Init(TIM3, &oc); // 通道1:左电机 TIM_OC2Init(TIM3, &oc); // 通道2:右电机 TIM_Cmd(TIM3, ENABLE); }

有几个细节要解释一下。PSC=7 和 ARR=899 得到的是 10kHz PWM,如果把 PSC 改成 71、ARR 改成 999,就变成 1kHz,适合看波形调试但电机噪声明显。TIM3 是通用定时器,跟 TIM1/TIM8 不一样,不需要调用 TIM_CtrlPWMOutputs 使能主输出,加了反而编译报错——这是很多照着高级定时器例程改代码的人踩过的坑。

调速函数封装成 0~100 的整数占空比,内部映射到 CCR 寄存器:

void Set_Motor_Speed(uint8_t left_duty, uint8_t right_duty) { if (left_duty > 100) left_duty = 100; if (right_duty > 100) right_duty = 100; // ARR=899,CCR 范围 0~899,这里乘 8 映射 0~100 到 0~800 TIM_SetCompare1(TIM3, left_duty * 8); TIM_SetCompare2(TIM3, right_duty * 8); }

占空比上限我留到 80% 而不是 100%,因为 100% 占空比时电机两端电压等于电源电压,速度快但遇到突发障碍根本刹不住,而且对电池放电压力大。上限 80% 配合后面要讲的斜坡函数,小车响应会温柔很多。

3.3 调速曲线里最容易忽略的三个参数:启动占空比、斜坡、限幅

调速这事看着简单,实际跑起来第一个坑就是“设了 20% 占空比电机纹丝不动”。这既可能是最小启动占空比的问题,也可能是电机驱动芯片的 STBY 引脚没有拉高。TB6612 的 STBY 是待机控制引脚,拉低时整个芯片不工作,PWM 输出了也没用。排查时先用万用表量电机两端电压,确认 PWM 信号确实到了驱动芯片,再谈调速。

第二个坑是占空比突变。循迹时发现偏了,立刻把左轮从 60% 拉到 80%,右轮从 60% 拉到 10%——小车会猛地甩头甚至翻车。正确做法是给调速加斜坡,让占空比按固定步长渐变:

void Set_Speed_With_Ramp(uint8_t target_left, uint8_t target_right) { static int16_t cur_left = 0, cur_right = 0; if (target_left > cur_left) cur_left += 5; // 每次中断调用最多爬升 5% else if (target_left < cur_left) cur_left -= 5; if (target_right > cur_right) cur_right += 5; else if (target_right < cur_right) cur_right -= 5; cur_left = cur_left > 100 ? 100 : cur_left; cur_right = cur_right > 100 ? 100 : cur_right; Set_Motor_Speed((uint8_t)cur_left, (uint8_t)cur_right); }

这个函数每 10ms 调用一次,占空比以每个周期 5% 的速率变化,0 到 80% 需要 160ms,体感上是平稳起步而不是弹射起步。步长需要按实际车重和电机响应调整,车越重步长越小。

第三个坑和调速本身无关,但直接决定 PWM 的可靠性——PWM 输出引脚如果和电机驱动芯片之间没有共地,PWM 信号就是悬浮的,驱动芯片会随机动作。我遇到过左轮偶发乱转,查了半天发现是驱动模块的 GND 和单片机 GND 之间靠杜邦线虚接,重新压紧后症状消失。这种问题看代码永远看不出来,只能靠量电压和检查接线。

4. 循迹、避障、跟随、红外遥控:四个功能的代码与判别逻辑

4.1 循迹:灰度传感器排布与阈值判决逻辑

循迹模块的排布方式直接影响控制逻辑。我常用的布局是 4 路传感器并排固定在底盘前方,间距约 1.5cm,覆盖黑线宽度。传感器输出数字量:黑线反射率低,模块输出一种电平;白底反射率高,输出另一种电平。具体高低电平取决于模块用的是同相比较还是反相比较,接好后先跑一个串口打印程序,把 4 路电平打出来确认黑白对应关系,再写判线逻辑。

读取 4 路传感器状态的代码很直接,PB0 到 PB3 各接一路:

uint8_t Read_Line_Sensors(void) { uint8_t value = 0; // 每一位置 1 表示该路压到黑线 if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0) == SET) value |= 0x01; // PB0:最右路 if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) == SET) value |= 0x02; // PB1:右中路 if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_2) == SET) value |= 0x04; // PB2:左中路 if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_3) == SET) value |= 0x08; // PB3:最左路 return value; }

这里定义 SET 表示压线。返回值的低四位和物理位置一一对应,方便用 switch 做判决:

void Track_Control(void) { uint8_t line = Read_Line_Sensors(); switch (line) { case 0x06: // 中间两路压线:直线行驶 Set_Speed_With_Ramp(55, 55); break; case 0x02: // 只有右中路压线:偏右,左轮降速 Set_Speed_With_Ramp(35, 55); break; case 0x04: // 只有左中路压线:偏左,右轮降速 Set_Speed_With_Ramp(55, 35); break; case 0x01: // 只有最右路压线:大幅偏右 Set_Speed_With_Ramp(20, 55); break; case 0x08: // 只有最左路压线:大幅偏左 Set_Speed_With_Ramp(55, 20); break; case 0x00: // 全部悬空:丢线,保持上次动作 case 0x0F: // 全部压线:十字路口,直行 Set_Speed_With_Ramp(50, 50); break; default: Set_Speed_With_Ramp(50, 50); break; } }

丢线处理是循迹最容易想当然的地方。0x00 表示 4 路全都没检测到黑线,很多人写成急停,结果黑线稍微有个断开或弯道弧度大一点车就停在路中间。正确逻辑是保持上一次的有效转向动作,让车继续往前冲一小段,直到重新压线。上面的代码简单处理成直行,实际项目里我会用两个静态变量记住上一次左右占空比,丢线时沿用而不是复位到直行。

灰度传感器离地高度和地面颜色是最大的变量。我在每次上电时让车静止 2 秒,在这 2 秒里连续读 4 路传感器几十次,把黑白极值存下来作为阈值参考。这是后话,第五章展开讲。

4.2 避障:超声波测距与分级转向策略

避障的核心不是“测得多准”,而是“别卡死、别抖”。先写一个带超时的单路测距函数:

uint32_t Ultrasonic_GetDistanceCm(void) { uint32_t cnt = 0; // PB5 接 TRIG,PB6 接 ECHO GPIO_SetBits(GPIOB, GPIO_Pin_5); Delay_us(20); // HC-SR04 要求 TRIG 高电平至少 10us GPIO_ResetBits(GPIOB, GPIO_Pin_5); // 等待 ECHO 变高,带 50ms 超时防止前方无障碍时卡死 uint32_t timeout = 50000; while (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_6) == RESET && timeout--); if (timeout == 0) return 999; // 没收到回波,当作很远 // TIM2 按 1us 计一次,PSC=71,ARR 设成 65535 TIM_SetCounter(TIM2, 0); TIM_Cmd(TIM2, ENABLE); timeout = 50000; while (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_6) == SET && timeout--); TIM_Cmd(TIM2, DISABLE); if (timeout == 0) return 999; cnt = TIM_GetCounter(TIM2); // 声速 340m/s,往返除 2:距离 = us * 0.034 / 2 = us * 0.017 return cnt * 17 / 1000; }

TRIG 引脚在发送触发信号前一定要先拉低,很多模块上电瞬间 TRIG 是浮空的,会自己触发一次测距,导致第一次读数是脏数据。测距间隔至少要留 60ms,因为超声波模块内部有余波,连续触发会读到上一次的反射信号。

这里有一个电平问题必须强调:HC-SR04 的 ECHO 引脚输出 5V 高电平,STM32 的 GPIO 容忍 5V 输入但不推荐直接长期硬接。我习惯的解法是 ECHO 串一个 1k 电阻再进 STM32 引脚,利用单片机的钳位二极管把电压限制在安全范围。更严谨的做法是加两个电阻分压把 5V 降到 3.3V,或者用电平转换模块。

避障的控制策略我常用的是三级判决:

void Avoid_Control(void) { uint32_t dist = Ultrasonic_GetDistanceCm(); if (dist > 40) { // 前方开阔,直行 Set_Speed_With_Ramp(50, 50); } else if (dist > 20) { // 接近障碍,减速并向右微偏试探 Set_Speed_With_Ramp(40, 20); } else { // 距离太近,停车后转向 Set_Motor_Speed(0, 0); Delay_ms(200); // 右转 90 度:左轮前进,右轮后退/停止 Set_Speed_With_Ramp(40, 0); Delay_ms(400); } }

转向时间的估计是个玄学问题,它取决于轮胎摩擦、地面材质、电池电压。同一个 400ms 延时,在瓷砖地面和粗糙木地板上转过的角度可能差 30%。我在实际调试中不用固定延时,而是让小车先右转,然后持续测距直到前方距离大于某个阈值才继续前进,以“结果”而不是“时间”作为转向完成的判断依据。固定延时只在初调阶段用,最终代码一定要用传感器反馈闭环。

4.3 跟随:双超声波偏差与距离控制

跟随功能最朴素的实现思路是“保持距离 + 对准目标”。前文说到的双超声波方案,左边一个装车头左前方,右边一个装右前方,两个探头朝前略有夹角。测距函数复用上一节 Ultrasonic_GetDistanceCm,分别测出左前距离和右前距离。

控制量拆成两个:一个是距离误差,决定整体速度快慢;一个是左右距离差,决定转向力度。

#define FOLLOW_TARGET_CM 25 // 期望跟随距离 25cm void Follow_Control(void) { uint32_t dl = Get_Left_DistanceCm(); uint32_t dr = Get_Right_DistanceCm(); // 目标丢失保护:任意一路超过 80cm,认为跟丢了,停车 if (dl > 80 || dr > 80) { Set_Motor_Speed(0, 0); return; } int16_t dist_err = (dl + dr) / 2 - FOLLOW_TARGET_CM; int16_t steer = (int16_t)dl - (int16_t)dr; // 左远右近,偏右 int16_t base_speed = 50 + dist_err * 1.2f; // 距离远加速,距离近减速 int16_t speed_l = base_speed - steer * 0.8f; int16_t speed_r = base_speed + steer * 0.8f; // 限幅到安全范围,杜绝负占空比和超 100% speed_l = speed_l > 70 ? 70 : (speed_l < 0 ? 0 : speed_l); speed_r = speed_r > 70 ? 70 : (speed_r < 0 ? 0 : speed_r); Set_Motor_Speed((uint8_t)speed_l, (uint8_t)speed_r); }

这里的比例系数 1.2 和 0.8 是手调出来的起点值,不是万能参数。车越重转向系数要越大,因为同样的左右差矩,重车偏航角速度小。先给一个在小范围内不抖的参数,再逐步增大转向系数直到出现左右摆头,然后往回退一点,这个方法比理论计算靠谱得多。

跟随的“人”和跟随的“箱子”其实是同一套逻辑,区别只在目标距离和目标丢失阈值。跟人时目标距离可以设远一点,比如 30cm 到 35cm,太近会撞到腿;跟物时距离可以近一些。要先确认自己的超声波模块在 5cm 内测距是失灵的,HC-SR04 的盲区大约在 2cm 到 5cm,目标离得太近会读到跳变值,控制函数里要过滤掉小于 5cm 的读数。

4.4 红外遥控:NEC 协议解码与多模式状态机

红外遥控采用 NEC 协议,38kHz 载波,数据帧格式是引导码(9ms 低电平 + 4.5ms 高电平)加 32 位数据(地址码 8 位 + 地址反码 8 位 + 命令码 8 位 + 命令反码 8 位)。接收头 HS0038B 的输出平时是高电平,收到有效载波时输出低电平脉冲。

解码的思路是测量引脚的电平持续时间。引导码的特点是高电平约 4.5ms,数据位中的“1”高电平约 1.69ms,“0”高电平约 0.56ms,所以根据两次下降沿之间的间隔就能区分引导码、逻辑 0 和逻辑 1:

void EXTI0_IRQHandler(void) { static uint32_t last_ticks = 0; static uint32_t bit_buf = 0; static uint8_t bit_count = 0; uint32_t now = TIM_GetCounter(TIM4); // TIM4 配置为 1us 计数 uint32_t gap = now - last_ticks; last_ticks = now; if (gap > 13000) { // 大于 13ms,认为是引导码之后的高电平或遥控器重复码,重新开始 bit_count = 0; bit_buf = 0; return; } if (gap > 1100) { // 高电平约 1.69ms:逻辑 1 bit_buf = (bit_buf << 1) | 1; bit_count++; } else if (gap > 300) { // 高电平约 0.56ms:逻辑 0 bit_buf = (bit_buf << 1) | 0; bit_count++; } if (bit_count == 32) { // 解码完成,校验命令码和反码,符合才保存 uint8_t cmd = (bit_buf >> 8) & 0xFF; uint8_t cmd_not = bit_buf & 0xFF; if (cmd == (uint8_t)(~cmd_not)) { ir_key = cmd; // 全局变量,主循环里消费 } bit_count = 0; bit_buf = 0; } }

上面代码做了反码校验,只有命令码和它的反码匹配才认作有效按键,这能显著减少误码和按一下出两次的问题。另一个细节是中断里只做时间测量和数据移位,不调用 Delay 函数、不操作电机,所有耗时逻辑放在主循环里消费 ir_key 之后处理。中断做太多事是嵌入式开发的大忌,在红外遥控上尤其明显——中断里一旦超过 1ms,就会漏掉后续的脉冲沿。

模式切换用状态机实现,把所有功能统一到一个模式变量下:

typedef enum { MODE_IDLE = 0, MODE_TRACK, MODE_AVOID, MODE_FOLLOW, MODE_REMOTE } Car_Mode; Car_Mode car_mode = MODE_IDLE; uint8_t ir_key = 0; void Key_Process(void) { if (ir_key == 0) return; switch (ir_key) { case 0x45: car_mode = MODE_TRACK; break; // 按键1:循迹 case 0x46: car_mode = MODE_AVOID; break; // 按键2:避障 case 0x47: car_mode = MODE_FOLLOW; break; // 按键3:跟随 case 0x44: car_mode = MODE_REMOTE; break; // 按键4:遥控 case 0x40: car_mode = MODE_IDLE; break; // 按键5:停止 default: break; } ir_key = 0; // 切换模式前先停车,避免残留速度进入新功能 Set_Motor_Speed(0, 0); }

不同品牌遥控器的按键码不一样,上面的 0x45、0x46 是我手里某款遥控器的实际码值。你拿到的遥控器很可能不同,正确做法是先用串口把解码结果打印出来,按一下记一个码,再填进 switch。千万不要直接抄网上的码值。

主循环的调度逻辑是这套代码的灵魂:

int main(void) { system_init(); Motor_PWM_Init(); Timer2_Init(); // 超声波计时 Timer4_Init(); // 红外解码计时 USART1_Init(); // 串口日志 while (1) { Key_Process(); // 红外键值消费 switch (car_mode) { case MODE_TRACK: Track_Control(); break; case MODE_AVOID: Avoid_Control(); break; case MODE_FOLLOW: Follow_Control(); break; case MODE_REMOTE: // 手动遥控由红外按键直接驱动速度 break; default: Set_Motor_Speed(0, 0); break; } Delay_ms(10); // 主循环节拍 100Hz } }

每个功能函数在 main 循环里被统一调度,模式变量是唯一的开关。这套“一个模式一个函数”的结构,比在循环里堆 if 判断好用得多,也方便后面往 RTOS 移植或者加新功能。

5. 调车避坑:电源、阈值、时序三大翻车现场

5.1 电机一转单片机就复位

现象:小车静止时一切正常,代码烧录、串口打印都稳定;按下启动键电机一转,单片机立刻复位,从串口日志看程序从头开始跑。

原因:电机启动瞬间电流可达正常工作电流的好几倍,瞬间把动力电源电压拉低。单片机的降压芯片如果直接接在动力电源上,或者动力电源和逻辑电源之间没有足够的储能电容,电压跌落超过降压芯片的掉电阈值就会复位。

解决:把动力电源和逻辑电源分开供电,两个电源共地。电机驱动芯片电源引脚处并联一个大容量电解电容(470uF 到 1000uF)和一个 0.1uF 瓷片电容;降压模块输入侧也加一个 100uF 以上的电解电容缓冲。另外检查所有共地点是否牢靠,我遇到过一次复位问题最后是电池负极和驱动板 GND 之间的接线端子松动导致的。

5.2 循迹阈值漂移,上午能跑下午失灵

现象:早上的车在白色瓷砖地面循迹正常,下午阳光斜照进实验室,同样的代码小车频繁冲出跑道;换到木纹桌面或者深色地面,完全无法识别。

原因:TCRT5000 这类反射式光电传感器依赖地面反射率。白色地面对红外反射强,黑色线吸收红外,但环境光含有大量红外分量,太阳光直射地面时传感器的环境光干扰会超过阈值比较器的判断范围。电位器调死的固定阈值无法适应光照变化。

解决:改用传感器的模拟量输出,接到 STM32 的 ADC,每次上电先做一次自动标定:让车静止,读取黑线区域和白底区域的最大最小 ADC 值,以中间值作为动态阈值。另外一个补救措施是给传感器加一个遮光罩,用黑色热缩管或 3D 打印件挡住侧面的环境光,只留正下方开口,效果立竿见影。

5.3 超声波读数跳变,前方无障碍却卡死

现象:串口打印距离值,偶尔出现 0cm 或者几百厘米的跳变;测试避障时车头对着空旷区域,程序停住不动,像是卡在某个函数里。

原因:两个问题叠加。一是 ECHO 引脚 5V 电平直接输入 STM32,虽然多数引脚电气特性标称容忍 5V,但长期直接连接在电机频繁启停时容易异常;二是测距函数没有超时保护,前方无障碍或目标太远时 ECHO 一直为高,while 等待语句永远跳不出去,程序卡死。

解决:ECHO 串 1kΩ 电阻进单片机,有条件用两个电阻分压到 3.3V 更稳。测距函数加超时变量,实测 6m 以内 HC-SR04 收不到回波就返回 999,我在代码里已经写了这个保护。另外测距调用间隔不要低于 60ms,触发一次后留足余波衰减时间。

5.4 红外遥控距离短,按键偶尔变成连续触发

现象:遥控器要凑到接收头 5cm 内才有反应,正常距离按下没反应;偶尔按一下,小车执行了两次动作。

原因:接收头的供电质量决定灵敏度。接收头对电源纹波敏感,电机运转时 PWM 的电流波动耦合到 5V 电源上,38kHz 的干扰频率和载波频率接近,接收头信噪比下降导致距离骤减。连续触发则是没做反码校验,解码误码被当成有效按键。

解决:给 HS0038B 的供电脚加 RC 滤波,100Ω 电阻串联加 10uF 电解电容并联 0.1uF 瓷片,构成低通滤波。软件上严格做命令码和反码校验,并且上位机消费完键值后立即清零,即使解码出错也不会被二次消费。

5.5 循迹和避障同时触发,两个功能打架

现象:循迹模式跑得好好的,车前方经过一个人,超声波测到 30cm,避障逻辑突然介入了,把车从巡线状态拉去转向,人走开后车已经丢了线,停在原地不知道下一步干嘛。

原因:main 循环里各功能模块独立判断,没有统一的模式约束。循迹只管读灰度,避障只管测距,两个模块各自有权操作电机,后执行的模块覆盖先执行的速度设定。

解决:所有功能入口先检查当前模式,不是自己的模式直接 return。也就是把第 4 章状态机的思想贯穿到每个功能函数里——每条控制逻辑只在对应模式下运行,硬件外设可以共享,但电机的“控制权”同一时刻只能归属一个模式。模式切换前强制停车,这个我在 Key_Process 里已经处理了。

6. 进阶验证:用串口日志把每一路传感器调到可交付状态

小车功能全部合起来之后,最需要的不是再改代码,而是一套能够快速定位“哪个环节出了问题”的验证手段。我的做法是给系统加一层透亮的串口日志,把传感器读数、模式状态、PWM 占空比全部打印出来,在轮子架空状态下跑一遍全流程,再放地上低速实测。

首先是 printf 重定向到 USART1,标准库的 fputc 钩子就能实现:

int fputc(int ch, FILE *f) { // 等待发送寄存器空 while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }

有了这个基础,在 track_control 里加一行就能看到循迹的实时状态:

printf("mode=%d line=0x%02X L=%d R=%d\r\n", car_mode, line, cur_left, cur_right);

超声波的距离、遥控器按下的码值也可以用同样的方式打印。这个日志层的价值体现在整机联调时:小车跑歪了,看串口就能知道是灰度传感器读错了、还是 PWM 占空比没跟上,不需要猜。我调跟随那会儿一直画龙,最后把左右超声波距离打印出来才发现,两个传感器装的高度差了 5mm,导致同一距离读数不同,重新固定后才解决。

参数集中管理也是一个值得养成的习惯。把目标距离、最大占空比、斜坡步长、传感器阈值全部用宏定义集中在文件头部,标好注释。电动车上路前,可以在轮子架空状态先跑一次全部模式,观察串口日志是否有异常时序;再放地上从循迹开始低速磨合,速度逐步从 30% 提到 70%——这条路径能筛掉绝大多数硬件接线问题,剩下真正需要调的控制参数反而没几个。

这套小车方案做到最后,比“每个模块单独能工作”更重要的是“所有模块在同一个主循环里有序协作”。我自己的经验是:先把红外遥控和状态机搭好,让五个模式都能安全切进切出,再一个一个往模式里填功能逻辑,而不是先把循迹调到满分再加避障——后者每次加法都会把前面的成果打回原点。硬件上多做共地检查,软件上多做日志输出,玄学问题会少一大半。希望帮到你。

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

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

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

立即咨询