拿到一块全新的FPGA开发板很多人前一周都在点灯第二周开始刷数码管第三周就不知道该干什么了。如果你也卡在这个阶段我强烈建议你认真玩一次SPI Flash驱动。这颗料价格便宜、协议清晰、反馈直观堪称FPGA外设驱动的“最佳教学样本”。它不像DDR那样有复杂的初始化时序也不像HDMI那样涉及高速差分信号但麻雀虽小五脏俱全有协议、有时序、有状态机、有仿真还有上板调试的完整闭环。这篇文章我就基于野火FPGA征途Pro板卡把SPI Flash驱动的全流程拆开揉碎讲一遍包含硬件连接、手册阅读、Verilog状态机设计、仿真验证和上板实测希望对正在学FPGA外设驱动的人有帮助。1. 动手前的两件事读懂原理图和数据手册很多初学者习惯性先找例程代码拿到就跑。我建议把顺序反过来先花半小时读两样东西板卡的原理图和Flash的数据手册。驱动写得好不好取决于你对这两样东西的理解深度而不是代码敲得快不快。1.1 板载Flash的硬件连接与引脚对应关系野火征途Pro板卡上板载了一颗SPI NOR Flash常见型号是W25Q系列比如W25Q64或W25Q128分别对应64Mbit和128Mbit也就是8MB和16MB。这类芯片在市面上非常普及也是FPGA配置芯片的常见选择。它和FPGA之间只有6根线CS片选、CLK时钟、DI数据输入、DO数据输出外加WP写保护和HOLD保持。引脚连接上WP和HOLD在标准SPI模式下通常被拉高或通过电阻上拉只需要关注前四根线。下面这个表格是征途Pro板卡上Flash与FPGA引脚的示意连接方式注意不同批次、不同版本的板卡引脚完全可能不同动手前一定要对照你自己的原理图逐个确认Flash引脚方向连接到FPGA IO功能说明CS#FPGA → FlashBANK2的普通IO示意片选低电平有效拉低时芯片被选中CLKFPGA → FlashBANK2的普通IO示意SPI时钟由FPGA产生DI (MOSI)FPGA → FlashBANK2的普通IO示意数据输入命令、地址、写数据都从这根线进DO (MISO)Flash → FPGABANK2的普通IO示意数据输出读数据、读状态都从这根线出WP#外部上拉接高电平写保护低电平时禁止写状态寄存器HOLD#外部上拉接高电平暂停传输低电平时芯片忽略时钟这里有一个非常容易踩的坑网上很多W25Q的例程用的是别的板卡的引脚约束直接抄过来大概率读不到任何数据。FPGA的IO引脚是可以自由配置的但前提是你得在工程里正确约束到板卡实际连接的引脚。我第一次做这个实验的时候就因为在原理图上少看了一眼把CLK和MOSI的两根线约束反了结果上板以后读ID读到一堆乱码花了半小时才查出来。别笑话这种低级错误几乎每个人都会犯一次。1.2 数据手册里最值得精读的几页W25Q系列的数据手册动辄上百页但真正驱动开发需要精读的其实就几部分。第一部分是SPI模式说明明确芯片支持Mode 0和Mode 3这也是后面设计时序的基准。第二部分是命令集表Instruction Set一张表格把芯片所有能力列得明明白白。第三部分是状态寄存器定义负责判断芯片忙不忙、有没有写保护。第四部分是AC时序参数表告诉你能跑多快、建立保持时间要留多少余量。对新手来说最容易被忽略的就是命令集表下面那段关于写使能Write Enable的说明。W25Q系列芯片有个非常反直觉的规矩任何擦除、写入操作之前必须先单独发一条0x06写使能命令把芯片内部的状态寄存器WEL位置1然后CS拉高结束这条命令再在下一个CS周期里发真正的擦除或写命令。漏掉这一步Flash会直接忽略后续所有操作。这个设计是为了防止系统上电瞬间或信号抖动导致意外擦写属于芯片的自我保护机制。理解了这个背景你写驱动时就知道为什么流程里总是多出来一个0x06。命令集不需要全部背下来但下面这几条是驱动最核心的“字典”建议对着表格理解每条命令的使用场景命令名称命令字节地址数据功能说明Write Enable0x06无无写使能擦除/写入前必须发送Read Status Register0x05无读出1字节读状态寄存器判断WIP忙标志Read Data0x033字节连续读N字节最常用的读数据命令Page Program0x023字节1到256字节页编程一次最多写256字节Sector Erase0x203字节无扇区擦除一次擦除4KBChip Erase0xC7无无全片擦除慎用Read JEDEC ID0x9F无读出3字节读厂商ID、型号ID上电验证首选读JEDEC ID这条命令在调试阶段非常有用。只要FPGA和Flash之间的物理连接没问题上电后发0x9F芯片会依次返回厂商ID 0xEFWinbond、内存类型ID、容量ID。比如W25Q64返回0xEF、0x40、0x17W25Q128返回0xEF、0x40、0x18。先用这条命令验证链路是排查一切通信问题最快的方法。2. SPI时序中的关键细节模式选择、完整操作序列与忙轮询协议看懂了是一回事能写进FPGA是另一回事。在位级通信层面有几个细节决定了你的驱动稳不稳定SPI模式选择、数据采样时机、以及Flash内部操作的等待逻辑。2.1 为什么几乎所有人都选Mode 0W25Q支持SPI Mode 0和Mode 3二者的区别在时钟极性和相位上。Mode 0对应CPOL0、CPHA0也就是SCK空闲为低电平数据在SCK上升沿被采样。Mode 3对应CPOL1、CPHA1SCK空闲为高电平同样在上升沿采样只是上升沿出现在每个位周期的后半段。对FPGA驱动来说选Mode 0最直观SCK空闲是低电平一个bit的完整周期从低电平开始先产生上升沿再产生下降沿。这样每个bit周期的“节拍”非常清晰状态机也容易对齐。Mode 3虽然也可以但初学时没必要增加脑负担。这里用更直白的方式描述时序约定。在Mode 0下主机的职责是SCK下降沿之后把下一位数据放到MOSI上因为Flash会在上升沿采样你必须保证数据在上升沿到来之前已经稳稳定定地待在那里。反过来Flash在SCK下降沿之后把MISO上的数据更新好所以FPGA作为主机应该在SCK上升沿之后、下一次下降沿之前的这段时间里去采样MISO。如果采样点太靠近边沿就可能采到不稳定电平这也是后面第5节要讲的跨时钟采样问题。2.2 读ID、擦除、页编程的完整操作序列有了模式约定我们就可以把一条条命令串成完整的操作序列。写驱动的时候最重要的就是严格按照手册流程走下面是我在项目中反复使用的三个序列模板。读ID操作序列最简单CS拉低发送0x9F命令字节然后连续读3个字节最后CS拉高。整个过程都在一个CS有效的周期内完成。扇区擦除的序列稍微复杂第一步CS拉低发送0x06写使能CS拉高结束命令第二步再次CS拉低发送0x20擦除命令再发送24位扇区地址CS拉高。到此擦除操作只是被Flash接收了Flash内部还需要几十毫秒才能真正擦完所以之后必须轮询状态寄存器的忙标志确认擦除完成。页编程的序列和擦除类似先0x06写使能再CS拉低发送0x02页编程命令接着发24位目标地址然后连续发送最多256字节数据最后CS拉高并轮询忙标志。注意和读数据不同页编程的地址是写入起始地址数据紧接着地址之后连续给。这三个序列建议画在纸上尤其是CS什么时候拉低、什么时候拉高一定要清晰。CS的拉高不是收尾动作而是很多命令的“结束标志”。比如0x06写使能CS拉高才表示这条命令彻底结束芯片才会真正把WEL位置1。这也是新手最容易犯的错把0x06和后面的擦除命令放在同一个CS周期内连续发送看起来没什么问题实际上Flash根本不认。2.3 状态寄存器与WIP忙标志轮询Flash的擦除和写入是内部物理操作需要时间。W25Q64的页编程典型时间在毫秒量级扇区擦除典型时间几十毫秒如果数据手册写得保守一点最大时间甚至到几百毫秒。写驱动时不能靠延时死等正确的做法是轮询。轮询用0x05读状态寄存器命令。CS拉低发送0x05然后读一个字节。这个字节的bit0就是WIP忙标志1表示Flash内部正在忙着擦除或写入0表示空闲。WIP为1时需要反复发送0x05读取直到读到0为止。注意每次读状态寄存器都要完整走一个CS周期CS拉低、发命令、读字节、CS拉高。不要图省事让CS一直低着连续读手册虽然也支持但每次CS周期新发命令是最清晰最不易出错的做法。这里有个实操技巧轮询结束之后建议顺手再读一次状态寄存器的bit1WEL位确认一下。如果擦除结束后WEL位自动清零说明操作确实被芯片执行了。如果WEL位一直是0说明之前的写使能根本没有生效问题大概率出在命令序列或CS时序上。3. Verilog状态机实现把一串固定操作翻译成硬件逻辑协议部分捋顺之后真正的挑战来了怎么用Verilog把这串操作翻译成可综合的硬件逻辑。FPGA里没有“延时函数”的概念也没法按顺序一行行执行代码所以必须引入状态机让硬件按照固定的状态顺序一步步走完整个流程。3.1 模块划分与接口定义我这里采用一个常规的做法把整个驱动拆成两层。顶层是一个SPI Flash控制器模块负责生成状态机和用户接口底层的字节级传输理论上可以单独拆一个spi_master模块也可以直接在顶层里用移位寄存器实现。为了先讲清楚主状态机的设计思路我们先看控制器的整体接口。module spi_flash_ctrl #( parameter CLK_FREQ_HZ 50_000_000, parameter SPI_FREQ_HZ 6_250_000 )( input wire clk, // 系统时钟 input wire rst_n, // 复位低有效 // 用户接口 input wire wr_req, // 页编程请求单字节版本 input wire rd_req, // 读数据请求 input wire [23:0] addr, // 24位操作地址 input wire [7:0] wr_data, // 要写入的数据 output reg [7:0] rd_data, // 读出的数据 output reg busy, // 忙标志1表示正在执行命令序列 output reg done, // 操作完成脉冲 // SPI 物理接口 output reg spi_cs_n, // Flash片选低有效 output reg spi_sck, // SPI时钟 output reg spi_mosi, // 主出从入 input wire spi_miso // 主入从出 );用户接口我给的是单字节读写场景一次请求对应一个地址、一个字节。真实项目里如果要连续读写大块数据还需要再加数据总线和握手信号但学习阶段先把单字节流程跑通后续扩展的思想是完全一样的。用状态机而不是别的方式核心原因是SPI协议本质上是“一串有严格先后顺序的电平变化”。状态机天然适合表达这种顺序逻辑每个状态负责一个阶段满足条件后切换下一个状态。而且状态机调试起来很直观仿真波形里看状态切换一目了然。3.2 状态机主框架代码解析下面这段代码是整个控制器的主状态机框架为便于阅读我把底层字节传输逻辑先抽象成一个“byte_done触发”事件略去了分频细节重点看状态跳转关系localparam [3:0] S_IDLE 4d0; localparam [3:0] S_CMD 4d1; localparam [3:0] S_ADDR 4d2; localparam [3:0] S_WRITE 4d3; localparam [3:0] S_READ 4d4; localparam [3:0] S_WAIT 4d5; localparam [3:0] S_DONE 4d6; reg [3:0] state; reg [7:0] cmd_r; reg [23:0] addr_r; reg [7:0] data_r; reg [1:0] addr_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; spi_cs_n 1b1; busy 1b0; done 1b0; end else begin done 1b0; case (state) S_IDLE: begin if (wr_req) begin cmd_r 8h02; // 页编程命令 addr_r addr; data_r wr_data; busy 1b1; state S_CMD; end else if (rd_req) begin cmd_r 8h03; // 读数据命令 addr_r addr; busy 1b1; state S_CMD; end end S_CMD: begin // 底层字节传输模块负责发送 cmd_r // 发送完成后会拉高 byte_done 一拍 if (byte_done) state S_ADDR; end S_ADDR: begin // 依次发送 addr_r[23:16]、addr_r[15:8]、addr_r[7:0] if (byte_done) begin if (addr_cnt 2d2) begin addr_cnt 2d0; state (cmd_r 8h02) ? S_WRITE : S_READ; end else begin addr_cnt addr_cnt 1b1; end end end S_WRITE: begin // 发送1字节数据 if (byte_done) begin spi_cs_n 1b1; // 结束页编程命令 state S_WAIT; // 进入忙轮询 end end S_READ: begin // 从MISO读回1字节 if (byte_done) begin rd_data rx_byte; spi_cs_n 1b1; // 结束读命令 state S_DONE; end end S_WAIT: begin // 轮询WIP忙标志发送0x05读1字节 // 若读到bit0为0说明Flash空闲 if (byte_done) begin if (!wip_flag) begin spi_cs_n 1b1; state S_DONE; end else begin state S_WAIT; // 继续轮询 end end end S_DONE: begin busy 1b0; done 1b1; state S_IDLE; end endcase end end这段代码有几个地方值得展开说。第一S_CMD状态只是准备发送命令但真正发送的是底层字节传输逻辑。这就像你给下属布置任务状态机只负责“下指令”具体“办事”的是后面的移位寄存器和分频计数器。两者通过byte_done这个脉冲握手。第二S_ADDR状态里我用addr_cnt记录已经发了几个字节地址发送顺序是高位在前这是Flash手册明确要求的。第三S_WRITE结束和S_READ结束都拉起spi_cs_n这里CS拉高是命令结束的标志非常重要。还有一个细节页编程命令0x02和读数据命令0x03处理完数据阶段之后去向不一样。写操作需要进S_WAIT轮询读操作直接进S_DONE。这个分支在上面的代码里没有显式写全因为教学版把擦除命令省掉了。实际工程加擦除命令的话S_WAIT前面还要加一个S_ERASE状态处理思路完全一样发命令、发地址、CS拉高、轮询。3.3 字节传输与SCK生成的内部逻辑上面所有状态跳转都依赖byte_done信号现在补上这块拼图。字节级传输的目标是把8位数据逐比特送上MOSI同时从MISO逐比特收下8位数据最后产生一个byte_done脉冲。做法上先要有一个分频计数器。系统时钟通常是50MHz或100MHzFlash的标准SPI模式最高跑到几十MHz稳妥起见初始驱动可以跑到6.25MHz或10MHz。以50MHz系统时钟出6.25MHz SPI时钟为例意味着SCK每翻转一次要经历4个系统时钟周期。我们需要一个div_cnt计数器计数到3后清零并翻转sck_phase。位计数器bit_cnt负责记录当前传输到第几个bit从0到7。每个bit周期包含SCK低半拍和高半拍。在Mode 0下SCK低电平期间是准备时间你应该在低电平阶段把当前bit的数据放到MOSI上SCK上升沿后Flash采样SCK高电平期间则适合去采样MISO因为Flash在下降沿附近更新数据高电平中点附近数据已经稳定。下面这段代码是字节传输的核心思路同样做了简化重点是结构reg [3:0] div_cnt; reg sck_phase; // 当前SCK电平 reg [2:0] bit_cnt; reg [7:0] tx_shift; reg [7:0] rx_shift; reg byte_done; assign spi_sck sck_phase; assign spi_mosi tx_shift[7]; always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt 4d0; sck_phase 1b0; bit_cnt 3d0; byte_done 1b0; end else begin byte_done 1b0; if (div_cnt 4d3) begin div_cnt 4d0; sck_phase ~sck_phase; if (sck_phase) begin // SCK 从高到低下降沿下一拍更新MOSI tx_shift {tx_shift[6:0], 1b0}; if (bit_cnt 3d7) begin bit_cnt 3d0; byte_done 1b1; end else begin bit_cnt bit_cnt 1b1; end end else begin // SCK 从低到高上升沿之后采样MISO rx_shift {rx_shift[6:0], spi_miso}; end end else begin div_cnt div_cnt 1b1; end end end注意这段代码里SCK相位判断和实际边沿的对应关系sck_phase为0时SCK输出低电平sck_phase为1时输出高电平。div_cnt到3翻转所以每个bit周期SCK先低后高符合Mode 0。tx_shift[7]作为MOSI输出每次下降沿后左移一位这样下一bit的数据就位。rx_shift则在高电平时采样MISO并右移一位等8位齐了就得到rx_byte。很多初学者会在仿真里看到波形很奇怪比如MOSI的第一个bit总是不对。这通常是因为初始化时tx_shift没有先装载好要发的那一字节。正确的做法是在状态机进入S_CMD时先把cmd_r装载到tx_shift然后再开始bit传输。装载动作和传输动作要分开否则第一个bit发送时移位寄存器里还是旧数据。另外如果你在Testbench里写仿真可以偷懒用SystemVerilog的task加#延时来模拟字节发送这会比可综合的移位寄存器代码简单很多。但请务必记住带#延时的代码只能仿真不能综合。真正上板必须用上面的计数器分频思路。实践中比较高效的项目方式是先写一个不可综合但逻辑简单的task版本跑通功能再替换成可综合的FIFO式串行接口模块。4. 仿真验证与上板实测让驱动真正跑起来代码写完了千万别急着上板。先花15分钟写一个Testbench在电脑上跑仿真把逻辑错误消灭在综合之前。FPGA调试有个铁律能在仿真里看到的问题绝不上板抓。4.1 用仿真模型先把逻辑跑通Testbench里需要一个“假的Flash”来模拟W25Q的实际行为。网上有开源的W25Q系列Verilog模型直接拿过来用很方便。如果想自己写一个极简版思路也不复杂用一个reg数组模拟存储空间收到读命令0x03时按地址输出收到页编程命令0x02时把数据写入数组收到0x9F时返回EF 40 17。仿真激励的流程我是这样安排的先等系统复位释放然后发起读ID请求验证链路再发起扇区擦除请求等待完成接着发起页编程请求往某个地址写一个已知值比如0xA5最后发起读数据请求把那个地址读回来比对是不是0xA5。如果仿真中读回的值和写入值一致说明核心逻辑基本正确。仿真波形里重点看几个地方。第一个是spi_cs_n它应该严格按命令序列拉低和拉高不该低的时候低着就是命令边界错了。第二个是看sck和mosi的时序关系在SCK上升沿附近MOSI数据应该是稳定的如果波形显示数据恰好在上升沿跳变说明CLK相位处理有问题。第三个是WIP轮询阶段看状态机是否真的进去了S_WAIT再出来。还要警惕一种“仿真过了但上板挂掉”的情况Testbench里的假Flash模型通常不带真实芯片的AC时序参数。比如真实Flash要求某些信号在CS拉低后有建立时间你的RTL如果CS拉低瞬间就发数据仿真里假Flash可能无所谓但真芯片就可能丢命令。所以我写完Testbench后还会回头翻手册的时序参数表逐个确认建立保持时间余量。4.2 用野火DAP下载器下载并上板验证仿真通过后就可以上板了。野火征途Pro板卡配套的是野火DAP下载器连接好下载器和板卡的调试口在开发环境里点击下载bit文件等待进度条走完FPGA内部逻辑就跑起来了。征途Pro如果用得是紫光同创的FPGA对应开发平台是PDS如果手头板卡是其他厂商芯片注意使用对应的IDE步骤大同小异。上板验证的第一个目标不是写数据而是读ID。把读ID命令的结果抓到几个管脚上比如用LED显示低8位或者通过串口回传又或者用板上数码管轮询显示。我习惯用串口打印板卡调试信息量更大。野火板卡例程里一般有UART的现成工程把读到的3个ID字节拼进打印格式里即可。如果串口打印出EF 40 17或EF 40 18说明链路和状态机都对可以继续往下测试。第二个目标是做“擦、写、读回”闭环测试。选一个空闲的扇区地址先擦除再写入一串已知值最后读回来比对。这里要注意写数据时如果超过页边界事情就会变得复杂这点我会在第5节专门讲。测试代码建议Write、Read分别做成按键触发按一下key1擦除按一下key2写入按一下key3读回。每次操作前后用LED或串口打印状态能非常直观地看到Flash的生命周期。4.3 常见异常与排查思路上板遇到问题不要慌大多数SPI Flash异常都能归到下面这几类。我总结了一个排查表基本是按出现概率排序的现象可能原因检查方法读ID一直是0xFF 0xFF 0xFF引脚约束错误或MOSI/MISO接反对照原理图重新检查引脚用万用表测FPGA引脚到Flash引脚通断读ID前两个字节对第三个不对时钟频率过高或采样点偏了降低SPI时钟加长SCK高电平后再采样MISO擦除后读回全是0xFF符合预期但写不进去漏发0x06写使能或WEL位没置1检查命令序列CS是否在0x06后正确拉高写数据后读回乱序数据字节顺序问题或MOSI移位方向错误检查tx_shift移位方向确认MSB先发状态机卡死在S_WAIT不退出Flash处于意外状态WIP一直为1检查之前是否有非法命令尝试芯片复位命令0x660x99写入数据后读回是旧值目标地址有写保护BP位先读状态寄存器检查BP位必要时软件关闭保护有一种情况比较隐蔽多数Flash芯片上电后如果你的引脚悬空或上电时序不对芯片可能进入一种“半睡半醒”的异常状态。此时发送任何命令都无响应。解决方法是发一次软件复位序列CS拉低发0x66CS拉高再CS拉低发0x99CS拉高。这组命令会强制Flash复位到默认状态很多疑难杂症都能救回来。5. 容易翻车的四个细节复位、页边界、CS间隔与跨时钟采样主流程跑通之后驱动就算“能用”了。但距离“稳定能用”还差几个细节。这些细节是项目经验的沉淀教程里通常不会展开讲但恰恰是它们决定了你的代码在高温、上电瞬间、连续大量读写时会不会出幺蛾子。5.1 复位信号的处理与Flash初始状态FPGA的复位设计本身就有一堆坑比如直接用按键电平做异步复位容易引入亚稳态。更稳的惯例是做一个“异步复位、同步释放”的小模块把外部按键复位转换成干净的内部复位脉冲。这个在FPGA面试里也经常被问值得认真做一次。另一方面Flash上电后不一定处于完全闲置状态。如果它之前被设置过块保护BP位那么即使你发了0x06写使能擦除和写入也会失败。所以上电后建议先发0x05读一下状态寄存器检查WEL和BP位。如果BP位非零可以发0x01写状态寄存器把保护位清零。注意写状态寄存器命令的时序和擦除类似也要先0x06写使能而且部分型号需要连续写2字节。5.2 页编程的256字节边界W25Q的页编程命令一次最多写256字节这256字节是必须落在同一个“页”内的。页的划分很简单地址低8位就是页内偏移低8位归零的位置就是页边界。如果你从地址0x0100的第100字节开始写200字节那么第101字节已经跨越到0x0200那一页了芯片不会自动帮你跨页而是把低8位回绕直接覆盖当前页开头的数据。这是SPI Flash新手最容易踩的隐形坑。解决办法有两个层面。应用层直接限制每次写入前先计算剩余页空间拆成不超过页边界的多个写请求。驱动层加保护在状态机里检测写入长度和地址如果发现跨页就返回错误或自动拆分。学习阶段建议先做应用层拆分代码直观也更容易理解页的概念。等你后面要用SPI Flash存文件系统时会频繁和这个页边界打交道。5.3 命令之间的CS间隔与命令完整性前面提过CS拉高是命令结束的标志但还有一个细节容易被忽略两个命令之间的CS高电平持续时间不能太短。手册上有一个最小时间参数通常叫tSHSL或CS High Time。如果上一条命令CS拉高后立刻又拉低发下一条命令Flash可能没有足够时间完成内部状态切换导致命令被吞。实际代码里在CS拉高之后可以插入几个空时钟周期相当于“喘口气”这个余量在高速时钟下尤为重要。另外所有命令都要“发完整”。比如擦除命令0x20后面必须跟满3个字节地址少一个都不行。读数据命令0x03虽然可以连续读但如果你想读到第4个字节就必须在地址后持续给出SCK不能读两个字节就停住然后期待状态机正常。命令收尾的字节数、dummy字节数量都要严格参考手册的命令格式。5.4 MISO信号的跨时钟域采样很多初学者会直接把spi_miso接到采样寄存器里发现偶尔数据会错。原因是MISO由Flash在SCK下降沿之后更新而你的采样是在SCK上升沿附近或下降沿附近两者之间如果建立时间余量不足采样结果就会进入亚稳态。安全的做法有两种。第一种是在SCK的高电平中点附近、远离上升沿的位置采样。比如一个bit周期有8个系统时钟上升沿在第4个时钟处那么可以数到第6、7个时钟再去采样MISO。第二种是把MISO先打两拍同步到系统时钟域再送进移位寄存器。两种方式可以结合使用成本很低可靠性提升却很明显。实际工程里我倾向于把“MISO打两拍”作为标准动作写进所有SPI相关模块。尤其是时钟频率提高以后这根线的信号完整性问题会越来越突出提前打拍相当于给系统上了一道保险。最后说点个人体会。做完这个SPI Flash驱动实验之后你会对“协议”“时序”“状态机”这三个词有完全不同的认识。以后再碰I2C、UART、甚至以太网控制器方法论是完全相通的读手册、拆时序、搭状态机、仿真、上板、调问题。建议你做完标准SPI之后把W25Q的QSPI模式也试一下或者把Flash驱动挂到一个简单的FIFO接口上实现连续大块数据读写。这些扩展练习的收益比再抄十个点灯例程大得多。野火征途Pro这套板子外设资源很丰富SPI Flash只是其中一块敲门砖把它吃透了后面玩DDR、玩千兆网口、玩图像采集都会顺很多。