☰
STM32F103模拟SPI读写W25Q16:GPIO时序驱动与避坑指南
2026/10/15 16:54:27 网站建设 项目流程

简介:基于STM32F103的软件模拟SPI读写W25Q16方案,面向嵌入式开发者与STM32入门进阶人群,解决无硬件SPI外设或需节省资源时的Flash读写问题。资料包含完整工程源码,覆盖GPIO推挽输出配置、时钟极性与相位(CPOL/CPHA)设置、波特率选择、SPI时序模拟及W25Q16读写命令序列等核心环节。压缩包共78个文件,以C源文件(32个)和头文件(34个)为主,辅以汇编启动文件、工程配置文件与清理脚本,整体仅311KB,目录结构清晰,便于直接移植到自家工程。目前已有4105人学习下载。通过阅读示例代码与使用说明,可掌握软件模拟SPI的时序控制方法,理解W25Q16状态寄存器读取、写使能、页写入等操作流程,并可直接在STM32F103开发板上验证;这套实现既能作为裸机驱动开发的参考模板,也为后续移植到其他MCU提供了思路。

1. 模拟SPI读写W25Q16:把时间花在值得的地方

做过几块STM32F103小板的开发者,多半遇到过这种尴尬:硬件SPI引脚被别的功能占了,或者PCB走线绕不过去只能换脚。这时候“stm32f103模拟SPI读写W25Q16”就是最省事的出路——用两个普通GPIO当时钟和数据线,把SPI时序一位一位抠出来,照样能稳定读写W25Q16这颗2MB串行Flash。这套方案亲测可用,坏处是速度比硬件SPI慢一个量级,好处是引脚任意选、代码移植到任何型号都能跑。适合手里有W25Q16模块、想绕过引脚冲突快速把存储跑通的开发者,也适合理解SPI时序到底在做什么的入门者。

2. 方案选型和接线:模拟SPI不是玄学,是时序取舍

2.1 模拟SPI和硬件SPI怎么选:引脚自由度和时序控制

很多开发者一上来就问:STM32F103本身自带SPI外设,为什么非要用GPIO模拟?真实原因往往很朴素——板子上硬件SPI的SCK、MOSI、MISO三根脚被LCD、SD卡或者其它外设占了,又不想改PCB;或者画板的时候为了走线方便,把Flash的引脚接到了任意GPIO上。硬件SPI的引脚映射是固定的(虽然F103有部分重映射,但也就那几组),完全自由的GPIO显然更灵活。

模拟SPI还有一个隐性优势:你能精确控制每一位的时序。硬件SPI在F103上跑18MHz主频时,波特率分频配置不对容易在临界频率翻车,而模拟SPI的速率完全由代码里的延时决定,想多慢就多慢,对信号完整性要求极低。项目里如果只是存参数、存字库、存日志,几百kHz的速率完全够用——写一页256字节也就几毫秒的事,瓶颈在Flash擦写本身,不在传输速度。

那模拟SPI的劣势在哪里?首先是CPU占用,每一位都要翻转GPIO和采样输入,不能像硬件SPI那样靠外设自动搬运;其次是速率上限,F103的GPIO翻转频率在18MHz主频下实测约2~4MHz,模拟SPI跑1MHz已经是比较理想的数字,再高时序就容易出毛刺。我在某图像处理Demo里用模拟SPI跑过W25Q16,时钟设在500kHz,稳定性很好;硬要提到2MHz以上,就得检查波形了。

2.2 接线图和GPIO初始化代码

W25Q16是标准的SPI从设备,四线制:片选CS、时钟SCK、主机输出MOSI、主机输入MISO。模拟SPI主机的接线原则是:任意GPIO都可以,但尽量避开JTAG占用的引脚(PA13~PA15、PB3、PB4),否则下载程序时会冲突。常见做法是选PB12做CS、PB13做SCK、PB14做MOSI、PB15做MISO,这四个脚在F103最小系统板上都有引出,且默认是普通GPIO,不用关闭JTAG。

注意W25Q16的MISO在没被选中时是高阻态,主机侧的MISO引脚需要上拉电阻,否则CS拉低之前读到的电平是浮动的。很多模块板上已经焊了上拉,但如果你是自己画的板子,引脚配置成上拉输入最省事。

#define FLASH_CS_L() GPIO_WriteBit(GPIOB, GPIO_Pin_12, Bit_SET) #define FLASH_CS_H() GPIO_WriteBit(GPIOB, GPIO_Pin_12, Bit_RESET) #define FLASH_SCK_H() GPIO_WriteBit(GPIOB, GPIO_Pin_13, Bit_SET) #define FLASH_SCK_L() GPIO_WriteBit(GPIOB, GPIO_Pin_13, Bit_RESET) #define FLASH_MOSI_H() GPIO_WriteBit(GPIOB, GPIO_Pin_14, Bit_SET) #define FLASH_MOSI_L() GPIO_WriteBit(GPIOB, GPIO_Pin_14, Bit_RESET) #define FLASH_MISO_GET() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_15) void SPI_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_15; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入 GPIO_Init(GPIOB, &GPIO_InitStructure); FLASH_CS_H(); FLASH_SCK_L(); }

这段代码的关键点有三个。第一,CS、SCK、MOSI都是推挽输出,50MHz翻转速度是模拟SPI能达到较高速率的前提;MISO必须配置成上拉输入,前面说过,这是为了CS高电平时输入脚不是浮动状态。第二,初始化完后把CS拉高、SCK拉低,这对应SPI Mode 0的空闲电平——时钟空闲时为低,这是后面时序正确的基础。第三,宏定义里CS的电平逻辑容易记混:W25Q16是低有效片选,所以CS_L代表选中,CS_H代表释放,写反了会导致每次操作都选中不了Flash,读ID全是0xFF。

提示:F103的GPIO翻转速度受APB2总线时钟影响,如果你把GPIOB挂在APB2上(默认72MHz),50MHz输出速度配置没问题;但如果你改了时钟树,注意别让APB2分频后太低,否则模拟SPI速率会跟着掉。

3. 用GPIO把SPI时序抠出来:Mode 0收发函数

3.1 SPI Mode 0的时序拆解

W25Q16的数据手册虽然厚,但模拟SPI只需要关心一种模式:Mode 0,也就是CPOL=0、CPHA=0。含义是:时钟空闲时SCK为低电平,数据在SCK上升沿被主机采样发送、在下降沿被从机采样接收。对这个Flash来说,Mode 0和Mode 3(CPOL=1、CPHA=1)都支持,但Mode 0的实现最简单,因为空闲电平是低,初始化时把SCK拉低就行。

时序拆开看就三步。第一步,准备阶段:SCK保持低电平,主机把要发送的位放到MOSI上。第二步,上升沿:SCK从低变高,此时从机(W25Q16)采样MOSI上的电平,这一位被写入Flash内部移位寄存器;同时,从机把自己的输出位放到MISO上。第三步,下降沿:SCK从高变低,主机采样MISO上的电平,完成一位数据的接收。循环八次,就是一个字节。

很多人在这一步栽跟头,是因为把“主机采样MISO”的时机搞错了。上升沿是Flash锁存MOSI的时刻,但MISO上的数据在上升沿之前就已经被Flash放到引脚上了,主机必须等到上升沿之后再读MISO才有意义。正确做法是在SCK拉高之后、拉低之前读MISO。反过来,发送MOSI的时机是SCK拉低之后——给Flash一个稳定的建立时间。

uint8_t SPI_ReadWriteByte(uint8_t byte) { uint8_t i; uint8_t received = 0; for (i = 0; i < 8; i++) { // 发送位:先放数据,再拉高时钟 if (byte & 0x80) FLASH_MOSI_H(); else FLASH_MOSI_L(); FLASH_SCK_H(); // 上升沿,从机锁存MOSI byte <<= 1; // 接收位:时钟高电平时读MISO if (FLASH_MISO_GET()) received |= 0x01; else received &= 0xFE; FLASH_SCK_L(); // 下降沿,准备下一个位 } return received; }

这个收发一体的函数是模拟SPI的核心,读和写共用同一个函数:读数据时主机发0xFF,写数据时主机发真正的字节,同时把从机返回的字节收回来。参数里有几个值得注意的点。第一,发送位用的是byte & 0x80,从最高位开始发,这是SPI的MSB先行约定,W25Q16的指令和地址都是高位先出,顺序反了会读出完全错位的数据。第二,接收位是在SCK拉高之后读取的,这和前面讲的时序一致——时钟高电平期间MISO是稳定的。第三,received的赋值方式用了|= 0x01和&= 0xFE,连续接收八次就把位拼成了一个完整字节。

3.2 字节收发与片选控制代码

SPI设备的所有操作都以片选拉低开始、拉高结束。W25Q16尤其严格:芯片上电后,CS必须有一次完整的低-高跳变,芯片才能正确识别指令边界。这个问题在连续读写时容易暴露——上次操作结束后CS没拉高到位,下次直接操作,Flash可能把两条指令当成一条来处理。

uint8_t W25Q_ReadByte(void) { return SPI_ReadWriteByte(0xFF); } void W25Q_WriteByte(uint8_t byte) { SPI_ReadWriteByte(byte); } void W25Q_Select(void) { FLASH_CS_L(); } void W25Q_Release(void) { FLASH_CS_H(); }

片选操作的时序要求:CS拉低之后,至少要等一个时钟周期再开始发数据;CS拉高之前,要等最后一个时钟的下降沿完成。模拟SPI里没有严格的时间约束,但建议在CS跳变前后加几个空操作或者极短的延时(GPIO翻转本身就是微秒级的),避免在高速主频下CS和SCK之间的建立时间不够。我见过有人把CS和SCK写在同一个GPIO寄存器的连续操作里,结果CS还没稳定SCK就跳了,读ID偶尔正常偶尔错乱,加了两条__NOP()就稳定了。

这里补充一个容易忽略的细节:W25Q16的SO(MISO)在CS拉高后是高阻态,所以主机的MISO引脚必须保持上拉输入。如果你把MISO配成了浮动输入,CS高电平时读到的电平随机,影响的是下一次传输的第一个位——因为Flash在CS拉低后第一时间把状态放到MISO上,如果上一位残留了错误电平,接收函数会在第一位采错。

4. 把W25Q16跑起来:读写擦除命令的最小驱动

4.1 读ID和读数据:先确认芯片活着

写驱动代码的第一步永远是读ID。W25Q16的JEDEC ID指令是0x9F,发送指令后跟三个空字节,依次读回Manufacturer ID(0xEF)、Memory Type(0x40)、Capacity(0x15)。0x15是W25Q16的容量代码,等于十进制的21,对应2MB;如果是W25Q32则读到0x16。读ID成功的基本上可以确认接线、时序、上拉都没问题,再往下走就有底气了。

读数据指令是0x03,后面跟24位地址(3字节),随后连续输出数据。地址范围从0x000000到0x1FFFFF,因为W25Q16是2MB容量,地址宽度正好24位。这里有个新手容易踩的坑:地址必须是高位在前,比如读地址0x123456,发送顺序是0x12、0x34、0x56,不是0x56、0x34、0x12。

uint16_t W25Q_ReadID(void) { uint16_t id = 0; W25Q_Select(); W25Q_WriteByte(0x9F); id = W25Q_ReadByte() << 8; id |= W25Q_ReadByte(); W25Q_Release(); return id; } void W25Q_ReadData(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t i; W25Q_Select(); W25Q_WriteByte(0x03); W25Q_WriteByte((addr >> 16) & 0xFF); W25Q_WriteByte((addr >> 8) & 0xFF); W25Q_WriteByte(addr & 0xFF); for (i = 0; i < len; i++) { buf[i] = W25Q_ReadByte(); } W25Q_Release(); }

W25Q_ReadID里读JEDEC ID用了两个字节,其实标准流程要读三个字节才会依次得到厂家、类型、容量;但对我们来说厂家的0xEF和类型的0x40是固定的,真正要确认的是容量码,所以先把前两个字节拼进一个uint16_t就够判断了。读ID的返回值和预期不符时,优先检查三件事:接线是否把MOSI和MISO搞反了、时钟极性是否反了、片选是否真的拉低了。如果ID读出来是0xFF 0xFF,基本可以断定Flash根本没响应,大概率是CS没选中或者MISO断了。

读数据函数的地址用32位变量保存,但发送时只取低24位。(addr >> 16) & 0xFF是把最高字节取出来,注意右移16位之后要跟0xFF做与运算,否则地址超过2MB时会有高位垃圾数据混进去。虽然2MB以内地址不会溢出,但这个防呆习惯建议保留。

4.2 页写与扇区擦除:先擦才能写

W25Q16的写操作有两个铁律:一是写之前必须发写使能指令0x06;二是写只能把1写成0,要把0恢复成1必须先擦除。所以往Flash里写数据的正确顺序永远是:擦除整块/整扇区 → 等待擦除完成 → 写使能 → 页写 → 等待写完成。跳过擦除直接写,读回来的数据会和旧数据做“与运算”,出现莫名其妙的字符缺失。

页写指令是0x02,后面跟24位地址,然后最长256字节的数据。每次写入不能在页边界(地址低8位为0的位置)跨页,否则数据会回卷到本页开头——这是W25Q系列最经典的问题,后面避坑章节会细讲。擦除指令按粒度分为扇区擦除0x20(4KB)、块擦除0xD8(64KB)、整片擦除0xC7,常用的是扇区擦除,因为4KB粒度对存储参数和日志都够用。

void W25Q_WriteEnable(void) { W25Q_Select(); W25Q_WriteByte(0x06); W25Q_Release(); } void W25Q_WaitBusy(void) { uint8_t status; W25Q_Select(); W25Q_WriteByte(0x05); do { status = W25Q_ReadByte(); } while (status & 0x01); W25Q_Release(); } void W25Q_EraseSector(uint32_t addr) { W25Q_WriteEnable(); W25Q_Select(); W25Q_WriteByte(0x20); W25Q_WriteByte((addr >> 16) & 0xFF); W25Q_WriteByte((addr >> 8) & 0xFF); W25Q_WriteByte(addr & 0xFF); W25Q_Release(); W25Q_WaitBusy(); } void W25Q_WritePage(uint32_t addr, const uint8_t *buf, uint16_t len) { uint16_t i; W25Q_WriteEnable(); W25Q_Select(); W25Q_WriteByte(0x02); W25Q_WriteByte((addr >> 16) & 0xFF); W25Q_WriteByte((addr >> 8) & 0xFF); W25Q_WriteByte(addr & 0xFF); for (i = 0; i < len; i++) { W25Q_WriteByte(buf[i]); } W25Q_Release(); W25Q_WaitBusy(); }

W25Q_WaitBusy是这套驱动里最重要的函数。0x05是读状态寄存器指令,返回的状态寄存器最低位bit0是WIP(写忙标志),为1表示Flash正在擦除或写入,为0表示空闲。写入和擦除都必须等WIP清零才能进行下一步,否则会出现“上一条指令还没干完,下一条指令就发过来了”的错乱。扇区擦除典型耗时约150ms,页写约3ms,这段等待时间是模拟SPI方案里真正的耗时大头,比传输时间高一个数量级。

W25Q_WritePage里两个细节需要解释。第一,写使能和页写是两个独立的片选操作,中间CS必须拉高再拉低;有不少人把0x06和0x02放在了同一个片选周期内,结果Flash拒绝执行写操作,因为W25Q要求写使能指令结束后CS必须拉高一次才能生效。第二,页写最多256字节,但真实项目里经常要写超过256字节的数据,这时候需要在应用层把数据按页切分成多次调用W25Q_WritePage,每次传入的地址要对齐到页边界。

5. W25Q16模拟SPI避坑:5条亲测踩坑记录

5.1 读到全0xFF:芯片没响应还是接线反了

现象是读ID、读数据全是0xFF,用示波器看MOSI上有波形但MISO一直是高电平。最开始怀疑芯片坏了,换了新片也一样。原因在接线:把MOSI和MISO反接了。主机的MOSI连到了Flash的SO(数据输出),主机的MISO连到了Flash的SI(数据输入),Flash根本没收到任何有效指令,自然不回应。解决方法是交换这两根线,或者把SPI_ReadWriteByte里的发送和接收逻辑对调——但改硬件接线比改代码更可靠,因为换线之后波形一目了然。

这个坑在模块板上一半的概率会踩到,因为很多W25Q16模块的丝印只标了DI和DO,不标MOSI/MISO,接的时候想当然就反了。最快的确认方法是测Flash的DO引脚(第2脚)在CS拉低后是否从高阻变成输出——用万用表量电压,如果在CS拉低瞬间DO被拉低过一下,说明Flash收到了指令在回应,只是数据没走到主机这边。

5.2 ID读到了但数据全是0x00:SCK极性和相位反了

现象是读JEDEC ID能读到0xEF 0x40,但连续读数据时返回的全是0x00。这次接线没问题,问题出在SCK的时序上。W25Q16其实支持Mode 0和Mode 3两种模式,如果代码里SCK空闲时是高电平(Mode 3),Flash也能工作,但某些老批次芯片对相位比较敏感,上升沿和下降沿的采样时机差一点点,就会导致第一个字节正常、后续字节全错。

解决方法是统一用Mode 0:初始化时SCK拉低,发送位在SCK低电平期间放上MOSI,SCK上升沿后读MISO,下降沿后准备下一位。检查方法也很简单:把SCK直接接地,看Flash是否还能响应读ID——如果能,说明Flash在SCK低电平模式(Mode 0)下工作正常;如果不行,多半是代码里SCK空闲电平搞反了。

5.3 跨页写数据错乱:256字节边界的诅咒

现象是写一个500字节的缓冲区,读回来前半段正常,后半段数据跑到文件开头去了,或者读回来全是0xFF。原因是页写跨越了256字节边界。W25Q16的一页是256字节,页写地址如果从页中间开始又连续写了超过页尾的数据,超过的部分会自动回卷到本页的开头,把已经写好的数据覆盖掉。比如从地址0x1FF0开始写32字节,写到0x1FFF之后回卷到0x1F00,而不是继续到0x2000。

解决方法是应用层做分页写入:计算当前地址距离页尾的字节数,每次写入不超过这个数,然后地址步进到下一页继续写。没有更好的办法,芯片硬件就这个行为,必须驱动层处理。这也是为什么很多现成Flash驱动库里都带一个page_size参数,本质就是做边界对齐。

5.4 擦除后立刻写失败:忘记查忙状态

现象是扇区擦除后马上执行页写,然后在读回时发现数据没写进去或者写入的数据是错的。原因是擦除还没完成就发了写指令。W25Q16的扇区擦除需要约50~150ms,如果主控擦除指令一发完就接着发写使能和页写,Flash的内部控制逻辑还在擦除状态,新指令会被忽略。这是模拟SPI方案里最常见的时序问题,因为硬件SPI方案里主控也会跑得很快,但模拟SPI的操作频率相对低,容易让人忽视等待时间。

解决方法是严格遵守操作顺序:擦除完调用W25Q_WaitBusy,写使能完也调用一次,页写完再调用一次。把这三处等待都做上,就不会出问题。有人嫌轮询状态寄存器浪费CPU,用固定延时(比如延时200ms)替代,这种方法能用但不推荐——如果换了不同批次的Flash,擦除时间可能有波动,固定延时要么太长拖慢速度,要么太短翻车。

5.5 用JTAG引脚当GPIO:下载器直接被断开

现象是把Flash接在PB3、PB4上,代码里正常操作GPIO,结果程序下载失败,提示找不到芯片。原因是PB3和PB4在F103上默认复用为JTAG的JTDO和JTRST,如果不关闭JTAG功能,这两个引脚根本不受GPIO控制。解决方法是操作前调用GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE),把JTAG关闭只保留SWD;或者干脆避开PB3、PB4、PA13、PA14、PA15这几个脚。注意,关闭JTAG后如果想再用JTAG下载,得先把代码里这行配置去掉,否则会带来额外的下载麻烦——这就是为什么我建议直接用PB12~PB15这一组默认GPIO最稳妥。

6. 验证与提速技巧:回读校验和翻转优化

把上面的驱动跑通之后,最后一步是验证——写数据不校验等于没写。我的习惯是写完后立即回读整块数据做比对,比对结果错误就重新擦除写入,重试三次还错就报错上抛。这个回读校验的代码如下:

uint8_t W25Q_Verify(uint32_t addr, const uint8_t *buf, uint16_t len) { uint8_t temp[64]; uint16_t offset = 0; while (offset < len) { uint16_t read_len = (len - offset > 64) ? 64 : (len - offset); W25Q_ReadData(addr + offset, temp, read_len); if (memcmp(temp, buf + offset, read_len) != 0) return 0; offset += read_len; } return 1; }

回读分块处理是因为W25Q16单次读可以连续读任意长度,但主控侧缓冲区有限,分64字节一块不容易出错。memcmp比对失败时,如果需要定位具体错在哪一位,可以把buf和temp逐字节打印出来看差异,通常能直接看出是跨页问题还是写使能遗漏。

提速方面,模拟SPI的瓶颈在GPIO翻转速度,优化技巧不外乎三个方向。第一,把GPIO的操作从不安全但慢的GPIO_WriteBit改成直接操作GPIOB->ODR寄存器,打开外设时钟后直接赋值寄存器位,速度能提升30%以上;第二,SCK翻转之间减少多余的判断,比如把发送位的数据判断提前到循环外预处理;第三,在允许的范围内提高系统时钟到72MHz,并且确认APB2总线时钟不是分频状态。实测500kHz时钟下读2MB全部数据约4秒,对参数存储类应用完全够用。

最后分享一个习惯:不管代码改了什么,第一件事永远是读ID和回读刚写的1字节数据,确认整条链路是通的再做批量操作。这套流程帮我挡掉了不少低级问题,省下的都是调试时间。希望帮到你。

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

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

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

立即咨询