news 2026/10/26 1:08:51

嵌入式通信总线SPI与I2C实战详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
嵌入式通信总线SPI与I2C实战详解

1. 先从“片内”说起:为什么需要片间总线

做嵌入式这些年,我越来越觉得“总线”这词被用得有点滥。芯片内部的AXI、AHB、APB算是片内总线,解决的是CPU核、DMA、存储控制器和外设IP之间怎么传数据的问题;而SPI、I2C这类板级总线,解决的是芯片与芯片、芯片与模块之间怎么交换数据的问题。标题里那句“从沙子到车辙”其实挺贴切——沙子是硅片,车辙是电路板上的走线,SPI和I2C就是连接这两端的“毛细血管”。

很多刚接触MCU的朋友容易犯一个迷糊:片内已经有APB总线和GPIO了,为什么还要学SPI、I2C?答案很简单,APB不可能引出几十根引脚接到外部器件上,就算能引出来,信号完整性、时序同步、设备寻址这些问题也会把人逼疯。片间总线本质上就是把一组复杂的同步、握手、地址选择逻辑封装成一个标准外设,你只需要配置几个寄存器,剩下的活让硬件去干。SPI是高速、简单、多线,I2C是低速、少线、可挂载量大,两者互补,几乎覆盖了板级通信90%以上的场景。

这篇文章我会结合自己做过的实际项目,把SPI和I2C从协议层到电路层、再到调试层的细节都捋一遍。不会只讲“什么是模式0”,而是要讲清楚为什么选模式0、片选怎么拉才不出毛刺、I2C的2.2kΩ上拉怎么算出来的、什么时候要电平转换。这些东西不踩几次坑是记不牢的,我趁着热乎全写出来,给后来人留个宝典。

1.1 片内总线与片间总线的分界线

严格来说,片内总线和片间总线之间并没有一条物理上的硬边界。以常见的Cortex-M内核MCU为例,CPU通过AHB总线访问Flash、SRAM和DMA控制器,APB1/APB2桥接外设寄存器,而SPI和I2C外设的寄存器就挂在APB上。你写一个SPI_DR寄存器,数据从CPU核经AHB→APB→SPI移位寄存器,最后从MOSI引脚串行出去——这一刻,数据才刚刚离开“片内”。

理解这条数据通路很重要,因为它决定了性能上限。APB总线的最大工作频率通常受限于外设挂载总线的时钟,比如GD32F303的APB1最大是60MHz,APB2最大是120MHz。SPI2挂在APB1上,SPI1挂在APB2上,这意味着SPI1的理论波特率能跑到更高。很多人不看数据手册就直接把SPI时钟配成主频一半,结果高频下读取外部Flash出错,排查半天才发现是APB分频没算对。想跑高速,先查你的SPI挂在哪条总线上,然后确认APB分频系数,这一步省不掉。

另外还有个容易被忽略的点:片内总线是并行数据,片间总线是串行数据。CPU往SPI_DR写一个16位数据,SPI硬件负责把这个16位变成一串比特从MOSI发出去。这个过程如果完全靠CPU手动移位,效率极低;所以SPI外设内部有移位寄存器和缓冲区,甚至还有FIFO。而DMA可以把内存里的数据块直接搬到SPI_TDR,不占CPU,这就是“SPI DMA”的意义所在。后面我会专门讲DMA的配置时机。

1.2 从“点对点”到“一主多从”的设计思路

片内通信基本是“一点对多点”,总线上所有设备抢仲裁。但片间通信不一样,大多数场景是“一主多从”,由主机发起所有传输。这种设计哲学根植于物理层:SPI靠片选线(CS)区分从机,低电平选中;I2C靠设备地址区分从机,用ACK应答确认数据是否送达。理解了这一点,你就能明白为什么SPI接口那么“简单粗暴”,而I2C接口那么“讲究礼仪”。

SPI本质上是“主子对话,其他人都闭嘴”。主机把CS拉低,等于对某个从机说“我只跟你说话”,同步时钟SCK一拉,数据就按照约定好的顺序在MOSI/MISO上飞来飞去。其他从机的CS没被拉低,它们的MISO引脚必须处于高阻态,否则总线就打架了。这也是为什么SPI从机设备通常都有三态输出的要求。有些国产芯片的MISO设计不标准,CS未选中但引脚不是高阻,导致总线上多个设备冲突,测出来全是乱码,这坑我踩过。

I2C则是“所有人听广播,点名应答”。主机先发START条件,然后发7位地址+读写标志,总线上的每个从机都拿自己的地址跟收到的地址比较,匹配的一方会在第9个时钟周期拉低SDA产生ACK。I2C只有两根线,但它靠一套复杂的时序协议换来了“一个主机最多能挂100多个从机(理论地址空间128减去保留地址)”的好处。代价就是速度上限低,标准模式100kbit/s,快速模式400kbit/s,高速模式也就3.4Mbit/s。相比之下,SPI轻松跑几十Mbit/s。所以做数据采集卡、显示刷新,优先SPI;做传感器、EEPROM这种低速率设备,I2C足够。

2. SPI:四根线撑起的高速通道

SPI的全名叫Serial Peripheral Interface,串行外设接口。它通常是四根线:SCK、MOSI、MISO、CS。某些设备只需要三根线,比如只读传感器没有MISO,或者只写的DAC没有MOSI回读功能;还有一些设备把CS省略掉变成三线模式,但那样就得靠同步字来区分帧,用起来很别扭。板级设计上,四线制还是最稳的选择。

SPI是我用过最“舒服”的通信协议,因为它的时序极其透明:SCK提供节拍,在每个时钟沿,主机和从机同时移出一位并移入一位,天然全双工。没有地址帧、没有应答位,想做突发传输直接把CS拉低,连续发N个字节,想停就拉高CS。这种透明性让SPI非常容易通过示波器抓波形来判断问题——只要看CS、SCK、MOSI三条线是否对齐就能定位八九成的故障。不过,太快也不是好事。SCK时钟速率一旦超过从机允许的范围,或者时序模式选错,数据就全废。

2.1 SPI协议核心:时钟极性与相位

SPI有四种模式,由CPOL(时钟极性)和CPHA(时钟相位)两个bit决定。CPOL决定空闲时SCK是高还是低:CPOL=0,空闲低,第一个边沿是上升沿;CPOL=1,空闲高,第一个边沿是下降沿。CPHA决定在第一个边沿采样还是第二个边沿采样:CPHA=0,第一个边沿采样;CPHA=1,第二个边沿采样。这四个组合就是Mode 0~3。

你不需要背下“Mode0=上升沿采样”这种表,那是应试教育的做法。实际选模式只看一件事:从机手册的时序图里,数据在哪根边沿被锁存。有些传感器特别“皮”,数据手册里时序图画得不清楚,那就先用示波器看它回传的MISO波形,反推它是在上升沿还是下降沿把数据送出来的。比如AD7606这颗16位ADC,它有并行和SPI两种接口,用SPI模式时官方推荐CPOL=0、CPHA=1(Mode 2)。为什么?因为AD7606的MISO变化发生在SCK下降沿,主机在随后一个上升沿采样最稳。如果你用Mode 0,SCK上升沿采样时MISO可能刚好在切换,读到的就是毛刺中间的值。

还有一点容易翻车:不少国产MCU的SPI外设寄存器里CPOL和CPHA的默认值跟标准ST库不一样,比如GD32F303的配置位和STM32是兼容的,但CH32V307由于内核是RISC-V,其寄存器布局有区别,你按STM32的库函数写有可能初始化失败。所以任何时候都要去查对应MCU的参考手册,确认SPI_CFG寄存器里CKPL和CKPH的含义。我吃过亏:一个项目用CH32V307挂OLED,按STM32的思路配置SPI模式0,结果满屏雪花,查了两天才发现CH32V307的CPOL在空闲时的有效电平定义刚好是反的。

2.2 硬件片选与软件片选:谁更靠谱?

片选是SPI最容易被忽视但最关键的一环。硬件片选(NSS)由SPI外设自动控制,配置好之后你来发数据前它会自动拉低,发送完拉高。软件片选则是用普通GPIO拉低片选,然后通过SPI收发数据,最后手动拉高。很多初学者喜欢用软件片选,因为它直观,想什么时候拉就什么时候拉,还能实现“中途暂停保持片选有效”这种特殊需求。但其实硬件片选在很多场景更合适,尤其是配合DMA。

硬件片选的好处是不需要CPU参与,减少了GPIO操作带来的延迟。但硬件片选的坑也不少:一是它通常在你写入数据寄存器后立刻拉低CS,如果你的数据流有间隙,CS也会跟着抖动;二是一些MCU的硬件NSS在连续传输模式下可能会在字节之间释放CS(比如STM32的NSS脉冲模式),导致从机误认为传输结束。所以我通常的建议是:简单低速、字节间允许间隙的情况下用软件片选;高速连续流、配合DMA的场景,优先用硬件片选,但一定要检查NSS的输出时序是不是你想要的,必要时用GPIO模拟NSS配合DMA也行,那个其实还是软件片选,但可以利用DMA的空闲中断来释放片选。

我之前做过一个项目,用GD32F303的SPI0跑DMA向TF卡写日志,一开始用软件片选,在DMA传输完成中断里拉高CS。结果发现TF卡偶尔写错块,原因在于DMA传输完成后CPU去中断拉高CS的延迟有几十微秒,而TF卡在SPI模式下对CS上升沿的要求很严格,必须在特定字节边界后拉高,晚一点它就把前面的数据当成下一条命令了。后来改成硬件NSS自动片选,CS拉高时机跟发送完成硬件对齐,问题立刻消失。所以结论很简单:有DMA就尽量用硬件片选。

2.3 SPI DMA:什么时候才能真正提速?

很多人一看到“SPI DMA”就热血沸腾,觉得只要挂上DMA速度就起飞。实际上DMA只是“搬运工”,真正的速度瓶颈在SPI时钟和外设总线。DMA的作用是减少CPU参与字节搬运的开销,让你可以把大批数据扔给外设之后去干别的活,或者干脆让CPU在内存里准备下一块数据。如果数据量只有几十字节,中断收发反而更简单,DMA初始化代码的耗时可能比中断累加还多。

SPI DMA的典型使用场景是连续的块传输,比如一次刷新一个320x240的LCD缓冲区(15万字节)、向TF卡写一个4KB扇区、从AD7606连续采集几万个16位样品。这时DMA才能体现价值。配置DMA时有三个关键参数容易配错:第一,外设地址必须是SPI的数据寄存器地址,而不是SPI的某个FIFO寄存器,否则数据搬不到位;第二,内存地址要保证4字节对齐,特别是用32位DMA搬运16位FIFO数据时,如果内存地址不对齐,会触发总线错误;第三,传输方向要分清“从内存读到SPI发送”和“从SPI接收写到内存”,两种方向对应的DMA通道不同,发展成收发全开的话要开两个DMA通道,一个TX、一个RX。

还有一个细节,DMA搬运数据时,CPU写SPI_DR会导致数据交叉错乱,因为DMA和CPU同时访问外设寄存器会产生竞争。解决办法是配置好DMA后,启动阶段唯一一次写入SPI_DR来触发SPI发送,后续数据全部由DMA接管。我习惯的做法是:先配置SPI为仅发送模式、使能DMA请求,然后写SPI_DR唤醒转移,紧接着使能DMA通道,形成一个无缝衔接。这个顺序颠倒一下,就可能在LED屏上看到第一个字节重复或丢失。

2.4 实战:AD7606和TF卡的SPI电路细节

AD7606是16位8通道同步采样ADC,采样率最高200kSPS,使用SPI接口时要注意它有两个模式:一种是普通的命令读模式,主机发一个字节的配置命令,然后从机持续输出通道数据;另一种是自动循环模式,CS拉低后直接连续读所有通道。我常用的是前者,因为它更可控。电路连接上,AD7606的SCLK最高频率有25MHz,但建议开始不要跑太高,先设成10MHz测通再往上提,因为PCB走线寄生电容会影响MISO边沿,尤其当MISO线上还有下拉电阻时,边沿会更慢。

AD7606的SPI从机模式有一个特点:它的MISO在SCLK上升沿变化(输出),在下降沿被主机采样,所以主机必须配置成CPOL=1、CPHA=1(Mode 2),也就是SCLK空闲为高、数据在第二个边沿采样。这个细节如果错了,读出来的ADC数据会整体错位一位甚至全部出错。另外,AD7606的/CS信号最好用GPIO控制而不用硬件NSS,因为你要在读取过程中有一次“拉高再拉低”的操作来重新触发下一次转换,硬件NSS很难做到这种中间打断的时序。

再说TF卡,TF卡在SPI模式下是半双工模式的简化:主机发送命令,从机回应响应,之后数据流。TF卡需要一个上电延时,还必须先发送至少74个时钟脉冲(此时CS为高)让它初始化。很多人的TF卡SPI老是初始化失败,就是因为省略了这个“喂时钟”的过程。TF卡SPI模式下最大时钟频率通常25MHz,但要注意CRC校验命令——有些卡在初始化时如果不带CRC(CMD0的CRC字段为0x95),会直接拒绝响应。另外,TF卡的MISO线上建议加上拉电阻(10kΩ左右),因为卡内部输出可能是开漏或类似结构,加上拉才能保证高电平正确。

TF卡的CS拉高和拉低之间,主机最好保持时钟空闲,避免在CS无效时SCK继续翻转,否则卡可能捕捉到杂散时钟沿导致状态错误。调试时我习惯先用低速SPI(400kHz)跑通初始化,再切换到25MHz高速模式。这样能明显区分“电路问题”和“协议问题”,省去很多无谓的纠结。

3. I2C:两根线打天下的“开漏”艺术

I2C的发明初衷是“用最少导线连接最多设备”,所以它把协议设计得极其精致。两根线,SDA和SCL,都是开漏输出,必须接上拉电阻才能产生高电平。这也是I2C与SPI最根本的差异点之一。SPI的推挽输出直接驱动MOSI和MISO,输出能力强,速度快;而I2C的开漏结构决定了它只能低速、长距离、抗干扰能力有限。但这个“弱”换来的是设备地址化、多主仲裁、时钟拉伸这些高级功能,而且任意两个器件之间只有两根公共线,扩展起来非常方便。

我第一次接触I2C的时候不理解为什么要那么别扭地搞开漏。后来做I2C扩展芯片才明白,开漏结构允许不同设备在不同的时间拉低总线,而这种“线与”逻辑天然实现了多主仲裁和设备的应答机制。如果I2C也搞成推挽输出,两个设备同时驱动总线时一个拉高一个拉低就会短路,那才是灾难。开漏就像一个多人共用的电话线,谁想说话就拉低线,说完就释放,总线保持高电平表示没人说话——非常优雅。

3.1 开漏输出与推挽输出的本质区别

推挽输出(Push-Pull)就是GPIO内部有两个管子,一个接到VCC(P-MOS),一个接到GND(N-MOS),输出高电平时P管导通,电流可以灌进负载;输出低电平时N管导通,电流可以拉出去。这种结构输出阻抗低、速度高,但“不能”同时两个输出对C强输出,否则一个拉高一个拉低,就是短路电流。

开漏输出(Open-Drain)则只有下拉管接到GND,高电平完全依赖外部上拉电阻。好处是多个开漏输出可以直接并联在同一条线上,任何一个拉低就输出低,大家都不拉才输出高。这就构成了“线与”逻辑,是I2C总线的基础。坏处是上升沿时间受RC充电速度限制,电阻越大,上升沿越慢,最大通信速率就越低。所以I2C总线上拉电阻的选择是一个经典的工程权衡。

选上拉电阻有一条经验公式:R_pullup ≤ t_rise / C_bus(其中t_rise是总线的最大上升时间要求,C_bus是总线电容总和)。标准模式100kbit/s允许上升沿最多1微秒,快速模式400kbit/s允许最多300纳秒。如果总线上挂的设备多,布线长,电容可能达到几百pF,这时候用4.7kΩ的上拉电阻很容易让上升沿拖到微秒级别,导致通信失败。我在一个挂有8个I2C设备、延长线约30cm的板子上,初调用4.7kΩ电阻,400kHz跑不起来,后来换成2.2kΩ才稳定。电容不是凭空算的,大概经验:每挂一个I2C设备增加5~10pF,每厘米PCB走线增加0.5~1pF,算个总数再选电阻。

3.2 地址、ACK与时钟拉伸:I2C的“握手”玄机

I2C的通信流程是:START(SCL高时SDA产生下降沿)→ 地址字节(7位地址+读写位)→ ACK(从机在第9个时钟拉低SDA)→ 数据字节+ACK/NAK → STOP(SCL高时SDA产生上升沿)。这个框架很简单,但每个环节都有魔鬼细节。

地址方面,7位地址的0x00是广播呼叫地址,0x01是起始字节等特殊用途,实际可用地址在0x02~0x7E。有些器件支持可编程地址引脚,比如AT24C256EEE的A0/A1/A2三个引脚可以组合出8个不同地址,坑的是这三个引脚内部有上拉/下拉陷阱:有些封装的A0/A1/A2悬空时默认低电平,所以你焊接后以为悬空=0,实际可能是1或不确定,必须仔细读手册。另外地址冲突是I2C最常见的问题,设备地址一旦撞车就得换芯片或者用I2C扩展器的地址分配功能,所以画板前一定要列个地址表。

ACK是I2C可靠性的基石。主机发送完每个字节后,从机必须在第9个SCL低电平期间把SDA拉低,否则主机认为传输失败。有一个反直觉的点:如果是主机读数据,当主机不想再收数据了,它在最后一个字节时不发ACK而发NAK(释放SDA),然后发STOP,从机才知道“别发了”。如果主机一直发ACK,从机会一直接着发,导致系统卡在读数据死循环。

时钟拉伸(Clock Stretching)是I2C的一个高级功能:从机在需要处理数据时,可以把SCL拉低,让主机暂停时钟,直到从机准备就绪。很多老器件不支持时钟拉伸,但有些EEPROM在页写的时候会延长时间,用硬件的I2C外设时如果外设不支持自动处理时钟拉伸,程序就得自己检测SCL电平来做软件上的等待。CH32V307的I2C硬件支持时钟拉伸,但具体要配置相关寄存器,不然可能忙着忙那反而出错。

3.3 电平转换:什么时候需要,怎么做

很多板级系统里,MCU是3.3V,但I2C设备可能是5V,比如老式EEPROM、铁电存储还是5V供电的。如果直接把3.3V的引脚连到5V的开漏总线上,由于开漏输出实际只是“不驱动高电平”,只要上拉电阻接到5V,两个不同电压域的器件就能安全通信,前提是每个器件能够耐受5V的电压(3.3V器件引脚常通过内部钳位二极管组成保护,但超压会损坏)。

还有人担心“I2C需要电平转换吗”,答案不是绝对的。如果3.3V设备较弱,最稳妥的方案是用专用电平转换芯片,比如PCA9306或者分立MOS管做的双向电平转换电路。分立方案常用2N7002MOS管,方法是用两个MOS管背靠背接,Vref1端接3.3V,Vref2端接5V,两边总线各通过上拉电阻到各自电源。原理是:一侧拉低时MOS管导通,另一侧也被拉低;一侧释放时MOS管截止,另一侧靠上拉电阻恢复高电平。这个电路便宜且可靠,是低速率I2C电平转换的标准做法。

要注意的是,电平转换会把时序延迟再加一点,400kHz下要尽量用低电容MOS管,同时两侧上拉电阻值要恰当(2.2k~4.7k)。如果你用了一个无源上拉电平转换,而且在高速模式下出现数据乱码,多半是上升沿太慢,可以把电阻降到1k以下,但要算好电流,避免超出总线驱动能力。还有一个容易忽视的问题:双向转换电路的MOS管栅极必须接两个电源域低侧的那个电源(通常是3.3V),接反了会完全不通。

3.4 I2C扩展与编码器:用小成本解决多设备连接

I2C的一大优势是可以扩展。比如你MCU的I2C1只引出两组引脚,但板上还有10个设备要挂,这时就可以用I2C扩展器,如PCF8574/PCF8575,把I2C地址转换为并行GPIO。PCF8574占用一个I2C地址,提供8个准双向IO,每路IO可以输入或输出,但要注意它内部是准双向结构,输入时外部必须驱动成高或低,不能浮空。PCF8574有两个地址引脚,可以配置8种不同地址,这样一个I2C总线就能挂8个PCF8574,变成64路IO,性价比很高。

I2C编码器则是一个很有趣的应用。有些旋转编码器不并联EC11,而用I2C接口芯片如AS5600,直接输出12位角度数据。它的I2C地址固定为0x36,不支持修改,所以一个总线上最多只能挂一个AS5600,除非你用地址转换芯片。还有I2C机械编码器模块,内部用移位寄存器扫描按键状态,读出来的数据格式复杂,但接线确实省心——两根线加上电源就能读旋转方向和按键值。

I2C扩展和编码器项目里,我最常犯的错是忘记考虑“总线地址冲突”。如果设备地址完全固定且不可调,那就得规划好:哪个设备挂在I2C1,哪个挂在I2C2。实在无法避免冲突时,可以用TCA9548A这种I2C选择器把总线分成8路,每路挂一个冲突设备。虽然多了一个芯片,但逻辑清晰,调试容易得多。

3.5 软件I2C与硬件I2C的选择

“软件I2C和硬件I2C的区别”绝对是搜索引擎常客。软件I2C是用GPIO模拟时序,手动拉高拉低SCL/SDA,并检测状态。硬件I2C则是芯片内部外设,只要配置寄存器,硬件自己产生START/STOP、时钟、ACK检测等。原则上,如果MCU支持硬件I2C,优先用硬件I2C,因为软件模拟在时序上很脆,尤其是在中断频繁的环境里,稍有延迟就会被从机判定为超时。

但现实是残酷的。很多老款MCU的硬件I2C被吐槽“难用、BUG多”,比如STM32F1系列早期I2C外设就有很多坑,导致很多工程师宁愿用软件I2C。其实现在的芯片,比如GD32F303、CH32V307,硬件I2C已经成熟得多,只要配置正确,稳定性和速度都远胜软件模拟。这里我给个实用建议:如果你只是偶尔读写一个EEPROM,数据量小、速率无所谓,软件I2C完全能用,还能减少调试外设配置的时间;如果你要连续读传感器、刷OLED,或者对时序有要求,那就老老实实硬件I2C。

软件I2C还有一个用途是“移植友好性”——当你想把一个传感器例程移植到任意MCU上,只要手写一对IO模拟函数,就能跑通,完全不受外设寄存器差异困扰。CH32V307开发板上很多人用I2C驱动OLED,就是因为CH32V307的I2C外设配置稍微复杂一点(需要设置时序寄存器SCLT/H),很多人索性用GPIO模拟I2C,省心。这事没有绝对对错,我自己的原则是:量产项目用硬件I2C,原型评估用软件I2C。

4. SPI与I2C的实战对比与选型

很多人选型的时候纠结“这个设备该用SPI还是I2C”,其实只要遵循几条经验就能快速决策:速度要求高、数据量大,选SPI;引脚紧张、挂载设备多、速率低,选I2C。但真要落到实做,还有一些隐性因素要考虑,比如时序复杂度、驱动能力、从机数量、调试难度。

我做了个表格,方便大家对照着看:

项目SPII2C
引脚数4根(SCK/MOSI/MISO/CS)2根(SCL/SDA)
传输速率典型10~50Mbit/s标准100k,快400k,高速3.4M
工作模式全双工半双工
从机选择硬件片选或GPIO靠地址寻址
从机数量受片选引脚数限制地址空间大,理论多但受电容限制
时序复杂度简单,线少但模式多复杂,有START/STOP/ACK等
数据可靠性无应答机制每字节都有ACK
硬件开销片选线多,但线路简单上拉电阻、电平转换麻烦
调试难度较容易,抓波形直观较难,时序节奏较多

从实际项目角度讲,SPI适合:LCD/OLED(尤其高刷新率屏幕)、NOR Flash、TF卡、数据采集芯片(AD7606、ADS1256)、SPI多轴陀螺仪。I2C适合:EEPROM、温度传感器、RTC、光传感器、I2C IO扩展器、I2C SSD1306 OLED(不过小屏也可以SPI)。如果你的MCU引脚特别紧张,比如只有CN32个脚,又想挂时钟、温度、EEPROM三个设备,I2C一根总线搞定,SPI就得占12个脚(4*3),瞬间就明白怎么选了。

还有一点,SPI多设备总线的总线上拉问题没有I2C那么明显,但SPI的MISO线如果多个从机挂并联,就需要保证每个从机未被选中时输出高阻,否则会电平冲突。有些芯片手册只写了一行“MISO tri-state when CS is high”,但实际要等CS拉高后一小段时间才能释放,时序上容易有交叠,此时应在MISO上加一个小电阻(如100Ω)做隔离,或者改用片选信号带延迟的译码器来减少串扰。

另外,GD32F303的SPI和I2C外设在使用DMA时的中断标志位也有差异,SPI的DMA传输完成事件是TXE/RXNE,I2C则是TXBE/RXNE。习惯了STM32库的人换到GD32要特别注意,因为寄存器定义虽然兼容,但库函数的命名可能不同。我记得有一次用GD32F303的I2C1配合DMA读MEMS传感器,DMA中断里清标志没清对,导致第二次读取直接卡死,后来改用轮询才稳。这个项目要是加个注释,能省后来者半天时间。

5. 常见问题与调试技巧实录

这一章全是血泪教训。我汇总了SPI和I2C调试中最常见的几个“坑”,以及怎么定位、怎么解决,完全可以当速查表用。

5.1 SPI接口测试的常见坑与定位方法

SPI接口测试看起来很直观,但最容易出错的地方不是波形,而是初始化顺序和模式配置。最常见的坑是“MOSI和MISO接反”。尤其那些排线接口的模块,比如SD卡模块,经常有人把MOSI接SD卡的MISO上,卡当然读不了。接反了的现象:能写入,读回的响应全是0xFF或0x00。解决办法永远都是先拿逻辑分析仪抓,看一眼SDA、MOSI线上到底有没有数据。

第二个坑是“时钟速率过高导致误码”。SPI从机的最大时钟频率都有标注,比如某LCD模块支持最高30MHz,但在建议工作电压下实际只能跑20MHz。如果你设置25MHz,显示花屏,这不是代码问题,是速率问题。处理办法是把SPI时钟降到10MHz测试,如果稳定了就逐步往上调。同时检查电源旁路电容是否靠近器件,因为电流尖峰会导致器件内部逻辑误判。

第三个坑跟“片选时序”有关。当用软件片选时,必须先拉低CS,延时一小段时间(很多从机要求CS低到第一个SCK前沿之间有最小建立时间),再开始发SCK。中断里操作时,要仔细计算GPIO写寄存器到SPI开始发送之间的延迟。最好在CS拉低后加几个空指令的延时,或者用SPI外设的脚本来保证时间。如果CS和SCK同时变换,有些从机直接忽略整个传输。

SPI DMA调试的坑更多:DMA传输完成后,SPI可能还处于忙碌状态,如果立即拉高片选,可能漏掉最后一个字节。解决办法是在DMA传输完成中断里,先看SPI的忙碌标志(BSY),等BSY=0后再拉CS。经验值是等几十微秒,但最可靠是查标志。另外,用DMA收发时,如果DMA和SPI的时钟分频不匹配,可能收到残段数据,建议先用轮询模式跑通,再加DMA,分步调试永远是最省心的。

5.2 I2C读写EEPROM的Verilog实现要点

说到“i2c读写eeprom代码 verilog”,这是FPGA新手比较喜欢做的练习。Verilog里实现I2C主机,我一般拆成这几个状态:IDLE、START、SEND_ADDR、WAIT_ACK、SEND_DATA、RECEIVE_DATA、SEND_NACK、STOP。核心是“周期计数器”,因为每个位传输需要多个时钟周期(比如目标400kHz,系统时钟50MHz,那么每个SCL周期要125个系统时钟周期)。我用一个计数器从0数到124,在一个周期的特定点改变SCL和SDA。

写EEPROM时最需要注意的是“页写”和“写周期时间”。AT24C系列EEPROM每页通常8字节或16字节(具体看型号),如果你一次写入超过页大小,地址会回卷,导致数据覆盖错误。所以一次写数据必须按页边界拆分成多次写事务。每次写事务结束之后,EEPROM内部进入写周期,通常要等3~5毫秒,这期间它不响应任何I2C命令。判断写周期结束的办法,一是延时固定时间,二是发一个无操作读来探测ACK,如果EEPROM返回ACK说明写完。第二种更高效。

Verilog实现里还要注意ACK检测:在第9个时钟,主机的SDA线必须处于输入状态,所以要在那个周期把SDA设成高阻,然后采样外部值。如果采到低电平说明从机应答,若采到高电平则从机没有应答。有很多人的代码在发送完地址后忘了释放SDA,导致读状态永远为1。另外,I2C起始条件定义为SCL为高时SDA从高到低,停止条件是SCL为高时SDA从低到高,这两个条件必须严格遵守,否则从机状态机就乱了。调试时用逻辑分析仪把SDA/SCL抓到屏幕上,看START和STOP位置,基本一眼能找到问题。

5.3 总线死锁与复位策略

I2C有一个经典死锁场景:主机在传输过程中被复位(比如看门狗),而此时从机正处于数据输出状态(正把SDA拉低),如果主机复位释放总线变成高阻,但因SDA仍被从机拉低,导致总线一直处于低电平——看起来就像总线“卡死”了。解决这个问题的方法有两种:一是硬件上在从机的SDA线上串一个可断开的隔离(一般不做),二是软件上用“总线恢复时序”:主机的SCL线连续输出9个脉冲,并在这9个周期内监测SDA,当SDA变高后,再发一个STOP,让总线恢复空闲状态。这个操作相当于强迫从机释放SDA并结束当前通信。很多驱动库里都有这个函数,比如BH1750传感器的init里就会先做一次总线恢复。

另外一个死锁是软件模拟I2C时,由于GPIO方向切换不及时,导致SDA被拉低后无法读回高电平。解决办法是检查GPIO配置:开漏输出模式下,读引脚时要先关闭输出方向,或者用输入模式读取。如果你的MCU的GPIO没有真正的开漏输出模式(有些只有“推挽输出+方向切换”,需要额外配置),那就用输出寄存器置1和方向寄存器切换来实现开漏。

SPI的死锁相对少见,但也可能因为CS电平配置错误导致从机认为总线一直忙。比如某些设备的CS是高有效而不是低有效,如果你按低有效配置了,主机一直选中从机,从机可能持续输出数据把自己的缓冲区占满,然后默默不理你了。所以永远先确认CS的有效极性,别上来就按默认去拉。

5.4 I2C读写EEPROM的代码片段参考(以Verilog为例)

这里给一个简洁的I2C写单个字节的状态机片段,注意这不是完整可综合工程,只为展示关键逻辑。

// 简化:系统时钟50MHz,目标I2C时钟约400kHz,每个SCL半周期125个主时钟周期 localparam IDLE=0, START=1, SEND_BITS=2, ACK_BIT=3, STOP=4; reg [6:0] cnt; // 位计数 reg [125:0] tcnt; // 时间计数 reg scl, sda_out; wire sda_in; always @(posedge clk) begin case(state) IDLE: if(start_go) begin state<=START; tcnt<=0; end START: begin // SCL高时,SDA拉低构成START scl<=1; sda_out<=0; if(tcnt==124) begin state<=SEND_BITS; bit_cnt<=7; tcnt<=0; end else tcnt<=tcnt+1; end SEND_BITS: begin // 在SCL低电平期间改变SDA,高电平期间保持 if(tcnt==0) begin scl<=0; sda_out<=data_to_send[bit_cnt]; end if(tcnt==62) scl<=1; // 前半周期后半段拉高SCL if(tcnt==124) begin if(bit_cnt==0) state<=ACK_BIT; else begin bit_cnt<=bit_cnt-1; tcnt<=0; end end else tcnt<=tcnt+1; end ACK_BIT: begin sda_out<=1; // 释放SDA,让从机应答 if(tcnt==62) scl<=0; // 下降沿 if(tcnt==124) begin state<=STOP; tcnt<=0; end else tcnt<=tcnt+1; end STOP: begin scl<=0; sda_out<=0; if(tcnt==62) scl<=1; if(tcnt==124) begin sda_out<=1; state<=IDLE; end // SCL高时SDA上升沿产生STOP else tcnt<=tcnt+1; end endcase end

这个代码只是个骨架,真正工程里还需要处理时钟拉伸检测、总线超时、错误状态恢复等。但只要你把START和STOP的时序卡正确,后面扩展发送多字节就不难了。

6. 写在最后:我的几点实战体会

我做嵌入式这么多年,SPI和I2C算是用得最多的两种片间总线。说几句掏心窝的话,给大家当参考。

第一,别迷信硬件外设,也别妖魔化软件模拟。硬件外设速度快,但寄存器配置复杂,查问题看寄存器状态比扒波形麻烦;软件模拟调试灵活,但时序不够稳。我现在的习惯是:所有外设驱动先跑通软件版本,再切换到硬件外设,这样既能快速验证系统逻辑,又能让最终产品跑在硬件加速上。特别是I2C,先软件模拟跑通通信流程,再换硬件外设,能省不少查手册的时间。

第二,示波器和逻辑分析仪是总线调试的底气。很多人对着代码发呆三个小时,不如下个逻辑分析仪(几十块的也能用)抓十几秒波形。SPI看CS和SCK、MOSI的对齐关系,I2C看START/STOP条件和ACK位。波形一出来,很多问题直接就水落石出了。我强烈建议每个嵌入式工程师桌面常备一个8通道以上的逻辑分析仪。

第三,关于CH32V307这类RISC-V MCU,库函数和例程跟STM32很相似,但寄存器细节有差异,特别是时钟树和外设复用时,容易遇到“例程没问题,就是跑不对”的怪现象。我测试CH32V307 I2C OLED例程时,发现它的I2C配置需要额外设置I2C_CTLR2的FREQ字段,如果不设置,总线时钟就不是想要的400kHz,时序可能错乱。所以遇到问题多翻手册,别死盯例程。

第四,电路设计上一定要把总线电容和上拉电阻算好。I2C的2.2k、4.7k上拉不是固定值,要根据总线长度和挂载数量调整。SPI的线长超过10cm就要考虑阻抗匹配,信号线尽量短而粗,或增加串联电阻阻尼振铃。早年在板级布线时忽略这些,后期调试会让你怀疑人生。

最后分享一个小技巧:调试两个设备通信,在主机端先发一串固定的字节(如0x55、0xAA、0x01),然后用逻辑分析仪看从机回发的数据,如果回发的数据不是预期值,优先怀疑寄存器配置和硬件连接,不要急着查协议分析。等双方数据对齐了,再上DMA或中断,一次成功的概率会高很多。这些经验是我踩了无数次坑之后才总结出来的,希望你少走弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 1:08:17

HPM6750双核RISC-V MCU飞控实战:核间通信与实时控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 1:08:06

C++14赋能汽车电子嵌入式:ECU开发与域控实践解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 1:07:16

基于Java的养老院管理系统源码:Spring Boot+MyBatis-Plus实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 1:07:16

无人机导航系统详解:从传感器融合到工程落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 1:07:15

微信小程序毕业设计校友会系统:源码落地与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 1:06:49

CAN总线家族全解析:经典CAN、CAN FD、CAN XL与TTCAN选型指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华