RTFSC(2)

上一小节中的内容全部出自i386手册, 现在我们结合框架代码来理解上面的内容.

在PA1中, 你已经阅读了monitor部分的框架代码, 了解了NEMU执行的粗略框架. 但现在, 你需要进一步弄明白, 一条指令是怎么在NEMU中执行的, 即我们需要进一步探究 exec() 函数中的细节. 为了说明这个过程, 我们举了两个 mov 指令的例子, 它们是框架代码自带的用户程序mov( testcase/src/mov.S )中的两条指令(mov的反汇编结果在 obj/testcase/mov.txt 中):

100014:    b9 00 80 00 00            mov    $0x8000,%ecx
......
1000fe:    66 c7 84 99 00 e0 ff    movw   $0x1,-0x2000(%ecx,%ebx,4)
100105:    ff 01 00

helper函数命名约定

对于每条指令的每一种形式, NEMU分别使用一个helper函数来模拟它的执行. 为了易于维护, 框架代码对helper函数的命名有一种通用的形式:

指令_形式_操作数后缀

例如对于helper函数 mov_i2rm_b() , 它模拟的指令是 mov , 形式是 把立即数移动到寄存器或内存 , 操作数后缀是 b , 表示操作数长度是8位. 在PA2中, 你需要实现很多helper函数, 这种命名方式可以很容易地让你知道一个helper函数的功能.

一个特殊的操作数后缀是 v , 表示variant, 意味着光看操作码的首字节, 操作数长度还不能确定, 可能是16位或者32位, 需要通过 ops_decoded.is_data_size_16 成员变量来决定. 其实这种helper函数做的事情, 就是在根据指令是否出现 operand-size prefix 来确定操作数长度, 从而决定最终的指令形式, 调用最终的helper函数来模拟指令的执行.

也有一些指令不需要区分形式和操作数后缀, 例如 int3 , 这时可以直接用指令的名称来命名其helper函数. 如果你觉得上述命名方式不易看懂, 你可以使用其它命名方式, 我们不做强制要求.

简单mov指令的执行

我们先来剖析第一条 mov $0x8000, %ecx 指令的执行过程. 当NEMU执行到这条指令的时候(eip = 0x100014), 当前 %eip 的值被作为参数送进 exec() 函数(在 nemu/src/cpu/exec/exec.c 中定义)中. 其中 make_helper 是个宏, 你需要编写一系列helper函数来模拟指令执行的过程, 而 make_helper 则定义了helper函数的声明形式:

#define make_helper(name) int name(swaddr_t eip)

make_helper 的定义可以看到, helper函数都带有一个参数 eip , 返回值类型都是 int . 从抽象的角度来说, 一个helper函数做的事情就是对参数 eip 所指向的内存单元进行某种操作, 然后返回这种操作涉及的代码长度. 例如 exec() 函数的功能是"执行参数 eip 所指向的指令, 并返回这条指令的长度"; 框架代码中还定义了一些获取指令中的立即数的helper函数, 它们的功能是"获取参数 eip 所指向的立即数, 并返回这个立即数的长度".

对于大部分指令来说, 执行它们都可以抽象成取指-译码-执行的指令周期. 为了使描述更加清晰, 我们借助指令周期中的一些概念来说明指令执行的过程.

取指(instruction fetch, IF)

要执行一条指令, 首先要拿到这条指令. 指令究竟在哪里呢? 还记得冯诺依曼体系结构的核心思想吗? 那就是"存储程序, 程序控制". 你以前听说这两句话的时候可能没有什么概念, 现在是实践的时候了. 这两句话告诉你, 指令在存储器中, 由PC(program counter, 在x86中就是 %eip )指出当前指令的位置. 事实上, %eip 就是一个指针! 在计算机世界中, 指针的概念无处不在, 如果你觉得对指针的概念还不是很熟悉, 就要赶紧复习指针这门必修课啦. 取指令要做的事情自然就是将 %eip 指向的指令从内存读入到CPU中. 在NEMU中, 有一个函数 instr_fetch() (在 nemu/include/cpu/helper.h 中定义)专门负责取指令的工作.

译码(instruction decode, ID)

在取指阶段, CPU拿到的是指令的比特串. 如果想知道这串比特串究竟代表什么意思, 就要进行译码的工作了. 我们可以把译码的工作作进一步的细化: 首先要决定具体是哪一条指令的哪一种形式, 这主要是通过查看指令的 opcode 来决定的. 对于大多数指令来说, CPU只要看指令的第一个字节就可以知道具体指令的形式了. 在NEMU中, exec() 函数首先通过 instr_fetch() 取出指令的第一个字节, 然后根据取到的这个字节查看 opcode_table , 得到指令的helper函数, 从而调用这个helper函数来继续模拟这条指令的执行. 以 mov $0x8000, %ecx 指令为例, 首先通过 instr_fetch() 取得这条指令的第一个字节 0xb9 , 然后根据这个字节来索引 opcode_table , 找到了一个名为 mov_i2r_v 的helper函数, 这样就可以确定取到的是一条 mov 指令, 它的形式是将立即数移入寄存器(move immediate to register).

事实上, 一个字节最多只能区分256种不同的指令形式, 当指令形式的数目大于256时, 我们需要使用另外的方法来识别它们. x86中有主要有两种方法来解决这个问题(在PA2中你都会遇到这两种情况):

  • 一种方法是使用转义码(escape code), x86中有一个2字节转义码 0x0f , 当指令 opcode 的第一个字节是 0x0f 时, 表示需要再读入一个字节才能决定具体的指令形式(部分条件跳转指令就属于这种情况). 后来随着各种SSE指令集的加入, 使用2字节转义码也不足以表示所有的指令形式了, x86在2字节转义码的基础上又引入了3字节转义码, 当指令 opcode 的前两个字节是 0x0f0x38 时, 表示需要再读入一个字节才能决定具体的指令形式.
  • 另一种方法是使用 ModR/M 字节中的扩展opcode域来对 opcode 的长度进行扩充. 有些时候, 读入一个字节也还不能完全确定具体的指令形式, 这时候需要读入紧跟在 opcode 后面的 ModR/M 字节, 把其中的 reg/opcode 域当做 opcode 的一部分来解释, 才能决定具体的指令形式. x86把这些指令划分成不同的指令组(instruction group), 在同一个指令组中的指令需要通过 ModR/M 字节中的扩展opcode域来区分.

决定了具体的指令形式之后, 译码工作还需要决定指令的操作数. 事实上, 在确定了指令的 opcode 之后, 指令形式就能确定下来了, CPU可以根据指令形式来确定具体的操作数. 我们还是以 mov $0x8000, %ecx 来说明这个过程, 但在这之前, 我们需要作一些额外的说明. 在上文的描述中, 我们通过这条指令的第一个字节 0xb9 找到了 mov_i2r_v() 的helper函数, 这个helper函数的定义在 nemu/src/cpu/exec/data-mov/mov.c 中:

make_helper_v(mov_i2r)

其中 make_helper_v() 是个宏, 它在 nemu/include/cpu/exec/helper.h 中定义:

#define make_helper_v(name) \ make_helper(concat(name, _v)) { \ return (ops_decoded.is_data_size_16 ? concat(name, _w) : concat(name, _l)) (eip); \ }

进行宏展开之后, mov_i2r_v() 的函数体如下:

int mov_i2r_v(swaddr_t eip) { return (ops_decoded.is_data_size_16 ? mov_i2r_w : mov_i2r_l) (eip); \ }

它的作用是根据全局变量 ops_decoded (在 nemu/src/cpu/decode/decode.c 中定义)中的 is_data_size_16 成员变量来决定操作数的长度, 然后从复用 opcode 的两个helper函数中选择一个进行调用. 全局变量 ops_decoded 用于存放一些译码的结果, 其中的 is_data_size_16 成员和指令中的 operand-size prefix 有关, 而且会经常用到, 框架代码把类似于 mov_i2r_v() 这样的功能抽象成一个宏 make_helper_v() , 方便代码的编写. 关于 is_data_size_16 成员的更多内容会在下文进行说明. 根据指令 mov $0x8000, %ecx 的功能, 它的操作数长度为4字节, 因此这里会调用 mov_i2r_l() 的helper函数. mov_i2r_l() 的helper函数在 nemu/src/cpu/exec/data-mov/mov-template.h 中定义, 它的函数体是通过宏展开得到的, 在这里我们直接给出宏展开的结果, 关于宏的使用请阅读相应的框架代码:

int mov_i2r_l(swaddr_t eip) { return idex(eip, decode_i2r_l, do_mov_l); }

其中 idex() 函数的原型为

int idex(swaddr_t eip, int (*decode)(swaddr_t), void (*execute) (void));

它的作用是通过 decode 函数对参数 eip 指向的指令进行译码, 然后通过 execute 函数执行这条指令.

对于 mov $0x8000, %ecx 指令来说, 确定操作数其实就是确定寄存器 %ecx 和立即数 $0x8000 . 在x86中, 通用寄存器都有自己的编号, mov_i2r 形式的指令把寄存器编号也放在指令的第一个字节里面, 我们可以通过位运算将寄存器编号抽取出来. 对于 mov_i2r 形式的指令来说, 立即数存放在指令的第二个字节, 可以很容易得到它. 然而很多指令都具有i2r的形式, 框架代码提供了几个函数( decode_i2r_l() 等), 专门用于进行对i2r形式的指令的译码工作. decode_i2r_l() 函数会把指令中的立即数信息和寄存器信息分别记录在全局变量 ops_decoded 中的 src 成员和 dest 成员中, nemu/include/cpu/helper.h 中定义了两个宏 op_srcop_dest , 用于方便地访问这两个成员.

立即数背后的故事

decode_i_l() 函数中通过 instr_fetch() 函数获得指令中的立即数, 别看这里就这么一行代码, 其实背后隐藏着针对字节序的慎重考虑. 我们知道x86是小端机, 当你使用高级语言或者汇编语言写了一个32位常数 0x8000 的时候, 在生成的二进制代码中, 这个常数对应的字节序列如下(假设这个常数在内存中的起始地址是x):

x   x+1  x+2  x+3
+----+----+----+----+
| 00 | 80 | 00 | 00 |
+----+----+----+----+

而大多数PC机都是小端架构(我们相信没有同学会使用IBM大型机来做PA), 当NEMU运行的时候,

op_src->imm = instr_fetch(eip, 4);

这行代码会将 00 80 00 00 这个字节序列原封不动地从内存读入 imm 变量中, 主机的CPU会按照小端方式来解释这一字节序列, 于是会得到 0x8000 , 符合我们的预期结果.

Motorola 68k系列的处理器都是大端架构的, 现在问题来了, 考虑以下两种情况:

  • 假设我们需要将NEMU运行在Motorola 68k的机器上(把NEMU的源代码编译成Motorola 68k的机器码)
  • 假设我们需要编写一个新的模拟器NEMU-Motorola-68k, 模拟器本身运行在x86架构中, 但它模拟的是Motorola 68k程序的执行

在这两种情况下, 你需要注意些什么问题? 为什么会产生这些问题? 怎么解决它们?

事实上不仅仅是立即数的访问, 长度大于1字节的内存访问都需要考虑类似的问题. 我们在这里把问题统一抛出来, 以后就不再单独讨论了.

执行(execute, EX)

译码阶段的工作完成之后, CPU就知道当前指令具体要做什么了, 执行阶段就是真正完成指令的工作. 对于 mov $0x8000, %ecx 指令来说, 执行阶段的工作就是把立即数 $0x8000 送到寄存器 %ecx 中. 由于 mov 指令的功能可以统一成"把源操作数的值传送到目标操作数中", 而译码阶段已经把操作数都准备好了, 所以只需要针对 mov 指令编写一个模拟执行过程的函数即可. 这个函数就是 do_mov_l() , 它是通过在 nemu/src/cpu/exec/data-mov/mov-template.h 中定义的 do_execute() 函数进行宏展开后得到的:

static void do_mov_l() { write_operand_l((&ops_decoded.dest), (&ops_decoded.src)->val); Assert(snprintf(assembly, 80, "movl %s,%s", (&ops_decoded.src)->str, (&ops_decoded.dest)->str) < 80, "buffer overflow!"); }

其中 write_operand_l() 函数会根据第一个参数中记录的类型的不同进行相应的写操作, 包括写寄存器和写内存.

更新 %eip

执行完一条指令之后, CPU就要执行下一条指令. 在这之前, CPU需要更新 %eip 的值, 让 %eip 指向下一条指令的位置. 为此, 我们需要确定刚刚执行完的指令的长度. 在NEMU中, 指令的长度是通过helper函数的返回值进行传递的, 最终会传回到 cpu_exec() 函数中, 完成对 %eip 的更新.

复杂mov指令的执行

对于第二个例子 movw $0x1, -0x2000(%ecx,%ebx,4) , 执行这条执行还是分取指, 译码, 执行三个阶段.

首先是取指. 这条mov指令比较特殊, 它的第一个字节是 0x66 , 如果你查阅i386手册, 你会发现 0x66 是一个 operand-size prefix . 因为这个前缀的存在, 本例中的 mov 指令才能被CPU识别成 movw . NEMU使用 ops_decoded.is_data_size_16 成员变量来记录操作数长度前缀是否出现, 0x66 的helper函数 data_size() 实现了这个功能.

data_size() 函数对 ops_decoded.is_data_size_16 成员变量做了标识之后, 越过前缀重新调用 exec() 函数, 此时取得了真正的操作码 0xc7 , 通过查看 opcode_table 调用了helper函数 mov_i2rm_v() . 由于 ops_decoded.is_data_size_16 成员变量进行过标识, 在 mov_i2rm_v() 中将会调用 mov_i2rm_w() 的helper函数. 到此为止才识别出本例中的指令是一条 movw 指令.

接下来是识别操作数. 同样地, 我们先给出 mov_i2rm_w() 函数的宏展开结果:

int mov_i2rm_w(swaddr_t eip) { return idex(eip, decode_i2rm_w, do_mov_w); }

这里使用 decode_i2rm_w() 函数来进行译码的工作, 阅读代码, 你会发现它最终会调用 read_ModR_M() 函数. 由于本例中的 mov 指令需要访问内存, 因此除了要识别出立即数之外, 还需要确定好要访问的内存地址. x86通过 ModR/M 字节来指示内存操作数, 支持各种灵活的寻址方式. 其中最一般的寻址格式是

displacement(R[base_reg], R[index_reg], scale_factor)

相应内存地址的计算方式为

addr = R[base_reg] + R[index_reg] * scale_factor + displacement

其它寻址格式都可以看作这种一般格式的特例, 例如

displacement(R[base_reg])

可以认为是在一般格式中取 R[index_reg] = 0, scale_factor = 1 的情况. 这样, 确定内存地址就是要确定 base_reg , index_reg , scale_factordisplacement 这4个值, 而它们的信息已经全部编码在 ModR/M 字节里面了.

我们以本例中的 movw $0x1, -0x2000(%ecx,%ebx,4) 说明如何识别出内存地址:

1000fe:    66 c7 84 99 00 e0 ff    movw   $0x1,-0x2000(%ecx,%ebx,4)
100105:    ff 01 00

根据 mov_i2rm 的指令形式, 0xc7opcode , 0x84ModR/M 字节. 在i386手册中查阅表格17-3得知, 0x84 的编码表示在 ModR/M 字节后面还跟着一个 SIB 字节, 然后跟着一个32位的 displacement . 于是读出 SIB 字节, 发现是 0x99 . 在i386手册中查阅表格17-4得知, 0x99 的编码表示 base_reg = ECX, index_reg = EBX, scale_factor = 4 . 在 SIB 字节后面读出一个32位的 displacement , 发现是 00 e0 ff ff , 在小端存储方式下, 它被解释成 -0x2000 . 于是内存地址的计算方式为

addr = R[ECX] + R[EBX] * 4 - 0x2000

框架代码已经实现了 load_addr() 函数和 read_ModR_M() 函数(在 nemu/src/cpu/decode/modrm.c 中定义), 它们的函数原型为

int load_addr(swaddr_t eip, ModR_M *m, Operand *rm); int read_ModR_M(swaddr_t eip, Operand *rm, Operand *reg);

它们将变量 eip 所指向的内存位置解释成 ModR/M 字节, 根据上述方法对 ModR/M 字节和 SIB 字节进行译码, 把译码结果存放到参数 rmreg 指向的变量中, 同时返回这一译码过程所需的字节数. 在上面的例子中, 为了计算出内存地址, 用到了 ModR/M 字节, SIB 字节和32位的 displacement , 总共6个字节, 所以 read_ModR_M() 返回6. 虽然i386手册中的表格17-3和表格17-4内容比较多, 仔细看会发现, ModR/M 字节和 SIB 字节的编码都是有规律可循的, 所以 load_addr() 函数可以很简单地识别出计算内存地址所需要的4个要素(当然也处理了一些特殊情况). 不过你现在可以不必关心其中的细节, 框架代码已经为你封装好这些细节, 并且提供了各种用于译码的接口函数.

本例中的执行阶段就是要把立即数写入到相应的内存位置, 这是通过 do_mov_w() 函数实现的. 执行结束后返回指令的长度, 最终在 cpu_exec() 函数中更新 %eip .

结构化程序设计

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

  • 对于同一条指令的不同形式, 它们的执行阶段是相同的. 例如 add_i2rmadd_rm2r 等, 它们的执行阶段都是把两个操作数相加, 把结果存入目的操作数.
  • 对于不同指令的同一种形式, 它们的译码阶段是相同的. 例如 add_i2rmsub_i2rm 等, 它们的译码阶段都是识别出一个立即数和一个 rm 操作数.
  • 对于同一条指令同一种形式的不同长度, 它们的译码阶段和执行阶段都是非常类似的. 例如 add_i2rm_b , add_i2rm_wadd_i2rm_l , 它们都是识别出一个立即数和一个 rm 操作数, 然后把相加的结果存入 rm 操作数.

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

在框架代码中, 实现译码和执行之间的解耦的是 idex() 函数, 它把译码和执行的helper函数的指针作为参数, 依次调用它们, 这样我们就可以分别编写译码和执行的helper函数了. 实现操作数长度和译码, 执行这两者之间的解耦的是宏 DATA_BYTE , 它把不同操作数长度的共性抽象出来, 编写一份模板, 分别进行3次实例化, 就可以得到3分不同操作数长度的代码.

为了实现进一步的封装和抽象, 框架代码中使用了大量的宏, 我们在这里把相关的宏整理出来, 供大家参考.

含义
nemu/include/macro.h
str(x) 字符串 "x"
concat(x, y) token xy
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/exec/template-start.h
SUFFIX 表示 DATA_BYTE 相应长度的后缀字母, 为 b, w, l 其中之一
DATA_TYPE 表示 DATA_BYTE 相应长度的无符号数据类型, 为 uint8_t, uint16_t, uint32_t 其中之一
DATA_TYPE_S 表示 DATA_BYTE 相应长度的有符号数据类型, 为 int8_t, int16_t, int32_t 其中之一
REG(index) 编码为 index, 长度为 DATA_BYTE 的GPR
REG_NAME(index) 编码为 index, 长度为 DATA_BYTE 的GPR的名称
MEM_R(addr) 从内存位置 addr 读出 DATA_BYTE 字节的数据
MEM_W(addr) 把长度为 DATA_BYTE 字节的数据 data 写入内存位置 addr
OPERAND_W(op, src) 把结果 src 写入长度为 DATA_BYTE 字节的目的操作数 op
MSB(n) 取出长度为 DATA_BYTE 字节的数据 n 的MSB位
nemu/include/cpu/helper.h
make_helper(name) 名为 name 的helper函数的原型说明
op_src 全局变量 ops_decoded 中源操作数成员的地址
op_src2 全局变量 ops_decoded 中2号源操作数成员的地址
op_dest 全局变量 ops_decoded 中目的操作数成员的地址
nemu/include/cpu/exec/helper.h
make_helper_v(name) 名为 name_v 的helper函数的定义, 用于根据指令的操作数长度前缀进一步确定调用哪一个helper函数
do_execute 用于模拟指令真正的执行操作的函数名
make_instr_helper(type) 名为 指令_形式_操作数后缀 的helper函数的定义, 其中 type 为指令的形式, 通过调用 idex() 函数来进行执行的译码和执行
print_asm(...) 将反汇编结果的字符串打印到缓冲区 assembly
print_asm_template1() 打印单目操作数指令的反汇编结果
print_asm_template2() 打印双目操作数指令的反汇编结果
print_asm_template3() 打印三目操作数指令的反汇编结果
nemu/src/cpu/exec/*/*-template.h
instr 指令的名称, 被 do_execute, make_instr_helper(type)print_asm_template?() 使用

强大的宏

如果你知道C++的"模板"功能, 你可能会建议使用它, 但事实上在这里做不到. 我们知道宏是在编译预处理阶段进行处理的, 这意味着宏的功能不受编译阶段的约束(包括词法分析, 语法分析, 语义分析); 而C++的模板是在编译阶段进行处理的, 这说明它会受到编译阶段的限制. 理论上来说, 必定有一些事情是宏能做到, 但C++模板做不到. 一个例子就是框架代码中的拼接宏 concat() , 它可以把两个token连接成一个新的token; 而在C++模板进行处理的时候, 词法分析阶段已经结束了, 因而不可能通过C++模板生成新的token.

计算机世界处处都是tradeoff, 有好处自然需要付出代价. 由于处理宏的时候不会进行语法检查, 因为宏而造成的错误很有可能不会马上暴露. 例如以下代码:

#define N 10; int a[N];

在编译的时候, 编译器会提示代码的第2行有语法错误, 但如果你光看第2行代码, 你很难发现错误, 甚至会怀疑编译器有bug. 因此如果你对宏不太熟悉, 可能会对阅读框架代码带来困难. 我们准备了命令, 用来专门生成 nemu/src/cpu/decode 目录和 nemu/src/cpu/exec 目录下源文件的预处理结果, 键入

make cpp

会在这些目录中生成 .i 的预处理结果, 它们可以帮助你阅读框架代码, 调试与宏相关的错误. 键入

make clean-cpp

可以移除这些预处理结果.

源文件组织

最后我们来聊聊 nemu/src/cpu/exec 目录下源文件的组织方式.

nemu/src/cpu/exec
├── all-instr.h
├── arith
│   └── ...
├── data-mov
│   ├── mov.c
│   ├── mov.h
│   ├── mov-template.h
│   ├── xchg.c
│   ├── xchg.h
│   └── xchg-template.h
├── exec.c
├── logic
│   └── ...
├── misc
│   ├── misc.c
│   └── misc.h
├── prefix
│   ├── prefix.c
│   └── prefix.h
├── special
│   ├── special.c
│   └── special.h
└── string
    ├── rep.c
    └── rep.h
  • exec.c 中定义了操作码表 opcode_table 和helper函数 exec() , exec() 根据指令的 opcode 首字节查阅 opcode_table , 并调用相应的helper函数来模拟相应指令的执行. 除此之外, 和2字节转义码相关的2字节操作码表 _2byte_opcode_table , 以及各种指令组表也在 exec.c 中定义.
  • all-instr.h 中列出了所有用于模拟指令执行的helper函数的声明, 这个头文件被 exec.c 包含, 这样就可以在 exec.c 中的 opcode_table 直接使用各种helper函数了.
  • 除了 exec.call-instr.h 两个源文件之外, 目录下还有若干子目录, 这些子目录分别存放用于模拟不同功能的指令的源文件. i386手册根据功能对所有指令都进行了分类, 框架代码中对相关文件的管理参考了手册中的分类方法(其中 special 子目录下模拟了和NEMU相关的功能, 与i386手册无关). 以 nemu/src/cpu/exec/data-mov 目录下与 mov 指令相关的文件为例, 我们对其文件组织进行进一步的说明:
    • mov.h 中列出了用于模拟 mov 指令所有形式的helper函数的声明, 这个头文件被 all-instr.h 包含.
    • mov-template.hmov 指令helper函数定义的模板, mov 指令helper函数的函数体都在这个文件中定义. 模板的功能是通过宏来实现的: 对于一条指令, 不同操作数长度的相近形式都有相似的行为, 可以将它们的公共行为用宏抽象出来. mov-template.h 的开头包含了头文件 nemu/include/cpu/exec/template-start.h , 结尾包含了头文件 nemu/include/cpu/exec/template-end.h , 它们包含了一些在模板头文件中使用的宏定义, 例如 DATA_TYPE, REG() 等, 使用它们可以编写出简洁的代码.
    • mov.c 中定义了 mov 指令的所有helper函数, 其中分三次对 mov-template.h 中定义的模板进行实例化, 进行宏展开之后就可以得到helper函数的完整定义了; 另外操作数后缀为 v 的helper函数也在 mov.c 中定义.

在PA2中, 你需要编写很多helper函数, 好的源文件组织方式可以帮助你方便地管理工程.

results matching ""

    No results matching ""