ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

GD32F103 SPI+DMA实战:从轮询到DMA的配置与避坑指南

GD32F103 SPI+DMA实战:从轮询到DMA的配置与避坑指南 做驱动或者数据采集的同学应该都有过这种经历主控明明跑得挺快却被SPI的收发拖住一个字节一个字节地等标志位。我去年做一块采集板主控用的是GD32F103C8T6板上同时挂了SPI Flash和一颗SPI接口的传感器。第一版代码图省事SPI的读写全部用轮询跑起来才发现问题CPU大部分时间都耗在检查SPI状态上页面刷新、传感器采样、通信协议处理全部挤在一起时序稍微一紧张就开始出错。后来把SPI的数据搬运全部切到DMA同样的主频、同样的SPI速率CPU压力肉眼可见地降了下来。这篇就把从零配置GD32F103的SPIDMA到最终稳定传输的过程完整写一遍顺带记录几个不翻手册根本想不通的坑。先说明一点GD32F103的库函数命名和外设细节与常见的STM32F103标准库有差异很多人第一次切过来会卡在库函数名上。但这颗芯片本身的内核和外设思路是成熟的只要把DMA的请求映射、SPI的时序参数和传输完成后的收尾工作理清楚跑起来并不难。下面每一步我都会解释为什么这样配置而不是只丢一段代码。1. 轮询方式把我卡怕了SPIDMA解决的核心问题1.1 一次SPI轮询传输CPU到底在等什么SPI是一种主从同步协议主机提供SCK时钟每来一个时钟沿MOSI上发一位数据同时MISO上收一位数据。GD32F103的SPI外设内部有发送缓冲区和接收缓冲区CPU往SPI_DATA寄存器写一个字节硬件会并行完成8个SCK周期的移位输出读SPI_DATA寄存器则拿到从设备在MISO上送回的数据。轮询方式的瓶颈就在这里。发送一个字节的流程是CPU等待TBE标志置位写入数据然后再次等待TBE写下一个字节。接收一个字节的流程是CPU等待RBNE标志置位读取数据。在SPI速率9MHz时一个字节大约需要0.89微秒看起来很短但要连续传输4KB数据累计就是接近3.6毫秒。这3.6毫秒内CPU不能做任何其他事情只能不断查询标志位、写寄存器、读寄存器。听起来3.6毫秒也不算夸张在裸机里确实能忍但放到实际项目里采集板可能同时还要做定时器采样、LED驱动、串口日志、按键扫描这些任务叠加在一起CPU的可用时间被SPI轮询吃掉一大块。更难受的是如果从设备的响应时间不稳定轮询代码里还要塞超时判断整个程序的可读性和维护性都会下降。1.2 DMA介入以后运行逻辑发生了什么变化DMADirect Memory Access的作用是替CPU完成数据搬运。配置好通道后SPI外设每产生一个发送请求DMA就会自动把内存里的下一个字节写入SPI_DATA每收到一个字节DMA又会自动把SPI_DATA里的数据搬到内存缓冲区。整个过程不经过CPUCPU只需要在传输开始前把内存地址、传输数量和方向告诉DMA在传输结束后去检查结果。从代码结构上看变化是根本性的。轮询时代是一个字节一个字节地“挤牙膏”DMA时代是一次性把整块缓冲区交出去CPU可以在DMA搬运期间去处理其他任务。如果配合DMA传输完成中断CPU甚至可以在搬运结束后才收到一个轻量通知整个传输过程对主程序来说几乎是透明的。1.3 GD32F103上的DMA资源和SPI通道映射GD32F103内部有DMA0和DMA1两个DMA控制器。DMA0有多个通道DMA1也有多个通道每个通道可以关联不同的外设请求。使用SPI0时默认的映射关系是SPI0_TX对应DMA0的通道3SPI0_RX对应DMA0的通道4。使用SPI1时发送和接收对应的则是DMA1的通道0和通道1。这个映射关系必须从芯片手册里的“DMA请求映射表”确认不同系列、不同封装的芯片可能会有差异。我第一次用GD32F103时想当然地照搬了其他平台的通道号结果SPI数据根本没反应折腾了半天才发现是通道对应错了。2. 动手前的四张表引脚、时钟、映射和帧格式2.1 GPIO复用配置和NSS的不同玩法GD32F103的SPI0默认引脚是PA4NSS、PA5SCK、PA6MISO、PA7MOSI。SCK、MOSI要配置为复用推挽输出MISO配置为浮空输入。很多人习惯把所有SPI引脚一股脑配成复用推挽结果MISO读回来全是错的因为输入引脚被强行配成了输出模式。NSS的处理是个关键点。GD32F103支持硬件NSS和软件NSS两种模式。硬件NSS模式下SPI外设会在传输开始和结束时自动驱动NSS引脚看起来省心但实际使用中尤其在多从机共用一个SPI总线的场景下硬件NSS的时序很难精确匹配DMA的完成时机。软件NSS模式把NSS引脚当成普通GPIO来拉高拉低传输前手动拉低CS传输完全结束后再拉高时序完全可控。我的建议是除非你对硬件NSS的自动时序非常熟悉否则一律用软件NSS尤其是刚开始调DMA的时候。需要特别提醒的是NSS引脚在软件NSS模式下要配置成通用推挽输出千万不要配成复用推挽否则SPI外设会接管这个引脚软件拉高拉低根本不生效。2.2 APB1/APB2分频对SPI波特率的直接影响GD32F103的时钟树并不复杂但很多人容易忽略APB1和APB2的分频差异。外部8MHz晶振经过PLL倍频到72MHz后AHB总线是72MHzAPB1总线最大36MHzAPB2总线最大72MHz。SPI0挂在APB2总线上SPI1挂在APB1总线上。SPI波特率的计算公式是SPI波特率 PCLK / (2 * prescale)其中prescale是SPI控制寄存器里的分频系数最小是2。所以SPI0在72MHz的APB2时钟下prescale取2时波特率是18MHz而SPI1在36MHz的APB1时钟下prescale取2时波特率只有9MHz。如果你要把SPI速率推到18MHz甚至更高就必须用SPI0。我在实际项目里把SPI0配成9MHz到18MHz之间使用PCB走线质量好的情况下波形很干净。但如果从设备不支持这么高的速率配置再高也没用最终速率要由SPI总线上最慢的那颗设备决定。2.3 SPI0与SPI1对应的DMA通道这块是很容易踩坑的地方。GD32F103的DMA0与DMA1请求映射表里SPI0_TX对应DMA0_CH3SPI0_RX对应DMA0_CH4SPI1_TX对应DMA1_CH0SPI1_RX对应DMA1_CH1。我在项目里使用的是SPI0所以下面的代码都基于DMA0_CH3和DMA0_CH4来写。注意DMA通道和外设的对应关系不是一成不变的换芯片型号后一定要重新查表。之前帮朋友调过一块板子他把GD32F103的代码原封不动移植到另一个系列上SPI死活不工作最后查手册发现DMA请求映射变了。查表这个动作虽然枯燥但十分钟能省半天的调试时间。2.4 帧大小、CPOL/CPHA要从从机手册里找SPI通信的帧大小可以配成8位或16位具体取决于从设备。最常见的是8位帧一次收发一个字节。如果从机是16位寄存器结构比如某些传感器芯片就要把SPI配成16位帧同时DMA的外设宽度和内存宽度也要对应改成16BIT。CPOL时钟极性和CPHA时钟相位决定了SCK空闲电平以及数据采样沿。这两项没有任何猜测空间必须查从设备的数据手册。配错了的表现是波形能出来但数据要么全错要么偶发错位。我有一次就是因为把CPOL配反了波形看起来完全正常但读回来的数据每个字节都是反的后来用逻辑分析仪对照手册才定位到问题。3. 配置代码逐段拆解从时钟到DMA通道完整走一遍3.1 时钟和引脚先把电通起来初始化第一件事是开时钟。GD32F103的库函数里GPIO、SPI、DMA分别属于不同的时钟域。以下代码开启了GPIOA、复用功能、SPI0和DMA0的时钟rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_AF); rcu_periph_clock_enable(RCU_SPI0); rcu_periph_clock_enable(RCU_DMA0);有些库版本里复用功能时钟宏可能叫RCU_AFIO以你手头GD32F10x固件库头文件为准。然后是GPIO初始化/* SCK(PA5)和MOSI(PA7)设为复用推挽MISO(PA6)设为浮空输入 */ gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_5 | GPIO_PIN_7); gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_6); /* CS使用PA4软件NSS配置为普通推挽输出 */ gpio_init(GPIOA, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_4); gpio_bit_set(GPIOA, GPIO_PIN_4); /* CS默认拉高 */这里有两个细节。第一GPIO速度一定要配成50MHzSPI速率在10MHz以上时如果GPIO速度等级太低上升沿会变得平缓容易引入误码。第二CS作为软件NSS引脚配置成普通推挽输出就够了。3.2 SPI外设参数一次配对的注意事项SPI外设初始化可以直接使用GD32F10x标准库里的结构体。下面是一份最常用的主机全双工8位帧配置spi_parameter_struct spi_init_para; spi_init_para.device_mode SPI_MASTER; spi_init_para.trans_mode SPI_TRANSMODE_FULLDUPLEX; spi_init_para.frame_size SPI_FRAMESIZE_8BIT; spi_init_para.nss SPI_NSS_SOFT; spi_init_para.endian SPI_ENDIAN_MSB; spi_init_para.clock_polarity_phase SPI_CK_PL_LOW_PH_1ST; spi_init_para.prescale SPI_PSC_8; /* 72MHz / (2*8) 4.5MHz */ spi_init(SPI0, spi_init_para);prescale的值要根据从设备支持的速率来选。如果你的Flash或者传感器最高支持到50MHz分频系数取2就能到18MHz但我个人建议先保守一点用4.5MHz或者9MHz把功能调通再往上拉速率。CPOL和CPHA按从机手册来不是所有设备都是模式0。还有一个容易漏掉的操作NSS模式选软件后SPI控制寄存器里要手动把NSS驱动位拉高让内部NSS处于非激活状态。GD32F10x库的spi_init函数内部会根据SPI_NSS_SOFT配置自动处理这一点不用额外写代码但如果是直接操作寄存器的方式初始化很多人会漏掉NSSDR位的设置。3.3 DMA收发通道初始化顺序有讲究DMA初始化是整个配置里最容易出错的部分。先看发送通道dma_parameter_struct dma_tx_para; dma_deinit(DMA0, DMA_CH3); dma_tx_para.direction DMA_MEMORY_TO_PERIPHERAL; dma_tx_para.periph_addr (uint32_t)SPI0-DATA; dma_tx_para.periph_width DMA_PERIPHERAL_WIDTH_8BIT; dma_tx_para.memory_addr (uint32_t)tx_buffer; dma_tx_para.memory_width DMA_MEMORY_WIDTH_8BIT; dma_tx_para.number 0; dma_tx_para.priority DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH3, dma_tx_para); dma_circulation_disable(DMA0, DMA_CH3); dma_memory_to_memory_disable(DMA0, DMA_CH3);接收通道类似差别在direction是DMA_PERIPHERAL_TO_MEMORY并且通道号是DMA0_CH4。两个通道初始化完成后需要单独调用spi_dma_enable来打开SPI向DMA发出请求的开关spi_dma_enable(SPI0, SPI_DMA_TRANSMIT); spi_dma_enable(SPI0, SPI_DMA_RECEIVE);这一步很多人会漏。SPI外设的DMA请求如果不使能就算DMA通道配置得再完美SPI也不会主动通知DMA去搬运数据。另外要注意dma_deinit会把DMA通道的相关寄存器全部恢复成默认值所以初始化顺序建议是先deinit再配置参数再确认关闭循环模式和内存到内存模式。有个代码层面的小细节dma_parameter_struct里的number字段我建议一开始填0真正发起传输时再通过dma_transfer_number_config去设置。原因是DMA通道一旦使能寄存器里number的值会随着传输递减如果把一个固定值放在初始化结构体里第二次传输时很容易忘记重新装载。最后再使能SPI外设spi_enable(SPI0);3.4 一次完整传输的启动流程配置好SPI和DMA后发起一次完整传输的流程是这样的void spi_dma_transfer(uint8_t *tx, uint8_t *rx, uint16_t len) { if (len 0) { return; } /* 先失能DMA通道重新配置内存地址和数量才会生效 */ dma_channel_disable(DMA0, DMA_CH3); dma_channel_disable(DMA0, DMA_CH4); /* 清除上一次传输遗留的完成标志 */ dma_flag_clear(DMA0, DMA_CH3, DMA_INT_FLAG_FTF); dma_flag_clear(DMA0, DMA_CH4, DMA_INT_FLAG_FTF); /* 重新装载内存地址和数据长度 */ dma_memory_address_config(DMA0, DMA_CH3, DMA_MEMORY_TO_PERIPHERAL, (uint32_t)tx); dma_transfer_number_config(DMA0, DMA_CH3, len); dma_memory_address_config(DMA0, DMA_CH4, DMA_PERIPHERAL_TO_MEMORY, (uint32_t)rx); dma_transfer_number_config(DMA0, DMA_CH4, len); /* 拉低CS选中从设备 */ gpio_bit_reset(SPI_CS_PORT, SPI_CS_PIN); /* 先启动接收DMA再启动发送DMA */ dma_channel_enable(DMA0, DMA_CH4); dma_channel_enable(DMA0, DMA_CH3); /* 等待发送和接收DMA都完成 */ while (RESET dma_flag_get(DMA0, DMA_CH3, DMA_INT_FLAG_FTF)); while (RESET dma_flag_get(DMA0, DMA_CH4, DMA_INT_FLAG_FTF)); /* 等待SPI总线真正空闲后再拉高CS */ while (SET spi_i2s_flag_get(SPI0, SPI_FLAG_TRANS)); gpio_bit_set(SPI_CS_PORT, SPI_CS_PIN); dma_flag_clear(DMA0, DMA_CH3, DMA_INT_FLAG_FTF); dma_flag_clear(DMA0, DMA_CH4, DMA_INT_FLAG_FTF); }这段代码里最关键的是启动顺序。全双工模式下发送端DMA启动后SPI会立刻开始产生SCK时钟第一个字节的发送和接收是同时发生的。如果接收DMA还没有就绪第一个接收字节很可能丢失或者被覆盖。所以稳妥的做法是先使能接收DMA再使能发送DMA。3.5 完整的初始化代码与调用示例把上述内容整合成一份可用的初始化函数static uint8_t tx_buffer[1024]; static uint8_t rx_buffer[1024]; void bsp_spi_dma_init(void) { spi_parameter_struct spi_init_para; dma_parameter_struct dma_tx_para; dma_parameter_struct dma_rx_para; /* 使能时钟 */ rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_AF); rcu_periph_clock_enable(RCU_SPI0); rcu_periph_clock_enable(RCU_DMA0); /* GPIO */ gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_5 | GPIO_PIN_7); gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_6); gpio_init(GPIOA, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_4); gpio_bit_set(GPIOA, GPIO_PIN_4); /* SPI0初始化 */ spi_init_para.device_mode SPI_MASTER; spi_init_para.trans_mode SPI_TRANSMODE_FULLDUPLEX; spi_init_para.frame_size SPI_FRAMESIZE_8BIT; spi_init_para.nss SPI_NSS_SOFT; spi_init_para.endian SPI_ENDIAN_MSB; spi_init_para.clock_polarity_phase SPI_CK_PL_LOW_PH_1ST; spi_init_para.prescale SPI_PSC_8; spi_init(SPI0, spi_init_para); /* DMA发送通道 SPI0_TX - DMA0_CH3 */ dma_deinit(DMA0, DMA_CH3); dma_tx_para.direction DMA_MEMORY_TO_PERIPHERAL; dma_tx_para.periph_addr (uint32_t)SPI0-DATA; dma_tx_para.periph_width DMA_PERIPHERAL_WIDTH_8BIT; dma_tx_para.memory_addr (uint32_t)tx_buffer; dma_tx_para.memory_width DMA_MEMORY_WIDTH_8BIT; dma_tx_para.number 0; dma_tx_para.priority DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH3, dma_tx_para); dma_circulation_disable(DMA0, DMA_CH3); dma_memory_to_memory_disable(DMA0, DMA_CH3); /* DMA接收通道 SPI0_RX - DMA0_CH4 */ dma_deinit(DMA0, DMA_CH4); dma_rx_para.direction DMA_PERIPHERAL_TO_MEMORY; dma_rx_para.periph_addr (uint32_t)SPI0-DATA; dma_rx_para.periph_width DMA_PERIPHERAL_WIDTH_8BIT; dma_rx_para.memory_addr (uint32_t)rx_buffer; dma_rx_para.memory_width DMA_MEMORY_WIDTH_8BIT; dma_rx_para.number 0; dma_rx_para.priority DMA_PRIORITY_HIGH; dma_init(DMA0, DMA_CH4, dma_rx_para); dma_circulation_disable(DMA0, DMA_CH4); dma_memory_to_memory_disable(DMA0, DMA_CH4); /* 使能SPI的DMA请求最后使能SPI */ spi_dma_enable(SPI0, SPI_DMA_TRANSMIT); spi_dma_enable(SPI0, SPI_DMA_RECEIVE); spi_enable(SPI0); }调用方式可以用读取SPI Flash的JEDEC ID来验证uint8_t cmd[4] {0x9F, 0x00, 0x00, 0x00}; uint8_t id[4] {0x00, 0x00, 0x00, 0x00}; spi_dma_transfer(cmd, id, 4); /* id[1]为厂家IDid[2]、id[3]为设备ID */这里能看出SPI全双工的特性要读取数据主机必须同时发送命令和填充字节。cmd数组里前一个字节是命令后面几个0xff或者0x00是为了给SCK提供时钟周期让从设备有机会把数据移出来。4. 稳定传输的隐性边界光有DMA还不够4.1 DMA完成标志和SPI_BUSY是两回事DMA传输完成标志表示“DMA已经把最后一个字节交给了SPI的数据寄存器”但SPI的移位寄存器可能还在工作SCK上可能还有最后几个时钟沿没有走完。如果此时立刻拉高CS或者立刻去操作从设备就有可能在最后一拍上出错。所以我在传输函数里专门加了一步等待while (SET spi_i2s_flag_get(SPI0, SPI_FLAG_TRANS));这一步等待SPI总线上所有移位操作真正结束。对于高速SPI这个时间窗口可能只有几十纳秒到几百纳秒但正确处理和忽略处理决定了一套代码在极端情况下稳定不稳定。如果你要做高可靠性的传输这一行不能省。4.2 软件片选配合DMA的时序控制软件NSS模式下CS引脚完全由软件控制所以拉低CS和启动DMA之间、等待DMA完成和拉高CS之间都有明确的时序关系。最安全的套路是先拉低CS再启动DMA传输完全结束后再拉高CS。如果CS拉低太晚从设备可能只在CS有效期间的部分时钟上工作数据就错位了。如果是硬件NSS模式SPI外设会在传输开始时自动拉低NSS结束后自动拉高看起来不需要软件干预。但硬件NSS在DMA连续传输多个block时CS拉高拉低的时机不一定符合某些从设备的需求。比如有些Flash要求CS在一次完整指令执行过程中必须保持低电平如果硬件NSS在DMA每个block之间自动翻转就会出问题。所以我个人的经验是能软件NSS就软件NSS它对时序的控制最直接。硬件NSS更适合那些从设备手册明确要求“CS由硬件自动控制”的特殊场景。4.3 中断里重启DMA的连环坑用中断方式做DMA传输时有一个反复出现的坑DMA完成中断发生后如果直接重新配置通道不先disable新的内存地址和传输数量可能不生效。因为在DMA通道使能状态下很多配置寄存器被锁定必须先把通道关闭再修改配置最后重新使能。正确的中断收尾大概是这个模式void DMA0_Channel3_IRQHandler(void) { if (dma_interrupt_flag_get(DMA0, DMA_CH3, DMA_INT_FLAG_FTF) SET) { dma_interrupt_flag_clear(DMA0, DMA_CH3, DMA_INT_FLAG_FTF); /* 关CS、做业务处理、下次传输前先disable通道 */ dma_channel_disable(DMA0, DMA_CH3); dma_channel_disable(DMA0, DMA_CH4); /* 重新配置地址和数量后重新使能 */ } }另外还有一个容易忽略的点DMA的FTF完成标志如果不手动清除下次使能通道时可能产生一个虚假的完成事件。很多“第二次传输直接乱码”的bug根源就是上一条传输的FTF标志没有清干净。4.4 数据缓冲区对齐和设备时序极限GD32F103没有复杂的Cache一般不存在缓存一致性问题但DMA对内存地址的对齐要求还是要遵守。8位宽度的DMA没有对齐要求16位宽度要求地址2字节对齐32位宽度要求4字节对齐。如果你的SPI配成16位帧DMA内存宽度是16BIT那缓冲区地址至少要2字节对齐。全局数组默认对齐通常没问题但如果你用了栈上的局部数组就有风险。稳妥做法是用全局数组必要时手动加对齐属性。从设备本身的时序极限也是一个隐性边界。SPI速率提上去以后波形在示波器上看可能很漂亮但Flash或者传感器的tSU、tH等参数不满足照样会出现偶发错误。这类问题排查起来非常隐蔽因为错误不是每次都有。遇到这种场景我会先把SPI速率降到从设备手册标称值的1/4确认功能稳定后再逐步提速率。5. 一次SPIDMA乱码的完整排错记录5.1 故障现场第二次传输开始数据全错有一次在新板子上调SPI Flash驱动第一次调用spi_dma_transfer读JEDEC ID完全正常但紧接着做第二次读操作时返回的数据全部不对。更奇怪的是如果每次读之前加一个10毫秒延时第二次又能读对。这个现象暗示时序上有什么地方没有处理干净。5.2 排查链路一从波形入手遇到SPI问题我第一件事永远是拿逻辑分析仪抓SCK、MOSI、MISO和CS的波形。抓下来发现第二次传输时SCK的脉冲数量比预期少了好几个字节。这意味着DMA根本没有把足够的数据搬给SPI而不是SPI波形本身有问题。顺着这个线索再去检查DMA配置果然发现问题第二次调用传输函数时发送DMA的通道没有先disable直接修改了传输数量。GD32F103的DMA通道在使能状态下部分配置寄存器是锁定的写入的新number根本不生效。通道还沿用上一次传输完后的number值而那个值已经递减到了0。结果就是DMA认为自己已经搬完了SPI只发出了几个时钟就停下来。5.3 排查链路二查DMA状态寄存器再看第二次传输的第一字节波形上第一个字节也是错的。这说明还有一个独立于number计数的问题。我去读DMA_INTF寄存器发现FTF完成标志还停留在置位状态上一轮传输的完成事件没有被清掉。重新使能DMA通道时这个残留标志会干扰DMA的启动导致第一次请求没有被正常响应。这一类问题在代码里看就是两个细节一是传输入口处先dma_channel_disable二是在disable后先清FTF标志。这两个动作缺一不可。5.4 真正的根因和修复方案把这两个点修复后第二次传输依然不稳定因为还有一个更隐蔽的坑传输完成后没有等待SPI总线空闲就拉高了CS。第二次传输开始时上一轮的最后一个字节可能还在SPI移位寄存器里没有完全发完此时拉低CS、重新启动DMA两轮数据的前后沿就叠在了一起。修复后的传输函数流程是先disable两个DMA通道清FTF标志重新装载地址和长度拉低CS先开接收DMA再开发送DMA等待两个FTF都置位然后等待SPI_TRANS位清0最后拉高CS。这几步完整走完后连续读、反复读都非常稳定加延时反而没必要了。这次排查给我的最大教训是SPIDMA出问题时不要只盯着DMA代码看要按“波形 - DMA寄存器 - SPI状态寄存器”的顺序一层层排除每一步都要有直接证据。直接改代码试错很多时候只是碰运气。6. 实测数据与后续优化方向6.1 同一块板子轮询和DMA的CPU占用对比我在同一块GD32F103C8T6板子上做过一个对比测试SPI速率固定在9MHz读取4KB数据。轮询方式的代码在传输过程中全程占用CPU4KB传输约耗时3.6毫秒这3.6毫秒内其他任务完全被阻塞。改成DMA启动后阻塞等待FTF标志的写法CPU仍然在空转等待但等待循环里不需要再逐字节判断TBE和读写数据寄存器代码路径短了很多综合开销能降一半以上。真正让CPU几乎完全闲下来的是DMA完成中断方式CPU只负责启动DMA然后在中断里处理完成事件传输过程中可以去跑其他任务。这里的结论是DMA本身解决的是“搬运”的效率问题但CPU是否真正闲下来取决于你是阻塞等待还是中断通知。开发时如果只是为了简化代码用阻塞等待FTF也没什么问题如果要让CPU去处理更多并发任务中断方式才是正解。6.2 大块数据场景下的分段策略GD32F103的DMA传输数量寄存器是16位的最大支持65535次传输。如果一次要传超过这个数量的数据就必须分段。常用的办法是在DMA传输完成中断里重新装载下一段的地址和长度继续启动下一次DMA。分段时要特别注意临界区保护。如果主循环刚好在操作缓冲区DMA中断又把下一段数据搬了进来二者可能冲突。我的做法是在中断里只设置一个“段完成计数”主循环轮询计数变化后再去处理数据尽量避免在中断里做复杂的业务逻辑。6.3 中断方式、循环模式以及RTOS下的注意事项如果数据采集是持续性的比如传感器连续输出数据可以考虑用DMA的循环模式。开启循环模式后DMA搬完设定的数量会自动把number恢复成初始值从头开始继续搬配合“半传输完成中断”和“传输完成中断”可以实现双缓冲处理CPU处理上一半数据的同时DMA正在填下一半数据流可以无缝衔接。在FreeRTOS或者类似RTOS环境下还要注意中断优先级。SPI和DMA相关的中断优先级如果设置过高可能会打断系统节拍影响任务切换。一般建议把外设中断优先级设置在FreeRTOS可接管的范围之内这样在中断里可以向任务发送信号量或者队列消息。另外DMA缓冲区如果是任务级的局部变量要防止在任务还没退出时缓冲区被系统回收最简单的方法是把缓冲区定义成全局数组。我个人在经历这次SPIDMA改造之后对“外设搬运”这件事的看法变了很多MCU的算力是有限的不应该浪费在重复的寄存器搬运上。GD32F103的DMA资源虽然不算多但只要用好SPI0_TX和SPI0_RX这两个通道再配合合理的片选策略和中断设计完全能满足大多数数据采集和存储场景的需求。希望这篇实战记录能给正在折腾GD32F103 SPI的朋友省下一些调试时间。
返回列表