2. FPGA基础回顾
好,我们正式开始第二讲。这一章是地基中的地基——Verilog基础与组合逻辑设计。别小看这些基础,我见过太多工程师在阻塞与非阻塞赋值上栽跟头,一查就是几个小时。咱们今天就把这些概念彻底理清楚。
2.1 模块化设计思想:把大问题拆成小积木
FPGA设计说白了就是搭积木。一个复杂的RISC-V处理器,你不可能在一个文件里写完所有逻辑。模块化设计就是把这个大系统拆成一个个功能独立的小模块,每个模块负责一件事。
我个人习惯这样划分模块:
- 每个模块只做一件事——比如加法器只管加,多路选择器只管选
- 接口清晰——输入输出定义明确,不搞隐式连接
- 可复用——写一次,到处例化
举个例子,一个4位加法器模块的接口定义:
module adder_4bit (
input [3:0] a, // 输入a
input [3:0] b, // 输入b
input cin, // 进位输入
output [3:0] sum, // 和
output cout // 进位输出
);
assign {cout, sum} = a + b + cin;
endmodule
你看,接口一目了然。别人拿到这个模块,不用看内部实现就知道怎么用。这就是模块化的价值。
2.2 assign语句:连续赋值的精髓
assign是组合逻辑最常用的语句。它描述的是线网的连续驱动关系——只要右边表达式变化,左边立即更新。
说白了,assign就像一根硬连线,左边和右边时刻保持同步。
// 基本用法
assign y = a & b; // 与门
assign z = (sel) ? a : b; // 二选一多路器
// 位拼接
assign {cout, sum} = a + b;
这里要注意:assign的左边必须是wire类型,不能是reg。我刚开始学的时候总搞混,后来记住一句话就再也没错过——"assign赋值给线网,always赋值给寄存器"。
2.3 always块:敏感列表与组合逻辑描述
always块是Verilog的另一个核心。它既可以描述组合逻辑,也可以描述时序逻辑。区别在于敏感列表怎么写。
对于组合逻辑,敏感列表里要包含所有输入信号:
// 组合逻辑的always块
always @(*) begin
if (sel)
y = a;
else
y = b;
end
这里的@(*)表示"对所有输入信号变化敏感"。我建议组合逻辑一律用@(*),别手动列信号——万一漏了一个,仿真和综合结果就不一致了。我曾经因为这个坑,花了整整一个下午排查一个"看起来没问题但仿真就是不对"的bug。
2.4 阻塞赋值与非阻塞赋值:一字之差,天壤之别
这是Verilog里最容易踩的坑,没有之一。我面试FPGA工程师时,必问这个问题。
| 特性 | 阻塞赋值 (=) | 非阻塞赋值 (<=) |
|---|---|---|
| 执行顺序 | 顺序执行,阻塞后续语句 | 并行执行,不阻塞后续语句 |
| 适用场景 | 组合逻辑(always块内) | 时序逻辑(always @(posedge clk)) |
| 硬件行为 | 立即更新 | 在时钟沿统一更新 |
| 典型错误 | 在时序逻辑中用阻塞赋值 | 在组合逻辑中用非阻塞赋值 |
记住这个黄金法则:
组合逻辑用阻塞赋值(=),时序逻辑用非阻塞赋值(<=)。
这个规则不要打破,除非你非常清楚自己在做什么。
为什么?我举个例子你就明白了:
// 错误示例:时序逻辑中用阻塞赋值
always @(posedge clk) begin
a = b;
c = a; // 这里c拿到的是a更新后的值,不是a在时钟沿前的值
end
// 正确示例:时序逻辑中用非阻塞赋值
always @(posedge clk) begin
a <= b;
c <= a; // 这里c拿到的是a在时钟沿前的值,符合寄存器行为
end
第一个例子综合出来的电路和你想象的可能完全不同。我见过有人把移位寄存器写成这样,结果仿真对了,上板子就乱套——因为综合器推断出的硬件和仿真模型不一致。
2.5 加法器实战:从半加器到全加器
加法器是CPU的核心运算单元。咱们从最简单的开始,一步步搭起来。
半加器:两个1位二进制数相加,产生和与进位。
module half_adder (
input a, b,
output sum, cout
);
assign sum = a ^ b; // 异或:和
assign cout = a & b; // 与:进位
endmodule
全加器:三个1位二进制数相加(两个加数+进位输入)。
module full_adder (
input a, b, cin,
output sum, cout
);
assign sum = a ^ b ^ cin;
assign cout = (a & b) | (a & cin) | (b & cin);
endmodule
行波进位加法器:把多个全加器串联起来,前一级的进位输出接到后一级的进位输入。
module ripple_carry_adder_4bit (
input [3:0] a, b,
input cin,
output [3:0] sum,
output cout
);
wire c1, c2, c3;
full_adder fa0 (.a(a[0]), .b(b[0]), .cin(cin), .sum(sum[0]), .cout(c1));
full_adder fa1 (.a(a[1]), .b(b[1]), .cin(c1), .sum(sum[1]), .cout(c2));
full_adder fa2 (.a(a[2]), .b(b[2]), .cin(c2), .sum(sum[2]), .cout(c3));
full_adder fa3 (.a(a[3]), .b(b[3]), .cin(c3), .sum(sum[3]), .cout(cout));
endmodule
你看,这就是模块化的威力——写一个全加器,然后例化4次就得到一个4位加法器。
2.6 多路选择器实战:数据通路的选择开关
多路选择器(MUX)在数据通路中无处不在——寄存器堆的写地址选择、ALU的输入选择、访存数据的选择……
用assign实现二选一:
module mux2_1 (
input a, b, sel,
output y
);
assign y = sel ? b : a;
endmodule
用always块实现四选一:
module mux4_1 (
input [3:0] data,
input [1:0] sel,
output reg y
);
always @(*) begin
case (sel)
2'b00: y = data[0];
2'b01: y = data[1];
2'b10: y = data[2];
2'b11: y = data[3];
default: y = 1'b0; // 覆盖所有分支,避免latch
endcase
end
endmodule
这里有个细节:case语句一定要写default分支。不写的话,如果sel出现未覆盖的值(比如仿真中的'x'或'z'),综合器会推断出锁存器。
2.7 本章知识体系总览
下面这张图把本章的核心知识点串起来了。你可以把它当作一个快速索引,复习时对照着看。
2.8 避坑指南:我踩过的那些坑
最后,分享几个我实际项目中遇到的教训:
- 敏感列表不完整——组合逻辑的always块里,如果敏感列表漏了某个输入,仿真和综合结果不一致。用
@(*)就对了。 - 时序逻辑用阻塞赋值——仿真可能对,但综合出来的电路和你想象的不一样。我见过有人把移位寄存器写成阻塞赋值,结果上板子数据全乱套。
- case语句没有default——综合器会推断出锁存器。锁存器在FPGA里是"隐形炸弹",时序分析时很难发现。
- 模块端口不匹配——例化模块时,端口连接顺序搞错。建议用
.port_name(signal)的显式连接方式,别用位置连接。