
学习STM32的流体水位传感器我学STM32的契机挺狼狈的——家里阳台有个蓄水桶有次忘记关补水阀水漫出来把楼下天花板泡了。赔完钱之后我就琢磨能不能做个水位检测装置到警戒线就报警、关阀。查了一圈资料发现用STM32加上一个流体水位传感器就能搞定。这个项目看着不起眼但做下来你会发现GPIO输入、ADC采集、定时器、串口通讯、中断处理这些STM32的看家本领几乎全都能练一遍而且因为涉及真实的流体环境还要处理各种干扰和误判问题比单纯点个流水灯有价值得多。这篇文章我就把完整的实操过程拆开讲传感器怎么选、CubeMX怎么配、代码怎么写、标定怎么做、坑怎么踩过来。新手照着做能跑通老手也能看看我在精度和稳定性上的处理思路。1. 先搞明白这个流体水位传感器到底在测什么很多人拿到传感器第一反应是直接连单片机读数据结果读数乱跳就开始怀疑传感器有问题。其实问题往往出在你没搞清楚它测量的物理量到底是什么。1.1 传感器输出的是什么信号市面上叫水位传感器的东西输出信号大致分三类开关量、模拟量、数字量。开关量最简单典型代表是浮球开关。里面一个磁簧管加一个带磁铁的浮球液位到了就吸合或者断开输出就是高电平或低电平STM32用GPIO直接读就行。这种适合做上下限报警比如水满了关阀、水少了提示。模拟量传感器就比较多样了。最常见的Arduino那种铜箔电极水位传感器模块利用水的导电性水位越高覆盖的电极面积越大输出的模拟电压就越高。另外还有电容式、压力式的输出也是模拟信号需要STM32的ADC去采集。数字量输出的一般是设备内部已经集成了采样和转换直接通过I2C、UART之类的接口输出水位数据。比如超声波液位计或者一些高端的电容式液位变送器。这种精度高但价格也高新手入门不推荐先玩这个。1.2 流体的特殊性是干扰的根源这里要说一个新手最容易忽略的点水的导电性、流动性、温度变化、杂质含量全都会影响测量结果。铜箔电极型的传感器水里离子浓度稍微变一下测出来的电压就不一样水面有波动的时候读数会剧烈跳动水垢附着在电极上时间久了灵敏度直线下降。所以做流体水位检测不能像读一个固定电阻那样期待稳定的数值。你得在硬件上做好滤波软件上做好数据处理才有可能得到可靠的结果。这也是我觉得这个项目特别值得做的原因——它能逼着你学会处理真实世界里的噪声问题这是教科书上不会细讲的东西。2. 五种常见水位传感器怎么选我用这个项目接触过的传感器不下五种各有各的脾气。这里直接对比一下省得大家走弯路。2.1 各类传感器的原理与适用场景先看表格把主流方案放在一起比较类型工作原理输出信号优点缺点适用场景浮球开关浮子带动磁簧管通断开关量结构简单、便宜、可靠只能判断开关点、机械活动件易卡滞水箱上下限报警铜箔电极模块水覆盖电极面积改变电阻模拟电压成本极低、可连续测量电极易腐蚀、读数受水质影响实验室水位趋势监测电容式介电常数随水位变化模拟/数字无接触、耐腐蚀电路复杂、需要标定腐蚀性液体的液位检测压力式静压随液位高度变化模拟电流/电压精度高、不受泡沫影响需要底部安装、价格高工业储罐液位超声波式发射超声波测回波时间数字量/模拟量非接触、量程大有盲区、受泡沫影响大型水池、河道水位2.2 我最终选了什么组合我的蓄水桶场景需要两件事一是水位到高位时给出开关信号去联动报警二是能连续看到水位变化的趋势方便我在手机上调出来看。所以最终选了双传感器方案一个浮球开关做硬报警一个铜箔电极模块接ADC做连续监测。选浮球开关是因为它最皮实关键场合电气通断比什么都可靠。铜箔模块则是为了练ADC同时也是为了实时观测水面变化。这套组合加起来不到15块钱非常适合练手。唯一要提醒的是铜箔电极模块尽量不要长期泡水我后面会专门说怎么延长寿命的问题。3. 环境搭建CubeMX Keil 的完整配置硬件和软件配置是一开始最劝退人的环节。我把当时踩过的坑和最终验证可行的流程完整列出来。3.1 开发板与调试器选择我用的是最常见的STM32F103C8T6小板子也就是大家说的蓝丸。之所以推荐它首先是便宜几块钱一片就算烧坏了也不心疼其次是资料铺天盖地标准库和HAL库的例程到处都能搜到再者F103的资源对这个项目来说绰绰有余——12位ADC、多个定时器、USART全都有。调试器我用的是ST-Link V2二十几块钱的国产小棍SWD模式只要接SWDIO、SWCLK、GND三根线就能下载和在线调试。这里有个经验如果用了ST-Link还下载不进去多半是板子上的BOOT0跳线设置问题把它拉低到GND再复位一下就好了。3.2 CubeMX初始化配置的细节我用STM32CubeMX做初始化代码生成再丢进Keil里编译。CubeMX的好处是引脚分配和时钟配置可视化不用对着寄存器手册翻半天。配置过程有几个细节要特别注意时钟树Clock Configuration里外部高速时钟选8MHz晶振系统时钟拉到72MHz。新手容易跳过这步直接默认配置但默认的HSI内部时钟只有8MHz后面做延时和串口波特率都会不准。GPIO引脚分配时我给浮球开关分配了PB0配置成GPIO_Input模式Pull-up上拉电阻要打开。为什么要上拉因为浮球开关是干接点一端接GND触点闭合时PB0被拉低不闭合时如果悬空引脚电平会随机波动就是典型的浮空输入误判问题。开了内部上拉开关断开时PB0稳定在高电平只有闭合才变低逻辑就干净了。铜箔电极模块的模拟量输出接PA1配置成ADC1的通道1采样时间我选了55.5个周期。这里有个细节采样时间越长ADC的内阻影响越小采样越准确但速度会变慢。做水位检测这种慢变信号55.5周期完全够用精度优先。3.3 Keil工程里容易被忽略的设置CubeMX生成代码后用Keil打开工程我遇到过几个坑第一个是芯片型号没选对导致编译报错或者下载后芯片不跑。用CubeMX生成的时候型号就锁定了但如果你是从拷贝的工程改的一定要在Keil的Options for Target - Device里确认芯片型号。第二个是Flash Download里的编程算法没配置好下载时报No Algorithm found。解决方法是点Settings在Flash Download页面加上对应型号的Programming AlgorithmSTM32F103C8T6选64KB Flash的算法。第三个是宏定义问题。标准库工程需要在C/C的Define里加上USE_STDPERIPH_DRIVER和STM32F10X_MD否则编译会报一堆头文件找不到。HAL库工程通常不需要手加但如果你用老版本标准库这步躲不掉。4. 代码实现数字量读取与模拟量采集环境配好之后就是核心代码环节。我先把整体逻辑画个谱主循环里轮询浮球开关的电平触发报警时点亮LED并开启蜂鸣器同时对ADC通道做连续采集经过软件滤波换算成水位百分比定期通过串口打印到调试助手。4.1 浮球开关最简单的GPIO输入浮球开关的读取代码很简单但有几个细节值得说说。初始化部分我用标准库写是这样GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure);GPIO_Mode用IPU上拉输入而不是IPD的原因是浮球开关一端接GND闭合时引脚被拉低断开时上拉电阻保证高电平这个逻辑和我前面说的一致。读取电平我用一个带消抖的函数uint8_t ReadFloatSwitch(void) { uint8_t stableHigh 0; uint8_t stableLow 0; uint8_t i; uint8_t level GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0); // 简单多轮采样消抖 for(i 0; i 5; i) { if(level Bit_SET) stableHigh; else stableLow; delay_ms(10); } return (stableHigh stableLow) ? 1 : 0; }消抖的用意是浮球开关在触点临界位置时会反复通断产生机械抖动和电平抖动。不加处理的话一个简单的开盖动作可能被单片机识别成十几次跳变报警会乱叫。这里就是把采样拉长到50毫秒让电平稳定下来再做判断。4.2 ADC采集水位模块的模拟电压ADC部分我初始化了ADC1的通道1配置成软件触发、单次转换模式void ADC_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; // 必须配置成模拟输入 GPIO_Init(GPIOA, GPIO_InitStructure); ADC_InitStructure.ADC_Mode ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode DISABLE; // 单通道不需要扫描 ADC_InitStructure.ADC_ContinuousConvMode DISABLE; ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel 1; ADC_Init(ADC1, ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_55Cycles5); ADC_Cmd(ADC1, ENABLE); // ADC校准 ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); } uint16_t ADC_GetValue(void) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while(!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); return ADC_GetConversionValue(ADC1); }有个地方容易被忽略引脚必须配置成GPIO_Mode_AIN模拟输入模式。如果配成普通的输入模式会破坏模拟信号的完整性读出的ADC值会偏大或者跳动。然后是电压换算和百分比换算。F103的ADC是12位的参考电压3.3V所以// ADC值换算成实际电压 float voltage (float)adc_value * 3.3f / 4096.0f;4.3 软件滤波为什么原始读数不能直接用铜箔电极传感器在真实水里读数非常不稳定。水面稍微晃动一下电压能跳几十毫伏换算成ADC值就是几十上百个数。直接用这个数做判断水位会在报警线附近疯狂横跳。这时候必须加软件滤波。我常用的组合是中值滤波滑动平均滤波。先取5次采样做冒泡排序取中值剔除离群的异常值再把中值结果放入滑动平均窗口进一步平滑曲线#define FILTER_N 5 uint16_t MedianFilter(uint16_t *buf, uint8_t len) { uint8_t i, j; uint16_t tmp; uint16_t sorted[len]; for(i 0; i len; i) sorted[i] buf[i]; for(i 0; i len - 1; i) { for(j i 1; j len; j) { if(sorted[i] sorted[j]) { tmp sorted[i]; sorted[i] sorted[j]; sorted[j] tmp; } } } return sorted[len / 2]; } uint16_t MovingAverage(uint16_t newValue) { static uint16_t buffer[FILTER_N]; static uint8_t index 0; static uint8_t count 0; uint32_t sum 0; uint8_t i; buffer[index] newValue; if(index FILTER_N) index 0; if(count FILTER_N) count; for(i 0; i count; i) sum buffer[i]; return sum / count; }实测下来这个组合能把ADC值的峰峰值从上百压到十个以内稳定性提升非常明显。做水位判断时再配合滞回比较就是阈值加一个小的滞回带上升时超过阈值才报警下降时低过另一个值才解除避免在临界点反复触发。5. 标定与精度让数据贴近真实水位代码跑通之后你会发现一个尴尬的事实ADC读到的数值和水位的高度并不是1:1的线性关系。铜箔电极模块的电压输出和水位覆盖面积近似正比但因为电极形状、水盆大小、导电率的影响原始值不能直接当水位百分比用。必须做标定。5.1 两点标定的操作流程标定说白了就是建立ADC值到实际水位的映射关系。我的做法很简单把传感器放进水中记录当前真实水深和ADC滤波后的稳定值。至少取两个点一个最低水位比如0厘米一个最高水位比如20厘米。如果传感器线性度好两点就够了如果感觉不放心可以再加一个中间点做三点非线性插值。用线性插值公式换算任意ADC值对应的水位。假设低点ADC值是adc_low对应深度depth_low高点ADC值是adc_high对应深度depth_highfloat mapWaterLevel(uint16_t adc_value) { float depth; if(adc_value adc_low) return depth_low; if(adc_value adc_high) return depth_high; depth (float)(adc_value - adc_low) * (depth_high - depth_low) / (float)(adc_high - adc_low) depth_low; return depth; }这里有个很重要的经验标定测量要在传感器完全静止、水面平静之后再进行。如果水面还在晃读出的ADC值本身就带误差标出来的曲线就不准。我第一版就是把传感器丢水里就读数结果标出的曲线完全不对后来静置两分钟再采数据就好了。5.2 ADC精度上不去的原因排查有段时间我ADC读数的底部噪声特别大最低值在30~60之间乱跳。排查了一圈问题出在供电上。我用的USB转串口模块给板子供电那个模块的输出纹波很大直接影响到VDDA和VREF引脚。解决方法是供电改用单独的LDO稳压芯片给模拟部分再加一个10uF钽电容和0.1uF陶瓷电容去耦。改完以后底部噪声降到了±2以内。另一个容易被忽略的是参考电压。STM32F103的VREF在内部已经连到VDDA也就是说你的ADC精度上限取决于VDDA的稳定性。如果你用的板子VDDA是从线性稳压器出来的纹波大了ADC自然不准。要更极致的精度就得换带外部VREF引脚的芯片或者加一个高精度基准电压芯片。不过这个项目用不上那么高精度稳压稳了就行。5.3 温漂和水垢这些实际问题水温和水质变化也会影响测量。水的电导率随温度升高而变大同样覆盖面积下夏天测得的ADC值普遍比冬天高。我的处理办法是不把精度做得太苛刻——这个项目测量的是水位变化趋势允许±2厘米的误差。在软件里做一个温度补偿系数并不难但需要加温度传感器对这个场景来说性价比不高。水垢问题更现实。铜箔电极用久了表面会结一层碳酸钙水垢相当于给电极盖了绝缘层灵敏度下降得很快。我试过两种方案一是定期拿出来用白醋浸泡除垢二是在电极表面涂一层石墨导电涂层能显著延长寿命。如果预算允许直接换成电容式或压力式传感器从根上解决电极腐蚀问题。6. 我踩过的坑和排查思路这部分是干货中的干货都是花时间踩出来的。我按排查链路写大家照着思路走能省不少事。6.1 引脚浮空输入导致误触发现象浮球开关什么都没接程序却偶尔报高位报警。排查过程我一开始怀疑是开关坏了用万用表量通断一切正常。后来把读到的电平打出来发现PB0的电平在开机瞬间会跳一下。问题出在初始化顺序上——GPIO引脚在上电复位到初始化完成之间的这段时间处于浮空状态电平不确定。如果这期间程序逻辑读了它就可能读到0触发报警。解决一是硬件上把浮球开关一端接到VCC而不是GND另一端接PB0配合下拉电阻这样开关断开时引脚稳定接地二是软件上做上电延时主函数一开始先delay几百毫秒等所有的上拉和时钟稳定后再开始读引脚。两个都做最稳妥。6.2 水泵启停瞬间的ADC尖峰干扰现象报警逻辑测试没问题但一旦接上真实的水泵ADC读数偶尔会猛地跳到满量程然后立刻恢复。这会导致误报警。排查过程用示波器抓PA1引脚波形发现水泵启动瞬间母线上出现很大的尖峰噪声幅值直接超过3.3V。原因是水泵是感性负载启停瞬间反电动势会灌回电源回路ADC引脚被耦合了。解决硬件的做法是水泵供电和单片机供电完全隔开用继电器或者MOS管驱动水泵同时在水泵两端加续流二极管。软件的做法是做了限幅滤波即判断相邻两次ADC值的差值如果超过设定阈值比如50就丢弃本次值不更新结果uint16_t LimitFilter(uint16_t newValue, uint16_t lastValue, uint16_t maxDelta) { if(abs(newValue - lastValue) maxDelta) { return lastValue; // 丢弃突变 } return newValue; }这套硬件隔离软件限幅的组合拳下来水泵启停再也没干扰过读数。6.3 引脚复用冲突现象我把LCD接到PB10、PB11、PB12浮球开关也接到PB10结果LCD显示异常、浮球读数也乱。排查过程这个坑纯粹是自己粗心。STM32的很多引脚都有复用功能一块板子的引脚是有限的接线之前一定要打开CubeMX看引脚占用图确认不冲突再接。我当时就是偷懒随便找几个空闲引脚就接结果撞车了。解决重新规划引脚分配。我把浮球开关移到PB0把LCD的数据线安排到未被占用的其他引脚然后在CubeMX里重新生成一遍代码问题消失。6.4 浮球开关抖动导致报警反复现象水位刚好在报警阈值附近波动时浮球开关跟着水面起伏一会儿通一会儿断蜂鸣器响个不停。排查过程这个现象说明逻辑判断缺少滞回区间。我在程序里写了一个水位状态机当水位超过高位阈值H时进入报警状态只有水位降到低于低阈值LL H时才解除报警。这个H和L之间的差值就是滞回带。我设置的滞回带是2厘米。实测效果非常好水面再怎么轻微晃动报警状态都能保持稳定不会来回跳。7. 这个项目还能怎么往上扩展水位检测跑通之后整个系统的骨架就搭起来了剩下的就是往上面挂新技能。7.1 定时器超声波测距做无接触水位铜箔电极模块的痛点是接触液体时间长了会腐蚀。如果想彻底避免传感器和液体接触可以用定时器的输入捕获功能配合超声波模块测量超声波从发射到回收的飞行时间换算出到水面的距离再换算成水位。这块用到的定时器输入捕获、中断、PWM输出刚好是把STM32定时器体系玩明白的一个好契机。7.2 串口ESP8266做远程水位监测STM32的USART串口接一个ESP8266模块把水位数据通过MQTT或者HTTP POST上报到公网平台手机端就能远程看到家里的水位。这个扩展方向能顺便学会串口协议解析、AT指令、网络通信实用性直接拉满。我当时做到这步的时候家里人都说这钱修天花板修得值。7.3 加一个旋转编码器做按键校准水位传感器每次都进水标定太麻烦我还试过用旋转编码器按键进入标定模式把当前ADC值直接存进Flash下次开机自动加载。这样就不用每次重烧代码日常维护体验好很多。涉及到的Flash读写操作也是STM32一个很基础但重要的功能点。写在最后回头复盘这个项目最有价值的不是代码本身而是它逼着我完整走了一遍需求分析—选型—硬件连接—软件实现—标定—排障—扩展的闭环。如果你也在学STM32千万别只点灯找个像水位传感器这样带有真实物理环境的项目练手收获会大得多。遇到读数不准、逻辑乱跳这种问题别急着改代码先拿万用表、示波器量测硬件再回头看软件逻辑排查问题的顺序对了绝大多数坑都能少走一半弯路。