c 函数多次声明_关于C/C++里面.h和.c文件及链接错误的那些事~ 拆分声明和定义的作用及更深入讨论...

        最近在写嵌入式开发以及给大一同学答疑的时候又碰到我的老朋友了——C/C++,抽空来写一写遇到的问题及想法。

        大家在学习C/C++的时候,经常遇到把一些函数拆分到多个文件中的情况。对于一个C/C++函数,可以拆分成声明和定义两个部分,比如:

int function(int); //此处为声明int function(int num){return num;} //此处为定义// 若无第一行代码 第二行为声明并定义
         在C/C++某些标准版本中,要求函数必须 先声明再调用 ,也就是说,我们可以把整个函数写在调用语句之前,也可以仅把声明写在函数调用之前。         在学习C/C++的时候,在拆分成多个文件的时候(拆分.h和.c/cpp),一般都在头文件中进行函数/变量声明、宏、结构体声明,而在C文件中去进行变量定义、函数实现。老师在实验的时候也是这样要求的。不过为什么要这样呢?         我问过老师,老师说是可以 更快的编译 ,问过同学,同学说是 方便阅读 ,看程序的时候一眼就能看到有什么函数。我想,这些应该都不是本质的原因。         毕竟拆分声明和定义个人认为是一件很麻烦的事情。后来我又详细的研究了这件事,以及做了一些测试。首先,我们来说, 把声明和定义都写在.h文件中行不行? 结论是大部分情况下, 。         举个例子,现在我们有三个函数段,我们要将他们分别放入独立的文件。还有一个main函数用来调用他们。(此处省略include)  第一个函数outputInt用来输出一个数字,代码如下:
void outputInt(int num){   printf("%d",num); }

 第二个函数outputIncreasedInt用来输出一个数字+1,代码如下:

void outputIncreasedInt(int num){   outputInt(num+1); }

 第三个函数outputDecreasedInt用来输出一个数字-1,代码如下:

void outputDecreasedInt(int num){   outputInt(num-1); }

(这两个函数在下文简写为+1-1函数)

main函数对他们进行调用,代码如下:

int main(){  outputIncreasedInt(5);  outputInt(6);  outputDecreasedInt(7);  return 0;}

然后,我们将其分为四种情况,进行测试。(添加#define #ifdef等用于防止多次包含的代码)

  1. 声明定义都放在.h里,共有四个文件。

    结果:编译通过,运行正常。

  2. 声明定义分别放置在.h和.c中,共七个文件。

    结果:编译通过,运行正常。

  3. outputInt函数声明定义分别放置在.h和.c中,+1-1输出函数声明定义都放在.h里,共五个文件。

    结果:编译通过,运行正常。

  4. outputInt函数声明定义都放在.h里,+1-1输出函数声明定义分别放置在.h和.c中,共六个文件。

    结果:链接错误,如图。

f0acfe958aa2cecaab440095551e9d65.png

(可以通过在void outputInt前面加上static解决此问题)

        链接错误是非常烦人的,因为它并不会给出明确的错误位置,也不会在 IntelliSense 中看到波浪线报错。所以,究竟为什么会这样呢,让我们深入来探讨这个问题。


在深入探讨之前,我们要明白当我们写完程序,按下生成按钮之后,电脑都为我们做了什么。简单来说可以简化为以下三个步骤
  1. 预处理,处理所有#开头的语句(除#pragma),展开#include等。
  2. 编译,此处包括编译和汇编,生成目标文件(Object Files)。
  3. 链接,将目标文件链接为一个可执行文件,进行符号重定位等。

a5a9ef8526fa8cfbb932617351118cff.png

其中,通常意义上 , 每个.c(或.cpp)的文件都会编译生成一个目标文件 ,而.h的头文件 只会在#include的地方将整个文件嵌入进去 。(实际上,与扩展名无关,在VS中由放置在源文件或头文件下决定,在CLion中由CMakeList决定,在行命令下由输入的文件参数决定)这就是.h和.c/cpp文件的本质区别。 在上文中讨论的第四种情况,因为outputInt.h中的outputInt函数,被插入到了多个c/cpp文件中,所以链接器在链接时无法确定使用哪个定义。而第三种情况下+1-1输出函数所在的.h文件都被插入在了main.c中,仅有outputInt.c和main.c两个编译单元,不存在符号重定义问题。 我们还是接着刚刚提到的报错的那种情况进行分析。该报错为链接错误,所以说已经完成了编译。可以在编译输出目录执行ls *.o中看到生成了三个目标文件。

df54c8274acdd03510269d66bd1c0290.png

使用nm命令分别分析这三个目标文件:

a78dfa3cf763001ca5105034e2de53f0.png

其中T代表该符号位于 代码段 (Text Section),U 代表该符号在当前文件中是 未定义 的,即该符号的定义在别的文件中。图中使用g++以c++标准编译,gcc对C++符号命名以_Z开头,然后是名词空间(namespace)/类名(此处并没有),然后是函数名,(所有名字会在之前加上长度),接下来是接收的参数类型。若使用gcc以C语言标准命名,则函数名为符号名本身。此处也揭示了C++为何支持 函数重载 (Function Overload)等特性。

e0710373109b55e8c1f03754e8219380.png

刚刚提到,T代表符号位于代码段,前面的地址为在代码段的偏移量,因为outputInt函数被声明及定义在.h文件中,而.h在文件最开始的地方被引用进来,所以在代码段的最前部。我们可以通过查看反汇编代码得知这一点。

649bd6bd219961a6d185e0b3d7706498.png

在编译成功后,我们接着要对其进行链接。链接器的主要任务之一就是重定位外部符号(刚刚nm命令中查看的U标记的符号),而在三个目标文件中代码段都含有outputInt的代码定义,所以链接器会报错,提示符号重定义。 细心的同学们,可能注意到前面提到在output函数前加上static关键字,即可正常编译运行。static关键字类似于文件中private,凡是加了static关键字的函数,则它 只对包含该static函数的 文件是可见的,在文件外是不可见的。在nm命令下我们看出他的符号标记变为小写t。链接器不会将t符号与其他目标文件进行链接。

16d36aef40d3f3d5eccdbb3afc91ace5.png

总结一下:
  1. 通常情况.h文件仅仅是在预处理过程中#include位置展开那个文件。
  2. 通常情况每个.c/.cpp文件会编译出一个目标文件
  3. 拆分.h和.c/.cpp能提升编译速度是因为在修改某些代码后只需要重新编译修改的代码所在的编译单元,并不需要重新编译整个项目。
  4. 第三方库或系统库通常只提供.h文件和链接库(分静态链接和动态链接,这点比较复杂本文就不讲了)(例如stdio.h会链接到编译好的系统库)
  5. 只是习惯上把.h和.c/.cpp取一样的名字罢了,实际上无所谓,也可以没有.h,如下示例也可正确编译和运行。
// main.cint fun(int);int main(){  fun(666);  return 0;}
// fun.cint fun(int i){return i;}
以上内容是最近关于这个问题的思考,如有错误欢迎指正。 如果喜欢 欢迎转发 以及点 在看
欢迎关注公众号 Xianfei 北京邮电大学软件学院  王衔飞 xianfei@bupt.edu.cn 参考书籍:《程序员的自我修养:链接、装载与库》
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值