一、freestanding

图片名称
在之前的学习中都是在linux进行编译。那么从学习的角度看还是freestanding比较简单,图中_start这个程序是作为死循环的,输出一个A后处于while(1)的循环状态。 那么如果我们在freestanding中进行编译呢?要在freestanding中编译首先要明白一个问题,程序如何结束运行?
图片名称
通过yzh视频中得知,C99的手册中写到freetanding environment 的结束是一个未定义行为,那么怎么解决这个问题呢?就要在exit地址写暗号~,如果把//*exit注释,则会出现 死循环,不断输出A的情况。 正如C99手册所说,freestanding在手册中很多都是处于未定义的状态,在上篇博客中我们也提到未定义状态可能引起的问题,所以我们最好自己设计一个!
图片名称

二、YEMU (ysyx EMU)

要实现YEMU,也就是实现指令模拟,我们回顾上次课的“状态机”相关知识

图片名称
正如图片中所描述的,我们只要把这个状态机实现出来,就可以来执行指令。那么又在上节课介绍:C程序是个状态机,那么我们是不是可以用C语言来实现这个状态机,从而实现指令运行。
图片名称
如何用C语言实现寄存器和内存,例如在RISCV32中有32个32位的寄存器,那么我们定义一个数组就可以了,每个元素都是uint32_t,也就是32位 32个寄存器了。特殊的是用PC,还有内存
#include <stdint.h>
uint32_t R[32], PC; // according to the RISC-V manual
uint8_t M[64];      // 64-Byte memory

为什么我们不适用int32_t和int8_t?

图片名称
8位最大是127,如果内存值为127,在+1后溢出,溢出值为undefine behavior,但是无符号数不会溢出 ![image](https://img2024.cnblogs.com/blog/3412936/202404/3412936-20240419113907886-619491592.png) 模拟器本质上执行的就是上述几条行为,正如右下角程序所呈现的,如果halt为false,那么while循环会一直执行inst_cycle(), 这个cycle就是 指令周期。所以本质上我们要做的就是把 inst_cycle给实现出来,这样就是一个模拟器。但是在进行模拟前要先清楚inst的语义,也就是每个指令是什么意思。搞清楚 每个指令的意思,就需要去查手册,这个行为就称作“译码”。如下是用C实现一个模拟器
点击查看代码
void inst_cycle() {
  uint32_t inst = *(uint32_t *)&M[PC];
  if (((inst & 0x7f) == 0x13) && ((inst >> 12) & 0x7) == 0) { // addi
    if (((inst >> 7) & 0x1f) != 0) {
      R[(inst >> 7) & 0x1f] = R[(inst >> 15) & 0x1f] +
        (((inst >> 20) & 0x7ff) - ((inst & 0x80000000) ? 4096 : 0));
    }
  } else if (inst == 0x00100073) { // ebreak
    if (R[10] == 0) { putchar(R[11] & 0xff); }
    else if (R[10] == 1) { halt = true; }
    else { printf("Unsupported ebreak command\n"); }
  } else { printf("Unsupported instuction\n"); }
  PC += 4;
}

可以看到对指令进行分析,如果为addi指令,则进行xxx操作,如果位ebreak指令,则进行xxx操作。在指令执行完毕后需要把pc + 4,这对应这我们的"更新pc"。这里值得注意的是R[(inst >> 7) & 0x1f] = R[(inst >> 15) & 0x1f] +...这条指令是把立即数和rs寄存器的值相加,存到rd寄存器中。但是可以看到他后面还有个减法 -((inst & 0x80000000) ? 4096 : 0)。具体解释如下


最高位通常用于表示一个立即数是否为负。如果最高位是1,这通常意味着立即数是负的,需要进行符号扩展;如果最高位是0,则表示立即数是非负的。
inst & 0x80000000 这个表达式通过和 0x80000000 进行位与操作来检查 inst 的最高位是否设置(即为1)。这里的 0x80000000 是一个只有最高位为1的32位整数。如果 inst 的最高位是1,这个操作的结果将不为0,这表示立即数是负的,否则为正。
当确定立即数为负时,代码使用 4096(或者 0x1000)来进行符号扩展。在这种情况下,负的立即数将从12位扩展到32位,而12位立即数的表示方法是使用补码。在补码中,一个负数可以通过从2的指数次方中减去其绝对值来表示。由于立即数是12位,所以2的指数次方就是 2^12,也就是 4096。

因此,当你看到 ((inst & 0x80000000) ? 4096 : 0) 这段代码时,它是在做这样的操作:如果立即数是负的,就从 4096 减去其绝对值来得到32位的负立即数;如果是正数,就直接使用该值。这是RISC-V和其他许多处理器架构在处理立即数时常用的一种技巧。


指令部分分析结束后,我们还需要设定初始值,也就是rest:

图片名称
riscv手册中定义 pc set 一个 0,0号寄存器恒为0,其他state 是unspecified的,也就是自己说了算的。 综合上述信息,我们就可以得到YEMY(Ysyx EMUlator )1.0版本
点击查看代码
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
uint32_t R[32], PC;
uint8_t M[64] = {
  0x13, 0x05, 0x00, 0x00, 0x93, 0x05, 0x10, 0x04, 0x73, 0x00, 0x10, 0x00,
  0x13, 0x05, 0x10, 0x00, 0x93, 0x05, 0x00, 0x00, 0x73, 0x00, 0x10, 0x00,
  0x6f, 0x00, 0x00, 0x00,
};
bool halt = false;

void inst_cycle() {
  uint32_t inst = *(uint32_t *)&M[PC];
  if (((inst & 0x7f) == 0x13) && ((inst >> 12) & 0x7) == 0) { // addi
    if (((inst >> 7) & 0x1f) != 0) {
      R[(inst >> 7) & 0x1f] = R[(inst >> 15) & 0x1f] +
        (((inst >> 20) & 0x7ff) - ((inst & 0x80000000) ? 4096 : 0));
    }
  } else if (inst == 0x00100073) { // ebreak
    if (R[10] == 0) { putchar(R[11] & 0xff); }
    else if (R[10] == 1) { halt = true; }
    else { printf("Unsupported ebreak command\n"); }
  } else { printf("Unsupported instuction\n"); }
  PC += 4;
}

int main() {
  PC = 0; R[0] = 0; // can be omitted since uninitialized global variables are initialized with 0
  while (!halt) { inst_cycle(); }
  return 0;
}

当然也可以从文件来读入程度,例如
点击查看代码
uint8_t M[1024];
int main(int argc, char *argv[]) {
  PC = 0; R[0] = 0;
  FILE *fp = fopen(argv[1], "r");
  fread(M, 1, 1024, fp);
  fclose(fp);
  while (!halt) { inst_cycle(); }
  return 0;
}
根据读入的文件再次进行模拟,如果我们把可执行文件(prog)的指令序列抽取到prog.bin中,可以看到.bin的内容和反汇编内容是指令序列
图片名称

三、编写可读可维护的代码
1、写assert,assert可以避免出现 Segmentation Fault 等类似无法调试的错误,例如

点击查看代码
#include <assert.h>
// ...
int main(int argc, char *argv[]) {
  PC = 0; R[0] = 0;
  assert(argc >= 2);  // 要求至少包含一个参数
  FILE *fp = fopen(argv[1], "r");
  assert(fp != NULL); // 要求argv[1]是一个可以成功打开的文件
  int ret = fseek(fp, 0, SEEK_END);
  assert(ret != -1); // 要求fseek()成功
  long fsize = ftell(fp);
  assert(fsize != -1); // 要求ftell()成功
  rewind(fp);
  assert(fsize < 1024); // 要求程序大小不超过1024字节
  ret = fread(M, 1, 1024, fp);
  assert(ret == fsize); // 要求完全读出程序的内容
  fclose(fp);
  while (!halt) { inst_cycle(); }
  return 0;
}
在每一个容易出错的地方都插入assert,即使会出现错误也会非常清晰的呈现:
./yemu not-exist.bin
yemu: yemu.c:27: main: Assertion `fp != NULL' failed.

同时也可以利用宏,来让assert 失败时输出更多信息,例如:

点击查看代码
#define Assert(cond, format, ...) \
  do { \
    if (!(cond)) { \
      fprintf(stderr, format "\n", ## __VA_ARGS__); \
      assert(cond); \
    } \
  } while (0)

int main(int argc, char *argv[]) {
  PC = 0; R[0] = 0;
  Assert(argc >= 2, "Program is not given");  // 要求至少包含一个参数
  FILE *fp = fopen(argv[1], "r");
  Assert(fp != NULL, "Fail to open %s", argv[1]); // 要求argv[1]是一个可以成功打开的文件
  int ret = fseek(fp, 0, SEEK_END);
  Assert(ret != -1, "Fail to seek the end of the file"); // 要求fseek()成功
  long fsize = ftell(fp);
  Assert(fsize != -1, "Fail to return the file position"); // 要求ftell()成功
  rewind(fp);
  Assert(fsize < 1024, "Program size exceeds 1024 Bytes"); // 要求程序大小不超过1024字节
  ret = fread(M, 1, 1024, fp);
  Assert(ret == fsize, "Fail to load the whole program"); // 要求完全读出程序的内容
  fclose(fp);
  while (!halt) { inst_cycle(); }
  return 0;
}
但是要注意Assert中有if ,而编译器中else会匹配最近的if,所以用宏的时候要注意最近匹配的原则。 2、减少代码中的隐含依赖 这里举了一个例子
uint8_t M[512];
Assert(fsize < 1024, "Program size exceeds 1024 Bytes");
ret = fread(M, 1, 1024, fp);  // BUG: 忘了改, 可能发生缓冲区溢出!

例如我们的代码原来是uint8_t M[1024],但是在后期调试的时候进行更改,改为了512,那么就会发生溢出。后续的内容收到影响进而出现非常难调试的bug。

#define MSIZE 1024
uint8_t M[MSIZE];
// 另一种方式
uint8_t M[1024];
#define MSIZE (sizeof(M) / sizeof(M[0]))
Assert(fsize < MSIZE, "Program size exceeds %d Bytes", MSIZE);
ret = fread(M, 1, MSIZE, fp);

如果把这些参数定义为宏,那么消灭了上述的代码依赖。
3、编写可复用代码
未来可能一块代码复用多次,不可以直接copy paste,(容易出现一处出现错误,所有的copy-paste都需要进行修改的情况)
例如:

点击查看代码
if (((inst & 0x7f) == 0x13) && ((inst >> 12) & 0x7) == 0) { // addi
  if (((inst >> 7) & 0x1f) != 0) {
    R[(inst >> 7) & 0x1f] = R[(inst >> 15) & 0x1f] +
      (((inst >> 20) & 0x7ff) - ((inst & 0x80000000) ? 4096 : 0));
  }
} else if (((inst & 0x7f) == 0x13) && ((inst >> 12) & 0x7) == 0x4) { // xori
  if (((inst >> 7) & 0x1f) != 0) {
    R[(inst >> 7) & 0x1f] = R[(inst >> 15) & 0x1f] ^
      (((inst >> 20) & 0x7ff) - ((inst & 0x80000000) ? 4096 : 0));
  }
} else if (((inst & 0x7f) == 0x13) && ((inst >> 12) & 0x7) == 0x6) { // ori
  if (((inst >> 7) & 0x1f) != 0) {
    R[(inst >> 7) & 0x1f] = R[(inst >> 15) & 0x1f] |
      (((inst >> 20) & 0x7ff) - ((inst & 0x80000000) ? 4096 : 0));
  }
} else if (((inst & 0x7f) == 0x13) && ((inst >> 12) & 0x7) == 0x4) { // andi
  if (((inst >> 7) & 0x1f) != 0) {
    R[(inst >> 7) & 0x1f] = R[(inst >> 15) & 0x1f] &
      (((inst >> 20) & 0x7ff) - ((inst & 0x80000000) ? 4096 : 0));
  }
} else if (...) {  ...  }
这其中其实是有错误的,但是明显很难找出。所以我们需要把它简化,可以让代码进行复用,such as
点击查看代码
uint32_t inst = *(uint32_t *)&M[PC];
uint32_t opcode = inst & 0x7f;
uint32_t funct3 = (inst >> 12) & 0x7;
uint32_t rd  = (inst >> 7 ) & 0x1f;
uint32_t rs1 = (inst >> 15) & 0x1f;
uint32_t imm = ((inst >> 20) & 0x7ff) - ((inst & 0x80000000) ? 4096 : 0);
if (opcode == 0x13) {
  if      (funct3 == 0x0) { R[rd] = R[rs1] + imm; } // addi
  else if (funct3 == 0x4) { R[rd] = R[rs1] ^ imm; } // xori
  else if (funct3 == 0x6) { R[rd] = R[rs1] | imm; } // ori
  else if (funct3 == 0x7) { R[rd] = R[rs1] & imm; } // andi
  else { panic("Unsupported funct3 = %d", funct3); }
  R[0] = 0; // 若指令写入了R[0], 此处将其重置为0
} else if (...) {  ...  }
PC += 4;
把代码进行模块化,需要的话直接调用即可,对齐的代码更容易阅读并发现错误 4、使用合适语言特性 其实上述代码还可以进行更标准的写法,如我们所知RISCV有很多类型例如I,J等。那么我们可以定义合适的结构体,such as
点击查看代码
typedef union {
  struct {
    uint32_t opcode  :  7;
    uint32_t rd      :  5;
    uint32_t funct3  :  3;
    uint32_t rs1     :  5;
     int32_t imm11_0 : 12;
  } I;
  struct { /* ... */ } R;
  uint32_t bytes;
} inst_t;

inst_t *inst = (inst_t *)&M[PC];
uint32_t rd  = inst->I.rd;
uint32_t rs1 = inst->I.rs1;
uint32_t imm = (int32_t)inst->I.imm11_0;
if (inst->I.opcode == 0b0010011) {
  switch (inst->I.funct3) {
    case 0b000: R[rd] = R[rs1] + imm; break; // addi
    case 0b100: R[rd] = R[rs1] ^ imm; break; // xori
    case 0b110: R[rd] = R[rs1] | imm; break; // ori
    case 0b111: R[rd] = R[rs1] & imm; break; // andi
    default: panic("Unsupported funct3 = %d", inst->I.funct3);
  }
  R[0] = 0; // 若指令写入了R[0], 此处将其重置为0
} else if (inst->bytes == 0x00100073) {  ...  }
后续可以仿照I类型的把其余J S等类型补齐。