nju pa2
其他资料:
https://github.com/riscv-non-isa/riscv-asm-manual/blob/master/riscv-asm.md
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#
实现字符串处理函数#
根据前面的铺垫,这部分比较简单,就不贴代码了,只需要:
- 在
abstract-machine/klib/src/string.c
中实现string.c
测试用例中使用到的库函数,如strcmp
- 对照
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]
而记录函数调用和返回则在jal
与jalr
这两个指令执行时调用,具体原因可自行了解指令作用及查看汇编代码验证
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_tbl
,symbol_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
区分了调用与返回来做更精确的判断。实际上直接判断是否落在区间上即可,但因为目前还不知道jal
和jalr
是否可能会被用于其他用途比如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的流程以及需要注意的地方和难点,流程再次总结就是:
- 修改makefile,给模拟器传一个
-e FILE
参数作为elf文件的路径 - 修改
parse_args
函数,解析传入的-e
参数,并传给itrace中的parse_elf
函数让其解析该文件得到符号表symbol_tbl
- 编写
trace_func_call
和trace_func_ret
,并在相应的函数调用和返回指令处调用之
另外一个难点来源于对编译器和汇编不熟悉,出现的问题即文档中提到的:
不匹配的函数调用和返回
如果你仔细观察上文
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
一并返回之
综上,我们需要维护在”等待返回“的函数调用,即被尾调用优化的函数调用:
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
进行稍微地修改
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
参数)
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_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
,即输出到日志中
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
实现,因此在这两个函数里调用就好了
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.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的反汇编代码吧,确实如此:
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;
}
非常简单的一段代码,目光集中在putch
(putstr
也是通过循环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_read
和vaddr_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
调用传入的参数顺序要与抽象寄存器结构体的数据定义顺序相同
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!