📝

第一阶段:Verilog 基础入门

学习数字电路与 Verilog 的关系,掌握模块、端口、数据类型、常量、运算符和基础 Testbench。

返回 FPGA 与 Verilog 学习目录

第一阶段:Verilog 基础入门讲义

对应课程目录第 1~3 章。建议学习时间:3~5 天。

本阶段学习目标

完成本阶段后,你应该能够:

  • 解释 Verilog、FPGA 和数字电路之间的关系
  • 区分组合逻辑与时序逻辑
  • 看懂并编写基本的 module
  • 正确使用 inputoutputwirereg
  • 完成模块例化和端口连接
  • 正确书写不同进制、不同位宽的常量
  • 使用常见运算符描述简单组合逻辑
  • 编写基础 Testbench 并观察仿真结果

第 1 章:数字电路与 Verilog 概述

1.1 Verilog 是什么

Verilog 是一种硬件描述语言,英文为 Hardware Description Language,简称 HDL。它不是用来描述“处理器按顺序执行哪些指令”,而是用来描述:

  • 电路中有哪些输入和输出
  • 信号之间如何连接
  • 多个电路单元如何同时工作
  • 信号在时钟到来时如何变化

例如:

1
assign y = a & b;

这行代码不是要求设备先读取 a,再读取 b,最后计算 y。它表示硬件中始终存在一个与门,ab 变化后,y 会随电路传播而变化。

1.2 FPGA、ASIC 与 Verilog

FPGA

FPGA 是可重复配置的数字芯片。Verilog 代码经过综合、布局布线并生成配置文件后,可以下载到 FPGA 中形成实际电路。

ASIC

ASIC 是针对特定功能制造的芯片。Verilog 也可以用于 ASIC 设计,但芯片制造完成后,其硬件结构通常不能再次修改。

三者关系

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Verilog 代码
    |
    v
综合:把代码转换为逻辑门、触发器等电路
    |
    v
布局布线:把电路映射到具体芯片资源
    |
    v
FPGA 配置文件或 ASIC 制造数据

必须记住:综合工具只会把“可综合的 Verilog”转换成硬件。用于测试的延时、打印等语句通常只能用于仿真。

1.3 数字信号

理想数字电路主要使用两个逻辑值:

逻辑值 常见含义
1'b0 逻辑 0、低电平、条件不成立
1'b1 逻辑 1、高电平、条件成立

Verilog 仿真实际采用四值逻辑:

含义
0 逻辑 0
1 逻辑 1
x 未知,常见于信号没有初始化或存在冲突
z 高阻态,常见于三态总线或未驱动线路

看到仿真波形中的 x 时,不要把它当作 0。它通常表示设计或 Testbench 中有信号没有被正确赋值。

1.4 组合逻辑与时序逻辑

组合逻辑

组合逻辑的输出只取决于当前输入。

1
y = f(a, b)

常见组合电路包括:

  • 与门、或门、非门
  • 加法器
  • 比较器
  • 编码器和译码器
  • 多路选择器

时序逻辑

时序逻辑的输出不仅取决于当前输入,还与之前保存的状态有关,通常由时钟控制。

常见时序电路包括:

  • 触发器
  • 寄存器
  • 计数器
  • 状态机

本阶段主要学习组合逻辑。时钟和时序逻辑将在第 7 章开始系统学习。

1.5 第一个模块:与门

新建 and_gate.v

1
2
3
4
5
6
7
8
9
module and_gate (
    input  wire a,
    input  wire b,
    output wire y
);

assign y = a & b;

endmodule

逐行理解:

代码 作用
module and_gate ( 开始定义名为 and_gate 的模块
input wire a 定义输入端口 a
output wire y 定义输出端口 y
assign y = a & b 用与运算持续驱动 y
endmodule 结束模块定义

模块可以理解为一个有明确输入和输出的电路元件。

1.6 与门真值表

a b y = a & b
0 0 0
0 1 0
1 0 0
1 1 1

1.7 第一个 Testbench

Testbench 是用于验证设计的仿真代码。它本身一般不会被综合到 FPGA 中。

新建 and_gate_tb.v

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
`timescale 1ns/1ps

module and_gate_tb;

reg  a;
reg  b;
wire y;

and_gate dut (
    .a(a),
    .b(b),
    .y(y)
);

initial begin
    a = 1'b0;
    b = 1'b0;
    #10;

    a = 1'b0;
    b = 1'b1;
    #10;

    a = 1'b1;
    b = 1'b0;
    #10;

    a = 1'b1;
    b = 1'b1;
    #10;

    $finish;
end

initial begin
    $monitor("time=%0t a=%b b=%b y=%b", $time, a, b, y);
end

endmodule

关键内容:

  • `timescale 1ns/1ps:时间单位是 1 ns,仿真精度是 1 ps
  • dut:Design Under Test,被测试的设计
  • initial:仿真开始时执行一次
  • #10:等待 10 个时间单位,只用于仿真
  • $monitor:信号变化时打印信息,只用于仿真
  • $finish:结束仿真

预期输出类似:

1
2
3
4
time=0  a=0 b=0 y=0
time=10 a=0 b=1 y=0
time=20 a=1 b=0 y=0
time=30 a=1 b=1 y=1

1.8 本章易错点

  • Verilog 描述的是电路,不要默认代码像普通软件一样逐行运行
  • 每条普通语句后通常需要分号
  • 模块必须以 endmodule 结束
  • Verilog 区分大小写,dataData 是两个不同名称
  • #10$monitor$finish 用于仿真,不应写进需要综合的功能模块

1.9 本章练习

  1. 将与门改为或门,并写出真值表。
  2. 实现非门 y = ~a
  3. 实现异或门,并验证只有输入不同时输出才为 1。
  4. 思考:两个输入同时变化时,与门是否需要“执行”一次才会更新?

1.10 本章检查点

在进入下一章前,确认你能回答:

  • Verilog 与 C、Python 的核心区别是什么?
  • xz 分别表示什么?
  • 组合逻辑和时序逻辑的判断依据是什么?
  • Testbench 为什么通常不会下载到 FPGA?

第 2 章:模块与端口

2.1 模块是设计的基本单位

一个 Verilog 工程通常由多个模块组成。小模块实现单一功能,上层模块通过例化连接这些小模块。

模块的一般结构如下:

1
2
3
4
5
6
7
8
module module_name (
    input  wire input_signal,
    output wire output_signal
);

// 电路描述

endmodule

推荐遵守以下命名方式:

  • 模块名和信号名使用小写字母与下划线,例如 and_gate
  • 时钟常命名为 clk
  • 复位常命名为 rstrst_nreset
  • 低电平有效信号常使用 _n 后缀,例如 rst_n
  • Testbench 模块常使用 _tb 后缀

2.2 输入、输出与双向端口

input

输入信号由模块外部提供,模块内部只能读取它。

1
input wire a;

output

输出信号由模块内部产生,提供给模块外部。

1
output wire y;

inout

双向端口既可以输入也可以输出,常用于芯片引脚级的共享总线。

1
inout wire data;

初学阶段不要随意使用 inout。FPGA 内部逻辑通常使用方向明确的独立输入和输出信号。

2.3 单比特与多比特信号

单比特信号:

1
input wire enable;

8 位信号:

1
input wire [7:0] data;

[7:0] 表示信号由 8 个比特组成:

1
2
data[7] data[6] data[5] data[4] data[3] data[2] data[1] data[0]
  MSB                                                   LSB
  • data[7] 是最高有效位,简称 MSB
  • data[0] 是最低有效位,简称 LSB
  • data[7:4] 表示选择高 4 位

2.4 wirereg

wire

wire 表示网络或连线。它通常由以下来源驱动:

  • assign 连续赋值
  • 另一个模块的输出端口
  • 模块输入端口
1
2
wire y;
assign y = a & b;

reg

在 Verilog 中,过程块内被赋值的信号需要声明为 reg

1
2
3
4
5
reg y;

always @(*) begin
    y = a & b;
end

需要特别注意:reg 是语言中的变量类型,不代表它一定会综合成物理寄存器。上面的代码仍然是组合逻辑。是否产生寄存器,取决于赋值所在的过程和逻辑是否需要保存状态。

本阶段先遵守两个简单规则:

  • assign 驱动的信号使用 wire
  • alwaysinitial 中被赋值的信号使用 reg

2.5 模块例化

假设已经有一个与门模块:

1
2
3
4
5
6
7
8
9
module and_gate (
    input  wire a,
    input  wire b,
    output wire y
);

assign y = a & b;

endmodule

可以在上层模块中例化它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
module top (
    input  wire sw0,
    input  wire sw1,
    output wire led
);

and_gate u_and_gate (
    .a(sw0),
    .b(sw1),
    .y(led)
);

endmodule

这段例化代码的结构是:

1
2
3
4
模块名    实例名
and_gate u_and_gate (
    .模块端口(当前层信号)
);

and_gate 类似电路元件的型号,u_and_gate 是这个具体元件实例的名称。同一个模块可以被例化多次,每次都会对应一份独立硬件。

2.6 两种端口连接方式

按名称连接

1
2
3
4
5
and_gate u_and_gate (
    .a(sw0),
    .b(sw1),
    .y(led)
);

这种方式清晰、不依赖端口排列顺序,工程中优先使用。

按位置连接

1
and_gate u_and_gate (sw0, sw1, led);

这种方式代码较短,但端口顺序一旦写错就会连接错误,不推荐用于较复杂模块。

2.7 实践:多个逻辑门组成一个模块

新建 logic_gates.v

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
module logic_gates (
    input  wire a,
    input  wire b,
    output wire and_y,
    output wire or_y,
    output wire xor_y,
    output wire not_a
);

assign and_y = a & b;
assign or_y  = a | b;
assign xor_y = a ^ b;
assign not_a = ~a;

endmodule

新建 logic_gates_tb.v

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
`timescale 1ns/1ps

module logic_gates_tb;

reg a;
reg b;
wire and_y;
wire or_y;
wire xor_y;
wire not_a;

logic_gates dut (
    .a(a),
    .b(b),
    .and_y(and_y),
    .or_y(or_y),
    .xor_y(xor_y),
    .not_a(not_a)
);

integer errors;

initial begin
    errors = 0;

    a = 0; b = 0; #10;
    if ({and_y, or_y, xor_y, not_a} !== 4'b0001)
        errors = errors + 1;

    a = 0; b = 1; #10;
    if ({and_y, or_y, xor_y, not_a} !== 4'b0111)
        errors = errors + 1;

    a = 1; b = 0; #10;
    if ({and_y, or_y, xor_y, not_a} !== 4'b0110)
        errors = errors + 1;

    a = 1; b = 1; #10;
    if ({and_y, or_y, xor_y, not_a} !== 4'b1100)
        errors = errors + 1;

    if (errors == 0)
        $display("PASS: all test cases passed");
    else
        $display("FAIL: %0d test case(s) failed", errors);

    $finish;
end

endmodule

这是一个简单的自检式 Testbench。它不只产生波形,还会自动比较实际输出与预期输出。

表达式 {and_y, or_y, xor_y, not_a} 使用了拼接运算,将 4 个单比特信号组合为一个 4 位数。拼接运算会在第 3 章详细介绍。

2.8 本章易错点

  • 例化时不要把模块名和实例名混淆
  • 按名称连接时,点号后的名称必须是被例化模块的端口名
  • 信号位宽应与端口位宽一致
  • 同一根普通 wire 不应由多个逻辑源同时驱动
  • reg 不等于物理寄存器
  • 顶层模块的端口最终需要通过约束文件连接到 FPGA 引脚

2.9 本章练习

  1. 编写一个 nand_gate 模块,实现 y = ~(a & b)
  2. 编写顶层模块,同时例化两个 and_gate
  3. 定义一个 8 位输入 data,分别输出它的高 4 位和低 4 位。
  4. logic_gates 的 Testbench 故意改错一个预期值,观察自检结果。

2.10 本章检查点

  • wirereg 的基本使用规则是什么?
  • 例化模块为什么会产生实际硬件?
  • 按名称连接和按位置连接,哪种方式更不容易出错?
  • [7:0] 一共有多少位?

第 3 章:数值与运算符

3.1 Verilog 数值的写法

Verilog 常量的完整形式是:

1
位宽'进制数值

常用进制标识:

标识 进制 示例 含义
b 2 4'b1010 4 位二进制数 1010
o 8 6'o52 6 位八进制数 52
d 10 8'd25 8 位十进制数 25
h 16 8'hA5 8 位十六进制数 A5

以下数值相等:

1
2
3
8'b0001_1010
8'd26
8'h1A

下划线可以提高可读性,不影响数值:

1
32'b1100_1010_0001_1110_0101_0011_1000_0111

建议初学阶段总是明确写出位宽,避免工具对无位宽常量采用默认规则造成意外结果。

3.2 位宽截断与扩展

截断

把更宽的值赋给较窄的信号时,高位会被丢弃:

1
2
wire [3:0] value;
assign value = 8'b1010_1100;

最终 value4'b1100

无符号扩展

把较窄的无符号值赋给更宽信号时,通常在高位补 0:

1
2
wire [7:0] value;
assign value = 4'b1010;

最终 value8'b0000_1010

加法位宽

两个 4 位数相加最多需要 5 位保存结果:

1
2
3
4
5
wire [3:0] a;
wire [3:0] b;
wire [4:0] sum;

assign sum = {1'b0, a} + {1'b0, b};

显式补 0 后再相加,可以清楚地保留最高位进位。

3.3 无符号数与有符号数

普通向量默认是无符号数:

1
wire [7:0] unsigned_value;

使用 signed 声明有符号数:

1
wire signed [7:0] signed_value;

8 位二进制补码的范围是:

1
-128 到 127

例如:

1
2
8'sd10
-8'sd10

有符号数和无符号数混合运算容易出现符号扩展问题。初学阶段应尽量让参与同一运算的信号具有一致的位宽和符号属性。

3.4 算术运算符

运算符 含义 示例
+ 加法 a + b
- 减法 a - b
* 乘法 a * b
/ 除法 a / b
% 取余 a % b

乘法器、除法器可能占用较多 FPGA 资源。常数除以 2 的幂通常可以用移位实现,但具体优化应交给综合工具并结合时序、资源报告判断。

3.5 位运算符与逻辑运算符

这两类运算符非常容易混淆。

位运算符

位运算会对向量的每一位分别计算:

运算符 含义
& 按位与
` `
^ 按位异或
~ 按位取反
1
2
3
4'b1100 & 4'b1010 = 4'b1000
4'b1100 | 4'b1010 = 4'b1110
4'b1100 ^ 4'b1010 = 4'b0110

逻辑运算符

逻辑运算把整个操作数判断为真或假,结果通常是 1 位:

运算符 含义
&& 逻辑与
`
! 逻辑非
1
2
(4'b0010 && 4'b1000) = 1'b1
(!4'b0000)            = 1'b1

判断条件时常使用逻辑运算;对总线逐位操作时使用位运算。

3.6 归约运算符

归约运算只在一个操作数前使用,将多位信号归约为 1 位:

1
2
3
4
5
6
7
8
wire [3:0] data;
wire all_one;
wire any_one;
wire parity;

assign all_one = &data;
assign any_one = |data;
assign parity  = ^data;
  • &data:所有位都为 1 时结果为 1
  • |data:任意一位为 1 时结果为 1
  • ^data:所有位进行异或,可用于简单奇偶校验

3.7 比较运算符

运算符 含义
== 相等
!= 不相等
> 大于
< 小于
>= 大于或等于
<= 小于或等于
=== 全等,连 xz 也参与精确比较
!== 非全等

设计模块通常使用 ==!=。Testbench 检查结果时常使用 ===!==,这样未知值 x 不会被悄悄忽略。

注意:<= 在不同语境中可能表示“小于或等于”,也可能表示时序逻辑中的非阻塞赋值。需要根据代码位置判断。

3.8 移位运算符

运算符 含义
<< 逻辑左移,低位补 0
>> 逻辑右移,高位补 0
<<< 算术左移
>>> 算术右移,有符号数高位补符号位

示例:

1
2
4'b0011 << 1 = 4'b0110
4'b1010 >> 1 = 4'b0101

移位后的结果位宽通常与左操作数一致,移出的位会丢失。

3.9 拼接与复制运算符

拼接

使用大括号连接多个信号:

1
2
3
4
5
wire [3:0] high;
wire [3:0] low;
wire [7:0] data;

assign data = {high, low};

复制

1
2
3
4
wire a;
wire [3:0] repeated;

assign repeated = {4{a}};

a = 1'b1 时,repeated = 4'b1111

3.10 条件运算符

条件运算符常用于描述简单二选一逻辑:

1
assign y = select ? a : b;

含义是:

  • select 为 1 时,y = a
  • select 为 0 时,y = b

综合后通常对应一个二选一多路选择器。

3.11 运算优先级

Verilog 运算符有优先级,但不要依赖记忆编写复杂表达式。使用括号明确意图:

1
assign y = enable && ((a & b) == 4'b0000);

清晰的括号既方便阅读,也可以减少优先级理解错误。

3.12 实践:4 位简单 ALU

ALU 是算术逻辑单元。下面的 ALU 根据 op 选择不同运算:

op 功能 输出
2'b00 加法 a + b
2'b01 减法 a - b
2'b10 按位与 a & b
2'b11 按位或 `a

新建 simple_alu.v

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
module simple_alu (
    input  wire [3:0] a,
    input  wire [3:0] b,
    input  wire [1:0] op,
    output wire [3:0] y,
    output wire       zero
);

assign y = (op == 2'b00) ? (a + b) :
           (op == 2'b01) ? (a - b) :
           (op == 2'b10) ? (a & b) :
                           (a | b);

assign zero = (y == 4'b0000);

endmodule

这个示例只保留 4 位结果,因此加法的最高位进位和减法的借位没有单独输出。后续学习完整加减法器时会处理这些标志。

新建 simple_alu_tb.v

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
`timescale 1ns/1ps

module simple_alu_tb;

reg  [3:0] a;
reg  [3:0] b;
reg  [1:0] op;
wire [3:0] y;
wire       zero;

integer errors;

simple_alu dut (
    .a(a),
    .b(b),
    .op(op),
    .y(y),
    .zero(zero)
);

initial begin
    errors = 0;

    a = 4'd3;  b = 4'd5;  op = 2'b00; #10;
    if ((y !== 4'd8) || (zero !== 1'b0)) errors = errors + 1;

    a = 4'd9;  b = 4'd4;  op = 2'b01; #10;
    if ((y !== 4'd5) || (zero !== 1'b0)) errors = errors + 1;

    a = 4'b1100; b = 4'b1010; op = 2'b10; #10;
    if ((y !== 4'b1000) || (zero !== 1'b0)) errors = errors + 1;

    a = 4'b0101; b = 4'b1010; op = 2'b11; #10;
    if ((y !== 4'b1111) || (zero !== 1'b0)) errors = errors + 1;

    a = 4'd5;  b = 4'd5;  op = 2'b01; #10;
    if ((y !== 4'd0) || (zero !== 1'b1)) errors = errors + 1;

    if (errors == 0)
        $display("PASS: all ALU test cases passed");
    else
        $display("FAIL: %0d ALU test case(s) failed", errors);

    $finish;
end

endmodule

3.13 本章易错点

  • 忽略位宽可能造成结果截断
  • &&& 的含义不同
  • |data 是归约或,a | b 是按位或
  • 无符号数与有符号数混合运算可能改变比较和扩展结果
  • 逻辑右移与算术右移对负数的处理不同
  • 不要在除法运算中让除数为 0
  • 复杂表达式应使用括号明确计算顺序

3.14 本章练习

  1. 8'hA6 转换成二进制和十进制。
  2. 计算 4'b1101 & 4'b1011|^ 的结果。
  3. 使用拼接运算把两个 4 位输入组成一个 8 位输出。
  4. 使用复制运算把 1 位输入复制成 8 位输出。
  5. simple_alu 增加按位异或功能。需要相应扩大或重新编码 op
  6. 给 ALU 增加一个 carry 输出,保留加法的最高位进位。
  7. 测试 4'd15 + 4'd1,解释为什么当前 ALU 的 y 等于 0。

3.15 本章检查点

  • 8'hA5 中的 8hA5 分别表示什么?
  • 两个 4 位数相加为什么可能需要 5 位结果?
  • &dataa & b 有什么不同?
  • ===== 各适合在什么场景使用?
  • 如何使用条件运算符描述二选一多路选择器?

第一阶段综合练习

练习 A:三输入多数表决器

输入为 abc。当至少两个输入为 1 时,输出 y 为 1。

要求:

  • 先写出 8 种输入组合的真值表
  • 使用位运算和 assign 实现
  • 编写自检式 Testbench,遍历全部输入组合

提示:三个“两两相与”的结果再进行或运算。

练习 B:8 位数据拆分与重组

输入一个 8 位信号 data_in,完成以下输出:

  • high 输出高 4 位
  • low 输出低 4 位
  • swapped 将高低 4 位互换

要求使用位选择、范围选择和拼接运算。

练习 C:扩展 ALU

将本章的 4 位 ALU 扩展为 8 位,并支持以下功能:

op 功能
3'b000 加法
3'b001 减法
3'b010 按位与
3'b011 按位或
3'b100 按位异或
3'b101 左移 1 位
3'b110 右移 1 位
3'b111 输出 b

同时输出 zero 标志。为每一种操作至少编写一个测试用例。


参考答案

第 1 章练习要点

1
2
3
assign or_y  = a | b;
assign not_y = ~a;
assign xor_y = a ^ b;

组合电路始终存在,输入变化会通过电路传播到输出,不需要像软件函数一样被主动调用。

第 2 章练习要点

1
2
3
4
5
6
7
8
9
module nand_gate (
    input  wire a,
    input  wire b,
    output wire y
);

assign y = ~(a & b);

endmodule

8 位数据拆分:

1
2
assign high = data[7:4];
assign low  = data[3:0];

第 3 章练习要点

1
2
3
4
5
8'hA6 = 8'b1010_0110 = 166

4'b1101 & 4'b1011 = 4'b1001
4'b1101 | 4'b1011 = 4'b1111
4'b1101 ^ 4'b1011 = 4'b0110

拼接与复制:

1
2
assign joined   = {high, low};
assign repeated = {8{bit_in}};

保留加法进位:

1
2
3
4
wire [4:0] add_result;

assign add_result = {1'b0, a} + {1'b0, b};
assign carry      = add_result[4];

4'd15 + 4'd1 的完整结果是 5'b1_0000。当前 ALU 只保留低 4 位,所以 y = 4'b0000

综合练习 A 参考逻辑

1
assign y = (a & b) | (a & c) | (b & c);

综合练习 B 参考逻辑

1
2
3
assign high    = data_in[7:4];
assign low     = data_in[3:0];
assign swapped = {data_in[3:0], data_in[7:4]};

第一阶段完成标准

满足以下条件后,再进入第二阶段:

  • 能独立编写包含输入、输出和 assign 的模块
  • 能独立编写基础 Testbench 并覆盖所有简单输入组合
  • 能使用按名称连接的方式例化模块
  • 能正确判断信号位宽以及运算结果是否会被截断
  • 能区分位运算、逻辑运算和归约运算
  • 能完成三输入多数表决器和扩展 ALU 中至少一个综合练习
comments powered by Disqus