引言

你已经使用Logisim设计过数字电路

 

本次课内容:

RTL代码在仿真时发生了什么?

  • RTL仿真的基本原理
  • Verilog的仿真行为
  • Verilog编码风格

RTL仿真的基本原理

仿真

电路的设计和制造是两个阶段

  • 设计时需要考虑电路的功能等各项指标
  • 但理论上来讲, 只有将电路制造出来, 才知道电路的设计是否正确

 

迭代周期太长, 怎么办?

 

仿真 = 用软件程序来模拟真实硬件电路的行为

  • 期望 - 在仿真程序中表现出的电路行为, 与制造出来的电路行为一致

 

有了仿真程序, 就可以基于程序进行电路的设计和验证了

  • 功能验证 - 验证正在设计的电路功能是否符合预期
  • 还有性能验证

RTL仿真 = 用C程序实现数字电路的状态机

C程序 数字电路
状态 \(\{PC, V\}\) 时序逻辑电路
激励事件 执行语句 处理组合逻辑
状态转移规则 语句的语义 组合逻辑电路的逻辑

 

  • 用C程序的状态实现数字电路的状态
    • 用C程序的变量实现时序逻辑电路
  • 用C程序的状态转移规则实现数字电路的状态转移规则
    • 用C语言语句实现组合逻辑电路的逻辑

一个例子 - 流水灯

module light(
  input clk,
  input rst,
  output reg [15:0] led
);
  reg [31:0] count;
  always @(posedge clk) begin
    if (rst) begin led <= 1; count <= 0; end
    else begin
      if (count == 0) led <= {led[14:0], led[15]};
      count <= (count >= 5000000 ? 32'b0 : count + 1);
    end
  end
endmodule

仿真程序 - 用变量实现时序逻辑电路

#define _CONCAT(x, y) x ## y
#define CONCAT(x, y)  _CONCAT(x, y)

#define DEF_WIRE(name, w) uint64_t name : w
#define DEF_REG(name, w)  uint64_t name : w; \
                          uint64_t CONCAT(name, _next) : w; \
                          uint64_t CONCAT(name, _update) : 1

typedef struct {
  DEF_WIRE(clk, 1);
  DEF_WIRE(rst, 1);
  DEF_REG (led, 16);
  DEF_REG (count, 32);
} Circuit;

Circuit circuit;

通过结构体变量circuit实现时序逻辑电路

  • ledcount
    • 还包含后缀为_next的中间变量和后缀为_update的更新标志, 用于实现Verilog中非阻塞赋值的语义
  • clkrst虽然不是时序逻辑电路, 但作为输入, 仍然属于电路状态

仿真程序 - 用语句实现组合逻辑电路的逻辑

#define EVAL(c, name, val) do { \
                             c->CONCAT(name, _next) = (val); \
                             c->CONCAT(name, _update) = 1; \
                           } while (0)
#define UPDATE(c, name)    do { \
                             if (c->CONCAT(name, _update)) { \
                               c->name = c->CONCAT(name, _next); \
                             } \
                           } while (0)
static void cycle(Circuit *c) {
  c->led_update = 0;
  c->count_update = 0;
  if (c->rst) {
    EVAL(c, led, 1);
    EVAL(c, count, 0);
  } else {
    if (c->count == 0) {
      EVAL(c, led, (BITS(c->led, 14, 0) << 1) | BITS(c->led, 15, 15));
    }
    EVAL(c, count, c->count >= 5000000 ? 0 : c->count + 1);
  }
  UPDATE(c, led);
  UPDATE(c, count);
}

cycle()基本上是相应Verilog代码的直接翻译

数字电路工作的本质

数字电路的工作, 就是不断重复以下步骤:

  1. 根据输入和当前状态, 以及组合逻辑电路的逻辑, 计算出新状态
  2. 将新状态更新到时序逻辑电路中

static void reset(Circuit *c) {
  c->rst = 1;
  cycle(c);
  c->rst = 0;
}

int main() {
  reset(&circuit);
  while (1) {
    cycle(&circuit);
  }
  return 0;
}

RTL仿真的主要过程: 不断执行cycle()函数

  • 为了展示仿真效果, 需要添加一些打印功能

流水灯电路的仿真程序

#include <stdio.h>
#include <stdint.h>

#define _CONCAT(x, y) x ## y
#define CONCAT(x, y)  _CONCAT(x, y)
#define BITMASK(bits) ((1ull << (bits)) - 1)
// similar to x[hi:lo] in verilog
#define BITS(x, hi, lo) (((x) >> (lo)) & BITMASK((hi) - (lo) + 1))
#define DEF_WIRE(name, w) uint64_t name : w
#define DEF_REG(name, w)  uint64_t name : w; \
                          uint64_t CONCAT(name, _next) : w; \
                          uint64_t CONCAT(name, _update) : 1
#define EVAL(c, name, val) do { \
                             c->CONCAT(name, _next) = (val); \
                             c->CONCAT(name, _update) = 1; \
                           } while (0)
#define UPDATE(c, name)    do { \
                             if (c->CONCAT(name, _update)) { \
                               c->name = c->CONCAT(name, _next); \
                             } \
                           } while (0)

流水灯电路的仿真程序(2)

typedef struct {
  DEF_WIRE(clk, 1);
  DEF_WIRE(rst, 1);
  DEF_REG (led, 16);
  DEF_REG (count, 32);
} Circuit;
static Circuit circuit;

static void cycle(Circuit *c) {
  c->led_update = 0;
  c->count_update = 0;
  if (c->rst) {
    EVAL(c, led, 1);
    EVAL(c, count, 0);
  } else {
    if (c->count == 0) {
      EVAL(c, led, (BITS(c->led, 14, 0) << 1) | BITS(c->led, 15, 15));
    }
    EVAL(c, count, c->count >= 5000000 ? 0 : c->count + 1);
  }
  UPDATE(c, led);
  UPDATE(c, count);
}

流水灯电路的仿真程序(3)

static void reset(Circuit *c) {
  c->rst = 1;
  cycle(c);
  c->rst = 0;
}

static void display(Circuit *c) {
  static uint16_t last_led = 0;
  if (last_led != c->led) { // only update display when c->led changes
    for (int i = 0; i < 16; i ++) {
      putchar(BITS(c->led, i, i) ? 'o' : '.');
    }
    putchar('\r');
    fflush(stdout);
    last_led = c->led;
  }
}

int main() {
  reset(&circuit);
  while (1) {
    cycle(&circuit);
    display(&circuit);
  }
  return 0;
}

RTL仿真器 = 自动生成仿真程序的编译器

为每个电路手工编写仿真程序, 是很麻烦的

 

RTL仿真器\(\approx\)编译器

  • 输入 - RTL代码
  • 输出 - C程序
    • 即RTL代码所描述的电路的仿真程序

Verilator: 一款开源的RTL仿真器

用Verilator仿真流水灯

#include <stdio.h>
#include "Vlight.h"

#define BITMASK(bits) ((1ull << (bits)) - 1)
#define BITS(x, hi, lo) (((x) >> (lo)) & BITMASK((hi) - (lo) + 1))

static Vlight *top = NULL;
void cycle() { top->clk = 0; top->eval(); top->clk = 1; top->eval(); }
void reset() { top->rst = 1; cycle(); top->rst = 0; }

static void display() {
  static uint16_t last_led = 0;
  if (last_led != top->led) {
    for (int i = 0; i < 16; i ++) {
      putchar(BITS(top->led, i, i) ? 'o' : '.');
    }
    putchar('\r');
    fflush(stdout);
    last_led = top->led;
  }
}

int main() {
  top = new Vlight;
  reset();
  while (1) { cycle(); display(); }
  return 0;
}
verilator --cc --exe main.cpp light.v  # 通过Verilator将RTL代码编译成仿真程序的C++源文件
make -C obj_dir -f Vlight.mk           # 将C++编译成可执行的仿真程序
./obj_dir/Vlight                       # 启动仿真程序

Verilator生成的仿真程序

用变量实现时序逻辑电路

// obj_dir/Vlight___024root.h
    VL_IN8(clk,0,0);
    VL_IN8(rst,0,0);
    VL_OUT16(led,15,0);
    IData/*31:0*/ light__DOT__count;
    // ...

用语句实现组合逻辑电路的逻辑

// obj_dir/Vlight___024root__DepSet_hbb020019__0.cpp
    if (vlSelf->rst) {
        __Vdly__led = 1U;
        __Vdly__light__DOT__count = 0U;
    } else {
        if ((0U == vlSelf->light__DOT__count)) {
            __Vdly__led = ((0xfffeU & ((IData)(vlSelf->led)
                                       << 1U)) | (1U
                                                  & ((IData)(vlSelf->led)
                                                     >> 0xfU)));
        }
        __Vdly__light__DOT__count = ((0x4c4b40U <= vlSelf->light__DOT__count)
                                      ? 0U : ((IData)(1U)
                                              + vlSelf->light__DOT__count));
    }

更多的模拟器/仿真器

举例 效率 精确度
指令集模拟器 sEMU, NEMU, QEMU +++++ 指令集(行为正确)
体系结构模拟器 GEM5 +++ 性能(大致时间)
RTL仿真器 VCS, Verilator ++ 电路逻辑(精确时间)
晶体管仿真器 Spice + 晶体管(电气特性)

 

在处理器芯片设计流程中, 前三类都会使用:

  • 指令集模拟器 - 前期软件开发, 验证的行为参考模型
  • 体系结构模拟器 - 设计空间探索, 快速寻找可提升处理器性能的特性
  • RTL仿真器 - RTL测试验证

 

后面的课程会进一步讨论

回顾: 现代的处理器设计流程

Verilog语言标准规范

测试题

下面有若干条Verilog的编码建议或描述, 但其中有一些是不正确的, 请尝试找出它们:

  1. 使用#0可以将赋值操作强制延迟到当前仿真时刻的末尾.
  2. 在同一个begin-end语句块中对同一个变量进行多次非阻塞赋值, 结果是未定义的.
  3. always块描述组合逻辑元件时, 不能使用非阻塞赋值.
  4. 不能在多个always块中对同一个变量进行赋值.
  5. 不建议使用$display系统任务, 因为有时候它无法正确输出变量的值.
  6. $display无法输出非阻塞赋值语句的结果.

 

你可能会写一些Verilog代码, 但你大概率无法解释清楚上述问题

你真正需要了解的, 是Verilog的本质

Verilog语言标准规范及其本质

Verilog语言在仿真过程中的语义, 是由Verilog标准手册定义的

Intruduction

It was designed ... for a variety of design tools, including verification
simulation, timing analysis, test analysis, and synthesis.
  • Verilog标准的实现 = 各种设计工具, 主要面向仿真
    • simulation出现356次, synthesis出现4次

 

手册的第11章揭示了Verilog语言的本质

  • 只有5页, 但信息量巨大

传统的教材和大部分Verilog资料中都没被提及

  • 绝大多数Verilog的开发者, 甚至是从业者, 都不了解这部分内容
    • 从而无法精确理解上述问题

花点时间看看手册, 会对Verilog有脱胎换骨的理解

Verilog代码的执行

  • C语言的执行 = 通过符合标准规范的某种求值顺序来修改对象的状态
  • Verilog的执行 = ???

 

RTFM:

11.1 Execution of a model

The elements that make up the Verilog HDL can be used to describe the behavior, at
varying levels of abstraction, of electronic hardware. An HDL has to be a parallel
programming language. The execution of certain language constructs is defined by
parallel execution of blocks or processes. It is important to understand what
execution order is guaranteed to the user and what execution order is indeterminate.

Although the Verilog HDL is used for more than simulation, the semantics of the
language are defined for simulation, and everything else is abstracted from this
base definition.
  1. Verilog语言用来从各个抽象层次来描述硬件的行为.
    • 系统级, 行为级, RTL级, 门级, 晶体管级

Verilog代码的执行(2)

  1. HDL是并行编程语言, 部分语言成分的执行被定义为代码块或过程的并行执行.
    • 并行 = 所描述的对象是同时工作的
    • 和C语言的程序有所不同
  1. 理解什么样的执行顺序是由标准手册保证的, 什么样的执行顺序是不确定的, 对Verilog的用户来说至关重要.
    • 需要在使用Verilog的过程中避免这些情况, 从而保证所描述对象的行为是确定的
    • 和C语言的未指定行为或未定义行为有相似之处
  1. 尽管Verilog的使用场景并不仅限于仿真, Verilog的语义是为了仿真而定义的, 其他场景的语义都是基于这一基础定义抽象得到的.
    • Verilog的语义并不是为综合而定义的
    • 暗示: 仿真行为和综合行为可能存在差异! (仿真正确, 上FPGA出错)

基于事件的仿真

基于事件的仿真

Verilog标准手册的第11.2节定义了仿真的概念, 我们进行逐条说明:

The Verilog HDL is defined in terms of a discrete event execution model. The discrete
event simulation is described in more detail in this subclause to provide a context
to describe the meaning and valid interpretation of Verilog HDL constructs. These
resulting definitions provide the standard Verilog reference model for simulation,
which all compliant simulators shall implement. However, there is a great deal of
choice in the definitions that follow, and differences in some details of execution
are to be expected between different simulators. In addition, Verilog HDL simulators
are free to use different algorithms from those described in this clause, provided
the user-visible effect is consistent with the reference model.
  1. Verilog语言是基于离散事件的执行模型来定义的
  1. Verilog代码的语义和解析跟离散事件仿真的细节相关
  1. 这些定义提供了一个标准的Verilog仿真参考模型, 所有符合规范的仿真器都需要实现
  1. 对于有些定义的不同选择, 以及执行的不同细节, 不同仿真器之间可以存在差异
  1. 此外, 在保证对用户可见的行为与参考模型一致的条件下, Verilog仿真器可以选择不同的算法实现

基于事件的仿真(2)

A design consists of connected threads of execution or processes. Processes are
objects that can be evaluated, that may have state, and that can respond to changes
on their inputs to produce outputs. Processes include primitives, modules, initial
and always procedural blocks, continuous assignments, asynchronous tasks, and
procedural assignment statements.
  1. 一个设计由相关的执行线程组成, 也称过程
  1. 过程是一些可以被求值的对象, 这些对象拥有自己的状态, 当输入产生变化时, 它们可以响应这些变化, 并产生输出
  1. 过程包括原语, 模块, initialalways块, 连续赋值, 异步任务和过程赋值语句

 

Every change in value of a net or variable in the circuit being simulated, as well
as the named event, is considered an update event.

在被仿真电路中, 线网或变量的值的变化, 以及命名事件, 都被视为一个更新事件

基于事件的仿真(3)

Processes are sensitive to update events. When an update event is executed, all the
processes that are sensitive to that event are evaluated in an arbitrary order. The
evaluation of a process is also an event, known as an evaluation event.
  1. 过程是对更新事件敏感的: 执行一个更新事件后, 对该事件敏感的所有过程将会被求值, 求值顺序是任意的
  1. 一个过程的求值也是一个事件, 称为求值事件

 

In addition to events, another key aspect of a simulator is time. The term
simulation time is used to refer to the time value maintained by the simulator to
model the actual time it would take for the circuit being simulated. The term time
is used interchangeably with simulation time in this clause.
  1. 除了事件以外, 对仿真器来说, 另一个关键的概念是时间
  1. 仿真时间用于对仿真电路所用的实际时间进行建模, 由仿真器维护
  1. 在本章中, 仿真时间有时候也简称时间

基于事件的仿真(4)

Events can occur at different times. In order to keep track of the events and to
make sure they are processed in the correct order, the events are kept on an event
queue, ordered by simulation time. Putting an event on the queue is called
scheduling an event.
  1. 不同的仿真时刻都会发生事件
  1. 为了保证按正确顺序处理事件, 需要将它们按仿真时刻的顺序存储在一个事件队列中
  1. 将一个事件放入队列中称为事件的调度

 

总结: Verilog语言成分的语义都和事件有关联:

  • 仿真的过程就是按某种正确的顺序处理这些事件的过程
    • 包括更新事件和求值事件
  • 在处理事件的过程中, 电路中对象的状态会发生改变
  • 我们期望这种改变符合电路的预期行为, 从而实现对电路行为的仿真

层次化事件队列

层次化事件队列

根据Verilog标准手册的第11.3节, 事件队列在逻辑上包含以下5个区域, 分别用于处理对应种类的事件:

  1. 激活事件(active event)区域, 记为\(R_1\)
    • 存放发生在当前仿真时刻, 且能被处理的事件
  1. 未激活事件(inactive event)区域, 记为\(R_2\)
    • 存放发生在当前仿真时刻, 但不能立即处理的事件
    • 需要在\(R_1\)为空时, 才能处理这类事件
  1. 非阻塞赋值更新事件(nonblocking assign update event)区域, 记为\(R_3\)
    • 存放在之前的仿真时刻已经完成求值, 但需要在当前仿真时刻结束时才能进行赋值的事件
    • 需要在\(R_1\)\(R_2\)均为空时, 才能处理这类事件

层次化事件队列(2)

  1. 监控事件(monitor event)区域, 记为\(R_4\)
    • 存放监控操作相关的事件
    • 需要在\(R_1\), \(R_2\)\(R_3\)均为空时, 才能处理这类事件
  1. 未来事件(future event)区域, 记为\(R_5\)
    • 存放在未来仿真时刻才处理的事件

 

  • 一个事件会按其类别添加到不同区域, 并按照一定的规则转移到\(R_1\), 被处理后从事件队列中移除
  • 一些例子:
    • 显式零延迟(#0)可以使对应的过程挂起, 将产生一个\(R_2\)事件
    • 非阻塞赋值将产生一个\(R_3\)事件
    • 系统任务$monitor$strobe在每个仿真时刻都产生一个\(R_4\)事件

事件处理引擎的参考模型

Verilog标准手册的第11.4节给出了事件处理引擎的参考模型

  • 这个参考模型实现了层次化事件队列, 是Verilog仿真器的核心循环:
while (there are events) {  // 不断重复如下操作
  if (no active events) {   // R1为空时
    if (there are inactive events) {  // 如果R2不为空
      activate all inactive events;   // 则将R2中的所有事件转移到R1
    } else if (there are nonblocking assign update events) { // 否则, 如果R3不为空
      activate all nonblocking assign update events;  // 则将R3中的所有事件转移到R1
    } else if (there are monitor events) {  // 否则, 如果R4不为空
      activate all monitor events;          // 则将R4中的所有事件转移到R1
    } else {                                  // 否则
      advance T to the next event time;       // 将仿真时刻前进一个单位
      activate all inactive events for time T;// 将R5中当前仿真时刻的事件按类型转移到R1或R3
    }
  }
  E = any active event;  // 从R1中取出一个事件
  if (E is an update event) {    // 如果是更新事件
    update the modified object;  // 更新相应的对象
    // 将对该事件敏感的过程的求值作为求值事件添加到事件队列中
    add evaluation events for sensitive processes to event queue;
  } else {                                 // 如果是求值事件
    evaluate the process;                  // 对过程进行求值
    add update events to the event queue;  // 将赋值行为作为更新事件添加到事件队列中
  }
}

Verilog代码 != C代码

  • 数字电路老师: 不能把Verilog当作C语言来写
  • 你: 如果不能当作C语言来写, 那Verilog究竟是什么?

 

事件处理引擎可以帮助大家回答这个问题

  • i = i + 1为例
    • C代码: 最终被编译成一条类似addi a0, a0, 1的指令, 最后在处理器上直接执行
    • Verilog代码: 被转化成一个求值事件和一个更新事件, 在事件处理引擎的处理下完成加法操作和赋值操作, 并产生对其敏感的新事件

 

  • 你写的Verilog代码, 最终都会按照标准手册的约定转换成一个个事件
  • 仿真器按照符合标准手册约定的某种顺序来处理这些事件
    • 反映出硬件电路的整体行为, 从而实现硬件电路的建模

赋值操作的事件调度

赋值操作的事件调度

根据Verilog标准手册的第11.6节, 赋值操作会转换成行为等价的过程

  • 从而产生相应的事件, 被仿真器处理

 

下面选取一些常见的赋值操作进行说明

  • 先考虑没有指定延迟信息(#)的情况

 

  1. 连续赋值(即assign语句)
    • 对应一个对表达式的源操作数都敏感的过程
    • 当表达式的任意源操作数的值发生变化时, 将产生一个求值事件添加到\(R_1\)
    • 当表达式的值变化时, 将产生一个更新事件添加到\(R_1\)
    • 特别地, 连续赋值过程会产生一个0时刻的求值事件, 实现常量的传播

重新认识阻塞赋值和非阻塞赋值

  1. 过程中的阻塞赋值
    • 先使用对象的当前值计算赋值表达式右侧的值
    • 然后马上计算赋值表达式左侧的赋值目标对象, 对其进行更新
    • 执行过程可以继续按顺序执行下一条语句, 也可以处理其他激活事件
  1. 过程中的非阻塞赋值
    • 先使用对象的当前值计算赋值表达式右侧的值和左侧的赋值目标对象
    • 产生一个当前仿真时刻的\(R_3\)事件

两者的本质区别: 更新操作的时机有所不同

  • 阻塞赋值 - 更新操作紧跟在求值操作后一同进行, 不产生更新事件
  • 非阻塞赋值 - 求值操作和更新操作是分开的
    • 求值后生成一个\(R_3\)的更新事件, 要等到\(R_1\)\(R_2\)均为空时才能处理

结果: 能看到赋值结果的事件集合并不相同, 从而影响这些事件的行为, 最终影响电路的整体行为

赋值操作的事件调度(2)

  1. 端口连接
    • 对于输入端口的连接.a(i), 将视为连续赋值语句assign a = i;来处理
    • 对于输出端口的连接.b(o), 将视为连续赋值语句assign o = b;来处理
  1. 函数和任务
    • 调用时参数按值传递
    • 返回时, 用返回值替换调用处的行为按阻塞赋值来处理

 

Verilog标准手册还定义了如何将更多情况转换成事件来处理:

  • 包括指定延迟信息, 使用过程连续赋值语句, 处理晶体管层次行为等
  • 需要了解时可RTFM

事件处理顺序

事件处理顺序的不确定性

事件处理的顺序并非100%确定的

 

根据Verilog标准手册的定义, 不确定性主要有两个来源:

  1. 事件队列中有多个激活事件时, 处理顺序是任意的
  1. 在行为模块中, 不带时间控制(即#表达式和@表达式)的语句不必作为一整个事件来处理
    • 在对行为模块中的一条语句进行求值时, 仿真器可以随时挂起这条语句的执行, 并将剩下的执行操作作为事件队列中的一个激活事件
    • 这样可以允许不同的过程交织执行
    • 但交织的顺序是不确定的, 而且不受用户的控制

为什么Verilog需要引入这些不确定性?

硬件电路的行为本身就具有并行性: 多个组件之间天然就是并行工作的

  • 从电路行为模型和真实电路的一致性来看, 并不存在规定这些组件之间工作先后顺序的理由
    • 如果强行规定这些先后顺序, 会导致建模结果无法全面地反映真实电路的工作情况
    • 特别地, 如果真实电路存在问题, 但无法通过建模反映出来并及时修复, 建模就失去意义
  • 从仿真器的软件本质来看, 仿真器只能以串行方式处理不同的事件
    • Verilog标准定义的这些不确定性, 其实是对事件处理顺序的一种放松: 如果两个事件之间不存在依赖关系, 也不必要求它们按一定的先后顺序来处理
    • 进一步地, 仿真器甚至可以使用一些并行优化技术来处理这些没有依赖关系的事件, 从而更好地模拟电路组件之间的并行性

事件处理顺序的确定性

定义\(A \to B\)表示事件\(A\)先于事件\(B\)被处理

  • 类似C语言标准中程序执行的前序于关系

 

事件处理引擎其实隐含了一些顺序要求:

  • 顺序规则1(GEN) - 如果在处理\(A\)的过程中生成了\(B\), 则\(A \to B\)
    • 要处理\(B\), 必须先完成\(A\)的处理

 

  • 顺序规则2(EVQ) - 如果在仿真过程中的某一时刻, 有\(A\in R_i\), \(B\in R_j\), 且\(i < j\), 则\(A \to B\)
    • 事件处理引擎会在\(R_i\)中的事件都处理完后, 才会处理\(R_j\)中的事件

事件处理顺序的确定性(2)

此外, Verilog标准手册还显式定义了如下两条顺序规则:

  • 顺序规则3(SEQ) - begin-end语句块中的语句需要按语句顺序执行
    • 也即, 对于同一个begin-end语句块中的两个语句\(S_i\)\(S_j\), 若\(i < j\), 则\(S_i \to S_j\)

 

  • 顺序规则4(NBA) - 非阻塞赋值操作需要按语句的执行顺序来进行
    • 也即, 若\(A, B \in R_3\), 相应的赋值表达式求值操作分别为\(A^\prime\)\(B^\prime\), 且有\(A^\prime \to B^\prime\), 则\(A \to B\)

一个示例

initial begin
  a <= 0; // (1)
  a <= 1; // (2)
end
  • \(\mathrm{eval(expr)}\)表示 “表达式\(\mathrm{expr}\)的求值事件”
  • \(\mathrm{update(obj)}\)表示 “对象\(\mathrm{obj}\)的更新事件”
  • 标注(1)的语句可分解为\(E_1: \mathrm{eval(0)}\)\(E_2: \mathrm{update(a)}\)两个事件
  • 标注(2)的语句可分解为\(E_3: \mathrm{eval(1)}\)\(E_4: \mathrm{update(a)}\)两个事件

 

  • 考虑顺序规则1(GEN), 应有\(E_1 \to E_2\), \(E_3 \to E_4\)
  • 考虑顺序规则2(EVQ), 应有\(E_3 \to E_2\), \(E_1 \to E_4\)
  • 考虑顺序规则3(SEQ), 应有\(E_1 \to E_3\)
  • 考虑顺序规则4(NBA), 应有\(E_2 \to E_4\)

 

结论: 有且仅有\(E_1 \to E_3 \to E_2 \to E_4\)

  • 仿真器只能按这种顺序来处理事件: 对象a将先被赋0, 然后被赋1

回顾流水灯的例子

always @(posedge clk) begin
  if (rst) begin led <= 1; count <= 0; end
  else begin
    if (count == 0) led <= {led[14:0], led[15]};
    count <= (count >= 5000000 ? 0 : count + 1);
  end
end

假设clk上升沿到来, 在这个仿真时刻中, 可能会发生以下事件:

  • \(E_1: \mathrm{eval(1)}\)
  • \(E_2: \mathrm{update(led)}\)
  • \(E_3: \mathrm{eval(0)}\)
  • \(E_4: \mathrm{update(count)}\)
  • \(E_5: \mathrm{eval(\{led[14:0], led[15]\})}\)
  • \(E_6: \mathrm{update(led)}\)
  • \(E_7: \mathrm{eval(count >= 5000000 ? 0 : count + 1)}\)
  • \(E_8: \mathrm{update(count)}\)

结论:

  • rst = 1时, 应有\(E_1 \to E_3 \to E_2 \to E_4\)
  • rst = 0, count = 0时, 应有\(E_5 \to E_7 \to E_6 \to E_8\)
  • rst = 0, count != 0时, 应有\(E_7 \to E_8\)

用C代码实现事件处理顺序

  • rst = 1时, 应有\(E_1 \to E_3 \to E_2 \to E_4\)
  • rst = 0, count = 0时, 应有\(E_5 \to E_7 \to E_6 \to E_8\)
  • rst = 0, count != 0时, 应有\(E_7 \to E_8\)
static void cycle(Circuit *c) {
  c->led_update = 0; c->count_update = 0;
  if (c->rst) { EVAL(c, led, 1); EVAL(c, count, 0); }
  else {
    if (c->count == 0) { EVAL(c, led, (BITS(c->led,14,0)<<1) | BITS(c->led,15,15)); }
    EVAL(c, count, c->count >= 5000000 ? 0 : c->count + 1);
  }
  UPDATE(c, led); UPDATE(c, count);
}
  • 两个宏EVAL()UPDATE()分别实现了求值事件\(\mathrm{eval(expr)}\)和更新事件\(\mathrm{update(obj)}\)的语义
  • 事件并非从一个队列中取出, 而是按一定顺序直接平铺在C代码中
    • Verilog标准手册对事件队列的定义是逻辑上的
The Verilog event queue is logically segmented into five different regions.

两种仿真机制

周期仿真(cycle simulation) 事件仿真(event simulation)
驱动方式 仿真周期 事件队列
事件调度 静态调度, 求值顺序在仿真开始前(编译时刻)决定 动态调度, 求值顺序在仿真过程中(运行时刻)决定
仿真效率 较快, 仿真时没有调度开销; C代码有更多优化机会 较慢, 需要维护事件队列
时序信息 不支持 支持
适用电路 同步电路 同步/异步/混合电路
使用场景 大规模同步电路的功能验证 功能验证, 时序验证
代表工具 Verilator VCS, iVerilog
仿真香山 585Hz(1T), 1062Hz(2T), 1830Hz(4T), 2968Hz(8T) 50Hz(xprop), 86Hz(non-xprop)

数据竞争

数据竞争的概念

  • 一个有效的真实电路在各个组件并行工作的情况下, 行为应当一致
  • 这要求电路模型无论不管按何种顺序处理事件, 都应该得到一致的结果

 

  • 相反, 如果存在两种不同的事件处理顺序, 使得仿真结果不一致, 则称为存在数据竞争(data race)
    • Verilog标准手册将其称为竞争条件(race condition)

 

通常, 如果仿真过程中存在数据竞争, 这个电路模型所描述的就不是一个有效的真实电路

数据竞争的例子

always @(posedge clk or negedge rstn) begin
  if (!rstn) a = 1'b0;
  else a = b; // (1)
end

always @(posedge clk or negedge rstn) begin
  if (!rstn) b = 1'b1;
  else b = a; // (2)
end

假设在某时刻有a = 0, b = 1, rstn = 1, 且clk的上升沿到来

定义一个新操作\(\mathrm{evalAndUpdate(expr)}\)来表示阻塞赋值的行为

  • 标注(1)的语句可得到\(E_1: \mathrm{evalAndUpdate(a = b)}\)
  • 标注(2)的语句可得到\(E_2: \mathrm{evalAndUpdate(b = a)}\)

根据Verilog标准手册的定义, 多个激活事件的处理顺序是任意的

  • 其他顺序规则无法适用
  • \(E_1 \to E_2\), 结果为a = 1, b = 1
  • \(E_2 \to E_1\), 结果为a = 0, b = 0

数据竞争的仿真结果

对于存在数据竞争的代码, 当仿真器选择不同的事件处理顺序时, 会导致不同的仿真结果

  • 不同仿真结果可能出现在不同的仿真器中
  • 也可能会出现在同一款仿真器的不同版本中
  • 还可能会出现在同一款仿真器, 同一个版本的多次运行中
  • 甚至可能会出现在同一款仿真器, 同一个版本, 单次运行的不同仿真时刻中

 

如果Verilog代码中存在数据竞争, 仿真结果可能是难以预测的

  • 这有点类似C语言中的未指定行为

 

作业: 将上述代码中的阻塞赋值改成非阻塞赋值, 可以消除数据竞争, 分析原因

另一个数据竞争的例子

always @(posedge clk or negedge rstn) begin
  if (!rstn) a = 1'b0;
  else a = 1;
end

always @(posedge clk) begin
  $display("a = %d", a);
end

假设在某时刻有a = 0, rstn = 1, 且clk的上升沿到来

  • 分析可得\(E_1: \mathrm{evalAndUpdate(a = 1)}\)\(E_2: \mathrm{display(a)}\)

注意到$display系统任务的处理事件\(E_2 \in R_1\)

  • \(E_1 \to E_2\), 输出a = 1; 若\(E_2 \to E_1\), 输出a = 0

结论: 上述代码中存在数据竞争

  • 当仿真器选择不同的事件处理顺序时, 仍然会导致不同的仿真结果
  • 这可能会给开发者的调试带来混乱

另一个数据竞争的例子(2)

module top(output [15:0] y);
  initial begin y = 1234; end
  initial begin $display("y = %d", y); end
endmodule
module top(output [15:0] y);
  initial begin $display("y = %d", y); end
  initial begin y = 1234; end
endmodule
#include <stdio.h>
#include "Vtop.h"
int main() {
  static Vtop *top = new Vtop;
  while (1) { top->eval(); }
  return 0;
}
verilator --cc --exe main.cpp top.v
make -C obj_dir -f Vtop.mk
./obj_dir/Vtop

作业: 将$display改成$strobe可消除数据竞争, 分析原因

存在数据竞争的充分必要条件

Verilog代码中存在数据竞争, 当且仅当存在和同一个对象\(\mathrm{obj}\)相关的两个事件\(E_1\)\(E_2\), 同时满足:

  • \(E_1\)\(E_2\)之间的处理顺序不确定
  • \(E_1\)\(E_2\)中, 至少一个事件会更新\(\mathrm{obj}\)

 

要消除数据竞争, 就需要消除满足上述条件的事件

  • 不过当项目规模变得复杂, 要人工判断代码中是否存在数据竞争, 是很困难的

 

为了应对这个挑战, 很多Verilog书籍和相关资料都会推荐一些良好的编码规范

  • 如果开发者遵循这些编码规范, 就可以消除代码中的绝大部分数据竞争, 从而更有可能设计出行为符合预期的电路

良好的Verilog编码风格

一些编码建议

Nonblocking Assignments in Verilog Synthesis, Coding Styles That Kill!”这篇文章中提出了不少Verilog编码建议

  • 并提到, 采用这些建议可以消除90%以上的数据竞争
  • 理解Verilog的事件模型后, 我们可以来分析这些建议背后的原理

 

  1. 时序电路建模时, 用非阻塞赋值
    • 非阻塞赋值的更新事件在\(R_1\)\(R_2\)中的事件处理结束后才处理
    • 与 “时序逻辑元件在下一个时钟到来时才进行写入”的属性匹配
  1. always块建立组合逻辑模型时, 用阻塞赋值.
    • 阻塞赋值的更新事件在\(R_1\)中马上被处理
      • 其他事件可以马上看到阻塞赋值的更新结果, 从而可以使用更新后的结果进行后续的求值
    • 与 “组合逻辑元件的输出在输入改变时马上改变”的属性匹配

一些编码建议(2)

  1. 在同一个always块中建立时序和组合逻辑电路时, 用非阻塞赋值
    • 和Verilog的可综合语义相关, 下次课介绍
  1. 在同一个always块中不要既用非阻塞赋值又用阻塞赋值
    • 和Verilog的可综合语义相关, 下次课介绍
  1. 不要在一个以上的always块中为同一个变量赋值
    • 不同的always块属于不同的过程, 它们之间的求值顺序是不确定的
    • 加上对变量赋值会产生更新事件, 正好满足数据竞争的充分必要条件
    • 和Verilog的可综合语义相关, 下次课介绍
  1. $strobe系统任务来显示用非阻塞赋值的变量值
    • 非阻塞赋值的更新事件属于\(R_3\), 而$strobe系统任务的事件属于\(R_4\)
  1. 在赋值时不要使用#0延迟
    • #0产生的事件属于\(R_2\), 其处理时机位于\(R_1\)\(R_3\)之间
    • 若不理解事件处理顺序, 可能会写出行为与预期不符的代码

回顾测试题

下面有若干条Verilog的编码建议或描述, 但其中有一些是不正确的, 请尝试找出它们:

  1. 使用#0可以将赋值操作强制延迟到当前仿真时刻的末尾.
  2. 在同一个begin-end语句块中对同一个变量进行多次非阻塞赋值, 结果是未定义的.
  3. always块描述组合逻辑元件时, 不能使用非阻塞赋值.
  4. 不能在多个always块中对同一个变量进行赋值.
  5. 不建议使用$display系统任务, 因为有时候它无法正确输出变量的值.
  6. $display无法输出非阻塞赋值语句的结果.

 

理解Verilog的事件模型后, 你已经具备能力来判断这些说法是否正确了

  • 作为作业留给大家

总结

RTL仿真

  • RTL仿真 = 用C程序实现数字电路的状态机

 

  • Verilog仿真过程 = 按某种正确的顺序处理事件, 从而改变电路的状态
    • 两种事件, 层次化事件队列(5个区域)
    • 赋值操作会转换成行为等价的过程, 从而产生相应的事件
    • 事件处理的顺序并非100%确定的, 存在不确定性
    • 事件处理顺序的不确定性可能会导致数据竞争
      • 存在数据竞争的充分必要条件 - 事件顺序不确定+至少一个更新事件

 

  • 遵循一些编码建议, 可以消除大部分数据竞争
    • 学会通过事件模型分析一条建议是否合理