news 2026/9/6 11:44:10

Verilog结构建模与行为建模:从电路到信号流动的两种思维

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Verilog结构建模与行为建模:从电路到信号流动的两种思维

做了这么久数字逻辑设计,我经常碰到刚入门的朋友问一个问题:Verilog到底算一门什么语言?有人说是硬件描述语言,有人说是并行语言,这些说法都对,但我自己最认可的一种解释是——Verilog既能描述电路本身,也能描述信号在电路中的流动。这两个维度,恰好对应了它的两大核心描述风格:结构建模和行为建模。

这篇文章我想围绕这个主题,把Verilog的两种“描述方式”彻底讲透。适合刚学完语法但还没建立起硬件思维的学生,也适合写过一些代码但经常被仿真和综合结果不一致折磨的工程师。我会结合真实代码、波形思路和实际踩坑经验,把这套东西讲得尽量接地气。

1. 先想清楚一件事:Verilog到底是画电路还是讲故事

1.1 两种思维模型:结构视图与信号流视图

如果你打开一份图纸,看到的是芯片、电阻、电容、导线组成的网络——这是电路的“结构视图”。在这个视图里,你关心的是:哪些器件存在,引脚怎么连接,信号从哪个引脚出去、又进到哪个引脚。Verilog里的模块例化、门级原语、连线声明,干的就是这件事。

而“信号流动”是另一个维度。同一块电路板,如果你画一条信号从按键输入、经过消抖逻辑、进入寄存器、再被状态机处理的路径,你关心的就不是物理连接,而是信号随时间如何一步步传播和变化。前者像地铁路线图,后者像一列车沿轨道运行的时刻表。路线图告诉你站点在哪、怎么连;时刻表告诉你列车几点到、几点走、中间停了几站。Verilog的厉害之处就在于,它用一套语法同时表达这两种东西。

我刚学Verilog时犯过一个典型错误:用写C语言的顺序思维去写always块,觉得语句从上到下执行。后来才明白,Verilog的每条语句本质上都在描述一个“电路存在”——它不执行,它就在那儿。这种认知转换不过来,后面写啥都别扭。所以理解“描述电路”和“描述信号流动”这两个视角,是所有后续技术的基础。

1.2 可综合子集与仿真行为:工具眼里只有固定套路

再进一步,你要明白综合器是怎么看代码的。综合工具(如Design Compiler、Vivado的synth)只认一小撮固定语法模式,这些模式被叫作“可综合子集”。reg在always里用非阻塞赋值,综合成触发器;assign连续赋值,综合成组合逻辑门;wire只是连线。你写的循环和函数调用如果没被展开成固定硬件,综合器要么报错,要么直接忽略。

但仿真器是另一种逻辑:它执行的是语义,不是电路。#10;延时、initial块、$display打印,在仿真里有意义,在综合时根本没有对应硬件。这就是为什么“仿真通过但上板失败”的案例这么常见——你用了仿真独有的语法描述了一个综合器无法理解的行为。

记住一句话:写RTL时,你的每一行代码在动手之前,就应该在脑子里“看见”它综合出来的电路长什么样。看得见电路的代码,才是好代码;看不见电路的代码,早晚要还债。

2. 描述电路:用Verilog把结构画出来

2.1 模块与端口:电路的黑盒抽象

一个Verilog模块,本质上就是一个电路黑盒。外部世界只通过端口和它打交道,内部实现细节被封装起来。端口有三种方向:input(输入)、output(输出)、inout(双向),分别对应芯片的输入引脚、输出引脚和三态引脚。

举个例子,一个4位加法器的端口定义长这样:

module adder4( input [3:0] a, input [3:0] b, input cin, output [3:0] sum, output cout );

在这个黑盒外面,别人只关心给a、b、cin扔进去什么值,sum和cout会输出什么。这种封装和PCB上焊一个芯片没什么区别,都是把复杂逻辑包进一个壳子里,只暴露必要的引脚。

端口定义有两种风格,早期版本要写两遍端口(先列名字再列方向),现在ANSI风格一行搞定,推荐直接用ANSI风格。不过老代码里经常见到旧风格,你能看懂就行,不用刻意模仿。

2.2 结构化建模:门级原语与实例化

最“电路”的Verilog写法,是直接例化门级原语和子模块。门级原语就是and、or、not、xor这类基础门电路,用法像这样:

and g1(y, a, b); // y = a & b

这和原理图里画一个与门然后连两根线,没有任何区别。不过实际工程中直接写门级的场景很少,更多是用子模块例化来构造层次化结构。这里有个经典例子——用两个半加器组成全加器:

module half_adder( input a, b, output s, c ); assign s = a ^ b; assign c = a & b; endmodule module full_adder( input a, b, cin, output sum, cout ); wire s1, c1, c2; half_adder u1( .a(a), .b(b), .s(s1), .c(c1) ); half_adder u2( .a(s1), .b(cin), .s(sum), .c(c2) ); assign cout = c1 | c2; endmodule

这段代码里,u1和u2就是两个被实例化的半加器,wire s1、c1、c2就是模块之间的连线。这和你在面包板上插两个芯片然后用导线连起来,思维方式一模一样。模块例化的端口连接有两种方式:按顺序连(half_adder u1(a, b, s1, c1);)和按名字连(.a(a))。我强烈建议只用按名字连接,顺序连接一旦参数多了就是灾难,改一个端口顺序满屏找错。

层次化设计的价值在于:一个复杂的CPU,你可以拆成取指、译码、执行、访存、写回几个大模块,每个大模块再往下拆,最终拆到寄存器、加法器、多路选择器这些基本单元。这种设计方式和团队协作模式是强匹配的,每个人负责一个子模块,接口定清楚,互不干扰。

2.3 连续赋值assign:持续存在的电路连接

assign语句是另一种“画电路”的方式。它描述的是一个持续的电气连接关系——右侧表达式里的任何信号发生变化,左侧信号立即跟着更新。比如:

assign sum = a ^ b; assign carry = a & b;

这两条assign放在模块里,综合出来就是两个异或门和一个与门。它不是一个先算异或再算与的过程,而是两个并行的、一直存在的硬件逻辑。你可以把assign理解成“一根由逻辑门驱动的导线”,右侧是输入,左侧是输出,电流(信号)一直在流动。

在这里要注意一个问题:assign左边必须是wire类型,不能是reg。反过来说,如果你发现某个信号需要持续被驱动,用wire+assign;如果需要在一个过程块里按某种条件赋值,才用reg。很多新手在这里被绕晕,其实记住一句话就行:assign描述的是纯组合逻辑,always描述的是带时序或条件的过程行为。

3. 描述信号的流动:从电路到时间轴上的变化

3.1 时序逻辑与触发器:信号在节拍中前进

如果说assign描述的是信号在“空间”上的连接,那always @(posedge clk)描述的就是信号在“时间”上的流动。数字电路里,你不可能让每个信号都随意跳变,必须用时钟来对齐信号的变化节奏。每来一个时钟上升沿,信号就往前进一拍,就像流水线上每个工位的节奏都由同一个节拍器控制。

一个最简单的计数器:

reg [7:0] cnt; always @(posedge clk) begin if (rst) cnt <= 8'd0; else cnt <= cnt + 1'b1; end

每个时钟上升沿,cnt的值都会加一。从信号流动的角度看,这条语句描述的是:cnt这个寄存器的输出经过一个加法器,然后回到自己的输入,下一个时钟沿到来时,新值被锁进触发器。这就是信号在时间轴上的“步进”。

3.2 reg与wire的分工:谁来“保管”信号

reg和wire是Verilog里最容易混淆的概念。初学者喜欢按字面意思理解,觉得reg是寄存器,wire是导线。这个理解在行为仿真里基本凑合,在综合思维里会误导你。

准确说法是:wire是被驱动的信号,它本身不存储任何状态,由assign语句或某个模块的输出持续驱动;reg是过程块(always/initial)里被赋值的变量,它有“保持上一次赋值结果”的能力。但reg不一定是触发器——如果在组合逻辑always块里给reg赋值,它综合出来可能只是一根线,因为有赋值路径但没有存储需求。

看这个例子:

reg y; always @(*) begin if (sel) y = a; else y = b; end

这段代码综合出来就是一个二选一多路选择器,y是输出线,根本不是什么寄存器。真正的寄存器必须有时钟边沿触发,比如always @(posedge clk)。所以判断一个reg综合出来是寄存器还是线,不看类型名,看它是否在时钟边沿敏感列表里被赋值。

3.3 非阻塞赋值的真正含义:信号流动的“同步交接”

说起信号的流动,最关键的语法点就是<=(非阻塞赋值)和=(阻塞赋值)的选择。很多教程会列一堆规则,但理解底层原理之后,规则自然就记住了。

非阻塞赋值描述的是这样一个行为:在时钟上升沿到来的瞬间,右边表达式被采样,所有信号采样完成后,左边再同时更新。它模拟的是真实寄存器在时钟沿时刻的“统一交接班”。以移位寄存器为例:

always @(posedge clk) begin q0 <= d; q1 <= q0; q2 <= q1; end

在同一个时钟沿,q0拿到的是d的旧值,q1拿到的是q0的旧值,q2拿到的是q1的旧值。三个寄存器的数据同时向右移动一格,不会出现某个信号“提前穿透”的现象。这就是流水线思维的基础。

如果你把这段代码改成阻塞赋值=,情况就变成:先算q0 = d,再算q1 = q0(此时q0已经更新),再算q2 = q1(此时q1已经更新),结果变成d在三个寄存器里同时被复制,而不是移位。在仿真波形上,你会看到数据“穿透”了整条链路。更麻烦的是,综合结果可能是同一时钟沿数据直接流到所有寄存器,和你想要的移位链完全不一致。

所以核心规则很简单:描述时序逻辑的always块里,一律用非阻塞赋值;描述组合逻辑的always块或assign语句里,用阻塞赋值或直接用assign。这个规则我靠它避开了无数坑。

3.4 延时与仿真:在时间轴上观察信号

Verilog还有一套专门用于仿真的延时语法,就是#符号。#10;表示延时10个仿真时间单位。注意,这些延时只存在于仿真模型里,综合时会被忽略。设计RTL时,你会刻意不在可综合代码里写#,但在testbench里,#是制造激励的好帮手。

看一个最简单的testbench时钟生成:

initial clk = 0; always #5 clk = ~clk;

这段代码让clk每5个时间单位翻转一次,产生的就是周期为10的时钟信号。配合initial块里用#制造复位和激励,你就能在波形上看到信号沿着时间轴一步步流动。仿真器本质上就是按照仿真时间片的顺序,不断推进时钟、更新信号值,然后把每一步的信号状态记录下来。

这就是“描述信号流动”最直观的样子——波形图上,每个信号随时间的变化轨迹,就是它在电路里流动的过程。你可以用modelsim或者iverilog跑一下这个例子,把时钟、数据、输出都拉出来看,感受会很直观。

4. 从“描述”到“运行”:仿真、测试与综合

4.1 testbench的设计:给信号流动录制“电影”

设计RTL只是第一步,想要验证信号在电路里“流得对不对”,必须写testbench。testbench本质上是仿真环境,它例化你的DUT(被测模块),给它喂时钟和激励,然后观察输出是否符合预期。

一个标准的testbench结构大致是这样:

module tb_adder4(); reg [3:0] a, b; reg cin; wire [3:0] sum; wire cout; adder4 dut( .a(a), .b(b), .cin(cin), .sum(sum), .cout(cout) ); initial begin a = 0; b = 0; cin = 0; #10; a = 4'hA; b = 4'h5; cin = 1'b0; #10; a = 4'hF; b = 4'h1; cin = 1'b1; #10; $display("a=%h b=%h cin=%b sum=%h cout=%b", a, b, cin, sum, cout); #10; $finish; end endmodule

在testbench里,你可以随意使用initial、#、$display这些不可综合语法,因为testbench压根不参与综合。它的目的只有一个:在仿真器里把DUT的“信号流动电影”播放出来,然后检测关键节点的信号是否按照设计意图变化。

常见流程是用ICARUS Verilog或modelsim跑仿真,把波形dump成vcd文件,打开波形工具观察。整个流程很快,推荐新朋友从iverilog这种轻量级工具入手,等跑到多模块层次化验证时再换modelsim或Vivado自带仿真器。

4.2 把仿真数据存成文件:信号不只活在波形里

有时候你需要的不是肉眼看波形,而是把仿真结果导出成文件做自动化比对。比如你想验证一个CRC模块或一个滤波器的输出是否正确,手工在波形上一个个点数显然不现实。

Verilog提供了文件操作的系统任务,可以在仿真时将信号写入文本文件:

integer fp; initial begin fp = $fopen("output.txt", "w"); end always @(posedge clk) begin $fwrite(fp, "%h %h\n", din, dout); end

配合$fclose(fp);在仿真结束时关闭文件。你自己定格式,比如每次时钟沿写一行“输入 输出”。仿真结束后,拿到这个文本文件,用Python或者脚本和预期结果比对,整个验证流程就自动化起来了。这个方法在调试复杂的协议模块时特别管用。

4.3 波形分析实战:毛刺、亚稳态与竞争冒险

看波形也有一些门道。信号流动的“完美形态”是:每个时钟沿前,数据稳定。但如果组合逻辑的输入路径延时不一样,比如一个多路信号变化时,各路到达时间有先后,输出就会出现短暂的毛刺。

最经典的例子是级联加法器:低位进位和高位输入同时变化,进位需要一段时间才能传递到高位。这个时间里高位输出可能跳几次才稳定。毛刺在组合逻辑里是正常现象,关键是它不能落入后一级触发器的建立时间窗口内,否则就可能触发亚稳态。

还有一种情况是组合逻辑反馈,比如assign y = y ^ 1'b1;这种写法在仿真里可能振荡,波形会出现高频翻转。原因在于组合逻辑的输出影响输入,没有时钟来打断反馈环。真正的电路里这种自激振荡的环路往往意味着设计bug,要么加时序控制,要么换结构。

跨时钟域(CDC)是亚稳态的重灾区。两个不同频率或相位不定的时钟域之间传信号,稍不注意就采样到中间电平。工程上最常见的手段是打两拍同步器——把异步信号先用两个寄存器同步到本地时钟域。这里要强调,打两拍不是万能的,脉冲太窄的信号会被滤掉,电平信号才能安全通过;更复杂的跨时钟域场景得上FIFO或握手协议,核心思路仍然是“让信号在受控的节奏里流动”。

5. 常见问题与避坑指南

5.1 敏感列表不完整,仿真结果和综合结果对不上

这是一个我见过无数次的bug。一个组合逻辑always块,敏感列表只写了a边沿,但块里使用了b和c。仿真时b或c变化不会触发块体执行,输出不更新;但综合器会“自动补全”敏感列表,综合出真实电路。于是仿真波形看起来功能正确,上板却表现异常。

如果你现在还在用always @(a or b)这种写法来写组合逻辑,我建议立刻改成always @(*)。这个写法让工具自动推导敏感列表,从根源上消除漏写敏感项的问题。老代码里那种把每个信号列一遍的风格,是遗留习惯,不建议学习。

5.2 组合逻辑里漏写else,悄悄多出个锁存器

另一个高频坑是:在组合逻辑的always块里,if语句没有else分支,case没有default。仿真时如果条件不满足,信号保持上一次的值,会给你“好像没问题”的感觉。但综合器一看:哦,分支不完整,需要记忆电路。于是它给你推断出一个锁存器(latch)。

锁存器在FPGA里有专门的查找表资源可以实现,但它对时序收敛很不友好,容易引发毛刺和时序违规。设计组合逻辑时,最稳妥的习惯是给所有输出赋默认值,或者确保if-else和case分支完整。例如:

always @(*) begin y = 1'b0; // 默认值 if (enable) y = a; end

这样即使enable为0,y也有确定的赋值路径,不会产生意外latch。

5.3 可综合代码的“禁止清单”

还有一些语法,写起来很爽,但别指望它综合成电路。initial块只在仿真里执行,综合时工具直接忽略;#延时同样不会出现在综合结果里;fork/join这种并行块概念虽好,但综合器不认。还有循环,如果循环次数是变量而不是常量,综合器可能展开不出来。

所以写RTL前先给自己立个规矩:可综合模块里只准出现module、wire、reg、assign、always、if、case、for(常数循环)、function/task(可综合子集)这些结构。其余语法留给testbench。这个规矩能让你的RTL代码在仿真和综合之间保持一致,大幅减少“仿真过了上板挂”的情况。

5.4 模块例化端子写反、位宽不匹配

模块例化时最容易出的低级错误是端口位宽不匹配。比如你把4位的a接到一个2位输入端口的模块上,综合工具可能不报错,但功能完全错乱。新手排查半天找不到问题,最后发现就是某个端口少写了一位数。

我的习惯是:例化模块时用按名字连接,并且在每个端口连接处用注释标上位宽和类型,例如:

my_module u_inst( .clk (clk), // input 1bit .data_i (data), // input [7:0] .data_o (out) // output [3:0] );

别嫌啰嗦,代码是写给人看的,更是写给三个月后的自己看的。

6. 从Verilog到工程实践:几个经典场景中的信号流思考

6.1 SPI/UART等接口模块,本质是信号在协议规定的节奏里移动

接口类模块是练习“信号流动”思维的好场景。以UART接收为例,一条串行数据线上的电平随时间变化,接收端要做的就是:检测起始位、在每一位中心位置采样、把8位数逐步移入寄存器、拼成一字节。每一步都是信号在时钟节拍里的移动。

SPI也是同样的思路:主机在SCK边沿把MOSI上的数据移入从机,从机在另一个边沿把数据移到MISO上。设计SPI slave模块时,你要关心的就是“数据何时被采样”“输出何时被采走”这两个时间点。用always @(posedge SCK)和always @(negedge SCK)组合,就能精确控制信号流动的节奏。

从思路上说,写接口模块前一定要先画时序图,把每一拍信号的状态标清楚,再翻译成Verilog。没有时序图直接上手写代码,大概率一遍遍返工。

6.2 按键消抖:异步信号进入同步世界前的第一道关卡

按键消抖是个信号流动问题的典型样本。按键按下时,金属触点物理接触会带来几十毫秒的抖动,如果直接把这个信号接到时钟域逻辑里,同一个按键操作可能会被误识别成很多次。

常见消抖思路有两种。一种是用RC低通滤波加施密特触发器,这是模拟电路的玩法;另一种是纯数字方式:用计数器稳定采样。原理也很简单——检测到电平变化后,不立即确认,而是开始计数,只有连续N个时钟周期都采到相同的电平,才认为信号稳定了。这本质上是在时间轴上对信号做“滤波”,让抖动的信号在流动过程中被平滑掉。

用Verilog实现时,可以用一个寄存器保存上次采样值,另一个计数器统计“连续相同电平”的周期数。每次采样到不同电平就清零,计数到预设值就输出稳定后的信号。这个消抖模块在很多项目里都能当作通用IP复用。

6.3 状态机:信号在不同状态之间的流动路径

再往大了说,状态机就是一套“信号流动的路线图”:信号决定状态转移,状态决定输出,进而影响下一个状态。设计一个cache控制器、一个总线仲裁器,本质上都是在定义信号在不同状态间怎么流动。

写状态机时,我习惯用两个always块:一个纯时序块负责状态寄存器更新,一个组合逻辑块负责下一状态计算和输出。这种写法结构清晰,代码容易维护,也方便时序分析。网上看到很多人用单always块写状态机,功能上能跑,但代码复杂到一定程度后,可读性和可维护性会迅速恶化。

如果你要上手一个状态机项目,我的建议是先画状态转移图,标清每个状态的输入条件和输出,再动手写代码。图中信号流动的方向和条件,最终会变成Verilog里一行行的case和if。

写到这里,回头看最初的问题——Verilog是描述电路还是描述信号流动?我认为两者都算,这两者也是数字设计的两个基本视角。实际工程里,开一个模块的RTL代码,先看顶层例化的层次结构,这是电路视角;追一条控制信号的时序路径,从产生、传送到状态机判断,这是流动视角。能把这两套思维自如切换,读代码、调bug、写设计都会顺手得多。我自己每次遇到难缠的仿真不一致问题,都会先问自己一句:我到底是在画电路,还是在讲故事?想明白了,问题也就解决了大半。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/6 11:41:30

实时读取配置最新值

先说结论 可以改成 static&#xff0c;但是&#xff01;你现在这种写法有一个隐藏致命大坑&#xff0c;就算加上 static 也会出BUG。 先看你当前代码的问题 public class PipeTopologyConfiguration { public double Tolerance PipeTopologyConfig.Instance.Toleranc…

作者头像 李华
网站建设 2026/9/6 11:40:08

手把手学Linux设备驱动开发:从字符设备到中断与设备树实战

1. 这本书解决的是“从入门到放弃”的老大难问题做Linux驱动开发的人&#xff0c;很多都经历过这样一段窘境&#xff1a;大学里学完C语言、操作系统原理&#xff0c;感觉自己懂了进程调度、懂了文件系统&#xff0c;可一旦面对内核源码&#xff0c;面对Kconfig、Makefile、devi…

作者头像 李华
网站建设 2026/9/6 11:34:39

同型号数码管能直接替换吗?引脚定义不兼容的坑与读 pinout 方法

【核心结论】同封装、同型号的数码管&#xff0c;不同厂家的引脚定义&#xff08;pinout&#xff09;经常对不上。共阴共阳、段序 a 到 g、小数点 dp 位置、位选脚排布都可能反着来&#xff0c;拿 A 厂的板子直接焊 B 厂的管&#xff0c;十次翻车八次。替换前必须拿规格书逐脚核…

作者头像 李华
网站建设 2026/9/6 11:28:55

双月让叶AI图像生成项目:本地部署与Stable Diffusion实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 11:25:18

JMeter 4000并发压测实战:从脚本设计到性能瓶颈定位全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 11:22:14

灵视P1空间相机:从真实场景到游戏引擎的高精度三维重建实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华