Verilog硬件描述语言入门:从编程思维到硬件电路设计
1. 从“连线”到“描述”为什么Verilog不是编程语言如果你是从单片机或者软件编程转过来的第一次接触Verilog最大的误区可能就是把它当成C语言或者Python来学。我见过不少新手一上来就琢磨“这个for循环怎么写”、“这个函数怎么调用”结果代码一综合要么面积爆炸要么时序完全不对甚至根本生成不了电路。这背后的根本原因是思维模式的差异软件是顺序执行的指令流而硬件是并行发生的物理连接。Verilog的全称是Verilog HDLHDL是Hardware Description Language硬件描述语言。这个“描述”二字是精髓。你不是在“写程序”告诉CPU一步一步做什么你是在“画图纸”用文本的方式告诉综合器比如Vivado、Quartus“我这里要一个与门它的两个输入分别来自A和B输出接到C旁边还要一个寄存器每个时钟上升沿把D的数据存进去”。综合器读你的“图纸”把它翻译成目标FPGA芯片里实际存在的查找表LUT、触发器FF、布线资源等最终生成一个电路网表。举个例子软件里你写c a b;CPU会在一个特定的时钟周期从内存取a和b送到加法器算出结果再写回c的地址。但在Verilog里你写assign c a b;你描述的是一个加法器电路。只要a或b变化c几乎同时经过一个很小的门延迟就会变化这是一个持续不断的物理过程没有“执行”的概念。所以学习Verilog语法第一步是建立“硬件思维”你写的每一行代码最终都会对应芯片上的一块硅片区域和金属连线。代码的简洁、清晰、可综合比软件的“奇技淫巧”重要得多。接下来我们就从最基础的构建单元开始拆解这张“电路图纸”该怎么画。2. 构建硬件世界的“原子”数据类型与运算符任何电路都在处理信号信号就有类型和值。Verilog的数据类型体系直接映射了硬件信号的特质理解它们是避免后期诡异错误的基础。2.1 线网与变量信号的两大载体这是Verilog中最核心、也最容易混淆的一对概念。线网Net最常用的就是wire。它代表的是电路单元之间的物理连接线。想象一下PCB板上的铜线它本身不存储信息只是传递信息。因此驱动wire必须被持续驱动。驱动源可以是模块的输出端口、一个assign连续赋值语句或者一个门实例的输出。如果一根wire没有被任何东西驱动它的值就是高阻态z。赋值使用assign语句进行赋值代表一种永久的连接关系。用途主要用于模块间的连接或者组合逻辑中信号的传递。wire a, b, sum; // 声明三根连线 assign sum a b; // 描述一个与门a和b是输入线sum是输出线三者被永久连接变量Variable最常用的是reg。这个名字极具误导性它不一定对应硬件寄存器它代表一个数据存储单元。在Verilog的抽象层次reg是一种可以在always和initial块中被赋值的变量。行为reg保存的是最后一次被赋予的值。赋值必须在always或initial过程块中使用过程赋值语句或赋值。硬件对应reg最终综合成什么取决于它被如何使用。在时序逻辑的always (posedge clk)块中它通常被综合成触发器Register在组合逻辑的always (*)块中它会被综合成连线Wire加一些逻辑。用途用于表示在特定条件下如时钟边沿才更新的数据存储点。reg q; // 声明一个reg型变量 always (posedge clk) begin q d; // 在时钟上升沿将d的值锁存到q。这里q会被综合成一个D触发器。 end关键心得一个简单的记忆法是——看赋值语句在哪里。assign后面跟wirealways块里用reg。在大多数可综合设计中模块的输入端口默认为wire输出端口可以是wire或reg但内部连接推荐用wire需要存储状态或用于过程赋值的信号用reg。2.2 向量与数值如何表示总线数字电路很少只处理1比特信号更多的是4位数据线、8位地址线、32位数据总线。Verilog用向量来表示。wire [7:0] data_bus; // 一个8位宽的wire型向量高位在左索引7低位在右索引0 reg [31:0] counter; // 一个32位宽的reg型向量你可以访问向量的某一位或某一段assign led data_bus[0]; // 取最低位 assign high_byte data_bus[7:4]; // 取高4位即第7到第4位 assign merged {data_bus[3:0], 4b0000}; // 拼接操作将低4位和高4位0拼接成一个8位数数值的表示方法也很重要位宽进制数值。4b1010 4位二进制数 1010 (十进制10)8hFF 8位十六进制数 FF (十进制255)16d100 16位十进制数 1001b1,1b0 明确的逻辑1和0。直接写1或0会被解释为32位的整数在赋值给1位信号时可能产生警告建议养成使用明确位宽的习惯。2.3 运算符描述逻辑功能Verilog的运算符和C语言类似但硬件意义不同。位运算符(与)|(或)~(非)^(异或)。它们按位操作直接对应与门、或门等。assign c a b; // 一个与门 assign parity ^data_bus; // 对data_bus所有位进行异或得到奇偶校验位缩减运算符对向量的所有位进行位操作产生1位结果。如data_bus表示data_bus[0] data_bus[1] ... data_bus[7]。逻辑运算符||!。它们操作的结果是布尔值真或假通常用于条件判断。注意和的区别4b0011 4b0101 4b0001而4b0011 4b0101 1b1因为两个操作数都不为0。关系与算术运算符!-*/。这些运算符会被综合成相应的比较器、加法器、乘法器等电路。要特别注意*和/会消耗大量逻辑资源在FPGA中非2的幂次的除法和乘法通常需要调用IP核。移位运算符(左移)(右移)。左移一位相当于乘2右移一位相当于除2在硬件上只是连线的重新排列几乎不消耗逻辑资源是非常高效的运算。assign power_of_two 8b1 shift_amount; // 根据shift_amount生成2的幂次数3. 描述电路行为的核心结构过程块与赋值掌握了基本材料数据类型接下来就需要用特定的“语法结构”来描述电路的行为。过程块always和initial和赋值语句是这里的核心。3.1 always块硬件行为的“发动机”always块是描述硬件并行行为最重要的结构。一个模块里可以有多个always块它们同时、并行地执行从仿真角度看。其语法为always (敏感列表) begin // 过程语句 end敏感列表是灵魂它决定了这个块在什么条件下“触发”执行。时序逻辑敏感列表always (posedge clk or posedge rst)。这描述了一个由时钟clk上升沿和复位rst上升沿触发的触发器组。这是描述寄存器同步逻辑的标准形式。块内应使用非阻塞赋值。组合逻辑敏感列表always (*)或always (a or b or sel)。(*)是 Verilog-2001 标准引入的便捷写法编译器会自动列出块内所有读取信号的列表。这用于描述组合逻辑如多路选择器、译码器。块内必须使用阻塞赋值并且要确保所有输入信号的变化都能导致输出重新计算否则会隐含锁存器Latch这通常是设计错误。3.2 阻塞赋值与非阻塞赋值顺序与并行的艺术这是Verilog学习中最经典的坑没有之一。阻塞赋值如同其名会“阻塞”后续语句的执行。在同一个always块中语句按书写顺序依次执行前一句赋值完成值立即更新后一句再使用这个新值。它模拟了软件的顺序执行行为主要用于组合逻辑建模。always (*) begin temp a b; // 执行完temp立即变成ab的和 c temp * 2; // 这里使用的temp是上一句更新后的值 end综合后temp可能只是一个中间连线最终电路是c (ab)*2的组合逻辑。非阻塞赋值所有语句同时计算右侧表达式RHS但在当前时间步结束时才统一将结果更新到左侧变量LHS。它完美模拟了寄存器在时钟边沿同时更新的硬件行为专用于时序逻辑。always (posedge clk) begin reg1 data_in; // 计算data_in的值但reg1并未立即改变 reg2 reg1; // 计算reg1的值注意是时钟沿到来时reg1的旧值 end这个块描述了一个经典的移位寄存器。在时钟上升沿data_in被锁存到reg1而reg1的旧值被锁存到reg2。两个寄存器的更新是同时发生的。血泪教训记住这个黄金法则——在描述组合逻辑的always (*)块中用在描述时序逻辑的always (posedge clk)块中用。混合使用或者在同一个块里混用两种赋值是导致仿真与综合结果不一致、产生竞争冒险的常见根源。在刚入门时请严格遵守此规则。3.3 条件与循环语句实现逻辑分支与重复结构if-else和case语句用于实现条件分支它们通常被综合成多路选择器MUX。// 使用 if-else 描述一个带优先级的2选1 MUX always (*) begin if (sel_a) begin out in_a; end else if (sel_b) begin out in_b; end else begin out default_val; end end // 使用 case 描述一个不带优先级的4选1 MUX always (*) begin case (sel) 2b00: out in0; 2b01: out in1; 2b10: out in2; 2b11: out in3; default: out 1b0; // 良好的习惯加上default避免产生锁存器 endcase end关键点在组合逻辑的always块中必须为if-else或case的所有可能分支都明确赋值否则会综合出锁存器来“记忆”未赋值的情况。这通常不是你想要的功能会浪费资源并引入时序问题。for循环在Verilog中用于描述重复的硬件结构而不是软件中的循环执行。综合器会将循环“展开”。// 描述一个8位奇偶校验生成器 always (*) begin parity 1b0; // 初始化 for (int i0; i8; ii1) begin parity parity ^ data[i]; // 这会被展开成7个级联的异或门 end end循环次数必须在编译时确定不能是变量。generate for循环更强大用于实例化多个模块或生成重复的代码块是构建参数化设计的重要工具。4. 模块化设计与层次化构建复杂系统的基石任何复杂的数字系统都是由一个个功能相对独立的模块像搭积木一样连接起来的。Verilog的模块module就是这个“积木块”。4.1 模块的定义与实例化一个模块包括接口声明和内部实现。// 模块定义一个简单的2输入与门 module my_and ( input wire a, // 输入端口通常省略wire直接写 input a input wire b, output reg y // 输出端口如果在always块中赋值需声明为reg ); always (*) begin y a b; end endmodule在其他模块中使用这个“积木”叫做实例化。module top ( input wire x1, x2, x3, output wire z ); wire net1; // 内部连接线 // 实例化my_and模块方式一按顺序连接不推荐易错 // my_and U1 (x1, x2, net1); // 实例化my_and模块方式二按名称连接推荐清晰 my_and U1 ( .a (x1), // 将顶层模块的x1连接到实例U1的端口a .b (x2), .y (net1) ); my_and U2 ( .a (net1), .b (x3), .y (z) ); endmodule按名称连接是工程实践中的最佳选择它避免了因端口顺序改变而导致的隐蔽错误代码可读性也强得多。4.2 参数化设计让模块更灵活使用parameter可以定义模块参数让模块像函数一样可配置。module generic_adder #( parameter WIDTH 8 // 默认参数为8 )( input wire [WIDTH-1:0] a, b, output reg [WIDTH-1:0] sum ); always (*) begin sum a b; end endmodule // 实例化时传递参数 generic_adder #(.WIDTH(16)) adder_16bit (.a(a_bus), .b(b_bus), .sum(sum_bus)); generic_adder adder_8bit (.a(a8), .b(b8), .sum(sum8)); // 使用默认8位宽度参数化设计极大地提高了代码的复用性是构建IP核和复杂子系统的基础。4.3 测试平台Testbench的初步认识Verilog不仅是设计语言也是验证语言。测试平台Testbench是一个特殊的顶层模块它没有输入输出端口其任务是实例化待测设计DUT并产生激励信号检查其输出。timescale 1ns/1ps // 定义时间单位/精度 module tb_my_and(); reg a, b; // 驱动DUT输入用reg wire y; // 接收DUT输出用wire // 实例化待测设计 my_and dut (.a(a), .b(b), .y(y)); // 产生测试激励 initial begin a 0; b 0; #10; // 初始值等待10个时间单位 a 0; b 1; #10; a 1; b 0; #10; a 1; b 1; #10; $finish; // 结束仿真 end // 监控输出在仿真器中打印 initial begin $monitor(Time%t, a%b, b%b, y%b, $time, a, b, y); end endmoduleinitial块在仿真开始时执行一次常用于初始化或生成非周期的激励。$monitor等系统任务用于调试和观察波形。虽然现代FPGA开发更多依赖于在Vivado/Quartus中直接编写约束和进行上板调试但掌握简单的Testbench编写对于模块级的功能验证和仿真调试依然非常有用。5. 可综合风格与常见陷阱写出能变成电路的代码并非所有符合语法的Verilog代码都能被综合工具转换成有效的硬件电路。可综合子集Synthesizable Subset是RTL设计者必须遵守的规则。5.1 不可综合或慎用的语法initial块除用于Testbench和某些特殊的FPGA初始化如初始化存储器内容但通常有更好方式外不可用于设计代码。上电后寄存器的初始状态应通过复位信号来设定。#延迟控制如#5 a b;这是仿真用的综合工具会忽略。电路延迟由实际物理路径决定。real类型浮点数大部分综合工具不支持。FPGA中需使用定点数或调用浮点IP核。fork...join进程并行块主要用于高级验证不可综合。循环边界不确定的while/forever综合工具需要确定硬件规模循环必须能在编译时静态展开。5.2 避免隐含锁存器Latch这是新手最常踩的坑。锁存器由电平触发对毛刺敏感在ASIC设计中要尽量避免在FPGA中也会带来时序和验证问题。它通常在组合逻辑的always块中由于条件分支赋值不完整而产生。// 错误示例会产生锁存器 always (*) begin if (en) begin q data; end // 当en为0时q没有新值硬件为了“保持”原值便生成了一个锁存器。 end // 正确写法1为所有分支赋值 always (*) begin if (en) begin q data; end else begin q 1b0; // 或者 q q; (但这样逻辑意义可能不对) end end // 正确写法2在块开始处赋默认值推荐 always (*) begin q 1b0; // 默认值 if (en) begin q data; end end养成在组合逻辑always块开头为所有输出信号赋默认值的习惯能有效避免隐含锁存器。5.3 时序逻辑中的异步复位与同步复位复位是数字系统可靠工作的保障。根据复位信号是否与时钟同步分为两种风格// 异步复位复位信号独立于时钟一旦有效立即生效 always (posedge clk or posedge rst_async) begin if (rst_async) begin q 1b0; end else begin q d; end end // 同步复位复位信号仅在时钟有效边沿被采样 always (posedge clk) begin if (rst_sync) begin q 1b0; end else begin q d; end end异步复位优点是可以立即恢复系统状态对时钟是否稳定无要求。缺点是复位释放时如果刚好在时钟边沿附近可能导致寄存器输出亚稳态复位恢复问题且不利于静态时序分析。同步复位优点是完全在时钟域控制下避免亚稳态STA分析简单。缺点是复位信号需要像数据一样满足建立/保持时间且需要时钟有效才能复位。FPGA的底层触发器通常有专用的异步复位/置位端使用异步复位可以直接映射到该硬件资源效率高。但为了系统的可靠性工程上常采用“异步复位同步释放”的电路结构来兼顾两者的优点。这通常通过编写一个复位同步模块来实现。5.4 关于代码风格与可维护性最后一些软性的经验同样重要命名清晰使用有意义的信号名、模块名如clk_50m,rst_n,data_valid,fifo_rd_en。避免使用a,b,tmp等。注释得当在模块头部说明功能、接口、参数在复杂逻辑旁说明意图。但避免用注释重复代码。一个always块只做一件事尽量让一个always块只描述一组相关的寄存器或一块组合逻辑。如果一个always块过于庞大就拆分它。这有利于综合、调试和后期修改。使用localparam对于模块内部常量使用localparam而非parameter避免被外部修改。小心使用integer和int在可综合循环中常用作索引但需注意其是32位有符号数可能产生非预期的符号扩展问题。语法是骨架思维是灵魂。把Verilog当成画电路图的工具而不是写程序的工具时刻思考你写的每一行代码会对应什么样的物理电路这是从语法入门走向合格数字电路设计者的关键一步。在实际项目中多读好的代码如开源IP核多使用综合工具的RTL视图查看自己代码生成的电路反复对比和思考是提升最快的方法。

相关新闻

最新新闻

日新闻

周新闻

月新闻