ARTICLE DETAIL

资讯详情

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

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

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.cHARDWARE目录放 spi.c、dma.c 或 spi_dma.cSYSTEM目录放延时和串口调试代码CORE和STM32F10x_FWLib是标准库本体。从stm32f10x_spi.c和stm32f10x_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_PeripheralDataSize和DMA_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 TXSPI1_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_GetFlagStatus和DMA_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 接收为什么需要同时开 TXSPI 是全双工协议主机要接收数据就必须发出时钟。时钟来自主机对SPI1-DR的写入。若只配置接收 DMA不发送任何字节那么主机不产生时钟MISO 上的数据根本不会移入接收寄存器。这就是“SPI DMA 只能收不能发或只能发不能收”问题的根源。在 STM32F103 标准库下用 DMA 接收数据的一个常见做法是让 TX DMA 和 RX DMA 同时工作。TX 方向发送一个哑字节0xFFRX 方向接收从设备回复的数据。比如读取 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方向如果忘记 EnableDMA 会反复覆盖缓冲区第一个字节数据看起来像全是最后一个字节。DMA_Priority在只有一个 DMA 工作时无所谓但如果 SPI_DMA 和串口 DMA 同时运行SPI 优先级建议设为DMA_Priority_VeryHigh否则大批量串口打印会拉长 SPI 传输间隙从设备容易超时。CNDTR寄存器只有 16 位单次 DMA 传输不能超过 65535 字节。W25Q64 的一页是 256 字节SD 卡一个扇区 512 字节一般不会触顶。但如果是刷全屏 OLED128×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 的CNDTR和SPI1-SR寄存器抓出来对比对照参考手册里的忙标志和错误标志比逐行看代码更快定位问题。SPI_DMA 的调试本质是确认“外设请求产生 DMA 传输DMA 请求服务外设”这个闭合链路没有断裂链路每段都有对应的状态寄存器可查。本文还有配套的精品资源点击获取
返回列表