
搞嵌入式这行很多人的第一个正经项目都是从“读一个传感器”开始的。前阵子我折腾了一套基于STM32的流体水位传感器从选型、接线到写代码、调参踩了不少坑也把整套流程梳理清楚了。这篇文章就聊聊我是怎么把这套东西从零搭起来的除了告诉你怎么接线、怎么写代码更想讲讲背后那些“为什么这么做”的逻辑。如果你正准备拿STM32做个水位检测的小项目或者想在嵌入式路上练练手这篇文章应该能让你少走不少弯路。先说清楚这东西是干嘛的。流体水位传感器字面意思就是检测液体液面高度的传感器常见于鱼缸水位提醒、水箱防溢出、工业储液罐监测这类场景。我这次做的是基于STM32F103C8T6的版本传感器用的是非接触式电容感应的方案单片机负责读取传感器输出的模拟量经过滤波和换算把水位信息通过串口打印出来同时驱动一个LED做高低水位报警。整体难度适中既能练到GPIO操作又能涉及ADC采样、定时器、串口通信这些STM32的核心外设对刚学完单片机基础的朋友来说是非常好的进阶练手项目。1. 项目核心拆解与方案选型1.1 先想清楚你要测的是“有没有水”还是“有多少水”做项目之前我习惯先问自己一个问题我到底要测什么这个听起来很基础但直接决定了传感器选型和整个方案的复杂度。市面上常见的流体水位传感器大致分三类。第一类是开关量输出的比如浮球式、电极式它们只能告诉你“水位到了某个点”输出要么是高电平要么是低电平单片机只需要读一个GPIO就能搞定。第二类是模拟量输出的比如我今天用的电容式非接触传感器它输出的电压会随液位变化单片机要用ADC去采样然后通过软件换算成实际液位。第三类是数字接口的比如超声波液位计、带I2C输出的压力传感器它们自己完成测量直接通过协议把数据发给单片机。如果你只是做个鱼缸缺水提醒用开关量的浮球开关就行了成本几块钱代码也简单。但如果你想实时了解水位变化比如做自动补水系统、远程液位监测那必须用模拟量输出的传感器。我这次选电容式非接触传感器也是因为它的传感器本体不接触液体不存在电极腐蚀问题而且可以贴在容器外壁测量安装位置非常灵活。1.2 为什么选STM32而不是51或者Arduino这个问题我经常被刚入门的朋友问到。51单片机确实便宜学起来也简单但它的ADC精度大多只有8到10位而且只有一个通道扩展性和处理能力都有限。Arduino虽然对新手友好但它的HAL库封装得太厉害你写一句analogRead()就完事了底层是怎么采样、怎么转换的你完全没概念。STM32就不同了。首先是ADC资源丰富F103系列有3个ADC每个ADC支持多达10个外部通道精度可以做到12位也就是4096级测电压分辨率的精细程度远不是51能比的。其次它的DMA、定时器、串口这些外设配合起来非常成熟你完全可以根据自己的节奏去控制采样频率、处理数据、发送结果这个过程的每一步你都能看清楚。我始终觉得学习嵌入式不能只看表面功能你得知道“结果是怎么算出来的”。STM32给了你足够大的空间去探究底层逻辑这对后期做复杂项目帮助非常大。1.3 传感器选型备忘我踩过的坑市面上能买到的电容式水位传感器模块很多我最常见到的是两类。一种是自带数字量和模拟量双输出的模块方便是方便但价格稍贵。另一种是纯模拟量输出的传感器本体需要自己在外围加信号调理电路麻烦一点但成本低适合自己搭电路学习。我第一次做的时候买的是成品模块带一个LM393比较器的那种。这种模块上面有个蓝色电位器可以调节触发电平然后通过数字量输出给单片机。听起来很完美但它有一个致命问题它只输出“有没有水”的开关信号无法告诉你水位到哪儿了。如果你只想做“水满自动关泵”这种控制用它没问题但你要监测连续水位变化它就不行了。后来我换成了纯模拟量的电容式传感器工作电压3.3V到5V输出0到3V左右的模拟电压传感器贴在水箱外壁液位上升输出电压也跟着上升。这才是我真正需要的。所以选传感器之前建议大家先把自己的需求想透不要看着模块功能多就下单。2. 核心细节解析与实操要点2.1 从传感器到STM32硬件的关键连接我这次用的是STM32F103C8T6最小系统板传感器是XKC-Y25型非接触式液位传感器模拟量版本工作电压3.3V输出模拟量范围大概是0.4V到2.0V对应没有水和满水状态。接线非常简单传感器VCC接STM32的3.3V输出引脚传感器GND接STM32的GND传感器AO模拟输出接STM32的PA1引脚这个引脚对应ADC1的通道1这里有一个特别重要的细节如果传感器支持5V供电而STM32的ADC引脚最大只能承受3.3V那你就必须确认传感器在3.3V供电时能否正常工作不能直接把5V电源接到传感器上否则传感器的模拟输出电压可能超过3.3V把单片机引脚击穿。稳妥起见可以先在实验台上用万用表测一下传感器的实际输出电压范围确认在安全区间再接STM32。还有一点需要留意传感器信号线属于模拟信号最好用短一点的杜邦线连接如果拖得太长线材本身会引入噪声导致ADC读数不稳定。我在调试时就遇到过线长超过30cm后数值狂跳的情况后来换成短跳线就稳定多了。2.2 看懂ADC的12位精度是怎么回事STM32F103的ADC精度是12位什么意思就是它会把0到3.3V的电压范围分成4096个等级2的12次方采样后得到0到4095之间的一个数字。比如传感器输出电压是1.65V那ADC读数就是1.65 / 3.3 × 4095 ≈ 2047。但这个计算里有个隐藏问题参考电压。很多最小系统板的VREF是直接接在3.3V电源上的而3.3V电源本身可能有波动。如果你用的是USB供电USB口的5V经过板载稳压芯片转成3.3V这个3.3V通常还比较稳定但如果你用电池供电或者外部电源电压纹波大了ADC的参考电压就会跟着漂测出来的数据也就不准了。这就要求你供电方案上尽量做好稳压或者在软件里做校准。另外12位精度的“4096”其实是个理论值实际使用中末位一两位经常在跳。这不是硬件坏了而是正常的噪声问题。后面我会讲怎么通过软件滤波来处理这个现象。2.3 GPIO初始化和ADC的CubeMX配置细节我用STM32CubeMX来生成初始化代码这是ST官方出的图形化配置工具能省去很多手写初始化底层的时间。有些老工程师习惯手写标准库也完全可以只是CubeMX在改引脚、调时钟的时候更直观尤其适合项目中途想换个引脚的情况。关键的配置步骤首先是RCC在System Core里把HSE设置为Crystal/Ceramic Resonator让单片机使用外部晶振时钟走PLL跑到72MHz。然后是ADC1在Analog里找到ADC1把IN1对应PA1勾选上。配置参数我推荐这样设Resolution12-bitSampling Time55.5 Cycles这个值越大采样越稳但对快速变化信号响应越慢Continuous Conversion ModeDisable我们有时候要控制采样节奏不让它一直忙转Scan Conversion ModeDisable时钟树里要注意ADC的时钟不能超过14MHz这个在CubeMX里它会自动帮你算不需要手调但你要知道有这么个限制。填完配置后Project Manager里勾选生成初始化代码生成完成后用Keil打开工程。2.4 为什么我不直接用读函数而非要配一个DMA学习STM32 ADC的时候最简单的做法是在主循环里调HAL_ADC_Start(hadc1)然后HAL_ADC_PollForConversion(hadc1, 100)最后HAL_ADC_GetValue(hadc1)拿结果。这样循环读确实能工作但有几个问题每次读ADC都要等转换完成主循环被阻塞如果你还想做定时器控制、串口收发代码就会很臃肿。我用的是ADCDMA的方式。简单说就是让ADC转换完数据后自动通过DMA直接内存访问搬运到内存的数组里CPU全程不用管等DMA攒够一批数据后再通知CPU来处理。这样主循环可以专心做其他事数据采样交给后台彼此不干扰。CubeMX里配置DMA也简单在ADC1的DMA Settings标签页Add一个DMA请求方向是PeripheralToMemory模式是Circular。只要开了Circular模式DMA就会循环不断地把ADC采样值写入数组根本不用CPU插手。然后你只需要在代码里加一行HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_buffer, 8);adc_buffer是一个全局数组长度我设了8。意思是ADC每转换完一个值DMA就存一个到数组里存满8个之后自动从头开始。这样什么时候想读水位直接从数组里取平均值就行。3. 实操过程与核心环节实现3.1 把数据搬进数组DMA_Init之后的正式代码整个项目流程是这样的系统上电后初始化时钟、ADC、DMA、串口然后进入主循环。主循环里每隔200毫秒读一次DMA缓冲区里的数据做一个简单的滤波处理换算成水位百分比通过串口打印出来同时判断水位是否超过阈值决定蜂鸣器和LED的状态。主要代码片段如下省略了CubeMX自动生成的初始化部分#define ADC_BUF_SIZE 8 uint16_t adc_buffer[ADC_BUF_SIZE]; float water_level_percent 0.0f; uint16_t water_level_raw 0; // 滤波函数去掉最大值和最小值后取平均 uint16_t get_adc_average(uint16_t *buf, uint8_t len) { uint16_t min_val 4095, max_val 0; uint32_t sum 0; for (uint8_t i 0; i len; i) { if (buf[i] min_val) min_val buf[i]; if (buf[i] max_val) max_val buf[i]; sum buf[i]; } sum - min_val; sum - max_val; return (uint16_t)(sum / (len - 2)); }这里我特意做了个“去极值平均”的滤波操作。为什么不用简单的算术平均因为ADC采样数据经常会有突发的干扰尖峰比如电机启停瞬间、手机靠近产生的电磁干扰这些尖峰值可能明显偏大或偏小如果直接平均会把结果拉偏。去极值平均把最大的和最小的丢掉剩下的取平均能有效抑制这类脉冲干扰。对于水位这种变化缓慢的物理量这个滤波方法简单且有效。主循环里的逻辑while (1) { water_level_raw get_adc_average(adc_buffer, ADC_BUF_SIZE); // 将ADC原始值映射为0~100%水位 // 传感器空水时ADC约520满水时ADC约2480两个点做线性映射 if (water_level_raw 520) water_level_raw 520; if (water_level_raw 2480) water_level_raw 2480; water_level_percent (float)(water_level_raw - 520) / (2480 - 520) * 100.0f; printf(ADC:%u 水位:%.1f%%\r\n, water_level_raw, water_level_percent); if (water_level_percent 15.0f) { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); // 低水位亮灯 } else if (water_level_percent 90.0f) { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); // 高水位亮灯 } else { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); } HAL_Delay(200); }3.2 关键参数的计算ADC读数怎么换算成水位上面的代码里我提到了两个关键数字520和2480。这两个数是怎么来的不是拍脑袋定的是我实测标定出来的。做法是先把传感器贴到一个空容器上记录此时的ADC读数稳定后我得到约520然后往容器里加水到传感器感应区域的顶部再记录ADC读数得到约2480。这两个点就是传感器输出特性的低端和高端用它们做线性插值就能把ADC原始读数换算成水位百分比。公式就是最基本的线性映射 水位百分比 (当前读数 - 空水读数) / (满水读数 - 空水读数) × 100%用实际数字代入如果当前ADC读数是1500那水位就是(1500-520)/(2480-520)×100% ≈ 49.2%接近一半水位。需要注意的是这个线性关系不是绝对的。电容式传感器的输出在满水位附近会有些非线性但对于大多数学习项目和家庭场景这个精度完全够用了。如果你做的是工业级液位监测那要换更高精度的传感器或者用多点标定来拟合曲线这就超出我们今天讨论的范围了。3.3 从ADC值到物理量的完整信号链梳理我画一下我脑子里整个信号流的逻辑链条方便大家理解每个环节在干什么传感器把液位变化转换成电压变化 → STM32的ADC引脚采集这个电压 → ADC按12位精度把电压量化成数字值 → DMA把数字值搬运到内存数组 → 软件取出数组做去极值平均 → 将平均值按标定点线性映射成水位百分比 → 串口打印给用户看同时驱动报警逻辑这一步一步走下来你会发现每个环节之间是环环相扣的。ADC读数的稳定性决定了之后所有计算的准确性滤波算法的选择影响了最终的平滑度标定点的精度直接决定了水位百分比准不准。任何一个环节出问题最后的结果都会出问题。这也是我推荐大家不要把ADC当成一个“黑盒子”的原因——你以为你在调一个传感器其实你是在调一整条信号链。3.4 再给串口配个“嘴巴”重定向printf到USART要看到水位数据得通过串口把数据发出来。STM32CubeMX默认生成的代码里HAL库提供了HAL_UART_Transmit这样的底层发送函数但每次发字符串都要写一堆参数很麻烦。我习惯把标准库的printf重定向到串口这样就能直接用printf打印格式化数据了。Keil环境下在usart.c或者main.c里加这么几行#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }然后勾选Keil的MicroLIB选项这样printf就能通过串口1输出到电脑的串口助手上了。注意别漏了Include头文件不然编译会报错的。3.5 打开串口前的准备波特率必须两边对齐串口通信里最容易出问题的地方就是波特率不匹配。单片机端我在CubeMX里把USART1的Baud Rate设成了115200电脑串口助手里也必须选115200数据位8位、停止位1位、无校验两边不一致收到的就是乱码。这里有个小知识115200的波特率对STM32F103来说时钟分频能算出很接近的整数分频系数误差非常小。如果你选一个特殊的波特率比如9600的奇数倍算出来的实际波特率和理想值会有偏差短时间内传输看不出问题传大量数据时可能就出错。所以一般我习惯用115200这个常规值。4. 进阶优化数据平滑与稳定性提升4.1 从“能读”到“读得准”一次采样和多次采样的差距刚把代码跑通的时候串口打印出来的数据让我很崩溃。水位明明没动数值却在520到580之间来回跳幅度超过了10%。这些跳变就是之前提到的噪声而它对水位测量结果的影响是“百分比对应不上”本来30%水位显示出来可能在28%和33%之间乱跳。去极值平均已经处理了一部分但8个点的窗口还是太短。后期我把窗口改成了16个点效果好了很多但还是不够顺滑。于是我又加了一层“滑动平均”算法也就是维护一个最近N次计算结果的队列每次取平均。这意味着一次突发的数据跳变只会让最终结果挪动N分之一曲线变得非常平滑。#define SMOOTH_SIZE 10 float smooth_buffer[SMOOTH_SIZE]; uint8_t smooth_index 0; float smooth_add(float new_val) { smooth_buffer[smooth_index] new_val; smooth_index (smooth_index 1) % SMOOTH_SIZE; float sum 0.0f; for (uint8_t i 0; i SMOOTH_SIZE; i) { sum smooth_buffer[i]; } return sum / SMOOTH_SIZE; }这个方法实现很简单但延迟会稍微大一点。N越大越平滑响应越慢。水位变化本来就很慢所以10次滑动平均对于这个场景非常合适最终串口输出的数值稳定到小数点后一位都不怎么跳。4.2 温度和介质对传感器的影响与对策电容式传感器是通过感应容器内外介质的介电常数差异来工作的。水的介电常数大约是80空气是1差异巨大所以传感器能清楚区分“有水”和“没水”的状态。但不同液体介电常数差别很大同样高度的酒精和自来水传感器输出的是不同电压。如果你测的不是水标定就得重新做。温度也会影响测量结果水在温度变化时介电常数会跟着变化虽然幅度不大但如果你要精确测量需要考虑把温度传感器也加进来做补偿。对于我的项目温度影响在误差范围内还不是大问题。但如果你做的是热水箱、太阳能系统那类温度跨度很大的水位监测这一点必须重视。还有一点传感器的感应区域是局部性的。XKC-Y25这类传感器的感应范围大概是一个直径25mm左右的圆形区域它检测的是这个区域附近是否有液体不代表整个容器各高度都有水。如果你想测连续液位需要把传感器阵列排布或者加装导波结构这也是从“点检测”走向“连续测量”时容易踩的坑。4.3 电源干扰是最大的隐形杀手前面提到过一次电源的干扰这里我再单独拿一节来说因为它太容易被人忽略了。我用USB供电时数据相对稳定。后来为了脱离电脑运行我换了一个12V的开关电源通过降压模块给STM32供电结果串口数据就开始疯狂跳动范围能从500跳到1500。排查了很久最后用示波器一看降压模块的输出纹波非常大达到了200mV以上。这个纹波直接叠到了ADC参考电压上导致采样数据剧烈抖动。后来我换了一个低纹波的LDO稳压器并且在STM32的供电引脚附近加了10uF电解电容和100nF陶瓷电容做去耦数据立刻稳了。所以我的建议是做高精度ADC采集时不要忽视电源质量。尽量用线性稳压器不要用开关电源直接给单片机和传感器供电在主控板的VDD和GND之间加上去耦电容这个在芯片数据手册里通常有明确说明。5. 常见问题与排查技巧实录5.1 数据显示正常但读数一直偏高或偏低怎么办这个问题九成是标定没做好。很多人拿到传感器直接套用别人代码里的标定点但每个传感器的个体差异和安装位置不同导致输出电压范围不一样。正确做法是把传感器固定到实际安装位置然后分别记录空水和满水时的ADC读数用这个实测值更新代码里的标定参数。我见过一个极端的例子同样型号的传感器一个装在3mm厚玻璃水箱上一个装在1.5mm厚塑料瓶上满水时ADC读数差了400多。介质和容器壁材料都会影响电容感应所以标定必须现场做不能从网上抄。5.2 串口输出乱码波形却是对的串口乱码的原因通常有两个。第一是波特率不匹配这个我已经说过了。第二是共地问题也就是STM32的GND和USB转串口模块的GND没有连在一起。串口信号是相对于地线的电压差两边地电位不一致数据当然收不对。解决办法很简单确认USB转串口模块的TXD接STM32的RX1RXD接STM32的TX1GND接GND。如果还乱码再检查一下是不是TXD和RXD接反了这个也是高频错误。5.3 传感器一接触容器读数就跳到最大这个现象通常是因为传感器贴得太紧或者容器壁太薄导致感应区附近始终有微弱的电容变化。传感器的安装位置和方式对测量结果影响很大不是随便贴上去就能用的。建议传感器表面和容器壁之间不要留空气间隙可以抹一点硅脂或双面胶保证贴合紧密另外尽量减少贴合面积以外的金属或液体干扰。5.4 使用DMA后程序跑飞死在HardFault里新手用DMA时最容易出事的点DMA缓冲区数组长度没设够或者数组类型不对。我遇到过有人把uint16_t数组的长度写成4但ADC在Circular模式下连续搬运根本不管数组多大越界写入了相邻内存区域直接把程序搞崩了。所以使用DMA时缓冲区的定义一定要足够并且确认传给HAL_ADC_Start_DMA的地址和长度是对的。另外ADC的DMA中断如果没配置好也可能引发问题。我这个项目没有开DMA中断而是直接在主循环里轮询缓冲区内容简单可靠适合学习阶段。如果你要在中断里处理数据记得在中断服务函数里清除相应标志位防止死循环。5.5 一个排查实例数据的零点漂移有次我晚上调试程序没动过但第二天再跑空水读数从520变成了580。折腾了半天发现是环境温度变了。实验室晚上暖气停了室温降了几度传感器的输出就跟着飘了。后来我再做项目就养成了一个习惯每次上电后先做个短时间的“零点校准”把刚上电且确认没有水的ADC读数记录下来作为基准后边的水位计算都用这个实时基准来计算。这样环境因素带来的慢速漂移就能被自动抵消掉。6. 项目扩展还能怎么玩出花水位传感器基础项目做完后你可以朝几个方向扩展锻炼不同方面的能力。第一个方向是加显示。把串口打印换成OLED屏幕显示用I2C接口的0.96寸OLED就能低成本实现显示实时的水位百分比和温度整体会显得完整很多。第二个方向是加控制。比如做个自动补水系统当水位低于某个阈值时继电器驱动水泵加水水位到位后自动停止。这就涉及PID控制或者简单滞回控制的思路对实际工程能力提升很有帮助。第三个方向是加通信。把数据通过ESP8266模块发到云平台实现手机远程查看水位。这就要处理串口通信、TCP/IP协议、MQTT协议等是物联网方向非常典型的练习。第四个方向是低功耗。如果你想让这个系统用电池供电就得优化功耗可以考虑用定时器唤醒、待机模式、降低ADC采样频率等方式。这类优化做得好对你的功耗控制能力是很大的锻炼。7. 写在最后的一些体会这个项目我从接线到调通大概花了两个晚上其中一半时间花在排查电源干扰和标定上。做嵌入式就是这样硬件上一个小小的疏忽可能导致软件上排查半天。经历过这些坑后面的路就顺很多了。最后分享一个我认为最重要的习惯做任何传感器项目先用手头的万用表或示波器把电源和传感器的实际输出测一遍再写代码。很多看起来是代码问题导致的现象根源都在硬件上。先让硬件说话再让代码跑起来这才是调试效率最高的顺序。这套水位监测方案现在还在我工作室里跑着给一个植物自动灌溉系统做水位反馈已经稳定运行了一个多月。以后有机会我打算把数据通过Wi-Fi发到手机上看顺便再做一个低功耗版本放在户外用。嵌入式这条路折腾的乐趣就在于每往前走一步都有新的问题等着你而解决它的过程就是真正成长的过程。