ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

GD32 SPI中断接收从原理到调试:完整例程与避坑指南

GD32 SPI中断接收从原理到调试:完整例程与避坑指南 GD32的SPI外设发送就是往数据寄存器里写一个字节接收就是从同一个数据寄存器里读一个字节听起来是特别基础的功能。但一旦把接收改成中断方式很多人就会碰到奇怪问题RBNE中断不触发、进一次中断就感觉程序卡死、收到的数据全是0xFF、发送和接收的顺序怎么都对不上。这篇笔记把我常用的GD32 SPI发送加中断接收例程完整拆开从方案选择、寄存器原理到可以直接复制的代码和调试技巧都过一遍适合刚把SPI基础例程跑通、想提高收发效率的朋友也适合正在用GD32驱动SPI接口从机Flash、传感器、另一颗MCU的同学参考。1. 例程拆解GD32 SPI发送与中断接收到底要解决什么1.1 一个典型场景主机发命令、从机回数据我在一个项目里用GD32读取一颗SPI接口的传感器工作流程大概是主机先发一条命令让传感器进入测量模式然后每隔一段时间发一个字节通知传感器把数据放到MISO线上传感器在CS有效期间把状态和数据送回来。如果接收用轮询方式代码会一直卡在等待接收标志这一步传感器响应慢的时候CPU什么都干不了按键、串口、显示全部受影响。这个例程就复现了这种“主机发命令、从机回数据”的简化模型主机每轮发送2个命令字节从机每收到一个命令字节就在MISO上回一个字节响应。主机端发送用查询方式接收用RBNE中断方式中断里把收到的字节放进全局数组主循环等到收齐后继续处理下一轮。选这个模型的原因很直接项目里最常用的SPI交互就是这种短帧、一问一答的格式轮询浪费CPUDMA又有点杀鸡用牛刀。理解了这个模型的写法后面再改造成驱动Flash读ID、读ADC采样值、和另一块MCU做全双工通信都是同一套思路。1.2 轮询、DMA、中断这个例程为什么用中断很多刚接触SPI的人习惯用轮询收发因为代码最简单但这要看使用场景。SPI接收时从机响应时间是未知的轮询意味着CPU要不停查状态标志直到数据到位。如果从机本身就要好几个毫秒才准备好数据这段时间CPU就算被白白占掉了。有人会说数据量大的时候用DMA不就行了DMA确实能把数据从外设搬到内存不用CPU介入但SPI主机的DMA接收有个很容易踩的坑只开接收DMA是不够的主机必须同时让发送DMA持续输出数据才能产生SCK时钟否则接收DMA永远等不到数据。很多人在SPIDMA上翻车就是没想明白这个时钟来源的问题。中断接收正好处于两者之间的平衡点主循环该干嘛干嘛从机数据到位后由RBNE中断通知CPU来取大部分时间CPU是解放的。对几十个字节以内的短帧交互来说中断方式既不吃DMA的配置复杂度又避免了轮询的CPU浪费所以这个例程选择“发送查询接收中断”的组合。数据量到几百字节以上、需要持续吞吐的场景再去切DMA也不迟。实现方式优点缺点适合场景轮询代码简单、容易理解CPU长时间被占用收发字节少、实时性要求低中断CPU利用率高响应快需要处理标志和优先级短帧交互、从机响应时间不固定DMA几乎不占用CPU吞吐高配置复杂收发要联动大块连续数据、高速率传输2. 写代码前必须想清楚的SPI硬件细节2.1 全双工机制发送一个字节的同时一定会收到一个字节SPI的全双工机制是很多人理解中断接收的最大障碍。SPI有四根线SCK时钟、MOSI主机输出从机输入、MISO主机输入从机输出、CS片选。主机控制SCK就像一个打节拍的人每个时钟边沿上主机通过MOSI送出1位同时从MISO上采回1位数据。所以SPI发送和接收本质上是同一个动作主机往数据寄存器写一个字节硬件把这个字节通过移位寄存器一位一位送出去同时把从MISO线上进来的位移进另一个移位寄存器。8个时钟边沿之后发送完成接收也完成接收移位寄存器里的整字节会进入接收缓冲区RBNE标志置位。这意味着什么意味着你不能只开接收中断然后什么都不做等着从机主动送数据。SPI主机模式下没有SCK就没有一切。如果你希望从机回数据必须先发送一个字节来产生8个SCK哪怕这个字节是无效的0xFF。搞清楚这一点很多中断不触发的问题其实已经解决一半了。2.2 两个关键标志TBE和RBNEGD32的SPI外设状态寄存器里有两个位要盯死TBE表示发送缓冲区空RBNE表示接收缓冲区非空。写DATA寄存器后TBE会清零。当硬件把发送缓冲区里的数据交给移位寄存器开始往外移时TBE重新置位表示你可以往发送缓冲区写下一个字节了。注意这里有个经典误区TBE置位不代表当前字节已经发完它只表示发送缓冲区空了数据可能还在移位寄存器里一位一位往外走。多字节连续发送时只要TBE置位就可以直接写下一个字节硬件会等当前移位完成后再接着发所以连续发送没问题。但如果你想在发完最后一个字节后立刻拉高CS最好确认整个字节已经在线上发完否则会截断最后一个数据的最后一个比特。RBNE方向正好相反移位寄存器收满8位后整字节进入接收缓冲区RBNE置位。读取DATA寄存器会把RBNE清掉同时取走数据。如果RBNE已经置位而你一直不读DATA那么下一字节收进来的时候就会把上一字节覆盖掉这就是丢数据的常见原因之一。2.3 时钟模式与波特率先看从机手册再配参数SPI有四种时钟模式由CPOL和CPHA两个参数决定。CPOL决定空闲时SCK是高还是低CPHA决定数据在SCK的第一个边沿采样还是第二个边沿采样。四种模式分别对应Mode 0到Mode 3几乎所有SPI从机手册都会明确标注自己支持哪些模式。模式CPOLCPHA采样边沿Mode 0低第一个边沿前沿采样Mode 1低第二个边沿后沿采样Mode 2高第一个边沿前沿采样Mode 3高第二个边沿后沿采样配置的时候主机的模式必须和从机一致。GD32标准库里的枚举名比较直观SPI_CK_PL_LOW_PH_1EDGE对应Mode 0SPI_CK_PL_LOW_PH_2EDGE对应Mode 1SPI_CK_PL_HIGH_PH_1EDGE对应Mode 2SPI_CK_PL_HIGH_PH_2EDGE对应Mode 3代码里照着填就行。波特率由SPI预分频决定公式是SPI时钟等于SPI所在总线时钟除以分频系数。比如GD32F303的SPI0挂在APB2总线上假设APB2时钟是120MHz配置成SPI_PSC_128SCK就是120MHz/128约等于0.94MHz。实际配置不要一味追求高速时钟越快对布线、对从机时序要求越严格杜邦线连接的情况下更明显。我习惯先把速率压低功能跑通后再逐步调高。3. 完整例程GD32 SPI初始化、发送函数、中断接收实现3.1 引脚与硬件连接软件片选手动控制最放心这个例程用GD32的SPI0默认引脚映射是PA5作为SCK、PA6作为MISO、PA7作为MOSICS用一个普通GPIO控制我选了PA4。如果你的板子PA5到PA7正好接了别的外设也可以查数据手册的复用功能表换到其他引脚甚至使用重映射功能但要注意有些重映射引脚和JTAG调试口冲突需要先关闭对应复用。功能引脚方向说明SCKPA5输出SPI时钟MISOPA6输入从机数据输出MOSIPA7输出主机数据输出CSPA4输出软件片选空闲拉高CS片选推荐用软件控制也就是普通的GPIO去拉。硬件NSS模式下SPI外设会根据发送状态自动控制CS听起来省事但不同从机对CS时序要求差别很大有些从机要求整个多字节命令周期内CS必须保持低电平硬件自动控制反而容易在字节间隙把CS拉上去导致通信失败。软件CS虽然多写两行代码但时序完全可控排查问题也直观对新手最友好。3.2 初始化代码GPIO复用、SPI参数和NVIC初始化分三块GPIO、SPI外设、NVIC中断。GPIO需要开启时钟把PA5、PA6、PA7配置成复用功能PA4配置成普通推挽输出空闲状态拉高。GD32F303上这组引脚的SPI0复用功能号一般填GPIO_AF_5不同型号可能不一样写代码前看一眼数据手册的复用表确认。#include gd32f30x.h #define TX_CMD_CNT 2 #define RX_EXPECT_CNT 2 static uint8_t tx_cmd[TX_CMD_CNT] {0x01, 0x02}; static uint8_t rx_buf[RX_EXPECT_CNT]; static volatile uint8_t rx_cnt 0; static void spi_gpio_config(void) { rcu_periph_clock_enable(RCU_GPIOA); /* PA4 作为软件CS推挽输出空闲拉高 */ gpio_mode_set(GPIOA, GPIO_MODE_OUTPUT, GPIO_PUPD_PULLUP, GPIO_PIN_4); gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_4); gpio_bit_set(GPIOA, GPIO_PIN_4); /* PA5SCK, PA6MISO, PA7MOSI复用功能号以数据手册为准 */ gpio_af_set(GPIOA, GPIO_AF_5, GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7); gpio_mode_set(GPIOA, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7); gpio_output_options_set(GPIOA, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7); } static void spi_config(void) { spi_parameter_struct spi_parameter; rcu_periph_clock_enable(RCU_SPI0); spi_struct_para_init(spi_parameter); spi_parameter.device_mode SPI_MASTER; /* 主机模式 */ spi_parameter.trans_mode SPI_TRANSMODE_FULLDUPLEX; /* 全双工 */ spi_parameter.frame_size SPI_FRAMESIZE_8BIT; /* 8位数据帧 */ spi_parameter.nss SPI_NSS_SOFT; /* 软件CS */ spi_parameter.endian SPI_ENDIAN_MSB; /* 高位在前 */ spi_parameter.clock_polarity_phase SPI_CK_PL_LOW_PH_1EDGE; /* Mode 0 */ spi_parameter.prescaler SPI_PSC_128; /* 先低速调通 */ spi_init(SPI0, spi_parameter); spi_enable(SPI0); /* 使能SPI后先读一次DATA清掉可能残留的RBNE标志 */ (void)spi_i2s_data_receive(SPI0); nvic_irq_enable(SPI0_IRQn, 1, 0); spi_i2s_interrupt_enable(SPI0, SPI_I2S_INT_RBNE); }初始化代码里有几个细节要特别注意。第一spi_parameter.nss SPI_NSS_SOFT;表示禁用硬件NSS控制CS完全交给GPIO。第二spi_i2s_interrupt_enable里我开的是接收非空中断也就是RBNE中断不是发送中断很多人想当然把发送中断也开了结果TBE一直置位中断一直在触发反而干扰主流程。第三spi_enable之后、正式开中断之前我用一次读操作把RBNE清掉。SPI外设刚使能时状态寄存器不一定干净如果直接开RBNE中断偶发情况下一个莫须有的标志会把程序带进中断服务函数白白收一个垃圾字节。中断优先级这里配置成抢占优先级1、子优先级0。实际项目中要根据你的系统来定SPI接收中断优先级不能太低否则从机数据来了你还在处理其他更重要的中断接收缓冲区里的字节可能被下一个字节覆盖但也没必要开到最高毕竟收一个字节的活不重给关键的定时器中断留点空间。3.3 发送函数与RBNE中断服务函数发送函数保持查询方式逻辑非常简单等TBE置位写数据寄存器。TBE置位就说明上一个字节已经从发送缓冲区交出去了可以继续写下一个字节。static void spi_send_byte(uint8_t data) { while (RESET spi_i2s_flag_get(SPI0, SPI_I2S_FLAG_TBE)); spi_i2s_data_transmit(SPI0, data); }中断服务函数这边判断RBNE中断标志然后读取DATA寄存器。读取这个动作本身就把RBNE清掉了同时拿到从机返回的字节。中断里只做两件事保存数据、更新计数绝不做耗时操作。有的半路出家的代码会在中断里加延时、加打印、加协议解析这些都会拖慢SPI时钟节奏主机这边一慢从机的内部FIFO可能就溢出了数据就乱了。void SPI0_IRQHandler(void) { if (RESET ! spi_i2s_interrupt_flag_get(SPI0, SPI_I2S_INT_FLAG_RBNE)) { uint8_t rx (uint8_t)spi_i2s_data_receive(SPI0); if (rx_cnt RX_EXPECT_CNT) { rx_buf[rx_cnt] rx; rx_cnt; } } }注意中断服务函数里加了数组越界保护rx_cnt RX_EXPECT_CNT这个判断不是可有可无。如果从机因为某种原因多返回了一个字节没有保护的话写入会直接越界把其他全局变量甚至栈搞坏表现出来的问题可能五花八门非常难查。这个习惯我建议从一开始就养成。另外一个坑是中断服务函数的名字。GD32的SPI0中断服务函数叫SPI0_IRQHandler和STM32的习惯不一样很多从STM32转过来的人在这里卡半天写成了SPI1_IRQHandler编译不报错但中断根本不进来因为向量表里链接的根本不是那个函数。3.4 主循环通信流程与收发同步主循环的逻辑是先清空接收计数拉低CS选中从机然后逐个发送命令字节。每发送一个字节就要等对应的响应字节被中断收进数组再发下一个。全部发完后拉高CS处理一下收到的数据延时一会进入下一轮。int main(void) { uint8_t i; spi_gpio_config(); spi_config(); while (1) { uint32_t timeout; rx_cnt 0; gpio_bit_reset(GPIOA, GPIO_PIN_4); /* CS低选中从机 */ for (i 0; i TX_CMD_CNT; i) { spi_send_byte(tx_cmd[i]); /* 等RBNE中断把第i个响应收进rx_buf */ timeout 0; while ((rx_cnt i) (timeout 100000)) { timeout; } if (rx_cnt i) { /* 超时说明从机没响应按通信错误处理 */ break; } } gpio_bit_set(GPIOA, GPIO_PIN_4); /* CS高释放从机 */ /* 这里的rx_buf[0]、rx_buf[1]就是这一轮收到的数据 */ delay_1ms(10); } }这个流程看起来简单但有一个SPI初学者特别容易绕晕的点主机发送第0个命令字节的时候MISO上收到的其实是从机对第0个命令字节的响应。也就是说发送第n个字节同时收到的是第n个字节的响应不是第n1个命令的响应。如果从机手册上写的是“主机先发命令再发3个哑地址然后才能读到数据”那前几个收到的字节是无效数据需要根据协议手动丢弃不能直接当成有效数据用。例程里发2个命令、收2个响应是一一对应的最简单情况。实际项目中如果从机返回时序更复杂比如读Flash ID时要先发命令和地址后面主机连续发0xFF来产生时钟数据才从MISO出来那就要加一个状态机来区分当前处于命令阶段、地址阶段还是数据阶段。先把这种一收一发的模型跑通再加状态机调试压力会小很多。等待循环里我加了一个超时计数器这是必须的。如果从机因为没供电、接线错、模式不对等原因没有响应rx_cnt i的条件永远不会满足没有超时的话程序就死在这个while里了。加个超时超时后按通信错误处理整个系统才不会因为一个SPI从机的问题崩掉。4. 中断接收调试记录常见问题与避坑技巧4.1 RBNE中断不触发按这个顺序查RBNE中断不触发是新手问得最多的问题排查顺序其实很固定。第一先确认中断服务函数名字写对了GD32的SPI0对应SPI0_IRQHandler不是STM32习惯的SPI1_IRQHandler。名字写错的话程序能编译通过但中断永远不会进入。第二确认spi_i2s_interrupt_enable(SPI0, SPI_I2S_INT_RBNE);真的执行了。很多人初始化写到一半后面加了其他代码把中断使能覆盖了或者把发送中断和接收中断搞混检查时要看一下这行代码是不是在SPI使能之后。第三确认nvic_irq_enable(SPI0_IRQn, 1, 0);配置了并且中断优先级没有因为其他中断而一直被抢占。如果是调试状态下单步执行还要注意有些IDE环境下NVIC配置在复位后可能不会立刻生效尝试全速跑一下看看。第四也是最重要的一步确认主机真的发出了SCK时钟。SPI主机模式下SCK由发送行为产生如果你只开了RBNE中断但从来没有任何发送动作SCK是静的RBNE永远不会置位。用逻辑分析仪或示波器夹在SCK引脚上看有没有波形是最直接的判断方法。最后一个容易忽略的问题是GPIO复用配置。GD32的SPI0引脚如果不做AF复用配置SCK、MOSI只会被当成普通GPIOSPI外设的信号根本出不到引脚上。这时候波特率、模式、中断配置都正确但SCK就是没波形。我在GD32F303上遇到过AF编号选错导致MISO没信号的情况这种问题光看代码很难发现必须对照数据手册的AF复用表确认。4.2 数据全0xFF、错位、丢字节的常见原因读回的数据全是0xFF是最有特征的故障现象大概率是从机根本没被驱动起来。SPI从机的MISO通常是三态输出CS没拉低或者从机没供电时MISO处于高阻状态。主板上的MISO走线如果接了上拉电阻你读到的就是0xFF如果没上拉可能读到随机值或全0。遇到全0xFF先查CS波形有没有正常拉低再查从机电源和复位引脚大部分问题都能在这两步找到答案。数据错位比全0xFF难查一点。常见原因有两种第一种是CPOL/CPHA配置和从机不一致从机在错误的时钟边沿采样采到的每一位都偏了半个周期表现出来就是数据整体错位、某些位对某些位错。第二种是CS时序问题从机可能要求CS拉低后保持一段最短时间才开始接收第一个字节或者要求最后一个字节发送完成后CS才能拉高。如果刚加完CS拉低就立刻发数据从机还没来得及准备好第一个字节就会丢失或错位。丢字节的问题则要重点看中断响应速度。主机连续发送时SCK的节奏不会等你的中断服务函数如果RBNE中断触发后没有及时把数据读走下一字节到达时接收缓冲区就被覆盖了表现是隔几个字节就丢一个。这种情况我一般先降波特率给中断留出足够时间如果降速后正常说明中断响应不够快再考虑调高中断优先级或者对于大批量数据传输直接上DMA。一个非常推荐的排查手段是回环测试把MOSI和MISO直接短接主机自发自收。如果回环模式下数据都对说明主机SPI配置没问题问题出在从机端如果回环模式本身就收不对那问题肯定在主机端根本不用去怀疑从机。这个测试能把故障范围缩到一半效率很高。4.3 中断标志处理不当造成的“假死机”很多人第一次跑通SPI中断接收后遇到的现象是程序看起来完全卡死在线仿真暂停后发现PC指针一直停在中断服务函数里。这种情况十有八九是RBNE标志没有清掉。RBNE标志的清除方式就是读DATA寄存器不是软件写某个位去清。如果你在中断服务函数里判断了标志但最后忘了读数据RBNE一直为1SPI硬件就会不断重新触发中断整个CPU全被中断占满看起来就像死机一样。还有一种变体是确实读了DATA但读的数据没有保存编译器开启了优化后把读操作优化掉了也可能导致标志没被清掉。所以我写中断函数时会把返回值赋给一个局部变量既保证读取真的执行了又把数据用起来。另一个容易迷惑人的问题是共享变量的可见性。主循环里的等待条件rx_cnt i依赖中断函数更新rx_cnt这个变量必须加volatile修饰。不加的话编译器可能把rx_cnt的值优化进寄存器缓存主循环永远读不到最新值表现就是明明中断里已经收了数据主循环还是在死等。这个问题在开了O2优化后特别容易出现排查时先看看共享变量有没有volatile。还要提一个我踩过两次的坑主循环里为了开启新一轮通信会在发数据前把rx_cnt清零但如果清零之后、CS拉低之前SPI外设残留了一个RBNE标志没有清干净中断会在这段空隙里触发把垃圾数据写进rx_buf[0]然后主循环里等到的第一字节其实是垃圾真正的响应被当成第二字节整个顺序全部错位。例程里的处理办法是在spi_enable后先读一次DATA清标志然后在主循环里先清计数再拉CS两个动作间隔尽量短。4.4 调试工具与一个很实用的排查习惯调试SPI最低成本的工具不是示波器而是逻辑分析仪。现在几十块钱的8通道逻辑分析仪配合PC端软件能同时抓CS、SCK、MOSI、MISO四路信号还能自动解析成字节显示。遇到任何SPI数据对不上的情况把探头夹上去数一数CS低电平期间到底发出了几个SCK边沿每个边沿上MOSI和MISO是什么电平基本一眼就能看出问题在哪端。没有逻辑分析仪的话在线仿真也能凑合。在中断服务函数里打断点看SPI0-STAT寄存器里的TBE和RBNE位看SPI0-DATA寄存器里的值看rx_buf和rx_cnt的实际变化能判断是标志没置位还是数据没进来。但要注意断点本身会影响时序如果问题只在全速运行时出现单步调试往往复现不了这时候还是得靠逻辑分析仪抓真实波形。最后分享一个我一直在用的调试习惯拿到SPI从机后先不要急着上中断把所有收发逻辑先用轮询写一遍让主机能正确回环、能和从机交换出正确的字节然后再把接收改成中断。改完之后如果出现问题问题范围就集中在中断这一小块不用再回头怀疑SPI模式、波特率、GPIO配置这些基础设置。写SPI调试多了你会发现绝大多数所谓“SPI不稳定”根本原因就是时钟和数据没对上数把逻辑分析仪夹上去数几个SCK边沿答案往往比改代码更快出现。
返回列表