简介:面向海思平台Linux驱动开发场景,这份资源用GPIO软件模拟I2C时序,解决无硬件I2C控制器或需节省外设资源时的通信需求,适合嵌入式驱动工程师与内核学习者参考。压缩包共3个文件,包括1个c驱动源码、1个h头文件和1个makefile,整体约4KB。h文件定义gpio_i2c_device结构,包含SDA/SCL GPIO号、时序参数等关键字段;c文件完整实现gpio_i2c_probe、gpio_i2c_remove与gpio_i2c_xfer,涵盖GPIO申请与方向设置、i2c适配器注册、起始/停止信号生成、地址读写、数据位翻转、应答检测及超时错误处理。传输函数会根据i2c_msg方向区分读写操作,通过控制GPIO电平模拟实时时序,并在异常时给出退避处理。已有992人学习该资源,可直接对照代码理解软件模拟I2C的时序控制与底层实现,并在海思平台上快速移植验证,节省从零开发时间;同时围绕i2c_msg消息队列循环处理,适合作为内核适配器编写的范例。
1. 海思平台I2C控制器不够用时:GPIO模拟I2C为什么是必选项
做海思平台Linux驱动,经常会碰到一个拧巴的局面:芯片自带I2C控制器的数量是固定的,而且大多已经被SDK默认占用——一组接了sensor,一组接了EEPROM或看门狗,新加的外部器件只能干瞪眼。海思平台I2C控制器不够用不是罕见情况,尤其在板级方案改版、要外接多个I2C从设备时,最省事的兜底方案就是用两个GPIO口把I2C时序模拟出来,再挂到内核的i2c-algo-bit框架上,让上层无感访问。这套方案解决的是总线资源不足和管脚复用冲突,适合正在做海思Linux二次开发、调试外设驱动的工程师。它不挑具体芯片型号,只要内核支持标准GPIO子系统和i2c-dev,就能把源码包里的驱动搬过去,编译、加载、直接当一条普通I2C总线用。
2. 从时序到bit-bang:先把I2C协议拆成GPIO能执行的动作
2.1 I2C协议里真正绕不开的只有四个信号
GPIO模拟I2C不需要把协议栈重新实现一遍,内核的i2c-algo-bit已经替我们做了消息调度和地址匹配,我们要做的只是把这四类底层信号用GPIO翻出来:START、STOP、数据位、ACK。I2C的物理层本质是两根线:SCL(时钟)和SDA(数据)。START信号的定义是“SCL为高电平时,SDA产生一个下降沿”;STOP的定义是“SCL为高电平时,SDA产生一个上升沿”。数据位则要求SDA在SCL高电平期间保持稳定,SCL低电平期间允许SDA跳变。每个字节传输完毕后的第9个时钟周期就是ACK位,从机把SDA拉低表示应答。
static void i2c_delay(void) { udelay(2); /* 标准模式100kHz周期10us,半周期5us;这里取2us是给快速模式留余量 */ } static void scl_high(void) { gpio_set_value(scl_pin, 1); /* 拉高SCL,让从机采样SDA */ i2c_delay(); } static void scl_low(void) { gpio_set_value(scl_pin, 0); /* 拉低SCL,允许SDA变化 */ i2c_delay(); }这里最核心的动作是SCL高和低之间必须有延时,否则从机跟不上。延时太小信号不稳定,太大速率上不去。i2c_delay最终值一般是在逻辑分析仪上量过实际波形才定死,先用2us起步,后面按波形校正。上面这段代码是后面所有时序函数的地基,START、STOP、读写字节都会调用它。
2.2 START和STOP怎么用GPIO翻出来
static void i2c_start(void) { gpio_set_value(sda_pin, 1); scl_high(); gpio_set_value(sda_pin, 0); /* SCL高电平期间SDA下拉,产生START */ i2c_delay(); scl_low(); }START的关键在于翻转顺序:必须先保证SDA为高,再拉高SCL,最后在SCL高电平期间把SDA拉低。顺序错了,从机不认为这是起始条件。STOP是镜像操作——SCL高电平期间把SDA拉高。这两个信号是所有I2C通信的边界,海思SDK里自带的i2c控制器驱动同样遵循这套时序,只是硬件帮你翻了引脚,这里用手翻。
2.3 从设备和主设备的角色切换:读数据时必须放开SDA
GPIO模拟I2C比硬件控制器麻烦的地方在于SDA是双向的。主设备写数据时SDA是输出,主设备读数据时SDA要切到输入,靠上拉电阻把电平拉高,从机再根据要发送的bit去拉低或释放。GPIO子系统切换方向有一个典型模式:
static int i2c_read_bit(void) { int bit; gpio_direction_input(sda_pin); /* 读之前必须切输入,否则总线冲突 */ scl_high(); /* 从机在SCL高电平时把数据放到SDA上 */ i2c_delay(); bit = gpio_get_value(sda_pin); scl_low(); gpio_direction_output(sda_pin, 0); /* 切回输出,准备下一个ACK位 */ return bit; }这里有个很多新手会栽的坑:读位之前不切输入,直接gpio_get_value。GPIO方向配置仍然是输出时,读到的电平是自己驱动出来的值,不是从机发的电平。表现为设备地址扫描时某些地址能收到ACK、某些一直超时,而且每次结果还不一样。另一方面,gpio_direction_input和gpio_direction_output背后的实现是寄存器读写,每次切换都有开销,这也是模拟I2C速率上不去的根源之一。
2.4 为什么选GPIO模拟而不是直接改硬件方案
I2C控制器、GPIO模拟、外部IO扩展芯片三种方案放在一起对比,才能看出各自的边界。硬件I2C控制器速率可以做得很高(400kHz以上)、有FIFO和DMA、有中断和错误检测,但数量是死的,而且海思平台多个I2C控制器的管脚复用常被SDK固化。GPIO模拟的优势是管脚灵活、代码可控、不依赖具体控制器外设,缺点是CPU占用高、速率受GPIO翻转速度限制、没有硬件仲裁。外部IO扩展芯片(比如用PCA9685之类)确实能扩展总线数量,但还要先驱动扩展芯片本身,属于“先有鸡还是先有蛋”的问题,所以真正落地时GPIO模拟仍然是最直接的手段。
2.5 中继模式:协议层的ACK检查放在哪里做
很多第一次写模拟I2C的人会把ACK检查放到bit-bang函数里,在主循环里逐位判断。其实内核的i2c-algo-bit框架已经把这层逻辑包好了。我们只需要实现底层的byte写和byte读,i2c-algo-bit会自动完成地址重试、ACK检查、对端无响应时的错误上报。
| 数据操作 | 框架层处理 | bit-bang层要做的 |
|---|---|---|
| 写一个字节 | 组装START + 地址 + DATA + STOP | 按位翻转SDA,SCL同步打拍 |
| 读一个字节 | 同样组装消息序列 | 读取每个bit,SCL驱动时钟 |
| ACK检查 | 地址阶段读SDA、判断ACK | 数据方向控制、第9个SCL时钟 |
| 总线错误重试 | 发生NACK后发STOP重试 | 暴露errno给上层i2c_msg |
结论是:底层只做物理信号,逻辑层全部交给i2c-algo-bit,不要在驱动里重复发明协议。很多所谓“GPIO模拟I2C不稳定”的案例,其实是把协议判断写在底层导致状态机混乱。这条界限清楚了,代码量会小很多,出问题的概率也低。
3. 驱动框架与源码结构:i2c-algo-bit、platform driver、misc设备三层关系
3.1 从零搭一个privatedriver的骨架
源码包里的驱动本体实际上是一个platform driver,因为海思SDK普遍用设备树描述管脚和硬件资源。驱动的probe函数负责获取GPIO、初始化i2c_algo_bit_data结构、注册i2c_adapter;remove函数反向注销。下面是probe函数的核心片段:
static int gpio_i2c_probe(struct platform_device *pdev) { struct i2c_adapter *adap; struct i2c_algo_bit_data *bit_data; struct device *dev = &pdev->dev; struct gpio_i2c_platform_data *pdata; int ret; pdata = devm_kzalloc(dev, sizeof(*pdata), GFP_KERNEL); if (!pdata) return -ENOMEM; pdata->scl_gpio = of_get_named_gpio(dev->of_node, "scl-gpios", 0); pdata->sda_gpio = of_get_named_gpio(dev->of_node, "sda-gpios", 0); if (!gpio_is_valid(pdata->scl_gpio) || !gpio_is_valid(pdata->sda_gpio)) { dev_err(dev, "invalid gpio pin\n"); return -EINVAL; } bit_data = devm_kzalloc(dev, sizeof(*bit_data), GFP_KERNEL); bit_data->setsda = gpio_i2c_setsda; bit_data->setscl = gpio_i2c_setscl; bit_data->getsda = gpio_i2c_getsda; bit_data->udelay = pdata->udelay; bit_data->timeout = msecs_to_jiffies(pdata->timeout); bit_data->data = pdata; adap = devm_kzalloc(dev, sizeof(*adap), GFP_KERNEL); adap->owner = THIS_MODULE; adap->class = I2C_CLASS_DEPRECATED; adap->algo_data = bit_data; adap->dev.parent = dev; adap->nr = pdata->bus_num; strlcpy(adap->name, "GPIO_I2C_ADAPTER", sizeof(adap->name)); ret = i2c_bit_add_numbered_bus(adap); if (ret) return ret; platform_set_drvdata(pdev, adap); dev_info(dev, "gpio i2c adapter registered, bus %d\n", adap->nr); return 0; }probe里做了三件关键事:从设备树读GPIO编号、填i2c_algo_bit_data回调组、注册i2c适配器。i2c_bit_add_numbered_bus决定了这条模拟总线的编号是“指定编号”而非自动分配,这样上层打开/dev/i2c-x时编号是固定的,不会随着启动顺序漂移。若用i2c_bit_add_bus则让内核自动挑一个空闲的bus号,适合不关心编号的场景。
3.2 i2c_algo_bit_data回调组:四个函数指针打通上下层
i2c_algo_bit_data里最核心的四个回调是setsda、setscl、getsda、getscl,分别对应SDA输出、SCL输出、SDA输入、SCL输入。有时还要用到getscl,用于时钟同步和读取SDA状态异常时的恢复流程。这个结构体把物理层和协议层解耦,内核的bit-bang算法不关心GPIO到底怎么操作,只要这五个回调能反映真实的引脚电平。
static void gpio_i2c_setsda(void *data, int state) { struct gpio_i2c_platform_data *pdata = data; if (state) gpio_set_value(pdata->sda_gpio, 1); else gpio_set_value(pdata->sda_gpio, 0); } static int gpio_i2c_getsda(void *data) { struct gpio_i2c_platform_data *pdata = data; gpio_direction_input(pdata->sda_gpio); /* 读之前切换方向,避免读回自己输出电平 */ return gpio_get_value(pdata->sda_gpio); }常见做法的确是这样,但如果读操作频繁切换GPIO方向,性能会受拖累。一种优化是初始化时把SDA配成开漏模式,用输入输出去控制寄存器的高低切换,省掉direction切换。海思平台多数GPIO控制器支持开漏配置,但要注意设备树里要配置好,否则pinctrl不认。这里gpioget和gpioset的state参数含义是0或1,不是GPIO_ACTIVE_HIGH那种带极性反转的概念,极性转化要在解析设备树时提前完成。
3.3 源码包文件组织和内核Makefile的接入方式
拿到源码包后第一件事是搞清楚文件怎么编译成ko。源码包通常包含三个文件:gpio_i2c.c、gpio_i2c.h、Makefile。gpio_i2c.c是驱动主体,声明platform_driver并注册模块入口;gpio_i2c.h放平台数据结构体和方向宏;Makefile提供内核模块编译规则。
obj-m := gpio_i2c.o KERNELDIR ?= /path/to/kernel PWD := $(shell pwd) ARCH ?= arm CROSS_COMPILE ?= arm-himix100-linux- default: $(MAKE) -C $(KERNELDIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) ARCH=$(ARCH) clean编译时把KERNELDIR换成海思SDK解压出来的内核源码目录,CROSS_COMPILE按SDK自带的交叉编译器前缀来填。这里有个容易翻车的点:海思SDK有多套交叉编译器,比如针对不同平台有不同的工具链,不能拿一套硬套。编出来的ko要检查内核版本匹配,SDK里的内核源码树如果自己单独编过,模块版本号不一致时insmod会报unknown symbol或者版本魔术字错误。
3.4 数据结构的生命周期管理:memory devm和引用计数
上面代码里大量使用devm_kzalloc,这个函数在probe里分配的内存不用手动释放,设备移除时内核会自动回收。老代码习惯用kzalloc加在remove里手动kfree,但一旦分支里有错误提前返回,容易泄漏。devm系列函数的一个隐含缺点是它会绑定设备生命周期,如果GPIO编号解析失败或者i2c_adapter注册失败,释放顺序要和probe里的申请顺序完全相反。这里不推荐手动混用devm和非devm内存,一旦混用,remove函数里的释放时序非常容易搞错。
4. 源码集成与验证流程:从dts配置到/dev/i2c-x读写
4.1 设备树里怎么描述SCL和SDA两根GPIO
设备树是海思平台驱动最常用的硬件描述方式,GPIO模拟I2C在设备树里看起来像这样:
gpio_i2c: gpio-i2c { compatible = "vendor,gpio-i2c"; scl-gpios = <&gpio4 5 GPIO_ACTIVE_HIGH>; sda-gpios = <&gpio4 6 GPIO_ACTIVE_HIGH>; bus-num = <5>; udelay = <2>; timeout = <100>; status = "okay"; };compatible字符串要和源码里of_match_table里的完全一致,少一个字母都不行。scl-gpios和sda-gpios的写法是GPIO口的引用,<&gpio4 5 GPIO_ACTIVE_HIGH>表示gpio4控制器下面的第5号引脚,极性为高有效。这里的GPIO_ACTIVE_HIGH只是描述电平极性关系,真正的管脚复用功能(比如把一个引脚从普通GPIO切到其它功能)需要pinctrl节点配合。尤其在海思平台上,很多引脚默认复用成了其它外设,光写gpio属性不够,还得在pinctrl里把这个引脚复用成GPIO功能。一个常见毛病是设备树改了gpio子节点但忘记同步pinctrl,结果bo加载时of_get_named_gpio能拿到编号,但实际驱动到不了引脚,看寄存器电平永远为高。
4.2 把ko编译进SDK的build流程
海思SDK的模块编译方式通常不是手动敲make,而是通过SDK的build脚本统一编。源码包里的Makefile,我一般会先手动验证能编过,再把它整个目录拷贝到SDK板级目录,或者用SDK的ext_module机制。手动编译命令如下:
make KERNELDIR=/home/user/hisi-sdk/kernel ARCH=arm CROSS_COMPILE=arm-himix100-linux-编出gpio_i2c.ko之后,下一步是加载:
insmod gpio_i2c.ko dmesg | tail -20dmesg输出里能看到probe是否成功、注册的bus号是多少。如果systemd或SDK的rc脚本已经实现了模块加载,则要把ko放到SDK发布文件系统的/lib/modules目录,并在启动脚本里加insmod命令。加载顺序也有讲究:必须在gpio控制器驱动已经加载之后加载,否则of_get_named_gpio拿到的gpio_chip可能还没注册,调用gpio_direction_output会失败。海思平台的内核一般采用device tree,GPIO控制器驱动由内核统一定位,基本不会出现这个问题,但如果GPIO控制器本身被编译为模块,顺序就会出错。
4.3 用户态访问方式:i2c-dev和ioctl
驱动加载成功后,/dev目录下会多出一个i2c-x节点,这个节点的编号对应设备树里bus-num或者系统自动分配的值。可以用i2c-dev工具直接访问:
#include <stdio.h> #include <fcntl.h> #include <linux/i2c-dev.h> int main(void) { int fd = open("/dev/i2c-5", O_RDWR); if (fd < 0) { perror("open"); return -1; } /* 从机地址0x50,8bit格式左移一位后的值 */ unsigned char buf[2] = {0x00, 0xAA}; struct i2c_rdwr_ioctl_data packets; struct i2c_msg messages[1]; messages[0].addr = 0x50; messages[0].flags = 0; /* 写方向 */ messages[0].len = 2; messages[0].buf = buf; packets.msgs = messages; packets.nmsgs = 1; if (ioctl(fd, I2C_RDWR, &packets) < 0) { perror("ioctl"); close(fd); return -1; } close(fd); return 0; }上面这段是典型的用户态I2C访问代码:先open设备节点,再构造i2c_msg数组,ioctl用I2C_RDWR一次传输多个消息。相比直接调用read/write,I2C_RDWR可以组合“先写寄存器地址再读数据”的场景,而且能实现原子的重复起始位。很多从设备手册要求读操作是“写地址+重复START+读数据”,如果拆成两次ioctl调用,中间可能被其它进程抢占,导致从设备状态错乱。I2C_RDWR则保证了一次系统调用内的所有消息按序发出。
4.4 第一次通电先不要接设备:地址扫描
首次验证时,建议先不接任何从设备,直接跑一个地址扫描程序,确认驱动能正常发起START并收到NACK。NACK是正常现象,因为总线上没有设备拉低SDA。若地址扫描完全没反应,连NACK都读不到,大概率是GPIO极性配反或者SCL/SDA两根线接反了。我一般用i2cdetect工具,或者用下面这段简化的扫描代码,只扫0x03到0x77范围内的奇数地址:
for (addr = 0x03; addr < 0x78; addr++) { uint8_t val = 0; struct i2c_msg msg = {addr, 0, 1, &val}; struct i2c_rdwr_ioctl_data packets = {&msg, 1}; if (ioctl(fd, I2C_RDWR, &packets) < 0 && errno == EREMOTEIO) printf("addr 0x%02x: no ack\n", addr); else if (ioctl(fd, I2C_RDWR, &packets) == 0) printf("addr 0x%02x: ack!\n", addr); }这段代码的价值在于能一次性确认模拟总线的物理通路是否正确。正常情况下,所有地址都返回NACK(或EREMOTEIO)。若某些地址显示有ACK但实际没挂设备,极可能是SDA方向切换异常,从机没在这个地址上,反而是GPIO读回来的电平错了。
5. 避坑指南:GPIO模拟I2C的五个典型故障与排查
5.1 第一位数据总是错,波形上SDA跳变在SCL高电平中间
现象:用示波器抓SCL/SDA,第一个字节的最高位时序不对,SDA在SCL高电平时还在变化,从机容易识别成START或乱码。
原因:写第一个bit时,初始SDA状态没准备到位。START结束后SDA正好处于低电平,如果下一个bit要写1,SDA必须先拉高,再抬SCL。很多实现没做“数据准备”这一步,直接抬SCL导致从机采错。
解决:在i2c_byte写函数的循环里,先根据bit值设置SDA,延时,再抬高SCL。顺序必须固定:SDA变化要发生在SCL低电平期间,收紧时序先准备SDA再产生SCL上升沿。修正后波形上可以看到SDA的变化点全部位于SCL低电平段中间。
5.2 同一份驱动在A板卡正常,在B板卡上完全不通
现象:内核模块和用户态程序都没变,只是换了颗CPU或者换了块底板的IO扩展方式,总线就完全无响应。
原因:不同板卡的GPIO控制器的读写开销不同。有的GPIO挂在低速总线上,操作一次要几十微秒,I2C信号被拉宽但没问题;有的GPIO带内部上拉或下拉,SDA天然被拉到一个电平,导致从机永远收不到正确START。还有一种是pinctrl配置没迁移过来,板卡B的引脚复合功能不同。
解决:先量硬件电平,确认总线空闲时SDA和SCL都在高电平。再用逻辑分析仪抓地址周期,如果地址bit数和相位都对,但ACK位置异常,多半是延时参数和方向切换时序需要按板卡重新标定。把udelay从2us改成4us或8us试试,有些情况下半速运行反而稳定。
5.3 与海思SDK自带的I2C控制器驱动共用管脚冲突
现象:模块加载时报-gpio_request-失败或者-gpio direction-错误,dmesg提示该GPIO已被占用。
原因:SDK的默认设备树里,同一组引脚既配置成了I2C控制器功能,又被我们当成普通GPIO使用。某个其它设备驱动已经请求了这个GPIO,或者pinctrl里把这组引脚复用成了I2C功能,gpio_request自然失败。
解决:检查设备树中默认的i2c节点是否在dtsi里被enable,确认该节点没有冲突引脚。在/i2c控制器节点里加status = "disabled",或者在pinctrl配置中删除这一组引脚复用关系。这里还要留意海思的复用寄存器,有时候设备树里没写但bootloader已经设了引脚复用,必须对照芯片手册把对应的mux寄存器改到GPIO功能,不然后面怎么配都白搭。
5.4 速率上不去:400kHz模式下从机偶发NACK,降速到100kHz就正常
现象:udelay改成1us后,短距离通信正常,稍长距离或者多字节读写就偶发超时,监控设备日志能看到NACK。
原因:GPIO模拟没有I2C控制器的毛刺滤波和时钟整形能力。SCL上升沿过缓,从机采样不准;SDA变化过快又产生振铃,形成伪时钟。400kHz要求2.5us的周期,但GPIO操作本身要占用寄存器读写时间,实际留给延时的空间很小。
解决:把udelay调到3-5us,先把速率稳定住,之后再用逻辑分析仪看每一段电平的实际宽度。缩短总线走线长度,在设备端加2.2k上拉电阻,必要的时候在SCL和GND之间加一个小电容滤波。但不要盲目加电容,容值大了会把上升沿拖慢,反而降低可用速率。
5.5 内核日志正常但用户态读到的全是0xFF
现象:insmod成功,i2c节点存在,从机设备地址扫描也正常,但读固定寄存器总是0xFF,写寄存器也不生效。
原因:SDA方向切换和字节读时序有隐藏问题。主机读数据时,SDA切输入,释放总线依赖上拉电阻;若板级上拉电阻没贴,总线释放时电平上不去,读到的一直是真实的0而不是从机发的电平。另一个原因是从机要求读操作前必须发寄存器地址,程序直接发读命令导致从机返回无意义数据。
解决:检查外部上拉电阻,标准I2C需要1k到4.7k的上拉,很多板子默认没焊。接着核对读时序:发地址后必须补一个重复START,再发读命令。重复START在i2c_msg数组里用flags标志区分,具体做法是第一条消息flags=0,第二条消息flags=I2C_M_RD,内核对重复起始位的自动处理依赖算法层正确完成。
6. 波形验证与速率校准:让模拟I2C在调试器面前不翻车
最后说一个我每次调试必备的招式:用逻辑分析仪把时序拉出来,对着波形改参数。GPIO模拟I2C是个“黑匣子”,内核日志只能告诉你消息整体成功或失败,很难回答“到底哪一段波形不对”——比如SDA是在SCL高电平期间变化的,还是上拉电阻太弱导致上升沿陡不起来,这些只有看波形才知道。
连接逻辑分析仪时,SDA、SCL接对应通道,GND要共地。采样率不要求很高,25MHz足够。触发模式设为通道SCL下降沿或SDA下降沿,抓一段从START开始到STOP结束的完整通信,重点看三处:START是否符合规范(SCL高时SDA下降)、数据位在SCL高时是否稳定、ACK位之后总线上SDA是否确实被拉低。
我第一次调这个驱动时,波形看起来正常,但读EEPROM怎么都不对,后来把波形放大才发现SCL上升沿有毛刺,在SCL高电平中间SDA跟着跳了一下,从机把那个跳变认成了第二个START。CPU GPIO操作在寄存器层面是有延时的,代码里看起来是先写SDA再抬SCL,但实际GPIO控制器的写操作不是瞬间完成,两个寄存器写操作之间可能存在竞争。从那以后我再调GPIO模拟I2C,都强制走一遍波形验证加延时校准的流程:先量实际波形算出SCL半周期,再反向微调udelay,确认meta的波形和协议层完全对应,然后才接真正的从机器件。
另外,源码包里附带的示例工具值得一用。它实现了一个read-reg/write-reg的命令行工具,用来验证从设备寄存器寻址方式非常顺手。这个工具的核心代码比上面的示例多了对I2C_M_RD标志的处理,能正确处理“写地址后重复起始位再读数据”的经典序列。实际对接传感器时,先通过这个工具把设备寄存器读一遍,确认通路正常,再放到具体的业务驱动里,省掉大量在应用框架里打日志的时间。
希望帮到你——愿你的模拟I2C总线一拉就通,不用像我当年那样对着波形找跳变找了一整个晚上。
本文还有配套的精品资源,点击获取