2. SPI控制器架构设计:顶层模块划分、时钟域与复位策略、寄存器列表定义
好,咱们进入正题。上一章我们把SPI协议拆了个底朝天,现在该动手搭架子了。架构设计这事儿,说白了就是「分而治之」——把一个大系统切成若干小块,每块干好自己的活,再通过标准接口互相通信。
我个人习惯,在设计任何模块之前,先画一张顶层结构图。这张图能帮你理清数据流、控制流和时钟域边界。来,先看看我画的这张架构图:
这张图我画了好几个版本才定下来。你注意看,整个控制器被切成了两大时钟域:左侧和上方的总线时钟域(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连接。这样顶层看起来就像一张「接线图」,子模块只管自己的逻辑。
2.2 时钟域与复位策略
时钟域划分是架构设计的核心决策。我见过不少新手把整个SPI控制器放在一个时钟域里,结果要么总线频率跑不上去,要么SPI时钟抖动太大。说白了,总线时钟和SPI时钟本来就是两个独立的时钟源,硬要同步化反而得不偿失。
我的做法是这样的:
- 总线时钟域(HCLK):寄存器读写、中断逻辑、状态更新。复位用同步复位,跟HCLK对齐。
- SPI时钟域(SPI_CLK):协议状态机、移位操作、IO控制。复位用异步复位、同步释放,确保SPI_CLK还没稳定时模块处于已知状态。
跨时钟域的数据传输,我统一用双级同步器处理单比特信号(如中断请求、传输完成标志),用异步FIFO处理多比特数据(如发送FIFO、接收FIFO)。
复位策略上,我推荐这样写:
// 总线时钟域:同步复位
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
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; // 模式错误
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
好了,架构设计这块就聊到这儿。你想想看,顶层模块划分清楚了,时钟域和复位策略定好了,寄存器列表定义好了,总线接口也搭起来了——剩下的就是把这些模块一个个填上RTL代码。下一章我们开始撸SPI核心控制状态机的代码,那才是真正见功夫的地方。
- SPI控制器拆分为7个模块,分属两个时钟域
- 总线域用同步复位,SPI域用异步复位同步释放
- 跨时钟域信号必须经过同步器或异步FIFO
- 寄存器分控制、状态、数据三类,地址空间留余量
- APB接口实现地址译码和读写控制