读书笔记之C语言三剑客之《C专家编程》
2021-01-31 20:37:57 阿炯
不少人学C语言是直接看书,比如看国内谭浩强写的《C语言程序设计》。首先你需要一本好的入门书,那么《C primer plus》一定能帮到你,读完C primer plus,你已经对C有一定了解了,那么你应该读读这本《C程序设计语言》;C语言之父为作者之一,本书精炼,简洁,绝对值得一读。另外,C语言三剑客亦是广大C语言学习者必读经典,读完之后,就能认识到一个全然不同的C语言。它们分别是《C和指针》、《C专家编程》、《C缺陷与陷阱》。学完三剑客,对C语言的使用已经已经有足够的了解了。那么是时候来深入学习一下更深层次的内容了,那么下面两本书你一定不能错过。它们是《程序员的自我修养》和《深入理解计算机系统》。
C语言的设计哲学:
一切工作程序员自己负责。
语言中的所有特性都不需要隐式的运行时支持。
程序员所做的都是对的。
程序员应该知道自己在干什么,并保证自己的所作所为是正确的。
第1章--C: 穿越时空的迷雾
小即是美。事物发展都有个过程,由简入繁,不能一开始就想得太复杂,Multics,IBM的OS/360都是因此而失败。
C语言的许多特性是为了方便编译器设计者而建立的。----唉,怎么这个样子
C语言的基本数据类型直接与底层硬件相对应。----确实如此
register关键字,这个设计可以说是一个失误,如果让编译器在使用各个变量时自动处理寄存器的分配工作,显然比一经声明就把这类变量在生命周期内始终保留在寄存器里要好,使用register关键字,简化了编译器,却把包袱丢给了程序员。
C编译器不曾实现的一些功能必须通过其他途径实现----标准I/O库和C预处理器。
在宏扩展中,空格会对扩展的结果造成很大的影响。宏后面不可加';',它不是C语句。宏最好只用于命名常量,并为一些适当的结构提供简捷的记法。宏名应该大写这样便很容易与函数调用区分开来。
const关键字原先如果命名为readonly就好多了。
const int *p;是指不能够通过通过p来改变int的值,即:*p = 30和p[3] = 4都是错误,但p是可以改变。
const int *与int *是相容的,都是指向int的指针;const int **与int **不相容,前者是指向const int *的指针,int **是指向int *的指针。
尽量不要在你的代码中使用无符号类型,以免增加不必要的复杂性。只有在使用位段和二进制掩码时,才可以使用无符号数。应该在表达式中使用强制类型转换,使操作数均为有符号数,或者无符号数,这样就不必由编译器来选择结果的类型。有个例子,在ANSI C中,-1 < (unsigned char)1为真,而-1 < (unsigned int)1 为假。
第2章--这不是Bug,而是语言特性
进步——是计算机软件工程和编程语言设计艺术逐步发展的重要动因。这也是为什么C++语言令人失望的原因:它对C语言中存在的一些最基本问题没有什么改进,而它对C语言最重要的扩展(类)却是建立在脆弱的C类型模型上。
按照C语言的理念,程序员应该知道自己在干什么,而且保证自己的所作所为是正确的。
多做之过:fall through作为switch的默认行为是个失误;相邻的字符串自动合并成一个字符串;太多的缺省可见性,全局可见,一个大型函数一群“内部”函数不得不在该函数的外部进行定义。没有人会记得在它们之前加上static限定符,所以他们在缺省情况下是全局可见的。
误做之过:
C语言中符号重载:static 在函数内部,表示该变量的值在各个调用间一直保持延续性;在函数这一极,表示该函数只对文本文件可见。extern用于函数定义表示全局可见(属于冗余),用于变量,表示它在其他地方定义。
运算符优先级存在的问题:.优先级高于*, p.f表示(p.f);函数()高于*;==和!=高于位运算符(val & mask != 0)表示val & (mask != 0);==和!=高于赋值符,c = getchar() != EOF表示c = (getchar() != EOF);算数运算符高于移位运算符 msb<<4 + lsb表示msb<<(4+lsb);逗号最低。
有些专家建议在C语言中记牢两个优先级就够了:乘除先于加减,在涉及其他的操作符时一律加括号。
结合性,在几个操作符具有相同优先级时决定先执行哪一个。
计算的次序之所以未定义,是想让编译器充分利用自身架构的特点,或者充分利用存储于寄存器的值。
如果对于堆栈的每次访问之前都要检查其大小和访问权限,对于软件来说代价太大了,根本不可行。
gets(char *s),不检查缓冲区的空间,而fgets(char *s, int n, FILE *stream)可以对读入的字符数设置一个上限n。fgets对缓冲大小进行限制的方式,更为安全。
少错之过,标准参数的处理以及把lint程序错误的从编译器中分离出来。
Lint Early, Lint Often Lint is your software conscience. It tells you when you are doing bad things. Always use lint. Listen to your conscience.gcc as lint,使用-Wall:enable a bunch of warning。gcc --help=warning查询。
linux上可以使用splint。
让充满Bug的代码快速通过编译实在是不划算。----我习惯于写过代码后用眼睛看一遍,确认无误后再编译调试,看来以后可以在中间加上一步用lint检查。
大型缓冲区如果闲置不用是非常浪费空间的。
如果程序员可以在同一代码块中同时进行malloc和free操作,内存管理是最轻松的。
深刻教训:即使可以保证你的编程语言100%可靠,你仍然可能成为算法中灾难的牺牲品。----确实如此,学好算法。
第3章--分析C语言的声明
声明器(declarator), 就是标识符以及与它组合与它组合在一起的任何指针,函数括号,数组下标等。以下形式: 标识符
或 标识符[下标]
或 标识符(参数)
或 (声明器)
----注意括号不能乱加,就两个地方可以加括号
声明格式:类型说明符 声明器[,声明器];
类型说明符: int char void等
存储类型: extern static register auto
类型限定符: const volatile
理解C语言声明的优先级规则
A 声明从它的名字开始读取,然后按照优先级顺序依次读取。
B 优先级从高到底依次是:
B.1 声明中被括号括起来的那部分
B.2 后缀操作符:
括()表示一个函数,
[]表示这是一个数组。
B.3 前缀操作符:
*表示指向...的指针
C 如果const和(或)volatile关键字与类型说明符(如int,long等)相邻,它作用于类型说明符;其他情况下const和(或)volatile关键字作用于它左边紧邻的指针*号。
用优先级规则分析C语言声明:
char * const *(*next)();
char *(* c[10])(int **p);
如果需要频繁地对整个数组进行赋值操作,可以通过把它放入struct中。
在调用函数中,参数传递时首先尽可能地存放到寄存器中(追求速度)。
union也可以把同一个数据解释成两种不同的东西,不用强制类型转换。
typedef和宏文本替换之间存在一个关键性的区别:typedef看成是一种彻底的"封装"类型——在它声明后不能再往里面增加别的东西。首先,可以用其他类型说明符对宏类型名进行扩展,但对typedef所定义的类型名称不能这样做。typedef int banana; unsigned banana i; /*错误!非法 */;其次连续几个变量声明。
----由于typedef由编译器解释的,而宏是由预处理器解释的
typedef void (*ptr_to_func)(int);//这样来定义函数指针的别名。
不要为了方便起见对结构使用typedef,这样做唯一的好处是能使你不必书写struct关键字,但这个关键字可以向你提示一些信息。
应该始终在struct的定义中使用结构标签,即使它并非必须。这种做法可以使代码更为清晰。结构标签的名字可以取一个以"_tag"结尾的名字。
C语言中存在多种名字空间:
标签名(label name)
标签(tag):这个名字空间用于所有的结构、枚举和联合
成员名:每个结构或联合都有自身的名字空间
其他
在同一个名字空间,任何名字必须具有唯一性。
----C中也有名字空间,没注意啊。
第4章--令人震惊的事实:数组和指针并不相同
extern对象声明告诉编译器对象的类型和名字,对象的内存分配则在别处进行。
X = Y;
在这个上下文环境里,符号X的含义是X所代表的地址。这被成为左值。
在这个上下文环境里,符号Y的含义是Y所代表的地址的内容。这被称为右值。
左值在编译时可知,左值表示存储结果的地方。
右值直到运行时才知。如无特别说明,Y的值是指右值。
数组名是个左值,但不是可修改的左值。
指针是间接寻址,数组名是直接寻址,这就是两者在访问数据时的区别。指针的值是运行时从内存取得的,数名的值是编译时已经确定的。
专业的C程序员必须熟练的掌握malloc()函数,并且学会用指针操纵匿名内存。
第5章--对链接的思考
动态链接优点:
1.可执行文件的体积小,节省磁盘空间和虚拟内存。
2.所有动态链接到某个特定函数库的可执行文件在运行时共享该函数库在内存中的一个单独拷贝。
只使用动态链接。
gcc创建动态链接库和使用
创建:gcc tomato.c -fPIC -shared -o libfruit.so
使用:gcc test.c -Wl,--rpath,. -L. -lfruit
这样只要a.out和libfruit.so放在同一个目录就可以了
与位置无关的代码(position-independent code),对于共享库显得格外有用,因为每个使用共享库的进程一般都会把它映射到不同的虚拟地址(尽管共享同一份物理拷贝),只要修改一下偏移量表就可以了。
grep很有用啊!
始终将-l函数库选项放在编译命令行的最右边。
警惕Interpositoning。缺省全局作用域。
准则:不要让程序中的任何符号成为全局的,除非有意把他们作为程序的接口之一。
ldd程序print shared library dependencies。
第6章--运动的诗章:运行时数据结构
编程语言理论的经典对立之一就是代码和数据的区别。
代码和数据的区别也可以是编译时和运行时的分界线。编译器的绝大部分工作都跟翻译代码有关;必要的数据存储管理的绝大部分都在运行时进行。
linux可执行文件用文件第一个字节来标注,7F开头,紧跟后面的是"ELF",代表Executable and Linking Format.
可执行文件由文本段、数据段和bss段组成,运行size a.out可查看各段大小。
bss段保存没有值的变量,事实上只是,给出了运行时所需要的bss段大小。
运行时数据结构有好几种:堆栈,过程活动记录,数据,堆等。
堆栈有3个用处:
堆栈为函数内部声明的局部变量提供存储空间。
进行函数调用时,堆栈存储与此有关的一些维护信息。
堆栈也可以被看作暂时存储区。比如计算表达式,存储中间结果。
alloca()函数分配的内存位于堆栈中,函数结束后自动销毁。
发现数据段和文本段的位置,以及位于数据段中的堆,方法是声明位于这些段的变量,并打印它们的地址。
过程活动记录:局部变量,参数,指向先前结构的指针,返回地址。
Fedora上测了下,一个只有一个int参数的函数调用,要用32个字节,参数4个,返回地址4,esp和ebp其他不知道。fame.h中是汇编,没太看懂。
编译器的设计者会尽可能地把过程活动记录的内容放到寄存器中,这样可以提高速度。
static变量保存在数据段,而不是堆栈中。
auto关键字几乎没什么用处,因为它只能用于函数内部,但是在函数内部声明的数据缺省就是这种分配。
setjmp和longjmp,在C++中变异为更普通的异常处理机制“catch”和“throw”。
对于如何在进程中支持不同的控制线程,只要简单地为每个控制线程分配不同的堆栈即可。
有用的C语言工具:
indent 代码缩进工具
默认GNU风格,使用-kr选项按K&R风格。还有各种各样选项,可以定制。
语法: indent [选项] [源文件列表]
indent [选项] [源文件] [-o 输出文件]
ldd 用来查看程式运行所需的共享库,常用来解决程式因缺少某个库文件而不能运行的一些问题。
nm 打印目标文件的符号表。
strace 工具:trace system calls and signals
用法:strace [选项] command
gdb---哈哈,常用
time显示程序所使用的实际时间和CPU时间
gprof列出程序的运行时分析图。
标准的代码优化技巧包括:消除循环,函数代码就地扩展,公共子表达式消除,改进寄存器分配,省略运行时对数组边界的检查,循环不变量代码移动,操作符长度削减(把指数操作转变为乘法操作,把乘法操作转变为移位操作或加法操作)等。
第7章--对内存的思考
内存泄漏(leak)检查工具:
mtrace
valgrind
malloc所分配的内存通常会圆乘为下一个大于申请数的2的整数次方。
总线错误,几乎都是由于未对齐的读或写引起的。----目前linux好像不出现错误
段错误是由于MMU(内存管理单元,负责支持虚拟内存的硬件)的异常所致,而该异常通常是由于解除引用(查看指针所指地址的内容)一个未初始化或非法值的指针引起的。
Keep it Simple, Stupid !
条件操作符简洁,允许我们高高兴兴的在一行内写下代码,而无需不必要的代码膨胀。
最可能导致段错误的常见编程错误是:
坏指针的错误。free(p);后值空 p = NULL;
改写错误。如数组越界。
指针释放引起的错误。
第8章--为什么程序员无法分清万圣节和圣诞节
很无厘头的开始。
类型提升:在任何表达式中,并不局限于涉及操作符和混合类型的操作数的表达式。
char, 位段, enum, unsigned char, short, unsigned char -> int
float -> double
任何数组 -> 相应类型的指针。----注意
函数的参数也是表达式,所以也会发生类型提升。不用函数原型,会先提升再自动剪裁。
如果使用了函数原型,缺省参数提升就不会发生,与实际类型相符合。----但数组到指针的提升仍会发生
不需要按回车键就能得到一个字符,单字符I/O----用于游戏编程,这个我就不看了
有限自动机(FSM)可以用作程序的控制结构。它的基本思路是用一张表保存所有可能的状态,并列出进入每个状态时可能执行的所有动作,其中最后一个动作就是计算(通常在当前状态和下一次输入字符的基础上,另外再经过一次表查询)下一个应该进入的状态。你从一个“初始状态”开始。在这一过程中,翻译表可能告诉你进入了一个错误的状态,表示一个预期之外的或错误的输入。你不停地在各种状态间转换,直到到达结束状态。
在C语言中,有好几种方法可以用来表达FSM,但他们绝大多数都是基于函数指针数组。一个函数指针数组可以像下面这样声明:
void (*state)MAX_STATES;
debugging hooks
调试器调试时可以调用函数,比如gdb用call 函数名,对于复杂的数据结构可以编写一个函数,用于遍历数据结构并打印出来。----时过境迁,现在强大的GUI调试器,这个已不怎么有用了。
可调式性编码
可调式性编码意味着把系统分成几个部分,先让程序总体结构运行。只有基本的程序能够运行之后你才能为那些复杂的细节完善、性能调优和算法优化进行编码。
有时候,花点时间把编程问题分解成几个部分往往是解决它的最快办法。----确实得花时间,动脑筋来分解。
作者描写其同事,写散列表就是个例子啊。最初,使散列函数返回0,这样所有元素都存储于第0个位置后面的链表中。----这使得程序很容易调试
复杂类型转换,先写一个对象的声明,然后删去标识符,最后放在左面,如int (*compar)(int *)。
不加类型说明符,声明变量默认是int;函数默认返回值是int, 一般放在eax(第一个寄存器)中。int几乎是C语言所有的默认方式。应该也是C最善于处理的数据类型。
qsort函数原型:void qsort(void *base, size_t count, size_t size, int (*compar)(const void* element1, const void *element2));
compar函数参数可以定义为(const void *)类型,这需要在compar函数内部cast为所处理类型;也可以直接定义为所处理类型的指针,在调用qsort函数时需要将compar函数cast为(int (*)(const void *, constvoid *),一开始我以为这样不正确(因为qsort函数内部还是会调用compar的,这样类型就不匹配了啊),其实是正确的,因为这种类型检查是编译时做的(gcc 使用-c选项),链接时不做类型检查,只要能找到那个函数名就行,运行时取参数更不管这些东西了,是用ebp+offset直接抓来的。
第9章--再论数组
数组的声明就是数组,指针的声明就是指针,两者不能混淆。声明与定义必须对应。
对于编译器而言,一个数组就是一个地址,一个指针就是一个地址的地址。----左值
什么时候数组和指针是相同的?
C语言标准对此作了如下说明:
规则1. 表达式中的数组名(与声明不同)被编译器当作一个指向该数组第一个元素的指针。
规则2. 下标(subscript)总是与指针的偏移(an offset from a pointer相同
规则3. 在函数参数的声明中,数组名被编译器当作指向该数组的第一个元素的指针----这里数组是指一维数组
指针有类型限制,是因为编译器需要知道对指针进行解除引用时应该取几个字节,以及每个下标的步长。
sizeof(数组名)结果是数组所占字节数(真正的数组,不是函数形参),由此可见是可以数组名包含了长度信息,并可以通过sizeof取得,所以C中检查数组是否越界访问是能够做到的,但是很容易用指针避开,就像用指针可以修改const一样。我觉得编译器可以打开一个选项,是否检查数组越界访问。
把作为形参的数组和指针等同起来是出于效率原因的考虑。在C语言中,所有非数组形式数据实参均以传值形式。如果要copy整个数组,无论在时间上还是内存空间上的开销都可能是非常大的。
int apricot[2][3][5]; // apricot 两个[3][5]的数组,2*3个[5]的数组,2*3*5个int
int (*p)[3][5] = apricot; // 步长 3 * 5
int (*r)[5] = apricot[0]; // 步长 5
int *t = apricot[0][0]; // 步长 1
int u = apricot[0][0][0];
指向数组第一个元素的指针与数组名等同。
内存中数组的布局
C语言中,最右边的下标最先变化,这个约定被称为"行主序"。
只有字符串常量才可以初始化指针数组,因为可执行文件中字符串常量是作为数据存储。而161这样的字面常量只出现在代码中。
数组与指针可交换性的总结:
用a[i]这样的形式对数组进行访问总是被编译器”改写“或解释为像*(a+i)这样的指针访问。
指针始终就是指针。它绝不可以改写成数组。只是可以使用下标形式访问指针。
在特定上下文中,也就是指针作为函数的参数(也就只有这种情况--注意),一个数组的声明可以看作是一个指针。作为函数参数的数组始终会被编译器修改成为指向数组中第一个元素的指针。
第10章--再论指针
数组和指针参数是如何被编译器修改的?
“数组名被改写成一个指针参数”规则并不是递归定义的。数组的数组会被改写成“数组的指针”,而不是“指针的指针”。
数组的数组 char c[8][10]; char (*c)[10]; 数组的指针
指针数组 char *c[15]; char**c; 指针的指针
指针的指针 char **c; char **c; 不改变----指针与指针不用修改
数组的指针 char (*c)[64]; char (*c)[64]; 不改变----注意,指向一个长度为64的char数组的数组名的指针,访问数组中元素这样做:(*c)[0]。
int a[20];
int **p = &a; // 错误,指针的指针与数组的指针不兼容
int (*t)[20] = &a; // 正确,t为由20个int的数组的指针。
----此处括号是必须的,因为[]的优先级比*高
Iliffe向量,创建一个一维数组,数组中的元素是指向其他东西的指针。
例如main(int argc, char *argv[]),第二个参数会被改写成char **。(注意,只有把二维数组改为一个指向向量的指针数组的前提下才可以这么做!)
在C语言中,传递多维数组必须提供除最左面一维以外的所有维的长度。
可以放弃多维数组的形式,提供自己的下标方式,如char_array[row_size*i + j] = ...
模拟动态数组,当表满后,用realloc()对数组重新分配内存,并确保realloc操作成功。
重分配操作很可能把原先的整个内存块移到一个不同的位置,这样表格中元素的地址便不再有效。为了避免麻烦,应该使用下标而不是元素的地址。----这也是STL中引入迭代器的一个原因吧
“增加”和“删除”操作都必须通过函数来进行,这样才能维持表的完整性。
第11章--你懂得C,所以C++不在话下
类内部定义的函数是inline函数
重载是编译时解析的。
多态——运行时绑定。late binding
new和delete操作符,用于取代malloc()和free()函数,能够自动完成sizeof的计算工作,并会自动调用合适的构造函数和析构函数。new能真正的创建一个对象,malloc()函数只是分配内存。
C++的设计受限于严格的兼容性、内部一致性和高效率。
复用是软件科学的一个崇高而又朦胧的目标。----很多时候不如另起炉灶从头开始
管理和市场状况是导致许多公司破产的原因,比单纯的技术失败更为常见。那些不时刻注意顾客需求的公司终究难以为继,最能掌握这项艺术的公司往往能获得成功。
附录A--程序员工作面试的秘密
面试的关键在于正确理解问题!你需要仔细地听,如果不理解问题或者觉得它的定义不清,可以要求一个更好的解释。
提供一种寻找可靠答案的好方法。
链表环的检测。
mango[i++] += y; // i++仅执行一次
优秀的程序员将会休息的更好,精力更加充沛,而蹩脚的程序员则很可能困得脑袋常常和桌子打架。
人类的最高目标是奋斗、寻求、创造。
本文转自杨源鑫的博客,感谢原作者。
在介绍完最具代表的编程语言后,就不得不提计算机系统方面的经典书箱。
《深入理解计算机系统》是理解计算机系统首选书目,是10余万程序员的共同选择。卡内基-梅隆、北京大学、清华大学、上海交通大学等国内外众多知名高校选用指定教材。从程序员视角全面剖析的实现细节,使读者深刻理解程序的行为,将所有计算机系统的相关知识融会贯通。
本书主要介绍了计算机系统的基本概念,包括最底层的内存中的数据表示、流水线指令的构成、虚拟存储器、编译系统、动态加载库,以及用户应用等。书中提供了大量实际操作,可以帮助读者更好地理解程序执行的方式,改进程序的执行效率。此书以程序员的视角全面讲解了计算机系统,深入浅出地介绍了处理器、编译器、操作系统和网络环境,是这一领域的权威之作。适合作为计算机及相关专业的本科生教材,同时也适用于编程人员参考阅读。
第一章是提纲挈领性质的一章,从一个 hello world 程序来引出 C、引出 UNIX、Linux ,引出 C 语言程序的编译过程,引出高速缓存、系统的硬件组成、存储结构、虚拟内存、网络编程、并发编程。第一章的内容虽然不难理解,却是能让人涨自信心的一章,这一章能让你产生一种激动的心情,因为内容不难,但是却能够开拓你的思路,把你的知识体系串联起来。
第一部分
第二章的内容比较枯燥,个人认为需要一定的 C 语言基础,这里推荐两本 C 语言的书吧,第一本书 《C Primer Plus》 这本书是把 C 语言揉碎了手把手教你,另外《C 程序设计语言》也不错。
第二章主要讲的是计算机中数据的存储方式,基本的数据类型和各种补码、反码的表示方式。总之看这一章如果不了解 C 的话,有点昏昏欲睡(原因是可能不太懂),但实际上第二章写的很不错,当你阅读了本书的第二章之后,也许会发现,你所谓的(懂)只是你的一厢情愿而已,数值系统远没有你想象的那么简单。
第三章是程序的机器级表示,如果你把 C 学的差不多了之后(其实如果单看第二章的话,把 C 语言数据类型、运算补充一下即可),接下来你要学习一下汇编了,否则根本看不懂第三章在说什么。你可能想知道诸如 pushq、movq、call、popq、ret、%rbx、%rdx 等等都是干嘛的,还有汇编是如何编写的。而且你还要懂 C 语言。这么看来这本书根本就不是一本为初学者准备的书,也可以说是初学者的劝退书。
第四章又是一个打开新世界大门的一章,这章会直接从各种电路开始搞起,马上就直接奔硬件了!主要讲了 X86-64 体系、各种门电路、引出计算机流水线的设计。毕竟现代微处理器可以称得上是人类创造出的最复杂的系统之一。也会给你讲解各种指令集的区别。
与这一章节相关的书籍,可以看看《计算机组成与设计:软件/硬件接口》还有《编码》这两本书,都非常好,非常透彻。
这一章也会聊指令集架构,这些架构和宏观意义上的应用层架构不一样,非常复杂,比如下面这个 ARM 架构:
第五章:优化程序性能,现在普遍意义上提到的各种优化,不论是架构层优化、指令集优化等核心都离不开这一章所介绍的内容。优化的难点在于你需要对系统有充分理解,当然了在你做优化之前首先要保证原始程序功能正确(并且有回归测试),否则一切都是徒劳。比如你需要了解现在系统存在的性能瓶颈,才能系统性的进行优化,你才能够编写高效的程序。
编写高效程序需要做到下面这几点:
选择适当的数据结构和算法。
编写出编译器能够有效优化以转换成可执行代码的源代码。
任务拆分,采用并行计算的方式。
第六章:存储器层次结构,这一章会向你介绍存储技术的发展,磁盘、主存、高速缓存的性能差距到底有多大,然后介绍局部性原理,一项非常强大的缓存技术。高速缓存读写是如何映射的,高速缓存不同参数的性能影响,如何编写高速缓存又好的代码,存储器山是个什么概念,以及你会见到封面的插图。
看本章的时候强烈建议把 Ulrich Drepper 撰写的长达 114 页的经典论文 What Every Programmer Should Know About Memory 看了。
以上就是 CSAPP 的第一部分,第一部分主要介绍了程序和硬件之间的交互关系。
而第二部分则专注于程序和操作系统之间的交互关系,会学到如何使用操作系统提供的服务来构建系统级程序。
第二部分
第七章:链接,在使用 Linux 的时候,很多情况下会出现很多难以理解的错误,其中很多都是链接错误。链接分为静态链接和动态链接,我们写的 C 程序在执行的过程中都会经过链接阶段。
除了这一章内容之外,大家也可以看一下一本把链接讲的非常透彻的一本书:《程序员的自我修养 -- 链接,装载与库》,主要讲授代码指令是如何保存的,库文件如何与应用程序代码静态链接,应用程序如何被装载到内存中并开始运行,动态链接如何实现,C/C++运行库的工作原理,以及操作系统提供的系统服务是如何被调用的。非常好的一本国产书。
第八章:异常控制流,世界上不会存在完美运行的程序,任何程序都会出错,这些错误可能是线程执行过程中出错、可能是系统调用异常、页面映射错误等等。这一章会向你介绍各种异常出现之后,操作系统是如何处理的。
第九章:虚拟内存,虚拟内存其实是存储器层次结构的衍生,至于为什么单独拿一章来说,因为虚拟内存太重要了。这一章会向你介绍为什么我们的计算机内存只有 8G(或其他)却能够运行自身数倍以上的程序。虚拟内存有的时候也是面试官比较爱问的一个点:虚拟内存是如何映射的,什么是页框、页表诸如此类。
第三部分
第三部分主要介绍程序间的相互通信,主要包括 IO、网络编程和并发编程。
IO 这部分介绍类 Unix 系统下的 I/O 读写,主要介绍系统层面的 I/O 接口。
如今互联网都立足于 TCP/IP 协议之上,Socket 甚至已经成为了网络编程的同义词。这部分主要向你介绍了网络的变迁,什么客户端-服务器编程模型、Web 服务器,最后再带你写一个 Web 服务器。
网络这部分内容远比这一章节介绍的复杂,网络这部分内容给大家推荐几本书:《计算机网络:自顶向下方法》、《TCP/IP 详解》,《UNIX 网络编程》。
一定要知道的是 W. Richard Stevens和他的个人网站。
并发这一章节主要介绍了 C 中如何编写并发程序,如何榨干 CPU ,让其发挥峰值性能。读 csapp 这本书还是需要一定基础的,而且读起来不是那么容易。不过业界还是有一些好资源,能让你更快的深入这本书。给大家推荐这个网站。可以理解为是 CSAPP 的导读网站,对每一章都进行了介绍,而且推荐了一些不错的资源。
最最最重要的就是做实验,你可以在相关页面上找到 csapp 的所有实验。
Randal E.Bryant:1981年在麻省理工学院获计算机科学博士学位,现任美国卡内基·梅隆大学计算机学院院长,是ACM和IEEE的双会士,多次获得这两个协会颁发的大奖。Bryant教授从事计算机系统方面的教学工作已超过20年,结合计算机体系结构课程多年的教学经验,他开始把关注点从如何设计计算机转移到如何使程序员在更好地了解系统的情况下编写出更有效、更可靠的程序上来。
C语言的设计哲学:
一切工作程序员自己负责。
语言中的所有特性都不需要隐式的运行时支持。
程序员所做的都是对的。
程序员应该知道自己在干什么,并保证自己的所作所为是正确的。
第1章--C: 穿越时空的迷雾
小即是美。事物发展都有个过程,由简入繁,不能一开始就想得太复杂,Multics,IBM的OS/360都是因此而失败。
C语言的许多特性是为了方便编译器设计者而建立的。----唉,怎么这个样子
C语言的基本数据类型直接与底层硬件相对应。----确实如此
register关键字,这个设计可以说是一个失误,如果让编译器在使用各个变量时自动处理寄存器的分配工作,显然比一经声明就把这类变量在生命周期内始终保留在寄存器里要好,使用register关键字,简化了编译器,却把包袱丢给了程序员。
C编译器不曾实现的一些功能必须通过其他途径实现----标准I/O库和C预处理器。
在宏扩展中,空格会对扩展的结果造成很大的影响。宏后面不可加';',它不是C语句。宏最好只用于命名常量,并为一些适当的结构提供简捷的记法。宏名应该大写这样便很容易与函数调用区分开来。
const关键字原先如果命名为readonly就好多了。
const int *p;是指不能够通过通过p来改变int的值,即:*p = 30和p[3] = 4都是错误,但p是可以改变。
const int *与int *是相容的,都是指向int的指针;const int **与int **不相容,前者是指向const int *的指针,int **是指向int *的指针。
尽量不要在你的代码中使用无符号类型,以免增加不必要的复杂性。只有在使用位段和二进制掩码时,才可以使用无符号数。应该在表达式中使用强制类型转换,使操作数均为有符号数,或者无符号数,这样就不必由编译器来选择结果的类型。有个例子,在ANSI C中,-1 < (unsigned char)1为真,而-1 < (unsigned int)1 为假。
第2章--这不是Bug,而是语言特性
进步——是计算机软件工程和编程语言设计艺术逐步发展的重要动因。这也是为什么C++语言令人失望的原因:它对C语言中存在的一些最基本问题没有什么改进,而它对C语言最重要的扩展(类)却是建立在脆弱的C类型模型上。
按照C语言的理念,程序员应该知道自己在干什么,而且保证自己的所作所为是正确的。
多做之过:fall through作为switch的默认行为是个失误;相邻的字符串自动合并成一个字符串;太多的缺省可见性,全局可见,一个大型函数一群“内部”函数不得不在该函数的外部进行定义。没有人会记得在它们之前加上static限定符,所以他们在缺省情况下是全局可见的。
误做之过:
C语言中符号重载:static 在函数内部,表示该变量的值在各个调用间一直保持延续性;在函数这一极,表示该函数只对文本文件可见。extern用于函数定义表示全局可见(属于冗余),用于变量,表示它在其他地方定义。
运算符优先级存在的问题:.优先级高于*, p.f表示(p.f);函数()高于*;==和!=高于位运算符(val & mask != 0)表示val & (mask != 0);==和!=高于赋值符,c = getchar() != EOF表示c = (getchar() != EOF);算数运算符高于移位运算符 msb<<4 + lsb表示msb<<(4+lsb);逗号最低。
有些专家建议在C语言中记牢两个优先级就够了:乘除先于加减,在涉及其他的操作符时一律加括号。
结合性,在几个操作符具有相同优先级时决定先执行哪一个。
计算的次序之所以未定义,是想让编译器充分利用自身架构的特点,或者充分利用存储于寄存器的值。
如果对于堆栈的每次访问之前都要检查其大小和访问权限,对于软件来说代价太大了,根本不可行。
gets(char *s),不检查缓冲区的空间,而fgets(char *s, int n, FILE *stream)可以对读入的字符数设置一个上限n。fgets对缓冲大小进行限制的方式,更为安全。
少错之过,标准参数的处理以及把lint程序错误的从编译器中分离出来。
Lint Early, Lint Often Lint is your software conscience. It tells you when you are doing bad things. Always use lint. Listen to your conscience.gcc as lint,使用-Wall:enable a bunch of warning。gcc --help=warning查询。
linux上可以使用splint。
让充满Bug的代码快速通过编译实在是不划算。----我习惯于写过代码后用眼睛看一遍,确认无误后再编译调试,看来以后可以在中间加上一步用lint检查。
大型缓冲区如果闲置不用是非常浪费空间的。
如果程序员可以在同一代码块中同时进行malloc和free操作,内存管理是最轻松的。
深刻教训:即使可以保证你的编程语言100%可靠,你仍然可能成为算法中灾难的牺牲品。----确实如此,学好算法。
第3章--分析C语言的声明
声明器(declarator), 就是标识符以及与它组合与它组合在一起的任何指针,函数括号,数组下标等。以下形式: 标识符
或 标识符[下标]
或 标识符(参数)
或 (声明器)
----注意括号不能乱加,就两个地方可以加括号
声明格式:类型说明符 声明器[,声明器];
类型说明符: int char void等
存储类型: extern static register auto
类型限定符: const volatile
理解C语言声明的优先级规则
A 声明从它的名字开始读取,然后按照优先级顺序依次读取。
B 优先级从高到底依次是:
B.1 声明中被括号括起来的那部分
B.2 后缀操作符:
括()表示一个函数,
[]表示这是一个数组。
B.3 前缀操作符:
*表示指向...的指针
C 如果const和(或)volatile关键字与类型说明符(如int,long等)相邻,它作用于类型说明符;其他情况下const和(或)volatile关键字作用于它左边紧邻的指针*号。
用优先级规则分析C语言声明:
char * const *(*next)();
char *(* c[10])(int **p);
如果需要频繁地对整个数组进行赋值操作,可以通过把它放入struct中。
在调用函数中,参数传递时首先尽可能地存放到寄存器中(追求速度)。
union也可以把同一个数据解释成两种不同的东西,不用强制类型转换。
typedef和宏文本替换之间存在一个关键性的区别:typedef看成是一种彻底的"封装"类型——在它声明后不能再往里面增加别的东西。首先,可以用其他类型说明符对宏类型名进行扩展,但对typedef所定义的类型名称不能这样做。typedef int banana; unsigned banana i; /*错误!非法 */;其次连续几个变量声明。
----由于typedef由编译器解释的,而宏是由预处理器解释的
typedef void (*ptr_to_func)(int);//这样来定义函数指针的别名。
不要为了方便起见对结构使用typedef,这样做唯一的好处是能使你不必书写struct关键字,但这个关键字可以向你提示一些信息。
应该始终在struct的定义中使用结构标签,即使它并非必须。这种做法可以使代码更为清晰。结构标签的名字可以取一个以"_tag"结尾的名字。
C语言中存在多种名字空间:
标签名(label name)
标签(tag):这个名字空间用于所有的结构、枚举和联合
成员名:每个结构或联合都有自身的名字空间
其他
在同一个名字空间,任何名字必须具有唯一性。
----C中也有名字空间,没注意啊。
第4章--令人震惊的事实:数组和指针并不相同
extern对象声明告诉编译器对象的类型和名字,对象的内存分配则在别处进行。
X = Y;
在这个上下文环境里,符号X的含义是X所代表的地址。这被成为左值。
在这个上下文环境里,符号Y的含义是Y所代表的地址的内容。这被称为右值。
左值在编译时可知,左值表示存储结果的地方。
右值直到运行时才知。如无特别说明,Y的值是指右值。
数组名是个左值,但不是可修改的左值。
指针是间接寻址,数组名是直接寻址,这就是两者在访问数据时的区别。指针的值是运行时从内存取得的,数名的值是编译时已经确定的。
专业的C程序员必须熟练的掌握malloc()函数,并且学会用指针操纵匿名内存。
第5章--对链接的思考
动态链接优点:
1.可执行文件的体积小,节省磁盘空间和虚拟内存。
2.所有动态链接到某个特定函数库的可执行文件在运行时共享该函数库在内存中的一个单独拷贝。
只使用动态链接。
gcc创建动态链接库和使用
创建:gcc tomato.c -fPIC -shared -o libfruit.so
使用:gcc test.c -Wl,--rpath,. -L. -lfruit
这样只要a.out和libfruit.so放在同一个目录就可以了
与位置无关的代码(position-independent code),对于共享库显得格外有用,因为每个使用共享库的进程一般都会把它映射到不同的虚拟地址(尽管共享同一份物理拷贝),只要修改一下偏移量表就可以了。
grep很有用啊!
始终将-l函数库选项放在编译命令行的最右边。
警惕Interpositoning。缺省全局作用域。
准则:不要让程序中的任何符号成为全局的,除非有意把他们作为程序的接口之一。
ldd程序print shared library dependencies。
第6章--运动的诗章:运行时数据结构
编程语言理论的经典对立之一就是代码和数据的区别。
代码和数据的区别也可以是编译时和运行时的分界线。编译器的绝大部分工作都跟翻译代码有关;必要的数据存储管理的绝大部分都在运行时进行。
linux可执行文件用文件第一个字节来标注,7F开头,紧跟后面的是"ELF",代表Executable and Linking Format.
可执行文件由文本段、数据段和bss段组成,运行size a.out可查看各段大小。
bss段保存没有值的变量,事实上只是,给出了运行时所需要的bss段大小。
运行时数据结构有好几种:堆栈,过程活动记录,数据,堆等。
堆栈有3个用处:
堆栈为函数内部声明的局部变量提供存储空间。
进行函数调用时,堆栈存储与此有关的一些维护信息。
堆栈也可以被看作暂时存储区。比如计算表达式,存储中间结果。
alloca()函数分配的内存位于堆栈中,函数结束后自动销毁。
发现数据段和文本段的位置,以及位于数据段中的堆,方法是声明位于这些段的变量,并打印它们的地址。
过程活动记录:局部变量,参数,指向先前结构的指针,返回地址。
Fedora上测了下,一个只有一个int参数的函数调用,要用32个字节,参数4个,返回地址4,esp和ebp其他不知道。fame.h中是汇编,没太看懂。
编译器的设计者会尽可能地把过程活动记录的内容放到寄存器中,这样可以提高速度。
static变量保存在数据段,而不是堆栈中。
auto关键字几乎没什么用处,因为它只能用于函数内部,但是在函数内部声明的数据缺省就是这种分配。
setjmp和longjmp,在C++中变异为更普通的异常处理机制“catch”和“throw”。
对于如何在进程中支持不同的控制线程,只要简单地为每个控制线程分配不同的堆栈即可。
有用的C语言工具:
indent 代码缩进工具
默认GNU风格,使用-kr选项按K&R风格。还有各种各样选项,可以定制。
语法: indent [选项] [源文件列表]
indent [选项] [源文件] [-o 输出文件]
ldd 用来查看程式运行所需的共享库,常用来解决程式因缺少某个库文件而不能运行的一些问题。
nm 打印目标文件的符号表。
strace 工具:trace system calls and signals
用法:strace [选项] command
gdb---哈哈,常用
time显示程序所使用的实际时间和CPU时间
gprof列出程序的运行时分析图。
标准的代码优化技巧包括:消除循环,函数代码就地扩展,公共子表达式消除,改进寄存器分配,省略运行时对数组边界的检查,循环不变量代码移动,操作符长度削减(把指数操作转变为乘法操作,把乘法操作转变为移位操作或加法操作)等。
第7章--对内存的思考
内存泄漏(leak)检查工具:
mtrace
valgrind
malloc所分配的内存通常会圆乘为下一个大于申请数的2的整数次方。
总线错误,几乎都是由于未对齐的读或写引起的。----目前linux好像不出现错误
段错误是由于MMU(内存管理单元,负责支持虚拟内存的硬件)的异常所致,而该异常通常是由于解除引用(查看指针所指地址的内容)一个未初始化或非法值的指针引起的。
Keep it Simple, Stupid !
条件操作符简洁,允许我们高高兴兴的在一行内写下代码,而无需不必要的代码膨胀。
最可能导致段错误的常见编程错误是:
坏指针的错误。free(p);后值空 p = NULL;
改写错误。如数组越界。
指针释放引起的错误。
第8章--为什么程序员无法分清万圣节和圣诞节
很无厘头的开始。
类型提升:在任何表达式中,并不局限于涉及操作符和混合类型的操作数的表达式。
char, 位段, enum, unsigned char, short, unsigned char -> int
float -> double
任何数组 -> 相应类型的指针。----注意
函数的参数也是表达式,所以也会发生类型提升。不用函数原型,会先提升再自动剪裁。
如果使用了函数原型,缺省参数提升就不会发生,与实际类型相符合。----但数组到指针的提升仍会发生
不需要按回车键就能得到一个字符,单字符I/O----用于游戏编程,这个我就不看了
有限自动机(FSM)可以用作程序的控制结构。它的基本思路是用一张表保存所有可能的状态,并列出进入每个状态时可能执行的所有动作,其中最后一个动作就是计算(通常在当前状态和下一次输入字符的基础上,另外再经过一次表查询)下一个应该进入的状态。你从一个“初始状态”开始。在这一过程中,翻译表可能告诉你进入了一个错误的状态,表示一个预期之外的或错误的输入。你不停地在各种状态间转换,直到到达结束状态。
在C语言中,有好几种方法可以用来表达FSM,但他们绝大多数都是基于函数指针数组。一个函数指针数组可以像下面这样声明:
void (*state)MAX_STATES;
debugging hooks
调试器调试时可以调用函数,比如gdb用call 函数名,对于复杂的数据结构可以编写一个函数,用于遍历数据结构并打印出来。----时过境迁,现在强大的GUI调试器,这个已不怎么有用了。
可调式性编码
可调式性编码意味着把系统分成几个部分,先让程序总体结构运行。只有基本的程序能够运行之后你才能为那些复杂的细节完善、性能调优和算法优化进行编码。
有时候,花点时间把编程问题分解成几个部分往往是解决它的最快办法。----确实得花时间,动脑筋来分解。
作者描写其同事,写散列表就是个例子啊。最初,使散列函数返回0,这样所有元素都存储于第0个位置后面的链表中。----这使得程序很容易调试
复杂类型转换,先写一个对象的声明,然后删去标识符,最后放在左面,如int (*compar)(int *)。
不加类型说明符,声明变量默认是int;函数默认返回值是int, 一般放在eax(第一个寄存器)中。int几乎是C语言所有的默认方式。应该也是C最善于处理的数据类型。
qsort函数原型:void qsort(void *base, size_t count, size_t size, int (*compar)(const void* element1, const void *element2));
compar函数参数可以定义为(const void *)类型,这需要在compar函数内部cast为所处理类型;也可以直接定义为所处理类型的指针,在调用qsort函数时需要将compar函数cast为(int (*)(const void *, constvoid *),一开始我以为这样不正确(因为qsort函数内部还是会调用compar的,这样类型就不匹配了啊),其实是正确的,因为这种类型检查是编译时做的(gcc 使用-c选项),链接时不做类型检查,只要能找到那个函数名就行,运行时取参数更不管这些东西了,是用ebp+offset直接抓来的。
第9章--再论数组
数组的声明就是数组,指针的声明就是指针,两者不能混淆。声明与定义必须对应。
对于编译器而言,一个数组就是一个地址,一个指针就是一个地址的地址。----左值
什么时候数组和指针是相同的?
C语言标准对此作了如下说明:
规则1. 表达式中的数组名(与声明不同)被编译器当作一个指向该数组第一个元素的指针。
规则2. 下标(subscript)总是与指针的偏移(an offset from a pointer相同
规则3. 在函数参数的声明中,数组名被编译器当作指向该数组的第一个元素的指针----这里数组是指一维数组
指针有类型限制,是因为编译器需要知道对指针进行解除引用时应该取几个字节,以及每个下标的步长。
sizeof(数组名)结果是数组所占字节数(真正的数组,不是函数形参),由此可见是可以数组名包含了长度信息,并可以通过sizeof取得,所以C中检查数组是否越界访问是能够做到的,但是很容易用指针避开,就像用指针可以修改const一样。我觉得编译器可以打开一个选项,是否检查数组越界访问。
把作为形参的数组和指针等同起来是出于效率原因的考虑。在C语言中,所有非数组形式数据实参均以传值形式。如果要copy整个数组,无论在时间上还是内存空间上的开销都可能是非常大的。
int apricot[2][3][5]; // apricot 两个[3][5]的数组,2*3个[5]的数组,2*3*5个int
int (*p)[3][5] = apricot; // 步长 3 * 5
int (*r)[5] = apricot[0]; // 步长 5
int *t = apricot[0][0]; // 步长 1
int u = apricot[0][0][0];
指向数组第一个元素的指针与数组名等同。
内存中数组的布局
C语言中,最右边的下标最先变化,这个约定被称为"行主序"。
只有字符串常量才可以初始化指针数组,因为可执行文件中字符串常量是作为数据存储。而161这样的字面常量只出现在代码中。
数组与指针可交换性的总结:
用a[i]这样的形式对数组进行访问总是被编译器”改写“或解释为像*(a+i)这样的指针访问。
指针始终就是指针。它绝不可以改写成数组。只是可以使用下标形式访问指针。
在特定上下文中,也就是指针作为函数的参数(也就只有这种情况--注意),一个数组的声明可以看作是一个指针。作为函数参数的数组始终会被编译器修改成为指向数组中第一个元素的指针。
第10章--再论指针
数组和指针参数是如何被编译器修改的?
“数组名被改写成一个指针参数”规则并不是递归定义的。数组的数组会被改写成“数组的指针”,而不是“指针的指针”。
数组的数组 char c[8][10]; char (*c)[10]; 数组的指针
指针数组 char *c[15]; char**c; 指针的指针
指针的指针 char **c; char **c; 不改变----指针与指针不用修改
数组的指针 char (*c)[64]; char (*c)[64]; 不改变----注意,指向一个长度为64的char数组的数组名的指针,访问数组中元素这样做:(*c)[0]。
int a[20];
int **p = &a; // 错误,指针的指针与数组的指针不兼容
int (*t)[20] = &a; // 正确,t为由20个int的数组的指针。
----此处括号是必须的,因为[]的优先级比*高
Iliffe向量,创建一个一维数组,数组中的元素是指向其他东西的指针。
例如main(int argc, char *argv[]),第二个参数会被改写成char **。(注意,只有把二维数组改为一个指向向量的指针数组的前提下才可以这么做!)
在C语言中,传递多维数组必须提供除最左面一维以外的所有维的长度。
可以放弃多维数组的形式,提供自己的下标方式,如char_array[row_size*i + j] = ...
模拟动态数组,当表满后,用realloc()对数组重新分配内存,并确保realloc操作成功。
重分配操作很可能把原先的整个内存块移到一个不同的位置,这样表格中元素的地址便不再有效。为了避免麻烦,应该使用下标而不是元素的地址。----这也是STL中引入迭代器的一个原因吧
“增加”和“删除”操作都必须通过函数来进行,这样才能维持表的完整性。
第11章--你懂得C,所以C++不在话下
类内部定义的函数是inline函数
重载是编译时解析的。
多态——运行时绑定。late binding
new和delete操作符,用于取代malloc()和free()函数,能够自动完成sizeof的计算工作,并会自动调用合适的构造函数和析构函数。new能真正的创建一个对象,malloc()函数只是分配内存。
C++的设计受限于严格的兼容性、内部一致性和高效率。
复用是软件科学的一个崇高而又朦胧的目标。----很多时候不如另起炉灶从头开始
管理和市场状况是导致许多公司破产的原因,比单纯的技术失败更为常见。那些不时刻注意顾客需求的公司终究难以为继,最能掌握这项艺术的公司往往能获得成功。
附录A--程序员工作面试的秘密
面试的关键在于正确理解问题!你需要仔细地听,如果不理解问题或者觉得它的定义不清,可以要求一个更好的解释。
提供一种寻找可靠答案的好方法。
链表环的检测。
mango[i++] += y; // i++仅执行一次
优秀的程序员将会休息的更好,精力更加充沛,而蹩脚的程序员则很可能困得脑袋常常和桌子打架。
人类的最高目标是奋斗、寻求、创造。
本文转自杨源鑫的博客,感谢原作者。
在介绍完最具代表的编程语言后,就不得不提计算机系统方面的经典书箱。
《深入理解计算机系统》是理解计算机系统首选书目,是10余万程序员的共同选择。卡内基-梅隆、北京大学、清华大学、上海交通大学等国内外众多知名高校选用指定教材。从程序员视角全面剖析的实现细节,使读者深刻理解程序的行为,将所有计算机系统的相关知识融会贯通。
本书主要介绍了计算机系统的基本概念,包括最底层的内存中的数据表示、流水线指令的构成、虚拟存储器、编译系统、动态加载库,以及用户应用等。书中提供了大量实际操作,可以帮助读者更好地理解程序执行的方式,改进程序的执行效率。此书以程序员的视角全面讲解了计算机系统,深入浅出地介绍了处理器、编译器、操作系统和网络环境,是这一领域的权威之作。适合作为计算机及相关专业的本科生教材,同时也适用于编程人员参考阅读。
第一章是提纲挈领性质的一章,从一个 hello world 程序来引出 C、引出 UNIX、Linux ,引出 C 语言程序的编译过程,引出高速缓存、系统的硬件组成、存储结构、虚拟内存、网络编程、并发编程。第一章的内容虽然不难理解,却是能让人涨自信心的一章,这一章能让你产生一种激动的心情,因为内容不难,但是却能够开拓你的思路,把你的知识体系串联起来。
第一部分
第二章的内容比较枯燥,个人认为需要一定的 C 语言基础,这里推荐两本 C 语言的书吧,第一本书 《C Primer Plus》 这本书是把 C 语言揉碎了手把手教你,另外《C 程序设计语言》也不错。
第二章主要讲的是计算机中数据的存储方式,基本的数据类型和各种补码、反码的表示方式。总之看这一章如果不了解 C 的话,有点昏昏欲睡(原因是可能不太懂),但实际上第二章写的很不错,当你阅读了本书的第二章之后,也许会发现,你所谓的(懂)只是你的一厢情愿而已,数值系统远没有你想象的那么简单。
第三章是程序的机器级表示,如果你把 C 学的差不多了之后(其实如果单看第二章的话,把 C 语言数据类型、运算补充一下即可),接下来你要学习一下汇编了,否则根本看不懂第三章在说什么。你可能想知道诸如 pushq、movq、call、popq、ret、%rbx、%rdx 等等都是干嘛的,还有汇编是如何编写的。而且你还要懂 C 语言。这么看来这本书根本就不是一本为初学者准备的书,也可以说是初学者的劝退书。
第四章又是一个打开新世界大门的一章,这章会直接从各种电路开始搞起,马上就直接奔硬件了!主要讲了 X86-64 体系、各种门电路、引出计算机流水线的设计。毕竟现代微处理器可以称得上是人类创造出的最复杂的系统之一。也会给你讲解各种指令集的区别。
与这一章节相关的书籍,可以看看《计算机组成与设计:软件/硬件接口》还有《编码》这两本书,都非常好,非常透彻。
这一章也会聊指令集架构,这些架构和宏观意义上的应用层架构不一样,非常复杂,比如下面这个 ARM 架构:
第五章:优化程序性能,现在普遍意义上提到的各种优化,不论是架构层优化、指令集优化等核心都离不开这一章所介绍的内容。优化的难点在于你需要对系统有充分理解,当然了在你做优化之前首先要保证原始程序功能正确(并且有回归测试),否则一切都是徒劳。比如你需要了解现在系统存在的性能瓶颈,才能系统性的进行优化,你才能够编写高效的程序。
编写高效程序需要做到下面这几点:
选择适当的数据结构和算法。
编写出编译器能够有效优化以转换成可执行代码的源代码。
任务拆分,采用并行计算的方式。
第六章:存储器层次结构,这一章会向你介绍存储技术的发展,磁盘、主存、高速缓存的性能差距到底有多大,然后介绍局部性原理,一项非常强大的缓存技术。高速缓存读写是如何映射的,高速缓存不同参数的性能影响,如何编写高速缓存又好的代码,存储器山是个什么概念,以及你会见到封面的插图。
看本章的时候强烈建议把 Ulrich Drepper 撰写的长达 114 页的经典论文 What Every Programmer Should Know About Memory 看了。
以上就是 CSAPP 的第一部分,第一部分主要介绍了程序和硬件之间的交互关系。
而第二部分则专注于程序和操作系统之间的交互关系,会学到如何使用操作系统提供的服务来构建系统级程序。
第二部分
第七章:链接,在使用 Linux 的时候,很多情况下会出现很多难以理解的错误,其中很多都是链接错误。链接分为静态链接和动态链接,我们写的 C 程序在执行的过程中都会经过链接阶段。
除了这一章内容之外,大家也可以看一下一本把链接讲的非常透彻的一本书:《程序员的自我修养 -- 链接,装载与库》,主要讲授代码指令是如何保存的,库文件如何与应用程序代码静态链接,应用程序如何被装载到内存中并开始运行,动态链接如何实现,C/C++运行库的工作原理,以及操作系统提供的系统服务是如何被调用的。非常好的一本国产书。
第八章:异常控制流,世界上不会存在完美运行的程序,任何程序都会出错,这些错误可能是线程执行过程中出错、可能是系统调用异常、页面映射错误等等。这一章会向你介绍各种异常出现之后,操作系统是如何处理的。
第九章:虚拟内存,虚拟内存其实是存储器层次结构的衍生,至于为什么单独拿一章来说,因为虚拟内存太重要了。这一章会向你介绍为什么我们的计算机内存只有 8G(或其他)却能够运行自身数倍以上的程序。虚拟内存有的时候也是面试官比较爱问的一个点:虚拟内存是如何映射的,什么是页框、页表诸如此类。
第三部分
第三部分主要介绍程序间的相互通信,主要包括 IO、网络编程和并发编程。
IO 这部分介绍类 Unix 系统下的 I/O 读写,主要介绍系统层面的 I/O 接口。
如今互联网都立足于 TCP/IP 协议之上,Socket 甚至已经成为了网络编程的同义词。这部分主要向你介绍了网络的变迁,什么客户端-服务器编程模型、Web 服务器,最后再带你写一个 Web 服务器。
网络这部分内容远比这一章节介绍的复杂,网络这部分内容给大家推荐几本书:《计算机网络:自顶向下方法》、《TCP/IP 详解》,《UNIX 网络编程》。
一定要知道的是 W. Richard Stevens和他的个人网站。
并发这一章节主要介绍了 C 中如何编写并发程序,如何榨干 CPU ,让其发挥峰值性能。读 csapp 这本书还是需要一定基础的,而且读起来不是那么容易。不过业界还是有一些好资源,能让你更快的深入这本书。给大家推荐这个网站。可以理解为是 CSAPP 的导读网站,对每一章都进行了介绍,而且推荐了一些不错的资源。
最最最重要的就是做实验,你可以在相关页面上找到 csapp 的所有实验。
Randal E.Bryant:1981年在麻省理工学院获计算机科学博士学位,现任美国卡内基·梅隆大学计算机学院院长,是ACM和IEEE的双会士,多次获得这两个协会颁发的大奖。Bryant教授从事计算机系统方面的教学工作已超过20年,结合计算机体系结构课程多年的教学经验,他开始把关注点从如何设计计算机转移到如何使程序员在更好地了解系统的情况下编写出更有效、更可靠的程序上来。