简介:这份资源面向使用STM32F103驱动TSL1401线性CCD模块的嵌入式开发者与智能车竞赛选手,提供从底层驱动到上位机调试的完整配套资料。项目以正点原子MINI板为主控,搭配逐飞科技红孩儿CCD与拉普兰德线性CCD调试助手,代码同样适用于任意STM32F103单片机;由于红孩儿模块已停产,也可替换为龙邱科技或平衡小车之家的同型号CCD。压缩包共437个文件,约12.56MB,以c与h源码为主,辅以Keil、IAR等工程文件、上位机可执行程序、PDF指导手册及少量批处理脚本,覆盖驱动移植、采集调试与工程编译等环节。目前已有2082人学习下载。读者可据此快速搭建CCD采集与二值化处理框架,参考上位机完成阈值调试与波形观察,并借助指导手册与逐飞、拉普兰德资料排查时序与曝光问题,适合入门智能车视觉循迹或需要复用CCD驱动代码的开发者。
1. 从一颗红色LED到一维图像:STM32F103驱动TSL1401线性CCD到底在做什么
如果你拆开过老式扫描仪、循迹小车或者某些工业测量设备,大概率会看到一条细长的透明感光条——那就是线性CCD。TSL1401是其中最常见的一款:128个像素排成一条直线,每个像素把光强转成模拟电压,按顺序从AO引脚吐出来。STM32F103要做的,就是精确地给它两个时钟信号(SI和CLK),然后在AO输出的模拟波形上,按像素节拍采回128个电压值。听起来简单,但实际调试时你会发现:时钟快了波形糊成一团,慢了帧率跟不上;曝光时间不对,白天全饱和、晚上全黑;ADC采样点没对准,像素值串位。这篇笔记就把这套“给CCD喂时钟、从模拟线上抢数据”的完整链路拆开,从GPIO翻转、ADC触发、DMA搬运到上位机看波形,一步步落到你能直接复现的代码和参数上。适合手里有TSL1401模块、想用F103做图像采集但被时序和噪声卡住的嵌入式开发者。
2. 先搞懂TSL1401的时序逻辑:为什么普通GPIO翻转不够用
2.1 128个像素如何被“推”出来:SI与CLK的分工
TSL1401内部是一个128位的模拟移位寄存器。每个像素单元包含一个光电二极管和一个积分电容。曝光阶段,电容根据光强充电;读出阶段,SI(串行输入)引脚上的一个高脉冲把整个寄存器“锁存”到输出端,然后CLK每来一个上升沿,AO就输出下一个像素的电压。关键点在于:SI高脉冲必须与第一个CLK上升沿满足特定建立/保持关系,否则第一个像素会丢失或重复。很多新手直接用软件延时翻转GPIO,结果就是第一帧数据永远对不上——因为中断一打断,SI和CLK的相对位置就飘了。
正确的做法是用一个定时器产生CLK,另一个GPIO在精确时刻拉高SI。F103的TIM2或TIM3都可以,但要注意:TSL1401的CLK频率典型值在500kHz到1MHz之间,太高了AO输出跟不上(内部运放压摆率限制),太低了帧周期太长。我一般先用500kHz起步,对应2微秒周期,F103主频72MHz下用PWM模式很容易实现。
2.2 用TIM PWM输出CLK:频率、占空比与相位
配置TIM3通道1为PWM输出,ARR和PSC决定频率。目标500kHz,则周期2us。72MHz主频下,PSC=0,ARR=143,得到72M/(143+1)=500kHz。占空比设50%,即CCR=72。但这里有个坑:TSL1401要求CLK高电平时间至少250ns,低电平至少250ns,50%占空比在500kHz下正好满足。如果你把频率提到1MHz,高电平只有500ns,勉强够,但AO稳定时间会紧张。
// TIM3 PWM输出CLK,PA6引脚 void CCD_CLK_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = 143; // ARR TIM_TimeBaseStructure.TIM_Prescaler = 0; // PSC TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 72; // CCR,50%占空比 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM3, ENABLE); TIM_Cmd(TIM3, ENABLE); }这段代码把PA6配成TIM3_CH1的PWM输出。参数说明:ARR=143、PSC=0对应500kHz;CCR=72对应50%占空比。注意GPIO模式必须是AF_PP,否则波形出不来。如果你用的引脚不是PA6,查F103数据手册的复用功能映射表,TIM3_CH1还可以重映射到PB4、PC6等。
2.3 SI脉冲的精确位置:用另一个定时器或主循环控制
SI需要在CLK开始前拉高,并在第一个CLK上升沿之前保持高电平至少一个CLK周期。简单做法:在启动TIM3之前,先把SI拉高,延时1us,再启动TIM3,然后在第129个CLK后拉低SI。但这样SI宽度就是整个128个CLK周期,TSL1401允许SI在读出期间一直为高吗?查手册:SI高电平期间,CLK的上升沿依次输出像素;SI变低后,再给CLK也不会输出新数据。所以更稳妥的做法是:SI只在第一个CLK前拉高,持续1~2个CLK周期,然后拉低,后续128个CLK正常输出。
我一般用TIM4做一个单脉冲定时,或者直接用主循环控制SI,因为SI的时序要求没有CLK那么苛刻。但如果你用中断方式采集ADC,主循环可能被抢占,所以最好还是用定时器硬件控制SI。下面是一个用TIM4产生SI脉冲的配置思路:TIM4设为单脉冲模式,触发后输出一个宽度为2us的高电平,然后自动停止。这样每次启动采集时,先启动TIM4,再启动TIM3,两者通过主从触发同步。
// TIM4单脉冲输出SI,PB6引脚 void CCD_SI_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = 143; // 2us脉冲 TIM_TimeBaseStructure.TIM_Prescaler = 0; TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM4, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 72; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM4, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM4, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM4, ENABLE); TIM_SelectOnePulseMode(TIM4, TIM_OPMode_Single); // 单脉冲模式 TIM_Cmd(TIM4, ENABLE); }调用一次TIM4启动,它就输出一个2us高脉冲然后停止。但要注意:单脉冲模式下,需要先给TIM4一个触发事件(比如设置UG位)才会开始计数。实际使用时,我在启动采集前先TIM_GenerateEvent(TIM4, TIM_EventSource_Update),让计数器复位并产生更新事件,然后TIM_Cmd(TIM4, ENABLE),SI就会输出一个脉冲。同时这个脉冲的上升沿可以通过内部触发线去启动TIM3,实现硬件同步。F103的TIM4可以触发TIM3,具体查参考手册的ITR配置。
3. 把模拟波形变成数字:ADC采样点与DMA搬运的配合
3.1 ADC触发源选择:为什么不能用软件触发
TSL1401的AO输出在每个CLK周期内有效,但并不是整个周期都稳定。数据手册给出:在CLK上升沿之后,AO会在约200ns内建立到新值,然后保持到下一个上升沿。所以ADC采样点应该放在CLK上升沿之后、下一个上升沿之前,且避开建立时间。如果用软件触发ADC,你无法保证每次都在同一个相位采样,结果就是像素值抖动。正确做法是用TIM3的更新事件或CC通道事件触发ADC。F103的ADC规则组支持外部触发,触发源可以选TIM3_TRGO或TIM3_CC1。我一般用TIM3的CC1事件,因为它在PWM上升沿时产生,但需要稍微延迟一点采样。实际测试下来,直接用TIM3_TRGO(更新事件)在计数器溢出时触发,对应CLK周期的末尾,此时AO已经稳定了约1.8us,采样最可靠。
配置ADC1为外部触发,触发源选TIM3_TRGO,使能DMA循环模式,缓冲区大小128。这样每个CLK周期自动采一个点,128个CLK后DMA传输完成,产生中断。注意ADC采样时间要设短一点,因为两个触发间隔只有2us。F103的ADC时钟最高14MHz,采样时间设1.5周期时,总转换时间约1.17us,来得及。如果设55.5周期,转换时间就超过2us了,会丢点。
// ADC1+DMA采集AO,PA0引脚 #define CCD_PIXELS 128 uint16_t ccd_buffer[CCD_PIXELS]; void CCD_ADC_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; DMA_InitTypeDef DMA_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; // 模拟输入 GPIO_Init(GPIOA, &GPIO_InitStructure); DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)ccd_buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = CCD_PIXELS; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 循环模式 DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel1, &DMA_InitStructure); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T3_TRGO; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_1Cycles5); ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); }关键参数:DMA_Mode_Circular让缓冲区自动循环,适合连续采集;ADC_ExternalTrigConv_T3_TRGO把触发源绑到TIM3更新事件;ADC_SampleTime_1Cycles5保证转换时间小于2us。注意ADC校准不能省,否则零点漂移会吃掉暗电平细节。
3.2 曝光时间怎么调:SI周期与积分时间的取舍
TSL1401没有独立的曝光控制引脚,曝光时间就是两次SI脉冲之间的间隔。间隔越长,积分电容充电越久,输出幅值越大。但太长会饱和,太短则信号淹没在噪声里。典型应用里,室内日光灯下,1ms到5ms的间隔比较合适。你可以用TIM3的重复计数或者主循环延时来控制采集频率。我一般把整个采集流程放在一个定时中断里,比如每10ms启动一次SI+CLK+ADC,这样帧率100fps,曝光时间约10ms减去128个CLK的256us,实际积分约9.7ms。如果发现全白,就把间隔降到2ms;如果全黑,就加到20ms。
注意:调整曝光时间时,要同步调整ADC采样后的阈值判断。动态阈值算法比固定阈值更抗光照变化,后面第5章会讲。
3.3 DMA传输完成中断里做什么:数据对齐与去噪
DMA传输完成中断触发时,ccd_buffer里已经有128个ADC值。但第一个值可能对应SI脉冲期间的无效像素,最后一个值也可能因为DMA循环而混入下一帧。稳妥做法是:在中断里先判断SI标志,确认这一帧有效,然后把128个值拷贝到另一个缓冲区,做简单的滑动平均滤波。比如每4个像素求平均,得到32个“超像素”,既能降噪又能减少后续处理量。但如果你要做边缘检测,就不要降采样,直接对原始128点做差分。
void DMA1_Channel1_IRQHandler(void) { if(DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_TC1); // 拷贝并滤波 for(int i = 0; i < CCD_PIXELS; i++) { ccd_filtered[i] = (ccd_buffer[i] + ccd_buffer[(i+1)%CCD_PIXELS]) >> 1; } ccd_frame_ready = 1; // 置标志,主循环处理 } }这里用了一个简单的相邻平均,参数是移位代替除法,速度快。注意ccd_buffer是DMA循环写入的,中断里读取时可能正在被DMA修改,所以最好用双缓冲:DMA写buffer0,中断里切换到buffer1,下一帧再切回来。F103的DMA支持双缓冲模式,但配置稍复杂,简单项目里用“中断里快速拷贝”也能接受,只要拷贝时间远小于帧间隔。
4. 避坑与排查:TSL1401驱动中最容易翻车的5个点
4.1 现象:AO输出一直是高电平或低电平,没有波形
原因:SI或CLK没有正确接入,或者模块供电不对。TSL1401需要5V供电,但AO输出范围是0~5V,而F103的ADC参考电压是3.3V。如果你直接接AO到PA0,超过3.3V的部分会被钳位,甚至损坏引脚。正确做法是加一个电阻分压,比如10k+20k,把5V降到3.3V以下。或者用运放做电平偏移。我见过有人直接接,结果ADC读数一直是4095,还以为程序问题。
解决:先量模块VCC和GND,再量SI和CLK是否有波形。如果波形正常但AO不动,检查分压电阻。用示波器看AO,应该能看到一串阶梯波。
4.2 现象:采集到的128个值里,前几个或后几个总是0或4095
原因:SI脉冲与CLK的相位不对,导致第一个像素丢失或最后一个像素重复。或者DMA缓冲区大小与CLK个数不匹配。比如你发了129个CLK,但DMA只收128个,最后一个就是下一帧的第一个像素。
解决:用示波器双通道看SI和CLK,确认SI上升沿在第一个CLK上升沿之前至少500ns。调整TIM4的触发延迟,或者改用主循环控制SI时加__NOP()微调。DMA缓冲区严格设为128,CLK个数也严格128个。可以在第128个CLK后关闭TIM3,避免多余时钟。
4.3 现象:图像噪声大,相邻像素跳变超过50个LSB
原因:ADC采样点落在AO建立时间内,或者电源纹波大。F103的ADC对电源噪声很敏感,如果模拟部分和数字部分共用LDO,电机一启动就全屏雪花。
解决:给TSL1401的VCC加100nF+10uF退耦,AO走线远离CLK和SI。ADC采样时间从1.5周期增加到7.5周期,牺牲一点转换速度换稳定。如果还不行,在AO和GND之间并一个1nF电容,但会限制带宽,适合低速采集。
4.4 现象:帧率上不去,一提高CLK频率就丢数据
原因:F103的ADC最高时钟14MHz,采样时间1.5周期时转换时间约1.17us,加上DMA搬运和中断响应,500kHz已经是比较稳的上限。想再快,要么换更高主频的MCU,要么用外部ADC。
解决:如果必须1MHz CLK,把ADC采样时间降到1.5周期,DMA优先级设最高,中断里只置标志不做处理。另外,TIM3的TRGO触发ADC有固定延迟,实测在1MHz下采样点会偏移,需要把TIM3的CCR值微调,让触发提前一点。
4.5 现象:换一块TSL1401模块,同样的代码却工作不正常
原因:不同批次的TSL1401灵敏度有差异,或者模块上的运放增益不同。有的模块带可调电位器,出厂位置不一样。
解决:先用手电筒照CCD,看AO最大值是否达到4V以上。如果不到,调模块上的增益电位器。然后在代码里做自适应阈值:取128个值的最大值和最小值,动态计算二值化门限。这样换模块不用改代码。
5. 从波形到图像:上位机可视化与动态阈值二值化技巧
5.1 用串口把128个字节发到上位机看曲线
采集到的数据最终要能看到。最简单的方式是串口发送,每帧发128个字节,波特率115200,一帧约11ms,勉强能到50fps。发送前把12位ADC值右移4位变成8位,减少数据量。上位机用任意串口示波器工具,或者自己写一个Python脚本画曲线。
// 串口发送一帧,帧头0xAA,帧尾0x55 void CCD_SendFrame(uint16_t *buf) { uint8_t i; USART_SendData(USART1, 0xAA); while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); for(i = 0; i < CCD_PIXELS; i++) { USART_SendData(USART1, (uint8_t)(buf[i] >> 4)); while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); } USART_SendData(USART1, 0x55); while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); }参数说明:右移4位是因为ADC是12位,串口发8位,丢掉低4位噪声。帧头帧尾用于上位机同步。如果你要发原始12位,就拆成两个字节,但帧率会减半。
5.2 动态阈值:让二值化适应环境光变化
固定阈值在室内能用,拿到窗边就废了。动态阈值的思路:每帧找到最大值和最小值,取中间值作为门限。更稳一点:取所有像素的平均值,乘以一个系数(比如0.8)。如果场景里黑色物体占多数,平均值会偏低,门限也跟着低,不会把背景误判成目标。
// 动态阈值二值化,结果存入bin_buffer void CCD_Binarize(uint16_t *buf, uint8_t *bin) { uint32_t sum = 0; uint16_t max = 0, min = 4095; for(int i = 0; i < CCD_PIXELS; i++) { sum += buf[i]; if(buf[i] > max) max = buf[i]; if(buf[i] < min) min = buf[i]; } uint16_t threshold = (max + min) / 2; // 或者用 sum/128 * 0.8 for(int i = 0; i < CCD_PIXELS; i++) { bin[i] = (buf[i] > threshold) ? 1 : 0; } }这里用了(max+min)/2,适合黑白分明的场景。如果背景光不均匀,可以先用滑动窗口求局部阈值,但计算量大,F103跑起来吃力。我一般先用全局阈值,不够再上局部。
5.3 一个具体技巧:用CCD做循迹小车的线位置提取
线性CCD最经典的应用就是循迹。128个像素里,黑线对应低电压(反射光弱),白底对应高电压。二值化后,找到所有为1的像素索引,求加权平均,就是线的中心位置。如果有多段黑线,取最长的一段。这个值直接送给PID控制器,控制舵机转向。
// 提取黑线中心,返回0~127的位置,-1表示丢线 int16_t CCD_GetLineCenter(uint8_t *bin) { int32_t weighted_sum = 0; int16_t count = 0; for(int i = 0; i < CCD_PIXELS; i++) { if(bin[i] == 1) // 假设1代表黑线 { weighted_sum += i; count++; } } if(count < 3) return -1; // 有效像素太少,丢线 return (int16_t)(weighted_sum / count); }参数说明:count<3是经验值,防止噪声误判。加权平均比简单取中点更抗干扰,因为黑线边缘的像素也会被计入,但权重相同。如果想更精确,可以用像素值本身作为权重,做灰度重心法。
5.4 我踩过的坑与现在的习惯
最早做这个方案时,我直接用软件延时翻转CLK,结果每帧数据都在跳,调了三天才发现是中断打断了时序。后来改用TIM PWM,世界立刻安静了。另一个坑是忘了给AO分压,5V直接进ADC,烧了一个引脚。现在我的习惯是:任何模拟信号进MCU之前,先量电压范围,再决定分压还是加运放。还有,TSL1401的SI和CLK走线一定要短,最好并行走,中间夹地线。如果模块离MCU超过10cm,CLK波形会振铃,AO上全是毛刺。最后,动态阈值不是万能的,如果场景里同时有强光和阴影,全局阈值会失效,这时候要么加遮光罩,要么上局部阈值算法。希望这些经验能帮你少走点弯路。
本文还有配套的精品资源,点击获取