简介:面向物联网与嵌入式开发的入门及进阶人群,这份工程实例以主流微控制器为核心,完整演示了如何借助硬件抽象层库驱动温湿度传感器,并在小型显示屏幕上实时呈现采集到的温度与湿度数据。资源包内共收录一百九十三个文件,整体体积约六点九兆,以C语言源文件、头文件、HAL驱动文件为主,辅有编译生成的HEX与AXF镜像、链接映射文件、列表文件,以及MDK-ARM工程配置和IOC引脚配置文件,目录划分规范,可一站式完成从工程打开、编译到烧录的全流程。项目详细展示了DHT11单总线协议下的温湿度读取、40位数据校验与数值转换,同时利用HAL库的I2C接口驱动SSD1306控制芯片,实现文本与数字的刷新渲染,覆盖传感器底层时序、协议解析、驱动封装与显示应用等多个关键环节。目前已有七百七十二人学习使用,对于希望快速上手传感器数据采集与小型显示终端的开发者,是一份兼具教学与参考价值的高质量模板,可直接扩展至智能家居、环境监测、农业大棚等实际项目,也可作为课程设计或毕业设计的参考原型。1. 一个卡住无数新手的物联网入门件:HAL库下的DHT11与OLED显示在物联网嵌入式开发里,DHT11加OLED屏的组合几乎是最常见的起步项目,但恰恰是这个看似人畜无害的组合,翻车率出奇地高。很多人代码写完、编译通过、下载到板子里,屏幕死活不亮或者温湿度显示成恒定的0。问题大多不在逻辑,而在HAL库的延时机制和GPIO模式配置上。这套资源提供的就是完整的HAL库工程模板,打通从DHT11单总线数据读取到OLED(SSD1306)画面刷新的全链路。适合正在做物联网课程设计、智能家居小样机,或者单纯想用STM32验证传感器读取流程的开发者参考。2. 硬件连接与工程骨架:引脚分配、时钟树与HAL库初始化顺序2.1 引脚分配原则:别把DHT11挂在复用功能上DHT11是单总线器件,数据线只有一根,既当输入又当输出,所以必须选择一个既能推挽输出又能浮空输入的GPIO。常见做法是选PA0或PB8这种普通IO口,避开SCL、SDA、TX、RX这类默认复用引脚。OLED走I2C时,SCL和SDA是固定的硬件I2C引脚,比如I2C1的PB6和PB7,同时也要确认板子上有没有外部上拉。我一般习惯这样分配:外设引脚模式说明DHT11 DATAPA0开漏输出/浮空输入单总线数据OLED SCLPB6复用开漏I2C1时钟OLED SDAPB7复用开漏I2C1数据OLED VCC3.3V—0.96寸OLED必须3.3VOLED GNDGND—共地注意DHT11的数据线如果选开漏输出模式,外部必须接一个4.7kΩ到10kΩ的上拉电阻到3.3V。有些开发板板载了上拉电阻,但为了保险起见,我会直接用推挽输出来做主机起始信号,然后切换成输入模式去读从机响应。推挽输出模式不需要外部上拉也能拉高电平,但这会导致总线空闲时电平完全由主机控制,如果主机输出高但从机想拉低,就会产生冲突。所以更稳妥的还是一开始就配好开漏加外部上拉,这也是工业上单总线标准的配置方式。2.2 CubeMX配置要点:时钟树与GPIO初始化顺序工程的创建从CubeMX开始,芯片选中后第一件事是配时钟树。DHT11的时序精度依赖微秒级延时,如果系统主频是72MHz(STM32F103常见配置),那么DWT计数器的一个tick约等于13.89纳秒,刚好适合做高精度延时。如果你的板子主频是64MHz或48MHz,延时函数里的系数记得按比例调整。GPIO初始化顺序也有讲究,先初始化I2C外设再配置OLED,能让OLED上电后立刻进入可接收命令的状态。DHT11的GPIO配置不需要在CubeMX里做额外特殊处理,生成代码后手动修改模式即可。2.3 HAL库工程骨架:手动补齐的延时函数与数据类型CubeMX生成的HAL工程里,HAL_Delay()只能做到毫秒级延时,而DHT11要求的微秒级操作必须自己实现。推荐使用DWT(Data Watchpoint and Trace)模块,它是Cortex-M内核自带的调试计数器,不需要额外占用定时器资源。以下是配套的延时模块实现,直接加到main.c里即可:/* 微秒延时:基于DWT内核计数器实现 */ static volatile uint32_t g_dwt_delay_us = 0; void DWT_Delay_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; /* 使能DWT追踪 */ 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); }逻辑说明:DWT_CYCCNT寄存器在使能后每个CPU时钟周期自动加一,利用两次读取的差值就能算出经过的时间。参数换算的关键是SystemCoreClock / 1000000得到每微秒对应的周期数,72MHz主频下就是72,延时10微秒就是720个周期。相比SysTick来做的延时,DWT不受中断影响,精度更稳定。DHT11的数据位读取需要26到28微秒的判断窗口,DWT方式读取能保证误差在1微秒以内。如果你用的HAL库版本比较老,记得检查system_stm32f1xx.c里的SystemCoreClock全局变量是否被正确赋值,否则延时时间会偏差很大。3. 与DHT11对话:单总线时序、校验和数据解析的完整拆解3.1 DHT11的时序协议:40比特数据结构DHT11返回40位数据,格式是:8位湿度整数 + 8位湿度小数 + 8位温度整数 + 8位温度小数 + 8位校验和。校验和等于前四个字节之和的低八位,这个校验必须做,因为单总线在长线传输时极易受干扰。一次完整的读取过程分成三个阶段:主机起始信号、从机响应、40位数位输出。主机起始信号是拉低数据线至少18毫秒,然后释放总线。这里需要注意,DHT11要求起始信号的低电平时间在18ms到30ms之间,太短传感器不认,太长会触发传感器内部的复位逻辑。起始信号结束后,总线被上拉电阻拉高,DHT11会在20到40微秒后拉低总线,持续80微秒,这是响应信号的前半段;再拉高80微秒代表从机准备好发送数据。每个数据位的读取方式是:DHT11先拉低50微秒,然后拉高,高电平持续26到28微秒代表"0",持续70微秒代表"1"。判断方法就是在拉高之后等待大概40微秒,再去读引脚电平,如果还是高就是"1",已经变低就是"0"。3.2 代码实现:HAL库下DHT11读取函数/* DHT11读取函数,成功返回0,失败返回-1 */ #define DHT11_PIN GPIO_PIN_0 #define DHT11_PORT GPIOA /* 输入输出模式切换 */ static void DHT11_Set_Output(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = DHT11_PIN; gpio.Mode = GPIO_MODE_OUTPUT_OD; /* 开漏输出,配合外部上拉 */ gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_PORT, &gpio); } static void DHT11_Set_Input(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = DHT11_PIN; gpio.Mode = GPIO_MODE_INPUT; /* 浮空输入,读取外部电平 */ gpio.Pull = GPIO_NOPULL; HAL_GPIO_Init(DHT11_PORT, &gpio); } int DHT11_Read(uint8_t *humidity, uint8_t *temperature) { uint8_t data[5] = {0}; /* 1. 主机起始信号:拉低 >18ms */ DHT11_Set_Output(); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); HAL_Delay(20); /* 20ms低电平 */ /* 2. 释放总线,切换输入模式 */ HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); DHT11_Set_Input(); DWT_Delay_us(30); /* 等待从机拉低响应 */ /* 3. 检测响应信号:低80us + 高80us */ if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) != GPIO_PIN_RESET) return -1; /* 总线没有被拉低,传感器未响应 */ /* 等待低电平结束(响应信号前半段) */ uint16_t timeout = 100; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) if (--timeout == 0) return -1; /* 等待高电平结束(响应信号后半段) */ timeout = 100; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) != GPIO_PIN_RESET) if (--timeout == 0) return -1; /* 4. 读取40位数据 */ for (uint8_t i = 0; i < 40; i++) { /* 每个位先拉低50us,等待低电平结束 */ timeout = 100; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) if (--timeout == 0) return -1; /* 高电平持续30us后判断:40us的参考点 */ DWT_Delay_us(40); /* 读引脚状态,高电平为1,低电平为0 */ if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { data[i / 8] = (data[i / 8] << 1) | 0x01; /* 写入1 */ } else { data[i / 8] = (data[i / 8] << 1); /* 写入0 */ } /* 等待剩余高电平结束,回到低电平起点 */ timeout = 100; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) != GPIO_PIN_RESET) if (--timeout == 0) return -1; } /* 5. 校验和判断 */ if ((data[0] + data[1] + data[2] + data[3]) != data[4]) return -1; *humidity = data[0]; /* 湿度整数部分 */ *temperature = data[2]; /* 温度整数部分 */ return 0; }逻辑说明:开漏输出模式下写1等价于释放总线,所以起始信号的释放不需要切换模式就能被上拉电阻拉高。DHT11的时序窗口很紧凑,检测数据位的参考点选在40微秒,如果此时引脚还是高电平,说明这一位的高电平宽度大于40微秒,判定为"1"不会有歧义。参数说明:timeout值的设定取决于主频和while循环的执行速度,100次循环在72MHz下大约能覆盖1ms以上的时间窗口,足以应对DHT11的80微秒电平宽度。如果芯片主频更高,比如168MHz,建议把timeout增加到200,避免while轮询次数不够导致误判超时。3.3 连续读取的间隔问题DHT11的内部采样频率只有0.5Hz到1Hz,也就是每1到2秒才能做一次新的测量。如果读取间隔小于这个时间,DHT11会直接返回上一次的缓存数据。更严重的情况是,传感器在忙状态下收到起始信号会不响应,总线上的电平状态会变得不可预期。所以读取频率的设定上限是每2秒一次,最低也要1秒一次。我通常在主循环里用HAL_GetTick()做非阻塞延时,确保每次读取之间有2秒的间隔。有些开发者在循环里连续调用读取函数直到成功为止,这种做法会打乱时序节拍,导致读取成功率下降,正确的做法是失败后等待2秒再发起下一次读取。4. 数据上屏与系统整合:OLED的字库、小数处理与轮询策略4.1 SSD1306驱动方案选择:自带字库还是移植开源库OLED屏幕的主控芯片是SSD1306,I2C接口下默认从机地址是0x3C或者0x3D,具体看屏幕背面的电阻配置。驱动方式有两种选择,一是移植现成的开源库比如u8g2,二是自己写底层驱动。对于DHT11这种简单应用,自带精简字库的驱动更合适,因为u8g2体积偏大,在F103这种Flash只有64KB的芯片上会比较吃紧。推荐的做法是使用纯软件I2C的SSD1306驱动,不占用硬件I2C外设,引脚选择更灵活。DHT11的抢占式读取对I2C时序没有要求,软件模拟也没有问题。4.2 I2C地址检测与写命令函数/* I2C写命令,addr是设备地址,cmd是命令字节 */ void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; /* 控制字节0x00表示后续是命令 */ HAL_I2C_Master_Transmit(&hi2c1, 0x3C << 1, buf, 2, 100); } /* I2C写数据,dat是显示数据 */ void OLED_WriteData(uint8_t dat) { uint8_t buf[2] = {0x40, dat}; /* 控制字节0x40表示后续是显存数据 */ HAL_I2C_Master_Transmit(&hi2c1, 0x3C << 1, buf, 2, 100); }逻辑说明:SSD1306通过控制字节区分命令和数据,0x00后面跟的是寄存器配置命令,0x40后面跟的是写入显存的数据。HAL_I2C_Master_Transmit的第三个参数是发送字节数,这里固定为2,不能把buf大小写成sizeof(buf),因为I2C发送长度是用户指定的,跟缓冲区大小不是一个概念。参数说明:超时时间100ms在I2C总线正常工作时不会触发,如果屏幕接线不良或者地址不匹配,这个参数决定了报错前要等多久。OLED初始化命令序列包括关闭显示、设置显示时钟分频、设置复用比、设置显示偏移、打开显示等,一共需要25条左右的命令,这些初始化命令会在后续代码包中完整提供。4.3 温湿度数据显示:整数和小数的处理DHT11的温湿度精度是1位小数,比如湿度45%RH,温度27℃。但DHT11返回的数据格式比较特殊,数据手册上写"湿度整数部分+湿度小数部分",实际上低端版本的DHT11小数部分恒为0。工程代码里处理显示时,直接用整数部分展示即可,小数部分可以保留并显示成".0"。温度有负数场景,DHT11温度数据的最高位是符号位,1表示零下。完整的转换逻辑如下:/* 温湿度显示缓冲,带符号处理 */ char display_buf[32]; int8_t temp_int = (int8_t)temperature; /* 转为有符号数 */ uint8_t humi_int = humidity; /* 负数温度显示 "-12" */ if (temp_int < 0) { sprintf(display_buf, "Temp: -%d.%d C", abs(temp_int), data[3]); /* data[3]是温度小数部分 */ } else { sprintf(display_buf, "Temp: %d.%d C", temp_int, data[3]); } OLED_ShowString(0, 0, display_buf); /* 第1行显示温度 */ sprintf(display_buf, "Humi: %d.%d %%RH", humi_int, data[1]); /* data[1]是湿度小数部分 */ OLED_ShowString(0, 2, display_buf); /* 第3行显示湿度 */逻辑说明:把温度字节强转为int8_t后,负温度会正确显示符号。sprintf的%d和%d.%d组合,是为了把整数和小数拼成带小数的字符串。OLED_ShowString内部维护一个ASCII字库表,每个字符占8x16像素,0.96寸OLED共128x64像素,所以可以显示8列16行字符。参数说明:OLED_ShowString的第二个参数是行号,取值范围0到7,对应屏幕纵向8个字符行。每次刷屏建议整屏更新而不是局部刷新,因为SSD1306的显存是1KB,写局部区域需要重新计算显存地址,整屏更新反而逻辑更简单,速度也够快。4.4 主循环轮询策略:DHT11与OLED刷新节奏分离一个常见的架构错误是每2秒读一次DHT11,同时也每2秒刷一次OLED。这样做的问题是屏幕响应看起来非常迟钝,按键操作或者菜单切换时体验很差。更好的做法是把两件事的节奏分开,DHT11的采样频率固定2秒,但OLED只在新数据到来时刷一次,或者每200ms刷新一次显示缓存,这样视觉上更流畅。/* 主循环架构:轮询DHT11 + 独立刷新显示 */ uint32_t last_read_tick = 0; uint32_t last_display_tick = 0; while (1) { uint32_t now = HAL_GetTick(); /* DHT11读取节奏:每2000ms一次 */ if ((now - last_read_tick) >= 2000) { uint8_t humi, temp; if (DHT11_Read(&humi, &temp) == 0) { /* 读取成功,更新全局变量 */ g_humi = humi; g_temp = temp; g_data_valid = 1; } last_read_tick = now; } /* OLED刷新节奏:每200ms一次,数据有效时才刷 */ if ((now - last_display_tick) >= 200 && g_data_valid) { OLED_DisplayUpdate(); last_display_tick = now; } }逻辑说明:g_data_valid标志位确保DHT11第一次成功读取之前,OLED显示区不会打印初始化的0值。200ms的显示刷新率对人眼来说已经很平滑,同时不会因为在读取DHT11的时序关键区间打断而产生数据错乱。参数说明:2000ms的读取间隔对应DHT11内部采样率的上限,小于这个值会读到重复数据。200ms的显示间隔是可以调节的,如果你在屏幕上加了动画效果或者翻页操作,可以缩短到100ms,对72MHz主频来说I2C刷整屏耗时约20ms,时间预算充足。5. 避坑实录:DHT11读值不准、时序超时、显示异常的三类高频问题5.1 DHT11一直返回-1:时序超时的常见翻车现场现象:代码编译下载后,DHT11_Read函数返回-1,用调试器看变量,卡在等待响应信号的while循环里。万用表量数据线电压,发现始终是3.3V高电平,没有任何拉低的动作。原因:起始信号的低电平时间不够。很多人写HAL_Delay(1)以为就是1ms,但实际上HAL_Delay的精度受SysTick中断优先级影响,如果被其他中断抢占,实际延时时间会超过预期或小于预期。更常见的是CubeMX默认生成代码里,GPIO模式被配置成了GPIO_MODE_ANALOG,这种模式下引脚既不是输出也不是输入,数据线根本拉不低。另一个常见原因是板载DHT11模块的三根线接反了,有些模块的引脚顺序是VCC、DATA、GND,有些是DATA、VCC、GND,万用表量一下或者看丝印标注就能确认。解决:起始信号使用两个步骤——先写0再延时20ms,确保GPIO模式是开漏输出。调试时用逻辑分析仪抓数据线波形,重点检查起始信号的低电平宽度是否在18到30ms之间。如果没有逻辑分析仪,可以用示波器或者LED临时做指示,拉低时点灯、释放时灭灯,肉眼观察亮灯持续时间。5.2 湿度小数一直为0:DHT11的"精度"玄学现象:读取成功,校验和也通过,但湿度的小数位永远是0,温度小数也一样。屏幕显示"Temp: 27.0 C"、"Humi: 45.0 %RH",看起来像是驱动解析错误。原因:这个现象在某个批次的DHT11上非常普遍。数据手册上写的是"小数位代表0.1%精度",但实测很多模块小数位确实恒为0,只有整数位参与运算。还有一部分模块返回的值在小数位上不是0,但数值恒定为某个固定值(比如永远显示0.5)。这不是代码bug,而是DHT11本身就是10位精度的传感器,出厂校准后的分辨率实际只有1。解决:不要纠结小数位。显示时小数位直接隐藏,只显示整数。如果你想显示小数增加观感,可以人为在后面拼接".0"。真正需要小数精度的场景,要换DHT22或者SHT30,DHT22的湿度分辨率能做到0.1%RH,代价是价格翻3倍以上。5.3 OLED上电白屏或者花屏:I2C时序冲突和初始化顺序现象:OLED屏幕第一次上电显示白屏,复位后变成花屏,或者屏幕只亮一半。DHT11读取正常,证明主芯片工作没问题。原因:SSD1306的I2C时序要求较高,数据线在时钟线为高时不能跳变。如果软件I2C的引脚初始化顺序不对,在SSD1306还在上电复位的过程中就发命令,屏幕会进入不可预期的状态。另一种可能是I2C的速率配置过高,STM32F103的硬件I2C外设在400kHz模式下有时序毛刺,和SSD1306的I2C从机配合不稳定。解决:GPIO初始化的顺序调整为:先初始化I2C引脚和I2C外设,再初始化OLED的电源(如果用到GPIO控制VCC),最后发送初始化命令序列。初始化命令里必须先发0xAE(关闭显示),再发0x8D和0x14(使能电荷泵),最后发0xAF(打开显示)。另外I2C时钟频率改成100kHz标准模式,宁可慢一点也要稳定。5.4 读取到的温度和实际情况偏差大:DHT11响应慢是主因现象:温湿度数据显示比实际环境的数值滞后了10分钟以上,比如空调开了半天,屏幕才慢慢降下来。用手捏住传感器,温度要等很久才上升。原因:DHT11的感温元件是NTC热敏电阻,外面包了一层透气的塑料外壳,热响应时间本身就慢,数据手册上写的响应时间<=50秒是指在流动空气中的时间。更关键的是,如果读取间隔是2秒,而传感器内部的采样周期是2秒,那么显示值变化的实际速率受限于传感器本身的热时间常数,不是软件能解决的。解决:把DHT11尽量露出,不要紧贴PCB板,不要放在塑料外壳里。如果是固定在开发板上的模块,测量的是板载温度而不是空气温度,这属于物理布局问题。软件上可以把读取间隔缩短到1秒,配合均值滤波,能略微改善响应速度,但不会有质的提升。5.5 校验和不通过的终结方案:重试机制与错误丢弃现象:程序跑一段时间后,屏幕上的温度突然变成0或者显示err。调试发现校验和不通过的概率大概是每几十次会出现一次。原因:单总线协议没有纠错机制,数据线在传输过程中受到电磁干扰、长度超过20cm、或者供电电压不稳定,都可能导致某个数据位翻转。定时器中断如果在数据位读取的40微秒判断窗口内触发,也会让主循环的读取时序被拉长,导致误判。解决:在软件上做两层保护。第一是读取函数的内部超时保护,任何一步while等待超时都立即退出并返回错误码;第二是外部重试机制,连续3次读取失败才认定传感器异常,并显示错误提示。同时,结合一个数字低通滤波器,对温度变化做限幅处理——如果相邻两次读取的温度值变化超过5度,判定为本次读取出错,丢弃并保留上次值。/* 限幅滤波:超过5度变化视为异常 */ #define MAX_TEMP_DELTA 5 int8_t filtered_temp = g_temp; /* 上一次的有效温度 */ if (abs(g_temp - filtered_temp) <= MAX_TEMP_DELTA) { filtered_temp = g_temp; /* 正常更新 */ } else { /* 丢弃本次值,保持上次值 */ g_temp = filtered_temp; }逻辑说明:DHT11的温度变化在正常环境下每秒不会超过0.5度,5度的阈值已经留了很大余量。这个滤波器能有效拦截偶发的数据跳变,同时保留真实温度变化的响应速度。参数说明:MAX_TEMP_DELTA的值可以根据实际场景调整,如果是测量沸水蒸气或者其他剧烈温度变化的场景,调大到10即可。6. 把时间轴拉长:DHT11的连续采集稳定性验证验证方法比代码本身更重要。第一次写通过之后,建议花一个下午做连续采集测试,验证这套组合的长期稳定性。写一个统计模块,记录从开机到现在的读取总次数、成功次数、失败次数、校验错误次数,每隔30分钟把统计结果和最新温湿度打印输出。常见的验证代码循环里,在DHT11_Read返回失败时连续重试,但重试间隔必须严格遵守2秒的约束。还有一种做法是把重试改成读取DHT22,DHT22的时序和DHT11几乎一样,只是数据格式从8位变成16位,这套工程只需要改数据解析部分就能复用。最后分享一个习惯,从那以后我每次搭传感器采集工程都强制走一遍同样的流程:先确认物理连接和上拉电阻,再量引脚静态电平,接着用逻辑分析仪抓一次完整时序,最后才写业务逻辑代码。这套流程省掉的时间远比花掉的多,希望帮到你。本文还有配套的精品资源,点击获取
简介:面向物联网与嵌入式开发的入门及进阶人群,这份工程实例以主流微控制器为核心,完整演示了如何借助硬件抽象层库驱动温湿度传感器,并在小型显示屏幕上实时呈现采集到的温度与湿度数据。资源包内共收录一百九十三个文件,整体体积约六点九兆,以C语言源文件、头文件、HAL驱动文件为主,辅有编译生成的HEX与AXF镜像、链接映射文件、列表文件,以及MDK-ARM工程配置和IOC引脚配置文件,目录划分规范,可一站式完成从工程打开、编译到烧录的全流程。项目详细展示了DHT11单总线协议下的温湿度读取、40位数据校验与数值转换,同时利用HAL库的I2C接口驱动SSD1306控制芯片,实现文本与数字的刷新渲染,覆盖传感器底层时序、协议解析、驱动封装与显示应用等多个关键环节。目前已有七百七十二人学习使用,对于希望快速上手传感器数据采集与小型显示终端的开发者,是一份兼具教学与参考价值的高质量模板,可直接扩展至智能家居、环境监测、农业大棚等实际项目,也可作为课程设计或毕业设计的参考原型。1. 一个卡住无数新手的物联网入门件:HAL库下的DHT11与OLED显示在物联网嵌入式开发里,DHT11加OLED屏的组合几乎是最常见的起步项目,但恰恰是这个看似人畜无害的组合,翻车率出奇地高。很多人代码写完、编译通过、下载到板子里,屏幕死活不亮或者温湿度显示成恒定的0。问题大多不在逻辑,而在HAL库的延时机制和GPIO模式配置上。这套资源提供的就是完整的HAL库工程模板,打通从DHT11单总线数据读取到OLED(SSD1306)画面刷新的全链路。适合正在做物联网课程设计、智能家居小样机,或者单纯想用STM32验证传感器读取流程的开发者参考。2. 硬件连接与工程骨架:引脚分配、时钟树与HAL库初始化顺序2.1 引脚分配原则:别把DHT11挂在复用功能上DHT11是单总线器件,数据线只有一根,既当输入又当输出,所以必须选择一个既能推挽输出又能浮空输入的GPIO。常见做法是选PA0或PB8这种普通IO口,避开SCL、SDA、TX、RX这类默认复用引脚。OLED走I2C时,SCL和SDA是固定的硬件I2C引脚,比如I2C1的PB6和PB7,同时也要确认板子上有没有外部上拉。我一般习惯这样分配:外设引脚模式说明DHT11 DATAPA0开漏输出/浮空输入单总线数据OLED SCLPB6复用开漏I2C1时钟OLED SDAPB7复用开漏I2C1数据OLED VCC3.3V—0.96寸OLED必须3.3VOLED GNDGND—共地注意DHT11的数据线如果选开漏输出模式,外部必须接一个4.7kΩ到10kΩ的上拉电阻到3.3V。有些开发板板载了上拉电阻,但为了保险起见,我会直接用推挽输出来做主机起始信号,然后切换成输入模式去读从机响应。推挽输出模式不需要外部上拉也能拉高电平,但这会导致总线空闲时电平完全由主机控制,如果主机输出高但从机想拉低,就会产生冲突。所以更稳妥的还是一开始就配好开漏加外部上拉,这也是工业上单总线标准的配置方式。2.2 CubeMX配置要点:时钟树与GPIO初始化顺序工程的创建从CubeMX开始,芯片选中后第一件事是配时钟树。DHT11的时序精度依赖微秒级延时,如果系统主频是72MHz(STM32F103常见配置),那么DWT计数器的一个tick约等于13.89纳秒,刚好适合做高精度延时。如果你的板子主频是64MHz或48MHz,延时函数里的系数记得按比例调整。GPIO初始化顺序也有讲究,先初始化I2C外设再配置OLED,能让OLED上电后立刻进入可接收命令的状态。DHT11的GPIO配置不需要在CubeMX里做额外特殊处理,生成代码后手动修改模式即可。2.3 HAL库工程骨架:手动补齐的延时函数与数据类型CubeMX生成的HAL工程里,HAL_Delay()只能做到毫秒级延时,而DHT11要求的微秒级操作必须自己实现。推荐使用DWT(Data Watchpoint and Trace)模块,它是Cortex-M内核自带的调试计数器,不需要额外占用定时器资源。以下是配套的延时模块实现,直接加到main.c里即可:/* 微秒延时:基于DWT内核计数器实现 */ static volatile uint32_t g_dwt_delay_us = 0; void DWT_Delay_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; /* 使能DWT追踪 */ 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); }逻辑说明:DWT_CYCCNT寄存器在使能后每个CPU时钟周期自动加一,利用两次读取的差值就能算出经过的时间。参数换算的关键是SystemCoreClock / 1000000得到每微秒对应的周期数,72MHz主频下就是72,延时10微秒就是720个周期。相比SysTick来做的延时,DWT不受中断影响,精度更稳定。DHT11的数据位读取需要26到28微秒的判断窗口,DWT方式读取能保证误差在1微秒以内。如果你用的HAL库版本比较老,记得检查system_stm32f1xx.c里的SystemCoreClock全局变量是否被正确赋值,否则延时时间会偏差很大。3. 与DHT11对话:单总线时序、校验和数据解析的完整拆解3.1 DHT11的时序协议:40比特数据结构DHT11返回40位数据,格式是:8位湿度整数 + 8位湿度小数 + 8位温度整数 + 8位温度小数 + 8位校验和。校验和等于前四个字节之和的低八位,这个校验必须做,因为单总线在长线传输时极易受干扰。一次完整的读取过程分成三个阶段:主机起始信号、从机响应、40位数位输出。主机起始信号是拉低数据线至少18毫秒,然后释放总线。这里需要注意,DHT11要求起始信号的低电平时间在18ms到30ms之间,太短传感器不认,太长会触发传感器内部的复位逻辑。起始信号结束后,总线被上拉电阻拉高,DHT11会在20到40微秒后拉低总线,持续80微秒,这是响应信号的前半段;再拉高80微秒代表从机准备好发送数据。每个数据位的读取方式是:DHT11先拉低50微秒,然后拉高,高电平持续26到28微秒代表"0",持续70微秒代表"1"。判断方法就是在拉高之后等待大概40微秒,再去读引脚电平,如果还是高就是"1",已经变低就是"0"。3.2 代码实现:HAL库下DHT11读取函数/* DHT11读取函数,成功返回0,失败返回-1 */ #define DHT11_PIN GPIO_PIN_0 #define DHT11_PORT GPIOA /* 输入输出模式切换 */ static void DHT11_Set_Output(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = DHT11_PIN; gpio.Mode = GPIO_MODE_OUTPUT_OD; /* 开漏输出,配合外部上拉 */ gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_PORT, &gpio); } static void DHT11_Set_Input(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = DHT11_PIN; gpio.Mode = GPIO_MODE_INPUT; /* 浮空输入,读取外部电平 */ gpio.Pull = GPIO_NOPULL; HAL_GPIO_Init(DHT11_PORT, &gpio); } int DHT11_Read(uint8_t *humidity, uint8_t *temperature) { uint8_t data[5] = {0}; /* 1. 主机起始信号:拉低 >18ms */ DHT11_Set_Output(); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); HAL_Delay(20); /* 20ms低电平 */ /* 2. 释放总线,切换输入模式 */ HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); DHT11_Set_Input(); DWT_Delay_us(30); /* 等待从机拉低响应 */ /* 3. 检测响应信号:低80us + 高80us */ if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) != GPIO_PIN_RESET) return -1; /* 总线没有被拉低,传感器未响应 */ /* 等待低电平结束(响应信号前半段) */ uint16_t timeout = 100; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) if (--timeout == 0) return -1; /* 等待高电平结束(响应信号后半段) */ timeout = 100; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) != GPIO_PIN_RESET) if (--timeout == 0) return -1; /* 4. 读取40位数据 */ for (uint8_t i = 0; i < 40; i++) { /* 每个位先拉低50us,等待低电平结束 */ timeout = 100; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) if (--timeout == 0) return -1; /* 高电平持续30us后判断:40us的参考点 */ DWT_Delay_us(40); /* 读引脚状态,高电平为1,低电平为0 */ if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { data[i / 8] = (data[i / 8] << 1) | 0x01; /* 写入1 */ } else { data[i / 8] = (data[i / 8] << 1); /* 写入0 */ } /* 等待剩余高电平结束,回到低电平起点 */ timeout = 100; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) != GPIO_PIN_RESET) if (--timeout == 0) return -1; } /* 5. 校验和判断 */ if ((data[0] + data[1] + data[2] + data[3]) != data[4]) return -1; *humidity = data[0]; /* 湿度整数部分 */ *temperature = data[2]; /* 温度整数部分 */ return 0; }逻辑说明:开漏输出模式下写1等价于释放总线,所以起始信号的释放不需要切换模式就能被上拉电阻拉高。DHT11的时序窗口很紧凑,检测数据位的参考点选在40微秒,如果此时引脚还是高电平,说明这一位的高电平宽度大于40微秒,判定为"1"不会有歧义。参数说明:timeout值的设定取决于主频和while循环的执行速度,100次循环在72MHz下大约能覆盖1ms以上的时间窗口,足以应对DHT11的80微秒电平宽度。如果芯片主频更高,比如168MHz,建议把timeout增加到200,避免while轮询次数不够导致误判超时。3.3 连续读取的间隔问题DHT11的内部采样频率只有0.5Hz到1Hz,也就是每1到2秒才能做一次新的测量。如果读取间隔小于这个时间,DHT11会直接返回上一次的缓存数据。更严重的情况是,传感器在忙状态下收到起始信号会不响应,总线上的电平状态会变得不可预期。所以读取频率的设定上限是每2秒一次,最低也要1秒一次。我通常在主循环里用HAL_GetTick()做非阻塞延时,确保每次读取之间有2秒的间隔。有些开发者在循环里连续调用读取函数直到成功为止,这种做法会打乱时序节拍,导致读取成功率下降,正确的做法是失败后等待2秒再发起下一次读取。4. 数据上屏与系统整合:OLED的字库、小数处理与轮询策略4.1 SSD1306驱动方案选择:自带字库还是移植开源库OLED屏幕的主控芯片是SSD1306,I2C接口下默认从机地址是0x3C或者0x3D,具体看屏幕背面的电阻配置。驱动方式有两种选择,一是移植现成的开源库比如u8g2,二是自己写底层驱动。对于DHT11这种简单应用,自带精简字库的驱动更合适,因为u8g2体积偏大,在F103这种Flash只有64KB的芯片上会比较吃紧。推荐的做法是使用纯软件I2C的SSD1306驱动,不占用硬件I2C外设,引脚选择更灵活。DHT11的抢占式读取对I2C时序没有要求,软件模拟也没有问题。4.2 I2C地址检测与写命令函数/* I2C写命令,addr是设备地址,cmd是命令字节 */ void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; /* 控制字节0x00表示后续是命令 */ HAL_I2C_Master_Transmit(&hi2c1, 0x3C << 1, buf, 2, 100); } /* I2C写数据,dat是显示数据 */ void OLED_WriteData(uint8_t dat) { uint8_t buf[2] = {0x40, dat}; /* 控制字节0x40表示后续是显存数据 */ HAL_I2C_Master_Transmit(&hi2c1, 0x3C << 1, buf, 2, 100); }逻辑说明:SSD1306通过控制字节区分命令和数据,0x00后面跟的是寄存器配置命令,0x40后面跟的是写入显存的数据。HAL_I2C_Master_Transmit的第三个参数是发送字节数,这里固定为2,不能把buf大小写成sizeof(buf),因为I2C发送长度是用户指定的,跟缓冲区大小不是一个概念。参数说明:超时时间100ms在I2C总线正常工作时不会触发,如果屏幕接线不良或者地址不匹配,这个参数决定了报错前要等多久。OLED初始化命令序列包括关闭显示、设置显示时钟分频、设置复用比、设置显示偏移、打开显示等,一共需要25条左右的命令,这些初始化命令会在后续代码包中完整提供。4.3 温湿度数据显示:整数和小数的处理DHT11的温湿度精度是1位小数,比如湿度45%RH,温度27℃。但DHT11返回的数据格式比较特殊,数据手册上写"湿度整数部分+湿度小数部分",实际上低端版本的DHT11小数部分恒为0。工程代码里处理显示时,直接用整数部分展示即可,小数部分可以保留并显示成".0"。温度有负数场景,DHT11温度数据的最高位是符号位,1表示零下。完整的转换逻辑如下:/* 温湿度显示缓冲,带符号处理 */ char display_buf[32]; int8_t temp_int = (int8_t)temperature; /* 转为有符号数 */ uint8_t humi_int = humidity; /* 负数温度显示 "-12" */ if (temp_int < 0) { sprintf(display_buf, "Temp: -%d.%d C", abs(temp_int), data[3]); /* data[3]是温度小数部分 */ } else { sprintf(display_buf, "Temp: %d.%d C", temp_int, data[3]); } OLED_ShowString(0, 0, display_buf); /* 第1行显示温度 */ sprintf(display_buf, "Humi: %d.%d %%RH", humi_int, data[1]); /* data[1]是湿度小数部分 */ OLED_ShowString(0, 2, display_buf); /* 第3行显示湿度 */逻辑说明:把温度字节强转为int8_t后,负温度会正确显示符号。sprintf的%d和%d.%d组合,是为了把整数和小数拼成带小数的字符串。OLED_ShowString内部维护一个ASCII字库表,每个字符占8x16像素,0.96寸OLED共128x64像素,所以可以显示8列16行字符。参数说明:OLED_ShowString的第二个参数是行号,取值范围0到7,对应屏幕纵向8个字符行。每次刷屏建议整屏更新而不是局部刷新,因为SSD1306的显存是1KB,写局部区域需要重新计算显存地址,整屏更新反而逻辑更简单,速度也够快。4.4 主循环轮询策略:DHT11与OLED刷新节奏分离一个常见的架构错误是每2秒读一次DHT11,同时也每2秒刷一次OLED。这样做的问题是屏幕响应看起来非常迟钝,按键操作或者菜单切换时体验很差。更好的做法是把两件事的节奏分开,DHT11的采样频率固定2秒,但OLED只在新数据到来时刷一次,或者每200ms刷新一次显示缓存,这样视觉上更流畅。/* 主循环架构:轮询DHT11 + 独立刷新显示 */ uint32_t last_read_tick = 0; uint32_t last_display_tick = 0; while (1) { uint32_t now = HAL_GetTick(); /* DHT11读取节奏:每2000ms一次 */ if ((now - last_read_tick) >= 2000) { uint8_t humi, temp; if (DHT11_Read(&humi, &temp) == 0) { /* 读取成功,更新全局变量 */ g_humi = humi; g_temp = temp; g_data_valid = 1; } last_read_tick = now; } /* OLED刷新节奏:每200ms一次,数据有效时才刷 */ if ((now - last_display_tick) >= 200 && g_data_valid) { OLED_DisplayUpdate(); last_display_tick = now; } }逻辑说明:g_data_valid标志位确保DHT11第一次成功读取之前,OLED显示区不会打印初始化的0值。200ms的显示刷新率对人眼来说已经很平滑,同时不会因为在读取DHT11的时序关键区间打断而产生数据错乱。参数说明:2000ms的读取间隔对应DHT11内部采样率的上限,小于这个值会读到重复数据。200ms的显示间隔是可以调节的,如果你在屏幕上加了动画效果或者翻页操作,可以缩短到100ms,对72MHz主频来说I2C刷整屏耗时约20ms,时间预算充足。5. 避坑实录:DHT11读值不准、时序超时、显示异常的三类高频问题5.1 DHT11一直返回-1:时序超时的常见翻车现场现象:代码编译下载后,DHT11_Read函数返回-1,用调试器看变量,卡在等待响应信号的while循环里。万用表量数据线电压,发现始终是3.3V高电平,没有任何拉低的动作。原因:起始信号的低电平时间不够。很多人写HAL_Delay(1)以为就是1ms,但实际上HAL_Delay的精度受SysTick中断优先级影响,如果被其他中断抢占,实际延时时间会超过预期或小于预期。更常见的是CubeMX默认生成代码里,GPIO模式被配置成了GPIO_MODE_ANALOG,这种模式下引脚既不是输出也不是输入,数据线根本拉不低。另一个常见原因是板载DHT11模块的三根线接反了,有些模块的引脚顺序是VCC、DATA、GND,有些是DATA、VCC、GND,万用表量一下或者看丝印标注就能确认。解决:起始信号使用两个步骤——先写0再延时20ms,确保GPIO模式是开漏输出。调试时用逻辑分析仪抓数据线波形,重点检查起始信号的低电平宽度是否在18到30ms之间。如果没有逻辑分析仪,可以用示波器或者LED临时做指示,拉低时点灯、释放时灭灯,肉眼观察亮灯持续时间。5.2 湿度小数一直为0:DHT11的"精度"玄学现象:读取成功,校验和也通过,但湿度的小数位永远是0,温度小数也一样。屏幕显示"Temp: 27.0 C"、"Humi: 45.0 %RH",看起来像是驱动解析错误。原因:这个现象在某个批次的DHT11上非常普遍。数据手册上写的是"小数位代表0.1%精度",但实测很多模块小数位确实恒为0,只有整数位参与运算。还有一部分模块返回的值在小数位上不是0,但数值恒定为某个固定值(比如永远显示0.5)。这不是代码bug,而是DHT11本身就是10位精度的传感器,出厂校准后的分辨率实际只有1。解决:不要纠结小数位。显示时小数位直接隐藏,只显示整数。如果你想显示小数增加观感,可以人为在后面拼接".0"。真正需要小数精度的场景,要换DHT22或者SHT30,DHT22的湿度分辨率能做到0.1%RH,代价是价格翻3倍以上。5.3 OLED上电白屏或者花屏:I2C时序冲突和初始化顺序现象:OLED屏幕第一次上电显示白屏,复位后变成花屏,或者屏幕只亮一半。DHT11读取正常,证明主芯片工作没问题。原因:SSD1306的I2C时序要求较高,数据线在时钟线为高时不能跳变。如果软件I2C的引脚初始化顺序不对,在SSD1306还在上电复位的过程中就发命令,屏幕会进入不可预期的状态。另一种可能是I2C的速率配置过高,STM32F103的硬件I2C外设在400kHz模式下有时序毛刺,和SSD1306的I2C从机配合不稳定。解决:GPIO初始化的顺序调整为:先初始化I2C引脚和I2C外设,再初始化OLED的电源(如果用到GPIO控制VCC),最后发送初始化命令序列。初始化命令里必须先发0xAE(关闭显示),再发0x8D和0x14(使能电荷泵),最后发0xAF(打开显示)。另外I2C时钟频率改成100kHz标准模式,宁可慢一点也要稳定。5.4 读取到的温度和实际情况偏差大:DHT11响应慢是主因现象:温湿度数据显示比实际环境的数值滞后了10分钟以上,比如空调开了半天,屏幕才慢慢降下来。用手捏住传感器,温度要等很久才上升。原因:DHT11的感温元件是NTC热敏电阻,外面包了一层透气的塑料外壳,热响应时间本身就慢,数据手册上写的响应时间<=50秒是指在流动空气中的时间。更关键的是,如果读取间隔是2秒,而传感器内部的采样周期是2秒,那么显示值变化的实际速率受限于传感器本身的热时间常数,不是软件能解决的。解决:把DHT11尽量露出,不要紧贴PCB板,不要放在塑料外壳里。如果是固定在开发板上的模块,测量的是板载温度而不是空气温度,这属于物理布局问题。软件上可以把读取间隔缩短到1秒,配合均值滤波,能略微改善响应速度,但不会有质的提升。5.5 校验和不通过的终结方案:重试机制与错误丢弃现象:程序跑一段时间后,屏幕上的温度突然变成0或者显示err。调试发现校验和不通过的概率大概是每几十次会出现一次。原因:单总线协议没有纠错机制,数据线在传输过程中受到电磁干扰、长度超过20cm、或者供电电压不稳定,都可能导致某个数据位翻转。定时器中断如果在数据位读取的40微秒判断窗口内触发,也会让主循环的读取时序被拉长,导致误判。解决:在软件上做两层保护。第一是读取函数的内部超时保护,任何一步while等待超时都立即退出并返回错误码;第二是外部重试机制,连续3次读取失败才认定传感器异常,并显示错误提示。同时,结合一个数字低通滤波器,对温度变化做限幅处理——如果相邻两次读取的温度值变化超过5度,判定为本次读取出错,丢弃并保留上次值。/* 限幅滤波:超过5度变化视为异常 */ #define MAX_TEMP_DELTA 5 int8_t filtered_temp = g_temp; /* 上一次的有效温度 */ if (abs(g_temp - filtered_temp) <= MAX_TEMP_DELTA) { filtered_temp = g_temp; /* 正常更新 */ } else { /* 丢弃本次值,保持上次值 */ g_temp = filtered_temp; }逻辑说明:DHT11的温度变化在正常环境下每秒不会超过0.5度,5度的阈值已经留了很大余量。这个滤波器能有效拦截偶发的数据跳变,同时保留真实温度变化的响应速度。参数说明:MAX_TEMP_DELTA的值可以根据实际场景调整,如果是测量沸水蒸气或者其他剧烈温度变化的场景,调大到10即可。6. 把时间轴拉长:DHT11的连续采集稳定性验证验证方法比代码本身更重要。第一次写通过之后,建议花一个下午做连续采集测试,验证这套组合的长期稳定性。写一个统计模块,记录从开机到现在的读取总次数、成功次数、失败次数、校验错误次数,每隔30分钟把统计结果和最新温湿度打印输出。常见的验证代码循环里,在DHT11_Read返回失败时连续重试,但重试间隔必须严格遵守2秒的约束。还有一种做法是把重试改成读取DHT22,DHT22的时序和DHT11几乎一样,只是数据格式从8位变成16位,这套工程只需要改数据解析部分就能复用。最后分享一个习惯,从那以后我每次搭传感器采集工程都强制走一遍同样的流程:先确认物理连接和上拉电阻,再量引脚静态电平,接着用逻辑分析仪抓一次完整时序,最后才写业务逻辑代码。这套流程省掉的时间远比花掉的多,希望帮到你。本文还有配套的精品资源,点击获取