4. Verilog基础与RTL设计(下):参数化、生成语句与总线接口

好,我们接着往下聊。上一章我们把Verilog的基础语法和组合逻辑、时序逻辑过了一遍。这一章,咱们要玩点更高级的——让代码变得灵活、可配置,甚至能自动“繁殖”。说白了,就是从一个“写死”的电路,进化成一个“可配置”的IP核。

我个人觉得,能不能用好参数化设计和generate语句,是区分“代码搬运工”和“芯片设计师”的一道分水岭。你想想看,一个项目里,同样的FIFO、同样的总线桥,可能要用在不同位宽、不同深度的场景。难道每次都重新写一遍?那不得累死。所以,这一章的内容,实战性非常强。

4.1 参数化设计:parameter与localparam

参数化,说白了就是给模块留“旋钮”。你拧一下,模块的行为或者位宽就变了。Verilog里有两个关键词干这事:parameter 和 localparam。

  • parameter:模块对外暴露的接口参数。上层模块例化时可以修改它。
  • localparam:模块内部的本地常量。外部看不见,也改不了。通常用来做状态机的编码、或者根据parameter计算出来的中间常量。

我习惯把parameter放在模块端口声明的前面,一眼就能看到这个模块有哪些“旋钮”。

核心原则:能用parameter的地方,就别写死。这能让你的代码复用率提升好几倍。

来看个例子。一个可配置位宽的加法器:

module adder #(
    parameter DATA_WIDTH = 8
)(
    input  [DATA_WIDTH-1:0] a,
    input  [DATA_WIDTH-1:0] b,
    output [DATA_WIDTH-1:0] sum
);
    assign sum = a + b;
endmodule

上层例化时,可以这样用:

adder #(.DATA_WIDTH(16)) u_adder_16 (...);
adder #(.DATA_WIDTH(32)) u_adder_32 (...);

你看,同一个模块,生成了两个不同位宽的加法器。这就是参数化的威力。

我的习惯:对于深度、位宽、FIFO大小这类参数,我通常会给一个默认值(比如8或16),这样在简单测试时可以直接用默认值,省事。

4.2 generate语句:让代码自动“繁殖”

有时候,我们需要重复例化同一个模块很多次,或者根据某个条件来决定是否生成某段电路。这时候,generate 语句就派上用场了。它有点像C语言里的宏展开,但它是硬件描述层面的。

generate有三种常用形式:

  • generate for:循环生成,最常用。
  • generate if:条件生成,根据参数决定是否生成某段逻辑。
  • generate case:多分支条件生成。

我记得有一次做多通道数据采集,需要例化32个完全一样的SPI接口模块。要是手写32遍例化代码,不仅累,还容易出错。用 generate for 几行就搞定了。

genvar i;
generate
    for (i = 0; i < 32; i = i + 1) begin : spi_inst
        spi_master #(
            .CLK_DIV(CLK_DIV)
        ) u_spi (
            .clk        (clk),
            .rst_n      (rst_n),
            .cs         (cs[i]),
            .sclk       (sclk[i]),
            .mosi       (mosi[i]),
            .miso       (miso[i])
        );
    end
endgenerate

这里有个细节要注意:begin : spi_inst 这个命名是必须的。它给每个生成的实例一个唯一的层级名称,方便仿真时调试。如果不写,很多仿真器会报错。

我曾经踩过的坑:在generate for循环里,如果内部有组合逻辑赋值,一定要小心变量作用域。循环变量 genvar i 在generate块内是独立的,但如果你在循环外又用了同名变量,可能会引起奇怪的仿真行为。建议把genvar变量名取得特殊一点,比如 genvar g_i。

4.3 函数与任务:function/task

function和task,说白了就是Verilog里的“子程序”。它们能把重复的逻辑封装起来,让代码更整洁。但两者有本质区别:

特性 function task
返回值 必须有,且只有一个 可以有多个输出,或无返回值
时序控制 不能包含 #delay、@、wait 可以包含时序控制
调用方式 作为表达式的一部分 作为独立的语句
可综合性 通常可综合(纯组合逻辑) 部分可综合(需谨慎)
典型用途 数据计算、位宽转换、编码解码 仿真激励、复杂时序操作

我个人的建议是:在RTL设计里,尽量用function,少用task。因为task里可以带时序控制,很容易写出不可综合的代码。function天生就是组合逻辑,综合工具很喜欢它。

举个例子,一个计算奇偶校验位的function:

function [7:0] calc_parity;
    input [31:0] data;
    integer i;
    begin
        calc_parity = 8'b0;
        for (i = 0; i < 32; i = i + 1) begin
            calc_parity = calc_parity ^ data[i*8 +: 8];
        end
    end
endfunction

调用时直接赋值:assign parity = calc_parity(tx_data); 干净利落。

4.4 多维数组与存储器建模

Verilog支持多维数组,这在建模存储器(RAM、ROM、FIFO)时非常有用。一个二维数组,就可以看作一个存储器阵列。

声明方式:

reg [7:0] mem [0:255];  // 256个字节,每个字节8位
reg [31:0] ram [0:1023]; // 1K x 32位的RAM

访问方式:mem[addr] 取出的就是一个8位的数据。

这里有个容易混淆的点:reg [7:0] mem [0:255] 和 reg [7:0] mem [255:0] 的区别。前者地址从0到255递增,后者地址从255递减到0。我习惯统一用 [0: DEPTH-1],这样地址和索引的概念更一致。

一个简单的单端口RAM模型:

module sp_ram #(
    parameter DATA_WIDTH = 8,
    parameter ADDR_WIDTH = 8
)(
    input                    clk,
    input                    we,
    input  [ADDR_WIDTH-1:0] addr,
    input  [DATA_WIDTH-1:0] din,
    output [DATA_WIDTH-1:0] dout
);
    reg [DATA_WIDTH-1:0] mem [0:(1<<ADDR_WIDTH)-1];

    always @(posedge clk) begin
        if (we)
            mem[addr] <= din;
        dout <= mem[addr];
    end
endmodule

避坑指南:在综合时,多维数组会被综合成寄存器阵列或SRAM。如果数组很大(比如超过1K深度),建议用工艺库提供的SRAM编译器来生成,而不是直接用reg数组。否则综合工具会给你生成一大堆触发器,面积和功耗都受不了。

4.5 接口与总线协议基础:APB与AXI-Lite

好了,终于到了接口部分。在一个复杂的SoC里,不同的IP核之间怎么通信?靠总线协议。APB和AXI-Lite是两种最常用的轻量级总线,专门用来访问控制寄存器。

先看一张图,了解一下它们在SoC中的位置:

SoC总线架构:APB与AXI-Lite CPU / 主控 AXI Full 总线(高性能、高带宽) AXI-Lite 总线(轻量级、控制寄存器) APB 桥接器(Bridge) APB Slave 1 UART APB Slave 2 GPIO APB Slave 3 Timer APB Slave N 说明:AXI-Lite用于高性能控制,APB用于低功耗外设。两者通过桥接器连接。

4.5.1 APB协议

APB(Advanced Peripheral Bus)是AMBA家族里最简单的协议。它只有两个状态:IDLE和SETUP/ACCESS。特点是:

  • 非流水线:每个传输需要两个时钟周期(SETUP + ACCESS)。
  • 无等待机制:从机必须在第二个周期准备好数据,不能插入等待。
  • 信号少:只有地址、写数据、读数据、写使能、选择、使能等几个信号。

APB的写时序:

// 写传输
always @(posedge clk) begin
    if (psel & penable & pwrite) begin
        mem[paddr] <= pwdata;
    end
end

APB的读时序:

// 读传输
always @(posedge clk) begin
    if (psel & penable & !pwrite) begin
        prdata <= mem[paddr];
    end
end

我的经验:APB的从机设计非常简单,几乎不会出错。但要注意,psel 和 penable 的组合关系。在SETUP周期,psel=1, penable=0;在ACCESS周期,psel=1, penable=1。很多新手会把这两个信号搞混,导致数据写错地址。

4.5.2 AXI-Lite协议

AXI-Lite是AXI Full的轻量版。它保留了AXI的独立地址/数据通道,但去掉了突发传输(burst)、乱序传输等复杂特性。每个传输只传一个数据。

AXI-Lite有5个独立通道:

  • AW:写地址通道
  • W:写数据通道
  • B:写响应通道
  • AR:读地址通道
  • R:读数据通道

每个通道都有各自的VALID/READY握手信号。这种握手机制,说白了就是“你准备好了告诉我,我准备好了告诉你,咱俩都准备好了就干活”。

一个简单的AXI-Lite从机写逻辑:

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        awready <= 1'b0;
        wready  <= 1'b0;
        bvalid  <= 1'b0;
    end else begin
        // 握手:AW通道
        if (awvalid & awready) awready <= 1'b0;
        else if (!awready) awready <= 1'b1;

        // 握手:W通道
        if (wvalid & wready) wready <= 1'b0;
        else if (!wready) wready <= 1'b1;

        // 写响应
        if (awvalid & awready & wvalid & wready) begin
            mem[awaddr] <= wdata;
            bvalid <= 1'b1;
            bresp <= 2'b00; // OKAY
        end else if (bvalid & bready) begin
            bvalid <= 1'b0;
        end
    end
end

我曾经犯过的错:在AXI-Lite从机里,awready 和 wready 不能同时拉高后一直保持。必须等握手完成后拉低,否则主机会认为你一直在接收新的地址和数据,导致数据覆盖。正确的做法是:握手成功后,把ready信号拉低一个周期,再重新拉高。

好了,这一章的内容就到这里。参数化让你的代码更灵活,generate让你的代码更简洁,function/task让你的代码更整洁,多维数组让你能建模存储器,而APB和AXI-Lite则是你与SoC世界对话的桥梁。这些技能,在后面的项目实战中会反复用到。多练练,自然就熟了。