ARTICLE DETAIL

资讯详情

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

STM32 SPI+DMA驱动W25Q256 Flash完整教程:从配置到串口调试

STM32 SPI+DMA驱动W25Q256 Flash完整教程:从配置到串口调试 简介这是一份基于STM32F429IGT6的Keil MDK工程资源核心演示标准库下SPI5通过DMA收发数据并读写W25Q256JV Flash芯片同时实现仿printf和scanf的可变参数宏输入输出。项目包含完整的LED、按键、SysTick延时等基础驱动发送与接收缓冲区均为一个扇区大小4096BNSS采用软件控制且针对TX产生SCK时钟的场景给出了DMA优先级配置与中断收尾的排错思路适合正在学习STM32 SPI/DMA或Flash读写的中高级开发者参考。资源共126个文件以57个h头文件和55个c源码文件为主另含Keil工程配置、链接脚本及hex文件等压缩包整体约616KB目录紧凑、可直接打开工程对照学习。已有3103人学习使用代码结构清晰、注释集中尤其对DMA仲裁优先级和软件片选时序的处理具有实践参考价值。 实话讲SPI和DMA单独拿出来都不算难但把它们和Flash读写、串口命令交互串在一起调试过程中踩的坑是一层接一层。这篇文章会把整个链路的思路、配置、代码和排错过程完整记录下来项目覆盖标准库下的SPI主机通信、DMA收发、W25Q256JV的读写擦除以及仿printf/scanf的串口交互层。适合刚学完STM32基础外设、想做一个完整小项目的读者也适合正在被SPI时序和DMA丢数据折磨的工程师参考。1. 整体思路与方案选型为什么用SPIDMA这套组合1.1 从需求倒推这套组合本质在解决什么问题先理清这个项目到底做了几件事。第一步用STM32的硬件SPI接口去驱动一颗W25Q256JV NOR Flash芯片完成ID读取、扇区擦除、页编程和数据读取。第二步在SPI收发过程中引入DMA让大块数据搬运不占用CPU。第三步把串口改造成类似printf和scanf的交互工具通过串口助手直接输入命令来控制Flash的读写。这三步串起来以后实际上就构成了一个非常典型的嵌入式数据存储与交互框架底层是SPIDMA的高速传输通道中间是Flash驱动层上层是串口命令解析层。这个框架在很多场景下都能复用比如存储日志、保存配置参数、OTA升级时的固件暂存甚至可以作为简易文件系统的底层搬运工。选型上我有几个明确考量。首先标准库在这个场景下比HAL库更直接因为标准库的外设结构体配置方式几乎和寄存器一一对应出问题时你能清楚地知道操作的是哪个寄存器排查起来更顺手。其次SPI比I2C更适合这类项目W25Q256JV本身就是SPI接口的Flash而且SPI是全双工、速率高、时序简单读写大块数据时明显比I2C有优势。1.2 硬件资源规划引脚、DMA通道与片选策略开发板以STM32F103C8T6为例实际用其他型号也大同小异。SPI1对应PA5SCK、PA6MISO、PA7MOSIUSART1对应PA9TX、PA10RX片选CS用PA4作为普通GPIO控制。DMA方面STM32F103的SPI1_TX映射到DMA1_Channel3SPI1_RX映射到DMA1_Channel2。在片选策略上我建议直接用软件控制GPIO不用硬件NSS自动管理。标准库下硬件NSS配合DMA发送时片选拉低和拉高的时机经常和发送完成中断冲突容易把最后一字节切掉。软件片选虽然多写一行代码但在Flash这类要求CS在整帧传输期间保持低电平的从设备上控制精度和可靠性反而更高。资源规划表格如下功能引脚/通道说明SPI1_SCKPA5复用推挽输出速率50MHzSPI1_MISOPA6浮空输入或上拉输入SPI1_MOSIPA7复用推挽输出CS片选PA4普通推挽输出软件控制USART1_TX/RXPA9/PA10串口命令交互SPI1_TX DMADMA1_Channel3内存到外设SPI1_RX DMADMA1_Channel2外设到内存2. SPI底层配置从外设初始化到DMA收发链路2.1 SPI外设初始化模式0加软件片选稳妥优先先上代码再解释。SPI1挂在APB2总线上F103的APB2最高36MHz这里选择8分频得到4.5MHz的SCK。为什么不用更高的主频因为W25Q256JV虽然支持到133MHz但在STM32F103这种老平台上4.5MHz已经比串口快了近300倍调试阶段稳定比极限速度更重要等整个链路都跑通了再回头提频也不迟。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_Init(SPI1, SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }重点解释几个容易出错的参数。SPI_CPOL_Low和SPI_CPHA_1Edge合起来就是模式0也就是SCK空闲为低电平数据在上升沿采样。W25Q256JV的数据手册里明确支持模式0和模式3选模式0只是因为工程习惯没有特殊原因。SPI_NSS_Soft这个配置很关键。标准库把NSS配置为软件模式后硬件不再自动控制NSS引脚你就可以放心地用PA4做片选。如果配置成硬件NSS模式主模式下NSS信号会在多主机冲突检测时被硬件拉低配合DMA传输会出现莫名其妙的片选抖动。2.2 DMA的收发逻辑先使能接收DMA再触发发送DMASPI的DMA配置分两路。发送侧内存地址是待发送的缓冲区外设地址是SPI1_DR寄存器方向是内存到外设缓冲区长度就是要发送的字节数。接收侧外设地址同样是SPI1_DR内存地址是接收缓冲区方向是外设到内存。这里有一个很多人第一次写都会翻车的地方SPI是全双工总线主机向从机发送数据的同时也必须接收数据否则接收数据会堆积在RXNE寄存器里导致溢出。所以用DMA做SPI读操作时必须先使能接收DMA通道再使能发送DMA通道并且发送缓冲区和接收缓冲区长度一致。否则你会发现读回来的数据少一个字节或者第一字节永远是垃圾数据。void SPI1_DMA_Init(uint8_t *txBuf, uint8_t *rxBuf, uint16_t len) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel3); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)SPI1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)txBuf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize len; 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); DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)rxBuf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize len; DMA_Init(DMA1_Channel2, DMA_InitStructure); DMA_ITConfig(DMA1_Channel3, DMA_IT_TC, ENABLE); }DMA发送完成后会产生传输完成中断这个中断里要做的第一件事不是立刻拉高CS而是等待SPI的BSY标志清零。原因是DMA把最后一个字节写入SPI_DR后SPI移位寄存器可能还在往外输出此时如果CS被拉高Flash会认为传输提前终止最后一个字节丢失。正确做法是轮询SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY)等到BSY0再拉高CS。2.3 DMA传输的数据宽度和缓冲区对齐问题DMA配置里外设数据宽度和内存数据宽度都设置成了Byte这对应SPI_DR寄存器是8位有效的情况。如果涉及16位的数据宽度需要同步修改SPI_DataSize和DMA的数据宽度配置而且内存缓冲区地址需要满足相应的对齐要求。字节宽度下缓冲区对齐要求没那么严格但强烈建议养成2字节对齐甚至4字节对齐的习惯。标准库的DMA在Cortex-M3内核上对非对齐地址也能工作但某些情况下会触发总线错误或者导致性能下降。工程上最稳的做法是把缓冲数组定义成uint32_t类型或者用__attribute__((aligned(4)))修饰。还有一个隐蔽的问题不能忽略DMA传输是异步的如果缓冲区定义在函数内部的栈上函数在DMA还没传完时返回栈内存被其他函数复用数据就全乱了。我的习惯是所有DMA缓冲区都定义为全局静态数组或者用malloc分配后不再释放从根上避免这类生命周期问题。另外CPU和DMA同时访问缓冲区时编译器优化可能造成数据不一致缓冲区相关变量要加上volatile修饰。3. W25Q256JV驱动实现从读ID到页编程的完整流程3.1 先读JEDEC ID通信链路通不通一步就知道W25Q256JV的读ID指令是0x9F。发送指令后从设备会连续返回3个字节制造商ID、存储类型ID、容量ID。W25Q256JV的返回值是0xEF、0x40、0x19。如果接的是W25Q128JV第三字节是0x18这个细节可以用来在软件里区分Flash容量。读ID函数如下它同时验证了SPI发送、接收和片选控制三个环节uint32_t SPI_Flash_ReadID(void) { uint32_t id 0; uint8_t cmd 0x9F; CS_LOW(); SPI1_DMA_TransmitReceive(cmd, (uint8_t *)id, 4); CS_HIGH(); return id; }注意读ID时返回的数据是从第2个字节开始有效的因为发送指令字节的同时接收到的第一个字节是无效数据。所以上面的DMA收发长度设为4指令占用1字节后面3字节才是真正的ID。我第一次调试时只发了3字节结果读回来的ID永远是乱的排查了很久才发现是时序长度问题。3.2 写使能和状态寄存器轮询页编程成功的前提每次执行页编程、扇区擦除、写状态寄存器等写操作之前必须先发送0x06写使能命令把状态寄存器里的WEL位置1。否则Flash会直接忽略写命令状态寄存器的WEL位在每次写操作完成后自动清零所以每条写命令前都要重新发一次0x06。Flash执行内部编程或擦除操作时状态寄存器的BUSY位为1。读状态寄存器指令是0x05通过连续读取状态值并判断最低位可以知道内部操作是否完成。这个轮询过程必不可少因为扇区擦除最长可能需要数秒如果擦除尚未完成就发下一条命令结果不可预期。void SPI_Flash_WaitBusy(void) { uint8_t cmd 0x05; uint8_t status 0; CS_LOW(); SPI1_DMA_TransmitReceive(cmd, status, 2); CS_HIGH(); while (status 0x01) { CS_LOW(); SPI1_DMA_TransmitReceive(cmd, status, 2); CS_HIGH(); } }3.3 页编程与扇区擦除跨页边界处理是最大隐患W25Q256JV的页大小是256字节页编程指令是0x02一次最多写入256字节。扇区大小是4KB扇区擦除指令是0x20。页编程的地址是3字节还是4字节取决于当前工作在哪种地址模式后面专门讲。页编程最容易被忽略的问题是跨页边界。假设当前要写入的地址是0x0000FF缓冲区有10字节数据如果直接把10字节全部通过页编程命令写入Flash会从0x0000FF开始写5字节到达页边界后地址回卷到0x000000继续写剩下的5字节。这就会把页首的数据覆盖掉造成逻辑错误。解决办法是每次页编程前先计算当前地址到页末尾还有多少剩余空间如果待写入数据长度超过剩余空间就拆分成两次页编程。实现上先写第一段到页尾再写第二段到新页直到所有数据写完。嵌入式里这种边界问题最常见也最隐蔽逻辑上完全看不出来只有拿到数据比对时才会发现。3.4 W25Q256JV的3字节和4字节地址模式切换W25Q256JV的容量是32MB超过16MB之后用3字节地址无法访问必须使用4字节地址模式。芯片上电后的默认地址模式取决于非易失配置寄存器很多开发板出厂默认是3字节模式所以只访问前16MB没问题一旦地址超过0xFFFFFF读出来的数据全是FF。启用4字节地址模式的方法是发送0xB7命令。发送后后续所有读、写、擦除命令的地址字段都变成4字节包括页编程0x02、读数据0x03、扇区擦除0x20在内的指令格式都要调整。这里容易犯的错是只把地址参数从3字节改成4字节却忘记指令本身也有对应的4字节地址版本实际上W25Q256JV在4字节地址模式下不需要额外指令变体直接发原来的命令加上4字节地址即可。切换代码如下void SPI_Flash_Enter4ByteAddrMode(void) { uint8_t cmd 0xB7; CS_LOW(); SPI1_DMA_TransmitReceive(cmd, NULL, 1); CS_HIGH(); }进入4字节地址模式后我建议加一个全局标志变量所有构造地址字段的地方都根据这个标志决定地址字节数。这样可以避免在代码里到处写死地址长度后续换Flash型号或者改模式时只需要改一处。4. 仿printf/scanf的串口交互层把命令行搬进单片机4.1 printf重定向MicroLIB和半主机模式是必过的坎标准库的printf最终会调用fputc函数向标准输出写字符默认情况下标准输出可能被重定向到调试器也就是半主机模式。在STM32上如果不管这个程序运行到printf时会进入HardFault或者根本卡死在半主机调试请求上。解决方法是牺牲一点浮点打印性能在Keil工程里勾选Use MicroLIB然后自行实现fputc。MicroLIB是一个精简版C库它不依赖半主机模式配合自定义fputc就能把printf的输出转到串口上。实现方式很简单int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }如果要支持scanf同理需要实现fgetc函数从串口接收缓冲区读取数据。直接用标准scanf的问题在于它会阻塞等待输入而且对回车、退格等字符的处理逻辑在嵌入式场景下非常难用。所以我更建议自己实现一个简化的scanf而不是老老实实重定向标准scanf。4.2 环形缓冲区加自定义解析一个可用的mini_scanf先搭一个串口接收环形缓冲区接收中断把每个字节写入缓冲区这是仿scanf能够非阻塞工作的基础。环形缓冲区用两个指针分别表示写入位置和读取位置写入在中断中完成读取在主循环中完成注意关闭中断保护读操作。#define RING_BUF_SIZE 256 static uint8_t ringBuf[RING_BUF_SIZE]; static volatile uint16_t readIdx 0; static volatile uint16_t writeIdx 0; void USART1_IRQHandler(void) { uint16_t next; if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { ringBuf[writeIdx] USART_ReceiveData(USART1); next (writeIdx 1) % RING_BUF_SIZE; if (next ! readIdx) { writeIdx next; } } }有了环形缓冲区就可以实现一个类似scanf的输入函数。这里提供一个简化版支持%d、%x、%s三种格式足够应付Flash调试命令的解析int mini_scanf(const char *fmt, ...) { va_list ap; va_start(ap, fmt); while (*fmt) { if (*fmt %) { fmt; if (*fmt d) { int *val va_arg(ap, int *); *val read_dec_number(); } else if (*fmt x) { uint32_t *val va_arg(ap, uint32_t *); *val read_hex_number(); } else if (*fmt s) { char *buf va_arg(ap, char *); read_string(buf); } } fmt; } va_end(ap); return 0; }read_dec_number和read_hex_number从环形缓冲区逐个读取字符并做进制转换遇回车或空格结束。这套实现占用资源极少行为可预期不会像标准scanf那样出现缓冲区溢出或者阻塞卡死的问题。4.3 命令解析实战通过串口控制Flash读写有了mini_scanf后一个简单的命令行循环就能实现了。主循环里获取字符串用swtich或者strcmp判断命令关键字再调用对应Flash操作函数。比如支持以下命令read 0x000000 256读取指定地址和长度并以十六进制打印write 0x000000 11 22 33把一串十六进制数写入Flasherase 0x000000擦除指定扇区id打印Flash ID这个命令行交互层在实际项目里非常有用。调试Flash驱动时不需要每次改代码重新烧录直接在串口助手里输入命令就能验证功能、测试边界条件效率提升非常明显。5. 常见问题排查实录那些年踩过的坑5.1 烧录报错error: flash download failed和no target found这个问题在调试初期最容易遇到现象是Keil点击下载后直接弹error: flash download failed - target dll has been cancelled或者连接仿真器时提示no stm32 target found。遇到这类报错我的排查顺序固定如下第一步检查接线SWDIO和SWCLK这两根线必须短而且可靠线太长在高速下载时信号反射严重会导致连接失败。第二步检查目标板供电很多时候下载失败是目标板供电不稳导致MCU没有正常上电。第三步如果还是不行按住目标板的复位键点击下载等待Keil开始擦除Flash的瞬间松手。这个方法对付锁死的芯片特别有效原理是让MCU在复位释放后的第一时间进入调试模式。如果按住复位都解决不了多半是芯片已经锁死或者调试端口被复用。这时候用STM32 ST-LINK Utility先做一次全片擦除把Flash里的程序清空解除读保护和写保护芯片就能恢复正常。注意ST-LINK Utility连接时也要选connect under reset模式。5.2 Flash读出来全是0xFF或数据错位读Flash全FF是SPI调试中最常见的现象原因按概率排名如下SPI时钟极性或相位不对。W25Q256JV虽然支持模式0和模式3但如果配的是模式1或模式2从机采样时序对不上读回的数据就是无效的。CS时序错误。传输过程中CS被提前拉高Flash认为命令被终止。DMA发送后一定要等BSY清零再拉高CS。地址模式没切换。访问超过16MB的地址时没进入4字节地址模式读高位地址全是FF。Flash写保护没解除。上新买的Flash芯片有时候状态寄存器写保护位是使能的页编程和擦除都不会成功需要先写状态寄存器把保护关掉。调试这个问题的思路是分层排查先用读ID验证最基础的通信链路ID能读对说明SPI时序没问题再用读状态寄存器验证命令通道最后才去排查地址模式和写保护。5.3 DMA传输过程中数据被破坏或丢失DMA相关的问题通常和缓冲区生命周期、数据宽度、以及SPI的RXNE溢出有关。局部数组传给DMA是重灾区函数返回后DMA还在后台搬运数据被栈上其他内容覆盖调试时看到的现象时好时坏极具迷惑性。严格规则是DMA缓冲区和DMA生命周期的绑定要清清楚楚传完才能释放。另一个容易忽视的问题在SPI半双工或单线模式下的DMA配置。如果项目里某些场景只用SPI发送不接收可以通过SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY)等待BSY清零或者直接查看RXNE是否溢出。一旦RXNE溢出标志置位后续接收的数据全会错位在DMA接收场景下必须确保发送和接收是同步进行的。5.4 串口DMA接收不定长数据的经验补充部分项目需要在DMA模式下接收不定长串口数据这和本文的仿scanf是两个方案但实际工程中经常会同时使用。常规做法是把USART接收配置成DMA循环模式再开启空闲中断IDLE。每次收到一帧数据后硬件会在总线空闲时置位IDLE标志中断里通过DMA_GetCurrDataCounter函数计算当前通道剩余容量再用缓冲总长度减去剩余容量得到新接收的数据长度。这个方案比逐字节中断接收节省大量CPU开销适合波特率较高、数据帧较多的场景。收尾一点个人实操体会调试SPIDMAFlash这条链路我最大的体会是不要想着一步到位。先用查询方式把Flash读写跑通确认SPI时序和Flash命令都没问题再把发送侧换成DMA最后把接收侧也换成DMA。每换一段就验证一次层层推进出问题时定位范围小很多。还有一个小建议逻辑分析仪在这种调试里值回票价16MHz采样率就够用能够把SCK波形和CS时序拍得清清楚楚比用示波器方便很多真的能省下好几个晚上的排查时间。本文还有配套的精品资源点击获取
返回列表