--- title: nju pa2 categories: [lab,nju-pa] --- > 其他资料: > > > > ![](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/2290847-20230126224635360-2075265859.png) # RTFM ## 运行第一个客户程序 第一个客户程序即文档所说的`dummy.c`,键入命令后,会将`dummy.c`编译成基于rv64指令的二进制格式文件(后缀名为 .bin),作为nemu模拟器的镜像文件(img_file) ``` shell make ARCH=$ISA-nemu ALL=dummy run ``` ### 实现指令 首先查看反汇编结果,看看需要实现哪些指令 ``` asm 0000000080000000 <_start>: 80000000: 00000413 li s0,0 80000004: 00009117 auipc sp,0x9 80000008: ffc10113 addi sp,sp,-4 # 80009000 <_end> 8000000c: 00c000ef jal ra,80000018 <_trm_init> 0000000080000010
: 80000010: 00000513 li a0,0 80000014: 00008067 ret 0000000080000018 <_trm_init>: 80000018: ff010113 addi sp,sp,-16 8000001c: 00000517 auipc a0,0x0 80000020: 01c50513 addi a0,a0,28 # 80000038 <_etext> 80000024: 00113423 sd ra,8(sp) 80000028: fe9ff0ef jal ra,80000010
8000002c: 00050513 mv a0,a0 80000030: 00100073 ebreak 80000034: 0000006f j 80000034 <_trm_init+0x1c> ``` 通过查询手册,可以知道`li` `ret`等指令都是伪指令,比如第一条指令 ``` assembly 80000000: 00000413 li s0,0 ``` 其十六进制内容为`0x00000413`,对应二进制为`0000 0000 0000 0000 0000 0100 0001 0011`,查询手册可知其为`addi`指令,而当伪指令`li`中的立即数小于4096时,的确会被编译器展开为`addi`指令,因此目前可以确定,反汇编结果的右边的指令只是为了方便阅读(生成反汇编指令的代码在`disasm.cc`中),我们在实现指令功能时,只需要看左边的十六进制内容即可 按照框架和文档提示,以及根据手册查询每条指令干了什么,然后填写代码即可 ``` c enum { TYPE_I, TYPE_U, TYPE_S, TYPE_J, TYPE_N, // none }; #define immJ() do { *imm = SEXT(( \ (BITS(i, 31, 31) << 19) | \ BITS(i, 30, 21) | \ (BITS(i, 20, 20) << 10) | \ (BITS(i, 19, 12) << 11) \ ) << 1, 21); Log(ANSI_FG_CYAN "%#lx\n" ANSI_NONE, *imm); } while(0) static void decode_operand(Decode *s, int *dest, word_t *src1, word_t *src2, word_t *imm, int type) { // ... switch (type) { // ... case TYPE_J: immJ(); break; } } static int decode_exec(Decode *s) { // ... INSTPAT("??????? ????? ????? 000 ????? 00100 11", addi , I, R(dest) = src1 + imm); INSTPAT("??????? ????? ????? ??? ????? 11011 11", jal , J, s->dnpc = s->pc; s->dnpc += imm; R(dest) = s->pc + 4); INSTPAT("??????? ????? ????? 000 ????? 11001 11", jalr , I, s->dnpc = (src1 + imm) & ~(word_t)1; R(dest) = s->pc + 4); // ... return 0; } ``` # 程序,运行时环境与AM ## 实现字符串处理函数 根据前面的铺垫,这部分比较简单,就不贴代码了,只需要: 1. 在`abstract-machine/klib/src/string.c`中实现`string.c`测试用例中使用到的库函数,如`strcmp` 2. 对照`string-riscv64-nemu.txt`的反汇编结果或运行中的错误提示,查询手册,在`inst.c`中补充未实现的指令,如`bne` ## 实现sprintf 实现步骤与上一part如出一辙:实现库函数并补充指令。其中库函数`sprintf`实现: ``` c static void reverse(char *s, int len) { char *end = s + len - 1; char tmp; while (s < end) { tmp = *s; *s = *end; *end = tmp; } } /* itoa convert int to string under base. return string length */ static int itoa(int n, char *s, int base) { assert(base <= 16); int i = 0, sign = n, bit; if (sign < 0) n = -n; do { bit = n % base; if (bit >= 10) s[i++] = 'a' + bit - 10; else s[i++] = '0' + bit; } while ((n /= base) > 0); if (sign < 0) s[i++] = '-'; s[i] = '\0'; reverse(s, i); return i; } int sprintf(char *out, const char *fmt, ...) { va_list pArgs; va_start(pArgs, fmt); char *start = out; for (; *fmt != '\0'; ++fmt) { if (*fmt != '%') { *out = *fmt; ++out; } else { switch (*(++fmt)) { case '%': *out = *fmt; ++out; break; case 'd': out += itoa(va_arg(pArgs, int), out, 10); break; case 's': char *s = va_arg(pArgs, char*); strcpy(out, s); out += strlen(out); break; } } } *out = '\0'; va_end(pArgs); return out - start; } ``` 此时的实现只能支持通过`hello-str`测试用例,其它功能(包括%x/%p/%..,位宽, 精度等)可以在将来需要的时候再自行实现 # 基础设施(2) ## itrace 我将itrace归为工具类,因此相关功能代码放在`nemu/src/utils/itrace.c`下 ### 实现iringbuf `ItraceNode`记录单条指令的`pc`和内容,`iringbuf`是节点的环形缓冲区。给外界提供两个函数来对缓冲区进行"存取",`trace_inst`进行"存",`display_inst`则负责"取"(展示经过反汇编的指令到屏幕上) 通过修改宏`MAX_IRINGBUF`的值,来指定最大可存放指令条数 ``` c #include #define MAX_IRINGBUF 16 typedef struct { word_t pc; uint32_t inst; } ItraceNode; ItraceNode iringbuf[MAX_IRINGBUF]; int p_cur = 0; bool full = false; void trace_inst(word_t pc, uint32_t inst) { iringbuf[p_cur].pc = pc; iringbuf[p_cur].inst = inst; p_cur = (p_cur + 1) % MAX_IRINGBUF; full = full || p_cur == 0; } void display_inst() { if (!full && !p_cur) return; int end = p_cur; int i = full?p_cur:0; void disassemble(char *str, int size, uint64_t pc, uint8_t *code, int nbyte); char buf[128]; // 128 should be enough! char *p; Statement("Most recently executed instructions"); do { p = buf; p += sprintf(buf, "%s" FMT_WORD ": %08x ", (i+1)%MAX_IRINGBUF==end?" --> ":" ", iringbuf[i].pc, iringbuf[i].inst); disassemble(p, buf+sizeof(buf)-p, iringbuf[i].pc, (uint8_t *)&iringbuf[i].inst, 4); if ((i+1)%MAX_IRINGBUF==end) printf(ANSI_FG_RED); puts(buf); } while ((i = (i+1)%MAX_IRINGBUF) != end); puts(ANSI_NONE); } ``` 对于这两个函数在哪里调用,由于需要记录导致程序出错的指令,因此"存"的时机要在其取指之后执行之前: ``` c int isa_exec_once(Decode *s) { s->isa.inst.val = inst_fetch(&s->snpc, 4); IFDEF(CONFIG_ITRACE, trace_inst(s->pc, s->isa.inst.val)); return decode_exec(s); } ``` 而对于出错后展示缓冲区,即"取"操作,暂时放在了`cpu-exec.c`中的`assert_fail_msg`中,这样在物理内存访问越界时(`paddr_read`/`paddr_write`)会展示最近执行的指令。 放在这里是否欠妥未知,以后有问题再换个合适的地方调用 ``` c void assert_fail_msg() { display_inst(); isa_reg_display(); statistic(); } ``` ### 实现mtrace 在`itrace.c`中添加 ``` c void display_pread(paddr_t addr, int len) { printf("pread at " FMT_PADDR " len=%d\n", addr, len); } void display_pwrite(paddr_t addr, int len, word_t data) { printf("pwrite at " FMT_PADDR " len=%d, data=" FMT_WORD "\n", addr, len, data); } ``` 在访存函数中调用 ``` c word_t paddr_read(paddr_t addr, int len) { IFDEF(CONFIG_MTRACE, display_pread(addr, len)); if (likely(in_pmem(addr))) return pmem_read(addr, len); IFDEF(CONFIG_DEVICE, return mmio_read(addr, len)); out_of_bound(addr); return 0; } void paddr_write(paddr_t addr, int len, word_t data) { IFDEF(CONFIG_MTRACE, display_pwrite(addr, len, data)); if (likely(in_pmem(addr))) { pmem_write(addr, len, data); return; } IFDEF(CONFIG_DEVICE, mmio_write(addr, len, data); return); out_of_bound(addr); } ``` 对于只追踪某一段内存区间的访问的功能,目前没有这个需求,需要到的话可以在`Kconfig`添加一个区间选项或`sdb.c`中添加一条命令,用来在调试中随时更改区间 ### 实现ftrace 首先在`itrace.c`中添加两个函数,分别用来追踪函数调用和返回,其中`ftrace_write`暂时定义为`log_write`,即输出到log文件中 ``` c void trace_func_call(paddr_t pc, paddr_t target) { if (symbol_tbl == NULL) return; ++call_depth; if (call_depth <= 2) return; // ignore _trm_init & main int i = find_symbol_func(target, true); ftrace_write(FMT_PADDR ": %*scall [%s@" FMT_PADDR "]\n", pc, (call_depth-3)*2, "", i>=0?symbol_tbl[i].name:"???", target ); } void trace_func_ret(paddr_t pc) { if (symbol_tbl == NULL) return; if (call_depth <= 2) return; // ignore _trm_init & main int i = find_symbol_func(pc, false); ftrace_write(FMT_PADDR ": %*sret [%s]\n", pc, (call_depth-3)*2, "", i>=0?symbol_tbl[i].name:"???" ); --call_depth; } ``` 其中,根据文档的提示,`call`(调用)和`ret`(返回)都需要记录指令所在的地址,用参数`pc`表示。`call`的第二个参数`target`表示被调用函数的首地址,如以下函数调用中,`pc=0x8000000c, target=0x80000260 ` ``` 0x8000000c: call [_trm_init@0x80000260] ``` 而记录函数调用和返回则在`jal`与`jalr`这两个指令执行时调用,具体原因可自行了解指令作用及查看汇编代码验证 ``` c INSTPAT("??????? ????? ????? ??? ????? 11011 11", jal , J, s->dnpc = s->pc + imm; IFDEF(CONFIG_ITRACE, { if (dest == 1) { // x1: return address for jumps trace_func_call(s->pc, s->dnpc, false); } }); R(dest) = s->pc + 4); INSTPAT("??????? ????? ????? 000 ????? 11001 11", jalr , I, s->dnpc = (src1 + imm) & ~(word_t)1; IFDEF(CONFIG_ITRACE, { if (s->isa.inst.val == 0x00008067) { trace_func_ret(s->pc); // ret -> jalr x0, 0(x1) } else if (dest == 1) { trace_func_call(s->pc, s->dnpc); } else if (dest == 0 && imm == 0) { trace_func_call(s->pc, s->dnpc); // jr rs1 -> jalr x0, 0(rs1), which may be other control flow e.g. 'goto','for' } }); R(dest) = s->pc + 4); ``` 为了便于阅读ftrace的输出,需要将函数地址转换为函数名显示,根据文档提示,涉及到ELF文件的解析。 首先,根据文档提示,为NEMU传入一个ELF文件,即在`parse_args`函数添加代码: ``` c static char *elf_file = NULL; void parse_elf(const char *elf_file); static int parse_args(int argc, char *argv[]) { const struct option table[] = { {"elf" , required_argument, NULL, 'e'}, }; int o; while ( (o = getopt_long(argc, argv, "-bhl:d:p:e:", table, NULL)) != -1) { switch (o) { case 'e': elf_file = optarg; break; default: printf("\t-e,--elf=FILE elf file to be parsed\n"); } } return 0; } void init_monitor(int argc, char *argv[]) { /* Initialize elf */ parse_elf(elf_file); } ``` 由于运行`nemu`的命令和参数是在Makefile中生成的,因此修改`$AM_HOME/scripts/platform/nemu.mk`,给`NEMUFLAGS`变量添加`-e`参数,运行模拟器时就会加上这个参数,从而一步步传入`parse_args`中进行解析 ``` c NEMUFLAGS += -l $(shell dirname $(IMAGE).elf)/nemu-log.txt NEMUFLAGS += -e $(IMAGE).elf ``` **本节以下内容全为ELF解析以及地址到函数名转换** 可以看到上面两个trace函数都使用了`symbol_tbl`,`symbol_tbl`即对应ELF文件中的符号表,因此需要解析ELF文件并生成`symbol_tbl`给上面的代码使用 解析ELF相关代码比较多,放在[代码仓库](https://git.lug.ustc.edu.cn/nos-ae/pa-tmp/-/blob/main/itrace.c)自行查看,并且在阅读之前请了解ELF文件的格式以及`elf.h`中相关库函数的使用。在这里只做部分代码说明 ``` c typedef struct { char name[32]; // func name, 32 should be enough paddr_t addr; unsigned char info; Elf64_Xword size; } SymEntry; SymEntry *symbol_tbl = NULL; // dynamic allocated ``` `SymEntry`是自定义的便于存储符号表中一行的结构体,`symbol_tbl`是一个`SymEntry`类型的数组,每一个元素代表符号表中记录的一个符号,即`.symtab`中的一行。 函数地址到`symbol_tbl`对应下标的转换在`find_symbol_func`中实现,其中函数调用肯定跳转到函数首地址,而函数返回指令则可能落在`[Value, Value + Size)`区间内,因此使用`is_call`区分了调用与返回来做更精确的判断。实际上直接判断是否落在区间上即可,但因为目前还不知道`jal`和`jalr`是否可能会被用于其他用途比如`goto`等,所以这样能一定程度上避免未知的bug ``` c static int find_symbol_func(paddr_t target, bool is_call) { int i; for (i = 0; i < symbol_tbl_size; i++) { if (ELF64_ST_TYPE(symbol_tbl[i].info) == STT_FUNC) { if (is_call) { if (symbol_tbl[i].addr == target) break; } else { if (symbol_tbl[i].addr <= target && target < symbol_tbl[i].addr + symbol_tbl[i].size) break; } } } return i 不匹配的函数调用和返回 > > 如果你仔细观察上文`recursion`的示例输出, 你会发现一些有趣的现象. 具体地, 注释(1)处的`ret`的函数是和对应的`call`匹配的, 也就是说, `call`调用了`f2`, 而与之对应的`ret`也是从`f2`返回; 但注释(2)所指示的一组`call`和`ret`的情况却有所不同, `call`调用了`f1`, 但却从`f0`返回; 注释(3)所指示的一组`call`和`ret`也出现了类似的现象, `call`调用了`f1`, 但却从`f3`返回. > > 尝试结合反汇编结果, 分析为什么会出现这一现象. 实际操作上确实也出现几乎一样的情况,通过分析汇编指令发现,`f1`和`f0`函数这两个函数分别在调用`f0`和`f3`时,用的是`jr`(伪指令,展开为`jalr x0, 0(rs1)`),即返回地址没有记录在`x1` (return address for jumps),而是记录在了`x0`(hardwired to 0, ignores writes)中,相当于直接把返回地址丢弃了,进一步得出不会再返回到该函数中的结论,反观`f2`和`f3`的汇编代码却没有这个现象,因此输出的某些`ret`是错误的。再结合观察`recusion.c`中四个函数的代码得出最终结论:编译器将`f1`和`f0`的返回优化成了**尾调用**(你可能听说过尾递归,实际上都是一样的原理),关于尾调用定义自行搜索 因此我们需要在函数调用时识别出尾调用的情况,即存放返回地址的目的寄存器为`x0`的情况,并且记录当前`pc`值(代表不会被显式返回的函数,代码上狭义地定义为使用了`jr`指令的函数)与`depth`(代表调用深度),每次当有函数返回时,都检查一下是否在上一层有”等待返回“的函数,如果有的话,则再次调用`trace_func_ret`一并返回之 综上,我们需要维护在”等待返回“的函数调用,即被尾调用优化的函数调用: ``` c typedef struct tail_rec_node { paddr_t pc; int depth; struct tail_rec_node *next; } TailRecNode; TailRecNode *tail_rec_head = NULL; // linklist with head, dynamic allocated ``` 使用带头节点的头插法链表维护这些函数,并且对`trace_func_call`和`trace_func_ret`进行稍微地修改 ``` c static void init_tail_rec_list() { tail_rec_head = (TailRecNode *)malloc(sizeof(TailRecNode)); tail_rec_head->pc = 0; tail_rec_head->next = NULL; } static void insert_tail_rec(paddr_t pc, int depth) { TailRecNode *node = (TailRecNode *)malloc(sizeof(TailRecNode)); node->pc = pc; node->depth = depth; node->next = tail_rec_head->next; tail_rec_head->next = node; } static void remove_tail_rec() { TailRecNode *node = tail_rec_head->next; tail_rec_head->next = node->next; free(node); } void trace_func_call(paddr_t pc, paddr_t target, bool is_tail) { if (symbol_tbl == NULL) return; ++call_depth; if (call_depth <= 2) return; // ignore _trm_init & main int i = find_symbol_func(target, true); ftrace_write(FMT_PADDR ": %*scall [%s@" FMT_PADDR "]\n", pc, (call_depth-3)*2, "", i>=0?symbol_tbl[i].name:"???", target ); if (is_tail) { insert_tail_rec(pc, call_depth-1); } } void trace_func_ret(paddr_t pc) { if (symbol_tbl == NULL) return; if (call_depth <= 2) return; // ignore _trm_init & main int i = find_symbol_func(pc, false); ftrace_write(FMT_PADDR ": %*sret [%s]\n", pc, (call_depth-3)*2, "", i>=0?symbol_tbl[i].name:"???" ); --call_depth; TailRecNode *node = tail_rec_head->next; if (node != NULL) { if (node->depth == call_depth) { paddr_t ret_target = node->pc; remove_tail_rec(); trace_func_ret(ret_target); } } } ``` 并且稍微修改`jalr`和`jal`(加个`is_tail`参数) ``` c INSTPAT("??????? ????? ????? ??? ????? 11011 11", jal , J, s->dnpc = s->pc + imm; IFDEF(CONFIG_ITRACE, { if (dest == 1) { // x1: return address for jumps trace_func_call(s->pc, s->dnpc, false); } }); R(dest) = s->pc + 4); INSTPAT("??????? ????? ????? 000 ????? 11001 11", jalr , I, s->dnpc = (src1 + imm) & ~(word_t)1; IFDEF(CONFIG_ITRACE, { if (s->isa.inst.val == 0x00008067) { trace_func_ret(s->pc); // ret -> jalr x0, 0(x1) } else if (dest == 1) { trace_func_call(s->pc, s->dnpc, false); } else if (dest == 0 && imm == 0) { trace_func_call(s->pc, s->dnpc, true); // jr rs1 -> jalr x0, 0(rs1), which may be other control flow e.g. 'goto','for' } }); R(dest) = s->pc + 4); ``` 至此ftrace的相关实现暂时结束,上述实现可能会由于本人对ELF文件格式不熟悉、编译器优化及汇编代码的不熟悉、实现逻辑上的未仔细验证而导致bug或者兼容性差,请自行甄别代码正确性 ## DiffTest 尝试RTFSC之后发现,这里直接按顺序对比`CPU_State`的各个寄存器就行了 ``` cpp bool isa_difftest_checkregs(CPU_state *ref_r, vaddr_t pc) { int reg_num = ARRLEN(cpu.gpr); for (int i = 0; i < reg_num; i++) { if (ref_r->gpr[i] != cpu.gpr[i]) { return false; } } if (ref_r->pc != cpu.pc) { return false; } return true; } ``` PA2中这部分比较简单,后面可能有更难的任务得回头再看一看文档 ## 一键回归测试 之前跳过了一题“通过批处理模式运行NEMU”,现在企图运行 ``` shell make ARCH=$ISA-nemu run ``` 来实现一键回归测试的话,就得手动输入`c`来测试当前程序,`q`退出当前程序并测试下一个程序,因此给`nemu.mk`中的`NEMUFLAGS`添加一个`-b`参数即可 ``` makefile NEMUFLAGS += -l $(shell dirname $(IMAGE).elf)/nemu-log.txt NEMUFLAGS += -e $(IMAGE).elf # parse elf NEMUFLAGS += -b ``` 最后就是测试..补充指令..测试..补充指令..测试 这里是可以通过所有cpu-test的指令.. ``` cpp #define MAYBE_FUNC_JAL(s) IFDEF(CONFIG_ITRACE, { \ if (dest == 1) { \ trace_func_call(s->pc, s->dnpc, false); \ } \ }) #define MAYBE_FUNC_JALR(s) IFDEF(CONFIG_ITRACE, { \ if (s->isa.inst.val == 0x00008067) { \ trace_func_ret(s->pc); \ } else if (dest == 1) { \ trace_func_call(s->pc, s->dnpc, false); \ } else if (dest == 0 && imm == 0) { \ trace_func_call(s->pc, s->dnpc, true); \ } \ }) INSTPAT_START(); /* rs2 rs1 rd */ INSTPAT("0000000 ????? ????? 111 ????? 01100 11", and , R, R(dest) = src1 & src2); INSTPAT("??????? ????? ????? 111 ????? 00100 11", andi , I, R(dest) = src1 & imm); INSTPAT("??????? ????? ????? ??? ????? 00101 11", auipc , U, R(dest) = s->pc + imm); INSTPAT("0000000 ????? ????? 000 ????? 01100 11", add , R, R(dest) = src1 + src2); INSTPAT("0000000 ????? ????? 000 ????? 01110 11", addw , R, R(dest) = SEXT(src1 + src2, 32)); INSTPAT("??????? ????? ????? 000 ????? 00100 11", addi , I, R(dest) = src1 + imm); INSTPAT("??????? ????? ????? 000 ????? 00110 11", addiw , I, R(dest) = SEXT(src1 + imm, 32)); INSTPAT("??????? ????? ????? 000 ????? 11000 11", beq , B, if (src1 == src2) s->dnpc = s->pc + imm); INSTPAT("??????? ????? ????? 001 ????? 11000 11", bne , B, if (src1 != src2) s->dnpc = s->pc + imm); INSTPAT("??????? ????? ????? 100 ????? 11000 11", blt , B, if ((sword_t)src1 < (sword_t)src2) s->dnpc = s->pc + imm); INSTPAT("??????? ????? ????? 110 ????? 11000 11", bltu , B, if (src1 < src2) s->dnpc = s->pc + imm); INSTPAT("??????? ????? ????? 111 ????? 11000 11", bgeu , B, if (src1 >= src2) s->dnpc = s->pc + imm); INSTPAT("??????? ????? ????? 101 ????? 11000 11", bge , B, if ((sword_t)src1 >= (sword_t)src2) s->dnpc = s->pc + imm); INSTPAT("0000001 ????? ????? 100 ????? 01110 11", divw , R, R(dest) = SEXT(src1, 32) / SEXT(src2, 32)); INSTPAT("??????? ????? ????? ??? ????? 11011 11", jal , J, s->dnpc = s->pc + imm; MAYBE_FUNC_JAL(s); R(dest) = s->pc + 4); INSTPAT("??????? ????? ????? 000 ????? 11001 11", jalr , I, s->dnpc = (src1 + imm) & ~(word_t)1; MAYBE_FUNC_JALR(s); R(dest) = s->pc + 4); INSTPAT("??????? ????? ????? ??? ????? 01101 11", lui , U, R(dest) = imm); INSTPAT("??????? ????? ????? 000 ????? 00000 11", lb , I, R(dest) = SEXT(Mr(src1 + imm, 1), 8)); INSTPAT("??????? ????? ????? 001 ????? 00000 11", lh , I, R(dest) = SEXT(Mr(src1 + imm, 2), 16)); INSTPAT("??????? ????? ????? 010 ????? 00000 11", lw , I, R(dest) = SEXT(Mr(src1 + imm, 4), 32)); INSTPAT("??????? ????? ????? 011 ????? 00000 11", ld , I, R(dest) = Mr(src1 + imm, 8)); INSTPAT("??????? ????? ????? 100 ????? 00000 11", lbu , I, R(dest) = Mr(src1 + imm, 1)); INSTPAT("??????? ????? ????? 101 ????? 00000 11", lhu , I, R(dest) = Mr(src1 + imm, 2)); INSTPAT("??????? ????? ????? 110 ????? 00000 11", lwu , I, R(dest) = Mr(src1 + imm, 4)); INSTPAT("0000001 ????? ????? 000 ????? 01100 11", mul , R, R(dest) = src1 * src2); INSTPAT("0000001 ????? ????? 000 ????? 01110 11", mulw , R, R(dest) = SEXT(src1 * src2, 32)); INSTPAT("??????? ????? ????? 100 ????? 00100 11", xori , I, R(dest) = src1 ^ imm); INSTPAT("0000000 ????? ????? 110 ????? 01100 11", or , R, R(dest) = src1 | src2); INSTPAT("0000001 ????? ????? 110 ????? 01110 11", remw , R, R(dest) = SEXT(src1, 32) % SEXT(src2, 32)); INSTPAT("??????? ????? ????? 000 ????? 01000 11", sb , S, Mw(src1 + imm, 1, src2)); INSTPAT("??????? ????? ????? 001 ????? 01000 11", sh , S, Mw(src1 + imm, 2, src2)); INSTPAT("??????? ????? ????? 010 ????? 01000 11", sw , S, Mw(src1 + imm, 4, src2)); INSTPAT("??????? ????? ????? 011 ????? 01000 11", sd , S, Mw(src1 + imm, 8, src2)); INSTPAT("??????? ????? ????? 011 ????? 00100 11", sltiu , I, R(dest) = src1 < imm ); INSTPAT("0000000 ????? ????? 011 ????? 01100 11", sltu , R, R(dest) = src1 < src2); // warn: "right shift a negative number"'s behavior(arithmetic or logical) depends on the compiler INSTPAT("000000? ????? ????? 101 ????? 00110 11", srliw , I, R(dest) = BITS(src1, 31, 0) >> BITS(imm, 4, 0)); INSTPAT("000000? ????? ????? 001 ????? 00100 11", slli , I, R(dest) = src1 << BITS(imm, 5, 0)); INSTPAT("000000? ????? ????? 001 ????? 00110 11", slliw , I, R(dest) = SEXT(src1 << BITS(imm, 4, 0), 32)); INSTPAT("0000000 ????? ????? 001 ????? 01110 11", sllw , R, R(dest) = SEXT(src1 << BITS(src2, 4, 0), 32)); INSTPAT("000000? ????? ????? 101 ????? 00100 11", srli , I, R(dest) = src1 >> BITS(imm, 5, 0)); INSTPAT("0000000 ????? ????? 101 ????? 01110 11", srlw , R, R(dest) = SEXT(BITS(src1, 31, 0) >> BITS(src2, 4, 0), 32)); INSTPAT("0100000 ????? ????? 101 ????? 01110 11", sraw , R, R(dest) = (sword_t)SEXT(src1, 32) >> BITS(src2, 4, 0)); INSTPAT("010000? ????? ????? 101 ????? 00100 11", srai , I, R(dest) = (sword_t)src1 >> BITS(imm, 5, 0)); INSTPAT("010000? ????? ????? 101 ????? 00110 11", sraiw , I, R(dest) = (sword_t)SEXT(src1, 32) >> BITS(imm, 4, 0)); INSTPAT("0000000 ????? ????? 010 ????? 01100 11", slt , R, R(dest) = (sword_t)src1 < (sword_t)src2); INSTPAT("0100000 ????? ????? 000 ????? 01100 11", sub , R, R(dest) = src1 - src2); INSTPAT("0100000 ????? ????? 000 ????? 01110 11", subw , R, R(dest) = SEXT(src1 - src2, 32)); INSTPAT("0000000 ????? ????? 100 ????? 01100 11", xor , R, R(dest) = src1 ^ src2); // INSTPAT("??????? ????? ????? 000 ????? 01000 11", sb , S, Mw(/*src1 + imm*/0x8fffffff, 1, src2)); INSTPAT("0000000 00001 00000 000 00000 11100 11", ebreak , N, NEMUTRAP(s->pc, R(10))); // R(10) is $a0 INSTPAT("??????? ????? ????? ??? ????? ????? ??", inv , N, INV(s->pc)); INSTPAT_END(); ``` # 输入输出 ## 串口 ### 理解mainargs 以下命令如何将mainargs传到hello程序中 ``` shell make ARCH=$ISA-nemu run mainargs=I-love-PA ``` 答案在\$AM_HOME/scripts/platform/nemu.mk中,有这么一句`CFLAGS += -DMAINARGS=\"$(mainargs)\"`,gcc的编译选项-D将定义宏MAINARGS,并且将宏的值设为"\$mainargs",也就是字符串“I-love-PA”,那么这个宏在哪里使用呢,查看`nemu/trm.c`就知道了 ### 实现printf > 有了`putch()`, 我们就可以在klib中实现`printf()`了. > > 你之前已经实现了`sprintf()`了, 它和`printf()`的功能非常相似, 这意味着它们之间会有不少重复的代码. 你已经见识到Copy-Paste编程习惯的坏处了, 思考一下, 如何简洁地实现它们呢? > > 实现了`printf()`之后, 你就可以在AM程序中使用输出调试法了. 因为格式化部分的代码与sprintf基本相同,于是可以考虑开辟一个缓冲区buffer,借助sprintf来实现printf,但这两个函数的可变参数都是...,无法直接传递,因此将格式化的功能封装到接受一个`va_list`类型的`vsprintf`函数中,然后调用即可,printf最终遍历已格式化字符串用putch逐个输出: ``` c int vsprintf(char *out, const char *fmt, va_list ap) { char *start = out; for (; *fmt != '\0'; ++fmt) { if (*fmt != '%') { *out = *fmt; ++out; } else { switch (*(++fmt)) { case '%': *out = *fmt; ++out; break; case 'd': out += itoa(va_arg(ap, int), out, 10); break; case 's': char *s = va_arg(ap, char*); strcpy(out, s); out += strlen(out); break; } } } *out = '\0'; return out - start; } ``` ## 时钟 ### 实现IOE 根据文档的提示以及给出的输入API,需要用`intl`分别读取`RTC_ADDR`处的高4位和低4位,并拼接起来作为微秒数: ``` c void __am_timer_uptime(AM_TIMER_UPTIME_T *uptime) { uint32_t low = inl(RTC_ADDR); uint32_t high = inl(RTC_ADDR+4); uptime->us = (uint64_t)low + (((uint64_t)high) << 32); } ``` 代码非常简单,但是这个东西的调用流程得记录下来备忘:先不管框架代码的流程,跑一下rtc_test这个测试时钟的程序,并查看函数调用: ``` 0x8000000c: call [_trm_init@0x80000fe4] 0x80000ff4: call [main@0x80000df8] 0x80000fa0: call [ioe_init@0x80001098] 0x800010d8: call [__am_gpu_init@0x80001184] 0x80001184: ret [__am_gpu_init] 0x800010dc: call [__am_timer_init@0x8000112c] 0x8000112c: ret [__am_timer_init] 0x800010e0: call [__am_audio_init@0x800011c8] 0x800011c8: ret [__am_audio_init] 0x800010f0: ret [ioe_init] 0x80000fa4: call [rtc_test@0x80000010] 0x80000068: call [ioe_read@0x800010f4] 0x8000110c: call [__am_timer_uptime@0x80001130] 0x80001154: ret [__am_timer_uptime] 0x8000110c: ret [ioe_read] 0x80000068: call [ioe_read@0x800010f4] 0x8000110c: call [__am_timer_uptime@0x80001130] 0x80001154: ret [__am_timer_uptime] 0x8000110c: ret [ioe_read] ... ``` - 基于测试程序的视角来看待程序运行,就是通过`io_read`不断地读取系统时间,涉及到的“系统调用”无非就是`io_read`这个函数(其他初始化的代码不重要暂时忽略) - 基于模拟器的视角来看待程序运行,调用`io_read(AM_TIMER_UPTIME)`来获取系统时间的流程是怎么样的?`io_read`封装了`ioe_read`,一步步点进去可以发现最终调用的是`__am_timer_uptime`,也就是我们实现的时钟IOE,其中通过`inl`来读取时钟 - `inl`实现十分简单,只是读取了传入地址的内容: ``` c static inline uint32_t inl(uintptr_t addr) { return *(volatile uint32_t *)addr; } ``` 这句命令将会被编译成`lw a5,76(a5)`(见汇编代码中的__am_timer_uptime函数),查看之前实现过的指令就会知道,`lw`将会从内存中读入数据,即`vaddr_read`,进而调用我们熟悉的`paddr_read`,其中会根据访存地址判断是读取内存还是读取IO端口(mmio),这里测试程序当然是读取IO端口,进而调用`mmio_read`,然后到`map_read`,最终在`map_read`中调用之前注册的回调函数`rtc_io_handler`获取系统时间并存到`rtc_port_base`中(即对外开放的`map->space`,此处设计得很巧妙),并用`host_read`读取`map->space`中的内容 - 因此是在访存时(如`inl`),通过框架定义好的一系列流程来实现获得“实时”的系统时间 - 对于其他IO设备的访问,上述流程也大致适用 ### 看看NEMU跑多快 第一次跑了30w 40w分,跑分出现问题,后来查看源码后发现在`rtc_io_handler`中,有这么一句判断:`offset==4` ``` c if (!is_write && offset == 4) { uint64_t us = get_time(); rtc_port_base[0] = (uint32_t)us; rtc_port_base[1] = us >> 32; } ``` 意思是获取一次系统时间会触发两次这个回调函数,加一个判断避免重复`get_time`。因此在`__am_timer_uptime`中`inl`的顺序应该是先读高32位,让他获取到当前系统时间,然后再读低32位。而当时我先读取的低32位导致`rtc_port_base`没有更新,获取到的是上一次`__am_timer_uptime`得到的系统时间的低32位,因此跑分出错。修改方法有两个 - `rtc_io_handler`中的判断改成`offset==0` - `__am_timer_uptime`中先获取高32位 修改完成后,跑分大概是两百多,河里 ## dtrace 十分简单,直接在itrace.c中添加两个函数,`dtrace_write`暂时定义为`log_write`,即输出到日志中 ``` c void trace_dread(paddr_t addr, int len, IOMap *map) { dtrace_write("dtrace: read %10s at " FMT_PADDR ",%d\n", map->name, addr, len); } void trace_dwrite(paddr_t addr, int len, word_t data, IOMap *map) { dtrace_write("dtrace: write %10s at " FMT_PADDR ",%d with " FMT_WORD "\n", map->name, addr, len, data); } ``` 调用的话,由于pio和mmio最终都是通过`map_write`和`map_read`实现,因此在这两个函数里调用就好了 ``` c word_t map_read(paddr_t addr, int len, IOMap *map) { // ... trace_dread(addr, len, map); return ret; } void map_write(paddr_t addr, int len, word_t data, IOMap *map) { // ... trace_dwrite(addr, len, data, map); } ``` ## 键盘 ### 实现IOE 这块比较简单了,参考native版本的ioe即可写出nemu版本的代码 ``` c void __am_input_keybrd(AM_INPUT_KEYBRD_T *kbd) { uint32_t kc = inl(KBD_ADDR); kbd->keydown = kc & KEYDOWN_MASK ? true : false; kbd->keycode = kc & ~KEYDOWN_MASK; } ``` ## VGA(Video Graphics Array) ### 实现IOE `__am_gpu_config`用于读取VGA的一些参数,比如屏幕大小等(其他参数不知道干什么的,也暂时用不上),通过查看nemu框架vga.c,可以知道`VGACTL_ADDR`处的4个字节保存屏幕的宽高,因此代码如下 ``` c void __am_gpu_config(AM_GPU_CONFIG_T *cfg) { uint32_t screen_wh = inl(VGACTL_ADDR); uint32_t h = screen_wh & 0xffff; uint32_t w = screen_wh >> 16; *cfg = (AM_GPU_CONFIG_T) { .present = true, .has_accel = false, .width = w, .height = h, .vmemsz = 0 }; } ``` 而负责绘图的`__am_gpu_fbdraw`已经实现了同步屏幕的功能,很简单,也就是外界调用`io_write(AM_GPU_FBDRAW, ... ,true)`时(最后一个参数是sync),用outb输出到抽象寄存器即可。而根据文档提示我们需要实现的是nemu的框架部分,代码也有TODO注释帮助实现: ``` c void vga_update_screen() { // TODO: call `update_screen()` when the sync register is non-zero, // then zero out the sync register uint32_t sync = vgactl_port_base[1]; if (sync) { update_screen(); vgactl_port_base[1] = 0; } } ``` 需要注意的是,上面的`__am_gpu_fbdraw`并没有真正实现绘图的操作,它只是把需要绘制的区域的像素点数据放到frame buffer中,最终是在vga.c的每次设备更新中从frame buffer中取出数据并实现绘制 至于`__am_gpu_init`添加的测试代码,实际上可加可不加,因为它只是在最开始给画面填充了一些没有意义的颜色而已,如果代码写得没问题的话,运行起来就会看到蓝绿渐变的一副画面 最后是实现真正的`AM_GPU_FBDRAW`的功能,首先要看一下测试代码,目的是看看`io_read(AM_GPU_FBDRAW...)`这个系统调用会传入什么参数,最终与`__am_gpu_fbdraw`中的参数一一对应: - x:绘制的水平起始点 - y:绘制的垂直起始点 - w:绘制的矩形宽度 - h:绘制的矩形高度 - pixels:绘制的矩形内所有像素点的颜色,表示成二维数组就是,pixels\[i][j]表示点(i+x, j+y)的颜色,这个坐标是相对整个GUI程序来说的,即GUI程序的左上角点坐标为(0, 0) 不理解的话可以查看[这篇博客](https://anya.cool/archives/81c5e64f.html),基于上述理解写出代码: ``` c void __am_gpu_fbdraw(AM_GPU_FBDRAW_T *ctl) { int x = ctl->x, y = ctl->y, w = ctl->w, h = ctl->h; if (!ctl->sync && (w == 0 || h == 0)) return; uint32_t *pixels = ctl->pixels; uint32_t *fb = (uint32_t *)(uintptr_t)FB_ADDR; uint32_t screen_w = inl(VGACTL_ADDR) >> 16; for (int i = y; i < y+h; i++) { for (int j = x; j < x+w; j++) { fb[screen_w*i+j] = pixels[w*(i-y)+(j-x)]; } } if (ctl->sync) { outl(SYNC_ADDR, 1); } } ``` PA2至此结束,欢迎指出不足之处 # 补充 ## makefile 在pa2中,`am-kernels/tests/cpu-tests/Makefile`是我们接触到的第一个makefile,从他入手 ``` makefile # 如果没指定ALL,则为所有的测试 ALL = $(basename $(notdir $(shell find tests/. -name "*.c"))) # 最后输出所有测试的名字 all: $(addprefix Makefile., $(ALL)) @echo "" $(ALL) $(ALL): %: Makefile.% # 生成测试对应的makefile并执行,根据执行结果判断是否通过测试 Makefile.%: tests/%.c latest @/bin/echo -e "NAME = $*\nSRCS = $<\nLIBS += klib\ninclude $${AM_HOME}/Makefile" > $@ @if make -s -f $@ ARCH=$(ARCH) $(MAKECMDGOALS); then \ printf "[%14s] $(COLOR_GREEN)PASS!$(COLOR_NONE)\n" $* >> $(RESULT); \ else \ printf "[%14s] $(COLOR_RED)FAIL!$(COLOR_NONE)\n" $* >> $(RESULT); \ fi -@rm -f Makefile.$* # $(RESULT)是个临时文件,保存所有测试结果 run: all @cat $(RESULT) @rm $(RESULT) ``` 主要的部分的解释都在注释里了,其中生成makefile这块解释一下:比如测试dummy,则生成`Makefile.dummy`文件,文件内容为: ``` makefile NAME = dummy SRCS = tests/dummy.c LIBS += klib include /home/ubuntu/ics2022/abstract-machine/Makefile ``` 这个内容应该都很熟悉了,在`nemu/tools`下的工具Makefile也是这样的形式,不过注意到最后一行,引入的不是`build.mk`而是AM的Makefile,这说明这个程序依赖于AM以及NEMU,下面一探究竟 查看$AM_HOME下的Makefile: ``` makefile ARCH_SPLIT = $(subst -, ,$(ARCH)) ISA = $(word 1,$(ARCH_SPLIT)) PLATFORM = $(word 2,$(ARCH_SPLIT)) ``` 我们通过传入的ARCH分别得到ISA和PLATFORM,如riscv64-nemu,随后指定了一系列的编译相关参数以及生成目标文件和目录,**随后引入架构相关的makefile**,这个需要重点关注 ``` makefile -include $(AM_HOME)/scripts/$(ARCH).mk ``` 我们查看`riscv64-nemu.mk`,可以看到它分别引入了ISA和PLATFORM相关的makefile,最后添加一些编译flags和am相关的sources,接下来分别查看`riscv64.mk`和`nemu.mk` `riscv64.mk`添加rv64相关的编译flags,`nemu.mk`添加am与平台相关的sources、编译flags、模拟器运行参数NEMUFLAGS,以及添加`image`、`run`等运行目标,这里`image`目标是将ELF反汇编以及生成最终的镜像文件,那为什么要多一层ELF文件而不是直接生成镜像文件呢,因为ELF是为了做ftrace任务的,方便我们使用。 而这里的`run`目标显然就是构建模拟器,并且将我们的镜像文件传入模拟器中执行 到这里事情就比较清晰了:**nemu负责构建模拟器,am负责构建镜像文件,并且强大的am可以适配不同的isa和platform。而从运行层次上来说,nemu负责跟host打交道,am负责跟platform(在这里是nemu)打交道** ## 镜像的执行流程 执行流程无非就是找到入口 -> 中间经过哪些代码 -> 找到出口 pa1我们知道了程序从pc=RESET_VECTOR处开始执行,这个是从模拟器层面来说的。从我们基于模拟器上的程序来说,比如dummy程序,**我们在哪里指定这个程序的入口呢**?首先可以肯定的是不是测试程序的main函数。看到`nemu.mk`,其中有一句`LDFLAGS += --gc-sections -e _start`,也就是在链接过程中用`-e`指定程序的入口,这里程序入口是`_start`函数,这个函数被定义在`am/src/riscv/nemu/start.S`文件中,`_start`函数会调用`_trm_init`函数,被定义在`platform/nemu/trm.c`中,随后就是一目了然的事情了。还有一点,程序的虚拟地址空间在什么地方确定的,依然是在这个makefile里为ld添加的标志`--defsym=_pmem_start=0x80000000`,defsym为程序中的标志`pmem_start`设定地址,`_pmem_start`这个标志是在`nemu.h`中声明的,这个变量仅仅只是做了一个类似于label的作用,为在他之后的程序的代码提供基地址0x80000000 再看一下dummy的反汇编代码吧,确实如此: ``` assembly Disassembly of section .text: 0000000080000000 <_start>: 80000000: 00000413 li s0,0 80000004: 00009117 auipc sp,0x9 80000008: ffc10113 addi sp,sp,-4 # 80009000 <_end> 8000000c: 00c000ef jal ra,80000018 <_trm_init> 0000000080000010
: 80000010: 00000513 li a0,0 80000014: 00008067 ret 0000000080000018 <_trm_init>: 80000018: ff010113 addi sp,sp,-16 8000001c: 00000517 auipc a0,0x0 80000020: 01c50513 addi a0,a0,28 # 80000038 <_etext> 80000024: 00113423 sd ra,8(sp) 80000028: fe9ff0ef jal ra,80000010
8000002c: 00050513 mv a0,a0 80000030: 00100073 ebreak 80000034: 0000006f j 80000034 <_trm_init+0x1c> ``` 至此程序的入口我们找到了,中间经过哪些代码也不用多说,再来看看程序出口,对应的源码是`halt`函数里,这个函数很简单,调用了`nemu_trap`,这个“函数”我们可以在`nemu.h`中找到定义: ``` c # define nemu_trap(code) asm volatile("mv a0, %0; ebreak" : :"r"(code)) ``` 其含义就是把code放在a0寄存器,并且执行ebreak,ebreak干了什么呢,查看`inst.c`就知道了...设置nemu_state、halt_pc、halt_pc等... ## 设备使用的流程 > 源程序会根据CONFG_DEVICE宏是否被定义来使用设备,因此需要在menuconfig开启Devices选项 来分析下最简单的串口执行流程,测试用例为第一个设备相关的程序:`kernels/hello.c` ``` c int main(const char *args) { const char *fmt = "Hello, AbstractMachine!\n" "mainargs = '%'.\n"; for (const char *p = fmt; *p; p++) { (*p == '%') ? putstr(args) : putch(*p); } return 0; } ``` 非常简单的一段代码,目光集中在`putch`(`putstr`也是通过循环`putch`实现的),这个函数在`trm.c`中实现: ``` c void putch(char ch) { outb(SERIAL_PORT, ch); } ``` `outb`被定义在`riscv.h`中: ``` c static inline void outb(uintptr_t addr, uint8_t data) { *(volatile uint8_t *)addr = data; } ``` 我们知道riscv采用内存映射IO的编址方式,因此outb会被汇编成store指令,比如`sb`,这条指令的具体实现我们查看`inst.c`: ``` c Mw(src1 + imm, 1, src2); // 宏展开后: vaddr_write(src1 + imm, 1, src2); ``` 至此,我们可以举一反三,所有的关于设备读写的操作最终都是通过`vaddr_read`和`vaddr_write`来模拟实现的,跟随代码一路走到`mmio_write`: ``` c void mmio_write(paddr_t addr, int len, word_t data) { map_write(addr, len, data, fetch_mmio_map(addr)); } ``` 首先需要明确每个设备以`IOMap`结构体的形式存储,结构体里存储了设备的一些metadata,比如设备的虚拟地址、设备的内容以及回调函数。`fetch_mmio_map`功能就是获取给定地址对应的`IOMap`结构体,**这一步算是地址映射设备的核心步骤之一**,随后调用`map_write`对结构体进行写入。注意到low和high只是虚拟地址,而我们要真正写入的地方是`io_space`(由`map.c`管理),因此我们需要将虚拟地址转换为`io_space`起始的地址,**这步是第二个核心步骤**: ``` c paddr_t offset = addr - map->low; host_write(map->space + offset, len, data); ``` 最后调用回调函数,回调函数是真正进行对宿主设备操作的部分,比如写串口是用系统的`putc`输出的,比如timer设备的读取我们是通过`gettimeofday`读取宿主系统的时间来实现的 至此,关于IO读写的部分,可以作出总结:**mmio.c管理设备结构体IOMap,核心是从设备虚拟地址到设备的转换,map.c负责管理设备地址空间,核心是从设备虚拟地址到物理地址的转换** 最后关于IO读写再提一嘴,只有写串口才是直接调用`outb`来实现的,而其他的时钟、键盘等由于含有多个字段,直接使用`in*`/`out*`函数十分麻烦并且可读性极差,也不便于扩展。因此首先将设备全部抽象成“抽象寄存器”(见`amdev.h`)以及寄存器的内容结构体`AM_##reg##_T`,然后将从IO具体的读写逻辑单独封装,当使用`ioe_read`/`ioe_write`读写这些抽象寄存器时,就会映射到相应的函数中,在这些函数中我们才真正使用`in*`/`out*`来读写数据。并且在`klib-macros.h`定义了`io_read`/`io_write`方便IO读写,但是需要注意的是,`io_write`调用传入的参数顺序要与抽象寄存器结构体的数据定义顺序相同