
干FPGA也有几年了SPI这接口从入门到做项目一路都在打交道。说实话很多朋友觉得SPI简单无非四根线MISO、MOSI、SCK、CS接上就能用。但真到了FPGA上自己动手写控制器的时候才发现坑一个接一个时序模式对不上、片选释放早了、状态机跑飞、上电瞬间误动作……这篇文章就把我实际调SPI的经验掰开揉碎讲清楚从协议本质到RTL实现再到和W25Q64这类Flash对接的完整链路一次性把SPI通信FPGA实现这件事讲透。新手可以照着写有经验的朋友也可以看看有没有自己踩过但没想明白的坑。1. 先把SPI的时序底裤翻出来CPOL、CPHA和四种模式1.1 为什么FPGA实现SPI之前必须搞懂模式SPI协议本身简单但它有一个非常容易让新手翻车的点时钟极性和相位。你在草稿纸上画的时序图、在别人博客里抄的代码、从某宝买来的模块四者的CPOL/CPHA很可能不一样。FPGA不像MCU那样有现成外设你可以通过寄存器配置一个模式就完事。FPGA里面所有逻辑都是自己用verilog写出来的你得自己保证发出去的波形和从机要求的完全一致。所以搞懂CPOL和CPHA不是理论问题而是直接决定你代码能不能跑通的问题。CPOLClock Polarity决定SCK在空闲时是高还是低CPHAClock Phase决定数据是在SCK的第一个边沿采样还是第二个边沿采样。这两者组合出四种模式模式CPOLCPHA空闲电平采样边沿Mode 000低上升沿Mode 101低下降沿Mode 210高下降沿Mode 311高上升沿1.2 数据边沿规约采样沿和变化沿的关系SPI是同步串行接口数据线和时钟线在传输过程中满足这样一个关系发送方在某个边沿更新数据线接收方在另一个边沿采样数据线。通常说的在上升沿采样只是接收视角对主机来说它既要发送也要接收因此同一时刻它在一个边沿更新MOSI在另一个边沿采集MISO。用Mode 0来举例SCK空闲为低第一个边沿是上升沿主机在这个上升沿采样从机送来的MISO同时在这个上升沿之后的下降沿更新下一bit的MOSI。从机的视角正好反过来。所以设计FPGA发送模块时不能把所有bit都用同一个always块在同一个边沿去做至少你要清楚自己时钟域里那个跳变是否让数据稳定了。我见过不少入门的写法是always (posedge sck) begin mosi tx_data[bit_cnt]; bit_cnt bit_cnt 1; end这样在Mode 0下也能跑因为从机是在上升沿采样你的MOSI你也在上升沿更新自己的bit看起来好像两边都在上升沿但由于FPGA内部逻辑有延迟实际波形从从机角度看到的是稳定数据。问题在于你还要在同一个上升沿去采集MISO你无法保证此时MISO已经稳定。这就是为什么很多初版代码仿真都对上板子之后随机读到错误数据的原因。1.3 全双工的错觉你真的在用全双工吗SPI是看起来全双工主机发一个bit的同时可以收一个bit。但实际使用时很多从机比如W25Q64的读操作是主机先发命令和地址从机在这个阶段MISO是无效的然后主机再持续输出时钟从机才把数据放到MISO上。也就是说同一根时钟下MOSI和MISO的有效数据是有阶段性的。FPGA设计时如果只是简单地在每个时钟沿同时收发你得在协议层面处理好哪些时钟周期是发送阶段、哪些是接收阶段。这个点后面讲W25Q64实战时还会细说这里先提醒一句SPI主机的通用设计建议把收发状态机做成一个时钟周期滑动8次的移位寄存模型而不是发完一字节再收一字节的割裂模型。2. 用状态机而非计数器堆时序FPGA主机设计思路2.1 为什么推荐三段式状态机而不是delay计数很多FPGA新手实现SPI时序第一反应是数时钟周期拉低CS然后往IO上写数据延时多少个时钟再拉高CS。这种做法在仿真里看着没问题一旦时序紧张、频率提高、或者需要响应中断式操作就会非常痛苦。调试的时候一个delay一个delay去数你根本不知道卡在哪。我推荐的做法是用一个标准三段式状态机来管理传输事务。核心思想是把SPI传输拆解成几个明确的事务阶段空闲IDLE、预处理PREPARE拉低片选、启动SCK、移位SHIFT按位收发8个bit或N个bit、后处理POST等待从机完成、拉高片选。2.2 一个可直接改用的SPI主机RTL骨架下面这个例子是我在项目里常用的基础版本4线模式支持mode 0/1/2/3配置数据长度可参数化module spi_master #( parameter CLK_DIV 4, // 分频系数CLK/CLK_DIV 为SCK频率 parameter DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire start, input wire [DATA_WIDTH-1:0] tx_data, output reg [DATA_WIDTH-1:0] rx_data, output reg busy, output reg sck, output reg mosi, input wire miso, output reg cs_n ); localparam IDLE 3d0; localparam PREP 3d1; localparam SHIFT 3d2; localparam POST 3d3; reg [2:0] state, next_state; reg [7:0] clk_cnt; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] tx_shift; reg [DATA_WIDTH-1:0] rx_shift; reg sck_en; always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end always (*) begin next_state state; case (state) IDLE: if (start) next_state PREP; PREP: next_state SHIFT; SHIFT: if (bit_cnt DATA_WIDTH-1 clk_cnt CLK_DIV*2-1) next_state POST; POST: next_state IDLE; endcase end always (posedge clk or negedge rst_n) begin if (!rst_n) begin cs_n 1b1; sck_en 1b0; busy 1b0; end else begin case (next_state) PREP: begin cs_n 1b0; // 拉低片选 busy 1b1; end SHIFT: sck_en 1b1; // 启动SCK POST: begin sck_en 1b0; // 关闭SCK cs_n 1b1; // 释放片选 busy 1b0; end endcase end end endmodule这只是一个骨架完整代码里还需要在SHIFT阶段用clk_cnt产生SCK翻转逻辑并在采样沿采集MISO。之所以推荐这种写法核心原因是把SCK的产生、数据的移位、片选的拉低拉高分别放到不同的always块里状态清晰出了问题一剑封喉直接看状态机跳变到哪一步。2.3 SCK产生逻辑分频和边沿对齐的细节FPGA一般跑在50MHz、100MHz甚至更高而SPI从机通常要求SCK不超过几十MHz所以分频是必须的。分频这里有一个容易忽略的点SCK的时钟沿必须和内部系统时钟对齐不能出现毛刺。用计数器去翻转SCK时要保证SCK在系统时钟的上升沿改变并且在你采样MISO的那个系统时钟周期里SCK已经稳定。如果CLK_DIV设成4那么SCK的一个周期等于4个系统时钟周期高电平2个周期、低电平2个周期。那么采样沿应该落在SCK跳变之后的至少1/4周期处确保数据稳定。实际代码里我习惯把采样点放在SCK沿后的一个系统时钟上升沿留出约一个系统时钟周期的建立时间。3. 硬件片选与软件片选一次选错整套逻辑白写3.1 两种片选到底在FPGA里怎么体现热搜里SPI硬件片选与软件片选上了榜说明这个问题确实困扰了不少人。在MCU上硬件片选指SPI外设自动控制NSS引脚软件片选指用普通GPIO手动拉低拉高。在FPGA里区别更本质硬件片选通常是指你例化厂商提供的SPI IP核让IP核内部逻辑自动控制CS信号软件片选则是你自己在RTL里用寄存器控制CS。3.2 软件片选的自由度与代价我自己实现SPI主机时几乎都用软件片选也就是自己用一个reg控制cs_n。原因很简单SPI从机的片选规则五花八门有的要求CS拉低后至少等几个时钟周期再给SCK有的要求在CS拉高之前把最后一个bit完成有的要求CS高电平保持一段时间才能进行下一次操作。用硬件片选想把这些时序都包在IP核配置参数里很费劲。但软件片选也有代价。最大的代价是你必须在状态机里自己管理CS和其他信号之间的相对时序。比如W25Q64的Fast Read命令CS拉低后需要发命令、地址、dummy然后把CS拉高。如果在发完最后一个数据bit的同一拍就把CS拉高可能丢失最后一个bit。所以我在POST状态里通常会让SCK停止之后再等若干个系统时钟周期才拉高CS。3.3 为什么FPGA的片选常常比MCU更讲究MCU的硬件SPI外设把片选时序做得很成熟你只管调寄存器。但在FPGA里尤其当你同时控制多个从机、在同一个总线上挂Flash和SD卡的时候片选就是你自己写的时序。一个典型的坑是片选信号的毛刺如果CS是用组合逻辑直接生成而不是用寄存器延迟一拍上电瞬间可能出现极窄的低脉冲导致从机误触发。解决办法是把所有控制信号都在系统时钟域打一拍再输出到IO。我见过有人用assign cs_n (state IDLE) ? 1b1 : 1b0这种写法看起来简单但状态机的组合输出如果有竞争就会产生毛刺。正确做法是always (posedge clk or negedge rst_n) begin if (!rst_n) cs_n 1b1; else cs_n cs_next; // cs_next来自状态机的组合逻辑 end这样CS的输出永远比状态跳变晚一个系统时钟周期毛刺被消化掉了。4. 实战目标让FPGA正确读写W25Q64 Flash4.1 为什么选W25Q64作为练手对象W25Q64是华邦推出的一款8MB SPI NOR Flash网上资料多、淘宝模块便宜、协议指令也很有代表性。你要通过它基本就把SPI通信从发几个字节升级到完成一个完整的读写事务了。而且它覆盖了命令发送、地址发送、状态轮询、数据连续读等SPI里最典型的事务形态。4.2 读ID指令验证时序模式最直接的手段我调试SPI有一个习惯先读ID不读数据、不擦除、不写。读ID能一次性验证好几件事CS是否正常拉低、SCK频率是否能让从机响应、发送的bit数和位序对不对、收回来的是不是预期的厂商ID和器件ID。W25Q64的0x9F指令格式是CS拉低 - 发送0x9F - 连续接收3个字节 - CS拉高。正常情况下应该是EF 40 17。这里有一个常见问题很多人用逻辑分析仪抓波形发现发出去的0x9F在MISO上没有回应于是怀疑时序模式。实际排查顺序应该是用示波器看CS拉低后SCK是否正常翻转看MOSI上发的第一个字节是不是0x9FMSB在前看SCK翻转期间MISO是否有数据看CS是否在正确的时间点拉高如果前3步都对但MISO上一直是高电平大概率是FPGA在SCK的下降沿采样MISO而W25Q在下降沿更新数据、上升沿采样输入实际通信时它在MISO上输出的数据在下降沿之后才稳定。这时你把采样沿改为SCK的上升沿或下降沿的后半段问题就解决了。4.3 状态轮询为什么是SPI Flash操作必须的一环W25Q64的写使能0x06、页编程0x02、扇区擦除0x20都是有时延的操作。芯片内部在忙的时候会通过状态寄存器bit 0BUSY位告诉你。你发出页编程或擦除命令后不能立刻发下一个命令必须持续读状态寄存器0x05直到BUSY位为0。很多FPGA新手在写Flash控制器时忽略了这个轮询流程结果就是发完页编程命令马上发读数据命令读回来的全是0xFF或者上一条命令的数据。原因不是SPI时序有问题而是Flash压根还没写完。给读者的建议是在FPGA顶层模块里把Flash操作拆成事务级的函数一次完整操作必须包含写使能、命令地址数据、轮询状态、完成。4.4 连续读和页编程的边界问题连续读0x03没有页边界限制可以一直读下去读地址会自然跨越页和扇区边界。但页编程0x02按页组织一页通常256字节发送数据时如果越过页边界数据会回卷到页首把那一页开头重新覆盖。FPGA实现时必须在发送前判断本次写入的数据是否超过页边界。我在代码里处理方式是先算当前页内剩余空间如果本次数据长度超过剩余空间就分多次编程每次最多写剩余空间那么多。这个细节在纯寄存器读写模式下没有但一旦你做FIFO连续写入就一定会碰到。我给自己的代码里加了一个错误码如果检测到数据跨页直接报错而不是静默回卷调试时能省大量时间。5. 整条链路上的时序约束与信号完整性从set_input_delay到共享总线5.1 set_input_delay和set_output_delay在SPI约束里到底怎么用FPGA工程跑不跑得稳除了代码逻辑时序约束占一半。SPI这类低速接口在低速工作时可以不约束但当你把SCK频率提到20MHz以上、或者信号走线很长时必须告诉综合工具外部器件的建立时间要求。典型用法是约束MISO这条输入路径set_input_delay -clock [get_clocks sck_gen] -max 5.0 [get_ports miso] set_input_delay -clock [get_clocks sck_gen] -min 2.0 [get_ports miso]这里的时钟是内部生成的sck_genmax和min表示MISO数据在sck有效沿前后的到达窗口。如果这个约束不设布局布线工具可能认为MISO从片外来了就立刻要采导致时序报告显示乱套实际波形也乱套。反过来MOSI和CS的输出延迟也要用set_output_delay约束告诉工具从IO输出到从机输入端的路径延迟。5.2 SPI总线共享同时挂屏幕和SD卡到底选谁热搜里有esp32屏幕与sd卡共享spi哪个好这说明SPI总线的共享问题非常常见。FPGA里同样会遇到一块SPI屏、一张SD卡挂在同一个总线上主机只有一组MISO/MOSI/SCK用两个CS去区分。表面上看完全可行但实际有两个坑第一个坑是从机的MISO信号在半双工模式下会变成高阻如果两个从机同时把MISO驱动为低或者高总线就冲突了。解决办法是在FPGA内部为每个从机加一个输入选择器当CS_A有效时MISO信号取从机A的返回CS_B有效时取从机B的返回。不要在板上直接把两个MISO连在一起。第二个坑是SCK的负载和走线长度。SPI总线的SCK同时驱动两个从机如果走线过长反射会导致信号质量差。实测中我建议在两个从机的SCK输入脚附近串联33Ω电阻以减小反射同时控制走线长度尽量短。5.3 SPI屏幕刷新率的本质瓶颈往往不在SPI协议热搜词SPI屏幕刷新率是多少背后其实是很多人对SPI的吞吐量没有概念。屏幕刷新率取决于三件事像素时钟所需的数据量、SPI实际吞吐率、屏幕控制器本身的刷新方式。比如一块240x320的屏幕16位色一帧数据是2403202153600字节。如果SPI跑20MHz理论上每秒传2.5MB刷一帧约61ms也就是约16fps。如果屏幕自己带GRAM帧缓冲那么你只需要把变化区域的数据传进去刷新率看的是局部写入速度不是全屏刷速度。在FPGA实现里真正拖后腿的往往不是SPI协议本身而是你FIFO的设计。比如从图像处理模块来了数据你要缓冲到FIFO再通过SPI发出去如果FIFO深度不够或者读写时钟域没有处理好就会丢数据屏幕上表现为花屏。所以SPI屏幕项目里FIFO深度和背压逻辑的重要性不亚于SPI主机本身。5.4 和IIC的对比什么时候SPI是明显更优解IIC和SPI的区别也是热搜词之一。简单说IIC只有两根线SDA、SCL靠地址选择从机支持多主机有应答机制适合低速、短距离、器件少的场合。SPI四根线没有标准应答硬件简单速度上限高得多也没有收发冲突的问题。FPGA内部逻辑主频高数据吞吐需求大优先用SPI。但要注意如果从机是IIC接口比如某些温度传感器、RTC芯片、或者某个LED驱动芯片那你在FPGA里就得用IO模拟IIC时序或者用厂商IP核。两者不是互斥关系我很多板子上既有SPI Flash也有用IIC接口的EEPROM和温度传感器。6. 我在调试过程中遇到的几个真实翻车现场和排查方法6.1 上电瞬间片选被意外拉低第一次做SPI从机控制时我遇到过一个诡异现象FPGA刚上电连接在SPI从机上的LED就先闪了一下像是被片选选中了。排查波形发现CS信号在上电初期有一个很窄的低脉冲然后恢复高电平。原因是FPGA的IO在上电配置完成前处于不确定状态如果你给CS分配的这个管脚恰好被内部上拉到半高就有可能在配置瞬间产生低脉冲。解决办法有两个一是在PCB上给CS管脚加一个10kΩ上拉到VCC确保默认高电平二是在RTL里让CS寄存器在复位状态下也输出高电平并且把复位释放和系统时钟同步。后来我在所有要求CS默认高的从机上都加了这个上拉电阻再也没有出现误触发。6.2 状态机卡住的排查思路SPI状态机如果卡在某个状态不跳转先不要急着改逻辑先确认是不是bit_cnt没有清零导致的。我在早期代码里PREP状态只发cs_n和sck_en没有在进入SHIFT之前把bit_cnt清零结果第二次传输时bit_cnt从上一轮的8开始状态机永远等不到bit_cnt到边界的条件直接死锁。排查技巧是在仿真阶段把状态寄存器和bit_cnt都放进波形窗口每次传输前确认状态回到了IDLEbit_cnt是0。如果发现bit_cnt没复位看看是不是只有SHIFT状态里做bit_cnt bit_cnt 1没有在PREP里做bit_cnt 0。这个问题我在论坛上看到过不下十次。6.3 电平域问题3.3V和1.8V的SPI设备混接有一次我接一个1.8V供电的传感器模块我的FPGA板子IO是3.3V电平直接把MISO接到FPGA的管脚上结果读回来的数据总是错的。用示波器看波形发现MISO高电平时只有1.6V左右刚好处于FPGA输入高电平阈值的边缘。解决办法是加电平转换或者把FPGA的IO bank配置成匹配的电平标准。FPGA的IO管脚支持不同bank设不同电平标准但这种改动需要重新综合布局还得注意bank内管脚统一。不追求高速的话用一颗电平转换芯片更省事。调试SPI时如果数据偶尔错、时好时坏先量一下高电平幅度很多时候问题不在时序而在电平。6.4 位序问题MSB和LSB的反射SPI协议虽然绝大多数情况是MSB First但协议本身没有硬性规定有些器件会在数据手册里明确指出支持LSB First。我用过一个电机驱动芯片它的配置寄存器是LSB First而我习惯性地按MSB写结果配置后电机完全不动。这个问题的隐蔽性在于如果器件对某些位不敏感错误不会立刻暴露直到你换一个功能位才发现。在FPGA代码里位序问题的排查比较费劲因为示波器上看每个bit的电平才能反推。建议是先在Testbench里做一次回环测试把MOSI短接到MISO看主机发出去的bit是否原样收回来。如果回环正确位序多半没问题。6.5 用一个土办法验证整套链路验证FPGA SPI模块最笨但最有效的方法是把MISO和MOSI在板子上用跳线短接然后在FPGA里发一个已知序列比如0xA5、0x5A、0xFF、0x00看rx_data是否原样返回。如果这一步通过了说明时钟极性、相位、移位、位序都没问题。然后再接真实从机。这个习惯帮我排掉过大量低级错误。7. 最后分享一个让调试效率翻倍的小习惯调试SPI时序时很多人习惯在代码里加#延时或者靠$display打log但FPGA不像MCU那样方便仿真和在线逻辑分析仪才是主力。我自己的做法是在SPI主机模块里预留几个调试端口把状态机的状态和bit_cnt引到ILAIntegrated Logic Analyzer集成逻辑分析仪上每次调试只需要盯着状态跳变和数据字节不需要猜。尤其当同时调试Flash和屏幕时多个模块的状态同时抓能很快定位是哪个环节卡住了。SPI通信FPGA实现这件事说透了就是一个协议理解状态机设计时序细节的综合体。不要被各种术语唬住从回环测试开始一步一步验证你会发现自己写出来的SPI控制器也能稳定跑到几十MHz而且改起来比IP核顺手得多。