ARTICLE DETAIL

资讯详情

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

SPI通信实战:STM32时序解析与W25Q128驱动开发

SPI通信实战:STM32时序解析与W25Q128驱动开发 SPI是嵌入式开发里出镜率最高的接口之一围着Flash、SD卡、屏幕、传感器转一圈到处都有它的身影。我做过的STM32项目里几乎每次都要挂一颗W25Q128来存日志、配置参数或者采集数据而新手最容易卡住的地方恰恰不是代码逻辑而是SPI时序到底怎么对上。这篇文章我想从SPI传输的本质讲起一步步拆解时序、STM32 SPI外设的配置再到W25Q128的读写驱动把整套流程讲透。内容适合刚学完GPIO和串口、想往SPI外设走的读者也适合正在做数据记录、简易文件系统、或者毕业设计里要用大容量存储的人。读完你能理解“SPI到底是怎么传输数据的”这个核心问题也能独立写出一套能跑通的W25Q128驱动。1. SPI到底是什么四根线如何完成一次数据传输1.1 为什么嵌入式通信需要SPI串行通信里我们最常接触的三兄弟是UART、I2C和SPI。UART简单可靠但天生是异步的双方得事先约定好波特率而且一般只做点对点连接想挂多个设备就得靠复杂的协议栈。I2C虽然用两根线解决了多设备总线问题但它的速率上限不高标准模式100kbps、快速模式400kbps而且每次传输都要带地址帧、应答位协议开销摆在那里。SPI却走的是另一个极端速度快、结构简单、没有繁重的协议封装。它属于同步通信时钟由主机产生主机说“几时采样”从机就“几时采样”双方拿着同一个节拍器跳舞。正因为设计得足够纯粹SPI才能在NOR Flash、SD卡、LCD屏幕、ADC、传感器、CAN控制器这些器件上遍地开花。就拿W25Q128这颗Flash来说正常工作时钟可以达到上百MHz这种吞吐量用UART和I2C很难做到。我刚开始学STM32的时候也觉得SPI这玩意儿不如串口直观毕竟串口一个字节发出去就完事。但后来做了几个需要频繁存日志的项目才明白SPI的意义不在于“发一个字节”而在于“高速地搬数据”尤其是配合DMA之后几乎不占CPU。所以从实用角度讲SPI是STM32外设里绕不开的一块基本功。1.2 四根线的职责与主从架构SPI的硬件连接非常简单满打满算四根线SCLK串行时钟由主机输出相当于歌曲的节拍器MOSI主出从入主机发送数据到从机的通道MISO主入从出从机回传数据到主机的通道CS片选信号低电平有效主机用这根线告诉某个从机“我要跟你说话”这里面最容易被忽略的是CS。SPI总线上可以挂多个从设备但同一时刻只能有一个从设备被选中。片选线拉低那个从机就“竖起耳朵听”片选拉高它就进入高阻状态把MISO让出来避免多个从机同时驱动数据线导致短路。你可以把它理解成一群人开会SCLK是主持人敲桌子的节奏MOSI是主持人讲话的麦克风MISO是现场观众的回话麦克风而CS就是点名器点到谁谁才能开口。从机端的CS一般叫nCS或者SS注意是低有效也就是说空闲时必须保持高电平。很多新手第一次接线把CS直接接地结果设备永远处于选中状态总线上多个设备时就会互相干扰。就算只有一个从机我也建议留一个GPIO控制CS因为很多器件是以“CS的上升沿”作为指令生效的触发边沿W25Q128就是这样CS一直拉低反而会出问题。1.3 移位寄存器全双工传输的底层秘密SPI之所以能做到同时收发靠的是一对“移位寄存器”组成的大闭环。主机内部有一个8位移位寄存器从机内部也有一个主机把要发的数据装进自己的移位寄存器后每来一个时钟边沿两边寄存器就同时左移一位主机最高位通过MOSI送到从机最低位从机最高位通过MISO送回主机最低位。所以一个时钟周期内主机既发出了一位也收到了一位。8个时钟周期后主机把8位数据完整地“推”给了从机同时从机也把8位数据完整地“推”给了主机。这就是为什么SPI是全双工的也是为什么主机想从从机读数据时必须不断发送字节来产生时钟。严格来说那个发送字节的内容往往是无意义的一般填0xFF或者0x00真正有意义的是从机的回传数据。理解这一层之后你再看代码就不会晕了。底层我们只需要一个最核心的函数uint8_t SPI_ReadWriteByte(uint8_t byte) { uint8_t rx 0; HAL_SPI_TransmitReceive(hspi1, byte, rx, 1, 100); return rx; }这个函数把发送和接收封装在一起既用来发指令也用来收数据。后面的所有驱动代码几乎都是建立在这一个函数之上。2. SPI时序拆解四种模式背后的CPOL与CPHA2.1 CPOL和CPHA到底管什么SPI没有规定SCLK空闲时必须是什么电平也没有规定数据必须哪个边沿采样这两件事由两个参数决定CPOL和CPHA。CPOL时钟极性决定SCLK在空闲状态下的电平。CPOL0表示空闲低电平CPOL1表示空闲高电平。CPHA时钟相位决定数据在时钟的哪个边沿被采样。CPHA0表示第一个边沿采样CPHA1表示第二个边沿采样。于是就有了四种组合也就是常说的SPI Mode 0到Mode 3SPI模式CPOLCPHA时钟空闲电平采样边沿Mode 000低第一个边沿上升沿Mode 101低第二个边沿下降沿Mode 210高第一个边沿下降沿Mode 311高第二个边沿上升沿看上去四个模式有点绕但核心就一句话发送方在某个边沿切换数据接收方在另一个边沿锁存数据。这样做是为了让数据在采样时保持稳定不至于抓到刚切换完还在抖动的电平。就像两人传球一个人先出球另一个等球稳了再接。2.2 从时序图看数据怎么被读走拿最常见的Mode 0举例。SCLK空闲为低第一个边沿是上升沿主机在上升沿到来之前就已经把数据位放到了MOSI线上上升沿时从机采样这根线上的电平下降沿时主机继续切换下一位数据。整个过程稳定且简单。如果主机和从机对模式的配置不一致比如从机期待Mode 3主机却配成Mode 0那么数据沿和采样沿会错开整整半个周期本来该上升沿采样的变成了下降沿采样结果读回来的就是一串移位的数据甚至全是0xFF。这个现象在调试W25Q128时非常典型很多人的ID读出来不对不是芯片坏了而是模式配错了。看数据手册是最高效的办法。W25Q128手册里明确写了支持SPI Mode 0和Mode 3也就是说CPOL/CPHA为00或11都能正常工作。实际上这两种模式的采样沿都是上升沿差别只在空闲电平和切换沿很多器件因此两种都支持。为了省心我统一用Mode 0也建议你把默认模式定为Mode 0除非目标芯片手册指出只能用其他模式。2.3 如何选择正确的SPI模式选模式这件事不要靠猜要去看从设备数据手册里的时序图。时序图一般会画出SCLK波形然后标出数据稳定的区间以及采样点位置。你只需要对比自己手里的时钟极性和相位找到对应的模式即可。如果手头没有示波器或逻辑分析仪第一个可以尝试的就是Mode 0因为80%以上的SPI从设备都兼容Mode 0。其次是Mode 3因为这两种模式桥接采样沿一致。Mode 1和Mode 2在外面相对少见很多传感器会用但NOR Flash、SD卡基本都在Mode 0或3上。还有一个容易忽略的参数是位顺序。SPI默认一般是MSB first也就是高位先发但有些芯片是LSB first比如某些WiFi模块配置时就要注意。W25Q128是MSB first所以STM32侧也保持MSB就行不要随便改成LSB。2.4 SPI、UART、I2C什么时候该用谁很多同学学到这里会纠结这么多通信协议到底该用哪个我的经验是看需求不要盲目追新。协议线数同步/异步方向常见速率协议复杂度UART2异步全双工115200bps~几Mbps低I2C2同步半双工100k/400k/1Mbps中SPI4同步全双工几Mbps~几十Mbps低如果只是打印调试信息UART是绝对首选简单可靠如果总线上要挂多个速率要求不高的传感器I2C两根线就能搞定如果是大容量Flash、高刷屏幕、SD卡这种高吞吐场景SPI基本上是不二之选。所以不是哪个更好而是哪个更合适。3. STM32 SPI外设配置与W25Q128读写实操3.1 CubeMX配置SPI1从分频到片选我用STM32CubeMX配置SPI1整体流程非常快但有几个参数值得细说。先是基本模式把SPI1设置为Full-Duplex Master方向选双向全双工。然后是参数区这几个是关键Data Size选8 bits这是绝大多数SPI设备的默认配置First Bit选MSB First对应W25Q128的位顺序Prescaler分频系数决定SCLK频率CPOLLowCPHA1 Edge也就是Mode 0NSS选Software或者Hardware后面单独展开分频系数的计算要看你用的主控。以STM32F407为例SPI1挂在APB2总线上APB2时钟典型是84MHz。CubeMX里选择Prescaler为64那么SCLK就是84/641.3125MHz这个速度对W25Q128来说非常保守跑通逻辑完全没问题。调试阶段我习惯先用低速等通信稳定了再降到32分频、8分频甚至4分频。4分频能到21MHz已经接近很多Flash的高速能力但要注意杜邦线长了之后信号完整性会变差飞线场景别盲目拉高。CubeMX生成工程后HAL库已经帮我们封装好了SPI句柄比如hspi1。底层收发函数直接用HAL_SPI_TransmitReceive不需要关心寄存器细节。但要注意生成代码不会自动初始化CS引脚如果选的是软件NSS需要在GPIO配置里把CS引脚设为推挽输出并且初始化为高电平。3.2 先认识W25Q128的结构与指令系统W25Q128是华邦推出的一颗SPI NOR Flash容量128Mbit换算过来是16MByte这在嵌入式产品里足够存下一大堆日志、字库或者固件备份。它内部的组织方式和硬盘有点像也有“扇区”“块”的概念页Page256字节扇区Sector16页共4KB块Block16个扇区共64KB整颗芯片一共4096个扇区256个块。为什么强调这个结构因为NOR Flash有一个物理特性擦除操作会把区域内的所有位变成1而编程操作只能把1写成0不能把0写回1。所以你想把某个字节从0x00改成0x01必须先把所在区域擦除成0xFF再写入目标值。这个特性直接影响指令设计。写入前必须先发写使能指令0x06然后发页编程指令0x02擦除则分扇区擦除0x20和块擦除0xD8粒度不同耗时也不同。以下是W25Q128最常用到的几条指令指令字节码功能Write Enable0x06写使能写/擦前必须执行Read Data0x03从指定地址连续读数据Page Program0x02从指定地址写数据最多256字节Sector Erase0x20擦除一个4KB扇区Block Erase0xD8擦除一个64KB块Read Status Register0x05读状态寄存器判断是否忙JEDEC ID0x9F读设备ID用于识别芯片状态寄存器的Bit0是忙标志位WIP如果为1说明Flash正在执行内部操作比如擦除或者编程这个时候不能再发新的写操作必须等待它变成0。3.3 第一步驱动读JEDEC ID验证通信通路新拿到一块W25Q128我建议第一件事不是急着读写数据而是先把ID读出来。ID读对了说明接线、SPI模式、时钟频率、片选逻辑全都通了后面再开发就是顺水推舟。读JEDEC ID的流程很简单拉低CS发送0x9F命令然后连续读3个字节最后拉高CS。发送0x9F之后主机每产生一次时钟从机就往MISO上送一个字节的ID所以代码里看起来就是连续调用三次读字节函数只不过发送的内容无所谓填0xFF就行。uint32_t W25Q_ReadID(void) { uint32_t id 0; W25Q_CS_Low(); SPI_ReadWriteByte(0x9F); id | (uint32_t)SPI_ReadWriteByte(0xFF) 16; id | (uint32_t)SPI_ReadWriteByte(0xFF) 8; id | (uint32_t)SPI_ReadWriteByte(0xFF); W25Q_CS_High(); return id; }用串口把这个ID打印出来如果是W25Q128应该看到0xEF4018。0xEF是华邦的厂商ID0x40代表SPI NOR系列0x18代表容量型号。如果串口打印的是0xFFFFFF大概率是通信没通如果打印的是0x000000那就要看看MISO是不是被拉死了。3.4 数据读写的完整流程读数据最简单。拉低CS发0x03命令接着发24位地址然后连续读取字节最后拉高CS。地址在高三位字节里先发高位也就是地址的高8位、中8位、低8位依次发出。void W25Q_ReadData(uint32_t addr, uint8_t *buf, uint16_t len) { W25Q_CS_Low(); SPI_ReadWriteByte(0x03); SPI_ReadWriteByte((addr 16) 0xFF); SPI_ReadWriteByte((addr 8) 0xFF); SPI_ReadWriteByte(addr 0xFF); for (uint16_t i 0; i len; i) { buf[i] SPI_ReadWriteByte(0xFF); } W25Q_CS_High(); }写数据就多几个步骤。先发0x06写使能然后把CS拉高让Flash在CS上升沿确认使能有效再拉低CS发0x02页编程命令接着发24位地址和数据数据发完后拉高CS最后轮询状态寄存器等WIP变为0。这里我吃了很大的亏所以必须单独拎出来强调0x06写使能之后必须把CS拉高一下再拉低发页编程命令。有些新手看到代码里写使能和页编程中间有个CS高电平以为是多余的节奏就直接删了结果数据怎么都写不进去。这不是玄学Flash内部就是靠CS上升沿来锁存写使能状态的。写数据时还有一个绕不开的问题跨页。页编程最多一次性写256字节如果起始地址不是页对齐比如地址0x01FF你硬写超过一页的数据Flash会把超出部分回绕到当前页的页首导致数据覆盖错乱。所以写多字节时要自己算好每页能容纳多少分多次发送。更推荐的做法是封装一个分页写函数void W25Q_WriteData(uint32_t addr, uint8_t *buf, uint16_t len) { uint16_t remain 256 - (addr % 256); if (len remain) remain len; while (1) { W25Q_WriteEnable(); W25Q_CS_Low(); SPI_ReadWriteByte(0x02); SPI_ReadWriteByte((addr 16) 0xFF); SPI_ReadWriteByte((addr 8) 0xFF); SPI_ReadWriteByte(addr 0xFF); for (uint16_t i 0; i remain; i) { SPI_ReadWriteByte(buf[i]); } W25Q_CS_High(); W25Q_WaitBusy(); if (remain len) break; buf remain; addr remain; len - remain; remain (len 256) ? len : 256; } }等忙的代码也是固定套路发0x05命令后不断读状态字节直到Bit0变成0。这个过程中CS要一直保持低电平不能中断。void W25Q_WaitBusy(void) { uint8_t status; W25Q_CS_Low(); SPI_ReadWriteByte(0x05); do { status SPI_ReadWriteByte(0xFF); } while (status 0x01); W25Q_CS_High(); }擦除操作也类似先写使能再发扇区擦除或者块擦除命令然后等忙。擦除的耗时比写长不少一个4KB扇区典型几十毫秒一块64KB可能要几百毫秒涉及擦除的代码要考虑到主流程的阻塞时间。4. SPI驱动开发分层设计让代码真正可复用4.1 为什么驱动要分层很多同学的第一个驱动就是直接把代码堆在main函数里调通了就完事。但项目一复杂这种写法会拖垮你。因为底层通信接口随时可能变今天是SPI1明天改成SPI2甚至可能换一颗MCU把所有逻辑混在一起改一次牵连一堆。我习惯把SPI设备的代码分成三层。最底层是硬件抽象层负责和STM32的SPI外设打交道比如SPI_ReadWriteByte、CS拉高拉低这一层只跟MCU相关。中间层是设备驱动层比如W25Q_ReadID、W25Q_ReadData、W25Q_WriteData、W25Q_EraseSector这一层只跟芯片的指令集相关不关心底层是STM32还是其他MCU。最上层是应用层比如日志存储模块、配置参数管理模块这一层只调用设备驱动层的接口。这样的分层有个直观的好处今天用STM32F407明天换GD32后天换国产某个M内核芯片应用层代码一行不用动只需要把最底层的SPI_ReadWriteByte换成对应平台实现。这种思想不是裸机开发专属Linux下的SPI设备驱动也是类似套路spi_transfer、spi_message这些封装本质上就是把底层传输细节藏起来让上层的Flash驱动专注于指令逻辑。4.2 可复用的W25Q128驱动接口设计基于分层思想我会把接口设计成下面这样。对外暴露的函数数量不要太多功能内聚使用者只需要关心地址、缓冲区、长度三件事。void W25Q_Init(void); uint32_t W25Q_ReadID(void); void W25Q_ReadData(uint32_t addr, uint8_t *buf, uint16_t len); void W25Q_WriteData(uint32_t addr, uint8_t *buf, uint16_t len); void W25Q_EraseSector(uint32_t addr); void W25Q_EraseChip(void);在应用层做日志存储时这类接口已经足够。你可以维护一个写入索引每次追加数据就往Flash里写空间快满就整片擦除。如果后续觉得直接操作扇区太粗暴还可以在上层加一层磨损均衡逻辑但这已经属于存储方案设计的范畴不在SPI通信本身。至于底层实现我是直接在HAL库的SPI_ReadWriteByte基础上封装。这里有一个性能细节如果每次一个字节都走完整HAL_SPI_TransmitReceive开销会很大。批量读写时可以用HAL_SPI_TransmitReceive的len参数一次传多个字节效率更高。不过对大部分入门项目来说单字节收发已经够用先把功能跑通再优化是我一贯的顺序。5. 常见问题与排查技巧实录5.1 读ID始终是0xFF先查时序和片选这个问题出现的频率高得惊人。第一步用逻辑分析仪或示波器抓CS、SCK、MOSI、MISO四根线确认CS有没有正确拉低、SCK有没有波形、MOSI上发送的0x9F对不对。如果CS一直是高检查GPIO配置和代码是否真的调用了拉低函数。第二步确认SPI模式。如果逻辑分析仪显示的采样沿和Slave手册不一致把CPOL和CPHA换一换试试。第三步降低时钟频率尤其用杜邦线连接时高频下信号反射和串扰很容易让通信失败。我调试期间习惯用1Mbps左右的SCLK稳定后再提速。5.2 写入后读回来全是0xFF检查擦除和等待写入工作看似正常但读出来全是0xFF这个问题大概率出在“没有擦除”或者“没有等待完成”。NOR Flash要先把目标区域擦成0xFF再写数据才对。如果你是从没擦过的Flash直接写旧数据和新数据按位“与”在一起自然不对。另外写完数据后不能立刻去读要等WIP忙标志清除。建议先实现W25Q_WaitBusy并且确保在0x06写使能后、0x02页编程前CS有一次完整的拉高再拉低。我看到过不少代码CS的操作和Flash手册上的时序图对不上改掉之后立刻就好了。5.3 跨页写导致数据错乱数据写进去读出来前面一段正常后面一段跑到了奇怪的位置这基本就是跨页问题。W25Q128的页编程如果越过页边界地址会回绕到当前页的页首而不是自然进入下一页。解决办法就是我在前面写的分页写函数每次发送前计算剩余空间超出页面就拆分写性能稍有牺牲但正确性有保证。顺带提一句页编程数据长度如果在跨页时恰好是256字节也要小心页内偏移计算。用“256 - (addr % 256)”这个式子能够兼容各种起始地址是我实测下来最不容易出错的写法。5.4 调试工具与排障速查SPI调试最得力的工具是逻辑分析仪百元内的8通道24MHz采样率设备配合开源软件就能轻松抓出CS、SCK、MOSI、MISO的时序关系。当你对着一片空白的数据线发呆时波形会让你瞬间明白是主机没发数据还是从机没回数据。下面这张表是我在实际项目中排查SPI问题时的速查清单基本按从外到内、从硬件到软件的顺序排查现象可能原因检查手段读ID全是0xFFCS没拉低、模式不对、时钟太快、MISO断开逻辑分析仪抓波形、降频读ID全是0x00MISO引脚被拉低、从机未供电万用表测量MISO电平、确认供电3.3V写不进去没擦除、没等待忙、缺写使能检查CS时序、看状态寄存器数据跨页错乱页编程越过页边界用分页写函数偶尔通信失败杜邦线太长、干扰大降频、缩短线缆、加滤波还有就是很多新手把W25Q128的供电电压搞错这颗芯片供电范围是2.7V到3.6V不能用5V直接怼。如果你用的是5V单片机和3.3V的Flash相连必须做电平转换或者选用兼容5V的版本否则芯片可能直接烧毁。顺便提一句网上经常有人反馈Keil烧录时出现“error: flash download failed - target dll has been cancelled”这样的报错这通常跟调试器连接、目标板供电、或下载算法配置有关不一定是SPI驱动的问题。我遇到类似情况基本先检查调试器驱动和SWD线是否松动再看工程里有没有误改下载算法。硬件问题排干净再回头查代码不要被这种弹窗带偏了节奏。最后分享一个我自己的习惯任何SPI设备拿到手第一件事永远不是写业务逻辑而是先搞定读ID。读ID这步能过说明线序、时钟模式、片选逻辑全部通了读ID过不了后面再多的读写代码都是白搭。这套“先验证通路、再开发功能”的思路帮我省下了大量调试时间。如果你正在入门STM32不妨也按这个顺序来先把W25Q128这颗Flash玩透再去碰更复杂的SPI设备和协议栈你会发现自己对“时序”这两个字的理解已经在不知不觉中超过身边的人了。
返回列表