3. I2C驱动实现:I2C时序分析、主机读写函数封装、多字节传输与错误处理
好,咱们进入第三章。I2C总线,说白了就是嵌入式世界里最常用的「小水管」——两根线,一根时钟(SCL),一根数据(SDA),就能把传感器、屏幕、存储芯片全串起来。我最早接触I2C是在做游戏手柄的六轴陀螺仪驱动时,当时被时序折腾得够呛。今天咱们就把I2C的底裤扒干净,从时序到代码,一步到位。
3.1 I2C时序分析:别被波形图吓到
很多新手看到I2C的时序图就头大。其实你把它拆成几个动作,就简单了。
核心动作就四个:
- 起始条件(Start):SCL高电平时,SDA从高变低。这是总线的「发令枪」。
- 停止条件(Stop):SCL高电平时,SDA从低变高。这是「比赛结束」。
- 数据位传输:SCL低电平时改变SDA,SCL高电平时采样SDA。记住这个节奏就行。
- 应答位(ACK/NACK):每发完8个数据位,第9个时钟周期由接收方拉低SDA表示应答。如果没拉低,就是NACK,表示「我没收到,重发!」
我个人习惯:写I2C驱动前,先用逻辑分析仪抓一次波形。看一眼真实的起始、停止、ACK,比看十遍数据手册都管用。我在项目中遇到过,某国产传感器对时序要求特别苛刻,SCL上升沿必须小于300ns,否则不认。这种坑,光看手册根本发现不了。
3.2 主机读写函数封装:从底层到API
咱们直接上代码。我习惯把I2C操作封装成三个基础函数:起始、停止、字节收发。然后在此基础上构建读写API。
// 基础函数:起始条件
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);
}
// 基础函数:发送一个字节,返回ACK状态
uint8_t I2C_SendByte(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);
}
// 第9个时钟:读取ACK
SDA_HIGH(); // 释放SDA,让从机控制
SCL_HIGH();
delay_us(5);
uint8_t ack = (SDA_READ() == 0) ? 1 : 0; // 0表示ACK
SCL_LOW();
return ack;
}
// 基础函数:读取一个字节,并决定是否发送ACK
uint8_t I2C_ReadByte(uint8_t send_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);
}
// 发送ACK或NACK
if (send_ack) SDA_LOW(); // 应答:继续读
else SDA_HIGH(); // 非应答:停止读
SCL_HIGH();
delay_us(5);
SCL_LOW();
SDA_HIGH(); // 释放总线
return data;
}
避坑指南:我曾经在STM32上用GPIO模拟I2C,忘记在读取字节前把SDA设为输入模式,结果读回来的全是0xFF。折腾了一下午才发现。记住:SDA方向切换一定要做对,发送时推挽输出,读取前切换为开漏输入。
3.3 多字节传输与错误处理
单字节读写只是热身。实际项目中,传感器数据往往是一串——比如MPU6050的加速度计,一次要读6个字节(X、Y、Z各2字节)。多字节传输的关键在于:连续读时,最后一个字节要发NACK,告诉从机「够了,别发了」。
下面是我常用的多字节读写封装,带错误处理:
// 多字节写:向从机地址dev_addr的寄存器reg_addr写入len字节数据
int I2C_WriteMulti(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint16_t len) {
I2C_Start();
// 发送从机地址+写位
if (!I2C_SendByte(dev_addr << 1 | 0)) {
I2C_Stop();
return -1; // 从机无应答
}
// 发送寄存器地址
if (!I2C_SendByte(reg_addr)) {
I2C_Stop();
return -2;
}
// 逐字节发送数据
for (uint16_t i = 0; i < len; i++) {
if (!I2C_SendByte(data[i])) {
I2C_Stop();
return -3; // 某字节发送失败
}
}
I2C_Stop();
return 0; // 成功
}
// 多字节读:从从机地址dev_addr的寄存器reg_addr读取len字节
int I2C_ReadMulti(uint8_t dev_addr, uint8_t reg_addr, uint8_t *buffer, uint16_t len) {
// 第一步:伪写,设置寄存器指针
I2C_Start();
if (!I2C_SendByte(dev_addr << 1 | 0)) {
I2C_Stop();
return -1;
}
if (!I2C_SendByte(reg_addr)) {
I2C_Stop();
return -2;
}
// 第二步:重启,切换为读模式
I2C_Start();
if (!I2C_SendByte(dev_addr << 1 | 1)) {
I2C_Stop();
return -3;
}
// 第三步:连续读取,前len-1个字节发ACK,最后一个发NACK
for (uint16_t i = 0; i < len; i++) {
uint8_t send_ack = (i < len - 1) ? 1 : 0;
buffer[i] = I2C_ReadByte(send_ack);
}
I2C_Stop();
return 0;
}
注意:错误处理不是摆设。我见过太多人写I2C驱动时,假设从机永远在线、永远应答。结果产品量产时,某批次传感器焊接不良,总线卡死,整机死机。我的建议是:每次I2C操作都检查返回值,如果连续3次失败,就复位从机(拉低SCL 9个时钟)并重新初始化。
3.4 实战经验:I2C速率与上拉电阻
最后聊两个容易被忽略的点:
| 参数 | 标准模式(100kHz) | 快速模式(400kHz) | 我的建议 |
|---|---|---|---|
| 上拉电阻 | 4.7kΩ ~ 10kΩ | 1.5kΩ ~ 4.7kΩ | 先按4.7kΩ试,波形不好再换小 |
| 总线电容 | < 400pF | < 200pF | 线长超过20cm就要小心 |
| SCL高电平时间 | > 4.0μs | > 0.6μs | 用示波器实测最准 |
嗯,这里要注意:上拉电阻不是随便选的。电阻太大,上升沿变缓,高速通信会出错;电阻太小,功耗增加,从机可能拉不动。我在做游戏手柄时,为了省电选了10kΩ上拉,结果400kHz通信时波形像正弦波,数据错得一塌糊涂。后来换成2.2kΩ才搞定。
好了,I2C驱动这块就讲这么多。下一章咱们开始玩真的——用I2C驱动MPU6050陀螺仪,读取六轴数据。到时候你会看到,今天封装的这些函数,到底怎么用在实战中。