资讯动态

Verilog语言基础入门:从并行思维到组合逻辑与时序逻辑实战

发布时间:2026/10/13 1:26:01 来源:尧图企业网站定制
这是 Josh 的学习笔记之 VerilogPart 2——Verilog 语言基础。这一篇不会去聊那些高大上的算法和架构只解决一件事看着一段 Verilog 代码你能不能像看 C 语言一样知道它在干什么、为什么这么写、哪里容易出错。如果你正在从单片机编程或者纯软件方向往 FPGA 这边转这篇文章就是为你准备的。1. 先转变思维Verilog 不是“编程语言”1.1 核心差异并行执行不是顺序执行我第一次正式接触 Verilog 的时候最大的冲击不是语法而是思维模型。C 语言里写个a 1; b a;那么 a 先被赋值为 1b 再去读 a拿到的是 1。但在 Verilog 里如果这两条语句写在同一个 always 块里并且用的是阻塞赋值效果可能也是 b 等于 1如果换用了非阻塞赋值结果就变成 b 等于旧 a 的值。这不是语法奇葩而是在模拟硬件真实的行为数字电路里每个寄存器在同一时刻采样输入然后同一时刻更新输出。关键认知Verilog 从本质上讲不是程序设计语言而是一种硬件描述语言。你写的每一行最后都会被综合工具映射成实际的组合逻辑门、触发器、连线。所以写代码的时候要不断问自己一句话这行代码会综合成什么电路1.2 wire 和 reg 不是 C 语言的变量刚开始学的时候我特别想把 wire 理解成“线”把 reg 理解成“寄存器”后来发现这个理解很容易误导人。更准确地说wire 是物理连线的抽象reg 是过程块里保存值的存储单元抽象。但 reg 不一定是寄存器在组合逻辑的 always 块里reg 综合出来可能只是一堆逻辑门连线不产生任何触发器。所以不要去死记“wire 是线、reg 是寄存器”要记住两个使用规则连续赋值语句assign的左边必须是 wire过程块always块内被赋值的变量必须是 reg。记住这两条写代码的时候能少报一半的编译错误。2. 最小模块长什么样module 和端口2.1 从 module 到 endmodule一个 Verilog 设计的最外层容器是模块就像 C 语言里的函数但又不完全一样。模块是硬件结构的基本单位一个模块对应一个独立的电路功能块。下面是一个最简单的模块示例module adder( input wire [7:0] a, input wire [7:0] b, output wire [7:0] sum ); assign sum a b; endmodule这里做了三件事声明模块名字、定义端口、描述内部逻辑。注意端口声明里带了wire关键字其实端口类型默认就是 wire不写也可以但显式写出来会清晰很多。2.2 端口方向是设计意图的核心端口方向分三种input、output、inout。其中 inout 是双向端口比如某些存储器数据总线会用到。写 inout 时内部要配合高阻态z来控制方向这部分初学者直接用得少可以先留着等做到总线类设计再回来啃。端口位宽也是一开始就容易出问题的地方。[7:0]表示高到位从 bit7 到 bit0。实际上 Verilog 也支持[0:7]但绝大多数代码习惯用[N-1:0]这种降序写法。我建议你从一开始就统一用降序因为很多第三方 IP 和综合工具对位宽顺序有敏感处理混用会给自己埋雷。2.3 模块例化硬件里的“函数调用”模块写好后要放到顶层去用这个动作叫例化。有人类比成 C 语言函数调用但例化更像是把芯片焊到板子上。每个例化出来的模块在 FPGA 里都对应一份实实在在的硬件电路。同一个模块例化两遍综合出来的面积就是双份。module top( input wire [7:0] x, input wire [7:0] y, output wire [7:0] result ); wire [7:0] sum_internal; adder u_adder( .a (x), .b (y), .sum (sum_internal) ); assign result sum_internal; endmodule例化时端口连接方式推荐用.端口名(信号名)这种显式连接不要用位置连接adder u_adder(x, y, sum_internal);。位置连接一旦端口顺序看错或者后续修改端口列表错误会非常隐蔽用显式连接即使对不上编译器报错信息也更友好。3. 三大基本数据类型实战对照3.1 wire物理连线随连随有wire 的值由驱动源决定比如 assign 语句、模块输出端口等。它不能在 always 块里被赋值。多个 assign 驱动同一个 wire 会导致多驱动冲突这在综合时会报错在仿真里会出现 x 态。用表格看一下 wire 和 reg 的适用场景使用场景wireregassign 连续赋值使用禁止always 过程块赋值禁止使用模块端口默认类型使用不默认能否被直接驱动为触发器不能可以有些初学者问reg 类型是不是就一定能综合成寄存器答案是否定的。在always (*)组合逻辑块里声明的 reg综合出来纯组合电路。reg 这个名字是个历史遗留问题你只需要记住规则不要被名字牵着走。3.2 reg过程块里储存值“reg 不等于寄存器”实际编码中最常用的组合逻辑写法就是这样reg [3:0] cnt_next; always (*) begin cnt_next cnt 4d1; end这里 cnt_next 是 reg但它综合出来只是几根走线和加法器不产生触发器。真正产生触发器的是像always (posedge clk)这样的时序块。想判断一段代码会不会综合出触发器就看它的敏感列表里有没有时钟沿。3.3 parameter 和 localparam常量也有讲究parameter 是模块的对外参数例化时可以被修改localparam 是模块内部私有常量不可以被外部修改。常见的用途包括位宽定义、状态编码、计数器上限。parameter WIDTH 8; // 对外可配置 localparam IDLE 2b00; // 内部私有用#(...)在例化时传参是一种很常见的参数化设计方式。比如定义一个位宽可配置的计数器模块例化时把 WIDTH 改成 16同一个模块就能用在不同场合。这也是现代硬件设计复用思想的一个基础手法。3.4 数据类型误区速查reg声明在 always 块里赋值时如果该 always 是时序块且条件不完整会综合出寄存器有时还会带使能或复位wire在连续赋值中只能被驱动一次多个驱动会引入 x 态integer是 32 位有符号数在 for 循环里当循环变量很方便但要注意它综合出来不一定高效real类型不用于可综合代码只在仿真中出现。4. 组合逻辑的两种主要写法assign 与 always(*)4.1 assign一句顶五句的连续赋值assign 用于描述两个信号之间的纯组合关系。它很适合做简单逻辑、数据通路、拼接和截断。例如assign sel_signal (addr 8hFF) ? 1b1 : 1b0; assign data_out {data_in[3:0], 4b0000}; // 拼接assign 和一个 always 块实现的组合逻辑本质相同综合效果也一样。区别在于 assign 写起来更简短而 always 块能承载更多控制结构比如 if/else 和 case。如果一个复杂模块里全是 assign逻辑会非常碎片化难读难维护。折中做法是简单逻辑用 assign复杂组合逻辑用 always(*)。4.2 always(*) 组合逻辑块与 Latch 陷阱always (*)是 Verilog-2001 引入的写法替代了早期的always (a or b)。使用通配符*的好处是敏感列表由工具自动推断免得漏写信号。写组合逻辑 always 块有个黄金法则所有分支都要赋值。这个说起来容易做起来难最容易出问题的就是 if 没有 else。比如reg [3:0] out; always (*) begin if (en) begin out in_a; end end这段代码仿真没问题但综合时会警告当 en 为 0 时 out 要保持原值于是综合工具自动推断出一个锁存器 Latch。Latch 在 FPGA 里通常不是你想要的东西它会带来时序分析困难、毛刺风险。避免方法很简单补上 else或者提前给个默认赋值reg [3:0] out; always (*) begin out 4b0; if (en) begin out in_a; end end4.3 运算符优先级小抄Verilog 运算符优先级和 C 语言高度相似但有几个值得留意的点需要记忆优先级运算符高一元! ~ ~ | ~| ^ ~^中算术* / -低移位 更低的关系、相等、逻辑与、逻辑或、条件 ?:最实用的建议是别依赖记忆优先级直接用括号把表达式拆清楚。硬件代码是给别人看的也是给综合工具看的括号多一点不影响面积和性能但能减少大量低级错误。5. 时序逻辑时钟、复位与非阻塞赋值5.1 时钟敏感列表的正确姿势时序逻辑的标准写法是always (posedge clk)表示在时钟上升沿触发。一个常见的错误是写成always (clk)这会在时钟的两个沿都触发行为综合工具会感到困惑仿真也常表现异常属于踩坑高频区。时钟命名建议信号带明确语义不要满屏都是clk1、clk2这种不明含义的名字。时钟和复位信号在项目里属于关键路径花了心思起名排错时能少很多痛苦。5.2 复位方式异步复位与同步复位复位是时序逻辑里很容易产生分歧的话题。异步复位和同步复位两种方式都存在选择哪种要结合团队规范。// 异步复位 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 8b0; end else begin cnt cnt 1b1; end end // 同步复位 always (posedge clk) begin if (!rst_n) begin cnt 8b0; end else begin cnt cnt 1b1; end end异步复位的优势是复位信号不依赖时钟就能生效适合对上电初始化速度有要求的场景但容易产生复位释放时的亚稳态问题。同步复位更容易做时序收敛但复位宽度必须足够。初学者建议先掌握异步复位写法这是目前主流 FPGA 设计中最常见的风格。5.3 阻塞赋值与非阻塞赋值的“一页纸规则”很多人刚学 Verilog 就背口诀组合逻辑用阻塞赋值时序逻辑用非阻塞赋值。这个口诀没错但更重要的是理解为什么。在时序逻辑中所有寄存器在同一个时钟沿应该同时采样、同时更新模拟的行为就是先取旧值再统一写入。非阻塞赋值正好符合这一点它使赋值操作被安排在仿真时间步的“结尾”统一更新。如果时序逻辑里混用阻塞赋值中间变量的更新顺序会让代码依赖书写顺序最终导致仿真结果和真实硬件不一致。一个典型反面示例always (posedge clk) begin a b; c a; // 这里 c 得到的是 b 的新值 end这段代码用阻塞赋值仿真结果是 a 和 c 在同一时刻都变成 b。但如果用非阻塞赋值仿真中 a 和 c 更新为 b 的旧值c 不会先拿到 a 的新值。硬件上两个寄存器都是在水滴沿同时更新的所以非阻塞赋值的仿真结果才更接近真实电路。最稳妥的写法原则always(*) 里用阻塞赋值always(posedge clk) 里用非阻塞赋值。规则不复杂但很多人会因为图省事混用在仿真时留下隐患。5.4 最小可用的计数器与移位寄存器计数器是时序逻辑的“Hello World”几乎每个工程里都跑不掉。一个带使能和复位的计数器如下module counter #( parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire en, output reg [WIDTH-1:0] count ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin count {WIDTH{1b0}}; end else if (en) begin count count 1b1; end end endmodule注意{WIDTH{1b0}}这种拼接复制的写法比逐个写 8b0 更灵活适合参数化位宽。计数器综合出来就是寄存器和加法器的组合位宽每增加 1理论最大计数值就会翻倍这个参数关系在做分频设计时要提前算清楚。移位寄存器也非常常见比如串转并、流水线打拍。最简单的流水线打拍是延迟一拍reg [7:0] data_d1, data_d2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin data_d1 8b0; data_d2 8b0; end else begin data_d1 data_in; data_d2 data_d1; end end这里 data_d2 比 data_in 晚两个时钟周期常用于跨时钟域或者组合逻辑链的插入延迟。做时序收敛时这种打拍方式比插一堆 assign 直观得多。6. 条件与循环语句在硬件中的真实形态6.1 if/else 的优先级陷阱软件里 if/else 糊一点没事性能不会差太多。但硬件里 if/else 和优先级有关综合工具会把它映射成一串多路选择器最前面的 if 拥有最高优先级电路链路上它的影响最大路径也最容易变长。always (*) begin if (sel_a) begin out input_a; end else if (sel_b) begin out input_b; end else begin out input_c; end end这种级联的 if/else 会形成优先级链。如果条件之间本身是互斥的完全可以用 case 替代case 综合出来更接近并行的多路选择器路径更短。不要无脑用 if/else 嵌套堆出很深的优先级树到时候时序报告会来教你做人。6.2 case 语句与 casez/casex 的正确打开方式case 语句在独热状态机、指令译码、地址译码里很常见。标准 case 检查每一位是否完全匹配所以条件里的位宽一定要对齐。always (*) begin case ({sel_a, sel_b}) 2b00: out 4b0001; 2b01: out 4b0010; 2b10: out 4b0100; 2b11: out 4b1000; default: out 4b0000; endcase endcasez 允许把不关心的位写成?用于部分匹配比如4b1??0表示第 3 位为 1 且第 0 位为 0中间两位任意。casex 更进一步把 x 和 z 都视为不关心。casex 使用时需要小心仿真中 x 态容易被忽略导致验证不够充分实际工程里用得相对少优先用 casez。不管哪种 casedefault 分支都建议写上。即使所有分支看起来已经完备default 可以兜住仿真中的 x 态也能避免不必要的 Latch。6.3 for 循环在硬件里不是循环软件里的 for 循环是反复执行同一段代码硬件里的 for 循环本质是循环展开属于编译期处理。比如给寄存器阵列清零integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i 16; i i 1) begin mem[i] 8b0; end end else begin mem[wr_addr] wr_data; end end这个 for 循环会被综合成 16 个触发器的复位逻辑不是真的在仿真时刻层层循环执行。因此循环边界最好用常量或者 parameter不要在循环条件里用运行时变量。那样综合不了或者综合出非常奇怪的电路。7. 仿真验证最小闭环写一个 testbench 跑起来7.1 基本骨架写 testbench 是验证设计的第一道关卡。testbench 本身不需要可综合所以可以大胆使用initial块、#延时、task、$display这些仿真语法。一个最小的 testbenchmodule tb_counter; reg clk; reg rst_n; reg en; wire [7:0] count; counter #(.WIDTH(8)) u_counter ( .clk (clk), .rst_n (rst_n), .en (en), .count (count) ); initial begin clk 1b0; rst_n 1b0; en 1b0; #100; rst_n 1b1; en 1b1; #500; en 1b0; #100; $finish; end always #10 clk ~clk; endmodule这里用always #10生成周期为 20 个时间单位的时钟。一个常见的规范是不要用#50这种随手写的延时最好定义时钟周期参数避免时间尺度乱掉。7.2 初始化和仿真的常见坑不要忘记复位释放不释放复位状态机永远停在初始态不要用wait(clk 1b1)这种时钟边沿写法代替(posedge clk)时序粒度和仿真效率都容易出问题仿真里$display打印信息时建议加上时间戳$time否则排错时对不上时间线。7.3 层次引用与监控testbench 里可以直接通过层次路径访问设计内部信号。例如tb_counter.u_counter.count可以直接打印出来。这对定位深层 bug 帮助极大但层次路径一旦设计结构调整testbench 就要跟着改所以不要滥用复杂信号建议用内部调试接口或者断言代替。7.4 仿真和综合的差异提醒仿真过了不等于硬件没问题。仿真环境里信号都是理想状态没有门延迟、布线延迟也没有亚稳态。所以 testbench 里看到的波形和真实芯片上的波形有时间差。这部分不用太焦虑关键是知道差异存在后续学到时序约束和静态时序分析时再回头深化理解。8. 初学 Verilog 高频报错自查与排查心得8.1 莫名其妙出现的 Latch编译器报了 Latch 警告按这个顺序查always(*) 组合块里所有分支是否都有赋值if 是否有 elsecase 是否有 default同一个变量是否只在一条赋值路径里被驱动。多数 Latch 警告都能在这一步解决。8.2 信号位宽不匹配assign out a b;如果 out 位宽比 ab 的结果小高位会被截断如果大高位扩展。对无符号数而言高位自动补零有符号数要小心扩展方式不同。建议所有涉及位宽不匹配的地方先手动补好位数再进综合不要靠工具去猜。wire [7:0] a, b; wire [8:0] sum; assign sum {1b0, a} {1b0, b}; // 手动扩展到 9 位8.3 阻塞赋值引发的仿真混乱时序逻辑里混用阻塞赋值最容易出现的现象是仿真波形看着“一些寄存器早了一个周期”或者综合前仿真和综合后仿真结果不一致。排查方法也很简单把时序块里所有换成对照规则重新检查一遍。8.4 语法与编码风格小坑语句结尾的分号遗漏在宏定义、模块例化、语句块结束时很常见编译报错行经常不在真正出错的位置要多看上下一行位宽写反了[0:7]与[7:0]混用在小模块看似没问题一旦和总线对接就会出现数据和预期相反的问题注释里//和/* */混用嵌套有些编译器对跨行注释的处理容易把人坑到命名时不要用单一字母尤其是i和l在等宽字体下太像了代码量和复杂度上去后排查成本高。写在最后的个人体会这套笔记整理了挺久写到这里我自己最大的体会是学习 Verilog 语言基础与其死记语法不如时刻想着“这段代码会变成什么电路”。很多时候语法本身并不难难的是你知道综合器会怎么理解它、仿真器会怎么执行它以及硬件上真正会发生什么。后面如果继续写 Part 3我打算顺着本地的组合逻辑和触发器展开把状态机、时序约束和跨时钟域这些硬骨头一个个啃下来。到那时候你再回头看这一篇里反复强调的“并行思维”和“非阻塞赋值”会有更深一层的感觉。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑