CSAPP深入理解计算机系统 Lab2(bomb Lab) 详解

CSAPP深入理解计算机系统 Lab2(bomb Lab) 详解

本文以记录个人学习CSAPP的过程,使用blog的方式记录能更专注的思考,不至于走马观花式的做实验。同时我关注的不仅是实验本身还有实验中使用工具的学习。

实验说明

本实验通过逆向的方式模拟拆炸弹的过程,炸弹共有6道锁,我们需要逐一破解每一道锁,最终拆除炸弹。我们可以通过执行./bomb开始输入密码,也可以把密码输入到任意文件中作为参数传递给./bomb,例如./bomb passwords.txt

在动手实验之前需要仔细阅读实验说明, 其中重点阅读Hints的章节,这里提供一些提示

  1. gdb, gdb是一个命令行调试器,我们可以用gdb查看程序的汇编并且轻易的查看内存。这里有一个CMU的简明gdb使用教程
  2. objdump -t ,这个命令可以打印出炸弹的符号表,符号表会打印出所有函数名和函数地址。我们可以对程序的整体有一个直观的认识,同时快速定位到函数的位置。
  3. objdump -d ,这个命令会直接打印出整个程序的汇编指令
  4. prints 这个命令会打印出可打印的字符串。

还有一些工具能够提供官方文档:

  1. apropos,能够检索并列出本地相关关键词的文档
  2. man,能够提供相应的官方文档,使用man ascii能方便获取到各个字符的ascii码

声明:本文后续转换的代码并被严格的C代码,而是描述思路的伪代码,并没有严格区分指针和值等的表述关系

实验具体流程

准备工作

实验提供一个c版本的框架可以让我们快速了解程序的框架,我们看到整个程序中的六个函数是phase_{1..6},如果输入字符正确则会拆除炸弹。

1. 逆向产生汇编代码并保存
objdump -d > source.S

对于这个项目,我们需要关注其.text字段,为了方便我们可以删除其他字段。

objdump -t bomb > table.txt

这里对其做一些解释。

0000000000400ac0	l	d		.init  0000000000000000	.init
0000000000000000	l	df	*ABS*  0000000000000000	bomb.c

根据其manual中的解释

Here the first number is the symbol’s value (sometimes referred to as its address). The next field is actually a set of characters and spaces indicating the flag bits that are set on the symbol. These characters are described below. Next is the section with which the symbol is associated or ABS if the section is absolute (ie not connected with any section), or UND if the section is referenced in the file being dumped, but not defined there. After the section name comes another field, a number, which for common symbols is the alignment and for other symbol is the size.Finally the symbol’s name is displayed.

  1. 第一列是标识符的地址或者值。
  2. 第二列是一个标记位,具体标记规则见其manual文档,这里给出几个常见的标记 :l代表本地,g代表全局,f代表文件,F代表函数,O代表对象。
  3. 第三列是标识符对应的段,其中*ABS*代表绝对的位置,UND代表没有定义在当前程序中。
  4. 地四列是对齐方式或变量大小
  5. 最后是标记符名
2.使用strings获取
strings bomb > strings.txt

将数据存储在strings.txt中以便后续使用。

3.整体逻辑分析

定位到其main的函数的字段,分析其执行逻辑,观察到如下代码段

  400e19:	e8 84 05 00 00       	call   4013a2 <initialize_bomb>
  400e1e:	bf 38 23 40 00       	mov    $0x402338,%edi
  400e23:	e8 e8 fc ff ff       	call   400b10 <puts@plt>
  400e28:	bf 78 23 40 00       	mov    $0x402378,%edi
  400e2d:	e8 de fc ff ff       	call   400b10 <puts@plt>
  400e32:	e8 67 06 00 00       	call   40149e <read_line>

在这里是炸弹的初始化阶段,edi寄存器用于传递参数作为存储数据的位置,也就是说这里是输出程序的第一个炸弹提示语的地方,我们可以通过gdb进行验证。

gdb bomb

进入gdb界面后我们通过b main将断点设置在main开始处,并通过r,执行到断点处。执行如下

(gdb) x/s 0x402338
0x402338:   "Welcome to my fiendish little bomb. You have 6 phases with"

验证和推理一致,我同样可以在Strings.txt中找到这个字符串,这样我们就知道几个工具的用途,接下来就需要灵活应用来拆除炸弹了。

phase_1

第一个密码的破解我们首先定位到main函数中的

  400e32:	e8 67 06 00 00       	call   40149e <read_line>
  400e37:	48 89 c7             	mov    %rax,%rdi
  400e3a:	e8 a1 00 00 00       	call   400ee0 <phase_1>

以下部分是对程序的深入探索,单纯解题可以跳到分割线后。


我们知道这里将这里将函数read_line的返回值地址作为函数phase_1的参数传递

先看到函数read_line,根据函数名我们大致推测这是简单读入一行字符,但是不知道是否还有其他操作。根据其中调用的函数skip理解其含义(理解的过程中可以对照table.txt中的变量确定大小和位置,借用gdb确定其值),将skip写出伪代码大概如下:

skip(){
  do{
  	//这里有一段很令人迷惑的汇编会在最后彩蛋部分解释
		...
    rdi = 0x603780;
    rdx = (stdin);
    esi = 80;
    // 如果读到函数会返回地址,如果未读到返回空
    rax= fgets(addr= rdi, size= esi, stream = rdx); 
    rbx = rax;
    if(rax == 0) break; //没有读到行
    rdi = rax;
    eax = blank_line(rdi); //判断是否为空行
  }while(eax != 0)
  rax = rbx;
  return rax;
}

skip会自动跳过空行读取到第一个有数据的行,或者错误的行。

read_line()有大量代码用于检测各种输入问题,同时隐藏细节,在彩蛋部分会解释,这里只需要知道这是一个读入一行字符串的函数即可


接下来正式进入phase_1的代码

我们发现在调用strings_not_equal时,他仅仅是将0x402400传递给第二个参数esi,而第一个参数是传入函数的参数可以写出如下伪代码

phase_1(rdi){
	esi = 0x402400;
	eax = strings_not_equals(rdi, esi);
	if(eax != 0){
		explode_bomb(); //爆炸
	}
}

看到strings_not_equals的代码可以转化为

strings_not_equals(rdi, rsi){
	//第一个字符串
	rbx = rdi;
	//第二个字符串
	rbp = rsi;
	eax = string_length(rdi);
	//r12d 存长度
	r12d = eax;
	rdi = rbp;
	eax = string_length(rdi);
  if(eax != r12d) retrun eax = edx = 1;
	eax = *(rbx);
	for(al != 0){
		if(al != *(rbp)) retrun eax = edx = 1;
		rbx++;
		rbp++;
		eax = *(rbx);
	}
	return eax = edx = 0;
}

其实就是正常的判断是否相等的代码。

所有只需要通过gdb找到0x402400位置的字符串即可

(gdb) x/s 0x402400
0x402400:       "Border relations with Canada have never been better."

这个字符串即结果

phase_2

调用函数前的过程详见phase_1的解析,我们直接关注到调用的函数内部。

函数中调用read_six_number函数的解析如下

这个函数其实让我们看到机器在参数多于寄存器能表达的数量是所做的工作,利用栈来保存结果指针的位置。该函数所做的工作就是将rsi传递过来的参数(也就是需要存储的指针)分别存在6个变量中以便sscanf调用时能够存储到正确的位置。

首先我们看到sscanf的声明如下

int sscanf(const char *restrict str, const char *restrict format, ...);

而这里传递的第二个参数对应于format,我们通过gdb查看对应的寄存器$esi位置0x4025c3可知

(gdb) x/s 0x4025c3
0x4025c3:       "%d %d %d %d %d %d"

所需要解析的是6个32位整数,对应每个变量大小为4B,也和代码中以4B为基准将指针记入寄存器相同。

我们可以看到6个整数指针的对应关系如下:

str			$rdi
format	$rsi
arg0		$rdx
arg1		$rcx
arg2		$r8
arg3		$r9
arg4		($rsp)
arg5		($rsp + 8)

这样就能把6个整数从字符串中解析到指针$rsi所指向的地址中,下面看回phase_2可以表示为如下伪代码

phase_2(rdi){
	rsi = rsp;
	read_six_numbers(rdi, rsi); //将6个数据从rdi字符串中读入rsp位置中
	if(rsp[0] != 1) {
		explode_bomb();
	}
	
	for(i=1; i<6; ++i){
		if(rsp[i] != rsp[i-1] + rsp[i-1]){
			explode_bomb();
		}
	}
}

所有可以知道第二个密码应该是

1 2 4 8 16 32

phase_3

开始读取两个整数的操作和phase_2完全相同,后面的部分核心的语句为

 400f75: ff 24 c5 70 24 40 00  jmp    *0x402470(,%rax,8)

我们在gdb里打印对应内存信息如下

(gdb) x/x *0x402470
0x400f7c <phase_3+57>:  0xb8

说明这里对应的就是<phase_3+57> + 8 * $rax,而观察发现下面的代码正好每两行占用内存空间为8B,所有这就是一个switch语句。伪代码如下

phase_3(rdi){
	rdx = rsp + 8;
	rcx = rsp + 12;
	rax = sscanf(rdi, rsi, rdx, rcx);
	if(rax > 1 && (rsp+8) > 7){
		switch((rax)){
		case 0:
			eax = 0xcf;
			break;
		..  //后续省略
		}
		if( (rsp + 12) != eax){
			explode_bomb();
		}
	}else{
		explode_bomb();
	}
}

所有本题的答案有八组,任意选择其中的一组即可,我选择 0 207

phase_4

本题前面读入两个整数的过程和phase_3相同,为便于分析逻辑可以直接替换变量名,容易转换为如下形式

phase_4(str){
	sscanf(str, "%d %d", a, b);
	if(a > 15){
		explode_bomb();
	}
	ans = func4(a, 0, 15);
	if(ans != 0 || b != 0){
		explode_bomb();
	}
}

下面重点放在func4函数中,我们看到这个函数需要三个参数,分析函数后可以将其转化为以下递归函数,

func4(rdi, rsi, rdx){
	eax = (rdx) - (rsi);
	ecx = (unsigned)(eax) >> 31;
	eax += ecx;
	eax >> 1;
	ecx = rax + rsi + 1;
	if(ecx <= edi){
		rdx = rcx - 1;
		rax = func4(rdi, rsi, rdx);
		rax = rax * 2;
	}else if(ecx == edi){
		rax = 0;
	}else{
    esi = rcx + 1;
    rax = func4(rdi, rsi, rdx);
    rax = rax * 2 + 1;
	}
	return rax;
}

在根据调用是的限制条件知道可以转化为

func4(x, y, z){
	tmp = z - y + (z < y ? 1 : 0);
	tmp >>= 1;
	tmp += y;
	//tmp 实际上就是 (y + z) / 2;
	if(tmp > x){
		ans = func4(x, y, tmp - 1);
		ans = ans * 2;
	}else if(tmp == x){
		ans = 0;
	}else{
    ans = func4(x, tmp + 1, z);
    ans = ans * 2 + 1;
	}
	return rax;
}

可以看出其实就是一个二分查找的算法,而让ans为0的方法是,始终保证x,在中值的左边,直到找到中间值为x时。

始终选择左边的中间值如下:

( 0 + 15 )/ 2 = 7
( 0 + 7  ) / 2 = 3
( 0 + 3  ) / 2 = 1
( 0 + 1  ) / 2 = 0

所有最终第一个数可以是7,3,1,0,第二个数为0

phase_5

本题有一点真正破解的的感觉了,通过汇编代码会发现,整个过程分为三个阶段,我将逐一解释,为方便起见,我会给字符串以a,b,c命名。

1. 确保读入的字符串a长度为6,并进入一个循环

这里的代码没有什么特别的,但是有一个与解题无关的小细节小细节,

  40106a:	64 48 8b 04 25 28 00 	mov    %fs:0x28,%rax
  401071:	00 00 
  401073:	48 89 44 24 18       	mov    %rax,0x18(%rsp)
  ...
  4010de:	64 48 33 04 25 28 00 	xor    %fs:0x28,%rax
  4010e5:	00 00 
  4010e7:	74 05                	je     4010ee <phase_5+0x8c>
  4010e9:	e8 42 fa ff ff       	call   400b30 <__stack_chk_fail@plt>

这里实际上是在栈中偏移为0x18的地方放置一个用于验证的验证码,确保在函数返回是能找到正确的返回地址,防止出现通过读入字符串而破坏栈,从而植入非法地址的问题。

2. 通过a获取新字符串

核心代码如下

  40108b:	0f b6 0c 03          	movzbl (%rbx,%rax,1),%ecx
  40108f:	88 0c 24             	mov    %cl,(%rsp)
  401092:	48 8b 14 24          	mov    (%rsp),%rdx
  401096:	83 e2 0f             	and    $0xf,%edx
  401099:	0f b6 92 b0 24 40 00 	movzbl 0x4024b0(%rdx),%edx
  4010a0:	88 54 04 10          	mov    %dl,0x10(%rsp,%rax,1)

我们可以看到,其中rax相当于字符串的偏移量,取出的字符串也只需要其低位的字,又以该字为偏移量到0x4024b0中取出一个字,并放在栈中的指定位置。

我们通过gdb打印出0x4024b0处的字符串(可以看作是一个对应的哈希表)

(gdb) x/s 0x4024b0
0x4024b0 <array.3449>:  "maduiersnfotvbylSo you think you can stop the bomb with ctrl-c, do you?"

我们指需要前16个字符,因为取出低位最多访问16个字符

抽象出的代码如下

char * str = "xxxxxx" //输入的字符串
const char * hash = "maduiersnfotvbyl"; //0x4024b0
char ans[7]; //0x10(%rsp)
for(i = 0; i < 6; ++i){
  ans[i] = hash[str[i] & 0xf];
}
ans[6] = '\0';  //4010ae: movb   $0x0,0x16(%rsp)
3. 比较生成的字符串和秘密表

最终的密码放在0x40245e的位置处,通过gdb可知为flyers,因此只要能让我们输入的字符串作为键产生相应的值即可。具体如下

'f' <=> hash[0x9]
'l' <=> hash[0xd]
'y' <=> hash[0xc]
'e' <=> hash[0x5]
'r' <=> hash[0x6]
's' <=> hash[0x7]

通过键入man ascii找到任意对应的值字符即可,我选择的是IONEFG

phase_6

在最开始的部分和phase_2一样通过字符串将6个整数读入栈中,接下来的代码将分为以下几个部分

1. 双重循环

首先看代码从 0x40110e0x401151的部分。

这里的r13代表外层循环的位置,r12d代表外层指针索引,可以抽象为以代码

int arr[6]; //6个数
for(i = 0; i < 6; ++i){
  if(arr[i] < 1 || arr[i] > 6){
    explode_bomb();
  }
  for(j = i; j < 6; ++j){
    if(arr[i] == arr[j]){
      explode_bomb();
    }
  }
}

说明这6个数互不相同其都在[1,6]之间,也就是1到6的排列。(难怪官方要限制测试数量,这里写个脚本暴力测试只需要最多5! = 120次就可以测出答案)

2. 数据修改

代码从 0x4011530x40116d的部分,将数据都用7减去

for(i = 0 ; i < 6; ++i){
  arr[i] = 7 - arr[i];
}
3.链表查询

代码从 0x40116f0x4011a9的部分。

我们看到rdx存储的实际上是0x6032d0,而其变换方式是mov 0x8(%rdx),%rdx,也就是说将代码当前位置偏移8B位置下的数据作存入rdx中,实际上就是

struct node{
	long data; //数据为8B
	struct node* next;
}

这样一个结构的链表的遍历方式。

而在代码中又通过arr[i]作为其控制遍历的位置,并将地址存入栈中,可以将这部分代码表示为

struct node*  keys[6]; //0x20(%rsp)
struct node* first = 0x6032d0; //头结点位置
for(int i = 0 ; i < 6; ++i){
	int times = arr[i] - 1;
	struct node * tmp = first;
	while(times--){
		tmp = tmp -> next;
	}
	keys[i] = tmp;
}
4.将结点值重新赋值

代码从 0x4011ab0x4011d0的部分。

在这里rcx可以理解为新链表的当前指针。

struct node* current = keys[0];
while(1){
	long rdx = keys[i]->data;
	current->next->data = rdx;
	i++;
	if(i >= 6) break;
	current = key[i];
}

这一步相当于将链表的值按照keys中的顺序重新赋值

5.判断结点是否满足关系

代码从 0x4011da0x4011f5的部分。

可以将rbx看作当前指针current,这里有个坑在于虽然数据存储的8B,但是在比较时却只使用其中4位。

int i = 5;
current = keys[0];
while(i--){
  struct node* next = current->next;  // rax
  if((int)curret->data < (int)next->data){ //
  	explode_bomb();
  }
  current = next;
}

可以看出需要数据在链表中顺序排列

6.通过分析结果反推密码

我们通过gdb以此查找处链表中的数据如下

node1: 0x014c
node2: 0x00a8
node3: 0x039c
node4: 0x02b3
node5: 0x01dd
node6: 0x01bb

所以需要保证其数据为排列为3 4 5 6 1 2,又因为在第2步用7将数据去补数,所以转换后为4 3 2 1 6 5

结束了?(彩蛋)

将所有结果按行写入文件key.txt后,执行./bomb key.txt,会得到以下结果

执行结果

但是在看汇编代码时可以看到其实本实验还有一个隐藏部分,在phase_defused中会尝试从有个这样sscanf(0x603870, "%d %d %s", rdx, rcx, r8)的函数调用,输入串中读入一个"%d %d %s"的数据,并比较其中的%s是否为"DrEvil",如果符合条件将进入隐藏函数secret_phase

我们看到这个函数 secret_phase中只需判断函数fun7($6030f0, input) == 2即可,函数fun7()表示如下

//x($edi) y($esi)
int fun7(void* x, void* y){
	if(x == 0){
		return 0xfffffffff;
	}
	if(*(int *)x > *(int *)y){//part1
		return fun7(x+0x8, y) * 2; 
	}else if(*(int *)x == *(int *)y){//part2
		return 0;
	}else if(*(int *)x < *(int *)y){//part3
		return fun7(x+0x10, y)*2 + 1;
	}
}

为保证达到目的,画出递归栈可知道,需要保证递归栈中的顺序为part2 part3 part1 (从栈顶向下看)即可。使用gdb查看内存情况如下

(gdb) x/16x 0x6030f0    //需要知道 n1 + 8 存储的地址
0x6030f0 <n1>:  0x24    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x6030f8 <n1+8>:        0x10    0x31    0x60    0x00    0x00    0x00    0x00   0x00

(gdb) x/24x 0x603110		//需要知道 n21 + 16 存储的地址
0x603110 <n21>: 0x08    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x603118 <n21+8>:       0x90    0x31    0x60    0x00    0x00    0x00    0x00    0x00
0x603120 <n21+16>:      0x50    0x31    0x60    0x00    0x00    0x00    0x00    0x00

(gdb) x/8x 0x603150			//需要知道 n32存储的值
0x603150 <n32>: 0x16    0x00    0x00    0x00    0x00    0x00    0x00    0x00

由此知结果应该表示为22(即0x16)

想要触发并解题,应在任意一道做对后一行输入x x DrEvil (前面两个数字目前看来为任意值)。现在问题转化成如何将这个字符串放在0x6030f0的内存中。

这让我想到第一次读到skip开始那一段没有看懂的代码(可以回看到phase_1部分)。现在抱着目的性的看就豁然开朗。这里实际上是通过记录输入字符串的数量作为偏移量的,将每个读入的字符串都存在内存中的,相当于一个字符串堆,计算过程如下

0x603870 - 0x603780 = 240  //偏移总量
240 >> 4 = 240 / 2^4 = 15  //对应shl操作
15 / 5 = 3 //对应lea

说明在第4个字符串时应该写入这个串,那么读入这个串后如何读入正常的第4个串,其实对于sscanf它根据给定格式匹配,所以第四个字符1 0 后加入能DrEvil即可。不得不说这样的设计还是很精妙的。

总结

最后贴图测试结果

测试结果

对于这个实验,我是以直接阅读逆向的汇编代码为主。实际上以做题为目的,将GDB作为主要调试工具更快,更高效。我认为lab2的特点在于循序渐进,它的前后铺垫做的很好,整体做下来对汇编的理解会提升不少。欢迎大家留言讨论,共同学习。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 深入理解计算机系统(CSAPP)是由Randal E. Bryant和David R. O'Hallaron编写的经典计算机科学教材。该教材通过涵盖计算机体系结构、机器级别表示和程序执行的概念,帮助学生深入理解计算机系统的底层工作原理和运行机制。 深入理解计算机系统的练习题对于学生巩固并应用所学知识非常有帮助。这些练习题涵盖了计算机硬件、操作系统和编译器等多个领域,旨在培养学生解决实际问题和设计高性能软件的能力。 对于深入理解计算机系统的练习题,关键是通过实践进行学习。在解答练习题时,应根据课本提供的相关知识和工具,仔细阅读问题描述,并根据实际需求设计相应的解决方案。 在解答练习题时,需要多角度思考问题。首先,应准确理解题目要求,并设计合适的算法或代码来解决问题。其次,应考虑代码的正确性和效率,以及对系统性能的影响。此外,还要注意处理一些特殊情况和异常情况,避免出现潜在的错误或安全漏洞。 解答练习题的过程中,应注重查阅相关资料和参考优秀的解答。这可以帮助我们扩展对问题的理解,并学习他人的思路和解决方法。同时,还可以通过与同学和老师的讨论,共同探讨问题和学习经验。 总之,通过解答深入理解计算机系统的练习题,可以帮助学生巩固所学知识,同时培养解决实际问题和设计高性能软件的能力。这是一个学以致用的过程,可以加深对计算机系统运行机制和底层工作原理的理解。 ### 回答2: 理解计算机系统(CSAPP)是一本经典的计算机科学教材,通过深入研究计算机系统的各个方面,包括硬件、操作系统和编程环境,对于提高计算机科学专业知识与能力具有很大帮助。 练习题是CSAPP中的重要部分,通过练习题的完成,可以加深对计算机系统的理解,并将理论知识转化为实践能力。练习题的数量、难度逐渐递增,从简单的概念与基础问题到复杂的系统设计与实现。 在解答练习题时,首先需要对题目进行仔细阅读和理解,明确题目的要求和限制条件。然后,可以利用课堂讲解、教材内容、网络资源等进行查阅和学习相应的知识。同时,还可以参考课后习题解答等资料,了解一些常见的解题方法和思路。 在解答练习题时,可以利用计算机系统的工具和环境进行实际测试和验证。例如,可以使用调试器、编译器和模拟器等工具对程序或系统进行分析和测试。这样可以更加深入地理解问题的本质,并找到恰当的解决方法。 另外,解答练习题时还可以与同学、教师和网上社区进行交流和讨论。这样可以互相学习和交流解题思路,共同解决问题。还可以了解不同的解题方法和技巧,提高解题效率和质量。 练习题的解答过程可能会遇到一些困难和挑战,例如理论知识的不足、复杂问题的分析与解决。但是通过不断地思考和实践,相信可以逐渐提高解题能力,更好地理解计算机系统。 总之,深入理解计算机系统(CSAPP)练习题是提高计算机科学专业知识和能力的重要途径。通过仔细阅读和理解题目,查阅相关知识,利用计算机系统工具和环境进行实践,与他人进行交流和讨论,相信可以更好地理解计算机系统的各个方面,并将知识转化为实际能力。 ### 回答3: 《深入理解计算机系统(CSAPP)》是计算机科学领域的经典教材之一,对于深入理解计算机系统的原理、设计和实现起到了极大的帮助。在阅读这本书的过程中,书中的习题也是非常重要的一部分,通过做习题,我们可以更好地理解书中所讲的概念和思想。 CSAPP的习题涵盖了课本中各个章节的内容,从基础的数据表示和处理、程序的机器级表示、优化技术、程序的并发与并行等方面进行了深入探讨。通过解答习题,我们可以对这些知识进行实践应用,巩固自己的理解,并培养自己的解决问题的思维方式。 在解答习题时,我们需要充分理解题目要求和条件,并从知识的角度进行分析。有些习题可能需要进行一些编程实践,我们可以通过编程实现来验证和测试我们的思路和解决方案。在解答问题时,我们还可以查阅一些参考资料和网上资源,充分利用互联网的学习资源。 在解答习题时,我们需要保持积极的思维和态度。可能会遇到一些困难和挑战,但是通过坚持和努力,我们可以克服这些困难,提高我们的解决问题的能力。同时,我们还可以通过与同学或者其他人进行讨论,相互分享解题经验和思路,从而更好地理解问题。 综上所述,通过深入理解计算机系统(CSAPP)的习题,我们可以进一步巩固和深化对计算机系统的理解。掌握这些知识,不仅可以提高我们在计算机领域的能力,还可以为我们未来的学习和职业发展奠定重要的基础。因此,认真对待CSAPP的习题,是我们在学习计算机系统知识中不可或缺的一部分。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值