经典三问
Q: 循迹模块的 DO 是什么?
A: DO 是模块比较器根据反射光强输出的数字电平,只表示当前是否超过设定阈值。
Q: 为什么先用 DO 做循迹?
A: 它把复杂的光学变化压成容易读取的状态,能先验证传感器排布、方向判断和基本差速动作。
Q: DO 循迹常见使用场景?
A: 黑线跟随、边缘检测、全黑停车、全白丢线保护和低速路径验证。
把八路模块放到白底上,8 个 DO 各自保持稳定电平;黑线从探头下方经过,某一路的指示灯亮起,DO 也随之翻转。STM32 接收到的不是连续的灰度,而是模块上的比较器根据电位器阈值作出的二选一结果:黑,或者白;1,或者 0。原始反射光强的细节被留在了比较器之前,但这个简化后的结果已经足够让小车沿着线跑起来。
接线时把车头朝前,从左到右将探头编号为 S0 到 S7。代码约定黑线为 1、白底为 0;全黑停车,整排白底视为丢线,保持上一次转向方向低速找线。至于黑线到底对应高电平还是低电平,以实际读到的状态为准,统一在配置项中处理。
DO 不是"数字版 ADC"
很多常见 MH-Sensor-Series、TCRT5000 类模块都有 VCC、GND、DO 和 AO。其中 AO 是传感头此刻反射光强对应的模拟电压;DO 则经过板上的比较器。你拧动电位器,其实是在移动比较器的分界线:高过线的一边输出一种电平,低过线的另一边输出另一种电平。
text
红外发射 + 接收 -> 反射强弱 -> 模拟电压 AO -> 比较器 + 电位器 -> DO: 0 或 1
所以 DO 的优势和代价都很直接:
- 它能让 STM32 只读 GPIO,不需要先写 ADC、平均、阈值。
- 模块上已经先定好了"黑"和"白"的边界;环境一变,电位器就得重新调。
- 当它误判时,STM32 只看见一个 0 或 1,很难知道原始反射值离边界到底有多远。
刚开始做完整低速循迹时,我会先用 DO,因为黑线位置、转向和差速之间的关系能马上落到轮子上;需要更细的边缘位置、调试环境光或建立更稳定的判断时,再回头用 AO。
八个模块怎么接,不靠一张万能线序图
8 路模块都需要 VCC 和 GND,每个 DO 各接一根 STM32 输入线。模块常见支持 3.3V 到 5V 供电,但仍然应当以买到的模块资料为准。我使用 5V 供电,DO 输出通常可以直接给 3.3V MCU 读取。
我暂定的 GPIO 是:
text
S0 -> PA4
S1 -> PA5
S2 -> PA8
S3 -> PA11
S4 -> PA12
S5 -> PA15
S6 -> PB0
S7 -> PB5
PA15 需要关闭 JTAG 后才能当普通 GPIO 使用,工程会在初始化时处理这一点。你的循迹板如果排针顺序不同,只改引脚表,传感器从左到右的权重顺序不要跟着物理线束乱掉。
车头朝前看时,S0 是最左侧,S7 是最右侧。八个模块必须排在同一条横线上,离地高度尽量一致。一个模块抬得更高,另一个贴得更近,代码再整齐,读到的也不是同一条黑线。
先把电位器调到黑白交界,而不是随便拧
先不上车跑。给模块供电,把其中一个探头依次放到白底和黑线下方,观察模块板上的比较器指示灯或者 OLED 显示。慢慢调电位器,找到一个位置:白底稳定输出 0,黑线稳定输出 1。
不是所有模块都按这套电平方向工作。工程把这个约定写成一个宏:
c
#define LINE_SENSOR_BLACK_IS_HIGH 1
若你实际测到黑线是 0、白底是 1,不要急着把八根 DO 线交换,也不需要把所有判断条件里的 if 逐行取反,只要改为:
c
#define LINE_SENSOR_BLACK_IS_HIGH 0
模块指示灯亮不亮也不必和"黑线为 1"强行绑定。灯接在比较器的哪一侧、板子是否低电平点亮,都取决于模块电路。相信你在 OLED 里实测到的逻辑值。
八个 0/1 先装进一个字节
定时器每 10ms 读取一次传感器。bit0 放 S0,一直到 bit7 放 S7,这样 8 路状态只占一个 uint8_t:
text
S7 S6 S5 S4 S3 S2 S1 S0
0 0 0 1 1 0 0 0 二进制:00011000
工程在 LineSensor_ReadMask() 里把它们收起来:
c
if ((GPIO_ReadInputDataBit(port, pin) != Bit_RESET) == LINE_SENSOR_BLACK_IS_HIGH)
{
mask |= (uint8_t)(1u << index);
}
这里不需要中断。DO 是已经稳定的数字状态,不是编码器那种每一个边沿都不能漏的高速脉冲。每 10ms 看一次,对低速循迹已经足够快,也让控制节拍固定下来。
10ms 控制任务为什么放在定时器里
这辆教学小车里,TIM1 每 1ms 进入一次中断。计数满 10 次后,直接调用传感器和电机更新:
c
void TIM1_UP_IRQHandler(void)
{
if (TIM_GetITStatus(TIM1, TIM_IT_Update) != RESET)
{
TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
system_ms++;
control_divider++;
if (control_divider >= 10)
{
control_divider = 0;
LineSensor_Update();
Motor_Update();
}
}
}
LineSensor_Update() 只是读取 8 路 GPIO、算一个很小的固定差速;Motor_Update() 只是写方向脚和 TIM2 的比较值。这些动作短、时间稳定,放进 10ms 中断能保证控制周期不被主循环里多一条调试代码打乱。
Motor_Update() 写的是 Motor_SetTargetPercent() 存下来的两个目标值。这个接口就是前面 PWM 那篇里单轮版本的两轮版:
c
void Motor_SetTargetPercent(int16_t left, int16_t right)
{
left_target = Motor_LimitPercent(left);
right_target = Motor_LimitPercent(right);
}
Motor_LimitPercent() 做的是同一件事:把越界的数夹回 -100 到 100。单轮调用现在只是一个薄壳:
c
void Motor_SetPercent(int16_t percent)
{
Motor_SetTargetPercent(percent, percent);
}
OLED 刷屏仍然放在 main(),每 100ms 才做一次:
c
if (Timer_Expired(&report_time, 100))
{
OLED_ShowNum(0, 0, LineSensor_GetMask(), 3, OLED_6X8);
}
把一整屏 OLED 数据塞进 10ms 中断,刚刚获得的稳定节拍又会被自己砸掉。控制任务和调试任务都需要时间,但不该抢同一段时间。
从"哪边压线"到固定差速
八路传感器不是八句独立的 if。代码给每个位置一份权重:
c
static const int8_t weights[8] = {-7, -5, -3, -1, 1, 3, 5, 7};
黑线被 S0、S1 看到,平均位置偏左;被 S6、S7 看到,平均位置偏右;中间的 S3/S4 命中,位置接近 0。这里算出来的 position 只是"线偏在哪一边"的离散描述,不是任何神秘控制量。
默认直走给:
text
left = 30, right = 30
线偏左,左轮慢、右轮快,小车就往左收回来:
text
轻微左偏:left = 24, right = 36
明显左偏:left = 18, right = 42
严重左偏:left = 10, right = 50
线偏右时反过来。工程把这些差速写成 6、12、20 三档,不会假装它们对每个底盘都正确:
c
if (position == 0)
{
correction = 0;
}
else if ((position <= -5) || (position >= 5))
{
correction = 20;
}
else if ((position <= -2) || (position >= 2))
{
correction = 12;
}
else if (position != 0)
{
correction = 6;
}
LINE_BASE_SPEED 默认是 30。某些电机带着车身低于 50% 就压不过静摩擦,30/10/50 在它们身上不会换来漂亮的渐变转向,只会换来停、抖、突然窜出去。那不是 8 路循迹逻辑错了,是电机、减速箱、供电、地面和占空比门槛没有一起考虑。先架空观察方向,再从实际能稳定起转的最低速度往上调。
两个特殊画面,必须单独写
全黑:11111111
八个模块同时看到黑线,工程默认停车:
c
if (sensor_mask == 0xFF)
{
Motor_SetTargetPercent(0, 0);
return;
}
它可能意味着终点横线、加宽的标记、传感器贴得太低,也可能只是一张整块黑纸。默认将它作为最保守的安全行为;赛道把全黑定义为"掉头"或"停止线"时,再将这个状态映射到相应动作。
全白:00000000
八个模块都没看到黑线时,不让车直接停住。它保留最后一次偏转方向,低速往那边找:
c
if (sensor_mask == 0x00)
{
if (last_turn < 0)
{
Motor_SetTargetPercent(10, 35);
}
else
{
Motor_SetTargetPercent(35, 10);
}
return;
}
最后一次线偏左,就继续往左寻找;最后一次偏右,就继续往右寻找。这个策略不保证每一种赛道都能找回线,它只是比"丢线以后随机直走"更有依据。若你只用 2 路传感器,很容易在一个急弯处直接把线丢掉;8 路排在车头前方,能给这段寻找动作多留一些位置线索。
先在 OLED 里确认,再放到地上
主循环每 100ms 更新调试信息。OLED 三行依次显示:
text
第一行:8 路掩码的十进制值
第二行:left 目标速度
第三行:right 目标速度
先把一小段黑线从 S0 缓慢扫到 S7,看掩码和左右目标速度怎样变化。再把整排放到白底,确认它进入上一次转向的找线逻辑;最后用整块黑卡纸盖住,确认两轮目标都变成 0。
确认这些之后才接电机。先架空,让左/右轮执行的方向与画面中"线偏左就向左修正"一致;若实际转向反了,通常是电机安装方向、驱动板方向脚或左右电机定义和代码约定相反。先统一这些基础方向,不要把 8 路权重全部颠倒来掩盖一根电机线接反的问题。
放到地面后,环境光一变、黑线褪色、白底反光、模块高度不齐、两个同型号模块电位器位置不一样,都可能让 DO 在边缘附近来回跳。重新逐个调电位器,把黑白切换点留在稳定范围中间;真需要知道每个模块离阈值还有多远,就进入 AO 模拟输出篇。
最后橘猫说
现在的转向是固定三档差速:看到线在左边就给左慢右快,看到线在右边就反过来。急弯、不同速度、不同电机启动门槛和不同赛道,都会让同一张表出现过转或转不过去。
但它有一个好处:每一个动作都能从 mask、position、左右目标速度直接追出来。你把黑线挪到 S0,OLED 显示更大的左偏转向;把黑线挪回中间,两个目标又回到同一速度。传感器、差速和轮子之间的因果链没有被参数遮住。
DO 模式给小车的是一排已经越过阈值的决定。把这些决定接到可靠的固定节拍和低速差速上,小车就能沿线走;需要观察比较器作出决定前的电压时,再使用 AO。