0%

近期接触了国科大的Nutshell项目,深感其差分测试思想的精妙之处,非常感谢国科大的教学团队为CPU测试提供了一个创新的思路。

本文记录了笔者学习差分测试源代码时的一些收获与想法。

要理解差分测试的源代码,首先需要理解Verilator仿真器的仿真机制。笔者在学习Verilator的过程中,也记录了相关的笔记:https://hubohan.space/2020/08/30/Verilator_note/

差分测试的总体思想

在参加“龙芯杯”竞赛的时候,我们接触了基于trace比对的测试机制,让DUT和黄金模型同样执行一段程序,捕捉黄金模型处理器状态改变的轨迹,即哪条指令(PC)在何时(trace中的顺序)往哪个寄存器(wnum)写入了什么值(wbdata),然后将DUT执行的轨迹和黄金模型执行的轨迹逐条比对,遇到出错点即停止执行,这其中其实也用到了差分测试的思路。

Easydiff是Nutshell采用的测试框架,它将这个差分测试的思想更进了一步,trace比对机制是将处理器状态改变的轨迹进行比对,而Easydiff是将处理器的全状态进行比对,包括GPR、控制寄存器和PC。毫无疑问地,这个比对的粒度更细,能够更精确地定位到错误点。

在Easydiff中,DUT是我们自己实现的CPU,黄金模型是南京大学的NEMU模拟器。Easydiff在DUT状态改变的时候,立即让NEMU模拟器执行和DUT相同的指令,并且比对其状态,若有差异,立刻报错停止。

在Easydiff框架中,采用的仿真器是Verilator,其将Verilog源码构建为C++描述的仿真模型,而C++语言相较Verilog,具有更大的灵活性。项目提供的NEMU模拟器为动态链接库的形式,能够在运行时灵活地调用其API,进行动态链接。

为了屏蔽NEMU的实现细节,将NEMU提供的API摘录如下:

API 说明
ref_difftest_getregs 从NEMU中获取寄存器状态
ref_difftest_setregs 设置NEMU的寄存器状态
ref_difftest_exec NEMU执行n条指令
ref_difftest_raise_intr 触发NEMU的中断

在仿真阶段,NEMU的API通过动态链接的形式在仿真驱动代码main.cpp以及其下层函数中被调用,使用者无需关注其实现细节。

观察生成的Verilog顶层模型

  • 使用make verilog生成相应的Verilog文件,最终生成的代码在build/Topmain.v里面

    image-20200829214644319

其中可以看到,除了NutShell的顶层模块之外,还有SDHelper.vUARTGetc.vFBHelper.v三个文件,这三个文件是使用了Verilog与C语言的DPI接口,使用C语言描述相关的仿真行为,并在Verilog中进行调用,我们可以在文件中找到相关的细节:

1
import "DPI-C" function void uart_getc(output byte ch);

为什么要这样写呢?因为仿真时可能会用到串口输入,而我们的控制台明显无法仿真串口,需要用C语言编写一个“假”的串口来进行仿真,而这个“串口”可以读取用户的键盘输入,并且将DUT的输出显示在控制台上。

关于DPI-C的接口使用,稍后会进行介绍。

同时注意到,仿真所使用的顶层模块NutShellSimTop,其暴露给外部有以下接口:

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
28
29
module NutShellSimTop(
input clock,
input reset,
output [63:0] io_difftest_r0,
output [63:0] io_difftest_r1,
output [63:0] io_difftest_r2,
// ...........................中间略 ............................
output [63:0] io_difftest_r30,
output [63:0] io_difftest_r31,
output io_difftest_commit,
output io_difftest_isMultiCommit,
output [63:0] io_difftest_thisPC,
output [31:0] io_difftest_thisINST,
output io_difftest_isMMIO,
output io_difftest_isRVC,
output io_difftest_isRVC2,
output [63:0] io_difftest_intrNO,
output [1:0] io_difftest_priviledgeMode,
output [63:0] io_difftest_mstatus,
output [63:0] io_difftest_sstatus,
output [63:0] io_difftest_mepc,
output [63:0] io_difftest_sepc,
output [63:0] io_difftest_mcause,
output [63:0] io_difftest_scause,
input [63:0] io_logCtrl_log_begin,
input [63:0] io_logCtrl_log_end,
input [63:0] io_logCtrl_log_level,
output io_difftestCtrl_enable
);

这些接口是暴露给编写的difftest函数使用的,目的在于进行对比。

main.c - 开始的地方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int main(int argc, const char** argv) {
auto emu = Emulator(argc, argv); // <--------------- PAY ATTENTION TO IT

get_sc_time_stamp = [&emu]() -> double { // 匿名函数,lambda表达式
return emu.get_cycles();
};

emu.execute();

extern uint32_t uptime(void);
uint32_t ms = uptime();

int display_trapinfo(uint64_t max_cycles);
int ret = display_trapinfo(emu.get_max_cycles());
eprintf(ANSI_COLOR_BLUE "Guest cycle spent: %" PRIu64 "\n" ANSI_COLOR_RESET, emu.get_cycles());
eprintf(ANSI_COLOR_BLUE "Host time spent: %dms\n" ANSI_COLOR_RESET, ms);

return ret;
}

我们看到,主循环中,构造了一个模拟器Emulator,并且根据Verilator的要求,需要自行构造一个get_cycle的函数,以供Verilog中的$time任务调用。

那么需要熟悉的就是Emulator和里面的execute函数了。

emu.h - 解构Emulator

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
28
29
30
31
32
33
34
class Emulator {
const char *image; // 镜像路径
std::shared_ptr<VNutShellSimTop> dut_ptr; // Verilator模型
VerilatedVcdC* tfp; // Verilator波形(由宏定义进行开关)

// 仿真器控制的相关变量
uint32_t seed;
uint64_t max_cycles, cycles;
uint64_t log_begin, log_end, log_level;
// 部分辅助函数,此处无需关注
std::vector<const char *> parse_args(int argc, const char *argv[]);
static const struct option long_options[];
static void print_help(const char *file);

void read_emu_regs(rtlreg_t *r); // 从DUT顶层模块读取相关寄存器

public:
// Emulator的构造函数
Emulator(int argc, const char *argv[]);
// n个周期的复位
void reset_ncycles(size_t cycles);
// 步进单个周期
void single_cycle();
// 执行n个周期
void execute_cycles(uint64_t n);
// 测试Cache的函数
void cache_test(uint64_t n);
// 执行,同时进行difftest
void execute();
// 返回当前的周期数,用于Verilog的$time任务
uint64_t get_cycles();
// 返回最大的周期数,是通过构造函数设置的,防止卡死
uint64_t get_max_cycles();
};

read_emu_regs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  void read_emu_regs(rtlreg_t *r) {   // 从DUT顶层模块读取相关寄存器
#define macro(x) r[x] = dut_ptr->io_difftest_r_##x
macro(0); macro(1); macro(2); macro(3); macro(4); macro(5); macro(6); macro(7);
macro(8); macro(9); macro(10); macro(11); macro(12); macro(13); macro(14); macro(15);
macro(16); macro(17); macro(18); macro(19); macro(20); macro(21); macro(22); macro(23);
macro(24); macro(25); macro(26); macro(27); macro(28); macro(29); macro(30); macro(31);
r[DIFFTEST_THIS_PC] = dut_ptr->io_difftest_thisPC;
#ifndef __RV32__ // 读取CSR寄存器,我们在顶层编译选项中定义的是RV64
r[DIFFTEST_MSTATUS] = dut_ptr->io_difftest_mstatus;
r[DIFFTEST_SSTATUS] = dut_ptr->io_difftest_sstatus;
r[DIFFTEST_MEPC ] = dut_ptr->io_difftest_mepc;
r[DIFFTEST_SEPC ] = dut_ptr->io_difftest_sepc;
r[DIFFTEST_MCAUSE ] = dut_ptr->io_difftest_mcause;
r[DIFFTEST_SCAUSE ] = dut_ptr->io_difftest_scause;
#endif
}

将宏定义展开之后,我们看到,这个函数其实是依次将相关的寄存器dump出来,按顺序放入了一个rtlreg_t类型的变量中。(事实上,rtlreg_t变量也就是uint64的数组。如果是RV32,那么就是uint32,这个根据宏定义来开关,而宏定义是在编译的时候,通过编译选项指定的,详见Makefile)。

其中,剩下的例如DIFFTEST_THIS_PC等下标,在difftest.h中定义如下,PC作为32号,其余以此类推:

1
2
3
4
5
6
7
8
9
10
11
12
enum {
DIFFTEST_THIS_PC = 32,
#ifndef __RV32__
DIFFTEST_MSTATUS,
DIFFTEST_MCAUSE,
DIFFTEST_MEPC,
DIFFTEST_SSTATUS,
DIFFTEST_SCAUSE,
DIFFTEST_SEPC,
#endif
DIFFTEST_NR_REG
};

初始化 - 构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
   auto args = parse_args(argc, argv);

// srand
srand(seed);
srand48(seed);
// 随机复位
Verilated::randReset(2);

// 设置日志的起始时间和日志级别
dut_ptr->io_logCtrl_log_begin = log_begin;
dut_ptr->io_logCtrl_log_end = log_end;
dut_ptr->io_logCtrl_log_level = log_level;

// 使用镜像来初始化ram
extern void init_ram(const char *img);
init_ram(image);

// 初始化外部设备
extern void init_device(void);
init_device();

// init core
reset_ncycles(10);

reset_ncycles - 重置n个周期

1
2
3
4
5
6
7
8
for(int i = 0; i < cycles; i++) { // 重置n个周期
dut_ptr->reset = 1;
dut_ptr->clock = 0;
dut_ptr->eval();
dut_ptr->clock = 1;
dut_ptr->eval();
dut_ptr->reset = 0;
}

single_cycle() - 前进一个时钟周期(单步)

1
2
3
4
5
6
7
8
9
10
11
    dut_ptr->clock = 0;
dut_ptr->eval();

dut_ptr->clock = 1;
dut_ptr->eval();

#if VM_TRACE
tfp->dump(cycles);
#endif

cycles ++;
  • 此处使用了Verilator手册中提到的eval()

  • 当设置了时钟下降沿之后,调用eval()计算组合逻辑,设置了上升沿之后,调用eval()更新时序逻辑。

  • 这里维护的cycles变量是为了给Verilog的$time任务使用,Verilator规定这个需要由用户维护。

execute_cycles - 最核心的部分

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
  void execute_cycles(uint64_t n) {
extern bool is_finish();
extern void poll_event(void);
extern uint32_t uptime(void);
extern void set_abort(void);
uint32_t lasttime = 0;
uint64_t lastcommit = n;
int hascommit = 0;
const int stuck_limit = 2000; // 超过2000周期没有指令提交,可能会是卡住了

#if VM_TRACE
Verilated::traceEverOn(true); // Verilator must compute traced signals
VL_PRINTF("Enabling waves...\n");
tfp = new VerilatedVcdC;
dut_ptr->trace(tfp, 99); // Trace 99 levels of hierarchy
tfp->open("vlt_dump.vcd"); // Open the dump file
#endif

while (!is_finish() && n > 0) { // 主执行循环
single_cycle();
n --;

if (lastcommit - n > stuck_limit && hascommit) { // 如果太久都没有指令提交,那说明有可能卡住了,强制停止
eprintf("No instruction commits for %d cycles, maybe get stuck\n"
"(please also check whether a fence.i instruction requires more than %d cycles to flush the icache)\n",
stuck_limit, stuck_limit);
#if VM_TRACE
tfp->close();
#endif
set_abort();
}

if (!hascommit && (uint32_t)dut_ptr->io_difftest_thisPC == 0x80000000) { // 开始执行,初始PC=80000000
//
hascommit = 1;
extern void init_difftest(rtlreg_t *reg);
rtlreg_t reg[DIFFTEST_NR_REG];
read_emu_regs(reg);
init_difftest(reg);
}

// difftest
if (dut_ptr->io_difftest_commit && hascommit) { // 有提交的时候,就需要进行差分测试了
// 将里面的所有reg都提取出来
rtlreg_t reg[DIFFTEST_NR_REG];
read_emu_regs(reg);

extern int difftest_step(rtlreg_t *reg_scala, uint32_t this_inst,
int isMMIO, int isRVC, int isRVC2, uint64_t intrNO, int priviledgeMode, int isMultiCommit);
// 这里有一个参数,如果是双提交的话,需要检测两次
if (dut_ptr->io_difftestCtrl_enable) {
if (difftest_step(reg, dut_ptr->io_difftest_thisINST, // <----------- PAY ATTENTION TO THIS FUNCTION !!!
dut_ptr->io_difftest_isMMIO, dut_ptr->io_difftest_isRVC, dut_ptr->io_difftest_isRVC2,
dut_ptr->io_difftest_intrNO, dut_ptr->io_difftest_priviledgeMode,
dut_ptr->io_difftest_isMultiCommit)) {
#if VM_TRACE
tfp->close();
#endif
set_abort();
}
}
lastcommit = n;
}

uint32_t t = uptime();
if (t - lasttime > 100) {
poll_event();
lasttime = t;
}
}
}
  • difftest_step()

  • poll_event()

  • 在开始仿真的时候,其实NEMU的状态是不确定的,需要在Reset之后,用我们编写的CPU的初始状态去校准NEMU的初始状态,他们一致之后,才会开始真正的仿真。

difftest_step() - 灵魂

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
int difftest_step(rtlreg_t *reg_scala, uint32_t this_inst,
int isMMIO, int isRVC, int isRVC2, uint64_t intrNO, int priviledgeMode,
int isMultiCommit
) {

// Note:
// reg_scala[DIFFTEST_THIS_PC] is the first PC commited by CPU-WB
// ref_r[DIFFTEST_THIS_PC] is NEMU's next PC
// To skip the compare of an instruction, replace NEMU reg value with CPU's regfile value,
// then set NEMU's PC to next PC to be run

#define DEBUG_RETIRE_TRACE_SIZE 16

rtlreg_t ref_r[DIFFTEST_NR_REG];
rtlreg_t this_pc = reg_scala[DIFFTEST_THIS_PC];
// ref_difftest_getregs() will get the next pc,
// therefore we must keep track this one
// 需要注意的是:NEMU的PC是NEMU将要执行的下一条指令的PC
// ref_difftest_getregs() 拿到的PC是NEMU计算出的下一个PC
// 为了避免对比出错,我们需要用当前CPU执行的PC去覆盖读出的PC
// 为了避免丢失原有的PC,我们需要将NEMU拿出来的下一个PC进行保存,保存到nemu_this_pc中
static rtlreg_t nemu_this_pc = 0x80000000;
// 这个循环队列,是用于记录之前的轨迹用的
static rtlreg_t pc_retire_queue[DEBUG_RETIRE_TRACE_SIZE] = {0};
static uint32_t inst_retire_queue[DEBUG_RETIRE_TRACE_SIZE] = {0};
static uint32_t multi_commit_queue[DEBUG_RETIRE_TRACE_SIZE] = {0};
static uint32_t skip_queue[DEBUG_RETIRE_TRACE_SIZE] = {0};
static int pc_retire_pointer = 7;
static int need_copy_pc = 0;
#ifdef NO_DIFFTEST
return 0;
#endif
// Copy PC是什么机制?
// 这个代码块的作用,猜测是仅在双提交+MMIO的情况下有用
// 通过在代码中添加assert断言,确定其确实是在双提交的时候才有用
// 在双提交,且为MMIO+跳转的模式下,下一个PC不应当是PC+8,而应当是跳转的目标
// 我们假设DUT计算的跳转的目标永远是正确的,直接将其拷贝给NEMU
if (need_copy_pc) {
need_copy_pc = 0;
ref_difftest_getregs(&ref_r);
nemu_this_pc = reg_scala[DIFFTEST_THIS_PC];
ref_r[DIFFTEST_THIS_PC] = reg_scala[DIFFTEST_THIS_PC];
ref_difftest_setregs(ref_r);
}
if (isMMIO) {
// 对于MMIO,直接不对比,NEMU中的MMIO关系我们暂时无法干涉,所以直接跳过去,并且将执行完毕MMIO指令的CPU状态,拷贝到NEMU里面
// printf("diff pc: %x isRVC %x\n", this_pc, isRVC);
// MMIO accessing should not be a branch or jump, just +2/+4 to get the next pc
int pc_skip = 0;
// 我们为什么很肯定地能认为NEMU的下一个PC是PC+4呢?
// 在单提交的情况下,MMIO相关的不可能是跳转指令,所以PC+4一定是下一个PC
// 所以直接将当前DUT的状态复制给NEMU,并且将NEMU的PC+4
// 双提交的情况下,一定会是PC+8吗?这里需要多加考虑。
pc_skip += isRVC ? 2 : 4;
pc_skip += isMultiCommit ? (isRVC2 ? 2 : 4) : 0;
reg_scala[DIFFTEST_THIS_PC] += pc_skip;
nemu_this_pc += pc_skip;
// to skip the checking of an instruction, just copy the reg state to reference design
ref_difftest_setregs(reg_scala); // 把状态拷贝到里面去
// 设置相关的队列
pc_retire_pointer = (pc_retire_pointer+1) % DEBUG_RETIRE_TRACE_SIZE;
pc_retire_queue[pc_retire_pointer] = this_pc;
inst_retire_queue[pc_retire_pointer] = this_inst;
multi_commit_queue[pc_retire_pointer] = isMultiCommit;
skip_queue[pc_retire_pointer] = isMMIO;
// 标记下一次需要覆盖PC(仅针对MMIO+跳转的双提交情形)
need_copy_pc = 1;
return 0; // NEMU并不执行,直接return
}


if (intrNO) { // 如果产生了中断或者异常,则NEMU也需要获知相关的信息
ref_difftest_raise_intr(intrNO);
} else {
ref_difftest_exec(1);
}

if (isMultiCommit) {
ref_difftest_exec(1);
// 如果是多提交,需要再运行一步
}

ref_difftest_getregs(&ref_r);

rtlreg_t next_pc = ref_r[32];
pc_retire_pointer = (pc_retire_pointer+1) % DEBUG_RETIRE_TRACE_SIZE;
pc_retire_queue[pc_retire_pointer] = this_pc;
inst_retire_queue[pc_retire_pointer] = this_inst;
multi_commit_queue[pc_retire_pointer] = isMultiCommit;
skip_queue[pc_retire_pointer] = isMMIO;

int isCSR = ((this_inst & 0x7f) == 0x73);
int isCSRMip = ((this_inst >> 20) == 0x344) && isCSR;
if (isCSRMip) {
// We can not handle NEMU.mip.mtip since it is driven by CLINT,
// which is not accessed in NEMU due to MMIO.
// Just sync the state of NEMU from NutCore.
reg_scala[DIFFTEST_THIS_PC] = next_pc;
nemu_this_pc = next_pc;
ref_difftest_setregs(reg_scala);
return 0;
}

// replace with "this pc" for checking
// 同步NEMU的PC
ref_r[DIFFTEST_THIS_PC] = nemu_this_pc;
// 保存下一个PC
nemu_this_pc = next_pc;

ref_r[0] = 0;


// 检查上面获得的结果,如果有误,打印出最近DEBUG_RETIRE_TRACE_SIZE条
if (memcmp(reg_scala, ref_r, sizeof(ref_r)) != 0) {
printf("\n==============Retire Trace==============\n");
int j;
for(j = 0; j < DEBUG_RETIRE_TRACE_SIZE; j++){
printf("retire trace [%x]: pc %010lx inst %08x %s %s %s\n", j,
pc_retire_queue[j],
inst_retire_queue[j],
(multi_commit_queue[j])?"MC":" ",
(skip_queue[j])?"SKIP":" ",
(j==pc_retire_pointer)?"<--":""
);
}
printf("\n============== Reg Diff ==============\n");
ref_isa_reg_display();
printf("priviledgeMode = %d\n", priviledgeMode);
puts("");
int i;
for (i = 0; i < DIFFTEST_NR_REG; i ++) {
if (reg_scala[i] != ref_r[i]) {
printf("%s different at pc = 0x%010lx, right= 0x%016lx, wrong = 0x%016lx\n",
reg_name[i], this_pc, ref_r[i], reg_scala[i]);
}
}
return 1;
}
return 0;
}

需要注意的有以下几个逻辑:

  • NEMU中的PC寄存器,指向的是下一个将要执行的指令的PC
  • 进入difftest_step()函数时,NEMU的状态比DUT的状态滞后一条指令,但是NEMU的PC和DUT是相同的
  • 在NEMU单步执行一个周期之后,NEMU的通用寄存器和控制寄存器应当和DUT相同,但是NEMU的PC较DUT超前了一条指令。

  • 最重要的是需要理解PC跳过的逻辑,具体内容在代码中有详尽的注释。
  • 对比一般流程如下:
    • CPU提交一条指令
    • 调用difftest_step()
    • 保存NEMU的PC(nemu_this_pc)
    • NEMU单步执行
    • 对比
  • 如果是有需要跳过的指令
    • CPU提交一条指令
    • 调用difftest_step()
    • 拷贝CPU的状态给NEMU
    • 更新NEMU的PC
    • 返回
need_copy_pc到底有啥用?

笔者在阅读源代码时,一直不理解这个need_copy_pc标志有什么用。

need_copy_pc标志仅在MMIO这个分支内会被设置。

某天突然想到,之前实现的乱序处理器中,MMIO和跳转指令可能会同时提交,如果跳转指令发生了跳转,那么接下来,不可能会在PC+8的地方继续取指。那么在这种情况下,在MMIO这个分支中为NEMU设置的PC就不对了!

因为CPU在提交完MMIO和跳转指令这两条指令之后,接下来的PC不再是PC+8,而是会跳到目标地址,此时,need_copy_pc就派上用场了。根据作者的注释,在这种情况下,认为CPU计算的目标地址是正确的。

笔者认为,此处的对比似乎不是那么严谨,如果需要跳过指令,并且双提交的情况,一个改进的措施是去除need_copy_pc这个标志,转而在CPU暴露的接口中指明跳转指令所在的PC,进而让NEMU也去执行对应的跳转指令,这样能够将测试覆盖的更加全面。

并且如果DUT为顺序标量处理器,是否可以使用宏来关闭这个need_copy_pc机制?因为在顺序标量的核中,这个选项确实是多余的,但是每次碰到MMIO,都会去执行这个分支,会不会造成效率的下降?

猜测与验证

猜测:MMIO和跳转双提交的时候,会进入到这个条件之中,因为NEMU的PC需要校正。

为了验证自己的想法,笔者在关键部分添加了断言:

  • 通过在此处添加断言,发现need_copy_pc这个条件似乎是多余的,那么事实是这样吗?

  • 虽然每一次进入这个分支的时候,都复制了PC给NEMU,但是这个复制似乎是“多余”的,因为每次assert中的条件都成立。

  • 在后面添加断言语句,断言是否有双提交,发现确实没有双提交


进一步地,修改Nutshell的配置,配置为顺序双发射和乱序多发射。

  • 在顺序双发射的核中,也不存在MMIO与跳转指令同时提交的情况,唯有在乱序核中会有这种情况,所以当将参数调到如下情形时,断言才有不成立的可能。

image-20200901010449629


结论:need_copy_pc仅在双提交,并且双提交为跳转指令+MMIO时才起作用。

device.cpp, ram.cpp, sdcard.cpp, uart.cpp, vga.cpp等 - DPI-C接口

Nutshell的仿真事实上也接近于全系统仿真,也就是说不仅仅仿真CPU核心,还需要同步仿真SoC上面的外设,例如内存、I/O设备。因此,Nutshell的源代码中提供了相应的C++编写的仿真模型。

一个很好的例子是串口模型。使用Verilog编写串口仿真模型,往往会比较困难,并且在命令行下难以与用户进行交互。而Verilog提供了DPI-C接口,可以在Verilog中调用编写好的C语言函数。

src/main/scala/device中,是Nutshell提供的AXI外设模型,我们以串口模型为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class UARTGetc extends BlackBox with HasBlackBoxInline {
val io = IO(new Bundle {
val clk = Input(Clock())
val getc = Input(Bool())
val ch = Output(UInt(8.W))
})

setInline("UARTGetc.v",
s"""
|import "DPI-C" function void uart_getc(output byte ch);
|
|module UARTGetc (
| input clk,
| input getc,
| output reg [7:0] ch
|);
|
| [email protected](posedge clk) begin
| if (getc) uart_getc(ch);
| end
|
|endmodule
""".stripMargin)
}

可以看到的是,在这个UARTGetc模块中,关联了外部的uart_getc函数,这个函数在控制台中能够接收用户输入,将输入存放在缓冲区中,当调用uart_getc函数的时候,能够返回缓冲区首部的字符,这里在Verilog中调用了C语言函数,能够直接将返回的字符传送给上层的硬件模块进行仿真。

1
2
3
4
5
6
7
8
9
10
11
12
class AXI4UART extends AXI4SlaveModule(new AXI4Lite) {
val rxfifo = RegInit(0.U(32.W))
val txfifo = Reg(UInt(32.W))
val stat = RegInit(1.U(32.W))
val ctrl = RegInit(0.U(32.W))

val getcHelper = Module(new UARTGetc)
getcHelper.io.clk := clock
getcHelper.io.getc := (raddr(3,0) === 0.U && ren)

def putc(c: UInt): UInt = { printf("%c", c(7,0)); c }
def getc = getcHelper.io.ch

那么串口输出就比较简单了,直接调用scala的输出函数就可以实现。

有关于DPI-C以及相关仿真模型的细节,由于时间所限,此处不一一描述。

和我们平时熟悉的xsim仿真器不同,Verilator是一款高性能的仿真器,其高性能的秘诀就在于将Verilog代码编译成C++模型进行执行,这样可以达到多线程仿真的效果。

如果想要理解使用模拟器(例如NEMU)进行差分测试的原理,就必须理解Verilator仿真的机制和流程。

本文总结了理解差分测试程序需要的Verilator预备知识。

Verilator仿真全流程

  1. 类似于GCC的,我们调用verilator可执行文件,后接相关的Verilog源文件,verilator将Verilog源文件编译为C++模型或者System C模型。在手册中,他们把这个叫做verilating,输出的模型叫做verilated的模型。
  2. Verilate完成之后,并不是万事大吉,用户需要编写一个C++的wrapper模块,其中包含main()函数,实例化了编译好的模型。
  3. 使用C++编译器,将Verilator编译好的模型、用户编写的顶层wrapper和Verilator提供的库函数编译成可执行的仿真文件。
  4. 执行可执行文件,完成仿真。

一个简单的例子

our.v:

1
2
3
module our;
initial begin $display("Hello World"); $finish; end
endmodule

sim_main.cpp:

1
2
3
4
5
6
7
8
9
#include "Vour.h"    // Verilog模块会被编译成Vxxx
#include "verilated.h"
int main(int argc, char **argv, char **env){
Verilated::commandArgs(argc, argv); // Verilator仿真运行时参数(和编译的参数不一样,详见Verilator手册第6章
Vour *top = new Vour;
while (!Verilated::gotFinish()) { top->eval(); }
delete top;
exit(0);
}

使用相关的命令进行编译:

1
verilator -Wall --cc our.v --exe --build sim_main.cpp

仿真过程

当我们使用C++模型进行仿真的时候,用户编写的顶层函数必须调用eval()或者eval_step()eval_end_step()

当我们在C++层面,仅仅实例化一个模型的时候,只需要调用designPtr->eval(),事实上,eval()eval_step()+eval_end_step()等效。

当每次eval()被调用的时候,就会执行一次always @(posedge clk)语句,计算相应的结果,然后计算组合逻辑。

和C++的交互

对于Verilator而言,会对顶层测试模块创建一个Vxxx.hVxxx.c文件,还有其他的模块文件,此处我们并不关注。

在编译的过程中,还会有Vxxx.mk文件,目标文件是Vxxx_ALL.a文件,会和用户编写的C++主函数中的循环链接,并创建出仿真的可执行文件。

我们来看以下的代码:

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
28
29
30
31
32
33
34
35
#include <verilated.h> // Defines common routines
#include <iostream> // Need std::cout
#include "Vtop.h" // From Verilating "top.v"

Vtop *top; // 模块的实例
vluint64_t main_time = 0; // 当前的仿真时间
// This is a 64-bit integer to reduce wrap over issues and
// allow modulus. This is in units of the timeprecision
// used in Verilog (or from --timescale-override)
double sc_time_stamp() { // Called by $time in Verilog
return main_time; // 需要被Verilog中的$time调用
}

int main(int argc, char **argv) {
Verilated::commandArgs(argc, argv); // Remember args
top = new Vtop; // 创建一个新的实例
top->reset_l = 0; // 把reset拉低
while (!Verilated::gotFinish()) {
if (main_time > 10) {
top->reset_l = 1; // Deassert reset // 拉高Reset,结束复位
}
if ((main_time % 10) == 1) {
top->clk = 1; // 时钟翻转
}
if ((main_time % 10) == 6) {
top->clk = 0; // 时钟翻转
}
top->eval(); // 计算输出
cout << top->out << endl; // Read a output
main_time++; // 时间戳自增
}
top->final(); // 仿真结束的时候,需要运行final()来执行所有的final块
// // (Though this example doesn't get here)
delete top;
}

由上面代码可见,仿真时如果需要访问模块的顶层信号,可以使用top->signal的形式进行访问。

DPI(直接编程接口)

一个简单的例子

如果我们想在Verilog中调用C语言编写的函数,可以在our.v的头部声明:

1
2
3
4
import "DPI-C" function int add (input int a, input int b);
initial begin
$display("%x + %x = %x", 1, 2, add(1,2));
endtask

在Verilator编译之后,将会创建一个Vour__Dpi.h文件,里面有以下的extern声明:

1
extern int add (int a, int b);

此后,在其他文件中实现这个add函数,记住,需要包含两个头文件:

1
2
3
#include "svdpi.h"
#include "Vour__Dpi.h"
int add(int a, int b) { return a+b; }

DPI系统任务/函数

Verilator还支持将外部的C函数作为系统函数,需要使用$作为前缀,但注意,$前缀需要被转义:

1
2
export "DPI-C" function integer \$myRand;
initial $display("myRand=%d", $myRand());

同样地,我们也可以将Verilog中的task导出为C++函数:

1
2
3
4
5
export "DPI-C" task publicSetBool;
task publicSetBool;
input bit in_bool;
var_bool = in_bool;
endtask

在”Verilate”之后,Verilator就会创建一个Vour__Dpi.h文件,其中有相应函数的原型:

1
extern void publicSetBool(svBit in_bool);

此时,我们可以在sc_main.cpp文件中条用相关的函数了。

1
2
#include "Vour__Dpi.h"
publicSetBool(value);

或者以如下的形式显式调用:

1
2
3
#include "Vour__Dpi.h"
Vour::publicSetBool(value);
// or top->publicSetBool(value);

关于更多DPI的使用方法,可以参考Verilator的官方手册第15章:https://www.veripool.org/ftp/verilator_doc.pdf

Scala是强类型语言。类似于Python这种动态类型语言,在解释执行的时候出现的错误,可能会在Scala的编译阶段出现,因此,在编译阶段报错,能够尽可能地减少运行时错误。

在这一节中,我们将讨论“类型是Scala中的一等公民”这个话题。

静态类型

Scala中的所有对象都有类型,常常是该对象所属的class,可以通过getClass方法取得。

1
2
3
println(10.getClass)
println(10.0.getClass)
println("ten".getClass)

强烈推荐在声明函数的时候定义输入和输出的类型,以避免奇怪的错误。特别是,对于重载过的运算符而言。

1
2
3
4
def double(s: String): String = s + s
// double("hi") // 正确用法
// double(10) // 编译报错
// double("hi") / 10 // 编译报错

返回值类型为Unit的函数不返回任何东西。

Scala类型 VS. Chisel类型

以下的代码是合法的:

1
2
val a = Wire(UInt(4.W))
a := 0.U

因为0.UUInt ,是Chisel类型。

而以下代码:

1
2
val a = Wire(UInt(4.W))
a := 0

是非法的,因为0是 Int ,是Scala类型。

对于 Bool而言也是如此,在Chisel中是Boolean类型。

1
2
3
4
5
6
7
8
val bool = Wire(Bool())
val boolean: Boolean = false
// legal
when (bool) { ... }
if (boolean) { ... }
// illegal
if (bool) { ... }
when (boolean) { ... }

在编译的时候,Scala会做静态类型检查,所以能够找到类型不匹配的问题。

Scala类型转换

asInstanceOf

x.asInstanceOf[T] 将对象 x 转换成T类型。当无法转换时,将抛出异常。

1
2
3
4
5
6
7
8
9
val x: UInt = 3.U
try {
println(x.asInstanceOf[Int])
} catch {
case e: java.lang.ClassCastException => println("As expected, we can't cast UInt to Int")
}

// But we can cast UInt to Data since UInt inherits from Data.
println(x.asInstanceOf[Data])

在上面的运行结果中,UInt不能转换成Int。

Chisel中的类型转换

Type Casting in Chisel

下面代码的问题在于,强行地将一个UInt 赋值给了SInt,这在Chisel中是不允许的。

Chisel拥有一系列的类型转换函数,最常用的就是 asTypeOf(),其参数应该是某个Chisel信号。

1
2
3
4
5
6
7
class TypeConvertDemo extends Module {
val io = IO(new Bundle {
val in = Input(UInt(4.W))
val out = Output(SInt(4.W))
})
io.out := io.in//应当加上.asTypeOf(io.out)
}

某些时候,我们还需要使用asUInt()asSInt()

类型匹配

匹配操作

在之前,我们讨论过类型匹配的问题。类型匹配包含于Scala的模式匹配机制之内,对于编写那些“通用类型”的代码,非常有用。

下面的例子中介绍了一种能够进行UIntSInt的类型匹配机制。

1
2
3
4
5
6
7
8
9
10
class ConstantSum(in1: Data, in2: Data) extends Module {
val io = IO(new Bundle {
val out = Output(in1.cloneType)
})
(in1, in2) match {
case (x: UInt, y: UInt) => io.out := x + y
case (x: SInt, y: SInt) => io.out := x + y
case _ => throw new Exception("I give up!")
}
}

Data是Chisel中所有类型的超类,能够接收所有Chisel类型的参数。

注意:模式匹配仅仅能够使用在电路生成阶段,这是Scala的语法,而并不能对应到实际的电路中。

例如,下面的代码就有问题:

1
2
3
4
5
6
7
8
9
10
11
class InputIsZero extends Module {
val io = IO(new Bundle {
val in = Input(UInt(16.W))
val out = Output(Bool())
})
io.out := (io.in match {
// note that case 0.U is an error
case (0.U) => true.B
case _ => false.B
})
}

Unapply方法

为什么我们可以直接做这样的类型匹配?

1
2
3
4
5
6
case class Something(a: String, b: Int)
val a = Something("A", 3)
a match {
case Something("A", value) => value
case Something(str, 3) => 0
}

对于每个Case Class,Scala自动为其生成了unapply方法。这是一种语法糖,能够让match语句在匹配类型的同时,将对象中的值提取出来进行匹配

让我们来看下面一个例子,例如,我们确定一个参数,如果pipelineMe为真,则取delay3*totalWidth;如果为假,则取为2*someOtherWidth

1
2
3
4
5
6
7
8
9
10
11
12
13
14
case class SomeGeneratorParameters(
someWidth: Int,
someOtherWidth: Int = 10,
pipelineMe: Boolean = false
) {
require(someWidth >= 0)
require(someOtherWidth >= 0)
val totalWidth = someWidth + someOtherWidth
}

def delay(p: SomeGeneratorParameters): Int = p match {
case sg @ SomeGeneratorParameters(_, _, true) => sg.totalWidth * 3
case SomeGeneratorParameters(_, sw, false) => sw * 2
}

我们看到,这个case引用了实例中的字段。

下面的这种语法,能够让我们同时引用对象用于匹配的内部的值,同时引用其父对象sg

1
case sg@SomeGeneratorParameters(_, sw, true) => sw

如果我们想对某个非case class采用unapply方法,可以手动在其伴生对象中写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Boat(val name: String, val length: Int)
object Boat {
def unapply(b: Boat): Option[(String, Int)] = Some((b.name, b.length))
def apply(name: String, length: Int): Boat = new Boat(name, length)
}

def getSmallBoats(seq: Seq[Boat]): Seq[Boat] = seq.filter { b =>
b match {
case Boat(_, length) if length < 60 => true
case Boat(_, _) => false
}
}

val boats = Seq(Boat("Santa Maria", 62), Boat("Pinta", 56), Boat("Nina", 50))
println(getSmallBoats(boats).map(_.name).mkString(" and ") + " are small boats!")

Chisel的类型层级

chisel3.Data 是所有Chisel硬件类型的基类。 UInt, SInt, Vec, Bundle等都是Data的子类。 Data 可以被用于IO,并且支持被:=赋值,例如wires, regs等。

在Chisel中,寄存器是多态类型的代表。 对于RegEnable寄存器的实现,如果想要其支持通用类型,最关键的就是那句[T <: Data],使得RegEnable对于Chisel所有的硬件类型都能够工作。

1
2
3
4
5
def apply[T <: Data](next: T, enable: Bool): T = {
val r = Reg(chiselTypeOf(next))
when (enable) { r := next }
r
}

某些操作符仅仅对于 Bits有定义,例如+。这就是你可以累加UIntSInt,但不能累加BundleVec的原因。

例子:通用类型的移位寄存器

在Scala中,不仅仅是对象和函数可以当作参数,类型也可以当作参数。

我们通常需要提供类型相关的限制,在这个情况下,我们需要将信号放在一个Bundle里面,:=连接他们,并以其创建一个寄存器。

赋值语句不能随便进行,例如,wire := 3是不合法的,因为3是Scala的整数类型,并不是Chisel的UInt类型。如果我们创建了一个类型约束,并且声明T是Data的子类,那么我们就可以自由地使用:=了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ShiftRegisterIO[T <: Data](gen: T, n: Int) extends Bundle {
require (n >= 0, "Shift register must have non-negative shift")

val in = Input(gen.cloneType)
val out = Output(Vec(n + 1, gen.cloneType)) // + 1 because in is included in out
override def cloneType: this.type = (new ShiftRegisterIO(gen, n)).asInstanceOf[this.type]
}

class ShiftRegister[T <: Data](gen: T, n: Int) extends Module {
val io = IO(new ShiftRegisterIO(gen, n))

io.out.foldLeft(io.in) { case (in, out) =>
out := in
RegNext(in)
}
}

注解:上面的foldLeft方法有点绕,需要一定时间理解。

(TBC)

Chisel基于Scala,而Scala支持面向对象的编程范式,也就是说,代码可以被分割成不同的对象。

Scala中的面向对象

在这一节中,我们将展示Scala是如何实现面向对象的编程范式的。在面向对象的编程中,Scala还拥有以下的特性:

  • 抽象类(Abstract class)
  • 特质(Traits)
  • 对象(Objects)
  • 伴生对象(Companion Objects)
  • 案例类(Case Classes)

抽象类

对于抽象类,熟悉面向对象的人应该不陌生了。在抽象类中,我们定义一些继承他的子类必须实现的字段。

任何对象只能从最多一个抽象类中继承。

以下是抽象类的例子:

1
2
3
4
5
6
7
8
9
10
abstract class MyAbstractClass {
def myFunction(i: Int): Int
val myValue: String
}
class ConcreteClass extends MyAbstractClass {
def myFunction(i: Int): Int = i + 1
val myValue = "Hello World!"
}
// val abstractClass = new MyAbstractClass() // 这段代码不能编译,因为抽象类的部分字段没有实现
val concreteClass = new ConcreteClass() // 这段代码是合法代码

特质

特质和抽象类非常相似,在于他们都可以定义“未实现”的字段。特质和抽象类有两点不同:

  • 一个类可以从多个特质继承而来
  • 一个特质不能拥有构造函数的参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
trait HasFunction {
def myFunction(i: Int): Int
}
trait HasValue {
val myValue: String
val myOtherValue = 100
}
class MyClass extends HasFunction with HasValue {
override def myFunction(i: Int): Int = i + 1
val myValue = "Hello World!"
}
// Uncomment below to test!
// val myTraitFunction = new HasFunction() // Illegal! Cannot instantiate a trait
// val myTraitValue = new HasValue() // Illegal! Cannot instantiate a trait
val myClass = new MyClass() // Legal!

通常情况下,请多多使用特质,而非抽象类,除非我们能够确保仅仅从抽象类做单继承。

对象

对于单例的类(仅有一个对象的类),可以创建一个单独的Object。

Object不能被实例化,我们可以直接引用这个对象。

1
2
3
4
5
6
object MyObject {
def hi: String = "Hello World!"
def apply(msg: String) = msg
}
println(MyObject.hi)
println(MyObject("This message is important!")) // equivalent to MyObject.apply(msg)

伴生对象

当一个类和一个对象的名字相同,且位于同一个文件中时,这个对象可以称为是伴生对象

当在名字前面看到new关键字的时候,它将创建一个类的实例;当没有使用new的时候,它将指向伴生对象。

1
2
3
4
5
6
7
8
object Lion {
def roar(): Unit = println("I'M AN OBJECT!")
}
class Lion {
def roar(): Unit = println("I'M A CLASS!")
}
new Lion().roar()
Lion.roar()

我们为什么要使用伴生对象呢?原因有几个:

  • 维护那些和类有关的常量
  • 在运行类的构造函数之前/之后,运行一些其他的代码
  • 创建一个类的很多个构造函数

看下面代码的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
object Animal {
val defaultName = "Bigfoot"
private var numberOfAnimals = 0
def apply(name: String): Animal = {
numberOfAnimals += 1
new Animal(name, numberOfAnimals)
}
def apply(): Animal = apply(defaultName)
}
class Animal(name: String, order: Int) {
def info: String = s"Hi my name is $name, and I'm $order in line!"
}

val bunny = Animal.apply("Hopper") // Calls the Animal factory method
println(bunny.info)
val cat = Animal("Whiskers") // Calls the Animal factory method
println(cat.info)
val yeti = Animal() // Calls the Animal factory method
println(yeti.info)
  • Animal伴生对象定义了一个和Animal类有关的常量:

    1
    val defaultName = "Bigfoot"
  • 并且给出了一个可变的字段,来记录创建的实例数量:

    1
    private var numberOfAnimals = 0
  • 然后定义了两个apply方法,我们称之为工厂方法,能够返回Animal类的实例

    • 第一个方法,传入动物名称,并将总计数器+1,返回新的实例
    • 第二个方法,以默认名称创建实例
  • 工厂方法可以以以下的形式调用:

    1
    val bunny = Animal.apply("Hopper")

    也可以省略apply

    1
    val bunny = Animal("Hopper")
  • 工厂方法常常通过伴生对象提供,在我们创建实例时,调用工厂方法的apply,可以省略关键字new


    在Chisel中,经常使用工厂方法,例如:

    1
    val myModule = Module(new MyModule)

案例类

案例类是Scala类的一个特别的类型,在Scala编程中十分常见。具有以下特性:

  • 对于类的任何参数,外部都可以访问
  • 可以不使用new来实例化新的对象
  • 自动创建一个unapply方法,支持对类所有参数的访问
  • 不能被继承
1
2
3
4
5
6
7
8
9
10
11
class Nail(length: Int) // Regular class
val nail = new Nail(10) // Requires the `new` keyword
// println(nail.length) // Illegal! Class constructor parameters are not by default externally visible

class Screw(val threadSpace: Int) // By using the `val` keyword, threadSpace is now externally visible
val screw = new Screw(2) // Requires the `new` keyword
println(screw.threadSpace)

case class Staple(isClosed: Boolean) // Case class constructor parameters are, by default, externally visible
val staple = Staple(false) // No `new` keyword required
println(staple.isClosed)

Nail是一个常规的类,它的参数并不能在外部可见,因为在定义时,并没有使用val关键字;同时,实例化Nail需要new关键字。

Screw 的声明类似于Nail, 但是在参数列表中使用了 val 关键字。这使得其字段 threadSpace对外部可见的。

使用案例类声明的 Staple 其参数对外部均可见。并且,在创建对象的时候,不需要使用new关键字,因为Scala自动为每一个case class创建了伴生类。

在Chisel中使用继承

我们之前使用过Bundle和Module。事实上,每一个我们创建的Chisel模块,都继承自基类Module,每一个IO都继承自基类Bundle。Chisel的类型UInt或者Bundle都以Data为超类。

模块的继承

当我们在Chisel中创建一个硬件模块的时候,我们都需要从Module类继承过来。继承不一定是复用的最佳手段,但仍然是一个非常有利的工具。

例子:格雷码编码器和解码器(待续)

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class NoGlitchCounterIO(bitwidth: Int) extends Bundle {
val en = Input(Bool())
val out = Output(UInt(bitwidth.W))
}

abstract class NoGlitchCounter(val maxCount: Int) extends Module {
val bitwidth: Int
val io = IO(new NoGlitchCounterIO(bitwidth))
}

abstract class AsyncFIFO(depth: Int) extends Module {
val io = IO(new Bundle{
// write inputs
val write_clock = Input(Clock())
val write_enable = Input(Bool())
val write_data = Input(UInt(32.W))

// read inputs/outputs
val read_clock = Input(Clock())
val read_enable = Input(Bool())
val read_data = Output(UInt(32.W))

// FIFO status
val full = Output(Bool())
val empty = Output(Bool())
})

def makeCounter(maxCount: Int): NoGlitchCounter

// add extra bit to counter to check for fully/empty status
assert(isPow2(depth), "AsyncFIFO needs a power-of-two depth!")
val write_counter = withClock(io.write_clock) {
val count = makeCounter(depth * 2)
count.io.en := io.write_enable && !io.full
count.io.out
}
val read_counter = withClock(io.read_clock) {
val count = makeCounter(depth * 2)
count.io.en := io.read_enable && !io.empty
count.io.out
}

// synchronize
val sync = withClock(io.read_clock) { ShiftRegister(write_counter, 2) }

// status logic goes here
}

Scala中的函数式编程

函数,以多个值为输入,以单个值为输出。输入值通常叫做参数。如果函数没有输出,则返回Unit类型。

以下是函数定义的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 没有参数的函数,可以省略去括号和返回值类型(返回值类型可以是Unit)
def hello1(): Unit = print("Hello!")
def hello2 = print("Hello again!")

// Math operation: one input and one output.
def times2(x: Int): Int = 2 * x

// 参数可以有默认值,并且建议写明返回值类型
def timesN(x: Int, n: Int = 2) = n * x

// Call the functions listed above.
hello1() // 没有参数,可以使用括号,也可以省略
hello2 // 调用的时候,如果原来定义时没有括号,则也不可以使用括号
times2(4)
timesN(4) // no need to specify n to use the default value
timesN(4, 3) // argument order is the same as the order where the function was defined
timesN(n=7, x=2) // arguments may be reordered and assigned to explicitly

函数是一等公民

函数可以被赋值给某个val,并且传递给类、对象,或者作为某个参数传递给其他函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 常规的函数定义
def plus1funct(x: Int): Int = x + 1
def times2funct(x: Int): Int = x * 2

// 函数字面量赋值给某个Val
val plus1val: Int => Int = x => x + 1
val times2val = (x: Int) => x * 2

// Calling both looks the same.
plus1funct(4)
plus1val(4)
plus1funct(x=4)
//plus1val(x=4) // 不可以这样调用

为什么需要有val定义的函数这种操作呢?使用val定义函数,那么函数就和往常的变量一样,可以在各个函数之间作为参数传递。我们甚至可以自己定义高阶函数。

定义高阶函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 首先定义自己的函数
val plus1 = (x: Int) => x + 1
val times2 = (x: Int) => x * 2

// 传递给Map,在List上面调用
val myList = List(1, 2, 5, 9)
val myListPlus = myList.map(plus1)
val myListTimes = myList.map(times2)

// 定义一个可以进行递归计算的函数
def opN(x: Int, n: Int, op: Int => Int): Int = {
if (n <= 0) { x }
else { opN(op(x), n-1, op) }
}

opN(7, 3, plus1)
opN(7, 3, times2)

我们注意到,定义的opN函数,接收一个函数参数op

函数VS对象

有时候,我们会看到,使用一些不带参数的函数,会造成一定的误会。

val在定义的时候就已经求值,而def需要在调用的时候才会被求值。

1
2
3
4
5
6
7
8
9
10
11
12
13
import scala.util.Random

// x和y都是Random函数,但是x在定义的时候,其已经被求值了,而y是一个函数,每次对他进行引用的时候,都会重新求值
val x = Random.nextInt
def y = Random.nextInt

// x已经被求值了,所以不会再发生改变了
println(s"x = $x")
println(s"x = $x")

// y的输出会和上次的不一样,因为调用的时候被重新进行了求值
println(s"y = $y")
println(s"y = $y")

匿名函数

我们仅仅使用一次这个函数,所以这个函数可以不用赋值给val,当作一个字面量(如C中的常量)来进行使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
val myList = List(5, 6, 7, 8)

// 将列表中的每一个值都加1
myList.map( (x:Int) => x + 1 )
myList.map(_ + 1)

// 匿名函数中可以使用case,可以进行模式匹配
val myAnyList = List(1, 2, "3", 4L, myList)
myAnyList.map {
case (_:Int|_:Long) => "Number"
case _:String => "String"
case _ => "error"
}

Chisel中的函数式编程

实例:FIR

我们可以看一个在Chisel中使用函数式编程的例子,还是以刚才的FIR为例。

之前的所有b_i全部都是以固定参数的形式传入,这次,我们传入一个能够计算参数的函数。这个计算函数以窗口长度和位宽为参数,产生一个b_i的参数列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// get some math functions
import scala.math.{abs, round, cos, Pi, pow}

// simple triangular window
// 这个语法是先声明函数的类型,然后用'=’来用一个函数初始化val
val TriangularWindow: (Int, Int) => Seq[Int] = (length, bitwidth) => {
val raw_coeffs = (0 until length).map( (x:Int) => 1-abs((x.toDouble-(length-1)/2.0)/((length-1)/2.0)) )
val scaled_coeffs = raw_coeffs.map( (x: Double) => round(x * pow(2, bitwidth)).toInt)
scaled_coeffs
}

// Hamming window
val HammingWindow: (Int, Int) => Seq[Int] = (length, bitwidth) => {
val raw_coeffs = (0 until length).map( (x: Int) => 0.54 - 0.46*cos(2*Pi*x/(length-1)))
val scaled_coeffs = raw_coeffs.map( (x: Double) => round(x * pow(2, bitwidth)).toInt)
scaled_coeffs
}

然后就可以使用它来进行生成了:

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
// our FIR has parameterized window length, IO bitwidth, and windowing function
class MyFir(length: Int, bitwidth: Int, window: (Int, Int) => Seq[Int]) extends Module {
val io = IO(new Bundle {
val in = Input(UInt(bitwidth.W))
val out = Output(UInt((bitwidth*2+length-1).W)) // expect bit growth, conservative but lazy
})

// 将所有的参数转换成UInt硬件节点,宽度自动推断
val coeffs = window(length, bitwidth).map(_.U)

// 注意:我们不使用Vec,因为不需要按照索引访问,我们只需要在编译阶段把这些寄存器连接到正确的位置
val delays = Seq.fill(length)(Wire(UInt(bitwidth.W))).scan(io.in)( (prev: UInt, next: UInt) => {
next := RegNext(prev)
next
})

// multiply, putting result in "mults"
val mults = delays.zip(coeffs).map{ case(delay: UInt, coeff: UInt) => delay * coeff }

// add up multiplier outputs with bit growth
val result = mults.reduce(_+&_)

// connect output
io.out := result
}

这个实现和之前的简洁实现差不多,只是将连续的map,reduce操作拆分开了。

实例:感知机

image-20200825221618486

1
2
3
4
5
6
7
8
class Neuron(inputs: Int, act: FixedPoint => FixedPoint) extends Module {
val io = IO(new Bundle {
val in = Input(Vec(inputs, FixedPoint(16.W, 8.BP)))
val weights = Input(Vec(inputs, FixedPoint(16.W, 8.BP)))
val out = Output(FixedPoint(16.W, 8.BP))
})
io.out := ( io.in zip io.weights ).map( case(a,b) => a*b ).reduce(_+_)
}

我们在前面的代码中,经常使用到for循环,显然过于冗长,并且和函数式编程的宗旨相悖。

在本节中,我们以之前实现的FIR滤波器为例,通过Scala的特性进行重构。

回顾:FIR滤波器

image-20200825135235362

$$ y[n] = b_0x[n]+b_1x[n-1]+b_2x[n-2]+\cdots$$

之前的实现

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
28
class MyManyDynamicElementVecFir(length: Int) extends Module {
val io = IO(new Bundle {
val in = Input(UInt(8.W))
val out = Output(UInt(8.W))
val consts = Input(Vec(length, UInt(8.W)))
})

// Reference solution
val regs = RegInit(VecInit(Seq.fill(length - 1)(0.U(8.W))))
for(i <- 0 until length - 1) {
if(i == 0) regs(i) := io.in
else regs(i) := regs(i - 1)
}

val muls = Wire(Vec(length, UInt(8.W)))
for(i <- 0 until length) {
if(i == 0) muls(i) := io.in * io.consts(i)
else muls(i) := regs(i - 1) * io.consts(i)
}

val scan = Wire(Vec(length, UInt(8.W)))
for(i <- 0 until length) {
if(i == 0) scan(i) := muls(i)
else scan(i) := muls(i) + scan(i - 1)
}

io.out := scan(length - 1)
}

回顾我们的实现:

  • 对于每一个regs(i),和其对应的io.const相乘,并且存储到muls向量中。
  • 对于每一个muls(i)scan(0) = muls(0), scan(1) = scan(0) + muls(1) = muls(0) + muls(1)……
  • 取出scan中的最后一个元素,并且赋值给io.out

事实上,我们可以使用一个更简单的方法实现。

更简单的实现

前方高能。


1
2
3
4
5
6
7
8
class MyManyDynamicElementVecFir(length: Int) extends Module {
val io = IO(new Bundle {
val in = Input(UInt(8.W))
val out = Output(UInt(8.W))
val consts = Input(Vec(length, UInt(8.W)))
})
io.out := (taps zip io.consts).map { case (a, b) => a * b }.reduce(_ + _)
}

就这?就这??????你在逗我??????????

让我们来解析一下这个代码。

  • (taps zip io.consts) 的输入是两个List: tapsio.consts,这个函数最终返回的是一个列表,其中的元素是二元组,两个数组相同下标的元素被组合成一个二元组。最终,列表会长这样:[(taps(0), io.consts(0)), (taps(1), io.consts(1)), ..., (taps(n), io.consts(n))]。注意:在Scala中,由于对于仅有一个参数的方法,其调用可以省略.(),所以这个等效于(taps.zip(io.consts))

  • .map { case (a, b) => a * b } 在列表中的每一个函数上面都应用了一个匿名函数,并返回如此操作之后的列表。这个匿名函数的输入是一个元组(a,b),函数输出是a*b。最终,返回的列表是 [taps(0) * io.consts(0), taps(1) * io.consts(1), ..., taps(n) * io.consts(n)]

  • 最终, .reduce(_ + _) 也对列表进行了操作。这个函数拥有两个参数:

    • 当前的累加和

    • 当前处理到的元素

      最终返回的结果应当是 (((muls(0) + muls(1)) + muls(2)) + ...) + muls(n),最深层的括号是最先被计算的。这就是reduce模型。

函数作为参数

在我们上面所见的mapreduce被称为高阶函数。为什么称之为高阶函数?因为他们输入的参数是函数

函数式编程的一个好处是,我们不必聚焦于控制相关的代码,而是可以专注于编写逻辑。

声明匿名函数的不同方法

  • 对于那些每个参数都仅仅被使用过一次的函数,可以使用下划线(_)来引用每一个参数。在上面的例子中,reduce的就是拥有两个参数,可以被写作是_+_,代表第一个参数加第二个参数
  • 也可以显式地声明那些输入参数,例如,上面的函数可以写成是(a,b) => a + b。将参数列表放在括号中,后接一个=>,然后是函数体。
  • 当需要对元组进行解包的时候,需要使用case语句。case (a,b) => a * b

在Scala中的实践

Map函数

List[A].map的定义是map[B](f: (A)=>B)):List[B]。定义看起来略微有点复杂,我们先将A认为是Int(软件类型),B认为是UInt(硬件类型)。

上面的声明可以看作是:map函数接收一个输入类型为A,返回类型为B的函数,并且返回一个元素类型为B的列表。

zipWithIndex函数

List.zipWithIndex的定义是zipWithIndex: List[(A, Int)]

List("a", "b", "c", "d").zipWithIndex将返回List(("a", 0), ("b", 1), ("c", 2), ("d", 3))

Reduce函数

List[A].reduce的定义是reduce(op: (A, A) ⇒ A): A

事实上,A只需要是子类就可以了。

1
2
3
println(List(1, 2, 3, 4).reduce((a, b) => a + b))  // returns the sum of all the elements
println(List(1, 2, 3, 4).reduce(_ * _)) // returns the product of all the elements
println(List(1, 2, 3, 4).map(_ + 1).reduce(_ + _)) // you can chain reduce onto the result of a map

Fold函数

fold函数和reduce函数非常类似,有一点不同的是,fold函数对于迭代具有初始值,可以从fold函数的定义中看出: fold(z: A)(op: (A, A) ⇒ A): A

1
2
3
println(List(1, 2, 3, 4).fold(0)(_ + _))  // equivalent to the sum using reduce
println(List(1, 2, 3, 4).fold(1)(_ + _)) // like above, but accumulation starts at 1
println(List().fold(1)(_ + _)) // unlike reduce, does not fail on an empty input

高阶函数应用实例:仲裁器

我们将构建一个仲裁器,拥有n个输入和1个输出,选择编号最小的有效输出。

这个例子需要一定的时间消化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyRoutingArbiter(numChannels: Int) extends Module {
val io = IO(new Bundle {
val in = Vec(Flipped(Decoupled(UInt(8.W))), numChannels)
val out = Decoupled(UInt(8.W))
} )

io.out.valid := io.in.map(_.valid).reduce(_ || _) // 取出Valid位,并将所有的Valid或起来
val channel = PriorityMux(
io.in.map(_.valid).zipWithIndex.map { case (valid, index) => (valid, index.U) }
)
io.out.bits := io.in(channel).bits // 将编码好的进行选择
io.in.map(_.ready).zipWithIndex.foreach { case (ready, index) =>
ready := io.out.ready && channel === index.U // 被选中的就Ready
}
}

PriorityMux(List[Bool, Bits]),按照Index从低到高,选中第一个有效的,其实是一个优先编码+多路选择。


1
io.out.valid := io.in.map(_.valid).reduce(_ || _)

io.in.map(_.valid)将输入中所有的Valid取出,组成一个新的向量。

.reduce(_ || _)将向量中所有的Bit都或在一起。


1
io.in.map(_.valid).zipWithIndex.map { case (valid, index) => (valid, index.U) }

io.in.map(_.valid).zipWithIndex将每一项都和他的Index串联在一起。

.map { case (valid, index) => (valid, index.U) }index转换为硬件信号,因为Mux的输出是硬件信号,同时Vec也需要硬件信号做索引。


1
2
3
io.in.map(_.ready).zipWithIndex.foreach { case (ready, index) =>
ready := io.out.ready && channel === index.U // 被选中的就Ready
}

注意:此处虽然定义了新的函数,case(ready, index) => ,但是传入的仍然是原来的硬件节点,也就是说,传入函数的硬件节点不会被重复的创建,相当于传递的是引用。

在这个模块中,我们将使用Chisel容器作为硬件的生成器。

背景:FIR滤波器

我们首先来了解一下FIR滤波器:

image-20200825135235362

对于FIR滤波器的输出,我们这样定义:

$$ y[n] = b_0x[n]+b_1x[n-1]+b_2x[n-2]+\cdots$$

其中,

  • $y[n]$是在第$n$时刻的输出信号
  • $x[n]$是输入信号
  • $b_i$是滤波器的参数,或脉冲反馈
  • $x[n-1]$是上个周期的$x[n]$,以此类推。

简单实现

对于上图出现的滤波器,我们可以简单的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
class FIR_4(b0: Int, b1: Int, b2: Int, b3: Int) extends Module {
val io = IO(new Bundle{
val in = Input(UInt(8.W))
val out = Output(UInt(8.W))
})
val x_n1 = RegNext(io.in, 0.U)
val x_n2 = RegNext(x_n1, 0.U)
val x_n3 = RegNext(x_n2, 0.U)
io.out := io.in * b0.U(8.W) +
x_n1 * b1.U(8.W) +
x_n2 * b2.U(8.W) +
x_n3 * b3.U(8.W)
}

参数化实现与验证

如果我们想要引入更多的状态,例如,在上面的例子中,我们如果需要做到让FIR的Taps数量可以参数化,应该怎么实现呢?

  • 首先,使用软件建立一个可以用于仿真的模型
  • 其次,重新设计这个硬件,通过刚刚的仿真确定是否正常工作

软件实现的Golden Model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* A naive implementation of an FIR filter with an arbitrary number of taps.
*/
class ScalaFirFilter(taps: Seq[Int]) {
var pseudoRegisters = List.fill(taps.length)(0)

def poke(value: Int): Int = {
pseudoRegisters = value :: pseudoRegisters.take(taps.length - 1)
var accumulator = 0
for(i <- taps.indices) {
accumulator += taps(i) * pseudoRegisters(i)
}
accumulator
}
}

我们使用了一个Var来记录上图中的X寄存器阵列。

对于Poke方法,我们也对其进行了相应的重写,以模拟硬件的行为。

此处需要注意的是,value :: pseudoRegisters.take(taps.length - 1)表示将某个值追加到列表的头部。

使用容器实现

此处发生了一系列的改动:

  • 输入的常量从b0,b1,b2变成了一个整数序列
  • 宽度也可以定制了:bitWidth
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
28
class MyManyElementFir(consts: Seq[Int], bitWidth: Int) extends Module {
val io = IO(new Bundle {
val in = Input(UInt(bitWidth.W))
val out = Output(UInt(bitWidth.W))
})
// 注意到这是Mutable的,也就是可变的
val regs = mutable.ArrayBuffer[UInt]()
for(i <- 0 until consts.length) {
// 寄存器阵列,除了第一个是直接采样输入,其他的都是前几个时刻的输入
if(i == 0) regs += io.in
else regs += RegNext(regs(i - 1), 0.U)
}

// 做完乘法的结果
val muls = mutable.ArrayBuffer[UInt]()
for(i <- 0 until consts.length) {
muls += regs(i) * consts(i).U
}

//
val scan = mutable.ArrayBuffer[UInt]()
for(i <- 0 until consts.length) {
if(i == 0) scan += muls(i)
else scan += muls(i) + scan(i - 1)
}

io.out := scan.last
}

代码注解

代码中存在三个并行块,分别使用了Scala的容器 ArrayBufferArrayBuffer 可以使用 += 往后追加元素。

在第一个块中,我们创建了一个regsArrayBuffer,其中的元素是UInt,注意,这个集合仅仅包含硬件的输出(寄存器、组合逻辑的输出),而不包含实际的寄存器。创建的寄存器RegNext,是匿名的硬件节点。

这里跟Vec是不一样的,我们是否可以通过其访问某个硬件呢?

实例:RISC寄存器堆

不多说,再熟悉不过了吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class RegisterFile(readPorts: Int) extends Module {
require(readPorts >= 0)
val io = IO(new Bundle {
val wen = Input(Bool())
val waddr = Input(UInt(5.W))
val wdata = Input(UInt(32.W))
val raddr = Input(Vec(readPorts, UInt(5.W)))
val rdata = Output(Vec(readPorts, UInt(32.W)))
})

// A Register of a vector of UInts
val reg = RegInit(VecInit(Seq.fill(32)(0.U(32.W))))
when (io.wen) {
reg(io.waddr) := io.wdata
}
for (i <- 0 until readPorts) {
when (io.raddr(i) === 0.U) {
io.rdata(i) := 0.U
} .otherwise {
io.rdata(i) := reg(io.raddr(i))
}
}
}

使用生成器的方法实现

另一种实现方法,我们将在后面讨论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyManyDynamicElementVecFir(length: Int) extends Module {
val io = IO(new Bundle {
val in = Input(UInt(8.W))
val valid = Input(Bool())
val out = Output(UInt(8.W))
val consts = Input(Vec(length, UInt(8.W)))
})

// Such concision! You'll learn what all this means later.
val taps = Seq(io.in) ++ Seq.fill(io.consts.length - 1)(RegInit(0.U(8.W)))
taps.zip(taps.tail).foreach { case (a, b) => when (io.valid) { b := a } }

io.out := taps.zip(io.consts).map { case (a, b) => a * b }.reduce(_ + _)
}

Chisel提供了大量的标准接口,并且可以为我们提供可复用的硬件模块。

Decoupled: 标准Ready-Valid接口

DecoupledIO是Chisel提供的一个标准接口,它提供了一个用于数据传输的Ready-Valid界面。

  • 发送方(数据源):控制bitsvalid
  • 接收方:控制ready,当其准备好接收数据的时候,拉高ready

这个接口提供了一个双向流控机制,也可以做到接收方反压发送方的效果。

注意:readyvalid信号不应该组合地依赖于对方,否则会导致组合逻辑环路。ready仅仅应当依赖于接收方是否准备好接收数据,valid应当仅仅依赖于发送方是否已经准备好数据。当传输完成后,readyvalid信号才允许变化。

Chisel提供了一个DecoupledIO接口。

1
2
val myChiselData = UInt(8.W)
val myDecoupled = Decoupled(myChiselData)

实例:队列

Chisel提供了队列,一个标准的Ready-Valid接口模型。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
Driver(() => new Module {
// Example circuit using a Queue
val io = IO(new Bundle {
val in = Flipped(Decoupled(UInt(8.W)))
val out = Decoupled(UInt(8.W))
})
val queue = Queue(io.in, 2) // 2-element queue
io.out <> queue
}) { c => new PeekPokeTester(c) {
// Example testsequence showing the use and behavior of Queue
poke(c.io.out.ready, 0)
poke(c.io.in.valid, 1) // Enqueue an element
poke(c.io.in.bits, 42)
println(s"Starting:")
println(s"\tio.in: ready=${peek(c.io.in.ready)}")
println(s"\tio.out: valid=${peek(c.io.out.valid)}, bits=${peek(c.io.out.bits)}")
// in.Ready:1, out.Valid: 0
step(1)

poke(c.io.in.valid, 1) // Enqueue another element
poke(c.io.in.bits, 43)
// What do you think io.out.valid and io.out.bits will be?
println(s"After first enqueue:")
println(s"\tio.in: ready=${peek(c.io.in.ready)}")
println(s"\tio.out: valid=${peek(c.io.out.valid)}, bits=${peek(c.io.out.bits)}")
step(1)
// in.Ready:1, out.Valid:1

poke(c.io.in.valid, 1) // Read a element, attempt to enqueue
poke(c.io.in.bits, 44)
poke(c.io.out.ready, 1)
// What do you think io.in.ready will be, and will this enqueue succeed, and what will be read?
println(s"On first read:")
println(s"\tio.in: ready=${peek(c.io.in.ready)}")
println(s"\tio.out: valid=${peek(c.io.out.valid)}, bits=${peek(c.io.out.bits)}")
step(1)
// in.Ready:0, out.Valid:1

poke(c.io.in.valid, 0) // Read elements out
poke(c.io.out.ready, 1)
// What do you think will be read here?
println(s"On second read:")
println(s"\tio.in: ready=${peek(c.io.in.ready)}")
println(s"\tio.out: valid=${peek(c.io.out.valid)}, bits=${peek(c.io.out.bits)}")
step(1)
// in.Ready:1, out.Valid:1

// Will a third read produce anything?
println(s"On third read:")
println(s"\tio.in: ready=${peek(c.io.in.ready)}")
println(s"\tio.out: valid=${peek(c.io.out.valid)}, bits=${peek(c.io.out.bits)}")
// in.Ready:1, out.Valid:0
step(1)
} }

实例:仲裁器

根据预先设定好的优先级,仲裁器将数据从DecoupledIO源路由到DecoupledIO目的。

仲裁器分为

  • 普通仲裁器:优先允许Index较低的请求
  • 轮询仲裁器:轮询各个请求,优先级相等

发起请求的时候,是拉高Valid信号,而请求被满足的时候,接收方会拉高Ready信号。

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
28
29
30
31
32
33
34
Driver(() => new Module {
// Example circuit using a priority arbiter
val io = IO(new Bundle {
val in = Flipped(Vec(2, Decoupled(UInt(8.W))))
val out = Decoupled(UInt(8.W))
})
// Arbiter doesn't have a convenience constructor, so it's built like any Module
val arbiter = Module(new Arbiter(UInt(8.W), 2)) // 2 to 1 Priority Arbiter
arbiter.io.in <> io.in
io.out <> arbiter.io.out
}) { c => new PeekPokeTester(c) {
poke(c.io.in(0).valid, 0)
poke(c.io.in(1).valid, 0)
println(s"Start:")
println(s"\tin(0).ready=${peek(c.io.in(0).ready)}, in(1).ready=${peek(c.io.in(1).ready)}")
println(s"\tout.valid=${peek(c.io.out.valid)}, out.bits=${peek(c.io.out.bits)}")
poke(c.io.in(1).valid, 1) // Valid input 1
poke(c.io.in(1).bits, 42)
// What do you think the output will be?
println(s"valid input 1:")
println(s"\tin(0).ready=${peek(c.io.in(0).ready)}, in(1).ready=${peek(c.io.in(1).ready)}")
println(s"\tout.valid=${peek(c.io.out.valid)}, out.bits=${peek(c.io.out.bits)}")
poke(c.io.in(0).valid, 1) // Valid inputs 0 and 1
poke(c.io.in(0).bits, 43)
// What do you think the output will be? Which inputs will be ready?
println(s"valid inputs 0 and 1:")
println(s"\tin(0).ready=${peek(c.io.in(0).ready)}, in(1).ready=${peek(c.io.in(1).ready)}")
println(s"\tout.valid=${peek(c.io.out.valid)}, out.bits=${peek(c.io.out.bits)}")
poke(c.io.in(1).valid, 0) // Valid input 0
// What do you think the output will be?
println(s"valid input 0:")
println(s"\tin(0).ready=${peek(c.io.in(0).ready)}, in(1).ready=${peek(c.io.in(1).ready)}")
println(s"\tout.valid=${peek(c.io.out.valid)}, out.bits=${peek(c.io.out.bits)}")
} }

其他常用函数

位操作

PopCount

PopCount对某个向量中的1的个数进行计数。

Reverse

反转输入的向量。

计数器

计数器每个周期加1.

1
2
3
4
val counter = Counter(3)  // 3-count Counter (outputs range [0...2])
when(io.count) {
counter.inc()
}

其余的函数可以参见CheetSheet。

RV32I指令集基础

对于RV32I而言,有4种核心的指令类型(R/I/S/U)。所有的32位的指令必须在内存中四字对齐。

image-20200826195432860

  • 地址未对齐异常将被确定要跳转的跳转指令或无条件跳转指令所报告,而不是被目标指令报告,这一点和MIPS不同。不跳转的指令不会报告地址未对齐异常。
  • 对于保留指令,并不指明其行为,不会触发异常。这一点和MIPS不同。在实现中,我们可以直接Invalidate这条指令。
  • 除了CSR指令,其他的指令的立即数全部都会做符号扩展
  • 符号位总是在指令的第31位,为了简化电路的逻辑

立即数的编码

若考虑立即数编码,严格来说,指令类型应该分为以下几种:

image-20200826195653448


从另一个角度看,各类指令编码出的立即数如下所示:

image-20200828220320504

我们可以看到,符号扩展总是使用的第31位。

指令

整数运算指令

  • 整数运算指令不造成任何异常,因为结果溢出的异常通常是可以使用软件机制来进行检测的

寄存器-立即数指令

image-20200828220855474

  • ADDI将符号扩展的12位立即数加到rs1,并且会忽略溢出。

  • SLTIrs1小于符号扩展后的立即数时,将rd设置为1.

  • SLITU还是做的符号扩展,只是之后做的无符号比较

  • ANDI/ORI/XORI还是做的符号扩展,然后进行相应的逻辑运算


image-20200828221627196

  • rs1存放要被移位的操作数
  • 需要被移动的位数在低5位的IMM(注意RV64I不一样!)
  • 30位表示右移的类型
  • SLLI: 逻辑左移(填0)
  • SRLI: 逻辑右移(填0)
  • SRAI:算术左移(填符号位)

image-20200829124013137

  • LUI: 不对称的立即数载入,将imm载入rd的高20位,低12位填0.
  • AUIPC: 将当前的PC和imm[31:12], 12'b0相加,结果存入rd

寄存器-寄存器指令

image-20200829124031748

  • 比较简单,不解释了

NOP

image-20200829124049028

  • NOP === ADDI x0,x0,0

控制转移指令

  • RV32I提供了两种控制转移指令:无条件跳转和条件分支。没有延迟槽
  • 如果指令访问异常或缺页异常,则会在目标指令汇报异常,而不是那条跳转指令

无条件转移指令

image-20200828232145570

  • 偏移量被符号扩展,并且加到跳转指令的PC上,获得跳转目标地址。

  • JAL将跳转指令的pc+4存入到rd寄存器里面。

  • J指令被编码为JAL x0,也就是说不写入寄存器


image-20200828232427713

  • JALR是间接转移指令,目标地址是rs1加上符号扩展的12位的立即数,最后把目标的最低位置0,然后将pc+4写入rd

  • 如果不需要保存返回地址,直接把rd设置成0就可以了。

  • JAL和JALR指令会产生地址未对齐异常


关于RAS

RISC-V规定,JAL仅在rd=x1/x5的时候将地址放入RAS中,其余时刻不做操作。

JALR的操作遵循下表,link在寄存器是x1x5的时候为真:

image-20200828234854720

条件转移指令

image-20200828235706431

  • 所有的条件转移指令使用B类指令编码
  • 偏移量在符号扩展之后,和当前指令的PC相加
  • 在预测的时候,应当在第一次预测时,猜测往后会跳转,往前不跳转。
  • 无条件转移在RV中永远都是JAL,不会使用那些条件永远为真的无条件转移指令,例如MIPS中的x0,x0
  • 只有在跳转的时候,才有可能生成地址未对齐异常

Load/Store

EEI会定义地址空间的哪些部分可以被指令读取,哪些部分只能以字读取等待。

如果Load指令的目的地址是x0,则必须要造成异常!

image-20200829000716160

  • Load属于I类指令,Store属于S类指令。

  • 地址没有对齐,将造成的行为取决于EEI的定义。

  • 我们定义,地址未对齐,将造成异常。

ECALL和断点

image-20200829002351701

RV64I指令集

  • RV64I指令集在上面RV32的基础上,将寄存器宽度拓展到了64位。
  • 在RV64I中,多出了一些*W指令,这些指令忽略操作数的高32位,并且总是输出32位有符号数,并符号扩展到64位。也就是说,从XLEN-131位都是一样的。
  • 注意RV64I中的移位指令,其shamt域多了1位
  • 对于LUI而言,是将原来32位的结果扩展到了64位,AUIPC也是如此

Zicsr控制指令

image-20200829125633184

  • 所有的CSR指令原子地读、改写单个CSR寄存器。
  • CSR寄存器被编码到上面的csr域中,31-20
  • 无符号整数使用了rs1域进行了编码
  • CSRRW指令在CSR和整数寄存器之间做原子交换,从CSR中读取源寄存器,并做零扩展,写回到rd,并且rs1中的值被写入了CSR。注意,如果rdx0,则指令不应该读CSR寄存器,但是会写入CSR寄存器,不应该造成任何的副作用。
  • CSRRS指令读CSR寄存器的值,并做零扩展,写回到rd,并且rs1被作为位掩码,其位1的对应位在CSR中也会被设置为1.
  • CSRRC的作用和CSRRS相反,对应位1的位会被清除
  • 如果上述2个指令的源寄存器是x0,那么什么都不会做
  • 对于CSR的读写,在RISC-V中,定义是不会对后续指令的执行产生相应的影响的
  • 某些CSR,例如:instret指令,有可能会因为指令被执行而更改。在这种情况下,如果CSR指令读了CSR,他应该读出指令执行之前的值,如果CSR指令写了CSR,那么应该覆盖原来的值。特别地,如果某个指令写了instret,那么这条指令后续的指令读出的应当是其写入的那个值。

Chisel的一大优势,就是不仅可以编写硬件描述代码,还可以灵活地编写硬件生成器,即“生成硬件”的代码。再其他硬件描述语言,例如Verilog中 ,笔者比较经常使用Python来生成一些繁复的代码,例如端口连线、实例化等。

硬件模块参数化

参数可以是非常普通的Scala整形常量,也可以是Chisel的硬件类型。

简单的参数

  • 对电路定制最简单的参数化方法是使用参数来定义位宽。

  • 参数可以作为Chisel模块的构造器参数传入类的构造函数中。

下面是一个可定制宽度的加法器的例子:

1
2
3
4
5
6
7
8
9
class ParamAdder(n: Int) extends Module {
val io = IO(new Bundle{
val a = Input(UInt(n.W))
val b = Input(UInt(n.W))
val c = Output(UInt(n.W))
})
io.c := io.a + io.b
}
val add_8 = Module(new ParamAdder(8))

为了保证鲁棒性,防止硬件和预期不同,一般需要在类的头部加上一个require语句,来对参数的合法性进行断言。

1
2
3
4
5
6
7
8
9
10
11
12
class ParameterizedWidthAdder(in0Width: Int, in1Width: Int, sumWidth: Int) extends Module {
require(in0Width >= 0)
require(in1Width >= 0)
require(sumWidth >= 0)
val io = IO(new Bundle {
val in0 = Input(UInt(in0Width.W))
val in1 = Input(UInt(in1Width.W))
val sum = Output(UInt(sumWidth.W))
})
// a +& b includes the carry, a + b does not
io.sum := io.in0 +& io.in1
}

将硬件的行为参数化

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
28
29
30
31
32
33
class Sort4(ascending: Boolean) extends Module {
val io = IO(new Bundle {
val in0 = Input(UInt(16.W))
val in1 = Input(UInt(16.W))
val in2 = Input(UInt(16.W))
val in3 = Input(UInt(16.W))
val out0 = Output(UInt(16.W))
val out1 = Output(UInt(16.W))
val out2 = Output(UInt(16.W))
val out3 = Output(UInt(16.W))
})

// this comparison funtion decides < or > based on the module's parameterization
def comp(l: UInt, r: UInt): Bool = {
if (ascending) {
l < r
} else {
l > r
}
}

val row10 = Wire(UInt(16.W))
val row11 = Wire(UInt(16.W))
val row12 = Wire(UInt(16.W))
val row13 = Wire(UInt(16.W))

when(comp(io.in0, io.in1)) {
row10 := io.in0 // preserve first two elements
row11 := io.in1
}.otherwise {
row10 := io.in1 // swap first two elements
row11 := io.in0
}

在上述例子中,我们看到,硬件的行为被参数化了,comp(UInt, UInt) => Bool函数接收的是两个硬件的节点,返回的也是两个硬件的节点。

以类型为参数的函数

类型同样可以作为参数,传递入函数中。

以下的例子可以让Chisel生成对应数据类型的多路选择器。

1
2
3
4
5
6
7
def myMux[ T <: Data](sel: Bool, tPath: T, fPath: T): T = {
val ret = WireDefault(fPath)
when(sel) {
ret := tPath
}
ret // 返回一个“硬件”
}

在上述的例子中,表达式中[T <: Data]定义了一个类型参数T,其中T应当是Data类或者Data的子类。Data是Chisel类型系统的根类型。

我们可以通过以下的方式获得一个多路选择器:

1
val resA = myMux(selA, 5.U, 10.U)

如果使用不同类型的参数,将会造成runtime error.

我们甚至可以传入一个复杂的Bundle作为多路选择器的选择值。

1
2
3
val tVal = Wire(new ComplexIO)
val fVal = Wire(new ComplexIO)
val resB = myMux(selB, tVal, fVal)

我们可以使用cloneType方法来获取某个数据的类型。

1
2
3
4
5
6
7
8
def myMux[ T <: Data](sel: Bool, tPath: T, fPath: T): T = {
val ret = Wire(fPath.cloneType) // 获取数据类型
ret := fPath
when(sel) {
ret := tPath
}
ret // 返回一个“硬件”
}

以类型为模块的参数

设想,我们要实现一个NOC中在不同处理器核之间移动数据的模块,我们并不会在路由接口中对其进行硬编码,而是将其参数化。除此之外,我们还将路由端口的数量进行了参数化。

1
2
3
4
5
6
7
8
class NoCRouter[ T <: Data](dt: T, n: Int) extends Module {
val io = IO(new Bundle {
val inPort = Input(Vec(n, dt))
val address = Input(Vec(n, UInt(8.W)))
val outPort = Output(Vec(n, dt))
})
// 根据地址进行路由
}

对于在不同的处理器之间传递的信息,我们使用一个Payload来代表。

1
2
3
4
class Payload extends Bundle {
val data = UInt(16.W)
val flag = Bool()
}

现在,我们可以根据此来创建一个新的路由模块了。

1
val router = Module(new NocRouter(new Payload, 2))

可参数化的Bundle

在上面的例子中,我们对于Payload使用了统一的Data类型Bool,进一步思考,是否可以将Data类型也进行参数化呢?

1
2
3
4
class Port[ T <: Data ](dt: T) extends Bundle {
val address = UInt(8.W)
val data = dt.cloneType
}

注意上面的cloneType方法,Bundle的参数T,应当是属于Chisel的Data类型的子类。在Bundle中,我们定义了一个data域,通过在参数上面应用cloneType方法,来定义数据类型。

以下语句摘录自Chisel-Book,用于备忘:

However, when we use a constructor parameter, this parameter becomes a public field of the class. When Chisel needs to clone the type of the Bundle, e.g., when it is used in a Vec, this public field is in the way.

为了避免上述情况,可以使用以下的方法定义:

1
2
3
4
class Port[ T <: Data ](dt: T) extends Bundle {
val address = UInt(8.W)
val data = dt.cloneType
}

此时,我们可以定义路由模块了:

1
2
3
4
5
6
class NocRouter2[ T <: Data ](dt: T, n: Int) extends Module {
val io = IO(new Bundle {
val inPort = Input(Vec(n, dt))
val outPort = Output(Vec(n, dt))
})
}

实例化代码:

1
val router = Module(new NocRouter2(new Port(new Payload), 2))

可选的IO端口

常用于有时可以选择去除某些调试信号。

注意到如果val是None,那么就没有这个信号了。

1
2
3
4
5
6
7
8
9
10
11
12
class HalfFullAdder(val hasCarry: Boolean) extends Module {
val io = IO(new Bundle {
val a = Input(UInt(1.W))
val b = Input(UInt(1.W))
val carryIn = if (hasCarry) Some(Input(UInt(1.W))) else None
val s = Output(UInt(1.W))
val carryOut = Output(UInt(1.W))
})
val sum = io.a +& io.b +& io.carryIn.getOrElse(0.U)
io.s := sum(0)
io.carryOut := sum(1)
}

可选的参数

可以使用Option类型,来定义某个可选的参数,在实例化的时候,如果不提供这个参数,那么这个参数的isDefined字段就为False

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class DelayBy1(resetValue: Option[UInt] = None) extends Module {
val io = IO(new Bundle {
val in = Input( UInt(16.W))
val out = Output(UInt(16.W))
})
val reg = if (resetValue.isDefined) { // resetValue = Some(number)
RegInit(resetValue.get)
} else { //resetValue = None
Reg(UInt())
}
reg := io.in
io.out := reg
}

println(getVerilog(new DelayBy1))
println(getVerilog(new DelayBy1(Some(3.U))))

用代码生成组合逻辑

在Chisel中,我们可以通过从Scala的Array转为Chisel的Vec类型,非常方便地创建组合逻辑表格。我们可以使用存储在文件中的数据,在硬件生成阶段创建一个逻辑表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import chisel3._
import scala.io.Source

class FileReader extends Module {
val io = IO(new Bundle {
val address = Input(UInt(8.W))
val data = Output(UInt(8.W))
})

val array = new Array[Int](256)
val idx = 0

// 将文件的内容读入到scala的数组中
val source = Source.fromFile("data.txt")
for(line <- source.getLines()) {
array(idx) = line.toInt
idx += 1
}
// 将scala整数array转换成chisel的vec类型
val table = VecInit(array.map(_.U(8.W)))
// 使用索引访问Table
io.data := table(io.address)
}

让我们把目光聚焦到这一行代码:

1
val table = VecInit(array.map(_.U(8.W)))

一个Scala的数组(Array)可以被隐式地转换成一个序列(Sequence),序列支持map函数。map函数的意义是,将函数应用到序列中的每一个对象,并返回经过处理后的序列。在上述代码中,_.U(8.W)是一个匿名函数,_是一个通配符,代表列表中的元素,这个函数将列表中的Scala Int类型转换为Chisel中硬件的UInt,位宽为8.

有了这个方法,我们可以非常便捷的编码某些查找表逻辑,例如,二进制转BCD逻辑,等等。

使用继承

Chisel基于Scala,而Scala是一门面向对象的语言。因此,我们可以充分利用面向对象的特性,抽象出不同的硬件模块的共同行为,构造出一个父类。

在之前的学习中,我们已经构造了不同的计数器。假设现在有了新的场景,我们需要实现不同版本的计数器,来比较其资源消耗情况。

此时,我们需要先定义一个抽象类:

1
2
3
4
5
abstract class Ticker(n: Int) extends Module {
val io = IO(new Bundle{
val tick = Output(Bool())
})
}

如果我们需要具体的实现某个Ticker,则需要继承这个抽象类:

1
2
3
4
5
6
7
8
9
class UpTicker (n: Int) extends Ticker(n) {
val N = (n-1).U
val cntReg = RegInit (0.U(8.W))
cntReg := cntReg + 1.U
when(cntReg === N) {
cntReg := 0.U
}
io.tick := cntReg === N
}

测试代码的参数包括:

(1)类型:仅接收Ticker类型

(2)DUT:用以测试的代码

(3)期待得到tick的周期数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import chisel3.iotesters.PeekPokeTester
import org.scalatest._
class TickerTester[ T <: Ticker ]( dut: T, n: Int) extends
PeekPokeTester(dut: T) {
// -1 is the notion that we have not yet seen the first tick
var count = -1
for (i <- 0 to n * 3) {
if (count > 0) {
expect(dut.io.tick , 0)
}
if (count == 0) {
expect(dut.io.tick , 1)
}
val t = peek(dut.io.tick)
// On a tick we reset the tester counter to N-1,
// otherwise we decrement the tester counter
if (t == 1) {
count = n-1
} else {
count -= 1
}
step (1)
}
}

模式匹配机制

Scala的模式匹配机制在Chisel中非常常见,Scala提供了强大的模式匹配机制,包括:

  • 类似于C语言的switch语句的匹配功能
  • 对于不同值的任意组合进行匹配
  • 对于变量的类型进行匹配,这一点非常好用,例如:
    • 用于迭代的值来自于列表,而列表中对象的类型不尽相同
    • 变量是某个超类的成员,但并不知道其子类是什么
  • 通过正则表达式匹配字符串的子串

值匹配

1
2
3
4
5
6
7
8
9
10
11
12
13
// y is an integer variable defined somewhere else in the code
val y = 7
/// ...
val x = y match {
case 0 => "zero" // 可以写在同一行里
case 1 => // 可以不写在同一行里
"one" // 在下一个case之前都是这个case的代码
case 2 => { // 可以添加大括号,但通常不是必要的
"two"
}
case _ => "many" // _可以匹配所有的值
}
println("y is " + x)
  • 模式匹配是按照从上到下的顺序的,一旦发生成功匹配,就不会进行接下来的搜索
  • 通配符_的作用是匹配其他值,用于处理没有匹配上的情况

多值匹配

1
2
3
4
5
6
7
8
9
def animalType(biggerThanBreadBox: Boolean, meanAsCanBe: Boolean): String = {
(biggerThanBreadBox, meanAsCanBe) match {
case (true, true) => "wolverine"
case (true, false) => "elephant"
case (false, true) => "shrew"
case (false, false) => "puppy"
}
}
println(animalType(true, true))

多值匹配的语法如上所示。

类型匹配

1
2
3
4
5
6
7
8
9
val sequence = Seq("a", 1, 0.0)
sequence.foreach { x =>
x match {
case s: String => println(s"$x is a String")
case s: Int => println(s"$x is an Int")
case s: Double => println(s"$x is a Double")
case _ => println(s"$x is an unknown type!")
}
}

Scala是强类型语言,类型匹配是一种强大的机制。

如果想匹配多种类型,可以这样写,注意此时需要使用通配符_

1
2
3
4
5
6
7
val sequence = Seq("a", 1, 0.0)
sequence.foreach { x =>
x match {
case _: Int | _: Double => println(s"$x is a number!")
case _ => println(s"$x is an unknown type!")
}
}

使用实例

1
2
3
4
5
6
7
8
9
10
11
12
class DelayBy1(resetValue: Option[UInt] = None) extends Module {
val io = IO(new Bundle {
val in = Input( UInt(16.W))
val out = Output(UInt(16.W))
})
val reg = resetValue match {
case Some(r) => RegInit(r)
case None => Reg(UInt())
}
reg := io.in
io.out := reg
}

使用模式匹配去匹配类型。

隐式

Scala引入了隐式的概念,允许编译器引入部分语法糖。

隐式参数

有时,我们的代码可能需要访问一些顶层的变量,特别是在比较深的嵌套函数调用中。相比于我们手动将这些变量在每次函数调用中传递,我们可以使用隐式参数。

在Scala中,我们可以隐式或显式地传入参数:

1
2
3
4
5
6
7
8
9
10
11
object CatDog {
implicit val numberOfCats: Int = 3
//implicit val numberOfDogs: Int = 5

def tooManyCats(nDogs: Int)(implicit nCats: Int): Boolean = nCats > nDogs

val imp = tooManyCats(2) // 隐式地传入了参数nCats
val exp = tooManyCats(2)(1) // 显式地传入了参数nCats
}
CatDog.imp
CatDog.exp

此处,我们首先定义了一个隐式值numberOfCats。在某个定义域内,同一个类型的隐式值只能有一个值。(Scala通过类型来进行判定的,从上述代码可以看到,nCatsnumberOfCats并不同名)

然后,我们定义了一个函数,接收两个参数列表,第一个是显式的参数值,第二个是隐式的参数值。

当我们调用tooManyCats函数的时候,我们可以隐藏第二个参数列表(让编译器为我们寻找隐式值),或是显式地提供相应的参数(可以和默认的隐式值不同)。

以下情况下,隐式参数推断会失败:

  • 在作用域中定义了给定类型的两个或多个隐含值

  • 编译器找不到函数调用所需的隐含值

隐式转换

类似于隐式参数的,隐式转换常常被用于减少模板代码的数量。更具体地,它们用来自动将Scala对象转换为其他对象

在下面的例子中,我们有两个类,分别为AnimalHuman类,AnimalSpecies字段,但是Human没有。

当我们在Human上面调用Species时,编译器会尝试进行隐式转换。

因此,为了完成AnimalHuman之间的转换,我们需要定义一个转换函数。

1
2
3
4
5
class Animal(val name: String, val species: String)
class Human(val name: String)
implicit def human2animal(h: Human): Animal = new Animal(h.name, "Homo sapiens")
val me = new Human("Adam")
println(me.species)

例子:Mealy机生成器

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
28
29
30
31
32
33
34
35
36
37
// Mealy machine has
case class BinaryMealyParams(
// number of states
nStates: Int,
// initial state
s0: Int,
// function describing state transition
stateTransition: (Int, Boolean) => Int,
// function describing output
output: (Int, Boolean) => Int
) {
require(nStates >= 0)
require(s0 < nStates && s0 >= 0)
}

class BinaryMealy(val mp: BinaryMealyParams) extends Module {
val io = IO(new Bundle {
val in = Input(Bool())
val out = Output(UInt())
})

val state = RegInit(UInt(), mp.s0.U)

// output zero if no states
io.out := 0.U
for (i <- 0 until mp.nStates) {
when (state === i.U) {
when (io.in) {
state := mp.stateTransition(i, true).U
io.out := mp.output(i, true).U
}.otherwise {
state := mp.stateTransition(i, false).U
io.out := mp.output(i, false).U
}
}
}
}

上述的代码是一个Mealy状态机的生成逻辑。首先,声明了一个case class,在里面包装了所有构建Mealy机所需的参数,包括状态的数量、初态、状态转移函数和输出函数,并且使用了两个require语句做断言,保证状态的合法性。

BinaryMealy是状态机的硬件模块,其构造器接收参数mp,这个参数用以构建状态机。

image-20200824210948423

下面构造这个状态机的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
val nStates = 3
val s0 = 2
// 将上述函数翻译成状态转移函数
def stateTransition(state: Int, in: Boolean): Int = {
if(in) {
1
} else {
0
}
}
// 状态机输出函数
def output(state:Int, in: Boolean): Int = {
if (state == 2) {
0
} else if ((state == 1 && !in) || (state == 0 && in)) {
1
} else {
0
}
}
val testParams = BinaryMealyParams(nStates, s0, stateTransition, output)