做I2C通讯,DSP这块我最早也走过一段弯路,图省事直接用软件模拟时序,眉毛胡子一把抓,效果只能说勉强能用。后来被折腾够了,才老老实实把DSP自带的硬件I2C外设配置玩明白。这篇就把我这次在DSP上用硬件I2C外设做通讯的完整过程写出来,重点聊聊如何配置I2C外设、初始化时序、读写EEPROM这类从机,以及调试中踩过的坑。标题里说“非IO口软件模拟时序”,这句话不是矫情,是真的能少掉很多麻烦,后面我详细拆给你看。
这篇内容适合正在调DSP驱动传感器、EEPROM、RTC、音频解码器之类I2C从机设备的同学,尤其是刚从单片机转过来、或者之前一直用GPIO口软件模拟、现在想切到硬件I2C外设的人。全文以TI C2000系列(F28335)为例,思路同样适用于其他带I2C模块的DSP型号。
1. 为什么我坚持用硬件I2C外设,而不是IO口软件模拟时序
1.1 软件模拟时序的隐性成本
GPIO软件模拟I2C的思路很直接:把SCL和SDA配成普通IO,按协议要求手动翻转电平,起始条件、停止条件、每个bit的电平保持时间、从机ACK采样,全部用延时函数去卡。这种方案看起来简单,因为你不需要看芯片参考手册里那一堆I2C寄存器,只要会写延时,就能把逻辑跑通。我之前在低负载场景下也确实这么干过,当时只接了一个温度传感器,慢速轮询,CPU任务不重,没出大问题。
但一旦工程复杂起来,软件模拟的隐形成本就全暴露出来了。首先是CPU占用率,每一位都要实时翻转IO,还要精确延时,从机应答时还得切换到输入模式去采样,一个字节8个bit加上ACK,算下来一次简单读写要占几十上百微秒,这期间如果来了中断,时序就拉长,甚至直接破坏通讯时序。其次是可维护性,换一个主频不同的芯片,或者从100kHz改成400kHz,所有延时参数都得重新标定,一旦遇到从机时钟延展,软件里还要额外判断,代码越写越枝繁叶茂,越枝繁叶茂越容易出bug。
再一个就是电平配合问题。I2C是开漏结构,SCL和SDA外部要加上拉电阻,GPIO模拟时如果引脚配置成推挽输出,一旦主从双方电平冲突,就容易造成灌电流过大,严重时可能损伤引脚。虽然可以配成开漏模式,但很多MCU/DSP的普通GPIO开漏能力并不强,加上电平转换、总线电容这些因素,波形完整度很难保证。
1.2 硬件外设真正解决的问题
DSP芯片自带的I2C外设,本质上是一个独立的状态机,负责产生START、STOP、ACK、重复起始这些时序细节,CPU只需要往数据寄存器里放数据、读数据、处理状态标志就行。像F28335的I2C模块,SCL和SDA的翻转、每bit的保持时间、从机应答的采样窗口,全部由硬件自动完成,和CPU主频、中断响应延迟基本解耦。这意味着时序稳定性好很多,跑400kHz时波形依然干净,不会因为中断嵌套导致边沿抖动。
硬件I2C外设对于多字节读写特别有用。用GPIO模拟时,读一个EEPROM的连续区域,要在循环里一个个字节地翻转时序,代码写得心累;用硬件外设时,你只需要设置好要传输的字节数,然后往TX数据寄存器里填充数据,硬件会自动把起始条件、地址、数据、应答、停止条件全部按顺序发出去,CPU在传输期间还能去处理别的任务,配合中断或者DMA,效率完全是另一回事。
当然硬件I2C外设也有自己的代价,就是学习曲线比GPIO模拟要多一层寄存器理解成本。但这层成本是一次性的,一旦把外设的初始化流程和状态标志理清楚,后面换任何一款带I2C的DSP都能快速迁移。以F28335为例,它内部有两个I2C模块,I2C-A和I2C-B,主从模式都支持,也支持多主机仲裁,这些特性是GPIO模拟方案比不了的。
2. 配置前先把硬件基础打牢:引脚、上拉与时钟
2.1 引脚复用和外部上拉电阻选型
用硬件I2C外设的第一步,是把对应引脚从GPIO功能复用到I2C功能上。很多人在这一步就栽了跟头,因为DSP引脚默认可能是普通IO,也可能被其他外设占用,不查数据手册就直接操作寄存器,结果SCL拉不高、SDA没反应。以F28335为例,I2C-A的SDA和SCL对应GPIO32和GPIO33,I2C-B的对应GPIO28和GPIO29,具体要把GPAMUX寄存器里的对应bit设置成哪个值,一定以你手上的芯片数据手册引脚复用表为准。
设置完MUX之后还有个细节,就是引脚的输入方向。虽然硬件外设会接管引脚控制,但有些DSP在初始化外设之前需要先把GPIO方向寄存器配好,否则引脚方向停留在输出状态,可能导致外部上拉被拉低或者无法正确接收数据。具体到F28335,GPIODIR寄存器对应位我习惯顺手清0,让引脚方向先保持输入,再交给I2C外设接管,这样更稳。
接着是上拉电阻。I2C规范要求SCL和SDA是开漏结构,总线外部必须接上拉电阻,这个电阻不是随便选的。100kHz标准模式下,常见经验值是用4.7kΩ;400kHz快速模式,如果总线电容不大,可以用2.2kΩ。实际项目中,总线上挂的设备越多、走线越长,总线电容越大,上拉电阻太小会导致低电平灌电流过大,太大会让上升沿变缓,影响时序。我调试时通常先用4.7kΩ起步,如果示波器看到上升沿太缓,再逐步换成2.2kΩ或者1kΩ,这个要靠实测,不是套公式能一步到位的。
2.2 SCL速率计算:从分频器到一位电平
I2C外设的速率不是随便给个目标值就能跑,必须由时钟树逐级分频得到。拿F28335来说,系统时钟SYSCLKOUT先经过I2CPSC预分频得到模块时钟module clock,然后再通过I2CCLKL和I2CCLKH这两个寄存器分别设定SCL低电平和高电平持续多少个module clock周期,最终得到SCL频率。
我在项目里常用的一套参数是这样的:假设SYSCLKOUT是150MHz,把I2CPSC设为9,那么module clock就是150/(9+1)=15MHz,也就是每个module clock周期约66.7ns。如果目标是标准模式100kHz,一个SCL周期是10us,约等于150个module clock周期,那我可以把高电平和低电平大致分配一下,比如I2CCLKL=75、I2CCLKH=74,两个加起来就是149个module clock,再加上一些内部同步延迟,最终算出来大概就是100kHz。
具体公式在各个型号的参考手册里写得略有差异,但思路都一样:先通过I2CPSC把模块时钟分到合适范围,再用I2CCLKL和I2CCLKH去精调占空比和频率。这里有个容易踩的坑,就是如果I2CPSC设得太大,module clock频率太低,那么CLKL和CLKH稍微动一点,SCL频率就会跳很大,导致调不出精确的100kHz或400kHz。我一般会把module clock控制在几MHz到十几MHz之间,留出足够的可调余地,这样精度更好控制。
3. 硬件I2C收发流程:从寄存器到完整读写
3.1 主写一个字节的完整流程
先把寄存器配置讲透,再上代码。F28335的I2C模块里,I2CSAR是从机地址寄存器,I2CCNT是传输字节计数寄存器,I2CDXR是发送数据寄存器,I2CDRR是接收数据寄存器,I2CSTR是状态寄存器,I2CMDR是模式控制寄存器。我们做主控的时候,核心就是操作这几个寄存器。
主写一个字节的常规流程是:先确认总线空闲,把从机地址写入I2CSAR,把要发送的字节数写入I2CCNT,然后把第一个要发的内容写到I2CDXR,最后在I2CMDR里置位主模式、发送模式、START位。硬件检测到START位之后,会自动产生起始条件,随后把从机地址和RW位发出去,再依次发送I2CDXR里的数据,发送完成之后状态寄存器里会出现相应标志。
这里要注意,很多I2C外设设计的发送流程是“第一次写DXR,启动START,然后等待XRDY标志,再继续写下一字节”,所以先在启动START前把第一个数据放进去,是很常见的做法。写多个字节时,后续字节在每次XRDY置位后写DXR就行。发送完所有数据后,在I2CMDR里置STP位产生停止条件。如果你漏掉了STP,从机会一直认为总线被占用,下一轮通讯就会异常。
我贴一段实际在F28335上验证过的主发送初始化代码,供参考:
// F28335 I2C-A 初始化示例 void I2CA_Init(void) { EALLOW; // 使能I2C-A外设时钟 SysCtrlRegs.PCLKCR0.bit.I2CAENCLK = 1; // 引脚复用:GPIO32 -> SDAA,GPIO33 -> SCLA GpioCtrlRegs.GPAMUX1.bit.GPIO32 = 1; GpioCtrlRegs.GPAMUX1.bit.GPIO33 = 1; GpioCtrlRegs.GPADIR.bit.GPIO32 = 0; GpioCtrlRegs.GPADIR.bit.GPIO33 = 0; EDIS; // 复位I2C模块 I2caRegs.I2CMDR.bit.IRS = 0; I2caRegs.I2CMDR.bit.IRS = 1; // 分频配置,目标约100kHz I2caRegs.I2CPSC.all = 9; // module clock = 150 / 10 = 15MHz I2caRegs.I2CCLKL = 75; I2caRegs.I2CCLKH = 74; // 主模式、发送模式、自由运行 I2caRegs.I2CMDR.bit.MST = 1; I2caRegs.I2CMDR.bit.TRX = 1; I2caRegs.I2CMDR.bit.FREE = 1; }写EEPROM这类带子地址的从机时,要多发一个内部寄存器地址。比如向AT24C02的地址0x10写入0xAA,发送序列就是:起始条件,从机地址写位,子地址0x10,数据0xAA,停止条件。用硬件外设实现时,I2CCNT要配成2,第一个数据写子地址,第二个数据写实际要写入的值,硬件会一次性把整个序列发完。
3.2 读EEPROM类从机:子地址加重复起始
读操作比写操作复杂一些,因为要先告诉从机你要读哪个内部地址,然后重新产生起始条件,把从机地址切到读方向,再接收数据。这里用到的就是I2C协议里的重复起始(Repeated START),硬件I2C外设天然支持,这也是比GPIO模拟省心很多的地方。
具体流程是:先按写操作的时序,发送从机地址写位和要读的子地址,然后不发送停止条件,在总线仍被占用的情况下,重新置位START,这时候从机地址寄存器不变,但要把TRX位切到接收模式,硬件会再次发送起始条件和从机地址,只不过这次RW位是读方向。之后从机开始输出数据,DSP每收到一个字节,状态寄存器里的RRDY会置位,我们就去读I2CDRR取数据。如果需要连读多个字节,读完一个取一个,最后一个字节读完后置STP,告诉从机停止通信。
用代码表示大概是这个套路:
// 从EEPROM的regAddr地址连续读len个字节到buf void I2CA_ReadBytes(Uint16 devAddr, Uint16 regAddr, Uint16 *buf, Uint16 len) { Uint16 i; // 等待总线空闲 while (I2caRegs.I2CSTR.bit.BB == 1) {} // 先写子地址 I2caRegs.I2CSAR = devAddr; I2caRegs.I2CCNT = 1; I2caRegs.I2CDXR = regAddr; I2caRegs.I2CMDR.bit.TRX = 1; // 发送模式 I2caRegs.I2CMDR.bit.STT = 1; // 起始条件 // 等待子地址发送完成(轮询XRDY/ARDY/SCD,实际建议加超时) while (I2caRegs.I2CSTR.bit.XRDY == 0) {} // 重新起始,切换为接收模式 I2caRegs.I2CCNT = len; I2caRegs.I2CMDR.bit.TRX = 0; // 接收模式 I2caRegs.I2CMDR.bit.STT = 1; // 重复起始 // 接收len个字节 for (i = 0; i < len; i++) { while (I2caRegs.I2CSTR.bit.RRDY == 0) {} buf[i] = I2caRegs.I2CDRR; } I2caRegs.I2CMDR.bit.STP = 1; // 停止条件 }这段代码能跑通,但轮询等待的地方没有加超时保护,实际工程里必须加上,否则一旦从机没有应答或者总线被拉死,程序就会卡死在while循环里。后面讲问题排查的时候我会细说。
3.3 查询驱动与中断驱动的取舍
用查询方式处理I2C收发,优点是代码直观、时序可控,适合初始化阶段快速验证硬件链路。缺点是传输过程中CPU被占住,尤其是读取较大块的数据时,一次几十上百字节,CPU就得一直盯着状态寄存器,这对实时性要求高的控制系统来说不太友好。
中断方式其实是更推荐的工程方案。F28335的I2C模块支持多种中断源,比如发送就绪XRDY、接收就绪RRDY、无应答NACK、停止条件检测SCD等。我们可以让硬件在每发送完一个字节后触发中断,在中断服务函数里填下一字节;接收时每收到一个字节触发中断,在中断里读取数据。这样CPU在绝大多数时间里可以去做别的任务,只在需要处理I2C数据的瞬间进入中断,整体效率提升明显。
不过中断方式也有自己的坑。首先是中断优先级,如果系统里还有其他高频率中断,比如PWM中断、AD采样中断,而I2C中断优先级设得太低,就可能在高速通讯时出现数据丢失。其次是中断服务函数里的代码要尽量短,不要在中断里做复杂运算或者长时间阻塞,否则会影响下一字节的处理,打破时序连续性。
如果你是第一次切到硬件I2C,我的建议是先跑查询模式把读写逻辑调通,用示波器确认波形正确后再改中断模式,这样排查问题会清晰很多,不至于一开始就多个变量来回纠缠。
4. 实战排查避坑:总线卡死、ACK异常与波形异常
4.1 总线卡死后的恢复方法
I2C总线卡死是我在实际调试里遇到最多的问题。现象很典型:程序跑着跑着通讯就断了,示波器看SCL或者SDA一直卡在低电平,怎么发指令都没反应。这种问题多数是从机异常占住总线、主机在传输中途被复位、或者代码在某个轮询里死等导致状态机没有走完。
恢复方法有两个层面。第一层是软件复位外设,把I2CMDR里的IRS位先清零再置一,让I2C模块回到复位状态,重新初始化寄存器。这样能解决一部分状态机迷路的问题,但如果是从机硬件把总线拉死,软件复位主机不一定管用。第二层是把SCL和SDA临时配回GPIO输出,手动翻转出几个时钟周期,尝试让从机释放总线。我在调试时写过一段恢复函数,先把引脚切回GPIO模式,把SCL手动输出9个高电平脉冲,同时让SDA保持高电平,很多情况下能将从机从异常状态中释放出来。
防患于未然更重要。在初始化I2C之前,建议先读取状态寄存器里的总线忙标志BB,如果总线忙,就先执行恢复流程,再进入正常初始化。另外所有轮询标志位的地方都要加超时计数,通常用循环次数或者定时器计时,超时后打印错误状态并把外设复位,而不是傻等。
4.2 ACK和地址相关的常见误判
另一个高频坑就是从机地址格式搞错。很多器件的说明书里会把从机地址写成8位格式,比如EEPROM 24C02写地址0xA0、读地址0xA1,但DSP的I2C外设寄存器通常只接受7位地址,发送时硬件会在LSB自动补写读位。这个时候你不能直接把0xA0写到I2CSAR里,而是要把0xA0右移一位,变成0x50填入。这个细节不处理好,最常见的现象就是通讯完全不通,示波器能看到波形,但从机始终不回ACK,或者回的是NACK。
如果波形看起来有地址、有数据,但就是没有ACK,优先检查从机地址右移有没有做,然后检查从机供电、从机地址引脚上下拉配置,最后再考虑是不是总线上挂的器件地址冲突。I2C地址冲突的表现很有意思,两个设备地址一样时,回ACK的波形可能看起来正常,但数据会错乱,因为两个从机同时在驱动总线,这种诡异问题排查起来最费时间。
NACK标志的处理也要养成顺手清标志的习惯。很多I2C外设在收到NACK后,状态寄存器里的NACK位会被置1,如果不手动写1清除,下次传输可能一直处于异常状态。我在F28335上的做法是:每次检测到NACK后,先清除NACK标志,再置STP结束当前传输,然后重新发起新一轮传输,这能省掉很多莫名其妙的偶发问题。
4.3 时钟延展、中断优先级与DMA配合
当从机一时处理不过来,会把SCL拉低,也就是时钟延展。硬件I2C外设对时钟延展的处理通常是自动的,主机会等待从机释放SCL后再继续传输。这听起来是好事,但也可能变成隐患:如果你在调试时发现程序卡在某处,而示波器上SCL被拉低半天不动,就需要检查从机为什么一直不释放总线,比如从机程序死循环、从机电压异常、从机复位引脚被拉低等。
如果你要进一步提速,可以配合DMA来搬运I2C数据。F28335的DMA可以和I2C外设联动,当我发送一个缓冲区里的连续数据时,可以让DMA自动往I2CDXR里填充下一字节;接收时DMA自动把I2CDRR中的数据搬运到内存缓冲区。这种方案在数据量大的场景优势非常明显,CPU负载几乎可以忽略,连中断次数都大幅减少。不过DMA加I2C的组合调试难度也更高,建议先把非DMA版本调通,再引入DMA,分步推进。
4.4 问题排查速查表
下面这个表格是我自己调试时整理出来的速查表,基本覆盖了常见问题。优先级越靠前的越先检查,能少走很多弯路。
| 现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| SCL/SDA完全没有波形 | 引脚MUX未配好、外设时钟未使能、外部上拉电阻缺失 | 检查GPIO复用寄存器,确认外设时钟打开,确认SCL/SDA有外部上拉电阻 |
| SCL有波形,SDA始终为高 | 从机无应答、器件地址错误、从机未上电 | 核对7位/8位地址,用示波器抓ACK位,测量从机供电电压 |
| 通讯时好时坏 | 中断优先级不合适、总线电容过大、上拉电阻选得太大 | 固定总线速率,换成2.2k上拉,必要时改用中断或DMA方式 |
| 读到数据显示为0xFF | 从机地址错误、读写方向不对、从机处于复位状态 | 确认从机地址和读方向,检查从机复位引脚,确认上电时序 |
| 程序卡死在等待标志处 | 从机时钟延展未释放、NACK标志未处理、总线被拉低 | 加超时保护,检查从机状态,手动恢复总线,清除NACK标志 |
| 高速传输时数据错位 | 电气特性不达标、PCB走线干扰、外部上拉过弱 | 降低速率测试,改善上拉电阻,检查走线长度和共地情况 |
最后再分享一个我个人的习惯:调I2C之前,先把示波器两个通道分别接到SCL和SDA上,然后写一个最简单的连续读从机ID或者固定寄存器的测试程序,反复触发,观察波形是否稳定。波形正常再继续往下调功能,波形不对就先停在这个环节把硬件和初始化彻底搞定。我第一次调F28335的硬件I2C时,就是因为上拉电阻选得太大,80cm飞线导致上升沿拖得很长,400kHz根本跑不起来,降到100kHz才勉强工作,后来换了2.2k上拉并缩短线缆,400kHz就稳定了。很多时候问题不在代码,而在那些看起来不起眼的硬件细节上。