引言
预处理所做工作涉及头文件包含、常见注释风格、宏定义使用及各种预条件编译的作用。函数库主要涉及静态库和动态库;
为什么需要编译链接
看下下编译流程
举一个简单的例子,helloc
#include<stdio.h>
int main(int argc,char *argv[]){
printf("hello world\n");
return 0;
}
对于C语言程序,我们需要将它编译链接成为可执行的二进制文件,然后由系统加载执行。在Linux中,GCC编译程序会读取源代码hello.c文件,将其翻译成一个可执行文件hello,经历四个过程,由编译工具链完成;
第一个过程
预处理(cpp)。在命令行输入以下命令,预处理器会对以#开头的预处理命令进行处理。
gcc -E hello.c -o hello.i
如hello.c中#include<stdio.h>,预处理器会将系统中的hello.h具体内容读到文本中,替换原有的#include<stdio.h>,得到一个新的C程序,称之为.i文件;
hello.i
...
...
extern void funlockfile(FILE *__stream) __attribute__ ((__nothrow__,__leaf__));
int main(int argc,char *argv[]){
printf("hello world\n");
return 0;
}
第二个过程
编译(cc)。在命令行下输入gcc编译命令,编译器将hello.i翻译成了hello.s汇编文件,得到一条条通用的机器语言指令;
gcc -S hello.i -o hello.s
或者将预处理器和编译器一起完成
gcc -S hello.c -o hello.s
第三个过程
汇编(ss)。在命令行输入以下命令,汇编器会将hello.s翻译成机器语言指令,将这些指令打包称为***.o格式的可重定位文件,并把结果保存到目标文件hello.o中。目标文件由不同的段组成,通常一个目标至少有两个段---数据段和代码段。得到hello.o是一个二进制文件;
gcc -c hello.s -o hello.o
第四个过程
连接(ld)。输入以下命令,连接是最后一个过程,连接器会将hello.o和其他库文件、目标代码链接后形成可执行文件。在hello.c程序中调用了printf函数,连接器会将printf.o文件并入hello.out可行执行文件中。最后将可执行文件加载到存储器后,然后由系统执行。在Linux中可以通过以下命令查看生成的文件。
gcc hello.o -o hello.out
ls -l hello*
编译链接中各种文件扩展名的含义
在Linux中,分为可执行文件和不可执行文件,由源码到可执行程序的过程中,以扩展名(即后缀)来区分各个阶段。
扩展名 | 含义 | 扩展名 | 含义 |
.c | C源代码文件 | .m | Objective-C源代码文件 |
.a | 由目标文件构成的静态库文件 | .o | 编译后的目标文件 |
.C | C++源代码文件 | .out | 链接器生成的可执行文件 |
.h | 程序中包含的头文件 | .s | 汇编语言源代码,后期不进行预处理 |
.i | 预处理过C源代码文件 | .S | 汇编语言源代码,后期需预处理,包含预处理指令 |
.ii | 预处理过C++源代码文件 |
预处理详解
C语言预处理意义
编译器的主要目的是编译源代码,将C语言源代码转化成.s的汇编代码。编译器聚焦核心功能后,剥离出一部分非核心功能由预处理器执行。预处理器对程序源码进行一些预处理,为后续编译做好基础,在由编译器编译。预处理的意义使得编译器实现功能变得更为专一。
预处理涉及内容
- 文件包含
- 宏定义
- 条件编译
- 一些特殊的预处理关键字
- 去掉程序中的注释
预处理指令很多,如#include(文件包含);#if #ifdef #ifndef #else #elif #endif(条件编译),#define宏实现。由此可见,编译器根本不需要处理宏定义,因为预处理器已经处理完成;typedef重命名语言还在,说明它和宏定义右本质区别,typedef是由编译器处理。
文件包含
每个程序一般分为两个部分,一个用于保存各种声明的头文件,一个用于保存程序的实现;头文件作用在于声明和实现的分离,可以在头文件中查阅需要调用的函数,并定义很多的宏定义,这样只修改头文件内容,而不用去繁琐代码中去更改,提高了效率和代码可读性。
尖括号<>专门用来包含系统提供的头文件(由操作系统自带的,不是程序员自己的写的)。双引号" "用来包含自己写的头文件。
尖括号<>表示,C编译器会到系统指定目录(编译器中配置或操作系统配置的目录中寻找)去寻找该头文件,隐含意思不会找当前目录;双引号" "包含的头文件,编译器默认会先从当前目录下寻找相应的头文件,如果没找到然后再到系统指定目录去寻找。
注释
注释为了增加代码的可读性,C中一般有两种注释方式/* */ 和 //,常见注释有模块描述,头文件开头的版权和版本声明,函数的说明,重要代码的注释。
- 注释格式尽量统一,建议使用/* */,linux内核注释几乎使用它;
- 注释考虑程序易读及排版优美,注释语言尽量统一;
- 函数头部应该注释,使用代码自注释;
- 建议边写代码边注释,更改代码同时更改注释,保持注释与代码一致;
- 防止代码二义性,注释必须简洁,不要用缩写;
- 在单行注释时,注释放在代码上方或右方;
- 注释和代码通缩进,注释和代码之间要空出一行;
预处理时预处理器会移除所有注释用空格替换,到了编译器进行编译阶段,程序中已经没有注释了。
宏定义
宏定义简化了重复性劳动,便于程序修改,增加程序可读性,如使用宏定义给字符串3.14定义一个标识符来替代;一般将宏定义放在头文件中,便于查找和提高编程效率;标识符后的字符串在预处理中仅仅只是文本上的替换。
宏定义规则和使用
- 宏定义在预处理阶段由预处理器进行替换(原封不动的替换);
- 宏定义替换会递归进行,直到替换出来的值本身不再是一个宏为止;
- 一个完整的宏定义含三个部分:#define、宏名、剩下部分;
- 宏可带参数(带参宏),带参宏和带参函数使用类似且有差异,定义时每个参数在宏体中引用时都必须加括号,最后整体再加括号,括号缺一不可;
无参数宏定义
#define 标识符(符号常量) 宏体(常数、表达式、格式串)
#define SEC_PER_YREAR (365*24*60*60UL)
带参数宏定义
MAX宏 求两个数中较大的一个
#define MAX(a,b) (((a)>(b))?(a):(b))
注意:使用三目运算符、添加括号防止有关优先级问题。
#define X(a,b) a+b
int main(){
int x = 1,y=2;
int x = 3*X(x,y);//3*x+y
return 0;
}
宏定义的缺陷
宏定义在预处理期间处理的,而函数是在编译期间处理的。两者实质差别是,宏定义最终在调用宏的地方把宏体原地展开,而函数是在调用函数处跳转到函数中去执行,执行完在调整回来。
宏定义是原地展开,没有调用开销;而函数是跳转执行再返回,因此函数有较大调用开销。宏定义和函数相比,没有调用开销,没有传参开销,所以当函数体很短(尤其只有一句代码时),可用宏定义来替代,效率高。
带参宏和带参函数差别:宏定义不会检查参数的类型,返回值不会附带类型;而函数有明确的参数类型和返回值类型;
带参函数执行时,编译器会检测形参和实参类型匹配问题,而宏定义实际传参和宏所希望的参数类型必须一致,否则编译不报错但运行有误。
带参函数 | 宏 | 内联函数 | |
优点 | 编译器自行检查形参和实参类型 | 原地展开,无调用开销;预处理阶段完成,不占编译时间
| 函数代码被放入符号表中,使用时类似宏替换,无调用开销 效率高且会检查参数类型 |
缺点 | 函数带参,返回地址等压栈和出栈,栈变量开辟和销毁,运行效率没有带参宏高 | 不检查参数类型,多次宏替换导致代码体积变大,宏原地替换由于一些参数副作用导致出错,例++,--操作 | 若函数代码较长,会消耗过多内存,若函数内有循环,导致执行时间长; |
内联函数和inline关键字
- 内联函数通过函数定义前加inline关键字实现,若仅把inline放在函数声明处是不起作用的;
- 内联函数本质是函数,有函数的优点,内联函数是编译器负责处理的,编译器可对参数做静态类型检查;但是同时有带参宏的优点,无调用开销, 原地展开;
- 当函数体很短(一两句代码时),且希望利用编译器的参数类型检查来排错,希望没有调用开销,适合使用内联函数
只有宏名没有宏体的宏主要用于条件编译,用于实现跨平台;
#define DEBUG
条件编译
我们希望程序有多种配置,在编写源码时写好了各种配置的代码,然后给个配置开关,在源码代码级别去修改配置开关来让程序编译出不同的效果;常见的条件编译如下:
#if #else #elif #endif
#ifdef #endif
宏定义来实现条件编译(#define #undef #ifdef)
程序有DEBUG版本和RELEASE版本,区别是编译时有无定义DEBUG宏。
//macro.c
#include<stdio.h>
#define DEBUG //法1:注释掉或法2:下一行添加#undef DEBUG
#ifdef DEBUG
#define debug(x) print(x)
#else
#define debug(x)
#endif
int main(void){
debug("this is a debug info.\n");
return 0;
}
注释掉#define DEBUG或法2:#define DEBUG下一行添加#undef DEBUG,通过这样的开关就可以配置DEBUG版本和RELEASE版本的程序。
避免重复包含头文件
#ifndef _ASM_ARM_BITOPS_H //如果不存在asm-arm/bitops.h
#define _ASM_ARM_BITOPS_H //就引入asm-arm/bitops.h
/*
头文件内容
*/
#endif /*_ARM_BITOPS_H */ //否则不引入asm-arm/bitops.h
当第一次包含头文件时,会定义出_ASM_ARM_BITOPS_H宏,后续再次包含该头文件时,已经有了该宏定义,文件#ifndef _ASM_ARM_BITOPS_H不会成立,所以头文件不会被再次包含;
#if define、#ifdef和 #if !define、#ifndef用法
#if define(x)
...code...
#endif
#if define中,不管括号里x的逻辑是真还是假,只管该程序前面的宏定义里有无定义“x”这个宏,若定义,则编译器会编译中间的...code...,否则直接忽略中间...code...代码,#if defined 取反就是 #if !defined,具体看个例子。
#include<stdio.h>
#define NUM
int main(void){
int a = 0;
#ifdef NUM //如果前面定义NUM
a = 111;
printf("#ifdef NUM.\n");
#else //如果前面没有定义NUM
//这个符号,则执行下面的//语句
a = 222;
printf("#else.\n");
#endif
return 0;
}
结果
#ifdef NUM.
--------------------------------
//预处理
gcc -E preprocess_test.c -o preprocess_test.i
//preprocess_test.i
int main(void){
int a = 0;
a = 111;
printf("#ifdef NUM.\n");
return 0;
}
在preprocess_test.i中,已经找不到头文件包含的指令了。例子可见,头文件包含是原地替换,所有注释被移除,用一个空格替换;条件编译部分,在预处理中也被移除,用一个空格替换。
#ifdef 与 #if define的区别
区别在于#if defined可以组成复杂的预编译条件。
#if define(A) && defined(B)
<code>
#endif
表示A和B这两个宏定义都存在的时候才编译代码,而#ifdef只能判断单个宏定义,不能判断多个复杂条件;
内容查考:C语言内核深度解析