「palab01」开天辟地

本文最后更新于:2023年4月15日 晚上

1 | 安装和配置wsl

用了一晚上 + 一上午的时间安装和配置wsl

发现不能这样下去了, 得尽快开始写内容, 不然linux是永远折腾不完的, 插件啥的就不倒腾了, 效率低点就低点吧

2 | ISA的选择

由于pa具有多主线的特性, 可以自由选择ISA完成后面的实验

ISA: Instruction set architecture, 指令集架构

如果你打算选熟悉的, 那就选x86, 毕竟ICS理论课主要围绕x86开展. 但你多半会被x86指令的复杂性折磨半死, 而且x86的最终性能其实并不高, 不能流畅地展示游戏的运行.

如果你打算选简单的, 那就选riscv32, 你将会体会到什么是”优雅的ISA设计”. 由于riscv32的简单, 你可以比较轻松地获得近乎x86两倍的性能, 有着不错的展示效果.

如果你接下来打算设计一款riscv64的硬件处理器, 那就选riscv64, 你将会体会到DiffTest是如何帮你大幅提升硬件开发效率, 告别枯燥的波形调试.

如果你打算挑战极限, 那就选mips32: 相比于以上两者, 选择mips32需要了解更多细节才能正确构建出完整的计算机系统. 因此mips32仅供喜欢挑战, 或者攻略二周目的同学选择.

不过无论你选哪种ISA, 有一点是共通的, 那就是RTFM, 因为ISA的本质是规范手册. 另外, NEMU程序本身也是x86的(准确来说是x64), 不会随着你选择的ISA而变化, 变化的只是在NEMU中模拟的计算机.

我最后选择 => rsicv32, 因为我喜欢优雅的ISA设计(简单万岁)

3 | 程序与状态机

graph LR
A[程序] --> B[时序逻辑部件]
A --> C[组合逻辑部件]
C --> D(加法器等)
B --> E(存储器, 计数器, 寄存器)
graph LR
BB(时序逻辑部件的状态1) -->|组合逻辑部件作用| AA(时序逻辑部件状态2)

既然计算机是一个数组逻辑电路, 那么我们可以把计算机划分成两部分, 一部分由所有时序逻辑部件(存储器, 计数器, 寄存器)构成, 另一部分则是剩余的组合逻辑部件(如加法器等). 这样以后, 我们就可以从状态机模型的视角来理解计算机的工作过程了: 在每个时钟周期到来的时候, 计算机根据当前时序逻辑部件的状态, 在组合逻辑部件的作用下, 计算出并转移到下一时钟周期的新状态.

4 | 框架代码初探

架构对ISA的支持:

NEMU把ISA相关的代码专门放在nemu/src/isa/目录下, 并通过nemu/include/isa.h以及nemu/include/isa/$ISA.h这两个头文件提供ISA相关API的声明. 这样做有两点好处:

  • 有助于我们认识不同ISA的共同点: 无论是哪种ISA的客户计算机, 它们都具有相同的基本框架
  • 体现抽象的思想: 框架代码将ISA之间的差异抽象成API, 基本框架会调用这些API, 从而无需关心ISA的具体细节. 如果你将来打算选择一个不同的ISA来进行二周目的攻略, 你就能明显体会到抽象的好处了: 基本框架的代码完全不用修改

vscode函数跟踪相关

记录一些常用操作

方式 作用
vscode ctrl + 左键 文件和函数跳转
alt + <-, alt + -> 返回 / 跳转
ctrl + shift + o 列出该文件下的函数和全局变量名
ctrl + p 近期打开文件名
ctrl + tab 文件快速切换
alt + 数字键 按标签栏位置切换文件

从哪里开始阅读?

那必然是main.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
void init_monitor(int, char *[]);
void engine_start();
int is_exit_status_bad();

int main(int argc, char *argv[]) {
/* Initialize the monitor. */
init_monitor(argc, argv);

/* Start engine. */
engine_start();

return is_exit_status_bad();
}

跟踪到init_monitor中:

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
void init_monitor(int argc, char *argv[]) {
/* Perform some global initialization. */

/* Parse arguments. */
parse_args(argc, argv);

/* Open the log file. */
init_log(log_file);

/* Fill the memory with garbage content. */
init_mem();

/* Perform ISA dependent initialization. */
init_isa();

/* Load the image to memory. This will overwrite the built-in image. */
long img_size = load_img();

/* Compile the regular expressions. */
init_regex();

/* Initialize the watchpoint pool. */
init_wp_pool();

/* Initialize differential testing. */
init_difftest(diff_so_file, img_size, difftest_port);

/* Display welcome message. */
welcome();
}

各个函数的用途(只列出部分, 后面的palab中说暂时不必理解)

函数 作用
parse_args() 解析参数 (调用了getopt_long)
init_log() 初始化日志系统 (但是static char *log_file = NULL;)说明日志目前并不存在, 估计是要以后自己实现?
init_mem() 把内存乱填满(p[i] = rand();) (==如果不填会咋样?==)
init_isa() ISA相关的初始化工作
load_img() 这个函数会将一个有意义的客户程序从镜像文件读入到内存, 覆盖刚才的内置客户程序. 这个镜像文件是运行NEMU的一个可选参数, 在运行NEMU的命令中指定. 如果运行NEMU的时候没有给出这个参数, NEMU将会运行内置客户程序.

其中init_isa()解释如下:

1
2
3
4
5
6
7
8
void init_isa() {
/* Load built-in image. ==> 将客户程序读到固定的内存位置即IMAGE_START
为了让客户计算机的CPU可以执行客户程序, 因此我们需要一种方式让客户计算机的CPU知道客户程序的位置. 我们采取一种最简单的方式: 约定.*/
memcpy(guest_to_host(IMAGE_START), img, sizeof(img));

/* Initialize this virtual computer system. ==> 初始化寄存器, 见下面 */
restart();
}

其中restart():

1
2
3
4
5
6
7
8
9
10
11
12
13
static void restart() {
/*
Set the initial program counter.
cpu.pc的初值, 我们需要将它设置成刚才加载客户程序的内存位置, 这样就可以让CPU从我们约定的内存位置开始执行客户程序了.
*/
cpu.pc = PMEM_BASE + IMAGE_START;

/*
The zero register is always 0.
对于mips32和riscv32, 它们的0号寄存器总是存放0, 因此我们也需要对其进行初始化.
*/
cpu.gpr[0]._32 = 0;
}

问题- 为什么全部都是函数

为什么全部都是函数?

阅读init_monitor()函数的代码, 你会发现里面全部都是函数调用. 按道理, 把相应的函数体在init_monitor()中展开也不影响代码的正确性. 相比之下, 在这里使用函数有什么好处呢?

封装性和可重用啦

随便跟踪一个函数即可发现比如这里的init_isa()对应不同的ISA有不同的实现, 直接展开必然不合适

选择ISA

nemu/目录下make ISA=$ISA run

我选择risv32, 所以make ISA=riscv32 run

$ISA=riscv32

在选择ISA后, main.c调用engine_start()

1
2
3
4
5
6
7
void engine_start() {
/* Initialize devices. */
init_device();

/* Receive commands from user. */
ui_mainloop();
}

其中ui_mainloop:

用户界面主循环是monitor的核心功能, 我们可以在命令提示符中输入命令, 对客户计算机的运行状态进行监控和调试. 框架代码已经实现了几个简单的命令, 它们的功能和GDB是很类似的.

问题-究竟要执行多久

究竟要执行多久?

cmd_c()函数中, 调用cpu_exec()的时候传入了参数-1, 你知道这是什么意思吗?

观察到cpu_exec函数入参类型为uint64_t, 即传入的-1变成了无符号11111111111111111111111..这是个非常大的数(win下打印出来是18446744073709551615), 1e19级别, 估计cpu一秒钟执行1e9, 估计要执行到1e10秒也就是100000+天, 所以效果等同于死循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* Simulate how the CPU works. */
void cpu_exec(uint64_t n) {
switch (nemu_state.state) {
case NEMU_END: case NEMU_ABORT:
printf("Program execution has ended. To restart the program, exit NEMU and run again.\n");
return;
default: nemu_state.state = NEMU_RUNNING;
}

uint64_t timer_start = get_time();

for (; n > 0; n --) {
// ...
}

调试宏

下面是三个调试的宏, 位于nemu/include/debug.h

  • Log()printf()的升级版, 专门用来输出调试信息, 同时还会输出使用Log()所在的源文件, 行号和函数. 当输出的调试信息过多的时候, 可以很方便地定位到代码中的相关位置
  • Assert()assert()的升级版, 当测试条件为假时, 在assertion fail之前可以输出一些信息
  • panic()用于输出信息并结束程序, 相当于无条件的assertion fail
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define Log(format, ...) \
_Log("\33[1;34m[%s,%d,%s] " format "\33[0m\n", \
__FILE__, __LINE__, __func__, ## __VA_ARGS__)

#define Assert(cond, ...) \
do { \
if (!(cond)) { \
fflush(stdout); \
fprintf(stderr, "\33[1;31m"); \
fprintf(stderr, __VA_ARGS__); \
fprintf(stderr, "\33[0m\n"); \
extern void isa_reg_display(); \
extern void monitor_statistic(); \
isa_reg_display(); \
monitor_statistic(); \
assert(cond); \
} \
} while (0)

#define panic(...) Assert(0, __VA_ARGS__)

内存模拟

内存通过在nemu/src/memory/paddr.c中定义的大数组pmem来模拟

1
static uint8_t pmem[PMEM_SIZE] PG_ALIGN = {};

其中在paddr.h中可以发现\#define PMEM_SIZE (128 * 1024 * 1024)

即内存大小为8bit * 1024 * 1024 * 128 = 128MB

内存的读写

在客户程序运行的过程中, 总是使用vaddr_read()vaddr_write() (在nemu/include/memory/vaddr.h中定义)来访问模拟的内存. vaddr, paddr分别代表虚拟地址和物理地址

vaddr_readvaddr_write

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
static inline word_t vaddr_read(vaddr_t addr, int len) {
word_t vaddr_read1(vaddr_t addr);
word_t vaddr_read2(vaddr_t addr);
word_t vaddr_read4(vaddr_t addr);
#ifdef ISA64
word_t vaddr_read8(vaddr_t addr);
#endif
switch (len) {
case 1: return vaddr_read1(addr);
case 2: return vaddr_read2(addr);
case 4: return vaddr_read4(addr);
#ifdef ISA64
case 8: return vaddr_read8(addr);
#endif
default: assert(0);
}
}

static inline void vaddr_write(vaddr_t addr, word_t data, int len) {
void vaddr_write1(vaddr_t addr, word_t data);
void vaddr_write2(vaddr_t addr, word_t data);
void vaddr_write4(vaddr_t addr, word_t data);
#ifdef ISA64
void vaddr_write8(vaddr_t addr, word_t data);
#endif
switch (len) {
case 1: vaddr_write1(addr, data); break;
case 2: vaddr_write2(addr, data); break;
case 4: vaddr_write4(addr, data); break;
#ifdef ISA64
case 8: vaddr_write8(addr, data); break;
#endif
default: assert(0);
}
}

然后看看这个vaddr_read1248是咋实现的, 跟一下发现有这么个宏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

#define def_vaddr_template(bytes) \
word_t concat(vaddr_ifetch, bytes) (vaddr_t addr) { \
int ret = isa_vaddr_check(addr, MEM_TYPE_IFETCH, bytes); \
if (ret == MEM_RET_OK) return paddr_read(addr, bytes); \
return 0; \
} \
word_t concat(vaddr_read, bytes) (vaddr_t addr) { \
int ret = isa_vaddr_check(addr, MEM_TYPE_READ, bytes); \
if (ret == MEM_RET_OK) return paddr_read(addr, bytes); \
return 0; \
} \
void concat(vaddr_write, bytes) (vaddr_t addr, word_t data) { \
int ret = isa_vaddr_check(addr, MEM_TYPE_WRITE, bytes); \
if (ret == MEM_RET_OK) paddr_write(addr, data, bytes); \
}


def_vaddr_template(1)
def_vaddr_template(2)
def_vaddr_template(4)
#ifdef ISA64
def_vaddr_template(8)
#endif

paddr_readpaddr_write

1
2
3
4
5
6
7
8
9
inline word_t paddr_read(paddr_t addr, int len) {
if (in_pmem(addr)) return pmem_read(addr, len);
else return map_read(addr, len, fetch_mmio_map(addr));
}

inline void paddr_write(paddr_t addr, word_t data, int len) {
if (in_pmem(addr)) pmem_write(addr, data, len);
else map_write(addr, data, len, fetch_mmio_map(addr));
}

pmem_readpmem_write

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
static inline word_t pmem_read(paddr_t addr, int len) {
void *p = &pmem[addr - PMEM_BASE];
switch (len) {
case 1: return *(uint8_t *)p;
case 2: return *(uint16_t *)p;
case 4: return *(uint32_t *)p;
#ifdef ISA64
case 8: return *(uint64_t *)p;
#endif
default: assert(0);
}
}

static inline void pmem_write(paddr_t addr, word_t data, int len) {
void *p = &pmem[addr - PMEM_BASE];
switch (len) {
case 1: *(uint8_t *)p = data; return;
case 2: *(uint16_t *)p = data; return;
case 4: *(uint32_t *)p = data; return;
#ifdef ISA64
case 8: *(uint64_t *)p = data; return;
#endif
default: assert(0);
}
}

那么调用顺序很明确了, vaddr(虚拟地址)->paddr(物理地址)->pmem(内存操作)

解析命令

鉴于文档中要求阅读readlinestrtok的man文档, 这里大概阅读一下以防后面实验听天书

strtok

1
2
#include <string.h>
char *strtok(char *str, const char *delim);

用法(from man strtok)

The strtok() function breaks a string into a sequence of zero or more nonempty tokens. On the first call to strtok(), the string to be parsed should be specified in str. In each subsequent call that should parse the same string, str must be NULL

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main() {
char str[80] = "123xx456xx789";
const char delim[3] = "xx";
char *token;
token = strtok(str, delim);
while (token != NULL) {
printf("%s\n", token);
token = strtok(NULL, delim);
}
return 0;
}
/* 输出:
123
456
789
*/

readline

使用: char* readline (const char *prompt)

readline will read a line from the terminal and return it, using prompt as a prompt. If prompt is NULL or the empty string, no prompt is issued. The line returned is allocated with malloc(3); the caller must free it when finished. The line returned has the final newline removed, so only the text of the line remains.

即readline使用prompt作为提示, 返回readline读入的字符串

有什么用?

提供行编辑功能, 还有记录历史的能力, 事实上, shell就是利用readline工作的

来自一天后的我

妈蛋, 原来这里要实现命令解析啊!

我就说实现完表达式求值后怎么怪怪的…

原来是压根没有实现命令, 啊啊啊我还在改框架代码进行调试, 还觉得怎么这么麻烦…

教训: 文档都不好好看你还做什么实验!


「palab01」开天辟地
https://blog.roccoshi.top/posts/22335/
作者
RoccoShi
发布于
2021年8月14日
许可协议