E6 简易运行时环境

你已经用RTL实现了minirv NPC, 但它只能运行一些很简单的程序. 为了运行更多复杂的程序, 我们需要编译出可以运行在minirv上的程序. 为此, 我们提供了一个用于构建程序的项目abstract-machine(简称AM). 这个项目提供了一个裸机运行时环境, 为程序直接运行在处理器上(即裸机环境)提供了一些必要的支持.

体验AM

我们先来让你体验一个经典的游戏. 在这之前, 你需要先安装好python:

apt install python python-is-python3

运行红白机游戏

阅读PA1的在开始愉快的PA之旅之前->NEMU是什么?开头部分的讲义内容, 按照讲义指示尝试运行红白机游戏, 并完成画面, 按键和声音的检查.

你一定会对红白机游戏具体如何运行感到好奇. 事实上, 游戏本身也是一个指令序列, 不过其中的指令属于一种叫6502的指令集. 这种指令集是上世纪80年代的时候问世的, 现在已经很少使用, 相应的处理器也不那么常见了. 但你刚才并没有在真正的6502处理器上运行游戏, 那游戏究竟是如何运行起来的呢?

刚才其实是先运行了一个6502指令集的模拟器程序fceux, 这个程序的行为十分特殊, 它可以通过软件的行为模拟6502指令执行的过程, 从而模拟出整个游戏游戏执行的过程! 因此, 从本质上来说, fceux的工作过程和你之前开发的sEMUminirvEMU是一样的!

事实上, AM的设计十分巧妙: 我们可以通过提供不同的ARCH参数, 来让同一个程序编译到不同的平台上. 例如, ARCH=native表示将程序编译到Linux本地. 我们也可以将fceux编译到minirv上, 从而在你设计的minirv NPC上运行红白机游戏!

不过在AM项目中, minirv相关的运行时环境并不完善, 让我们先来完善它. 目前你只需要按照下文的操作来编译程序即可, 无需理解AM中的细节, 我们会在D阶段进一步讲解AM的详细内容.

运行第一个编译的程序

我们首先来编译并运行一个简单的C程序. 这个简单的C程序是am-kernels/tests/cpu-tests/tests/dummy.c, 阅读它的代码, 你会发现它什么都不做就直接返回了.

为了编译这个dummy程序, 首先你需要安装交叉编译工具链:

apt install g++-riscv64-linux-gnu

然后在am-kernels/tests/cpu-tests/目录下尝试编译它:

make ARCH=riscv32-nemu ALL=dummy

如果发生编译报错, 你需要参考PA2的RTFM->运行第一个C程序中的修复riscv32编译错误, 来尝试修复相关的问题, 然后重新输入上述make命令来编译. 如果编译成功, 你可以通过ls build/命令看到如下3个文件:

dummy-riscv32-nemu.bin
dummy-riscv32-nemu.elf
dummy-riscv32-nemu.txt

上述操作只是为了确认RISC-V工具链可以成功运行. 现在我们来将dummy程序编译到minirv上. 首先先清除刚才产生的编译结果:

make clean

然后输入以下命令:

make ARCH=minirv-npc ALL=dummy

如果编译成功, 你可以通过ls build/命令看到如下3个文件:

dummy-minirv-npc.bin
dummy-minirv-npc.elf
dummy-minirv-npc.txt

其中:

  • dummy-minirv-npc.bin是程序的二进制表示.
  • dummy-minirv-npc.txt是程序的反汇编结果, 你可以从中查看指令的汇编表示. 我们刚才提到, dummy程序本质上是个空函数, 但如果你阅读反汇编结果, 你会发现编译出的程序还包含很多指令. 事实上, 这些指令都属于运行时环境的部分, 用于支持程序的运行.
  • dummy-minirv-npc.elf是程序的ELF形式, 目前你不必关心它.

我们在NPC运行程序前, 会把程序的指令序列以初始化的形式手动编写到NPC的存储器数组中. 随着编译出的程序越来越复杂, 对存储器数组进行手动初始化, 效率将会越来越低. 为了提高仿真环境装入程序的效率, 我们可以让仿真环境通过文件方式, 将程序的二进制文件读入到NPC的存储器数组中.

和之前在NPC上运行的程序不同, AM中的minirv-npc运行时环境约定程序从0x80000000开始. 虽然不是强制的, 但RISC-V处理器通常把0x80000000以下的地址空间留给设备使用. 我们会在下文继续讨论设备的话题. 总之, 为了在minirv NPC中运行dummy程序, 你还需要:

  1. 将NPC的PC复位值修改为0x80000000
  2. 修改pmem_read()pmem_write(), 根据参数addr距离0x80000000的偏移来访问存储器数组

在minirv NPC上运行dummy程序

根据上述要求修改NPC和仿真环境, 让仿真环境将dummy程序读入到存储器中, 然后让NPC开始执行dummy程序. 我们建议你将仿真环境的存储器数组改为128MB, 这个大小足够容纳将来的各种测试程序.

如果你的实现正确, dummy程序会在halt()函数附近陷入死循环.

搭建批量运行程序的流程

很快你就需要运行很多程序, 来对NPC进行更深入的测试. 如果每次都要手动指定需要运行的程序, 并手动判断程序运行的结果是否正确, 效率是十分低下的. 因此, 你需要搭建一个支持程序批量自动运行的流程, 来帮助你高效地运行各种程序.

我们之前约定, 程序执行ebreak指令后就会结束. 但AM上编译出来的程序并不包含ebreak指令, 而在仿真环境中手动为每一个程序插入ebreak指令是一种效率低下的做法. 事实上, 我们可以将这条ebreak指令插入到minirv的运行时环境中. 这样以后, 通过AM编译到minirv上的所有程序, 都会自动包含ebreak指令.

但AM中的halt()函数是C代码, 为了在其中插入我们期望的指令, 我们可以使用内联汇编在新窗口中打开语句:

--- abstract-machine/am/src/riscv/npc/trm.c
+++ abstract-machine/am/src/riscv/npc/trm.c
@@ -17,3 +17,4 @@
 void halt(int code) {
+  asm volatile("ebreak");
   while (1);
 }

编译出包含ebreak指令的程序

修改代码后, 重新编译dummy程序并运行. 如果你的实现正确, dummy程序将会自动结束, 而不再陷入死循环.

然后, 我们来考虑如何自动判断程序执行是否正确. 为此, 我们约定在程序结束前, 先将一个表示结束状态的整数写入a0寄存器, 然后执行ebreak指令. 这个整数如果为0, 表示程序正确结束; 若不为0, 表示程序发生错误.

为了实现这个约定, 一方面, 我们需要修改程序结束时的指令序列, 保证在执行ebreak指令之前a0寄存器存放了程序的结束状态. 我们只需要对上述内联汇编指令稍作修改即可: asm volatile("mv a0, %0; ebreak" : :"r"(code));. 修改后, 内联汇编会在执行ebreak指令前, 先将表示程序结束状态的code变量的值移动到a0寄存器中.

另一方面, 在NPC执行ebreak后, 仿真环境可以通过检查a0寄存器的值, 来查看程序是否成功结束. 例如, 若a00, 则输出HIT GOOD TRAP的信息, 表示程序成功结束; 若a0不为0, 则输出HIT BAD TRAP的信息, 表示程序发生错误.

让仿真环境输出程序结束信息

按照上文的要求, 修改内联汇编语句和仿真环境.

修改代码后, 重新编译dummy程序并运行, 你将看到仿真环境输出HIT GOOD TRAP的信息. 尝试在相同的目录下编译运行wrong程序, 你将看到仿真环境输出HIT BAD TRAP的信息.

为了运行wrong程序, 你可能需要手动修改仿真环境中指定的程序文件路径. 如果要频繁切换程序, 手动修改文件路径是很低效的. 为此, 我们可以将程序文件的路径作为启动仿真时的一个命令行参数. 这样以后, 只需要通过命令行参数给出不同的文件路径, 就能在仿真过程中运行不同的程序.

进一步地, 我们可以将启动仿真时的命令与Makefile关联起来, 使得在运行类似的make run命令时能自动进行仿真. 具体地, 你需要在abstract-machine/scripts/platform/npc.mk中实现一条run规则, 使得键入make ARCH=minirv-npc ALL=dummy run后可以自动编译并在NPC中运行dummy程序. 此外, 只要将ALL=dummy改为ALL=wrong, 就能编译并运行wrong程序. 接下来, 你将会多次运行各种程序来测试你的NPC在添加新功能后是否正确, 这种批量运行程序的功能可以大大提升测试运行的效率.

让run规则自动进行仿真

根据上文的要求添加相关代码, 使得Makefile可以通过run规则启动仿真过程.

Hint: 可以在Makefile中使用$(IMAGE).bin来指代编译生成的二进制文件的路径.

不过, 通过make ARCH=native ALL=wrong run来运行wrong程序时, 结果显示FAIL; 而通过make ARCH=minirv-npc ALL=wrong run来运行wrong程序时, 结果却显示PASS. 按照上文提到的程序结束状态的约定, wrong程序应该总是以错误的状态结束运行, 因此在minirv-npc上运行后显示PASS, 并不符合预期.

事实上, 这是因为仿真环境在发现程序以a0不为0的状态运行ebreak指令时, 并没有将"程序运行出错"的信息返回给启动仿真的Makefile, 导致Makefile认为仿真结果正确, 从而显示PASS. 为了修复这个问题, 我们需要让仿真环境根据程序的运行结果, 从main()函数返回不同的值: 当程序运行成功时, 返回0; 当程序运行出错时, 返回非零值.

让Makefile得知仿真结果是否正确

修改仿真环境的代码,使其根据程序运行结果返回不同的值. 修改后通过make ARCH=minirv-npc ALL=wrong run来运行wrong程序, 你应该看到结果却显示FAIL.

运行更多程序

搭建好批量运行程序的流程后, 我们就可以用这个流程方便地运行更多程序, 来测试你的NPC了.

首先是riscv-tests:

cd ysyx-workbench
bash init.sh riscv-tests
cd riscv-tests
make ARCH=minirv-npc run TEST_ISA=i # 运行RV32I的所有测试
make ARCH=minirv-npc run ALL=addi   # 只运行addi测试

虽然上述测试和RV32I有关, 其中测试的对象包含很多不属于minirv的指令, 但AM的构建过程保证最终生成的二进制文件中仅包含minirv的指令, 因此riscv-tests同样能用于当前的minirv NPC.

运行riscv-tests

运行riscv-tests, 测试你的NPC是否正确.

和之前你运行的程序相比, 这些测试程序的规模更大. 当测试出错时, 你会发现调试的难度开始上升. 为了应对这个问题, 我们可以在仿真环境中添加之前介绍的DiffTest机制.

添加DiffTest机制

添加DiffTest机制, 让NPC和minirvEMU进行对比, 从而帮助你快速找到错误的指令. 当DiffTest发现指令的执行结果不同时, 你需要让仿真环境返回非零值, 来让Makefile捕捉到DiffTest报告的错误.

实现后, 尝试在NPC中注入一些错误, 观察DiffTest和Makefile能否按预期报错.

然后是cpu-tests, 不过其中的某些测试依赖于klib库函数的实现. 目前我们不要求你实现这些库函数, 我们准备了一个编译好的klib在新窗口中打开, 下载后解压缩, 将其中的klib-minirv-npc.a放置到abstract-machine/klib/build/目录下. 若该目录不存在, 你需要先手动创建它; 若该文件已存在, 你需要覆盖它.

准备好klib后, 就可以来编译并运行cpu-tests了:

cd am-kernels/tests/cpu-tests
make ARCH=minirv-npc run          # 运行所有测试
make ARCH=minirv-npc run ALL=fib  # 只运行fib测试

运行cpu-tests

运行cpu-tests, 测试你的NPC是否正确.

添加UART支持字符输出

你在F阶段已经了解, 用户是通过输入输出设备和处理器进行交互的. 在F阶段中, sISA提供了一条专门进行输入输出的io指令. 而在RISC-V中, 输入输出是通过"内存映射I/O"(Memory-mapped I/O)方式来进行的. 具体地, RISC-V的load指令和store指令, 既可以访问存储器, 也可以访问外部设备. 至于要访问何者, 是根据访存地址的范围来决定的. minirv也采用这种方式.

例如, 假设地址0x10000000对应串口的输出数据寄存器. 那么, 以下指令序列可以往串口输出一个字符A:

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

可以看到, 上述指令序列与"将字符A写入存储器"并无本质区别. 在C代码中, 我们可以通过指针来实现类似的功能:

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

其中volatile是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;
  }
  // 写入存储器数组
}

这个行为模型很简单, 在进行存储器的写操作时, 如果写入的目标地址是0x10000000, 就通过fputc()输出wdata低8位所表示的字符, 从而模仿UART输出的行为. 这个简单的行为模型有助于你理解内存映射I/O的本质. 我们会在接入SoC的时候再介绍真实的串口控制器.

添加UART行为模型

abstract-machine/am/src/riscv/npc/trm.c中实现putch()函数, 然后在仿真环境中添加UART的行为模型, 然后运行hello程序:

cd am-kernels/kernels/hello
make ARCH=minirv-npc run

你可以先在native上查看预期的运行效果.

对于DiffTest, 你可以参考类似上文的方式, 在minirvEMU中识别出写入串口的store指令, 然后忽略它.

有了UART, 我们就可以来运行一些需要输出字符的程序了.

运行benchmark

尝试运行am-kernels/benchmarks/目录下的benchmark. 你可以先在native上运行它们, 来查看预期的运行效果. 由于minirv的性能并不高, 我们建议按照如下方式来缩短仿真的时间:

  • 对于dhrystonecoremark, 你可以修改代码, 将迭代次数改为2
  • 对于microbench, 通过mainargs=test来指定采用小规模的输入
    make ARCH=minirv-npc run mainargs=test
    

由于目前我们还无法在NPC中测量时间, 因此目前可以先忽略程序输出的耗时或分数.

我们甚至还能在NPC上运行LLaMa大模型! 我们将LLaMa大模型移植到另一套benchmark中, 你需要先获取相关代码:

cd ysyx-workbench
bash init.sh archbench

然后可以在native上先运行它:

cd archbench/bench/202.llama2
make ARCH=native run mainargs=test

archbench中共有4种输入规模, 从小到大分别是test, train, ref, huge. 对于LLaMa程序来说, test规模采用的模型文件很小, 精度很低, 因此难以输出一句逻辑通顺的话, 只能输出一个词Once. 你可以尝试其他输入规模: 输入规模越大, LLaMa程序采用的模型文件越大, 输出的句子越有逻辑, 但程序运行的时间也越长. 由于处理器的仿真效率和真机相比低几个数量级, 因此在NPC中通常运行testtrain规模就足够了, 其中test规模主要用于检查功能正确性, train规模主要用于进行一定的性能评估. 我们会在下文继续讨论性能评估的话题.

运行LLaMa大模型

尝试在NPC上运行LLaMa大模型, 检查运行结果是否正确. 如果你有耐心, 你可以尝试train规模, 它需要大概5分钟才能运行结束.

运行benchmark(2)

运行archbench中的其他benchmark, 检查运行结果是否正确. archbench提供了一个并行运行的脚本, 你需要先安装一些工具:

apt install time parallel python3-numpy

然后运行脚本:

cd archbench/scripts
bash run.sh ARCH=minirv-npc mainargs=test

对于真实的UART来说, 在输出一个字符之前, 还需要检查设备的状态是否就绪. 如果在UART的状态未就绪的时候就输出字符, 该字符将会丢失. 查询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, 表示就绪.

为UART添加状态寄存器

根据上文, 在UART的行为模型中添加状态寄存器. 然后修改putch()函数, 在输出字符之前查询UART的状态, 若状态就绪, 则输出字符并返回; 若状态未就绪, 则再次查询, 直到就绪为止.

特别地, 对于DiffTest, 你还需要识别用于查询UART状态的load指令, 并将读出的状态拷贝到minirvEMU中, 使得NPC和minirvEMU在执行同一条load指令后, 读出的数据保持一致.

添加时钟支持计时功能

和UART类似, 我们也可以在仿真环境中添加一个时钟的简单行为模型:

unsigned long long get_time() {
  // 返回从仿真开始所经过的时间, 单位为微秒
  // 可通过gettimeofday()来实现
  return 0;
}

extern "C" int pmem_read(int raddr) {
  if (raddr == 0x10000004) { /* ... */ } // 读出UART状态
  // 读出时钟的低32位
  else if (raddr == 0x20000000) { return get_time() & 0xffffffff; }
  // 读出时钟的高32位
  else if (raddr == 0x20000004) { return get_time() >> 32; }
  // 读出存储器数组
}

添加时钟

根据上文, 在仿真环境中添加时钟的行为模型. 然后实现abstract-machine/am/src/riscv/npc/timer.c中的__am_timer_uptime()函数, 通过访问时钟的行为模型, 返回微秒数.

实现后, 尝试运行时钟测试:

cd am-kernels/tests/am-tests
make ARCH=minirv-npc run mainargs=t

如果你的实现正确, 你将看到程序每经过1秒钟就输出一句话.

对于DiffTest, 你可以参考UART状态寄存器那样, 来处理那些访问时钟行为模型的load指令.

运行benchmark(3)

重新运行之前的benchmark, 你将看到程序输出有意义的时间或分数.

运行红白机游戏

现在, 你也可以尝试在NPC上运行红白机游戏了! 不过由于目前NPC并未连接键盘和显示器, 我们只能以字符模式观看画面的变化. 具体地, 按如下方式修改FCEUX的配置:

--- fceux-am/src/config.h
+++ fceux-am/src/config.h
@@ -1,5 +1,5 @@
 #ifndef __CONFIG_H__
 #define __CONFIG_H__

-#define HAS_GUI
+//#define HAS_GUI
 #define SIZE_OPT

修改后, 编译并在NPC上运行. 你可以缩小终端的字体大小, 以获得较好的视觉效果.

目前NPC在仿真环境中运行程序的效率并不高, 即使接入了键盘, 游戏体验也不好. 因此, 我们目前将红白机游戏作为对NPC的一项功能测试即可. 我们会在后续阶段提升红白机游戏的体验.

性能评测入门

评估NPC的性能

尝试在archbench中通过脚本运行train规模的测试. 脚本会自动计算每项测试的分数以及它们的算术平均和几何平均, 其中, 几何平均通常作为性能评测的最终结果.

以后, 我们会将archbenchtrain规模作为默认的测试程序来进行性能评测, 你可以将脚本运行的结果整理到表格中, 来观察系统变化时对性能评测结果的量化影响.

可以将test规模的运行情况作为性能评测的结果吗?

通常, 计算机的工作是不断处理重复的任务. 在这个意义下, 计算机的性能表现就是计算机在一定负载下处理这些重复任务的性能表现. 因此, 性能评测的每一项程序都应该具备一定的代表性, 可以代表计算机将来需要重复处理的任务. 性能评测其实就是测试目标系统在这些代表性场景下的性能表现.

test规模通常是为了快速测试功能正确性而存在的, 对应的程序输入规模通常很小, 各种循环的次数也很小, 负载很弱, 因此从性能评测的角度来说并不具备代表性.

NPC中流逝的时钟

如果你仔细思考, 你就会发现, 虽然程序输出了分数, 但这些分数并无参考意义. 这是因为, 我们期望benchmark评估NPC的性能表现, 应当使用NPC中流逝的时钟来计时; 但gettimeofday()返回的是主机的系统时间, 它反应的是真实世界流逝的时间.

为了修复这个问题, 你可以修改上文中get_time()函数的实现. 具体地, 你需要在仿真环境中记录已经仿真了多少个周期, 然后根据NPC的频率将周期数换算成对应的微秒数. 不过, NPC的频率和电路的时序表现有关, 它可以通过EDA工具的评估来获得. 为了简单起见, 目前可以假设NPC的工作频率为100MHz.

评估NPC的性能(2)

修改get_time()函数的实现, 然后重新运行archbench. 此时, 你看到输出的分数, 将表示NPC工作在100MHz时的性能表现.

评估NPC的性能(3)

假设NPC的工作频率为1000MHz, 修改get_time()函数的实现, 然后重新运行archbench. 你预计此时跑分大约是多少? 尝试和输出的跑分进行对比, 验证你的想法是否正确.

通过综合工具获取NPC的频率

建设中

最近更新时间:
贡献者: Zihao Yu