nju pa2

其他资料:

https://github.com/riscv-non-isa/riscv-asm-manual/blob/master/riscv-asm.md

http://riscvbook.com/chinese/RISC-V-Reader-Chinese-v2p1.pdf

RTFM#

运行第一个客户程序#

第一个客户程序即文档所说的dummy.c,键入命令后,会将dummy.c编译成基于rv64指令的二进制格式文件(后缀名为 .bin),作为nemu模拟器的镜像文件(img_file)

make ARCH=$ISA-nemu ALL=dummy run

实现指令#

首先查看反汇编结果,看看需要实现哪些指令

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 <main>:
    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 <main>
    8000002c: 00050513            mv  a0,a0
    80000030: 00100073            ebreak
    80000034: 0000006f            j 80000034 <_trm_init+0x1c>

通过查询手册,可以知道li ret等指令都是伪指令,比如第一条指令

80000000: 00000413            li  s0,0

其十六进制内容为0x00000413,对应二进制为0000 0000 0000 0000 0000 0100 0001 0011,查询手册可知其为addi指令,而当伪指令li中的立即数小于4096时,的确会被编译器展开为addi指令,因此目前可以确定,反汇编结果的右边的指令只是为了方便阅读(生成反汇编指令的代码在disasm.cc中),我们在实现指令功能时,只需要看左边的十六进制内容即可

按照框架和文档提示,以及根据手册查询每条指令干了什么,然后填写代码即可

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实现:

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的值,来指定最大可存放指令条数

#include <common.h>

#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);
}

对于这两个函数在哪里调用,由于需要记录导致程序出错的指令,因此"存"的时机要在其取指之后执行之前:

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)会展示最近执行的指令。

放在这里是否欠妥未知,以后有问题再换个合适的地方调用

void assert_fail_msg() {
  display_inst();
  isa_reg_display();
  statistic();
}

实现mtrace#

itrace.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);
}

在访存函数中调用

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文件中

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]

而记录函数调用和返回则在jaljalr这两个指令执行时调用,具体原因可自行了解指令作用及查看汇编代码验证

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函数添加代码:

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中进行解析

NEMUFLAGS += -l $(shell dirname $(IMAGE).elf)/nemu-log.txt
NEMUFLAGS += -e $(IMAGE).elf

本节以下内容全为ELF解析以及地址到函数名转换

可以看到上面两个trace函数都使用了symbol_tblsymbol_tbl即对应ELF文件中的符号表,因此需要解析ELF文件并生成symbol_tbl给上面的代码使用

解析ELF相关代码比较多,放在代码仓库自行查看,并且在阅读之前请了解ELF文件的格式以及elf.h中相关库函数的使用。在这里只做部分代码说明

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区分了调用与返回来做更精确的判断。实际上直接判断是否落在区间上即可,但因为目前还不知道jaljalr是否可能会被用于其他用途比如goto等,所以这样能一定程度上避免未知的bug

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<symbol_tbl_size?i:-1;
}

上述就是实现ftrace的流程以及需要注意的地方和难点,流程再次总结就是:

  1. 修改makefile,给模拟器传一个-e FILE参数作为elf文件的路径
  2. 修改parse_args函数,解析传入的-e参数,并传给itrace中的parse_elf函数让其解析该文件得到符号表symbol_tbl
  3. 编写trace_func_calltrace_func_ret,并在相应的函数调用和返回指令处调用之

另外一个难点来源于对编译器和汇编不熟悉,出现的问题即文档中提到的:

不匹配的函数调用和返回

如果你仔细观察上文recursion的示例输出, 你会发现一些有趣的现象. 具体地, 注释(1)处的ret的函数是和对应的call匹配的, 也就是说, call调用了f2, 而与之对应的ret也是从f2返回; 但注释(2)所指示的一组callret的情况却有所不同, call调用了f1, 但却从f0返回; 注释(3)所指示的一组callret也出现了类似的现象, call调用了f1, 但却从f3返回.

尝试结合反汇编结果, 分析为什么会出现这一现象.

实际操作上确实也出现几乎一样的情况,通过分析汇编指令发现,f1f0函数这两个函数分别在调用f0f3时,用的是jr(伪指令,展开为jalr x0, 0(rs1)),即返回地址没有记录在x1
(return address for jumps),而是记录在了x0(hardwired to 0, ignores writes)中,相当于直接把返回地址丢弃了,进一步得出不会再返回到该函数中的结论,反观f2f3的汇编代码却没有这个现象,因此输出的某些ret是错误的。再结合观察recusion.c中四个函数的代码得出最终结论:编译器将f1f0的返回优化成了尾调用(你可能听说过尾递归,实际上都是一样的原理),关于尾调用定义自行搜索

因此我们需要在函数调用时识别出尾调用的情况,即存放返回地址的目的寄存器为x0的情况,并且记录当前pc值(代表不会被显式返回的函数,代码上狭义地定义为使用了jr指令的函数)与depth(代表调用深度),每次当有函数返回时,都检查一下是否在上一层有”等待返回“的函数,如果有的话,则再次调用trace_func_ret一并返回之

综上,我们需要维护在”等待返回“的函数调用,即被尾调用优化的函数调用:

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_calltrace_func_ret进行稍微地修改

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);
		}
	}
}

并且稍微修改jalrjal(加个is_tail参数)

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的各个寄存器就行了

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”,现在企图运行

make ARCH=$ISA-nemu run

来实现一键回归测试的话,就得手动输入c来测试当前程序,q退出当前程序并测试下一个程序,因此给nemu.mk中的NEMUFLAGS添加一个-b参数即可

NEMUFLAGS += -l $(shell dirname $(IMAGE).elf)/nemu-log.txt
NEMUFLAGS += -e $(IMAGE).elf # parse elf
NEMUFLAGS += -b

最后就是测试..补充指令..测试..补充指令..测试

这里是可以通过所有cpu-test的指令..

#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程序中

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逐个输出:

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位,并拼接起来作为微秒数:

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实现十分简单,只是读取了传入地址的内容:

      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

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_uptimeinl的顺序应该是先读高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,即输出到日志中

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_writemap_read实现,因此在这两个函数里调用就好了

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版本的代码

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个字节保存屏幕的宽高,因此代码如下

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注释帮助实现:

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)

不理解的话可以查看这篇博客,基于上述理解写出代码:

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,从他入手

# 如果没指定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文件,文件内容为:

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:

ARCH_SPLIT = $(subst -, ,$(ARCH))
ISA        = $(word 1,$(ARCH_SPLIT))
PLATFORM   = $(word 2,$(ARCH_SPLIT))

我们通过传入的ARCH分别得到ISA和PLATFORM,如riscv64-nemu,随后指定了一系列的编译相关参数以及生成目标文件和目录,随后引入架构相关的makefile,这个需要重点关注

-include $(AM_HOME)/scripts/$(ARCH).mk

我们查看riscv64-nemu.mk,可以看到它分别引入了ISA和PLATFORM相关的makefile,最后添加一些编译flags和am相关的sources,接下来分别查看riscv64.mknemu.mk

riscv64.mk添加rv64相关的编译flags,nemu.mk添加am与平台相关的sources、编译flags、模拟器运行参数NEMUFLAGS,以及添加imagerun等运行目标,这里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的反汇编代码吧,确实如此:

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 <main>:
    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 <main>
    8000002c:	00050513          	mv	a0,a0
    80000030:	00100073          	ebreak
    80000034:	0000006f          	j	80000034 <_trm_init+0x1c>

至此程序的入口我们找到了,中间经过哪些代码也不用多说,再来看看程序出口,对应的源码是halt函数里,这个函数很简单,调用了nemu_trap,这个“函数”我们可以在nemu.h中找到定义:

# 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

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;
}

非常简单的一段代码,目光集中在putchputstr也是通过循环putch实现的),这个函数在trm.c中实现:

void putch(char ch) {
  outb(SERIAL_PORT, ch);
}

outb被定义在riscv.h中:

static inline void outb(uintptr_t addr, uint8_t  data) { *(volatile uint8_t  *)addr = data; }

我们知道riscv采用内存映射IO的编址方式,因此outb会被汇编成store指令,比如sb,这条指令的具体实现我们查看inst.c

Mw(src1 + imm, 1, src2);
// 宏展开后:
vaddr_write(src1 + imm, 1, src2);

至此,我们可以举一反三,所有的关于设备读写的操作最终都是通过vaddr_readvaddr_write来模拟实现的,跟随代码一路走到mmio_write

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起始的地址,这步是第二个核心步骤

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调用传入的参数顺序要与抽象寄存器结构体的数据定义顺序相同

posted @   NOSAE  阅读(16389)  评论(14编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示