news 2026/9/10 1:08:20

STM32 HAL库驱动DHT11+OLED完整教程:从时序到调试的实战总结

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 HAL库驱动DHT11+OLED完整教程:从时序到调试的实战总结

简介:一套基于STM32 HAL库的物联网入门项目,面向嵌入式开发者,演示DHT11温湿度传感器数据采集与OLED屏实时显示。工程涵盖传感器时序解析、I2C/GPIO配置、SSD1306驱动调用等关键环节,适合学习HAL库外设操作与小型显示方案集成。压缩包共193个文件,以C源文件(29个c)、头文件(59个h)及工程配置文件(uvprojx、ioc、uvoptx)为主体,附带编译生成的axf、hex等文件,整体6.9MB,目录结构清晰便于对照学习。项目最大的价值在于打通从外设初始化、数据采集到界面渲染的完整链路,覆盖单总线时序、数据校验、OLED驱动移植等实战细节,可直接迁移至智能家居或环境监测等物联网场景。资源已有772人学习,对初学者快速上手HAL库开发很有帮助。 搞嵌入式这一年多,最大的感受就是:看似简单的传感器,真正拿到工程里用起来,坑往往不在传感器本身,而在你选的库、走的通信方式、以及时序的细节处理上。DHT11温湿度传感器加上OLED显示,算是最经典的入门组合了,网上教程一抓一大把,但大多数都是标准库的老代码,或者直接抄来的延时凑时序。这次我直接用HAL库从零开始写了一遍,把DHT11的单总线时序、OLED的I2C驱动、以及两者配合时的调度问题全部趟了一遍,把过程和踩过的坑都记下来,给后面走这条路的朋友一个参考。

这篇东西适合谁看?如果你已经会用STM32CubeMX建工程、知道怎么点灯、用过串口,但还没系统搞过单总线传感器和OLED屏,那这篇文章基本就是为你写的。如果你是大佬,可以直接跳到第4节看我在时序校准和调度上踩的坑,那部分应该是常规教程里不会写的东西。

1. 为什么选HAL库来做DHT11+OLED,而不是继续用标准库

先说一个争论了很久的问题:DHT11这种对时序要求极端的传感器,到底适不适合用HAL库?很多老工程师会说HAL库函数调用开销大、延时不准、中断优先级乱七八糟,根本不该拿来驱动DHT11这种单总线设备。这话放在五六年前有一定道理,但HAL库发展到现在,配合CubeMX生成的代码框架,只要你会正确配置定时器或者用好systick,时序精度完全是够用的。

我自己选HAL库的核心原因是工程可维护性。标准库写DHT11的驱动,代码量确实少,但一旦你要在这个项目里加上OLED、加上按键、加上串口调试、甚至后面想上RTOS,标准库的代码组织方式就会变得非常痛苦。HAL库虽然函数调用层级深,但它把硬件抽象做得很好,CubeMX生成的初始化代码几乎不用改,你只需要专注在业务逻辑上。

另外一个很现实的原因是学习成本。现在新出的STM32芯片、开发板资料,几乎全是HAL库的,CubeMX生成的工程结构已经成了事实标准。你花一周时间适应HAL库的写法,换来的是一劳永逸的工程框架能力。而我实际测试下来,在72MHz主频的STM32F103C8T6上,HAL库驱动DHT11的数据读取误差完全在可接受范围内,温度精度±1℃,湿度精度±1%RH,这是DHT11本身的精度极限,跟库的关系不大。

1.1 整体方案选型:用GPIO模拟单总线,还是硬件外设

DHT11用的是单总线协议,STM32没有专用的单总线外设。市面上常见做法有三种:

  • 纯GPIO模拟:直接用普通GPIO口拉高拉低,靠延时函数控制时序。最简单,但对延时精度要求高。
  • GPIO+定时器:GPIO控制电平,定时器精确计时,适合对时序要求苛刻的场景。
  • GPIO+外部中断:监听DHT11的应答信号,靠中断记录高低电平持续时间。代码最复杂,但最稳。

我这次选的是纯GPIO模拟+HAL库的HAL_DelayDWT时钟计数器配合的方式。为什么不用纯HAL_Delay?因为HAL_Delay基于Systick,最小精度是1ms,而DHT11的时序要求是微秒级的,差一个毫秒整个通信就废了。所以我的方案是:毫秒级的延时用HAL_Delay,微秒级的延时用DWT(Data Watchpoint and Trace)硬件计数器,这个后面详细说。

OLED方面我用的是I2C接口的SSD1306,0.96寸128x64分辨率。选I2C而不是SPI,主要是省引脚:I2C只需要SCL和SDA两根线,加上电源和地,总共四根线就搞定了。STM32F103C8T6的I2C1在PB6和PB7上,正好空出来用。

1.2 硬件连接与引脚分配

先给一张我实际使用的引脚分配表,照着接就不会出错:

器件引脚STM32引脚说明
DHT11 DATA数据脚PA0配置为开漏输出+上拉,兼容输入输出
DHT11 VCC电源3.3VDHT11供电范围3.3V-5V,3.3V可用
DHT11 GNDGND共地
OLED SCL时钟PB6I2C1_SCL,复用开漏
OLED SDA数据PB7I2C1_SDA,复用开漏
OLED VCC电源3.3VSSD1306支持3.3V
OLED GNDGND共地

有一点要特别注意:DHT11的数据脚建议接一个4.7kΩ上拉电阻到VCC。虽然DHT11模块上通常已经自带上拉电阻了,但如果你用的是裸传感器而不是模块,这个上拉必须自己加。没有上拉电阻,DHT11的数据线无法被正确拉高,通信时序直接崩。我一开始用的是裸传感器,忘了加上拉,折腾了半个小时,后来仔细看了数据手册才想起来这回事。

2. DHT11单总线协议解析:时序才是真正的核心考点

DHT11能成为入门神器,一方面是因为便宜好用,另一方面是因为它的通信协议足够简单——单根数据线,40bit数据一次性传完。但"简单"不等于"容易",恰恰是因为协议简单,它对时序的要求反而非常苛刻。DHT11的数据手册里所有时间参数都是微秒级的,最严格的地方是"数据位0"和"数据位1"的区分,两者的高电平持续时间差异只有大概26微秒

DHT11的一次完整通信过程可以拆成四个阶段:

  1. 主机发起起始信号:主机将数据线拉低至少18ms,然后释放并拉高20-40us,等待DHT11响应。
  2. DHT11应答信号:DHT11检测到起始信号后,拉低数据线80us,再拉高80us,表示"我准备好了"。
  3. 数据输出:DHT11依次输出40bit数据,高位先出,格式是:湿度整数部分8bit + 湿度小数部分8bit + 温度整数部分8bit + 温度小数部分8bit + 校验和8bit。
  4. 通信结束:DHT11释放数据线,回到空闲状态。

其中最关键的判断逻辑在数据位读取:每一位数据都是以50us的低电平开始,然后是一个高电平。如果高电平持续约26-28us,表示这一位是"0";如果高电平持续约70us,表示这一位是"1"。你只需要测量每个bit的高电平持续时间,然后跟一个阈值比较(通常取50us),就能判断出0和1。

这里我要提醒一个很容易被忽略的点:DHT11唤醒之后,从你发出起始信号到DHT11真正开始应答,中间有20-40us的"释放总线"时间。很多初学者在这个阶段没加延时,直接就开始读数据线电平,导致读到的永远是高电平,然后一脸懵。正确的做法是在起始信号结束后,先把GPIO切成输入模式,然后耐心等DHT11把数据线拉低,这个等待是有时间窗口的——如果超过100us数据线还没被拉低,说明DHT11没被唤醒,应该重新发起通信。

2.1 40bit数据的校验逻辑

DHT11的数据帧里,前四个字节是真实的温湿度数据,第五个字节是校验和。校验规则极其简单:前四个字节相加,取低8位,如果等于校验字节,说明数据传输正确

比如实测数据:湿度整数=45,湿度小数=0,温度整数=25,温度小数=0,那么前四个字节相加就是45+0+25+0=70(0x46),校验字节也应该是0x46。如果校验失败,直接丢弃这帧数据,等下一次采样。

校验这个步骤一定不能省,哪怕你是在实验室环境测试。我真实遇到过一次:数据线没插紧,导致某个bit读取错误,温度直接跳到了-38℃。如果没有校验逻辑,这个错误数据就直接显示在OLED上了,你还会以为是传感器坏了。

2.2 时序参数表与实测值

DHT11数据手册给出的典型时序参数,以及我实际用逻辑分析仪抓到的时间值,对比如下:

参数手册标称值实测值说明
起始信号低电平时间至少18ms20ms用HAL_Delay(20)实现
起始信号后高电平时间20-40us30usDWT延时
DHT11应答低电平80us78us实测接近
DHT11应答高电平80us82us实测接近
数据位0高电平26-28us27us阈值判断用50us
数据位1高电平70us68us实测接近
数据位低电平前缀50us48us每位数据开始

逻辑分析仪是这个项目里最值得投资的调试工具,淘宝几十块钱的24MHz 8通道逻辑分析仪就够用了。没有它之前,我写时序全靠猜,出了问题只能瞎试;有了它之后,一眼就能看出是高电平时间不对还是低电平时间不对,排查效率提升十倍不止。

3. HAL库工程搭建:CubeMX配置和代码框架

开始写代码之前,先把CubeMX的工程配置说清楚。我用的是STM32F103C8T6,也就是最常见的蓝色Pill板,主频设到72MHz。

CubeMX里需要配置的内容如下:

  • RCC:HSE选择Crystal/Ceramic Resonator,外部8MHz晶振。
  • Clock Configuration:HCLK设为72MHz,APB1分频器设为/2(36MHz),APB2设为/1。
  • GPIO
    • PA0配置为GPIO_MODE_OUTPUT_OD(开漏输出),上拉GPIO_PULLUP,初始电平高。这个引脚用于DHT11数据通信,开漏输出可以方便地在输入输出之间切换。
    • PB6和PB7由I2C1自动配置,不用手动设置。
  • I2C1:选择I2C模式,速度设为100KHz(Standard Mode),其他参数默认。SSD1306完全兼容100KHz,不需要上400KHz。
  • USART1:用于调试信息输出,可选,但强烈建议开。我配置的是115200-8-N-1,PA9/PA10。

生成的代码框架之后,你需要在main.c里做的核心事情是:

  1. 实现微秒级延时函数(用DWT)。
  2. 实现DHT11的GPIO输入输出切换。
  3. 实现DHT11的时序读取函数。
  4. 移植SSD1306驱动。
  5. 在主循环里做数据采集和显示刷新。

3.1 DWT微秒延时:HAL库下最可靠的延时方案

前面说过,HAL_Delay只能做到毫秒级,而DHT11需要微秒级延时。在HAL库环境下,实现微秒延时有三个常见方案:

  • 空的for循环:简单但不可靠,编译优化级别变了,延时就不准了。弃用。
  • 定时器延时:准确但浪费一个定时器外设,而且调用HAL_GetTick()的封装有开销。
  • DWT延时:ARM Cortex-M3内核自带的调试硬件计数器,精度高,不占用额外外设,强烈推荐。

DWT(Data Watchpoint and Trace)是Cortex-M3内核里的一个调试组件,它内部有一个32位的自由运行计数器CYCCNT,每个CPU时钟周期加1。72MHz主频下,它的分辨率就是1/72MHz≈13.9ns,用来做微秒级延时绰绰有余。

启用DWT的代码非常简洁:先通过CoreDebug->DEMCR使能DWT,然后通过DWT->CTRL使能CYCCNT计数器,最后等待计数器清零。实测下来整个初始化过程不到10行代码:

static void DWT_Delay_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; DWT->CYCCNT = 0; } static void DWT_Delay_us(uint32_t us) { uint32_t startTick = DWT->CYCCNT; uint32_t delayTicks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - startTick) < delayTicks); }

核心逻辑很简单:读取当前计数器值,计算目标延迟对应的时钟周期数,然后死等。后台while循环里没有函数调用,没有任何中断开销,精度非常高。我在逻辑分析仪上实测过,DWT_Delay_us(50)的实际延时误差在1us以内,完全满足DHT11的时序要求。

3.2 DHT11驱动代码实现与逐段解释

下面是DHT11驱动最核心的代码,我把关键逻辑注释进去了。这个驱动思路参考了ST官方的应用笔记,但在时序细节上做了修正和实测校准。

// 定义GPIO切换宏,PA0作为数据脚 #define DHT11_GPIO_Port GPIOA #define DHT11_GPIO_Pin GPIO_PIN_0 // 将PA0切换为输入模式 static void DHT11_Pin_Input(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_GPIO_Pin; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(DHT11_GPIO_Port, &GPIO_InitStruct); } // 将PA0切换为输出模式 static void DHT11_Pin_Output(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_GPIO_Pin; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_GPIO_Port, &GPIO_InitStruct); } // 读取一位数据,返回0或1 static uint8_t DHT11_ReadBit(void) { uint16_t highTime = 0; // 等待低电平结束(50us低电平前缀) while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_GPIO_Pin) == GPIO_PIN_RESET); // 低电平结束后,开始计时高电平持续时间 while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_GPIO_Pin) == GPIO_PIN_SET) { highTime++; DWT_Delay_us(1); if (highTime > 100) break; // 超时保护 } // 高电平时间超过50us,判定为1,否则为0 return (highTime > 50) ? 1 : 0; } // 读取DHT11的40bit数据 uint8_t DHT11_ReadData(uint8_t *humidity, uint8_t *temperature) { uint8_t data[5] = {0}; // 1. 主机发送起始信号:拉低至少18ms DHT11_Pin_Output(); HAL_GPIO_WritePin(DHT11_GPIO_Port, DHT11_GPIO_Pin, GPIO_PIN_RESET); HAL_Delay(20); // 拉低20ms,超过手册要求的18ms HAL_GPIO_WritePin(DHT11_GPIO_Port, DHT11_GPIO_Pin, GPIO_PIN_SET); DWT_Delay_us(30); // 释放总线20-40us // 2. 切换为输入,等待DHT11应答 DHT11_Pin_Input(); // 等待DHT11将总线拉低(应答开始),超时100us uint16_t waitCnt = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_GPIO_Pin) == GPIO_PIN_SET) { DWT_Delay_us(1); if (++waitCnt > 100) return 1; // 无应答,返回错误 } // 应答低电平80us waitCnt = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_GPIO_Pin) == GPIO_PIN_RESET) { DWT_Delay_us(1); if (++waitCnt > 100) return 1; } // 应答高电平80us waitCnt = 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_GPIO_Pin) == GPIO_PIN_SET) { DWT_Delay_us(1); if (++waitCnt > 100) return 1; } // 3. 连续读取40bit数据 for (int i = 0; i < 40; i++) { data[i / 8] <<= 1; data[i / 8] |= DHT11_ReadBit(); } // 4. 校验 uint8_t checksum = (data[0] + data[1] + data[2] + data[3]) & 0xFF; if (checksum != data[4]) return 2; // 校验失败 *humidity = data[0]; *temperature = data[2]; return 0; // 成功 }

这段代码有几个关键细节值得说明:

开漏输出+输入切换是DHT11通信的正确姿势。开漏输出模式下,GPIO输出低电平可以主动拉低总线,输出高电平时实际上是把总线释放给上拉电阻。这样切换输入模式时,不需要额外的方向寄存器操作,也不会出现推挽输出高电平时跟DHT11驱动的低电平打架的情况。

超时保护是必须的。如果DHT11没有接入、线断了、或者芯片损坏,总线会一直保持高电平。没有超时保护的话,你的程序会卡死在等待应答的死循环里,整个系统看起来就跟死机了一样。我在读取bit的高电平等待里也加了100us的上限,防止异常数据导致死循环。

读取bit时的高电平时间判断,用了一个简单的循环计数。每计数1次延时1us,然后判断是否超过50。这个阈值是有讲究的:DHT11的"0"高电平是26-28us,"1"的高电平是70us,50us正好处于两者中间,分类错误率最低。如果你用的主频不是72MHz,这个阈值也需要相应调整——具体做法是用逻辑分析仪抓一下实际波形,看你的"1"高电平是多少us,然后取"0"和"1"的中间值。

3.3 SSD1306 OLED驱动的移植要点

OLED显示部分,我用的是SSD1306驱动芯片的0.96寸屏,I2C接口。网上SSD1306的驱动代码很多,但大多是Arduino或者标准库的版本。在HAL库下移植,核心工作是重写底层I2C发送函数。

SSD1306的驱动逻辑分为三层:

  1. 底层I2C发送:通过HAL库的HAL_I2C_Mem_Write向SSD1306发送命令或数据。
  2. 中间层画点/画线:在显存数组上操作像素点。
  3. 上层显示函数:显示字符串、汉字、数字等。

这里重点说底层。SSD1306的I2C地址一般是0x78(7位地址0x3C左移一位)。发送数据时,需要先发送控制字节:0x00表示后续是命令,0x40表示后续是显存数据

// 向SSD1306发送命令 void SSD1306_WriteCmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; HAL_I2C_Master_Transmit(&hi2c1, 0x78, buf, 2, 100); } // 向SSD1306写显存数据 void SSD1306_WriteData(uint8_t *data, uint16_t size) { uint8_t buf[size + 1]; buf[0] = 0x40; memcpy(&buf[1], data, size); HAL_I2C_Master_Transmit(&hi2c1, 0x78, buf, size + 1, 100); }

显存缓存方案:SSD1306有128x64=8192个像素点,1bit对应一个像素,所以整屏显存是8192/8=1024字节。我直接在MCU内存里开一个1024字节的数组当显存,所有画点操作都在这块数组上进行,需要刷新时才一次性把整块显存推给SSD1306。

uint8_t SSD1306_Buffer[1024]; // 画点函数 void SSD1306_DrawPixel(uint8_t x, uint8_t y, uint8_t color) { if (x > 127 || y > 63) return; if (color == 1) { SSD1306_Buffer[x + (y / 8) * 128] |= (1 << (y % 8)); } else { SSD1306_Buffer[x + (y / 8) * 128] &= ~(1 << (y % 8)); } } // 整屏刷新 void SSD1306_Refresh(void) { SSD1306_WriteCmd(0x21); // 设置列地址范围 SSD1306_WriteCmd(0x00); SSD1306_WriteCmd(0x7F); SSD1306_WriteCmd(0x22); // 设置页地址范围 SSD1306_WriteCmd(0x00); SSD1306_WriteCmd(0x07); for (int i = 0; i < 8; i++) { SSD1306_WriteData(&SSD1306_Buffer[i * 128], 128); } }

需要注意的坑:I2C传输1024字节的显存,在100KHz速率下大约需要80-100ms左右。如果你在每次读完DHT11之后立刻刷新OLED,整个刷新过程会阻塞CPU,导致你无法及时响应其他任务。我的做法是降低刷新频率:DHT11每2秒采样一次,OLED每1秒刷新一次,并且通过HAL_I2C_Master_Transmit的超时参数做了保护,就算I2C异常也不会把系统卡死。

3.4 中文字库和显示排版

OLED显示汉字是很多新手卡壳的地方。SSD1306内部没有字库,只能用点阵方式显示。我用的方案是16x16的汉字点阵,每个汉字需要32字节数据。可以先用取模软件(比如PCtoLCD2002)把要显示的汉字生成C语言数组,然后按行列扫描方式写入显存。

我项目里显示的内容布局如下:

第1行:温度: 25 C (16x16汉字,2倍大小容易出界,所以用1倍) 第2行:湿度: 45 %RH 第3行:状态: OK/ERROR 第4行:时间戳: 12345s

OLED的128x64分辨率虽然不大,但合理排版还是能显示很多信息。温度和小数这里我做了取舍:DHT11的湿度精度是1%RH、温度精度是1℃,整数部分完全够用,小数部分意义不大。如果需要更高精度,应该直接换DHT22或者SHT30,DHT11本身就是个入门级传感器,不要指望它测出0.1℃级别的变化。

4. 实测调参:HAL库与DHT11时序的磨合过程

代码写完之后,进入最磨人的阶段:调时序。这部分我把完整过程写出来,因为这才是DHT11项目真正花时间的环节。

第一次上电测试,现象是:OLED正常点亮,温度显示-1℃,湿度显示0%RH,数据明显不对。用逻辑分析仪抓引脚波形,发现问题出在起始信号后的总线释放时间上。我原来写的是HAL_Delay(1),也就是1ms,但DHT11手册要求的是20-40us。1ms是什么概念?比要求的25倍还长!DHT11早就从唤醒状态跳过了应答窗口,主机开始读的时候,总线已经被DHT11释放回高电平了,自然读到的是全1,解析出来就是明显的错误数据。

HAL_Delay(1)换成DWT_Delay_us(30)之后,波形正常了,数据也出来了。但紧跟着第二个问题出现:数据偶发读取失败,大概每十次采样会有一次返回校验错误。

排查过程比较曲折。我一开始怀疑是电源噪声,给DHT11单独加了100nF去耦电容,没用;又怀疑是杜邦线太长导致信号反射,换短线,改善但不彻底。最后用逻辑分析仪逐位对比,发现问题出在DHT11_ReadBit函数里对高电平的判断逻辑——原来的写法是在高电平期间不断DWT_Delay_us(1)并计数,但当高电平长达70us时,计数达到70,这个时间长度本身没问题,问题在于DWT延时循环里头的读取操作和计数操作也有执行时间,叠加起来导致实测判断阈值偏大,某些数据位为0(27us)的情况下也会被判成1,从而误码。

调整方案是把判断逻辑改为直接读取当前计数值,而不是用循环计数:

static uint8_t DHT11_ReadBit(void) { uint32_t t1, t2; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_GPIO_Pin) == GPIO_PIN_RESET); t1 = DWT->CYCCNT; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_GPIO_Pin) == GPIO_PIN_SET); t2 = DWT->CYCCNT; uint32_t highUs = (t2 - t1) / (SystemCoreClock / 1000000); return (highUs > 50) ? 1 : 0; }

这个版本用DWT硬件计数器直接测量高电平的精确时钟周期数,while循环里不做任何额外操作,测量精度极高。改完后再测,连续读取200次,零错误。

这个经历也验证了一件事:HAL库并不妨碍你写出高精度的时序代码,关键是你得知道在关键路径上用对工具HAL_Delay负责毫秒级的粗延时,DWT负责微秒级的精延时,各司其职,完全不冲突。

4.1 数据刷新频率与OLED显示冲突的调度思路

DHT11的数据手册里写着"采样周期不得低于1秒"——不是说不能每秒采样,而是DHT11内部的上电测量周期就是1-2秒,你如果高频连续读取,DHT11根本来不及完成新的测量,返回的是上次的旧数据。所以DHT11的读取频率建议2秒一次,留足余量。

但OLED的刷新和DHT11的采样是互相影响的。如果你在DHT11时序读取的关键阶段(微秒级)被OLED的I2C中断打断,会导致时序超时,读取失败。I2C传输1024字节需要不少时间,如果DHT11读取和OLED刷新同时进行,系统调度就会出现优先级问题。

我的解决思路是把任务拆成两个状态机,在主循环里按时间片轮转:

  • DHT11采样任务:每2秒执行一次,执行期间关闭I2C中断(或者保证OLED不发起传输)。
  • OLED刷新任务:每1秒执行一次,只刷新显存,不做其他事。

实际代码里我用了最简单的调度方式:主循环里用HAL_GetTick()获取当前毫秒时间戳,判断距离上次执行是否超过设定周期。DHT11读取和OLED刷新之间用标志位互斥,确保同一时刻只有一个任务在占用I2C总线。

uint32_t lastDHT11Tick = 0, lastOLEDTick = 0; uint8_t dht11Busy = 0; while (1) { uint32_t now = HAL_GetTick(); // DHT11采样任务:每2秒执行一次 if ((now - lastDHT11Tick >= 2000) && !dht11Busy) { dht11Busy = 1; uint8_t hum, temp, err; err = DHT11_ReadData(&hum, &temp); if (err) { snprintf(displayBuf, sizeof(displayBuf), "DHT11 ERR %d", err); } else { snprintf(displayBuf, sizeof(displayBuf), "T:%dC H:%d%%", temp, hum); } dht11Busy = 0; lastDHT11Tick = now; } // OLED刷新任务:每1秒执行一次 if (now - lastOLEDTick >= 1000) { SSD1306_Clear(); SSD1306_ShowString(0, 0, displayBuf, 16); SSD1306_Refresh(); lastOLEDTick = now; } }

这个调度方式不能说多优雅,但非常实用。它避免了DHT11时序关键段被I2C传输打断的问题,也不可能出现两个任务同时访问I2C总线的竞态条件。如果你后面要做更复杂的系统,建议整体迁移到RTOS,用信号量来管理I2C总线的互斥访问。

4.2 常见硬件问题排查清单

代码都调通之后,如果还是出问题,大概率是硬件层面。我把这个项目里遇到的所有硬件坑列成一张排查表:

现象可能原因排查方法
一直读到0xFF或0x00数据线未加上拉用万用表测数据线电压,正常空闲时应为3.3V
温度湿度固定不变采样频率过高,DHT11来不及更新把采样周期拉长到2秒以上
数据偶发校验错误杜邦线过长或接触不良缩短连线,或改用杜邦线+面包板之外的焊接方式
OLED白屏I2C地址错误SSD1306的7位地址一般是0x3C或0x3D,对应8位地址0x78或0x7A
OLED显示一半正常一半花屏显存刷新数据错位检查SSD1306的页地址设置,确认每页128字节
DHT11读数偏高传感器靠近发热元件(如MCU)远离热源,必要时用延长线引出传感器

特别是最后一条,我实际测试时发现DHT11贴在STM32旁边,读出来的温度比室温高2-3℃,因为MCU本身发热。如果你要做的是环境温度监测,传感器一定要远离主控板放。

5. 进阶扩展:从DHT11到更精准的测量方案

做完DHT11+OLED显示这个基础项目之后,大概率你会有一个想法:DHT11的精度实在太低了,温度整数1℃的粒度让人很不爽。这时候有两条进阶路线:

路线一:换DHT22(AM2302)。DHT22同样是单总线协议,通信流程和DHT11完全一样,区别在于DHT22返回的是16bit温湿度数据,温度分辨率0.1℃,湿度分辨率0.1%RH。你只需要把驱动里读取数据的位数从8bit改成16bit,解析逻辑稍微调整,就能复用绝大部分代码。

路线二:换I2C接口的SHT30/SHT40。SHT30的精度更高,温度±0.2℃,湿度±2%RH,而且走I2C接口,不需要自己写时序。代码更简洁、更可靠,缺点是价格比DHT11贵一些。

我个人建议的顺序是:先用DHT11把单总线协议的时序、GPIO切换、校验逻辑吃透,然后迁移到DHT22几乎无痛,之后再换SHT30你会发现HAL库的I2C驱动已经玩得门儿清了。这条路走下来,嵌入式常用的传感器通信方式基本就都覆盖了。

除了换传感器,显示端也可以升级。如果觉得0.96寸的OLED显示信息太少,可以换1.3寸的OLED(同样是SSD1306/SSH1106驱动,分辨率128x64不变),或者换成LCD屏幕。核心驱动逻辑都不用动,只是初始化配置的屏幕尺寸参数要改。

6. 写在最后的工程建议

项目到这里已经完整跑通了:STM32F103C8T6 + HAL库 + DHT11 + SSD1306 OLED,温湿度实时显示在屏幕上,数据稳定,校验可靠。最后分享几个我在这个项目里沉淀下来的工程经验,都是拿时间换来的:

第一,调试工具一定要舍得买。一个逻辑分析仪、一个质量好点的万用表,总共不到一百块,但能帮你省下的调试时间是以天为单位的。没有逻辑分析仪之前,我调DHT11时序全靠加串口打印然后在串口助手里猜,效率极低。

第二,写代码的时候就要考虑数据校验和超时保护。DHT11这种单总线设备,任何一根线接触不良、任何一个环境干扰,都可能导致数据错误。如果你的代码不做校验、不加超时,系统会偶尔卡死或者显示错误数据,而且极难复现和排查。我在上面驱动里加的waitCnt超时保护,就是吃够了卡死的亏之后才加上的。

第三,代码结构要为一个"更大的项目"做准备。如果你只是想点个灯,随便写写没问题。但如果你后面要做产品原型,建议从第一天就把驱动模块和数据解析模块分开。DHT11的驱动就放在dht11.c里,OLED驱动放在ssd1306.c里,主循环只负责调度和业务逻辑。这样后期加蓝牙上报、加WIFI、加按键,就是在主循环里加状态,不会把代码改成一团乱麻。

HAL库驱动的路,入门的时候总觉得啰嗦,等你真正习惯了这套抽象,回头看标准库那份什么都得自己管的酸爽,你会庆幸自己当初选了HAL库。这个DHT11+OLED的项目就是一套很好的练手组合,硬件成本十几块钱,代码量也不大,但里面涉及的单总线时序、GPIO切换、I2C驱动、状态机调度,全是嵌入式开发的看家本事。花一个周末做完,收获绝对超出你的预期。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 1:04:31

大数据可视化大屏模板实战:从选型到落地全流程拆解

简介&#xff1a;面向大数据可视化项目开发与数据大屏展示场景&#xff0c;这份压缩包提供了可直接复用的前端模板&#xff0c;适合前端工程师、BI分析师及需要快速搭建监控中心、运营看板或汇报演示页面的团队。包内共40个文件&#xff0c;以JavaScript、CSS、图片及字体资源为…

作者头像 李华
网站建设 2026/9/10 1:02:00

Ollama+WebUI Lite本地部署实战:安装配置与模型迁移全攻略

简介&#xff1a;面向需要本地部署Ollama Web UI Lite的开发者或机器学习爱好者&#xff0c;资源整理了该Web界面的完整安装流程与配置思路&#xff0c;涵盖npm镜像加速、Git仓库克隆、依赖安装与开发服务器启动等核心环节。压缩包共48个文件&#xff0c;约1.01MB&#xff0c;以…

作者头像 李华
网站建设 2026/9/10 1:02:00

VS Code原生AI完胜Cursor?7天实测回迁复盘与配置指南

我承认&#xff0c;最初我对Cursor也有“真香”滤镜。用了一阵之后&#xff0c;几乎每天都能看到“再也不用VS Code了”“Cursor就是AI编程的天花板”“VS Code原生AI太弱了”这类论调&#xff0c;说实话我也差点被带跑。原因很简单&#xff1a;Cursor确实把AI和编辑器的融合做…

作者头像 李华
网站建设 2026/9/10 0:59:03

无标题文档急救指南:从零提炼标题、关键词与摘要

“项目标题&#xff1a;无标题”——这个场景&#xff0c;做内容或做研发的朋友应该都不陌生。打开笔记软件&#xff0c;文件夹里躺着好几个“无标题文档”&#xff0c;代码仓库里整整齐齐排着 untitled.ipynb&#xff0c;项目文档的标题栏是一个尴尬的空格&#xff0c;连文件名…

作者头像 李华
网站建设 2026/9/10 0:57:20

ToolJet 动作参考:Set variable 动作的配置与底层实现原理

ToolJet 动作参考&#xff1a;Set variable 动作的配置与底层实现原理 【免费下载链接】ToolJet Open-source foundation of ToolJet AI - the enterprise app generation platform for internal tools, dashboards, business applications, workflows and AI agents. Build vi…

作者头像 李华