引言

  • 大家已经学会Linux系统的操作
  • 接下来我们就可以体验处理器芯片设计的全流程了!

 

本次课内容:

  • 以8条指令的minirv指令集为例, 简单介绍处理器芯片设计全流程
    • 包括需求分析, 架构设计, 逻辑设计, 系统软件, 输入输出, 功能验证, 性能评估, SoC集成, 电路评估, 物理设计

minirv指令集

sISA的局限性

我们之前对sISA进行扩展, 缓解了部分问题

但扩展后的sISA v2.0仍然具有局限性:

  • PC寄存器的位宽只有8位
    • 程序最多只能包含256条指令
  • GPR的位宽只有8位
    • 无法表示大于255的数据
  • 指令的功能有限
    • 无法进行减法操作, 更不用说乘法和除法

 

要运行更多程序, 就要增加PC和GPR的位宽, 并添加更多功能的指令

  • 基本上等同于换一个ISA

RISC-V指令集

RISC-V是近十年流行起来的开放指令集架构

  • 采用模块化思想, 把指令划分成不同模块
    • 除了基础指令集RV32I, 还有乘除, 浮点, 原子操作等多种指令扩展
  • 开发者可以根据自身需求选择零个或多个扩展

 

有了sISA的认识, 可以很容易通过RISC-V官方手册了解RISC-V指令集

  1. PC寄存器的位宽是多少?
  2. GPR共有多少个? 每个GPR的位宽是多少?
  3. R[0]和sISA的R[0]有什么不同之处?
  4. 指令编码的位宽是多少? 指令有多少种基本格式?
  5. 在指令的基本格式中, 需要多少位来表示一个GPR? 为什么?
  6. add指令的格式具体是什么?
  7. 还有一种基础指令集称为RV32E, 它和RV32I有什么不同?

迷你RISC-V指令集

RISC-V的32位整数基础指令集RV32I有42条指令

 

我们选取其中的一部分子集, 提出一个迷你RISC-V指令集minirv

  • PC初值为0
  • GPR数量与RV32E中定义的GPR数量一致
  • 支持如下8条指令: add, addi, lui, lw, lbu, sw, sb, jalr
  • 其他的ISA细节与RV32I相同

 

我们在编译层次研发了一些黑科技

  • 可以将其他常用的34条RISC-V指令编译到上述8条指令
    • 也即, 用上述8条指令实现其他34条指令的功能
  • 不必实现完整的42条常用RISC-V指令, 也能让CPU运行更复杂的程序!

需求分析 - 理解minirv指令的行为

理解指令行为的两种方式

  1. RTFM
    • 官方手册中包含你需要了解的所有细节
    • 在第34章RV32/64G Instruction Set Listings中有一些指令表

 

但带着编程任务去RTFM, 效果更好

 

  1. 实现指令集模拟器
    • 用C语言实现minirv指令的行为
    • 将模拟器称为minirvEMU
    • 比在数字电路层次实现minirv处理器更简单
      • 后者需要考虑数字电路层次的细节

sEMU - 一个简单的sISA v1.0指令集模拟器

#include <stdint.h>
#include <stdio.h>
uint8_t PC = 0;
uint8_t R[4];
uint8_t M[16] = {
  0b10001010, // li r0, 10
  0b10010000, // li r1, 0
  0b10100000, // li r2, 0
  0b10110001, // li r3, 1
  0b00010111, // add r1, r1, r3
  0b00101001, // add r2, r2, r1
  0b11010001, // bner0 r1, 4
  0b11011111, // bner0 r3, 7
};
void inst_cycle() {
  uint8_t inst = M[PC];
  if (((inst >> 6) & 0x3) == 0) { // add
    R[(inst >> 4) & 0x3] = R[(inst >> 2) & 0x3] + R[inst & 0x3];
  } else if (((inst >> 6) & 0x3) == 2) { // li
    R[(inst >> 4) & 0x3] = inst & 0xf;
  } else if (((inst >> 6) & 0x3) == 3) { // bner0
    if (R[0] != R[inst & 0x3]) { PC = (inst >> 2) & 0xf; return; }
  } else { printf("Unsupported instuction\n"); }
  PC += 1;
}
int main() {
  for (int i = 0; i < 100; i ++) { inst_cycle(); }
  printf("r2 = %d\n", R[2]);
  return 0;
}

在minirvEMU中实现addi指令

  1. 取指
  • 存储器位宽在ISA层次和具体实现层次都存在
    • 在ISA层次, PC寄存器是以\(w_{ISA}\)为单位寻址
    • 在模拟器的具体实现中, 如果代码中M的位宽\(w_{M} \ne w_{ISA}\), 则不能直接用PC进行寻址

 

  1. 操作码译码
  • minirv指令数量少, 编码稀疏, 用译码器反而不方便
  • 可以直接对比指令中的操作码字段是否与addi指令的编码一致
// 以下为伪代码
is_addi = (inst[6:0] == ?) && (inst[14:12] == ?)

在minirvEMU中实现addi指令(2)

  1. 操作数译码
  • 立即数需要符号扩展
  • R[0]和sISA不同, 需要考虑

 

  1. 执行
  • 直接用+

 

  1. 更新PC
  • 指令编码的位宽与sISA不同, 需要考虑如何更新

 

各种细节的考虑, 作为编程作业留给大家思考

实现更多指令

  • jalr
    • 无条件跳转, 比bner0更简单
    • 但跳转目标的计算方式和bner0不同
    • 而且还会写GPR

 

  • add
    • 和sISA的add类似

 

  • lui
    • 和sISA的li类似, 只是立即数的格式不同

存储器和访存指令

在ISA的概念中, 和GPR相比, 存储器空间更大, 但访问速度更慢

 

为了控制CPU访问存储器的数据, 需要添加访存指令

  • load类指令 - 将数据从内存读入GPR
    • RV32I提供lw, lh, lb, 分别用于从存储器中读入4/2/1字节
      • lh和lb要对读入的数据进行符号扩展
    • 还有lhu和lbu, 对读入的数据进行零扩展
  • store类指令 - 将数据从GPR写入内存
    • RV32I提供sw, sh, sb, 分别用于向存储器中写入4/2/1字节

 

访存指令需要指定一个存储器地址

  • 在RISC-V中, 通过访存指令中的寄存器(基址)和立即数(偏移量)来指定
lw r0, 4(r1)   # R[r0] <= M[R[r1] + 4]

字节序

通常来说, ISA层次定义的存储器是按字节寻址的

  • 每个内存地址对应1个字节
  • 对于多字节的数据, 如何在存储器中存放, 就成了一个需要考虑的问题

 

字节序 - 多字节数据在存储器中的存放顺序

  • 小端(little-endian) - 低地址存低位字节, 高地址存高位字节
  • 大端(big-endian) - 低地址存高位字节, 高地址存低位字节
data = 0x12345678
             +------+------+------+------+
             |      |      |      |      |
             +------+------+------+------+
address          x     x+1    x+2    x+3
little-endian  0x78   0x56   0x34   0x12
big-endian     0x12   0x34   0x56   0x78

小端 vs. 大端

小端的优势:

  • 按不同长度的数据类型解释同一个数据时, 无需移动字节
    • C语言的强制类型转换, union的实现
      x                            x     x+1    x+2    x+3
  +------+   little-endian     +------+------+------+------+
  | 0x12 |  ---------------->  | 0x12 | 0x00 | 0x00 | 0x00 |
  +------+                     +------+------+------+------+
  | 0x12 |  ---------------->  | 0x00 | 0x00 | 0x00 | 0x12 |
  +------+     big-endian      +------+------+------+------+

 

  • 算术运算的进位方向与地址方向一致, 可从低地址开始计算
    • 以前的8位机/16位机, 读到低位字节就可以开始计算
      • 和大端相比性能更高, 成为了多数CPU采用小端的一个原因
    • 高精度大整数库也采用小端存储

小端 vs. 大端(2)

大端的优势:

  • 符合人的书写和阅读习惯, 调试时更直观
    • 网络传输通常使用大端
      • 只传输不计算, 小端的优势无法发挥
  • 可以快速判断一个数的符号

 

小结:

  • 从低位开始的运算,小端有优势
  • 只涉及到高位的运算,或可读性更重要,则大端有优势

RISC-V中的字节序

数据的存储

  • 字节序可根据不同场景来选择
    • 网络传输等场景可选择大端, 但还是小端用得更多

 

指令的存储

  • 以16位包为单位的小端存储
    • 仅读出低地址的若干位, 就能判断指令的长度
                        +----------------+
                        |xxxxxxxxxxxxxxaa|    16-bit (aa != 11)
       +----------------+----------------+
       |xxxxxxxxxxxxxxxx|xxxxxxxxxxxbbb11|    32-bit (bbb != 111)
-------+----------------+----------------+
...xxxx|xxxxxxxxxxxxxxxx|xxxxxxxxxx011111|    48-bit
-------+----------------+----------------+
...xxxx|xxxxxxxxxxxxxxxx|xxxxxxxxx0111111|    64-bit
-------+----------------+----------------+
...xxxx|xxxxxxxxxxxxxxxx|xnnnxxxxx1111111|    (80+16*nnn)-bit, nnn != 111
-------+----------------+----------------+
...xxxx|xxxxxxxxxxxxxxxx|x111xxxxx1111111|    Reserved for >= 192-bits
-------+----------------+----------------+
     base+4           base+2            base

架构设计 - 从需求到NPC图纸

回顾 - CPU设计过程

  1. 分析指令的预期行为
    • 在需求分析阶段已经完成
  1. 设计数据通路 - 根据指令的行为, 在数据流动的方向上依次添加所需的部件
  1. 设计控制逻辑和控制信号 - 当多条指令的数据通路出现冲突时, 需要引入一些额外的电路, 来控制数据如何流动

 

和sCPU相比, minirv需要额外考虑访存指令的数据通路 - 需要添加RAM

  • 以及相应的控制信号

 

作为编程作业留给大家思考

模块化设计

和sCPU不同, 将来还需要不断改进NPC, 添加更多功能

  • 例如, 在D阶段中, 你将会在NPC中实现约40条指令
  • 因此有必要维护好NPC项目, 为将来的改进做好准备
    • 维护代码的一个方式就是模块化

 

根据minirv指令的功能, 结合处理器的工作流程给NPC划分模块:

  • IFU(Instruction Fetch Unit): 根据当前PC从存储器中取出一条指令
  • IDU(Instruction Decode Unit): 对当前指令进行译码, 准备执行阶段需要使用的数据和控制信号
  • EXU(EXecution Unit): 根据控制信号控制ALU, 对数据进行计算
  • LSU(Load-Store Unit): 根据控制信号控制存储器, 从存储器中读出数据, 或将数据写入存储器
  • WBU(WriteBack Unit): 将数据写入寄存器, 并更新PC

逻辑设计 - 按照图纸编写RTL

Verilog的本质是什么?

是RTL设计语言?

 

不, Verilog的本质是一门事件建模语言

Originally, Verilog was only intended to describe and allow simulation;
  • 不信的话RTFM, 然后搜索synthesis
    • 正文中只有少数几处相关的描述

 

用Verilog来设计可综合电路是后来的事情

  • 但很多建模用的语法无法对应到RTL电路, 例如延迟# 30
  • 于是大家从Verilog中划出一部分子集, 赋予它们可综合的含义

用建模方式设计可综合电路

  • 结构化建模 - 实例化, 端口连线
  • 数据流建模
    • assign - 连线
    • 运算符 - 功能部件
      • *, /和%一般不用, 它们综合出的是在一个周期内得到结果的复杂电路, 面积, 功耗, 延迟都很大

 

仅靠结构化建模和数据流建模无法描述状态

  • 为了描述时序逻辑元件, 只能用行为建模
always @(...) begin
  // 基于事件模型的编程
end

 

但行为建模并不直观, 初学者很容易产生误解

问题1 - RTL设计过程不直观

行为建模的设计过程:

  1. RTL设计者看着电路图的结构
  1. 心里想象电路的行为
  1. 用事件模型的方式重新思考电路行为
  1. 用行为建模方式来描述事件模型
  1. 综合器根据行为建模的描述推导出相应的电路

 

从第2步就已经开始偏离RTL设计的初衷:

  • 都已经有电路图的结构了, 直接描述电路结构不就行了吗 😂
  • 绕这么大一个弯, 白白增加出错的风险
    • 行为想错了, 事件模型思考错了, 行为建模代码写错了, 综合器不支持

问题2 - 让初学者忽略电路设计的本质

行为建模的设计过程:

  1. RTL设计者看着电路图的结构
  2. 心里想象电路的行为
  3. 用事件模型的方式重新思考电路行为
  4. 用行为建模方式来描述事件模型
  5. 综合器根据行为建模的描述推导出相应的电路

 

更可怕的是跳过第1步, 直接从第2步开始

  • 拍脑袋想一个行为, 仿真过了就交给综合器来生成电路
    • 有点HLS(高层次综合)的味道了
    • 然而大多时候只会得到PPA质量很低的电路

问题3 - 把老师绕晕, 让学生从课堂开始误入歧途

你可以很容易在课本等学习资料中找到类似的描述:

在Verilog中,各语句是并发执行的,模块中所有的assign语句、always语句块和实例化语句,其执行顺序不分
先后。而if语句是顺序执行的语句,其执行过程中必须先判断if后的条件,如果满足条件则执行if后的语句,否则
执行else后的语句。Verilog语法规定,顺序执行的语句必须包含中always块中,always块中的语句按照它们
在代码中出现的顺序执行。

灵魂拷问:

  • 在RTL设计中, 什么叫执行?
  • 是谁在执行Verilog的语句? 是电路,综合器,还是其他的?
  • if的条件满足, 就不执行else后的语句, 这里的不执行又是什么意思? 和电路有什么联系?
  • 有并发执行, 又有顺序执行, 还有任何一个变量发生变化就立即执行, 以及在任何情况下都执行, 它们都是如何在电路中体现的?

 

估计大部分教Verilog的老师, 甚至是一些从业者, 都无法对这些问题作出明确的回答 😂

远离行为建模, 回归RTL设计的本质

RTL设计 = 直接描述电路结构 = 实例化 + 连线

  • 想象有一个空白的电路板, 还有一张设计图纸
  • 实例化 = 按照图纸拿一个元件(门电路, 触发器…)放在电路板上
  • 连线 = 按照图纸用导线将这些元件的引脚连起来

我们让大家在F阶段用Logisim设计电路, 就是希望大家理解上述本质

 

  • 用数据流建模和结构化建模就可以实现上述本质
    • 为了支持触发器/存储器, 可以写一个参数化的module
      • 内部用always实现(简单可控), 但通过结构化建模屏蔽实现细节
  • 少用或不用行为建模
    • 有的企业限制always中的编码风格, 有的企业甚至不允许写always
      • 例如蜂鸟E203的处理器代码中没有任何always语句
    • 如果你用Chisel, 也不建议用when和switch

一个参数化的触发器模板

// 触发器模板
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

远离行为建模: 硬件设计和软件设计并不相同

软件设计 硬件设计
图 算法流程图 电路结构图
代码 描述算法流程 描述电路结构
多行代码间的关系 反映流程的先后顺序 反映部件之间的连接关系
代码的作用 生成程序来执行 生成电路版图来制造
模块的作用 调用子程序 实例化子部件

 

* 在RTL设计中, 什么叫"执行"?
* 是谁在执行Verilog的语句? 是电路,综合器,还是其他的?
* "if的条件满足, 就不执行else后的语句", 这里的"不执行"又是什么意思? 和电路有什么联系?
* 有"并发执行", 又有"顺序执行", 还有"任何一个变量发生变化就立即执行", 以及"在任何情况下都执行",
  它们都是如何在电路中体现的?

行为建模的水很深, 我们会在D阶段进一步介绍

  • 介绍目的: 让大家更深刻地明白为什么要远离行为建模

给初学者的建议: 如果设计电路时大脑蹦出执行这个词, 你就输了

另一个话题 - 存储器的实现

我们会在将来接入SoC时使用可以流片的真实存储器

  • 因此目前实现一个简单的存储器即可
    • 反正将来都要换的
  • 甚至没必要用RTL实现
    • 可以通过DPI-C机制, 让Verilog代码访问用C代码实现的仿真环境
import "DPI-C" function int pmem_read(input int raddr);
import "DPI-C" function void pmem_write(
  input int waddr, input int wdata, input byte wmask);
always @(*) begin
  rdata = (valid ? pmem_read(raddr) : 0);
  if (wen && valid) pmem_write(waddr, wdata, wmask);
end
extern "C" int pmem_read(int raddr) {
  // 总是读取地址为`raddr & ~0x3u`的4字节返回
}
extern "C" void pmem_write(int waddr, int wdata, char wmask) {
  // 总是往地址为`waddr & ~0x3u`的4字节按写掩码`wmask`写入`wdata`
  // `wmask`中每比特表示`wdata`中1个字节的掩码,
  // 如`wmask = 0x3`代表只写入最低2个字节, 内存中的其它字节保持不变
}

另一个话题 - 存储器的实现(2)

extern "C" int pmem_read(int raddr) {
  // 总是读取地址为`raddr & ~0x3u`的4字节返回
}
extern "C" void pmem_write(int waddr, int wdata, char wmask) {
  // 总是往地址为`waddr & ~0x3u`的4字节按写掩码`wmask`写入`wdata`
  // `wmask`中每比特表示`wdata`中1个字节的掩码,
  // 如`wmask = 0x3`代表只写入最低2个字节, 内存中的其它字节保持不变
}

这两个内存读写函数模拟了32位总线的行为

  • 只支持地址按4字节对齐的读写
  • 其中读操作总是返回按4字节对齐读出的数据
    • 需要由RTL代码根据读地址选择出需要的部分

 

这样做的原因:

  • 将来在实现总线之前, 可以通过行为模型简单地模拟一些设备的行为
  • 将来在实现总线的时候不必改动太多的代码

系统软件 - 支撑程序在NPC上运行

在NPC上运行程序

完成逻辑设计后, 接下来就是要在NPC上运行程序

 

考虑求和程序, 如何将其运行在NPC上?

  1. 如何将程序编译成minirv的指令序列?
  2. 如何将编译出的指令序列放置在存储器中?
  3. 如何向程序提供printf()库函数的功能?
  4. 如何向程序提供动态内存管理的功能?
    • printf()可能需要调用malloc()
  5. 从main()函数返回后, 如何指示程序结束?
#include <stdio.h>
int main() {
  int i = 1;
  int sum = 0;
  while(i <= 100) {
    sum += i;
    i ++;
  }
//printf("%d\n", sum);
  return 0;
}

 

我们提供一个用于构建程序的项目abstract-machine(简称AM)

  • AM = 裸机运行时环境, 为程序直接运行在NPC上提供必要支持
  • 从概念上来说, AM属于系统软件

AM裸机运行时环境简介

  1. 如何将程序编译成minirv的指令序列?
    • 先通过riscv64-linux-gnu-gcc将C代码编译成RISC-V汇编代码
    • 然后利用汇编层次的宏定义, 将其他指令展开成8条minirv指令
    • 最后将宏展开后的汇编代码转换成二进制的指令序列
  1. 如何将编译出的指令序列放置在存储器中?
    • 在仿真环境中通过文件操作, 将编译出的指令序列读入存储器中
  1. 如何向程序提供printf()库函数的功能?
    • 提供klib库函数的实现, 以及putch()用于输出一个字符
  1. 如何向程序提供动态内存管理的功能?
    • 提供堆区的范围和malloc()的实现
  1. 从main()函数返回后, 如何指示程序结束?
    • 在halt()中执行ebreak指令, 通知仿真环境结束仿真

我们会在D阶段中进一步介绍AM的细节

输入输出 - 让程序和外界交互

程序和输入输出

借助AM, 在不超过内存容量的前提下, 你可以在NPC上运行各种纯计算的程序

  • 各种C语言程序设计的作业
    • 排序, 阶乘, 斐波那契数列, 矩阵乘法, 素数判定, 字符串处理…
  • 所有在理论上可计算的程序, 都能运行

 

但类似输出hello信息这种简单的程序, 却无法运行

  • 要支持程序输出信息, 就要在系统中添加输入输出机制

 

你在F阶段已经了解, 用户是通过输入输出设备和处理器进行交互的

  • sISA提供了一条专门进行输入输出的io指令
  • 而在RISC-V中, 输入输出是通过内存映射I/O(Memory-mapped I/O)方式来进行的

内存映射I/O

考虑指定设备的方式:

  • sISA的io指令通过指令中的idx设备编号来指示一个设备
    • 这种方式又叫端口映射I/O(Port-mapped I/O)
  • 内存映射I/O是通过访存指令中的地址来指示设备
    • 根据访存地址的范围来决定是访问存储器, 还是其他设备

 

假设地址0x10000000对应串口的输出数据寄存器

  • 以下指令序列可以往串口输出一个字符A:
lui t0, 0x10000    # t0 = 0x10000000
addi t1, zero, 65  # 65 = 'A'的ASCII编码
sb t1, 0(t0)

上述指令序列看上去和将字符A写入存储器并无本质区别

通过C代码实现内存映射I/O

lui t0, 0x10000    # t0 = 0x10000000
addi t1, zero, 65  # 65 = 'A'的ASCII编码
sb t1, 0(t0)

 

在C代码中, 可以通过指针来实现类似的功能:

void putch(char c) {
  volatile char *p = (volatile char *)0x10000000ul;
  *p = c;
}

volatile是C语言中的一个关键字

  • 用于要求编译器不要对相应的指针访问过程进行优化
  • 对访问设备的过程进行优化, 通常会带来一些非预期的问题
    • 关于volatile的进一步细节, 我们会在后续阶段讲述

使用行为模型实现串口的功能

为了让程序可以使用串口进行输出, 还需要在NPC中添加一个串口

  • 不过对目前来说, 只需在仿真环境中添加一个串口的简单行为模型:
extern "C" void pmem_write(int waddr, int wdata, char wmask) {
  if (waddr == 0x10000000) {  // 写入UART
    fputc(wdata & 0xff, stderr);   // 在stdio.h中定义
    return;
  }
  // 写入存储器数组
}

上述行为模型通过if语句实现内存映射I/O的功能

  • 在进行存储器的写操作时, 如果目标地址是0x10000000, 就写入串口
    • 通过fputc()输出wdata低8位所表示的字符, 模仿UART输出的行为
  • 接入SoC时再引入真实的串口控制器

 

有了串口, 就可以在NPC上运行各种需要输出信息的程序了

  • hello, 各种benchmark(只看对错, 不评分), 甚至是AI大模型(无交互)

为串口添加状态寄存器

对于真实的UART来说, 在输出一个字符之前, 还需要检查状态是否就绪

  • 如果在状态未就绪时就尝试输出字符, 该字符将会丢失

 

查询UART是否就绪是通过读出UART的状态寄存器来实现的:

extern "C" int pmem_read(int raddr) {
  if (raddr == 0x10000004) {  // 读出UART状态
    return (rand() & 0x7) == 0 ? 1 : 0; // 就绪概率为12.5%
  }
  // 读存储器数组
}

上述行为模型约定, 可以从地址0x10000004读出UART的状态

  • 状态寄存器的行为由随机数模拟: 有12.5%的概率读出1, 表示就绪

 

还需要修改AM中putch()的实现, 在输出字符前查询UART状态

  • 若状态就绪, 则输出字符并返回
  • 若状态未就绪, 则再次查询, 直到就绪为止

使用行为模型实现时钟

行为模型让仿真环境通过gettimeofday()来获取当前的系统时间

 

在AM中实现读取时钟的函数, 在函数中访问时钟的行为模型, 就可以让NPC上的程序具备计时功能

unsigned long long get_time() {
  // 返回从仿真开始所经过的时间, 单位为微秒
  // 可通过gettimeofday()来实现
  return 0;
}
extern "C" int pmem_read(int raddr) {
  // ...
  // 读出时钟的低32位
  else if (raddr == 0x20000000) {
    return get_time() & 0xffffffff; }
  // 读出时钟的高32位
  else if (raddr == 0x20000004) {
    return get_time() >> 32; }
  // 读出存储器数组
}
  • 运行benchmark时可以输出有意义的时间或分数
[RESULT] PASS with min time:   0:00:01.698185 (1698185 us)
              25 Marks
  vs.    1000000 Marks @ AMD Ryzen(TM) 9 7950X3D
Scored time:   0:00:01.698185 (1698185 us)
Total  time:   0:00:01.704395 (1704395 us)
  • 甚至能运行红白机游戏!

功能验证 - 测试NPC功能是否正确

功能验证的两种方法

  1. 测试(testing)
  • 查看系统在特定输入(即测试用例)的情况下, 行为是否符合预期
    • 单元测试 - 与具体模块相关
    • 集成测试 - AM上的各种小规模程序, riscv-tests
    • 系统测试 - 红白机游戏, RT-Thread

通常来说, 通过测试无法说明系统没有bug

  • 需要通过全覆盖的测试(枚举)才能说明, 但开销通常无法承受

 

  1. 证明(proving)
  • 形式化验证 - 证明一个命题是否在所有情况下都成立
    • 覆盖所有可能性, 最终要么证明成立, 要么给出反例
    • 无需测试用例
    • 需要把什么是正确用命题表达出来

对NPC进行功能验证

目前我们采用简单的测试方法: 查看程序在NPC上的运行结果是否正确

  • AM上的很多程序, 都容易观测运行结果的正确性
  • 相比之下, 单元测试需要自行编写并观测正确性
    • 目前系统也并不复杂, 单元测试的作用不明显
      • 不过对于香山这种乱序超标量的复杂处理器, 单元测试就很重要了

 

如果程序的运行结果不正确, 如何高效地定位错误?

  • 特别是那些运行了很多指令的程序
    • 如果你将来设计的NPC有1000个信号, 运行某个复杂程序时, 在100000个周期后发现程序运行结果不正确
      • 波形文件中将会包含1000x100000=1亿个信号值
      • 如何从中快速找到若干个关键的信号值, 来诊断RTL的问题?

处理器的高效验证方法 - DiffTest

启发: 在处理器上运行程序, 就是在处理器上执行一条条指令的过程

  • 一个程序在RTL实现的NPC上运行, 和在C语言实现的指令集模拟器中运行, 行为应当一致
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;
  }
}

基本思想: 对比每条指令执行后, NPC和模拟器的寄存器状态是否一致

  • 如果不一致, 就马上报错 - 找到第一条行为不一致的指令

 

实现模拟器不仅可以帮助我们理解指令行为, 还能作为测试的金标准

总结

以minirv指令集为例的处理器芯片设计全流程(上)

  • 需求分析 - 理解minirv指令的行为
    • 通过实现指令集模拟器理解minirv指令的行为
  • 架构设计 - 从需求到NPC图纸
    • 设计数据通路 + 设计控制逻辑和控制信号
    • 模块化设计
  • 逻辑设计 - 按照图纸编写RTL
    • 建议初学者少用或不用行为建模
  • 系统软件 - 支撑程序在NPC上运行
    • 需要AM裸机运行时环境, 为程序运行在NPC上提供必要支持
  • 输入输出 - 让程序和外界交互
    • 通过行为模型实现内存映射I/O和简单设备(串口, 时钟)的功能
  • 功能验证 - 测试NPC功能是否正确
    • 程序级测试(AM上的程序), 指令级测试(和指令集模拟器DiffTest)