4、嵌入式固件开发(一):传感器驱动开发,以DHT22为例讲解单总线协议,编写读取温湿度的C代码。

好,咱们进入实战环节了。

前面几章讲了硬件选型、电路设计、通信协议选型。现在终于到了写代码这一步。我个人觉得,嵌入式开发最核心的环节就是驱动开发。你硬件搭得再好,传感器选得再贵,驱动写不好,数据就是乱的。

这一章,我拿DHT22这个温湿度传感器来举例。为什么选它?因为DHT22在环境监测项目里太常见了,而且它用的通信方式——单总线协议,非常典型。你学会了DHT22,其他单总线设备(比如DS18B20)基本就是换汤不换药。

4.1 单总线协议到底是什么?

单总线,英文叫1-Wire。说白了,就是一根数据线搞定所有通信。

你想想看,平时我们用的I2C需要两根线(SCL、SDA),SPI需要四根线(MOSI、MISO、SCLK、CS)。单总线就一根线,既传数据又传时钟。怎么做到的?靠的是严格的时序控制。

我在一个农业大棚项目里用过DHT22。当时布线距离有十几米,如果用I2C,信号早衰减没了。单总线就一根线,拉远一点问题不大。当然,也别太远,超过20米建议加驱动芯片。

单总线的核心特点:

  • 一根数据线,双向通信
  • 主机(MCU)控制时序,从机(传感器)响应
  • 通信速率一般不高,DHT22典型速率在几百微秒级别
  • 每个设备有唯一ROM码,可以挂多个(但DHT22不支持多设备挂载)

4.2 DHT22的通信时序详解

DHT22的通信过程,我拆成几个步骤来讲。你跟着走一遍,心里就有数了。

4.2.1 起始信号

MCU先把数据线拉低,至少保持18ms。这是告诉DHT22:我要开始通信了,你准备一下。

然后MCU释放总线(拉高),等待20-40微秒。DHT22会在这段时间内响应。

我的经验:起始信号的低电平时间,我习惯拉到20ms。有些DHT22模块对时序比较敏感,18ms刚好卡在边界上。多给2ms余量,稳一点。

4.2.2 响应信号

DHT22收到起始信号后,会做两件事:

  1. 拉低总线80微秒(表示收到)
  2. 再拉高80微秒(表示准备发送数据)

如果MCU没等到这个响应,说明传感器没工作。可能是接线问题,也可能是传感器坏了。

4.2.3 数据位读取

这是最核心的部分。DHT22发送每一位数据时,都是先拉低50微秒,然后拉高。拉高的时间长短决定了这一位是0还是1。

数据位 低电平时间 高电平时间
逻辑0 50微秒 26-28微秒
逻辑1 50微秒 70微秒

为什么会这样设计?因为单总线没有单独的时钟线,只能靠电平宽度来区分0和1。你想想看,如果高低电平时间都一样,那接收端根本不知道你在传什么。

4.2.4 数据格式

DHT22一次传输40位数据,顺序是:

  • 高16位:湿度数据(整数部分8位 + 小数部分8位)
  • 低16位:温度数据(整数部分8位 + 小数部分8位)
  • 最后8位:校验和

注意:校验和的计算方式是:湿度高8位 + 湿度低8位 + 温度高8位 + 温度低8位,取低8位。如果和接收到的校验位不一致,这帧数据直接丢弃。我曾经遇到过一批DHT22,有5%的概率校验失败,后来发现是电源纹波太大导致的。加个100nF电容就好了。

4.3 编写DHT22驱动代码

好,理论讲完了,咱们直接上代码。我习惯用STM32的HAL库来写,但底层GPIO操作是通用的,你移植到其他MCU也很方便。

#include "dht22.h"
#include "delay.h"  // 需要微秒级延时函数

#define DHT22_GPIO_PORT  GPIOB
#define DHT22_GPIO_PIN   GPIO_PIN_0

// 拉低总线
static void DHT22_SetLow(void) {
    HAL_GPIO_WritePin(DHT22_GPIO_PORT, DHT22_GPIO_PIN, GPIO_PIN_RESET);
}

// 拉高总线
static void DHT22_SetHigh(void) {
    HAL_GPIO_WritePin(DHT22_GPIO_PORT, DHT22_GPIO_PIN, GPIO_PIN_SET);
}

// 读取总线电平
static uint8_t DHT22_ReadPin(void) {
    return HAL_GPIO_ReadPin(DHT22_GPIO_PORT, DHT22_GPIO_PIN);
}

// 配置GPIO为输出模式
static void DHT22_SetOutput(void) {
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = DHT22_GPIO_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(DHT22_GPIO_PORT, &GPIO_InitStruct);
}

// 配置GPIO为输入模式
static void DHT22_SetInput(void) {
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = DHT22_GPIO_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(DHT22_GPIO_PORT, &GPIO_InitStruct);
}

// 读取一个字节
static uint8_t DHT22_ReadByte(void) {
    uint8_t data = 0;
    for (int i = 0; i < 8; i++) {
        // 等待低电平结束
        while (DHT22_ReadPin() == 0);
        // 延时30微秒后采样
        delay_us(30);
        if (DHT22_ReadPin() == 1) {
            data |= (1 << (7 - i));  // 高位在前
        }
        // 等待高电平结束
        while (DHT22_ReadPin() == 1);
    }
    return data;
}

// 读取温湿度数据
uint8_t DHT22_Read(float *humidity, float *temperature) {
    uint8_t data[5] = {0};
    
    // 1. 发送起始信号
    DHT22_SetOutput();
    DHT22_SetLow();
    delay_ms(20);  // 至少18ms,我习惯20ms
    DHT22_SetHigh();
    delay_us(30);  // 等待20-40微秒
    
    // 2. 切换为输入,等待响应
    DHT22_SetInput();
    
    // 等待DHT22拉低总线(响应信号开始)
    uint32_t timeout = 0;
    while (DHT22_ReadPin() == 1) {
        timeout++;
        if (timeout > 1000) return 1;  // 超时,传感器无响应
        delay_us(1);
    }
    
    // 等待80微秒低电平结束
    while (DHT22_ReadPin() == 0);
    // 等待80微秒高电平结束
    while (DHT22_ReadPin() == 1);
    
    // 3. 读取40位数据
    for (int i = 0; i < 5; i++) {
        data[i] = DHT22_ReadByte();
    }
    
    // 4. 校验
    uint8_t checksum = data[0] + data[1] + data[2] + data[3];
    if (checksum != data[4]) {
        return 2;  // 校验失败
    }
    
    // 5. 解析数据
    uint16_t hum_raw = ((uint16_t)data[0] << 8) | data[1];
    uint16_t temp_raw = ((uint16_t)data[2] << 8) | data[3];
    
    *humidity = hum_raw / 10.0f;
    *temperature = temp_raw / 10.0f;
    
    return 0;  // 成功
}

4.4 代码要点解析

这段代码我写的时候有几个地方特别留意,跟你分享一下:

  • 延时函数必须精确:DHT22的时序在微秒级别。我用的是定时器做的微秒延时,千万别用HAL_Delay,那个是毫秒级的,精度不够。
  • 超时处理不能少:如果传感器没接或者坏了,while循环会卡死。加个超时计数,超过一定时间直接返回错误码。我在现场调试时遇到过线松了,程序卡死,整个系统都挂了。从那以后,所有while循环我都加超时。
  • GPIO方向切换:起始信号时MCU是输出模式,之后要切回输入模式。如果忘了切,MCU和传感器会打架,数据根本读不到。

避坑指南:我曾经在批量生产时发现,有5%的DHT22读取失败。排查了很久,最后发现是PCB走线太长,寄生电容影响了时序。解决办法是在数据线上加一个4.7kΩ上拉电阻,并且把走线控制在10cm以内。

4.5 主函数调用示例

#include "dht22.h"
#include "usart.h"  // 串口打印用

int main(void) {
    HAL_Init();
    SystemClock_Config();
    MX_USART1_UART_Init();
    
    float humidity = 0.0f;
    float temperature = 0.0f;
    uint8_t ret;
    
    while (1) {
        ret = DHT22_Read(&humidity, &temperature);
        
        if (ret == 0) {
            char buf[64];
            sprintf(buf, "湿度: %.1f%%, 温度: %.1f°C\r\n", humidity, temperature);
            HAL_UART_Transmit(&huart1, (uint8_t*)buf, strlen(buf), 100);
        } else if (ret == 1) {
            printf("传感器无响应,请检查接线\r\n");
        } else if (ret == 2) {
            printf("数据校验失败,丢弃本次数据\r\n");
        }
        
        HAL_Delay(2000);  // DHT22采样间隔至少2秒
    }
}

注意最后那个HAL_Delay(2000)。DHT22的数据手册明确写了,两次读取间隔至少2秒。如果你读得太快,传感器会来不及更新数据,返回的全是旧值。我见过有人1秒读一次,结果数据曲线跟心电图似的,上下乱跳。

4.6 总结一下

单总线协议看着简单,但坑不少。核心就是时序要准,超时要加,校验不能省。你把这套DHT22的驱动吃透了,以后遇到其他单总线设备,比如DS18B20、SHT30(单总线模式),基本就是改改时序参数的事。

下一章我会讲怎么把多个传感器整合到一起,做一个完整的数据采集固件。到时候咱们还会用到RTOS,让系统跑得更稳。