目录
转载请注明出处!
文章内容仅为个人完成项目过程中的总结,仅供参考。
出于ysyx诚信要求,仅提供完成思路,不提供代码。
1. RTFSC
本章节开始正式进入nemu实验的核心内容,需要认真阅读框架代码。
NEMU主要包含了四个模块:monitor, CPU, memory, 设备。
Monitor(监视器)模块是为了方便地监控客户计算机的运行状态而引入的。它除了负责与GNU/Linux进行交互(例如读入客户程序)之外,还带有调试器的功能,为NEMU的调试提供了方便的途径。PA1的主要内容就是完成monitor部分的实现,为后续开发提供基础设施。
需要完成or了解的内容:
- nemu项目构建
- make run后nemu都做了哪些工作
- 参考gdb实现sdb
- 用native环境玩玩游戏(回忆青春属于是)
getopt - 解析命令行参数
具体内容可以man 1 getopt,中文可以参考翻译:man getopt(1)中文手册 - 骏马金龙 - 博客园 (cnblogs.com)或者getopt函数使用说明 - 荧火虫 - 博客园 (cnblogs.com)
static int parse_args(int argc, char *argv[]) {
const struct option table[] = {
{"batch" , no_argument , NULL, 'b'},
{"log" , required_argument, NULL, 'l'},
{"diff" , required_argument, NULL, 'd'},
{"port" , required_argument, NULL, 'p'},
{"help" , no_argument , NULL, 'h'},
{"elf" , required_argument, NULL, 'e'},
{0 , 0 , NULL, 0 },
};
int o;
while ( (o = getopt_long(argc, argv, "-bhl:e:d:p:", table, NULL)) != -1) {
switch (o) {
case 'b': sdb_set_batch_mode(); break;
case 'p': sscanf(optarg, "%d", &difftest_port); break;
case 'l': log_file = optarg; break;
case 'd': diff_so_file = optarg; break;
case 'e': elf_file = optarg; break;
case 1: img_file = optarg; return 0;
default:
printf("Usage: %s [OPTION...] IMAGE [args]\n\n", argv[0]);
printf("\t-b,--batch run with batch mode\n");
printf("\t-l,--log=FILE output log to FILE\n");
printf("\t-d,--diff=REF_SO run DiffTest with reference REF_SO\n");
printf("\t-p,--port=PORT run DiffTest with port PORT\n");
printf("\t-e,--elf=FILE inport FILE to elf\n");
printf("\n");
exit(0);
}
}
return 0;
}
这部分内容在PA2解析elf文件时候还会用到,需要认真阅读理解。
2. 简易调试器
简易调试器(Simple Debugger, sdb)是NEMU中一项非常重要的基础设施.。我们知道NEMU是一个用来执行其它客户程序的程序, 这意味着,NEMU可以随时了解客户程序执行的所有信息.。然而这些信息对外面的调试器(例如GDB)来说,是不容易获取的。
sdb详细的内容参考基础设施: 简易调试器 | 官方文档 (oscc.cc)
sdb的功能代码在nemu/src/monitor/sdb/sdb.c中。框架代码已经提供了help,c,q指令的实现,,仿照框架代码在cmd_table内添加内容
static struct {
const char *name;
const char *description;
int (*handler) (char *);
} cmd_table [] = {
{ "help", "Display informations about all supported commands", cmd_help },
{ "c", "Continue the execution of the program", cmd_c },
{ "q", "Exit NEMU", cmd_q },
/* TODO: Add more commands */
{ "si", "Let the programexcute N instuctions and then suspend the excution,while the N is not given,the default value is 1", cmd_si},
};
添加完成后,make run运行nemu,通过help指令查看自己的实现情况。
后续指令的具体实现都可以参考cmd_c,cmd_help的内容。
static int cmd_c(char *args) {
cpu_exec(-1);
//convert -1 to unsigned number will get the biggest,so all program will be excuted
return 0;
}
static int cmd_p(char *args) {
char *arg = strtok(args, " ");
if(arg == NULL){
printf("Please give an expression\n");
}
else{
bool success = false;
word_t result = expr(arg, &success);
if(success){
printf("result=%ld\n", result);
}
else{
printf("Error expression\n");
}
}
return 0;
}
一个有用的函数: strtok(args, " ") 可以用这个函数过滤掉空格内容,具体的行为请RTFM。
2.1 单步执行
单步执行的功能十分简单,,而且框架代码中已经给出了模拟CPU执行方式的函数,只需要传入一个参数N,然后
cpu_exec(N);
printf("Step excute N=%s\n",arg);
即可实现单步执行的功能。参数N可以通过函数atoi从args中获取。
2.2 打印寄存器
使用strcmp函数进行参数对比,参数为r时调用isa_reg_display函数(需自己在对应函数内完成打印)即可实现打印。
else if (strcmp(arg,"r") == 0) {
isa_reg_display();
}
2.3 扫描内存
扫描内存的实现也不难, 对命令进行解析之后, 先求出表达式的值. 但你还没有实现表达式求值的功能, 现在可以先实现一个简单的版本: 规定表达式
EXPR
中只能是一个十六进制数
首先要先将两个参数解析出来,然后调用paddr_read函数打印内容即可。
static int i;
for(i=0;i<len;i++){
printf("%lx:%08lx\n",addr,paddr_read(addr,4));
addr += 4;
}
3. 表达式求值
1.首先识别出表达式中的单元
2.根据表达式的定义完成递归求值
3.1 词法分析
为了实现表达式求值的功能,首先要把表达式中的单元全部识别出来,这个过程会使用到正则表达式编写字符的匹配规则。词法分析在函数中,有一个TODO
标明了你要写的地方。
关于正则表达式的学习可以参考正则表达式 – 教程 | 菜鸟教程 (runoob.com)
简易调试器在初始化时候会通过init_regx函数将字符识别规则编译。在框架代码中, 一条规则是由正则表达式和token类型组成的二元组。首先要在enum中补充完整的token类型。
enum
{
/* TODO: Add more token types */
TK_NOTYPE = 256,TK_EQ = 255,TK_NEQ = 254,TK_AND = 253,TK_OR = 252,
TK_HNUM = 251,TK_DNUM = 250,TK_NEG = 257,TK_REG = 258,TK_DEREF = 259
};
然后在结构体rule中,编写字符的识别规则。
static struct rule
{
const char *regex;
int token_type;
} rules[] = {
/* TODO: Add more rules.
* Pay attention to the precedence level of different rules.
*/
{" +", TK_NOTYPE}, // spaces
{"==", TK_EQ}, // equal
{"!=", TK_NEQ}, // not equal
};
在表达式求值这一章节中,可以只实现比较简单的匹配:加减乘除,左右括号,空格,十进制整数,十六进制整数。识别出的token会使用make_token函数进行处理,具体行为RTFSC,函数里有一个TODO标明了需要自己完成的地方。我们可以将情况分两类,如果token是空格,那么不存入数组,其他的内容存入数组内。同时对于符号如加减乘除只需要保存它的type就行,如果是数字需要同时保存type和value两个属性。在这一过程中要注意当表达式中的数字过长时,使用int可能会造成buf溢出,需要改变类型或者加一个if判断。
3.2 递归求值
递归求值部分个人认为是PA1的第一个难点(没怎么写过算法我是fw),当时也在这里卡了一段时间。讲义中已经提供了表达式求值的递归定义和框架代码,如下所示:
eval(p, q) {
if (p > q) {
/* Bad expression */
}
else if (p == q) {
/* Single token.
* For now this token should be a number.
* Return the value of the number.
*/
}
else if (check_parentheses(p, q) == true) {
/* The expression is surrounded by a matched pair of parentheses.
* If that is the case, just throw away the parentheses.
*/
return eval(p + 1, q - 1);
}
else {
/* We should do more things here. */
}
}
使用eval函数进行递归求值,p,q为表达式的起始和结束位置。当p>q时,显然是错的,可以用assert进行中止。p=q时,说明是数字或(寄存器),根据类型的不同直接把值return即可。
p<q时,我们首先要进行括号的检查,将外层括号去除。
static bool check_parentheses(int p, int q){
int i,cnt = 0;
if(tokens[p].type != '(' || tokens[q].type != ')')
return false;
for(i = p; i <= q; i++){
if(tokens[p].type == '(')
cnt++;
else if(tokens[q].type == ')')
cnt--;
if(cnt == 0 && i<q)
return false;
}
if(cnt < 0) return false;
return true;
}
然后进行表达式的分裂,在这一过程中,我们需要确定“主符号”,可以认为是表达式中优先级最低的符号就是主符号位。我们将表达式遍历,根据switch表找到优先级最低的主符号。
int oprator_level(int op_type){
switch(op_type){
case '+': return 1;
case '-': return 1;
case '*': return 2;
case '/': return 2;
case TK_NEG: return 3;
case TK_DEREF: return 3;
default:
printf("Undefine oprator\n");
assert(0);
}
}
完成了分裂和括号去除,就可以根据运算符的类型完成最终的求值。
switch (op_type) {
case '+': return val1 + val2;
case '-': return val1 - val2;
case '*': return val1 * val2;
case '/': return val1 / val2;
default: assert(0);
}
完成了表达式求值后,就可以继续实现扫描内存的指令,调用表达式求值计算出EXPR的值即可。
3.3 更进一步
负数和指针解引用我们只需要判定符号的前一位是否是数字或寄存器即可
if(tokens[i].type == '-' && (i == 0 || certain_type(i-1))){
tokens[i].type = TK_NEG;
}
if(tokens[i].type == '*' && (i == 0 || certain_type(i-1))){
tokens[i].type = TK_DEREF;
}
3.4 测试代码
讲义中已经给出了代码和实现方案,只需要按照讲义内容实现即可。将生成好的表达式读入nemu_main中可以使用fopen,具体用法C 库函数 – fopen() | 菜鸟教程 (runoob.com)
这个测试过程其实已经体现了difftest的思想,即将“未测试内容”和“已经完成测试保证正确的内容”进行对比。在后续的pa2和npc的实现中,会实现专门的difftest设施!
4. 监视点
4.1 扩展表达式求值
按照如下进行功能扩展,并添加各符号的优先级
<expr> ::= <decimal-number>
| <hexadecimal-number> # 以"0x"开头
| <reg_name> # 以"$"开头
| "(" <expr> ")"
| <expr> "+" <expr>
| <expr> "-" <expr>
| <expr> "*" <expr>
| <expr> "/" <expr>
| <expr> "==" <expr>
| <expr> "!=" <expr>
| <expr> "&&" <expr>
| "*" <expr> # 指针解引用
4.2 实现监视点
首先用链表组织监视点信息,框架代码给出了监视点的结构体,将内容补充完整
typedef struct watchpoint {
int NO;
struct watchpoint *next;
/* TODO: Add more members if necessary */
char EXPR[32];
word_t VALUE;
} WP;
监视点部分主要由四个函数组成
- new_wp:新建一个监视点,对应指令w
- free_wp:释放对应监视点,对应指令d
- dispaly_wp:打印监视点内容,对应指令info w
- watchpoint_test:检查监视点值是否变化
确定了函数功能之后每个函数就都很好实现了。
5. 编写可维护的代码
当我们的工程很小时,可以很快定位问题。但是当项目工程逐渐变大,定位和维护会越来越困难。
调试的最高境界:不用调试
Programs are meant to be read by humans and only incidentle for computers to excute.
程序首先是拿给人都的,其次才是被机器执行。
编写可维护代码:
- 不言自明:仅看代码就能明白是做什么的(specification)
- 不言自证:仅看代码就能验证实现是对的(verification)
防御性编程——不相信外界的输入/其他函数传递的参数,通过断言提前拦截非预期情况。
减少代码中的隐含依赖——define,kconfig。
编写可复用代码——通过变量,函数,宏等消除重复/相似的代码。
使用合适的语言特性。
6. 思考题
程序从哪里开始执行?谁来指示程序结束?
在c语言的视角里,程序从main开始,到main结束。
在汇编中,以PA2为例,通过看汇编代码可以发现,程序会首先进行进行时环境的初始化,再运行c程序,最后再退出时环境。
cpu_exec()中传入了参数-1。
参数n是无符号整型,-1代表最大的无符号数,在for循环中可以执行最大次数的循环。这样可以保证客户程序内的指令全部执行(默认的客户程序一共有四条指令,所以可以尝试改成大于4的数,一样可以完成运行)
优雅地退出。
搞清楚make run后nemu都做了哪些工作可以定位到is_exit_status_bad函数
int is_exit_status_bad() {
int good = (nemu_state.state == NEMU_END && nemu_state.halt_ret == 0) ||
(nemu_state.state == NEMU_QUIT);
return !good;
}
通过打印good可以发现main函数返回了-1,理解这段代码并相应改动即可。
统计代码行数。
统计所有.c和.h文件
find . -name "*.[hc]" | xargs wc -l
去掉空行
find . -name "*.[hc]" | xargs grep "^." | wc -l
参考资料:
南京大学 计算机科学与技术系 计算机系统基础 课程实验 2022 | 官方文档 (oscc.cc)
计算机系统基础习题课 (2021 秋季学期) (jyywiki.cn)
The Missing Semester of Your CS Education · the missing semester of your cs education (mit.edu)