前阵子收拾宿舍把吃灰很久的STM32F103C8T6蓝丸小板翻了出来顺手做了一个鱼缸水位监测的小装置一块WaterSensor水位传感器测湿度状态一块0.96寸OLED实时显示电压和液位等级。整个项目看起来简单但串联了STM32的ADC采集、I2C通信、数据处理和显示刷新这几个最常用的模块。这篇博文就把我从传感器原理到代码实现、从实验数据到踩坑补救的完整过程写下来希望对正在做类似小项目或者刚开始接触STM32的朋友有一点实际帮助。1. 先搞明白WaterSensor输出的是什么信号1.1 传感器的真实身份与构造拆解很多新手第一次拿到WaterSensor模块时会以为它是什么精密水位检测元件。实际上这块小板在淘宝上更多被叫做雨滴传感器或者雨水传感器英文标WaterSensor或Rain Sensor。核心就是一块表面布满蛇形裸露铜线的PCB板外加一个四针接口的小电路板。为什么走线要做成蛇形两个目的一是增加两根电极之间的有效覆盖面积水膜搭在铜线上时能形成更稳定的导通路径二是蛇形走线之间间隙很小哪怕只有少量水也能把相邻电极连通。说白了它检测的不是水有多深而是电极表面被水覆盖的面积和湿润程度。这个定位一开始就要搞清楚否则后面做数据处理容易跑偏。模块上的四针接口分别是VCC、GND、DO数字输出和AO模拟输出。仔细看小电路板上面有一颗LM393比较器芯片和一个蓝色电位器。AO是传感器极板分压后的原始电压信号DO是LM393把AO和电位器设定电压比较之后输出的高低电平。电位器用来调DO翻转的灵敏度但它对AO没有任何影响这点后面标定的时候要注意。1.2 输出信号的电压特征与反向直觉WaterSensor的AO输出逻辑跟很多传感器是反着来的。常态干燥时电极之间没有水等效电阻极大AO输出几乎等于供电电压一旦有水覆盖水的导电性把电极间的电阻拉低AO电压随之下降。水位越高、覆盖面积越大AO电压越低。内部电路其实就是一个简单的分压结构VCC经过一个固定电阻接到AO输出点传感器极板一端接到这个点、另一端接地不同厂家接法略有差异但最终表现一致。干燥时极板近似开路分压点被拉到接近VCC湿润后极板电阻掉到几十千欧甚至几千欧分压点电压被拉低。我实测手头这块模块3.3V供电时干燥状态AO电压约3.26V手指按住一部分蛇形走线后降到2.8V左右整块板浸入水中时只有0.35V。所以代码里如果想要水越多显示数值越大要么用满量程减去ADC值要么在显示层做反转映射。很多人第一次写代码时没注意这个反向特性显示出来的结果跟直觉完全相反其实不是传感器坏了。1.3 常见理解误区它不是精密液位计用这个传感器之前必须先接受一个事实它做不了精确的连续液位测量。水的导电性受水质影响极大自来水的电阻率跟纯净水差几个数量级同一块传感器在不同水质下输出完全不一样。再加上覆盖面积、水膜厚度、电极表面的氧化程度都在变AO电压跟水位高度之间没有一个稳定的线性关系。它真正适合的场景是状态判断干燥、潮湿、浸水。比如鱼缸水位低于警戒线时报警、下雨检测、滴灌系统水箱缺水提醒、宿舍阳台雨水倒灌预警。想要测具体多少厘米水位老老实实去用超声波传感器或者压力传感器不要指望这块小板。2. 硬件准备与ADC采样电路设计2.1 器件清单与引脚分配这个项目用到的器件非常少大部分玩STM32的人手头都有器件数量说明STM32F103C8T6最小系统板1蓝丸小板WaterSensor水位传感器模块1带AO/DO四针接口0.96寸OLED模块1SSD1306方案I2C接口杜邦线若干母对母ST-Link V21下载调试引脚分配我直接照搬STM32F103C8T6上最常用的几组WaterSensor引脚STM32引脚VCC3.3VGNDGNDAOPA0ADC1_IN0DO不接可选GPIO输入OLED引脚STM32引脚VCC3.3VGNDGNDSCLPB6I2C1_SCLSDAPB7I2C1_SDAPA0能直接映射到ADC1的通道0不需要额外的模拟开关配置PB6/PB7是STM32F103上I2C1的默认引脚STM32CubeMX里勾选I2C1就会自动分配。这样接线不用跳飞线布局清爽排查问题也方便。2.2 供电和电平匹配3.3V还是5V这里有一个非常关键的细节WaterSensor模块的供电范围虽然标称3.3V到5V但AO输出电压在干燥时会非常接近供电电压。如果你用5V给传感器供电干燥时AO可能输出4.8V左右而STM32F103的ADC参考电压是3.3V引脚最大输入电压也是3.3V加上钳位二极管的保护范围直接接PA0轻则采样值一直是4095重则损坏引脚。最稳妥的做法是传感器VCC直接接3.3V。实测3.3V供电下AO的电压范围大概在0.35V到3.27V之间完全落在ADC量程内不需要额外的分压电阻或者电平转换电路。这也是我推荐新手直接3.3V供电的原因省掉一堆麻烦。如果非要5V供电至少在AO线上串一个10kΩ电阻再进PA0但这样会让采样源阻抗变高ADC结果可能受影响所以不如直接3.3V简单。2.3 为什么用AO而不用DO模块上明明有DO数字输出接一个GPIO就能读状态为什么还要费劲接AO做ADC采样DO经过LM393比较器后只有高低电平两种状态电位器旋到某个位置后是否触发就变成了一个非黑即白的判断。实际使用中传感器表面滴水、凝露、部分覆盖这些情况都会造成DO反复跳变。而AO保留了完整的电压信息我可以在软件里定义多个阈值把状态分成干燥、潮湿、浸水三级甚至根据电压波动做防抖处理。这对后面做报警逻辑很重要——你不希望鱼缸水位稍微波动一下OLED上的状态就疯狂闪烁。3. 从CubeMX配置到滤波采样代码3.1 STM32CubeMX里的关键勾选项工程基于STM32CubeMX生成目标芯片选STM32F103C8Tx。配置不多但每一项都要确认系统引脚SYS - Debug 选 Serial Wire保留SWD下载口。不选的话ST-Link可能连不上芯片这是很多人新建工程后遇到的第一个坑。时钟RCC - HSE 选 Crystal/Ceramic Resonator然后把Clock Configuration里系统时钟拉到72MHz。ADC的时钟频率跟APB2总线相关PCLK2默认72MHzADC预分频器选6分频就是12MHz刚好在ADC最高14MHz的规格内。ADC1配置勾选IN0 Sampling Time选55.5 Cycles或者239.5 Cycles。这个采样时间直接影响等效输入阻抗要求和采样精度。传感器模块的AO输出带一个不小的源阻抗采样时间太短会采不准我实测用55.5 Cycles够用但如果你发现数据跳动厉害可以拉到239.5 Cycles看看。连续转换模式关掉后面我用软件触发方式每次读之前Start一次逻辑更清楚。I2C1配置在Connectivity - I2C1里勾选I2CSpeed Mode选Standard Mode100kHz就行。0.96寸OLED对速度不敏感100kHz非常稳。Fast Mode的400kHz也能跑但杜邦线稍微长一点就容易出乱码不值得。3.2 ADC采样代码与滑动滤波CubeMX生成的代码里已经包含了ADC和I2C的初始化在main函数里调用MX_ADC1_Init()和MX_I2C1_Init()后就能直接用了。读取ADC值的核心代码非常简单uint32_t WaterSensor_ReadRaw(void) { HAL_ADC_Start(hadc1); if (HAL_ADC_PollForConversion(hadc1, 100) HAL_OK) { return HAL_ADC_GetValue(hadc1); } return 0; }但这个函数读出来的原始值是不能直接用的传感器输出本身有波动供电纹波、水膜微动都会让ADC值上下跳动几十甚至上百。我在实际测试中看到干燥状态时单次采样值在4060到4095之间乱跳这会导致液晶屏上的电压数字一直变看起来很不安心。解决办法是滑动滤波。我采用的策略是连续采样9次去掉最大值和最小值剩下7个值取平均uint16_t WaterSensor_ReadFiltered(void) { uint32_t buf[9]; uint32_t sum 0; uint32_t minVal 4095, maxVal 0; for (int i 0; i 9; i) { buf[i] WaterSensor_ReadRaw(); HAL_Delay(2); } for (int i 0; i 9; i) { if (buf[i] maxVal) maxVal buf[i]; if (buf[i] minVal) minVal buf[i]; sum buf[i]; } sum sum - minVal - maxVal; return (uint16_t)(sum / 7); }去掉最大最小再平均是经典的防脉冲干扰做法。如果正好采到一次受外部干扰的异常值它不会影响最终结果。每采一次间隔2ms9次一共16ms这个耗时对水位检测来说完全不是问题。电压换算的公式是float voltage raw * 3.3f / 4095.0f;STM32F103的ADC是12位满量程4095对应参考电压3.3V。注意用3.3f浮点数参与运算如果写成整数除法结果永远是0。3.3 水位等级判断与防抖有了稳定的ADC值和电压值后定义三个状态枚举typedef enum { LEVEL_DRY, LEVEL_WET, LEVEL_FLOOD } WaterLevel_t; WaterLevel_t level_current LEVEL_DRY;判断逻辑放在主循环里每500ms执行一次。关键是引入一个连续计数防抖机制状态不能因为一次采样就立即跳变要连续N次满足条件才切换。static uint8_t dry_count 0; static uint8_t wet_count 0; static uint8_t flood_count 0; if (voltage 2.8f) { dry_count; wet_count 0; flood_count 0; if (dry_count 3) level_current LEVEL_DRY; } else if (voltage 1.2f) { wet_count; dry_count 0; flood_count 0; if (wet_count 3) level_current LEVEL_WET; } else { flood_count; dry_count 0; wet_count 0; if (flood_count 3) level_current LEVEL_FLOOD; }阈值2.8V和1.2V是我根据手头这块传感器的实测数据定的不同模块之间会有一点差异但大体在这个范围。建议拿到传感器后先做一遍实测再微调。防抖逻辑保证状态切换需要连续3次也就是1.5秒确认这样水面上飘过一片叶子、或者手碰了一下传感器边缘不会导致状态乱跳。4. OLED显示模块接入与界面逻辑4.1 OLED模块参数与原理0.96寸OLED模块主控芯片是SSD1306分辨率128x64接口有I2C、SPI、并行三种我买的是四针I2C版本。买之前一定要看清是四针I2C还是七针SPI两者驱动方式完全不同。这个模块本质是一个128x64的单色点阵屏内部有一块1KB的显存GRAM每个bit对应屏幕上的一个像素点。你要显示什么内容就往对应的显存地址写0或1。它没有字符发生器所有字符都得自己用字模数据拼出来。I2C接口下SSD1306从机地址通常是0x3C或0x3D大部分模块是0x3C。后面踩坑部分我会讲怎么用代码扫描确认地址。4.2 SSD1306的核心驱动流程SSD1306的驱动可以拆成两步初始化配置和写入显示数据。初始化就是按厂家手册写一串配置指令设置显示时钟、MUX比率、显示偏移、对比度等。不需要死记用现成的初始化序列就可以。初始化完成后屏幕是黑屏状态。写数据显示时要先发送控制字节0x00表示后面跟的是命令或者0x40表示后面跟的是数据。I2C一次传输的格式是先发送从机地址加写位0x3C 1然后发送控制字节然后发送数据。我用的字符显示方案是经典的8x16 ASCII字模表每个字符占用16字节对应16行、每行一个字节。显示STM32这种字符串本质就是把每个字符的字模数据一块一块拷贝到显存对应位置。底层函数长这样void OLED_ShowChar(uint8_t x, uint8_t y, char ch) { uint8_t i; for (i 0; i 8; i) { OLED_SetPos(x i, y); OLED_WriteData(OLED_ASCII16[ch - ][i]); OLED_WriteData(OLED_ASCII16[ch - ][i 8]); } }OLED_SetPos是设置显存的列地址和页地址OLED_WriteData是I2C写入一个字节。x方向是列坐标0到127y方向是页坐标0到7每页8像素高一个8x16字符占8列、2页。4.3 完整显示界面与刷新策略我的主界面分四行显示信息组织是OLED_ShowString(0, 0, Water Monitor); OLED_ShowString(0, 2, State:); OLED_ShowString(0, 4, ADC:); OLED_ShowString(0, 6, Vol:);后面对应的动态内容要单独刷新因为电压和ADC值会变状态文字也会变。这里有一个新手容易犯的错OLED像素一旦点亮不会自动熄灭刷新新内容时如果不先清掉旧内容屏幕上就会出现残影叠字。解决方法是每次更新动态区域前先往该区域填充0x00清屏再写新内容OLED_ClearBuffer(); // 重绘全部界面 OLED_ShowString(0, 0, Water Monitor); OLED_ShowString(0, 2, State: Flood); OLED_ShowString(0, 4, ADC: 0000); OLED_ShowString(0, 6, Vol: 0.00V); OLED_Sync();我直接把整个界面重绘然后一次性同步到显存。SSD1306支持显存页写入整个界面重绘一次耗时约几毫秒完全感觉不到闪烁。OLED的数据刷新不需要像数码管那样做动态扫描只要控制好不要在主循环里每毫秒都全屏重绘性能完全够。显示ADC值和电压值的时候把整数转换成字符串再传给OLED_ShowString转换时用标准库的sprintf最省事char buf[16]; sprintf(buf, ADC: %04d, raw); OLED_ShowString(0, 4, buf); sprintf(buf, Vol: %.2fV, voltage); OLED_ShowString(0, 6, buf);这里有个坑sprintf的浮点转换在嵌入式平台上需要开启微库功能。在Keil的Options for Target - Target - Code Generation里勾选Use MicroLIB否则printf和sprintf的浮点版不会被链接进来程序会进HardFault或者显示不出来。这个坑我踩过折腾了好几个小时才反应过来。5. 实测数据与阈值标定5.1 四组状态实测记录代码写完以后我用手头这块传感器做了一组实测环境是宿舍室温供电3.3V传感器模块放在桌面上。测试结果测试状态AO电压ADC原始值覆盖情况完全干燥3.27V4060电极表面无任何水迹手指单点接触2.85V3530单个蛇形走线区域被触碰一勺水倒上去1.32V1640水膜覆盖约三分之一电极区整体浸没水中0.35V435传感器垂直插入水杯从数据可以清楚看到AO电压和ADC值跟传感器表面的湿润程度之间有明显的梯度关系。干燥到手指接触这一段的电压变化其实不大只有0.4V左右但水膜覆盖到三分之一时就直接掉到1.3V。这说明传感器对连续水膜更敏感对零星的凝露或小水珠反应没那么强。如果你用它做漏水检测建议把阈值定得高一点比如大于2.5V就认为干燥而不是贴着3.27V去卡。还有一个很明显的现象同一状态下ADC值并不是完全固定的。干燥时有几十到上百的波动浸水时相对稳定一点。这也是为什么必须做滤波和防抖单次采样直接拿去判断状态的话阈值边界上一定会出现误判。5.2 标定策略怎么定你的阈值模块与模块之间存在个体差异。上电后先不接水在干燥状态下读几次电压记录下来。然后用湿布全覆盖读一次浸水再读一次。有了这三组数据你就能把自己的阈值确定下来。一个相对通用的做法是取干燥和潮湿的中间值DRY_THRESHOLD (V_dry V_hand) / 2; WET_THRESHOLD (V_hand V_flood) / 2;意思就是干燥状态和手触状态之间取中值手触状态和浸水状态之间取中值。这样设置的好处是即使传感器老化导致整体电压漂移只要漂移方向一致阈值依然能落在相对安全的位置。我是在程序里把这些阈值定义成宏方便随时修改#define DRY_THRESHOLD 2.80f #define WET_THRESHOLD 1.20f如果你想把显示的数值做成一个0到100的湿度百分比可以用这个映射公式float percent (3.3f - voltage) / 3.3f * 100.0f;电压越低百分比越高。但这个百分比只是一个相对概念不能当成真正意义上的湿度值因为水的电导率随温度、水质变化很大。我最终显示界面还是用State文字而不是百分比因为文字状态更诚实不会给使用者造成很精确的错觉。5.3 实验过程中的观察记录调试过程中我注意到一个很有意思的现象把一滴水放在传感器角落ADC值会先快速下降然后慢慢回升一点。原因是水滴最开始铺开时覆盖面积大过一会儿水在铜线表面收缩成一个小水珠实际覆盖面积变小等效电阻升高电压就回升了一些。这个现象说明如果你做的是是否有水的报警器采样频率不需要太高等系统稳定后再判断效果更好。我在代码里把状态判断周期设成500ms这样留给水滴铺展稳定时间也兼顾了响应的实时性。6. 我踩过的几个坑和补救方案6.1 传感器长期通电的电解腐蚀问题这是WaterSensor模块最隐蔽的坑。裸露铜电极接直流电以后水中离子会发生电解反应铜电极会逐渐氧化、腐蚀。我第一次做测试的时候传感器浸在水里连续通电一个晚上第二天看到电极表面明显变暗上面还附着了一些绿色絮状物。铜电极被腐蚀后表面电阻增大同样的水位条件下AO电压会漂移阈值就不准了。补救方案有两种。第一种是软件层间歇供电把传感器VCC接到一个GPIO控制的MOS管或者直接用STM32的一个引脚通过三极管开关供电采样前先供电100ms读完后立刻断电。这样电极只在采样瞬间带电电解反应时间大幅减少。第二种是使用交流激励代替直流用两个GPIO交替输出高电平来翻转电极极性让电解反应互相抵消。不过第二种方案电路复杂一些我目前用的是间歇供电方案实测腐蚀速度明显减缓。6.2 AO输出接近供电电压时的读数漂移干燥状态下AO电压跟VCC之间的差值只有几十毫伏这时候ADC读数的波动最明显。因为传感器的分压网络在极板电阻无穷大时理论上AO应该等于VCC但实际上电路板上的漏电流、PCB表面的潮气、LM393的输入偏置电流都会让这个电压产生细微变化。所以不要试图用ADC值来精确判断干燥到极点的状态设一个稍微宽松的干燥范围比如电压大于2.8V一律显示Dry。如果你发现在干燥状态下ADC偶尔会跳到4095满量程大概率不是传感器的问题而是3.3V供电纹波导致的参考电压波动。可以在VCC和GND之间加一个100uF的电解电容和一个100nF的陶瓷电容给传感器做局部去耦实测数据会稳很多。6.3 OLED地址不是0x3C的问题SSD1306模块的I2C地址大部分是0x3C但我也见过0x3D的版本还有一些模块底板上带有地址选择电阻焊上后地址会变。如果你发现OLED一直不亮先用I2C扫描程序看看总线上到底有没有设备、地址是多少。扫描代码本质上就是向总线上每个可能的地址发送一个读请求能收到ACK的设备就存在uint8_t addr; for (addr 0x08; addr 0x78; addr) { HAL_StatusTypeDef status HAL_I2C_IsDeviceReady(hi2c1, (uint16_t)(addr 1), 1, 10); if (status HAL_OK) { printf(Found I2C device at 0x%02X\r\n, addr); } }如果屏幕始终不亮还有一种常见情况是SDA和SCL接反了。杜邦线没有颜色规范很多人插的时候没注意导致I2C时序完全错乱。这里有个小技巧I2C两根线先交换一下试试如果交换后能点亮说明之前确实接反了。6.4 从功能延伸到产品化的思路做完这个基础版本后我还做了两个小改进算是给这个项目增加实战价值。第一个是加入超时巡检。在宿舍使用场景中鱼缸水位每天只需要检查几次就够了没必要让传感器一直带电。我的方案是用STM32的定时器每30分钟唤醒一次外部GPIO给传感器供电采样完成后立即断开。这样既省电又减少电解腐蚀如果后续改成电池供电这个思路也是必须的。第二个是把结果通过串口发出来。WaterSensor检测到的状态并不只在OLED上显示我顺便接了一颗ESP8266模块用串口把水位状态发给家中小米智能网关的场景联动。实际就是一条字符串的转发但对于宿舍这种场景手机看到推送提醒比凑近看OLED上的字要方便得多。以后再扩展这个装置时可以考虑把多路传感器水位、温度、光照都挂到I2C总线上用同一套OLED显示框架统一调度。这个项目本身不算难但把ADC采集、I2C通信、滤波算法、状态机、显示刷新全串起来对刚入门STM32的朋友来说是一个非常好的练手题目。如果你也在做类似的东西遇到我上面提到的这些问题希望这些记录能帮你少走几步弯路。