GD32F103 SPI+DMA从零配置指南:解放CPU的高效数据传输方案

发布时间:2026/9/29 15:44:04
GD32F103 SPI+DMA从零配置指南:解放CPU的高效数据传输方案 最近调了一块GD32F103的板子要从外部SPI Flash里连续读几百字节的数据一开始图省事直接用轮询结果一跑起来CPU就基本被占死了整个系统其他任务全在等。后来把SPI和DMA搭配起来用数据搬运这块彻底解放了CPU传输也稳定了。这篇就专门聊聊GD32F103上SPIDMA这套组合怎么从零配置到稳定传输其中不少坑是我实际踩过的写出来给大家做个参考。这套方案适合谁如果你正从STM32F103迁移到GD32F103、刚接触DMA不知道怎么下手、或者项目里需要大批量通过SPI读取数据外部Flash、ADC采样、显示屏刷新等这篇文章里的代码和思路可以直接抄作业。核心解决一个问题怎么让SPI在搬运大量数据时不占用CPU同时保证数据不错、不丢、不卡死。1. 为什么SPI要配DMA一起用1.1 SPI通信模式下的CPU浪费问题SPI在嵌入式里太常见了外部Flash、SD卡、显示屏、传感器、编码器……很多朋友习惯了写几行轮询代码就开干。但真遇到“一次传几KB、一秒钟传几十次”的场景轮询的代价就暴露出来了。SPI是同步全双工总线主设备发一个字节的同时必然接收一个字节收发是绑在一起的。你要读数据就必须一边发填充字节一边收从机返回的数据。没有DMA的情况下你得不停查询TXE标志发送寄存器空和RXNE标志接收寄存器非空或者开中断一个字节一个字节地处理。SPI时钟跑到9MHz的时候每秒钟会出现超过100万次收发事件哪怕一次中断只花2微秒CPU也有接近2秒被浪费在中断处理上。这还没算上业务逻辑、协议解析、任务调度的时间整个系统直接喘不过气。1.2 轮询、中断、DMA三种模式对比我习惯把SPI数据传输方式分为三档实际选型时按数据量和频率来定模式CPU参与度代码复杂度适合场景典型问题轮询查询全程阻塞最低偶尔传几个字节大块数据时CPU被拖死逐字节中断每字节一次中断中等小数据量、不定长通信高频中断开销大DMA搬运开始和结束时各参与一次较高大块数据、高频传输时序细节需谨慎一句话总结DMA不是魔法它只是把“搬运数据”这个重复劳动外包给了专门的硬件模块但启动和结束仍然需要CPU配置和响应。数据量低于十几个字节的时候DMA的初始化配置开销可能比轮询还大这时候没必要硬上DMA。等数据量到了几百字节以上或者传输频率很高的时候DMA的优势就非常明显了。1.3 DMA到底是怎么工作的DMA直接内存访问本质上是一个专职搬运工。它被外设的硬件事件触发不需要CPU干预直接把数据从内存搬到外设寄存器或者从外设寄存器搬到内存。具体到SPI场景流程是这样的SPI发送寄存器空了硬件自动向DMA发出一个请求DMA收到请求后把内存里预置好的下一个字节写入SPI数据寄存器同时SPI每移出一个字节也从MISO线上移入一个字节接收寄存器非空后触发另一个DMA请求接收DMA把收到的字节搬到接收缓冲区。程序员要安排的只有四件事源地址、目标地址、数据长度、搬运方向。剩下的DMA会自己干活搬完再通过中断通知CPU。2. 方案选型与硬件准备2.1 GD32F103与STM32F103的血缘关系很多朋友是从STM32F103转过来的。GD32F103在引脚排布、片上外设、内存映射上都和STM32F103高度兼容所以整体方案可以直接平移。但这不代表能无脑替换有几个关键差异必须先清楚第一是主频。STM32F103标称最高72MHzGD32F103标称最高108MHz但也有不少工程出于兼容性考虑仍然跑72MHz。要超频的话Flash等待周期、定时器预分频、串口波特率都需要重新算。第二是库函数。GD32使用的是自家的GD32F10x固件库外设寄存器名字、结构体字段、库函数接口和ST标准外设库非常相似但细节上有差异。比如DMA结构体里的长度字段ST叫BufferSizeGD32叫number初始化函数名也变了。直接把ST的工程改个芯片型号编译能过但运行效果不一定对。第三是中断向量和DMA通道映射。GD32的DMA控制器和SPI外设虽然挂在类似的位置但通道编号、中断向量名称都要对着GD32F10x参考手册逐一确认。2.2 硬件片选与软件片选怎么选SPI片选CS/NSS的做法直接影响DMA传输的稳定性我的建议很明确优先用软件片选。用普通GPIO控制片选的好处有三个硬件NSS在单主多从、开放式总线等场景下容易触发模式错误MODF处理麻烦软件片选可以精确控制CS拉低到SCK第一个边沿的时间。很多SPI从机对这个时间有要求软片选最灵活DMA传输过程中片选的拉低和拉高时机需要和SPI完成状态精确配合软件控制最直观。用软件片选时有个细节必须注意SPI主模式如果配置成软件NSS必须把内部SSI位置1否则SPI主模式根本不产生时钟。GD32库函数里一般通过spi_nss_soft之类的调用处理配置寄存器时则是把SPI_CTL0的SSM和SSI都置1。2.3 芯片型号与DMA请求映射确认GD32F103有DMA0和DMA1两组控制器SPI0对应ST的SPI1和SPI1对应ST的SPI2挂在不同通道上。我这次用的SPI0GD32F103参考手册里的DMA请求映射是这样的外设事件DMA通道SPI0发送寄存器空TXDMA0通道3SPI0接收寄存器非空RXDMA0通道2SPI1发送寄存器空TXDMA1通道5SPI1接收寄存器非空RXDMA1通道4特别提醒从SPI0换成SPI1时很多人只改了外设基地址忘了切换DMA通道结果代码怎么调都不出数据。新项目上来第一件事打开参考手册的DMA请求表核对一遍。2.4 硬件准备与测试环境搭建我的测试平台很简单GD32F103C8T6最小系统板一块W25Q64 SPI Flash模块一个逻辑分析仪一台接线如下PA5SCKPA6MISOPA7MOSIPA4CS软件控制烧录用ST-Link或者DAP-Link都可以GD32F103的SWD接口和ST兼容。逻辑分析仪不用太贵几十块的就够用后面排查时序问题全靠它。3. SPIDMA初始化与驱动实现3.1 时钟与GPIO初始化下面代码以GD32F10x固件库为例不同版本的库函数名可能有差异重点看配置思路。void bsp_spi_gpio_init(void) { rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_AF); rcu_periph_clock_enable(RCU_SPI0); rcu_periph_clock_enable(RCU_DMA0); /* CS引脚普通推挽输出空闲保持高电平 */ gpio_init(GPIOA, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_4); gpio_bit_set(GPIOA, GPIO_PIN_4); /* SCK、MOSI复用推挽输出 */ gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_5 | GPIO_PIN_7); /* MISO浮空输入 */ gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_6); }MISO为什么用浮空输入因为大多数SPI从机模块的MISO引脚驱动能力足够不需要内部上拉。如果板子走线很长、或者从机是开漏输出再根据情况改成上拉输入。GPIO速度这里选50MHz其实对SPI 9MHz时钟来说是足够的不要为了信号完整性盲目降低速度等级。3.2 SPI外设参数配置的五个关键选项SPI初始化看起来简单但几个参数直接决定通信能不能通传输模式全双工SPI的本质就是同时收发设备模式主模式帧格式8位数据帧绝大多数从机都是这个格式时钟极性相位约定好模式0还是模式3我习惯用模式3CPOL1CPHA1多数Flash和传感器芯片都支持波特率分频我选择8分频72MHz的APB2时钟下SPI时钟为9MHz既保证了速度又给信号完整性留足了余量。spi_parameter_struct spi_init_struct; spi_struct_para_init(spi_init_struct); spi_init_struct.trans_mode SPI_TRANSMODE_FULLDUPLEX; spi_init_struct.device_mode SPI_MASTER; spi_init_struct.frame_size SPI_FRAMESIZE_8BIT; spi_init_struct.clock_polarity_phase SPI_CK_PL_HIGH_PH_2EDGE; /* 模式3 */ spi_init_struct.nss SPI_NSS_SOFT; /* 软件片选 */ spi_init_struct.prescale SPI_PSC_8; /* 72MHz / 8 9MHz */ spi_init_struct.endian SPI_ENDIAN_MSB; spi_init(SPI0, spi_init_struct); spi_enable(SPI0);这里有个我踩过的坑spi_struct_para_init一定要在spi_init之前调用把结构体所有字段清零。如果从别的工程复制代码遗漏某个字段SPI的某个参数就会沿用上次的残留值出现很隐蔽的偶发通信错误。3.3 DMA通道初始化与长度配置DMA要配置两路TX方向从内存搬运到SPI数据寄存器RX方向从SPI数据寄存器搬运到内存。GD32F103的DMA配置结构体字段和ST有差异我在代码里注释了关键点。发送DMAdma_parameter_struct dma_tx_struct; dma_deinit(DMA0, DMA_CH3); dma_struct_para_init(dma_tx_struct); dma_tx_struct.direction DMA_MEMORY_TO_PERIPHERAL; dma_tx_struct.periph_addr (uint32_t)SPI_DATA(SPI0); /* SPI数据寄存器地址 */ dma_tx_struct.memory_addr (uint32_t)tx_buf; /* 内存源地址 */ dma_tx_struct.periph_inc DMA_PERIPH_INCREASE_DISABLE; /* 外设地址不递增 */ dma_tx_struct.memory_inc DMA_MEMORY_INCREASE_ENABLE; /* 内存地址递增 */ dma_tx_struct.periph_memory_width DMA_PERIPHERAL_WIDTH_8BIT; dma_tx_struct.number len; /* 搬运字节数 */ dma_tx_struct.priority DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH3, dma_tx_struct); dma_circulation_disable(DMA0, DMA_CH3); dma_interrupt_enable(DMA0, DMA_CH3, DMA_INT_FTF);接收DMA的配置几乎一样只有方向反过来了dma_parameter_struct dma_rx_struct; dma_deinit(DMA0, DMA_CH2); dma_struct_para_init(dma_rx_struct); dma_rx_struct.direction DMA_PERIPHERAL_TO_MEMORY; dma_rx_struct.periph_addr (uint32_t)SPI_DATA(SPI0); dma_rx_struct.memory_addr (uint32_t)rx_buf; dma_rx_struct.periph_inc DMA_PERIPH_INCREASE_DISABLE; dma_rx_struct.memory_inc DMA_MEMORY_INCREASE_ENABLE; dma_rx_struct.periph_memory_width DMA_PERIPHERAL_WIDTH_8BIT; dma_rx_struct.number len; dma_rx_struct.priority DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH2, dma_rx_struct); dma_circulation_disable(DMA0, DMA_CH2); dma_interrupt_enable(DMA0, DMA_CH2, DMA_INT_FTF);GPIO模式的选择注意MISO是浮空输入SCK和MOSI是复用推挽CS是普通推挽。这个细节一旦错了SPI可能完全没输出或者输入异常。SG。关于GD32的DMA循环模式普通Normal模式传完指定长度就停适合一次性的SPI读写Circular模式会循环搬运适合持续数据流比如SPI从机连续输出数据、或者PWMDMA控制LED这类场景。但循环模式下要自己对半中断和全中断做处理否则数据覆盖会让你焦头烂额。普通SPI Flash读写用Normal模式就够了。3.4 DMA中断与NVIC配置DMA传完数据必须通知CPU所以NVIC要配好。一个技巧是SPI全双工下发送长度和接收长度一致所以接收DMA完成中断基本代表了整轮传输结束发送DMA中断可以不进省一点中断处理时间。nvic_irq_enable(DMA0_Channel2_IRQn, 0, 0); /* 接收DMA中断 */ nvic_irq_enable(DMA0_Channel3_IRQn, 0, 0); /* 发送DMA中断可选 */中断优先级这里如果你项目里跑了FreeRTOS要注意把DMA中断优先级设得比configMAX_SYSCALL_INTERRUPT_PRIORITY数值更低即实际优先级更高并且中断服务函数里别调用普通的系统API要用带FromISR后缀的版本否则很容易死机。3.5 传输启动函数的正确顺序这是整个DMA配置里最容易出问题的地方。很多人习惯先把CS拉低再使能DMA结果发现某些从机第一次通信就失败。原因在于CS拉低后从机就开始等待时钟如果此时DMA还没准备好SCK迟迟不来部分从机直接进入异常状态。一个稳妥的启动顺序是这样的void spi_dma_transfer(uint8_t* tx_buf, uint8_t* rx_buf, uint16_t len) { /* 1. 重新配置DMA长度和地址 */ dma_memory_address_config(DMA0, DMA_CH3, DMA_MEMORY_INCREASE_ENABLE, DMA_PERIPHERAL_WIDTH_8BIT, (uint32_t)tx_buf); dma_memory_address_config(DMA0, DMA_CH2, DMA_MEMORY_INCREASE_ENABLE, DMA_PERIPHERAL_WIDTH_8BIT, (uint32_t)rx_buf); dma_transfer_number_config(DMA0, DMA_CH3, len); dma_transfer_number_config(DMA0, DMA_CH2, len); /* 2. 清标志位 */ dma_flag_clear(DMA0, DMA_CH3, DMA_INT_FLAG_FTF); dma_flag_clear(DMA0, DMA_CH2, DMA_INT_FLAG_FTF); /* 3. 先使能接收DMA再使能发送DMA */ dma_channel_enable(DMA0, DMA_CH2); dma_channel_enable(DMA0, DMA_CH3); /* 4. 最后拉低片选开始传输 */ gpio_bit_reset(GPIOA, GPIO_PIN_4); }每次传输前重新设置DMA长度这一步不能省。GD32的DMA在Normal模式下传输完成后计数器归零如果不重新配置长度下一次传输长度就是0通道一旦使能就立即触发完成中断但实际没有任何数据搬动。4. 一个完整的稳定传输Demo4.1 方案构思批量读取外部SPI Flash空讲原理没意思我拿W25Q64当从机写一个真实场景通过SPIDMA读取Flash的256字节数据。W25Q64的读数据命令是0x03后面跟24位地址然后主机持续发送任意字节通常填0x00充当时钟从机就会在MISO上返回数据。如果是轮询代码得在发送命令和地址后一个字节一个字节地收现在用DMA直接把“命令地址连续0x00”拼成一个发送缓冲区接收缓冲区同样大小一次DMA传输解决整轮读取。#define FLASH_READ_SIZE 256 #define TX_BUF_SIZE (4 FLASH_READ_SIZE) /* 命令1字节 地址3字节 数据256字节 */ uint8_t tx_buf[TX_BUF_SIZE]; uint8_t rx_buf[TX_BUF_SIZE]; void flash_read_data_dma(uint32_t addr) { tx_buf[0] 0x03; tx_buf[1] (addr 16) 0xFF; tx_buf[2] (addr 8) 0xFF; tx_buf[3] addr 0xFF; memset(tx_buf[4], 0x00, FLASH_READ_SIZE); spi_dma_transfer(tx_buf, rx_buf, TX_BUF_SIZE); /* 传输完成后有效数据在 rx_buf[4] 到 rx_buf[259] */ }就这样一个CS低电平周期内同时完成命令发送和数据接收发送和接收由DMA并行搬运CPU只负责拼一下缓冲区。4.2 从触发传输到中断回调的完整时序我习惯用接收DMA完成中断作为整轮传输的结束信号。初始化完成后整个流程是这样的spi_dma_transfer函数重新配置DMA使能接收DMA、发送DMA拉低CSSPI硬件在发送DMA驱动下产生SCK时钟把0x03和3字节地址移出去同时从MISO接收到4字节无意义数据发送DMA继续搬运填充的0x00从机开始返回256字节有效数据接收DMA同步搬运发送DMA计数到0接收DMA也恰好收到260字节触发DMA0通道2完成中断中断服务函数里等待SPI的BSY标志清0拉高CS清除DMA标志位设置全局标志主循环检测到全局标志后从rx_buf[4]开始处理数据。第5步里“等待BSY清0”特别重要。DMA把最后一个字节搬进内存时SPI移位寄存器可能还在移出最后一个bit此时如果立刻拉高CS接收到的最后一个字节就可能不完整或者直接丢失。我一般会用一个带超时的循环void spi_dma_irq_handler(void) { if(dma_interrupt_flag_get(DMA0, DMA_CH2, DMA_INT_FLAG_FTF) ! RESET) { dma_interrupt_flag_clear(DMA0, DMA_CH2, DMA_INT_FLAG_FTF); dma_channel_disable(DMA0, DMA_CH2); dma_channel_disable(DMA0, DMA_CH3); /* 等待SPI忙标志清除超时保护防止卡死 */ uint32_t timeout 0xFFFF; while((spi_flag_get(SPI0, SPI_FLAG_BSY) ! RESET) (timeout-- 0)); gpio_bit_set(GPIOA, GPIO_PIN_4); /* 拉高CS */ dma_flag_clear(DMA0, DMA_CH3, DMA_INT_FLAG_FTF); g_spi_dma_done 1; } }这个超时循环不能省。万一从机没有正确响应SPI的BSY标志可能一直不清晰没有超时就直接卡死在中断里。4.3 D:\实际项目中的补充小数据量传输不要迷信DMA有些朋友问我项目里读一个MT6701磁编码器角度值是不是也该用SPIDMA我的回答是没必要。MT6701通过SPI读角度通常只要2~4个字节这时候DMA初始化、中断处理、标志清除的代码开销比普通轮询还大纯属杀鸡用牛刀。DMA适合的是“单次传输数据量大”或者“传输频率高”的场景。如果一次就传几个字节老老实实用轮询或单字节中断更实际。判断标准很简单算一下每秒钟要传多少字节如果每秒不超过几百字节轮询完全够用。4.4 实测DMA到底省了多少CPU我用逻辑分析仪和计数器实测过SPI时钟9MHz时传输256字节需要约227微秒。轮询模式每个字节都要等TXE、RXNE标志加上循环判断开销整个过程大概500~800微秒期间CPU完全被占住没法处理其他任务DMA模式CPU只在启动前花几微秒配置DMA传输期间完全可以去处理其他任务或进入低功耗完成中断后直接取数据。对没有实时要求的应用来说这点差别只是“卡顿”和“流畅”的区别对实时性要求高的应用DMA就是刚需。这也是为什么很多工业通信、电机控制项目里串口、SPI、CAN都要上DMA的原因。5. 稳定性经验与问题排查实录5.1 坑一接收DMA完成数据却全是0xFF这是SPI通信最常见的问题现象很典型从机明明有数据返回但rx_buf里全是0xFF。排查顺序应该是这样的第一步检查CS波形。用逻辑分析仪看CS是否正常拉低整个传输期间有没有误拉高。CS没拉低从机不可能响应。第二步检查SPI模式。时钟极性和相位不匹配MISO上采到的全是无效电平读回0xFF很正常。W25Q64这类芯片一般支持模式0和模式3但你得先确认从机手册不要想当然。第三步检查接线。MISO和MOSI接反了、模块没共地都会导致全0xFF。我的建议是调DMA之前先用轮询方式把SPI调通。轮询都读不对别急着上DMA否则问题叠加在一起排查难度翻倍。5.2 坑二第二次传输卡死DMA不触发第一次传输好好的第二次就卡死这是DMA项目里的高发问题。根据我自己的经验原因就三个第一DMA完成标志没清。我见过很多代码中断里光处理数据忘了清除中断标志导致下一次传输完成中断根本不触发。GD32里就是dma_flag_clear每次进中断必须清而且在使能通道前再清一次更保险。第二DMA长度没重新设置。前面说过GD32的Normal模式传完一次计数归零不重新设置number第二次传输长度就是0。把“清标志、重设长度、使能通道”封装到同一个传输函数里能有效避免这种问题。第三上一轮DMA通道没完全禁止。如果上一次传输因为异常中断DMA通道还处于使能状态SPI的请求一出现就会触发新的DMA传输数据和长度完全错乱。我一般在进入下一次传输前会把发送和接收DMA通道都dma_channel_disable一次。还有一些工程里会看到“continuous requests”这个选项。它控制DMA请求在SPI外设上的持续方式普通单次传输用Normal模式就够了没必要开。开了之后DMA请求和外设时序容易对不上反而诱发卡死。5.3 坑三SPI时钟太快信号畸变导致偶发错位现象是大部分数据都对偶尔错一个字节或者整体错一位。这种偶发问题用逻辑分析仪抓半天往往抓不到因为它和硬件环境强相关。我当时用的杜邦线比较长SCK频率升到18MHz后信号反射和振铃相当明显MISO线上的数据沿抖动严重。解决办法很粗暴把SPI分频从4改成8时钟降到9MHz问题立刻消失。所以如果你的SPI时钟不是特别吃紧优先保证信号完整性。板内走线短、阻抗控制好可以追求高频飞线调试阶段稳妥第一。5.4 调试利器逻辑分析仪看时序调SPIDMA逻辑分析仪是最好的老师。我主要看三个地方一是CS拉低到SCK第一个上升沿的时间。这个时间太短从机来不及准备太长从机可能判断出错。不同从机要求不同通常在几十到几百纳秒之间。二是SCK的连续性。轮询模式下如果SPI服务被高优先级中断打断SCK波形中间会出现明显gap。DMA模式下SCK应该是均匀连续的如果有gap说明DMA仲裁存在问题或者DMA通道之间的优先级冲突。三是MISO的采样点。看数据在SCK采样沿上是否稳定如果跨越了采样沿就是时序余量不够。调试技巧方面我习惯在DMA完成中断里翻转一个GPIO用示波器看这个GPIO的翻转频率就知道DMA是否在持续工作、每秒完成多少次传输。比打印日志要高效得多。5.5 问题定位速查表故障现象可能原因处理建议数据全是0xFFCS未拉低、SPI模式不对、接线错误先轮询调通再查CS波形第一次好后卡死DMA标志未清、长度未重设清标志重设长度封装成同一函数偶发错字节信号完整性问题、时序余量不足降SPI分频、缩短杜邦线接收数据少一段CS提前拉高、SPI移位未完成等BSY清0后再拉高CSDMA中断不进NVIC配置错误、DMA中断使能缺失核对DMA通道对应的IRQnFreeRTOS下死机中断优先级设置不当DMA中断优先级要高于系统调用上限最后分享一点个人调试体会刚开始调GD32F103的SPIDMA时我也犯过懒直接拿STM32的例程改一改就烧进去结果代码编译通过、运行却不正常查了一下午才发现GD32库的DMA结构体字段和ST不一样再加上主频、GPIO速度等差异光靠经验主义很容易翻车。后来老老实实对着GD32F10x参考手册把DMA请求表、SPI寄存器捋了一遍又把“复位DMA状态→重新配置长度→使能通道→拉低CS”这个启动顺序固定成模板之后项目里所有SPI外设都复用这套代码稳定性再也没出过问题。如果你接下来准备在自己项目里上这套方案我的建议是先用轮询把SPI从机读通再接DMA手边备一个逻辑分析仪关键时序一目了然遇到问题按“先电气层、再协议层、最后查DMA状态”的顺序排查。这套思路不光GD32F103能用换到GD32F303、GD32E103甚至移回STM32都是同样的套路。