news 2026/9/28 1:33:19

GD32 SPI+DMA全双工通信实战:从寄存器配置到性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
GD32 SPI+DMA全双工通信实战:从寄存器配置到性能优化

1. 为什么我最终选了GD32的SPI+DMA做板间通信

两片MCU之间要高速交换数据,串口太慢、I2C更慢、并口太占引脚,SPI几乎是唯一解。但普通SPI每传一个字节都要CPU亲自搬运,波特率一拉到十几兆,CPU基本就被中断吃满了,主循环里其他任务全得靠边站。我最早做的一块数据采集板就踩过这个坑:主频120MHz的GD32F303,SPI跑到18MHz,结果CPU占用率直接飙到60%以上,采样任务频繁丢点。

后来把SPI的收发全部交给DMA,CPU只在传输完成中断里处理一批数据,占用率瞬间掉到个位数。这就是SPI+DMA全双工通信的价值所在——让DMA当搬运工,CPU只做决策。

这篇文章面向的是已经会用GD32点灯、会配置基本SPI的嵌入式开发者,尤其是做板间高速数据交换、传感器阵列采集、双机协同控制的朋友。我会把主从机全双工DMA的完整实现思路、寄存器配置、踩坑经验全部摊开讲,代码基于GD32F30x系列标准外设库,其他系列(GD32F4xx、GD32E23x)逻辑一致,改改寄存器名就能用。

先说清楚一个概念:SPI本身就是全双工的,主机发一个字节的同时必然收到一个字节,这是硬件移位寄存器的天性。所谓"全双工DMA通信",本质是收发两个方向各挂一条DMA通道,让数据在后台自动流转,主从机各自维护一套发送缓冲和接收缓冲,靠片选信号和时钟同步来对齐数据帧。

2. 整体方案设计与关键选型考量

2.1 主从机角色划分与数据流设计

做板间通信,第一件事不是写代码,而是把数据流画清楚。我习惯先定三个东西:谁主动、传什么、怎么对齐。

主机负责产生时钟和片选,从机被动响应。全双工意味着每个时钟周期双方都在交换一个bit,所以主机发出去的数据和从机发出去的数据是同时进行的。这就带来一个设计约束:从机必须提前把要回传的数据塞进发送寄存器,否则主机收到的就是无效数据。

我的做法是给双方各定义一套对称的帧结构:

typedef struct { uint8_t header; // 帧头 0xAA uint8_t cmd; // 命令字 uint16_t len; // 数据长度 uint8_t payload[64]; // 有效载荷 uint8_t checksum; // 校验 } spi_frame_t;

主机发起一次传输时,把命令帧放进发送缓冲,DMA自动搬出去;从机在接收中断里解析命令,准备好应答数据放进自己的发送缓冲,等下一次主机发起传输时自动回传。这种"一问一答"的模型最简单可靠,适合绝大多数场景。

如果你需要从机主动上报,可以约定一个"从机有数据"的标志位,主机周期性轮询该标志,发现置位就发起一次读取传输。这样既保持了主机对时钟的完全控制,又实现了双向数据流。

2.2 为什么用DMA而不是中断搬运

有人会问:SPI中断搬运不行吗?行,但要看数据量。假设波特率10MHz,一个字节8bit加开销约1us,如果每字节进一次中断,中断频率就是1MHz。GD32的中断响应加现场保护大约十几个时钟周期,1MHz中断频率下CPU基本没时间干别的。

DMA的优势在于批量搬运、零CPU干预。配置好源地址、目的地址、传输长度后,DMA控制器自己完成所有搬运,只在传输完成(或半传输)时给CPU一个中断。同样是10MHz波特率传1KB数据,中断搬运要进1024次中断,DMA搬运只需要1次完成中断。

这里有个关键参数要算清楚:DMA传输长度和SPI数据宽度的匹配。GD32的SPI数据寄存器是16位,可以配置成8位或16位数据帧。如果用8位帧,DMA的传输宽度也要设成字节;如果用16位帧,DMA传输宽度设成半字。两者必须一致,否则会出现数据错位。

2.3 硬件片选与软件片选的取舍

SPI的NSS片选有两种模式:硬件NSS和软件NSS。我强烈建议用软件片选,原因有三:

第一,硬件NSS在多主机场景下容易出问题,一旦NSS被拉低,SPI会自动切换到从机模式,时序不好控制。第二,软件片选可以灵活控制片选和时钟的先后顺序,方便加延时。第三,从机的NSS如果配成硬件模式,片选抖动可能导致移位寄存器复位,丢数据。

具体做法是把NSS引脚配成普通GPIO推挽输出,传输前拉低,传输完成后拉高。主机代码里这样写:

gpio_bit_reset(SPI_CS_PORT, SPI_CS_PIN); // 拉低片选 spi_dma_transfer_start(); // 启动DMA传输 // 等待传输完成中断 gpio_bit_set(SPI_CS_PORT, SPI_CS_PIN); // 拉高片选

从机这边,NSS引脚配成浮空输入或者上拉输入,用外部中断或者轮询检测下降沿来准备接收。不过更稳的做法是从机也用软件NSS,把NSS引脚当普通输入,在SPI初始化时设置spi_nss_internal_high(),让硬件忽略NSS信号。

2.4 DMA通道与请求映射的坑

GD32的DMA请求映射和STM32不完全一样,这是很多人移植代码时翻车的地方。以GD32F303为例,SPI0的TX请求固定在DMA0通道3,RX请求固定在DMA0通道2;SPI1的TX是DMA0通道4,RX是DMA0通道5。这个映射是硬件固定的,不能随便改。

我见过有人把SPI0的TX配到DMA0通道1,结果DMA死活不触发,查了半天以为是SPI没配好。所以动手前一定要翻对应型号的参考手册,把DMA请求映射表看清楚。

另外,GD32的DMA和STM32的DMA在寄存器层面有差异,比如GD32的DMA通道配置寄存器里,优先级和传输方向的位定义就不太一样。直接抄STM32的代码大概率跑不起来,建议用GD32官方标准外设库的dma_deinit()和dma_init()函数来配置,别自己手撸寄存器。

3. 核心寄存器配置与实操要点

3.1 SPI初始化:模式、时钟、数据帧

SPI初始化的核心是四个参数:主从模式、时钟极性相位、数据帧格式、波特率预分频。

主从机必须约定好时钟极性和相位(CPOL和CPHA)。我一般用模式0(CPOL=0,CPHA=0),即空闲时时钟为低,第一个边沿采样。这个模式最通用,绝大多数SPI从设备都支持。

数据帧我选8位,因为要传的payload是字节数组,8位处理起来最自然。如果追求更高吞吐,可以用16位帧,但要注意字节序问题——GD32的SPI是MSB先行,16位帧时高字节先发。

波特率预分频要算清楚。GD32F303的SPI时钟来自APB2(SPI0)或APB1(SPI1),假设APB2是120MHz,预分频设成8,波特率就是15MHz。这个速率下PCB走线要短,最好加串阻匹配,否则波形振铃严重。

主机初始化代码:

void spi_master_init(void) { spi_parameter_struct spi_init_struct; rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_SPI0); // PA5=SCK, PA6=MISO, PA7=MOSI, PA4=CS(软件控制) 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); // CS默认拉高 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_LOW_PH_1EDGE; spi_init_struct.nss = SPI_NSS_SOFT; spi_init_struct.prescale = SPI_PSC_8; spi_init_struct.endian = SPI_ENDIAN_MSB; spi_init(SPI0, &spi_init_struct); spi_enable(SPI0); }

从机初始化基本一样,只改device_mode = SPI_SLAVE,nss = SPI_NSS_SOFT,预分频从机不用管(时钟由主机提供)。

3.2 DMA发送通道配置:内存到外设

发送通道的方向是内存到外设,源地址是发送缓冲,目的地址是SPI数据寄存器。

void spi_tx_dma_init(uint8_t *buf, uint16_t len) { dma_parameter_struct dma_init_struct; rcu_periph_clock_enable(RCU_DMA0); dma_deinit(DMA0, DMA_CH3); dma_init_struct.direction = DMA_MEMORY_TO_PERIPHERAL; dma_init_struct.memory_addr = (uint32_t)buf; dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.memory_width = DMA_MEMORY_WIDTH_8BIT; dma_init_struct.number = len; 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_CH3, &dma_init_struct); dma_circulation_disable(DMA0, DMA_CH3); dma_memory_to_memory_disable(DMA0, DMA_CH3); dma_interrupt_enable(DMA0, DMA_CH3, DMA_INT_FTF); dma_channel_enable(DMA0, DMA_CH3); }

几个关键点:memory_inc必须使能,否则DMA永远搬同一个字节;periph_inc必须禁止,因为SPI数据寄存器地址固定;number是传输字节数,最大65535。

3.3 DMA接收通道配置:外设到内存

接收通道方向相反,源地址是SPI数据寄存器,目的地址是接收缓冲。

void spi_rx_dma_init(uint8_t *buf, uint16_t len) { dma_parameter_struct dma_init_struct; dma_deinit(DMA0, DMA_CH2); dma_init_struct.direction = DMA_PERIPHERAL_TO_MEMORY; dma_init_struct.memory_addr = (uint32_t)buf; dma_init_struct.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_init_struct.memory_width = DMA_MEMORY_WIDTH_8BIT; dma_init_struct.number = len; 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_VERY_HIGH; dma_init(DMA0, DMA_CH2, &dma_init_struct); dma_circulation_disable(DMA0, DMA_CH2); dma_memory_to_memory_disable(DMA0, DMA_CH2); dma_interrupt_enable(DMA0, DMA_CH2, DMA_INT_FTF); dma_channel_enable(DMA0, DMA_CH2); }

接收通道优先级我设成"非常高",因为接收不及时会导致SPI溢出(OVR标志置位),数据直接丢失。发送慢一点没关系,接收必须快。

3.4 SPI的DMA使能:别漏了这一步

DMA通道配好了,还得告诉SPI"我要用DMA"。GD32的SPI有两个DMA使能位:SPI_DMA_TRANSMIT和SPI_DMA_RECEIVE。

spi_dma_enable(SPI0, SPI_DMA_TRANSMIT); spi_dma_enable(SPI0, SPI_DMA_RECEIVE);

这两句必须在DMA通道使能之后、SPI传输开始之前调用。我踩过一次坑:先使能了SPI的DMA,再配置DMA通道,结果DMA通道重配时SPI已经发出了请求,导致第一次传输数据错位。正确顺序是先配DMA通道,再使能SPI的DMA请求,最后拉片选启动传输。

3.5 传输启动与完成判断

主机启动一次传输的完整流程:

void spi_master_transfer(uint8_t *tx_buf, uint8_t *rx_buf, uint16_t len) { // 1. 配置发送DMA dma_channel_disable(DMA0, DMA_CH3); dma_transfer_number_config(DMA0, DMA_CH3, len); dma_memory_address_config(DMA0, DMA_CH3, (uint32_t)tx_buf); dma_channel_enable(DMA0, DMA_CH3); // 2. 配置接收DMA dma_channel_disable(DMA0, DMA_CH2); dma_transfer_number_config(DMA0, DMA_CH2, len); dma_memory_address_config(DMA0, DMA_CH2, (uint32_t)rx_buf); dma_channel_enable(DMA0, DMA_CH2); // 3. 拉低片选,启动传输 gpio_bit_reset(GPIOA, GPIO_PIN_4); // 4. 等待接收DMA完成(接收完成意味着整个传输结束) while(dma_flag_get(DMA0, DMA_CH2, DMA_FLAG_FTF) == RESET); dma_flag_clear(DMA0, DMA_CH2, DMA_FLAG_FTF); // 5. 拉高片选 gpio_bit_set(GPIOA, GPIO_PIN_4); }

这里有个细节:判断传输完成要看接收DMA的标志,不是发送DMA。因为SPI是全双工,发送完成不代表接收完成,最后一个字节可能还在移位寄存器里。等接收DMA的FTF标志置位,说明所有字节都已经收进来了。

从机这边不需要主动启动传输,它只要在SPI初始化后把发送缓冲挂到DMA上,等主机时钟到来时自动收发。从机的接收完成中断里处理数据,然后重新装载发送缓冲。

4. 完整实操流程与关键环节实现

4.1 工程搭建:从标准模板开始

我习惯用GD32官方标准外设库建工程,不依赖任何图形化配置工具。原因很简单:SPI+DMA这种底层配置,图形化工具生成的代码往往有冗余,出了问题不好排查。手写配置虽然麻烦点,但每一行都清楚。

工程目录结构:

project/ ├── CMSIS/ # 内核头文件 ├── GD32F30x_standard_peripheral/ # 标准外设库 ├── User/ │ ├── main.c │ ├── spi_dma.c │ ├── spi_dma.h │ └── gd32f30x_it.c └── Startup/

Keil里需要添加宏定义GD32F30X_HD(根据具体型号改),头文件路径包含CMSIS和标准外设库的include目录。如果用VSCode+EIDE插件开发,配置逻辑一样,只是编译脚本换成CMake或者EIDE自己的配置。

4.2 主机端完整实现

主机端的核心是一个状态机:空闲→准备数据→启动传输→等待完成→处理接收数据→回到空闲。

#define FRAME_SIZE 72 // 1+1+2+64+1+3(对齐) static uint8_t tx_buffer[FRAME_SIZE]; static uint8_t rx_buffer[FRAME_SIZE]; static volatile uint8_t transfer_done = 0; void DMA0_Channel2_IRQHandler(void) { if(dma_interrupt_flag_get(DMA0, DMA_CH2, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH2, DMA_INT_FLAG_FTF); gpio_bit_set(GPIOA, GPIO_PIN_4); // 拉高片选 transfer_done = 1; } } void spi_master_send_frame(spi_frame_t *frame) { memcpy(tx_buffer, frame, sizeof(spi_frame_t)); transfer_done = 0; dma_channel_disable(DMA0, DMA_CH3); dma_transfer_number_config(DMA0, DMA_CH3, FRAME_SIZE); dma_memory_address_config(DMA0, DMA_CH3, (uint32_t)tx_buffer); dma_channel_enable(DMA0, DMA_CH3); dma_channel_disable(DMA0, DMA_CH2); dma_transfer_number_config(DMA0, DMA_CH2, FRAME_SIZE); dma_memory_address_config(DMA0, DMA_CH2, (uint32_t)rx_buffer); dma_channel_enable(DMA0, DMA_CH2); gpio_bit_reset(GPIOA, GPIO_PIN_4); while(!transfer_done); // 等待完成中断 }

注意这里用了中断方式等待,而不是死循环轮询标志位。中断方式下CPU可以在等待期间处理其他任务,效率更高。如果传输频率很高,建议用中断+状态机的方式,别在主循环里死等。

4.3 从机端完整实现

从机的难点在于时序配合。从机不知道主机什么时候发起传输,所以必须时刻准备着。我的做法是从机在初始化时就把发送DMA挂好,接收DMA也挂好,然后等主机的时钟。

static uint8_t slave_tx_buffer[FRAME_SIZE]; static uint8_t slave_rx_buffer[FRAME_SIZE]; static volatile uint8_t slave_frame_ready = 0; void DMA0_Channel2_IRQHandler(void) // 从机接收完成 { if(dma_interrupt_flag_get(DMA0, DMA_CH2, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH2, DMA_INT_FLAG_FTF); slave_frame_ready = 1; // 重新装载接收DMA,准备下一帧 dma_channel_disable(DMA0, DMA_CH2); dma_transfer_number_config(DMA0, DMA_CH2, FRAME_SIZE); dma_memory_address_config(DMA0, DMA_CH2, (uint32_t)slave_rx_buffer); dma_channel_enable(DMA0, DMA_CH2); } } void spi_slave_init(void) { // SPI配置成从机模式 // ...省略GPIO和SPI基础配置... // 预先装载发送缓冲(全0或默认应答) memset(slave_tx_buffer, 0, FRAME_SIZE); spi_tx_dma_init(slave_tx_buffer, FRAME_SIZE); spi_rx_dma_init(slave_rx_buffer, FRAME_SIZE); spi_dma_enable(SPI0, SPI_DMA_TRANSMIT); spi_dma_enable(SPI0, SPI_DMA_RECEIVE); }

从机有个坑:发送DMA的传输长度必须和接收一致。因为SPI全双工,主机发多少字节,从机就收多少字节,同时也发多少字节。如果从机发送DMA长度设短了,后面几个字节会发默认值(通常是0xFF或0x00),主机收到的数据就不对。

4.4 数据对齐与帧同步

全双工通信最容易出问题的地方是帧对齐。主机和从机必须对"一帧从哪开始、到哪结束"有完全一致的理解。

我的做法是用固定长度帧,每帧72字节(含帧头、命令、长度、64字节payload、校验、填充)。主机每次传输固定72字节,从机接收DMA也固定72字节。这样不需要额外的帧同步机制,靠片选信号就能对齐。

如果数据长度可变,就必须在帧头里放长度字段,从机先收帧头解析长度,再决定后续收多少。这种变长帧处理起来复杂得多,建议新手先用定长帧跑通,再考虑变长。

校验我用简单的累加和,把帧头到payload的所有字节相加取低8位。虽然不如CRC可靠,但胜在计算快、代码简单。如果通信环境恶劣,建议换成CRC16。

4.5 实测波形与性能数据

我用逻辑分析仪抓过波形,主机SCK 15MHz,片选拉低后第一个时钟沿到来,MOSI和MISO同时出数据。72字节传输耗时约40us,算下来有效吞吐约1.8MB/s。这个速率下CPU占用率不到3%,因为DMA搬运完全不占CPU。

对比一下:同样15MHz波特率,用中断搬运,72字节要进72次中断,每次中断约1us,总共72us,CPU占用率约15%。数据量越大,DMA的优势越明显。

有个细节要注意:片选拉低到第一个时钟沿之间要留至少半个时钟周期的延时。GD32的SPI在片选拉低后如果立即出时钟,从机可能还没准备好。我在代码里加了几个NOP,实测下来波形干净很多。

5. 常见问题排查与避坑经验

5.1 数据错位、首字节丢失

这是最常见的问题,表现为主机收到的数据整体偏移一个字节,或者第一个字节是0xFF。

原因通常是DMA使能顺序不对。如果先使能了SPI的DMA请求,再配置DMA通道,SPI在DMA通道还没配好时就发出了请求,导致第一个字节被丢弃。

解决方法:严格按"配置DMA通道→使能SPI DMA请求→拉片选启动"的顺序来。另外,从机在每次接收完成后要重新装载DMA,否则第二次传输会从上次的末尾继续,数据全乱。

5.2 接收溢出(OVR标志置位)

OVR置位意味着SPI接收到了新数据,但上一个数据还没被读走。在DMA模式下,这通常是因为接收DMA优先级太低,或者DMA通道被其他外设占用。

排查步骤:先看DMA通道优先级,接收通道设成"非常高";再看有没有其他DMA通道抢同一个DMA控制器;最后检查SPI波特率是不是太高,超过了DMA搬运速度。

GD32的DMA每个通道都有独立的优先级,但同一个DMA控制器内的通道是分时复用的。如果DMA0上挂了多个高优先级通道,SPI接收可能被延迟。这种情况可以考虑把SPI换到DMA1上(如果型号支持)。

5.3 从机发送数据不更新

从机每次回传的数据都一样,说明发送缓冲没有更新。原因可能是从机在接收完成后没有重新装载发送DMA,或者更新了缓冲但DMA还指向旧地址。

解决方法:从机在接收完成中断里,先处理接收数据,准备好应答,然后重新配置发送DMA的源地址和传输长度,再使能通道。注意要先dma_channel_disable()再配置,配置完再dma_channel_enable()。

5.4 高速传输时波形振铃

波特率超过10MHz后,SCK和MOSI波形可能出现振铃,导致从机误采样。这是PCB走线和阻抗匹配的问题,不是软件能完全解决的。

硬件上:SPI走线尽量短,最好等长;串联33Ω或22Ω的匹配电阻;地平面完整。软件上:适当降低波特率,或者调整时钟相位(CPHA),在第二个边沿采样,给信号更多稳定时间。

5.5 常见问题速查表

现象可能原因排查方向
首字节丢失DMA使能顺序错误先配DMA通道,再使能SPI DMA
数据整体偏移帧长度不一致主从机传输长度必须相同
OVR标志置位接收DMA优先级低提高接收通道优先级
从机数据不更新发送DMA未重载接收中断里重配发送DMA
波形振铃走线阻抗不匹配加串阻、缩短走线、降速
DMA不触发请求映射错误查手册确认DMA通道映射
传输卡死片选未拉高检查完成中断里是否拉高CS

5.6 几个我踩过的坑

第一个坑:GD32的SPI数据寄存器读取会清标志。在DMA模式下,DMA自动读SPI_DATA,不需要CPU干预。但如果你在调试时手动读了一次SPI_DATA,DMA就会少搬一个字节。调试时尽量用逻辑分析仪看波形,别乱读寄存器。

第二个坑:从机NSS配置成硬件模式时,片选抖动会复位SPI。我有一次从机NSS配成硬件模式,主机片选信号上有毛刺,结果从机SPI状态机被复位,数据全丢。后来改成软件NSS,问题消失。

第三个坑:DMA传输长度是16位,最大65535。如果要传更大的数据块,必须分多次传输,每次重新配置DMA。我传1MB数据时忘了这点,结果DMA只搬了65535字节就停了,后面的数据全丢。

第四个坑:Keil的优化等级会影响DMA缓冲的对齐。如果DMA缓冲没有4字节对齐,在某些GD32型号上会导致传输错误。建议用__attribute__((aligned(4)))强制对齐。

static uint8_t tx_buffer[FRAME_SIZE] __attribute__((aligned(4))); static uint8_t rx_buffer[FRAME_SIZE] __attribute__((aligned(4)));

5.7 调试技巧:用DMA传输计数判断进度

调试DMA传输时,dma_transfer_number_get()函数可以读取剩余传输字节数。在传输过程中调用它,能知道当前传到哪了。如果发现剩余字节数一直不变,说明DMA没触发,检查SPI的DMA使能位和请求映射。

另外,GD32的DMA有传输完成、半传输、传输错误三个中断标志。半传输中断在传输到一半时触发,可以用来做双缓冲——前半段传输时处理后半段数据,进一步提高吞吐。不过双缓冲实现复杂,新手先把单缓冲跑通再说。

6. 性能优化与进阶玩法

6.1 双缓冲提升吞吐

单缓冲模式下,DMA传完一帧才能处理,处理期间SPI空闲。双缓冲把缓冲分成A、B两块,DMA传A块时CPU处理B块,传B块时处理A块,理论上可以做到零等待。

实现方式是用DMA的半传输中断:传输到一半时触发中断,此时前半段已收完,可以处理;传输完成中断触发时,后半段也收完了。两个中断交替处理,SPI几乎不停歇。

不过双缓冲对从机时序要求更高,从机必须在半传输中断里就准备好下一半的发送数据,否则会发默认值。我建议先把单缓冲跑稳,再考虑双缓冲。

6.2 用DMA测速评估通信带宽

想知道实际通信带宽,可以用DMA传输计数配合定时器来测。方法:启动传输时清零定时器,传输完成中断里读定时器计数值,算出耗时,再除以数据量。

我实测GD32F303在120MHz主频、SPI 15MHz下,72字节传输耗时约40us,有效带宽1.8MB/s。如果把SPI提到30MHz(需要PCB支持),带宽能到3.6MB/s。这个数据供参考,实际值受走线、从机响应速度影响。

6.3 从机主动上报的实现

前面说的都是一问一答,从机被动响应。如果从机有紧急数据要上报,可以约定一个GPIO中断线:从机有数据时拉低该引脚,主机检测到下降沿后发起一次读取传输。

这种方式比轮询高效,但需要额外一根信号线。如果引脚紧张,也可以在主机的命令帧里加一个"查询从机状态"的命令,从机在应答帧里放状态标志,主机解析后决定是否发起读取。

6.4 多从机场景的片选管理

如果一条SPI总线上挂多个从机,每个从机需要独立的片选引脚。主机在传输前拉低对应从机的片选,其他从机片选保持高电平。从机这边,NSS配成软件模式,只有片选被拉低时才响应时钟。

多从机场景下,DMA配置需要为每个从机准备独立的发送和接收缓冲,或者用同一个缓冲但传输前切换片选。我一般给每个从机分配独立的缓冲,避免数据混淆。

7. 写在最后的一些实操体会

这套SPI+DMA全双工方案我在三个项目里用过,最长的已经稳定运行两年多,每天传输几十万帧,没出过数据错误。核心经验就几条:DMA通道映射要查手册、使能顺序不能乱、接收优先级要最高、片选用软件控制、帧长度主从必须一致。

新手最容易犯的错是急着写代码,不画数据流图。我建议动手前先在纸上画清楚:主机发什么、从机回什么、一帧多长、怎么校验、出错怎么办。这些想清楚了,代码就是翻译工作。

另外,逻辑分析仪是调试SPI的必备工具。看波形能发现很多代码里看不出的问题,比如时钟相位不对、片选时序太紧、数据建立时间不够。几百块的分析仪就够用,比反复改代码猜问题高效得多。

如果要从这个方案继续扩展,我建议往两个方向走:一是加CRC校验和重传机制,提高可靠性;二是上双缓冲,把吞吐再拉高一个档次。这两个方向都需要对DMA和SPI有更深的理解,但基础打好了,后面就是水到渠成的事。

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

CPU中断系统硬核解析:从响应周期到FPGA实现

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

作者头像 李华
网站建设 2026/9/28 1:31:19

YOLOv8部署RK3588 NPU实战:C++推理全链路指南

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

作者头像 李华
网站建设 2026/9/28 1:31:10

Python混合调度架构:定时任务与事件驱动的高效实践

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

作者头像 李华
网站建设 2026/9/28 1:30:03

AUTOSAR工具链配置实战:EB Tresos与DaVinci协同原理与避坑指南

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

作者头像 李华
网站建设 2026/9/28 1:29:48

STM32开发告别Keil:VSCode+GCC+STLink+GDB全流程避坑指南

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

作者头像 李华
网站建设 2026/9/28 1:28:55

动态图神经网络在异常流量检测中的实战建模

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

作者头像 李华