程序, 运行时环境与AM
Last updated
Was this helpful?
Last updated
Was this helpful?
我们已经成功在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()
作为提示), 你还需要编写相应的功能.
但并不是有了足够的指令就能运行更多的程序. 我们之前提到"并不是每一个程序都可以在NEMU中运行", 现在我们来解释一下背后的缘由.
从直觉上来看, 让TRM来支撑一个功能齐全的操作系统的运行还是比较勉强的. 这给我们的感觉就是, 计算机也有一定的"功能强弱"之分, 计算机越"强大", 就能跑越复杂的程序. 换句话说, 程序的运行其实是对计算机的功能有需求的. 在你运行Hello World程序时, 你敲入一条命令(或者点击一下鼠标), 程序就成功运行了, 但这背后其实隐藏着操作系统开发者和库函数开发者的无数汗水. 一个事实是, 应用程序的运行都需要的支持, 包括加载, 销毁程序, 以及提供程序运行时的各种动态链接库(你经常使用的库函数就是运行时环境提供的)等. 为了让客户程序在NEMU中运行, 现在轮到你来提供相应的运行时环境的支持了. 不用担心, 由于NEMU目前的功能并不完善, 我们必定无法向用户程序提供GNU/Linux般的运行时环境.
我们先来讨论一下程序执行究竟需要些什么. 1. 程序需要有地方存放代码和数据, 于是需要内存 1. 程序需要执行, 于是需要CPU以及指令集 1. 对于需要运行结束的程序, 需要有一种结束运行的方法
事实上, 可以在TRM上运行的程序都对计算机有类似的需求. 我们把这些计算机相关的需求抽象成统一的API提供给程序, 这样程序就不需要关心计算机硬件相关的细节了. 这正是AM(Abstract machine)项目的意义所在.
你或许会觉得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作为一个计算机的抽象模型, 可以将一个现代计算机从逻辑上划分成以下模块
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的流程更加明确:
这个流程其实与PA1中开天辟地的故事遥相呼应: 先驱希望创造一个计算机的世界, 并赋予它执行程序的使命. 亲自搭建NEMU(硬件)和AM(软件)之间的桥梁来支撑程序的运行, 是"理解程序如何在计算机上运行"这一终极目标的不二选择.
我们来简单介绍一下AM项目的代码. 代码中nexus-am
目录下的源文件组织如下(部分目录下的文件并未列出):
整个AM项目分为三大部分:
nexus-am/am
- 不同计算机架构的AM实现, 在PA中我们只需要关注nexus-am/am/arch/x86-nemu
即可
nexus-am/tests
和nexus-am/apps
- 一些功能测试和直接运行AM上的应用程序
nexus-am/libs
- 一些体系结构无关的, 可以直接运行在AM上的库, 方便应用程序的开发
在让NEMU运行客户程序之前, 我们需要将客户程序的代码编译成可执行文件. 需要说明的是, 我们不能使用gcc的默认选项直接编译, 因为默认选项会根据GNU/Linux的运行时环境将代码编译成运行在GNU/Linux下的可执行文件. 但此时的NEMU并不能为客户程序提供GNU/Linux的运行时环境, 在NEMU中运行上述可执行文件会产生错误, 因此我们不能使用gcc的默认选项来编译用户程序.
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()
函数处执行. 1. 在_trm_init()
中调用main()
函数执行程序的主体功能. 1. 从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.md
和nexus-am/SPEC.md
.
把_putc()
作为TRM的API是一个很有趣的考虑, 我们在不久的将来再讨论它, 目前我们暂不打算运行需要调用_putc()
的程序.
因为这条特殊的指令是我们人为添加的, 标准的汇编器并不能识别它. 如果你查看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/tests/cputest/
目录下执行
其中xxx
为测试用例的名称(不包含.c
后缀).
上述make run
的命令最终会调用nexus-am/am/arch/x86-nemu/img/run
来启动NEMU. 为了使用GDB来调试NEMU, 你需要修改这一run
脚本的内容:
然后再执行上述make run
的命令即可. 无需GDB调试时, 可将上述run
脚本改回来.
解决这个问题的方法是, 我们需要在GNU/Linux下根据AM的运行时环境编译出能够在NEMU中运行的可执行文件. 为了不让链接器ld使用默认的方式链接, 我们还需要提供描述AM运行时环境的链接脚本. AM的框架代码已经把相应的配置准备好了:
最后来看看_halt()
. _halt()
里面是一条语句, 内联汇编语句允许我们在C代码中嵌入汇编语句. 这条指令和我们常见的汇编指令不一样(例如movl $1, %eax
), 它是直接通过指令的编码给出的, 它只有一个字节, 就是0xd6
. 如果你在nemu/src/cpu/exec/exec.c
中查看opcode_table
, 你会发现, 这条指令正是那条特殊的nemu_trap
! 这其实也说明了为什么要通过编码来给出这条指令, 如果你使用以下方式来给出指令, 汇编器将会报错: