拓冰建站拓冰建站
首页 / 资讯中心 / 正文

STM32标准库SPI+DMA驱动W25Q256JV读写与串口调试实战

简介面向嵌入式开发者的串行外设接口与直接存储器访问通信实战资源以STM32F429单片机为载体在标准库环境下实现读写W25Q256JV闪存芯片并借助DMA完成数据同步收发。工程使用可变参数宏实现类似printf与scanf的输入输出功能极大方便调试同时覆盖滴答定时器延时、LED指示灯、按键检测与下载配置等基础功能模块适合正在学习SPI时序或DMA仲裁机制的工程师与高校学生。资源压缩包共包含126个文件其中以57个头文件与55个C语言源文件为主另有工程配置、烧录、分散加载与批处理等辅助文件整体大小仅616KB目录结构清晰。目前已有三千一百零三人学习下载可作项目移植与二次开发的参考。资源中重点展示了发送与接收共用同一DMA模块时的优先级仲裁处理以及软件控制片选信号拉高、在接收完成中断中结束通信等排错思路同时使用Flash输入输出结构体与共用体的设计有助于快速理解底层时序并迁移到类似项目。 做STM32的数据存储我几乎绕不开Flash读写而只要一提到FlashSPI和DMA这对搭档就一定会被拉出来遛一遛。这次我把标准库V3.5环境下的SPIDMA收发、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引脚SCKPA5CLKMISOPA6DOMOSIPA7DICSPA4CS#VCC3.3VVCCGNDGNDGND关于库的选择我还是用了标准外设库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. 标准库下SPIDMA收发的完整实现3.1 SPI1初始化模式、极性和分频选择标准库的SPI初始化条理很清楚关键是几个参数必须选对。W25Q256JV支持SPI模式0和模式3我习惯用模式0即CPOLLow、CPHA1Edge空闲时钟为低电平第一个边沿采样。别搞混了如果你的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); }波特率预分频我用了8F103的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和接收DMASPI是同步全双工协议主设备读从设备时必须持续输出时钟。而输出时钟的方式就是主设备往发送寄存器写数据写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 IDerase 0擦除0号扇区write 0 hello往0地址写入字符串helloread 0 16从0地址读16字节并十六进制打印这里有一个经验之谈scanf处理字符串和数字混输时格式控制要小心比如用scanf(%s %x %[^\r\n])这类格式按空格分割参数。串口调试助手发送时注意回车换行符Windows下是\r\nfgetc会把\r也读进来所以命令解析时要把\r和\n都过滤掉否则命令会对不上。我用这个方法后调试Flash再也不用反复改代码烧录了。改测试地址、改数据内容直接在串口终端敲命令就行效率成倍提升。这算是这个项目里最值得做的一个模块。5. 实操过程全记录与验证结果5.1 编译烧录与串口验证工程用的是Keil MDK5芯片选STM32F103C8标准库V3.5需要在Options里勾选Use MicroLIB。烧录用ST-LinkSWD四线接好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如果不下一次传前重新设置CountDMA启动直接不会工作。另外传输完成后检查DMA_GetFlagStatus时要用对通道对应的标志位SPI1接收是DMA1_FLAG_TC2发送是DMA1_FLAG_TC3搞混了可能永远等不到。6.3 Flash写不进或写入数据错位这个八成是忘了擦除或者擦除后没等BUSY清零。Nor Flash的物理特性是擦除后所有位变1只有1能写成00不能直接写成1。所以写前必须擦除擦完以后发读状态寄存器0x05检查BUSY位等它变0再写。另外页编程不能跨页我实测过跨页写入会造成数据覆盖必须做拆分。6.4 printf没输出或程序卡死程序卡死基本是半主机模式没处理勾上MicroLIB基本能解决。如果是printf有输出但乱码先看波特率和USART时钟对不对F103的USART1挂APB2总线72MHzUSART2和3挂APB1是36MHz波特率寄存器的计算基准不一样。另外fputc里要等TXE而不是TC不然每个字符都慢半拍。还有个细节是scanf获取输入时串口助手发的换行符\r会被fgetc读到。我用了一个简单的过滤函数遇到\r和\n就跳过把有效字符交给命令解析器这个问题就消失了。写到这里我最后分享一个小技巧这套工程我强烈建议你先用轮询SPI把Flash读写跑通再改成DMA最后再加printf和scanf。一次引入太多变量出了bug你根本不知道是SPI的问题还是DMA的问题还是串口的问题。我一开始直接全上结果ID读出来了、写数据失败查了快两个小时才发现是擦除后没等BUSY。按“先能跑、再高效、后好用”的顺序做调试难度会低很多。这也是我做外设驱动一直沿用的方法。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门