STM32标准库SPI+DMA驱动W25Q256JV读写与串口调试实战
2026/9/7 5:44:45 网站建设 项目流程

简介:面向嵌入式开发者的串行外设接口与直接存储器访问通信实战资源,以STM32F429单片机为载体,在标准库环境下实现读写W25Q256JV闪存芯片,并借助DMA完成数据同步收发。工程使用可变参数宏实现类似printf与scanf的输入输出功能,极大方便调试;同时覆盖滴答定时器延时、LED指示灯、按键检测与下载配置等基础功能模块,适合正在学习SPI时序或DMA仲裁机制的工程师与高校学生。资源压缩包共包含126个文件,其中以57个头文件与55个C语言源文件为主,另有工程配置、烧录、分散加载与批处理等辅助文件,整体大小仅616KB,目录结构清晰。目前已有三千一百零三人学习下载,可作项目移植与二次开发的参考。资源中重点展示了发送与接收共用同一DMA模块时的优先级仲裁处理,以及软件控制片选信号拉高、在接收完成中断中结束通信等排错思路;同时使用Flash输入输出结构体与共用体的设计,有助于快速理解底层时序并迁移到类似项目。 做STM32的数据存储,我几乎绕不开Flash读写;而只要一提到Flash,SPI和DMA这对搭档就一定会被拉出来遛一遛。这次我把标准库V3.5环境下的SPI+DMA收发、W25Q256JV读写、仿printf/scanf输入输出完整跑了一遍,包括读芯片ID、扇区擦除、页编程、整片读写校验,以及通过串口终端交互式下发命令。整个项目跑通后最大的感受是:这四样东西单独拎出来都不复杂,但拧在一起时,时序、片选、DMA传输长度和串口重定向的各种小坑会轮着出来招呼你。

这篇文章适合正在用标准库调SPI、想用DMA解放CPU,或者准备在F103上挂一颗Nor Flash做数据存储记录的朋友。我会把硬件接线、SPI和DMA的初始化参数、Flash命令集、printf/scanf重定向代码,以及调试时踩过的几个坑都摊开来说,保证你照着搭能少走不少弯路。

1. 整体设计思路:为什么非要把它们串在一起

1.1 这套组合解决了什么问题

先说结论:这个项目的本质,是搭建一个“高效存储 + 友好调试”的最小系统。SPI负责和Flash通信,DMA负责让数据搬运不占CPU,而仿printf/scanf输入输出则是给你一个能实时下发命令、看返回结果的交互界面。

如果把这三件事拆开看,单独调SPI读Flash是入门课,单独调DMA收发也不难,但实际产品里不会让你用轮询方式一字节一字节搬数据。比如写一张256字节的页,轮询模式CPU要死等SPI的TXE和RXNE标志几百次;换成DMA发送,你只需配置好传输长度和内存地址,启动后CPU就去做别的事了,传输完成再回来校验结果。

再说仿printf和scanf,它的价值在于把调试信息从“串口输出一堆十六进制数”升级成“人能直接读懂的文本”,而且能通过串口命令直接操作Flash。我做完这套以后,测试擦除、写入、读取就是敲一行命令的事,不用每次改代码重新烧录。这对调试效率和方案验证的帮助是实打实的。

1.2 硬件接线与版本选型参考

硬件上我用的是STM32F103C8T6最小系统板,Flash选W25Q256JV,这是一颗32MB的Nor Flash,读写速度在SPI设备里算主流。接线非常简单,SPI1四根线加片选,全部3.3V电平,注意不要和5V逻辑混接。

信号STM32F103引脚W25Q256JV引脚
SCKPA5CLK
MISOPA6DO
MOSIPA7DI
CSPA4CS#
VCC3.3VVCC
GNDGNDGND

关于库的选择,我还是用了标准外设库V3.5而不是HAL。原因有两个:第一,很多老项目、课程设计和公司维护代码都是标准库写的,你接手的时候没得选;第二,标准库的寄存器操作路径更短,SPI和DMA的关系看得更直观,适合把原理吃透。如果你以后切HAL,底层思想是一样的,迁移成本并不高。

2. SPI协议与W25Q256JV核心细节拆解

2.1 W25Q256JV的存储结构:块、扇区、页必须搞清楚

写这颗Flash之前,它的容量结构必须先刻在脑子里。W25Q256JV总容量256Mbit,也就是32MB。最小的可擦除单位是4KB的扇区(Sector),一个扇区里面有16个页,每页256字节。64KB是块(Block),整个芯片一共512个块。

为什么强调这个?因为Nor Flash的物理特性决定了写之前必须先擦除,而擦除只能按扇区、块或者全片操作,不能单独擦某个字节。你要修改某个页里的几个字节,正确姿势是:先把整个扇区读出来放到内存,改好数据,再擦掉整个扇区,最后把数据写回去。很多人第一次写Nor Flash直接往地址写数据,读出来全是0xFF,就是因为忘了擦除这一步。

页编程也有讲究,一次最多写256字节,如果跨页了,数据会绕回页首把前面覆盖掉。比如你要往0x800100写入300字节,这300字节会从页内偏移0开始写,写到255后又回到偏移0继续写,结果数据全乱了。所以我在封装写函数时,强制做跨页拆分,一次只写一个页。

2.2 Flash常用命令集与状态检测

W25Q256JV的操作全部通过命令码加地址加数据完成,我列几个最常用的:

命令命令码功能说明
JEDEC ID0x9F读芯片ID,返回3字节
写使能0x06擦除和编程前必须发
读状态寄存器0x05查BUSY位,判断Flash忙不忙
扇区擦除0x20擦除一个4KB扇区
页编程0x02写一页数据(最多256字节)
读数据0x03从指定地址连续读出数据
进入4字节地址模式0xB7把地址从3字节扩展为4字节

读状态寄存器是每个擦写操作之后必须等的。具体做法是发0x05,然后连续读字节,检查最低位(BUSY),如果为1说明Flash还在忙,必须等到它变成0才能进行下一步。实测中我见过不少人擦除后立刻写数据,结果写失败,就是没等BUSY清除。

2.3 很容易被忽略的坑:4字节地址模式

W25Q256JV这颗芯片默认使用3字节地址,但3字节最大只能寻址16MB。芯片总容量32MB,你要访问高16MB,就必须处理地址扩展问题。有两种做法,一种是发0xB7进入4字节地址模式,所有地址都按4字节发;另一种是用专用的扩展地址寄存器配合3字节地址访问高地址段。

我推荐直接进4字节模式,代码逻辑简单统一。初始化时发送0xB7即可,之后所有命令的地址字段都变成4字节。如果你只操作低16MB,可以偷懒用3字节地址,但等到要操作高地址时再发现数据写不进去,那就得回头排查,反而更折腾。我这次直接按4字节地址模式做,一劳永逸。

3. 标准库下SPI+DMA收发的完整实现

3.1 SPI1初始化:模式、极性和分频选择

标准库的SPI初始化条理很清楚,关键是几个参数必须选对。W25Q256JV支持SPI模式0和模式3,我习惯用模式0,即CPOL=Low、CPHA=1Edge,空闲时钟为低电平,第一个边沿采样。别搞混了,如果你的MISO读出来全是0xFF或者数据错位,第一个怀疑对象就是极性相位。

void SPI1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; 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_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_4); 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_InitStructure.SPI_CRCPolynomial = 7; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }

波特率预分频我用了8,F103的APB2时钟是72MHz,分频8就是9MHz。W25Q256JV规格书上说最高支持133MHz,所以瓶颈在MCU侧,9MHz对于飞线调试完全够用,稳定优先。等PCB做好、走线短了,再把分频降到2提到36MHz也不迟。

3.2 DMA通道配置与内存映射

STM32F103的SPI1发送对应DMA1_Channel3,接收对应DMA1_Channel2。这个映射关系是固定的,用错通道数据搬不动。DMA配置里还有一个容易忽略的点:外设地址固定成SPI1->DR,不能递增;内存地址要递增,因为数据在缓冲区里是连续存放的。

void SPI1_DMA_RX_TX_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel3); // SPI1_TX DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&SPI1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)tx_dma_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); DMA_DeInit(DMA1_Channel2); // SPI1_RX DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&SPI1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)rx_dma_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; 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_Channel2, &DMA_InitStructure); SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_TX | SPI_I2S_DMAReq_RX, ENABLE); }

初始化后DMA并不会开始传输,必须重新设置缓冲区地址和传输长度,也就是每次传输前都要重新配置DMA_BufferSize,然后使能对应的DMA通道。标准库的一个坑是,DMA传输完成后,需要重新赋值DMA_BufferSize才能再次启动,不然DMA认为传输长度还是上次剩余的值,直接不工作。

3.3 读Flash时为什么要同时开发送DMA和接收DMA

SPI是同步全双工协议,主设备读从设备时,必须持续输出时钟。而输出时钟的方式就是主设备往发送寄存器写数据,写0xFF也好写0x00也好,关键是得写。DMA接收模式下,接收DMA负责把SPI的RX寄存器搬到内存,但如果没有数据往外写,SPI不会产生时钟,接收DMA就永远等不到数据。

所以我的读数据函数长这样:先软件拉低片选,发送读命令0x03和4字节目标地址,然后同时启动接收DMA和发送DMA。发送DMA发送的是一块全部填充0xFF的数据,长度等于你要读的字节数,这样每发一个0xFF就产生一个时钟周期,接收DMA同步接回一个字节。两个DMA传输完成后,拉高片选,整段读操作结束。

void W25Q_ReadData(uint32_t addr, uint8_t *read_buf, uint32_t len) { uint8_t cmd[5]; memset(tx_dma_buf, 0xFF, sizeof(tx_dma_buf)); CS_LOW(); cmd[0] = 0x03; cmd[1] = (addr >> 24) & 0xFF; cmd[2] = (addr >> 16) & 0xFF; cmd[3] = (addr >> 8) & 0xFF; cmd[4] = addr & 0xFF; for (int i = 0; i < 5; i++) { SPI_I2S_SendData(SPI1, cmd[i]); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); SPI_I2S_ReceiveData(SPI1); } DMA_Cmd(DMA1_Channel3, DISABLE); DMA_Cmd(DMA1_Channel2, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel3, len); DMA_SetCurrDataCounter(DMA1_Channel2, len); DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)read_buf; DMA_Init(DMA1_Channel2, &DMA_InitStructure); DMA_Cmd(DMA1_Channel3, ENABLE); DMA_Cmd(DMA1_Channel2, ENABLE); while (DMA_GetFlagStatus(DMA1_FLAG_TC2) == RESET); DMA_ClearFlag(DMA1_FLAG_TC2); DMA_ClearFlag(DMA1_FLAG_TC3); CS_HIGH(); }

写数据的逻辑和读类似,先发写使能命令0x06,再发页编程命令0x02和地址,然后启动发送DMA,让DMA把内存中的数据连续写到SPI发送寄存器。写完以后记得查BUSY位,Flash擦写需要时间,没等忙完就做下一步操作,十有八九会出问题。

4. 仿printf和scanf输入输出:串口重定向

4.1 重定向的本质:让printf不知道自己在写串口

C库的printf最终是通过fputc逐字符输出,scanf通过fgetc逐字符获取输入。嵌入式裸机环境下没有文件系统,但C库仍然预留了这两个钩子函数。只要你重新实现fputc和fgetc,把字符送到串口,printf和scanf就自动变成串口输入输出了。

标准库默认情况下,printf会进入半主机模式(Semihosting),在Keil下表现为程序卡死在BKPT指令,串口什么都没有。解决办法有两个:一是勾选Keil的Use MicroLIB微库选项,微库的精简实现不会触发半主机;二是手动实现_sys_exit等函数屏蔽半主机模式。最省事的还是勾选MicroLIB。

4.2 fputc和fgetc的代码实现

重定向代码非常简单,我用USART1,波特率115200。

#include <stdio.h> int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); return ch; } int fgetc(FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET); return (int)USART_ReceiveData(USART1); }

fputc的用法是发一个字节等一个字节,等TXE标志置位表示发送寄存器空。这里别用TC标志,TC要等移位寄存器也空了才置位,做打印会明显拖速度。fgetc就是死等RXNE标志,收到一个字符就返回一个字符。

4.3 用scanf搭一个串口命令行终端

有了重定向,scanf就成了串口命令解析器。我在这套工程里写了几个简单命令,通过串口调试助手输入,就可以直接测试Flash的各种操作:

输入命令执行操作
id读取并打印JEDEC ID
erase 0擦除0号扇区
write 0 hello往0地址写入字符串hello
read 0 16从0地址读16字节并十六进制打印

这里有一个经验之谈:scanf处理字符串和数字混输时,格式控制要小心,比如用scanf("%s %x %[^\r\n]")这类格式按空格分割参数。串口调试助手发送时注意回车换行符,Windows下是\r\n,fgetc会把\r也读进来,所以命令解析时要把\r和\n都过滤掉,否则命令会对不上。

我用这个方法后,调试Flash再也不用反复改代码烧录了。改测试地址、改数据内容,直接在串口终端敲命令就行,效率成倍提升。这算是这个项目里最值得做的一个模块。

5. 实操过程全记录与验证结果

5.1 编译烧录与串口验证

工程用的是Keil MDK5,芯片选STM32F103C8,标准库V3.5,需要在Options里勾选Use MicroLIB。烧录用ST-Link,SWD四线接好,Download后复位运行,打开串口调试助手,波特率115200,这时候程序每上电会先执行一次硬件自检:打印芯片ID。

实测读取W25Q256JV的JEDEC ID返回0xEF 0x40 0x19,第一字节是厂商代码Winbond,第二字节0x40代表SPI系列,第三字节0x19对应256Mbit容量。只要打印出EF 40 19,说明SPI通信和Flash识别已经通了。如果你的ID返回全是FF或00,优先检查片选和接线,尤其是MISO这条线。

5.2 读写与擦除的闭环测试

我做了这样一轮完整测试:先发id确认通信,然后erase 0擦除第0扇区,read 0 16读出来应该是全0xFF。接着write 0 hello,再read 0 16,能看到前面5个字节变成68 65 6C 6C 6F,也就是ascii码的hello。

页编程跨页问题我也专门做了测试,写一个超过256字节的字符串,比如300字节,然后读回来会发现偏移0到255的数据正常,256到299的数据跑到了页首。这就是我在2.1节强调的跨页回卷现象,代码里做页边界拆分的必要性就是通过这种测出来的。修正以后,任意跨页长度的连续写操作都能完整写回,读数据对比全部一致。

5.3 DMA带来的实际提升

为了直观感受DMA的效果,我把读1KB数据的操作在轮询模式下统计耗时,再和DMA模式对比。同样读1024字节,轮询模式下CPU一直在SPI状态寄存器上打转;DMA模式下,配置好DMA后CPU可以去做别的,包括打印日志、刷新指示灯、处理串口命令,等TC标志后再回来取数据。

从工程上的意义来说,DMA不是让你这一条读操作快多少,而是让CPU从线程阻塞中解脱出来。如果你的系统里还有其他实时任务,比如ADC采样、PWM控制、显示刷新,DMA的价值就会非常明显。这也是为什么产品代码里几乎不会用阻塞式轮询做数据搬运。

6. 常见问题与排查技巧速查表

6.1 SPI通信一片空白,全是FF或00

这个问题的排查顺序我建议是:先查片选,用示波器或者万用表看CS有没有正常拉低拉高;再查模式和极性,MISO全FF往往是CPOL和CPHA配置不对,或者是MISO接错线;最后查分频,飞线情况下SPI频率太高信号质量差,降到4.5MHz或更低试试。

还有一个隐蔽情况:SPI_NSS用了硬件模式,但NSS引脚没接,从设备认为没有被选中。我统一用软件NSS,并且手动控制PA4的高低电平,省去NSS引脚的麻烦。

6.2 DMA传输不完成或长度不对

DMA最常见的坑是没重新赋传输长度就再次启动。DMA是边传边减计数器的,传完后计数器是0,如果不下一次传前重新设置Count,DMA启动直接不会工作。另外,传输完成后检查DMA_GetFlagStatus时要用对通道对应的标志位,SPI1接收是DMA1_FLAG_TC2,发送是DMA1_FLAG_TC3,搞混了可能永远等不到。

6.3 Flash写不进或写入数据错位

这个八成是忘了擦除或者擦除后没等BUSY清零。Nor Flash的物理特性是擦除后所有位变1,只有1能写成0,0不能直接写成1。所以写前必须擦除,擦完以后发读状态寄存器0x05检查BUSY位,等它变0再写。另外页编程不能跨页,我实测过跨页写入会造成数据覆盖,必须做拆分。

6.4 printf没输出或程序卡死

程序卡死基本是半主机模式没处理,勾上MicroLIB基本能解决。如果是printf有输出但乱码,先看波特率和USART时钟对不对,F103的USART1挂APB2总线72MHz,USART2和3挂APB1是36MHz,波特率寄存器的计算基准不一样。另外fputc里要等TXE而不是TC,不然每个字符都慢半拍。

还有个细节是scanf获取输入时,串口助手发的换行符\r会被fgetc读到。我用了一个简单的过滤函数,遇到\r和\n就跳过,把有效字符交给命令解析器,这个问题就消失了。

写到这里,我最后分享一个小技巧:这套工程我强烈建议你先用轮询SPI把Flash读写跑通,再改成DMA,最后再加printf和scanf。一次引入太多变量,出了bug你根本不知道是SPI的问题还是DMA的问题还是串口的问题。我一开始直接全上,结果ID读出来了、写数据失败,查了快两个小时才发现是擦除后没等BUSY。按“先能跑、再高效、后好用”的顺序做,调试难度会低很多。这也是我做外设驱动一直沿用的方法。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询