经典三问
Q: 循迹位置误差是什么?
A: 它是根据八路传感器所在位置和黑线命中状态计算出的横向偏差。
Q: 为什么用误差而不是固定动作?
A: 连续误差能反映偏离程度,左右轮输出可以随偏差渐变,转向会比固定差速更平顺。
Q: 循迹位置环常见使用场景?
A: 黑线跟随、弯道修正、丢线保护和双轮小车的方向控制。
之前的循迹程序只有几组固定动作:直走时左右都是 30,偏到左边就把左轮减到 10、右轮加到 35。它能把一条简单的线跑完,弯道一急,代码里就开始堆新的数字。
八路传感器手里已经有一条更细的线索。左边亮几颗、右边亮几颗,车头偏到哪边,程序都能把它变成一个位置误差。这个误差经过 Kp、Kd 变成差速修正,最后交给左右速度环------速度环已经把它调稳,循迹位置环把它当一只听话的黑盒:循迹位置环只回答"该往哪边拧、拧多少",轮子要多快,内层自己会去追。
八个开关先组成一个位置
这里沿用 DO 的约定:黑线读到 1,白底读到 0。从左到右给探头编号 0 到 7,权重先用:
text
探头: 0 1 2 3 4 5 6 7
权重: -7 -5 -3 -1 1 3 5 7
左边探头压线,误差是负数;右边探头压线,误差是正数。中间两颗同时压线,-1 和 +1 的平均值就是 0,车头正对着线。
程序把所有有效探头的权重加起来,再除以有效探头数量:
c
weighted_sum = -7 + -5 + -3;
count = 3;
error = (float)weighted_sum / (float)count;
它没有挑某一颗"最重要"的传感器。线压在左边三颗时,三颗一起留下一个平均位置;边缘的一颗和中间的一颗同时亮,误差也会落在两者之间。这个数不再是"左转 10、右转 35"这样的动作编号,而是一把连续一些的尺子。
工程里用一个 8 位 mask 保存八颗探头:
c
uint8_t mask = LineSensor_ReadMask();
float error = LineSensor_CalculateError(mask, &line_found);
这个函数就是把上面那句话写成循环------查到哪几颗亮了、权重加起来、除以亮的颗数:
c
float LineSensor_CalculateError(uint8_t mask, uint8_t *found)
{
static const int8_t weights[8] = {-7, -5, -3, -1, 1, 3, 5, 7};
int16_t weighted_sum = 0;
uint8_t count = 0U;
uint8_t i;
for (i = 0U; i < 8U; i++)
{
if ((mask & (uint8_t)(1U << i)) != 0U)
{
weighted_sum += weights[i];
count++;
}
}
*found = (count > 0U) ? 1U : 0U;
if (count == 0U)
{
return 0.0f;
}
return (float)weighted_sum / (float)count;
}
例如从左到右只有第 2、3 颗为 1,掩码写成二进制就是 00110000------从最左边开始,一位对一颗探头,顺序和探头上车后的物理顺序一致。OLED 第一行就按这个形状印出八个字符:最左那颗亮是 10000000,最右那颗亮是 00000001------亮的是第几颗,字符串自己会说,不用猜。
引脚不必挤在同一组
这个工程把八路 DO 接在下面这些脚,避开了速度环已经占用的 PWM、编码器引脚,也不碰 OLED 和按键占着的位置:
| 传感器位置 | STM32 引脚 |
|---|---|
| 最左 0 | PA4 |
| 1 | PA5 |
| 2 | PA8 |
| 3 | PA11 |
| 4 | PA12 |
| 5 | PA15 |
| 6 | PB0 |
| 最右 7 | PB5 |
DO 是纯输入,本来不挑引脚,空着的脚都能接。这些是教学工程的参考分配。你的循迹板如果排针顺序不同,只改 sensor_pins[] 的端口和引脚,权重顺序不要跟着物理线束乱掉。。
初始化只负责把这些脚变成输入:
c
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA |
RCC_APB2Periph_GPIOB, ENABLE);
gpio.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 |
GPIO_Pin_8 | GPIO_Pin_11 |
GPIO_Pin_12 | GPIO_Pin_15;
gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &gpio);
gpio.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_5;
GPIO_Init(GPIOB, &gpio);
读这八颗就是按位拼一个字节,最左那颗是 bit0:
c
uint8_t LineSensor_ReadMask(void)
{
uint8_t mask = 0U;
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_4) != 0U) { mask |= 0x01U; }
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_5) != 0U) { mask |= 0x02U; }
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_8) != 0U) { mask |= 0x04U; }
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_11) != 0U) { mask |= 0x08U; }
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_12) != 0U) { mask |= 0x10U; }
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_15) != 0U) { mask |= 0x20U; }
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0) != 0U) { mask |= 0x40U; }
if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_5) != 0U) { mask |= 0x80U; }
return mask;
}
PA15 默认还连接着 JTAG。初始化这些传感器之前,工程先关闭 JTAG、保留 SWD 下载调试:
c
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);
比较器阈值、发射管高度、环境光这些事情已经在 DO 文章里处理过了。这里接收的就是模块已经做好的 0/1,不搞 AO 电压。
外环只改左右目标
位置环的计算很短:
c
correction = LINE_KP * error +
LINE_KD * (error - previous_control_error);
left_target = base_speed + correction;
right_target = base_speed - correction;
error 为负,说明线在左侧,左目标会变大还是变小,取决于你对电机正方向和转向的约定。上面这组加、减是一个参考起点,第一次落地时看车头实际偏转:若车越偏越远,先交换差速符号或调整方向宏,不要马上把 Kp 调成一个很大的数。
基础速度暂定 30 个脉冲/20ms,目标限制在 0~50:
c
#define LINE_BASE_SPEED 30.0f
#define LINE_TARGET_MIN 0.0f
#define LINE_TARGET_MAX 50.0f
#define LINE_KP 1.0f
#define LINE_KD 0.0f
急弯时修正量可能比基础速度还大。这里先把目标夹到 0,避免外环一脚把某个轮子打成反转;等低速完整跑通,再单独试允许内侧轮倒转的版本。左右目标得到以后,代码不直接写 PWM:
c
left_output = IncrementalPID_Update(&left_speed_pid,
left_target,
(float)left_actual);
right_output = IncrementalPID_Update(&right_speed_pid,
right_target,
(float)right_actual);
Motor_SetTargetPercent(FloatToPercent(left_output),
FloatToPercent(right_output));
Motor_Update();
这就是两层控制:循迹环改"希望轮子多快",速度环改"为了达到这个速度,PWM 该是多少"。传感器短暂抖一下,内层不会立刻把 PWM 当成方向命令;轮胎落地后的摩擦,也由速度环去补偿。
两个时间节拍别混在一起
八路 DO 每 10ms 读一次,速度和循迹控制每 20ms 算一次:
c
if (Timer_Expired(&line_sample_time, 10))
{
line_mask = LineSensor_ReadMask();
line_error = LineSensor_CalculateError(line_mask,
&line_found);
}
if (Timer_Expired(&control_time, 20))
{
/* 读取编码器、算循迹修正、更新两个速度 PID */
}
微分项的上一误差必须属于"上一次控制周期",不能在 10ms 采样时就覆盖掉。工程里分成两个变量:last_line_error 只用于丢线时保持方向,previous_control_error 专门给 Kd。这个区别很小,写错后 Kd 会安静得像没接上一样。
全黑和全白不是同一类异常
全黑时 mask == 0xFF。按照当前小车策略,这代表压进了大面积黑区,左右目标清零、速度 PID 复位、电机停止:
c
if (LineSensor_IsAllBlack(line_mask))
{
left_target = 0.0f;
right_target = 0.0f;
IncrementalPID_Reset(&left_speed_pid, 0.0f);
IncrementalPID_Reset(&right_speed_pid, 0.0f);
Motor_Stop();
}
两个辅助函数都不长。判全黑就是拿掩码和 0xFF 比;复位分两步,把这一路的输出直接设成给定值,再把误差历史清掉------不清历史的话,下一次增量里会带着上一个目标留下的旧趋势:
c
uint8_t LineSensor_IsAllBlack(uint8_t mask)
{
return (mask == 0xFFU) ? 1U : 0U;
}
void IncrementalPID_Reset(PID *pid, float output)
{
pid->output = output;
pid->prev_error = 0.0f;
pid->prev_prev_error = 0.0f;
pid->out_p = 0.0f;
pid->out_i = 0.0f;
pid->out_d = 0.0f;
}
全白时 mask == 0,它更像是探头暂时越过了线。此时保留上一次有效误差:刚才线在左边,就继续按左边的方向找;刚才线在右边,就往右找。刚上电还没有"上一次",误差保持 0,车不会凭空向一边冲。
落到代码里,它接在全黑那个 if 后面,是一条 else if:
c
else if (line_found == 0U) /* 全白:探头越过了线,这一拍没有位置 */
{
line_error = last_line_error; /* 按上一次的方向继续找 */
}
else
{
last_line_error = line_error; /* 这一拍有效,记下来给丢线用 */
}
line_found 是 LineSensor_CalculateError() 顺手带出来的:亮了几颗就置 1,一颗都没亮就置 0。所以 else if 判的不是掩码本身,而是"这一拍到底有没有读到线"。
全黑停车和全白找线是当前赛道的保守约定。赛道有十字、粗黑块或终点标记时,全黑未必意味着停车,需要另加状态判断;这里先把两种输入的动作分开,别让 mask == 0 和 mask == 0xFF 走同一条分支。
OLED 四行就够了
OLED 固定显示四行数字:
text
第 0 行:传感器掩码(8 个二进制字符,第一位是最左那颗)
第 1 行:误差×100
第 2 行:左速度目标
第 3 行:右速度目标
掩码按二进制印成八个字符,最左边那个字符是探头 0 ,也就是小车最左边那颗:全黑是 11111111,全白是 00000000。这样一行字符就是车上八颗探头的形状,从左往右读,和低头看车头是一个顺序。
要印成字符,中间只是一次移位判断------bits[0] 存探头 0,所以字符串的第一个字符就是最左边那颗:
c
void LineSensor_MaskToBits(uint8_t mask, char *bits)
{
uint8_t i;
for (i = 0U; i < 8U; i++)
{
bits[i] = ((mask & (uint8_t)(1U << i)) != 0U) ? '1' : '0';
}
bits[8] = '\0';
}
误差行多乘了 100。误差是加权平均,常常不是整数:三颗探头一起压线,权重加起来不一定能被 3 整除,会算出 -1.67、2.33 这样的数。一行整数最稳,于是把误差乘 100 再放上去,-1.67 显示成 -166(负数是向零截断),2.33 显示成 233。读的时候不用心算回去------负号表示线偏在左侧,正号表示偏在右侧,数字越大偏得越远;连续刷新时数字往 0 收,说明车头正在回线。
OLED 每 100ms 刷一次,比 20ms 的控制周期慢,但看趋势够用。手把小车抬起来时,轮子转得很快,传感器却没有经过真实地面;OLED 上先观察掩码、误差和目标差速,落地后再看车头是否真的朝线回去。
先调稳参数,再让它上赛道
循迹环的电机使能放在一个宏里,默认让电机先不转,等前面的读数和方向确认完再放开:
c
#define LINE_PID_ENABLE_MOTOR 0U /* 1U:循迹环把目标交给电机 */
把一颗传感器单独压在线上
默认 LINE_PID_ENABLE_MOTOR = 0U,先烧录。把一小段黑胶带从最左探头依次挪到最右,每次只让一颗探头看见黑线。掩码行会一格一格往右挪:最左那颗亮时是 10000000,往右移一颗是 01000000......最右那颗亮时是 00000001;误差行从负侧逐渐走到正侧。
如果黑线在最左,误差却变成正数,先交换传感器数组顺序或修正输入有效电平。这个实验不接触 PWM,适合在桌面完成。
恢复黑胶带位置,再让相邻两颗同时压线。观察误差是否落在两颗权重的平均值附近;中间两颗(权重 -1、+1)同时压线时,误差接近 0。胶带回到白底,掩码回到 00000000,误差保持上一次有效方向。
只改 Kp,看车头拧得有多急
确认掩码和误差方向后,把车放到平整地面,LINE_PID_ENABLE_MOTOR 仍保持 0,只看左右目标行。让黑线从中心慢慢移到左侧,逐步把 LINE_KP 从 1.0f 改到 2.0f、3.0f,每次只改一个数。
误差行数值相同时,Kp 越大,左右目标差越大。把目标差速超过电机落地起转门槛后,再开启电机;车头可能回线更快,也可能左右摆动。恢复上一组没有明显摆动的 Kp。
架空时轮子没有静摩擦,目标差速很小也会动;落地后内侧轮可能完全不转。参数必须在地面上重新确认,不能拿架空时"看起来很灵敏"的一组数字直接作为最终答案。
Kd 只压变化,不负责把车拉回去
保留 Kp,只把 LINE_KD 从 0.0f 改成 1.0f。在直线入口轻轻把车头推向一侧,观察误差行和左右目标行的变化;Kd 会对误差变化提前给出修正。继续加大,传感器掩码在黑白边界跳动时,目标差速也可能跟着细碎抖动。
恢复上一组 Kd。循迹位置环通常只用 Kp、Kd,Ki 保持 0;没有固定角度或位置需要长期累积,积分记住一次丢线偏差只会让回线后仍然拧着。
全黑停车,全白找线
正常运行起点是车压在白底的一条黑线上。先把八路探头全部放到黑纸上,确认掩码行显示 11111111,左右目标变成 0,电机停止;恢复到白底,再把车头整体移过黑线,直到掩码变成 00000000,观察左右目标是否仍保持上一次有效转向。
只改一个点,把全白分支里的 line_error = last_line_error 临时改成 line_error = 0.0f。重复丢线动作,误差行会归零,左右目标回到相同基础速度,小车会直走而非继续找线。恢复原代码。这一轮把"保持上次转向"从一句策略变成了可观察的差别。
跑一段低速完整赛道
把 LINE_PID_ENABLE_MOTOR 改为 1U,目标仍保持基础速度 30,车架先架空确认两个轮子的正方向,再落地放在直线起点。先让它低速走直线,观察误差行和左右目标行;再加入一个缓弯,观察误差从负到正的过程。
最后再做一次急弯和丢线恢复。车如果朝线外越拧,优先检查差速符号和左右电机方向;车头能回线但左右摆动,再看 Kp/Kd;内侧轮总是不动,先测落地起转 PWM 和电池电压。别用一个参数名替换掉三种不同故障。
循迹位置环跑通以后,固定差速表可以删掉了。速度环让两只轮子各自跟住目标,循迹环只根据八路 DO 的位置改变目标差。再往后,速度目标可以来自更高层的路径规划,传感器也可以换成灰度或编码器融合,下面这条反馈链已经有了可以继续加东西的落点。
最后橘猫说
这次真正管用的变化,是把"固定差速表"换成"误差"。差速表是几个离散档位,代码里那串数字越堆越长;误差是把八颗探头的位置平均成一个连续量,后面接 Kp、Kd 就够了。