基本指令集
- 由于内容较多,务必反复仔细阅读本节内容;
- 跟随讲义和思考题的引导阅读项目中和本节相关的源代码,理解并掌握本节大量使用的框架函数(或宏);
- 对宏保持高度警惕,多通过手算学习遇到的宏定义;
- 实现所有剩余的基本指令;
- 实现标志寄存器及基本指令中对标志寄存器的更新;
- 跑通所有测试用例;
- 回答所有思考题(部分思考题需要在完全阅读完本节内容后回答);
现代指令系统
我们已经成功在 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 般的运行时环境.
我们先来讨论一下程序执行究竟需要些什么.
- 程序需要有地方存放代码和数据, 于是需要内存;
- 程序需要执行, 于是需要 CPU 以及指令集;
- 对于需要运行结束的程序, 需要有一种结束运行的方法。
事实上, 可以在 TRM 上运行的程序都对计算机有类似的需求. 我们把这些计算机相关的需求抽象成统一的 API 提供给程序, 这样程序就不需要关心计算机硬件相关的细节了. 这正是 AM(Abstract machine) 项目的意义所在.
我们经常在各种地方听说过 API 这个词语,那么你能解释一下什么是 API 吗?如果你没有听说过,可以在网络上搜索相关信息并回答这个问题。
你或许会觉得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 属于硬件还是软件呢?经过 PA1,我们了解到 NEMU 的实质是模拟硬件。另外,我们都知道,操作系统(OS)是直接运行在硬件之上的,是一个软件。那么 AM 和你认为的操作系统是否一样?如果一样,请说明他们的共同点;如果不一样,请说明他们之间的区别。
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/tests
和nexus-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
)的链接顺序, 还定义了一些关于位置信息的符号, 包括每个节的末尾, 栈顶位置, 堆区的起始和末尾.
我们对编译得到的可执行文件的行为进行简单的梳理:
- 第一条指令从
nexus-am/am/arch/x86-nemu/img/boot/start.S
开始, 设置好栈顶之后就跳转到nexus-am/am/arch/x86-nemu/src/trm.c
的_trm_init()
函数处执行. - 在
_trm_init()
中调用main()
函数执行程序的主体功能. - 从
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
.
我们知道代码和数据都在可执行文件里面, 但却没有提到堆(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
脚本改回来.
我们知道当打入命令
make ARCH=x86-nemu ALL=dummy run
并敲击回车后,NEMU 将被编译并载入 dummy
这个用户程序进入运行,那么请你根据 Makefile
和上文的介绍思考一下,从你输入完这条命令敲击回车,直到出现 NEMU 的提示符 (nemu)
之间,都做了哪些工作?请你用条目式依次列举出来。
你需要实现上文中提到的更多指令, 以通过上述测试用例. 请结合下列几个并列的任务一起实现本部分任务。
你可以自由选择按照什么顺序来实现指令. 经过 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.
由于部分测试用例需要实现较多指令, 建议按照以下顺序进行测试:
- 其它所有测试用例
- string
- hello-str
框架中已经实现了部分指令,但是没有将它们填入指令表中,部分需要设置标志位的指令也没有做设置标志位的操作,请你在逐个通过测试样例的过程中,将它们补充完整。
正常来说,你应该按照如下思路来实现剩余的所有指令:
- 从未测试的测试用例中选取一个新的测试用例;
- 将该测试用例送入 NEMU 中运行;
- 运行过程中,自前往后逐个实现未实现的指令(或已实现指令中未实现的形式);
- 重复如上步骤直至所有测试用例都能够正常运行。
需要注意的是,你需要在使用这些测试用例的过程中,确保你的指令实现能够通过 diff-test
的测试,以免在今后出现意外的问题。
当你实现了 jcc
指令之后,是不是对 eflags
的作用有了新的认识呢?对的,eflags
在为指令跳转提供判断依据,希望你能回答如下问题来加深你对跳转指令的理解。
cmp
指令是用减法实现的,与 sub
指令的区别是 cmp
指令只改变 eflags
,不会存结果。这里我们用减法来表示两个数比较的过程。 例如 cmp 1,2
可以用 1-2
来表示。
OF
和 SF
结合可以比较有符号数的大小,请根据样例补充下表,补充完成之后比较一下两个数的大小,你会发现eflags的神奇之处:
+-----+-----+------------------------------------+
| SF | OF | 实例 |
+-----+-----+------------------------------------+
| 0 | 0 | 2 - 1 |
+-----+-----+------------------------------------+
| 0 | 1 | |
+-----+-----+------------------------------------+
| 1 | 0 | |
+-----+-----+------------------------------------+
| 1 | 1 | |
+-----+-----+------------------------------------+
观察 i386 手册中关于 jcc
的指令集细节,我们发现 jcc
指令有许多种形式,例如 ja
, jb
, jbe
等等……从手册上我们也知道 ja
即 jump above
,或者 jbe
意为 jump below or equal
。
此外,我们还知道 cmp
指令的形式为:cmp op1, op2
。那么请你思考一下,op1
, op2
之间的大小关系,和 jcc
指令中的 above
, below
, greater
, less
这些大小关系词之间有什么关联呢?
一键回归测试
在实现指令的过程中, 你不仅需要调试, 你还需要逐个测试用例地运行. 但在指令实现正确之后, 是不是意味着可以和这些测试用例说再见呢? 显然不是. 以后你还需要在 NEMU 中加入新的功能, 为了保证加入的新功能没有影响到已有功能的实现, 你还需要重新运行这些测试用例. 在软件测试中, 这个过程称为回归测试.
既然将来还要重复运行这些测试用例, 而手动重新运行每一个测试显然是一种效率低下的做法. 为了提高效率, 我们提供了一个用于一键回归测试的脚本. 在 nemu/
目录下运行
bash runall.sh
来自动批量运行 nexus-am/tests/cputest/
中的所有测试, 并报告每个测试用例的运行结果. 如果一个测试用例运行失败, 脚本将会保留相应的日志文件; 当使用脚本通过这个测试用例的时候, 日志文件将会被移除.
也许你在尝试跑通下个测试用例的时候修改了某些指令的实现代码,而你却并不知情。而这些修改可能导致之前本来可以正常运行的测试用例出现了问题,因此你需要使用框架提供的一键回归测试脚本,将所有测试用例均成功运行。当你成功运行的时候,将看到满屏的 PASS!!
,这时候你就可以放松一下了。
你已经知道, NEMU 是一个用来执行其它程序的程序. 在可计算理论中, 这种程序有一个专门的名词, 叫通用程序(Universal Program), 它的通俗含义是: 其它程序能做的事情, 它也能做. 通用程序的存在性有专门的证明, 我们在这里不做深究, 但是, 我们可以写出 NEMU, 可以用 Docker / 虚拟机做实验, 乃至我们可以在计算机上做各种各样的事情, 其背后都蕴含着通用程序的思想: NEMU 和各种模拟器只不过是通用程序的实例化, 我们也可以毫不夸张地说, 计算机就是一个通用程序的实体化. 通用程序的存在性为计算机的出现奠定了理论基础, 是可计算理论中一个极其重要的结论, 如果通用程序的存在性得不到证明, 我们就没办法放心地使用计算机, 同时也不能义正辞严地说"机器永远是对的".
我们编写的 NEMU 最终会被编译成 x86 机器代码, 用 x86 指令来模拟 x86 程序的执行. 事实上在 30 多年前(1983年), Martin Davis教授 就在他出版的"Computability, complexity, and languages: fundamentals of theoretical computer science"一书中提出了一种仅有三种指令的程序设计语言 L 语言, 并且证明了 L 语言和其它所有编程语言的计算能力等价. L语言中的三种指令分别是:
V = V + 1
V = V - 1
IF V != 0 GOTO LABEL
用x86指令来描述, 就是inc
, dec
和jne
三条指令. 假设除了输入变量之外, 其它变量的初值都是0, 并且假设程序执行到最后一条指令就结束, 你可以仅用这三种指令写一个计算两个正整数相加的程序吗? 试试下面这个小题目:
# Assume a = 0, x and y are initialized with some positive integers.
# Other temporary variables are initialized with 0.
# Let "jne" carries a variable: jne v, label.
# It means "jump to label if v != 0".
# Compute a = x + y used only these three instructions: inc, dec, jnz.
# No other instructions can be used.
# The result should be stored in variable "a".
# Have a try?
令人更惊讶的是, Martin Davis 教授还证明了, 在不考虑物理限制的情况下(认为内存容量无限多, 每一个内存单元都可以存放任意大的数), 用L语言也可以编写出一个和 NEMU 类似的通用程序! 而且这个用 L 语言编写的通用程序的框架, 竟然还和 NEMU 中的 cpu_exec()
函数如出一辙: 取指, 译码, 执行... 这其实并不是巧合, 而是模拟(Simulation)在计算机科学中的应用.
早在 Martin Davis 教授提出L语言之前, 科学家们就已经在探索什么问题是可以计算的了. 回溯到19世纪30年代, 为了试图回答这个问题, 不同的科学家提出并研究了不同的计算模型, 包括Gödel, Herbrand和Kleen研究的递归函数, Church提出的λ-演算, Turing提出的图灵机, 后来发现这些模型在计算能力上都是等价的; 到了40年代, 计算机就被制造出来了. 后来甚至还有人证明了, 如果使用无穷多个算盘拼接起来进行计算, 其计算能力和图灵机等价! 我们可以从中得出一个推论, 通用程序在不同的计算模型中有不同的表现形式. NEMU作为一个通用程序, 在19世纪30年代有着非凡的意义. 如果你能在80年前设计出NEMU, 说不定"图灵奖"就要用你的名字来命名了. 计算的极限这一篇科普文章叙述了可计算理论的发展过程, 我们强烈建议你阅读它, 体会人类的文明(当然一些数学功底还是需要的). 如果你对可计算理论感兴趣, 可以选修宋方敏老师的计算理论导引课程.
把思绪回归到PA中, 通用程序的性质告诉我们, NEMU的潜力是无穷的. 为了创造出一个缤纷多彩的世界, 你觉得NEMU还缺少些什么呢?
NEMU除了作为模拟器之外, 还具有简单的调试功能, 可以设置断点, 查看程序状态. 如果让你为 NEMU 添加如下功能:当用户程序陷入死循环时, 让用户程序暂停下来, 并输出相应的提示信息。
你觉得应该如何实现? 如果你感到疑惑, 在互联网上搜索相关信息. 如果可以,期望看到你能在 NEMU 中添加一定代码将本功能实现。
以上是 PA2.2 的所有内容.