☰
STM32水位检测实战:ADC采样滤波与OLED显示完整方案
2026/10/28 17:51:16 网站建设 项目流程

做嵌入式这几年,我发现水位检测是一个非常“高频”的入门项目需求:鱼缸水位报警、智能花盆自动补水、水箱液位监测、实验室漏水预警,甚至很多基于STM32的毕业设计都会拿它当核心功能。前阵子我用一块最常见的STM32F103C8T6最小系统板,配上一枚几块钱的WaterSensor水位传感器,加上一块0.96寸的SSD1306 OLED屏,半天时间就跑通了一套完整的“水位采集-处理-显示”系统。

这篇文章就把完整的原理和实验过程记录下来。硬件的选型思路、传感器的工作原理、OLED驱动的底层细节、HAL库工程配置、采样滤波和湿度映射逻辑,以及我踩过的几个坑,全部拆开讲清楚。不管你是正在做课程设计的在校生,还是刚接触STM32想搞个实用小项目的爱好者,这套方案都能直接参考复现。

1. 项目整体设计与硬件选型思路

1.1 为什么选择HAL库而不是标准库

这个项目涉及的外设其实不少:ADC采集、I2C通信、定时器中断(用于周期性采样)、还有后续可能要加的串口日志。如果用标准库,ADC的校准函数、I2C的时序配置、GPIO复用模式这些代码全都得手写。手写本身不难,但出问题的时候排查链路特别长——比如说I2C波形不对,你不知道是初始化顺序不对,还是时钟配置错了。

HAL库配合STM32CubeMX的做法就不一样。图形化界面里把引脚一勾,时钟树一配,初始化代码自动生成。我们只需要关注业务逻辑层,也就是“读取传感器数值、做滤波、映射成百分比、刷新到OLED”这一层。开发效率至少能提升一倍,而且工程结构统一,后续想移植到F4系列或者G0系列,改动量很小。

有人可能担心HAL库的执行效率。对于水位传感器这种毫秒级变化缓慢的物理量,ADC采样频率几十赫兹都绰绰有余;OLED是I2C接口,标准模式100kHz、快速模式400kHz,一帧128x64的显存全量刷新也就几毫秒。HAL库在性能上完全不是瓶颈,真正追求极致速度的场景我才会考虑寄存器操作或DMA。所以这个项目,无脑选HAL库。

1.2 核心硬件拆解与选型

项目用到的核心硬件就三样,全是嵌入式玩家手边最常见的元器件:

  • 主控:STM32F103C8T6。Cortex-M3内核,72MHz主频,64KB Flash,20KB SRAM。片内集成12位ADC、多个I2C、USART、定时器,做传感器采集和显示任务绰绰有余。这块芯片现在被各种国产替代芯片炒得很热,几块钱一片,学习成本极低。
  • 传感器:WaterSensor水位传感器模块。板子很小,蓝色PCB上有一排裸露的平行铜箔走线,本质是一个可变电阻器。板载LM393比较器电路,引出AO(模拟量输出)和DO(数字量输出)两个引脚,还有一个电位器用来调节数字输出的触发阈值。
  • 显示:0.96寸OLED屏,SSD1306驱动,I2C接口。128x64分辨率,单色。不需要背光,每个像素自发光,对比度极高,在阳光下也看得清。市面上有I2C和SPI两种版本,新手推荐选I2C版本,只占用两个IO口。

接线方面,注意一个关键点:水位传感器的AO输出引脚,接主控之前一定要确认供电电压。如果用5V给传感器模块供电,LM393的输出上拉电平也是5V,直接怼到STM32的PA0引脚上有超压风险。稳妥的做法是传感器模块也用3.3V供电,这在大多数应用场景下完全够用。

OLED模块标准接法是VCC接3.3V、GND接地、SCL接PB6(I2C1_SCL)、SDA接PB7(I2C1_SDA)。模块板载了I2C上拉电阻,不需要额外接。要是自己焊的裸屏,SCL和SDA线上需要各自接一个4.7kΩ上拉电阻到3.3V,否则通信不稳定。

2. 传感器工作原理与信号处理

2.1 水位的导电性检测原理:越深的“水”等于越短的“导线”

WaterSensor水位传感器表面那几根平行走线,本质上就是两套互相交叉的电极。把传感器竖直插入水中时,水漫过铜箔走线的面积越大,两套电极之间能够导电的“通路截面”就越大。用物理学的语言说,电极间等效电阻减小。

这里有个容易忽略的细节:水的导电能力取决于水中溶解的离子浓度。纯水电阻率极高,接近绝缘体;自来水因为有矿物质和氯离子,导电性尚可;河水湖水根据成分差异,导电性波动很大;盐水导电性最强。这意味着同一个传感器、同一个电路,放在自来水和放在纯净水里,同样的浸没深度,输出的模拟电压完全不同。所以任何调整方案,都必须用实际使用的水体进行现场校准,不能直接抄网上的系数。

传感器板上的电路是一个比较典型的分压结构:两电极之间的可变电阻R_sensor和一个固定电阻R_ref串联,AO引脚从中间节点引出电压。电极浸水越深,R_sensor越小,分压点的电压变化越明显。这个电压信号很微弱,而且输出阻抗较高,直接接入ADC可能产生明显的非线性误差,所以模块板上用LM393(或兼容比较器/运放)做了信号调理,AO输出的电压就是可以直接采集的线性化信号了。

2.2 AO模拟输出与DO数字输出的选择策略

模块上两个输出引脚的分工很明确:

  • AO(Analog Output):模拟电压输出,随水位连续变化。接主控的ADC引脚,通过模数转换得到具体数值,再经过标定映射成百分比。适合做连续液位监测、渐变数据显示。
  • DO(Digital Output):数字电平输出,只有高和低两种状态。板上电位器设定一个电压阈值,当传感器等效电阻变化导致比较器翻转时,DO从高电平跳到低电平(或者反过来)。适合做最简单的缺水报警、溢出报警。

对于本项目的“OLED实时显示水位”需求,AO是必须的,DO可以作为辅助报警信号接入一个GPIO。如果以后扩展继电器水泵控制,DO直接就能驱动逻辑电路,不用再写阈值比较代码。我建议两者都接上:AO做精确显示,DO做开关量保险。

2.3 信号抖动问题与滤波策略

实际项目中最恼人的问题不是信号太小,而是信号乱跳。原因有三个:水面波动导致电容式浸没面积瞬间变化;电极表面电解产生的微小气泡附着后改变了有效导电面积;市电或电机启停引入的共模干扰。

应对手段分两级。硬件上,传感器的两根电源线加上100nF去耦电容,尽量靠近模块放置;ADC采样引脚对地并联一个1nF-10nF的小电容,构成低通滤波器。软件上,必须做滤波处理。滤波算法要根据信号特点选,水位这类缓慢变化信号,我推荐先做中值滤波再做滑动平均。

中值滤波的思想很简单:连续采样N次,排序后取中间值,能有效剔除突发毛刺。比如N取5,连采5个值,排序取第3个。滑动平均则是维护一个长度为M的窗口,每次新采样进来,窗口整体平移,取窗口内所有数据的算术平均值,能平滑掉缓慢的波动。两者结合,先中值剔除野值,再滑动平均平滑输出,效果非常稳。

下面这段是我在项目里实际使用的滤波代码,用环形缓冲区实现的滑动平均:

#define FILTER_MEDIAN_N 5 #define FILTER_AVG_WINDOW 10 static uint16_t filter_buf[FILTER_AVG_WINDOW]; static uint8_t filter_index = 0; // 中值滤波:取N次采样的中间值 static uint16_t water_median_filter(uint16_t *sample, uint8_t len) { uint16_t tmp[FILTER_MEDIAN_N]; for (uint8_t i = 0; i < len; i++) { tmp[i] = sample[i]; } // 简单冒泡排序,N很小,无所谓效率 for (uint8_t i = 0; i < len - 1; i++) { for (uint8_t j = i + 1; j < len; j++) { if (tmp[i] > tmp[j]) { uint16_t t = tmp[i]; tmp[i] = tmp[j]; tmp[j] = t; } } } return tmp[len / 2]; } // 滑动平均:返回滤波后的ADC值 uint16_t water_sensor_read_filtered(void) { uint16_t raw_samples[FILTER_MEDIAN_N]; for (uint8_t i = 0; i < FILTER_MEDIAN_N; i++) { raw_samples[i] = water_sensor_adc_read(); } uint16_t median = water_median_filter(raw_samples, FILTER_MEDIAN_N); filter_buf[filter_index] = median; filter_index = (filter_index + 1) % FILTER_AVG_WINDOW; uint32_t sum = 0; for (uint8_t i = 0; i < FILTER_AVG_WINDOW; i++) { sum += filter_buf[i]; } return (uint16_t)(sum / FILTER_AVG_WINDOW); }

代码的流程是:先连续读5次ADC,排序取中间值,得到一个相对干净的当前采样点;再把当前采样点丢进10个深度的环形缓冲区,取平均值作为最终输出。这样一级滤波去毛刺、一级滤波平滑,最终OLED上显示的数值非常稳定,基本看不到跳动。

3. OLED驱动全流程与显示原理

3.1 SSD1306驱动的基础协议

0.96寸OLED的核心驱动芯片是SSD1306,内部集成了一块128x64的显存区域,分为8页(Page0-Page7),每页8行像素,恰好2^3=8页组成64行。显存总量128x64/8=1024字节。你在屏幕上看到的内容,本质上是MCU通过I2C把每字节的每一位写到SSD1306内部的GRAM里。

I2C总线上,SSD1306从机地址通常有两个选择:0x3C或0x3D,由模块上的SA0电阻决定。绝大多数淘宝模块默认是0x3C。通信协议分两类:写命令和写数据。控制字节0x00表示接下来是命令,0x40表示接下来是显存数据。

HAL库驱动时,底层的I2C读写函数非常简单:

void oled_write_cmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR << 1, buf, 2, 100); } void oled_write_data(uint8_t data) { uint8_t buf[2] = {0x40, data}; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR << 1, buf, 2, 100); }

注意HAL库的地址参数需要左移一位,因为7位地址0x3C在I2C协议里实际发送时占据高7位,最低位是读写标志。初次配置时经常有人在这里栽跟头,结果屏幕一直白屏。

3.2 完整的初始化流程

初始化成功的标志是屏幕正常点亮。点亮过程先写一串初始化命令序列,告诉屏幕怎么工作。截图一个可用的序列:

void oled_init(void) { HAL_Delay(100); // 等待屏幕内部上电稳定 oled_write_cmd(0xAE); // 关闭显示 oled_write_cmd(0x20); // 设置内存地址模式 oled_write_cmd(0x00); // 水平地址模式 oled_write_cmd(0x21); // 设置列地址范围 oled_write_cmd(0x00); oled_write_cmd(0x7F); oled_write_cmd(0x22); // 设置页地址范围 oled_write_cmd(0x00); oled_write_cmd(0x07); oled_write_cmd(0xB0); // 页地址起始 Page0 oled_write_cmd(0x81); // 设置对比度 oled_write_cmd(0xCF); oled_write_cmd(0xA1); // 段重映射,左右翻转 oled_write_cmd(0xA6); // 正常显示(非反色) oled_write_cmd(0xA8); // 多路复用比 oled_write_cmd(0x3F); // 1/64 duty oled_write_cmd(0xC8); // 扫描方向,上下翻转 oled_write_cmd(0xD3); // 显示偏移 oled_write_cmd(0x00); oled_write_cmd(0xD5); // 时钟分频 oled_write_cmd(0x80); oled_write_cmd(0x8D); // 电荷泵使能 oled_write_cmd(0x14); // 开启电荷泵 oled_write_cmd(0xAF); // 打开显示 }

这段初始化如果漏掉0x8D/0x14也就是电荷泵使能,屏幕就不会亮。这是控制IC内部DC-DC升压电路的关键,OLED需要更高的驱动电压,必须开启电荷泵才能正常工作。很多人刷了代码屏幕不亮,八成就是电荷泵没开。

3.3 显示刷新策略与中文字库

OLED的刷新策略会影响显示效果和CPU占用。最简单粗暴的做法是每帧全量刷新:清空GRAM,重新写入所有像素。好处是代码简单,坏处是I2C传输1024字节数据,在某些场景下可能造成明显闪烁感。

更高效的做法是局部刷新,只更新变化区域。比如水位百分比数字变了,就只清除数字所在的页和列范围,重新写入新数字。对SSD1306来说,设置列地址和页地址后,连续写数据即可更新局部区域。

写中文需要先取模。常用工具如PCtoLCD2002,设置取模方式为“阴码、逐行式、顺向、C51格式”,可以得到16x16点阵的数组。显示原理是:汉字占2页,每页写入左侧8像素和右侧8像素的两组字节。一个16x16汉字最终表现为一个const数组,例如:

const uint8_t gFont_hanshu[] = { 0x00, 0x00, 0x3F, 0xFC, 0x20, 0x04, 0x20, 0x04, 0x20, 0x04, 0x20, 0x04, 0x20, 0x04, 0x3F, 0xFC, 0x20, 0x04, 0x20, 0x04, 0x20, 0x04, 0x20, 0x04, 0x20, 0x04, 0x20, 0x04, 0x20, 0x04, 0x00, 0x00, };

底层写字体函数,核心逻辑是两层循环:外层循环控制页,从上到下每页各写两列数据;内层循环把16个字节依次写进对应页。注意取模方向必须和写入方向一致,否则汉字会左右颠倒、上下错位,这个我在“常见问题”部分会详细展开。

4. 完整工程实现:从ADC采样到OLED显示

4.1 CubeMX图形化配置要点

讲完理论,直接上手。打开STM32CubeMX,选择STM32F103C8Tx芯片后,以下是我的配置清单,照着做就能跑起来:

RCC配置:HSE选择晶振/陶瓷谐振器方式,Eable外部高速晶振。

SYS配置:Debug设为Serial Wire,也就是保留SWD调试接口。这里务心设置,否则下载一次程序后,下一次烧录可能会提示找不到目标芯片。

I2C1配置:I2C模式选择I2C,默认参数即可。标准模式100kHz够用,如果觉得刷新慢可以调到快速模式400kHz。实测对于OLED这类显示芯片,400kHz完全没问题。

ADC1配置:开启IN0通道,也就是PA0引脚。分辨率12位。Sample Time我习惯设成最大值,比如239.5 Cycles,水位传感器变化缓慢,采样时间拉长提升精度、减少抖动。

GPIO配置:PA1可作为DO输入的读取引脚,设置为输入模式;如果有蜂鸣器或者LED,再单独配置一个输出引脚。

时钟树:HSE 8MHz,PLL源选择HSE,倍频到72MHz。APB1预分频2(36MHz),APB2不分频(72MHz)。这样I2C1总线时钟36MHz,USART也能正常48MHz波特率生成。ADC时钟挂载在APB2上,72MHz经过芯片内部再分频,CubeMX会自动处理。

配置完成后点击GENERATE CODE生成工程。

4.2 百分比映射与ADC数值校准

读取ADC原始数值只是第一步,真正有意义的是把它映射成“当前水位百分比”。映射依赖两个关键参考点:

  • 干燥状态值:传感器完全离开水面时,读到的ADC值(记为DRY_VALUE)。
  • 全浸没状态值:传感器完全浸入水中(不管水位到达传感器顶端),读到的ADC值(记为WET_VALUE)。

理论映射公式:

percentage = (raw_value - DRY_VALUE) * 100 / (WET_VALUE - DRY_VALUE)

要注意几个问题。首先,两个参考点必须在实际环境中测出。不同水体的导电性差异很大,所以不要照抄网上的常数。其次,计算后需要加钳位逻辑,防止raw_value超出参考点范围导致百分比溢出。最后,传感器正常工作时不会完全干燥(比如鱼缸里始终有一段浸没),所以也可以定义“最低工作水位”和“最高水位”两个点,用二分法细化映射。

我实际测试的一组数据可以给个参考。在自来水环境中,传感器干燥时ADC读数约为860,完全浸没3cm时ADC读数约为2950,线性分布大致均匀。映射函数如下:

#define DRY_VALUE 860 #define WET_VALUE 2950 uint8_t water_sensor_get_percent(uint16_t adc_value) { if (adc_value <= DRY_VALUE) { return 0; } if (adc_value >= WET_VALUE) { return 100; } return (uint8_t)((uint32_t)(adc_value - DRY_VALUE) * 100 / (WET_VALUE - DRY_VALUE)); }

这里乘法和除法都可能产生中间值溢出,所以先转成uint32_t再运算,避免在16位整型上溢出。

4.3 主程序循环框架

整个程序的核心循环非常简单,包含一次采样、一次滤波、一次显示刷新:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_I2C1_Init(); MX_ADC1_Init(); oled_init(); oled_clear(); oled_show_string(0, 0, "Water Level:"); oled_show_chinese(0, 3, "当前水位"); while (1) { uint16_t adc_val = water_sensor_read_filtered(); uint8_t pct = water_sensor_get_percent(adc_val); char buf[8]; sprintf(buf, "%3d%%", pct); oled_show_string(72, 2, buf); // 状态提示:低于阈值显示"低",高于显示"正常" HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, (pct < 15) ? GPIO_PIN_RESET : GPIO_PIN_SET); HAL_Delay(200); } }

为了直观,我在第0行显示传感器原始ADC数值和计算后的电压值,第2行用大号数字显示水位百分比,第3行显示汉字状态。每200毫秒刷新一次,显示流畅且眼感舒适。

ADC读取函数用HAL库自带的阻塞轮询方式:

uint16_t water_sensor_adc_read(void) { HAL_ADC_Start(&hadc1); if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) { return (uint16_t)HAL_ADC_GetValue(&hadc1); } return 0; }

如果后期要追求高性能,可以把ADC切换成DMA模式。HAL_ADC_Start_DMA启动后,转换结果自动搬运到内存数组,CPU不需要参与,节省等待时间,在同时处理多个传感器和通信任务时很有用。

4.4 完整实验现象与数据记录

硬件搭建完成后,把传感器垂直插入一个透明水杯,缓慢加水,观察OLED显示的百分比变化。实测记录如下:

实际状态ADC原始读数OLED显示百分比实际浸没深度
完全干燥约8600%0cm
底部刚接触水面约135024%0.5cm
浸没1cm约180046%1cm
浸没2cm约240078%2cm
浸没3cm(完全浸没)约2950100%3cm

可以看到,ADC读数与浸没深度之间基本线性,映射成百分比后准确度可以接受。自来水中浸没3cm就达到满量程,说明这个传感器比较适合0-3cm的小范围水位检测。如果需要更大量程,要么把传感器竖放让电极更长部分被覆盖,要么换成专用的压力式液位传感器。

实际显示效果中,OLED在阳光直射下依然清晰可见,字体边缘锐利,对比度明显优于LCD屏幕。

5. 常见问题与排查技巧实录

5.1 问题速查表

任何一个嵌入式项目,遇到问题才是常态。我把这个项目里最容易踩的坑整理成一张速查表:

故障现象可能原因解决思路
OLED完全白屏无显示I2C地址配置错误(0x3C和0x3D混淆)确认模块上SA0焊盘,程序里打日志打印地址测试
OLED白屏但用逻辑分析仪能看到I2C波形电荷泵未开启确认初始化序列里包含0x8D和0x14两段命令
OLED亮度不均匀或闪烁I2C速率过高或者供电纹波太大降到100kHz,OLED电源对地并100nF+10uF电容
ADC读数一直不变引脚复用配置错误或传感器未供电检查CubeMX里PA0是否被配置成ADC模式,万用表量AO引脚电压
ADC读数漂移严重水体波动/电极极化/电磁干扰硬件加滤波电容,软件加深层滤波,参考第2.3节
汉字显示左右颠倒或上下错位字模取模方向和写入方向不一致重新取模,注意“逐行式”与“列行式”的区别
STM32程序能下载一次,第二次报找不到芯片SWD引脚被禁用或者被占用为普通IOCubeMX里Debug选Serial Wire,按住复位键下载
用Proteus仿真时OLED无反应Proteus的OLED模型时序要求严格仿真软件里降速,I2C时序初始化加长延时

5.2 排障思路详解

第一个坑,关于OLED白屏。如果屏幕一点反应都没有,先不要怀疑芯片坏了,先用万用表量I2C引脚的电压。SDA和SCL在空闲状态下应该被上拉到3.3V。如果量出来是0V,说明模块没供电或者上拉电阻虚焊。如果是2V左右且波形都是方波,说明MCU正在发起通信,问题多半在地址。

第二个坑,ADC读数不变。我遇到过PA0引脚被其他功能占用的情况。比如CubeMX里默认把PA0配成了外部中断引脚,或者有的开发板板载了按键占用PA0。重新核对CubeMX的Pinout视图,确认PA0是绿色的ADC1_IN0。

第三个坑,汉字显示颠倒。取模软件里“逐行式”取出的数据按字节分布在一行行扫描线上,适合像ALIENTEK那种写入方式;而“列行式”适合直接写页的方式。只要显示效果不对,优先改取模设置重新生成数组,不要自己手动去调整显示代码,那样只会越调越乱。

第四个坑,也是最容易忽视的——传感器探头长期泡在水里,电极会电解和氧化。特别是直流供电的分压结构,电极表面的铜箔会在电场作用下慢慢腐蚀。解决方法是降低采样频率,比如每5秒才给传感器通一次电,其他时间断电“休息”,能显著延长寿命。真要做长期浸水项目,建议选镀金电极或者不锈钢电极的传感器。

5.3 一些小技巧

程序里我建议把滤波窗口大小、干燥值、浸没值都定义成宏或者做成结构体,方便后续校准时修改。校准真正的采水样过程也很重要:先在目标水体里测干燥值(传感器悬空),再测浸没值(完全入水),记下两组数据填到宏定义里。不同水质的差异非常大,同一个传感器,自来水和纯净水测出来的浸没值能差出一倍,现场校准是必须的步骤。

调试阶段推荐使用SWO串口打印数值到上位机观察。没有板载串口转USB芯片的话,用USB转TTL模块接PA9/PA10即可。调试时一边实时打印ADC原值,一边观察OLED显示,能快速判断问题在采集链路还是显示链路。这类小技巧往往能节省大量盲调时间。

写在最后的几句经验

做完这个项目再回头看,其实STM32驱动水位传感器加OLED显示这个组合,最大的价值在于它能覆盖嵌入式开发的完整链路:传感器模拟量采集、模数转换、软件滤波、数据映射、I2C通信、OLED显示。每一步单独拆开都不难,但串起来却能遇到各种奇奇怪怪的问题。我最初做的时候也因为OLED白屏卡了半个下午,最后发现就是电荷泵没开,这种错误手册里和视频里不会专门提醒,不踩一次真的记不住。

我在实际使用中还有一个体会:这套系统特别适合作为“迷你智能家居”的原型基础。把OLED换成其他显示方式,比如手机蓝牙接收,或者给传感器加上自动补水泵的控制逻辑,整个框架都不用大改。现在不少人做鱼缸水位检测、阳台自动浇水,用的也基本都是这个组合。等基础功能稳定之后,可以试着加一个继电器模块去控制水泵,水位低于阈值自动补水,高于阈值自动停止,那就是一个完整的闭环控制系统了。这个扩展方向有兴趣的朋友可以自己试一下,遇到问题随时可以回头翻翻这篇文章里提到的调试思路。

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

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

立即咨询