2、SPI控制器架构设计:顶层模块划分、AHB/APB总线接口设计、寄存器模块设计、时钟分频模块设计

好,咱们进入第二章。这一章,说白了就是搭骨架。

你想想看,一个SPI控制器,它得跟CPU通信吧?得有人给它发指令、配置参数吧?还得有地方存这些配置吧?时钟也得有人管吧?这些模块怎么切、怎么连,就是架构设计的核心。

我个人习惯,做任何IP设计之前,先画一张顶层结构图。别急着写代码,先把框图画清楚。我见过太多人上来就撸代码,结果模块接口对不上,回头再改,那叫一个痛苦。

核心观点:架构设计的目标,是让每个模块职责单一、接口清晰、便于验证。

SPI控制器顶层架构图 AHB/APB总线接口 (总线协议转换) 寄存器模块 (配置/状态/数据) 时钟分频模块 (sclk生成) SPI核心控制模块 (状态机/移位/中断) 数据FIFO模块 (发送/接收缓冲) IO接口模块 (pad控制/三态) 配置/数据 分频系数 控制信号 sclk 数据 spi信号

2.1 顶层模块划分

从上面的图可以看出来,我把SPI控制器分成了五个主要模块。这不是拍脑袋定的,是经过多次迭代后沉淀下来的结构。

我记得刚入行那会儿,带我的老工程师跟我说过一句话:「模块划分的粒度,以一个人能独立负责、一周内能写完为佳。」这话我一直记着。你想想看,如果一个模块代码量超过2000行,那基本上就是该拆了。

咱们这五个模块,每个的职责都很清楚:

  • AHB/APB总线接口模块:负责跟系统总线打交道,把总线协议翻译成内部寄存器读写操作。
  • 寄存器模块:存放所有配置、状态和数据寄存器,是CPU和SPI硬件之间的「翻译官」。
  • 时钟分频模块:从系统时钟分频出SPI的串行时钟sclk。
  • SPI核心控制模块:状态机、移位操作、中断生成,这是整个控制器的「大脑」。
  • 数据FIFO模块:做数据缓冲,解决CPU和SPI速率不匹配的问题。
  • IO接口模块:处理pad的输入输出,控制三态门。

小提示:实际项目中,FIFO模块和IO接口模块有时候会合并到核心控制模块里。但我建议分开,因为验证的时候可以单独测FIFO的满空状态,方便很多。

2.2 AHB/APB总线接口设计

总线接口这块,说白了就是「翻译」。CPU通过总线发过来的读写请求,你得把它转成寄存器模块能理解的信号。

我项目中用得最多的是APB总线,因为它简单、功耗低。AHB虽然快,但协议复杂,对于SPI这种外设来说有点「杀鸡用牛刀」的感觉。

APB总线的状态机就三个状态:

  • IDLE:空闲状态,等待传输
  • SETUP:地址建立,拉高PSEL
  • ACCESS:访问状态,拉高PENABLE,完成读写

这里有个坑,我曾经踩过。APB总线的写操作,数据是在PENABLE拉高后的第二个时钟沿采样的。如果你在SETUP状态就把数据写进去了,那读到的就是旧数据。嗯,这个bug我调了整整一个下午。

// APB从机接口核心代码片段
always @(posedge pclk or negedge presetn) begin
    if (!presetn) begin
        pready <= 1'b0;
        prdata <= 32'h0;
        state  <= IDLE;
    end else begin
        case (state)
            IDLE: begin
                if (psel && !penable) begin
                    state <= SETUP;
                    pready <= 1'b0;
                end
            end
            SETUP: begin
                // 地址译码,准备读写
                state <= ACCESS;
            end
            ACCESS: begin
                pready <= 1'b1;
                if (pwrite) begin
                    // 写操作:数据在此时写入寄存器
                    reg_wr_data <= pwdata;
                end else begin
                    // 读操作:输出数据
                    prdata <= reg_rd_data;
                end
                state <= IDLE;
            end
        endcase
    end
end

注意:APB总线的pready信号不能一直拉高,否则会死锁。我见过有同事把pready直接接1,结果仿真卡死,查了半天才发现是协议违例。

2.3 寄存器模块设计

寄存器模块,说白了就是一堆寄存器。但怎么组织它们,是有讲究的。

我个人习惯,把寄存器分成三类:

寄存器类型 访问属性 典型寄存器 说明
配置寄存器 可读可写 SPI_CR1, SPI_CR2 存放模式、极性、相位、分频系数等
状态寄存器 只读 SPI_SR 指示忙/空/溢出等状态
数据寄存器 可读可写 SPI_DR 发送/接收数据,通常与FIFO配合

这里有个设计要点:寄存器的地址分配要留余量。我建议每个寄存器占4字节对齐,即使有些寄存器只用到了低8位。为什么?因为后续迭代加功能时,你不需要改地址映射。我曾经吃过这个亏,一开始寄存器地址挨着排,后来加了一个控制位,整个地址表都得改,验证环境也得跟着动,那叫一个酸爽。

经验之谈:寄存器模块的RTL代码,我建议用脚本生成。比如用Perl或者Python,读一个寄存器描述文件(CSV或者Excel),自动生成RTL和验证的UVM寄存器模型。这样既快又不容易出错。

2.4 时钟分频模块设计

时钟分频模块,任务很单纯:从系统时钟分频出SPI的串行时钟sclk。

SPI的时钟频率,取决于主从设备能跑多快。比如一个SPI Flash,可能最高支持50MHz,那你的sclk就不能超过这个值。

分频的方式有两种:

  • 偶数分频:直接用计数器,每计到N/2-1翻转一次。简单可靠。
  • 奇数分频:需要两个计数器,一个在上升沿触发,一个在下降沿触发,然后组合。稍微复杂一点。

我项目中用得最多的是偶数分频,因为SPI对时钟占空比要求不高,偶数分频刚好50%占空比,省心。

// 偶数分频器核心代码
module clk_divider #(
    parameter DIV_FACTOR = 4  // 分频系数,必须是偶数
)(
    input  wire        clk_in,
    input  wire        rst_n,
    output reg         sclk
);
    reg [$clog2(DIV_FACTOR)-1:0] cnt;
    
    always @(posedge clk_in or negedge rst_n) begin
        if (!rst_n) begin
            cnt  <= 0;
            sclk <= 1'b0;
        end else begin
            if (cnt == (DIV_FACTOR/2 - 1)) begin
                cnt  <= 0;
                sclk <= ~sclk;
            end else begin
                cnt <= cnt + 1'b1;
            end
        end
    end
endmodule

避坑指南:我曾经在项目中遇到一个问题——sclk在使能关闭后没有回到默认电平。结果从设备一直检测到时钟沿,导致误触发。解决方案是:在SPI使能拉低时,强制sclk输出为CPOL设定的空闲电平。

另外,时钟分频模块还要考虑一个事情:时钟门控。当SPI没有传输时,sclk应该被关掉,这样可以降低动态功耗。做法很简单,用SPI的使能信号作为时钟门控的使能。

嗯,这一章的内容就这些。架构设计是地基,地基打好了,后面的代码实现才能顺风顺水。下一章咱们会深入到核心控制模块的状态机设计,那才是SPI控制器的灵魂所在。

可可精品资料库