☰
STM32 HAL库驱动DHT11与OLED:单总线时序与I2C显示实战
2026/10/16 4:17:05 网站建设 项目流程

简介:这份资源面向嵌入式与物联网方向的初学者及开发者,围绕STM32 HAL库展开,解决DHT11温湿度采集与OLED实时显示联动的实践问题。包内共193个文件,以h头文件、c源文件、o目标文件、crf交叉引用文件及d依赖文件为主,另含ioc工程配置、uvprojx与uvoptx工程文件、hex烧录固件、map映射文件等,压缩包约6.9MB,完整覆盖从驱动源码到编译产物的工程结构。资源涉及单总线时序读取40位温湿度数据、I2C或SPI驱动SSD1306类OLED屏、HAL库GPIO与通信接口初始化等知识点,并包含异常处理与低功耗设计思路。目前已有772人学习下载,适合希望打通传感器采集与图形显示链路、理解HAL库跨芯片移植方法的读者参考实践。

1. 从一根数据线说起:为什么这个温湿度方案值得你花一个下午跑通

如果你手头正躺着一块 STM32 开发板、一个 DHT11 模块和一块 0.96 寸 OLED,却一直没让它们真正跑起来,那这篇拆解就是写给你的。DHT11 是物联网入门里最经典的传感器之一,单总线协议、时序敏感、数据量小,但恰恰因为“简单”,很多人在 HAL 库环境下第一次写它时会翻车——要么读出来全是 0,要么湿度正常温度乱跳。这个项目的价值就在于:它把 DHT11 的微秒级时序、HAL 库的延时控制、OLED 的 I2C 显示这三件事串成了一条完整的链路,跑通之后你对 GPIO 时序、I2C 通信、传感器数据解析会有一个非常具体的认知。适合刚接触 HAL 库、想找一个“能看见结果”的练手项目的开发者,也适合需要快速搭一个环境监测原型的熟手。

2. 项目架构拆解:从传感器到屏幕的数据链路

2.1 硬件连接与模块分工

这个项目的硬件拓扑非常清晰,三块东西各司其职。STM32 作为主控,负责发起单总线通信、解析 DHT11 返回的 40 位数据、再通过 I2C 把格式化后的温湿度值送到 OLED 上显示。DHT11 是单总线器件,只有一根 DATA 线既做输出也做输入,这意味着同一根 GPIO 需要在不同时刻切换成输出模式和输入模式,这是整个项目里最容易出问题的地方。OLED 这边通常用 SSD1306 驱动芯片,I2C 接口只需要 SCL 和 SDA 两根线,地址一般是 0x78 或 0x3C,具体看模块背面的标注。

常见的接线方式是:DHT11 的 DATA 接 PA0(或其他任意 GPIO),VCC 接 3.3V,GND 共地,DATA 和 VCC 之间加一个 4.7k 到 10k 的上拉电阻。OLED 的 SCL 接 PB6、SDA 接 PB7(对应 STM32 的 I2C1),VCC 和 GND 同样接 3.3V 和地。这里有个血泪经验:DHT11 的上拉电阻不能省,很多模块自带上拉,但如果你买的是裸传感器,不加电阻读出来的数据会非常不稳定,甚至完全没响应。

2.2 软件分层与目录结构

一个合格的 HAL 库工程不会把所有代码堆在 main.c 里。我一般会按下面这种方式组织目录,既方便调试也方便后续加传感器:

Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── dht11.h │ │ └── oled.h │ └── Src/ │ ├── main.c │ ├── dht11.c │ └── oled.c ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ └── MDK-ARM/ └── Project.uvprojx

dht11.c 里封装三个函数:DHT11_Init()负责 GPIO 初始化和上电后的稳定延时,DHT11_Read_Data()负责完整的单总线握手和数据读取,DHT11_Check()做校验和验证。oled.c 里则是OLED_Init()、OLED_ShowString()、OLED_ShowNum()这些显示函数。main.c 只做三件事:初始化 HAL、初始化外设、在 while 循环里读数据并刷新屏幕。这种分层的好处是,你换一个传感器只需要改对应的 .c 文件,main.c 几乎不用动。

2.3 单总线时序的核心逻辑

DHT11 的通信协议不复杂,但对时间要求很严。主机先拉低总线至少 18ms 作为起始信号,然后拉高 20 到 40 微秒,接着释放总线并切换到输入模式。DHT11 检测到起始信号后会拉低总线 80 微秒作为响应,再拉高 80 微秒,之后开始传输 40 位数据。每一位数据都以 50 微秒的低电平开始,高电平的持续时间决定这一位是 0 还是 1:26 到 28 微秒表示 0,70 微秒表示 1。

在 HAL 库里实现这套时序,最大的坑是HAL_Delay()只能做到毫秒级,微秒级延时必须自己写。常见做法是用__NOP()循环或者 DWT 周期计数器。下面这段代码是我在多个项目里验证过的微秒延时实现:

// 基于 DWT 的微秒延时,精度比 NOP 循环高 void DWT_Delay_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } void DWT_Delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }

SystemCoreClock是系统主频,比如 72MHz 时每个微秒对应 72 个周期。DWT 的好处是不占用定时器资源,也不受中断影响。如果你不想用 DWT,也可以用 TIM 做一个微秒级计数器,但配置起来更麻烦。参数上唯一需要注意的是us不要超过 DWT 计数器的溢出范围,单次延时控制在 1 秒以内是安全的。

3. 手把手跑通:从 GPIO 配置到 OLED 出数

3.1 CubeMX 里的关键配置

用 CubeMX 生成工程时,DHT11 的 DATA 引脚先配置成 GPIO_Output,模式选 Open-Drain 或者 Push-Pull 都可以,但输出速度建议设为 High,因为单总线切换频率不低。上拉电阻在硬件上已经加了,软件里就不用再开内部上拉。OLED 的 I2C 引脚配置成 I2C1,速度用 400kHz 标准模式或 100kHz 都可以,SSD1306 对时序不挑剔。

时钟树这边,如果你的板子是 STM32F103,主频拉到 72MHz 就行。注意HAL_Init()里默认的 SysTick 是 1ms 中断,这个不影响 DWT 延时,但如果你用HAL_Delay()做毫秒级延时,要确保中断优先级不要被其他外设打断。生成代码时记得把dht11.c和oled.c加到工程里,头文件路径也要在 Keil 或 CubeIDE 里配好。

3.2 DHT11 读取函数的完整实现

下面这个DHT11_Read_Data()是我实际项目里用的版本,去掉了多余的打印,逻辑比较紧凑:

// 返回值:0 成功,1 超时失败,2 校验和错误 uint8_t DHT11_Read_Data(uint8_t *temp, uint8_t *humi) { uint8_t buf[5] = {0}; uint8_t i, j; // 主机发送起始信号 DHT11_Set_Output(); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); HAL_Delay(20); // 拉低至少 18ms HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); DWT_Delay_us(30); // 拉高 20-40us DHT11_Set_Input(); // 等待 DHT11 响应 if (DHT11_Wait_Level(0, 100) != 0) return 1; // 等待低电平 if (DHT11_Wait_Level(1, 100) != 0) return 1; // 等待高电平 if (DHT11_Wait_Level(0, 100) != 0) return 1; // 等待数据起始低电平 // 读取 40 位数据 for (i = 0; i < 5; i++) { for (j = 0; j < 8; j++) { if (DHT11_Wait_Level(1, 100) != 0) return 1; // 等待高电平 DWT_Delay_us(40); // 延时 40us 判断高电平持续时间 buf[i] <<= 1; if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { buf[i] |= 1; } if (DHT11_Wait_Level(0, 100) != 0) return 1; // 等待下一位低电平 } } // 校验和:前四字节之和等于第五字节 if (buf[4] != (buf[0] + buf[1] + buf[2] + buf[3])) return 2; *humi = buf[0]; // 湿度整数部分 *temp = buf[2]; // 温度整数部分 return 0; }

DHT11_Wait_Level()是一个辅助函数,用来在指定超时时间内等待引脚变成目标电平,超时返回 1。这个函数很关键,因为 DHT11 如果没接好或者坏了,主机会一直卡在 while 循环里,加上超时机制就能让程序优雅地返回错误码。DWT_Delay_us(40)这个 40 微秒是判断位值的关键窗口:如果 40 微秒后引脚还是高电平,说明这一位是 1;如果已经变低,说明是 0。这个值不能随便改,改成 30 或 50 都会导致误判。

3.3 OLED 显示与主循环整合

OLED 这边,如果你用的是现成的 SSD1306 驱动库,初始化之后直接调用显示函数就行。我一般会在 main.c 的 while 循环里每 2 秒读一次 DHT11,因为 DHT11 的采样周期不能低于 1 秒,太快了数据不会更新。下面是一个典型的 main 循环:

while (1) { uint8_t temp, humi; uint8_t ret = DHT11_Read_Data(&temp, &humi); OLED_Clear(); if (ret == 0) { OLED_ShowString(0, 0, "Temp:", 16); OLED_ShowNum(48, 0, temp, 2, 16); OLED_ShowString(0, 2, "Humi:", 16); OLED_ShowNum(48, 2, humi, 2, 16); } else { OLED_ShowString(0, 0, "DHT11 Error", 16); OLED_ShowNum(0, 2, ret, 1, 16); } HAL_Delay(2000); }

OLED_ShowNum的最后一个参数是字体大小,16 表示 16 像素高度。OLED_Clear()每次刷新前清屏,避免残影。如果你觉得清屏太闪,可以改成只更新数字区域,但代码会复杂一些。错误码显示在屏幕上是个很实用的调试手段,ret=1 说明时序超时,ret=2 说明校验和错误,一眼就能定位问题。

4. 避坑与排查:DHT11 读不出数的五种典型情况

4.1 现象:数据全是 0 或 255

这是最常见的问题。原因通常是 GPIO 模式切换没做对,或者上拉电阻缺失。DHT11 在空闲时总线应该是高电平,如果你读到的全是 0,先拿万用表量一下 DATA 线在空闲时是不是 3.3V。如果不是,检查上拉电阻和接线。如果电压正常但数据还是 0,检查DHT11_Set_Input()里有没有把 GPIO 模式正确切到GPIO_MODE_INPUT,HAL 库切换模式需要重新调用HAL_GPIO_Init(),不能只改寄存器。

4.2 现象:湿度正常但温度乱跳

这种情况一般是微秒延时不准导致的。DWT 延时依赖SystemCoreClock的正确性,如果你在 CubeMX 里改了主频但没更新这个变量,延时就会偏。另一个可能是中断打断了时序,比如 SysTick 或其他外设在数据读取过程中抢了 CPU。解决办法是在读取 DHT11 的整个过程中关掉全局中断,读完再开:

__disable_irq(); ret = DHT11_Read_Data(&temp, &humi); __enable_irq();

注意关中断时间不要超过 30ms,否则可能影响其他外设。DHT11 一次完整读取大约 5ms,关中断是安全的。

4.3 现象:第一次读成功,后面全失败

DHT11 上电后需要 1 秒左右的稳定时间,如果你在DHT11_Init()之后立刻读,第一次可能成功,但传感器还没完全进入状态,后续就会失败。常见做法是在初始化函数里加一个HAL_Delay(1000),或者把第一次读取的结果丢弃。另外,两次读取之间必须间隔至少 1 秒,如果你在 while 循环里不加延时,DHT11 会直接不响应。

4.4 现象:OLED 不亮或显示乱码

OLED 不亮先查 I2C 地址,0x78 和 0x3C 是最常见的两个,用 I2C 扫描程序确认一下。如果地址对但屏幕没反应,检查初始化序列里有没有发0xAF(开显示命令)。显示乱码通常是字体取模方向不对,SSD1306 的页地址模式要求先设置列地址再写数据,如果你用的驱动库是从别的屏幕移植过来的,取模方式可能不匹配。建议直接用模块配套的驱动库,省去调字体的时间。

4.5 现象:校验和偶尔错误

校验和错误说明数据在传输过程中被干扰了。除了前面说的关中断,还要检查杜邦线是不是太长或者接触不良。DHT11 的单总线对线材质量比较敏感,超过 20cm 的杜邦线就容易出问题。如果条件允许,把 DATA 线换成屏蔽线或者缩短到 10cm 以内。另外,电源噪声也会影响数据,在 DHT11 的 VCC 和 GND 之间并一个 100nF 电容能明显改善稳定性。

5. 二次开发:从单点采集到多传感器数据上报

跑通显示只是第一步,这个项目真正的价值在于它是一个可扩展的数据采集节点。我一般会在这个基础上做三件事:加数据上报、加历史存储、加低功耗管理。下面说一个最实用的改造方向——把温湿度数据通过串口打包上报给上位机或网关。

5.1 串口协议设计与数据打包

在 main.c 里加一个Send_Sensor_Data()函数,把温湿度按固定格式发出去。我习惯用简单的帧头加数据加校验的方式,方便上位机解析:

// 帧格式:0xAA 0x55 | temp | humi | checksum | 0x0D 0x0A void Send_Sensor_Data(uint8_t temp, uint8_t humi) { uint8_t frame[7]; frame[0] = 0xAA; frame[1] = 0x55; frame[2] = temp; frame[3] = humi; frame[4] = temp + humi; // 简单校验 frame[5] = 0x0D; frame[6] = 0x0A; HAL_UART_Transmit(&huart1, frame, 7, 100); }

HAL_UART_Transmit的最后一个参数是超时时间,单位毫秒。如果你用中断或 DMA 发送,这个函数会立刻返回,不阻塞主循环。帧头 0xAA 0x55 用来做同步,上位机收到这两个字节后开始计数,收满 7 个字节就解析。校验和用 temp+humi 是最简单的做法,够用但不防碰撞,如果要更可靠可以换成 CRC8。

5.2 用定时器替代 HAL_Delay 做非阻塞采集

HAL_Delay(2000)会让 CPU 空转两秒,这段时间什么都做不了。更合理的做法是用一个定时器每 2 秒触发一次标志位,主循环检测到标志位再去读 DHT11。这样你可以在等待期间处理按键、刷新屏幕或者响应串口命令。具体做法是配置一个 TIM,溢出时间设为 2 秒,在中断里置位dht11_flag,主循环里判断这个标志:

if (dht11_flag) { dht11_flag = 0; uint8_t ret = DHT11_Read_Data(&temp, &humi); if (ret == 0) { Send_Sensor_Data(temp, humi); OLED_Refresh(temp, humi); } }

这个改动不大,但程序结构会健康很多。注意 DHT11 读取过程中还是要关中断,所以 TIM 中断的优先级不要设得太高,避免和读取过程冲突。

5.3 验证方法与调试习惯

改完之后怎么确认没问题?我的习惯是分三步验证:第一步,用串口助手看原始数据帧,确认帧头、数据和校验都对;第二步,用手捂住 DHT11 或者用吹风机稍微加热,看温湿度数值有没有变化,变化方向对不对;第三步,连续跑一个小时,看有没有偶发的校验错误或超时。如果第三步出现问题,大概率是电源或线材的锅,回头查硬件。

从那以后我每次做传感器采集项目,都会先把原始数据通过串口打出来确认,再上屏幕显示。屏幕只是给人看的,串口数据才是给程序看的,这个习惯帮我省了很多来回改代码的时间。希望这个拆解能帮你把 DHT11 和 OLED 这条链路真正跑通,后面加什么传感器都是同样的套路。

本文还有配套的精品资源,点击获取

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

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

立即咨询