LV01-08-C语言-多文件

本文主要是C语言基础——多文件编程相关笔记,若笔记中有错误或者不合适的地方,欢迎批评指正😃。

点击查看使用工具及版本
Windows windows11
Ubuntu Ubuntu16.04的64位版本
VMware® Workstation 16 Pro 16.2.3 build-19376536
SecureCRT Version 8.7.2 (x64 build 2214) - 正式版-2020年5月14日
开发板 正点原子 i.MX6ULL Linux阿尔法开发板
uboot NXP官方提供的uboot,NXP提供的版本为uboot-imx-rel_imx_4.1.15_2.1.0_ga(使用的uboot版本为U-Boot 2016.03)
linux内核 linux-4.15(NXP官方提供)
STM32开发板 正点原子战舰V3(STM32F103ZET6)
点击查看本文参考资料
参考方向 参考原文
------
点击查看相关文件下载
--- ---

一、多文件编程

前边学习函数的时候,我们把函数全部都放在一个文件中,函数少的话倒是无所谓,很多的话,就不是很方便了。 C 语言是支持多文件编程的,我们可以将某些功能放入独立的源文件,形成一个单独的模块,这样不仅便于阅读,也更加方便维护。这也可以称之为模块化编程。

1.简单的例子

首先来看一个例子,了解一下多文件编程时程序的编译和运行吧。

点击查看实例
1
2
3
4
5
6
7
#include <stdio.h>

int main(int argc, char *argv[])
{
fun1();
return 0;
}
1
2
3
4
5
6
#include <stdio.h>

void fun1(void)
{
printf("fun1\n");
}

在终端执行以下命令:

1
gcc *.c -Wall    # 编译程序 

会在终端看到以下提示信息:

1
2
3
4
main.c: In function ‘main’:
main.c:5:5: warning: implicit declaration of function ‘fun1’ [-Wimplicit-function-declaration]
fun1();
^~~~

虽然有警告,但是依然生成了 a.out 可执行文件,终端运行 ./a.out 命令,看到有如下信息输出:

1
fun1

这说明,虽然有警告,但是依然正常调用了函数。

看过例子,会发现,这样写出来的程序是有警告的,原因应该就在于函数应该先声明,再调用,这个问题我们下边再解决。

2. extern 关键字

C 语言代码是由上到下依次执行的,不管是变量还是函数,原则上都要先定义再使用,否则就会报错。但在实际开发中,经常会在函数或变量定义之前就使用它们,这个时候就需要提前声明。声明( Declaration ),就是告诉编译器现在要使用这个变量或函数,现在没有找到它的定义不要紧,不要报错,后边会有定义的。

2.1函数声明

在前边学习函数的时候提到过函数声明,那时并没有使用 extern 关键字,因为函数的定义有函数体,函数的声明没有函数体,编译器很容易区分定义和声明,所以对于函数声明来说,有没有 extern 都是一样的。所以函数的声明可以有以下形式,

1
2
3
4
5
6
/* 不使用 extern */
dataType function( dataType arg1, dataType arg2, ... );
dataType function( dataType1, dataType2, ... );
/* 使用 extern */
extern dataType function( dataType1 arg1, dataType2 arg2, ... );
extern dataType function( ddataType1, dataType2, ... );
点击查看实例
1
2
3
4
5
6
7
8
9
#include <stdio.h>

extern void fun1(void);

int main(int argc, char *argv[])
{
fun1();
return 0;
}
1
2
3
4
5
6
#include <stdio.h>

void fun1(void)
{
printf("fun1\n");
}

在终端执行以下命令:

1
gcc *.c -Wall    # 编译程序 

会发现没有警告了,然后在终端运行 ./a.out 命令,看到有如下信息输出:

1
fun1

2.2变量声明

变量和函数不同,编译器只能根据 extern 来区分,有 extern 才是声明,没有 extern 就是定义。变量的声明只有一种形式,就是使用 extern 关键字:

1
extern dataType name;
点击查看实例
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

extern void fun1(void);
extern int a;
int main(int argc, char *argv[])
{
fun1();
printf("a = %d\n", a);
return 0;
}
1
2
3
4
5
6
#include <stdio.h>
int a = 10;
void fun1(void)
{
printf("fun1 a=%d\n", a);
}

在终端执行以下命令:

1
2
gcc *.c -Wall    # 编译程序 
./a.out # 运行可执行程序

会看到有如下信息输出:

1
2
fun1 a=10
a = 10

【注意】变量不要在声明的同时初始化,格式为:

1
extern dataType name = value;

但是这种方式会有警告,并且不会正常生成可执行文件。

点击查看警告信息
1
2
3
4
5
6
main.c:4:12: warning: ‘a’ initialized and declared ‘extern’
extern int a = 10;
^
/tmp/ccy6uPA9.o:(.data+0x0): a'被多次定义
/tmp/ccfIMEHM.o:(.data+0x0):第一次在此定义
collect2: error: ld returned 1 exit status

二、 GCC 编译

前边我们使用的 GCC 编译器来生成可执行文件,那么生成可执行文件的中间经历了什么呢?这些其实没必要关心,但是如果了解的话可以帮助我们更好的理解多文件编程。

查阅资料会知道,从源代码生成可执行文件可以分为四个步骤,分别是预处理( Preprocessing )、编译( Compilation )、汇编( Assembly )和链接( Linking )。

点击查看测试文件内容
  • 简单的只使用一个文件用于测试,文件名称为 test.c
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#define N 100

int main(int argc, char *argv[])
{

printf("N = %d \n", N);

return 0;
}

1.预处理

预处理,主要是处理源文件和头文件中以 # 开头的命令,包括 #include 、 #define 、 #ifdef 等(关于这些后边还会详细学习)。

1.1预处理的规则

  • 删除所有 #define ,并展开所有的宏定义。
  • 处理所有条件编译命令,包括 #if 、 #ifdef 、 #elif 、 #else 、 #endif 等。
  • 处理 #include 命令,将被包含文件的内容插入到该命令所在的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还会包含其他的文件。
  • 删除所有的注释,包括 // 和 /* … */ 。
  • 添加行号和文件名标识,便于在调试和出错时给出具体的代码位置。
  • 由于编译器的需要,预处理阶段会保留所有的 #pragma 命令。

1.2预处理的结果

预处理的结果是生成 .i 文件, .i 文件是包含 C 语言代码的源文件,只不过在此文件中所有的宏已经被展开,所有包含的文件已经被插入到当前文件中。

1.3预处理的命令

在 linux 下,我们使用 GCC 编译来生成预处理文件的命令如下:

1
gcc -E test.c -o test.i

我们打开这个 test.i 文件就可以看到其中的内容,这里只截取一部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 1 "test.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "test.c"
# 1 "/usr/include/stdio.h" 1 3 4

中间部分省略...

# 4 "test.c"
int main(int argc, char *argv[])
{

printf("N = %d \n", 100);

return 0;
}

2.编译

编译就是把预处理完的文件进行一些列的词法分析、语法分析、语义分析以及优化后生成相应的汇编代码文件。编译是整个程序构建的核心部分,也是最复杂的部分之一。

由于比较复杂,而且对我来说不是关注重点,这里就简单介绍,不再详写了。毕竟就这一部分的东西就有一本叫《编译原理》的书来详细解释了😂。

2.1编译的结果

编译的结果就是生成汇编文件, GCC 中它以 .s 为后缀名,其他编译器下可能以 .asm 为后缀。

2.2编译的命令

在 linux 下,我们使用 GCC 编译来生成汇编文件的命令如下:

1
2
3
gcc -S test.i -o test.s
/* 或者也可以由源文件直接得到 */
gcc -S test.c -o test.s

我们打开这个 test.s 文件就可以看到其中的内容,将 C 语言源代码转化为了汇编语言:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
	.file	"test.c"
.text
.section .rodata
.LC0:
.string "N = %d \n"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl %edi, -4(%rbp)
movq %rsi, -16(%rbp)
movl $100, %esi
leaq .LC0(%rip), %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0"
.section .note.GNU-stack,"",@progbits

3.汇编

汇编的过程就是将汇编代码转换成可以执行的机器指令。

3.1汇编的结果

汇编的结果是产生目标文件,这个文件是二进制文件。它在 GCC 下的后缀为 .o ,在 其他编译器下的后缀可能为 .obj 。

3.2汇编的命令

在 linux 下,我们使用 GCC 编译来生成目标文件的命令如下:

1
2
3
gcc -c test.s -o test.o
/* 或者也可以由源文件直接得到 */
gcc -c test.c -o test.o

一般来说,这个二进制文件我们是打不开的,不过也没有必要里边的详细内容。

4.链接

目标文件已经是二进制文件,与可执行文件的组织形式类似,但是有些函数和全局变量的地址还未找到,程序不能执行。

链接的作用就是找到那些目标地址,将所有的目标文件组织成一个可以执行的二进制文件

4.1链接的结果

链接的结果是产生可执行文件,这个文件是二进制文件。它在 GCC 下默认的名称为 a.out ,在其他编译器下的后缀可能为 .exe 。

4.2链接的命令

在 linux 下,我们使用 GCC 编译来生成可执行文件的命令如下:

1
2
3
4
5
gcc test.o         /* 生成文件名称默认 */
gcc test.o -o test /* 生成文件名称指定为test */
/* 或者也可以由源文件直接得到 */
gcc test.c /* 生成文件名称默认 */
gcc test.c -o test /* 生成文件名称指定为test */

三、目标文件和可执行文件

上边我们看到最后生成的有目标文件和可执行文件,它们都是二进制文件,那么它们的组织形式是怎样的呢?

1.文件格式

现在PC平台上流行的可执行文件格式主要有两种,一种是 Windows 下的 PE ( Portable Executable )和,另一种是 Linux 下的 ELF ( Executable Linkable Format ),它们都是 COFF ( Common File Format )格式的变种。

COFF 是 Unix V3 首先提出的规范,微软在此基础上制定了 PE 格式标准,并将它用于 Windows。后来 Unix V4 又在 COFF 的基础上引入了 ELF 格式,被 Linux 广泛使用。所以 Windows 和 Linux 上的可执行文件非常相似。

其中目标文件与可执行文件的存储格式几乎是一样的,我们可以将它们看成是同一种类型的文件,在 Windows 下,它们统称为 PE 文件,在 Linux 下,将它们统称为 ELF 文件。

2.目标文件

整体上看,编译生成的目标文件被划分成了多个部分,每个部分叫做一个( Section )。在 Linux 中 GCC 生成的目标文件的格式如下:

image-20220419080208035

【说明】

(1)段名大都以 . 作为前缀,表示这些名字是系统保留的。

(2)除了上边系统保留的段名,应用程序也可以使用其它名字定义自己的段,应用程序自定义的的段不建议使用 . 作为前缀,否则容易和系统保留段发生冲突。例如,在在 ELF 文件中插入一个名为 music 的段来保存 MP3 音频数据。

(3)图中仅列出一些关键的段名称,还有一些隐藏在 Other Data 也就是其他数据中。

点击查看各部分说明
段名 说明
ELF Header文件头,描述了整个目标文件的属性,包括是否可执行、是动态链接还是静态链接、入口地址、目标硬件、目标操作系统、段表偏移等信息。
.text代码段,存放编译后的机器指令,也即各个函数的二进制代码。
.data数据段,存放全局变量和静态变量。
.rodata只读数据段,存放一般的常量、字符串常量等。
.rel.text
.rel.data
重定位段,包含了目标文件中需要重定位的全局符号以及重定位入口。
.comment注释信息段,存放的是编译器的版本信息,比如 GCC:(GUN) 4.2.0 。
.debug调试信息。
.line调试时的行号表,就是源代码行号与编译后指令的对应表。
Section Table段表,描述了 ELF 文件包含的所有段的信息,比如段的名字、段的长度、在文件中的偏移、读写权限以及其他属性。可以说,ELF 文件的段结构是由段表来决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的。
.strtab字符串表,保存了 ELF 文件用到的字符串,比如变量名、函数名、段名等。因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难,常见的做法就是把字符串集中起来存放到一个表中,然后使用字符串在表中的偏移来引用字符串。
.symtab符号表,保存了全局变量名、局部变量名、函数名等在字符串表中的偏移。

3.可执行文件

可执行文件与目标文件的组织形式非常类似,在 Linux 中 GCC 生成的目标文件的格式如下:

image-20220419105929631

【说明】

(1)图中仅列出一些关键的段名称,还有一些隐藏在 Other Data 也就是其他数据中。

(2)红色字体为相对于目标文件,可执行文件新增的一些段,画有删除线的是可执行文件删除的一些段。

(3)右侧为 32 位环境的 Linux 内存模型。

(4)可执行文件在加载时实际上是被映射的虚拟地址空间,所以可执行文件很多时候又被叫做映像文件( Image )。

4.段合并

编译器生成的是目标文件,而我们最终需要的是可执行文件,链接( Linking )的作用就是将多个目标文件合并成一个可执行文件。在链接过程中,链接器会将多个目标文件中的代码段、数据段、调试信息等合并成可执行文件中的一个段,链接器还会删除多余的段(例如重定位段、段表等),增加其他段(例如程序头表等)。

段的合并仅仅是一个简单的叠加过程,如下图所示。

image-20220419111437250

四、符号 

其实多数情况下,我们的程序都不会只有一个源文件,大多有很多的源文件,他们都会生成目标文件( Linux 中 GCC 编译后为 .o 文件),但是最终只有一个可执行文件,这个可执行文件与那些目标文件是怎样联系起来的呢?为什么我们可以把不同的模块写在不同的源文件进行模块化开发呢?大多数情况下完全不用考虑这两个问题,它并不会影响我们写程序,但是我觉得吧了解一下总归是好的,这样我们也可以更好的理解链接到底做了什么。

1.符号的概念

数据是保存在内存中的,对于计算机硬件来说,必须知道它的地址才能使用。变量名、函数名等仅仅是地址的一种助记符,目的是在编程时更加方便地使用数据,当源文件被编译成可执行文件后,这些标识符都不存在了,它们被替换成了数据的地址。

点击查看实例

假设变量 a 、 b 、 c 的地址分别为 0x0000 、 0x0004 、 0x0008 ,加法运算的机器指令为 1000 ,赋值运算的机器指令为 1001 ,那么在 C 语言中实现加法运算,

1
c = a + b;

当生成可执行文件后的机器码就如下:

1
2
1000  0X0000  0X0004  /* 将两个数据相加的值保存在一个临时区域 */
1001 0X0008 /* 将临时区域中的数据复制到地址为0X1008的内存中 */

计算机刚刚诞生的时候没有编程语言,人们直接使用机器语言(二进制)编程。现在假设有一种跳转指令,它的二进制形式为 0001 ,如果需要执行地址为 1010 的代码,那么就可以这样写:

1
0001 1010

但是程序序并不是一写好就不再变化,它可能会经常被修改。例如我们在地址 1010 之前插入了其他指令,那么原来的代码就得往后移动,这样的话上面的跳转指令的跳转地址也得相应地调整。

在这个过程中,程我们需要人工重新计算每个子程序或者跳转的目标地址,这种重新计算各个目标地址的过程叫做重定位( Relocation )。每次程序修改时,这些位置都要重新计算,十分繁琐又耗时,并且很容易出错。如果程序包含了多个源文件,就很可能会有跨文件的跳转,这种人工重定位的方式在程序拥有多个模块时会导致更加严重的问题。

为了解决这些问题,汇编语言( Assembly )诞生了,汇编语言使用接近人类的各种符号和标记来帮助记忆,比如用 jmp 表示跳转指令,用 func 表示一个子程序( C 语言中的函数就是一个子程序)的起始地址,这种符号的方法使得人们从具体的机器指令和二进制地址中解放出来。上边的机器码写成汇编的形式就是:

1
jmp func

这样,不管在 func 之前增加或者减少了多少条指令导致 func 的地址发生了变化,汇编器在每次汇编程序的时候会重新计算 func 这个符号的地址,然后把所有使用到 func 的地方修正为新的地址。

于是,符号( Symbol )这个概念随着汇编语言的普及被人们广泛接受,它用来表示一个地址,这个地址可能是一段子程序(后来发展为函数)的起始地址,也可以是一个变量的地址。

2.模块化开发

我们自己写程序的时候也会将不同功能的代码放入单独的文件,形成模块,这些模块之间相互依赖又相互独立,原则上每个模块都可以单独开发、编译、测试,改变一个模块中的代码不需要编译整个程序。哈哈,不懂就问,这些模块是怎么被联系起来的呢?其实以前只管写,属实不知道其中原因。

在 C 语言中,模块(可以理解为源文件,就是我们写的 .c 文件)之间的依赖关系主要有两种:一种是模块间的函数调用,另外一种是模块间的变量访问函数调用需要知道函数的首地址变量访问需要知道变量的地址,所以这两种方式可以归结为一种,那就是模块间的符号引用。模块间依靠符号来交流,这其实很类似于拼图版,定义符号的模块多出一个区域,引用符号的模块刚好少了那一块区域,两者刚好完美组合。如下图所示:

image-20220419121748007

通过符号将多个模块拼接为一个独立的程序的过程就叫做链接( Linking )。我们可以将符号看做是链接中的粘合剂,整个链接过程正是基于符号才能正确完成。

所有的符号都保存在符号表 .symtab 中,它一个结构体数组,每个数组元素都包含了一个符号的信息,包括符号名、符号在段中的偏移、符号大小(符号所占用的字节数)、符号类型等。在符号表中的符号包括:

  • 全局符号,也就是函数和全局变量,它们可以被其他目标文件引用。

  • 外部符号( External Symbol ),也就是在当前文件中使用到、却没有在当前文件中定义的全局符号。

  • 局部符号,也就是局部变量。它们只在函数内部可见,对链接过程没有作用,所以链接器往往也忽略它们。

  • 段名,这种符号往往由编译器产生,它的值就是该段的起始地址,比如 .text 、 .data 等。

确切地说,真正的符号名字是保存在字符串表 .strtab 中的,符号表仅仅保存了当前符号在字符串表中的偏移。

3.符号决议

当要进行链接时,链接器首先扫描所有的目标文件,获得各个段的长度、属性、位置等信息,并将目标文件中的所有(符号表中的)符号收集起来,统一放到一个全局符号表。同时,链接器会将目标文件中的各个段合并到可执行文件,并计算出合并后的各个段的长度、位置、虚拟地址等。

在目标文件的符号表中,保存了各个符号在段内的偏移,生成可执行文件后,原来各个段( Section )起始位置的虚拟地址就确定了下来,这样,使用起始地址加上偏移量就能够得到符号的地址(在进程中的虚拟地址)。这种计算符号地址的过程被称为符号决议( Symbol Resolution )。

重定位表 .rel.text 和 .rel.data 中保存了需要重定位的全局符号以及重定位入口,完成了符号决议,链接器会根据重定位表调整代码中的地址,使它指向正确的内存位置。至此,可执行文件就生成了,链接器的任务也随之完成。

4.强弱符号

4.1是什么?

可以先看个例子:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

extern void fun1(void);
int a = 20;
int main(int argc, char *argv[])
{
fun1();
printf("a = %d\n", a);
return 0;
}
1
2
3
4
5
6
#include <stdio.h>
int a = 10;
void fun1(void)
{
printf("fun1 a=%d\n", a);
}

在终端执行以下命令:

1
gcc *.c -Wall    # 编译程序 

会看到终端有如下信息输出:

1
2
3
/tmp/ccjAxzo8.o:(.data+0x0):  a'被多次定义
/tmp/ccxtucvP.o:(.data+0x0):第一次在此定义
collect2: error: ld returned 1 exit status

这是一种符号重复定义( Multiple Definition )的错误,是因为在多个源文件中定义了名字相同的全局变量,并且都将它们初始化了,这种符号的定义可以被称为强符号。

在 C 语言中,编译器默认函数和初始化了的全局变量为强符号( Strong Symbol ),未初始化的全局变量为弱符号( Weak Symbol )。强符号强在它拥有确切的数据,变量有值,函数有函数体;弱符号弱在它还未被初始化,没有确切的数据。

4.2怎么处理?

链接器会按照如下的规则处理被多次定义的强符号和弱符号:

  • 不允许强符号被多次定义,即不同的目标文件中不能有同名的强符号;如果有多个强符号,那么链接器会报符号重复定义错误。

  • 如果一个符号在某个目标文件中是强符号,在其他文件中是弱符号,那么选择强符号。

  • 如果一个符号在所有的目标文件中都是弱符号,那么选择其中占用空间最大的一个。

例如目标文件 a.o 定义全局变量 a 为 int 类型,占用 4 个字节,目标文件 b.o 定义 a 为 double 类型,占用 8 个字节,那么被链接后,符号 a 占用 8 个字节,但是自己进行测试的时候,似乎编译器是选择弱符号自己所在文件中的类型,而且同一个源文件中不允许定义名称相同但是数据类型不同的全局变量。例如,

点击查看实例
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

extern void fun1(void);
double a;
int main(int argc, char *argv[])
{
fun1();
a = 0.3;
printf("a = %f, sizeof(a)=%ld\n", a, sizeof(a));
return 0;
}
1
2
3
4
5
6
7
8
#include <stdio.h>
int a;
void fun1(void)
{
a = 20;
printf("fun1 a=%d, sizeof(a)=%ld\n", a, sizeof(a));

}

在终端执行以下命令:

1
2
gcc *.c -Wall    # 编译程序 
./a.out # 执行可执行程序

会看到终端有如下信息输出:

1
2
fun1 a=20, sizeof(a)=4
a = 0.300000, sizeof(a)=8

【注意】在 GCC 中,可以通过 attribute((weak)) 来强制定义任何一个符号为弱符号。但是 attribute((weak)) 只对链接器有效,对编译器不起作用,编译器不区分强符号和弱符号,只要在一个源文件中定义两个相同的符号,不管它们是强是弱,都会报重复定义错误。

4.4有什么用?

我们在开发库时,可以将某些符号定义为弱符号,这样就能够被用户定义的强符号覆盖,从而使得程序可以使用自定义版本的函数,增加了很大的灵活性。

  • 强符号覆盖弱符号
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

extern void fun1(void);
__attribute__((weak)) int a = 20;
int main(int argc, char *argv[])
{
fun1();
printf("a = %d\n", a);
return 0;
}
1
2
3
4
5
6
#include <stdio.h>
int a = 10;
void fun1(void)
{
printf("fun1 a=%d\n", a);
}

在终端执行以下命令:

1
2
gcc *.c -Wall    # 编译程序 
./a.out # 执行可执行程序

会看到终端有如下信息输出:

1
2
fun1 a=10
a = 10

可以看到,强符号覆盖了弱符号,最后输出的 a 的值都是 10 。

5.强弱引用

引用( Reference ),是指对符号的使用。我们所看到的符号引用,在所有目标文件被链接成可执行文件时,它们的地址都要被找到,如果没有符号定义,链接器就会报符号未定义错误,这种被称为强引用( Strong Reference )。如果符号有定义,就使用它对应的地址,如果没有定义,也不报错,这种引用就叫弱引用( Weak Reference ),在变量声明或者函数声明前边加上 attribute((weak)) 就会使符号变为弱引用。

链接器处理强引用和弱引用的过程几乎是一样的,只是对于未定义的弱引用,链接器不认为它是一个错误,一般默认其为 0 (地址为 0 ),或者是一个特殊的值,以便程序代码能够识别。

点击查看实例
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

extern void fun1(void);
__attribute__((weak)) extern int a;
int main(int argc, char *argv[])
{
fun1();
printf("&a = %p\n", &a);
printf("a = %d\n",a);
return 0;
}
1
2
3
4
5
6
#include <stdio.h>

void fun1(void)
{
printf("fun1 a=%d\n", a);
}

在终端执行以下命令:

1
2
gcc *.c -Wall    # 编译程序 
./a.out # 运行可执行程序

会看到有如下信息输出:

1
2
3
fun1
&a = (nil)
段错误 (核心已转储)

在程序,变量 a 是没有进行定义的,直接使用了 extern 来进行声明,当有 attribute((weak)) 时表示 a 为弱符号,这个时候再引用 a 的时候就是弱引用了,所以即便没有定义,编译也不会报错,但是程序执行一部分后,需要使用 a 的值得时候,就开始报错了。这是因为 a 的地址是 0 ,这个地址是禁止被访问的。

若是不想让它报错,可以加一个判断即可,主函数可修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

extern void fun1(void);
__attribute__((weak)) extern int a;
int main(int argc, char *argv[])
{
fun1();
printf("&a = %p\n", &a);
if(&a)
{
printf("a = %d\n", a);
}
else
{
printf("a is undefined!\n");
}
return 0;
}

弱引用和强引用非常利于程序的模块化开发,我们可以将程序的扩展模块定义为弱引用,当我们将扩展模块和程序链接在一起时,程序就可以正常使用;如果我们去掉了某些模块,那么程序也可以正常链接,只是缺少了某些功能,这使得程序的功能更加容易裁剪和组合。

五、头文件

我们写程序的时候,会加上 #include <stdio.h> 这样的头文件, C 语言中 .h 结尾的文件叫做头文件。 .c 和 .h 文件都是源文件,除了后缀不一样便于区分外和管理外,其他的都是相同的。

在 .c 中编写的代码同样也可以写在 .h 中,包括函数定义、变量定义、预处理等。但是 .h 和 .c 承担的角色不一样: .c 文件主要负责实现,也就是定义函数和变量; .h 文件主要负责声明(包括变量声明和函数声明)、宏定义、类型定义等。这些不是 C 语法规定的内容,而是约定成俗的规范,或者说是长期形成的事实标准。

在实际开发中,我们会将函数、变量等的声明放在 .h 文件中,以便于其他程序调用。这样做有什么好处呢?源文件通过编译可以生成目标文件(例如 GCC 下的 .o 和 Visual Studio 下的 .obj ),或者打包成静态库,只要向用户提供头文件,用户就可以将这些模块链接到自己的程序中。这样既可以保护版权,也便于发布和使用。

1. C 语言标准库

在 C 语言编程的时候,我们一般是看不到自带函数的具体实现的,例如 printf 函数,我们即便是跳转到定义,也只能看到一个函数生命罢了。这是因为 C 语言允许将多个相关的目标文件打包成一个静态链接库( Static Link Library ),例如 Linux 下的 .a 文件和 Windows 下的 .lib 文件。 C 语言在发布的时候已经将标准库打包到了静态库,并提供了相应的头文件,例如 stdio.h 、 stdlib.h 、 string.h 等。

Linux 一般将静态库和头文件放在 /lib 和 /user/lib 目录下, C 语言标准库的名字是 libc.a 。 Windows 下,标准库由 IDE 携带,像 Visual Studio ,在安装目录下的 \VC\include 文件夹中会看到很多头文件,包括常用的 stdio.h 、 stdlib.h 等,在 \VC\lib 文件夹中有很多 .lib 文件,这就是链接器要用到的静态库。这里有两个网站,里边对 C 语言的标准库有一些很详细的说明,甚至还有一些函数的使用例子可以参考:

C library http://www.cplusplus.com/reference/clibrary/
C Standard Library header files https://en.cppreference.com/w/c/header

2.自定义头文件

一般来说我们自己实现了某些模块的功能,我们就可以自己来写相应的头文件,便于自己的调用。根据大家约定俗成的规范,自定义的头文件一般可以包含如下内容:

  • 声明函数,但不可以定义函数。
  • 声明变量,但不可以定义变量。
  • 定义宏,包括带参的宏和不带参的宏。
  • 结构体的定义、自定义数据类型一般也放在头文件中。

【注意】在头文件声明函数的时候不用像声明变量一样加上 extern ,即便不加也不会造成混乱,为了简便,往往是不用加的。

点击查看实例

【说明】引入自定义头文件,一般格式如下:

1
#include "filename.h"
1
2
3
4
5
6
7
8
9
#include <stdio.h>
#include "fun1.h"

int main(int argc, char *argv[])
{
fun1();
printf("a = %d\n", a);
return 0;
}
1
2
3
4
5
6
7
8
#include <stdio.h>

int a = 100;

void fun1(void)
{
printf("fun1 a = %d\n", a);
}
1
2
extern int a;
extern void fun1(void);

在终端执行以下命令:

1
2
gcc *.c -Wall    # 编译程序 
./a.out # 运行可执行程序

会看到有如下信息输出:

1
2
fun1 a = 100
a = 100

【注意】其实习惯上,在 .h 文件中对函数进行声明,也可以不用添加 extern 关键字(具体原因还没有追究过)。

3.头文件路径

头文件在被包含的时候有两种格式,一种使用 <> ,另一种使用 “ “ :

1
2
#include <filename.h>
#include "filename.h"

两者区别如下:

< >编译器会到系统路径(C语言标准静态链接库所在路径,Linux中静态库和头文件一般在/lib和/user/lib目录)下查找头文件。
" "编译器首先在当前目录下查找头文件,如果没有找到,再到系统路径下查找。

也就是说,使用双引号比使用尖括号多了一个查找路径,它的功能更为强大,所以完全可以使用双引号来包含标准头文件。

引入头文件时可以使用绝对路径,也可以使用相对路径。当使用相对路径的方式引入头文件时,如果使用 < > ,那么相对的就是系统路径,也就是说,编译器会直接在这些系统路径下查找头文件;如果使用 “ “ ,那么首先相对的是当前路径,然后相对的才是系统路径,而使用绝对路径的方式引入头文件时, < > 和 “ “ 没有任何区别,因为头文件路径已经固定了(从根目录开始查找),不需要相对任何路径。

总起来说,相对路径要有相对的目标,这个目标可以是当前路径,也可以是系统路径, < > 和 “ “ 决定了到底相对哪个目标。一般来说,自己编写的头文件在引用时最好使用相对路径,这样即便工程进行了移动,文件路径也不会出现问题。

4.重复引入

头文件包含命令 #include 与直接复制粘贴头文件内容的效果是一样的,预处理器会读取头文件的内容,然后将文件内容插入到 #include 命令所在的位置。如果被包含的头文件中还包含了其他的头文件,预处理器会继续将它们也包含进来;这个过程会一直持续下去,直到不再包含任何头文件。

4.1有什么问题?

不懂就问,文件重复引入的话会有什么问题呢,为什么要关心这个问题?接下来我们看一个实例。

点击查看实例
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include "fun1.h"
#include "fun2.h"

int main(int argc, char *argv[])
{
fun1();
printf("main:a = %d\n", a);
return 0;
}
1
2
3
4
5
#include "fun1.h"
void fun1(void)
{
printf("fun1:a = %d\n", a);
}
1
2
3
#include <stdio.h>
#include "fun2.h"
extern void fun1(void);
1
int a;

在终端命令行执行以下命令:

1
2
gcc -E main.c -o main.i
vim main.i
image-20220419220121990

会发现,变量 a 在预处理后,出现了两次定义。然后我们全部编译链接一下,让它生成可执行程序,在终端执行以下命令:

1
gcc *.c -Wall # 编译程序

这个时候我们就会发现,编译似乎并没有报错,甚至于还可以正常执行,个中缘由嘛想一下是为啥嘞?前边提到了弱符号,未赋值的全局变量属于弱符号,它在程序中并不会报错,根据处理规则,会选择其中的一个,但若是提前初始化了,就会导致出现两个强符号,这在 GCC 中是不被允许的。接下来我们可以修改 fun2.h 中 a 的定义,我们直接进行赋值,然后再进行编译,会看到有如下提示:

1
2
3
4
5
6
7
8
9
In file included from main.c:3:0:
fun2.h:3:5: error: redefinition of ‘a’
int a = 10;
^
In file included from fun1.h:3:0,
from main.c:2:
fun2.h:3:5: note: previous definition of ‘a’ was here
int a = 10;
^

这是直接报了一个错误,是说 a 重复定义了。

通过上述例子,我们会发现重复引入头文件的话,头文件中的一些变量也会被多次引入。更不必说 stdio.h 这种标准库头文件中除了有函数声明,还有宏定义、类型定义、结构体定义等,多次引入的话它们都会出现很多次次,如果不做任何处理,不仅可能会出现重复定义错误,而且不符合编程规范。

4.2如何解决?

上边我们看到了头文件重复包含导致的问题,但是我们会发现,为什么 stdio.h 文件也被包含了很多次,但是却没有报错呢,并且里边的内容似乎也仅仅只被包含了一次而已?这是因为标准库头文件使用了宏保护来放置重复引入头文件,一般格式如下:

1
2
3
4
5
#ifndef _FILENAME_H
#define _FILENAME_H
/* 需要引入的头文件 */

#endif

第一次包含头文件,会定义宏 _FILENAME_H ,并执行”需要引入的头文件”部分的代码;第二次包含时因为已经定义了宏 _FILENAME_H ,不会重复执行”需要引入的头文件”部分的代码。所以头文件只在第一次包含时起作用,再次包含无效。

所以上边的例子可以做一个修改。

点击查看实例
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include "fun1.h"
#include "fun2.h"

int main(int argc, char *argv[])
{
fun1();
printf("main:a = %d\n", a);
return 0;
}
1
2
3
4
5
#include "fun1.h"
void fun1(void)
{
printf("fun1:a = %d\n", a);
}
1
2
3
4
5
6
7
#ifndef _FUN1_H
#define _FUN1_H
/* 需要引入的头文件 */
#include <stdio.h>
#include "fun2.h"
extern void fun1(void);
#endif
1
2
3
4
5
#ifndef _FUN2_H
#define _FUN2_H
/* 需要引入的头文件 */
int a;
#endif

在终端命令行执行以下命令:

1
2
gcc -E main.c -o main.i
vim main.i
image-20220419221732388

从图中可以推断出,文件只被包含了一次。然后我们全部编译链接一下,让它生成可执行程序,在终端执行以下命令:

1
gcc *.c -Wall # 编译程序

这个时候我们就会发现,并没有报错,这是因为它依然是弱符号,但是至少 main.i 中没有出现重复定义的 a 了。那要是变为强符号呢?我们对 fun2.h 中的 a 进行赋值,然后再编译链接,会看到如下提示:

1
2
3
/tmp/ccLQGYqr.o:(.data+0x0):  a 被多次定义
/tmp/cctZ4oU9.o:(.data+0x0):第一次在此定义
collect2: error: ld returned 1 exit status

虽然没有报 error ,但是编译还是无法通过,所以呢,即便是只引入一次,但是依然在 GCC 中不被允许。

从上边的例子中可以看出,宏保护可以有效防止文件的重复引入,但是对于定义在头文件中的变量依然会有问题,所以最好的方法当然就是规范编程习惯啦,将变量定义到 .c 文件中去, .h 文件只负责声明就没得问题啦。

六、 extern 与 .h

从上边可以看出, extern 与 .h 头文件有些功能是很类似的,那他们有什么不同嘛?既然用 #include 可以包含其他头文件中变量、函数的声明,为什么还要 extern 关键字嘞?本部分笔记主要参考了这篇文章

1.头文件

不管是 C 还是 C++ ,我们要是把函数,变量或者结构体,类啥的放在 .c 或者 .cpp 文件里。然后编译成 lib 、 dll 、 obj 、 .o 等等,然后其他人用的时候,最基本的就是直接 gcc hisfile.c ourfile.o 等等。但对于其他开发者来说,他们怎么知道我们的 lib 、 dll 、 obj 或者 .o 里面到底有什么东西?这个时候要看头文件啦。头文件就需要有对用户的说明,函数,参数,各种各样的接口的说明等。既然是说明,那么头文件里面放的自然就是关于函数,变量,类的”声明”(对函数来说,也叫函数原型)了。

我们可以将头文件后缀改为 .text ,在引用该头文件的地方用 #include “xxx.txt” ,然后再去编译链接程序,会发现,依然可以全部通过,这就说明,其实头文件仅仅是通过被包含将自己里边的内容插入到被需要的地方去,没有其他的作用。

还记得上边重复引入的问题吧,在头文件中定义的变量即便我们用上了宏保护,但是依然在生成可执行文件的过程中出现了错误,严格来讲应该是在链接的过程中会报重复定义的错误。这是因为多个 c 文件包含这个头文件时,因为宏名有效范围仅限于本 c 源文件,所以在这多个 c 文件编译时是不会出错的,但在链接时就会报错:就像上边一样:

1
2
3
/tmp/ccLQGYqr.o:(.data+0x0):  a 被多次定义
/tmp/cctZ4oU9.o:(.data+0x0):第一次在此定义
collect2: error: ld returned 1 exit status

2. extern

在定义变量的时候,这个 extern 可以被省略(定义时,默认均省略);在声明变量的时候,这个 extern 必须添加在变量前,所以有时会让我们搞不清楚到底是声明还是定义。或者说,变量前有 extern 不一定就是声明,而变量前无 extern 就只能是定义。注意,定义要是为变量分配内存空间的;而声明不需要。

  • 变量

对于变量来说,有如下几种形式,

1
2
3
4
5
extern int a;     /* 声明一个全局变量 a */
int a; /* 定义一个全局变量 a */

extern int a =0 ; /* 定义一个全局变量 a 并赋初值 */
int a =0; /* 定义一个全局变量 a, 并赋初值 */

其中, int a; 、 extern int a=0; 还有 int a=0; 都只能出现一次,而那个 extern int a; 可以出现很多次。我们需要引用一个全局变量的时候,就必须要声明 extern int a; 这时候 extern 不能省略,若是省略了,就变成 int a; 这是一个定义,不是声明。注意, extern int a; 中类型 int 可省略,即 extern a; 但其他类型则不能省略。

  • 函数

对于函数也一样,也是定义和声明,定义的时候用 extern ,说明这个函数是可以被外部引用的,声明的时候用 extern 说明这是一个声明。 但由于函数的定义和声明是有区别的,定义函数要有函数体,声明函数没有函数体(还有以分号结尾),所以函数定义和声明时都可以将 extern 省略掉,有没有 extern 其他文件也都是知道这个函数是在其他地方定义的,所以不加 extern 也行,两者很容易区分,所以省略了 extern 也不会有问题。

  • 总而言之

对变量,如果想在本源文件A中使用另一个源文件B中的变量,方法有两种:

(1)在A文件中必须用extern声明在B文件中定义的变量(当然是全局变量);

(2)在A文件中添加B文件对应的头文件,这个头文件需要包含B文件中的变量声明,即在这个头文件中必须用extern声明该变量,否则,该变量又会被定义一次。

对函数,如果想在本源文件A中使用另一个源文件B的函数,方法也有两种:

(1)在A文件中用extern声明在B文件中定义的函数(其实也可省略extern,只需在A文件中出现B文件定义函数原型即可,这样似乎也不会有什么问题);

(2)在A文件中添加B文件对应的头文件,当然这个头文件包含B文件中的函数原型,在头文件中函数可以不用加extern。

3.两者联系

上边看完,有两个问题:

(1)用 #include 可以包含其他头文件中变量、函数的声明,为什么还要 extern 关键字?

(2)如果想引用一个全局变量或函数 a ,只要直接在源文件中包含 #include ( xxx.h 包含了 a 的声明)不就可以了么,为什么还要用 extern 呢?

如果一个文件 A 要大量引用另一个文件 B 中定义的变量或函数,则使用头文件效率更高,程序结构也更规范。其他文件(例如文件名 C 、 D 等)要引用文件名 B 中定义的变量或函数,则只需用 #include 包含文件 B 对应的头文件(要注意这个头文件只有对变量或函数的声明,绝不能有定义)即可。

很久很久以前,有一个编译器😁,它只认识 .c (或 .cpp )文件,而不知道 .h 是什么。那时的人们写了很多的 .c (或 .cpp )文件,渐渐地,人们发现在很多 .c (或 .cpp )文件中的声明变量或函数原型是相同的,但他们却不得不一个字一个字地重复地将这些内容敲入每个 .c (或 .cpp )文件。但更为恐怖的是,当其中一个声明有变更时,就需要检查所有的 .c (或 .cpp )文件,并修改其中的声明,额,这就太恐怖了😇。

后来终于,有人或许是一些人再不能忍受这样的折磨,他们将重复的部分提取出来,放在一个新文件里,然后在需要的 .c (或 .cpp )文件中敲入 #include XXX 这样的语句。这样即使某个声明发生了变更,也再不需要到处寻找与修改了😃.

这个新文件,经常被放在 .c (或 .cpp )文件的头部,所以就给它起名叫做头文件,扩展名是 .h 。从此,编译器(其实是其中预处理器)就知道世上除了 .c (或 .cpp )文件,还有 .h 文件,以及一个叫做 #include 命令。