/************************************************************* - 《C专家编程》学习日志 - 作者:谢荣桢 - 版本:V1.0 *************************************************************/ -------------------------------------- 2018/10/04(16:25): 第一章 C:穿越时空的迷雾 1.1 C语言的史前阶段 1.2 C语言的早期体验 1.数组下标从0而不是1开始 2.C语言的基本数据类型直接与底层硬件相应 3.auto关键字显然是摆设 auto是默认的变量内存分配方式,只对创建符号表入口的编译器设计者有意义。 4.表达式中的数组名可以看作是指针 注:数组和指针并不是在任何情况下都是等效的 5.float被自动扩展为double 注:在ANSIC中不在如此 6.不允许嵌套函数(函数内部包含另一个函数的定义) 简化了编译器,并稍微提高了C程序的运行时组织结构 7.register关键字 这个关键字定义的变量将存放到寄存器中,简化了编译器,但把包袱丢给了程序员 1.3 标准I/O库和C预处理器 C预处理的3个主要功能 1.字符串替换 通常用于为常量提供一个符号名 2.头文件包含 一般性的声明可以被分离到头文件中,并且可以被许多源文件使用。 3.通用代码模板的扩展 宏(marco)在连续几个调用中所接收的类型可以不同(宏的实际参数只是按照原样输出) 空格会对扩展的结果造成很大的影响 #define a(y) a_expanded(y) a(x); 被扩展为 a_expanded(x); 而 #define a (y) a_expanded (y) a(x); 则被扩展为 (y) a_expanded (y) (x) 1.4 K&R C 《The C programming Langauage》(中文版为《C程序设计语言》) 1.5 今日之ANSI C 1.6 它很棒,但它符合标准吗 1.不可移植的代码(unportable code) (1).由编译器定义的(implementation-defined),不同的编译器可能不同 例:当整型数向右移位时,要不要扩展符号位。 (2).未确定的(unspecified),在某些正确情况下的做法,标准并未明确规定怎么做(即不在ANSI C标准内的做法) 例:参数求值的顺序 2.坏代码(bad code) (1).未定义的(undefined),在某些不正确情况下的做法,但标准为规定怎么做 例:当一个有符号整数溢出该采取什么行动 (2).约束条件(a constraint),必须遵守的限制或要求 例:求余操作符(%)的操作数必须属于整型,在非整型数据上使用%操作符会引发一条错误信息 3.可移植的代码(portable code) (1).严格遵循标准的(strictly-conforming) 只使用已确定的特性 不突破任何由编译器实现的限制 不产生任何依赖由编译器定义的或未确定的或未定义的特性的输出 (2).遵循标准的(conforming),可以依赖一些某种编译器特有的不可移植的特性,但移植时需对其进行修改。 1.7 编译限制 ANSI C 编译器必须能够支持 1.在函数定义中形参数量的上限至少可以达到31个 2.在函数调用时实参数量的上限至少可以达到31个 3.在一条源代码行里至少可以有509个字符 4.在表达式中至少可以支持32层嵌套的括号 5.long int的最大值不得小于2147483647(即long型整数不得低于32位) 1.8 ANSI C标准的结构 *1.9 阅读ANSI C标准,寻找乐趣和裨益 ** training1.c中的代码编译后会发出一条警告信息 line 5:warning:argument is incompatible with prototype 这是因为char **argv与形参const char **p不相容,但实参char *s与const char *p是相容的。 const char **是一个没有限定符的指针类型,它的类型是“指向有const限定符的char类型的指针的指针” char **也是一个没有限定符的指针类型,它的类型是“指向无限定符的char类型的指针的指针” 因为char **argv指向char *argv,而const char **p指向const char*p,被赋值的对象p带有限定符违反了第6.3.2.2节的约束条件。 在ANSI C标准第6.3.2.2节中讲述约束条件的小结中的一段话说明参数传递过程类似于赋值。 所以,除非一个类型为char **的值可以赋给一个const char **类型的对象。 而在标准第6.3.16.1节描述了下列约束条件: 要使上述的赋值形式合法,必须满足下列条件之一: 两个操作数都是指向有限定符或无限定符的相容类型的指针,左边指针所指向的类型必须具有右边指针所指向类型的全部限定符 所以实参char*与const char*匹配。 合法的原因如例: char *cp; const char *ccp; ccp = cp; 左操作数所指向的类型具有右操作数所指向类型的限定符(无),再加上自身的限定符(const)。 注:反过来赋值cp = ccp;会报之前的编译警告(argument is incompatible with prototype) 1.10 “安静的改变”究竟有多少安静 K&R C采用无符号保留(unsigned preserving)原则,就是当一个无符号类型与int或更小的整型(如char)混合使用时,结果类型是无符号类型,这会使 一个负数丢失符号位。 ANSI C采用值保留(value preserving)原则,即 当执行算术运算时,操作数的类型如果不同,就会发生转换。数据类型一般朝着浮点精度更高、长度更长的方向装换,整型数如果转换为signed不会 丢失信息,就转换为signed,否则转换为unsigned。 training2.c的代码在ANSI C和K&R C编译器中将打印不同的信息。 training3.c中的if语句结果不为真,原因是TOTAL_ELEMENTS所定义的值是unsigned int类型(sizeof函数返回的类型是无符号数), 导致d被升级为unsigned int类型,-1转换成unsigned int的结果将是一个非常大的正整数,导致表达式值为假。要修正这个bug,需要对 TOTAL_ELEMENTS进行强制类型转换。 if(d <= (int)TOTAL_ELEMENTS - 2) 对无符号类型的建议 1.尽量不要使用无符号类型,以免增加不必要的复杂性。 2.尽量使用有符号类型,这样在设计升级混合类型的复杂细节时,不必担心如trainin3.c那样的边界情况(如-1被翻译为非常大的正数) 3.只有在使用位段和二进制掩码时,才可以用无符号数。应该在表达式中使用强制类型转换,使操作数均为有符号数或无符号数,避免编译器来选择结果的类型。 容易混淆的const 关键字const并不能把变量变成常量。只是表示这个变量不能被赋值,即只读的,但不能防止通过程序的内部(甚至是外部)的方法来修改这个值。 const最有用之处是限定函数的形参,使得函数不会修改实参指针所指的数据,但其他函数却可能修改它。 training4.c揭示了const存在的问题,limitp是一个指向常量类型的指针,其不能用于修改整型数,但这个指针本身的值却可以改变(即可以指向不同的地址), 这回造成一些罕见的错误。 建议: const和*的组合通常只用于在数组形式的参数中模拟传值调用。 -------------------------------------- 2018/10/06(15:15): 第二章 这不是Bug,而是语言特性 2.1 这关语言特性何事,在Fortran里这就是Bug呀 NUL:用于结束一个ASCII字符串 NULL:用于表示空指针。 2.2 多做之过 2.2.1 由于存在fall through,switch语句会带来麻烦 switch语句的一般形式如下: switch(表达式) { case 常量表达式:零条或多条语句 default:零条或多条语句 case 常量表达式:零条或多条语句 } 如果没有default,而且所有的case均不匹配,那整条switch语句便什么都不做。 一个遵循标准的C编译器至少允许一条swtich语句中有257个case标签(ANSI C标准,第5.2.4.1节)--256个可能的值加上EOF ** switch存在的问题: 1.对case可能出现的值太过于放纵 例如:可以在switch的左花括号之后声明一些变量,从而进行一些局部存储的分配,而不报错。 但这些变量并没有用处,它们不会被执行,因为switch语句是从匹配表达式的case开始执行。 training2.1.c表明了这一问题。 所以如果需要一些临时变量最好放在块的开始处。 2.switch语句内部的任何语句都可以加上case标签,并在执行时跳转到那里。 正如training2.2.c所展示的错误,当把default的“l”错写成数字1,并不会报错, 实际效果相当于default子句并不存在与switch语句中。 注:const关键字并不真正表示常量。 3.switch语句不会再每个case标签后面的语句执行完毕后自动中止。 一旦执行某个case语句,程序将会依次执行后面所有的case,除非遇到break语句。 这称之为"fall through"。详情如training2.3.c所示。 2.2.2 粉笔也成了可用的硬件 ANSI C引入的一个新特性: 相邻的字符串常量将被自动合并成一个字符串的约定 这省掉了过去在书写多行信息时必须在行末加“\”的做法,但这种自动合并意味着 字符串数组在初始化时,如果不小心漏掉了一个逗号,编译器将不会发出错误信息,而是 悄无声息地把两个字符串合并在一起。 training2.4.c表明了这一问题。 2.2.3 太多的缺省可见性 定义C函数时,在缺省情况下函数的名字是全局可见的,即加不加extern效果一样。 如果想限制对这个函数的访问,必须加个static关键字 function apple() {/* 在任何地方均可见 */} extern function pear() {/* 在任何地方均可见 */} static function turnip() {/* 在这个文件之外不可见 */} 作用域过宽的问题常见于库中:一个库需要让一个对象在另一个库中可见。唯一的办法 是让它变得全局可见,但这样一来,它对于链接到该库的所有对象都是可见的了。 2.3 误做之过 2.3.1 骆驼背上的重载 许多符号是被“重载”的————在不同的上下文环境里有不同的意义。 C语言的符号重载 1.static:在函数内部,表示该变量的值在各个调用间一直保持延续性,即只做一次初始化 在函数这一级,表示该函数只对本文件可见 2.extern:用于函数定义,表示全局可见(属于冗余的) 用于变量,表示它在其他地方定义 3.void :作为函数的返回类型,表示不返回任何值 在指针声明中,表示通用指针的类型 位于参数列表这种,表示没有参数 4.* :乘法运算符 用于指针,间接引用 在声明中,表示指针 5.& :位的AND操作符 取地址操作符 6.<<= :左移复合赋值运算符 7.< :小于运算符 #include指令的左定界符 8.() :在函数定义中,包围形式参数表 调用一个函数 改变表达式的运算次序 将值转换为其他类型(强制类型转换) 定义带参数的宏 包围sizeof操作符的操作数(如果它是类型名) p38页的apple = sizeof(int) * p;经测试为int的长度乘以p,若p为指针会报错。 2.3.2 “有些运算符的优先级是错误的” C语言运算符优先级存在的问题 详见P38页表2-2 注:表达式中如果有布尔操作、算术运算、位操作等混合计算,始终应该在适当的地方加上括号。 结合性是仲裁者,在几个操作符具有相同的优先级时决定先执行哪一个。 2.3.3 早期gets()中的Bug导致了Internet蠕虫 gets()函数从流中读入一个字符串,但其无法检查缓冲区的空间,故如果函数调用者提供一个指向堆栈的指针, 并且gets()函数读入的字符数量超过缓冲区的空间,多出来的字符会继续写入到堆栈,覆盖原先的内容。 所以推荐用fgets()彻底取代gets(),fgets()函数对读入的字符数设置了一个限制,这样就不会超出缓冲区范围。 2.4 少做之过 2.4.1 用户名中若有字母f,便不能收到邮件。 2.4.2 空格————最后的领域 1.“\”字符可用于对一些字符进行“转义”,包括newline(这里指回车键) 即\和新行之间如果多了空格会造成问题,并且很难被发现。 这通常出现在用于转义连续多行的宏定义 2.如果将所有的空格都弃之不用,也会陷入麻烦。 例如: z = y+++x; ANSI C规定如果下一个标记有超过一种的解释方案,编译器将选取能组成最长字符序列的方案。 所以,上例将解析为z = y++ +x; 但 z = y+++++x;会有麻烦,按照前面的策略将解析为z = y++ ++ +x;这将引起一个编译错误:“++操作符迷失于空格间” 即使编译器能推断(从理论上说)唯一有效的编排方式是 z = y++ + ++x;还是会出现编译错误。 training2.6.c说明了这个问题。 3.当程序员有两个指向int的指针并想对两个int数据执行除法运算时, ratio = *x/*y; 但编译器会报错:出现了语法错误。问题出在除法运算符“/”与“*”操作符之间缺少空格。 编译器误认为/*是一个注释的开始部分。 2.4.3 C++的另一种注释形式 "//"注释符 对于a //* //*/ b 在C语言中表示a/b,但在C++中表示a。 在training2.7.c中可知“//”注释符可用的情况下上例结果为a。 2.4.4 编译器日期被破坏 p48页的例子。 当函数返回的是一个指向局部变量的指针时,当控制流离开局部变量的范围,变量将失效,无法得知变量的内容。 解决方案 1.返回一个指向字符串常量的指针(最简单的解决方案,适用于无需计算字符串的内容) 例:return "Only"; 2.使用全局声明的数组 3.使用静态数组(static关键字) 4.显示的分配一些内存,保存返回的值 但要注意释放内存,在函数内分配后,很容易忘记在使用函数后释放该内存,造成严重的问题。 5.要求调用者分配内存来保存函数的返回值。 例:buffer = malloc(size); fuc(buffer,size); free(buffer); 2.4.5 lint程序绝不应该被分离出来 lint程序是程序的道德准则。当你做错事时,它会告诉你那里不对。应该始终使用lint程序,按照它的道德准则办事。 -------------------------------------- 2018/10/07(13:35): 第三章 分析C语言的声明 3.1 只有编译器才会承认的语法 1.把类型强制转换为指向数组的指针: char (*j)[20]; //j是一个指向数组的指针,数组内有20个char元素 j = (char (*)[20]) malloc(20); 如果把星号两边的括号拿掉,代码会变得非法。 2.涉及指针和const的声明可能出现集中不同的顺序: const int * grape; int const * grape; int * const grape_jelly; 在最后一种情况下,指针是只读的,而在另外两种情况下,指针所指向的对象是只读的。 3.对象和指针都是只读的声明方法有两种: const int * const grape_jam; int const * const grape_jam; 3.2 声明是如何形成的 声明的核心————声明器(declarator) 是标识符以及与它组合在一起的任何指针、函数括号、数组下标等。如表3.1所示 表3.1 C语言中的声明器(declarator) ------------------------------------------------------------------------------------ 数量 C语言中的名字 C语言中出现的形式 ------------------------------------------------------------------------------------ 零个或多个 指针 下列的形式之一: * const volatile * volatile * * const * volatile const ------------------------------------------------------------------------------------ 有且只有一个 直接声明器 标识符 或:标识符[下标] 或:标识符(参数) 或:(声明器) ------------------------------------------------------------------------------------ 零个或一个 初始化内容(initializer) = 初始值 ------------------------------------------------------------------------------------ 一个声明由表3.2所示的各个部分组成(并非所有的组合形式都合法)。声明确定了变量的基本类型以及初始值(有的话)。 表3.2 C语言中的声明 ------------------------------------------------------------------------------------------ 数量 C语言中的名字 C语言中出现的形式 ------------------------------------------------------------------------------------------ 至少一个类型说明符 类型说明符 void char short int (type-specifier) long signed unsigned float double 结构说明符(struct-specifier) 枚举说明符(enum-specifier) 联合说明符(union-specifier) (并非所有的组合都合法) 存储类型 (storage-class) extern static register auto typedef 类型限定符 (type-qualifier) cosnt volatile -------------------------------------------------------------------------------------------- 有且只有一个 声明器 参见表3.1 (declarator) -------------------------------------------------------------------------------------------- 零个或多个 更多的声明器 ,声明器 -------------------------------------------------------------------------------------------- 一个 分号 ; -------------------------------------------------------------------------------------------- 合法的声明存在限制条件: 1.函数的返回值不能是一个函数,例:foo()()是非法的 2.函数的返回值不能是一个数组,例:foo()[]是非法的 3.数组里面不能有函数, 例:foo[]()是非法的 下面的声明是合法的 1.函数的返回值允许是一个函数指针, 例:int(*foo())() 2.函数的返回值允许是一个指向数组的指针,例:int(* foo())[] 3.数组里面允许有函数指针, 例:int(*foo[])() 4.数组里面允许有其他数组, 例:int foo[][] 3.2.1 关于结构 结构就是一种把一些数据项组合在一起的数据结构。 结构的通常形式是: struct 结构标签(可选){ 类型1 标识符1; 类型2 标识符2; ... 类型N 标识符N; }变量定义(可选); 结构的内容可以是任何其他数据声明:单个数据项、数组、其他结构、指针等。 结构的参数传递: 1.参数在传递时首先尽可能地存放到寄存器中(追求素的) 注:int型变量i跟只包含一个int型成员的结构变量s在参数传递时的方式可能完全不同。一个int 参数一般会被传递到寄存器中,而结构参数则很可能被传递到堆栈中。 2.在结构中放置数组,可以把数组当做第一等级的类型,用赋值语句拷贝整个数组(数组本身不可以用赋值语句拷贝),以传值 调用的方式把它传递到函数,或者把它作为函数的返回类型。 如training3.1.c所示。 3.2.2 关于联合 联合的一般形式: union 可选的标签 { 类型1 标识符1; 类型2 标识符2; ... 类型N 标识符N; }可选的变量定义。 联合(一般作为大型结构的一部分存在)的作用: 1.节省空间,有些数据项不可能同时出现,可用联合节省空间 2.把同一个数据解释成两种不同的东西。 3.2.3 关于枚举 枚举(enum)通过一种简单的途径,把一串名字与一串整型值联系在一起。 枚举的一般形式: enum 可选的标签 {内容...}可选的变量定义; 3.3 优先级规则 C语言声明的优先级规则 A 声明从它的名字开始读取,然后按照优先级顺序依次读取 B 优先级从高到低依次是: B.1 声明中被括号括起来的那部分 B.2 后缀操作符: 括号()表示这是一个函数,而 方括号[]表示这是一个数组。 B.3 前缀操作符:星号*表示 “指向...的指针” C 如果const和(或)volatile关键字的后面紧跟类型说明符(如int,long等),其作用于类型说明符 在其他情况下,const和(或)volatile关键字作用于它左边紧邻的指针星号。 3.4 通过图表分析C语言的声明 更多关于步骤的详情请阅读《C专家编程》P65页 图3-1 表3.4 分析一个C语言声明的步骤 ------------------------------------------------------------------------------------------------- 剩余的声明 所采取的下一步骤 结果 (从最左边的标识符开始) ------------------------------------------------------------------------------------------------- char * const *(*next)(); 第1步 表示“next 是 ... ” ------------------------------------------------------------------------------------------------- char * const *(* )(); 第2、3步 不匹配,转到下一步,表示“next 是...” char * const *(* )(); 第4步 不匹配,转到下一步 char * const *(* )(); 第5步 与星号匹配,表示“指向...的指针”,转第4步 char * const *( )(); 第4步 “(”和“)”匹配,转第2步 char * const * (); 第2步 不匹配,转到下一步。 char * const * (); 第3步 表示“返回...的函数” char * const * ; 第4步 不匹配,转到下一步 char * const * ; 第5步 表示“指向...的指针” char * const ; 第5步 表示“只读的...” char * ; 第5步 表示“指向...的指针” char ; 第6步 表示“char” -------------------------------------------------------------------------------------------------- 结果是: next是一个指向函数的指针,该函数返回另一个指针,该指针指向一个只读的指向char的指针。 3.5 tupedef可以成为你的朋友 typedef为一种类型引入新的名字,而不是为变量分配空间。“宣称这个名字是指定类型的同义词” 一般情况下,typedef用于简洁地表示指向其他东西的指针。 注:1.不要在一个typedef中放入几个声明器。 2.千万不要把typedef嵌入到声明的中间部分。 3.6 typedef int x[10]和#define x int[10]的区别 1.可以用其他类型说明符对宏类型名进行扩展,但对typedef所定义的类型名却不能这样做。 例:#define peach int unsigned int i; /* 没问题 */ typedef int banana; unsigned banana i; /* 错误!非法 */ 2.在连续几个变量的声明中,用typedef定义的类型能够保证声明中的所有变量均为同一种类型, 而#define定义的类型则无法保证。 例:#define int_ptr int * int_ptr chalk, cheese; 经过宏扩展后,第二行变为: int *chalk, cheese; 这使得chalk和cheese称为不同的类型。 3.7 typedef struct foo{... foo;}的含义 详见training3.3.c所示 例:typedef struct fruit {int weight,price_per_1b;}fruit; /*语句1*/ 语句1声明了结构标签“fruit”和由typedef声明的结构类型“fruit” 操作typedef的提示 typdef应该用在: 1.数组、结构、指针以及函数的组合类型 2.可移植类型。 当把代码移植到不同平台时,要选择正确的类型如short、int、long时,只要在typedef中进行修改。 3.typedef也可以为后面的强制类型转换提供一个简单的名字。 **training3.4.c理解 -------------------------------------- 2018/10/09(22:51): 第四章 令人震惊的事实:数组和指针并不相同 4.1 数组并非指针 extern int *x; extern int y[]; 第一条语句声明x是个int型的指针;第二条语句声明y是个int型数组,长度尚未确定(不完整的类型),其存储在别处定义。 4.2 我的代码为什么无法运行 例:文件1: int mango[100]; 文件2: extern int *mango; 这相当于把整数和浮点数混为一谈,即类型不匹配(int数组和int指针并不是同一数据类型): 例:文件1: int guava; 文件2: extern float guava; 之所以会产生这种混淆,是因为对数组的引用总可以写成对指针的引用,而且确实存在一种指针和数组的定义完全相同的上下文环境。 4.3 什么是声明,什么是定义。 定义:只能出现在一个地方 确定对象的类型并分配内存,用于创建新的对象。例:int my_array[100]; 声明:可以多次出现 描述对象的类型,用于指代其他地方定义的对象(例如在其他文件里)。例:extern int my_array[]; 注:声明不分配内存,只是描述其他地方创建的对象。 -------------------------------------- 2018/10/10(23:18): 4.3.1 数组和指针是如何访问的 1.首先需要注意的是“地址y”和“地址y的内容”之间的区别。 例:X = Y; 在这个上下文环境里,符号X的含义是X所代表的地址。在编译时可知,表示存储结果的地方。 符号Y的含义是Y所代表的地址的内容。知道运行时才知,如无特别说明,表示“Y的内容”。 注:C语言引入了“可修改的左值”这个术语与数组名区分,因为数组名是个不可修改的左值。 数组下标引用与指针的区别: 1.关键:每个符号的地址在编译时可知。故数组下标可以直接操作;但指针使用时需要在运行时取得它的当前值, 然后对它进行解除引用。 2.对于指针,编译器想要取得指针指向的内容,需要先得到指针的内容,把它作为指针指向的内容的地址,再到 这个地址去取内容。(详情看P84页图B) -------------------------------------- 2018/10/14(10:42): 4.3.2 当你“定义为指针,但以数组方式引用”时会发生什么 例:char *p = "abcdefgh";c = p[3]; 编译器将会: 1.取得符号表示p的地址,提取存储于此处的指针 2.把下标所表示的偏移量与指针的值相加,产生一个地址。 3.访问上面这个地址,取得字符。 4.4 使声明与定义相匹配 4.5 数组和指针的其他区别 表4-1 数组和指针的区别 ------------------------------------------------------------------------------------ | 指针 | 数组 | |----------------------------------------|-----------------------------------------| |保存数据的地址 |保存数据 | |----------------------------------------|-----------------------------------------| |间接访问数据,首先取得指针的内容,把它作为 |直接访问数据,a[I]只是简单地以a+I为地址取得 | |地址,然后从这个地址提取数据。 |数据 | |如果指针有一个下标[I],就把指针的内容加上I | | |作为地址,从中提取数据 | | |----------------------------------------|-----------------------------------------| |通常用于动态数据结构 |通常用于存储固定数目且数据类型相同的元素 | |----------------------------------------|-----------------------------------------| |相关的函数为malloc(),free()。 |隐式分配和删除 | |----------------------------------------|-----------------------------------------| |通常指向匿名数据 |自身即为数据名 | ------------------------------------------------------------------------------------ 4.6 回文的乐趣 -------------------------------------- 2018/10/16(22:42): 第五章 对链接的思考(有关UNIX,很多细节没弄懂,需要后期与《UNIX高级环境编程》相验证) 5.1 函数库、链接和载入 链接器位于编译过程的哪一阶段 编译器驱动器(compiler driver)包括 预编译器(preprocessor)、语法和语义检查器(syntactic and semantic checker)、 代码生成器(code generator)、汇编程序(assembler)、优化器(optimizer)、 链接器(linker)、驱动器程序(driver program) 优化器几乎可以加在上述所有阶段的后面。 详见P92图5-1 静态链接:函数库的一份拷贝是可执行文件的物理组成部分; 动态链接:可执行文件只是包含文件名,让载入器在运行时能够寻找程序所需要的函数库。 注:由于外部函数被真正调用前,运行时载入器并不解析它们。所以动态链接可以节省开销。 详见P94图5-2 5.2 动态链接的优点 动态链接是一种更为现代的方法,可执行文件的体积可以非常小。 虽然运行速度稍慢,但能够更有效地利用磁盘空间,编译-编辑阶段时间缩短。 动态链接的主要目的:把程序与它们使用的特定的函数库版本中分离出来。 约定由系统向程序提供一个接口,介于应用程序和函数库二进制可执行文件 所提供的服务之间的接口,称为应用程序二进制接口(Application Binary Interface,ABI). 动态链接必须保证4个特定的函数库: libc(C运行时函数库)、libsys(其他系统函数)、libX(X windowing)和libnsl(网络服务). 动态链接可以从两个方面提高性能: 1.动态链接可执行文件比功能相同的静态链接可执行文件的体积小。 2.所有动态链接到某个特定函数库的可执行文件在运行时共享该函数库的一个单独拷贝。 动态链接是一种"just-in-time(JIT)"链接,这意味着程序在运行时必须能够找到它们所需要的函数库。 链接器通过把库文件名或路径名植入可执行文件来完成,故函数库的路径不能随意移动,除非在链接器中 进行特别说明。 静态库(archive):通过ar(用于archive的实用工具)来创建和更新。拓展名为“.a” 动态链接库有链接编辑器ld创建。拓展名为".so" 动态链接库的最简单形式可以通过cc命令加上-G选项来创建。 详见P97. -------------------------------------- 2018/10/17(23:10): 5.3 函数库链接的5个特殊秘密 1.动态链接库文件的扩展名是".so",而静态库文件的扩展名是".a" 2.例如,你通过-lthread选项,告诉编译链接到libthread.so 编译器被告知根据选项-lthread链接到相应的函数库,函数库的名字是libthread.so——"lib" 部分和文件的扩展名被省掉了,但在前面加一个"l"。 3.编译器期望在确定的目录找到库 编译器查看一些特殊的位置,如在/usr/lib中查找函数库。 同时,编译器选项-Lpathname告诉链接器一些其他的目录,如果命令中加入了-l选项,链接器就 往这些目录查找函数库。同理-Rpathname选项也是如此。 同时系统中存在几个环境变量,LD_LIBRARY_PATH和LD_RUN_PATH,也用于提供这类信息,但出于 安全性、性能和创建/运行独立性方面的考虑,不提倡使用环境变量。 4.观察头文件,确认所使用的函数库 想要了解要链接到哪些函数库,一个好的建议是观察程序所使用的#include指令。 5.与提取动态库中的符号相比,静态库中的符号提取的方法限制更严 5.4 警惕Interpositioning Interpositioning(也有人称为"interposing")是通过编写与库函数同名的函数来取代该库函数的行为 使用Interpositioning需要格外小心。很容易发生自己代码中某个符号的定义取代函数库中的相同符号的意外。 5.5 产生链接器报告文件 可以在ld程序中使用"-m"选项,让链接器产生一个报告。 -------------------------------------- 2018/10/18(23:15): 第六章 运动的诗章:运行时数据结构 运行时系统: 1.有助于优化代码,获得最佳的效率 2.有助于理解更高级的材料 3.当陷入麻烦时,可以使分析问题更加容易 6.1 a.out及其传说 a.out是“assembler output(汇编程序输出)”的缩写形式 注:事实上它不是汇编程序输出,而是链接器输出!!! 为重要的数据定义标签,用独特的数字惟一地标识该数据是一种普遍采用的编程技巧。 例:superblock,UNIX文件系统中的基础数据结构的标识 #define FS_MAGIC 0x011954 6.2 段(segments) 目标文件和可执行文件可以有几种不同的格式。详见P117 6.2第一段。 这些不同的格式具有一个共同的概念:段(segments). 在UNIX中,段表示一个二进制文件相关的内容块。(在本书中不作说明,段都指UNIX上的段) 而在Intel x86架构中,地址空间并非一个整体,而是分成一些64K大小的区域,称为段。 -------------------------------------- 2018/10/19(18:12): 6.3 操作系统在a.out文件里干了些什么 文本段包含程序的指令。 数据段包含经过初始化的全局和静态变量以及它们的值。 BSS段大小从可执行文件中得到,紧跟在数据段之后,当内存区进入程序的地址空间后全部清零。 数据区:数据段和BSS段。 堆栈段(stack segment):用于保存局部变量、临时数据、传递到函数中的参数等。 堆(heap)空间:用于动态分配的内存。 注:虚拟地址空间的最低部分未被映射,用于捕捉空指针和小整型值的指针引用内存的情况。 6.4 C语言运行时系统在a.out文件里干了些什么 堆栈段 1.堆栈为函数内部声明的局部变量提供存储空间 2.进行函数调用时,对照存储于此有关的一些维护性信息。 称为:堆栈结构(stack frame),或者叫过程活动记录(precedure activation recored). 包括函数调用地址(即当所调用的函数结束后跳回的地方)、任何不适合装入寄存器的参数以及一些寄存器值的保存。 3.用作暂时存储区。alloca()函数分配。 注:除了递归调用,堆栈并非必需。 6.5 当函数被调用时发生了什么:过程活动记录 C语言自动提供的服务:跟踪调用链——哪些函数调用了哪些函数,当下一个return语句执行后,控制返回何处等。 跟踪调用链的机制:堆栈中的过程活动记录。 运行时系统维护一个指针(常常位于寄存器中),通常称为fp,用于提示活动堆栈结构。 它的值是最靠近堆栈顶部的过程活动记录的地址。 6.6 auto和static关键字 惟一能用到auto的地方就是使你的声明更加清楚整齐。 过程活动记录可能并不位于堆栈中 尽可能地把过程活动记录的内容放到寄存器中会使函数调用的速度更快,效果更好。 6.7 控制线程 在进程中支持不同的控制线程只用简单地为每个控制线程分配不同的堆栈即可。 -------------------------------------- 2018/10/20(23:05): 6.8 setjmp和longjmp 这两个函数通过操纵过程活动记录实现,它们协同工作: 1.setjmp(jmp_buf j)必须首先被调用。表示:使用变量j记录现在的位置。函数返回零。 2.longjmp(jmpbuf j,int i)可以接着被调用。表示:回到j所记录的位置,使其像从原先的setjmp()函数返回一样。 但是函数返回i,使代码能够知道它是实际上是通过longjmp()返回的。 3.当使用与longjmp()时,j的内容被销毁。 setjmp保存了一份程序的计数器和当前的栈顶指针。也可以保存一些初始值。 longjmp恢复这些值,有效地转移控制并把状态重置回保存状态的时候。称为"展开堆栈(unwinding stack)"。 longjmp会导致转移,但和goto不同。 1.goto语句不能跳出C语言当前的函数。 2.用longjmp只能跳回到曾经到过的地方。 注:保证局部变量在longjmp过程中一直保持它的值的惟一可靠方法是声明为volatile。 setjmp/longjmp最大的用途是错误恢复。 setjmp和longjmp使程序难以理解和调试,故非特殊需要,最好避免使用。 6.9 UNIX中的堆栈段 在UNIX中,当进程需要更多空间时,堆栈会自动生长。 当试图访问当前系统分配给堆栈的空间之外时,它将产生一个硬件中断,称为页错误(page fault)。 在堆栈顶部的下端有一个称为red zone的小型区域,如果对这个区域进行引用,并不会产生失败。 内存映射硬件确保你无法访问操作系统分配给你的进程之外的内存。 6.10 MS-DOS中的堆栈段 在DOS中,在建立可执行文件时,堆栈的大小必须同时确定,而且它不能在运行时增长。 6.11 有用的C语言工具 详见P131表6-1、表6-2、表6-3、表6-4 第七章 对内存的思考 7.1 Intel 80x86系列 7.2 Intel 80x86内存模型以及它的工作原理 8086中的段是一块64KB的内存区域,由一个段寄存器所指向。 -------------------------------------- 2018/10/21(11:19): 7.3 虚拟内存 虚拟内存使用磁盘而不是主存来保存运行进程的映像。 虚拟内存通过"页"的形式组织。 页:操作系统在磁盘和内存之间移来移去或进行保护的单位,一般为几K字节。 进程只能操作位于物理内存的页面。 操作系统使用相同的底层数据结构(vnode"虚拟结点")来操纵文件系统和内存。 7.4 Cache存储器 Cache存储器:容量小、价格高、速度快。 Cache位于CPU和内存之间,是一种极快的存储缓冲区。 Cache包含一个地址的列表以及它们的内容。 1.全写法(write-through)Cache:每次写入Cache时总是同时写入到内存中,使内存和 Cache始终保持一致。 2.写回法(write-back)Cache:当第一次写入时,只对Cache进行写入。 7.5 数据段和堆 堆区域用于动态分配的存储,即通过malloc(内存分配)函数获得的内存,并通过指针访问。 calloc函数在返回指针之前先把分配好的内存的内容都清空为零。 realloc函数改变一个指针所指向的内存块的大小,既可以扩大,也可以缩小, 它经常把内存拷贝到别的地方然后将指向新地址的指针返回。 堆内存管理策略: 建立一个可用块("自由存储区")的链表,每块由malloc分配的内存块都在自己的前面标明自己的大小。 堆的末端由一个称为break的指针来标识。 *7.6 内存泄漏 堆经常出现两种类型的问题: 1.释放或改写仍在使用的内存(称为"内存损坏")。 2.未释放不再使用的内存(称为"内存泄漏")。 避免内存泄漏的方法: 在可能的时候使用alloca()来分配动态内存。当离开调用alloca的函数时,它所分配的内存会被自动释放。 但alloca函数并不是一种可移植的方法。 *如何检测内存泄漏 swap命令观察还有多少可用的交换空间: /usr/sbin/swap -s 在一两分钟内键入该命令三到四次,看看可用的交换区是否在减少。 第二个步骤:确定可疑的进程,看看它是不是该为内存泄漏负责。 7.7 总线错误 7.7.1 总线错误 总线错误几乎都是由于未对齐的读或写引起的。 出现未对齐的内存访问请求时,被堵塞的组件就是地址总线。 对齐(alignment):数据项只能存储在地址是数据项大小的整数倍的内存位置上。 编译器通过自动分配和填充数据(在内存中)来进行对齐。 7.7.2 段错误 段错误或段违规(segmentation violation): 由于内存管理单元(负责支持虚拟内存的硬件)的异常所致,而该异常则通常是由于接触引用一个未初始化 或非法值的指针引起的。 通常导致段错误的几个直接原因: 1.解除引用一个包含非法值的指针 2.解除引用一个空指针(常常由于从系统程序中返回空指针,并未检查就使用)。 3.在未得到正确的权限时进行访问。例:试图往一个只读的文本段存储值 4.用完了堆栈或堆空间(虚拟内存虽然巨大但并非无限)。 最终可能导致段错误的常见编程错误是: 1.坏指针错误: 在指针赋值前就用它来引用内存, 向库函数传送一个坏指针。 对指针进行释放之后再访问它的内容 2.改写(overwrite)错误: 越过数组边界写入数据,在动态分配的内存两端之外写入数据 改写一些堆管理数据结构(在动态分配的内存之前的区域写入数据容易发生这种情况) 3.指针释放引起的错误: 释放同一个内存块两次 释放一块未曾使用malloc分配的内存 释放仍在使用中的内存 释放一个无效的指针 注:在遍历链表时正确释放元素的方法:使用临时变量存储下一个元素的地址。 -------------------------------------- 2018/10/22(22:26): 第八章 为什么程序员无法分清万圣节和圣诞节 8.1 Potrzebie度量衡系统 8.2 根据位模式构筑图形 8.3 在等待时类型发生了变化 类型提升:在表达式中,每个char都被转换为int...注意所有位于表达式中的float都被转换 为double...由于函数参数也是一个表达式,所以当参数传递给函数时也会发生类型转换。具体 地说,char和short转换为int,而float转换为double。(K&R C) 在ANSI C中该概念变为在运算时不会发生溢出异常,则省略类型提升。 表8-1 C语言中的类型提升 ------------------------------------------------------------------------------- |源类型 |通常提升后的类型 | |-----------------------------------------------------------------------------| |char |int | |位段(bit-field) |int | |枚举(enum) |int | |unsigned char |int | |short |int | |unsigned short |int | |float |double | |任何数组 |相应类型的指针 | |-----------------------------------------------------------------------------| 注:参数也会被提升。 隐式类型转换需注意的地方: 1.隐式类型转换简化了代码的生成。运行时系统只需要知道参数的数目,而不需知道长度。 2.即使不理睬缺省的类型转换,也可进行大量的编程工作。 *3.隐式类型转换在涉及原型的上下文中显得很重要。(不理解隐式类型转换,不能成为专家级C程序员)。 8.4 原型之痛 K&R C函数声明和ANSI C原型的对比详见P175 表8-2 或training8.6.c 建立原型(ANSI C):为了消除形参和实参之间类型不匹配的错误。例:int foo(int a,int b);/int foo(int, int); 但存在一个问题,使用了函数原型,缺省参数提升就不会发生。 这会导致原来在K&R C中进行了缺省参数提升的函数的参数无法提升,引入一些未知的问题。 8.5 原型在什么地方会失败 需要考虑4中情况: 1.K&R C函数声明和K&R C函数定义 能够顺利调用,所传递的函数会进行类型提升。 2.ANSI C函数声明(原型)和ANSI C函数定义 能够顺利调用,所传递的参数为实际参数。 3.ANSI C函数声明(原型)和K&R C(training8.7.c) 如果使用一个较窄的类型就会失败!函数调用时所传递的是实际类型,而函数期望接收的是提升后的类型。 4.K&R C函数声明和ANSI C函数定义(training8.7.c) 如果使用一个较窄的类型就会失败!函数调用时所传递的是提升后的类型,而函数期望接收的是实际类型。 注:不要在函数的声明和定义中混用新旧两种风格。 -------------------------------------- 2018/10/23(21:56): 8.6 不需要按回车键就能得到一个字符 1.stty程序实现不按回车键得到一个字符(training8.8.c),阻塞式读入(blocking read)。 2.使用ioctl()函数,非阻塞式读入,而是轮询。 注:调用库函数之后检查errno详见(P181 小启发) curses屏幕管理调用函数库。 8.7 用C语言实现有限状态机(finite state machine,FSM) 基本思路:用一张表保存所有可能的状态,并列出进入每个状态时可能执行的所有动作, 其中最后一个动作就是计算(通常在当前状态和下一次输入字符的基础上,另外在经过一次表查询) 下一个应该进入的状态。 FSM基于函数指针数组: void (*state[MAX_STATES])(); 对数组进行初始化 extern int a(), b(), c(), d(); int (*state[])() = {a, b, c, d}; 更漂亮的做法: 让状态函数返回一个指向通用后续函数的指针,并把它转换为适当的类型,就不需要全局变量了。 简朴的方式: 使用switch语句,赋值给控制变量并把switch语句放在循环内部。 注:如果状态函数需要多个不同的参数,可以考虑使用一个参数计数器和一个字符串指针数组, 例如main函数中熟知的int argc,char argv[]机制。 8.8 软件比硬件更困难(不理解) 增量开发(incremental development)、显示代码调试(debugging code into existence) 可调试性编码:把系统分成几个部分,先让程序总体结构运行。 可调试性编码实现散列程序。 8.9 如何进行强制类型转换,为何要进行强制类型转换 复杂的类型转换的步骤 1.一个对象的声明,它的类型就是想要转换地结果类型 2.删去标识符(以及任何如extern之类的存储限定符),并把剩余的内容放在一对括号里。 3.把第2步产生的内容放在需要进行类型转换地对象的左边。 -------------------------------------- 2018/10/25(21:43): 第九章 再论数组 9.1 什么时候数组与指针相同 声明和使用(使用它们传统的直接含义)。 声明的情况: 1.外部声明(external array)的声明 2.数组的定义(定义是声明的一致特殊情况,它分配内存空间,并可能提供一个初始值) 3.函数参数的声明 所有作为函数参数的数组名总是可以通过编译器转换为指针。 注:数组的声明是数组,指针的声明是指针,不能混淆。 对编译器而言,一个数组就是一个地址,一个指针就是一个地址的地址。 9.2 为什么会发生混淆 char s[]和char *s等价只限于作为函数定义的形式参数 数组和指针相同的规则 规则1.表达式中的数组名(与声明不同)被编译器当做一个指向该数组第一个元素的指针 注:以下情况对数组的引用不能用第一个元素的指针来代替: 1.数组作为sizeof()的操作数,此时需要的是整个数组的大小,例:sizeof(a[]) 2.使用&操作符取数组地址 3.数组是一个字符串(或宽字符串)常量初始值 规则2.下标总是与指针的偏移量相同 规则3.在函数参数的声明中,数组名被编译器当做指向该数组第一个元素的指针 9.2.1 规则1:“表达式中的数组名”就是指针 在表达式中,指针和数组时可以互换的,因为它们在编译器里的最终形式都是指针, 并且都可以进行取下标操作。 详情参见training9.2.c。 9.2.2 规则2:C语言把数组下标作为指针的偏移量 在编写数组算法时,使用指针在通常情况下并不比使用数组“更有效率”。具体参见P204 图9-2 在处理一维数组时,指针并不见得比数组更快。 9.2.3 “作为函数参数的数组名”等同于指针 编译器只向函数传递数组的地址,而不是整个数组的拷贝。 详见training9.3.c -------------------------------------- 2018/10/26(23:53): 9.3 为什么C语言把数组形参当作指针 把作为形参的数组和指针等同起来是出于效率原因的考虑。 数组形参是如何被引用的 倾向于始终把参数定义为指针,因为这是编译器内部使用的形式。 -------------------------------------- 2018/10/28(10:22): 9.4 数组片段的下标 如果想要让数组下标从1到N,那么在数组的声明中让它的长度比所需要的多1,然后只使用 1到N。 9.5 数组和指针可交换性的总结 1.用a[i]这样的形式对数组进行访问总是被编译器“改写”或解释为像*(a+1)的指针访问 2.指针始终是指针。它绝不可改写为数组。用下标形式访问指针,一般都是指针作为函数参数时,而实际传递给函数的是一个数组。 3.在特定的上下文中,也就是它作为函数的参数(也只有这种情况),一个数组的声明可以看作一个指针。 4.当把一个数组定义为函数的参数时,可以选择把它定义为数组,也可以定义为指针。 5.在其他所有情况下,定义和声明必须匹配。即:定义一个数组,在其他文件对它进行声明也必须声明为数组。 9.6 C语言的多维数组 9.6.1 但所有其他语言都把这称为“数组的数组” C语言:定义和引用多维数组惟一的方法就是使用数组的数组。例:training9.4.c *9.6.2 如何分解多维数组 多维数组如何分解为几个单独的数组:training9.5.c 9.6.3 内存中数组是如何布局的 在C语言的多维数组中,最右边的下标是最先变化的,这个约定称为“行主序”。 C语言中多维数组最大的用途是存储多个字符串。 9.6.4 如何对数组进行初始化 只能够在数组声明时对它进行整体的初始化。 对多维数组,可以省略最左边下标的长度(也只能是最左边的下标),编译器会根据初始化值的个数推断出它的长度。 初始化二维字符串数组的方法:(training9.6.c) 1.建立指针数组 2.一维数组的初始化方式 注意:指针数组不能由非字符串的类型直接初始化,得先创建几个单独的数组,在有数组名来初始化指针数组。 第十章 再论指针 10.1 多维数组的内存布局 对于二维数组pea[i][j],被编译器解析为 *(*(pea + i) + j) training10.1.c 10.2 指针数组就是Iliffe向量 声明一个一维指针数组,其中每个指针指向一个字符串来取得类似二维字符数组的效果。 char *pea[4];//Iliffe向量 这种数组必须用指向为字符串而分配的内存的指针进行初始化, 1.可以在编译时用一个常量初始值, 2.也可以在运行时用下面这样的代码进行初始化: for(j=0; j<=4; j++) pea[j] = malloc(6); 3.也可以一次性地用malloc分配整个x*y个数据的数组: malloc(row_size * colum_size * sizeof(char)); 然后用一个循环,用指针指向这块内存的各个区域。 优点:整个数组存储在连续的内存中,按C用于分配静态内存的次序。减少了调用malloc的维护性开销 缺点:当处理完一个字符串时无法单独将其释放。 数组的数组char a[4][6]和字符串指针数组的char *p[4]的区别: 详见P222 表10-1 和 表10-2 以及training10.2.c 10.3 在锯齿状数组上使用指针 Iliffe数组的价值: 1.出错各行长度不一的表 2.在一个函数调用中传递一个字符串数组 对于存储50个最大长度为255个字符的二维数组: char carrot[50][256]; 但经常这样做,内存的浪费很大。 替代方法(training10.3.c):使用字符串指针数组(它的所有第二级数组不需要长度一致,称为锯齿状数组), 字符串指针可以直接使用现有的,也可以通过分配内存创建一份现有字符串的新鲜拷贝。 注:如有可能,尽量不要选择拷贝整个字符串的方法。 如果需要从两个不同的数据结构访问它,拷贝一个指针比拷贝整个数组快得多,空间也节省得多。 另一个可能影响性能的因素是Illife向量可能会使字符串分配于内存中不同的页面中。 trianing10.4.c 1.3个函数都接受同样类型的参数,就是一个[2][3][5]int型三维数组或是一个指向[3][5]int型二维数组的指针 2.3个变量:apricot,p,*p都匹配所有3个函数的参数声明。 10.4 向函数传递一个一维数组 形参被改写为指向数组第一个元素的指针,需要一个约定来提示数组的长度。 1.增加一个额外的参数,来表示元素的数目(argc就是起这个作用) 2.赋予数组最后一个元素一个特殊的值,提示它是数组的尾部(字符串结尾的'\0'字符)。 这个特殊值必须不会作为正常的元素值在数组中出现。 二维数组,需要两个预定,一个提示每行的结束,另一个提示所有行的结束。 1.接收一个指向数组第一个元素的指针,每次对指针执行自增操作时,指针就指向数组中下一行的起始位置。 增加一个额外的行,行内所有元素的值都不可能在数组正常元素中出现,用来提示数组超出范围。当对指针 进行自增操作时,要对它进行检查,看指针是否到达额外的行。 2.定义一个额外的参数,提示数组的行数。 10.5 使用指针向函数传递一个多维数组 在函数内部声明一个二维数组参数 1.放弃传递二维数组,把array[x][y]改写为一个一维数组array[x+1],它的元素类型是指向array[y]的指针。 在数组最后的元素array[x+1]里存储一个NULL指针,提示数组的结束。 在C语言中,无法向函数传递一个普通的多维数组(三维及三维以上) invert_in_palce(int a[][3][5]) 可以调用 int b[10][3][5]; invert_in_palce(b); int b[999][3][5]; invert_in_palce(b); 但无法调用任意的三维数组(第二、第三位不同) int fails1[10][5][5]; invert_in_palce(fails1);//无法通过编译 int fails2[999][3][6]; invert_in_palce(fails2);//无法通过编译 10.5.1 方法一 my_function(int my_array[10][20]); 最简单,但它迫使函数只处理10行20列的int数组。 10.5.2 方法二 省略第一维的长度 my_function(int my_array[][20]); 不够充分,限制每一行必须正好是20个整数的长度。也可声明为 my_function(int(*my_array)[20]); 括号是必须的,确保它是一个指向20个元素的int数组的指针,而不是一个20个int指针元素的数组 10.5.3 方法三 放弃二维数组,把它的结构改为一个Illife向量。 my_function(char **my_array);(用于二维数组) 注意:只有把二维数组改为一个指向向量的指针数组的前提下才可以这样做。 它允许任意的字符串指针数组传递给函数,但必须是指向字符串的指针数组。 这是因为字符串和指针都有一个显式的越界值(分别为NUL和NULL)可以作为结束标记。 其它类型则没有一种内置的方法知道何时到达数组某一维的结束位置 10.5.4 方法四 放弃多维数组的形式,提供自己的下标方式。 例:char_array[row_size * i + j] = ... 三维或更多维的数组:必须把它分解为几个维数更小的数组。 注:对于多为数组作为参数传递的支持缺乏是C语言存在的一个内在限制。 10.6 使用指针从函数返回一个数组 例:training10.5.c 10.7 使用指针创建和使用动态数组 当预先不知道数据的长度时,可以使用动态数组。 在ANSI C中,数组是静态的。而在C++中可以动态修改数组大小。 动态数组的机理:一个数组下标访问可以改写为一个指针加上偏移量(使用内存分配) 例:training10.6.c 使表具有根据需要自动增长的能力。 有一个realloc()函数,能对一个现在的内存块大小进行重新分配,同时不丢失原先内存块的内容。 当需要在动态表中增长一个项目时,可以进行如下操作 1.对表进行检查,看它是否已满 2.如果已满,使用realloc()函数扩展表的长度。并进行检查,确保realloc()操作成功 3.在表中增加所需要的项目 例:training10.7.c