RTFM
我们在上一小节中已经在概念上介绍了一条指令具体如何执行, 其中有的概念甚至显而易见得难以展开. 但当我们决定往TRM中添加各种高效指令的同时, 也意味着我们无法回避繁琐的细节.
首先你需要了解指令确切的行为, 为此, 你需要阅读生存手册中指令集相关的章节. 具体地, 无论你选择何种ISA, 相应手册中一般都会有以下内容, 尝试RTFM并寻找这些内容的位置:
- 每一条指令具体行为的描述
- 指令opcode的编码表格
特别地, 由于x86的指令集的复杂性, 我们为选择x86的同学提供了一个简单的阅读教程.
i386手册勘误
RISC - 与CISC平行的另一个世界
如果你非常幸运地选择了riscv32, 你会发现目前只需要阅读很少部分的手册内容就可以了: 在PA中, riscv32的客户程序只会由RV32I和RV32M两类指令组成. 这得益于RISC-V指令集的设计理念 - 模块化.
RISC-V - 一款设计精巧的指令集
RISC-V是一款非常年轻的指令集 - 第一版RISC-V是在2011年5月由UC Berkeley的研究团队提出的, 至今已经风靡全球. 开放性是RISC-V的最大卖点, 就连ARM和MIPS也都为之震撼, 甚至还因竞争关系而互撕... 这篇文章叙述了RISC-V的理念以及成长的一些历史.
当然, 这些和处于教学领域的PA其实没太大关系. 关键是
- RISC-V真的很简单.
- 简单之余, 还有非常多对程序运行深思熟虑的考量. 如果你阅读RISC-V的手册, 你就会发现里面阐述了非常多设计的推敲和取舍. 另外David Patterson教授(因推广RISC而获得2018年的图灵奖, 可谓体系结构领域的一代宗师) 还为RISC-V的推广编写了一本入门书籍The RISC-V Reader, 书中从系统的角度叙述了RISC-V大量的设计原则, 并与现有指令集进行对比, 非常值得一读. 中科院计算所的三位研究生为这本书编写了中文翻译的版本 (其中一位也算是你们的直系师兄了), 不过我们还是建议阅读英文原版.
RTFSC(2)
下面我们来介绍NEMU的框架代码如何实现指令的执行.
我们在PA1中提到:
我们可以看到
cpu_exec()
模拟了CPU的工作方式: 不断执行指令.exec_once()
函数(在nemu/src/cpu/cpu.c
中定义)让CPU执行当前PC指向的一条指令, 然后更新PC.
具体地, 在exec_once()
中, 框架代码首先将当前的PC保存到全局译码信息decinfo
的成员seq_pc
中.
其中:
decinfo
结构在nemu/src/cpu/cpu.c
中定义, 它用于记录一些全局译码信息供后续使用, 包括操作数的类型, 宽度, 值等信息. 还有一些信息是ISA相关的, NEMU用一个结构类型struct ISADecodeInfo
来对这些信息进行抽象, 具体的定义在nemu/src/isa/$ISA/include/isa/decode.h
中.seq
代表顺序(sequential)的意思
然后decinfo.seq_pc
的地址将被作为参数送进isa_exec()
函数(在nemu/src/isa/$ISA/exec/exec.c
中定义)中.
isa_exec()
显然是一个用于屏蔽ISA差异的API: 不同ISA的指令行为天然不同.
取指(instruction fetch, IF)
isa_exec()
做的第一件事情就是取指令.
在NEMU中, 有一个函数instr_fetch()
(在nemu/include/cpu/exec.h
中定义)专门负责取指令的工作.
阅读这个函数的代码, 你就会发现取指操作的本质: 不过就是一次内存的访问而已.
译码(instruction decode, ID)
译码的目的是得到指令的操作和操作对象, 这主要是通过查看指令的opcode
来决定的.
不同ISA的opcode
会出现在指令的不同位置, 我们只需要根据指令的编码格式,
从取出的指令中抽取出相应的opcode
即可.
取出opcode
之后, 框架代码用它来对opcode_table
数组进行索引,
取出其中的一个OpcodeEntry
类型的元素.
opcode_table
数组其实就是我们之前提到的译码查找表, 这一张表通过操作码opcode来索引,
它记录了每一个opcode对应指令的译码辅助函数, 执行辅助函数, 以及操作数宽度.
有了这些信息, 我们就可以得知指令的具体操作了, 例如对两个寄存器进行加法操作.
操作数宽度的处理
mips32和riscv32的访存指令会有不同操作数宽度的版本, 包括32位, 16位和8位,
因此我们还需要把宽度信息记录到decinfo
中提供后续过程使用.
对于x86来说就更复杂了, 大部分指令都有不同操作数宽度的版本,
因此x86的操作数宽度信息记录会更复杂:
首先通过set_width()
函数记录译码查找表中记录的操作数宽度;
若这一操作数宽度结果为0
, 表示仅仅根据操作码来判断, 操作数宽度还不能确定,
可能是16位或者32位, 需要通过decinfo.isa.is_operand_size_16
成员变量来决定.
这其实实现了"操作数宽度前缀"的相关功能, 更多说明请参考这个例子.
这个从译码查找表中取得的元素将会被作为参数, 调用idex()
函数(在nemu/include/cpu/exec.h
中定义).
顾名思义, 这个函数就是用来进行译码和执行的.
刚才我们只是知道了指令的具体操作, 但我们还是不知道操作对象(例如具体要对哪两个寄存器进行加法操作呢).
为了解决这个问题, 框架代码需要进行进一步的译码工作.
具体地, idex()
将会根据从译码查找表中取得的内容, 进一步调用相应的译码辅助函数
(decode helper function).
译码辅助函数统一通过宏make_DHelper
(在nemu/include/cpu/decode.h
中定义)来定义:
#define make_DHelper(name) void concat(decode_, name) (vaddr_t *pc)
每个译码辅助函数负责进行一种类型的操作数译码,
把指令中的操作数信息分别记录在全局译码信息decinfo
的src
成员,
src2
成员和dest
成员中, 它们分别代表两个源操作数和一个目的操作数.
nemu/include/cpu/decode.h
中定义了三个宏id_src
, id_src2
和id_dest
, 用于方便地访问它们.
译码辅助函数的命名很直观, 直接反映了操作数的类型和数据流向.
以nemu/src/isa/x86/decode/decode.c
为例,
其中的译码辅助函数主要以i386手册附录A中的操作数表示记号来命名.
例如I2r
表示将立即数移入寄存器, 其中I
表示立即数, 2
表示英文to
, r
表示通用寄存器,
更多的记号请参考i386手册.
我们会发现, 类似寄存器和立即数这些操作数, 其实是非常常见的操作数类型.
为了进一步实现操作数译码和指令译码的解耦, 框架代码对这些操作数的译码进行了抽象封装,
指令译码过程由若干操作数译码辅助函数
(operand decode helper function)组成.
操作数译码辅助函数统一通过宏make_DopHelper
来定义
// nemu/src/isa/x86/decode/decode.c
#define make_DopHelper(name) void concat(decode_op_, name) (vaddr_t *pc, Operand *op, bool load_val)
注意不同ISA的make_DopHelper
定义会有不同.
以x86为例, 操作数译码辅助函数会把操作数的信息记录在结构体op
中,
如果操作数在指令中, 就会通过instr_fetch()
将它们从pc
所指向的内存位置取出.
为了使操作数译码辅助函数更易于复用, 函数中将带有一个load_val
参数,
用于控制是否需要将该操作数读出到全局译码信息decinfo
供后续使用.
例如如果一个内存操作数是源操作数, 就需要将这个操作数从内存中读出来供后续执行阶段来使用;
如果它仅仅是一个目的操作数, 就不需要从内存读出它的值了,
因为执行这条指令并不需要这个值, 而是将新数据写入相应的内存位置.
x86的变长指令
由于CISC指令变长的特性, x86指令长度和指令形式需要一边取指一边译码来确定,
而不像RISC指令集那样可以泾渭分明地处理取指和译码阶段,
因此你会在x86的操作数译码辅助函数中看到instr_fetch()
的操作.
立即数背后的故事
在x86的操作数译码辅助函数中, 框架代码通过instr_fetch()
函数获得指令中的立即数.
别看这里就这么一行代码, 其实背后隐藏着针对字节序的慎重考虑.
我们知道x86是小端机, 当你使用高级语言或者汇编语言写了一个32位常数0x1234
的时候,
在生成的二进制代码中, 这个常数对应的字节序列如下(假设这个常数在内存中的起始地址是x):
x x+1 x+2 x+3
+----+----+----+----+
| 34 | 12 | 00 | 00 |
+----+----+----+----+
而大多数PC机都是小端架构(我们相信没有同学会使用IBM大型机来做PA), 当NEMU运行的时候,
op_src->imm = instr_fetch(pc, 4);
这行代码会将34 12 00 00
这个字节序列原封不动地从内存读入imm
变量中,
主机的CPU会按照小端方式来解释这一字节序列, 于是会得到0x1234
, 符合我们的预期结果.
Motorola 68k系列的处理器都是大端架构的. 现在问题来了, 考虑以下两种情况:
- 假设我们需要将NEMU运行在Motorola 68k的机器上(把NEMU的源代码编译成Motorola 68k的机器码)
- 假设我们需要把Motorola 68k作为一个新的ISA加入到NEMU中
在这两种情况下, 你需要注意些什么问题? 为什么会产生这些问题? 怎么解决它们?
事实上不仅仅是立即数的访问, 长度大于1字节的内存访问都需要考虑类似的问题. 我们在这里把问题统一抛出来, 以后就不再单独讨论了.
立即数背后的故事(2)
mips32和riscv32的指令长度只有32位, 因此它们不能像x86那样, 把C代码中的32位常数直接编码到一条指令中. 思考一下, mips32和riscv32应该如何解决这个问题?
执行(execute, EX)
idex()
函数中的译码过程结束之后,
会调用译码查找表中的相应的执行辅助函数(execution helper function)来进行真正的执行操作.
执行辅助函数统一通过宏make_EHelper
(在nemu/include/cpu/exec.h
中定义)来定义:
#define make_EHelper(name) void concat(exec_, name) (vaddr_t *pc)
它们的名字是指令操作本身. 执行辅助函数通过RTL指令来描述指令真正的执行功能(RTL指令将在下文介绍).
特别地, 对于x86来说, 大部分计算指令都可以访问内存,
于是框架代码提供了operand_write()
函数(在nemu/src/isa/x86/decode/decode.c
中定义),
来根据目的操作数类型的不同, 决定是写入寄存器还是写入内存;
而对于mips32和riscv32, 访问内存只能通过特定的访存指令进行,
因此每条指令的目的操作数类型都是唯一的.
更新PC
为了更新PC, 我们需要确定刚刚执行完的指令的长度.
事实上, 在instr_fetch()
中, 每次取指都会更新它的pc
参数,
而这个参数就是在exec_once()
调用isa_exec()
时传入的decinfo.seq_pc
.
在isa_exec()
执行的过程中, decinfo.seq_pc
将会随着取出的指令长度而增长.
因此当代码从isa_exec()
返回时, decinfo.seq_pc
将会指向下一条静态指令的地址,
此时通过update_pc()
(在nemu/include/cpu/exec.h
中定义)对PC进行更新即可.
静态指令和动态指令
在程序分析领域中, 静态指令是指程序代码中的指令, 动态指令是指程序运行过程中的指令. 例如对于以下指令序列
100: jmp 102
101: add
102: xor
jmp
指令的下一条静态指令是add
指令, 而下一条动态指令则是xor
指令.
上文已经把一条指令在NEMU中执行的流程进行了大概的介绍, 但还有少量的细节没有完全覆盖(例如指令组的译码表), 这些细节就交给你来去尝试理解啦. 不过为了特别照顾选择x86的同学, 我们还是准备了一个例子来RTFSC.
驾驭项目, 而不是被项目驾驭
你和一个项目的关系会经历4个阶段:
- 被驾驭: 你对它一无所知
- 一知半解: 你对其中的主要模块和功能有了基本的了解
- 驾轻就熟: 你对整个项目的细节都了如指掌
- 为你所用: 你可以随心所欲地在项目中添加你认为有用的功能
在PA中, 达到第二个阶段的主要手段是阅读讲义和代码, 达到第三个阶段的主要手段是独立完成实验内容和独立调试. 至于要达到第四个阶段, 就要靠你的主观能动性了: 代码还有哪里做得不够好? 怎么样才算是够好? 应该怎么做才能达到这个目标?
你毕业后到了工业界或学术界, 就会发现真实的项目也都是这样:
- 刚接触一个新项目, 不知道如何下手
- RTFM, RTFSC, 大致明白项目组织结构和基本的工作流程
- 运行项目的时候发现有非预期行为(可能是配置错误或环境错误, 可能是和已有项目对接出错, 也可能是项目自身的bug), 然后调试. 在调试过程中, 对这些模块的理解会逐渐变得清晰.
- 哪天需要你在项目中添加一个新功能, 你会发现自己其实可以胜任.
这说明了: 如果你一遇到bug就找大神帮你调试, 你失去的机会和能力会比你想象的多得多.
拦截客户程序访存越界的非法行为
你将来很可能会遇到客户程序访存越界的错误, NEMU的框架代码一旦检测到这一行为就会直接panic. 这一行为的检测已经极大地帮助你发现代码的问题了, 想象一下, 如果NEMU并未拦截这一error, 你可能会看到怎么样的failure?
不过, 你也许很快就会发现, 你想要更多的信息来帮助你调试客户程序访存越界的错误. 你打算怎么做呢?
结构化程序设计
对于x86, 细心的你会发现以下规律:
- 对于同一条指令的不同形式, 它们的执行阶段是相同的.
例如
add_I2E
和add_E2G
等, 它们的执行阶段都是把两个操作数相加, 把结果存入目的操作数. - 对于不同指令的同一种形式, 它们的译码阶段是相同的.
例如
add_I2E
和sub_I2E
等, 它们的译码阶段都是识别出一个立即数和一个E
操作数. - 对于同一条指令同一种形式的不同操作数宽度, 它们的译码阶段和执行阶段都是非常类似的.
例如
add_I2E_b
,add_I2E_w
和add_I2E_l
, 它们都是识别出一个立即数和一个E
操作数, 然后把相加的结果存入E
操作数.
这意味着, 如果独立实现每条指令不同形式不同操作数宽度的辅助函数, 将会引入大量重复的代码. 需要修改的时候, 相关的所有辅助函数都要分别修改, 遗漏了某一处就会造成bug, 工程维护的难度急速上升.
Copy-Paste - 一种糟糕的编程习惯
事实上, 第一版PA发布的时候, 框架代码就恰恰是引导大家独立实现每一个辅助函数.
大家在实现指令的时候, 都是把已有的代码复制好几份, 然后进行一些微小的改动(例如把<<
改成>>
).
当你发现这些代码有bug的时候, 噩梦才刚刚开始.
也许花了好几天你又调出一个bug的时候, 才会想起这个bug你好像之前在哪里调过.
你也知道代码里面还有类似的bug, 但你已经分辨不出哪些代码是什么时候从哪个地方复制过来的了.
由于当年的框架代码没有足够重视编程风格, 导致学生深深地陷入调试的泥淖中, 这也算是PA的一段黑历史了.
这种糟糕的编程习惯叫Copy-Paste, 经过上面的分析, 相信你也已经领略到它的可怕了. 事实上, 周源源教授的团队在2004年就设计了一款工具CP-Miner, 来自动检测操作系统代码中由于Copy-Paste造成的bug. 这个工具还让周源源教授收获了一篇系统方向顶级会议OSDI的论文, 这也是她当时所在学校UIUC史上的第一篇系统方向的顶级会议论文.
不过, 之后周源源教授发现, 相比于操作系统, 应用程序的源代码中Copy-Paste的现象更加普遍. 于是她们团队把CP-Miner的技术应用到应用程序的源代码中, 并创办了PatternInsight公司. 很多IT公司纷纷购买PatternInsight的产品, 并要求提供相应的定制服务, 甚至PatternInsight公司最后还被VMWare收购了.
这个故事折射出, 大公司中程序员的编程习惯也许不比你好多少, 他们也会写出Copy-Paste这种难以维护的代码. 但反过来说, 重视编码风格这些企业看中的能力, 你从现在就可以开始培养.
一种好的做法是把译码, 执行和操作数宽度的相关代码分离开来,
实现解耦, 也就是在程序设计课上提到的结构化程序设计.
在框架代码中, 实现译码和执行之间的解耦的是idex()
函数,
它依次调用opcode_table
表项中的译码和执行的辅助函数,
这样我们就可以分别编写译码和执行的辅助函数了.
实现操作数宽度和译码, 执行这两者之间的解耦的是id_src
, id_src2
和id_dest
中的width
成员,
它们记录了操作数宽度, 译码和执行的过程中会根据它们进行不同的操作,
通过同一份译码辅助函数和执行辅助函数实现不同操作数宽度的功能.
指令执行的共性部分
事实上, mips32和riscv32也存在类似的规律. 尝试寻找这些规律, 并阅读NEMU中执行指令相关的代码, 梳理其中ISA无关和ISA相关的内容, 然后尝试理解为什么框架代码要这样设计.
为了易于使用, 框架代码中使用了一些宏, 我们在这里把相关的宏整理出来, 供大家参考.
宏 | 含义 |
---|---|
nemu/include/macro.h |
|
str(x) |
字符串"x" |
concat(x, y) |
tokenxy |
nemu/include/cpu/decode.h |
|
id_src |
全局变量decinfo 中源操作数成员的地址 |
id_src2 |
全局变量decinfo 中2号源操作数成员的地址 |
id_dest |
全局变量decinfo 中目的操作数成员的地址 |
make_DHelper(name) |
名为decode_name 的译码辅助函数的原型说明 |
nemu/src/isa/$ISA/decode.c |
|
make_DopHelper(name) |
名为decode_op_name 的操作数译码辅助函数的原型说明 |
nemu/include/cpu/exec.h |
|
make_EHelper(name) |
名为exec_name 的执行辅助函数的原型说明 |
print_asm(...) |
将反汇编结果的字符串打印到缓冲区log_asmbuf 中 |
suffix_char(width) |
操作数宽度width 对应的后缀字符(仅x86使用) |
print_asm_template[1|2|3](instr) |
打印单/双/三目操作数指令instr 的反汇编结果 |
用RTL表示指令行为
我们知道, x86指令作为一种CISC指令集, 不少指令的行为都比较复杂. 但我们会发现, i386手册会用一些更简单的操作来表示指令的具体行为. 这说明, 复杂的x86指令还是能继续分解成一些更简单的操作的组合. mips32和riscv32的指令也能够进一步进行分解. 如果我们先实现这些简单操作, 然后再用它们来实现指令, 不就可以进一步提高代码的复用率了吗?
在NEMU中, 我们使用RTL(寄存器传输语言)来描述这些简单的操作.
下面我们对NEMU中使用的RTL进行一些说明, 首先是RTL寄存器的定义.
在NEMU中, RTL寄存器统一使用rtlreg_t
来定义,
而rtlreg_t
(在nemu/include/common.h
中定义)其实只是一个uint32_t
类型:
typedef uint32_t rtlreg_t;
在NEMU中, RTL寄存器只有以下这些
- 不同ISA的通用寄存器(在
nemu/src/isa/$ISA/include/isa/reg.h
中定义) id_src
,id_src2
和id_dest
中的访存地址addr
和操作数内容val
(在nemu/include/cpu/decode.h
中定义). 从概念上看, 它们分别与MAR和MDR有异曲同工之妙- 临时寄存器
s0, s1, t0, t1
和ir
(在nemu/src/cpu/cpu.c
中定义)
有了RTL寄存器, 我们就可以定义RTL指令对它们进行的操作了. 在NEMU中, RTL指令有两种.
一种是RTL基本指令(在nemu/include/rtl/rtl.h
中定义),
它们的特点是不需要使用临时寄存器, 可以看做是CPU执行过程中最基本的操作.
不同的ISA都可以使用RTL基本指令, 因此它们属于ISA无关的代码.
RTL基本指令包括(我们使用了一些简单的正则表达式记号):
- 立即数读入
rtl_li
- 寄存器传输
rtl_mv
- 32位寄存器-寄存器类型的算术/逻辑运算,
包括
rtl_(add|sub|and|or|xor|shl|shr|sar|i?mul_[lo|hi]|i?div_[q|r])
, 这些运算的定义用到了nemu/include/rtl/c_op.h
中的C语言运算 - 被除数为64位的除法运算
rtl_i?div64_[q|r]
- guest内存访问
rtl_lm
和rtl_sm
- host内存访问
rtl_host_lm
和rtl_host_sm
- 关系运算
rtl_setrelop
, 具体可参考nemu/src/cpu/relop.c
- 跳转, 包括直接跳转
rtl_j
, 间接跳转rtl_jr
和条件跳转rtl_jrelop
- 终止程序
rtl_exit
(在nemu/src/monitor/cpu-exec.c
中定义)
上述RTL基本指令在nemu/include/rtl/rtl.h
中定义时, 添加了interpret_
前缀, 这是为了给PA5作准备.
在nemu/include/rtl/rtl-wrapper.h
的作用下, 其它代码中使用到这些RTL基本指令时会自动添加interpret_
前缀.
因此你在代码中使用它们的时候, 只需要编写rtl_xxx
即可.
神秘的host内存访问 (建议二周目思考)
为什么需要有host内存访问的RTL指令呢?
第二种RTL指令是RTL伪指令, 它们是通过RTL基本指令或者已经实现的RTL伪指令来实现的. RTL伪指令又分两类, 包括:
- ISA无关的RTL伪指令(在
nemu/include/rtl/rtl.h
中定义)- 32位寄存器-立即数类型的算术/逻辑运算,
包括
rtl_(add|sub|and|or|xor|shl|shr|sar|i?mul_[lo|hi]|i?div_[q|r])_i
- 其它常用功能, 如按位取反
rtl_not
, 符号扩展rtl_sext
等
- 32位寄存器-立即数类型的算术/逻辑运算,
包括
- ISA相关的RTL伪指令(在
nemu/src/isa/$ISA/include/isa/rtl.h
中定义)- 通用寄存器访问
rtl_lr
和rtl_sr
- ISA相关性较强的功能(如x86的溢出和进/借位判断, EFLAGS标志位访问等)
- 通用寄存器访问
其中大部分RTL伪指令还没有实现, 必要的时候你需要实现它们. 有了这些RTL指令之后, 我们就可以方便地通过若干条RTL指令来实现每一条指令的行为了.
小型调用约定
我们定义RTL基本指令的时候, 约定了RTL基本指令不需要使用RTL临时寄存器. 但某些RTL伪指令需要使用临时寄存器存放中间结果, 才能实现其完整功能. 这样可能会带来寄存器覆盖的问题, 例如如下RTL指令序列:
(1) rtl_mv(&t0, &t1);
(2) rtl_sext(&t1, &s0, 1); // use t0 temporarily
(3) rtl_add(&s0, &t0, &t1);
如果实现(2)
的时候恰好使用到了t0
作为临时寄存器, 在(3)
中使用的t0
就不再是(1)
的结果了,
从而产生非预期的结果.
为了尽可能避免上述问题, 我们约定以下两条规则:
- 实现RTL伪指令的时候, 尽可能不使用
dest
之外的寄存器存放中间结果. 由于dest
最后会被写入新值, 其旧值肯定要被覆盖, 自然也可以安全地作为RTL伪指令的临时寄存器. - 实在需要使用临时寄存器的时候, 按照以下约定来使用:
ir
(immediate register) - 只能作为rtl_li
的目的RTL寄存器t0
,t1
- 只能在RTL伪指令的实现过程中存放中间结果s0
,s1
- 只能在译码辅助函数和执行辅助函数的实现过程中存放中间结果
仔细体会上述约定, 你也许会发现, 这和课上学习的调用约定(calling convention)有那么一点点相似之处. 如果把RTL指令看成一个函数调用, 我们刚才其实在讨论, 在这个"函数"里面究竟可以使用哪些RTL寄存器. 在调用约定中, 有些寄存器对被调用函数来说, 使用它们之前是需要先保存的. 但我们的RTL编程模型中并没有"栈"的概念, 所以在RTL中我们就不设置所谓的"被调用者保存寄存器"了. 从某种程度上来说, 这样的"小型调用约定"很难支撑大规模RTL指令的编写. 不过幸好, 在用RTL来实现指令的时候, 这一"小型调用约定"已经足够使用了.
计算机系统中的约定与未定义行为
上述例子其实折射出计算机系统工作的一种基本原则: 遵守约定.
我们定义了RTL寄存器和相应的RTL指令, 基于这些定义, 原则上可以编写出任意的RTL指令序列,
这些RTL序列最终也会按照它们原本的语义来执行.
但光靠这些定义, 我们无法避免上述RTL寄存器相互覆盖造成错误的问题.
所以我们又提出一些新的约定, 来避免这个问题.
当然, 你也可以自己提出一套新的约定(比如用t1
替代上述ir
的作用).
违反约定会发生什么呢? 最常见的就是程序无法得到正确的结果.
比如当两套约定不兼容的RTL代码放在一起的时候, 它们都分别违反了对方的约定
(你的RTL覆盖了我的ir
, 我的RTL覆盖了你的t1
).
当然也有可能恰好没有覆盖各自约定使用的寄存器, 撞大运地得到正确的运行结果.
总之, 违反约定的具体行为会怎么样, 还需要具体问题具体分析, 很难明确地说清楚.
既然说不清楚, 那就干脆不说吧, 于是有了未定义行为(UB, Underfined Behavior)的概念: 只要遵守约定, 就能保证程序具有遵守约定后的特性; 如果违反, 不按照说好的来, 那就不保证行为是正确的.
计算机系统就是这样工作的: 计算机系统抽象层之间的接口其实也是一种约定, 比如指令就是软件和硬件的一种接口, 所以有了ISA手册来规范每一条指令的行为.
- 一方面, 编译器需要根据ISA手册中的约定来生成可以正确执行的代码. 如果编译器不按照手册约定来生成代码, 那么编译出的程序的行为就是未定义的.
- 另一方面, 硬件开发者也需要根据ISA手册中的约定来设计可以正确执行指令的处理器. 如果处理器不按照手册约定来执行指令, 处理器运行程序的行为就是未定义的.
引入未定义行为还有一个好处是, 给约定的实现方式带来一定的自由度. 例如, C语言标准规定, 整数除法的除数为0时, 结果是未定义的. x86的除法指令在检测到除数为0时, 就会向CPU抛出一个异常信号. 而MIPS的除法指令则更简单暴力: 首先在MIPS指令集手册中声明, 除数为0时, 结果未定义; 然后在硬件上实现除法器电路的时候, 对除0操作就可以视而不见了. 然而给定一个除法器电路, 就算除数为0, 电路的输出也总会有一个值, 至于具体的值是什么, 就看造化了. 反正C语言标准规定除0的行为本身就是未定义的, 让除法指令随便返回一个值, 也不算违反C语言标准的约定.
未定义行为其实离你很近. 比如野指针的解引用, 会发生什么完全无法预料.
还有你经常使用的memcpy()
, 如果源区间和目的区间有重叠时, 它的行为会怎么样?
如果你从来没有思考过这个问题, 你应该去man
一下, 然后思考一下为什么会这样.
还有一种有人欢喜有人愁的现象是基于未定义行为的编译优化:
既然源代码的行为是未定义的, 编译器基于此进行各种奇葩优化当然也不算违反约定.
这篇文章列举了一些让你大开眼界的花式编译优化例子,
看完之后你就会刷新对程序行为的理解了.
所以这就是为什么我们强调要学会RTFM.
RTFM是了解接口行为和约定的过程: 每个输入的含义是什么? 查阅对象的具体行为是什么?
输出什么? 有哪些约束条件必须遵守? 哪些情况下会报什么错误? 哪些行为是UB?
只有完全理解并遵守它们, 才能正确无误地使用查阅的对象,
大至系统设计原则, 小到一个memcpy()
的行为, 都蕴含着约定与遵守的法则.
理解这些法则, 也是理解计算机系统的不二途径.
UB, 编译优化和datalab
听闻大班的lab1(datalab)因为使用了debian10的新版gcc而翻车. 后来了解到, 是因为datalab的参考代码中含有int整数溢出的UB, debian 10的gcc利用了该UB进行编译优化, 导致参考代码生成了错误的参考答案.
C语言标准规定, int整数溢出的行为是未定义的, 但大部分程序员并不知道这一约定, 甚至连市面上流行的C语言教科书都认为int整数溢出的结果是wrap around. datalab是CMU设计的实验, 但原作者也会编写出含有UB的代码, 说明原作者对UB的理解也并未到位. 在旧版本的编译器中, 这些UB均未被触发. 但UB毕竟是UB, 只能说明作者写代码的时候没有充分理解C语言标准.
这篇论文对整数溢出的分类和行为进行了梳理, 并且在实际应用中找出了大量整数溢出的例子进行分析, 推荐大家阅读. 论文中提到有一个被广泛应用(包括Office和Windows)的函数库SafeInt用于避免整数溢出, 但这个函数库自身的代码就被论文作者检测出整数溢出导致的UB.
这些例子给我们的启示是: 我们不仅需要编写通过测试的代码, 而且需要编写符合语言规范的well-defined的代码. 退一步讲, 人毕竟会犯错误, 但我们至少要在出错的时候知道, 什么才是对的.
RTL寄存器中值的生存期
在程序设计课上, 我们知道C语言中不同的变量有不同的生存期: 有的变量的值会一直持续到程序结束, 但有的变量却很快消亡. 在上述定义的RTL寄存器中, 其实也有不同的生存期. 尝试根据生存期给RTL寄存器分类.
尽管目前这个分类结果并没有什么用处, 但其实将来在PA5中设计RTL优化方案的时候, 生存期的性质会给我们提供很大的优化机会.
实现新指令
对译码, 执行和操作数宽度的解耦实现以及RTL的引入, 对在NEMU中实现客户指令提供了很大的便利, 为了实现一条新指令, 你只需要
- 在
opcode_table
中填写正确的译码辅助函数, 执行辅助函数以及操作数宽度 - 用RTL实现正确的执行辅助函数, 需要注意使用RTL伪指令时要遵守上文提到的小型调用约定
感觉RTL临时寄存器不够用了?
其实是存在够用的实现方式的, 你可以尝试挑战一下这个目标.
当然, 如果实在坚持不了的话, 你也可以定义更多的RTL临时寄存器, 如t2
, t3
, s2
...
事实上, 如果定义了过多的RTL临时寄存器, 可能会对PA5的性能优化带来一些负面影响. 不过PA5并不记入成绩, 所以你也不用为此感到担忧.
框架代码定义了一些译码辅助函数和执行辅助函数, 你可以很方便地使用它们.
偏心的框架代码
这个"一些"对不同的ISA来说, 还是有很大差别的: 对x86来说其实是"绝大部分", 而对mips32和riscv32来说其实是"少量".
你应该能感受到, 这节讲义内容为x86提供了很多帮助,
而对mips32和riscv32的提示却很少, 甚至提供的框架代码也非常寒酸.
你也许会觉得这是yzh偏心, 但其实这恰恰反衬出x86的复杂(看看不同ISA的opcode_table
就知道了).
考虑到公平性, 选择mips32和riscv32的同学则需要独立编写大部分的译码辅助函数和执行辅助函数.
我们希望无论选择你何种ISA, 为了获取相同的分数, 你都需要付出一定的努力.
如果你对公平性有更好的建议, 请联系yzh.
你可以与选择不同ISA的同学交流, 我们也鼓励你在二周目的时候选择不同的ISA进行新的攻略, 这样你也许就能对不同ISA之间的区别有更深刻的体会了.
如果你读过上文的扩展阅读材料中关于RISC与CISC融为一体的故事, 你也许会记得CISC风格的x86指令最终被分解成RISC风格的微指令在计算机中运行, 才让x86在这场扩日持久的性能大战中得以存活下来的故事. NEMU在经历了第二次重构之后, 也终于引入了RISC风格的RTL来实现指令, 这也许是冥冥之中的安排吧.
必答题(需要在实验报告中回答) - 理解指令执行的过程
这一小节的细节非常多, 你可能需要多次阅读讲义和代码才能理解每一处细节. 根据往届学长学姐的反馈, 一种有效的理解方法是通过做笔记的方式来整理这些细节. 为了避免你长时间对代码的理解没有任何进展, 我们就增加一道必答题吧:
请整理一条指令在NEMU中的执行过程.
运行第一个C程序
说了这么多, 现在到了动手实践的时候了.
你在PA2的第一个任务, 就是实现若干条指令, 使得第一个简单的C程序可以在NEMU中运行起来.
这个简单的C程序是nexus-am/tests/cputest/tests/dummy.c
, 它什么都不做就直接返回了.
准备交叉编译环境
如果你选择的ISA不是x86, 你还需要准备相应的gcc和binutils, 才能正确地进行编译.
- mips32
apt-get install g++-mips-linux-gnu binutils-mips-linux-gnu
- riscv32
apt-get install g++-riscv64-linux-gnu binutils-riscv64-linux-gnu
- 使用sudo权限修改以下文件:
--- /usr/riscv64-linux-gnu/include/bits/wordsize.h
+++ /usr/riscv64-linux-gnu/include/bits/wordsize.h
@@ -25,5 +25,5 @@
#if __riscv_xlen == 64
# define __WORDSIZE_TIME64_COMPAT32 1
#else
-# error "rv32i-based targets are not supported"
+# define __WORDSIZE_TIME64_COMPAT32 0
#endif
在nexus-am/tests/cputest/
目录下键入
make ARCH=$ISA-nemu ALL=dummy run
编译dummy
程序, 并启动NEMU运行它.
事实上, 并不是每一个程序都可以在NEMU中运行,
nexus-am
子项目专门用于编译出能在NEMU中运行的程序, 我们在下一小节中会再来介绍它.
在NEMU中运行dummy
程序, 你会发现NEMU输出以下信息(以x86为例):
invalid opcode(pc = 0x0010000a): e8 05 00 00 00 90 31 c0 ...
There are two cases which will trigger this unexpected exception:
1. The instruction at pc = 0x0010000a is not implemented.
2. Something is implemented incorrectly.
Find this pc 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中添加指令了.
为什么执行了未实现指令会出现上述报错信息
RTFSC, 理解执行未实现指令的时候, NEMU具体会怎么做.
我选择x86, 但出错信息中显示的字节序列有所不同
这很可能是你选择了其它Linux发行版, 由于gcc版本的差异导致的. 此时按照你的gcc编译结果来实现指令即可.
要实现哪些指令才能让dummy
在NEMU中运行起来呢?
答案就在其反汇编结果(nexus-am/tests/cputest/build/dummy-$ISA-nemu.txt
)中.
以x86为例, 查看反汇编结果, 你发现只需要添加call
, push
, sub
, xor
, ret
五条指令就可以了.
每一条指令还有不同的形式, 根据KISS法则, 你可以先实现只在dummy
中出现的指令形式,
通过指令的opcode
可以确定具体的形式.
交叉编译工具链
如果你选择的ISA不是x86, 在查看客户程序的二进制信息(如objdump
, readelf
等)时,
需要使用相应的交叉编译版本, 如mips-linux-gnu-objdmup
, riscv-none-embed-readelf
等.
这里要再次强调, 你务必通过RTFM来查阅指令的功能, 不能想当然. 手册中给出了指令功能的完整描述(包括做什么事, 怎么做的, 有什么影响), 一定要仔细阅读其中的每一个单词, 对指令功能理解错误和遗漏都会给以后的调试带来巨大的麻烦.
再提供一些x86的提示吧
call
:call
指令有很多形式, 不过在PA中只会用到其中的几种, 现在只需要实现CALL rel32
的形式就可以了. 至于跳转地址, 框架代码里面已经有不少提示了, 也就算作是RTFSC的一个练习吧.push
: 现在只需要实现PUSH r32
和PUSH imm32
的形式就可以了, 它们可以很容易地通过rtl_push
来实现sub
: 在实现sub
指令之前, 你首先需实现EFLAGS寄存器. 你只需要在寄存器结构体中添加EFLAGS寄存器即可. EFLAGS是一个32位寄存器, 但在NEMU中, 我们只会用到EFLAGS中以下的5个位:CF
,ZF
,SF
,IF
,OF
, 它们的功能可暂不实现. 关于EFLAGS中每一位的含义, 请查阅i386手册. 实现了EFLAGS寄存器之后, 再实现相关的RTL指令, 之后你就可以通过这些RTL指令来实现sub
指令了xor
,ret
: RTFM吧
运行第一个客户程序
在NEMU中通过RTL指令实现上文提到的指令, 具体细节请务必参考手册.
实现成功后, 在NEMU中运行客户程序dummy
, 你将会看到HIT GOOD TRAP
的信息.
如果你没有看到这一信息, 说明你的指令实现不正确,
你可以使用PA1中实现的简易调试器帮助你调试.
温馨提示
PA2阶段1到此结束.