ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F429与W25Q128的DMA读写改造实战

STM32F429与W25Q128的DMA读写改造实战 简介面向STM32F429与W25Q128高速数据交互需求该工程实现了通过DMA完成大量数据读写的完整方案。工程以Keil MDK项目形式组织包含192个文件压缩包约4.67MB其中以.c源文件、.h头文件为主辅以编译生成的.o、.d、.axf等构建产物并保留有.crf、.lst、.map等调试与依赖信息便于对照配置进行二次开发。内容覆盖SPI接口初始化、DMA通道选择与触发源配置、W25Q128命令序列封装以及传输完成中断处理等关键环节可直接参考或移植到实际设计中。已有3082人学习下载适合正在调试大容量外部Flash读写或希望借助DMA降低CPU占用的嵌入式开发者。这份代码能帮助读者快速理清DMA与SPI协同工作的配置思路减少自行查阅手册与反复实验的时间成本。 做嵌入式的人多少都有这种经历要从W25Q128这类SPI Flash里读几兆音频、字库或者日志直接上轮询循环CPU就像被绑在SPI传输上一样读一个字节等一个字节。后来我在一个数据采集项目里要把Flash里攒下的大块数据搬出来上传轮询方案终于把系统拖得不像样我才下决心把STM32F429和W25Q128之间的读写链路整条换成DMA。这篇文章就是这次改造过程的完整复盘从硬件引脚、DMA资源分配、命令时序到HAL库代码和一些网上不太容易查到的坑都会讲到。适合手头有类似需求想把W25Q128的批量读写从CPU上解放出来的同学参考。1. 为什么这个项目要把W25Q128的读写改成DMA先说结论DMA不会让Flash本身变快它解决的是CPU占用问题。W25Q128是128Mbit16MB的SPI NOR Flash数据手册上最高的读取时钟可以跑到100MHz以上但STM32F429的SPI1挂在APB2上APB2最高90MHz所以SPI分频后通常跑到45MHz也就是一个字节大约需要0.18us。读1MB数据光传输时间就接近190ms。如果按字节轮询这190ms里CPU基本别想干别的就算改成中断方式45MHz下每来一个字节就进一次中断中断服务程序的开销也会把有效吞吐压得很低系统调度和实时响应一样会出问题。我这个项目的数据采集板是这样的W25Q128里存着连续采集几十分钟的原始数据设备空闲时要把这批数据通过以太网口送出去。原来的阻塞式SPI读取函数一旦开始执行就占着CPU不放结果网络协议栈被饿死发送缓冲区经常断流。改成DMA之后我只要配置好SPI和DMACPU马上可以继续处理网络组包、协议栈收包这些事情DMA把Flash数据搬完后再通过中断通知我。同样是读1MB传输耗时没有明显变化但CPU占用从接近100%降到几乎为0整个系统顺滑了很多。还有一点也值得说DMA和中断方式不一样。SPI加上中断处理每个字节都要经历“进中断、读寄存器、存储、退出中断”这一套流程45MHz时钟下CPU上下文切换的开销会反噬吞吐。DMA是把外设寄存器到内存之间的搬运交给硬件DMA控制器规则设置好以后CPU只在传输开始和结束的时候参与一下。对大量数据的批量读写来说这才是真正合适的方式。2. 硬件连接和DMA资源规划不踩硬件坑才能让软件跑起来我的板卡上用的是SPI1外设和W25Q128连接方式如下表这也是一块很常规的接法信号STM32F429引脚说明SCKPA5SPI1时钟最高配到45MHzMISOPA6Flash数据输出接STM32的MISOMOSIPA7命令、地址、写数据从这脚进FlashCSPA4片选我把它配成普通GPIO输出不用硬件NSSCS引脚强烈建议用普通GPIO控制。SPI硬件NSS虽然在主机模式下也能管理片选但它更多是用来做多从机仲裁和通信帧同步的在DMA连续读写场景下NSS信号的翻转时机和DMA传输的边界不一定吻合容易在事务中间把Flash的片选拉高导致本次读操作被中断。用GPIO软件控制CSDMA开始前置低DMA结束后拉高时序上完全由自己掌控最稳妥。供电方面W25Q128是3.3V器件直接接STM32F429的3.3V电源轨没问题。我习惯在它的VCC引脚旁边放一个100nF去耦电容尽量靠近芯片引脚不然大电流读写瞬间的电压跌落会让Flash工作不稳定。VCC和GND之间再留一个大点的10uF钽电容尤其是数据量大的场合别省。DMA资源上STM32F429的SPI1由DMA2服务我用的分配是DMA请求DMA流通道SPI1_RXDMA2 Stream0通道3SPI1_TXDMA2 Stream3通道3如果你用CubeMX自动分配它有可能把TX放到DMA2 Stream5或者Stream6上只要不和别的外设冲突问题不大。需要注意的关键点是DMA流是独占资源一个DMA2 Stream0不能同时服务SPI1_RX和DAC也不能同时服务SPI1_RX和别的外设。后面我会专门讲这个坑。3. W25Q128的底层读写规则DMA不能替你违背Flash的物理特性W25Q128虽然是大容量存储芯片但它不是RAM不能像SRAM那样直接往任意地址写任意数据。它内部结构按页、扇区、块分了好几层结构大小数量页Page256字节65536页扇区Sector4KB16页4096扇区块Block64KB16扇区256块Flash编程有一个物理特性是“写只能把1变成0擦除才能把0变成1”。往一个还没有擦除过的区域写数据写进去的结果往往是不对的。所以标准的写入流程是先擦除目标扇区/块再执行页编程。页编程又有个上限一次最多写256字节而且这256字节不能跨页也就是说地址低字节是0xFF的话一页只能写1个字节地址低字节是0x00的话这一页最多能写满256个字节。基于这个特性DMA写入时不能直接扔一个几KB的缓冲区进去。必须先把数据按页拆分每页单独做一次“写使能 页编程命令 页内数据”的DMA传输然后等待Flash内部编程完成。等待完成靠的是查状态寄存器的WIP位0x05指令读出的bit0为0才代表这页写完了。DMA能帮你做的是把“命令地址数据”这一整串内容快速搬到SPI发送寄存器DMA不能帮你做的是省去擦除等待和页编程等待。这些等待时间由Flash物理特性决定CPU该轮询还得轮询。所以把这套东西改成DMA之后程序设计上更要分清楚哪些阶段是“等DMA搬运”哪些阶段是“等Flash内部忙完”两个阶段不能混在一起。4. HAL库下的DMA读写实现从配置到代码4.1 CubeMX配置的关键点我用CubeMX生成工程时主要配置如下SPI1开启为“Full-Duplex Master”8bit数据宽度MSB先行硬件NSS禁用。时钟设置中SPI1的时钟源来自APB2APB2配置为90MHzSPI波特率预分频选2也就是SCK45MHz。DMA配置里添加SPI1_TX和SPI1_RX两个请求方向分别是MemoryToPeripheral、PeripheralToMemory模式设为Normal数据宽度按字节内存地址递增外设地址不递增。NVIC里打开对应DMA流的全局中断SPI1的全局中断也要开。生成代码后可以检查一下HAL_SPI_MspInit里面应该能看到类似hdma_spi1_rx.Instance DMA2_Stream0;和hdma_spi1_tx.Instance DMA2_Stream3;的绑定关系。如果这里绑错了后面DMA调用会直接返回HAL_ERROR或者进HardFault。4.2 大块DMA读取我建议用全双工一条龙读取W25Q128最简单的方式是发0x03读命令然后跟3字节地址再连续读数据。这里有个容易绕晕的地方SPI是全双工发送命令的同时MISO上也会同步返回数据。如果只启动SPI接收DMAMOSI线上没有数据时钟虽然照样产生但很多人的经验是这部分时钟和数据的对齐关系容易出问题。我的做法是直接用全双工模式把发送缓冲区和接收缓冲区一起交给DMA。发送缓冲区的前4个字节放“读命令3字节地址”后面全部放0xFF作为虚拟数据目的是让SPI时钟在整个事务期间持续运转。接收缓冲区和发送缓冲区一样长前4个字节是垃圾数据真正有效数据从索引4开始。#define SPI_FLASH_PAGE_SIZE 256 static uint8_t s_flash_tx[SPI_FLASH_PAGE_SIZE 4]; static uint8_t s_flash_rx[SPI_FLASH_PAGE_SIZE 4]; static volatile uint8_t s_dma_busy 0; void W25Q128_ReadDMA(uint32_t addr, uint8_t *dst, uint32_t len) { if (s_dma_busy) { return; } s_flash_tx[0] 0x03; // Read Data s_flash_tx[1] (addr 16) 0xFF; s_flash_tx[2] (addr 8) 0xFF; s_flash_tx[3] addr 0xFF; memset(s_flash_tx[4], 0xFF, len); // 虚拟字节 s_dma_busy 1; W25Q128_CS_LOW(); if (HAL_SPI_TransmitReceive_DMA(hspi1, s_flash_tx, s_flash_rx, len 4) ! HAL_OK) { W25Q128_CS_HIGH(); s_dma_busy 0; } // 等待DMA传输完成这里用事件标志或者信号量更好 while (s_dma_busy) { // 喂狗、调度 } memcpy(dst, s_flash_rx[4], len); // 去掉前4字节垃圾数据 }对应的DMA完成回调void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi hspi1) { // 等SPI移位寄存器完全停止再拉高CS while (__HAL_SPI_GET_FLAG(hspi, SPI_FLAG_BSY)); W25Q128_CS_HIGH(); s_dma_busy 0; } }这里有一个细节DMA完成回调触发时最后一个字节可能还在SPI移位寄存器里没有完全移出。如果在回调里直接拉高CSFlash可能只收到最后一个字节的一部分读取结果就会出现偶发错位。我的做法是在回调里用__HAL_SPI_GET_FLAG(hspi, SPI_FLAG_BSY)等待SPI总线不忙了再拉高CS。4.3 分页DMA写入命令、地址、数据一次发完写入比读取麻烦因为必须分页。我封装了一个等待整块数据写完的函数思路很直接每次计算当前地址所在页的剩余空间只发一页的数据等这一页写完再挪到下一页继续发。static uint8_t s_write_buf[4 SPI_FLASH_PAGE_SIZE]; static volatile uint8_t s_write_done 0; void W25Q128_WriteEnable(void) { uint8_t cmd 0x06; W25Q128_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 1, 100); W25Q128_CS_HIGH(); } uint8_t W25Q128_ReadStatus(void) { uint8_t cmd 0x05; uint8_t status 0; W25Q128_CS_LOW(); HAL_SPI_TransmitReceive(hspi1, cmd, status, 2, 100); W25Q128_CS_HIGH(); return status; } void W25Q128_WriteDMA(uint32_t addr, const uint8_t *buf, uint32_t len) { while (len 0) { uint32_t remain SPI_FLASH_PAGE_SIZE - (addr (SPI_FLASH_PAGE_SIZE - 1)); uint32_t chunk (len remain) ? remain : len; W25Q128_WriteEnable(); s_write_buf[0] 0x02; // Page Program s_write_buf[1] (addr 16) 0xFF; s_write_buf[2] (addr 8) 0xFF; s_write_buf[3] addr 0xFF; memcpy(s_write_buf[4], buf, chunk); s_write_done 0; W25Q128_CS_LOW(); if (HAL_SPI_Transmit_DMA(hspi1, s_write_buf, chunk 4) ! HAL_OK) { W25Q128_CS_HIGH(); } while (s_write_done 0) { } // 等待Flash内部编程完成 while (W25Q128_ReadStatus() 0x01) { } addr chunk; buf chunk; len - chunk; } }写DMA的完成回调和读稍有不同void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi hspi1) { while (__HAL_SPI_GET p a hrefhttps://download.csdn.net/download/zoujiao6609/10608593 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进