解读gcc和g++编译器分别对c与c++文件影响

  1. 概述

为什么需要解读gcc/g++编译器对c/c++文件的影响呢?由于系统内核一般是使用C语言来编写的,系统内核中用C语言实现了很多库。而上层应用程序有可能是用C++来开发,如果在内核库函数头文件中不用extern“C”来声明库函数的话,在编写C++应用程序时,包含库头文件,在C++文件链接时就会以C++标准来链接库的函数名,而在库文件实现时是用C来实现的,二者函数名不同,在链接时就会出现找不到函数的现象。

  1. 实验环境

本次实验平台:Debian 9.4   gcc/g++版本:6.3.0   IDE环境:Qt Creator 4.2.0  qmake版本:3.0。这里可以看到最新的Debian系统对软件版本更新也是很及时的,对于电脑不能上外网情况,debian与centos都推出了离线包,两个系统都使用了很长一段时间,这里还是给大家推荐centos,centos很多东西是继(chao)承(xi)红帽的,可能是这个原因,所以centos很稳定、bug少。

  1. 编译步骤

编译器一般可分为预处理、编译、汇编、链接四个阶段,GNU手册也做了介绍,手册如图 1所示。

1

  1. 预处理:将宏定义展开,将相关和类型定义引入等操作,生成后缀为.i的预处理文件。
  2. 编译:将预处理文件编译成汇编文件,生成后缀.S的汇编文件。
  3. 汇编:将汇编文件编译成目标文件,生成后缀为.o的目标文件。
  4. 链接:将各个.o文件与相关库文件进行链接,链接方式可以选择静态或动态链接。

上面是标准的面经,同时也是网上最容易搜索到的答案,但实质根本没理解gcc/g++编译。从上述面经来看,感觉就是gcc只能编译c程序,g++只能编译c++程序。gcc/g++编译c/c++程序可以分为四种情况,gcc编译.c程序,gcc编译.cpp程序,g++编译.c程序,g++编译.cpp程序。下面通过对比这四种情况来真正了解gcc/g++编译器。

  1. gcc/g++编译.c文件

为了彻底理解清楚两种之间的区别,下面通过编译的几个阶段去解读,test.c文件如图 2所示,这里需要注意,在test.c中我没有引入c++头文件和语法,如果用gcc编译,那么就连万里长征第一步预编译都不能通过,有且仅有当用gcc编译.c文件时,编译器才会按照C文件规则进行预编译,这里提前提出结论。为了更好的对比,对比.c文件代码中采用c规则。

2

 

 

    1. 预编译处理

使用gcc和g++分别对test.c进行预处理,执行结果如图 3所示。

3

从图中可以看到24c24等字符,c代表转换意思,前后数字代表行数,前后出现一对数字代表行号区间。同时可以看到g++预编译.c文件时候,会在部分头文件和类型定义前添加extern “C”,这个标识符的作用把标识符作用域的数据类型采用gcc去编译,C++保留了一部分过程式语言的特点(被世人称为“不彻底地面向对象”),因而它可以定义不属于任何类的全局变量和函数。但是,C++毕竟是一种面向对象的程序设计语言,为了支持函数的重载,C++对全局函数的处理方式与C有明显的不同。

    1. 编译阶段

编译阶段是把.i文件编译成汇编文件,编译过程和结果如图 4所示。

4

通过图中对比发现,只有程序中自定义的函数接口命中不一样,其它代码完全一样。gcc编译器编译后的函数名字和编译前函数名一致(有些可能是在函数前面添加_,具体机制是跟编译机制有关),g++编译器是在函数中添加_z3*ii。这里其它代码相同原因是程序中使用的语法c++是部分兼容c的,上图仅仅代表是一种特殊情况,因为.c文件中全部采用的c语法规则。

    1. 汇编阶段

程序处于汇编阶段时,gcc/g++内部都是调用as汇编命令,在这里两者是没有区别的,可以通过如下命令做测试,用c++_test.S做测试对比。

gcc -c c++_test.S -o c_test.o

g++ -c c++_test.S -o c++_test.o

diff c_test.o c++_test.o

通过对比试验,两个.o文件完全一样,在汇编阶段gcc/g++的作用是一样的,读者可以尽情做实验。

    1. 链接阶段

gcc/g++都是调用ld命令,同样两种区别在于传入的参数与调用库的不同,g++默认与c++库链接(兼容C语言部分,还是调用C库),gcc默认是与c库链接。实验是检验真理的最好工具,下面同样选取c++_test.o做实验。

gcc c++_test.o -o c_test

g++ c++_test.o -o c++_test

diff c_test c++_test

通过diff对比,两个可执行文件内容不一样的。通过strace跟踪系统调用结果如图 5所示。可以清晰看到两者仅仅是一些mmap地址映射不一样,即使两次用g++编译相同文件,可能生成的文件也不一样,这个不一样仅仅体现在地址映射上。

5

  1. gcc/g++编译.cpp文件

编译cpp文件同样通过四个阶段做比对,同时还和编译.c文件得出的结论做对比,test.cpp文件内容如图 6所示。

6

复制test.cpp文件为test.c文件,然后进行四个阶段做对比。

    1. 预编译

g++ -E test.c -o test.ii

g++ -E test.cpp -o c++_test.ii

gcc -E test.cpp -o c_test.i

这里保存为.ii或者.i都是无所谓的,只有当预编译的输出需要手动指定作为编译阶段的输入(-x选项指定输入文件类型),并且没有指定编译阶段输入是什么类型文件,那么编译器就会根据系统自定义的后缀去解析,默认编译器会把.i后缀当成c预编译的结果,.ii后缀当成是c++预编译的结果。通过对比这个3个.i文件发现预编译结果完全一样,只有文件名不同,test.c与test.cpp的区别。

  1. 编译

gcc -S test.cpp -o c_test.S

g++ -S test.cpp -o c++_test.S

g++ -S test.c -o test.S

编译结果如图 7所示。生成的.S文件内容又是完全一样,只有.file不一样,这个标识符是指文件名,1c1代表第一个文件的第一行与第二个文件的第一行转换关系。

7

    1. 汇编

gcc -c test.cpp -o c_test.o

g++ -c test.cpp -o c++_test.o

g++ -c test.c -o test.o

汇编阶段结果同样是一致。

    1. 链接

g++ test.cpp -o c++_test

g++ test.c -o test

objdump -S test > test.obj

objdump -S c++_test > c++_test.obj

这两种编译结果通过查看二进制只有文件名是不一样的,两种方式都装载了libstdc++.so.6和libc.so.6库文件,这是g++兼容c的另外一个表现;同样可以通过反编译手段对比两个二进制文件,对比结果如图 8所示,两个obj文件同样只有文件名不一样。

8

还剩下一种情况没有对比,那就是gcc链接.cpp文件。

gcc c_test.o -o c_test

链接不能正常通过的,提示找不到c++库。由于链接阶段gcc不能够自动链接c++库,g++可以自动链接c++库,如果加上-lstdc++选项就能够正常编译通过。

gcc c_test.o -o c_test -lstdc++

同样可以对比c_test  与c++_test文件,但是读者会发现,这两个文件肯定不一样。我们通过反编译手段观察两个文件的区别,同样仅仅是地址不一样,比如_init地址不一样等。图 9是我截取的局部反编译对比图。程序最先从_init中rsp堆栈指针顶开始做堆栈处理,然后跳转到程序中我们看到的main函数入口地址,进入函数之前先保存栈指针,在进行push,程序中在跳转到add函数的的地址,最后程序执行完成在_fini中恢复栈平衡。(大概思路是这样的,已经三年没有碰过底层地址相关的知识,凭记忆补充一点相关知识。在嵌入式开发过程中有几个地方需要用到地址概念,uboot链接脚本确定程序第一条指令入口、编译文件顺序、位置无关地址跳转问题,栈顶设置;加载linux地址时分析相对复杂一点,编译kernel需要指定入uImage或zImage入口地址,uImage与zImage仅仅相差64Bytes头,这里牵扯到解压kernel,代码搬运等操作)。

9

通过上面两个对比实现可以得出如下结论:

  1. 后缀为.c的,gcc把它当成c程序;g++当作c++程序,即跟.cpp没有区别。
  2. 后缀.cpp的,gcc与g++都当成c++程序。
  3. gcc不能自动链接c++库,g++会自动链接c++库。
  4. c++程序中,预编译后会存在extern “C”标识符,即是用gcc按照c程序规则编译,这是兼容C程序的表现。
  5. gcc也可以编译c++程序。
  6. gcc编译.c文件,.c文件中一定不能出现c++语法和库操作。
  1. __cplusplus测试

如果编译器按照c++规则编译,那么这个宏__cplusplus就会被编译器定义。上面已经得出结论了,下面通过这个宏定义再次验证上面1、2结论。test.c与test.cpp文件内容如图 10所示,依次执行如下命令。

gcc test.c && ./a.out        @结果是:a+b=1314

g++ test.c && ./a.out        @结果是:a+b=520

gcc test.cpp && ./a.out      @结果是:a+b=520

g++ test.cpp && ./a.out      @结果是:a+b=520

从结果同样可以证明,只有当用gcc编译.c程序时才会按照c规则编译程序。

10

有读者可能就问了,既然g++能够编译c程序,gcc还有必须要存在吗?这个答案是肯定的。原因一:操作系统全是按照c规则编写与编译(除开head.S等文件中的汇编),很多系统库文件都是按照c规则写与编译的,如果采用g++去编译这一部分,那么生成的可执行文件会非常大,以及程序运行效率大大降低;还可能存在不能正常编译通过情况,就是上述对比实验中函数链接接口不一样。编译器优化性能也是有限的,系统内核是整个上层应用的心脏,必须做到最高效率,不许存在过多冗余代码(编译器的问题)。原因二:做单片机出身的工程师对c语言扣字节操作应该是一种信仰,做上层应用的工程师更偏向c++面向对象,一个大项目基本都是通力合作完成。所以gcc用来编译c程序(效率问题,c实现对系统接口二次封装),g++编译c++程序。

  1. 工程包含.c与.cpp文件如何处理

如果是工程师自己写编译规则,那么在Makefile中应该检测.c和.cpp文件分别用gcc与g++编译,最后统一链接。如果是IDE环境,由IDE环境决定,在qt中qmake是将.c文件用gcc编译,g++编译。那么g++如何调用c程序封装的函数或者库呢,从上述讲解知识可以看到c++程序直接调用c程序接口,这个肯定报错提示找不到库函数,c和c++处理函数接口机制不一样,所以在c++程序中调用c程序应该在函数声明前加上extern “C”,这是在提醒g++编译链接这个函数时按照c函数机制去链接。同样如果在程序中函数实体前加extern “C”,这是在提醒编译器整个函数用gcc按照C规则去编译。切记理解编译与链接是两码事情。             

 图 11是一个QT工程模板,图中pcie.c文件的后缀只能用.c,如果换成.cpp,无论你是用gcc还是g++,走到链接阶段是找不到libpci.so提供的库函数的,虽然可以用把标准头文件路径pci.h中使用的函数声明前重新加上extern “C”,但是得不偿失。定义成.c文件qt环境会用gcc去编译,在pcie.h文件中的声明函数前加上extern “C”就可以正常链接。

11

  1. 动态库封装

看3条简单命令就可以很好理解,封库大多数是针对客户做二次开发使用;还有可能各个部门之间的合作需要封库。还有一种比较恶心的人,写代码特别喜欢封装库,玩隐蔽,不可替代性;在svn上只看得到几行画蛇添足的代码,在代码中调用库、管道接口、二进制文件。这种代码可阅读性很差,仅限本人阅读。

gcc -fPIC –shared test.c –o libtest.so   //封库

gcc main.c  –L. -ltest –o main     //使用

export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH