一,C语言预处理
1、由源码到可执行程序的过程
源码.c->(预处理)->预处理过的.i源文件->(编译)->汇编文件.S->(汇编)->目标文件.o->(链接)->elf可执行程序
- 预处理用预处理器,编译用编译器,汇编用汇编器,链接用链接器,这几个工具再加上其他一些额外的会用到的可用工具,合起来叫编译工具链。gcc就是一个编译工具链。
编译链接中各种文件扩展名的含义
- 在Linux系统中,分为可执行文件和不可执行文件,由源码到可执行程序的过程中,以扩展名(即后)来区分各个阶段。GCC中一些常见的扩展名,我们需要注意扩展名的写法及其背后的含义,否则编译失败,例如
扩展名 | 含义 |
---|---|
.c | C语言源代码文件 |
.m | Objective-C源代码文件 |
.a | 由目标文件构成的静态库文件 |
.o | 编译后的目标文件 |
.C | CC++源代码文件 |
.out | 链接器生成的可执行文件 |
.h | 程序所包含的头文件 |
.s | 汇编语言源代码文件 后期不再进行预处理操作 |
.S | 汇编语言源代码文件,后期还会进行预处理操作,可以包含预处理指令 |
.i | 预处理过的C源代码文件 |
.ii | 预处理过的C++源代码文件 |
2、预处理的意义
- (1)编译器本身的主要目的是编译源代码,将C的源代码转化成.S的汇编代码。编译器聚焦核心功能后,就剥离出了一些非核心的功能到预处理器去了。
- (2)预处理器帮编译器做一些编译前的杂事。
3、编程中常见的预处理
- (1)#include
- (2)注释
- (3)#if #elif #endif #ifdef
- (4)宏定义
4、gcc中只预处理不编译的方法
- (1)gcc编译时可以给一些参数来做一些设置,譬如
gcc xx.c -o xx
可以指定可执行程序的名称;譬如gcc xx.c -c -o xx.o
可以指定只编译不连接,也可以生成.o的目标文件。 - (2)
gcc -E xx.c -o xx.i
可以实现只预处理不编译。一般情况下没必要只预处理不编译,但有时候这种技巧可以用来帮助我们研究预处理过程,帮助debug程序。
preprocess.c源代码文件
#define pchar char *
typedef char * PCHAR
int main(void)
{
pchar p3,P4;
PCHAR p1, p2;
return 0;
}
通过执行gcc -E preprocess.c -o preprocess.i (只预处理不编译)
得到preprocess.i
typedef char * PCHAR
int main(void)
{
char * p3,P4;
char * p1, char * p2;
return 0;
}
- 总结:宏定义被预处理时的现象有:第一,宏定义语句本身不见了;第二,typedef重命名语言还在,说明它和宏定义是有本质区别的(说明typedef是由编译器来处理而不是预处理器处理的);
5、头文件包含
-
(1)
#include <>
和#include" "
的区别:<>专门用来包含系统提供的头文件,""用来包含自己写的头文件;更深层次来说:<>的话C语言编译器只会到系统指定目录(编译器中配置的或者操作系统配置的寻找目录,譬如在ubuntu中是/usr/include目录,编译器还允许用-I来附加指定其他的包含路径)去寻找这个头文件(隐含意思就是不会找当前目录下),如果找不到就会提示这个头文件不存在。 -
(2)""包含的头文件,编译器默认会先在当前目录下寻找相应的头文件,如果没找到然后再到系统指定目录去寻找,如果还没找到则提示文件不存在。
-
注意:规则虽然允许用双引号来包含系统指定目录,但是一般的使用原则是:如果是系统指定的自带的用<>,如果是自己写的在当前目录下放着用"",如果是自己写的但是集中放在了一起专门存放头文件的目录下将来在编译器中用-I参数来寻找,这种情况下用<>。
-
(3)头文件包含的真实含义就是:在#include<xx.h>的那一行,将xx.h这个头文件的内容原地展开替换这一行#include语句。过程在预处理中进行。
6、注释
- (1)注释是给人看的,不是给编译器看的。
- (2)编译器既然不看注释,那么编译时最好没有注释的。实际上在预处理阶段,预处理器会拿掉程序中所有的注释语句,到了编译器编译阶段程序中其实已经没有注释了。
在工程中注释应该注意些什么
注释的原则是有助于对程序的阅读理解,下面列举一些有关注释的注意点
- 注释格式尽量统一,建议使用/**/。Linux内核中的注释几乎都使用这个。
- 注释应考虑程序易读及排版的优美,注释语言应尽量统一
- 函数头部应该注释,也可以使用代码自注释。
- 建议边写代码边注释,更改代码的同时更改注释,保证注释与代码一致。
- 防止注释出现二义性,所以注释必须要简洁明了,而且尽量不要使用缩写。
- 在单行代码注释时,注释应放在代码的上方或者右方有些特殊内容晦涩难懂的,一定要进行注释,如一些具有特殊意义的数
- 注释和代码同缩进,并且注释与上方代码之间要空出一行
7、条件编译
- (1)有时候我们希望程序有多种配置,我们在源代码编写时写好了各种配置的代码,然后给个配置开关,在源代码级别去修改配置开关来让程序编译出不同的效果。
- (2)条件编译中用的两种条件判定方法分别是#ifdef 和 #if
int a = 0;
#ifdef NUM // 如果前面有定义NUM这个符号,成立
a = 111;
printf("#ifdef NUM.\n");
#else // 如果前面没有定义NUM这个符号,则执行下面的语句
a = 222;
printf("#elif.\n");
#endif
printf("a = %d.\n", a);
int a = 0;
#if (NUM == 0) // 如果前面有定义NUM这个符号,成立
a = 111;
printf("#ifdef NUM.\n");
#else // 如果前面没有定义NUM这个符号,则执行下面的语句
a = 222;
printf("#elif.\n");
#endif
- 区别:
#ifdef XXX
判定条件成立与否时主要是看XXX这个符号在本语句之前有没有被定义,只要定义了这个符号就是成立的。
#if的格式是:#if (条件表达式),
它的判定标准是()中的表达式是否为true还是flase,跟C中的if语句有点像。
二,宏定义
1、宏定义的规则和使用解析
- (1)宏定义的解析规则就是:在预处理阶段由预处理器进行替换,这个替换是原封不动的替换。
- (2)宏定义替换会递归进行,直到替换出来的值本身不再是一个宏为止。
- (3)一个正确的宏定义式子本身分为3部分:第一部分是#dedine ,第二部分是宏名 ,剩下的所有为第三部分。
- (4)宏可以带参数,称为带参宏。带参宏的使用和带参函数非常像,但是使用上有一些差异。在定义带参宏时,每一个参数在宏体中引用时都必须加括号,最后整体再加括号,括号缺一不可。
2、宏定义示例1:MAX宏,求2个数中较大的一个(带参数宏)
#define MAX(a, b) (((a)>(b)) ? (a) : (b))
3、宏定义示例2:SEC_PER_YEAR,用宏定义表示一年中有多少秒(不带参数宏)
#define SEC_PER_YEAR (365*24*60*60UL)
关键:
第一点:当一个数字直接出现在程序中时,它的是类型默认是int
第二点:一年有多少秒,这个数字刚好超过了int类型存储的范围
4、带参宏和带参函数的区别(宏定义的缺陷)
-
(1)宏定义是在预处理期间处理的,而函数是在编译期间处理的。这个区别带来的实质差异是:宏定义最终是在调用宏的地方把宏体原地展开,而函数是在调用函数处跳转到函数中去执行,执行完后再跳转回来。
-
宏定义和函数的最大差别就是:宏定义是原地展开,因此没有调用开销;而函数是跳转执行再返回,因此函数有比较大的调用开销。所以宏定义和函数相比,优势就是没有调用开销,没有传参开销,所以当函数体很短(尤其是只有一句话时)可以用宏定义来替代,这样效率高。
-
(2)带参宏和带参函数的一个重要差别就是:宏定义不会检查参数的类型,返回值也不会附带类型;而函数有明确的参数类型和返回值类型。当我们调用函数时编译器会帮我们做参数的静态类型检查,如果编译器发现我们实际传参和参数声明不同时会报警告或错误。
5、内联函数和inline关键字
- (1)内联函数通过在函数定义前加inline关键字实现。
- (2)内联函数本质上是函数,所以有函数的优点(内联函数是编译器负责处理的,编译器可以帮我们做参数的静态类型检查);但是他同时也有带参宏的优点(不用调用开销,而是原地展开)。所以几乎可以这样认为:内联函数就是带了参数静态类型检查的宏。
- (3)当我们的函数内函数体很短(譬如只有一两句话)的时候,我们又希望利用编译器的参数类型检查来排错,我还希望没有调用开销时,最适合使用内联函数。
6、宏定义来实现条件编译(#define #undef #ifdef)
(1)#ifndef ,#define和#endif
#ifndef __ARM_H_ //如果不存在ARM.h
#define __ARM_H_ //就引入ARM.h
/*。。。。。
头文件的内容
。。。。。*/
#endif //否则不引入ARM.h
- 这是为了避免重复包含ARM.h因为当第一次包含这个头文件的时候,会定义出__ARM_H_宏,后续再次包含该头文件时,因为已经有了该宏定义,#ifndef _ARM_H 不会成立,所以头文件不会被再次包含.
(2)#if defined,#ifdef 和#if !defined, #ifndef
#if define (x)
/...code...../
#endif
- 在这个
#if defined
中,不管括号里面的x的逻辑是真还是假它只管这个程序的前面的宏定义里面有没有定义“x”这个宏,如果定义了x这个宏,那么编译器会编译中间的code,否则直接忽视中间的…code…代码。#if defined取反就是#if ! defined.。#ifdef x与 #ifdefined (x)用法相似。
#include <stdio.h>
#define NUM 1
int main(void)
{
int a = 0;
#ifdef NUM // 如果前面有定义NUM这个符号,成立
a = 111;
printf("#ifdef NUM.\n");
#else // 如果前面没有定义NUM这个符号,则执行下面的语句
a = 222;
printf("#elif.\n");
#endif
return 0;
}
通过gcc -E a.c -o a.i (只预处理不编译)得到a.i
int main(void)
{
int a = 0;
a = 111;
printf("#ifdef NUM.\n");
return 0;
}
(3)#ifdef与#if defined的区别
- 区别在于#if defined可以组成复杂的预编译条件,如下所示。
#if define(A) && define (B)
... <code> ....
#endif
表示只有A和B这两个宏定义都存在的时候才编译代码,而ifdef只能判断单个宏定义,不能判断多个复杂条件。
三,函数
1.函数的本质
1、C语言为什么会有函数
- (1)整个程序分成多个源文件,一个文件分成多个函数,一个函数分成多个语句,这就是整个程序的组织形式。这样组织的好处在于:分化问题、便于编写程序、便于分工。
- (2)函数的出现是人(程序员和架构师)的需要,而不是机器(编译器、CPU)的需要。
- (3)函数的目的就是实现模块化编程。说白了就是为了提供程序的可移植性。
2、函数书写的一般原则:
- 第一:遵循一定格式。函数的返回类型、函数名、参数列表等。
- 第二:一个函数只做一件事:函数不能太长也不宜太短,原则是一个函数只做一件事情。
- 第三:传参不宜过多:在ARM体系下,传参不宜超过4个。如果传参确实需要多则考虑结构体打包
- 第四:尽量少碰全局变量:函数最好用传参返回值来和外部交换数据,不要用全局变量。
3、函数是动词、变量是名词(面相对象中分别叫方法和成员变量)
- (1)函数将来被编译成可执行代码段,变量(主要指全局变量)经过编译后变成数据或者在运行时变成数据。一个程序的运行需要代码和数据两方向的结合才能完成。
- (2)代码和数据需要彼此配合,代码是为了加工数据,数据必须借助代码来起作用。拿现实中的工厂来比喻:数据是原材料,代码是加工流水线。名词性的数据必须经过动词性的加工才能变成最终我们需要的产出的数据。这个加工的过程就是程序的执行过程。
4、函数的实质是:数据处理器
- (1)程序的主体是数据,也就是说程序运行的主要目标是生成目标数据,我们写代码也是为了目标数据。我们如何得到目标数据?必须2个因素:原材料+加工算法。原材料就是程序的输入数据,加工算法就是程序。
- (2)程序的编写和运行就是为了把原数据加工成目标数据,所以程序的实质就是一个数据处理器。
- (3)函数就是程序的一个缩影,函数的参数列表其实就是为了给函数输入原材料数据,函数的返回值和输出型参数就是为了向外部输出目标数据,函数的函数体里的那些代码就是加工算法。
- (4)函数在静止没有执行(乖乖的躺在硬盘里)的时候就好象一台没有开动的机器,此时只占一些存储空间但是并不占用资源(CPU+内存);函数的每一次运行就好象机器的每一次开机运行,运行时需要耗费资源(CPU+内存),运行时可以对数据加工生成目标数据;函数运行完毕会释放占用的资源。
- (5)整个程序的运行其实就是很多个函数相继运行的连续过程。
2.函数的基本使用
1、函数三要素:定义、声明、调用
- (1)函数的定义就是函数体、函数声明是函数原型、函数调用就是使用函数
- (2)函数定义是函数的根本,函数定义中的函数名表示了这个函数在内存中的首地址,所以可以用函数名来调用执行这个函数(实质是指针解引用访问);函数定义中的函数体是函数的执行关键,函数将来执行时主要就是执行函数体。所以一个函数没有定义就是无基之塔。
- (3)函数声明的主要作用是告诉编译器函数的原型
2、函数原型和作用
-
(1)函数原型就是函数的声明,说白了就是函数的函数名、返回值类型、参数列表。
-
(2)函数原型的主要作用就是给编译器提供原型,让编译器在编译程序时帮我们进行参数的静态类型检查
-
(3)必须明白:编译器在编译程序时是以单个源文件为单位的(所以一定要在哪里调用在哪里声明),而且编译器工作时已经经过预处理处理了,最最重要的是编译器编译文件时是按照文件中语句的先后顺序执行的。
-
(4)编译器从源文件的第一行开始编译,遇到函数声明时就会收到编译器的函数声明表中,然后继续向后。当遇到一个函数调用时,就在我的本文件的函数声明表中去查这个函数,看有没有原型相对应的一个函数(这个相对应的函数有且只能有一个)。如果没有或者只有部分匹配则会报错或报警告;如果发现多个则会报错或报警告(函数重复了,C语言中不允许2个函数原型完全一样,这个过程其实是在编译器遇到函数定义时完成的。所以函数可以重复声明但是不能重复定义)
#include <stdio.h>
int add(int a, int b); // 函数声明
int main(void)
{
int a = 3, b = 5;
int sum = add(a, b); // 典型的函数调用
printf("3+5=%d.\n", add(3, 5)); // add函数的返回值作为printf函数的一个参数
return 0;
}
// 函数定义
int add(int a, int b) // 函数名、参数列表、返回值
{
return a + b; //函数体
}
3.递归函数
1、什么是递归函数
- (1)递归函数就是函数中调用了自己本身这个函数的函数。
- (2)递归函数和循环的区别。递归不等于循环
- (3)递归函数解决问题的典型就是:求阶乘、求斐波那契数列
// 用递归函数来计算阶乘
#include <stdio.h>
int jiecheng(int n); // 函数声明
void digui(int n);
int main(void)
{
digui(300);
//int a = 5;
//printf("%d的阶乘是:%d.\n", a, jiecheng(a));
return 0;
}
// 函数定义
int jiecheng(int n)
{
// 传参错误校验
if (n < 1)
{
printf("n必须大于等于1.\n");
return -1;
}
if (n == 1)
{
return 1;
}
else
{
return (n * jiecheng(n-1));
}
}
void digui(int n)
{
int a[100];
//printf("递归前:n = %d.\n", n);
if (n > 1)
{
digui(n-1);
}
else
{
printf("结束递归,n = %d.\n", n);
}
printf("递归后:n = %d.\n", n);
}
2、函数的递归调用原理
- (1)实际上递归函数是在栈内存上递归执行的,每次递归执行一次就需要耗费一些栈内存。
- (2)栈内存的大小是限制递归深度的重要因素。
3、使用递归函数的原则:收敛性、栈溢出
- (1)收敛性就是说:递归函数必须有一个终止递归的条件。当每次这个函数被执行时,我们判断一个条件决定是否继续递归,这个条件最终必须能够被满足。如果没有递归终止条件或者这个条件永远不能被满足,则这个递归没有收敛性,这个递归最终要失败。
- (2)因为递归是占用栈内存的,每次递归调用都会消耗一些栈内存。因此必须在栈内存耗尽之前递归收敛(终止),否则就会栈溢出。
- (3)递归函数的使用是有一定风险的,必须把握好。
4.函数库
1、什么是函数库?
- (1)函数库就是一些事先写好的函数的集合,给别人复用。
- (2)函数是模块化的,因此可以被复用。我们写好了一个函数,可以被反复使用。也可以A写好了一个函数然后共享出来,当B有相同的需求时就不需自己写直接用A写好的这个函数即可。
2、函数库的由来
- (1)最开始是没有函数库,每个人写程序都要从零开始自己写。时间长了慢慢的早期的程序员就积累下来了一些有用的函数。
- (2)早期的程序员经常参加行业聚会,在聚会上大家互相交换各自的函数库。
- (3)后来程序员中的一些大神就提出把大家各自的函数库收拢在一起,然后经过校准和整理,最后形成了一份标准化的函数库,就是现在的标准的函数库,譬如说glibc。
3、函数库的提供形式:动态链接库与静态链接库
-
(1)早期的函数共享都是以源代码的形式进行的。这种方式共享是最彻底的(后来这种源码共享的方向就形成了我们现在的开源社区)。但是这种方式有它的缺点,缺点就是无法以商业化形式来发布函数库。
-
(2)商业公司需要将自己的有用的函数库共享给被人(当然是付费的),但是又不能给客户源代码。这时候的解决方案就是以库(主要有2种:静态库和动态库)的形式来提供。
-
(3)比较早出现的是静态链接库。静态库其实就是商业公司将自己的函数库源代码经过只编译不连接形成.o的目标文件,然后用ar工具将.o文件归档成.a的归档文件(.a的归档文件又叫静态链接库文件)。商业公司通过发布.a库文件和.h头文件来提供静态库给客户使用;客户拿到.a和.h文件后,通过.h头文件得知库中的库函数的原型,然后在自己的.c文件中直接调用这些库文件,在连接的时候链接器会去.a文件中拿出被调用的那个函数的编译后的.o二进制代码段链接进去形成最终的可执行程序。
-
(4)动态链接库比静态链接库出现的晚一些,效率更高一些,是改进型的。现在我们一般都是使用动态库。静态库在用户链接自己的可执行程序时就已经把调用的库中的函数的代码段链接进最终可执行程序中了,这样好处是可以执行,坏处是太占地方了。尤其是有多个应用程序都使用了这个库函数时,实际上在多个应用程序最后生成的可执行程序中都各自有一份这个库函数的代码段。当这些应用程序同时在内存中运行时,实际上在内存中有多个这个库函数的代码段,这完全重复了。
-
而动态链接库本身不将库函数的代码段链接入可执行程序,只是做个标记。然后当应用程序在内存中执行时,运行时环境发现它调用了一个动态库中的库函数时,会去加载这个动态库到内存中,然后以后不管有多少个应用程序去调用这个库中的函数都会跳转到第一次加载的地方去执行(不会重复加载)。
4、函数库中库函数的使用
- (1)gcc中编译链接程序默认是使用动态库的,要想静态链接需要显式用-static来强制静态链接。
- (2)库函数的使用需要注意3点:第一,包含相应的头文件;第二,调用库函数时注意函数原型;第三,有些库函数链接时需要额外用-lxxx来指定链接;第四,如果是动态库,要注意-L指定动态库的地址。