C语言的本质(六):链接详解-定义和声明

1 extern和static关键字

在上一节我们把两个.c文件放在一起编译链接,main.c用到的函数push、pop和is_empty由stack.c提供,其实有一点小问题,我们用-Wall选项编译main.c可以看到:

      $ gcc -c main.c -Wall
      main.c: In function ‘main’:
      main.c:8: warning: implicit declaration of function ‘push’
      main.c:12: warning: implicit declaration of function ‘is_empty’
      main.c:13: warning: implicit declaration of function ‘pop’

编译器在处理main.c中的函数调用时找不到函数原型,也就不知道函数的参数和返回值类型,而只能根据函数调用的实参类型做隐式声明,并假定返回值是int型。编译器把这三个函数隐式声明为:

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

结合上一章讲过的知识想想,为什么编译器在处理函数调用时需要知道函数原型?

因为必须知道参数的类型和个数以及返回值类型才知道应该生成什么样的指令。为什么隐式声明靠不住呢?

因为隐式声明是根据函数调用代码推测的,

  • 第一,函数的形参类型可能跟函数调用的实参类型不一致,
  • 第二,如果函数定义带有可变参数(例如printf),从函数调用代码也看不出来它带可变参数,
  • 第三,从函数调用代码看不出来返回值应该是什么类型,隐式声明只能假定返回值都是int型。

既然隐式声明靠不住,那编译器为什么不自己去找函数定义,非要我们在调用之前提供函数原型呢?
因为编译器不知道去哪里找函数定义,像上面的例子,我让编译器编译main.c,而这几个函数的定义却在stack.c里,编译器怎么会知道呢?所以编译器只能通过隐式声明来推测函数原型,这种推测往往是错的,但在比较简单的情况下还算可用,比如上一节的例子这么编译过去了也能得到正确结果。

现在我们在main.c中声明这几个函数的原型:

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

这样编译器就不会报警告了。用extern关键字修饰的函数名具有External Linkage。

External Linkage的定义在上一章讲过,现在应该更容易理解了,push这个标识符具有External Linkage指的是:push在main.c和stack.c中都有声明(stack.c中的声明同时也是定义),如果把main.c和stack.c链接在一起,那么这些声明指的是同一个函数,在链接时各目标文件中的全局符号push代表同一个地址。

函数声明中的extern关键字也可以省略不写。

用static关键字修饰的函数名具有Internal Linkage。例如有以下两个.c文件:

      /* 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

虽然foo.c中定义了函数foo,但这个函数名只有Internal Linkage,只有在foo.c中引用这个函数名才表示同一个函数,而在main.c中声明的那个foo应该表示另一个具有External Linkage的函数名。

如果把foo.c编译成目标文件,函数名foo在其中是一个局部符号,在链接时不参与符号解析

所以,在main.c中引用了一个具有External Linkage的函数名foo,但链接器却找不到它的定义在哪儿,无法确定它的地址,只好报错。凡是被多次声明的变量或函数,必须有且只有一个声明是定义,如果有多个定义,或者一个定义都没有,链接器就无法完成链接。

以上讲了用static和extern修饰函数声明的情况,现在来看用它们修饰变量声明的情况。仍然用stack.c和main.c的例子,如果我想在main.c中直接访问stack.c中定义的变量top,可以用extern声明它:

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

变量top具有External Linkage,extern int top;只是一个声明而不是定义,因为它不在main.c中分配存储空间,而是在stack.c中定义和分配存储空间,main.c只是引用这个变量名。以上函数和变量声明也可以写在main函数体里面,使所声明的标识符具有块作用域:

      int main(void)
      {
              void push(char);
              char pop(void);
              int is_empty(void);
              extern int top;
              push('a');
              push('b');
              push('c');
              printf("%d\n", top);
              while(!is_empty())
                    putchar(pop());
              putchar('\n');
              printf("%d\n", top);
              return 0;
      }

注意,变量声明和函数声明有一点不同,函数声明的extern关键字可以省略,而变量声明如果不写extern意思就完全变了,如果上面的例子不写extern就表示在main函数中定义一个局部变量top。

另外要注意,变量定义可以初始化而声明不可以,stack.c中的定义可以写成int top = -1;,

而main.c中的声明却不能写成extern int top = -1;,否则编译器会报错。

在main.c中可以通过extern声明来访问stack.c中的变量top,但是从实现stack.c这个模块的角度来看,top这个变量是不希望被外界访问到的,变量top和stack都属于这个模块的内部状态,外界应该只允许通过push、pop函数来改变模块的内部状态,这样才能保证堆栈的LIFO访问特性,如果外界可以随机访问stack或者随便修改top,那么堆栈的状态就乱了。

怎么才能阻止外界访问top和stack呢?答案就是用static关键字把它们声明为Internal Linkage的:

      /* stack.c */
      static char stack[512];
      static 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中用extern声明也访问不到stack.c的变量top和stack,从而保护了stack.c模块的内部状态,这也是一种封装。用static关键字声明具有Internal Linkage的函数也是出于这个目的。

在一个模块中,有些函数是提供给外界使用的,或者说导出(Export)给外界使用,这些函数声明为External Linkage的。有些函数只在模块内部使用而不希望被外界访问到,则声明为Internal Linkage的。

2 头文件

我们继续前面关于stack.c和main.c的讨论。

stack.c这个模块封装了top和stack两个变量,导出了push、pop、is_empty三个函数接口,已经设计得比较完善了。

但是使用这个模块的每个.c文件都要写三个函数声明也是很麻烦的,假设又有一个foo.c也使用这个模块,main.c和foo.c中各自要写三个函数声明。

重复的代码总是应该尽量避免的,比如在之前节讲过用宏定义避免硬编码。要避免写重复的声明也有办法,可以自己写一个头文件stack.h:

      /* stack.h */
      #ifndef STACK_H
      #define STACK_H
      extern void push(char);
      extern char pop(void);
      extern int is_empty(void);
      #endif

main.c和foo.c都可以包含这个头文件,就相当于声明了这三个函数。比如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;
      }

首先说为什么#include <stdio.h>用角括号,而#include "stack.h"用引号。

  • 对于用角括号包含的头文件,gcc首先查找-I选项指定的目录,然后查找系统的头文件目录(在我的系统上是按/usr/local/include、/usr/lib/gcc/i486-linux-gnu/4.4.3/include、/usr/i486-linux-gnu/include、/usr/include的顺序依次查找);

  • 而对于用引号包含的头文件gcc首先查找正在被处理的#include指示所在的当前文件所在的目录,然后查找-I选项指定的目录,然后查找系统的头文件目录。

假如三个代码文件都放在当前目录下:

      $ tree
      .
      |-- main.c
      |-- stack.c
      '-- stack.h
      0 directories, 3 files

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

      $ tree
      .
      |-- main.c
      '-- stack
          |-- stack.c
          '-- stack.h
      1 directory, 3 files

则需要用gcc -c main.c -Istack编译,用-I选项告诉gcc头文件要到子目录stack里找

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

注意,-I选项可以指定相对路径也可以指定绝对路径,如果指定相对路径,它是相对于gcc进程的当前工作目录的路径,而不是相对于正在被处理的#include指示所在的当前文件的路径。

在这里先解释一下当前工作目录(Current Working Directory)的概念。每个进程都有自己的当前工作目录,Shell进程的当前工作目录可以用pwd命令查看:

      $ pwd
      /home/akaedu

通常Linux发行版缺省配置的Shell提示符会显示当前工作目录,例如

  • ~$表示当前工作目录是主目录,
  • /etc$表示当前工作目录是/etc目录。
  • 用cd命令可以改变Shell进程的当前工作目录。
  • 如果在Shell下敲命令启动新进程(比如gcc),则新进程会继承Shell的当前工作目录,新进程也可以调用chdir(2)改变自己的当前工作目录。

下面再举个例子来加深理解。比如有以下目录结构:

      $ tree
      .
      '-- foo
          |-- bar.h
          '-- foo.h
      1 directory, 2 files

在foo.h中有一行代码#include “bar.h”,用gcc -E命令可以查看预处理的结果:

      $ gcc -E foo/foo.h
      # 1 "foo/foo.h"
      # 1 "<built-in>"
      # 1 "<command-line>"
      # 1 "foo/foo.h"
      # 1 "foo/bar.h" 1
      content of bar.h
      # 1 "foo/foo.h" 2

如果在foo.h中有一行代码#incldue <bar.h>,则用gcc -E命令做预处理会出错,因为gcc不会查找foo.h所在的目录:

      $ gcc -E foo/foo.h
      # 1 "foo/foo.h"
      # 1 "<built-in>"
      # 1 "<command-line>"
      # 1 "foo/foo.h"
      foo/foo.h:1:17: error: bar.h: No such file or directory

如果先cd到foo子目录中再指定-I.选项就管用了,这时gcc的当前工作目录正是bar.h所在的目录:

      $ cd foo/
      $ gcc -E foo.h
      # 1 "foo.h"
      # 1 "<built-in>"
      # 1 "<command-line>"
      # 1 "foo.h"
      foo.h:1:17: error: bar.h: No such file or directory
      $ gcc -E -I. foo.h
      # 1 "foo.h"
      # 1 "<built-in>"
      # 1 "<command-line>"
      # 1 "foo.h"
      # 1 "./bar.h" 1
      content of bar.h
      # 1 "foo.h" 2

回到正题。在stack.h中我们又看到两个新的预处理指示#ifndef STACK_H和#endif,意思是说,如果STACK_H这个宏没有定义过,那么从#ifndef到#endif之间的代码就包含在预处理的输出结果中,否则这一段代码就不出现在预处理的输出结果中。

stack.h这个头文件的内容全部被#ifndef和#endif括起来了,如果在包含这个头文件时STACK_H这个宏已经定义过了,则相当于这个头文件里什么都没有,包含了一个空文件。这有什么用呢?

假如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)
      {
      ...

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

为什么需要防止重复包含呢?谁会把一个头文件包含两次呢?像上面这样明显的错误没人会犯,但有时候重复包含的错误并不是那么明显的。比如:

      #include "stack.h"
      #include "foo.h"

foo.h里又包含了bar.h,bar.h里又包含了stack.h。在规模较大的项目中头文件包含头文件的情况很常见,经常会包含四五层,这时候重复包含的问题就很难发现了。比如在我的系统头文件目录/usr/include中,errno.h包含了bits/errno.h,后者又包含了linux/errno.h,后者又包含了asm/errno.h,后者又包含了asm-generic/errno.h。

另外一个问题是,就算我重复包含了头文件,那有什么危害么?像上面的三个函数声明,在一个编译单元中多出现几次也没有错。重复包含头文件主要有以下问题:

  • 1.预处理和编译的速度变慢了,要处理很多本来不需要处理的代码。
  • 2.如果不小心出现foo.h包含bar.h、bar.h又包含foo.h的情况,就陷入死循环了。其实一般编译器都会规定一个包含层数的上限,超过这个上限就报错。
  • 3.头文件里有些代码不允许重复出现,虽然变量和函数允许多次声明(只要不是多次定义就行),但头文件里有些代码是不允许多次出现的,比如用typedef定义一个类型名,在一个编译单元中只允许定义一次。

还有一个问题,既然要#include头文件,那我不如直接在main.c中#include "stack.c"得了。这样把stack.c和main.c合并成一个编译单元,相当于又回到最初例12.1的代码了,用gcc main.c -o main也能编译通过。这样不是更简单吗?连头文件都不用写了。

假如又有一个foo.c也要用stack.c这个模块怎么办呢?如果在foo.c里面也#include"stack.c",就相当于push、pop、is_empty这三个函数在main.c和foo.c的编译单元中都有定义,那么main.c和foo.c就不能链接在一起了。如果采用包含头文件的办法,这三个函数只在stack.c中定义一次,在main.c和foo.c中只是声明,就可以把main.c、stack.c、foo.c三个编译单元链接在一起,如下图所示。

一般来说,应遵循以下原则:

  • 1…c文件中可以有变量或函数定义,而.h文件中应该只有变量或函数声明而没有定义。
  • 2.不要把一个.c文件包含到另一个.c文件中。

“二般”来说,也有些特别的Convention不遵循这两条原则:有些程序员喜欢在.h文件中定义static inline函数,也有些情况下确实会在一个.c文件中include另一个.c文件(例如在单元测试代码中include被测代码),在此不深入探讨这些问题。

3 定义和声明的详细规则

以上两节关于变量、函数的定义和声明只介绍了最基本的规则,在写代码时掌握这些基本规则就够用了,但其实C语言关于定义和声明还有很多复杂的规则,在维护别人的代码时还是有必要了解这些规则的。

首先看关于函数声明的规则,表1和表2出自参考文献[4],我做了一些修改。

前面我说“extern关键字表示这个标识符具有External Linkage”其实是不准确的,准确地说应该是Previous Linkage。

Previous Linkage的定义是:当前声明的这个标识符具有什么样的Linkage取决于该编译单元中前面对这个标识符的声明(而且必须是文件作用域的声明),如果在前面找不到这个标识符的声明,即当前声明是该编译单元中对这个标识符的第一次声明,那么这个标识符具有External Linkage。

例如在一个编译单元中在文件作用域两次声明同一个函数:

      static int f(void); /* internal linkage */
      extern int f(void); /* previous linkage */

则这里的extern修饰的标识符f具有Interanl Linkage而不是External Linkage。

从表1的前两行可以总结出我们先前所说的规则——函数声明中的extern关键字可以省略不写。

表1也说明了在文件作用域允许定义函数,而在块作用域不允许定义函数,或者说函数定义不能嵌套。

另外,在块作用域中不允许用static关键字声明函数。

关于变量声明的规则要复杂一些,如表.2所示。

表2的每个单元格里分成四行,分别描述变量的链接属性、变量的生存期、这种变量如何初始化,以及这种声明是否算变量定义。

链接属性有

  • External Linkage、
  • Internal Linkage、
  • No Linkage
  • Previous Linkage四种情况,

生存期有

  • Static Duration
  • Automatic Duration两种情况。

初始化有

  • Static Initializer
  • Dynamic Initializer两种情况,前者表示Initializer中只能使用常量表达式,表达式的值必须在编译时确定,后者表示Initializer中可以使用任意右值表达式,表达式的值可以在运行时计算。

是否算变量定义有三种情况,Definition、Not a Definition和Tentative Definition。前面我说“有extern的变量声明不是定义,没有extern的才是定义,变量定义可以初始化而声明不可以”,其实也不准确。

C标准是这么规定的:有初始化的变量声明是定义;没有初始化的变量声明如果加了extern修饰则属于Previous Linkage,这种声明不是定义;如果没加extern修饰也没有初始化则属于Tentative Definition。什么叫Tentative Definition呢?如果一个变量声明具有文件作用域,没有初始化,没有用Storage Class关键字修饰,或者用static关键字修饰,那么编译器认为这个变量是在该编译单元中定义的,但初始值待定,然后继续编译下面的代码,到整个编译单元编译结束时如果没有遇到这个变量的带初始化的定义,就用0来初始化它。来看个例子:

      int i1 = 1;        // definition, external linkage
      static int i2 = 2; // definition, internal linkage
      extern int i3 = 3; // definition, external linkage
      int i4;            // tentative definition, external linkage
      static int i5;     // tentative definition, internal linkage
      int i1;            // valid tentative definition, refers to previous
      int i2;            // 6.2.2 renders undefined, linkage disagreement
      int i3;            // valid tentative definition, refers to previous
      int i4;            // valid tentative definition, refers to previous
      int i5;            // 6.2.2 renders undefined, linkage disagreement
      extern int i1;     // refers to previous, whose linkage is external
      extern int i2;     // refers to previous, whose linkage is internal
      extern int i3;     // refers to previous, whose linkage is external
      extern int i4;     // refers to previous, whose linkage is external
      extern int i5;     // refers to previous, whose linkage is internal

变量i2和i5第一次声明为Internal Linkage,第二次又声明为External Linkage,这是不允许的,编译器会报错。

注意表2中标有[*]的单元格,对于文件作用域的extern变量声明,C99是允许带Initializer的,并且认为它是一个定义,但是gcc对于这种写法会报警告,为了兼容性应避免这种写法。

gcc对于Tentative Definition的处理也和C99的规定不一致。比如在foo.c中用int i;定义一个变量i,是Tentative Definition,C99规定这个变量应该在foo.c中定义,初始值是0,而gcc编译的结果是:

      $ gcc -c foo.c
      $ nm foo.o
      00000004 C i

符号i的类型是Common,nm(1)中说Common符号的定义在链接时确定。

如果bar.c中定义int i = 1;,则foo.c和bar.c链接在一起时foo.c的那个只能算声明而不算定义。

如果bar.c中也定义int i;,也是一个Tentative Definition,则foo.c和bar.c链接在一起时才定义变量i,并且用0初始化。

也就是说,C99对Tentative Definition的处理是在编译一个单元时做,而gcc是推迟到链接时才做。如果编译时加上-fno-common选项则不会生成Common符号,gcc对Tentative Definition的处理就和C99一致了。

猜你喜欢

转载自blog.csdn.net/weixin_45264425/article/details/132333186