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中的位置:
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世界对话的桥梁。这些技能,在后面的项目实战中会反复用到。多练练,自然就熟了。