SPI通信FPGA实现:从协议原理到W25Q64读写实战
做现场调试时最常遇到的一类事MCU和传感器之间说不上话示波器一挂SCK和MOSI波形都对但从设备就是没反应。查到最后十有八九是时序不匹配或者片选时序太随意。这时候如果手里有块FPGA处理起来会踏实很多——SPI这种相对“简单”的协议恰恰是FPGA最容易发挥确定性和可控性的地方。这篇内容围绕“SPI通信FPGA实现”展开重点聊三件事SPI协议本身怎么理解、FPGA里怎么用状态机把主机时序写明白、以及实战中读写W25Q系列Flash这类常见从设备时需要注意哪些细节。适合两类人看一类是正在学FPGA、想找个小而完整的通信接口练手的另一类是做系统集成、需要把FPGA和外部ADC、Flash、传感器、显示屏对接的工程师。看完之后你能独立写出一个可综合、可上板的SPI主机模块并且知道遇到读数据全FF、偶发错位这类问题时往哪个方向排查。1. 内容整体设计与思路拆解1.1 SPI协议的本质以及FPGA为什么适合干这件事SPI全称Serial Peripheral Interface中文叫串行外设接口是Motorola当年定义的一套全双工、同步、四线制通信协议。四条线分别是SCK时钟、MOSI主出从入、MISO主入从出、CS/SS片选。它的特点是主机负责产生时钟数据在时钟边沿进行移位传输双方各自有一个移位寄存器每来一个时钟边沿主机送出一位、接收一位8个时钟周期完成一个字节的交换。为什么FPGA适合做SPI核心原因在于SPI的时序要求非常直接——SCK的频率、相位、占空比、边沿采样位置都由主机决定。FPGA恰恰是数字逻辑里对时序控制最精确的器件时钟分频、边沿对齐、位计数等操作全部可以在RTL里精细控制还可以把若干个SPI通道并行实例化互不干扰。相比之下MCU用软件翻转I/O去模拟SPI优点是灵活缺点是占CPU、频率受限、时序抖动大用硬件SPI外设时序稳定但通道数和自定义能力有限。FPGA的方案则处于“既要灵活、又要确定性”的中间地带。我看到很多FPGA初学者做SPI第一反应是去网上搜现成的模块复制粘贴仿真一过就上板。但真正遇到问题时会发现如果不理解SPI的相位极性和状态机跳转逻辑排查起来特别费劲。这篇内容把SPI从协议到代码到调试完整走一遍你做完之后对时序的理解会扎实很多。1.2 用IP核还是手写RTL先想清楚需求再选FPGA厂商都提供了成熟的SPI相关IP比如Xilinx的AXI Quad SPI、Intel/Altera的SPI Core使用起来确实方便——挂上AXI总线配好寄存器CPU或逻辑就能直接读写。但IP核并不是所有场景的最优解尤其在你只想让FPGA做纯粹的数据桥接、或者需要多个自定义时序的SPI通道时IP核有时候反而是负担。我个人的选型原则是这样的如果SPI从设备是标准FLASH、EEPROM、SD卡这类成熟外设且系统里已经有AXI总线或CPU软核优先用AXI Quad SPI这类IP核效率高、时序经过验证。如果你需要跟非标准时序的传感器打交道或者需要多个SPI口但不想消耗太多逻辑资源手写一个精简的SPI主机模块更合适。如果SPI频率很高比如几十MHz以上或者对相位延迟非常敏感手写RTL反而容易精确控制因为你可以自定义时钟分频点、采样点位置、片选建立/保持时间。说白了IP核解决“有没有”的问题手写RTL解决“能不能按我的方式做”的问题。对于学习来说手写一遍SPI状态机绝对比直接拖IP核有价值得多这也是FPGA入门项目里SPI通信经久不衰的原因。1.3 适用场景图像采集、存储扩展、外设控制SPI在FPGA项目里出现的频率非常之高。这几年我做过或见过的典型应用包括传感器采集FPGA通过SPI读取加速度计、陀螺仪、ADC采样芯片的数据打上时间戳后做滤波比如卡尔曼滤波的前端采集这种场景对时序确定性要求高FPGA天然适合。存储扩展通过SPI接口连接W25Q64、W25Q256等NOR Flash用于存储配置参数、固件升级包、图像数据等。SPI Flash便宜、管脚少在FPGA最小系统里几乎是标配。显示屏驱动部分OLED、LCD模组支持SPI接口FPGA可以把图像数据通过SPI刷到屏幕上。不过注意SPI屏幕刷新率受限于时钟频率一般几MHz到几十MHz适合小分辨率屏。与MCU互联FPGA和STM32等MCU之间除了FMC/并口也常用SPI做低速控制通道传命令和状态。一句话总结SPI在FPGA项目里属于“小而重要”的基础模块花半天时间把它彻底搞透后面做项目能省很多无谓的调试时间。2. 核心细节解析SPI协议四种模式与时序设计2.1 CPOL、CPHA与四种工作模式别再搞混了SPI协议里最让初学者头疼的就是四种模式。其实只要抓住两个概念就不乱CPOLClock Polarity时钟极性决定空闲时SCK是高还是低CPHAClock Phase时钟相位决定数据在哪个边沿被采样。CPOL0空闲时SCK为低电平有效边沿是上升沿或下降沿取决于CPHA。CPOL1空闲时SCK为高电平有效边沿是下降沿或上升沿取决于CPHA。CPHA0在第一个边沿采样数据通常配合CPOL0时是上升沿采样。CPHA1在第二个边沿采样数据。合起来就是常见的SPI Mode 0~3模式CPOLCPHA空闲电平采样边沿常见用途Mode 000低上升沿W25Q系列Flash、多数传感器Mode 101低下降沿少量外设Mode 210高下降沿部分音频芯片Mode 311高上升沿SD卡、部分Flash实际项目里90%的从设备用Mode 0或Mode 3因为这两种模式下采样边沿都远离数据变化点时序裕量更大。我调试时习惯先查数据手册确认模式不确定的话优先试Mode 0然后在逻辑分析仪上看数据是否稳定再决定要不要换模式。2.2 分频设计如何得到稳定的SCKFPGA的时钟通常来自板载晶振或PLL常见的有50MHz、100MHz、125MHz等。SPI从设备支持的SCK上限各不同比如W25Q64在普通SPI模式下最高支持约50MHz但实际使用中为了保证时序裕量一般跑到10~40MHz比较稳妥。分频设计的思想很简单用系统时钟计数生成SCK输出。假设系统时钟是50MHz想要10MHz的SCK那么分频系数就是5。具体做法是定义一个计数器从0计数到4再回绕在中间某个点翻转SCK电平即可得到占空比50%的10MHz时钟。这里有一个细节SPI的SCK最好是由计数器生成的门控时钟而不是用PLL产生的独立时钟树。原因在于SPI的SCK频率不高而且只需要保证数据与SCK的相对关系正确用计数器分频实现更灵活还能随时在线调整。如果直接用PLL生成高频时钟送给从设备反而会因为PCB走线、管脚约束等引入额外的不确定性。还有个容易忽略的点SCK在空闲时保持不变只有在传输数据时才产生连续的时钟脉冲。所以更准确的做法是让状态机控制SCK的“使能”而不是让SCK永远翻转。2.3 发送与采样边沿为什么“中间采样”最稳SPI全双工通信的关键在于数据何时变化、何时被采样。主机在发送数据时数据线上的电平变化必须发生在SCK的某个边沿附近而从设备则在另一个边沿采样。如果两个边沿重合就会导致采样到不稳定的中间态。以Mode 0为例空闲时SCK为低CPHA0意味着在第一个边沿上升沿采样。所以发送端要保持数据在上升沿到来之前已经稳定。通常的做法是在SCK下降沿改变数据上升沿时数据已稳定从设备在上升沿采样。这样发送和采样相隔半个SCK周期天然留出了数据建立时间。FPGA实现时对应的状态机设计是在SCK下降沿把数据位的值赋给MOSI输出在SCK上升沿进行数据移位和采样MISO。这样保证数据是在“发送边沿”变化、“采样边沿”读取两个动作不会打架。2.4 硬件片选与软件片选区别没那么简单热搜词里“spi硬件片选与软件片选”出现频率很高。简单说硬件片选CS信号由FPGA的专用I/O或普通I/O直接控制电平变化与状态机严格同步。优点是时序精确CS拉低后过了指定的建立时间再产生SCK数据结束后SCK停在高/低电平再拉高CS。缺点是占用一个I/O管脚且如果设计不当CS毛刺会导致从设备误触发。软件片选CS直接接GND从设备常选或者通过寄存器手动拉低拉高。优点是省管脚缺点是无法在时序上保证严格的建立/保持时间多从设备场景下容易互相干扰。我的经验是除非从设备只有一个且支持常选模式否则不要用软件片选。硬件片选在RTL里多写两行代码的事却能避免大量莫名其妙的问题。比如W25Q系列Flash如果CS没有严格按“命令发送期间必须保持低电平”的要求来驱动Flash会直接把命令丢弃返回全FF或者0x00排查半天还以为SPI模式配错了。3. 实操过程手写一个SPI主机控制器3.1 状态机设计思路从IDLE到STOP的完整闭环写SPI主机模块本质是写一个状态机。我不建议一上来就写代码先画清楚状态跳转图。常见的状态划分如下IDLE等待传送请求。此时CS保持高电平SCK保持空闲电平MOSI为空闲电平或高阻。START拉低CS等待若干周期满足片选建立时间。TRANSFER按位发送/接收一个完整的字节需要8个SCK周期。每周期内又细分为“发送相位”和“采样相位”。STOP发送完最后一个位后等待若干周期满足保持时间再拉高CS回到IDLE。这个状态机的关键点在于TRANSFER状态内部需要精确控制SCK的翻转和位计数。我习惯用“半周期计数器”来管理SCK每个半周期产生一个tick再根据tick判断当前是发送沿还是采样沿。这样状态机代码结构清晰不容易出现竞争问题。3.2 关键代码示例一个精简的SPI主机模块Verilog下面给出一个精简但完整可综合的SPI主机发送/接收模块核心代码。它实现了Mode 0下的单字节读写接口简单方便集成到更大的系统里。module spi_master #( parameter CLK_FREQ 50_000_000, // 系统时钟频率 parameter SCLK_FREQ 5_000_000 // SPI时钟频率 )( input wire clk, input wire rst_n, input wire start, // 启动一次传输 input wire [7:0] tx_data, // 要发送的数据 output reg [7:0] rx_data, // 接收到的数据 output reg done, // 传输完成标志 output reg cs_n, // 片选低有效 output reg sclk, // SPI时钟 output reg mosi, // 主出从入 input wire miso // 主入从出 ); // 分频计数 localparam DIV_CNT CLK_FREQ / SCLK_FREQ / 2 - 1; reg [15:0] half_cnt; reg half_tick; // 状态机 localparam IDLE 2b00, START 2b01, TRANS 2b10, STOP 2b11; reg [1:0] state, next_state; // 位计数 reg [3:0] bit_cnt; reg [7:0] tx_shift, rx_shift; // 1. 半周期计数器生成 always (posedge clk or negedge rst_n) begin if (!rst_n) begin half_cnt 0; half_tick 1b0; end else if (state TRANS || state START || state STOP) begin if (half_cnt DIV_CNT) begin half_cnt 0; half_tick 1b1; end else begin half_cnt half_cnt 1b1; half_tick 1b0; end end else begin half_cnt 0; half_tick 1b0; end end // 2. 状态机主逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; cs_n 1b1; sclk 1b0; mosi 1b0; bit_cnt 0; tx_shift 0; rx_shift 0; done 1b0; end else begin case (state) IDLE: begin cs_n 1b1; sclk 1b0; done 1b0; if (start) begin tx_shift tx_data; bit_cnt 0; cs_n 1b0; // 拉低片选 state START; end end START: begin if (half_tick) begin sclk 1b0; // 保持空闲电平满足建立时间 state TRANS; end end TRANS: begin if (half_tick) begin if (!sclk) begin // 上升沿前的半周期输出数据 mosi tx_shift[7]; sclk 1b1; end else begin // 下降沿采样输入移位 rx_shift {rx_shift[6:0], miso}; tx_shift {tx_shift[6:0], 1b0}; sclk 1b0; if (bit_cnt 7) begin bit_cnt 0; state STOP; end else begin bit_cnt bit_cnt 1b1; end end end end STOP: begin if (half_tick) begin cs_n 1b1; // 拉高片选 rx_data rx_shift; done 1b1; state IDLE; end end endcase end end endmodule这段代码的要点半周期计数器生成half_tick作为所有状态跳变的节拍。TRANS状态里通过sclk当前值判断是进入上升沿半周期还是下降沿半周期实现“下降沿发送、上升沿采样”的效果注意这里为了跟Mode 0匹配在低电平段准备数据上升沿时数据稳定。位计数到7时说明一个字节传输完进入STOP状态。done信号保持一个周期告诉外部模块可以读取rx_data或者发起下一次传输。实际工程中你还需要考虑多字节传输、FIFO缓冲、中断反馈等但核心的时序框架就是上面这个样子。3.3 管脚约束与时序约束别再让工具乱猜RTL写完之后上板之前必须做管脚约束。Xilinx Vivado里用XDC文件Intel Quartus里用QSF文件。SPI四根线的管脚约束通常这么写以Vivado为例set_property PACKAGE_PIN AB12 [get_ports sclk] set_property IOSTANDARD LVCMOS33 [get_ports sclk] set_property PACKAGE_PIN AB13 [get_ports mosi] set_property IOSTANDARD LVCMOS33 [get_ports mosi] set_property PACKAGE_PIN AB14 [get_ports miso] set_property IOSTANDARD LVCMOS33 [get_ports miso] set_property PACKAGE_PIN AB15 [get_ports cs_n] set_property IOSTANDARD LVCMOS33 [get_ports cs_n]如果系统时钟和SPI之间有时序要求比如需要约束输入延迟set_input_delay要在综合后、实现前添加时序例外或输入延迟约束。不过在低速SPI10MHz以下场景下通常只要管脚约束正确、IOSTANDARD匹配、走线不过长基本都能正常工作。真正要关心set_input_delay的场景是SPI时钟跑到25MHz以上、或者外部器件手册里明确给出了建立/保持时间窗口时。这时候可以用逻辑分析仪实测后反推开set_input_delay的数值再交给Vivado去布局布线。3.4 FPGA方案与STM32 HAL库方案对比各自的坑很多人会问STM32的HAL库有现成的SPI驱动写起来多方便为什么还要FPGA做我的观点是两种方案适合不同阶段。STM32上HAL库操作SPI难点在于配置结构体参数多、中断/DMA处理复杂而且在多字节传输时容易出现“忙等待”逻辑写不好导致卡死的情况。调试NRF24L01这类对时序有特殊要求的无线模块时HAL库的SPI在某些版型上还会因为字节间间隔不合适导致通信失败。FPGA方案的优势在于你可以精确控制两个字节之间的间隔、CS释放的时机、SCK的每一个边沿。比如NRF24L01要求CSN拉低后SCK的第一个上升沿到来前必须满足一定的建立时间这在MCU软件里只能靠延迟函数“差不多”实现在FPGA里却是确定的。但FPGA方案也有劣势开发周期长、调试工具门槛高、灵活性不如软件改起来快。所以实际项目里我经常看到“FPGAMCU”混合架构FPGA做高速数据通路和严格时序接口MCU跑协议栈和应用逻辑SPI作为两者之间的命令通路。这种架构下SPI在FPGA侧反而要做成“可控、可诊断”的模块比如每个命令带序号、超时计数、错误标志位方便MCU侧判断通信是否正常。4. 实战案例FPGA读写W25Q64 Flash4.1 SPI Flash的命令集读ID、写使能、页编程W25Q64是目前最常用的SPI NOR Flash之一容量8MB支持SPI/Dual SPI/Quad SPI。它的命令集非常典型上手SPI Flash时拿它练手最合适。常用的命令如下命令命令码说明读JEDEC ID0x9F返回3字节厂商/型号/容量信息写使能0x06执行写操作前必须先发该命令读状态寄存器0x05读取忙标志等读数据0x03普通SPI读速度较慢页编程0x02一次最多写256字节扇区擦除0x20擦除4KB擦除后全为0xFF块擦除0xD8擦除64KB用FPGA读写W25Q64时最关键的一点是“命令这条链路不能断”。也就是说发命令期间、地址期间、数据期间CS都必须保持低电平不能在每个字节之间拉高CS。这个要求比普通的传感器通信严格得多也是软件模拟SPI时最容易犯的错。4.2 从单字节到多字节读操作的状态机扩展前面那个简单的SPI主机模块只支持单字节收发但W25Q64读数据命令0x03需要先发送命令码3字节地址然后连续读出N字节。因此需要把状态机扩展成“命令阶段 数据阶段”的模式。我的做法是定义一个传输长度寄存器tx_len和rx_len在TRANS状态内部用一个计数器记录已经传输的字节数。命令阶段结束后根据当前是读命令还是写命令决定后续的数据字节方向和长度控制。这个扩展不复杂但需要仔细处理字节计数和CS时序。从系统集成角度我习惯把SPI Flash操作封装成“从设备驱动层”对外提供三个简单接口读ID、读数据、写数据。上层逻辑只需要发起请求、等待完成不关心具体时序。这样FPGA内部如果还有图像处理、卡尔曼滤波等模块主控逻辑可以很干净。4.3 踩坑实录片选时序、忙等待、写保护说几个我实际调试W25Q64时踩过的坑给后来人提个醒。第一个坑是片选时序。刚开始写的SPI模块在命令结束后立即拉高CS但Flash内部正在处理写命令。如果你紧接着发读状态命令CS拉低间隔太短Flash可能还没准备好。解决办法是在两次命令之间插入延时或者通过读状态寄存器的忙标志来等待。W25Q系列擦除操作耗时较长扇区擦除几十ms必须有忙等待机制。第二个坑是写保护。W25Q64默认状态寄存器里的WEL位写使能锁存在每次写完操作后会被清除所以每次写操作前都必须重新发送0x06写使能命令。很多初学者只发一次写使能然后连续写多个页结果只有第一页写入成功。从FPGA状态机的角度看就是“每个页编程命令之前都要先发0x06”这个顺序不能省。第三个坑是地址和数据的位序。SPI协议规定高字节在前、高bit在前这跟很多MCU习惯的“最低有效位先行”相反。你发送0x123456的地址时必须按字节顺序0x12、0x34、0x56依次送出如果顺序错乱Flash会访问错误的地址空间读出来的数据完全不对。再者W25Q系列Quad SPIQSPI模式下的命令码、管脚复用规则和普通SPI不同。如果你从普通SPI切换到Quad模式需要先发写使能再写状态寄存器2的QE位之后才能使用四线模式。这个切换如果放在上电初始化阶段做务必要等待芯片从复位状态完全释放否则命令会被吞。4.4 从SPI扩展到QSPI/DSPI什么时候值得升级热搜词里“DSPI和QSPI的区别”被问到很多次。简单说DSPIDual SPIMOSI和MISO两根线都用于发送数据读操作时MISO发奇数位、MOSI发偶数位速度翻倍。QSPIQuad SPI四根IO线都用于数据传输读速度进一步提升通常还配合DTR双倍速率模式。用FPGA实现QSPI关键变化在IO方向控制和数据拼接逻辑。普通SPI的MOSI永远是输入到FPGA逻辑的方向约束为输出MISO是输入而QSPI的四根线每根都要支持输入和输出双向切换通常用三态缓冲器配合IO方向寄存器实现。这个复杂度比普通SPI高不少建议先彻底跑通普通SPI再做扩展。5. 常见问题与调试技巧5.1 调试工具怎么选逻辑分析仪 示波器 ILASPI是数字信号频率最高也就几十MHz用逻辑分析仪查看波形是最高效的方式。几十块钱的8通道逻辑分析仪就能覆盖SCK、MOSI、MISO、CS四根线还能自动解析SPI协议。相比之下示波器更适合看模拟特性比如过冲、边沿斜率但对协议级调试帮助有限。Xilinx Vivado自带的ILA集成逻辑分析仪核也值得用尤其当SPI信号在FPGA内部生成、外部管脚不好测量的时候。ILA可以实时抓取内部的sclk_reg、mosi、miso、state等信号并且支持条件触发。缺点是要占用片上BRAM和逻辑资源调试完成后记得关闭。实话说最推荐的调试组合是先在仿真环境里用testbench验证SPI模块时序再上板用逻辑分析仪实测真实波形。很多问题在仿真阶段就能暴露比如分频计算错误、状态机卡死、位序颠倒。上板后再排查效率会低很多。5.2 常见故障速查表现象可能原因排查方法读数据全0xFF片选未严格保持低电平检查CS时序确认命令期间CS不被拉高读数据全0x00从设备未响应或MISO接线错误检查MISO管脚约束、电平标准数据偶发错位SPI模式不匹配确认CPOL/CPHA尝试切换Mode发送正常但接收固定错1bit采样边沿选择不当把采样沿移半个周期或在SCK中间采样初始化不成功写保护未解除或忙等待缺失先发0x06再操作轮询0x05忙标志偶尔通信超时时钟分频导致占空比不稳定用计数器生成占空比50%的SCKQSPI读不到ID未切换Quad模式或IO方向控制错误确认QE位检查四线IO方向寄存器5.3 资源占用与性能评估一个精简的SPI主机模块在FPGA上的资源占用非常小我记得一个单通道Mode 0/1/2/3自适应、带FIFO的SPI主机在Xilinx Artix-7上大概只用到几十个LUT、几十个FF几乎可以忽略不计。即使例化8个通道资源也远小于一个MicroBlaze软核或者图像处理IP。性能方面SPI的瓶颈在以下三个地方SCK最高频率由FPGA的I/O速度、从设备支持上限、PCB走线质量共同决定。普通管脚跑25MHz问题不大跑50MHz就要开始注意走线等长和端接了。数据吞吐量考虑全双工时每周期传1bitSCK10MHz时理论吞吐约10Mbit/s刨去命令开销实际有效数据吞吐可能只有60%~70%。协议开销读Flash时命令地址4字节是额外开销若一次只读1字节实际效率很低一次读256字节时开销占比就很小了。如果你做的项目对吞吐要求很高比如SPI屏幕刷新率不够、图像数据传不过来优先考虑优化数据长度、提升SCK频率而不是折腾FPGA内部逻辑——后者带来的收益非常有限。最后再说点个人体会。SPI在协议族里其实算简单的但它包含了很多数字系统设计的共性时钟分频、状态机、数据移位、片选管理、时序约束。把它在FPGA里完整实现一遍比单纯在MCU上调用库函数收获大得多因为你被迫面对每一个时钟沿、每一位数据、每一次状态跳转。以后再去接触IIC、UART、MIPI、LVDS这些接口你会发现底层的思维是相通的——都是“在正确的时刻把正确的数据放到正确的线上”。希望这篇内容能帮你在SPI和FPGA这条路上少踩几个坑。