STM32 软件 SPI vs 硬件 SPI 读写 W25Q64 —— 核心原理深度解析

发布时间:2026/8/17 19:02:43
STM32 软件 SPI vs 硬件 SPI 读写 W25Q64 —— 核心原理深度解析 关键词STM32、SPI、W25Q64、Flash、软件 SPI、硬件 SPI声明此篇文章代码出自江协科技一、从一个实际问题出发假设你要做一个数据记录仪需要把传感器数据存起来断电后不丢失。你选了一颗W25Q64——8MB 的 Flash 存储芯片SPI 接口便宜好用。现在问题来了STM32 怎么和这颗芯片通信SPI 协议本身不复杂 —— 四根线主机发一个字节同时收一个字节。但在 STM32 上有两种实现方式用 GPIO 引脚手动模拟时序软件 SPI或者用芯片自带的 SPI 外设硬件 SPI。两种方式能实现同样的功能但代码、性能、适用场景完全不同。本文通过对比这两个实际项目把核心原理讲透。两个项目除了MySPI.c这一个文件不同其余代码完全一样 —— 这正是分层设计的魅力。二、先搞清楚 W25Q64 是个什么东西在写代码之前必须理解你要驱动的芯片有什么 脾气否则看了代码也不知道为什么这样写。它的 脾气 一写完不能直接覆盖W25Q64 是Flash 存储器和 RAM 不一样。RAM 可以随意改写任意地址但 Flash 的存储单元物理上只能把 1 变成 0不能把 0 变成 1。那想把 0 变回 1 怎么办只能擦除。擦除会把一整个区域最小 4KB叫一个扇区的所有位重置为 1也就是所有字节都变成0xFF。这就决定了写 W25Q64 的标准流程先擦除 → 再写入。后面代码里每次写数据之前都要调用SectorErase原因就在这里。它的 脾气 二通过 指令码 来控制W25Q64 没有地址总线也没有读写控制引脚主机怎么让它执行擦除、写入、读取这些操作答案是通过 SPI 发送指令码—— 一个特定的字节芯片收到后就知道接下来要干什么。比如发0x06芯片收到后知道 接下来要写数据了做好准备写使能发0x20 3 字节地址芯片收到后把对应扇区擦除发0x02 3 字节地址 数据芯片把数据写入对应位置发0x03 3 字节地址芯片把对应位置的数据返回给主机发0x05芯片把当前状态返回忙不忙这些指令码在代码里被定义成了宏方便使用#define W25Q64_WRITE_ENABLE 0x06 #define W25Q64_SECTOR_ERASE_4KB 0x20 #define W25Q64_PAGE_PROGRAM 0x02 #define W25Q64_READ_DATA 0x03 #define W25Q64_READ_STATUS_REGISTER_1 0x05 #define W25Q64_JEDEC_ID 0x9F它的 脾气 三SPI 时序有要求SPI 有 4 种模式由 CPOL时钟极性和 CPHA时钟相位决定CPOL0SCK 空闲低电平CPOL1SCK 空闲高电平CPHA0上升边沿采样CPHA1下降边沿采样W25Q64 数据手册明确要求模式 0CPOL0CPHA0或模式 3CPOL1CPHA1。两者本质相同只是 SCK 极性相反。本文统一使用模式 0硬件 SPI 配置时选择 CPOLLowCPHA1Edge 即可。SCK 空闲时为低电平数据在 SCK 的上升沿被采样也就是 SCK 从低变高的那一瞬间双方读取对方发来的数据SCK: ___/‾‾‾\___/‾‾‾\___/‾‾‾\___/‾‾‾\___ ↑ ↑ ↑ ↑ 上升沿 上升沿 上升沿 上升沿 (采样) (采样) (采样) (采样)这个时序要求直接决定了后面代码怎么写 —— 软件 SPI 里为什么先拉高 SCK 再拉低硬件 SPI 里为什么 CPOL 和 CPHA 要那样配置根源都在这里。好现在搞清楚了W25Q64 用 SPI 模式 0 通信通过指令码来控制写入前必须擦除。接下来看怎么在 STM32 上实现 SPI 通信。三、软件 SPI —— 用最直观的方式理解 SPI 协议软件 SPI 的思路非常直接CPU 亲自控制四根 GPIO 引脚手动产生 SPI 时序。哪个引脚该高该低什么时候翻转全由代码决定。核心函数MySPI_SwapByte()—— 逐位读懂 SPI 的 心跳整个软件 SPI 的灵魂就是这个函数其他初始化代码都是辅助。它实现了一个字节的发送和接收uint8_t MySPI_SwapByte(uint8_t ByteSend) { uint8_t i, ByteReceive 0x00; for (i 0; i 8; i) // 8个bit一次循环处理一个 { MySPI_W_MOSI(!!(ByteSend (0x80 i))); // ① 把数据的第i位放到MOSI上 MySPI_W_SCK(1); // ② SCK上升沿 → 从机采样 if (MySPI_R_MISO()) { ByteReceive | (0x80 i); } // ③ 读MISO存到接收变量 MySPI_W_SCK(0); // ④ SCK下降沿 → 一个bit结束 } return ByteReceive; }别被!!(ByteSend (0x80 i))吓到拆开来看很清楚0x80 i是一个会 移动 的掩码i0: 0b1000_0000 → 取出 bit7最高位 i1: 0b0100_0000 → 取出 bit6 i2: 0b0010_0000 → 取出 bit5 ... i7: 0b0000_0001 → 取出 bit0最低位ByteSend (0x80 i)把要发送字节的第 i 位 抠 出来。结果可能是 0也可能是 0x80、0x40 等非零值。!!两次逻辑取反作用是把非零值统一变成 10 还是 0—— 这就把 任意非零值 归一化成了 0 或 1正好对应 MOSI 引脚的高低电平。为什么 ② 先拉高 SCK 再 ④ 拉低回到前面说的 SPI 模式 0SCK 空闲低电平拉高产生上升沿从机在这个瞬间采样 MOSI拉低产生下降沿一个 bit 传输结束。这一个循环正好对应上面时序图里的一个时钟周期。③ 为什么if里面不需要 elseByteReceive初始化为0x00所有位默认已经是 0。MISO 为 0 时不需要做任何事MISO 为 1 时才用|把对应位置 1。软件 SPI 的痛点这个函数写起来很直观但有一个致命问题每发送一个字节CPU 要遍历 8 次循环执行几十条 GPIO 操作指令。发送 1KB 数据就是上万条指令CPU 全程被占用什么别的事都干不了。更麻烦的是如果这期间发生了中断SCK 的时序就可能被打乱导致通信失败。更准确地说中断的风险在于它可能发生在 SCK 上升沿和下降沿之间。如果中断服务例程较长SCK 高电平持续时间被拉长导致从机在错误的时刻采样数据。SPI 时钟越快单个 bit 的窗口越短中断越容易破坏时序。在实际工程中对时序要求严格的场景下软件 SPI 发送期间通常需要关闭全局中断或使用优先级管理但这会增加系统延迟。有没有办法让 CPU 解放出来这就是硬件 SPI 要解决的问题。四、硬件 SPI —— 把脏活累活交给外设硬件 SPI 的思路和软件 SPI 完全不同CPU 不再手动翻转引脚而是把数据丢给 STM32 内部的 SPI 外设让外设自动产生时序。4.1 外设内部到底是怎么工作的要理解硬件 SPI必须先搞清楚 SPI 外设内部的结构。它核心有两个关键寄存器SPI1 外设内部 ┌──────────────────────────┐ │ │ │ ┌────────────────────┐ │ │ │ 发送数据寄存器(TDR) │ │ ← CPU 把要发的数据写到这里 │ └────────┬───────────┘ │ │ │ 并行加载 │ │ ┌────────▼───────────┐ │ │ │ 移位寄存器(SR) │ │ ← 真正干活的地方 │ │ [b7 b6 b5 ... b0] │ │ │ └──┬─────────────┬──┘ │ │ │ │ │ │ MOSI引脚 MISO引脚 │ │ (向外发) (从外收) │ │ │ │ ┌────────────────────┐ │ │ │ 接收数据寄存器(RDR) │ │ ← CPU 从这里读收到的数据 │ └────────────────────┘ │ │ │ └──────────────────────────┘关键理解发送和接收共享同一个移位寄存器。当移位寄存器把数据从 MOSI 一位一位移出去时空出来的位置正好从 MISO 一位一位收进来。8 个时钟后发送的 8 位全部移出同时从 MISO 收到的 8 位刚好填满移位寄存器。这就是 SPI 全双工的本质 ——发一个字节的同时必然收到一个字节。4.2 硬件MySPI_SwapByte()—— 4 行代码替代 8 次循环uint8_t MySPI_SwapByte(uint8_t ByteSend) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) ! SET); // ① SPI_I2S_SendData(SPI1, ByteSend); // ② while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) ! SET); // ③ return SPI_I2S_ReceiveData(SPI1); // ④ }配合上面的结构图逐行看硬件内部发生了什么①while (TXE ! SET);—— 发送寄存器空了吗TXETransmit Empty是 SPI 外设的一个状态标志位。TXE1 表示发送数据寄存器TDR是空的可以接收新数据。如果上一次发送还没完成TDR 里的数据还没转移到移位寄存器那 TXE0CPU 就在这里等。②SPI_I2S_SendData(SPI1, ByteSend);—— 数据给你干活吧CPU 把要发送的字节写入 TDR。这一步触发硬件自动执行以下流程TDR 中的数据被并行加载到移位寄存器8 位一次性搬过去瞬间完成移位寄存器开始工作在 SCK 引脚上自动产生 8 个时钟脉冲每个时钟脉冲中MOSI 移出一位、MISO 移入一位8 个时钟后发送全部完成收到的 8 位数据刚好在移位寄存器中移位寄存器的内容被并行加载到接收数据寄存器RDRRXNE 标志位硬件自动置 1重点步骤 2~6 全部由硬件自动完成CPU 不参与CPU 写完 TDR 后理论上可以去做别的事。③while (RXNE ! SET);—— 接收完了吗RXNEReceive Not Empty1 表示 RDR 里有新数据等待 CPU 来读。CPU 在这里等待硬件完成。④return SPI_I2S_ReceiveData(SPI1);—— 把结果给我CPU 从 RDR 读走数据并返回。读取 RDR 后 RXNE 自动清零。4.3 一个容易困惑的点为什么 SS 引脚还是用软件控制读到这里你可能会有疑问SPI 外设不是有硬件 NSS片选功能吗为什么代码里 PA4 还是手动控制原因在于W25Q64 的通信帧长度不固定。硬件 NSS 的工作方式是你每发送一个字节硬件自动在发送前拉低 NSS发送后拉高 NSS。也就是说一个 NSS 周期 一个字节的发送周期硬件NSS: ‾‾\_______/‾‾‾ 1字节但 W25Q64 的指令帧远不止一个字节读ID指令4字节 SS: ‾‾\_____________________________/‾‾‾ [0x9F] [MID] [DID高] [DID低] ← 4字节期间SS必须持续低 → 页编程指令变长 SS: ‾‾\_____________________________________________/‾‾‾ [0x02] [地址3字节] [数据1] [数据2] ... [数据N] ← N字节期间SS必须持续低 →如果用硬件 NSS每发一个字节 NSS 就自动拉高一次芯片会认为 通信结束了整个指令帧就断了。所以方案是时钟和数据线交给 SPI 外设硬件产生但片选线用普通 GPIO 手动管理。MySPI_Start()拉低、MySPI_Stop()拉高中间发多少字节都可以SS 一直保持低电平。五、软件 vs 硬件 SPI 对比总结维度软件 SPI硬件 SPI核心原理CPU 逐位翻转 GPIO外设移位寄存器自动产生时序发送 1 字节for 循环 8 次几十条指令写 TDR → 等待 → 读 RDR4 条语句CPU 占用发送期间完全被占用只在读写寄存器时占用不到 1μs最高速度受 GPIO 翻转限制约几百 kHz可达 18 MHz72M ÷ 4中断安全差中断可能打乱 SCK 时序好硬件时序不受中断影响引脚选择任意 GPIO只能用 SPI1 的复用引脚PA5/6/7适用场景引脚不够用、学习 SPI 协议实际产品、高速通信、多任务系统六、W25Q64 驱动层 —— 从指令到数据的完整解析前面的 SPI 解决了 怎么传数据 的问题。接下来要解决的是 传什么数据—— 也就是如何用 SPI 发送正确的指令码和地址让 W25Q64 执行擦除、写入、读取等操作。不管是软件 SPI 还是硬件 SPI这一层的代码完全一样。因为它只调用四个底层接口void MySPI_Init(void); // 初始化 SPI void MySPI_Start(void); // SS 拉低开始通信 void MySPI_Stop(void); // SS 拉高结束通信 uint8_t MySPI_SwapByte(uint8_t ByteSend); // 交换一个字节发一个收一个这四个接口把底层实现细节完全封装了上层不需要知道 SPI 是软件模拟的还是硬件外设干的。6.1 读 ID —— 最简单的通信帧验证芯片是否正常拿到一颗 W25Q64第一步往往是读它的 ID确认接线没问题、芯片没坏。这也是理解 W25Q64 通信帧格式的最佳入口因为它最简单。void W25Q64_ReadID(uint8_t *MID, uint16_t *DID) { MySPI_Start(); MySPI_SwapByte(W25Q64_JEDEC_ID); // 发送指令 0x9F *MID MySPI_SwapByte(W25Q64_DUMMY_BYTE); // 接收制造商 ID *DID MySPI_SwapByte(W25Q64_DUMMY_BYTE); // 接收设备 ID [15:8] *DID 8; // 左移 8 位 *DID | MySPI_SwapByte(W25Q64_DUMMY_BYTE); // 接收设备 ID [7:0] MySPI_Stop(); }对应的 SPI 通信帧SS: ‾‾\_______________________________________/‾‾‾ MOSI: [0x9F] [0xFF] [0xFF] [0xFF] MISO: [0xFF] [ MID] [DID高][DID低]逐行解析MySPI_SwapByte(0x9F)发送 JEDEC ID 指令码。芯片收到后知道主机要读 ID。接下来三个MySPI_SwapByte(0xFF)发送哑字节 0xFF。SPI 是全双工的主机每发一个字节必定同时收到一个字节。这三个字节分别收到 MID、DID 高 8 位、DID 低 8 位。*DID 8; *DID | ...;*DID是 uint16_t 类型先收到高 8 位左移 8 位腾出低 8 位的位置然后|或上低 8 位拼成完整的 16 位设备 ID。W25Q64 的 MID 通常是 0xEFWinbond 华邦DID 通常是 0x4017。如果读出来不是这个值说明接线有问题或者芯片损坏。这里有一个技巧值得一提*MID和*DID是输出参数指针函数通过指针把结果 返回 给调用者。这比 return 更灵活因为一个函数可以同时返回多个值。6.2 写使能 —— 为什么每次写操作前都要发 0x06void W25Q64_WriteEnable(void) { MySPI_Start(); MySPI_SwapByte(W25Q64_WRITE_ENABLE); // 发送 0x06 MySPI_Stop(); }这个函数只有三行但它是所有写操作页编程、扇区擦除、块擦除、全片擦除的前置条件。W25Q64 上电后默认处于写禁止状态 —— 这是 Flash 芯片的一种保护机制防止上电瞬间的电压波动或误操作导致数据被意外修改。当你发送 0x06 后芯片内部的 写使能锁存器Write Enable Latch简称 WEL被置 1。这时芯片才允许执行写操作。而且这个锁存器只对紧接着的一次写操作有效 —— 写操作完成后WEL 自动清零芯片恢复写禁止状态。如果你连续两次页编程中间必须发两次写使能。写操作的完整流程 写使能(0x06) → 写操作(0x02/0x20) → 写使能自动失效 下次写操作 写使能(0x06) → 写操作(0x02/0x20) → 写使能自动失效这也是为什么代码里W25Q64_PageProgram()和W25Q64_SectorErase()的第一行都是W25Q64_WriteEnable()。6.3 等待忙 —— 芯片内部在 干活你得等它干完void W25Q64_WaitBusy(void) { uint32_t Timeout 100000; MySPI_Start(); MySPI_SwapByte(W25Q64_READ_STATUS_REGISTER_1); // 发送 0x05 while ((MySPI_SwapByte(W25Q64_DUMMY_BYTE) 0x01) 0x01) { if (--Timeout 0) break; // 超时保护防止死等 } MySPI_Stop(); }擦除一个扇区需要约 45ms页编程需要约 0.7ms。在这段时间里芯片内部的高压电路正在对存储单元进行操作不能接受任何新指令。如果主机不等它忙完就发下一个指令芯片会直接忽略。所以写操作之后必须调用 WaitBusy轮询状态寄存器 1 的 BIT0BUSY 位状态寄存器 18位 BIT7 BIT6 BIT5 BIT4 BIT3 BIT2 BIT1 BIT0 SRP SEC TB BP2 BP1 BP0 WEL BUSY ↑ 0 空闲 1 忙碌中MySPI_SwapByte(0xFF) 0x01这一句发送指令 0x05 后芯片持续返回状态寄存器 1 的值。 0x01只取最低位 BUSY。BUSY1 就继续循环等待BUSY0 就退出。Timeout 超时机制万一芯片异常BUSY 一直不归零代码不能死等。Timeout 从 100000 往下减减到 0 就 break 跳出。这个值不是精确的时间只是在 for 循环里大概估算 ——100000 次循环足够覆盖最长的擦除时间约 45ms。6.4 扇区擦除 —— 写数据前的必要步骤void W25Q64_SectorErase(uint32_t Address) { W25Q64_WriteEnable(); // ① 写使能 MySPI_Start(); MySPI_SwapByte(W25Q64_SECTOR_ERASE_4KB); // ② 发送指令 0x20 MySPI_SwapByte(Address 16); // ③ 地址 [23:16] MySPI_SwapByte(Address 8); // ④ 地址 [15:8] MySPI_SwapByte(Address); // ⑤ 地址 [7:0] MySPI_Stop(); W25Q64_WaitBusy(); // ⑥ 等待擦除完成 }通信帧SS: ‾‾\_________________________________/‾‾‾ MOSI: [0x20] [ADDR23:16] [ADDR15:8] [ADDR7:0]逐行解读写使能前面说过擦除也是写操作的一种必须先发 0x06。发送指令 0x20告诉芯片要擦除一个 4KB 扇区。如果改发 0xD8就是擦除 64KB 块发 0xC7 就是全片擦除。发送 24 位地址W25Q64 的容量是 8MB 2^23 字节所以地址需要 23 位bit22~bit0但 SPI 是 8 位一帧所以分 3 个字节发送。Address 16取高 8 位bit23~16Address 8取中 8 位bit15~8Address 直接取低 8 位bit7~0。等待忙擦除是耗时操作。芯片收到指令后内部高压电路开始擦除指定扇区。主机必须等 BUSY 位清零后才能进行下一步操作。擦除的结果指定扇区内的 4096 个字节全部变为 0xFF。6.5 页编程 —— 真正写入数据void W25Q64_PageProgram(uint32_t Address, uint8_t *DataArray, uint16_t Count) { uint16_t i; W25Q64_WriteEnable(); // ① 写使能 MySPI_Start(); MySPI_SwapByte(W25Q64_PAGE_PROGRAM); // ② 发送指令 0x02 MySPI_SwapByte(Address 16); // ③ 地址 [23:16] MySPI_SwapByte(Address 8); // ④ 地址 [15:8] MySPI_SwapByte(Address); // ⑤ 地址 [7:0] for (i 0; i Count; i) // ⑥ 发送数据 MySPI_SwapByte(DataArray[i]); MySPI_Stop(); W25Q64_WaitBusy(); // ⑦ 等待写入完成 }通信帧SS: ‾‾\______________________________________________________/‾‾‾ MOSI: [0x02] [ADDR23:16] [ADDR15:8] [ADDR7:0] [数据1]...[数据N]页编程不能跨页这是 W25Q64 最容易被忽略的陷阱。一页是 256 字节如果你从地址 0x0000F0 开始写 32 个字节0xF0 32 0x110前 16 个字节0xF0~0xFF正常写入但第 17 个字节不会写到 0x0100而是会回卷到页首 0x0000覆盖掉页首的数据。所以在调用 PageProgram 之前需要确保 Address Count ≤ 页尾地址。如果数据量超过一页需要分多次调用每次处理一页。6.6 读数据 —— 最简单也最自由void W25Q64_ReadData(uint32_t Address, uint8_t *DataArray, uint32_t Count) { uint32_t i; MySPI_Start(); MySPI_SwapByte(W25Q64_READ_DATA); // ① 发送指令 0x03 MySPI_SwapByte(Address 16); // ② 地址 [23:16] MySPI_SwapByte(Address 8); // ③ 地址 [15:8] MySPI_SwapByte(Address); // ④ 地址 [7:0] for (i 0; i Count; i) // ⑤ 连续读取 DataArray[i] MySPI_SwapByte(W25Q64_DUMMY_BYTE); MySPI_Stop(); }通信帧SS: ‾‾\_______________________________________________________/‾‾‾ MOSI: [0x03] [ADDR23:16] [ADDR15:8] [ADDR7:0] [0xFF]...[0xFF] MISO: [0xFF] [0xFF] [0xFF] [0xFF] [数据1]...[数据N]读数据有几个特点不需要写使能读操作不会改变存储内容所以不需要 WriteEnable。不需要等待忙读操作是即时的芯片随时可以响应。可以连续读没有页边界限制和页编程不同读数据可以跨页、跨扇区、跨块地址自动递增。理论上可以一口气读完整个 8MB 芯片。Count 参数是 uint32_t最大可以到 0x8000008MB。注意发送 0x03 指令和 3 字节地址后前 4 个 MySPI_SwapByte 收到的数据是无效的芯片还没来得及返回数据从第 5 个字节开始才是真正的存储数据。这是因为芯片收到地址后需要一个 准备时间在标准读模式 0x03 下从指定地址开始输出数据。不过在本代码中前 4 次交换对应的是指令和地址的发送阶段收到的数据本身就是无意义的从 for 循环开始才是真正读取数据。6.7 完整的写入流程串联把上面的函数串起来W25Q64 一次完整的写入操作是这样的① 扇区擦除 写使能(0x06) → 扇区擦除(0x20 地址) → 等待忙(轮询0x05) ↓ 扇区所有字节变为 0xFF ② 页编程 写使能(0x06) → 页编程(0x02 地址 数据) → 等待忙(轮询0x05) ↓ 数据写入完成 ③ 验证 读数据(0x03 地址) → 对比写入和读出的数据这个流程在主程序 main.c 中体现得清清楚楚int main(void) { OLED_Init(); W25Q64_Init(); // 初始化 SPI W25Q64_ReadID(MID, DID); // 读 ID验证芯片 OLED_ShowHexNum(1, 5, MID, 2); // 显示 MID应 EF OLED_ShowHexNum(1, 12, DID, 4); // 显示 DID应 4017 W25Q64_SectorErase(0x000000); // ① 擦除扇区 0 W25Q64_PageProgram(0x000000, ArrayWrite, 4); // ② 写入 {01,02,03,04} W25Q64_ReadData(0x000000, ArrayRead, 4); // ③ 读回数据 // OLED 显示对比 // W: 01 02 03 04 // R: 01 02 03 04 // 两行一致 → 写入成功 while (1) {} }七、分层设计与全文总结回顾整个项目最值得学习的不只是 SPI 和 W25Q64 本身更是这种分层设计的思想┌─────────────────────────────────┐ │ main.c应用层 │ ← 调用驱动函数完成业务逻辑 ├─────────────────────────────────┤ │ W25Q64.c芯片驱动层 │ ← 封装芯片操作软件/硬件共用 ├─────────────────────────────────┤ │ MySPI.cSPI 底层驱动层 │ ← 软件/硬件两套实现接口一致 └─────────────────────────────────┘每一层只关心自己的职责MySPI.c 只负责 怎么可靠地传输一个字节不管是手动翻转 GPIO 还是用 SPI 外设W25Q64.c 只负责 芯片需要什么指令序列不关心底层传输细节main.c 只负责 我要做什么调用现成的驱动函数换底层实现时只需要替换 MySPI.c其余代码一行不改。这就是 高内聚、低耦合 在嵌入式开发中的具体实践。