尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SPI通信原理与STM32驱动W25Q128 Flash实战:从时序到驱动开发

SPI通信原理与STM32驱动W25Q128 Flash实战:从时序到驱动开发 接触过单片机开发的朋友应该都有体会UART、I2C、SPI这三兄弟里SPI往往是最让人又爱又恨的那个。爱它是因为速率高、全双工、硬件接线简单恨它是因为时序、分频、片选、模式匹配稍微哪个环节没搞对读回来的数据就是一堆乱码。这篇文章我想从SPI的底层时序讲起再手把手带你把STM32的SPI外设和W25Q128这颗典型的SPI NOR Flash彻底玩明白。不管你是刚点亮LED的新手还是正在做毕业设计、准备把嵌入式开发当成职业方向跟着完整走一遍读ID、擦除、写入、校验的流程以后碰到任何SPI设备OLED、SD卡、传感器、CAN控制器心里都有底。1. SPI通信原理为什么它又快又简单1.1 四根线各司其职SPI全称是Serial Peripheral Interface串行外设接口。名字听起来很正式本质上就是个主从结构的同步串行总线。一个SPI总线系统里主设备通常就是MCU负责产生时钟、控制通信节奏从设备比如Flash、显示屏、SD卡被动响应。连接两者的线最少只要四根SCLKSerial Clock串行时钟由主设备产生。每一拍时钟传输一位数据时钟频率决定了通信速率。MOSIMaster Out Slave In主设备输出、从设备输入。主设备给从设备发指令和数据就走这根线。MISOMaster In Slave Out主设备输入、从设备输出。从设备给主设备回数据、回状态就走这根线。CSChip Select/SSSlave Select片选信号同样是主设备控制低电平有效。拉低哪根片选线就等于在说我现在要跟你通信了其他挂在同一条总线上的设备乖乖靠边站。经常有朋友问I2C才两根线SPI凭什么要四根线原因很简单——它是全双工的。MOSI和MISO物理上独立数据收发可以同时进行主设备在发指令的当下就能收到从设备返回的数据。这一点后面讲时序的时候你会有更直观的感受。1.2 全双工是怎么做到的SPI之所以能在同一个时钟周期内既发送又接收靠的就是两条独立的数据线移位寄存器这个机制。先理解移位寄存器。主设备内部有一个8位的发送移位寄存器和8位的接收移位寄存器从设备也一样。通信开始时主设备把要发送的字节装载到发送移位寄存器里然后开始给SCLK打时钟。每来一个时钟的上升沿或下降沿具体看模式主设备的发送移位寄存器往MOSI线上推出一位同时从设备的移位寄存器从MOSI线上采走一位反过来从设备也往MISO线上推出一位主设备从MISO线上采走一位。你可以把整个通信过程想象成两个人站在传送带两边同时往对面扔球、同时接对面扔过来的球主设备发一个字节的过程其实就是扔出自己8个球、接住对方8个球的过程。正因为收和发是同时进行的所以当主设备向W25Q128发送读取数据指令后还没等指令发完Flash已经开始把第一个字节往MISO上送了。这一点在写驱动时特别重要每次HAL_SPI_TransmitReceive的时候你要提前想清楚我发出去的这1个字节换来的是什么。发读指令、发地址、收数据、发空字节——每一个时钟都是在同时做两件事数据能不能对上全靠对这个机制的清晰理解。1.3 和其他通信方式的对比很多初学者分不清UART、I2C、SPI到底什么场合选哪个。我用大白话给你捋一遍通信方式线数速率全双工特点UART2TX/RX较低典型115200bps-数Mbps是异步、无需时钟线、点对点适合调试日志、模块通信I2C2SDA/SCL较低标准100kbps/快速400kbps否同步、半双工、多设备靠地址区分适合接传感器、EEPROMSPI4SCLK/MOSI/MISO/CS高STM32轻松跑几十MHz是同步、全双工、多设备靠片选区分适合Flash、SD卡、TFT屏选型逻辑也很直白如果对速率要求高、数据量大比如显示屏刷图、Flash读写固件优先考虑SPI如果只是接几个温湿度传感器I2C就够了还能省GPIO调试输出不追求速度就用UART简单环保。2. SPI时序的核心CPOL和CPHA2.1 四种模式怎么选SPI不像UART那样有起始位停止位它靠SCLK的极性和相位来同步数据。芯片手册里常看到CPOLClock Polarity时钟极性和CPHAClock Phase时钟相位这两个参数它们组合出了SPI的四种工作模式CPOL决定SCLK空闲状态的电平。CPOL0表示空闲时SCLK为低电平CPOL1表示空闲时SCLK为高电平。CPHA决定数据在哪个边沿被采样。CPHA0表示在第一个边沿上升沿或下降沿取决于CPOL采样CPHA1表示在第二个边沿采样。四种模式的组合我建议直接背这张表SPI模式CPOLCPHA空闲电平数据采样边沿Mode 000低上升沿Mode 101低下降沿Mode 210高下降沿Mode 311高上升沿实际项目里碰到最多的就是Mode 0因为大部分SPI外设的默认设计就是空闲时时钟拉低数据在上升沿采样。W25Q128这颗Flash支持Mode 0和Mode 3所以我们驱动里通常配置成Mode 0稳妥。2.2 时序图怎么看新手看时序图最容易懵的是那一堆上升沿下降沿、建立时间保持时间。我换个方式给你拆解。以Mode 0为例SCLK空闲为低电平。主设备把要发送的第一位数据放到MOSI线上这时候其实可以理解为先把球放到传送带上然后SCLK拉高——这个上升沿就是开始采样的信号。从设备看到上升沿把MOSI线上的电平读进去同时把自己的数据放到MISO线上。等SCLK拉低一位数据就算传完了。接着主设备放第二位数据、SCLK再拉高……如此循环8次一个字节传完CS再拉高释放总线。打个比方SCLK就像老师喊开始答题的铃声主设备必须先布置好考卷数据放到线上老师才喊开始时钟边沿到来学生听到铃声才开始读题从设备采样。顺序反了数据就错位——这就是为什么SPI对建立时间有要求数据必须在时钟边沿之前稳定下来。看时序图的时候你要抓住三个要素数据线在哪个边沿变化、在哪个边沿采样、片选什么时候拉低什么时候拉高。W25Q128的数据手册里读指令0x03的时序图画得很清楚CS先拉低、然后MOSI上依次发出指令字节和24位地址、同时MISO上开始吐出数据。对照着时序图写代码比闭着眼调参数腰不疼腿不酸。2.3 模式不匹配的后果写驱动时最大的坑之一就是主从设备模式不匹配。如果STM32配的是Mode 0而外设要求Mode 3会出现什么情况时钟极性和采样边沿都反了最典型的两个症状读回来的数据全是错位位、或者数据整体向左/向右平移一位。我自己就踩过一回。当时手里一个射频模块手册上写的是SPI Mode 0我配好之后读寄存器发现低字节总是比预期的大一倍。排查了半天最后用逻辑分析仪一看才发现模块上电后内部实际工作在Mode 3手册和硬件对不上害得我怀疑人生。所以拿到任何SPI设备第一步不是接线写驱动而是翻手册确认模式、最大时钟频率、以及数据位顺序MSB first还是LSB first。3. W25Q128 Flash的内部结构与操作逻辑3.1 W25Q128到底有多大、怎么组织W25Q128型号里的128代表容量是128Mbit除以8就是16MB。这颗Flash在嵌入式领域简直是国民级存在——路由器、工控板、开发板上跑固件或存字库图片四处都能看到它的身影。它的内部结构是这样组织的页Page最小写入单位1页是256字节。扇区Sector最小擦除单位1个扇区是4KB也就是16页。块Block32KB或64KB两种分别对应块擦除指令。整个芯片16MB一共4096个扇区。这里有个概念一定要刻在脑子里Flash写入之前必须先擦除而且只能把1擦成0不能把0写回1。擦除操作会把整个扇区或块、整片都变成0xFF。所以如果你想修改某个扇区里的一个字节正确姿势是先把整个扇区读出来存到RAM在RAM里改好然后擦除这个扇区最后再把整页数据写回去。直接往没擦过的地址写大概率写进去的和你想要的对不上。3.2 W25Q128的指令集W25Q128通过SPI发送指令字节来操作常用指令整理如下指令指令码Hex功能说明Write Enable0x06写使能执行写/擦除前必须先发Write Disable0x04写禁止Read Status Register0x05读状态寄存器常用于查忙Read Data0x03普通读最快支持133MHz实际取决于分频Page Program0x02页编程一次最多写256字节Sector Erase0x20扇区擦除4KBBlock Erase0xD8块擦除64KBChip Erase0xC7整片擦除慎用Read JEDEC ID0x9F读厂商/容量ID常用于验证通信每次发写指令或擦除指令之前必须先发0x06写使能发完之后通过读状态寄存器的第0位WIP写进行中位来确认Flash内部操作是否完成。我习惯封装一个W25QXX_WaitBusy()函数循环读状态寄存器直到WIP位变0再继续下一步。3.3 为什么读地址是24位W25Q128容量16MB需要24位地址来寻址2^24 16M字节。所以读数据指令和页编程指令的格式都是1个字节指令码 3个字节地址高字节在前。地址怎么拆比如要读地址0x123456就分三次发送0x12、0x34、0x56。这里体现的是MSB First——高位先发配置SPI时要注意这个参数别选成LSB First否则地址直接变天书。同理页编程发送完指令和地址之后紧接着往MOSI上送最多256字节数据。超过256字节怎么办地址会自动回卷到当前页开头所以跨页写入必须分多次页编程这是新手经常忽略的细节。4. STM32 SPI外设配置从CubeMX到代码落地4.1 CubeMX里的关键配置项打开STM32CubeMX选中你用的芯片型号把SPI1勾成Full-Duplex Master全双工主机模式然后进入参数配置页面。需要关心的就这几项Frame Format选Motorola8位别选TI模式。Data Size8 Bits。虽然SPI外设也支持16位帧但绝大多数SPI芯片都用8位。Clock Polarity (CPOL)和Clock Phase (CPHA)按外设手册选W25Q128用Low / 1st Edge就是Mode 0。Prescaler预分频决定SCLK实际频率。比如APB2总线时钟是84MHz除以4就是21MHz——W25Q128的读指令支持很高频率但页编程、擦除这些内部操作慢通信时钟太高意义不大还会增加信号干扰风险。我一般选4分频或8分频跑起来稳定。NSS Signal Type这里重点讲一下见下一节。First BitMSB First。CubeMX配置完之后生成代码HAL库会自动初始化好SPI句柄和相关GPIO。你要是想手动写寄存器也行但用CubeMX能少踩很多时钟使能、复用功能配置的坑。4.2 片选硬件NSS还是软件GPIOSPI的片选CS有两种控制方式硬件NSS和软件GPIO。这是实际项目里争论很多的一个点也是热词里spi硬件片选与软件片选这个话题的来源。硬件NSSSTM32的SPI外设里有NSS引脚可以配置成硬件输出、由外设自动拉低拉高。听起来很方便但有坑如果你用的是从模式NSS电平变化会影响SPI状态在主模式下想让Peripheral硬件自动控制NSS需要开SSOESlave Select Output Enable而且一个SPI外设只能控制一根NSS引脚——你要是挂了两个Flash第二个就得另外接GPIO。软件片选直接用普通GPIO手动拉低/拉高。这也是我强烈推荐新手和大多数项目采用的方式。原因很简单灵活、可控、逻辑清晰。你在代码里想什么时候拉低CS就什么时候拉低完全跟着时序走而且一个SPI总线上挂多设备时每个设备用各自独立的GPIO做CS互不干扰。实际操作中我通常是CS引脚配置成GPIO输出推挽模式默认输出高片选无效通信前拉低通信结束拉高。忙轮询、擦除等待这些场景下CS的状态可以非常精确地控制不用被硬件的NSS逻辑牵着走。4.3 HAL库发送接收的几种APISTM32 HAL库封装了SPI发送接收的几组API我分别说下它们的使用场景HAL_SPI_Transmit/HAL_SPI_Receive半双工只用一根数据线收发SPI全双工场景不推荐。HAL_SPI_TransmitReceive最常用的全双工收发函数。发送缓冲和接收缓冲同时工作收发字节数必须一致。HAL_SPI_TransmitReceive_DMADMA版本适合大块数据搬运比如从Flash读一整个固件包能省CPU。代价是用完要小心处理DMA回调避免数据还没搬完就去做别的。我给出的建议刚上手阶段读写小数据用阻塞式HAL_SPI_TransmitReceive就好等到要接显示屏、要读大容量数据时再换DMA。别一上来就上DMA排查问题的难度不是一个数量级的。5. 从读ID到读写Flash驱动开发完整流程5.1 第一步读JEDEC ID验证通信拿到W25Q128第一件事永远不是写数据而是读ID。读ID能同时验证接线、SPI模式、时钟配置是否正确。如果ID读出来是0xEF 0x40 0x18说明通信一切正常读出来全是0xFF八成是片选没拉低或者接线问题读出来乱码八成是模式不匹配或时钟太快。读取流程很简单CS拉低发0x9F指令然后连续发3个空字节0x00收3个数据字节CS拉高。第1字节是厂商ID0xEF代表Winbond第2字节是类型ID第3字节是容量ID0x18对应128Mbit。核心代码大概长这样uint8_t buf[4] {0x9F, 0x00, 0x00, 0x00}; uint8_t recv[4] {0}; HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(hspi1, buf, recv, 4, 100); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); printf(JEDEC ID: 0x%02X 0x%02X 0x%02X\r\n, recv[1], recv[2], recv[3]);注意一次TransmitReceive调用里发送和接收是同时进行的所以我发的4个字节分别是指令、空、空、空接收到的4个字节里第1个是无效/垃圾数据后面3个才是ID。这个多发空字节凑时钟的技巧就是SPI全双工特性最直白的体现。5.2 写一个通用读写函数通信链路确认没问题之后就可以封装通用的读写了。我常用的两个函数void W25QXX_ReadBytes(uint32_t addr, uint8_t *pBuf, uint32_t len) { uint8_t cmd[4]; cmd[0] 0x03; cmd[1] (uint8_t)(addr 16); cmd[2] (uint8_t)(addr 8); cmd[3] (uint8_t)(addr); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 4, 100); HAL_SPI_Receive(hspi1, pBuf, len, 100); // 全双工时Receive会同时发0xFF HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); } uint8_t W25QXX_WritePage(uint32_t addr, const uint8_t *pBuf) { uint8_t cmd[4]; cmd[0] 0x02; cmd[1] (uint8_t)(addr 16); cmd[2] (uint8_t)(addr 8); cmd[3] (uint8_t)(addr); W25QXX_WriteEnable(); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 4, 100); HAL_SPI_Transmit(hspi1, (uint8_t *)pBuf, 256, 100); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); W25QXX_WaitBusy(); }写页编程时地址末尾要对齐到256字节边界低8位为0不然数据会回卷覆盖页开头。这一点在写跨页数据时要特别注意我一般在封装层做边界检查超过当前页就拆分多次写。5.3 擦除与写入的完整流程擦除操作和写操作一样必须先写使能。扇区擦除一次4KB块擦除一次64KB。我封装了扇区擦除函数后写数据流程通常是计算数据所在扇区范围。把对应扇区内容读出来备份到RAM如果之前有数据。扇区擦除。在备份数据基础上修改要改的部分。按256字节一页一页写回去。这套流程也是文件系统FatFS读写Flash时底层的通用逻辑。理解这一步你后面看文件系统的实现会轻松很多。5.4 从块设备驱动角度理解驱动开发标题里提到驱动开发在MCU裸机或者RTOS环境下驱动开发通常就是指这么一件事把芯片寄存器操作封装成对外接口上层不用关心底层细节。比如我想做一个小型日志系统对上层只暴露三个接口Log_Write(addr, data)、Log_Read(addr, buf, len)、Log_Erase(addr)底层的SPI时序、W25Q128指令、忙等待全都被藏在这个接口后面。再往大一点说你在Linux里写SPI设备驱动也一样设备树里描述SPI控制器和从设备节点的兼容性、片选引脚、时钟频率驱动里注册spi_driver在probe函数里初始化设备通过spi_transfer结构体与硬件交互。思维模式是完全一致的。先在这里把MCU裸机驱动写顺手了以后看Linux驱动代码就不会觉得是另一个世界的东西。6. 常见问题与排查技巧实录6.1 读ID全是0xFF这是SPI调试时出现频率最高的问题。从头到尾顺序排查这几项CS片子有没有拉低示波器/万用表量CS引脚通信期间应该是低电平。不要默认代码拉低了就真的拉低了GPIO配置成复用功能还是普通输出会差很多。接线接反没有MOSI和MISO接反是新手经典操作。MCU的MOSI要接Flash的DI数据输入MCU的MISO接Flash的DO数据输出。拿万用表量通断不一定能查出来最好对照原理图一个个看。时钟没起来或分频不对确认CubeMX里SPI时钟源、GPIO复用是开了的板子上如果APB1/APB2的SPI时钟没使能寄存器写进去也白搭。供电问题W25Q128供电一般是3.3V如果板子某个引脚因为电平不匹配导致Flash没正常工作ID也会读不到。用示波器看下Flash VCC引脚是否稳定。6.2 读ID乱码、特定字节移位数据回来了但跟你预期对不上大概率是模式匹配问题。拿逻辑分析仪抓一下SCLK、MOSI、MISO和手册时序图对比空闲电平和采样沿是不是反了如果是把CPOL/CPHA改一改再试。另一种情况是错位而不是乱码读到的ID是0x40 0x18 0xEF而不是0xEF 0x40 0x18这通常不是硬件问题是发送接收缓冲没有对齐。SPI是全双工你发第一个字节的同时已经收到从设备吐过来的第一个字节了所以收/发缓冲要错开一位理解。这个在5.1节已经有体现多看两遍就通了。6.3 写数据读回来不对读回来不是0xFF但也不是你要的数据先检查是不是没擦除就直接写了。Flash的特性是只能把1变0所以往没擦除的区域写结果就是新旧数据与在一起变乱。每次写之前务必确认目标扇区已经擦除过了。然后检查页边界写入数据跨越了256字节页边界而你没有分页处理地址回卷会导致数据写到页开头覆盖掉之前的内容。用4KB扇区做先擦后写再校验的流程时尤其要注意。6.4 擦除等待和写使能顺序状态寄存器查忙轮询是必须的。擦除一个扇区最坏情况要几百毫秒如果擦除后立刻去写Flash还在忙指令会被忽略数据写入失败且没有任何报错——这就是表面看起来正常、数据其实没写进去的经典套路。正确顺序永远是发0x06写使能 → 检查状态寄存器WIP位其实这步也可以放到操作前→ 发擦除/写指令 → 循环查WIP位直到空闲 → 再进行下一步操作。别把写使能和擦除/写指令放反也别省掉忙检查尤其是连续进行多个扇区操作时。6.5 时钟频率太高导致的偶发失败有人图快把SPI分频拉到2分频甚至不分频结果批量读写时偶发出错。原因是信号完整性排线太长、跳线接触不良、地线没接好高频下波形严重畸变。解决思路是降频或者缩短杜邦线、改用焊接或FPC连接。工业级做法是SPI时钟速率控制在从设备额定频率的1/4甚至更低保证余量。W25Q128最高能跑104MHz但STM32跑二三十兆已经够快对绝大多数应用来说24MHz以下稳稳的。屏幕闪烁、Flash偶发读写错误这种玄学问题一半以上最后都能归结到这个原因信号完整性。排除完代码问题请优先怀疑它。7. 一些可以继续深入的方向文章写到这核心的东西已经讲清楚了。如果你后面想更进一步这几个方向我觉得值得继续折腾DMA方式读写Flash特别是连续读大量数据比如从Flash启动升级固件DMA能把CPU解放出来整个过程体验会好很多。FatFS文件系统移植把Flash挂成一个小盘符用文件接口读写数据底层驱动就是基于我上面写的这些函数。理解Flash的先擦后写和搬移逻辑文件系统设计思想就能看透一大半。Flash磨损均衡Flash擦写寿命有限频繁擦同一个扇区会把它写坏。做日志存储类应用时可以考虑轮换写不同扇区延长整体寿命。多从设备SPI总线一个SPI总线上挂Flash、SD卡、显示屏每个设备一个CS。实践一下软件片选的优雅之处。Linux SPI驱动如果你打算往嵌入式Linux方向走把裸机驱动里寄存器操作→封装接口的思路迁移到设备树、spi_transfer、spi_driver上学习曲线会平缓很多。在实际开发中SPI始终是那个看起来简单、细究起来全是门道的外设。把时序和模式搞明白把W25Q128的读写流程走通一次你在嵌入式这条路上就拿到了一个很通用的底层技能。我的个人体会是调试SPI时永远不要怀疑是不是芯片坏了先从片选、模式、时钟、接线这四个方面动手查90%的问题都出在这几个地方。最后再分享一个小技巧手头常备一台逻辑分析仪嵌入式调试省的不是一星半点的心。
返回列表