☰
STM32 HAL库驱动DHT11与OLED:从时序到显示的完整实战
2026/10/16 4:17:06 网站建设 项目流程

简介:这是一份面向嵌入式与物联网初学者的STM32实战工程,基于HAL库实现DHT11温湿度传感器数据采集,并通过OLED屏实时显示温湿度信息,适合正在学习单总线时序控制、I2C/SPI显示驱动与HAL库移植的开发者参考。压缩包共193个文件,约6.9MB,以h头文件、c源文件、o目标文件、crf编译中间文件为主,另含ioc配置、uvprojx与uvoptx工程文件、hex烧录文件及map、lst等编译产物,完整保留了从驱动到工程的目录结构。资源已有772人学习下载,可帮助读者理清DHT11的40位数据解析、OLED显示驱动配置以及GPIO、I2C等外设初始化流程,并借鉴异常处理与低功耗设计思路,快速搭建可运行的温湿度监测原型。

1. 从一根数据线到屏幕数字:这个项目能让你少走三天弯路

如果你手头正捏着一块 STM32 开发板、一个 DHT11 温湿度模块和一块 OLED 屏,却卡在“时序对不上、屏幕不亮、数据全是 0”的阶段,这个项目就是冲着你来的。它做的事情很具体:用 HAL 库驱动 DHT11 采集环境温湿度,再把结果实时刷到 OLED 上,形成一个能独立运行的物联网感知节点 Demo。别小看这个组合,它几乎是每个嵌入式新手从“点灯”迈向“做产品”的必经关卡——单总线协议、微秒级延时、I2C 通信、字库取模,四个坑一个不落全踩一遍。适合谁?刚学完 GPIO 和定时器、想找个综合小项目练手的嵌入式初学者;也适合需要快速验证传感器方案、不想从寄存器手册翻起的在职开发者。代码结构清晰,改起来不费劲,拿来当毕业设计的基础框架或者课程设计的底稿都撑得住场面。

2. 硬件连线与 CubeMX 配置:先把地基打牢

2.1 硬件清单与接线逻辑

这个项目对硬件要求不高,但接线错了后面全是玄学问题。核心三件套:一块 STM32F103C8T6 最小系统板(或者任何带 HAL 库支持的 STM32 型号)、一个 DHT11 温湿度模块(三针或四针都行,四针的忽略 NC 脚)、一块 0.96 寸 OLED 屏(SSD1306 驱动,I2C 接口,四针:VCC、GND、SCL、SDA)。

接线方案我一般这样安排:DHT11 的数据脚接 PA0,OLED 的 SCL 接 PB6、SDA 接 PB7,这是 STM32 的硬件 I2C1 默认引脚,省得软件模拟。DHT11 的 VCC 和 GND 分别接 3.3V 和 GND,OLED 同理。注意 DHT11 的供电范围是 3.3V 到 5.5V,但为了和 STM32 电平匹配,统一用 3.3V 最稳妥。数据线上拉电阻的问题:DHT11 模块本身通常自带 4.7k 到 10k 的上拉,如果你买的是裸传感器,必须在数据线和 VCC 之间手动加一个 4.7k 电阻,否则读出来的数据会随机跳变,这个坑后面避坑章节还会细说。

2.2 CubeMX 里的关键配置项

打开 CubeMX 新建工程,选好芯片型号后按下面几步走。第一步配时钟:在 RCC 里把 HSE 设为 Crystal/Ceramic Resonator,然后到 Clock Configuration 把 HCLK 拉到 72MHz,这是 F103 的标配。第二步配 GPIO:PA0 设为 GPIO_Input,模式选 Pull-up(内部上拉),因为 DHT11 空闲时数据线是高电平,开漏输出需要外部上拉配合。第三步配 I2C1:模式选 I2C,速度设 400kHz 快速模式,其他保持默认。第四步配一个微秒级延时用的定时器:TIM1 或者 TIM2 都行,预分频器设 71,自动重装载值设 65535,这样计数器每加一就是 1 微秒,用来做 DHT11 的时序控制。

配置完生成代码,MDK 或者 STM32CubeIDE 都行。生成的时候注意勾选“Generate peripheral initialization as a pair of .c/.h files”,这样每个外设的初始化代码会单独成文件,后面改起来清爽。

// tim.c 中已经由 CubeMX 生成好初始化代码 // 这里补充一个微秒级延时函数,放在 tim.c 或者单独的工具文件里 void delay_us(uint16_t us) { __HAL_TIM_SET_COUNTER(&htim1, 0); // 计数器清零 HAL_TIM_Base_Start(&htim1); // 启动定时器 while (__HAL_TIM_GET_COUNTER(&htim1) < us); // 等待计数到目标值 HAL_TIM_Base_Stop(&htim1); // 关闭定时器 }

这段代码的逻辑很直白:利用 TIM1 的 1MHz 计数频率,每计数一次就是 1 微秒。__HAL_TIM_SET_COUNTER把计数器归零,然后启动定时器开始累加,while循环里不断读取当前计数值,一旦达到设定的微秒数就跳出循环并停止定时器。参数us就是你要延时的微秒数,DHT11 的时序里最长需要 30 微秒左右的延时,这个函数完全够用。注意不要用 HAL_Delay 来做微秒延时,它的最小分辨率是毫秒,根本满足不了 DHT11 的时序要求。

3. DHT11 驱动层拆解:单总线协议的时序博弈

3.1 单总线通信的起始与响应

DHT11 用的是单根数据线完成双向通信,靠的是严格的时序配合。主机先拉低数据线至少 18 毫秒,然后拉高 20 到 40 微秒,接着释放总线转为输入模式。DHT11 检测到这个起始信号后,会拉低总线 80 微秒作为响应,再拉高 80 微秒,之后才开始传输 40 位数据。这 40 位数据里,前 16 位是湿度整数和小数,中间 16 位是温度整数和小数,最后 8 位是校验和。

// dht11.c 中的起始信号函数 uint8_t DHT11_Start(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; // 切换 PA0 为推挽输出 GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET); // 拉低总线 HAL_Delay(20); // 保持至少 18ms HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET); // 拉高总线 delay_us(30); // 保持 20-40us // 切换 PA0 为输入模式,准备接收响应 GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // 等待 DHT11 拉低响应 if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { // 等待响应低电平结束 while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET); // 等待响应高电平结束 while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET); return 1; // 响应成功 } return 0; // 无响应 }

这段代码的关键在于 GPIO 模式的动态切换。DHT11 是单总线设备,主机发送起始信号时数据线是输出模式,发送完毕后必须立刻切成输入模式,否则 DHT11 的响应信号会和主机的输出打架,轻则读不到数据,重则烧掉引脚。HAL_Delay(20)保证拉低时间超过 18 毫秒,delay_us(30)保证拉高时间落在 20 到 40 微秒之间。后面的两个while循环用来等待 DHT11 的响应低电平和高电平依次结束,如果第一个判断就没检测到低电平,说明传感器没接好或者坏了,直接返回 0。

3.2 40 位数据的读取与校验

响应信号结束后,DHT11 开始逐位传输数据。每一位都以 50 微秒的低电平开始,然后是 26 到 28 微秒的高电平表示“0”,70 微秒的高电平表示“1”。所以读取每一位的逻辑就是:先等低电平过去,然后延时 40 微秒后采样,如果还是高电平就是“1”,否则是“0”。

// 读取一个字节,8 位 uint8_t DHT11_ReadByte(void) { uint8_t data = 0; for (int i = 0; i < 8; i++) { // 等待 50us 低电平结束 while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET); delay_us(40); // 延时 40us 后采样 data <<= 1; if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET) { data |= 1; } // 等待剩余的高电平结束 while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET); } return data; } // 读取完整的温湿度数据 uint8_t DHT11_ReadData(uint8_t *temp, uint8_t *humi) { uint8_t buf[5]; if (DHT11_Start() == 0) return 0; for (int i = 0; i < 5; i++) { buf[i] = DHT11_ReadByte(); } // 校验:前四字节之和等于第五字节 if (buf[0] + buf[1] + buf[2] + buf[3] == buf[4]) { *humi = buf[0]; // 湿度整数部分 *temp = buf[2]; // 温度整数部分 return 1; } return 0; }

DHT11_ReadByte里的delay_us(40)是采样时机的核心。DHT11 的“0”和“1”靠高电平持续时间区分,40 微秒正好卡在两者之间:如果是“0”,高电平只有 26 到 28 微秒,40 微秒后早就变低了;如果是“1”,高电平有 70 微秒,40 微秒时还是高的。这个采样点不能偏,偏了就会把“1”读成“0”或者反过来。DHT11_ReadData里读 5 个字节,前四个是湿度和温度数据,第五个是校验和,校验通过才把值赋给输出参数。注意 DHT11 的小数部分通常都是 0,所以这里只取了整数部分,够用且省事。

4. OLED 显示层:从显存到屏幕的最后一公里

4.1 SSD1306 初始化与显存映射

OLED 屏用的是 SSD1306 驱动芯片,I2C 地址通常是 0x78(8 位地址)或者 0x3C(7 位地址)。初始化流程比较长,但 CubeMX 已经帮我们配好了 I2C 外设,剩下的就是发命令和发数据。SSD1306 的显存是 128x64 位,分成 8 页,每页 8 行,每行 128 个像素。写入数据时按页地址和列地址定位,然后连续写入字节,每个字节的 8 个位对应垂直方向的 8 个像素。

// oled.c 中的写命令和写数据函数 void OLED_WriteCmd(uint8_t cmd) { HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, 0x00, I2C_MEMADD_SIZE_8BIT, &cmd, 1, 100); } void OLED_WriteData(uint8_t data) { HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, 0x40, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); } // 初始化序列,只列关键几条 void OLED_Init(void) { HAL_Delay(100); // 上电等待稳定 OLED_WriteCmd(0xAE); // 关闭显示 OLED_WriteCmd(0x20); // 设置内存寻址模式 OLED_WriteCmd(0x10); // 页寻址模式 OLED_WriteCmd(0xB0); // 页地址从 0 开始 OLED_WriteCmd(0xC8); // 扫描方向 OLED_WriteCmd(0xA1); // 段重映射 OLED_WriteCmd(0x8D); // 电荷泵设置 OLED_WriteCmd(0x14); // 开启电荷泵 OLED_WriteCmd(0xAF); // 开启显示 }

HAL_I2C_Mem_Write的第二个参数是设备地址,第三个参数是寄存器地址——对 SSD1306 来说,0x00 表示后面跟的是命令,0x40 表示后面跟的是数据。这个区分很关键,写反了屏幕要么不亮要么花屏。初始化序列里0x8D和0x14是开启电荷泵,没有这两条屏幕根本亮不起来,很多人卡在这里以为是接线问题,其实是初始化漏了命令。

4.2 温湿度数据的格式化与刷新

显示部分我一般用一个 8x16 的 ASCII 字库,每个字符占 16 个字节,刚好填满两页。显示字符串的时候按页写入,先写上半页再写下半页。温湿度数据是整数,用sprintf格式化成字符串再送显示。

// 显示温湿度到 OLED void OLED_ShowTempHumi(uint8_t temp, uint8_t humi) { char buf[20]; // 格式化字符串 sprintf(buf, "Temp: %d C", temp); OLED_ShowString(0, 0, (uint8_t *)buf); // 第 0 页开始显示 sprintf(buf, "Humi: %d %%", humi); OLED_ShowString(0, 2, (uint8_t *)buf); // 第 2 页开始显示 OLED_Refresh(); // 统一刷新显存到屏幕 }

OLED_ShowString内部会查字库表,把每个字符的 16 个字节依次写入显存缓冲区。这里用了一个显存缓冲区uint8_t OLED_GRAM[8][128],所有绘制操作先改缓冲区,最后调OLED_Refresh一次性刷到屏幕。这样做的好处是避免频繁 I2C 通信导致闪烁,也方便做局部刷新。sprintf里的%%是为了输出一个百分号,因为%在格式化字符串里是转义字符。温度值后面加个C表示摄氏度,湿度加%表示百分比,显示效果直观。

5. 避坑与常见问题排查:那些让你怀疑人生的瞬间

5.1 数据全是 0 或者 255

现象:OLED 能亮,但温湿度读数始终是 0 或者 255,怎么复位都不变。原因通常是 DHT11 的起始信号没被响应,或者数据线接触不良。先检查 PA0 的接线是否牢固,杜邦线松了是高频事故。然后用万用表量一下数据线在空闲时是不是 3.3V,如果不是,说明上拉电阻没起作用。解决方法是换一根线,或者在数据线和 VCC 之间并一个 4.7k 电阻。还有一种可能是HAL_Delay(20)被中断打断导致拉低时间不够,可以在起始信号前后关一下全局中断。

5.2 读数偶尔跳变或者校验失败

现象:大部分时候读数正常,但每隔几次就出现一次校验失败或者数值突变。原因是 DHT11 的采样周期不能低于 1 秒,如果你在循环里连续调用读取函数,传感器还没完成上一次转换,返回的就是旧数据或者错误数据。解决方法是在DHT11_ReadData调用之后加一个HAL_Delay(1500),保证每次读取间隔超过 1.5 秒。另外电源纹波也会影响 DHT11,如果和电机或者继电器共用电源,最好给 DHT11 的 VCC 加一个 100nF 的去耦电容。

5.3 OLED 不亮或者显示乱码

现象:OLED 通电后完全不亮,或者显示一堆乱码像素。先确认 I2C 地址对不对,0x78 和 0x3C 是两个常见地址,用 I2C 扫描程序扫一下就能确定。如果地址对但还不亮,检查初始化序列里有没有漏掉电荷泵开启命令0x8D和0x14。乱码通常是字库取模方式不对,比如阴码阳码搞反了,或者页地址计算错了。解决方法是换一个标准的 8x16 字库文件,确认取模方式为“列行式、逆向、阴码”。

5.4 编译报错找不到 HAL_I2C_Mem_Write

现象:编译时提示HAL_I2C_Mem_Write未定义。原因是 CubeMX 生成代码时没有使能 I2C 外设,或者stm32f1xx_hal_i2c.h没有被包含进来。解决方法是回到 CubeMX 把 I2C1 设为 I2C 模式重新生成,或者在main.h里手动包含对应的头文件。还有一种可能是你用的是 LL 库而不是 HAL 库,函数名不一样,需要换成 LL_I2C_ 开头的函数。

5.5 定时器延时不准导致时序错乱

现象:DHT11 偶尔能读到数据,但成功率很低,大部分时候超时。原因是delay_us函数用的定时器被其他中断打断,导致实际延时比设定值长。解决方法是把 TIM1 的中断优先级设到最高,或者在delay_us执行期间关中断。另外检查定时器的预分频器是不是 71,如果系统时钟不是 72MHz,预分频值要相应调整,否则 1 微秒的基准就错了。

6. 二次开发与改造思路:从 Demo 到可用节点

这个项目跑通之后,最自然的改造方向是把它变成一个物联网节点。我一般会从三个层面动手:通信层、数据层、应用层。

通信层加一个 ESP8266 或者 ESP32 模块,通过串口 AT 指令连上 WiFi,把温湿度数据推到 MQTT 服务器。HAL 库的 UART 收发很简单,HAL_UART_Transmit和HAL_UART_Receive_IT配合就能搞定。数据层加一个环形缓冲区,把最近几十条温湿度记录存下来,OLED 上除了显示当前值,还能翻页看历史曲线。应用层加一个按键,短按切换显示页面,长按进入设置模式调整报警阈值。

// 一个简单的 MQTT 上报数据拼接示例 void MQTT_PublishTempHumi(uint8_t temp, uint8_t humi) { char payload[64]; sprintf(payload, "{\"temp\":%d,\"humi\":%d}", temp, humi); // 通过串口发送 AT 指令给 ESP8266 HAL_UART_Transmit(&huart1, (uint8_t *)"AT+MQTTPUB=0,\"topic\",\"", 26, 100); HAL_UART_Transmit(&huart1, (uint8_t *)payload, strlen(payload), 100); HAL_UART_Transmit(&huart1, (uint8_t *)"\",0,0\r\n", 7, 100); }

这段代码把温湿度拼成 JSON 格式,然后分三次通过串口发给 ESP8266。实际用的时候要把topic换成你自己的 MQTT 主题,AT 指令的格式也要根据模块固件版本调整。注意HAL_UART_Transmit是阻塞发送,数据量大的时候会卡住主循环,建议改成 DMA 或者中断发送。

另一个改造方向是低功耗。DHT11 和 OLED 都是耗电大户,如果做电池供电的节点,可以在两次采集之间让 STM32 进入 STOP 模式,用 RTC 定时唤醒。OLED 也可以设置成 30 秒无操作自动熄屏,按键唤醒。这些改动不需要动核心驱动,只是在main循环里加状态机就行。

验证改造是否成功的方法很简单:先确保原始项目能稳定读数,然后每加一个功能就用串口打印调试信息,确认数据流没断。我习惯在main循环里加一个 LED 闪烁,频率和采集周期一致,这样一眼就能看出程序有没有跑飞。从那以后我每次改驱动层代码,都强制走一遍“先读固定值、再读传感器、最后上屏幕”的流程,避免一上来就调显示导致问题定位不清。希望帮到你。

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

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

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

立即咨询