简介:这份资源面向物联网与环境监测方向的开发者及51单片机初学者,提供pH值、TDS(总溶解固体)与浊度三类水质参数采集的完整驱动代码与配套传感器说明书,帮助读者快速搭建水质检测系统。压缩包共113个文件,约4.1MB,包含6个C语言源文件、2个头文件、5个HEX烧录文件及多个Keil工程文件(uvproj、uvopt、m51、lst、obj等),另有8份PDF与8份TXT文档,涵盖TDSPH值检测传感器说明书V1.0、浊度传感器说明书V1.0等操作指南、接口说明与校准方法,并附有A51汇编启动文件与工程备份。目前已有8547人学习下载。读者可从中获得传感器与51单片机的硬件连接参考、AD转换与温度补偿的代码实现、数据校准滤波思路,以及LCD显示或串口通信的界面方案,适合对照说明书完成从接线到调试的完整实践。
1. 水质检测三件套:PH、浊度、TDS 传感器代码到底怎么写才不翻车
很多人第一次做水质检测,都是从一个开发板加三个传感器开始的:PH 传感器、浊度传感器、TDS 传感器。硬件买回来一接,串口一开,数据是有了,但数值飘得离谱——PH 从 6.8 跳到 8.2,浊度电压换算出来的 NTU 跟实际完全对不上,TDS 更是随温度一路漂。问题往往不在传感器本身,而在代码里缺少标定、温度补偿和滤波这三件事。这篇笔记就围绕 PH、浊度、TDS 三路传感器的代码实现展开,把采集、换算、补偿、滤波、输出这条链路拆开讲清楚。适合正在做水质监测节点、鱼缸控制器、农业灌溉回传系统的开发者,也适合刚拿到传感器模块、想先把数据跑稳的新手。下面所有代码基于常见的模拟量输出型传感器,主控用 ESP32 或 Arduino 都能跑,核心逻辑通用。
2. 三路传感器怎么接、怎么采:从原始 ADC 到可用电压
2.1 先搞清楚三路传感器的输出类型和供电要求
动手写代码之前,必须先把三路传感器的电气特性对齐,否则后面所有换算都是空中楼阁。常见的水质传感器模块大致分两类:一类是模拟电压输出,PH 和浊度基本都属于这种;另一类是输出经过内部电路处理后的模拟量或数字量,TDS 模块多数也是模拟电压输出,但部分版本带温度补偿输出。接线时最容易翻车的地方是供电电压不统一——PH 模块通常要求 5V 供电,浊度模块有的支持 3.3V 到 5V,TDS 模块一般也是 3.3V 到 5V 宽压。如果主控是 3.3V 逻辑的 ESP32,而传感器输出摆幅超过 3.3V,直接进 ADC 会打满甚至损伤引脚,所以要么选支持 3.3V 的模块,要么在输出端加分压电阻。
我一般会先做一张接线对照表,把每个传感器的供电、输出范围、对应 ADC 通道写清楚,再开始写代码。下面这张表是我在多个模拟项目里反复用过的配置模板,具体引脚按你的板子改。
| 传感器 | 供电 | 输出类型 | 典型输出范围 | 建议 ADC 衰减 |
|---|---|---|---|---|
| PH | 5V | 模拟电压 | 0~3V 左右 | 11dB(ESP32) |
| 浊度 | 5V | 模拟电压 | 0~4.5V | 需分压或选 3.3V 版 |
| TDS | 3.3~5V | 模拟电压 | 0~2.3V | 11dB |
注意:ESP32 的 ADC 在 11dB 衰减下理论量程约 0~3.3V,但实际线性区大概在 0.1V~3.1V,两端会有非线性。如果传感器输出接近 0V 或超过 3.1V,读数会明显失真。
2.2 用滑动平均和中间值滤波把 ADC 抖动压下去
原始 ADC 读数抖动是第一个要解决的问题。很多人直接analogRead一次就拿去换算,结果 PH 值在小数点后两位疯狂跳。正确做法是先做多次采样,再用滤波算法处理。我常用的是「去极值滑动平均」:连续采 20 次,去掉最大和最小的几个,剩下的取平均。这样既能抑制突发尖峰,又不会像纯滑动平均那样把真实变化也拖慢。
// 去极值滑动平均滤波,适用于 PH、浊度、TDS 三路 ADC 采样 #define SAMPLE_COUNT 20 // 总采样次数 #define TRIM_COUNT 4 // 去掉的最大最小各几个 float readFilteredADC(int pin) { int samples[SAMPLE_COUNT]; for (int i = 0; i < SAMPLE_COUNT; i++) { samples[i] = analogRead(pin); delay(5); // 给 ADC 采样保持电容留充电时间 } // 简单冒泡排序,样本少够用 for (int i = 0; i < SAMPLE_COUNT - 1; i++) { for (int j = 0; j < SAMPLE_COUNT - i - 1; j++) { if (samples[j] > samples[j + 1]) { int t = samples[j]; samples[j] = samples[j + 1]; samples[j + 1] = t; } } } long sum = 0; int count = 0; for (int i = TRIM_COUNT; i < SAMPLE_COUNT - TRIM_COUNT; i++) { sum += samples[i]; count++; } return (float)sum / count; }这段代码的逻辑是:先采 20 个点,排序后掐掉头尾各 4 个,剩下的 12 个求平均。SAMPLE_COUNT越大越稳但响应越慢,TRIM_COUNT越大抗尖峰越强但可能把真实突变也滤掉。一般水质变化是慢过程,20 采 4 掐是比较平衡的参数。delay(5)不能省,ESP32 的 ADC 连续快速读取时通道间会互相干扰,加个小延时能明显改善。
2.3 把 ADC 值换算成电压:别忽略参考电压和分辨率
拿到滤波后的 ADC 值,下一步是换算成电压。这一步的坑在于:不同主控的 ADC 分辨率不同,ESP32 默认 12 位(0~4095),Arduino Uno 是 10 位(0~1023),而参考电压也不一样。更麻烦的是 ESP32 的analogRead返回值在 11dB 衰减下并不是线性的 0~3.3V 对应 0~4095,实际需要查表或校准。我一般会先用万用表量一个已知电压,反推实际斜率,而不是直接套公式。
// 将滤波后的 ADC 值换算为电压(以 ESP32 12 位、11dB 衰减为例) float adcToVoltage(float adcValue, float vRef, float adcMax) { return (adcValue / adcMax) * vRef; } // 实际使用时,vRef 和 adcMax 建议用实测校准 // 例如输入 1.000V 时读数为 1240,则斜率 = 1.000 / 1240 float calibratedVoltage(float adcValue, float slope, float offset) { return adcValue * slope + offset; }vRef和adcMax是理论值,slope和offset是实测校准值。我通常会在代码里留两个宏,方便现场用标准电压源或万用表标定。如果你没有标准源,至少用一个稳定的 3.3V 或 5V 基准测一次,把斜率算出来。这一步不做,后面 PH 和 TDS 的换算误差会直接放大。
3. PH 传感器代码:从电压到 PH 值的标定与温度补偿
3.1 PH 传感器的电压-PH 关系为什么不能直接套公式
PH 模块的典型输出是:PH 7 时约 2.5V,每变化 1 个 PH 单位电压变化约 0.18V(不同模块斜率不同)。很多教程直接写ph = 7 + (2.5 - voltage) / 0.18,但实际用起来偏差很大。原因有三个:一是模块的零点电压不是精确 2.5V,二是斜率随温度变化,三是不同批次的探头斜率有差异。所以必须做两点标定:用 PH 4.00 和 PH 6.86(或 7.00)标准液各测一次,算出实际的斜率和截距。
我一般会在代码里存一组标定参数,现场标定后写入。下面是一个带两点标定的 PH 换算函数。
// PH 两点标定参数,现场标定后更新 float phSlope = -5.70; // 每伏电压对应的 PH 变化量(负号表示电压越高 PH 越低) float phOffset = 21.34; // 截距,由标定计算得出 float voltageToPH(float voltage, float temperature) { // 先按 25 度标定值换算 float ph = phSlope * voltage + phOffset; // 温度补偿:PH 电极斜率随温度变化,25 度为基准 float tempCoeff = 1.0 + 0.0034 * (temperature - 25.0); ph = 7.0 + (ph - 7.0) / tempCoeff; return ph; }phSlope和phOffset的来历:假设标定液 PH 4.00 时电压 3.05V,PH 6.86 时电压 2.53V,那么斜率 = (4.00 - 6.86) / (3.05 - 2.53) = -5.50,截距 = 4.00 - (-5.50 * 3.05) = 20.78。实际代码里用你标定出来的值替换。温度补偿系数 0.0034 是 PH 电极的典型值,不同电极略有差异,高精度场合需要查探头手册。
3.2 温度补偿怎么做:DS18B20 或 NTC 的接入方式
温度补偿需要一路水温。最常用的是 DS18B20 防水探头,单总线接口,代码简单。也可以用 NTC 热敏电阻,成本低但需要自己换算。我一般用 DS18B20,因为水质检测场景里防水封装现成,精度也够。下面是把 DS18B20 读数接入 PH 补偿的代码片段。
#include <OneWire.h> #include <DallasTemperature.h> #define ONE_WIRE_BUS 4 OneWire oneWire(ONE_WIRE_BUS); DallasTemperature sensors(&oneWire); float readWaterTemperature() { sensors.requestTemperatures(); float t = sensors.getTempCByIndex(0); if (t < -50 || t > 100) { return 25.0; // 读数异常时回退到 25 度,避免补偿跑飞 } return t; }requestTemperatures之后需要等转换完成,DS18B20 默认 12 位精度约 750ms,getTempCByIndex会阻塞等待。如果主循环里还有其他任务,建议用异步方式或降低精度到 11 位。异常回退到 25 度是个保险,防止探头脱落时 PH 补偿把数值带偏。
3.3 完整 PH 采集流程与串口输出格式
把前面几步串起来,PH 通道的完整流程是:读滤波 ADC → 换算电压 → 读水温 → 电压转 PH → 输出。我习惯在串口里同时输出原始电压和 PH 值,方便现场判断是探头问题还是换算问题。
void loopPH() { float adc = readFilteredADC(PH_PIN); float voltage = calibratedVoltage(adc, phAdcSlope, phAdcOffset); float temp = readWaterTemperature(); float ph = voltageToPH(voltage, temp); Serial.print("PH_Voltage:"); Serial.print(voltage, 3); Serial.print(",PH:"); Serial.print(ph, 2); Serial.print(",Temp:"); Serial.println(temp, 1); }输出格式用逗号分隔,方便后面用串口绘图器或上位机解析。PH_Voltage保留三位小数,因为 0.01V 的电压误差对应约 0.05 个 PH 单位,保留太少看不出问题。Temp保留一位小数足够。
4. 浊度与 TDS 代码:NTU 换算、温度补偿和共地干扰处理
4.1 浊度传感器的 NTU 换算:分段拟合比单一公式靠谱
浊度模块的输出电压与 NTU 的关系不是线性的,低浊度段灵敏度高,高浊度段趋于饱和。很多教程给一个线性公式NTU = -1120.4 * V^2 + 5742.3 * V - 4352.9,但这个公式只在特定模块和特定供电下成立,换一批模块就偏。我一般做法是:用标准浊度液(0 NTU、100 NTU、500 NTU、1000 NTU)测几个点,然后在代码里做分段线性插值。没有标准液的话,至少用清水和已知浑浊水做两点标定,比直接套公式强。
// 浊度分段标定表:电压 -> NTU struct TurbidityPoint { float voltage; float ntu; }; TurbidityPoint turbTable[] = { {4.10, 0.0}, {3.80, 50.0}, {3.20, 200.0}, {2.50, 500.0}, {1.80, 1000.0}, {1.20, 2000.0} }; const int TURB_POINT_COUNT = sizeof(turbTable) / sizeof(turbTable[0]); float voltageToNTU(float voltage) { // 超出标定范围时钳位,避免外推产生离谱值 if (voltage >= turbTable[0].voltage) return turbTable[0].ntu; if (voltage <= turbTable[TURB_POINT_COUNT - 1].voltage) { return turbTable[TURB_POINT_COUNT - 1].ntu; } // 分段线性插值 for (int i = 0; i < TURB_POINT_COUNT - 1; i++) { float v1 = turbTable[i].voltage; float v2 = turbTable[i + 1].voltage; if (voltage <= v1 && voltage >= v2) { float n1 = turbTable[i].ntu; float n2 = turbTable[i + 1].ntu; float ratio = (voltage - v2) / (v1 - v2); return n2 + ratio * (n1 - n2); } } return 0.0; }这段代码的关键是标定表要按电压从高到低排列,因为浊度越高电压越低。插值时先判断区间,再做线性比例。超出表范围直接钳位,比外推安全得多。实际使用中,如果发现低浊度段分辨率不够,可以在 0~100 NTU 之间多加几个标定点。
4.2 TDS 换算与温度补偿:为什么你的 TDS 值总是偏高
TDS 模块的输出电压与 TDS 值大致成线性,但有两个修正必须做:一是温度补偿,TDS 值随水温升高而升高,标准是补偿到 25 度;二是供电电压修正,如果模块供电不是精确 5V 或 3.3V,输出电压会整体偏移。常见公式是TDS = (133.42 * V^3 - 255.86 * V^2 + 857.39 * V) * 0.5,其中 V 是补偿后的电压。这个公式来自某类模块的典型曲线,实际用之前最好用标准 TDS 液验证。
float voltageToTDS(float voltage, float temperature) { // 先做温度补偿,补偿到 25 度 float compensatedV = voltage / (1.0 + 0.02 * (temperature - 25.0)); // 多项式换算 float tds = (133.42 * compensatedV * compensatedV * compensatedV - 255.86 * compensatedV * compensatedV + 857.39 * compensatedV) * 0.5; if (tds < 0) tds = 0; return tds; }温度补偿系数 0.02 是 TDS 的典型值,即每升高 1 度,读数约升高 2%。compensatedV是把实际电压折算到 25 度下的等效电压。多项式里的系数来自模块特性曲线,不同模块会有差异,所以标准液验证不能省。如果测出来整体偏高,优先检查供电是否准确,其次检查探头是否干净。
4.3 三路共地干扰:为什么 PH 一读数 TDS 就跳
三路模拟传感器共用一块板子时,最常见的问题是共地干扰。PH 模块工作时会在电源和地线上产生微小波动,如果 TDS 和浊度的地线走线不好,ADC 读数就会跟着跳。我遇到过 PH 一采样,TDS 读数就跳几十个 ADC 值的情况,后来在代码和硬件上同时做了处理。硬件上,每个传感器的地线单独走回主控地,不要串在一起;软件上,三路采样错开时间,不要在同一时刻连续读。
// 三路采样错开,每路之间留出稳定时间 void loopAllSensors() { float phAdc = readFilteredADC(PH_PIN); delay(50); float turbAdc = readFilteredADC(TURB_PIN); delay(50); float tdsAdc = readFilteredADC(TDS_PIN); delay(50); // 后续换算和输出 }delay(50)是给电源和地线一个恢复时间。如果主循环任务多,可以用状态机把三路采样拆到不同时间片,避免阻塞。另外,PH 模块的供电最好单独加一个 100uF 电解电容和 0.1uF 陶瓷电容,能明显压住电源纹波。
5. 避坑与排查:PH 跳变、浊度饱和、TDS 漂移的 5 个血泪记录
5.1 PH 值在 7 附近来回跳,换了探头也没用
现象:PH 读数在 6.8 到 7.4 之间无规律跳动,换新探头后依旧。原因:多数是标定参数不对,或者 ADC 参考电压不稳。先检查phSlope和phOffset是不是用当前模块标定出来的,再检查主控供电是否稳定。解决:用标准液重新两点标定,标定时把温度也记录下来;如果供电纹波大,给主控和传感器分别加稳压。我一般会在代码里加一个「标定模式」,串口输入标准液 PH 值,自动算斜率和截距。
5.2 浊度读数一直饱和在最大值
现象:浊度 NTU 始终显示 3000 或满量程。原因:电压超出标定表范围,或者传感器输出端分压不对。先量传感器输出电压,如果接近供电电压,说明光路被遮挡或探头脏了。解决:清洗探头,检查分压电阻;如果电压正常但代码仍饱和,检查标定表顺序是否写反。浊度表必须按电压从高到低排,写反了插值会直接钳到端点。
5.3 TDS 值随温度一路往上飘
现象:水温升高时 TDS 读数跟着涨,补偿后仍偏高。原因:温度补偿系数用错,或者温度探头和 TDS 探头不在同一水体。解决:确认temperature来自同一水体的 DS18B20,补偿系数先用 0.02 试,再用标准液在 25 度和 35 度各测一次,反推实际系数。如果 TDS 模块自带温度补偿,代码里就不要重复补偿。
5.4 三路数据一起跳,串口打印乱码
现象:三路读数同时大幅波动,串口偶尔乱码。原因:共地干扰加串口波特率不稳。解决:三路地线单独走,采样错开;串口波特率降到 9600 试,如果还乱码,检查主控晶振和电源。我遇到过 USB 供电不足导致 ADC 基准漂移的情况,换成独立电源后立刻稳定。
5.5 标定后当天准,第二天又偏了
现象:标定完数据很好,过一天 PH 和 TDS 都偏了。原因:探头老化、标定液污染、或者代码里标定参数没保存。解决:标定参数写入 EEPROM 或 Flash,开机读取;标定液一次性使用,不要倒回瓶里;探头不用时泡在保护液里。PH 探头尤其娇气,干放一晚斜率就变。
6. 进阶技巧:用 EEPROM 存标定参数和串口命令标定
6.1 把标定参数写进 EEPROM,断电不丢
每次改代码重新标定太麻烦,我一般把 PH 斜率、截距、浊度标定表、TDS 系数都存进 EEPROM,开机读取。ESP32 用Preferences库,Arduino 用EEPROM库。下面以 ESP32 为例。
#include <Preferences.h> Preferences prefs; void saveCalibration(float slope, float offset) { prefs.begin("water", false); prefs.putFloat("phSlope", slope); prefs.putFloat("phOffset", offset); prefs.end(); } void loadCalibration(float &slope, float &offset) { prefs.begin("water", true); slope = prefs.getFloat("phSlope", -5.70); // 默认值兜底 offset = prefs.getFloat("phOffset", 21.34); prefs.end(); }Preferences的命名空间用"water",键名自己定。默认值给一组典型值,即使没标定过也能跑,只是精度差些。写入频率不要太高,EEPROM 有擦写寿命,标定完写一次就够。
6.2 串口命令标定:不重新烧录也能改参数
现场标定时,我习惯留一组串口命令,比如输入CAL PH 4.00 3.05表示当前标准液 PH 4.00、电压 3.05V,代码自动算斜率和截距并保存。这样不用带电脑烧录,用串口助手就能完成。
void handleSerialCommand(String cmd) { if (cmd.startsWith("CAL PH")) { // 格式:CAL PH <标准液PH> <当前电压> float stdPH = cmd.substring(7, 11).toFloat(); float voltage = cmd.substring(12).toFloat(); // 需要两个点才能算斜率和截距,这里存第一个点 // 实际代码里用两个点计算,此处省略存储逻辑 Serial.println("PH point saved"); } }命令解析用substring按位置切,简单但要求格式严格。更稳的做法是用sscanf或自己写分词。标定两个点后自动计算并保存,串口返回新的斜率和截距供确认。
6.3 验证方法:用标准液和万用表交叉检查
代码写完不算完,验证才是关键。我一般做三步:第一步,用万用表量传感器输出电压,和串口打印的PH_Voltage对比,误差应在 0.02V 以内;第二步,用标准液测 PH 和 TDS,看读数是否落在标称值附近;第三步,把探头从标准液移到清水,观察响应时间,PH 一般 30 秒内稳定,浊度 10 秒内,TDS 20 秒内。如果响应太慢,检查滤波参数是不是太重。
| 验证项 | 工具 | 合格标准 |
|---|---|---|
| 电压一致性 | 万用表 | 串口电压与实测差 < 0.02V |
| PH 准确度 | PH 4.00/6.86 标准液 | 误差 < 0.1 PH |
| TDS 准确度 | 707ppm 标准液 | 误差 < 5% |
| 浊度响应 | 清水/浊水切换 | 10 秒内稳定 |
这套流程跑下来,三路传感器的数据基本就能用了。我自己的习惯是每次改完代码,先跑一遍标准液验证,再上实际水体。标定参数存 EEPROM 之后,现场只需要偶尔用标准液复核,不用每次都重新烧录。希望帮到你。
本文还有配套的精品资源,点击获取