很多刚开始玩STM32F103C8T6的朋友,第一次把红外避障模块和OLED屏幕接到一起时,十有八九会翻车。不是OLED点不亮,就是红外模块乱触发,又或者明明是同一个程序,换了一块屏就是死活不出字。这些坑我基本都踩过一遍。这篇教程就把硬件接线、底层原理、软件滤波和现象调试一次性讲清楚,给准备做避障小车或者环境检测项目的同学一个可以直接抄作业的完整方案。
我用的主控是STM32F103C8T6最小系统板,也就是大家常说的Blue Pill,再加上一个常见的TCRT5000红外反射式避障模块和一块0.96寸I2C接口OLED。三个东西单独拿出来都不复杂,但组合在一起,细节就多了。接下来我会按实际调试的顺序来写,从硬件到代码,再到滤波和排错,尽量做到你照着操作就能跑通。
1. 硬件接线:从最小系统板到传感器和屏幕的实际连接
1.1 元器件清单和选购避坑
先列一下我用的东西,方便你对号入座:
- STM32F103C8T6最小系统板,蓝色Pill板型,板上自带8MHz晶振和AMS1117稳压芯片,USB口直连电脑供电下载程序
- TCRT5000红外避障模块,板上带有红外发射管、光电接收管和一个LM393电压比较器,输出口有模拟量AO和数字量DO两种
- 0.96寸OLED屏幕,SSD1306驱动芯片,I2C接口,4个引脚(VCC、GND、SCL、SDA)
- 面包板、杜邦线若干,最好多备几条母对母的
这里有一个很关键的选购建议:红外避障模块尽量买带LM393比较器、有电位器可调阈值的那种,不要买那种只有一个红外对管和电阻的裸板子。带比较器的模块可以直接输出稳定的高低电平,不需要你再用ADC采样去做阈值判断,省掉很多麻烦。
OLED部分,同样要买4针I2C版本的,不要买7针SPI版本。4针I2C只占两个IO口,对于F103C8T6这种引脚不算富裕的芯片来说,能省则省。而且I2C的OLED驱动代码网上非常多,资料也更集中。如果你手里只有SPI版本的屏,后面接线和程序都要改,但没有必要,直接换一块更省心。
1.2 引脚分配表和接线实操
我最终确定的引脚分配是这样:
| 模块 | 引脚 | STM32引脚 | 说明 |
|---|---|---|---|
| 红外避障模块 | VCC | 3.3V或5V | 供电 |
| 红外避障模块 | GND | GND | 共地 |
| 红外避障模块 | DO | PA0 | 数字输出接入,读取障碍信号 |
| 红外避障模块 | AO | 不接 | 本次不用模拟输出 |
| OLED | VCC | 3.3V | 必须3.3V,严禁接5V |
| OLED | GND | GND | 共地 |
| OLED | SCL | PB8 | 软件I2C时钟线 |
| OLED | SDA | PB9 | 软件I2C数据线 |
这样一个IR传感器占用PA0,OLED占用PB8和PB9,其余引脚全部空出来,后续你如果要加电机驱动、舵机或者蓝牙模块,还有充足空间。
接线的时候有两点要注意。第一,所有模块的地线必须和STM32共地,否则信号电平没有参考点,读到的数据完全不可靠。第二,OLED的VCC不要接到5V上,虽然不少模块板载了稳压电路,但为了保险起见,直接用3.3V供电就好。F103C8T6的IO口是3.3V电平逻辑,OLED的I2C接口也是3.3V逻辑,两边匹配,不需要电平转换。
红外模块的供电我建议接5V。TCRT5000内部的红外发射管串联了一个限流电阻,5V供电时发射管工作电流更大,红外光更强,检测距离会更远一些。DO输出的是TTL电平,3.3V的单片机读取高电平信号完全没问题。当然,你如果不想折腾,直接全接3.3V也能正常工作,只是有效检测距离会缩短一些。
1.3 上拉电阻的问题:为什么你的I2C有时灵有时不灵
接下来说一个特别容易忽略的坑:I2C总线的上拉电阻。
I2C协议本身是开漏结构的,SCL和SDA线必须靠外部上拉电阻拉高到高电平。很多OLED模块背面已经焊好了4.7kΩ或10kΩ的上拉电阻,买回来直接用就行。但也有一些便宜的模块,板上没有焊上拉电阻,这时候你直接用STM32的GPIO开漏模式去驱动I2C,就会出现一个诡异的现场:屏幕有时候能亮,有时候通电后怎么都不出字,用手摸一下杜邦线又有反应,把线重新插一遍又好了。
这是因为线的分布电容和寄生电容影响了I2C信号的上升沿。开漏输出本身没有主动拉高的能力,全靠上拉电阻给电容充电,阻值太大、线太长、接触不良,上升沿就慢,数据就容易出错。
如果你遇到这种情况,自己动手在SCL和SDA上各接一个4.7kΩ电阻到3.3V就行。如果没有4.7kΩ,用2.2kΩ到10kΩ之间都可以,阻值越小信号边沿越陡,但功耗也稍大一点,对OLED这种低速设备来说差别不大。
我在调试过程中还发现,杜邦线的质量也很关键。好一点的杜邦线镀层均匀、插针紧密,坏一点的松垮垮,稍微动一下面包板就接触不良。OLED点不亮的时候,不要上来就怀疑代码,先用手按住所有接头试试,很多时候问题就出在物理连接上。
2. 红外避障模块与OLED背后的工作原理
2.1 红外避障模块到底是怎么判断“有障碍物”的
TCRT5000红外避障模块的核心部件其实只有两个:一个红外发射管和一个光电二极管接收管。发射管持续向外发射波长为950nm左右的红外光,红外光遇到障碍物后发生漫反射,部分反射光被旁边的光电二极管接收,接收管的内阻随之发生变化,从而产生微弱的电流信号。
但这个电流信号非常微弱,直接接单片机完全没法用。所以模块上设计了LM393比较器电路,把光电二极管输出的电压信号和电位器设定的阈值电压做比较。当反射光强大于阈值时,比较器输出翻转,DO引脚输出低电平;反射光弱时,输出高电平。
这里有个容易误解的地方:不同厂家的模块逻辑可能相反。我用的这块是障碍物靠近时DO输出低电平,障碍物远离时输出高电平。但有的模块设计是反过来的,障碍物靠近时输出高电平。所以拿到模块第一件事,不是写代码,而是上电后用手在传感器前面晃一晃,观察模块上的指示灯和DO引脚电平变化,搞清楚你的模块到底是哪种逻辑,再去写代码。
模块上的电位器旋钮控制的是比较器参考电压,也就是灵敏度。顺时针旋转,检测阈值变高,需要更强的反射光才能触发,有效距离变短;逆时针旋转,阈值变低,稍微有点反射光就可能触发,检测距离变远,但也更容易受到环境光干扰。实际调试时,我一般先把电位器旋到中间位置,再根据具体场景微调。
另外一个非常实用的经验:TCRT5000的有效检测距离大约是1cm到8cm,超过这个范围就很不稳定了。做避障小车的时候,传感器安装高度最好控制在2cm到4cm之间,这样既有足够的反应时间,又不会误判地面上的深色花纹。
2.2 SSD1306驱动OLED的I2C通信机制
0.96寸OLED屏幕分辨率为128x64像素,内部集成了SSD1306驱动芯片,显存一共是128x64bit,也就是1024字节。芯片内部把整个屏幕分成8页(Page0到Page7),每页是128列x8行像素,刚好对应一个字节的8个bit位。
I2C通信的流程是这样的:主机先发送起始信号,然后发送从机地址加写位,从机应答后,接着发送控制字节。控制字节为0x00时表示后面跟的是命令,为0x40时表示后面跟的是显存数据。这个地址这里多提一句,SSD1306的I2C地址取决于模块上地址引脚的电平,绝大多数模块固定为0x3C,但也有一些是0x3D。如果初始化半天屏幕没反应,把地址改成0x3D试一下,往往问题就解决了。
写命令的典型序列是这样:
void OLED_WriteCmd(uint8_t cmd) { OLED_Start(); OLED_WriteByte(OLED_ADDR << 1); // 从机地址 + 写位 OLED_WriteByte(0x00); // 控制字节,表示命令 OLED_WriteByte(cmd); // 命令本身 OLED_Stop(); }写显存数据只需要把控制字节换成0x40,然后连续写入数据即可。注意SSD1306写入数据是按列地址自动递增的,写入8个字节就填满了一个页的一列,总共需要写1024个字节才能刷新一整屏。这就解释了为什么刷新一屏的耗时和I2C时钟频率直接相关。
2.3 为什么我推荐用软件模拟I2C而不是HAL库硬件I2C
用F103C8T6驱动OLED,业界有个心照不宣的经验:能用软件模拟I2C,尽量别用HAL库的硬件I2C。
这么说不是HAL库不能用,而是HAL库的I2C在STM32F1上有一个历史遗留的坑,具体表现就是调用HAL_I2C_Mem_Write的时候,偶尔会卡死在忙等判断里面,需要超时处理或者重启才能恢复。虽然官方提供了补丁和解决方法,但对于初学者来说,排查这个问题的成本实在太高了。
软件模拟I2C则完全可控。用两个GPIO口,开漏模式,手动拉高拉低,时序完全由自己掌握,出了bug也好排查。代码逻辑也不复杂,核心就是起始、停止、发字节、收应答这四个操作。
void OLED_Start(void) { OLED_SDA_H(); OLED_SCL_H(); OLED_SDA_L(); OLED_SCL_L(); } void OLED_Stop(void) { OLED_SCL_L(); OLED_SDA_L(); OLED_SCL_H(); OLED_SDA_H(); } void OLED_WriteByte(uint8_t data) { for (uint8_t i = 0; i < 8; i++) { if (data & 0x80) OLED_SDA_H(); else OLED_SDA_L(); OLED_SCL_H(); OLED_SCL_L(); data <<= 1; } }软件模拟I2C的速率不用刻意去算,一个字节8个bit,每次翻转之间插入几条空指令或者直接不延时,实测跑下来刷新一屏大概十几毫秒,显示动态画面完全够用。
3. 工程搭建与代码实现:从CubeMX到实际显示
3.1 CubeMX工程配置要点
工欲善其事,必先利其器。我这里用的是STM32CubeIDE开发环境,配合STM32CubeMX做图形化初始化配置。建工程的时候需要注意几个地方:
时钟配置选择HSE外部晶振,主频拉到72MHz。F103C8T6最高只能跑到72MHz,这个没得商量。系统时钟分配好后,调试方式选择Serial Wire,不然有的板子下载一次程序之后第二次就无法连接了。
GPIO配置方面,PA0设置为输入模式,看你的模块逻辑决定是否需要启动内部上拉或下拉。如果模块平时输出高电平、有障碍物时输出低电平,那外部已经有明确的电平驱动,内部上下拉都无所谓,直接配置成普通输入即可。PB8和PB9设置为开漏输出模式,开漏模式是I2C的标准配置。初始化完GPIO后,再写几个宏定义来控制引脚拉高拉低。
串口建议也顺手配置一个,比如USART1的TX引脚PA9和RX引脚PA10,波特率115200。后面调试滤波效果的时候,把原始数据和滤波后的数据打出来看波形,你就知道串口有多重要了。
3.2 OLED驱动移植与显示流程
OLED的驱动代码,网络上流传的版本很多,不同作者写的功能也略有差异。我的建议是核心文件自己维护一份,包含初始化、清屏、显示单个字符、显示字符串、显示整数和浮点数这几个基础功能就够用了。
SSD1306的初始化需要发送一串配置命令,这些命令的顺序和值基本都是固定的,不用去逐条死记硬背,但核心的几个命令最好理解一下。0xAE关闭显示和0xAF开启显示是成对出现的,0x8D配合0x14开启内部电荷泵。这里要注意的是,必须开启电荷泵,否则OLED屏幕即使I2C通信正常也不会发光,这也是很多人初始化代码写完屏幕依然全黑的原因。
清屏函数实现起来很简单,遍历所有页和所有列,全部写0x00:
void OLED_Clear(void) { for (uint8_t page = 0; page < 8; page++) { OLED_SetCursor(page, 0); for (uint8_t col = 0; col < 128; col++) OLED_WriteData(0x00); } }显示字符部分的原理是查表。取模软件生成的字模是一个8字节数组,每个字节对应一列8个像素。显示的时候把某个字符的字模数据逐个写入对应位置即可。
我自己的经验是,显示字符串和整数的时候,先准备好一个足够大的缓冲区,用sprintf格式化好,再一次性调用显示字符串函数。这样做代码简洁,而且方便拼接调试信息,比如同时显示“Distance:”和具体的传感器状态。
3.3 红外采集与主循环逻辑
红外模块的读取在代码上非常简单,就是读一个GPIO的电平。但放在完整的项目里,要思考的问题就多了。
我的主循环逻辑大致是这样的:先初始化OLED和串口,清屏显示启动信息。然后进入死循环,每10ms读取一次红外模块状态,做去抖滤波处理,接着更新屏幕显示,通过串口输出调试信息。
while (1) { uint8_t ir_raw = HAL_GPIO_ReadPin(IR_PORT, IR_PIN); uint8_t ir_filtered = IR_Filter(ir_raw); OLED_ShowString(0, 0, "IR Status:"); if (ir_filtered == 0) OLED_ShowString(0, 2, "OBSTACLE!"); else OLED_ShowString(0, 2, "CLEAR "); printf("IR raw=%d filtered=%d\r\n", ir_raw, ir_filtered); HAL_Delay(10); }这个500ms的显示刷新频率需要和10ms的数据采集频率分开考虑。IR滤波函数内部维护自己的采样节奏,而屏幕显示不需要刷那么频繁,否则OLED会一直处于忙碌状态,而且人眼也看不出那么快的变化。
4. 软件滤波:如何让传感器数据稳定可靠
4.1 红外模块噪声来源和抖动现象
红外避障模块的信号真的不是稳定的高低电平,至少在真实场景中不是。我遇到过的情况有这么几种:
障碍物刚好位于检测距离边缘时,DO输出会在高低电平之间来回跳动,频率还很快,目测大概几十赫兹。模块正对阳光或者靠近大功率白炽灯时,环境光里的红外成分会干扰接收管,导致输出电平异常。还有一个很经典的情况,障碍物表面是黑色或者镜面时,红外光吸收或定向反射掉,传感器明明离得很近却判定为无障碍。这种问题软件滤波救不了,需要靠传感器安装位置和角度来弥补。
不做任何处理直接读取GPIO的话,避障小车会表现为蛇形走位,明明前方没有物体,轮子却疯狂转向。所以软件滤波不能只做一次,要考虑多个维度。
4.2 中值滤波加去抖动的双层方案
我最后采用的方案是两层滤波,每层解决一个不同的问题。
第一层是中值滤波,解决的是单次采样电平偶然漂移的问题。取连续5次采样值,排序后取中间那个数。5次里就算有1次采到了错误电平,也不会影响最终结果。采样间隔建议1ms左右,这样5次采样一共耗时5ms,对实时性影响不大。
uint8_t RAW_Buffer[5] = {1, 1, 1, 1, 1}; uint8_t IR_MedianFilter(void) { uint8_t temp[5]; uint8_t i, j; for (i = 0; i < 5; i++) temp[i] = RAW_Buffer[i]; for (i = 0; i < 4; i++) { for (j = i + 1; j < 5; j++) { if (temp[j] < temp[i]) { uint8_t t = temp[i]; temp[i] = temp[j]; temp[j] = t; } } } return temp[2]; }第二层是去抖滤波,解决的是状态切换时的抖动问题。思路很简单:只有连续若干次采样的滤波结果都是同一个值,才认为状态发生了真正改变,否则维持上一个状态不变。
这个去抖的思路非常类似轻触按键消除机械抖动。可以把状态变化想象成一个懒人,领导连续喊他三遍同一个指令他才动一下,喊一遍他不理你。
4.3 滤波参数怎么调
滤波参数不是随便拍的,需要结合实际的检测距离和运动速度来定。
中值滤波的窗口大小,我建议3到7之间。窗口越大,滤除突刺的能力越强,但采样耗时越长,反应越迟钝。对TCRT5000这种本身信号就还算干净的传感器来说,5足够。
去抖滤波的关键参数是连续确认次数。我做避障小车的时候,用的连续次数是10次。结合前面采样间隔1ms来算,一个状态要稳定10ms才会被确认,这个延迟对于低速小车来说完全无感。如果你做的是高速运动的机器人,可能需要把这个次数降到3或者4,否则遇到突发障碍物会反应不过来。
调试参数的时候,别瞎调,把串口打开。串口每隔10ms输出一次原始状态和滤波后状态,观察两种情况下的数据差异。如果原始状态偶尔出现0和1交替,但滤波后状态保持稳定,说明滤波参数是合适的。如果滤波后状态还在抖动,说明连续次数不够,继续加大。
5. 现象调试:排查问题的完整思路
5.1 OLED点不亮的排查链路
OLED屏幕不亮这个问题,大概能占初学者问题的六成。我总结了一套排查链路,按顺序走一遍,基本都能定位。
第一步查供电。万用表测OLED的VCC和GND之间电压,应该是稳定的3.3V。如果没有电压,顺着线查到面包板,可能杜邦线插错了孔位,或者面包板内部断路。这一步就排除掉一大半的问题。
第二步查I2C地址。写一个简单的扫描函数,给每个可能的I2C地址发送起始信号,看是否有从设备应答。0x3C和0x3D都试一下。这个函数不用写的很复杂,就是一个循环地址加应答判断。
第三步查上拉电阻。前面说过,没有上拉电阻的模块会表现为时好时坏,用手触摸引脚反而恢复正常。如果你用的是那种特别便宜的模块,直接补焊两个4.7kΩ电阻上去。
第四步查初始化命令。尤其是电荷泵是否开启。可以用逻辑分析仪抓一下I2C波形,看初始化命令是否完整发出。没有逻辑分析仪的话,也可以试着在初始化之后立刻清屏点亮一个点,看屏幕有没有微弱的反应。
第五步,终极测试,换一块OLED屏。有些模块出厂就是坏的,别在这种模块上浪费太多时间,换一块屏立刻见分晓。
我调试的时候踩过最蠢的一个坑:OLED模块正常,代码正常,接线也正常,但屏幕就是全黑。最后发现是初始化代码里连续发送命令的时候,两条命令之间漏掉了延时,导致部分命令没有被执行。后来在OLED_WriteCmd函数的Stop之后加了个微秒级延时,问题就消失了。
5.2 避障模块不触发或者常触发的检查方法
红外避障模块的常见问题有两个方向:该触发时不触发,和不该触发时乱触发。
该触发时不触发,先检查模块上的电源指示灯是否亮起。亮起说明供电正常,再检查输出指示灯。用手遮挡传感器正面,如果输出指示灯不变化,说明传感器本身可能有问题或者接线错了。用手遮挡时输出指示灯有变化,但主控读不到信号变化,检查DO引脚是否接到了正确的IO口,程序里读取的引脚号是否和接线一致。
这里分享一个技巧:可以在主循环里只写一个最简单的GPIO读取程序,把原始电平状态用串口循环打印出来。串口数据是整个调试过程的照妖镜,能让你快速区分问题是出在硬件层、软件层还是滤波层。
该触发时不触发的另一个常见原因是电位器阈值调节不当。传感器距离障碍物10cm,电位器阈值调得太高,反射光根本达不到触发条件。逆时针旋转电位器,降低参考电压,直到障碍物在目标距离出现时输出电平能够稳定翻转。
乱触发的问题通常是环境光干扰或者安装角度不对。TCRT5000虽然有滤光片,但强阳光下还是会误触。把传感器尽量安装在车体下方、背光位置,不要正对太阳。另外,两个红外传感器同时工作时,要注意它们之间的红外光束不能互相干扰,否则会频繁误触发。安装间距拉大,或者发射管角度略向外偏。
5.3 滤波前后数据对比与效果验证
滤波到底有没有效果,不能靠肉眼感觉,要有数据支撑。这是我串口打印设计成同时输出原始值和滤波值的初衷。
| 场景 | 原始信号 | 滤波后信号 |
|---|---|---|
| 无障碍物,传感器稳定 | 持续高电平 | 持续高电平 |
| 障碍物稳定靠近 | 低电平,偶发毛刺 | 稳定低电平 |
| 障碍物在边缘区域 | 高低快速抖动 | 稳定的原状态 |
| 强光照射 | 有时出现误低电平 | 保持原状态 |
从中可以看出,滤波主要解决的是毛刺和边缘抖动问题。但看到这个对比,有个认知要建立起来:滤波不是万能的,它只是延迟了判断,没有改变根本物理现象。如果一个障碍物在边缘区域来回晃动,滤波后的值最终还是会跟着切换,只是切换的次数大大减少了。这对电机驱动的避障逻辑来说已经足够友好,不会因为单次误判就改变运动方向。
5.4 最终效果演示与扩展方向
全部调试完成之后,OLED屏幕上会显示两行内容,第一行是IR Status,第二行是CLEAR或者OBSTACLE。用手在传感器前慢慢移动,屏幕状态会跟着切换,但不会出现快速闪烁。串口会同步输出原始值和滤波值,整体响应速度大概在30ms左右,体感上就是手一伸过去,屏幕立刻变化,没有粘滞感。
这个项目跑通之后,继续扩展的方向很多。最直接的是加两个电机和L298N驱动模块,把IR状态转换为避障行为逻辑,小车的巡线模块也可以照搬这套滤波方案。OLED上还可以扩展显示更多信息,比如障碍物的左右方向、行驶速度、超声波测距值等。F103C8T6的引脚资源和性能足以支撑这些扩展。
如果后续想用硬件I2C节省CPU时间,也不是不行,但需要额外花时间处理HAL库I2C的卡死问题。一个折中方案是IO口配置为开漏复用功能,配合定时器做超时重置,不过这些属于进阶话题了。现阶段先把软件I2C跑稳,把滤波逻辑吃透,比什么都强。