基于STM32的TDS水质监测系统设计与实现

发布时间:2026/9/1 19:44:26
基于STM32的TDS水质监测系统设计与实现 简介本资源是一套基于STM32F103平台的TDS水质实时监测系统完整嵌入式源码面向嵌入式初学者、课程设计学生及环境监测类项目开发者解决水质参数采集、本地可视化、阈值报警与PC端数据回传等典型物联网感知层开发问题。压缩包含234个文件6.49MB涵盖47个.o目标文件、46个.d依赖文件、36个.c源文件与36个.h头文件构成完整的Keil MDK工程结构其中OLED.c、stm32f10x_adc.c、stm32f10x_i2c.c等关键模块清晰体现TDS传感器驱动、ADC采样、I²C通信及OLED显示逻辑配合keilkill.bat、uvprojx工程配置与axf可执行文件开箱即可编译调试。已有57人学习下载提供从硬件连接、传感器标定、阈值设定到串口协议封装的全流程实现特别适合用于课程实验、毕业设计或小型水质预警设备原型开发。 作为一个常年混迹在各种单片机项目里的老嵌入式我最初做这个STM32水质监测小系统纯粹是家里鱼缸水质总出问题买成品水质检测仪又觉得不爽利干脆自己动手。后来发现这套东西不光能用在鱼缸用在净水器滤芯寿命提醒、学校课程设计、甚至小型水培种植项目里都非常合适。这个项目用到的核心关键词很明确STM32单片机作为主控TDS水质传感器负责检测水中溶解固体的含量OLED屏幕用来本地显示数据蜂鸣器在指标超标时发出报警最后把数据通过串口调试助手实时发送到电脑上方便分析和记录。整个系统不复杂但它把单片机开发里最常见的外设几乎全用了一遍——ADC采样、I2C驱动、GPIO控制、UART通信——非常适合想练手或者做课程设计的同学直接跟着源码跑一遍基本就能把STM32的常用外设玩明白。这篇文章我会把整套系统的设计思路、硬件接线、代码逻辑、实测调试过程全部拆开讲尤其是TDS传感器的标定问题和OLED显示的抖动问题这些都是我自己踩过坑之后总结出来的经验网上很多教程根本不会写。1. 项目整体设计与思路拆解1.1 这套系统到底在解决什么问题先搞清楚TDS是什么。TDS全程是Total Dissolved Solids中文叫溶解性总固体单位是ppm或者mg/L。它测量的是水里溶解了多少钙镁离子、盐分、矿物质这类导电物质。TDS值越高说明水中导电的离子越多水质通常越硬或者污染越重。但是要注意一点TDS测的是“可导电的溶解固体”它不等于水质安全指标。纯净水TDS可能只有几矿泉水的TDS可能有两三百自来水通常在100到300之间。你不能单纯说TDS越高水质越差只能说它反映了水的纯净程度的一个侧面。这个项目里我们要做的就是把这个TDS值实时测出来显示在OLED屏上并且在超过设定阈值的时候触发蜂鸣器报警。这个系统的场景其实很清晰鱼缸水监测、水培营养液浓度监测、净水器进出水检测。在这些场景里用户关心的是一个相对变化趋势——水质是变好了还是变差了滤芯是不是该换了。这正是TDS传感器最大的用武之地。1.2 为什么要选STM32 TDS OLED 蜂鸣器这套组合先说主控。STM32F103C8T6算是国内玩单片机绕不开的一款芯片72MHz主频、64KB Flash、20KB RAM对这类项目来说性能绰绰有余。关键是它的ADC是12位的比51单片机的8位精度高了四个数量级而且有多个通道可以扫描后面如果扩展多个传感器也不怕。另外STM32的I2C和UART都是硬件外设不是你用软件模拟的稳定性要好得多。TDS传感器选型上市面上常见的是两种一种是模拟输出的探头输出0~2.3V左右的电压需要配合ADC读取另一种是带RS485或者I2C接口的数字探头直接输出数据。这个项目用的是模拟探头成本低逻辑简单也更能锻炼ADC编程的能力。OLED屏幕我选了0.96寸的I2C接口版本SSD1306驱动芯片。选它是因为I2C只需要两根线就能驱动省IO而且SSD1306的驱动资料满天飞移植起来非常快。相比SPI接口的版本I2C刷新率确实低一点但显示几个数字和状态文字完全够用了。蜂鸣器用的是有源蜂鸣器就是那种通电就响、断电就停的。选有源版本纯粹是为了省事不用写PWM驱动频率。你要是想报警声音有高低起伏可以换无源蜂鸣器用定时器输出PWM控制音调效果会更好但这属于进阶玩法先把基本逻辑跑通再说。1.3 整体数据流设计整个系统的数据流可以用一条线说清楚TDS探头 → ADC采样 → 电压值换算成TDS值 → 滑动滤波 → OLED显示 阈值判断 → 蜂鸣器控制 → UART发送到串口助手这里有一个容易忽略的细节TDS传感器模块上其实带了信号调理电路模拟电压输出是已经放大和稳压过的我们只需要用ADC去读这个电压然后按照公式把电压换算成TDS值就行。千万不能直接拿单片机ADC去量探头的两极那样读出来的数值是不稳定的误差大得离谱。2. 硬件选型与接线要点2.1 器件清单老规矩先列清单附上选型理由和大致成本方便你们采购元器件型号/规格数量选型理由主控STM32F103C8T6最小系统板1性价比高资料多ADC精度好TDS传感器模拟输出探头 信号调理模块1简单直接学习ADC最佳实践OLED0.96寸 I2C接口 SSD13061两根线驱动显示够清晰蜂鸣器有源蜂鸣器 3.3V版本1简单有源免去PWM频率控制电阻10KΩ分压用1有些TDS模块输出电压会超过3.3V ADC量程电容100nF 10uF滤波用各2给电源滤波ADC数据更稳定面包板/洞洞板-1方便搭电路杜邦线母对母若干-接线必备这一套下来成本大概在30到50块钱之间比买成品水质检测仪便宜太多了而且所有代码和原理都清楚出问题能自己排查。2.2 引脚连接表STM32F103C8T6最小系统板的引脚分配如下外设引脚说明TDS传感器模拟输出PA0 (ADC1_IN0)ADC通道0OLED SCLPB6 (I2C1_SCL)I2C时钟线OLED SDAPB7 (I2C1_SDA)I2C数据线蜂鸣器正极PB1GPIO推挽输出高电平触发蜂鸣器负极GND-TDS模块VCC3.3V注意部分模块要5V供电TDS模块GNDGND-接线部分有几个坑要先说明第一个坑是TDS模块的供电。市面上的TDS模块有两种一种支持3.3V~5V宽压供电一种必须5V供电。你得看一眼模块上的丝印或者说明书。如果模块必须5V供电但输出信号线直接接到了STM32的PA0引脚上那就得注意电压匹配问题。很多3.3V版本的STM32虽然是“5V容忍”的但稳妥起见还是加个分压电阻或者在信号线上串一个1KΩ电阻防止电流过大。第二个坑是共地。TDS模块如果用的是5V供电STM32用的是3.3V供电那两块板子的GND必须连在一起否则ADC读出来的电压值完全没有参考意义会乱跳。这个可以说是单片机项目里最常见的低级错误但也是每次都要提醒的点。2.3 电源设计心得电源是整个项目里最容易被忽略、但实际影响最大的部分。我最初测试的时候直接用USB转TTL模块给STM32供电TDS探头一浸到水里OLED屏幕就开始闪烁数据狂跳。后来用示波器一看电源纹波大得吓人TDS探头上稍微有点负载变化系统电压就跟着波动。经验做法是STM32板子通过USB或者AMS1117稳压到3.3V供电TDS模块独立用5V供电并且在模块电源附近加上100nF 10uF的去耦电容如果手头有隔离的DC-DC模块把TDS的电源和STM32的电源隔离开效果最好但成本会高一些。另外TDS探头的工作电流其实不大大概在5mA左右但信号调理电路里有运放运放的电源质量直接影响输出信号的稳定性。所以给TDS模块单独一串磁珠或者小电阻隔离一下电源能明显减少ADC读数的跳动。3. TDS传感器的测量原理与ADC读取实现3.1 电导率与TDS的关系TDS传感器测的是什么说穿了就是水的电导率。水里溶解的离子越多导电能力越强电导率就越高。TDS值和电导率之间存在一个经验换算关系TDS (ppm) 电导率 (μS/cm) × 换算系数最常见的换算系数是0.5到0.7之间很多传感器模块默认用的是0.5。但这个系数不是固定的它会随着水的温度、离子种类不同而变化。这就是为什么TDS测量一定要配合温度补偿的原因。有的TDS模块自带温度补偿有的不带。便宜的模块通常不带默认在25度下校准。如果你测的水温离25度比较远读出来的值会有偏差。这个在代码里可以加上NTC温度补偿但那就是进阶内容了基础版先不做。3.2 从电压到TDS的换算公式我们用的这个模拟TDS模块输出电压与TDS值呈线性关系。以某个常见的模块为例它的换算公式是TDS值 (电压值 / 供电电压) × 参考TDS电压值具体到你手里的模块厂家一般会给出一个测试条件下的电压-浓度对应表。比如某模块给出的关系是在5V供电、25℃条件下2.3V输出对应TDS 1000ppm所以电压和TDS的比例是TDS (电压 / 2.3) × 1000但这里要注意如果你的模块是3.3V供电输出电压范围就会变化。最稳妥的方法是看模块说明书或者自己做一组标准液标定。我的建议是不要死记公式而是直接在代码里做一次线性映射。先用标准液测出几个已知TDS值对应的ADC读数然后用一元线性回归拟合出斜率和截距把这两个系数写死在代码里。这个方法虽然笨但胜在准确而且适配任何厂家的模拟TDS模块。3.3 STM32的ADC采样配置我用的开发环境是STM32标准外设库如果你用的是HAL库逻辑也是类似的只是API不同。下面直接上ADC初始化的核心代码void ADC_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; // 使能GPIOA和ADC1时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); // 配置PA0为模拟输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStructure); // ADC1配置 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); // 配置ADC1通道0, 采样时间为239.5个周期 ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); // 使能ADC1 ADC_Cmd(ADC1, ENABLE); // 校准ADC ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); }这段代码里最关键的一点是采样时间。TDS传感器输出的不是纯直流电压它叠加了一些高频噪声尤其是水质探头本身有极化效应会产生波动。如果采样时间太短你采到的可能就是噪声尖峰而不是真实电平。我把采样时间拉到239.5个周期虽然不是最长的最长的那个是55.5个周期等等我记得239.5周期其实已经是很长的那一档了我确认一下——STM32 ADC的采样时间可选项是1.5、7.5、13.5、28.5、41.5、55.5、71.5、239.5个周期239.5就是最大的选这个没错总之尽可能长的采样时间会把高频噪声在硬件层面过滤掉一部分。3.4 多次采样求平均光靠ADC长采样时间是不够的因为TDS探头信号还有一个低频漂移的问题。我的做法是连续采样10次去掉最大值和最小值剩下8次取平均。这个“去极值平均滤波”算法对付传感器尖峰噪声特别有效代码也就几行uint16_t Get_TDS_Value(void) { uint16_t adc_buf[10]; uint16_t sum 0; uint16_t max 0, min 4095; uint8_t i; for (i 0; i 10; i) { adc_buf[i] ADC_Read(ADC_Channel_0); if (adc_buf[i] 4095) adc_buf[i] 4095; } // 找最大值和最小值 for (i 0; i 10; i) { if (adc_buf[i] max) max adc_buf[i]; if (adc_buf[i] min) min adc_buf[i]; } // 求和, 去掉最大最小 for (i 0; i 10; i) { if (adc_buf[i] ! max adc_buf[i] ! min) { sum adc_buf[i]; } } return sum / 8; }实际测试下来用这个滤波之后TDS数值的抖动可以控制在±2%以内已经足够用了。如果你还觉得不稳可以把采样次数从10提高到20甚至50但这会牺牲响应速度。做鱼缸监测的话响应速度不重要采样50次都行但如果你拿来做水龙头实时监测那建议保持10次以内否则数字更新会很肉。3.5 TDS标定方法逃不掉的关键步骤这里必须重点讲标定。TDS传感器模块出厂前虽然做过一次标定但每个探头实际装到你的电路、你的供电电压、你的ADC参考电压之后误差会被放大。不做标定直接用的后果是你测出来TDS是150实际水质可能是200偏差绝对不是你想要的。最可靠的标定方法是买标准TDS校准液常见的标准液有342ppm和1382ppm两种规格TB上几块钱一瓶。步骤很简单把探头擦干插入342ppm的标准液中等读数稳定记录此时ADC读到的电压值V1然后把探头擦干插入1382ppm标准液等稳定记录此时电压值V2用这两组数据计算出线性比例系数对应地代码里标定系数就出来了// 标定系数示例 #define TDS_SLOPE ((1382.0f - 342.0f) / (V2 - V1)) #define TDS_OFFSET (342.0f - TDS_SLOPE * V1)然后把ADC电压值带入这个线性公式得到的TDS就是相对准确的了。如果你手头没有标准液还有一个偷懒的办法拿一瓶你知道TDS的纯净水看瓶身标注一般矿泉水上会写TDS范围拿它当作一个参考点来标定。虽然精度不如双点标定但比你直接用默认公式强。4. OLED屏幕显示与蜂鸣器报警逻辑4.1 I2C驱动与OLED初始化OLED屏幕驱动用的是SSD1306方案代码可以直接移植网上开源的ssd1306驱动库。移植的核心分两步一是实现底层的I2C字节发送函数二是把图形库和字体接上。STM32的硬件I2C在标准外设库下初始化也不复杂这里给出关键配置void I2C_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; I2C_InitTypeDef I2C_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); I2C_InitStructure.I2C_Mode I2C_Mode_I2C; I2C_InitStructure.I2C_DutyCycle I2C_DutyCycle_2; I2C_InitStructure.I2C_OwnAddress2 0; I2C_InitStructure.I2C_Ack I2C_Ack_Enable; I2C_InitStructure.I2C_AcknowledgedAddress I2C_AcknowledgedAddress_7bit; I2C_InitStructure.I2C_ClockSpeed 400000; I2C_Init(I2C1, I2C_InitStructure); I2C_Cmd(I2C1, ENABLE); }注意这里GPIO配置的是开漏输出GPIO_Mode_AF_OD因为I2C协议本身就要求开漏配合外部上拉电阻使用。如果你用推挽输出驱动I2C极有可能导致通信异常。OLED的I2C地址一般是0x3C或者0x3D看模块的地址选择电阻。如果发现OLED没反应先试试切换这两个地址。4.2 显示界面的布局思路屏幕只有128x64分辨率如何组织信息是个讲究。我的布局是这样的第一行显示标题Water TDS Monitor第二行是TDS大数字用大字库显示数字比如TDS: 0236 ppm第三行显示状态水 质: 正常或者水 质: 异常第四行显示报警状态和温度如果接了温度传感器在实际代码里我用的是u8g2库的简化版本移植或者直接手写一个小的字体驱动。如果是用开源的ssd1306驱动它一般带6x8和8x16两种字体够用了。这里有个特别容易踩的坑就是频繁调用全屏刷新导致的闪烁。SSD1306的I2C传输速率最大约400KHz一张完整的128x64位图需要传1024字节约等于20ms以上。如果你每100ms全屏刷新一次屏幕会肉眼可见地闪烁、拖影。解决办法是局部刷新——只更新变化的区域比如TDS数值那几个字符的位置而不是整个屏幕的framebuffer都重发一遍。局部刷新的核心思路是先用一个全局framebuffer数组在内存里维护屏幕状态每次要改数值先改framebuffer里对应的字节然后只把那一小行区域的字节通过I2C发到OLED的GRAM。// 局部刷新示例: 只更新第2行的数值区域 void OLED_Update_TDS(uint16_t tds_value) { char str[16]; sprintf(str, TDS: %04d ppm, tds_value); OLED_ShowString(0, 2, str); // 注意这个函数内部只更新framebuffer对应行 OLED_Refresh_Range(0, 2, 127, 3); // 只发送第2行到第3行的数据 }这个技巧非常实用显示效果一下子从“闪烁PPT”变成了“流媒体视频”。4.3 蜂鸣器报警策略设计报警逻辑不能太简单粗暴。如果只是if (tds threshold) beep_on();那么TDS值在阈值附近波动时蜂鸣器会反复开断发出烦躁的“嘟嘟嘟嘟”声体验极差。我的做法是加一个迟滞区间#define TDS_WARN_LEVEL 300 // 报警阈值 #define TDS_WARN_HYSTERESIS 20 // 迟滞带宽度 uint8_t alarm_active 0; void Alarm_Logic(uint16_t tds) { if (alarm_active 0) { if (tds TDS_WARN_LEVEL TDS_WARN_HYSTERESIS) { alarm_active 1; } } else { if (tds TDS_WARN_LEVEL - TDS_WARN_HYSTERESIS) { alarm_active 0; } } if (alarm_active) { GPIO_SetBits(GPIOB, GPIO_Pin_1); // 蜂鸣器响 } else { GPIO_ResetBits(GPIOB, GPIO_Pin_1); // 蜂鸣器停 } }迟滞区间的思想是进入报警状态需要TDS超过310但退出报警状态需要TDS降到280以下。这样中间有个30ppm的缓冲区TDS在这个区间内浮动时报警状态不会反复翻转。这个技巧在工业控制里叫“施密特触发器”非常简单但极其有效。另外如果你的蜂鸣器一直长鸣会很吵我建议做间歇报警响200ms停800ms。用定时器或者简单的计数值来实现。// 间歇报警: 在主循环里调用 void Alarm_Beep_Handler(void) { static uint32_t last_tick 0; static uint8_t beep_state 0; uint32_t now HAL_GetTick(); if (!alarm_active) { GPIO_ResetBits(GPIOB, GPIO_Pin_1); beep_state 0; last_tick now; return; } if (now - last_tick (beep_state ? 200 : 800)) { beep_state !beep_state; last_tick now; if (beep_state) GPIO_SetBits(GPIOB, GPIO_Pin_1); else GPIO_ResetBits(GPIOB, GPIO_Pin_1); } }这个效果就是“滴——滴——滴”的间歇声既达到警示目的又不会让人崩溃。5. 串口调试助手收发与数据上报5.1 UART初始化和输出重定向串口调试助手是这套系统默认的人机交互出口。电脑上随便装一个SSCOM或者XCOM设置波特率、数据位、停止位就能看到数据。前提是STM32的UART得先把数据发出来。UART1的初始化代码void USART1_Config(uint32_t baudrate) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); // PA9 TX, PA10 RX GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate baudrate; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); USART_Cmd(USART1, ENABLE); }然后把fputc重定向到串口这样就能用printf直接打印调试信息int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); return ch; }这里一定要留意USART_FLAG_TXE和USART_FLAG_TC的区别。TXE表示发送数据寄存器空可以往里面写下一个字节TC表示发送移位寄存器空说明这个字节真正发完了。如果往寄存器写数据太快、不顾TXE会导致数据覆盖串口出现乱码。5.2 数据帧格式设计为了以后扩展方便我给串口输出定义了一个简单的帧格式void Send_TDS_Data(uint16_t tds, uint16_t voltage) { printf({\type\:\tds\,\value\:%d,\volt\:%d,\status\:\%s\}\r\n, tds, voltage, alarm_active ? WARN : OK); }用JSON格式的好处是能够直接被上位机软件或者Python脚本解析后续做历史趋势记录、网页显示都方便。如果你只是临时调试也可以用最简单的字符串printf(TDS%d ppm, 电压%d mV\r\n, tds, voltage);两者没有本质区别看你的用途。我实际项目中用的是JSON格式因为后面要接一个数据可视化页面直接解析JSON比字符串切割省太多事了。5.3 串口调试助手的实测体验用SSCOM或者XCOM打开串口后选择对应的COM口号波特率设为1152008位数据、1位停止、无校验就能周期性地看到数据刷出来。我实测的效果是这样的TDS探头放在自来水里串口助手每2秒输出一行数据数值稳定在210~220ppm之间。把探头放到纯净水里数值缓降到8~15ppm左右。设备在两种水质之间切换时通常需要在新的水里泡几十秒才能读数稳定因为探头表面附着的气泡和之前的残留液体会影响测量。这是TDS传感器本身的物理特性不是代码问题。5.4 发数据卡死用DMA或者中断发送有些同学直接在主循环里用printf发送大量数据会导致主循环被串口阻塞OLED刷新变卡甚至按钮响应延迟。这是因为printf的阻塞发送方式在115200波特率下每个字符大约要耗时87微秒一帧30个字符就要2.6毫秒主循环跑一次才几毫秒你发两帧就把时间吃完了。解决办法是用DMA发送。初始化好DMA通道后每次调用发送函数只是把数据搬进DMA缓冲区真正传输由DMA硬件完成CPU不用等。代码大致这样void USART1_DMA_Send(uint8_t *data, uint16_t len) { // 等待上次DMA传输完成 while (DMA_GetFlagStatus(DMA1_FLAG_TC4) RESET); DMA_ClearFlag(DMA1_FLAG_TC4); DMA_Cmd(DMA1_Channel4, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel4, len); DMA_Cmd(DMA1_Channel4, ENABLE); }用DMA之后主循环时间就把发送的等待时间省下来了OLED刷新、按键扫描这些实时任务都不会被串口拖累。5.5 接收端串口助手使用小技巧在串口调试助手里有几个实用小技巧定时器发送如果单片机程序出了问题不主动发数据可以用串口自带的定时发送功能每1秒发个命令触发单片机回传。日志保存很多调试助手支持把接收到的数据保存为文件跑一个晚上第二天直接拿日志文件分析水质变化趋势非常方便。HEX显示与文本显示切换调试二进制协议时用HEX显示调试ASCII字符串协议时切回文本模式不要搞混。6. 常见问题与排查技巧实录6.1 ADC读数为0或者满量程4095这个现象最常见的原因是TDS模块的供电电压和STM32参考电压不一致。如果模块输出的电压幅度在0~5V而STM32的ADC参考电压是3.3V那么当模块输出电压超过3.3V时ADC读数就会直接饱和跑到4095。反之如果信号线虚接或模块没上电ADC读数为0。排查顺序先用万用表量TDS模块的VCC和GND再用万用表量信号输出引脚电压对GND最后量STM32的PA0引脚电压。用这个顺序逐级检查基本能定位问题。6.2 OLED屏幕不亮或者花屏先确认OLED的I2C地址是0x3C还是0x3D。很多驱动库默认0x3C如果你的模块跳线选了0x3D那怎么调都不亮。其次确认供电电压OLED用3.3V供电有的模块虽然标注3.3~5V但5V下亮度更高、对比度更好量一下VCC脚电压就知道了。还有一个我遇到的坑STM32的硬件I2C在某些情况下会出现卡死问题。这不是OLED的问题是I2C总线被拉低后无法释放造成总线占用。解决方法是换软件模拟I2C只要两个GPIO用软件翻转方式模拟I2C时序彻底绕开硬件I2C的bug。代码实现不复杂网上搜“STM32软件模拟I2C驱动OLED”有大量参考实测稳定性甚至比硬件I2C还好。6.3 蜂鸣器一直响或者一直不响先测一下蜂鸣器驱动引脚的电平。如果代码里设置为高电平触发但你接的蜂鸣器是低电平触发版有些模块有源输入极性不同那就彻底反了。再者蜂鸣器尽量不要直接接在GPIO上虽然小蜂鸣器工作电流不大但有些大蜂鸣器启动瞬间电流能到30mA超过GPIO的安全驱动能力。最好用一个S8050三极管或者ULN2003驱动GPIO控制基极蜂鸣器接在集电极和电源之间。6.4 串口数据乱码乱码的第一大原因是波特率不对。115200的配置下双方必须完全一致一点偏差就会导致字节错位。第二大原因是单片机使用外部晶振但HSE启动失败后系统自动切到了HSI内部RC时钟导致实际波特率和理论波特率偏差很大。这个坑极隐蔽排查方法是看一下串口管脚像不像有正常的方波信号或者把波特率改成9600再试如果正常了大概率就是时钟源的问题。我用过的排查顺序是现象大概率原因解决方法乱码偶尔能出正确字符晶振没起振跑在HSI检查RCC配置用SystemInit确认HSE启动全是FF或者00TX/RX接反或者没共地交换TX/RX确认共地一帧数据里中间有丢字波特率不匹配或线太长降低波特率缩短杜邦线数据周期性重复两遍控制字多了CR/LF去掉printf里的\r\n中的一个6.5 TDS数值反复跳动的终极对策我已经讲了去极值平均滤波但如果做了之后还是跳那就从硬件层面找原因。我遇到过的情况是TDS探头的信号线用的是长杜邦线和蜂鸣器以及OLED的I2C线捆在一起蜂鸣器响的时候电磁干扰直接耦合到信号线上TDS读数瞬间暴涨。把信号线和干扰源分开走线之后问题马上消失。另外TDS探头不要放在流动的水流中测量水流会造成探针局部离子浓度不均读数同样会跳动。测量时让探头静置在一个固定位置等30秒到1分钟再读数。7. 写在最后的实操体会这套系统我前后迭代了三版第一版用最粗暴的阻塞式代码功能倒是都能跑通但体验很一般第二版加了滤波和迟滞报警逻辑才觉得“像个产品了”第三版才是现在的串口JSON输出DMA发送界面也做了局部刷新实际用下来已经完全可以作为家用设备的原型了。如果你想在这个代码基础上继续扩展我可以给几个明确的方向加一个DS18B20温度传感器做温度补偿TDS数据在高温和低温下会准确很多加一个ESP8266模块把数据上报到云端或者局域网网页手机就能随时看或者加按键和菜单逻辑让报警阈值可以在OLED界面上自由调节。每个方向都不难关键是把现在这套基础代码吃透后面都是往上加模块的事。做嵌入式项目最重要的就是“先跑通再优化”。别一上来就想着把架构设计得多完美先把传感器数值读出来、屏幕点亮、串口能发数据这个最小闭环通了之后剩下的改进都是水到渠成的事。这篇文章里所有代码和思路你都直接拿去用有任何跑不通的地方优先检查接线和供电八成的问题都出在这里。本文还有配套的精品资源点击获取