
简介面向飞思卡尔智能车竞赛光电组的线性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_WIDTH128中位点mid64 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结果车完全不会跑一夜回到解放前。宁可带着一个“偏保守但稳定”的参数上赛场也不要赌一个“理论上更快但没充分测试”的参数。第四个是关于慢慢来。我见过太多队伍车还没跑稳就急着把速度往上拉结果一个下午都在捡车、修车。智能车这东西慢就是快。先把每一步都走扎实把循迹程序这个地基打得足够稳固后面无论是加速度闭环、做路径规划还是换更复杂的传感器你都会比别人轻松很多。本文还有配套的精品资源点击获取