Skip to content

PA2-1 note

可以先去看 i386 的指令结构 (for PA2) 了解一条指令的解析过程

由于 i386 手册上存在一些严重影响PA进行的笔误,所以请时刻注意手册是否存在笔误(尤其是出现难以理解的错误时)

这里有一个修正后的 i386 手册 https://github.com/NJU-ProjectN/i386-manual

上面的手册也会存在未修正的部分,你应该进行三方面验证:

**手册 - PA 框架代码 - 这个链接

本 note 基本不会给出代码实现,更接近于对实验手册的解读

NEMU 如何执行指令?

指令循环的实现对应nemu/src/cpu/cpu.cvoid exec(uint32_t)函数中的while循环。其中,exec()函数的参数为需要执行的指令条数,当满足条件时,CPU将不断地执行指令。

exec()函数的while循环中,语句len = exec_inst()调用了函数exec_inst()。通过阅读代码注释,可以了解到exec_inst()函数的功能是执行EIP指向的指令,并返回指令的长度。

这里贴出 exec 函数的代码,了解 NEMU 如何完成对一系列指令的执行:

英文注释是原程序自带的,不作改动

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
void exec(uint32_t n)   // uint32_t n 作为指令条数传入函数
{
    // 初始化部分
    static BP *bp = NULL;           // BP 即 BreakPoint,它是一个指向当前断点的指针
    verbose = (n <= 100000);        // 一个 bool 变量,作用参考后面的补充内容
    int instr_len = 0;              // 存储每条指令的长度
    bool hit_break_rerun = false;   // 标记当前是否为“从断点状态恢复执行”

    if (nemu_state == NEMU_BREAK)   // 判断当前是否为“从断点状态恢复执行”
    {
        hit_break_rerun = true;
    }

    nemu_state = NEMU_RUN;          // NEMU 的状态被设定为 RUN(正在运行)
    while (n > 0 && nemu_state == NEMU_RUN)     // 逐条执行程序的主循环
    {
        if(!is_nemu_hlt)                        // HALT 是“停止运行” 的指令
        {
            instr_len = exec_inst();            // 执行一条指令,函数的返回值是指令长度
            cpu.eip += instr_len;               // eip 在这里就是 PC 的具体实现
            n--;                                // 待运行指令数 -1

            if (hit_break_rerun)                // 如果是“从断点状态恢复执行”,进行恢复操作
            {
                resume_breakpoints();
                hit_break_rerun = false;
            }
            // 这部分内容是 “断点检测”
            // check for breakpoints
            if (nemu_state == NEMU_BREAK)
            {
                // find break in the list
                bp = find_breakpoint(cpu.eip - 1);
                if (bp)
                {
                    // found, then restore the original opcode
                    vaddr_write(bp->addr, SREG_CS, 1, bp->ori_byte);
                    cpu.eip--;
                }
                // not found, it is triggered by BREAK_POINT in the code, do nothing
            }

            // 这部分内容是“标记点检测”
            // check for watchpoints

            BP *wp = scan_watchpoint();
            if (wp != NULL)
            {
                // print_bin_instr(eip_temp, instr_len);
                // puts(assembly);
                printf("\n\nHit watchpoint %d at address 0x%08x, expr = %s\n", wp->NO, cpu.eip - instr_len, wp->expr);
                printf("old value = %#08x\nnew value = %#08x\n", wp->old_val, wp->new_val);
                wp->old_val = wp->new_val;
                nemu_state = NEMU_READY;
                break;
            }
        }

    // 这里是外接设备的处理(在 PA 4-2: 外设与I/O 才会通过添加宏启用)
#if defined(HAS_DEVICE_TIMER) || defined(HAS_DEVICE_VGA) || defined(HAS_DEVICE_KEYBOARD) || defined(HAS_DEVICE_AUDIO)
    do_devices();
#endif
    // 这里是中断处理(在 PA 4-1: 异常和中断的响应 才会通过添加宏启用)
#ifdef IA32_INTR
        // check for interrupt
        do_intr();
#endif
    }
    if (nemu_state == NEMU_STOP)            // 虚拟机停止运行
    {
        printf("NEMU2 terminated\n");
    // 这里也是中断处理,PA 4-1 会有更详细的解释)
#ifdef IA32_INTR
        i8259_destroy();
#endif
    }
    else if (n == 0)                        // 虚拟机做好了运行的准备
    {
        nemu_state = NEMU_READY;
    }
}
如果你想知道 BP 是什么自定义的结构

BP 是定义断点的数据类型,定义于 nemu/include/monitor/breakpoint.h,你现在只需要知道 BP 记录了断点的相关信息即可

(这里有一个 TODO,但是我没有找到 PA 中需要修改这部分代码的任务)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef struct breakpoint
{
    uint8_t ori_byte : 8;
    bool enable : 1;
    bool in_use : 1;
    int NO : 22;

    union {
        vaddr_t addr;
        struct
        {
            char *expr;
            uint32_t old_val;
            uint32_t new_val;
        };
    };
    int type;
    struct breakpoint *next;

    /* TODO: Add more member if necessary */

} BP;
verbose 是个什么参数?

这个参数用于控制调试信息的输入情况,比如 nemu/include/cpu/instr_helper.h 中有:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define decode_operand_o2a                    \
    opr_src.type = OPR_MEM;                   \
    opr_src.sreg = SREG_DS;                   \
    if (verbose)                              \
        clear_operand_mem_addr(&opr_src);     \
    opr_src.addr = instr_fetch(eip + 1, 4);   \
    if (verbose)                              \
        opr_src.mem_addr.disp = opr_src.addr; \
    opr_dest.type = OPR_REG;                  \
    opr_dest.addr = REG_AL;                   \
    len += 4;

#define decode_operand_a2o                      \
    opr_dest.type = OPR_MEM;                    \
    opr_dest.sreg = SREG_DS;                    \
    if (verbose)                                \
        clear_operand_mem_addr(&opr_dest);      \
    opr_dest.addr = instr_fetch(eip + 1, 4);    \
    if (verbose)                                \
        opr_dest.mem_addr.disp = opr_dest.addr; \
    opr_src.type = OPR_REG;                     \
    opr_src.addr = REG_AL;                      \
    len += 4;

这里有两个宏函数,verbosetrue 时可以输出更多关于内存操作/寄存器状态的细节,我们理解为 “调试模式”。在 exec 函数中,我们规定指令数 <= 100000 时输出一些更详细的内容,否则为了输出的简洁性将 verbose 设置为 false

以及很快还会见到这两个函数的

对于如何执行单条指令,有 exec_inst 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// in nemu/src/cpu/cpu.c
int exec_inst()
{
    uint8_t opcode = 0;
    // get the opcode
    opcode = instr_fetch(cpu.eip, 1);   // 通过程序计数器获取当前指令的第一个字节


// printf("opcode = %x, eip = %x\n", opcode, cpu.eip);  // 一个插桩操作 Instrumentation
// instruction decode and execution
#ifdef NEMU_REF_INSTR                   // 这里有一个 “参考实现” 的切换
    int len = __ref_opcode_entry[opcode](cpu.eip, opcode);
#else
    int len = opcode_entry[opcode](cpu.eip, opcode);
#endif
    return len; // 返回指令长度
}
这里附带了 instr_fetch 函数的实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// in nemu/src/memory/memory.c
uint32_t instr_fetch(vaddr_t vaddr, size_t len)
{
    assert(len == 1 || len == 2 || len == 4);
    return vaddr_read(vaddr, SREG_CS, len);
}

uint32_t vaddr_read(vaddr_t vaddr, uint8_t sreg, size_t len)
{
    assert(len == 1 || len == 2 || len == 4);
    return laddr_read(vaddr, len);
}

uint32_t laddr_read(laddr_t laddr, size_t len)
{
    return paddr_read(laddr, len);
}

uint32_t paddr_read(paddr_t paddr, size_t len)
{
    uint32_t ret = 0;
    ret = hw_mem_read(paddr, len);
    return ret;
}

uint32_t hw_mem_read(paddr_t paddr, size_t len)
{
    uint32_t ret = 0;
    memcpy(&ret, hw_mem + paddr, len);
    return ret;
}

为什么要嵌套多层函数最后只是为了执行 hw_mem_read 这一步?

这是为了模拟 x86-64 的内存管理机制,参见 “物理地址、虚拟地址、线性地址、逻辑地址” 这些内存概念

exec_inst 函数先通过 instr_fetch 获取 opcode,然后通过下面的函数进行指令执行:

1
int len = opcode_entry[opcode](cpu.eip, opcode);

这里 opcode_entry 是一个函数指针数组,在 nemu/src/cpu/decode/opcode.c 中有

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// From nemu/include/cpu/instr_helper.h:
// typedef int (*instr_func)(uint32_t eip, uint8_t opcode);

instr_func opcode_entry[256] = {
    /* 目前这里的 inv 都是占位符,需要具体实现 */
    /* 0x00 - 0x03*/ inv, inv, inv, inv,
    /* 0x04 - 0x07*/ inv, inv, inv, inv,
    /* 0x08 - 0x0b*/ inv, inv, inv, inv,
    /* 0x0c - 0x0f*/ inv, inv, inv, opcode_2_byte,
    /* ... */
    /* 0xf0 - 0xf3*/ inv, break_point, inv, rep_repe,
    /* 0xf4 - 0xf7*/ hlt, inv, group_3_b, group_3_v,
    /* 0xf8 - 0xfb*/ clc, inv, inv, inv,
    /* 0xfc - 0xff*/ cld, inv, inv, group_5_indirect,
};
如何理解上面的内容?

首先是下面的类型定义:

typedef int (*instr_func)(uint32_t eip, uint8_t opcode);

这里定义了一个 instr_func 类型,表示为函数指针,并且专门指向参数为 (uint32_t eip, uint8_t opcode),返回值为 int 的函数,下面是一个使用例:

1
2
3
instr_func a;
a = op_func;    // op_func 是一个签名符合上述要求的函数
int res = a(eip, opcode);

在定义了 instr_func 后,构建一个存储 instr_func 的数组(即 opcode_entry),在 opcode_entry 中放置对应函数,这样通过数组索引就可以在 O(1) 的时间复杂度下获取 opcode 对应的函数,比如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
typedef int (*instr_func)(uint32_t eip, uint8_t opcode);

int ADD_rm8_reg8(uint32_t eip, uint8_t opcode){
    // 具体的函数实现...
}
/* 其他函数... */

instr_func opcode_entry[256] = {
    ADD_rm8_reg8,
    /* ... */
}

// 下面的操作即完成了一次指令执行
uint8_t opcode = instr_fetch(cpu.eip, 1);
int res = opcode_entry[opcode](eip, opcode);
(具体的实现参考 NEMU 源代码)

如果一条指令包含 0x66 前缀,如何正确读取 opcode?

在 NEMU 的实现中,0x66opcode_entry[0x66] 中有单独的操作

1
 /* 0x64 - 0x67*/ inv, inv, data_size_16, inv,

在 nemu/src/cpu/instr/data_size.c 中有

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "cpu/instr.h"

extern uint8_t data_size;   // 这里有一个全局变量
extern bool has_prefix;     // 同上

// make_instr_func(data_size_16) 是 data_size_16(uint32_t eip, uint8_t opcode) 的宏封装
// 之后会提到
make_instr_func(data_size_16)
{
    uint8_t op_code = 0;
    int len = 0;
    data_size = 16;
    has_prefix = true;
    op_code = instr_fetch(eip + 1, 1);
#ifdef NEMU_REF_INSTR
    len = __ref_opcode_entry[op_code](eip + 1, op_code);
#else
    len = opcode_entry[op_code](eip + 1, op_code);
#endif
    data_size = 32;
    has_prefix = false;
    return 1 + len;
}

简单来说,0x66 被当作一条完整的指令执行(而不是某一条指令的前缀),通过全局变量 data_sizehas_prefix 传递前缀信息(对接下来的指令有效),这样就实现了 0x66 前缀的读取,同时不需要为每个操作额外加上 0x66 的判断

模拟指令的实现

“一条简单mov指令的实现” 的部分可以认真看看

简单来说,我们需要做的就是:

  1. 实现指令,以 b1 01: movb $1, %cl 为例:

    1
    2
    3
    4
    5
    6
    int mov_i2r_b(uint32_t eip, uint8_t opcode) {
        uint8_t imm = instr_fetch(eip + 1, 1); // 获取立即数
        uint8_t regIdx = opcode & 0x7;  // 获取寄存器编号
        cpu.gpr[regIdx]._8[0] = imm; // 完成mov动作 
        return 2; // 返回指令长度
    }
    

  2. mov.h 中声明函数,在 instr.h 中引用头文件 #include "cpu/instr/mov.h"

  3. opcode.c 中修改 opcode_entry 中的相关内容,容易得到 mov_i2r_b 这个函数对 0xB0 ~ 0xB7 的 opcode 都适用,所以把这几个元素的 inv 都修改为 mov_i2r_b

但是事实上 mov 指令有很多变种,不同的变种的大致框架是一样的,我们希望抽象出部分相同逻辑,封装提供调用,简化逻辑

NEMU 中对一些函数/结构进行了封装:

操作数和操作数寻址

操作数的封装

NEMU中所有的操作数都封装在一个叫做OPERAND的数据结构中。该数据结构的声明在头文件nemu/include/cpu/operand.h

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// nemu/include/cpu/operand.h
#ifndef __OPERAND_H__
#define __OPERAND_H__

#include "nemu.h"
#include "cpu/cpu.h"
#include "memory/memory.h"

// operand type for immediate number, register, and memory
enum {
    OPR_IMM,   // 立即数
    OPR_REG,   // 寄存器
    OPR_MEM,   // 内存
    OPR_CREG,  // 控制寄存器
    OPR_SREG   // 段寄存器
};

#define MEM_ADDR_NA 0xffffffff

//enum {MEM_ADDR_OFF, MEM_ADDR_SIB};

// 内存地址结构体 (MEM_ADDR)
// 描述了内存的寻址方式
typedef struct
{
    // uint32_t type;
    uint32_t disp;  // hex
    uint32_t base;  // register
    uint32_t index; // register
    uint32_t scale; // 1, 2, 4, 8
} MEM_ADDR;         // memory address details

// 操作数结构体 (OPERAND)
// 包含了一个操作数的完整信息
typedef struct
{
    int type;           // 操作数类型
    uint32_t addr;      // 地址
    uint8_t sreg;       // 段寄存器
    uint32_t val;       // 数据内容
    size_t data_size;   // 数据大小
    MEM_ADDR mem_addr;  // 内存地址的详细信息(包括寻址方式)
} OPERAND;

extern OPERAND opr_src, opr_dest;

// 以下是对读写 OPERAND 的相关封装

// read the operand's value from its addr
// 从操作数地址读取值
void operand_read(OPERAND *opr);

// write the operand's value to its addr
// 将值写入操作数地址
void operand_write(OPERAND *opr);
void operand_write_cr0(OPERAND *opr);

void parse_operand_address(OPERAND *opr, char *str);
void clear_operand_mem_addr(OPERAND *opr);

#endif

这样的封装设计能够应对复杂的操作数寻址模式的处理,比如我们现在重写 mov_i2r_b 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
int mov_i2r_b(uint32_t eip, uint8_t opcode) {
    OPERAND imm, r;        // 创建源操作数和目的操作数局部变量

    imm.type = OPR_IMM;    // 配置源操作数类型
    imm.type = SREG_CS;    // 设置段寄存器,PA 3-2 开始涉及
    imm.addr = eip + 1;    // 配置源操作数地址
    imm.data_size = 8;     // 配置源操作数长度

    r.data_size = 8;        // 配置目的操作数类型
    r.type = OPR_REG;       // 配置目的操作数类型
    r.addr = opcode & 0x7;  // 配置目的操作数类型

    operand_read(&imm);   // 读源操作数的值
    r.val = imm.val;      // 将源操作数的值赋给目的操作数
    operand_write(&r);    // 写入目的操作数,完成mov动作

    return 2;             // 返回指令长度
}

虽然代码量更大了,但是函数的设计更加规整,可以用较为固定化的方式高效实现一系列的 mov 函数


现在我们进一步实现 “操作数寻址” 的细节,这其中的核心是对 ModR/M 字段与 SIB 字段的解析

ModR/M

回顾 :ModR/M 字段总共分为三部分:Mod + Opcode/Reg + R/M

在头文件nemu/include/cpu/modrm.h中,声明了ModR/M字节的结构定义和四个函数:

1
2
3
4
int modrm_rm(uint32_t eip, OPERAND * rm);
int modrm_r_rm(uint32_t eip, OPERAND * r, OPERAND * rm);
int modrm_opcode_rm(uint32_t eip, uint8_t * opcode, OPERAND * rm);
int modrm_opcode(uint32_t eip, uint8_t * opcode);

这四个函数分别对应指令希望通过解析ModR/M字节所获得的数据的四种不同类型组合,已涵盖实验中所涉及的所有指令。

我们以 modrm_r_rm 函数为例具体分析,为了展示调用关系,将内层函数放在了更后面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// From modrm.h
// 很显然的定义
typedef union {
    struct
    {
        uint32_t rm : 3;
        uint32_t reg_opcode : 3;
        uint32_t mod : 2;
    };
    uint8_t val;
} MODRM;

// From modrm.c
// modrm 有四种类型组合,这里只展示 r_rm 型
int modrm_r_rm(uint32_t eip, OPERAND *r, OPERAND *rm)
{
    MODRM modrm;                                // 定义一个 ModR/M
    modrm.val = instr_fetch(eip, 1);            // 通过 instr_fetch 填充
    r->type = OPR_REG;                          // 第一个操作数 r 类型设置为“寄存器”
    r->addr = modrm.reg_opcode;                 // 其地址设置为 Reg/Opcode 字段
    int len = parse_rm_32(eip, modrm, rm);      // 第二个操作数 rm 调用函数处理
    return len;
}

int parse_rm_32(uint32_t eip, MODRM modrm, OPERAND *opr)
{
    int len = 1; // modr/m 的长度
    if (verbose)
        clear_operand_mem_addr(opr);
    switch (modrm.mod)                          // 根据 mod 设置 rm 
    {
    case 0:
        len += case_mod_00(eip, modrm, opr);
        break;
    case 1:
        len += case_mod_01(eip, modrm, opr);
        break;
    case 2:
        len += case_mod_10(eip, modrm, opr);
        break;
    case 3:
        len += case_mod_11(eip, modrm, opr);
        break;
    }
    return len;
}

// 四个 case_mod 函数都很直白,此处只展示其中两个
// Mod = 01,对应带 disp8 偏移量的内存寻址
int case_mod_01(uint32_t eip, MODRM modrm, OPERAND *opr)
{
    int len = 0;
    int8_t disp8 = 0;
    switch (modrm.rm) {

    case 4: //disp8 SIB
        opr->type = OPR_MEM;
        //len += parse_sib(eip + 1, modrm.mod, &opr->addr, &opr->sreg);
        len += parse_sib(eip + 1, modrm.mod, opr);      // 这里是 SIB 字节的处理
        disp8 = (int8_t)instr_fetch(eip + 2, 1);
        len += 1; // disp8
        break;

    default: //disp8[EXX]
        opr->type = OPR_MEM;
        opr->addr = cpu.gpr[modrm.rm]._32;
        opr->mem_addr.base = modrm.rm;                  // 参考 OPERAND 的结构体实现
        disp8 = (int8_t)instr_fetch(eip + 1, 1);
        len += 1; // disp8
        // 这里是段寄存器的操作
        if (modrm.rm == 5)
        { // EBP
            opr->sreg = SREG_SS;
        }
        else
        {
            opr->sreg = SREG_DS;
        }
        break;
    }
    opr->addr = disp8 + (int32_t)opr->addr;             // 地址计算
    opr->mem_addr.disp = disp8;
    return len;
}

// Mod = 11,对应直接寄存器
int case_mod_11(uint32_t eip, MODRM modrm, OPERAND *opr)
{
    opr->type = OPR_REG;
    opr->addr = modrm.rm;
    return 0;
}

SIB

对 SIB 字节的结构定义nemu/include/cpu/sib.h 与函数 nemu/src/cpu/decode/sib.c

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 内存寻址方式 [Base + Index * Scale + Disp]

typedef union {
    struct
    {
        uint32_t base : 3;
        uint32_t index : 3;
        uint32_t ss : 2;
    };
    uint8_t val;
} SIB;

int parse_sib(uint32_t eip, uint32_t mod, OPERAND *opr)
{
    SIB sib;                                    // 定义一个 SIB
    sib.val = instr_fetch(eip, 1);              // 取值填充
    uint32_t idx = 0;                           // 变址值初始化
    opr->sreg = SREG_DS;                        // 段寄存器选择

    if (sib.base == 5 || sib.base == 4)         // 段寄存器选择
    {
        opr->sreg = SREG_SS;
    }
    /* 特例:当 Index = 100 时,看作“Index = NULL”(不使用变址寄存器),而不是 “Index = ESP” */
    if (sib.index != 4)                         // 启用了变址寄存器
    {
        idx = cpu.gpr[sib.index]._32;           // 获取变址寄存器的 32 位值
        opr->mem_addr.index = sib.index;        // 记录变址寄存器的 id
        switch (sib.ss)                         // 缩放因子
        {
        case 0x0:
            opr->mem_addr.scale = 1;
            break;
        case 0x1:
            idx *= 2;
            opr->mem_addr.scale = 2;
            break;
        case 0x2:
            idx *= 4;
            opr->mem_addr.scale = 4;
            break;
        case 0x3:
            idx *= 8;
            opr->mem_addr.scale = 8;
            break;
        }
    }
    switch (mod)                                
    {
    case 0: // only now has additional disp32?
        if (sib.base == 5)
        /* 特例:当 Base = 101 且 Mod = 00 时,看作“Base = NULL”(不使用基址寄存器),而不是 “Base = EBP”,同时启用 Disp32 */
        {
            int32_t disp32 = instr_fetch(eip + 1, 4);
            opr->addr = idx + disp32;
            opr->mem_addr.disp = disp32;
            return 5;
        }
    case 1:
    case 2:
        // 正常情况下
        opr->addr = cpu.gpr[sib.base]._32 + idx;
        opr->mem_addr.base = sib.base;
        return 1;
    default:
        printf("illegal mod=11 in SIB\n");
        fflush(stdout);
        assert(0);
        break;
        return 0;
    }
}

进一步的封装

现在我们有一个 mov_i2rm_v 指令 (将一个立即数 mov 到一个由 ModR/M 字节所表达的寄存器或内存地址(R/M)中,位数不定)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
int mov_i2rm_v(uint32_t eip, uint8_t opcode) {
    OPERAND rm, imm;

    imm.data_size = rm.data_size = data_size; // 解码操作数:指定操作数长度
                                              // data_size 会受到 0x66 的影响
    int len = 1;
    len += modrm_rm(eip + 1, &rm);            // 解码操作数:操作数寻址
    imm.type = OPR_IMM;
    imm.sreg = SREG_CS;
    imm.addr = eip + len;

    operand_read(&imm);                       // mov操作
    rm.val = imm.val;
    operand_write(&rm);

    return len + data_size / 8;               // 返回长度
}

以这个两操作数的指令为例,进行进一步的封装、简化操作:

前置知识:concat

concat 系列宏可以将多个参数硬拼接使用,在 nemu/include/macro.h 中有:

1
2
3
4
5
6
7
#define concat_temp(x, y) x##y
#define concat(x, y) concat_temp(x, y)
#define concat3(x, y, z) concat(concat(x, y), z)
#define concat4(x, y, z, w) concat3(concat(x, y), z, w)
#define concat5(x, y, z, v, w) concat4(concat(x, y), z, v, w)
#define concat6(x, y, z, v, w, u) concat5(concat(x, y), z, v, w, u)
#define concat7(x, y, z, v, w, u, h) concat6(concat(x, y), z, v, w, u, h)

由于宏操作的特性,它可以用来拼接函数名:

比如 make_instr_func(mov_i2rm_v),可以写成

1
make_instr_func(concat7(mov, _, i, 2, rm, _, v))

一个非常方便的使用例

1
2
#define make_instr_impl_2op(inst_name, src_type, dest_type, suffix)    \
    make_instr_func(concat7(inst_name, _, src_type, 2, dest_type, _, suffix))

这种宏展开匹配函数名的方式可以极大的简化分支操作

  • 所有的指令的签名都是一样的(符合 opcode_entry 的要求),所以可以简化函数声明:
1
2
3
// From nemu/include/cpu/instr_helper.h
// macro for making an instruction entry
#define make_instr_func(name) int name(uint32_t eip, uint8_t opcode)

此时 int mov_i2rm_v(uint32_t eip, uint8_t opcode) 可以重写为 make_instr_func(mov_i2rm_v)

  • NEMU 不存在指令并发,所以可以将指令的操作数修改为全局变量,规范命名并且节省栈空间
1
2
// From nemu/src/cpu/decode/operand.c
OPERAND opr_src, opr_dest;
  • 不难发现,对于很多两操作数指令,都满足下面的步骤:解码两个操作数 -> 进行实际的指令操作(比如mov) -> 返回指令长度

这让我们尝试统一所有符合上述规则的函数:

··· 这些指令的名字都符合 "inst_name"_"src_type"'2'"dest_type"_"suffix" 的命名规范(比如 mov_i2rm_v),因此我可以构造一个 make_instr_impl_2op(inst_name, src_type, dest_type, suffix) 的大框架,通过输入各个参数的内容就可以映射到对应的两操作数指令(借助 concat 宏实现)

··· 接下来实现两个解码操作数的函数封装(分别是操作数长度与操作数寻址的函数)

··· 然后一个限定在单个文件内的 static instr_execute_2op(); 函数,在每个文件内(代表一个类型的指令)分别指代同一种操作

··· 最后统一返回长度

instr_helper.h 中,我们有了对应的实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 一个实现部分两操作数指令的模板
// decode_data_size 与 decode_operand 系列的指令也在 instr_helper.h 中实现,自行分析

// macro for generating the implementation of an instruction with two operands
#define make_instr_impl_2op(inst_name, src_type, dest_type, suffix)                                                                        \
 make_instr_func(concat7(inst_name, _, src_type, 2, dest_type, _, suffix))                                                              \
 {                                                                                                                                      \
     int len = 1;                                                                                                                       \
     concat(decode_data_size_, suffix)                                                                                                  \
     concat3(decode_operand, _, concat3(src_type, 2, dest_type))                                                                    \
     print_asm_2(#inst_name, opr_dest.data_size == 8 ? "b" : (opr_dest.data_size == 16 ? "w" : "l"), len, &opr_src, &opr_dest); \
     instr_execute_2op();                                                                                                               \
     return len;                                                                                                                        \
 }

对于 mov 系指令,我们最终有下面的写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 所有mov指令共享的执行方法
// static 使得 instr_execute_2op() 只会在 mov.c 中生效,实现隔离
static void instr_execute_2op() {
    operand_read(&opr_src);
    opr_dest.val = opr_src.val;
    operand_write(&opr_dest);
}

// 这里的每一行指令在宏展开后都是完整的实现
make_instr_impl_2op(mov, i, rm, b)
make_instr_impl_2op(mov, i, rm, v)
make_instr_impl_2op(mov, r, rm, v)
make_instr_impl_2op(mov, rm, r, v)
...

mov 函数的主要实现已经在 mov.c 中完成,作为举例


instr_helper.h 中,我们实现了一操作数/两操作数指令的完整逻辑(满足取数 -> 执行 -> 返回长度的指令),同时对 jcc 系指令也有了相对应的 cc 版宏实现

根据框架代码的构筑经验,适用和不适用宏的指令分别是:

  • 适用宏的指令:adc, add, and, bt, cbw, cmov, cmp, dec, inc, jcc, 大多数的mov, not, or, pop, push, sar, sbb, setcc, shl, shr, sub, test, xor

  • 不适用的指令:call, cltd, cmps, div, idiv, mul, imul, cld, clc, sahf, hlt, int, jmp, lea, leave, rep, ret, stos, x87

---分隔线---

Tips when completing PA

为防止剧透,几乎所有的 Tips 都被折叠

(Tips 指的是本人在实际完成 PA 时认为有推动性帮助的点)

老师给出的有关 pop / push 实现的建议

pushpop 建议不管 data_size原来是多少,都扩展到 32 位再操作

关于填写 opcode_entry / 函数名如何命名 / 我需要实现哪些小函数的问题

opcode.c 文件的同级目录下有一个 opcode_ref.c 文件,你会发现删除所有的 __ref_ 后,其余的内容就是你需要在 opcode.c 中填写的内容

你只需要选择性的 Ctrl C+V,然后 Ctrl+F 删掉就能获得一个大致填写完成的 opcode.c 文件,同时还附赠了你需要具体实现的每一个子函数的名称

注意你不应该一次复制整个 opcode_ref.c,你复制的函数都需要被实现才能通过编译,你应该只复制实现的部分

另外:对于 PA 2-1,你不需要实现所有的函数,所以部分函数使用 __ref_ 版本不影响完成

编译时出现了 ./include/cpu/instr_helper.h:14:31: error: expected '=', ',', ';', 'asm' or '__attribute__' before 'int' 之类的报错?

/nemu/src/cpu/instr 里面的每一项头文件都检查一下 make_instr_func(); 有没有漏加分号

由于宏展开的神秘特性,报错所在的文件位置不一定正确

个人经历:“push.h 没加分号怎么报错报在了 div.h 😨”(发送于凌晨三点)

我在某一个 testcase 出现了 HIT BAD TRAP,但是我并不知道自己的函数实现中有哪些错误,如何排查

每一个函数都有一个 __ref_ 版本的标准实现。如果你大概能确定可能是哪一条指令出了问题,就将它在 opcode_entry[] 中加上 __ref_,如果再次测试(记得先 make)发现 HIT GOOD TRAP,说明你刚刚加上 __ref_ 的指令的实现有问题;

如果你不清楚哪一条指令出了问题,那就将所有涉及到这个样例的指令全部加上 __ref_,一个个解除 __ref_,直到出现 HIT BAD TRAP,说明最后一个解除 __ref_ 的函数实现有问题

我在某一个 testcase 出现了 invalid opcode 报错

如果这个 opcode 是预期的,检查 opcode_entry[] 是否忘记添加函数

如果这个 opcode 不是预期的,那么大概率是其他指令的实现有问题(比如返回了错误的 len)。你可以用上面一条提到的 __ref_ 方案尝试找到错误的函数,也可以采用 monitor 的单步执行操作,定位出现 invalid opcode 前最后执行的 opcode

(个人经历:我在实现 and 函数时没有注意符号扩展,导致 quick-sort 测试点出现数值溢出,地址在将近 1w 步执行后直接跳转到了 0x0 左右的位置,并最终 invalid opcode 。在这个不常见的经历中,__ref_ 是最直接的排查方案)

我将全部的指令都 __ref_ 了,但是依旧 HIT BAD TRAP

这不应该,根据测试,全部使用参考实现可以通过所有的 testcases(test-float 除外)

需要思考自己是不是动过不该动的东西了,或者再次确认有没有忘记 __ref_ 化的函数

我在 make 时没有看到红色的编译报错信息,但是我在调用 nemu 进行 test 时发现 command not found,没有生成 nemu 可执行文件

说明 make 确实没有编译问题,但是可能存在链接问题,此时不会有红色的报错,而是正常颜色,混在正常的编译信息中,比如:

1
2
3
undefined reference to 'xxx'
collect2: error: ld returned 1 exit status
make-[1]: *** [Makefile:13: nemu] Error 1

上面的例子说明你声明了一个函数但是从来没有实现它,这是一个链接阶段的错误,而不是编译阶段的错误,所以没有彩色的报错提示

为什么我没有使用 __ref_ 完成指令,某个 testcase 依旧提醒 “You have used reference implementation, DO NOT submit this version.

首先确认一下自己是不是真的在这个 testcase 所需要的指令中完全没有使用 __ref_

然后引用老师的解答:

这一般是因为某整数运算相关的指令,当遇到两个操作数长度不一样的时候,没有在调用alu对应运算函数前先统一操作数长度导致的

统一的时候要用sign_ext函数做符号扩展

样例测试的时候出现了 load_exec: Assertion 'fp != 0' failed

意思是 nemu 没有找到样例文件

说明你把样例的名字输错了(应该没有后缀名)/ 你手搓了一个样例但是没有生成可执行文件

不好奇一下 __ref_ 是如何实现的吗

libs/nemu-ref/lib-nemu-ref.a 中(.a 文件可以看作多个 .o 文件的打包文件)有每个 __ref_ 函数的实现,你可以用逆向工具将其反汇编为汇编语言/反编译为 C 语言

可以反编译 __ref_ 函数来了解某个函数的实现方式,虽然非常不直观

image.png