这段时间帮朋友做了一台基于单片机的红外测距仪,从传感器选型、电路设计到代码联调,前前后后折腾了两周。整套方案最终定下来用的是模拟电压输出的红外测距模块和一颗带ADC的32位单片机,量程锁定在10到80厘米,实测误差基本能稳定在两厘米以内。这篇文章就把整个设计过程完整拆开讲一遍:不同红外测距原理之间的差别、传感器到底怎么选、电源和接口怎么搭、代码里的滤波和标定怎么做、上电后又会暴露哪些只有实测才能发现的问题。不管是做课设、电子竞赛还是单纯DIY,应该都能从中找到可以复用的部分。
1. 红外测距的核心原理,为什么反射强度方案最先被淘汰
1.1 三种红外测距方式一次说清
很多人一听“红外测距”,下意识觉得就是“发射红外光然后测反射强度”,实际上红外测距能分成三条完全不同的技术路线,选错了一条,后面的电路、代码、精度全都不对味。
第一种是反射强度检测。一个红外发射管一直发光,旁边放一个光电接收管,物体离得近,反射光强,接收管输出的电压高;离得远,反射光弱,电压低。听起来很简单对不对?真正上手你就会发现,这个方案只能做接近开关,做不了“测距仪”。因为输出和物体颜色关系太大了:黑色绒布和白色纸板放在同样的距离,接收到的光强可以差好几倍,电压根本没法对应回固定距离。再加上环境光里本身就有大量红外成分,白天窗口直射光一照,接收管直接饱和,读出来的数据完全不可信。光强随距离近似平方反比衰减,还叠加接收管的非线性响应,想靠它标定出“多少电压等于多少厘米”,基本是给自己挖坑。
第二种是三角测距。这是目前低价红外测距模块最主流的方案。模块内部有一个红外LED、一个透镜、一个位置敏感探测器(PSD)。LED发出一束光,打到前方物体上之后,反射光穿过透镜,落在探测器表面。物体离得近,反射光线进入模块的角度不同,光斑落在探测器上的位置就不同。探测器输出的模拟电压和这个落点位置挂钩,再经过内部信号处理电路,最终输出一个随距离单调变化的电压值。GP2Y0A21这类经典模块就是这条路线的代表。
第三种是飞行时间法(TOF)。原理可以理解成简化版激光雷达:模块发出红外光脉冲,光碰到物体反射回来,测出发射和接收之间的时间差,乘上光速再除以2就是往返距离。VL53L0X就是这类代表。TOF的优点是精度高、对物体颜色不敏感、输出直接就是距离数值,缺点是价格高一些,驱动代码要处理I2C时序和测量状态机。
1.2 三角测距为什么最适合做设计和DIY
三角测距用生活里的场景来理解特别直观:你闭上一只眼,用两只眼睛看手指,手指从远往近移动时,两只眼睛看到的“视差”变化非常明显。PSD就相当于在测量这个视差,只是发射端和接收端都封装在同一个模块里,小型化做得很好。
这个方案的优势首先是输出简单,直接给模拟电压,单片机只要有ADC就能采集,代码工作量最小。其次是模块内部自带滤光处理,抗环境光能力比简易对管好不少。第三是电压和距离虽然不是线性关系,但关系单调、可重复,只要做一次标定,就能稳定复现。对“基于单片机的红外测距仪”这个场景来说,三角测距就是性价比最优解。想体验更极致的精度,可以上TOF;想省钱,又对精度没要求,那不如直接用超声波或者对管。三角测距卡在中间,正好满足“定量测距”和“成本可控”两个需求。
2. 传感器和单片机选型:GP2Y0A21搭配哪颗MCU最省心
2.1 主流红外测距传感器对比
选传感器不能只看价格,要看输出类型、量程、调试复杂度三项。我把实际比较过的三种方案放在一起看:
| 参数 | 简易对管 | GP2Y0A21 | VL53L0X |
|---|---|---|---|
| 测距原理 | 反射强度 | 三角测距 | 飞行时间 |
| 典型量程 | 5~30cm,粗略 | 10~80cm | 30~200cm |
| 输出信号 | 数字高低电平/模拟强度 | 模拟电压,反比关系 | I2C数字 |
| 抗环境光能力 | 差 | 较好 | 好 |
| 对物体颜色敏感度 | 很敏感 | 中等 | 基本不敏感 |
| 标定难度 | 低但精度差 | 中,需要查表或拟合 | 低,内部校准 |
| 参考价格 | 几元 | 十几到二十几元 | 二十到四十元 |
GP2Y0A21的输出电压大约在0.3V到2.8V之间波动,距离越近电压越高,整体差不多是反比曲线。10cm左右大概是2.8V,80cm左右掉到0.3V。这里有个容易被忽略的点:它输出上限是2.8V左右,这对后级ADC的输入范围很友好,不会把3.3V ADC打坏。VL53L0X名义量程能到2米,但强环境光下会缩水到1米左右,而且I2C驱动比模拟电压复杂,需要初始化、发起测量、轮询状态、读结果。
2.2 STC89C52没有ADC,这是第一个大坑
很多课设题目写着“基于单片机的红外测距仪”,大家习惯性选了STC89C52,因为它是51入门经典,教材和开发板大都用它。但这里就撞上一个特别关键的问题:STC89C52内部没有ADC。GP2Y0A21输出的是模拟电压,51单片机接过来只能干瞪眼。
拆开来看有三条路可以走。
第一条,外挂ADC芯片。比如ADC0832(串行接口,8位)或者ADC0804(并行接口)。这样沿用原来的51单片机,代码里要额外写SPI或者并口读取逻辑。但8位ADC的分辨率有限,按3.3V参考计算,每个字对应约12.9mV。GP2Y0A21近距离时电压变化快,勉强够用;到了60cm以上,电压曲线变得很平缓,8位的量化误差对应出来的距离误差会明显变大,数据显示会一跳一跳的。
第二条,换成自带ADC的增强型51单片机。比如STC12C5A60S2、STC15系列。它们还是51内核,但内置10位ADC,代码从STC89C52迁移成本低,传感器输出直接进ADC引脚就行。这是最平滑的升级路径,既保留51生态,又解决核心痛点。
第三条,直接上32位MCU。我这次用的是STM32F103C8T6,理由很实在:ADC是12位,分辨率高,测量数据平滑得多;后续要扩展报警、按键、多路测距也比较省事。对刚入门的朋友来说,HAL库开发环境搭一次之后,以后再换型号也顺。
我最终选的是STM32F103C8T6。这颗芯片的ADC满量程是4095,精度做10到80cm的测距项目很够用,而且网上资料多,遇到问题容易查。如果你手里已经有一块STC89C52开发板,又不想额外买板子,我的建议是优先换STC12系列,而不是硬加外部ADC,那样布线麻烦,效果提升也有限。
3. 硬件搭建细节:电源、接口、显示模块这三个环节别踩雷
3.1 电源设计:为什么需要大电解电容
GP2Y0A21和STM32F103都要5V供电路径。很多STM32开发板自带3.3V稳压,输入5V就行;GP2Y0A21直接吃5V。整体电流不大,传感器几十毫安、单片机几十毫安,一个5V/1A以上的适配器就够用了。但实测中我发现,传感器模拟输出对电源纹波相当敏感,电源不干净的时候,ADC低位一直在乱跳。
解决办法是在传感器电源引脚旁边并联一颗100uF电解电容和一颗0.1uF瓷片电容。电解电容负责吸收低频波动,瓷片电容负责滤高频噪声,两个搭配起来效果明显。如果板子供电来自USB口,USB入口再加一颗1000uF电容做总储能,防止负载启动瞬间把电压拉塌。这一层电容网络我在调试阶段省下了大量怀疑人生的时间。
3.2 接口电路:模拟信号线不能和电源线捆一起
GP2Y0A21模块一般是三根线:VCC、GND、VO。VO最高不超过2.8V,接3.3V ADC引脚可以直接连,不用额外分压。但要注意一点:模拟信号线尽量短,不要跟电源线扎成一捆走很长时间。否则信号线上会感应到电源纹波,ADC读数跟着抖。
实际项目里,我会把信号线和地线双绞在一起,这样能大幅降低共模干扰。如果是业余环境,飞线控制在15cm以内,基本不会出大问题。超过20cm之后,我实测ADC值会出现每秒几十次的随机跳动,滤波都救不回来。
3.3 显示与指示电路:LCD1602的两个经典坑
显示部分我选了LCD1602,用四线接法,省IO。这个模块有两个高频坑。第一个是第3脚对比度调节,必须接一个10K电位器,或者用电阻分压把电压拉到合适范围。很多人接好线后屏幕上只有一排黑块,其实就是这个脚电压不对。第二个是背光正极接5V时最好串一个限流电阻,100欧到220欧都行,不串的话背光电流偏大,长期使用影响寿命。
我还加了一颗有源蜂鸣器和一颗LED做声光指示。逻辑很直接:距离小于20cm时蜂鸣器响,超过60cm时LED熄灭。这样整个系统就有了交互层,后续做成倒车雷达类的应用,不用再改电路。
3.4 布局和焊接的实操建议
如果不用现成开发板,而是自己做PCB,建议模拟地和数字地单点连接。传感器部分的地单独走,回到电源地之后,用0欧电阻或者磁珠接到数字地。这个在双面板上花一点心思就有明显效果。万用板焊接时,尽量缩短传感器到MCU引脚之间的飞线,每条线别超过15cm。传感器靠近板边角放置,不要让信号线穿过电感、继电器或者电机驱动这些干扰源。
4. 软件写起来不难,难点在标定和滤波
4.1 ADC采样与中值滤波
GP2Y0A21的单次采样噪声挺大,周围有人走动、日光灯频闪都会让ADC值抖动。我采用的做法是“5次采集排序取中值”。那为什么不用平均值?平均值对脉冲干扰不够鲁棒,偶尔一个尖峰就会把结果拉偏。中值滤波在有少量离群点时能把异常值直接丢掉,而且排序5个元素的开销在单片机上也完全可以忽略。
这里是基于STM32 HAL库的示意代码:
#include "stm32f1xx_hal.h" #define FILTER_N 5 uint16_t adc_buf[FILTER_N]; uint16_t read_adc_raw(void) { ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_2; sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig); HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); return (uint16_t)HAL_ADC_GetValue(&hadc1); } uint16_t median_filter(void) { for (int i = 0; i < FILTER_N; i++) { adc_buf[i] = read_adc_raw(); HAL_Delay(2); } for (int i = 0; i < FILTER_N - 1; i++) { for (int j = i + 1; j < FILTER_N; j++) { if (adc_buf[j] < adc_buf[i]) { uint16_t t = adc_buf[i]; adc_buf[i] = adc_buf[j]; adc_buf[j] = t; } } } return adc_buf[FILTER_N / 2]; }注意这里每次采样间隔了2ms,目的是避开模块内部测量周期的波动,也防止ADC连续采样互相干扰。
4.2 电压到距离的标定:查表加线性插值
GP2Y0A21的电压和距离不是线性关系,数据手册给的是典型曲线,但每一颗传感器个体差异不小。我做了8个标定点,距离从10cm到80cm,用卷尺和白色纸板标出来的,得到这样一张表:
| 距离cm | 输出电压V |
|---|---|
| 10 | 2.78 |
| 20 | 1.78 |
| 30 | 1.20 |
| 40 | 0.86 |
| 50 | 0.65 |
| 60 | 0.52 |
| 70 | 0.43 |
| 80 | 0.36 |
然后我用分段线性插值把电压换算成距离。为什么不用多项式拟合?多项式在标定区间内看着很光滑,但区间外容易发散,单片机跑浮点多项式也要额外时间和内存。分段线性插值实现简单、区间内可信、区间外直接锁定边界值,对近距离测距项目来说完全够用。
float voltage_to_distance(float v) { static const float points[][2] = { {10.0f, 2.78f}, {20.0f, 1.78f}, {30.0f, 1.20f}, {40.0f, 0.86f}, {50.0f, 0.65f}, {60.0f, 0.52f}, {70.0f, 0.43f}, {80.0f, 0.36f}, }; int n = sizeof(points) / sizeof(points[0]); if (v >= points[0][1]) return points[0][0]; if (v <= points[n - 1][1]) return points[n - 1][0]; for (int i = 0; i < n - 1; i++) { if (v >= points[i + 1][1] && v <= points[i][1]) { float t = (v - points[i][1]) / (points[i + 1][1] - points[i][1]); return points[i][0] + t * (points[i + 1][0] - points[i][0]); } } return -1.0f; }这里有个关键细节:ADC换算电压时,参考电压必须和实际一致。我的STM32板子参考电压是3.3V,12位ADC满量程4095,所以电压 = raw * 3.3f / 4095.0f。如果你用5V参考电压或者别的芯片,这个比例必须同步改,否则标定表全部错位,数据显示会整体偏移。
4.3 LCD显示和主循环架构
主循环就是“采集、滤波、换算、显示”四步,每50ms刷新一次。50ms的刷新率下数字更新平滑自然,又不至于太耗CPU。示意代码如下:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); lcd_init(); HAL_Delay(300); while (1) { uint16_t raw = median_filter(); float v = raw * 3.3f / 4095.0f; float dist = voltage_to_distance(v); lcd_show_distance(dist); HAL_Delay(50); } }如果你的标定表足够密,比如每5cm一个点,精度还能再往上走。如果做实时性要求更高的场景,可以把ADC采样放到定时器中断里,主循环只负责读取滤波结果和显示。但本项目50ms周期内完成5次采样、排序、计算,STM32主频完全够用,不需要上中断,代码也更好理解。
5. 整机调试与实测数据,这些坑只有通电之后才暴露
5.1 上电瞬间为什么读数全乱
GP2Y0A21内部有测量周期,上电后前几百毫秒输出是不稳定的,电压会先冲高再回落。如果单片机一上电就立刻读数,第一帧距离可能会显示成几厘米甚至负值。解决方法是程序上电后延时300ms再开始正式采集显示,或者上电后前100帧丢弃不显示。
我自己是做了一个启动计数逻辑:上电后连续读100次,只过滤掉异常范围后的结果才刷新到LCD上。实际体验下来,比单纯延时更稳,因为它在等待的同时还在确认传感器输出是否进入正常区间。
5.2 实测数据与误差分布
整机搭好后,我在室内灯光环境下做了几组实测,目标是卷尺固定的白色纸板:
| 真实距离cm | 显示距离cm | 误差cm |
|---|---|---|
| 10 | 9.8 | -0.2 |
| 20 | 19.6 | -0.4 |
| 30 | 30.5 | +0.5 |
| 40 | 39.8 | -0.2 |
| 50 | 50.5 | +0.5 |
| 60 | 60.8 | +0.8 |
| 70 | 69.2 | -0.8 |
| 80 | 78.3 | -1.7 |
20到50cm区间误差基本在±1cm以内,60cm之后误差开始加大,80cm处有接近1.7cm的偏差。原因主要是两个方向:一个是远距离电压变化斜率变缓,同样的ADC量化误差对应更大的距离误差;另一个是标定纸板和实际测量目标的反射特性不完全一致。如果要求全量程±1cm,可以加密标定表,或者换更高分辨率的ADC和TOF传感器。
5.3 环境光和物体颜色带来的影响
我用黑色绒布做了对照测试,同样在80cm位置,读数会突然跳到70cm以内,这是因为黑色表面吸收了大部分红外光,PSD上的光斑变暗,系统判断距离发生偏移。解决办法要分场景:如果测距对象固定,比如只测某个机器前方的托盘,那就用这个实际对象重新做一次标定;如果是通用场合,就要把标称量程写得保守一些,比如对外标20到60cm,避开边缘坑。
环境光方面,室内日光灯影响不大,但白天窗边直射的太阳光会让读数略微偏小。要做户外版本,需要加遮光罩,或者选择带红外滤光片的模块。
5.4 扩展思路:在线校准、声光报警、多路测距
整个系统跑通之后,我加了两个扩展功能,成本很低但体验提升明显。一个是通过两颗按键实现在线校准:用户把目标放到已知距离,长按按键,系统自动记录当前ADC值并更新标定表,完全不需要连电脑改代码。另一个是用定时器控制蜂鸣器输出不同频率,距离越近声音越急促,模拟倒车雷达的节奏,实用性很强。
如果需要多路测距,GP2Y0A21这类模拟输出模块非常友好,因为MCU只要预留足够ADC通道,就能并行接多个传感器,分别滤波、分别标定、分别显示。我在第二路扩展测试时,只多花了几根线和一段ADC初始化代码。红外测距仪这个项目做到这一步,已经从“能显示距离”变成“能作为感知节点接入更大的系统了”。
做完这个项目,我最深的体会是:红外测距仪真正的难点不在电路,也不在写代码,而在标定。传感器硬件只是把物理量变成电信号,能不能精确还原成厘米,完全取决于你对传感器特性的理解和数据处理的方式。另一个收获是,设计过程中一定要在早期就确认单片机的ADC能力,否则后期再改主控,整个电路和代码都要推倒重来。希望这篇记录能帮正在做类似项目的朋友少走几步弯路。