STM32+HS-S37A水位传感器+OLED显示实战:从ADC采集到标定
2026/9/9 11:31:34 网站建设 项目流程

做水位检测这个需求,我最近刚好用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通道1(PA1)采样后通过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),其中R1=10kΩ,R2=20kΩ。代入最坏情况U_SENSOR=5V,U_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(蓝/黑)GND
HS-S37ASIG(黄/白)10kΩ电阻一端,经分压后接PA1
OLEDVCC3.3V
OLEDGNDGND
OLEDSCLPB6(软件模拟时可用任意GPIO,这里用PB6便于日后切硬件I2C)
OLEDSDAPB7
USART1TX(PA9)串口模块RX
USART1RX(PA10)串口模块TX
USART1GND串口模块GND

HS-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里主要配置项如下:

  • 芯片选STM32F103C8T6,SYS里的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采集驱动:ADC+DMA+软件滤波

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[j+1]) { uint16_t t = tmp[j]; tmp[j] = tmp[j+1]; tmp[j+1] = t; } return tmp[len / 2]; }

ADC值转换为引脚电压的公式很简单:

float voltage = (float)adc_mid / 4095.0f * 3.3f;

如果你的分压比是20/(10+20),真实传感器输出电压需要再除以分压比:

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.3V;PA1误配成模拟输出;分压电路没接通用万用表测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采集链路都是通的,剩下的问题就只在气管安装和标定环节了。这个办法我每次调试都会用,比倒水测试方便太多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询