简介:一套面向智能小车开发与嵌入式入门学习者的巡线传感器驱动源码包,覆盖C51、STM32、Arduino三大主流平台,解决巡线场景中传感器数据采集、黑白线识别、偏移量计算与电机调速等核心问题,适合高校课设、电子竞赛及个人DIY项目参考。压缩包共11个文件,打包体积约7.84MB,包括4份docx实验文档、2个rar平台工程、1份PDF原理图以及.c/.py/.ino等源码文件,图中还附带4路巡线原理图,方便对照硬件接线理解程序流程。该资源已有740人学习下载,热度可观。价值方面,读者既能从实验文档中梳理巡线传感器的工作原理和调试步骤,也能通过对比三种平台驱动代码,体会不同单片机在IO读取、中断处理和PID控制上的实现差异;同时,现成的源码与工程可直接移植或二次开发,有效降低从零搭建智能小车项目的门槛,提升嵌入式系统设计能力。
1. 同一个巡线传感器,三种单片机为什么差出一套驱动
TCRT5000 巡线传感器的核心信号只有“压到黑线”和“没压到黑线”两种,但 C51、STM32、Arduino 三种主控拿到这路信号后的处理方式差别很大。C51 的sbit位寻址可以把每个传感器当开关读,几行判位代码就能完成巡线;STM32 的 GPIO 时钟、ADC 采样和中断优先级更复杂,适合把模拟量读成连续偏差;Arduino 的库把底层封装好了,上手快,但库隐藏的采样间隔和引脚映射会让老手在调 PID 时回头找原因。下文直接按三个平台的典型驱动写法拆开讲,给能直接改引脚定义的最小源码,再补一份跨平台通用的校准顺序和现场排错手段。无论是课设、毕业设计还是竞赛小车,都能在这套逻辑里找到对应的那一段。
2. C51 巡线驱动:位读取、方向判定和丢线保护
2.1 TCRT5000 数字量输出与 C51 IO 的对接
TCRT5000 模块由红外发射管和光电接收管组成,白底反射红外光强,接收管导通程度高;黑线吸收红外,接收管截止。模块板载比较器把这种反射差异转化成稳定电平,数字输出脚只有两个电压状态。需要先明白一件事:比较器出来的电平极性不统一,有的模块压黑线输出高电平,有的压黑线输出低电平。C51 这边不依赖上拉输出,P1 口在复位后为准双向口,直接读引脚即可,外部不用再并联上拉电阻。
对接时最常见的接法是把五个传感器的数字输出依次接到 P1.0 到 P1.4,传感器供电 5V,输出信号也是 5V,和 5V 供电的 STC89C52RC、AT89C52 电平兼容。如果用的是 3.3V 的 STC15 系列,模块最好也换成 3.3V 供电,或者把模块输出串一个 1k 电阻再进引脚。模块上那颗蓝色电位器,调的是比较器阈值,也就是“反射强度多弱才算黑线”,具体调法放到 2.3 节说。
| 模块形态 | 输出脚含义 | C51 侧接线 | 读法 |
|---|---|---|---|
| 带比较器的数字量模块 | 压黑线输出高或低,由模块版本决定 | P1.x 直接接,加 100nF 电容滤波 | sbit位读取 |
| 模拟量模块 | 输出电压随反射强度连续变化 | P1 读不了模拟量,需外接 ADC0832 等 | 不推荐直接接裸 C51 |
表格里最后一行不是偷懒,是给选型提个醒:纯数字量方案在 C51 上最稳,非要模拟量就得多一个 SPI 接口的 ADC 芯片,驱动复杂度直接翻倍。
提示:传感器模块和主控板必须共地。车上的电池负极、模块 GND、单片机 GND 三个点不连在一起时,信号电平会悬空,读回来的状态字在全 0 和全 1 之间乱跳。
2.2 五路轮询的最小驱动:状态字代替层层判断
写巡线驱动最忌讳的是五个if嵌套到底,后面换线序时只改判断逻辑就改到头大。先把五个引脚读成一个 8 位状态字,一个传感器对应一个位,再用状态字做分支。下面是完整的最小主循环:
#include <reg52.h> sbit LINE_1 = P1 ^ 0; /* 最左一路 */ sbit LINE_2 = P1 ^ 1; sbit LINE_3 = P1 ^ 2; /* 中间一路 */ sbit LINE_4 = P1 ^ 3; sbit LINE_5 = P1 ^ 4; /* 最右一路 */ sbit ENA = P2 ^ 0; /* 左电机使能,接电机驱动板 INx */ sbit ENB = P2 ^ 1; /* 右电机使能 */ unsigned char get_line_mask(void) { unsigned char mask = 0; if (LINE_1) mask |= 0x01; if (LINE_2) mask |= 0x02; if (LINE_3) mask |= 0x04; if (LINE_4) mask |= 0x08; if (LINE_5) mask |= 0x10; return mask; } void main(void) { unsigned char mask; while (1) { mask = get_line_mask(); /* 0x04:只有中间一路压黑线,直行 */ if (mask == 0x04) { ENA = 1; ENB = 1; } /* 0x06:中间和左二压线,车头偏右,右侧轮提速 */ else if (mask == 0x06) { ENA = 0; ENB = 1; } /* 0x0C:中间和右二压线,车头偏左,左侧轮提速 */ else if (mask == 0x0C) { ENA = 1; ENB = 0; } /* 0x00:五个传感器全白,说明线丢了,做振荡搜索 */ else if (mask == 0x00) { ENA = 0; ENB = 1; } /* 其他组合:过十字、线序反向,保持上一轮动作 */ else { ENA = 1; ENB = 1; } } }这段代码的核心是get_line_mask()返回的位图。mask的 bit0 对应最左路、bit2 对应中路的约定一旦定下来,后面所有判断都按这个位图走;想加一路只要再补一个位。第二个关键点是控制逻辑里没有写绝对转速,而是写“哪侧轮子应该快”,具体占空比交给 PWM 输出部分,巡线逻辑和电机驱动就分开了。
如果模块是压黑线输出低电平,把get_line_mask()里的if (LINE_1)全部换成if (!LINE_1),状态字含义不变。判断方式也可以再简化:C51 的 P1 是寄存器,整段可以用mask = P1 & 0x1F;一步读到,等价于上面五句sbit拼位,但这样会直接依赖引脚物理顺序,换线序时不够直观,新手建议保留逐位拼装。
2.3 阈值的物理含义:电位器调的是“反光量分界线”
数字量模块上的电位器通俗说法是调“灵敏度”,实际调的是比较器反相输入端的参考电压。TCRT5000 的输出反射电压高于这个参考电压时,比较器翻转到低电平;低于参考电压时翻转到高电平。传感器距离地面高度不同、白纸反光率不同、黑线材质不同,同一个电位器位置得到的效果完全不一样。
调试顺序我一般是:先把车放在白纸上,缓慢拧电位器直到模块状态灯刚好熄灭或点亮,记住这个位置;再把传感器挪到黑线上,确认状态灯反转。两个位置之间通常有 1/4 圈以上的余量,如果余量很小,说明传感器离地太近或太远。TCRT5000 的推荐检测距离在 3~10mm,实际装好后传感器底面离地 5mm 左右最典型。低于 2mm 时白底和黑线的反射差反而变小,因为接收管贴近反光面后白底的黑斑也被算进来了。
这类模块的输出逻辑还有一个常见坑:同一家店的不同批次,丝印上可能标的是“黑线输出高”或“黑线输出低”。代码层面只做一个翻转就能适配,真正要小心的是别调完电位器再改极性,否则之前记录的阈值位置全部作废。
3. STM32 巡线驱动:GPIO 配置、ADC 采样与偏差计算
3.1 数字量与模拟量两套接线,驱动路线完全不同
STM32 和 C51 最大的区别在于,巡线传感器的信号接法决定了整套驱动的结构。接数字量输出时,STM32 只要配 GPIO 输入模式,把五个电平拼成状态字,逻辑和 C51 一致;接模拟量输出时,STM32 可以用片内 ADC 直接读电压,从而拿到“压线多深”的程度信息,这是 C51 方案做不到的,也是不少项目从 C51 换到 STM32 的原因。
模拟量方案的价值在弯道。数字量只有 0 或 1,传感器偏出黑线一半时看不出变化;模拟量读出来的是反射强度,压线一半和完全压线对应不同电压,用五路加权公式可以算出车体相对黑线的横向偏差,输出给电机的是连续差速而不是简单的快慢档位。代价是 ADC 转换要考虑采样时间、参考电压和信号隔离。
下表是两套接法的关键区别,照着这个表接线,至少能避免一半的硬件错误。
| 引脚类型 | 模块供电 | 信号电平 | STM32 注意事项 |
|---|---|---|---|
| 数字量输出 | 5V | 0V / 5V | 选带 5V 容忍的普通 IO 直接接 |
| 模拟量输出 | 3.3V | 0~3.3V | 接 ADC 通道引脚,如 PA0~PA7 |
| 模拟量输出 | 5V | 0~5V | 必须分压到 0~3.3V 后才能接 ADC |
第二、三行的差别很多人第一次没注意:STM32F103 的 ADC 输入范围是 0 到 VDDA,VDDA 一般就是 3.3V,模块从 5V 供电时直接接会让 ADC 满偏甚至损伤引脚。把模块的 VCC 改成 3.3V,输出范围跟着变小,这是最简单的方案。
3.2 用 HAL 库将五路数字输入读成巡线状态字
用 STM32F103C8T6 或 ZET6 最小板做巡线,GPIO 初始化差异不大。CubeMX 里把 PA0 到 PA4 设成 Input 模式,Pull-up 和 Pull-down 都选 No,因为比较器模块输出的是推挽电平,不需要内部上下拉。初始化代码的 HAL 部分如下:
GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | GPIO_PIN_4; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);读取函数直接读 IDR 寄存器,这比反复调用HAL_GPIO_ReadPin快,也好理解。PA0 在 bit0、PA1 在 bit1,正好组成一个 5 位状态字:
uint16_t read_line_mask(void) { uint16_t pin_levels; pin_levels = GPIOA->IDR & 0x1F; /* 只保留低 5 位 */ /* 如果模块压黑线输出低电平,这里取反 */ /* pin_levels = (~pin_levels) & 0x1F; */ return pin_levels; }GPIOA->IDR读的是整组引脚电平,0x1F掩码把 PA0 到 PA4 之外的信号全部滤掉。取反那行的作用前面提过:具体要不要取反,取决于模块的丝印标注或实测结果。注意取反后掩码还得要,别写成不掩码的~GPIOA->IDR,否则高 11 位会被拉高,状态字直接变成 0xFFFF 之类的异常值。
数字量方案到这里已经可以接电机跑了,但 STM32 真正值得写的还是模拟量。这个平台还有更进阶的玩法,比如把五路数字信号接成 EXTI 外部中断,黑线一碰到传感器就触发回调,主循环不用频繁读 GPIO。只是五路巡线本身的轮询频率在 2kHz 以下时,轮询读取完全够用,别为了用中断而引入中断优先级和回调重入的复杂度。
3.3 模拟量采样与质心法计算车体横向偏差
模拟量读取要先把 ADC 外设初始化出来。五个通道用扫描模式一次读完是常见做法,但驱动源码里更直观的是逐通道采样——五路传感器间距约 1.5cm,小车速度最多也就 1m/s,逐通道采样时间差不到 0.5ms,不影响偏差计算。初始化代码只保留必要参数:
ADC_HandleTypeDef hadc1; ADC_ChannelConfTypeDef sConfig = {0}; hadc1.Instance = ADC1; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.ScanConvMode = DISABLE; hadc1.Init.ContinuousConvMode = DISABLE; HAL_ADC_Init(&hadc1); sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; uint16_t read_adc_channel(uint32_t channel) { uint16_t value; sConfig.Channel = channel; HAL_ADC_ConfigChannel(&hadc1, &sConfig); HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 50); value = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); return value; }239CYCLES_5的采样时间对几十 kHz 的传感器信号绰绰有余,取值偏大一点反而能滤掉红外反射的高频抖动。逐次调用五遍read_adc_channel,得到adc[0]到adc[4],然后进入偏差计算:
#define LINE_THRESHOLD 400 /* 白底与黑线读数的中间值,需现场标定 */ typedef struct { uint16_t adc[5]; int16_t position; /* -2 全偏左,+2 全偏右,0 居中 */ uint8_t line_lost; } LineSensor; void calc_position(LineSensor *line) { int32_t weight_sum = 0; uint32_t value_sum = 0; uint8_t i; for (i = 0; i < 5; i++) { if (line->adc[i] > LINE_THRESHOLD) { weight_sum += (int32_t)line->adc[i] * (int32_t)(i - 2); value_sum += line->adc[i]; } } line->line_lost = (value_sum == 0); if (value_sum == 0) { line->position = 0; return; } line->position = (int16_t)(weight_sum / value_sum); }这个函数的原理是按每路 ADC 读数充当权重,对传感器位置求加权平均。i - 2把五路传感器的物理位置映射成 -2、-1、0、1、2,黑线越靠哪侧,权重和就偏向哪侧。除以value_sum是归一化,让结果不依赖黑线的绝对反射强度,这样同一个阈值在光照变化不大的环境中依然稳定。
weight_sum / value_sum的范围在 -2 到 2 之间,始终是定点数,直接当误差送给方向控制即可。LINE_THRESHOLD不是传感器手册给出的,是在实际赛道表面量出来的,阈值取“白底读数”和“黑线读数”的中间值。这里有个容易搞错的点:STM32 的 ADC 是 12 位,读数范围 0~4095,别把 Arduino 的 0~1023 阈值习惯搬过来。
注意:给 STM32 的 ADC 引脚接线之前,先确认模块供电电压。若模块由 5V 供电,模拟输出电压会超出 3.3V 量程,需要电阻分压或把模块供电改成 3.3V。
4. Arduino 巡线驱动:数字量挡位差速与模拟量连续差速
4.1 数字量五路读取:查找表映射到电机差速
Arduino 的巡线工程一般用 Uno 或 Nano 的最小板。五个数字输出引脚接 D2 到 D6,两个电机 PWM 引脚接 D9 和 D10,L298N 的 IN1~IN4 控制方向。驱动源码里最直接的区别是digitalRead返回后的逻辑处理。
| 方案 | 偏差输出 | 弯道表现 | 调试难度 |
|---|---|---|---|
| 数字量三档查表 | -1/0/1 三态 | 弯道有明显顿挫感 | 低,改数值即生效 |
| 数字量多档查表 | -2~+2 | 映射更细但仍离散 | 中,需要定义全部状态 |
| 模拟量加权偏差 | -2~+2 连续 | 适合比例差速和 PID 调参 | 高,需要标定阈值 |
const int sensorPin[5] = {2, 3, 4, 5, 6}; const int enA = 9; /* 右侧电机 PWM */ const int enB = 10; /* 左侧电机 PWM */ int readLineState() { int mask = 0; for (int i = 0; i < 5; i++) { if (!digitalRead(sensorPin[i])) { mask |= (1 << i); /* bit0 对应最左一路 */ } } return mask; } void loop() { int state = readLineState(); switch (state) { case 0x04: /* 中间压线,直行 */ analogWrite(enA, 140); analogWrite(enB, 140); break; case 0x06: /* 中偏左,右边提速 */ analogWrite(enA, 160); analogWrite(enB, 120); break; case 0x02: /* 明显偏左 */ analogWrite(enA, 180); analogWrite(enB, 100); break; case 0x0C: /* 中偏右,左边提速 */ analogWrite(enA, 120); analogWrite(enB, 160); break; case 0x08: /* 明显偏右 */ analogWrite(enA, 100); analogWrite(enB, 180); break; case 0x00: /* 丢线,原地左右摆扫 */ analogWrite(enA, 120); analogWrite(enB, 120); break; default: /* 过十字或多路同时压线,维持直行 */ analogWrite(enA, 140); analogWrite(enB, 140); break; } }这里用了if (!digitalRead(...)),默认模块压黑线输出低电平。如果是输出高电平的模块,把!去掉即可。mask |= (1 << i)是按位拼装状态字,和 C51 那节是同一个思想,只是 Arduino 上用int更顺手。
一个值得注意的细节:数字量方案里case 0x00丢线时给了同速,这看起来是直行,实际在弯道会直接冲出赛道。更好的处理是记录上一次有效状态,丢线后按上一状态的方向再加一档速度,这里为了演示只保留了最简结构。电机 PWM 值没做constrain,因为开关分支里已经写死了范围,后续改成 PID 连续输出时才需要加约束函数。
4.2 模拟量五路读取:按加权位置产生连续差速
数字量方案的差速只有固定的几档,过 S 弯时常觉得不够顺。Arduino 的analogRead返回 0~1023,五路 TCRT5000 接 A0 到 A4,读完做同样的加权求和,就能得到连续偏差。核心计算和 STM32 那节几乎一样,只把 12 位 ADC 换成 10 位:
const int analogPin[5] = {A0, A1, A2, A3, A4}; const int lineThreshold = 600; /* 这个值需要现场标定 */ int readScaledPosition() { int raw[5]; int weightSum = 0; int valueSum = 0; for (int i = 0; i < 5; i++) { raw[i] = analogRead(analogPin[i]); if (raw[i] > lineThreshold) { weightSum += raw[i] * (i - 2); valueSum += raw[i]; } } if (valueSum == 0) { return 0; /* 丢线状态由上层处理 */ } return weightSum / valueSum; }readScaledPosition的返回值在 -2 到 2 之间,车体偏差是连续值。拿到偏差后,给电机一个比例差速就够了:
const int baseSpeed = 150; const int kp = 25; /* 误差放大倍数,按赛道实测调整 */ void driveByPosition() { int pos = readScaledPosition(); int leftSpeed = baseSpeed + kp * pos; int rightSpeed = baseSpeed - kp * pos; leftSpeed = constrain(leftSpeed, 0, 255); rightSpeed = constrain(rightSpeed, 0, 255); analogWrite(enA, rightSpeed); analogWrite(enB, leftSpeed); }kp从 15 起调,观察 S 弯:低频震荡(左右甩)就加大,高频抖动(电机声音发颤)就减小。constrain是 Arduino 自带函数,比手写三目判断清晰。这个比例控制没有积分和微分,黑线断线时valueSum == 0直接返回 0,车会直线冲出,需要配合 4.1 里提到的丢线记忆逻辑。
4.3 串口打印与 Wokwi 仿真验证
验证驱动写没写对,最直接的办法是串口打印,而不是上电看车跑。Arduino 调试环境里加一段:
void setup() { Serial.begin(9600); } void debugPrint() { for (int i = 0; i < 5; i++) { Serial.print(analogRead(analogPin[i])); Serial.print("\t"); } Serial.println(); delay(100); }把debugPrint()放到驱动调用之前,人手拿着传感器在白底和黑线上来回移动,串口监视器里能直接看到五列数据的翻转关系。阈值标定的过程就是在这里完成的:白底读数记为 white[i],黑线读数记为 black[i],阈值取(white[i] + black[i]) / 2。注意初始阈值可以先给个中间值,比如 ADC 满量程一半的 512,让程序先跑起来再校。
没有实体板子时可以用 Wokwi 这类在线仿真器搭一套虚拟 Arduino 巡线小车,巡线传感器模块用可调电位器模拟反射强度变化。仿真器能验证代码逻辑,但电位器翻转的响应速度和真实红外反射差得很远,PID 参数基本不可移植,仿真只适合入门阶段和代码流程调试。
5. 跨平台巡线校准流程和三种高频异常现场
5.1 白底黑线的双点校准,动作比代码更决定效果
很多巡线车跑偏,不是代码问题,是传感器高度和阈值根本没对准。校准步骤固定成下面四步,三个平台都适用:
- 把车放在水平白纸面上,调整传感器支架,使传感器底面离地 5mm 左右,五路传感器都垂直于地面。
- 在白纸上记录五路读数,得到 white[i];再将车移到黑线上,让每一路传感器独立压线,记录 black[i]。
- 数字量模块直接调电位器,直到状态灯在白纸和黑线之间临界翻转;模拟量模块把阈值设为
(white[i] + black[i]) / 2。 - 把车放回赛道,从低速跑一圈,观察弯道中是否有某一路长期触发,有则是高度不均或阈值偏差。
第二步里最容易出错的是五路传感器同时压过黑线时读数相互串扰。TCRT5000 的红外发射角比较宽,相邻两路间距小于 10mm 时,一路压黑线会把相邻路也带低一点,所以记录 black[i] 时要保证其他四路都在白纸上,而不是整车骑在黑线上一起量。
5.2 三种高频异常现象和对应检查方向
巡线车最常见的三种异常,按优先级排的话是:某一路上电后一直保持同一个状态、五路全部没有反应、跑几圈后状态漂移。
| 异常现象 | 优先检查项 | 常见根因 |
|---|---|---|
| 单独一路永远输出同一电平 | 该路杜邦线是否松动、传感器焊点 | 接触不良或针脚虚焊 |
| 五路全无反应或全拉高 | 模块供电和主控共地 | 只接了信号线,GND 没接 |
| 跑一段时间后弯道外抛 | 传感器高度是否被撞偏 | 支架松动,5mm 离地距离变化 |
| 白底频繁误判为黑线 | 电位器阈值被拧偏 | 光电管老化或环境光线干扰 |
五路全无反应时先量模块 VCC 和 GND 是不是 5V。很多模块可以兼容 3.3V 供电,但输出电平会跟着拉低,有些主控还会因此误判。跑几圈后弯道外抛的问题,一半原因是固定传感器的铜柱没有锁紧,过减速带或触碰后高度变了;另一半原因是电机转速不稳,这时候检查电压和 PWM 占空比的关系,别急着改阈值。
最后一个实用技巧:用板载 LED 做信号监测,不用示波器也能定位丢线瞬间。C51 里把 LED 接到 P0.0,丢线时让它翻转;STM32 用 PC13 的板载 LED 同理。看到 LED 规律的亮灭节奏,就知道丢线发生在哪一段赛道。最后把测得的 white/black 阈值、传感器高度、跑圈时间记成一行日志,下一次赛道变化时直接照着重标一遍。巡线驱动的代码结构不会变,变的一直是这组阈值。
本文还有配套的精品资源,点击获取