
wire 用于组合逻辑)和状态保持变量(reg 用于时序逻辑)。=)对于建模时序逻辑至关重要,因为它们通过模拟触发器的并行行为来防止竞争冒险。要构建为我们世界提供动力的复杂数字系统,从微处理器到庞大的通信网络,工程师需要一种能够表达结构、并发和时序的语言。这种语言就是 Verilog。对于许多习惯于顺序编程的新手来说,Verilog 的学习曲线非常陡峭,因为它的语法和规则看似随意。然而,掌握它的关键在于视角的根本转变:我们不是在为计算机编写指令,而是在描述一个物理的、并行的世界的蓝图。本文便是通往那个世界的指南。
接下来的章节将解构 Verilog 的核心原则,从其基本理念过渡到实际应用。在“原理与机制”一章中,我们将探讨 Verilog 世界的基本法则——作为蓝图的 module、无状态 wire 和有状态 reg 之间的关键区别,以及阻塞赋值和非阻塞赋值之间微妙而至关重要的差异。在“应用与跨学科联系”一章中,我们将看到这些原则的实际运用,构建从基本逻辑门到解决数字信号处理和通信等领域真实工程挑战的复杂系统,揭示 Verilog 如何构筑从抽象概念到实体硅片的关键桥梁。
要真正理解一门语言,你必须理解它为描述哪个世界而生。当我们学习像 Python 或 C++ 这样的编程语言时,我们是在学习为一位勤奋但头脑简单的工人——CPU——编写一系列指令,让它逐一执行。Verilog 则不同。它不是一门用于编写指令的语言,而是一门用于描述一个宇宙的语言。它所描述的宇宙是电路的宇宙:一个由门、线网和触发器组成的世界,所有这些都在同时、并行地运行。忘记这一事实是大多数新手感到困惑的根源。一旦你理解了这一点,Verilog 的规则就不再是随意的约束,而是变成了这个并行现实中优美而合乎逻辑的法则。
每一个伟大的建设项目都始于一张蓝图。在 Verilog 的世界里,这张蓝图就是 module。模块是对一块硬件的独立描述。它定义了自身的边界以及与外部世界的接口——即它的输入和输出端口。
在最基本的形式中,模块只是一个命名的容器,一个由 module 和 endmodule 关键字定义的概念性盒子。例如,一个完全空的模块在语法上是有效的,尽管没什么用。它只是声明了自身的存在。
当我们给这个盒子一些引脚以连接到外部世界时,它真正的威力才显现出来。我们在模块名之后的一个列表中定义这些连接,即端口。现代 Verilog 为此采用了一种简洁的、类似 C 语言的风格,你可以在一个地方声明端口的方向(input、output)、类型和名称。例如,一个 8 位数据寄存器的蓝图可能如下所示:
注意 [7:0] 这个表示法。它声明了一条“总线”或一个包含 8 根线网的向量,编号从 7 向下到 0。我们正在描述一个组件,它有一个时钟引脚、一个 8 位数据输入 d 和一个 8 位数据输出 q。现在,这个模块成了一个可重用的组件,一张我们可以反复使用的蓝图。
没有复杂的机器是由单一的整体部件构成的。汽车由发动机、底盘和车轮组成;发动机又由活塞和气缸组成。数字系统也是如此。我们通过连接更简单的组件来构建复杂的系统。这就是层次化设计的原则。
想象一下,你有一张 full_adder 组件的蓝图。你想通过连接两个这样的全加器来构建一个 two_bit_adder。程序员的第一直觉可能是在 two_bit_adder 内部定义 full_adder。但在硬件世界里,这毫无意义。你不会在房子的蓝图里设计砖块的蓝图。你应该分开设计砖块的蓝图,然后在房子的蓝图中指定砖块的摆放位置。
Verilog 遵循这种物理逻辑。一个 module 不能在另一个 module 内部定义。它们都是顶层蓝图。正确的构建方式是,首先定义所有组件的蓝图,然后在一个更大的模块内部,创建这些组件的实例并将它们连接起来。
放置组件的这个行为被称为实例化。我们创建了 full_adder 蓝图的两个实例,分别命名为 fa0 和 fa1。然后我们用 wire(线网)来连接它们的端口。wire 顾名思义:它就是一个将信号从一点传输到另一点的连接。在上面的例子中,第一个加法器 (fa0) 的进位输出通过 carry_out_0 这根线网成为了第二个加法器 (fa1) 的进位输入。有时,对于简单的连接,即使你没有声明,Verilog 也足够聪明,知道需要一根线网。这被称为隐式线网,是在连接整个宇宙的宏伟计划中的一个小便利。
我们现在来到了 Verilog 的核心,这个概念将它与软件编程最深刻地区分开来。一个 Verilog 设计在两个并行的世界中运行:一个是连续、无时序的逻辑世界,另一个是离散、有定时的事件世界。
想象一个简单的与门。它的输出永远是其输入的逻辑与。它不会“执行”或“运行”;它就是这样存在。这就是组合逻辑,在 Verilog 中使用连续赋值来描述。
assign y = a b;
这行代码的意思不是“取 a 和 b 的值,将它们相与,然后把结果放入 y”。它的意思是“声明 y 等于,并且永远等于 a 与 b 的与运算结果”。如果 a 或 b 发生变化,y 会立即自动改变,就好像通过一根物理导线连接到一个真实门电路的输出端一样。
这个世界的天然居民是 wire。wire 是一种简单的导体。它不存储任何东西;它只是传递一个由其驱动源决定的值。它是一根管道,而不是一个桶。因为 wire 代表一个连续的连接,它只能由同样是连续性的东西驱动,比如 assign 语句或模块实例的输出。
但并非所有硬件都是简单的组合逻辑。有些部分需要有记忆,需要保持一个状态,并且只在特定时刻改变那个状态——例如,在时钟信号的上升沿。这就是时序逻辑。为了描述它,我们使用像 always 这样的过程块进入定时事件的世界。
@(posedge clk) 部分是事件控制;它告诉代码块只在时钟信号 clk 从低电平转换到高电平的精确时刻“醒来”并执行操作。在这些时钟沿之间,信号 q 必须记住它的值。它需要有记忆功能。
为此,Verilog 提供了 reg 数据类型。reg 这个名字是一个著名的误称;它并不总是意味着它会成为一个物理寄存器(如触发器)。它真正、根本的含义是:“一个能在过程块的赋值操作之间保持其值的变量”。它是一个桶,而不是一根管道。你不能将它连接到一个持续流动的 assign 语句上。你只能在 always 这样的过程块所规定的离散时间点上为其赋值。
这就引出了 Verilog 数据类型的黄金法则:
wire 并用 assign 语句来驱动它。reg 并在 always(或 initial)块内为其赋值。试图在 always 块内为一个 wire 赋值,就像对着一根铜管大喊“记住这个电压!”。这是对物理模型的无理违背,Verilog 会报告一个错误。
让我们仔细看看 always 块。它描述了当一个事件发生时要执行的一系列动作。但是硬件是并行的。如果我们想描述两个应该在完全相同的时间发生的动作该怎么办?例如,交换两个寄存器 a 和 b 的值。
程序员可能会这样写:
a = b;
b = a;
在顺序语言中,这样做是失败的。如果 a 是 0,b 是 1,第一行会将 a 设为 1。然后第二行会将 b 设为 a 的新值,也就是 1。我们最终得到 a 和 b 都为 1。a 的原始值丢失了。
这就是阻塞赋值 (=) 的行为。它会“阻塞”后续语句的执行,直到自身完成,就像在标准编程语言中一样。但这种方式通常无法正确地模拟硬件的并行特性。
Verilog 提供了一个更优美的工具:非阻塞赋值 (=)。
让我们再试一次交换操作,这次放在两个独立的 always 块中以强调它们的并发性,并使用非阻塞赋值:
奇妙之处就在于此。= 运算符意味着:“在时钟沿,采样所有在右侧的值。然后,安排所有在左侧的变量在时间步结束时,用这些采样到的值同时更新。”
所以,在时钟沿:
b 是 1。它安排 a 变为 1。a 是 0(它的原始值!)。它安排 b 变为 0。a 变为 1,b 变为 0。交换完美成功!这种机制优美地模拟了真实触发器的行为方式。它们都在时钟沿采样输入,并在稍后一同改变输出。在这种场景下使用阻塞赋值 (=) 会造成竞争冒险——最终结果将取决于模拟器决定首先执行哪个 always 块,这对于可预测的硬件来说是一场灾难。
以下是指导原则:
always 块中),使用非阻塞赋值 (=) 来模拟触发器的并行行为。always @(*) 块中),使用阻塞赋值 (=),因为你想描述的是在一个时间步内得出最终结果的一系列计算。最后,至关重要的是要记住,Verilog 描述最终注定要成为一个真实的物理电路。将描述翻译成门和触发器布局的过程称为综合。综合工具就像一个读取你蓝图的工厂,但它有严格的物理规则。
最重要的规则是:一个信号只能有一个驱动源。在物理世界中,你不能将两个不同门电路的输出连接到同一根导线上,并让它们同时尝试输出不同的值(例如,一个驱动高电平,一个驱动低电平)。这会导致短路。
如果你编写的 Verilog 代码从两个不同的 always 块中对同一个 reg 进行赋值,你就是在描述这种不可能的物理情况。虽然模拟器可能会尝试解决这个问题(通常是非确定性的,导致竞争冒险),但综合工具会直接失败,报告一个“多驱动源”错误。这是工具在告诉你,你描述了物理上不可能实现的东西。要从多个源控制一个信号,你必须在单个驱动块内描述选择逻辑,例如使用一个多路选择器。
理解这些原则——作为蓝图的模块、wire 和 reg 的两个世界、assign、always、= 和 = 的精妙协作,以及综合这个根植于现实的基础——是掌握 Verilog 的关键。它不仅是一门编写代码的语言,更是一门设计宇宙的语言。
正如物理定律以数学为语言,描绘了宇宙的宏伟舞蹈,也存在一种语言,用以描述硅芯片内部电子微观而复杂的芭蕾。这种语言就是 Verilog。学习它,你会发现它并非传统意义上由一连串命令组成的“编程语言”,而是一种硬件描述语言(HDL)。你不是告诉机器一步步做什么,而是描述一个结构——一个由互连逻辑组成的系统,一个由微小决策者构成的网络——而这个描述本身,就成为了物理现实的蓝图。
Verilog 的原理和机制并非单纯的学术练习。它们是构建我们现代世界的艺术与科学的日常工具。让我们踏上一段旅程,从逻辑的基本原子到复杂系统的架构,看 Verilog 如何将抽象的思想世界与有形的硅片世界连接起来。
每一项宏伟的计算,无论是渲染一部逼真的电影,还是模拟蛋白质的折叠,最终都由无数简单、原始的操作组成。Verilog 的强大之处在于它能够以优美而直接清晰的方式描述这些基本的逻辑行为。
思考一个最基本的操作:减法。在处理器算术逻辑单元的深处,处理一个比特减去另一个比特的是一个称为半减法器的电路。“差”比特的逻辑是一个简单的异或 (XOR) 函数,数学上表示为 。在 Verilog 中,这种抽象关系变成了一个直接的物理描述:assign Difference = A ^ B;。纸面上的符号直接映射到芯片上晶体管的配置。
这种表达能力在更复杂但同样基础的应用中大放异彩。在数字通信中,数据可能被噪声破坏——一个‘1’可能会翻转成‘0’。我们如何检测这种错误?一种常见的技术是为每个字节的数据添加一个“奇偶校验位”。对于奇校验方案,选择的奇偶校验位会使字节(加上校验位)中‘1’的总数为奇数。计算这个需要检查所有的数据位。Verilog 提供了归约运算符,可以用一行优雅的代码完成这个任务,而不必编写冗长的逻辑链。通过这种方式可以构建一个紧凑的奇校验生成器,为从计算机内存到卫星传输的各种应用提供抵御数据损坏的第一道防线。这就像问一整个房间的人,站着的人数是否为奇数,并立即得到一个单一的答案。
计算机不仅仅是计算器;它是一位物流大师,不断地在正确的时间将数据移动到正确的位置。Verilog 就是我们用来为这种信息流设计“高速公路”和“十字路口”的语言。
数字设计中典型的交通控制器是多路选择器,或称“mux”。就像铁路道岔一样,它从多个输入数据流中选择一个传递到输出。处理器是从内存中取指令,还是读取上一次计算的结果?多路选择器做出了选择。借助 Verilog 强大的参数化功能,我们不必为每一种可能的数据宽度都费力地设计新的多路选择器。我们可以创建一个通用的 2-1 多路选择器蓝图,它可以动态配置以处理 8 位、16 位甚至 256 位的数据路径,使我们的设计可重用且可扩展。
有时我们需要相反的操作:知道多条输入线中的哪一条是激活的。编码器执行此任务,将一个“独热码”(one-hot)信号(多条线中只有一条为高电平)转换为代表该线索引的紧凑二进制数。这对于需要响应多个可能事件之一的电路至关重要,例如键盘控制器识别哪个键被按下。
但是当多个设备需要共享同一条数据高速公路或“总线”时会发生什么?如果两个组件试图同时“通话”,它们的信号——导线上的电压——会发生碰撞,产生无用数据。解决方案是一种叫做三态缓冲器的巧妙设备。它可以传输‘0’、传输‘1’,或进入第三种状态:高阻态。在这种‘z’状态下,缓冲器实际上将自己与导线断开,变得电学上不可见。这使得一个设备可以发言,而总线上的所有其他设备则礼貌地倾听。Verilog 建模这种高阻态 1'bz 的能力不是一个次要特性;对于设计任何带有共享资源的系统,从你笔记本电脑的内存总线到片上系统(System-on-Chip)的内部工作,这都是绝对必要的。
到目前为止,我们的电路都是纯组合逻辑的——它们的输出仅仅是其当前输入的函数。它们没有记忆。要构建任何真正有趣的东西,从简单的计数器到整个中央处理器,我们都需要时间和状态的概念。
D 型触发器是数字世界中内存的基本原子。它做一件简单而神奇的事情:在一个精确的时刻——时钟的滴答声中——它捕获输入端的一个比特数据,并将其值稳定地保持在输出端,直到下一个时钟滴答。这就是电路记忆的方式。
Verilog 允许我们以极其精确的控制来描述这些状态保持元件。我们可以指定一个触发器的状态只应在 posedge clk 时刻改变,即时钟信号从低电平转换到高电平的精确瞬间。我们可以添加复杂的控制层,例如一个低电平有效的异步清零(clr_n),它会立即将状态强制为‘0’,而不管时钟如何,提供了一个至关重要的复位机制。或者我们可以添加一个同步使能(en),它指示触发器忽略时钟滴答并保持其先前的值。正是通过数百万个这样的触发器协同一致、富有节奏的舞蹈,全部跟随着同一个时钟节拍,才得以实现现代处理器复杂的时序行为。
有了这些构建块,我们就可以从简单的逻辑上升到面对引人入胜的系统级挑战,将数字设计与其他科学和工程学科联系起来。
当芯片的两个部分在不同、独立的时钟下运行时,会发生什么?比如一个快速的 CPU 与一个慢速的外设通信。这就像两个人试图跟着不同鼓点的节拍击掌。如果 CPU 的信号在外设时钟滴答的瞬间发生变化,外设的输入触发器可能会被驱动进入一个物理上不稳定的状态,称为亚稳态。它会卡住,在一段不可预测的时间内,在一个既不是有效‘0’也不是‘1’的中间电压状态下颤抖。这不是一个理论问题;这是一个真实的物理现象,可能导致灾难性的系统故障。
工程解决方案既优雅又有效:两级触发器同步器。异步信号首先被送入目标时钟域的一个触发器,然后其输出再被送入第二个触发器。第一个触发器被允许进入亚稳态。但是,通过给它一个完整的时钟周期来恢复到稳定的‘0’或‘1’,然后再让第二个触发器采样其输出,我们可以将失败的概率降低到无穷小的值。描述这个两级寄存器链的简单 Verilog 代码掩盖了其所解决的物理问题的深刻性——这是一个我们如何使用数字抽象来驯服现实世界中不羁的模拟物理学的优美例子。
在数字信号处理(DSP)等领域,数学运算必须符合被处理信号(如音频或视频)的特性。想象一下,你正在为音乐合成器设计一个 DSP 芯片。如果你将两个 8 位有符号音频样本相乘,结果可能需要多达 16 位才能精确表示。如果你简单地丢弃多余的位(截断),一个大的正数结果可能会“环绕”变成一个大的负数。在音频流中,这会产生一个响亮、刺耳的“爆音”或“咔哒声”。
正确的方法是饱和运算。如果计算结果超过了可表示的最大值(比如 8 位有符号数的 127),输出就被“钳位”到那个最大值。同样,如果它低于最小值(-128),它就被钳位到最小值。这可以防止溢出失真,对高保真音频和视频处理至关重要。Verilog 非常适合描述这一点。我们可以通过先在一个临时的、更宽的寄存器中计算出全精度乘积,然后使用简单的条件逻辑检查该乘积是否超出目标范围,最后再赋给最终的钳位值,来建模一个饱和乘法器。这确保了最终的硬件能够完美实现 DSP 算法所要求的行为。
现代集成电路很少像一次性的定制雕塑那样被设计。它们被设计成高度灵活和可配置的平台。一家公司可能想销售一款设备的“基础版”和“专业版”。“专业版”可能包含大量的片上调试硬件,这会消耗面积和功耗,而“基础版”为了节省成本则会省略它。
无需费力地维护两个独立的设计,可以使用 Verilog 的 generate 结构编写一个单一的、智能的代码库。这些强大的指令在电路综合之前的“精化”(elaboration)阶段被评估。一个 if-generate 语句可以检查一个参数的值——比如 DEBUG_LEVEL——并有条件地实例化不同的模块。如果 DEBUG_LEVEL 是 2,它可能会实例化一个 full_debug_monitor;如果是 1,则实例化一个 basic_status_reg;如果是 0,它可能会将状态端口接地,并且什么也不实例化。这种条件生成的范式,连同参数化,是现代基于 HDL 的设计的基石,它允许单一的 Verilog 源码生成一整个家族的相关、优化的芯片。
工程师有句格言:每一行设计代码背后,都有十行验证代码。如果不能证明一个拥有十亿晶体管的芯片的蓝图在所有可能条件下都能正确工作,那么这个蓝图就一文不值。在这方面,Verilog 同样是必不可少的工具。
这门语言不仅用于描述电路本身(“被测设备”),还用于创建虚拟世界,以便对该电路进行严格测试。这种模拟环境被称为测试平台 (testbench)。对于同步设计,任何测试平台的关键组件都是时钟生成器。使用一个简单的 initial 块和一个 forever 循环,几行 Verilog 代码就能产生一个完美的周期性时钟信号来驱动整个仿真。更复杂的测试平台会成为完整的虚拟系统,生成激励、向设计发送数据包、将输出与参考模型进行比对,并报告任何差异。这整个“设计-然后-验证”的周期,全都在 Verilog 生态系统内进行,正是它使得现代微电子学的伟业成为可能。
从最简单的逻辑门到多时钟系统的微妙时序挑战,从布尔代数的纯粹抽象到饱和运算和亚稳态的复杂现实,Verilog 是联结概念与物理的语言。它是构建数字时代的知识脚手架。掌握它不仅仅是学习一种语法,而是学习以一种可以直接转化为物理工作机器的方式来思考结构、时间和信息。归根结底,它是从一个稍纵即逝的想法通向你手中强大集成电路的桥梁。
module my_empty_box;
endmodule
module data_register(
input clk,
input [7:0] d,
output [7:0] q
);
// The internal workings of the register go here...
endmodule
// Blueprint for the component
module full_adder( ... );
// ... logic for a full adder
endmodule
// Blueprint for the larger system using the component
module two_bit_adder( ... );
wire carry_out_0; // A wire to connect the two adders
// Place the first full_adder component
full_adder fa0 ( .a(a[0]), .b(b[0]), .cin(cin), .cout(carry_out_0), ... );
// Place the second full_adder component
full_adder fa1 ( .a(a[1]), .b(b[1]), .cin(carry_out_0), .cout(cout), ... );
endmodule
always @(posedge clk) begin
q = d;
end
// Initial state: a = 0, b = 1
always @(posedge clk)
a = b;
always @(posedge clk)
b = a;