4. 总线与互联:TileLink总线协议详解、AXI4总线桥接设计、跨时钟域同步策略、总线矩阵与仲裁器实现

总线互联,说白了就是SoC的“血管系统”。数据怎么流、谁先走、跨时钟域怎么同步,这些搞不定,整个芯片就跑不起来。我这些年调过的RISC-V SoC,十有八九的问题都出在总线上。今天咱们就把这块硬骨头啃下来。

4.1 TileLink总线协议详解

TileLink是SiFive主推的片上互联协议,也是RISC-V生态里用得最多的总线之一。它跟AXI最大的区别在于——它原生支持缓存一致性。嗯,这一点在做多核系统时特别香。

TileLink有五个信道,我习惯叫它们“五兄弟”:

信道 方向 作用
A (Acquire) Master → Slave 发起读/写请求,获取权限
B (Probe) Slave → Master 一致性探测,问“你缓存里有这个地址吗?”
C (Release) Master → Slave 释放缓存行,写回数据
D (Grant) Slave → Master 响应数据,授予权限
E (Finish) Master → Slave 握手完成,确认事务结束

你想想看,AXI只有5个通道,TileLink也是5个,但TileLink的B信道和C信道是AXI没有的。这就是它做缓存一致性的底气。

我的经验: 刚开始接触TileLink时,我总觉得E信道是多余的。直到有一次调试多核数据不一致,才发现没有E信道的确认,Slave根本不知道Master什么时候真正用完了数据。这个“最后握手”在乱序执行场景下特别重要。

TileLink还分三个等级:TL-UL( Uncached Lightweight)、TL-UH( Uncached Heavyweight)和TL-C(Cached)。做SoC集成时,我一般这么选:

  • TL-UL:给控制寄存器、GPIO这类低速外设用,延迟低、面积小
  • TL-UH:给DMA、以太网这类高吞吐但不需缓存一致性的设备
  • TL-C:给CPU核、GPU核,必须走缓存一致性

4.2 AXI4总线桥接设计

现实很骨感——市面上大部分IP还是AXI4接口。你要把RISC-V核(走TileLink)挂到AXI总线上,就得做桥接。

桥接的核心,说白了就是“协议翻译”。我做过一个项目,需要把TL-UH转成AXI4-Full,踩了不少坑。这里把关键点列出来:

TL-UH → AXI4 桥接要点:
  • TL的A信道对应AXI的AR+AW通道,但TL的地址和数据是打包的,AXI是分开的——需要做拆包
  • TL的D信道对应AXI的R+B通道,响应类型要映射好:TL的GrantData对应AXI的RVALID+RDATA,TL的Grant对应AXI的BVALID+BRESP
  • 突发长度:TL最大支持64拍,AXI4最大256拍,桥接时要做长度截断或拆分
  • ID域:TL的source ID和AXI的AXID要一一映射,否则乱序回来对不上号

我曾经犯过一个低级错误——TL的burst类型是递增(INCR),我直接映射成AXI的FIXED,结果外设读回来的数据全是乱的。后来花了整整两天才定位到问题。嗯,从那以后我每次做桥接,都会在验证环境里把burst类型、地址对齐、wrap边界全测一遍。

下面是一个简化的桥接状态机核心代码片段:

// TL-UL to AXI4-Lite bridge FSM (简化版)
typedef enum logic [2:0] {
    IDLE,
    READ_ADDR,
    READ_DATA,
    WRITE_ADDR,
    WRITE_DATA,
    WRITE_RESP
} state_t;

state_t state, next_state;

always_comb begin
    next_state = state;
    case (state)
        IDLE: begin
            if (tl_a_valid && tl_a_opcode == GET)
                next_state = READ_ADDR;
            else if (tl_a_valid && tl_a_opcode == PUT_FULL_DATA)
                next_state = WRITE_ADDR;
        end
        READ_ADDR: begin
            if (axi_arready)
                next_state = READ_DATA;
        end
        READ_DATA: begin
            if (axi_rvalid && axi_rready)
                next_state = IDLE;  // 单拍读完成
        end
        // ... 写路径类似
    endcase
end

4.3 跨时钟域同步策略

跨时钟域(CDC)是数字IC设计里最容易出bug的地方之一。我见过太多芯片因为CDC没处理好,跑着跑着就死机了。

SoC里常见的跨时钟域场景:

  • CPU核时钟(高频)→ 外设总线时钟(低频)
  • DDR控制器时钟 → AXI互联时钟
  • 调试接口(JTAG时钟)→ 系统时钟

我的处理原则很简单:

  1. 控制信号用双级同步器——两个触发器串起来,降低亚稳态概率
  2. 数据总线用异步FIFO——双口RAM + 格雷码指针,保证数据不丢不重
  3. 握手协议用“请求-确认”——req跨时钟域,ack回来,两边都等到稳定再操作
避坑指南: 我曾经在AXI桥接里直接用双级同步器同步了AXI的VALID信号,结果数据偶尔会丢一拍。后来发现VALID是组合逻辑输出,同步前必须先寄存一拍。记住:同步器前面一定要加一个寄存器,把组合逻辑打一拍再送进去。

异步FIFO的深度怎么算?我一般这么估:

写时钟频率 读时钟频率 最大突发长度 推荐FIFO深度
200 MHz 100 MHz 16 拍 32
500 MHz 200 MHz 64 拍 128
1 GHz 500 MHz 256 拍 512

这个表不是死公式。你想想看,如果写时钟比读时钟快很多,FIFO深度就得留余量,防止写指针追上读指针。我习惯再加2~4个深度做安全余量。

4.4 总线矩阵与仲裁器实现

总线矩阵,说白了就是多个Master(CPU、DMA、GPU)怎么共享多个Slave(内存、外设)。仲裁器决定“谁先用总线”。

我常用的仲裁策略有三种:

  • 固定优先级:CPU永远最高,DMA次之。实现简单,但低优先级设备可能饿死
  • 轮询(Round-Robin):每个Master轮流用,公平但延迟不稳定
  • 加权轮询:给高吞吐设备多分几个时间片,适合视频、网络这类场景

下面这张图是我做的一个4x4总线矩阵的结构,你可以看到Master和Slave之间通过交叉开关连接:

4x4 总线矩阵结构图 Master 0 (CPU) Master 1 (DMA) Master 2 (GPU) Master 3 (Eth) Slave 0 (DDR) Slave 1 (UART) Slave 2 (SPI) Slave 3 (GPIO) 交叉开关矩阵 (Crossbar Switch) 仲裁器 (Round-Robin / 固定优先级) 说明: • 每个Master通过交叉开关可以访问任意Slave,但同一时刻只能有一个Master访问同一个Slave • 仲裁器决定多个Master同时请求同一Slave时的访问顺序 • 实际实现中,交叉开关内部包含地址译码器、多路选择器和仲裁逻辑

仲裁器的Verilog实现,我一般这么写:

// Round-Robin 仲裁器(4个Master)
module rr_arbiter #(
    parameter NUM_MASTERS = 4
) (
    input  logic [NUM_MASTERS-1:0] req,
    input  logic                   clk, rst_n,
    output logic [NUM_MASTERS-1:0] grant
);

    logic [$clog2(NUM_MASTERS)-1:0] priority;
    logic [NUM_MASTERS-1:0]         masked_req;
    logic [NUM_MASTERS-1:0]         masked_grant;

    // 掩码:只考虑优先级以上的请求
    assign masked_req = req & ({NUM_MASTERS{1'b1}} << priority);

    // 优先仲裁掩码后的请求
    always_comb begin
        masked_grant = '0;
        for (int i = 0; i < NUM_MASTERS; i++) begin
            if (masked_req[i]) begin
                masked_grant[i] = 1'b1;
                break;
            end
        end
    end

    // 如果掩码后无请求,则从0开始重新仲裁
    assign grant = (masked_grant != '0) ? masked_grant : (req & (~req + 1));

    // 更新优先级:当前被grant的Master的下一个
    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            priority <= '0;
        else if (|grant)
            priority <= priority + 1;
    end

endmodule
我的习惯: 仲裁器的优先级更新逻辑,我一般放在grant有效后的下一个时钟沿更新。这样当前事务不会被中断,下一个事务再换人。如果你在grant有效的同时更新优先级,可能会造成“抢跑”问题——当前Master还没用完总线,优先级就变了。

总线矩阵的实现,说白了就是地址译码 + 多路选择 + 仲裁。地址译码决定“这个地址属于哪个Slave”,多路选择把Master的请求路由到对应的Slave,仲裁解决冲突。我一般用组合逻辑做地址译码,用寄存器做多路选择——这样时序好收敛。

嗯,总线这块内容确实不少。TileLink的协议细节、AXI桥接的映射关系、CDC的同步策略、仲裁器的公平性,每一个都值得深挖。我建议你在做SoC集成时,先把总线拓扑画清楚,再逐块实现。别一上来就写代码,否则后面调试起来会非常痛苦。