
很多人第一次碰 Verilog都会问同一个问题这到底是在写程序还是在画电路我做了几年 FPGA 开发带过不少新人发现一个规律凡是能想明白“Verilog 描述的是电路描述的是信号的流动而不是像 C 语言那样一步一步执行的算法”的人上手速度都远超同龄人。这篇文章就围绕“Verilog 描述电路”和“Verilog 描述信号的流动”这两条主线展开把模块、实例化、assign、always、时序逻辑、状态机、仿真验证这些核心知识点串起来讲。无论你是刚看完数电课本准备入门的在校生还是已经写了几个模块想系统梳理一遍的工程师都能对照着查漏补缺。1. 先想清楚Verilog 到底在“写”什么1.1 硬件描述语言和软件语言的根本差异很多刚开始学 Verilog 的人会下意识地把一段代码当作“程序”来读比如看到if、case就觉得这是顺序执行的分支语句看到always就以为是个死循环。这是最大的认知误区。C 语言、Python 这类软件语言描述的是“步骤”先做什么、再做什么、最后输出什么CPU 会按顺序一条条执行。而 Verilog以及 VHDL属于硬件描述语言HDL它描述的是电路本身哪个器件放在哪里、引脚之间怎么连线、信号在什么条件下从 A 点流到 B 点。我经常用一个类比C 语言是菜谱按顺序交代操作步骤Verilog 是施工图标明元件的型号、位置和连线。电路上电之后所有模块是同时工作的不存在“先执行这个模块、再执行那个模块”的概念。这也解释了为什么 Verilog 里没有main()函数——因为硬件没有唯一的入口每个模块都是入口。1.2 两条主线电路结构描述与信号流动描述把标题拆开看“Verilog 描述电路”和“Verilog 描述信号的流动”其实是两个维度描述电路是静态维度。用 module 定义一个电路单元的边界端口用实例化把多个电路单元拼接成系统用线网wire把引脚之间的连接关系画出来。这相当于画原理图。描述信号的流动是动态维度。回答“信号什么时候从哪到哪”组合逻辑里信号持续不断地从输入流向输出时序逻辑里信号在时钟边沿被捕获、被锁存再流向下一级。这相当于仿真时看到的波形也是硬件真正的工作方式。一个能落地的 Verilog 设计必然是这两条线的结合。模块端口定义了电路边界内部实现描述了信号在这个边界内怎么流动。写代码之前如果能在纸上把这两条线画清楚后面基本不会出大问题。2. 描述电路结构从 module 到实例化再到数据流2.1 module一个模块就是一个电路单元Verilog 的最小设计单元是 module它对应真实电路里的一个“芯片”或者一个“功能块”。端口列表就是芯片的引脚定义标明哪些是输入、哪些是输出、哪些是双向。module and_gate ( input wire a, input wire b, output wire y ); assign y a b; endmodule这个两输入与门虽然简单但已经包含了 module 的全部要素模块名、端口方向、端口类型、内部逻辑。input、output声明了信号方向wire表示这是线网类型适合描述组合逻辑的输出。新手最容易犯的错是端口方向搞反或者漏掉某个信号这在综合时会被工具报成端口不匹配比仿真报错更难查。2.2 实例化用层级把电路“拼”出来写一个复杂系统不可能把所有逻辑都塞进一个 module正确的做法是拆分成多个小模块然后在顶层模块里“实例化”它们。实例化就相当于在 PCB 上摆放芯片、焊接连线。module top ( input wire clk, input wire rst_n, input wire a, input wire b, output wire result ); wire mid; and_gate u_and1 ( .a(a), .b(b), .y(mid) ); not_gate u_not1 ( .in(mid), .out(result) ); endmodule实例化时我强烈建议用名字映射.a(a)这种写法而不是位置映射。位置映射一旦在修改端口顺序时没同步更新信号就连错了而且综合工具不一定报错。名字映射虽然代码长一点但可读性和可维护性都好得多。一个真实项目里顶层模块往往就是这样一层层“拼”出来的树形层级越清晰后期调试越省心。2.3 三种描述层次门级、数据流级、行为级Verilog 描述电路有三种抽象层次很多人学的时候没留意但对理解“描述电路”这个主题很关键描述层次典型写法抽象程度使用场景门级and u1(y, a, b);最低逐个例化门电路教材、特殊工艺库数据流级assign y a b;中等用表达式描述信号关系组合逻辑主体行为级always (*) ...最高用语句描述行为时序逻辑、复杂组合逻辑实际工程中门级基本不手写那是综合工具做的事大部分组合逻辑用数据流级时序逻辑用行为级。assign和always看似是两种语法实际对应的是两种电路描述哲学。assign描述的是“接线”右边表达式任何输入变化输出立刻跟着变这正是连续赋值的含义——它模拟了真实电路中信号持续流动的效果。我把这条规律记成组合逻辑用 assign 或 always(*)时序逻辑用 always(posedge clk)两者不要混用。3. 描述信号流动always 块、时序逻辑与赋值的秘密3.1 组合逻辑与时序逻辑信号流动的两种节奏如果只用一个概念解释“信号流动”那就是组合逻辑和时序逻辑的区别。组合逻辑像水管输入一变、输出立刻变实际有纳秒级门延迟但宏观上可以认为是即时的时序逻辑像水闸只有时钟边沿到来那一刻闸门才打开信号才能从输入流向输出。// 组合逻辑输入变化输出立刻跟随 always (*) begin y a b; end// 时序逻辑时钟上升沿才采样 always (posedge clk) begin q d; end理解这个区别之后很多问题就顺了为什么仿真时组合逻辑的波形会“毛刺”因为有竞争冒险。为什么时序逻辑的输出不会立刻变因为信号被锁存器/触发器锁住了只有边沿才更新。设计时我们要刻意把信号流动“节奏化”用寄存器把长的组合逻辑链切短这样每个时钟周期只走一小段路时序收敛才容易。3.2 边沿触发的 always让信号跟着时钟走时序逻辑是 FPGA 设计的核心而它的灵魂就是边沿触发的always块。下面是一个最典型的带异步复位的 D 触发器always (posedge clk or negedge rst_n) begin if (!rst_n) q 1b0; else q d; end这里的敏感列表写了posedge clk和negedge rst_n意思是时钟上升沿到来或者复位信号下降沿到来时才执行块内语句。复位优先级最高所以放在if的最前面。很多人刚开始会纠结“为什么复位要写 negedge”、“为什么不能在 else 里再嵌套一堆条件”其实都是同一个问题你写出来的每一个always块最终都会被综合成一块真实的数字电路。复位信号接到触发器的异步复位端时钟接到时钟端D 端接数据Q 端接输出——代码里的每个分支都要能映射到电路结构上写不出来对应电路的代码就是坏代码。3.3 阻塞赋值与非阻塞赋值新手最容易踩的坑这是 Verilog 里被问得最多、也最经典的问题。我的经验可以浓缩成两条铁律组合逻辑的always(*)用阻塞赋值时序逻辑的always(posedge clk)用非阻塞赋值为什么这么规定看一个经典的交换例子。假设每个时钟周期要把a和b的值互换// 非阻塞赋值正确实现交换 always (posedge clk) begin a b; b a; end// 阻塞赋值综合结果是 a 和 b 都变成原来的 b always (posedge clk) begin a b; b a; end非阻塞赋值的语义是先计算右边的所有值再统一在时钟边沿更新左边的变量。这正好对应真实触发器阵列的行为——同一个时钟边沿所有触发器同时采样输入、同时更新输出。而阻塞赋值是“算一个、赋一个”在仿真里会有执行顺序和真实硬件行为不一致。我见过不少项目功能仿真全对上板就乱最后定位到就是赋值方式没注意。记住这条铁律能帮你避开 Verilog 最大的坑。4. 实战拆解计数器、状态机与通信接口的信号流动4.1 计数器理解信号流动的第一个里程碑计数器是使用频率最高的时序电路没有之一。它不仅用于计数还是分频、定时、产生使能脉冲的基础。核心就是一个寄存器加反馈加法器module counter #( parameter WIDTH 4 ) ( input wire clk, input wire rst_n, output reg [WIDTH-1:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {WIDTH{1b0}}; else cnt cnt 1b1; end endmodule这段代码描述了这样一条信号流动回路cnt 寄存器输出 → 加 1 → 反馈回 D 端 → 下一个时钟上升沿 → 新值写入寄存器 → 再输出。这就是时序逻辑的本质信号在寄存器里流动一圈一圈地转。从计数器出发可以延伸出很多实用设计用计数器的溢出位做分频对应“二分频”“任意分频”这类需求、用计数值比较产生周期性的使能脉冲、用计数器做按键消抖的时间基准。关于按键消抖多说一句很多人一开始用延时循环的思路写消抖那是软件思维。硬件里正确的做法是用计数器对按键电平连续采样比如要求电平稳定 20ms 才认为有效。这就是“用时间换可靠性”的信号流动设计。4.2 状态机信号流动的调度中心计数器解决“什么时候做什么”状态机解决“当前处于什么阶段、下一步去哪里”。信号在状态机里的流动路径非常清晰当前状态 → 组合逻辑判断输入 → 生成下一状态 → 时钟边沿锁存 → 状态更新。推荐大家使用“两段式”写法一个时序always负责状态寄存器的更新一个组合always负责下一状态和输出逻辑的计算。localparam IDLE 2d0; localparam SEND 2d1; localparam WAIT 2d2; reg [1:0] state; reg [1:0] next_state; // 组合逻辑计算下一状态 always (*) begin next_state state; case (state) IDLE: next_state start ? SEND : IDLE; SEND: next_state WAIT; WAIT: next_state done ? IDLE : WAIT; default: next_state IDLE; endcase end // 时序逻辑状态更新 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end组合块里我习惯先给next_state state;兜底再写转移条件这样避免产生不必要的锁存器。状态机的核心价值是把复杂时序拆成清晰的状态比如 SPI 的读写时序、UART 的收发时序、I2C 访问 EEPROM 的时序本质都是一张状态转移表。画好状态转移图再编码比直接写代码快得多。4.3 通信接口跨模块、跨时钟域的信号流动通信接口是“信号流动”最直观的工程场景。以 UART 发送为例数据要经过这样的流动链路并行数据 → 发送使能 → 移位寄存器逐位移出 → 起始位/数据位/停止位拼接 → TX 引脚输出。核心是一个移位寄存器// UART TX 简化核心逻辑 reg [3:0] bit_cnt; reg [7:0] tx_shift; reg tx_line; always (posedge clk) begin if (send_en) begin tx_line 1b0; // 起始位 tx_shift tx_data; bit_cnt 4d0; end else if (bit_cnt 4d9) begin tx_line tx_shift[0]; // 输出最低位 tx_shift tx_shift 1; bit_cnt bit_cnt 1b1; end else begin tx_line 1b1; // 停止位 end end这里能看到信号流动的典型套路使能信号启动数据搬运移位寄存器在时钟驱动下把并行数据“串行化”计数器控制每位的节奏。更复杂的 SPI、I2C、DDR3 控制器本质上也是这套思路只是数据宽度更大、时序要求更严。写这类代码时我特别关注三个点时钟域是否统一、使能信号是否只持续一个周期、跨时钟域的接口有没有做同步处理。后面两点是工程上最容易出隐性 bug 的地方。5. 仿真验证让看不见的信号现出原形5.1 testbench构造信号的“试验台”写完了 RTL 代码第一件事不是上板而是仿真。仿真靠的是 testbenchTB它本身也是 Verilog 代码但不需要综合成电路只是用来产生激励信号、观察输出。timescale 1ns/1ps module tb_counter; reg clk; reg rst_n; wire [3:0] cnt; counter #(.WIDTH(4)) u_dut ( .clk(clk), .rst_n(rst_n), .cnt(cnt) ); // 产生 100MHz 时钟周期 10ns initial clk 0; always #5 clk ~clk; // 复位与测试流程 initial begin rst_n 0; #25; rst_n 1; #200; $finish; end endmoduletimescale定义了仿真时间单位always #5 clk ~clk生成周期 10ns 的时钟initial块负责按时间点施加激励。写好 TB 后在 Modelsim 或 Vivado 里把设计文件和 TB 一起加入工程编译、运行、打开波形窗口就能看到 clk、rst_n、cnt 的完整波形。这是我调试 Verilog 代码的主要手段先看复位是否正常释放再看信号是否按预期翻转最后对照协议手册检查时序是否符合要求。5.2 仿真调试的常见问题与排查技巧分享几个我高频遇到的仿真问题波形一直 X 态多半是复位没做或者寄存器没有初始值。先查复位时序再看敏感列表是否漏了信号。计数器不翻转检查 clk 是否真的在跑查看时钟频率设置是否合理确认敏感列表是否写了posedge clk。输出和预期差一拍这是正常的因为寄存器输出总是比输入晚一个时钟周期。要习惯“一拍延迟”的思维方式。仿真结果对但上板不对这属于仿真环境与真实电路不一致常见原因是没有加输入约束、没有做时序仿真或者 RTL 里有不可综合写法。仿真还有一个很实用的习惯把关键信号在特定时刻导出成文件方便用脚本比对。比如用$fwrite把仿真数据存成文本再用 Python 做数据分析这套流程在处理 FIR 滤波器、滑动窗口滤波这类算法验证时非常高效。6. 从综合到时序收敛几个能救命的工程化建议6.1 仿真通过不等于电路能用很多人调试完仿真就急着上板结果一跑就蹦出各种诡异现象。原因很简单仿真用的是理想延时模型而真实电路有门延迟、布线延迟、建立时间和保持时间的限制。组合逻辑路径太长信号在一个时钟周期内走不完就会触发时序违规导致寄存器采到错误数据。我现在的习惯是仿真通过只是第一步接下来必须跑综合然后看综合报告的时序报告Timing Report。重点看 Worst Negative SlackWNS如果为负说明时序违例需要优化关键路径。常用的优化手段包括在长组合逻辑链中间插入寄存器流水线、减少扇出、调整代码结构让综合工具更高效地映射逻辑。记住一个原则让信号每走一步都离目标近一点不要试图在一个时钟周期里飞过整块芯片。6.2 时序约束与跨时钟域工程进阶的必修课时序约束比如 XDC/SDC 文件是很多自学 Verilog 的人完全没接触过的领域但真正做项目就绕不开。最基本的约束是定义时钟周期和输入输出延时让综合工具知道你的设计目标。没有约束的综合是“盲人骑瞎马”工具只会按默认策略优化结果往往不是你要的。跨时钟域CDC处理则是另一个高频事故区。两个异步时钟之间传递信号时如果不做同步采样的触发器会进入亚稳态导致系统行为随机。最简单的处理方式是对单比特信号打两拍同步always (posedge clk) begin sync1 async_signal; sync2 sync1; end多比特数据跨时钟域则建议用异步 FIFO 或握手协议。我看到热搜词里有“SPI slave”“UART RS485”“DDR3 读写控制”全都涉及跨时钟域和时序设计这些都是从“会写 RTL”到“能做系统”的分水岭。6.3 我这几年的实操经验总结最后聊几句个人体会。带过的人多了我越发觉得 Verilog 入门最快的路径不是刷语法而是做“看得见的东西”用一块便宜的教育板从点亮 LED、做计数器、写 UART 回环慢慢到 SPI 读传感器、驱动屏幕每完成一个实验你对“信号怎么流动”的理解都会深入一层。几个具体建议值得拿出来说画图先行。写任何模块之前先把端口、内部信号、状态转移图画出来再动键盘。我见过太多“边写边想”导致的返工。命名规范。时钟统一叫 clk、复位统一叫 rst_n信号名要能表达含义比如tx_busy、fifo_almost_full。调试的时候你才会感谢自己。保持同步设计风格。尽量只在时钟边沿操作信号尽量避免组合逻辑输出直接驱动其他模块的时钟端这是消除大量疑难杂症的根本。重视仿真波形。不要只盯着$display打印要养成看波形的习惯波形里藏着信号的完整流动轨迹。多读工具报告。综合报告、时序报告、约束检查报告每个警告都值得花时间搞懂它们是综合工具在给你“提意见”。写 Verilog 这件事说到底是学着用硬件的眼光看问题。你写的每行代码最终都变成芯片上一个真实的电路节点你描述的每个信号最终都在真实世界里以纳秒级的速度流动。想通了这一点很多语法困扰都会迎刃而解。最后再分享一个小经验调试状态机的时候如果你觉得状态转移逻辑写得没问题但波形就是不对试着把当前状态和下一状态同时拉出来看对照状态转移图一个周期一个周期地走。绝大多数情况下问题都出在“我以为状态已经跳了其实还停在原地”。这大概就是 Verilog 调试里最朴素也最实用的心法了。