简介:在嵌入式系统开发中,传感器数据采集是常见却容易踩坑的环节,尤其是单总线类器件。DHT11作为典型的温湿度传感器,其数据读取并不依赖复杂的协议栈,而是依赖对微秒级时序的精确把控。掌握其工作原理,需要理解单总线的物理层电平状态、起始信号、应答窗口以及40位数据帧的校验规则。通过GPIO模拟单总线时序,可以避免专用外设资源占用,提升代码可移植性,这在资源受限的MCU上具有重要工程价值。实践中,标准库与HAL库在引脚方向切换、延时实现和中断处理上存在显著差异,而编译优化等级、上拉电阻配置、采样点位置等因素都会直接影响读取的稳定性。串口输出与逻辑分析仪是验证时序正确性的有效工具。本文围绕DHT11的读取过程,系统梳理时序参数、代码实现和调试技巧,帮助开发者从原理层面彻底解决温湿度采集中常见的误读、卡死和随机跳变等问题。
1. DHT11温湿度采集不是把数据读对,而是把时序抠准
DHT11 这类单总线传感器在 STM32 上跑起来的难点从来不在协议本身,而在微秒级延时是否准确、GPIO 方向切换有没有引入额外开销。项目里同时给出了标准库与 HAL 库两套实现,并在 USART1 上以文本帧输出温度与湿度,适合已经能点灯、但还没系统整理过单总线时序的开发者。初看工程文件时容易困惑:里面混着iar_cortexM3b_math.a、libarm_cortexM3l_math.a和arm_common_tables.c,这些其实是模板工程自带的 DSP 相关文件,与 DHT11 数据采集没有关系,后面单独解释。把主频、延时、引脚模式这三件事理清楚,标准库和 HAL 库两个版本都能稳定读到 0.1 级温湿度。
2. DHT11单总线协议:起始信号、应答窗口与40位数据帧校验
2.1 单总线物理层与总线空闲状态
DHT11 只有一根 DATA 线,既做输入又做输出,主机和传感器之间通过拉低、释放总线来传递信息。总线空闲时必须保持高电平,所以 STM32 的 GPIO 要配置成开漏输出带上拉,或者推挽输出加外部 4.7kΩ 上拉电阻。很多人在标准库工程里把引脚配成推挽输出后读不到数据,就是因为释放总线时引脚被强制拉高,DHT11 的应答低电平根本拉不动这根线。
总线上所有时序都以低电平脉冲的宽度作为信息载体。主机先发出一个大于 18ms 的起始低电平,然后释放总线,DHT11 检测到这个下降沿后开始应答。应答信号由一段 80μs 的低电平和一段 80μs 的高电平组成,主机在应答高电平结束后开始读取 40 位数据。
| 时序段 | 最短 | 典型 | 最长 |
|---|---|---|---|
| 主机起始低电平 | 18 ms | 20 ms | 30 ms |
| 主机释放到探测应答 | 20 μs | 30 μs | 40 μs |
| DHT11 应答低电平 | 78 μs | 80 μs | 84 μs |
| DHT11 应答高电平 | 78 μs | 80 μs | 84 μs |
| 数据位“0”高电平宽度 | 22 μs | 26 μs | 30 μs |
| 数据位“1”高电平宽度 | 68 μs | 70 μs | 75 μs |
| 每一位起始低电平宽度 | 48 μs | 50 μs | 54 μs |
这张表是后面调代码的对照基准。DHT11 手册上没有给全这些数值,但实际抓波形时不同批次的传感器会有几微秒偏差,表中典型值就是代码里延时的设计依据。
2.2 主机起始信号与应答窗口的判定
主机的起始信号不能太短。如果只拉低 5ms,DHT11 可能还在上电初始化阶段,不会响应。我一般控制在 20ms 左右,误差大一点没关系,因为这个阶段只需要保证大于 18ms。起始信号结束后释放总线,等待 DHT11 把总线拉低。
需要特别注意主机释放总线到 DHT11 拉低总线之间存在一个盲区。常见实现是这样写的:
static void DHT11_Start(void) { DHT11_Pin_Output(); /* 切到输出模式 */ GPIO_ResetBits(DHT11_GPIO, DHT11_PIN); Delay_Ms(20); /* 拉低 20ms,必须大于 18ms */ GPIO_SetBits(DHT11_GPIO, DHT11_PIN); Delay_Us(30); /* 释放总线,等待传感器拉低 */ DHT11_Pin_Input(); /* 切回输入才能检测电平 */ }这段代码里DHT11_Pin_Output()和DHT11_Pin_Input()承担了模式切换,标准库和 HAL 库的差异也集中在这两个函数上。Delay_Us(30)不能省,也不能太长,超过 40μs 会错过应答窗口的低电平起点,导致后面采样到的是应答高电平而不是数据位。
2.3 数据位“0”和“1”的判别点在 40μs
DHT11 发送的每一位都以 50μs 低电平开头,之后是 26μs 或 70μs 的高电平。问题在于主机怎么知道当前是高电平的哪一段。常见做法是等总线被拉低后延时跳过 50μs 低电平窗口,然后再采样引脚。如果此时读到高电平,说明高电平还没结束,这一位是“1”;如果读到低电平,说明高电平已结束,这一位是“0”。
uint8_t DHT11_ReadByte(void) { uint8_t i, byte = 0; for (i = 0; i < 8; i++) { while (GPIO_ReadInputDataBit(DHT11_GPIO, DHT11_PIN) == RESET); Delay_Us(40); /* 跳过 50us 低电平窗口 */ if (GPIO_ReadInputDataBit(DHT11_GPIO, DHT11_PIN) == SET) { byte |= (uint8_t)(0x01 << (7 - i)); while (GPIO_ReadInputDataBit(DHT11_GPIO, DHT11_PIN) == SET); } } return byte; }while等待低电平的作用是同步每一位的起始点,因为前一位结束后的高电平时长不确定,不能靠固定延时对齐位边界。Delay_Us(40)之后才采样,避开了低电平的 48~54μs 波动范围。读到高电平时把它判为“1”,并把对应位置 1,然后等总线回到低电平进入下一位。这个循环里若不等待位结束,下一轮while会立即退出,导致连续误读。
2.4 校验位:前四个字节相加的末 8 位
40 位数据依次是湿度整数、湿度小数、温度整数、温度小数、校验字节,每个部分 8 位,高位在前。DHT11 的小数位在大部分批次里固定输出 0,但代码必须按完整帧解析,不能直接丢弃小数位。校验规则是把前四个字节相加,取低 8 位与校验字节比较。
uint8_t DHT11_ReadFrame(uint8_t *temp, uint8_t *humi) { uint8_t buf[5] = {0}; uint8_t i; for (i = 0; i < 5; i++) { buf[i] = DHT11_ReadByte(); } if ((uint8_t)(buf[0] + buf[1] + buf[2] + buf[3]) != buf[4]) { return 1; /* 校验失败 */ } *humi = buf[0]; *temp = buf[2]; return 0; }校验失败时整帧丢弃,不要输出半帧数据。DHT11 本身是慢传感器,两次采集间隔至少保持 1 秒,所以偶尔丢一帧不影响显示连续性。工程里如果没有做帧校验环境,串口上就会看到温湿度偶尔整体跳变一个随机值,定位起来很费劲。
3. 标准库版本:寄存器级GPIO模拟单总线读写
3.1 延时函数校准与微秒级精度
标准库工程里最常用的延时方式是软件循环,但Delay_Us(40)的实际执行时间受编译器优化级别影响很大。Keil 的 -O0 和 -O2 编译出的循环耗时可能差 30% 以上。一个比较稳的做法是用 SysTick 做基准,配置成 1μs 中断一次,再用全局变量计数,但中断会打断 GPIO 采样,读位时反而可能踩到跳变沿。
更简单的方案是保持软件循环,但把延时函数和读位函数放在同一个 C 文件里,关闭该文件的优化,或者用逻辑分析仪实测后修正循环次数。标准库 V3.5 的模板里如果直接抄例程的Delay_Us,要注意原例程主频可能是 8MHz,而 STM32F103 的晶振倍频后是 72MHz,延时差接近 9 倍。
| 编译优化等级 | 实测 40μs 延时误差 | 读位稳定性 |
|---|---|---|
| -O0 | 偏大 2~4μs | 稳定 |
| -O2 | 偏小 8~12μs | 数据位偶尔错位 |
-O2 +__no_optimize | 接近理论值 | 稳定 |
优化等级对普通外设操作无感,对 DHT11 这种微秒级单总线来说就是能不能稳定读帧的区别。我一般给延时函数加上__attribute__((optimize("O0"))),这样即使整个工程开了 -O2,采样时序部分仍然不受编译器影响。
3.2 引脚初始化和总线复位
标准库下 GPIO 初始化要把引脚先配置成推挽输出,用于发送起始信号,然后通过修改 CRL/CRH 寄存器切换到浮空输入或上拉输入。初始化时不能只配一种模式,因为在一次完整采集任务里引脚要在输出和输入之间切换至少 41 次。
void DHT11_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStruct.GPIO_Pin = DHT11_PIN; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DHT11_GPIO, &GPIO_InitStruct); GPIO_SetBits(DHT11_GPIO, DHT11_PIN); }这里的GPIO_Mode_Out_PP只是初始状态,DHT11_Pin_Input()内部要把模式改成GPIO_Mode_IPU,也就是上拉输入。上拉输入能保证总线空闲时为高电平,省掉外接上拉电阻。如果板子上已经接了 4.7kΩ 上拉,改成GPIO_Mode_IN_FLOATING也可以,两者表现差距不大。
复位总线时,GPIO_ResetBits拉低引脚只是一个寄存器写操作,速度很快;真正耗时的是后面的Delay_Ms(20)。有些资料写成Delay_Ms(18),但考虑到温度变化影响 RC 延时,我习惯留出 20% 余量。
3.3 GPIO方向切换的两种写法和读时序容差
标准库里切换方向的正规做法是重新调用GPIO_Init,但这种方式内部有几十条语句,切换一次要 1μs 以上,对位时序影响明显。读位函数里每读一位要切换两次方向,累计误差不可忽略。更好的做法是直接操作 CRL 寄存器。
void DHT11_Pin_Output(void) { GPIOA->CRL &= ~(0x0F << (4 * 6)); /* 清除 PA6 配置 */ GPIOA->CRL |= (0x03 << (4 * 6)); /* 推挽输出 50MHz */ } void DHT11_Pin_Input(void) { GPIOA->CRL &= ~(0x0F << (4 * 6)); /* 清除 PA6 配置 */ GPIOA->CRL |= (0x08 << (4 * 6)); /* 上拉/下拉输入,配合设置 ODR */ GPIOA->ODR |= (1 << 6); /* 选择上拉 */ }CRL 寄存器每 4 位控制一个引脚,PA6 对应的偏移是4 * 6。0x03表示推挽输出 50MHz,0x08表示输入模式。改为输入后还要设置 ODR 对应位置 1,硬件上相当于内部上拉电阻接入。这套写法的执行时间只有几条汇编指令,对时序影响可以忽略。
3.4 标准库下的数据组包与错误丢弃策略
一次完整的 DHT11 读取如果在中途失败,比如读到的字节全是 0xFF,总线状态可能已经异常。处理策略是读取失败后把本次数据标记为无效,同时拉低总线复位一次,并等待下次轮询周期再重试。不要在失败后立即连续重试,DHT11 连续唤醒会拉高功耗,而且大约需要 1 秒恢复时间。
if (DHT11_ReadFrame(&temp, &humi) == 0) { printf("TEMP:%d.%d HUMI:%d.%d\r\n", temp >> 4, temp & 0x0F, humi >> 4, humi & 0x0F); } else { printf("DHT11 frame error\r\n"); }温度值的整数部分是buf[2]的高四位,因为 DHT11 的温度范围到 50℃,8 位有符号数够用,所以高四位实际只用了 0~9 的值区间。小数部分在低四位,多数器件恒为 0。temp >> 4得到整数,temp & 0x0F得到小数,这样打印出来就是带一位小数的文本帧。
4. HAL库版本:CubeMX工程下的移植与代码生成
4.1 CubeMX配置项与工程模板选择
HAL 库版本的项目是从 STM32CubeMX 生成的工程文件,里面保留了.ioc配置。CubeMX 里需要确认几个关键项,缺一个都会让代码在运行时行为异常。SYS 的 Debug 必须选Serial Wire,否则首次烧写后 SWD 引脚被复用成 GPIO,再想下载程序会提示error: no stm32 target found,这个问题出现在很多 STM32F103C8T6 最小系统板上。
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| SYS -> Debug | Serial Wire | 保留 SWD 下载能力 |
| RCC -> HSE | Crystal/Ceramic Resonator | 使用外部 8MHz 晶振 |
| USART1 -> Mode | Asynchronous | 115200-8-N-1 |
| GPIO -> PA6 | Output Push Pull | 初始输出态,代码内动态切方向 |
| Clock Configuration | HCLK 72MHz | 必须确认 PLL 倍频为 9 |
PA6 在 CubeMX 里只能选一种初始模式,HAL 库代码运行时再改方向。选Output Push Pull更合适,因为上电瞬间引脚保持默认低电平概率小一点,不会在传感器初始化时产生意外起始信号。
4.2 引脚方向切换的两种写法
HAL 库对 GPIO 方向切换没有提供专门的 API,常规做法是重新调用HAL_GPIO_Init,但它在内部做了延迟和时钟检测,每次调用耗时在 3μs 以上,对位时序是个隐患。另一种做法是访问 GPIO 的 MODER 寄存器,HAL 库封装了结构体,可以直接操作底层硬件。
#define DHT11_PORT GPIOA #define DHT11_PIN GPIO_PIN_6 void DHT11_Set_Output(void) { GPIOA->MODER &= ~(GPIO_MODER_MODER6); /* 清空 PA6 模式位 */ GPIOA->MODER |= (1U << (2 * 6)); /* 01 = 输出模式 */ } void DHT11_Set_Input(void) { GPIOA->MODER &= ~(GPIO_MODER_MODER6); /* 00 = 输入模式 */ GPIOA->PUPDR |= (1U << (2 * 6)); /* 01 = 上拉 */ }GPIO_MODER_MODER6是 HAL 库头文件里预定义的掩码宏,展开后就是寄存器操作。1U << (2 * 6)表示 MODER 寄存器里每 2 位控制一个引脚,PA6 对应第 12~13 位。切输入模式时把 PUPDR 对应的两位设为01,等效于标准库的GPIO_Mode_IPU。这两组宏定义放在dht11.h里,读位函数和起始信号函数共用,避免重复初始化。
4.3 HAL库读时序与超时保护
HAL 库版本的读位函数与标准库逻辑相同,但读引脚用的函数是HAL_GPIO_ReadPin,等待电平的while循环里必须加超时退出。DHT11 如果损坏或接线松动,总线会一直保持高电平,while循环会卡死整个采集线程。
uint8_t DHT11_ReadBit(void) { uint16_t timeout = 10000; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) { if (--timeout == 0) { return 0xFF; /* 超时返回异常值 */ } } Delay_Us(40); /* 跳过低电平窗口 */ timeout = 10000; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { if (--timeout == 0) { return 0xFF; } } return HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET ? 1 : 0; }超时值 10000 在 72MHz 主频下折算成时间远小于 DHT11 的响应窗口,它只起保险作用,正常工作时不会触发。注意第二个while等的是高电平结束,这样可以确保下一位起始前总线回到低电平,时序不会累积偏移。把超时返回值和校验失败都归为一次无效采集,调用方只依赖最终校验结果,不单独判断超时。
4.4 工程中DSP库文件与采集无关,不要删除
源码包里混着iar_cortexM3b_math.a、iar_cortexM3l_math.a、libarm_cortexM3l_math.a、arm_common_tables.c、arm_linear_interp_data.c,这些是 CMSIS-DSP 的库和查表文件。DHT11 采集过程只用 GPIO 翻转和延时,完全不涉及浮点运算或 FFT,所以这些文件对最终程序大小和运行效率没有影响。
它们存在的原因是创建工程模板时勾选了 DSP 库选项,Keil 或 IAR 把相应源文件拷贝到了工程目录。删除它们不会影响 DHT11 功能,但如果工程里其他模块引用了arm_math.h,链接阶段会报告找不到符号。我的建议是放在工程里不动,反正编译时只链接被引用的部分,不产生额外 Flash 占用。
5. 串口显示与验证技巧:从串口调试助手到逻辑分析仪
5.1 printf重定向到USART1
两个版本都用串口打印温湿度,重定向方式不同。标准库工程在 Keil 里勾选MicroLIB后用fputc重定向,HAL 库工程直接调用HAL_UART_Transmit。标准库的printf自带缓存和格式化逻辑,代码更简洁;HAL 库的printf重定向要处理半主机模式的问题。
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }0xFFFF是发送超时时间,单位是毫秒,这里表示无限等待。串口调试助手要设置成 115200-8-N-1,不要开流控。如果用的是 CH340 或 FTDI 芯片的 USB 转串口模块,先确认 Windows 设备管理器里枚举出的 COM 口号,下载完程序后重新拔插一次模块,避免串口被旧驱动占用。
5.2 常见异常现象与排查顺序
| 异常现象 | 可能原因 | 排查方法 |
|---|---|---|
| 温湿度输出为 0 | 起始信号未生效,引脚模式始终为输入 | 检查DHT11_Set_Output是否被调用 |
| 输出 255 或 0xFF | 总线被拉低后无法恢复 | 确认外部上拉电阻焊接,ODR 是否置 1 |
| 数据偶尔跳变 | 延时偏短,采样点落在低电平窗口 | 用逻辑分析仪测量延时实际值 |
| 串口无任何输出 | 串口助手波特率错误或引脚接反 | 短接 TX/RX 自测发送 |
| 第一次烧写后无法再下载 | SWD 引脚被复用 | CubeMX 里开启 Serial Wire,按住复位烧写 |
这些现象里最隐蔽的是“温湿度都显示 255”,因为校验字节也变成了 0xFF,帧校验反而能通过。遇到这种情况优先检查上拉电阻,而不是调延时。DHT11 模块上如果已经集成了上拉电阻,代码里就不需要开内部上拉,两个上拉并联也不会产生问题。
5.3 用逻辑分析仪抓时序波形定位问题
软件调参解决不了的时序问题,最终要靠逻辑分析仪。采样率设置在 10MHz 以上,将探头接到 PA6 和 GND,触发方式选下降沿,抓取从起始信号到 40 位数据结束的完整波形。先看主机起始低电平宽度是否在 18ms 以上,再看 DHT11 应答的低电平和 80μs 高电平是否完整,最后逐位测量高电平宽度。
波形上每一位都呈现“50μs 低 + 26μs 高”或“50μs 低 + 70μs 高”的模式。把光标放在高电平中段读出宽度,26μs 附近是数据“0”,70μs 附近是数据“1”,如果看到高低电平交替杂乱,说明延时函数实际执行时间和理论值偏差太大。这时优先把延时函数改成__no_optimize或检查系统主频是否在 72MHz,而不是怀疑 DHT11 本身。
本文还有配套的精品资源,点击获取