ARTICLE DETAIL

资讯详情

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

参数化SPI控制器设计:从Verilog实现到FPGA工程实践

参数化SPI控制器设计:从Verilog实现到FPGA工程实践 前阵子在一块多路数据采集板上同时调了三片SPI设备12位ADC、温湿度传感器和一片SPI Flash。本来以为SPI这种用烂了的协议写个Verilog模块也就半天的事结果被现实狠狠教育了一顿ADC要求MSB在前、8位地址12位数据混合帧温湿度传感器是LSB在前且时钟极性和相位跟ADC正好相反Flash那边地址长度24位不说指令还分读、写、擦除好几种帧格式。改来改去代码里全是ifdef和分散的parameter仿真波形对了一版又一版最后总算跑通了但代码已经丑得自己都不想看第二遍。回头复盘这种痛苦完全是可以避免的。只要一开始就按参数化思路来设计SPI接口控制器把位宽、极性、相位、位序、分频系数这些变量全部暴露成参数后续适配任何从设备都只是例化时改参数的事代码一行都不用动。这也是我写下这篇东西的原因把一套经过验证的参数化SPI控制器设计思路和Verilog实现完整拆开讲清楚从参数怎么定、状态机怎么设计、收发路径怎么处理到仿真怎么搭、上板怎么调一次性说明白。新手照着敲能直接用老手看完也能在细节上交换点心得。1. 为什么我坚持要做参数化而非每次copy改两行1.1 从一次真实项目经历说起先说个背景。当时那块板上三片SPI从机选型时各有各的考虑ADC采样率要求高只能用高速串行接口温湿度传感器是I2C/SPI双模为了统一总线选了SPIFlash则是标准SPI NOR用于参数掉电保存。硬件上大家共用一个SPI控制器但协议细节差异巨大。如果按传统写法SPI控制器的移位寄存器宽度、时钟分频系数、CPOL/CPHA、MSB/LSB这些参数全部写死那每适配一个新设备就得复制一份代码改几个参数然后重新仿真、重新综合。更麻烦的是如果有两个设备需要分时复用同一个控制器光切换参数就得靠寄存器动态配置写死参数的方案根本做不了。那时候我就意识到SPI控制器做得成不成熟不取决于它能跑多高的时钟而取决于它能不能用最小的改动适配多样化的从设备。参数化不是锦上添花而是刚需。1.2 参数化到底解决了什么问题参数化设计简单说就是把设计中容易变化的属性提取出来作为模块例化时可通过parameter或localparam配置的接口。对于SPI控制器需要变化的点非常明确数据位宽8位、12位、16位、24位甚至32位都有可能。时钟极性CPOL空闲时SCK是高还是低。时钟相位CPHA数据在SCK的哪个边沿采样、哪个边沿变化。位序先发MSB还是先发LSB。时钟分频系数系统时钟和SCK频率的比值。CS极性低有效还是高有效。传输模式单次固定长度还是连续不定长。这些点在协议层面都是可选项但对具体设备却都是硬约束。比如很多ADC规定只在SCK下降沿输出数据控制器就得在下降沿采样有些传感器则反过来。如果设计里没有把这些约束参数化换设备时改代码就是必然的。而参数化之后模块本身的逻辑结构完全不变变的只是例化参数。好处是显而易见的工作量复用一套代码适配所有SPI从机。错误率降低不用每次改逻辑自然少引入bug。仿真环境复用testbench里改参数就能验证不同模式。可维护性强代码只有一个维护点而不是散落各处。1.3 和别人家IP相比自研参数化模块值不值有人会问Xilinx、Altera现在叫Intel不是都有SPI IP核吗直接用不好吗我的看法是分场景。厂商的SPI IP核性能很强配置项也不少但它有几个问题。第一IP核生成的接口偏重往往带AXI-Lite之类总线接口如果只是想在FPGA内部做一个轻量级控制器这层总线协议反而是负担。第二IP核的配置界面虽然图形化但对参数化设计的支持并不友好——你没法在一份RTL里通过改parameter快速切换两套配置只能生成两套IP再在顶层做MUX。第三有些IP核的仿真模型做得并不透明出了问题很难定位到内部细节不像自己写的代码$display打到哪里都行。自研参数化SPI控制器性能上完全够用——SPI时钟频率本身就不高几十兆赫兹已经是绝大多数从设备的上限FPGA全局时钟跑个100MHz以上毫无压力中间留出的分频空间非常充裕。所以在资源不大紧张、时序要求不极端苛刻的场景下我更倾向于自己写一个参数化模块既灵活又可控。2. 参数化SPI接口参数到底该怎么定2.1 参数清单和选型原则在设计接口之前先明确需要哪些参数。我最终采用的一套参数如下参数名类型默认值说明DATA_WIDTHinteger8一次传输的数据位宽常见8/12/16/24/32CPOLinteger0空闲时钟极性0为低1为高CPHAinteger0采样相位0为第一个边沿采样1为第二个边沿采样CLK_DIVinteger4系统时钟分频系数SCK频率 系统时钟 / (2 * CLK_DIV)CS_ACTIVE_LOWinteger1CS有效电平1为低有效0为高有效MSB_FIRSTinteger1位序1为MSB先发0为LSB先发这几个参数基本覆盖了我在项目里遇到的所有SPI变种。选型上有几个原则值得说一下。第一参数尽量是静态的。如果一个参数在系统运行过程中需要动态切换比如分频系数那它更应该做成寄存器输入而不是parameter。parameter的优势是综合时就已经确定逻辑可以做得更紧凑劣势是运行期改不了。我的建议是把相对固定的属性位宽、极性、相位、位序用参数实现把可能动态变化的属性分频系数留作端口输入或者寄存器配置。第二参数之间的耦合要最小化。比如CPOL和CPHA虽然是两个独立参数但实际使用时它们常常是组合出现的SPI Mode 0/1/2/3。设计时最好也提供一个SPI_MODE参数方便整体配置但内部的极性、相位逻辑仍用独立的CPOL/CPHA来驱动这样既方便整体设置也保留单独覆盖的能力。第三参数默认值要尽量通用。Mode 0CPOL0, CPHA0是最常见的SPI模式MSB First也是绝大多数设备的默认选择所以我把这些作为默认值这样最普通的场景下连参数都可以不传。2.2 对外接口规划模块的对外接口我按功能分成三组系统接口clk、rst_n全局时钟和异步复位。本地控制接口start启动传输、tx_data待发送数据、rx_data接收完成的数据、busy忙标志、done单次传输完成脉冲。物理SPI接口spi_sck、spi_mosi、spi_miso、spi_cs。没有做FIFO也没有做寄存器组是因为我倾向于把SPI控制器做成一个纯执行引擎上层逻辑状态机、FIFO、CPU接口需要发送数据时给一个start脉冲把数据放到tx_data总线上即可SPI传输完成后控制器拉一个done脉冲同时把收到的数据放到rx_data上整个过程干净利落。有人可能担心握手信号太少实际使用中会不够用。我的实践经验是SPI控制器最重要的就是启动-忙-完成三个信号。更复杂的控制逻辑比如DMA握手、FIFO接口完全可以在这三个信号基础上再包一层但是控制器内部保持简单反而更容易验证。2.3 内部架构状态机和数据通路分离参数化SPI控制器的内部结构我习惯划分为四个子模块时钟分频器由CLK_DIV参数决定分频比产生sck_enable脉冲。SCK信号不直接由分频器输出而是通过一个sck_enable使能信号在系统时钟域中统一控制位计数和数据移位这是同步设计的关键。主状态机负责整个传输过程的调度从IDLE到LOAD到TRANSFER再到DONE。发送移位寄存器在tx_data加载后按照配置的位序和时钟边沿逐位移出。接收移位寄存器在SCK的采样边沿从spi_miso捕获数据按位序拼装成完整字。数据通路和状态机分开的好处是改协议细节时只需要动对应子模块不会互相污染。比如要改成LSB First只动移位寄存器的索引方向状态机完全不用管。3. Verilog实现的核心细节一个都不能马虎3.1 参数定义与可综合性写法先贴一段参数定义这段代码是整个模块的配置中心每个例化这个模块的地方改的就是这些#(...)部分。module spi_master #( parameter DATA_WIDTH 8, parameter CPOL 0, parameter CPHA 0, parameter CLK_DIV 4, parameter CS_ACTIVE_LOW 1, parameter MSB_FIRST 1 )( 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 done, // SPI物理接口 output reg spi_sck, output reg spi_mosi, input wire spi_miso, output reg spi_cs ); localparam IDLE 3d0; localparam LOAD 3d1; localparam TRANSFER 3d2; localparam DONE_ST 3d3; // 后续逻辑... endmodule这里有一个细节spi_sck、spi_mosi、spi_cs我用了output reg看起来是直接在时钟驱动下产生的信号确实是这样。SPI这种低速串行总线对信号源同步的要求并不苛刻直接在系统时钟域里同步翻转输出即可。真正的关键是SCK的翻转逻辑一定要和位计数器的状态严格绑定不能出现SCK已经翻转了但移位寄存器还没准备好数据的错位情况。3.2 主状态机的设计思路主状态机的流转是模块的骨架。我定义的状态很简单IDLE、LOAD、TRANSFER、DONE_ST四个状态有的场景可能还加WAIT_CS状态用来控制两次传输之间CS的拉高时间这个一会儿再说。先看核心状态转移逻辑reg [1:0] state; reg [1:0] next_state; reg [7:0] clk_cnt; // SCK分频计数器宽度可根据CLK_DIV参数扩展 reg [$clog2(DATA_WIDTH):0] bit_cnt; // 移位位计数 // 状态转移 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; end else begin state next_state; end end // 次态逻辑 always (*) begin next_state state; case (state) IDLE: begin if (start) next_state LOAD; end LOAD: begin next_state TRANSFER; end TRANSFER: begin if (trans_done) next_state DONE_ST; end DONE_ST: begin next_state IDLE; end default: next_state IDLE; endcase endtrans_done信号由位计数器产生当移位次数达到DATA_WIDTH时拉高一个周期。LOAD状态存在的意义是start脉冲到来时输入数据tx_data可能还没稳定我先在LOAD状态把数据锁存到移位寄存器里同时也给CS和SCK的建立留出一个时钟周期的缓冲避免CS刚拉低数据就开始变化这在时序上是很有必要的。3.3 SCK生成和时钟分频的同步设计SCK信号的设计是整个模块最容易翻车的地方。很多初学朋友直接用一个计数器分频产生SCK然后在SCK的边沿做移位和采样这在RTL仿真里通常没问题但上板后经常遇到亚稳态或者数据错位。原因在于SCK是由系统时钟分频产生的它在时间轴上和系统时钟并不同步如果你在系统时钟域里采样由SCK驱动的寄存器就有跨时钟域的风险。更稳妥的做法是产生SCK跳变使能信号而不是直接产生SCK边沿。具体来说设clk_cnt从0计数到CLK_DIV-1当计到特定值时产生toggle_en和sample_en信号。SCK信号本身仍然在系统时钟域里翻转但所有数据移位和采样操作都由系统时钟统一控制只是让SCK边沿恰好发生在对应的时钟沿上。always (posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt 8d0; sck_toggle_en 1b0; end else if (state TRANSFER) begin if (clk_cnt CLK_DIV - 1) begin clk_cnt 8d0; sck_toggle_en 1b1; end else begin clk_cnt clk_cnt 1b1; sck_toggle_en 1b0; end end else begin clk_cnt 8d0; sck_toggle_en 1b0; end end // SCK翻转 always (posedge clk or negedge rst_n) begin if (!rst_n) begin spi_sck CPOL; end else if (sck_toggle_en) begin spi_sck ~spi_sck; end end这样SCK看起来仍然是一个方波但它实际上是用系统时钟的寄存器输出模拟出来的。所有的位计数、数据移位、采样点判断都在系统时钟域里完成从根本上规避了跨时钟域的问题。这是我在多个项目里验证过的最稳妥的SPI实现方式。采样点判断需要根据CPHA决定是在SCK偶数跳变还是奇数跳变后进行采样。以CPOL0, CPHA0为例SCK空闲为低第一个跳变是上升沿数据应该在上升沿被采样实际是上升沿前的建立时间已经稳定上升沿采样而数据变化发生在下降沿。对应到使能信号上就是如果当前是SCK的偶数次跳变上升沿下一拍执行采样如果当前是奇数次跳变下降沿下一拍执行数据移位。对于CPHA1则反过来数据在第一个跳变前就要准备好采样在第二个边沿。这一段的逻辑比较绕建议直接画一张时序图把SCK、数据和采样点标出来然后再写代码会比硬着头皮写容易得多。3.4 发送移位寄存器的实现发送数据的关键在于数据要在SCK的非采样边沿变化在采样边沿保持稳定。如果采样边沿是上升沿数据就要在下降沿变化。我采用的发送移位逻辑如下reg [DATA_WIDTH-1:0] shift_reg; // LOAD always (posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg {DATA_WIDTH{1b0}}; end else if (state LOAD) begin shift_reg tx_data; end else if (state TRANSFER sck_shift_en) begin if (MSB_FIRST) begin shift_reg {shift_reg[DATA_WIDTH-2:0], 1b0}; end else begin shift_reg {1b0, shift_reg[DATA_WIDTH-1:1]}; end end end always (*) begin if (MSB_FIRST) spi_mosi shift_reg[DATA_WIDTH-1]; else spi_mosi shift_reg[0]; endspi_mosi我用了组合逻辑直接取移位寄存器的最高位或最低位然后在SCK的跳变沿由外围逻辑保证数据稳定。这种做法的好处是简单直观缺点是spi_mosi的信号质量依赖组合逻辑延迟如果SCK频率较高比如几十MHz建议多打一拍寄存器即把spi_mosi做成寄存器输出。实测中在100MHz系统时钟下跑10MHz的SCK组合逻辑输出完全没问题但如果SCK跑到25MHz以上还是建议寄存一拍更稳。3.5 接收采样从MISO捕获数据的时机接收数据比发送要微妙因为spi_miso是外部从设备驱动的异步信号相对系统时钟而言你只能在指定时刻采样它而且还要处理亚稳态问题。采样逻辑的核心是一个采样使能信号sample_en它在SCK的采样边沿后半个时钟周期内拉高此时数据已经稳定可以直接锁存。always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_shift_reg {DATA_WIDTH{1b0}}; end else if (state TRANSFER sample_en) begin if (MSB_FIRST) begin rx_shift_reg {rx_shift_reg[DATA_WIDTH-2:0], spi_miso}; end else begin rx_shift_reg {spi_miso, rx_shift_reg[DATA_WIDTH-1:1]}; end end end关于亚稳态由于spi_miso是由外部从设备在SCK边沿驱动的而采样时刻我们刻意避开了SCK边沿选择了边沿之后的半个周期所以理论上数据在采样时刻已经稳定。即便如此工程上我还是建议在采样之前先把spi_miso打一拍也就是先同步到系统时钟域再使用这样能进一步降低亚稳态风险。打完一拍之后注意接收时序要让过这一拍的延迟不要急着下一拍就采样最好在sample_en之后再加一拍缓冲确保同步后的数据已经有效。3.6 CS信号的时序处理CS信号看似简单但两个细节一定要注意。第一CS有效沿必须在SCK之前。在LOAD状态拉低CS如果低有效保证从机在SCK首个跳变之前已经看到CS有效否则部分从机根本不会响应。第二两次传输之间CS必须释放一段时间。很多从设备要求在CS拉高后有一个最小间隔比如Flash的tSH如果连续发起两次传输CS释放时间不足第二次传输可能失败。我通常在DONE_ST状态里保持CS无效状态至少一个时钟周期实际项目中甚至会拉长到几十个周期。如果需要更精确的控制可以在状态机里增加一个WAIT_CS状态用一个cs_idle_cnt计数器控制CS释放时间。always (posedge clk or negedge rst_n) begin if (!rst_n) begin spi_cs ~CS_ACTIVE_LOW; // 默认无效电平 end else begin case (state) IDLE: spi_cs ~CS_ACTIVE_LOW; LOAD: spi_cs CS_ACTIVE_LOW; TRANSFER: spi_cs CS_ACTIVE_LOW; DONE_ST: spi_cs ~CS_ACTIVE_LOW; default: spi_cs ~CS_ACTIVE_LOW; endcase end end这段代码里CS在IDLE时就保持无效LOAD时拉有效DONE_ST时拉回无效实现上非常直接。3.7 关于inout引脚MOSI/MISO是否需要三态有人会问SPI除了四线模式还有半双工两线模式如Flash的x1模式这时候MOSI和MISO会合成一根线需要inout处理。如果做的是标准四线SPI主控制器MOSI和MISO是独立的根本不需要inout。但如果你要兼容半双工模式比如某些Flash芯片的读操作在发送完指令和地址后需要把MOSI转成输入从同一根线上读取数据那就要处理三态。Verilog里三态控制的标准写法assign spi_io spi_oe ? spi_mosi_out : 1bz; assign spi_miso_in spi_oe ? 1b0 : spi_io; // 读取时注意方向三态设计的关键是方向切换的时序尤其是从输出切到输入再切回输出的过程中间要留出足够的时间让总线翻转完成否则容易产生总线冲突。这个细节在调试半双工模式时踩过坑后面在问题排查部分详细说。4. 仿真验证testbench怎么搭才不浪费时间4.1 基础testbench搭一个能复用的平台写验证环境时我的核心诉求是改几个参数testbench就能模拟一个特定从设备的时序从而验证SPI控制器在各种模式下的行为。基础结构很简单例化DUT生成250MHz或者100MHz系统时钟异步复位然后通过task封装不同的SPI操作。module tb_spi_master; reg clk; reg rst_n; reg start; reg [7:0] tx_data; wire [7:0] rx_data; wire busy; wire done; wire spi_sck; wire spi_mosi; reg spi_miso; wire spi_cs; // 默认参数 localparam DATA_WIDTH 8; localparam CPOL 0; localparam CPHA 0; localparam CLK_DIV 4; localparam CS_ACTIVE_LOW 1; localparam MSB_FIRST 1; spi_master #( .DATA_WIDTH (DATA_WIDTH), .CPOL (CPOL), .CPHA (CPHA), .CLK_DIV (CLK_DIV), .CS_ACTIVE_LOW (CS_ACTIVE_LOW), .MSB_FIRST (MSB_FIRST) ) dut ( .clk (clk), .rst_n (rst_n), .start (start), .tx_data (tx_data), .rx_data (rx_data), .busy (busy), .done (done), .spi_sck (spi_sck), .spi_mosi(spi_mosi), .spi_miso(spi_miso), .spi_cs (spi_cs) ); // 生成时钟 initial clk 0; always #5 clk ~clk; // 100MHz // 激励 initial begin rst_n 0; start 0; tx_data 8h00; spi_miso 1b0; repeat(10) (posedge clk); rst_n 1; // 测试用例... // 发送0x5A期望回读0xA5 (posedge clk); tx_data 8h5A; start 1; (posedge clk); start 0; wait(done 1b1); $display(rx_data 0x%02x, rx_data); // 更多测试... $finish; end endmodule这个testbench里为了让仿真能够自动验证可以在SPI输出端反向搭一个简单的从机模型即用always块检测spi_sck边沿模拟从机行为。比如从机在每个采样边沿发送固定数据主机在对应边沿接收。4.2 用task封装SPI读写大幅度提升复用性如果用Task封装一组操作testbench的可读性和复用性会成倍提升。我通常封装两个taskspi_write和spi_read。task spi_write; input [DATA_WIDTH-1:0] data_to_send; begin (posedge clk); start 1b1; tx_data data_to_send; (posedge clk); start 1b0; // 等待传输完成 while (busy 1b1) begin (posedge clk); end (posedge clk); end endtask task spi_read; output [DATA_WIDTH-1:0] received; input [DATA_WIDTH-1:0] dummy_tx; begin (posedge clk); start 1b1; tx_data dummy_tx; // 主机发送任意值驱动SCK (posedge clk); start 1b0; while (busy 1b1) begin (posedge clk); end received rx_data; end endtaskSPI读操作的基本思路是主控需要先发一个dummy数据来产生SCK时钟从机在这期间把有效数据送到MISO上所以spi_read至少要有一个dummy_tx参数。有了这两个task测试用例就非常简洁了initial begin // 复位... spi_write(8h5A); spi_read(read_val, 8h00); if (read_val 8hA5) $display(PASS: read_val 0x%02x, read_val); else $display(FAIL: expected 0xA5, got 0x%02x, read_val); end4.3 仿真中发现三个高频陷阱我在多次仿真参数化SPI控制器时遇到过三个印象很深的问题。陷阱一SCK占空比不是50%。如果CLK_DIV是奇数SCK翻转间隔里高电平和低电平的时间可能不一样长。这在大多数SPI从机里都能接受但如果遇到要求严格的设备就可能出问题。解决方法是要么把CLK_DIV限制为偶数要么在内部生成一个更高频的时钟来精确控制SCK占空比。陷阱二仿真时MISO悬空变成Z。如果从机在CS无效时不驱动MISO而testbench里没有给MISO接上拉仿真中MISO就会显示为Z采样时会采到一个不定值。解决办法是在testbench中为spi_miso绑定一个上拉pullup(spi_miso);或者在采样前对spi_miso做同步时把Z状态归一到逻辑1。陷阱三参数改变后初始状态没对。比如把CPOL从0改成1后SCK空闲状态应该是高但复位时如果没有正确初始化为CPOL第一次传输的波形就会全乱。这是小细节但特别容易漏建议复位时就把SCK赋值成CPOL的值。always (posedge clk or negedge rst_n) begin if (!rst_n) begin spi_sck CPOL; // 关键 end end4.4 常见问题速查表症状可能原因排查方法从机无响应SCK没有任何输出状态机没进入TRANSFER状态或start信号时序不对波形中查看state信号确认LOAD后进入TRANSFERSCK有输出但数据全0spi_mosi在LOAD状态没锁存到数据检查LOAD状态的数据装载逻辑确认tx_data时序接收到的数据和预期差一位采样边沿选择错误或MSB/LSB序反了对比CPHA配置和从机手册的时序图数据错位且SCK频率越高越严重组合逻辑延迟过大spi_mosi在SCK边沿附近未稳定把spi_mosi改成寄存器输出降低SCK频率验证连续传输第二次失败CS释放时间不足在两次传输之间插入CS无效等待时间回读数据全为1MISO同步层上拉让Z变成1实际从机没驱动检查CS时序确认从机已有效选中5. 上板调试ILA波形定位到问题根因5.1 用ILA抓信号时应该看哪些信号上板调试阶段我一般会例化ILAIntegrated Logic Analyzer来抓内部信号。重点关注这几组状态机当前状态state确认传输流程是否按预期推进。spi_sck、spi_mosi、spi_miso、spi_cs四根物理信号这是最直接的信息来源。shift_reg和rx_shift_reg的内容变化判断数据移位是否正确。ILA的采样深度不需要太深每次传输的位数有限采样深度设成1024就足够但采样时钟必须用系统时钟而不是SCK。原因很简单系统时钟域的ILA才能观察到SCK内部的使能信号和位计数的关系如果用SCK域采样很多内部信号的关系就看不出来了。5.2 上板遇到的三个典型问题问题一从机返回数据全是1。一开始以为是MISO方向的问题后来用示波器量了MISO引脚发现从机根本没有驱动它。再往前查发现CS时序不对——CS在SCK开始之后才拉低从机压根没被选中。这是因为我在LOAD状态的停留时间太短CS的有效时间和从机要求的建立时间不匹配。解决办法是把LOAD状态的时间拉长或者直接在LOAD状态插入等待周期确保CS有效后至少等待几个周期再开始SCK。问题二读回来的数据周期性错位。比如ADC的数据总是整体右移一位。这种情况基本可以断定是CPHA配置错了。不同ADC对采样边沿的定义不太一样有的在SCK上升沿采样有的在下降沿采样。需要仔细查手册里的时序图把CPHA参数对应调整一次问题就消失了。问题三MOSI信号出现毛刺。在示波器上看MOSI发现数据跳变沿附近有明显的毛刺。这是因为spi_mosi是组合逻辑从移位寄存器取出来的组合延迟在时钟沿附近可能导致毛刺。解决方法把spi_mosi改成寄存器输出并且把数据变化时刻设计在SCK的跳变沿之后确保有足够的建立时间。5.3 抓波形的一个小窍门加计数器有时候ILA抓到的是信号在时间轴上的快照很难直接看出是哪一次传输出了问题。我习惯在模块里加一个传输计数器trans_cnt每次DONE_ST时加1调试时将计数器一起抓进ILA就能精确定位是第几次传输出了问题。这个计数器平时不使用时也不影响资源就多几个寄存器但调试时价值巨大。6. 参数化之外我的一些实操心得代码看完了仿真实测也跑通了最后聊几个在工程实践中沉淀下来的体会。关于参数设计宁可多给也别抠门。我一开始只做了数据位宽、极性相位和分频参数后来发现实际项目里还遇到了位序要求相反的设备只好又补了MSB_FIRST参数。如果一开始就考虑到位序可变这版代码就能少改一次。现在我的SPI控制器参数设计上凡是协议层面可能变化的点都尽量暴露成参数反正综合时多余的参数不会产生额外逻辑。关于仿真千万不要只测默认配置。参数化模块的天敌就是只验证了一种配置。参数化意味着有很多组合每一种组合都应该至少跑一遍基本读写验证。我现在的做法是写一个循环测试把CPOL、CPHA、MSB_FIRST的组合都遍历一遍每个组合跑一个简单的回环测试。虽然仿真时间会变长但换来的是对参数化修改的信心。关于代码风格多用localparam定义有意义的状态名少用裸数字。状态机里如果全是4d0、4d1这种裸数字看完一遍脑子要好久才能把这些数字和实际功能对应上。用localparam IDLE 3d0;这种方式回头维护代码时能节省大量精力。关于时序余量SCK频率宁可保守一点。有些从设备标称能跑到40MHz但实际布线的寄生电容、PCB走线长度都会影响信号质量。我一般把SCK频率控制在从设备额定值的1/2到2/3比如从机支持40MHz我就按20MHz跑。信号质量好了调试成本的节省远超过那点性能损失。最后再分享一个小技巧如果多个从设备共享一组SPI总线但它们的参数各不相同常见于多传感器采集板我通常会在顶层例化多个SPI控制器副本——每个副本单独配自己的参数但不实际复制多套物理引脚——而是让所有控制器共用一个SCK和MOSI每个控制器自己控制CS选择对应的从机。这种方法既有参数化的灵活性又避免了单控制器频繁切配置的麻烦。实测下来这种架构在多从机场景下比动态切配置稳定得多值得参考。
返回列表