在很长一段时间里,当我们想要解决一个数学问题或者处理数据时,我们习惯于使用 C、Python 或 Java 这样的计算机编程语言。这些语言的特点是代码执行是顺序的——一行代码执行完,才执行下一行。但是,当我们试图用这种方式来描述数字电路(比如 CPU 内部的数亿个晶体管是如何同时工作的)时,传统语言就显得力不从心了。
这就是硬件描述语言(HDL)大显身手的时候了。HDL 允许我们使用文本代码来并发地描述硬件系统。目前,HDL 领域主要有两种核心语言:
- Verilog HDL:以其简洁和类 C 语法而闻名,易于上手。
- VHDL:虽然语法更为严谨和冗长,但在某些军工和航天领域应用广泛。
在这篇文章中,我们将主要专注于 Verilog。我们将一起探索它是如何工作的,以及我们如何通过它来设计 FPGA 和 ASIC 芯片。
什么是 Verilog?
简单来说,Verilog 是一种用于描述数字系统的硬件描述语言。它不仅仅是一个仿真工具,更是现代芯片设计(RTL 设计)和验证(测试平台开发)的核心工具。无论是我们在 FPGA 上实现一个复杂的逻辑控制,还是流片一颗专用的 ASIC(Application-Specific Integrated Circuit),Verilog 都是我们手中的利剑。
与软件编程不同,Verilog 允许我们在不同的抽象层次上查看和设计电路。想象一下,如果你在盖房子,你可以选择从砖块(门级)的角度描述,也可以选择从房间功能(行为级)的角度描述。在 Verilog 中,主要有以下三种抽象层次:
- 门级建模:关注最底层的逻辑门(如与门、非门)。
- 数据流建模:关注数据如何在逻辑之间流动。
- 行为级建模:关注电路的功能和行为,最接近人类思维。
在深入探讨这些层次之前,我们需要先搞懂一个贯穿始终的概念:RTL。
#### 寄存器传输级 (RTL)
你可能会经常听到 "RTL 代码" 这个词。寄存器传输级(Register Transfer Level) 是数字设计中的一个特定抽象层次。它不是纯粹的算法描述(比如 C 语言),也不是纯粹的晶体管连接图。
在 RTL 层面,我们规定数据如何在寄存器(存储单元)之间传输,以及在这些传输过程中进行什么样的逻辑操作。它是连接高级行为描述和底层物理实现之间的桥梁。简单来说,我们编写的大部分 Verilog 代码,其实都是在编写 RTL 代码。
1. 门级建模
当一个电路完全由基本逻辑门( primitives,如 AND, OR, NAND, XOR 等)组成时,我们称之为门级建模。这就像是在画电路原理图,只不过是用文字来画。这种层次非常贴近硬件物理结构,适合用于对时序要求极其精确或者手动优化关键路径的场景。
让我们来看一个经典的例子:半加器。它由一个异或门(XOR,用于求和)和一个与门(AND,用于生成进位)组成。
// 模块声明:定义输入和输出端口
// half_adder 是模块名,a, b 是输入,sum, carry 是输出
module half_adder(input a, input b, output sum, output carry);
// 实例化原语门
// 这里的 x1 是我们给这个异或门起的名字(实例名)
// 语法:门类型 实例名 (输出端口, 输入端口1, 输入端口2)
xor x1(sum, a, b);
// a1 是与门的实例名
and a1(carry, a, b);
endmodule
代码解析:
在这个例子中,我们显式地调用了 Verilog 内置的原语(INLINECODEff781ae3 和 INLINECODEa76e9627)。这种写法非常直观,就像在连接导线。但是,如果电路很复杂(比如一个 32 位加法器),用这种方式去写每一个门是非常痛苦且容易出错的。这就引入了我们的下一个层次。
2. 数据流建模
为了提高效率,我们可以不关心具体的门是什么,而是关心数据的流动和逻辑运算。在 Verilog 中,我们使用关键字 assign 来实现数据流建模。这被称为连续赋值。
关键点: 只要 assign 右边的表达式发生变化,左边的信号就会立即更新。这与电路中电信号传播的特性是一致的。
让我们用数据流的方式重写半加器:
module half_adder_df(input a, input b, output sum, output carry);
// 使用 assign 关键字进行连续赋值
// 这里的 ^ 和 & 分别是 Verilog 中的异或和与运算符
// 描述逻辑:sum 始终等于 a 异或 b
assign sum = a ^ b;
// 描述逻辑:carry 始终等于 a 与 b
assign carry = a & b;
endmodule
实用见解:
你会发现代码变得非常简洁。在实际工程中,当我们在进行组合逻辑设计(没有时钟信号,仅由当前输入决定输出的逻辑)时,assign 是最常用的手段之一。例如,我们要实现一个简单的多路选择器(MUX),数据流建模是首选:
// 2选1多路选择器示例
module mux2to1(input d0, input d1, input sel, output out);
// 如果 sel 为 1,选择 d1;否则选择 d0
// 语法:条件表达式 ? 真值 : 假值
assign out = (sel) ? d1 : d0;
endmodule
3. 行为级建模
这是抽象层次最高、也是最像软件编程的建模方式。在行为级建模中,我们不再描述电路的具体结构(是门还是连线?),而是描述电路应该做什么。我们使用 always 块来包裹逻辑。
这种方式的强大之处在于,我们可以使用高级编程语句(如 INLINECODE699f5f0b, INLINECODE82e54433, for 循环)。综合器会自动将我们的行为描述转换成底层的门级电路。
让我们用行为级的方式再次实现半加器:
module half_adder_bh(input a, input b, output reg sum, output reg carry);
// always @(...) 是一个敏感列表
// @(*) 是 Verilog 2001 引入的语法,表示“自动敏感”
// 意思是:只要 a 或 b 发生变化,就执行下面的块
always @(*) begin
// 在 begin...end 之间编写类似 C 语言的逻辑
sum = a ^ b;
carry = a & b;
end
endmodule
为什么这里要用 output reg?
这是初学者最容易踩的坑之一。在 INLINECODEa8c91672 块中被赋值的信号,必须被定义为 INLINECODEfcdfb046 类型。但这并不意味着它一定会生成一个物理寄存器,它只是综合器识别的一种变量类型。
Verilog 的词法基石
想要写出漂亮的 Verilog 代码,我们需要掌握它的基本词汇——也就是词法标记。这就像我们要先学会字母和单词才能写出文章。
#### 空白字符
在 Verilog 中,空格、制表符(INLINECODEfc5c97d1)和换行符(INLINECODE8d2e29d1)通常是会被忽略的(除了在字符串或某些特定情况下)。这意味着你可以自由地缩进代码,使其美观易读。
实用技巧: 尽管代码格式不影响逻辑,但在 INLINECODEc6c6a75a 或 INLINECODE8389d00f 等输出任务中,我们可以使用转义字符来格式化输出信息。例如 " 可以在控制台打印出一个换行,让调试信息更清晰。
"
#### 注释
代码是写给人看的,机器只是执行它。良好的注释习惯是工程师的基本素养。
- 单行注释:以
//开头。这一行的后续内容都会被编译器忽略。 - 多行注释:以 INLINECODE96bad305 开始,以 INLINECODE340526b6 结束。中间所有内容都被忽略。
/*
这是一个多行注释的示例。
通常用于模块头部的版权声明或功能说明。
Author: AI Engineer
Date: 2023-10-27
*/
// 这是一个单行注释,用于解释下一行代码
// assign y = a & b; // 这是一个注释掉的代码段
#### 数字
Verilog 中的数字系统非常灵活。我们要习惯使用这种格式:
‘
- 二进制:
4‘b1010(4位宽,值1010) - 十六进制:
8‘hFF(8位宽,值为FF) - 十进制:
16‘d65535(16位宽,值65535)
常见错误警示: 很多初学者会忘记写位宽,直接写 123。虽然在某些情况下这是合法的(默认为32位十进制),但在硬件设计中,明确位宽是避免安全隐患的最佳实践。想象一下,如果一个 4 位宽的信号被意外赋值成了 32 位宽的数据,后果可能是灾难性的。
#### 标识符与关键字
- 关键字:语言保留的词,如 INLINECODEcf8deefb, INLINECODEd04e6cd3, INLINECODE6d4d9a82, INLINECODEcb08dd78, INLINECODEa7100a6c, INLINECODE0ba7ac38 等。你不能用它们作为变量名。
- 标识符:你自己定义的名字,如模块名、变量名。
注意*:Verilog 是区分大小写的!INLINECODEc9039f10 和 INLINECODE033e5274 是两个完全不同的信号。这是一个让很多老程序员也头疼的特性,所以建议统一种命名风格(比如全部小写,或者下划线分割法 snake_case)。
#### 数据类型
这是重中之重。Verilog 中最主要的数据类型家族是:
- 线网:
这就像真实的导线。它用于连接模块和逻辑门。Wire 的值不能“保持”,它必须被驱动(被 INLINECODE6ca6423b 赋值,或者被某个模块的输出连接)。如果没有驱动源,它的值将是高阻态 INLINECODE7ba31e03。
- 寄存器:
如前所述,INLINECODEe0403353 并不一定代表物理寄存器,但在行为级代码中,它是用来存储数值的。INLINECODEb0c3a986 的值可以在 always 块中被赋值,并且它会保持这个值直到下一次赋值。
实战应用场景:
- 做组合逻辑(比如数据流逻辑)时,输入输出端口通常声明为 INLINECODE799d1d7a,或者直接使用 INLINECODE9e23d958 类型(隐式 wire)。如果在
assign中使用,必须是 wire。 - 做时序逻辑(比如计数器、状态机)时,我们需要在时钟边沿保存数据,这时必须使用
reg类型。
模块声明的结构
无论是简单的逻辑门还是复杂的 CPU,所有的 Verilog 设计都封装在模块中。模块是硬件世界中的“乐高积木”。
一个标准的模块结构如下:
- 声明:
module module_name(端口列表); - 端口定义:声明输入(INLINECODEe35e6c04)、输出(INLINECODEd96aa717)和双向端口(
inout)。 - 内部信号声明:定义内部的 wire 或 reg。
- 逻辑功能描述:使用门级、数据流或行为级代码填充。
- 结束:
endmodule
常见陷阱与性能优化建议
在结束这篇入门文章之前,我想分享几个实战中的经验,这能帮你少走弯路:
- 不要生成不需要的锁存器:这是新手最容易犯的错。在 INLINECODEc74c3468 块中写组合逻辑(比如 INLINECODE0cae7d96)时,如果覆盖了所有情况,综合器会生成组合电路;但如果你漏写了 INLINECODEce47c527 分支,或者变量没有被赋值,Verilog 会认为你想“保持”上一个状态,从而生成一个锁存器。锁存器在 FPGA 中通常是不推荐的,因为它们会导致时序问题和毛刺。解决办法:总是写全 INLINECODE250af3e4,或者在
always块开始时给变量赋初值。
- 阻塞赋值 vs 非阻塞赋值:
* INLINECODE3a74639a (阻塞赋值):就像 C 语言,先算完右边的,马上赋给左边,然后才执行下一句。通常用于组合逻辑 (INLINECODE71902bd9)。
* INLINECODE8e3b75f5 (非阻塞赋值):右边的值计算完后,并不马上赋给左边,而是等到这个时间步结束时才更新。这是为了模拟触发器的特性。绝对规则:在时序逻辑 (INLINECODEdf8f41b5) 中,永远只使用非阻塞赋值。
总结与下一步
现在,我们已经对 Verilog 有了一个全面的认识。我们了解了硬件描述语言的演变,掌握了 Verilog 的三种建模方式(门级、数据流、行为级),并深入探讨了词法、数据类型和模块结构。
Verilog 的美妙之处在于,它让我们能够在文本编辑器中构建复杂的物理世界。你可以尝试自己写一个代码,比如将上面的半加器扩展成全加器,或者尝试写一个能够检测 101 序列的序列检测器。
实战练习建议:
去安装一个仿真工具(如 Icarus Verilog 或 ModelSim),或者直接在浏览器端的 EDA Playground 上运行你刚才看到的代码。亲眼看到波形图按照你的代码翻转,是学习硬件设计最有成就感的时刻。
希望这篇文章能帮助你顺利开启 Verilog 之旅。Happy Coding!