尧图精选

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

🕒 发布时间:2026/9/6 11:44:16 📁 来源:尧图网络
做了这么久数字逻辑设计我经常碰到刚入门的朋友问一个问题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。反过来说如果你发现某个信号需要持续被驱动用wireassign如果需要在一个过程块里按某种条件赋值才用reg。很多新手在这里被绕晕其实记住一句话就行assign描述的是纯组合逻辑always描述的是带时序或条件的过程行为。3. 描述信号的流动从电路到时间轴上的变化3.1 时序逻辑与触发器信号在节拍中前进如果说assign描述的是信号在“空间”上的连接那always (posedge clk)描述的就是信号在“时间”上的流动。数字电路里你不可能让每个信号都随意跳变必须用时钟来对齐信号的变化节奏。每来一个时钟上升沿信号就往前进一拍就像流水线上每个工位的节奏都由同一个节拍器控制。一个最简单的计数器reg [7:0] cnt; always (posedge clk) begin if (rst) cnt 8d0; else cnt cnt 1b1; 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 4hA; b 4h5; cin 1b0; #10; a 4hF; b 4h1; cin 1b1; #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 ^ 1b1;这种写法在仿真里可能振荡波形会出现高频翻转。原因在于组合逻辑的输出影响输入没有时钟来打断反馈环。真正的电路里这种自激振荡的环路往往意味着设计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 1b0; // 默认值 if (enable) y a; end这样即使enable为0y也有确定的赋值路径不会产生意外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、写设计都会顺手得多。我自己每次遇到难缠的仿真不一致问题都会先问自己一句我到底是在画电路还是在讲故事想明白了问题也就解决了大半。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →