线性CCD循迹程序实战:从时序采集到转向控制全解析
2026/9/9 19:45:55 网站建设 项目流程

简介:面向飞思卡尔智能车竞赛光电组的线性CCD循迹程序完整工程,采用改进型PID控制器,支持在2米/秒高速下稳定识别赛道并自动循迹,适合参赛队伍及嵌入式控制学习者参考。压缩包内共44个文件,体积约343KB,涵盖C语言源码、头文件、CodeWarrior工程配置及编译输出文件,其中C源码与头文件覆盖AD采集、PWM调速、PID计算和线性CCD信号处理等关键功能,便于按模块阅读与二次修改。目前已有1825人学习/浏览,说明该方案对同类光电组任务具有较高的参考价值。除核心控制逻辑外,工程还保留了定时器初始化、底层启动代码和内存映射等辅助配置,便于读者理解从传感器数据采集到转向与速度执行的完整控制链路,是系统学习飞思卡尔MCU嵌入式开发和智能车调参思路的实用素材。 先说明一下,这篇不是从零教你焊板子、装车模的入门教程,我默认你手上已经有一套能正常通电、电机能转、舵机能打角的基础车模,至少见过线性CCD长什么样,也知道怎么把采集到的波形通过串口或者屏幕打出来。接下来要聊的,是光电组最核心的一环:线性CCD循迹程序

很多刚接触智能车竞赛的同学,第一个任务往往不是让车跑起来,而是让舵机能老老实实盯着赛道上的那条黑线动起来。这一步通了,后面所有关于速度、PID、图像优化、坡道处理的活儿才有下手的地方。反过来说,如果循迹程序写得很糙,车就会在直道上画龙、在弯道里冲出去,后面你调得再辛苦也白搭。

这篇文章我打算按我自己调车的顺序来讲:先从整体方案说起,然后拆线性CCD的采集时序和底层驱动,再讲图像处理和中心线提取,最后聊转向控制和常见的调车坑。内容以飞思卡尔智能车竞赛光电组为背景,用的传感器是常见的TSL1401线性CCD,主控以K60为例,但思路换到TC264、STM32、CH32V307这些平台一样适用。

1. 光电组方案的整体思路与选型思考

1.1 为什么光电组偏爱线性CCD

智能车竞赛发展到现在,传感器方案早就不只是“光电”两个字那么单一了。摄像头组有全局快门、有鱼眼畸变校正,电磁组有双水平、双垂直电感,新规则下还有AI视觉组、完全模型组这些花活。但在众多方案里,线性CCD依然是光电组的经典配置,也是入门门槛最低、见效最快的一条路。

线性CCD说白了就是一个一维摄像头,它不像普通摄像头那样输出一张二维图像,而是只输出一条线上的128个灰度值。你把它装在车头,朝前下方看,得到的就是一条横贯赛道的“光线强度分布”。黑线所在的位置,对应输出的电压值明显偏低。程序要做的,就是在这128个点里找到那条黑线,算出它相对于图像中心的偏移量,然后把这个偏移量喂给舵机控制。

用一维传感器做循迹,好处非常明显。第一是计算量小,128个点做一次简单扫描和阈值判断,在单片机上的开销几乎可以忽略不计;第二是帧率高,采集一次完整数据加上简单处理,哪怕不用DMA也能轻松做到一两百赫兹,这对转向控制的实时性非常友好;第三是代码直观,整个处理链条短,从AD值到舵机打角的路径非常清晰,特别适合第一次参赛的队员建立整体认知。

对比一下其他方案:普通摄像头要处理几十KB的图像数据,光是二值化和边缘提取就要仔细设计;电磁组虽然不怕光照,但对环境磁场极其敏感,摆头动作、工频干扰都会让电感值飘。线性CCD站在两者之间——比电磁组直观,比摄像头组轻量。当然它也有先天短板:只有一条线,看不到远处弯道的曲率变化,遇到急弯只能靠车速和控制来弥补。所以光电组的车,通常会把CCD架在车头较高位置,通过俯仰角让视野看得更远一些。

1.2 整体系统构成与数据流

一个典型的光电组循迹小车,硬件上大概是这么一套:主控板(K60或TC264)、线性CCD模块、舵机、电机驱动、编码器、OLED显示屏或者无线调试模块。数据流是这样的——CCD在曝光完成后,通过SI和CLK引脚把128个像素的电压值逐个输出,主控的ADC模块把这串模拟量转成数字量,得到一维数组。之后程序对这个数组做灰度处理,提取出黑线的中心位置,换算成偏差值。偏差值经过转向环PID运算后,输出给舵机打角;编码器测回来的速度经过速度环PID运算后,输出给电机驱动调整PWM占空比。

这个链路里的每个环节都不难,难的是每个环节之间的时间配合。比如说,主控一边要忙着采集CCD,一边还要刷新舵机PWM,采集过程如果不能高效完成,就会挤占控制周期。这也是为什么我强烈建议,从一开始就把CCD采集放到DMA+中断里去做,而不是在主循环里用阻塞方式一格格读像素。关于这部分,我放到下一节详细讲。

2. 采集时序与底层驱动:把AD值稳稳拿到手

2.1 读懂TSL1401的时序,比抄代码更重要

很多同学拿到线性CCD的第一反应,是去网上找“逐飞库”或者学长留下的底层代码,改两个引脚就开始用。这没什么问题,抄代码能帮你快速跑起来,但如果不懂时序,遇到图像异常你会完全无从下手。

TSL1401的工作流程,我尽量说得简单一点。它内部有128个光敏像素,每个像素把接收到的光强转换成对应的电压。控制时序主要是两个引脚:SI和CLK。一次完整的采集过程是这样的:先把SI拉高,同时给一个CLK上升沿,这相当于通知CCD“开始一次新曝光,把内部移位寄存器复位”;然后拉低SI,后面每来一个CLK上升沿,AO引脚就会依次输出第1、第2、第3……直到第128个像素的电压值。也就是说,128个像素的数据要靠128个CLK脉冲逐个“踢”出来

主控这边要做的事情就很明确了:控制SI和CLK产生时序,同时用ADC模块去读AO引脚的电压。最容易踩的坑就在这里——ADC采样必须和CLK同步好。如果你用的是阻塞式方式,先拉高CLK再启动一次ADC转换,等转换完成,再拉下一个CLK,理论上也能采到数据,但速度极慢。因为ADC转换本身需要时间,串行模式下读完128个点可能需要好几个毫秒,这个帧率根本撑不起高速循迹。

2.2 DMA采集:把CPU从搬运工变成决策者

我的建议是使用DMA(直接内存访问)。让DMA控制器在CLK时钟的驱动下,自动把ADC转换结果搬运到内存数组里,整个过程完全不需要CPU干预。CPU只需要在所有像素都采集完成之后,收到一个“传输完成中断”,再开始处理这128个数据就行。

K60上常用的做法,是用PIT定时器产生固定频率的时钟信号,接到ADC的硬件触发引脚,同时把ADC配置为DMA请求源。当CLK上升沿触发一次ADC转换,转换完成后DMA自动把结果存到数组的下一个位置。配置一次,之后每次只需要启动一轮DMA传输即可。

这里有一个关键参数需要自己算:CLK频率和曝光时间的关系。TSL1401的每个像素曝光时间是内部积分时间,由SI的脉冲间隔决定。如果你把SI周期设得很大,比如10ms一帧,那每个像素的积分时间就很长,图像整体偏亮;反过来,如果SI周期太短,曝光不足,图像就会整体偏暗。实际调试中,我会先用示波器看AO引脚的波形,调整SI周期,让白色赛道的电压大概落在AD值满量程的70%~80%左右,这样既有充足的对比度,又不会出现白色区域削顶饱和。

// 伪代码示例:DMA方式采集128点 void CCD_Init(void) { // 1. 初始化ADC模块,配置为硬件触发,开启DMA请求 adc_init(ADC0, ADC_SE9, ADC_8BIT); // 2. 配置DMA通道,源地址为ADC结果寄存器,目的地址为ccd_buf数组 dma_init(DMA_CH0, (uint32_t)&ADC0->R[0], (uint32_t)ccd_buf, 128); dma_enable_interrupt(DMA_CH0); // 3. 配置PIT定时器,用于产生SI周期(例如200us) pit_init_ms(PIT0, 200); // 4. 配置FTM产生CLK时钟,触发ADC采样 ftm_pwm_init(FTM0, FTM_CH0, 50, 20); // 频率约1MHz } void DMA_IRQHandler(void) { // 一轮采集完成,置标志位,主循环中处理ccd_buf ccd_frame_ready = 1; dma_clear_interrupt(DMA_CH0); }

这套流程跑起来之后,你会发现程序的主循环突然“空”了,有大量的时间可以用来做控制计算和逻辑判断。这其实是很多高速智能车的一个共性思路:底层尽量自动化,CPU只做决策

3. 图像处理:从一维数组里算出赛道中心

3.1 原始数据到二值化的三种思路

拿到128个AD值之后,第一件事是理解这串数据长什么样。在正常的室内灯光下,白色赛道区域AD值可能落在2500左右(12位ADC),黑色引导线大概只有300~600,边界非常清晰。但问题在于,光照不均会让整个波形的“地板”和“天花板”飘动。比如车跑到窗边,一侧受到阳光照射,那一侧的白色区域AD值可能冲到3500,而另一侧只有2000。如果你用一个固定阈值去切,很容易把阳光照到的那半边全判成白色,黑线反而找不到了。

所以二值化的方式,我按自己的使用经验排个序:

  • 固定阈值:最简单,调一个常数,低于它就算黑。适合环境完全可控的训练场地,比赛现场用风险很大。
  • 动态阈值(大津法/OTSU):根据当前一帧数据的灰度分布,自动算出一个最佳分割阈值。在128个点里做Otsu,计算量非常小,但效果比固定阈值稳得多,是我最推荐的做法。
  • 边缘检测法:不直接定义黑白,而是找灰度变化最剧烈的位置作为黑线的左右边界。这种方法对抗光照不均的能力最强,但实现起来稍微复杂,对噪声也敏感一些。

我个人的习惯是,先用Otsu求出阈值,然后做二值化;后续如果发现场地光照太恶劣,再在二值化的基础上叠加边缘约束,比如要求黑线宽度在某一个合理范围内,否则认为是噪点,直接丢弃这一帧。

3.2 提取黑线中心,别让“丢线”毁掉你的车

二值化之后,数组里就只有0和1了,1代表黑线,0代表背景。提取中心线的标准做法是从左往右扫描,找到第一个黑点作为左边沿,再从右往左扫,找到第一个黑点作为右边沿,两者取平均就是黑线中心。

一个比较完整的函数参考:

uint16_t get_line_center(uint16_t *gray_buf, uint8_t *binary_buf, uint16_t threshold) { // 先做二值化 for (int i = 0; i < CCD_WIDTH; i++) { binary_buf[i] = (gray_buf[i] < threshold) ? 1 : 0; } int left = -1, right = -1; for (int i = 0; i < CCD_WIDTH; i++) { if (binary_buf[i]) { left = i; break; } } for (int i = CCD_WIDTH - 1; i >= 0; i--) { if (binary_buf[i]) { right = i; break; } } if (left == -1 || right == -1) { return CCD_INVALID; // 丢线,返回0xFFFF } return (uint16_t)((left + right) / 2); }

这里有一个非常关键但新手经常忽略的点:左边沿和右边沿必须分开扫描。有些同学图省事,只找第一个黑点,把它当作黑线位置。这在赛道只有一条黑线时勉强能用,但一旦遇到十字路口、坡道前的大面积阴影或者反光点,只取一个边缘会让中心位置产生严重跳变。用左右边缘求平均,抗干扰能力会好很多。

丢线处理是另一个大坑。所谓“丢线”,就是当前帧根本找不到黑线,通常发生在车已经冲出赛道、或者被坡道遮挡、或者摄像头视野里全是白色的时候。如果丢线时不处理,直接把无符号数0xFFFF当成中心坐标用,舵机会瞬间打到一个错误角度,车就彻底失控了。

我的处理策略分三层:第一,如果丢线,先用上一帧的中心值保持,同时给转向环一个较大的死区限制,防止舵机猛打;第二,加上一个丢线计数器,连续丢线超过比如5帧,就认为车真的出赛道了,此时强制降速甚至停车;第三,在重新找到线的第一帧,不要立刻信任偏差值,给它加一个一阶低通滤波,防止恢复瞬间的跳变导致车身抖动。

3.3 十字、坡道和阴影:光电组的“天然陷阱”

光电组赛道上最常见的三种“规则内障碍”,是十字路口、坡道和光影变化。十字路口在CCD视野中的表现是黑线突然横向铺开,整帧图像可能出现一个很宽的黑色区域。如果你的程序是“左右边缘直接扫描”,就会把整条横向黑线的中心当成赛道中心,偏差反而变成0,车就会直直地冲过去——十字路口如果处理不好,直冲其实是能接受的,最怕的是左右边缘跳动,让车身在过十字时左右摇摆。

处理十字的思路主要有两种:一是根据黑线宽度判断,如果黑线覆盖了超过图像宽度60%以上,就认为进入十字区域,此时强制赋一个“直行”的偏差;二是根据上一帧中心位置做连续性校验,如果当前帧中心与上一帧中心偏差大得离谱,说明进入了异常区域,暂时屏蔽误差。坡道则相对友好,因为坡道上黑线依然清晰,只是整车姿态变化导致CCD视野范围变小,需要注意的只是曝光时间可能因为光照变化而溢出。至于阴影,我建议把阈值算法尽可能做“动态化”,避免依赖单一灰度绝对值。

4. 偏差计算与转向控制:把“看不见的线”变成“舵机角度”

4.1 从中心坐标到转向PWM,中间只差一个P

当我们得到黑线中心的坐标之后,很容易算出偏差:

// 图像宽度CCD_WIDTH=128,中位点mid=64 int16_t error = (int16_t)center - 64;

这个error的取值范围大约是-64到+64,正负号代表黑线在车的左边还是右边。转向环最简单的形式,就是比例控制:

steer_pwm = mid_pwm + (int16_t)(error * steer_kp);

其中mid_pwm是舵机几何中位对应的PWM值,这个值必须通过实测标定,而不是直接取PWM中值。steer_kp是比例系数,它的大小决定了同样的偏差会让舵机打多少角。

很多第一次参赛的同学会问:为什么转向只用P就够了,不用PD甚至PID?我的理解是,线性CCD本身的帧率非常高,而且舵机是一个自带阻尼的机电系统,过量使用微分项反而容易把噪声放大,导致舵机高频抖动。实际调车中,先只加P,大部分赛道都能勉强跑下来;如果发现过弯时入弯不够积极、出弯回正太慢,再考虑加一点D。

4.2 速度控制和“压速度曲线”的朴素思路

光电组的车如果想跑得快,速度环和转向环一定不能是独立的。最简单的联动策略,是根据偏差的绝对值动态调整目标速度。偏差小,说明车在直道上,目标速度拉高;偏差大,说明要进弯或者已经在弯里,目标速度压低。

一种比较朴素的压速度曲线可以这样写:

speed_target = speed_max - (int16_t)(abs(error) * speed_k); if (speed_target < speed_min) speed_target = speed_min;

speed_k是一个调节系数,决定了“多大的弯降多少速”。这种思路虽然粗糙,但非常好调,也容易理解。更进阶的做法,就是根据CCD图像中黑线左右两边的延伸趋势,预判前方赛道曲率,提前把目标速度降下来,这其实就是很多强队用的“前瞻控制”雏形。

速度环本身我一般用PI控制就够了:

speed_pwm += speed_kp * speed_error + speed_ki * integral;

编码器测速的周期建议固定,比如5ms测一次,这样速度环的控制周期稳定,积分项不会因为时间间隔不均匀而飘。关于PID参数整定,我自己的经验是:先只调P,让车速能稳定在目标值附近但不震荡,然后加一点点I消除静差。I不能太大,否则转速一冲一冲的,车跑起来会一顿一顿。

4.3 转向环调参经验:从“画龙”到“贴线”

调转向环参数时,最常见的现象是“画龙”——车在直道上左右晃动,像喝醉了一样。这种情况十有八九是steer_kp太大,舵机对微小误差反应过度。解决方法是把kp往下调,同时可以考虑给误差加一个小死区,比如abs(error) < 3时,强制令error = 0。死区能让车在直道上更稳定,但也别设太大,否则过弯时会有迟滞感。

另一个常见问题是“切弯不够狠”,车头总是往外飘。这种情况通常不是P太小,而是舵机响应速度跟不上。检查一下舵机供电电压是否足够,舵机拉杆是否顺滑,PWM频率是否在舵机正常工作范围(常见的是50Hz)。如果这些都没问题,再考虑加一点微分项。

我自己的调参节奏是:先在低速(比如0.8m/s)下把转向环调稳,让车能贴着线流畅跑完整个赛道;再把速度往上提,每次只加0.2m/s,观察弯道表现,结合压速度曲线不断调整speed_k。这个过程很枯燥,但确实是最可靠的提速度路径。

5. 常见问题与调车实战技巧

5.1 图像异常排查速查表

我把这几年带队和陪练过程中遇到的典型问题做了一个速查表,每次车出了问题,先按这个表排查一圈,基本能覆盖80%的情况。

现象可能原因排查方向
图像全黑曝光时间太短;SI时序不对;AO没接对引脚拉长SI周期;用示波器查CLK/SI波形
图像全白曝光时间太长;CCD对着强光源缩短SI周期;检查镜头朝向是否过高
图像一侧偏暗光照不均;CCD镜头有污渍清理镜头;调整CCD安装角度
黑线抖动/跳变阈值不合理;曝光不稳定换动态阈值;检查供电纹波
直道画龙kp过大减小转向kp;加小死区
弯道冲出去速度过快;kp太小降低入弯速度;增大转向kp
丢线后乱打角丢线处理缺失加上丢线保持和计数停车逻辑

5.2 调车过程中的几个“反直觉”经验

最后再分享几个我实际调车过程中总结出来的、不太符合直觉的经验。

第一个是关于OLED和无线调试。很多同学觉得OLED只是用来显示个开机画面的,其实在调车阶段,OLED最大的作用是在赛道上实时显示当前的中心坐标、偏差值和二值化后的波形。我习惯把图像按128个点横向压缩成一行显示在OLED上,这样你推着车走一遍赛道,就能肉眼看到程序“看”到的是什么,排查问题效率翻倍。有条件的话,用无线串口把波形传到电脑上位机上看,效果更直观。

第二个是机械结构远比你想的重要。CCD支架稍微歪一点,你程序里跑出来的中心坐标就会偏好几个像素。你花一天时间调kp都没调好的问题,很可能只是把CCD掰正就解决了。所以我每次开始调程序之前,都要先确认CCD的安装角度、舵机的拉杆长度、前轮的束角都是正确的。

第三个是别在比赛前夜大改参数。赛场的灯光和你平时训练的场地肯定有差异,到了现场只需要微调阈值或者曝光时间就行,千万不要在现场动控制结构的参数。很多队伍临场改了kp,结果车完全不会跑,一夜回到解放前。宁可带着一个“偏保守但稳定”的参数上赛场,也不要赌一个“理论上更快但没充分测试”的参数。

第四个是关于慢慢来。我见过太多队伍,车还没跑稳就急着把速度往上拉,结果一个下午都在捡车、修车。智能车这东西,慢就是快。先把每一步都走扎实,把循迹程序这个地基打得足够稳固,后面无论是加速度闭环、做路径规划还是换更复杂的传感器,你都会比别人轻松很多。

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

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

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

立即咨询