第三章 硬件描述语言基础(Verilog):模块结构、数据类型、运算符、assign语句、always块
各位同学,欢迎来到第三章。从这一章开始,我们就要真正动手写代码了。
Verilog 这门语言,说白了就是用来描述数字电路的。你写出来的每一行代码,最终都会变成实实在在的门电路、触发器和连线。我刚开始学的时候,总觉得它跟 C 语言差不多,后来被现实狠狠教育了一顿——Verilog 是硬件,不是软件。这个观念一定要先立住。
3.1 模块结构:Verilog 的基本单元
一个 Verilog 设计,本质上就是一堆模块(module)的集合。模块就像是一个黑盒子,有输入、有输出,内部封装了具体的逻辑功能。
来看一个最简单的模块结构:
module led_controller(
input wire clk, // 时钟输入
input wire rst_n, // 复位输入,低电平有效
output reg led_out // LED 输出
);
// 这里写内部逻辑
endmodule
结构其实很清晰:
- module 关键字后面跟模块名,我习惯用下划线命名法,比如
uart_rx、spi_master。 - 端口列表 定义模块对外的接口,包括方向(input/output/inout)和数据类型。
- 内部逻辑 写在 module 和 endmodule 之间。
- endmodule 收尾,别忘了这个。
3.2 数据类型:wire 和 reg
这是新手最容易搞混的地方。我见过不少同学把 wire 和 reg 用反,仿真能过,综合却报错。
wire 型:说白了就是一根导线。它不存储值,只是把驱动端的信号传递出去。比如 assign 语句的左边,或者模块端口连接,都用 wire。
reg 型:它代表一个寄存器,可以在 always 块中被赋值。注意,reg 不一定会综合成寄存器——如果你在组合逻辑的 always 块里用 reg,它综合出来还是导线。
我给大家整理了一个对照表:
| 特性 | wire | reg |
|---|---|---|
| 默认值 | 高阻态 z | 不定态 x |
| 赋值方式 | assign 连续赋值 | always 块内赋值 |
| 综合结果 | 组合逻辑(导线) | 组合逻辑或时序逻辑 |
| 端口类型 | input/output/inout | output 常用 |
3.3 运算符:不只是加减乘除
Verilog 的运算符跟 C 语言很像,但有几个地方要特别小心。
算术运算符:+、-、*、/、%。这里要注意,乘法和除法在 FPGA 里很耗资源。我一般能用移位实现的,绝不用乘除。比如乘以 2 写成 << 1,除以 2 写成 >> 1。
位运算符:&、|、^、~。这些是逐位操作的,跟逻辑运算符 &&、||、! 要区分开。举个例子:
wire [3:0] a = 4'b1010;
wire [3:0] b = 4'b1100;
wire [3:0] c = a & b; // 结果是 4'b1000
wire d = a && b; // 结果是 1'b1(非零即真)
拼接运算符:{ }。这个非常实用,我经常用它来组合信号。比如:
wire [7:0] byte_data = {high_nibble, low_nibble};
三目运算符:? :。写组合逻辑时特别好用,比 if-else 更简洁。
assign max = (a > b) ? a : b;
3.4 assign 语句:连续赋值
assign 语句描述的是组合逻辑。它的特点是:只要右边表达式发生变化,左边立即更新。这跟实际电路里的导线行为完全一致。
来看一个加法器的例子:
module adder(
input wire [7:0] a,
input wire [7:0] b,
output wire [8:0] sum
);
assign sum = a + b;
endmodule
嗯,这里要注意:assign 的左边必须是 wire 类型,不能是 reg。而且一个 wire 只能被一个 assign 驱动,不能重复赋值。
3.5 always 块:时序逻辑的核心
always 块是 Verilog 里最灵活、也最容易出问题的地方。它既可以描述组合逻辑,也可以描述时序逻辑,关键看敏感列表怎么写。
时序逻辑的标准写法:
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
counter <= 8'd0;
end else begin
counter <= counter + 1'b1;
end
end
这里有几个关键点:
- 敏感列表:posedge 表示上升沿触发,negedge 表示下降沿触发。我习惯把时钟和复位都写进去。
- 非阻塞赋值 <=:时序逻辑里一定要用这个。它表示「在时钟沿到来时,同时计算并更新」。如果用 =(阻塞赋值),仿真结果会完全不一样。
- 复位逻辑:异步复位时,一定要把复位信号写进敏感列表。我曾经漏写过一次,仿真没问题,上板后复位死活不生效,查了两天才发现。
组合逻辑的 always 块:
always @(*) begin
case (sel)
2'b00: out = a;
2'b01: out = b;
2'b10: out = c;
default: out = 4'b0;
endcase
end
这里用 @(*) 表示敏感列表自动推导,所有输入信号变化都会触发。组合逻辑里一定要用阻塞赋值 =,而且要注意覆盖所有分支,否则会综合出锁存器。
3.6 实战:一个简单的分频器
把今天学的知识串起来,写一个 8 分频器:
module clk_divider(
input wire clk_in,
input wire rst_n,
output reg clk_out
);
reg [2:0] cnt;
always @(posedge clk_in or negedge rst_n) begin
if (!rst_n) begin
cnt <= 3'd0;
clk_out <= 1'b0;
end else begin
if (cnt == 3'd7) begin
cnt <= 3'd0;
clk_out <= ~clk_out;
end else begin
cnt <= cnt + 1'b1;
end
end
end
endmodule
这个例子用到了:模块结构、reg 数据类型、算术运算符、always 时序块、非阻塞赋值。你想想看,是不是每个知识点都用上了?
好了,第三章的内容就到这里。下一章我们会深入讨论组合逻辑和时序逻辑的区别,以及如何写出可综合的代码。记住,写 Verilog 的时候,脑子里要想着电路,而不是想着软件。这个习惯越早养成越好。