we choose to go to the moon!😉|

上山砍大树

园龄:5年3个月粉丝:13关注:3

2024-09-26 11:28阅读: 972评论: 7推荐: 1

PA2 - 简单复杂的机器: 冯诺依曼计算机系统

PA2 - 简单复杂的机器: 冯诺依曼计算机系统

  • task PA2.1: 实现更多的指令, 在NEMU中运行大部分cpu-tests
  • task PA2.2: 实现klib和基础设施
  • task PA2.3: 运行FCEUX, 提交完整的实验报告

DDL

PA2.1- 10月11号(已完成除mul-longlong.c的所有指令测试)

PA2.2 - 10月28号(已完成包括mul-longlong在内的所有指令测试)

PA2.3 - 11月2号

[!NOTE]

热身结束, 进入真正的PA

在PA2结束之后, 你需要做到可以理解NEMU中的每一处细节. 随着你对这些代码细节的了解变得深入, 就算是调bug你也会觉得多了几分轻松. 相反, 如果你仍然抱着"测试能过就行", "这里不算分, 关我P事"的心态, 很快你就会发现连bug都不知道怎么调了.

所以, 不要再偷懒了.

好的,谢谢老师,被小黄龙折磨的我,期待在PA2得到对应的训练。😆

不停计算的机器

介绍了YEMU执行指令的流程

STFSC后解决两个问题:

  • 画出在YEMU上执行的加法程序的状态机
  • 通过RTFSC理解YEMU如何执行一条指令
  1. 画出在YEMU上执行的加法程序的状态机

    用一个三元组(PC, r0, r1)表示程序的所有状态,画出其状态转移过程(此处的x表示未初始化):

    (0, x, x) -> (1, 33, x) -> (2, 33, 33) -> (3, 16, 33) -> (4, 49, 33)

    最终程序会将寄存器r0保存的值存储到对应的内存地址处。

  2. 通过RTFSC理解YEMU如何执行一条指令

    • 取指阶段:从数组地址对应的下标pc中读取一个长度为8的指令this
    • 译码:
      • 操作码:访问指令this的操作码位域rtype.op,获取对应的操作码op
      • 操作数:根据指令this中的操作码op,对指令的剩余部分按照一定的规则划分为对应的操作数
    • 执行:根据操作码和操作数,做对应的计算
    • 更新pc:如果前面执行成功,则更新下一条指令的地址pc = pc + 1
  3. 两者的联系是什么?

    YEMU执行指令,会更新pc,同时可能修改寄存器的值。状态机屏蔽了程序指令执行时的细节,只会展示最后指令的结果;而单独执行一条指令则会将TRM的执行细节完整展示出来。

RTFM(2)

主线任务:

  • 阅读生存手册中指令集相关的章节,了解指令确切的行为

    • 每一条指令具体行为的描述

    • 指令opcode的编码表格

  • 弄懂exec_once()函数的技术细节:整理一条指令在NEMU中的执行过程.

  • 用于译码和执行之间的解耦的INSTPAT,其定义的模式匹配规则是什么?其作用是什么?

  • 受限于指令长度最大32位的risv32,应该如何进行超过32位的常数读取操作?

  • PA2任务一:实现若干条指令, 使得第一个简单的C程序dummy可以在NEMU中运行起来. (待实现的指令,参考其反汇编结果am-kernels/tests/cpu-tests/build/dummy-$ISA-nemu.txt

  • 执行未实现指令的时候, NEMU具体会怎么做?

    invalid opcode(PC = 0x80000000):
    13 04 00 00 17 91 00 00 ...
    00000413 00009117...
    There are two cases which will trigger this unexpected exception:
    1. The instruction at PC = 0x80000000 is not implemented.
    2. Something is implemented incorrectly.
  • 实现更多的指令,通过测试用例(暂停stringhello-str的实现)

  • AT&T格式反汇编结果中的少量指令,与risv32手册中列出的指令名称不符,如何在手册中找到对应的指令?

支线任务:

  • 在PA中, riscv32的客户程序只会由RV32I和RV32M两类指令组成。这两类指令是怎么定义的?
  • 这篇文章叙述了RISC-V的理念以及成长的一些历史
  • 理解NEMU如何用模式匹配规则(nemu/src/isa/$ISA/inst.c),使得新增指令变得简单
  • 让GCC编译NEMU的时候顺便输出预处理的结果
  • 为什么执行了未实现指令会出现报错信息?

要求:

  • 请用clean code来编写代码
  • 及时测试代码
  • 及时做笔记的方式来整理代码细节(必须滴)
  • 尽可能地理解每一处细节

取指

在执行指令之前,需要获取这个指令,我们看下NEMU如何获取一条指令的。

exec_once()接受一个Decode类型的结构体指针s.这个结构体存放“在执行一条指令过程中所需的信息”。

Decode结构体定义在nemu/include/cpu/decode.h

typedef struct Decode {
vaddr_t pc;
vaddr_t snpc; // static next pc
vaddr_t dnpc; // dynamic next pc
ISADecodeInfo isa;
IFDEF(CONFIG_ITRACE, char logbuf[128]);
} Decode;

这里可以看出,除了指令的地址信息pcsnpcdnpc,还包括了一个与ISA相关的结构体抽象ISADecodeInfo.

其具体的定义在nemu/src/isa/$ISA/include/isa-def.h

typedef struct {
union {
uint32_t val;
} inst;
} MUXDEF(CONFIG_RV64, riscv64_ISADecodeInfo, riscv32_ISADecodeInfo);

这个ISADecodeInfo结构体中包含了一个联合体inst,联合体中有一个uint32_t类型的成员val。 RISC-V 32 位架构中,每一条指令的长度都是 32 位,因此这个 val 可以用于存储一条完整的指令。

现在exec_once()函数接收了传入参数s,然后将当前的PC保存到s的成员pcsnpc中。随后调用isa_exec_once()进行指令的执行操作。

函数isa_exec_once()定义在nemu/src/isa/riscv32/inst.c

int isa_exec_once(Decode *s) {
s->isa.inst.val = inst_fetch(&s->snpc, 4);
return decode_exec(s);
}

因为inst.val是用来存储一条完整指令的变量,所以推测inst_fetch()函数的功能,是用来取指令的。

下面我们看下函数inst_fetch()的定义(nemu/include/cpu/ifetch.h

static inline uint32_t inst_fetch(vaddr_t *pc, int len) {
uint32_t inst = vaddr_ifetch(*pc, len);
(*pc) += len;
return inst;
}

而函数vaddr_ifetch()的功能就是通过pc所指的客户程序地址,找到对应物理内存中的长度为len的数据。

函数isa_exec_once()pc->snpc的地址作为参数传入到函数vaddr_ifetch()中,所以函数vaddr_ifetch()取完数据后,会根据len(这里是4)来更新s -> snpc,从而让s -> snpc指向下一条指令。

已经获取的指令,将其存放于结构体s关于ISA信息的isa中。至此,取指令流程结束。

译码

随后s带着指令的信息,传入到函数decode_exec()开始译码,其定义在nemu/src/isa/riscv32/inst.c

static int decode_exec(Decode *s) {
int rd = 0;
word_t src1 = 0, src2 = 0, imm = 0;
s->dnpc = s->snpc;
#define INSTPAT_INST(s) ((s)->isa.inst.val)
#define INSTPAT_MATCH(s, name, type, ... /* execute body */ ) { \
decode_operand(s, &rd, &src1, &src2, &imm, concat(TYPE_, type)); \
__VA_ARGS__ ; \
}
INSTPAT_START();
INSTPAT("??????? ????? ????? ??? ????? 00101 11", auipc , U, R(rd) = s->pc + imm);
INSTPAT("??????? ????? ????? 100 ????? 00000 11", lbu , I, R(rd) = Mr(src1 + imm, 1));
INSTPAT("??????? ????? ????? 000 ????? 01000 11", sb , S, Mw(src1 + imm, 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();
R(0) = 0; // reset $zero to 0
return 0;
}

译码的目的是得到指令的操作和操作对象, 这主要是通过查看指令的opcode来决定的. NEMU通过一个模式字符串来指定指令中的opcode.

因为译码部分研读时候发现细节很多,所以为了理解这部分的内容,我这个小节的规划是:先从宏观角度讲译码做了什么,即译码的功能;随后着眼细节,剖析代码的筋骨纹理,看看译码是怎么实现这些功能的。

来不及解释了,我们开始⭐

功能

首先看下,如何获取指令中的opcode.

NEMU定义了用于识别对应opcode的模式匹配规则INSTPAT(意思是instruction pattern)

INSTPAT(模式字符串, 指令名称, 指令类型, 指令执行操作);

宏展开后,首先调用pattern_decode()函数,将一条包含opcode对应匹配规则的字符串,经过转换,作为opcode的判断参数。

然后再将输入的s中的指令信息s->isa.inst.val,经过位操作后,跟上一步骤中的opcode判断参数进行比对。

如果比对成功,则宣告了指令的操作类型已经确定,指令类型的译码工作已经完成。

指令类型确定后,随后便是对操作对象的译码处理decode_operand()。此函数根据传入的指令类型type来进行操作数的译码,译码结果会被保存起来。

decode_operand(s, &rd, &src1, &src2, &imm, TYPE_U);

以上就是宏观角度,屏蔽掉函数内部的复杂粒度,只概述每个函数的输入输出,从简化译码的逻辑。

但是实际的操作,还是需要依靠复杂的逻辑处理和对应c语言特性才能实现。下面,我们就着手细节,从细节上剖析译码的操作流程。

细节

首先看下如何实现的模式匹配。NEMU可以通过一个模式字符串来指定指令中opcode, 例如在riscv32中有如下模式:

INSTPAT_START();
INSTPAT("??????? ????? ????? ??? ????? 00101 11", auipc, U, R(rd) = s->pc + imm);
// ...
INSTPAT_END();

而定义每一条模式匹配规则的INSTPAT是一个宏,其格式为:

INSTPAT(模式字符串, 指令名称, 指令类型, 指令执行操作);

INSTPAT的各个参数的说明如下:

模式字符串中只允许出现4种字符:

  • 0表示相应的位只能匹配0
  • 1表示相应的位只能匹配1
  • ?表示相应的位可以匹配01
  • 空格是分隔符, 只用于提升模式字符串的可读性, 不参与匹配

指令名称在代码中仅当注释使用, 不参与宏展开;

指令类型用于后续译码过程;

指令执行操作则是通过C代码来模拟指令执行的真正行为.

下面看下INSTPAT宏如何转换为对应的C代码。

我们看下INSTPATINSTPAT_START()INSTPAT_END()其宏定义的具体实现。它们均被定义在nemu/include/cpu/decode.h中。

// --- pattern matching wrappers for decode ---
#define INSTPAT(pattern, ...) do { \
uint64_t key, mask, shift; \
pattern_decode(pattern, STRLEN(pattern), &key, &mask, &shift); \
if ((((uint64_t)INSTPAT_INST(s) >> shift) & mask) == key) { \
INSTPAT_MATCH(s, ##__VA_ARGS__); \
goto *(__instpat_end); \
} \
} while (0)
#define INSTPAT_START(name) { const void ** __instpat_end = &&concat(__instpat_end_, name);
#define INSTPAT_END(name) concat(__instpat_end_, name): ; }

INSTPAT又使用了另外两个宏INSTPAT_INSTINSTPAT_MATCH, 它们在nemu/src/isa/$ISA/inst.c中定义.

#define INSTPAT_INST(s) ((s)->isa.inst.val)
#define INSTPAT_MATCH(s, name, type, ... /* execute body */ ) { \
decode_operand(s, &rd, &src1, &src2, &imm, concat(TYPE_, type)); \
__VA_ARGS__ ; \
}

具体定义如上文所述,下面我们按照其源码,分析下INSTPATINSTPAT_START()INSTPAT_END()的具体逻辑。

首先是宏INSTPAT的各部分含义解析:

  1. pattern_decode 是一个函数,通过解析 pattern(模式)生成 keymaskshift 这三个变量。

  2. if ((((uint64_t)INSTPAT_INST(s) >> shift) & mask) == key)

    • INSTPAT_INST(s) 是一个宏,用于从存放指令执行信息的结构体 s 中提取指令或数据。其逻辑实现为

      #define INSTPAT_INST(s) ((s)->isa.inst.val)
    • (uint64_t)INSTPAT_INST(s)将提取到的指令,转换为64位的整数

    • & mask:通过掩码操作保留需要匹配的位,屏蔽掉其他不相关的位。

    • == key:最后将处理后的结果与 key 进行比较,判断当前指令或数据是否符合指定模式。

  3. INSTPAT_MATCH(s, ##__VA_ARGS__)

    • 当2阶段if判断条件成立,宏调用INSTPAT_MATCH,执行与该模式匹配的逻辑

      #define INSTPAT_MATCH(s, name, type, ... /* execute body */ ) { \
      decode_operand(s, &rd, &src1, &src2, &imm, concat(TYPE_, type)); \
      __VA_ARGS__ ; \
      }
    • ##__VA_ARGS__ 表示可变参数,允许传递多个参数给 INSTPAT_MATCH,使这个宏更灵活。

  4. goto *(__instpat_end)

    • 匹配成功,代码跳转到预定义的指针__instpat_end所指的地址。__instpat_end实现在INSTPAT_START

总结INSTPAT宏的整体逻辑:

  1. 通过pattern_decode()函数解析传入的指令模式pattern,用与生成匹配的 keymaskshift
  2. s 中提取待处理的指令或数据,并根据 shiftmaskkey 进行模式匹配。
  3. 如果匹配成功,执行 INSTPAT_MATCH 中对应__VA_ARGS__的操作
  4. 最后 goto *(__instpat_end) 跳转到预定义的位置,可能是为了跳过某些指令或结束当前匹配过程。

分析完毕INSTPAT的整体逻辑后,再深入分析下函数pattern_decode()decode_operand()的逻辑。

首先是将模式字符串解析到变量keymaskshift的函数pattern_decode(),定义在nemu/include/cpu/decode.h中。

static inline void pattern_decode(const char *str, int len,
uint64_t *key, uint64_t *mask, uint64_t *shift) {
uint64_t __key = 0, __mask = 0, __shift = 0;
#define macro(i) \
if ((i) >= len) goto finish; \
else { \
char c = str[i]; \
if (c != ' ') { \
Assert(c == '0' || c == '1' || c == '?', \
"invalid character '%c' in pattern string", c); \
__key = (__key << 1) | (c == '1' ? 1 : 0); \
__mask = (__mask << 1) | (c == '?' ? 0 : 1); \
__shift = (c == '?' ? __shift + 1 : 0); \
} \
}
#define macro2(i) macro(i); macro((i) + 1)
#define macro4(i) macro2(i); macro2((i) + 2)
#define macro8(i) macro4(i); macro4((i) + 4)
#define macro16(i) macro8(i); macro8((i) + 8)
#define macro32(i) macro16(i); macro16((i) + 16)
#define macro64(i) macro32(i); macro32((i) + 32)
macro64(0); // 从索引 0 开始解析字符串
panic("pattern too long"); // 如果解析到这里,表示字符串超长
#undef macro
finish:
*key = __key >> __shift; // 将 __key 右移 __shift 位
*mask = __mask >> __shift; // 将 __mask 右移 __shift 位
*shift = __shift; // 返回移位值
}

里面比较有意思的是宏macro(i)的相关定义,这里学习下。

#define macro(i) \
if ((i) >= len) goto finish; \
else { \
char c = str[i]; \
if (c != ' ') { \
Assert(c == '0' || c == '1' || c == '?', \
"invalid character '%c' in pattern string", c); \
__key = (__key << 1) | (c == '1' ? 1 : 0); \
__mask = (__mask << 1) | (c == '?' ? 0 : 1); \
__shift = (c == '?' ? __shift + 1 : 0); \
} \
}
  • 边界检查:如果索引 i 超过字符串长度 len,则跳转到 finish 标签,结束解析
  • 字符处理:
    • 获取字符串中索引为i的字符c
    • 如果字符为非空格,继续下面的执行
    • 使用宏Assert()确保字符是01?
    • 根据字符更新__key__mask__shift
      • 1:将 __key 向左移位并设置最低位为 1,同时在 __mask 中将相应位设为 1
      • 0:将 __key 向左移位并设置最低位为 0,同时在 __mask 中将相应位设为 1
      • ?:不影响__Key,在__mask中对应位设置为0,并增加__shift的计数

这里我们单独拎出__key的处理方法,来看看是怎么根据当前字符c来决定这个参数值的

__key = (__key << 1) | (c == '1' ? 1 : 0);
  1. 左移操作__key << 1
  2. 条件表达式(c == '1' ? 1 : 0)
  3. 按位或操作|:按位或是位运算的一种,是将两个数据的二进制表示右对齐后,按位进行运算,两个对应的二进制位中只要一个是1,结果对应位就是1。

举例:如果当前__key值为5(二进制0101

  • 当前字符c1,则__key = 1010 | 1 = 1011
  • 当前字符c0,则__key = 1010 | 0 = 1010

其实这行代码的目的是逐个处理字符串中的字符,并根据字符是否为1,来构建一个二进制数。举一反三,其他两个参数__mask__shift的值获取方式类似。

pattern_decode()中另一个需要学习的点,就是用于简化和加速字符串解析过程的宏扩展。

#define macro2(i) macro(i); macro((i) + 1)
  • 顺序执行:带有分号的 macro(i); 使得它是一个完整的语句,接着在同一行执行 macro((i) + 1)。这两者是顺序执行的,编译器能够正确解析。
  • 通过一次宏调用处理两个字符,相比于逐个调用 macro(i),可以减少宏调用的次数,从而提高解析效率。

学习完主要的宏macro(i)后,我们再回头对函数pattern_decode()主要部分进行解析

  • macro(i):将字符串表示的指令模式转换为可以用于比较的位值和掩码

  • macro64(0):从索引0开始,处理从 0 到 63 的字符。

  • panic():尝试处理的字符超出了实际字符串的长度范围,会调用此函数报错。

    举例字符串如果为1010,而len为十进制的10。将这个4位的字符串用函数macro4(0)处理时,即使处理完字符串,也会因为if语句不成立,不会跳转到正常的finish部分。所以会调用函数panic()报错。

  • finish:保留__key__mask的有效位,并将解析过程中遇到的 ? 字符的数量,有效位的偏移量__shift返回

这个pattern_decode()的含金量还是蛮高的,总结下里面值得学习的东西有:

  1. 宏的使用和递归解析:灵活地处理输入字符串的不同长度
  2. 将字符串转换为keymask:增加新类型的字符或改变解析规则只需调整宏逻辑,而无需重写整个解析过程。
  3. 错误保护机制
  4. 代码维护性:体现在宏和finish。所有解析结果的更新集中在finish:标签处,避免了代码重复。

分析完毕pattern_decode(),再具体分析下函数decode_operand()

INSTPAT的整体逻辑不仅包含模式字符串处理函数pattern_decode(),而且包含另一个匹配操作码行为的函数decode_operand()

#define src1R() do { *src1 = R(rs1); } while (0)
#define src2R() do { *src2 = R(rs2); } while (0)
#define immI() do { *imm = SEXT(BITS(i, 31, 20), 12); } while(0)
#define immU() do { *imm = SEXT(BITS(i, 31, 12), 20) << 12; } while(0)
#define immS() do { *imm = (SEXT(BITS(i, 31, 25), 7) << 5) | BITS(i, 11, 7); } while(0)
static void decode_operand(Decode *s, int *rd, word_t *src1, word_t *src2, word_t *imm, int type) {
uint32_t i = s->isa.inst.val;
int rs1 = BITS(i, 19, 15);
int rs2 = BITS(i, 24, 20);
*rd = BITS(i, 11, 7);
switch (type) {
case TYPE_I: src1R(); immI(); break;
case TYPE_U: immU(); break;
case TYPE_S: src1R(); src2R(); immS(); break;
}
}

其中用到的宏定义BITSSEXTnemu/include/macro.h中定义,分别用于位抽取和符号扩展

#define BITMASK(bits) ((1ull << (bits)) - 1)
#define BITS(x, hi, lo) (((x) >> (lo)) & BITMASK((hi) - (lo) + 1)) // similar to x[hi:lo] in verilog
#define SEXT(x, len) ({ struct { int64_t n : len; } __x = { .n = x }; (uint64_t)__x.n; })

宏的简单介绍:

  • BITMASK(bits):生成一个由 bits 个低位为 1 的无符号长整型掩码。例如,BITMASK(3) 的结果是 0b111,即十进制的 7。
  • BITS(x, hi, lo):获取x[lo, hi]部分数据
  • SEXT(x, len):将x符号扩展为64位的无符号整数

这里深入学习下宏SEXT():

#define SEXT(x, len) ({ struct { int64_t n : len; } __x = { .n = x }; (uint64_t)__x.n; })
  1. 位域结构体的定义

    { struct { int64_t n : len; } __x;

    这部分定义了一个匿名的结构体 __x,其中有一个名为 n 的成员。这个成员 n 是一个位域(bit-field),它使用 len 位来存储数据。

  2. 结构体的初始化

    __x = { .n = x };

    这一部分使用的是指定成员初始化的语法。这种初始化方式允许你在定义结构体实例时,直接为其中的特定成员赋值。下面是一些关键点:

    • __x 是一个结构体变量,它的类型是前面定义的匿名结构体类型。
    • { .n = x } 是一个初始化语句,表示将 x 的值赋给结构体 __x 的成员 n。这里的 .n 是指定成员 n 来初始化的语法。

    使用 { .member_name = value } 的形式,可以初始化结构体中的特定成员,而不是依赖于结构体定义的顺序。在这段代码中,x 的值会赋给 n,并且由于 n 是一个位域,它会自动根据 len 进行处理,可能会截断或者扩展。

  3. 符号扩展与位域

    在宏 SEXT(x, len) 中,这段代码的作用是将 x 的值放入一个占用 len 位的位域 n 中。位域会根据 len 这个长度自动处理 x 的值,包括保留符号位进行符号扩展。

    具体的过程是:

    • 如果 x 是一个较短的有符号整数,n : len 会将它截断或扩展到 len 位。
    • 位域中,如果 x 是负数并且需要扩展位数,符号位(最高位)会自动扩展到更大的范围。
    • 这样可以方便地实现符号扩展,而不用手动进行位移和掩码操作。
  4. 语法扩展 ({ ... })

    GCC 语法扩展,它允许在宏定义中包含一段复合语句并返回一个值。这段复合语句相当于一个代码块,最后一个表达式的值会被返回。

  5. 总结

    这个宏的目的是将一个 x 值(通常是较短的有符号整数)进行符号扩展,扩展到 len 指定的位数长度。

这样函数decode_operand()根据指令的类型type,来进行操作数的译码,并将译码结果记录到函数参数rd, src1, src2imm中, 它们分别代表目的操作数的寄存器号码, 两个源操作数和立即数.

执行

执行的操作其实被包含在了宏INSTPAT中了:

// --- pattern matching wrappers for decode ---
#define INSTPAT(pattern, ...) do { \
uint64_t key, mask, shift; \
pattern_decode(pattern, STRLEN(pattern), &key, &mask, &shift); \
if ((((uint64_t)INSTPAT_INST(s) >> shift) & mask) == key) { \
INSTPAT_MATCH(s, ##__VA_ARGS__); \
goto *(__instpat_end); \
} \
} while (0)

当指令的类型被确定后,即判断条件成立:

(((uint64_t)INSTPAT_INST(s) >> shift) & mask) == key

这时候来到了INSTPAT_MATCH流程实现两个功能:

#define INSTPAT_MATCH(s, name, type, ... /* execute body */ ) { \
decode_operand(s, &rd, &src1, &src2, &imm, concat(TYPE_, type)); \
__VA_ARGS__ ; \
}
  1. 调用函数decode_operand(),将指令中的寄存器、立即数等解析到对应的变量 rd, src1, src2, imm 中。
  2. __VA_ARGS__:传入的额外参数(可变参数)会被替换到 __VA_ARGS__ 这个位置上.如果想传入多条语句作为 __VA_ARGS__,可以直接在宏调用中传递这些语句,并将它们用分号分隔,或者用大括号{...}将多条语句包裹起来。

INSTPAT_MATCH举例

假设你这样调用宏:

INSTPAT_MATCH(s, "auipc", U, R(rd) = s->pc + imm);

此时宏展开为:

{
decode_operand(s, &rd, &src1, &src2, &imm, concat(TYPE_, U));
R(rd) = s->pc + imm;
}

这里,__VA_ARGS__ 被替换为了 R(rd) = s->pc + imm,相当于你可以在宏调用时插入一段可执行代码。

这样就就完成了指令的执行。

指令执行的阶段结束之后, decode_exec()函数将会返回0, 并一路返回到exec_once()函数中. 不过目前代码并没有使用这个返回值, 因此可以忽略它.

更新PC

最后是更新PC. 更新PC的操作非常简单, 只需要把s->dnpc赋值给cpu.pc即可.

snpc是下一条静态指令, 而dnpc是下一条动态指令.

对于顺序执行的指令, 它们的snpcdnpc是一样的; 但对于跳转指令, snpcdnpc就会有所不同, dnpc应该指向跳转目标的指令. 显然, 我们应该使用s->dnpc来更新PC, 并且在指令执行的过程中正确地维护s->dnpc.

运行第一个C程序

主线任务:

  1. 实现dummy反汇编中具有的指令,并且成功运行

支线任务:

  1. 为什么执行了未实现指令会出现上述报错信息?(思考题)

    invalid opcode(PC = 0x80000000):
    13 04 00 00 17 91 00 00 ...
    00000413 00009117...
    There are two cases which will trigger this unexpected exception:
    1. The instruction at PC = 0x80000000 is not implemented.
    2. Something is implemented incorrectly.
    Find this PC(0x80000000) in the disassembling result to distinguish which case it is.
    If it is the first case, see
    _ __ __ _
    (_) | \/ | | |
    _ __ _ ___ ___ ________ __ | \ / | __ _ _ __ _ _ __ _| |
    | '__| / __|/ __|______\ \ / / | |\/| |/ _` | '_ \| | | |/ _` | |
    | | | \__ \ (__ \ V / | | | | (_| | | | | |_| | (_| | |
    |_| |_|___/\___| \_/ |_| |_|\__,_|_| |_|\__,_|\__,_|_|
    for more details.
    If it is the second case, remember:
    * The machine is always right!
    * Every line of untested code is always wrong!

RISC-V指令格式

在RISC-V 32位(RV32)架构的指令集中,每条指令的长度为固定的32位。指令的不同部分表示不同的字段,具体字段及其意义取决于指令的类型。RISC-V 有多种指令格式,最常见的包括 R型I型S型B型U型J型。每种格式的指令字段布局略有不同。下面我将简要介绍这些格式以及它们对应的位区间代表的参数。

  1. R型指令(R-Type Instruction)

R型指令用于寄存器间的操作,两个源寄存器和一个目标寄存器。

位区间 字段名 位宽 描述
[31:25] funct7 7 功能码(确定具体运算类型)
[24:20] rs2 5 源寄存器2
[19:15] rs1 5 源寄存器1
[14:12] funct3 3 功能码(决定运算类型)
[11:7] rd 5 目标寄存器
[6:0] opcode 7 操作码(确定指令类型)
  • opcode:操作码,决定指令类型,如加法、减法、按位与等。
  • funct3funct7:这两个字段共同决定了具体的运算类型。
  • rs1rs2:两个源寄存器。
  • rd:目标寄存器。

例子ADD x1, x2, x3 是一条 R型指令,表示将 x2x3 中的值相加,并将结果存入 x1

  1. I型指令(I-Type Instruction)

I型指令包含一个12位的立即数,通常用于立即数运算或内存访问。

位区间 字段名 位宽 描述
[31:20] imm[11:0] 12 立即数(符号扩展到32位)
[19:15] rs1 5 源寄存器1
[14:12] funct3 3 功能码(决定运算类型)
[11:7] rd 5 目标寄存器
[6:0] opcode 7 操作码
  • imm[11:0]:12位的立即数,经过符号扩展用于运算。
  • rs1:源寄存器1,存储第一个操作数。
  • rd:目标寄存器,存储运算结果。
  • opcodefunct3:共同决定具体指令和运算类型。

例子ADDI x1, x2, 10 表示将 x2 中的值与立即数 10 相加,并将结果存入 x1

  1. S型指令(S-Type Instruction)

S型指令用于内存存储操作,如存储字节、存储半字或存储字。

位区间 字段名 位宽 描述
[31:25] imm[11:5] 7 立即数(高7位)
[24:20] rs2 5 源寄存器2
[19:15] rs1 5 源寄存器1
[14:12] funct3 3 功能码
[11:7] imm[4:0] 5 立即数(低5位)
[6:0] opcode 7 操作码
  • imm[11:5]imm[4:0]:立即数分为高7位和低5位,构成12位立即数,用于计算内存地址偏移。
  • rs1:源寄存器1,用于提供基地址。
  • rs2:源寄存器2,提供要存储的值。

例子SW x2, 4(x3) 表示将 x2 中的值存储到 x3 + 4 所指向的内存地址中。

  1. B型指令(B-Type Instruction)

B型指令用于条件跳转操作,使用两个源寄存器进行比较。

位区间 字段名 位宽 描述
[31] imm[12] 1 立即数(第12位)
[30:25] imm[10:5] 6 立即数(第10到5位)
[24:20] rs2 5 源寄存器2
[19:15] rs1 5 源寄存器1
[14:12] funct3 3 功能码
[11:8] imm[4:1] 4 立即数(第4到1位)
[7] imm[11] 1 立即数(第11位)
[6:0] opcode 7 操作码
  • imm[12:1]:组合成一个分支目标地址的偏移量,符号扩展后用于跳转。
  • rs1rs2:用于比较的两个源寄存器。
  • opcodefunct3:决定具体的条件跳转类型。

例子BEQ x1, x2, offset 表示当 x1 等于 x2 时,跳转到指定的偏移地址。

  1. U型指令(U-Type Instruction)

U型指令用于加载高位立即数,常见的指令如 LUIAUIPC

位区间 字段名 位宽 描述
[31:12] imm[31:12] 20 立即数的高20位
[11:7] rd 5 目标寄存器
[6:0] opcode 7 操作码
  • imm[31:12]:立即数的高20位,低12位为0,组成一个32位立即数。
  • rd:目标寄存器,用于存储立即数结果。

例子LUI x1, 0x12345 表示将 0x12345000 存入寄存器 x1

  1. J型指令(J-Type Instruction)

    J型指令用于无条件跳转,常见的指令是 JAL(Jump and Link)。

位区间 字段名 位宽 描述
[31] imm[20] 1 立即数的第20位
[30:21] imm[10:1] 10 立即数的第10到1位
[20] imm[11] 1 立即数的第11位
[19:12] imm[19:12] 8 立即数的第19到12位
[11:7] rd 5 目标寄存器
[6:0] opcode 7 操作码
  • imm[20:1]:跳转目标地址的偏移量,符号扩展后与当前PC相加形成跳转地址。
  • rd:存储返回地址的寄存器。

例子JAL x1, offset 表示跳转到指定的偏移地址,并将返回地址存入 x1

首先看下dummy反汇编结果(am-kernels/tests/cpu-tests/build/dummy-riscv32-nemu.txt

Disassembly of section .text:
80000000 <_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>
80000010 <main>:
80000010: 00000513 li a0,0
80000014: 00008067 ret
80000018 <_trm_init>:
80000018: ff010113 addi sp,sp,-16
8000001c: 00000517 auipc a0,0x0
80000020: 01c50513 addi a0,a0,28 # 80000038 <_etext>
80000024: 00112623 sw ra,12(sp)
80000028: fe9ff0ef jal ra,80000010 <main>
8000002c: 00050513 mv a0,a0
80000030: 00100073 ebreak
80000034: 0000006f j 80000034 <_trm_init+0x1c>

.text是程序的代码段,主要用于存放可执行的机器指令。

对比INSTPAT已经实现的模式匹配规则,我们需要实现这几个指令

  • addiI类型指令,加立即数。

    addi rd, rs1, immediate //x[rd] = x[rs1] + sext(immediate)
  • jalJ类型指令,跳转并链接。JAL rd,imm

    jal rd, offset //x[rd] = pc+4; pc += sext(offset)
  • li:伪指令,CI类型指令,转入立即数 (即ADDI rd,x0,imm)

  • retI类型指令,返回 (即 JALR x0,0(ra))

    jalr rd, offset(rs1) // t =pc+4; pc=(x[rs1]+sext(offset))&~1; x[rd]=t
  • swS类型,存字SW rs2,imm(rs1)

    sw rs2, offset(rs1) //M[x[rs1] + sext(offset)] = x[rs2][31: 0]
  • mv:伪指令,R类型,传送 (即 ADDI rd,rs,0)

  • jJ类型,跳转 (即 JAL x0,imm)

可以看出,伪指令limv都是基于前置指令addi实现的。同样地,伪指令ret基于前置指令JALR(Jump And Link Register);伪指令j可以通过JAL(Jump And Link)指令来实现。

所以我们要添加INSTPAT来匹配的指令就是addijaljalrsw。而指令的具体操作可以参考RV32I基础整数指令集.

运行更多的程序

add-longlong-run

bit

bubble-sort

crc32

div

goldbach

if-else

load-store

mersenne

movsx

mul-longlong

这个文件似乎有问题:

ij均为0时,

int test_data[0] = 0xaeb1c2aa
long long ans[0] = 0x19d29ab9db1a18e4LL
long long result = mul(test_data[0],test_data[0]);//0x7736 200D DB1A 18E4
check(ans[0] == result);

很明显两个数的高32位不同。

shift

程序, 运行时环境与AM

主线任务:

  • 通过批处理模式运行NEMU:阅读NEMU的代码并合适地修改Makefile, 使得通过AM的Makefile可以默认启动批处理模式的NEMU.
  • 实现abstract-machine/klib/src/string.c中列出的字符串处理函数, 让cpu-tests中的测试用例string可以成功运行
  • 实现库函数sprintf(),通过测试用例hello-str

支线任务:

一些阅读时候的思考。

  • 架构和程序解耦的思考:n个程序运行在m个架构上,终止指令不同,难道要维护n*m份代码?

    答:例如对m个架构的终止方式统一抽象为一个接口halt(),只需要维护m个架构的具体终止接口halt()即可

  • 如果程序需要大量的架构功能实现的接口怎么办?

    答:将程序所需要的架构接口,抽象为API后,集合为一个库

  • 这样抽象的好处是什么?

    答:不同的程序不需要实现对应架构的特殊指令,减少了代码量。降低了维护成本

  • AM是什么?

    答:抽象计算机。将硬件的功能打包成接口,方便程序运行时候调用。满足了程序运行对计算机的需求

  • PA到底想实现什么?

    答:模拟计算机硬件(cpu、内存等),然后再实现AM,通过两者配合为程序的运行保驾护航

AM - 裸机(bare-metal)运行时环境

AM(Abstract machine)项目就是这样诞生的. 作为一个向程序提供运行时环境的库, AM根据程序的需求把库划分成以下模块

AM = TRM + IOE + CTE + VME + MPE
  • TRM(Turing Machine) - 图灵机, 最简单的运行时环境, 为程序提供基本的计算能力
  • IOE(I/O Extension) - 输入输出扩展, 为程序提供输出输入的能力
  • CTE(Context Extension) - 上下文扩展, 为程序提供上下文管理的能力
  • VME(Virtual Memory Extension) - 虚存扩展, 为程序提供虚存管理的能力
  • MPE(Multi-Processor Extension) - 多处理器扩展, 为程序提供多处理器通信的能力 (MPE超出了ICS课程的范围, 在PA中不会涉及)

AM给我们展示了程序与计算机的关系: 利用计算机硬件的功能实现AM, 为程序的运行提供它们所需要的运行时环境.

而怎么理解下面这段话,就很有意思了😀

实现NEMU(硬件)和AM(软件)之间的桥梁来支撑程序的运行,是理解PA终极目标“理解程序如何在计算机上运行”的唯一选择。

  • NEMU:是一个模拟硬件运行的工具,它可以模拟出真实的硬件环境(比如处理器、内存等),让我们在没有真实硬件的情况下也能运行和测试程序。通过使用NEMU,我们可以理解硬件层面上的运作机制。
  • AM:是软件层面上为程序提供统一接口的环境。它让程序不需要直接接触硬件,而是通过一组抽象的API与底层硬件交互。AM相当于在硬件和应用程序之间搭建了一座桥梁,屏蔽了硬件的复杂性。
  • 搭建桥梁:要让程序能够顺利在硬件上运行,我们需要理解并搭建NEMU和AM之间的联系。具体来说,就是要理解程序如何通过AM调用硬件资源,NEMU如何模拟硬件响应程序的请求。

RTFSC(3)

abstract-machine/am/src/platform/nemu/trm.chalt()中调用了nemu_trap()宏 (在abstract-machine/am/src/platform/nemu/include/nemu.h中定义)

这个宏展开之后是一条内联汇编语句. 内联汇编语句允许我们在C代码中嵌入汇编语句, 以riscv32为例, nemu_trap(code)宏展开之后将会得到:

asm volatile("mv a0, %0; ebreak" : :"r"(code));

显然, 这个宏的定义是和ISA相关的, 如果你查看nemu/src/isa/$ISA/inst.c, 你会发现这条指令正是那条特殊的nemu_trap! nemu_trap()宏还会把一个标识结束的结束码移动到通用寄存器中, 这样, 这段汇编代码的功能就和nemu/src/isa/$ISA/inst.cnemu_trap的行为对应起来了: 通用寄存器中的值将会作为参数传给set_nemu_state(), 将halt()中的结束码设置到NEMU的monitor中, monitor将会根据结束码来报告程序结束的原因.

这里需要搞明白NEMU在执行指令ebreak的时候,到底做了什么事情?

指令与对应的ebreak模式匹配

INSTPAT("0000000 00001 00000 000 00000 11100 11", ebreak, N, NEMUTRAP(s->pc, R(10)));

而对应的宏NEMUTRAPnemu/include/cpu/cpu.h中定义(对应的set_nemu_statenemu/src/engine/interpreter/hostcall.c定义)

void set_nemu_state(int state, vaddr_t pc, int halt_ret) {
difftest_skip_ref();
nemu_state.state = state;
nemu_state.halt_pc = pc;
nemu_state.halt_ret = halt_ret;
}
#define NEMUTRAP(thispc, code) set_nemu_state(NEMU_END, thispc, code)

展开后便可以看到执行指令ebreak的操作

{ const void ** __instpat_end = &&__instpat_end_;
do {
uint64_t key, mask, shift;
pattern_decode("0000000 00001 00000 000 00000 11100 11", 38, &key, &mask, &shift);
if ((((uint64_t)s->isa.inst.val >> shift) & mask) == key) {
{
decode_operand(s, &rd, &src1, &src2, &imm, TYPE_N);
nemu_state.state = NEMU_END;
nemu_state.halt_pc = s->pc;
nemu_state.halt_ret = halt_ret;
}
goto *(__instpat_end);
}
} while (0);
// ...
__instpat_end_: ; }

所以AM实现了统一的终止方法halt()后,会自动调用对应NEMU架构下(此时为RISCV32)的中断指令ebreak.

当NEMU上运行的程序想要终止进程的时候,就可以调用库方法halt()来实现。这样做的好处就不用程序考虑NEMU的具体架构及其对应的终止方法,提升了开发效率。

这里完成第一个支线任务:通过批处理模式运行NEMU

任务

NEMU中实现了一个批处理模式, 可以在启动NEMU之后直接运行客户程序. 请你阅读NEMU的代码并合适地修改Makefile, 使得通过AM的Makefile可以默认启动批处理模式的NEMU.

解决思路

  • NEMU的批处理模式在nemu/src/monitor/sdb/sdb.c中定义

    static int is_batch_mode = false;
    void sdb_set_batch_mode() {
    is_batch_mode = true;
    }

    并且在NEMU的简易调试器(Simple Debugger)主循环sdb_mainloop() (在nemu/src/monitor/sdb/sdb.c中定义)中,可以看到批处理模式如何影响执行程序的。

    void sdb_mainloop() {
    if (is_batch_mode) {
    cmd_c(NULL);
    return;
    }
    //...
    }
  • 根据上面提供的信息,要想"通过AM的Makefile可以默认启动批处理模式的NEMU",就需要修改AM的Makefile中的启动NEMU的命令。

    直接在Makefile的编译选项CFLAGS中,加入批处理的宏BATCH_MODE

    CFLAGS += -DBATCH_MODE

    这样即可根据BATCH_MODE是否定义来确定此程序是否为AM编译的。

    随后在函数engine_start()(在nemu/src/engine/interpreter/init.c定义)中,添加对于批处理模式的识别

    void engine_start() {
    #ifdef CONFIG_TARGET_AM
    cpu_exec(-1);
    #else
    /* Receive commands from user. */
    #ifdef BATCH_MODE
    sdb_set_batch_mode();
    #endif
    sdb_mainloop();
    #endif
    }

    等后续编译完成后再来验证是否正确。//TODO

而在实现主线任务的时候,有几个C语言的小技巧帮助了我很多,在此记录下:

  • 以字节为单位索引区域s

    unsigned char *p = s;
  • 内存重叠:从后往前复制即可

    unsigned char *d = dest;
    const unsigned char *s = src;
    *(--d) = *(--s);
  • 将数字的个位转换为对应的字符串

    char a = (num % 10) + '0';

🎏截至2024年10月17号,已完成此小结主线任务

基础设施(2)

主线任务:

  • 实现指令环形缓冲区iringbuf
  • 实现mtrace
  • 实现ftrace
  • 实现DiffTest
  • am-kernels/tests/目录下新增一个针对klib的测试集klib-tests
  • 在Kconfig和相关文件中添加相应的代码, 使得可以通过menuconfig来打开或者关闭mtrace

支线任务:

  • 如何自定义输出trace的条件?
  • 理解如何生成native的可执行文件
  • 尝试为klib-tests添加针对第二类只读函数的测试, 例如memcmp(), strlen()
  • 尝试为klib-tests添加针对格式化输出函数的测试. 你可以先通过sprintf()把实际输出打印到一个缓冲区中, 然后通过strcmp()来和预期输出进行对比.
  • 捕捉死循环(有点难度)

bug诊断的利器 - 踪迹

itrace

  1. 踪迹功能 -- NEMU如何记录inst_fetch()取到的每一条指令的

首先看下build/nemu-log.txt在执行默认程序的时候,记录的内容

^[[1;34m[src/monitor/monitor.c:29 welcome] If trace is enabled, a log file will be generated to record the trace. This may lead to a large log file. If it is not necessary, you can disable it in menuconfig^[[0m
^[[1;34m[src/monitor/monitor.c:32 welcome] Build time: 17:00:42, Sep 10 2024^[[0m
0x80000000: 00 00 02 97 auipc t0, 0
0x80000004: 00 02 88 23 sb zero, 16(t0)
0x80000008: 01 02 c5 03 lbu a0, 16(t0)
0x8000000c: 00 10 00 73 ebreak
^[[1;34m[src/cpu/cpu-exec.c:130 cpu_exec] nemu: ^[[1;32mHIT GOOD TRAP^[[0m at pc = 0x8000000c^[[0m

而这个记录功能,就在cpu执行一条指令的函数exec_once()中实现的

static void exec_once(Decode *s, vaddr_t pc) {
s->pc = pc;
s->snpc = pc;
isa_exec_once(s);
cpu.pc = s->dnpc;
#ifdef CONFIG_ITRACE
/*
实现的具体逻辑
*/
#endif
#endif
}

cpu执行完一条指令并更新pc后,便会根据是否设置CONFIG_ITRACE来做已执行指令的记录功能。

我们单独看下具体的记录逻辑

#ifdef CONFIG_ITRACE
char *p = s->logbuf;
// log记录刚才执行指令的pc值
p += snprintf(p, sizeof(s->logbuf), FMT_WORD ":", s->pc);
int ilen = s->snpc - s->pc; // 指令的字节数
int i;
uint8_t *inst = (uint8_t *)&s->isa.inst.val; // 按照字节顺序访问此指令
for (i = ilen - 1; i >= 0; i --) {
p += snprintf(p, 4, " %02x", inst[i]); // 按照字节顺序打印此指令的十六进制值
}
// log加 space_len 个空格的间隔
int ilen_max = MUXDEF(CONFIG_ISA_x86, 8, 4);
int space_len = ilen_max - ilen;
if (space_len < 0) space_len = 0;
space_len = space_len * 3 + 1;
memset(p, ' ', space_len);
p += space_len;
// log加入反汇编的结果
/* 参数包括
p: 指向日志缓冲区的指针
s->logbuf + sizeof(s->logbuf) - p: 指定可用的缓冲区大小
MUXDEF(CONFIG_ISA_x86, s->snpc, s->pc): 指定程序计数器
(uint8_t *)&s->isa.inst.val:指向当前指令的代码
ilen:指令的字节数
*/
void disassemble(char *str, int size, uint64_t pc, uint8_t *code, int nbyte);
disassemble(p, s->logbuf + sizeof(s->logbuf) - p,
MUXDEF(CONFIG_ISA_x86, s->snpc, s->pc), (uint8_t *)&s->isa.inst.val, ilen);
#endif

对于其中的

void disassemble(char *str, int size, uint64_t pc, uint8_t *code, int nbyte);

这是一个函数声明,意味着 disassemble 函数在其他地方已经定义。此处的声明告诉编译器该函数的参数和返回类型,但并不提供其实现。

而当编译器看到一个函数调用时,它会检查传递给该函数的参数类型是否与函数声明中定义的参数类型匹配。如果不匹配,编译器会发出错误提示。这有助于捕获潜在的错误,减少运行时错误的可能性。

  1. 如何自定义输出trace?

回顾sic均会调用函数cpu_exec(n)来实现对应的功能,而差异就在n的大小上。

执行一条指令si的时候,会打印当前执行的指令,而使用c执行完毕程序所有指令的时候,反而不会输出指令。

我们探究下指令的数量如何影响trace的输出。

  1. 调用函数cpu_exec()

    g_print_step = (n < MAX_INST_TO_PRINT);

    n若小于MAX_INST_TO_PRINT,则g_print_step被赋值为1.

    n若大于MAX_INST_TO_PRINT,则g_print_step被赋值为0.

    这里可以看出,c会执行cpu_exec(-1),而-1的无符号数要大于MAX_INST_TO_PRINT,所以会导致这个值始终为0.

  2. 调用trace_and_difftest()

    if (g_print_step) { IFDEF(CONFIG_ITRACE, puts(_this->logbuf));

    这样就可以得出指令的数量n会影响trace的输出了。

这是一个自定义输出trace的方法,其他方法日后用到再试。

iringbuf

  1. 如何判断异常执行?

我们看下cpu_exec()在执行指令的时候,对于异常状态的处理

// ....
case NEMU_END: case NEMU_ABORT:
Log("nemu: %s at pc = " FMT_WORD,
(nemu_state.state == NEMU_ABORT ? ANSI_FMT("ABORT", ANSI_FG_RED) :
(nemu_state.halt_ret == 0 ? ANSI_FMT("HIT GOOD TRAP", ANSI_FG_GREEN) :
ANSI_FMT("HIT BAD TRAP", ANSI_FG_RED))),
nemu_state.halt_pc);
// ....

可以看出当前状态如果不是NEMU_ABORT,同时nemu_state.halt_ret不为0时,则会打印HIT BAD TRAP

所以这个条件就是程序执行出错的条件。

  1. 如何获取最近执行的若干条指令?

在riscv-32的体系结构中,每条指令长度为4个字节。所以得知当前pc值,就可以得到上一条指令的pc值;反之,也可以得到下一条指令的pc值。得知指令的pc值后,就可以通过与itrace相同的办法输出这些指令了。

  1. 环形缓冲区的数据结构属性是什么?

带头和尾节点的链表。

  1. 环形缓冲区的需求梳理

如果客户程序出错,则输出环形缓冲区的内容

如果客户程序正常,则维护环形缓冲区的内容

根据执行指令的pc值维护缓冲区:

  • 获取当前指令信息
  • 获取后面的指令信息
  • 信息加入到环形缓冲区的尾部
  1. 实现环形缓冲区的数据结构

满员后,新增数据在尾部,移除的数据在前方,典型的队列

实现思路是定义一个文件iringbuf.c单独存放与环形缓冲区的代码。截至晚上23点35分,写完并且测试完毕。

文件iringbuf.c的实现:

#define IRINGBUF_MAX_LEN 10
typedef struct Node {
char message[128];
struct Node *next;
} Node;
typedef struct {
Node *head;
Node *tail;
int count; // 链表元素的数量
} iringbuf;
// 函数声明
void init_ringbuf(iringbuf *rb);
void pop(iringbuf *rb);
void push(iringbuf *rb, const char *msg);
void destroy_ringbuf(iringbuf *rb);
void print_ringbuf(iringbuf *rb);
// 函数实现
void init_ringbuf(iringbuf *rb) {
rb->head = NULL;
rb->tail = NULL;
rb->count = 0;
}
// 删除队列的首元素
void pop(iringbuf *rb) {
if(rb->count == 0) return;
Node *temp = rb->head; // 保存当前头节点
rb->head = rb->head->next; // 更新头节点为下一个节点
if (rb->head == NULL) { // 仅有一个元素且删除后,尾节点也为空
rb->tail = NULL;
}
rb->count--;
free(temp);
}
// 在缓冲区末尾保存指令信息
void push(iringbuf *rb, const char *msg) {
Node *new_node = (Node *) malloc(sizeof(Node)); // 分配新节点内存
// 如果已经达到缓冲区最大容纳量
if (rb->count == IRINGBUF_MAX_LEN) {
pop(rb); // 删除缓冲区中第一条指令信息
}
// 在末尾添加新的带有信息节点
strncpy(new_node->message, msg, sizeof(new_node->message)); // 复制信息
new_node->next = NULL;
if (rb->count == 0) { // 如果缓冲区只有此一条新信息,则首尾皆更改为此信息
rb->head = new_node;
rb->tail = new_node;
} else {
rb->tail->next = new_node; // 旧尾节点后添加此节点
rb->tail = new_node; // 更新尾节点指针
}
rb->count++;
}
void destroy_ringbuf(iringbuf *rb) {
while( rb->count > 0 ) {
pop(rb);
}
}
void print_ringbuf(iringbuf *rb) {
// 获取缓冲区的首元素地址
Node *temp = rb->head;
for (int i = 0; i < rb->count; i++) {
// 最后一个元素的特殊输出
if(i == rb->count - 1) {
printf(" --> ");
} else {
printf(" ");
}
// 输出当前元素的信息
printf("%s\n", temp->message);
temp = temp->next; // 更新临时节点为下一个节点
}
}

ftrace

由于自己对于NEMU的Makefile学习不够深入,对于很多细节不够了解,导致做了3天也没整出个所以然来。这里就贴一下这位大佬的实现,基于自己的理解,写一写自己的实现之路。自然自己的很多代码也参考了这位大佬的代码实现,放一下大佬的博客:NOSAE's Blog

ftrace因为涉及到监控指令执行,所以需要放在monitor中去实现。所以在nemu/src/monitor/下,新建专门处理与ftrace有关的文件ftrace.c

ftrace.c的存在主要有两个功能:

  • 提供给监视器,用来解析ELF文件的函数
  • 提供给译码器,用来记录函数调用和返回指令信息的函数
static uint32_t call_depth = 0;
static uint32_t trace_func_call_flag = 0; // Flag to determine whether to trace function calls
void parse_elf(const char *elf_file) {
if (elf_file == NULL) {
return;
}
Log("The elf file is %s\n", elf_file);
trace_func_call_flag = 1;
FILE *file = fopen(elf_file, "rb");
assert(file != NULL);
init_symtab_entrys(file);
}
void trace_func_call(paddr_t pc, paddr_t target) {
if (trace_func_call_flag == 0) return; //No elf file
++call_depth;
if (call_depth <= 2) return; // ignore _trm_init & main
char *name = get_function_name_by_addres(target);
// Example output: 0x800001f8: call [f0@0x80000010]
ftrace_write(FMT_PADDR ": %*scall [%s@" FMT_PADDR "]\n",
pc,
(call_depth-3)*2, "",
name?name:"???",
target
);
}
void trace_func_ret(paddr_t pc) {
if (trace_func_call_flag == 0) return; //No elf file
if (call_depth <= 2) return; // ignore _trm_init & main
char *name = get_function_name_by_addres(pc);
ftrace_write(FMT_PADDR ": %*sret [%s]\n",
pc,
(call_depth-3)*2, "",
name?name:"???"
);
--call_depth;
}

基本函数框架确定后,下一步就需要在nemu/src/monitor/monitor.c中,加入解析ELF文件的函数parse_elf

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

ftrace的最后一步便是在译码阶段,判断为调用或返回指令时候,调用ftrace记录函数(译码实现在nemu/src/isa/riscv32/inst.c

static int decode_exec(Decode *s) {
//.....
INSTPAT_START();
INSTPAT("??????? ????? ????? ??? ????? 11011 11", jal , J, { R(rd) = s->pc + 4; s->dnpc = s->pc + imm; IFDEF(CONFIG_ITRACE, {
if (rd == 1) { // x1($ra): stores return value
trace_func_call(s->pc, s->dnpc);
}
})
});
INSTPAT("??????? ????? ????? 000 ????? 11001 11", jalr , I, { R(rd) = s->pc + 4; s->dnpc = (src1 + imm) & ~1; IFDEF(CONFIG_ITRACE, {
if (s->isa.inst.val == 0x00008067) { // ret: jalr x0, 0(x1)
trace_func_ret(s->pc);
}
}
)});
INSTPAT_END();
R(0) = 0; // reset $zero to 0
return 0;
}

实现ftrace.c剩余的辅助函数后,ftrace实现的框架就可以运行了。

因为涉及到ELF文件的处理,所以这里按照man 5 elf的信息,写了一个小程序来处理ELF文件,有兴趣的可以来看下。

Differential Testing

DiffTest的思想理论基础: 找一个正确的实现, 跟它对比结果.

DiffTest应用于NEMU的实践则是:NEMU模拟出了一个riscv32的硬件系统,此为测试对象(DUT);然后找一个已经成功实现的riscv32架构的硬件系统(这里是riscv32模拟器Spike,在PA中又称之为真机),作为参考实现(REF)

而NEMU硬件系统的运行情况,又可以被状态机理论抽象为一个状态二元组S = <R, M>。这样就通过对比两个系统的寄存器和内存状态,就可以得知两个系统是否运行一致。

这样两套系统执行相同指令后,观察两个系统的状态是否一致,即可得知测试对象是否得到了正确的结果。

实现isa_difftest_checkregs()函数(在nemu/src/isa/riscv32/difftest/dut.c中定义)

bool isa_difftest_checkregs(CPU_state *ref_r, vaddr_t pc) {
if(cpu.pc == ref_r->pc) {
for(int i = 0; i < MUXDEF(CONFIG_RVE, 16, 32); i++) {
if (gpr(i) != ref_r->gpr[i]) {
return false;
}
}
}
return true;
}

而最终的回归测试,被mulh的译码困住了。

一开始我使用强制类型转换来获得有符号乘法的高32位:

R(rd) = (int64_t) src1 *(int64_t) src2 >> 32

但是结果仍然不对,猜测是NEMU在这里限制了强制类型转换。

然后将有符号数的乘法,变成高位的无符号数乘法:

R(rd) = SEXT(src1, 32) * SEXT(src2, 32) >> 32

这样就可以通过测试了。其他指令的实现请看这里

PA2.2阶段用时17天,效率慢的原因在于:

  • 闷头编写代码:ftrace的实现,非常依赖对于项目架构的理解。Makefile不会直接STFW
  • 完美主义:总想写漂亮代码?先让你的思路正常跑通吧~

PA2阶段2到此结束.(10.28-22:28)

串口

NEMU串口如何输出字符

首先看下,在AM中,输出一个字符做了什么?

  1. am-kernels/kernels/hello/hello.c:调用AMTRM部分定义的putch()函数输出字符

  2. abstract-machine/am/src/platform/nemu/trm.c:调用针对riscv架构的数据写入指令outb()

    void putch(char ch) {
    outb(SERIAL_PORT, ch); // SERIAL_PORT = (DEVICE_BASE + 0x00003f8)
    //abstract-machine\am\src\platform\nemu\include\nemu.h
    }

    SERIAL_PORT(在abstract-machine\am\src\platform\nemu\include\nemu.h定义)为

    #define DEVICE_BASE 0xa0000000
    #define SERIAL_PORT = (DEVICE_BASE + 0x00003f8)
  3. am/src/riscv/riscv.h:定义outb()

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

    即将addr处的一个字节保存数据data

所以运行在特定架构riscv32-nemuAM中,调用函数putch()输出一个字符的结果,是将一个字符保存到地址为0x0xa00003f8的空间中。

然后我们再看下NEMU对于串口硬设备的管理(在nemu/src/device/serial.c中定义)

从串口初始化函数init_serial()可以得知的几个信息:

  • 注册0x3F8处长度为8个字节的端口
  • 注册0xa00003F8处长度为8字节的MMIO空间
  • 两个注册都会映射到串口的数据寄存器serial_base

串口初始化函数,说明串口数据寄存器,会被映射到内存地址为0xa00003F8处。而此地址恰好是基于riscv32-nemu的AM输出字符时,将字符保存起来的地址。

综上所述,在NEMU运行的程序,输出一个字符的步骤为:

  • 调用AM的TRM模块提供的字符输出函数putch()
  • putch()的实现依赖于架构riscv32-nemu,具体实现就是通过outb()将字符保存到串口数据寄存器中

而串口数据寄存器拿到字符后,则会执行相应的逻辑将字符打印。这就是输出一个字符的流程。

可变参数函数的处理

#include <stdio.h>
#include <stdarg.h>
// 示例函数,计算传入的整数之和
int sum(int count, ...) {
va_list args; // 定义可变参数列表
va_start(args, count); // 初始化 args,count 是最后一个固定参数
int total = 0;
for (int i = 0; i < count; i++) {
total += va_arg(args, int); // 获取下一个参数,类型为 int
}
va_end(args); // 清理 va_list
return total;
}
int main() {
int result = sum(4, 1, 2, 3, 4); // 计算 1 + 2 + 3 + 4
printf("Sum: %d\n", result); // 输出结果
return 0;
}
  • 使用 va_start(args, count); 初始化 args,以便可以从函数参数中获取可变参数。count 是最后一个固定参数。
  • 使用循环和 va_arg(args, int) 获取可变参数。va_arg 将返回下一个参数,参数类型由第二个参数指定(在这里是 int)。
  • 使用 va_end(args); 清理 va_list,释放相关资源。

关于sprintf()和printf()的功能思考

int sprintf(char *out, const char *fmt, ...);
  • 处理格式化字符串
  • 将格式化字符串传入到out区域
  • 返回字符的数量
int printf(const char *fmt, ...);
  • 处理格式化字符串
  • 将格式化字符串传入到串口
  • 返回字符的数量

所以两者耦合的部分是处理格式化字符串的处理的代码和获取字符的数量代码。

处理格式化字符串的代码可以抽象为一个函数:

int process_format_string(char *out, const char *fmt, va_list args);

此函数输入字符串输出的缓冲区指针out,格式化字符串fmt以及对应的可变参数args;输出处理格式化逻辑后的字符串的长度。函数具体实现放在这里

时钟

解析nemu\src\device\timer.c

  • init_timer()将时钟设备注册到映射结构体数组map[]中。注册后的时钟设备的映射参数:
    • name:时钟设备映射的名字rtc
    • low:映射的起始地址,为CONFIG_RTC_MMIO(0xa0000048)
    • high:映射的结束地址,为CONFIG_RTC_MMIO + 8(0xa000004f)
    • space:映射的目标空间为时钟数据寄存器rtc_port_base
    • callback:回调函数rtc_io_handler
  • rtc_io_handler():读取AM系统64位的启动时间,并将其写入到rtc_port_base的8个字节中。

当CPU访问地址[0xa0000048, 0xa000004f]的时候(调用map_read()),会调用函数rtc_io_handler(),然后时钟数据寄存器rtc_port_base将启动时间记录下来,相当于访问时钟进行状态的更新。

当程序获取系统启动时间流程:

  1. 调用klib中提供的io_read()宏,访问时钟设备AM_TIMER_UPTIME保存的启动时间信息。

  2. io_read使用AM提供的设备访问函数ioe_read(),访问编号为AM_TIMER_UPTIME所代表的时钟设备

    enum { AM_TIMER_UPTIME = 6};
    typedef struct { uint64_t us; } AM_TIMER_UPTIME_T
    uint64_t io_read(AM_TIMER_UPTIME)
    ({ AM_TIMER_UPTIME_T __io_param;
    ioe_read(AM_TIMER_UPTIME, &__io_param);
    __io_param; })
  3. ioe_read()调用时钟对应的函数__am_timer_uptime(在abstract-machine\am\src\platform\nemu\ioe\timer.c定义)

    void __am_timer_uptime(&__io_param) {
    __io_param->us = 0; //TODO
    }

基于riscv32-nemu的AM,可以通过MMIO的方式,在内存0xa0000048处访问到映射到时钟数据寄存器的数据。

需要注意三个点为

  1. nemu的时钟设备用两个32位的寄存器rtc_port_base[2]保存一个64位的时间数据

    uint64_t us = get_time();
    rtc_port_base[0] = (uint32_t)us; // 0xa0000048 存放us低32位
    rtc_port_base[1] = us >> 32; // 0xa000004C 存放us高32位

    所以我们可以读取两次4字节的数据,然后组装时间数据。

  2. 基于riscv的数据读取长度为4个字节的指令为inl(在abstract-machine\am\src\riscv\riscv.h定义)

    static inline uint32_t inl(uintptr_t addr) { return *(volatile uint32_t *)addr; }
  3. 时钟数据寄存器映射到内存的地址为CONFIG_RTC_MMIO(在abstract-machine\am\src\platform\nemu\include\nemu.h定义)

    #define RTC_ADDR (DEVICE_BASE + 0x0000048)

基于上面的认识,实现AM_TIMER_UPTIME的功能:

void __am_timer_uptime(AM_TIMER_UPTIME_T *uptime) {
uint64_t us = 0;
uint32_t us_high = inl(RTC_ADDR + 4);
uint32_t us_low = inl(RTC_ADDR);
us = (uint64_t)us_low | ((uint64_t)us_high << 32);
uptime->us = us;
}

运行测试(分析参数mainargs如何影响程序执行的?)

make ARCH=riscv32-nemu run mainargs='t'

运行后发现有浮点溢出,经过检查发现更新时钟数据寄存器的细节:

static void rtc_io_handler(uint32_t offset, int len, bool is_write) {
assert(offset == 0 || 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;
}
}

键盘

abstract-machine/am/include/amdev.h中为键盘的功能定义了一个抽象寄存器:

  • AM_INPUT_KEYBRD, AM键盘控制器, 可读出按键信息. keydowntrue时表示按下按键, 否则表示释放按键. keycode为按键的断码, 没有按键时, keycodeAM_KEY_NONE

当CPU访问键盘数据寄存器的流程是什么?(与时钟类似)

  1. 调用klib中提供的io_read()宏,访问AM键盘控制器AM_INPUT_KEYBRD中保存的按键信息

  2. 宏展开使用AM提供的设备访问函数ioe_read(),访问编号为AM_INPUT_KEYBRD所代表的键盘控制器

    enum { AM_INPUT_KEYBRD = (8) };
    typedef struct { bool keydown; int keycode; } AM_INPUT_KEYBRD_T;
    uint64_t io_read(AM_INPUT_KEYBRD)
    ({ AM_INPUT_KEYBRD_T __io_param;
    ioe_read(AM_INPUT_KEYBRD, &__io_param);
    __io_param; })
  3. ioe_read()调用获取键盘控制器信息的函数__am_input_keybrd(在abstract-machine\am\src\platform\nemu\ioe\input.c中定义)

我们要想实现读取键盘的数据,就要看下基于NEMU架构的键盘数据寄存器是怎么实现的(在nemu\src\device\keyboard.c中定义)

static uint32_t *i8042_data_port_base = NULL;
static void i8042_data_io_handler(uint32_t offset, int len, bool is_write) {
assert(!is_write);
assert(offset == 0);
i8042_data_port_base[0] = key_dequeue();
}

当访问键盘数据寄存器的时候,键盘控制器会在读命令的状态下,从维护的按键队列中读取一个4字节的数据。而

键盘事件的维护是通过键盘控制器的函数send_key()实现的:

#define KEYDOWN_MASK 0x8000
void send_key(uint8_t scancode, bool is_keydown) {
// NEMU在正常状态`NEMU_RUNNING`运行并且键入的扫描码`scancode`在键盘映射表`keymap`中对应有效的键值。
if (nemu_state.state == NEMU_RUNNING && keymap[scancode] != NEMU_KEY_NONE) {
// 构造一个扩展的扫描码 am_scancode
uint32_t am_scancode = keymap[scancode] | (is_keydown ? KEYDOWN_MASK : 0);
// 写入键盘事件队列
key_enqueue(am_scancode);
}
}
  • scancode:表示键事件的原始扫描码。
  • is_keydown:布尔值,指示此事件是按下(true)还是抬起(false)
  • 构造拓展的扫描码
    • keymap[scancode] 取出对应的键值
    • 如果 is_keydowntrue,则按下的键会被加上这个掩码,表示这是一个按下事件;如果为 false,则是抬起事件。
    • 使用位运算将 KEYDOWN_MASK(用于标识按键按下的状态,在NEMU中为0x8000)与 keymap[scancode] 结合。

这样键盘数据寄存器中保存的一个键盘事件,就是一个拓展后的4字节扫描码数据。按照构造扫描码的逻辑,通过一个键盘事件,获取键值keycode和判断是否按键按下的信息逻辑为:

// 获取一个键盘事件
uint32_t k = key_dequeue();
bool keydown = (k & KEYDOWN_MASK ? true : false);
uint32_t keycode = k & ~KEYDOWN_MASK;

VGA

从NEMU进行vga设备的初始化流程:

#define SCREEN_W 400
#define SCREEN_H 300
static uint32_t screen_width() {
return SCREEN_W;
}
static uint32_t screen_height() {
return SCREEN_H;
}
static uint32_t screen_size() {
return screen_width() * screen_height() * sizeof(uint32_t);
}
void init_vga() {
// 初始化VGA显示控制器数据,将 vgactl 的控制寄存器映射到内存地址[0xa0000100,0xa0000108]
vgactl_port_base = (uint32_t *)new_space(8);
vgactl_port_base[0] = (screen_width() << 16) | screen_height();
add_mmio_map("vgactl", CONFIG_VGA_CTL_MMIO, vgactl_port_base, 8, NULL);
// 分配显示内存.将 vmem 映射到内存地址[0xa1000000, 0xa1000008]
vmem = new_space(screen_size());
add_mmio_map("vmem", CONFIG_FB_ADDR, vmem, screen_size(), NULL);
init_screen();
memset(vmem, 0, screen_size());
}

本节要实现两个关于GPU的抽象寄存器(与NEMU输出设备VAG关联):

  • AM_GPU_CONFIG, AM显示控制器信息, 可读出屏幕大小信息widthheight.
  • AM_GPU_FBDRAW, AM帧缓冲控制器, 可写入绘图信息, 向屏幕(x, y)坐标处绘制w*h的矩形图像. 图像像素按行优先方式存储在pixels中, 每个像素用32位整数以00RRGGBB的方式描述颜色. 若synctrue, 则马上将帧缓冲中的内容同步到屏幕上.

首先分析AM_GPU_CONFIG寄存器需要的数据在VGA的哪些寄存器中:

typedef struct { bool present, has_accel; int width, height, vmemsz; } AM_GPU_CONFIG_T;
  • width: 屏幕宽度 --> VGA显示控制器寄存器vgactl_port_basescreen_width
  • height:屏幕高度 --> VGA显示控制器寄存器vgactl_port_basescreen_height
  • vmemsz:显存大小 --> 显存vmemscreen_size
void __am_gpu_config(AM_GPU_CONFIG_T *cfg) {
*cfg = (AM_GPU_CONFIG_T) {
.present = true, .has_accel = false,
.width = 0, .height = 0,
.vmemsz = 0
};
}

再分析AM_GPU_FBDRAW的逻辑,看下VGA同步寄存器在哪里:

#define SYNC_ADDR (VGACTL_ADDR + 4)
void __am_gpu_fbdraw(AM_GPU_FBDRAW_T *ctl) {
if (ctl->sync) {
outl(SYNC_ADDR, 1);
}
}

结合VGA部分刷新屏幕的代码vga_update_screen

void vga_update_screen() {
// TODO: call `update_screen()` when the sync register is non-zero,
// then zero out the sync register
}

可以推断出VGA同步寄存器存在于VGA显示控制器上,且位于高4字节。映射于内存位置为[0xa0000104,0xa0000108]

至此2024年11月2号,完结PA2.3(10.29-11.2)

本文作者:上山砍大树

本文链接:https://www.cnblogs.com/shangshankandashu/p/18433107

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   上山砍大树  阅读(972)  评论(7编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起