
1. 从“点灯”到“通信”为什么SPI是FPGA工程师的必修课很多朋友刚接触FPGA时都是从点亮一个LED灯开始的。看着自己写的Verilog代码通过一个简单的计数器控制GPIO引脚的高低电平让LED闪烁起来那种成就感是实实在在的。但很快你就会发现FPGA的世界远不止于此。当你想让FPGA读取一个温度传感器的数据或者控制一块OLED屏幕显示字符时你立刻就会撞上“通信协议”这堵墙。而SPI往往是这堵墙上最容易打开的第一扇门。我刚开始学FPGA那会儿也卡在这里很久。看着数据手册里SPI的时序图那些CS、SCLK、MOSI、MISO信号线感觉比控制LED复杂了不止一个数量级。但后来我明白了SPISerial Peripheral Interface串行外设接口之所以成为嵌入式领域最基础、最常用的通信协议之一恰恰是因为它的“简单”。这种简单不是功能上的简陋而是设计哲学上的清晰和直接。它不像I2C那样需要复杂的起始、停止条件和应答机制也不像UART那样需要事先约定好波特率。SPI的核心就是“同步”和“全双工”主设备提供一个时钟从设备跟着这个时钟的节拍收发数据你发一位我同时收一位效率高实现起来逻辑也相对规整。对于FPGA工程师来说掌握SPI的意义远不止于驱动某一个具体的外设。它更像是一把钥匙帮你理解数字系统中“主从”设备如何协同工作如何通过几根线实现高效的数据交换。更重要的是用Verilog或VHDL去实现一个SPI主控制器是一个绝佳的、从组合逻辑/时序逻辑的简单应用过渡到复杂状态机设计的实战项目。你会第一次真正需要去仔细阅读一份外设的数据手册理解它的寄存器映射、命令格式和时序要求然后把这些要求翻译成精确的时钟周期级的硬件行为。这个过程是FPGA开发从“玩具”走向“工具”的关键一步。所以这篇笔记不会只给你一段能“跑通”的SPI代码。我更想和你一起从硬件工程师的视角拆解SPI协议的每一个细节理解其背后的设计考量然后用Verilog亲手搭建一个健壮、可配置的SPI主控制器。我们会以驱动一个常见的SPI Flash存储器比如W25Q128为例但设计的核心模块将具备通用性稍作修改就能适配各种SPI设备。准备好了吗让我们从最根本的协议层开始。2. 深入骨髓SPI协议的四线制与工作模式详解在开始写代码之前我们必须把SPI的“规矩”吃透。很多人对SPI有个模糊的印象四根线主从通信。但具体是哪四根它们怎么配合为什么同样的设备有的代码能通有的就不行问题往往出在对工作模式的理解偏差上。SPI通常需要四根信号线这是它的物理基础SCLK (Serial Clock串行时钟)由主设备产生并输出给所有从设备。这是整个通信的节拍器所有数据位的传输都严格对齐时钟边沿。这里有个关键点SCLK在空闲状态即没有数据传输时的电平是可配置的这直接引出了CPOL参数。MOSI (Master Out Slave In主出从入)主设备的数据输出线从设备的数据输入线。主设备通过这根线向从设备发送指令和数据。MISO (Master In Slave Out主入从出)主设备的数据输入线从设备的数据输出线。从设备通过这根线向主设备回传数据。注意在单主多从的典型架构中所有从设备的MISO线需要“线与”通常通过三态门实现主设备通过片选信号来选择哪一个从设备有权驱动MISO线避免总线冲突。CS/SS (Chip Select / Slave Select片选/从机选择)由主设备控制低电平有效。当主设备想和某个从设备通信时就将对应从设备的CS线拉低。这是SPI总线实现“一主多从”的关键。CS信号必须在整个数据传输周期内保持有效低电平。理解了物理连接我们来看最核心也最容易出错的部分SPI的工作模式。这由两个参数共同决定时钟极性CPOL和时钟相位CPHA。CPOL (Clock Polarity时钟极性)定义SCLK线在空闲状态CS无效无数据传输时的电平。CPOL0SCLK空闲时为低电平。CPOL1SCLK空闲时为高电平。CPHA (Clock Phase时钟相位)定义数据在时钟的哪个边沿被采样捕获。CPHA0数据在时钟的第一个边沿如果CPOL0则是上升沿如果CPOL1则是下降沿被采样并在下一个边沿切换。CPHA1数据在时钟的第二个边沿被采样并在同一个边沿切换。这两个参数的组合形成了四种SPI模式通常用Mode 0到Mode 3表示Mode 0 (CPOL0 CPHA0)空闲时SCLK为低数据在SCLK的上升沿被采样下降沿切换。这是最常用的模式很多传感器、Flash芯片都默认使用此模式。Mode 1 (CPOL0 CPHA1)空闲时SCLK为低数据在SCLK的下降沿被采样上升沿切换。Mode 2 (CPOL1 CPHA0)空闲时SCLK为高数据在SCLK的下降沿被采样上升沿切换。Mode 3 (CPOL1 CPHA1)空闲时SCLK为高数据在SCLK的上升沿被采样下降沿切换。一个必须牢记的实操要点你所实现的SPI控制器模式必须与外设数据手册中要求的工作模式完全一致否则数据采样会错位导致通信完全失败。在阅读数据手册时要仔细查找关于“SPI Mode”或“CPOL/CPHA”的描述。例如W25Q128 Flash芯片就支持Mode 0和Mode 3。为了更直观地理解这四种模式下的时序差异特别是数据采样MOSI/MISO稳定和切换的时机我整理了下表。在设计状态机时这张表能帮你清晰地确定在哪个时钟边沿需要做什么操作。SPI模式CPOLCPHASCLK空闲电平数据采样边沿 (主设备采样MISO)数据切换边沿 (主设备切换MOSI)常见应用设备举例Mode 000低电平SCLK上升沿SCLK下降沿W25Q系列SPI Flash, BMP280传感器Mode 101低电平SCLK下降沿SCLK上升沿某些型号的SD卡SPI模式Mode 210高电平SCLK下降沿SCLK上升沿较少见Mode 311高电平SCLK上升沿SCLK下降沿MAX31855热电偶转换器如何根据上表设计你的Verilog代码以最常用的Mode 0为例。对于主设备来说“数据采样边沿”意味着你需要在这个边沿到来之前确保MISO线上的数据已经稳定从设备已经准备好。因此你的状态机应该在SCLK的下降沿采样边沿的前一个边沿去锁存采样MISO的数据。而“数据切换边沿”意味着你需要在这个边沿将下一位要发送的数据放到MOSI线上。所以你的状态机应该在SCLK的上升沿切换边沿去更新MOSI的输出。这个“提前半个周期准备数据”的思想是保证SPI时序稳定的关键。3. 架构与实现设计一个可配置的SPI主控制器状态机理解了协议我们就可以动手用Verilog来塑造它了。我们的目标是设计一个通用的SPI主控制器模块它应该具备以下特性可配置的工作模式通过参数或输入端口支持Mode 0-3。可配置的时钟分频能够根据FPGA的系统时钟比如50MHz和所需的SPI时钟速率比如25MHz进行分频。易于集成的用户接口提供类似“启动传输”、“传输完成”、“待发送数据”、“接收到的数据”这样的握手信号方便上层状态机调用。处理任意字节长度虽然SPI通常以8位一个字节为单位但我们的设计应该能通过计数器轻松扩展到16位、24位等以支持某些特殊外设的命令格式。首先我们定义模块的输入输出端口。除了标准的时钟和复位核心是用户控制接口和SPI物理接口。module spi_master #( parameter CLK_DIV 2, // 系统时钟分频系数SCLK sys_clk / (2 * CLK_DIV) parameter CPOL 0, // 时钟极性 parameter CPHA 0 // 时钟相位 )( input wire sys_clk, // 系统时钟 (e.g., 50MHz) input wire rst_n, // 低电平复位 // 用户控制接口 input wire start, // 启动一次传输高电平脉冲有效 input wire [7:0] data_tx, // 待发送的数据8位 output reg [7:0] data_rx, // 接收到的数据8位 output reg busy, // 忙标志传输过程中为高 output reg done, // 传输完成标志高电平脉冲 // SPI物理接口 output reg sclk, // SPI串行时钟 output reg mosi, // 主设备数据输出 input wire miso, // 主设备数据输入 output reg cs // 片选信号低电平有效 );接下来是核心的状态机设计。一个典型的SPI字节传输过程可以分为以下几个状态IDLE空闲等待start信号。CS为高无效SCLK处于空闲电平由CPOL决定。START启动收到start脉冲后拉低CS选中从设备并根据CPHA准备第一个数据位。这里有个细节如果CPHA0第一个数据位需要在第一个SCLK边沿之前就出现在MOSI上。因此在START状态就需要将data_tx的最高位MSB输出到MOSI。DATA数据传输在这个状态中我们通过一个计数器控制循环8次每次循环完成一位数据的发送和接收。根据CPOL和CPHA在SCLK的适当边沿切换MOSI和采样MISO。STOP停止8位数据传输完毕将CS拉高结束本次通信。产生done完成脉冲。状态机的状态转移和输出逻辑需要严格对照我们之前分析的时序表。下面以Mode 0 (CPOL0 CPHA0)为例给出关键部分的代码逻辑和注释。// 状态定义 localparam S_IDLE 2b00; localparam S_START 2b01; localparam S_DATA 2b10; localparam S_STOP 2b11; reg [1:0] state, next_state; reg [3:0] bit_cnt; // 位计数器0-7 reg [7:0] tx_reg; // 发送移位寄存器 reg [7:0] rx_reg; // 接收移位寄存器 reg sclk_en; // SCLK使能信号高电平时才翻转SCLK reg [7:0] clk_div_cnt; // 时钟分频计数器 // 时钟分频逻辑用于产生SCLK always (posedge sys_clk or negedge rst_n) begin if (!rst_n) begin clk_div_cnt 8d0; sclk CPOL; // 复位时SCLK置于空闲电平 end else if (sclk_en) begin if (clk_div_cnt CLK_DIV - 1) begin clk_div_cnt 8d0; sclk ~sclk; // 达到分频计数翻转SCLK end else begin clk_div_cnt clk_div_cnt 1; end end else begin // sclk_en为低时保持SCLK为空闲电平 sclk CPOL; clk_div_cnt 8d0; end end // 主状态机 always (posedge sys_clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; cs 1b1; mosi 1b1; // 通常MOSI空闲时置高 busy 1b0; done 1b0; bit_cnt 4d0; tx_reg 8h00; rx_reg 8h00; data_rx 8h00; sclk_en 1b0; end else begin done 1b0; // 默认done信号为0只在完成时产生一个周期脉冲 case (state) S_IDLE: begin cs 1b1; mosi 1b1; busy 1b0; sclk_en 1b0; if (start) begin state S_START; busy 1b1; tx_reg data_tx; // 锁存要发送的数据 rx_reg 8h00; // 清空接收寄存器 end end S_START: begin cs 1b0; // 拉低片选选中设备 // Mode 0 (CPHA0): 在第一个SCLK边沿之前就准备好数据 if (CPHA 0) begin mosi tx_reg[7]; // 输出最高位(MSB) tx_reg {tx_reg[6:0], 1b0}; // 左移为下一位准备 end // 等待一段时间建立时间(Tcs)这里用状态停留实现 // 实际中可根据需要插入计数器延时 state S_DATA; sclk_en 1b1; // 使能SCLK生成 bit_cnt 4d0; end S_DATA: begin // 关键根据CPHA决定在SCLK的哪个边沿采样和切换 // 以下针对 Mode 0 (CPOL0, CPHA0) 的写法 // 假设sclk在分频逻辑中生成我们检测其边沿 if (sclk_en clk_div_cnt CLK_DIV - 1) begin // 当SCLK即将翻转时即clk_div_cnt计满根据当前SCLK电平判断边沿 // 对于CPOL0sclk从0-1是上升沿从1-0是下降沿 if (sclk 1b0) begin // 当前是低电平下一个时钟是上升沿 // 在上升沿采样边沿采样MISO rx_reg {rx_reg[6:0], miso}; end else begin // 当前是高电平下一个时钟是下降沿 // 在下降沿切换边沿切换MOSI输出 if (bit_cnt 4d7) begin // 前7位 mosi tx_reg[7]; tx_reg {tx_reg[6:0], 1b0}; bit_cnt bit_cnt 1; end else begin // 第8位已经处理完 // 第8位数据在START状态或上一个下降沿已输出 // 接收第8位数据已经在上升沿完成 state S_STOP; sclk_en 1b0; // 停止SCLK end end end end S_STOP: begin // 保持CS低电平一段时间(Thold)这里用状态停留 // 将接收到的数据输出 data_rx rx_reg; done 1b1; // 产生完成脉冲 state S_IDLE; end endcase end end注意上面的代码是一个高度简化的示例重点在于展示状态机和时序控制的思想。一个健壮的工业级设计还需要考虑更多细节比如精确的建立/保持时间Tsu/Thd通过计数器来保证、CS信号的建立和保持时间、支持CPHA1的模式、以及更优雅的边沿检测方法通常使用寄存器打拍来检测sclk的上升沿和下降沿。在S_DATA状态中通过检测clk_div_cnt来判断SCLK边沿的方法在低速下可行但在高速或要求严格时序的情况下建议使用独立的边沿检测逻辑。4. 实战驱动SPI Flash以W25Q128为例的完整流程有了通用的SPI主控制器我们就可以用它来驱动具体的设备了。我们选择SPI Flash W25Q128作为例子因为它命令集丰富包含了读、写、擦除等典型操作非常适合用来检验我们的SPI控制器。驱动一个外设本质上是按照其数据手册的时序和命令格式通过SPI总线发出一系列特定的字节序列。4.1 理解W25Q128的命令帧与数据帧W25Q128的通信遵循一个基本格式先拉低CS片选信号然后主设备发送一个指令字节对于某些指令如读数据0x03后面还需要跟上地址字节24位3个字节然后才是数据的读写。传输完成后拉高CS。例如读取数据的命令时序如下CS拉低。发送指令0x03。发送24位地址A23-A0高位在前。之后从设备会从该地址开始通过MISO线持续输出数据每个SCLK周期输出一位直到CS被拉高。CS拉高结束读取。这里有一个非常重要的细节W25Q128支持标准SPI单线、Dual SPI和Quad SPI模式。我们这里只使用最基础的标准SPI模式即只用MOSI和MISO两根数据线。在标准SPI下所有指令、地址、数据的传输都是MSB最高位在前。4.2 构建上层控制状态机我们的SPI主控制器spi_master模块负责最底层的字节传输。我们需要一个上层的控制模块比如叫flash_controller来组织这些字节形成完整的命令帧。这个模块也是一个状态机它负责在收到“读Flash”的请求时依次将指令0x03、地址字节1、地址字节2、地址字节3通过spi_master发送出去。然后连续循环调用spi_master发送哑元数据如0x00同时从spi_master接收返回的数据即为Flash中读出的数据。管理CS信号。注意对于W25Q128一次读命令从CS拉低开始到读完所需数据后再拉高CS中间CS不能拉高否则命令会终止。下面是一个简化的flash_controller读取逻辑的状态描述IDLE: 等待读请求。SEND_CMD: 启动spi_master发送指令0x03。等待spi_master的done信号。SEND_ADDR_H:spi_master完成后启动下一次传输发送地址高字节。SEND_ADDR_M: 发送地址中字节。SEND_ADDR_L: 发送地址低字节。READ_DATA: 循环发送哑元数据0x00并读取spi_master返回的数据存入缓冲区。每读完一个字节地址加1直到读完指定长度。FINISH: 拉高CS通过控制spi_master的start信号停止或直接控制CS产生读完成信号。// flash_controller 模块的部分关键逻辑示例 reg [2:0] state; reg [23:0] rd_addr; // 读地址 reg [7:0] rd_len; // 要读取的字节数 reg [7:0] data_buffer [0:255]; // 数据缓冲区 reg [7:0] buffer_idx; always (posedge sys_clk or negedge rst_n) begin if(!rst_n) begin state IDLE; spi_start 1b0; spi_data_tx 8h00; end else begin case(state) IDLE: if(rd_req) begin rd_addr rd_addr_in; rd_len rd_len_in; state SEND_CMD; end SEND_CMD: begin spi_data_tx 8h03; // 读指令 spi_start 1b1; if(spi_done) begin spi_start 1b0; state SEND_ADDR_H; end end SEND_ADDR_H: begin spi_data_tx rd_addr[23:16]; spi_start 1b1; if(spi_done) begin spi_start 1b0; state SEND_ADDR_M; end end // ... 发送中地址和低地址状态类似 READ_DATA: begin spi_data_tx 8h00; // 发送哑元数据以产生SCLK读取MISO spi_start 1b1; if(spi_done) begin spi_start 1b0; data_buffer[buffer_idx] spi_data_rx; // 保存数据 buffer_idx buffer_idx 1; rd_addr rd_addr 1; // 地址递增为连续读做准备 if(buffer_idx rd_len - 1) begin state FINISH; end end end FINISH: begin // 产生完成中断或信号 state IDLE; end endcase end end4.3 上板调试与逻辑分析仪抓取波形代码写好了仿真也通过了但上板后可能就是不工作。这时候逻辑分析仪Logic Analyzer是你的救命稻草。我强烈建议每个FPGA开发者都学会使用它像Saleae Logic这类USB逻辑分析仪就很好用。将逻辑分析仪的探头连接到FPGA板上的SCLK、MOSI、MISO、CS信号线。设置触发条件为CS下降沿。然后启动你的FPGA程序发起一次读操作。抓取到的波形是你排查问题的黄金依据。你需要对照W25Q128的数据手册逐一检查时序模式SCLK空闲电平对吗数据是在正确的边沿采样和切换的吗对应CPOL/CPHA命令序列CS拉低后发送的第一个字节是不是0x03后面跟的三个地址字节是否正确MSB在前吗时序参数CS拉低到第一个SCLK边沿的时间Tcss够吗数据建立时间Tsu和保持时间Thd满足要求吗这些时间在数据手册里都有明确的最小值要求。从设备响应在发送完地址后的SCLK周期里MISO线上是否有数据输出数据是否稳定我遇到过最常见的问题就是模式不对。代码里默认用了Mode 0但有些板子或Flash可能被配置成了其他模式。还有一次是地址字节序错了我误发了LSB在前。通过逻辑分析仪你能一眼看到实际发出的指令序列从而快速定位是控制器代码问题还是外设本身或硬件连接问题。5. 性能优化与高级话题从基础SPI到效率提升当你成功驱动了SPI Flash实现了基本的读写后可以思考如何优化和扩展。这能让你对SPI的理解更深一层。5.1 时钟分频与速度权衡我们的spi_master模块有一个CLK_DIV参数。SPI的时钟频率SCLK并非越快越好。你需要考虑两个限制从设备支持的最高SCLK频率W25Q128在标准SPI模式下最高支持104MHz。你的控制器必须能产生低于或等于这个频率的时钟。FPGA到从设备的PCB走线长度频率越高信号完整性越重要。过长的走线可能导致边沿畸变产生通信错误。通常在面包板或飞线环境下建议先从低频如1MHz以下开始测试稳定后再逐步提高。在FPGA内部更高的SCLK意味着你的状态机和控制逻辑需要运行在更高的系统时钟下以确保能精确控制SCLK的边沿。例如要产生50MHz的SCLK你的系统时钟sys_clk可能需要达到100MHz或更高才能进行可靠的边沿检测和信号生成。5.2 引入FIFO缓冲与DMA思想目前我们的设计是“请求-响应”式的上层要一个字节SPI控制器传一个字节。当需要连续读取大量数据比如读取整个Flash扇区时这种频繁的握手会消耗大量FPGA逻辑资源和时间。一个常见的优化是引入FIFOFirst In First Out缓冲区。在发送端上层模块可以提前将需要发送的多个字节如一条完整的读命令地址写入发送FIFO。SPI控制器状态机则从发送FIFO中依次取出字节发送无需等待上层干预。在接收端SPI控制器将收到的字节直接写入接收FIFO。上层模块可以从接收FIFO中连续读取数据。这相当于为SPI控制器配备了一个“流水线”大大提高了连续传输的效率和吞吐量。如果再进一步配合一个简单的DMA直接内存访问控制器让DMA自动从FPGA内部的Block RAM或外部存储器中取出要发送的数据填入发送FIFO并将接收FIFO的数据搬移到目标存储器那么FPGA的软核处理器如NIOS II甚至可以不参与具体的数据搬运过程极大解放了CPU。5.3 应对更复杂的SPI变种Dual/Quad SPI与QPIW25Q128等现代SPI Flash为了提升速度支持了Dual SPI和Quad SPI。在Dual SPI模式下MOSI和MISO线在指令和地址阶段后可以同时用于数据传输变成IO0和IO1每个时钟周期传输2位数据。在Quad SPI模式下四根数据线IO0-IO3全部用于数据传输每个时钟周期传输4位数据。要实现这些模式你的SPI控制器就需要升级为支持可配置数据线宽度的控制器。在发送了特定的“使能Quad IO”命令后后续的数据传输阶段控制器需要同时驱动或采样2根或4根数据线。这涉及到更复杂的IO管理和时序控制但对提升Flash的读取性能尤其是执行代码的XIP功能是质的飞跃。QPIQuad Peripheral Interface模式则更进一步连指令和地址也通过4线传输。从基础的单线SPI到Quad SPI这个演进路径清晰地展示了如何通过增加数据线宽度来突破串行接口的带宽瓶颈。理解了这个你也就理解了为什么高速存储器接口如DDR会是并行总线。6. 避坑指南与调试心得那些数据手册里不会写的细节最后分享几个我在调试SPI特别是Flash这类设备时踩过的坑和总结的经验。这些在官方数据手册里往往一笔带过但却是项目成败的关键。坑一Flash的写使能与写保护W25Q128上电后默认是写保护状态。在执行任何写操作如页编程0x02、扇区擦除0x20之前必须先发送“写使能”指令0x06。而且这个使能状态不是永久的在一次写操作完成后或者断电后状态会自动回到写禁止。所以你的写操作流程必须是0x06写使能 - 等待一小段时间Twen -0x02页编程 - 等待编程完成检查忙状态位或延时Tpp。忘记发0x06是最常见的“写了半天没反应”的原因。坑二忙状态查询Flash在执行写或擦除操作时需要一定时间毫秒级这段时间内它不响应任何指令。你有两种方法处理硬延时发送写/擦除命令后简单延时足够长的时间如扇区擦除最多400ms。简单粗暴但效率低会阻塞整个系统。查询状态寄存器发送“读状态寄存器1”指令0x05检查返回值的第0位BUSY位。为1表示忙为0表示空闲。这是推荐的做法。你需要在上层状态机中实现一个轮询循环不断发送0x05并检查直到BUSY位为0再继续后续操作。坑三地址对齐与跨页写Flash的页编程操作0x02一次最多写入256字节。但有一个重要限制写入的起始地址和数据的结合不能跨越页边界。一页的起始地址是256字节对齐的如0x0000000x000100。如果你想从地址0x0000F0开始写入20个字节这是允许的。但如果你想从0x0000F0开始写入30个字节那么后10个字节就会“跨页”到0x000100开始的页这是不允许的会导致写入错误。安全的做法是在写之前由软件或硬件逻辑检查地址和长度如果跨页则拆分成两次页编程操作。调试心得善用仿真与虚拟模型在接触一块新的SPI芯片时不要急于上板。先在仿真环境里搭建测试平台Testbench。你可以用Verilog编写一个简单的SPI从设备行为模型Behavioral Model这个模型不需要实现芯片的所有功能只需要能正确响应你当前测试的指令即可比如对读指令0x03返回一个预定义的数据序列。用这个模型来验证你的SPI控制器状态机和命令序列是否正确比上板调试高效得多。很多FPGA开发环境如Vivado、Quartus也自带一些IP核的仿真模型可以加以利用。实现一个SPI控制器就像在FPGA内部搭建了一座与外部世界沟通的精确桥梁。从理解协议的四根线和四种模式到用状态机将时序图转化为Verilog代码再到驱动真实外设时处理各种边界情况和异常状态每一步都是对数字逻辑设计能力的扎实锻炼。当你第一次通过自己写的代码从Flash中正确读出制造商ID0xEF0x40时那种喜悦和信心是无可替代的。更重要的是掌握了SPI的设计思路你再去看I2C、UART甚至更复杂的协议会发现它们核心的思想是相通的定义好物理层、数据链路层的规则然后用状态机忠实地实现它。希望这篇笔记能帮你稳稳地跨过FPGA与外部器件通信的第一道门槛。