
做嵌入式实验这些年我越来越觉得“一套能跑通的小系统”比“一堆零散的例程”更有价值。STM32驱动HS-S37A水位传感器并把结果实时显示在OLED上就是这样一个完整闭环它不复杂却串起了GPIO、ADC采集、I2C通信、数据处理、显示刷新这些嵌入式开发里天天要用的基本功。你把这个实验吃透后面做鱼缸自动补水、智能台灯提醒、宿舍灯光联动甚至拿来做毕业设计的核心模块都是一条清晰的路。这篇文章我直接按自己的实操顺序来写从选型原因到接线从OLED移植到ADC校准再到联调排坑尽量把每一步为什么这么做、遇到问题怎么看都讲明白。1. 项目概览与整体设计思路1.1 HS-S37A传感器核心原理与选型理由HS-S37A本质上是电容式非接触液位传感器模块不是那种两根金属探针插进水里测导电率的传统水位开关。它内部有一个高频振荡电路感应面会形成一个微小的电容检测区当液体接近或覆盖感应区时介电常数变化导致电容值改变内部电路再把这种变化转换成模拟电压AO引脚或数字电平DO引脚输出。这个原理意味着一个很实用的特点传感器可以贴在容器外壁或水管外侧不用在容器上打孔也不用让探头接触液体。对于鱼缸、水箱、饮水机内部水位监测这种场景非接触意味着卫生、安全、不容易被水垢腐蚀。当然它也有局限性它更多是“检测感应区域是否有液体”或“反应液体覆盖面积变化”的器件适合近距贴壁安装管壁太厚超过5mm左右或者感应面与液体之间有明显空气间隙检测就会失灵。后面我会专门讲怎么贴、贴在哪。对比其他常见方案HC-SR04超声波传感器也能测水位但它测的是“回波时间差”适合开放空间、液面波动大、需要精确距离的场景。而HS-S37A的优点是成本极低、电路简单、不依赖声学反射适合做“有没有水”“水位大概几成”这类状态判断。在这个实验里我利用它的AO模拟输出通过ADC采样和两点校准把它从“液位开关”升级成“粗略水位计”能显示0%到100%的百分比实用性一下子提高了。1.2 系统组成与方案选型考量整套系统的硬件链路非常简洁主控STM32F103C8T6最小系统板传感器HS-S37A液位传感器模块显示0.96寸I2C接口OLEDSSD1306驱动辅助面包板、杜邦线、USB-TTL串口模块调试用、5V供电为什么选F103C8T6因为它的资料最多、例程最全不管是标准库还是HAL库遇到问题很容易搜到答案。ADC是12位分辨率做这种百分比水位显示绰绰有余。如果你想用APM32、AT32这类国产兼容芯片代码基本可以无缝迁移它们都以STM32的寄存器或HAL接口为参考这点不用担心。OLED为什么选I2C接口而不是SPISPI刷新速度确实快但它要多占3-4个IO口CS、DC、RST接线也更乱。对于显示百分比数字和几个汉字来说I2C的100kHz或400kHz速率完全够用两根线SDA、SCL就能搞定工程化更简洁。唯一要注意的是OLED的I2C地址问题这个坑我在3.2节会单独说明。这套方案的选型逻辑就一句话用最少的引脚、最简单的电路实现一个可观测、可扩展的完整硬件闭环。它不追求性能极限追求的是把知识点串起来。2. 硬件准备与接线细节2.1 模块引脚功能与接线表HS-S37A模块通常引出4个引脚丝印分别是VCC、GND、AO、DO。背面一般还有一个蓝色电位器用来调节DO数字输出的触发阈值。VCC供电正极多数模块设计为5V供电。GND电源地。AO模拟电压输出电压随感应面液体覆盖程度变化。DO数字电平输出超过电位器设定的阈值时输出跳变相当于液位开关。接线时有个关键点要特别注意不同批次模块的AO输出电压范围并不完全一致。有些模块AO最高只能到3.0V左右可以直接接STM32的ADC引脚有些模块AO会被拉到接近VCC如果是5V供电直接接PA1就可能烧坏ADC引脚。保险做法是上电后用万用表量一下AO引脚在“完全干燥”和“完全浸湿”两种状态下的最大电压如果超过3.3V就必须加电阻分压。我在测试时先用的就是5V供电测到AO最大3.1V才放心直接进PA1。OLED模块这里我用的是最常见的0.96寸4针I2C版本引脚分别为VCC、GND、SCL、SDA。部分模块丝印写作D0、D1本质还是SCL、SDA。完整接线表如下模块引脚接STM32说明HS-S37AVCC5V模块供电部分可3.3V建议看丝印HS-S37AGNDGND共地HS-S37AAOPA1ADC1_IN1采集模拟电压HS-S37ADO不接或PA0调试时可接本实验不用OLEDVCC3.3VI2C屏供电OLEDGNDGND共地OLEDSCLPB6I2C1_SCLOLEDSDAPB7I2C1_SDA调试串口TXPA10可选用于打印ADC原始值调试串口RXPA9可选用于打印ADC原始值调试串口GNDGND共地一个容易被忽略的细节HS-S37A和OLED要分别靠近STM32供电不要让5V和3.3V的线在面包板上来回绕长线传感器模拟量容易被电源纹波干扰OLED也可能因为供电抖动出现闪屏。比较好的做法是给整个面包板电源轨加一个100uF电解电容靠近传感器模块附近再并联一个0.1uF瓷片电容。2.2 非接触式水位传感器安装要点HS-S37A的感应面一般在模块正面装的时候要让感应面紧贴容器外壁。如果管壁是圆形的尽量把感应面贴在管子的侧面中线上让水位上升时“扫过”感应区而不是“挤出”空气间隙。用透明胶带或热熔胶固定都可以但别用太厚的双面胶胶层也会拉开液体与感应面的距离。还有一个很多人忽视的问题传感器应该贴在液位变化区间的中间位置而不是贴在底部。如果你想让OLED显示0%~100%的水位变化就把感应区放在你需要监测的液位变化范围的正中央。传感器感应区是一小块不是一根长电极它不会自动测出整桶水的高度它只感知覆盖到这块区域附近的液体会不会引起电容变化。这个“感知一小块区域”的特性决定了它适合的是“这个位置有没有水面”“水面大概在我贴的这段管内什么位置”这类场景。实际装的时候我会先用一个透明水杯做测试传感器贴在杯壁外侧高度大概在液面中段然后逐步加水观察串口打印的AO值变化。如果发现加了很多水数值才微微动说明感应面没有对准液面扫过的路径或者管壁太厚。换一个更薄的容器或者调整传感器位置往往立竿见影。这个“贴装位置和方向直接影响信号灵敏度”的问题是很多同学抄例程跑通了代码却在实际应用中失败最常见的原因。3. 代码实现从初始化到显示3.1 工程搭建HAL库 vs 标准库STM32开发环境我建议用ST官方STM32CubeMX生成工程再配合Keil MDK或STM32CubeIDE编译。CubeMX里选芯片型号STM32F103C8Tx配置好SYS里的Debug为Serial Wire否则烧录一次后SWD口被占用会出现“Error: No STM32 target found”的尴尬RCC外部时钟选Crystal/Ceramic Resonator时钟树设到72MHz。至于标准库和HAL库怎么选我的观点很直接新项目学HAL库因为CubeMX图形化配置能省大量初始化时间HAL库对I2C、ADC这类外设的封装也更工程化但接手旧项目或看老教程时标准库的代码也必须能看懂。这个实验里如果你用标准库核心逻辑几乎一模一样只是外设初始化换成了标准库函数。本文的示例代码以HAL库为主因为CubeMX生成的代码结构清晰方便读者对照自己的工程。工程搭建时建议把外设配置清楚ADC1 IN1PA1单通道采样时间尽量长一些选55.5 Cycles或更高能有效降低内阻和噪声的影响。I2C1标准模式100kHzPB6/PB7不需要开DMA。USART1115200-8-N-1用于打印ADC原始值调试完可以保留不影响功能。这里多说一句ADC采样时间它决定了采样电容充电的时间采样时间太短外部信号源内阻稍大就会导致读数偏低或波动。对HS-S37A这种输出阻抗不算很低的模块我习惯把采样时间拉长让读数更稳定。3.2 OLED驱动移植SSD1306初始化与显字市面上绝大多数0.96寸OLED用的都是SSD1306驱动芯片。我这里用的是软件模拟I2C方式驱动不依赖STM32硬件I2C原因是硬件I2C在F103上偶尔会因为时钟延展、引脚配置等原因出现ACK异常对新手排查不友好。软件I2C用两个普通GPIO做延时翻转简单可靠显示更新率在这个场景下完全够用。核心初始化序列包含屏幕关显示、设置显示时钟分频、设置复用比、设置显示偏移、开启电荷泵、设置内存地址模式、设置列地址范围、清屏、开显示等指令。这段命令序列在各大OLED库中几乎都是公开通用的移植时只要保证发送顺序正确即可。我建议不要乱调整初始化命令的顺序尤其“开启电荷泵”这条必须位于显示开启之前否则屏幕会一直亮不起来。显示字符的原理是先取好字模每个字符定义为一个8x16或16x16的数组再通过SSD1306的显存页面地址写入对应页。SSD1306内部有1KB显存分成8页每页128个字节。写入时要先设置页地址和列地址然后连续写数据。我封装了三个常用函数oled_show_char(row, col, ch)在指定行列显示一个字符。oled_show_string(row, col, str)显示一个字符串。oled_show_num(row, col, num, len)显示一个整数len控制占位宽度方便刷新时不残留旧数字。要显示“水位”“百分比”这类汉字需要先用取模软件生成16x16字模库。我这里为了控制篇幅不贴全部字模只说取模方式取模软件选“阴码”“列行式”“C51格式”生成后放到一个const unsigned char数组里。OLED显示汉字和显示ASCII字符的本质区别只有一个ASCII字符宽度是8像素汉字是16像素。3.3 ADC采集函数与数据滤波HS-S37A的AO输出不是理想直流受电源纹波、环境电容变化影响读数会有轻微抖动。如果用单次ADC值直接换算百分比显示出来的数字会跳得让人难受。所以我在ADC采集后面加了一个简单的滑动平均滤波连续采10次去掉最大最小再取平均。这样既滤掉了尖峰毛刺又不会像中值滤波那样丢失快速变化趋势。HAL库下单通道ADC采集的核心代码大致如下uint32_t get_adc_average(void) { uint32_t sum 0; uint16_t val_min 4095; uint16_t val_max 0; uint16_t tmp; for (int i 0; i 10; i) { HAL_ADC_Start(hadc1); if (HAL_ADC_PollForConversion(hadc1, 100) HAL_OK) { tmp HAL_ADC_GetValue(hadc1); } HAL_ADC_Stop(hadc1); sum tmp; if (tmp val_min) val_min tmp; if (tmp val_max) val_max tmp; } sum sum - val_min - val_max; return sum / 8; }这个函数的逻辑很简单采10个值去掉最大值和最小值剩下8个求平均。如果对实时性要求高可以把采样次数降到6次如果是静态水位检测场景10次甚至20次都无所谓。3.4 水位百分比换算与状态判断拿到稳定的ADC平均值后还需要把它换算成能看懂的水位百分比。这时就必须进行两点校准干燥状态下读取ADC值记为dry_val。用手或者用一杯水完全覆盖感应区读取ADC值记为wet_val。请注意不同安装环境、不同容器壁厚原料、不同供电电压dry_val和wet_val都会不同。所谓“两点校准”就是要在实际安装好之后再采集这两个值而不是直接套用别人的数值。这也是整个实验里最容易跳过的步骤很多人代码抄完显示出的百分比永远不对基本都是没校准。换算公式如下percent (adc_val - dry_val) * 100 / (wet_val - dry_val)公式本质上就是线性插值。为了性能和可读性我全程使用整数运算C语言实现如下uint16_t dry_val 1500; uint16_t wet_val 2800; int8_t calc_water_level(uint16_t adc_val) { int32_t percent; if (adc_val dry_val) return 0; if (adc_val wet_val) return 100; percent (int32_t)(adc_val - dry_val) * 100 / (wet_val - dry_val); if (percent 0) percent 0; if (percent 100) percent 100; return (int8_t)percent; }这里有个工程师习惯先把adc_val和阈值做比较超出范围就提前返回这样既防止div0又防止百分比溢出。乘以100时先转int32_t再做除法避免uint16_t乘法溢出这个细节很多初学者容易忽略。状态判断则更简单我把水位分成空、低、中、高四档分别对应0、1-49、50-90、91-100。为什么要这么分因为0%和100%在OLED上可以配合图标或颜色做醒目提示50%上下是一个水泵启停的常见阈值90%以上则可能触发超限报警。不同应用可以自己调整阈值我这里给出的只是一个通用示例。3.5 主程序逻辑与OLED显示刷新主程序逻辑采用经典while循环加状态机的方式不依赖操作系统代码量小且直观。整体思路是初始化HAL库、OLED、ADC。循环中读取ADC滤波值计算水位百分比。根据百分比更新状态机空/低/中/高得到状态ID。如果状态或数值与上次不同才刷新OLED否则跳过显示避免无意义刷新带来的闪烁。OLED刷新要注意“消隐残留”问题显示数字时如果上一次显示的是10%这次变成9%数字变短了旧的‘0’并不会自动消失。所以显示数字时位数要固定比如显示“LEVEL: 92%”我给数字留3位宽度不足3位前面补空格这样刷新时就不会有残影。我实际工程中main函数的简化版如下int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); MX_I2C1_Init(); MX_USART1_UART_Init(); oled_init(); oled_show_string(0, 0, STM32 WaterLevel); uint8_t last_level 0xFF; uint16_t last_pct 0xFFFF; while (1) { uint32_t adc_val get_adc_average(); uint8_t pct calc_water_level(adc_val); uint8_t level get_level(pct); if (pct ! last_pct) { oled_show_string(2, 0, Level:); oled_show_num(2, 8, pct, 3); oled_show_string(2, 11, %); last_pct pct; } if (level ! last_level) { oled_show_string(4, 0, State:); oled_show_string(4, 8, level_str[level]); last_level level; } HAL_Delay(200); } }实际的工程里我还会在串口打印ADC原始值和百分比方便校准。但OLED已经能显示时串口只是辅助手段考虑到很多新手没有USB-TTL模块只要OLED显示正常串口不接也不影响实验。4. 联调、校准与常见问题4.1 完整联调步骤拿到这套系统我推荐的联调顺序不是一上来就把所有模块接好而是分三步走每步验证一个环节。第一步只接OLED烧一个纯显示程序让屏幕亮起并显示固定内容。这一步验证的是I2C引脚、OLED地址、显示驱动三段链路是否正确。如果白屏先检查接线和供电再看地址是0x3C还是0x78。91449这里最多的是地址问题。第二步在OLED显示程序基础上加ADC采集和串口打印功能。不用接传感器直接用手碰PA1引脚观察串口或OLED上显示的ADC值是否发生明显变化。如果手碰都不变说明ADC配置有问题如果变化剧烈说明采集代码和滤波函数基本正常。第三步接上HS-S37A进行两点校准。先把感应面悬空或贴上干燥容器壁记录干燥状态的ADC值再用盛满水的杯子贴着感应面记录湿润状态的ADC值。把这两个值写进代码里的dry_val和wet_val重新下载然后开始加水排水观察OLED百分比是否跟着变化。这个过程一定要有耐心尤其第三步传感器贴装位置和校准值配合不好需要反复调整。我自己的经验是校准时的“干”和“湿”两种状态要模拟真实使用场景而不是随意拿手按一下。不是湿度计不要把手当作水来校准。4.2 常见问题速查表踩过很多次坑后我把经典问题整理成一个速查表联调时直接对照排查现象可能原因排查与解决OLED全白无显示I2C地址不对、接线错误、电荷泵未开启先确认SDA/SCL接反再试地址0x3C和0x78重新检查初始化命令序列OLED亮但显示乱码字模数据格式不对、列地址设置错误检查取模方式确认是列行式还是行列式和驱动库保持一致ADC值始终为0传感器AO没接、共地缺失、ADC通道配置错误用万用表测AO对GND电压确认PA1对应ADC1_IN1检查CubeMX是否开启ADC引脚ADC值始终4095AO引脚悬空、传感器未供电、模拟量超出量程检查VCC供电确认AO确实输出电压若电压超过3.3V需要分压水位变化但百分比不变传感器贴装位置不对、校准值错误、感应区没有覆盖液面重新贴装到液面扫过的路径中段重新采集干湿校准值观察串口打印原始ADC是否变化百分比跳变严重电源纹波大、采样时间太短、线路过长加100uF电容滤波延长ADC采样时间缩短传感器至MCU的连线增加滑动平均次数DO输出乱触发背面电位器阈值设置不当、电磁干扰重新调节电位器加延迟消抖本实验尽量以AO为主不用DOSWD烧录时报target not found调试口被复用、芯片死锁按住复位键烧录如果已禁用SWJ用串口ISP擦除后再连上电后OLED偶发不亮上电时序问题、I2C总线被拉死在oled_init之前延时200ms再初始化检查SDA/SCL是否被外部上拉电阻干扰4.3 电源与布线的隐蔽坑最后一个可能让整套系统工作不稳定的因素是电源和布线。很多人喜欢用电脑USB口直接给STM32供电再接上OLED和传感器表面上都能工作但电脑USB供电的纹波其实不小HS-S37A的模拟量对电源噪声很敏感OLED刷新时电流波动也可能让ADC读数跳变。我的建议是整个实验共用同一个5V电源轨STM32板载稳压给MCU和OLED提供3.3V传感器直接取5V。不要在电脑USB和面包板之间串太多转接线。如果手头有独立5V电源优先用独立电源再和电脑共地。这样做下来ADC的稳定性会有明显改善。另外传感器连接线尽量不要和OLED的I2C线绞在一起模拟信号线和数字信号线互相干扰是嵌入式布线的经典问题。5. 应用场景与后续扩展5.1 这套实验能落地的典型场景STM32驱动HS-S37A加OLED显示虽然是个教学实验但它的完整链路就是很多实际产品的雏形。最直接的应用是鱼缸自动补水。在鱼缸侧壁贴上传感器STM32通过继电器控制一个小水泵当水位百分比低于设定值时启动补水高于设定值时停止。OLED实时显示当前水位人不用蹲下来凑近看。这里要注意鱼缸玻璃壁厚和传感器贴装位置要重新校准因为玻璃壁和塑料杯的介电特性不一样。另一个典型场景是智能台灯的防干烧提醒。很多加湿器、香薰机的内部水箱是不透明的用户不知道什么时候缺水。把HS-S37A贴在水箱底部或侧壁当水位接近干涸时OLED上显示“低水位”同时可以驱动一个蜂鸣器报警。这个场景要求的检测精度不高液体覆盖感应区就会触发很适合用这套链路实现。宿舍灯光控制类的项目也可以借鉴。比如在洗手台下方水管上贴传感器检测到有人打开水龙头后联动卫生间的过道灯人走自动关。这种“水流没水”“有没有人来用”的判断本质上就是检测感应区的状态变化和测水位是同一种信号逻辑。如果是做“基于STM32的毕业设计”这个项目可以直接作为核心模块向上扩展成智能鱼缸系统、远程水位监控系统、智能灌溉控制器等题目。OLED显示模块让作品看起来完整HS-S37A的非接触特性可以写成创新点STM32作为主控也符合常规思路。5.2 功能扩展方向整套系统预留了足够的接口扩展思路很多我列几个我觉得实用且难度不高的方向接入ESP8266 WiFi模块把水位百分比上报到云平台或手机APP实现远程监控。通信链路可以直接走USART透传数据格式用JSON封装比如{level: 75}服务端解析显示。这样就把一个本地显示小系统变成了物联网终端。接入K210或其他视觉模块做联动。这在很多创客项目里很流行用摄像头识别水箱是否透明、判断液位刻度再和HS-S37A的数据做交叉验证。不过说实话视觉方案的稳定性跟环境光强关系很大作为扩展方向可以尝试但核心水位判断还是以电容式传感器为主更可靠。增加继电器驱动负载本地闭环控制。OLED显示可以加一个“自动/手动”状态自动模式下按水位阈值启停水泵手动模式下用按键控制。这个扩展看起来工作量不大但涉及状态机切换、安全保护逻辑能锻炼的东西不少。低功耗改造。如果传感器和OLED放在电池供电的设备里OLED不能一直亮着可以改成按需唤醒平时显示10秒自动熄灭MCU进入STOP模式定时器唤醒读一次ADC后继续睡眠。这个方向会让项目从“实验板”迈向“产品原型”。关于和多路传感器的端口冲突问题我要提醒一句如果以后想把两路HS-S37A接到同一块STM32上就要用到ADC的多通道扫描模式或者是把第二路接到另一个ADC通道然后通过DMA循环采集。HAL库下多通道ADCDMA是一个比较经典的组合网上资料也很多扩展前最好先把单通道的滤波和校准逻辑彻底吃透否则两路数据混在一起会修到怀疑人生。就我个人实际体验来看这套实验最关键的并不是代码本身写得多么漂亮而是你能不能理解“传感器不是绝对准确的系统需要校准和容错”这个思想。HS-S37A给出的只是一个模拟量把这个模拟量翻译成水位百分比靠的是你基于场景做的两点标定和阈值划分。很多人在开发板上跑通例程就以为万事大吉结果换一个容器、换一个电源数据就完全对不上原因就是缺少了校准这个环节。以后无论你做哪个传感器项目我建议都先问自己三个问题这个传感器输出的是什么类型的信号信号范围和分辨率是多少要换算成业务数据需要哪些标定或补偿三个问题想清楚代码写起来就是水到渠成的事。