4、I2C驱动层实现:I2C起始/停止条件、发送/接收字节、多字节读写封装、超时与错误处理机制
好,我们进入驱动层的核心部分。I2C总线协议,说白了就是一根时钟线(SCL)加一根数据线(SDA)的舞蹈。你要让这两根线按照规定的节拍跳起来,才能跟传感器对话。这一章,我们就来手撕I2C的底层驱动。
4.1 I2C起始条件与停止条件
I2C通信的开始和结束,都有固定的信号。起始条件(Start Condition)是:SCL为高电平时,SDA从高电平切换到低电平。停止条件(Stop Condition)则是:SCL为高电平时,SDA从低电平切换到高电平。
嗯,这里要注意。很多新手会忽略时序的严格性。我在项目中遇到过,因为GPIO的翻转速度不够快,导致起始条件没被从设备识别,整包数据都丢了。所以,代码里一定要加微秒级的延时。
核心要点:起始和停止条件必须在SCL高电平时完成SDA的跳变。这是I2C协议的硬性规定,没有商量的余地。
// 起始条件
void i2c_start(void) {
SDA_HIGH(); // 确保SDA为高
SCL_HIGH(); // SCL拉高
delay_us(5); // 保持稳定
SDA_LOW(); // SDA拉低,产生起始条件
delay_us(5); // 保持
SCL_LOW(); // SCL拉低,准备传输数据
}
// 停止条件
void i2c_stop(void) {
SDA_LOW(); // 确保SDA为低
SCL_HIGH(); // SCL拉高
delay_us(5); // 保持
SDA_HIGH(); // SDA拉高,产生停止条件
delay_us(5); // 保持
}
个人习惯:我一般会把delay_us(5)改成根据主频动态计算的宏。比如#define I2C_DELAY (SystemCoreClock / 100000 / 2)。这样移植到不同主频的MCU时,不用一个个改延时。
4.2 发送与接收一个字节
I2C的数据传输是以字节为单位的。每发送一个字节后,从设备会回复一个ACK(应答)或NACK(非应答)。发送字节时,数据在SCL低电平时准备好,SCL高电平时被采样。说白了,就是SCL上升沿锁存数据。
接收字节的逻辑刚好反过来。主机在SCL低电平时释放SDA,从设备在SCL高电平时把数据放到SDA上。主机在SCL高电平时读取SDA的电平状态。
// 发送一个字节,返回从设备的应答位
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,等待从设备应答
SDA_HIGH();
SCL_HIGH();
delay_us(5);
uint8_t ack = SDA_READ(); // 读取应答位
SCL_LOW();
delay_us(5);
return ack; // 0: ACK, 1: NACK
}
// 接收一个字节,主机发送应答位
uint8_t i2c_recv_byte(uint8_t ack) {
uint8_t i, data = 0;
SDA_HIGH(); // 释放SDA,让从设备控制
for (i = 0; i < 8; i++) {
data <<= 1;
SCL_HIGH();
delay_us(5);
if (SDA_READ()) {
data |= 0x01;
}
SCL_LOW();
delay_us(5);
}
// 主机发送应答
if (ack) {
SDA_HIGH(); // NACK
} else {
SDA_LOW(); // ACK
}
SCL_HIGH();
delay_us(5);
SCL_LOW();
delay_us(5);
SDA_HIGH(); // 释放SDA
return data;
}
避坑指南:我曾经在接收最后一个字节时忘了发NACK,结果从设备以为我还要继续读,一直拉着SDA不放,导致总线卡死。记住:读最后一个字节时,一定要发NACK,然后发停止条件。
4.3 多字节读写封装
溶解氧传感器通常需要读写多个寄存器。比如读取校准参数时,可能要连续读4个字节。如果每次都调用单字节函数,效率太低。所以我们要封装多字节读写函数。
多字节读写的套路其实很简单:先发设备地址+写位,再发寄存器地址,然后重启起始条件,发设备地址+读位,最后连续读取N个字节。
// 多字节写:先发寄存器地址,再发数据
uint8_t i2c_write_multi(uint8_t dev_addr, uint8_t reg_addr,
uint8_t *data, uint16_t len) {
i2c_start();
if (i2c_send_byte(dev_addr << 1 | 0)) { // 写位
i2c_stop();
return 1; // 设备无应答
}
if (i2c_send_byte(reg_addr)) {
i2c_stop();
return 2; // 寄存器地址无应答
}
for (uint16_t i = 0; i < len; i++) {
if (i2c_send_byte(data[i])) {
i2c_stop();
return 3; // 数据发送失败
}
}
i2c_stop();
return 0; // 成功
}
// 多字节读:先写寄存器地址,再读数据
uint8_t i2c_read_multi(uint8_t dev_addr, uint8_t reg_addr,
uint8_t *buffer, uint16_t len) {
i2c_start();
if (i2c_send_byte(dev_addr << 1 | 0)) {
i2c_stop();
return 1;
}
if (i2c_send_byte(reg_addr)) {
i2c_stop();
return 2;
}
i2c_start(); // 重启起始条件
if (i2c_send_byte(dev_addr << 1 | 1)) { // 读位
i2c_stop();
return 3;
}
for (uint16_t i = 0; i < len; i++) {
uint8_t ack = (i == len - 1) ? 1 : 0; // 最后一字节发NACK
buffer[i] = i2c_recv_byte(ack);
}
i2c_stop();
return 0;
}
我的经验:多字节读写时,我习惯把错误码定义成枚举。比如I2C_OK、I2C_ERR_NO_ACK、I2C_ERR_TIMEOUT。这样上层调用时,看一眼返回值就知道哪里出了问题,不用猜。
4.4 超时与错误处理机制
嵌入式系统里,最怕的就是死等。如果从设备突然掉线,或者总线被拉死,你的程序就会卡在某个while循环里出不来。所以,超时机制是必须的。
我一般用硬件定时器或者系统滴答定时器来实现超时。每次开始I2C操作前,记录当前时间戳。在等待应答或等待总线空闲的循环里,不断检查时间差是否超过阈值。
// 带超时的起始条件
uint8_t i2c_start_timeout(uint32_t timeout_ms) {
uint32_t start = get_tick_ms();
// 等待总线空闲(SDA和SCL都为高)
while (!(SDA_READ() && SCL_READ())) {
if (get_tick_ms() - start > timeout_ms) {
return I2C_ERR_TIMEOUT; // 超时
}
}
i2c_start();
return I2C_OK;
}
// 带超时的发送字节
uint8_t i2c_send_byte_timeout(uint8_t data, uint32_t timeout_ms) {
uint32_t start = get_tick_ms();
// ... 正常的发送逻辑 ...
// 等待应答时加入超时检查
while (/* 等待应答条件 */) {
if (get_tick_ms() - start > timeout_ms) {
i2c_stop(); // 超时后主动释放总线
return I2C_ERR_TIMEOUT;
}
}
return I2C_OK;
}
错误处理策略:
- 设备无应答:重试3次,如果还是无应答,上报错误并复位I2C外设。
- 总线超时:强制拉高SCL和SDA,发送9个时钟脉冲让从设备复位。
- 数据校验错误:丢弃当前数据包,重新发起通信。
你想想看,如果传感器在深水里泡着,突然总线卡死了,你的设备是不是就废了?所以,超时和错误处理不是锦上添花,而是保命用的。
避坑指南:我曾经在一个项目里,超时时间设得太短(只有1ms),结果传感器正常工作时偶尔也会触发超时。后来我把超时时间改成10ms,问题就解决了。超时时间要根据实际通信速率来调整,别拍脑袋定。
最后,我建议把所有I2C底层操作都封装成带超时的版本。上层调用时,统一使用这些带超时的函数。这样,整个驱动层的健壮性会提升一个档次。
嗯,这一章的内容就到这里。下一章我们会把这些底层函数组合起来,实现溶解氧传感器的具体读写流程。到时候,你会看到这些基础操作是怎么拼成完整驱动的。