FPGA安路赛项第二讲:数字逻辑与verilog基础(下)笔记

第 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

  • 物理模型:代表现实中的金属铜箔导线,自身无法锁存任何电荷。
  • 驱动准则:
    1. 只能由 assign 连续赋值语句驱动,或作为底层模块的输出端由逻辑门持续推流;
    2. 绝对禁止在 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 位(需先读出到临时变量再切片)。

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; // 大填小:位宽过大
  1. 零扩展(Zero-Extension):
    • w1 实际得到:8'b0000_1101(高位自动补零)。
  2. 硬件截断(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 运算符优先级天梯榜(从高到低)

  1. 括号 ( )(优先级最高,任何复杂表达式都建议手动加括号!)
  2. 一元操作符:!、~、&(缩减与)、|(缩减或)、^(缩减异或)
  3. 乘除取模:*、/、%
  4. 加减法:+、-
  5. 移位运算:<<、>>、<<<、>>>
  6. 大小比较:<、<=、>、>=
  7. 相等比较:==、!=、===、!==
  8. 按位逻辑:& $\to$ ^ $\to$ |
  9. 条件逻辑:&& $\to$ ||
  10. 三目条件:? :

第六章:赋值机制与非阻塞赋值的秘密

6.1 连续赋值(Continuous Assignment):assign

  • 语法:assign 目标wire = 表达式;
  • 硬件模型:输入端与输出端之间焊接死了一组纯组合逻辑门,输入信号哪怕出现 $0.1\text{ ns}$ 的跳变,输出端立即无时钟跟随改变。

6.2 过程赋值:阻塞 = 与 非阻塞 <= 的微观对决

这是 FPGA 面试与工程中最核心、失误率最高的语法要点!

① 仿真器底层原理:事件分层队列(Stratified Event Queue)

在一个时钟上升沿到来的瞬间(时刻 $T$),仿真器把计算任务分为几个时钟域槽位:

  1. 活跃事件队列(Active Queue):
    • 执行所有阻塞赋值 =:立即计算右边,并且立刻写入左边变量,后面的代码立即看到新值!
    • 计算所有非阻塞赋值 <= 右边的表达式,并将结果存入草稿纸,暂不更新左边变量!
  2. 非阻塞事件队列(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

③ 终身遵守的赋值军规:

  1. 时钟边沿触发的时序逻辑(always @(posedge clk ...)):一律无条件使用 <=!
  2. 纯组合逻辑(assign 或 always @(*)):一律使用 =!
  3. 同一个 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 的两大圣经:

  1. 组合逻辑块第一行无条件赋默认值(防御性编程):

    always @(*) begin
        out = 1'b0; // 第一行先给一个默认安全值!
        if (enable)
            out = in; // 满足条件时推翻它,分支不满足时退守 0,绝不产生 Latch!
    end
    
  2. 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)

在顶层调用子模块时:

  • :cross_mark: 位置关联法(极其危险,禁止使用):
    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. 题目 1:在写时序逻辑 always @(posedge clk) 时,如果不小心把非阻塞赋值 <= 写成了阻塞赋值 =,对移位寄存器会产生什么致命后果?
  2. 题目 2:在组合逻辑 always @(*) 中,为什么缺少 else 分支会导致综合器生成锁存器(Latch)?如何用一句话防范此问题?
  3. 题目 3:#10 延时控制可以在可综合的开发板硬件代码里使用吗?它在 Testbench 和模块顶层定义 #(parameter ...) 中有什么本质区别?