
在IC设计圈待久了你会发现真正把Verilog用明白的人很少把它当成“编程语言”来用。他们心里装的不是“语句”而是“电路”。链接触发器的D端和Q端之间的逻辑关系、数据在组合逻辑和时序逻辑之间的流动节奏这些才是硬功夫。今天我想认真聊聊“Verilog描述电路”和“Verilog描述信号的流动”这两个说法希望能帮你把脑子里关于Verilog的零散认知串成一条完整的线。不管你是刚学Verilog的入门者还是写了一段时间但总觉得哪里没想透的开发者这篇文章都值得慢慢看。1. 整体设计思路Verilog的一体两面很多新手拿着Verilog代码第一反应是“这不就是C语言吗”。有if、有case、有for还能做加法看起来像程序于是照着软件思维去写。但综合器不会像编译器那样把代码变成机器指令而是会把它映射成查找表、触发器、多路选择器和连线。这个认知差是很多设计出问题的根源。1.1 描述电路从结构视角到数据流视角“描述电路”这四个字听起来很简单但在Verilog里有两种完全不同的视角。一种是结构视角。就像画原理图一样把已经设计好的模块一个个例化出来把它们的端口连起来用什么线连接、哪些引脚短接、哪些信号共用全都明明白白。这种写法在FPGA工程里非常常见顶层文件绝大多数都是这种“搭积木”的方式。你例化一个FIFO例化一个SPI Slave例化一个DDR3 Controller然后把它们之间的数据总线、控制信号、时钟复位信号连好一个系统就这么搭出来了。这个视角下你的工作重点是“电路拓扑结构”和“模块间接口协议”。另一种是数据流视角。这种视角更抽象一点你不需要去关心内部到底是哪些门电路在干活只需要用连续的赋值语句描述“数据从哪里来到哪里去、中间经过什么运算”。比如assign sum a b;这条语句描述的是a和b经过一个加法器得到sum至于这个加法器是用行波进位还是超前进位实现的完全不重要。综合工具会根据约束帮你选。“描述电路”这件事本质上是建立信号之间的连接关系和运算关系这一层想通了你写出来的代码就已经脱离了软件思维。1.2 描述信号的流动从组合时序到状态转移“描述信号的流动”这句话比“描述电路”更深一层。电路是静态的信号是动态的。时钟每跳一下数据就往触发器的输出端推进一级组合逻辑的输入端一旦变化经过一段传播延迟输出端也会跟着变化。你要做的是用Verilog把这些“随时间变化的信号状态”精确地刻画出来。在组合逻辑中信号的流动是无节拍的。输入一变输出就跟着变就像水管里的水你只要打开阀门水立刻开始流动。在时序逻辑中信号的流动是有节拍的。所有信号只能在时钟上升沿或下降沿到来的一瞬间被采样、被更新就像一列火车每一站都严格按照时刻表停靠。一个老练的工程师在做一个设计时脑子里会有两条线并行思考一条是“当前这个模块需要哪些组合逻辑来处理数据”另一条是“这些数据在哪些时钟域里被采样经过多少拍才能到达下一个模块”。他写代码的过程实际是在同时描述“电路长什么样”和“信号怎么流动”。这两个维度缺一不可只看电路不看时序设计会因竞争冒险而翻车只看时序不看电路代码会写出不可综合的仿真风格。1.3 为什么要把这两个维度放在一起理解我自己带过不少新人发现一个规律凡是能把“描述电路”和“描述信号的流动”分开理解又合起来用的人写代码的速度和调试效率都会高出不少。原因很简单。当你拿到一个项目需求比如“实现一个SPI从机接口”如果你只从“电路”角度看你能想到的是SCLK、MOSI、MISO、CS这些引脚想到的是移位寄存器、计数器、状态机这些模块。但如果你同时从“信号的流动”角度看你会继续追问SCLK的上升沿采样数据时数据线上电平和SCLK的相位关系是怎样的数据是在空闲态就开始准备还是等CS拉低之后才开始转移接收完一个字节之后信号要经过几拍才能被CPU侧读走这些问题本质上都围绕“信号如何按照时间顺序从一个模块流向另一个模块”。把这两个维度当成一个整体来训练你对Verilog的理解会有一个质的飞跃。很多面试中常考的“阻塞赋值与非阻塞赋值”“毛刺如何产生”“跨时钟域为什么要打两拍”等问题其实都能从这个整体视角找到根因。讨论八股也好华为数字IC面试也好很多考点最终都在考察候选人是否建立了这种双重视角。2. 描述电路结构、连续赋值与模块例化先讲“描述电路”这一面。这是Verilog最基础的东西但越是基础越容易被忽略。2.1 连续赋值assign与组合逻辑网表assign是最能体现“描述电路”特点的语句。它描述的是一种持续有效的连接关系只要右侧表达式的值发生变化左侧的信号就会在短暂延迟后被更新。这种关系不受时钟控制是一种纯粹的硬件行为。举个例子你写assign cin_ripple data_a data_b | data_c;这条语句本质上在描述一个由与门和或门组成的组合逻辑网络。综合工具会把它映射成查找表或门级网表。你不需要关心内部的延迟是1纳秒还是3纳秒你只需要告诉工具“我有这么个逻辑需求你帮我实现”。这种思维方式和写软件有本质区别——软件是“按顺序执行语句”硬件是“所有assign语句同时并行求值”。这一点非常关键写Verilog的时候每一行assign之间是没有先后顺序的它们描述的是电路里的“线”和“门”而不是“步骤”。在数字IC面试里经常有人被问“assign和always有什么区别”“阻塞赋值和非阻塞赋值有什么区别”其实这些问题的底层都在考察你是否理解“assign描述的是组合逻辑网络”而“alway时序块描述的是触发器在不同时刻的采样和更新行为”。前者是电路的静态结构后者是信号的动态流动两者互为补充。2.2 模块例化像焊电路板一样连接端口“模块例化”这个词听起来专业其实本质就是“把芯片焊到电路板上然后接线”。你设计好一个模块就像做好一颗芯片模块的input端口就是芯片的引脚output端口就是要输出的引脚。在顶层模块里你需要做的事情只有两件选择需要的芯片把它们的引脚用导线连起来。看一个典型例化edge_detector u_det ( .clk (clk), .rst_n(rst_n), .d (din), .trig (trig) );这段代码告诉综合器我需要一个叫edge_detector的模块实例把它命名为u_det然后把顶层信号clk接到这个模块的clk引脚上把din接到d上把trig接到它的输出trig上。这就是在描述电路拓扑非常直观。模块例化的方式有两种一种是u_module ( .port(signal) )叫显式连接名字对名字清晰不易出错另一种是u_module ( signal1, signal2, ... )叫隐式连接按端口列表的顺序对应信号写起来快但极易搞错顺序。我强烈建议你从头到尾都用显式连接哪怕多敲几个字符也远比后来花两小时追一个“信号接错引脚”的bug划算。还有一个新手容易忽略的细节模块例化中不允许把同一个信号同时接到两个输入端口上会造成多驱动冲突在仿真里会出现X态在综合时会报多驱动错误。很多奇怪的“这个信号为什么不是0而是X”的问题源头都在这里。2.3 从端口类型理解信号方向描述电路时端口声明不仅仅是语法它直接决定了信号的驱动方向。input是由外部驱动的信号output是由模块内部驱动的信号inout是双向总线比如I2C的SDA、SPI在某些定义下的MISO。每当你写一个模块先想清楚端口方向就等于先画好了信号流图。数据从哪里进来经过哪些处理和变换最后从哪里出去这个“信号流图”就是你模块的骨架。很多初学者会犯一个错误在顶层模块里把内部计数器的值直接定义成reg然后在always块里用阻塞赋值更新结果综合出来一个奇怪的东西。这背后其实就是因为他对“这个信号到底是模块内部的状态变量还是要对外输出的端口”没有清晰判断。我的经验是端口方向先花两分钟列清楚再开始写always块和assign写起来会顺畅得多。3. 描述信号的流动时钟域与时序行为如果说“描述电路”是画出一张静态的交通地图那么“描述信号的流动”就是记录每一辆车在不同时刻经过哪些路口。没有时钟数字电路就像没有红绿灯的城市信号毫无秩序地到处乱撞。有了时钟所有流动才有了节奏。3.1 时钟上升沿信号流动的节拍器在同步时序设计中所有状态寄存器的更新都发生在时钟上升沿瞬间。也就是说always (posedge clk)这个块里的内容描述了触发器组的采样行为在每个时钟上升沿输入端的数据被“锁存”到输出端。这个“锁存”的动作非常微妙。在真实电路里它需要满足建立时间和保持时间。建立时间要求数据在时钟沿到来之前稳定一段最短时间保持时间要求数据在时钟沿到来之后仍然保持一段最短时间。如果违反触发器输出就会进入亚稳态导致后续状态不可预测。很多FPGA工程师会在跨时钟域信号上打两拍就是为了用两级触发器把亚稳态的概率降到可以接受的范围。在Verilog里你不需要显式地声明“这是触发器”只需要在always (posedge clk)里给某个reg类型变量赋值综合工具就知道这里应该摆一个触发器。比如always (posedge clk or negedge rst_n) begin if (!rst_n) dff_out 1b0; else dff_out dff_in; end这就是一个最基本的D触发器模型。信号dff_in在每个时钟上升沿被采样然后出现在dff_out上。信号的流动节奏完全由时钟决定这就是“描述信号的流动”最直白的样子。3.2 计数器与状态机信号流动的控制骨架很多实际模块比如UART、SPI、I2C、DDR控制器本质上都可以用“计数器 状态机 数据通路”三个部分来拆解。计数器负责记录时间节拍状态机负责控制“当前这个节拍该干什么”数据通路负责实际搬运和处理数据。三者协同就构成了信号流动的完整框架。以计数器为例reg [7:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 8h00; else if (cnt 8hFF) cnt 8h00; else cnt cnt 1b1; end这段代码描述了复位时计数器清零每个时钟脉冲到来时计数器加一加到255后归零。信号cnt的值在每个时钟周期不断流动变化形成一个新的时间基准。你可以基于这个cnt去产生使能信号、产生地址、产生读写信号所有后续的信号的“流动节奏”都由这个基础计数器驱动。再进一步状态机是用一个寄存器记录“当前处于哪个状态”然后根据输入信号和当前状态决定“下一步跳转到哪个状态”并给出相应的输出。这就是一种更高级的信号流动控制方式。比如SPI从机接收数据时状态从IDLE跳到LOAD在LOAD状态移位寄存数据再跳到DONE把数据交付给上层。整个过程信号像流水线一样一站一站向前推进。3.3 阻塞赋值与非阻塞赋值背后的“流动”逻辑这可能是新手最头疼的地方也是面试中几乎必考的知识点。很多人死记硬背“时序逻辑用非阻塞、组合逻辑用阻塞”但始终不理解为什么。这里从“信号流动”的角度说透了。非阻塞赋值模拟的是触发器的行为。在always块中所有右侧的值都是在时钟沿到来瞬间被采样并暂存的等到这个always块结束之后这些暂存值才统一赋给左侧变量。所以多个之间互不影响它们描述的是“同一时刻并行采样随后并行更新”。阻塞赋值模拟的是组合逻辑或锁存器的行为。它一旦执行立刻生效右边的值马上被赋给左边后面的语句能立刻看到更新后的值。多条阻塞赋值之间存在先后顺序就像软件语句一样按顺序执行。为什么时序逻辑必须用非阻塞因为如果你在多个always块里各自用阻塞赋值更新同一个时钟驱动的寄存器仿真时的执行顺序会严重影响结果可能第一次仿真正确换个工具或者换一种顺序就完全不对。非阻塞赋值保证了同一时间内所有寄存器看到的是旧值又同时更新到新值精确模拟了真实硬件中触发器集群的行为。这个点真的值得你在写每一行时序逻辑时反复咀嚼。理解了它你就能理解为什么testbench里经常要写#10的延时也能理解为什么状态机的次态逻辑用阻塞赋值、状态寄存器用非阻塞赋值会有那么多坑。4. 实操演示从电路结构到信号流动的完整例子理论知识讲了一堆最终还是要落到代码上。我用一个比较经典的场景——上升沿检测加事件计数从顶层到底层完整过一遍展示“描述电路”和“描述信号的流动”是怎么协同工作的。4.1 边沿检测模块用组合逻辑抓取瞬间变化在很多应用中你需要对一个外部信号跳变沿产生一个单时钟周期宽度的脉冲。比如检测一个按键按下上升沿或者检测一个传感器的脉冲输出。最稳妥的方法是先用两级寄存器做同步与延迟再用组合逻辑比较延迟前后的电平。module edge_detector ( input wire clk, input wire rst_n, input wire d, output wire rise_trig, output wire fall_trig ); reg d1; reg d2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin d1 1b0; d2 1b0; end else begin d1 d; d2 d1; end end assign rise_trig d1 ~d2; assign fall_trig ~d1 d2; endmodule这段代码里always块描述了信号在时钟节拍下逐级流动的过程外部信号d在每个时钟沿被采样到d1下一拍再从d1流到d2。assign语句则描述了纯组合逻辑当前一拍数据为高而上一拍数据为低时说明信号刚发生上升沿于是rise_trig输出一个高电平脉冲。这就是“描述信号的流动”和“描述电路”在同一个模块里的完美结合。需要注意的是外部输入信号直接接入触发器时如果这个信号和时钟无关异步输入第一拍可能产生亚稳态。这里d1可以理解为同步用的第一级实际工程中最好再加一级总共打三拍之后再用于逻辑检测稳妥至上。4.2 脉冲计数模块用计数器记录流动的次数接下来做一个简单的事件计数器每当rise_trig出现一个脉冲计数器的值加一。这个模块描述的是“脉冲信号流到计数器推动计数器状态更新”的流动过程。module pulse_counter ( input wire clk, input wire rst_n, input wire trig, output reg [7:0] cnt, output reg overflow ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 8d0; overflow 1b0; end else if (trig) begin if (cnt 8d255) begin cnt 8d0; overflow 1b1; end else begin cnt cnt 1b1; overflow 1b0; end end else begin overflow 1b0; end end endmodule在每一个时钟上升沿如果trig为高计数器就加一。cnt的值代表了“信号流动”过程中捕获到的事件的累计次数。这里的所有赋值都是非阻塞赋值保证了cnt的更新是“同时且有序”的不会出现竞争。这个模块就是典型的时序电路数据的流动被时钟精确切分成了细小的时钟周期。4.3 顶层例化把电路结构拼装起来现在把上面的两个模块例化到顶层里用连线把它们的端口连接起来。这个过程就是“描述电路”的舞台。module top ( input wire clk, input wire rst_n, input wire din, output wire [7:0] pin_cnt, output wire pin_overflow ); wire pulse_rise; edge_detector u_det ( .clk (clk), .rst_n (rst_n), .d (din), .rise_trig(pulse_rise), .fall_trig(open) // 不关心的输出悬空处理 ); pulse_counter u_cnt ( .clk (clk), .rst_n (rst_n), .trig (pulse_rise), .cnt (pin_cnt), .overflow(pin_overflow) ); endmodule看到这里你应该能明显感觉到顶层代码和底层代码的关注点完全不同。底层模块在乎的是电路内部的状态如何随时间和输入变化顶层模块在乎的是模块之间怎么连线、信号的流向是什么。所以“Verilog描述电路”和“Verilog描述信号的流动”从来不是互斥的而是不同层次设计时切换的不同视角。4.4 Testbench与仿真让波形替你看懂流动写testbench的目的是模拟外部环境推动输入信号按时间顺序变化然后观察输出波形从而验证设计是否符合预期。这个过程本身就是“描述信号的流动”——只不过这次是在验证环境里让信号按照你设定的时间规则流动起来。timescale 1ns/1ps module tb_top; reg clk; reg rst_n; reg din; wire [7:0] pin_cnt; wire pin_overflow; initial begin clk 1b0; forever #5 clk ~clk; end initial begin rst_n 1b0; din 1b0; repeat (3) (posedge clk); rst_n 1b1; // 制造两次上升沿 #30; din 1b1; #40; din 1b0; #40; din 1b1; #40; din 1b0; // 等待计数器稳定输出 #200; $finish; end top u_top ( .clk (clk), .rst_n (rst_n), .din (din), .pin_cnt (pin_cnt), .pin_overflow(pin_overflow) ); endmodule在Modelsim、Vivado和VCS这些工具里跑完仿真后打开波形窗口你能非常直观地看到din的电平变化、rise_trig的单脉冲、以及pin_cnt的递增。这种波形就是“信号流动”的可视化呈现。我见过不少初学者代码写完了不仿真直接上板然后被神秘现象折磨两三天。这里分享一个实测有效的习惯任何模块先花10分钟写好testbench把波形跑出来确认时序符合预期再继续往下面做。这10分钟能给你省下的是几小时的板级调试时间。5. 常见问题与排查技巧实录写Verilog做得越久踩过的坑越多。有些坑属于语法层面查一下手册就能解决有些坑则属于认知层面不突破那个认知你会在同一个地方反复摔倒。整理几个出现频率最高的问题和自己的排查思路供你参考。5.1 仿真波形不对先检查阻塞/非阻塞赋值是否混用信号波形出现“错误”的原因千奇百怪但其中最高频的原因依然是阻塞赋值和非阻塞赋值的混用。经典错误场景是你让一个组合逻辑always块用阻塞赋值更新变量又让另一个时序always块用非阻塞赋值更新同一个变量仿真的时候两个块谁先执行完全取决于工具调度结果必然乱套。我的排查顺序是先看报错信息有没有多驱动再看代码里每个被赋值的变量到底在几个always块里出现最后把时序逻辑统一改成组合逻辑单独拆成assign或纯组合always块。一版改下来90%的波形异常都能解决。5.2 异步复位和同步复位的选择问题复位信号是数字电路里非常重要但经常被忽略的细节。异步复位写法是always (posedge clk or negedge rst_n)同步复位则只用always (posedge clk)在块内判断rst_n。两种方式各有适用场景。异步复位的优势是复位响应快不需要等时钟沿适合上电后需要立刻进到已知状态的场景。但它的风险在于如果复位释放时刻离时钟沿太近容易造成亚稳态行业里常见做法是加一个异步复位同步释放电路。同步复位则天然避免了这个时序窗口问题但缺点是复位信号必须在时钟沿到来前保持稳定足够时间否则触发不了复位。设计时我会先确认IP手册里的要求再统一决定复位策略最怕的是不同模块一个异步一个同步最后系统复位时序顾此失彼。5.3 典型错误与对应解决办法用表格整理几个常见问题方便你快速对照排查。症状可能原因排查方法解决办法波形中出现大量X态多驱动冲突或变量未被初始化找变量在几个模块中被驱动检查复位逻辑覆盖是否完整检查例化连接只在单一always块中驱动变量确保复位时变量被赋值时序输出整体比预期慢一拍两级寄存器造成流水延迟未考虑打拍查看波形中握手信号和数据信号的对应关系理清协议时序在数据有效判断处补偿一拍或重新设计流水结构组合逻辑环路报警assign或组合always中存在反馈回路追踪信号依赖关系图检查是否将输出误接回输入拆为时序状态更新组合逻辑避免产生锁存器必要时插入寄存器打断环路仿真正常但上板结果不对未做跨时钟域处理或输入信号未同步确认两个时钟域之间信号是否经过同步器对异步输入至少打两拍同步必要时做异步FIFO或握手处理5.4 使用远程开发环境的额外提醒现在有越来越多团队使用云端开发环境或远程FPGA实验平台这对于调试流程提出了新要求。很多人喜欢在本地坐等仿真波形但当你面对的是远程FPGA时真正的优势不在于仿真而在于把仿真模型做扎实然后直接烧录验证。远程开发时建议你提前把testbench写好把自检的打印信息比如计数器变化、状态机跳转都放到仿真脚本里一键跑完先看输出再决定是否上板。这样能大幅缩短远程连接和任务排队的时间。另外iverilog加gtkwave这类轻量级开源工具在远程环境里非常好用没有大型IDE的臃肿适合快速验证一个模块。和大型EDA工具的仿真结果有差异时优先怀疑SDF时序标注有没有被正确加载这个问题我遇到过多次。6. 实操心得把这两个维度变成习惯说句实在话我自己刚学Verilog的时候也犯过很多类似的错。总希望找到一个“万能模板”套上去就能写出正确的模块。后来项目做多了才慢慢明白真正起决定性作用的是你在开始写代码之前脑子里是否已经能够画出这个模块如何工作的画面。每次开始写一个新模块我会先问自己三个问题第一这个模块的输入信号从哪里来输出信号往哪里去属于哪个时钟域第二模块内部有哪些状态需要被保存哪些信号是纯组合逻辑就能算出来的第三信号从输入到输出需要经过几拍这期间有没有可能出现半字节、控制信号的竞争带着这三个问题去画时序图和结构图再动手写代码效率会高很多。这个习惯也适用于全芯片集成阶段。你例化各个IP时会自然而然地思考“这个时钟域的信号什么时候被拉高什么时候被释放”“这个数据和另一个数据的到达时间能否对齐”这不就是“描述电路”和“描述信号的流动”在系统层面的统一吗最后再分享一个小技巧。调试时序问题的时候不要一开始就看代码先看波形。波形上每一步信号的跳变都是真实的电路行为对照着你脑子里的预期流程很快就能定位到是第几级流水出了问题。如果波形和自己的设计预期对不上就用反推法从出错的信号往后追找到它是由哪个信号触发的哪一条always块在驱使它的变化。这个习惯帮我解决过无数个看似玄学的bug。Verilog的学习曲线确实不缓但当你把“描述电路”和“描述信号的流动”这两条主线搭起来你会发现之前觉得零散的语法、面试题、经验技巧全部都能挂到这两根主线上变得井然有序。多写多仿多上板时间会给你答案。