FPGA驱动SPI Flash实战:从W25Q64时序到Verilog状态机设计
1. 拿到征途Pro后我为什么先拿SPI Flash开刀很多朋友第一次接触野火FPGA征途Pro开发板第一件事就是点灯、跑流水灯、驱动一下数码管。这些当然没问题但等你真正接触到实际项目的时候就会发现FPGA上电以后配置数据从哪里来、程序怎么固化、系统怎么跟外部存储打交道这些全都绕不开一个基本功——SPI Flash驱动。征途Pro板卡上板载了一颗SPI Nor Flash芯片通常是W25Q系列用来存储FPGA的配置文件以及用户数据。说白了它就是FPGA的“硬盘”断电以后程序不丢上电自动加载。你可能平时不太注意它但它一直在默默干活。如果哪天你发现板子上电后没有启动你的逻辑大概率就是这颗Flash里的配置数据出了问题。这篇博文我就拿征途Pro上的SPI Flash驱动做例子从头到尾把整个流程捋一遍从SPI总线协议的基础概念到W25Q系列芯片的指令集再到Verilog代码逐模块拆解最后把仿真、板级调试、常见坑全部交代清楚。目标很直接——让看过这篇文章的人能在自己板子上独立写出一套能用的SPI Flash读写驱动而不是只会跑厂商现成的IP核。这里说明一下我手里的这块征途Pro使用的是Xilinx Artix-7架构的FPGA开发环境是Vivado板载Flash芯片是W25Q64。如果你的板子芯片不一样比如用的Intel Cyclone系列又或者Flash是W25Q128、W25Q16都没关系驱动思路和代码框架完全通用改一下指令参数和时钟分频系数就能移植过去。2. 开工前的环境准备开发工具、仿真环境和硬件连接2.1 开发环境与调试器配置在写代码之前先把环境搭好不然代码写出来也没法验证。我用的是Vivado 2019.1版本这块板子官方也推荐这个版本因为工程模板和约束文件都是按照这个环境给的。如果你用的是更新的Vivado比如2021.2、2022.1也完全可以只要在创建工程的时候选择对应的器件型号就行。注意一点不同版本的Vivado对纯净工程的约束文件语法略有差异老工程升级到新版本时经常报一些引脚约束的警告这个不是大问题后面在时序约束部分我会细说。调试器方面野火征途Pro板载了一路USB调试器插上USB线后系统会枚举出一个串口和一个调试器接口。我第一次插上去的时候设备管理器里显示的是正常的COM口和调试器设备说明驱动已经自动装好了。如果你遇到设备管理器里出现黄色感叹号或者完全找不到设备那就需要手动安装一下USB转串口驱动常见的是CH340或者CP2102的驱动安装完重启电脑基本能解决。2.2 确认Flash硬件连接驱动写之前先确认硬件上Flash到底连在FPGA的哪些引脚上。这一步很多人容易忽略上来就打开原理图找SPI信号结果把引脚认错了驱动写好了也白搭。在征途Pro的原理图上SPI Flash挂在FPGA的专用配置引脚上信号名方向作用SPI_CLK输出时钟信号FPGA产生SPI_MOSI输出FPGA发送数据给FlashSPI_MISO输入Flash返回数据给FPGASPI_CS输出片选信号低有效征途Pro这块板子的Flash引脚已经固定分配到了特定Bank的引脚上在XDC约束文件里会明确写出物理引脚号。比如SPI_CLK可能分配在某个Bank的IO_L14P_T2_SRCC_引脚上具体数值以板卡附带的约束文件为准。这一步不复杂但非常关键因为有些开发板把Flash信号和普通IO复用了如果你用错了引脚读出来的ID永远是0xFF。2.3 创建Vivado工程和仿真环境在Vivado里新建一个RTL工程器件型号选择Artix-7对应的具体型号然后添加顶层文件和约束文件。我的习惯是把整个驱动拆成三个模块来写一个SPI物理时序生成模块一个Flash控制状态机模块再加一个顶层例化文件。仿真方面用Vivado自带的XSim就够用不需要额外安装ModelSim除非你要做复杂的时序仿真。工程建好以后先不要急着写Flash控制逻辑先做一个最简单的测试把FPGA的时钟分频后直接引到SPI_CLK引脚然后给Flash发一个读ID命令看能不能读回0xEF。这一步能快速验证硬件链路通不通、引脚约束对不对。如果读ID都失败后面的读写操作根本没戏。3. SPI Flash协议解析从时序图到Verilog状态机3.1 SPI总线基础扫盲SPI是串行外设接口主从结构一共四根线时钟线SCLK、主机输出从机输入MOSI、主机输入从机输出MISO、片选CS。它和I2C最大的区别是SPI是全双工时钟由主机产生速率可以很高I2C是半双工速率相对较低。对于FPGA来说SPI驱动比I2C更简单因为不用处理时钟同步和应答信号。W25Q64这颗Flash支持SPI Mode 0和Mode 3我这次用的是Mode 0也就是CPOL0CPHA0。说人话就是时钟空闲状态是低电平数据在时钟的上升沿被采样下降沿输出。这个参数在写代码的时候会用到一定要记清楚不然数据就是错位的。3.2 W25Q64的指令集和状态寄存器W25Q64是Winbond公司出的一块64Mbit容量的SPI Nor Flash换算过来就是8MB。指令系统比较规整常用的就几个读ID0x90、写使能0x06、读状态寄存器0x05、页编程0x02、读数据0x03、扇区擦除0x20。每个指令的时序格式在芯片手册里都画得很清楚。读数据指令先发0x03再发24位地址然后持续发时钟脉冲Flash就会从对应地址把数据一个字节一个字节地吐出来。这个指令的时序最简单可以连续读任意长度不需要一页一页地限制。页编程指令先发0x02再发24位地址然后紧跟待写入的数据注意一次最多写256字节因为Flash的页大小就是256字节。如果你写的数据跨页了会发生什么数据会回卷到当前页的开头把你刚写好的数据覆盖掉。这个坑我踩过后面排查老半天。扇区擦除指令先发0x20再发24位地址。一个扇区是4KB擦除后整片变回0xFF。注意Flash写操作只能在“1”变成“0”的方向上进行所以要先把目标区域擦除成全1才能写入新数据。状态寄存器最低位是BUSY位擦除和编程期间这一位为1操作完成后变0。写代码的时候发完擦除或编程指令要轮询这个位直到变成0才能进行下一步操作。3.3 驱动架构设计状态机怎么切分驱动SPI Flash说难不难说简单也不简单核心在于状态机的设计。我见过很多新手喜欢在顶层文件里写一个巨大的状态机把SPI发送、接收、Flash指令、轮询全部塞到一起最后代码变得又臭又长排查问题的时候无从下手。我自己的习惯是把驱动拆成两层底层是SPI主机模块专门负责收发bit数据。它对外提供一个简单的接口一个start信号、一个8位数据输入、一个8位数据输出每次调用时序模块就能完成一个字节的发送和接收。时钟分频也放在这一层。上层是Flash控制状态机负责组织指令、地址、数据的发送顺序以及处理状态寄存器的轮询和读回数据的组装。这种分层的好处很明显底层模块可以被复用不管是驱动Flash、ADC还是屏幕都可以直接拿来用上层模块逻辑清晰一条指令就是一个子状态调试的时候只要看当前在哪个状态就能快速定位问题。4. Verilog代码实现SPI主机模块和Flash控制器全解析4.1 顶层模块与引脚约束顶层模块有两个任务例化子模块以及处理时钟和复位。板上主时钟一般是50MHz或100MHzFPGA内部逻辑工作频率可以跑得更高但SPI_Flas接口频率一般控制在5到20MHz之间就够了毕竟Flash这种Nor型存储器的最高读时钟也就100MHz左右低速访问反而更稳定。直接在顶层做时钟分频把系统时钟分频得到SCLK。分频系数可以通过参数配置比如50MHz的时钟要得到10MHz的SCLK分频系数就是5。注意SCLK在半周期时翻转所以实际上用一个计数器计数到分频系数的一半时翻转时钟信号。这种方法生成的时钟在时序上不会太标准但驱动Flash完全够用。在XDC约束文件里只需绑定以下引脚set_property PACKAGE_PIN R4 [get_ports spi_clk] set_property IOSTANDARD LVCMOS33 [get_ports spi_clk] set_property PACKAGE_PIN T4 [get_ports spi_mosi] set_property IOSTANDARD LVCMOS33 [get_ports spi_mosi] set_property PACKAGE_PIN U4 [get_ports spi_miso] set_property IOSTANDARD LVCMOS33 [get_ports spi_miso] set_property PACKAGE_PIN V4 [get_ports spi_cs] set_property IOSTANDARD LVCMOS33 [get_ports spi_cs]实际的引脚编号以野火官方约束文件为准我这里只是演示写法。IOSTANDARD设为LVCMOS33因为Flash供电是3.3V。如果FPGA引脚在1.8V Bank下记得改成LVCMOS18电平不匹配会导致数据采不到。4.2 SPI主机时序模块的实现底层SPI时序模块用有限状态机实现。这里的关键是在Mode 0模式下每个字节由8个bit组成每个bit传输跨度是一个完整时钟周期。在第1个边沿主机把数据放到MOSI上在第2个边沿主机从MISO上采样数据。我习惯用一个状态寄存器记录当前传输到第几个bit两个计数器分别控制半周期延时。这里给出简化后的核心代码module spi_master #( parameter DIVIDER 5 // 50MHz - 10MHz )( input wire clk, input wire rst_n, input wire start, input wire [7:0] din, output reg [7:0] dout, output reg busy, output reg sclk, output reg mosi, input wire miso, output reg cs_n ); localparam IDLE 2d0; localparam TX 2d1; localparam DONE 2d2; reg [1:0] state; reg [3:0] bit_cnt; reg [4:0] clk_cnt; reg half_toggle; reg [7:0] shift_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; sclk 1b0; cs_n 1b1; busy 1b0; bit_cnt 4d0; clk_cnt 5d0; end else begin case (state) IDLE: begin if (start) begin cs_n 1b0; busy 1b1; shift_reg din; bit_cnt 4d0; clk_cnt 5d0; state TX; end end TX: begin if (clk_cnt DIVIDER/2 - 1) begin clk_cnt clk_cnt 1b1; end else begin clk_cnt 5d0; half_toggle ~half_toggle; if (!half_toggle) begin // 前半周期: 输出数据 mosi shift_reg[7]; sclk 1b1; end else begin // 后半周期: 采样输入 shift_reg {shift_reg[6:0], miso}; sclk 1b0; if (bit_cnt 4d7) begin state DONE; dout {shift_reg[6:0], miso}; end else begin bit_cnt bit_cnt 1b1; end end end end DONE: begin cs_n 1b1; busy 1b0; state IDLE; end endcase end end endmodule注意看时序细节在下降沿时采样数据读到的是前一个bit周期打过来的数据。我在采样完成后把数据拼接到shift_reg里并在最后一拍把完整字节输出到dout。这个模块每次调用只处理一个字节输入输出都是8位并行。主机侧的MISO在空闲时是高阻态所以读取的时候要注意上拉或者采样前先等待半个时钟周期让数据稳定。4.3 Flash控制器状态机擦除与写操作写Flash和读Flash逻辑差异比较大所以我拆成两个状态机来写一个是写控制状态机一个是读控制状态机。两个状态机通过一组寄存器交换数据这样设计的好处是消除耦合调试的时候可以分别验证。写操作的整体流程是发送写使能指令0x06等Flash把状态寄存器里的WEL位置1。发送扇区擦除指令0x20连同24位地址然后不停轮询状态寄存器直到BUSY位变0。再次发送写使能指令。发送页编程指令0x02连同24位地址再连续发送最多256字节的数据。轮询BUSY位直到写操作完成。localparam WRITE_ENABLE 8h06; localparam SECTOR_ERASE 8h20; localparam PAGE_PROGRAM 8h02; localparam READ_STATUS 8h05; localparam CHIP_ERASE 8hC7; localparam S_IDLE 4d0; localparam S_WREN 4d1; localparam S_WREN_WAIT 4d2; localparam S_ERASE_CMD 4d3; localparam S_ERASE_ADDR1 4d4; localparam S_ERASE_ADDR2 4d5; localparam S_ERASE_ADDR3 4d6; localparam S_ERASE_POLL 4d7; localparam S_PROG_CMD 4d8; localparam S_PROG_ADDR1 4d9; localparam S_PROG_ADDR2 4d10; localparam S_PROG_ADDR3 4d11; localparam S_PROG_DATA 4d12; localparam S_PROG_POLL 4d13; localparam S_DONE 4d14;这里有几个坑必须提醒你发送完写使能指令后要等待一小段时间让Flash内部完成WEL标志位的置位。不能立刻发扇区擦除指令否则会按“非法操作”被丢弃。我实测下来等待几个微妙就够了。地址是高位在前24位分别由三个字节组成发送顺序是地址[23:16]、地址[15:8]、地址[7:0]。擦除一个4KB扇区大约需要几十毫秒甚至上百毫秒这期间芯片不会响应其他非状态寄存器指令。页编程数据发送阶段用SPI主模块逐一发送数据字节。数据发送完以后必须等BUSY位为0才算完成。如果BSY位一直是1那多半是写入的数据超过了一页边界芯片内部发生了地址回卷。排查方法很简单每次页编程前检查当前页内偏移加上数据长度是否超过256字节超过就拆成两页写。4.4 Flash控制器读操作连续读取与ID识别读操作相对简单但有个细节值得注意读数据指令0x03在发出地址后主机要空闲发送0xFF来作为时钟来源因为Flash是被动器件它不会自己产生时钟所有数据输出都需要主机提供时钟脉冲。读操作状态机切分如下发送0x03指令。发送24位地址。持续发送空字节每发一个空字节就收到一个字节的Flash数据。读够指定长度后停止发时钟拉高片选。localparam READ_DATA 8h03; localparam S_IDLE 3d0; localparam S_CMD 3d1; localparam S_ADDR1 3d2; localparam S_ADDR2 3d3; localparam S_ADDR3 3d4; localparam S_READ 3d5; localparam S_STOP 3d6;读ID是另一个常用功能发送0x90后跟24位地址通常用0x000000然后发一个空字节得到Manufacturer ID0xEF代表Winbond再发一个空字节得到Device ID。通过ID可以区分W25Q64、W25Q128这个在上电自检、固件校验的时候非常有用。读操作里有一个容易被忽略的问题读数据时如果地址跨越了页边界Flash内部会自动处理读操作不像写操作那样有回卷限制。所以读操作可以一直连续读下去不需要拆页。这个特性在实现固件升级的时候特别方便直接把整块文件从首地址一直读到末尾。4.5 仿真Testbench怎么写写好RTL代码后一定要仿真。很多人急着上板结果出了问题又不知道怎么排查。我的习惯是仿真通过以后才上板调试。Testbench的写法比较简单例化顶层模块把SPI接口连到Flash的仿真模型上然后给激励信号。关键是Flash模型要选对。野火官方提供的仿真模型可以从Winbond官网下载或者直接用Vivado自带的SPI Flash模型。如果找不到官方模型可以自己写一个简化的行为模型只实现读ID、读、写、擦除这几个关键指令足够验证逻辑。仿真流程大致是上电复位等待稳定。发起一次读ID操作检查返回的ID是不是0xEF。发起一次扇区擦除等BUSY位结束。发起页编程写入一批数据。进行读操作把读回来的数据和写入的数据比对。仿真波形里面重点观察SPI_CLK的频率是否符合预期MOSI上的数据时序有没有毛刺CS信号有没有正确地拉低拉高。我自己碰到过一个问题状态机从发送地址跳转到发送数据时CS信号出现了半个周期的高电平毛刺导致Flash把指令序列切断了。这个问题在仿真里很难看出来只会在板级调试的时候以极其诡异的方式出现。原因是我在状态切换的时候没有等SPI主模块完全输出完当前字节就启动了下一个字节的传输。解决办法是加一个done信号握手确保上一个字节发送完再开始下一个。5. 板级调试在线逻辑分析仪和实测时序分析5.1 用ILA抓内部信号代码综合、布局布线都通过以后就可以上板了。这里我强烈建议使用在线逻辑分析仪ILA来抓信号而不是靠眼睛看LED判断状态。Vivado里的ILA IP核使用起来很简单在IP Catalog里搜索ILA配置好要观察的信号和采样深度然后例化到工程里。注意ILA会占用额外的逻辑资源调试完记得删掉再出正式固件。我调试SPI Flash驱动的时候抓取了以下几个信号状态机的当前状态、SPI主模块的busy信号、cs_n、sclk、mosi、miso。触发条件设置为状态机进入读ID状态时触发采样深度设成1024这样能完整记录整个读ID过程。观察波形的时候重点看MISO在时钟上升沿附近是不是稳定的有没有时序违例。如果MISO在上升沿采样的瞬间刚好在跳变那就要注意了很可能是时钟太快了导致数据建立时间不够。5.2 实测调整分频系数和数据稳定我最初把SPI时钟设成了20MHz结果发现读回的数据偶尔有错位——多读了一个字节或者读回的值是0x00。排查后发现MISO引脚上的信号跳变时间比较长可能是板级走线的寄生电容太大。我把分频系数调大了一倍SPI时钟降到10MHz问题就消失了。这里有个经验驱动Flash这类外部器件时钟速率不是越高越好宁可慢一点也要稳定。W25Q64的极限读时钟是100MHz以上但那是理想情况下的极限值。板级设计不好、走线过长、电源噪声大都会限制实际可用的最高频率。10MHz到20MHz是性价比最高的区间既快又稳。另外在上电初始化的过程中FPGA自身的配置流程会占用Flash的一部分时间。如果你在配置完成后的极短时间内立刻访问Flash可能会遇到Flash还在忙的情况。解决办法是在初始化状态机里加一个固定延时比如延时10毫秒等一切稳定了再开始操作。5.3 ChipScope已过时Vivado里怎么办用Vivado的新用户可能不知道老版本ISE里有ChipScopeVivado里被ILA取代了。用法类似但有些细节不同。在Vivado里创建ILA有两种方式一种是在IP Catalog里配置ILA IP核手动例化到代码里另一种是在综合后打开综合设计右键选择要探测的网络自动插入debug核。手动例化的方式更可控推荐在写代码的时候就规划好调试点。自动插入的方式适用于不想动代码的情况但要重新布局布线耗时更长。6. 常见问题排查与避坑经验上板调试遇到的典型故障6.1 下载器识别不到或程序烧不进去的排查先检查USB线是不是可以传数据的线。很多USB线只能充电插上去设备管理器里一点反应都没有。然后打开设备管理器看看有没有带感叹号的设备。没有的话可能是USB口供电不足换个接口试试。如果设备管理器里能看到串口但下载工具识别不到大概率是驱动冲突把老的驱动卸载干净再装一遍。有一个比较容易混淆的地方野火板卡的调试器和串口是共用USB引脚的插上USB线后会枚举出两个设备。有的下载工具会占用串口导致串口助手打不开。正确做法是先关闭串口助手再连接下载器或者反过来不要两个工具同时占用同一个端口。6.2 读ID返回0xFF或者0x00这个问题出现的概率最高。先说清原因读回0xFF说明Flash没有响应大概率是片选信号没拉低或者SPI时钟完全没送到Flash。读回0x00通常是数据在传输过程中全部变成了0可能是时钟相位配置错误或者MOSI/MISO接反了。我排查这个问题的顺序是用万用表量SPI_CS引脚的电平确认FPGA配置完成后这个引脚是高的只有发起访问时才拉低。用示波器看SPI_CLK有没有波形输出频率对不对。把SPI主模块的读写逻辑在仿真里单独跑一遍确认时序正确。检查约束文件里引脚位置是否和原理图一致。大部分情况下问题出在引脚约束上。我见过有人把SPI_CLK和SPI_MOSI调换了引脚代码里看逻辑没毛病但实际传输的数据全部错位。6.3 写操作不成功读出来的全是0xFF写完以后读出来全是0xFF大概率是擦除没有生效。常见原因有两个一是没有等BUSY位变成0就急着去写数据二是写使能没有生效。W25Q系列在每次写操作之前都必须发一次0x06写使能但很多新手在状态机里跳过了这一步或者只在复位时发了一次。另外芯片内部有状态寄存器的保护位BP0、BP1、BP2、BP3默认状态是可以全片操作的。如果你之前配置过这些保护位可能把需要写的区域锁住了。解决办法发指令0x01配合0x00来把状态寄存器里的BP位全部清零解除写保护。这个操作在买回来的新芯片上一般不需要做但用过的旧芯片一定要检查。6.4 程序固化到Flash后上电不运行SPI Flash另一个重要作用是存放FPGA配置文件。Vivado里配置好bitstream后要生成一个bin文件固化到Flash中。操作流程是点击生成存储器配置文件选择bin格式然后在硬件管理器里右键FPGA器件选择配置存储器。这一步经常被忘记的是需要同时勾选擦除整片、编程、验证三个选项否则可能出现程序只写了一半上电后配置失败板卡指示灯不亮。如果配置成功后依然不运行检查一下板卡的配置模式跳线。Artix-7支持多种配置模式SPI模式、BPI模式、JTAG模式等。如果跳线设置成了JTAG模式上电后FPGA不会主动从SPI Flash加载。征途Pro板卡上一般有专门的拨码开关或者跳线帽默认应该是SPI模式。7. 驱动代码的后续扩展从基础读写到实用应用SPI Flash的基本读写驱动搞明白之后能做的事情就很多了。最简单的一个扩展是EEPROM替代方案。FPGA调试过程中经常需要保存配置参数比如PID系数、阈值、校准值。这些数据量不大用EEPROM有点浪费直接用SPI Flash的一个扇区就能搞定。我的做法是分配一个固定地址区域给参数存储上电的时候读出来运行中如果参数被修改就发写使能并擦除对应扇区再写新数据。注意不要让参数存储区域和配置文件区域重叠否则固件升级的时候参数就丢了。另一个实用方向是固件远程升级。通过串口或网口把新固件先存到SPI Flash的某个扇区然后触发FPGA重配置。Artix-7支持Multiboot功能可以指定从Flash的多个地址加载不同版本的程序。这个功能在设备维护场景下特别有用不用每次都去现场烧录。如果你手头的项目需要存储大量的日志数据SPI Flash的写入速度可能不够。常见的情况是一边大量写入数据一边还要保证数据不丢这时候你需要把待写数据放进一个FIFO缓冲由驱动状态机在后台上不停地页编程、轮询BUSY。这里就又回到我们前面说的状态机设计总体上就是加一个FIFO接口把数据流和写指令流合并到一起。8. 一点个人体会SPI Flash的驱动代码量不大但我前前后后踩了不少坑每次重新打开原理图确认引脚、每次对着波形图数数据位最后发现其实都是自己的状态机时序不对或者分频系数没算准。想做这种底层驱动的FPGA工程师最重要的能力不是背代码而是会看时序图、会分析波形。把W25Q64的手册翻熟把SPI Mode 0的时序刻在脑子里比背一堆代码有用得多。这里分享一个我自己调试时的小习惯每次改完代码上板前先在纸上把状态机画一遍每个状态跳转边上标注清楚操作的时序确认没有遗漏后才动手改代码。看起来多花了几分钟实际上省下了反复上板、反复抓波形的时间。如果哪天你也发现自己的Flash读写偶尔不稳定建议先试试把SPI时钟降下来让信号走线的毛刺不再是问题。