第 03 讲:数字逻辑与 Verilog 基础(下)
课前导读:建立“硬件描述”的底层心智模型
在正式敲下第一行 Verilog 代码之前,必须彻底洗掉以往学习 C 语言、Python 等软件编程形成的“软件思维”:
软件代码(C/Python):是按时间一步一步往下执行的指令序列,运行在一颗单核或多核 CPU 上,指令指针自上而下逐行跑。
硬件描述语言(Verilog HDL):是用来描摹一块集成电路硅片内部结构的“图纸”。代码中写的不是指令,而是真实的导线、与门、或门、选择器和触发器。芯片通电后,千千万万个晶体管在同一时刻都有电流流过,所有模块、所有赋值、所有逻辑门都在 100% 同时并行运行!
第一章:预编译指令与工程规范(Compiler Directives)
预编译指令以反引号 `(键盘 Esc 键下方、数字 1 左侧的按键)开头。它在编译器真正分析电路之前先行处理,决定了仿真器的时间刻度与编译器的代码检查强度。
1.1 `timescale <时间单位> / <时间精度>
`timescale 1ns / 1ps
- 时间单位(Time Unit):代码中所有延时控制符号
#数字的基准。- 若写
#10,因单位是1ns,则代表延时 $10\text{ ns}$。
- 若写
- 时间精度(Time Precision):仿真器内部推进时间的最小刻度。
- 精度是
1ps($0.001\text{ ns}$),意味着写#10.125是有效的;若精度是1ns,#10.125会被四舍五入为#10。
- 精度是
- 基本准则:时间精度必须小于或等于时间单位(绝不能写
1ps / 1ns)。 - 【老师敲黑板 · 避坑天条】:
若工程中有多个文件,有的写了`timescale 1ns/1ps,有的写了`timescale 1us/1ns,或者有的文件漏写了,EDA 工具会以最后编译的文件为准,导致仿真波形的时钟周期相差 1000 倍!工程规范要求:每个包含延时的.v文件顶端都必须统一显式声明`timescale。
1.2 `default_nettype none(资深工程师的防错利器)
-
Verilog 默认机制的暗坑:如果你的代码里拼错了一个信号名(例如把
sys_clk误写成了sys_clkk),Verilog 默认会默默将其声明为 1 位的wire,编译完全不报错,但该信号在硬件上永远悬空断路,导致排查 Bug 耗费数天! -
解决方案:在文件第一行加入:
`default_nettype none强制编译器要求:所有用到的线网必须显式声明类型。一旦有任何拼写错误,编译阶段立即报错拦截。
1.3 `define 宏定义 vs parameter 参数
-
`define:全局文本替换,作用域跨越整个工程所有文件。`define DATA_WIDTH 32 // 定义宏 wire [`DATA_WIDTH-1 : 0] d; // 文本直接被替换为 31:0- 缺点:容易在不同模块间引起同名宏污染。
-
parameter/localparam:模块内部私有常量,遵循模块作用域,是模块化参数化设计的绝对主力。
1.4 条件编译:`ifdef、`else、`endif
根据不同环境动态选择综合哪一部分电路:
`ifdef SIMULATION
// 仿真环境下:计数器目标设为 10,瞬间跑完,避免仿真卡顿
parameter CNT_MAX = 10;
`else
// 真实开发板上:计数器目标设为 50,000,000,精准倒数 1 秒
parameter CNT_MAX = 50_000_000;
`endif
第二章:四值逻辑系统与硅片物理映射
Verilog 中的导线并非只有 0 和 1 两种抽象概念。为了精准描述半导体物理特性,它支持 4 种逻辑状态:
VDD (3.3V / 1.8V 电源)
│
┌─┴─┐
│ P │ 导通 ──► 输出 1 (高电平)
└─┬─┘
├────── 物理输出引脚
┌─┴─┐
│ N │ 导通 ──► 输出 0 (低电平)
└─┬─┘
│
GND (0V 接地)
| 逻辑电平 | 名称 | 硅片物理层真相 | 什么时候会发生? |
|---|---|---|---|
0 |
逻辑低电平 | 下方 N-MOS 导通,引脚被强制拉低接到地($0\text{ V}$)。 | 正常的逻辑 0 计算结果。 |
1 |
逻辑高电平 | 上方 P-MOS 导通,引脚被强制拉高接到电源($3.3\text{ V}$)。 | 正常的逻辑 1 计算结果。 |
z / Z |
高阻态(悬空) | 上下两个 MOS 管同时关闭! 引脚与内部芯片断开,电阻呈无穷大。 | ① 双向数据总线(如 I2C)释放总线时; ② 芯片引脚用作纯外部输入时。 |
x / X |
未知态 / 冲突 | 电压浮在半空中(如 $1.5\text{ V}$ 阈值区),无法判定是 0 还是 1。 | ① 电路上电未复位,寄存器不知道初始值; ② 总线冲突:两个输出端短路连在一起,一个硬推 1,一个硬拉 0! |
第三章:数据对象体系与存储器建模
3.1 线网型(Net):wire
- 物理模型:代表现实中的金属铜箔导线,自身无法锁存任何电荷。
- 驱动准则:
- 只能由
assign连续赋值语句驱动,或作为底层模块的输出端由逻辑门持续推流; - 绝对禁止在
always、initial过程块中位于等号左边被赋值。
- 只能由
wire flag; // 1 根单独导线
wire [7:0] data_bus; // 8 根导线捆绑在一起组成的 8 位并行数据总线
3.2 变量型(Variable):reg、integer
- 物理模型:代表能够保存数据的“小抽屉”。
- 语法准则:凡是在
always、initial、task块中位于等号左侧(LHS)被赋值的变量,必须无条件声明为reg型! - 【全课最大考点 · 概念辨析】:
代码里写了reg,综合后就一定是硬件上的寄存器(触发器)吗?—— 绝对不一定!- 若写在时钟边沿触发块中(
always @(posedge clk)):它会被综合成真实的带时钟端 D 触发器(D-FF); - 若写在纯组合逻辑块中(
always @(*)):且所有分支都写完整了,它会被综合为普通导线或多路选择器(MUX),没有任何物理寄存器!
- 若写在时钟边沿触发块中(
3.3 标量与向量(Scalar vs Vector)
-
标量:位宽为 1 的信号,如
wire a;。 -
向量:位宽大于 1 的多位总线,定义格式为:
[最高有效位 MSB : 最低有效位 LSB]。wire [7:0] bus_a; // 推荐规范写法:大端在上,7 为最高位,0 为最低位 reg [0:7] bus_b; // 极少使用的倒序写法:0 为最高位,7 为最低位(极易混淆,禁止使用)
进阶技巧:动态范围索引(+: 与 -:)
在标准 Verilog 中,切片范围 bus[a : b] 的 a 和 b 必须是常数。如果想在循环中动态截取总线(例如每 8 位取一截),写 bus[i*8+7 : i*8] 是非法语法。
- 标准解决方案:
[base +: width]:从base索引开始,**向上(高位方向)**连续取width位。bus[0 +: 8]$\to$ 等效于bus[7:0]bus[8 +: 8]$\to$ 等效于bus[15:8]
[base -: width]:从base索引开始,**向下(低位方向)**连续取width位。bus[15 -: 8]$\to$ 等效于bus[15:8]
3.4 存储器与 RAM 建模(Memory Array)
通过声明多维数组,可以直接在 FPGA 内部推导出分布式 RAM 或 Block RAM:
reg [7:0] ram_block [0:255]; // 这是一个深度为 256、数据位宽为 8 位的内部 RAM
[7:0](前面的方括号):每一个存储单元的位宽(8 位宽)。[0:255](后面的方括号):整个 RAM 的地址深度(拥有 256 个单元房间)。- 读写调用:
- 写入第 5 个房间:
ram_block[5] <= 8'hAB; - 读取第 5 个房间的整包数据:
wire [7:0] dout = ram_block[5]; - 【禁忌】:不能直接写
ram_block[5][2]去读第 5 个房间的第 2 位(需先读出到临时变量再切片)。
- 写入第 5 个房间:
3.5 有符号数与补码运算:signed
-
Verilog 默认所有变量均为无符号数(Unsigned)。
-
若要进行真正的数学负数运算,必须加上
signed关键字:reg signed [7:0] a, b; wire signed [15:0] result = a * b; // 综合器会调取底层的补码乘法器硬件 -
系统转换函数:
$signed(a):强制将无符号数转换为补码有符号数。$unsigned(b):强制解释为无符号数。
第四章:常量表示规范与位宽转换暗坑
4.1 格式规范:[位宽]'[进制][数值]
- 位宽:对应的物理二进制导线根数。
- 进制:
'b(二进制,Binary)'o(八进制,Octal)'d(十进制,Decimal)'h(十六进制,Hexadecimal)
- 下划线
_:为了防止数错零,可随意插入数值内部(如32'd50_000_000、24'hFF_FFFF),编译器会自动忽略。
4.2 位宽不匹配时的硬件行为
wire [7:0] w1 = 4'b1101; // 小填大:位宽不足
wire [3:0] w2 = 8'b1101_0011; // 大填小:位宽过大
- 零扩展(Zero-Extension):
w1实际得到:8'b0000_1101(高位自动补零)。
- 硬件截断(Truncation):
w2实际得到:4'b0011(高位的1101被直接切断丢弃,仅保留最低的 4 位)。
第五章:运算符全谱系与硬件电路映射
Verilog 运算符不是抽象的算式,它每一个符号在硅片中都有一组专属门电路对应。
5.1 运算符分类与门级映射总表
算术运算 (+, -, *) ────────► 硬件加法器 (Ripple/Carry-Lookahead) / 乘法器阵列
按位运算 (&, |, ~, ^) ──────► 独立的门电路矩阵 (与门、或门、反相器、异或门)
逻辑运算 (&&, ||, !) ──────► 汇聚为 1 位的真/假条件判决器
缩减运算 (&, |, ^) ────────► 树状折叠门电路级联
三目运算 (? :) ────────────► 物理 2选1 多路数据选择器 (MUX)
拼接运算 ({ , }) ──────────► 零硬件开销!纯粹的铜箔导线重新捆绑排列
5.2 核心运算易错点对比
① 按位运算(Bitwise) vs 逻辑运算(Logical)
- 按位(两边每一根线独立对齐计算):
~4'b1010结果为4'b0101(每一位都反转)。4'b1010 & 4'b1100结果为4'b1000。
- 逻辑(把两边当成布尔“真/假”看待,非 0 即为 1):
!4'b1010结果为1'b0(因为原值非 0 代表真,取非变假)。4'b1010 && 4'b1100结果为1'b1(真且真得真)。
② 一元缩减运算符(Reduction Operators)
只针对单个多位向量自己内部的所有位进行级联运算:
&A(缩减与):
等价于把 A 的所有位拉进一个超大多输入与门:A[3] & A[2] & A[1] & A[0]。
当且仅当 A 的每一位全是 1 时,结果才为 1!(常用在计数器满溢检测:if (&counter))。|A(缩减或):
A[3] | A[2] | A[1] | A[0]。只要有任何一位是 1,结果就是 1(常用在判断变量是否不为 0)。^A(缩减异或):
计算 A 内部 1 的个数是奇数还是偶数(硬件奇偶校验器实现)。
③ 逻辑相等 == vs 全等 ===
==与!=(可综合到芯片):
如果比对的信号线上有x或z,硬件不知道真实电压,直接返回不定态x(判定不相等)。===与!==(不可综合,仅用于 Testbench 仿真):
四值电平严格硬核比对。如果写if (bus === 1'bz),能精准探测总线是否处于悬空断开状态。
④ 移位运算暗坑:算术右移 >>>
- 只有当变量显式声明了
signed,>>>才会执行真正的算术右移(高位补符号位); - 若变量是默认的无符号数,
>>>和逻辑右移>>完全一样(高位无脑补 0)!
⑤ 拼接 {A, B} 与多重复制 {N{A}}
wire [3:0] a = 4'b1010;
wire [1:0] b = 2'b01;
wire [5:0] res1 = {a, b}; // 拼成 6'b1010_01
// 符号扩展经典用法:把 8 位有符号数扩充到 16 位
wire [7:0] d = 8'b1000_0001; // 最高位是符号位 1
wire [15:0] d_ext = { {8{d[7]}}, d }; // 前面复制 8 个 1,后面拼上原值
5.3 运算符优先级天梯榜(从高到低)
- 括号
( )(优先级最高,任何复杂表达式都建议手动加括号!) - 一元操作符:
!、~、&(缩减与)、|(缩减或)、^(缩减异或) - 乘除取模:
*、/、% - 加减法:
+、- - 移位运算:
<<、>>、<<<、>>> - 大小比较:
<、<=、>、>= - 相等比较:
==、!=、===、!== - 按位逻辑:
&$\to$^$\to$| - 条件逻辑:
&&$\to$|| - 三目条件:
? :
第六章:赋值机制与非阻塞赋值的秘密
6.1 连续赋值(Continuous Assignment):assign
- 语法:
assign 目标wire = 表达式; - 硬件模型:输入端与输出端之间焊接死了一组纯组合逻辑门,输入信号哪怕出现 $0.1\text{ ns}$ 的跳变,输出端立即无时钟跟随改变。
6.2 过程赋值:阻塞 = 与 非阻塞 <= 的微观对决
这是 FPGA 面试与工程中最核心、失误率最高的语法要点!
① 仿真器底层原理:事件分层队列(Stratified Event Queue)
在一个时钟上升沿到来的瞬间(时刻 $T$),仿真器把计算任务分为几个时钟域槽位:
- 活跃事件队列(Active Queue):
- 执行所有阻塞赋值
=:立即计算右边,并且立刻写入左边变量,后面的代码立即看到新值! - 计算所有非阻塞赋值
<=右边的表达式,并将结果存入草稿纸,暂不更新左边变量!
- 执行所有阻塞赋值
- 非阻塞事件队列(NBA Queue):
- 在此时钟拍数的最后时刻,仿真器把草稿纸上暂存的值,在同一瞬时统一灌入所有非阻塞赋值的左边变量中!
② 两级移位寄存器实验(对照分析):
-
写法 A(错误使用阻塞赋值
=):always @(posedge clk) begin b = a; // b 立即变成了 a 的最新值 c = b; // c 读到的已经是刚刚变掉的 b (即 a) 了! end- 硬件结果:
b和c在同一拍都变成了a。寄存器b被综合器短路吃掉,电路退化成只有 1 级寄存器!
- 硬件结果:
-
写法 B(正确使用非阻塞赋值
<=):always @(posedge clk) begin b <= a; // 记录当前 a,记入草稿纸 1 c <= b; // 记录当前旧 b,记入草稿纸 2 end- 硬件结果:时钟走完瞬间,两张草稿纸同时写入。数据从 a 到 b 花 1 拍,从 b 到 c 又花 1 拍。成功综合出 2 级串联 D 触发器!
【阻塞赋值 = 拓扑】
a ──────► [ D触发器 c ] (触发器 b 被编译器短路吃掉!)
▲
clk
【非阻塞赋值 <= 拓扑】
a ──────► [ D触发器 b ] ──────► [ D触发器 c ] (标准两级流水线)
▲ ▲
clk clk
③ 终身遵守的赋值军规:
- 时钟边沿触发的时序逻辑(
always @(posedge clk ...)):一律无条件使用<=! - 纯组合逻辑(
assign或always @(*)):一律使用=! - 同一个
always块内,绝对禁止把=和<=混用!
6.3 经典工程设计模式:门铃弹簧脉冲机制
在 always 块里生成单周期使能脉冲的标准写法:
always @(posedge clk) begin
tx_start <= 1'b0; // 【方案A】:默认置 0 (弹簧永远向下拽)
if (条件满足) begin
tx_start <= 1'b1; // 【方案B】:条件达成瞬间推翻方案 A,置 1
end
end
- 原理解析:利用 Verilog “同一时钟拍内后者覆盖前者”的规则。
- 条件达成的那一拍:方案 B 胜出,输出
1(脉冲发射); - 仅仅过了 20ns(下一拍):条件不复存在,方案 A 自动生效,脉冲瞬间被弹簧拉回
0! - 保证信号绝不粘连卡死。
第七章:过程块与意外锁存器(Latch)事故
7.1 时序 always vs 组合 always
// 1. 标准时序逻辑:时钟上升沿驱动,带异步低电平复位
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
q <= 1'b0; // 异步清零
else
q <= d; // 正常打拍保存
end
// 2. 纯组合逻辑:监听所有输入端
always @(*) begin
out = a & b; // @(*) 自动推导所有输入信号,等效于连续赋值
end
7.2 灾难之源:意外锁存器(Inferred Latch)的生成与扑灭
- 什么是 Latch(锁存器)?
- D 触发器是边沿敏感(Edge-sensitive),只在时钟跳变瞬间采样,抗噪性极强;
- 锁存器是电平敏感(Level-sensitive),只要使能电平为高,输入端所有的毛刺杂波都会直接穿透到输出端,破坏时序分析,是 FPGA 工程设计中的一级违规事故!
事故现场 1:if 语句缺少 else
// 错误代码:将诱发意外 Latch!
always @(*) begin
if (enable)
out = in;
// 漏写了 else!
end
- 硬件推导:当
enable == 0时,你没告诉综合器out应该等于什么。为了让out在没有新命令时保持原值,综合器被迫在硅片里硬塞进一个电平透明锁存器(Latch)来暂存电荷!
事故现场 2:case 语句缺少分支或缺少 default
// 错误代码:将诱发意外 Latch!
always @(*) begin
case (sel)
2'b00: out = in0;
2'b01: out = in1;
// 漏掉了 2'b10 和 2'b11,且没有 default!
endcase
end
彻底扑灭 Latch 的两大圣经:
-
组合逻辑块第一行无条件赋默认值(防御性编程):
always @(*) begin out = 1'b0; // 第一行先给一个默认安全值! if (enable) out = in; // 满足条件时推翻它,分支不满足时退守 0,绝不产生 Latch! end -
case语句末尾必须写上default:分支兜底。
第八章:流程控制与分支语句(Control Flow)
8.1 if-else 的硬件本质:优先级编码器(Priority MUX)
if (cond1)
out = a;
else if (cond2)
out = b;
else
out = c;
- 综合出来的物理结构是级联的 2选1 多路选择器链条。
cond1距离输出端最近,优先级最高;越往后的分支门电路级联越深,延时越大。- 时序优化技巧:最晚到达的时序关键信号,必须放在最外层的
if判断中!
8.2 case、casez、casex 辨析
case(常规完全匹配):所有位必须严格相等才执行对应分支。casez(忽略高阻与通配):把分支标号中的z或?视为“无关项(Don’t Care)”。-
常用在优先级中断编码器中:
always @(*) begin casez (irq_bus) 4'b1???: highest_id = 2'd3; // 只要最高位是 1,低 3 位是什么都不在乎! 4'b01??: highest_id = 2'd2; 4'b001?: highest_id = 2'd1; 4'b0001: highest_id = 2'd0; default: highest_id = 2'd0; endcase end
-
casex(绝禁止在可综合 RTL 中使用):把x和z都当作无关项。若前级复位失败产生未知态x,casex会误命中分支,掩盖致命错误!
第九章:模块结构与层次化例化(Module & Instantiation)
9.1 ANSI C 风格标准模块声明
推荐现代 Verilog-2001 规范写法:
module my_counter #(
parameter integer WIDTH = 8, // 参数声明列表
parameter integer MAX_VAL = 255
)(
input wire clk, // 端口声明列表
input wire rst_n,
output reg [WIDTH-1:0] cnt
);
// 模块主体内部逻辑
endmodule
9.2 工业级例化军规:名字关联法(Named Association)
在顶层调用子模块时:
位置关联法(极其危险,禁止使用):
my_counter u_cnt (clk_top, rst_top, count_wire);
缺陷:完全依赖子模块端口位置顺序。只要子模块调整了引脚顺序,整片电路接错线却不报任何编译错误!- 名字关联法(工业强制规范):
my_counter #(
.WIDTH (16), // 外部强行修改参数值
.MAX_VAL(65535)
) u_my_counter_inst (
.clk (sys_clk), // .内部引脚名 (外部连接线名)
.rst_n (sys_rst_n),
.cnt (cnt_output)
);
第十章:任务(Task)与函数(Function)的边界
| 维度对比 | 函数(Function) | 任务(Task) |
|---|---|---|
| 执行时间 | 纯零耗时($0\text{ ns}$ 瞬时完成) | 可以包含延时 #、等待时钟 @(posedge) |
| 可综合性 | 可综合(生成纯组合逻辑) | 通常不可综合,专职用于 Testbench 仿真开发 |
| 参数输入输出 | 至少有 1 个输入,绝对不能有 output | 可以有 0 个或任意多个 input、output |
| 返回值 | 通过函数名自身返回唯一的一个结果 | 无返回值,运算结果通过 output 端口引申带出 |
| 调用场景 | 在 assign 或过程块内自由调用 |
只能在 initial 或 always 过程块中独立调用 |
第十一章:仿真系统任务与常用指令(System Tasks)
以 $ 美元符号开头的函数,由 EDA 仿真内核内置提供,专供 Testbench 监控与调试使用:
11.1 打印与监听
$display("format", ...);:自动带换行的控制台打印(等同于 C 语言的printf("...\n"))。$write("format", ...);:不换行的控制台打印。$monitor("format", ...);:全自动常驻监听器! 只要参数列表中任意一个变量发生跳变,自动在控制台打印一行记录!- 格式占位符速查:
%b(二进制)、%h(十六进制)、%t(时间戳)、%s(字符串)%0d:消除多余前导零的十进制紧凑输出(如打印 5,%d输出00000005,%0d输出5)。
11.2 时间控制与生命周期
$time:返回当前仿真时间的 64 位整数。$stop;:暂停当前仿真,保持波形界面供工程师手动调试。$finish;:彻底关闭仿真软件引擎,退出运行。
11.3 外部文件与 RAM 初始化
$readmemh("init.hex", ram_reg);:从文本文件中以十六进制格式逐行读取数据,预灌入内部数组ram_reg。$readmemb("init.bin", ram_reg);:以二进制格式读取数据初始化。
课后思考与巩固自测题
为确保彻底消化本讲语法,请尝试在草稿纸上回答以下 3 道经典工程思考题:
- 题目 1:在写时序逻辑
always @(posedge clk)时,如果不小心把非阻塞赋值<=写成了阻塞赋值=,对移位寄存器会产生什么致命后果? - 题目 2:在组合逻辑
always @(*)中,为什么缺少else分支会导致综合器生成锁存器(Latch)?如何用一句话防范此问题? - 题目 3:
#10延时控制可以在可综合的开发板硬件代码里使用吗?它在 Testbench 和模块顶层定义#(parameter ...)中有什么本质区别?