ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

嵌入式I2C驱动开发实战:从协议时序到Linux驱动与避坑指南

嵌入式I2C驱动开发实战:从协议时序到Linux驱动与避坑指南 1. I2C 在嵌入式驱动开发中的真实定位I2C 总线在嵌入式圈子里有个很尴尬的地位几乎每个 MCU 都带几乎每个项目都会用到但真正把它写稳、写透的人并不多。大部分人的 I2C 经验停留在“调通一个 OLED”或者“读一个 EEPROM”的层面一旦遇到多设备挂载、时钟拉伸、总线死锁、休眠唤醒后设备失联这些场景就开始抓瞎。这一期我想把 I2C 驱动开发这件事从头到尾捋一遍。不是那种“I2C 有两根线一根 SDA 一根 SCL”的科普而是从实际项目出发讲清楚在嵌入式 Linux 和裸机环境下I2C 驱动到底该怎么设计、怎么调试、怎么避坑。涉及的内容会覆盖 I2C 通信协议的本质、时序细节、Linux 下的 i2c-dev 和 i2c-client 两种驱动模型、常见外设EEPROM、OLED、传感器、编码器、数字电位器的读写套路以及那些文档里不会写但实际项目中一定会遇到的坑。适合的读者是已经能点亮 LED、跑通过串口、对 GPIO 和中断有基本概念准备往驱动层深入的嵌入式开发者。如果你还在纠结寄存器怎么配建议先把 GPIO 和时钟树搞清楚再来看这篇。I2C 驱动开发的核心难点不在协议本身而在于对时序的精确控制和对异常状态的恢复能力这两点贯穿全文。2. I2C 协议层那些被忽略的时序细节2.1 起始、停止与重复起始的实际含义I2C 的物理层很简单SDA 和 SCL 两根线加上拉电阻。但协议层的细节决定了你的驱动能不能在复杂环境下稳定运行。起始条件START的定义是SCL 为高电平时SDA 从高变低。停止条件STOP是SCL 为高电平时SDA 从低变高。这两个条件必须由主机产生从机永远不能主动发起。重复起始Repeated START是很多人忽略的一个点。它的时序和普通起始一样但它出现在一次传输的中间而不是总线空闲时。为什么需要它考虑这样一个场景你要读一个 EEPROM 的某个地址标准流程是先写设备地址加写标志再写内存地址然后发起重复起始再写设备地址加读标志最后读数据。如果没有重复起始你必须在写完之后发一个停止条件再重新发起始条件。这中间总线会短暂空闲如果系统里有多个主机就可能被其他主机抢走总线。重复起始保证了整个读操作是一个原子操作不会被中断。在实际驱动代码里很多硬件 I2C 控制器会自动处理重复起始你只需要在传输结构里把两个消息连续提交即可。但软件模拟 I2C 的时候必须手动实现这个时序否则读 EEPROM 会失败。我见过不少人用软件 I2C 读 EEPROM 读不出来最后发现就是漏了重复起始。2.2 时钟拉伸从机的“暂停键”时钟拉伸Clock Stretching是 I2C 协议里一个非常关键但经常被忽视的机制。它允许从机在还没准备好数据的时候把 SCL 线拉低强制主机等待。这相当于从机按下了暂停键主机必须等到从机释放 SCL 之后才能继续产生时钟。为什么需要这个机制因为 I2C 是同步通信主机产生时钟从机被动响应。但有些从机处理速度慢比如某些传感器完成一次 ADC 转换需要时间或者 EEPROM 在写周期内不响应。如果没有时钟拉伸主机按自己的节奏发时钟从机跟不上就会丢数据。问题在于不是所有主机都支持时钟拉伸。很多 MCU 的硬件 I2C 外设对时钟拉伸的处理有 bug或者根本不支持。比如某些早期的 STM32 型号硬件 I2C 在从机拉伸时钟时会出错。这时候要么换软件模拟要么在驱动层加延时来规避。我在实际项目里遇到过 BH1750 光照传感器在低功耗模式下需要时钟拉伸但 STM32F1 的硬件 I2C 处理不好最后改成软件 I2C 才稳定。注意如果你的 I2C 总线上挂了多个从机时钟拉伸的兼容性要逐个确认。一个从机拉伸时钟总线上所有设备都会受影响。2.3 数据帧格式与 ACK/NACK 的边界情况I2C 的数据帧格式是每个字节 8 位高位先发后面跟一个 ACK/NACK 位。发送方释放 SDA接收方拉低 SDA 表示 ACK保持高电平表示 NACK。这里有几个边界情况需要特别注意。第一个是地址帧和数据帧的 ACK 来源不同。地址帧的 ACK 由被寻址的从机产生数据帧的 ACK 由接收方产生。写操作时主机发数据从机回 ACK读操作时从机发数据主机回 ACK。最后一个字节读完后主机必须回 NACK然后发停止条件。如果主机在最后一个字节回了 ACK从机会继续发下一个字节导致总线挂死。第二个是 NACK 的处理。主机收到 NACK 通常意味着从机没准备好或者地址不对。在驱动层收到 NACK 后应该立即发停止条件释放总线而不是继续发时钟。我见过有人在收到 NACK 后还在循环发数据结果整个总线被拉死所有设备都失联。第三个是总线仲裁。多主机环境下两个主机同时发起传输谁先拉低 SDA 谁赢。输的那一方要立即退出转为从机模式。这个机制在单主机系统里用不到但理解它有助于你明白为什么 I2C 的 SDA 和 SCL 必须用开漏输出。3. 裸机 I2C 驱动从寄存器到状态机3.1 硬件 I2C 与软件模拟的选型逻辑裸机环境下做 I2C 驱动第一个决策就是硬件 I2C 还是软件模拟。硬件 I2C 的优点是速度快、CPU 占用低、时序精确。缺点是不同 MCU 的硬件 I2C 外设差异大有些型号的硬件 I2C 有已知 bug调试起来很痛苦。软件模拟的优点是移植性好、时序可控、不受硬件外设限制。缺点是速度慢、CPU 占用高、时序精度依赖延时函数。我的选型原则是这样的如果 MCU 的硬件 I2C 外设成熟稳定比如 STM32F4 系列、ESP32 系列优先用硬件 I2C。如果硬件 I2C 有已知问题比如 STM32F1 的某些型号或者需要非常特殊的时序比如某些非标准 I2C 设备用软件模拟。另外如果 I2C 总线上有需要时钟拉伸的设备而硬件 I2C 不支持也只能用软件模拟。软件模拟 I2C 的代码结构通常是一个状态机每个状态对应一个时序单元。比如起始条件、发送字节、接收字节、发送 ACK、接收 ACK、停止条件。每个状态里控制 SDA 和 SCL 的电平然后延时半个周期。延时的精度决定了 I2C 的实际速率。标准模式 100kHz快速模式 400kHz高速模式 3.4MHz。软件模拟一般只能做到 100kHz 左右再快延时就不准了。3.2 状态机设计避免阻塞式延时很多人的软件 I2C 代码是阻塞式的发一个字节就死等 ACK等不到就卡在那里。这在简单项目里能用但在复杂系统里是灾难。如果从机没接好或者地址错了整个系统就卡死了。正确的做法是把 I2C 传输设计成状态机每个状态执行一小步然后返回。主循环或者定时器中断里轮询状态机超时了就报错退出。这样即使从机没响应系统也不会卡死。状态机的状态可以这样划分IDLE、START、SEND_ADDR、CHECK_ACK、SEND_DATA、RECV_DATA、SEND_ACK、STOP、ERROR。每个状态里只做一个动作然后根据结果跳转到下一个状态。超时机制是必须的。每个状态都有一个超时计数器超过阈值就跳到 ERROR 状态发停止条件释放总线。超时阈值根据 I2C 速率来定100kHz 下一个字节大概 90 微秒超时设 1 毫秒足够了。3.3 中断驱动与 DMA 的取舍硬件 I2C 可以用中断或者 DMA 来驱动。中断方式下每个字节传输完成产生一个中断CPU 在中断里处理下一个字节。DMA 方式下CPU 只需要配置好传输参数DMA 控制器自动搬运数据传输完成后产生一个中断。中断方式的优点是实现简单适合小数据量传输。缺点是每个字节都要进中断CPU 占用高。DMA 方式的优点是 CPU 占用低适合大数据量传输比如读写 EEPROM 的连续页。缺点是实现复杂需要处理 DMA 和 I2C 的同步问题。我的建议是如果传输数据量小于 16 字节用中断方式就够了。如果大于 16 字节考虑用 DMA。但要注意I2C 的 DMA 传输和 SPI 不同I2C 有地址帧、ACK 位这些额外的时序DMA 控制器不一定能自动处理。很多 MCU 的 I2C DMA 只支持数据阶段的搬运地址和 ACK 还是要 CPU 参与。所以实际用起来I2C DMA 的收益没有 SPI DMA 那么明显。4. 嵌入式 Linux 下的 I2C 驱动模型4.1 i2c-dev用户态直接操作总线嵌入式 Linux 下操作 I2C 有两种方式i2c-dev 和 i2c-client。i2c-dev 是把 I2C 适配器暴露成字符设备用户态程序通过 ioctl 直接发起 I2C 传输。这种方式适合快速验证和简单应用不需要写内核驱动。使用 i2c-dev 的流程是这样的打开 /dev/i2c-N 设备文件然后用 I2C_SLAVE ioctl 设置从机地址接着用 read/write 或者 I2C_RDWR ioctl 进行数据传输。I2C_RDWR 是最灵活的可以一次提交多个消息支持重复起始。#include linux/i2c-dev.h #include linux/i2c.h #include fcntl.h #include sys/ioctl.h #include unistd.h int fd open(/dev/i2c-1, O_RDWR); ioctl(fd, I2C_SLAVE, 0x50); struct i2c_msg msgs[2]; unsigned char addr_buf[1] {0x00}; unsigned char data_buf[16]; msgs[0].addr 0x50; msgs[0].flags 0; msgs[0].len 1; msgs[0].buf addr_buf; msgs[1].addr 0x50; msgs[1].flags I2C_M_RD; msgs[1].len 16; msgs[1].buf data_buf; struct i2c_rdwr_ioctl_data rdwr; rdwr.msgs msgs; rdwr.nmsgs 2; ioctl(fd, I2C_RDWR, rdwr);这段代码读 EEPROM 的 0x00 地址开始的 16 个字节。两个消息连续提交第一个写内存地址第二个读数据中间自动插入重复起始。这是 i2c-dev 最常用的模式。i2c-dev 的优点是简单直接不需要编译内核模块。缺点是每次传输都要进内核开销大而且用户态程序要自己处理错误和重试。适合调试阶段和简单应用不适合高性能场景。4.2 i2c-client内核态驱动框架i2c-client 是内核态的 I2C 驱动模型。你需要实现一个 i2c_driver 结构体注册到 I2C 核心层然后实现 probe、remove、以及具体的读写函数。这种方式适合需要在内核态频繁访问 I2C 设备的场景比如触摸屏、传感器、电源管理芯片。i2c-client 驱动的核心是 i2c_transfer 函数它和 i2c-dev 的 I2C_RDWR 类似也是提交一组 i2c_msg。区别在于 i2c_transfer 是在内核态调用的不需要经过字符设备层。static int mydev_read(struct i2c_client *client, u8 reg, u8 *buf, int len) { struct i2c_msg msgs[2]; int ret; msgs[0].addr client-addr; msgs[0].flags 0; msgs[0].len 1; msgs[0].buf reg; msgs[1].addr client-addr; msgs[1].flags I2C_M_RD; msgs[1].len len; msgs[1].buf buf; ret i2c_transfer(client-adapter, msgs, 2); if (ret 0) return ret; return 0; }i2c-client 驱动需要在设备树或者板级文件里描述设备信息包括 I2C 总线号、从机地址、中断引脚等。内核启动时会根据这些信息匹配驱动调用 probe 函数。probe 函数里初始化设备注册字符设备或者 input 设备供用户态使用。i2c-client 的优点是性能好、可以处理中断、可以和其他内核子系统集成。缺点是需要编译内核模块调试起来比用户态麻烦。我的经验是如果只是读个传感器数据用 i2c-dev 就够了如果要处理中断、要做电源管理、要和其他驱动交互就必须用 i2c-client。4.3 设备树中的 I2C 节点配置设备树是嵌入式 Linux 描述硬件的方式。I2C 设备在设备树里的节点通常挂在 I2C 控制器节点下面。比如i2c1 { status okay; clock-frequency 100000; eeprom50 { compatible atmel,24c02; reg 0x50; }; oled3c { compatible solomon,ssd1306; reg 0x3c; }; };clock-frequency 指定 I2C 总线速率标准模式 100kHz快速模式 400kHz。reg 属性指定从机地址。compatible 属性用于匹配驱动。这里有个坑I2C 从机地址在设备树里是 7 位地址不包括读写位。但有些数据手册给的地址是 8 位的包含了读写位。比如 SSD1306 的数据手册写从机地址是 0x78这是 8 位地址实际 7 位地址是 0x3C。设备树里要填 0x3C不是 0x78。我见过不少人在这里填错导致驱动匹配不上。另一个坑是地址冲突。I2C 总线上每个从机地址必须唯一。如果两个设备地址相同总线会出问题。有些设备可以通过引脚配置地址比如 EEPROM 的 A0/A1/A2 引脚。设计硬件的时候就要规划好地址分配避免冲突。5. 典型外设的 I2C 读写套路5.1 EEPROM页写与写周期等待EEPROM 是最经典的 I2C 设备也是学习 I2C 驱动的最好样本。以 AT24C02 为例容量 2Kbit即 256 字节页大小 8 字节。读操作很简单写内存地址然后读数据。写操作稍微复杂分字节写和页写。字节写是写一个内存地址加一个字节数据。页写是写一个内存地址加多个字节数据但不能跨页。AT24C02 的页大小是 8 字节如果你从地址 0x06 开始写 4 个字节会写到 0x06、0x07、0x00、0x01因为地址在页内回绕了。这是 EEPROM 的一个特性也是容易踩的坑。写操作完成后EEPROM 进入内部写周期通常 5 毫秒。在这期间EEPROM 不响应任何 I2C 命令。如果你紧接着发起下一次写操作会收到 NACK。正确的做法是写完之后等待 5 毫秒或者用“应答轮询”的方式反复发起起始条件加设备地址直到收到 ACK 为止。void eeprom_wait_ready(int fd, uint8_t addr) { uint8_t dummy; while (1) { if (i2c_read(fd, addr, dummy, 1) 0) break; usleep(100); } }应答轮询比固定延时更高效因为写周期可能提前完成。但要注意有些 EEPROM 在写周期内会拉低 SCL 做时钟拉伸而不是回 NACK。这时候主机要支持时钟拉伸才能正确等待。5.2 OLED 屏命令与数据的分界SSD1306 驱动的 OLED 屏是 I2C 设备里比较特殊的一类因为它需要区分命令和数据。SSD1306 的 I2C 传输格式是第一个字节是控制字节0x00 表示后面跟的是命令0x40 表示后面跟的是数据。然后才是实际的内容。void oled_write_cmd(int fd, uint8_t cmd) { uint8_t buf[2] {0x00, cmd}; write(fd, buf, 2); } void oled_write_data(int fd, uint8_t data) { uint8_t buf[2] {0x40, data}; write(fd, buf, 2); }初始化 SSD1306 需要发送一系列命令包括设置对比度、显示模式、扫描方向、时钟分频等。这些命令的顺序不能乱否则屏幕不亮或者显示异常。我建议直接参考厂商提供的初始化序列不要自己瞎试。0.9 寸 OLED 和 1.3 寸 OLED 的驱动芯片可能不同。0.9 寸通常是 SSD13061.3 寸通常是 SH1106。SH1106 的显存是 132x64但屏幕只有 128x64所以每页有 2 个字节的偏移。如果你用 SSD1306 的驱动去驱动 SH1106显示会偏移两列。这个坑我在项目里踩过调了半天才发现是驱动芯片不兼容。5.3 传感器BH1750 与 AS5600 的读取差异BH1750 是光照传感器I2C 地址 0x23ADDR 引脚接地或 0x5CADDR 接 VCC。它的读取流程是发送测量命令等待测量完成然后读 2 个字节的数据。测量时间取决于测量模式连续高分辨率模式需要 120 毫秒左右。AS5600 是磁编码器I2C 地址 0x36。它的读取流程是直接读角度寄存器得到 12 位的角度值。AS5600 支持硬件 I2C 和软件 I2C但硬件 I2C 读取时要注意时序因为 AS5600 的寄存器地址是 16 位的需要发送两个字节的地址。uint16_t as5600_read_angle(int fd) { uint8_t reg[2] {0x0E, 0x00}; uint8_t data[2]; struct i2c_msg msgs[2]; msgs[0].addr 0x36; msgs[0].flags 0; msgs[0].len 2; msgs[0].buf reg; msgs[1].addr 0x36; msgs[1].flags I2C_M_RD; msgs[1].len 2; msgs[1].buf data; i2c_transfer(fd, msgs, 2); return (data[0] 8) | data[1]; }AS5600 的角度寄存器是 0x0E12 位数据高 4 位在 0x0E低 8 位在 0x0F。读出来之后要屏蔽掉高 4 位的无效数据。5.4 数字电位器与 DAC通过 I2C 调节电压用 I2C 数字电位器或者 DAC 来调节 DC-DC 的反馈引脚电压是一个很实用的技巧。DC-DC 的输出电压由反馈引脚的分压电阻决定。如果你在分压电阻上并联一个数字电位器就可以通过 I2C 动态调节输出电压。比如 MCP4725 是 I2C DAC12 位分辨率地址 0x60。它的输出接一个电阻到 DC-DC 的反馈引脚就可以控制输出电压。写 DAC 的流程是发送快速写命令然后发送 12 位数据。void mcp4725_set(int fd, uint16_t value) { uint8_t buf[3]; buf[0] 0x40; buf[1] (value 4) 0xFF; buf[2] (value 4) 0xF0; write(fd, buf, 3); }这个方案的关键是计算电阻值。假设 DC-DC 的反馈电压是 0.8V上分压电阻是 10k下分压电阻是 2k输出电压是 0.8 * (102) / 2 4.8V。如果你在 2k 电阻上并联一个数字电位器调节电位器的阻值就可以改变下分压电阻的有效值从而改变输出电压。具体计算要用并联电阻公式这里不展开。6. 那些让你加班到凌晨的 I2C 坑6.1 总线死锁从机拉低 SDA 不放I2C 总线死锁是最常见也最头疼的问题。现象是 SDA 一直被拉低主机发不了起始条件所有设备都失联。原因通常是从机在传输过程中被复位或者断电导致它还在等待时钟但主机已经放弃了传输。解决方法是手动模拟时钟脉冲让从机把剩下的数据发完释放 SDA。具体操作是把 SCL 配置为 GPIO 输出发送 9 个时钟脉冲然后发一个停止条件。如果从机是正常的它会在第 9 个时钟后释放 SDA。void i2c_bus_recover(int scl_pin, int sda_pin) { gpio_set_output(scl_pin); gpio_set_input(sda_pin); for (int i 0; i 9; i) { gpio_set_low(scl_pin); udelay(5); gpio_set_high(scl_pin); udelay(5); } gpio_set_output(sda_pin); gpio_set_low(sda_pin); udelay(5); gpio_set_high(scl_pin); udelay(5); gpio_set_high(sda_pin); }这个恢复流程在 Linux 下有现成的实现叫 i2c-gpio-recover。在设备树里配置 gpios 属性内核会在总线死锁时自动调用恢复流程。6.2 休眠唤醒后 I2C 设备失联ESP32 休眠唤醒后 I2C 设备失联是一个经典问题。原因是休眠时 I2C 控制器断电唤醒后没有重新初始化。解决方法是在唤醒后重新配置 I2C 控制器包括时钟、引脚、速率。另一个可能的原因是休眠时 SDA 或 SCL 被拉低唤醒后从机处于异常状态。这时候需要执行总线恢复流程。ESP32 的 I2C 驱动有一个 i2c_reset_tx_fifo 和 i2c_reset_rx_fifo 函数可以在唤醒后调用。还有一种情况是休眠时从机也断电了唤醒后从机需要重新初始化。比如 OLED 屏唤醒后需要重新发送初始化命令。这个要在应用层处理驱动层管不了。6.3 上拉电阻选型不是随便放一个 4.7k 就行I2C 的上拉电阻选型经常被忽视。很多人直接抄别人的原理图放两个 4.7k 电阻就完事了。但实际上上拉电阻的阻值要根据总线速率、总线电容、电源电压来计算。上拉电阻的最大值由上升时间决定。I2C 标准规定标准模式下上升时间不超过 1000 纳秒快速模式下不超过 300 纳秒。上升时间 t R * C其中 R 是上拉电阻C 是总线电容。总线电容包括 PCB 走线电容、引脚电容、设备电容通常 10 到 50 皮法。假设总线电容 50 皮法快速模式上升时间 300 纳秒那么 R 300ns / 50pF 6k。所以上拉电阻不能大于 6k。最小值由灌电流决定。I2C 标准规定标准模式下灌电流不超过 3 毫安快速模式下不超过 6 毫安。电源电压 3.3V灌电流 3 毫安那么 R 3.3V / 3mA 1.1k。所以上拉电阻在 1.1k 到 6k 之间。4.7k 是一个折中值适合 100kHz 的总线速率和较小的总线电容。如果你的总线速率是 400kHz或者总线电容较大4.7k 可能就太大了导致上升沿变缓通信失败。这时候要换小一点的电阻比如 2.2k。6.4 多设备挂载时的地址冲突与总线负载多设备挂载时地址冲突是最直接的问题。但即使地址不冲突总线负载也会影响通信质量。每个设备都会给总线增加电容设备越多总线电容越大上升时间越长。如果超过标准限制通信就会出错。解决方法是减少总线电容比如缩短走线、减少设备数量、使用 I2C 多路复用器如 TCA9548A。TCA9548A 是一个 8 通道 I2C 开关可以把总线分成 8 个子总线每个子总线上挂不同的设备。这样即使多个设备地址相同也可以分别挂在不同通道上。另一个问题是总线速率。设备越多支持的最高速率可能越低。比如有些老旧的 EEPROM 只支持 100kHz如果你总线上还挂了支持 400kHz 的传感器整个总线只能跑 100kHz。这时候要么分开总线要么接受低速。7. 调试 I2C 的实用工具与方法7.1 i2c-tools命令行快速验证i2c-tools 是 Linux 下最常用的 I2C 调试工具。i2cdetect 可以扫描总线上的设备i2cget 和 i2cset 可以读写寄存器。i2cdetect -y 1 i2cget -y 1 0x50 0x00 i2cset -y 1 0x50 0x00 0xABi2cdetect 的原理是向每个地址发送起始条件加设备地址看是否收到 ACK。如果收到 ACK说明该地址有设备。但要注意有些设备在写周期内不响应i2cdetect 可能扫不到。还有些设备地址是保留的i2cdetect 会跳过。i2cget 和 i2cset 适合快速验证寄存器读写。但它们的传输格式是固定的不支持重复起始。对于需要重复起始的设备比如 EEPROMi2cget 可能读不出来。这时候要用 i2ctransfer它支持自定义消息组合。i2ctransfer -y 1 w10x50 0x00 r16这条命令向 0x50 写一个字节 0x00然后读 16 个字节。中间自动插入重复起始。7.2 逻辑分析仪抓时序的终极手段逻辑分析仪是调试 I2C 的终极武器。它可以把 SDA 和 SCL 的波形抓下来解码成具体的字节和 ACK。Saleae 的逻辑分析仪配合它的软件可以自动解码 I2C 协议非常方便。抓波形的时候要注意采样率。I2C 标准模式 100kHz快速模式 400kHz采样率至少要 4 倍以上建议 10 倍。比如 400kHz 的总线采样率设 4MHz 以上。采样深度要足够至少能抓到一个完整的传输周期。分析波形的时候重点看几个地方起始条件是否干净、地址帧的 ACK 是否正常、数据帧的 ACK 是否正常、停止条件是否干净、有没有时钟拉伸、上升沿是否太缓。如果上升沿太缓说明上拉电阻太大或者总线电容太大。7.3 用 GPIO 模拟 I2C 做交叉验证如果你怀疑硬件 I2C 有问题可以用 GPIO 模拟 I2C 做交叉验证。把 SDA 和 SCL 配置成 GPIO用软件模拟时序看能不能通信。如果软件模拟能通硬件 I2C 不通说明硬件 I2C 的配置有问题。如果软件模拟也不通说明硬件电路有问题。这个方法虽然笨但非常有效。我在项目里遇到过 STM32 硬件 I2C 在特定条件下死锁的问题最后就是用 GPIO 模拟验证的。软件模拟跑了几天都没问题硬件 I2C 几个小时就死一次最后确认是硬件 I2C 外设的 bug。8. 从驱动到应用I2C 代码的工程化组织8.1 分层设计总线层、设备层、应用层I2C 代码的工程化组织很重要。我通常分三层总线层、设备层、应用层。总线层封装 I2C 的基本读写提供 i2c_read 和 i2c_write 接口。设备层封装具体设备的寄存器读写比如 eeprom_read、oled_write_cmd、bh1750_read。应用层调用设备层的接口实现业务逻辑。这样分层的好处是换 MCU 或者换 I2C 控制器时只需要改总线层设备层和应用层不用动。换设备时只需要改设备层总线层和应用层不用动。总线层的接口设计要统一。比如int i2c_write(int bus, uint8_t addr, uint8_t *buf, int len); int i2c_read(int bus, uint8_t addr, uint8_t *buf, int len); int i2c_write_read(int bus, uint8_t addr, uint8_t *wbuf, int wlen, uint8_t *rbuf, int rlen);i2c_write_read 是最常用的它封装了写地址加重复起始加读数据的流程。设备层的函数都基于这三个接口实现。8.2 错误处理与重试策略I2C 通信出错是常态尤其是在电磁干扰大的环境下。错误处理策略决定了系统的稳定性。我的做法是每次 I2C 传输失败后重试 3 次每次重试之间延时 1 毫秒。如果 3 次都失败执行总线恢复流程然后再重试 3 次。如果还是失败返回错误给上层。重试的时候要注意不是所有错误都值得重试。NACK 错误可能是从机没准备好重试有用。总线死锁错误重试没用必须先恢复总线。超时错误可能是从机没响应重试也可能没用。所以错误处理要分类不同错误不同策略。int i2c_transfer_with_retry(int bus, uint8_t addr, uint8_t *wbuf, int wlen, uint8_t *rbuf, int rlen) { int ret; for (int i 0; i 3; i) { ret i2c_write_read(bus, addr, wbuf, wlen, rbuf, rlen); if (ret 0) return 0; if (ret -EAGAIN) usleep(1000); else if (ret -EBUSY) { i2c_bus_recover(bus); usleep(1000); } } return ret; }8.3 性能优化批量传输与缓存I2C 的速率有限标准模式 100kHz快速模式 400kHz。每次传输都有起始条件、地址帧、ACK 位的开销实际有效数据速率更低。所以性能优化的关键是减少传输次数尽量批量传输。比如读 EEPROM如果每次读一个字节读 256 字节需要 256 次传输。如果一次读 16 字节只需要 16 次传输。如果一次读 256 字节只需要 1 次传输。当然EEPROM 的页大小有限制不能一次读太多。但传感器数据通常可以批量读。另一个优化是缓存。如果某个寄存器的值不经常变可以读一次缓存起来下次直接用缓存不用再读。比如 OLED 的初始化命令只需要发一次不用每次都发。但要注意缓存的一致性如果设备状态可能被外部改变缓存就要失效。9. 写在最后一些个人体会I2C 驱动开发这件事说难不难说简单也不简单。协议本身很简单两根线几个状态。但实际项目里遇到的问题往往不是协议本身的问题而是硬件、时序、异常处理这些细节的问题。我个人的经验是调试 I2C 问题的时候先确认硬件没问题再确认时序没问题最后才怀疑代码。硬件问题包括上拉电阻、电源、走线、地址配置。时序问题包括速率、时钟拉伸、重复起始。代码问题包括状态机、超时、错误处理。按这个顺序排查能省很多时间。还有一个体会是不要迷信硬件 I2C。硬件 I2C 虽然快但不同 MCU 的硬件 I2C 差异很大有些型号的硬件 I2C 有已知 bug调试起来很痛苦。软件模拟 I2C 虽然慢但可控性强移植性好在很多场景下反而是更稳妥的选择。最后I2C 总线上挂的设备越多出问题的概率越大。如果项目里 I2C 设备很多建议用 I2C 多路复用器把总线分开每个子总线上挂少量设备。这样即使某个设备出问题也不会影响其他设备。这个经验是我在一个项目里踩了无数次坑之后总结出来的希望对你有用。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表