ICS2017 Programming Assignment
  • Introduction
  • PA0 - 世界诞生的前夜: 开发环境配置
    • Installing a GNU/Linux VM
    • First Exploration with GNU/Linux
    • Installing Tools
    • Configuring vim
    • More Exploration
    • Transferring Files between host and container
    • Acquiring Source Code for PAs
  • PA1 - 开天辟地的篇章: 最简单的计算机
    • 在开始愉快的PA之旅之前
    • 开天辟地的篇章
    • RTFSC
    • 基础设施
    • 表达式求值
    • 监视点
    • i386手册
  • PA2 - 简单复杂的机器: 冯诺依曼计算机系统
    • 不停计算的机器
    • RTFSC(2)
    • 程序, 运行时环境与AM
    • 基础设施(2)
    • 输入输出
  • PA3 - 穿越时空的旅程: 异常控制流
    • 更方便的运行时环境
    • 等级森严的制度
    • 穿越时空的旅程
    • 文件系统
    • 一切皆文件
  • PA4 - 虚实交错的魔法: 分时多任务
    • 虚实交错的魔法
    • 超越容量的界限
    • 分时多任务
    • 来自外部的声音
    • 编写不朽的传奇
  • PA5 - 从一到无穷大: 程序与性能
    • 浮点数的支持
    • 通往高速的次元
    • 天下武功唯快不破
  • 杂项
    • 为什么要学习计算机系统基础
    • 实验提交要求
    • Linux入门教程
    • man入门教程
    • git入门教程
    • i386手册指令集阅读指南
    • i386手册勘误
    • 指令执行例子
Powered by GitBook
On this page
  • RTFM
  • RTFSC(2)
  • 数据结构
  • 执行流程
  • 结构化程序设计
  • 用RTL表示指令行为
  • 实现新指令
  • 运行第一个C程序

Was this helpful?

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

RTFSC(2)

Previous不停计算的机器Next程序, 运行时环境与AM

Last updated 6 years ago

Was this helpful?

RTFM

我们在上一小节中已经在概念上介绍了一条指令具体如何执行, 其中有的概念甚至显而易见得难以展开. 不过x86这一庞然大物背负着太多历史的包袱, 但当我们决定往TRM中添加各种高效的x86指令时, 也同时意味着我们无法回避这些繁琐的细节.

首先你需要了解指令确切的行为, 为此, 你需要阅读i386手册中指令集相关的章节. 有一个简单的阅读教程.

RTFSC(2)

下面我们来介绍NEMU的框架代码是如何执行指令的.

数据结构

首先先对这个过程中的两个重要的数据结构进行说明.

  • nemu/src/cpu/exec/exec.c中的opcode_table数组.

    这就是我们之前提到的译码查找表了, 这一张表通过操作码opcode来索引,

    每一个opcode对应相应指令的译码函数, 执行函数, 以及操作数宽度.

  • nemu/src/cpu/decode/decode.c中的decoding结构.

    它用于记录一些全局译码信息供后续使用, 包括操作数的类型, 宽度, 值等信息.

    其中的src成员, src2成员和dest成员分别代表两个源操作数和一个目的操作数.

    nemu/include/cpu/decode.h中定义了三个宏id_src, id_src2和id_dest, 用于方便地访问它们.

执行流程

然后对exec_wrapper()的执行过程进行简单介绍.

  • 首先将当前的%eip保存到全局译码信息decoding的成员seq_eip中,

    然后将其地址被作为参数送进exec_real()函数中.

    seq代表顺序的意思, 当代码从exec_real()返回时, decoding.seq_eip将会指向下一条指令的地址.

    exec_real()函数通过宏make_EHelper来定义:

    #define make_EHelper(name) void concat(exec_, name) (vaddr_t *eip)

    其含义是"定义一个执行阶段相关的helper函数", 这些函数都带有一个参数eip.

    NEMU通过不同的helper函数来模拟不同的步骤.

在exec_real()中:

  • 首先通过instr_fetch()函数(在nemu/include/cpu/exec.h中定义)进行取指,

    得到指令的第一个字节, 将其解释成opcode并记录在全局译码信息decoding中.

  • 根据opcode查阅译码查找表, 得到操作数的宽度信息,

    并通过调用set_width()函数将其记录在全局译码信息decoding中.

  • 调用idex()对指令进行进一步的译码和执行

idex()函数会调用译码查找表中的相应的译码函数进行操作数的译码. 译码函数统一通过宏make_DHelper来定义(在nemu/src/cpu/decode/decode.c中):

#define make_DHelper(name) void concat(decode_, name) (vaddr_t *eip)

它们的名字主要采用i386手册附录A中的操作数表示记号, 例如I2r表示将立即数移入寄存器, 其中I表示立即数, 2表示英文to, r表示通用寄存器, 更多的记号请参考i386手册. 译码函数会把指令中的操作数信息分别记录在全局译码信息decoding中

这些译码函数会进一步分解成各种不同操作数的译码的组合, 以实现操作数译码的解耦. 操作数译码函数统一通过宏make_DopHelper来定义 (在nemu/src/cpu/decode/decode.c中, decode_op_rm()除外):

#define make_DopHelper(name) void concat(decode_op_, name) (vaddr_t *eip, Operand *op, bool load_val)

它们的名字主要采用i386手册附录A中的操作数表示记号. 操作数译码函数会把操作数的信息记录在结构体op中, 如果操作数在指令中, 就会通过instr_fetch()将它们从eip所指向的内存位置取出. 为了使操作数译码函数更易于复用, 函数中的load_val参数会控制 是否需要将该操作数读出到全局译码信息decoding供后续使用. 例如如果一个内存操作数是源操作数, 就需要将这个操作数从内存中读出来供后续执行阶段来使用; 如果它仅仅是一个目的操作数, 就不需要从内存读出它的值了, 因为执行这条指令并不需要这个值, 而是将新数据写入相应的内存位置.

idex()函数中的译码过程结束之后, 会调用译码查找表中的相应的执行函数来进行真正的执行操作. 执行函数统一通过宏make_EHelper来定义, 它们的名字是指令操作本身. 执行函数通过RTL来描述指令真正的执行功能(RTL将在下文介绍). 其中operand_write()函数(在nemu/src/cpu/decode/decode.c中定义) 会根据第一个参数中记录的类型的不同进行相应的写操作, 包括写寄存器和写内存.

从idex()返回后, exec_real()最后会通过update_eip()对%eip进行更新.

结构化程序设计

细心的你会发现以下规律:

  • 对于同一条指令的不同形式, 它们的执行阶段是相同的.

    例如add_I2E和add_E2G等, 它们的执行阶段都是把两个操作数相加, 把结果存入目的操作数.

  • 对于不同指令的同一种形式, 它们的译码阶段是相同的.

    例如add_I2E和sub_I2E等, 它们的译码阶段都是识别出一个立即数和一个E操作数.

  • 对于同一条指令同一种形式的不同操作数宽度, 它们的译码阶段和执行阶段都是非常类似的.

    例如add_I2E_b, add_I2E_w和add_I2E_l,

    它们都是识别出一个立即数和一个E操作数, 然后把相加的结果存入E操作数.

这意味着, 如果独立实现每条指令不同形式不同操作数宽度的helper函数, 将会引入大量重复的代码. 需要修改的时候, 相关的所有helper函数都要分别修改, 遗漏了某一处就会造成bug, 工程维护的难度急速上升. 一种好的做法是把译码, 执行和操作数宽度的相关代码分离开来, 实现解耦, 也就是在程序设计课上提到的结构化程序设计.

在框架代码中, 实现译码和执行之间的解耦的是idex()函数, 它依次调用opcode_table表项中的译码和执行的helper函数, 这样我们就可以分别编写译码和执行的helper函数了. 实现操作数宽度和译码, 执行这两者之间的解耦的是id_src, id_src2和id_dest中的width成员, 它们记录了操作数宽度, 译码和执行的过程中会根据它们进行不同的操作, 通过同一份译码函数和执行函数实现不同操作数宽度的功能.

为了易于使用, 框架代码中使用了一些宏, 我们在这里把相关的宏整理出来, 供大家参考.

宏

含义

nemu/include/macro.h

str(x)

字符串"x"

concat(x, y)

tokenxy

nemu/include/cpu/reg.h

reg_l(index)

编码为index的32位GPR

reg_w(index)

编码为index的16位GPR

reg_b(index)

编码为index的8位GPR

nemu/include/cpu/decode.h

id_src

全局变量decoding中源操作数成员的地址

id_src2

全局变量decoding中2号源操作数成员的地址

id_dest

全局变量decoding中目的操作数成员的地址

make_Dhelper(name)

名为decode_name的译码函数的原型说明

nemu/src/cpu/decode.c

make_Dophelper(name)

名为decode_op_name的操作数译码函数的原型说明

nemu/include/cpu/exec.h

make_Ehelper(name)

名为exec_name的执行函数的原型说明

print_asm(...)

将反汇编结果的字符串打印到缓冲区decoding.assembly中

suffix_char(width)

操作数宽度width对应的后缀字符

print_asm_template1(instr)

打印单目操作数指令instr的反汇编结果

print_asm_template2(instr)

打印双目操作数指令instr的反汇编结果

print_asm_template3(instr)

打印三目操作数指令instr的反汇编结果

用RTL表示指令行为

下面我们对NEMU中使用的RTL进行一些说明, 首先是RTL寄存器的定义. RTL寄存器是RTL指令专门使用的寄存器. 在NEMU中, RTL寄存器统一使用rtlreg_t来定义, 而rtlreg_t(在nemu/include/common.h中定义)其实只是一个uint32_t类型:

typedef uint32_t rtlreg_t;

在NEMU中, RTL寄存器只有以下这些

  • x86的八个通用寄存器(在nemu/include/cpu/reg.h中定义)

  • id_src, id_src2和id_dest中的访存地址addr和操作数内容val(在nemu/include/cpu/decode.h中定义).

  • 临时寄存器t0~t3(在nemu/src/cpu/decode/decode.c中定义)

  • 0寄存器tzero(在nemu/src/cpu/decode/decode.c中定义), 它只能读出0, 不能写入

有了RTL寄存器, 我们就可以定义RTL指令对它们进行的操作了. 在NEMU中, RTL指令有两种(在nemu/include/cpu/rtl.h中定义). 一种是RTL基本指令, 它们的特点是在即时编译技术里面可以只使用一条机器指令来实现相应的功能, 同时也不需要使用临时寄存器, 可以看做是最基本的x86指令中的最基本的操作. RTL基本指令包括:

  • 立即数读入rtl_li

  • 算术运算和逻辑运算, 包括寄存器-寄存器类型rtl_(add|sub|and|or|xor|shl|shr|sar|slt|sltu)

    和立即数-寄存器类型rtl_(add|sub|and|or|xor|shl|shr|sar|slt|sltu)i

  • 内存的访存rtl_lm和rtl_sm

  • 通用寄存器的访问rtl_lr_(b|w|l)和rtl_sr_(b|w|l)

第二种RTL指令是RTL伪指令, 它们是通过RTL基本指令或者已经实现的RTL伪指令来实现的, 包括:

  • 带宽度的通用寄存器访问rtl_lr和rtl_sr

  • EFLAGS标志位的读写rtl_set_(CF|OF|ZF|SF|IF)和rtl_get_(CF|OF|ZF|SF|IF)

  • 其它常用功能, 如数据移动rtl_mv, 符号扩展rtl_sext等

其中大部分RTL伪指令还没有实现, 必要的时候你需要实现它们. 有了这些RTL指令之后, 我们就可以方便地通过若干条RTL指令来实现每一条x86指令的行为了.

实现新指令

对译码, 执行和操作数宽度的解耦实现以及RTL的引入对NEMU中实现一条新的x86指令提供了很大的便利, 为了实现一条新指令, 你只需要 1. 在opcode_table中填写正确的译码函数, 执行函数以及操作数宽度 1. 用RTL实现正确的执行函数, 需要注意使用RTL伪指令时不要把临时变量中有意义的值覆盖了

框架代码把绝大部分译码函数和执行函数都定义好了, 你可以很方便地使用它们.

如果你读过上文的扩展阅读材料中关于RISC与CISC融为一体的故事, 你也许会记得CISC风格的x86指令最终被分解成RISC风格的微指令在计算机中运行, 才让x86在这场扩日持久的性能大战中得以存活下来的故事. 如今NEMU在经历了第二次重构之后, 也终于引入了RISC风格的RTL来实现x86指令, 这也许是冥冥之中的安排吧.

运行第一个C程序

说了这么多, 现在到了动手实践的时候了. 你在PA2的第一个任务, 就是实现若干条指令, 使得第一个简单的C程序可以在NEMU中运行起来. 这个简单的C程序的代码是nexus-am/tests/cputest/tests/dummy.c, 它什么都不做就直接返回了. 在nexus-am/tests/cputest目录下键入

make ARCH=x86-nemu ALL=dummy run

编译dummy程序, 并启动NEMU运行它. 事实上, 并不是每一个程序都可以在NEMU中运行, nexus-am/子项目专门用于编译出能在NEMU中运行的程序, 我们在下一小节中会再来介绍它.

在NEMU中运行dummy程序, 你会发现NEMU输出以下信息:

invalid opcode(eip = 0x0010000a): e8 01 00 00 00 90 55 89 ...

There are two cases which will trigger this unexpected exception:
1. The instruction at eip = 0x0010000a is not implemented.
2. Something is implemented incorrectly.
Find this eip value(0x0010000a) in the disassembling result to distinguish which case it is.

If it is the first case, see
 _ ____   ___    __    __  __                         _ 
(_)___ \ / _ \  / /   |  \/  |                       | |
 _  __) | (_) |/ /_   | \  / | __ _ _ __  _   _  __ _| |
| ||__ < > _ <| '_ \  | |\/| |/ _  | '_ \| | | |/ _  | |
| |___) | (_) | (_) | | |  | | (_| | | | | |_| | (_| | |
|_|____/ \___/ \___/  |_|  |_|\__,_|_| |_|\__,_|\__,_|_|

for more details.

If it is the second case, remember:
* The machine is always right!
* Every line of untested code is always wrong!

这是因为你还没有实现以0xe8为首字节的指令, 因此, 你需要开始在NEMU中添加指令了.

要实现哪些指令才能让dummy在NEMU中运行起来呢? 答案就在其反汇编结果(nexus-am/tests/cputest/build/dummy-x86-nemu.txt)中. 查看反汇编结果, 你发现只需要添加call, push, sub, xor, pop, ret六条指令就可以了. 每一条指令还有不同的形式, 根据KISS法则, 你可以先实现只在dummy中出现的指令形式, 通过指令的opcode可以确定具体的形式.

这里要再次强调, 你务必通过i386手册来查阅指令的功能, 不能想当然. 手册中给出了指令功能的完整描述(包括做什么事, 怎么做的, 有什么影响), 一定要仔细阅读其中的每一个单词, 对指令功能理解错误和遗漏都会给以后的调试带来巨大的麻烦.

  • call: call指令有很多形式, 不过在PA中只会用到其中的几种,

    现在只需要实现CALL rel32的形式就可以了.

    %eip的跳转可以通过将decoding.is_jmp设为1, 并将decoding.jmp_eip设为跳转目标地址来实现,

    这时在update_eip()函数中会把跳转目标地址作为新的%eip, 而不是顺序意义下的下一条指令的地址

  • push, pop: 现在只需要实现PUSH r32和POP r32的形式就可以了,

    它们可以很容易地通过rtl_push和rtl_pop来实现

  • sub: 在实现sub指令之前, 你首先需实现EFLAGS寄存器.

    你只需要在寄存器结构体中添加EFLAGS寄存器即可.

    EFLAGS是一个32位寄存器, 它的结构如下:

    31                  23                  15               7             0
    +-------------------+-------------------+-------+-+-+-+-+-+-+-------+-+-+
    |                                               |O| |I| |S|Z|       | |C|
    |                       X                       | |X| |X| | |   X   |1| |
    |                                               |F| |F| |F|F|       | |F|
    +-------------------+-------------------+-------+-+-+-+-+-+-+-------+-+-+

    在NEMU中, 我们只会用到EFLAGS中以下的5个位: CF, ZF, SF, IF, OF,

    标记成X的位不必关心, 它们的功能可暂不实现.

    关于EFLAGS中每一位的含义, 请查阅i386手册.

    添加EFLAGS寄存器需要用到结构体的位域(bit field)功能, 如果你从未听说过位域, 请查阅相关资料.

    关于EFLGAS的初值, 我们遵循i386手册中提到的约定,

    你需要在i386手册的第10章中找到这一初值, 然后在restart()函数中对EFLAGS寄存器进行初始化.

    实现了EFLAGS寄存器之后, 再实现相关的RTL指令, 之后你就可以通过这些RTL指令来实现sub指令了

  • xor, ret: RTFM吧

上文已经把一条指令在NEMU中执行的流程进行了大概的介绍. 如果觉得上文的内容不易理解, 可以结合来RTFSC. 但这个例子中会描述较多细节, 阅读的时候需要一定的耐心.

NEMU使用(寄存器传输语言)来描述x86指令的行为. 这样做的好处是可以提高代码的复用率, 使得指令模拟的实现更加规整. 同时RTL也可以作为一种(中间表示)语言, 将来可以很方便地引入技术对NEMU进行优化, 即使你在PA中不一定有机会感受到这一好处.

从概念上看, 它们分别与和

有异曲同工之妙

这里
这个例子
RTL
IR
即时编译
MAR
MDR