第三章 硬件描述语言基础:从代码到电路
各位同学,今天我们来聊聊硬件描述语言。说实话,很多刚接触FPGA的朋友都会问:这玩意儿跟C语言有啥区别?我当年刚入行时也这么想,结果写出来的第一个模块,综合出来一堆莫名其妙的Latch。嗯,那都是血泪教训。
3.1 模块化设计:把大问题拆成小积木
模块化设计,说白了就是“分而治之”。你想想看,一个复杂的网络协议栈,动辄几万行代码,如果全写在一个文件里,调试起来简直要命。我个人习惯是把功能拆成独立的模块,每个模块只干一件事。
举个例子,一个简单的以太网帧处理模块,我会拆成这样:
// 顶层模块:以太网帧处理器
module eth_frame_processor (
input wire clk,
input wire rst_n,
input wire [7:0] data_in,
input wire data_valid,
output reg [7:0] data_out,
output reg data_ready
);
// 子模块实例化
wire [47:0] mac_dst, mac_src;
wire [15:0] eth_type;
wire header_valid;
// 解析MAC头部
mac_header_parser u_parser (
.clk (clk),
.rst_n (rst_n),
.data_in (data_in),
.data_valid (data_valid),
.mac_dst (mac_dst),
.mac_src (mac_src),
.eth_type (eth_type),
.header_valid (header_valid)
);
// 根据以太网类型分发数据
payload_dispatcher u_dispatcher (
.clk (clk),
.rst_n (rst_n),
.data_in (data_in),
.data_valid (data_valid & header_valid),
.eth_type (eth_type),
.data_out (data_out),
.data_ready (data_ready)
);
endmodule
看到没?顶层模块只负责连线,具体逻辑交给子模块。这样做的好处是:
- 可读性强:一眼就能看出数据流走向
- 可复用性高:MAC解析器可以在多个项目里重复使用
- 调试方便:哪个模块出问题,直接定位到那个文件
3.2 组合逻辑与时序逻辑:分清“现在”和“将来”
这是新手最容易搞混的地方。组合逻辑,输出只取决于当前输入;时序逻辑,输出还取决于之前的状态。说白了,组合逻辑是“即时反应”,时序逻辑是“有记忆的”。
来看个对比:
// 组合逻辑:纯加法器
assign sum = a + b;
// 时序逻辑:带寄存器的加法器
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
sum_reg <= 0;
else
sum_reg <= a + b;
end
注意看区别:组合逻辑用assign,时序逻辑用always @(posedge clk)。我曾经见过一个同事,把所有逻辑都写在always块里,结果综合出来的电路面积大了三倍。为什么?因为综合器会把所有信号都当成寄存器来处理。
这里有个实用的判断方法:
| 特性 | 组合逻辑 | 时序逻辑 |
|---|---|---|
| 输出延迟 | 几乎无延迟(ns级) | 延迟一个时钟周期 |
| 代码风格 | assign 或 always @(*) | always @(posedge clk) |
| 综合结果 | 门电路 | 触发器 + 门电路 |
| 典型应用 | 地址译码、数据选择 | 计数器、状态机、流水线 |
3.3 状态机设计:控制逻辑的灵魂
网络协议处理,本质上就是状态机。TCP的三次握手、IP分片重组、MAC层的帧接收……全是状态机。我个人最喜欢用三段式状态机,清晰又好维护。
来看一个ARP协议处理的状态机:
// 三段式状态机示例:ARP请求处理
localparam IDLE = 3'd0;
localparam PARSE_HDR = 3'd1;
localparam CHECK_IP = 3'd2;
localparam GEN_REPLY = 3'd3;
localparam SEND_REPLY = 3'd4;
reg [2:0] current_state, next_state;
// 第一段:状态转移
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
current_state <= IDLE;
else
current_state <= next_state;
end
// 第二段:次态逻辑(组合逻辑)
always @(*) begin
case (current_state)
IDLE: begin
if (frame_start)
next_state = PARSE_HDR;
else
next_state = IDLE;
end
PARSE_HDR: begin
if (hdr_done)
next_state = CHECK_IP;
else
next_state = PARSE_HDR;
end
CHECK_IP: begin
if (ip_match)
next_state = GEN_REPLY;
else
next_state = IDLE;
end
GEN_REPLY: begin
next_state = SEND_REPLY;
end
SEND_REPLY: begin
if (send_done)
next_state = IDLE;
else
next_state = SEND_REPLY;
end
default: next_state = IDLE;
endcase
end
// 第三段:输出逻辑(时序逻辑)
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
arp_reply_valid <= 0;
// ... 其他输出复位
end else begin
case (current_state)
GEN_REPLY: arp_reply_valid <= 1;
SEND_REPLY: arp_reply_valid <= 0;
default: arp_reply_valid <= 0;
endcase
end
end
3.4 仿真与测试:不仿真就流片?想都别想
仿真,是硬件开发中最重要的一环。我见过太多人,写完代码直接上板,结果板子冒烟了还不知道为什么。正确的流程应该是:写代码 → 写testbench → 仿真验证 → 上板测试。
一个简单的testbench长这样:
module tb_arp_processor;
reg clk;
reg rst_n;
reg [7:0] data_in;
reg data_valid;
wire [7:0] data_out;
wire data_ready;
// 实例化待测模块
arp_processor u_dut (
.clk (clk),
.rst_n (rst_n),
.data_in (data_in),
.data_valid (data_valid),
.data_out (data_out),
.data_ready (data_ready)
);
// 生成时钟
always #5 clk = ~clk;
// 测试序列
initial begin
clk = 0;
rst_n = 0;
data_in = 0;
data_valid = 0;
#20 rst_n = 1;
// 发送ARP请求
@(posedge clk);
data_in = 8'hFF; // 目的MAC
data_valid = 1;
// ... 发送完整帧
#100;
$finish;
end
// 波形输出
initial begin
$dumpfile("arp_test.vcd");
$dumpvars(0, tb_arp_processor);
end
endmodule
仿真时要注意几个关键点:
- 覆盖率:不仅要测正常情况,还要测边界条件。比如ARP请求的目标IP是自己的IP,或者MAC地址全0的情况。
- 时序检查:用$setup/$hold检查时序约束是否满足。
- 自动比对:写个脚本自动比对仿真输出和预期结果,别用眼睛盯着波形看。
3.5 知识体系总览
下面这张图,是我自己总结的硬件描述语言学习路径。你把它打印出来贴在工位上,写代码时对照着看,能少走很多弯路。
这张图把本章的核心内容串起来了。你从模块化设计入手,先学会拆功能;然后掌握组合逻辑和时序逻辑的区别;接着用状态机把控制逻辑串起来;最后用仿真验证一切。每一步都踩实了,写出来的代码才能可靠。
公众号:蓝海资料掘金营,微信deep3321