FPGA读写SPI Flash的Verilog工程实战:从状态机到时序约束 简介FPGA基于SRAM结构掉电后配置数据丢失因此需要外置SPI接口Nor Flash存储配置程序针对这一典型应用场景资源提供了完整的Verilog逻辑源码与Quartus工程适用于FPGA学习者、嵌入式开发者和硬件工程师帮助其掌握FPGA外部存储接口设计。压缩包内包含132个文件整体大小约5.04MB除15个Verilog源码文件外还有Quartus工程配置文件、PDF格式文档说明、readme文本以及多种编译生成报告方便对照学习完整工程结构。已有3048人学习下载说明该工程受到较多开发者关注具有较好的参考价值。工程内实现了SPI主设备控制器严格依据SPI Flash数据手册发出擦除、编程、读取等指令上电后自动读取Flash首字节并显示按键按下时当前数值加一后写回Flash完整覆盖了从硬件控制到数据验证的流程。配套文档说明对配置Flash特殊IO的复用过程、SPI时序设计要点以及Quartus工程组织方式做了讲解能帮助读者理清FPGA上电配置与用户逻辑的关系适合作为存储接口开发的入门参考。1. 一个ZIP里到底该有什么SPI FLASH读写工程的价值边界收到过这类FPGA学习资料的人多半是被一句话吸引一个能直接跑通的FPGA读写SPI FLASH的Verilog工程附带Quartus工程文件和文档说明。这个标题指向的东西其实很具体——用Verilog在FPGA内部实现一个SPI主控通过四条信号线CS、SCK、MOSI、MISO读写一颗SPI NOR Flash比如最常见的W25Q64。它能解决什么问题FPGA的配置芯片容量固定想存校准参数、版本信息或上电自检日志时外挂一颗SPI FLASH是最便宜的扩容方案而对刚入门FPGA的人来说这个工程又是理解状态机、移位寄存器和时序约束三者的完整样本。适合谁读手里有一块Cyclone IV开发板、想跑通第一个真实外设读写的人以及需要复用SPI主控逻辑的嵌入式转FPGA开发者。本文按为什么这样设计—代码怎么搭—Quartus里怎么落地—常见坑在哪—如何验收的顺序把整条路讲透。2. 把SPI FLASH的时序翻译成Verilog命令字、状态机与时钟约束2.1 从0x03命令读懂读操作的数据通路SPI NOR Flash虽然容量从512KB到128MB不等但命令集高度统一以W25Q64为例64Mbit8MB最常用的读命令是0x03。发完命令字节后紧跟24位地址之后SCK的每个时钟周期Flash在MISO上输出1位数据高位在前连续读时地址自动递增不发新命令就能把整片内容读完。这是最简单的读时序SPI模式固定为Mode 0CPOL0、CPHA0即SCK空闲为低、主机在上升沿发送地址和命令、在下降沿采样数据。这里有个新手容易想当然的点SPI的“读”和“写”在Verilog实现里不是两条独立的通路而是同一个移位寄存器在收发区别只在采样点安排在哪个沿。也就是说你不需要分别实现一个“读状态机”和一个“写状态机”只要一个主控状态机在命令、地址、数据三个阶段间切换即可。这个抽象一旦建立后续换Flash芯片、加命令字都只是改命令表不用动状态机的骨架。如果把0x03命令的时序图摊开看读一个字节的完整过程是这样的CS先拉低MOSI在SCK上升沿依次移出0x03和24位地址Flash从第25个SCK上升沿开始在MISO上逐个移出数据读完所需字节后拉高CS。在FPGA里实现这个过程常规做法是一个三段式状态机IDLE状态检测到start信号后跳转到发送命令状态命令和地址发送完成后进入接收状态。有人图省事用延时计数硬凑波形结果在时序约束上一改参数就翻车这种写法在工程里不可取。2.2 页编程与擦除写Flash为什么必须先写使能写操作比读操作多两道工序。第一道是写使能任何写、擦除操作之前必须先发0x06命令将Flash内部状态寄存器的WEL位bit 1置1否则Flash直接忽略后续的页编程命令。第二道是等待非忙写或擦除命令发出后Flash内部进入编程状态此时不接受新的写命令必须通过0x05读状态寄存器、轮询bit 0BUSY位直到它变成0才算本次操作真正完成。页编程命令是0x02一次最多编程256字节且这256字节必须在同一页内。W25Q64的页大小是256字节如果你的数据恰好跨页就得把命令拆成两条第一条写前半段第二条从下一页首地址继续。这个“跨页拆分”是新手最容易漏掉的逻辑常见做法是在顶层逻辑里先把待写长度和页边界算好再逐段发起页编程。血泪经验是不要试图在Flash控制器内部自动拆页把地址和长度暴露给上层让调用方决定怎么分段控制器的状态机会干净得多。至于擦除W25Q64支持0x20擦除4KB扇区、0x52擦除32KB块、0xD8擦除64KB块、0xC7擦除整片。工程里通常只实现扇区擦除就够了但注意扇区擦除的地址必须4KB对齐低12位为零。擦除比页编程慢得多一个扇区大约几十毫秒到一百毫秒如果业务上只需要覆盖写反而比擦除后重写更省时间——很多做参数存储的人最后都改成“直接写新地址不擦除旧扇区”的策略这是后话。2.3 时钟域与分频选择SPI时钟的约束边界SPI时钟的最小周期不是随便定的Flash数据手册里有tCH、tCL等参数工程上通常直接控制在10MHz以下留足余量。FPGA端如果系统时钟是50MHz直接用计数器分频到10MHz会得到50%占空比的SCK如果系统时钟是25MHz分频方法完全一样。这块有一个实际工程里经常遇到的约束细节SPI时钟由内部计数器生成属于内部逻辑时钟不是直接来自PLL或专用时钟引脚。在Quartus里这类时钟会被默认放到无约束时钟组时序分析默认不对它做严格检查表现出来就是——SPI控制器在时序仿真里一切正常可上板后连续读数据偶尔出个错位。常见做法是在SDC文件里把派生出来的SPI时钟用create_generated_clock约束出来同时给SPI接口的输出路径加set_output_delay让工具真正按你的频率去收敛路径。具体命令在第4章给出。3. 用Verilog搭一个可落地的SPI控制器模块划分与代码骨架3.1 顶层模块接口设计信号列表与注释规范第一步先定义模块端口。一个可直接复用的SPI控制器顶层接口通常长这样module spi_flash_ctrl #( parameter CLK_FREQ 50_000_000, // 系统时钟频率用于内部计数分频 parameter SPI_FREQ 10_000_000, // 目标SPI时钟频率 parameter ADDR_BITS 24 // 地址位宽W25Q64为24位 )( input wire clk, // 系统时钟 input wire rst_n, // 异步复位低有效 input wire start, // 启动一次传输高电平有效 input wire op_read, // 1读0写 input wire [7:0] cmd, // 命令字 input wire [23:0] addr, // 24位目标地址 input wire [7:0] wdata, // 待写数据 output reg [7:0] rdata, // 读回的数据 output reg busy, // 总线忙标志 output reg done, // 本次操作完成标志 // SPI物理接口 output reg spi_cs_n, // Flash片选低有效 output reg spi_clk, // SPI时钟 output reg spi_mosi, // 主出从入 input wire spi_miso // 主入从出 );参数设计有两个实际考量。CLK_FREQ和SPI_FREQ用parameter而不是硬编码是为了换开发板时不改逻辑只改参数——50MHz换成100MHz系统时钟时只需要改调用端实例化参数控制器内部的分频逻辑自己会算。cmd和addr分开定义会让调用方代码更直白尤其当你想在逻辑里复用同一个控制器去操作不同Flash类型时。端口里busy和done是两个独立标志看似冗余实际在业务逻辑里分工很明确busy用于电平信号外部握手逻辑拉低busy表示“当前不能发起新操作”done是脉冲用于触发下一拍逻辑。不少现成代码只留一个done上层逻辑就得自己计时等待麻烦。3.2 三段式状态机的骨架从IDLE到DONE控制器核心是一个三段式状态机状态包括IDLE、CMD、ADDR、WRITE、READ、WAIT、DONE。这里给出状态转移的简化框架localparam ST_IDLE 4d0; localparam ST_CMD 4d1; localparam ST_ADDR 4d2; localparam ST_WRITE 4d3; localparam ST_READ 4d4; localparam ST_WAIT 4d5; localparam ST_DONE 4d6; reg [3:0] state, next_state; // 第一段状态转移 always (posedge clk or negedge rst_n) begin if (!rst_n) state ST_IDLE; else state next_state; end // 第二段次态逻辑组合 always (*) begin next_state state; case (state) ST_IDLE: if (start) next_state ST_CMD; ST_CMD: if (bit_cnt 3d7) next_state ST_ADDR; ST_ADDR: if (bit_cnt 3d7) begin if (op_read) next_state ST_READ; else next_state ST_WRITE; end ST_WRITE: if (bit_cnt 3d7) next_state ST_WAIT; ST_READ: if (byte_cnt rd_len) next_state ST_DONE; ST_WAIT: if (flash_busy 1b0) next_state ST_DONE; ST_DONE: next_state ST_IDLE; endcase end注意三段式的关键点第二段是纯组合逻辑只描述“在什么条件下跳到哪个状态”不产生任何寄存器第三段才是时序输出负责把命令、地址、数据按SCK边沿移位出去。bit_cnt和byte_cnt是内部计数器分别跟踪当前阶段的第几位、以及整次传输的第几个字节。这里的flash_busy信号来自另一个模块——发起0x05命令读取状态寄存器后得到的bit 0值在WAIT状态下控制器会自动反复发0x05直到它归零。有一个经验把“命令阶段”和“数据阶段”分开计数比用一个总数硬算清晰得多。后面如果要插入Dummy周期例如快速读0x0B或跳过字节例如读状态寄存器只回读而不需要地址只需要在对应状态里多待几个周期状态机的骨架完全不用改。这个结构对后续扩展非常友好回调用的代码也基本不用动。3.3 移位寄存器与采样点安排上升沿发、下降沿收SPI主控最核心的部分是一个8位移位寄存器。发送路径上每个SCK上升沿把shift_reg左移一位最高位移到MOSI线上接收路径上每个SCK下降沿把MISO采样进shift_reg的最低位。这样一个寄存器同时完成收发8个SCK后得到读回的字节。// 发送与接收共用一个移位寄存器 always (posedge clk) begin if (spi_clk_rising) begin spi_mosi shift_reg[7]; // 上升沿前把最高位推到MOSI shift_reg shift_reg 1; // 左移一位为下一位做准备 end if (spi_clk_falling) begin shift_reg[0] spi_miso; // 下降沿采样MISO放进最低位 end end这里spi_clk_rising和spi_clk_falling是两个单周期脉冲由边沿检测逻辑产生。必须从头想清楚一个小细节SCK上升沿和下降沿之间间隔半个SPI时钟周期而系统时钟要比SPI时钟快几倍所以用系统时钟检测SPI时钟边沿在检测到上升沿的时钟周期安排发送动作、在下降沿的周期安排采样动作时间上是错开的。如果反过来写在上升沿采样MISO仿真时因为MISO在波形上变化和采样沿之间没有真实传播延迟往往看不出问题一上板MISO信号在边沿附近可能还在翻转采到临界电平数据就错位了。这就是SPI调试里最常见的“仿真正常、上板翻车”。SPI时钟的具体生成方式有两种。常见做法是用计数器按SPI_FREQ分频产生一个clk_en脉冲SCK在clk_en有效时翻转另一种是直接用系统时钟打一拍产生窄脉冲来模拟SCK。第一种生成的SCK占空比接近50%符合Flash数据手册对tCH/tCL的要求第二种虽然简单但SCK高电平时间可能只有一两个系统时钟周期高速Flash不一定认账。我一般用第一种代价是计数器逻辑多十几行换来的是时序干净、可分析。4. 在Quartus里把工程跑起来版本选型、引脚分配与SignalTap调时序4.1 Quartus版本选型与新建工程的三个关键步骤Quartus的版本跨度很大从13.1、18.0到20.x很多人第一个工程都在13.1或18.0上完成因为对Cyclone IVEP4CE10/EP4CE6支持最成熟、网络教程多老电脑也跑得动。如果你手里的板子是Cyclone IV优先选13.1或18.0如果是Cyclone V或更新器件就用20.x以上版本。版本选型在工程层面影响不大但会牵涉IP核授权文件、Device Family支持列表和SignalTap波形文件格式选定版本后整个工程最好固定下来不要中途升级。新建工程有三个关键步骤容易出错。第一步是Device选型和板卡一致比如黑金或正点原子的Cyclone IV开发板通常选EP4CE10F17C8选错型号后Pin Planner里的引脚名会完全不同。第二步是工程路径不要带中文和空格Windows下带中文路径会在编译时报一些诡异错误排查起来非常浪费时间。第三步是综合器和仿真器保持默认的Quartus Prime自带选项不要随手勾选第三方综合工具除非你已经配置好对应工具链。新建工程本身不复杂很多人卡在后续的引脚分配上。Quartus图形界面的Pin Planner里FPGA顶层端口要和板卡原理图里的网络名一一对应常见错误是把SPI_FLASH_CK写成SPI_FLASH_SCK一字母之差编译能过、上板不工作。解决思路很简单打开板卡的原理图PDF把Flash相关网络名复制粘贴进Pin Planner不要手敲。4.2 引脚分配与SDC约束的实操写法引脚分配有两种方式在Pin Planner界面里手选或直接编写QSF文件。工程自动化之后我一般用QSF因为可追查、可复用、可版本管理。以Cyclone IV开发板为例假设板子上FLASH的CS、SCK、MOSI、MISO分别连到FPGA的M1、M2、M3、M4脚示例引脚实际以你的原理图为准QSF里对应四行set_location_assignment PIN_M1 -to spi_cs_n set_location_assignment PIN_M2 -to spi_clk set_location_assignment PIN_M3 -to spi_mosi set_location_assignment PIN_M4 -to spi_miso引脚锁定后别忘了把弱上拉设置关掉。SPI信号线上Flash芯片外部已有上拉电阻再开FPGA内部上拉会拖慢信号边沿高速通信时容易出现误码。在Assignment Editor里把这四个引脚的Weak Pull-Up设为Off默认即可。SDC时序约束才是上板稳定性的分水岭。最基础的两条对系统时钟做create_clock约束对SPI派生时钟做create_generated_clock约束create_clock -period 20.000 -name sys_clk [get_ports clk] create_generated_clock -name spi_clk \ -source [get_pins u_spi_flash_ctrl/clk_div|clk] \ -divide_by 5 [get_pins u_spi_flash_ctrl/clk_div|spi_clk]create_generated_clock的源路径要指向综合后实际产生spi_clk的寄存器节点这需要打开Report Timing Closure窗口确认节点名我这里写的路径只是示意。如果完全不做这些约束Quartus编译时大概率不报错但时序分析栏会全是“unconstrained”——这就是后面上板异常的隐患来源。有经验的工程师会把约束文件当成工程的一部分写进版本库换机器、换板子时直接改引脚和时钟参数就行这算是一个脚手架习惯。4.3 用SignalTap II抓取SPI时序的配置方法SignalTap II是Quartus内置的逻辑分析仪是SPI调试中最常用的工具。配置步骤很短新建.stp文件把spi_cs_n、spi_clk、spi_mosi、spi_miso、state、done和rdata加进观察列表采样深度建议选16K触发条件设为spi_cs_n下降沿。采样时钟用系统时钟clk这样你能看到SCK每个边沿的相对位置。一个SignalTap使用细节SPI时钟10MHz、系统时钟50MHz时每个SPI数据位跨5个系统时钟周期波形完全可读如果SPI时钟提得比16MHz还高每个位只剩两三个采样点看起来会比较勉强这时建议把采样时钟改成PLL输出的200MHz或者把SignalTap的采样深度加大。波形毛刺多时先用“Transition”按钮看信号边沿的位置再对照状态机看看是哪一拍出了问题。SignalTap本身需要占用FPGA内部存储和JTAG带宽对时序有轻微影响频率越高波形失真越明显。如果对时序细节有疑虑最终验收还是要靠逻辑分析仪或示波器。这个取舍在一线开发中很常见SignalTap定位逻辑问题示波器定位电气问题两者不能互相替代。5. 避坑手册SPI FLASH读写最常见的5个翻车现场5.1 读ID读出来全是0xFF问题出在SPI模式选择现象上板后读ID寄存器串口打印的字节全是0xFF偶尔出现乱码。原因最常见的是SPI模式配置成了Mode 3CPOL1、CPHA1。W25Q64在Mode 0和Mode 3下都能工作但FPGA端如果设错了极性和相位主机在上升沿采样数据而Flash在下降沿切换输出采样点落在信号翻转的临界区采回来几乎全是1。另外一个隐蔽原因是MOSI和MISO引脚在QSF里被无意交换命令发出去了但数据路径反了。解决统一先按Mode 0CPOL0、CPHA0调通再考虑兼容Mode 3。检查SCK空闲电平是否为低、数据是否在上升沿发送、下降沿接收。如果ID仍是0xFF用示波器或SignalTap抓SCK、MOSI、MISO三个信号看MOSI上是否有正常的0x9F波形没有就查引脚分配有就看MISO有没有响应。5.2 页编程后回读不对大概率是漏了写使能现象发送完0x02页编程命令读回该地址的数据还是0xFF或旧值多次尝试都一样。原因W25Q64要求任何写、擦除操作之前必须先发0x06写使能命令把状态寄存器SR里的WEL位置1。如果Verilog状态机里漏掉这个状态或者把命令字记成其他值Flash会直接忽略后续编程操作不会报任何错误只是数据写不进去。解决在状态机里补一个专用的写使能状态发送0x06后等至少tSU时间数据手册约几微秒再发0x02。更稳妥的做法是写操作完成后主动用0x05读状态寄存器确认WEL位和BUSY位都回到正常值再向上层返回done信号。血泪经验告诉我永远不要在没查BUSY位的情况下就认为写完成了。5.3 扇区擦除之后读到残留数据地址对齐被忽视了现象执行一次0x20扇区擦除后回读扇区内某个地址仍然是旧数据而不是0xFF。原因扇区擦除命令0x20要求目标地址低12位必须是0也就是4KB对齐。如果程序里直接把这个扇区的任意地址发给Flash地址不满足对齐条件Flash会直接忽略这条命令不执行擦除。另一个可能原因是擦除前没有检查BUSY位上一笔写操作还没完成就发了擦除命令被Flash忽略。解决软件层在发起擦除前强制把地址低12位清零或者改用块擦除命令0x5232KB对齐或0xD864KB对齐把对齐要求放宽到32KB或64KB。时序上在每次操作之间补一次0x05状态寄存器轮询确认Flash退出busy后再发下一条命令。5.4 状态机仿真正常、上板间歇性错位注意内部生成时钟现象ModelSim里波形完美SignalTap里大部分数据正确但连续读大块数据时偶尔错一个字节错的位置没有规律。原因SPI时钟是内部生成的派生时钟如果没在SDC里给它做约束Quartus默认不按频率要求去收敛MISO的输入路径和MOSI的输出路径。芯片内部温度变化、电压波动都会让I/O路径延迟漂移采样点就越过临界区间歇性出错。解决按第4.2节的写法先把sys_clk和spi_clk约束好再把MISO的input delay和MOSI的output delay按Flash数据手册的时序参数填进SDC。重新编译后打开Report Timing Closure确认SPI时钟域下没有红色FAIL项。如果还有问题把SPI时钟频率从10MHz降到5MHz再试排除法判断频率余量是否够。5.5 引脚冲突与复位毛刺两个隐蔽到最后一刻才暴露的问题现象下载sof后板子外设全不响应或者按下复位键后状态机偶尔跑飞、读操作随机失败。原因引脚冲突通常是顶层端口名和板卡实际网络名对不上或者占用了与JTAG复用的引脚复位毛刺则是异步复位没有做同步处理上电瞬间或按键复位的抖动直接灌进状态机导致状态寄存器进入未定义状态。解决引脚分配逐一对照原理图核对避免使用与JTAG复用的引脚复位信号在进状态机之前先做两级同步reg [1:0] rst_sync; always (posedge clk) begin rst_sync[0] rst_n; rst_sync[1] rst_sync[0]; end wire rst_n_sync rst_sync[1];内部所有状态机统一使用rst_n_sync不要拿外部rst_n直接接异步复位端口。如果复位按键本身带抖动再在按键输入后面加一个简单的消抖计数器十毫秒级别的延时就能滤掉绝大多数机械抖动。6. 从读ID到回读校验上板验收的冒烟测试顺序SPI FLASH控制器调通的标准不是“编译通过”而是三步冒烟测试全过。第一步读ID发送0x9F命令W25Q64会返回三字节的JEDEC ID0xEF 0x40 0x17。这一步能同时证明物理链路、引脚分配、SPI极性和收发通路都是对的——如果读出来是0xFF说明方向、极性和采样沿至少有一个错了先别往下走回头查第5章第1条。第二步擦除验证发送0x20扇区擦除等BUSY位归零后读回整个扇区确认全是0xFF这一步验证擦除命令和状态轮询这两条路径是否真的闭合。第三步页编程回读校验在一个干净扇区的首地址写入一串特征值例如0x00到0xFF递增序列再立即读回并逐字节比较确认写入链路和读回链路都工作。我在调试时对这三步有个固定习惯在顶层逻辑里加一个“只读ID”的简化分支用拨码开关直接触发启动后把读到的三字节ID显示在数码管或板上LED上。这样不用连串口也能确认链路通尤其适合手头没有逻辑分析仪的阶段。ID对了后面的页编程回读大概率一次能过ID不对先检查SPI模式、引脚分配、SDC约束这三项逐项排除后再重新编译。仿真里也别跳过时序检查。值得养成的一个习惯是在testbench里给MISO加上延迟驱动模拟Flash真实的输出延迟而不是做零延迟的连续赋值。零延时的MISO会让“采样沿选错”这类问题在仿真里完全复现不出来等到上板才暴露。// 在testbench里给MISO通路人为加入30ns延迟 wire flash_out; // Flash行为模型的原始输出 reg spi_miso_model; // 延迟后的MISO信号 always (posedge spi_clk) spi_miso_model #30 flash_out; // #30让MISO晚到30ns assign spi_miso spi_miso_model;让仿真环境更接近真实硬件之后很多采样沿问题在电脑上就能现形。我也是在某个深夜把读ID的冒烟测试代码删掉、直接去写业务逻辑结果花了一晚上排查一个本该在ID阶段就暴露的SCK极性错误之后才彻底养成“先读ID、再擦除、最后页编程”这个顺序。这套顺序放在所有SPI FLASH工程里都成立也希望帮到你——先证明最小链路是通的再往上叠业务逻辑方向不对时回头也快。本文还有配套的精品资源点击获取