4. I2C总线与温湿度传感器:从时序到实战

各位同学,今天我们来啃一块硬骨头——I2C总线。说实话,I2C这玩意儿看着简单,但坑是真不少。我当年刚入行时,就被一个应答信号折腾了两天。嗯,咱们今天就把这些坑一个个填平。

4.1 I2C协议时序:起始、停止与应答

I2C总线就两根线:SCL(时钟)和SDA(数据)。但你别小看这两根线,里面的规矩可不少。

4.1.1 起始条件与停止条件

先说说最基础的。起始条件:SCL高电平时,SDA从高变低。停止条件:SCL高电平时,SDA从低变高。

我个人的习惯是,写代码前先在逻辑分析仪上看一眼波形。你想想看,如果起始条件没做对,后面所有数据都是白搭。

// 起始条件
void i2c_start(void) {
    SDA_HIGH();
    delay_us(5);
    SCL_HIGH();
    delay_us(5);
    SDA_LOW();   // SCL高时,SDA下降沿
    delay_us(5);
    SCL_LOW();   // 准备传输数据
    delay_us(5);
}

// 停止条件
void i2c_stop(void) {
    SDA_LOW();
    delay_us(5);
    SCL_HIGH();
    delay_us(5);
    SDA_HIGH();  // SCL高时,SDA上升沿
    delay_us(5);
}

注意:我曾经遇到过一个问题——起始条件后忘了拉低SCL,结果从机根本不响应。记住:起始条件完成后,SCL必须拉低,否则后续的数据位无法正常传输。

4.1.2 数据位传输与应答机制

I2C的数据传输,说白了就是SCL低电平时准备数据,高电平时采样数据。每个字节8位,后面跟一个应答位。

应答机制是这样的:主机发送完8位数据后,释放SDA线(拉高),然后产生第9个时钟脉冲。从机如果拉低SDA,表示应答(ACK);如果保持高,表示非应答(NACK)。

为什么会需要NACK?我举个例子:你读传感器数据时,如果读到最后一个字节,主机发送NACK告诉从机「够了,别发了」。这在多字节读取时特别重要。

// 发送一个字节,并检查应答
uint8_t i2c_send_byte(uint8_t data) {
    uint8_t i;
    for (i = 0; i < 8; i++) {
        if (data & 0x80) {
            SDA_HIGH();
        } else {
            SDA_LOW();
        }
        data <<= 1;
        SCL_HIGH();
        delay_us(5);
        SCL_LOW();
        delay_us(5);
    }
    
    // 检查应答
    SDA_HIGH();  // 释放SDA
    SCL_HIGH();
    delay_us(5);
    if (SDA_READ()) {  // 如果SDA为高,表示NACK
        SCL_LOW();
        return 1;  // 返回错误
    }
    SCL_LOW();
    return 0;  // 成功
}

小技巧:调试I2C时,我建议你在每个字节传输后加个延时。虽然会降低速度,但排查问题方便很多。等调通了再优化不迟。

4.2 SHT30温湿度传感器驱动开发

SHT30是Sensirion家的产品,精度不错,价格也适中。它的I2C地址是0x44(ADDR引脚接GND)或0x45(接VDD)。

4.2.1 初始化与测量命令

SHT30的测量命令是16位的。比如单次测量命令:0x2C 0x06(高重复性,时钟拉伸使能)。

我刚开始用SHT30时,犯了个低级错误——忘了等测量完成就去读数据。结果读回来的全是0xFF。后来看了数据手册才发现,测量需要约15ms的时间。

// SHT30单次测量
uint8_t sht30_measure(void) {
    uint8_t cmd[2] = {0x2C, 0x06};
    
    i2c_start();
    if (i2c_send_byte(0x44 << 1)) {  // 写地址
        i2c_stop();
        return 1;
    }
    i2c_send_byte(cmd[0]);
    i2c_send_byte(cmd[1]);
    i2c_stop();
    
    delay_ms(20);  // 等测量完成
    
    // 读取6字节数据
    i2c_start();
    i2c_send_byte((0x44 << 1) | 0x01);  // 读地址
    uint8_t data[6];
    for (int i = 0; i < 6; i++) {
        data[i] = i2c_read_byte();
        if (i < 5) {
            i2c_send_ack();   // 发送ACK
        } else {
            i2c_send_nack();  // 最后一个字节发NACK
        }
    }
    i2c_stop();
    
    // 计算温度和湿度
    uint16_t temp_raw = (data[0] << 8) | data[1];
    uint16_t hum_raw = (data[3] << 8) | data[4];
    
    float temperature = -45.0 + 175.0 * temp_raw / 65535.0;
    float humidity = 100.0 * hum_raw / 65535.0;
    
    return 0;
}

关键点:SHT30的CRC校验是可选的,但建议加上。我遇到过一批传感器,偶尔会读出异常数据,加了CRC校验后就能过滤掉这些坏数据。

4.3 DHT12温湿度传感器驱动开发

DHT12是国产传感器,价格便宜,但精度一般。它的I2C地址固定为0x5C。

说实话,DHT12的驱动比SHT30简单多了。它内部有寄存器,直接读就行,不需要发测量命令。

// DHT12读取温湿度
uint8_t dht12_read(float *temp, float *hum) {
    uint8_t buf[5];
    
    // 从寄存器0x00开始读5个字节
    i2c_start();
    i2c_send_byte(0x5C << 1);  // 写地址
    i2c_send_byte(0x00);        // 寄存器地址
    i2c_stop();
    
    delay_ms(10);  // DHT12需要一点时间准备数据
    
    i2c_start();
    i2c_send_byte((0x5C << 1) | 0x01);  // 读地址
    for (int i = 0; i < 5; i++) {
        buf[i] = i2c_read_byte();
        if (i < 4) i2c_send_ack();
        else i2c_send_nack();
    }
    i2c_stop();
    
    // 校验和
    if (buf[4] != (buf[0] + buf[1] + buf[2] + buf[3]) & 0xFF) {
        return 1;  // 校验失败
    }
    
    *hum = buf[0] + buf[1] * 0.1;
    *temp = buf[2] + (buf[3] & 0x7F) * 0.1;
    if (buf[3] & 0x80) *temp = -*temp;  // 负温度
    
    return 0;
}

避坑指南:DHT12的校验和算法很简单,就是前4个字节相加取低8位。我曾经遇到过一批DHT12,校验和总是算不对。后来发现是读取时序太快了,加了个10ms延时就好了。

4.4 多设备地址冲突解决

这个问题在实际项目中太常见了。你想想看,一个板子上可能有好几个I2C设备,万一地址冲突了怎么办?

4.4.1 硬件解决方案

很多传感器都有地址选择引脚。比如SHT30的ADDR引脚,接GND是0x44,接VDD是0x45。如果你有两个SHT30,一个接GND,一个接VDD,地址就不冲突了。

但有些传感器地址是固定的,比如DHT12的0x5C。这时候怎么办?

  • 使用I2C多路复用器:比如PCA9548A,可以分时选通不同的I2C通道
  • 使用GPIO模拟I2C:给每个冲突的设备分配独立的GPIO引脚
  • 更换传感器型号:选地址可配置的型号

4.4.2 软件解决方案

如果硬件上没法改,软件上也有办法。我个人的经验是:先扫描总线,看看哪些地址有设备响应。

// I2C总线扫描
void i2c_scan(void) {
    printf("Scanning I2C bus...\n");
    for (uint8_t addr = 0x08; addr < 0x78; addr++) {
        i2c_start();
        if (i2c_send_byte(addr << 1) == 0) {
            printf("Found device at 0x%02X\n", addr);
        }
        i2c_stop();
        delay_ms(1);
    }
    printf("Scan complete.\n");
}

实用技巧:如果两个设备地址相同,但功能不同(比如一个温度传感器、一个气压传感器),你可以通过设备ID寄存器来区分。SHT30有0x3677的ID,DHT12就没有。先读ID,再决定怎么操作。

4.4.3 实际项目中的处理策略

我在一个智能家居项目里遇到过这种情况:用了4个SHT30,但只有两个地址可选。最后我用了两个PCA9548A多路复用器,每个复用器接两个SHT30。

代码逻辑是这样的:

  1. 先选通PCA9548A的通道0
  2. 读取该通道上的SHT30数据
  3. 选通通道1
  4. 读取另一个SHT30数据
  5. 切换到下一个PCA9548A,重复以上步骤

说白了,就是用时间换空间。虽然读取速度慢了点,但解决了地址冲突的问题。

总结一下:I2C总线看似简单,但实际开发中坑不少。起始停止时序要严格,应答信号不能忽略,多设备冲突要提前规划。我建议你在画PCB之前,先把所有I2C设备的地址列出来,看看有没有冲突。等板子打回来再改,那成本就高了。

好了,这一章的内容就到这儿。下一章我们讲SPI总线,那个速度更快,但线也更多。到时候咱们再聊。