
做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和GPIO33I2C-B的对应GPIO28和GPIO29具体要把GPAMUX寄存器里的对应bit设置成哪个值一定以你手上的芯片数据手册引脚复用表为准。设置完MUX之后还有个细节就是引脚的输入方向。虽然硬件外设会接管引脚控制但有些DSP在初始化外设之前需要先把GPIO方向寄存器配好否则引脚方向停留在输出状态可能导致外部上拉被拉低或者无法正确接收数据。具体到F28335GPIODIR寄存器对应位我习惯顺手清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/(91)15MHz也就是每个module clock周期约66.7ns。如果目标是标准模式100kHz一个SCL周期是10us约等于150个module clock周期那我可以把高电平和低电平大致分配一下比如I2CCLKL75、I2CCLKH74两个加起来就是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 - SDAAGPIO33 - 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就稳定了。很多时候问题不在代码而在那些看起来不起眼的硬件细节上。