三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Verilog入门:从软件思维到硬件思维的转变与实践

Verilog入门:从软件思维到硬件思维的转变与实践

1. 从“软件思维”到“硬件思维”的认知跃迁

如果你是一名软件工程师,或者对编程有一定了解,第一次接触Verilog时,大概率会感到一种熟悉的“错觉”。它的语法看起来很像C语言,有if-else,有for循环,有case语句,甚至还有functiontask。你可能会想:“这不就是另一种编程语言吗?我写个always @(posedge clk)块,里面写点逻辑,不就能描述硬件了?”

这正是Verilog入门路上最大的认知陷阱。我见过太多新手,包括当年的我自己,带着写软件的惯性思维一头扎进去,结果写出来的代码要么仿真结果诡异,要么综合后电路面积和时序一塌糊涂,甚至根本综合不出预期的电路。Verilog的全称是硬件描述语言,关键词是“描述”,而不是“编程”。它的核心是让你用一种高级的、文本化的方式,向综合工具“描述”你想要的数字电路结构。工具再根据你的描述,把它“翻译”成由门电路、触发器、连线等基本单元组成的物理网表。

举个最简单的例子。在软件里,你写a = b + c;,CPU会按顺序执行:从内存读b,读c,送到ALU做加法,结果写回a。这是一个时间序列上的操作。但在Verilog里,你写assign a = b + c;,你描述的是一个空间结构:你要求综合工具生成一个加法器电路,它的两个输入端口永远连着b和c的信号线,输出端口永远连着a的信号线。只要b或c的值变化,a几乎“同时”(在考虑了电路延迟后)就会跟着变化。这里没有“先读b再读c”的顺序,它们是并发的。

所以,学习Verilog的第一步,不是去死记硬背语法,而是彻底扭转思维模式:从“顺序执行”的软件思维,切换到“并行发生”的硬件思维。你要时刻想象你写的每一行代码,最终都会变成硅片上的晶体管、连线和时钟驱动的寄存器。这篇文章,我就以一个过来人的身份,带你绕过那些我踩过的坑,从最核心的思维转换开始,一步步构建起对Verilog和数字电路设计的坚实理解。

2. 构建你的第一个“数字世界”:环境、工具与Hello World

在开始描述电路之前,我们需要一个“数字实验室”。这个实验室不需要昂贵的FPGA开发板,初期只需要两样东西:一个文本编辑器和一个仿真工具。我强烈推荐使用VS Code作为编辑器,配合诸如Verilog-HDL/SystemVerilog等插件,可以获得优秀的语法高亮、自动补全和代码格式化体验。这能极大提升编码效率和减少低级错误。

2.1 仿真工具选型:Modelsim vs. Icarus Verilog

仿真工具是我们的“虚拟示波器”和“逻辑分析仪”。主流选择有两个:

  1. Modelsim (或 Questasim):业界标准,功能强大,图形化界面友好,调试能力极强。对于学生或初学者,Intel (原Altera) 和 AMD (原Xilinx) 都提供功能受限的免费版本(如 ModelSim-Intel FPGA Starter Edition)。它的波形查看器是学习时序概念的绝佳工具。
  2. Icarus Verilog (iverilog):开源、轻量、命令行驱动。搭配GTKWave查看波形,构成了一个完全免费、跨平台的开源仿真环境。虽然功能不如Modelsim全面,但对于学习语法和进行中小型模块仿真完全足够,而且其“原始”的特性迫使你更理解编译和仿真的流程。

对于纯新手,我建议从Icarus Verilog + GTKWave开始。理由很简单:安装配置简单,没有许可限制,能让你更专注于语言本身,而不是复杂的IDE操作。当你需要更复杂的调试(如查看多维数组、复杂数据结构)或与特定厂商的IP核协同仿真时,再迁移到Modelsim也不迟。

注意:安装Icarus Verilog时,Windows用户建议直接下载预编译的安装包。Linux用户通过包管理器(如apt install iverilog gtkwave)安装即可。确保安装后能在命令行中执行iverilog -vgtkwave命令。

2.2 第一个模块:点亮“逻辑灯”

我们不写“Hello World”,我们点亮一盏“逻辑灯”。在数字电路里,最基本的“灯”就是一个输出端口。让我们创建一个最简单的模块:一个反相器(NOT gate)。

// 文件名:not_gate.v // 模块定义:模块名(端口列表) module not_gate ( input wire in, // 输入端口,声明为wire类型 output wire out // 输出端口 ); // 持续赋值语句:描述一个组合逻辑 // 它意味着:out 这个信号线,永远等于 (~in) 这个逻辑值。 assign out = ~in; endmodule

这就是一个完整的Verilog模块。我们来拆解一下:

  • module ... endmodule:定义一个模块,这是Verilog描述功能的基本单元。
  • input/output:声明端口的方向。wire是默认的、最常见的线网类型,用于连接组合逻辑。
  • assign:持续赋值语句。它描述的不是一个“动作”,而是一种永恒的“关系”。只要in变化,out就会立即(在仿真中)或经过一个极短的门延迟(在实际电路中)变为in的反相值。

2.3 编写测试平台:给电路注入“激励”

电路模块设计好了,我们怎么验证它是对的呢?这就需要编写一个测试平台。测试平台本身也是一个Verilog模块,但它通常不会被综合成实际电路(使用特殊的initial块和系统任务$display,$monitor等)。它的作用是实例化待测模块,并模拟输入信号的变化,观察输出是否符合预期。

// 文件名:tb_not_gate.v `timescale 1ns / 1ps // 时间单位 / 时间精度 module tb_not_gate(); // 测试平台的内部信号 reg in; // reg类型,用于在initial/always块中驱动信号 wire out; // wire类型,连接待测模块的输出 // 实例化待测模块:将测试平台的信号连接到模块端口 not_gate u_not_gate ( .in (in), // .模块端口名(连接信号名) .out (out) ); // 初始化块:只在仿真开始时执行一次,用于生成测试序列 initial begin in = 0; // 初始时刻,in为0 #10; // 等待10个时间单位(根据`timescale`是10ns) in = 1; // 10ns后,in变为1 #10; in = 0; #10; $display("Simulation finished at time %0t", $time); $finish; // 结束仿真 end // 可选:将信号变化记录到VCD文件,用于GTKWave查看波形 initial begin $dumpfile("wave.vcd"); // 波形文件名称 $dumpvars(0, tb_not_gate); // 记录本模块及其下所有子模块的信号 end endmodule

2.4 运行仿真并查看波形

保存好两个文件后,在命令行中执行以下步骤:

  1. 编译iverilog -o not_gate.vvp not_gate.v tb_not_gate.v
    • -o not_gate.vvp:指定编译输出的可执行文件。
    • 后面列出所有需要编译的源文件。
  2. 运行仿真vvp not_gate.vvp
    • 这会执行编译好的仿真程序,生成wave.vcd波形文件,并在控制台输出$display的信息。
  3. 查看波形gtkwave wave.vcd
    • 打开GTKWave,添加信号(in, out)到波形窗口。你将看到,out信号始终是in信号的反相,直观地验证了反相器的功能。

恭喜!你已经完成了从代码到仿真的完整流程,成功“点亮”了你的第一个数字电路。这个过程虽然简单,但包含了模块定义、实例化、测试平台编写、仿真流程等所有核心环节的雏形。

3. 深入核心:可综合逻辑的两种描述风格

当你写的Verilog代码最终要变成真实的电路(通过FPGA或ASIC实现)时,这部分代码必须是“可综合的”。综合工具只理解特定的编码风格。可综合逻辑主要分为两大类:组合逻辑时序逻辑。描述它们有两种主要风格:数据流描述行为描述

3.1 数据流描述:用assign描绘信号间的连接

数据流描述使用assign持续赋值语句,直接描述信号之间的逻辑函数关系。它最适合描述简单的、没有状态变化的组合逻辑。

module dataflow_example ( input wire a, b, c, output wire y1, y2 ); // 描述一个与门和一个或门 assign y1 = a & b; // y1 = a AND b assign y2 = (a | b) & c; // y2 = (a OR b) AND c endmodule

综合工具看到assign y1 = a & b;,就知道需要生成一个二输入的与门,输入端接a和b,输出端接y1。这种描述非常直接,与最终生成的电路结构一一对应。

3.2 行为描述:用always块定义电路行为

行为描述使用always块,它描述的是在特定条件下,寄存器(reg)值如何更新。这是描述复杂逻辑,尤其是时序逻辑(需要时钟)的主要方式。always块的敏感列表决定了它何时被执行。

组合逻辑的always: 敏感列表包含所有在右侧表达式中出现的输入信号。使用=(阻塞赋值)。

module behavioral_comb ( input wire [1:0] sel, input wire [3:0] d0, d1, d2, d3, output reg [3:0] out // 在always块中赋值的信号必须声明为reg类型 ); always @(*) begin // @(*) 或 @(sel, d0, d1, d2, d3),表示任何输入变化都触发 case (sel) 2'b00: out = d0; 2'b01: out = d1; 2'b10: out = d2; 2'b11: out = d3; default: out = 4'b0000; // 良好的编码习惯:提供default,避免锁存器 endcase end endmodule

这个例子描述了一个4选1的多路选择器。always @(*)块意味着,只要seld0d1d2d3中任何一个发生变化,块内的语句就会重新计算,并更新out的值。这里综合出的就是一个纯粹的组合逻辑MUX。

关键陷阱:不完整的敏感列表与锁存器如果你写always @(sel)而忽略了数据输入d0-d3,那么当sel不变而d0变化时,always块不会执行,out将保持旧值。为了保存这个旧值,综合工具会被迫生成一个锁存器!锁存器在ASIC设计中需要谨慎处理,在同步电路设计中通常被视为不受欢迎的“毛刺敏感”元件。因此,对于组合逻辑,永远使用always @(*)是最安全的选择,它能自动推断出完整的敏感列表。

时序逻辑的always: 敏感列表通常只包含时钟边沿(posedge clknegedge clk)和可选的异步复位信号。使用<=(非阻塞赋值)。

module behavioral_seq ( input wire clk, input wire rst_n, // 低电平有效的异步复位 input wire [7:0] d, output reg [7:0] q ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin // 异步复位:只要rst_n变低,立即(在时钟沿之前)将q清零 q <= 8'b0; end else begin // 时钟上升沿触发:在clk上升沿到来时,将d的值存入q q <= d; end end endmodule

这个例子描述了一个带异步复位的8位寄存器(D触发器)。q <= d;是非阻塞赋值,它意味着“在同一个时钟沿,所有非阻塞赋值右边的表达式被同时计算,然后在时间步结束时同时更新左边的寄存器”。这是描述同步电路的关键。

3.3 阻塞赋值(=)与非阻塞赋值(<=)的黄金法则

这是Verilog初学者最容易混淆和出错的地方。记住这个简单的法则,可以避免99%的时序逻辑错误:

  • 在描述组合逻辑的always @(*)块中,使用阻塞赋值(=)。
  • 在描述时序逻辑的always @(posedge clk)块中,使用非阻塞赋值(<=)。

为什么?阻塞赋值像软件里的“顺序执行”。在同一个always块中,a = b; c = a;执行后,c得到的是已经更新过的a的值(即b的值)。这在组合逻辑中是正确的,因为它模拟了信号通过组合逻辑的瞬时传播。

非阻塞赋值模拟了硬件寄存器的并行更新。在同一个时钟沿的always块中,a <= b; c <= a;执行时,右侧的ab使用的是它们在这个时钟沿到来之前的旧值。所以这两条语句是“同时”计算的,c得到的是a的旧值,而不是刚被赋予的b的值。这精确对应了:在时钟上升沿,所有触发器的D端输入被采样,并准备更新到Q端,而Q端的新值要到下一个时钟沿才生效。

违反这条法则,常常会导致仿真行为与综合后电路行为不一致,产生难以调试的竞争冒险问题。

4. 从模块到系统:设计方法与常见电路实现

掌握了基本描述方法后,我们可以尝试构建更复杂的功能。数字系统设计通常采用自顶向下或自底向上的方法。这里我们通过几个经典电路,来实践模块化设计。

4.1 状态机设计:清晰、安全、可维护

状态机是数字逻辑控制的灵魂。一个清晰的状态机设计至关重要。推荐使用“三段式”描述法,它将状态转移逻辑、状态寄存器更新和输出逻辑分离,结构清晰,易于综合和调试。

module fsm_sequence_detector ( input wire clk, input wire rst_n, input wire data_in, output reg sequence_found ); // 第一部分:状态定义 parameter S_IDLE = 2'b00; parameter S_1 = 2'b01; parameter S_11 = 2'b10; parameter S_110 = 2'b11; reg [1:0] current_state, next_state; // 第二部分:时序逻辑,状态寄存器更新 always @(posedge clk or negedge rst_n) begin if (!rst_n) current_state <= S_IDLE; else current_state <= next_state; end // 第三部分:组合逻辑,下一状态计算 always @(*) begin next_state = current_state; // 默认保持当前状态 case (current_state) S_IDLE: next_state = (data_in == 1'b1) ? S_1 : S_IDLE; S_1: next_state = (data_in == 1'b1) ? S_11 : S_IDLE; S_11: next_state = (data_in == 1'b0) ? S_110 : S_11; S_110: next_state = (data_in == 1'b1) ? S_1 : S_IDLE; default: next_state = S_IDLE; endcase end // 第四部分:组合逻辑,输出计算(Moore型:输出仅与当前状态有关) always @(*) begin sequence_found = 1'b0; // 默认输出 if (current_state == S_110) sequence_found = 1'b1; end endmodule

这个状态机检测输入序列“1101”。三段式(这里把输出逻辑算作一段,共四段)的好处是:第二部分一定是纯粹的时序逻辑,综合出来就是一组触发器;第三和第四部分是纯粹的组合逻辑。这种分离让综合工具能更好地优化,也让代码更容易理解和维护。

4.2 计数器与分频器:时序的基础

计数器是数字系统中最基本的时序元件之一。

module counter #( parameter WIDTH = 8 // 参数化设计,提高模块复用性 )( input wire clk, input wire rst_n, input wire en, // 计数使能 output reg [WIDTH-1:0] count ); always @(posedge clk or negedge rst_n) begin if (!rst_n) count <= {WIDTH{1'b0}}; // 复位时清零,使用复制运算符{} else if (en) count <= count + 1'b1; // 使能时加1 // else 保持,无需显式写出 end endmodule

这个简单的二进制递增计数器,每个时钟沿在en有效时加1。基于计数器,可以轻松实现分频器。例如,要实现一个N分频(N为偶数)的时钟,可以生成一个占空比50%的方波:

module clock_divider #( parameter N = 10 // 分频系数,假设为偶数 )( input wire clk_in, input wire rst_n, output wire clk_out ); reg [$clog2(N)-1:0] cnt; // $clog2是系统函数,计算位宽 reg clk_out_reg; always @(posedge clk_in or negedge rst_n) begin if (!rst_n) begin cnt <= 0; clk_out_reg <= 1'b0; end else begin if (cnt == N/2 - 1) begin // 计数到一半时翻转 clk_out_reg <= ~clk_out_reg; cnt <= 0; end else begin cnt <= cnt + 1; end end end assign clk_out = clk_out_reg; endmodule

4.3 存储器与FIFO:数据缓冲的基石

小容量存储器可以用寄存器阵列实现,例如一个双端口RAM(先读后写)。

module simple_ram #( parameter ADDR_WIDTH = 4, parameter DATA_WIDTH = 8 )( input wire clk, input wire we, // 写使能 input wire [ADDR_WIDTH-1:0] addr, input wire [DATA_WIDTH-1:0] din, output reg [DATA_WIDTH-1:0] dout ); // 用reg数组定义存储器 reg [DATA_WIDTH-1:0] mem [(2**ADDR_WIDTH)-1:0]; always @(posedge clk) begin if (we) mem[addr] <= din; // 写操作 dout <= mem[addr]; // 读操作(输出在时钟沿后更新,是同步读) end endmodule

对于更复杂的数据流控制,FIFO是必不可少的组件。一个同步FIFO的核心是读写指针的控制和空满状态的判断。判断空满状态时,通常会让指针多出一位([ADDR_WIDTH:0]),当最高位不同而其余位相同时,表示“满”;当所有位都相同时,表示“空”。这是FIFO设计的经典模式,网上有大量成熟可靠的代码可以参考和修改。自己动手实现一个参数化的FIFO,是理解指针操作和状态机结合的绝佳练习。

5. 仿真、调试与代码质量:从能跑到可靠

写出来的代码能通过编译和基础仿真,只是第一步。确保它在各种边界条件下都能正确工作,并且综合出的电路性能良好,才是工程师价值的体现。

5.1 编写完备的测试平台

一个糟糕的测试平台比没有更危险,因为它会给你虚假的信心。好的测试平台应该:

  • 覆盖所有功能点:包括正常功能和所有定义的异常情况。
  • 进行边界测试:对计数器测试满量程和溢出,对FIFO测试写满和读空。
  • 加入随机激励:使用$random或更高级的约束随机验证方法,可以发现确定性测试难以覆盖的角落情况。
  • 实现自检查:在测试平台中自动比较输出与预期值,并用$error报告失败,而不是依赖人工看波形。
// 一个简单的自检查测试平台片段 initial begin integer error_count = 0; for (int i=0; i<1000; i=i+1) begin @(posedge clk); data_in = $random; // 随机输入 expected_out = some_function(data_in); // 计算预期输出 #1; // 等待一个小的delta时间,让待测模块输出稳定 if (dut_out !== expected_out) begin $error("Mismatch at time %0t: input=%h, dut_out=%h, expected=%h", $time, data_in, dut_out, expected_out); error_count++; end end if (error_count == 0) $display("Test PASSED!"); else $display("Test FAILED with %0d errors.", error_count); $finish; end

5.2 利用波形调试

当测试失败时,波形是你的第一现场。学会高效使用波形查看器:

  • 定位问题时间点:找到输出第一次出现错误的时间。
  • 回溯信号:沿着出错的信号向前追溯,看是哪个模块、哪个输入导致的。
  • 检查关键信号:对于状态机,检查current_statenext_state是否按预期跳转;对于计数器,检查使能、清零信号是否有效。
  • 注意仿真与现实的差距:仿真中的#1延迟在实际电路中不存在。要小心由仿真延迟掩盖的竞争冒险问题。一个在仿真中完美的设计,可能因为布线延迟在实际电路中产生毛刺。对于时钟、复位等关键信号,必须保证其满足建立时间和保持时间。

5.3 可综合代码风格与最佳实践

为了让综合工具生成更优的电路,并避免潜在问题,请养成以下习惯:

  1. 明确所有情况:在if-elsecase语句中,为所有可能的输入组合指定输出,否则会推断出锁存器。使用default分支或赋初值。
  2. 避免异步逻辑:除了全局复位/置位,尽量避免使用异步逻辑。异步逻辑的时序难以分析,容易导致系统不稳定。
  3. 寄存器输出:对于模块的输出信号,尽量在顶层用寄存器打一拍再输出。这有助于改善时序,将关键路径限制在模块内部。
  4. 参数化设计:使用parameterlocalparam来定义常数,而不是直接写死数字。这极大提高了代码的可复用性和可维护性。
  5. 代码注释与命名:信号名、模块名要有意义。wr_enwe更好,data_fifodf好得多。详细的注释,尤其是对复杂算法或状态机转移条件的注释,对于后续维护和团队协作至关重要。
  6. 目录结构规范:建立清晰的目录结构,如rtl/存放设计代码,sim/存放测试平台,doc/存放文档,syn/存放综合脚本和报告。

学习Verilog,就像学习一门新的“建筑语言”,你用它在描述一个并发的、空间化的数字世界。从扭转思维开始,通过搭建一个个小模块(与门、寄存器、计数器、状态机),逐渐掌握描述复杂系统(处理器、通信协议、图像处理流水线)的能力。这个过程充满挑战,但当你在示波器上看到自己设计的电路按照预想运行时,那种成就感是无与伦比的。记住,多看优秀的开源代码,多动手仿真和调试,多思考“这行代码会变成什么样的电路”,你就能在这条路上越走越稳。

← 返回列表