1. 为什么要在FPGA里直接读写配置Flash
做过FPGA项目的朋友大概率都遇到过这样一个场景:板子焊好之后,配置Flash里是空的,或者需要在线升级固件,但手头没有专用的下载器,或者产品已经装在设备内部,根本没法插JTAG。这时候如果FPGA逻辑本身就能读写那颗配置Flash,事情就简单多了——上电后FPGA先从Flash加载配置,运行起来之后,逻辑里再通过SPI接口去擦写Flash的其它区域,实现远程升级或者参数存储。
这个思路听起来很美好,但真正动手的时候,第一个拦路虎就是:FPGA的配置引脚在配置完成后,通常被内部逻辑"接管"了,你没法直接拿普通IO去驱动那些专用的配置引脚。Xilinx的7系列和UltraScale系列FPGA提供了一个叫STARTUPE3的原语,专门用来在配置完成后继续访问配置相关的专用引脚,其中就包括SPI Flash的时钟、片选和数据线。Intel这边对应的方案是Remote System Update和ASMI Parallel IP,思路类似但实现方式不同。
我这次的项目背景是一块Kintex-7的板子,配置Flash用的是Micron的N25Q128,128Mbit的NOR Flash,标准SPI接口。需求是在FPGA正常运行期间,能够读取Flash的ID、擦除指定扇区、写入数据、再读回来校验。整个链路走的是STARTUPE3原语加自己写的SPI Master控制器,没有用MicroBlaze软核,也没有用Vivado自带的Flash编程IP,纯RTL实现,资源占用小,时序可控。
这篇文章我会把整个实战过程拆开来讲,包括STARTUPE3原语到底怎么用、SPI时序怎么对齐、Flash的扇区擦除和页写入有什么坑、Vivado里怎么约束这些专用引脚、以及调试过程中遇到的各种报错和排查思路。如果你也在做类似的事情,或者正在被"配置完成后无法访问Flash"这个问题困扰,下面的内容应该能帮你省下不少时间。
2. STARTUPE3原语的核心机制与使用要点
2.1 这个原语到底解决了什么问题
FPGA的配置过程是这样的:上电后,FPGA处于配置模式,内部的配置控制器接管了专用配置引脚(比如SPI Flash的CS、DCLK、DIN、DOUT),从Flash里读取比特流。配置完成后,FPGA进入用户模式,这些专用引脚按理说就"功成身退"了,普通逻辑没法直接驱动它们。
但实际项目中,我们往往需要在用户模式下继续访问配置Flash。比如远程升级时,新固件先写到Flash的备用区域,然后触发一个IPROG命令让FPGA重新配置;又比如把Flash的剩余空间当作非易失存储,存一些校准参数、设备序列号之类的数据。
STARTUPE3就是Xilinx给7系列及更新架构FPGA提供的一个原语,它把配置专用引脚"借"给用户逻辑使用。具体来说,它提供了以下几个关键信号:
- USRCCLKO:用户时钟输出,连接到配置时钟引脚(CCLK)
- USRDONEO:用户DONE信号输出
- USRDONETS:DONE引脚的三态控制
- DI:配置数据输入(从Flash到FPGA)
- DO:配置数据输出(从FPGA到Flash)
- DTS:数据线三态控制
- FCSBO:片选输出
- FCSBTS:片选三态控制
对于SPI Flash的读写,我们主要用到的是USRCCLKO、DO、DI、FCSBO以及对应的三态控制信号。这里有个关键点:STARTUPE3原语必须例化在顶层模块中,而且一个设计里只能例化一次。如果你在多个子模块里都例化,综合会直接报错。
2.2 原语例化的正确姿势
先看一段实际的例化代码,这是我在项目里用的:
STARTUPE3 #( .PROG_USR("FALSE"), .SIM_CCLK_FREQ(10.0) ) STARTUPE3_inst ( .CFGCLK(), .CFGMCLK(), .DI(di_from_flash), .DO(do_to_flash), .DTS(dts_ctrl), .FCSBO(fcsbo_out), .FCSBTS(fcsbts_ctrl), .GSR(), .GTS(), .KEYCLEARB(), .PACK(), .USRCCLKO(usr_cclk), .USRCCLKTS(usr_cclk_ts), .USRDONEO(), .USRDONETS() );几个参数和信号需要重点说明:
PROG_USR参数设为"FALSE",表示不使用用户触发的重配置功能。如果你需要做IPROG远程升级,这个参数要设为"TRUE",并且还要配合ICAP原语一起使用。
SIM_CCLK_FREQ只在仿真时起作用,设置CCLK的频率,单位是MHz。实际硬件上CCLK的频率由STARTUPE3内部逻辑和你的时钟源决定。
USRCCLKTS是CCLK的三态控制,低电平有效。也就是说,当usr_cclk_ts为0时,CCLK输出使能;为1时,CCLK为高阻。这个信号必须由你的逻辑正确控制,否则CCLK一直输出或者一直高阻都会导致Flash通信失败。
FCSBTS同理,控制片选的三态。当fcsbts_ctrl为0时,FCSBO输出使能。
DTS控制数据线DO的三态。当dts_ctrl为0时,DO输出使能;为1时,DO高阻,此时DI可以接收Flash返回的数据。
这里有个很容易踩的坑:DI和DO的方向。在SPI模式下,FPGA的DO连到Flash的SI(或DI),FPGA的DI连到Flash的SO(或DO)。命名上容易搞混,建议对着原理图仔细核对。我第一版就把DI和DO接反了,结果读出来的ID全是0xFF。
2.3 时钟域的处理
STARTUPE3的USRCCLKO需要你提供一个时钟信号。这个时钟的频率决定了SPI的SCK频率。对于N25Q128这类NOR Flash,SPI模式0(CPOL=0,CPHA=0)下最高支持到108MHz,但实际用的时候要考虑板级信号完整性和Flash的访问时间。
我一开始用的是50MHz的系统时钟直接分频到12.5MHz作为SCK,实测读写都正常。后来为了加快升级速度,提到了25MHz,也没问题。但再往上提到50MHz的时候,读ID正常,读数据偶尔出错。用示波器看波形发现SCK的上升沿有振铃,Flash端接收到的时钟质量下降。后来在SCK线上串了一个22欧姆的电阻,问题解决。
所以这里给个经验值:SCK频率在25MHz以下时,一般不需要额外处理;超过25MHz建议在PCB上预留串阻位置,或者降低频率换取稳定性。远程升级场景下,25MHz已经足够快了,128Mbit的Flash全片擦写也就几十秒的事。
另外,USRCCLKO的时钟源最好来自一个全局时钟缓冲(BUFG),不要直接用普通IO或者局部时钟,否则时钟偏斜会很大,SPI时序容易出问题。
3. SPI Master控制器的RTL设计与时序对齐
3.1 状态机设计思路
SPI Master控制器我采用的是经典的四状态机结构:IDLE、CMD、DATA、DONE。但Flash操作和普通SPI设备不太一样,Flash的命令有单字节命令(如读ID、写使能)、带地址的命令(如读数据、页写入、扇区擦除),还有带Dummy周期的快速读命令。所以状态机需要能灵活配置每个阶段的字节数和Dummy周期数。
我的做法是定义一个命令配置寄存器组,包含以下字段:
| 字段 | 位宽 | 说明 |
|---|---|---|
| cmd_byte | 8 | 命令码 |
| addr_bytes | 2 | 地址字节数(0/3/4) |
| dummy_cycles | 4 | Dummy周期数(以SCK周期计) |
| data_bytes | 16 | 数据阶段字节数 |
| direction | 1 | 0=写,1=读 |
这样设计的好处是,上层模块只需要配置这个寄存器组,然后启动传输,状态机自动完成整个SPI帧的发送和接收。对于不同的Flash命令,只需要改配置寄存器的值,不需要改状态机逻辑。
比如读ID命令(0x9F),配置为:cmd_byte=0x9F,addr_bytes=0,dummy_cycles=0,data_bytes=3(读3字节ID),direction=1。扇区擦除命令(0xD8),配置为:cmd_byte=0xD8,addr_bytes=3,dummy_cycles=0,data_bytes=0,direction=0。
3.2 SCK相位与数据采样的对齐
SPI模式0下,数据在SCK的上升沿被采样,在下降沿变化。这意味着:
- 发送数据时:在SCK下降沿更新DO,这样接收端在下一个上升沿能采到稳定的数据。
- 接收数据时:在SCK上升沿采样DI。
我的实现方式是用一个2倍频的时钟来生成SCK和数据。比如系统时钟50MHz,SCK目标25MHz,那么用一个计数器产生一个相位标志:计数器为0时SCK为低,计数器为1时SCK为高。在计数器为0的时刻更新DO,在计数器为1的时刻采样DI。
具体代码片段:
always @(posedge clk) begin if (spi_en) begin case (sck_cnt) 1'b0: begin sck_r <= 1'b0; if (bit_cnt < total_bits) begin do_r <= tx_shift[total_bits-1-bit_cnt]; end end 1'b1: begin sck_r <= 1'b1; rx_shift[total_bits-1-bit_cnt] <= di_sync; bit_cnt <= bit_cnt + 1; end endcase sck_cnt <= ~sck_cnt; end end这里有个细节:di_sync需要先做两级同步,因为DI来自Flash,相对于FPGA内部时钟是异步信号。不做同步的话,亚稳态会导致偶发的位错误。我一开始没加同步器,读出来的数据偶尔有几位翻转,加了之后就没再出现过。
3.3 片选和时序间隔
Flash的片选(CS)在每条命令之间必须拉高至少一个时钟周期,这是Flash内部逻辑复位所需要的。我在状态机的DONE状态之后加了一个IDLE_DELAY状态,强制CS拉高至少4个SCK周期,然后再允许下一条命令。
另外,扇区擦除和页写入之后,Flash需要内部写周期时间。扇区擦除典型值是0.7秒,最大4秒;页写入典型值是0.7毫秒,最大5毫秒。在这段时间内,Flash不响应任何命令,除了读状态寄存器。所以我的控制器里加了一个轮询状态寄存器的逻辑:发送写命令后,不断读状态寄存器的bit0(WIP位),直到WIP变为0,才认为操作完成。
这个轮询逻辑很重要,如果不等WIP清零就发下一条命令,Flash会忽略你的命令,导致数据丢失。我踩过这个坑:连续写两个页,第一页写完后没等WIP清零就发第二页的写命令,结果第二页数据没写进去,读回来全是0xFF。
4. Flash擦写操作的完整流程与参数计算
4.1 读ID和读状态寄存器
读ID是最基础的操作,用来确认SPI通信链路是否正常。N25Q128的读ID命令是0x9F,返回3字节:制造商ID(0x20)、存储类型(0xBA)、容量(0x18)。如果读回来是0xFFFFFF或者0x000000,说明通信有问题。
读状态寄存器命令是0x05,返回1字节。bit0是WIP(Write In Progress),bit1是WEL(Write Enable Latch),bit5是SRWD,bit7是BUSY。擦写操作前需要先发写使能命令(0x06),把WEL置1,然后才能发擦除或写入命令。
这里有个容易忽略的点:写使能命令之后,CS必须拉高,WEL才会生效。如果你在发完0x06之后不拉高CS就直接发擦除命令,WEL不会被置位,擦除命令会被忽略。我一开始就是把写使能和擦除命令连在一起发,中间没有拉高CS,结果擦除一直不成功,查了好久才发现这个问题。
4.2 扇区擦除的地址对齐
N25Q128的扇区大小是64KB,共2048个扇区。擦除命令0xD8需要3字节地址,地址必须是扇区起始地址,也就是低16位为0。如果你给的地址不是扇区对齐的,Flash会忽略这个命令,或者擦除错误的扇区。
我的做法是在上层模块里做一个地址对齐检查:如果传入的地址低16位不为0,就自动向下对齐到扇区起始地址。同时,擦除长度也要按扇区对齐,不足一个扇区的按一个扇区算。
擦除时间方面,N25Q128的64KB扇区擦除典型值是0.7秒,最大4秒。实际测试下来,室温下大约0.8到1.2秒。所以轮询WIP的时候,超时计数器要设得足够大。我用25MHz的SCK,读状态寄存器一次大约需要几十个时钟周期,轮询间隔设为1毫秒,超时设为5秒,足够覆盖最坏情况。
4.3 页写入的数据组织
N25Q128的页大小是256字节。页写入命令0x02,每次最多写256字节,而且不能跨页。如果你要写的数据超过256字节,或者起始地址不是页对齐的,就需要拆分成多次页写入。
拆分逻辑是这样的:假设起始地址是addr,长度是len,那么:
- 计算当前页剩余空间:page_remain = 256 - (addr % 256)
- 本次写入长度 = min(len, page_remain)
- 写入后,addr += 本次长度,len -= 本次长度
- 重复直到len为0
每次页写入之前都要发写使能命令,写入之后要轮询WIP。页写入时间典型值0.7毫秒,最大5毫秒。实际测试下来,256字节写入大约0.5到1毫秒。
这里有个性能优化的点:不要每写一页就轮询一次WIP。可以连续发多页写入命令,每页之间只发写使能,不轮询WIP,等所有页都发完之后再统一轮询。但这样做的前提是Flash内部有足够的缓冲,而且你的SPI控制器能连续发送。N25Q128支持这种操作,但为了简单可靠,我还是每页都轮询了。实际升级128KB的固件,也就多花几百毫秒,可以接受。
4.4 读数据的Dummy周期
普通读命令0x03支持最高50MHz的SCK,不需要Dummy周期。但快速读命令0x0B支持更高的频率,需要在地址之后插入8个Dummy周期。我用的是0x03,25MHz下稳定工作,就没折腾快速读。
读数据的时候,DI上的数据在SCK上升沿被采样。我的控制器里,rx_shift寄存器在bit_cnt达到地址和命令的总位数之后开始移位。这里要注意:读命令的data_bytes要设对,否则状态机会提前结束或者多读。我一般是在上层模块里根据要读的长度动态配置data_bytes。
5. Vivado工程配置与引脚约束实战
5.1 原语的综合与实现设置
STARTUPE3原语在Vivado里是直接支持的,不需要额外的IP核。但有几个综合选项需要注意:
- -flatten_hierarchy:建议设为"none"或者"rebuilt",不要用"full"。因为STARTUPE3必须例化在顶层,如果综合工具把层次打平了,可能会出问题。
- -keep_equivalent_registers:建议设为"true",防止工具把三态控制相关的寄存器优化掉。
- -resource_sharing:建议设为"off",避免工具把SPI控制器的资源和其他逻辑共享。
实现阶段,STARTUPE3相关的路径会被自动识别为专用配置路径,不需要额外的时序约束。但USRCCLKO的输出时钟需要约束,我加了一条:
create_generated_clock -name usr_cclk -source [get_pins STARTUPE3_inst/USRCCLKO] -divide_by 1 [get_pins STARTUPE3_inst/USRCCLKO]这条约束告诉工具,USRCCLKO是一个生成时钟,频率和源时钟一致。如果不加,工具可能会报时序违例,因为默认情况下它不知道这个时钟的频率。
5.2 引脚约束的写法
配置专用引脚在Vivado里是通过CFGBVS和CONFIG_MODE属性来约束的,不需要在XDC里手动指定管脚位置。但如果你用了STARTUPE3,需要在XDC里加上:
set_property CFGBVS VCCO [current_design] set_property CONFIG_VOLTAGE 3.3 [current_design] set_property BITSTREAM.CONFIG.SPI_BUSWIDTH 1 [current_design] set_property BITSTREAM.CONFIG.CONFIGRATE 25 [current_design]CFGBVS设为VCCO,表示配置bank的电压跟随VCCO。CONFIG_VOLTAGE设为3.3,因为我的Flash是3.3V供电。SPI_BUSWIDTH设为1,表示用标准SPI模式,不是Quad SPI。CONFIGRATE设为25,表示配置时钟25MHz。
这里有个坑:如果你在用户模式下用STARTUPE3访问Flash,配置比特流的时候也要用相同的SPI模式。也就是说,如果用户逻辑用标准SPI,比特流也必须用标准SPI。如果比特流用了Quad SPI,用户逻辑再用标准SPI,可能会因为Flash的Quad使能位被置位而通信失败。
5.3 比特流生成与固化
生成比特流之后,需要把它固化到Flash里。Vivado里可以用"Add Configuration Memory Device"来添加Flash型号,然后"Program Configuration Memory Device"来烧录。但这个过程需要JTAG连接,而且烧录速度受JTAG时钟限制。
如果板子已经装到设备里,没法插JTAG,那就需要用到前面说的远程升级方案:FPGA逻辑里通过STARTUPE3读写Flash,把新比特流写到Flash的备用区域,然后触发IPROG重配置。这个方案需要ICAP原语配合,我这次项目没用到,但思路是通的。
固化的时候有个细节:比特流的起始地址和Flash的扇区边界要对齐。N25Q128的比特流通常从0x000000开始,占用前几个扇区。用户数据要放在比特流之后的区域,比如从0x100000开始。这样升级的时候不会覆盖配置比特流,即使升级失败,FPGA还能从原始比特流启动。
6. 调试过程中遇到的典型问题与排查方法
6.1 读ID返回全0xFF或全0x00
这是最常见的问题,说明SPI通信根本没建立起来。排查顺序如下:
- 检查DI和DO是否接反。这是最高频的错误。用示波器看DO上有没有波形,如果没有,说明DO被约束成了输入,或者三态控制反了。
- 检查CS是否拉低。用示波器看FCSBO引脚,发命令的时候应该拉低。如果一直高,说明FCSBTS控制反了。
- 检查SCK是否有输出。看USRCCLKO引脚,应该有25MHz的方波。如果没有,检查USRCCLKTS是否拉低,以及时钟源是否正常。
- 检查Flash的供电和复位。有些Flash有复位引脚,如果复位没释放,Flash不工作。
我遇到过一次读ID全0xFF,查了半天发现是FCSBTS的极性搞反了。STARTUPE3的FCSBTS是低电平有效,也就是说fcsbts_ctrl=0时CS输出使能。我一开始以为高电平有效,结果CS一直高阻,Flash根本没被选中。
6.2 擦除成功但写入后读回数据错误
这种情况通常是时序或者WIP轮询的问题。排查思路:
- 确认写使能命令之后CS拉高了。前面说过,WEL只有在CS拉高之后才生效。
- 确认WIP轮询正确。读状态寄存器的时候,DI上的数据要在SCK上升沿采样。如果采样相位错了,读到的WIP永远是0,就会在Flash还没写完的时候发下一条命令。
- 确认页写入没有跨页。跨页写入会导致Flash内部地址回绕,数据写到错误的位置。
- 确认数据在DO上的建立时间。如果SCK频率太高,DO上的数据还没稳定就被Flash采样了,写进去的数据就是错的。降低SCK频率试试。
我遇到过一次写入后读回数据错位,查下来是页写入的地址计算错了。我的地址是字节地址,但Flash的页写入命令需要的是字节地址,不是字地址。我一开始把字节地址右移了2位当成字地址传进去,结果写到了错误的位置。
6.3 Vivado实现报错DRC RTSTAT-2
这个报错通常和STARTUPE3的时钟约束有关。RTSTAT-2的意思是"未约束的时钟资源",工具发现USRCCLKO这个时钟没有约束,就报错了。解决方法就是前面说的,加一条create_generated_clock约束。
还有一种情况是STARTUPE3例化了多次。前面强调过,一个设计里只能例化一次。如果你在多个模块里都例化了,综合会报错"STARTUPE3 is already instantiated"。检查一下代码,确保只在顶层例化一次。
6.4 比特流固化后FPGA不启动
这个问题比较严重,通常是比特流配置和Flash实际状态不匹配。排查:
- 确认Flash的Quad使能位没有被置位。如果你之前用过Quad SPI模式,Flash的Quad使能位可能是1,这时候再用标准SPI配置,FPGA读不到正确的数据。解决方法是通过SPI发命令0xFF(Reset Enable)和0x66(Reset),或者用0x01写状态寄存器把Quad位清零。
- 确认比特流的起始地址正确。有些Flash的0x000000地址不是从扇区0开始的,或者有保护区域。查一下Flash的数据手册,确认配置区域没有被写保护。
- 确认CONFIGRATE和Flash的访问时间匹配。如果CONFIGRATE设得太高,Flash来不及返回数据,FPGA配置就会失败。N25Q128在标准SPI下最高支持50MHz,但实际用25MHz更稳。
7. 实操心得与性能优化建议
7.1 关于STARTUPE3的三态控制
STARTUPE3的三态控制信号(USRCCLKTS、FCSBTS、DTS)在配置完成后默认是什么状态,这个在文档里写得不是很清楚。实测下来,配置完成后这些信号默认是高阻的,也就是说CCLK、CS、DO都是高阻。所以你的逻辑必须在开始SPI通信之前,主动把这些三态控制拉低,否则通信无法建立。
我的做法是在复位释放之后,先等一段时间(比如1毫秒),确保配置完成,然后把三态控制信号拉低,再开始SPI操作。这个等待时间不能省,否则可能在配置还没完全结束的时候就抢占了配置引脚,导致配置失败。
7.2 关于SPI时钟的频率选择
前面提到25MHz是个比较稳妥的选择。但实际项目中,如果你的板子信号完整性做得好,Flash也是高速型号,可以尝试50MHz甚至更高。不过要注意,SCK频率越高,Flash的读访问时间(tV)就越关键。N25Q128在50MHz下的tV是6纳秒,也就是说SCK上升沿之后6纳秒,DI上的数据才有效。如果你的采样点太靠前,就会采到错误的数据。
我的建议是:先用低速(比如10MHz)调通功能,再逐步提高频率,同时用示波器观察DI和SCK的相位关系。如果发现数据眼图很小,就降低频率或者调整采样相位。
7.3 关于Flash的寿命和磨损均衡
NOR Flash的擦写寿命通常是10万次。如果你把Flash当参数存储用,频繁擦写同一个扇区,很快就会坏。所以如果要做参数存储,建议加一个简单的磨损均衡算法:把参数分成多个副本,轮流写入不同的扇区,读的时候取最新的那个。
我这次项目里,Flash主要用来存固件,擦写频率很低,所以没做磨损均衡。但如果你的应用场景是频繁记录数据,这个一定要考虑。
7.4 关于远程升级的可靠性
远程升级最怕的是升级过程中断电,导致Flash里的固件不完整,FPGA再也启动不了。所以升级方案必须要有回滚机制。常见的做法是:
- Flash里存两份固件:一份是当前运行的,一份是升级用的。
- 升级时先擦除备用区域,写入新固件,校验通过后,更新一个标志位,然后触发IPROG重配置。
- 如果新固件启动失败,看门狗超时,自动回滚到旧固件。
这个方案需要ICAP原语和看门狗配合,实现起来稍微复杂一些,但可靠性高很多。我这次项目没做这么复杂,但如果你要做产品级的远程升级,强烈建议加上。
7.5 一个容易被忽略的细节:DI上的上拉电阻
有些板子在DI线上没有上拉电阻,Flash在CS拉高的时候,DO输出高阻,DI线上的电平是不确定的。如果这时候FPGA的DI输入没有上拉,可能会采到随机数据,导致状态机误判。我的做法是在FPGA内部给DI加一个弱上拉(用PULLUP属性),或者在PCB上加一个10K的上拉电阻。这个细节很小,但能避免很多偶发问题。
8. 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方法 |
|---|---|---|---|
| 读ID返回0xFF | DI/DO接反 | 示波器看DO波形 | 交换DI/DO连接 |
| 读ID返回0xFF | CS未拉低 | 示波器看FCSBO | 检查FCSBTS极性 |
| 读ID返回0xFF | SCK无输出 | 示波器看USRCCLKO | 检查USRCCLKTS和时钟源 |
| 擦除不成功 | 写使能后CS未拉高 | 逻辑分析仪看CS | 写使能后强制拉高CS至少1周期 |
| 写入后读回错误 | WIP轮询错误 | 读状态寄存器看WIP | 检查DI采样相位 |
| 写入后读回错误 | 页写入跨页 | 检查地址计算 | 拆分写入,不跨256字节边界 |
| 写入后读回错误 | SCK频率过高 | 降低SCK频率 | 降到25MHz以下 |
| Vivado报DRC RTSTAT-2 | USRCCLKO未约束 | 检查XDC | 加create_generated_clock |
| 综合报STARTUPE3重复例化 | 多处例化 | 搜索代码 | 只在顶层例化一次 |
| 固化后FPGA不启动 | Quad使能位被置位 | 读状态寄存器 | 发0xFF和0x66复位 |
| 固化后FPGA不启动 | CONFIGRATE过高 | 检查比特流设置 | 降到25MHz |
| 偶发数据位翻转 | DI未同步 | 检查代码 | 加两级同步器 |
| 偶发数据位翻转 | DI无上拉 | 检查PCB | 加上拉电阻或内部PULLUP |
9. 写在最后的一些个人体会
这个项目做下来,最大的感受是:STARTUPE3原语本身不复杂,复杂的是对Flash时序的理解和对三态控制的把握。很多问题不是逻辑写错了,而是对Flash的行为理解不到位。比如写使能之后必须拉高CS、擦除必须扇区对齐、页写入不能跨页,这些细节在Flash的数据手册里都有,但如果不仔细看,很容易踩坑。
另外,调试工具很重要。我这次用了一台带SPI解码功能的逻辑分析仪,抓SPI波形的时候直接解码出命令、地址、数据,省了很多时间。如果没有逻辑分析仪,用示波器看波形也能凑合,但效率低很多。建议做SPI相关项目的朋友,手头备一个逻辑分析仪,几百块钱的就行,比示波器好用。
最后,关于代码的复用性。我把SPI Master控制器做成了一个独立的模块,接口是标准的AXI-Lite或者简单的寄存器接口,这样在不同的项目里可以直接复用。Flash的命令配置也做成了参数化的,换不同型号的Flash只需要改配置参数,不需要改逻辑。这个思路在FPGA开发里很实用,值得推广。
如果你也在做类似的项目,或者遇到了什么奇怪的问题,欢迎一起交流。FPGA这个领域,坑多,但踩多了也就成了路。