STM32F103 GPIO模拟SPI驱动W25Q16实战
简介:这份压缩包面向使用STM32F103进行嵌入式存储扩展的开发者与学习者,聚焦在不依赖硬件SPI外设的前提下,用GPIO软件模拟SPI时序完成W25Q16闪存的读写。内容围绕SPI四种模式、时钟极性与相位配置、片选与数据收发等关键点展开,适合硬件SPI资源紧张、需要节省引脚或做低成本Flash方案的场景。包内共78个文件,以34个h头文件和32个c源文件为主,辅以8个汇编启动文件及Keil工程、说明文档和清理脚本,压缩包约311KB;代码按SPI模拟层、Flash操作层、板级支持以及串口、定时器等模块组织,便于按层阅读和移植。现有4111人学习,读者可从中获得可编译的工程框架、W25Q16命令封装与读写流程参考,以及软件模拟SPI的时序实现和调试排错思路。
1. 板载 SPI 被占用之后:用 GPIO 把 W25Q16 挂上 stm32f103
手上那块 stm32f103 最小系统板,SPI1 的四个脚早被板载屏和调试口分干净了,可参数区、字库、日志缓存又必须落在一颗 W25Q16 上。这时候最省事的做法不是改板子,而是拿四个空闲 GPIO 把 SPI 时序"手动打"出来——stm32f103 模拟 SPI 读写 W25Q16 这件事,本质就是把寄存器外设换成软件位翻转,代价是速率和 CPU 占用,换来的是引脚随便挑、时序随便调、移植到别的芯片上改几个宏就能跑。它适合两类人:一类是硬件已经定型、外设不够用的;另一类是初学 flash 驱动、想看清 CS、SCK、MOSI、MISO 到底怎么配合的。这篇从时序讲到驱动,再讲到跨页写和校验,代码是能在实板上跑通的那一版。
2. 先对准时序再写代码:SPI Mode 0 与 W25Q16 命令集的对应关系
模拟 SPI 最容易出的问题,不是代码写错,是时序理解偏了一格。W25Q16 支持 Mode 0 和 Mode 3 两种,实际项目里几乎都用 Mode 0:时钟空闲为低,数据在上升沿被采样。软件模拟没有"配置项"这回事,你必须把这句话翻译成代码里语句的先后顺序,顺序错了,读回来的就是一堆 0xFF 或者 0x00。
2.1 从时序图反推四个相位的先后顺序
Mode 0 下一次完整的位传输是这样走的:CS 先拉低,主机在 SCK 还是低电平的时候把这一位放到 MOSI 上,然后 SCK 拉高,从机在上升沿采样 MOSI,同时把自己的这一位推到 MISO 上,主机在 SCK 高电平期间读 MISO,最后 SCK 拉低,准备下一位。也就是说,改数据在低电平,采数据在高电平,这两件事之间的延时就决定了你能跑多快。
写成代码就是四步循环:设 MOSI → 拉高 SCK → 读 MISO → 拉低 SCK。中间插的延时不能省,F103 在 72MHz 下 GPIO 翻转本身就接近几十纳秒,如果从机还没把 MISO 稳定下来你就去读,读到的是上一次的残留值——这种错很难查,因为它只在某些板子、某些温度下出现,非常玄学。
提示:CS 不是"传输开关"这么简单。W25Q16 的擦除和编程命令,是在 CS 的上升沿才真正启动内部操作的。CS 拉高早了一步,命令字节还没发完;拉高晚了一步倒没事,但如果你在忙等待期间反复拉 CS,内部状态机会被打断。
2.2 最小命令子集:六条命令撑起读写擦
W25Q16 的完整命令表有几十条,真正常用的就六条。先把这六条跑通,剩下的都是锦上添花。
| 命令 | 指令码 | 方向 | 作用 | 关键约束 |
|---|---|---|---|---|
| Write Enable | 0x06 | 写 | 置位状态寄存器 WEL 位 | 每次擦/写前都必须发一次 |
| Read Status-1 | 0x05 | 读 | 读 BUSY(bit0)、WEL(bit1) | 可连续读,直到 CS 拉高 |
| Read JEDEC ID | 0x9F | 读 | 返回厂商+容量三字节 | 上电自检用,固定 0xEF4015 |
| Read Data | 0x03 | 读 | 从任意地址连续读 | 地址可跨页、跨扇区,无限制 |
| Page Program | 0x02 | 写 | 写一页,最多 256 字节 | 不可跨页,越界会回卷到页首 |
| Sector Erase | 0x20 | 写 | 擦除 4KB 一个扇区 | 写之前必须先擦,最小单位就是它 |
这张表里最要命的两条约束,一条是"写前必擦",一条是"页编程不能跨页"。前者很多人第一次用 flash 会忽略,直接往有数据的地址写,结果新数据和旧数据按位与在一起,读出来是乱的;后者更隐蔽,地址越过 256 字节边界后不会报错,而是绕回本页开头把前面的数据覆盖掉,属于写完了还觉得自己对的那种翻车。
WEL 位的意义也要说清:它是一个"一次性授权"。发完 0x06 之后,芯片只允许你执行紧接着的下一条擦除或编程命令,执行完自动清零。所以代码里必须严格保持"写使能 → 擦/写 → 忙等待"这个顺序,中间不能插别的写类操作。
2.3 位序与 CS 的持有范围
W25Q16 是 MSB first,一个字节从最高位开始发。软件模拟时如果忘了tx <<= 1这一句,发出去的字节位序就反了,读 ID 会得到 0x7FF502 之类的怪值——这个错误很典型,因为它不崩溃,只是所有数据都错,容易让人怀疑硬件。
CS 的持有范围按命令区分:
- 0x06 写使能:拉低 → 发 8 位 → 拉高,全程就一个字节。
- 0x05 读状态:拉低 → 发 0x05 → 连续读若干字节 → 拉高。可以在一次 CS 低电平期间反复读,直到 BUSY 清零。
- 0x9F 读 ID:拉低 → 发 0x9F → 读 3 字节 → 拉高。
- 0x03 读数据:拉低 → 发 0x03 → 发 3 字节地址 → 读 N 字节 → 拉高。
- 0x02 页编程:拉低 → 发 0x02 → 发 3 字节地址 → 发 N 字节数据 →拉高(此时才真正开始烧写)。
- 0x20 扇区擦除:拉低 → 发 0x20 → 发 3 字节地址 → 拉高(此时才开始擦)。
注意地址是 24 位、3 个字节、高字节在前。W25Q16 容量 2MB,地址范围 0x000000 到 0x1FFFFF,实际只用低 21 位,最高字节恒为 0x00,但协议要求照样发满三个字节,少发一个就全乱。
3. 从 GPIO 到命令层:把模拟 SPI 拆成四个可测的原语
驱动不要一上来就写"读整个文件"这种大函数,先做分层:最底下是引脚宏,中间是字节传输原语,上面是命令封装,最上面才是业务读写。这样出错时能一段一段往下压,不用整块怀疑。
3.1 引脚分配与 GPIO 初始化
选脚的原则是避开已有外设、尽量挨着同一个端口,方便用 BSRR 一次性置位。下面这组是我常用的组合,SCK/MOSI 推挽输出,MISO 上拉输入,CS 推挽输出且上电默认高电平——这条很重要,CS 上电是低的话,芯片会误判成命令起始,后面怎么调都不对。
#include "stm32f10x.h" /* 引脚映射集中在这一层,换板子只改这四行 */ #define W25_CS_L() GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define W25_CS_H() GPIO_SetBits(GPIOA, GPIO_Pin_4) #define W25_SCK_L() GPIO_ResetBits(GPIOA, GPIO_Pin_5) #define W25_SCK_H() GPIO_SetBits(GPIOA, GPIO_Pin_5) #define W25_MOSI_L() GPIO_ResetBits(GPIOA, GPIO_Pin_7) #define W25_MOSI_H() GPIO_SetBits(GPIOA, GPIO_Pin_7) #define W25_MISO_R() GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6) void w25_gpio_init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* SCK / MOSI / CS:推挽输出,50MHz 档位留足翻转余量 */ gpio.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7; gpio.GPIO_Mode = GPIO_Mode_Out_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); /* MISO:上拉输入,防止从机未驱动时读到浮空电平 */ gpio.GPIO_Pin = GPIO_Pin_6; gpio.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &gpio); W25_CS_H(); /* 空闲态 CS 必须为高 */ W25_SCK_L(); /* Mode 0:空闲时钟为低 */ }参数上要留意的有两点。GPIO_Speed_50MHz不是"必须这么快",而是 F103 的输出速度档位只有 2/10/50 三档,选 50MHz 是为了让上升沿足够陡,逻辑分析仪上看不出明显的斜坡;如果选 2MHz,长一点的杜邦线上波形会变圆,高速下从机采样就可能出错。MISO 配上拉是因为 W25Q16 在 CS 为高时 MISO 是高阻态,不上拉的话读到的第一位常是随机值。
3.2 spi_transfer 原语:一个函数定生死
整个模拟 SPI 的核心就这么一个函数,它同时完成"发一个字节"和"收一个字节",这就是 SPI 全双工的本质——你发的同时它也在发。半双工时收到的字节丢掉即可。
/* 约 100~200ns 的空转,按逻辑分析仪实测的 SCK 频率回调 NOP 数量 */ static inline void spi_dly(void) { __NOP(); __NOP(); __NOP(); __NOP(); } uint8_t spi_transfer(uint8_t tx) { uint8_t rx = 0; uint8_t i; for (i = 0; i < 8; i++) { /* 1. 时钟为低时改变 MOSI(Mode 0 要求) */ if (tx & 0x80) { W25_MOSI_H(); } else { W25_MOSI_L(); } tx <<= 1; spi_dly(); /* 2. 上升沿:从机在此刻采样 MOSI */ W25_SCK_H(); spi_dly(); /* 3. 时钟高电平期间读 MISO,先移位再填最低位 */ rx <<= 1; if (W25_MISO_R()) { rx |= 0x01; } /* 4. 下降沿:从机在此刻更新 MISO,准备下一位 */ W25_SCK_L(); spi_dly(); } return rx; }rx <<= 1的位置是关键:必须在读 MISO之前移位,这样第一次读到的位经过 8 次移位后落在 bit0,符合 MSB first。如果写成先读再移,整个字节的位序会整体偏移一位,读 ID 会得到看似合理但完全错误的数值。
spi_dly()里的 NOP 数量决定了 SCK 频率。F103 在 72MHz 下,一次循环加上 GPIO 操作大约十几个周期,4 个 NOP 大致能把 SCK 压在 2~3MHz 这一档,具体得拿逻辑分析仪抓一次再定。别凭感觉填,这是最容易拍脑袋的地方。
3.3 命令层封装:写使能、忙等待、擦除、页编程
有了字节原语,上面就是拼命令。先做两个基础设施:写使能和忙等待。忙等待做不好,后面所有擦写都会随机失败。
/* 读状态寄存器 1 */ static uint8_t w25_read_sr1(void) { uint8_t sr; W25_CS_L(); spi_transfer(0x05); sr = spi_transfer(0xFF); /* 发什么都行,0xFF 是惯例 */ W25_CS_H(); return sr; } /* 写使能:发完自动置 WEL,无需检查返回值,但要确认 WEL 真的置上了 */ static void w25_write_enable(void) { W25_CS_L(); spi_transfer(0x06); W25_CS_H(); } /* 忙等待:擦除一个扇区最长几十毫秒,超时要给够 */ static int w25_wait_busy(uint32_t timeout_ms) { while (timeout_ms--) { if ((w25_read_sr1() & 0x01) == 0) return 0; /* BUSY 位清零 */ delay_ms(1); } return -1; } /* 扇区擦除:addr 必须 4KB 对齐 */ int w25_sector_erase(uint32_t addr) { w25_write_enable(); W25_CS_L(); spi_transfer(0x20); spi_transfer((addr >> 16) & 0xFF); spi_transfer((addr >> 8) & 0xFF); spi_transfer( addr & 0xFF); W25_CS_H(); /* 上升沿触发内部擦除 */ return w25_wait_busy(500); }w25_wait_busy的 timeout 不能省。曾经有个项目在擦除时死循环,查了两天才发现是供电电压偏低导致擦除一直不完成——如果没有超时退出,整个系统就卡在那里,看门狗都救不回来。扇区擦除典型时间 30~50ms,整片擦除可能到几秒,超时值按最坏情况给。
页编程同理,只多一步:发完地址后连续发数据,最后一个字节发完才拉高 CS。
/* 页编程:addr 所在页内偏移 + len 不能超过 256,len 由调用者保证 */ int w25_page_program(uint32_t addr, const uint8_t *buf, uint16_t len) { uint16_t i; w25_write_enable(); /* 严格检查 WEL,没置上说明上一条命令被打断 */ if ((w25_read_sr1() & 0x02) == 0) return -1; W25_CS_L(); spi_transfer(0x02); spi_transfer((addr >> 16) & 0xFF); spi_transfer((addr >> 8) & 0xFF); spi_transfer( addr & 0xFF); for (i = 0; i < len; i++) { spi_transfer(buf[i]); } W25_CS_H(); /* 上升沿后才真正开始烧写 */ return w25_wait_busy(20); }这里读 WEL 的那两行是血泪经验换来的。正常流程下 WEL 一定置位,但如果上一次操作被中断(比如中断里也去操作了 flash),WEL 可能是 0,此时发页编程命令芯片会直接忽略,函数返回成功、数据没写进去,属于最难查的一类问题。
3.4 上电自检:读 JEDEC ID 是唯一可信的握手
所有外设驱动的第一行测试代码都应该是读 ID。W25Q16 的 0x9F 命令固定返回 0xEF、0x40、0x15 三字节,厂商码 0xEF,容量码 0x15 对应 2MB。这三个值对不上,后面所有代码都不用看了。
int w25_check_id(void) { uint8_t id[3]; W25_CS_L(); spi_transfer(0x9F); id[0] = spi_transfer(0xFF); id[1] = spi_transfer(0xFF); id[2] = spi_transfer(0xFF); W25_CS_H(); if (id[0] == 0xEF && id[1] == 0x40 && id[2] == 0x15) return 0; return -1; /* 调用方把 id[] 打出来,比看返回值有用得多 */ }如果读出来是0x00 0x00 0x00,优先怀疑 MISO 没接好或者上拉没配;如果是0xFF 0xFF 0xFF,优先怀疑 MOSI 没发出去或者 CS 根本没拉低;如果三个值看着像但整体错位,多半是位序或者rx <<= 1的位置写反了。这三种现象对应三类完全不同的错误,把 ID 打出来比单看返回值有价值得多。
4. 擦、写、读、校验:4KB 扇区与 256 字节页的边界处理
单条命令跑通只是万里长征第一步,业务代码里真正会出问题的是边界。W25Q16 有两套粒度:擦除最小 4KB(一个扇区),编程最大 256 字节(一页),这两个数字不能混。写入逻辑必须建立在"先擦后写、按页拆分"这两条规则之上,否则跑一次测试看着对,跑第二次就全错。
4.1 存储结构:扇区、页与地址映射
2MB 空间按 4KB 切成 512 个扇区,每个扇区 16 页,每页 256 字节。地址是线性字节地址,所以:
| 层级 | 大小 | 地址对齐要求 | 操作 |
|---|---|---|---|
| 页 Page | 256 B | addr % 256 == 0 | 页编程起点,单次最多 256 B |
| 扇区 Sector | 4 KB | addr % 4096 == 0 | 擦除最小单位 |
擦除只能按扇区来,"改一个字节"这件事在物理上做不到。实际工程里的做法是:把需要频繁改的参数集中放在一个扇区里,改的时候整块读出来、改内存里的副本、擦除整个扇区、再整块写回去。或者用双扇区乒乓,一个写一个备份,掉电也不会丢。
4.2 跨页写:拆包是最简单的解法
页编程越界会回卷到本页开头,不报错。所以写任意长度数据时,必须按"本页剩余空间"切分。
/* 任意地址、任意长度写入,内部完成跨页拆分;调用前需保证目标区域已擦除 */ int w25_write(uint32_t addr, const uint8_t *buf, uint32_t len) { uint32_t chunk; while (len > 0) { /* 当前地址到本页末尾还能放多少字节 */ chunk = 256 - (addr & 0xFF); if (chunk > len) chunk = len; if (w25_page_program(addr, buf, (uint16_t)chunk) != 0) { return -1; } addr += chunk; buf += chunk; len -= chunk; } return 0; }256 - (addr & 0xFF)这一句是整个函数的核心,它算的是"从当前地址到下一个 256 边界还有多少字节"。第一次写从页中间开始时,chunk 会小于 256,正好写到页尾;后面每次都是整页 256。这段代码不长,但它决定了你的文件系统能不能正常工作——我见过一个日志模块就是因为省了这一步,每 256 字节覆盖一次前面的数据,跑了一周才发现日志全是乱的。
注意这个函数不负责擦除。擦除是扇区级的、耗时的、会清掉整块 4KB 的操作,把它隐藏在写函数里会导致每次写都擦一遍,寿命和速度都扛不住。擦除时机由上层业务决定,这是设计上必须有意识分开的两件事。
4.3 读回校验:写完不验等于没写
模拟 SPI 的速率比硬件 SPI 慢一个数量级,但慢有慢的好处:出错概率高,早点暴露比上线后暴露强。每次写入后立即读回比对,是这套驱动最值钱的一步。
/* 逐字节读回比对,返回第一个不一致的偏移,全部一致返回 -1 */ int32_t w25_verify(uint32_t addr, const uint8_t *buf, uint32_t len) { uint32_t i; uint8_t rd; W25_CS_L(); spi_transfer(0x03); spi_transfer((addr >> 16) & 0xFF); spi_transfer((addr >> 8) & 0xFF); spi_transfer( addr & 0xFF); for (i = 0; i < len; i++) { rd = spi_transfer(0xFF); if (rd != buf[i]) { W25_CS_H(); return (int32_t)i; /* 返回偏移,便于定位是页边界还是首字节 */ } } W25_CS_H(); return -1; }返回偏移而不是简单的布尔值,是因为偏移本身携带信息:错在偏移 0,多半是写使能没生效;错在第 256 字节,多半是跨页拆分有问题;错得零散且每次位置不同,多半是时序延时不够。用返回值定位比重新加打印快得多。
读命令 0x03 没有长度限制,可以一口气读完整个 2MB,所以校验大块数据时不用循环发命令,一次 CS 低电平就能读完。
4.4 速率标定:延时到底给多少
模拟 SPI 的实际速率取决于spi_dly()里塞了几个 NOP,以及编译器优化等级。别信计算,拿逻辑分析仪抓 SCK 波形最准。下面是同一块板子、72MHz 主频、-O2 优化下的大致量级:
| NOP 数量 | SCK 约 | 单字节 8 位耗时 | 读 1KB 理论耗时 |
|---|---|---|---|
| 0 | 6~8 MHz | ~1.3 us | ~1.3 ms |
| 4 | 2~3 MHz | ~3.5 us | ~3.6 ms |
| 10 | 1 MHz 左右 | ~8 us | ~8 ms |
速率不是越高越好。杜邦线超过 10cm、没有地线回流、或者板上有开关电源干扰时,SCK 一快,MISO 上就会出现毛刺,读出来的数据偶发一位翻转,而且只在某些板子上出现。工程上的稳妥做法是把 SCK 压到自己实测能稳定跑的最高频率的一半,剩下的性能用页读取来补——一次 CS 读整页,比多次单字节读快得多。
注意:擦除和编程的耗时跟 SPI 速率无关,它们是芯片内部的高压操作。一个 4KB 扇区擦除典型 30~50ms,页编程典型 0.7~3ms。写 1MB 数据的瓶颈几乎全在擦除上,优化 SPI 时序的意义有限。
5. 避坑排查:模拟 SPI 读写 W25Q16 最容易翻车的五个点
下面这五条都是我或者身边人真实踩过的,按现象、原因、解决三段写,遇到问题时可以对着现象直接查。
5.1 读 ID 恒为 0xFF 或恒为 0x00
现象:上电调w25_check_id(),三个字节要么全是 0xFF,要么全是 0x00,改代码没用。
原因:这两种返回值指向完全不同的硬件问题。全 0xFF 说明 MISO 线一直是被拉高的,最常见的是 MISO 引脚配成了输出模式,或者根本没接、接触不良;全 0x00 说明 MISO 一直被拉低,可能是引脚配成了下拉,或者 CS 压根没拉低、芯片根本没被选中。还有一种隐蔽情况:CS 用的引脚和调试口(JTAG)复用,程序跑起来了但引脚功能没重映射,CS 实际上不受控。
解决:先断开 flash,用万用表量 MISO 对地电压,空闲时应该接近 VCC(上拉生效)。确认 GPIO 初始化代码里 MISO 是GPIO_Mode_IPU,CS 是推挽输出且初始为高。如果 CS 脚复用了 JTAG,先关掉 JTAG 再初始化。这三步做完 ID 还不对,才去怀疑时序。
5.2 写入返回成功,读回数据错位或整段为旧值
现象:w25_write返回 0,w25_verify却在某个偏移开始对不上,或者读回的是上次写的旧内容。
原因:九成是跨页问题。页编程越过 256 字节边界后会回卷到本页开头覆盖,函数不报错。另一种可能是目标区域没擦除,新数据与旧数据按位与,看起来"写进去了"实际是脏数据。
解决:先确认调用w25_write之前,目标地址所在扇区已经擦过。然后在w25_page_program里加一句断言,检查(addr & 0xFF) + len <= 256,越界直接返回错误。再打印 verify 返回的偏移,如果偏移正好是 256 的整数倍,那基本可以锁定跨页拆分写错了。
5.3 擦除后第一次写一定失败,第二次就正常
现象:擦除 → 写 → 读,第一次读出来全是 0xFF;再执行一遍同样的写,就成功了。
原因:擦除和写使能之间的时序问题。w25_sector_erase里发完 0x06 后立刻发 0x20,如果这两个命令之间 CS 没有完整地拉高再拉低,芯片可能把两个命令当成一条解析。另一种情况是擦除完成后 BUSY 还没真正清零,代码就急着发写使能,导致 WEL 被内部状态机清掉。
解决:在w25_write_enable()之后、发擦除命令之前,读一次 SR1 确认 WEL(bit1) 为 1,不为 1 就重发一次。擦除后的忙等待超时值给到 500ms 以上,并且每次读 SR1 之间至少隔 1ms,别用紧密循环把 SPI 总线占满。
5.4 小批量测试全对,跑久了偶发一位翻转
现象:单次读写几千字节没问题,连续跑几小时后校验偶发失败,重新上电又好了。
原因:时序余量不足。SCK 太快,或者spi_dly()在 -O2 优化下被编译器当成无用代码删掉了(__NOP()一般不会被删,但如果自己写了空循环for(i=0;i<4;i++);就很可能被优化掉)。另一个来源是电源:擦除瞬间电流能到十几毫安,去耦电容不够时 VCC 会掉一下,芯片内部状态机异常。
解决:把spi_dly()改成__NOP()内联汇编保证不被优化,然后拿逻辑分析仪在长时间运行后抓一次 SCK 和 MISO 的对齐关系,看 MISO 的建立时间还剩多少。电源侧在 flash 的 VCC 脚旁边并一颗 0.1uF 加一颗 10uF,走线尽量短。这两条做完,偶发错误基本能消掉。
5.5 断电重启后数据丢失或内容错乱
现象:写入并校验通过,拔电再上电,读出来是 0xFF 或者半截数据。
原因:写操作还没真正完成就断电了。页编程的 CS 上升沿只是"启动",真正烧进去要几毫秒,w25_wait_busy如果超时值给小了,函数提前返回,上层以为写完了就去干别的,此时断电就丢。另一种是参数区没有做掉电保护,擦除到一半断电,整个扇区变成不确定状态。
解决:所有写操作的忙等待超时必须给足,页编程至少 10ms,扇区擦除至少 500ms,并且检查返回值,超时就报错而不是当成功。参数区用双扇区乒乓:A 区写成功并校验通过后,才把标志位写到 B 区,上电时比对两个标志位取有效的那一份。代价是多占 4KB,换的是断电不丢数据。
6. 收敛成可移植接口:换硬件 SPI 时不用重写业务代码
模拟 SPI 跑通之后,早晚会遇到"这板子 SPI 外设空出来了,能不能提速"的需求。如果业务代码里到处都是W25_CS_L()和spi_transfer(),换硬件 SPI 就得全文件重写。比较省心的做法是在最下面加一层操作函数指针,模拟和硬件两套实现都挂到这个接口上。
typedef struct { void (*cs)(int level); uint8_t (*xfer)(uint8_t tx); } w25_bus_t; /* 模拟 SPI 实现 */ static void sim_cs(int level) { level ? W25_CS_H() : W25_CS_L(); } static const w25_bus_t bus_sim = { sim_cs, spi_transfer }; /* 硬件 SPI 实现,接上去就是改这两行 */ static void hw_cs(int level) { level ? W25_CS_H() : W25_CS_L(); } static uint8_t hw_xfer(uint8_t tx) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, tx); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); return SPI_I2S_ReceiveData(SPI1); } static const w25_bus_t bus_hw = { hw_cs, hw_xfer };命令层所有函数改成接收const w25_bus_t *bus参数,内部用bus->xfer()替代直接调用。这样业务层完全不用动,切换只改一行初始化。硬件 SPI 跑到 18MHz 时,读 1KB 从 3.6ms 降到 0.5ms 以内,擦除时间不变,整体收益主要在频繁读字库的场景。
进阶验证可以做两件事。一是把整片 2MB 写满递增序列再全读比对,跑通一次基本能排除地址位宽和跨页的所有问题;二是人为把 VCC 拉低到 2.7V(W25Q16 的下限)再跑同样的测试,能提前暴露电源余量不足的隐患。
最后说个习惯。从那以后我每次调新的 flash 驱动,都强制走一遍固定的四步:读 ID、单字节写读、跨页写读、整扇区擦写校验,一步不过就不往下走。看起来慢,但比对着示波器猜一整天快得多。模拟 SPI 这东西不难,难的是每个环节都可能悄悄错一点点,而错一点点在 flash 上就是数据全废。希望帮到你。
本文还有配套的精品资源,点击获取