简介:在海思平台开发中,如果硬件I2C控制器不足或为了节约资源,常常使用GPIO模拟I2C总线通信,这套驱动源码正好给出完整参考。压缩包很小,仅有4KB,里面包含三个文件:一个头文件、一个C语言源文件和一个Makefile;头文件定义设备结构体以及函数原型,源文件实现设备初始化、移除和核心的数据传输函数,Makefile负责内核模块编译配置。目前已有989人参与学习下载,适合嵌入式Linux驱动开发人员。整个传输函数的逻辑细致,覆盖了起始信号、停止信号、地址读写、数据收发与应答检测,帮助理解怎样通过高低电平变化模拟I2C时序;代码中对时序延迟和错误处理的安排,同样可以为海思平台系统调试或者其他外设驱动开发提供借鉴。
1. 为什么海思平台要用GPIO模拟I2C
1.1 硬件I2C不够用的真实场景
海思平台(Hi3516、Hi3536、Hi3559这些常见IPC/NVR主控)本身I2C控制器不算少,一般都有两到三个硬件I2C总线,但真到项目落地的时候,总会有那么几回被逼到墙角。我碰到过的情况大致是这么几类:一是项目改版时新加了一个传感器,比如光敏、温湿度或者音频编解码芯片,结果硬件上能用的I2C总线全被占满了,要么接在某个引脚上但该引脚已经被复用成其它功能;二是板子布线问题导致原有的I2C总线走线过长,信号质量差,从设备死活枚举不到,这时候临时拉两根GPIO出来做软件I2C是最快的解法;三是在某些定制板卡上,硬件设计本来就把I2C设备挂在了GPIO上,根本没有接专用的I2C控制器引脚。
这种时候,海思平台GPIO模拟I2C驱动就成了救命稻草。它的本质就是通过软件控制GPIO引脚的电平翻转,严格模拟I2C总线的时序协议,让任何挂在总线上的标准I2C从设备(EEPROM、温湿度传感器、IO扩展芯片、触摸屏控制器等等)感觉不到你用的是“假”I2C。对做嵌入式的朋友来说,这套东西不是“会不会”的问题,而是“迟早要会”的问题。
1.2 模拟I2C的优劣势分析与方案取舍
一定要先说清楚,GPIO模拟I2C不是用来替代硬件I2C控制器的,它是在特定条件下的补充方案。
先看优势。第一是引脚自由,不挑引脚,只要是有输入输出能力的GPIO都能用,不用受硬件I2C引脚复用关系的约束;第二是调试方便,协议栈就在自己手里,每一步都可以加打印,时序不对可以直接改代码,不像硬件控制器出问题时只能干瞪眼;第三是灵活度高,想模拟多少个I2C总线都行,只要GPIO够用;第四是兼容性好,如果从设备有时序上的特殊要求(比如某些器件对SCL上升沿有要求),可以随时调整软件延时。
再看劣势。模拟I2C最大的问题是时序稳定性不如硬件控制器,尤其是在Linux内核里做的时候,一个调度延时抖动可能就让SCL的周期偏移很大,碰到时序要求苛刻的从设备就头疼。其次,它要占用CPU资源,每次位翻转都是CPU亲自去做的,大量读写时会拉高CPU占用率。最后,模拟I2C很难做到高速模式,一般稳定跑100Kbit/s的标准模式或者400Kbit/s的快速模式就到头了,再往上就很容易出错。
所以在方案取舍上,我的原则是:能用硬件I2C绝不用模拟,只有在硬件资源受限、调试临时救火、或者对速度没要求且需要灵活调整时序的情况下,才用GPIO模拟I2C。这个定位要先想清楚,后面写代码的心态就完全不一样了。
2. 模拟I2C的核心思路与时序控制细节
2.1 先回顾I2C协议的关键时序
写模拟I2C之前,I2C协议本身必须吃透,不然代码写出来也是一堆bug。I2C总线只有两根线,一根是SCL(时钟线,由主机控制),一根是SDA(数据线,双向)。总线上所有设备通过地址来区分,通信由主机发起。
有几个关键时序节点必须烂熟于心。起始条件(START):SCL为高电平时,SDA由高变低。停止条件(STOP):SCL为高电平时,SDA由低变高。数据位的改变:SDA数据必须在SCL为低电平时改变,在SCL为高电平时保持稳定,这样从设备才能正确采样。应答信号(ACK):每传输完一个字节(8位)后,第9个时钟周期释放SDA,由接收方拉低表示应答,如果接收方不拉低则说明无应答(NACK)。
GPIO模拟I2C的精髓就是把这几个时序用代码精确还原出来。实际写代码时,起始条件就是“先把SDA拉高,SCL拉高,再拉低SDA,最后拉低SCL”;停止条件反过来,SCL高时SDA从低变高。看起来简单,但这些操作之间的延时直接决定了通信速率和稳定性。
2.2 海思平台GPIO的方向配置和电平操作
在海思平台上操作GPIO,不同SDK版本、不同内核版本的接口差异很大。有些平台用gpiolib的标准接口,有些则用SDK自带的寄存器操作接口,还有的板子干脆是通过ioctl直接操作/dev/mem。这一步很关键,因为模拟I2C对时序有要求,接口的调用开销越小越好。
如果是在内核态做,最直接的当然是操作寄存器。海思的GPIO寄存器一般按bank组织,每个bank有数据寄存器、方向寄存器、中断寄存器等。拿常见的Hi3516系列来说,GPIO基址加上偏移就能直接读写引脚电平。但如果你用的是SDK封装好的GPIO操作函数,也务必确认这些函数在调用时不会产生太多额外开销,否则每个bit翻转都去走一遍spinlock加函数调用,时序会稀烂。
GPIO的8种工作模式这里要提一下。很多读者对“工作模式”理解成输入、输出两种,其实不然,完整的概念一般包含上拉(pull-up)、下拉(pull-down)、推挽输出(push-pull)、开漏输出(open-drain)、模拟输入、浮空输入等模式组合。模拟I2C设计里最理想的做法是把SDA引脚配成开漏输出(如果硬件支持),因为开漏输出的引脚可以主动拉低,释放后靠外部上拉电阻恢复高电平,这样天然符合I2C总线“线与”的特性。但海思很多GPIO不支持开漏模式,那就只能在代码里手动切换SDA的方向了。具体的做法是:写数据时把SDA配成输出,拉低或拉高;读ACK或读数据时把SDA切回输入模式,然后读取引脚电平。
另外一个容易踩坑的点是引脚上下拉电阻的选择。模拟I2C的SDA、SCL外部必须接上拉电阻,一般4.7K到10K比较合适。如果板子上本来没画上拉电阻,软件再怎么做都是白搭,可能偶尔能通信但极不稳定。
3. 海思平台GPIO模拟I2C驱动源码解析
3.1 引脚复用与初始化配置
在海思平台上用GPIO模拟I2C,第一件事就是把引脚从复用功能切到GPIO功能。这个步骤如果漏了,后面所有代码都是白跑。海思的引脚复用通常通过配置寄存器来实现,比如在Hi3516EV200上,要用某个引脚作为GPIO,需要先看它是否默认被复用为其他功能,如果是就要修改复用寄存器。
下面是一个内核态模拟I2C驱动的初始化代码骨架:
#include <linux/module.h> #include <linux/kernel.h> #include <linux/init.h> #include <linux/delay.h> #include <linux/gpio.h> #include <linux/of_gpio.h> #include <linux/platform_device.h> #define GPIO_I2C_SCL 158 #define GPIO_I2C_SDA 159 static void gpio_i2c_init_pins(void) { /* 海思平台一般先在 DSP/系统寄存器里把引脚配成GPIO模式, 再用gpio_request申请占用,最后设置方向和初始电平 */ gpio_request(GPIO_I2C_SCL, "i2c_scl"); gpio_request(GPIO_I2C_SDA, "i2c_sda"); gpio_direction_output(GPIO_I2C_SCL, 1); gpio_direction_output(GPIO_I2C_SDA, 1); }这里注意,gpio_request之前最好确认引脚有没有被其他驱动占用,否则会返回-EBUSY。另外,海思有些SDK版本的GPIO编号和原理图上标的GPIO组号不是直接对应的,需要看清楚内核里的GPIO base映射关系。我曾经在Hi3536上遇到过明明在dts里把引脚功能配好了,但gpio_request一直失败的情况,最后发现是编号差了个base偏移,白排查了半天。
3.2 时序转换函数与延时控制
GPIO模拟I2C的代码核心就两个字:延时。总线速率靠延时控制,时序稳定也靠延时控制。标准I2C模式下SCL周期大概是10us,高电平和低电平各5us;快速模式400Kbit/s下SCL周期2.5us,高低电平各1.25us左右。但在海思嵌入式平台上,软件延时函数本身的精度要实测,因为内核里的ndelay、udelay在CPU频率、Cache状态不同的情况下表现会不一样。
我常用的做法是先写一个带参数的延时控制函数,调试时通过示波器看SCL波形再微调次数:
static void gpio_i2c_delay(unsigned int cycles) { volatile unsigned int i; for (i = 0; i < cycles; i++) { /* 空循环 */ } }cycles的初值根据CPU主频估算,一个空循环大概几个周期。比如主频1GHz时,目标半周期5us需要大约5000个周期,但如果内核开了编译器优化,空循环可能被优化掉,所以volatile不能省。更好的方案是直接用udelay(5)这种内核提供的忙等待接口,但在内核态频繁调用udelay其实开销也不小。我实际用的比较多的是折中方案:用ktime接口做精确延时,或者干脆在宏里用udelay先跑通,再根据示波器实测换成自旋延时。
3.3 起始停止与字节读写实现
下面这段是模拟I2C的核心时序代码,包含了启动、停止、读写一个字节、ACK检查。这些函数在内核态或应用态都可以用,只需要替换底层的GPIO操作接口。
static void gpio_i2c_scl_high(void) { gpio_direction_output(GPIO_I2C_SCL, 1); } static void gpio_i2c_scl_low(void) { gpio_direction_output(GPIO_I2C_SCL, 0); } static void gpio_i2c_sda_high(void) { gpio_direction_output(GPIO_I2C_SDA, 1); } static void gpio_i2c_sda_low(void) { gpio_direction_output(GPIO_I2C_SDA, 0); } static int gpio_i2c_sda_read(void) { gpio_direction_input(GPIO_I2C_SDA); return gpio_get_value(GPIO_I2C_SDA); } static void gpio_i2c_start(void) { gpio_i2c_sda_high(); gpio_i2c_scl_high(); gpio_i2c_delay(50); gpio_i2c_sda_low(); gpio_i2c_delay(50); gpio_i2c_scl_low(); gpio_i2c_delay(50); } static void gpio_i2c_stop(void) { gpio_i2c_sda_low(); gpio_i2c_scl_high(); gpio_i2c_delay(50); gpio_i2c_sda_high(); gpio_i2c_delay(50); } static int gpio_i2c_write_byte(unsigned char data) { int i; int ack; for (i = 7; i >= 0; i--) { if (data & (1 << i)) { gpio_i2c_sda_high(); } else { gpio_i2c_sda_low(); } gpio_i2c_delay(30); gpio_i2c_scl_high(); gpio_i2c_delay(50); gpio_i2c_scl_low(); gpio_i2c_delay(30); } /* 释放SDA准备读ACK */ gpio_i2c_sda_high(); gpio_i2c_delay(30); gpio_i2c_scl_high(); gpio_i2c_delay(50); ack = gpio_i2c_sda_read(); gpio_i2c_scl_low(); gpio_i2c_delay(30); return (ack == 0) ? 0 : -1; } static unsigned char gpio_i2c_read_byte(void) { int i; unsigned char data = 0; gpio_i2c_sda_high(); gpio_direction_input(GPIO_I2C_SDA); for (i = 0; i < 8; i++) { gpio_i2c_delay(30); gpio_i2c_scl_high(); gpio_i2c_delay(50); data <<= 1; if (gpio_i2c_sda_read()) { data |= 0x01; } gpio_i2c_scl_low(); gpio_i2c_delay(30); } gpio_direction_output(GPIO_I2C_SDA, 1); return data; }要注意的是,gpio_i2c_sda_read()函数里先做了gpio_direction_input,目的是在读取前把SDA切换到输入模式。海思GPIO如果输出模式下直接gpio_get_value,有些平台读到的其实是输出寄存器的值而不是引脚实际电平,这一点很多人都栽过。读字节的函数在每次读之前都切回输入,读完一位后没有切回去,而是在整个字节读完后切回输出,这个细节可以省掉很多重复的方向切换,提高效率。
3.4 多字节读写封装
给从设备传数据不可能是单字节的,一般是“设备地址+寄存器地址+数据”的组合。所以还需要封装一个复合读写函数。以EEPROM的页写为例,包括起始条件、发送7位地址和写标志、发送寄存器地址、发送一个或多个数据字节、停止条件。整个过程只需要按顺序调用前面那些底层函数就行:
int gpio_i2c_write_reg(unsigned char dev_addr, unsigned char reg_addr, unsigned char *buf, int len) { int i; int ret; gpio_i2c_start(); /* 发送设备地址+写位 */ ret = gpio_i2c_write_byte((dev_addr << 1) | 0); if (ret != 0) { gpio_i2c_stop(); return -1; } /* 发送寄存器地址 */ ret = gpio_i2c_write_byte(reg_addr); if (ret != 0) { gpio_i2c_stop(); return -1; } /* 写数据 */ for (i = 0; i < len; i++) { ret = gpio_i2c_write_byte(buf[i]); if (ret != 0) { gpio_i2c_stop(); return -1; } } gpio_i2c_stop(); return 0; }读EEPROM的流程稍微绕一点,需要先写寄存器地址,然后重新发一遍起始条件(即重复起始条件,repeated START),再发送设备地址加读位,然后连续读数据,最后一个字节要发NACK表示读完了,最后停止。这个重复起始条件的实现,直接在start之后重新写地址就行,但要注意SCL/SDA的状态要正确,不然从设备不会认。
4. 实际调试问题与排查经验
4.1 SCL频率异常和ACK读不到
我在海思平台调模拟I2C,遇到最多的就是ACK失败。表面上看起始条件、时钟都正常,但写完设备地址后读ACK永远是高电平。这种情况先排查两件事:一是从设备地址有没有写错。7位地址加上读写位之后要左移一位,很多新手在地址拼接上翻车。比如设备地址是0x50(AT24C02的典型地址),发送时要发0xA0(即0x50<<1|0),如果直接发0x50,从设备根本不响应,ACK自然是NACK。
第二件事是查SDA方向切换的时机。我发现很多人写模拟I2C时,写完最后一个数据位后直接就去读SDA,但此时SDA还处于输出模式,而且上一次设置的电平是1,引脚被强拉高,从设备想拉低也拉不动,这是典型的“总线争抢”问题。正确做法是第8个时钟下降沿后先把SDA切到输入模式,再拉高SCL,在第9个时钟的高电平期间读取SDA。这个顺序错一步,ACK读取就会失败,而且偶尔成功偶尔失败的现象特别容易让人怀疑是硬件问题。
SCL频率异常的问题更隐蔽。如果示波器上看到SCL波形不是方波,上升沿很缓,那多半是上拉电阻太大加上引脚电容太大导致的。比如用了100K欧姆的大阻值上拉,SCL上升时间会明显变长,超过I2C协议标准的1us之后,从设备可能就采样出错了。解决方法是换小阻值上拉电阻,或者降低总线速率,把延时时序整体拉长。
4.2 中断和系统调度对时序的干扰
内核态模拟I2C还有个大敌是中断和进程调度。SCL正在高电平期间,来了一个中断,ISR在里面跑100us,那SCL高电平就被硬生生拉长了,整个I2C时序的占空比就乱了。有的从设备可以容忍这种偏差,有的不行,尤其是时序带检测功能的器件,会直接判定为非法通信,总线就挂在那里。
这个问题的解决方法主要有几种。第一,在做关键时序操作时关闭中断或者屏蔽本地中断,但这会造成系统实时性下降,能用但别多用。第二,把驱动挂在实时线程里,每个I2C操作是一个独立任务,优先级调高,减少被其他任务抢占的概率。第三,降低I2C速率到标准模式100K或以下,让时序裕量变大,系统调度带来的抖动就不容易导致超限。
如果是在应用层用/dev/mem操作GPIO做模拟I2C,系统调度的干扰会更大,因为用户态的线程随时可能被换出CPU。一个改善办法是用pthread_setschedparam给线程设置实时优先级配合mlockall锁定内存,减少被换出的概率。但说实话,应用层做模拟I2C我通常只用来调试,真要做生产用的驱动,还是要进内核态做,或者使用海思平台自带的MISC驱动框架来挂一个字符设备,把I2C逻辑放在内核里。
4.3 总线死锁与多设备挂载问题
GPIO模拟I2C还有一个比较头疼的故障模式是总线死锁。当从设备在某个时间点等待主机继续发时钟,而主机因为流程错误提前停了SCL,从设备会一直把SDA拉低,导致整个总线处于“锁死”状态。这时候最直接的解决办法是:连续给SCL发9个时钟脉冲来复位从设备状态机。这个技巧非常管用,我写的模拟I2C驱动里一般都会内置一个gpio_i2c_recover函数,在通信失败时自动触发总线恢复。
多设备挂载时还有一个细节要注意。如果两个从设备的地址相同,比如两块相同型号的EEPROM挂在同一条模拟I2C总线上,就需要通过它们的地址引脚(A0/A1/A2)来区分地址。AT24C02这类EEPROM通常有3个地址引脚,设计时可以通过上下拉让它们产生8个不同地址。如果硬件没有复用到地址引脚,那软件怎么调也调不出来两片一样的设备,这也是排查时要提前想到的。
4.4 电平匹配和上拉电阻选择
海思主控的IO电平通常是1.8V或者3.3V,这取决于核心板的设计。如果I2C从设备的工作电平和主控不一致,比如有个外设是5V供电,那就必须在总线上加电平转换芯片,绝对不能直接连。GPIO模拟I2C也一样,SCL/SDA直接接过去,轻则读不到数据,重则烧毁引脚。
上拉电阻的取值经验上这么选:3.3V系统用4.7K到10K都行,1.8V系统建议用2.2K到4.7K,具体看总线挂载的设备数量和走线长度。总线设备越多、走线越长,上拉电阻就要越小,以保证足够的驱动能力。但上拉电阻也不能太小,否则低电平下拉电流太大,GPIO可能拉不低,SCL/SDA低电平状态就不对了。一个简单的验证方法是:设备通信时用示波器量SCL低电平,确保低于设备VIL阈值,一般是0.3倍VDD左右。
最后的一点个人经验
项目里这套GPIO模拟I2C驱动前前后后调了一个多月,踩过的坑比写过的代码还多。我自己的心得就是:调模拟I2C,别上来就抱着示波器猛看波形,先把代码逻辑理清楚,从起始条件到ACK,一个一个时序去核对,把“什么时候切方向”“什么时候拉电平”这些顺序确定得明明白白,再上板调试,事半功倍。真遇到波形不对的,再拿示波器和逻辑分析仪看也不晚。
另外,如果在海思平台上做长期稳定的项目,我的建议还是优先把设备挂到硬件I2C总线上,GPIO模拟I2C用作备胎方案或者临时救急就好。毕竟控制器在稳定性、功耗、传输速率上确实有天然优势。但无论如何,掌握GPIO模拟I2C这套手法,在做嵌入式开发的过程中绝对是值得的——它逼着你把I2C协议吃透,也让你在遇到硬件资源紧张时多一条出路。
本文还有配套的精品资源,点击获取