
上个月阳台那盆绿萝差点被我浇死要么忘了浇要么浇完盆底渗水。我索性做了个小装置STM32 读电阻式土壤湿度模块实时把湿度显示到 0.96 寸 OLED 屏上。这个组合非常典型既涉及 ADC 采样又涉及 I2C 通信和显示驱动很适合作为学完 GPIO、串口之后的第一个完整实战项目。网上零散代码很多但大多是你抄我我抄你的片段真正从模块选型、硬件接线、CubeMX 配置、驱动代码写到标定校准的完整教程反而少见。这篇文章我就把这套流程完整过一遍用的器件是 STM32F103C8T6、电阻式土壤湿度模块、I2C 接口的 0.96 寸 OLED软件基于 HAL 库。项目做完之后你不仅能看懂每一步在干什么还能自己改造成鱼缸水位监测、花盆浇水提醒之类的小玩意儿。1. 电阻式探头的工作原理以及为什么我最终没选电容式1.1 电阻式湿度模块的电气本质电阻式土壤湿度传感器说白了就是两根裸露的金属探针。土壤里含水量越高导电性越好两根探针之间的等效电阻就越小。干燥土壤可以到 1MΩ 以上泡透水之后可能只有几千欧姆这个跨度非常大。模块上的电路就是把这个可变电阻转换成电压信号探针作为分压电阻的一部分土壤越湿AO 引脚输出的模拟电压就随之变化。模块上通常还有一颗 LM393 比较器用来把模拟信号转成数字信号。板上有个蓝色电位器拧它其实就是调比较器的阈值电压。超过阈值时 DO 输出低电平否则输出高电平。我一开始以为 DO 可以直接用但实际用下来发现它只能告诉你干没干这种二值结果没法告诉你湿度是 30% 还是 70%所以项目里我主要用 AO把模拟值读进 STM32 的 ADC。1.2 LM393 模块引脚与接线表这种模块一般有 4 个引脚VCC、GND、AO、DO。OLED 模块是 4 个引脚VCC、GND、SCL、SDA。我用的板子是 STM32F103C8T6接线如下模块引脚接 STM32说明湿度模块 VCC3.3V供电电压见 1.3湿度模块 GNDGND共地湿度模块 AOPA1ADC1 的通道 1湿度模块 DOPD2可选数字量输出不接也能跑OLED VCC3.3V供电OLED GNDGND共地OLED SCLPB6I2C1_SCLOLED SDAPB7I2C1_SDA注意一点PB6/PB7 是 STM32F103 上 I2C1 的默认映射引脚CubeMX 里选硬件 I2C 的时候会自动指过去。如果你想换引脚在 CubeMX 里重新分配 AF 映射就行但软件 I2C 会更自由后面会说。1.3 供电电平3.3V 还是 5V很多卖家说模块支持 3.3~5V但实际操作时我强烈建议给模块供 3.3V不要图省事直接接 5V。原因很简单AO 输出电压范围接近供电电压如果供 5V土壤较干时 AO 可能输出 4V 以上而 STM32F103 的 ADC 输入范围是 0~3.3V超过 VDD 的电压直接灌进引脚长时间可能损伤单片机。供 3.3V 时AO 输出范围大概在 0.6~2.8V 之间完全落在 ADC 量程内不需要分压电路。有人会担心 3.3V 供电会不会让传感器灵敏度下降。实际测下来LM393 的比较器参考电压也是从 VCC 分压出来的比例关系没变只是绝对电压整体低一些对 ADC 采样反而更安全。唯一的限制是探针插入很深或土壤接近饱和时AO 可能被拉到接近 0V只要不低于 0 就没事。2. CubeMX 里的 ADC 配置与 HAL 库读取代码2.1 引脚分配与 CubeMX 参数如果你用标准库写过 51 或老版 STM32 外设库再用 HAL 库时会发现初始化代码被 CubeMX 生成了一大堆结构体。这个项目里 CubeMX 只需要配三块ADC、I2C、时钟再加一个 Debug 口方便调试。具体步骤是新建工程芯片选择 STM32F103C8T6。PA1 设为 ADC1_IN1PB6 设为 I2C1_SCLPB7 设为 I2C1_SDA。SYS 里 Debug 选 Serial Wire这个如果不选第一次烧录后 SWD 接口可能被 disable之后连不上调试器。ADC1 参数里Resolution 选 12 bitsScan Conversion Mode 关掉Continuous Conversion 关掉Sample Time 拉到 239.5 Cycles。I2C1 参数保持默认 100kHz 即可OLED 驱动不需要很快I2C 速率太快反而容易出时序问题。时钟树HSE 8MHz 外部晶振PLL 倍频到 72MHz。没有外部晶振的板子就选 HSI 内部时钟也能跑。Project Manager 里 Toolchain 选 MDK-ARM生成代码。这里的 Sample Time 是关键后面专门讲。2.2 ADC 轮询读数的三行核心代码与运行逻辑CubeMX 生成的代码里ADC 外设已经被初始化好了。在主循环里读 ADC 最直观的方式是轮询uint16_t adc_raw 0; float adc_voltage 0.0f; HAL_ADC_Start(hadc1); if (HAL_ADC_PollForConversion(hadc1, 100) HAL_OK) { adc_raw HAL_ADC_GetValue(hadc1); } HAL_ADC_Stop(hadc1); adc_voltage (float)adc_raw * 3.3f / 4095.0f;每次采样前 Start采样完立刻 Stop。这样做的优点是逻辑清晰ADC 只在需要的时候工作对主循环影响小。如果你开了 Continuous Conversion也可以在主循环外 Start 一次然后主循环里直接 GetValue但很多初学者会忘了 Stop导致 ADC 一直占着却不再读取反而容易困惑。HAL_ADC_PollForConversion 的第二个参数是超时时间单位毫秒。如果返回 HAL_TIMEOUT说明转换没完成这时别读 GetValue拿到的值不可信。2.3 为什么建议让采样时间尽量长一点ADC 内部是一个采样保持电容开关闭合后外部信号源要给这个电容充电。土壤湿度模块的输出阻抗不低探针埋在土里时电极和土壤之间还有接触电阻可能到几十千欧姆以上。如果采样时间太短电容还没来得及充满就被切换去转换读到的电压自然偏低且不稳定。CubeMX 里 Sample Time 有 1.5 Cycles 到 239.5 Cycles 可选。我实测同一个传感器用 1.5 Cycles 时读数上下跳 300~400 个 LSB改成 239.5 Cycles 后跳动能压到 20 个 LSB 以内。代价只是每次转换时间变长但对这种慢速监控场景完全无所谓。另外如果你以后要多通道采集比如同时接土壤湿度、光照、水位传感器扫描模式下规则组的通道配置很容易写错导致通道串位。这种时候建议把每个通道读完后 Stop再切换通道重新 Start虽然慢一点但不容易踩坑。3. SSD1306 驱动 0.96 寸 OLED从零写一个可用驱动3.1 0.96 寸 OLED 的 I2C 通信结构大部分 0.96 寸 OLED 用的是 SSD1306 驱动芯片128x64 分辨率I2C 接口。I2C 上传输有两种字节命令字节和数据字节。控制字节 0x00 表示后续是命令0x40 表示后续是显示数据。这个机制很像寄包裹时的标签先告诉接收方里面是什么再发内容。SSD1306 的 7 位 I2C 地址通常是 0x3C 或 0x3D取决于模块背面 SA0 电阻。绝大多数模块接地地址是 0x3C。在 HAL 库里I2C 地址参数要左移一位变成 8 位地址所以代码里会看到#define OLED_I2C_ADDR (0x3C 1)这个左移非常容易忘忘了之后 OLED 完全没反应而且 HAL 函数不会报错只是底层 ACK 一直失败。3.2 初始化序列与最小编译代码SSD1306 上电后需要一串初始化命令才能点亮。经典的初始化命令大概是这样static const uint8_t ssd1306_init_cmds[] { 0xAE, // 关闭显示 0xD5, 0x80, // 显示时钟分频 0xA8, 0x3F, // multiplex ratio64 0xD3, 0x00, // 显示偏移 0x40, // 起始行 0 0x8D, 0x14, // 开启 charge pump 0x20, 0x00, // 水平地址模式 0xA1, // 左右反置取决于模块 0xC8, // 上下反置 0xDA, 0x12, // COM 引脚配置 0x81, 0xCF, // 对比度 0xD9, 0xF1, // 预充电周期 0xDB, 0x40, // VCOMH 值 0xA4, // 恢复 RAM 显示 0xA6, // 正常显示 0x2E, // 停止滚动 0xAF // 打开显示 };发送函数用 HAL 的 I2C 发送static void oled_send_cmd(uint8_t cmd) { uint8_t buf[2] { 0x00, cmd }; HAL_I2C_Master_Transmit(hi2c1, OLED_I2C_ADDR, buf, 2, 100); }如果你想在屏幕上显示字符最直接的方式是维护一块 1024 字节的显存缓冲区每 1 bit 对应一个像素。显示函数往缓冲区里写点然后一次性把整个缓冲区推给屏幕uint8_t g_gram[1024]; // 128x64/8 void oled_draw_pixel(uint8_t x, uint8_t y, uint8_t color) { if (x 128 || y 64) return; uint16_t idx (y / 8) * 128 x; uint8_t bit y % 8; if (color) g_gram[idx] | (1 bit); else g_gram[idx] ~(1 bit); }刷新函数先把页地址和列地址设置成从 (0,0) 开始然后把 1024 字节按顺序写出去屏幕上的内容就和缓冲区一一对应。字符、进度条、图标本质都是往这个缓冲区里填充点阵。3.3 显示界面设计百分比数字加进度条我的界面很简单第一行显示湿度数值第二行画一个进度条。湿度数值用 6x8 的 ASCII 字库取模软件生成一个数组就行设置时选阴码、逐列式、顺向、C51 格式。进度条的实现思路更省事先画一个外框再根据湿度百分比填充内部点。比如进度条区域从 x2 到 x126共 124 个像素每递增一个像素代表 1% 左右。代码粗略是这样void oled_draw_progress(uint8_t x0, uint8_t y0, uint8_t width, uint8_t percent) { uint8_t fill width * percent / 100; for (uint8_t i 0; i fill; i) for (uint8_t j 0; j 8; j) oled_draw_pixel(x0 i, y0 j, 1); }刷新频率我设在 1 秒一次不需要更高频。OLED 显示内容变化不快刷新太快反而显得闪。注意 OLED 没有记忆效应你不用每秒都重画全部内容只有湿度变化超过 1% 时再重画进度条和数字区域这样屏幕闪烁感会小很多。4. 一块干土和一块湿土就能完成的湿度标定4.1 AO 电压与湿度极性非常容易搞反很多教程默认湿度越高电压越高但实际不是所有模块都这样。我手上这个 LM393 模块AO 输出是越湿电压越低。这是因为探针电阻作为分压的一部分土壤越湿探针间电阻越小分压点电压被拉得越低。如果你直接套用电压高湿度高的公式会得到完全相反的结果甚至湿度算出来是负值。所以拿到模块后第一步不是写代码而是干土和湿土各测一次电压确认极性。这也是整个项目里最容易让新手困惑的一步。4.2 两点标定与线性映射公式标定只需要两个锚点。第一个锚点是干燥土壤把土彻底晾干探针插到固定深度等 10 秒读取 ADC 值记为 adc_dry。第二个锚点是饱和土壤把水浇透等多余水分流掉读取 ADC 值记为 adc_wet。假设我实测得到 adc_dry2100较干adc_wet900较湿当前值是 adc_now那么湿度百分比可以这样算#define ADC_DRY 2100 #define ADC_WET 900 int calc_humidity(uint16_t adc_now) { if (adc_now ADC_DRY) return 0; if (adc_now ADC_WET) return 100; return (int)(((float)(ADC_DRY - adc_now) * 100.0f) / (float)(ADC_DRY - ADC_WET)); }这里假设的是越湿电压越低的极性。如果你的模块是反的就把两个判断反过来当 adc_now ADC_DRY 时为 0当 adc_now ADC_WET 时为 100分子用 adc_now - ADC_DRY。这就是标定的全部内容本质就是两点之间做线性插值。4.3 滑动平均与防抖策略ADC 原始值即使在采样时间调到最大之后还是会有少量波动主要是因为土壤不是均匀介质探针附近的土粒、水分分布会变化。在读取端加一个滑动平均能明显改善#define FILTER_N 5 uint16_t filter_buf[FILTER_N]; uint8_t filter_idx 0; uint32_t filter_sum 0; uint16_t adc_filtered(uint16_t new_value) { filter_sum - filter_buf[filter_idx]; filter_buf[filter_idx] new_value; filter_sum new_value; filter_idx (filter_idx 1) % FILTER_N; return (uint16_t)(filter_sum / FILTER_N); }每隔 200ms 采一次取最后 5 次平均1 秒刷新一次界面稳定性和实时性都能兼顾。如果想要响应更快又不牺牲平滑度可以用一阶滤波filtered filtered * 0.8 new_value * 0.2效果也够用。另外探针插入深度对读数影响非常大。插得浅接触面积小读数会虚高插得深又可能碰到盆底石块。标定和实际测量时一定要保持同一插入深度这是很多人忽略的变量。5. 联调现场的典型故障排查记录5.1 OLED 批量点不亮从地址到上拉逐项排查OLED 点不亮是这类项目里出现频率最高的问题尤其是同一批模块中零星几块点不亮。排查顺序建议是量供电。OLED VCC 两端是不是真有 3.3V电源线松动、杜邦线接触不良都会导致黑屏。扫描 I2C 地址。写一个临时函数分别向 0x3C 和 0x3D 发起传输看哪个返回 HAL_OK。如果两个都失败说明硬件链路有问题先查接线。检查 SDA/SCL 是否接反。这听起来低级但非常容易在一堆杜邦线里看花眼。查上拉电阻。正规模块板载一般有 I2C 上拉但一些低成本板子会缺上拉电阻导致总线电平不确定。这种情况下需要自己外接两个 4.7kΩ 电阻到 3.3V。检查初始化命令顺序。如果 charge pump 没开就开始显示屏幕也是黑的但 I2C 通信完全正常这种最迷惑。有人会遇到所有模块都点不亮的情况优先怀疑供电和上拉而不是怀疑芯片质量问题。OLED 这种东西很少整批坏。5.2 ADC 数值乱跳传感器电源与采样时长的关联ADC 数值跳动的根因通常有三个。第一个是模块供电不稳定尤其和电机、蜂鸣器共用一个电源时负载一工作电压就波动。解决办法是给模拟电路单独供电或者在模块 VCC 和 GND 之间并一个 100μF 电解电容。第二个是采样时间太短前面已经说过把 Sample Time 调到 239.5 Cycles。第三个是探针和土壤接触不稳定。刚浇水后土壤湿度变化极快读数上下摆动是正常物理现象不是代码 bug。建议等土壤稳定几分钟后再做标定测量时探针插入后也等一两秒再读值。5.3 程序莫名其妙卡死在 I2C 和 delay 处加了 OLED 函数之后程序卡死是让我最头疼的一个问题排查到最后发现两个原因。第一个是硬件 I2C 卡死。STM32F103 的硬件 I2C 在总线异常比如半路拔线、从机无 ACK时如果代码里没有超时判断会一直停在等待标志位的地方看起来就是程序死机了。解决方法有两类要么每次调用 HAL_I2C_Master_Transmit 后都判断返回值不为 HAL_OK 就重新初始化 I2C要么直接改用软件 I2C用两个 GPIO 模拟时序。软件 I2C 虽然占 CPU但对这种低速设备非常稳我后来为了省心直接换成了软件 I2C。第二个是 HAL_Delay 卡死。HAL_Delay 依赖 SysTick 中断如果时钟树配置不对或者 CubeMX 生成工程时 SYS 选项里把 SysTick 关了HAL_Delay 会永远等下去。检查方法是打断点看程序停在哪一行如果停在 HAL_Delay 内部优先检查 SystemClock_Config 是否正确执行、有没有在中断里长时间调用延时函数。还有个容易忽略的坑矩阵按键扫描。如果按键矩阵占用了 I2C 同一个引脚按下一个按键时会把 SDA 拉低总线上所有 I2C 通信都会卡住。要么按键和 I2C 引脚彻底分开要么在按键扫描期间禁止 I2C 通信不然排查半天都找不到原因。6. 这个小项目做完我最大的体会整套跑通之后除开驱动和标定还有一个很容易被忽略的点长时间通电时电阻式探头存在电化学腐蚀。两根金属探针通的是直流电土壤里有水分和电解质长期通电会加速探针氧化使用周期变短。所以我的主循环里做了个处理每 10 秒才给传感器供电一次采样几十毫秒后立刻断电这样既省电又延长探针寿命。代码上就是用一个 GPIO 控制模块 VCC或者用 MOSFET 做电源开关逻辑不复杂但非常实用。如果你是想把它做成浇水提醒器这个方案已经够用。如果想再进一步可以试试在定时器中断里做周期采样主循环只负责界面刷新或者接一个继电器控制水泵低于 25% 自动浇水再往后还可以挂 ESP8266 把数据发到手机上。但无论往哪个方向扩展先把传感器读数这一环做稳后面才省心。