E4 处理器的仿真和验证
你已经通过在线学习网站HDLbits学习了Verilog的使用, 同样地, 为了设计自己的处理器, 我们需要使用Linux作为开发环境.
RTL仿真(Simulation) - 功能验证
在Linux中进行RTL代码的开发并不困难, 只需要一款编辑器即可. 但在完成RTL代码的开发工作后, 我们还需要一款仿真器来验证RTL代码所描述的电路是否符合预期.
STFW + RTFM搭建Verilator仿真环境
Verilator是一款开源的Verilog仿真器, 你将会使用它来进行RTL功能仿真.
框架代码默认提供了一个npc目录, 这里的npc是New Processor Core的含义, 你将来会在这个目录下设计自己的处理器. 我们将大家设计的处理器统称为NPC, 当然大家可以给自己的处理器起一个更个性化的名字. 不过为了设置一个环境变量NPC_HOME, 你需要运行如下命令:
cd ysyx-workbench
bash init.sh npc
这个环境变量会在将来使用. npc目录下有一些简单的文件:
ysyx-workbench/npc
├── csrc
│ └── main.cpp
├── Makefile
└── vsrc
└── example.v
目前这三个文件几乎都是空文件, 我们将会引导大家搭建Verilator仿真环境, 并编写两个简单的数字电路模块来进行仿真.
竟然连仿真框架都没有, 真寒酸
我们之所以设置这部分的实验内容, 是为了让大家知道, 项目里面的所有细节都是和大家有关系的.
在以前的课程实验当中, 大家不多不少都会觉得, 框架理所应当是由助教来提供的, 做实验就是在指定的地方写上相应的代码, 其它都是无关的代码/文件, 大家不需要关心. 事实上, 这样的实验方案是很危险的, 这不仅不能将你训练成真正的专业人士, 反而会使得你无法在真正的项目中存活下来:
- 遇到系统性的bug, 肯定调不出来, 因为连调用你代码的模块你都觉得跟你没关系, 更别说可以清晰地认识到整个项目的架构和其中的每一处细节了
- 离开了讲义, 就什么都做不了, 因为你总是在等别人像这些讲义一样清楚地告诉你接下来应该做什么怎么做, 而不是站在项目的角度去切实分析现在应该做什么
一个很现实的场景是, 以后你到了企业或者进入课题组, 不会再有讲义和框架代码照顾你, 你的老板说一句"来试试Verilator", 你就要自己把Verilator跑起来, 写一份使用报告, 在下周的组会上给老板汇报工作.
因此, 我们希望给大家提供更真实的训练: 给出一个目标, 让大家学会对目标进行分解, 并通过自身的技能一步步达成这个目标. 搭建Verilator仿真框架其实是一个很容易实现的目标, 因此作为一项小试牛刀的训练, 也是非常合适的.
如果你想使用Chisel
Chisel可以生成功能等价的Verilog代码, 并通过Verilator进行仿真. 目前我们先关注Verilator的使用方法, 如果你想使用Chisel, 我们也建议你先按照讲义内容搭建好Verilog的工作流程, 完后再切换到Chisel.
事不宜迟, 我们马上开始.
认识Verilator
你很可能是第一次听说过Verilator这个工具, 这是很正常的. 然后你就会想进一步了解Verilator的各种信息, 这也是很正常的. 但如果你的第一反应是去问人, 这就不恰当了. 事实上, Verilator这个工具在仿真领域已经非常有名, 以至于你可以很容易在互联网上搜索到它的相关信息. 你需要通过STFW找到它的官方网站, 然后阅读一下相关的介绍.
找到官网并且阅读过相关介绍之后, 接下来就是通过运行来体会一下了. 不过在这之前, 我们还需要安装它.
安装Verilator
在官网中找到安装Verilator的步骤, 然后按照从git安装的相应步骤进行操作. 我们之所以不采用apt-get安装, 是因为其版本较老. 我们推荐安装stable版本. 为此, 你还需要进行一些简单的git操作, 如果你对此感到生疏, 你可能需要寻找一些git教程来学习. 另外, 你最好在ysyx-workbench/之外的目录进行这一操作, 否则git将会追踪到Verilator的源代码, 从而占用不必要的磁盘空间.
安装成功后, 运行以下命令来检查安装是否成功, 以及版本是否正确.
verilator --version
Verilator会编译出C++文件, 然后将C++文件编译成可执行文件, 通过执行这个可执行文件来进行仿真.
运行示例
Verilator手册中包含一个C++的示例, 你需要在手册中找到这个示例, 然后按照示例的步骤进行操作.
你已经学习过C语言, 为了使用Verilator, 你并不需要了解复杂的C++语法, 你只需要了解一些类(class)的基本使用方法就可以了. 单从这一点来看, 网上的很多资料都可以满足你的需求.
示例: 双控开关(组合逻辑电路)
手册中的示例非常简单, 甚至算不上是一个真正的电路模块. 接下来我们编写一个真正的电路模块, 双控开关, 来进行测试. 编写如下的Verilog代码:
module top(
input a,
input b,
output f
);
assign f = a ^ b;
endmodule
双控开关的一个应用是通过两个开关(a和b)联合控制同一盏灯的亮灭(f). 和手册中的示例不同, 这个模块有输入输出端口. 为了驱动输入端口, 并从输出端口获得结果, 我们需要对C++文件中的while循环进行修改:
// 以下为伪代码
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
while (???) {
int a = rand() & 1;
int b = rand() & 1;
top->a = a;
top->b = b;
top->eval();
printf("a = %d, b = %d, f = %d\n", a, b, top->f);
assert(top->f == (a ^ b));
}
在一次循环中, 代码将会随机生成两个1比特信号, 用来驱动两个输入端口, 然后通过eval()函数更新电路的状态, 这样我们就可以读取输出端口的值并打印. 为了自动检查结果是否正确, 我们通过assert()语句对输出结果进行检查.
对双控开关模块进行仿真
尝试在Verilator中对双控开关模块进行仿真. 由于顶层模块名与手册中的示例有所不同, 你还需要对C++文件进行一些相应的修改. 此外, 这个项目没有指示仿真结束的语句, 为了退出仿真, 你需要键入Ctrl+C.
上述代码是什么意思?
如果你不知道应该如何修改, 说明你还不太熟悉C程序的编写, 你应该先回到上一小节复习C语言.
打印并查看波形
查看波形文件是RTL调试的常用手段之一. Verilator支持波形的生成, 你可以通过开源的波形查看工具GTKWave来查看波形.
生成波形并查看
Verilator手册中已经介绍了波形生成的方法, 你需要阅读手册找到相关内容, 然后按照手册中的步骤生成波形文件, 并通过
apt-get install gtkwave
安装GTKWave来查看波形.
手册这么多内容, 怎么找?
尝试一下键入Ctrl+F.
不要长时间生成波形
波形文件一般会占用较多的磁盘空间, 长时间生成波形可能会导致磁盘空间耗尽, 从而导致系统崩溃.
生成FST格式的波形
FST格式的波形文件的大小大致是VCD格式的1/50, 但它仅能被GTKWave支持. 尽管如此, 我们还是推荐你使用它. 具体地, 你可以查阅Verilator手册, 了解如何生成FST格式的波形.
编写Makefile
一键仿真
反复键入编译运行的命令是很不方便的, 尝试为npc/Makefile编写规则sim, 实现一键仿真, 如键入make sim即可进行上述仿真.
注意保留git追踪的命令
框架代码已经在npc/Makefile中提供了一条默认的sim规则, 它已经包含用于git追踪的命令$(call git_commit, "sim RTL"), 在编写Makefile的时候注意不要修改这一命令, 否则会影响开发跟踪的功能, 而这是记录"一生一芯"成果原创性的重要依据. 因此在编写Makefile并运行之后, 你也需要确认git是否已经正确追踪了仿真的记录.
接入NVBoard
NVBoard(NJU Virtual Board)是南京大学开发的, 用于教学的虚拟FPGA板卡项目, 可以在RTL仿真环境中提供一个虚拟板卡的界面, 支持拨码开关, LED灯, VGA显示等功能, 在速度要求不高的场景下可完全替代真实的FPGA板卡(毕竟不是每人身边都有一块FPGA). 通过以下命令获取NVBoard的代码:
cd ysyx-workbench
bash init.sh nvboard
运行NVBoard示例
阅读NVBoard项目的介绍, 尝试运行NVBoard项目中提供的示例.
不知道NVBoard如何工作?
试试从make命令开始, 看看一切是如何发生的. 通过前面的学习, 你已经掌握了足够的知识背景去理解NVBoard如何工作了: 包括Makefile的使用, C语言和C++中类的基本用法. 现在就试试阅读代码(Makefile也是代码), 看看示例中的Verilog顶层端口, 约束文件, 以及NVBoard是如何建立联系的.
在NVBoard上实现双控开关
阅读NVBoard项目的说明, 然后仿照该示例下的C++文件和Makefile, 修改你的C++文件, 为双控开关的输入输出分配引脚, 并修改npc/Makefile, 使其连接到NVBoard上的开关和LED灯.
NVBoard的故事
NVBoard虽然是南京大学的教学项目, 但它却与参加"一生一芯"的各位有着一种特殊的联系: 在第三期"一生一芯"的流片名单当中有两位特殊的同学, 他们报名的时候还只是大一, 而其中一位同学sjr就是NVBoard的第一作者.
事实上, 也正是sjr同学在参加"一生一芯"时锻炼出的独立解决问题的能力和自信, 帮助他成功开发NVBoard项目. 如今NVBoard项目又反过来帮助"一生一芯"改进学习效果, NVBoard承载的除了虚拟FPGA板卡的功能之外, 还有"一生一芯"秉承的独立解决问题的理念.
这些离你其实并不遥远, 当你愿意自主学习而不再等着别人给你答案的时候, 你的将来来也会充满无限可能.
示例: 流水灯(时序逻辑电路)
流水灯是按照顺序依次亮起和熄灭的一组灯, 以下是流水灯的一个参考实现:
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
这里的输出信号led的每一位对应虚拟板卡的一个LED灯. 由于代码中包含需要进行复位的时序逻辑部件, 我们需要对Verilator的仿真代码进行修改:
// 以下为伪代码
void single_cycle() {
top->clk = 0; top->eval();
top->clk = 1; top->eval();
}
void reset(int n) {
top->rst = 1;
while (n -- > 0) single_cycle();
top->rst = 0;
}
...
reset(10); // 复位10个周期
while(???) {
...
single_cycle();
...
}
将流水灯接入NVBoard
编写流水灯模块, 然后接入NVBoard并分配引脚. 如果你的实现正确, 你将看到灯从右端往左端依次亮起并熄灭.
静态代码检查
Verilator还可以作为一个lint工具来对代码进行静态检查. 在命令行中给verilator传递--lint-only参数, Verilator将仅开展代码检查工作, 并以警告信息的方式指出可能存在问题的代码, 而不生成C++文件. 特别地, 你还可以添加-Wall选项, 来让Verilator开启所有类型的检查, 让它帮助你找到更多的潜在问题.
Verilator手册中的Errors and Warnings章节列出了所有警告的说明, 通过阅读它们, 你将了解这些警告是如何产生的, 从而得知应该如何修复它们. 对于代码逻辑相关的警告, 你应该通过修改代码来移除它们; 但对于一些代码风格类型的警告, 如果确定不影响代码逻辑, 可以通过额外的-Wno-xxx选项来关闭xxx警告, 例如-Wno-DECLFILENAME.
通过Verilator进行静态代码检查
尝试使用Verilator检查你的代码, 并尽最大可能修复所有警告.
我们建议你将来总是开启Verilator的静态代码检查功能. 一方面, 这有助于你养成良好的编码习惯, 从而编写出更高质量的代码. 另一方面, 尽早发现代码中潜在问题, 也有利于节省不必要的调试工作: 随着代码规模的增加, 将来你很可能因为某个信号的位宽错误而调试好几天, 而Verilator的警告可以让你马上注意到这个问题, 从而轻松地排除相应的错误.
Verilator进阶学习
若干代码风格和规范
在往期的"一生一芯"计划中, 我们发现部分不规范的代码风格会给后期的SoC集成带来额外的问题. 为了避免将来影响SoC集成的进度, 我们建议大家遵守以下若干代码规范.
1. 如果你还没有深入理解Verilog的事件模型, 不要使用行为建模
事实上, 南京大学数字电路实验讲义中也提到"行为建模不利于初学者建立电路思维", 我们在这里引用相关的描述:
强烈建议初学者不要使用行为建模方式设计电路
Verilog一开始并不是为了设计可综合电路而提出的, 它的本质是一门基于事件队列模型的电路建模语言. 因此, 行为建模很容易会让初学者偏离描述电路的初衷: 开发者需要看着电路图, 心里想象电路的行为, 然后转化成事件队列模型的思考方式, 最后再用行为建模方式来描述电路的行为, 综合器再来根据这样的描述推导出相应的电路. 从这个过程来看, 这不仅是没有必要的, 而且还很容易引入错误:
- 如果开发者心里本身就已经有电路图, 直接描述它是最方便的
- 如果开发者心里本身就已经有电路图, 而开发者对行为建模方式的理解所有偏差, 可能会采用了错误的描述方式, 从而设计出非预期的电路
- 如果开发者心里没有电路图, 而是期望通过行为建模方式让综合器生成某种行为的电路, 这就已经偏离“描述电路”的本质了. 大部分同学非常容易犯这样的错误, 把行为建模当作过程式的C语言来写, 尝试把任意复杂的行为描述映射到电路, 最终综合器只会生成出延迟大, 面积大, 功耗高的低质量电路, 甚至因为代码包含数据竞争而被综合器综合成行为不符合预期的电路
所以, 直到大家掌握"描述电路"的思维而不被行为建模误导之前 我们强烈建议初学者远离行为建模方式, 仅通过数据流建模和结构化建模方式直接描述电路. 下面的问题可以帮助大家测试自己是否已经掌握Verilog的本质:
- 在硬件描述语言中, "执行"的精确含义是什么?
- 是谁在执行Verilog的语句? 是电路,综合器,还是其它的?
- if的条件满足, 就不执行else后的语句, 这里的"不执行"又是什么意思? 和描述电路有什么联系?
- 有"并发执行", 又有"顺序执行", 还有"任何一个变量发生变化就立即执行", 以及"在任何情况下都执行", 它们都是如何在设计出来的电路中体现的?
如果你无法对这些问题作出明确的回答, 我们强烈建议你不要使用行为建模方式. 如果你真的想弄懂它们, 你需要阅读[Verilog标准手册][verilog manual].
真正的描述电路 = 实例化 + 连线
忘记行为建模方式, 就可以很容易回归到描述电路的简单本质. 想象一下, 你手中有一张电路图纸, 如果你需要向其它人描述图纸上的内容, 你将会如何描述? 你一定会说出类似"有一个A元件/模块, 它的x引脚和另一个B元件/模块的y引脚相连"的描述, 因为这才是描述电路的最自然的方式. 用HDL设计电路, 就是在用HDL来描述电路图纸, 图纸上有什么, 就直接描述什么. 所以, 用HDL描述电路, 无非是做两件事情:
- 实例化: 在电路板上放一个元件/模块, 可以是一个门电路, 或者是由门电路组成的模块
- 连线: 用导线将元件/模块的引脚正确地连起来
大家可以体会一下, 数据流建模和结构化建模是如何体现这两件事的, 而行为建模又是如何把这两件简单的事情复杂化的.
所以, 我们不建议初学者在Verilog代码中编写任何always语句. 为了方便大家使用触发器和选择器, 我们提供了如下Verilog模板给大家进行调用:
// 触发器模板
module Reg #(WIDTH = 1, RESET_VAL = 0) (
input clk,
input rst,
input [WIDTH-1:0] din,
output reg [WIDTH-1:0] dout,
input wen
);
always @(posedge clk) begin
if (rst) dout <= RESET_VAL;
else if (wen) dout <= din;
end
endmodule
// 使用触发器模板的示例
module example(
input clk,
input rst,
input [3:0] in,
output [3:0] out
);
// 位宽为1比特, 复位值为1'b1, 写使能一直有效
Reg #(1, 1'b1) i0 (clk, rst, in[0], out[0], 1'b1);
// 位宽为3比特, 复位值为3'b0, 写使能为out[0]
Reg #(3, 3'b0) i1 (clk, rst, in[3:1], out[3:1], out[0]);
endmodule
// 选择器模板内部实现
module MuxKeyInternal #(NR_KEY = 2, KEY_LEN = 1, DATA_LEN = 1, HAS_DEFAULT = 0) (
output reg [DATA_LEN-1:0] out,
input [KEY_LEN-1:0] key,
input [DATA_LEN-1:0] default_out,
input [NR_KEY*(KEY_LEN + DATA_LEN)-1:0] lut
);
localparam PAIR_LEN = KEY_LEN + DATA_LEN;
wire [PAIR_LEN-1:0] pair_list [NR_KEY-1:0];
wire [KEY_LEN-1:0] key_list [NR_KEY-1:0];
wire [DATA_LEN-1:0] data_list [NR_KEY-1:0];
genvar n;
generate
for (n = 0; n < NR_KEY; n = n + 1) begin
assign pair_list[n] = lut[PAIR_LEN*(n+1)-1 : PAIR_LEN*n];
assign data_list[n] = pair_list[n][DATA_LEN-1:0];
assign key_list[n] = pair_list[n][PAIR_LEN-1:DATA_LEN];
end
endgenerate
reg [DATA_LEN-1 : 0] lut_out;
reg hit;
integer i;
always @(*) begin
lut_out = 0;
hit = 0;
for (i = 0; i < NR_KEY; i = i + 1) begin
lut_out = lut_out | ({DATA_LEN{key == key_list[i]}} & data_list[i]);
hit = hit | (key == key_list[i]);
end
if (!HAS_DEFAULT) out = lut_out;
else out = (hit ? lut_out : default_out);
end
endmodule
// 不带默认值的选择器模板
module MuxKey #(NR_KEY = 2, KEY_LEN = 1, DATA_LEN = 1) (
output [DATA_LEN-1:0] out,
input [KEY_LEN-1:0] key,
input [NR_KEY*(KEY_LEN + DATA_LEN)-1:0] lut
);
MuxKeyInternal #(NR_KEY, KEY_LEN, DATA_LEN, 0) i0 (out, key, {DATA_LEN{1'b0}}, lut);
endmodule
// 带默认值的选择器模板
module MuxKeyWithDefault #(NR_KEY = 2, KEY_LEN = 1, DATA_LEN = 1) (
output [DATA_LEN-1:0] out,
input [KEY_LEN-1:0] key,
input [DATA_LEN-1:0] default_out,
input [NR_KEY*(KEY_LEN + DATA_LEN)-1:0] lut
);
MuxKeyInternal #(NR_KEY, KEY_LEN, DATA_LEN, 1) i0 (out, key, default_out, lut);
endmodule
其中, MuxKey模块实现了"键值选择"功能, 即在一个(键值, 数据)的列表lut中, 根据给定的键值key, 将out设置为与其匹配的数据. 若列表中不存在键值为key的数据, 则out为0. 特别地, MuxKeyWithDefault模块可以提供一个默认值default_out, 当列表中不存在键值为key的数据, 则out为default_out. 实例化这两个模块时需要注意如下两点:
- 需要使用者提供键值对的数量
NR_KEY, 键值的位宽KEY_LEN以及数据的位宽DATA_LEN这三个参数, 并保证端口的信号宽度与提供的参数一致, 否则将会输出错误的结果 - 若列表中存在多个键值为
key的数据, 则out的值是未定义的, 需要使用者来保证列表中的键值互不相同
MuxKeyInternal模块的实现中用到了很多高级的功能, 如generate和for循环等, 为了方便编写还使用了行为建模方式, 在这里我们不展开介绍, 通过结构化建模的抽象, 使用者可以无需关心这些细节.
以下代码通过使用选择器模板来分别实现2选1多路选择器和4选1多路选择器:
module mux21(a,b,s,y);
input a,b,s;
output y;
// 通过MuxKey实现如下always代码
// always @(*) begin
// case (s)
// 1'b0: y = a;
// 1'b1: y = b;
// endcase
// end
MuxKey #(2, 1, 1) i0 (y, s, {
1'b0, a,
1'b1, b
});
endmodule
module mux41(a,s,y);
input [3:0] a;
input [1:0] s;
output y;
// 通过MuxKeyWithDefault实现如下always代码
// always @(*) begin
// case (s)
// 2'b00: y = a[0];
// 2'b01: y = a[1];
// 2'b10: y = a[2];
// 2'b11: y = a[3];
// default: y = 1'b0;
// endcase
// end
MuxKeyWithDefault #(4, 2, 1) i0 (y, s, 1'b0, {
2'b00, a[0],
2'b01, a[1],
2'b10, a[2],
2'b11, a[3]
});
endmodule
如果你使用Chisel, 也建议你不要使用when和switch
在Chisel中, when和switch的语义和Verilog的行为建模非常相似, 因此也不建议初学者使用. 相反, 你可以使用Mux1H等库函数来实现选择器的功能, 具体可以查阅Chisel的相关资料.
2. 如果你坚持使用Verilog的行为建模, 不要用negedge
posedge和negedge混用, 会导致时序收敛更加困难, 增加后端物理实现的难度. 如果你不清楚如何在两者混用的情况下仍然保持很好的时序, 我们建议你只使用posedge. 否则, 如果你的处理器严重影响SoC整体的时序, 在流片时间节点紧张的情况下, "一生一芯"项目组将会把你的处理器移出该批次的流片名单.
如果你使用我们提供的上述Verilog模板, 或者使用Chisel, 你不必担心这一问题.
3. 如果你坚持使用Verilog的行为建模, 不要使用锁存器(Latch)
锁存器的变化不受时钟驱动, 因此时序分析工具难以对其进行分析. 如果你不清楚如何避免锁存器, 我们建议你不要使用行为建模.
如果你使用我们提供的上述Verilog模板, 或者使用Chisel, 你不必担心这一问题.
4. 需要在模块名前添加学号前缀
如module IFU需要修改为module ysyx_22040000_IFU. 这是因为大家将自己的处理器集成到SoC后, 名字相同的模块会导致工具报告重复定义的错误.
如果你使用Chisel, 我们将来会提供一个自动添加前缀的方法, 目前你编写代码时模块名不必添加学号前缀.
5. 如果你使用Verilog, 需要在宏定义的标识符前添加学号前缀
如`define SIZE 5需要修改为`define ysyx_22040000_SIZE 5. 这是因为大家将自己的处理器集成到SoC后, 名字相同的宏会导致工具报告重复定义的错误.
如果你使用Chisel, 你不必担心这一问题.
完成数字电路实验
你之前已经通过在线学习网站HDLBits完成了不少数字电路的设计. 在搭建仿真环境后, 我们已经可以支撑一个相对完整的数字电路设计流程:
新需求 -> 架构设计 -> 逻辑设计 -> 功能验证 -> 电路评估
其中, 架构设计是指"思考如何通过电路功能实现新需求的方案", 逻辑设计是指"用RTL代码实现设计方案"; 功能验证目前通过Verilator仿真来实现, 以检查RTL代码实现的功能是否符合预期; 电路评估则是用EDA工具评估电路的性能, 面积, 功耗等指标, 目前我们暂不涉及.
接下来, 你将尝试按照上述流程来完成一些数字电路实验, 从而对其建立更深刻的认识.
学习敏捷开发语言Chisel
对于一些复杂的数字电路, 采用Chisel进行设计将会更方便, 你在将来的学习过程中将会逐渐体会到这一点. 不过"一生一芯"并不限制你使用何种语言来设计处理器.
如果你打算学习Chisel语言, 我们还是建议你先掌握一些Verilog语言的基础, 然后建议你按照如下顺序学习:
- Chisel Users Guide比较系统地整理了chisel的特性, 也是不错的入门教程.
- Chisel小抄简明地列出了chisel语言的大部分用法.
- Digital Design with Chisel是一本结合数字逻辑设计和Chisel的参考书.
- Chisel API详细地列出了chisel库的所有API供参考.
如果你希望加入Chisel交流群, 可以微信扫描下方二维码联系助教申请进群:

如果你想使用Chisel
请运行以下命令:
cd ysyx-workbench
bash init.sh npc-chisel
上述命令会将npc目录中的文件换成一个Chisel开发环境, 具体介绍可以阅读其中的README.md.
对于Chisel生成的Verilog代码, Verilator的静态代码检查功能产生的警告可能难以修复, 你可以在确定这些警告不影响代码正确性的情况下忽略它们. 但我们仍然建议你总是开启Verilator的静态代码检查功能, 因为在查阅这些警告的过程中, 你很有可能会发现一些代码逻辑相关的问题.
借助NVBoard完成数字电路实验
我们首先推荐南京大学的数字电路与计算机组成实验.
南京大学开展教学改革, 将"数字电路"与"计算机组成原理"两门课程进行融合, 其实验内容贯穿从数字电路基础到简单的处理器设计. 有了NVBoard之后, 你就可以把它当作FPGA来使用, 用它来实现需要FPGA支持的实验内容.
你需要完成以下必做内容:
- 实验二 译码器和编码器
- 实验三 加法器与ALU
- 实验六 移位寄存器及桶形移位器
- 实验七 状态机及键盘输入
- 实验八 VGA接口控制器实现
如果你打算使用Chisel来完成上述数字电路实验, 你只需要把编译出的Verilog代码接入Verilator和NVBoard就可以了.
用RTL实现简单处理器
终于到了用RTL来设计自己的第一个处理器的时刻了! 你已经用Logisim实现了sCPU, 要用RTL来实现sCPU, 就是根据Logisim中的电路结构图, 用RTL代码描述出每个模块的电路结构. 有了完成数字电路的经验, 这对你来说并不困难.
用RTL实现sCPU
尝试把sCPU作为NPC的设计目标, 根据之前你用Logisim设计的sCPU, 用RTL重新设计它, 用于计算1+2+...+10. 为了看到输出效果, 你可以通过io指令将计算结果输出到NVBoard的LED.
处理器的高效验证方法——DiffTest
现在sISA只有4条指令, sCPU上运行的数列求和程序也很简单, 即使RTL实现有误, 靠波形进行调试也并不困难. 但随着处理器和程序变得复杂, 从大量的波形信息中找到错误, 将会变得很困难. 想象一下, 如果你将来设计的NPC有1000个信号, 运行某个复杂程序时, 在100000个周期后发现程序运行结果不正确, 波形文件中将会包含1000x100000=1亿个信号值. 如何从中快速找到若干个关键的信号值, 来帮助你诊断RTL的问题呢? 可见, 在这种复杂场景中, 仅仅靠波形进行调试, 效率是十分低下的.
为了寻找一种高效的调试方法, 我们需要回顾处理器的工作原理. 在处理器上运行程序, 就是在处理器上执行一条条指令的过程. 这个过程本质上可以通过ISA的模型机来进行描述, 处理器只不过是用数字电路将这个ISA模型机实现出来. 那么, 我们能不能用另一种更简单的方式来实现这个ISA模型机, 并对比两者执行指令的过程是否正确呢?
还真可以! 这实际上是一种非常奏效的验证方法, 在软件测试领域称为differential testing(后续简称DiffTest). 通常来说, 进行DiffTest需要提供一个和DUT(Design Under Test, 测试对象) 功能相同但实现方式不同的REF(Reference, 参考实现), 然后让它们接受相同的有定义的输入, 观测它们的行为是否相同. 当两者的行为有差异时, 通常预示着DUT在相应输入下存在bug.
显然, 我们应该把用数字电路实现的处理器作为DUT. 为了使用DiffTest对DUT进行验证, 我们还需要一个REF.
指令集模拟器 - 可以执行程序的程序
事实上, 我们尝试用C语言来实现程序执行的过程. 这样一个用来执行程序的程序, 称为模拟器. 在工业界的处理器设计流程中, 模拟器起着非常重要的作用. 你已经猜到了, 这个模拟器可以作为DiffTest的REF! 不过目前我们还是先关注如何实现一个简单的模拟器.
你已经设计过CPU了, 无论是用Logisim设计还是用RTL设计, 其过程都是用数字电路的状态机实现ISA的状态机. 要用C语言来实现模拟器, 其实需要考虑的是如何用C程序的状态机实现ISA的状态机. 为此, 我们先回顾状态机视角下的C程序和ISA:
| C程序 | ISA | |
|---|---|---|
| 状态 | ||
| 激励事件 | 执行语句 | 执行指令 |
| 状态转移规则 | 语句的语义 | 指令的语义 |
要用C程序的状态机实现ISA的状态机, 我们需要开发一个包含如下功能的C程序:
- 用C程序的状态实现ISA的状态, 也即, 用C程序的变量实现ISA的PC, GPR和内存
- 用C程序的状态转移规则实现ISA的状态转移规则, 也即, 用C语言语句实现指令的语义
这种仅仅从ISA层面实现指令行为的模拟器, 称为指令集模拟器. 类似地, 还有架构模拟器和电路模拟器, 不过我们暂时不会接触它们.
下面以sISA为例说明如何实现指令集模拟器, 我们称相应的指令集模拟器为sEMU(simple EMUlator). 关于sISA的细节, 请查阅之前的讲义.
sEMU要用C程序的变量实现ISA的PC, GPR和内存, 这并不难:
// sEMU.c
#include <stdint.h>
uint8_t PC = 0;
uint8_t R[4];
uint8_t M[256];
其中, PC的位宽有8位, 说明sISA的程序最多只能包含256条指令, 因此用于实现内存的数组M的大小只需设置为256即可.
sEMU要用C语言语句实现指令的语义, 这也不难. 从指令周期的角度考虑, 需要编写一个函数inst_cycle()实现如下功能:
- 取指 - 直接根据
PC索引内存M, 即可取出一条指令 - 译码 - 通过C语言的位运算抽取出指令的
opcode字段, 并检查属于哪一条指令; 然后根据指令格式抽取出操作数字段, 并获得相应的操作数 - 执行 - 若执行的指令不是
bner0, 则将结果写回目的寄存器; 否则, 根据判断情况决定是否进行跳转 - 更新PC - 若不跳转, 则让
PC加1
实现inst_cycle()后, 只需要让sEMU不断调用它即可:
while (1) { inst_cycle(); }
上文已经考虑了sISA的所有细节, 但要让程序在sEMU中运行起来, 还需要考虑如何将程序放置到M中. 但因为sEMU只打算运行数列求和这一个程序, 我们可以直接对M进行初始化:
uint8_t M[16] = { ... };
此外由于目前sEMU中没有设备的概念, 因此无法正确运行包含io指令的程序. 我们可以不实现io指令的具体行为, 如果需要执行io指令, 只需要更新PC即可. 不过目前我们只打算运行数列求和程序, 因此不必考虑其他程序的运行.
实现sEMU
根据上述思路, 用C代码实现sEMU, 并运行之前的数列求和程序. 由于数列求和程序本身不会结束, 你可以修改while语句的循环条件, 在循环一定的次数后退出循环, 然后检查数列求和的结果是否符合预期.
对比sEMU和sCPU
针对sISA, 你已经分别实现了sEMU和sCPU. 尝试对比一下, 它们之间有什么不同?
让sCPU和sEMU进行DiffTest
有了sEMU作为REF, 我们就可以考虑让sCPU和sEMU进行DiffTest了. 具体地, 在仿真过程中, sCPU执行一条指令后, 马上让sEMU也执行一条指令, 然后获得sCPU和sEMU的GPR, 检查它们的值是否一样. 若两者GPR的值不同, 则输出错误信息并停止仿真.
// 以下为伪代码
while (???) {
dut_single_cycle();
ref_inst_cycle();
uint8_t *dut_regs = &top->rootp->NPC__DOT__gpr_ext__DOT__Memory;
uint8_t *ref_regs = ref_get_regs();
int is_diff = check_regs(dut_regs, ref_regs);
if (is_diff) {
printf("GPR different\n");
printf("Simulation stop\n");
break;
}
}
为了实现上述功能, 你需要:
- 去掉sEMU的
main()函数, 将源文件的后缀从.c修改为.cpp, 这是为了方便让sEMU链接到sCPU的仿真环境中. - 将
sEMU.cpp添加到verilator命令的文件列表中. - 理解上述伪代码后, 改写仿真过程的
while循环, 并实现缺失的函数- 其中, 伪代码中的
&top->rootp->NPC__DOT__gpr_ext__DOT__Memory用于通过Verilator编译出的C++文件来访问GPR. 具体的C++变量名与Verilog中的模块名和变量名有关, 可阅读编译出的C++头文件得知. 不过C++变量名可能会在修改RTL代码或更改Verilator版本时发生变化, 需要手动同步修改.
- 其中, 伪代码中的
让sCPU和sEMU进行DiffTest
按照上述步骤, 在sCPU的仿真环境中添加DiffTest机制. 添加后, 尝试在sCPU中注入一些错误, 观察DiffTest能否按照预期地报告错误.
完善报错信息
上文提供的报错信息非常模糊, 并不利于问题的诊断. 尝试修改代码, 输出报错时的PC值, 以及存储内容不同的GPR.
让DiffTest检查PC值
由于上述代码只检查GPR是否相同, 但bner0指令只会修改PC. 当bner0指令的跳转目标不正确时, 上述代码可能无法及时发现错误.
尝试修改代码, 把PC值纳入比较的范围. 然后尝试往bner0指令的实现中注入一些错误, 观察DiffTest能否及时发现.
恭喜你, 你已经基本完成了一个简单CPU的全流程设计了!
新需求 -> 架构设计 -> 逻辑设计 -> 功能验证 -> 电路评估
- sCPU设计的需求就是用RTL实现一个sISA的处理器
- F阶段的讲义内容已经帮助大家梳理了如何通过数字电路的功能来实现sCPU, 这其实已经完成了架构设计, 其产出就是Logisim中的电路结构图
- 大家用RTL设计sCPU的过程, 就是逻辑设计的过程
- 用Verilator验证你的RTL代码能否成功运行
1+2+...+10, 就是在进行功能验证; 而让sCPU和sEMU进行DiffTest, 则是在更细粒度的指令层次来进行功能验证 - 关于电路评估的步骤, 目前暂未涉及, 我们将在E阶段的最后进行介绍
当然, sCPU的功能距离我们想要实现的CPU还差很远, 因此目前我们暂不要求大家去关注sCPU实现的好坏. 从项目流程上来看, 我们需要先设计出一个功能相对完整的CPU, 再考虑如何优化它, 这也是为了践行"先完成, 后完美"的法则. 后续的讲义内容将会按照上述流程, 指导大家如何设计功能更强大的处理器, 从而运行更复杂的程序.
