FPGA实战:一种精简可配置位宽的SPI主机Verilog实现
1. 为什么需要精简可配置的SPI主机设计
在FPGA开发中,SPI通信是最常用的外设接口之一。我刚开始接触FPGA时,发现网上大多数SPI实现方案都存在两个明显问题:一是状态机过于复杂,每传输一位数据就需要一个独立状态;二是数据位宽固定为8位,无法适配特殊设备。这些问题在实际项目中会带来很多麻烦。
以MCP2515 CAN控制器为例,它要求在一次片选有效期间连续传输32位数据。如果采用传统的一位一状态的写法,代码会变得极其臃肿。我在实际项目中就遇到过这种情况:状态机膨胀到32个状态,不仅调试困难,后期维护更是噩梦。更糟的是,当需要支持不同位宽的设备时,几乎要重写整个模块。
精简状态机的核心思路是将相似操作合并。通过分析发现,无论传输多少位数据,SPI的基本操作流程都是固定的:初始化→启动传输→循环移位→结束传输。基于这个观察,我们可以将状态机精简到4个核心状态:
- IDLE:等待传输请求
- START:锁存待发送数据
- RUNNING:数据移位传输
- DELIVER:输出接收数据
这种设计不仅代码量减少50%以上,更重要的是提高了可维护性。当需要修改时,只需调整RUNNING状态的边沿计数逻辑即可,不会影响整体架构。
2. 模块架构设计与关键信号
整个SPI主机模块采用经典的"时钟生成+主控逻辑"双模块设计。这种分离式架构在实践中证明更灵活,特别是在需要动态调整时钟频率的场景下。
2.1 时钟生成模块
时钟模块的核心任务是产生符合SPI时序要求的SCK信号,并提取关键边沿事件。这里有几个设计要点:
// 时钟分频示例 localparam CLK_DIV_CNT = (CLK_FREQ * 1000)/SPI_CLK_FREQ; always@(posedge Clk_I) begin if(!En_I) begin ClkDivCnt <= 0; SCK <= CPOL; // 根据极性初始化 end else if(ClkDivCnt == CLK_DIV_CNT - 1) begin ClkDivCnt <= 0; SCK <= ~SCK; // 翻转时钟 end else begin ClkDivCnt <= ClkDivCnt + 1; end end关键输出信号包括:
- SCK_O:最终输出的时钟信号
- SCKEdge1_O:第一个跳变沿脉冲(上升/下降取决于CPOL)
- SCKEdge2_O:第二个跳变沿脉冲
2.2 主控模块接口设计
主控模块的接口信号需要兼顾通用性和易用性:
module SPI_Master#( parameter DATA_WIDTH = 8 // 可配置位宽 )( input [DATA_WIDTH-1:0] Data_I, // 并行输入数据 output [DATA_WIDTH-1:0] Data_O, // 并行输出数据 output DataValid_O // 数据有效脉冲 );特别要注意的是Busy_O信号,它在实际项目中非常有用。当模块处于非IDLE状态时,该信号有效,可以防止主控制器发起新的传输请求,避免数据冲突。
3. 状态机实现细节
3.1 精简状态机设计
采用四状态设计后,状态转移逻辑变得非常清晰:
always@(*) begin case(MainState) IDLE: NxtMainState = WrRdReq_Pdg ? START : IDLE; START: NxtMainState = RUNNING; RUNNING: NxtMainState = RecvDoneFlag ? DELIVER : RUNNING; DELIVER: NxtMainState = IDLE; default: NxtMainState = IDLE; endcase end这种设计的关键在于RUNNING状态的智能处理。它通过边沿计数器判断传输完成时机,而不是为每个bit创建独立状态:
assign RecvDoneFlag = (SCKEdgeCnt == DATA_WIDTH * 2);3.2 数据移位实现技巧
发送和接收都采用移位寄存器实现,但需要注意CPHA参数对采样时刻的影响:
// 发送数据控制 always@(posedge Clk_I) begin if(CPHA ? SCKEdge1 : SCKEdge2) begin WrDataLatch <= {WrDataLatch[DATA_WIDTH-2:0], 1'b0}; end end // 接收数据控制 always@(posedge Clk_I) begin if(CPHA ? SCKEdge2 : SCKEdge1) begin RdDataLatch <= {RdDataLatch[DATA_WIDTH-2:0], MISO_I}; end end这种写法通过三目运算符简化了CPHA的判断逻辑,比传统的if-else结构更简洁。我在实际测试中发现,这种写法在综合后占用的LUT资源更少。
4. 可配置位宽的实现方法
支持可变位宽的核心是参数化设计和动态计数机制。在Verilog中,我们可以通过parameter实现:
module SPI_Master#( parameter DATA_WIDTH = 8 // 默认8位 )( // 端口定义 );关键修改点包括:
- 将固定值8替换为DATA_WIDTH参数
- 边沿计数器上限改为DATA_WIDTH*2
- 移位寄存器宽度改为DATA_WIDTH
对于特殊位宽设备(如32位的MCP2515),实例化时只需指定参数值:
SPI_Master#(.DATA_WIDTH(32)) spi_mcp2515( .Clk_I(clk), // 其他连接 );我在项目中测试过从4位到32位的各种配置,验证了这种设计的灵活性。特别是在使用QSPI Flash时,可以通过修改参数快速适配不同的地址模式。
5. 实测与调试经验
5.1 仿真验证要点
搭建测试平台时,建议重点验证以下场景:
- 不同数据位宽的传输完整性
- CPOL/CPHA各种组合下的时序
- 背靠背连续传输的稳定性
// 简单的测试用例 initial begin // 测试8位传输 Data_I = 8'hA5; #100 WrRdReq_I = 1; #20 WrRdReq_I = 0; // 测试16位传输 #1000; Data_I = 16'hABCD; #100 WrRdReq_I = 1; #20 WrRdReq_I = 0; end5.2 常见问题排查
在实际调试中遇到过几个典型问题:
- 数据错位:通常是CPHA设置错误导致,检查采样边沿是否与从设备匹配
- 最后一位丢失:确保DELIVER状态后返回IDLE前完成所有操作
- 时钟抖动:检查时钟分频计数器是否溢出
有个特别容易忽略的点是片选信号时序。我发现有些设备对CS的建立/保持时间要求严格,需要在状态机中精确控制CS信号:
assign CS_O = (MainState == RUNNING) ? 1'b0 : 1'b1;6. 性能优化技巧
经过多次项目迭代,总结出几个优化方向:
- 时钟门控:在非RUNNING状态关闭时钟模块,降低功耗
- 流水线处理:在DELIVER状态预加载下一组数据,提高吞吐量
- 跨时钟域处理:添加同步器处理异步信号
对于高速应用(>10MHz),建议:
- 使用寄存器输出替代组合逻辑
- 添加时序约束保证建立/保持时间
- 在PCB布局时缩短SCK走线
// 高速优化示例 always@(posedge Clk_I) begin MOSI_reg <= WrDataLatch[DATA_WIDTH-1]; end assign MOSI_O = MOSI_reg;7. 完整代码解析
以下是核心代码的结构说明:
module SPI_Master#( parameter DATA_WIDTH = 8 )( // 端口定义 ); // 状态定义 localparam IDLE = 0, START = 1, RUNNING = 2, DELIVER = 3; // 主状态机 always@(posedge Clk_I) begin MainState <= NxtMainState; end // 数据移位逻辑 always@(posedge Clk_I) begin if(SCKEdge1) begin // 接收数据处理 end end // 边沿计数器 always@(posedge Clk_I) begin if(MainState == RUNNING) begin SCKEdgeCnt <= SCKEdgeCnt + (SCKEdge1 || SCKEdge2); end else begin SCKEdgeCnt <= 0; end end // 实例化时钟模块 SPI_Clock clock_gen(.*); endmodule实际项目中,我会额外添加这些功能:
- 传输超时检测
- 错误重试机制
- 自动时钟频率调整
这种设计已经在工业控制、消费电子等多个领域得到验证。特别是在需要兼容多种SPI设备的场合,参数化设计大大减少了重复开发工作。当需要支持新的设备时,通常只需要调整参数即可,无需修改核心代码。
