STM32红外测距实战:2Y0A21传感器ADC采样与距离计算
简介STM32 单片机与 SHARP 2Y0A21 红外测距模块联合测距的完整工程源程序面向嵌入式开发人员、电子竞赛参赛者及智能小车避障项目爱好者。工程以 Keil 形式组织围绕模拟电压采集、电压-距离换算、数据显示等常见需求解决该型号红外模块在 STM32 平台快速接入和稳定测量的问题。7z 压缩包共 138 个文件大小仅 1.4MB内容以 44 个 h 头文件、43 个 c 源文件和 33 个 s 启动文件为主并附带 Keil 工程配置文件、hex 烧录文件以及 pdf/doc 格式的说明文档覆盖源码阅读、工程编译、程序烧录等多个环节目录结构清晰。已有 237 人学习这份资料读者可以了解 STM32F10x 标准外设库的初始化和 ADC 采集流程并可直接复用其中针对 2Y0A21 的电压-距离换算与滤波逻辑无论是用于课程设计还是实际避障项目都能节省从头编写驱动的时间缩短调试过程。1. SHARP 2Y0A21 这类“电压型”测距传感器为什么比数字式更好上手以前在 stm32 项目里做红外测距很多人第一反应是找 UART、I2C 接口的传感器。SHARP 2Y0A21 恰恰相反它的输出是一根模拟信号线电压随目标距离升高而降低距离越近电压越高。STM32 的 12 位 ADC 引脚接入后就能按比例还原出障碍物距离。这个特性让它响应快、驱动简单对深色物体和斜面的适应力也比超声波好。STM32F103 板载 ADC 已能满足 10cm 到 80cm 测距需求不需要外接运放。这里要分享的源程序正是基于 SHARP 2Y0A21 的标准外设库实现适合正在用 ADC 做传感采集、想给小车或机械臂加避障模块的工程师。2. 先把 ADC 采样这条路走通2Y0A21 输出特性与 STM32 标准库初始化2.1 为什么是电压输出而不是直接给距离SHARP 2Y0A21 使用三角测量原理内部光敏元件输出与反射角度相关的电流经信号调理后变成模拟电压典型输出范围约 0.4V 到 2.8V。距离与电压呈非线性反比关系距离越远曲线越平缓。数据手册的适用区间是 10cm 到 80cm靠近下限时电压变化剧烈所以在近距离能分辨毫米级靠近上限时电压差很小远距离只能保证厘米级分辨率。表 1 是我从手册上摘出的典型值实际模块会有约 5% 装配误差。距离(cm)输出电压(V)102.25151.85201.45251.20301.05400.82500.70700.55一张表在手写代码时就不需要每次对着曲线估计了。2.2 接线与时钟分配的一个关键点模块三根线分别是 VCC、GND、Voltage Output。建议使用 3.3V 供电因为 STM32 的 ADC 参考电压是 3.3V用 5V 供电会把近处的高电压顶到量程之外。信号线接到 PA1对应 ADC1 的通道 1。标准外设库中配置 GPIO 为模拟输入模式时不要开启上下拉否则等效电阻会改变分压近距离电压读数会漂。模块与 PA1 之间不需要串联电阻但很多工程师会加一个 0.1uF 电容到 GND滤掉红外 LED 的调制残余实测在开关电源纹波大的时候效果明显。下面是 ADC1 的初始化代码。我这里没有使用连续转换模式为的是后面能平滑切换到定时器触发。void ADC1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; uint32_t timeout; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStructure); ADC_DeInit(ADC1); ADC_InitStructure.ADC_Mode ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode DISABLE; ADC_InitStructure.ADC_ContinuousConvMode DISABLE; ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel 1; ADC_Init(ADC1, ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_239Cycles5); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); timeout 0xFFFF; while(ADC_GetResetCalibrationStatus(ADC1) --timeout); timeout 0xFFFF; ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1) --timeout); }代码逻辑先开 GPIOA 和 ADC1 时钟配置 PA1 为模拟输入设置 ADC 为单通道独立模式数据右对齐。ADC_SampleTime_239Cycles5是标准库里最大的采样周期对 2Y0A21 这种慢变信号足够虽然采样时间更长但是抗串扰更好。ADC_RegularChannelConfig的四个参数分别是 ADC 编号、通道编号、排序位置和采样时间排序位置在只用一个通道时固定为 1。标定函数里加了超时保护防止芯片异常时死循环。如果传感器接在 PA0把通道号改成ADC_Channel_0即可其余不用动。2.3 读 ADC 的常见调用方式初始化完成后可以用软件触发来读取。下面这个函数做了多次平均能在不改变硬件的情况下先拿到一个稳定读数。uint16_t read_adc_avg(uint8_t times) { uint32_t sum 0; uint8_t i; if (times 16) times 16; for (i 0; i times; i) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while(ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) RESET); sum ADC_GetConversionValue(ADC1); } return (uint16_t)(sum / times); }每次先发起一次软件转换等待 EOC 位置位再取转换值。times 上限设成 16 次避免连续转换占满 CPU。返回值是 ADC 原始值范围 0 到 4095。想转成电压用voltage adc_value * 3.3f / 4095.0f就行。需要注意如果之后改成定时器外部触发就不能再调用ADC_SoftwareStartConvCmd了否则一次触发会被软件手动触发的下一次采样顶掉时序就乱了。调试阶段先用软件触发把电压读数打印出来确认传感器有反应再切换定时器方式。3. 查表还是公式把 ADC 原始值算成厘米的三种做法3.1 反演曲线手册表格够用吗数据手册的距离-电压曲线适合做查表但每个模块的装配误差会导致电压偏移。直接用手册数值做分段线性插值工程上能得到正负 2cm 误差对避障完全够用。想要更高精度需要单板标定也就是最后一章要说的“卷尺最小二乘”。这一节先把查表版本做出来。3.2 分段线性插值查表距离和电压不是正比关系所以用线性插值把离散点连起来比单个线性公式更贴近真实曲线。写法如下const float dist_table[] {8.0f, 10.0f, 15.0f, 20.0f, 25.0f, 30.0f, 40.0f, 50.0f, 70.0f}; const float volt_table[] {2.55f, 2.25f, 1.85f, 1.45f, 1.20f, 1.05f, 0.82f, 0.70f, 0.55f}; float voltage_to_distance(float voltage) { uint8_t i; if (voltage volt_table[0]) return dist_table[0]; for (i 0; i 8; i) { if (voltage volt_table[i 1]) { float ratio (volt_table[i] - voltage) / (volt_table[i] - volt_table[i 1]); return dist_table[i] ratio * (dist_table[i 1] - dist_table[i]); } } return dist_table[8]; }表索引 0 是近距离 8cm电压 2.55V索引 8 是远距离 70cm电压 0.55V。电池电压从近到远单调下降所以循环从近到远找第一个满足voltage volt_table[i1]的区间比例ratio表示当前电压在区间内靠近远端的程度。最后如果电压很低直接返回 70cm。这段代码适合对距离精度要求不高的场合。3.3 用一个反比公式快速估算如果你的主循环已经有太多浮点运算或者只是想少写几十行查表代码可以用反比拟合。2Y0A21 的曲线形状接近distance K / (voltage - V0)。取两个手册点(20cm, 1.45V) 和 (40cm, 0.82V)计算得到 V0 约 0.19VK 约 25.2cm*V。float distance 25.2f / (voltage - 0.19f); if (distance 3.0f) distance 3.0f; if (distance 80.0f) distance 80.0f;这个公式在 15cm 到 40cm 区间误差很小超过 50cm 后误差变大因为电压下降趋缓。限制在 3cm 到 80cm 是必要的模块在 2cm 以内会输出饱和电压公式可能给出负值或无意义结果。三种映射方法的取舍可以看表 2。方法代码量精度适合场景分段线性插值约 20 行正负 2cm通用避障、测距反比公式约 3 行中近距离准快速原型、演示最小二乘多项式需要标定数据最优固定安装、量产3.4 采样间隔为什么不能小于 40msSHARP 2Y0A21 内部是红外调制测距信号处理电路需要时间建立。手册标称测量周期约 38ms意思是相隔少于 30ms 的两次采样第二次电压可能还停留在上次输出上。物体从 30cm 突然移到 10cm传感器输出电压不会瞬间跳变而是要等将近两个测量周期。所以不管用软件轮询还是定时器触发采样间隔都应该设置在 50ms 以上。下一章的定时器触发方案正是顺应这个要求。4. 用 STM32 定时器触发 ADC再通过串口输出距离值4.1 为什么不让主循环一直读stm32 项目中如果只用延时做轮询主循环在处理其他任务时可能跳过采样导致距离序列不均匀。ADC 转换本身开销很小但采样间隔抖动会影响后续滤波效果。用 stm32 定时器触发 ADC 是标准做法TIM2 每 200ms 产生一次更新事件事件通过内部 TRGO 接到 ADC 外部触发输入ADC 自动开始单次转换转换结束置 EOC 标志。这种方式既不阻塞主循环又保证采样节奏固定。4.2 定时器初始化与 ADC 触发源配置下面这段代码定义了 5Hz 采样频率也就是 200ms 一次。void TIM2_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructure.TIM_Prescaler 7200 - 1; // 72MHz/7200 10kHz TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period 2000 - 1; // 10kHz计数到2000即5Hz TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); TIM_Cmd(TIM2, ENABLE); }TIM_Prescaler 7200 - 1把 72MHz 定时器时钟分频到 10kHzTIM_Period 2000 - 1让计数器数 2000 次后溢出并产生更新事件。计算方式是触发频率 定时器时钟 / (Prescaler1) / (Period1)。如果时钟树里 APB1 定时器时钟是 36MHz预分频要改成 3600 - 1。ADC 初始化里需要把第二章节代码中的触发源从ADC_ExternalTrigConv_None改成ADC_ExternalTrigConv_T2_TRGO并且打开外部触发命令。ADC_InitStructure.ADC_ContinuousConvMode DISABLE; ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_T2_TRGO; ADC_Init(ADC1, ADC_InitStructure); ADC_ExternalTrigConvCmd(ADC1, ENABLE);注意顺序ADC_ExternalTrigConvCmd要在ADC_Cmd(ADC1, ENABLE)之前调用。网上很多例程漏掉这一行导致定时器触发信号一直被忽略ADC 始终停在手触发的状态。标准外设库有多个触发源可选表 3 列出了 F1 系列的常见组合。触发源宏触发信号来源ADC_ExternalTrigConv_T2_TRGOTIM2 更新事件ADC_ExternalTrigConv_T3_TRGOTIM3 更新事件ADC_ExternalTrigConv_T4_TRGOTIM4 更新事件ADC_ExternalTrigConv_T5_TRGOTIM5 更新事件如果 TIM2 已经被占用去做 PWM直接换成 TIM3初始化代码几乎不用改。4.3 中断读取与串口打印在中断里读取 ADC 值可以保证每次转换结果都被及时取走。开启ADC_ITConfig(ADC1, ADC_IT_EOC, ENABLE)后EOC 中断会进入ADC1_IRQHandler。volatile uint8_t adc_sample_ready 0; uint16_t latest_adc_value; void ADC1_IRQHandler(void) { if (ADC_GetITStatus(ADC1, ADC_IT_EOC) ! RESET) { latest_adc_value ADC_GetConversionValue(ADC1); ADC_ClearITPendingBit(ADC1, ADC_IT_EOC); adc_sample_ready 1; } }主循环里只需要检查adc_sample_ready然后做电压转换和距离查表。串口输出直接用重定向 printf 比较方便。在 Keil 工程里勾选 MicroLIB再补一个 fputc 函数就可以用printf(dist: %.1f cm\r\n, distance);输出结果。int fputc(int ch, FILE *f) { while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }这段代码会阻塞等待发送寄存器空闲对 5Hz 采样间隔来说完全不影响。如果后期把采样频率提到 25Hz而串口波特率还停留在 9600打印本身就会消耗几十毫秒那时就需要把打印任务放进主循环不要留在中断上下文里。5. 躲开“读数跳变”和“换传感器要重新标”的坑5.1 一组简单耐用的滑动平均直接用查表函数得到的距离会跳 ±1cm加上环境光扰动可能到 ±3cm。我习惯加一个 8 点滑动平均窗口对应 1.6 秒适合测距稳定输出。如果对快速性要求高可以把窗口缩到 4 点或者改用中值滤波滤掉尖峰。#define FILTER_SIZE 8 static float filter_buf[FILTER_SIZE]; static uint8_t filter_index 0; static float filter_sum 0.0f; float add_filter_sample(float value) { filter_sum - filter_buf[filter_index]; filter_buf[filter_index] value; filter_sum value; filter_index (filter_index 1) % FILTER_SIZE; return filter_sum / FILTER_SIZE; }这段代码用环形数组存窗口数据每次传入新值就减去旧值并加上新值最后返回平均。8 个 float 占用 32 字节在 STM32F103 上不痛不痒。要注意的是滤波后距离变化会变慢小车高速接近障碍物时可能要多测两个周期才能刹住所以更激进的方案是采用 5 点中值滤波取 5 次样本排序取中间值既能抑制脉冲又保留边缘。5.2 换传感器后怎么做现场标定每个 2Y0A21 的红外 LED 功率和装配位置都不同手册上的表只是平均值。批量换模块后我会在固定位置用卷尺量几个点记录 ADC 电压再在电脑上做一次最小二乘拟合把新的表项写回程序。下面是一个示意用的 Python 片段用两点或三点电压去拟合距离与电压的线性关系。import numpy as np distance np.array([15, 30, 50]) voltage np.array([1.85, 1.05, 0.70]) k, b np.polyfit(voltage, distance, 1) for d in [10, 20, 30, 40, 60]: v (d - b) / k print(f{d}cm - {v:.3f}V)校准环境要干净传感器正对白色漫反射纸周围 30cm 内不要有金属反光物。黑色物体对红外测距模块的反射系数低实际得到电压会比白色物体低一些如果用出厂表查距离会比真实值偏远。实际项目里最好用真正的目标材质做校准。另外2Y0A21 在 80cm 以上输出已经低于 0.5VADC 读数接近几十噪声会淹没真实距离。把最小可置信距离设为 80cm超过就上报“无目标”能避免小车在远处反复误判。做到这一步这段源程序就不仅能跑通而是达到了一线设备可用的状态。本文还有配套的精品资源点击获取