
上个月调一块带SPI NOR Flash的板子遇到一个很有意思的重复劳动项目里需要把一批校准参数掉电保存本想着用FPGA厂家自带的SPI控制器IP结果一查许可证、版本、外设配置折腾半天还不如自己写个驱动来得痛快。于是我又把N25Q128的Datasheet翻出来从头过了一遍把读ID、写使能、页编程、读数据、查状态寄存器这些基本操作全部做成一个可复用的Verilog状态机模块。这篇文章就把这条完整的路走一遍怎么从Datasheet里提取关键时序怎么设计状态机框架怎么把命令通道做得通用以及我实测中踩过哪些坑。适合已经写过一点Verilog、但第一次在FPGA上真正驱动SPI Flash的朋友。1. 读懂DatasheetN25Q128的核心时序与命令集很多新手拿到Datasheet就头大几百页全是表格。别怕真正要精读的其实就三四页。N25Q128是Micron原Numonyx的128Mb SPI NOR Flash换算过来就是16MB存储空间页大小256字节一个扇区通常是64KB支持标准SPI、双SPI、四SPI最高时钟能达到108MHz。驱动工作第一步不是写代码而是把这几件事确认清楚供电电压、封装引脚、命令编码、时序参数。1.1 先看引脚和接法别让原理图坑了你N25Q128常见封装有SO-8、SO-16、BGA等FPGA项目里最常用的是SO-8兼容引脚核心信号就六个CS_N片选、CLK时钟、DQ0MOSI数据输入、DQ1MISO数据输出、VCC和GND。有些封装还会引出WP_N写保护、HOLD_N保持和DQ2、DQ3如果你只用标准SPI模式这两个引脚可以按Datasheet建议上拉到高电平。这里有一个容易忽略的点N25Q128有1.8V和3V两种版本丝印后缀不同供电电压也不同。我曾经拿3V的片子接到1.8V的FPGA IO上读ID读出来全是一堆乱码查了半小时才发现是电平不匹配。原理图阶段就确认好电压域能省掉后面整个调试流程的痛苦。1.2 从几百页Datasheet里提炼驱动必需的命令SPI NOR Flash的命令集其实非常收敛N25Q128常用的基础命令就十几个。我把驱动里真正会用到的列成了一张表建议你打印出来贴在显示器边上命令编码地址字节数说明READ_ID0x9F0读3字节JEDEC IDREAD_STATUS_REG0x050读状态寄存器bit0是WIPWRITE_ENABLE0x060写使能编程/擦除前必须发WRITE_DISABLE0x040写禁止READ_DATA0x033普通读数据任意地址开始PAGE_PROGRAM0x023页编程最大256字节SECTOR_ERASE0xD83擦除64KB扇区SUBSECTOR_ERASE0x203擦除4KB子扇区BULK_ERASE0xC70整片擦除你可能注意到N25Q128命令和Winbond的W25Q128几乎一模一样这个兼容性不是巧合而是NOR Flash行业多年沉淀出的默契。这意味着你今天在N25Q128上调通的模块明天换一颗W25Q128往往只需要改一下器件ID检查逻辑命令通道完全复用。1.3 时序最重要的一件事时钟极性和采样沿SPI有两种时钟极性和相位组合N25Q128支持Mode 0和Mode 3。Mode 0是时钟空闲为低、数据在上升沿被主机锁存Mode 3是时钟空闲为高、数据在上升沿锁存。FPGA的SPI主机侧其实没有绝对的对错只要和从机约定一致就行。我在设计里固定用Mode 0SCLK空闲为低在每个SCLK上升沿发出一个MOSI位同时锁存MISO位。这么做的好处是在FPGA里实现最直观用一个分频计数器生成SCLK再配合位计数器的上升沿去移位寄存器逻辑非常干净。官方Datasheet手册里的时序图很多重点看“CLK正在上升沿采样数据”的图并把tsu、thd这些参数转换成约束或者仿真激励后面仿真阶段能派上大用场。2. 状态机驱动怎么设计一条命令执行的完整视角驱动SPI Flash核心就是把“发命令、发地址、读写数据、等待完成”这一整套流程抽象成状态机。有人会问读ID、读状态、页编程、读数据这些命令格式都不一样是不是每个命令写一个状态机我强烈不建议这么做。原因是状态机一旦多起来代码的可维护性会急剧下降改一个时序细节要同步改N个模块。正确做法是设计一套“命令通道”用参数配置命令类型状态机只负责按顺序把命令字节、地址字节、数据字节从SPI总线上送出去或者收回来。2.1 状态划分从一条读写时序看“共性”不管什么命令在SPI总线上的行为都可以归纳成“命令阶段地址阶段数据阶段”。比如READ_DATA是先发一个0x03再发三个字节的地址然后连续读取数据PAGE_PROGRAM是先发0x02再发三个字节地址然后连续送出要写入的数据WRITE_ENABLE则啥地址都没有只发一个命令字节就结束。基于这个共性状态机可以划成五个核心状态IDLE空闲、CMD发命令字节、ADDR发三个地址字节、WR_DATA写数据、RD_DATA读数据。对于写类命令在数据阶段结束之后还需要额外进入一个“等待器件内部操作完成”的状态这个状态本质上是反复读状态寄存器、检查WIP位。我把等待状态单独拎出来是因为它和前面的命令通道逻辑完全不一样前面的通道是主动发送等待状态是被动轮询。2.2 命令通道架构状态机统一跑命令流水线状态机本身不关心当前发的是哪个命令它只按照外部配置好的“命令类型”和“数据长度”来工作。这个架构可以用一个简单的Host接口来驱动外部逻辑。举个例子我设计时给上层提供的输入信号是这样的cmd_start拉高一拍表示启动一次Flash操作cmd_op操作码比如读ID、读数据、写数据、擦除addr_in24位目标地址读数据和写数据时用wr_data写入数据总线的字节rd_data状态机读回的字节busy状态机忙碌标志done操作完成标志拉高一拍状态机内部的角色很清楚它不决定要做“读地址0x000000的256字节”这种高层逻辑它只负责把命令字节、3字节地址、若干数据字节按顺序搬上SPI总线。高层逻辑是更上层的用户模块通过控制cmd_op和cmd_start来发指令。这样做有一个非常大的好处四个基础命令共享同一套发送逻辑以后想加一个Quad Output Fast Read命令只需要扩展cmd_op的编码在状态机里多一个分支不会影响到其它命令。2.3 三段式状态机与“表驱动”思路写状态机我一向推荐三段式第一段是状态跳转逻辑第二段是次态寄存器第三段是输出逻辑。三段式能把时序逻辑和组合逻辑分得很清楚RTL仿真时看波形也方便。有些复杂协议比如SD卡、以太网控制器会用到表驱动状态机也就是把状态转移关系存成一张ROM表通过查表代替一长串case分支。对于SPI Flash这种命令数量有限的场景用传统的case写法就够了状态数也不会太多。真要是哪一天要支持几十种厂商Flash的私有命令再切换到查表结构也不迟。3. 手写核心模块从空文件到可综合的Verilog代码理论说了一堆现在开始写代码。我的目标很明确一个可综合、通用、便于扩展的SPI Flash控制器不依赖任何厂商IP纯手写RTL。3.1 对外接口定义与主机握手先定义模块端口。我习惯把SPI物理信号和Host命令信号分开这样顶层例化时FPGA内部逻辑走Host接口物理引脚直接连到Flash芯片。module spi_flash_ctrl #( parameter CLK_DIV 8 // 系统时钟分频系数例如50MHz时钟分频后SCLK约6.25MHz )( input wire clk, input wire rst_n, // Host 控制接口 input wire cmd_start, input wire [2:0] cmd_op, // 0:READ_ID 1:READ_STATUS 2:WRITE_ENABLE 3:READ_DATA 4:PAGE_PROGRAM 5:SECTOR_ERASE input wire [23:0] addr_in, // 目标地址 input wire [7:0] data_in, // 写数据字节 input wire [7:0] wr_len, // 写数据长度页编程使用最大256 output reg [7:0] data_out, // 读数据字节 output reg busy, output reg done, // SPI 物理接口 output reg spi_cs_n, output reg spi_sclk, output reg spi_mosi, input wire spi_miso );CLK_DIV用来控制SCLK频率。比如系统时钟50MHzCLK_DIV设为8则SCLK周期是系统时钟的8倍频率6.25MHz。N25Q128标准SPI模式最高能108MHz6.25MHz绝对够用。如果你的系统时钟本身很低比如12MHz那CLK_DIV设为1或2都没问题只要保证SCLK不超过Flash规格上限。3.2 核心状态机代码状态跳转与数据收发前面说了三段式这里给出状态机的核心实现。状态定义我用局部参数IDLE、CMD、ADDR、WR_DATA、RD_DATA、WAIT_WIP、DONE_ST。其中DONE_ST不是必须的但有了它done信号时序会非常干净。localparam IDLE 4d0; localparam CMD 4d1; localparam ADDR 4d2; localparam WR_DATA 4d3; localparam RD_DATA 4d4; localparam WAIT_WIP 4d5; localparam DONE_ST 4d6; reg [3:0] state, next_state; reg [3:0] bit_cnt; // 当前字节内的位计数 reg [3:0] byte_cnt; // 当前阶段内的字节计数 reg [7:0] shift_reg; // 发送/接收移位寄存器 reg [7:0] rd_byte_buf; // 最近读到的字节 reg [7:0] sclk_div_cnt; // SCLK分频计数器 reg sclk_re; // SCLK上升沿脉冲 reg sclk_fe; // SCLK下降沿脉冲备用SCLK的生成逻辑放在独立的always块里这样状态机主体不用关心分频细节。我在设计中让SCLK空闲为低Mode 0所以当状态机离开IDLE之后才开始翻转SCLK。// SCLK分频与边沿脉冲 always (posedge clk or negedge rst_n) begin if (!rst_n) begin sclk_div_cnt 0; sclk_re 1b0; sclk_fe 1b0; end else if (state IDLE) begin sclk_div_cnt 0; sclk_re 1b0; sclk_fe 1b0; end else begin if (sclk_div_cnt CLK_DIV/2 - 1) begin sclk_div_cnt 0; spi_sclk ~spi_sclk; sclk_re spi_sclk; // 上升沿脉冲 sclk_fe ~spi_sclk; // 下降沿脉冲 end else begin sclk_div_cnt sclk_div_cnt 1; sclk_re 1b0; sclk_fe 1b0; end end end真正关键的发送逻辑就在CMD、ADDR、WR_DATA这几个状态里。我以CMD状态为例说明怎么发一个命令字节先用一个字节计数器把命令从cmd_op查表得到然后在每个SCLK上升沿把移位寄存器左移一位输出到MOSI与此同时如果这个命令有数据读回就把MISO的值移进同一个移位寄存器。SPI本身是全双工所以MOSI和MISO共用一套SCLK边沿是天然成立的。always (posedge clk or negedge rst_n) begin if (!rst_n) begin spi_cs_n 1b1; spi_mosi 1b0; bit_cnt 0; byte_cnt 0; done 1b0; state IDLE; end else begin case (state) IDLE: begin spi_cs_n 1b1; busy 1b0; done 1b0; if (cmd_start) begin busy 1b1; spi_cs_n 1b0; // 拉低片选开始一次操作 bit_cnt 0; byte_cnt 0; state CMD; end end CMD: begin // 在每个SCLK上升沿发送一个bit // cmd_op经过查表得到command_byte // 当bit_cnt7时说明命令字节发送完毕 if (sclk_re) begin spi_mosi command_byte[7]; command_byte {command_byte[6:0], 1b0}; if (bit_cnt 4d7) begin bit_cnt 0; // 根据是否需要地址决定下一个状态 if (addr_needed) state ADDR; else if (cmd_need_wr_data) state WR_DATA; else state DONE_ST; end else begin bit_cnt bit_cnt 1; end end end // ADDR, WR_DATA, RD_DATA 类似 endcase end end这里的command_byte、addr_needed、cmd_need_wr_data是组合逻辑由cmd_op查表得到。查表放在一个always (*)块里可以一次性把所有命令的属性表都定义清楚。3.3 把读ID、读状态、写使能、页编程、读数据塞进同一套逻辑我实际把命令属性表写成了这样你可以直接参考reg [7:0] command_byte; reg addr_needed; reg read_needed; reg write_needed; always (*) begin case (cmd_op) 3d0: begin command_byte 8h9F; // READ_ID addr_needed 1b0; read_needed 1b0; write_needed 1b0; end 3d1: begin command_byte 8h05; // READ_STATUS_REG addr_needed 1b0; read_needed 1b1; write_needed 1b0; end 3d2: begin command_byte 8h06; // WRITE_ENABLE addr_needed 1b0; read_needed 1b0; write_needed 1b0; end 3d3: begin command_byte 8h03; // READ_DATA addr_needed 1b1; read_needed 1b1; write_needed 1b0; end 3d4: begin command_byte 8h02; // PAGE_PROGRAM addr_needed 1b1; read_needed 1b0; write_needed 1b1; end 3d5: begin command_byte 8hD8; // SECTOR_ERASE addr_needed 1b1; read_needed 1b0; write_needed 1b0; end default: begin command_byte 8h00; addr_needed 1b0; read_needed 1b0; write_needed 1b0; end endcase end有这张表在手状态机的主体逻辑就非常简单了CMD状态结束之后根据addr_needed决定是否进入ADDRADDR阶段结束之后根据read_needed决定进入RD_DATA根据write_needed进入WR_DATA如果两者都没有直接到DONE_ST。写操作比如页编程数据发完之后不能立刻完成还需要读状态寄存器确认WIP位清零。我的处理是在WR_DATA发送完最后一个数据字节后自动回到CMD状态发一个0x05读状态命令然后进入RD_DATA读到状态字节后检查bit0。如果WIP为1就继续重复读状态如果WIP为0表示Flash内部操作结束进入DONE_ST。这里有个小技巧为了避免WAIT_WIP这个状态里再套一层复杂逻辑我把读状态和读数据统一成RD_DATA状态只不过在WIP检测模式下只读一个字节就检查。从状态机视角看它只是执行一次READ_STATUS命令而已区别在于后续跳转。4. 仿真验证与上板调试的完整流程代码写完只是第一步真正的挑战在于验证。SPI Flash是有外部时序的芯片仿真时你不仅需要激励自己的状态机还要模拟Flash芯片的行为。如果你手头没有现成的Flash仿真模型最简单的方式是自己写一个行为级模型只模拟你关心的命令。我第一次调驱动时就是这么干的在Testbench里写一个简易的N25Q128行为模型模拟READ_ID返回0x20 0xBA 0x18模拟WRITE_ENABLE置位状态寄存器的WEL位模拟PAGE_PROGRAM把数据存进二维数组模拟READ_DATA把对应地址的数据读回来。4.1 写一个能用的Testbench把命令执行顺序跑通Testbench的核心思路就是“按命令通道的时序给状态机发指令”。读ID最简单我来写一个直观的测试流程initial begin rst_n 0; cmd_start 0; #100 rst_n 1; #200; // 测试 READ_ID cmd_op 3d0; cmd_start 1; #40; cmd_start 0; // 等待 busy 拉低done 拉高 (posedge done); $display(READ_ID done, data_out %02x, data_out); // 测试 WRITE_ENABLE PAGE_PROGRAM READ_DATA ... end注意握手时序cmd_start至少保证一个系统时钟周期的高电平状态机在IDLE采样到上升沿后立刻拉低spi_cs_n开始发送命令。我在自己项目里习惯把cmd_start设计成脉冲信号而不是电平信号这样可以避免重复触发。Testbench里用(posedge done)等待操作完成之后再发起下一个命令。仿真跑完之后务必打开波形对照Datasheet的时序图逐字节检查。我通常看得最仔细的是这几处片选拉低后是否先稳定一小段时间再出时钟命令字节的MSB是否先发地址字节是否按高到低的顺序发数据读取阶段每个字节的采样沿是否落在SCLK上升沿附近。如果波形对不上先不要急着改代码静下心数一数片选、时钟、MOSI三个信号之间的关系。4.2 ILA在线抓波形的几个坑仿真过了不代表上板没问题尤其是时钟频率、IO delay、上电时序这些仿不出来的东西。我用Xilinx的ILA抓过好几次Flash时序每次都有新教训。第一ILA采样时钟必须用系统时钟不能直接用SPI的SCLK。因为SCLK是门控时钟或者由逻辑生成的时钟直接用的话等效触发条件会乱掉。第二ILA的触发条件尽量用busy或者done信号做边沿而不是用spi_cs_n。因为spi_cs_n在每次操作开始都会拉低如果命令执行很快波形会刷得飞快根本看不清细节。我最常做的是把data_out、spi_mosi、spi_miso、spi_sclk、spi_cs_n全部抓到ILA里设置触发为done1然后观察最后一次操作的波形。虽然只能看到快照但足够判断命令是否执行正确、读回的数据是否符合预期。4.3 常见问题速查表我把实际调试中遇到的典型问题整理成了表格你可以收藏起来当排查手册用现象可能原因排查方向读ID读到全是0xFFSPI接线错误、Flash未上电、CS是否拉低用万用表/示波器测CS、CLK、MOSI电平读ID读到固定错误值命令字节位序错误、SCLK极性不对对照时序图检查MSB是否先发写使能后WEL位仍为0写保护引脚电平不对、命令序列不对确认为WRITE_ENABLE后再读状态页编程后读回数据全是FF未发写使能、地址越界、Flash被写保护检查代码是否先发0x06测试简单固定地址读数据死循环WIP一直为1、时钟频率过高、Flash内部忙降低SCLK频率单独测READ_STATUS仿真报license错误仿真环境license配置问题改用Icarus Verilog等开源工具跑行为仿真ILA看不到SCLK翻转采样时钟选择错误、ILA触发条件太频繁改用系统时钟采样触发条件设为done上升沿5. 实测心得这几个坑我踩过你可以直接绕开最后分享一些很难在Datasheet里直接看到、但实际项目中特别重要的经验。5.1 写使能这个东西真的会让你怀疑人生页编程和擦除操作必须先在Flash内部把WEL位置1否则发出去的编程命令会被直接忽略连错误标志都不给。我第一次调试时写数据写进去读回来永远是0xFF排查了很久才发现是WRITE_ENABLE命令没发或者发了但被当成普通命令忽略了。记住一个口诀先WRITE_ENABLE再PAGE_PROGRAM先WRITE_ENABLE再SECTOR_ERASE。这个顺序不能乱也不能省。5.2 千万不要忘记轮询WIP页编程和擦除都是耗时操作N25Q128页编程正常需要几毫秒扇区擦除更是几十到几百毫秒。如果你发完数据直接拉高片选然后马上去读数据大概率读到的是Flash内部还没完成写入的结果。正确做法是编程/擦除发起之后反复发READ_STATUS命令检查bit0也就是WIP位直到WIP为0才算操作真正完成。我做了一个阻塞式等待状态机一直卡在WIP检测状态过程中Host接口一直拉高busy上层逻辑看到busy就会耐心等。5.3 页面边界是隐藏杀手N25Q128页大小256字节一次页编程最多写256字节而且不能自动跨页。如果你从地址0x0100开始写128字节没任何问题但如果从0x0100开始写200字节其中最后56字节会写到0x0200这个页面里去而不会顺延到下一页的0x0180。这是NOR Flash的通用行为很多老工程师都在这上面栽过跟头。解决办法是上层模块自己拆分写入请求超过页边界就分成多次页编程。另外还有一个多字节扩展的小建议一旦你的系统要支持连续多字节读写Verilog里优先使用task封装“发送一个字节”和“接收一个字节”的重复逻辑可综合的task在FPGA里没问题但更推荐在Testbench里使用task来做更灵活的行为仿真。这也符合“任务调用”思路能让代码结构清晰很多。如果你在工程里需要连续采集一批ADC数据存入Flash正好可以结合滑动窗口滤波的Verilog实现在数据存入之前先做一次滑动平均这样存进去的值会稳定很多减少了Flash反复擦写的频率——这可是延长Flash寿命的实用招数。经验之谈能少擦写一次是一次。最后再说一条如果你的系统里Flash操作会和其它模块比如UART收发、DDR3读写并行存在强烈建议在设计时就把异步FIFO放在Flash控制器的上游。我一开始图省事直接用寄存器握手结果在高负载下偶发丢命令加上FIFO之后问题再没出现过。数据跨时钟域不是小事前期多花半小时后面能省一整天的Debug时间。拨开Datasheet几百页的迷雾SPI Flash驱动其实就是“命令查表状态机流水线WIP轮询”这三板斧。把一个通用命令通道写好后面不管是换Flash型号还是扩展Quad SPI、DTR这些高级模式都能在现有框架上平滑演进。这几段代码和踩坑记录希望你能直接用得上。