3. Verilog基础语法(下):结构化建模、任务与函数、编译预处理
好,咱们接着聊。上一节我们把Verilog的“词”和“句”讲清楚了,这一节要聊的是“段落”和“章节”——也就是怎么把零散的代码组织成有层次、可复用的硬件描述。
结构化建模,说白了就是三种描述硬件的“视角”。你想想看,描述一个加法器,你可以用一个个与门或门搭出来(门级),也可以用assign连续赋值写个表达式(数据流级),还可以用always块描述它的行为(行为级)。这三种方式,我在项目里都见过,也踩过坑。
核心观点:三种建模方式不是互斥的,而是互补的。一个成熟的RTL设计,往往是行为级控制逻辑 + 数据流级运算路径 + 门级关键路径优化的混合体。
3.1 结构化建模的三种层次
3.1.1 门级建模(Gate-Level)
门级建模是最底层的描述方式。你直接实例化标准单元库里的与门、或门、非门、缓冲器。嗯,这里要注意,门级建模在现在的ASIC流程里,基本是综合工具帮你做的。但为什么还要学?
我个人习惯在两种场景下用手写门级:一是做FPGA里的小型快速进位链,二是做后仿真的网表调试。你想想看,当综合后的网表有几十万行,你一眼看出某个与门的输入接错了,那感觉——比在行为级代码里找bug快多了。
// 门级建模示例:半加器
module half_adder (
input a, b,
output sum, cout
);
xor u_xor (sum, a, b); // 异或门
and u_and (cout, a, b); // 与门
endmodule
这里每个门都是一个实例,u_xor和u_and是实例名。我曾经在调试一个老项目时,发现设计者把异或门和与门的输入顺序写反了——虽然功能没错,但后仿时序收敛不了。所以,门级建模的连线顺序一定要和端口声明一致。
3.1.2 数据流级建模(Dataflow)
数据流级用assign连续赋值语句,描述信号之间的组合逻辑关系。说白了,就是写布尔表达式。
// 数据流级建模:半加器
module half_adder_df (
input a, b,
output sum, cout
);
assign sum = a ^ b;
assign cout = a & b;
endmodule
这种方式可读性比门级好太多了。我在项目中,80%的组合逻辑都用数据流级描述。但有个坑——assign语句的右边不能出现寄存器变量,否则综合会报错。我曾经有个同事,在assign里用了一个always块里的reg变量,查了半天才发现。
3.1.3 行为级建模(Behavioral)
行为级用always块,配合if-else、case等语句,描述电路的行为。这是最接近软件思维的描述方式,也是新手最容易上手、也最容易出错的。
// 行为级建模:半加器(其实没必要,但为了演示)
module half_adder_bh (
input a, b,
output reg sum, cout
);
always @(*) begin
case ({a, b})
2'b00: begin sum = 0; cout = 0; end
2'b01: begin sum = 1; cout = 0; end
2'b10: begin sum = 1; cout = 0; end
2'b11: begin sum = 0; cout = 1; end
endcase
end
endmodule
行为级建模最大的陷阱是——你以为在写软件,其实在画电路。比如always @(posedge clk)里漏了else分支,综合工具会给你生成一个锁存器。我刚开始做设计时,就因为这个被 mentor 骂过。
我的建议:组合逻辑用always @(*),时序逻辑用always @(posedge clk)。别混用,别偷懒。每个always块只描述一个功能,这样调试时能快速定位问题。
3.2 任务(Task)与函数(Function)
任务和函数,是Verilog里用来封装可重用代码的机制。很多初学者分不清它们,我当年也迷糊过。其实区别很简单:
| 特性 | 函数(function) | 任务(task) |
|---|---|---|
| 返回值 | 必须有,且只有一个 | 无返回值,通过output/input传递 |
| 时序控制 | 不能包含#、@、wait等 | 可以包含时序控制 |
| 调用方式 | 作为表达式的一部分 | 作为独立的语句 |
| 可综合性 | 纯组合逻辑可综合 | 通常不可综合(用于仿真) |
函数适合做纯组合逻辑的运算,比如计算奇偶校验、数据编码。任务适合做仿真中的激励生成、波形打印等。
// 函数示例:计算奇偶校验位
function parity;
input [7:0] data;
begin
parity = ^data; // 异或归约,返回1表示奇数个1
end
endfunction
// 调用
wire p = parity(8'b1010_1011);
// 任务示例:生成仿真激励
task gen_stimulus;
input [7:0] addr;
input [31:0] data;
begin
@(posedge clk);
addr_bus <= addr;
data_bus <= data;
we <= 1;
@(posedge clk);
we <= 0;
end
endtask
注意:函数里不能调用任务,但任务里可以调用函数。另外,函数默认是1位返回值,如果要返回多位,必须在声明时指定范围,比如function [7:0] my_func;。
3.3 编译预处理
编译预处理指令,以反引号`开头。它们不是Verilog语法的一部分,而是编译器在编译前处理的文本替换。我常用的有这几个:
`define:宏定义,类似C语言的#define`include:文件包含`ifdef / `ifndef / `else / `endif:条件编译`timescale:时间单位和精度
// 宏定义示例
`define DATA_WIDTH 32
`define SIM_MODE
reg [`DATA_WIDTH-1:0] data_bus;
`ifdef SIM_MODE
initial $monitor("data = %h", data_bus);
`endif
我在项目中,经常用`ifdef来做仿真和综合的代码切换。比如仿真时打印调试信息,综合时把这些代码“剪掉”。但要注意——宏定义是全局的,不同文件之间可能会冲突。我建议把宏定义集中放在一个头文件里,用`include统一管理。
一个小技巧:用`timescale 1ns/1ps定义仿真时间精度。1ns是时间单位,1ps是精度。精度不能比单位更粗,否则仿真器会报错。我习惯用1ns/1ps,既能满足大多数场景,又不会让仿真跑得太慢。
3.4 本章知识体系
为了让你更直观地理解这三部分内容的关系,我画了一张图:
这张图把本章的三个核心模块串起来了。结构化建模是“骨架”,任务与函数是“肌肉”,编译预处理是“皮肤”——三者结合,才能写出可维护、可复用的Verilog代码。
好了,这一节的内容就到这里。记住,写Verilog不是写C语言,你脑子里要时刻有一张电路图。下一节我们会聊到组合逻辑和时序逻辑的建模技巧,那才是真正的实战环节。