news 2026/9/10 17:24:41

STM32标准库SPI+DMA实战:原理、配置与高效传输指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32标准库SPI+DMA实战:原理、配置与高效传输指南

简介:面向 STM32F103 标准库开发者的 SPI 与 DMA 联合传输示例代码,适合嵌入式初学者及需要优化外设数据搬运效率的工程师,旨在解决传统阻塞式 SPI 传输长期占用 CPU 的问题。资源围绕 SPI 主模式初始化、DMA 通道参数配置、外设与内存地址映射、传输完成中断处理等核心环节展开,可帮助理解 CPOL/CPHA 四种工作模式差异,以及如何借助 DMA 将 CPU 从连续数据搬移中解放出来,适用于物联网传感器采集、工业控制通信等场景的快速验证与代码移植。整份资源共 4 个文件,包括 2 个头文件与 2 个源文件,分别对应 SPI1、SPI2 的初始化及 DMA 收发逻辑,压缩包仅 5KB,结构简洁、无多余依赖,方便直接阅读和按需裁剪。目前已有 1632 人学习下载,是一份轻量且典型的标准库外设编程参考;源码注释清晰,可作为学习 SPI 与 DMA 协作机制的入门范例。示例代码覆盖从 SPI 接口使能、DMA 流选择到中断回调的完整流程,读者可对照库函数调用与寄存器设置,并自行修改引脚、DMA 通道、数据长度等参数,迁移到具体项目中使用;也适合在开发板上做通信实验时快速复用。整体代码量虽小,但完整展示了 DMA 与 SPI 配合的关键步骤,对理解 STM32 中断与 DMA 事件机制也很有帮助。

1. 把 SPI 与 DMA 绑在一起,是标准库工程里最实用的提速组合

拿到STM32F103标准库SPI_DMA代码.rar这个压缩包,第一反应大概率不是“代码能不能跑”,而是“它到底演示的是哪条数据链路”。在标准库环境下,SPI 外设本身就能收发字节,加上 DMA 之后,CPU 从逐字节搬运中彻底解放出来。对于刷 OLED、写 W25Q64、驱动 SD 卡这类持续吞吐的场景,SPI_DMA 组合能把 CPU 占用率从 30% 以上压到接近 0%。

这套代码的价值不在于“SPI 怎么初始化”这种基础问题,而在于三点:DMA 通道怎么选、传输结束怎么判断、半双工与全双工模式下 DMA 的配置差异。适合三类人:正在从 51 或模拟 SPI 切到硬件 SPI 的嵌入式新手;想把串口屏或 Flash 读写吞吐提上去的固件工程师;以及维护老项目、手里只有标准库没有 CubeMX 生成代码的维护者。

2. 标准库下 SPI_DMA 的底层配合方式

2.1 DMA 为什么适合 SPI,而不是所有外设

SPI 的特点是时钟由主机控制,收发同时进行。主机向 MOSI 移出一位的同时,MISO 上也同步移入一位,所以 SPI 的发送和接收天然同步。如果用查询方式,每收发一字节要等待 TXE 或 RXNE 标志位,期间 CPU 一直在忙等。DMA 做的事情很简单:外设数据寄存器有空间时,自动把存储器里的数据搬进去;外设收到数据时,自动搬进内存缓冲区。整个过程不需要 CPU 逐字节干预。

但并不是所有外设都适合配 DMA。像 GPIO 这种没有数据寄存器的外设,DMA 没有搬运对象;而 I2C 虽有数据寄存器,但每字节带 ACK 位,协议状态机复杂,DMA 反而增加调试难度。SPI 则不同,它只有 8 位或 16 位数据寄存器,没有额外握手,是 DMA 最理想的外设之一。标准库里DMA_InitTypeDef结构体就是为这类“块搬运”场景设计的。

2.2 标准库工程里 SPI_DMA 代码的典型文件构成

打开STM32F103标准库SPI_DMA代码.rar,解压后一般能看到以下结构:USER目录放 main.c、stm32f10x_it.c,HARDWARE目录放 spi.c、dma.c 或 spi_dma.c,SYSTEM目录放延时和串口调试代码,CORESTM32F10x_FWLib是标准库本体。从stm32f10x_spi.cstm32f10x_dma.c这两个固件库里可以找到关键函数原型。

一个微妙的点是标准库 V3.5 里 SPI 外设的 DMA 请求开关。SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Tx, ENABLE)这行写在 SPI 初始化之后、DMA 使能之前,顺序颠倒会导致第一次传输丢字节。压缩包里的代码如果没注意这个顺序,很容易出现在逻辑分析仪上看到波形但数据错位的问题。

2.3 SPI 硬件片选与软件片选对 DMA 的影响

标准库环境下,SPI1 的 NSS 引脚可以配置为硬件自动管理,也可以由普通 GPIO 软件拉低。DMA 方式下这个选择尤其关键。如果启用硬件 NSS,主模式下 NSS 引脚由 SPI 外设自动驱动,DMA 传输结束后 NSS 拉高。但许多 STM32F103 的从设备(比如 W25Q64)要求片选在整个指令序列期间保持低电平,而 DMA 传输是按“每次请求搬运多少字节”工作的,字节间隙片选可能抖动,导致从设备状态机错乱。

常见做法是关闭硬件 NSS,改用GPIO_WriteBit手动控制片选。在 SPI_DMA 代码里,片选拉低后启动 DMA,传输完成中断里拉高片选。这样 DMA 的连续吞吐和片选的稳定性能同时保证。若压缩包里的代码沿用了硬件 NSS,建议改成软件片选再调 DMA,否则很容易出现“单独读 ID 正常,连续读 Flash 数据就错”的怪现象。

3. 用标准库在 STM32F103 上跑通 SPI_DMA 最小代码

3.1 SPI1_TX 和 SPI1_RX 的 DMA 通道映射

STM32F103 的 DMA1 有 7 个通道,每个通道有固定的外设映射关系。SPI1 的发送对应 DMA1_Channel3,接收对应 DMA1_Channel2。这个映射在stm32f10x_dma.h头文件里没有直接体现,需要查参考手册的 DMA 请求映射表。如果照抄串口 DMA 的通道号,编译不会报错,但数据永远不动,这是最常见的新手坑。

DMA 配置时,外设地址要写(uint32_t)&SPI1->DR,存储器地址写发送缓冲区的地址。DMA_PeripheralDataSizeDMA_MemoryDataSize都设为DMA_PeripheralDataSize_Byte,这是 8 位 SPI 的标准配置。如果用DMA_PeripheralDataSize_HalfWord,意味着 16 位 SPI 帧,W25Q64 这类 Flash 在非 QSPI 模式下用不上。

3.2 初始化顺序与关键参数代码

void SPI1_DMA_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; DMA_InitTypeDef DMA_InitStructure; // 开启时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // SPI1_SCK PA5, MOSI PA7, MISO PA6, NSS 用 PA4 软件控制 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_4); // SPI1 主机模式,8 位数据,软件 NSS SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; SPI_InitStructure.SPI_Mode = SPI_Mode_Master; SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8; SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_Init(SPI1, &SPI_InitStructure); // DMA TX:SPI1_TX = DMA1_Channel3 DMA_DeInit(DMA1_Channel3); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&SPI1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)tx_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = 0; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel3, &DMA_InitStructure); // 开启 SPI1 TX DMA 请求 SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Tx, ENABLE); }

这段代码里值得留意的参数:DMA_DIR_Direction_PeripheralDST表示数据从存储器搬到外设,即发送方向。DMA_MemoryInc_Enable让 DMA 搬运完一个字节后内存地址自动加一,否则所有字节都从缓冲区第一个地址取。DMA_BufferSize在初始化时设 0,每次发送前再重新设置,表示本次要发送的字节数。

void SPI1_DMA_Send(uint8_t *buf, uint16_t len) { DMA1_Channel3->CNDTR = len; // 直接操作寄存器设置传输数量 DMA_ClearFlag(DMA1_FLAG_TC3); // 清除上次的传输完成标志 GPIO_ResetBits(GPIOA, GPIO_Pin_4); // 片选拉低 DMA_Cmd(DMA1_Channel3, ENABLE); // 启动 DMA,开始搬运 }

发送完成后需要在中断里关闭 DMA 并拉高片选:

void DMA1_Channel3_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC3)) { DMA_ClearITPendingBit(DMA1_IT_TC3); DMA_Cmd(DMA1_Channel3, DISABLE); GPIO_SetBits(GPIOA, GPIO_Pin_4); // 片选拉高 } }

标准库的DMA_GetFlagStatusDMA_GetITStatus都能查传输完成,区别在于前者查状态寄存器,后者查中断标志。在中断服务函数里建议用DMA_GetITStatus,因为它会同时确认中断使能位和标志位,避免误触发。

3.3 发送后要不要等待的问题

DMA 置传输完成标志的时候,最后一个字节是从内存搬到 SPI->DR,但 SPI 外设可能还没把这一字节移位输出完。如果立即拉高片选,从设备可能收到残缺字节。很多人在这踩坑:DMA 中断触发了,但 W25Q64 写入的数据不对。

解决方法是检查 SPI 的 BSY 标志。在 DMA 中断里拉高片选前,加一个等待循环:

while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY));

BSY是 SPI 忙标志,只有移位寄存器完全空闲时才清零。加了这句之后,DMA 中断里拉高片选的时机才真正对应“最后一比特已发出”。标准库环境下,不能只依赖 DMA 的 TC 中断,SPI 外设的 BSY 标志才是传输完成的最后确认。

4. SPI_DMA 接收方向、循环模式与边界参数排查

4.1 SPI 全双工模式下 DMA 接收为什么需要同时开 TX

SPI 是全双工协议,主机要接收数据就必须发出时钟。时钟来自主机对SPI1->DR的写入。若只配置接收 DMA,不发送任何字节,那么主机不产生时钟,MISO 上的数据根本不会移入接收寄存器。这就是“SPI DMA 只能收不能发,或只能发不能收”问题的根源。

在 STM32F103 标准库下,用 DMA 接收数据的一个常见做法是让 TX DMA 和 RX DMA 同时工作。TX 方向发送一个哑字节(0xFF),RX 方向接收从设备回复的数据。比如读取 W25Q64 的 JEDEC ID,需要先发送 0x9F 指令再加 3 个哑字节,同时接收 3 字节 ID。这个流程可以用双通道 DMA 完成,但注意 TX DMA 的传输完成中断不应触发片选拉高,片选拉高要等 RX DMA 完成。

void SPI1_DMA_Read(uint8_t *rx_buf, uint16_t len) { uint8_t dummy = 0xFF; DMA1_Channel3->CNDTR = len; // TX 通道,发送 len 个哑字节产生时钟 DMA1_Channel2->CNDTR = len; // RX 通道,接收 len 字节 DMA_Cmd(DMA1_Channel2, ENABLE); // 先开 RX DMA_Cmd(DMA1_Channel3, ENABLE); // 再开 TX GPIO_ResetBits(GPIOA, GPIO_Pin_4); } void DMA1_Channel2_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC2)) { DMA_ClearITPendingBit(DMA1_IT_TC2); DMA_Cmd(DMA1_Channel2, DISABLE); DMA_Cmd(DMA1_Channel3, DISABLE); GPIO_SetBits(GPIOA, GPIO_Pin_4); } }

这个例子里,RX DMA 的中断优先级要高于 TX DMA,否则在极端时序下,TX 完成中断先到,程序错误地拉高片选。接收方向在标准库里必须配置为DMA_DIR_PeripheralSRC,内存地址自增,外设地址固定指向SPI1->DR

4.2 DMA 循环模式什么时候适合 SPI

标准库里DMA_Mode_Circular让 DMA 传输完后自动重载CNDTR并继续搬运。连续读取传感器数据流时,可以配成循环模式,配合DMA_GetCurrDataCounter计算新数据量。但要注意,循环模式下不会产生 Normal 模式的TC中断,只有半传输(HT)和传输完成(TC)中断可用来搬数据。SPI 从设备如果要求每条指令的字节数固定、片选必须拉高结束,循环模式就不适用。

一个典型误用是:用循环模式做 AD7705 这类 SPI 接口 ADC 的连续采样,但每帧数据以片选分界,帧与帧之间片选要拉高。循环模式下 DMA 不间断搬运,没有机会拉高片选,导致帧同步丢失。正确做法是 Normal 模式,每读完一帧在 DMA 中断里拉高片选,再重新配置CNDTR开启下一轮。

4.3 三个必查边界参数

DMA_PeripheralInc设为 Disable 是确定的。外设地址固定在一个 32 位寄存器上,绝不自动增量。DMA_MemoryInc方向如果忘记 Enable,DMA 会反复覆盖缓冲区第一个字节,数据看起来像全是最后一个字节。

DMA_Priority在只有一个 DMA 工作时无所谓,但如果 SPI_DMA 和串口 DMA 同时运行,SPI 优先级建议设为DMA_Priority_VeryHigh,否则大批量串口打印会拉长 SPI 传输间隙,从设备容易超时。

CNDTR寄存器只有 16 位,单次 DMA 传输不能超过 65535 字节。W25Q64 的一页是 256 字节,SD 卡一个扇区 512 字节,一般不会触顶。但如果是刷全屏 OLED(128×64),一次传输 1024 字节,离上限还远;而驱动某些 32 位色彩 TFT 屏时,一帧数据可能超过 64KB,就需要拆成多次 DMA 传输。

4.4 排错时先看寄存器而不是改代码

SPI_DMA 出问题时,不要急着调SPI_BaudRatePrescaler,先用调试器读DMA1_Channel3->CNDTR。如果 DMA 使能后该值不变,说明 DMA 没收到外设请求,检查SPI_I2S_DMACmd是否已使能。如果CNDTR递减但 SPI 的RXNE一直为 0,说明时钟没产生,检查 SPI 主模式配置和 SCK 引脚。

常见的是 SPI1 引脚 PA5、PA6、PA7 初始化成GPIO_Mode_AF_PP,但漏配 AFIO 时钟。STM32F103 上 SPI1 不需要RCC_APB2Periph_AFIO,但若用了重映射到 PB3、PB4、PB5,则必须打开 AFIO 时钟并调用GPIO_PinRemapConfig,否则引脚不工作,DMA 照常搬运但线上没有任何波形。

5. 验证 SPI_DMA 传输是否真的高效的两个技巧

5.1 用 GPIO 翻转测实际 CPU 占用

初始化时把一个空闲 GPIO(比如 PB0)设为推挽输出,在 main 主循环中做翻转。跑同样的 SPI 数据发送任务,对比裸 SPI 查询发送和 SPI_DMA 发送两种情况下 PB0 翻转频率的差异。DMA 模式下主循环几乎不被阻塞,翻转频率显著升高;查询模式下 SPI 发送期间主循环卡住,翻转频率明显下降。

// 主循环中测试翻转频率 while (1) { GPIO_SetBits(GPIOB, GPIO_Pin_0); SPI1_DMA_Send(tx_buf, 128); while (DMA_GetFlagStatus(DMA1_FLAG_TC3) == RESET); GPIO_ResetBits(GPIOB, GPIO_Pin_0); // 其余应用逻辑 }

用示波器或逻辑分析仪测 PB0 的周期时间,能直接算出 SPI 传输耗时和剩余可用 CPU 时间。DMA_GetFlagStatus在库函数里是纯寄存器读取,适合轮询判断发送完成,但要注意它返回后CNDTR可能已清零而 SPI 的 BSY 还没结束,所以用于耗时测试没问题,实际片选控制还是要待在 BSY 确认之后。

5.2 对 DMA 缓冲区做 Cache 清理并非多余

标准库代码在 STM32F103 上运行时没有 Cache 问题,但很多人习惯从 F4 或 F7 代码移植过来,F103 有两个特殊之处:一是 DMA 访问 SRAM 不经过 CPU 总线,不需要SCB_CleanDCache,二是 F103 的 DMA 不支持存储器到存储器以外的自动字节对齐,所以缓冲区首地址建议 4 字节对齐,否则某些编译器优化选项下 DMA 读不到正确数据。

如果 SPI_DMA 代码配的发送缓冲区是局部变量数组,在 GCC 的高优化等级下可能被优化到寄存器而无法取地址,DMA 搬运的就是未知地址的数据。把发送和接收缓冲区定义为全局数组,或者在函数前加__attribute__((aligned(4))),能避免这类“代码看着对但数据不对”的问题。

最后可以在 DMA 中断里加一个计数器变量,每传输完成一次加一。主循环定期读这个值判断传输是否持续发生。如果计数器长时间不动,先把 DMA 的CNDTRSPI1->SR寄存器抓出来对比,对照参考手册里的忙标志和错误标志,比逐行看代码更快定位问题。SPI_DMA 的调试本质是确认“外设请求产生 DMA 传输,DMA 请求服务外设”这个闭合链路没有断裂,链路每段都有对应的状态寄存器可查。

本文还有配套的精品资源,点击获取

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

Python上下文管理器:原理、应用与性能优化

1. Python上下文管理器的本质与价值第一次接触with语句时,我正处理一个棘手的文件操作bug——脚本异常退出时总有文件描述符泄漏。传统try-finally写法让代码臃肿不堪,直到发现上下文管理器这个优雅的解决方案。本质上,它通过__enter__和__ex…

作者头像 李华
网站建设 2026/9/10 17:22:10

ESP32无线日志监控方案:MQTT与Webhook实践

1. 项目背景与核心需求在物联网设备开发中,单片机日志的实时监控一直是个痛点。传统方式要么需要物理连接调试器,要么得搭建复杂的日志服务器。我在最近一个ESP32温室监控项目中,就遇到了需要实时查看多个节点运行状态的需求。这个方案的核心…

作者头像 李华
网站建设 2026/9/10 17:21:47

锂电池卷绕机控制系统设计与EtherCAT总线应用

1. 锂电池卷绕机控制系统概述锂电池卷绕机作为动力电池生产线的核心设备,其控制系统直接决定了电芯的卷绕精度和生产效率。这套采用欧姆龙NY512-1500 PLC作为主控、威伦通触摸屏为人机界面、通过从机扩展机架实现分布式控制的系统,代表了当前锂电池设备控…

作者头像 李华
网站建设 2026/9/10 17:21:46

Python轻量CNN实现细粒度鸟类识别

简介:本资源是一个基于Python与卷积神经网络(CNN)实现的鸟类图像识别实战项目,面向深度学习初学者、计算机视觉入门者及高校课程设计学生,解决真实场景下的细粒度图像分类问题。压缩包共856个文件,主体为84…

作者头像 李华