ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32+HS-S37A水位传感器+OLED显示实战:从ADC采集到标定

2026/9/9 11:31:42 拓冰建站 浏览量
STM32+HS-S37A水位传感器+OLED显示实战:从ADC采集到标定 做水位检测这个需求我最近刚好用STM32搭了一套完整链路HS-S37A传感器负责采集水位ADC负责把模拟电压转成数字量0.96寸OLED屏做实时显示从硬件接线到软件调试全部走了一遍。这套组合非常适合做智能鱼缸、水箱液位监控、宿舍自动加水装置之类的项目也是嵌入式入门阶段绕不开的经典外设练习。不管你是刚学STM32的学生还是想自己动手做个小装置这篇文章都可以直接当参考。HS-S37A严格来说是洗衣机里常用的气压式水位传感器成本低、耐脏、不怕水垢通过一根气管感知压力变化输出线性电压。和常见的浮球开关、电极探针相比它最大的优势是传感器本体不接触液体寿命长得多。而OLED屏选I2C接口只需要SCL和SDA两根线配上STM32F103C8T6的ADC采集就能实现一个完整的水位监测终端。下面我把整个项目的选型思路、电路设计、代码实现和调试经验都拆开讲。1. 项目整体设计与方案选型1.1 为什么选择HS-S37A而不是其他水位传感器做水位检测市面上能选的方案其实很多但每一种都有自己的脾气。我在项目一开始就列了一个对比表把常见方案的优缺点捋了一遍。方案类型输出信号优点明显的坑浮球开关开关量结构简单、便宜只能判断“到没到”拿不到连续水位浮球还可能卡死电极探针电阻/通断成本极低水会电解电极时间长了腐蚀严重需要交流激励防极化超声波测距数字/模拟非接触、精度高单价高、安装空间大水面泡沫多时会误判气压式(HS-S37A)模拟电压传感器不接触液体、抗污染、输出线性需要装气管气压传导路径不能堵HS-S37A刚好踩中了“可靠”和“便宜”两个关键点。它内部是一个压差开关结构气管连接到一个密封腔体水位上升时压迫气管内的空气压力传递到传感器内部膜片输出电压随之变化。传感器本体始终在液面之上水汽不会直接侵蚀电路这也是洗衣机厂商大量采用它的原因。这里要提醒一点HS-S37A的输出电压范围并不是固定不变的它和你给传感器的供电电压、气管长度、测量容器结构都有关系。但好在它的电压-水位关系在有效量程内接近线性我们只需要在实际安装好后做一次两点标定就能得到比较准的换算公式。这一点后面第三节会详细讲。1.2 OLED显示屏选型I2C接口比SPI更省事显示部分我选了0.96寸、SSD1306驱动芯片、I2C接口的OLED模块。市面上这种模块有I2C和SPI两种版本价格差不多但我强烈建议新手优先用I2C版本。原因很简单STM32F103C8T6引脚本来就吃紧SPI方式要占用SCK、MOSI、CS、DC、RST五六个引脚而I2C方式只需要SCL和SDA两根线。OLED本身刷新频率不高I2C的100k标准模式甚至400k快速模式都完全够用没必要上SPI去抢带宽。另外I2C总线是开漏结构多个设备可以挂在同一条总线上以后想加个温湿度传感器直接并联就行。至于硬件I2C和软件模拟I2C的选择我最终实际写代码时用的是软件模拟I2C。不是说STM32的硬件I2C不行而是软件模拟的兼容性最好、代码逻辑透明换了芯片型号也能直接用。尤其是在调试阶段遇到OLED不亮的问题软I2C可以逐条波形排查比硬件外设出问题后云里雾里地找原因要舒服得多。当然如果你对硬件I2C已经很有经验用HAL库的硬件I2C也是完全没问题的。1.3 整体系统信号流整套系统的信号链路非常清晰可以分成四个环节HS-S37A传感器输出0~5V范围内的模拟电压经过分压电路把电压压缩到STM32的ADC安全输入范围内ADC1通道1PA1采样后通过DMA搬运到内存MCU做滤波和线性换算得到水位高度最后通过I2C总线把结果送到OLED显示。同时USART1串口会输出调试数据方便在电脑上监控传感器原始读数。这里的核心点有两个一是模拟信号的电平匹配直接决定硬件是否安全二是软件层面的标定和滤波直接决定显示的数据是否可信。下面我先从硬件搭起。2. 硬件准备与电路搭建2.1 材料清单这个项目用到的元器件不多基本都是手边常备的东西。STM32F103C8T6最小系统板一块也可以用F103ZET6开发板就是体积大一些HS-S37A水位传感器一个0.96寸I2C接口OLED屏一块SSD1306驱动10kΩ电阻一只、20kΩ电阻一只做分压0.1uF陶瓷电容一个ADC引脚滤波面包板一块、杜邦线若干USB转TTL串口模块一个调试用不必须万用表一个强烈建议常备排查硬件问题全靠它选做5V继电器模块和小型潜水泵做自动加水功能我用的STM32板子是从国产最小系统板Flash 64KB跑这个项目绰绰有余。如果你用的是其他型号只要带ADC和基本的GPIO功能代码逻辑基本可以直接移植。2.2 分压电路保护STM32引脚的关键很多新手第一次接HS-S37A会踩一个坑传感器正常工作电压是5V输出信号范围可以达到0~5V而STM32的ADC引脚输入范围不能超过VDD也就是3.3V。信号超过3.3V以后ADC读数会直接饱和在4095无法区分不同水位如果电流稍大还可能损坏引脚。解决办法是加一个电阻分压器。我用的是10kΩ和20kΩ串联信号从传感器SIG线输入接到10kΩ电阻一端10kΩ另一端接20kΩ再接地中间抽头引到STM32的PA1。分压比计算U_STM32 U_SENSOR × R2 / (R1 R2)其中R110kΩR220kΩ。代入最坏情况U_SENSOR5VU_STM32 5 × 20 / 30 ≈ 3.33V略高于3.3V但非常接近ADC还能承受。如果传感器的实际最大输出只有4.5V那分压后就是3.0V更加安全。为了保险也可以把分压比再压低一点比如10k和10k最大输入就只有2.5V但会牺牲一些分辨率。我实测下来20k:10k这个组合够用不过建议你对传感器做一次空载实测确认最大输出电压再决定。此外在PA1引脚对地并联一个0.1uF陶瓷电容可以有效滤除导线耦合进来的高频噪声。注意这颗电容要尽量靠近STM32引脚放置距离远了效果会大打折扣。2.3 接线对照表接线其实不复杂但建议按下面的表格一一对照接错很容易烧传感器或者读不到数据。器件引脚接STM32/电源HS-S37AVCC(红)5V电源正极HS-S37AGND(蓝/黑)GNDHS-S37ASIG(黄/白)10kΩ电阻一端经分压后接PA1OLEDVCC3.3VOLEDGNDGNDOLEDSCLPB6软件模拟时可用任意GPIO这里用PB6便于日后切硬件I2COLEDSDAPB7USART1TX(PA9)串口模块RXUSART1RX(PA10)串口模块TXUSART1GND串口模块GNDHS-S37A的引脚颜色不是绝对统一的不同批次、不同商家可能颜色有差异接线前务必用万用表确认一下引脚定义或者查商家给的规格书。传感器接反电源可能会直接烧坏内部芯片。OLED模块分4针和7针两种。4针版本在板上已经默认接好了I2C模式直接接VCC/GND/SCL/SDA即可。7针版本需要先确定背后电阻把模式设为I2C并把BS0、BS1跳线配置成I2C所需的状态否则无论怎么写代码屏幕都不会亮。2.4 气管安装很多怪问题的源头HS-S37A是气压式传感器气管安装的好坏直接决定数据的可靠性。我有几条实际经验供参考。传感器本体最好放在液面以上气管从传感器气压嘴伸向需要检测的水体气管末端要侵入水中至少几厘米。水位升高时水压迫使管内空气压力升高传感器内部膜片感受到压力变化输出电压随之改变。如果气管在安装路径中被压扁、打折或者接口处有漏气压力就无法正确传导读数要么一直不变要么乱跳。还有一个容易被忽略的问题气管内壁冷凝水。如果气管长期处于低温潮湿环境水汽会在管内凝结成水珠堵住气压传导。解决办法是把传感器的气压嘴朝下安装气管从气压嘴弯一个向下的弧度再伸向水中这样冷凝水会顺着管壁流回容器不容易倒灌进传感器。气管的长度也不要拖太长10~30厘米以内效果最好。给气管末端加一小段硬质塑料管可以防止气管在水里漂浮摆动。3. 软件工程搭建与驱动实现3.1 CubeMX配置ADC、DMA、I2C、USART一个都不能少我开发时用的是STM32CubeMX生成初始化代码配合Keil MDK编译。这样生成的HAL库代码结构清晰外设初始化参数一目了然也方便以后换芯片重新生成。CubeMX里主要配置项如下芯片选STM32F103C8T6SYS里的Debug选Serial Wire防止下载一次程序后SWD接口被禁用。RCC里选HSE晶振板载8MHz时钟树拉到72MHz主频。ADC1开启通道IN1对应PA1引脚。分辨率选12位对齐选右对齐采样时间直接选最大的239.5周期。采样时间拉长可以减少信号源内阻带来的采样误差对高阻抗输出的传感器很友好。DMA配置ADC1请求设置为DMA模式DMA通道选择Channel 1这一项在不同CubeMX版本里可能显示为ADC1 DMA1, DMA Request数据方向Peripheral To Memory模式选Circular循环模式数据宽度和外设宽度都选Half Word半字对应16位ADC值。I2C1开启标准模式即可速度100kHz。如果你想更快400kHz也能用但OLED的响应速度并不会因此加快多少反而容易在长线上出现信号质量问题。USART1开启异步模式波特率115200。定时器TIM2开启设置1ms时基用来做显示刷新和状态机轮询。CubeMX生成的main.c里已经把这些外设初始化好了接下来要在用户代码区里添加自己的逻辑。注意每次重新生成代码用户代码区里“USER CODE BEGIN”到“USER CODE END”之间的内容会保留。3.2 OLED驱动点亮屏幕只需要一个初始化序列SSD1306驱动芯片控制方式很固定核心就是一组初始化寄存器和读写指令。我用的软I2C实现底层只需要两个函数写命令和写数据。先定义I2C地址和命令/数据控制字节#define OLED_ADDR 0x78 // 7位地址0x3C左移1位 #define OLED_CMD 0x00 // 控制字节后续是命令 #define OLED_DATA 0x40 // 控制字节后续是显存数据 void OLED_WriteCmd(uint8_t cmd) { I2C_Start(); I2C_SendByte(OLED_ADDR); I2C_WaitAck(); I2C_SendByte(OLED_CMD); I2C_WaitAck(); I2C_SendByte(cmd); I2C_WaitAck(); I2C_Stop(); } void OLED_WriteData(uint8_t data) { I2C_Start(); I2C_SendByte(OLED_ADDR); I2C_WaitAck(); I2C_SendByte(OLED_DATA); I2C_WaitAck(); I2C_SendByte(data); I2C_WaitAck(); I2C_Stop(); }初始化序列比较长核心操作是关闭显示、设置显示时钟分频、设置复用比、设置显示偏移、打开电荷泵、设置寻址模式为页寻址、复位列地址和页地址、打开显示。完整序列在SSD1306数据手册里有网上也有无数现成代码我就不整段贴了但只要按顺序调用屏幕就能点亮。点亮之后就是画点、画线、画矩形填充、显示英文字符。英文字符本质上是字模数组ASCII从0x20到0x7F每个字符对应8字节数据每字节表示字符的一列。写字符时按页坐标定位一页是8个像素高度。128x64的屏被分成8页每页128列。显示字符串的核心逻辑void OLED_ShowString(uint8_t x, uint8_t y, const char *str) { while (*str ! \0) { OLED_ShowChar(x, y, *str); x 8; // 一个ASCII字符宽度为8像素 if (x 120) { x 0; y 2; } // 自动换行 str; } }汉字显示也是同理16x16字模一个字占两行两列按行写入显存。用PCtoLCD2002这一类取模软件字模格式选“行列式”或“横向取模左高位”生成16进制数组然后分段发送即可。我界面里显示“水位”两个汉字就是用的16x16字模。3.3 HS-S37A采集驱动ADCDMA软件滤波ADC采集部分我用的是DMA循环模式这样ADC每次转换完成后数据会自动搬进内存数组CPU不用一直等待转换结果。配置好之后在main函数里调用一次启动函数#define ADC_BUF_SIZE 32 uint16_t adc_buf[ADC_BUF_SIZE]; HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, ADC_BUF_SIZE);之后adc_buf数组里会持续刷新最新的32次采样值。读取时只需要从中取数即可。这里有一个关键点DMA是循环写入意味着数组任何位置的数据都可能新旧交替读的时候最好一次性读出多个值做处理后计算不要只读一个。滤波我用了“中值均值”的两级方案。先用32个样本里做一次简单排序取中值能剔除异常毛刺再对连续7次中值做算术平均平滑传感器本身的微小波动。这比单纯平均更抗干扰代码也不复杂。uint16_t level_filter(uint16_t *buf, uint8_t len) { uint16_t tmp[ADC_BUF_SIZE]; memcpy(tmp, buf, len * sizeof(uint16_t)); // 冒泡排序取中值采样点不多性能足够 for (int i 0; i len - 1; i) for (int j 0; j len - 1 - i; j) if (tmp[j] tmp[j1]) { uint16_t t tmp[j]; tmp[j] tmp[j1]; tmp[j1] t; } return tmp[len / 2]; }ADC值转换为引脚电压的公式很简单float voltage (float)adc_mid / 4095.0f * 3.3f;如果你的分压比是20/(1020)真实传感器输出电压需要再除以分压比float sensor_v voltage / (20.0f / (10.0f 20.0f));但我实际调的时候发现直接用PA1引脚上的电压做标定更省事没必要来回还原传感器原始电压。这个思路下面会讲。3.4 水位换算两点标定和线性插值这是整个项目里最影响数据准确性的环节我觉得值得单独说细一点。HS-S37A的电压输出与水位在有限范围内近似线性但这并不意味着你把手册里的某个参数抄过来就能用。因为气管长度、容器形状、传感器个体差异都会影响斜率。所以正确的做法是装好传感器后实际做一次两点标定。标定方法很简单。水箱为空时记录一次PA1引脚上的ADC滤波值记为empty_adc然后往水箱里加水到目标高度比如10cm再记录一次adc值记为full_adc。那么水位换算公式为float water_level_cm(float adc_val) { float ratio (float)(adc_val - empty_adc) / (float)(full_adc - empty_adc); float level ratio * full_height_cm; if (level 0) level 0; if (level full_height_cm) level full_height_cm; return level; }这本质上就是两点线性插值。空水读一次满水读一次中间均匀映射。如果追求更高精度可以取多个标定点比如0cm、5cm、10cm、15cm记录每个点的ADC值存入表格然后分段线性插值。这样即使传感器在某个区间略有非线性也能修正回来。标定过程中有一个容易忽略的前提气管内的压力必须在每次测量前都处于参考状态。也就是说第一次标定empty_adc时气管已经浸入水中但水位为零系统保持静止几分钟再读数。不要在刚加水翻腾的时候记录数据否则气泡、水波会让标定值失真。4. 功能实现与UI设计4.1 数据实时显示界面OLED分辨率128x64可以做两屏显示。第一屏显示用户关心的水位数据第二屏显示调试信息通过按键切换。第一屏布局我这样设计第一行显示“当前水位”第二行用大号数字显示水位数值单位为cm第三行画一条水位条作为直观视觉反馈。大号数字可以用8x16的ASCII字模比默认6x8的字符好看很多。显示刷新的代码放在TIM2的定时中断或主循环里用毫秒计数触发每100ms刷新一次就够了。刷新太快反而会出现闪烁因为OLED写显存需要一定时间而人眼对50Hz以下的刷新能明显感知到闪烁。100ms对应10Hz看起来非常平滑。4.2 水位条和动态水波效果水位条的绘制原理是画矩形填充。先确定水位条区域的范围比如左上角(96, 10)宽度24像素高度50像素。根据当前水位百分比计算填充高度uint8_t bar_height (uint8_t)(50.0f * (level / full_height_cm)); OLED_FillRect(96, 60 - bar_height, 24, bar_height, 1); // 1表示填充填充之后再用背景色覆盖原来超出部分的残留否则水位下降时旧像素会留在屏幕上。实际上我每帧都会先清空水位条区域然后重新填充这样代码不会因为忘了局部擦除而出错。想做一个动态水波纹效果可以在水位条的上边界附近每隔一帧用不同的Y坐标作为波峰做1~2像素的上下抖动。比如水位条高度确定后给顶部两行做交替填充/清零的操作模拟水面在晃动。这个效果成本极低观感却很不错。4.3 自动加水控制逻辑选做如果你想把项目做成“自动水位控制器”加一个继电器和水泵就行。控制逻辑要注意防抖和滞后区间。比如设定目标水位为8cm但继电器不能在水位刚好到8cm时立刻断开而是在水位到8.5cm时才断开低于7.5cm时才启动。这个“滞后区间”非常关键否则水泵会在目标值附近不停开关继电器触点很快烧蚀也消耗水泵电机寿命。代码逻辑可以用一个简单的状态机if (level 7.5f) pump_on 1; else if (level 8.5f) pump_on 0; HAL_GPIO_WritePin(PUMP_GPIO, PUMP_PIN, pump_on ? GPIO_PIN_RESET : GPIO_PIN_SET);继电器模块一般用低电平触发所以逻辑上是GPIO_PIN_RESET对应导通这个接线时要看模块型号。另外继电器线圈感性负载很重一定要在模块上并联续流二极管或者使用自带光耦隔离的模块避免反向电动势干扰STM32。4.4 串口调试输出调试阶段我通过USART1把原始ADC值、引脚电压、计算水位同时发到串口终端char buf[64]; snprintf(buf, sizeof(buf), ADC:%d, VOLT:%.2fV, LEVEL:%.1fcm\r\n, adc_mid, voltage, level); HAL_UART_Transmit(huart1, (uint8_t *)buf, strlen(buf), 100);这里有一个容易踩的坑HAL_UART_Transmit里的超时参数不要给0否则在高波特率下可能偶尔发送不完整。还有snprintf的缓冲区长度一定要够长特别是在同时打多个float变量时64字节是底线。如果buf长度不够字符串会被截断数据就乱了。5. 常见问题与排查实录5.1 问题速查表这次实验前后踩了不少坑我把典型问题整理成了表格方便你对照排查。现象可能原因排查/处理方法ADC读数始终4095信号电压超出3.3VPA1误配成模拟输出分压电路没接通用万用表测PA1电压检查分压电阻重查CubeMX引脚配置ADC读数始终0传感器没上电SIG线没接上GND没共地先测传感器VCC和GND确认传感器SIG有电压输出ADC跳动超过±20个码电源纹波大导线过长采样时间太短加0.1uF电容采样时间调到最大软件加中值均值滤波OLED通电不亮I2C接线接反供电不足地址错误交换SCL/SDA用万用表查OLED VCC电压扫描I2C地址OLED花屏/乱码初始化时序不对I2C速率过高电源不稳重新上电降到100k速率确认上拉电阻到3.3V水位值一直固定不变气管堵塞/折叠传感器气压嘴漏气气管末端没浸入水中检查气管通不通气用嘴吹一下气压嘴确认传感器输出会变水位值上下漂移气管里有冷凝水液面波动大标定点取得太近排除气管积水加大滤波窗口扩大标定间距数据出现nansprintf/snprintf格式错误浮点除零检查分母是否可能为0检查buf长度5.2 三个我在调试中印象最深的教训第一个教训是OLED不亮但代码看起来没问题。排查了一晚上最后发现是OLED模块的I2C地址问题。HAL库的I2C写入函数需要8位地址SSD1306的7位地址是0x3C左移一位后应该传0x78。我把0x3C直接传进去总线上一句话都没发出去。这个问题如果你也遇到先把地址核对一遍别急着改代码。第二个教训是传感器供电和信号电平。第一次测的时候我图省事传感器用5V供电SIG线没分压直接接到了PA1。结果水位还没到一半ADC读数已经卡在4095不动了。后来用万用表一量PA1上电压已经到3.5V以上明显超限了。加装分压电路后问题立刻解决。这件事之后我养成了一个习惯任何模拟信号进MCU之前先量电压再焊线。第三个教训是气管的冷凝水。我在实验室用长气管接了1米多的软管测了几天之后数据越来越漂怀疑是传感器坏了。拆下来用嘴吹气压嘴输出能正常变化装回去又不行。最后发现是气管中间有一段存了水珠。后来把传感器气压嘴朝下安装让水管设计成低进高出这个问题就再也没出现过。6. 项目扩展方向与个人经验6.1 还能往哪些方向扩展这个项目的结构其实非常通用“传感器采集MCU处理显示/控制”是嵌入式开发的经典骨架。换一个传感器改一下采集代码这套框架就能变成温度监控器、光照记录仪、湿度控制器。把OLED换成ESP8266或ESP32模组数据通过局域网或者MQTT上报手机端就能远程查看水位。增加内部Flash存储把标定参数和运行日志掉电保存。用FreeRTOS把采集、显示、控制拆成三个独立任务加强系统的响应实时性。多路水位检测ADC1还有很多通道一个传感器占一个通道就能同时监测多个水桶。用PT100或者温度探头配合同一个OLED和串口框架做成多功能环境监测终端。6.2 我自己的一些经验和建议如果你第一次做这套实验我建议按“点亮OLED → 读取ADC裸值 → 滤波 → 标定 → 显示水位”这个顺序分阶段开发。每个阶段做完都烧录实测一次不要一口气写完所有代码再上电那样出了问题很难定位。关于标定我觉得“空水满水”两点线性法对于绝大多数场景已经够用了。不要在精度上过度纠结而迟迟不推进先把链路跑通数据有一个稳定的线性关系后面想提高精度再加多点插值比一开始就追求完美要高效得多。最后分享一个小技巧调试HS-S37A时可以用嘴巴含住传感器气管轻轻吹气观察OLED上的ADC值是否随吹气力度变化。如果吹气时ADC值有明显波动说明传感器本身、分压电路、ADC采集链路都是通的剩下的问题就只在气管安装和标定环节了。这个办法我每次调试都会用比倒水测试方便太多了。