本文包含NEMU实验(天津大学)中PA2必做任务1的完整实验思路,篇幅较长,因此仅记录了这一个任务的思路,后续任务慢慢更新,仅为个人完整思路分享,欢迎指正!
(注:在阅读思路前,确保阅读过NEMU PA2 实验手册学习过程分享)
必做任务1:运行用户程序mov-c
首先我们不妨先尝试运行一下mov-c.c文件,看看会发生一些什么,也顺便熟悉一下我们后怎么去配置Makefile文件来实现不同用户程序的测试。我们只需要找到项目总目录下的Makefile文件,打开后修改USERPROG对应的代码即可(原先为obj/testcase/mov):
##### some convinient rules #####
USERPROG := obj/testcase/mov-c
ENTRY := $(USERPROG)
然后就可以正常回到项目总目录中make clean -> make -> make run,此时我们运行的程序就是mov-c程序了,我们c运行后,就会发现一下的提示:
仔细分析就可以知道,错误出在了指令没有完善,导致程序无法运行,那么是哪些指令呢,报错中提到的是opcode为e8对应的指令,但我们知道肯定远不止这个,我们此时可以去obj/testcase/mov-c.txt中看到汇编语言的结果(部分代码如下):
完整汇编语言阅读完后我们其实可以知道,除去我们已经实现了的各类mov指令外,我们还需要额外实现call, push, test, je, cmp, pop, ret 七条指令。那么问题来了,我们应该怎么去添加新指令呢?
我们在手册学习过程中已经解读过了函数,我们知道,以mov指令的执行为例,真正涉及到mov指令实现的部分是位于mov.c中的make_helper_v()函数宏定义的mov_i2r_v()函数,位于mov-template.h中的make_instr_helper()函数宏定义的mov_i2r_l()函数,和do_execute()函数,位于mov.h中的函数声明。
由此,我们需要添加指令也应该完善这些部分内容。我们先以第一个触及到的指令call为例,来进行指令的添加。首先我们清楚call指令的功能,就是将当前指令的下一条指令地址压入栈,然后跳转到call指令的目标地址(计算机系统基础1中学过的内容),假如不清楚,也可以查询i386手册:
清楚了call指令的作用后我们就可以开始实现了,首先先编写指令的模板文件,定义指令的实现逻辑模版:
nemu/src/cpu/exec/control/call-template.h
// 头部引入文件start引入需要使用到的宏定义
#include "cpu/exec/template-start.h"
#define instr call //构造指令名称
make_helper(concat(call_i_, SUFFIX)){ //定义call指令跳转相对偏移量的操作
int len = concat(decode_i_, SUFFIX)(eip + 1); //decode函数中解码立即数的操作获取偏移量长度
reg_l(R_ESP) -= DATA_BYTE; //esp-4操作,相当于压地址入栈的第一步,栈顶地址减4
MEM_W(reg_l(R_ESP), cpu.eip+len+1); //将下一步的指令地址写入栈顶
cpu.eip += (DATA_TYPE_S)op_src->val; //加上立即数偏移量,实现跳转操作
print_asm("call: 0x%x", cpu.eip + len + 1) //打印调试信息,查看跳转的具体地址
return len + 1; //返回指令长度
}
make_helper(concat(call_rm_, SUFFIX)){ //定义call指令直接跳转内存或寄存器地址的操作
int len = concat(decode_rm_, SUFFIX)(eip + 1); //decode函数中解码寄存器或地址获取指令长度
reg_l(R_ESP) -= DATA_BYTE;
MEM_W(reg_l(R_ESP), cpu.eip + len + 1);
cpu.eip = (DATA_TYPE_S)op_src->val - len - 1; //跳转到目标寄存器地址,修正当前指令的影响
print_asm("call: %s", op_src->str);
return len + 1;
}
//尾部再引入文件end清除宏定义,防止污染后续代码文件
#include "cpu/exec/template-end.h"
随后编写指令实例化文件,处理所有指令需要应对的情况:
nemu/src/cpu/exec/control/call.c
#include "cpu/exec/helper.h"
// 定义DATA_BYTE为1,生成处理1字节的数据的call指令函数
#define DATA_BYTE 1
#include "call-template.h"
#undef DATA_BYTE
// 定义DATA_BYTE为2,生成处理2字节的数据的call指令函数
#define DATA_BYTE 2
#include "call-template.h"
#undef DATA_BYTE
// 定义DATA_BYTE为4,生成处理4字节的数据的call指令函数
#define DATA_BYTE 4
#include "call-template.h"
#undef DATA_BYTE
//定义重载函数_v来处理不同数据长度的指令
make_helper_v(call_i)
make_helper_v(call_rm)
最后编写指令头文件,声明指令最终的函数体,方便直接调用使用:
nemu/src/cpu/exec/control/call.h
#include "cpu/exec/helper.h"
//声明指令最终的函数体,用于调用使用
make_helper(call_i_v);
make_helper(call_rm_v);
以上代码就实现了call指令的操作,但是要想使用call指令,还需要进行指令的添加:
#include "control/call.h"
2.在 nemu/src/cpu/exec/exec.c 中的 opcode_table 中填写相应的 helper 函数:
/* 0xe8 */ call_i_v, jmp_si_l, inv, jmp_si_b,
完成了添加以后,我们再次运行nemu,我们就会发现,call指令已经成功执行了,下一个问题就到了opcode为0x55的push指令了:
push
依葫芦画瓢,我们往下实现push部分的代码:
首先我们了解过push指令,是将数据(可以是寄存器或者内存存储的值)压入栈顶中,其实和call里面的部分实现很相似(就是压入返回地址),不熟悉的可以参考i386手册:
nemu/src/cpu/exec/data-mov/push-template.h
#include "cpu/exec/template-start.h"
#define instr push
//通过do_execute实现do_push的通用实现定义
static void do_execute(){
cpu.esp -= 4; //栈顶地址减4来存放数据
swaddr_write(cpu.esp, 4, op_src->val); //写入目标数据
print_asm_template1();
}
//处理寄存器和内存里面的数据
//使用make_instr_helper已经实现好的译码来简化定义
#if DATA_BYTE == 2 || DATA_BYTE == 4;
make_instr_helper(r)
make_instr_helper(rm)
#endif
//处理立即数的数据(source immediate)
#if DATA_BYTE == 1
make_instr_helper(si)
#endif
#include "cpu/exec/template-end.h"
nemu/src/cpu/exec/data-mov/push.c
#include "cpu/exec/helper.h"
#define DATA_BYTE 1
#include "push-template.h"
#undef DATA_BYTE
#define DATA_BYTE 2
#include "push-template.h"
#undef DATA_BYTE
#define DATA_BYTE 4
#include "push-template.h"
#undef DATA_BYTE
make_helper_v(push_r)
make_helper_v(push_rm)
nemu/src/cpu/exec/data-mov/push.h
//防止重复定义头文件,进行包含保护
#ifndef __PUSH_H__
#define __PUSH_H__
make_helper(push_si_b);
make_helper(push_r_v);
make_helper(push_rm_v);
#endif
1.在 nemu/src/cpu/exec/all-instr.h 中包含 xxx.h:
#include "data-mov/push.h"
2.在 nemu/src/cpu/exec/exec.c 中的 opcode_table 中填写相应的 helper 函数:
/* 0x50 */ push_r_v,push_r_v,push_r_v,push_r_v,
/* 0x54 */ push_r_v,push_r_v,push_r_v,push_r_v,
test
然后到opcode为85的test指令了,我们知道,test指令其实相当于进行AND按位与操作,但是test是不会改变操作数的,只会改变EFLAGS寄存器中的标志位,更多有关于test指令的情况我们可以看i386手册:
那么就涉及到了EFALGS寄存器的标志位的实现问题了,但是不用担心,EFLAGS寄存器其实已经实现好了,我们也曾经在PA1中是或多或少是和他见过面的,就在CPU的定义中可以见到:
是通过一个联合体来表示EFLAGS寄存器的,其中呢,又是通过一个32位的val来决定了每一个标志位的取值。其中每一个位是什么含义,又怎么进行置位呢,这就要看到i386中EFLAGS的结构来进行学习了:
如图就是有关于EFLAGS寄存器内的每一位所代表的标志位,详细的标志位解释如下,我们只解释我们用到的七个标志位:
- CF:进位标志,如果运算的结果最高位产生了进位或借位,其值为1,否则为0。
- PF:奇偶标志,计算运算结果里1的奇偶性,偶数为1,否则为0。
- ZF:零标志,相关指令结束后判断是否为0,结果为0,其值为1,否则为0。
- SF:符号标志,相关质量结束后判断正负,结果为负,其值为1,否则为0。
- IF:中断使能标志,表示能否响应外部中断,若能响应外部中断,其值为1,否则为0。
- DF:方向标志,当DF为1,ESI、EDI自动递减,否则自动递增。
- OF:溢出标志,反映有符号数运算结果是否溢出,如果溢出,其值为1,否则为0
nemu/src/monitor/monitor.c
虽然EFLAGS寄存器已经设置好了,但是我们在使用前,还是需要对其进行初始化:
void restart(){
...
/* Initialize EFLAGS. */
cpu.eflags.val = 0x00000002; //通过初始化val来初始化eflags的标志位
}
i386第十章中有提到这个初始化值。至于为什么是0x00000002,是因为第二位是一个ALWAYS_1的位,更多的内容大家自己查询。
nemu/src/cpu/exec/logic/test-template.h
#include "cpu/exec/template-start.h"
#define instr test
static void do_execute(){
DATA_TYPE result = op_dest->val & op_src->val; //记录按位与逻辑运算后的结果
update_eflags_pf_zf_sf((DATA_TYPE_S)result); //根据结果调用已经编写好的PF,ZF,SF更新函数
cpu.eflags.CF = 0; //清除CF,OF
cpu.eflags.OF = 0;
print_asm_template1();
}
make_instr_helper(i2a)
make_instr_helper(i2rm)
make_instr_helper(r2rm)
#include "cpu/exec/template-end.h"
nemu/src/cpu/exec/logic/test.c
#include "cpu/exec/helper.h"
#define DATA_BYTE 1
#include "test-template.h"
#undef DATA_BYTE
#define DATA_BYTE 2
#include "test-template.h"
#undef DATA_BYTE
#define DATA_BYTE 4
#include "test-template.h"
#undef DATA_BYTE
make_helper_v(test_i2a)
make_helper_v(test_i2rm)
make_helper_v(test_r2rm)
nemu/src/cpu/exec/logic/test.h
#ifndef __TEST_H__
#define __TEST_H__
make_helper(test_i2a_b);
make_helper(test_i2rm_b);
make_helper(test_r2rm_b);
make_helper(test_i2a_v);
make_helper(test_i2rm_v);
make_helper(test_r2rm_v);
#endif
1.在 nemu/src/cpu/exec/all-instr.h 中包含 xxx.h:
#include "logic/test.h"
2.在 nemu/src/cpu/exec/exec.c 中的 opcode_table 中填写相应的 helper 函数:
/* 0x84 */ test_r2rm_b, test_r2rm_v, inv, inv,
je
下面是opcode为74的je指令添加,je是属于jcc条件跳转中的一种,我们可以借此把jcc中的底层框架给定义了。jcc我们知道,就是进行标志位的条件判断,然后决定是否跳转到目标地址。可以看到i386手册的相关部分:
指令很多,但是我们后面用到再进行添加,先实现jcc的框架和je的实现:
nemu/src/cpu/exec/control/jcc-template.h
make_instr_helper
是一个生成简单指令的辅助工具,而 jcc
指令的执行过程涉及条件判断、跳转逻辑、以及对标志位的依赖。因此,jcc
指令的处理需要一个更加复杂的流程,不能简单套用 make_instr_helper
。
#include "cpu/exec/template-start.h"
//条件跳转指令译码过程复杂,make_instr_helper无法使用,需要重新定义
#define make_jcc_helper(cc) \
make_helper(concat4(j, cc, _, SUFFIX)){ \
int len = concat(decode_si_, SUFFIX)(eip + 1); \
print_asm(str(concat(j,cc)) " %x",cpu.eip+op_src->val+1+len+(DATA_BYTE ==4)); \
cpu.eip += (concat(check_cc_, cc)() ? op_src->val : 0);\
return len + 1; \
}
make_jcc_helper(e)
#include "cpu/exec/template-end.h"
cpu.eip += (concat(check_cc_, cc)() ? op_src->val : 0);\
这里是条件跳转的核心逻辑:
concat(check_cc_, cc)()
生成的函数检查条件码是否满足。例如,对于je
指令,生成check_cc_e()
,它检查ZF
(Zero Flag)是否为 1。如果条件成立,则返回true
,否则返回false
。- 如果条件成立,
cpu.eip
会增加立即数偏移量op_src->val
,实现跳转;否则不跳转,cpu.eip
保持不变。
因此,我们还需要实现以下check_cc函数,方便后续判断eflags寄存器的标志位信息:
nemu/include/cpu/eflags.h
//je条件的判断,检测ZF标志位
static inline bool check_cc_e(){
return cpu.eflags.ZF;
}
nemu/src/cpu/exec/control/jcc.c
#include "cpu/exec/helper.h"
#define DATA_BYTE 1
#include "jcc-template.h"
#undef DATA_BYTE
#define DATA_BYTE 4
#include "jcc-template.h"
#undef DATA_BYTE
nemu/src/cpu/exec/control/jcc.h
#ifndef __JCC_H__
#define __JCC_H__
make_helper(je_b);
make_helper(je_l);
#endif
1.在 nemu/src/cpu/exec/all-instr.h 中包含 xxx.h:
#include "control/jcc.h"
2.在 nemu/src/cpu/exec/exec.c 中的 opcode_table 中填写相应的 helper 函数:
/* 0x74 */ je_b, inv, inv, inv,
cmp
然后是opcode为83的cmp指令,cmp指令和test指令挺像的,而cmp则是类似于sub减法操作,但是不改变操作数,而是只改变标志位。详细可以查询i386手册的内容(要注意i386中cmp指令的减数和被减数位置,LeftSRC是目标操作数,RightSRC是操作数):
nemu/src/cpu/exec/arith/cmp-template.h
#include "cpu/exec/template-start.h"
#define instr cmp
static void do_execute(){
//被减数为左边的目标操作数,减数为右边的操作数
DATA_TYPE result = op_dest->val - op_src->val;
update_eflags_pf_zf_sf((DATA_TYPE_S)result); //利用函数更新PF,SF,ZF
cpu.eflags.CF = result > op_dest->val; //通过比较结果和被减数判断是否借位
//减法发生溢出只需判断是否被减数和减数不同号,被减数和结果不同号,再用MSB宏提取最高位即可
cpu.eflags.OF = MSB((op_dest->val ^ op_src->val) & (op_dest->val ^ result));
print_asm_template2();
}
make_instr_helper(i2a);
make_instr_helper(i2rm);
//有符号立即数需要位扩展,只在2与4字节中发生
#if DATA_BYTE == 2 || DATA_BYTE == 4
make_instr_helper(si2rm)
#endif
make_instr_helper(r2rm)
make_instr_helper(rm2r)
#include "cpu/exec/template-end.h"
nemu/src/cpu/exec/arith/cmp.c
#include "cpu/exec/helper.h"
#define DATA_BYTE 1
#include "cmp-template.h"
#undef DATA_BYTE
#define DATA_BYTE 2
#include "cmp-template.h"
#undef DATA_BYTE
#define DATA_BYTE 4
#include "cmp-template.h"
#undef DATA_BYTE
make_helper_v(cmp_i2a)
make_helper_v(cmp_i2rm)
make_helper_v(cmp_si2rm)
make_helper_v(cmp_r2rm)
make_helper_v(cmp_rm2r)
nemu/src/cpu/exec/arith/cmp.h
#ifndef __CMP_H__
#define __CMP_H__
make_helper(cmp_i2a_b);
make_helper(cmp_i2rm_b);
make_helper(cmp_r2rm_b);
make_helper(cmp_rm2r_b);
make_helper(cmp_i2a_v);
make_helper(cmp_i2rm_v);
make_helper(cmp_si2rm_v);
make_helper(cmp_r2rm_v);
make_helper(cmp_rm2r_v);
#endif
1.在 nemu/src/cpu/exec/all-instr.h 中包含 xxx.h:
#include "arith/cmp.h"
2.在 nemu/src/cpu/exec/exec.c 中的 opcode_table 中填写相应的 helper 函数:
但是这次的helper函数定义就有新的知识点了,就是实验指导书中提到的指令长度扩充:
我们通过查询可以知道:
0x83
是 x86 中一个典型的多功能操作码,它可以执行多种不同的操作,具体操作由后续的 ModR/M 字节决定。例如:
0x83 /0
:ADD r/m16, imm8
或ADD r/m32, imm8
0x83 /1
:OR r/m16, imm8
或OR r/m32, imm8
0x83 /4
:AND r/m16, imm8
或AND r/m32, imm8
0x83 /5
:SUB r/m16, imm8
或SUB r/m32, imm8
0x83 /7
:CMP r/m16, imm8
或CMP r/m32, imm8
上面的 /0
, /1
, /4
, /5
, /7
是通过 ModR/M 字节的 reg
字段来区分的。每个值对应一个不同的操作(如加法、或运算、与运算、减法、比较等)。他们的定义应该放在一个group(组)中进行详细的定义:
...
/* 0x83 */
make_group(group1_sx_v,
inv, or_si2rm_v, inv, inv,
and_si2rm_v, sub_si2rm_v, inv, cmp_si2rm_v)
...
/* 0x80 */ group1_b, group1_v, inv, group1_sx_v,
pop
接下来是opcode为5d的pop指令,我们也学习过,它就是将栈顶的数据弹出来,存放到一个位置,然后让栈顶地址+4的一个操作,具体操作过程可以参考i386手册:
nemu/src/cpu/exec/data-mov/pop-template.h
#include "cpu/exec/template-start.h"
#define instr pop
static void do_execute(){
//向译码出的对象操作数中写入栈顶的数据
OPERAND_W(op_src,swaddr_read(cpu.esp, 4));
cpu.esp += 4; //栈顶加4进行地址回退
print_asm_template1();
}
make_instr_helper(r)
#include "cpu/exec/template-end.h"
nemu/src/cpu/exec/data-mov/pop.c
#include "cpu/exec/helper.h"
#define DATA_BYTE 2
#include "pop-template.h"
#undef DATA_BYTE
#define DATA_BYTE 4
#include "pop-template.h"
#undef DATA_BYTE
make_helper_v(pop_r)
nemu/src/cpu/exec/data-mov/pop.h
#ifndef __POP_H__
#define __POP_H__
make_helper(pop_r_v);
#endif
1.在 nemu/src/cpu/exec/all-instr.h 中包含 xxx.h:
#include "data-mov/pop.h"
2.在 nemu/src/cpu/exec/exec.c 中的 opcode_table 中填写相应的 helper 函数:
/* 0x5c */ inv, pop_r_v, inv, inv,
ret
最后就到了opcode为c3的ret指令了,ret指令我们知道,是和call指令遥相呼应的,call指令当初压入栈的返回地址,就在ret指令中弹出来,并进行返回,更多的资料也是查询到i386手册:
nemu/src/cpu/exec/control/ret.c
#include "cpu/exec/helper.h"
//不需区分操作数位数,只需分析指令,因此不需要template.h文件
make_helper(ret){
cpu.eip = swaddr_read(cpu.esp, 4) - 1; //使eip跳转到esp中存放的返回地址
cpu.esp += 4; //栈回退4位
print_asm("ret");
return 1;
}
make_helper(ret_i){
uint16_t imm = instr_fetch(eip + 1, 2); //还需要读取ret后面所跟的16位的参数
cpu.eip = swaddr_read(cpu.esp, 4) - 1-2;
cpu.esp += 4 + imm;
print_asm("ret $0x%x", imm);
return 3;
}
nemu/src/cpu/exec/control/ret.h
#ifndef __RET_H__
#define __RET_H__
make_helper(ret);
make_helper(ret_i);
#endif
1.在 nemu/src/cpu/exec/all-instr.h 中包含 xxx.h:
#include "control/ret.h"
2.在 nemu/src/cpu/exec/exec.c 中的 opcode_table 中填写相应的 helper 函数:
/* 0xc0 */ group2_i_b, group2_i_v, inv, ret,
所有指令就绪之后,我们就可以来运行了,但是结果发现还有地方没有补充完整:
nemu/src/cpu/decode/decode-template.h
/* TODO: Use instr_fetch() to read `DATA_BYTE' bytes of memory pointed
* by `eip'. Interpret the result as an signed immediate, and assign
* it to op_src->simm.
*
op_src->simm = ???
*/
//按照提示,我们可以直接补充完整这一个有符号操作数的译码操作
op_src->simm = (DATA_TYPE_S)instr_fetch(eip,DATA_BYTE);
这下子总该一切就绪了吧,make run之后,如愿得到了正确运行提示“HIT GOOD TRAP”