前言
本篇文章基于 STM32F103C8T6 最小系统板,搭配 HS‑S37A 接触式电阻型水位传感器以及 OLED 显示屏完成水位检测实验。 HS‑S37A 会把探头浸水程度转化为模拟电压信号,STM32 通过 ADC 采集该电压,经过多次平均滤波、三点分段校准之后,最终在 OLED 屏幕上输出 ADC 采样值、引脚电压、相对水位百分比以及水位状态。本实验偏向教学演示,适合单片机入门学习传感器 AD 采集、滤波、数据校准相关知识点。
视频讲解:STM32驱动HS‑S37A水位传感器+OLED完整实验讲解
https://www.bilibili.com/video/BV1bMtB6bESD/?share_source=copy_web&vd_source=91b967b41a17a5686913521545a8c3fb
一、实验实现整体流程
整体工作逻辑: 水位升高或者降低 → 探头浸入水中导电检测条数量改变 → HS‑S37A 模块 OUT 引脚输出模拟电压改变 → STM32 PA0(ADC1 通道 0)采集模拟量 → 16 次平均滤波得到稳定 ADC 数值 → 三点分段校准换算得到相对水位百分比 → OLED 屏幕刷新显示各项测量数据。
简单概括:HS‑S37A 把浸水深浅转换成模拟电压,STM32 把模拟电压转为数字量,经过滤波校准,输出相对水位信息到 OLED。

二、硬件接线说明
⚠️重点提醒:所有模块必须共地,否则 ADC 采样数值乱跳、数据异常。电压是电位差,GND 不连通,单片机无法正确识别 OUT 引脚电压。
| HS‑S37A VCC | 5V | 水位传感器供电 |
| HS‑S37A OUT | PA0 | 模拟信号输出,接入 ADC1 通道 0 |
| HS‑S37A GND | GND | 公共地 |
| OLED VCC | 5V | OLED 供电 |
| OLED SCL | PB8 | 软件 I2C 时钟 |
| OLED SDA | PB9 | 软件 I2C 数据 |
| OLED GND | GND | OLED 电源地 |

OUT 引脚电压保护
STM32F103C8T6 的 ADC 参考电压为 3.3V,PA0 引脚输入模拟电压最大不能超过 3.3V。 HS‑S37A 使用 5V 供电时,OUT 输出电压有可能超过 3.3V,会损坏单片机 ADC 外设。 如果无法确认模块最大输出电压,建议增加分压电路:
HS‑S37A OUT →10K 电阻 →PA0 引脚;PA0 引脚同时接 20K 电阻到 GND。 该分压电路可以把最高 5V 输入降压至约 3.33V,适配本实验采样。 注意:OLED 上面显示的 PIN 电压,是分压之后 PA0 引脚电压,不是 HS‑S37A 原始 OUT 输出电压。
如果不想增加分压电路,则单独对模块供5V的电,然后测量满量程时,模块OUT引脚的输出电压,如果该输出电压大于3.3V,那建议使用3.3V供电,如果满量程下输出电压小于3.3V供电,则可使用5V供电。
三、HS‑S37A 传感器工作原理
HS‑S37A 属于接触式电阻型模拟水位传感器。 传感器探头表面有多条交错裸露导电走线。干燥状态下走线之间几乎不导通;当探头浸入导电液体,水会在走线之间形成导电通路。
水位越高,被水浸没的导电走线就越多,等效导电能力变强,模块 OUT 引脚输出模拟电压随之变大。
✔关键点:该传感器检测的不是水压!依靠的是水的导电特性。和工业压力式液位传感器、超声波液位计原理完全不一样。
简单信号流向:水位变化 →浸水导电条数量变化 →等效导电能力变化 →OUT 模拟电压改变 →STM32 ADC 采集 →滤波校准 →OLED 显示水位。
注意:纯净水导电能力很差,尽量使用自来水做实验,纯净水会导致测量效果很差。
四、STM32 ADC 采样原理
STM32F103C8T6 内置 12 位 ADC,本次使用 PA0 对应 ADC1 通道 0。
- 输入电压范围:0V ~ 3.3V
- ADC 转换输出数字范围:0 ~ 4095
- 引脚电压计算公式:引脚电压 = ADC原始值 × 3.3V / 4095
本次实验实测参考数据:
| 一半量程 | 1076 | 约 0.867V | 约 0.874V |
| 满量程 | 1636 | 约 1.318V | 约 1.3V |
理论和实测电压存在微小偏差属于正常现象,来源包括 3.3V 电源精度、ADC 量化误差、传感器输出波动、采样时刻数据变化。 程序内部使用整数运算完成 ADC 转毫伏,避免单片机消耗资源运行浮点运算。
五、采样与平均滤波逻辑
水面晃动、电源噪声、液体接触状态改变,都会造成 ADC 采样数值不停跳动。如果直接使用单次采样值显示,OLED 上面数字会不停闪烁抖动。
本实验采用16 次采样取平均值滤波方案: 平均ADC值 = 16次ADC采样总和 ÷16
执行逻辑:
对应工程关键函数说明
| HS_S37A.c | HS_S37A_Init() | 初始化 PA0、ADC1 通道 0,执行 ADC 自校准 |
| HS_S37A.c | HS_S37A_ReadRaw() | 获取单次 ADC 原始采样值 |
| HS_S37A.c | HS_S37A_ReadAverage() | 16 次采样求平均滤波 |
| HS_S37A.c | HS_S37A_RawToMilliVolts() | ADC 数值转换为毫伏电压 |
| HS_S37A.c | HS_S37A_SetCalibration() | 设置干燥、半量程、满量程三点校准参数 |
| HS_S37A.c | HS_S37A_RawToPercent() | 三点分段插值计算相对水位百分比 |
| HS_S37A.c | HS_S37A_GetLevel() | 根据百分比划分四种水位状态 |
| main.c | Demo_ShowWaterData() | OLED 显示全部测量数据 |
六、三点分段校准原理
为什么不能只用一个满量程点做线性换算
最开始直接使用单一直线换算水位,实际测试发现误差巨大。 例如 ADC=1076,探头实际已经到一半水位,旧算法只算出 36%;ADC=1636 探头已经满水位,旧算法仅仅输出 54%。
原因:传感器 ADC 输出和实际浸水深度不是简单线性关系。水的导电率、电极走线形状、浸水面积都会改变输出特性。因此采用三点分段校准。
预设校准参数:
- 干燥点 ADC:0
- 半量程 ADC:1076
- 满量程 ADC:1636
两段映射关系:
第一段计算公式 百分比 = (当前 ADC – 干燥 ADC) ×50 ÷ (半量程 ADC – 干燥 ADC)
第二段计算公式 百分比 =50 + (当前 ADC‑半量程 ADC) ×50 ÷ (满量程 ADC‑半量程 ADC)
重新校准操作
更换传感器、更换液体、更换容器,必须重新采集三个点位 ADC 数值,调用接口更新校准参数。
//函数调用示例
HS_S37A_SetCalibration(干燥ADC,半量程ADC,满量程ADC);
//本实验默认参数示例
HS_S37A_SetCalibration(0u, 1076u, 1636u);
要求三个参数必须从小到大,最大值不超过 4095。函数返回 1 代表设置成功;返回 0 代表参数非法,保留原有校准参数。
⚠️注意:三点校准只是分段线性逼近,输出的是教学演示用相对水位百分比,不是精确水深厘米数值,不能当做高精度液位测量。
七、OLED 屏幕显示内容与水位状态判定
OLED 屏幕输出示例:
HS‑S37A WATER
ADC:1076/4095
PIN:0.867 V
LEVEL:050 %
STATE:MEDIUM
字段含义:
| ADC | 经过 16 次滤波之后 ADC 采样值,最大值 4095 |
| PIN | PA0 引脚换算得到电压 |
| LEVEL | 三点校准输出相对水位百分比 |
| STATE | 划分的水位状态 |
水位状态划分:
| 0% ~5% | DRY | 探头干燥 |
| 6% ~35% | LOW | 低水位 |
| 36% ~89% | MEDIUM | 中等水位 |
| 90% ~100% | HIGH | 高水位 / 满量程 |
八、实物演示
演示操作步骤

演示现象总结
实验结束注意事项:断电,把探头擦拭干净保持干燥。电极长期泡水通电会发生电化学腐蚀,会造成传感器读数漂移损坏。
九、源代码专区
💡这里包括头文件、c 文件、main 函数。
main.c代码
/*
* STM32F103C8T6 + HS-S37A water-level teaching demonstration
*
* HS-S37A OUT -> PA0 (ADC1 channel 0)
* OLED SCL -> PB8
* OLED SDA -> PB9
*/
#include "delay.h"
#include "sys.h"
#include "oled.h"
#include "HS_S37A.h"
#define DISPLAY_UPDATE_MS 200u
static void Demo_ShowStartup(void);
static void Demo_ShowWaterData(const HS_S37A_Data *data);
static u8 *Demo_GetLevelText(HS_S37A_Level level);
int main(void)
{
HS_S37A_Data water_data;
delay_init();
NVIC_Configuration();
OLED_Init();
OLED_ColorTurn(0u);
OLED_DisplayTurn(0u);
Demo_ShowStartup();
HS_S37A_Init();
delay_ms(500u);
while (1)
{
HS_S37A_GetData(&water_data);
Demo_ShowWaterData(&water_data);
delay_ms(DISPLAY_UPDATE_MS);
}
}
static void Demo_ShowStartup(void)
{
OLED_Clear();
OLED_ShowString(0u, 0u, (u8 *)"HS-S37A WATER", 16u);
OLED_ShowString(0u, 20u, (u8 *)"OUT: PA0 / ADC1", 12u);
OLED_ShowString(0u, 36u, (u8 *)"OLED: PB8 PB9", 12u);
OLED_ShowString(0u, 52u, (u8 *)"INITIALIZING…", 12u);
OLED_Refresh();
}
static void Demo_ShowWaterData(const HS_S37A_Data *data)
{
OLED_Clear();
OLED_ShowString(0u, 0u, (u8 *)"HS-S37A WATER", 12u);
OLED_ShowString(0u, 13u, (u8 *)"ADC:", 12u);
OLED_ShowNum(30u, 13u, data->raw, 4u, 12u);
OLED_ShowString(60u, 13u, (u8 *)"/4095", 12u);
OLED_ShowString(0u, 26u, (u8 *)"PIN:", 12u);
OLED_ShowNum(30u, 26u, data->voltage_mv / 1000u, 1u, 12u);
OLED_ShowString(36u, 26u, (u8 *)".", 12u);
OLED_ShowNum(42u, 26u, data->voltage_mv % 1000u, 3u, 12u);
OLED_ShowString(60u, 26u, (u8 *)" V", 12u);
OLED_ShowString(0u, 39u, (u8 *)"LEVEL:", 12u);
OLED_ShowNum(42u, 39u, data->percent, 3u, 12u);
OLED_ShowString(60u, 39u, (u8 *)" %", 12u);
OLED_ShowString(0u, 52u, (u8 *)"STATE:", 12u);
OLED_ShowString(42u, 52u, Demo_GetLevelText(data->level), 12u);
OLED_Refresh();
}
static u8 *Demo_GetLevelText(HS_S37A_Level level)
{
switch (level)
{
case HS_S37A_LEVEL_LOW:
return (u8 *)"LOW";
case HS_S37A_LEVEL_MEDIUM:
return (u8 *)"MEDIUM";
case HS_S37A_LEVEL_HIGH:
return (u8 *)"HIGH";
case HS_S37A_LEVEL_DRY:
default:
return (u8 *)"DRY";
}
}
HS_S37A.c代码
#include "HS_S37A.h"
#include "stm32f10x_adc.h"
#include "stm32f10x_gpio.h"
#include "stm32f10x_rcc.h"
/* 编译阶段检查默认校准点,防止校准区间颠倒或重合。 */
#if (HS_S37A_HALF_CALIBRATION <= HS_S37A_DRY_CALIBRATION)
#error "HS_S37A_HALF_CALIBRATION must be greater than HS_S37A_DRY_CALIBRATION"
#endif
#if (HS_S37A_FULL_CALIBRATION <= HS_S37A_HALF_CALIBRATION)
#error "HS_S37A_FULL_CALIBRATION must be greater than HS_S37A_HALF_CALIBRATION"
#endif
/* 当前使用的三个校准点,可由 HS_S37A_SetCalibration() 动态更新。 */
static uint16_t hs_s37a_dry_raw = HS_S37A_DRY_CALIBRATION;
static uint16_t hs_s37a_half_raw = HS_S37A_HALF_CALIBRATION;
static uint16_t hs_s37a_full_raw = HS_S37A_FULL_CALIBRATION;
/*
* 函数名称:HS_S37A_Init
* 功能说明:初始化 PA0 和 ADC1 通道 0,并执行 ADC 自校准。
*/
void HS_S37A_Init(void)
{
GPIO_InitTypeDef gpio_init;
ADC_InitTypeDef adc_init;
/* 开启 GPIOA 和 ADC1 的外设时钟。 */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA |
RCC_APB2Periph_ADC1, ENABLE);
/* PCLK2 为 72MHz,6 分频后 ADC 时钟为 12MHz,不超过 14MHz 上限。 */
RCC_ADCCLKConfig(RCC_PCLK2_Div6);
/* PA0 配置为模拟输入,关闭数字输入输出电路。 */
gpio_init.GPIO_Pin = GPIO_Pin_0;
gpio_init.GPIO_Mode = GPIO_Mode_AIN;
gpio_init.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &gpio_init);
/* ADC1 使用独立、单通道、单次转换和右对齐模式。 */
ADC_DeInit(ADC1);
adc_init.ADC_Mode = ADC_Mode_Independent;
adc_init.ADC_ScanConvMode = DISABLE;
adc_init.ADC_ContinuousConvMode = DISABLE;
adc_init.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
adc_init.ADC_DataAlign = ADC_DataAlign_Right;
adc_init.ADC_NbrOfChannel = 1u;
ADC_Init(ADC1, &adc_init);
/* 使用最长采样时间,提高高阻抗模拟信号的采样稳定性。 */
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1u,
ADC_SampleTime_239Cycles5);
/* 使能 ADC,并等待复位校准和自校准完成。 */
ADC_Cmd(ADC1, ENABLE);
ADC_ResetCalibration(ADC1);
while (ADC_GetResetCalibrationStatus(ADC1) != RESET)
{
}
ADC_StartCalibration(ADC1);
while (ADC_GetCalibrationStatus(ADC1) != RESET)
{
}
}
/*
* 函数名称:HS_S37A_SetCalibration
* 功能说明:动态设置干燥、半量程和满量程三个 ADC 校准点。
* 返回说明:设置成功返回 1;校准点无效时返回 0。
*/
uint8_t HS_S37A_SetCalibration(uint16_t dry_raw, uint16_t half_raw,
uint16_t full_raw)
{
/* 校准值必须依次增大,并且不能超过 12 位 ADC 的满量程。 */
if ((dry_raw >= half_raw) || (half_raw >= full_raw) ||
(full_raw > HS_S37A_ADC_FULL_SCALE))
{
return 0u;
}
hs_s37a_dry_raw = dry_raw;
hs_s37a_half_raw = half_raw;
hs_s37a_full_raw = full_raw;
return 1u;
}
/* 启动一次软件触发转换,等待转换完成后读取 ADC 原始值。 */
uint16_t HS_S37A_ReadRaw(void)
{
ADC_SoftwareStartConvCmd(ADC1, ENABLE);
while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET)
{
}
return ADC_GetConversionValue(ADC1);
}
/* 对多次 ADC 采样求平均,减小水面波动和电气噪声造成的跳动。 */
uint16_t HS_S37A_ReadAverage(uint8_t sample_count)
{
uint8_t i;
uint32_t sum;
/* 采样次数为 0 时自动修正为 1,避免除零。 */
if (sample_count == 0u)
{
sample_count = 1u;
}
sum = 0u;
for (i = 0u; i < sample_count; i++)
{
sum += HS_S37A_ReadRaw();
}
/* 加上除数的一半,使整数除法结果实现四舍五入。 */
return (uint16_t)((sum + (sample_count / 2u)) / sample_count);
}
/* 根据 3.3V 参考电压,将 12 位 ADC 数值换算成毫伏。 */
uint16_t HS_S37A_RawToMilliVolts(uint16_t raw)
{
if (raw > HS_S37A_ADC_FULL_SCALE)
{
raw = HS_S37A_ADC_FULL_SCALE;
}
return (uint16_t)(((uint32_t)raw * HS_S37A_ADC_REFERENCE_MV +
(HS_S37A_ADC_FULL_SCALE / 2u)) /
HS_S37A_ADC_FULL_SCALE);
}
uint8_t HS_S37A_RawToPercent(uint16_t raw)
{
uint32_t span;
uint32_t position;
uint32_t percent;
/* 校准区间以外的数值直接限制到 0% 或 100%。 */
if (raw <= hs_s37a_dry_raw)
{
return 0u;
}
if (raw >= hs_s37a_full_raw)
{
return 100u;
}
/*
* 使用两段线性插值补偿传感器的非线性响应:
* 干燥点 -> 0%,半量程点 -> 50%,满量程点 -> 100%。
*/
if (raw <= hs_s37a_half_raw)
{
/* 干燥点到半量程点对应 0%~50%。 */
span = hs_s37a_half_raw – hs_s37a_dry_raw;
position = raw – hs_s37a_dry_raw;
percent = (position * 50u + (span / 2u)) / span;
}
else
{
/* 半量程点到满量程点对应 50%~100%。 */
span = hs_s37a_full_raw – hs_s37a_half_raw;
position = raw – hs_s37a_half_raw;
percent = 50u + (position * 50u + (span / 2u)) / span;
}
return (uint8_t)percent;
}
/* 按照头文件中定义的百分比边界划分水位状态。 */
HS_S37A_Level HS_S37A_GetLevel(uint8_t percent)
{
if (percent <= HS_S37A_DRY_MAX_PERCENT)
{
return HS_S37A_LEVEL_DRY;
}
if (percent <= HS_S37A_LOW_MAX_PERCENT)
{
return HS_S37A_LEVEL_LOW;
}
if (percent <= HS_S37A_MEDIUM_MAX_PERCENT)
{
return HS_S37A_LEVEL_MEDIUM;
}
return HS_S37A_LEVEL_HIGH;
}
/* 读取并整理一次完整的水位数据,供主程序直接显示或处理。 */
void HS_S37A_GetData(HS_S37A_Data *data)
{
/* 允许调用者传入空指针,避免发生非法内存访问。 */
if (data == 0)
{
return;
}
data->raw = HS_S37A_ReadAverage(HS_S37A_AVERAGE_SAMPLES);
data->voltage_mv = HS_S37A_RawToMilliVolts(data->raw);
data->percent = HS_S37A_RawToPercent(data->raw);
data->level = HS_S37A_GetLevel(data->percent);
}
HS_S37A.h代码
#ifndef __HS_S37A_H
#define __HS_S37A_H
#include "stm32f10x.h"
/*
* HS-S37A 水位传感器驱动
*
* 硬件连接:HS-S37A OUT -> PA0(ADC1 通道 0)。
* 注意:PA0 上的模拟电压必须保持在 0~3.3V 范围内。如果传感器使用
* 5V 供电,应确认 OUT 的最高电压不超过 3.3V,否则必须增加分压电路;
* 如果所用模块支持,也可以将传感器改为 3.3V 供电。
*/
/* ADC 参考电压、12 位满量程数值以及每次测量的平均采样次数。 */
#define HS_S37A_ADC_REFERENCE_MV 3300u
#define HS_S37A_ADC_FULL_SCALE 4095u
#define HS_S37A_AVERAGE_SAMPLES 16u
/*
* 三点校准的默认 ADC 数值:
* 干燥位置 -> 0%
* 一半量程 -> 50%
* 满量程位置 -> 100%
*
* 1076 和 1636 来自当前模块的实测结果。后续可以直接修改这些宏,
* 也可以在程序运行时调用 HS_S37A_SetCalibration() 设置新的校准值。
*/
#define HS_S37A_DRY_CALIBRATION 0u
#define HS_S37A_HALF_CALIBRATION 1076u
#define HS_S37A_FULL_CALIBRATION 1636u
/* 水位状态的百分比边界:0~5% 干燥,6~35% 低水位,36~89% 中水位。 */
#define HS_S37A_DRY_MAX_PERCENT 5u
#define HS_S37A_LOW_MAX_PERCENT 35u
#define HS_S37A_MEDIUM_MAX_PERCENT 89u
/* 水位状态枚举。 */
typedef enum
{
HS_S37A_LEVEL_DRY = 0, /* 干燥 */
HS_S37A_LEVEL_LOW, /* 低水位 */
HS_S37A_LEVEL_MEDIUM, /* 中水位 */
HS_S37A_LEVEL_HIGH /* 高水位或满量程 */
} HS_S37A_Level;
/* 一次完整水位测量的数据。 */
typedef struct
{
uint16_t raw; /* 经过平均滤波的 ADC 原始值,范围为 0~4095 */
uint16_t voltage_mv; /* PA0 引脚电压,单位为毫伏 */
uint8_t percent; /* 校准后的相对水位,范围为 0~100 */
HS_S37A_Level level; /* 根据百分比得到的水位状态 */
} HS_S37A_Data;
/* 初始化 PA0、ADC1 通道 0,并完成 ADC 自校准。 */
void HS_S37A_Init(void);
/*
* 设置干燥、半量程和满量程三个 ADC 校准点。
* 设置成功返回 1;参数无效时返回 0,并保留原来的校准值。
*/
uint8_t HS_S37A_SetCalibration(uint16_t dry_raw, uint16_t half_raw,
uint16_t full_raw);
/* 启动一次 ADC 转换并返回原始采样值。 */
uint16_t HS_S37A_ReadRaw(void);
/* 连续采样 sample_count 次并返回四舍五入后的平均值。 */
uint16_t HS_S37A_ReadAverage(uint8_t sample_count);
/* 将 ADC 原始值换算成 PA0 引脚上的毫伏值。 */
uint16_t HS_S37A_RawToMilliVolts(uint16_t raw);
/* 根据三点分段校准参数,将 ADC 原始值换算成相对水位百分比。 */
uint8_t HS_S37A_RawToPercent(uint16_t raw);
/* 根据水位百分比判断当前水位状态。 */
HS_S37A_Level HS_S37A_GetLevel(uint8_t percent);
/* 完成平均采样、电压换算、百分比校准和状态判断。 */
void HS_S37A_GetData(HS_S37A_Data *data);
#endif
十、传感器影响因素和使用局限
HS‑S37A 依靠液体导电完成检测,测量结果会被很多条件干扰:
适用场景:课堂教学演示、漏水检测、雨水检测。不适合工业长期浸泡高精度液位测量。
十一、实验总结
HS‑S37A 水位传感器利用水的导电特性,将探头浸水深度转化模拟电压信号;STM32F103 单片机 ADC 外设采集模拟电压,通过 16 次平均滤波抑制噪声,使用三点分段校准修正传感器非线性,最后在 OLED 屏幕输出 ADC、电压、相对水位百分比和水位状态。
本实验非常适合学习 ADC 采集、软件滤波、传感器校准,需要注意共地、电压保护、传感器本身的使用限制。


