第三讲:硬件描述语言基础(Verilog/VHDL)
各位同学好,今天我们聊聊硬件描述语言的基础。说实话,很多新手一上来就被Verilog和VHDL的选择问题给卡住了。我个人觉得,语言只是工具,核心是你能不能把硬件逻辑想清楚。不过既然要做工业级设计,这两者的差异还是得搞明白。
3.1 Verilog与VHDL语法对比
先说说我的个人经历。我最早用的是VHDL,后来转到Verilog。为什么?因为工业界,尤其是ASIC和高端FPGA领域,Verilog的生态更成熟。但VHDL在军工、航天领域依然很强势。你想想看,如果你去面试一家做卫星通信的公司,他们可能更看重VHDL。
下面这张表是我自己总结的,比较实用:
| 对比项 | Verilog | VHDL |
|---|---|---|
| 语法风格 | 类C语言,简洁 | 类Ada语言,严谨 |
| 大小写敏感 | 是 | 否 |
| 数据类型 | wire/reg,相对简单 | std_logic/std_logic_vector,类型严格 |
| 设计抽象 | 偏向行为级描述 | 偏向结构级描述 |
| 库管理 | 无显式库声明 | 需要use ieee.std_logic_1164.all |
| 并发语句 | always块 | process块 |
这里有个坑,我提醒一下。Verilog的reg类型,名字叫"寄存器",但它不一定综合成寄存器。比如在组合逻辑的always块里,reg只是表示一个变量,综合出来是连线。我曾经见过一个同事,看到reg就以为一定是触发器,结果综合出来的电路跟他想的不一样,排查了半天。
3.2 模块化设计思想
模块化设计,说白了就是"分而治之"。一个复杂系统,你不可能在一个文件里写完所有逻辑。我习惯把设计分成三层:
- 顶层模块:负责例化子模块,连接信号,不写具体逻辑
- 功能模块:实现具体功能,比如计数器、状态机、FIFO
- 基础单元:比如D触发器、多路选择器,通常由工具自动推断
举个例子,一个简单的SPI控制器,我会这样划分:
// 顶层模块
module spi_top (
input clk, rst_n,
input spi_cs, spi_sck, spi_mosi,
output spi_miso
);
wire [7:0] tx_data, rx_data;
wire tx_valid, rx_valid;
spi_ctrl u_ctrl (
.clk(clk), .rst_n(rst_n),
.spi_cs(spi_cs), .spi_sck(spi_sck),
.spi_mosi(spi_mosi), .spi_miso(spi_miso),
.tx_data(tx_data), .tx_valid(tx_valid),
.rx_data(rx_data), .rx_valid(rx_valid)
);
spi_fifo u_fifo (
.clk(clk), .rst_n(rst_n),
.tx_data(tx_data), .tx_valid(tx_valid),
.rx_data(rx_data), .rx_valid(rx_valid)
);
endmodule
你看,顶层模块只做连线,不写逻辑。这样调试的时候,我可以单独仿真每个子模块,定位问题快很多。
3.3 常用数据类型
Verilog里最常用的就是wire和reg。SystemVerilog引入了logic,算是把两者统一了。我个人的建议是:
- wire:用于连线,比如模块之间的信号连接
- reg:用于always块内的变量赋值
- logic:SystemVerilog推荐,可以替代wire和reg
这里有个容易混淆的地方。我记得有一次,一个新手问我:"为什么我用了logic,综合出来还是组合逻辑?" 嗯,logic只是数据类型,它不决定综合结果。决定综合结果的是你怎么写always块。比如:
// 组合逻辑
always @(*) begin
if (sel)
y = a;
else
y = b;
end
// 时序逻辑
always @(posedge clk) begin
if (rst_n)
q <= 0;
else
q <= d;
end
看到了吗?敏感列表不同,赋值方式不同(阻塞赋值 vs 非阻塞赋值),综合出来的电路就完全不同。
3.4 运算符与表达式
运算符这块,Verilog和C语言很像,但有几个地方要特别注意:
| 运算符类别 | 符号 | 说明 |
|---|---|---|
| 算术 | + - * / % | 乘除法综合出来资源很大,慎用 |
| 逻辑 | && || ! | 返回1位布尔值 |
| 按位 | & | ~ ^ | 按位操作,返回多位 |
| 归约 | & | ^ | 单目运算符,将多位归约为1位 |
| 移位 | << >> | 逻辑移位,空位补0 |
| 拼接 | {a, b} | 将多个信号拼接成更宽的向量 |
我特别想说说归约运算符。比如&data,它会把data的所有位做与操作,返回1位结果。这个在检测全1或者全0的时候非常有用。我曾经用|data来判断一个总线是否空闲,比写循环快多了。
3.5 代码风格规范
代码风格,说白了就是"让别人能看懂你的代码"。我见过太多"天书"代码了,变量名用a、b、c,没有注释,缩进乱七八糟。这种代码,别说别人看不懂,过两个月你自己都看不懂。
我总结了几条硬性规范:
- 命名规范:信号名用下划线分隔,比如
tx_data_valid;参数用大写,比如DATA_WIDTH - 缩进:统一用2个空格或4个空格,不要混用Tab和空格
- 注释:每个模块头部写清楚功能、输入输出、修改历史
- 代码块:begin/end对齐,always块内每个if-else对齐
- 避免魔法数字:用parameter定义常量,比如
parameter CLK_DIV = 100_000
这里我画了一张图,把本章的知识体系串起来:
核心要点:语言只是工具,模块化设计才是灵魂。代码风格决定了你的设计能不能被团队理解和维护。
个人小技巧:我习惯在代码开头写一个// Revision History的注释块,记录每次修改的内容和日期。这样出了问题,可以快速回溯。
注意:不要滥用always @(posedge clk)。每个always块只描述一个功能,比如一个计数器、一个状态机。把多个功能塞进一个always块,代码会变得难以阅读和调试。
好了,这一讲的内容就到这里。记住,写代码不是写作文,是画电路。每一行代码,你都要能想象出它综合出来是什么样子。下次我们聊聊组合逻辑和时序逻辑的深入对比。
公众号:蓝海资料掘金营,微信deep3321