3、I2C通信协议详解:I2C总线时序、从设备地址、读写操作、Linux内核I2C驱动框架
好,我们进入第三章。这一章要啃的,是触摸屏和主控芯片之间的“对话语言”——I2C协议。
说实话,在嵌入式领域混了这么多年,I2C是我打交道最多的总线之一。它简单、可靠,两根线就能搞定通信。但越是简单的东西,坑往往越隐蔽。我见过不少工程师,SPI玩得飞起,却在I2C上栽跟头。嗯,今天我们就把它彻底讲透。
3.1 I2C总线时序:两根线上的“摩尔斯电码”
I2C总线就两根线:SCL(时钟线)和SDA(数据线)。所有设备都挂在这两根线上,通过一套严格的时序规则来通信。
你想想看,一根线上挂着好几个设备,怎么区分谁在说话?靠的就是时序。
空闲状态:SCL和SDA都是高电平。这时候总线是“空闲”的,谁都可以发起通信。
起始信号:SCL为高电平时,SDA从高电平跳变到低电平。这个下降沿告诉所有从设备:“注意,我要开始说话了!”
停止信号:SCL为高电平时,SDA从低电平跳变到高电平。这个上升沿表示:“我说完了,你们可以抢总线了。”
数据传输:数据在SCL的每个时钟周期传输一位。SCL高电平时,SDA上的数据必须稳定;SCL低电平时,SDA可以变化。说白了,就是时钟的上升沿采样数据。
核心要点:I2C的数据传输是“高位先行”的,先传最高位(MSB),再传最低位(LSB)。每个字节后面必须跟一个应答位(ACK)。
我曾经在一个项目中,发现触摸屏偶尔会丢数据。排查了半天,最后发现是SCL时钟频率太高,从设备跟不上。把频率从400kHz降到100kHz,问题就解决了。嗯,有时候慢就是快。
3.2 从设备地址:7位还是10位?
每个I2C从设备都有一个唯一的地址。最常见的是7位地址,范围是0x00到0x7F。但有些设备支持10位地址,范围更大。
7位地址:地址字节的高7位是设备地址,最低位是读写位(0表示写,1表示读)。比如,一个触摸屏的地址是0x38,那么写操作时发送的字节是0x70(0x38左移一位),读操作时发送0x71。
10位地址:需要两个字节来传输。第一个字节的高5位是11110,接着是高2位地址,最低位是读写位。第二个字节是低8位地址。
| 地址类型 | 地址范围 | 应用场景 |
|---|---|---|
| 7位地址 | 0x00 - 0x7F | 大多数触摸屏、传感器 |
| 10位地址 | 0x00 - 0x3FF | 复杂系统、多设备场景 |
我个人习惯,在项目初期就把所有I2C设备的地址列出来,避免冲突。你想想看,两个设备地址一样,总线就乱套了。
小技巧:很多触摸屏的地址可以通过硬件引脚配置。比如,把ADDR引脚拉高或拉低,就能改变地址。这在多触摸屏系统中特别有用。
3.3 读写操作:主设备与从设备的“一问一答”
I2C的读写操作,本质上就是主设备(比如我们的主控芯片)和从设备(触摸屏)之间的数据交换。
写操作流程:
- 主设备发送起始信号
- 主设备发送从设备地址 + 写位(0)
- 从设备回应ACK
- 主设备发送寄存器地址(告诉从设备要写到哪里)
- 从设备回应ACK
- 主设备发送数据字节
- 从设备回应ACK
- 重复步骤6-7,直到数据发送完毕
- 主设备发送停止信号
读操作流程:
- 主设备发送起始信号
- 主设备发送从设备地址 + 写位(0)
- 从设备回应ACK
- 主设备发送寄存器地址(告诉从设备要读哪里)
- 从设备回应ACK
- 主设备发送重复起始信号(Restart)
- 主设备发送从设备地址 + 读位(1)
- 从设备回应ACK
- 从设备发送数据字节
- 主设备回应ACK(如果还要继续读)或NACK(如果这是最后一个字节)
- 重复步骤9-10,直到数据接收完毕
- 主设备发送停止信号
这里有个关键点:读操作需要先“假装”写一次,告诉从设备要读哪个寄存器,然后再真正开始读。这叫“组合事务”。
避坑指南:我曾经在读取触摸屏坐标时,忘记发送重复起始信号,直接发了停止信号再发起始信号。结果从设备状态乱了,读回来的数据全是错的。记住,重复起始信号和停止+起始信号是不一样的!
3.4 Linux内核I2C驱动框架:从用户态到硬件
在Linux下写I2C驱动,其实不用从头造轮子。内核已经帮我们搭好了框架。我们只需要填充几个关键部分。
驱动架构分层:
- I2C核心层:提供通用的I2C总线管理、设备注册、数据传输接口
- I2C总线驱动:负责具体硬件I2C控制器的操作(比如寄存器读写)
- I2C设备驱动:我们主要写的就是这一层,负责具体外设(如触摸屏)的操作
设备驱动核心结构:
static const struct i2c_device_id touch_id[] = {
{ "mytouch", 0 },
{ }
};
static struct i2c_driver touch_driver = {
.driver = {
.name = "mytouch",
.of_match_table = touch_of_match,
},
.probe = touch_probe,
.remove = touch_remove,
.id_table = touch_id,
};
数据传输接口:
// 读操作
int i2c_master_recv(struct i2c_client *client, char *buf, int count);
// 写操作
int i2c_master_send(struct i2c_client *client, const char *buf, int count);
// 组合事务(先写后读)
int i2c_transfer(struct i2c_adapter *adap, struct i2c_msg *msgs, int num);
我个人习惯用i2c_transfer,因为它支持组合事务,一次调用就能完成“写寄存器地址+读数据”的操作,效率更高。
设备树配置:
&i2c1 {
status = "okay";
clock-frequency = <100000>;
touch@38 {
compatible = "mytouch";
reg = <0x38>;
interrupt-parent = <&gpio1>;
interrupts = <5 IRQ_TYPE_EDGE_FALLING>;
};
};
这里要注意,clock-frequency要跟触摸屏的数据手册一致。我见过有人设成400kHz,结果触摸屏反应迟钝,因为从设备跟不上。
调试技巧:在驱动开发阶段,可以用i2c-tools工具包里的i2cdetect、i2cget、i2cset来验证硬件通信是否正常。先确保裸机通信没问题,再写驱动。
好了,这一章的内容就到这里。I2C协议说简单也简单,说复杂也复杂。关键是要理解时序的本质,以及Linux内核是如何封装这些底层操作的。下一章,我们会把这些知识用到触摸屏驱动开发中,真正开始写代码。