Verilog 入门指南:从零开始构建数字逻辑思维

在很长一段时间里,当我们想要解决一个数学问题或者处理数据时,我们习惯于使用 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!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。如需转载,请注明文章出处豆丁博客和来源网址。https://shluqu.cn/52408.html
点赞
0.00 平均评分 (0% 分数) - 0