STM32实战:土壤湿度传感器与OLED显示完整教程
2026/9/18 3:13:47 网站建设 项目流程

上个月阳台那盆绿萝差点被我浇死,要么忘了浇,要么浇完盆底渗水。我索性做了个小装置: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_SCL
OLED 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 口方便调试。

具体步骤是:

  1. 新建工程,芯片选择 STM32F103C8T6。
  2. PA1 设为 ADC1_IN1,PB6 设为 I2C1_SCL,PB7 设为 I2C1_SDA。
  3. SYS 里 Debug 选 Serial Wire,这个如果不选,第一次烧录后 SWD 接口可能被 disable,之后连不上调试器。
  4. ADC1 参数里,Resolution 选 12 bits,Scan Conversion Mode 关掉,Continuous Conversion 关掉,Sample Time 拉到 239.5 Cycles。
  5. I2C1 参数保持默认 100kHz 即可,OLED 驱动不需要很快,I2C 速率太快反而容易出时序问题。
  6. 时钟树:HSE 8MHz 外部晶振,PLL 倍频到 72MHz。没有外部晶振的板子就选 HSI 内部时钟,也能跑。
  7. 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 ratio:64 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 格式"。

进度条的实现思路更省事:先画一个外框,再根据湿度百分比填充内部点。比如进度条区域从 x=2 到 x=126,共 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_dry=2100(较干),adc_wet=900(较湿),当前值是 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 点不亮是这类项目里出现频率最高的问题,尤其是同一批模块中零星几块点不亮。排查顺序建议是:

  1. 量供电。OLED VCC 两端是不是真有 3.3V,电源线松动、杜邦线接触不良都会导致黑屏。
  2. 扫描 I2C 地址。写一个临时函数,分别向 0x3C 和 0x3D 发起传输,看哪个返回 HAL_OK。如果两个都失败,说明硬件链路有问题,先查接线。
  3. 检查 SDA/SCL 是否接反。这听起来低级,但非常容易在一堆杜邦线里看花眼。
  4. 查上拉电阻。正规模块板载一般有 I2C 上拉,但一些低成本板子会缺上拉电阻,导致总线电平不确定。这种情况下需要自己外接两个 4.7kΩ 电阻到 3.3V。
  5. 检查初始化命令顺序。如果 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 把数据发到手机上。但无论往哪个方向扩展,先把传感器读数这一环做稳,后面才省心。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询