ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

FPGA驱动SPI Flash全流程:从Verilog设计到上板固化

FPGA驱动SPI Flash全流程:从Verilog设计到上板固化 如果你手里正拿着一块“野火FPGA征途Pro”开发板我猜你大概率跟我当初一样看着板上那颗SPI Flash芯片有点“无从下手”——原理图上明明画着代码却不知道怎么编搜了一圈全是STM32的例程FPGA版的要么太老要么干脆跑不通。我折腾这块板子前后花了一个多星期踩了不少坑最后把SPI Flash驱动从读ID到擦除、写入、固化配置全流程跑通之后回头再看发现这件事其实非常适合作为FPGA进阶的第一个完整外设项目它同时牵扯状态机设计、时序约束、仿真验证和上板调试而且只要把一颗SPI Flash调明白后面I2C、UART、SD卡这些外设驱动几乎都是同一套思路。这篇内容我就按自己实际操作的顺序来写不绕圈子重点放在驱动模块的Verilog设计、时序分析和上板验证上。代码示例可以直接抄走改一改电路连接部分我会结合征途Pro常见板载Flash型号给出对照关系也会把最容易让人翻车的几个细节单独拉出来说清楚。适合正在用野火征途Pro学习FPGA、卡在外设驱动这一步的同学参考。1. 动手之前先弄明白这颗SPI Flash在板卡上扮演什么角色1.1 板载Flash的两种用途配置存储和通用存储征途Pro开发板上那颗SPI Flash好多初学者会有个误区以为它只是给FPGA存配置数据的跟用户逻辑关系不大。实际跑起来你会发现它有两种典型用途。第一种就是掉电配置存储。FPGA是SRAM工艺每次上电配置数据就没了需要从外部非易失存储器读取bitstream。这块板子上的SPI Flash在出厂时就承担了配置镜像的存储功能上电后FPGA通过AS模式主动把配置数据加载进内部RAM。这种情况Flash访问是自动完成的不需要你写Verilog。第二种才是我们这次的核心——把Flash当成一颗普通的串行存储芯片来用。你可以用它存传感器校准参数、存字库、存音频数据甚至做一个小型文件系统。要在用户逻辑里实现这个功能就需要自己用Verilog写SPI主机控制器然后再封装一套Flash读写命令。这个过程的本质就是用状态机去模拟一颗SPI从设备芯片的时序要求跟MCU上用硬件SPI外设完全不同所有时序都得靠逻辑自己“抠”出来。1.2 硬件连接与开发环境先把引脚关系理清征途Pro主控芯片是IntelAlteraCyclone 10 LP系列的FPGA板载Flash常见型号是Winbond W25Q16或W25Q64这类NOR Flash支持标准SPI/Dual/Quad模式。驱动开发阶段我建议只用标准SPI四线模式SCK、CS_N、MOSI、MISO先把时序打通再考虑Dual/Quad提速。硬件连接方面Flash的这几个信号在板子上通常直接连到FPGA的普通IO或者专用配置引脚上。我用逻辑分析仪确认过的思路是SCK和DCLK、CS_N和nCSO、MOSI和ASDO、MISO和DATA0在某些复用模式下可能通过板上的拨码开关或者跳线切换访问方式。换句话说调试过程中如果发现Flash完全没有响应先别急着查代码先去翻板子原理图确认当前拨码开关的状态允许FPGA用户逻辑访问Flash引脚而不是停留在“配置模式”占用中。开发环境我用的是Quartus Prime Lite 20.1仿真工具是ModelSim Starter调试工具直接用Quartus集成的SignalTap逻辑分析仪。编译之前务必把工程默认语言改成Verilog HDL新建文件时选择Verilog模板很多入门时“代码明明没写错但编译报一堆莫名其妙的错”往往就是工程创建时语言选项选成VHDL了。2. SPI协议基础与Flash指令集写代码前必须吃透的几件事2.1 SPI四种模式怎么选为什么我们都用Mode 0SPI总线有四种时序模式区别在于时钟极性和相位。对应到具体参数上就是SCK空闲电平和数据采样沿。Flash厂商的数据手册里写得清清楚楚W25Q系列在使用SPI模式时推荐CPOL0、CPHA0也就是Mode 0。Mode 0的含义是SCK空闲时低电平数据在SCK上升沿被主机采样在下降沿输出。不过这里有个容易搞混的地方主机的“输出”对应从机的“输入”主机在下降沿更新MOSI从机在紧接着的上升沿锁存反过来从机在下降沿更新MISO主机在上升沿采样。所以Verilog代码里经常出现“下降沿打数据、上升沿收数据”这种写法本质上是保证SCK上升沿到来时数据线已经稳定。为什么首选Mode 0而不是Mode 3因为Mode 0和Mode 3的区别只在空闲态电平大多数Flash芯片两种都支持但Mode 0是所有SPI设备默认兼容的模式后续想在上面挂SD卡、OLED、传感器时这套主机控制器还能直接复用。我一开始自作聪明选了Mode 3结果换了一颗器件就翻车后来老老实实统一改回Mode 0。2.2 W25Q系列核心指令读ID、读数据、擦除、页编程Flash的控制核心是命令字任何操作都是主机先拉低CS_N发送命令字节再跟地址或者数据最后拉高CS_N结束一次操作。W25Q系列最常用到的几条指令我整理成了一张表调试时对照着看非常省事。指令名称指令码说明Write Enable0x06写使能写任何数据前必须先发这条Read JEDEC ID0x9F读取3字节厂商/型号/容量信息Read Status Register-10x05读取状态寄存器bit0是忙标志Read Data0x03从指定地址连续读取数据Sector Erase0x20擦除一个4KB扇区擦完读回全0xFFBlock Erase0x52 / 0xD8擦除32KB/64KB块按需选用Page Program0x02向页内写入数据最大256字节这里要特别说一下“写使能”这件事。NOR Flash有个保护机制任何写操作包括擦除之前必须发送0x06把状态寄存器里的WEL位拉高否则后续命令会被芯片直接忽略。我调试时第一次写Flash发现读回全是0xFF排查了半天最后才发现是忘了发0x06。这个细节在datasheet里写得非常明确但真到写代码时特别容易漏。2.3 状态寄存器与忙等待机制为什么擦除后不能立刻写数据Flash的擦除和编程操作需要时间W25Q16的扇区擦除典型时间是45ms页编程典型时间0.4ms。这段时间内芯片内部在干活对外部命令一概忽略状态寄存器bit0的WIPWrite In Progress位会保持高电平。所以驱动里完成一次擦除或编程操作后不能马上进行下一次操作而是要循环发送0x05读取状态寄存器检查WIP位是否变低。这才是规范的“忙等待”机制。我实际测试的时候用固定延时等待确实也能跑但有两个麻烦一是时间不好把控延时短了Flash没干完活延时长了拖慢整体速度二是FPGA工程里同步逻辑的延时计数器本身也要占资源、增加代码复杂度。所以还是老老实实写一个状态机轮询WIP位Flash什么时候干完活程序什么时候继续往下走这才是可复用的写法。3. Verilog驱动核心模块设计与代码解析3.1 模块划分从SPI Master到Flash控制器驱动代码整体上可以分为两层。底层是SPI主机控制器负责把并行的字节数据转换成串行时序发送到Flash同时接收从机返回的数据上层是Flash控制器它根据Flash指令集组织命令序列、管理状态机并把用户接口简化成“地址、数据、读/写请求”这种形式。我在征途Pro上用的是三模块结构spi_master最底层只做字节级收发提供tx_data、rx_data、start、done接口。flash_ctrl中间层解析用户操作请求生成完整的Flash指令序列读ID、读数据、擦除、页编程内部包含指令状态机和忙等待状态。top顶层层模块接按键、LED、串口或者其他调试接口把Flash功能演示出来。这样分层的最大好处是解耦。SPI Master写完一次后面做SD卡、OLED、EEPROM都能复用Flash控制逻辑和具体时序线分离后续要改Flash型号只需要检查指令表和状态寄存器定义是否一致。3.2 SPI主机控制器代码逐段解析SPI Master是整套驱动的地基。代码不需要多复杂但要保证时序准确SCK频率不能超过Flash额定值W25Q系列标准SPI模式最高支持50MHz我控制在2MHz左右信号质量非常稳定每个字节按“MSB先行”方式发送。下面是一个精简但完整的SPI主机控制器实现分频系数为10系统时钟50MHz时SCK约2.5MHzmodule spi_master #( parameter DIV 10 )( input wire clk, input wire rst_n, input wire start, input wire [7:0] tx_data, input wire miso, output reg [7:0] rx_data, output reg sclk, output reg mosi, output reg done ); localparam IDLE 2d0; localparam TX 2d1; reg [1:0] state; reg [7:0] bit_cnt; reg [31:0] clk_cnt; reg [7:0] tx_reg; reg [7:0] rx_reg; reg sclk_clk; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; sclk 1b0; mosi 1b0; done 1b0; bit_cnt 8d0; clk_cnt 32d0; end else case (state) IDLE: begin sclk 1b0; done 1b0; if (start) begin tx_reg tx_data; bit_cnt 8d8; clk_cnt 32d0; sclk_clk 1b0; state TX; end end TX: begin if (sclk_clk 1b0) begin // 产生SCK低半周期并在输出数据 if (clk_cnt (DIV/2 - 1)) begin clk_cnt 32d0; sclk_clk 1b1; sclk 1b1; // 上升沿之前更新MOSI避免建立时间不足 mosi tx_reg[7]; tx_reg {tx_reg[6:0], 1b0}; end else begin clk_cnt clk_cnt 32d1; end end else begin if (clk_cnt (DIV/2 - 1)) begin clk_cnt 32d0; sclk_clk 1b0; sclk 1b0; // SCK高电平期间采样MISO存入rx_reg rx_reg {rx_reg[6:0], miso}; bit_cnt bit_cnt - 8d1; if (bit_cnt 8d1) begin state IDLE; rx_data {rx_reg[6:0], miso}; done 1b1; end end else begin clk_cnt clk_cnt 32d1; end end end default: state IDLE; endcase end endmodule这段代码有两个关键点。第一个是MOSI数据的变化必须发生在SCK低电平期间这样等SCK上升沿到来时数据早已稳定从机采样的可靠性才高第二个是MISO的采样放在SCK高电平中间位置直接避开边沿抖动区间。实际测试中我当时用这段Master在2MHz到10MHz的SCK频率下连续读Flash数据都没有出现误码。3.3 读ID状态机先把Flash“认出来”SPI Master做完第一个要写的Flash控制功能就是读JEDEC ID。这个功能最简单但意义很大ID读出来了说明SPI底层时序、引脚连接、控制器代码全都没问题后面的操作才有底气往下走。读ID的命令序列是拉低CS_N发送0x9F然后连续接收3个字节厂商ID、类型ID、容量ID最后拉高CS_N。在FPGA里实现这个序列我建议用一个独立的只读状态机localparam IDLE_C 4d0; localparam CMD 4d1; localparam DUM1 4d2; localparam DUM2 4d3; localparam DONE_C 4d4; reg [3:0] state; reg [7:0] cmd_buf; always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE_C; else case (state) IDLE_C: begin cs_n 1b1; if (read_id_req) begin cs_n 1b0; // 拉低片选开始操作 cmd_buf 8h9F; // JEDEC ID指令 spi_start 1b1; tx_data 8h9F; state CMD; end end CMD: begin if (spi_done) begin spi_start 1b0; tx_data 8h00; spi_start 1b1; // 继续发送/接收第1个ID字节 state DUM1; end end DUM1: begin if (spi_done) begin id_byte0 rx_data; spi_start 1b1; tx_data 8h00; state DUM2; end end DUM2: begin if (spi_done) begin id_byte1 rx_data; id_byte2 spi_miso_last; // 实际工程中改成再收一个字节 state DONE_C; end end DONE_C: begin cs_n 1b1; // 拉高片选结束操作 read_id_done 1b1; state IDLE_C; end endcase end如果手上是Winbond W25Q16读出来的ID应该是0xEF、0x40、0x15W25Q64是0xEF、0x40、0x17W25Q128是0xEF、0x40、0x18。如果ID完全不对甚至全为0xFF就按第5章的排查思路逐个环节查。3.4 读数据、扇区擦除和页编程的实现要点读数据指令0x03后面跟3字节地址再跟若干空闲字节SCK每来8个时钟Flash输出一个数据字节。这个操作很简单唯一需要注意的是地址是“高字节在前”发送顺序是A23-A16、A15-A8、A7-A0。页编程0x02则复杂一些先发0x06写使能再发0x02、3字节地址、要写入的数据最多256字节连续最后一字节发送完成后拉高CS_N。这里有个经典大坑——页编程不能跨页。所谓“跨页”就是如果你从一个页的接近末尾地址开始写写完剩余字节后数据会绕回到这个页的开头继续覆盖而不是自动写到下一页。实际应用中如果数据长度超过页边界就必须分多次页编程每次计算好当前页剩余字节数。扇区擦除0x20也是类似命令序列是0x06、0x20、3字节地址然后把CS_N拉高触发内部擦除。擦除完成后整个4KB扇区读回全是0xFF。这三个操作的完整状态机展开写太长了我这里只给一个可复用的状态跳转骨架具体代码在工程里实现时可以按这个骨架扩展localparam S_IDLE 4d0; localparam S_WREN 4d1; // 发送0x06 localparam S_CMD 4d2; // 发送擦除/写命令 localparam S_ADDR 4d3; // 发送3字节地址 localparam S_DATA 4d4; // 写数据/读数据阶段 localparam S_WAIT 4d5; // 轮询状态寄存器WIP位 localparam S_DONE 4d6;每次操作进入S_WAIT时通过0x05指令不断读取状态寄存器直到WIP位清零再跳到S_DONE然后回到S_IDLE等待下一次请求。整个Flash控制器对外只暴露一组读写接口信号地址、写数据、读请求、写请求、忙标志、完成标志。4. 工程落地从Quartus建工程到上板验证4.1 工程配置与引脚约束编译前一定要做的两件事在Quartus里新建工程时器件型号要跟板子上的FPGA一致。征途Pro用的是Cyclone 10 LP系列我手上的型号是10CL025YU256C8G不同批次可能有差异新建工程时选对应封装和速度等级就行。建完工程、添加完三个模块文件之后第一件事是把顶层模块名设置正确。Quartus默认以工程名作为顶层实体名如果顶层模块名和工程名不一致编译就直接报错。第二件事是引脚约束。打开Pin Planner把代码里的clk、rst_n、spi_sclk、spi_cs_n、spi_mosi、spi_miso分配到板卡原理图对应的引脚上。这里有个非常容易踩的坑复位引脚和按键、LED的引脚方向定义一定要看清。FPGA的按键通常是按下为低电平所以外部复位信号是低有效rst_n刚好匹配但如果你用时钟板上自带的PLL分频需要注意PLL的locked信号可以作为复位释放信号否则FPGA上电后亚稳态问题会增加。脚约束完成后一定要重新编译一遍让Quartus生成最终的bitstream文件。寄到FPGA之前先在Pin Planner里确认所有引脚分配没有冲突特别是SPI菊花链或JTAG相关引脚别占了配置用的专属引脚。4.2 上板调试SignalTap一开时序问题无处可藏上板调试的第一步不是直接下载.sof然后跑功能而是先搭一个简单地自测把读到的Flash ID送到板上的LED显示比如ID读对了就点亮一个LED读错了就闪烁。这样不需要串口和上位机就能快速验证SPI底层。不过更推荐的做法是直接上SignalTap逻辑分析仪。SignalTap是Quartus自带的在线调试工具可以把内部信号实时抓出来看波形对SPI这类时序非常有用。我在调试时添加了sclk、cs_n、mosi、miso、state、spi_done这几个信号采用深度4096、采样时钟用系统时钟。用SignalTap抓波形时有两点体会很深。第一触发条件尽量用“下降沿”或者“CS_N拉低”作为触发源这样能完整捕获一次完整的指令序列第二如果代码里信号名被综合优化掉了需要在综合设置里把“保持层次”打开或者给信号加(* keep true *)约束否则SignalTap里找不到这些内部节点。有一次我读ID老是失败用SignalTap一看才发现SCK的周期乱了——SPI Master里的分频计数器在连续收发时被意外清零了一次导致SCK波形出现一个不正常的短脉冲。这种问题纯靠代码review很难发现但波形上一眼就看出来了。4.3 把编译好的FPGA配置固化到SPI Flash跑通SPI Flash读写之后顺带可以把“用FPGA控制SPI Flash做配置固化”这个流程也走一遍。很多人分不清.sof和.jic的关系.sof是FPGA直接加载的SRAM配置数据掉电就没了.jic是给SPI Flash用的烧录镜像通过Quartus的Convert Programming File工具把.sof转成.jic然后烧录到Flash里之后板子一上电FPGA就会自动从Flash引导。转换流程很简单File - Convert Programming File选择器件系列和配置模式然后添加SOF文件输出格式选择JIC再指定Flash型号。烧录时用Quartus Programmer硬件选择USB-Blaster模式选择JTAG加载JIC文件后点击Program。这里要提醒一句如果板载SPI Flash刚好是你正在调试的那颗烧录配置会直接覆盖Flash里的已有内容。所以上电烧录前一定把该备份的数据备份好比如把原厂出厂程序先读出来存一份。万一以后想恢复出厂状态直接用保存的.jic或者原始配置文件烧回去就行。5. 常见问题排查与避坑经验5.1 芯片ID读不出来最可能的三个原因第一个原因是引脚没接对。这种情况在我带过的新手里占了差不多一半原理图上SCK和DCLK、CS_N和nCSO看起来一样但FPGA内部访问路径完全不同引脚分配错一个波形肯定不对。第二个原因是SCK频率太高。虽然Flash标称支持50MHz但如果杜邦线或者PCB走线质量一般再加上FPGA内部时钟树延迟SCK上沿毛刺会非常严重。我实测在部分飞线上10MHz就开始出现偶发读错ID降到2-5MHz就完全稳定了。初学者一定不要迷信标称频率先把低速调通再逐步提速。第三个原因是上电后FPGA还没配置完成。如果你用普通IO模拟访问Flash而Flash正被配置逻辑占用CS_N控制不了读出来的数据自然是乱的。解决办法是把拨码或跳线切到用户逻辑可控的模式或者用SignalTap看CS_N是否真的被拉低。5.2 数据读写错位、丢字节怎么办读写错位这个问题大概率出在SPI Master采样时序上。最简单的排查方法是用SignalTap抓指令序列对着Flash datasheet的时序图一比特一比特核对命令字节发送在CS_N拉低的第一个SCK周期地址字节顺序是高位先发读数据的第一个输出字节是地址之后的首个字节。如果是读数据连续读很多字节时出现错位基本可以确定是SCK高低电平计数不对称导致。比如DIV10按低半周期5拍、高半周期5拍来分频但如果代码里只用“等于DIV/2”作为翻转条件而计数器初始值不同就容易出现半个SCK周期变长或变短。这类bug层面问题波形上一抓一个准。还有一个小概率原因是miso和mosi接反。FPGA的引脚命名和Flash的命名在一些板卡上画得比较随意MISO和MOSI一旦交换发送正常但接收永远是全1。这时候用万用表顺着原理图量一下走线确认引脚定义是不是反了。5.3 擦除后读回全F写不进去是什么问题写不进去最常见的原因是忘了发写使能0x06。W25Q系列为了保证数据安全上电默认状态是写禁止只有发了0x06并把状态寄存器WEL位置1之后擦除和页编程才有效。检查方法很简单发0x06之后马上读状态寄存器看bit1是不是1如果还是0那说明命令没发出去或者片选时序有问题。第二个原因是页编程数据越过了页边界。前面已经提过页编程不能自动跨页很多人写了连续500字节结果超过256字节边界后数据全部写到了页开头把前面写的覆盖得乱七八糟。代码里在页编程前要计算“当前页剩余字节数”数据超出就拆分多次页编程。第三个原因是地址计算错误。W25Q16容量是2MB地址范围0x000000到0x1FFFFF如果地址超过这个范围芯片会拒绝执行。FPGA里用外部传入的地址总线时位数一定要匹配比如容量是2MB就需要24位地址少一位就会导致地址截断写错地方。5.4 提速与工程化建议把驱动变成可复用的IP基础功能跑通之后我强烈建议做两件事。第一件是把SPI Master从Flash控制逻辑里彻底摘出来封装成独立的参数化模块分频系数做成parameter数据位宽、CPOL/CPHA也做成可配置项。这样下次用到SD卡、OLED或者其它SPI设备直接例化这个层就行不用再改底层。第二件是在Flash控制器上加缓存FIFO和跨时钟域处理。多数场景下Flash数据要跟其他模块交互比如音频播放模块从Flash读PCM数据如果Flash控制器的读数据和外部模块的时钟不在一个频率域就需要FIFO做缓冲。具体做法是向Flash控制器发起读请求返回数据写入FIFO外部模块再从FIFO读走。这个结构在工程里非常实用基本把“外设驱动”和“数据通路”解耦了。另外如果你后面要调的项目涉及到数字信号处理比如滑动窗口滤波、反正切计算这类Verilog里最核心的其实还是状态机资源调度和时序约束能力跟这次SPI Flash驱动本质是相通的。这次把状态机写明白、把时序调稳定很多看似复杂的模块拆开看也是一个一个有序的小状态这部分能力才是通用的。最后聊两句个人体会SPI Flash驱动这个项目卡了我整整三天的时候我当时特别怀疑是不是自己不适合搞FPGA。后来从SignalTap抓波形开始一步步排查才发现大部分时间不是代码逻辑多复杂而是输在了对时序细节的把握上。现在回头给新手带路我基本上都是同一个建议先不要再往下扩展功能把SPI Master的波形在逻辑分析仪上“看熟”把Flash指令状态机跑通这个基本功练扎实了后面做任何外设驱动都会顺畅非常多。
返回列表