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。
代码逻辑是这样的:
- 先选通PCA9548A的通道0
- 读取该通道上的SHT30数据
- 选通通道1
- 读取另一个SHT30数据
- 切换到下一个PCA9548A,重复以上步骤
说白了,就是用时间换空间。虽然读取速度慢了点,但解决了地址冲突的问题。
总结一下:I2C总线看似简单,但实际开发中坑不少。起始停止时序要严格,应答信号不能忽略,多设备冲突要提前规划。我建议你在画PCB之前,先把所有I2C设备的地址列出来,看看有没有冲突。等板子打回来再改,那成本就高了。
好了,这一章的内容就到这儿。下一章我们讲SPI总线,那个速度更快,但线也更多。到时候咱们再聊。