PA2-1 note
可以先去看 i386 的指令结构 (for PA2) 了解一条指令的解析过程
由于 i386 手册上存在一些严重影响PA进行的笔误,所以请时刻注意手册是否存在笔误(尤其是出现难以理解的错误时)
这里有一个修正后的 i386 手册 https://github.com/NJU-ProjectN/i386-manual
上面的手册也会存在未修正的部分,你应该进行三方面验证:
**手册 - PA 框架代码 - 这个链接
本 note 基本不会给出代码实现,更接近于对实验手册的解读
NEMU 如何执行指令?
指令循环的实现对应nemu/src/cpu/cpu.c中void 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;
|
这里有两个宏函数,verbose 为 true 时可以输出更多关于内存操作/寄存器状态的细节,我们理解为 “调试模式”。在 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,然后通过下面的函数进行指令执行:
| 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 的函数,下面是一个使用例:
| 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 的实现中,0x66 在 opcode_entry[0x66] 中有单独的操作
| /* 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_size 和 has_prefix 传递前缀信息(对接下来的指令有效),这样就实现了 0x66 前缀的读取,同时不需要为每个操作额外加上 0x66 的判断
模拟指令的实现
“一条简单mov指令的实现” 的部分可以认真看看
简单来说,我们需要做的就是:
-
实现指令,以 b1 01: movb $1, %cl 为例:
| 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; // 返回指令长度
}
|
-
在 mov.h 中声明函数,在 instr.h 中引用头文件 #include "cpu/instr/mov.h"
-
在 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字节的结构定义和四个函数:
| 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 中有:
| #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),可以写成
| make_instr_func(concat7(mov, _, i, 2, rm, _, v))
|
一个非常方便的使用例
| #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 的要求),所以可以简化函数声明:
| // 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 不存在指令并发,所以可以将指令的操作数修改为全局变量,规范命名并且节省栈空间
| // 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 实现的建议
push 和 pop 建议不管 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 确实没有编译问题,但是可能存在链接问题,此时不会有红色的报错,而是正常颜色,混在正常的编译信息中,比如:
| 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_ 函数来了解某个函数的实现方式,虽然非常不直观
