Verilog硬件描述语言入门:从软件思维到硬件电路设计
2026/7/31 14:04:22 网站建设 项目流程

1. 从“电路”到“代码”:Verilog的独特定位

如果你是从单片机或者纯软件编程(比如C、Python)转过来接触FPGA的,那么第一道坎,往往不是复杂的时序约束或者资源优化,而是Verilog这门语言本身。很多人,包括当年的我,都会下意识地把它当成另一种“编程语言”来学,想着“赋值”、“循环”、“条件判断”这些概念都是相通的,应该很快就能上手。但实际一写代码、一上板调试,各种稀奇古怪的问题就来了:仿真结果都对,下载到板子上灯就是不亮;或者逻辑上看起来没毛病,但综合报告里一堆警告,甚至直接报错。

问题的根源就在于,Verilog本质上不是给CPU“执行”的编程语言,而是给工程师“描述”硬件电路的建模语言。这是最核心、也最需要从一开始就扭转的观念。CPU执行软件程序,是一条指令接一条指令的串行过程;而FPGA内部的硬件电路,是上百万甚至上亿个晶体管在通电后同时工作的并行世界。你用Verilog写的每一行代码,最终都会对应到FPGA芯片里真实的连线、寄存器和逻辑门。理解这一点,很多语法现象就不再是“规定”,而是“必然”。

举个例子,在C语言里,你写a = b + c;,意味着CPU去内存里取出b和c的值,送到加法器里计算,然后把结果写回a对应的内存地址。这是一个发生在时间轴上的动作。但在Verilog里,你写assign a = b + c;,你描述的是一个永久的连接关系:一根线(a)永远被连接到一个加法器的输出端,只要b或c的值变化,a几乎同时(考虑到门延迟)就会跟着变化。这里没有“执行顺序”,只有“连接关系”。

所以,学习Verilog语法,脑子里要时刻装着一个虚拟的“电路图”。你写的module就是一个芯片的黑盒子,input/output是它的引脚,wire是盒子内部的导线,reg是边沿触发的存储单元(通常对应触发器),而assignalways块就是在描述这些元器件是如何连接和工作的。本篇我们就来拆解这些构成硬件描述大厦的基本“砖块”,我会结合很多初学时容易误解的点,帮你建立起正确的“硬件思维”。

2. 构建硬件模块:module、端口与数据类型

任何硬件设计都有一个边界,小到一个与门,大到一颗复杂的SOC芯片。在Verilog中,module(模块)就是这个边界容器,它是设计的基本单元,你可以把它理解为一个集成电路的符号,或者一个黑盒子。

2.1 module的定义与实例化:你的第一个“芯片”

一个最基本的module结构如下:

module module_name ( // 端口声明列表 input wire clk, input wire rst_n, input wire [7:0] data_in, output reg [7:0] data_out ); // 模块内部的逻辑描述 endmodule

moduleendmodule是关键字,夹在中间的就是这个模块的全部内容。端口列表定义了它与外部世界交互的所有信号。这里有一个关键细节:端口方向(input/output/inout)是必须声明的,但数据类型(wire/reg)在端口声明处是可选的。上面的写法input wire clk是完整的写法,但通常对于input端口,综合器默认它就是wire型,所以直接写input clk是更常见的做法。而对于output端口,则需要注意:如果这个输出信号是在always块中被赋值的,它必须声明为reg型;如果只是通过assign连续赋值,则它应该是wire型。这是一个常见的语法错误来源。

定义好的module可以被其他module调用,这个过程叫实例化。这就像在电路图上放置一个芯片并连接它的引脚。

module top ( input sys_clk, input sys_rst_n, output [3:0] led ); // 实例化一个名为“my_chip”的模块,实例化名字叫“u_my_chip” my_chip u_my_chip ( .clk (sys_clk), // 将top模块的sys_clk信号,连接到my_chip的clk端口 .rst_n (sys_rst_n), // 连接规则:.模块端口名 (连接到此端口的线网/寄存器名) .data_out (led) // 这里my_chip的data_out端口,驱动了top模块的led输出 ); endmodule

实例化时,通过“.端口名(连接信号)”的语法进行映射,这保证了连接的明确性,顺序也可以任意排列,大大提高了代码的可读性和可维护性。强烈建议为每一个实例化模块添加一个有意义的前缀(如u_`代表一个单元),这在调试大型设计时非常有用。

2.2 信号的血统:wire与reg的深刻区别

这是Verilog初学者最大的困惑点之一,必须彻底讲清楚。

  • wire(线网类型):它代表电路中的一条物理连线。它的值由驱动源决定。你可以想象成一根导线,它本身不存储任何东西,一头接在某个元件的输出端,另一头的值就是这个输出端的值。因此:

    • wire只能被连续赋值(使用assign语句)驱动。
    • 多个assign驱动同一根wire会导致冲突(多驱动),这在电路上对应的是两个输出短路,综合时会报错。
    • input端口默认是wireoutput端口如果只用assign驱动,也应是wire
  • reg(寄存器类型):这个名字极具误导性!它不一定对应最终的硬件寄存器。它只是一个在alwaysinitial块中使用的变量类型。它的关键特征是:在always块中,它可以被过程赋值=阻塞赋值或<=非阻塞赋值)。它最终综合成什么,取决于上下文:

    • 如果reg型变量在一个由时钟沿触发的always块中被赋值,它通常会被综合成一个触发器(D Flip-Flop),也就是真正的寄存器。
    • 如果reg型变量在一个由电平敏感的always块(如always @(*))或initial块中被赋值,它可能被综合成组合逻辑(只是一堆门电路),相当于一根“虚拟的线”。此时它的行为和wire类似,但语法上必须用reg

一个核心记忆口诀wire对应“连线”,用assign驱动;reg对应“变量”,在always块里用。判断用wire还是reg,就看这个信号在哪里、如何被赋值。

2.3 给信号分组:向量与位选取

数字电路很少只处理1比特的信号。我们经常处理8位数据线、16位地址线等。Verilog用向量来表示多比特信号。

wire [7:0] data_bus; // 声明一个8位宽的wire型向量,最高位(MSB)索引为7,最低位(LSB)索引为0 reg [15:0] counter; // 声明一个16位宽的reg型向量

声明语法是[high:low],高位在前,低位在后。这是一种“大端”的位序定义,符合我们书写数字的习惯(左边是高位)。

我们可以方便地访问向量的部分位,这叫位选取

assign led = counter[25:22]; // 取counter的第25位到第22位,赋值给4位led assign sign_bit = data_bus[7]; // 取最高位作为符号位 assign low_byte = data_bus[7:0]; // 取整个字节,等同于 data_bus

位选取的索引必须是常量,这在编译时就能确定。如果需要动态选择位,会用到其他的语法(如+:,-:),那是更进阶的内容。

3. 描述电路行为:赋值、always块与运算符

有了基本的“材料”(module, wire, reg),接下来就需要用它们来“搭建”电路。Verilog提供了两种主要的描述方式:连续赋值过程块

3.1 连续赋值语句:描述组合逻辑的直通车

assign语句用于连续赋值,它描述的是信号之间持续的、并行的驱动关系。一旦等式右边的任何信号发生变化,左边的信号会立即重新计算并更新(在仿真中考虑门延迟,在逻辑上认为是立即的)。

assign out = a & b; // 描述一个与门 assign sum[3:0] = a[3:0] + b[3:0]; // 描述一个4位加法器 assign flag = (counter == 8‘d100); // 描述一个比较器,当counter等于100时,flag为1

assign只能驱动wire型信号。它非常适合描述简单的组合逻辑,如门电路、加减器、多路选择器等。它的逻辑是纯粹并发的,书写顺序不影响功能。

3.2 过程块:行为描述的核心

always块是描述时序逻辑和复杂组合逻辑的核心结构。它内部的语句是顺序执行的(指仿真语义),但多个always块之间是并行执行的。always块像一个敏感的进程,当敏感列表中的事件发生时,块内的代码就会被执行。

敏感列表always块的触发器:

  • always @(posedge clk):在时钟clk的每个上升沿触发。这是描述同步时序逻辑的标准形式,块内定义的reg通常会被综合成触发器。
  • always @(negedge rst_n):在复位信号rst_n的下降沿触发。通常用于异步复位逻辑。
  • always @(*)always @(a or b or sel):电平敏感。只要敏感列表中的任何一个信号发生变化,块内的代码就立即执行。这是描述组合逻辑的推荐方式。使用@(*)可以让编译器自动推断敏感列表,避免遗漏导致仿真与综合不一致的陷阱。

一个经典的带异步复位的计数器例子,展示了时序逻辑的写法:

reg [7:0] count; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin // 异步复位:只要rst_n变低,立即清零,不等待时钟沿 count <= 8‘d0; end else begin // 同步逻辑:在时钟上升沿,如果使能有效,则计数加1 if (en) begin count <= count + 8‘d1; end end end

3.3 过程赋值:阻塞与非阻塞的“世纪难题”

always块内,有两种赋值方式,它们的区别至关重要,是硬件并发思维与软件顺序思维冲突的集中体现。

  • 阻塞赋值=:像C语言一样,立即计算右侧表达式,并立即更新左侧变量的值。在同一条块内,后续语句读取该变量时,读到的是新值

    always @(posedge clk) begin a = b; // 语句S1:此刻a立刻变成b的值 c = a; // 语句S2:此刻a已经是b的值,所以c被赋值为b的值 end

    这描述的行为更像软件:先执行S1,再执行S2。在可综合的组合逻辑always @(*)块中,使用阻塞赋值有时可以写出简洁的代码,但需要非常小心顺序。

  • 非阻塞赋值<=:在always开始执行时,计算所有非阻塞赋值语句右侧的表达式,但直到整个always块执行结束时,才统一更新所有左侧变量的值。块内语句执行期间,任何读取操作得到的都是变量的旧值

    always @(posedge clk) begin a <= b; // 步骤1:记录下b当前的值,准备赋给a c <= a; // 步骤2:记录下a**当前(旧)**的值,准备赋给c // 步骤3:块结束,同时将步骤1和步骤2记录的值,更新到a和c中 end

    最终结果是:c得到的是a在时钟沿到来时的旧值,而a被更新为b的值。这完美描述了一组触发器在同一个时钟沿同时更新的硬件行为。

黄金法则

  1. 在描述时序逻辑(时钟沿触发的always块)时,一律使用<=(非阻塞赋值)。这是铁律,违反它几乎必然导致难以调试的电路功能错误。
  2. 在描述组合逻辑(电平敏感的always @(*)块)时,使用=(阻塞赋值)。这可以避免仿真出现不必要的延迟,也更符合组合逻辑的即时性。
  3. 严禁在同一个always块内混合使用阻塞和非阻塞赋值给同一个变量
  4. 简单组合逻辑直接用assign更清晰。

3.4 运算符:硬件世界的数学与逻辑

Verilog的运算符大部分和C语言类似,但有一些硬件特有的含义:

  • 位运算符&(与),|(或),~(非),^(异或)。它们按位操作。
  • 逻辑运算符&&(逻辑与),||(逻辑或),!(逻辑非)。它们将整个操作数视为布尔值(非零即真),返回单比特的0或1。
  • 缩减运算符:这是硬件特色。它对向量的所有位进行位操作,最终坍缩为1比特。
    wire [7:0] data; wire all_ones; assign all_ones = &data; // 等同于 data[7] & data[6] & ... & data[0]。只有data所有位都为1,结果才是1。 assign parity = ^data; // 异或缩减,常用于产生奇偶校验位。
  • 移位运算符<<(左移),>>(右移)。空出的位补0。注意,Verilog没有算术右移(补符号位)的原生运算符,需要自己处理。
  • 拼接运算符{}可以将多个信号拼接成一个新的向量。
    wire [3:0] a, b; wire [7:0] c; assign c = {a, b}; // c[7:4] = a, c[3:0] = b assign led = {4{1‘b1}}; // 复制运算符,得到4‘b1111

4. 构建复杂逻辑:条件、循环与结构描述

硬件描述语言也需要分支和循环结构,但它们的硬件含义与软件截然不同。

4.1 条件语句:if-else 与 case

  • if-else:用于描述优先级编码的逻辑或带使能的路径选择。

    always @(*) begin if (sel == 2‘b00) out = a; else if (sel == 2‘b01) out = b; // 此条件比上一级优先级低 else if (sel == 2‘b10) out = c; else out = d; end

    综合器会将其实现为一个多级选择器(MUX)链。注意,如果可能,必须写全else分支,否则在组合逻辑中会推断出锁存器,这通常是设计错误。

  • case:用于描述多路选择,所有分支是并行平等的(除非使用casex/casez,但慎用)。

    always @(*) begin case (state) 2‘b00: next_state = IDLE; 2‘b01: next_state = RUN; 2‘b10: next_state = DONE; default: next_state = IDLE; // default必不可少,避免锁存器! endcase end

    case语句通常比同等功能的if-else链综合出的电路更优化。default子句是良好习惯,能避免未覆盖情况下的意外行为。

4.2 循环语句:generate与for的硬件化思考

软件中的for循环是“重复执行一段代码”,而硬件中的“循环”意味着“复制出一堆相同的电路结构”。Verilog中用于生成硬件的循环主要是generate for

genvar i; // 必须用genvar作为循环索引变量 generate for (i=0; i<8; i=i+1) begin: gen_loop // 给循环块起个名字(gen_loop) assign data_out[i] = data_in[7-i]; // 生成8条并行的连线,实现位反转 end endgenerate

generate for是在编译时(Elaboration)展开的,它生成了8条独立的assign语句。它不能用于描述随时间重复的行为(那需要状态机)。普通的for循环也可以在always块中使用,但综合器会将其展开为并行的硬件。如果循环次数是编译时常量,它通常能正确综合;如果循环次数是动态的,则可能无法综合。

4.3 函数与任务:代码的封装与复用

为了提升代码的可读性和复用性,Verilog提供了functiontask

  • function(函数):用于纯组合逻辑的计算,不能包含任何时序控制语句(如#delay,@,wait)。它必须至少有一个输入,返回一个值。

    function [7:0] calculate_checksum; input [7:0] byte_data; begin calculate_checksum = ~byte_data + 1‘b1; // 简单的补码计算 end endfunction // 调用 assign checksum = calculate_checksum(data_byte);
  • task(任务):比函数更强大,可以包含时序控制、事件触发,可以没有或有多个输出。它更像一个过程块。

    task signal_drive; output sig; begin sig = 1‘b0; #10; // 延迟10个时间单位(仅用于仿真!不可综合) sig = 1‘b1; #10; sig = 1‘b0; end endtask // 调用 initial begin signal_drive(test_wave); end

    关键点task中使用的延迟#和事件控制@不可综合的,仅用于编写测试激励(Testbench)。在可综合的设计代码中,应避免使用task,或仅使用其中可综合的部分。

5. 从代码到电路:可综合风格与核心禁忌

写Verilog的最终目标,是让综合器(如Vivado、Quartus里的工具)能正确地将你的代码翻译成FPGA的逻辑门、触发器和布线资源。并非所有合法的Verilog语法都是可综合的。遵循可综合风格,是FPGA工程师的基本素养。

5.1 可综合子集:什么能变成硬件?

以下通常是可综合的:

  1. module定义、实例化。
  2. 端口声明、wirereg(在always块中)声明。
  3. assign连续赋值语句。
  4. 带有时钟沿或电平敏感列表的always块。
  5. 块内的if-elsecase选择语句。
  6. 阻塞赋值=(在组合逻辑always块中)和非阻塞赋值<=(在时序逻辑always块中)。
  7. 算术、逻辑、位、移位、拼接、缩减运算符。
  8. generate for循环。
  9. function(仅包含组合逻辑的)。

5.2 核心禁忌与“地雷”

以下语法或写法不可综合会导致糟糕的电路,必须避免在设计代码中使用:

  1. initial:用于仿真初始化,综合器会忽略它。给寄存器赋初值应使用复位信号。
  2. #delay延时语句:这是仿真行为,实际电路没有“延时10ns”这种精确控制。时序控制靠时钟和同步设计。
  3. waiteventfork/join:复杂的进程控制语句,一般不可综合。
  4. whileforeverrepeat循环:除非循环次数在编译时可确定(会被展开),否则不可综合。for循环需谨慎使用。
  5. casex/casez:在综合时,xz被视为不关心态,可能导致综合结果与仿真严重不符,除非非常明确其语义,否则建议用明确的casedefault替代。
  6. 不完全的条件分支(组合逻辑中):在描述组合逻辑的always @(*)块中,如果ifcase语句没有覆盖所有可能的输入情况,综合器会推断出锁存器。锁存器对毛刺敏感,在FPGA中通常不是期望的存储元件,且可能导致时序问题。
    // 错误示例:会产生锁存器! always @(*) begin if (en) begin q = d; end // 缺少 else 分支!当en为0时,q应该保持什么值?综合器会生成一个锁存器来“保持”原值。 end // 正确写法:补全分支 always @(*) begin if (en) begin q = d; end else begin q = 1‘b0; // 或者 q = q; (但这样还是锁存器,通常我们指定一个默认值) end end
  7. 在多个always块或assign语句中对同一变量赋值:这会产生“多驱动”,如同一条线被多个输出驱动,是硬件冲突。
  8. 使用浮点数、实数类型:FPGA主要处理定点数和整数。浮点运算需要专门的IP核或自己用定点数实现。

5.3 编写可维护的代码:风格建议

  1. 时钟与复位:统一使用明确的时钟沿(posedge clk)和低电平有效的异步复位(negedge rst_n)作为时序逻辑的模板。
  2. 命名规范
    • 信号名:小写加下划线,如data_valid,wr_en
    • 模块名:首字母大写,如UartTx
    • 实例化名:带前缀,如u_uart_tx,i_delay_line
    • 常量/参数:全大写,如DATA_WIDTH
  3. 参数化设计:使用parameterlocalparam来定义常量,提高代码复用性。
    module shift_register #( parameter WIDTH = 8, parameter DEPTH = 4 )( input clk, input [WIDTH-1:0] din, output [WIDTH-1:0] dout ); reg [WIDTH-1:0] regs [0:DEPTH-1]; // ... 参数化的逻辑 endmodule
  4. 注释:不仅注释“做了什么”,更要注释“为什么这么做”,尤其是对某些非常规操作或为了规避某个工具Bug而写的代码。

理解并熟练运用这些基本语法,是开启FPGA设计之旅的坚实第一步。记住,始终用“电路图”来思考你的Verilog代码,每写一行,都想一想它对应着芯片里的哪一部分物理结构。当你开始习惯这种思维模式,你会发现Verilog不再是一门陌生的语言,而是你与硅晶世界对话的桥梁。在接下来的实践中,你会遇到更多具体的场景和挑战,但万变不离其宗,这些基本语法常识就是应对所有复杂问题的基石。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询