3、硬件描述语言基础(Verilog):模块与端口、数据类型、赋值方式
各位同学,咱们今天聊聊 Verilog 最核心的几个基础概念。说实话,很多新手觉得 Verilog 就是写代码,跟 C 语言差不多。但我要告诉你——千万别这么想。Verilog 描述的是硬件,是实实在在的电路。你写的每一行代码,最终都会变成门、触发器和连线。
我刚开始学的时候也犯过这个错。把 Verilog 当软件写,结果综合出来的电路完全不是那么回事。后来被 mentor 骂了几次,才真正理解硬件描述语言的本质。今天咱们就把这些基础打牢。
3.1 模块与端口:Verilog 的基本单元
模块(module)是 Verilog 中最基本的构建块。你可以把它想象成一个芯片——有输入引脚、输出引脚,内部有逻辑电路。一个完整的 FPGA 设计,就是由无数个模块拼装起来的。
模块的基本结构:
module 模块名 (
input 端口1, // 输入端口
output 端口2, // 输出端口
inout 端口3 // 双向端口
);
// 内部逻辑写在这里
endmodule
端口类型有三种:input、output、inout。我个人习惯把端口声明写在括号里,这样看起来更紧凑。当然你也可以写在模块内部,看团队规范。
举个例子,一个最简单的 D 触发器模块:
module d_flip_flop (
input clk, // 时钟
input d, // 数据输入
output reg q // 数据输出
);
always @(posedge clk) begin
q <= d;
end
endmodule
这里有个细节——output reg q。为什么输出要声明成 reg?因为我们在 always 块里对它赋值。这个后面会细讲。
我的经验:模块命名要规范。我一般用下划线分隔单词,比如 uart_rx、spi_master。端口顺序也有讲究——时钟和复位放最前面,数据信号放后面。这样别人看你的代码,一眼就能找到关键信号。
3.2 数据类型:wire 与 reg
这是新手最容易搞混的地方。说白了:
- wire:相当于一根导线。它不存储值,只是把驱动端的值传递出去。
- reg:相当于一个寄存器。它能存储值,在 always 块或 initial 块中被赋值。
但这里有个坑——reg 不一定会被综合成寄存器!它只是语法上的“变量类型”。如果你在组合逻辑的 always 块里用 reg,综合出来可能只是一堆门电路,没有存储功能。
| 特性 | wire | reg |
|---|---|---|
| 默认值 | 高阻态 (z) | 不定态 (x) |
| 赋值方式 | 连续赋值 (assign) | 过程赋值 (always/initial) |
| 综合结果 | 连线 | 寄存器或组合逻辑 |
| 能否用于 assign | 可以(作为左值) | 不可以 |
| 能否用于 always | 不可以(作为左值) | 可以(作为左值) |
我曾经踩过的坑:刚做项目时,我把一个组合逻辑的输出声明成 wire,然后在 always 块里赋值。编译直接报错。后来才明白——wire 只能用 assign 赋值,reg 才能在 always 里用。这个规则一定要记住。
再补充一点:wire 和 reg 都可以是多位的。比如 wire [7:0] data_bus 就是一个 8 位宽的总线。
3.3 连续赋值与过程赋值
这两种赋值方式,对应着两种不同的电路描述思路。
3.3.1 连续赋值(assign)
连续赋值用 assign 关键字。它描述的是组合逻辑——输出随输入变化,没有时钟参与。说白了,就是一根带逻辑的导线。
wire a, b, c;
assign c = a & b; // c 是 a 和 b 的与运算结果
这里 c 必须声明为 wire。只要 a 或 b 变化,c 立即更新。这就是“连续”的含义——赋值语句一直在执行,没有时间概念。
3.3.2 过程赋值(always)
过程赋值写在 always 块里。它描述的是时序逻辑或组合逻辑,但需要敏感列表来触发。
// 时序逻辑:D 触发器
always @(posedge clk) begin
q <= d;
end
// 组合逻辑:多路选择器
always @(*) begin
if (sel)
y = a;
else
y = b;
end
注意看——第一个例子用 posedge clk,综合出来是触发器。第二个例子用 @(*),综合出来是组合逻辑。敏感列表决定了电路的行为。
我的建议:写组合逻辑时,尽量用 assign 而不是 always @(*)。为什么?因为 assign 更直观,不容易漏掉敏感信号。我见过太多人写 always @(a or b) 结果漏了 c,导致仿真和综合不一致。用 @(*) 虽然能自动推导,但代码可读性不如 assign。
3.4 阻塞赋值与非阻塞赋值
这是 Verilog 里最核心、也最容易出错的地方。很多面试题都爱考这个。咱们把它彻底讲明白。
3.4.1 阻塞赋值(=)
阻塞赋值用 =。它的特点是:顺序执行,立即生效。也就是说,上一条赋值语句执行完,下一条才能开始。这跟 C 语言很像。
always @(posedge clk) begin
a = b;
c = a; // c 得到的是 b 的值(因为 a 已经被更新了)
end
这段代码综合出来是什么?两个寄存器串联——a 存 b 的值,c 存 a 的值(也就是 b 的值)。但注意,它们用的是同一个时钟沿。
3.4.2 非阻塞赋值(<=)
非阻塞赋值用 <=。它的特点是:并行执行,延迟生效。所有赋值语句在同一个时间点计算右值,然后同时更新左值。
always @(posedge clk) begin
a <= b;
c <= a; // c 得到的是 a 的旧值(更新前的值)
end
这段代码综合出来是什么?两个寄存器——a 存 b 的值,c 存 a 的旧值。这跟阻塞赋值的结果完全不同!
| 特性 | 阻塞赋值 (=) | 非阻塞赋值 (<=) |
|---|---|---|
| 执行顺序 | 顺序执行 | 并行执行 |
| 更新时机 | 立即更新 | 块结束时更新 |
| 适用场景 | 组合逻辑 | 时序逻辑 |
| 综合结果 | 取决于上下文 | 触发器 |
铁律:时序逻辑用非阻塞赋值(<=),组合逻辑用阻塞赋值(=)。千万不要混用!我曾经在一个项目里看到有人把阻塞赋值用在时序逻辑里,结果仿真通过,综合出来的电路功能完全不对。查了三天才找到原因。
3.4.3 为什么会有这种区别?
你想想看——硬件电路里,所有触发器都是在同一个时钟沿同时采样的。非阻塞赋值正好模拟了这种并行行为。而阻塞赋值是顺序执行的,更适合描述组合逻辑的传播延迟。
举个例子,一个移位寄存器:
// 正确写法:非阻塞赋值
always @(posedge clk) begin
q1 <= d;
q2 <= q1;
q3 <= q2;
end
// 错误写法:阻塞赋值
always @(posedge clk) begin
q1 = d;
q2 = q1; // 这里 q1 已经被更新了
q3 = q2; // 这里 q2 也被更新了
end
用阻塞赋值,三个寄存器在同一个时钟沿全部更新为 d 的值——这根本不是移位寄存器!而是一个三位的并行寄存器。
3.5 知识体系总览
下面这张图把本章的核心知识点串起来了。你可以把它当作一个快速参考。
3.6 实战建议
学完这些基础,我建议你动手写几个小模块练练手:
- 写一个 8 位计数器——用非阻塞赋值,注意复位逻辑。
- 写一个 4 选 1 多路选择器——用连续赋值 assign 实现。
- 写一个移位寄存器——对比阻塞和非阻塞赋值的区别。
仿真的时候,仔细观察波形。你会发现非阻塞赋值的结果跟你的直觉可能不太一样。嗯,这就是硬件思维——所有事情都在时钟沿同时发生。
最后说一句:Verilog 不是 C 语言。写代码的时候,脑子里要想着电路——这个信号是连线还是寄存器?这个赋值是组合逻辑还是时序逻辑?想清楚了再下笔。我见过太多人把 Verilog 写成 C,结果综合出来的电路一塌糊涂。