程序, 运行时环境与AM

现代指令系统

我们已经成功在TRM上运行dummy程序了, 然而这个程序什么都没做就结束了, 一点也不过瘾啊. 为了让NEMU支持大部分程序的运行, 你还需要实现更多的指令:

  • Data Movement Instructions: mov, push, pop, leave, cltd(在i386手册中为cdq), movsx, movzx
  • Binary Arithmetic Instructions: add, inc, sub, dec, cmp, neg, adc, sbb, mul, imul, div, idiv
  • Logical Instructions: not, and, or, xor, sal(shl), shr, sar, setcc, test
  • Control Transfer Instructions: jmp, jcc, call, ret
  • Miscellaneous Instructions: lea, nop

框架代码已经实现了上述删除线标记的指令, 但并没有填写opcode_table. 此外, 某些需要更新EFLAGS的指令并没有完全实现好(框架代码中已经插入了TODO()作为提示), 你还需要编写相应的功能.

运行时环境与AM

但并不是有了足够的指令就能运行更多的程序. 我们之前提到"并不是每一个程序都可以在NEMU中运行", 现在我们来解释一下背后的缘由.

从直觉上来看, 让TRM来支撑一个功能齐全的操作系统的运行还是比较勉强的. 这给我们的感觉就是, 计算机也有一定的"功能强弱"之分, 计算机越"强大", 就能跑越复杂的程序. 换句话说, 程序的运行其实是对计算机的功能有需求的. 在你运行Hello World程序时, 你敲入一条命令(或者点击一下鼠标), 程序就成功运行了, 但这背后其实隐藏着操作系统开发者和库函数开发者的无数汗水. 一个事实是, 应用程序的运行都需要运行时环境的支持, 包括加载, 销毁程序, 以及提供程序运行时的各种动态链接库(你经常使用的库函数就是运行时环境提供的)等. 为了让客户程序在NEMU中运行, 现在轮到你来提供相应的运行时环境的支持了. 不用担心, 由于NEMU目前的功能并不完善, 我们必定无法向用户程序提供GNU/Linux般的运行时环境.

我们先来讨论一下程序执行究竟需要些什么.

  1. 程序需要有地方存放代码和数据, 于是需要内存
  2. 程序需要执行, 于是需要CPU以及指令集
  3. 对于需要运行结束的程序, 需要有一种结束运行的方法

事实上, 可以在TRM上运行的程序都对计算机有类似的需求. 我们把这些计算机相关的需求抽象成统一的API提供给程序, 这样程序就不需要关心计算机硬件相关的细节了. 这正是AM(Abstract machine)项目的意义所在.

什么是AM?

你或许会觉得NEMU与AM的关系有点模糊不清, 让我们还是来看ATM机的例子.

说起ATM机, 你脑海里一定会想起一个可以存款, 取款, 查询余额, 转账的机器. 我们不妨把你脑海里的这个机器的模型称为抽象ATM机. 从用户的角度来说, 用户对ATM机的功能是有期望的: 要能存款, 取款, 查询余额, 转账.

从银行的角度来说, 不同银行的ATM机千差万别: 存款的加密方式, 交易时使用的自定义通信协议, 余额在银行系统里面的表示和组织方式... 不同银行的ATM机之间存在这么多细节上的差异, 怎么样才能让用户方便地使用ATM机呢? 那就是, 为不同银行的ATM机分别实现上文提到的抽象ATM机的功能: 只要ATM机实现了存款, 取款和查询余额的这组统一的功能, 和用户对抽象ATM机的认识匹配上, 用户就可以方便地使用这台ATM机, 而不必关心ATM机的上述细节.

在NEMU和AM的关系中, 程序就像是用户, AM就像是抽象ATM机, 我们实现NEMU这个计算机就像是造一台新的(虚拟的)ATM机, 也就像我们在PA1中提到的, 写一个支付宝APP. 同样的道理, 程序对计算机的功能是有期望的: 要能计算, 输入输出... 这些功能的期望组成了一台抽象计算机AM, 它刻画了一台真实计算机应该具备的功能. 但不同计算机的硬件配置各不相同, ISA也千差万别, 怎么样才能让程序方便地运行呢? 那就是, 为不同的计算机分别实现AM的功能: 只要计算机实现了AM定义的一组统一的API, 就能和程序对计算机的功能期望匹配上, 程序就可以方便地在计算机上运行, 而不必关心计算机的底层细节.


有兴趣折腾的同学还可以来理解一下真机, NEMU和AM这三者的关系. 我们会发现, 无论是真实的ATM机还是支付宝APP, 都符合我们对的抽象ATM机的认知: 它们都能存款, 取款, 查询余额, 转账. 也正因为如此, 支付宝APP刚推出的时候, 我们才能很容易上手: 虽然支付宝APP是个虚拟的ATM机, 但我们还是可以很容易根据我们对抽象ATM机的认知来使用它.

回到NEMU的例子中来, 我们还是用ATM机的例子来比喻: 真机就像是一台真实的ATM机, NEMU这个虚拟机就像是一个支付宝APP, AM还是我们概念上的抽象ATM机. 只要一台机器实现了AM的功能(能计算, 能输出输入...), 程序都可以在上面运行, 不必关心这台机器是真实的, 还是用程序虚拟出来的.

用一句话来总结这三者的关系: AM在概念上定义了一台抽象计算机, 它从运行程序的视角刻画了一台计算机应该具备的功能, 而真机和NEMU都是这台抽象计算机的具体实现, 只是真机是通过物理上存在的数字电路来实现, NEMU是通过程序来实现.

如果你对面向对象程序设计有一些初步的了解, 解释起来就更简单了:

AM是个抽象类, 真机和虚拟机是由AM这个抽象类派生出来的两个子类, 而x86真机和NEMU则分别是这两个子类的实例化.

AM作为一个计算机的抽象模型, 可以将一个现代计算机从逻辑上划分成以下模块

AM = TRM + IOE + ASYE + PTE + MPE
  • TRM(Turing Machine) - 图灵机, 为计算机提供基本的计算能力
  • IOE(I/O Extension) - 输入输出扩展, 为计算机提供输出输入的能力
  • ASYE(Asynchronous Extension) - 异步处理扩展, 为计算机提供处理中断异常的能力
  • PTE(Protection Extension) - 保护扩展, 为计算机提供存储保护的能力
  • MPE(Multi-Processor Extension) - 多处理器扩展, 为计算机提供多处理器通信的能力 (MPE超出了ICS课程的范围, 在PA中不会涉及)

不同程序对计算机的功能需求也不完全一样, 例如只进行纯粹计算任务的程序在TRM上就可以运行; 要运行小游戏, 仅仅是TRM就不够了, 因为小游戏还需要和用户进行交互, 因此还需要IOE; 要运行一个现代操作系统, 还要在此基础上加入ASYE和PTE. 我们知道ISA是计算机系统中的软硬件接口, 而从上述AM的模块划分可以看出, AM描述的恰恰就是ISA本身, 它是不同ISA的抽象.

感谢AM项目的诞生, 让NEMU和程序的界线更加泾渭分明, 同时使得PA的流程更加明确:

(在NEMU中)实现硬件功能 -> (在AM中)提供软件抽象 -> (在APP层)运行程序
(在NEMU中)实现更强大的硬件功能 -> (在AM中)提供更丰富的软件抽象 -> (在APP层)运行更复杂的程序

这个流程其实与PA1中开天辟地的故事遥相呼应: 先驱希望创造一个计算机的世界, 并赋予它执行程序的使命. 亲自搭建NEMU(硬件)和AM(软件)之间的桥梁来支撑程序的运行, 是"理解程序如何在计算机上运行"这一终极目标的不二选择.

RTFSC(3)

我们来简单介绍一下AM项目的代码. 代码中nexus-am目录下的源文件组织如下(部分目录下的文件并未列出):

nexus-am
├── am                               # AM相关
│   ├── am.h
│   ├── arch                         # 不同体系结构-平台的AM实现
│   │   ├── native
│   │   └── x86-nemu                 # x86-nemu的AM实现
│   │       ├── img                  # 构建/运行二进制文件/镜像的脚本
│   │       │   ├── boot
│   │       │   │   ├── Makefile
│   │       │   │   └── start.S      # 程序入口
│   │       │   ├── build            # 构建脚本
│   │       │   ├── loader.ld        # 链接脚本
│   │       │   └── run              # 运行脚本
│   │       ├── include
│   │       ├── README.md
│   │       └── src
│   │           ├── asye.c           # ASYE
│   │           ├── ioe.c            # IOE
│   │           ├── pte.c            # PTE
│   │           ├── trap.S
│   │           └── trm.c            # TRM
│   └── Makefile
├── apps                             # 直接运行在AM上的应用
├── libs                             # 可以直接运行在AM上的库
├── Makefile
├── Makefile.app
├── Makefile.check
├── Makefile.compile
├── Makefile.lib
├── README.md
├── SPEC.md                          # AM接口规范说明
└── tests                            # 直接运行在AM上的测试

整个AM项目分为三大部分:

  • nexus-am/am - 不同计算机架构的AM实现, 在PA中我们只需要关注nexus-am/am/arch/x86-nemu即可
  • nexus-am/testsnexus-am/apps - 一些功能测试和直接运行AM上的应用程序
  • nexus-am/libs - 一些体系结构无关的, 可以直接运行在AM上的库, 方便应用程序的开发

在让NEMU运行客户程序之前, 我们需要将客户程序的代码编译成可执行文件. 需要说明的是, 我们不能使用gcc的默认选项直接编译, 因为默认选项会根据GNU/Linux的运行时环境将代码编译成运行在GNU/Linux下的可执行文件. 但此时的NEMU并不能为客户程序提供GNU/Linux的运行时环境, 在NEMU中运行上述可执行文件会产生错误, 因此我们不能使用gcc的默认选项来编译用户程序.

解决这个问题的方法是交叉编译, 我们需要在GNU/Linux下根据AM的运行时环境编译出能够在NEMU中运行的可执行文件. 为了不让链接器ld使用默认的方式链接, 我们还需要提供描述AM运行时环境的链接脚本. AM的框架代码已经把相应的配置准备好了:

  • gcc将AM实现的源文件编译成目标文件, 然后通过ar将这些目标文件打包成一个归档文件作为一个库, 把不同计算机架构的AM实现通过库的方式提供给程序
  • gcc把在AM上运行的应用程序源文件编译成目标文件
  • 必要的时候通过gcc和ar把程序依赖的运行库也打包成归档文件
  • 执行脚本文件nexus-am/am/arch/x86-nemu/img/build, 在脚本文件中
    • 将程序入口nexus-am/am/arch/x86-nemu/img/boot/start.S编译成目标文件
    • 最后让ld根据链接脚本nexus-am/am/arch/x86-nemu/img/loader.ld, 将上述目标文件和归档文件链接成可执行文件

根据这一链接脚本的指示, 可执行程序重定位后的节从0x100000开始, 首先是.text节, 其中又以nexus-am/am/arch/x86-nemu/img/boot/start.o中自定义的entry节开始, 然后接下来是其它目标文件的.text节. 这样, 可执行程序的0x100000处总是放置nexus-am/am/arch/x86-nemu/img/boot/start.S的代码, 而不是其它代码, 保证客户程序总能从0x100000开始正确执行. 链接脚本也定义了其它节(包括.rodata, .data, .bss)的链接顺序, 还定义了一些关于位置信息的符号, 包括每个节的末尾, 栈顶位置, 堆区的起始和末尾.

我们对编译得到的可执行文件的行为进行简单的梳理:

  1. 第一条指令从nexus-am/am/arch/x86-nemu/img/boot/start.S开始, 设置好栈顶之后就跳转到nexus-am/am/arch/x86-nemu/src/trm.c_trm_init()函数处执行.
  2. _trm_init()中调用main()函数执行程序的主体功能.
  3. main()函数返回后, 调用_halt()结束运行.

阅读nexus-am/am/arch/x86-nemu/src/trm.c中的代码, 你会发现只需要实现很少的API就可以支撑起程序在TRM上运行了:

  • _Area _heap结构用于指示堆区的起始和末尾
  • void _putc(char ch)用于输出一个字符
  • void _halt(int code)用于结束程序的运行
  • void _trm_init()用于进行TRM相关的初始化工作

这是因为, TRM所需要的指令集和内存已经被编译器考虑进去了: 编译器认为, 硬件需要提供具体的指令集实现和可用的内存, 编译生成的程序里面只需要包含"使用的指令"和"程序的内存映象"这两方面的信息, 程序就可以在硬件上运行了, 所以我们不需要在trm.c里面提供"使用指令集"和"使用内存"的API. 关于AM定义的API, 可以阅读nexus-am/README.mdnexus-am/SPEC.md.

堆和栈在哪里?

我们知道代码和数据都在可执行文件里面, 但却没有提到堆(heap)和栈(stack). 为什么堆和栈的内容没有放入可执行文件里面? 那程序运行时刻用到的堆和栈又是怎么来的? AM的代码是否能给你带来一些启发?

_putc()作为TRM的API是一个很有趣的考虑, 我们在不久的将来再讨论它, 目前我们暂不打算运行需要调用_putc()的程序.

最后来看看_halt(). _halt()里面是一条内联汇编语句, 内联汇编语句允许我们在C代码中嵌入汇编语句. 这条指令和我们常见的汇编指令不一样(例如movl $1, %eax), 它是直接通过指令的编码给出的, 它只有一个字节, 就是0xd6. 如果你在nemu/src/cpu/exec/exec.c中查看opcode_table, 你会发现, 这条指令正是那条特殊的nemu_trap! 这其实也说明了为什么要通过编码来给出这条指令, 如果你使用以下方式来给出指令, 汇编器将会报错:

asm volatile("nemu_trap" : : "a" (0))

因为这条特殊的指令是我们人为添加的, 标准的汇编器并不能识别它. 如果你查看objdump的反汇编结果, 你会看到nemu_trap指令被标识为(bad), 原因是类似的: objdump并不能识别我们人为添加的nemu_trap指令. "a"(0)表示在执行内联汇编语句给出的汇编代码之前, 先将0读入%eax寄存器. 这样, 这段汇编代码的功能就和nemu/src/cpu/exec/special.c中的helper函数nemu_trap()对应起来了. 此外, volatile是C语言的一个关键字, 如果你想了解关于volatile的更多信息, 请查阅相关资料.

运行更多的程序

未测试代码永远是错的, 你需要足够多的测试用例来测试你的NEMU. 我们在nexus-am/tests/cputest目录下准备了一些测试用例. 首先我们让AM项目上的程序默认编译到x86-nemu的AM中:

--- nexus-am/Makefile.check
+++ nexus-am/Makefile.check
@@ -7,2 +7,2 @@
-ARCH ?= native
+ARCH ?= x86-nemu
 ARCH = $(shell ls $(AM_HOME)/am/arch/)

然后在nexus-am/tests/cputest/目录下执行

make ALL=xxx run

其中xxx为测试用例的名称(不包含.c后缀).

上述make run的命令最终会调用nexus-am/am/arch/x86-nemu/img/run来启动NEMU. 为了使用GDB来调试NEMU, 你需要修改这一run脚本的内容:

--- nexus-am/am/arch/x86-nemu/img/run
+++ nexus-am/am/arch/x86-nemu/img/run
@@ -3,1 +3,1 @@
-make -C $NEMU_HOME run ARGS="-l `dirname $1`/nemu-log.txt $1.bin"
+make -C $NEMU_HOME gdb ARGS="-l `dirname $1`/nemu-log.txt $1.bin"

然后再执行上述make run的命令即可. 无需GDB调试时, 可将上述run脚本改回来.

实现更多的指令

你需要实现上文中提到的更多指令, 以通过上述测试用例.

你可以自由选择按照什么顺序来实现指令. 经过PA1的训练之后, 你应该不会实现所有指令之后才进行测试了. 要养成尽早做测试的好习惯, 一般原则都是"实现尽可能少的指令来进行下一次的测试". 你不需要实现所有指令的所有形式, 只需要通过这些测试即可. 如果将来仍然遇到了未实现的指令, 就到时候再实现它们.

需要注意的是, push imm8指令需要对立即数进行符号扩展, 这一点在i386手册中并没有明确说明. 在IA-32手册中关于push指令有如下说明:

If the source operand is an immediate and its size is less than the operand size, a sign-extended value is pushed on the stack.

由于部分测试用例需要实现较多指令, 建议按照以下顺序进行测试:

  1. 其它
  2. string
  3. hello-str

results matching ""

    No results matching ""