引言

上次课内容:

C程序如何从源代码生成指令序列(二进制可执行文件)

  • 预处理 -> 编译 -> 汇编 -> 链接 -> 执行

 

本次课内容:

  • 指令序列的状态机如何实现?
    • 如何实现一个可以执行指令的模拟器?
  • C程序的状态机如何实现?
    • 如何实现一个可以执行C代码的模拟器?

freestanding运行时环境

编译到freestanding

我们之前接触的是宿主操作系统(Linux)之上的运行时环境

  • Linux的运行时环境并不简单
    • 我们目前还很难解释清楚printf()的代码在哪里 😂

 

从学习的角度来说, 还是freestanding环境更简单

  • 我们来编译一个最简单的程序
#include <stdint.h>
void _start() {
  volatile uint8_t *p = (uint8_t *)(uintptr_t)0x10000000;
  *p = 'A';
  while (1);
}

选择qemu-system-riscv32作为freestanding环境

  • 0x10000000是qemu-system-riscv32中virt机器模型的串口地址
rv32gcc -ffreestanding -nostdlib -Wl,-Ttext=0x80000000 -O2 a.c
# QEMU emulator version 7.2.4 (Debian 1:7.2+dfsg-7+deb12u1)
qemu-system-riscv32 -nographic -M virt -bios none -kernel a.out

程序如何结束运行?

RTFM: C99

5.1.2.1 Freestanding environment

2 The effect of program termination in a freestanding environment is
implementation-defined.

 

在qemu-system-riscv32中的virt机器模型中, 往一个特殊的地址写入一个特殊的 “暗号”即可结束QEMU的运行

#include <stdint.h>
void _start() {
  volatile uint8_t *p = (uint8_t *)(uintptr_t)0x10000000;
  *p = 'A';
  volatile uint32_t *exit = (uint32_t *)(uintptr_t)0x100000;
  *exit = 0x5555; // magic number
  _start();
}

自制一个freestanding运行时环境

QEMU虽然是个开源项目, 但还挺复杂, 不利于我们理解细节

  • 25000+个源文件, 110000+行源代码

 

让我们来设计一个面向RISC-V程序的简单freestanding运行时环境!

  • 程序从地址0开始执行
  • 只支持两条指令
    • addi指令
    • ebreak指令
      • 寄存器a0=0时, 输出寄存器a1低8位的字符
      • 寄存器a0=1时, 结束运行

将程序编译到自制freestanding运行时环境

static void ebreak(long arg0, long arg1) {
  asm volatile("addi a0, x0, %0;"
               "addi a1, x0, %1;"
               "ebreak" : : "i"(arg0), "i"(arg1));
}
static void putch(char ch) { ebreak(0, ch); }
static void halt(int code) { ebreak(1, code); while (1); }

void _start() {
  putch('A');
  halt(0);
}
rv32gcc -ffreestanding -nostdlib -static -Wl,-Ttext=0 -O2 -o prog a.c
rvobjdump -M no-aliases -d prog

看看反汇编

  • 借助编译优化, 这个程序真的只有addiebreak指令

但怎么让这个程序运行呢?

  • 我们需要实现这个运行时环境

YEMU: 指令如何执行

回顾: ISA手册定义了一个状态机

  • 状态集合\(S = \{<R, M>\}\)
    • \(R = \{PC, x_0, x_1, x_2, \dots\}\)
      • RISC-V手册 -> 2.1 Programmers’ Model for Base Integer ISA
      • \(PC\) = 程序计数器 = 当前执行的指令位置
    • \(M\) = 内存
      • RISC-V手册 -> 1.4 Memory
  • 激励事件\(E = \{指令\}\)
    • 执行PC指向的指令
  • 状态转移规则\(next: S \times E \to S\)
    • 指令的语义(semantics)
  • 初始状态\(S_0 = <R_0, M_0>\)

 

我们只要把这个状态机实现出来, 就可以用它来执行指令了!

用C代码实现ISA状态机 = 指令集模拟器

程序 抽象计算机 CPU
状态 \(\{<V, PC>\}\) \(\{<R, M>\}\) \(\{时序逻辑电路\}\)
状态转移规则 C语言语句的语义 指令的语义 组合逻辑电路
FM C语言标准手册 指令集手册 架构设计文档

 

  • 用C语言变量实现寄存器和内存
  • 用C语言语句实现指令的语义
    • 指令采用符号化表示 -> 汇编模拟器
      • 如MIPS模拟器SPIM, 一些编译原理课程实验会用它来执行MIPS汇编代码
    • 指令采用编码表示 -> 传统的(二进制)指令集模拟器
      • 包含大部分指令集模拟器, 如QEMU, Spike, FCEUX, NEMU等

用变量实现寄存器和内存

#include <stdint.h>
uint32_t R[32], PC; // according to the RISC-V manual
uint8_t M[64];      // 64-Byte memory

 

Q: 为什么不使用int32_tint8_t?

A: C语言标准规定, 有符号数溢出是undefined behavior, 但无符号数不会溢出

6.5 Expressions

5 If an exceptional condition occurs during the evaluation of an expression (that is,
if the result is not mathematically defined or not in the range of representable
values for its type), the behavior is undefined.
6.2.5 Types

9 A computation involving unsigned operands can never overflow, because a result that
cannot be represented by the resulting unsigned integer type is reduced modulo the
number that is one greater than the largest value that can be represented by the
resulting type.

用语句实现指令的语义

指令周期(instruction cycle): 执行一条指令的步骤

  • 取指(fetch): 从PC所指示的内存位置读取一条指令
  • 译码(decode): 按手册解析指令的操作码(opcode)和操作数(operand)
  • 执行(execute): 按解析出的操作码, 对操作数进行处理
    • 若写入\(<R, M>\), 则更新状态
  • 更新PC: 让PC指向下一条指令
    • 更新状态

 

00000000 <_start>:
   0:   00000513  addi    a0,zero,0
   4:   04100593  addi    a1,zero,65
   8:   00100073  ebreak
   c:   00100513  addi    a0,zero,1
  10:   00000593  addi    a1,zero,0
  14:   00100073  ebreak
  18:   0000006f  jal     zero,18 <_start+0x18>

不断执行指令, 直到结束:

#include <stdbool.h>
bool halt = false;

while (!halt) {
  inst_cycle();
}

用语句实现指令的语义(续)

RTFM后得知:

 31           20 19 15 14 12 11  7 6       0
+---------------+-----+-----+-----+---------+
|   imm[11:0]   | rs1 | 000 | rd  | 0010011 |    ADDI
+---------------+-----+-----+-----+---------+
+---------------+-----+-----+-----+---------+
| 000000000001  |00000| 000 |00000| 1110011 |   EBREAK
+---------------+-----+-----+-----+---------+

一个简单的实现:

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

初始状态

RTFM: 《The RISC-V Instruction Set Manual - Volume II: Privileged Architecture》

3.4 Reset

The pc is set to an implementation-defined reset vector... All other hart state is
unspecified.

注意这里的unspecified和C语言标准的含义不同

 

根据手册, 初始状态如下:

  • R[0] = 0, 0号寄存器恒为0
  • PC = 0, 与自制运行时环境共同约定
  • M中存放程序, 与自制运行时环境共同约定, 由模拟器加载程序

YEMU(Ysyx EMUlator) v1.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
riscv64-linux-gnu-objcopy -j .text -O binary prog prog.bin
gcc -o yemu yemu.c && ./yemu prog.bin

 

运行更复杂的程序

void _start() {
  putch('H'); putch('e'); putch('l'); putch('l'); putch('o'); putch(','); putch(' ');
  putch('R'); putch('I'); putch('S'); putch('C'); putch('-'); putch('V'); putch('!');
  putch('\n');
  halt(0);
}

编写可读可维护代码

换个输入就炸了

./yemu not-exist.bin
segmentation fault

YEMU很小, 能很快定位问题; 但如何从大项目中存活?

  • 数十个源文件, 成千上万行代码
    • 这规模其实不算大, Linux有数千万行代码 😂

调试的最高境界: 不用调试

  • Programs are meant to be read by humans and only incidentally for computers to execute. — D. E. Knuth
  • 程序首先是拿给人读的, 其次才是被机器执行

 

诀窍: 编写可读可维护的代码

  • 不言自明 - 仅看代码就能明白是做什么的(specification)
  • 不言自证 - 仅看代码就能验证实现是对的(verification)

一个反例

IOCCC 2020年的一份获奖代码

                                                       #\
                                define C(c           /**/)#c
                               /*size=3173*/#include<stdio.h>
                            /*crc=b7f9ecff.*/#include<stdlib.h>
                           /*Mile/Adele_von_Ascham*/#include<time.h>
                           typedef/**/int(I);I/*:3*/d,i,j,a,b,l,u[16],v
                           [18],w[36],x,y,z,k;char*P="\n\40(),",*p,*q,*t[18],m[4];
                          void/**/O(char*q){for(;*q;q++)*q>32?z=111-*q?z=(z+*q)%185,(k?
                          k--:(y=z%37,(x=z/37%7)?printf(*t,t[x],y?w[y-1]:95):y>14&&y<33?x
                          =y>15,printf(t[15+x],x?2<<y%16:l,x?(1<<y%16)-1:1):puts(t[y%28])))
                          ,0:z+82:0;}void/**/Q(I(p),I*q){for(x=0;x<p;x++){q[x]=x;}for(;--p
    >1;q[p]=y)y          =q[x=rand()%-~p],q[x]=q[p];}char/**/n[999]=C(Average?!nQVQd%R>Rd%
  R%          %RNIPRfi#VQ}R;TtuodtsRUd%RUd%RUOSetirwf!RnruterR{RTSniamRtniQ>h.oidts<edulc
 ni                   #V>rebmun<=NIPD-RhtiwRelipmocResaelPRrorre#QNIPRfednfi#V__ELIF__R_
Re               nifed#V~-VU0V;}V{R= R][ORrahcRdengisnuRtsnocRcitatsVesle#Vfidne#V53556
 .           .1RfoRegnarRehtRniRre   getniRnaRsiR]NIP[R erehwQQc.tuptuoR>Rtxt.tupniR
 <         R]NIP[R:egasuV_Redulcn i#VfednfiVfednuVenife dVfedfiVQc%Rs%#V);I/**/main(
  I(      f),char**e){if(f){for(i=    time(NULL),p=n,q=  n+997,x=18;x;p++){*p>32&&!(
         *--q=*p>80&&*p<87?P[*p-   81]:*     p)?t  [( --  x)]=q+1:q;}if(f-2||(d=atoi
        (e[1]))<1||65536<d){;O("   \"");             goto  O;}srand(i);Q(16,u);i=0;Q(
       36,w);for(;i<36; i++){w[i]   +=w           [i]<26 ? 97:39; }O(C(ouoo9oBotoo%]#
      ox^#oy_#ozoou#o{ a#o|b#o}c#                o~d#oo-e   #oo.  f#oo/g#oo0h#oo1i#oo
     2j#oo3k#oo4l#o   p));for(j                   =8;EOF   -(i=   getchar());l+=1){a=1+
    rand()%16;for(b  =0;b<a||i-                           main   (0,e);b++)x=d^d/4^d/8^d/
    32,d=  (d/  2|x<<15)&65535;                          b|=   !l<<17;Q(18,v);for(a=0;a<18;
    a++     ){if( (b&(1<<(i=v[a]      ))))*                 m=75+i,O(m),j=i<17&&j<i?i:j;}O(C(
    !)           ); }O(C(oqovoo97o    /n!));i=           0;for(;i<8;O(m))m[2]=35,*m=56+u[i],m[1
    ]=          75   +i++;O(C(oA!oro   oqoo9)          );k=112-j*7;O(C(6o.!Z!Z#5o-!Y!Y#4~!X!X#3}
     !W  !W     #2    |!V!V#1{!U!U#0z!            T!T#/y!S!S#.x!R!R#-w!Q!Q#ooAv!P!P#+o#!O!O#*t!N!
       N#      oo       >s!M!M#oo=r!L!L#oo<q!K!K#   &pIo@:;= oUm#oo98m##oo9=8m#oo9oUm###oo9;=8m#o
               o9   oUm##oo9=oUm#oo98m####          o09]    #o1:^#o2;_#o3<o  ou#o4=a#o5>b#o6?c#o
             7@d#o8A e#o    9B    f#o:Cg#o;          D     h#o<Ei #o=Fj#o>   Gk#o?Hl#oo9os#####
           ));d=0                                          ;}          O:    for(x=y=0;x<8;++
          x)y|=                                                               d&(1<<u[x])?
          1<<                                                               x:0;return
           /*                                                               :9    */
            y                                                                ;    }
  • 不言自明? 不言自证?
    • 上面的代码和原版相比有改动, 如何调试?
    • 如何添加一个新功能?

YEMU v1.0其实也做得不够好, 让我们来改进它

防御性编程

不相信外界的输入/其他函数传递的参数, 通过断言提前拦截非预期情况

#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;
}
./yemu not-exist.bin
yemu: yemu.c:27: main: Assertion `fp != NULL' failed.

防御性编程的意义

将预期的正确行为直接写到程序中

  • 不言自证 ✅
    • 如果违反断言, 程序马上终止
    • 避免非预期情况继续传播, 造成更难理解的错误
    • 能够大幅提升调试效率
      • segmentation fault -> yemu.c:27: main: ...
      • 别忘了来收拾残局的是你 😂

程序中的断言足够多 -> 近似于证明了程序的正确性

 

IC验证教大家写SVA(SystemVerilog Assertion), 也是类似的道理

  • 其实写assert不难, 难的是怎么把 “程序正确”用代码语言表述出来
    • 但这是另一个话题了

改进1: 让断言失败时输出更多信息

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

改进2: 输出库函数错误原因

#include <string.h>
#include <errno.h>

#define Perror(cond, format, ...) \
  Assert(cond, format ": %s", ## __VA_ARGS__, strerror(errno))

int main(int argc, char *argv[]) {
  PC = 0; R[0] = 0;
  Assert(argc >= 2, "Program is not given");  // 要求至少包含一个参数
  FILE *fp = fopen(argv[1], "r");
  Perror(fp != NULL, "Fail to open %s", argv[1]); // 要求argv[1]是一个可以成功打开的文件
  int ret = fseek(fp, 0, SEEK_END);
  Perror(ret != -1, "Fail to seek the end of the file"); // 要求fseek()成功
  long fsize = ftell(fp);
  Perror(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;
}

RTFM: man errno

减少代码中的隐含依赖

破坏隐含依赖 = bug (例如这里改了, 那里忘了改):

uint8_t M[512];

Assert(fsize < 1024, "Program size exceeds 1024 Bytes");
ret = fread(M, 1, 1024, fp);  // 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);
  • 不言自明 ✅ - 代码中可能还有其他1024
  • 不言自证 ✅ - 不要自信地认为 “改的时候我会记得”
    • 面对几十个文件, 几千行代码, 你不会记得的

将定义放在头文件

随着项目规模增长, 需要分成多个文件来管理

// main.c
#define MSIZE 512
ret = fread(M, 1, MSIZE, fp);

// inst.c
#define MSIZE 1024 // BUG: 这里忘了改
assert(PC < MSIZE);
uint32_t inst = *(uint32_t *)&M[PC];

更好的代码:

// yemu.h
#define MSIZE 512 // 一改全改

// main.c
#include "yemu.h"
ret = fread(M, 1, MSIZE, fp);

// inst.c
#include "yemu.h"
assert(PC < MSIZE);
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 & 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 (...) {  ...  }

上述代码有一处错误, 你找到了吗?

拒绝Copy-Paste

Copy-Paste = 编写相似代码时, 复制旧代码并稍作修改

  • 开发效率++, 维护难度+++++

 

上述代码不言自明本身就不怎么样, 不言自证就更难了

  • 需要看很久的代码, 基本上都很难做到不言自证
    • 而且你基本上没有耐心仔细看的 😂
  • 当你粘贴出上百行这样的代码, 你很可能会改漏几处
    • 哪天你发现了一个共性的问题(例如立即数忘记符号扩展), 所有粘贴的代码都要修改
    • 改漏了 = bug

 

粘贴一时爽, 调试火葬场 😈

编写可复用的代码

通过变量, 函数, 宏等方式消除重复/相似的代码

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;

 

  • 引入中间变量, 不言自明 ✅
  • 对齐的代码更容易阅读并发现错误, 不言自证 ✅

使用合适的语言特性

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) {  ...  }

使用合适的语言特性(续)

  1. struct和位域(bit field)
    • 把位抽取操作交给编译器
  2. union
    • 可将数据解释成不同类型
  3. 指针
    • 按指针类型对内存地址中的数据进行解释
  4. switch-case语句
    • 替代对同一个变量的连续判断
  5. 二进制常数(GNU dialect)
    • 可以直接抄手册了

YEMU v2.0 - 编写可读可维护代码

正确的代码 != 好代码

  • 好代码更大概率是正确的

好代码的两条重要准则

  • 不言自明 - 仅看代码就能明白是做什么的(specification)
  • 不言自证 - 仅看代码就能验证实现是对的(verification)

使用正确的编程模式写出好代码

  • 防御性编程 - 通过assert检查非预期行为
  • 减少代码中的隐含依赖 - 使得 “打破依赖”不会发生
    • 头文件 + 源文件
  • 编写可复用的代码 - 不要Copy-Paste
  • 使用合适的语言特性 - 把细节交给语言规范和编译器

CEMU: C代码如何执行

回顾: C语言标准手册定义了一个状态机

  • 状态集合\(S = \{<V, PC>\}\)
    • \(V = \{v_1, v_2, v_3, \dots\}\) = 程序中所有变量的取值
      • 包括全局变量和局部变量
    • \(PC\) = 程序计数器 = 当前执行的语句位置
  • 激励事件\(E = \{语句\}\)
    • 执行PC指向的语句
  • 状态转移规则\(next: S \times E \to S\)
    • 语句的语义(semantics)
  • 初始状态\(S_0 = <V_0, main函数的第一条语句>\)

 

与YEMU类似, 我们可以把这个状态机实现出来, 用它来执行C程序!

  • 这里我们假设C程序运行在hosted environment

实现C语言状态机 = C语言解释器

import sys,re
# prepend an empty line to let PC starts from 1
srcs = [''] + list(map(lambda s: s.strip(), sys.stdin.read().split('\n')))
# set PC to the next line of "int main"
state = {'PC': i + 1 for i, line in enumerate(srcs) if line.startswith('int main') }
labels = {}  # record mappings of label -> PC
[labels.setdefault(line.rstrip(':'), i) for i, line in enumerate(srcs) if re.match('^\w+:', line) != None]
semantics = [
  (r'^int\s+(\w+)\s*;$',          lambda s, p: exec(re.sub(p, r'\1 = 0xdeadbeef', s), {}, state)),
  (r'^int\s+(\w+)\s*=\s*(.+)?;$', lambda s, p: exec(re.sub(p, r'\1 = \2', s), {}, state)),
  (r'^\w+\s*=.+\s*;$',            lambda s, p: exec(s, {}, state)),
  (r'^printf\s*\(.+\)\s*;$',      lambda s, p: exec(s, {'printf': lambda fmt, *args: print(fmt % args, end='')}, state)),
  (r'^return\s+(.+)\s*;$',        lambda s, p: (print('Exit with %d' % eval(re.sub(p, r'\1', s), {}, state)), exit())),
  (r'^\w+:$',                     lambda s, p: 0),  # do nothing
  (r'^if\s*\((.+)\)\s*goto\s+(\w+)\s*;$',
                                  lambda s, p: exec(re.sub(p, r'if \1: PC = labels["\2"]', s), {'labels': labels}, state)),
  (r'^.*$',                       lambda s, p: print("Not implement: " + s)),
]
while True:
  print(state)
  stmt = srcs[state['PC']]    # read one line of statement
  for pattern, fn in semantics:
    if re.match(pattern, stmt) != None:   # parse it with regular expression
      fn(stmt, pattern) # execute acccording to the semantics
      break
  state['PC'] = state['PC'] + 1  # read PC again, since it may be changed by the if statement
int main() {
  int x = 1;
  int y = 2;
  int z = x + y;
  printf("z = %d\n", z);
  return 0;
}
$ python cemu.py < a.c
{'PC': 2}
{'PC': 3, 'x': 1}
{'PC': 4, 'x': 1, 'y': 2}
{'PC': 5, 'x': 1, 'y': 2, 'z': 3}
z = 3
{'PC': 6, 'x': 1, 'y': 2, 'z': 3}
Exit with 0

人生苦短, 我用python

通过各种高级语言特性轻松实现CEMU

  • 开箱即用的字符串处理函数: strip(), split(), startwith()
  • 方便的容器: 列表, 元组, 字典(可用字符串索引)
    • 及其迭代操作: for ... in ..., map

  • 用正则表达式匹配C语句
  • 用lambda函数实现C语句的语义
  • 神奇的exec()eval()
    • 把C语句字符串当作python代码执行

如果用C语言来实现, 代码量至少翻10倍

 

状态机的4个要素同样存在

  • \(S=\{<V, PC>\}\): state字典
  • \(E\): while的循环体
  • \(next\): semantics列表
  • \(S_0\): state的初值

通过CEMU观察状态机的状态转移过程

int main() {
  int s = 0;
  int i = 1;
loop:
  s = s + i;
  i = i + 1;
  if (i <= 100) goto loop;
  printf("s = %d\n", s);
  return 0;
}

 

  • CEMU目前只支持很少部分的C语言语法, YEMU也只支持两条指令
  • 若CEMU和YEMU功能完整, 以下两种方式执行C程序的效果等价
    • 在CEMU上执行C程序
    • 将C程序编译成指令序列, 并在YEMU上执行

 

我们用代码展示了\(s_{compile}(next(S_c, 语句)) = next(S_{isa}, 指令序列)\)

总结

模拟器/解释器 = 状态机的实现

  • YEMU = 指令集模拟器 = 用C语言实现指令集手册定义的状态机
    • 自定义freestanding运行时环境
    • 支持两条指令的指令周期: 取指, 译码, 执行, 更新PC
  • CEMU = C语言解释器 = 用其他语言实现C标准手册定义的状态机
    • hosted运行时环境
    • 支持若干简单C语句: 读语句, 解析, 执行, 更新PC

 

编写可读可维护的代码

  • 不言自明 - 仅看代码就能明白是做什么的(specification)
  • 不言自证 - 仅看代码就能验证实现是对的(verification)

使用正确的编程模式写出好代码

  • 防御性编程/减少代码中隐含依赖/编写可复用代码/使用合适语言特性