STM32水位检测实战:ADC采样滤波与OLED显示完整方案

STM32水位检测实战:ADC采样滤波与OLED显示完整方案 做嵌入式这几年我发现水位检测是一个非常“高频”的入门项目需求鱼缸水位报警、智能花盆自动补水、水箱液位监测、实验室漏水预警甚至很多基于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 Flash20KB 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接PB6I2C1_SCL、SDA接PB7I2C1_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数字输出的选择策略模块上两个输出引脚的分工很明确AOAnalog Output模拟电压输出随水位连续变化。接主控的ADC引脚通过模数转换得到具体数值再经过标定映射成百分比。适合做连续液位监测、渐变数据显示。DODigital 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^38页组成64行。显存总量128x64/81024字节。你在屏幕上看到的内容本质上是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 8MHzPLL源选择HSE倍频到72MHz。APB1预分频236MHzAPB2不分频72MHz。这样I2C1总线时钟36MHzUSART也能正常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速率过高或者供电纹波太大降到100kHzOLED电源对地并100nF10uF电容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换成其他显示方式比如手机蓝牙接收或者给传感器加上自动补水泵的控制逻辑整个框架都不用大改。现在不少人做鱼缸水位检测、阳台自动浇水用的也基本都是这个组合。等基础功能稳定之后可以试着加一个继电器模块去控制水泵水位低于阈值自动补水高于阈值自动停止那就是一个完整的闭环控制系统了。这个扩展方向有兴趣的朋友可以自己试一下遇到问题随时可以回头翻翻这篇文章里提到的调试思路。