简介:一套面向海思平台的通用输入输出口模拟I2C总线驱动源码包,主要为了解决在没有硬件I2C控制器或希望节省芯片资源时的通信需求。压缩包共3个文件,包括一个C语言源文件、一个头文件以及一个Makefile构建脚本,整体大小仅为4KB,代码非常精简。头文件定义了关键的数据结构和函数接口,源文件实现了设备探测与移除、消息收发、应答检测以及各类时序控制,Makefile用于驱动模块的编译与链接。资料对起始信号、停止信号、读写时序、超时与错误处理均有清晰实现,适合具备一定Linux驱动基础、希望深入理解模拟I2C协议栈的开发者参考学习。目前已有991人浏览学习,源码结构完整、逻辑清晰,可直接对照分析并移植到相似平台,便于嵌入式调试和二次开发。
1. 海思平台为什么需要 GPIO 模拟 I2C:从引脚不够到时序可控
第一次拿到海思平台的评估板时,我对着原理图数了半天 I2C 总线:主控的硬件 I2C 控制器一共就那几路,一路给了图像传感器,一路挂在音频芯片,剩下的要么被占用,要么需要和别的外设多路复用。而手头的 EEPROM 和温湿度传感器还等着挂总线。硬件设计改不了,唯一能做的就是从 GPIO 里挤出两个脚,用软件把 I2C 时序拉出来。GPIO 模拟 I2C(bit-banging)正是干这个的:拿出任意两个空闲 GPIO,分别当作 SCL 和 SDA,依靠驱动里的延时和方向切换,把 START、数据位、ACK、STOP 一步步“搓”出来。这篇文章不打算只贴一段源码,我会把模拟 I2C 的时序原理、海思平台上的驱动实现、对接从设备的方法,以及最容易翻车的几个地方一次讲透。适合刚接触海思 BSP、想自己维护一路模拟 I2C 的工程师,也适合被串行总线问题追着跑的老手。
2. 模拟 I2C 的时序基础与 GPIO 选型:先让逻辑在示波器上成立
写代码之前先把时序逻辑在纸上拉一遍,这一步省不了。GPIO 模拟 I2C 的本质,是把硬件控制器内部的状态机用代码重放一遍。你对时序边沿定义得越清楚,后面在示波器上排查问题就越省事。很多人上来就抄代码,结果对着波形找不出问题在哪,就是因为没有把 I2C 协议拆成最基础的边沿事件。
2.1 I2C 时序四要素:START、STOP、ACK、数据位怎么模拟
I2C 总线的空闲态是 SCL 和 SDA 都保持高电平。起始条件 START 的定义是:在 SCL 为高时,将 SDA 从高拉低;停止条件 STOP 则相反:在 SCL 为高时,将 SDA 从低拉高。数据位传输时,主机必须在 SCL 低电平期间改变 SDA 电平,然后在 SCL 拉高后保持稳定,从机在 SCL 高电平期间采样。ACK 则发生在第 9 个时钟,此时主机释放 SDA,从机把 SDA 拉低表示应答。
对应到 GPIO 操作,先定义一组宏,后面所有函数都建立在它们之上:
#define I2C_SCL_H() gpio_set_value(scl_gpio, 1) #define I2C_SCL_L() gpio_set_value(scl_gpio, 0) #define I2C_SDA_H() gpio_set_value(sda_gpio, 1) #define I2C_SDA_L() gpio_set_value(sda_gpio, 0) #define I2C_SDA_IN() gpio_direction_input(sda_gpio) #define I2C_SDA_OUT() gpio_direction_output(sda_gpio, 1) #define I2C_SDA_READ() gpio_get_value(sda_gpio)这里有个容易被忽略的细节:I2C_SDA_OUT()除了把引脚切成输出,还会把 SDA 默认拉到高。这样在模拟“释放总线”时,先把方向切成输出并置高,再切输入,SDA 不会出现短时间的悬空。而真正读从机数据时,SDA 要靠外部上拉电阻维持高电平,所以方向切换的先后顺序会影响波形质量。
START 和 STOP 用了两次半周期延时,保证 SCL 高电平时 SDA 能稳定跳变:
static void i2c_start(void) { I2C_SDA_H(); I2C_SCL_H(); i2c_half_delay(); I2C_SDA_L(); i2c_half_delay(); I2C_SCL_L(); } static void i2c_stop(void) { I2C_SDA_L(); I2C_SCL_H(); i2c_half_delay(); I2C_SDA_H(); i2c_half_delay(); }写一个数据位,是在 SCL 低电平期间修改 SDA,再拉高 SCL 让从机采样,最后拉低 SCL 结束这个位:
static void i2c_write_bit(int bit) { I2C_SDA_OUT(); if (bit) I2C_SDA_H(); else I2C_SDA_L(); i2c_half_delay(); I2C_SCL_H(); i2c_half_delay(); I2C_SCL_L(); i2c_half_delay(); }读一个数据位则相反,主机先释放 SDA(切输入),再拉高 SCL,采样,最后拉低 SCL:
static int i2c_read_bit(void) { int val; I2C_SDA_IN(); I2C_SCL_H(); i2c_half_delay(); val = I2C_SDA_READ(); I2C_SCL_L(); i2c_half_delay(); I2C_SDA_OUT(); return val; }逻辑看起来很简单,但实际跑起来,方向切换、上拉电阻、延时选择都会影响结果。第 3 章会把完整驱动串起来,第 5 章会集中讲坑。
2.2 海思怎么操作 GPIO:内核 gpio 接口与寄存器方式的差异
海思平台的内核里有两条操作 GPIO 的路线。一条是标准内核接口:gpio_request、gpio_direction_output、gpio_set_value,这是大多数 BSP 驱动的默认选择。另一条是直接 ioremap GPIO 寄存器,通过读写寄存器值翻转引脚,常见于对时序要求很高的模拟逻辑。
在驱动里我一般优先用标准接口,原因只有一个:可以快速换引脚。开发阶段经常需要把 SCL 或 SDA 换到另一个 GPIO 上,标准接口只要改 module_param 传进来的引脚号,不用改代码。而寄存器方式需要查具体型号的 IO 地址、位偏移、读写锁,换一块板子可能还得重写一遍。标准接口的缺点是慢,gpio_set_value内部有锁和调用链,但模拟 100kbps 的 I2C 完全够用。
一个典型初始化片段如下:
static int scl_gpio = 23; static int sda_gpio = 24; module_param(scl_gpio, int, 0644); module_param(sda_gpio, int, 0644); static int gpio_i2c_init_gpio(void) { if (gpio_request(scl_gpio, "i2c-scl")) return -EBUSY; if (gpio_request(sda_gpio, "i2c-sda")) return -EBUSY; gpio_direction_output(scl_gpio, 1); gpio_direction_output(sda_gpio, 1); return 0; }参数说明:scl_gpio和sda_gpio是模块加载时传入的 GPIO 编号,编号要查板子原理图和芯片 GPIO 手册,不是随便填。初始化时把两个引脚都配置为输出且默认输出 1,是为了让总线进入空闲态。如果这步做反了,SDA 和 SCL 一上来就是低电平,整个总线直接被“锁死”。
有些海思平台的老 SDK 里没有完整的设备树,GPIO 可能已经被 pinmux 复用成其他功能。这种情况gpio_request不会报错,但引脚电平就是不对,需要回到平台驱动里确认 mux 寄存器。第 5.5 节会专门讲。
2.3 电平与上拉电阻:模拟 I2C 翻车的第一现场
GPIO 模拟 I2C 和硬件 I2C 控制器最大的区别是:硬件控制器内部有真正的开漏输出,GPIO 通常没有。GPIO 只能靠“输出 0”和“切换输入”来模拟 SDA 被释放,此时 SDA 必须依赖外部上拉电阻回到高电平。如果板子上没有焊上拉,读 SDA 永远读到 0,驱动会以为从机一直在响应,或者一直不响应。
上拉电阻的选择要根据总线电平来。海思平台 IO 电平常见的是 1.8V 和 3.3V,参考经验值如下:
| 总线电平 | 推荐上拉阻值 | 现象与调整 |
|---|---|---|
| 1.8V | 1kΩ ~ 2.2kΩ | 阻值太大上升沿会拖得很慢 |
| 3.3V | 2.2kΩ ~ 4.7kΩ | 常用 2.2kΩ,短总线很稳 |
| 5V(少见) | 4.7kΩ ~ 10kΩ | 注意 GPIO 耐压,一般要电平转换 |
拿到板子后,先不要写驱动,用示波器量一下 SCL 和 SDA 的空闲电平,确认两个引脚都是高。然后手动把 SDA 拉低再放开,看上升沿时间。上升沿超过 1us,模拟 400kbps 基本没戏;超过 3us,连 100kbps 都可能要加大延时。这个检查只需要 5 分钟,能避开一半以上的模拟 I2C 玄学问题。
注意:如果你的板子上没有上拉电阻,软件写得再完美也没用。SDA 在输入状态下会漂,读到的数据全看环境噪声。
3. 从零写一个内核态 GPIO 模拟 I2C 驱动:核心源码与参数说明
这一章给出可以抄作业的驱动骨架。我选择注册一个 misc 设备,而不是一上来就做一个标准的 I2C adapter。原因是 misc 设备最快能跑通,调试阶段只需要 insmod 后打开/dev/gpio_i2c就能验证时序,不需要牵扯 i2c-core 的设备模型。等确认从设备能正常通信,再考虑把代码升级为标准 adapter,这部分放到第 6 章。
3.1 驱动框架与设备注册:misc 设备三步走
misc 设备在内核里是“小而杂”设备,不需要自己在 /dev 下建节点,内核会在注册时自动创建。完整流程三步:模块加载时申请 GPIO;实现 file_operations;用misc_register注册。
先定义用户态和内核态共用的请求结构体和 ioctl 命令,这是 ioctl 传参的载体:
struct gpio_i2c_req { unsigned char addr; /* 7位从机地址 */ unsigned char reg; /* 寄存器/字地址 */ unsigned char __user *buf; /* 数据缓冲区 */ size_t len; /* 数据长度 */ }; #define GPIO_I2C_MAGIC 0x49 #define GPIO_I2C_WRITE _IOW(GPIO_I2C_MAGIC, 0x01, struct gpio_i2c_req) #define GPIO_I2C_READ _IOWR(GPIO_I2C_MAGIC, 0x02, struct gpio_i2c_req)然后写 fops 和注册代码:
static int gpio_i2c_open(struct inode *inode, struct file *filp) { if (!gpio_is_valid(scl_gpio) || !gpio_is_valid(sda_gpio)) return -ENODEV; return 0; } static long gpio_i2c_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct gpio_i2c_req req; int ret = 0; if (copy_from_user(&req, (void __user *)arg, sizeof(req))) return -EFAULT; switch (cmd) { case GPIO_I2C_WRITE: ret = gpio_i2c_write_request(&req); break; case GPIO_I2C_READ: ret = gpio_i2c_read_request(&req); break; default: return -ENOTTY; } if (copy_to_user((void __user *)arg, &req, sizeof(req))) return -EFAULT; return ret; } static const struct file_operations gpio_i2c_fops = { .owner = THIS_MODULE, .open = gpio_i2c_open, .unlocked_ioctl = gpio_i2c_ioctl, }; static struct miscdevice gpio_i2c_miscdev = { .minor = MISC_DYNAMIC_MINOR, .name = "gpio_i2c", .fops = &gpio_i2c_fops, }; static int __init gpio_i2c_init(void) { int ret; ret = gpio_i2c_init_gpio(); if (ret) return ret; ret = misc_register(&gpio_i2c_miscdev); if (ret) { gpio_free(scl_gpio); gpio_free(sda_gpio); return ret; } return 0; } module_init(gpio_i2c_init);逻辑说明:copy_from_user先把用户态传进来的struct gpio_i2c_req拷贝到内核,但其中的buf仍然是指向用户态地址的指针,后续读写数据时还需要再解引用一次。unlocked_ioctl是现在主流的 ioctl 实现,不需要 BKL 锁。misc_register成功后,/dev/gpio_i2c节点会自动出现,不需要 mknod。
3.2 时序核心源码:字节读写与 START/STOP/ACK 的 C 实现
有了前面的宏和 start/stop,剩下的就是把字节、ACK、多字节读取拼起来。这段是整篇最核心的代码,建议照着敲一遍,不要直接复制然后当黑匣子用。
static int i2c_delay = 10; /* SCL半周期延时,单位us */ module_param(i2c_delay, int, 0644); static void i2c_half_delay(void) { udelay(i2c_delay); } /* 发送一个字节,返回0表示收到ACK,返回负值表示从机无应答 */ static int i2c_write_byte(unsigned char byte) { int i, ack; for (i = 7; i >= 0; i--) i2c_write_bit((byte >> i) & 0x01); /* 第9个时钟:释放SDA,让从机拉低表示ACK */ I2C_SDA_IN(); I2C_SCL_H(); i2c_half_delay(); i2c_half_delay(); ack = (I2C_SDA_READ() == 0) ? 0 : -EIO; I2C_SCL_L(); i2c_half_delay(); I2C_SDA_OUT(); return ack; } /* 读取一个字节,last_nak为1表示这是最后一个字节,主机要回NAK */ static unsigned char i2c_read_byte(int last_nak) { int i; unsigned char byte = 0; I2C_SDA_IN(); for (i = 0; i < 8; i++) { I2C_SCL_H(); i2c_half_delay(); byte = (byte << 1) | I2C_SDA_READ(); I2C_SCL_L(); i2c_half_delay(); } /* 主机在最后一个字节回NAK,否则回ACK */ if (last_nak) i2c_write_bit(1); else i2c_write_bit(0); return byte; }i2c_write_byte里有个容易看漏的点:在I2C_SDA_IN()之后连续调用了两次i2c_half_delay()。原因是最后一个数据位结束时,SDA 还停留在主机驱动的电平上。如果最后一位是 0,SDA 被拉低,此时切换输入后,外部上拉需要时间把它拉回高电平,然后从机才有机会拉低它给出 ACK。延时不加,ACK 采样点就会落在 SDA 还没稳定的那个窗口里,波形看着对,程序却经常报 -EIO。
多字节读写请求是直接给用户态用的。写请求是:START + 设备地址(W) + 寄存器地址 + 数据,然后 STOP。读请求则是:START + 设备地址(W) + 寄存器地址 + repeated START + 设备地址(R) + 读数据 + STOP:
static int gpio_i2c_write_request(struct gpio_i2c_req *req) { unsigned char *kbuf; int ret, i; if (req->len > 64) return -EINVAL; kbuf = kmalloc(req->len, GFP_KERNEL); if (!kbuf) return -ENOMEM; if (copy_from_user(kbuf, req->buf, req->len)) { kfree(kbuf); return -EFAULT; } i2c_start(); ret = i2c_write_byte((req->addr << 1) | 0); /* 写方向 */ if (ret) goto out; ret = i2c_write_byte(req->reg); if (ret) goto out; for (i = 0; i < req->len; i++) { ret = i2c_write_byte(kbuf[i]); if (ret) goto out; } i2c_stop(); out: kfree(kbuf); return ret; } static int gpio_i2c_read_request(struct gpio_i2c_req *req) { unsigned char *kbuf; int ret, i; if (req->len > 64) return -EINVAL; kbuf = kmalloc(req->len, GFP_KERNEL); if (!kbuf) return -ENOMEM; i2c_start(); ret = i2c_write_byte((req->addr << 1) | 0); /* 先写寄存器地址 */ if (ret) goto out; ret = i2c_write_byte(req->reg); if (ret) goto out; i2c_start(); /* repeated START */ ret = i2c_write_byte((req->addr << 1) | 1); /* 读方向 */ if (ret) goto out; for (i = 0; i < req->len; i++) kbuf[i] = i2c_read_byte(i == req->len - 1 ? 1 : 0); i2c_stop(); if (copy_to_user(req->buf, kbuf, req->len)) ret = -EFAULT; out: kfree(kbuf); return ret; }参数说明:req->addr是 7 位 I2C 从机地址,例如 EEPROM 是 0x50。驱动内部要把它左移一位,最低位表示方向。req->len限制在 64 字节以内,是为了和内核栈的简单交互保持一致;真要读写一页 256 字节,可以改大kmalloc长度,但每笔事务占用 GPIO 的时间会更长,容易被上层任务打断。
3.3 关键参数:延时设计、上拉电阻与时钟拉伸探测
整个模拟 I2C 驱动里,最值得花时间调的是i2c_delay。它决定了 SCL 半周期长度,直接对应总线速率。标准 100kbps 模式要求低电平周期不小于 4.7us,所以半周期取 5us 左右;400kbps 模式要求低电平不小于 1.3us,半周期取 2us。但由于海思平台 GPIO 操作本身有开销,udelay(5)实际拉出来的低电平可能已经接近 6~7us,所以建议用示波器实测后再定。
| 参数 | 经验默认值 | 调整方向 | 坑 |
|---|---|---|---|
| i2c_delay | 10us(100kbps) | 示波器实测后修正 | 改太小会丢 ACK |
| scl_gpio | 板级引脚号 | 通过 module_param 传入 | 必须避开已复用引脚 |
| sda_gpio | 板级引脚号 | 通过 module_param 传入 | 同上 |
| timeout | 1000 次循环 | 时钟拉伸超时 | 传感器测量时可能等几百 ms |
如果对接的是带测量状态的传感器,比如温湿度芯片,从机在测量期间会把 SCL 拉低,这就是时钟拉伸。主机必须在每个时钟高电平期间检查 SCL 是否被拉低,拉低就等待,不能继续发下一个时钟。对应实现如下:
static int i2c_wait_scl_high(void) { int timeout = 1000; I2C_SCL_IN(); while (timeout--) { if (I2C_SCL_READ()) return 0; udelay(1); } return -ETIMEDOUT; }启用这个函数后,i2c_write_bit和读位流程里的I2C_SCL_H()后面要跟进i2c_wait_scl_high()。注意I2C_SCL_IN()和I2C_SCL_OUT()的方向切换是有开销的,所以在不需要时钟拉伸的 EEPROM 场景下,别开这个功能,白白拖慢时序。
3.4 编译与加载:Makefile 和 insmod 后的首测
驱动代码放在海思 SDK 提供的内核源码树外编译,需要一个 Makefile。KERNEL_DIR 指到 SDK 里的内核目录,交叉编译器由 SDK 环境变量导出:
obj-m := gpio_i2c.o KERNEL_DIR := /opt/hisilicon/kernel/linux-4.9 ARCH := arm CROSS_COMPILE := arm-himix100-linux- all: $(MAKE) -C $(KERNEL_DIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) modules clean: $(MAKE) -C $(CROSS_COMPILE) M=$(PWD) clean注意:CROSS_COMPILE前缀要和你 SDK 自带工具链一致,不同 SDK 名字不一样,用ls /opt/hisilicon/确认。编译得到gpio_i2c.ko后,拷贝到板子上加载:
insmod gpio_i2c.ko scl_gpio=23 sda_gpio=24 i2c_delay=10 ls -l /dev/gpio_i2c dmesg | tail -20如果gpio_request失败,dmesg会打印具体的 GPIO 编号,这时候先检查这个脚是不是被别的驱动占用了。加载成功且设备节点存在后,不要急着接传感器,先用示波器或逻辑分析仪抓一次i2c_start和i2c_stop,确认 SDA、SCL 的边沿和空闲电平都正常。这一步能让你把“驱动问题”和“外部硬件问题”彻底分开。
4. 用 GPIO 模拟 I2C 对接真实从设备:EEPROM 与温湿度传感器的读写验证
驱动能 insmod、设备节点能打开,只是第一步。要证明这套模拟 I2C 真能用,必须挂一颗真实从设备做读写回环。我一般先拿 EEPROM 验证写读一致性,再用温湿度传感器验证多次读和时钟拉伸场景。
4.1 找一颗最廉价的从设备:EEPROM 读写流程
最常见的 EEPROM 从机地址是 0x50,7 位地址,驱动内部左移后变成 0xA0/0xA1。写流程是:START + 设备地址(W) + 字地址 + 数据,STOP;读流程是:START + 设备地址(W) + 字地址 + repeated START + 设备地址(R) + 读数据,STOP。
用户态测试程序可以直接用 ioctl 封装好的接口,不需要关心底层时序:
#include <stdio.h> #include <fcntl.h> #include <sys/ioctl.h> #include <string.h> #include <unistd.h> struct gpio_i2c_req { unsigned char addr; unsigned char reg; unsigned char *buf; size_t len; }; #define GPIO_I2C_MAGIC 0x49 #define GPIO_I2C_WRITE _IOW(GPIO_I2C_MAGIC, 0x01, struct gpio_i2c_req) #define GPIO_I2C_READ _IOWR(GPIO_I2C_MAGIC, 0x02, struct gpio_i2c_req) int main(void) { int fd = open("/dev/gpio_i2c", O_RDWR); struct gpio_i2c_req req; char wbuf[] = "hello"; char rbuf[8] = {0}; if (fd < 0) { perror("open"); return 1; } /* 写5个字节到字地址0 */ req.addr = 0x50; req.reg = 0x00; req.buf = (unsigned char *)wbuf; req.len = 5; if (ioctl(fd, GPIO_I2C_WRITE, &req) < 0) { perror("write"); return 1; } usleep(10000); /* EEPROM内部写周期 */ /* 从字地址0读回5个字节 */ req.reg = 0x00; req.buf = (unsigned char *)rbuf; req.len = 5; if (ioctl(fd, GPIO_I2C_READ, &req) < 0) { perror("read"); return 1; } printf("read: %.5s\n", rbuf); close(fd); return 0; }这段代码里的usleep(10000)不能省。EEPROM 完成一次页写入后,内部还需要一段时间把数据固化到存储单元,如果立刻发读命令,读回来的可能是旧数据或者全 0xFF。不同芯片这个时间不一样,常见的是 5ms 左右,取 10ms 比较稳妥。
4.2 对接温湿度传感器:一次读多个字节的时序坑
温湿度传感器和 EEPROM 不一样,很多型号要求在发起测量命令后等待转换完成,期间从机会拉伸 SCL。如果驱动没有处理时钟拉伸,主板发过来的时钟会在测量期间撞上从机的“刹车”,导致通信失败。现象是:第一次读能通,第二次回读全是 0xFF,或者偶发无响应。
多字节读取还有一个容易错的地方:最后一个字节主机必须回 NAK,然后才能发 STOP。如果驱动在最后一个字节之后仍然回 ACK,从机可能会继续驱动 SDA,和你发的 STOP 产生总线竞争。多数传感器的数据手册写得很清楚,但代码里这一步经常被忽略。
在 3.2 节的i2c_read_byte(int last_nak)里,last_nak=1已经处理了这个问题。如果你的传感器还要求“写一个命令寄存器,然后读多字节”,就复用gpio_i2c_read_request的结构,把命令字节放在req->reg里;如果传感器不需要寄存器地址、直接读就行,可以把req->reg当成一个任意固定值,或者新增一个不带 reg 的 ioctl 命令。我一般会在驱动里再补一个GPIO_I2C_READ_NO_REG,避免为这种传感器硬凑一个寄存器号。
4.3 验证步骤:从 dmesg 到数据回读全流程
完整的验证流程分三步。第一步,确认硬件环境:SDA、SCL 空闲电平为高,上拉电阻已焊,示波器探头挂在引脚上。第二步,加载驱动并跑用户态测试,先写 EEPROM 再读,确认读回内容一致。第三步,如果 EEPROM 通了但传感器不通,优先用逻辑分析仪抓 START 到 STOP 的完整波形,数一下时钟个数和 ACK 位置。
insmod gpio_i2c.ko scl_gpio=23 sda_gpio=24 i2c_delay=10 ./gpio_i2c_eeprom_test dmesg | tail -10如果读回的数据每字节都是 0xFF,先怀疑上拉和 SDA 读方向;如果第一个字节对、后面不对,去查 ACK 采样点;如果换一个 GPIO 就不工作,去查 pinmux。这一套排查顺序能覆盖绝大多数模拟 I2C 失败场景。
5. GPIO 模拟 I2C 避坑手册:5 条踩坑记录与排查方法
模拟 I2C 的坑不只在驱动代码里,更多在硬件设计、内核配置和并发环境里。下面记几条我反复遇到的,每一条都按“现象 -> 原因 -> 解决”来写。
5.1 现象:SDA 上升沿像一条缓坡,原因:上拉电阻没焊或阻值过大
第一次调 GPIO 模拟 I2C 时,我在示波器上看到 SCL 挺正常,SDA 的上升沿却是一段接近 2us 的斜线,逻辑分析仪能解出来,但只要把速率提到 400kbps 就完全乱码。原因是板子的 SDA 上拉电阻没有焊,只靠芯片内部非常弱的上拉在工作。后来补了一个 2.2kΩ 上拉,波形立刻变成垂直沿。
如果你也遇到类似情况,不要急着调i2c_delay。先量 SDA 从 0 到 1 的上升时间,如果超过 1us,问题基本都在上拉。解决方法是换小阻值上拉,或者检查 PCB 上这个网络的寄生电容。I2C 总线拉得太长、过孔太多,也会让上升沿变慢,这是硬件决定的,软件加延时只能治标。
5.2 现象:第一字节能通、后续字节全乱,原因:ACK 后 SDA 方向没切回来
这是一个很隐蔽的代码问题。i2c_write_byte在发完 8 个数据位后会切 SDA 为输入,采样完 ACK 后,如果忘记把 SDA 切回输出,下一字节的第一个数据位就会写不进去,因为 SDA 一直处于输入状态,主机根本拉不动它。表现就是:从机能 ACK 第一个设备地址,但后续字节它完全收不到,逻辑分析仪上看 SDA 在第二个字节期间纹丝不动。
解决方法是严格按照 3.2 节代码的顺序:I2C_SDA_IN()采样 ACK,SCL 拉低后立即I2C_SDA_OUT(),再继续下一字节。如果代码里用了不同函数拼装,务必把方向切换检查一遍。这也解释了为什么很多人第一次抄网上的模拟 I2C 代码会翻车:抄的片段漏了I2C_SDA_OUT()。
5.3 现象:传感器偶发无响应,原因:时钟拉伸没处理
温湿度传感器在启动测量后,会把 SCL 拉低,直到内部转换完成。如果驱动完全没做时钟拉伸检测,只是按固定udelay往前发时钟,从机等于在被强制打断,通信自然失败。这个故障不是每次都出现,因为传感器转换时间有波动,读得快可能刚好躲过去,读得慢一定翻车。
解决方法是启用 3.3 节的i2c_wait_scl_high(),在每次 SCL 拉高后检查是否真的变高。注意检查后要有一个超时退出,否则从机故障永久拉低 SCL,驱动会死循环。超时时间我习惯设 100ms 左右,因为正常传感器的转换时间很少超过这个值。
5.4 现象:在中断或定时器里调用驱动直接挂死,原因:GPIO 操作在原子上下文
模拟 I2C 驱动里的gpio_set_value在部分内核配置下会导致睡眠,因为它可能走 I2C 扩展芯片或需要拿互斥锁。如果在中断上下文直接调用 ioctl 或封装的写读函数,系统可能直接死锁或 oops。我第一次在定时器里调用读函数时,板子一跑就重启,回头看崩溃栈全是 gpio 锁。
解决方法是把 I2C 事务放到工作队列或线程里,中断里只置一个标志位,由内核线程统一处理。如果一定要同步访问,可以改用gpio_raw_set_value这类原子接口,但要确认目标 GPIO 是真的内存映射引脚,不是挂在总线扩展芯片上的。
5.5 现象:同一套源码在另一块板子上波形完全不对,原因:pinmux 复用冲突
海思平台的引脚复用非常灵活,同一个物理引脚可能被配置成 UART、SPI、PWM 或 GPIO。某块板子的内核设备树里,这个引脚默认被配成其他功能,你的驱动虽然gpio_request成功了,但 pinmux 还在别人手里,实际驱动不了电平。现象是:代码没问题,示波器却量不到波,或者引脚电平被其他外设来回拉。
解决方法是先查板级 dts 或 SDK 的 pinmux 表格,确认这两个 GPIO 没有被其他节点占用;然后在内核启动日志里搜pinmux相关配置。更稳妥的做法是直接用 SDK 提供的himm工具在 uboot 阶段改写 mux 寄存器,或者通过设备树 pinctrl 把两个引脚配成 GPIO 功能。这一步属于硬件平台定制,换一块板子必须重新查。
6. 进阶:把模拟 I2C 接入 Linux I2C 子系统,让 i2c-tools 直接操作
MISC 设备方案始终是“单机版”,驱动只对你自己的 ioctl 负责。如果你想在设备树里挂一个传感器驱动,或者用i2cdetect、i2cget这些标准工具来调试,就需要把模拟 I2C 升级成内核 I2C 子系统里的一个 adapter。好消息是内核本来就提供了现成的算法,不需要自己把时序再写一遍。
6.1 用 i2c-algo-bit 复用内核算法:从手写时序到两行注册
内核的 i2c-algo-bit 模块实现了完整的 bit-banging 算法,你只需要提供 setsda、setscl、getsda、getscl 四个回调。它内部会处理大多数边界情况,比手写折腾 ACK 方向安全得多。接入方法如下:
#include <linux/i2c-algo-bit.h> #include <linux/i2c.h> static struct i2c_adapter gpio_i2c_adapter; static struct i2c_algo_bit_data gpio_i2c_bit_data; static void gpio_i2c_setscl(void *data, int state) { if (state) gpio_direction_output(scl_gpio, 1); else gpio_direction_output(scl_gpio, 0); } static void gpio_i2c_setsda(void *data, int state) { if (state) gpio_direction_output(sda_gpio, 1); else gpio_direction_output(sda_gpio, 0); } static int gpio_i2c_getscl(void *data) { gpio_direction_input(scl_gpio); return gpio_get_value(scl_gpio); } static int gpio_i2c_getsda(void *data) { gpio_direction_input(sda_gpio); return gpio_get_value(sda_gpio); } static int __init gpio_i2c_adapter_init(void) { gpio_i2c_bit_data.setsda = gpio_i2c_setsda; gpio_i2c_bit_data.setscl = gpio_i2c_setscl; gpio_i2c_bit_data.getsda = gpio_i2c_getsda; gpio_i2c_bit_data.getscl = gpio_i2c_getscl; gpio_i2c_bit_data.udelay = i2c_delay; gpio_i2c_bit_data.timeout = HZ; gpio_i2c_adapter.owner = THIS_MODULE; gpio_i2c_adapter.class = I2C_CLASS_HWMON; gpio_i2c_adapter.algo_data = &gpio_i2c_bit_data; gpio_i2c_adapter.name = "gpio-i2c"; return i2c_bit_add_bus(&gpio_i2c_adapter); }i2c_bit_add_bus会把i2c_algo_bit的算法绑定到 adapter 上,省去手动填algo的麻烦。展开以后,设备的 START、STOP、ACK 都由内核算法完成,你只需要保证四个回调的速度够快、方向切换正确。这也是我推荐“产品化”时的标准做法,而不是长期维护自己那套 MISC 驱动的时序代码。
6.2 接入后的验证与限制:哪些场景不能省掉硬件控制器
接入子系统后,验证方式立刻变成标准 I2C 工具链。加载模块后,先用i2cdetect -l看 adapter 是否注册成功,再用i2cdetect -y <bus号>扫描从机地址。如果探测到 EEPROM,说明地址应答正常;用i2cget -y <bus> 0x50 0x00能直接读到字节,比写 ioctl 测试程序快得多。
但必须清醒一点:GPIO 模拟 I2C 始终是“软实时”的。它受内核调度影响,SCL 半周期会有抖动,所以不适合高速传输,也不适合需要精确时序的外设。比如某些传感器要求连续读取不能间隔太久,如果系统里刚好有高优先级中断在忙,总线上的波形就被拉长,采样点就偏了。硬件 I2C 控制器有硬件 FIFO 和中断,能在低 CPU 占用下保证时序,这是 GPIO 模拟比不了的。
6.3 我的取舍:什么时候保留 MISC、什么时候改用硬件 I2C
如果是调试阶段,我一般保留 MISC 设备方案,因为它最简单,出了问题可以直接在 ioctl 里加打印,不用被内核 I2C 子系统的层层封装绕晕。如果是量产 BSP 里的固定外设,我会至少把它升级成 i2c-algo-bit adapter,让设备模型正规化。如果板子上还有空闲的硬件 I2C 控制器,我绝对优先用硬件控制器,哪怕要多接一根飞线。
模拟 I2C 的成本不在那几颗上拉电阻,而在调试它的时间。时序要看示波器、方向切换要仔细检查、并发要考虑锁,这些问题每一个都要花真金白银。我的习惯是:任何一对 GPIO 模拟 I2C 的引脚,在原理图上必须标注“软件 I2C”,这样硬件工程师改版时才有机会换成硬件控制器。希望帮到你。
本文还有配套的精品资源,点击获取