☰
Verilog三种描述方式详解:结构化、数据流与行为级
2026/10/17 5:14:29 网站建设 项目流程

1. 三种描述方式到底在描述什么

刚接触Verilog那会儿,我最大的困惑不是语法,而是同一个电路为什么能写出好几种样子。一个简单的二选一选择器,有人用assign一行搞定,有人用always块写好几行,还有人老老实实把门电路一个个例化出来。更让人抓狂的是,这三种写法仿真结果一模一样,综合出来的电路也差不多。那到底该用哪种?

这个问题困扰了我挺长时间,直到后来做多了项目才慢慢想明白:Verilog的三种描述方式,本质上是从三个不同的抽象层级去"看"同一个数字电路。结构化描述像是拿着电路图在搭积木,数据流描述像是用布尔表达式在写公式,行为级描述像是用高级语言在描述功能。它们不是互斥的,而是同一枚硬币的不同侧面。

先把三种方式的核心特征摆出来,方便你建立整体印象:

描述方式抽象层级核心语法思维方式典型场景
结构化描述最低模块例化、门级原语搭积木,连线门级网表、教学演示
数据流描述中等assign连续赋值写布尔表达式组合逻辑、简单运算
行为级描述最高always/initial块描述功能与算法时序逻辑、复杂控制

这三种方式对应的是数字设计的三个层次:门级、寄存器传输级(RTL)、算法级。结构化描述直接对应门级,你写出来的就是实实在在的逻辑门和它们的连接关系;数据流描述对应的是RTL中的组合逻辑部分,用表达式描述信号之间的运算关系;行为级描述则更接近算法级,你关注的是"做什么"而不是"怎么连"。

理解这一点很关键,因为它决定了你在不同阶段该用哪种方式。做架构设计的时候,行为级描述效率最高,几行代码就能描述一个复杂状态机;做时序约束和面积优化的时候,你得能看懂综合工具吐出来的门级网表,这就需要结构化描述的功底;而数据流描述则是日常RTL编码中用得最多的,尤其是组合逻辑部分。

很多人学Verilog的误区是一上来就死磕always块,觉得行为级描述最高级。实际上,不理解结构化描述,你永远看不懂综合报告;不理解数据流描述,你写不出干净的组合逻辑。三种方式得配合着用。

我个人的经验是:新手先学数据流描述建立"信号运算"的概念,再学行为级描述掌握时序控制,最后回头补结构化描述理解底层实现。这个顺序比教材上的顺序更符合认知规律,因为数据流描述的assign语句最直观,看到表达式就能想象出电路。

2. 结构化描述:像搭积木一样搭电路

2.1 结构化描述的核心逻辑

结构化描述说白了就是把电路拆成一个个模块,然后通过端口把它们连起来。这跟你小时候玩乐高是一个道理:每块积木有凸起和凹槽(端口),你把它们拼在一起就构成了一个完整的东西。

在Verilog里,结构化描述有两种粒度:一种是门级原语,比如and、or、not、nand这些;另一种是模块例化,就是把你自己写的或者别人写好的模块当成积木来用。

先看门级原语的例子。假设要实现一个半加器,用结构化描述写出来是这样的:

module half_adder_struct ( input wire a, input wire b, output wire sum, output wire carry ); xor u1 (sum, a, b); // 异或门产生和 and u2 (carry, a, b); // 与门产生进位 endmodule

这段代码里,xor和and就是门级原语,u1和u2是例化名。你几乎能直接画出对应的电路图:一个异或门和一个与门,输入都是a和b,输出分别是sum和carry。

再看模块例化的例子。假设你已经有了一个半加器模块,现在要搭一个全加器:

module full_adder_struct ( input wire a, input wire b, input wire cin, output wire sum, output wire cout ); wire s1, c1, c2; half_adder_struct u1 ( .a (a), .b (b), .sum (s1), .carry(c1) ); half_adder_struct u2 ( .a (s1), .b (cin), .sum (sum), .carry(c2) ); or u3 (cout, c1, c2); endmodule

这就是典型的结构化思维:两个半加器加一个或门,拼出一个全加器。你不需要知道半加器内部是怎么实现的,只需要知道它的端口定义和功能。

2.2 结构化描述的实操要点

用结构化描述的时候,有几个坑我踩过不止一次,这里挨个说清楚。

端口连接方式的选择。Verilog支持两种端口连接:按位置连接和按名称连接。按位置连接就是.a(a)写成(a),顺序必须和模块定义一致;按名称连接就是.a(a)这种写法,顺序无所谓。我强烈建议永远用按名称连接,原因很简单:按位置连接一旦模块端口顺序变了,你的例化就全错了,而且这种错误很难查,因为语法上完全合法。

// 按位置连接——不推荐 half_adder_struct u1 (a, b, s1, c1); // 按名称连接——推荐 half_adder_struct u1 ( .a (a), .b (b), .sum (s1), .carry(c1) );

未连接端口的处理。有时候你例化一个模块,但某些输出端口暂时用不到。这时候不能空着,得显式地留空或者接一个不用的wire。我见过有人直接不写,结果综合工具报warning,查了半天才发现是端口没接。

half_adder_struct u1 ( .a (a), .b (b), .sum (s1), .carry() // 显式留空,表示不使用 );

门级原语的驱动能力。门级原语默认驱动能力是strong0和strong1,一般不用改。但如果你在仿真里看到信号是x,有可能是多个门同时驱动同一个wire导致的冲突。这种情况在结构化描述里比在行为级描述里更容易出现,因为门级原语没有优先级的概念。

结构化描述最大的价值不在于写RTL,而在于读综合后的网表。当你做时序分析或者面积优化的时候,综合工具吐出来的就是门级网表,看不懂结构化描述就等于看不懂网表。

2.3 什么时候该用结构化描述

说实话,日常RTL编码中纯结构化描述用得不多,因为效率太低。一个32位加法器你要是用门级原语搭,得写几百行,而用数据流描述一行assign就搞定了。

但结构化描述在以下场景中不可替代:

  • 门级网表分析:综合后的网表就是结构化的,做形式验证、时序分析必须能看懂
  • IP核集成:当你例化第三方IP的时候,本质上就是在做结构化描述
  • 教学演示:理解数字电路底层原理,结构化描述最直观
  • 特定工艺库的原语调用:有些FPGA厂商提供的原语只能用结构化方式例化

我个人的做法是:RTL编码阶段尽量用数据流和行为级描述,但一定要保持"结构化思维"——写每一行代码的时候,脑子里要能想象出它对应什么电路结构。这样写出来的代码综合结果才可控。

3. 数据流描述:用表达式描述信号关系

3.1 数据流描述的核心机制

数据流描述的核心就一个关键字:assign。它描述的是信号之间的连续赋值关系,左边是wire类型,右边是表达式。只要右边任何一个信号发生变化,左边就会立即重新计算。这种特性叫"连续赋值",对应的是组合逻辑。

module dataflow_demo ( input wire [3:0] a, input wire [3:0] b, input wire sel, output wire [3:0] y, output wire eq ); assign y = sel ? a : b; // 二选一 assign eq = (a == b) ? 1'b1 : 1'b0; // 相等比较 endmodule

这段代码用两个assign就描述了一个选择器和一个比较器。你不需要关心底层用了几个门,综合工具会自动帮你优化。

数据流描述的关键在于表达式。Verilog支持丰富的运算符,包括算术运算、逻辑运算、位运算、关系运算、移位运算等。这些运算符可以自由组合,写出复杂的组合逻辑。

运算符类型符号示例综合结果
算术+-*assign y = a + b;加法器
逻辑&&||!assign y = a && b;逻辑门
位运算&|^~assign y = a & b;按位逻辑门
关系==!=><assign y = (a > b);比较器
移位<<>>assign y = a << 2;移位器
条件? :assign y = sel ? a : b;多路选择器

3.2 数据流描述的实操要点

位宽匹配是头号大坑。Verilog在赋值的时候会自动扩展或截断位宽,但这个规则很容易让人犯错。比如:

wire [3:0] a = 4'b1010; wire [7:0] b; assign b = a; // b = 8'b00001010,高位补0

如果a是有符号数,扩展的时候会补符号位。这个规则在写数据通路的时候特别重要,搞错了就会得到完全错误的结果。我的建议是:永远显式地指定位宽,不要依赖自动扩展。

assign b = {4'b0000, a}; // 显式扩展,一目了然

条件运算符的嵌套。? :可以嵌套使用,实现多路选择:

assign y = sel[1] ? (sel[0] ? d3 : d2) : (sel[0] ? d1 : d0);

这种写法虽然紧凑,但可读性差。如果选择分支超过4个,我建议改用case语句(行为级描述),或者用assign配合位拼接:

assign y = {4{sel == 2'b00}} & d0 | {4{sel == 2'b01}} & d1 | {4{sel == 2'b10}} & d2 | {4{sel == 2'b11}} & d3;

这种写法叫"与或式",综合出来就是标准的与或阵列,时序和面积都可预测。

assign不能用在always块里。这是语法层面的硬性规定。assign描述的是连续赋值,always块描述的是过程赋值,两者不能混用。我见过新手在always块里写assign,编译报错还找不到原因。

数据流描述最适合写纯组合逻辑。如果你发现自己在用assign写时序逻辑(比如想实现一个寄存器),那说明你用错了工具,应该改用always块。

3.3 数据流描述与结构化描述的对比

同样实现一个4位加法器,两种方式的写法差异很大:

// 数据流描述——一行搞定 module adder_dataflow ( input wire [3:0] a, input wire [3:0] b, input wire cin, output wire [3:0] sum, output wire cout ); assign {cout, sum} = a + b + cin; endmodule
// 结构化描述——需要例化4个全加器 module adder_struct ( input wire [3:0] a, input wire [3:0] b, input wire cin, output wire [3:0] sum, output wire cout ); wire c1, c2, c3; full_adder_struct u0 (.a(a[0]), .b(b[0]), .cin(cin), .sum(sum[0]), .cout(c1)); full_adder_struct u1 (.a(a[1]), .b(b[1]), .cin(c1), .sum(sum[1]), .cout(c2)); full_adder_struct u2 (.a(a[2]), .b(b[2]), .cin(c2), .sum(sum[2]), .cout(c3)); full_adder_struct u3 (.a(a[3]), .b(b[3]), .cin(c3), .sum(sum[3]), .cout(cout)); endmodule

数据流描述的优势一目了然:简洁、直观、易于维护。但它的劣势也很明显:你对综合结果的控制力较弱。综合工具可能会用超前进位加法器,也可能用行波进位加法器,取决于你的约束和工艺库。如果你对时序有严格要求,可能需要用结构化描述手动搭超前进位链。

我个人的选择标准是:没有特殊时序要求就用数据流描述,有特殊要求才用结构化描述手动优化。毕竟综合工具在大多数情况下比人聪明。

4. 行为级描述:描述功能而非结构

4.1 行为级描述的核心机制

行为级描述是Verilog中抽象层级最高的描述方式,核心是always块和initial块。在always块里,你可以用顺序语句(if、case、for等)来描述电路的行为,而不需要关心底层的门级实现。

module behavioral_demo ( input wire clk, input wire rst_n, input wire [3:0] d, output reg [3:0] q ); always @(posedge clk or negedge rst_n) begin if (!rst_n) q <= 4'b0000; else q <= d; end endmodule

这是一个4位寄存器的行为级描述。你看到的是"当时钟上升沿到来时,如果复位有效就清零,否则把d的值赋给q",而不是"4个D触发器怎么连"。这就是行为级描述的精髓:描述功能,而非结构。

行为级描述有两个关键概念需要搞清楚:阻塞赋值(=)和非阻塞赋值(<=)。

赋值类型符号执行时机适用场景综合结果
阻塞赋值=立即执行,顺序执行组合逻辑组合逻辑
非阻塞赋值<=块结束时统一更新时序逻辑寄存器

这个区别是Verilog学习中最容易混淆的地方。我见过太多人因为用错了赋值方式导致仿真结果和预期不符。记住一个简单的规则:时序逻辑用非阻塞赋值,组合逻辑用阻塞赋值。这条规则能帮你避开90%的坑。

4.2 行为级描述的实操要点

always块的敏感列表。组合逻辑的always块用@(*)或者列出所有输入信号;时序逻辑的always块用@(posedge clk)或者@(posedge clk or negedge rst_n)。

// 组合逻辑——敏感列表用* always @(*) begin case (sel) 2'b00: y = d0; 2'b01: y = d1; 2'b10: y = d2; default: y = d3; endcase end // 时序逻辑——敏感列表用边沿 always @(posedge clk or negedge rst_n) begin if (!rst_n) q <= 1'b0; else q <= d; end

组合逻辑的always块如果敏感列表写漏了信号,仿真结果会出错,但综合结果可能正确。这种"仿真综合不一致"的问题最难查,所以强烈建议组合逻辑一律用@(*)。

case语句的default分支。写case语句的时候,一定要加default分支。原因有两个:一是防止综合出锁存器(latch),二是提高代码的健壮性。锁存器是组合逻辑中非预期存储元件的统称,会导致时序分析困难,应该尽量避免。

always @(*) begin case (sel) 2'b00: y = d0; 2'b01: y = d1; 2'b10: y = d2; default: y = d3; // 必须有,防止latch endcase end

for循环的使用。行为级描述里可以用for循环,但要注意:循环次数必须是常数,综合工具会在编译时展开循环。如果循环次数是变量,综合工具无法展开,会报错。

// 可以综合——循环次数是常数 integer i; always @(*) begin for (i = 0; i < 4; i = i + 1) y[i] = a[i] & b[i]; end // 不可综合——循环次数是变量 always @(*) begin for (i = 0; i < n; i = i + 1) // n是变量,综合报错 y[i] = a[i] & b[i]; end

4.3 行为级描述与数据流描述的边界

很多人分不清什么时候该用assign,什么时候该用always。我的判断标准很简单:

  • 能用assign一行搞定的,就用assign:比如简单的逻辑运算、条件选择、位拼接
  • 需要多级条件判断或者case分支的,用always:比如状态机、复杂译码器
  • 时序逻辑一律用always:assign不能描述边沿触发的行为
// 用assign更合适——简单的条件选择 assign y = sel ? a : b; // 用always更合适——多级条件判断 always @(*) begin if (mode == 2'b00) y = a + b; else if (mode == 2'b01) y = a - b; else if (mode == 2'b10) y = a & b; else y = a | b; end

从综合结果来看,这两种写法可能得到相同的电路,但代码的可读性和可维护性差别很大。选择的标准不是"能不能综合",而是"哪种写法更能表达你的设计意图"。

5. 三种方式的混合使用与选型策略

5.1 混合使用的实际案例

实际项目中,纯用一种描述方式的情况很少,大多数时候是三种方式混合使用。我拿一个UART发送模块举例,展示一下混合使用的典型场景。

module uart_tx ( input wire clk, input wire rst_n, input wire tx_start, input wire [7:0] tx_data, output reg tx, output reg tx_busy ); // 参数定义——数据流描述 localparam IDLE = 2'b00; localparam START = 2'b01; localparam DATA = 2'b10; localparam STOP = 2'b11; reg [1:0] state, next_state; reg [7:0] data_buf; reg [2:0] bit_cnt; reg [15:0] baud_cnt; // 状态寄存器——行为级描述(时序逻辑) always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end // 次态逻辑——行为级描述(组合逻辑) always @(*) begin case (state) IDLE: next_state = tx_start ? START : IDLE; START: next_state = (baud_cnt == 16'd0) ? DATA : START; DATA: next_state = (baud_cnt == 16'd0 && bit_cnt == 3'd7) ? STOP : DATA; STOP: next_state = (baud_cnt == 16'd0) ? IDLE : STOP; default: next_state = IDLE; endcase end // 波特率计数器——行为级描述(时序逻辑) always @(posedge clk or negedge rst_n) begin if (!rst_n) baud_cnt <= 16'd0; else if (state == IDLE) baud_cnt <= 16'd0; else if (baud_cnt == 16'd0) baud_cnt <= 16'd104; // 假设时钟频率对应波特率 else baud_cnt <= baud_cnt - 1'b1; end // 数据缓冲——行为级描述(时序逻辑) always @(posedge clk or negedge rst_n) begin if (!rst_n) data_buf <= 8'd0; else if (tx_start && state == IDLE) data_buf <= tx_data; end // 位计数器——行为级描述(时序逻辑) always @(posedge clk or negedge rst_n) begin if (!rst_n) bit_cnt <= 3'd0; else if (state == DATA && baud_cnt == 16'd0) bit_cnt <= bit_cnt + 1'b1; else if (state != DATA) bit_cnt <= 3'd0; end // 输出逻辑——数据流描述 assign tx_busy = (state != IDLE); // 发送数据线——行为级描述(时序逻辑) always @(posedge clk or negedge rst_n) begin if (!rst_n) tx <= 1'b1; else begin case (state) IDLE: tx <= 1'b1; START: tx <= 1'b0; DATA: tx <= data_buf[bit_cnt]; STOP: tx <= 1'b1; default: tx <= 1'b1; endcase end end endmodule

这个例子中,assign tx_busy是数据流描述,状态机、计数器、输出逻辑都是行为级描述。如果综合工具需要,还可以在底层用结构化描述例化特定的IO单元。三种方式各司其职,配合使用。

5.2 选型策略与决策流程

面对一个具体的设计需求,怎么决定用哪种描述方式?我总结了一个决策流程:

第一步:判断是组合逻辑还是时序逻辑。时序逻辑必须用行为级描述(always块+非阻塞赋值),这个没得选。组合逻辑进入第二步。

第二步:判断逻辑复杂度。如果是一行表达式能搞定的简单逻辑,用数据流描述(assign);如果需要多级条件判断或者case分支,用行为级描述(always块+阻塞赋值)。

第三步:判断是否需要精确控制结构。如果对时序、面积有严格要求,或者需要调用特定工艺库的原语,用结构化描述。否则用数据流或行为级描述,让综合工具自动优化。

设计需求推荐方式理由
简单组合逻辑数据流描述简洁直观,综合结果可预测
复杂组合逻辑行为级描述case/if表达能力强
时序逻辑行为级描述唯一能描述边沿触发的方式
门级网表分析结构化描述网表本身就是结构化的
IP核集成结构化描述例化是唯一的集成方式
特定工艺原语结构化描述原语只能通过例化调用

5.3 常见误区与避坑指南

误区一:认为行为级描述一定比数据流描述"高级"。实际上,综合工具对两种方式的处理结果可能完全相同。选择的标准应该是可读性和可维护性,而不是抽象层级的高低。

误区二:在always块里用assign。这是语法错误,编译都过不了。assign只能用在模块级别,不能用在过程块里。

误区三:组合逻辑用非阻塞赋值。虽然仿真结果可能正确,但综合结果可能不符合预期。组合逻辑用阻塞赋值,时序逻辑用非阻塞赋值,这条规则要刻在脑子里。

误区四:敏感列表写漏信号。组合逻辑的always块如果敏感列表写漏了,仿真结果会出错。一律用@(*),让编译器自动推断敏感列表。

误区五:case语句不加default。这会导致综合出锁存器,增加时序分析的难度。每个case语句都要有default分支。

我踩过最深的坑是在一个状态机里用了阻塞赋值,仿真通过了,但上板后行为异常。查了三天才发现是赋值方式用错了。从那以后,我写时序逻辑一律用非阻塞赋值,组合逻辑一律用阻塞赋值,再也没出过类似问题。

6. 常见问题与排查技巧实录

6.1 仿真综合不一致问题排查

仿真结果和上板结果不一致,是Verilog开发中最让人头疼的问题。根据我的经验,90%以上的不一致都源于以下几个原因:

原因一:敏感列表不完整。组合逻辑的always块敏感列表写漏了信号,仿真时该信号变化不会触发块执行,但综合工具会把它当成组合逻辑,导致仿真和综合结果不一致。

// 错误写法——敏感列表漏了b always @(a) begin y = a & b; // b变化时y不会更新 end // 正确写法 always @(*) begin y = a & b; end

原因二:赋值方式用错。组合逻辑用非阻塞赋值,或者时序逻辑用阻塞赋值,都可能导致仿真综合不一致。

原因三:锁存器推断。组合逻辑中如果条件分支不完整,综合工具会推断出锁存器。锁存器在仿真中可能表现正常,但上板后时序问题会暴露出来。

// 错误写法——缺少else分支,推断出latch always @(*) begin if (en) y = d; // en为0时y保持原值,推断出latch end // 正确写法——补全所有分支 always @(*) begin if (en) y = d; else y = 1'b0; end

原因四:位宽不匹配。赋值时位宽不匹配会导致截断或扩展,仿真和综合的处理方式可能不同。

排查这类问题的技巧是:先看综合报告的warning。综合工具会报告锁存器推断、位宽不匹配、敏感列表不完整等问题。把这些warning全部消除,仿真综合不一致的问题基本就解决了。

6.2 常见问题速查表

问题现象可能原因排查方法解决方案
仿真结果与预期不符敏感列表不完整检查always块敏感列表改用@(*)
上板后行为异常赋值方式用错检查阻塞/非阻塞赋值时序用<=,组合用=
综合报告latch警告条件分支不完整检查if/case是否覆盖所有分支补全else和default
信号值为x多驱动冲突检查是否有多个源驱动同一wire确保每个wire只有一个驱动源
时序不满足组合逻辑太长查看综合报告的时序路径插入流水线寄存器
面积超预期综合工具优化不足查看综合报告的资源占用手动优化关键路径

6.3 独家避坑技巧

技巧一:用wire和reg的类型来辅助判断。wire只能用于assign和模块端口连接,reg只能用于always块中的过程赋值。如果你发现自己在always块里给wire赋值,那肯定错了。

技巧二:组合逻辑的always块里不要写<=。虽然语法上允许,但会导致仿真综合不一致。我个人的习惯是:看到always @(*)就条件反射地写=,看到always @(posedge clk)就条件反射地写<=。

技巧三:用localparam代替define。define是全局的,容易造成命名冲突;localparam是模块局部的,更安全。

技巧四:状态机用三段式写法。第一段时序逻辑更新状态寄存器,第二段组合逻辑计算次态,第三段时序逻辑或组合逻辑产生输出。这种写法结构清晰,易于调试和优化。

技巧五:关键路径手动插入寄存器。如果综合报告显示某条路径时序不满足,可以在中间插入一级寄存器,把长组合逻辑切成两段。这就是流水线设计的基本思路。

最后分享一个我用了很多年的调试方法:在Testbench里把关键信号都打印出来,对比仿真波形和实际上板波形。如果仿真波形正确但上板波形错误,那问题一定在综合或实现阶段;如果仿真波形就错了,那问题在RTL代码。这个方法能帮你快速定位问题所在的阶段。

6.4 代码风格建议

写了这么多年Verilog,我总结了几条代码风格建议,能帮你少走很多弯路:

  • 模块端口一律用按名称连接,不要用按位置连接
  • 组合逻辑一律用always @(*),不要手动列敏感列表
  • 时序逻辑一律用非阻塞赋值,组合逻辑一律用阻塞赋值
  • 每个case语句都要有default分支
  • 每个if语句都要有对应的else分支(除非你确实想推断出锁存器)
  • 信号命名要有意义,不要用a、b、c这种无意义的名称
  • 关键信号加注释,说明它的作用和时序关系

这些建议看起来简单,但真正做到的人不多。我见过太多项目因为代码风格混乱导致后期维护困难,改一个bug引入两个新bug。好的代码风格不是锦上添花,而是项目成功的基础。

三种描述方式没有优劣之分,只有适用场景的不同。结构化描述帮你理解底层实现,数据流描述帮你写出简洁的组合逻辑,行为级描述帮你描述复杂的时序行为。真正的高手不是只会用一种方式,而是能在合适的场景选择合适的方式。我个人的经验是:先把数据流和行为级描述用熟,再回头补结构化描述的功底,这样成长最快。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询