
简介本资源是面向FPGA开发初学者与嵌入式硬件工程师的MT25QL系列SPI Flash读写实战工程聚焦于高速可靠存储接口的底层驱动实现与验证。工程基于Xilinx Vivado 2018.3平台构建完整实现了50MHz输入时钟经PLL倍频至80MHz后以115200波特率通过UART交互完成Flash扇区级写入与回读验证——发送单字节即自动写入起始地址连续256字节并原样回传便于快速闭环调试。压缩包含329个文件10.56MB涵盖28个Verilog核心逻辑文件、7个XDC管脚约束文件、27个说明文本、14个Tcl自动化脚本及ILA调试配置等关键代码均附中文注释目录结构清晰FLASH驱动模块已封装为独立IP读/写数据端口标准化可直接对接用户自定义控制逻辑移植性强。目前已有164人学习下载适合用于课程设计、毕业设计或工业现场存储模块快速原型开发。 做FPGA的工程师迟早要和FLASH打交道。我这次用Verilog在Vivado里写了一个读写MT25QL FLASH的完整示例工程从SPI控制器到命令状态机全部自己实现没有用厂商现成的IP核把读写擦除三个操作在工程里全部跑通。这篇文章就把这个工程从设计思路到具体实现、再到调试踩坑的过程完整拆解出来给正在被NOR FLASH时序折腾的同行一个直接能抄作业的参考。先说下为什么选MT25QL。这芯片是Micron的QSPI NOR FLASH常见型号有MT25QL128、MT25QL256容量从128Mb到256Mb不等3.3V供电单芯片16MB的容量用来存FPGA配置、存固件、存数据记录都够用。相比I2C EEPROM它容量大得多相比NAND它接口简单、读写随机访问友好而且Xilinx和Intel的FPGA配置芯片标准方案里到处能看到它的影子。这个工程我以MT25QL128为例地址24位页大小256字节擦除最小单位是4KB扇区。1. 项目背景与整体设计思路1.1 为什么FPGA要和NOR FLASH打交道FPGA本身是SRAM工艺掉电就丢配置这决定了它必须外挂一颗非易失存储来放bit文件。上电后FPGA通过SPI接口把配置文件读进来完成加载这是NOR FLASH最典型的应用场景。但实际项目里FLASH的用途远不止存配置有些系统需要在线升级固件上位机把新版本bit文件下发到FLASH下次启动加载新版本有些信号处理板需要FPGA本地记录一些关键数据比如参数表、校准系数、运行日志还有些系统用FLASH做数据缓存断电后数据不丢。正是因为这些需求FPGA和FLASH之间的读写操作就成了一个绕不开的基础能力。你可以靠厂商的配置功能被动地烧写但要做在线升级、数据记录这种事必须自己设计一套主动读写FLASH的逻辑。1.2 方案选型为什么选MT25QL而不是其他FLASH选芯片的时候我对比过几个方案。NAND FLASH容量大价格低但管理机制复杂坏块、ECC、块擦除这些处理逻辑在FPGA里实现起来非常痛苦除非是做SSD或者大容量存储卡这种场景否则杀鸡用牛刀。I2C EEPROM倒是简单但容量做到几Mbit就到头了存个配置文件都紧巴巴更别说存数据日志。SPI NOR FLASH正好卡在中间容量从几Mbit到几百Mbit都有接口只有6根线随机读快擦写逻辑也直观最适合FPGA工程里做配置存储、固件备份、数据记录这些任务。选了SPI NOR之后具体挑哪颗我最终定的MT25QL128理由是第一Micron这颗芯片的命令集和时序都非常规范数据手册写的清晰网上参考资料也多第二它被大量Xilinx开发板用作配置芯片硬件上可以直接复用配置电路原理图都不用改第三它兼容JEDEC标准命令集如果哪天要换国产替代或者换其他品牌Verilog代码里改个命令码就行迁移成本低。当然如果你用的是Intel平台对应的W25Q系列命令集也类似这段代码稍加修改也能用。1.3 系统架构和接口定义整个工程我按三个层次来设计最底层是SPI控制器负责把字节变成SCK/SI/SO时序这是所有操作的基础中间层是FLASH命令层把读、写、擦除、状态查询这些命令按照数据手册的时序组织成SPI字节序列最上层是用户访问接口给外部逻辑提供类似读寄存器、写缓冲区、启动操作这样的调用方式。这样的分层架构有个明显好处SPI控制器不关心你发的是什么命令它只管按时钟把字节发出去、收回来命令层不关心数据从哪来、到哪去它只管按协议把命令码、地址、数据串起来用户接口层不关心FLASH内部状态它只管接收指令、反馈状态。哪一层出错直接看哪一层调试的时候思路非常清楚。用户接口我设计得很简单一组请求信号加上一组数据线// 用户接口信号 input wire user_cmd_valid // 命令请求有效 input wire [2:0] user_cmd_type // 命令类型1-读 2-写 3-擦除 input wire [23:0] user_cmd_addr // 目标地址 input wire [7:0] user_cmd_data_in // 写入数据 output wire [7:0] user_cmd_data_out // 读出数据 output wire user_cmd_busy // 忙标志 output wire user_cmd_done // 命令完成这套接口做出来之后上层逻辑要读数据就拉高valid、给地址和命令类型然后等done信号要写数据就往data_in上按节拍送数据。接口宽度可以根据实际需求扩展成8位、16位或者32位我这里是8位匹配FLASH的字节结构。2. 关键模块拆解与设计细节2.1 SPI控制器的设计模式选型与字节传输MT25QL支持标准SPI、Dual SPI和Quad SPI三种模式我这个工程先以标准SPI模式为基础把所有命令跑通。SPI的四种工作模式通过CPOL时钟极性和CPHA时钟相位区分MT25QL的数据手册明确要求工作在模式0也就是CPOL0、CPHA0空闲时SCK为低电平数据在SCK上升沿被采样下降沿更新。这个细节值得多说一句因为我见过不少人在FPGA里写SPI控制器时想当然地以为上升沿发送、下降沿接收结果时序对不上读回来的数据全是乱的。MT25QL的约定是FPGA作为主机在SCK的下降沿把数据放到SI线上FLASH在SCK的上升沿采样反过来FLASH在SCK下降沿把数据放到SO线上FPGA在SCK上升沿采样。所以统一用上升沿采样、下降沿更新的做法就不会错。SCK的频率我这里设成25MHz这对MT25QL来说是绰绰有余的标准Read命令最高支持50MHzFast Read可以到133MHz但FPGA内部逻辑跑25MHz意味着SPI控制器只需要一个简单的计数器分频就能产生SCK时序约束也宽松。后面如果要提速换用更高频率直接调分频系数就行控制器结构不用改。字节传输状态机我把每个字节的8个bit拆成独立状态这样每一拍干了什么看得清清楚楚// 发送一字节 SPI控制器核心状态 localparam S_IDLE 0, S_BIT 1, S_DONE 2; always (posedge clk) begin case (spi_state) S_IDLE: if (spi_start) begin spi_bit_cnt 3d7; spi_state S_BIT; end S_BIT: begin // 下降沿更新数据 if (!sck_reg) begin spi_data_out spi_buffer[spi_bit_cnt]; spi_bit_cnt spi_bit_cnt - 1b1; if (spi_bit_cnt 0) spi_state S_DONE; end end S_DONE: begin sck_en 1b0; // 拉高片选前停止时钟 spi_done 1b1; spi_state S_IDLE; end endcase end这个状态机里的数据移位逻辑我调整了几次才稳定核心在于必须保证SCK的下降沿和数据更新严格同步。很多人在FPGA里做SPI都是直接在系统时钟沿更新数据SCK从同一个寄存器打一拍出来这样做虽然能跑但时序上会引入不确定的延迟。我后来改成了先产生SCK的使能信号、再做数据移位的方案实际上是把SCK的每个有效沿当作独立的节拍来处理整个时序就干净多了。2.2 FLASH命令层设计为什么写操作必须先发写使能MT25QL的命令集看起来一大堆但基础操作总结下来就几个核心命令。我在命令层统一维护一个命令寄存器根据用户请求自动生成完整的命令序列包括命令码、地址字节和数据字节。常用的命令码需要直接背下来命令功能命令码命令格式Read Data0x03命令码 3字节地址 数据输出Fast Read0x0B命令码 3字节地址 1字节Dummy 数据输出Page Program0x02命令码 3字节地址 数据输入Sector Erase (4KB)0x20命令码 3字节地址Block Erase (64KB)0xD8命令码 3字节地址Write Enable0x06命令码Read Status Register0x05命令码 数据输出这里必须重点强调Write Enable命令。MT25QL有个写保护机制每次成功执行编程或者擦除命令之后内部的状态寄存器写使能锁存位WEL会自动清零下一次操作之前必须重新发Write Enable命令否则FLASH会直接忽略编程和擦除请求。也就是说每次写操作都得按这个固定套路来Write Enable0x06→ Page Program0x02→ 轮询状态寄存器直到WIP清零三步缺一不可。初次做这个工程的时候我写了第一版代码后给FLASH写数据读回来全是对应的值没任何错误但就是写不进去。排查了大半天最后打印状态寄存器发现WEL位一直是0才意识到少发了写使能。这个坑非常典型凡是用了SPI NOR FLASH的人几乎都踩过。2.3 用户访问接口让上层逻辑能干净地调用用户访问接口的设计直接决定了这个工程好不好用。我遇到过一些参考设计把SPI控制器和FLASH命令揉在一起上层逻辑直接对着SPI信号操作读一个字节要自己拉低片选、自己拼地址、自己数时钟用起来极其痛苦。这个工程我特意把用户接口做得干净一点核心就是一个命令状态机加一组寄存器。接口时序约定成类似简单总线的风格用户拉高valid表示发起操作给出命令类型和地址然后等busy拉低、done拉高。对于写操作需要额外把待写数据按字节送进来对于读操作FLASH返回的数据在done的时候同时出现在data_out总线上。命令状态机内部把整个操作拆成几个阶段localparam IDLE 4d0; localparam WREN 4d1; // 写使能 localparam SEND_ADDR 4d2; // 发送地址 localparam PROGRAM 4d3; // 编程阶段 localparam ERASE 4d4; // 擦除阶段 localparam READ_DATA 4d5; // 读数据阶段 localparam READ_STAT 4d6; // 读状态寄存器 localparam CHECK_WIP 4d7; // 等待WIP清零 localparam DONE 4d8; // 完成这套接口设计好之后上层逻辑只需要关心三个信号发起命令、等待完成、取数据完全不感知底层的SPI波形和FLASH时序。我后来在这套接口基础上做了DMA式的连续读、连续写只需要在接口层加一个计数器自动递增地址外部逻辑甚至不用管每个页的边界问题。3. 核心状态机与读写时序实现3.1 全局状态机如何把多个命令串起来FLASH操作和简单的SPI字节收发最大的区别在于操作是有“阶段”的每个阶段持续的时间还不一样。比如读操作是命令码加地址后马上就有数据流返回页编程是命令码加地址加数据发完之后还要等内部写入完成这个时间可能长达几毫秒扇区擦除更夸张发送命令之后要等几十毫秒甚至几百毫秒。全局状态机的作用就是把这些阶段有序地组织起来保证每个阶段的时序都符合数据手册要求。全局状态机我设计成上图那种结构每一条命令路径都是一个有限状态集合不同路径之间通过IDLE状态切换。以编程操作为例完整的路径是IDLE→WREN→SEND_ADDR→PROGRAM→READ_STAT→CHECK_WIP→DONE其中CHECK_WIP状态会一直停留直到FLASH状态寄存器的WIP位清零。这个状态机在实现上有个细节很考验人状态跳转的时机必须严格对齐SPI字节传输结束事件。我刚开始做的时候用了两个always块一个管状态跳转一个管SPI字节计数结果总是出现状态已经跳走了但SPI还没收完当前字节的情况。后来统一改成事件驱动的方式SPI控制器在每完成一个字节传输时产生一个done脉冲全局状态机只在这个脉冲到来时跳转整个时序就稳定了。3.2 读操作时序0x03和0x0B和0x6B怎么选读操作是FLASH用到最频繁的功能MT25QL提供了多个读命令最主要的区别在于频率和引脚数。0x03标准读最高支持50MHz一个数据引脚输出0x0B快速读最高支持133MHz需要额外的8个Dummy时钟周期0x6B是Quad Output快速读最高支持133MHz四个数据引脚同时输出吞吐量提升四倍。我这个示例工程先实现了0x03标准读因为它的时序最简单适合把框架跑通。读操作的具体波形是主机拉低CS发送命令码0x03紧接着发送24位地址之后FLASH会在每个SCK时钟沿从SO引脚输出对应地址的数据地址自动递增。注意地址发送是高字节在前也就是A23..A16、A15..A8、A7..A0的顺序这个顺序写反了读回来的数据位置全对不上。如果你要做高速读0x0B必须加Dummy周期。Dummy周期的含义是命令码和地址发完之后FLASH需要几个时钟周期来准备数据这段时间主机只给时钟不收数据。0x6B的Quad模式则要把单向输出的SO改成双向的IO0到IO3FPGA侧的引脚方向要跟着切换这个比标准SPI复杂不少我会在后面专门讲。3.3 页编程操作与页边界问题的处理写操作是让无数新手栽跟头的地方。MT25QL的Page Program命令一次最多写入256字节而且是按页对齐的如果你跨越页边界连续写数据不会自动换页而是回卷覆盖到当前页起始地址这可能把之前写好的数据冲掉。避免这个问题的办法很简单写数据之前先检查当前地址加上本次写入长度是否跨页如果跨了就拆成两次编程或者限制每次单次编程的字节数不超过当前页剩余空间。我工程里实现了一个自动拆分逻辑用户只需要给出起始地址和缓冲区命令状态机自动判断是否跨页跨页就分两次操作对上层完全透明。页编程的完整时序流程是发送Write Enable命令置位WEL发送Page Program命令码0x02发送24位起始地址连续发送数据字节最多256字节拉高CS触发内部编程轮询状态寄存器直到WIP清零检查WEL是否已经自动清零确认本次编程完成编程过程中如果CS拉高得太早数据没有完全写入拉得太晚可能多写进了额外的字节。这个时序窗口在数据手册里叫tPP典型值0.4ms但在FPGA实现里不需要精确计时轮询WIP才是可靠的完成判断方式。3.4 扇区擦除与状态轮询的实现原理NOR FLASH的特性决定了写操作只能把1写成0要把0恢复成1只能靠擦除。MT25QL支持4KB扇区擦除、64KB块擦除和整片擦除分别对应命令码0x20、0xD8和0xC7。我这个工程把4KB扇区擦除作为标准操作来实现因为它是批量擦除的最小粒度做数据记录的场合最常见。擦除的流程也是先写使能、再发擦除命令、最后等WIP清零但等待时间比编程长得多4KB扇区擦除典型时间是40ms最大可达400ms。这就带来了一个设计问题如果FPGA在擦除期间干等着整个系统就卡住了。我在接口层增加了一个busy状态和中断标志擦除期间上层逻辑可以去干别的事等busy拉低再发下一条命令。状态轮询的实现方式也有讲究。最基本的做法是发Read Status Register命令然后把状态寄存器的bit0WIP拉回来为1说明还在忙为0说明空闲。注意状态寄存器的数据是在命令码发完之后的下一个字节而且FLASH会持续输出状态寄存器的值所以如果你一直给时钟、不拉高CS就可以连续读状态。我的实现是一次轮询只读一个字节就拉高CS然后重新发命令虽然效率不是最高但逻辑清晰、不容易出错。4. 时序约束与上板调试要点4.1 FPGA工程里的时钟和引脚约束FPGA做SPI控制器最大的坑是SCK产生方式导致的时序违例。如果SCK不是从系统时钟分频来的而是用组合逻辑直接生成的那它在FPGA内部走的是通用布线资源延迟不确定在高速SPI下很容易产生毛刺。正确的做法有两种一是用ODDR原语把SCK映射到FPGA的专用输出时钟引脚二是在逻辑内部产生很高的SCK使能信号用IOB寄存器输出。我工程里选的是第一种方式用ODDR产生SCK这样SCK的质量有保障。引脚约束方面要注意FLASH的WP_N和HOLD_N两个引脚。在标准SPI模式下这两个引脚必须接高电平否则FLASH会处于写保护或者暂停状态。FPGA侧如果你没有用这两个引脚记得在约束文件里把它们设置成输出高电平或者外部电路直接上拉我遇到过几次因为HOLD_N引脚悬空导致FLASH偶发不响应的情况排查到最后都是这个原因。时钟约束在Vivado里我是这样写的create_clock -name system_clk -period 10.0 [get_ports clk] set_output_delay -clock [get_clocks system_clk] -max 4.0 [get_ports {spi_sck spi_si spi_cs}] set_output_delay -clock [get_clocks system_clk] -min 1.0 [get_ports {spi_sck spi_si spi_cs}] set_input_delay -clock [get_clocks system_clk] -max 4.0 [get_ports spi_so] set_input_delay -clock [get_clocks system_clk] -min 1.0 [get_ports spi_so]这些delay值不是拍脑袋写的要参考MT25QL数据手册里的时序参数SCK到数据输出的时钟沿偏斜、数据建立保持时间要求。算的时候把FPGA芯片内部走线延迟、PCB走线延迟都估算进去留出余量。实际调试时如果发现SPI在某些温度频率下不稳定优先回头检查这些约束是不是给得太极限了。4.2 仿真验证怎么搭模型仿真还是直接看波形FPGA逻辑写完一定先在仿真环境里验证再上板。MT25QL在Xilinx Vivado里有现成的仿真模型但我不太喜欢直接调库而是自己写了一个精简的FLASH行为模型只模拟了本例程用到的几个命令Read、Page Program、Sector Erase和Read Status。好处是仿真速度极快而且模型里可以加打印信息方便观察每一条命令的状态。仿真验证的核心是覆盖各种边界情况跨页边界的写操作、地址对齐的读操作、擦除超时、状态寄存器轮询次数等等。我专门构造了地址从页末尾开始写8字节的场景验证自动拆分逻辑是否正确又构造了相邻扇区连续擦除的场景验证状态机不会互相干扰。仿真通过之后再上板上板调试要养成看信号的习惯。FPGA内部逻辑的信号可以在ILAIntegrated Logic Analyzer里抓我一般抓CS、SCK、命令状态机状态和数据总线这几个信号配合SystemILA的触发功能在写操作发起时抓一段完整的波形对照数据手册逐拍检查。用这种方法我定位过芯片选错、地址错位、时序不足等好几个问题。4.3 板上实测结果和性能评估整个工程在我手头一块Artix-7开发板上跑通了FLASH型号是MT25QL128ABA。实测下来几个数据我心里有数标准读模式下25MHz时钟吞吐率大约3.1MB/s页编程256字节实测耗时约0.5ms吞吐率大约0.5MB/s扇区擦除4KB实测耗时约60ms符合数据手册典型参数。这些数字对配置存储和数据记录完全够用但如果要做大量数据吞吐建议升级到Quad读和Quad写模式吞吐率能再翻几倍。从写完代码到稳定运行期间大大小小修了十几个问题最大的感悟是SPI FLASH看起来简单但真正要把读写擦除都做到可靠需要逐个命令地抠时序细节。接下来我把调试过程中遇到的高频问题整理出来这些几乎都是新手必踩的坑。5. 常见问题与排查技巧实录5.1 写数据失败WEL位没有置位现象是写完数据后读回全是0xFF或者直接读状态寄存器发现WEL一直是0。这个问题的根源必然是漏发了Write Enable命令。FLASH编程和擦除命令执行完成之后内部会自动清除WEL位如果你提前把WEL当成常态条件下一次操作就会失败。排查技巧在每一条写路径上都检查一下WEL位如果WEL为0就补发Write Enable再执行编程。更稳健的做法是在编程前无条件发一次Write Enable不要依赖上一次的状态。我最终的代码就采用了这种方式省心很多。5.2 读回来数据错位地址字节顺序搞反现象是读数据本身能读出来但数据的位置跟预期不一致比如读0x000000地址却出0x000001的内容。这种问题几乎都是地址字节发送顺序的问题。MT25QL是高位地址先发也就是A23..A16在最前面这和很多工程师从低字节开始发的直觉正好相反。排查技巧用逻辑分析仪抓SCK和SI把发送的字节逐个解码出来跟命令格式对照一眼就能看出地址字节顺序对不对。另外常见的混淆是把命令码和地址拼在一个字节流里时位序搞反SPI本身是MSB first这个也容易在移位逻辑中写错。5.3 擦除完成判断不准确WIP轮询逻辑有缺陷现象是擦除命令发完后立即读FLASH返回旧数据过一会儿又变成新数据看起来好像“慢半拍”。这是没有做WIP轮询导致的。擦除操作是异步的命令发完不等于擦除完成只有状态寄存器的WIP位从1变成0才是真正的完成标志。正确做法是擦除命令发完之后循环执行“发送0x05命令→读回状态字节→检查bit0”直到bit0为0才返回完成。而且轮询时要保证每次读状态寄存器的CS拉低和拉高时序完整否则FLASH状态不对读出来的数据没意义。5.4 QSPI模式下IO方向切换导致的数据冲突这个工程标准SPI跑通之后我升级到Quad模式时碰到了IO冲突的问题。Quad模式下IO0到IO3都是双向引脚命令和地址阶段是输入方向数据阶段要切换成输出方向。如果方向切换时序没处理好数据引脚上会出现驱动冲突读回来的数据全是乱的。解决办法是在命令状态机里增加方向控制信号严格按“命令发送完成后的第一个时钟沿切换方向”的规则来。因为FPGA内部逻辑和引脚之间还有延迟方向切换要提前一拍否则切换完成后第一个数据采样可能是无效的。这个坑在Xilinx官方文档里也有提到做QSPI绕不开。5.5 常见问题速查表现象可能原因排查方法写数据后读回全FF没发Write Enable检查WEL位编程前补发0x06读数据位置错位地址字节顺序发反抓SI波形核对高字节在前写数据丢失后半段跨页边界未处理判断地址加长度是否超页拆分编程擦除后数据还是旧的没有轮询WIP位发送0x05循环读状态直到bit0为0Quad读数据乱IO方向切换冲突检查方向信号时序预留切换延迟FLASH偶发不响应WP/HOLD引脚电平不对确认两引脚接高或者FPGA输出高6. 一些经验总结和扩展方向6.1 自己写FLASH控制器和用IP核怎么选关于SPI FLASH控制器到底应该自己写还是用厂商IP核我的看法是这样如果你的项目时间紧、只求能用直接用Xilinx和Intel的Quad SPI IP核他们帮你处理了绝大多数时序细节配置界面里勾一下就行但如果你想深入理解FLASH的工作原理或者你的FLASH型号和IP核的默认配置差异比较大又或者你需要对时序做特别优化那自己写一遍是非常值得的。自己写的最大的好处是出了任何问题你都能从原理层面去分析而不是面对一个黑盒。比如IP核报错了你不知道是命令发错了还是时序不满足但自己写的控制器每个状态的波形都是自己设计的问题定位快得多。这个工程花了一个周末从零写完换来的经验在后续项目里反复用得上。6.2 如何从标准SPI扩展成QSPI模式如果要在现有工程上做QSPI扩展我的建议是先把标准SPI模式下的读写擦除全部跑稳定再一项项加能力。第一步改成Dual模式把SO变成双向的IO1第二步改成Quad模式把SI也解放出来变成IO0再补上IO2和IO3。每一步都是增量改动出问题时能很快定位是新加的引脚还是时序有问题。在现有工程上做QSPI扩展时重点改动三个地方命令层要能发送0x6B和0x32这类Quad命令SPI控制器要增加引脚方向切换逻辑顶层模块的引脚约束要重新定义双向总线。其他部分不用大动全局状态机的框架完全可以复用。6.3 这个工程后续还能怎么玩工程写好之后我在几个地方做了扩展。第一个是用它做在线升级上位机通过串口把新的bit文件分包发下来FPGA收到后写入一个专门的分区写完后设置启动标志下次上电从新分区加载。第二个是用它做数据记录因为MT25QL的随机读很快可以把采样数据先缓存到DDR然后按页批量写入FLASH实现类似数据记录仪的方案。第三个是把FLASH当作大容量参数存储把一堆标定参数按地址索引组织存储上电时一次性读入内部寄存器。这些扩展方向本质上都是在用户访问接口上做文章底层的FLASH控制器不需要改。所以我把这层接口设计得越干净后面做扩展就越省力。这也是我对这个工程最满意的地方。本文还有配套的精品资源点击获取