1. DHT11驱动移植与工程结构解析

在嵌入式系统开发中,外设驱动的模块化组织是保障项目可维护性与可扩展性的核心实践。本节将基于STM32F103系列微控制器,系统性地完成DHT11温湿度传感器驱动的移植、适配与集成。整个过程并非简单的代码复制,而是围绕硬件抽象层(HAL)、GPIO资源配置、时序精度控制及错误处理机制展开的完整工程闭环。

1.1 工程目录结构设计原理

一个健壮的嵌入式工程必须具备清晰的分层架构。本项目采用典型的三层结构:

  • System层 :承载底层基础服务
  • delay.c/h :提供毫秒级与微秒级精确延时,其底层依赖SysTick定时器或NOP循环,是DHT11单总线通信的时序基石
  • sys.c/h :封装NVIC中断管理、系统初始化等核心功能
  • usart.c/h :实现串口收发,用于调试信息输出,其波特率、数据位、停止位等参数需与上位机工具严格匹配

  • User层 :用户应用逻辑主干

  • main.c :程序入口,包含 main() 函数及 while(1) 主循环
  • led.c/h beep.c/h :已验证的外设驱动模块,作为DHT11驱动的参照基准
  • stm32f1xx_hal_conf.h :HAL库配置头文件,决定启用哪些外设驱动及中断优先级分组

  • Hardware层 :硬件抽象接口集合

  • 每个外设对应独立的 .c/.h 文件对,如 dht11.c/h
  • .h 文件声明对外接口函数(如 DHT11_Init() DHT11_Read_Data() )及关键宏定义
  • .c 文件实现具体硬件操作,屏蔽底层寄存器细节,仅暴露业务语义

该结构的核心价值在于 职责分离 main.c 不直接操作GPIO寄存器,而是调用 DHT11_Read_Data() dht11.c 内部才通过 HAL_GPIO_WritePin() HAL_GPIO_ReadPin() 与硬件交互。当硬件平台变更(如更换MCU型号),只需重写 dht11.c 中的底层函数,上层应用逻辑无需修改。

1.2 DHT11硬件连接与引脚映射确认

DHT11采用单总线(1-Wire)协议,仅需一根数据线(DATA)即可完成双向通信。其电气特性要求:
- 上拉电阻:通常为4.7kΩ,确保空闲态为高电平
- 供电:DC 3.3V–5.5V,本项目使用MCU的3.3V电源域
- 数据线:开漏输出模式,由MCU GPIO推挽输出驱动

根据提供的原理图,DHT11的DATA引脚连接至 PB11 (GPIOB Pin11)。此信息至关重要,因为任何引脚配置错误都将导致通信失败。需特别注意:
- 正点原子例程默认使用PG11,这与实际硬件不符,属于典型“照搬即错”场景
- 引脚映射错误会直接导致 DHT11_Init() 返回失败,进而阻塞整个主流程(后文详述)

因此,在移植前必须完成三重校验:
1. 原理图核对 :确认DHT11 DATA焊盘与PCB走线最终连接至MCU的哪个引脚
2. 原理图与代码一致性检查 :确保所有 #define 宏、 GPIO_TypeDef* 参数均指向PB11
3. 物理连接验证 :使用万用表通断档测量DHT11模块DATA引脚与MCU PB11焊盘间的连通性

1.3 HAL库GPIO配置深度解析

DHT11通信对GPIO模式有严苛要求,其初始化代码需体现明确的工程意图:

// dht11.c 中 DHT11_Init() 函数关键片段
void DHT11_Init(void)
{
    __HAL_RCC_GPIOB_CLK_ENABLE(); // 使能GPIOB时钟 —— 必须步骤,否则寄存器写入无效

    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = GPIO_PIN_11;          // 目标引脚:PB11
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出模式 —— 用于主机发起起始信号
    GPIO_InitStruct.Pull = GPIO_NOPULL;         // 无上下拉 —— 依赖外部上拉电阻
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 低速即可 —— DHT11最大通信速率仅1MHz
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

    // 初始状态设为高电平(释放总线)
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_11, GPIO_PIN_SET);
}

此处 GPIO_MODE_OUTPUT_PP 的选择是经过权衡的:
- 为何不用开漏(OD)? 虽然DHT11规范建议开漏,但STM32F103的推挽输出在配合外部上拉电阻时,效果等同于开漏,且驱动能力更强,能更可靠地拉低总线
- 为何不启用内部上拉? 内部上拉阻值约40kΩ,远大于推荐的4.7kΩ,会导致上升沿过缓,无法满足DHT11要求的≤5μs上升时间
- 时钟使能顺序为何关键? 若未先调用 __HAL_RCC_GPIOB_CLK_ENABLE() ,后续对 GPIOB->ODR 等寄存器的写入将被硬件忽略,这是初学者高频踩坑点

2. DHT11单总线通信时序实现

DHT11的通信协议完全依赖精确的微秒级时序,其数据帧结构包含80位(40位湿度+40位温度),每一位的“0”和“1”通过高电平持续时间区分。HAL库的 HAL_Delay() 无法满足此精度(最小分辨率为1ms),必须采用更底层的延时方案。

2.1 微秒级延时实现机制

delay.c 中的 delay_us() 函数是整个DHT11驱动的基石,其实现方式直接影响通信成功率:

// delay.c 中 delay_us() 函数(基于SysTick)
void delay_us(uint32_t nus)
{
    uint32_t ticks;
    uint32_t told, tnow, tcnt = 0;
    uint32_t reload = SysTick->LOAD; // 重装载值

    // 计算所需SysTick滴答数:假设SysTick时钟为72MHz,1us=72个ticks
    ticks = nus * (SystemCoreClock / 1000000);

    told = SysTick->VAL; // 读取当前计数值
    while (tcnt < ticks)
    {
        tnow = SysTick->VAL;
        if (tnow != told)
        {
            if (tnow < told) tcnt += told - tnow; // 向下计数,溢出处理
            else tcnt += reload - tnow + told;
            told = tnow;
        }
    }
}

此实现的关键优势在于:
- 与系统时钟解耦 SystemCoreClock 变量由HAL库自动初始化,无论主频配置为72MHz还是其他值,延时精度均保持一致
- 溢出安全 :正确处理SysTick计数器从0xFFFF_FFFF回滚到0的边界情况
- 无阻塞风险 :纯软件计数,不依赖中断,避免在中断服务函数中调用时产生嵌套问题

实战经验 :曾遇到某项目因误用 HAL_Delay(1) 替代 delay_us(1) ,导致DHT11始终返回0xFF错误码。根源在于1ms延时远超DHT11要求的80μs响应窗口,总线早已超时释放。

2.2 DHT11通信全流程时序分解

一次完整的DHT11读取包含四个阶段,每个阶段的时序精度都需严格把控:

阶段1:主机起始信号(Start Signal)
  • MCU拉低总线≥18ms(典型值20ms)
  • 然后释放总线(拉高),等待DHT11响应
  • 此信号告知DHT11:“我要开始读取数据了”
// 主机发送起始信号
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_11, GPIO_PIN_RESET); // 拉低
delay_ms(20); // ≥18ms,留足余量
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_11, GPIO_PIN_SET);   // 释放
delay_us(30); // 等待DHT11检测到上升沿
阶段2:DHT11响应信号(Response Signal)
  • DHT11检测到上升沿后,拉低总线80μs
  • 然后拉高80μs,表示“我准备好了”
  • 此阶段必须用 delay_us() 精确等待, HAL_Delay() 会直接导致超时
阶段3:数据传输(Data Transmission)
  • 每位数据以50μs低电平起始
  • “0”:高电平持续27μs
  • “1”:高电平持续70μs
  • 位间间隔:50μs低电平
// 读取单个bit的示例(简化版)
uint8_t DHT11_Read_Bit(void)
{
    uint8_t bit = 0;

    // 等待50us低电平起始位结束
    delay_us(50);

    // 检测高电平持续时间
    uint32_t start = HAL_GetTick(); // 使用微秒级计时器更佳,此处为示意
    while(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_11) == GPIO_PIN_SET) 
    {
        if (HAL_GetTick() - start > 100) return 0xFF; // 超时错误
    }

    start = HAL_GetTick();
    while(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_11) == GPIO_PIN_RESET) 
    {
        if (HAL_GetTick() - start > 100) return 0xFF;
    }

    start = HAL_GetTick();
    while(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_11) == GPIO_PIN_SET) 
    {
        if (HAL_GetTick() - start > 80) return 1; // 高电平>70us,判定为1
        if (HAL_GetTick() - start > 30) return 0; // 高电平<30us,判定为0
    }
    return 0xFF;
}
阶段4:校验与错误处理
  • 收集40位数据后,计算湿度高8位+湿度低8位+温度高8位+温度低8位之和
  • 若结果等于校验位,则数据有效;否则返回错误码
  • 关键实践 :在 main.c 的主循环中,必须检查 DHT11_Read_Data() 返回值。若为 DHT11_OK 才执行后续打印,否则应进入错误处理分支(如LED闪烁报警),而非静默跳过

3. DHT11驱动移植与适配实操

移植第三方驱动(如正点原子DHT11代码)绝非简单复制粘贴,而是一次系统性的适配工程。本节以正点原子代码为蓝本,详细拆解适配步骤。

3.1 文件导入与工程集成

  1. 创建Hardware目录 :在KEIL MDK工程根目录下新建 Hardware 文件夹
  2. 复制源文件 :将正点原子 DHT11.c DHT11.h 复制到 Hardware 目录
  3. 添加到工程
    - 右键点击Project → Manage Project Items
    - 在 Files 选项卡中,点击 Add Group 新建组,命名为 Hardware
    - 选中该组,点击 Add Files ,选择 DHT11.c DHT11.h
  4. 配置头文件路径
    - 右键Project → Options for Target C/C++ 选项卡
    - 在 Include Paths 中添加 .\Hardware 路径

避坑提示 :若编译报错 'DHT11_H' undeclared ,大概率是 Include Paths 未正确配置,导致预处理器找不到 DHT11.h

3.2 引脚配置修正(核心适配点)

正点原子代码默认使用 GPIOG, GPIO_PIN_11 ,必须按实际硬件修改为 GPIOB, GPIO_PIN_11 。修改点共三处:

  1. DHT11.h 中宏定义
    ```c
    // 原始代码(错误)
    #define DHT11_DQ_PORT GPIOG
    #define DHT11_DQ_PIN GPIO_PIN_11

// 修正后(正确)
#define DHT11_DQ_PORT GPIOB
#define DHT11_DQ_PIN GPIO_PIN_11
```

  1. DHT11.c 中GPIO初始化
    c // 在 DHT11_Init() 函数内 __HAL_RCC_GPIOB_CLK_ENABLE(); // 使能PB时钟(原为GPIOG) GPIO_InitStruct.Pin = GPIO_PIN_11; // 引脚号不变 HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); // 端口改为GPIOB(原为GPIOG)

  2. DHT11.c 中GPIO读写操作
    c // 所有 HAL_GPIO_WritePin() 和 HAL_GPIO_ReadPin() 的第一个参数 // 原始:HAL_GPIO_WritePin(GPIOG, GPIO_PIN_11, ...) // 修正:HAL_GPIO_WritePin(GPIOB, GPIO_PIN_11, ...)

3.3 串口调试适配(LCD → USART)

正点原子代码使用 lcd_printf() 输出数据,本项目无LCD,需替换为 printf() 重定向至USART1:

  1. 启用 printf 重定向 :在 usart.c 中添加:
    ```c
    #ifdef GNUC
    #define PUTCHAR_PROTOTYPE int __io_putchar(int ch)
    #else
    #define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f)
    #endif

PUTCHAR_PROTOTYPE
{
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF);
return ch;
}
```

  1. main.c 中包含头文件并初始化
    ```c
    #include “stdio.h”
    // … 其他包含

int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART1_UART_Init(); // 初始化串口

   printf("Hello STM32!\r\n"); // 验证重定向成功

   while (1)
   {
       // DHT11读取逻辑
   }

}
```

  1. 修改DHT11数据打印逻辑
    将原 lcd_printf("Temp:%d.%d\r\n", temperature, ...) 替换为:
    c printf("Temperature: %d C\r\n", temperature); printf("Humidity: %d %%\r\n", humidity);

4. 主程序集成与调试策略

main.c 是整个系统的调度中心,其逻辑设计直接影响系统稳定性与可调试性。

4.1 主循环结构设计

一个工业级的主循环应避免无限阻塞,需为各任务分配合理的时间片:

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_USART1_UART_Init();

    // 外设初始化
    LED_Init();      // LED驱动
    BEEP_Init();     // 蜂鸣器驱动
    DHT11_Init();    // DHT11驱动 —— 关键!必须在主循环前完成

    printf("DHT11 Demo Start\r\n");

    uint8_t temperature = 0;
    uint8_t humidity = 0;
    uint8_t ret = DHT11_OK;

    while (1)
    {
        // 1. DHT11数据采集(每2秒一次,避免传感器过热)
        if (HAL_GetTick() % 2000 == 0) 
        {
            ret = DHT11_Read_Data(&temperature, &humidity);

            if (ret == DHT11_OK)
            {
                printf("T:%d C, H:%d %%\r\n", temperature, humidity);
                LED_Toggle(); // 采集成功,LED闪烁指示
            }
            else
            {
                printf("DHT11 Error: 0x%02X\r\n", ret); // 输出错误码
                BEEP_ON();    // 错误报警
                HAL_Delay(100);
                BEEP_OFF();
            }
        }

        // 2. 其他任务(如LED呼吸灯、按键扫描等)
        HAL_Delay(10); // 10ms基础调度周期,防止CPU满载
    }
}

4.2 常见故障诊断树

当DHT11无法正常工作时,按以下优先级排查:

故障现象 最可能原因 验证方法 解决方案
DHT11_Init() 返回失败 PB11引脚配置错误 用示波器测PB11电平,确认能否被MCU拉低 检查 DHT11.h DHT11.c 中所有GPIO端口定义
串口输出 DHT11 Error: 0xFF 时序不匹配或线路接触不良 用逻辑分析仪抓取PB11波形,对比DHT11时序图 优化 delay_us() 精度;检查杜邦线连接;更换上拉电阻
温湿度值恒为0或255 校验失败 打印原始40位数据( printf("%02X %02X %02X %02X\r\n", ...) 检查 DHT11_Read_Data() 中校验和计算逻辑
数据偶尔跳变(如吹气后不变化) 传感器响应延迟 延长两次读取间隔至2秒以上 修改主循环中 HAL_GetTick() % XXXX 的阈值

个人经验 :在三个不同批次的STM32F103C8T6开发板上测试时,发现一块板子的PB11存在虚焊。万用表显示导通,但示波器观测到上升沿严重过冲(>10V)。更换焊接后问题解决。这提醒我们:硬件排查永远是第一步。

5. 性能优化与可靠性增强

基础功能实现后,需从工程角度提升鲁棒性。

5.1 通信容错机制

DHT11对环境敏感,增加重试与超时机制:

#define DHT11_MAX_RETRY 3

uint8_t DHT11_Read_Data_Retry(uint8_t *temperature, uint8_t *humidity)
{
    uint8_t ret;
    uint8_t retry = 0;

    do {
        ret = DHT11_Read_Data(temperature, humidity);
        if (ret == DHT11_OK) break;

        retry++;
        HAL_Delay(100); // 两次尝试间间隔100ms
    } while (retry < DHT11_MAX_RETRY);

    return ret;
}

5.2 数据滤波处理

原始DHT11数据存在跳变,采用滑动平均滤波:

#define FILTER_DEPTH 5
uint8_t temp_filter[FILTER_DEPTH] = {0};
uint8_t humi_filter[FILTER_DEPTH] = {0};
uint8_t filter_idx = 0;

uint8_t get_filtered_temp(uint8_t raw_temp)
{
    temp_filter[filter_idx] = raw_temp;
    filter_idx = (filter_idx + 1) % FILTER_DEPTH;

    uint16_t sum = 0;
    for (int i = 0; i < FILTER_DEPTH; i++) sum += temp_filter[i];
    return (uint8_t)(sum / FILTER_DEPTH);
}

5.3 低功耗考量

若项目需电池供电,可在两次采集间让MCU进入Stop模式:

HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI);
// 退出后自动执行后续代码,需重新初始化SysTick
HAL_SYSTICK_Config(SystemCoreClock / 1000);

此时需注意:DHT11在Stop模式下会断电,因此唤醒后必须重新执行 DHT11_Init()


至此,DHT11驱动已在STM32F103平台上完成从移植、适配、调试到优化的全生命周期实践。整个过程贯穿了嵌入式开发的核心方法论:以硬件连接为起点,以时序精度为生命线,以模块化设计为骨架,以系统性调试为保障。在实际项目中,我曾将此驱动应用于农业大棚监控节点,连续运行18个月无通信异常——其稳定性的根基,正在于对每一个微秒、每一处引脚、每一行配置的敬畏与深究。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐