STM32实战:SPI从机主动发送数据的3种实现方案

发布时间:2026/9/22 2:58:03
STM32实战:SPI从机主动发送数据的3种实现方案
做嵌入式这些年SPI 算是我用得最多的接口之一。前段时间有个项目两个 STM32 要高速交换数据从机采集完一批数据后必须“立刻”发给主机。需求一提出来很多人第一反应是SPI 从机怎么主动发数据SPI 主机才能提供时钟从机连时钟都没有怎么主动这句话对但也不全对。SPI 从机确实不能凭空发起传输但它可以通过别的办法“通知”主机来读数据。这篇文章我就拿 STM32 为例分享从机主动发送数据的 3 种实现方式方案一是 GPIO 请求线加中断方案二是主机轮询方案三是 DMA 加乒乓缓冲各有优缺点代码也都给出来。1. 先搞清楚SPI从机凭什么“主动”1.1 SPI主从机制的底层逻辑SPI 之所以叫同步串行接口关键在于“同步”这两个字。通信时谁提供 SCK 时钟谁就是主机。主机控制 CS 片选信号决定跟哪个从机通信主机也决定什么时候开始传输、传多少字节。从机的角色是被动的它只能检测 CS 是否拉低、SCK 有没有时钟过来然后在时钟的驱动下把数据从移位寄存器里送出去或者把总线上的数据收进来。所以从机“主动发送数据”这句话严格来说在 SPI 协议层面是不成立的。SCK 是主机的专属资源从机没有能力拉出 SCK 波形自然也就没法启动一次完整传输。我们要做的是在应用层解决“从机有数据要发给主机”这个需求本质思路都指向一句话让从机通过某种方式告诉主机“我这有数据了你赶紧来读”然后由主机发起 SPI 传输。这篇文章讲的三种方式全是在这句话的基础上展开的。1.2 三种思路的出发点第一种方式最直接从机专门拉一根 GPIO 线接到主机的 EXTI 中断脚。从机有数据要发时先把 SPI 发送寄存器准备好再把这根请求线拉高主机收到中断后就知道从机在等自己读数据于是拉低 CS、启动 SPI 接收。第二种方式省掉这根请求线主机按照固定周期主动发一个“读命令”给从机从机收到命令后回传数据。这种方式从机永远不会主动开口但它实现了“从机数据能被及时拿走”的效果。代价是主机必须一直轮询实时性和总线利用率都一般。第三种方式是把第一种方式的请求线和 DMA 结合适合数据量大、要求 CPU 占用低的场景。从机采集完数据后先启动 SPI 的 DMA 发送然后拉高请求线主机在中断里只用启动 DMA 接收剩下的事全部交给 DMA 搬运。数据吞吐高而且不需要 CPU 一字节一字节地去处理。三种方式并不冲突实际项目里甚至可以组合使用。下面我分别把硬件连接、CubeMX 配置思路和 STM32 HAL 库代码拆开讲。2. 方式一请求线EXTI中断最快最直观2.1 硬件连接与CubeMX配置思路方式一需要额外一根 GPIO 线我习惯叫它 REQ 线。连接是这样的从机某个 GPIO 配置成推挽输出接到主机的另一个 GPIO 输入脚主机这个 GPIO 配置成 EXTI 外部中断上升沿触发。其他四根线照旧SCK、MOSI、MISO、CS 都接好主机和从机共地。CubeMX 里主机的 SPI 配置为 Full-Duplex Master从机配置为 Full-Duplex Slave。传输模式建议先用 Mode 0也就是 CPOL0、CPHA0这样不容易出错。主机 CS 引脚用普通 GPIO 输出即可我习惯叫 CS_GPIO_Port / CS_Pin。从机的 NSS 如果不想用硬件控制可以把从机 SPI 的 NSS 设为 Software然后在初始化后把 SSI 置 1让从机始终处于选中状态真正的外部 CS 线就不参与 SPI 外设的使能判断了。如果从机使用硬件 NSS则需要确保主机 CS 拉低时从机能检测到片选有效时序要求更高新手阶段容易被 NSS 的电平搞得一头雾水所以软件 NSS 更稳。从机的 REQ 引脚在 CubeMX 里配成 GPIO_Output初始状态输出低电平主机的 REQ 引脚在 GPIO 配置里选择 External Interrupt Mode with Rising edge trigger使能 EXTI 中断。这里要注意主机的 GPIO 模式如果支持内部上拉/下拉最好根据实际情况选择是否启用。REQ 线一般没有高速翻转要求普通推挽输出就够了。2.2 主机端代码实现主机端的核心逻辑是在 EXTI 回调函数里只做一个标志位置位真正耗时的 SPI 接收放到主循环里处理不要在中断服务函数里直接跑阻塞式 HAL_SPI_Receive否则主机整个中断会被拉得很长其他实时任务可能被饿死。/* main.c 全局变量 */ volatile uint8_t g_slave_req 0; uint8_t g_rx_buf[32]; /* EXTI 回调REQ 引脚上升沿触发 */ void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin REQ_Pin) { g_slave_req 1; } } /* 主循环 */ while (1) { if (g_slave_req) { g_slave_req 0; /* 拉低片选通知从机开始传输 */ HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); /* 主机提供时钟接收从机数据 */ HAL_SPI_Receive(hspi1, g_rx_buf, sizeof(g_rx_buf), 100); /* 拉高片选结束本次传输 */ HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); ProcessData(g_rx_buf, sizeof(g_rx_buf)); } }这里有一个关键点主机的 HAL_SPI_Receive 在读数据的同时SPI 硬件也会把数据线上的数据移位进来但根据 SPI 全双工特性主机发送的字节是 0x00 或 0xFF具体由 HAL 库的清零处理决定。所以从机侧只要提前把数据放进发送寄存器主机这边就能完整收到。2.3 从机端代码实现从机端要做的事就是应用层准备好了数据后先把数据放进 SPI 发送通道再拉高 REQ 线通知主机。为什么要先放数据再拉高 REQ因为主机接收到中断后马上就会拉低 CS 并启动时钟如果从机这时候才开始填 SPI 数据寄存器第一个字节大概率来不及主机收到的第一个字节就是错的。/* main.c 从机侧 */ uint8_t g_tx_buf[32]; volatile uint8_t g_tx_busy 0; void Slave_SendData(uint8_t *data, uint16_t len) { while (g_tx_busy); // 等待上一次发送完成 memcpy(g_tx_buf, data, len); /* 先启动中断发送让第一个字节预装载到 SPI 数据寄存器 */ HAL_SPI_Transmit_IT(hspi1, g_tx_buf, len); g_tx_busy 1; /* 再通知主机过来读 */ HAL_GPIO_WritePin(REQ_GPIO_Port, REQ_Pin, GPIO_PIN_SET); } /* 从机发送完成回调 */ void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi hspi1) { /* 主机已经读完撤下请求线允许下一次请求 */ HAL_GPIO_WritePin(REQ_GPIO_Port, REQ_Pin, GPIO_PIN_RESET); g_tx_busy 0; } }从机调用 HAL_SPI_Transmit_IT 的时候CS 可能还是高电平SCK 也没有时钟但这不是问题。SPI 外设使能后数据寄存器是可写的HAL_SPI_Transmit_IT 会把第一字节写到数据寄存器里然后等 TXE 事件。等到主机拉低 CS、开始产生 SCK 时钟时第一字节被移出去TXE 置位中断再填充第二字节。所以“先启动发送再通知主机”这个顺序绝对不能反。2.4 方式一的关键细节第一个细节是请求线触发方式。我在代码里用的是上升沿触发从机拉高 REQ 请求主机收到上升沿中断。主机的判断要带标志位去清不要在主循环和 EXTI 回调之间出现竞态。如果同时来了多个请求可以给标志位加计数但如果只需要“有数据就读”一个标志位就够了。第二个细节是 CS 的时序。对从机来说主机 CS 拉低后从机 SPI 外设才真正进入被选中状态。如果从机用的是硬件 NSS那么 CS 线必须先于 SCK 拉低且持续到整个帧结束如果从机用软件 NSS则外部 CS 不直接控制 SPI 外设但仍建议用 GPIO 模拟 CS 来保证从机应用层能知道当前是否被选中这样在调试时你能清楚看见时序。第三个细节是中断优先级。主机 EXTI 中断不要设置成最高优先级因为中断里只置标志位实际 SPI 接收在主循环里所以优先级比系统节拍低一点即可。从机 SPI 发送中断的优先级则要看实时性要求数据量大时建议把 SPI 中断优先级调高避免 SCK 时钟太快导致 TXE 中断没来得及填充下一字节从而出现丢数据。3. 方式二主机轮询命令应答省一根请求线3.1 两阶段读命令协议设计方式二适合不想多拉线的场景主机只要定时发命令从机在命令的驱动下返回数据。很多人写到这里会忽略一个问题SPI 是全双工通信主机发命令字节的同时从机的 MISO 线上其实也在输出数据。从机在收到命令之后才准备数据那就晚了因为这个字节已经随着时钟移出去了。所以需要把一次“读取操作”拆成两个阶段。第一阶段主机发送一字节命令从机接收命令的同时MISO 上送出的是上一次预留的应答字节或者占位字节这一阶段主机收到的数据一般丢弃。第二阶段主机再发送一字节哑数据从机在收到第一阶段的命令后已经把真正的数据放到 SPI 发送数据寄存器里了这一次时钟会把数据完整送出主机收到的就是有效数据。计算一下开销每次读取有效数据 1 字节实际总线传输 2 字节。如果主机轮询周期是 1ms那么当前这包数据的最大延迟大约是 2ms平均延迟 1ms 左右。数据量小、实时性要求不高时这种方式很实用。3.2 主机轮询代码主机端比较简单用一个定时器或者直接在主循环里做周期轮询。我建议用定时器的回调置一个标志位主循环里处理避免在定时器中断里阻塞太久。每次读完再启动下一次。/* main.c 主机 */ uint8_t cmd CMD_READ_DATA; uint8_t dummy 0; uint8_t rx_dummy; uint8_t rx_data; void TimerPeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim htim2) { g_poll_flag 1; } } while (1) { if (g_poll_flag) { g_poll_flag 0; /* CS 拉低 */ HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); /* 阶段 1发送命令收到的是从机的占位数据 */ HAL_SPI_TransmitReceive(hspi1, cmd, rx_dummy, 1, 10); /* 阶段 2发送哑字节同时读取从机的有效数据 */ HAL_SPI_TransmitReceive(hspi1, dummy, rx_data, 1, 10); /* CS 拉高 */ HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); ProcessSensorData(rx_data); } }如果主机需要一次读取多个字节可以从机侧在命令阶段之后连续输出多个有效字节。比如读取 16 字节温度数据主机第一阶段发命令第二阶段连续发 16 个哑字节从机状态机里根据命令把 16 字节连续发送出来。3.3 从机状态机代码从机侧我推荐用 HAL 库的 HAL_SPI_TransmitReceive_IT让状态机始终处于接收状态。初始化时先启动一次“命令接收”传输收到命令后在回调里切换到“数据发送”状态发送完成后再切换回“命令接收”状态。/* 从机全局变量 */ uint8_t rx_byte; uint8_t tx_byte; uint8_t tx_dummy 0xFF; volatile uint8_t spi_state STATE_WAIT_CMD; // 0等命令1发数据 /* 初始化时启动命令接收 */ void Slave_SPI_Init(void) { spi_state STATE_WAIT_CMD; HAL_SPI_TransmitReceive_IT(hspi1, tx_dummy, rx_byte, 1); } /* 发送接收完成回调 */ void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi ! hspi1) return; if (spi_state STATE_WAIT_CMD) { if (rx_byte CMD_READ_DATA) { tx_byte ReadSensorData(); spi_state STATE_SEND_DATA; } /* 不管是不是有效命令都回到等待命令状态 但若收到有效命令下一阶段就会发送有效数据 */ if (spi_state STATE_SEND_DATA) { /* 阶段 2主机发送哑字节时从机发送 tx_byte */ HAL_SPI_TransmitReceive_IT(hspi1, tx_byte, rx_byte, 1); } else { HAL_SPI_TransmitReceive_IT(hspi1, tx_dummy, rx_byte, 1); } } else if (spi_state STATE_SEND_DATA) { /* 数据发送完成重新等命令 */ spi_state STATE_WAIT_CMD; HAL_SPI_TransmitReceive_IT(hspi1, tx_dummy, rx_byte, 1); } }这个状态机代码的精髓在于从机永远在等主机发起的 SPI 传输永远处于“接收命令或发送数据”的循环中。主机不来时钟这个状态机就一直挂着主机时钟来了从机的数据就会自动被读走。这种方式虽然叫“从机主动发送”但实际协议层面是主机一直在主动读从机的主动性体现在“有数据时能及时通过命令应答带出去”。3.4 延迟与总线效率分析方式二最怕的是主机轮询周期和数据到达时机不对齐。比如传感器每 10ms 更新一次主机若 5ms 轮询一次那读取到的数据平均会有 2.5ms 左右的陈旧延迟。主机若在数据更新前读拿到的就是上一周期的旧值如果业务要求拿到“最新值”可以给协议加一个状态字段从机在应答数据里带一个“数据更新序号”或者“数据有效标志”主机读到旧值时可以选择连续重读或丢弃。总线效率方面方式二每一字节有效数据都要消耗两个时钟字节效率是 50%。如果主机能接受问题不大如果对总线效率敏感建议用方式一或方式三。另外主机轮询会持续产生 SPI 时钟对一些低功耗场景不友好因为主机不能一直跑否则系统功耗压不下去。4. 方式三DMA批量搬运乒乓缓冲高性能大头兵4.1 DMA请求线的配合方式方式三是方式一的升级版思路仍然是“从机请求主机读取”但把“逐字节中断搬运”替换成“DMA 搬运”。使用 DMA 的好处很明显主机收到 REQ 中断后只需拉低 CS、启动一次 HAL_SPI_Receive_DMACPU 就可以去做别的事了DMA 会在 SPI 时钟驱动下把数据自动搬到内存。从机这边也一样数据在 DMA 的驱动下自动从内存搬到 SPI 发送数据寄存器不需要内核逐字节处理。这里有一个很重要的硬性约束从机必须先启动 DMA 发送让 DMA 把第一字节送到 SPI 数据寄存器再拉高 REQ 线。主机收到 REQ 后立即拉低 CS 并启动读取此时 SPI 时钟一产生第一字节就能直接移出。如果顺序颠倒从机先拉高 REQ主机随后马上产生时钟而 DMA 还没来得及把第一字节写入 SPI 数据寄存器第一个字节就会变成上一个残留数据或者干脆是空数据主机接收到的第一字节就会错位。4.2 乒乓缓冲为了什么DMA 搬运数据是直接从内存缓冲区搬到 SPI 外设不需要 CPU 参与。这意味着 CPU 可以在 DMA 正在发送缓冲区 A 的时候去往缓冲区 B 填充新数据等下一轮发送轮到缓冲区 B 时CPU 又可以去填充缓冲区 A。这叫乒乓缓冲。为什么一定要乒乓因为如果你只有一个缓冲区CPU 在一轮采集后填充完数据启动 DMA 发送接着 CPU 又想在新一轮数据到达时填充同一个缓冲区就会导致 DMA 还没发完的数据被新数据覆盖主机读到的数据前后撕裂或者出现半个新帧、半个旧帧。乒乓缓冲把“填充数据”和“发送数据”在时间上错开只要 CPU 填充缓冲区 B 的速度比 DMA 发送缓冲区 A 的速度快就不会互相干扰。乒乓缓冲的代码结构简单说就是两个数组一个正在被 DMA 读另一个正在被 CPU 填。发送完成后交换角色。我这里给一个单缓冲的简化版本重点是把 DMA 和 REQ 请求怎么配合讲清楚真正做乒乓时把下面代码里的单个 buffer 换成 buffer[2]再用一个索引来切换即可。4.3 从机和主机代码骨架从机侧代码假设传感器采集好的数据已经放在 g_tx_buffer 里长度为 BUF_LEN。/* 从机 */ #define BUF_LEN 256 uint8_t g_tx_buffer[BUF_LEN]; /* 应用层数据准备好后主动发送 */ void Slave_RequestSend(uint8_t *data) { /* 拷贝数据到 DMA 发送缓冲区 */ memcpy(g_tx_buffer, data, BUF_LEN); /* 等待上一次 DMA 发送完全结束 */ while (HAL_SPI_GetState(hspi1) ! HAL_SPI_STATE_READY); /* 先启动 DMA 发送让第一字节预装载 */ HAL_SPI_Transmit_DMA(hspi1, g_tx_buffer, BUF_LEN); /* 再通知主机来读 */ HAL_GPIO_WritePin(REQ_GPIO_Port, REQ_Pin, GPIO_PIN_SET); } /* 从机 DMA 发送完成回调 */ void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi hspi1) { /* 主机读完了撤下请求线 */ HAL_GPIO_WritePin(REQ_GPIO_Port, REQ_Pin, GPIO_PIN_RESET); } }主机侧代码收到 REQ 中断后启动 DMA 接收。/* 主机全局变量 */ uint8_t g_rx_buffer[BUF_LEN]; volatile uint8_t g_slave_req 0; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin REQ_Pin) { g_slave_req 1; } } /* 主循环 */ while (1) { if (g_slave_req) { g_slave_req 0; /* 拉低 CS从机开始输出数据 */ HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); /* 启动 DMA 接收主机产生 BUF_LEN 个时钟 */ HAL_SPI_Receive_DMA(hspi1, g_rx_buffer, BUF_LEN); } } /* DMA 接收完成回调 */ void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi hspi1) { /* 拉高 CS结束本次传输 */ HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); /* 处理数据 */ ProcessData(g_rx_buffer, BUF_LEN); } }这段代码看起来很简单但实际项目里我会把 HAL_SPI_Receive_DMA 放到 EXTI 回调里直接启动因为函数本身只是配置 DMA 并开启传输耗时极短。主循环里置标志位再处理也是一种稳妥做法优先级上并没有问题。真正要注意的是 CS 引脚和 DMA 状态的配合如果上一次 DMA 还没有完成下一次 REQ 就来了主机这边必须等待或做保护否则 CS 拉低之后启动 DMA 会出现并发错误。4.4 避免DMA传输踩坑DMA 的第一个坑是缓冲区长度超长时SPI 时钟连续翻转如果主机和从机的波特率分频设置不一致或者主频不同就可能导致采样点偏移。STM32 的 SPI 从模式一般可以跟得上主机速率但要注意 SPI 的时钟极性和相位必须完全一致特别是 Mode 0 和 Mode 3 这两个模式都不难配只是千万别一边配 Mode 0、一边配 Mode 3。第二个坑是 DMA 中断和 SPI 中断的优先级。从机在 DMA 发送完整个缓冲区后触发完成中断这个中断里要拉低 REQ不能拖太久。如果 REQ 线被长时间占用主机可能来不及在下一轮请求前释放资源。实际上我在做高速传输时会把从机的 DMA 中断优先级设置为较高主机的 EXTI 请求线中断优先级设置为中等SPI 的全局中断优先级设置为比 EXTI 低一点保证“请求”和“发送完成”不会被 DMA 中断的长时间处理拖延。第三个坑是首字节错误。前面反复强调过从机要先启动 DMA 再拉高 REQ。但还有一个细节是 DMA 配置里 Data Width 要和 SPI 数据宽度一致。STM32 的 SPI 通常配置 8 位数据DMA 的 Peripheral Data Size 和 Memory Data Size 都应该是 Byte。如果配成 Half Word 或者 WordDMA 搬运的数据宽度会和 SPI 外设不匹配收发内容直接乱掉。第四个坑是 CS 线。数据量大时CS 全程拉低直到 DMA 结束这一点协议上没有争议。但 CS 拉高前要确保主机已经不再产生 SCK 时钟。有些 HAL 库版本在 SPI 完成 DMA 后会自动处理但 CS 是普通 GPIO不会自动拉高所以必须在 DMA 完成回调里手动操作。从机侧如果使用硬件 NSS当 CS 拉高时从机 SPI 外设会强制停止当前传输导致 DMA 发送了一半被中止这个现象在调试时非常隐蔽。所以我个人强烈建议从机用软件 NSS外部 CS 只做电平参考SPI 外设的选中状态靠 SSI 位保持这样即使 CS 提前拉高从机 DMA 也不会突然中断。5. 三种方式怎么选一张表讲清楚5.1 核心维度对比我把三种实现方式的关键参数放在一起直接对照着看更清楚。对比维度方式一请求线EXTI方式二主机轮询方式三DMA请求线额外硬件引脚需要 1 根 REQ 线不需要额外引脚需要 1 根 REQ 线实时性高从机一拉线主机马上响应中取决于轮询周期高请求后 DMA 批量读取从机数据量小到中几十字节合适小以单字节或短数据为主大几百字节到几 KB 最合适CPU 占用主机接收时消耗 CPU主机持续轮询占用高主机和从机 CPU 占用都很低总线效率高一次传输全是有用数据低需要命令哑字节两阶段高批量数据一次传完实现复杂度简单简单但协议要设计好较复杂需要处理 DMA 和乒乓典型场景按键、告警、小包数据传感器周期上报、状态查询音频、波形、高速数据采集5.2 实际项目选型经验如果从机只是偶发地报一下状态比如按键按下、温度越限、设备故障我一般选方式一。请求线加中断代码量小实时性高主机不需要一直跑轮询功耗也低。如果是周期性的传感器数据比如温度、湿度、转速这些数据本身量不大而且业务上允许几百微秒到几毫秒的延迟方式二最合适。它省了请求线主机按固定周期读一次从机端只需要维护一个很简单的状态机。缺点就是主机轮询必须一直跑不能停太久如果主机本身还在做其他实时任务要考虑轮询周期是否影响整体调度。如果是做双机间的高速数据交换比如从机采集 ADC 波形、音频数据或者 IMU 九轴数据用方式三。请求线负责通知DMA 负责搬运乒乓缓冲负责把采集和发送流水线化。这种方式开发周期长一点但一旦调通效果非常稳定主机侧的 CPU 几乎不用管数据搬运留给应用层做算法和显示。6. 常见问题与排查实录6.1 一上电就是乱码乱码最常见的原因是 SPI 模式不一致。主机配置成 Mode 0从机配置成 Mode 3两边采样点对不上数据就会错位。碰到乱码先把两边都改成 Mode 0CPOL0CPHA0然后检查波特率分频。STM32 主机 SPI 时钟不能超过从机允许的极限从机 F103 一般建议主机 SPI 时钟在 9 MHz 以下保守一点用 4.5 MHz 或 2.25 MHz 调通再说。另一个容易忽略的是 MISO 和 MOSI 接反。STM32 主机的 MISO 必须接从机的 MISO主机的 MOSI 必须接从机的 MOSI。很多杜邦线项目里颜色一多就容易把两根数据线对调结果主机发命令从机收不到从机发数据主机也收不到。6.2 主机收不到从机数据先量一下 REQ 线有没有正常拉高。如果 REQ 线一直是低电平说明从机应用层可能没走到发送函数或者发送函数卡在 while 等待里。再量 CS 线有没有在主机收到请求后拉低。CS 线拉低但 MISO 没有数据大概率是从机还没准备好数据就通知了主机或者从机 SPI 没有正确使能。还要检查从机的 NSS 配置。如果从机用的硬件 NSS而主机的 CS 线和从机的 NSS 引脚没接到一起或者电平逻辑反了从机 SPI 外设根本没被选中自然发不出数据。从机用软件 NSS 时要在 SPI 初始化后把 SSI 位置 1否则从机以为自己没被选中也会罢工。6.3 请求线误触发或丢失请求线误触发大多是因为 REQ 线上有毛刺。从机 REQ 是普通 GPIO 推挽输出正常不会有毛刺但如果主机 REQ 输入引脚没有使能内部上拉悬空时很容易受干扰。建议主机 REQ 引脚配置上拉输入并且在 EXTI 回调里加一个软件去抖比如连续检测两次电平都满足条件再置标志位。请求线丢失则要检查从机拉高 REQ 之后是不是马上又被自己的发送完成回调拉低了。比如主机响应得很快CS 拉低、SCK 开始、DMA/中断发送完成后从机立刻拉低 REQ。如果这个拉低发生在主机 EXTI 上升沿还没被正确锁存之前某些单片机可能会丢中断。解决办法是 REQ 拉低的时机不要放在从机发送完成回调里而是放到主机读取完成后由主机主动拉低或者从机发送完成后再等一小段时间再拉低。实际项目里我习惯用从机发送完成回调拉低 REQ只要主机 EXTI 是上升沿触发且中断正常基本都能锁住但如果性能测试发现偶发丢请求可以考虑把 REQ 改成电平触发加标志位轮询。6.4 DMA传输和中断卡死DMA 卡死的典型现象是程序跑着跑着SPI 不再产生时钟REQ 线一直高主机的 DMA 接收回调一直不执行。原因一般有两个。第一个是主机在 DMA 接收过程中再次收到 REQ 中断主循环又把 CS 拉低、再次启动 DMA导致上一次 DMA 还没完成就被新的 DMA 请求覆盖HAL 库内部状态错乱。解决办法是加一个 g_dma_busy 标志DMA 接收完成回调里清除下次请求到来时如果 busy 还在就直接忽略或者排队处理。第二个是从机 DMA 发送缓冲区在发送过程中被应用层覆盖了。我在做乒乓缓冲时遇到过从机正在 DMA 发送 buffer A采集任务却把新数据写到 buffer A导致发送数据被新数据污染。加乒乓缓冲后这个问题就消失了核心原则是DMA 正在用的缓冲区应用层绝对不能写。6.5 FreeRTOS下SPI中断优先级怎么设置如果项目里用了 FreeRTOSSPI 中断和 EXTI 中断的优先级不能随便给。HAL 库在中断回调里可能会调用portYIELD_FROM_ISR这样的 FreeRTOS 宏如果中断优先级比configMAX_SYSCALL_INTERRUPT_PRIORITY更高FreeRTOS 就不允许在该中断里调用任何与系统相关的 API否则会触发断言或直接卡死。我的习惯是把 STM32 的中断优先级分组设置为 4 位抢占优先级然后 SPI 中断和 EXTI 中断的抢占优先级都设置在 5~7 之间不要设到 0~3。这样 FreeRTOS 可以管理这些中断而且不会影响系统节拍。如果业务上确实需要更高的实时性可以把 SPI 中断优先级设为 4但代价是不能在中断回调里调用任何 FreeRTOS API只能通过信号量或标志位通知任务不能直接在回调里osSemaphoreRelease或osMessageQueuePut。6.6 硬件NSS与软件NSS的选择STM32 的 SPI 支持硬件 NSS 和软件 NSS初学者经常在这上面踩坑。硬件 NSS 由 SPI 外设自动控制主机模式下可以在一次传输开始时自动拉低 NSS传输结束后拉高从机模式下 NSS 引脚的外部电平会直接决定从机是否被选中。看起来很方便但实际调试时NSS 和 SCK 的相对时序、多从机冲突、以及 DMA 传输过程中 CS 自动变化都会引入隐蔽问题。软件 NSS 则是把 NSS 功能交给 GPIO 手动控制。主机侧 CS 引脚用普通 GPIO 拉低拉高完全由自己控制时序从机侧 SPI 的 NSS 配置为软件模式然后设置 SPI 的 SSI 位为 1让从机一直处于“逻辑选中”状态外部 CS 线只作为应用层判断。这样 SPI 外设的数据收发不会因为 CS 时序问题突然中止代码也更好调。我个人做双机通信更推荐软件 NSS虽然要自己管理 CS 引脚但稳定性会明显好很多。7. 一点额外体会如果让我在新项目里重新选一次偶发小数据我会选方式一周期性传感器数据选方式二大数据量高速传输选方式三。实际项目里我更多是把方式一和方式三结合使用请求线负责通知DMA 负责搬运既能保证实时性又能把 CPU 从繁重的数据搬运里解放出来。SPI 的从机“主动发送”说到底只能做到通知主机来读真正的主从关系并没有变。选型时先想清楚数据量、延迟要求和引脚余量就不会走弯路。