C/C++语言——GCC编译器

时间:2023-01-05 02:04:53

        用 C 语言编写一个程序时,将编写的内容保存在一个被称为"源代码文件"的文本文件中。大多数C系统,都需要该文件的名称以 .c 结尾:例如:budget.c 。名称中小点前的部分被称为基本名,小点后的部分被称为扩展名。因此,budget 是一个基本名,c 是一个扩展名。组合在一起的 budget.c是文件名。


目标代码文件、可执行文件和库:

        C 编程的基本策略是使用程序将源代码文件转换为可执行文件,此文件包含可以运行机器语言代码。C分两步完成这一工作:编译和链接。编译器将源代码转换为中间代码,链接器将此中间代码与其他代码相结合生成可执行文件。C使用这一方法使程序便于模块化。你可分别编译各个模块,然后使用链接器将编译过的模块结合起来。这样,如果需要改变一个模块,则不必重新编译所有其他的模块。同时,链接器将你的程序与预编译的库代码结合起来。
        中间文件的形式有多种选择。最一般的选择,是将源代码转换为机器语言代码,将结果放置在一个目标代码中(这里假定你的源代码由单个文件组成)。虽然目标文件包含机器语言代码,但该文件还不能运行。目标文件包含源代码的转换结果,但它还不是一个完整的程序。
        目标代码文件中所缺少的第一个元素是启动代码(start-up code),此代码相当于程序和操作系统之间的接口。例如,你可以在DOS或Linux下运行一个 IBM PC兼容机,在两种情况中硬件是相同的,所以会使用同样的目标代码,但DOS与Linux要使用不同的启动代码,因为这两种系统处理程序的方式是不同的。所缺少的第二个元素是库例程的代码。几乎所有C程序都利用标准C库中所包含的例程(称为函数)。例如,函数 printf()。目标代码文件不包含这一函数的代码,它只包含声明使用 printf()函数的指令。实际代码存储在另一个称为“库”的文件中。库文件中包含许多函数的目标代码。
        链接器的作用是将这3个元素(目标代码,系统的标准启动代码和库代码)结合在一起,并将它们存放在单个文件,即可执行文件中。对库代码来说,链接器只从库中提取你所使用的函数所需要的代码.如图下所示:

concrete.c -> 源代码 -> 编译器 -> 目标代码 ---------->链接器 ----------> 可执行代码
                                                             |                             |                                |
                                                      concrete.obj     库代码+启动代码     concrete.exe

        简而言之,目标文件和可执行文件都是由机器语言指令组成的。但目标文件只包含你所编写的代码转换成的机器语言,而可执行文件还包含你所使用的库例程以及启动代码的机器代码。

对于GCC编译器:
        编译程序读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码,再由汇编程序转换为机器语言(目标文件),并且按照操作系统对可执行文件格式的要求链接生成可执行程序。

源文件 -> 预编译处理 -> 编译及优化程序本身 -> 汇编程序 -> 链接程序 -> 可执行文件


1、 预处理

命令: gcc -o test.i -E test.c
或cpp -o test.i test.c  (cpp指the C Preprocessor)
结果:  生成预处理后的文件test.i 
注解:  此步读取c源程序,对伪指令和特殊符号进行处理。包括宏定义,条件编译,包含的头文件,以及一些特殊符号。基本上是一个replace的过程。

(1)  宏定义指令
        如#define Name TokenString, #undef等。对于前一个伪指令,预编译所要做的是将程序中的所有Name用TokenString替换,但作为字符串常量的 Name则不被替换。对于后者,则将取消对某个宏的定义,使以后该串的出现不再被替换。

#define指令(用任意字符序列替代一个标记)
#define 名字 替换文本
替换文本可以是任意的
#define forever for(;;)
宏定义也可以带参数
#define max(A,B) ((A) > (B) ? (A) : (B))

        #define指示接受一个名字并定义该名字为预处理器变量。预处理器变量的名字在程序中必须是唯一的。任何与预处理器变量相匹配的名字的使用都关联到该预处理器变量。为了避免名字冲突,预处理器变量经常用全大写字母表示。预处理器变量有两种状态:已定义或未定义。定义预处理器变量和检测其状态所用的预处理器指示不同。


可以通过#undef指令取消名字的宏定义,这样做可以保证后续的调用是函数调用,而不是宏调用
#undef getchar
int getchar(void) {...}

参数名以#作为前缀则结果将被扩展为由实际参数替换该参数的带引号的字符串
#define dprint(expr) printf(#expr “= %g\n”, expr);
dprintf(x/y); 等同于printf(“x/y = %g\n”, x/y);

(2) 头文件包含指令
        如#include <>或者#include “”等。采用头文件的目的主要是为了使某些定义可以供多个不同的C源程序使用。因为在需要用到这些定义的C源程序中,只需加上一条#include语句即可,而不必再在此文件中将这些定义重复一遍。预编译程序将把头文件中的定义统统都加入到它所产生的输出文件中,以供编译程序对之进行处理。因为头文件包含在多个源文件中,所以不应该含有变量或函数的定义。同一个程序中有两个以上文件含有任一个相同定义都会导致多重定义链接错误。但头文件可以定义类、值在编译时就已知道的const 对象和inline 函数。
        包含到C源程序中的头文件可以是系统提供的,这些头文件一般被放在/usr/include目录下。在程序中#include它们要使用尖括号(< >)。另外开发人员也可以定义自己的头文件,这些文件一般与c源程序放在同一目录下,此时在#include中要用双引号("")。

#include指令(用于在编译期间把指定文件的内容包含进当前文件中)
#include “文件名”#include<文件名>

        源文件的开始处通常都会有多个#include指令,在头文件中一般用伪指令#define定义了大量的宏(最常见的是字符常量),类的定义、extern变量的声明和从头文件中访问库函数的函数原型声明。

        使得头文件安全的通用做法,是使用预处理器定义头文件保护符。头文件保护符用于避免在已经见到头文件的情况下重新处理该头文件的内容。


(3) 条件编译指令
        如#ifdef,#ifndef,#else,#elif,#endif,等等。这些伪指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。预编译程序将根据有关的文件,将那些不必要的代码过滤掉。
       #if语句对其中的常量整型表达式(不能包含sizeof、类型转换运算符或enum常量)进行求值,若该表达式的值不为0,则包含其后的各行,直到遇到#endif, #elif或#else语句为止。


#ifSYSTEM == SYSV
#defineHDR“sysv.h”
#elifSYSTEM == BSD
#defineHDR“bsd.h”
#else
#defineHDR“default.h”
#endif
#includeHDR

在#if语句中可以使用表达式defined(名字),该表达是遵循下列规则:
当名字已经定义时,其值为1;否则,其值为0
#if!defined(HDR)
#defineHDR
/* hdr.h文件的内容放在这里 */
#endif

上述代码可写为
#ifndefHDR
#defineHDR
/* hdr.h文件的内容放在这里 */
#endif

#ifndef指示检测指定的预处理器变量是否未定义。如果预处理器变量未定义,那么跟在其后的所有指示都被处理,直到出现#endif。


#pragma once方式:
#pragma once是一个比较常用的指令,只要在头文件的最开始加入这条指令就能够保证头文件被编译一次。#pragma once是编译相关,移植性差,不过现在基本上已经是每个编译器都有这个定义了。

编译器每次读到#ifndef时,如果已经定义过了则跳过,但还是要搜索整个文件,找到#endif时退出,此时无疑增加了编译时间。而加上#pragma once,则可以让编译器立即退出,减少了编译的时间。
综上,一般用法为:
#ifndef ...
#define ...
#pragma once
...
#endif


(4) 特殊符号

        预编译程序可以识别一些特殊的符号。例如在源程序中出现的LINE标识将被解释为当前行号(十进制数),FILE则被解释为当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换。预编译程序所完成的基本上是对源程序的“替代”工作。经过此种替代,生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件。这个文件的含义同没有经过预处理的源文件是相同的,但内容有所不同。下一步,此输出文件将作为编译程序的输出而被翻译成为机器指令。


2、编译及优化
命令: gcc -o test.s -S test.i
/完整路径(usr/libexec/gcc/i686-pc-linux-gnu/3.4.6)/cc1 -o test.s test.i
结果:
编译器的核心工作就是把C程序翻译成机器相关的汇编程序(assembly language), 生成汇编文件test.s(可打开后查看源文件生成的汇编码)

注解: 此步通过词法和语法分析,确认所有指令符合语法规则(否则报编译错),之后翻译成对应的中间码,在linux中被称为RTL (Register Transfer Language),通常是平台无关的,这个过程被称为编译前端。之后对RTL树进行裁减,优化,得到在目标机上可执行的汇编代码,这个过程被称为编译后端。使用不同的优化编译选项,可以看到在不同优化级别下的代码。了解编译器对你写的代码到底做了什么。

代码优化指的是编译器通过分析源代码,找出其中尚未达到最优的部分,然后对其重新进行组合,目的是改善程序的执行性能。


3、 汇编
命令:  gcc -o test.o -c test.s
as -o test.o test.s
结果:   将汇编语言程序翻译成可执行的二进制码,生成目标机器指令文件test.o(可用objdump查看)。 因为每一种机器架构都有它自己的汇编语言,所有GCC调用一个目标系统的汇编器将汇编语言程序翻译成可执行的二进制文件。

注解:  用file test.o 可以看到test.o是一个relocatable的ELF文件,通常包含.text .rodata代码段和数据段。可用readelf -r test.o查看需要relocation的部分。gcc采用as作为其汇编器,所以汇编码是AT&T格式的,而不是Intel格式,所以在用 gcc编译嵌入式汇编时,也要采用AT&T格式。


4、链接
命令:  gcc -o test test.o
ld -o test test.o
结果:   链接器把多个二进制目标文件并入一个单独的可执行文件中,生成可执行文件test (可用objdump查看)

注解:   此步将在一个文件中引用的符号同在另外一个文件中该符号的定义链接起来,使得所有的这些目标文件链接成为一个能被操作系统加载到内存的执行体。(如果有不 到的符号定义,或者重复定义等,会报链接错)。用file test 可以看到test是一个executable的ELF文件。


当然链接的时候还会用到静态链接库,和动态连接库。静态库和动态库都是.o目标文件的集合,但是使用相差很远。
5、静态库
命令:  ar -v -q test.a test.o
结果:   生成静态链接库test.a

注解:   静态库是在链接过程中将相关代码提取出来加入可执行文件的库(即在链接的时候将函数的代码将从其所在地静态链接库中被拷贝到最终的可执行程序中),ar只是将一些别的文件集合到一个文件中。可以打包,当然也可以解包。


6、动态库
命令:   gcc -shared test.so test.o
/PATH/collect2 -shared test.so test.o (省略若干参数)
结果:   生成动态连接库test.so
注解:   动态库在链接时只创建一些符号表,而在运行的时候才将有关库的代码装入内存,映射到运行时相应进程的虚地址空间。如果出错,如找不到对应的.so文件,会在执行的时候报动态连接错(可用LD_LIBRARY_PATH指定路径)。用file test.so可以看到test.so是shared object的ELF文件。而静态库test.a只是一个集合包。

所以当gcc编译源文件时经历了test.c -> test.i -> RTL -> test.s -> test.o -> test的过程。当然以上各步可以一步或若干步一起完成,如gcc -o test test.c直接得到可执行文件。当然也可以加上-v来查看在这个过程中,gcc总共做了多少事。

GCC命令:
gcc [-c|-S|-E] [-std=standard]
[-g] [-pg] [-Olevel]
[-Wwarn...] [-pedantic]
[-Idir...] [-Ldir...]
[-Dmacro[=defn]...] [-Umacro]
[-foption...] [-mmachine-option...]
[-o outfile] infile...
  • -c
Compile,只编译,不汇编连接库,产生一个.o的目标文件
  • -S
aSsemble,编译为汇编代码,产生一个.s的汇编源文件
  • -E
preprocess,预处理,之后的代码将送往标准输出
可以对它进行重定向:gcc -E helloworld.c > helloworld.txt
  • -Wwarn
设置警告,可以设置的警告开关很多,通常用-Wall开启所有的警告
  • -Olevel
Optimize,产生一个经过优化的叫作a.out的可执行文件。也可以同时使用-o选项,以指定输出文件名。
设置优化级别,level可以是0,1,2,3或者s,默认-O0,即不进行优化。
  • -Dname=definition
在命令行上定义宏,有两种方式: 
-Dname:预定义一个名为name,值为1的宏。$ gcc -DTEST_CONFIG test.c -o test
-Dname =definition:预定义一个名为name ,值为definition 的宏。

在命令行上设置宏定义的目的主要是为了在调试的时候设定一些开关, 而在发布的时候再关闭或者打开这些开关即可,当然宏定义也用来对代码进行有选择地编译.另外也还有其他的一些作用.

#ifdef DEBUG
printf("this code is for debugging\n");
#endif 
        
如果编译时加上-DDEBUG选项,那么编译器就会把printf所在的行编译进目标代码,从而方便地跟踪该位置的某些程序状态。这样-DDEBUG就可以当作一个调试开关,编译时加上它就可以用来打印调试信息,发布时则可以通过去掉该编译选项把调试信息去掉。

  • -Uname
取消宏定义name,作用和上面的正好相反.
  • -Idir
把dir加入到搜索头文件的路径列表中,而且gcc会在搜索标准头文件之前先搜索dir。
$ gcc test.c -I../inc -o test
  • -llibrary
        在链接的时候搜索library库,库是一些archieve文件--其成员是目标文件.如果有文件引用library,library在命令行的位置应该在那个文件之后,因此,越底层的库越要放在后面.比如如果你要连接pcap库,那么你就需要使用-lpcap对源文件进行编译.
  • -Ldir

把dir加到库文件的搜索路径中,而且gcc会在搜索标准库文件之前先搜索dir.

  • -pthread

通过pthreads库加入对多线程的支持,这为预处理和连接设置了标志.pthread是POSIX指定的标准线程库.

  • -std=standard

设置采用的标准,该选项是针对C语言的,比如-std=c99表示编译器遵循C99标准.该选项较少使用.而且有时反而会把你搞糊涂.

  • -o outfile

 指定输出文件的文件名,默认为a.out

  • -mmachine-option...

指定所用的平台。