2026/9/13 5:23:02

STM32红外智能车五合一:遥控、避障、跟随、循迹与PWM调速实现

STM32红外智能车五合一:遥控、避障、跟随、循迹与PWM调速实现 简介面向STM32F103C8T6智能小车爱好者的完整实验源码整合红外遥控、红外避障、红外跟随、红外循迹与电机调速五大功能可直接在KEIL4中打开工程并烧录验证。程序基于L293D驱动芯片与TT直流减速电机配套红外避障/跟随模块、循迹模块及遥控接收管演示了多种红外传感器的组合控制方式适合正在做课程设计或入门嵌入式小车控制的读者参考学习。压缩包共41个文件其中.c与.h为源码文件.hex为可直接烧写的固件.uvproj为工程配置.map与.axf供链接调试分析.o/.crf等为编译中间产物目录按SYSTEM、USER等模块组织另有电机驱动电路原理图与必看程序说明方便二次开发与调试。压缩包整体体积仅365KB下载和学习成本都很低作者在自己的自研小车上亲测可用工程可靠性有保障。已有1639人学习下载可作为红外多模式小车项目的快速起步模板。1. 一块最小系统板顶五台车的功能这个红外小车项目到底在做什么拿到stm32f103c8t6智能小车红外遥控红外避障红外跟随红外循迹调速实验程序源代码.rar这个包名懂的同行应该立刻能想到这是把大学电子设计竞赛和课程设计里最常见的五合一红外小车方案塞进了一个工程。蓝板STM32F103C8T6最小系统板加三路或五路红外传感器再利用一对红外发射接收管做遥控解码就能让一辆小车分别跑出遥控、避障、跟随、循迹四种行为最后再用定时器输出PWM调速。对于正在做课设、准备电赛或者刚把C8T6调通串口想搞点大作业的人来说这份代码的价值不在某一行神操作而在于它把“传感器输入 - 逻辑判断 - 电机输出”这条完整链路全部打通了而且每种红外功能共用同一套底层驱动。五年前我做类似项目时踩过最大的坑不是代码写不出来而是四个红外功能各写一套逻辑最后main函数里挤了三百行if-else换个传感器方向就找不到北。这份代码真正值得拆解的是它如何用有限几个GPIO和定时器资源做状态切换。下面我按自己的工程习惯把这个包背后的硬件选型、代码结构和参数调法完整走一遍。2. 底盘硬件与红外传感器选型C8T6引脚资源怎么分配才够用2.1 从最小系统板到电机驱动C8T6的引脚功能摸底STM32F103C8T6是LQFP48封装满打满算37个GPIO但做小车实际能动用的没那么多。PA9/PA10是串口1的TX/RX如果要烧录调试最好留着PA13/PA14是SWD下载口绝对别碰。剩下的引脚里我需要至少4个输出接电机驱动板的IN1~IN4或者用PB0/PB1接PWM调速再加上5路红外循迹、1路红外避障、1路红外跟随和1个红外遥控接收头总IO数在11到13个之间C8T6完全扛得住。常见引脚分配做法是PA0~PA3接电机驱动板比如L298N或TB6612FNG的方向控制PB0/PB1接PWM调速PA4~PA8接五路循迹传感器PA11接避障传感器PA12接跟随传感器PB5接红外遥控接收头。这样分配的好处是PA口和PB口分别承担数字输入和PWM输出避免定时器通道互相抢占。提示L298N的ENA/ENB如果直接接3.3V电机全速转根本没法调速。必须把ENA/ENB接到支持PWM的定时器通道上C8T6的TIM2_CH1PA0、TIM2_CH2PA1、TIM3_CH3PB0都是常用选择。2.2 LM393比较器输出与传感器检测距离调节市面上卖的循迹、避障、跟随模块核心几乎都是LM393电压比较器加一对红外发射接收管。发射管持续发出38kHz或直流红外光接收管根据反射强度改变电压LM393把这个电压与可调电位器设定的阈值比较输出TTL电平。检测到白色地面反射强时输出低电平检测到黑线反射弱时输出高电平这个逻辑决定了代码里的判断极性。距离调节要特别留意红外避障模块的检测距离一般在2~30厘米可调出厂默认可能只有5厘米太近了转弯都来不及。我一般把避障阈值电位器调到10厘米左右跟随模块调到15到20厘米循迹模块则压低灵敏度让传感器距离地面1.5到2.5厘米时刚好能识别黑线。实际调法很简单把小车放在白纸上拧电位器到指示灯刚好亮起再放回黑线上确认灯灭反复两次就准了。2.2.1 传感器供电与逻辑电平匹配C8T6的GPIO容忍5V输入但传感器模块如果直接供5V输出高电平也是5V虽然STM32引脚标称容忍5V长期跑还是建议统一3.3V供电。循迹模块的VCC接3.3V完全没问题红外功率低检测距离略短但不影响课设演示。若现场环境光干扰大再用5V供电并串1kΩ电阻分压进GPIO。2.3 电机测速与编码器没有测速的调速都是盲调标题里的“调速实验”如果只是固定占空比那就没意思了。带编码器的直流减速电机常见的是霍尔编码器AB相输出可以接在PB6/PB7、PB8/PB9上用定时器编码器模式读转速。C8T6的TIM4_CH1/CH2恰好是PB6/PB7TIM3_CH1/CH2是PA6/PA7都能做编码器输入。不过注意如果循迹用了PA6/PA7编码器只能挤到TIM4上两个电机各占一组通道刚好用完。没有编码器的版本也能做开环调速占空比按电池电压查表补偿。但作为实验程序我建议至少保留编码器接口的代码框架后续升级PID调速时不用改引脚。3. 工程代码结构与底层驱动main.c怎么把红外数据和电机控制串起来3.1 代码工程里应该有哪些文件拿到压缩包源码后先别急着编译。先看目录结构一个清晰的工程至少包含这些文件main.c主逻辑、motor.c/h电机驱动、infrared.c/h红外传感器读取、remote.c/h红外遥控解码、timer.c/hPWM与定时器初始化、pid.c/h调速控制。如果哪个版本把这些全塞进main.c还跑通了说明作者赶时间但基本功扎实参考价值要打折扣。我自己的工程习惯是infrared.c只做一件事——把各传感器GPIO电平读进来打包成一个8位无符号整数返回。remote.c用外部中断加定时器捕获做NEC协议解码。motor.c把PWM占空比和方向控制封装成Motor_SetSpeed(int left, int right)。这样main.c里就能写成非常干净的状态机。3.1.1 初始化代码示例GPIO与定时器的最小配置以标准外设库写法为例很多实验源码还是老版固件库HAL用户自行平移C8T6的RCC时钟、GPIO和TIM初始化如下void GPIO_InitConfig(void) { GPIO_InitTypeDef GPIO_InitStructure; // 使能GPIOA、GPIOB时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); // 循迹传感器输入 PA4~PA8上拉输入 GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_InitStructure.GPIO_Pin GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7 | GPIO_Pin_8; GPIO_Init(GPIOA, GPIO_InitStructure); // 电机方向控制 PA0~PA3推挽输出 GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_Init(GPIOA, GPIO_InitStructure); }这段代码的逻辑是输入引脚设成上拉这样传感器悬空时读到的电平是确定的1不会误触发。输出引脚速度设50MHz其实对电机驱动这种低频场景没必要但养成习惯没坏处。方向控制引脚不需要PWM所以不用映射到复用功能。3.2 红外遥控解码NEC协议的坑与C8T6的外部中断红外遥控常见协议是NEC载波38kHz数据格式为引导码9ms高电平4.5ms低电平 8位地址码 8位地址反码 8位数据码 8位数据反码。C8T6跑72MHz主频用外部中断检测下降沿再用定时器读两次沿之间的时间间隔就能区分数据位0560us高560us低和数据位1560us高1.68ms低。注意网上很多源码用延时函数轮询接收头小车一动电机产生干扰就会解码失败。正确做法是把接收头接到STM32的EXTI线比如PB5在中断服务函数里记录定时器计数主循环只消费解码结果。// 红外解码中断服务函数PB5外部中断 void EXTI9_5_IRQHandler(void) { if(EXTI_GetITStatus(EXTI_Line5) ! RESET) { static uint32_t last_time 0; uint32_t now_time TIM_GetCounter(TIM2); uint32_t duration now_time - last_time; last_time now_time; if(duration 7000) // 引导码约9ms bit_count 0; else if(duration 1000) // 数据位1约1.7ms remote_code | (1 bit_count); else // 数据位0约560us bit_count; if(bit_count 32) { decoded_flag 1; bit_count 0; } EXTI_ClearITPendingBit(EXTI_Line5); } }这个做的逻辑是TIM2的计数器工作在自由运行模式不分频到1MHz即每微秒计数一次。两次中断间的计数值就是高低电平的总时长。判断阈值取1000到7000之间能稳定区分三种信号。注意remote_code要在解码完成后才赋值不然中断里频繁写32位变量主循环读半截会出问题。3.3 电机PWM调速TIM3产生可调占空比的两种方式调速不外乎两种实现直接改CCR寄存器或者用定时器更新中断里逐渐逼近目标占空比。前者响应快但有电流冲击后者是软件上的软启动。实验代码里最常用的是直接改CCR因为简单直观void Motor_SetSpeed(int left_speed, int right_speed) { // left_speed范围 -100 ~ 100负值代表反转 if(left_speed 0) { GPIO_SetBits(GPIOA, GPIO_Pin_0); // IN1 1 GPIO_ResetBits(GPIOA, GPIO_Pin_1); // IN2 0 TIM_SetCompare1(TIM3, left_speed * 100); // CCR1 占空比 } else { GPIO_ResetBits(GPIOA, GPIO_Pin_0); GPIO_SetBits(GPIOA, GPIO_Pin_1); TIM_SetCompare1(TIM3, (-left_speed) * 100); } // 右电机同理用TIM3_CH2 }这里有个参数陷阱如果TIM3的ARR是9999那CCR的值范围是0~9999占空比就是CCR/ARR。left_speed * 100相当于把-100~100映射到0~10000但这依赖ARR正好是10000。更稳的写法是把ARR固定成9999然后TIM_SetCompare1(TIM3, left_speed * 9999 / 100)。我看过太多源码在PWM频率和占空比范围上对不上导致小车一快一慢。4. 红外避障、跟随、循迹与遥控四种模式一个状态机切换的完整实现4.1 避障模式单传感器前方探测与转弯方向决策避障逻辑最简单一个红外传感器装在小车正前方检测到障碍物输出高电平或低电平取决于模块小车就后退并转弯。真正的坑在转弯方向——固定右转的话如果右边也有墙小车会卡死。好一点的实验程序会加一个随机数或记忆上一次转向。void Mode_Avoid(void) { if(IR_Avoid_Read() OBSTACLE_DETECTED) // 有障碍 { Motor_SetSpeed(-40, -40); // 后退 Delay_ms(200); Motor_SetSpeed(-30, 30); // 原地右转左轮正转右轮反转 Delay_ms(350); } else { Motor_SetSpeed(50, 50); // 直行 } }这个代码的关键参数是后退时间和转弯时间。200ms后退在3.3V供电下大约移动2厘米350ms原地转弯大约是45度。如果电机电压高或者轮径大时间要缩。改成左转只需把Motor_SetSpeed(-30, 30)换成Motor_SetSpeed(30, -30)。4.2 跟随模式双传感器差动判断目标位置跟随比避障多一个维度需要判断目标在左边还是右边。常见做法是把一个传感器模块朝向斜前方或者用两个模块分开左右目标是检测到反射时认为“前面有人”。我做的跟随方案是左右各一个红外传感器夹角约30度左传感器右传感器动作00目标丢失原地旋转搜索10目标偏左左转01目标偏右右转11目标居中直行void Mode_Follow(void) { uint8_t left IR_Follow_Left_Read(); uint8_t right IR_Follow_Right_Read(); if(left right) Motor_SetSpeed(45, 45); // 居中直行 else if(left !right) Motor_SetSpeed(25, 45); // 左轮慢右轮快左转 else if(!left right) Motor_SetSpeed(45, 25); // 右转 else Motor_SetSpeed(-20, 20); // 丢目标原地搜索 }这里的转速差25和45是个保守值实测效果是转弯半径大约30厘米。如果想跟得更紧可以加大差值到20和60但要注意电机启动瞬间电流变大电池电压跌落可能导致C8T6复位。我在做毕设时遇到过转速一加大红外接收头就误触发后来在电机供电和逻辑供电之间加了电容隔离才解决。4.3 循迹模式五路传感器状态查表控制五路循迹是这份代码的技术含量最高点。五个传感器排成一排中间的是主探测位两边的是纠偏位。理想状态是中间两个传感器压着黑线此时直行。小车偏左时左侧传感器离开黑线右侧传感器还压线就需要右转。const uint8_t TRACK_TURN_TABLE[32] { // 依次对应 PB4~PA8 五路传感器左到右 // bit4最左, bit0最右, 1表示检测到黑线 [0b00100] ACTION_FORWARD, // 居中 [0b00110] ACTION_TURN_RIGHT_SLOW, // 偏右 [0b01100] ACTION_TURN_LEFT_SLOW, [0b00010] ACTION_TURN_RIGHT_FAST, [0b01000] ACTION_TURN_LEFT_FAST, [0b00000] ACTION_STOP, // 全丢线 [0b11111] ACTION_STOP, // 全压线十字路口 };查表法比if-else链的好处是传感器状态和动作一一对应加新路况只需往表里加一行。每个传感器的电平组合成一个5位整数作为下标TRACK_TURN_TABLE[state]直接得到动作码。有些源码用switch-case效果一样但扩展性差一些。void Mode_Tracking(void) { uint8_t state 0; state | IR_Track_Left2_Read() 4; state | IR_Track_Left1_Read() 3; state | IR_Track_Mid_Read() 2; state | IR_Track_Right1_Read() 1; state | IR_Track_Right2_Read() 0; switch(TRACK_TURN_TABLE[state]) { case ACTION_FORWARD: Motor_SetSpeed(55, 55); break; case ACTION_TURN_RIGHT_SLOW: Motor_SetSpeed(40, 55); break; case ACTION_TURN_LEFT_SLOW: Motor_SetSpeed(55, 40); break; // ... 略 } }循迹中最容易忽略的是传感器安装高度和间距。五个传感器排成一排间距要小于黑线宽度常见黑线胶带宽度1.8cm这样至少有一个传感器能覆盖黑线。高度控制在1.5cm左右太高则环境光干扰变大太低则地面不平会刮擦。调间距比调代码重要十倍源码里的查表参数只有在传感器间距正确时才有意义。4.4 遥控模式键值映射与调速档位切换遥控模式的实现分两步第一步是NEC解码拿到原始键值第二步是把键值映射成小车动作。很多源码在第二步偷懒直接拿遥控器厂商定义的键码换一个遥控器就全部失灵。正确做法是在初始化时让小车进入“学习模式”按一下遥控器某个键代码把解码值存到EEPROMC8T6无内部EEPROM可用Flash模拟或用外部AT24C02之后按键映射就固定了。void Mode_Remote(void) { if(decoded_flag) { decoded_flag 0; switch(remote_code) { case KEY_FORWARD: // 0x45实际以学习值为准 Motor_SetSpeed(60, 60); break; case KEY_BACKWARD: // 0x46 Motor_SetSpeed(-60, -60); break; case KEY_LEFT: // 0x44 Motor_SetSpeed(-30, 60); break; case KEY_RIGHT: // 0x43 Motor_SetSpeed(60, -30); break; case KEY_STOP: // 0x40 Motor_SetSpeed(0, 0); break; case KEY_PLUS: // 0x48 加速 speed_limit (speed_limit 100) ? speed_limit 10 : 100; break; case KEY_MINUS: // 0x49 减速 speed_limit (speed_limit 20) ? speed_limit - 10 : 20; break; } } }遥控模式下有个容易踩的坑红外遥控接收头和不带编码器的直流电机共用电源时电机启动瞬间的电压跌落会让接收头误码。我在代码里加了个软件滤波——连续两次解码值相同才执行动作这比加硬件滤波简单得多效果也够用。4.5 模式切换机制按键、串口还是拨码开关四种模式共用一个main循环通常用KEY1/KEY2两个按键切换模式。按下一次当前模式编号加1取模后进入下一个模式。也可以用串口发指令切换方便调试。int main(void) { uint8_t mode 0; // 0遥控 1避障 2跟随 3循迹 GPIO_Config(); Timer_PWM_Config(); EXTI_Remote_Config(); USART1_Config(); while(1) { if(KEY_Scan() KEY1_PRESS) mode (mode 1) % 4; switch(mode) { case 0: Mode_Remote(); break; case 1: Mode_Avoid(); break; case 2: Mode_Follow(); break; case 3: Mode_Tracking(); break; } Delay_ms(20); // 主循环节拍20ms电机控制周期50Hz } }20ms的延时让主循环稳定在50Hz这个频率对电机控制来说足够平滑。如果延时太短比如2ms传感器读取和模式切换都会正常但电机驱动信号更新太快PWM输出与定时器产生竞争可能出现电机抖动。5. 调速与PID从固定占空比到编码器闭环的参数整定方法5.1 开环调速的局限占空比与转速的非线性关系标题里的“调速实验”如果只是写TIM_SetCompare(x)然后观察转速变化那调完就完了。直流电机在低占空比下的死区效应很明显占空比低于15%时电机根本不转从20%到30%转速变化又很大从70%到100%变化就不明显了。这意味着开环调速时低速档位和高速档位的分辨率不一致小车走直线需要不断微调左右轮的偏置。5.2 编码器测速与增量式PID核心代码带霍尔编码器的电机C8T6读AB相可以用定时器编码器模式完全由硬件计数不占CPU。初始化TIM4为编码器模式后直接TIM_GetCounter(TIM4)就能拿到脉冲数配合定时器中断计算速度// 每10ms读一次编码器计数计算实际转速 void TIM2_IRQHandler(void) { if(TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { static int16_t last_count_left 0; int16_t cur_count_left TIM_GetCounter(TIM4); int16_t delta cur_count_left - last_count_left; last_count_left cur_count_left; pid_left.actual_speed (int16_t)((int32_t)delta * 1000 / 10 / ENCODER_LINES); // ENCODER_LINES 是电机编码器线数如13线霍尔经减速后一圈脉冲 13 * 减速比 TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } }增量式PID计算公式是output Kp * (e[k] - e[k-1]) Ki * e[k] Kd * (e[k] - 2*e[k-1] e[k-2])。在STM32上实现时要注意整型溢出问题转速误差在几十到几百之间把Kp调成小数在定点运算里需要缩放。常见做法是把Kp放大100倍输出再除以100int32_t PID_Calc(PID_TypeDef *pid, int16_t target_speed) { int16_t error target_speed - pid-actual_speed; int32_t delta_out; pid-error[2] pid-error[1]; pid-error[1] pid-error[0]; pid-error[0] error; // 增量式PID系数已放大100倍 delta_out (int32_t)pid-Kp * (pid-error[0] - pid-error[1]) (int32_t)pid-Ki * pid-error[0] (int32_t)pid-Kd * (pid-error[0] - 2*pid-error[1] pid-error[2]); delta_out / 100; pid-output delta_out; // 输出限幅防止积分饱和 if(pid-output pid-max_output) pid-output pid-max_output; if(pid-output pid-min_output) pid-output pid-min_output; return pid-output; }这套代码最容易被忽视的是pid-output的初值设置。如果初始为0PID启动时会先猛蹿一下再收敛因为误差大导致积分项快速累积。我一般把output预设在目标占空比附近比如目标转速对应的开环占空比PID只在那个基础上做微调。5.3 参数整定口诀与实测方法调PID没有捷径但有个稳定的顺序先令KiKd0只调Kp从小到大加到系统出现等幅震荡记下此时的Kp记为Ku和震荡周期Tu。然后用Ziegler-Nichols参数表换算Kp0.6KuKi1.2Ku/TuKd3Ku*Tu/40。参数过小表现过大表现Kp响应慢爬不上目标速度电机啸叫速度波动大Ki稳态误差消除不了跑不快时左右轮转速不一致过冲明显起步回声重Kd抗干扰差颠簸路面转速突变高频抖动PWM输出毛刺多我在C8T6上做两轮差速小车常用参数范围是Kp30~80、Ki5~20、Kd0~5。注意这是放大100倍后的值直接用原始小数会超出int32的预算范围。实际调试时先把编码器测速值打印到串口观察空载和负载两种情况下实际转速曲线再定参数。6. 四合一模式整合与现场调试用逻辑分析仪和串口打印快速定位问题6.1 模式切换时的电机防抖与传感器互扰四合一模式最怕切换瞬间电机乱转。原因是模式切换函数里直接改了PWM占空比而传感器状态还没稳定。我加了一个50ms的“模式切换过渡期”切换模式后先把电机停住延时50ms再开始执行新模式逻辑。这个时间足够让所有传感器输出稳定。另一个问题是红外遥控接收头与循迹传感器的载波频率冲突。循迹模块的红外发射管是常亮的周围环境如果有同频红外光会干扰接收头解码。解决方法是把遥控接收头贴在高于底盘3cm的支架上或者给接收头套一个黑色热缩管只留正前方开口物理屏蔽侧方干扰。6.2 串口输出每个传感器的原始电平比猜代码快十倍调试四方模式时我最常用的是串口打印传感器状态printf(Mode:%d Track:%d%d%d%d%d Avoid:%d Follow:%d%d\r\n, mode, IR_Track_Left2_Read(), IR_Track_Left1_Read(), IR_Track_Mid_Read(), IR_Track_Right1_Read(), IR_Track_Right2_Read(), IR_Avoid_Read(), IR_Follow_Left_Read(), IR_Follow_Right_Read());把这段加在main循环里用USB转TTL接PA9/PA10波特率115200电脑端OpenCRT或Python串口工具看。逻辑很简单小车不动时用手在传感器前晃看串口数据是否跟着变。如果某一个传感器的电平永远不变先用万用表量模块输出引脚电压排除接线问题再看代码读的GPIO端口有没有配错。6.3 循迹地图设计与速度适配的最终验证循迹模式跑不跑得稳很大程度卡在场地和速度上。用黑色电工胶带在浅色地板上贴一个S弯转弯半径不小于30厘米直线段不少于50厘米。循迹速度先给到30%占空比跑一圈确认每个弯都能过再逐步加到50%、70%。速度一高小车在急弯处会甩尾传感器脱离黑线。这时需要调高转向灵敏度也就是把查表里的慢转动作改成快转或者适当降低直行速度保持一致性。如果赛道是十字路口五路全为1表示压在线交叉点上这时候的决策是直行优先还是转弯优先取决于比赛规则。源码里的ACTION_STOP是个安全选择但在实际比赛中我会把十字路口的处理改成“上一次转弯方向延续”比如小车进十字前正在左转就继续左转这样省一个判断逻辑。6.4 一个调车技巧把循迹传感器读数映射成LED显示不用串口的时候可以借用C8T6板载LEDPC13做简易指示让LED闪烁快慢代表传感器状态变化频率。但更实用的做法是把五个循迹传感器的值映射到C8T6上五个空闲GPIO外接的LED小车过线时哪颗灯亮一目了然。我看过有同学用PA0~PA3的电机方向控制引脚顺带点灯结果一开电机灯就乱跳最后发现是方向控制引脚下拉电阻不够。有条件还是尽量用串口数据直观且不用分心看灯。本文还有配套的精品资源点击获取