前言
该实验为导师为研0的我布置的任务,我打算将实验记录下来,一方面将自己的学习过程记录下来,方便自己日后查看,另一方面也希望能为后来者提供帮助(我将实验过程和思考过程详细得记录了下来,应该跟着我的博客走一定可以顺利完成实验)
实验前
实验前最好掌握一下背景知识技能
- 使用过Linux系统(简单使用过就行,不然会比较难受)
- 有一定的C或C++语言基础
- 掌握存储器层次结构相光知识
3.1:课程
导师是让我去b站上学习一门叫 《深入理解计算机系统》csapp的入门通识课程(但是该课程为英文课程,本人英文底子较差,并加上本身对计算机内容了解较少,所以学习过程十分吃力)
我其实觉得王道的计组课程更适合我,内容基本一致,最主要是中文
3.2:参考书籍
王道408的《计算机组成原理》或者《深入理解计算机系统》(机械工业的死亡黑皮书),我是两本都看了,然后将机械工业的课后习题也完成了
实验任务
编写一个csim.c文件,使其实现csim-ref的功能
实验步骤
看完了实验的任务(需求)后我觉得实验大致可以分为下面几步
1. 搭建Ubuntu环境
我是参考了知乎上某个博主的经验,该博主还提供了实验指导书的英文原版和中英文翻译版(翻译是用脚本全篇直接翻译的,我觉得翻译的不怎么样,建议搭配知云翻译软件看英文版)链接在这
2.从命令中拿到所需的参数
我们的程序需要能够读取命令行参数。
参考了其他博客得知可以使用 getopt() 来完成,使用前注意包括 #include <unistd.h>,如果不在 CMU 的机器上运行,还需要加上 #include <getopt.h>
getopt()相关知识可以参考博客园中的一个博文(Linux下getopt()函数的简单使用)
我在查看上述博客学习了相关知识后想去自己的Ubuntu虚拟机上先试一试能否正确获取参数,但是突然发现自己甚至都不知道怎么在Linux系统上运行.cpp文件
查阅博客后发现使用gcc++指令可以编译.cpp文件,但是此时又出现了新的问题(我的Ubuntu系统里面没有gcc++命令)
我使用他提示的sudo apt install gcc命令下载安装gcc,但是却不停得说我网络不可达
我自己分析有两种可能,一种可能是因为我在图书馆,用的手机的热点网络,另外一种可能是因为我的Ubuntu系统不知道安装的时候有没有换源的原因
尝试换源后顺利下载安装了gcc,说明应该是没换源的原因
换源参考了这篇博客,这篇博客是在Ubuntu图形化界面下进行的换源,由于本人对Linux系统也不是很熟悉,担心使用命令换源会把系统搞崩,决定先用图形化界面换源,等实验结束了再学习如何使用命令换源(我使用的是阿里的源)
使用gcc --version查看是否安装gcc成功
实验真是到处碰壁,当我兴致勃勃以为可以顺利运行测试运行我的.cpp测试文件的时候,发现怎么还是不行
查阅博客后发现.c文件才是用的gcc,.cpp文件要用g++
然后安装g++过程基本和安装gcc过程一样
然后使用g++编译测试文件,如下所示,可见生成了可执行文件test_c.
尝试运行该可执行文件
#include<stdio.h>
int main(int argc, char* argv[]) {
printf("666");
return 0;
}
命令为:./test_c
结果如下
然后就是尝试在文件中使用getopt()从命令中拿到所需的参数
#include <stdio.h>
#include <unistd.h>
int main(int argc, char* argv[])
{
char ch;
while ((ch = getopt(argc, argv, "hvs:E:b:t:")) != -1) {
printf("optind: %d\n", optind);
switch (ch) {
case 'h':
printf("HAVE option: -h\n\n");
break;
case 'v':
printf("HAVE option: -v\n\n");
break;
case 's':
printf("HAVE option: -s\n");
printf("The argument of -s is %s\n\n", optarg);
break;
case 'E':
printf("HAVE option: -E\n");
printf("The argument of -E is %s\n\n", optarg);
break;
case 'b':
printf("HAVE option: -b\n");
printf("The argument of -b is %s\n\n", optarg);
break;
case 't':
printf("HAVE option: -t\n");
printf("The argument of -t is %s\n\n", optarg);
break;
case '?':
printf("Unknown option: %c\n\n", (char)optopt);
break;
}
}
}
编译运行后发现成功获取所需参数
3.从文件中读入内容
(问题:这部分内容不怎么熟悉,实验完成后补习下相关知识,特别是输入输出和C字符串的知识)
这部分内容涉及了从文件读的操作,参考了别人的博客后得知可以用fopen()和fscanf()实现
首先我在windows上写了个demo尝试使用fopen()和fscanf()读取文件内容,但是编译器说这个函数不安全
查询资料后发现在第一行加上#define _CRT_SECURE_NO_DEPRECATE即可解决问题
#define _CRT_SECURE_NO_DEPRECATE//加上这一行
#include<stdio.h>
int main() {
char* name[10];
int age;
char* sex[10];
FILE *fp;
if ((fp = fopen("test_fscanf.txt","r")) == NULL) {
puts("Fail to open file!");
}
while (fscanf(fp, " %s %d %s", name, &age, sex) > 0) {
printf("%s %d %s\n", name, age, sex);
}
}
文本test_fscanf.txt的内容为
man_a 18 man
woman_b 20 women
运行结果如下所示
然后尝试编写了test_fscanf.cpp文件
#define _CRT_SECURE_NO_DEPRECATE
#include<stdio.h>
#include <unistd.h>
#include<stdlib.h>
int main(int argc, char* argv[])
{
int s = 0, E = 0, b = 0, verbose = 0;
char option[2];
unsigned long long addr;
int size;
char* tracename;
char ch;
while ((ch = getopt(argc, argv, "hvs:E:b:t:")) != -1) {
printf("optind: %d\n", optind);
switch (ch) {
case 'h':
printf("HAVE option: -h\n\n");
break;
case 'v':
printf("HAVE option: -v\n\n");
break;
case 's':
printf("HAVE option: -s\n");
s = atoi(optarg);
printf("The argument of -s is %d\n\n",
s); // atoi函数把字符串转换为整数:atoi、atol、atoll和atoq
break;
case 'E':
printf("HAVE option: -E\n");
E = atoi(optarg);
printf("The argument of -E is %d\n\n", E);
break;
case 'b':
printf("HAVE option: -b\n");
b = atoi(optarg);
printf("The argument of -b is %d\n\n", b);
break;
case 't':
printf("HAVE option: -t\n");
tracename = (char*)optarg;
printf("The argument of -t is %s\n\n", tracename);
break;
case '?':
printf("Unknown option: %c\n\n", optopt);
break;
}
}
printf("---------------------------------------------------\n\n");
FILE* pFile = fopen(tracename, "r");
while (fscanf(pFile, " %s %llx,%d", option, &addr, &size) > 0) {
printf(" %s %llx,%d\n", option, addr, size);
}
}
运行结果如下所示
由结果可见在正确获取到输入参数的同时,正常获取到了.trace文件中的数据
由于代码行数较多,主函数过于臃肿,将获取输入参数部分写成getOpt函数
(问题:数组作为参数的时候如何进行引用传递?)
void getOpt(int argc, char* argv[], int* s, int* E, int* b, char** tracename) {
//数组作为参数的时候如何进行引用传递?
char ch;
while ((ch = getopt(argc, argv, "hvs:E:b:t:")) != -1) {
printf("optind: %d\n", optind);
switch (ch) {
case 'h':
printf("HAVE option: -h\n\n");
break;
case 'v':
printf("HAVE option: -v\n\n");
break;
case 's':
printf("HAVE option: -s\n");
*s = atoi(optarg);
printf("The argument of -s is %d\n\n",
*s); // atoi函数把字符串转换为整数:atoi、atol、atoll和atoq
break;
case 'E':
printf("HAVE option: -E\n");
*E = atoi(optarg);
printf("The argument of -E is %d\n\n", *E);
break;
case 'b':
printf("HAVE option: -b\n");
*b = atoi(optarg);
printf("The argument of -b is %d\n\n", *b);
break;
case 't':
printf("HAVE option: -t\n");
*tracename = (char*)optarg;
printf("The argument of -t is %s\n\n", *tracename);
break;
case '?':
printf("Unknown option: %c\n\n", optopt);
break;
}
}
}
4.进行 cache 的存储
4.1分析思路
在这之前的实验其实都和cache相关知识无关,只是用到了一些Linux和C语言的相关知识
实验流程图如下(用visio画的,本人十分满意,觉得清楚明了)
4.2定义结构体
最基础的结构体为行,行有的属性如下
- 有效位 valid
- 标识位 tag
存储的数据(由于实验我们不关心块的具体内容,所以可以在行中省去块的部分)- LRU计数位 LRUCount(这是因为该实验使用LRU算法来选择被驱逐的行)
结构体定义代码如下
//行
typedef struct {
int valid; //有效位,1表示有效
int tag; //标识位
int LRUCount; //在一组中LRU最大的行将被替代
};
然后是组(集合)
组可以当作存储了E个行的一维数组
结构体定义代码如下
//组(集合)
typedef struct {
Line* lines; //用于保存一组中行的一维数组
}Set;
- 这个地方我的想法是除了数组外,再额外添加一个lineCount属性,用于对组内的有效行数进行计数,在后续的judgeHit和judgeFull方法中会便利些,但是这样似乎与实际的情况不同,实际的judgeHit操作是与组内所有的行进行有效位和标记位的判定,而不是只与lineCount行相比较,故没有添加该属性
- 与组相关的还有个属性是每个组包含的行数,即E,但是这个属性其实是一个固定值,并且与具体的组无关,在一个缓存内的所有组都有着相同的行数,所以将E当成是组的属性也理所应当,然后E这个变量名不怎么直观,在结构体中我打算用numLine代替
然后是缓存
缓存可以当作是存储了S个组的一维数组
属性有组数numSet(不用S变量名同上E),和numLine
结构体定义代码如下
//缓存
typedef struct {
Set* sets; //用于保存缓存中组的一维数组
int numSet; //一个缓存拥有的组数(S)
int numLine; //一个组拥有的行数(E)
}Cache;
4.3初始化cache(initCache)
获取到参数s,E,b后需要对cache进行初始化
- 进行异常处理(主要指s,E,b是否有负数出现
- numSet = 2 ^ s (这里要注意输入的参数是s而不是S,S = 2 ^ s)
- numLine = E
- 为每个Set分配内存空间
- 为每个set中的Line分配空间,并将valid和LRUCount置0(这里我认为tag不需要进行初始化,可以等到空行第一次进行填写的时候再进行赋值)
(关于上述第5点我最开始的想法是LRUCount也不需要在这里置0,也可以等到空行第一次进行填写的时候再进行赋值,但是后面我发现这样不行,因为我在更新LRUCount的时候是将一组内除了特定某行外的所有行全部+1,如果不先对LRUCount进行初始化的话,可能会出现对未初始化的值进行运算的危险操作)
问题:这里出现的危险操作似乎也不会有影响,因为未初始化说明LRUCount是一个特定随机数,我只有在组内行已经满了,需要进行驱逐的时候会用到LRUCount,行满了说明在此之前的每一行都已经将LRUCount置0了,所以其实也没什么影响
(然后我又发现tag似乎也需要提前进行初始化,因为在判断是否命中的时候需要对比所有行的tag,无论valid是否为1,但是我发现可以通过&&的短路性质,先判断valid是否为1,如果valid的值为1才会去对比tag,如果valid为0则不会执行后面的tag是否相等)(然后我很快发现这样想其实是错的,虽然我将tag位初始化置0,避免了随机的tag碰巧碰上操作地址tag的情况,但是实际上置0其实也会导致碰巧碰上操作地址tag为0的情况,所以其实初始化与否其实没什么区别,实际上,有效位就是为了解决这个问题而产生的)
问题:短路性质对于先比较tag在比较valid同样有效,哪个放在前面是否会对性能产生影响呢,因为我觉得对于一个充分利用的缓存来说,valid应该多数情况下都是1吧,如果先比较valid那么有很大的概率需要进行两次判断运算才能确定是否命中了,如果是先比较tag的话,大概率的情况下是不会相同的,就可以避免第二次的比较valid的运算,可以减少一半的判断
初始化代码如下
void initCache(int s, int E, int b, Cache* cache)
{
if (s < 0 || E <= 0 || b < 0) {//注意这里s和b可以等于0,而E不能等于0
exit(0);
} else {
cache->numSet = 1 << s;//2的次方可以通过移位操作实现
cache->numLine = E;
//为cache中的sets分配内存空间
cache->sets = (Set*)malloc(sizeof(Set) * cache->numSet);
if (!cache->sets) exit(0);
//为每个set中的lines分配空间
for (int i = 0; i < cache->numSet; i++) {
cache->sets[i].lines = (Line*)malloc(sizeof(Line) * cache->numLine);
//将每个line中的valid置0
for (int j = 0; j < cache->numLine; j++) {
cache->sets[i].lines[j].valid = 0;
}
}
}
}
在主函数中加入相关的测试代码如下
getOpt(argc, argv, &s, &E, &b,&tracename);
initCache(s, E, b, &cache);
printf("The numLine of cache is %d\n\n", cache.numLine);
printf("The numSet of cache is %d\n\n", cache.numSet);
printf("The valid of line is %d\n\n", cache.sets[0].lines[0].valid);
printf("The argument of -t is %s\n\n", tracename);
printf("------------------------------------------------------\n\n");
测试结果正确无误
4.4获取组号和标识位
初始化cache后就是循环读取.trace文件中操作内存的指令
根据操作地址判断组号和标识位
首先要清楚地址的构成
内存与缓存之间传递数据是以块为最小单位的
最后地址的最后b位为块内偏移,我相信这应该大家都没什么问题
(对于为什么最左边t位是标识位,而中间s位是组索引,为什么不是反过来这个问题可以在机械工业的的那本深入理解计算机系统第三版的P432页中获取答案)
然后s位是set索引,用来判断在哪个集合中查找
其余位都是标志位(tag),用来组内匹配
注:这个地方有个假设是操作地址+操作空间大小不会跨越块边界(这是实验指导书说的,不是我说的,这个假设使得一次操作只会影响一个块,减少了实验的复杂性)
获取组号(getSet)
根据上述分析,组号为上述图中的绿色部分,要获取该部分数据可通过位操作实现
先将地址右移b位,去除块内偏移位,然后使用s位的掩码进行&操作即可
//获取组索引
int getSet(int addr, int s, int b) {
int set = addr >> b; //去除块内偏移
int mask = (1 << s) - 1; //生成s位的掩码
return mask & set;
}
这里要生成s位掩码,我开始最直接的想法是搞个循环相加,但是我看到了别的博主的操作很有意思
将1左移s位然后再减1,就得到了最后s位全为1的掩码,以后也可以这样用
获取标识位(getTag)
这就很简单了,就将地址位右移s+b位即可
//获取标识位
int getTag(int addr, int s, int b) {
//注意C/C++中的右移操作是算术右移,如果符号位位1,补充的数为1,所以先转换成无符号数再进行右移
addr = (unsigned)addr;
return addr >> (s + b);
}
这里要注意C/C++中的右移操作是算术右移,如果符号位位1,补充的数为1,所以先转换成无符号数再进行右移
先使用yi.trace进行测试(s=2,E=2,b=3)
运行结果如下,结果正确
4.5读写缓存操作(updateCache)
据上述流程图所示,主要有一下四个操作
更新LRU值(updateLRU)
根据流程图,无论是什么情况都会有更新LRU值的操作,所以将这一系列操作单独作为一个方法
更新一个组内行的LRU值很简单,就将命中行或者新写行或者驱逐行的LRU值置0.然后其他行的LRU值全部+1
这个地方我思考过一个问题,就是需不需要判断valid是否为0,如果是1则+1,如果是0则不管。然后我发现这个考虑是多余的,我需要用到LRU,说明该组已经满了,所有行的valid都是1,所以没必要分情况
//更新LRU
void updateLRU(Cache* cache, int setIndex, int lineIndex) {//注意这里的参数为lineIndex而不是tag
//将所有行的LRUCount置0
for (int i = 0; i < cache->numLine; i++) {
cache->sets[setIndex].lines[i].LRUCount++;
}
//将特定行的LRUCount置0
cache->sets[setIndex].lines[lineIndex].LRUCount = 0;
}
判断是否命中(judgeHit)
获取了setIndex和tag后判断是否命中的流程为将tag和setIndex对应组中的每一行的tag进行对比,如果相同,再看看有效位是否为1,若有效位也为1,则成功命中
//判断是否命中
//命中则返回1,不命中则返回0
int judgeHit(Cache* cache, int setIndex, int tag) {
for (int i = 0; i < cache->numLine; i++) {
if (cache->sets[setIndex].lines[i].tag == tag &&
cache->sets[setIndex].lines[i].valid == 1) {
updateLRU(cache, setIndex, i);
return 1;
}
}
return 0;
}
注:我开始想的是judgeHit方法写纯粹一点,就单纯的判断是否hit,不把updateLRU写在judgeHit中,但是由于该方法已经返回是否命中的变量了,无法返回命中行对应的行号,那么在判定为hit还需要在进行一次查找,才能找到命中行对应的行号,所以就将updateLRU方法写在judgeHit方法内了
判断组是否满了judgeFull
如果未命中则需要判断该组是否已满,如果未满则简单,在空白行处填写该行即可,若已满则需要使用LRU算法判断最久未使用的行进行替换
判断是否已满很简单,看看有没有行的valid == 0即可
//判断组是否已满
//未满则返回下标最小的行的下标,已满则返回-1
int judgeFull(Cache* cache, int setNum) {
for (int i = 0; i < cache->numLine; i++) {
if (cache->sets[setNum].lines[i].valid == 0) {
return i;
}
}
return -1;
}
寻找被替换的行
如果组内已经满了则需要寻找LRU最大的行进行替换
不用考虑只需要比较valid为1的行,因为需要替换的前提是已经满了,已经满了说明所有行的valid都是1
//寻找被替换的行
//返回被替换行在lines中的下标
int eviction(Cache* cache, int setNum) {
int max = -1;
int replaceLine = 0;
for (int i = 0; i < cache->numLine; i++) {
if (cache->sets[setNum].lines[i].LRUCount > max) {
max = cache->sets[setNum].lines[i].LRUCount;
replaceLine = i;
}
}
return replaceLine;
}
整合上面四个操作
整合上面四个操作,得到最终的updateCache方法
流程思路可参考之前的流程图
//每次读写缓存的操作
void updateCache(Cache* cache, int setIndex, int tag) {
if (judgeHit(cache, setIndex, tag)) {
//命中操作
hitCount++;
printf("hit");
}
else {
//未命中操作
missCount++;
printf("miss ");
int writeLine = judgeFull(cache, setIndex);
if (writeLine != -1) {
//未满操作
cache->sets[setIndex].lines[writeLine].valid = 1;
cache->sets[setIndex].lines[writeLine].tag = tag;
updateLRU(cache, setIndex, writeLine);
} else {
//已满,驱逐操作
evictionCount++;
printf("eviction ");
int replaceLine = eviction(cache, setIndex);
cache->sets[setIndex].lines[replaceLine].valid = 1;
cache->sets[setIndex].lines[replaceLine].tag = tag;
updateLRU(cache, setIndex, replaceLine);
}
}
}
5.在主函数中调用上述函数实现csim-ref的功能
int main(int argc, char* argv[])
{
int s = 0, E = 0, b = 0, verbose = 0;
char option[2];
unsigned long long addr;
int size;
char* tracename;
Cache cache;
getOpt(argc, argv, &s, &E, &b,&tracename);
initCache(s, E, b, &cache);
printf("------------------------------------------------------\n\n");
FILE* pFile = fopen(tracename, "r");
while (fscanf(pFile, " %s %llx,%d", option, &addr, &size) > 0) {
printf("%s %llx,%d\n", option, addr, size);
if (option[0] == 'I') {
continue;
}
int setIndex = getSet(addr, s, b);
int tag = getTag(addr, s, b);
if (option[0] == 'L' || option[0] == 'S') {
updateCache(&cache, setIndex, tag);
}
if (option[0] == 'M') {
updateCache(&cache, setIndex, tag);
updateCache(&cache, setIndex, tag);
}
}
printf("hitCount = %d\n", hitCount);
printf("missCount = %d\n", missCount);
printf("evictionCount = %d\n", evictionCount);
return 0;
}
6.测试结果
有很多个.trace文件,我这里就使用一个普通难度的.trace文件和最大的那个.trace文件做测试吧
这是我在别处找的别的博主的标准答案
对于(s,E,b)=(2,1,3),使用trans.trace进行测试,结果如下
与csim-ref结果相同
对于(s,E,b)=(5,1,5),使用long.trace进行测试,结果如下
与csim-ref结果相同
至此,深入理解计算机系统的cachelab(lab5)Part A部分结束
感想
这是导师第一次给我布置的任务,对于这次的任务,我以为会很难,但是学起来发现挺简单的,没有了绩点压力的学习似乎比起之前快乐了很多,希望今后的任务也能顺利的完成