STM32红外测距模块实战:ADC采样、非线性标定与滤波方法 📅 发布时间:2026/9/15 22:58:09 👁 浏览次数: 简介基于STM32与SHARP 2Y0A21红外测距模块的完整源码包面向嵌入式开发者与高校学生解决红外测距的数据采集、距离换算和工程应用问题可用于小车避障、距离监测等场景。包内共138个文件、约1.4MB包括44个.h头文件、43个.c源文件覆盖ADC、定时器、串口、I2C、CAN等外设驱动另有33个汇编启动文件、Keil工程文件、hex固件及PDF/Word说明文档工程完整可直接编译。已有237人浏览学习代码结构清晰适合初学者对照STM32标准外设库学习驱动写法。通过该工程可掌握2Y0A21距离标定与采样处理方法也能借鉴模块化分层思路快速移植到其他红外测距或避障项目中。1. 一块输出模拟电压的红外测距模块难点从来不在读 ADCSHARP 2Y0A21模块常标 GP2Y0A21YK是 STM32 项目里做 1080cm 避障测距的常见选择三根线、一路模拟电压、接一个 ADC 通道就能读数。可真正把它用好的人清楚麻烦不在“读电压”而在“把电压换成距离”。它的输出对距离是非线性反比关系模块个体差异能到 10% 以上直接抄网上的源程序误差大得足以让避障逻辑拿墙当路。这篇文章按我给单片机接传感器的习惯顺序来先讲 2Y0A21 的输出特性和为什么不能当线性器件处理再给 STM32 上可复现的 ADC 配置与测距源程序最后落到标定、滤波和验证上。用 HAL 库、标准库还是寄存器操作结论都一样差别只在写法。读完你应该能自己组装出这条数据链定时采样 → 滤波 → 查表插值 → 串口输出厘米距离同时知道哪些参数必须按自己手里那块模块重标。2. 2Y0A21 的输出曲线反比电压先看懂再谈采样和代码2.1 PSD 三角测距原理曲线天生就是反比形状2Y0A21 内部是红外 LED 加位置敏感探测器PSD。LED 发出的光经目标反射后落在 PSD 上的光斑位置会随目标距离连续移动模块里那颗信号处理芯片再把“光斑位置”换算成模拟电压输出。因为光斑位移和目标距离近似成反比V-d 曲线天然就是 1/d 的形状和超声波模块那种接近线性的曲线完全不同。这个原理直接决定了标定策略任何“电压乘个固定系数”的线性换算在 1080cm 全量程上都站不住脚。另一个连带结论是这类模块对目标颜色的敏感度比光敏电阻式模块低但深色低反射物仍然会让读数整体偏远这部分放到第 6 章讲。先记住一点它是三角测距不是测反射光强所以“手挡住 LED”和“物体太远”在输出上是两种完全不同的表现。2.2 典型输出电压参数量程内单调量程外不可信下表是 2Y0A21 在 5V 供电、白色目标下的典型电压具体每块模块会略有偏差但曲线形状一致实际距离 (cm)输出典型电压 (V)备注102.45量程下限再近进入盲区151.95201.60301.20曲线最接近反比关系的中段400.95500.78600.68800.52量程上限其他关键参数供电 4.55.5V典型 5V工作电流 30mA 上下输出电压范围约 0.42.6V单次测量周期典型 39ms对应刷新率上限约 25Hz。注意 20cm 以内电压斜率最陡10cm 到 20cm 掉了近 0.85V对噪声最敏感而 60cm 到 80cm 的 10cm 才掉 0.16V12 位 ADC 的一位量化误差就相当于 1cm 出头。所以远距离想测稳滤波和标定比 ADC 分辨率更重要。注意2Y0A21 是 1080cm 量程不是长量程的 2Y0A0220150cm两条曲线不能互用。网上有一些混用两条曲线参数的源码抄之前先看清模块型号。目标超出量程或完全移走时多数模块的输出会跳到 2.5V 附近的高位饱和区个别批次落到 0.3V 以下的低饱和区无论如何都超出 1080cm 对应的单调电压区间。这段电压和 10cm 以内的电压混在一起代码里如果只做上下限夹取就会把“没有目标”读成“距离 10cm”这个坑后面专门处理。2.3 上电时序与供电质量首帧无效电压不稳则曲线整体平移上电瞬间信号处理芯片处于建立过程前 12 次测量约 4080ms的输出不可信。源程序里的常规做法是上电后延时 150ms 左右把首帧丢掉再进入测距循环。更隐蔽的问题是供电模块电流 30mA 左右如果和舵机、电机共用一个 LDO堵转时总线电压被拉低V-d 曲线整体偏移测距会突然变近一两厘米。我一般会在模块 Vcc 脚就近并一个 100μF 电解电容加 0.1μF 陶瓷电容GND 单独拉回电源地不要串到信号地走线里。判断供电是否正常的办法很简单用万用表量模块 Vcc 脚读数必须在 4.5V 以上并且测距读数抖动时这个电压不能跟着抖。这个检查应该排在所有软件排查前面。在写 STM32 代码之前先用一段 Python 验证“电压换距离”的数学模型。把 datasheet 的典型点做一次拟合看 d 和 1/V 是不是近似直线# 用典型值验证 d a/V b 模型 import numpy as np d np.array([10, 15, 20, 30, 40, 50, 60, 80], dtypefloat) v np.array([2.45, 1.95, 1.60, 1.20, 0.95, 0.78, 0.68, 0.52], dtypefloat) a, b np.polyfit(1.0 / v, d, 1) pred a / v b print(a%.2f b%.2f % (a, b)) print(max_err%.2fcm % np.max(np.abs(pred - d)))这段代码把 1/V 当作自变量、距离当作因变量做一次线性拟合算出来 a 通常在 46 左右、b 在 -9 左右8 个典型点的最大残差不到 1cm。这说明 d a/V b 这个两参数模型在全量程上都成立比只有一个 K 的 d K/V 多了一个偏移项正好补上 10cm 附近的曲线下弯。后面 STM32 里的标定就基于这个模型。3. STM32 的 ADC 配置模拟输入、采样时间与 40ms 采样节奏3.1 接线与电平匹配为什么 5V 供电能直连 3.3V ADC以最常见的 Blue PillSTM32F103C8T6为例接线只有三根2Y0A21 引脚接到 STM32说明Vcc棕5V供电标称 4.55.5V不建议 3.3V 直接供电GND蓝GND必须与 MCU 共地Vo黑PA1 / ADC1_IN1模拟输出最大约 2.6VVo 最大约 2.6V低于 3.3V 的 ADC 输入耐压所以不需要分压电阻。分压反而会把小信号再衰减60cm 外 0.6V 级别的电压被分压后分辨率更差。如果实测 Vo 超过 3.0V先怀疑供电异常而不是加电阻。不共地时读数会整体漂移这是“插上 USB 调试就好、用电池供电就不对”的常见原因之一。3.2 GPIO ADC1 初始化一个最小可跑通的配置PA1 对应 ADC1 的通道 1。GPIO 必须配成模拟输入模式不能是浮空输入也不能开上下拉否则会拉偏传感器输出电压/* 接线PA1 - VoGND - GND5V - Vcc */ ADC_HandleTypeDef hadc1; void HAL_ADC_MspInit(ADC_HandleTypeDef *hadc) { if (hadc-Instance ! ADC1) return; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_ADC1_CLK_ENABLE(); GPIO_InitTypeDef gpio {0}; gpio.Pin GPIO_PIN_1; gpio.Mode GPIO_MODE_ANALOG; /* 模拟输入不是浮空输入 */ gpio.Pull GPIO_NOPULL; HAL_GPIO_Init(GPIOA, gpio); } void IR_ADC_Init(void) { hadc1.Instance ADC1; hadc1.Init.ScanConvMode ADC_SCAN_DISABLE; hadc1.Init.ContinuousConvMode DISABLE; /* 单次转换软件触发 */ hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion 1; HAL_ADC_Init(hadc1); ADC_ChannelConfTypeDef ch {0}; ch.Channel ADC_CHANNEL_1; ch.Rank 1; ch.SamplingTime ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(hadc1, ch); }参数说明ADC_SAMPLETIME_239CYCLES_5是 F103 最长的采样时间对 2Y0A21 这种输出阻抗偏高的模拟源采样时间短了内部采样电容充不满读数会偏低且带非线性多通道切换的项目里尤其明显。ADC 时钟在 72MHz 主频下建议分频到 12MHz单次转换 239.5 12.5 个周期约 21μs远小于传感器的 39ms 更新周期。寄存器风格的做法江科大那套视频里的写法就是把 GPIOA-CRL 的第 47 位清零让 PA1 变成模拟输入再用 CR2 打开 ADC1 时钟本质是同一件事。3.3 要不要加 RC 滤波模块输出脚到 MCU 之间的走线如果超过 10cm或者和电机线贴近可以在 Vo 到 GND 之间加一个 100Ω 0.1μF 的低通。但要注意RC 会拖慢建立时间加了之后采样时间就不能再用短的否则每次 ADC 采样看到的都是上一个电压台阶的残余。面包板加杜邦线的场景我一般不加 RC靠第 4 章的软件滤波足够只有做 PCB 且走线靠近功率回路时才加。3.4 定时器控制采样节奏而不是 while 死循环2Y0A21 的输出 39ms 才刷新一次ADC 转换只要 21μs读得再快拿到的也是同一个电压的重复读数。所以采样节奏要由滴答定时器控制每 40ms 触发一次完整测量static uint32_t last_ms 0; int IR_Ready(void) { uint32_t now HAL_GetTick(); if (now - last_ms 40) { /* 40ms 略大于 39ms 刷新周期 */ last_ms now; return 1; } return 0; }说明40ms 比 39ms 略大保证每次读取都落在新的输出周期里。如果有人在 5ms 内连读 20 次再取平均这 20 个点全是同一个电压的重复滤波毫无意义。这是“滤波加了没用、读数呈台阶状”的最常见原因。更彻底的做法是用定时器触发 ADC 的外部触发模式配合 DMA 把结果搬进环形缓冲主循环只做滤波和标定适合采样任务和主逻辑分离的场景但最小可跑通方案用上面的滴答判断就够了。4. 测距源程序核心中值滤波、电压换算与查表标定4.1 中值滤波 一阶低通对付毛刺和平台波动红外模块在目标边缘、反光面和阳光直射下会出现单点毛刺上一帧读 30cm下一帧直接跳 55cm。中值滤波对这种尖峰最有效因为尖峰只影响少数样本。每组读 5 次取中值再做一阶低通把器件自身的电压波动压掉#define IR_SAMPLES 5 static uint16_t IR_Median(uint16_t *buf, uint8_t n) { for (uint8_t i 0; i n - 1; i) { for (uint8_t j i 1; j n; j) { if (buf[j] buf[i]) { uint16_t t buf[i]; buf[i] buf[j]; buf[j] t; } } } return buf[n / 2]; } uint16_t IR_FilteredRead(void) { uint16_t buf[IR_SAMPLES]; static uint16_t smooth 0; for (uint8_t i 0; i IR_SAMPLES; i) { HAL_ADC_Start(hadc1); HAL_ADC_PollForConversion(hadc1, 10); buf[i] HAL_ADC_GetValue(hadc1); HAL_Delay(1); } uint16_t mid IR_Median(buf, IR_SAMPLES); if (smooth 0) smooth mid; else smooth smooth ((uint32_t)(mid - smooth) 2); return smooth; }说明5 个样本间隔 1ms总共 4ms远小于 39ms 刷新周期取到的是同一个输出周期里带噪声的 5 次读数。中值杀掉单点尖峰 2相当于 α0.25 的一阶惯性滤波把几十毫伏的器件波动压掉。避障场景这个动态响应够快如果目标移动很慢可以把移位改成 3更平滑但滞后更大需要自己权衡。4.2 从 ADC 原始值换算电压基准电压用万用表实测#define VREF 3.31f /* 实测 VDDA别直接填 3.3 */ float IR_GetVoltage(uint16_t raw) { return (float)raw * VREF / 4095.0f; }说明F103 的 ADC 基准就是 VDDA 引脚Blue Pill 这类板载 LDO 输出的 3.3V 实测经常是 3.283.36V。标定用的是电压和距离的对应关系基准电压偏一点标出来的 a、b 也跟着偏最后距离值反而没错但如果你想把电压打印出来和 datasheet 曲线对比基准不对就会对不上白白怀疑传感器。所以先把基准测准能省掉一轮无效排错。4.3 两点标定用 d a/V b 替代单参数反比公式2.3 节验证过d a/V b 对典型曲线全量程残差在 1cm 内。标定只需要两个已知距离点解出 a 和 btypedef struct { float d; /* 实际距离 cm用尺子量 */ float v; /* 对应电压 V */ } IR_CalPoint; void IR_CalibFit(IR_CalPoint p1, IR_CalPoint p2, float *a, float *b) { *a (p2.d - p1.d) / (1.0f / p2.v - 1.0f / p1.v); *b p1.d - *a / p1.v; }举个例子标定 15cm 和 50cm 两个点测得电压 1.95V 和 0.78V算得 a 35 / (1/0.78 - 1/1.95) ≈ 45.5b 15 - 45.5/1.95 ≈ -8.3。拿 30cm 验证45.5/1.20 - 8.3 ≈ 29.6cm非常接近。说明两个点选在量程两端、中间留一个验证点是性价比最高的标定方式。提示标定点的距离用卷尺量别目测电压取滤波后的稳定值。两个标定点离得越开曲线中段的拟合越好。如果是自己打样的小车标定完记得把 a、b 存到 Flash避免每次上电重新标。4.4 查表 线性插值多点标定时的更稳做法两点公式对数学模型的依赖较强如果某块模块的曲线形状和典型值差异大多点查表更稳。把标定好的“电压-距离”对按电压降序排成表两点之间做线性插值typedef struct { float volt; float dist; } IR_Node; const IR_Node ir_table[] { {2.45f, 10.0f}, {1.95f, 15.0f}, {1.60f, 20.0f}, {1.20f, 30.0f}, {0.95f, 40.0f}, {0.78f, 50.0f}, {0.68f, 60.0f}, {0.52f, 80.0f}, }; float IR_TableDist(float volt) { uint8_t n sizeof(ir_table) / sizeof(ir_table[0]); if (volt ir_table[0].volt) return ir_table[0].dist; if (volt ir_table[n-1].volt) return ir_table[n-1].dist; for (uint8_t i 0; i n - 1; i) { if (volt ir_table[i].volt volt ir_table[i1].volt) { float t (ir_table[i].volt - volt) / (ir_table[i].volt - ir_table[i1].volt); return ir_table[i].dist t * (ir_table[i1].dist - ir_table[i].dist); } } return 80.0f; }说明表按电压降序排列因为距离越近电压越高。线性插值假设两点之间曲线近似直线对 2Y0A21 在 20cm 以上够用1020cm 段斜率陡标定时这段要加密到 5cm 间隔。查表的好处是不假设任何数学模型的形状某一段曲线再怪也能跟住代价是标定 8 个点需要十分钟左右的耐心活。实际产品里我通常把两点公式用于出厂初值查表用于产线逐台校准后的修正值。到这里还要处理一个量程边界问题。电压超过 2.6V 时既可能是目标进入 10cm 盲区也可能是完全没有目标。只做上下限夹取会把“无遮挡”当成“距离 10cm”避障车会原地磨轮。处理方式是分出“出界状态”而不是硬给一个距离/* 返回 0有效1近端饱和/无遮挡2远端丢失 */ uint8_t IR_Classify(float volt, float *out) { if (volt 2.60f) return 1; /* 近端或无障碍物理上不可区分 */ if (volt 0.40f) return 2; /* 超出 80cm 量程 */ *out IR_TableDist(volt); return 0; }这个分类在后面接避障策略时非常关键。把“无法区分的状态”暴露给上层逻辑而不是在上层假装它是个准确的厘米数这是红外测距模块和超声波模块最大的用法差异。5. 把源程序串起来主循环节拍、串口输出与观察波形5.1 一个可直接用的主循环骨架把前面几节的函数串成一个完整的测距任务主循环只做三件事等 40ms 节拍、读滤波值、算距离打印int main(void) { HAL_Init(); SystemClock_Config(); /* 72MHz 主频 */ MX_USART1_Init(); /* 串口 1115200 8N1 */ IR_ADC_Init(); char line[64]; float volt, dist; uint16_t raw; HAL_Delay(150); /* 2Y0A21 上电建立时间丢弃首帧 */ IR_FilteredRead(); /* 再丢一帧清空滤波缓存 */ while (1) { if (!IR_Ready()) continue; /* 40ms 节奏 */ raw IR_FilteredRead(); volt IR_GetVoltage(raw); dist IR_TableDist(volt); /* 或用 a/Vb 公式 */ sprintf(line, raw%4u V%.3f D%5.1fcm\r\n, raw, volt, dist); HAL_UART_Transmit(huart1, (uint8_t *)line, strlen(line), 50); } }说明IR_Ready的 40ms 节拍要放在读取外面因为IR_FilteredRead本身会阻塞 4ms连续两次调用之间才是真正的采样间隔。串口打印放在节拍之后、读取之前都不影响节奏只要打印耗时不超过 40ms。Keil 环境记得启用 MicroLIB 或者加--printf_float选项否则%f打印不出来这是新手最容易卡住的点。5.2 串口打印为什么要带上 raw 和 V我调试这类模拟量模块时串口一定同时输出原始 ADC 值、电压和最终距离而不是只打印距离。原因有两个第一raw 能直接看出毛刺和台阶如果同一电压连续打印 19 次说明采样节奏没对读的是重复数据第二电压要和 datasheet 典型曲线对比能快速判断模块个体是整体偏高还是偏低这决定了要不要重新标定而不是重新滤波。调试期采样节拍参数可以参考下表参数取值说明ADC 时钟12MHzPCLK272MHz 时 DIV6单次转换约 21μs239.5 12.5 个周期每组样本5 个组内间隔 1ms采样节拍40ms对齐 39ms 刷新周期串口输出115200 8N1raw V D 一起打电压在目标静止时显示的是一串稳定的 39ms 更新台阶而不是密集的随机跳动。用串口助手的图形化波形看能看到约 25Hz 的小台阶刷新说明整个采样链路的节奏是对的。5.3 常见误区把延时和滤波叠在一起造成滞后有人喜欢在循环里写HAL_Delay(40)再读一次表面上看也是 40ms 周期但实际周期是 40ms 加滤波阻塞 4ms 加串口打印时间累计下来刷新率会掉到 15Hz 以下目标移动时读数滞后明显。用滴答判断节拍就没有这个问题延时只存在于滤波函数内部的 1ms 组内间隔。车用场景如果不调试串口打印要降到 5Hz 或者挪到单独调试模式否则打印本身会成为新的耗时源。6. 反射率、环境光与出界电压的验证处理6.1 深色目标让距离变远标定要用真实被测物PSD 三角测距对反射率的敏感度比光敏式模块低但黑色毛毡、磨砂深色塑料这类低反射物会让光斑变弱输出整体偏低读数偏远 5%15%。反过来反光金属和镜面会产生多径反射读数抖动甚至跳到量程外。所以标定时的被测物最好就是实际应用里的那个物体——用白纸标定完拿去测黑布误差直接不可用。阳光下 PSD 的调制解调容易饱和表现为固定距离下电压偏高且随机跳动室内灯光基本没影响。必要的时候用一段黑色纸筒围住传感器能同时挡掉正向阳光和侧面反光。6.2 快速验证三步空场、10cm、50cm上电后先看空场电压。正常时应该落到量程外的饱和区多数模块在 2.5V 上下如果稳定落在 0.71.8V 的中段说明开机时前方就有物体或者 Vo 没接对。然后用已知距离验证两个端点10cm 处电压应在 2.4V 左右50cm 处应在 0.8V 左右偏差超过 ±0.1V 就检查供电。最后用 30cm 中点验证标定效果两点标定算出的距离偏差超过 3cm 时改用多点查表/* 上电自检空场电压应落在量程之外的饱和区 */ float v_idle IR_GetVoltage(IR_FilteredRead()); if (v_idle 0.6f v_idle 2.3f) { /* 前方 20~60cm 有物体或 Vo 接触不良 */ sensor_error 1; }这个自检放在 while(1) 之前能提前暴露接线和安装位置问题比任何滤波都更早发现故障。6.3 把出界电压映射成安全状态而不是距离最后回到 4.4 节那个物理上不可区分的问题电压大于 2.6V既可能是 5cm 的近物也可能是无遮挡。硬件无法区分但策略可以处理。我一般把测距输出定义成三态距离只占其中一态/* 状态 0有效距离1近端或遮挡2远端丢失 */ uint8_t ir_state; float ir_dist_cm; ir_state IR_Classify(volt, ir_dist_cm); if (ir_state 1) { Motor_Stop(); /* 无法区分按最近处理停车最安全 */ } else if (ir_state 2) { Motor_Forward(); /* 远端丢失允许前进 */ }避障逻辑里状态 1 一律按“前方有物体、停车”处理因为不管是 5cm 还是没信号停车都是安全侧的选择状态 2 才允许继续前进。这样就把器件本身的物理盲区转化成了行为上的保守策略比在距离值上做各种平滑都更可靠。加一句这个三态分类同样适合拿来接智能小车状态机或者作为异常上报比把 2.5V 硬换算成 10cm 再让上层猜要干净得多。本文还有配套的精品资源点击获取