3、硬件描述语言基础(Verilog/VHDL):寄存器与组合逻辑,状态机设计,时序约束入门
各位好,我是老张。今天咱们聊聊硬件描述语言的基础。说实话,很多做算法的同事一看到 Verilog 就头疼,觉得跟 C 语言长得像但用起来完全不是那么回事。嗯,确实如此。硬件描述语言描述的是电路,不是指令序列。这个思维转换,是入门的第一道坎。
我个人习惯把 HDL 看作「画电路图的文本工具」。你写的每一行代码,最终都会变成实实在在的门电路、触发器和连线。想清楚这一点,很多问题就迎刃而解了。
3.1 寄存器与组合逻辑:数字电路的两大基石
数字电路说白了就两种东西:组合逻辑和时序逻辑。组合逻辑是纯门电路,输出只取决于当前输入;时序逻辑带记忆,输出还跟历史状态有关。寄存器就是时序逻辑的核心元件。
3.1.1 组合逻辑:没有记忆的「纯函数」
组合逻辑就像数学里的纯函数——输入一样,输出永远一样。常见的与或非门、加法器、多路选择器都属于组合逻辑。
在 Verilog 里,组合逻辑通常用 assign 连续赋值或者 always @(*) 过程块来描述。我建议初学者尽量用 assign,简单直观,不容易出错。
// 组合逻辑示例:半加器
module half_adder(
input wire a, b,
output wire sum, cout
);
assign sum = a ^ b; // 异或:和
assign cout = a & b; // 与:进位
endmodule
这里有个坑。我曾经见过一个同事,在 always @(*) 块里漏写了某个赋值分支,结果综合出来一个锁存器(latch)。锁存器这东西,时序分析时特别麻烦,能不用尽量别用。怎么避免?记住一条:组合逻辑的 always 块里,所有被赋值的变量在每个分支都要赋值,或者给个默认值。
always @(*) 或者 always_comb(SystemVerilog),让工具自动推导敏感列表。
3.1.2 寄存器:数字电路的「记忆细胞」
寄存器本质上是 D 触发器的集合。它在时钟沿采样输入数据,并保持到下一个时钟沿。没有寄存器,数字电路就没法实现流水线、状态机、计数器这些高级功能。
Verilog 里描述寄存器很简单,用 always @(posedge clk) 块就行。注意:被赋值的变量要声明为 reg 类型。
// 寄存器示例:8位同步复位寄存器
module reg_8bit(
input wire clk,
input wire rst_n, // 低电平复位
input wire [7:0] d,
output reg [7:0] q
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
q <= 8'b0; // 异步复位
else
q <= d; // 时钟沿采样
end
endmodule
注意这里用的是非阻塞赋值 <=。为什么?因为寄存器赋值需要「同时发生」的效果。如果用阻塞赋值 =,仿真时会出现意想不到的时序问题。我刚开始学的时候也搞混过,后来记住一句话:时序逻辑用非阻塞,组合逻辑用阻塞。
| 特性 | 组合逻辑 | 寄存器(时序逻辑) |
|---|---|---|
| 输出依赖 | 仅当前输入 | 当前输入 + 历史状态 |
| 时钟依赖 | 无 | 需要时钟沿触发 |
| Verilog 赋值 | =(阻塞) |
<=(非阻塞) |
| 综合结果 | 门电路 | 触发器 + 门电路 |
| 典型用途 | 译码器、加法器、MUX | 计数器、状态机、流水线 |
3.2 状态机设计:控制通路的「大脑」
状态机是数字电路里最常用的控制结构。3A 算法里的数据调度、流水线控制、握手协议,背后都是状态机在驱动。
我个人习惯把状态机分成三段式来写。为什么?因为可读性好,综合结果也干净。三段式分别是:
- 状态转移:当前状态 + 输入 → 下一状态
- 状态寄存器:时钟沿更新当前状态
- 输出逻辑:根据当前状态(或状态+输入)产生输出
你想想看,把这三个部分分开,调试的时候一眼就能看出问题在哪。我曾经接手过一个项目,状态机全写在一个 always 块里,两百多行代码,改一个状态要翻半天。后来我花了一周时间重构,拆成三段式,问题瞬间清晰了。
// 三段式状态机示例:简单交通灯控制
module traffic_light(
input wire clk,
input wire rst_n,
input wire car_sensor,
output reg [1:0] light // 00:红, 01:黄, 10:绿
);
// 状态编码
localparam IDLE = 2'b00;
localparam GREEN = 2'b01;
localparam YELLOW = 2'b10;
localparam RED = 2'b11;
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 = car_sensor ? GREEN : IDLE;
GREEN: next_state = YELLOW; // 固定时间后转黄灯
YELLOW: next_state = RED;
RED: next_state = car_sensor ? GREEN : RED;
default: next_state = IDLE;
endcase
end
// 第三段:输出逻辑(组合逻辑)
always @(*) begin
case (state)
IDLE: light = 2'b00; // 红灯
GREEN: light = 2'b10; // 绿灯
YELLOW: light = 2'b01; // 黄灯
RED: light = 2'b00; // 红灯
default: light = 2'b00;
endcase
end
endmodule
3.3 时序约束入门:让工具知道你的「期望」
写完了代码,综合工具怎么知道你的电路要跑多快?靠时序约束。说白了,时序约束就是告诉工具:「我希望这个路径在多少纳秒内完成」。
我刚开始做部署时,觉得时序约束是后端的事,跟我没关系。直到有一次,综合出来的网表仿真全对,上板子就是跑不稳。查了三天,发现是跨时钟域的路径没加约束,工具默认给了很松的限制。嗯,从那以后我再也不敢忽视时序约束了。
3.3.1 最基本的约束:时钟周期
最常用的约束就是定义时钟周期。SDC(Synopsys Design Constraints)格式里这样写:
# 定义时钟,周期10ns,占空比50%
create_clock -name clk -period 10.000 [get_ports clk]
# 定义输入延迟(相对于时钟沿)
set_input_delay -clock clk -max 2.000 [get_ports data_in]
set_input_delay -clock clk -min 0.500 [get_ports data_in]
# 定义输出延迟
set_output_delay -clock clk -max 3.000 [get_ports data_out]
set_output_delay -clock clk -min 1.000 [get_ports data_out]
这里有个关键点:set_input_delay 和 set_output_delay 描述的是外部器件的时序特性,不是芯片内部的。很多新手搞反了,以为这是芯片内部的延迟。其实它告诉工具:数据相对于时钟沿提前/滞后多久到达芯片引脚。
3.3.2 跨时钟域约束
3A 算法里经常有多个时钟域(比如 AXI 总线时钟和计算核时钟)。跨时钟域的路径必须加 set_false_path 或者用同步器处理。不加约束的话,工具会试图优化这些路径的时序,浪费大量资源。
# 跨时钟域路径,设为 false path
set_false_path -from [get_clocks clk_a] -to [get_clocks clk_b]
set_false_path -from [get_clocks clk_b] -to [get_clocks clk_a]
3.4 本章知识体系
下面这张图总结了本章的核心知识点和它们之间的关系。我建议你把它当作学习路线图,按顺序逐个攻克。
本章核心要点:
- 组合逻辑没有记忆,用
assign或always @(*)描述,注意避免锁存器。 - 寄存器有时钟沿触发,用
always @(posedge clk)描述,赋值用<=。 - 状态机推荐三段式写法:状态转移 + 状态寄存器 + 输出逻辑,分离清晰。
- 时序约束是告诉工具你的时序期望,时钟约束是基础,跨时钟域要设 false path。
好了,这一章的内容就这些。记住:写 HDL 的时候,脑子里要时刻想着「这行代码会综合成什么电路」。有了这个意识,你离资深工程师就不远了。