C语言多文件编译总结:extern和static关键字+.h头文件包含规则+不能包含.c文件原因分析

多文件编译链接与.h头文件包含规则

如下示例程序,stack.c中定义了一个栈,以及栈的一些简单功能函数:push(),pop()以及is_empty()等。main.c为程序主函数,主要测试了栈的这些功能。

// stack.c
char stack[512];
int top = -1;
void push(char c)
{
    stack[++top] = c;
}

char pop(void)
{
    return stack[top--];
}

int is_empty(void)
{
    return top == -1;
}
// main.c
#include<stdio.h>
int main(void)
{
    push('a');
    push('b');
    push('c');
    while(!is_empty())
        putchar(pop());
    putchar('\n');
    return 0;
}

在命令行中使用gcc进行编译:

$ gcc main.c stack.c -o main

也分可以多步编译:

$ gcc -c main.c
$ gcc -c stack.c
$ gcc main.o stack.o -o main

但是编译成功后会报如下警告,如果未报,可添加 -Wall 参数:

main.c: In function ‘main’:
main.c:14:5: warning: implicit declaration of function ‘push’ [-Wimplicit-function-declaration]
   14 |     push('a');
      |     ^~~~
main.c:17:12: warning: implicit declaration of function ‘is_empty’ [-Wimplicit-function-declaration]
   17 |     while(!is_empty())
      |            ^~~~~~~~
main.c:18:17: warning: implicit declaration of function ‘pop’; did you mean ‘popen’? [-Wimplicit-function-declaration]
   18 |         putchar(pop());
      |                 ^~~
      |                 popen

警告中提到 push(),pop()以及is_empty()都是隐式声明的函数,这是因为这些函数在调用之前未经声明或者定义(或者其声明定义在其他源文件中),编译器在处理这些函数调用代码时没有找到函数原型,只好根据函数调用代码做隐式声明,进行逆推。

此外,隐式声明的函数返回值类型都默认为 int ,再根据调用当前函数时传入的参数类型来确定函数的参数类型 ,这样函数的参数和返回值类型都确定下来了,编译器根据这些信息为函数调用生成相应的指令。因此,编译器会把这三个函数声明为:

int push(char);
int pop(void);
int is_empty(void);

extern 和 static 关键字

可以使用extern关键字,在函数调用之前进行声明,这样编译器就不会报警告了。

在这里 extern 关键字表示这个标识符具有外部链接External Linkage。push 这个标识符具有External Linkage指的是:如果把 main.c 和 stack.c 链接在一起,如果 push 在 main.c 和 stack.c 中都有声明(在 stack.c 中的声明同时也是定义),那么这些声明指的是同一个函数,链接之后是同一个 GLOBAL 符号,代表同一个地址。函数声明中的 extern 也可以省略不写,不写 extern 的函数声明也表示这个函数具有External Linkage。

#include<stdio.h>

//extern关键字告诉编译器,函数的定义在其他源文件中
extern void push(char);
extern char pop(void);
extern int is_empty(void);

int a, b = 1;

int main(void)
{
    push('a');
    push('b');
    push('c');
    while(!is_empty())
        putchar(pop());
    putchar('\n');
    return 0;
}

如果用 static 关键字修饰一个函数声明,则表示该标识符具有内部链接属性Internal Linkage,例如有以下两个程序文件:

/* foo.c */
static void foo(void) {}
/* main.c */
void foo(void);
int main(void) { foo(); return 0; }

编译链接在一起会出错:

$ gcc foo.c main.c
/tmp/ccRC2Yjn.o: In function `main':
main.c:(.text+0x12): undefined reference to `foo'
collect2: ld returned 1 exit status

通俗地来讲,static关键字具有文件作用域,上述程序中,在foo.c中使用static修饰了foo()函数,使得foo()函数只在foo.c文件中可见,对main.c文件不可见,所以在编译过程中找不到foo()函数的定义。

从编译链接的角度来分析:

虽然在 foo.c 中定义了函数 foo ,但这个函数只具有内部链接属性Internal Linkage,只有在 foo.c 中多次声明才表示同一个函数,而在 main.c 中声明就不表示它了。如果把 foo.c 编译成目标文件,函数名 foo 在其中是一个 LOCAL 的符号,不参与链接过程,所以在链接时, main.c 中用到一个External Linkage的 foo 函数,链接器却找不到它的定义在哪儿,无法确定它的地址,也就无法做符号解析,只好报错。

凡是被多次声明的变量或函数,必须有且只有一个声明是定义,如果有多个定义,或者一个定义都没有,链接器就无法完成链接。

使用头文件封装

在上文中提到:在函数调用之前,需要有函数的定义或者声明,否则编译器就会根据函数的调用,自己推导出隐式声明的函数声明,其中函数的返回值默认为 int ,函数参数类型根据调用的实际输入参数确定。但是随着程序的源文件逐渐增多,如果每一个功能文件中都需要调用stack.c里面的函数,则需要在各个文件中重复写函数的声明。重复性的工作应该尽量避免,我们可以写一个stack.h的头文件,将函数的声明放入,需要用到stack.c里面的函数的源文件,只需包含该头文件即可:

#ifndef _STACK_H
#define _STACK_H
void push(char);
char pop(void);
int is_empty(void);
#endif

这样在 main.c 中只需包含这个头文件就可以了,而不需要写三个函数声明:

/* main.c */
#include <stdio.h>
#include "stack.h"
int main(void)
{
    push('a');
    push('b');
    push('c');
    while(!is_empty())
    putchar(pop());
    putchar('\n');
    return 0;
}

关于头文件,有以下问题需要注意:

一,尖括号和双引号

包含头文件,有尖括号和双引号之分,这也是一道基础性的C语言面试问题,读者可以做如下实验,包含头文件时全使用尖括号或者双引号,看看是否可以通用:

//报错
#include<stack.h>
#include<stdio.h>
//正常运行
#include"stack.h"
#include"stdio.h"

通过实验可知,二者是不可通用的,尖括号有使用限制,不可以给自己定义的头文件使用尖括号包含,否则会出现如下错误:

main.c:7:9: fatal error: stack.h: 没有那个文件或目录
    7 | #include<stack.h>
      |         ^~~~~~~~~
compilation terminated.

如果全使用双引号,则可以编译成功。

尖括号和双引号的区别如下:

  • 对于用角括号包含的头文件, gcc 首先查找 -I 选项指定的目录,然后查找系统的头文件目录(通常是 /usr/include ,在我的系统上还包括 /usr/lib/gcc/x86_64-linux-gnu/9/include );
  • 而对于用引号包含的头文件, gcc 首先查找包含头文件的 .c 文件所在的目录,然后查找 -I 选项指定的目录,然后查找系统的头文件目录。

二,头文件路径查找

当前的目录结构如下:

luo@luo-X550JX:~/STUDY/Linux_C/ASM$ tree
.
├── main3.c
├── main.c
├── stack.c
└── stack.h

0 directories, 4 files

stack.h头文件和stack.c文件与main.c文件在同一个文件夹中。则可以用

gcc -c main.c

编译, gcc 会自动在 main.c 所在的目录中找到 stack.h 。假如把 stack.h 移到一个子目录:

luo@luo-X550JX:~/STUDY/Linux_C/ASM$ tree
.
├── main3.c
├── main.c
└── stack
    ├── stack.c
    └── stack.h

1 directory, 4 files

再次使用gcc -c main.c 编译则会找不到stack.h:

luo@luo-X550JX:~/STUDY/Linux_C/ASM$ gcc main.c -c
main.c:7:9: fatal error: stack.h: 没有那个文件或目录
    7 | #include"stack.h"
      |         ^~~~~~~~~
compilation terminated.

有两种解决办法:

一是使用gcc 的 -I参数指定路径,用 -I 选项告诉 gcc 头文件要到子目录 stack 里找。具体的写法如下:

# 写法1, 使用 -I[dir]格式,直接在I后面接路径名称
$ gcc main.c -c -Istack

# 写法2, 使用 -I [dir]格式,路径和-I分开写
$ gcc main.c -c -I ./stack/

# 编译链接
$ gcc main.c ./stack/stack.c -o main -I ./stack/

二是在 #include 预处理指示中可以使用相对路径,例如把上面的代码改成 #include “stack/stack.h” ,那么编译时就不需要加 -Istack 选项了,因为 gcc 会自动在 main.c 所在的目录中查找,而头文件相对于 main.c 所在目录的相对路径正是 stack/stack.h。

三,头文件重复包含

回到我们一开始写的头文件stack.h中,在 stack.h 中我们看到两个新的预处理指示 #ifndef STACK_H 和 #endif ,#ifndef是if not define的简写,意思是说,如果 STACK_H 这个宏没有定义过,那么就定义一个STACK_H宏,并且开始其他操作,如定义函数或变量,直到#endif这一行为止。如果在包含这个头文件时 STACK_H 这个宏已经定义过了,则直接跳过这段代码。

#ifndef _STACK_H
#define _STACK_H
void push(char);
char pop(void);
int is_empty(void);
#endif

这是为了解决头文件重复包含,而导致的代码冗余,命名冲突,重复定义等一系列问题。假如 main.c 包含了两次 stack.h :

#include "stack.h"
#include "stack.h"
int main(void)
{
...

则第一次包含 stack.h 时并没有定义 STACK_H 这个宏,因此头文件的内容包含在预处理的输出结果中:

#define STACK_H
extern void push(char);
extern char pop(void);
extern int is_empty(void);
#include "stack.h"
int main(void)
{
...

其中已经定义了这个宏,因此第二次再包含就相当于包含了一个空文件,这就避免了头文件的内容被重复包含。这种保护头文件的写法称为Header Guard,以后我们每写一个头文件都要加上Header Guard,宏定义名就用头文件名的大写形式,这是规范的做法。

在大规模的项目当中,头文件中包含头文件的问题很常见,这样重复包含的问题就很难被发现,虽然程序中的变量和函数可以被重复声明,这种情况下,程序还可以正常运行,但是重复包含头文件将会带来以下危害:

  1. 一是使预处理的速度变慢,要处理很多本来不需要处理的头文件。
  2. 二是头文件包含陷入死循环,如果有 foo.h 包含 bar.h , bar.h 又包含 foo.h 的情况,预处理器就陷入死循环了(其实编译器都会规定一个包含层数的上限)。
  3. 三是头文件里有些代码不允许重复出现,虽然变量和函数允许多次声明(只要不是多次定义
    就行),但头文件里有些代码是不允许多次出现的,比如 typedef 类型定义和结构体Tag定义
    等,在一个程序文件中只允许出现一次。

四,为什么要包含头文件而不是 .c 文件

先看示例程序和编译结果,本次实验有三个源文件,stack.c中定义了栈相关功能函数,foo.c和main.c中直接使用include包含了stack.c文件,来调用其中的函数:

//stack.c
char stack[512];
int top = -1;
void push(char c)
{
    stack[++top] = c;
}

char pop(void)
{
    return stack[top--];
}

int is_empty(void)
{
    return top == -1;
}
//foo.c
#include"stack/stack.c"

void foo()
{
    is_empty();
}

//main.c
#include"stack/stack.c"
#include<stdio.h>
void foo();
int main(void)
{
    foo();
    push('a');
    push('b');
    push('c');
    while(!is_empty())
        putchar(pop());
    putchar('\n');
    return 0;
}

编译结果:

luo@luo-X550JX:~/STUDY/Linux_C/ASM$ gcc main.c foo.c -o main
/usr/bin/ld: /tmp/cc61cDKn.o:(.data+0x0): multiple definition of `top'; /tmp/ccsld8Vm.o:(.data+0x0): first defined here
/usr/bin/ld: /tmp/cc61cDKn.o: in function `push':
foo.c:(.text+0x0): multiple definition of `push'; /tmp/ccsld8Vm.o:main.c:(.text+0x0): first defined here
/usr/bin/ld: /tmp/cc61cDKn.o: in function `pop':
foo.c:(.text+0x35): multiple definition of `pop'; /tmp/ccsld8Vm.o:main.c:(.text+0x35): first defined here
/usr/bin/ld: /tmp/cc61cDKn.o: in function `is_empty':
foo.c:(.text+0x5b): multiple definition of `is_empty'; /tmp/ccsld8Vm.o:main.c:(.text+0x5b): first defined here
collect2: error: ld returned 1 exit status

出现了重复定义的错误。原因在于直接include源文件stack.c,相当于把stack.c中关于变量和函数的相关定义也包含了进来,就相当于 push 、 pop 、 is_empty 这三个函数在 main.c 和 foo.c 中都有定义,那么 main.c 和 foo.c 就不能链接在一起了。如果采用包含头文件的办法,则是多次声明,一次定义,这三个函数在main.c和foo.c中声明了各声明了一次,只在 stack.c 中定义了一次,最后可以把 main.c 、 stack.c 、 foo.c 链接在一起。如下图所示:

参考文章:

http://akaedu.github.io/book/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

SOC罗三炮

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值