3. Verilog基础与RTL设计(上):模块化设计思想,组合逻辑,时序逻辑,阻塞赋值与非阻塞赋值深度解析,常用电路
各位同学,欢迎来到《芯片设计项目实战宝典》的第三讲。从这一章开始,我们正式进入RTL设计的核心地带。很多新手觉得Verilog就是写代码,其实不然。我个人认为,RTL设计的本质是用代码描述硬件电路,而不是写软件程序。这个观念如果转不过来,后面会踩很多坑。
3.1 模块化设计思想:把大问题拆成小积木
芯片设计动辄几百万门,没人能一口气搞定。模块化就是我们的生存法则。说白了,就是把一个复杂功能拆成多个独立、可复用的小模块,每个模块各司其职。
核心原则:
- 高内聚:一个模块只做一件事,并且把它做好。
- 低耦合:模块之间通过明确的接口(端口)通信,内部实现互不干扰。
我在项目中见过最头疼的代码,就是把计数器、状态机和数据通路全写在一个always块里。那代码,别说别人看不懂,过两周自己都看不懂。你想想看,调试的时候多痛苦。
一个标准的Verilog模块长这样:
module counter #(
parameter WIDTH = 8
)(
input wire clk,
input wire rst_n,
input wire en,
output reg [WIDTH-1:0] count
);
// 功能实现
endmodule
注意这里的#(parameter WIDTH = 8),这是参数化设计。我习惯把经常变的参数(比如数据位宽、计数器深度)都写成parameter,这样换个场景直接例化时改参数就行,不用重写代码。
3.2 组合逻辑:assign与always@(*)
组合逻辑的输出只取决于当前输入,跟历史状态无关。它没有记忆功能。
3.2.1 用assign描述简单组合逻辑
assign是连续赋值语句,适合描述简单的逻辑门或数据通路。比如一个加法器:
module adder (
input wire [7:0] a,
input wire [7:0] b,
output wire [8:0] sum
);
assign sum = a + b;
endmodule
这里sum是wire类型,因为assign只能驱动wire。记住,assign左边的信号必须是wire。
3.2.2 用always@(*)描述复杂组合逻辑
当组合逻辑比较复杂,比如需要if-else或case语句时,就用always@(*)。这里的*是通配符,表示敏感列表包含所有输入信号。
module mux_4to1 (
input wire [1:0] sel,
input wire [3:0] data_in,
output reg data_out
);
always @(*) begin
case (sel)
2'b00: data_out = data_in[0];
2'b01: data_out = data_in[1];
2'b10: data_out = data_in[2];
2'b11: data_out = data_in[3];
default: data_out = 1'b0;
endcase
end
endmodule
避坑指南:我曾经在always@(*)里漏写了某个输入信号,导致仿真和综合结果不一致。后来养成习惯,一律用always@(*),不用手动列敏感列表。这样最安全。
3.3 时序逻辑:always@(posedge clk)
时序逻辑有记忆功能,输出不仅取决于当前输入,还取决于之前的状态。它靠时钟沿来触发更新。
module dff (
input wire clk,
input wire rst_n,
input wire d,
output reg q
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
q <= 1'b0;
else
q <= d;
end
endmodule
这里用了异步复位,negedge rst_n表示低电平有效。我建议所有时序逻辑都带上异步复位,这样上电后电路状态是确定的,仿真和调试都方便。
3.4 阻塞赋值与非阻塞赋值:深度解析
这是Verilog里最容易出错的地方,没有之一。很多面试官喜欢问这个,因为能看出你对硬件电路的理解深度。
| 特性 | 阻塞赋值 (=) | 非阻塞赋值 (<=) |
|---|---|---|
| 执行顺序 | 顺序执行,阻塞后续语句 | 并行执行,不阻塞后续语句 |
| 适用场景 | 组合逻辑 (always@(*)) | 时序逻辑 (always@(posedge clk)) |
| 硬件行为 | 类似连线,立即更新 | 类似寄存器,时钟沿更新 |
| 仿真结果 | 立即生效 | 块结束后统一更新 |
为什么时序逻辑必须用非阻塞赋值?我举个例子你就明白了。假设我们要实现两个寄存器级联:
// 错误写法:用阻塞赋值
always @(posedge clk) begin
a = b;
c = a; // 这里a已经是新值了,c得到的是b的值
end
// 正确写法:用非阻塞赋值
always @(posedge clk) begin
a <= b;
c <= a; // 这里a还是旧值,c得到的是a的旧值
end
我的经验:记住一条铁律——写组合逻辑用阻塞赋值,写时序逻辑用非阻塞赋值。别问为什么,先照着做。等你写过几万行代码,自然就懂了。我曾经在同一个always块里混用两种赋值,结果综合出来的电路和仿真完全对不上,查了整整两天。
3.5 常用电路实战
理论讲完了,我们来点实际的。下面这三个电路,几乎每个芯片项目都会用到。
3.5.1 计数器
计数器是时序逻辑的入门级应用。一个带使能和同步复位的N位计数器:
module counter_nbit #(
parameter N = 8
)(
input wire clk,
input wire rst_n,
input wire en,
output reg [N-1:0] cnt
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
cnt <= {N{1'b0}};
else if (en)
cnt <= cnt + 1'b1;
end
endmodule
这里{N{1'b0}}是位拼接操作,生成N位全0。我习惯用这种方式,比直接写8'b0更通用。
3.5.2 分频器
分频器本质上就是计数器。比如实现一个2分频:
module clk_div2 (
input wire clk,
input wire rst_n,
output reg clk_out
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
clk_out <= 1'b0;
else
clk_out <= ~clk_out;
end
endmodule
注意,这种分频方式会产生新的时钟域。跨时钟域处理是个大话题,我们后面会专门讲。这里先提醒一句:尽量不要在设计中产生多个时钟,除非你很清楚CDC(Clock Domain Crossing)的处理方法。
3.5.3 状态机
状态机是控制逻辑的核心。我习惯用三段式写法,清晰且不易出错。
// 三段式状态机示例
module fsm_example (
input wire clk,
input wire rst_n,
input wire start,
output reg done
);
// 状态编码
localparam IDLE = 2'b00;
localparam WORK = 2'b01;
localparam DONE = 2'b10;
reg [1:0] state, next_state;
// 第一段:状态转移
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
state <= IDLE;
else
state <= next_state;
end
// 第二段:次态逻辑(组合逻辑)
always @(*) begin
case (state)
IDLE: next_state = start ? WORK : IDLE;
WORK: next_state = DONE;
DONE: next_state = IDLE;
default: next_state = IDLE;
endcase
end
// 第三段:输出逻辑(组合逻辑)
always @(*) begin
done = (state == DONE);
end
endmodule
三段式的好处:
- 状态转移和输出逻辑分离,代码可读性强
- 组合逻辑和时序逻辑分开,综合工具优化效果好
- 修改输出逻辑时不会影响状态机核心
好了,这一章的内容就到这里。模块化设计、组合逻辑、时序逻辑、阻塞与非阻塞赋值、常用电路,这些都是RTL设计的基石。下一章我们会继续深入,讲Verilog的更多高级用法。