十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

FPGA实战:SPI通信从零实现到稳定跑通的完整指南与避坑经验

FPGA实战:SPI通信从零实现到稳定跑通的完整指南与避坑经验 做FPGA开发也快十年了从最早的Lattice到现在的Xilinx、Intel Cyclone系列都过过手。SPI这个接口看着简单实际在工程里翻车的概率却不低。尤其是当你把它挂到FPGA上时序、跨时钟域、片选策略、主机从机角色分配每一个点都能让人调上半天。今天就拿这段时间我手头一个项目的真实经历把SPI通信在FPGA上从零实现到稳定跑通的思路、代码、调试方法全捋一遍。不管你是刚摸FPGA的新手还是被SPI折磨过的老哥们这篇应该都能给你点参考。1. SPI通信核心机制回顾在动手写代码之前先把SPI协议本身吃透。很多问题看起来是代码写错了根子上其实是协议理解有偏差或者是物理层、时序边界没考虑清楚。1.1 物理层连接与信号定义SPI全称Serial Peripheral Interface摩托罗拉在七十年代搞出来的串行外设接口总线四根线搞定全双工通信到今天工业级板卡、传感器、存储芯片还在大规模用。FPGA里做SPI本质就是把这四根线的时序用逻辑门实现出来。SCLKSerial Clock时钟线由主机产生从机完全被动接收。MOSIMaster Out Slave In主机输出、从机输入。MISOMaster In Slave Out主机输入、从机输出。CS/SSChip Select片选信号低电平有效。主机拉低时选中从机通信结束后拉高释放总线。工程里最常见的问题出在CS上尤其是硬件自动片选和软件手动片选混用的时候后面我单独开一节细说。这里先记住一个关键点SPI不是总线协议没有设备地址概念。 要跟谁通信就把谁的CS拉低同一时刻总线上只允许一个从机被选中否则MISO会打架。1.2 四种工作模式与时钟极性相位SPI的四种模式由CPOLClock Polarity时钟极性和CPHAClock Phase时钟相位组合决定。CPOL决定空闲态时钟电平CPHA决定数据采样沿。模式CPOLCPHA空闲时钟数据采样沿数据切换沿Mode 000低电平上升沿下降沿Mode 101低电平下降沿上升沿Mode 210高电平下降沿上升沿Mode 311高电平上升沿下降沿FPGA实现时很多人直接在代码里写always (posedge sclk)就完事完全没考虑CPOL和CPHA的匹配。我实测过的项目里传感器芯片比如AD7190要求Mode 3Flash芯片W25Q128JV支持Mode 0和Mode 3LCD驱动ST7789默认Mode 0ADC芯片ADS1256则是Mode 1。 对接不同外设前第一件事就是查数据手册里SPI章节的时序图确定模式再开写。从实现角度主机侧时钟相位其实是最好控制的你生成SCLK的计数器在哪个边沿翻转决定的就是CPOL你在模拟SCLK的时候在每个SCLK周期的哪个点上锁存MISO、更新MOSI决定的就是CPHA。所以主机的SPI模式是自己定义出来的不匹配是设计失误。而从机侧就没这么简单了你的采样窗口要严格对齐外部输入时钟的边沿这在跨时钟域下很容易出问题后面会专门说。1.3 数据帧格式与传输粒度SPI没有固定帧格式常见的是8位、16位、24位、32位甚至有些传感器是12位或者20位。FPGA实现时要注意SPI的“位计数”是主机说了算的主机发出多少个SCLK脉冲从机就必须接收多少位双方必须提前约定一致。比如ADS1256的24位转换结果如果主机只发16个时钟从机只会输出高16位低8位永远拿不到如果主机发32个时钟读到的结果还要自己移位。所以设计SPI控制器时位的宽度最好做成参数化不要写死在代码里。后续代码里我会把DATA_WIDTH设计成可配置的。2. FPGA实现SPI的方案选型与架构设计协议清楚了接着就是架构层面的取舍。这里直接说结论FPGA上实现SPI不是只能写一版Verilog就通了你得先想清楚角色主机还是从机、时钟域策略同步设计还是异步桥接、片选策略硬件还是软件这三大问题不然返工成本非常高。2.1 主机模式时钟生成策略主机模式下SCLK必须由FPGA内部产生。这里有个常见误区想当然地用计数器对系统时钟分频却没有考虑占空比和相位抖动。对于低速SPI1MHz以下直接计数器分频完全没问题。但对于10MHz以上的高速SPI我建议用PLL/MMCM产生专门的SPI时钟域或者至少在RTL里把分频逻辑约束好避免组合逻辑直接驱动SCLK引脚那会造成毛刺。两种常用做时钟的方式对比方式实现优点缺点计数器分频系统时钟计数翻转简单灵活频率实时可调高速时占空比与抖动控难保证PLL输出调用IP核配置频率时钟质量高适合高速外设频率固定改动需重配IP我在实际项目里是这样处理的SPI时钟频率低于5MHz时用计数器分频高于5MHz时改用PLL。注意计数器分频时SCLK的相位不能完全与系统时钟对齐驱动外部器件没有问题但如果SCLK还要反馈回FPGA内部采数据就要特别小心建立时间。2.2 从机模式外部时钟的跨时钟域处理从机模式的复杂度和主机完全不在一个级别。外部主机产生的SCLK与FPGA内部时钟完全异步如果直接用内部时钟去采样外部SCLK很容易打拍打到亚稳态或者漏采。最稳的做法是先对外部SCLK做两级寄存器同步打两拍然后用同步后的时钟沿作为事件触发再配合和内部系统时钟的双时钟域握手。具体实现细节后面第四节会展开。2.3 模块划分与整体架构SPI控制器通常划分为三个独立的子模块职责互不交叉方便测试和复用。spi_master_ctrl主机核心状态机产生片选、时钟、移位寄存器控制信号。spi_slave_ctrl从机核心状态机识别片选与时钟沿收发数据。spi_reg_file寄存器或FIFO接口连接上层业务逻辑如ADC采样控制、Flash命令解析。为什么要把从机和主机拆开因为项目里经常需要同时挂多个SPI外设有的芯片只能当从机比如EEG信号采集前端ADS1299有的模块只支持主机比如一些电阻触摸屏控制器拆开后单一模块可以独立复用组合起来也更灵活。3. 核心Verilog代码实现详解架构定好了下面进入最核心的环节RTL代码。这一节不玩虚的直接给出可落地的Verilog实现并逐段解释设计意图。所有代码我都简化过去除与主题无关的冗余逻辑但保留工程上必需的边界处理。3.1 参数化SPI主机模块实现先看主机模块。这个模块支持参数化数据位宽、时钟分频系数、CPOL/CPHA模式选择。设计思路是状态机按字节/数据帧为单位推进每个数据帧内部用位计数器控制SCLK的上升、下降沿与数据移位。module spi_master #( parameter DATA_WIDTH 8, parameter CLK_DIV 50, // 系统时钟/SCLK CLK_DIV设为偶数 parameter CPOL 0, parameter CPHA 0, parameter CS_HOLD_CLKS 2 // 片选维持时钟数 )( 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 sclk, output reg cs_n, output reg mosi, input wire miso ); localparam IDLE 3d0; localparam CS_SETUP 3d1; localparam TRANSFER 3d2; localparam CS_HOLD 3d3; localparam DONE 3d4; reg [2:0] state, next_state; reg [15:0] clk_cnt; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] shift_reg; // 时钟分频sclk由clk_cnt翻转产生 always (posedge clk or negedge rst_n) begin if (!rst_n) begin sclk CPOL ? 1b1 : 1b0; clk_cnt 0; end else if (state CS_SETUP || state CS_HOLD) begin sclk CPOL ? 1b1 : 1b0; clk_cnt 0; end else if (state TRANSFER) begin clk_cnt (clk_cnt CLK_DIV - 1) ? 0 : clk_cnt 1; if (clk_cnt CLK_DIV - 1) sclk ~sclk; end end // 数据移位发送/接收 always (posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg 0; mosi 1b0; end else if (state CS_SETUP) begin shift_reg tx_data; mosi tx_data[DATA_WIDTH-1]; end else if (state TRANSFER clk_cnt CLK_DIV/2 - 1) begin // 在SCLK沿前更新数据对应CPHA0时先更新后采样 mosi shift_reg[DATA_WIDTH-2]; shift_reg {shift_reg[DATA_WIDTH-2:0], miso}; end end // 接收数据锁存 always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_data 0; end else if (state TRANSFER clk_cnt CLK_DIV - 1) begin rx_data {rx_data[DATA_WIDTH-2:0], miso}; end end // 状态机推进 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; bit_cnt 0; busy 0; cs_n 1b1; end else begin state next_state; case (next_state) IDLE: busy 0; CS_SETUP: begin cs_n 1b0; busy 1; end TRANSFER: begin if (bit_cnt DATA_WIDTH) begin bit_cnt 0; end else if (sclk_rise_evt) begin bit_cnt bit_cnt 1; end end CS_HOLD: cs_n 1b0; DONE: cs_n 1b1; endcase end end endmodule这段代码有几个工程细节要重点说明CLK_DIV必须为偶数保证SCLK占空比50%。实际中我没见过完全苛刻的占空比要求但是50%对绝大多数从设备的时序计算最友好。发送与接收都用了移位寄存器但没有分开两个寄存器而是{shift_reg[DATA_WIDTH-2:0], miso}这样一位一拍高位先发、低位后收正好对应SPI的MSB First发收同时完成。状态机里加了一个CS_HOLD状态这是给那些需要CS低电平保持一段时间才能正确锁存数据的芯片用的。比如有些ADC在CS上升沿完成内部数据锁存CS拉太低太快会丢数据。3.2 参数化SPI从机模块实现从机模块的难点在于外部SCLK完全异步不能直接用posedge sclk触发。module spi_slave #( parameter DATA_WIDTH 8, parameter CPOL 0, parameter CPHA 0 )( input wire clk, input wire rst_n, input wire sclk, input wire cs_n, input wire mosi, output reg miso, output reg tx_done, input wire [DATA_WIDTH-1:0] tx_data, output reg [DATA_WIDTH-1:0] rx_data ); reg sclk_d1, sclk_d2, sclk_rise, sclk_fall; reg cs_d1, cs_d2, cs_start, cs_end; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] shift_rx; reg [DATA_WIDTH-1:0] shift_tx; // 同步外部信号到内部时钟域 always (posedge clk or negedge rst_n) begin if (!rst_n) begin sclk_d1 0; sclk_d2 0; cs_d1 1; cs_d2 1; end else begin sclk_d1 sclk; sclk_d2 sclk_d1; cs_d1 cs_n; cs_d2 cs_d1; end end assign sclk_rise sclk_d2 ~sclk_d1; // 检测外部时钟上升沿 assign sclk_fall ~sclk_d2 sclk_d1; // 检测外部时钟下降沿 assign cs_start ~cs_d2 cs_d1; assign cs_end cs_d2 ~cs_d1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin bit_cnt 0; shift_rx 0; miso 1bz; end else if (cs_start) begin bit_cnt 0; shift_tx tx_data; miso tx_data[DATA_WIDTH-1]; end else if (cs_end) begin tx_done 1; rx_data shift_rx; miso 1bz; end else begin tx_done 0; case ({CPHA, sclk_rise, sclk_fall}) 3b000, 3b100: ; // 无关沿 3b001, 3b101: begin // 非采样沿切换输出 if (!cs_d2) begin miso shift_tx[DATA_WIDTH-2]; shift_tx {shift_tx[DATA_WIDTH-2:0], 1b0}; end end 3b010, 3b110: begin // 采样沿收数据 if (!cs_d2) begin shift_rx {shift_rx[DATA_WIDTH-2:0], mosi}; bit_cnt bit_cnt 1; end end endcase end end endmodule这段从机代码里的关键设计有两点外部信号全部先打两拍同步sclk_rise和sclk_fall是由clk触发的脉冲信号之后所有逻辑都以clk为时钟从根上规避了亚稳态。代价是检测沿比真实沿晚了几个内部时钟周期但只要内部时钟频率远高于外部SCLK这个延迟可以接受。内部时钟至少要比SCLK快4倍以上这是经验值代码里用case同时处理CPOL和采样沿的关系。MISO在CS无效时输出高阻1bz这是多从机共享MISO总线的关键。如果CS没有选中还把MISO驱动成强高或低电瓶会直接拉死MISO总线。3.3 发送接收数据MSB/LSB顺序控制SPI器件有的要求MSB first有的要求LSB first。比如ST7789用的是MSB但有些射频芯片CC1101可以用MSB或LSB配置。更麻烦的是FPGA内部数据总线的字节序和SPI移位顺序可能不一致这时就会遇到两个经典困惑移位寄存器到底该从最高位还是最低位移以及外部给的数据字节要不要先翻转。工程上的统一方案是在模块内固定用MSB first的发送逻辑LSB需求在数据进入移位寄存器前做一次位反转。 位反转可以做成函数或者纯组合逻辑function [DATA_WIDTH-1:0] bit_reverse(input [DATA_WIDTH-1:0] d); integer i; for (i 0; i DATA_WIDTH; i i 1) bit_reverse[i] d[DATA_WIDTH-1-i]; endfunction这样做的好处是SPI核心逻辑只维护一种移位方向错误率大幅下降。数据宽度是可参数化的所以位反转函数也做成参数化。4. 硬件片选与软件片选的实践对比标题热词里专门有“spi硬件片选与软件片选”这个点真不能跳过。我在实际项目里既吃过硬件片选的哑巴亏也在软件片选上踩过坑这里好好掰扯一下。4.1 硬件片选与软件片选的基本差异硬件片选由处理器单片机/FPGA内部外设IP在SPI传输开始时自动拉低CS在传输结束后自动拉高全程不需要软件干预。软件片选则是把CS当作普通GPIO由用户代码在开始传输前手动拉低、结束后手动拉高。一图胜千言两者的工程差异如下维度硬件片选软件片选控制粒度由SPI控制器自动起始/结束固定完全靠程序控制灵活连续传输每包数据间CS会拉高再拉低可维持CS低电平实现多包连续传CPU占用不需要干预每字符前后都要操作GPIO时序精度通常有硬件保证但受FIFO影响受软件调度延迟抖动影响较大适用场景单包短数据、中断频繁的场景Flash页编程、长数据流、多字节帧4.2 FPGA实现时为什么推荐软件片选我发现有些新手在FPGA里搭SPI习惯性模仿MCU外设的硬件片选思路经常会遇到这样的现象用IP核自带的自动CS功能传输完成CS立刻拉高但下一包数据又来的时候CS才刚拉低从机还没来得及释放总线。在FPGA里SPI控制器是自己用状态机写的不存在“硬件自动”和“软件手动”的严格区分。 但逻辑上我强烈建议把CS控制独立于数据移位状态机单独用一个CS状态机控制。好处可以自由设定CS建立时间从拉低到第一个时钟边沿之间的延迟。可以自由设定CS保持时间最后一个时钟边沿到拉高之间的延迟。可以方便实现“连续传输”同一个事务内连续发送多个8/16位数据块CS全程保持低直到整帧事务结束。代码里我专门用了CS_SETUP和CS_HOLD两个状态就是干这个的。除非是那种指令地址数据三位一体的SPI Flash否则基本都可以保持CS低电平连续写完一整个命令序列不必中间频繁拉高拉低。4.3 多从机级联时的片选管理当FPGA作为主机连接多个从机时每个从机必须独享一条CS线。理论上CS线可以复用普通IO并行数量取决于FPGA引脚资源。但有个细节很容易被忽略CS拉高后从机内部的复位和状态恢复需要时间。 不同芯片不一样有的需要几微秒有的需要几十微秒比如CC1101切换模式后需要等待晶体稳定。如果在CS拉高后立刻再次拉低开启下一包从机可能还处于内部复位状态第一字节直接丢。通用的办法是在CS状态机里加一个CS_IDLE_GAP参数两次CS有效之间强制插入空闲间隔。我一般设为8到64个SCLK周期具体看从机手册的“CS high time”参数。这个参数不加就容易出现“偶发丢字节”的疑难杂症。5. 跨时钟域与数据同步的细节处理前面从机代码里已经用到两级同步器但这里还是单独开一节。因为跨时钟域是SPI在FPGA上出bug最多的重灾区很多项目“仿真全对上板就错”就是栽在这里。5.1 为什么不能直接采样外部信号外部主机产生的SCLK和MISO信号相对FPGA内部时钟是完全异步的。直接用always (posedge clk)去采样一旦信号沿恰好落在内部时钟建立保持窗口附近寄存器输出就会进入亚稳态。亚稳态可能持续几十到几百皮秒甚至更长且最终稳定值无法预测有时候会给出一个错误的0/1有时候会给出高低电平之间的中间电压。两级同步器并不能消除亚稳态它只是给了亚稳态一个完整的时钟周期去稳定再去采样第二拍就基本不会再出现不可控结果。 这是FPGA设计的基本原则任何跨时钟域信号都必须打至少两拍才能参与逻辑判断。5.2 同步器的小心机沿检测与总线信号SPI跨时钟域的核心信号是SCLK和CS这两者必须先同步到内部时钟域再检测边沿。关于边沿检测有一个经验性做法同步后信号进行打拍逻辑用sync_reg和sync_ff组合出脉冲。如果被同步信号本身是一个慢速的SCLK用到的脉冲宽度只是内部时钟的一个周期这正好适合状态机使用。另一个容易踩的坑是数据信号MISO。数据线不能直接打两拍后当普通信号用吗可以但要注意如果MISO在CS有效期间连续变化你采样时打两拍只是稳定了采样点不代表采准确了。必须保证采样点相对于数据变化沿有足够的建立时间。 所以数据信号打两拍后还要在SCLK的有效沿由同步后的脉冲指示那天锁存才算是真正的跨时钟域安全采样。5.3 异步FIFO的引入时机当SPI数据量很大时比如连续采集ADC多通道数据或者从SPI Flash读一大块数据如果每收一包数据都打断上层逻辑要么丢包要么CPU忙死。更合理的做法是在SPI控制器与上层逻辑之间加一个异步FIFOSPI侧写入用户侧读取。异步FIFO复杂度高但好在标准做法成熟。Xilinx可以直接用XPM_FIFO参数化生成在代码里实例化xpm_fifo_async #( .FIFO_MEMORY_TYPE (auto), .FIFO_READ_LATENCY (1), .FIFO_WRITE_DEPTH (1024), .WRITE_DATA_WIDTH (8), .READ_DATA_WIDTH (8), .READ_MODE (std) ) u_fifo ( .rst (~rst_n), .wr_clk (spi_clk), .wr_en (rx_data_valid), .din (rx_data), .rd_clk (user_clk), .rd_en (user_rd_en), .dout (user_data), .full (fifo_full), .empty (fifo_empty) );FIFO深度1024对大多数SPI应用够用如果要传更大数据也可以把FIFO换成简单的RAM地址计数器但中断交互就复杂很多了。异步FIFO方案对从机模式尤其重要因为外部主机的SCLK频率与内部逻辑时钟经常差好几倍没法靠轮询保证实时性。6. 调试与验证实测经验与波形分析SPI控制器写完了不代表能跑。没有示波器和逻辑分析仪你根本不知道线上跑的是什么妖魔鬼怪。这节说说我在实际调试过程中摸索出来的一整套验证思路。6.1 ModelSim/Questa仿真验证要点很多人写SPI仿真只写一个testbench拉几个激励信号仿真跑通就以为完事了。真实情况是SPI时序bug在仿真里最常见但也是最容易忽略的。必须加入的仿真激励场景主机发送全0和全1数据验证SCLK边沿与数据切换沿的相对位置防止建立时间不足。主机发送0xAA/0x55这类数据在线上会产生最高频率的翻转可以直观看到波形迅速定位MSB/LSB顺序问题。从机模式仿真时必须人为产生SCLK抖动。SCLK时钟周期设成连续变化比如每周期相差2ns验证同步器在非均匀时钟下不丢bit。片选信号在传输过程中间拉高再拉低验证从机状态机的复位逻辑是否正确。这里还要提醒一下仿真中的时序是理想化的RTL仿真不通过大概率逻辑有问题仿真通过了上板依然可能失败原因往往是物理时序条件没满足示波器才是终极裁判。6.2 ILA/SignalTap在线调试抓波形上板调试时在Vivado里用ILAIntegrated Logic Analyzer在FPGA内部挂探针抓取SPI控制器的关键内部信号效率远高于外部逻辑分析仪。建议抓取信号集合主机模式sclk、cs_n、mosi、miso、state[2:0]、bit_cnt[3:0]。从机模式同步后的sclk_d2、sclk_rise、sclk_fall、cs_d2、shift_rx。第一步先看状态机是否按预期跳转第二步看位计数是否正常从0到7递增第三步看数据在哪个时刻被锁存。有一个非常典型的故障现象ILA抓到的数据在仿真里完全正确但上板后首字节最高位总出错。这个90%情况是CS拉低到第一个SCLK上升沿之间的建立时间不够从机还没准备好就把第一个有效位发出去了。解决方法是加大CS_SETUP状态的时间。6.3 常见问题速查表从现象定位根因实际调试过程中我把碰到的SPI问题按现象整理成了一个速查表方便快速定位现象可能原因排查方向全部数据错位整体便宜1位MSB/LSB顺序配置错误检查数据手册的位序要求首字节最高位丢失CS建立时间不足增大CS_SETUP持续周期数据稳定但偶尔丢一字节从机在CS拉低后未复位完成增大CS_IDLE_GAP间隔数据全部为固定值0x00/0xFFMISO/MOSI接反或MISO总线多从机冲突检查物理连接与高阻态输出仿真正确上板在高速时错乱时钟质量差、逻辑布局不合理改用PLL检查IO约束SCLK上有毛刺波形不干净组合逻辑直接驱动SCLK改为D触发器输出寄存器不要组合直出6.4 双通道对比法无需示波器的数据分析技巧没有示波器怎么办有一种不用额外硬件的笨办法但非常好用把FPGA收到的原始数据和期望数据都通过串口打印或者DMA搬移到内存在PC端写脚本对比。如果数据从某个固定字节开始整体错位基本可以断定是对齐问题如果是随机乱码重点查硬件时序。这个方法在调试SPI Flash读取时特别高效先读JEDEC ID确定的0xEF 0x40 0x18如果读出来不是这个基本可以断定时序配置就有问题而不是Flash和数据内容的问题。 先锁定简单命令再调试复杂功能这个是调试SPI外设的不二法门。7. SPI与I2C协议的选型思考热词里出现了“iic和spi的区别”而且很多项目里都会有这个纠结。这里顺带聊聊我的选择思路因为在FPGA项目里这个决策影响后面整个接口设计。两者最核心的区别SPI速度快、线多、无应答机制I2C速度慢、线少、带应答和地址。 I2C只需要两根线SCL、SDA通过地址区分设备SPI四根线信号简单直接但一个从机一条CS设备多了线就多。FPGA项目里的选型建议追求吞吐率选SPI。W25Q128 SPI Flash读速可达40Mbps以上I2C最快也就3.4Mbps高速模式差了不止一个量级。引脚资源紧张选I2C。有些BGA封装FPGA引脚密集但PCB布线难度大两根线的好处很明显。从机数量多I2C用地址区分SPI每从机一根CS超过4个从机时SPI线数优势基本消失。实时性要求高SPI。I2C的时钟同步和应答机制在复杂总线上带来的延迟和不确定性远比SPI大。FPGA上实现I2C比SPI麻烦很多因为I2C是真正的总线协议漏极开路、地址仲裁、时钟拉伸、应答超时这些都要做。而SPI本质上还是移位寄存器的扩展难度至少低一半。8. 实战案例FPGA驱动SPI NOR Flash完整流程前面讲了协议、代码、调试方法这里用一个完整实例把整个流程串起来。FPGA读SPI NOR Flash以W25Q128JV为例是最典型也最有代表性的SPI应用场景这个案例做完基本可以应付绝大多数SPI外设。8.1 初始化流程与命令序列W25Q128JV支持Mode 0和Mode 3我用Mode 08位命令字MSB first。上电后必须先发0xABRelease Power Down/ 0x06Write Enable等命令。典型读取流程拉低CS。发送0x03Read Data命令。发送24位地址高字节在前。连续读取N个字节数据CS全程保持低。拉高CS结束。如果一次读超过Flash页边界一页通常256字节Flash内部地址会自动回卷所以跨页读取必须分多次操作。这一点在FPGA实现时要特别小心很多人第一次调通单页读取后直接连续读大块数据发现数据错乱就是因为没有处理跨页。8.2 状态机设计读操作FIFO化实际工程里我不会用笨办法每个字节轮询一次而是把SPI读数据直接灌入异步FIFO上层逻辑无阻塞地拿数据。// 简化代码SPI主机读Flash数据并写入FIFO spi_master #( .DATA_WIDTH(8), .CLK_DIV(20), // 假设系统100MHzSPI 5MHz .CPOL(0), .CPHA(0), .CS_HOLD_CLKS(4) ) u_spi_master ( .clk(clk_100m), .rst_n(rst_n), .start(tx_start), .tx_data(fifo_din), // 命令/地址写入 .rx_data(flash_data), .busy(spi_busy), .sclk(sclk), .cs_n(flash_cs_n), .mosi(mosi), .miso(miso) ); // 接收数据有效时写入FIFO always (posedge clk_100m) begin if (spi_busy rx_valid) fifo_wr_en 1; end这里有个细节读数据命令0x03之后主机必须持续拉低CS并继续提供SCLKFlash才会在后面每个时钟的下降沿输出一个字节数据。所以主机状态机的设计不能发完命令就停要允许连续传输模式。我上面主机模块里的TRANSFER状态设计成一次能传多个数据帧就是为这个场景准备的。8.3 Flash数据校验与错误恢复用SPI Flash做配置存储或者固件升级数据校验必须做。最简单的CRC16校验可以放在FPGA内部读完整块算一个CRC值和写入时存储的CRC比较。错误恢复策略我建议是连续三字节CRC错误就重新初始化Flash而不是盲目重试同一块区域。关于擦写寿命要注意Flash擦除按扇区/块进行通常一个扇区4KB页编程按256字节。频繁改写不要总是擦同一个扇区做磨损均衡。这个虽然在FPGA里不像MCU那么常见但做数据记录类项目时真的会遇到。9. 高速SPI设计的进阶优化低速SPI控制器大家都差不多到了20MHz以上很多设计就开始“碰运气”了。这个部分谈谈怎么把SPI速度往上拉或者说至少保证高数据率下的稳定性。9.1 串行器/解串器和DDR模式的思路如果追求极高吞吐率可以考虑在FPGA里用硬核串行器OSERDES/ISERDES来实现SPI的数据移位而不是用普通逻辑做移位寄存器。Xilinx 7系列FPGA的OSERDES可以支持DDR模式一个时钟周期内采样两个bit。这意味着如果外部SCLK为50MHz理论上可以做到100Mbps的数据率。当然这要求从机也支持DDR SPI很多高速ADC支持并且PCB信号完整性得跟得上。普通SPI模式下用逻辑移位寄存器的瓶颈在于每个bit的建立时间要满足而你的数据切换沿和采样沿之间只有半个SCLK周期。 SCLK越高这个窗口越小。到了50MHz以上纯逻辑设计基本压不住走线延迟这时候就必须用IOB里自带的寄存器或者干脆用硬核串行器。9.2 引脚约束与信号完整性高速SPI对PCB布局布线有要求SCLK与MOSI/MISO走线尽量等长避免时钟与数据到达偏差。CS走线不要走太长CS时序对建立时间影响很大。如果板子上SPI走线较长超过几厘米建议串联33欧姆电阻降低振铃。Vivado里时序约束重点约束SCLK与MOSI的相对相位可以用set_output_delay约束数据线相对SCLK的延迟。在做高速SPI之前最好先看看demo板原理图是怎么布线的照猫画虎能少踩很多坑。9.3 时钟占空比与数据有效窗口的粗算简单量化一下假设系统时钟100MHzSCLK50MHzCLK_DIV2每个SCLK周期20ns。Mode 0模式下MOSI在SCLK低电平期间更新数据上升沿采样。数据建立时间是半个SCLK周期10ns减去MOSI翻转到布线到达从机的延迟。如果PCB走线延迟2ns从机输入建立时间要求5ns那么剩给FPGA内部寄存器的输出延迟窗口只有3ns。这个数字已经非常紧张了。所以50MHz以上工程设计就必须严格计算时序预算。能用PLL产生更高倍频的串行时钟时可以考虑把内部逻辑频率提到200MHz、用逻辑打拍实现更细粒度的相位控制但本质没有改变物理窗口的限制。最彻底的办法其实是采用ISERDES/OSERDES这类专用IO逻辑让数据寄存直接绑定在IO资源里减少路径延迟。10. 工程应用中的经验总结与避坑指南最后这部分我把这些年在SPIFPGA项目里踩过的坑、总结出来的经验系统性列一下。没有高深理论全是实打实的操作建议。10.1 最容易忽视的5个细节上拉/下拉电阻从机MISO输出高阻时如果没有外部下拉浮空输入会在FPGA里读到不稳定的值。一般加10k下拉或者FPGA内部使能弱下拉。电平标准3.3V SPI接口与5V器件接口不是所有情况都能直连。很多传感器是1.8V逻辑电平FPGA的bank电压要和器件匹配否则测出波形是“好的”但芯片就是不工作。电源去耦SPI速度上去后芯片电源管脚如果去耦不佳会导致偶然性数据错误。这个和FPGA逻辑无关但属于SPI系统故障排查的常见根因。热插拔问题SPI不支持热插拔尤其是CS和SCLK没做保护和隔离的情况下上电瞬间的毛刺会导致从机锁死在未知状态。指令间隔某些外部设备在两个指令之间要求指令间隔比如SPI Flash的Write Status Register后需要等待内部忙。FPGA侧要留出足够的时间不要发完一个命令马上发下一个。10.2 从机模式和主机模式的选择策略有些芯片既支持主又支持从比如一些FPGA和MCU之间的SPI连接。我的建议是在整个系统里最好只有一个主机其他都是纯从机。多主机SPI需要处理总线仲裁和冲突检测复杂度直接上一个台阶除非必要别这么干。另外用FPGA做从机给MCU用的时候从机侧的响应延迟要尽量控制在一个SCLK周期内。尤其是在SCLK高达20MHz以上的场景下FPGA从机如果还要通过内部总线仲裁才能返回数据时序很容易超标。 这种情况下最稳的方案是预先在从机模块内部开一个双端口RAMMCU要读哪个寄存器地址提前写入FPGA从机直接根据地址从RAM取数数据路径上没有仲裁延迟。10.3 代码规范与可维护性SPI控制器代码最忌讳的是一股脑写成一个巨型always块。我自己的经验每个模块只做一件事主机/从机/寄存器接口分开。参数全部参数化不写死位宽和分频比。状态编码用本地参数名不要用魔法数字。关键跨时钟域信号注释标明“CDC signal, do not optimize”防止综合工具在综合时把这关键路径优化掉。还有一个很多人没注意的仿真testbench里加$monitor连续打印SPI数据看似方便但仿真速度会急剧下降。建议只在关键阶段开启或者干脆只打印数据帧的接收完成信号减少IO输出开销。结尾的话SPI通信FPGA实现说难也难说简单也简单。难在跨时钟域、时序边界和调试手段的完备性简单在协议本身内容不多一次把原理吃透、代码模板调好后面不同项目就是改参数和接口的事情。我个人建议无论项目多急都先在仿真里把主机和从机相互通信的case跑透再上板测真实外设。别嫌麻烦仿真阶段多花半天一般来说能省掉上板后三天抓波形的痛苦。最后再分享一个小经验所有SPI外设的调试先从读JEDEC ID这类固定值寄存器开始先证明通路再调复杂功能。这个顺序要是反了出了问题你往往不知道是哪一层的问题。
返回列表