3. 硬件描述语言基础
各位同学,今天我们聊聊Verilog。说实话,很多初学者觉得Verilog就是另一种编程语言,写起来跟C差不多。嗯,这个想法很危险。我在项目里见过太多人把Verilog当C写,结果综合出来的电路完全不是那么回事。今天我们就把它掰扯清楚。
3.1 Verilog HDL基本语法——别把它当软件写
Verilog的核心思想是描述硬件,不是执行指令。你写的每一行代码,最终都会变成实实在在的门电路、触发器和连线。这一点,请务必刻在脑子里。
3.1.1 模块结构
一个Verilog设计的基本单元是module。我习惯把它想象成一个芯片的封装——有输入引脚、输出引脚,内部有逻辑。
module counter (
input wire clk,
input wire rst_n,
output reg [7:0] count
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
count <= 8'd0;
else
count <= count + 1'b1;
end
endmodule
你看,input和output定义了端口方向。wire和reg是数据类型。这里有个坑:wire用于组合逻辑连线,reg用于存储变量。但注意,在always块里赋值的变量必须声明为reg,哪怕它综合出来只是个连线。
wire,然后在always块里赋值,编译直接报错。记住:always块里只能用reg。
3.1.2 数据类型与运算符
Verilog的数据类型其实不多,常用的就几种:
| 类型 | 说明 | 我常用的场景 |
|---|---|---|
wire |
线网类型,用于组合逻辑 | 模块间连线、assign赋值 |
reg |
寄存器类型,用于时序逻辑 | always块内的变量 |
integer |
32位有符号整数 | 仿真中的循环变量 |
parameter |
常量定义 | 定义位宽、计数值等 |
运算符方面,==和===的区别你搞清楚了吗?===会比较x和z状态,而==不会。仿真时,如果信号是x态,用==比较会返回x,条件判断可能出错。我个人习惯在状态机里用===,避免仿真陷阱。
3.1.3 阻塞赋值与非阻塞赋值
这是新手最容易翻车的地方。说白了:
- 阻塞赋值
=:顺序执行,前面的赋值会阻塞后面的。用于组合逻辑。 - 非阻塞赋值
<=:并行执行,所有赋值同时发生。用于时序逻辑。
为什么会这样?因为硬件里,多个触发器是在同一个时钟沿同时更新的。你用=写时序逻辑,仿真结果和实际电路可能完全不一样。
我的铁律: 写时序逻辑永远用<=,写组合逻辑用=。别问为什么,先记住,以后自然懂。
3.2 模块化设计——把大问题拆成小模块
你想想看,一个复杂的Zynq系统,如果所有逻辑都写在一个模块里,那调试起来得多痛苦?模块化设计就是把功能拆解,每个模块负责一件事。
3.2.1 模块划分原则
我在项目中总结了几条经验:
- 高内聚低耦合:每个模块内部功能紧密相关,模块间接口尽量简单。
- 单一职责:一个模块只做一件事。比如,计数器模块只计数,不要在里面混入串口逻辑。
- 接口标准化:尽量用统一的握手信号(valid/ready)或AXI总线接口。
3.2.2 模块实例化
写好了模块,怎么用?实例化。就像你买了一个芯片,把它焊到板子上,连好线。
// 顶层模块
module top (
input wire clk,
input wire rst_n,
output wire [7:0] led
);
wire [7:0] count;
// 实例化计数器模块
counter u_counter (
.clk (clk),
.rst_n (rst_n),
.count (count)
);
// 把计数值直接连到LED
assign led = count;
endmodule
注意实例化时的端口连接方式。我推荐用.端口名(连线名)这种显式连接,虽然代码长一点,但可读性强,不容易出错。用位置连接?嗯,我年轻时用过,后来改代码时把自己坑惨了。
3.2.3 参数化设计
用parameter让模块更灵活。比如计数器模块,位宽可以配置:
module counter #(
parameter WIDTH = 8
) (
input wire clk,
input wire rst_n,
output reg [WIDTH-1:0] count
);
// 逻辑同上,但位宽由参数决定
endmodule
// 实例化时指定参数
counter #(.WIDTH(16)) u_counter (...);
这样,同一个模块可以复用在不同位宽的场景。我在做多通道数据采集时,就用参数化模块生成了8个不同位宽的计数器,省了不少事。
3.3 仿真与综合——先仿真,后综合,别偷懒
写完了代码,下一步就是验证。我见过太多人直接上板调试,结果花的时间比写代码还多。仿真不花钱,上板烧芯片可是要花钱的。
3.3.1 仿真基础
仿真就是给设计加激励,看输出对不对。常用的仿真工具有Vivado Simulator、ModelSim、VCS等。写一个简单的testbench:
`timescale 1ns / 1ps
module tb_counter;
reg clk;
reg rst_n;
wire [7:0] count;
// 实例化被测试模块
counter uut (
.clk (clk),
.rst_n (rst_n),
.count (count)
);
// 生成时钟
initial begin
clk = 0;
forever #5 clk = ~clk; // 10ns周期,100MHz
end
// 生成复位
initial begin
rst_n = 0;
#20;
rst_n = 1;
end
// 监控输出
initial begin
#200;
$display("Test completed");
$finish;
end
endmodule
仿真时,我会先看波形,确认每个信号的变化是否符合预期。特别是复位释放后的第一个时钟沿,计数器是否从0开始?边界条件是否处理正确?
💡 我的习惯: 仿真时加一些$display打印关键信息,但别太多,否则输出刷屏反而看不清。重点打印状态跳转、数据有效标志等。
3.3.2 综合——把代码变成电路
综合是把Verilog代码映射到目标器件的逻辑单元(LUT、FF、BRAM等)。Vivado里跑综合时,我会关注几个指标:
- 资源利用率:LUT、FF、BRAM用了多少?别超了。
- 时序报告:WNS(最差负时序裕量)是不是正的?如果是负的,说明你的设计跑不到目标频率。
- 警告信息:Vivado的警告很多,但有些是致命的。比如"inferred latch"——这通常意味着组合逻辑里漏写了else分支。
⚠ 我曾经犯过的错: 有一次综合后看到"inferred latch"警告,没当回事。结果上板后,某个信号在特定条件下保持不定态,查了两天才发现是组合逻辑的case语句没写default。从那以后,我写组合逻辑一定补全所有分支。
3.3.3 仿真与综合的区别
仿真通过不代表综合没问题。为什么?因为仿真器是软件,它不关心你的代码能不能综合成硬件。比如:
initial块里的#delay——仿真能用,但综合会忽略或报错。for循环——仿真没问题,但综合时如果循环次数不是常数,会报错。integer变量——仿真灵活,但综合成硬件会占用大量资源。
所以,我的流程是:先写代码,跑仿真验证功能,再综合检查资源和时序。仿真和综合是两条腿走路,缺一不可。
3.4 本章小结
Verilog基础就这些。说白了,语法是工具,模块化是方法,仿真和综合是验证手段。你写代码时,脑子里要时刻想着:这行代码会综合成什么电路?是LUT还是触发器?连线对不对?
嗯,今天就到这里。记住,多写多练,仿真跑通了再上板。别像我当年那样,代码写完直接烧芯片,结果冒烟了才后悔。