我一直觉得STM32这类项目的价值不在于“跑通”而在于能不能把一个完整的“测量系统”做出来。水质检测正好是这种项目它不怎么吃算力但对模拟前端、信号完整性、标定校准、数据稳定性这些嵌入式基本功要求很细非常适合入门到进阶的跨度训练。这篇博文把我设计一套开源水质检测系统的完整过程拆开来讲从方案选型、原理图设计、代码架构到仿真验证和实物调试都会覆盖到。这套系统基于STM32F103C8T6集成pH检测、TDS溶解性总固体检测、水温检测、OLED显示与声光报警硬件上有完整的信号调理链路软件上有滤波算法和两点标定逻辑并且配套了Proteus仿真工程。如果你正准备做类似课题的毕业设计或者想给鱼缸、小型水族箱、DIY净水装置加一套水质监测模块这套方案可以直接拿来做底子代码和原理图都可以按需裁剪。1. 为什么选择STM32做水质检测项目定位与方案选型1.1 这个系统解决什么问题项目边界在哪里先明确一个点水质检测是一个很大的范畴实验室级别的水质分析仪动辄几万块涉及离子选择电极、自动进样、恒温浴这些高成本部件。我做这套系统的目标不是跟专业仪器较劲而是满足小型化、低成本、可交互、可二次开发这四个需求。放在实际场景里它适合监测鱼缸水体状态、小型水族馆循环水、雨水收集箱、鱼菜共生系统这类场合。这类场景对精度的要求很明确pH值能够分辨到0.1TDS值能够分辨到10ppm级别温度误差不超过0.5摄氏度响应时间不要超过5秒。这套指标对于STM32F103的12位ADC和常规传感器来说完全可以做到。项目的边界也要提前划好。我只能保证传感器探头本身质量合格的前提下把信号链路和算法做到最优不能用这个系统去测强酸强碱也不能把它扔到高温高压环境里。电极式传感器都有使用寿命这是行业通病跟主控和代码无关。1.2 主控选型为什么是STM32F103C8T6主控选择STM32F103C8T6说白了就是三个理由资源够用、参考资料多、成本低。先看资源。这个芯片有64KB Flash、20KB RAM主频72MHz。很多人觉得F103是“入门芯片”做不了什么复杂事情但水质检测系统根本不是算力密集型应用。你要跑的是ADC采样、滑动滤波、曲线标定、显示刷新这些操作对CPU的占用率极低。F103的多通道ADC可以做到1μs级别的采样转换12位分辨率对于pH和TDS这类模拟信号来说已经够用。再看生态。STM32F103的库函数和HAL库资料非常多无论是寄存器操作还是标准外设库遇到问题基本都能搜到解决方案。这对初学者尤其友好不会把时间浪费在调试工具链上。这一套系统里主控要做的事情包括周期性采集3路模拟信号pH、TDS、温度运行滑动平均滤波和标定补偿算法驱动OLED显示屏刷新数据根据阈值触发蜂鸣器和LED报警通过串口打印实时数据方便调试让我算一下这个负载量ADC采集3路每路采样64次取平均每次转换大约1μs加上滤波和补偿计算单轮处理不超过2ms。OLED刷新一帧数据大概需要30ms系统循环周期顶到100ms也已经很流畅了。F103的主频应对这些绰绰有余。1.3 传感器选型pH、TDS、温度各自怎么挑传感器选型是整个项目中最容易走弯路的地方我直接给出这三类传感器的选型思路。pH电极方面市面上一体化pH检测模块很多注意区分“工业级电极”和“教学实验级电极”。工业级电极支持长时间连续工作带有可拆卸保护套但价格往往在百元出头。教学实验级电极便宜二三十块就能买到但漂移大、寿命短。如果做毕业设计需要长期稳定运行我建议直接上工业级探头后续标定能省不少事。pH模块内部本质是一个由电极和前置运放组成的高输入阻抗放大器输出模拟电压信号直接给到单片机的ADC。TDS传感器方面TDS探头的原理是电导率测量常见的是两支平行的铂金或石墨电极。TDS模块和pH模块不一样它本身就带有一定的激励电路输出的是模拟电压。选TDS模块时重点看电极材质和探头线长度石墨电极比铜电极更耐腐蚀。这里要说清楚TDS数值不是一个“绝对精确”的物理量它本质上是通过电导率换算出来的估算值不同水质环境下换算系数会略有差异。温度传感器方面DS18B20几乎是这类系统的默认选项单总线数字接口一线上挂一个传感器就能读到0.0625摄氏度的分辨率。为什么要测温度两个原因第一pH电极的能斯特斜率与温度相关pH值计算时必须做温度补偿第二TDS的电导率受温度影响很大温度每升高1摄氏度电导率大约增加2%不补偿的话数据会漂得很厉害。最终我选定的是“pH模块 TDS模块 DS18B20 0.96寸OLED 有源蜂鸣器”这套组合再加一个按键用于标定模式切换整体BOM成本控制在两百元以内可以说是性价比很高的入门方案。2. 原理图设计细节信号链路里最容易翻车的三个环节原理图设计这句话听起来很硬核但放在水质检测系统里核心就三件事把信号调理好、把供电处理好、把交互外设接对。我逐个拆开讲这里面每一个点都是实物调试时踩过的坑。2.1 pH高阻抗信号链布线就是精度pH电极的输出阻抗极高高达几百兆欧到G欧级别。这意味着后级放大器的输入阻抗必须足够高否则信号会被分压吃掉。这也是为什么pH模块里都有一颗专用的高输入阻抗运放比如CA3140这类输入阻抗达到太欧级别的器件。这颗运放的作用是阻抗变换把高阻抗电压源变频成低阻抗电压源供后级ADC采样。原理图设计时我有几个具体要求pH模块输出端到STM32 ADC引脚之间的走线尽可能短尽量不要超过5cm走线越短耦合噪声越少。模拟信号线两侧包地用GND过孔围一圈形成简易屏蔽结构。pH模块的模拟GND和数字GND单点连接在MCU的GND焊盘附近汇合避免数字开关噪声串进模拟地。如果不具备画PCB的条件只有杜邦线飞线那至少要做到电源线跟信号线分开走不要和电机驱动线、蜂鸣器驱动线绑在一起。我实测过信号线跟蜂鸣器线并排走的时候蜂鸣器一响pH读数就跳0.2以上这就是典型的电磁耦合干扰。2.2 TDS检测的激励方式为什么不能直接通直流电TDS探头测量电导率的原理是向两支电极之间施加激励信号然后测量流过液体的电流。这里有一个很容易犯的错误直接用直流电激励。直流电会让电极极化水中离子会在电极表面富集导致测量结果不断漂移而且长期通直流电会加速电极腐蚀。这个问题在自制的电导率测量电路里尤其明显所以TDS模块内部通常都会做交流激励或脉冲激励也就是给电极施加交替变化的电压让离子在电极间来回迁移避免极化效应。我用的TDS模块是自带交流激励电路的输出是0到2.3V左右的模拟电压与TDS浓度基本成正比。原理图上TDS模块输出的模拟电压也是直接连接到ADC输入引脚因此同样需要做好滤波和隔离。我习惯在ADC引脚前加一个RC低通滤波器电阻用1kΩ电容用0.1μF截至频率大约1.6kHz足以滤掉高频干扰又不会影响正常信号。RC滤波器这里有一个细节阻值不能太大否则ADC内部的采样电容充电不够充分导致转换结果偏低。STM32的ADC采样电容大约几个pF级别外部串联电阻1kΩ时充电时间常数非常小完全够用。如果用过大的电阻比如100kΩ采样保持就会出问题转换值会明显偏低。2.3 供电、OLED显示和报警电路设计整个系统供电逻辑是这样的外部5V通过USB或电源适配器进来经过AMS1117-3.3稳压到3.3V给MCU和传感器供电。很多模块实际上是5V供电的比如某些pH模块和OLED模块这就需要看具体模块手册。我对每个模块的供电做了一次核对确保没有把5V或3.3V接错。这里有一个新手很容易忽略的问题AMS1117-3.3能提供的电流有限。OLED全亮时大约20mApH模块大约几mATDS模块大约几mA蜂鸣器工作时要到30mA左右DS18B20只有1mA不到整体合计不超过100mA所以AMS1117的800mA输出能力完全够用不用额外加扩流。OLED屏我选择的是I2C接口0.96寸版本只需要SDA、SCL两根线接线简单占用的引脚少。STM32的I2C1用PB6和PB7上拉电阻用4.7kΩ即可。蜂鸣器电路用一个NPN三极管S8050驱动MCU引脚通过1kΩ电阻接到基极集电极接蜂鸣器负极发射极接地蜂鸣器正极接3.3V。要注意加一个续流二极管防止蜂鸣器关断时产生反电动势击穿三极管这个细节虽然简单但很多入门原理图上都漏了。按键部分用两个轻触开关一个做菜单切换一个做确认。按键需要做软件消抖这里不在这里赘述后面代码部分会详细讲。3. 固件代码架构这套系统的灵魂在数据链路3.1 软件分层为什么代码不能全堆在main函数里我看到很多开源的STM32水质检测项目代码风格是“一竿子捅到底”所有逻辑全部堆在main函数里gpio初始化、adc读取、延时、显示全部揉成一团。这种代码跑demo没问题但后面想加功能、改逻辑、移植到其他芯片就会极其痛苦。我的代码按功能拆成了六个独立模块adc_driver.c负责ADC外设初始化、DMA采集和通道切换sensor_pH.c负责pH电压到pH值的换算与温度补偿sensor_tds.c负责TDS电压到浓度值的换算与温度补偿sensor_temp.c负责DS18B20的时序读取与温度值解析filter.c负责滑动平均滤波和限幅滤波display.c负责OLED数据排版与刷新alert.c负责阈值判断和声光报警主函数逻辑就变成了一个清晰的状态机初始化所有外设 循环 读取三路传感器数据 执行滤波算法 执行温度补偿与数值换算 刷新OLED显示 检查阈值是否越界决定是否报警 检测按键输入处理标定状态这种分层结构的好处是每个模块可以单独测试。我在调试时会把串口打开直接打印get_pH_value()函数的返回值来验证标定逻辑根本不需要经过显示层。这让调试效率提升一个档次。3.2 ADC采样、滤波与数据融合的落地实现ADC采样部分STM32F103的ADC是12位逐次逼近型转换器参考电压可以直接用3.3V电源也可以采用内部参考。对于水质检测系统来说用VDDA做参考已经够用前提是电源纹波不要太大。AMS1117的输出纹波其实不算优秀所以在ADC的VREF引脚外面并了一个10μF电容和一个0.1μF电容分别滤低频和高频噪声。采样方式我用的是定时器触发ADC配合DMA循环扫描三个通道。这样做的意义在于采样时间点由硬件决定不会受到主程序循环抖动的影响。每次触发转换后DMA会把结果自动搬运到内存数组主程序只需要从数组里取平均值CPU占用率极低。下面是简化版的多通道ADC初始化代码用的是寄存器操作方式适合理解底层逻辑void ADC_Init(void) { RCC-APB2ENR | 1 9; // 开启ADC1时钟 RCC-APB2ENR | 1 2; // 开启GPIOA时钟 RCC-APB2ENR | 1 3; // 开启GPIOB时钟 // PA0: pH电压PA1: TDS电压PB0: 备用 GPIOA-CRL ~(0xF 0); GPIOA-CRL ~(0xF 4); GPIOA-CRL | (0x0 0); // PA0模拟输入 GPIOA-CRL | (0x0 4); // PA1模拟输入 RCC-APB2ENR | 1 0; // 开启AFIO时钟 ADC1-CR2 | 1 20; // 使用外部触发 ADC1-CR2 | 7 17; // 选择软件触发 ADC1-SQR1 0; // 1个转换通道 ADC1-SQR3 0; // 通道0 ADC1-CR2 | 1 0; // 开启ADC ADC1-CR2 | 1 3; // 初始化校准 while(ADC1-CR2 (1 3)); // 等待校准完成 ADC1-CR2 | 1 2; // 开始校准 while(!(ADC1-CR2 (1 2))); // 等待校准结束 }滤波算法我采用了两级组合方案限幅滤波加滑动平均。限幅滤波的作用是剔除明显无效的毛刺比如pH值瞬间跳变超过0.5就认为是干扰用上一次的值替代。滑动平均则是取最近16次采样的平均能很好地平滑随机噪声。#define FILTER_WINDOW_SIZE 16 uint16_t filter_buf[FILTER_WINDOW_SIZE]; uint8_t filter_index 0; uint32_t filter_sum 0; uint16_t Filter_AddSample(uint16_t sample) { filter_sum - filter_buf[filter_index]; filter_buf[filter_index] sample; filter_sum sample; filter_index (filter_index 1) % FILTER_WINDOW_SIZE; return filter_sum / FILTER_WINDOW_SIZE; }这里有一个经验值TDS数据的噪声比pH小滑动平均窗口可以小一些降低响应延迟用8次就够了。pH数据更容易受到环境电场干扰窗口用16次甚至32次都不过分但窗口太长会让数据响应变慢标定时要等很久才能看到稳定值。这个平衡需要根据实际环境调整没有绝对的标准。3.3 pH和TDS的换算公式与温度补偿ADC原始值只是一个0到4095的数字关键在如何换算成物理意义明确的pH和TDS值。pH换算的原理是基于能斯特方程。pH电极在25摄氏度时的理想斜率为59.16mV/pH也就是说pH每变化1电极输出电压变化约59.16mV。实际电极的斜率会偏离理想值所以需要两点标定来校准。我的标定逻辑在代码里实现为线性拟合采集标准缓冲液4.0和6.86或7.0对应的ADC电压求出斜率和偏移然后套用到后续所有测量中。实际测量时标准pH对应的电压换算关系可以表示为pH值 (当前电压 - 标定零点电压) / (-斜率电压)这里的斜率电压通过标定求得标定零点通常是pH 7.0对应的电压。温度补偿采用能斯特斜率的温度修正公式实际斜率 理论斜率 × (273.15 当前温度) / 298.15其实代码实现时不需要真的去算自然对数只需要在标定时记录两个点的电压pH对然后求一次线性函数关系即可温度补偿在计算时修正斜率即可。TDS换算就更直接了。TDS模块输出的电压与水质电导率近似成正比典型换算关系是TDS值(ppm) 电压(V) / 3.3 × 1000 × 校准系数校准系数的取值一般在0.5到0.7之间取决于水质类型。比如用自来水标定时系数通常取0.5左右用纯净水标定时系数可能偏小。我这里把校准系数做成一个全局变量在标定模式下可以通过按键调整然后保存到FLASH中这样不同用户可以根据自己当地水质微调。温度补偿方面TDS的补偿公式是TDS_compensated TDS_raw / (1 0.02 × (T - 25))这就是水温每升高1摄氏度TDS读数增加2%除以修正项把数值拉回25摄氏度基准值。这个补偿逻辑在实物调试中效果非常明显不加补偿的话早上和中午的TDS读数能差几十ppm。4. 仿真的价值与方法硬件到手之前先把逻辑跑通很多初学者对仿真的认知停留在“画个电路图点一下运行”的阶段但仿真在本项目中的真正价值是在没有实物之前先把软件逻辑和参数换算公式验证一遍。4.1 Proteus里的传感器建模思路Proteus元件库中并没有现成的pH传感器和TDS传感器模型但我们可以用最基本的电子元件来模拟它们。这是我强烈推荐的做法因为这样你能理解到传感器本质上是“电阻/电压随物理量变化”的器件。pH模块的输出电压范围对应的原理是pH7时输出电压约为0V相对于pH 7中点pH0时输出电压约为414mVpH14时输出电压约为-414mV。但在单电源供电系统中这个电压通常会叠加一个偏置变成1.65V左右的中点电压。因此模拟方式是用一个可调电位器分压出一个电压配合一个直流偏置源让输出电压可以在0到3.3V之间变化。TDS传感器的模拟更简单它本质上是一个电导随浓度变化的电阻所以直接用一个电位器模拟TDS探头旋转电位器就相当于改变水位浓度。我在Proteus里搭的仿真电路包括STM32F103C8T6、两个电位器模拟pH和TDS电压、DS18B20的仿真模型、OLED显示屏和蜂鸣器。这套电路能完整跑通主循环、数据显示和报警逻辑。4.2 仿真验证的三个关键内容第一个要验证的是数值换算链路。ADC读取的12位原始值经过滤波后通过标定参数换算成pH和TDS数值这条链路有没有bug靠硬件很难一眼看出来但仿真时可以通过调整电位器输出电压直接观测OLED上的数值变化是否符合预期。比如给定电压对应pH应该显示7.0结果显示成6.2那不用等到接硬件就知道标定参数配置有问题了。第二个要验证的是阈值报警逻辑。在仿真中把pH阈值设为6.5到8.5当模拟电压超出范围时蜂鸣器是否立刻动作OLED上是否出现报警状态标记这在实物联调时是需要反复测试的行为仿真阶段提前跑通了实物调试就会减少大量“不知道问题是出在传感器还是逻辑”的排查过程。第三个要验证的是按键标定流程。我在代码里设计的长按按键进入标定模式、短按切换标定点、再短按确认并存储参数这个状态机在仿真中可以用逻辑分析配合观察显示器文本变化来逐步验证。标定流程的代码如果存在死循环或者状态跳转遗漏用逻辑分析器可以快速定位到具体分支。4.3 仿真与实物的差异哪些能信哪些不能信仿真通过不代表实物一定能跑这里面的差异主要来自三个方面。第一仿真中没有真实的噪声和干扰。Proteus里的线路是理想的没有寄生电容没有电磁耦合因此pH通道的仿真数据会很稳定。而实物中的pH信号非常容易受到电源纹波、电机启停、环境湿度的影响这些干扰在仿真中无法复现所以滤波算法必须在实物上重新调整参数。第二仿真中不会有传感器本身的漂移。真实pH电极在使用过程中会有老化、污染、响应变慢的问题仿真电位器完全没有这些特性。这决定了标定逻辑必须在实物上反复验证不能因为仿真标定正常就认为万事大吉。第三仿真中读到的电压与实际传感器输出的对应关系不一定一致。电位器的线性度很好而真实传感器的电压输出可能带有非线性。所以仿真验证的核心价值是逻辑、流程、交互和算法框架实物验证的核心价值才是精度和稳定性。5. 实物调试中的标定流程与避坑记录这一部分是我最想详细写的。因为代码和原理图都是看得到的而实物调试中积累的“为什么数据不对”的经验才是真正花时间换来的。5.1 pH电极的预处理和两点标定实操新的pH电极买回来不能直接泡进待测液体就读数它需要预处理。pH电极的敏感球泡在出厂时会有一层保护液通常是3M KCl浸泡保存的使用前要用去离子水冲洗干净然后在保护液中浸泡至少2小时激活。这一步如果跳过电极的响应速度会非常慢测出来的值也会偏低。标定的实际步骤如下准备pH 4.00和pH 6.86两种标准缓冲液各倒一小杯注意不要用嘴接触、不要混用。将电极放入pH 6.86标准液中等待读数稳定一般需要1到2分钟按确认键记录标定点1。用去离子水冲洗电极并用滤纸吸干水分再放入pH 4.00标准液中等待读数稳定按确认键记录标定点2。系统自动计算两点之间的斜率并把标定参数写入Flash。标定完成后再用pH 7.00标准液验证一次如果实际测量值与理论值偏差超过0.1需要重新标定。实际操作中有一个很容易翻车的点缓冲液的温度必须和系统测得的温度一致。标准缓冲液的pH值是在25摄氏度下定义的温度偏差会导致缓冲液本身的pH值发生变化。所以标定前让缓冲液和室温充分平衡不要从冰箱拿出来就直接标定。5.2 读数漂移的排查链路我在调试中遇到过一个非常典型的问题pH读数一开始是7.2放置五分钟后慢慢漂到7.6然后又慢慢漂回7.1。这种无规律的漂移非常折磨人。经过排查原因出在机电干扰和接地回路上。排查链路供参考遇到读数漂移不要怀疑人生按这个顺序查确认接线没有虚接。pH模块的BNC接口一定要拧紧BNC接头的松动是读数漂移的头号原因。确认供电稳定。用示波器看3.3V电源纹波如果纹波超过50mV就需要改善滤波电容或改用LDO供电。确认信号线没有与动力线并行走线。蜂鸣器、继电器、电机驱动线都会产生强干扰。确认采样电容充电时间足够。把ADC采样时间从1.5周期改成239.5周期排除采样保持电容充电不足导致的读数偏低。确认熔断的地线。模拟地和数字地是否单点连接没有共用回路。这五个点排查下来90%的漂移问题都能定位。剩余10%可能来自电极本身老化这时候就需要换电极了。5.3 ADC配置中三个容易影响的寄存器位STM32F103的ADC有几个寄存器位配置错了不会报错但读数就是不对这几个坑我逐一说明。第一个是ADC_CR2的ALIGN位组。这个位组决定转换结果是左对齐还是右对齐。如果设置为左对齐12位数据会变成ADC转换结果乘以16的数值需要右移4位才能还原真实的12位值。很多代码里的“读数”都正确但换算永远是错的就是因为对齐方式没设对。第二个是ADC_SMPR2的采样周期。采样周期越长采到的电压越准确但同时采样速度越慢。对于高输出阻抗的信号源比如pH传感器一定要把采样周期设置到最大239.5周期否则会因为采样电容充电不足导致读数偏小。第三个是ADC_CR1的SCAN位。如果你用多通道循环扫描但没有开启SCAN模式ADC只会采集第一个通道其他通道的数据全是垃圾。我调试TDS时遇到过这个问题TDS通道读出来的数据和pH通道一模一样后来才发现是SCAN位没置位。5.4 串口调试这个“隐形外设”不要省很多项目把OLED作为唯一输出设备但这个习惯在调试阶段效率极低。OLED一屏只能显示五六行字符每次刷新还有延时想打印中间变量、标定参数、实时波形都非常不方便。我保留了UART1串口输出调试日志调试阶段把所有关键信息全部通过串口打印出来OLED只用来显示最终的读数。串口输出的内容包括三个通道的ADC原始值、滤波后的值、换算后的物理量、标定参数、报警状态。这样调试时用串口助手盯着一行行数据流任何异常都能及时看见比起在OLED上翻页要高效太多了。串口初始化的代码非常简单void UART1_Init(void) { RCC-APB2ENR | 1 14; // USART1时钟 RCC-APB2ENR | 1 2; // GPIOA时钟 GPIOA-CRH ~(0xF 4); GPIOA-CRH | (0xB 4); // PA9 TX复用推挽 GPIOA-CRH ~(0xF 8); GPIOA-CRH | (0x4 8); // PA10 RX浮空输入 USART1-BRR 72000000 / 115200; USART1-CR1 | (1 13) | (1 3) | (1 2); // UE | TE | RE }实际经验告诉我串口调试的时间和节省的排错时间相比是完全值得投入的。等系统彻底稳定之后再决定要不要把这路串口关掉省电。6. 项目开源交付的内容说明与复用思路整个项目最终以开源形式放出包含三块交付物硬件资料、软件源码、仿真工程。硬件资料里有原理图PDF和AD/立创EDA的工程源文件。源码基于标准外设库编写兼容Keil MDK和STM32CubeIDE。仿真工程是Proteus 8.9以上版本可以直接打开运行。如果你打算在这个项目上继续扩展有几个方向可以参考增加pH自动温度补偿之外的溶解氧传感器扩展成多参数水质监测站增加ESP8266模块把数据上传到阿里云或巴法云做成远程水质监控增加SD卡存储记录长期变化趋势把OLED换成LCD触摸屏交互体验会更好增加继电器控制加热棒或增氧泵变成闭环控制系统最后分享一个我在调试过程中用到的小技巧测量前先把传感器在待测液体中搅动几下然后静置30秒再读数。这个动作能排出敏感球泡表面的微小气泡让电极更快达到平衡状态。这个小习惯能让你的读数稳定速度提升一半以上尤其适合需要频繁换水样的使用场景。任何嵌入式测量系统的核心都在“信号进得来、数据算得准、问题查得清”这三件事上这套水质检测系统正好把这三件事完整地走过了一遍。把它吃透对后续做传感器相关的嵌入式项目会非常有帮助。