☰
STM32 HAL库驱动DHT11与OLED显示:从时序到避坑的完整指南
2026/10/16 4:16:25 网站建设 项目流程

简介:这份资源面向嵌入式与物联网方向的初学者及开发者,围绕STM32 HAL库展开,解决DHT11温湿度采集与OLED实时显示联动的实践问题。包内共193个文件,以59个h头文件、29个c源文件、28个o目标文件及29个crf、30个d依赖文件为主,另有ioc、uvprojx、uvoptx等工程配置与hex、axf、map等编译产物,压缩包约6.9MB,完整保留了从驱动到构建的工程结构。资源涵盖单总线时序读取40位数据、I2C/SPI驱动SSD1306显示、GPIO与时钟配置等知识点,并包含stm32f0xx_hal_tim、i2c、uart、rcc等HAL模块源码,便于对照理解底层实现。目前已有772人学习下载,适合希望打通传感器采集与图形显示链路、积累嵌入式项目经验的读者参考。

1. 从一块“读不出数”的 DHT11 说起:STM32 HAL 库温湿度采集与 OLED 显示环境搭建

很多人第一次把 DHT11 接到 STM32 上,串口打印出来的不是温湿度,而是一串255或者干脆卡死在while里等电平翻转。我见过太多这样的现场:传感器买了、OLED 也焊了、CubeMX 工程也生成了,结果 DHT11 就是不出数,OLED 上永远显示Temp: --.- C。问题往往不在传感器本身,而在时序、上拉电阻、HAL 库延时精度和单总线协议理解这几件事上。这套“HAL 库 + DHT11 + OLED”的组合在物联网课程设计和毕业设计里出现频率极高,属于典型的“看起来简单、装起来玄学”的环境。这篇笔记面向的是手里已经拿到 STM32 开发板、DHT11 模块和 OLED 屏,准备从零把工程跑起来的开发者,尤其是用 CubeMX 生成 HAL 库工程、不想碰寄存器版本的人。我会把版本选择、工程配置、代码落地、验证方法和翻车救援按顺序写清楚,照着走能跑通,跑不通也能知道卡在哪一步。

2. 环境准备与版本选择:STM32CubeMX、HAL 库和 DHT11 模块怎么配

2.1 硬件清单与模块差异

先把东西列清楚,不然后面配 GPIO 的时候容易对不上。这套环境需要的硬件不多,但每一件都有版本或型号上的坑。

硬件常见型号/规格注意点
主控板STM32F103C8T6 最小系统板蓝色药丸板最常见,注意晶振是 8MHz
温湿度传感器DHT11 三针/四针模块三针模块自带 4.7k 上拉,四针裸传感器要自己加
显示模块0.96 寸 OLED,SSD1306 驱动分 I2C 和 SPI 两种,I2C 接线少但地址要确认
下载器ST-Link V2注意驱动装好,设备管理器里能看到
杜邦线母对母若干DHT11 数据线尽量短,长了容易受干扰

DHT11 有两种常见形态:一种是裸的四针传感器,DATA 引脚是开漏输出,必须外接一个 4.7kΩ 到 10kΩ 的上拉电阻到 3.3V;另一种是三针模块,板上已经集成了上拉电阻,直接插线就行。我一般会优先用三针模块,省去焊电阻的麻烦,也减少接触不良的概率。OLED 这边,I2C 版本只需要 SCL、SDA 两根信号线,地址通常是0x78(8 位写地址)或0x3C(7 位地址),不同厂家写法不一样,后面初始化失败多半是地址搞错了。

2.2 软件版本选择与安装

软件侧需要三样东西:STM32CubeMX、对应的 HAL 库固件包、以及一个能编译下载的 IDE。版本选择上,我踩过的坑是 CubeMX 版本和固件包版本不匹配,生成代码时直接报错。

  • STM32CubeMX:建议用 6.10 或 6.11 这类较新的稳定版。6.x 系列对 F1 系列支持很成熟,界面也比 5.x 顺手。安装时注意 Java 环境,Windows 下安装包自带 JRE,Linux 下要自己装 OpenJDK 11 以上。
  • HAL 固件包:在 CubeMX 里通过Help -> Manage embedded software packages下载STM32F1系列的最新版,比如STM32Cube FW_F1 V1.8.5。不要用太老的 1.6 以下版本,HAL 库的HAL_Delay和HAL_GetTick在旧版里有精度问题。
  • IDE:Keil MDK 5 或者 STM32CubeIDE 都行。CubeIDE 免费且和 CubeMX 集成好,我一般推荐新手直接用 CubeIDE,省去 Keil 的 Pack 安装和 License 问题。

安装 CubeMX 时有个细节:路径不要带中文和空格,否则生成代码时可能找不到固件包。CubeIDE 安装同理,工作空间路径也保持纯英文。这些看起来是小事,但每年都有人的工程因为路径问题编译不过。

2.3 CubeMX 工程创建与时钟配置

打开 CubeMX,新建工程,在Part Number里搜STM32F103C8,选中后开始配置。第一步是RCC设置:把High Speed Clock (HSE)选为Crystal/Ceramic Resonator,因为最小系统板外部是 8MHz 晶振。然后进Clock Configuration标签,把HCLK拉到 72MHz,CubeMX 会自动算好分频系数。这一步不做的话,后面HAL_Delay的延时基准不对,DHT11 的微秒级延时全乱。

# 时钟配置要点(在 CubeMX 界面操作,这里用文字记录参数) # HSE: Crystal/Ceramic Resonator # PLL Source: HSE # PLL Mul: 9 # AHB Prescaler: 1 # APB1 Prescaler: 2 # APB2 Prescaler: 1 # 最终 HCLK = 8MHz * 9 = 72MHz

上面这组参数是 F103 跑 72MHz 的标准配法。PLL 倍频 9 倍,AHB 不分频,APB1 二分频得到 36MHz(因为 APB1 最高 36MHz),APB2 不分频得到 72MHz。时钟配错的表现是串口乱码或者延时明显偏长,DHT11 读取时机会全部错位。

2.4 GPIO 与 I2C 外设配置

DHT11 的数据引脚我一般接在PA0,配置为GPIO_Output开漏输出,初始电平高。为什么用开漏?因为 DHT11 是单总线协议,主机和从机要分时驱动同一条线,开漏加上拉才能实现“线与”逻辑。如果你用的是三针模块,模块内部已经有上拉,CubeMX 里仍然要把引脚配成Output Open Drain。

OLED 的 I2C 我接在PB6(SCL)和PB7(SDA),在 CubeMX 里把I2C1设为I2C模式,速度用Standard Mode100kHz 就行,SSD1306 支持 400kHz 但长线容易出错,100kHz 更稳。配置完后检查GPIO Settings,确认 I2C 引脚自动变成了Alternate Function Open Drain。

// CubeMX 生成的 GPIO 初始化片段(在 gpio.c 中) GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull = GPIO_PULLUP; // 内部上拉,外部再加 4.7k 更稳 GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

这段代码里GPIO_MODE_OUTPUT_OD是关键,配成推挽输出的话,DHT11 拉低总线时主机会和从机打架,读出来的数据全是错的。Pull选GPIO_PULLUP启用内部上拉,但内部上拉电阻较大(约 40k),建议外部再并一个 4.7k 到 3.3V。

3. DHT11 驱动代码落地:微秒延时、单总线时序与数据校验

3.1 微秒延时函数的实现

HAL 库自带的HAL_Delay是毫秒级,DHT11 的时序要求微秒级,所以必须自己写一个delay_us。最常用的做法是用 SysTick 或者 DWT 计数器。我一般用 DWT,因为不占用定时器资源,精度也够。

// dht11.c 中的微秒延时,基于 DWT void DHT11_Delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }

DWT->CYCCNT是 Cortex-M3 内核的数据观察点计数器,每个时钟周期加一。SystemCoreClock是 72MHz,所以 1 微秒对应 72 个 tick。用之前要在main里使能 DWT:

// main.c 中使能 DWT CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;

这三行不写的话,DWT->CYCCNT永远是 0,delay_us会直接死循环。很多人卡在这里,以为传感器坏了,其实是 DWT 没开。

3.2 单总线起始信号与响应检测

DHT11 的通信流程是:主机拉低总线至少 18ms,然后拉高 20-40us,接着释放总线,等待 DHT11 响应。响应信号是 DHT11 先拉低 80us,再拉高 80us,之后开始传 40 位数据。

// 主机发送起始信号 void DHT11_Start(void) { DHT11_Set_Pin_Output(); // 切换为输出 HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); HAL_Delay(20); // 拉低至少 18ms,这里给 20ms HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); DHT11_Delay_us(30); // 拉高 30us DHT11_Set_Pin_Input(); // 切换为输入,释放总线 }

HAL_Delay(20)是毫秒级,这里用 HAL 库自带的就行,因为 18ms 的要求不严格。拉高 30us 后必须把引脚切成输入模式,否则主机会一直驱动总线,DHT11 没法拉低回应。切换输入的函数就是改 GPIO 模式:

void DHT11_Set_Pin_Input(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(DHT11_PORT, &GPIO_InitStruct); }

3.3 40 位数据的读取与校验

DHT11 的 40 位数据是:8 位湿度整数 + 8 位湿度小数 + 8 位温度整数 + 8 位温度小数 + 8 位校验和。校验和等于前四个字节相加的低 8 位。读取每一位的逻辑是:等待 50us 低电平结束,然后高电平持续 26-28us 表示 0,持续 70us 表示 1。

// 读取一个字节 uint8_t DHT11_Read_Byte(void) { uint8_t byte = 0; for (int i = 0; i < 8; i++) { while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET); // 等低电平结束 DHT11_Delay_us(40); // 延时 40us 后采样 byte <<= 1; if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { byte |= 1; } while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET); // 等高电平结束 } return byte; }

DHT11_Delay_us(40)是采样点,因为 0 的高电平只有 26-28us,40us 后已经变低;1 的高电平有 70us,40us 时还是高。这个 40us 是经验值,不同批次的 DHT11 可能有几微秒偏差,如果读出来全是 0 或全是 1,可以把 40 改成 35 或 45 试试。读完 5 个字节后做校验:

uint8_t checksum = humi_int + humi_dec + temp_int + temp_dec; if (checksum == data[4]) { // 数据有效 } else { // 校验失败,丢弃本次数据 }

校验失败是常态,尤其是杜邦线接触不良或者电源不稳的时候。我的做法是连续读三次,取校验通过的那次,三次都失败就返回错误码,OLED 上显示Error。

3.4 读取失败的重试与超时处理

上面的while循环没有超时,如果 DHT11 没接好,程序会死在那里。生产环境里必须加超时计数:

uint32_t timeout = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) { if (++timeout > 10000) return 0; // 超时返回错误 }

timeout的阈值根据主频调整,72MHz 下 10000 次循环大约几毫秒,足够覆盖正常的 80us 响应。超时后直接返回错误,让上层决定是重试还是报错。这个改动看起来小,但能让你的程序在传感器掉线时不会卡死,OLED 刷新也不会停。

4. OLED 显示驱动:SSD1306 初始化、字库与刷新逻辑

4.1 I2C 地址确认与初始化序列

SSD1306 的 I2C 地址有0x78和0x7A两种常见写法,取决于模块上SA0引脚的电平。0x78是 8 位写地址,对应 7 位地址0x3C;0x7A对应0x3D。HAL 库的HAL_I2C_Master_Transmit用的是 8 位地址,所以直接填0x78或0x7A。

#define OLED_ADDR 0x78 // 如果屏幕不亮,改成 0x7A 试试 void OLED_Init(void) { HAL_Delay(100); // 上电后等 100ms,SSD1306 需要稳定时间 uint8_t cmd[] = { 0xAE, // 关闭显示 0x20, 0x00, // 水平寻址模式 0xB0, // 页地址 0xC8, // COM 扫描方向 0x00, 0x10, // 列地址低位和高位 0x40, // 起始行 0x81, 0xFF, // 对比度 0xA1, // 段重映射 0xA6, // 正常显示 0xA8, 0x3F, // 多路复用比 0xA4, // 输出跟随 RAM 0xD3, 0x00, // 显示偏移 0xD5, 0x80, // 时钟分频 0xD9, 0xF1, // 预充电周期 0xDA, 0x12, // COM 硬件配置 0xDB, 0x40, // VCOMH 电压 0x8D, 0x14, // 电荷泵使能 0xAF // 开启显示 }; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, cmd, sizeof(cmd), 100); }

这段初始化序列里0x8D, 0x14是电荷泵使能,不写的话屏幕可能亮但很暗或者完全不亮。0xAF是最后开启显示,放在最后。如果屏幕一直不亮,先确认地址,再确认0x8D后面跟的是0x14而不是0x10。

4.2 字库取模与显示函数

OLED 显示文字需要字库,常用的是 8x16 和 6x8 两种 ASCII 字库。取模软件用 PCtoLCD2002,设置成“阴码、逐列式、顺向”,生成的字库数组直接放到oledfont.h里。

// 显示一个 8x16 的字符 void OLED_ShowChar(uint8_t x, uint8_t y, char chr) { uint8_t c = chr - ' '; // 计算字库偏移 for (uint8_t i = 0; i < 8; i++) { OLED_Write_Data(F8X16[c * 16 + i]); // 上半部分 OLED_Write_Data(F8X16[c * 16 + i + 8]); // 下半部分 } }

F8X16是字库数组,每个字符占 16 字节,前 8 字节是上半页,后 8 字节是下半页。OLED_Write_Data就是往 I2C 发数据,前面加0x40控制字节表示数据模式。显示温湿度的时候,先把数值转成字符串:

char buf[20]; sprintf(buf, "Temp: %d.%d C", temp_int, temp_dec); OLED_ShowString(0, 0, (uint8_t *)buf); sprintf(buf, "Humi: %d.%d %%", humi_int, humi_dec); OLED_ShowString(0, 2, (uint8_t *)buf);

sprintf在单片机里比较占资源,但 F103C8T6 的 Flash 和 RAM 够用。如果嫌大,可以用itoa自己拼字符串。

4.3 刷新频率与 I2C 速度的取舍

DHT11 的采样周期不能低于 1 秒,所以 OLED 刷新也跟着 1 秒一次就行。I2C 速度用 100kHz 时,刷一屏 128x64 大约需要 20-30ms,完全来得及。如果 I2C 配到 400kHz,刷新更快,但杜邦线长的时候容易出错,表现为屏幕局部花屏或者随机闪烁。我一般先用 100kHz 跑通,再根据线长决定要不要提速。

// 主循环中的刷新逻辑 while (1) { if (DHT11_Read_Data(&temp_int, &temp_dec, &humi_int, &humi_dec) == 0) { OLED_Clear(); sprintf(buf, "Temp: %d.%d C", temp_int, temp_dec); OLED_ShowString(0, 0, (uint8_t *)buf); sprintf(buf, "Humi: %d.%d %%", humi_int, humi_dec); OLED_ShowString(0, 2, (uint8_t *)buf); } else { OLED_ShowString(0, 4, (uint8_t *)"Sensor Error"); } HAL_Delay(1000); // 1 秒刷新一次 }

OLED_Clear每次刷新前清屏,避免旧数据残留。如果不想闪屏,可以只更新变化的部分,但代码会复杂一些,新手先用清屏方案。

5. 避坑与排查:DHT11 读数为 255、OLED 不亮、程序卡死的处理

5.1 读数全是 255 或 0

现象:串口打印出来的温湿度全是255或者0,校验和永远不对。

原因:最常见的是上拉电阻缺失或阻值不对。三针模块一般自带 4.7k 上拉,但如果你的模块是裸传感器,DATA 引脚没有上拉,总线在空闲时不是高电平,DHT11 无法拉低回应。另一个原因是delay_us不准,采样点偏移。

解决:先用万用表量 DATA 引脚在空闲时是不是 3.3V,如果不是,加一个 4.7k 电阻到 3.3V。然后检查 DWT 是否使能,DWT->CYCCNT是否在变化。如果都正常,把DHT11_Delay_us(40)改成 35 或 45 试几次。

5.2 OLED 完全不亮

现象:OLED 接上后没有任何显示,背光也不亮。

原因:I2C 地址错误占一半,初始化序列里电荷泵没使能占三成,剩下的是接线问题。SSD1306 的地址0x78和0x7A很容易搞混,尤其是模块背面丝印不清晰的时候。

解决:写一个 I2C 扫描程序,遍历0x00到0x7F,看哪个地址有应答。确认地址后,检查初始化序列里有没有0x8D, 0x14。如果屏幕微亮但不显示内容,多半是对比度或电荷泵问题。

5.3 程序卡死在 while 等待循环

现象:程序下载后卡在DHT11_Read_Byte的while里,看门狗复位或者直接死机。

原因:DHT11 没接好或者供电不足,总线一直是被拉低状态,while等不到高电平。另外,如果 DHT11 的 VCC 接的是 5V 而 STM32 是 3.3V,数据线电平不匹配,也会读不到。

解决:给所有while等待加超时计数,超时返回错误。确认 DHT11 的 VCC 是 3.3V 还是 5V,如果是 5V 供电,数据线要加电平转换或者串一个 1k 电阻。杜邦线换短的,接触不良也会导致这个问题。

5.4 校验和偶尔失败

现象:大部分时候读数正常,但每隔几次就有一次校验失败,OLED 上闪一下Error。

原因:DHT11 的采样间隔太短。DHT11 内部有个 1Hz 的采样周期,如果两次读取间隔小于 1 秒,第二次读到的可能是上一次的残留数据或者直接失败。

解决:主循环里HAL_Delay(1000)不能省,至少等 1 秒再读下一次。如果还是偶尔失败,连续读三次取有效值,三次都失败才报错。这个策略在实际项目里很实用,能过滤掉大部分偶发干扰。

5.5 CubeMX 重新生成代码后用户代码丢失

现象:在 CubeMX 里改了个引脚配置,重新生成代码,发现自己写的 DHT11 和 OLED 代码全没了。

原因:CubeMX 只会保留/* USER CODE BEGIN */和/* USER CODE END */之间的内容,写在别处的代码会被覆盖。

解决:所有用户代码都写在USER CODE注释块里。驱动文件单独建dht11.c和oled.c,在main.c里只调用函数,这样重新生成也不会丢。这个习惯我从第一次被覆盖后就养成了,血泪经验。

6. 装完怎么验证跑通:从串口打印到 OLED 稳定显示的检查清单

验证这套环境是否真正跑通,不能只看 OLED 亮没亮,要按信号链一层层确认。我一般会走一遍下面这个清单,每一步都有明确的通过标准。

检查项方法通过标准
时钟配置串口打印SystemCoreClock输出 72000000
DWT 延时翻转 GPIO,示波器或逻辑分析仪测1us 延时误差小于 10%
DHT11 响应读起始信号后的电平能看到 80us 低 + 80us 高
数据校验连续读 10 次,统计校验通过率通过率 90% 以上
OLED 地址I2C 扫描在 0x78 或 0x7A 有应答
显示刷新观察 1 分钟无花屏、无闪烁、数值随环境变化

串口打印是最直接的验证手段。在main里加一句printf("SystemCoreClock: %lu\n", SystemCoreClock);,重定向fputc到 USART1,波特率 115200。如果打印出来不是 72000000,说明时钟树配错了,后面所有延时都会偏。

DHT11 的响应信号用逻辑分析仪看最清楚,没有的话可以用一个简单的示波器,甚至用另一个 GPIO 翻转来间接判断。我一般会在DHT11_Start之后加一句翻转 LED 的代码,如果 LED 闪了说明程序走到了响应检测,没闪就是卡在起始信号之前。

校验通过率的统计很能说明问题。写一个循环读 10 次,每次把校验结果通过串口打印出来。如果 10 次里失败超过 3 次,就要检查上拉电阻和线长。我遇到过一根 20cm 的杜邦线导致通过率只有 50%,换成 10cm 后直接到 100%。

OLED 的稳定性要观察一段时间。有些屏幕刚上电正常,过几分钟开始花屏,这是 I2C 速度太快或者电源纹波太大。把 I2C 降到 100kHz,VCC 和 GND 之间并一个 100nF 电容,基本能解决。

最后一步是让整个系统连续跑 10 分钟,OLED 上的温湿度数值应该随环境缓慢变化,比如用手捂住 DHT11,湿度会上升,温度会向体温靠拢。如果数值一动不动,检查DHT11_Read_Data的返回值是不是一直被忽略,OLED 显示的是初始值而不是实时值。

从那以后我每次搭这套环境,都会先跑一遍串口打印时钟和 DWT 延时,再上 DHT11 和 OLED。这个顺序能让我在 10 分钟内定位是配置问题还是硬件问题,而不是对着不亮的屏幕瞎猜。希望帮到你。

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

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

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

立即咨询