平衡小车线性CCD寻迹:从TSL1401驱动到姿态环融合的嵌入式实战

平衡小车线性CCD寻迹:从TSL1401驱动到姿态环融合的嵌入式实战 简介面向智能车竞赛与嵌入式学习的一套平衡小车线性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两者最终会向更亮的方向偏移这正是想要的效果。返回值valid0对应整帧没有超阈值像素的情况上位机通常把这一状态解释为丢线或者冲出赛道。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, bins4096, range(0, 4096)) # 平滑直方图避免单像素噪声产生伪波谷 kernel np.ones(5) / 5 hist_sm np.convolve(hist, kernel, modesame) # 找最大波峰位置 peak1 hist_sm.argmax() # 简单双峰阈值取两个峰值的最小值区间 return hist_sm[peak1:].argmin() peak1 # 使用示例raw 为一帧灰度数组 # th find_threshold(raw)标定完阈值后还要处理丢线恢复。当 CCD 检测到valid0或者质心在边缘区停留超过 50ms可以触发一个倒计时状态机前 10ms 继续按最后有效质心转向中间 30ms 逐步减小转向目标超过 50ms 直接停车并抬头等待人工干预。这个策略能非常有效地处理十字交叉线因为十字线交叉位置 CCD 可能短暂丢失纯黑线段立即停车会显得整车很抖适当延迟却能顺利通过。最后一个技巧是用 CCD 宽度信息辅助判断弯道半径。width越大说明寻迹线越粗或者车身离地越高反过来在同一个赛道下弯道外侧看线会有透视缩短width变小。用width的变化率可以做弯道预判提前增大 TRACK_KP 的权重比单纯用位置误差反应快一个周期。我在实际工程中还试过把这个预判值和陀螺仪 Z 轴角速度加权得到更平滑的速度输入比赛效果比固定增益好不少。本文还有配套的精品资源点击获取