☰
STM32F103 GPIO模拟SPI读写W25Q16实战与避坑
2026/10/26 13:07:09 网站建设 项目流程

简介:面向嵌入式开发初学者与需要为STM32F103扩展外置存储的工程师,这份工程示例聚焦于不依赖硬件SPI外设、通过GPIO软件模拟SPI时序完成对W25Q16 Flash的读写验证,适合硬件SPI资源紧张、希望理解底层通信时序或进行小容量数据存储实验的场景。压缩包共78个文件,约311KB,主要由C源文件与头文件构成,辅以启动汇编、Keil工程文件及少量说明文本,涵盖GPIO模拟SPI、Flash读写命令、串口与定时器等模块,目录组织清晰,便于直接编译、对照和移植。已有4111人学习下载,说明该方案在同类需求中具有一定参考价值。读者可获得可运行的模拟SPI读写流程、Flash命令序列组织方式与工程配置参考,并据此排查时序、片选和引脚配置问题,快速搭建自己的Flash读写实验环境。

1. 为什么还要用 GPIO 模拟 SPI:stm32f103 读写 W25Q16 的真实使用场景

手上这块 stm32f103 最小系统板,SPI1 的引脚早被一块并口屏占死了,SPI2 又留给另一路传感器,可板子上还挂着一片 W25Q16 要存参数、存日志、存掉电前的那几帧数据。剩下的路只有一条:拿三个普通 IO 模拟 SPI。这套模拟SPI 读写 W25Q16 的代码我前后在四块板子上跑过,亲测可用,全程不到两百行,随便挑三个没被复用的脚就能开工。

模拟 SPI 不是权宜之计。量产后的小设备里这么干的很多:时序完全自己说了算,遇到从机对时钟相位挑剔、或者一条总线上挂几片片选不同的 Flash,改起来比翻硬件 SPI 的分频表快得多,也不受复用功能引脚的死约束。代价是速率上不去,72MHz 的 F103 用推挽翻转加几个 NOP,SCK 大概能压到 1~2MHz,读 2MB 的 W25Q16 够用;真要做高速录波,还是老老实实上硬件 SPI 加 DMA。

2. 硬件与工程底座:引脚分配、时序参数和工程骨架

动手写第一行之前,先把三件事定死:线怎么接、芯片认哪些指令、GPIO 配成什么模式。这三件里任何一件含糊,后面调波形就会变成玄学现场。

2.1 硬件连线与引脚选择:为什么我把总线放在 PB12~PB15

我习惯把一整组 SPI 信号放在同一个 GPIO 端口上,读 MISO 时只查一次 IDR,宏也好写。PB12~PB15 正好连号,又不和 SWD 的 PA13/PA14 打架,调试器可以一直挂着不用拔。

信号STM32F103 引脚W25Q16 引脚方向GPIO 模式
CSPB12/CS (1)输出推挽输出 50MHz
SCKPB13CLK (6)输出推挽输出 50MHz
MISOPB14DO (2)输入上拉输入
MOSIPB15DI (5)输出推挽输出 50MHz
3.3V3.3VVCC (8)电源—
GNDGNDGND (4)电源—

MISO 配成上拉输入而不是浮空输入,是个血泪经验:杜邦线一松,浮空脚读回来的是随机电平,程序会拿着垃圾数据一路跑下去,日志里全是莫名其妙的 ID。上拉之后线掉了至少稳定读到 0xFF,一眼就能看出来是没接上。

提示:W25Q16 是 3.3V 器件,VCC 脚旁边那只 0.1uF 去耦电容别省,尤其是用面包板搭的时候。CS 建议再挂一只 10k 上拉到 3.3V,防止 MCU 复位期间引脚高阻,芯片被悬空的片选误触发。

还有一点常被忽略:CS 必须由软件控制,不要接到硬件 NSS 上。W25Q16 的每条指令都靠 CS 的下降沿开始、上升沿结束,硬件 NSS 在某些配置下会自动翻转,指令边界就乱了。

2.2 W25Q16 指令集与容量结构:先读手册再动手

W25Q16 是 16Mbit 的串行 Flash,换算成字节是 2MB,地址线 24 位。内部按三级划分:页 256 字节、扇区 4KB(16 页)、块 64KB(16 个扇区)。这三个粒度决定了写入逻辑——编程的最小单位是页,擦除的最小单位是扇区,没有单字节擦除这回事。

驱动里真正要用到的指令其实就八条:

指令码名称作用后面的字节
0x9FJEDEC ID读厂商+容量 ID收 3 字节
0x06Write Enable置 WEL 位无
0x05Read Status 1读状态寄存器 1收 1 字节
0x03Read Data连续读3 字节地址 + N 字节数据
0x02Page Program页内编程3 字节地址 + N 字节数据
0x20Sector Erase擦 4KB3 字节地址
0xD8Block Erase擦 64KB3 字节地址
0xC7Chip Erase整片擦无

W25Q16 的 JEDEC ID 固定是0xEF4015:第一个字节是厂商码,后两个是容量码,0x15 对应 2MB。读 ID 是上电自检最省事的办法,能过这一关,说明供电、接线、时序大方向都是对的。

复位后的默认状态是:状态寄存器里 WEL=0,芯片处于写保护关闭但未使能写的状态。也就是说,任何一次写或擦之前,必须先发 0x06 把 WEL 置 1;这条指令执行完 CS 拉高才生效,然后写指令的 CS 下降沿会把它清掉。这个「一次使能对应一次操作」的规矩,是后面很多坑的源头。

2.3 工程骨架:时钟、GPIO 模式与延时函数的取舍

工程用 CubeMX 生成 HAL 骨架最省事,但 GPIO 的读写我不走 HAL 函数,直接怼 BSRR/BRR 寄存器。HAL_GPIO_WritePin要经过参数校验和函数调用,一次翻转几十个周期,模拟 SPI 的速率全耗在这上面了。

/* w25q_port.c —— GPIO 初始化,PB12~PB15 */ #include "stm32f1xx_hal.h" void w25q_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; /* PB 口时钟必须先开,否则后面写寄存器全是空操作 */ __HAL_RCC_GPIOB_CLK_ENABLE(); /* CS / SCK / MOSI:推挽输出,50MHz 档位保证边沿够陡 */ gpio.Pin = GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_15; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &gpio); /* MISO:上拉输入,线掉了能读到稳定的高电平 */ gpio.Pin = GPIO_PIN_14; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOB, &gpio); /* 空闲态:CS 高、SCK 低,符合 SPI 模式 0 的空闲电平 */ GPIOB->BSRR = GPIO_PIN_12; GPIOB->BRR = GPIO_PIN_13; }

这段初始化里有三个参数值得说清楚。GPIO_SPEED_FREQ_HIGH对应 50MHz 输出档,F103 的 GPIO 速度档位影响的是驱动能力而不是实际翻转频率,档位低了边沿会变缓,长一点的排线就容易采样出错。GPIO_PULLUP只对输入模式有效,输出模式下设了也没用。最后两行把总线拉到空闲态,这一步不能省——如果上电瞬间 SCK 恰好是高、CS 恰好是低,W25Q16 会认为收到一个时钟沿,状态机可能进到不确定的位置。

延时函数我用最土的办法,几个 NOP 顶上去,理由是模拟 SPI 的瓶颈本来就在软件翻转,上 DWT 周期计数器反而增加调用开销:

/* 半周期延时,72MHz 下 4 个 NOP 约 60ns,SCK 约 1.5MHz */ static inline void spi_delay(void) { __NOP(); __NOP(); __NOP(); __NOP(); }

这个数值不是拍脑袋定的。SCK 的一个完整周期包含「拉高—采样—拉低—放数据」四步,半周期太短,从机还没把数据摆到 MISO 线上主机就读了;太长则速率白白浪费。4 个 NOP 加函数内的寄存器操作,实测 SCK 高电平宽度在 300ns 左右,W25Q16 的手册要求高电平最小 20ns,余量足够,用一米长的杜邦线也稳。

3. 模拟 SPI 的底层实现:四个基础时序函数把总线跑起来

整个模拟 SPI 的代码量比想象中小,核心就一个字节收发函数,剩下全是宏和封装。把这一个函数写对,上层驱动就是拼装指令的事了。

3.1 引脚宏定义与方向切换:一行宏省掉一堆 if

宏定义我全部用 BSRR 和 BRR,不用 HAL:

/* w25q_port.h —— 引脚操作宏,全部走寄存器 */ #define W25Q_CS_PORT GPIOB #define W25Q_CS_PIN GPIO_PIN_12 #define W25Q_SCK_PORT GPIOB #define W25Q_SCK_PIN GPIO_PIN_13 #define W25Q_MISO_PORT GPIOB #define W25Q_MISO_PIN GPIO_PIN_14 #define W25Q_MOSI_PORT GPIOB #define W25Q_MOSI_PIN GPIO_PIN_15 /* BSRR 低 16 位置位,BRR 低 16 位复位,都是单周期原子操作 */ #define CS_LOW() (W25Q_CS_PORT->BRR = W25Q_CS_PIN) #define CS_HIGH() (W25Q_CS_PORT->BSRR = W25Q_CS_PIN) #define SCK_LOW() (W25Q_SCK_PORT->BRR = W25Q_SCK_PIN) #define SCK_HIGH() (W25Q_SCK_PORT->BSRR = W25Q_SCK_PIN) #define MOSI_LOW() (W25Q_MOSI_PORT->BRR = W25Q_MOSI_PIN) #define MOSI_HIGH() (W25Q_MOSI_PORT->BSRR = W25Q_MOSI_PIN) #define MISO_READ() ((W25Q_MISO_PORT->IDR & W25Q_MISO_PIN) ? 1 : 0)

用 BSRR/BRR 而不是「读-改-写」的 ODR,好处有两个。一是原子性,BSRR 一次写操作只影响目标位,不会把同端口上其他引脚的状态带翻;二是快,一条存储指令就完成,编译出来就是STR加地址偏移。如果哪天要把 CS 换到别的端口,改这两行宏就行,上层一行不用动。

MISO_READ返回 0 或 1 而不是原始的位掩码,是为了让上层判断写起来干净,代价是多一次三元运算,编译器开 -O2 之后基本会被优化掉。

3.2 模式 0 下的收发一个字节:CPOL/CPHA 谁先谁后

W25Q16 支持 SPI 模式 0 和模式 3,我统一用模式 0:CPOL=0 表示空闲时 SCK 为低,CPHA=0 表示在第一个边沿(上升沿)采样。这两句翻译成代码就是:先把 MOSI 摆好,再拉高 SCK,然后在 SCK 高电平期间读 MISO,最后拉低 SCK。

/* 全双工交换一个字节,SPI 模式 0 */ static uint8_t spi_transfer(uint8_t tx) { uint8_t rx = 0; uint8_t i; for (i = 0; i < 8; i++) { /* 1. 高位先出:把当前 bit 摆到 MOSI 上 */ if (tx & 0x80) { MOSI_HIGH(); } else { MOSI_LOW(); } tx <<= 1; /* 2. 上升沿:从机在这一刻锁存 MOSI,同时把数据推到 MISO */ SCK_HIGH(); spi_delay(); /* 3. 高电平中点采样 MISO,此时数据最稳 */ rx <<= 1; if (MISO_READ()) { rx |= 0x01; } /* 4. 下降沿:准备下一个 bit */ SCK_LOW(); spi_delay(); } return rx; }

逐段说一下。第一步的tx & 0x80判断的是最高位,因为 SPI 规定 MSB first,W25Q16 的指令码和地址都是高位在前。第二步拉高 SCK 之后立刻加spi_delay(),是给从机留出把数据摆到 MISO 上的时间,这一步省掉就会出现「读出来整体右移一位」的经典故障。第三步在 SCK 还是高的时候采样,符合 CPHA=0 的定义。第四步拉低 SCK 再加延时,是给从机留出准备下一 bit 的时间。

spi_delay()放在拉高之后、采样之前,而不是采样之后,这个顺序有讲究。如果放在采样之后,采样点会紧贴上升沿,此时 MISO 上的数据可能还在翻转过程中,读到的就是上一 bit 的值。我第一版就是这么写的,读 ID 永远差一位,查了半天才反应过来是采样点太靠前。

另外注意,这个函数是全双工的:发一个字节的同时必然收一个字节。所以「只发不收」的场合(比如发指令码)也得调用它,只是把返回值扔掉;「只收不发」的场合(比如连续读数据)要发 0xFF 作为 dummy 字节,因为 SCK 必须由主机产生,不产生时钟从机就不会吐数据。

3.3 时序自测:没有逻辑分析仪怎么确认 SCK 真的在翻转

手边没有逻辑分析仪的时候,我一般先用两个土办法验证时序,能把大部分接线和配置问题挡在门外。

第一个办法是最笨也最有效的:写一个死循环,每 500ms 翻转一次 SCK,拿万用表直流档量 SCK 脚,读数应该在 1.6V 左右(一半时间高一半时间低)。如果读到 0V 或者 3.3V 不动,说明 GPIO 时钟没开、引脚号写错、或者芯片根本没跑起来,后面的 SPI 代码一行都不用看。

第二个办法是自环测试:把 MOSI 和 MISO 用一根杜邦线短接,然后调用spi_transfer(0x5A),看返回值是不是 0x5A。短接之后主机发出去的东西会原样回到自己手里,这个测试同时验证了 MOSI 输出、MISO 输入、SCK 时序和采样点位置四件事。返回值不对,就去看是移位方向反了还是采样边沿错了;返回值对但偶发错误,基本就是延时太短或者线太长。

/* 上电自检:MOSI-MISO 短接后调用,应该返回 true */ bool spi_selftest(void) { uint8_t send = 0x5A; uint8_t recv = spi_transfer(send); return (recv == send); }

这两个办法都过了,再往下接 W25Q16。顺序很重要——先证明主机侧没问题,再去怀疑从机,不然两边同时有嫌疑,排查会变成瞎猜。

4. W25Q16 驱动分层:从读 ID 到页写、擦除的完整链路

底层字节收发搞定之后,上层就是按手册拼指令序列。这一层的关键不是代码写得多漂亮,而是把「什么时候要等、等多久、等到什么程度算完」这三件事掐准。

4.1 读 JEDEC ID:上电第一件事是确认芯片在不在

读 ID 的指令序列是:CS 拉低 → 发 0x9F → 连收 3 个字节 → CS 拉高。中间 CS 不能抬起来,否则芯片状态机复位,后面的字节就丢了。

/* 返回 0xEF4015 表示识别到 W25Q16;返回 0xFFFFFF 多半是 MISO 没接 */ uint32_t w25q_read_id(void) { uint32_t id = 0; CS_LOW(); spi_transfer(0x9F); /* JEDEC ID 指令 */ id |= (uint32_t)spi_transfer(0xFF) << 16; /* 厂商码,应为 0xEF */ id |= (uint32_t)spi_transfer(0xFF) << 8; /* 容量高字节,0x40 */ id |= (uint32_t)spi_transfer(0xFF); /* 容量低字节,0x15 */ CS_HIGH(); return id; }

三个 dummy 字节都是 0xFF,因为主机必须产生时钟,从机才会把数据吐出来。这里的 0xFF 具体是什么值无所谓,只要不是 0x00 导致 MOSI 长时间拉低就行;用 0xFF 是个习惯,也方便在逻辑分析仪上区分。

返回值判读有个简单口诀:0xEF4015 正常;0x000000 或者 0xFFFFFF 是接线或供电问题;0xEF4014 说明焊的是 W25Q80,容量算错了会越界。上电流程里我一般先读 ID,读不到就直接进错误处理,把 LED 闪成特定节奏,比在串口里刷日志直观得多。

4.2 状态寄存器与忙等待:WIP 位是唯一的节拍器

W25Q16 的写和擦都是异步的:指令发完 CS 拉高,芯片内部开始干活,此时总线是空闲的,但芯片不接受新指令。判断能不能发下一条,唯一依据是状态寄存器 1 的 bit0(WIP,Write In Progress)。

/* 等待芯片空闲,超时返回 false */ static bool w25q_wait_busy(uint32_t timeout) { uint8_t status; CS_LOW(); spi_transfer(0x05); /* 读状态寄存器 1 */ do { status = spi_transfer(0xFF); if (--timeout == 0) { CS_HIGH(); return false; /* 超时,多半是芯片没响应 */ } } while (status & 0x01); /* bit0 = WIP */ CS_HIGH(); return true; }

这段代码有两个细节容易翻车。第一,CS_LOW()和CS_HIGH()必须把整个轮询过程包住,中间不能抬 CS。有人图省事写成「拉低-发指令-拉高,再拉低-读状态-拉高」的循环,那样每次都重新开始一条新指令,WIP 位永远读不到正确值,看起来就是死循环。第二,超时计数器用递减而不是无限循环,芯片虚焊的时候无限等待会把整个系统卡死,看门狗都救不回来。

超时值怎么定?扇区擦除最大 400ms,按 SCK 1.5MHz、一次轮询大约 10us 算,需要 40000 次以上。我一般给 100000,留一倍余量,既不会误判超时,也不会等太久。

4.3 页写 256 字节与跨页回卷:一次写不完就拆

页编程指令的地址只有低 8 位在页内有效,写满 256 字节之后地址会自动回卷到本页开头,覆盖刚写进去的数据。这不是 bug,是设计如此,但第一次遇到的人基本都会懵:明明写了 300 字节,读出来只有最后 44 字节。

/* 单页内编程,len 不能超过本页剩余空间 */ static void w25q_page_program(uint32_t addr, const uint8_t *buf, uint16_t len) { uint16_t i; CS_LOW(); spi_transfer(0x06); /* 写使能,必须先发 */ CS_HIGH(); CS_LOW(); spi_transfer(0x02); /* Page Program */ spi_transfer((addr >> 16) & 0xFF); /* 地址高字节 */ spi_transfer((addr >> 8) & 0xFF); /* 地址中字节 */ spi_transfer( addr & 0xFF); /* 地址低字节 */ for (i = 0; i < len; i++) { spi_transfer(buf[i]); } CS_HIGH(); w25q_wait_busy(100000); /* 页编程典型 0.7ms,最大 3ms */ }

地址是 24 位的,但 W25Q16 只有 2MB,最高位实际用不到,照样发出去是为了兼容同系列的更大容量型号,换芯片时不用改代码。

真正对外用的是下面这个跨页封装,它把任意长度、任意起始地址的写请求拆成若干次页内写:

/* 任意地址、任意长度写入,自动处理跨页 */ bool w25q_write(uint32_t addr, const uint8_t *buf, uint32_t len) { while (len > 0) { uint16_t page_left = 256 - (addr & 0xFF); /* 本页还剩多少字节 */ uint16_t chunk = (len < page_left) ? (uint16_t)len : page_left; w25q_page_program(addr, buf, chunk); addr += chunk; buf += chunk; len -= chunk; } return true; }

addr & 0xFF取的是页内偏移,256 - 偏移就是当前页还能写多少。这个拆分逻辑看着简单,但它是整个驱动里最容易写错的地方——偏移算错一位,数据就会在页边界处错位,读回来的内容像是被谁啃掉一块。

注意:Flash 只能把 1 写成 0,不能把 0 写回 1。所以同一地址第二次写之前必须先擦除,而擦除的最小单位是 4KB 扇区。这就意味着「改一个字节」的实际操作是「读出整个扇区到 RAM → 改那一个字节 → 擦扇区 → 整扇区写回」。

4.4 扇区擦除与写使能:先 WE 后擦,擦完必须等

擦除的指令序列和写差不多,只是没有数据段,指令码换成 0x20,后面跟 3 字节地址。

/* 擦除 addr 所在的 4KB 扇区,addr 会被自动对齐到扇区首地址 */ bool w25q_sector_erase(uint32_t addr) { CS_LOW(); spi_transfer(0x06); /* 写使能,缺了这步擦除不生效 */ CS_HIGH(); CS_LOW(); spi_transfer(0x20); /* Sector Erase,4KB */ spi_transfer((addr >> 16) & 0xFF); spi_transfer((addr >> 8) & 0xFF); spi_transfer( addr & 0xFF); CS_HIGH(); /* 4KB 擦除典型 45ms,最大 400ms,超时给足 */ return w25q_wait_busy(200000); }

这里有两点必须强调。第一,0x06 写使能指令发完之后,CS 必须拉高,WEL 位才会真正置起来;如果 CS 一直保持低电平接着发擦除指令,芯片会把两个指令当成一条,写使能根本没生效,擦除指令被静默忽略,读回来还是老数据。我第一次遇到这个现象时以为是芯片坏了,换了两片才反应过来是 CS 时序问题。第二,擦除是耗时操作,4KB 典型 45ms、最大 400ms,块擦除能到 2 秒,这期间如果掉电,扇区会处于不确定状态,重要数据要留双备份。

读出数据的函数最简单,发完指令和地址之后就一路收:

void w25q_read(uint32_t addr, uint8_t *buf, uint32_t len) { CS_LOW(); spi_transfer(0x03); /* Read Data */ spi_transfer((addr >> 16) & 0xFF); spi_transfer((addr >> 8) & 0xFF); spi_transfer( addr & 0xFF); while (len--) { *buf++ = spi_transfer(0xFF); /* dummy 0xFF 产生时钟 */ } CS_HIGH(); }

读指令支持连续读,地址会自动递增,跨页跨扇区都不用管,这是四条指令里唯一不需要拆分处理的。所以整个驱动的读写不对称:写要拆页、擦要对齐、读随便读。

5. 避坑与排查:模拟 SPI 跑不通时我踩过的 5 个坎

模拟 SPI 的故障现象高度雷同,基本就是「读到全 FF」「读到全 00」「数据错位」这三类。下面五条是我实际踩过的,按出现频率从高到低排。

5.1 读 ID 返回 0xFFFFFF

现象:上电调用w25q_read_id(),串口打印出来永远是0xFFFFFF,换成0x000000的情况也有。

原因:MISO 线上根本没有有效电平。上拉输入在没有任何驱动时默认读 1,所以返回全 FF;如果配成下拉或者浮空恰好被拉低,就是全 00。根子在于从机没工作或者线没通。

解决:按这个顺序查——先量 W25Q16 的 VCC 是不是稳定的 3.3V(有人误接 5V,芯片当场报废);再用万用表通断档量四根线到芯片引脚是不是真通(杜邦线断在胶壳里很常见);最后确认 CS 有没有真的拉低,可以在CS_LOW()后面加一句翻转 LED,看程序有没有走到那里。这三步能解决九成的全 FF。

5.2 读 ID 正确但写进去读出来还是 0xFF

现象:w25q_read_id()返回 0xEF4015,说明芯片和时序都没问题,可写一页数据再读回来,内容全是 0xFF,像是没写进去。

原因:写操作被静默忽略了,最常见的两个原因是没发写使能(0x06)以及目标扇区没擦除。Flash 只能 1 写 0,如果目标区域本来就是 0x00,你再写什么都是 0x00;反过来如果没擦就直接写,新数据和老数据做的是「与」运算,结果既不是新的也不是旧的。

解决:在写之前先读一遍目标区域,确认是全 0xFF;如果不是,先执行扇区擦除再写。同时在w25q_page_program里加一句断言,检查w25q_wait_busy的返回值,超时就置错误标志。这一条加完之后,写入失败从「静默」变成了「可观测」,排查效率提升一大截。

5.3 数据整体错一位,ID 读成 0xDE80A8

现象:读回来的 ID 是 0xEF4015 左移或右移一位的结果,比如 0xDE80A8(右移一位)或者 0x7A02A0(左移一位)。

原因:采样点和数据变化沿贴太近。模式 0 下从机在 SCK 下降沿更新 MISO,主机在上升沿采样,如果主机的spi_delay()加在采样之后而不是之前,采样点就紧贴上升沿,此时 MISO 还在翻转过程中。

解决:把延时挪到SCK_HIGH()之后、MISO_READ()之前。另外把spi_delay()的 NOP 数量临时加到 20 个,如果错位消失,说明就是速度问题,再逐步往下调,找到能稳定工作的最小值。还有一个隐蔽原因:MISO 配成了推挽输出,和从机的输出打架,这种情况也会表现为数据错乱,检查GPIO_InitTypeDef里 MISO 的 Mode 是不是GPIO_MODE_INPUT。

5.4 写第二页把第一页的数据覆盖了

现象:从地址 0x000000 开始连续写 300 字节,读回来前 44 字节是 0xFF,后 256 字节是对的。

原因:一次页编程写了超过 256 字节,地址在页内回卷,把本页开头刚写的数据覆盖了,而真正要写到下一页的部分根本没写出去。这是 W25Q16 的硬件行为,不是代码 bug。

解决:所有写操作统一走w25q_write()这个跨页封装,不要直接调w25q_page_program。封装里用256 - (addr & 0xFF)算出当前页剩余空间,按这个长度切片,切完再进下一页。如果项目里已经散落着直接调页编程的地方,加一条assert(len <= 256 - (addr & 0xFF))兜底。

5.5 擦除后偶发读失败,重试又好了

现象:扇区擦除之后立刻读,偶尔读到全 FF(正常),偶尔读到旧数据,重新上电又恢复正常。

原因:擦除还没结束就去读了。w25q_wait_busy的超时值给得太小,或者轮询时 CS 被中途抬起导致 WIP 位读错。还有一种可能是擦除期间电源被拉低——擦除电流比读大得多,用 USB 供电加长线的时候尤其明显。

解决:把超时值从 100000 提到 200000 以上;轮询循环里 CS 全程保持低电平;电源脚并一只 10uF 电解加 0.1uF 陶瓷,尤其是擦除频繁的场合。如果擦除后立刻要做写操作,中间隔一次读 ID 作为「芯片还活着」的心跳检查,能提前发现异常。

6. 二次开发:把驱动抽成可替换总线的模块

这套代码最早是硬编码在业务里的,后来板子换了三次、SPI 从模拟换成硬件又换回来,来来回回改得心烦。第二次重构我把它拆成了「总线层 + 芯片层」,芯片层只认一个函数指针结构体,不关心底下是 GPIO 翻转还是硬件 SPI。

6.1 用函数指针结构体隔离总线

/* 总线抽象:芯片层只依赖这三个能力 */ typedef struct { void (*cs)(bool level); uint8_t (*transfer)(uint8_t tx); void (*delay)(void); } w25q_bus_t; /* 模拟 SPI 的实现 */ static const w25q_bus_t bus_gpio = { .cs = gpio_cs, .transfer = spi_transfer, .delay = spi_delay, }; bool w25q_init(const w25q_bus_t *bus) { g_bus = bus; return (w25q_read_id() == 0xEF4015); }

这么改的好处是换硬件 SPI 时只新增一个bus_spi结构体,芯片层的页写、擦除、读 ID 一行都不用动。代价是多一层间接调用,每次传输多几个周期,在 1.5MHz 的模拟 SPI 里基本看不出来。

6.2 验证方法与改造方向

改完之后必须回归验证,我固定跑三个用例:读 ID 连续 100 次不能有一次错;写满一个 4KB 扇区、读回做 CRC32 比对;整片擦除后全片读一遍,确认都是 0xFF。第三个用例最慢,2MB 全读加校验大概几秒,但能揪出地址越界和跨页切片的边界问题。

后续要往上加东西,方向无非两个:一是加一层小文件系统或环形日志缓冲,把「按扇区擦写」的物理限制藏起来,业务层只管 append;二是接入掉电保护,写之前先把元数据写到备份扇区,上电扫描恢复。这两个我都做过,坑主要在擦写次数的均衡上——固定往一个扇区写日志,十万次寿命很快就到,得做磨损均衡。

从那以后我每次做模拟 SPI 的板子,都强制先跑一遍 MOSI-MISO 短接自环测试,再读 ID,最后才动业务代码。这个习惯帮我省下的时间,比任何调试技巧都多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询