【C】《C专家编程》核心知识点总结

1、穿越时空的迷雾

这里写图片描述

这里写图片描述

编译器设计者的金科玉律:效率几乎就是一切,这包括两个方面,编译效率和运行效率,而后者起决定性作用。有很多编译优化措施会延长编译时间,但却能缩短运行时间;还有一些优化措施如清除误用代码和忽略运行时检查等,既能缩短编译时间,又能减少运行时间,同时还能减少内存的使用量,但有利就有弊,在于使用者如何权衡。

早期C语言的许多特性是为了方便编译器设计者而建立的,如类型系统、数组下标从0而不是1开始、基本数据类型直接与底层硬件相对应、默认的变量内存分配模式关键字auto、表达式中的数组名可以看作是指针、float被自动扩展为double、不运行函数内部包含另一个函数的定义、变量存放到寄存器的关键字register等。

C预处理器是编译器的第一个环节,主要功能是展开include的头文件(不局限于头文件)以及用到的宏,需要注意的是,宏的参数不作类型检查,宏的扩展受空格影响。

标准是一个很重要的东西,目前的C语言标准为ISO/IEC 9899:2011即C11,最初由ANSI C演变而来。整型变量在作数学运算时,如果类型不同,会进行类型提升即小整型提升为大整型,无符号类型还会转换为有符号类型,这必须引起重视,否则很可能造成严重的问题却又很难发现。关键字const用于声明变量是只读的,不可被修改,然而与指针一起使用时容易引起混淆,位置不同,作用不同,可能表示指针本身只读,也可能表示指针指向的内容只读。

    int foo = 100;
    int foo2 = 101;
    const int * bar = &foo; // 指针指向的内容只读
    int const * bar2 = &foo; // 指针指向的内容只读
    int * const bar3 = &foo; // 指针本身只读
    bar = &foo2;
    bar2 = &foo2;
    //bar3 = &foo2; // error
    //*bar = foo2; // error
    //*bar2 = foo2; // error
    *bar3 = foo2;

2、这不是Bug而是语言特性

这里写图片描述

这里写图片描述

编程语言缺陷归于三类,多做之过、少做之过和误做之过,防止掉入语言缺陷和陷进的最好办法就是熟悉语言特性,下面总结了需要熟记的C语言特性——
(1)NUL用于结束一个ASCII字符串,NULL用于表示空指针。
(2)合理使用语句块即一对大括号,限制变量作用域。
(3)const变量不是常量,而是只读变量。
(4)switch-case语句,注意break、default。
(5)续行可以使用反斜杠,但同一行的相邻字符串或者相邻行的字符串可以自动合并为一个字符串。
(6)函数中定义的static变量只有在第一次执行时会走到。
(7)函数定义时,默认存储类型是全局可见的,等同于在函数名前使用表示外部可见的关键字extern,如果只限于在当前文件内可见,则使用关键字static。
(8)注意各操作符的优先级及结合性,适当使用括号。
(9)正确使用库函数,如gets与fgets,两者功能类似,但后者限制了字符数,没有前者造成的缓冲区溢出风险。
(10)注意空格的使用,区别功能需要和代码可读性需要。
(11)函数的返回类型为指针时,注意不能返回指向局部变量的指针。

3、分析C语言的声明

C语言声明,涉及如下几类符号——
基本类型说明符:void char short int long signed unsigned float double
结构类型说明符:struct
枚举类型说明符:enum
联合类型说明符:union
存储类型说明符:extern static register auto
类型限定符:const volatile
其它:typedef define

C语言声明中,经常会看到一些复杂的声明语句,特别是有指针存在的场合,这需要了解C语言声明的优先级规则,如下——
(A)声明从它的名字开始读取,然后按照优先级顺序依次读取。
(B)优先级从高到低依次是:
(B.1)声明中被括号括起来的那部分
(B.2)后缀操作符:括号表示这是一个函数,而方括号表示这是一个数组。
(B.3)前缀操作符:星号表示指向什么的指针
(C)如果const、volatile关键字的后面紧跟类型说明符如int、long等,那么它作用于类型说明符;在其它情况下,const、volatile作用于它左边紧邻的指针星号。

这里写图片描述

char * const *(*next)();

首先,next是一个指针,它指向一个函数,该函数返回另一个指针,该指针指向一个类型为char的常量指针,或者说next是一个指向函数的指针,该函数返回另一个指针,该指针指向一个只读的指向char的指针。

typedef和define都可以定义数据类型,但两者有很大的差别,首先,可以用其它类型说明符对宏类型名进行扩展,但对typedef所定义的类型名却不能这样做,其次,在连续几个变量的声明中,用typedef定义的类型能够保证声明中所有的变量均为同一种类型,而用define定义的类型则无法保证。

#define peach int
unsigned peach i; // 简单直接的宏替换 把peach替换为int ok
typedef int banana;
unsigned banana j; // typedef是个整体 不能再进行别的扩展 ng

#define int_ptr int *
int_ptr chalk, cheese; // int * chalk, cheese; chalk为指向int的指针类型 cheese为int类型
typedef char * char_ptr;
char_ptr Bentley, Rolls_Royce; // Bentley和Rolls_Royce都为指向char的指针类型

4、数组和指针并不相同

这里写图片描述

在C语言中,数组和指针非常类似,但不完全相同。首先,从声明和定义开始,定义有且只有一个,extern声明却可以有多个,定义是一种特殊的声明,它创建了一个对象,而声明简单地说明了在其它地方创建的对象的名字,它允许你使用这个名字,也就是说,定义只能出现在一个地方,用于确定对象的类型并分配内存,创建新的对象,而声明可以出现多次,用于描述对象的类型,指代其它地方定义的对象。extern对象声明告诉编译器对象的类型和名字,对象的内存分配则在别处进行,所以在数组声明中并未分配内存,并不需要提供关于数组长度的信息,这就给编译器足够的信息产生相应的代码。

在许多情况下,数组可以像指针那样通过地址解引用访问,而指针也可以像数组那样通过下标索引访问,但两者有内在的不同。首先,对于一个变量来说,有两层含义,它们是变量地址和变量地址的内容,具体含义由编译器根据上下文环境判断,在等号左边的代表地址,称为左值,在编译时可知,表示存储结果的地方,在等号右边的代表地址的内容,称为右值,直到运行时才可知,左值包括可修改的左值,可修改的左值允许出现在赋值语句的左边,这个是为了区分数组名,数组名也用于确定对象在内存中的位置,也是左值,但它不能作为赋值的对象,不是可修改的左值。

编译器为每个变量分配一个地址即左值,这个地址在编译时可知,而且该变量在运行时一直保存于这个地址,相反,存储于变量中的值即它的右值,只有在运行时才可知,如果需要用到变量中存储的值,编译器就发出指令从指定地址读入变量值并将它存于寄存器中。这里的关键之处在于每个符号的地址在编译时可知,所以,如果编译器需要一个地址,可能还需要加上偏移量来执行某种操作,它就可以直接进行操作,并不需要增加指令首先取得具体的地址,相反,对于指针,必须首先在运行时取得它的当前值,然后才能对它进行解除引用操作。

extern char a[]; // 声明1
extern char a[100]; // 声明2
extern char *a; // 声明3

所以,上面的声明1和声明2是等价的,它们都提示a是一个数组,也就是一个内存地址,数组内的字符可以从这个地址找到,编译器并不需要知道数组总共有多长,因为它只产生偏离起始地址的偏移地址,从数组提取一个字符,只要简单地从符号表显示的a的地址加上下标,需要的字符就位于这个地址中,但是,声明3告诉编译器a是一个指针,在32位的机器里它是个四字节的对象,它指向的对象是一个字符,为了取得这个字符,必须得到地址a的内容,把它作为字符的地址并从这个地址中取得字符,指针的访问要灵活的多,但需要增加一次额外的提取。

char *a= "helloworld";
char a[] = "helloworld";

进一步来说,数组和指针的内部访问原理不同,正确的做法就是在定义和声明时保持一致,否则很可能会污染程序地址空间的内容,出现莫名其妙的错误。数组和指针都可以在它们的定义中用字符串常量进行初始化,尽管看上去一样,底层的机制却是不同的,定义指针时,编译器并不为指针所指向的对象分配空间,它只是分配指针本身的空间,除非在定义时同时赋给指针一个字符串常量进行初始化,也仅仅是字符串常量进行初始化的情况,而且这个字符串常量是只读的,不能通过指针进行修改,与指针相反,由字符串常量初始化的数组是可以修改的。

5、对链接的思考

这里写图片描述

这里写图片描述

动态链接与静态链接的优缺点是相对的。动态链接的优点是可执行文件的体积可以非常小,虽然运行速度稍慢一些,但动态链接能够更加有效地利用磁盘空间,节省虚拟内存,只有在需要时才被映射到进程中,所有动态链接到某个特定函数库的可执行文件在运行时共享该函数库的一个单独拷贝,提供了更好的IO和交换空间利用率,节省了物理内存,从而提高了系统的整体性能,而且链接-编辑阶段的时间也会缩短,链接器的有些工作被推迟到载入时。在动态链接中,所有的库符号进入输出文件的虚拟地址空间中,所有的符号对于链接在一起的所有文件都是可见的,相反,对于静态链接,它只是查找载入器当时所知道的未定义符号,所以在编译命令中通常把链接的函数库放到使用者的右边或者命令的最后,防止出现未定义符号的错误。

动态链接的目的之一是ABI即程序二进制接口,把程序与它们使用的特定的函数库版本中分离开来。动态链接是一种JIT即just-in-time链接,程序在运行时必须能够找到它们所需要的函数库,链接器通过把库文件名或路径名植入可执行文件中来做到这一点。创建动态或者静态的函数库,只需简单地编译一些不包含main函数的代码,并把编译生成的.o文件用ld或ar工具进行处理。

使用编译器选项可以为函数库产生与位置无关的代码,保证对于任何全局数据的访问都是通过额外的间接方法完成的,这样很容易对数据进行重新定位,只要简单地修改全局偏移量表的其中一个值就可以了,每个函数调用的产生就像是通过过程链接表的某个间接地址所产生的一样,文本可以很容易地定位到任何地方,所以,当代码在运行时被映射进来时,运行时链接器可以直接把它们放在任何空闲的地方,而代码本身并不需要修改。默认代码相关,因为代码无关时额外的指针解除引用操作将使程序在运行时稍稍变慢,代码相关的情况下所产生的代码会被对应到固定的地址,对于可执行文件来说很好,但对于共享库来说速度就要慢一点,因为每个全局引用必须在运行时通过修改页面安排到固定的位置,而运行时链接器总能够安排对页面的引用。

6、运行时数据结构

这里写图片描述

这里写图片描述

这里写图片描述

这里写图片描述

a.out是个很熟悉的名字,即assembler output,怎么是汇编输出,不应该是链接输出吗?其实,一开始并不存在链接器,创建程序时,先把所有文件连接在一起,然后进行汇编,汇编产生的汇编程序输出保存在a.out中,后来增加了链接器,但仍沿用a.out这个名字。编译产生的目标文件和可执行文件可以有几种不同的格式,在Linux上常见的便是ELF格式即Executable and Linking Format。

a.out中包含了若干段,在操作系统的内存管理术语中,段就是一片连续的虚拟地址。段中的不同section可以设置适当的属性,读、写和执行,例如,文本只读和可执行,数据只读或者只读写。文本段包含程序的指令,链接器把指令直接从文件拷贝到内存中,一般使用mmap系统调用,之后文本内容、大小都不会改变。数据段包含经过初始化的全局和静态变量以及它们的值,一般情况下,在任何进程中数据段是最大的段。BSS段即Block Started by Symbol,只保存没有值的变量,所以事实上它并不需要保存这些变量的映像,运行时所需要的BSS段的大小记录在可执行文件中,但BSS段不像其它段,并不占据目标文件的任何空间,紧跟在数据段之后,当这个内存区进入程序的地址空间后全部清零。BSS段和数据段统称为数据区。堆栈段用于保存局部变量、临时数据、传递到函数的参数等。在进程的地址空间中,还包括堆空间,用于动态分配的内存。虚拟地址空间的最低部分未被映射,位于进程的地址空间内,但并未赋予物理地址,所以任何对它的引用都是非法的,一般是从零开始的几K字节,用于捕捉使用空指针和小整型值的指针引用内存的情况。

堆栈段是一块动态内存区域,向下增长,也就是朝着低地址的方向增长,由系统维护,实现了一种后进先出的结构,运行时系统维护一个位于寄存器的堆栈指针sp,用于提示堆栈当前的顶部位置。堆栈段有三个主要的用途,一是为函数内部声明的局部变量提高存储空间,二是存储函数调用时与此有关的堆栈结构或称为过程活动记录,三是用作暂时存储区如alloca函数。除了递归调用之外,堆栈并非必需,因为在编译时可以知道局部变量、参数和返回地址所需空间的固定大小,并可以将它们分配于BSS段。

上面提到了过程活动记录,它是一种数据结构,用于支持过程调用,并记录调用结束以后返回调用点所需要的全部信息。运行时系统维护一个指针,常常位于寄存器中,通常成为fp,用于提示活动堆栈结构,它的值是最靠近堆栈顶部的过程活动记录的地址。可以把过程活动记录压入堆栈段中,但这也不是必需的,有些架构把过程活动记录的内容存放到寄存器中,这使得函数调用更快。关于过程活动记录,setjmp和longjmp函数便是通过操纵过程活动记录来实现的,最大的用途是错误恢复,C++引入了异常处理机制try-catch-throw。需要注意的地方是,保证局部变量在longjmp过程中一直保持它的值的唯一可靠方法是把它声明为volatile,这适用于那些值在setjmp执行和longjmp返回之间会变化的值。longjmp不同于goto,goto语句不能跳出C语言的当前函数,而longjmp可以跳得很远,甚至可以跳到其它文件的函数中,只要是setjmp曾经到过的地方即可,与goto类似的是,使得程序难以理解和调试,最好避免使用。

标准的代码优化技巧包括:消除循环、函数代码就地扩展、公共子表达式消除、改进寄存器分配、省略运行时对数据边界的检查、循环不变量代码移动、操作符长度削减(把指数操作转变为乘法操作,把乘法操作转变为移位操作或加法操作)等。

7、对内存的思考

这里写图片描述

这里写图片描述

今天,计算机系统结构的真正挑战不在于内存的容量,而是内存的速度,在相同的时间内,CPU速度倍增,内存的容量倍增,但内存的速度增长缓慢,于是只能寄望Cache以及相关技术。所有现代的计算机系统,都使用了虚拟内存,在任一给定时刻,程序实际需要使用的虚拟内存区段的内容就被载入物理内存中,当物理内存中数据有一段时间未被使用,它们就可能被转移到硬盘中,节省下来的物理内存空间用于载入需要使用的其它数据。通过虚拟内存,每个进程都以为自己拥有整个地址空间的独家访问权,所有进程共享机器的物理内存,当内存用完时就用磁盘保存数据,在进程运行时,数据在磁盘和内存之间来回移动,内存管理单元MMU负责把虚拟地址翻译为物理地址,并让一个进程始终运行于系统的真正内存中。如果该进程可能不会马上运行,可能它的优先级低,也可能是它处于睡眠状态,操作系统可以暂时取回所有分配给的它的物理内存资源,将该进程的所有相关信息都备份到磁盘上,这样,这个进程就被换出,在磁盘中有个特殊的交换区,用于保存从内存中换出的进程,在一台机器中,交换区的大小一般是物理内存的几倍,只有用户进程才会被换进换出。

Cache存储器是多层存储概念的扩展,位于CPU和内存之间,是一种极快的存储缓冲区。所有的现代处理器都使用了Cache存储器,当数据从内存读入时,一般16或32字节的整行的数据被装入Cache,如果程序具有良好的地址引用局部性,如顺序浏览一个字符串,那么CPU以后对临近数据的访问就可以从快速的Cache读取,而不用从缓慢的内存中读取。Cache操作的速度与系统的周期时间相同,所以一个50MHz的处理器,其Cach的存取周期为20ns,主存的存取速度可能只有它的四分之一。

在进程的地址空间中,堆空间位于数据区之上,堆的末端有一个称为break的指针来标识,当堆管理器需要更多内存时,它可以通过系统调用brk和sbrk来移动break指针。被分配的内存总是经过对齐,以适合机器上最大尺寸的原子访问,一个malloc请求申请的内存大小为方便起见一般被圆整为2的乘方。堆内存的回收不必与它所分配的顺序一致,所以无序的malloc/free最终会产生堆碎片,如果未释放不再使用的内存,就会造成内存泄漏,如果释放或改写仍在使用的内存,就会造成内存破坏。内存破坏是致命的,而内存泄漏不仅仅是泄漏那么简单,泄漏的内存本身并不被引用,但它仍可能存在于页面中,这样就增加了进程的工作页数量,而且泄漏的内存往往比忘记释放的数据结构要大,内存泄漏的进程有可能被系统换出,让别的进程运行,进程在换进换出时花费的时间也更多,最终导致速度变慢,性能下降。

常见的两个运行时错误,bus error和segmentation fault,默认结果为core dumped,源于操作系统所检测到的异常,当硬件告诉操作系统一个有问题的内存引用即硬件中断时,操作系统通过向出错的进程发送一个信号与之交流,信号就是一种事件通知或者软件中断,可以为这些信号设置一个信号处理程序,用于修改进程的默认结果,但信号是异步发生的,编程和调试都较为复杂。

union {
    char a[10];
    int i;
} u;
int *p = (int*)&(u.a[1]);
*p = 100; // bus error

bus error即总线错误,几乎都是由于未对齐的读或写引起的,对齐的意思就是数据项只能存储在地址是数据项大小的整数倍的内存位置上,数据项不能跨越页面或者Cache边界。上面例子中,数组和int的联合确保数组a是按照int的四字节对齐的,所以a+1的地址肯定未按int对齐,然后试图往这个地址存储4个字节的数据,但这个访问只是按照单字节的char对齐,这就违反了规则,导致总线错误。编译器通过在内存中自动分配和填充数据来进行对齐,一个好的编译器发现不对齐的情况时会发出警告,但它并不能检测到所有不对齐的情况。

int *p = 0;
*p = 100; // segmatation fault

segmatation fault即段错误,由MMU异常所致,如解除引用一个未初始化或非法值的指针。如果指针引用一个并不位于你的地址空间中的地址,操作系统便会对此进行干涉。一个微妙之处是,对于指针所要访问的数据而言,如果未初始化的指针恰好具有未对齐的值,它将会产生总线错误,而不是段错误,因为CPU先看到地址,然后再把它发送给MMU。以发生频率为序,最终可能导致段错误的编程错误是:在指针赋值之前就用它来访问内存,向库函数传递一个坏指针,对指针释放之后再访问它的内容,越过数据边界写入数据,在动态分配的内存两端之外写入数据,改写堆管理数据结构如在动态分配的内存之前的区域写入数据,释放同一个内存块两次,释放一块未曾使用malloc分配的内存,释放仍在使用中的内存,释放一个无效的指针,等等。

8、C语言中的类型提升

这里写图片描述

9、再论数组

这里写图片描述

所有作为函数参数的数组名称总是可以通过编译器转换为指针,在其它所有情况下,数组的声明就是数组,指针的声明就是指针,两者不能混淆,但在使用数组(在语句或表达式中引用)时,数组总是可以写成指针的形式,两者可以互换,数组下标表达式总是可以改写为带偏移量的指针表达式,当一个数组名出现在一个表达式中时,它会被转换为指向该数组第一个元素的指针。C语言把数组下标改写成指针偏移量的根本原因是指针和偏移量是底层硬件所使用的基本模型,但随着编译器的优化,在处理一维数组时,指针并不见得比数组更快。把作为函数形参的数组和指针等同起来是出于效率原因的考虑。

下面是数组和指针可交换性的总结——
(1)用a[i]这样的形式对数组进行访问,总是被编译器改写或解释为像*(a+i)这样的指针访问。
(2)指针始终就是指针,它绝不可以改写成数组,你可以用下标形式访问指针,一般都是指针作为函数参数时,而且你知道实际传递给函数的是一个数组。
(3)在特定的上下文中,也就是它作为函数的参数,也只有这种情况,一个数组的声明可以看作是一个指针,作为函数参数的数组,始终会被编译器修改成指向数组第一个元素的指针。
(4)因此,当把一个数组定义为函数的参数时,可以选择把它定义为数组,也可以定义指针,不管选择哪种方法,在函数内部事实上获得的都是一个指针。
(5)在其它所有情况下,定义和声明必须匹配,如果定义了一个数组,在其它文件对它进行声明时也必须把它声明为数组,指针也是如此。

char carrot[10][20];
carrit[5][6] = 0;
*(*(carrit+5)+6) = 0;

C语言支持多维数组,即数组的数组,以上面的二维数组carrot为例,carrot有10个元素,每个元素是个一维数组,这个一维数组有20个char类型的元素,同样可以使用下标或者指针加偏移量的形式进行访问。在C语言的二维数组中,最右边的下标是最先变化的,这个约定被成为行主序,可以理解为一张行列表,但实际的内存布局是线性存储的。

10、再论指针

这里写图片描述

这里写图片描述

结束

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值