简介:针对STM32F103C8T6智能小车黑线循迹运动实验,这是一份可直接编译运行的完整工程源代码,适合正在学习STM32嵌入式开发或智能车循迹控制的初学者参考。程序基于Keil4开发,处理器为STM32F103C8T6,小车端采用L293D电机驱动芯片与TT直流减速电机,配合红外循迹模块完成黑线识别与差速纠偏,已由作者在自用小车上亲测可用。压缩包共44个文件、约356KB,包含8个h头文件、3个c源文件,以及hex烧录文件、uvproj工程文件,另附txt程序说明文档和L293D电机驱动电路jpg图片,工程内SYSTEM、OBJ、USER等目录划分明确,既能直接下载烧录,也方便在Keil4中二次修改。当前已有1791人学习下载,对于正在做智能小车课程设计、电子竞赛备赛或初次接触STM32电机控制的同学来说,这份源码能帮助快速跑通黑线循迹功能,并理解L293D驱动、TT电机调速与循迹传感器配合的完整实现思路,从代码到硬件接线都有对应材料;整体循环逻辑清晰,也适合在工程基础上继续扩展其他智能功能。
1. 黑线循迹小车还是那个熟悉但常翻车的入门项目
我拆过几十块STM32F103C8T6最小系统板,但每次有人拿STM32F103C8T6智能小车黑线循迹运动实验程序源代码来问,我都要先问一句:白底黑线还是黑底白线?这决定了红外模块的输出极性判断,也决定了你后面是改代码还是改硬件。
这套源码是STM32标准外设库风格的工程,目标芯片STM32F103C8T6,驱动芯片L293D,电机是TT直流减速电机,配合至少两个红外循迹模块做双路检测。开发环境锁定Keil4,压缩包里放了SYSTEM、OBJ、USER三个目录、L293D电机驱动电路.jpg和程序说明(必看).txt,是典型的学校实验室出品,但工程可复用性比网上的零散例程高不少。
它适合谁?刚学会GPIO点灯、想跑通第一个闭环运动控制的人;也适合为工创赛智能物流小车练手,先把黑线循迹吃透的人。真正决定小车跑得好不好的,往往不是代码量,而是供电和阈值。
2. 硬件选型与接线:L293D和红外循迹模块的坑先排干净
2.1 引脚分配与GPIO输入模式
循迹模块TCRT5000常见数字输出,白底输出高电平,黑线输出低电平。但有一些套件的模块是反逻辑,所以第一步不是连IO,而是把模块放白纸上用万用表量输出脚。如果是0V则说明输出低有效,后面代码必须反转。原源码用的双路检测,通常占用两个普通GPIO,我习惯用PB0左、PB1右,这样能把PA9/PA10的串口留出来做调试输出。
GPIO初始化如下:
// 双路循迹传感器输入初始化 GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入,适配开漏输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure);这段代码把PB0和PB1配置成上拉输入。GPIO_Mode_IPU之所以重要,是因为TCRT5000模块的数字输出通常是三极管开漏结构,内部集电极开路,必须有外部上拉才能把高电平拉到稳定电位。GPIO_Speed对输入模式其实没有影响,但保留它可以让后续把引脚复用成TIM功能时少一次配置遗漏。如果模块输出是推挽结构,上拉输入也兼容,改成浮空输入也可以,但抗干扰差一些。
2.2 L293D驱动电路与电源接法
L293D是双电源工作的四通道半桥驱动:VCC1是逻辑电源接5V,VCC2是电机电源接电池正极。源码里如果只给一个电源,电机一启动,STM32会反复复位,这是黑线循迹实验里最常见的问题。实验包的L293D电机驱动电路.jpg里一般画得很清楚:VCC2与GND之间要有大电容,VCC1和VCC2需要共地,并且VCC2的电压不能超过36V,实际TT电机建议不超过12V。
接线参考表:
| 信号 | L293D引脚 | STM32引脚 |
|---|---|---|
| 左电机PWM使能 | EN1 | PB5 |
| 左电机方向1 | IN1 | PB6 |
| 左电机方向2 | IN2 | PB7 |
| 右电机PWM使能 | EN2 | PB8 |
| 右电机方向1 | IN3 | PB9 |
| 右电机方向2 | IN4 | PB10 |
| 左循迹模块输出 | OUT_L | PB0 |
| 右循迹模块输出 | OUT_R | PB1 |
注意上面是常用引脚分配,实际工程以main.c顶部宏为准。L293D用IN1/IN2的电平组合控制电机正反转,EN1用PWM控制速度。TT直流减速电机额定电压一般是6V,如果电池是7.4V锂电池,PWM占空比建议限制在70%左右,否则L293D里的功率管长时间满占空比工作,发热会非常快,外壳烫手后进入热关断,小车突然停摆。
提示:L293D逻辑电源和电机电源必须共地,不共地时PWM电流回路建立不起来,电机只会嗡嗡叫不转。
实验电路图上通常还会看到VCC2引脚对地的电解电容和VCC1对地的陶瓷电容。这两个电容不能省:电解电容吸收电机启停时的反向尖峰电流,陶瓷电容滤掉逻辑电源的高频噪声。没有它们,STM32在电机急停时容易检测到电源跌落而进入硬件错误中断。
2.3 红外阈值标定:比代码更关键的环节
黑线循迹失败,七成原因是模块电位器没调好,不是代码问题。把小车放在白色地面,模块距地面1到2厘米,用一字螺丝刀慢慢旋电位器,让指示灯刚好熄灭或刚好点亮,然后用黑色胶带挡在模块下方,确认指示灯状态反转。这个过程要重复三次,每次都从偏移大的方向旋回来,消除电位器机械回差。
如果模块使用的是可调电位器,旋向不一致时,最笨但有效的方法是:白底调灯亮、黑线调灯灭,把检测点卡在黑白交界处。你也可以在代码里加数字滤波:同一引脚连续读三次,三次一致才认为状态有效。这个消抖对双路循迹很重要,因为黑白交界处传感器输出会在几百微秒内跳变,直接导致小车沿锯齿路径跑。
传感器安装高度也同样关键。距离地面太近,模块外壳容易刮到跑道上的不平处,电位器产生机械偏移;太高,黑色胶带的反光不足以触发翻转。我一般用铜柱把模块固定在小车底板,离地约1.5厘米,固定螺丝用尼龙材质而不是金属,因为金属螺丝的反光会把传感器状态拉回高电平,造成误判。
3. 从库函数到主循环:GPIO读入与L293D差速转向
3.1 压缩包目录与程序说明文件
解压后根目录有SYSTEM、OBJ、USER三个文件夹,外加程序说明(必看).txt和L293D电机驱动电路.jpg。SYSTEM里是delay、sys、usart三个基础模块,提供延时和串口重定向;OBJ是编译输出目录,默认把生成的.hex放这里;USER里的main.c、stm32f10x_it.c才是逻辑主体。先读程序说明.txt,里面写了目标芯片、驱动芯片、电池电压等关键参数,避免拿51的思路来改STM32代码。
这套目录结构里有一个容易被忽略的细节:USER工程里会定义USE_STDPERIPH_DRIVER宏,Keil的魔术棒 -> C/C++ -> Preprocessor Symbols里如果没有这个宏,标准外设库的dma、gpio头文件会少一部分映射,编译报一堆typedef找不到。打开工程第一件事检查这个宏,而不是直接点Rebuild。
3.2 电机PWM初始化的参数选择
主循环控制速度前,要先让定时器输出两路PWM。用TIM2的CH1/CH2,初始化代码:
// 电机PWM初始化:1kHz,周期1ms RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructInit(&TIM_TimeBaseInitStructure); TIM_TimeBaseInitStructure.TIM_Prescaler = 72 - 1; // 72MHz时钟分频到1MHz TIM_TimeBaseInitStructure.TIM_Period = 1000 - 1; // 1MHz/1000 = 1kHz TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; TIM_OC1Init(TIM2, &TIM_OCInitStructure); TIM_OC2Init(TIM2, &TIM_OCInitStructure); TIM_Cmd(TIM2, ENABLE);这里预分频必须配置成72-1,因为STM32F103C8T6默认系统时钟72MHz。TIM_Period设为1000-1,得到1kHz的PWM频率。L293D在这个频率下开关损耗和噪声平衡较好,如果降到500Hz,电机会更响但发热略低;升到10kHz,占空比分辨率下降且驱动芯片发热明显。TIM_Pulse初始为0,防止上电瞬间电机猛冲,等主循环里设置好方向引脚后再给PWM。
PWM频率的选择还要看定时器的分辨率:TIM_Period = 1000时,占空比从0到999步进,足够做60%-70%的精细调节。如果要用PID,建议把Period提到5000,这样每次调节的最小步进是0.02%,避免PID输出震荡时电机一顿一顿的。
3.3 双路循迹主循环:差速转向状态表
循迹核心在主循环里做状态判断。这段逻辑用标准外设库读引脚:
// 主循环:读传感器并计算左右PWM while (1) { uint8_t left = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0); uint8_t right = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1); if (left == 0 && right == 0) // 两传感器都在黑线上 { TIM_SetCompare1(TIM2, 600); // 直行 TIM_SetCompare2(TIM2, 600); drive_forward(); } else if (left == 0 && right == 1) // 左传感器压线,车偏右 { TIM_SetCompare1(TIM2, 300); // 左轮降速 TIM_SetCompare2(TIM2, 650); drive_forward(); } else if (left == 1 && right == 0) // 右传感器压线,车偏左 { TIM_SetCompare1(TIM2, 650); TIM_SetCompare2(TIM2, 300); drive_forward(); } else // 两传感器都在白底,丢了线 { TIM_SetCompare1(TIM2, 350); TIM_SetCompare2(TIM2, 350); spin_in_place(); // 原地旋转找线 } }这段逻辑里最容易被忽略的是“两传感器都压线”的问题:窄黑线不可能同时压住两个传感器,所以直行状态实际应该对应左右都白。但这又和丢线状态冲突。双路模块的标准做法是:左右都白时短暂维持上一次转向方向,或者原地旋转。上面代码选择原地旋转,适合90°直角弯;如果跑大弯,建议改成“左转优先”来回扫线。
传感器状态与动作对应如下:
| 左PB0 | 右PB1 | 小车状态 | 左PWM | 右PWM |
|---|---|---|---|---|
| 0 | 0 | 黑白交界,纠偏 | 600 | 600 |
| 0 | 1 | 偏右,左转 | 300 | 650 |
| 1 | 0 | 偏左,右转 | 650 | 300 |
| 1 | 1 | 直线丢失,旋转找线 | 350 | 350 |
注意1和0的极性取决于模块是否反逻辑。如果模块黑线输出高,就在宏里把LINE_ACTIVE_LEVEL改成1,主循环判断改成== LINE_ACTIVE_LEVEL,不要逐行改if。这样的宏定义带来的好处是,换一套小车底盘时,只需要改引脚映射和有效电平,不用碰控制算法。
还要注意上文drive_forward和spin_in_place两个函数,源码里一般会封装成L293D的IN引脚组合。方向引脚和PWM比较值需要分开设置,因为PWM更新后,方向动作立即生效,如果两个轮子方向不一致,小车会原地打转而非转向。
4. 实战调试:Keil4工程编译与循迹抖动参数修正
4.1 Keil4工程打开与烧录设置
用Keil uVision4打开USER文件夹下的.uvproj工程文件。打开后第一次编译一般会报找不到STLink驱动,只有这个原因。在Options for Target -> Device确认芯片是STM32F103C8T6;Debug里选择ST-Link或J-Link;Utilities里勾选算法文件。如果Keil4装过标准外设库,直接编译会生成OBJ下的hex。
命令行方式下载也可以:
STM32_Programmer_CLI -c port=SWD mode=NRST STM32_Programmer_CLI -w OBJ\\line_follow.hex -v -rst说明:这里-c port=SWD表示使用SWD四线下载,mode=NRST是常见复位时序;-w写入hex并校验,-v对比写入内容,-rst下载后复位运行。如果下载器找不到芯片,先查BOOT0是不是接地,再查L293D逻辑电源是否和STM32共地。
Keil4在Windows 10及以上偶尔会有工程文件关联异常。如果双击.uvproj没反应,用管理员身份运行Keil再打开;如果编译报“Unknown type name 'u8'”这类错误,多半是SYSTEM目录的头文件没有加进Include Paths。C8T6是64KB Flash,编译出现ROM不足时,把调试器里的Download Function勾掉一部分,或者把优化级别从Level 0调到Level 2,立即能省下不少空间。
4.2 画龙、甩尾、冲出赛道的调参表
小车在弯道和直道的动态不同,不能靠一个占空比调完。建议按照下表顺序操作:
| 现象 | 最先检查 | 参数参考 |
|---|---|---|
| 直道画龙 | 左右电机空载转速差 | 快侧PWM减50 |
| 弯道冲出 | 转向差速不够大 | 650/300改成700/250 |
| 原地旋转太猛 | 丢线PWM过高 | 350降到280 |
| 电机啸叫 | PWM频率不合适 | 1kHz降到500Hz |
| 电池没电后无力 | 电池电压下降 | 全速阈值升到700 |
调参时只动一个参数,改一次跑一圈,不要同时动转向差速和直行速度。差速越大转向角速度越大,但过大的差速会让车尾甩起来,尤其在低速高摩擦地面。直行速度设定在60%左右比较合适,也就是占空比600/1000,这样给小电池电压波动留了余量。
判断左右电机是否一致,最简单的办法是把两个轮子悬空,调用一个固定PWM输出函数,看两轮转速是否一样。快的那一侧在程序里把PWM调低10%。如果两边转速差异超过15%,更换电机,因为差速控制算法默认两电机特性一致,差异大时黑线永远走不直。
4.3 用串口输出传感器状态
SYSTEM里的usart模块已经把printf重定向好,主循环里加一条:
printf("L:%d R:%d PWM1:%d PWM2:%d\r\n", (int)left, (int)right, (int)last_pwm1, (int)last_pwm2);注意:printf重定向到串口需要勾选Keil的Use MicroLIB,否则浮点支持会占大量Flash,C8T6只有64KB很容易超。调试时串口波特率通常设置为115200,PA9发送、PA10接收。串口输出的意义在于,你可以拿着小车贴着黑线缓慢推过赛道,看日志里的left/right状态变化是否符合预期。如果状态序列是01 -> 11 -> 10,说明传感器间距太大,需要把两个模块往中间收一点。
最后推荐加一个弯道减速的细节。检测到左或右传感器压线后,不要直接把PWM从600切到300,否则车尾会因为惯性甩出去。常见做法是做一个斜坡函数:
// 弯道减速斜坡:每次主循环降低50,最小不过200 if (turning_speed > target_speed) { turning_speed -= 50; TIM_SetCompare1(TIM2, turning_speed); }逻辑说明:这个斜坡让电机从600降到300的过程有4~5个控制周期,每周期约10ms,车体有足够时间把重心移到前轮,而不是瞬间甩尾。参数50和200要根据实际跑道摩擦系数调整,太慢则进弯前不够减速,太快等于瞬间切换。
5. 进阶扩展:把双路循迹升级成灰度检测与FreeRTOS任务化
5.1 灰度传感器的动态阈值改造
双路数字量只输出0/1,在阳光直射下阈值点飘得厉害。常见做法是换成四路灰度模块,把模拟输出接到PA0~PA3,用ADC规则组采集。上电时做一次自动校准,记录白底和黑线的基准值,再取中间值作为判断阈值:
// 灰度传感器阈值校准:白黑基准取中值 uint16_t white_ref[4], black_ref[4], threshold[4]; for (int i = 0; i < 4; i++) { threshold[i] = (white_ref[i] + black_ref[i]) / 2; } // 运行中读取灰度值并判断是否压线 if (adc_value[i] < threshold[i]) { line_state |= (1 << i); }逻辑说明:灰度值越黑数值越小,所以小于阈值认为是黑线。校准最好在赛道现场进行一次,白底基准取小车正常跑动时采集到的最小值,防止地砖纹理或阴影导致白底值波动。四路灰度比双路多的不只是状态,而是可以通过左右两路灰度值的差,计算小车中心与黑线的横向偏移,这个偏移量就是PID控制器的误差输入。
5.2 把裸机循环改成FreeRTOS任务
如果你要把这个源码扩展成工创赛智能物流小车,裸机while循环很快会被超声波、摄像头、电机闭环占满。FreeRTOS移植后,建议拆两个任务:
// 5ms周期采样传感器,10ms周期控制电机 void vSensorTask(void *arg) { for (;;) { read_line_sensors(); vTaskDelay(5); } } void vMotionTask(void *arg) { for (;;) { update_line_follow_pwm(); vTaskDelay(10); } }任务间通过全局结构体或者队列传递line_state,不要在中断里直接修改PWM比较值。运动任务优先执行,传感器任务可以被打断。栈空间上,运动任务因为调用printf,要分配256 words以上;否则调用完第一次printf任务栈就溢出,HardFault_Handler入口会被踩进去。
提示:FreeRTOS的SysTick会抢占业务代码是正常的,但PWM定时器中断优先级不能低于FreeRTOS可管理的最低优先级,否则占空比更新会被SysTick延迟,出现电机抽动。
5.3 一层感知一层运动:摄像头循迹复用差速模型
黑线循迹、电磁三轮、摄像头循迹,区别只在上层感知,下层的L293D差速控制完全可以复用。摄像头方案里,把vSensorTask替换成摄像头处理任务,输出一个left_offset值,再映射到左右PWM差,其余逻辑不需要动。这也是拆这套源码最大的价值:先在一个简单的双路循迹上验证差速模型,再往复杂感知方向迁移,不用重写电机驱动。
如果你还有时间,可以把灰度模块的四路状态归一化成一个-100到100的偏差值,然后套一个位置式PID。P项负责回正,I项负责消除长时间跑偏的稳态误差,D项抑制过弯时的抖动。PID参数从P=30开始调,I和D先置0,等P项让小车不冲出赛道后,再慢慢加D到15左右,最后加I。这个调法在大多数TT电机小车上都能稳定跑。
本文还有配套的精品资源,点击获取