
1. 项目概述与整体方案设计思路玩嵌入式这几年我越来越觉得“传感器显示”这套组合是所有物联网终端设备的最小可行原型。你把任何物理量采回来经MCU处理后可视化出来整个链路就通了。这次用STM32驱动WaterSensor水位传感器再加一块OLED屏实时显示水位数据正好覆盖了模拟信号采集、ADC转换、数据处理、外设通信、显示驱动这一整条嵌入式开发的经典路径。做完这个小项目后续做鱼缸水位告警、智能灌溉、宿舍防漏水提醒基本就是改改阈值和显示界面的问题。项目定下来之后我先列了一下要解决的核心问题第一WaterSensor水位传感器输出的是什么信号怎么把它变成STM32能读懂的数据第二OLED屏的驱动方式有哪些I2C和SPI怎么选第三如何让采样值稳定可靠而不是在屏幕上疯狂跳变第四整个工程怎么组织让代码清晰可维护。这套思路其实就是把“知其然”变成“知其所以然”的过程对新手来说比直接抄一份代码然后烧录进去更有价值。在硬件选型上我最终定的方案是STM32F103C8T6最小系统板作为主控水位传感器用最常见的WaterSensor模块铝合金电极那种带AO和DO双输出显示部分用0.96寸I2C接口的OLED屏SSD1306控制芯片分辨率128x64。这套组合的好处是全部都是量产件成本加起来不到二十块而且网上资料极其丰富踩坑了也容易查到解决方案。更关键的是这三个器件分别涉及模拟前端、MCU处理、数字显示三个环节正好把一个完整的数据链路串起来了。也有人问我为什么不用1.3寸或者SPI接口的OLED我的回答是对于水位这种变化速率比较慢的物理量I2C接口完全够用而且只占用两个IO口对GPIO资源紧张的板子很友好。SPI虽然刷新更快适合显示动态复杂图形但在这个场景里属于杀鸡用牛刀。后面如果要做动态曲线、指针表盘那种复杂的显示效果再切换到SPI屏也不迟。2. WaterSensor水位传感器工作原理与ADC采样基础2.1 传感器结构与信号输出解析WaterSensor水位传感器很多地方也叫水滴传感器、雨滴传感器或水位检测模块。它的核心是一个裸露在外的电路板板子上用蛇形走线布置了两组相互交错的电极一组接电源正极一组接检测端。没有水的时候两组电极之间的阻抗接近无穷大有水接触到电极表面时水的导电性会让两组电极之间形成一条低阻抗通路水的覆盖面积越大、水量越多阻抗就越低。模块板载了一个电压比较器通常是LM393。模拟输出端AO直接引出电极分压网络的电压数字输出端DO则接比较器的输出板上还有个蓝色的电位器用来调节比较器的翻转阈值。这个设计让我觉得非常巧妙——它不仅提供了模拟量输出还给了一个数字开关量输出相当于同一个传感器同时给出了“连续水位值”和“是否到限”两个维度的信号。实际项目中如果只需要做“水位是否到达某高度”的判断直接接DO引脚就可以省掉一路ADC通道需要获取实时水位变化趋势就用AO引脚。从电气特性上看AO输出的电压范围大致在0到VCC之间。当传感器完全干燥时AO输出接近VCC比如3.3V供电时输出约3.2V左右随着水位升高电极间阻抗下降AO输出被拉低电压越来越接近0V。也就是说AO电压和水位是反比关系这一点在写程序换算水位百分比时一定要记清楚不然容易把逻辑写反。实测时我用手接触电极电压从3.2V迅速掉到0.4V左右响应非常灵敏。2.2 STM32的ADC采样原理与参数选择STM32F103系列内置的ADC是12位逐次逼近型转换器参考电压一般是3.3V由VDDA引脚供电决定所以ADC读数范围是0到4095对应的模拟电压就是0到3.3V。计算公式很简单电压值 ADC采样值 × 3.3 / 4095。这里有个需要注意的细节虽然理论上VDDA最高可以到3.6V但很多最小系统板的VDDA直接和VDD3.3V相连所以参考电压就是3.3V。如果你用了外部基准电压芯片那计算时就要用基准电压的实际值。采样时间这个参数很多人忽略但它直接决定了ADC采样电路的输入阻抗匹配。STM32的ADC采样过程分为采样阶段和转换阶段采样阶段是通过内部开关给采样电容充电充电时间不够的话采样值会偏低尤其是信号源阻抗比较高的情况下误差更明显。WaterSensor模块的输出阻抗不算太低因为电极分压网络的电阻通常是几十千欧级别所以我建议把采样时间设置到55.5个ADC时钟周期以上。我当时配的是ADC时钟12MHz采样周期239.5个周期也就是单次采样耗时约20微秒对这个项目来说绰绰有余换来的是非常稳定的采样结果。多通道采样时要注意规则组的转换顺序配置。虽然这个项目只需要一个通道但我习惯性的还是把通道排列在规则组里并设置好采样时间因为后续扩展多个传感器时只需要在初始化数组里加一个通道配置就行不用重新生成整个工程。另外ADC的校准功能值得用上——ADC上电后先执行一次校准可以消除内部电容阵列的失调误差官方手册明确说校准能提高转换精度实际测试下来确实能看到读数的轻微偏移被修正。3. OLED显示模块硬件协议与SSD1306驱动核心3.1 I2C通信协议与OLED屏的接口细节0.96寸OLED屏用的SSD1306控制芯片支持三种接口模式6800/8080并行接口、SPI串行接口、I2C串行接口。I2C模式是最省IO的方式总共就两根线——SCL时钟线和SDA数据线再加上电源和地。SSD1306在I2C模式下的固定设备地址是0x3C或0x3D具体由SA0引脚的电平决定大多数模块SA0默认接地地址就是0x3C但也有个别模块默认接高地址变成0x3D。在这块屏第一次上电扫描不到地址的时候先别急着怀疑接线把0x3D也试一遍大概率就能解决。I2C总线是开漏结构所以SCL和SDA线上必须有上拉电阻。大多数OLED模块的PCB板上已经集成了4.7kΩ或10kΩ的上拉电阻可以直接使用。但如果自己用杜邦线延长连接线超过20厘米建议再加一组上拉电阻否则信号沿变缓会导致通信不稳定。我之前遇到过一种怪现象屏偶尔花屏逻辑分析仪看波形上升沿特别缓后来加了一根外部上拉电阻就好了原因就是模块板载电阻阻值偏大加上线缆寄生电容把边沿拖垮了。I2C的通信速率默认模式是100kbit/s快速模式是400kbit/s。STM32的硬件I2C可以配置到400k但实际跟SSD1306通信时400k和100k在显示静态文本上几乎感觉不到差异因为一个画面的数据量也就1KB左右。真正需要高速刷屏的场合比如做动态波形才需要考虑提高速率或换SPI屏。我的建议是稳定性优先先用100k把功能跑通再逐步提高速率测试上限。3.2 SSD1306显存结构与驱动API设计理解了SSD1306的显存布局写驱动代码就不会懵了。这块芯片内部集成了一块1KB的GRAM对应128x64像素组织方式是分8页Page0到Page7每页8个像素高正好对应一列字节的8个bit宽度128列。也就是说GRAM[row][col]这种二维数组在硬件上其实是被映射成按页、按列线性排列的128x8个字节。写数据时先通过命令设置目标页地址和列地址然后连续写入数据字节硬件会自动递增列地址。驱动代码的核心就是两个函数写命令和写数据。基于这两个函数又可以封装出设置光标、清屏、显示字符、显示字符串、显示图片等一系列API。由于OLED的GRAM是掉电即失的所以每次上电初始化时都要完整执行一遍SSD1306的初始化序列关闭显示、设置时钟分频、设置复用率、设置显示偏移、开启内部升压、设置对比度、开启显示。这个序列各个厂家基本一致但不排除个别屏幕有差异如果遇到初始化后白屏或显示异常优先检查初始化序列是否与屏厂提供的参考代码一致。在字体处理上最朴素的方式是内置一个ASCII字库每个字符用一个8x16或者6x8的点阵数组表示。8x16的字体高度正好对应OLED的一页显示中文则需要16x16点阵。我平时用的是从u8g2库中裁出来的字体数组压缩率高坐标系也简单。实际写代码时把字库数组独立成一个.c文件界面逻辑放在另一层这样以后换字体或者增加字库都方便。4. 完整工程代码实现与实测记录4.1 CubeMX配置与HAL库工程搭建我用STM32CubeMX生成工程骨架选好芯片型号后主要配置这几块RCC设为外部晶振调试接口选SWDADC1的IN0通道打开采样时间设成最大档转换模式选单次转换I2C1设为标准模式100kHz使用PB6和PB7引脚USART1配置成异步串口115200-8-N-1用于打印调试信息然后还要配置一个定时器我用TIM2做1毫秒时基用它的中断做软件定时方便程序里做周期性采样和界面刷新的调度。时钟树的配置有几个容易踩坑的地方一是APB1外设时钟不要超过36MHz否则I2C和USART会工作异常二是ADC的时钟来自APB2虽然最大可以到72MHz但实际推荐限制在14MHz以下否则精度下降明显。我的做法是APB2分频到72MHz再用ADC预分频器除以6得到12MHz的ADC时钟。这个值在精度和转换速度之间是比较均衡的。工程生成后我会先编译烧录一个空程序点灯确认编译链和下载工具都正常再接外设调试这样可以把“代码问题”和“硬件问题”隔离开来。这一步虽然看起来非常基础但能够排除大量底层配置的坑特别是新装的STM32CubeMX版本生成的HAL库版本如果不匹配的话有些ADC接口函数会被重构提前确认环境能省很多排查时间。4.2 水位采样与数据滤波代码实现样本采集我实现了三个层次的函数单次采样、多次采样滤波、周期读取。单次采样用HAL_ADC_Stop之前在ADC校准后调用一次HAL_ADC_Start然后等待转换完成标志再用HAL_ADC_GetValue取出结果。这里有个关于连续转换模式的小提示如果将ADC配置为连续转换模式启动后需要周期性的读取结果否则DMA满或转换完成中断处理不及时容易造成数据覆盖。对于水位这种慢变信号我直接用单次转换模式加查询等待完全够用。滤波方面我最开始采用的是简单的滑动平均维护一个长度为10的环形缓冲区每次新采样数据进入计算平均值。这个方案可以滤除大部分高频噪声但有一个问题——当出现一个特别离谱的尖峰时比如手碰到了导线平均值会被带偏好几帧。后来我换成了“滑动平均中值去毛刺”的复合策略先去除缓冲区中的最大值和最小值再对剩余8个数据取平均。实测下来即使偶发信号跳变输出也不会出现特别突兀的突变。代码上我倾向于把滤波逻辑封装成独立函数输入原始采样值输出滤波后的结果。函数内部维护静态数组作为缓冲区既不污染全局命名空间又能保留状态。另外由于主循环里还有其他任务OLED刷新、按键扫描、串口打印我采用非阻塞方式在定时器中断里设置一个“采样标志”主循环检测到标志后再执行采样和滤波采样周期通过修改定时器的重载值来调节。实测200毫秒采样周期对这个项目正合适太密集的采样没必要反而消耗CPU。4.3 OLED显示逻辑与水位状态界面设计显示逻辑这块我没有把所有字体、图片一次性写入屏幕而是用“页面模板局部刷新”的思路。整个屏幕分为三个区域顶部显示标题和状态图标中间用大号数字显示当前水位百分比底部显示ADC原始值和状态文字。每次刷新时只调用OLED_Clear清一下相关区域的数据再重绘内容避免整屏闪烁。为了让显示更直观我专门写了一个函数把水位百分比“画”在屏幕上——用16x16点阵字符显示三围数字同时在数字旁边画一个简易的“水滴”图案满水时水滴填满水位降低时水滴逐渐变空。实现方式很简单就是动态修改填充的列数在循环里根据百分比值计算边界列的像素数据再调用绘图函数。这个实现比硬盘一张张素材图的方式要灵活得多不需要占用额外的Flash空间。我在显示代码里还加了一个开机自检界面上电后先显示“Water Level Monitor”和固件版本号然后显示ADC初始值和I2C设备扫描结果。这看起来像画蛇添足但在调试阶段帮了大忙——之前有一次屏幕死活不亮通过自检界面打印的I2C扫描地址信息我很快就确定是模块的地址不是默认的0x3C而是一块地址为0x3D的屏。定位问题的效率比拿逻辑分析仪去测高多了。4.4 主程序逻辑综合与实验数据记录主程序的核心逻辑大致是这样while (1) { if (adc_sample_flag) { adc_sample_flag 0; raw_value sensor_read_filtered(); percentage calculate_percent(raw_value); update_oled_display(raw_value, percentage); printf(ADC%d, WaterLevel%d%%\r\n, raw_value, percentage); } }calculate_percent函数里做的是将ADC原始值线性映射到0-100百分比。前面提到传感器输出是反比关系所以具体映射方式为干燥时ADC值约为3900对应约3.15V完全浸没时ADC值约为400对应约0.32V。我做了两点线性标定然后计算出每个百分比对应的ADC步进值为35。需要注意的是这个映射关系只对当前这块传感器和当前供电电压有效如果你换了传感器或者VCC不是3.3V一定要重新标定两个端点。实测数据我很完整地记录了一下条件ADC采样值电压(V)显示百分比(%)干燥39023.150手指轻触两根电极32502.6219浸入水中约1/4面积24501.9842浸入水中约1/2面积15201.2368完全浸没4000.32100可以看到整条曲线整体呈线性趋势但中间段某些区间存在小幅度非线性这是因为电机上水的张力和电极表面氧化程度会影响阻抗特性。如果需要更精确的测量建议采用多点标定加插值查表的方式但针对常规提醒类应用两点线性标定已经足够了。实际实验用水我建议用自来水或纯净水不要直接浸泡盐水或其他导电性极强的液体否则电极腐蚀速度会非常快。另外传感器上的水滴如果不及时擦干表面会积累水垢直接影响读数稳定性所以实验结束后一定要甩干并用纸巾吸干残留水分。5. 实战中的常见问题排查与优化思路5.1 采样值跳动过大与滤波参数调整现象很典型明明水位没变OLED上的数字却在哗哗地跳有时候一分钟能跳十几个百分点。这个问题多数不是电源噪声就是信号线耦合进来的干扰。先把供电问题解决掉——STM32板子用USB供电没问题但传感器如果也从同样的3.3V引脚取电而USB线的压降又比较大那传感器AO输出的基准就会随着负载电流的变化而波动直接表现就是ADC读数的漂移。我的处理方法是在3.3V和GND之间并一个100μF的电解电容和一个100nF的瓷片电容尽量靠近传感器模块的VCC引脚放置。这样虽然不能做到彻底隔离但可以明显降低高频噪声和低频纹波。硬件层面的改动做完后再配合软件滤波实测跳变范围从正负15%降到了正负2%以内已经不影响阅读了。滤波参数上我发现滑动窗口选得越大数据越平缓但响应就越慢。水位从0%突变到100%这种阶跃响应窗口太大需要好几秒才能跟上来这在有些场景下是不可接受的。针对这个问题我建议使用自适应滤波当相邻两次采样差值超过设定的阈值时认为信号发生了真实变化快速跟踪差值小于阈值时启动深度平均滤除微小抖动。代码实现并不复杂效果却很明显。5.2 OLED无显示、花屏与I2C通信异常OLED屏最常见的坑就是上电后完全没反应。按照经验排查顺序就是从电源开始再到接线、地址、初始化序列。第一步测量OLED模块的VCC和GND之间电压是否在2.8V到3.5V之间低于这个范围芯片不会正常工作。特别是杜邦线接触不良的时候屏会间歇性闪一下这种“幽灵故障”最烦人。我后来把屏幕的电源线直接焊接到主板上问题就再没出现过。花屏或者显示乱码的锅大多数要由初始化时序和复位来背。SSD1306在I2C模式下没有单独的硬件复位引脚它只靠初始化序列中的软件复位命令来复位内部状态。如果你在初始化过程中漏掉了0x01命令软件复位或者开完显示后没有设置正常的列地址扫描方向屏幕就会出现左右镜像或上下倒置的显示效果。遇到这种情况不要慌对照数据手册检查初始化命令序列特别留意0xA1和0xA0这两个设置段重映射的指令。另一个容易忽略的问题是I2C总线上同时挂了多个设备时的地址冲突和总线抢占。如果后续你想在同一个总线上再接一个温度传感器比如AHT20要注意它的I2C地址也有可能落在0x3C附近的区间一旦冲突两个设备都会瘫痪。我习惯在项目初期就规划好所有I2C设备的总线地址分配并在初始化阶段做一个扫描程序来确认每个地址都能正常应答。5.3 传感器寿命与长期稳定性注意WaterSensor这类裸露电极的传感器最大的短板是寿命——长期在潮湿环境下工作电极很容易发生电化学腐蚀导致测量特性发生漂移。如果你打算把它用在长期运行的设备上比如智能鱼缸我建议采用间歇式供电策略平时通过一个MOS管切断传感器的电源只有在需要采样的几百毫秒内才给它通电。这招既能延长传感器寿命又能降低整个系统的功耗对于电池供电的设备特别友好。标定参数也会随着传感器老化而改变。最稳妥的办法是定期用标准液位做一次校正然后更新程序中的映射系数。我在这个项目里预留了串口调试指令可以在不重编译的前提下修正两个端点的ADC值让校准变成一个运行时操作。如果后续产品化还可以在Flash中保存多组标定数据供用户选择这样做维护成本就低很多了。最后再提一个关于界面显示的建议水位百分比这个数据本身并不直观实际使用中我更看重的是“安全水位区间”这种语义化的状态。比如我给这个项目定义了三个状态——低水位告警15%、正常水位15%~85%、高水位预警85%OLED屏幕上除了数字外还会显示状态颜色和提醒文字。这种设计对用户更友好也让项目更有“完整产品”的味道。这个项目做完我对“模拟世界与数字世界之间如何互相翻译”的理解又加深了一层。WaterSensor把水这种物理量变成电压ADC把电压变成数字SSD1306把数字变成光线整条链路环环相扣。后面如果你有兴趣可以在这个基础上加ESP8266做远程水位监测或者加一个继电器控制水泵实现自动补水都会是很好的延伸方向。