
简介面向嵌入式开发者的SPI与DMA联合应用工程资料包围绕SPI协议、DMA传输和中断机制展开适合需要处理大批量外设数据传输的STM32等MCU项目。压缩包共573个文件以C源码.c与头文件.h为核心包含编译生成的.o、.axf、.hex、.map以及Keil工程配置.uv2、.opt和调试清单.lst、.sct等便于对照代码、链接脚本和存储器映像进行深入学习。资料涵盖SPI工作模式配置、DMA通道设置、SPI与DMA关联及传输完成中断处理等关键环节并附有可编译的完整工程读者可直接借鉴其初始化流程、中断服务程序与出错处理思路。通过分析源码中的DMA请求使能与中断回调可快速掌握如何在不占用CPU的条件下完成SPI收发。已有633人学习使用适合正在优化嵌入式通信性能的开发者参考。1. SPIDMA 的工程价值从 CPU 忙碌到数据自行流动在嵌入式项目里一片 LCD 或一颗 SPI NOR Flash 看起来只是外设但每天经手的数据量非常大。一条 128×64 的 OLED 屏每帧 8KB55Hz 刷新就要搬接近 450KB/s 的数据而采用一字节进一次中断的 SPI 驱动方式CPU 的调度会被彻底打乱业务逻辑和协议栈全被拖慢。把 SPI 与 DMA 接在一起本质上是把数据搬运工作从 CPU 卸载给 DMA 控制器CPU 只在传输开始和传输完成两个节点各介入一次。这篇内容基于一个实际的 Keil MDK 工程构建会从 SPI 协议时序和 DMA 请求映射讲起给出可编译的初始化代码并讨论完成中断、错误中断的设计与现场排错技巧适合正在用 STM32 或其他 Cortex-M 系列芯片调试屏幕、Flash 或数据采集通道的工程师参考。2. SPI 协议与 DMA 请求映射先理顺底层通路2.1 SPI 四线与四种工作模式时序图决定寄存器取值SPI 协议是同步全双工通信主设备提供 SCLKMOSI 输出数据MISO 接收从机数据CS 选通从机。所谓同步是指双方在时钟沿完成数据的采样与移位CPOL 决定空闲时钟电平CPHA 决定采样沿两者组合出模式 0 到模式 3模式CPOLCPHA数据采样沿数据输出沿000上升沿下降沿101下降沿上升沿210下降沿上升沿311上升沿下降沿配 DMA 的时候这四种模式不影响 DMA 自身的传输方向但影响数据出现的节拍。SPI 外设内部的移位寄存器每完成一个字节就向 DMA 控制器发出一次硬件请求DMA 在请求到来时取数或填数。如果你把 CPOL 或 CPHA 配反表现出来往往是 DMA 一直正常搬运但 MOSI/MISO 上的电平采样全部错开半个周期数据每一位都对不上。这类错误不会触发任何中断或错误标志只能靠抓时序发现。调试时我习惯把 SCLK 空闲电平和从机手册里的时序图对比。W25Q64 这类 Flash 多数支持模式 0 或模式 3两者正好是相反的空闲电平模式 0 空闲低模式 3 空闲高。如果只改模式不改时钟极性硬件不会报错但从机永远解析不出正确命令读出数据全是 0xFF 或乱码。2.2 DMA 请求映射外设怎么通知 DMA“该搬数据了”DMA 控制器自身没有智能判断能力一切搬运动作都靠外设发出的硬件请求触发。SPI 发送缓冲空时硬件将 TXE 置 1 并产生发送请求接收缓冲有数据时RXNE 为 1 并产生接收请求。DMA 通道接收到请求后按照配置好的方向、数据宽度和地址递增方式执行搬运。关键点在于通道和外设的映射关系是固定的。在 STM32F1 系列上SPI1_RX 固定走 DMA1 通道 2SPI1_TX 固定走 DMA1 通道 3而 STM32F4 系列上 SPI1_TX 又可能挂到 DMA2 的通道 3。不同系列之间的映射表不能代代通用我见过不少同事把 F1 的 DMA 配置直接套到 F4 工程里编译不报错跑起来数据纹丝不动一查参考手册才发现映射通道完全不同。预防方法是把 DMA 请求映射整理成一张宏定义表放在头文件里换芯片时只改一处。DMA 配置里还有一个高频翻车点地址递增位。每次 DMA 搬移都访问同一个 SPI 数据寄存器外设地址必须固定数据缓冲区地址必须递增这样 256 字节数据才能落进数组的 256 个位置。把两个递增位配反的结果要么数据全部堆在缓冲区第一个字节要么 DMA 反复读同一段内存发给 SPI。2.3 SPI 收发各需要一路 DMA 吗SPI 需要两个 DMA 吗这个问题每个做 SPIDMA 的人都会遇到。物理层上 SPI 是全双工的主设备每发送一个字节MISO 上也会移入一个字节。如果你的应用只是写屏或写 Flash 命令只开 TX DMA 理论可行但必须处理 RX 方向残留的数据。不处理的话SPI 接收缓冲会被回填数据占满下一个字节到来时硬件直接置 OVR 溢出标志这个标志在部分芯片上会干扰 SPI 状态机的正常流转。所以只要数据量大我建议无论收还是发都把 TX 和 RX 两个 DMA 通道配置好。发送时把 RX 数据搬到一个 dummy 缓冲区接收时把 TX 方向固定输出 0xFF也就是 Flash 读操作的标准做法。这样 OVR 永远不会置位而且从 SPI 外设的角度看两条通路始终连续时序上没有空档。如果项目里需要读写 Flash比如先发 0x03 读命令加 3 字节地址再收 256 字节数据建议用分段启动的方式先启动 TX DMA 发送命令和地址在传输完成回调里再启动 RX DMA 读取数据。不要试图把命令和地址合并到同一个 DMA 事务里进行全双工收发因为那会把 MISO 上的命令回显读进缓冲区开头几个字节直接被污染。提示DMA 的 Mode 参数决定连续行为。普通模式搬完设定长度自动停适合单次交互循环模式会自动重新装载长度并继续搬运适合持续刷新类场景。要在连续传输和单次传输之间切换关键就是这一个字段。3. Keil MDK 工程中 SPIDMA 的初始化与启动3.1 CubeMX 生成框架后需要手动确认的参数用 STM32CubeMX 搭工程可以省掉大量底层配置时间但生成代码里的 DMA 部分有两个细节不能直接交出去。第一是 DMA 请求映射CubeMX 图形界面会列出可选的请求源如果选错通道生成代码编译不出问题调试时却怎么也触发不了中断。第二是 SPI_CR2 的 TXDMAEN/RXDMAEN 位部分版本的生成代码不会自动把位置 1而这两个位恰恰是 SPI 外设向 DMA 发出请求的总开关离开它们DMA 通道配置得再完整也是空转。我建工程时会保留 CubeMX 生成的 init 函数再补一小段/* SPI1 主模式全双工8 位数据模式 0软件 NSS */ hspi1.Instance SPI1; hspi1.Init.Mode SPI_MODE_MASTER; hspi1.Init.Direction SPI_DIRECTION_2LINES; hspi1.Init.DataSize SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity SPI_POLARITY_LOW; hspi1.Init.CLKPhase SPI_PHASE_1EDGE; hspi1.Init.NSS SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler SPI_BAUDRATEPRESCALER_8; hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; HAL_SPI_Init(hspi1); /* 关键一步使能 SPI 向 DMA 发出请求的能力 */ SET_BIT(hspi1.Instance-CR2, SPI_CR2_TXDMAEN | SPI_CR2_RXDMAEN); __HAL_SPI_ENABLE(hspi1);这段配置里每个参数都影响 DMA 的配合方式。Direction2LINES表示双线全双工HAL 的全双工 DMA 接口依赖这个设置即便你只发不收也建议保留全双工模式避免后续调读操作时又来回改配置。NSSSOFT让片选完全由 GPIO 控制硬件不干预在 DMA 场景里反而更可控因为硬件自动片选和 DMA 起传之间存在时间差容易出现 CS 已经拉高但数据还没发完的边界问题。BaudRatePrescaler决定 SCLK 分频APB2 为 72MHz 时分频 8 得到 9MHz对大多数 SPI NOR Flash 是安全值继续提频就要查从机手册的 SCLK 上限和板级信号质量。3.2 DMA 通道初始化与关联少一个 LINKDMA 都不行DMA 的接入不只是初始化一个通道实例还必须把它挂接到 SPI 句柄上HAL 库通过__HAL_LINKDMA宏完成这个动作。HAL_SPI_Transmit_DMA内部正是通过hspi-hdmatx这个指针去调用 DMA 启动函数的漏掉这步调用发送函数会直接进入错误处理或者什么动静都没有。下面是一份可编译的 DMA 初始化代码以 F1 系列为例SPI1_TX 对应 DMA1_Channel3SPI1_RX 对应 DMA1_Channel2__HAL_RCC_DMA1_CLK_ENABLE(); /* SPI1 发送通道内存 - SPI1_DR */ hdma_spi1_tx.Instance DMA1_Channel3; hdma_spi1_tx.Init.Direction DMA_MEMORY_TO_PERIPH; hdma_spi1_tx.Init.PeriphInc DMA_PINC_DISABLE; hdma_spi1_tx.Init.MemInc DMA_MINC_ENABLE; hdma_spi1_tx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_spi1_tx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_spi1_tx.Init.Mode DMA_NORMAL; hdma_spi1_tx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_spi1_tx); __HAL_LINKDMA(hspi1, hdmatx, hdma_spi1_tx); /* SPI1 接收通道SPI1_DR - 内存 */ hdma_spi1_rx.Instance DMA1_Channel2; hdma_spi1_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_spi1_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_spi1_rx.Init.MemInc DMA_MINC_ENABLE; hdma_spi1_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_spi1_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_spi1_rx.Init.Mode DMA_NORMAL; hdma_spi1_rx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_spi1_rx); __HAL_LINKDMA(hspi1, hdmarx, hdma_spi1_rx); /* 中断使能F1 的 DMA1 通道 2/3 共用同一 IRQ */ HAL_NVIC_SetPriority(DMA1_Channel2_3_IRQn, 1, 0); HAL_NVIC_EnableIRQ(DMA1_Channel2_3_IRQn);参数选择逻辑如下PeriphIncDISABLE是因为 SPI 数据寄存器永远只有一个地址不允许递增MemIncENABLE保证缓冲区地址按字节递增否则搬运会反复覆盖同一地址。PeriphDataAlignment和MemDataAlignment都配 BYTE对应 8 位 SPI 帧如果操作音频芯片使用 16 位帧格式这里要改 HALFWORD缓冲区也要用uint16_t数组对齐。ModeNORMAL表示搬完设定长度后停止适合单次交互需要连续采集时改成DMA_CIRCULAR。Priority两个通道都建议设高原因是低优先级 DMA 请求在高负载并发下可能被长时间仲裁SPI 接收缓冲一旦溢出就是物理性丢数据无法事后补偿。DMA 通道优先级只影响多个 DMA 请求同时到达时的仲裁顺序不影响其他外设的中断响应。3.3 启动传输的三种方式与场景选择HAL 库提供了三个 DMA 启动接口分别应对不同场景/* 写法一只发送适合写屏、写 Flash 命令 */ HAL_SPI_Transmit_DMA(hspi1, (uint8_t *)tx_buf, tx_len); /* 写法二只接收适合从从机批量读数据 */ HAL_SPI_Receive_DMA(hspi1, (uint8_t *)rx_buf, rx_len); /* 写法三同时收发全双工 */ HAL_SPI_TransmitReceive_DMA(hspi1, (uint8_t *)tx_buf, (uint8_t *)rx_buf, len);写法一只启用了发送通道适合纯写方向。但前面说过SPI 全双工特性会让 MISO 上也回数据规避方案是同时给接收通道挂一个 dummy 缓冲区并提前把 RX DMA 启动好这样回读字节会被自动搬走OVR 永远不会出现。操作顺序上要先启动 RX DMA再启动 TX DMA避免发送时钟先跑起来、接收通道还没就位造成时序空档。写法二和写法三用于读 Flash 或与传感器交互。读取数据前必须先发读命令和地址而接收阶段 SPI 必须继续输出时钟所以要保证 TX 侧有数据在发。常见流程分两步先调用HAL_SPI_TransmitReceive_DMA发送命令和地址RX 侧数据丢到 dummy等传输完成回调里再调用HAL_SPI_Receive_DMA读取数据段。这样命令回显不会污染接收缓冲区时序也干净。4. 中断与错误处理让 SPIDMA 传输真正可靠4.1 DMA 完成中断半传输和全传输的时序差异SPI DMA 的中断触发点位于 DMA 控制器而不是 SPI 外设。DMA 通道对每个方向支持三类事件半传输HT、全传输TC、传输错误TE。对于大块接收数据半传输中断能显著降低 CPU 的响应延迟DMA 搬完前一半时触发 HTCPU 处理前半段数据DMA 继续搬后半段全传输触发时再处理后一半。两块缓冲区交替使用CPU 和 DMA 处于流水线并行状态而不是串行等待一整块数据搬完。要实现这种双缓冲DMA 的Mode必须配成循环模式。循环模式下DMA 完成一次设定长度后 CNDTR 自动重新装载不会停止普通模式下全传输完成后通道关闭、CNDTR 归零必须重新装载才能再次启动。HAL 把两类事件拆成了独立回调覆写HAL_SPI_RxHalfCpltCallback和HAL_SPI_RxCpltCallback即可。回调里只做轻量操作比如置标志、释放信号量真正的数据处理放到主循环或 RTOS 任务里不要在中断服务程序里做浮点运算或长耗时操作。针对搜索中常见的“dma continuous requests”配置问题补充一点循环模式开启后DMA 会持续向 SPI 请求数据即便你没有主动调启动函数。如果接收缓冲区只是普通数组循环搬移会覆盖已处理的数据所以要么用双缓冲配合半传输中断要么每轮传输完成时立刻改 CNDTR 重新规划长度。4.2 SPI 错误中断OVR 的发生条件与清除方法SPI 硬件可以识别的错误主要是 OVR、MODF、FRE。DMA 场景下最容易出现的是 OVR它发生在接收缓冲已满而新数据又到达时硬件会覆盖旧数据并置位 OVR。典型触发路径是只开 TX DMA 而没有管 RX 方向MISO 不断回数据缓冲区很快被占满后续字节全部溢出。dummy 接收缓冲区是解决这类问题的标准手段uint8_t spi_rx_dummy[512]; void SPI1_Write_DMA(uint8_t *tx, uint16_t len) { /* 先让 RX DMA 就绪接管 MISO 返回的任意数据 */ HAL_SPI_Receive_DMA(hspi1, spi_rx_dummy, len); /* 再启动发送SCLK 开始工作后接收方向已有 DMA 待命 */ HAL_SPI_Transmit_DMA(hspi1, tx, len); }启动顺序在这里有讲究RX DMA 必须先于 TX 启动。因为 SPI 发送一旦开始SCLK 立即运行MISO 上的数据会立刻进入接收缓冲如果 RX DMA 尚未待命第一批数据就会被 OVR 挡在门外。先准备好接收再打开发送时钟时序没有空档。HAL_SPI_ErrorCallback是查看错误状态的统一入口调试时在里面断点读取hspi1.ErrorCode就能定位到具体错误位。错误标志如果不清理下一次传输会被错误状态干扰HAL 层返回HAL_BUSY。清除 OVR 的标准操作是依次读 SPI_SR 和 SPI_DR或直接调用__HAL_SPI_CLEAR_OVRFLAG宏。注意HAL 函数返回错误时先读 SPI_SR 和 DMA_CCR 再下结论。大多数情况不是代码写错而是上一次传输的错误标志残留导致的连锁反应。4.3 中断优先级DMA 完成中断不该被低优先级外设堵住Cortex-M 的 NVIC 支持可抢占优先级高优先级中断可以打断低优先级中断的服务函数。对 SPIDMA 系统DMA 完成中断的优先级应高于普通 UART 和按键中断但低于 HardFault、NMI 等系统异常。为什么必须高于 UART因为 DMA 接收数据如果不及时取走下一次数据到来就会发生 OVR这种丢失是物理性的无法事后补偿而 UART 数据的丢失至少在软件层还能做校验重发。我常用的一组优先级分配是SPI DMA 中断设为抢占优先级 1UART 空闲中断设为 2SysTick 设为 3。SysTick 放最低是因为 FreeRTOS 这类 RTOS 要求滴答中断和 PendSV 优先级都最低否则系统节拍会打断内核临界区。如果你没有使用 RTOS也可以把 SPI DMA 放到最高可抢占优先级把按键这类低频事件放后面。使用 FreeRTOS 时还需要确认一点osSemaphoreRelease在中断里调用时中断优先级必须不高于configMAX_SYSCALL_INTERRUPT_PRIORITY设定的阈值。如果信号量释放失败先检查是不是优先级越界这是中断优化中很容易踩的一个隐性错误。5. 现场排错顺序与吞吐量优化技巧DMA 初始化完成、中断也开了但数据搬不动或搬错了按下面的顺序排查能省一半时间。第一步看 SPI_CR2 的 TXDMAEN/RXDMAEN 是否为 1这两个位是 SPI 通往 DMA 的闸门。第二步看 DMA 通道的 CNDTR 寄存器如果为 0 说明搬运已经结束而中断没来问题就落在 NVIC 使能或 IRQ Handler 映射上。第三步用逻辑分析仪抓 SCLK、MOSI、MISO确认时钟极性和数据相位是否与从机手册一致。第四步检查从机一侧的 WP、HOLD 引脚是否被外部电路拉死这类问题 SPI 和 DMA 本身永远查不出来。验证用例可以拿 SPI Flash 读操作来跑通全链路uint8_t flash_cmd[4] {0x03, 0x00, 0x00, 0x00}; uint8_t flash_rx[256]; uint8_t flash_dummy[256]; volatile uint8_t flash_rx_done 0; void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { flash_rx_done 1; } } void Flash_Read_Page(uint32_t addr) { flash_cmd[1] (addr 16) 0xFF; flash_cmd[2] (addr 8) 0xFF; flash_cmd[3] addr 0xFF; HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); /* 阶段一发命令和地址RX 侧接收回显并丢弃 */ HAL_SPI_TransmitReceive_DMA(hspi1, flash_cmd, flash_dummy, 4); while (!flash_rx_done); /* 等待命令阶段完成 */ flash_rx_done 0; /* 阶段二正式读取数据 */ HAL_SPI_Receive_DMA(hspi1, flash_rx, 256); while (!flash_rx_done); flash_rx_done 0; HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); }这段代码展示了异步 DMA 传输的完整闭环。片选拉低必须在启动 DMA 之前完成片选拉高必须等最后一个字节的传输完成回调执行后再做。while轮询在这里是可接受的因为命令阶段只有 4 字节耗时极短如果数据段长到几 KB不要再裸等换成信号量或事件标志组来通知业务任务。吞吐量优化还有一个容易被忽略的点就是在带 D-Cache 的 Cortex-M7 核上维护缓存一致性/* 发送前刷 D-Cache确保 DMA 读到 CPU 写入的最新数据 */ SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len); /* 接收完成后使缓存行失效让 CPU 重新从内存读取 */ SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, len);这两行在 M7 内核上缺一不可。漏掉 CleanDMA 可能搬走缓存里的旧数据漏掉 InvalidateCPU 解析的接收缓冲一直读到缓存过期值表现为时好时坏的随机错误。在 M4 及更早内核上执行这两个函数是无副作用的空操作所以跨平台代码里可以直接调用。遇到改了缓冲区值却不生效的怪现象优先检查缓存一致性而不是反复改 DMA 配置。本文还有配套的精品资源点击获取