咖啡图片
正在将巧克力泡入咖啡
ntainer" style="display: none">
文章

FPGA系列(4)

Verilog建模设计

FPGA系列(4)

建模的内功心法

写 Verilog 强调建模而不是编程,思想是用 Verilog 去建立清晰可理解的硬件结构,模块是始终运行的硬件,顺序仅存在于观察结果而不是结构本身。建立了正确的建模直觉,后续的接口封装、系统组合、高级抽象都会变得顺理成章。你是否真的知道自己在描述什么硬件资源,你的设计是否能被画成结构图,你的模块是否在并行地、各司其职地运行。先拆、再分、再并行、再封装、最后组合成系统。

在有一定熟练度之后,写之前最好是先设计好电路大概长什么样(到加法器、ram、fifo 这个层级就可以),然后再用 verilog 实现。一个 always 块可以看作一个电路或者实物上的一个芯片。另外,Verilog 开发遵循自顶向下的模块化设计,模块划分要功能单一,且要留出使能、复位等接口以便系统搭建。有可能的话,把握编译器的理解方式也有助于开发,写出编译器能清晰理解的代码可以大大降低错误率。

功能划分

在实现上,一个模块只允许承担一个功能。功能模块只干具体的事,控制模块只负责调度,组合模块只负责拼装。功能模块 = 自己知道“怎么做”,但不知道“什么时候做”;控制模块 = 自己知道“什么时候做”,但不知道“具体怎么做”。

  • 功能模块是一种高度自洽的、近似“硬件器件”的存在。它的最大特点是:只要输入条件满足,它就自然地、持续地工作,而不需要外部有人一步一步指挥。功能模块通常具备自己的时序、自己的计数器、自己的寄存器状态,但这些状态都是为了完成“它这一件事”,而不是为了配合系统流程。它们的行为可以脱离“系统上下文”而存在。从设计风格上看,功能模块往往是“条件驱动”的,而不是“步骤驱动”的。也就是说,它更像是在回答:“当某个条件成立时,我该输出什么”,而不是:“现在轮到我做第几步了”。

  • 控制模块更像一个时序协调者、流程调度者,最大的特征是:它本身不干具体活,它只决定谁在什么时候该干活。控制模块关心的不是“这个模块内部怎么算”,而是“它什么时候开始”“它什么时候结束”“下一个该谁上”。因此,控制模块往往非常依赖外部模块的反馈信号,比如 done、busy、valid、ready 一类信号。从结构上看,控制模块通常是“强顺序感”的,是可以合理使用状态机思维的地方。控制模块只负责状态切换和调度决策,而不会把具体的功能逻辑塞进去。

    所谓的控制模块,就是用来产生 enable 信号的模块,其它功能模块中加入使能信号的设计,这样就可以由控制模块来调度功能模块。同时,功能模块起到沟通协调的作用,可以灵活调整,以接收各种功能模块的输入,产生各种功能的使能信号,保证了可扩展性。

串行和并行的理解

串行操作有步骤的概念,前后行为具有依赖关系;并行操作则是独立执行互不影响,各自在固定的时间执行操作。顺序操作的语言很多都是高级语言,一些调用的指令都是被隐性处理,如函数的调用指令和返回指令等。而在 Verilog 建模中,主要用状态机这种仿顺序的方式实现这种看起来是顺序的操作。

基本所有外设驱动的编写逻辑,都是晶振脉冲计时输出标志位,然后状态机根据计时标志位进行状态转换和相应动作。

封装和 FIFO

模块的封装,定义为某个基础的最后建模工作,以及封装过后的模块具有独立性。FIFO 可用于消除上下游模块的依赖关系,以缓冲的方式摆脱反馈的束缚。下层模块完成内部工作后,可以直接从 FIFO 中读取操作信息而不用等待上层模块等到下层反馈后下达新的指令。事实上,FIFO 是用来缓冲两个时钟域不同的访问,可以利用 FIFO 这个仓库作为接口的输入,使得接口具备独立性。上层模块调用某个接口时,只需要把信息写入仓库即可,上层模块不用与接口互动而被束缚,如果发现仓库有信息则处理即可。

image-20260131215406381

完整来说应该是,对于接收上游模块指令的模块,可以在最初环节之前引入 FIFO,而对于向下游模块发出指令的模块,可以在最后环节之后引入 FIFO,使得封装之后的接口具有独立性。不过理论上说,基本上所有模块都需要接收上游指令并向下游模块发出指令,所以在最开头还是最末尾引入 FIFO 也是相对的,还是根据具体需求来设计。这样,上层模块只管往 FIFO 中写入操作信息,不用等待下层模块的反馈信息就可以执行其它操作。控制模块接收到反馈模块后再从 FIFO 读取信息。

Verilog 开发规范

大部分来自华为 Verilog 开发规范。

设计风格类

  • 无特殊情况建议显式指明位宽,数值较长的每四位加下划线,并注意不要让结果超出位宽的表示范围发生溢出
  • 采用小写字母定义 wire、reg、input/inout/output 以及模块名
  • 采用大写字母定义参数,参数名小于 20 个字母
  • 时钟信号应前缀 “clk”,复位信号应前缀 “rst”
  • 三态输出的寄存器信号应后缀 “_z “
  • 输入端口前缀 “ i_“,输出端口前缀 “o_”
  • 延迟打拍的变量加后缀 _r1、_r2 等
  • _d 表示延迟后的信号,_t 表示暂时存储的信号,_n 表示低有效的信号,_s 表示 slave 信号,_m 表示 master 信号
  • module 例化名用 U_xx_x 标示(多次例化用次序号 0、1、2…)
  • 使用降序排列定义向量有效位顺序,即向量的 0 索引统一为最低权重位,且不使用负数的向量域
  • 不能使用 VHDL 保留字或 verilog 保留字作为变量名
  • 顶层模块的输出信号必须被寄存(防止毛刺跑出芯片外)
  • 三态逻辑可以在顶层模块使用,子模块中避免使用三态
  • 没有未连接的端口
  • 到其它模块的接口信号,按如下顺序定义端口信号:输入、(双向)、输出
  • 建议使用 IP catalog 生成的乘法电路
  • 例化模块时使用命名端口连接而不要使用顺序端口连接,即显式写出要连接的端口名称。无需连接的端口留空而不要不写
  • 模块例化时,端口信号与连接信号隔开,并各自对齐。连接信号为向量时指明其位宽,方便阅读和调试。
  • 不要书写空的模块,即一个模块至少要有一个输入和一个输出
  • 敏感表中的表达式要用 “negedge " 或 "posedge " 的形式,而不要用 "clk" 或 " clk == 1'b1 " 的形式
  • 异步复位,高电平有效用 “If ( = 1'b1)",低电平有效用 "if ( = 1'b0)"
  • if 语句嵌套不能太多
  • 建议不使用 include 语句
  • 建议每个模块加 timescale(规避 timescale 根据编译顺序继承上一个文件可能导致的问题)
  • 代码中给出必要的注释
  • 每个文件有一个文件头,格式为文件名、设计者、日期、主要功能描述和更新记录
  • 每个文件只包含一个模块
  • 模块名和文件名保持一致
  • 定义模块时,端口类型和端口数据类型直接写在端口列表中

设计可靠性类

  • 操作数位宽不一致时建议用注释说明,避免自动扩展位宽带来隐患。有符号数扩展符号位,如果扩展出来的符号位和另一个操作数的数值部分相加了就会出问题
  • 组合 always 块中使用 case 语句必须加上 default 以规避综合出锁存器的风险。除非锁存器是刻意设计的,否则都意味着设计存在问题
  • 任何控制流语句,建议使用 beginend 关键字,编译器一般按就近原则编译,不加 beginend 关键字可能导致不安全的行为
  • 同步时序逻辑的 always block 中有且只有一个时钟信号,并且在同一个沿动作(如上升沿)
  • 同步时序逻辑的 module 中,在时钟信号的同一个沿动作
  • 采用同步设计,避免使用异步逻辑(全局异步复位除外)
  • 一般不要将时钟信号作为数据信号输入
  • 不要在时钟路径上添加任何 buffer,也不要门控时钟(会导致 EDA 把专用的时钟网络当作普通信号布线)
  • 在顶层模块中,时钟信号必须可见
  • 不要采用向量的方式定义一组时钟信号
  • 不要在模块内部生成时钟信号,使用 DLL/PLL 产生的时钟信号
  • 建议使用单一的全局同步复位电路或者单一的全部异步复位电路
  • 不要在复位路径上添加任何 buffer,也不要使用任何门控复位信号
  • 不使用 PLI 函数
  • 不使用事件变量
  • 不使用系统函数
  • 不使用用户自定义单元(UDP)
  • 不使用 disable 语句
  • 不使用 ===!=== 等不可综合的语句
  • 建议不使用 forever、repeat、while 等循环语句
  • 避免产生 latch(CPU 接口除外)
  • 组合逻辑语句块敏感表中的敏感变量必须和该块中使用的一致,不能多也不能少
  • 一个 always 语句中有且只能有一个敏感表
  • 在时序 always 块的敏感表中必须都是沿触发事件,不允许出现电平触发事件
  • 数据位宽要匹配
  • 不使用 real、time、realtime 类型,建议不使用 integer 类型
  • 移位变量必须是一个常数
  • 避免使用异步反馈环路
  • 组合 always 块中使用阻塞赋值 =,时序 always 块中使用非阻塞赋值 <=,不得混用(时序不易控制,容易得到意外结果)
  • 非阻塞型赋值不应加单位延时,尤其是对于寄存器类型的变量赋值时
  • 例化模块端口的位宽应与要连接的信号位宽保持一致,避免自动匹配可能造成的隐患
  • 时序要求、优化目标不同的信号路径需要拆分到不同的模块中。比如时序紧的关键路径和不追求速度的非关键路径就不要写在一个模块中。同一条组合路径上的代码写在同一个模块中
  • 一个 always 块中只更新一个 reg 变量;理论上来说,always 块之间是并行的,always 块内部是串行的,不过考虑到非阻塞赋值会先记录要更新的值,然后等敏感表中的条件满足时一起更新,所以这一条没有那么严格。

不常用规则类

  • 整型常量基数格式中不能有 “?”
  • 禁止使用空的时序电路块及非法的 always 结构
  • 不要在连续赋值语句中引入驱动强度和延时
  • 不要为 net、n_input、n_output、enable_gate 型变量定义驱动强度、电荷保持强度以及延时
  • 禁止使用 trireg(具有电荷保持特性连接)NET 型定义
  • 禁止使用 tri1、tri0、triand 和 trior 型的连接(net)
  • 在 RTL 级代码中不能含有 initial 结构,也不可以对任何信号进行初始化赋值,而应采用复位的方式进行初始化
  • 不要在过程块中使用 assign、deassign、force、release 等语句
  • 不要使用 wait 语句
  • 不要使用 fork-join 语句块
  • 不要为驱动类型为 supply0 和 supply1 型的连线(net)赋值
  • 设计中不使用 macro_module
  • 不要在 RTL 代码中实例门级单元,尤其是下列单元:CMOS 开关、RCMOS 开关、NMOS 开关、PMOS 开关、RNMOS 开关、RPMOS 开关、trans 双向开关、rtrans 双向开关、tranif0、tranifl、rtranif0、rtranifl、pull_gate
  • 不使用 specify 模块

Verilog的综合

  1. 非阻塞赋值在触发时是同时赋值的,很符合触发器在上升沿到来后将 D 输出到 Q 的实际情况。时序 always 块中左侧变量被综合成触发器,右侧的表达式用逻辑电路实现后连接到触发器的 D 端。与 if 复位配对的 else if 中的变量会被综合成使能信号。

    image-20260130150256115

  2. 相较于 case,if else if 是有优先级的,会综合出多个 MUX2 来实现。

  3. 组合逻辑如果有分支没有输出则会综合出锁存器,因为没有时钟但是又需要存储的功能。锁存器会占用大量资源且时序上不稳定。

    image-20260130151051946

  4. 在Xilinx FPGA开发中,加减乘的运算可以直接写,但不要直接写除法或取余的操作。因为加法和乘法在Xilinx的FPGA芯片中都有对应的硬件资源(比如乘法有DSP Slice),而除法和取余这些操作则没有。这些操作符被综合后,结构和时序往往不易控制。应该使用相关优化后的 ip 模块或工艺库中的集成模块。但是 parameter 类型的常量就可以使用此类操作符,因为在编译之初编译器就会计算出常量运算的结果,不会消耗多余的硬件资源。

  5. 多个数的加法或乘法可以进行展开,因为硬件资源只支持两个操作数,将多个操作数的加法或乘法展开成两个操作数有助于时序收敛并提高频率,即用面积换速度。

复位电路设计

为确保系统上电后有一个明确、稳定的初始状态,或系统运行状态紊乱时可以恢复到正常的初始状态,数字系统设计中一定要有复位电路模块。

另一方面,复位电路会消耗更多的硬件逻辑和面积资源。所以在一些初始值不影响逻辑正确性的数字设计中,建议去掉复位信号。比如数据路径不需要复位逻辑,因为其正确性会由伴随的控制信号保障,加上复位没有必要反而会增加硬件负担浪费资源。

复位电路可分类为同步复位和异步复位。

同步复位

1
2
3
4
5
6
7
8
9
10
11
12
13
module sync_reset(
    input       rstn,  //同步复位信号
    input       clk,   //时钟
    input       din,   //输入数据
    output reg  dout   //输出数据
    );

    always @(posedge clk) begin   //复位信号不要加入到敏感列表中
        if(!rstn)  dout <= 1'b0 ; //rstn 信号与时钟 clk 同步
        else       dout <= din ;
    end

endmodule

对于没有同步复位端的寄存器,会综合出以下电路

image-20260201153105974

同步复位的优点:信号间是同步的,能滤除复位信号中的毛刺,有利于时序分析。

同步复位的缺点:大多数触发器单元是没有同步复位端的,采用同步复位会多消耗部分逻辑资源。且复位信号的宽度必须大于一个时钟周期,否则可能会漏掉复位信号。(然而 xilinx 的 FPGA 是有同步复位端的,并且官方也建议采用同步复位而不是异步复位)。

异步复位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module async_reset(
    input       rstn,  //异步复位信号
    input       clk,   //时钟
    input       din,   //输入数据
    output reg  dout   //输出数据
    );

    //复位信号要加到敏感列表中
    always @(posedge clk or negedge rstn) begin
        if(!rstn)  dout <= 1'b0 ; //rstn 信号与时钟 clk 异步
        else       dout <= din ;
    end

endmodule

image-20260201153353483

异步复位的优点:大多数触发器单元有异步复位端,不会占用额外的逻辑资源。且异步复位信号不经过处理直接引用,设计相对简单,信号识别快速方便。

异步复位的缺点:复位信号与时钟信号无确定的时序关系,异步复位很容易引起时序上 removal 和 recovery 的不满足。且异步复位容易受到毛刺的干扰,产生意外的复位操作。

异步复位同步释放

一般数字系统设计时都会使用异步复位。为消除异步复位的缺陷,复位电路往往会采用 “异步复位、同步释放” 的设计方法。即复位信号到来时不受时钟信号的同步,复位信号释放时需要进行时钟信号的同步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
module areset_srelease
    (
    input       rstn,  //异步复位信号
    input       clk,   //时钟
    input       din,   //输入数据
    output reg  dout   //输出数据
    );

    reg   rstn_r1, rstn_r2;
    always @ (posedge clk or negedge rstn) begin
        if (!rstn) begin
            rstn_r1 <= 1'b0;     //异步复位
            rstn_r2 <= 1'b0;
        end
        else begin
            rstn_r1 <= 1'b1;     //同步释放
            rstn_r2 <= rstn_r1;  //同步打拍,时序差可以多延迟几拍
        end
    end

    //使用 rstn_r2 做同步复位,复位信号可以加到敏感列表中
    always @ (posedge clk or negedge rstn_r2) begin
        if (!rstn_r2) dout <= 1'b0; //同步复位
        else          dout <= din;
    end

endmodule

image-20260201153452106

局部复位设计

复位有全局复位和局部复位两种。需要复位的信号,建议设计成局部复位。局部的模块化复位寄存器容易被综合器优化,可以用 KEEP 属性强制保留,保证复位逻辑不被优化掉。

1
2
3
4
5
6
7
8
9
(* keep="true" *) reg my_modular_reset1;
(* keep="true" *) reg my_modular_reset2;
(* keep="true" *) reg my_modular_reset3;

always @(posedge clkA) begin
  my_modular_reset1 <= synchronized_reset;
  my_modular_reset2 <= synchronized_reset;
  my_modular_reset3 <= synchronized_reset;
end

一些杂项列举

吞吐量 thoughput 指的是,模块在稳定工作时,每个时钟周期能够处理或输出的数据量,比如,如果模块每个 clock 都能输出一个新的除法结果 → 吞吐量 = 1 result/cycle,如果要每 4 个 clock 才能输出一个结果 → 吞吐量 = 1/4 result/cycle。

延迟 latency 指的是,从输入有效到得到第一个结果需要的周期数,在流水线的设计中,即使 latency 很大(比如几十个周期),只要 pipeline 深度够大,throughput 可以依旧是 1 个结果/周期。

non-blocking 指的是,一个通道的数据缺失不会导致另一个通道的数据被缓存。另外,non-blocking 不支持背压,因为这个模式没有 tready 信号。

溢出限制:真正工作起来应该是选择合适的位宽使得不会溢出,而不是靠溢出保护来避免异常值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
localparam signed [15:0] MAX_16 = 16'sd32767;
localparam signed [15:0] MIN_16 = -16'sd32768;
localparam signed [31:0] MAX16_TH = , MAX_16};
localparam signed [31:0] MIN16_TH = , MIN_16};
always @ (posedge clk or negedge rstn) begin
    if (!rstn) begin
        x_w2c <= 0;
        y_w2c <= 0;
        z_w2c <= 0;
    end
    else if (valid_d3) begin
        x_w2c <= (rx > MAX16_TH) ? 16'h7FFF : ((rx < MIN16_TH) ? 16'h8000 : rx[15:0]);
        y_w2c <= (ry > MAX16_TH) ? 16'h7FFF : ((ry < MIN16_TH) ? 16'h8000 : ry[15:0]);
        z_w2c <= (rz > MAX16_TH) ? 16'h7FFF : ((rz < MIN16_TH) ? 16'h8000 : rz[15:0]);
    end
end

在数字电路设计中,关键路径(Critical Path) 是指从输入到输出的所有信号传输路径中,延迟最大的路径,其延迟时间直接决定了整个电路的最高工作频率。

各个功能段所需时间应尽量相等,否则时间长的段会成为瓶颈,造成流水线堵塞或断流。

参考资料

内功心法部分来自黑金动力社区的《Verilog那些事儿——建模篇》,开发规范部分来自华为的Verilog开发规范,这两部分都找不到网络链接了,在此指出。

本文由作者按照 CC BY 4.0 进行授权
/body>