
我一直觉得给FPGA外挂一颗N25Q128 SPI Flash是件挺常见的事儿但真正动手用Verilog从零写一个能读能写、能擦除的状态机驱动很多同学还是会被几百页的Datasheet劝退。这篇就手把手带你把这份Datasheet翻译成可综合的Verilog状态机从挑指令、定接口到三段式状态机的实现再到仿真与上板调试。如果你刚学完Verilog想找个实战练手或者项目里要让FPGA挂Flash存参数、做升级这篇文章应该能帮你省下不少弯路。我敢这么说是因为早期我自己也在这个问题上耗过两个晚上。当时板卡上的FPGA需要在上电时从N25Q128里读配置参数运行中还要把采集结果定期写进去。最开始我准备直接调工具链里的SPI控制器IP结果折腾一圈发现那种通用IP核的配置项太多真要按照我们自己的字节流协议来用反而别扭。后来干脆自己写了个状态机驱动从那之后我就养成了一个习惯遇到SPI Flash先读Datasheet再动手写代码。读完这篇文章你不光能驱动N25Q128还能掌握一套通用方法以后拿到任意一颗SPI NOR Flash的Datasheet都知道该抓哪些重点、怎么用三段式状态机把它跑起来。1. 为什么放着现成IP不用偏要手写这颗Flash的驱动1.1 项目里的存储需求往往比想象中更具体在设计一块板卡的时候FPGA旁边常常要有一颗Nor FlashN25Q128就是非常常见的选择。128Mbit容量SPI接口只有四根线掉电数据不丢失用来存配置参数、运行日志、固件升级镜像都很合适。但真正动手写逻辑时会发现“读一下”“写一下”只是概念实际要处理的细节一点不少片选什么时候拉低、地址怎么拼、某些命令要不要Dummy周期、写完之后怎么知道芯片忙完了这些细节全部藏在Datasheet里。我见过不少项目一开始想得很简单觉得SPI嘛就是四根线照着别的代码抄一抄就行了。可是等真到了调试阶段问题一个接一个冒出来读ID返回的全是1写数据怎么都写不进去写进去了读出来又对不上。这些问题在现成IP核里会变成一堆让人看不懂的配置项在自研代码里则对应着一个一个具体的状态和逻辑节点。正因为如此我才坚持自己写驱动遇到问题至少知道该查哪一段代码。1.2 现成SPI控制器的窘境不是说现成IP核不能用。Xilinx的AXI Quad SPI IP或者Intel FPGA平台上的SPI控制器功能都很完整标准SPI、Dual、Quad模式都支持Page Program、Sector Erase这些命令也都有。问题在于这些IP核的服务对象是通用场景配置界面里一堆选项从时钟极性到指令长度再到FIFO深度每个都要理解到位。对一个“上电读几个字节、偶尔写一段日志”的项目来说这套东西明显过重。另一个实际痛点是黑盒调试。IP核生成之后内部状态对你不可见一旦时序异常只能抓引脚波形然后对着IP配置文档猜。我记得有一次读ID返回的数据整体错位排查半天最后发现是Dummy周期在IP核里多配了8位。如果是自研状态机这种问题一眼就能看到。所以我的建议是除非项目时间紧、任务重必须直接上成熟IP否则对这种简单外设自己写驱动反而是更可控、更高效的选择。代码在自己手里行为完全透明想加命令就加状态想改频率就改分频参数跨器件移植也容易。1.3 读Datasheet的正确姿势先抓三个表格很多同学拿到N25Q128的Datasheet就从头开始读读到引脚描述已经没耐心了。我读这种芯片文档有一套固定方法先翻Features和Connection Diagram确认芯片接口类型、工作电压、有什么特殊引脚然后把重心放到三个表格上——指令集表、时序特性表、状态寄存器表。指令集表告诉你每条命令发什么字节、要不要地址、要不要Dummy周期时序特性表告诉你SPI时钟最高能跑多高、输入输出的建立保持时间是多少状态寄存器表告诉你WIP位和WEL位在哪里这是判断写操作是否完成的关键。Datasheet里的小字注释也不要放过。N25Q128同时兼容CPOL0/CPHA0和CPOL1/CPHA1但指令表里有些命令对地址长度、Dummy周期有特殊要求这些细节经常写在表格下方的脚注里。我踩过最典型的一个坑是把Fast Read命令的Dummy周期少算了一个字节结果读出来的数据整体往后挪了8位光看波形完全发现不了还是对文件才反应过来的。所以每次写驱动前把这三张表的关键信息提取出来放在手边比对着屏幕翻几百页PDF高效得多。2. 先读懂N25Q128的关键特性再去写代码2.1 芯片底细与SPI模式选择N25Q128是Micron前身是Numonyx的128Mbit串行NOR Flash工作电压2.7V到3.6V页大小256字节擦除单位有4KB子扇区、32KB扇区和64KB块也支持整片擦除。接口上它支持标准SPI、Dual SPI和Quad SPI驱动第一步先只用标准SPI也就是CS_N、SCK、MOSI、MISO这四根线等基础功能稳定以后再考虑扩展成Quad模式提速。Datasheet里明确写了它支持SPI Mode 0和Mode 3。所谓Mode 0就是CPOL0、CPHA0空闲时SCK为低电平数据在SCK上升沿被主机发出、在下降沿被主机采样。Mode 3则相反空闲高电平采样沿也不一样。实际工程里我习惯统一用Mode 0因为大部分FPGA例程都是按Mode 0写的对着波形图不容易搞混。上板调试时如果遇到读ID返回全1或者数据错位第一反应先确认自己到底是按哪个模式实现的。时序参数方面普通Read指令的频率上限一般说50MHz左右Fast Read可以更高。但FPGA工程里我不会把SPI时钟压到极限通常让SPI时钟等于系统时钟的一半甚至更低比如50MHz系统时钟就配25MHz SPI时钟。这样留出时序裕量对布线没那么好的板子更友好调试时也能少很多奇怪的干扰问题。2.2 驱动必须用到的指令清单整个驱动其实用不到多少条指令下面这张表是我整理的最小指令集指令编码地址字节Dummy周期说明WRITE ENABLE (WREN)0x0600写使能置位WELREAD STATUS REGISTER (RDSR)0x0500读状态寄存器判断WIP/WELREAD DATA (READ)0x0330普通读频率上限约50MHzFAST READ0x0B38高速读多一个Dummy字节PAGE PROGRAM0x0230页编程一页最多256字节SUBSECTOR ERASE0x2030擦除4KB子扇区SECTOR ERASE0xD830擦除64KB扇区BULK ERASE0xC700整片擦除READ ID (RDID)0x9F00读电子签名返回3字节我实际量产版本里只用了其中六条RDID、READ、WREN、RDSR、PAGE_PROGRAM再加一条擦除指令。RDID用来验证SPI链路是否通READ用来读数据WREN配合RDSR实现写操作和忙等待擦除负责把目标区域清成0xFF。其他指令比如FAST READ、整片擦除等基础版本跑通之后再按需加上去就行。这里有个细节RDID读回来的值不同流片版本可能不一样常见如0x20、0xBB、0x18之类具体以手册为准。仿真和上板时只要返回值与手册一致就说明SPI物理链路没问题这是排障时最有用的一个信息。2.3 WIP位和WEL位写操作的“门锁”状态寄存器是整个写操作流程的核心。N25Q128的Status Register里bit0是WIPWrite In Progress芯片擦写期间为1完成之后自动变0bit1是WELWrite Enable Latch由WREN指令置位在完成一次写或擦除操作后自动清零。我把这两个位理解成一把门锁WEL是锁WIP是门。不先发WREN上锁后面的写操作根本推不开门门一旦打开芯片开始擦写WIP就是指示门上正在移动的信号必须等它停下才能进行下一次操作。写操作的标准流程是先发WREN再紧接着发PAGE_PROGRAM或擦除指令然后轮询RDSR直到WIP从1变0。这中间有两个必须注意的细节。第一WREN之后要立刻执行写指令不要中间穿插其他SPI操作否则WEL可能在你还没发送页编程指令时就被清掉。我在调试时就遇到过这种问题代码里在WREN和PAGE_PROGRAM之间夹了一个读状态寄存器的操作导致页编程始终不生效。第二WIP轮询不能偷懒擦除尤其耗时一个4KB子扇区擦除可能要上百毫秒不轮询驱动就不知道什么时候能进行下一步。2.4 页编程的256字节边界是最容易忽略的坑N25Q128的PAGE_PROGRAM按页编程每页256字节。如果起始地址不在页边界写满当前页之后会自动绕回该页首地址继续写而不是自动进入下一页。也就是说从页内偏移250开始写10个字节前6个字节会写到偏移250到255后4个字节会绕回到这个页的偏移0到3把原本的数据覆盖掉。这个坑在仿真里很容易被忽略因为不精确的行为模型可能不模拟这种回绕。等到上板测试上层如果向驱动发起一个跨页的写请求就会发现后半段数据没有出现在预期地址。我的建议是初版驱动先让上层调用者保证“起始地址加长度不跨页”驱动内部做一个简单检查等主链路跑通之后再在状态机里加跨页拆分逻辑。不要一上来就做复杂功能先把整个通信链路打通后面优化都来得及。3. 模块接口怎么定义直接决定你调试时的心情3.1 顶层端口设计分清“用户侧”和“物理侧”写驱动之前先想清楚模块对外长什么样。我把端口分成两侧一侧是用户接口接FPGA内部的其他逻辑一侧是SPI物理接口直接连Flash芯片的CS_N、SCK、MOSI、MISO。用户侧包括启动信号、操作类型、地址、写数据、读数据、忙标志物理侧就是那四根SPI线。这样分的好处是上层逻辑完全不需要知道SPI时序细节它只负责喊“开始”然后等“完成”。模块声明可以这样写作为参考module spi_flash_n25q128 #( parameter CLK_DIV 2 // 系统时钟 / SPI时钟 的分频比 )( input wire clk, // 系统时钟 input wire rst_n, // 复位低有效 // 用户接口 input wire i_start, // 启动一次操作高有效一个周期 input wire [1:0] i_op, // 2b00读2b01写2b10擦除2b11读ID input wire [23:0] i_addr, // 24位Flash地址 input wire [7:0] i_wr_data, // 写数据 input wire i_wr_valid, // 写数据有效 output reg [7:0] o_rd_data, // 读数据 output reg o_rd_valid, // 读数据有效 output reg o_busy, // 忙标志 // SPI物理接口 output reg spi_cs_n, output reg spi_clk, output reg spi_mosi, input wire spi_miso );可能有人会问为什么用户接口不做成AXI-Lite或者简单的寄存器总线我的答案是根据场景来。如果这个模块只是给FPGA内部逻辑用一个简单的握手协议比总线协议可靠得多也更直观。我甚至遇到过同事把AXI协议配错导致模块一直挂在事务上不响应的情况。握手协议就没有这种问题start拉高busy拉高busy释放一套流程干干净净。3.2 握手协议start、busy、valid三段握手我常用的握手逻辑是这样的i_start拉高一拍模块开始执行o_busy马上拉高执行完成后o_busy拉低。如果这次操作是读o_rd_valid会拉高一拍同时o_rd_data上出现有效数据。如果是写操作上层通过i_wr_valid和i_wr_data逐字节把数据喂给模块模块在内部准备好的节拍上采样这些数据。这样上层逻辑只要拉start、等busy再根据操作类型收数据或发数据整个流程是线性的很容易理解。这种设计还有一层好处读出的数据直接是同步有效的后面接FIFO非常方便。比如我想连续读一大块数据模块每输出一个字节就拉高一次o_rd_valid上层只要在o_rd_valid为高时把o_rd_data存入FIFO就能拼出完整的数据流。写方向同理上层可以用一个FIFO把要写的数据准备好然后在每个i_wr_valid高电平时把数据递进来模块按自己的节奏去采样。唯一要注意的是写数据不能比模块期望的节拍快否则会丢数据所以握手信号必须双向配合。3.3 SPI时钟生成千万不要用组合逻辑拼时钟这是我想特别强调的一点。有初学Verilog的朋友会在状态机里直接写spi_clk 1b0或spi_clk 1b1把spi_clk当时钟信号输出甚至用组合逻辑生成时钟沿。这在仿真里看着是正常的一到FPGA上就会因为毛刺和竞争导致Flash误采样问题极其难查。正确的做法是把SPI的波特率时钟也当作普通信号来处理用系统时钟驱动一个计数器根据计数器计到哪个阶段来决定spi_clk拉高还是拉低同时决定何时更新MOSI、何时采样MISO。以50MHz系统时钟、25MHz SPI时钟为例分频比是2也就是两个系统时钟周期组成一个SPI时钟周期。计数器在0和1之间循环计数为0时spi_clk置高计数为1时spi_clk置低。发送数据时在spi_clk为低的半周期把MOSI更新好到了上升沿Flash正好采到稳定数据采样数据时在spi_clk为高的半周期去读MISO这样整个收发过程就是有节奏的always (posedge clk or negedge rst_n) begin if (!rst_n) begin spi_clk 1b0; clk_cnt 1b0; end else begin clk_cnt clk_cnt 1b1; if (clk_cnt 1b0) begin spi_clk 1b1; // 高电平阶段采样MISO end else begin spi_clk 1b0; // 低电平阶段更新MOSI end end end这段代码是简化示意实际工作时还要结合位计数器来决定什么时候把下一个bit放到MOSI上、什么时候把MISO采进来。但核心思路记住spi_clk一定是寄存器输出不能是组合逻辑拼出来的时钟。3.4 WP#和HOLD#引脚写操作的隐形开关上板时最让人头痛的一类问题是仿真完全正确写Flash却始终失败读出来全是0xFF。遇到这种情况先量一下WP#和HOLD#这两个引脚的电平。WP#是写保护低有效HOLD#是暂停输入也是低有效。如果板子上这两个引脚悬空或者被某个默认低电平的GPIO控制着Flash会进入写保护或者暂停状态指令发进去根本不执行。我见过一块板子把WP#接到了GPIO上结果GPIO默认输出低电平所有写操作都静默失败。这个问题的隐蔽性在于读操作完全正常ID也能读出来唯独写和擦除不生效。所以我现在画板或者写驱动时都默认要求WP#和HOLD#要么直接上拉要么由FPGA上电后立刻置高不给它们留下任何处于低电平的窗口。如果你用的是别人的板子也要先查原理图确认这两个引脚是怎么接的再开始调逻辑。3.5 参数化设计让驱动能快速移植到其他Flash模块里所有可能变化的东西最好都参数化。比如分频系数做成parameter从50MHz系统时钟换到100MHz时只改一个数字就行。常用的指令编码也作为localparam集中在模块顶部一看就知道这条指令是什么含义。不要把这些魔数散落在case语句里否则两周之后回来看代码你会对着0x20和0xD8发呆不知道哪条是擦除、哪条是页编程。我一般会在每个localparam后面加注释比如localparam OP_READ 2b00; localparam OP_WRITE 2b01; localparam OP_ERASE 2b10; localparam OP_RDID 2b11; localparam CMD_WREN 8h06; localparam CMD_RDSR 8h05; localparam CMD_READ 8h03; localparam CMD_PP 8h02; localparam CMD_SUBSECT_E 8h20; localparam CMD_RDID 8h9F;这样改起代码来非常轻松。以后如果换成另一颗SPI Flash只要指令编码一致甚至代码都不用动即使指令不同也只需要改这些localparam和地址长度相关的逻辑。4. 三段式状态机把读、写、擦除翻译成可综合逻辑4.1 为什么我推荐三段式Verilog状态机有一段式、二段式、三段式之分。一段式把所有逻辑写在一个always块里代码最短但输出时序容易出毛刺可读性也差。二段式把状态寄存器和次态组合逻辑分开已经好了不少。三段式则进一步把输出逻辑也独立出来状态寄存器、次态逻辑、输出逻辑各管一摊逻辑清晰、时序收敛更容易。对于SPI Flash驱动这种状态多、分支多、时序要求稳定的场景我无脑推荐三段式。另一个实际原因是调试体验。上板之后如果行为怪异我的第一件事是看当前状态走到哪了。三段式把状态寄存器和输出逻辑分开抓波形时能清楚地区分“状态跳转”和“输出行为”不会混在一起无从下手。早期我用一段式写过一个I2C控制器出了问题盯着波形看半天根本分不清是哪段逻辑在捣乱。后来改成三段式问题就变得好定位多了。4.2 状态划分读、写、擦除三条主路径主状态机大体可以分成这几个状态IDLE空闲、ISSUE_CMD发命令、ISSUE_ADDR发地址、TRANSFER收发数据、WAIT_WIP等待Flash不忙、DONE完成。读操作路径是IDLE到ISSUE_CMD到ISSUE_ADDR到TRANSFER到DONE写操作要先经过一个WREN状态再进入PAGE_PROGRAM的命令、地址、数据阶段最后WAIT_WIP擦除操作也是在WREN之后发擦除命令和地址然后进入WAIT_WIP。这里有一个值得注意的设计点WREN本身也是一条SPI命令也要占用发送命令的状态。实现时可以让状态机根据操作类型决定连续发几条命令也可以在IDLE之后先进入一个专门的S_WREN状态发完WREN再回到ISSUE_CMD发真正的操作指令。我更推荐后者因为把WREN从主流程里拆出来状态更清晰以后扩展别的命令也方便localparam S_IDLE 5d0; localparam S_WREN 5d1; localparam S_ISSUE_CMD 5d2; localparam S_ISSUE_ADDR 5d3; localparam S_TRANSFER 5d4; localparam S_WAIT_WIP 5d5; localparam S_READ_ID 5d6; localparam S_DONE 5d7;4.3 三段式框架代码状态跳转、次态逻辑、输出逻辑三段式的基本骨架可以这样写。第一段是状态寄存器负责把次态锁存到当前状态always (posedge clk or negedge rst_n) begin if (!rst_n) state S_IDLE; else state next_state; end第二段是次态组合逻辑根据当前状态和输入条件计算下一个状态。这一步要特别小心组合逻辑的case一定要写default不然综合时会留下锁存器状态机也可能进入非法状态后卡死always (*) begin next_state state; case (state) S_IDLE: begin if (i_start) next_state (i_op OP_WRITE || i_op OP_ERASE) ? S_WREN : S_ISSUE_CMD; end S_WREN: next_state S_ISSUE_CMD; S_ISSUE_CMD: next_state S_ISSUE_ADDR; S_ISSUE_ADDR: next_state S_TRANSFER; S_TRANSFER: begin if (byte_done) next_state (need_wait_wip ? S_WAIT_WIP : S_DONE); end S_WAIT_WIP: begin if (!wip_flag) next_state S_DONE; end S_DONE: next_state S_IDLE; default: next_state S_IDLE; endcase end第三段是输出逻辑既可以写组合输出也可以写成时序输出。我习惯把关键输出做成寄存器输出比如o_busy、spi_cs_n这些这样能避免组合毛刺always (posedge clk or negedge rst_n) begin if (!rst_n) begin o_busy 1b0; spi_cs_n 1b1; end else begin o_busy (state ! S_IDLE); if (state S_IDLE) spi_cs_n 1b1; else spi_cs_n 1b0; end end上面三段代码是核心骨架实际运行还需要补齐位计数器、字节计数器、移位寄存器。我建议读者先把这个框架理解透再往里面填细节。4.4 帧发送细节bit计数、字节移位、采样时机SPI是串行接口每个字节都必须按位发送和接收。模块里至少需要两个计数器bit_cnt用于跟踪当前帧内发送到第几位byte_cnt用于跟踪这一阶段还需要处理几个字节。在ISSUE_CMD和ISSUE_ADDR阶段每发送完8位bit_cnt归零、byte_cnt减一当byte_cnt到0时地址阶段完成转入数据阶段。转移数据时既要做发送也要做接收所以还要一个移位寄存器来拼接接收到的bit。真正需要注意的是采样时机。在Mode 0下Flash在SCK上升沿锁存MOSI数据在SCK下降沿把数据放到MISO上。所以主机应该在SCK低电平阶段把MOSI更新好到了上升沿Flash正好采到稳定数据反过来主机可以在SCK下降沿之后、下一个上升沿之前采样MISO。落到代码里就是分频计数器为低半周期时更新MOSI分频计数器为高半周期时采样MISO一个SPI时钟周期完成“发一位、采一位”。如果采样时机搞反读回来的数据会整体偏移而且仿真中不一定能发现因为行为模型的采样时机可能没建模得那么严格。4.5 最容易翻车的四个细节这里说几个我实战中踩过的、必须拿本子记下来的坑。第一个忘记WREN。写操作前不发WRENWEL位还是0PAGE_PROGRAM会被芯片忽略。这个问题在仿真模型不严谨时可能完全看不出来上板后写Flash没反应回头读SR才发现WEL0。解决办法是把WREN状态强制放进写和擦除流程里上板时用RDSR回读确认。第二个SPI采样沿不对。Mode 0和Mode 3的采样沿不同一旦搞反数据要么左移要么右移。上板后最典型的症状是读ID返回值整体错位。这时候不要怀疑芯片先回Datasheet确认时序图再检查自己的采样逻辑。第三个状态机没有default分支。综合工具无法覆盖所有情况一旦状态变量进入未定义状态状态机可能卡死在某个非法状态表现就是o_busy一直拉高、再也不回IDLE。解决办法是在所有case里补上default并且让default回到S_IDLE。这个习惯看着小关键时刻能救命。第四个页边界跨越。这是前面提过的老坑写数据时如果跨页会遇到数据被写到页首位置的问题。排查顺序是先看地址再看长度最后看驱动有没有做跨页拆分。如果初版驱动不拆分那就在上层调用时保证不跨页。5. 仿真、上板与排查驱动从代码变成实物的最后一段路5.1 Testbench里先放一个Flash行为模型仿真不是把DUT连上就随便跑最重要的是有一个行为级Flash模型。没有模型你的testbench只能看到发送出去的波形没法验证接收到的数据是否正确。行为模型不用写得多复杂在内部开辟一块memory数组根据接收到的指令执行读、写、擦除操作模拟WIP位翻转就足够用了。以我常用的模型为例内部维护一个SR状态寄存器收到WREN就置WEL1收到PAGE_PROGRAM就在指定地址写入数据然后把WIP置1模拟几微秒后清0收到RDSR就返回SR收到READ就从memory数组里把数据依次移出。这个模型写起来并不复杂但对仿真收益极大。可以说没有行为模型的SPI Flash调试就像闭着眼睛开车出了问题根本不知道是发送端错了还是接收端错了。5.2 三个用例读ID、擦除、写后回读我的测试顺序是固定的先读ID再擦除再写后回读。读ID是为了验证SPI时序和物理链路擦除是验证WREN和WIP轮询是否正常写后回读用来验证数据通路。testbench里就按这个顺序写三个task每个task里拉start、等busy、对比返回结果任何一个失败就打印提示并停止仿真。读ID用例尤其重要。如果仿真中读ID不能返回手册上的值就不要急上板。检查点有两个一是CS_N有没有在发送RDID之前拉低、发送结束后拉高二是MISO上的返回值是不是和手册一致。上板调板时这个用例也是我的第一板检查项。仿真通过了只能说逻辑上没问题上板后读ID通过了才说明物理链路真的通了。5.3 上板调试三板斧第一板用逻辑分析仪或者FPGA内置的逻辑分析仪抓SPI四根线和仿真波形逐拍对比。如果发现SCK多了毛刺、CS_N提前拉高优先怀疑代码里的时序生成逻辑而不是怀疑芯片。第二板先运行读ID测试确认链路物理通。如果读ID返回全1或者乱序检查MISO上拉电阻、SPI时钟频率、CS_N极性不要急着怀疑Flash芯片坏了。第三板做写入测试前必须先把目标扇区擦掉否则写入结果永远是0xFF或旧数据。还有一个经验调试写操作时不要反复擦写同一个区域来折腾。N25Q128的擦写寿命虽然能到数万甚至十万次级别但调试时频繁擦写就是在浪费寿命。我调试时喜欢在模块里留一个只读模式的开关专门用来验证读链路避免不小心触发页编程或者扇区擦除。等确认读链路完全正常再打开写和擦除功能。5.4 常见问题速查表调试过程中遇到的问题我整理成了一张速查表每次排查都按这个顺序走现象可能原因排查方向读ID返回全1MISO上拉缺失、SPI模式错、CS_N未拉低抓SPI波形核对CPOL/CPHA读ID返回整体移位Dummy周期没算对、采样沿不对核对指令时序调整采样沿写操作静默失败没发WREN、WP#被拉低、HOLD#受干扰读SR看WEL位量引脚电平写入后读回错乱跨页写入、地址越界、电源纹波大检查地址范围和页边界状态机卡死不回IDLE状态寄存器没有default分支、复位没处理好补default分支检查复位时序我自己的习惯是把“读ID通过”作为一块板卡SPI Flash链路正常的唯一标准。只要读ID能拿到手册上的值剩下的写、擦除问题基本都能通过状态机和时序调整解决。第一次移植不熟悉没关系先把这条链路打通后面就是体力活。再往后遇到其他型号的SPI Flash你还是会感谢自己当初手写过N25Q128这个驱动——因为Datasheet的读法、状态机的写法、调试的思路全都是通用的。