首页 / 资讯中心 / 文章详情

告别IO口翻转:DSP硬件I2C外设配置与实战指南

告别IO口翻转:DSP硬件I2C外设配置与实战指南 ★ FEATURED ARTICLE
做DSP开发这些年I2C这个总线我接触得不算少但发现一个挺有意思的现象很多工程师一上来就抱着一堆IO口拿着示波器慢慢翻波形用软件去模拟I2C时序。我不是说这方法不能跑我自己也这么干过可一旦遇到从机地址写错、应答超时、数据错位你会发现自己一直在跟延时函数和GPIO电平较劲查到最后很可能就是某个延时差了几个微秒。这次我要分享的是另一条路直接用DSP片上自带的I2C硬件外设走标准的I2C通讯配置完全避开用IO口一比特一比特地“人工”翻转时序。这篇文章适合正在调DSP外设的朋友尤其是用TI C2000系列、想驱动EEPROM、温湿度传感器、OLED屏等I2C从机的开发者。我会从为什么推荐硬件I2C讲起把协议细节、模块结构、寄存器配置、波特率计算、读写函数代码全部过一遍最后把调试中经常踩的坑列成清单。不管你之前是纯软件模拟的老手还是刚接触DSP的小白按照这套思路走基本能少走大半个月的弯路。1. 先搞清楚为什么放着硬件I2C不用非要IO口翻转1.1 软件模拟时序的痛点IO口模拟I2C的原理其实很简单I2C的通讯就是两根线SCL负责时钟SDA负责数据只要按照协议在GPIO上输出高低电平再配上精确延时就能构造出起始条件、停止条件、数据帧和应答位。很多初学者在单片机上都是这么学的毕竟看起来直接也不用去理解芯片内部那一堆外设寄存器。但是“能跑”和“好用”是两个概念。先看最简单的软件模拟代码套路// 软件模拟I2C起始条件 void Soft_I2C_Start(void) { SDA_H; SCL_H; delay_us(5); SDA_L; // SCL为高时SDA由高变低产生起始条件 delay_us(5); SCL_L; }这段代码看起来人畜无害但实际工程里它有几个绕不开的坑第一时序完全依赖延时精度。你虽然写了delay_us(5)但这个5微秒在-O2优化和-O0优化下可能完全不是一个数。如果系统里还有定时器中断、串口中断一旦在延时的节骨眼上CPU被抢走I2C时序立刻拉长从机大概率直接忽略这次传输表现出来就是SCL有波形但从机就是不应答。第二多字节传输时CPU被死死占住。一个字节包括8个数据位加上1个应答位每一位你都要去翻转SDA、翻转SCL、判断应答。写一个256字节的EEPROM页面基本就是几千次GPIO操作加延时期间主程序什么都干不了。第三软件模拟很难处理真正的“总线仲裁”和“时钟拉伸”。很多I2C从机在内部EEPROM擦写、准备数据时会主动把SCL拉低也就是时钟拉伸告诉主机“你等一下”。软件模拟时你得额外检测SCL电平处理起来非常痛苦。至于多个主设备同时抢总线这种需求软件模拟基本只能靠加锁和祈祷。1.2 硬件I2C外设到底赢了哪里DSP芯片内置的I2C外设本质上是一个独立的状态机。它自己负责产生SCL、采样SDA、装配字节、检查应答你只需要往数据寄存器里填值再从接收寄存器里取值剩下的协议细节全部由硬件完成。还是用开车类比软件模拟是手动挡每个换挡动作都要人为介入硬件外设是自动挡你踩油门前记得看看仪表盘就行。实际对比下来硬件I2C最核心的优势可以总结成这么几点对比项IO口软件模拟DSP硬件I2C外设CPU参与度每个bit都要软件参与写/读寄存器即可硬件搬移数据时序稳定性依赖延时函数和中断响应由硬件状态机精确控制代码量协议逻辑全部手写动辄上百行配置寄存器和读写FIFO几十行搞定多主/仲裁/时钟拉伸很难完整支持硬件原生支持调试难度逻辑分析仪盯着波形逐bit看直接看状态寄存器和FIFO计数还有一个很容易被忽略的点硬件I2C的波特率是精确计算的半周期时钟高电平和低电平由寄存器分频决定不会随编译器优化等级变化而抖动。这一点在多机通讯、长线通讯时尤其重要总线速率能稳定压在规格书要求范围内。2. I2C协议和DSP模块结构配置寄存器之前要补的课2.1 协议里最容易被忽略的几个细节很多人以为I2C协议就是“起始、写数据、停止”实际配置DSP硬件外设时有几个细节不对寄存器怎么调都没用。第一个是地址位宽的问题。I2C有7位地址和10位地址两种模式而不管是7位还是10位地址字节里最后一位固定是读写方向位。举个例子常见的EEPROM从机地址如果是0x50那么实际总线上的第一个字节是0xA0也就是0x50左移一位低位是0表示写方向如果读操作首字节就是0xA1。DSP的I2CSAR寄存器填的通常是7位地址本身不包含R/W位硬件在发送时会自动拼上读写方向。很多人在这一步直接把0xA0填进SAR结果从机永远不应答这个问题我后面排查清单里还会重点提。第二个是重复起始条件。读EEPROM的典型流程是先写一个字节指定内部寄存器地址然后重新产生起始条件再发一次从机地址加读方向位最后才能读到数据。这个“重新起始”不是简单的“再发一次起始条件”而是在发送完寄存器地址且没有产生停止条件的情况下再次触发START。软件模拟时你只是再调用一次Start函数而硬件外设里一般要设置重复模式位或再次拉高STT位这个在TI的I2CMDR寄存器里有专门的处理方式下面代码部分会演示。第三个是停止条件的产生时机。主机在读完最后一字节之前就应该先准备好停止条件否则从机可能继续往外推数据主机如果多读一个字节整个数据流就错了。还有停止条件不能让硬件自动“顺手”产生你必须在合适的时间点把STP位置位硬件才会在下一个字节边界上拉起停止。第四个是应答位NACK。主机接收模式下读完最后一字节前主机要主动回一个NACK告诉从机“我读够了”然后发停止条件。如果主机的接收顺序没有处理好硬件外设会自动帮你发ACK结果从机以为你还要继续发数据读取长度就会失控。2.2 DSP的I2C外设内部结构以TI C2000系列为参考I2C外设的组成并不复杂但每个模块都对应了明确的寄存器理清结构后再配置会轻松很多。整个外设大概包含这几个部分串行接口负责实际总线上的电平收发时钟发生器负责把外设时钟分频成SCL发送/接收移位寄存器负责将字节一位一位地搬到SDA线上FIFO缓冲负责暂存待发送和接收的数据C2000系列多是16级深度的FIFO控制和状态寄存器负责主从模式选择、起始停止条件触发、事件标志位读取。实际操作中最常用到的寄存器有这些I2CSAR从机地址寄存器填入7位或10位从机地址。I2CPSC外设时钟预分频决定内部模块时钟频率。I2CCLKL和I2CCLKHSCL低电平和高电平的计数值直接决定总线速率。I2CMDR模式控制寄存器主从模式、发送接收模式、起始/停止条件、重复模式都在这里。I2CSTR状态寄存器里面是ARDY、NACK、SCD、TXRDY、RXRDY这些总线状态。调试时看这个寄存器比看示波器还方便。I2CDXR和I2CDRR数据发送寄存器和数据接收寄存器读写FIFO时操作的就是它们。I2CFFTX和I2CFFRXFIFO使能和中断触发级别配置。如果你用的是其他品牌的DSP寄存器名可能不一样但“地址寄存器、分频寄存器、控制寄存器、状态寄存器、数据寄存器”这套逻辑万变不离其宗。搞清楚自己芯片手册里哪个寄存器对应哪个功能再去填值就不会一头雾水。3. DSP I2C硬件通讯完整配置实操3.1 第一步引脚复用和外设时钟使能DSP的引脚基本是复用的同一颗引脚可能是普通GPIO也可能是某个外设的通讯信号。想用I2C外设第一步就是把对应引脚切到外设功能上同时打开I2C外设的时钟。以TMS320F28335为例I2C-A模块的SCL和SDA分别对应GPIO32和GPIO33。初始化代码大致是这样// 配置I2C-A引脚为外设功能 EALLOW; SysCtrlRegs.PCLKCR0.bit.I2CAENCLK 1; // 使能I2C-A外设时钟 GpioCtrlRegs.GPAMUX2.bit.GPIO32 1; // GPIO32 - I2CA_SCL GpioCtrlRegs.GPAMUX2.bit.GPIO33 1; // GPIO33 - I2CA_SDA GpioCtrlRegs.GPAPUD.bit.GPIO32 0; // 使能内部上拉辅助作用 GpioCtrlRegs.GPAPUD.bit.GPIO33 0; EDIS;这里有几个要注意的地方。第一不同型号的DSPI2C对应的引脚和MUX值差很多比如F2806x可能是GPIO28/29F2837x又不一样所以一定先查数据手册的GPIO MUX表格别照抄。第二内部上拉只是辅助I2C总线标准要求外部上拉电阻一般2.2kΩ到10kΩ之间具体取多少要看总线负载和通讯速率后面会单独讲。第三有些DSP芯片还要求配置GPXDIR但多数型号在MUX设置为外设功能后方向位由外设接管不会再影响引脚状态。3.2 第二步波特率分频值怎么算DSP I2C的波特率是通过两级分频得到的。第一级是I2CPSC把外设低速时钟LSPCLK预分频成模块时钟第二级是SCL高电平和低电平计数I2CCLKH和I2CCLKD共同决定最终SCL周期。通用的计算公式是SCL频率 LSPCLK / ((I2CPSC 1) * (I2CCLKH I2CCLKD 2))要不要加这个2不同型号手册写法略有差异但思路完全一样。我建议拿到芯片后先去数据手册找到自己的I2C时钟章节看官方给的计算公式。举一个实际算例。假设LSPCLK是37.5MHz目标是100kHz标准模式那么分频系数为375。设I2CPSC为9模块时钟就是37.5MHz除以10等于3.75MHz。这时需要CLKH和CLKD相加配合那个2让3.75MHz再被37.5整除才能得到100kHz。取CLKH17、CLKD18的话SCL就是3.75MHz除以(17182)刚好约100kHz。// 初始化I2C-A为100kHz I2caRegs.I2CPSC 9; // 预分频37.5MHz / 10 3.75MHz I2caRegs.I2CCLKL 18; // SCL低电平计数值 I2caRegs.I2CCLKH 17; // SCL高电平计数值实际操作时我习惯把低电平时间略长于高电平也就是I2CCLKL比I2CCLKH稍大一些。因为SDA和SCL在总线上有上拉电阻信号拉高的速度受RC影响会比拉低慢一些低电平时间长一点能让波形占空比更接近50%实测通讯稳定性会好一些。如果目标是400kHz快速模式计算方式一样但新问题就是总线上拉电阻要相应调小否则上升沿太缓波形爬不到高电平阈值从机就会错误采样数据。3.3 第三步主模式读写函数的代码实现接下来是重头戏主模式下的数据读写。我先给一个完整的主模式发送函数目标是向从机某个内部寄存器地址写入一批数据。这个过程比较直观填从机地址填要写的寄存器地址和数据设置起始和停止条件然后等状态寄存器。// 向从机devAddr的内部寄存器regAddr写入len个字节 int I2CA_WriteBytes(int devAddr, int regAddr, Uint16 *data, int len) { int i; int timeout 0; I2caRegs.I2CSAR devAddr; // 7位从机地址不含R/W位 // 复位并重新使能TX FIFO确保里面没有残数据 I2caRegs.I2FFTX.bit.TXFFRST 0; I2caRegs.I2FFTX.bit.TXFFRST 1; // 先填充寄存器地址 I2caRegs.I2CDXR regAddr; // 填充要发送的数据 for (i 0; i len; i) { I2caRegs.I2CDXR data[i]; } // 配置为主机模式、发送模式置STT和STP I2caRegs.I2CMDR.bit.MST 1; I2caRegs.I2CMDR.bit.TRX 1; I2caRegs.I2CMDR.bit.STT 1; // 发起起始条件 I2caRegs.I2CMDR.bit.STP 1; // 最后一个字节结束后自动停止 // 等待传输结束注意一定要加超时保护 while (I2caRegs.I2CSTR.bit.ARDY ! 1) { if (I2caRegs.I2CSTR.bit.NACK 1) // 从机无应答 { I2caRegs.I2CSTR.bit.NACK 1; // 写1清标志 return -1; } if (timeout 100000) { return -2; // 超时 } } return 0; }这段代码里最关键的就是STT和STP位置位的时机。我是在设置主模式后马上同时置位硬件会在数据全部填充、起始条件产生后按顺序把FIFO里的数据发出去并在最后一个字节传输完成后自动产生停止条件。这样写的好处是不用自己在数据发完后盯状态位再去补STP少一个时序窗口出错概率低一些。读操作的流程比写稍微绕一点需要两步第一步先以写模式发送寄存器地址第二步切换为读模式发起重复起始条件读取数据。注意主机在读模式时需要在倒数第二个字节前就置STP否则会多读一个字节或者总线状态收不干净。// 从从机devAddr的内部寄存器regAddr读取len个字节 int I2CA_ReadBytes(int devAddr, int regAddr, Uint16 *buf, int len) { int i; int timeout 0; I2caRegs.I2CSAR devAddr; // 第一步先以主机发送模式把寄存器地址写出去 I2caRegs.I2CMDR.bit.MST 1; I2caRegs.I2CMDR.bit.TRX 1; I2caRegs.I2CMDR.bit.STT 1; I2caRegs.I2CDXR regAddr; // 等待地址字节发送完成 while (I2caRegs.I2CSTR.bit.ARDY ! 1) { if (I2caRegs.I2CSTR.bit.NACK 1) { I2caRegs.I2CSTR.bit.NACK 1; return -1; } if (timeout 100000) { return -2; } } // 第二步切换为接收模式发送重复起始条件 I2caRegs.I2CMDR.bit.RM 1; // 重复模式开启 I2caRegs.I2CMDR.bit.TRX 0; // 切到接收 I2caRegs.I2CMDR.bit.STT 1; // 发起重复起始条件 // 循环读取数据 for (i 0; i len; i) { timeout 0; while (I2caRegs.I2CSTR.bit.RXRDY ! 1) { if (timeout 100000) { return -2; } } buf[i] I2caRegs.I2CDRR; // 倒数第二个字节读完时准备停止条件 if (i len - 2) { I2caRegs.I2CMDR.bit.STP 1; } } return 0; }读函数里那个“在倒数第二个字节读完时置STP”是I2C主机接收常见的关键点。硬件外设在收到STP请求后会在下一个字节边界也就是读完最后一字节时自动发出NACK和停止条件。如果你等到读完最后一个字节再置STP从机可能已经多推了一个字节你的缓冲区尾部就会多出一个无效数据。3.4 中断与FIFO的应用思路轮询写法的缺点是主程序会一直卡在等待循环里。如果通讯的数据量不大比如从机就是几个寄存器的传感器轮询完全够用。但如果你要一次性读写几十上百个字节或者系统里还有其他高优先级任务那就应该用FIFO加中断。C2000的I2C FIFO是16级深度。发送端可以设置一个触发值TXFFIL当FIFO中剩余待发送数据不大于这个值时会触发TXFFINT中断接收端设置RXFFIL当FIFO中接收到的字节数大于等于这个值时触发RXFFINT中断。中断思路大致是主程序把数据一次性塞进TX FIFO塞够了触发条件就初始化传输后续中断服务函数里检测是发送中断还是接收中断事件驱动地把数据处理掉。我自己在项目里的做法是用一个环形缓冲区中断服务函数里持续从FIFO取数并填入缓冲区同时通知主程序“收到新数据”。这样通讯过程不阻塞主循环实时性任务照常跑。要注意的是中断标志位要养成“读后写1清零”的习惯否则同一个中断会被反复触发。TI的I2C中断在响应后要清除对应的中断标志这个在官方例程里都有但很多人移植时容易漏掉。4. 高频踩坑现场与稳定运行经验4.1 一上电就NACK的排查路线NACK应该是所有人印象最深的问题。你配置好初始化信心满满地一跑结果状态寄存器里的NACK位立刻就亮了。我排查的思路是固定的按顺序查下来基本都能找到问题。第一查从机地址。如果你把含读写位的0xA0填进了I2CSAR而硬件期待的是7位0x50那从机看到的总线地址是错的永远不应答。这个错误在3.3节的代码框架里已经被规避了但自己写的时候还是很常见。第二查从机是否上电、供电电压是否正常。有些传感器模块有独立电源主控侧3.3V不代表从机侧也有电。我试过把OLED模块插反结果SDA被模块功耗拉低总线一直忙连NACK都报不出来。第三查上拉电阻。I2C是开漏结构SCL和SDA必须接上拉才能工作。如果你完全没接外部上拉只靠主控内部上拉低速短线下运气好可能能跑但只要线一长或者从机负载稍重总线高电平就抬不上去从机采不到有效数据。第四查地址位宽模式。从机如果只支持7位地址而你设了扩展地址的XA位那从机解析的地址字节格式完全对不上。4.2 数据错位、多读字节、死锁这几个经典问题数据错位是最让人头疼的因为波形看起来“好像都对”。常见原因是发送函数里写入FIFO后立刻设置STT而硬件还没准备好头几个字节被当成地址字节发出去。解决办法是先等待TXRDY就绪再置STT或者复位并使能FIFO确保数据装载完成。多读字节的问题基本都出在STP置位时机上这个在上面读函数里已经详细说过。如果你发现自己读出来的第一个字节后面跟着一个0x00或者其他奇怪数据回头检查一下你是不是在读最后一字节后才写STP。死锁的场景也比较经典。比如从机在传输中途因为内部错误没正常释放总线SDA或者SCL会被拉死这时候你发任何命令都没用总线上所有通讯都瘫痪。硬件I2C模块又没有“手动拉高释放”的按钮这时候可以临时把引脚切回普通GPIO用软件往SCL上打几个脉冲让从机重新同步。这个方法也被叫做“bus recovery”。// 手动恢复I2C总线SCL空转9个时钟 void I2CA_BusRecovery(void) { EALLOW; // 先把引脚切回普通GPIO GpioCtrlRegs.GPAMUX2.bit.GPIO32 0; GpioCtrlRegs.GPAMUX2.bit.GPIO33 0; GpioCtrlRegs.GPADIR.bit.GPIO32 1; // SCL输出 GpioCtrlRegs.GPADIR.bit.GPIO33 1; // SDA输出 EDIS; GpioDataRegs.GPBDAT.bit.GPIO33 1; // SDA拉高 DELAY_US(10); for (int i 0; i 9; i) { GpioDataRegs.GPBDAT.bit.GPIO32 1; // SCL高 DELAY_US(5); GpioDataRegs.GPBDAT.bit.GPIO32 0; // SCL低 DELAY_US(5); } // 恢复正常复用 EALLOW; GpioCtrlRegs.GPAMUX2.bit.GPIO32 1; GpioCtrlRegs.GPAMUX2.bit.GPIO33 1; EDIS; }这个方法不能保证100%修复从机的卡死状态但对付大部分因为总线扰动导致的状态机错乱效果很明显。恢复完总线后最好把I2C模块也做一次软件复位也就是把I2CMDR的IRS位拉到0再拉回1。4.3 总线恢复和稳定性心得在实际产品里I2C的稳定性是靠硬件电路和软件健壮性一起堆出来的。我自己总结下来有几点值得特别注意。上拉电阻的选型不要照抄开发板。开发板上通常用4.7kΩ但那是针对板载短走线和小负载设计的。如果你外接的从机模块比较多或者线缆长度超过20cm4.7kΩ会导致上升沿变缓高速模式下尤其明显。我习惯100kHz标准模式用2.2kΩ到4.7kΩ400kHz快速模式用1kΩ到2.2kΩ具体用示波器看上升沿是否尖锐。所有等待状态寄存器的循环都要加超时保护。这个怎么说都不为过。一台上电偶尔读一个NACK的设备如果代码逻辑在while里死等轻则整机卡死重则看门狗也救不回来。加一个timeout变量超过阈值就返回错误并重启I2C模块这种防御性编程在实际工程里比什么技巧都重要。通讯前先读取一次状态寄存器并清零。很多怪异问题是因为上次通讯失败后的残留标志位还在比如NACK、SCD这些会把下一次传输的状态判断搞乱。初始化时把所有中断标志和状态位清一遍。对了还有一个容易被忽略的点就是FIFO里的残留数据。如果在传输中途发生错误TX FIFO和RX FIFO里可能还留着上一轮的数据不清掉直接开启下一次通讯会出现“第一次好的第二次错第三次又好的”这种玄学现象。所以每次读写前我习惯先做一次FIFO复位再使能代码在3.3节里已经体现了这一点在调试时帮我省了不少时间。最后再分享一个小技巧。调试I2C时逻辑分析仪绝对是我最依赖的工具不用纠结协议解码准不准直接看原始波形SCL的周期有没有抖动SDA的跳变是不是都在SCL低电平期间应答位能不能看到从机拉低。把这些波形细节养成习惯I2C的很多问题其实一眼就能定位。自己别老盯着寄存器猜抓完波形再回到代码里核对效率高得多。
阅读完成 · 觉得有帮助?
咨询建站