
简介海思平台开发中GPIO模拟I2C常见于无硬件I2C控制器或需节省资源的场景能通过普通GPIO精确模拟I2C时序完成设备通信。资源为完整驱动源码面向嵌入式驱动开发者和海思平台学习者可系统理解软件模拟I2C的架构与实现细节。压缩包共3个文件gpio_i2c.c、gpio_i2c.h、Makefile整体仅4KB便于快速通读头文件定义设备结构体、GPIO配置和函数声明C文件覆盖探测、移除、数据传输与起始/停止/应答等时序生成Makefile提供编译入口。已有992人学习下载源码中probe/xfer等关键函数包含方向切换、时钟拉高拉低和超时错误处理可直接参考其思路完成驱动移植或避坑对内核I2C子系统调试也很有价值。1. 场景入口海思Linux平台上为什么放着硬件I2C不用反而要做一份GPIO模拟I2C源码一块板子摆到面前系统起来以后只差两路I2C外设没通。硬件工程师说第二路I2C那对引脚在量产时被别的功能占用了硬件上不能动。这种“引脚不够用但外设又必须上”的局面在海思Linux平台项目里很常见于是软件层面就得做GPIO模拟I2C。这份源码就是干这个的不占用硬件I2C控制器随便挑出两个空闲GPIO一个定义成SCL、一个定义成SDA用软件把START、STOP、地址帧、ACK、数据字节全部按协议时序搭出来再以Linux内核标准i2c_adapter的身份注册进系统。注册完成之后用户态的i2c-tools、内核里的各种传感器驱动都会觉得这块外设就是挂在一条普通i2c总线上。做海思BSP、摄像头模组适配和驱动入门的朋友这份代码值得打开看一眼。2. 时序核心拆解一个Bit从拉高拉低到收到ACK的完整链路2.1 源码文件结构与私有数据结构拿到这份源码建议先把驱动文件和一个叫gpio_i2c.h的头文件打开对应着看。源码没有依赖内核里现成的i2c-algo-bit框架而是把bit-banging状态机直接写在本地这样做的好处是能精确控制每一个半周期延时代价是START、STOP、ACK这些状态都得自己维护。私有结构体把所有跟硬件相关的引脚、延时常量、锁都收在一个地方后面换板子只需要改这一处。文件清单大致如下文件作用gpio_i2c.c驱动主文件i2c_adapter注册、i2c_algorithm回调、模块参数gpio_i2c.h私有结构体、引脚与频率参数定义、函数声明Makefile独立编译成内核模块gpio_i2c.dtsi设备树节点示例包含引脚、总线号、频率源码一开始先定义了一个结构体驱动几乎所有状态都挂在它下面struct gpio_i2c_dev { struct i2c_adapter adap; int scl_pin; int sda_pin; unsigned int half_cycle_us; spinlock_t lock; struct device *dev; };half_cycle_us是指SCL翻转一次之后的等待时间。I2C协议里一个时钟周期包含高电平和低电平两个半周期标准模式100kHz下周期约10us半周期就是5us快速模式400kHz下周期2.5us半周期约1.25us。把这几个字段抽出来之后驱动在probe阶段只要从模块参数或设备树里给它们赋值就能适配不同板子。2.2 引脚初始化与默认电平驱动第一步是申请GPIO并把两个引脚设成输出高电平。I2C总线的空闲状态要求SCL和SDA都保持高电平所以这个默认值不能省static int gpio_i2c_pin_init(struct gpio_i2c_dev *i2c) { int ret; ret gpio_request(i2c-scl_pin, gpio-i2c-scl); if (ret 0) return ret; ret gpio_request(i2c-sda_pin, gpio-i2c-sda); if (ret 0) { gpio_free(i2c-scl_pin); return ret; } gpio_direction_output(i2c-scl_pin, 1); gpio_direction_output(i2c-sda_pin, 1); return 0; }gpio_request先占住引脚避免别的驱动中途抢走。gpio_direction_output把方向设为输出同时把初始电平拉到1。如果省略这一步上电后引脚状态不确定第一次发START之前SDA就可能处于低电平总线上已有设备会把这种状态误判成起始条件。这里还有一个常见坑海思平台很多引脚默认被pinmux复用成UART、PWM之类的功能即使gpio_request返回成功引脚方向也可能不受GPIO子系统控制。遇到这种现象优先查设备树里有没有配置对应的pinctrl状态。2.3 时序原语SCL翻转与SDA置位bit-banging的根本操作只有两个拉高拉低SCL、拉高拉低SDA。源码把这两步包成内联函数统一加上半周期延时static inline void gpio_i2c_half_delay(struct gpio_i2c_dev *i2c) { udelay(i2c-half_cycle_us); } static inline void gpio_i2c_set_scl(struct gpio_i2c_dev *i2c, int v) { gpio_set_value(i2c-scl_pin, v); gpio_i2c_half_delay(i2c); } static inline void gpio_i2c_set_sda(struct gpio_i2c_dev *i2c, int v) { gpio_set_value(i2c-sda_pin, v); }这里半周期延时放在了SCL翻转之后是为了让从设备有足够时间在SCL高电平区间稳定采样。gpio_set_value是原子版本不睡眠可以安全地在中断上下文调用。需要注意的是它比直接操作寄存器多了一层API开销实测在400kHz频率下GPIO子系统内部的自旋锁和寄存器读写会占用相当一部分时间。源码里预留了一个寄存器直写宏开关如果后续要用高频时序可以绕开gpio_set_value直接写GPIO数据寄存器。2.4 START、STOP与一字节发送I2C协议里START和STOP最容易混淆核心在于SCL为高电平时SDA的变化方向。START是SCL高、SDA从1变0STOP是SCL高、SDA从0变1。顺序反了总线就挂了源码实现如下static void gpio_i2c_start(struct gpio_i2c_dev *i2c) { gpio_i2c_set_sda(i2c, 1); gpio_i2c_set_scl(i2c, 1); gpio_i2c_set_sda(i2c, 0); gpio_i2c_set_scl(i2c, 0); } static void gpio_i2c_stop(struct gpio_i2c_dev *i2c) { gpio_i2c_set_sda(i2c, 0); gpio_i2c_set_scl(i2c, 1); gpio_i2c_set_sda(i2c, 1); }发送一个字节的代码则分成8个数据位加1个ACK位来写。数据位从MSB开始在SCL低电平期间修改SDA拉高SCL让从机采样再拉低SCL进入下一位。第9个时钟周期主机释放SDA读从机的ACKstatic int gpio_i2c_send_byte(struct gpio_i2c_dev *i2c, u8 data) { int i, ack; for (i 7; i 0; i--) { gpio_i2c_set_sda(i2c, (data i) 1); gpio_i2c_set_scl(i2c, 1); gpio_i2c_set_scl(i2c, 0); } gpio_i2c_set_sda(i2c, 1); gpio_i2c_set_scl(i2c, 1); ack gpio_get_value(i2c-sda_pin); gpio_i2c_set_scl(i2c, 0); return ack; }返回值的含义是0表示从机应答ACK1表示无应答NACK。如果是在发从机地址这一步读到1基本可以断定总线上不存在这个地址的设备或者设备地址搞错了。调用方拿到非0返回值后通常会返回-ENODEV或-ENXIO。2.5 读取一字节与主机回ACK读操作和写操作相反主机要把SDA方向切回输入让从机驱动数据线。源码里单独封装了一个gpio_i2c_recv_bytestatic int gpio_i2c_recv_byte(struct gpio_i2c_dev *i2c, u8 *dst) { int i; u8 byte 0; for (i 7; i 0; i--) { gpio_i2c_set_scl(i2c, 1); byte (byte 1) | gpio_get_value(i2c-sda_pin); gpio_i2c_set_scl(i2c, 0); } *dst byte; return 0; }读的过程中SDA在SCL低电平时由从机准备好SCL拉高后主机采样。读完8位之后主机还需要在第9个时钟周期主动拉低SDA作为ACK告诉从机“继续发下一字节”。只有读到最后一个字节时回NACK从机才知道停止发送。这个“主机回ACK”的动作在方向切换频繁的场景下很容易翻车后面的排查章节会专门展开。3. 内核系统集成一份GPIO模拟时序如何变成i2c-5总线3.1 i2c_algorithm回调与master_xfer入口GPIO模拟的I2C要挂进Linux内核靠的是i2c_adapter和i2c_algorithm这对结构体。i2c_algorithm是适配器对外暴露的读写能力核心是master_xfer回调。用户空间或内核其他驱动调用i2c_transfer时i2c-core最终会走到这个函数static const struct i2c_algorithm gpio_i2c_algo { .master_xfer gpio_i2c_xfer, .functionality gpio_i2c_functionality, }; static int gpio_i2c_xfer(struct i2c_adapter *adap, struct i2c_msg *msgs, int num) { struct gpio_i2c_dev *i2c adap-algo_data; int ret 0, i; unsigned long flags; spin_lock_irqsave(i2c-lock, flags); for (i 0; i num; i) { if (msgs[i].flags I2C_M_RD) ret gpio_i2c_read_msg(i2c, msgs[i]); else ret gpio_i2c_write_msg(i2c, msgs[i]); if (ret 0) break; } gpio_i2c_stop(i2c); spin_unlock_irqrestore(i2c-lock, flags); return ret 0 ? ret : num; }i2c_transfer传入的msgs是一个数组代表一次完整事务里的多条消息。比如读EEPROM时第一条是写地址指针第二条才是读数据两条消息中间不能出现STOP而要用repeated START衔接。所以这个xfer函数有个关键设计循环结束时才统一发STOP每一条消息内部只各自发START这样第二次START在协议上就是重复起始条件。spin_lock_irqsave把整段时序保护住避免两个线程同时调用i2c_transfer导致时钟序列错乱。如果不在驱动层加这把锁两路并发请求会在总线上打出完全不可预测的波形。3.2 读消息与写消息的报文组织master_xfer拿到消息后最终还是交给底层时序函数处理。写消息的实现逻辑比较直观发送从机地址后逐字节写入static int gpio_i2c_write_msg(struct gpio_i2c_dev *i2c, struct i2c_msg *msg) { int ret, j; gpio_i2c_start(i2c); ret gpio_i2c_send_byte(i2c, (msg-addr 1) 0xFE); if (ret) return -ENODEV; for (j 0; j msg-len; j) { ret gpio_i2c_send_byte(i2c, msg-buf[j]); if (ret) return -EIO; } return 0; }从机地址左移一位最低位为0表示写操作。如果地址字节就返回NACK说明设备不在线没必要继续发数据。中间任何一个数据字节出现NACK返回-EIO上层一般会把这次传输视为失败。读消息的代码稍微复杂一点因为每读取一字节后要根据是否最后一字节决定回ACK还是NACKstatic int gpio_i2c_read_msg(struct gpio_i2c_dev *i2c, struct i2c_msg *msg) { int j; u8 byte; gpio_i2c_start(i2c); if (gpio_i2c_send_byte(i2c, ((msg-addr 1) | 1) 0xFF)) return -ENODEV; for (j 0; j msg-len; j) { if (gpio_i2c_recv_byte(i2c, byte)) return -EIO; msg-buf[j] byte; gpio_i2c_host_ack(i2c, j msg-len - 1); } return 0; }gpio_i2c_host_ack最后一个参数传1表示NACK传0表示ACK。逻辑就是“不是最后一个字节就拉低SDA回ACK最后一字节释放SDA回NACK”对应I2C协议里主收模式的结束规则。值得注意host_ack内部需要在输出和输入方向之间来回切换这直接影响时序后面避坑章节还会提到。3.3 i2c_adapter注册与设备树节点适配器注册部分源码用的是i2c_add_numbered_adapter而不是常见的i2c_add_adapter。区别在于前者指定总线编号比如下面例子里的bus_nr5系统里会稳定出现i2c-5后者是动态分配编号驱动加载顺序一变总线号就可能变。对业务层来说谁也不想看到i2c设备今天在i2c-3明天在i2c-5adap-algo gpio_i2c_algo; adap-algo_data priv; adap-dev.of_node pdev-dev.of_node; adap-nr priv-bus_nr; strlcpy(adap-name, gpio-i2c, sizeof(adap-name)); ret i2c_add_numbered_adapter(adap);配套设备树节点一般长这样gpio_i2c: gpio-i2c0 { compatible vendor,gpio-i2c; scl-gpios pio 14 4 0; sda-gpios pio 14 5 0; i2c-bus-nr 5; i2c-freq 100000; status okay; };scl-gpios和sda-gpios直接给到GPIO子系统probe里通过of_get_named_gpio解析出来。i2c-bus-nr指定总线号i2c-freq换算成half_cycle_us。这里再强调一次设备树里如果没有把引脚复用设置成GPIO模式后面驱动跑起来会非常诡异gpio_request成功、方向也设置了但引脚电平就是写不动。3.4 与硬件I2C控制器的差异对比开发中经常被问到“为什么要绕这么大一圈”。把差异列出来就清楚了对比维度硬件I2C控制器GPIO模拟I2CCPU开销写入FIFO后由硬件完成时序中断通知每翻转一次引脚都要CPU参与长传输占CPU频率精度分频器精确产生udelay只能保证最短延时实际频率偏差大并发能力硬件自己处理时钟拉伸、多主机仲裁依赖软件锁整个传输期间独占CPU调试难度看寄存器FIFO状态用逻辑分析仪抓GPIO波形直观但繁琐适用场景I2C外设多、频率要求高多出一条低速率总线、引脚复用冲突掌握这张表之后方案选型就能少走弯路。GPIO模拟适合用来接EEPROM、环境传感器这类低速设备不适合挂音频编解码器或需要不间断传输的从机。4. 编译与验证加载模块之后用i2cdetect扫出挂在GPIO上的芯片4.1 在海思SDK内核下独立编译这份源码可以直接放进内核树编但更推荐用Makefile独立编译成模块调试周期短、重启成本低。Makefile里把架构、编译器、内核目录都做成变量方便对接不同的SDKARCH ? arm CROSS_COMPILE ? arm-linux-gnueabi- KERNEL_DIR ? /opt/your-sdk/kernel obj-m gpio_i2c.o all: make ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) -C $(KERNEL_DIR) M$(PWD) modules clean: make ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) -C $(KERNEL_DIR) M$(PWD) clean海思各SDK自带的交叉编译器名称不一样有的是arm-hisiv500-linux-gnueabi-这类前缀具体以包内工具链为准。Makefile里用CROSS_COMPILE变量而不是写死换SDK时只需要export一下环境变量。如果源码拆成了多个.c文件obj-m这行就要改成类似gpio_i2c-objs : gpio_i2c_main.o gpio_i2c_bit.o的写法。编译前先确认内核头文件版本和加载模块的板子内核版本一致。版本不一致时insmod会直接报invalid module format这是新手最容易碰到的问题看起来像源码写错其实只是版本字符串对不上。4.2 加载顺序与常见错误码定位模块编译好之后按下面顺序做一次基本验证insmod gpio_i2c.ko scl_pin118 sda_pin119 bus_nr5 freq100000 dmesg | tail -20 ls /sys/bus/i2c/devices/ cat /sys/bus/i2c/devices/i2c-5/nameinsmod之后再dmesg如果看到gpio-i2c相关的probe成功日志并且/sys/bus/i2c/devices/i2c-5的name文件输出gpio-i2c说明适配器已经注册到内核。这里scl_pin、sda_pin是GPIO全局编号海思平台常规编码是bank号乘8加引脚序号。如果模块加载失败重点关注两类错误码返回-ENODEV一般是设备树compatible没有匹配正在加载的驱动返回-EBUSY基本是gpio_request失败说明引脚已经被占用或复用配置不对。4.3 i2c-tools验证整条链路适配器注册成功只是开始真正验证时序是否可信要看总线扫描结果。常用工具链是i2c-tools命令如下i2cdetect -l | grep gpio-i2c i2cdetect -y 5 i2cget -y 5 0x50 0x00 w i2cset -y 5 0x50 0x00 0x55i2cdetect -y 5会扫描整条总线打印出所有有应答的设备地址。如果总线拉着一个EEPROM会在0x50附近出现一个格子如果某个地址应显示但没显示优先怀疑ACK时序或从机地址看反了。i2cget加w参数会触发一次写地址加读数据的复合消息正好验证前面说的repeated START路径。i2cset写入之后立刻i2cget可能读出旧值因为EEPROM有内部写周期过几百毫秒再读就正常了这不是驱动Bug而是设备特性。第一次扫描时建议用逻辑分析仪或示波器同时挂上SCL和SDA如果发现波形高电平爬升很慢多半是上拉电阻太大或总线电容太多如果卡在某个位一直为低则多半是stop时序缺失。5. 常见问题与排查为什么照着源码改出来的总线总是读出0xFF5.1 踩坑一GPIO申请下来了引脚却被pinmux接管SCL电平写不动现象insmod成功i2cdetect长时间卡住或扫描结果全是空白用万用表量SCL引脚发现一直是低电平软件怎么拉高都没反应。原因海思平台的GPIO引脚通常对应多个复用功能寄存器默认值可能把引脚配成了UART、PWM或SPI。gpio_request只是在内核GPIO子系统中做了占用标记并不会主动解除SoC层面的pinmux复用。方向寄存器虽然写成了输出但最终输出通路还被复用模块占着。解决在设备树节点中显式配置pinctrl状态把两个引脚设为普通GPIO模式。不同SDK的写法有差异有的使用pinctrl-0配合pinmux属性有的需要调用板级初始化接口单独设置。做完这一步再加载驱动基本就能解决电平写不动的问题。我一般会先看SDK里硬件I2C控制器的dts是怎么写的照搬它的引脚配置方式。5.2 踩坑二读字节后主机回ACK时忘了切输出方向数据连续读错位现象读单个字节正常连续读多个字节时第二个字节开始数据就全乱了而且SDA波形在ACK阶段出现明显抖动。原因读字节过程中GPIO被设为输入方向由从机驱动SDA。读完之后主机要回ACK必须把SDA切成输出并拉低。如果驱动在recev_byte之后直接调用gpio_set_value而GPIO仍处于输入方向电平根本写不出去ACK没有真正拉低从机认为主机没有应答于是停止发送。解决在gpio_i2c_host_ack里先显式调用gpio_direction_output拉低SDA完成一个SCL周期后再切回输入等待下一字节。这里有一个性能隐患切换方向本身也是一次寄存器操作在400kHz下会占用不少时间所以源码提供了寄存器直写模式把方向切换的API调用换成直接操作GPIO_DIR寄存器。修改后要重点验证连续读16字节以上的场景单字节读正常不代表多字节读正常。5.3 踩坑三udelay时间在系统负载高时膨胀时序被拖垮现象单独调试时400kHz跑得好好的设备完整跑起来后I2C传输开始随机出现NACK有时还会触发从机的看门狗超时复位。原因Linux的udelay只能保证最短延时不少于指定值系统负载高、中断密集时实际延时可能明显大于预期。SCL高低电平被整体拉宽从机状态机如果对时钟间隔有严格要求就可能判定主机异常。尤其在双路摄像头同时出流的场景中断风暴会让GPIO翻转节奏完全不可控。解决不要按设备标称的最高频率来设置half_cycle_us而是留出余量。比如设备标称400kHz实际按300kHz甚至250kHz跑。另外排查上下拉电阻总线电容太大时波形上升沿慢也会让从机采到错误电平。频率参数的设置原则是“从机能受得住的最慢速度”而不是“协议支持的最快速度”。5.4 踩坑四传输函数里误用cansleep版本API内核直接警告非法上下文现象insmod成功第一次读写传感器时串口终端刷出sleeping function called from invalid context随后内核oops甚至整个系统卡死。原因实现者为了调用方便用了gpio_set_value_cansleep或gpio_direction_output这类可能睡眠的API。I2C的master_xfer在很多路径下是在进程上下文执行的但也可能被上层驱动在原子上下文中间接调用。一旦出现睡眠调用内核的上下文检查就会触发警告。解决时序相关的GPIO操作全部换成gpio_set_value和gpio_get_value这两个保证原子的版本如果要更进一步降低开销直接操作寄存器读写。代码里不能出现msleep或udelay以外的延时调用更不能在里面获取信号量。这条规则对任何模拟总线驱动都适用不只是I2C。6. 二次开发让固定引脚、固定频率的驱动变成一套可配置的软I2C工具6.1 用module_param把引脚和总线号变成参数常见的改造第一步是把源码里写死的GPIO编号改成模块参数。insmod时直接传参不同板子就不用重新编译static int scl_pin; static int sda_pin; static int bus_nr 5; static int freq 100000; module_param(scl_pin, int, 0644); module_param(sda_pin, int, 0644); module_param(bus_nr, int, 0644); module_param(freq, int, 0644);probe函数里判断参数是否显式传入传了就用参数值没传就回落设备树。这样一来调试板A时执行insmod gpio_i2c.ko scl_pin14 sda_pin15 bus_nr5换到板B时只需要改变参数。把freq参数换算成half_cycle_us时注意内核的计算取整400kHz理论半周期1.25us整数取2us再加上GPIO API自身开销实际频率可能明显低于标称值。我的习惯是先用逻辑分析仪测一次实际波形按测量结果把校准值写死而不是机械套用公式。6.2 扩展读效率与更多I2C场景源码目前的Xfer实现每次调用都会持锁一整段传输。如果要接大量传感器可以在上层做两处优化一是把零散的字节读写合并成多消息块传输减少总线起始停止次数二是在read路径上把连续读优化成一次事务里的多个读消息避免每条消息都重新建start。对于多主机共存的板子把spinlock升级为mutex并配合超时等待但要注意有些调用点可能已经在原子上下文。另外一个实用改法是把half_cycle_us和ACK重试次数都暴露成sysfs属性应用层可以根据不同外设动态调整速率。EEPROM写周期之后多等几毫秒这种逻辑放在应用层比放在驱动里更灵活。我早期接过一个海思项目GPIO编号在驱动里硬编码了三处板子改版后换了GPIO组全局替换时漏了一处调试了一个晚上才发现是地址那边的引脚没换。从那以后我每次拿到这类模拟时序驱动的源码第一件事就是把所有跟硬件相关的编号全部抽成module_param或dts属性跑通基线再谈业务优化。希望这份源码的注释和结构能帮你少踩我当时踩过的那些坑。本文还有配套的精品资源点击获取