海思平台GPIO模拟I2C实战:从时序原理到Linux驱动实现
2026/9/7 13:23:54 网站建设 项目流程

简介:海思平台GPIO模拟I2C驱动源码是一份针对海思Linux平台的参考实现,面向需要在无硬件I2C控制器场景下通过GPIO实现可靠通信的嵌入式开发人员。压缩包共3个文件,包含c源文件、h头文件和Makefile构建脚本,整体仅4KB,结构精简。c文件实现核心传输逻辑与时序控制,h文件定义gpio_i2c_device结构及接口,Makefile用于编译成内核模块。已有989人学习,是一份适合快速上手的驱动样例。源码清晰展示了probe、remove、xfer等关键函数,通过控制SDA/SCL引脚电平完成起始信号、地址读写、应答检测和停止信号,同时覆盖GPIO资源申请、释放以及超时错误处理。阅读这份代码,可加深对I2C协议时序和Linux驱动模型的理解,为后续驱动调试或移植提供直接参考。

1. 说清楚:海思平台为什么要用GPIO去模拟I2C

做海思平台方案的人,基本都遇到过这种尴尬:芯片原生的I2C控制器明明摆在芯片手册里,可真到画板、写驱动的时候发现,不是pinmux被其他功能复用掉了,就是硬件设计时压根没把I2C引脚引到合适的位置。海思的IPC芯片,比如Hi3516、Hi3536这些,I2C控制器一般也就两三路,Sensor一路、EEPROM一路、音频codec一路,基本就占满了。等你想再挂一个光照传感器BH1750或者IO扩展芯片,翻遍原理图也找不到空闲的I2C口。

这种时候,GPIO模拟I2C就成了最直接的解法。原理不复杂:I2C总线本身就是一个两线协议,SCL和SDA,通过软件控制GPIO引脚的电平变化、按协议时序去模拟通信,完全可以替代硬件I2C控制器。只要芯片有够用的GPIO,就能在任意引脚上开出一路“软I2C”来,不用改板子、不用重新流片,纯软件就能解决外设接入问题。

这篇文章适合正在海思SDK上做驱动开发、手里有块板子但I2C口不够用的工程师,也适合刚接触Linux内核驱动、想知道怎么把I2C协议用GPIO一点点敲出来的朋友。我会直接给出可参考的源码结构、关键代码片段和我在实际板子上踩过的坑。这个方案放在其他Linux嵌入式平台上也适用,只要把GPIO操作接口替换成对应平台的API就行。

2. 搞懂时序,比抄代码重要得多

2.1 I2C协议里的几个关键动作

不管你用寄存器操作还是gpiolib,模拟I2C的核心都在时序上。I2C通信里最基础的就是四个动作:起始信号、停止信号、发送一个字节、接收一个字节(外加应答位)。用大白话说:

  • 起始信号(START):SCL保持高电平,SDA从高电平跳变到低电平。
  • 停止信号(STOP):SCL保持高电平,SDA从低电平跳变到高电平。
  • 发送数据位:SCL低电平时把SDA设置成要发送的bit,然后在SCL高电平时让对方采样。每一位都是这么送上总线的。
  • 接收数据位:SCL低电平时释放SDA,改为输入方向,然后拉高SCL,在SCL高电平期间读SDA的电平。

你可以把SCL理解成一个节拍器,总线上的双方都在SCL上升沿附近读取SDA的状态。数据线什么时候变、什么时候稳住,都有规定,不能乱来。

2.2 时序参数怎么选

这里有个实际操作中特别容易被忽略的点:时序参数不是随便定的。I2C协议定义了标准模式(100Kbps)、快速模式(400Kbps)和高速模式(3.4Mbps)。GPIO模拟一般跑标准模式就够了,追求速度的话也可以跑到快速模式,但需要考虑GPIO翻转效率、系统调度带来的延迟抖动。

我实际使用时,标准模式就把SCL高电平和低电平各设成5us,也就是udelay(5),加上GPIO操作本身的开销,实际速率会在90Kbps左右,协议上完全满足。想跑400K的话,各个电平延迟需要压到1.3us左右,内核里要用ndelay或者忙等待来实现,而且中断影响就会更明显,建议一开始别急着提速度,先把功能调通。

提示:模拟I2C的延迟参数宁大勿小,速率慢一点不影响功能,时序不合规才是真正要命的。

2.3 时序原语:把协议翻译成函数

要把时序变成代码,先抽象出一组最基础的原语操作,后面的协议层就全部基于这些原语:

  • scl_high()/scl_low():控制SCL引脚高低
  • sda_high()/sda_low():控制SDA引脚高低
  • sda_input()/sda_output():切换SDA方向
  • sda_read():读SDA电平

在这些原语之上,再实现i2c_start()i2c_stop()i2c_send_byte()i2c_recv_byte()这些协议动作。有了这层封装,上面跑什么具体设备就不重要了,不管是AT24C02还是BH1750,最后都是先发设备地址,再发寄存器地址,然后读写数据。

3. 海思平台GPIO实操:从寄存器到引脚

3.1 海思GPIO的编号逻辑

海思平台的GPIO跟大多数ARM平台的GPIO控制器类似,分成若干个Bank,每个Bank管理一组引脚。以常见的Hi3516系列为例,GPIO编号一般写作GPIOx_y,x是组号,y是组内序号。换算成实际寄存器操作时,需要知道三样东西:基地址、组内偏移、以及每组的引脚个数(通常一组有8个引脚)。

有个容易搞混的点:驱动里申请GPIO用的编号,跟硬件寄存器里的bit位不是同一个东西。比如设备树里写gpio = <&gpio3 4 GPIO_ACTIVE_HIGH>,意思是用第3组的第4个引脚,对应到寄存器操作时,是base + 3 * 每组寄存器步长这个bank,操作的是第4个bit。

3.2 一个完整的软I2C驱动源码

下面给一份可以直接参考的源码框架,我在海思平台的内核模块里验证过。这份代码的工程意义在于:它把时序原语和协议层分开,换平台时只需要改底层的GPIO控制函数

#include <linux/module.h> #include <linux/init.h> #include <linux/kernel.h> #include <linux/delay.h> #include <linux/gpio.h> #include <linux/of_gpio.h> #include <linux/platform_device.h> #define I2C_SCL_GPIO 134 /* 按实际板子修改 */ #define I2C_SDA_GPIO 135 #define I2C_DELAY_US 5 static void scl_high(void) { gpio_set_value(I2C_SCL_GPIO, 1); } static void scl_low(void) { gpio_set_value(I2C_SCL_GPIO, 0); } static void sda_high(void) { gpio_set_value(I2C_SDA_GPIO, 1); } static void sda_low(void) { gpio_set_value(I2C_SDA_GPIO, 0); } static void sda_dir_input(void) { gpio_direction_input(I2C_SDA_GPIO); } static void sda_dir_output(void) { gpio_direction_output(I2C_SDA_GPIO, 1); } static int sda_read(void) { return gpio_get_value(I2C_SDA_GPIO); } static void i2c_delay(void) { udelay(I2C_DELAY_US); } static void i2c_start(void) { sda_dir_output(); sda_high(); scl_high(); i2c_delay(); sda_low(); i2c_delay(); scl_low(); } static void i2c_stop(void) { sda_dir_output(); sda_low(); scl_high(); i2c_delay(); sda_high(); i2c_delay(); } static int i2c_send_byte(unsigned char data) { int i; sda_dir_output(); for (i = 7; i >= 0; i--) { if (data & (1 << i)) sda_high(); else sda_low(); i2c_delay(); scl_high(); i2c_delay(); scl_low(); } /* 接收ACK: 主机释放SDA,从机拉低表示应答 */ sda_dir_input(); scl_high(); i2c_delay(); if (sda_read() != 0) { scl_low(); return -1; } scl_low(); return 0; } static unsigned char i2c_recv_byte(void) { int i; unsigned char data = 0; sda_dir_input(); for (i = 7; i >= 0; i--) { scl_high(); i2c_delay(); if (sda_read()) data |= (1 << i); scl_low(); i2c_delay(); } return data; } static void i2c_send_ack(void) { sda_dir_output(); sda_low(); scl_high(); i2c_delay(); scl_low(); sda_high(); } static void i2c_send_nack(void) { sda_dir_output(); sda_high(); scl_high(); i2c_delay(); scl_low(); sda_high(); }

这套代码重点在两个地方:一是i2c_send_byte里发送完8个bit后,必须把SDA切回输入模式去读从机的应答,这一步漏了,总线就会被一直拉低;二是i2c_recv_byte里,主机处于接收状态时SDA要全程保持输入方向,直到最后发送ACK/NACK时才切回输出。

3.3 初始化与资源释放

模块加载时,需要向内核申请GPIO号,并把SCL、SDA都初始化为高电平状态。这里有个细节:SCL和SDA的初始电平都应该是高,这是I2C总线空闲态的要求。如果初始化时SDA被拉低,后续的START信号就没法被正确识别了。

static int i2c_gpio_init(void) { int ret; ret = gpio_request(I2C_SCL_GPIO, "soft-i2c-scl"); if (ret) return ret; ret = gpio_request(I2C_SDA_GPIO, "soft-i2c-sda"); if (ret) { gpio_free(I2C_SCL_GPIO); return ret; } gpio_direction_output(I2C_SCL_GPIO, 1); gpio_direction_output(I2C_SDA_GPIO, 1); return 0; }

我习惯把GPIO申请放在驱动的probe里,如果申请失败就返回-EBUSY,这样启动日志里能第一时间看到资源冲突的问题。另外,模块卸载时记得把GPIO释放掉,不然下次加载会报GPIO已经被占用。

3.4 挂一个真实设备:读写EEPROM

有了底层协议函数,就可以封装出设备级的读写函数了。下面是用上面这套软I2C去读写AT24C02 EEPROM的示例。AT24C02的设备地址是0xA0(7位地址为0x50),写一个字节的流程是:发START,发设备地址+写位,发寄存器地址,发数据,发STOP。

static int eeprom_write_byte(unsigned char addr, unsigned char data) { i2c_start(); if (i2c_send_byte(0xA0)) { /* 设备地址 + 写位 */ i2c_stop(); return -1; } if (i2c_send_byte(addr)) { i2c_stop(); return -1; } if (i2c_send_byte(data)) { i2c_stop(); return -1; } i2c_stop(); return 0; } static int eeprom_read_byte(unsigned char addr, unsigned char *data) { i2c_start(); if (i2c_send_byte(0xA0)) { i2c_stop(); return -1; } if (i2c_send_byte(addr)) { i2c_stop(); return -1; } /* 重复起始信号,切换为读方向 */ i2c_start(); if (i2c_send_byte(0xA1)) { /* 设备地址 + 读位 */ i2c_stop(); return -1; } *data = i2c_recv_byte(); i2c_send_nack(); i2c_stop(); return 0; }

注意这里读EEPROM时用了重复起始信号(Repeated START),这是I2C协议里一个非常常用但新手容易忽略的特性——在同一个传输事务内,主机不需要先STOP再重新START,而是可以连续发两次START来切换读写方向。很多从设备支持这种操作,写驱动时要注意区分。

4. 驱动加载后的验证与调试方法

4.1 先用逻辑分析仪或示波器确认时序

我调试这套驱动时第一个动作不是直接挂设备,而是先看波形。把逻辑分析仪的通道夹在SCL和SDA上,单独调用一次i2c_start()i2c_stop(),看起始和停止条件是否符合协议标准。这里最容易发现的问题就是电平变化太快或者太慢,或者SDA在SCL高电平时发生了跳变——这在线缆较长的板子上尤其容易出现。

4.2 用通用工具扫描设备地址

如果系统里有i2c-dev模块,可以把模拟的I2C挂到Linux的i2c子系统中去,然后用i2cdetect直接扫描。这样不用自己写设备驱动也能验证总线是否工作。在设备树里把模拟I2C注册成一个i2c适配器,然后执行:

i2cdetect -y 0

如果扫描结果里有地址显示为50(AT24C02)或23(BH1750),说明软I2C已经能正常和从设备通信了。如果扫描出来全是--,而且i2cdetect卡住不动、日志没有任何报错,基本就是时序的问题,先用示波器定位。

4.3 遇到“没反应”先别怀疑源码

很多朋友用这套源码扫描设备时,第一反应是代码有bug,但根据我的经验,最常见的三个原因反而是外部的:一是两个GPIO上没接上拉电阻,I2C的SDA和SCL都是开漏结构,必须要有上拉才能正确输出高电平;二是GPIO编号选错了,驱动操作的引脚跟实际板子上连的引脚不是同一个;三是从设备地址搞错了,7位地址和8位地址傻傻分不清,比如BH1750的地址是0x23(7位),发到总线上的字节却是0x46(左移一位后的结果),这个细节不知道坑了多少人。

5. 常见问题与排查技巧实录

为了让你遇到问题时能快速定位,我把实际调试中高频遇到的问题整理成了一张表,每个都是踩过的真坑:

现象可能原因排查思路
SDA一直被拉低,START后就卡死SDA方向没切回输出,或从机没有释放总线先量SDA电平,确认是主机拉低还是从机拉低
扫描设备全是--,没任何反应GPIO上拉缺失、延时太短、地址错误示波器看两个引脚波形,确认驱动有波形输出
能检测到设备,但读写数据错乱时序不满足从设备要求,或SCL高电平期间SDA跳变增大I2C_DELAY_US,检查GPIO翻转函数是否夹带额外操作
驱动加载时报GPIO busy其他驱动已经申请了同样的GPIO编号查设备树,确认引脚没有被复用
偶发性读写失败,时好时坏系统调度导致时序间隙拉得太长,触发了从设备超时在模拟I2C的传输过程中关中断,或增加失败重试机制

还有一个必须单独拿出来说的点:GPIO模拟I2C没有一个统一的错误恢复机制。硬件I2C控制器通常有总线超时、仲裁丢失等错误状态寄存器,软件模拟的话,这些全都要自己处理。我在驱动里加了一个简单的保护逻辑:每次操作之前把SCL和SDA都拉高,延时一下,确保总线回到空闲状态再开始传输。这相当于一个半自动的总线复位,能减少很多因为上一次传输异常导致的历史状态残留。

注意:如果模拟I2C挂载的是需要长时间稳定通信的设备,比如某些传感器在启动初始化时要连续读取几十个寄存器,建议在传输期间用local_irq_save保护一下,防止被中断抢走CPU后时序被拉太长。代价是系统的实时性会受影响,但通常这种传输都是微秒级的,问题不大。

我个人的处理习惯是:在i2c_start()之前加一个mdelay(1)的复位延时,在i2c_stop()之后再留一个udelay(50)的释放时间。不要小看这几十微秒,它对整个传输稳定性的提升非常明显。尤其在海思这种跑着完整Linux系统的平台上,内核线程的调度随时可能打断你的时序,多给自己留点余量,比什么都强。

如果后续你有更多外设要接,可以把这层软I2C封装成标准的i2c_adapter注册进内核I2C子系统,这样上层所有建议的i2c-dev工具和内核i2c驱动都能直接复用。这个过程等于写一个i2c bit-bang适配器,本质上就是给内核提供master_xfer回调函数,跟本文提到的时序原语完全对应,换汤不换药。有需要的话,下次我可以把完整的内核适配器实现也整理出来。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询