NEMU PA2 必做任务1 实验思路

本文包含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指令,还需要进行指令的添加:

1.在 nemu/src/cpu/exec/all-instr.h 中包含 xxx.h:
#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, imm8ADD r/m32, imm8
  • 0x83 /1: OR r/m16, imm8OR r/m32, imm8
  • 0x83 /4: AND r/m16, imm8AND r/m32, imm8
  • 0x83 /5: SUB r/m16, imm8SUB r/m32, imm8
  • 0x83 /7: CMP r/m16, imm8CMP 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”

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值