2. SPI控制器架构设计:顶层模块划分、时钟域与复位策略、寄存器列表定义

好,咱们进入正题。上一章我们把SPI协议拆了个底朝天,现在该动手搭架子了。架构设计这事儿,说白了就是「分而治之」——把一个大系统切成若干小块,每块干好自己的活,再通过标准接口互相通信。

我个人习惯,在设计任何模块之前,先画一张顶层结构图。这张图能帮你理清数据流、控制流和时钟域边界。来,先看看我画的这张架构图:

SPI控制器顶层架构 AHB/APB总线接口 时钟域:HCLK/PCLK 寄存器模块 时钟域:HCLK/PCLK 时钟域交叉同步 双级同步器 / 异步FIFO SPI核心控制 时钟域:SPI_CLK 移位寄存器 时钟域:SPI_CLK IO Pad接口 时钟域:SPI_CLK 中断控制 时钟域:HCLK/PCLK 地址/数据/控制 控制字 同步后控制 数据/使能 sclk/mosi/ss miso 中断使能/状态 中断请求 HCLK/PCLK域 SPI_CLK域

这张图我画了好几个版本才定下来。你注意看,整个控制器被切成了两大时钟域:左侧和上方的总线时钟域(HCLK/PCLK),以及右下方的SPI时钟域(SPI_CLK)。中间那道虚线框就是时钟域交叉同步,这是整个设计最容易翻车的地方。

2.1 顶层模块划分

根据上面的架构图,我把SPI控制器拆成以下几个模块:

模块名称 时钟域 主要功能
spi_ahb_if HCLK AHB从机接口,地址译码,读写握手
spi_regfile HCLK 寄存器堆,存储控制/状态/数据
spi_sync 跨时钟域 双级同步器,异步FIFO
spi_core SPI_CLK SPI协议状态机,波特率生成
spi_shift SPI_CLK 移位寄存器,串并转换
spi_io SPI_CLK IO Pad控制,三态输出
spi_intr HCLK 中断产生与清除逻辑

每个模块的接口信号,我习惯在顶层统一声明,然后用wire连接。这样顶层看起来就像一张「接线图」,子模块只管自己的逻辑。

💡 我的经验:模块划分的粒度要适中。太细了,连线比逻辑还多;太粗了,综合工具优化不好。我个人觉得,一个模块的代码量控制在200-500行之间比较舒服。

2.2 时钟域与复位策略

时钟域划分是架构设计的核心决策。我见过不少新手把整个SPI控制器放在一个时钟域里,结果要么总线频率跑不上去,要么SPI时钟抖动太大。说白了,总线时钟和SPI时钟本来就是两个独立的时钟源,硬要同步化反而得不偿失。

我的做法是这样的:

  • 总线时钟域(HCLK):寄存器读写、中断逻辑、状态更新。复位用同步复位,跟HCLK对齐。
  • SPI时钟域(SPI_CLK):协议状态机、移位操作、IO控制。复位用异步复位、同步释放,确保SPI_CLK还没稳定时模块处于已知状态。

跨时钟域的数据传输,我统一用双级同步器处理单比特信号(如中断请求、传输完成标志),用异步FIFO处理多比特数据(如发送FIFO、接收FIFO)。

⚠️ 避坑指南:我曾经在一个项目里,直接把总线域的「发送数据使能」信号送到SPI_CLK域,没做同步。结果仿真没问题,上FPGA测试时偶尔丢数据。查了三天才发现是亚稳态问题。从那以后,所有跨时钟域信号必须经过同步器,这条规矩我写在团队的设计规范里了。

复位策略上,我推荐这样写:

// 总线时钟域:同步复位
always @(posedge hclk) begin
  if (!hresetn) begin
    reg_ctrl <= 'b0;
    reg_status <= 'b0;
  end else begin
    // 正常逻辑
  end
end

// SPI时钟域:异步复位,同步释放
always @(posedge spi_clk or negedge spi_rstn) begin
  if (!spi_rstn) begin
    spi_state <= IDLE;
    shift_reg <= 'b0;
  end else begin
    // 正常逻辑
  end
end
📌 小技巧:SPI_CLK域的复位信号,我通常从总线域的复位信号经过一个「异步复位同步释放」电路生成。这样保证整个芯片只有一个复位源,但每个时钟域都能得到干净的复位。

2.3 寄存器列表定义

寄存器是CPU和SPI控制器之间的「翻译官」。CPU通过读写寄存器来配置SPI参数、发送数据、获取状态。寄存器定义得好,软件工程师写驱动就轻松;定义得烂,后面全是补丁。

我一般把寄存器分成三类:

  • 控制寄存器(CR):只写或读写,用于配置SPI模式、波特率、使能等。
  • 状态寄存器(SR):只读,反映当前传输状态、FIFO空满、错误标志。
  • 数据寄存器(DR):读写,用于发送和接收数据。

下面是我常用的寄存器地址映射表:

地址偏移 寄存器名称 读写属性 位宽 描述
0x00 SPI_CR1 R/W 16 控制寄存器1:SPE使能、MSTR主从、CPOL/CPHA、LSB/MSB
0x04 SPI_CR2 R/W 16 控制寄存器2:波特率分频、中断使能、DMA使能
0x08 SPI_SR R 16 状态寄存器:BUSY、TXE、RXNE、OVR、MODF
0x0C SPI_DR R/W 16 数据寄存器:发送/接收数据
0x10 SPI_CRCPR R/W 16 CRC多项式寄存器(可选)

每个寄存器的位域定义,我习惯用`define或者localparam来命名,方便RTL代码和验证环境复用。举个例子:

// SPI_CR1 位域定义
localparam SPI_CR1_SPE     = 0;  // SPI使能
localparam SPI_CR1_MSTR    = 1;  // 主从模式选择
localparam SPI_CR1_CPOL    = 2;  // 时钟极性
localparam SPI_CR1_CPHA    = 3;  // 时钟相位
localparam SPI_CR1_LSBFIRST = 4; // 数据顺序

// SPI_SR 位域定义
localparam SPI_SR_BUSY     = 0;  // 忙标志
localparam SPI_SR_TXE      = 1;  // 发送FIFO空
localparam SPI_SR_RXNE     = 2;  // 接收FIFO非空
localparam SPI_SR_OVR      = 3;  // 溢出错误
localparam SPI_SR_MODF     = 4;  // 模式错误
💡 我的经验:寄存器地址一定要留够扩展空间。我见过一个设计,寄存器地址从0x00到0x0C排得满满当当,后来想加个CRC功能,发现没地址可用了。最后只能改地址映射,驱动代码全得重写。所以,地址空间留30%的余量,这是血的教训。

2.4 AHB/APB总线接口设计

总线接口是SPI控制器和CPU通信的「门面」。现在主流SoC都用AHB或APB总线。AHB适合高性能,APB适合低功耗外设。SPI控制器一般挂APB总线就够了,但如果数据吞吐量要求高(比如几十Mbps),建议用AHB。

我以APB总线为例,接口信号很简单:

// APB从机接口信号
input         pclk;      // APB时钟
input         presetn;   // APB复位(低有效)
input         psel;      // 从机选择
input         penable;   // 使能信号
input         pwrite;    // 写使能(1:写,0:读)
input  [15:0] paddr;     // 地址(按字节寻址)
input  [31:0] pwdata;    // 写数据
output [31:0] prdata;    // 读数据
output        pready;    // 准备好信号(用于等待状态)

APB的读写时序很简单,就两个周期:

  • SETUP周期:psel拉高,paddr和pwrite有效。
  • ACCESS周期:penable拉高,数据在pclk上升沿采样。

我习惯在APB接口里直接做地址译码,然后生成各个寄存器的写使能和读多路选择器:

// 地址译码
wire reg_cr1_sel = psel & (paddr[15:0] == 16'h0000);
wire reg_cr2_sel = psel & (paddr[15:0] == 16'h0004);
wire reg_dr_sel  = psel & (paddr[15:0] == 16'h000C);

// 写使能(在ACCESS周期有效)
wire wr_cr1 = reg_cr1_sel & penable & pwrite;
wire wr_cr2 = reg_cr2_sel & penable & pwrite;
wire wr_dr  = reg_dr_sel  & penable & pwrite;

// 读数据多路选择
always @(*) begin
  case (paddr[15:0])
    16'h0000: prdata = {16'b0, reg_cr1};
    16'h0004: prdata = {16'b0, reg_cr2};
    16'h0008: prdata = {16'b0, reg_sr};
    16'h000C: prdata = {16'b0, reg_dr};
    default:  prdata = 32'b0;
  endcase
end
📌 小技巧:APB接口的pready信号,我通常直接拉高(零等待),因为寄存器读写都是单周期操作。但如果你的SPI控制器有FIFO,读空或写满时需要插入等待周期,那pready就要根据FIFO状态来驱动。

好了,架构设计这块就聊到这儿。你想想看,顶层模块划分清楚了,时钟域和复位策略定好了,寄存器列表定义好了,总线接口也搭起来了——剩下的就是把这些模块一个个填上RTL代码。下一章我们开始撸SPI核心控制状态机的代码,那才是真正见功夫的地方。

📖 本章小结:
  • SPI控制器拆分为7个模块,分属两个时钟域
  • 总线域用同步复位,SPI域用异步复位同步释放
  • 跨时钟域信号必须经过同步器或异步FIFO
  • 寄存器分控制、状态、数据三类,地址空间留余量
  • APB接口实现地址译码和读写控制
可可精品资料库