☰
GD32 SPI全双工DMA配置实战:从原理到避坑指南
2026/9/28 18:01:51 网站建设 项目流程

1. 为什么SPI全双工配DMA是GD32项目里的硬骨头

SPI本身是个很朴素的协议,四根线一摆,时钟一推,数据就出去了。但一旦把速率拉高、数据量拉大,再叠加上"全双工"这个要求,事情就变得微妙起来。我见过太多项目在调试阶段用轮询方式跑SPI跑得好好的,一上量产、一提高吞吐量,CPU占用率直接飙到七八十,主循环里其他任务全被拖垮。这时候DMA就成了绕不开的选择。

GD32的SPI外设和STM32在寄存器层面高度相似,但细节上又有不少自己的脾气。全双工模式下,SPI的发送和接收是同时进行的——你往发送寄存器写一个字节,接收寄存器里就会同步收到一个字节。这个特性决定了DMA的配置不能只做单向搬运,必须收发两条通道同时开。很多人在这一步栽跟头:只配了TX的DMA,RX用中断或者轮询去读,结果高速率下要么丢数据,要么OVR溢出标志频繁置位。

这篇内容适合两类人看:一类是刚接触GD32、想把SPI+DMA跑通的嵌入式新手;另一类是在项目里被SPI通信稳定性折磨过、想搞清楚底层机制的老手。我会从外设触发机制讲起,把DMA请求映射、全双工收发通道配置、主从机角色差异、实测中的坑和排查方法全部拆开讲。代码基于GD32标准外设库,但思路对HAL库同样适用。

注意:全双工SPI的DMA配置,TX和RX必须同时使能,且RX通道要先于TX通道使能,否则第一个字节可能丢失。

2. GD32的SPI与DMA请求映射关系拆解

2.1 SPI外设的DMA请求是怎么产生的

要理解DMA怎么搬SPI的数据,得先搞清楚DMA请求是谁发出的。GD32的SPI外设内部有两个标志位:TBE(发送缓冲空)和RBNE(接收缓冲非空)。当TBE置位时,说明发送数据寄存器空了,可以往里写新数据;当RBNE置位时,说明接收数据寄存器里有新数据等着被读走。

DMA控制器就是盯着这两个标志位干活的。SPI的DMA发送请求连接到DMA控制器的某个通道,接收请求连接到另一个通道。以GD32F103系列为例,SPI0的TX请求映射到DMA0的通道3,RX请求映射到DMA0的通道2。这个映射关系是硬件固定的,不能随便改,选错了通道DMA就不会触发。

这里有个容易混淆的点:GD32不同系列的DMA请求映射表不完全一样。F103和F303的映射就有差异,F4系列更是换了一套DMA架构。所以你在移植代码的时候,第一件事就是翻对应型号的用户手册,找到DMA请求映射表,确认SPI_TX和SPI_RX分别挂在哪个DMA的哪个通道上。

2.2 全双工模式下收发通道的协同逻辑

全双工SPI的DMA配置有个反直觉的地方:接收通道要先使能。原因是这样的——SPI是全双工,你发一个字节出去的同时就会收一个字节进来。如果TX通道先使能,数据已经开始往外推了,但RX通道还没准备好,第一个收到的字节就会因为没人搬走而导致RBNE溢出。虽然不至于让整个通信崩溃,但数据流会错位一个字节,对于协议解析来说是致命的。

正确的顺序是:

  1. 配置RX DMA通道,设置好接收缓冲区地址和长度,先使能RX通道
  2. 配置TX DMA通道,设置好发送缓冲区地址和长度,再使能TX通道
  3. 最后使能SPI的DMA发送和接收请求位(SPI_CTL0寄存器里的DMATEN和DMAREN)

这个顺序我在多个项目里验证过,RX先行使能是保证第一个字节不丢的关键。如果你用的是HAL库,HAL_SPI_TransmitReceive_DMA内部会处理这个顺序,但标准库需要自己控制。

2.3 DMA通道优先级与仲裁的实战影响

GD32的DMA控制器支持通道优先级配置,有低、中、高、超高四档。SPI全双工场景下,TX和RX两个通道同时工作,如果优先级配置不当,会出现某一方向的数据流被压制的情况。

我的经验是:RX通道优先级设高,TX通道设中或低。理由很直接——接收是被动方,数据来了你不及时搬走就会溢出;发送是主动方,稍微晚一点写数据寄存器问题不大,SPI时钟会自然等待。把RX优先级提上去,能显著降低高速率下的溢出概率。

另外,DMA仲裁器在多个通道同时请求时会按优先级裁决。如果你系统里还有ADC、UART等其他DMA通道在跑,更要留意SPI_RX的优先级不能太低,否则在总线繁忙时接收数据容易被延迟搬运。

3. 主从机全双工DMA配置的差异与实操

3.1 主机模式下的时钟与片选控制

主机模式下,SPI负责产生时钟和片选信号。用DMA搬运数据时,片选的管理有两种做法:硬件片选和软件片选。

硬件片选由SPI外设自动控制NSS引脚,传输开始拉低,传输结束拉高。听起来省事,但实际项目里我不太推荐。原因是硬件片选的时序是固定的,在多从机场景下切换不够灵活,而且有些GD32型号的硬件片选在DMA传输结束时的拉高时机和预期有偏差。

软件片选就是自己用GPIO控制,传输前拉低,DMA传输完成中断里拉高。这种方式可控性强,适合多从机切换。但要注意:拉高片选的时机必须在DMA发送完成之后,而不是发送缓冲区空的时候。DMA发送完成中断(传输计数器归零)才代表最后一个字节真正发出去了,此时拉高片选才安全。

主机时钟配置方面,GD32的SPI时钟分频系数从2到256可选。实际选型时不要只看理论最大值,要结合从机器件手册里的最高时钟频率、PCB走线质量、以及DMA搬运速度综合决定。我一般会先用较低分频跑通,再逐步提高,同时用逻辑分析仪抓波形确认没有畸变。

3.2 从机模式的DMA响应机制

从机模式下,SPI的时钟由主机提供,从机只管在时钟边沿采样和输出数据。从机用DMA的难点在于:你无法预知主机什么时候开始传输。所以从机的RX DMA通道必须一直处于使能状态,随时准备接收。

从机TX方向稍微复杂一些。如果主机发来的数据长度不固定,从机怎么知道该准备多少数据发回去?常见的做法是预填充发送缓冲区,DMA传输长度设成最大可能长度,然后在传输完成中断里根据实际接收到的数据量做处理。另一种做法是用SPI的接收事件触发发送,但GD32的SPI没有这种硬件联动,需要软件介入。

从机模式还有一个坑:NSS引脚的管理。从机的NSS必须由主机控制,如果从机NSS配置成软件模式且一直拉低,从机会误以为一直被选中。正确做法是从机NSS配置成硬件模式,由主机片选信号驱动。

3.3 收发缓冲区设计与数据对齐

DMA搬运的数据宽度可以配置为8位或16位。SPI数据帧长度也可以配成8位或16位。这两者必须匹配,否则数据会错位。

我见过一个典型的错误:SPI配成8位数据帧,DMA配成16位搬运,结果每两个字节被合并成一个16位数据,接收缓冲区里的数据全是乱的。排查了半天才定位到这个问题。

缓冲区设计上,建议收发各用一个独立的数组,大小根据协议最大帧长来定。如果数据量很大,可以考虑用双缓冲(DMA双缓冲模式),一个缓冲区在搬运时另一个缓冲区供CPU处理,实现流水线操作。GD32的DMA支持双缓冲模式,配置稍微复杂一些,但在高吞吐场景下很值得。

/* GD32 SPI0 全双工DMA配置示例(主机模式) */ #define SPI_TX_BUFFER_SIZE 64 #define SPI_RX_BUFFER_SIZE 64 uint8_t spi_tx_buffer[SPI_TX_BUFFER_SIZE]; uint8_t spi_rx_buffer[SPI_RX_BUFFER_SIZE]; void spi_dma_full_duplex_init(void) { /* 使能DMA和SPI时钟 */ rcu_periph_clock_enable(RCU_DMA0); rcu_periph_clock_enable(RCU_SPI0); /* 配置DMA发送通道 - SPI0_TX映射到DMA0通道3 */ dma_deinit(DMA0, DMA_CH3); dma_init_struct.direction = DMA_MEMORY_TO_PERIPHERAL; dma_init_struct.memory_addr = (uint32_t)spi_tx_buffer; dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.memory_width = DMA_MEMORY_WIDTH_8BIT; dma_init_struct.number = SPI_TX_BUFFER_SIZE; dma_init_struct.periph_addr = (uint32_t)&SPI_DATA(SPI0); dma_init_struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_init_struct.periph_width = DMA_PERIPHERAL_WIDTH_8BIT; dma_init_struct.priority = DMA_PRIORITY_MEDIUM; dma_init(DMA0, DMA_CH3, &dma_init_struct); /* 配置DMA接收通道 - SPI0_RX映射到DMA0通道2 */ dma_deinit(DMA0, DMA_CH2); dma_init_struct.direction = DMA_PERIPHERAL_TO_MEMORY; dma_init_struct.memory_addr = (uint32_t)spi_rx_buffer; dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.memory_width = DMA_MEMORY_WIDTH_8BIT; dma_init_struct.number = SPI_RX_BUFFER_SIZE; dma_init_struct.periph_addr = (uint32_t)&SPI_DATA(SPI0); dma_init_struct.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_init_struct.periph_width = DMA_PERIPHERAL_WIDTH_8BIT; dma_init_struct.priority = DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH2, &dma_init_struct); /* 先使能接收通道,再使能发送通道 */ dma_channel_enable(DMA0, DMA_CH2); dma_channel_enable(DMA0, DMA_CH3); /* 使能SPI的DMA收发请求 */ spi_dma_enable(SPI0, SPI_DMA_TRANSMIT); spi_dma_enable(SPI0, SPI_DMA_RECEIVE); }

4. 实测中那些手册不会告诉你的坑

4.1 第一个字节丢失的完整排查链路

现象:主机发送8字节数据,从机接收到的第一个字节是0x00或者上一个传输的残留值,后面7个字节正常。

排查过程:

第一步,确认RX DMA通道是否先于TX使能。我最初的代码是先使能TX再使能RX,改成RX先行使能后,问题依然存在。

第二步,用逻辑分析仪抓SPI的MOSI和SCK波形。发现主机发出的第一个字节在MOSI上是正确的,但从机接收缓冲区里就是不对。说明问题出在从机侧。

第三步,检查从机的SPI初始化代码。发现从机的SPI使能(SPI_CTL0的SPIEN位)是在DMA配置之前做的。SPI已经使能了,但DMA还没配好,此时如果主机开始传输,第一个字节就会因为DMA没准备好而丢失。

修复方法:先配置DMA,再使能SPI。SPI使能放在最后一步,确保DMA通道已经就绪。这个顺序在主机侧同样适用。

提示:SPI使能位SPIEN的置位时机很关键,一定要在所有DMA配置完成之后。

4.2 DMA传输完成中断与SPI忙状态的时序陷阱

DMA传输完成中断触发时,DMA的传输计数器已经归零,但这不代表SPI的最后一个字节已经发送完毕。SPI的发送过程是:数据从发送缓冲区搬到移位寄存器,然后一位一位地移出去。DMA传输完成只说明数据都搬到了发送缓冲区,移位寄存器里可能还有最后一个字节在发送中。

如果你在DMA传输完成中断里立刻拉高片选或者关闭SPI,最后一个字节就会不完整。正确的做法是在DMA传输完成中断里等待SPI的TRANS(传输完成)标志或者TBE和BUSY标志都清零,再执行后续操作。

void DMA0_Channel3_IRQHandler(void) { if(dma_interrupt_flag_get(DMA0, DMA_CH3, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH3, DMA_INT_FLAG_FTF); /* 等待SPI最后一个字节真正发送完成 */ while(spi_i2s_flag_get(SPI0, SPI_FLAG_TRANS) == RESET); /* 此时才安全地拉高片选 */ gpio_bit_set(GPIOA, GPIO_PIN_4); } }

4.3 高速率下的OVR溢出与DMA搬运延迟

当SPI时钟超过一定频率,DMA的搬运速度可能跟不上SPI的接收速度,导致RBNE标志还没来得及被DMA清除,下一个字节就来了,OVR溢出标志置位。

这个问题的根源通常有两个:一是DMA通道优先级太低,被其他通道抢占了总线;二是系统时钟配置有问题,DMA时钟和SPI时钟的比例不协调。

我的处理经验是:先把SPI时钟降下来验证功能,确认DMA配置无误后,再逐步提高SPI时钟,同时用示波器观察OVR标志是否置位。如果提高到一个频率后开始出现OVR,说明已经到了DMA搬运的极限,要么降低SPI时钟,要么优化DMA优先级和总线占用。

另外,GD32的DMA和CPU共享总线矩阵,如果CPU频繁访问内存,DMA的搬运效率会受影响。在SPI DMA传输期间,尽量减少CPU对内存的大批量访问,能明显改善高速率下的稳定性。

4.4 主从机时钟极性与相位的匹配验证

SPI的时钟极性(CPOL)和时钟相位(CPHA)决定了数据在时钟的哪个边沿采样和输出。主从机必须配置一致,否则数据全是错的。

我遇到过一次诡异的情况:主机CPOL=0、CPHA=0,从机也是CPOL=0、CPHA=0,但通信就是不稳定,偶尔错一两个字节。后来用逻辑分析仪仔细看波形,发现从机的实际采样边沿和主机不一致。原因是从机的SPI初始化代码里,CPOL和CPHA的配置位写反了顺序,虽然最终寄存器值看起来一样,但中间过程产生了毛刺。

排查方法:用逻辑分析仪同时抓SCK、MOSI、MISO三根线,对照SPI时序图,确认数据在正确的时钟边沿上稳定。如果边沿对不上,先检查CPOL/CPHA配置,再检查GPIO的复用功能配置是否正确。

5. 从轮询到DMA的迁移策略与性能对比

5.1 什么场景下值得上DMA

不是所有SPI应用都需要DMA。如果你的SPI通信数据量很小(比如每次几个字节),传输频率也不高,轮询方式完全够用,代码还简单。但以下几种场景,DMA几乎是必选项:

  • 单次传输数据量超过16字节,且传输频率较高
  • CPU需要同时处理其他任务,不能长时间阻塞在SPI等待上
  • 需要实现高速连续数据传输,比如SPI Flash读写、ADC数据采集
  • 系统对功耗敏感,希望CPU尽快完成搬运后进入低功耗模式

我做过一个对比测试:GD32F103主频72MHz,SPI时钟18MHz,传输256字节数据。轮询方式下CPU占用时间约120微秒,DMA方式下CPU只需要配置和启动,占用时间不到5微秒,剩下的时间可以处理其他逻辑。数据量越大,DMA的优势越明显。

5.2 迁移过程中的代码改造要点

从轮询迁移到DMA,代码改动主要集中在三个地方:

第一,初始化部分增加DMA通道配置。注意DMA请求映射要和SPI外设对应,通道优先级要合理设置。

第二,数据传输函数从"写一个字节等一个字节"改成"配置DMA、启动传输、等待完成中断"。这里要注意传输完成中断里不能做太耗时的操作,否则会影响下一次传输的启动。

第三,错误处理要增加DMA相关的标志检查,比如传输错误标志、FIFO错误标志等。轮询方式下这些错误通常不会出现,但DMA方式下如果配置不当,这些标志会频繁置位。

5.3 实测性能数据与优化空间

以下是我在GD32F103平台上的实测数据,SPI时钟18MHz,全双工模式,收发各256字节:

传输方式CPU占用时间传输总耗时最大稳定速率
轮询约120us约120us18MHz
DMA单缓冲约5us约115us18MHz
DMA双缓冲约3us约115us18MHz

从数据看,DMA主要节省的是CPU占用时间,传输总耗时受限于SPI时钟本身,提升不大。但CPU省下来的时间可以用于其他任务,系统整体吞吐量提升明显。

优化空间主要在两个方面:一是提高SPI时钟频率,但受限于从机器件和PCB质量;二是使用DMA双缓冲,让数据搬运和数据处理并行,适合连续数据流场景。

6. 调试工具与波形验证的实战方法

6.1 逻辑分析仪抓SPI波形的正确姿势

调试SPI+DMA,逻辑分析仪是必不可少的工具。抓波形时要注意几点:

采样率要足够高,至少是SPI时钟的4倍以上。比如SPI时钟18MHz,逻辑分析仪采样率至少要72MHz,否则波形会失真,边沿判断不准。

触发条件设置成片选信号的下降沿,这样能抓到完整的传输过程。如果片选是软件控制的,触发条件设成SCK的第一个边沿。

解码器配置成SPI模式,设置正确的CPOL、CPHA、数据位宽和位序。解码出来的数据要和代码里发送的数据逐字节对比,快速定位是发送端还是接收端的问题。

6.2 用GPIO翻转做时间戳定位瓶颈

在没有逻辑分析仪的情况下,可以用GPIO翻转配合示波器来测量各阶段耗时。具体做法是在DMA启动前翻转一个GPIO,在传输完成中断里再翻转同一个GPIO,示波器上就能看到传输的总耗时。

如果想测量更细的粒度,比如DMA搬运时间和SPI移位时间的比例,可以用多个GPIO分别标记不同阶段。这种方法虽然粗糙,但在现场调试时非常实用。

6.3 常见错误标志的读取与解读

GD32的SPI状态寄存器里有几个关键标志位,调试时经常需要读取:

  • RBNE:接收缓冲非空,DMA没及时搬走数据时会一直置位
  • TBE:发送缓冲空,DMA没及时填充数据时会置位
  • TRANS:传输完成,最后一个字节移出后置位
  • OVR:溢出错误,接收缓冲还没被读走新数据就来了
  • MODF:模式错误,NSS引脚被意外拉低

调试时可以在传输完成后读取这些标志,判断是否有异常。OVR和MODF置位通常意味着配置有问题,需要重点排查。

7. 多从机场景下的DMA管理思路

7.1 片选切换与DMA重配置的时机

多从机场景下,每个从机的SPI参数可能不同(时钟频率、CPOL/CPHA),切换从机时需要重新配置SPI和DMA。这个切换过程要小心,不能在传输过程中切换。

我的做法是:每次切换从机前,先确认当前传输已完成(TRANS标志置位),然后关闭SPI的DMA请求,重新配置SPI参数和DMA缓冲区地址,再使能DMA和SPI。整个过程用状态机管理,避免在中断里做重配置操作。

7.2 用DMA链表实现多缓冲区管理

如果从机数量多、数据量大,可以考虑用DMA链表(Linked List)模式。GD32的部分型号支持DMA链表,可以在一个DMA通道上挂多个传输描述符,每个描述符对应一个从机的数据缓冲区。DMA自动按链表顺序搬运,搬完一个自动切换到下一个,CPU只需要在传输完成中断里处理数据即可。

这种方式的优点是CPU干预少,适合数据流连续的场景。缺点是配置复杂,调试难度大,建议在单缓冲模式跑通后再尝试。

7.3 从机数量扩展时的总线负载评估

每增加一个从机,SPI总线上的负载就增加一份。负载包括电容负载和协议开销。电容负载过大会导致信号边沿变缓,高速率下波形畸变。协议开销包括片选切换时间、从机响应时间等。

评估总线负载时,我一般会算一笔账:假设每个从机的片选切换需要1微秒,数据传输需要10微秒,那么4个从机轮询一遍需要44微秒。如果系统要求每毫秒完成一轮,那还有充足的余量。但如果从机数量增加到16个,一轮就需要176微秒,接近极限了。

这时候要么提高SPI时钟缩短传输时间,要么减少轮询频率,要么改用其他总线方案。具体怎么选,要看系统的实时性要求。

8. 个人实操体会与后续扩展方向

SPI+DMA这套组合,我前后在五六个项目里用过,从GD32F103到F303再到F4系列都踩过坑。最大的体会是:顺序比配置更重要。DMA通道的使能顺序、SPI的使能时机、中断里的操作顺序,任何一个顺序错了,现象都可能很诡异,而且不容易通过读代码发现。

另一个体会是:不要迷信手册里的典型配置。手册给的是理想情况下的配置,实际项目里PCB走线、电源质量、从机器件特性都会影响结果。我习惯在手册配置的基础上,先用较低速率跑通,再用逻辑分析仪逐项验证时序,确认无误后再逐步提高速率。

后续如果要把这套方案用到更复杂的场景,比如SPI屏幕刷新、高速ADC数据采集,可以考虑几个方向:一是用DMA双缓冲实现零等待的数据流处理;二是结合定时器触发SPI传输,实现精确的采样率控制;三是用DMA传输完成中断驱动状态机,把整个通信流程做成事件驱动架构,进一步降低CPU占用。

最后分享一个小技巧:在调试SPI+DMA时,如果怀疑是DMA搬运的问题,可以先把DMA关掉,用轮询方式跑同样的数据,对比结果。如果轮询正常而DMA异常,问题基本就锁定在DMA配置或时序上了。这个方法帮我省了不少排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询