
GD32做SPI主机发送用轮询、接收用中断这组搭配我这一年里前前后后调了不少次从F103到F303都踩过一遍总算把一套能直接抄的例程固定下来了。这次就把整个思路、代码、还有调试时撞过的坑一起整理出来如果你正好在调GD32的SPI从机通信或者准备把STM32的老工程往GD32上搬这篇应该能帮你少走不少弯路。先说清楚这个例程到底解决什么问题。很多场景下MCU作为SPI主机去读传感器、读Flash芯片数据量小且单向用轮询收发完全够用。但一旦涉及和外部设备做双向交互比如和另一个MCU通信用SPI或者在RTOS环境里一个任务负责发指令、另一个任务要随时响应从设备主动上报的数据这时候“发送用轮询、接收用中断”就非常实用——发送是主动的你知道什么时候发轮询发送代码简单、时序可控接收是被动的不知道对方什么时候来数据中断能保证第一时间响应又不会让CPU一直傻等在主循环里。这也是我最终选定这套组合的原因。1. 整体设计与思路拆解1.1 为什么不是“中断发送中断接收”或“全DMA”很多人一上来就想用中断或者DMA把收发全做了但实际项目里要看需求。中断发送有一个天然问题你要在中断服务函数里维护一个发送队列数据多的时候还要考虑队列满、半满这些情况代码复杂度上去了省下的那点CPU时间对大多数SPI应用来说根本无所谓。轮询发送就不一样SPI发送本质上就是把数据写进发送缓冲区硬件自动移位出去一个8位数据在常见波特率下也就微秒级别轮询等待发送完成标志位并不会卡住系统很久。全DMA方案适合大数据块连续传输比如刷LCD、读写W25Q128这类动不动几百字节的场景。但DMA的启动、回调、内存分配、缓冲区管理每一环都得仔细处理对于“收发几十个字节控制指令”这种轻量级通信来说属于杀鸡用牛刀。我这套例程的定位就是中等数据量、双向交互的控制类通信轮询发送保证请求及时发出中断接收保证响应不丢两种机制各干各擅长的事。1.2 GD32的SPI和STM32的SPI有哪些不一样GD32的SPI外设在寄存器层面和STM32很接近但并不是完全一致。用标准外设库的话函数名、参数结构体、标志位名称都有差异直接拿STM32的HAL库代码往GD32上编译基本过不去。下面这几个点我实际踩过值得单独说GD32标准外设库初始化SPI用的是spi_parameter_struct结构体一次性把传输模式、主机/从机、帧大小、时钟极性和相位、NSS模式、预分频、字节序全配置好比STM32的HAL库分多个步骤更集中。中断标志位名称不同。GD32里接收缓冲区非空标志叫SPI_I2S_INT_FLAG_RBNE发送缓冲区空叫SPI_I2S_INT_FLAG_TBE和STM32的SPI_FLAG_RXNE、SPI_FLAG_TXE不是一个命名风格排查问题的时候别拿老经验直接套。GD32的SPI0/SPI1/SPI2挂在不同的APB总线上SPI0在APB2SPI1和SPI2在APB1时钟使能和中断号都要对应正确。这个我在2.2节详细说。GD32F30x系列的主频可以到120MHzAPB2总线时钟最高也是120MHz意味着SPI0理论上能跑到30MHz甚至更高F30x系列SPI最高时钟为APB2的1/2即60MHz比同价位的STM32F1系列宽松不少跑高速设备时优势明显。1.3 这套例程适合什么场景我把这套发送中断接收的模版用在几个地方MCU之间SPI通信一主一从主机下发命令、从机响应和ESP8266这类带SPI从接口的Wi-Fi模块对接主机发送AT指令、中断接收模块主动上报的数据以及给一些工业传感器做数据采集传感器作为SPI从设备MCU定时发送读取命令中断接收数据。共同特点是数据帧短几十字节以内、双向交互、从设备随时可能主动发数据。如果你要刷屏、读写大容量存储这套代码就不太合适那种场景老老实实上DMA。判断标准很简单看单次传输数据量超过64字节且是纯数据流直接DMA以控制指令交互为主中断接收这套更灵活。2. 开发环境与硬件准备2.1 用Keil还是GD32 Embedded BuilderGD32官方现在推的是GD32 Embedded Builder基于Eclipse的免费IDE对自家芯片支持最全工程模板、固件库、烧录插件都集成好了新手入门建议直接用这个省去自己配工程的麻烦。但我自己日常还是用Keil MDK多一些因为老工程基本都是Keil的而且Keil的调试器界面用习惯了在GD32上继续用没有任何障碍只需要装好对应的Device PackGD32F30x_DFP就能在芯片选择列表里找到型号。两个环境初始化SPI外设的逻辑完全一样代码可以直接复用。区别只是工程配置Keil里需要自己在Manage Run-Time Environment或者Device选项卡里选好芯片型号Embedded Builder则是在新建工程时选型号自动把固件库路径配好。嵌入式方向的朋友如果手里有现成的STM32 Keil工程要移植继续用Keil最省事全新项目想少踩环境坑选Embedded Builder。2.2 SPI引脚分配与时钟树GD32F303我用的比较多以它为例SPI0的引脚通常是PA4NSS、PA5SCK、PA6MISO、PA7MOSI挂在APB2总线上SPI1引脚是PB12NSS、PB13SCK、PB14MISO、PB15MOSI挂在APB1上。用哪个SPI需要先确认对应的总线时钟有没有使能不然硬件外设根本起不来。时钟这块有个容易忽略的点SPI外设时钟是挂在APB总线上的但引脚复用功能的时钟在RCU里是独立控制的。使能SPI0之前要同时把GPIOA时钟、AFIO时钟如果需要重映射和SPI0时钟都打开。直接用库函数就是下面这三行rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_AF); rcu_periph_clock_enable(RCU_SPI0);用GPIOB则换成RCU_GPIOB。这三行少一个后面就会遇到外设配置完但引脚没输出的诡异问题。2.3 GPIO复用配置要点GD32的GPIO配置和其他ARM MCU一样复用功能要在GPIO模式里显式设置。SPI的SCK、MOSI、MISO都要配置为复用推挽输出模式GPIO_MODE_AF_PPNSS如果硬件片选就是复用输出用软件片选的话可以把NSS引脚当普通GPIO控制或者干脆不接。下面这段是SPI0引脚的初始化注意速度直接拉到50MHz避免高速通信时波形失真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);MISO是输入引脚所以配置为浮空输入。如果外部从设备的MISO引脚本身有上拉或下拉可以改成GPIO_MODE_IPU或GPIO_MODE_IPD但一般从设备的MISO都是推挽输出浮空输入没问题。SCK和MOSI配置成复用推挽由SPI外设控制引脚电平不是普通GPIO输出。3. 核心代码实现与逐段解析3.1 SPI主机初始化完整代码GD32标准外设库的初始化方式比较集中spi_parameter_struct结构体一次性把参数全带上再调用spi_init即可。我贴一段我实际在工程里用的SPI0主机初始化void spi_gd32_init(void) { spi_parameter_struct spi_init_struct; rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_AF); rcu_periph_clock_enable(RCU_SPI0); 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); 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_LOW_PH_1EDGE; spi_init_struct.nss SPI_NSS_SOFT; spi_init_struct.prescale SPI_PSC_64; spi_init_struct.endian SPI_ENDIAN_MSB; spi_init(SPI0, spi_init_struct); spi_enable(SPI0); }参数选择背后的逻辑SPI_TRANSMODE_FULLDUPLEX表示全双工SPI本来就是同时收发主机的MISO和MOSI同时工作SPI_MASTER让GD32作为主机提供SCKSPI_FRAMESIZE_8BIT即8位一帧SPI_CK_PL_LOW_PH_1EDGE是CPOL0、CPHA1即时钟空闲为低、第一个边沿采样这是绝大多数SPI从设备支持的默认模式SPI_NSS_SOFT是软件NSS控制不用硬件自动拉片选SPI_PSC_64是把SPI0时钟做了64分频如果APB2是120MHzSPI时钟就是120/64≈1.875MHz这个速率适合大多数从设备也方便用逻辑分析仪抓波形SPI_ENDIAN_MSB表示先发高位字节。3.2 轮询发送函数实现发送用轮询思路就是等待发送缓冲区为空然后把数据写进数据寄存器。GD32的库函数封装很直接void spi_send_byte(uint8_t data) { while(RESET spi_i2s_flag_get(SPI0, SPI_I2S_FLAG_TBE)); spi_i2s_data_transmit(SPI0, data); }SPI_I2S_FLAG_TBE就是发送缓冲区空标志。主机模式下SPI外设发送完一个字节硬件会把TBE置起来表示可以往数据寄存器写下一个字节。spi_i2s_data_transmit把数据写入发送缓冲区硬件自动把数据移位输出到MOSI上同时SCK产生对应的时钟脉冲。如果一次要发多个字节就用循环调用这个函数。这里有个很多人忽略的细节SPI是全双工主机发送一个字节的同时会接收到从机返回的一个字节。如果你只是连续发送不理会接收寄存器里的数据那么接收缓冲区满标志可能会一直置位影响后续发送。所以批量发送的代码最好加上清接收缓冲区的操作void spi_send_buffer(uint8_t *buf, uint16_t len) { uint16_t i; for(i 0; i len; i) { while(RESET spi_i2s_flag_get(SPI0, SPI_I2S_FLAG_TBE)); spi_i2s_data_transmit(SPI0, buf[i]); while(RESET spi_i2s_flag_get(SPI0, SPI_I2S_FLAG_RBNE)); (void)spi_i2s_data_receive(SPI0); } }每次发送后等待RBNE并读取接收寄存器把从设备返回的数据读走清空。这样发送过程中接收缓冲区始终是空的不会影响下一次发送。如果从设备响应内容还需要处理可以把spi_i2s_data_receive的返回值存起来。3.3 中断接收完整配置与中断服务函数接收用中断分两步初始化时使能接收中断和NVIC然后在中断服务函数里读数据。中断使能代码加在3.1节初始化函数末尾spi_i2s_interrupt_enable(SPI0, SPI_I2S_INT_RBNE); nvic_irq_enable(SPI0_IRQn, 1, 0);SPI_I2S_INT_RBNE对应接收缓冲区非空中断只要SPI外设收到一个字节RBNE标志置位并触发中断。NVIC使能时优先级分组需要提前配置一般用nvic_priority_group_set(NVIC_PRIGROUP_PRE2_SUB2)设在main函数开头。IRQn要注意SPI0对应SPI0_IRQnSPI1对应SPI1_IRQn千万别把外设和中断号搞混否则怎么调都不进中断。中断服务函数#define SPI_RX_BUF_SIZE 64 volatile uint8_t spi_rx_buf[SPI_RX_BUF_SIZE]; volatile uint16_t spi_rx_cnt 0; volatile uint8_t spi_rx_complete_flag 0; void SPI0_IRQHandler(void) { if(RESET ! spi_i2s_interrupt_flag_get(SPI0, SPI_I2S_INT_FLAG_RBNE)) { if(spi_rx_cnt SPI_RX_BUF_SIZE) { spi_rx_buf[spi_rx_cnt] spi_i2s_data_receive(SPI0); } else { (void)spi_i2s_data_receive(SPI0); } } }这里有几个关键点。第一一定要先判断中断标志位再读数据否则中断函数里任何一次读操作都可能误触发RBNE标志。第二spi_i2s_data_receive读数据寄存器这个动作本身会清掉RBNE标志所以读完之后不需要手动清标志。第三缓冲区容量不够时要防止越界写直接用else分支把数据读掉丢弃保证RBNE标志被清掉后续中断还能正常进入。3.4 中断接收的扩展空闲判断与数据帧边界上面的中断函数把每个字节都存到缓冲区里但什么时候才算“一帧数据接收完毕”呢SPI不像UART有明确空闲中断主机模式下SCK持续产生从设备随时可能来数据。我常用的方法是在主循环里做超时判断每次收到数据记录时间戳主循环检查如果超过N毫秒没有新数据就认为当前缓冲区的数据是一帧完整数据可以开始解析。一种简单的实现是在中断里记录接收计数主循环里持续检查计数是否变化uint16_t last_cnt 0; while(1) { if(spi_rx_cnt ! last_cnt) { last_cnt spi_rx_cnt; last_tick get_tick(); spi_rx_complete_flag 0; } else if((spi_rx_cnt 0) (spi_rx_complete_flag 0)) { if((get_tick() - last_tick) 5) { spi_rx_complete_flag 1; } } }get_tick是系统滴答定时器提供的毫秒计时函数。5毫秒没有新数据就判定为帧结束这个时间要根据实际通信速率调整。SPI从设备连续发数据时字节间隔很短通常微秒级5毫秒足够区分下一帧。如果你用的是RTOS直接在任务里做这个判断更自然。4. 主循环调度与通信协议设计4.1 主循环框架中断负责把收到的字节存进缓冲区主循环负责超时判断、协议解析和业务处理。完整的框架我习惯这样写int main(void) { uint16_t last_cnt 0; uint32_t last_tick 0; nvic_priority_group_set(NVIC_PRIGROUP_PRE2_SUB2); systick_config(); spi_gd32_init(); usart_gd32_init(); while(1) { if(spi_rx_cnt ! last_cnt) { last_cnt spi_rx_cnt; last_tick systick_get_value(); spi_rx_complete_flag 0; } else if((spi_rx_cnt 0) (spi_rx_complete_flag 0)) { if((systick_get_value() - last_tick) 5) { spi_rx_complete_flag 1; } } if(spi_rx_complete_flag) { spi_frame_parse(); spi_rx_cnt 0; spi_rx_complete_flag 0; } } }主循环的任务很纯粹没事就检查有没有新数据有就做超时判断判定帧结束后解析并清空缓冲区。中断和主循环之间的数据传递靠spi_rx_cnt这个计数属于基本的单生产者单消费者模型在中小型嵌入式系统里足够可靠。4.2 自定义帧格式与解析逻辑数据进来之后要能识别出一帧的边界和内容就得定帧格式。我在例程里用的帧格式比较通用适合控制指令交互帧头(1字节) 长度(1字节) 命令(1字节) 数据(N字节) 校验(1字节)帧头固定用0xAA长度指命令数据校验的总字节数校验用最简单的累加和。解析的时候先找帧头再按长度收齐数据最后校验。下面这个函数是简化版实际工程里还要加状态机处理帧头丢失、长度异常的情况#define FRAME_HEADER 0xAA #define FRAME_MAX_LEN 64 uint8_t spi_frame_buf[FRAME_MAX_LEN]; uint8_t spi_frame_index 0; uint8_t spi_frame_state 0; void spi_frame_parse(void) { uint16_t i; uint8_t sum 0; uint8_t frame_len; for(i 0; i spi_rx_cnt; i) { switch(spi_frame_state) { case 0: if(spi_rx_buf[i] FRAME_HEADER) { spi_frame_state 1; spi_frame_buf[0] FRAME_HEADER; spi_frame_index 1; } break; case 1: spi_frame_buf[spi_frame_index] spi_rx_buf[i]; if(spi_frame_index 2) { frame_len spi_frame_buf[1]; if(frame_len FRAME_MAX_LEN - 2) { spi_frame_state 0; break; } if(frame_len 2 spi_rx_cnt - i spi_frame_index - 1) { spi_frame_state 2; } } break; case 2: spi_frame_buf[spi_frame_index] spi_rx_buf[i]; if(spi_frame_index frame_len 2) { for(sum 0; sum frame_len; sum) { sum spi_frame_buf[sum 1]; } if(sum spi_frame_buf[frame_len 1]) { spi_frame_handler(spi_frame_buf); } spi_frame_state 0; } break; default: spi_frame_state 0; break; } } }帧解析用状态机的好处是缓冲区里可能同时存在半帧、一帧多、或者几帧粘在一起状态机都能逐步消化。frame_len是指命令数据校验的长度实际收到frame_len 2加上帧头和长度字节才算完整一帧。校验通过后调用spi_frame_handler做具体业务处理比如根据命令码执行对应的操作。4.3 缓冲区管理与多帧连续接收实际通信中经常遇到两帧数据几乎同时到达的情况如果单缓冲区直接覆盖就会丢数据。我通常用环形缓冲区来缓解这个问题。SPI中断里只负责把数据压入环形缓冲主循环从环形缓冲里取数据做解析这样即使主循环被高优先级任务卡了一下中断也能继续接收数据。#define RING_BUF_SIZE 128 uint8_t ring_buf[RING_BUF_SIZE]; volatile uint16_t ring_head 0; volatile uint16_t ring_tail 0; uint8_t ring_write(uint8_t data) { uint16_t next (ring_head 1) % RING_BUF_SIZE; if(next ring_tail) { return 0; } ring_buf[ring_head] data; ring_head next; return 1; } uint8_t ring_read(uint8_t *data) { if(ring_head ring_tail) { return 0; } *data ring_buf[ring_tail]; ring_tail (ring_tail 1) % RING_BUF_SIZE; return 1; }中断函数改成调用ring_write主循环调用ring_read配合帧状态机解析可以处理连续多帧的场景。注意环形缓冲区满的时候ring_write返回0表示丢了一个字节调试时可以在中断里加一个全局计数变量看看到底丢了多少字节方便定位是主循环处理太慢还是数据量太大。5. 常见问题与调试技巧实录5.1 中断不触发标志位一直不置位SPI中断不触发排查顺序我建议这样走先确认外设时钟有没有使能用调试器看RCC寄存器里SPI时钟使能位再确认GPIO复用有没有配对示波器或逻辑分析仪查SCK引脚有没有时钟输出然后查NVIC有没有正确使能中断优先级分组有没有设置最后查中断标志位有没有被提前清掉比如初始化过程中接收到残余数据导致RBNE为0。这里有一个非常容易中的坑全双工模式下主机只要发送数据就会同时接收数据初始化后如果没有及时读走接收寄存器RBNE标志会一直为1。当你使能接收中断时这个已经置位的标志会立刻触发一次中断如果中断服务函数里没有正确处理下次数据到来时RBNE可能还是1导致看起来好像中断不工作。解决方法是初始化之后先读一次数据寄存器把标志清掉(void)spi_i2s_data_receive(SPI0);5.2 接收数据乱码、第一个字节丢失乱码问题九成出在时钟极性相位配置上。GD32的SPI_CK_PL_LOW_PH_1EDGE表示CPOL0、CPHA0第一个边沿采样SPI_CK_PL_LOW_PH_2EDGE表示CPOL0、CPHA1第二个边沿采样SPI_CK_PL_HIGH_PH_1EDGE、SPI_CK_PL_HIGH_PH_2EDGE以此类推。从设备的datasheet里通常会写明支持哪种模式对照改一下clock_polarity_phase就行了。第一个字节丢失通常和片选时序有关。硬件片选模式下NSS拉低到SCK第一个有效边沿之间需要一点时间让从设备准备如果NSS刚拉低SCK就开始跑从设备的内部状态机可能还没切换到接收模式第一个字节就丢了。解决办法是用软件片选先拉低NSS引脚延时几个微秒再启动SPI发送。软件控制片选还有一个好处就是可以自己决定两个字节之间要不要保留片选低电平这在某些从设备的连续写时序里是必须的。5.3 软件片选和硬件片选怎么选GD32的SPI支持硬件NSS自动控制配置SPI_NSS_HARD即可发送数据时硬件自动拉低NSS数据发完自动拉高。听起来很方便但实际用起来有几个限制NSS引脚必须固定接在专用引脚上PCB设计不灵活连续发送帧数据时硬件可能会在字节间隙拉高NSS如果从设备要求整个传输期间片选保持低电平就会出现通信失败。所以我更推荐软件片选把NSS接到任意一个GPIO初始化时配置为推挽输出发送前手动拉低发完最后一位再拉高。GD32的SPI支持在发送最后一个字节后通过配置SPI_CTL1的NSSDRV位来让软件NSS延迟拉高但库函数默认行为不一定符合你的时序需求最稳妥的还是手动控制GPIO。一个典型的软件片选发送函数长这样#define SPI_CS_LOW() gpio_bit_reset(GPIOA, GPIO_PIN_4) #define SPI_CS_HIGH() gpio_bit_set(GPIOA, GPIO_PIN_4) void spi_cs_send(uint8_t *buf, uint16_t len) { uint16_t i; SPI_CS_LOW(); for(i 0; i len; i) { while(RESET spi_i2s_flag_get(SPI0, SPI_I2S_FLAG_TBE)); spi_i2s_data_transmit(SPI0, buf[i]); while(RESET spi_i2s_flag_get(SPI0, SPI_I2S_FLAG_RBNE)); (void)spi_i2s_data_receive(SPI0); } while(RESET spi_i2s_flag_get(SPI0, SPI_I2S_FLAG_TBE)); SPI_CS_HIGH(); }注意发送完最后一个字节后要等TBE标志置位再拉高片选确保最后一个字节已经从移位寄存器完整发送出去不然从设备会漏掉最后一个字节。5.4 ST-Link连不上GD32报internal command error这个问题在换用GD32芯片的老工程里特别常见。用ST-Link给GD32下载程序有时候会报“internal command error”或者连接不上多半不是芯片坏了而是接线或配置问题。我用下来的排查流程检查SWDIO、SWCLK、GND三根线是否连接可靠线路过长时SWD通信容易失败尽量控制在10厘米以内。在Keil的Debug设置里把SWD时钟降下来比如从默认的4MHz降到1MHz或更低很多连接失败都是因为线材质量差、SWD速率太高。尝试“Connect under Reset”模式在Reset下拉列表里选择“under Reset”或“HW Reset”让目标板复位时建立连接能解决一部分芯片内部程序把SWD引脚复用了导致连不上的问题。如果目标板供电不足ST-Link的供电能力有限外接供电后一般能稳定连接。5.5 中断里千万不能做的三件事中断服务函数里我给自己定了几条规矩分享出来供参考第一不要在中断里做耗时的协议解析。接收中断只做数据搬运把字节写进缓冲区就完事解析丢给主循环。曾经图省事在中断里直接跑帧校验结果SPI时钟稍微调高一点中断还没跑完下一个字节就到了直接丢数据。第二不要在中断里调用printf或串口发送。串口波特率再高发一串调试信息也要几百微秒SPI中断在这个时间内可能多次触发导致接收溢出。真要调试用变量记录状态主循环里再统一打印。第三不要在中断里操作大数组拷贝。memcpy几百字节的数据在中断里执行会阻塞系统很久同样会造成数据丢失。如果确实需要在中断里搬数据用DMA完成内存到内存的搬运或者重新设计缓冲区方案比如双缓冲切换。5.6 调试利器逻辑分析仪和波形观察点调SPI通信逻辑分析仪是必须的几十块钱的8通道逻辑分析仪就够用。抓波形时重点看三个信号SCK的时钟频率、极性和相位是否和配置一致MOSI上数据是否和发送缓冲区一致CS片选信号是否在整个帧传输期间保持低电平。用逻辑分析仪解码SPI时通道对应关系要设对SCK、MOSI、MISO、CS分别接在哪个通道软件里对应配置。解码出来的数据如果和预期一致那就基本可以排除硬件时序问题再去查软件逻辑。如果没有逻辑分析仪临时用一个GPIO翻转来观察中断触发频率也是办法但效率低很多建议一步到位买个逻辑分析仪。6. 从例程到项目几个实操建议整体例程做到这里发送轮询、接收中断、帧解析这一套已经能跑通简单的SPI主从通信了。最后再说几个我在实际项目中用得上的建议。第一个建议是把SPI速率参数独立成一个宏定义放在头文件里。调试时先低速跑通逻辑再逐步提高分频系数。比如先SPI_PSC_256跑通再改SPI_PSC_64最高能跑多快除了看外设支持上限还要看PCB走线质量和从设备的最大时钟限制。我有一次图省事直接配了很高频率结果从设备手册上写着最高18MHzSCK却给到了20MHz数据时好时坏折腾了快一天才查到。第二个建议是预留一套DMA接口的开关。虽然当前需求用中断接收够了但后期如果数据量变大可能还是得上DMA。在初始化代码里加一个编译宏比如#define SPI_USE_DMA 0把DMA的初始化和中断接收的代码用#if包起来后续切换时只需改宏定义不用大改代码。第三个建议是别忽略片选时序和字节间隙。很多从设备对片选拉低到首个字节之间的延时、两个字节之间的间隔有要求特别是那些带内部状态机的传感器芯片。必要时在片选拉低后加一个短延时在发送循环里加一个极短的nop虽然看起来微不足道但可能就是通信成功的分水岭。第四个建议如果SPI从设备支持尽量用硬件CRC或者加上自己的校验字节。SPI本身没有错误检测机制线缆干扰、接触不良都可能导致数据错乱没有校验的通信协议在实际现场就是定时炸弹。我这套例程里的累加和校验虽然简单但足够拦截大多数单字节错误更复杂场景可以换CRC16。这个例程我已经在多个项目里复用GD32F303和GD32F103上都跑过逻辑基本一致只需要根据具体型号调整外设时钟和引脚配置。例程的完整代码我整理在文章里了足够你自己搭一个能跑的最小验证工程。调通第一个字节之后剩下的扩展就都是水到渠成的事了。