我早期学习STM32时,最先搞定的通信接口是UART,毕竟收发打印太直观了。但一遇到SPI,整个人就有点懵:明明只有四根线,怎么比串口还难懂?当时拿着W25Q128的Flash模块,对着数据手册看时序图,看了一晚上都没敢动手写代码。后来真正跑通一次读写,把ID读出来那一刻,才恍然大悟——SPI其实比UART更简单,只是它把“规则”放在了你面前,很多人被时序图吓住了而已。
这篇文章就围绕“SPI到底是怎么传输数据的”这个问题,从协议基础讲到STM32的SPI外设,再以W25Q128这个最经典的Flash芯片为例,把整个驱动开发的流程走一遍。无论你是刚点完LED灯的新手,还是想系统补一补通信基础的同学,这篇文章都适合你。看完之后你不仅能看懂时序图,还能自己用CubeMX配置SPI、手写一套Flash读写驱动,从此告别“抄代码但不明白为什么”的状态。
1. 内容整体设计与思路拆解
1.1 为什么选SPI作为入门通信协议
先聊一个更基础的问题:单片机之间、单片机和外设之间通信,为什么有那么多协议,偏偏SPI值得专门学?
答案其实很朴素:SPI是“规则最透明”的同步串行协议。它不像UART那样需要双方约定波特率,也不像I2C那样有地址仲裁和应答机制,SPI的核心逻辑就是一个主设备主动产生时钟,数据跟着时钟一位一位地走。谁产生时钟,谁就掌控节奏,从设备完全被动。这种设计让SPI的时序极容易理解,也非常适合第一次接触“时序”概念的人。
另一个重要原因是SPI在嵌入式系统里的地位。W25Q128这种SPI Flash几乎出现在所有需要存储少量数据的设备上——固件升级、日志记录、字库存储、参数保存——它靠的是“便宜、够用、驱动简单”。掌握SPI协议之后,你还能顺手看懂SD卡(SDIO模式本质上兼容SPI)、触摸屏控制器、各类传感器、以太网控制器等大量外设的驱动逻辑。
1.2 我理解的SPI驱动开发思路
我做SPI驱动时一般分四步走,这套流程也推荐给你:
第一步,吃透外设侧的通信规则。也就是看W25Q128数据手册,搞清楚它支持哪些命令、每个命令的帧格式是什么样、每个位什么时候有效。这个阶段花的时间最多,但也是最值得的。
第二步,搞清主控侧的硬件能力。也就是STM32的SPI外设支持哪些模式、时钟最高能到多少、DMA怎么配。很多时候功能实现不了,不是代码问题,而是主控特性没摸清。
第三步,用CubeMX把底层引脚、时钟、外设参数配好,生成工程骨架。这一步能省掉90%的寄存器配置工作量,但你仍然需要知道每个配置项背后的含义,否则出问题时无从下手。
第四步,按“最小可用”原则写驱动。先把读ID这种最简单的命令跑通,再逐步实现读写、擦除、状态检测。一次只做一件事,验证完再往前走。
这套方法不只适用于SPI,任何通信协议的驱动开发都可以复用。因为通信的本质是“约定”,只要把约定拆解清楚,代码只是把约定翻译成机器能执行的步骤而已。
2. SPI协议核心时序与工作原理
2.1 四根线各自的职责
SPI全称Serial Peripheral Interface,串行外设接口,由Motorola(现在的NXP)在1979年提出。它用四根线完成主从设备之间的通信:
| 信号线 | 全称 | 职责 |
|---|---|---|
| SCLK | Serial Clock | 时钟线,由主机产生,决定数据传输的节奏 |
| MOSI | Master Out Slave In | 主机输出、从机输入,数据从主机流向从机 |
| MISO | Master In Slave Out | 主机输入、从机输出,数据从从机流向主机 |
| CS/SS | Chip Select / Slave Select | 片选线,低电平有效,主机选中哪个从机就拉低哪个 |
你可以把这四根线理解为一条双向独木桥配一个总指挥:SCLK就是总指挥手里的节拍器,MOSI和MISO是两条分开的单行道,CS则是点名用的喇叭——喊到谁,谁就开工。因为是全双工,主机发数据的同时从机也能发数据回来,所以通信效率非常高。
这里有个初学者容易忽略的点:SPI没有“地址”概念。主机想跟哪个设备通信,就直接拉低那个设备的CS线,其他设备看到自己的CS是高点平,就乖乖把MISO置为高阻态,不参与通信。所以一根总线上挂多个SPI设备是完全可行的,只要每根CS分给不同引脚即可。
2.2 时钟极性CPOL和时钟相位CPHA
SPI最难懂也最容易出问题的就是CPOL和CPHA这两个参数。数据手册上关于SPI模式的表格通常长这样:
| SPI模式 | CPOL | CPHA | 采样沿 |
|---|---|---|---|
| Mode 0 | 0 | 0 | 上升沿 |
| Mode 1 | 0 | 1 | 下降沿 |
| Mode 2 | 1 | 0 | 下降沿 |
| Mode 3 | 1 | 1 | 上升沿 |
很多人背下了这张表,但不知道为什么。我换个说法解释一下。
CPOL决定的是空闲时时钟线的电平。CPOL=0表示空闲时SCLK为低电平,CPOL=1表示空闲时SCLK为高电平。就这么简单,它只描述“没事的时候时钟线处于什么状态”。
CPHA决定的是数据在哪个边沿被采样。CPHA=0表示在第一个边沿采样,CPHA=1表示在第二个边沿采样。结合CPOL,就能确定是上升沿还是下降沿。
再打一个比方:你和一个朋友约定每周六早上8点见面,CPOL就是“周六”还是“周日”,CPHA就是“早上”还是“晚上”。两边必须同时满足这两个条件,才能在正确的时间碰头。主机和从机如果CPOL或CPHA任何一个不匹配,读出来的数据就会是乱的,而且这种错乱往往只在高速通信时才容易被发现——有些低速场景下甚至能“歪打正着”跑通。
W25Q128支持SPI Mode 0和Mode 3,也就是CPOL=0/CPHA=0或者CPOL=1/CPHA=1。绝大多数STM32例程都用Mode 0,即空闲低电平、上升沿采样,这也是最常用的模式。
2.3 一次完整的数据传输过程
以Mode 0为例,把一次8位数据传输从头到尾过一遍:
- 主机把CS引脚拉低,告诉从机“准备接收”。
- 主机在每个SCLK时钟周期内,把要发送的数据从高位到低位依次放到MOSI线上。
- 在SCLK的上升沿,主机和从机同时采样对方发来的数据位。
- 连续8个时钟后,一个字节传输完成,主机把CS拉高,一次事务结束。
注意一个关键点:SPI的数据收发是同步进行的。主机向从机写一个字节的同时,从机也在向主机发送一个字节。所以从机的MISO线上到底返回了什么数据,取决于从机当前的数据缓冲区和运行状态。这也是为什么很多SPI命令格式是“主机先发送命令字节,再从MISO读取响应”——发送和接收本来就是同一个时钟周期里同时完成的。
回到W25Q128的例子:主机发送0x9F(读JEDEC ID命令)后,理论上还需要继续发送3个空字节(0x00),同时从MISO上读回3个字节,分别对应厂商ID、容量ID等。如果你只发一个命令就不发时钟了,从机根本没有机会把数据推出来。这一点新手最容易忽略,代码里表现为“读出来的全是0xFF”。
3. STM32 SPI外设与CubeMX配置要点
3.1 STM32 SPI外设的核心能力
STM32的SPI外设是从硬件层面实现了SPI协议的完整逻辑,你在代码里只需要做三件事:配置参数、把数据塞进发送寄存器、从接收寄存器取数据。中间的时钟产生、移位输出、接收采样,全部由硬件完成。
以STM32F103系列为例,SPI外设支持:
- 主模式和从模式
- 最高18MHz的时钟速率(取决于系统时钟分频)
- 8位或16位数据帧格式
- 硬件NSS管理或软件NSS管理
- 硬件CRC校验
- 发送和接收DMA请求
- 多主机通信的冲突检测
对于绝大多数SPI外设应用,我们只用其中一小部分功能:主机模式、8位数据、软件管理NSS。但了解外设的上限是有必要的,比如当你需要把数据以DMA方式搬到内存、不占用CPU时,知道“SPI有DMA请求”这件事会让你少走很多弯路。
3.2 CubeMX配置的关键选项
用CubeMX配置SPI的步骤非常简单,但每个选项都不能乱选:
- 在Pinout视图中找到SPI1,将Mode设为Full-Duplex Master,硬件NSS设为Disable(用软件管理片选)。
- 在Configuration的SPI1参数区,按下表设置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Mode | Full-Duplex Master | 全双工主机模式 |
| Data Size | 8 Bits | W25Q128命令和数据都是字节单位 |
| Prescaler | 16或32 | 分频系数,决定SCLK频率 |
| CPOL | Low | 空闲低电平 |
| CPHA | 1 Edge | 第一个边沿采样 |
| NSS | Software | 软件管理片选,灵活度最高 |
关于Prescaler的选择,有个实用公式:SCLK频率 = PCLK频率 / Prescaler。F103的APB2总线最高是72MHz,如果Prescaler设为16,SCLK就是4.5MHz。W25Q128支持最高133MHz的时钟,但STM32F103的SPI最高只能到18MHz,而且PCB走线、杜邦线质量都会影响高速通信,所以我建议把时钟设置在1MHz到10MHz之间,兼顾速度和稳定性。
这里多说一句:如果你用的是杜邦线连接模块,SCLK超过10MHz大概率会出现数据错乱。不要一上来就追求最高速率,稳定跑通功能后再慢慢提速。
3.3 HAL库发送与接收的几个重要细节
CubeMX生成的HAL库代码很简洁,但有几个细节需要你特别注意:
HAL_SPI_Transmit(&hspi1, pData, Size, Timeout); HAL_SPI_Receive(&hspi1, pData, Size, Timeout); HAL_SPI_TransmitReceive(&hspi1, pTxData, pRxData, Size, Timeout);三个函数分别对应只发、只收、同时收发。对于W25Q128的很多命令,用TransmitReceive最合适,因为你需要“一边发命令字节,一边读响应字节”。
最典型的坑是HAL_SPI_Receive和HAL_SPI_Transmit之间切换时的状态残留。比如你先调用Transmit发送读ID命令,再调用Receive读数据,中间SCLK可能没有连续产生,导致从机状态错乱。
我自己的习惯是:所有涉及“先发命令再读数据”的场景,统一用TransmitReceive,把要发送的命令字节填入发送缓冲区,接收缓冲区留空等待数据填充。一次性完成整个交换过程,从机的时序压力最小,代码也最清晰。
4. W25Q128 Flash驱动开发全过程
4.1 W25Q128芯片的核心特性
W25Q128是Winbond(华邦)生产的一款SPI NOR Flash芯片,容量128Mbit,也就是16M字节。如果你想往单片机系统里加一个“断电不丢数据”的存储空间,它就是最常见的选择。
它有几个关键数字值得记住:
- 容量:128Mbit = 16MB
- 页大小:256字节
- 扇区大小:4KB(16个页)
- 擦除粒度:扇区擦除(4KB)、块擦除(32KB/64KB)、整片擦除
- 最大时钟频率:标准SPI模式133MHz
Flash芯片的“写”和“读”不对称:读取可以按任意字节地址进行,但写入必须先擦除后编程,而且擦除的最小单位是4KB的扇区,编程的最小单位是256字节的页。这种“先擦后写、按扇区擦除”的特性,是所有Flash共通的,理解了这个,你就理解了为什么Flash驱动的逻辑比EEPROM复杂——EEPROM可以按字节改写,Flash不行。
4.2 硬件连接与最小系统搭建
我常用的连接方式是SPI1 + 软件片选,引脚分配如下:
| W25Q128引脚 | STM32F103引脚 | 说明 |
|---|---|---|
| CS | PA4 | GPIO输出,低电平有效 |
| SCLK | PA5 | SPI1_SCK |
| MOSI | PA7 | SPI1_MOSI |
| MISO | PA6 | SPI1_MISO |
| VCC | 3.3V | 注意是3.3V,不是5V |
| GND | GND | 共地 |
如果你买的是现成模块,通常还带WP(写保护)和HOLD引脚,模块上一般已经用电阻拉高或拉低处理好了,不需要额外配置。但如果你自己画板子,这两个引脚一定要处理:WP拉高(禁用写保护)、HOLD拉高(允许正常工作),悬空会导致莫名其妙的读写失败。
CS用普通GPIO控制,而不是硬件NSS,原因是软件片选在代码上更直观,控制自由度也更高。你可以随时创建新的CS引脚去挂第二片Flash或别的SPI设备,不用改动SPI外设的配置。
4.3 读取Flash ID:第一个SPI驱动
读JEDEC ID是验证SPI通信是否正常的最简单方法,也是我建议所有初学者跑通的第一个函数。
命令格式:向Flash发送0x9F,然后继续发送3个空字节,同时在MISO上读回3个字节。第一个字节是厂商ID(W25Q128为0xEF),第二个字节是类型ID(0x40),第三个字节是容量ID(0x18)。
代码实现如下:
void W25QXX_ReadID(void) { uint8_t cmd = 0x9F; uint8_t rxData[3] = {0}; W25QXX_CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, &cmd, &rxData[0], 1, 100); HAL_SPI_TransmitReceive(&hspi1, &txDummy, &rxData[0], 1, 100); HAL_SPI_TransmitReceive(&hspi1, &txDummy, &rxData[1], 1, 100); HAL_SPI_TransmitReceive(&hspi1, &txDummy, &rxData[2], 1, 100); W25QXX_CS_HIGH(); printf("Manufacturer ID: 0x%02X\n", rxData[0]); printf("Memory Type: 0x%02X\n", rxData[1]); printf("Capacity: 0x%02X\n", rxData[2]); }如果串口打印出EF 40 18,说明SPI通信链路完全打通:引脚配置正确、时序模式匹配、芯片上电正常。如果读出全是FF,先检查片选有没有拉低、电源有没有到位;如果读出的是乱码,优先怀疑CPOL/CPHA配置和从机不匹配。
这里不需要关心Flash内部状态,因为JEDEC ID命令在任何状态下都响应,它是天然的“通信测试工具”。
4.4 写使能、状态寄存器与页编程
Flash写入的第一个前置操作是写使能(Write Enable)。W25Q128规定,任何写操作(页编程、擦除、写状态寄存器)之前必须先发送0x06命令,将芯片内部的状态寄存器中的WEL位(Write Enable Latch)置1。这个锁存位在每次成功的写操作后会自动清零,所以每写一次都要重新发0x06。
这里有个很常见的坑:发送0x06后,必须等到WEL位真正变成1再继续写操作。芯片接收0x06命令是“异步”的——命令发完后芯片需要一点时间完成内部状态修改。如果你紧跟着就发页编程命令,芯片可能还在处理上一个命令,导致本次写操作被忽略。
检查WEL位的方法是读取状态寄存器1(命令0x05):
uint8_t W25QXX_ReadStatusReg1(void) { uint8_t cmd = 0x05; uint8_t status = 0; uint8_t dummy = 0xFF; W25QXX_CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, &cmd, &dummy, 1, 100); HAL_SPI_TransmitReceive(&hspi1, &dummy, &status, 1, 100); W25QXX_CS_HIGH(); return status; }状态寄存器1的bit0是BUSY位,bit1是WEL位。你要等BUSY位清零、WEL位置1后,才说明Flash已经准备好接收编程命令。
页编程命令是0x02,帧格式为:命令字节 + 3字节目标地址(高字节在前)+ 最多256字节数据。关键限制是:一页编程写入的数据不能跨256字节的页边界。如果地址是0x00FF,你一次写入2个字节,第二个字节会回卷到页首地址0x0000,而不是写到0x0100。
这个限制是Flash驱动里最容易被忽视的“坑王”。解决方法是:在写入前先计算当前页还剩多少空间,如果本次要写的数据超过剩余空间,就拆成多次页编程。
页编程代码:
void W25QXX_WritePage(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[4]; W25QXX_WriteEnable(); cmd[0] = 0x02; cmd[1] = (addr >> 16) & 0xFF; cmd[2] = (addr >> 8) & 0xFF; cmd[3] = addr & 0xFF; W25QXX_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); HAL_SPI_Transmit(&hspi1, buf, len, 100); W25QXX_CS_HIGH(); W25QXX_WaitBusy(); }W25QXX_WaitBusy是一个轮询函数,不断读状态寄存器1直到BUSY位清零。因为Flash写操作需要时间,芯片在写的时候不会响应其他命令,所以等待是必须的。
4.5 扇区擦除与数据读取
擦除操作是Flash驱动里最耗时的一环。W25Q128的4KB扇区擦除大约需要45到400毫秒,取决于芯片的具体工况。擦除命令是0x20,帧格式为命令字节 + 3字节扇区地址。
void W25QXX_EraseSector(uint32_t sectorAddr) { uint8_t cmd[4]; W25QXX_WriteEnable(); cmd[0] = 0x20; cmd[1] = (sectorAddr >> 16) & 0xFF; cmd[2] = (sectorAddr >> 8) & 0xFF; cmd[3] = sectorAddr & 0xFF; W25QXX_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); W25QXX_CS_HIGH(); W25QXX_WaitBusy(); }这里要提醒一点:擦除地址必须是4KB对齐的,也就是说地址的低12位必须是0。如果你传入的地址是0x00000100,Flash不会报错,但实际擦除的是0x00000000起始的整个扇区。你的数据可能被“静默”擦掉。
读取则简单得多。读数据命令0x03,帧格式为命令字节 + 3字节地址,后面跟着的数据字节会被Flash按地址递增地返回出来。理论上一次读取的长度没有限制,因为读操作不会触发页回卷,地址会自动跨页连续递增。
void W25QXX_ReadData(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[4]; cmd[0] = 0x03; cmd[1] = (addr >> 16) & 0xFF; cmd[2] = (addr >> 8) & 0xFF; cmd[3] = addr & 0xFF; W25QXX_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); HAL_SPI_Receive(&hspi1, buf, len, 100); W25QXX_CS_HIGH(); }4.6 把整个Flash驱动串起来
单独的页编程和扇区擦除有了,但实际使用中你要写的可能是任意长度的数据。这时候就要写一个“任意地址任意长度写入”的接口,逻辑是:
- 检查写入地址是否与扇区对齐,不对齐就先读出来备份。
- 擦除目标扇区。
- 把数据按256字节一页、且不跨页边界的原则拆分成多次页编程,依次写入。
这个函数就是典型 Flash 驱动的“主逻辑”,它同时考验你对页边界、扇区擦除和写使能三个关键点的理解。我实践中常用的简化流程是:
void W25QXX_Write(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t pageRemain; uint32_t writeLen; while (len > 0) { pageRemain = 256 - (addr % 256); writeLen = (len < pageRemain) ? len : pageRemain; W25QXX_EraseSector(addr & 0xFFFFF000); // 简化处理,实际应该检查是否需要擦除 W25QXX_WritePage(addr, buf, writeLen); addr += writeLen; buf += writeLen; len -= writeLen; } }实际项目里不会每次写入都擦除,因为擦除很浪费时间。通常做法是:先读目标扇区数据,与要写入的数据比较,逐字节判断哪些位需要从1变为0;只有存在“1变0”的需求时才执行擦除;如果没有,直接按位与逻辑合成新数据后再编程。这样能大幅减少擦除次数,延长Flash寿命。
W25Q128号称支持10万次擦写,听起来很多,但如果你每次存日志都整扇区擦除,一个扇区用一年可能就顶不住了。所以“擦写均衡”不是服务器SSD专属概念,单片机上一样有现实需求。
5. SPI调试常见问题与排查实录
5.1 最典型的六个故障现象
SPI调试遇到的问题,归纳起来不外乎以下几类。我按照从“硬件物理层”到“协议逻辑层”的排查顺序,整理了一个速查表:
| 故障现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读出全是0xFF | CS没拉低、电源没上、MISO断线 | 万用表量CS电压,确认模块供电,检查杜邦线 |
| 读出全是0x00 | MISO线短路到地、从机没正常工作 | 断开从机MISO,测量其对地电阻 |
| 数据错乱、无规律 | CPOL/CPHA模式不匹配、时钟太快 | 统一用Mode 0,降低Prescaler |
| 第一个字节对,后续全错 | CS释放太早或太晚、时序不完整 | 检查CS在整个事务期间是否保持低电平 |
| 写入后读回仍是0xFF | 忘记写使能、WP引脚拉低保护 | 发送0x06后检查状态寄存器bit1 |
| 偶发读写失败 | 电源纹波大、线过长、接触不良 | 加104去耦电容,缩短杜邦线,改用焊接 |
5.2 关于片选信号的三个经验
CS是最容易被忽略的环节,但它出问题的概率极高。我踩过的坑集中在这三处:
一是CS要保持整个事务期间全程拉低。有些人会在每发送一个字节后就拉高CS再拉低,以为这样每个字节都是独立事务。但对于W25Q128这种设备,很多命令是多字节帧,中间CS跳变会被当成“事务终结”,后续字节全部无效。
二是CS拉低后、SCLK开始前,最好加一小段延时。特别是从机端有电平转换或滤波器时,CS有效到第一个时钟边沿之间需要建立时间。虽然STM32的GPIO和SPI时钟配合通常没问题,但在低速验证时加个微秒级延时能排除一类怪问题。
三是同一总线上挂多个设备时,CS之间绝对不能重叠。要让前一个设备的CS先拉高,再拉低后一个设备的CS。如果两个CS同时低,两个从机都会抢占MISO线,导致总线冲突和数据损坏。
5.3 调试SPI的实用技巧
SPI调试最痛苦的地方在于“看不见数据流”。UART可以打印,I2C有地址可寻,SPI却是一堆高速翻转的方波。我的调试工具箱里,按优先级排序是:逻辑分析仪 > 示波器 > 串口打印。
逻辑分析仪是最适合SPI调试的工具,100元左右的24通道8通道逻辑分析仪就能完美解码SPI时序,直接显示每一根线上的电平和数据内容。用它能直观看到:CS是否整个事务都拉低了、SCLK有没有多余毛刺、MISO上的数据位和采样沿是否对齐。
如果没有逻辑分析仪,串口打印HAL_SPI_TransmitReceive前后接收缓冲区的内容也有排查价值。重点关注:接收缓冲区里第一个字节是否等于预期命令、后续字节是否按规律变化。通过分段对比,能判断是“字节没发出去”还是“数据被从机忽略了”。
5.4 CubMX生成的代码为什么有时会卡死在超时
HAL库的HAL_SPI_TransmitReceive带超时参数,超时后会返回HAL_TIMEOUT。如果你发现代码卡死在SPI函数里,多半是以下三个原因之一:
最常见的是SCLK没有正常产生。检查SPI外设是否真的处于使能状态——CubMX生成的HAL_SPI_MspInit里如果GPIO时钟没开,外设根本无法工作,函数会一直等待发送完成标志。
第二种情况是从机拉低MISO的时序异常。某些SPI设备在CS有效后会先驱动MISO线,如果这个驱动时序和SCLK边沿太接近,主机的接收寄存器可能采到不稳定电平,持续产生溢出错误,导致HAL库一直清理溢出标志。
第三种情况是硬件NSS被误启用。如果你在CubeMX里没有把NSS设为Software,硬件在收到模式错误时会把SPI状态机卡住。这种问题最隐蔽,排查方法是在CubMX中确认NSS设置为Disable或Software。
6. 从W25Q128驱动到通用SPI设备驱动的方法论
6.1 命令集驱动的通用套路
写完W25Q128的驱动后,你会发现大多数SPI芯片的驱动套路都是一样的:拉低CS、发命令字节、发地址/数据、根据需要接收数据、拉高CS、等待芯片内部操作完成。
这个套路可以抽象成一组基础函数:
// 写寄存器或发送命令 void spi_dev_write_reg(uint8_t reg, uint8_t val) { CS_LOW(); spi_transmit(®, 1); spi_transmit(&val, 1); CS_HIGH(); } // 读寄存器 uint8_t spi_dev_read_reg(uint8_t reg) { uint8_t val = 0xFF; CS_LOW(); spi_transmit(®, 1); spi_receive(&val, 1); CS_HIGH(); return val; }下次你换一个SPI接口的传感器或SD卡,只需要查数据手册里的寄存器地址表和命令序号,套用这个模板就能快速写出一份能用的驱动。这就是“驱动开发方法论”的价值——不是背芯片手册,而是建立一套应对未知硬件的流程。
6.2 从轮询到中断再到DMA
我上边给出的代码全部是阻塞式轮询,因为这样最直观,适合入门理解。但实际项目中,SPI通信经常伴随着大数据量传输,轮询会占用大量CPU时间,这时候就需要引入中断或DMA。
中断方式的核心思路是:发起传输后不等待,在SPI的TXE/RXNE中断里逐个字节搬运数据;DMA方式的核心思路是:把RAM里的数据缓冲区的地址配给SPI的DMA通道,硬件自己完成“内存到外设寄存器”的搬运。
使用DMA之后,CPU可以在SPI传输期间去做别的事,比如驱动另一个传感器、处理控制逻辑。代价是代码复杂度增加不少:需要处理DMA传输完成中断、环形缓冲、半传输中断等。我给你的建议是:先跑通轮询版本,再用CubMX勾选DMA选项,对比一下代码差异,很容易理解DMA如何工作。
6.3 为什么你需要学会自己看数据手册
现在的网络资源极其丰富,搜索框里输入“STM32 SPI W25Q128”就能找到成千上万篇教程。但教程再详细,它也替代不了数据手册,原因很简单:别人可能写错了,或者理解了但在新芯片上不适用。
举一个实际例子:我在一个项目中用的Flash芯片不是W25Q128,而是另一家厂商的兼容料。代码完全按照W25Q128的驱动写,结果读ID一切正常,但页编程怎么也写不进去。后来查手册才发现,兼容料在页编程时要求CS在发送完所有数据后必须额外保持低电平超过100微秒,才允许释放。这个细节在原厂的手册里有标注,在教程里完全找不到。
所以我把阅读芯片数据手册看作是驱动开发的必修课。一开始不用全看,重点看这几块:引脚功能描述、命令表、状态寄存器说明、时序特性表。一页一页啃会痛苦,但积累几款芯片的阅读经验后,你的“硬件直觉”会有质的飞跃。
7. 关于这套驱动代码的几个实操建议
最后分享几个我在实际项目里用出来的经验,帮助你少走弯路。
关于硬件部分,优先使用开发板自带的SPI Flash芯片,而不是外接杜邦线模块。开发板上的走线短、电源干净,可以最大程度排除硬件干扰,让你专心学协议。如果必须用杜邦线连接外部模块,请把通信速率降低到1MHz以下,并且加上几个104去耦电容。
关于代码结构,不要在main函数里堆业务逻辑。把Flash驱动封装成独立模块,提供Init、ReadID、Read、Write、Erase几个接口就够了。后续想升级成DMA版本,只改模块内部实现,不影响上层调用代码。
关于验证方式,我强烈建议在写完驱动后做一个“倒序验证”:先向指定地址写入一串递增数据,再读取出来逐一比对。如果数据全对,说明驱动逻辑没有问题;如果中间有几个字节不对,大概率是页边界处理有bug。
还有一个调试心态的建议:SPI出问题时,不要急着改代码。先拿逻辑分析仪抓一遍波形,确认每一根线的电平状态是否符合预期。很多时候,问题根本不在软件逻辑,而是引脚配置冲突、GPIO复用模式错误这样的低级问题。看波形能从根上定位问题,比你盲猜改参数要高效得多。