平衡小车线性CCD寻迹:从TSL1401驱动到姿态环融合的嵌入式实战
2026/9/15 17:27:00 网站建设 项目流程

简介:面向智能车竞赛与嵌入式学习的一套平衡小车线性CCD寻迹资源,基于STM32F4,使用HAL库和CubeMX图形化配置,大幅降低工程搭建与移植成本。项目代码实现了二值化、动态阈值等图像预处理算法,将CCD采集到的数值滤波后引入位置环与速度环,构成串级PID,实测运行稳定;代码注释完善,模块划分清晰,便于快速移植和二次开发。整个资源包共82个文件,压缩后约105.59MB,其中包含44个zip工程与资料包、10个PDF和4个DOC格式的PID参考资料,如PID算法、参数整定、控制原理等经典文档,另有PCB工程、原理图及串口调试助手等硬件调试工具,覆盖从算法代码到硬件设计的完整链路。附带PID详解与优化算法笔记、直立行车参考设计方案,配合可直接运行的工程,可帮助读者系统梳理PID整定思路并快速搭建平衡寻迹小车。已有2798人学习下载,适合具备一定单片机基础、希望深入智能车控制的中高级开发者。

1. 平衡小车线性CCD寻迹:先站稳,再寻路的系统组合题

平衡小车先要“站得住”,再谈“看得见”。常见循迹小车用一排红外对管,只有 8 路到 16 路开关量,能应付简单弯道,但换算成赛道位置误差时分辨率粗糙。线性 CCD 则把视野变成一维灰度数组,常用 tsl1401 有 256 个像素,位置分辨率远高于开关量,也更容易在高速弯里提前预判路径。把线性 CCD 放到平衡小车上,难度立刻翻倍:车体本身是不稳定系统,姿态内环要跑到 1kHz 左右的更新率;而 CCD 视野随车身俯仰和转向晃动,寻迹数据又带有明显低频噪声。本文从 STM32 HAL 库的工程写法出发,拆开 TSL1401 线性 CCD 的采集时序、灰度质心算法,以及姿态环与寻迹环如何合成一个最终转矩控制量。适合正在做平衡循迹小车项目、对“姿态环快、图像环慢”这对时序关系还模糊的学生,也想给从裸机 GPIO 转向 HAL 库事件驱动架构的工程师一个可对照的参考。

2. 基于 HAL 库的 TSL1401 线性 CCD 采集与曝光控制

2.1 TSL1401 引脚时序和 Cubemx 初始化配置

线性 CCD 常用型号 tsl1401 有 128、256 两种像素规格,封装上无非 SI、CLK、AO、VCC、GND 五个关键引脚。SI 是启动积分信号的同步位,CLK 是像素输出时钟,AO 是模拟电压输出。每个 CLK 上升沿会让内部移位寄存器把当前像素的电荷包送到输出端,而 SI 的高电平决定了这一帧采集从哪一位开始。控制这个芯片的 HAL 工程思路很直接:用两个普通 GPIO 模拟 SI 和 CLK,用一个 ADC 通道采样 AO 电压。工程初始化不需要任何 I2C 或者 SPI 外设,外围电路至少留一个 0.1uF 退耦电容,AO 输出阻抗较高,ADC 采样时间尽量拉长到 28.5 周期以上,避免采样保持电容还没充满就被读走。

CubeMX 里需要配置的内容有三项:CCD_CLK 引脚和 CCD_SI 引脚设为推挽输出,AO 对应引脚设为模拟输入,ADC1 选择单通道扫描模式,分辨率 12 位。初始化函数里把 CLK 和 SI 都拉低,保证上电瞬间不会产生多余的移位脉冲。TSL1401 的数据手册要求 SI 高电平至少保持 230ns,但 HAL 库函数调用开销远大于这个时间,所以完全不用加延时,直接用HAL_GPIO_WritePin翻脚即可。

// tsl1401.h 中定义的引脚宏 #define CCD_SI_PIN GPIO_PIN_6 #define CCD_SI_PORT GPIOB #define CCD_CLK_PIN GPIO_PIN_7 #define CCD_CLK_PORT GPIOB #define CCD_PIXEL_NUM 256 // tsl1401.c 中的初始化,CubeMX 生成后补充即可 void TSL1401_Init(void) { HAL_GPIO_WritePin(CCD_CLK_PORT, CCD_CLK_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(CCD_SI_PORT, CCD_SI_PIN, GPIO_PIN_RESET); }

这段代码所做的工作是确定 SI 和 CLK 的初始电平。两个引脚都拉低的好处是上电后不会误启动移位寄存器,同时给后续TSL1401_Scan提供一个确定的状态起点。GPIO 翻转速度在 STM32F103 上约 18MHz 左右,对应单个 CLK 周期大概 55ns,远低于手册要求的最大时钟频率,所以不需要为此配置定时器 PWM 输出。

2.2 读取一帧线性 CCD 数据的 HAL 轮询代码

读取一帧 tsl1401 的标准节奏是:先产生一个 SI 上升沿,将内部移位寄存器复位到第 0 号像素;随后连续给出 256 个 CLK 脉冲,每给一个脉冲就把当前像素的电压送到 AO 引脚;在 CLK 为低电平期间读取 ADC 转换结果。常见的错误是把 SI 和 CLK 的先后顺序写反,或者丢掉最后一个像素的采样点,导致整条灰度曲线整体偏移一个像素。

// 轮询方式读取一帧 CCD 数据,raw 数组长度至少为 CCD_PIXEL_NUM void TSL1401_Scan(uint16_t *raw) { // 步骤1: 产生 SI 高电平脉冲 HAL_GPIO_WritePin(CCD_CLK_PORT, CCD_CLK_PIN, GPIO_PIN_SET_PIN); HAL_GPIO_WritePin(CCD_SI_PORT, CCD_SI_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(CCD_SI_PORT, CCD_SI_PIN, GPIO_PIN_SET_PIN); // SI 拉高 HAL_GPIO_WritePin(CCD_CLK_PORT, CCD_CLK_PIN, GPIO_PIN_RESET); // CLK 下降沿进入积分 HAL_GPIO_WritePin(CCD_CLK_PORT, CCD_CLK_PIN, GPIO_PIN_SET_PIN); // CLK 上升沿锁存 HAL_GPIO_WritePin(CCD_SI_PORT, CCD_SI_PIN, GPIO_PIN_RESET); // SI 拉低,准备移位输出 // 步骤2: 连续读取 256 个像素 for (uint16_t i = 0; i < CCD_PIXEL_NUM; i++) { HAL_GPIO_WritePin(CCD_CLK_PORT, CCD_CLK_PIN, GPIO_PIN_RESET); // CLK 低,输出当前像素电压 HAL_ADC_Start(&hadc1); if (HAL_ADC_PollForConversion(&hadc1, 1) == HAL_OK) { raw[i] = HAL_ADC_GetValue(&hadc1); } HAL_GPIO_WritePin(CCD_CLK_PORT, CCD_CLK_PIN, GPIO_PIN_SET_PIN); // 拉高,准备下一像素移位 } }

这段代码最需要注意的是 SI 脉冲产生方式:先让 CLK 保持高,SI 拉高后再将 CLK 拉低,这才能在 SI 为高期间制造一个完整的 CLK 下降沿,tsl1401 内部正是在这个沿锁存积分起始点。随后每次循环先把 CLK 拉低,此时 AO 引脚上是第 i 个像素的输出,完成 ADC 采样后再把 CLK 拉高,为下轮移位做好准备。HAL_ADC_PollForConversion的超时参数填 1ms,实际转换大约十几微秒,超时只是防御性保护,不会真的阻塞系统。

2.3 曝光时间对灰度的影响和参数选择

tsl1401 没有单独的曝光引脚,曝光时间实际上是由上一条 SI 脉冲复位到下一次 SI 脉冲复位之间的间隔决定的。简单理解就是:CLK 只负责“搬运”,SI 才是“快门”。同一行代码里,如果扫描循环间隔很短,曝光时间就短,亮的赛道和暗的背景之间对比清晰,但在弱光环境下整体灰度值偏低;如果两次扫描之间加入HAL_Delay(5)或定时间隔,曝光变长,动态范围变大,但车身抖动产生的运动模糊也会变明显。

曝光参数曝光时间适用场景副作用
无额外延时约0.5ms室内强光、黑白对比明显环境光弱时灰度低
HAL_Delay(2)约2.5ms常见实验室灯光帧率约400Hz,够用
定时器10ms约10ms户外或傍晚弯道运动模糊明显

实际平衡小车项目中,我习惯把 CCD 扫描放在一个 2ms 的定时器回调里,而不是在 while 循环里直接跑。这样曝光时间固定为 2ms 左右,保证比赛场地光照变化下灰度曲线形状稳定,同时不会因为姿态环代码耗时波动而影响有效像素数量。使用 FreeRTOS 时甚至可以单独开一个 CCD 任务,用osDelay控制曝光周期,但需要注意某些 CubeMX 版本生成的 HAL 库在 SysTick 优先级设置上会干扰定时器中断。

3. 线性 CCD 寻迹的位置算法:二值化、质心与丢线判断

3.1 灰度数据的脏点滤波与阈值选定

拿到 256 个原始 ADC 值后不能直接算质心。第一个坑是热像素,个别像素在长时间曝光后会稳定输出一个明显偏高的值,肉眼在串口波形里看是一根竖立的尖刺;第二个坑是环境光慢变化,左边窗口反光强、右边窗户反光弱,让整条灰度曲线呈现一个倾斜的基线。最简单有效的做法是先做一次滑动中值滤波,窗口取 3 像素,然后根据整帧灰度平均值计算阈值。

// 3点中值滤波,去掉孤立的亮点像素 void CCD_MedianFilter(const uint16_t *src, uint16_t *dst, uint16_t len) { uint16_t a, b, c; for (uint16_t i = 1; i < len - 1; i++) { a = src[i - 1]; b = src[i]; c = src[i + 1]; dst[i] = (a + b + c) - (a > b ? (b > c ? c : b) : (a > c ? c : a)) - (a < b ? (b < c ? b : c) : (a < c ? a : c)); } dst[0] = src[0]; dst[len - 1] = src[len - 1]; }

中值滤波的代价是一次遍历的时间开销,在 main 主频 72MHz 的 F103 上跑 256 个像素只需要几十微秒,可以忽略不计。滤波后阈值选取分两种流派:固定阈值和动态阈值。固定阈值在调试机上非常直观,把每个像素的灰度用串口画出来,找一个能分开黑线与背景的值,填成宏定义。但一旦小车从室内搬到走廊,环境光变了,这个值就得重新调。动态阈值用一个暗区和亮区的中间值,即整帧灰度的平均值,或者最大最小值的中位点,代码只有几行,效果却稳定很多。

// 根据帧内最大、最小灰度计算自适应阈值 uint16_t AutoThreshold(const uint16_t *raw, uint16_t len) { uint16_t min_v = raw[0], max_v = raw[0]; for (uint16_t i = 1; i < len; i++) { if (raw[i] < min_v) min_v = raw[i]; if (raw[i] > max_v) max_v = raw[i]; } // 当最大减最小低于噪声波动时,视为没有赛道信息 if (max_v - min_v < 40) return 1024; return (uint16_t)((min_v + max_v) / 2); }

这里有一个隐藏经验:如果赛道是白底黑线,寻迹对象比背景暗,需要把大于阈值当背景,小于阈值当目标;如果是黑底白线,条件反过来。工程上更稳妥的办法是同时统计超阈值像素和低于阈值的像素,看哪一组像素数量更像一条线宽。

3.2 加权质心法定位线性 CCD 寻迹线位置

二值化会丢失灰度深浅差异,同一个白色区块边缘像素灰度往往更低,直接二值化会把边缘切掉一圈。加权质心法直接使用原始灰度值作为权重,亮的地方对位置贡献大,暗的地方贡献小,得到的中心位置天然含有亚像素精度。对 256 像素的线性 CCD,质心计算式是:所有像素序号乘以灰度值的总和,除以灰度值总和。对大于阈值的像素才参与计算,背景像素权重为 0,避免大片弱反射物体把结果带偏。

typedef struct { uint8_t valid; // 1 表示本帧有效 uint16_t centroid; // 中心像素位置,乘以100避免浮点 uint16_t width; // 寻迹线所占像素宽度 uint16_t max_value; // 本帧最大灰度 } DetectResult; DetectResult CCD_CalcPosition(const uint16_t *raw, uint16_t len, uint16_t threshold) { DetectResult res = {0}; int32_t sum_g = 0; int32_t sum_gi = 0; for (uint16_t i = 0; i < len; i++) { if (raw[i] > threshold) { sum_gi += (int32_t)i * raw[i]; sum_g += raw[i]; res.width++; if (raw[i] > res.max_value) res.max_value = raw[i]; } } if (sum_g > 0) { res.valid = 1; res.centroid = (uint16_t)((sum_gi * 100UL) / sum_g); } else { res.valid = 0; res.centroid = 0; } return res; }

加权质心的逻辑说明:sum_gi是像素位置与灰度的乘积累积,sum_g是灰度累积。一个像素位置编号 150、灰度 500 的亮点,对中心位置的贡献是 150×500,而另一个位置 180、灰度 200 的暗点贡献只有 180×200,两者最终会向更亮的方向偏移,这正是想要的效果。返回值valid=0对应整帧没有超阈值像素的情况,上位机通常把这一状态解释为丢线或者冲出赛道。

3.3 丢线判断和多段线检测

线性 CCD 视野只有一条很窄的横线,一旦车身倾斜角度大,弯道内侧可能出现赛道线超出视野范围的情况,此时质心还可能落在视野边缘内,形成假位置。判断是否真丢线不能只看valid,要看质心是否进入像素 0 到 10 或像素 245 到 255 的边缘区,并且width是否小于正常线宽的一半,如果两个条件同时满足,就认为车身已经把寻迹线压到了视野外侧。

多段线是另一个隐蔽问题:赛道贴了几条胶带或者反光灰线,灰度图里出现两个分离的波峰。质心会把两个峰的平均位置当成目标,而实际上应该选择距离上一帧质心最近的那个峰,舍弃远处的干扰峰。简单做法是把连续大于阈值的像素段分割出来,每段作为一个候选,分别计算各自的质心,然后与上一帧位置比较,选择一个差值最小的候选作为有效跟踪对象。这个判断逻辑在巡线调试阶段非常有用,省去了反复粘贴黑胶带的痛苦。

4. 平衡小车姿态闭环与寻迹转向的融合控制架构

4.1 姿态环快、寻迹环慢的时序关系

平衡小车本质上是一个倒立摆,姿态环不工作,车子马上倒,寻迹环根本没有机会执行。姿态内环用的是 MPU6050 这类 IMU 传来的角速度与角度,通过串级 PID 输出电机转矩差。这个环路的更新率至少要 1kHz,也就是主循环里每条指令执行时间不能超过 1ms。而线性 CCD 一帧从曝光到计算质心大约需要 2ms 到 5ms,如果每帧都直接控制电机,相当于给姿态环注入一个以 200Hz 变化的干扰信号。

控制架构上的常规做法是把姿态环和寻迹环拆成两层:内环是姿态串级 PID,外环是 CCD 位置环。外环运算结果不是直接叠加到电机 PWM 上,而是作为内环的目标角速度给定值。姿态环负责让车身保持直立,同时跟踪这个角速度目标值,车速和转向角度都最终通过改变车身倾斜力矩实现。这种“位置环输出目标角速度”的做法,能天然利用直立控制本身的动态特性,让转向变得平滑,而不需要额外编写速度斜坡函数。

4.2 CCD 偏差输出到电机转矩的 HAL 代码示例

把第 3 节得到的质心位置转成转向目标值,最常用的是比例控制加微分前馈。质心位置减去中间像素值得到偏差,偏差经过比例系数后作为零偏角速度,再和陀螺仪 Z 轴角速度做一次 PI 差速控制,得到左右电机转矩偏差量。下面代码放在 1ms 的定时器中断里执行,CCD 数据由更高层循环以 2ms 周期更新。

// 平衡小车控制参数 #define CENTER_PIXEL 128.0f // 256 像素线性 CCD 的中间位置 #define TRACK_KP 0.08f // CCD 位置环比例系数 #define TRACK_KD 0.02f // CCD 位置环微分系数 #define YAW_KP 6.0f // 角速度环比例 #define YAW_KI 0.01f // 角速度环积分 static float last_track_error = 0.0f; static float yaw_target = 0.0f; static float yaw_integral = 0.0f; // 姿态环中每隔1ms调用一次,输入为 CCD 最新质心位置 void Balance_Control(float centroid, uint8_t valid, float gyro_z) { float track_error = 0.0f; float turn_torque = 0.0f; if (valid == 0) { // 丢线时不更新转向目标,保留上一次目标角速度 } else { track_error = (centroid * 0.01f) - CENTER_PIXEL; yaw_target = TRACK_KP * track_error + TRACK_KD * (track_error - last_track_error); last_track_error = track_error; } // 角速度 PI 控制,输出转向转矩 float yaw_error = yaw_target - gyro_z; yaw_integral += yaw_error; if (yaw_integral > 200.0f) yaw_integral = 200.0f; if (yaw_integral < -200.0f) yaw_integral = -200.0f; turn_torque = YAW_KP * yaw_error + YAW_KI * yaw_integral; // 将转向转矩叠加到电机输出,这里省略直立控制部分 Motor_Output(legacy_pwm_base + turn_torque, legacy_pwm_base - turn_torque); }

代码中的两个关键点是:检测到丢线时保留上次yaw_target,而不是清零,否则小车会在冲出视野瞬间猛打方向猛地纠正,这一下常常直接让车身倒下;中间用centroid * 0.01f把第 3 节保留两位小数的质心值还原成浮点数。yaw_integral的作用是消除直线赛道上的残余角速度,但积分饱和是对平衡小车的致命伤,限幅值设置必须谨慎,太大导致弯道超调,太小则直线时车身缓慢转动。

参数取值调节现象
TRACK_KP 偏大1.0 以上转向反应快,弯道冲出少,但直线蛇行
TRACK_KP 偏小0.02 以下直线稳定,弯道转向不足冲出赛道
YAW_KP 太大30 以上车身高频抖动,直立环被干扰
YAW_KI 太大0.1 以上连续弯道积分为零,转向滞后

4.3 转向与直立的耦合关系小结

直立控制的左右电机 PWM 差值同时承担了平衡和转向两个任务,转向动作本身就会破坏平衡。这就是为什么不能把 CCD 偏差直接降到电机输出上,必须先经过角速度目标值作为中间量。当车体前倾加速时,转向动作会放大这种前倾,可以让直立环中的速度反馈适当减弱,让车子在弯道里略微降低速度,而不是靠位置环去追速度差。这部分参数调整没有通用公式,只能靠看曲线来标定,但代码架构上一定要把两个环路写在同一个控制频率里,尤其是 HAL 库的定时器回调里不能有多余的阻塞延时。

5. 现场标定手记:串口波形、自适应阈值与丢线恢复技巧

线性 CCD 寻迹最值得投入时间的环节是标定。常见做法是先用 USB 串口把原始 256 像素灰度值周期性打印出来,在 HC-05 或者 CH340 的串口助手里观察曲线,而不是直接在车上看效果。我在工程里会做一个简版调试协议,每次发送单字节 0xAA 表示帧头,随后跟 256 个灰度值的高字节和低字节,再跟一帧DetectResult的质心和有效位。PC 端写一个简单的 Python 脚本,用 matplotlib 画出每一帧的灰度曲线和质心位置,现场调参效率远高于反复下载固件。

自适应阈值在上一章代码里用了最大最小值的平均值,但实际场地最好把这一帧的直方图做出来,取波谷作为阈值。具体方法是遍历 256 个像素,统计灰度值在 0 到 4096 之间的分布,找到两个波峰之间的最低谷底,这就是理论上最佳分割点。实际比赛场地往往只有黑白两个主色,波谷会很明显。以下 Python 代码片段可用来标定阈值,对多峰污染等情况做离线分析。

import numpy as np def find_threshold(grays): hist, _ = np.histogram(grays, bins=4096, range=(0, 4096)) # 平滑直方图,避免单像素噪声产生伪波谷 kernel = np.ones(5) / 5 hist_sm = np.convolve(hist, kernel, mode='same') # 找最大波峰位置 peak1 = hist_sm.argmax() # 简单双峰阈值取两个峰值的最小值区间 return hist_sm[peak1:].argmin() + peak1 # 使用示例,raw 为一帧灰度数组 # th = find_threshold(raw)

标定完阈值后,还要处理丢线恢复。当 CCD 检测到valid=0或者质心在边缘区停留超过 50ms,可以触发一个倒计时状态机:前 10ms 继续按最后有效质心转向,中间 30ms 逐步减小转向目标,超过 50ms 直接停车并抬头等待人工干预。这个策略能非常有效地处理十字交叉线,因为十字线交叉位置 CCD 可能短暂丢失纯黑线段,立即停车会显得整车很抖,适当延迟却能顺利通过。

最后一个技巧是用 CCD 宽度信息辅助判断弯道半径。width越大,说明寻迹线越粗或者车身离地越高;反过来,在同一个赛道下,弯道外侧看线会有透视缩短,width变小。用width的变化率可以做弯道预判,提前增大 TRACK_KP 的权重,比单纯用位置误差反应快一个周期。我在实际工程中还试过把这个预判值和陀螺仪 Z 轴角速度加权,得到更平滑的速度输入,比赛效果比固定增益好不少。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询