基于FPGA读写MT25QL SPI NOR Flash的工程实现与验证
简介:本资源是一套基于Xilinx FPGA平台实现MT25QL系列SPI NOR Flash读写控制的完整Verilog工程,面向数字电路初学者、FPGA开发工程师及嵌入式硬件开发者,解决Flash芯片底层驱动开发与验证难题。工程以Vivado 2018.3为开发环境,通过串口交互(115200波特率)触发对Flash起始地址连续256字节的写入与回读操作,具备即连即验的闭环验证能力,便于快速掌握SPI Flash时序控制、状态寄存器解析及扇区擦写管理等核心知识点。压缩包共329个文件,含28个Verilog源码(含关键注释)、7个XDC约束文件(需按实际原理图适配)、14个Tcl脚本(支持综合/实现/仿真全流程)、19个RST报告及多个bit/DCP/IP核文件,总大小10.56MB,目录结构清晰,模块划分明确。已有1141人学习下载,配套代码全部可读可调,FLASH驱动模块已封装为独立IP,读写端口标准化,可直接对接用户逻辑;同时提供时钟管理(50MHz输入→80MHz工作频率)、ILA调试配置及基础仿真脚本,显著降低移植与调试门槛。 做FPGA开发的朋友,迟早都会碰到一件事:给板子配一颗SPI NOR Flash。无论是存配置、存固件、存采集数据,这颗小芯片都是绕不开的。我最近在项目中用FPGA驱动MT25QL系列FLASH芯片,从指令时序到状态机设计,再到板级回环验证,完整走了一遍,踩了不少坑,也沉淀了一些值得分享的经验。这篇文章就把这个“基于FPGA读写MT25QL FLASH芯片详细示例工程”的整体思路、Verilog实现要点、验证流程和排查技巧一次性讲清楚,希望对正在做存储相关开发,或者正准备入门FPGA存储控制器的同学有帮助。
这个工程解决的核心问题很直接:让FPGA通过SPI接口对MT25QL FLASH完成擦除、写入、读取操作,并提供可靠的数据校验机制。适合正在学习FPGA的开发者、需要在自己的板卡上使用SPI NOR Flash的硬件工程师,以及想搞清楚存储控制器内部状态机设计逻辑的人参考。下面我开始讲正题。
1. 项目整体设计与思路拆解
1.1 为什么要用MT25QL而不是常见的W25Q128
先说芯片选型。MT25QL是美光(Micron)的工业级SPI NOR Flash系列,常见型号包括MT25QL128ABA(128Mbit,16MB)、MT25QL256ABA(256Mbit,32MB)、MT25QL512ABA(512Mbit,64MB)等。很多朋友可能更熟悉Winbond的W25Q128,因为国产开发板、ESP32模块上用得极多。那为什么这个工程选MT25QL?
原因有三:
第一,Xilinx官方评估板大量使用MT25QL系列。比如Zynq和Ultrascale+系列的开发板上,板载QSPI Flash很多就是MT25QL256,FPGA工程师如果能直接驱动它,在调试官方板卡或做原型验证时会非常顺手,不用再去换芯片。
第二,MT25QL的指令集完全兼容JEDEC SPI NOR标准,和W25Q系列的核心指令高度一致,像READ(0x03)、PAGE PROGRAM(0x02)、SECTOR ERASE(0x20)、READ ID(0x9F)这些指令两边通用。也就是说,只要把这个控制器写通,换到W25Q128上也基本不用改逻辑,顶多改改容量、页大小之类的参数。这个工程涉及的方案具有天然的延展性。
第三,MT25QL支持单线、双线、四线SPI模式,最高工作频率可以达到133MHz。虽然单线模式跑不到那么高,但如果后续项目有提速需求,同一个控制器可以平滑扩展到Quad SPI模式,代码架构不用推倒重来。
1.2 控制器架构:两级结构的设计取舍
这个工程没有用Xilinx自带的SPI IP核,而是完全自己写Verilog实现,这是有意的设计选择。为什么不用现成IP核?一是IP核内部逻辑黑盒化,出问题不好定位;二是不同Vivado版本生成的IP核接口有差异,换个版本工程要重新配置;三是自己写控制器可以清楚掌控每一个时钟周期、每一个延时,这对理解SPI协议本身非常有帮助。
整体架构上,我采用的是“SPI协议层 + Flash应用层”的两级结构,跟软件分层的思想类似,上层逻辑不关心底层SPI波形怎么产生,底层逻辑也不知道上层在发什么指令,只管按协议收发数据。这个解耦思路在调试和复用上都很好用。
具体划分为三个模块:
spi_master:SPI主控制器,负责时钟分频、字节级收发、片选信号控制,对外提供简单的字节发送/接收接口。flash_ctrl:Flash应用层状态机,负责拼装指令、地址、数据,管理擦除、编程、读取的完整流程,还要轮询状态寄存器判断WIP位。top顶层模块:完成两个模块的例化,并对外引出引脚约束,同时可以挂LED或串口用于状态指示。
这个分层的核心价值在于:如果你想换一颗支持不同指令集的Flash,只需要改flash_ctrl这一层,SPI物理层完全不用动;如果你想换FPGA平台,只需要改top层的引脚约束和spi_master的时钟分频参数。这个结构我在多个项目里复用下来,非常实用。
2. SPI协议基础与MT25QL指令集详解
2.1 SPI接口信号与工作模式选择
SPI接口一共4根线,比I2C多一根,但传输速率和简单程度都更有优势。MT25QL的SPI接口信号如下:
CS_N:片选,低有效。整个指令传输过程中必须一直拉低,指令结束后拉高。这里有一个容易踩坑的点:某些人对“低有效”不够敏感,把片选极性搞反,导致FPGA发出去的指令Flash完全收不到。SCK:串行时钟,空闲状态的电平由CPOL决定。DI(MOSI):主机发送数据线,数据在时钟沿被Flash采样。DO(MISO):主机接收数据线,Flash在读操作中输出数据。
MT25QL支持SPI Mode 0(CPOL=0,CPHA=0)和SPI Mode 3(CPOL=1,CPHA=1)。FPGA作为主机时,工程里最常用的是Mode 0,即空闲时SCK为低电平,数据在SCK上升沿被采样。选择Mode 0的好处是逻辑实现简单,FPGA内部时序分析也直观。
需要特别注意的是,MT25QL的DI信号在Mode 0下要求数据在SCK上升沿之前稳定,换句话说,FPGA发送数据时要在SCK下降沿前后更新数据,这样才能保证在上升沿被可靠采样。实际代码里我会用SCK的下降沿来更新发送数据,SCK的上升沿来采样接收数据,这个习惯帮我避免了很多时序毛刺问题。
2.2 核心指令集与状态寄存器
这个示例工程用到的指令不多,但每条都很关键:
| 指令 | 操作码 | 功能说明 | 备注 |
|---|---|---|---|
| READ ID | 0x9F | 读取JEDEC ID,返回3字节 | 调试首选,最快确认通信是否正常 |
| READ | 0x03 | 从指定地址读出数据 | 可以连续读,无需页边界限制 |
| WRITE ENABLE | 0x06 | 置位WEL,写/擦除前必发 | 忘发这步是写失败的No.1原因 |
| READ STATUS | 0x05 | 读取状态寄存器,判断WIP位 | 必须轮询,确保上一次写/擦完成 |
| PAGE PROGRAM | 0x02 | 以页为单位写入数据 | 单次最多256字节 |
| SECTOR ERASE | 0x20 | 擦除4KB扇区,全部置0xFF | 写数据前必须先擦除 |
| BLOCK ERASE | 0xD8 | 擦除64KB块 | 大容量擦除时比扇区擦除快 |
状态寄存器重点关注两个位:Bit0是WIP(Write In Progress),为1表示Flash正在执行内部擦除或编程操作,此时不响应其他指令;Bit1是WEL(Write Enable Latch),写完0x06之后该位变1,表示允许执行写/擦除指令。工程里最稳妥的流程是:每次发指令之前先发0x06,然后等WIP为0。
MT25QL不同工艺版本的JEDEC ID字节定义稍有差异,但通常第一个字节是厂商ID 0x20(Micron),第二个字节是Memory Type 0xBA,第三个字节代表容量。比如MT25QL128的容量字节是0x18,MT25QL256可能是0x19,具体以datasheet为准。上板时用这个ID做回读,如果读到0x20开头,基本可以判定SPI底层的收发通路是好的。
2.3 关键时序:写入和擦除到底要多久
NOR Flash的写入和擦除是慢操作,这和SRAM、DDR完全是两个物种。理解时序参数是状态机设计的前提。以MT25QL128ABA为例,几个关键参数据我实测和datasheet对照如下:
- 页编程(256字节):典型0.3ms,最大3ms
- 扇区擦除(4KB):典型90ms,最大400ms
- 块擦除(64KB):典型0.4s,最大2.5s
这就引出一个设计原则:FPGA发完PAGE PROGRAM或SECTOR ERASE指令后,必须持续轮询状态寄存器的WIP位,直到WIP清零才能进行下一步操作。绝对不能靠固定延时简单凑合,因为Flash的擦除时间随温度和芯片批次差异极大,固定延时太短会出错,太长会拖慢整体性能。
轮询WIP的标准做法是:发0x05指令,接8个SCK时钟,从DO线上读回状态寄存器,检查Bit0。如果为1,拉高CS_N、再拉低CS_N,重新发送0x05,循环。发送0x05读取状态寄存器不需要先发WRITE ENABLE,它任何时候都可以执行。芯片进入深度掉电模式需要先发送WAKE UP(0xAB)指令才能恢复,我用了一个示例来留作扩展提醒。
3. 核心模块Verilog实现
3.1 SPI主控制器:字节级收发基础
spi_master模块是整个工程的地基。它的工作核心是:根据分频系数产生SCK,在SCK下降沿更新发送数据,在SCK上升沿采样接收数据。每次传输一个字节,需要8个SCK周期。
模块接口设计如下:
module spi_master #( parameter CLK_FREQ = 50_000_000, // 系统时钟频率 parameter SPI_FREQ = 10_000_000 // SPI时钟频率,MT25QL单线模式建议不超过50MHz )( input wire clk, input wire rst_n, input wire start, // 启动一次单字节传输 input wire [7:0] tx_data, // 要发送的数据 output reg [7:0] rx_data, // 接收到的数据 output wire done, // 传输完成标志 // SPI物理接口 output wire sck_out, output reg cs_n_out, // 片选控制由上层逻辑接管 output reg mosi_out, input wire miso_in );这里需要注意一个细节:cs_n_out为什么由上层控制而不是模块内部自动拉低拉高?因为Flash指令的传输长度不同,比如READ ID是1个命令字节加3个字节回读,需要连续传输4个字节,片选必须在这4个字节期间全程保持低电平。如果由底层模块每传一个字节就拉高拉低一次片选,那指令就被切碎了,Flash会认为传输中断,返回的数据全是无效的。因此,片选信号由flash_ctrl应用层统一控制,底层模块只负责纯字节收发。
SCK分频的计算方式如下:SPI_FREQ = SPI时钟频率,分频系数 = CLK_FREQ / (2 × SPI_FREQ)。工程例化时如果系统时钟50MHz、SPI时钟10MHz,分频系数就是2.5,需要向上取整为3,也就是每6个系统时钟产生1个SCK周期,实际SCK约8.33MHz,在Flash允许的范围内。
3.2 Flash应用层状态机设计
flash_ctrl模块是工程的核心状态机,负责把底层字节传输组合成真正的Flash操作。状态机划分如下:
IDLE:空闲状态,等待上位机触发指令SEND_CMD:发送指令字节(如0x9F、0x02、0x20)SEND_ADDR:发送3字节地址,高位在前,按地址从高字节到低字节排列WRITE_DATA:逐字节发送页数据,每发送一个字节检查是否到底WAIT_WIP:等待Flash内部操作完成,轮询状态寄存器READ_DATA:读取数据,每读一个字节保存到FIFO或寄存器DONE:完成标志,拉高一个周期的脉冲
页编程状态机的关键代码如下,我做了简化但保留了核心思路:
// 以PAGE PROGRAM为例的简化状态机片段 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; byte_cnt <= 0; end else begin case (state) IDLE: begin if (start_write) begin // 先发写使能,然后在使能完成后进入SEND_CMD spi_cmd <= 8'h06; // WRITE ENABLE state <= ISSUE_WE; end end ISSUE_WE: begin if (spi_done) state <= SEND_CMD; end SEND_CMD: begin // 发送0x02,随后依次发送addr[23:16]、addr[15:8]、addr[7:0] if (byte_cnt == 3) begin byte_cnt <= 0; state <= WRITE_DATA; end end WRITE_DATA: begin // 每拍写入一字节,建议在最后字节时触发spi_start if (byte_cnt == PAGE_SIZE) state <= WAIT_WIP; end WAIT_WIP: begin // 读状态寄存器,判断Bit0是否清零 // 未清零则继续轮询,清零则去DONE end DONE: begin state <= IDLE; end endcase end end这个状态机最大的特点是:任何写、擦操作都循着“先发写使能→再发命令→轮询WIP→结束”这个固定模板。READ操作不涉及写使能,直接发命令和地址就能读数据。建议大家在实现时把状态机模板抽象出来,每条指令复用相同的状态逻辑,只替换命令字和数据处理分支,这样代码密度会大幅降低,结构也更清晰。
另外,页编程有一个必须注意的边界:一次PAGE PROGRAM最多写入256字节,而且不能跨页写。如果起始地址是0x0105,那么这一页剩下的字节数只有0xFF - 0x05 + 1 = 251字节,超过251字节就写到下一页去了。MT25QL对跨页写有两种处理策略,但工程上最安全的做法是从应用层严格切分,每次写前计算当前地址在页内偏移,确保单次编程不超过页边界。
3.3 顶层例化与仿真验证
顶层模块的工作就是把spi_master和flash_ctrl接好,同时做引脚约束。例化时注意以下事项:
spi_master的SPI_FREQ参数不要超过Flash单线模式的最高时钟,保守起见设为10MHz,足够应付绝大多数应用场景。flash_ctrl的地址位宽要针对MT25QL128设置24位,如果换到1Gb容量(128MB)则地址需要扩展到32位。- 在Vivado里需要在XDC约束文件中正确分配引脚,注意引脚所在的BANK电压必须是1.8V或3.3V(取决于板卡上Flash的实际供电),否则上电后引脚电平不匹配,数据读回来全是乱码。
仿真建议用ModelSim或Vivado自带的XSim,搭建一个简单的testbench,把Flash的SPI回环模型替换为理想模型(或者直接用逻辑分析仪抓仿真波形)。先跑一个最简单的场景:发起一次READ ID,检查回读的0x20、0xBA、0x18是否出现在DO线上。如果仿真能过,说明SPI层时序逻辑基本正确,再上板验证不至于瞎子摸象。
4. 板级实操与验证流程
4.1 硬件连接与初始化检查
上板之前,先做三件基本功检查,别急着烧bitstream,否则出了问题很难区分是逻辑问题还是硬件问题。
第一,确认Flash的供电电压。MT25QL支持3.3V VCC,部分工业级版本还支持1.8V,具体看芯片丝印的后缀。FPGA引脚所在的BANK电压必须与之匹配。之前我踩过一次坑,板卡上Flash是3.3V供电,但FPGA BANK设成了1.8V,结果SPI时钟、片选这些信号电平都不对,每次读ID都返回0x00。
第二,确认CS_N引脚在空闲状态被拉高。可以用万用表量一下FPGA引脚在未配置时的状态,如果FPGA的IO默认是下拉,而Flash的CS_N又没有外部上拉电阻,上电瞬间可能导致Flash误进入编程模式。工程里建议在XDC中将CS_N引脚设置为内置上拉(PULLUP),保险起见板级最好还有一颗10kΩ上拉。
第三,确认地线连接。这个说起来很基础,但SPI高速翻转时,如果FPGA和Flash的地电位不一致,MISO线上会出现严重的噪声干扰,轻则数据移位,重则整整比DDR内存条线的小身板都难过。
4.2 完整读写回环测试步骤
上板验证建议按以下顺序一步步跑,每一步都确认通过后再进入下一步:
- 读ID测试:烧写完工程后,通过UART或LED观察READ ID返回的数据。如果正确读到0x20、0xBA、0x18,说明SPI链路、片选控制、时钟极性全部正确,这时候可以认为底层已经通了。
- 擦除扇区测试:选择一个不重要的扇区(比如地址0x000000开始的4KB扇区),发SECTOR ERASE 0x20命令。擦除后,从该扇区首地址连读4KB,确认数据全部是0xFF。
- 页编程测试:向擦除后的扇区写入一页256字节的测试数据,建议使用递增序列(0x00到0xFF)而非全0或全1,这样万一有地址错位或位翻转,对比时能一眼看出来。
- 读回比较:再读取该页数据,和写入数据进行逐字节比对。如果全部匹配,说明写路径和读路径都正确。
- 跨页测试:从页尾地址(比如0x0000FF)写入一页数据,验证应用层是否正确处理了跨页切分逻辑。
- 掉电保持测试:完整写入一批数据后,断电重新上电,再次读取数据,确认数据没有丢失。
这里有一个非常重要的实测心得:测试数据一定不要用全0x00或全0xFF。因为Flash擦除后是0xFF,如果写全0xFF,读回来也是0xFF,根本分辨不出是“写成功了”还是“压根没写”。用递增序列或者伪随机序列,才能真实反映数据通路的正确性。
4.3 使用ILA抓取时序的调试方法
如果回环测试失败,最直接的定位手段是Vivado的ILA(Integrated Logic Analyzer)核。挂上ILA后,抓取cs_n_out、sck_out、mosi_out、miso_in这四根线,以及状态机的state信号,触发条件设为start_write上升沿。
抓到数据后重点看三个方面:
一是看片选时序。一个完整的指令过程,CS_N必须从指令开始一直拉低到最后一个比特结束,期间不能出现毛刺。如果在指令中间CS_N意外跳高了,Flash会立即终止当前传输。
二是看SCK与MOSI的相位关系。SPI Mode 0下,SCK空闲为低,MOSI数据必须在SCK上升沿前稳定。如果波形里看到MOSI在上升沿附近才跳变,说明发送数据的更新时刻偏晚了,需要检查spi_master里发送数据是在SCK的哪个沿更新的。
三是看MISO采样窗口。读数据时,FPGA在SCK上升沿采样MISO。如果MISO在上升沿附近有回踢噪声或翻转太慢,说明前面分析时序或走线存在问题,可能需要降低SPI的通信速率,或者给MISO信号加一个简单的一级寄存器打拍,消除亚稳态风险。
ILA采样深度建议设置到4096以上,因为页编程整个流程会产生大量跳变,太浅抓不住完整过程。触发位置设为“middle”,可以同时看到触发前的命令发送过程和触发后的WIP轮询过程。
5. 常见问题速查与避坑指南
5.1 典型问题与排查思路
根据我调试这个工程的经验,把最容易碰到的几个问题整理成了一张排查表,方便大家在现场快速定位:
| 现象 | 可能原因 | 定位方法 | 解决办法 |
|---|---|---|---|
| 读ID全FF | CS_N极性接反或未拉低 | 用ILA抓CS_N波形 | 检查CS_N逻辑,确认低有效 |
| 读ID全00 | FPGA IO电压与Flash不匹配 | 万用表量VCC和IO电平 | 调整BANK电压至匹配值 |
| 读ID正确,写入后读回全FF | 忘记发WRITE ENABLE | 查看波形确认0x06是否发送 | 在擦除/编程前补发0x06 |
| 写入后读回部分正确部分错误 | 跨页写入未处理 | 检查写入地址和页边界 | 应用层计算页内剩余字节并拆分 |
| 擦除后读回不是全FF | BLOCK ERASE和SECTOR ERASE搞混 | 核对命令字 | 0x20是4KB扇区擦除,0xD8是64KB块擦除 |
| 数据总是移位错位 | SPI采样沿不对 | ILA看SCK和MISO相位 | 确认Mode 0,上升沿采样 |
| 状态机卡在WAIT_WIP不动 | Flash已进入保护状态或命令未生效 | 波形分析 | 检查WP引脚和状态寄存器其他位 |
5.2 我踩过的坑和总结的经验
第一个坑是写保护。MT25QL有WP#引脚和状态寄存器里的块保护位(BP0-BP3),默认情况下BP位为0,没有启用保护,这在绝大多数新片子是安全的。但有些板载Flash在出厂时可能被配置了保护,或者上一个程序改过状态寄存器,导致你发PAGE PROGRAM后WIP一直为1,状态机卡死。遇到这种情况排查思路要拓宽:先发WRITE STATUS(0x01)把状态寄存器配置为0x00,解除块保护,再执行擦写。
第二个坑是上电后Flash状态不确定。MT25QL上电后可能处于标准SPI模式,也可能因为之前的供电时序问题,进入了一个非预期的状态。上电后建议先发送一条WRITE DISABLE(0x04)或在读ID之前多发几条0x06,让Flash的状态机恢复到已知状态,再执行正式操作。
第三个坑是仿真环境里忽略时序延时。很多初学者在仿真时,把Flash模型换成理想模型,WIP位在仿真里瞬间清零,导致状态机设计时低估了真实芯片的擦除等待时间。真实芯片上,擦除一个扇区可能需要几百毫秒,而且温度越低擦除越慢。因此,我强烈建议在实际调试时,预留足够长的WIP超时阈值(比如1000ms),并用一个计数器在轮询超过阈值后报错,避免死等。
第四个坑比较隐晦,是关于FPGA引脚推挽输出的。SPI的MOSI、SCK这两个输出信号在FPGA中如果用默认的推挽输出,本身没有问题。但MISO是输入信号,如果板级设计没有做串联匹配电阻,在长走线下会出现过冲,采样到错误的电平。如果读数据偶尔出错、复位后又正常,优先怀疑信号完整性,可以尝试在MISO的FPGA输入引脚上设置内部延迟单元(IDELAY),或者降低SPI速率以换取稳定。
第五个心得是关于命令字节的拼接顺序。MT25QL这类SPI NOR Flash指令长度不固定,同样是指令,有的需要1字节命令外加3字节地址,有的只需要命令无地址。我见过不少人在状态机里把地址字节顺序搞反,先发低字节后发高字节,导致读写地址偏移。Flash地址发送顺序是高位先行,即addr[23:16]、addr[15:8]、addr[7:0],这一点务必记牢。
第六个经验是工程落地时建议加上CRC校验或简单和校验。虽然这个示例工程验证阶段只用逐字节比较,但实际产品中数据从Flash读回后,最好通过CRC32或和校验来做完整性验证。特别是做固件升级场景时,Flash中的数据损坏可能是致命的。我后来在这颗MT25QL工程上增加了一个简单的异或校验模块,写入时计算校验值,读取时重新计算,不匹配就直接报错并重擦重写,在产线测试里显著提升了问题定位效率。
另外,关于时钟频率,很多人一开始就把SPI拉到50MHz甚至更高,结果调试时各种问题叠加,很难定位。我的建议是:先把SPI降到5MHz甚至1MHz跑通整个流程,确认逻辑正确后再逐步提速。FPGA和Flash之间的信号完整性问题,往往在高频下才会暴露,但绝大多数应用场景,单线SPI 10MHz到20MHz已经足够,没必要为了跑速度给自己找麻烦。
最后再分享一个小技巧:在正常读写验证通过后,可以把写入的数据换成随机数,连续写多个扇区再做完整读回比较,这个压力测试能发现一些边角情况下的地址错位问题。我通常在验收工程前会跑一轮全地址空间的“写-读-比”循环,发现过两次页边界处理不严谨导致的偶发错误,这类问题在只做单页测试时是根本复现不出来的。
这个工程的后续扩展方向也很明确:一是将单线模式扩展为Quad SPI,读写速率可以翻几倍;二是增加Flash坏块管理逻辑,把存储区域划分为数据区和备份区;三是对接FIFO或DMA接口,让Flash读写不占用CPU(这里指FPGA内部软核处理器)时间。如果你正在做类似的存储控制器,建议先把单线模式的读写流程吃透,再逐步扩展,底子打牢了后面就快很多了。
本文还有配套的精品资源,点击获取
