4. I2C总线驱动开发:从控制器到从设备的完整实战
I2C总线,说白了就是嵌入式系统里的"通用串行小总线"。两根线——SCL和SDA,就能挂一堆传感器、EEPROM、ADC。我在鸿蒙南向开发中,几乎每个传感器项目都离不开它。今天咱们就把I2C驱动开发的四个核心环节掰开揉碎讲清楚。
4.1 I2C控制器驱动框架:鸿蒙怎么管I2C
鸿蒙的I2C控制器驱动,采用标准的HDF(Hardware Driver Foundation)框架。我个人习惯把HDF框架理解成"驱动界的微服务"——每个控制器是一个独立服务,通过统一的接口对外暴露能力。
先看控制器驱动的核心数据结构:
struct I2cCntlr {
struct I2cMethod *ops; // 操作方法集
uint32_t cntlrId; // 控制器ID
struct I2cResource *res; // 硬件资源
OsalSpin lock; // 自旋锁
void *priv; // 私有数据
};
struct I2cMethod {
int32_t (*transfer)(struct I2cCntlr *cntlr, struct I2cMsg *msgs, int16_t count);
int32_t (*setConfig)(struct I2cCntlr *cntlr, struct I2cConfig *config);
int32_t (*getConfig)(struct I2cCntlr *cntlr, struct I2cConfig *config);
};
这里最关键的,就是那个transfer函数指针。所有I2C通信最终都会落到它头上。我在项目中遇到过,有些芯片厂商的SDK把transfer实现得特别复杂,动不动就几百行。其实核心逻辑就三步:
- 解析消息链表——把上层传下来的消息数组拆成一个个原子操作
- 操作硬件寄存器——配置控制寄存器、数据寄存器、状态寄存器
- 处理中断或轮询——等待传输完成,返回结果
鸿蒙的控制器驱动注册流程也很清晰:
// 驱动入口
static int32_t I2cDriverBind(struct HdfDeviceObject *device)
{
struct I2cCntlr *cntlr = (struct I2cCntlr *)OsalMemCalloc(sizeof(struct I2cCntlr));
cntlr->ops = &g_i2cMethod; // 绑定操作方法
cntlr->cntlrId = device->service->devId;
HdfDeviceSetService(device, cntlr); // 注册到HDF
return HDF_SUCCESS;
}
transfer函数用空实现占位,确保框架能正常加载。然后再逐步填充硬件操作。这样调试起来更可控。
4.2 I2C消息传输机制:数据怎么在总线上跑
I2C的消息传输,核心是struct I2cMsg这个结构体。你想想看,一次I2C通信可能包含多个消息段——比如先发设备地址+寄存器地址,再收数据。鸿蒙用消息链表来管理这种复合操作。
struct I2cMsg {
uint16_t addr; // 从设备地址(7位或10位)
uint16_t len; // 数据长度
uint8_t *buf; // 数据缓冲区
uint16_t flags; // 标志位:读/写、停止位等
int16_t err; // 错误码
void *priv; // 私有数据
};
消息传输的典型流程是这样的:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 构造消息数组 | 按读写顺序填充I2cMsg结构体 |
| 2 | 调用transfer接口 | 传入消息数组和数量 |
| 3 | 控制器解析消息 | 逐条处理,生成START/STOP信号 |
| 4 | 硬件传输 | SCL/SDA按协议时序驱动 |
| 5 | 返回结果 | 成功或错误码 |
我曾经踩过一个坑:消息数组里如果连续两个写消息,中间不能自动插入STOP信号。否则从设备会以为是一次传输的延续。正确的做法是在flags里明确设置I2C_FLAG_STOP。
err字段。
4.3 I2C从设备驱动编写:以温度传感器为例
从设备驱动,说白了就是给某个具体芯片写"翻译官"。我拿一个常见的温度传感器LM75来举例。
先看设备初始化:
// LM75设备结构体
struct Lm75Device {
struct I2cDevice i2cDev; // 继承I2C设备
uint16_t addr; // 设备地址(0x48~0x4F)
int32_t temp; // 当前温度值
};
// 初始化函数
static int32_t Lm75Init(struct Lm75Device *lm75)
{
struct I2cMsg msgs[2];
uint8_t config = 0x00; // 配置寄存器:正常模式
// 写配置寄存器
msgs[0].addr = lm75->addr;
msgs[0].flags = 0; // 写操作
msgs[0].len = 2;
msgs[0].buf = (uint8_t[]){0x01, config}; // 寄存器地址+数据
return I2cTransfer(&lm75->i2cDev, msgs, 1);
}
读温度数据时,需要先写寄存器地址,再读数据:
static int32_t Lm75ReadTemp(struct Lm75Device *lm75)
{
struct I2cMsg msgs[2];
uint8_t regAddr = 0x00; // 温度寄存器地址
uint8_t tempRaw[2];
// 第一步:写寄存器地址
msgs[0].addr = lm75->addr;
msgs[0].flags = 0;
msgs[0].len = 1;
msgs[0].buf = ®Addr;
// 第二步:读温度数据
msgs[1].addr = lm75->addr;
msgs[1].flags = I2C_FLAG_READ; // 读操作
msgs[1].len = 2;
msgs[1].buf = tempRaw;
int32_t ret = I2cTransfer(&lm75->i2cDev, msgs, 2);
if (ret != 2) {
return -1;
}
// 解析温度值(11位有符号数,分辨率0.125°C)
int16_t raw = (tempRaw[0] << 8) | tempRaw[1];
lm75->temp = (raw >> 5) * 125 / 1000;
return 0;
}
4.4 时序调试技巧:用逻辑分析仪说话
I2C调试,最怕的就是"黑盒调试"——代码跑完了,不知道总线上到底发生了什么。我个人习惯,调试I2C必用逻辑分析仪。嗯,这里要注意,不是所有逻辑分析仪都行,至少要能采样到SCL和SDA两根线。
常见的时序问题及排查方法:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 无ACK响应 | 设备地址错误、设备未上电 | 检查地址位(7位还是8位),测量VDD |
| 数据错位 | 时钟极性/相位不匹配 | 检查SCL高电平时的SDA采样点 |
| 总线卡死 | 从设备拉低SCL/SDA未释放 | 强制复位从设备,或发送9个时钟脉冲 |
| 速度不匹配 | 标准模式(100kHz) vs 快速模式(400kHz) | 降低频率测试,逐步提升 |
我曾经遇到一个特别诡异的问题:LM75读温度,偶尔返回0xFFFF。用逻辑分析仪一抓,发现是START信号后的地址字节,SDA在SCL低电平时发生了跳变。查了半天,原来是GPIO驱动能力不够,导致SDA上升沿太缓。加上拉电阻从4.7k换成2.2k就解决了。
最后,分享一个我常用的I2C时序验证脚本思路:
// 伪代码:验证I2C时序
bool VerifyI2cTiming(void)
{
// 1. 发送START信号
// 2. 发送设备地址+写位
// 3. 等待ACK
// 4. 发送寄存器地址
// 5. 等待ACK
// 6. 发送RESTART信号
// 7. 发送设备地址+读位
// 8. 等待ACK
// 9. 读取数据字节
// 10. 发送NACK + STOP信号
// 用逻辑分析仪抓取以上所有步骤的波形
// 与I2C协议规范逐项比对
}
这张图把I2C驱动开发的四个层次串起来了。从上到下依次是:应用层 → 从设备驱动 → 消息传输 → 控制器驱动 → 硬件总线。右侧的调试技巧贯穿始终。你写驱动时,脑子里要有这张图,出了问题就知道该查哪一层。