【C/C++】extern 的一些注意事项

2023-08-30 14:54:01 浏览数 (1)

前言

前些日子,有友友问了我这样的一道问题:

数组通过外部声明为指针时,数组和指针是不能互换使用的;那么请思考一下,在 A 文件中定义数组 char a[100];在 B 文件中声明为指针:extern char *a;此时访问 a[i],会发生什么;

先说结果,会引起 segmentation fault 报错;

那接下来由博主来分析一番;

数组与指针的区别

在介绍 extern 之前,我们需要了解一下数组与指针有什么区别?

数组变量代表了存放该数组的那块内存,它是这块内存的首地址。这就说明了数组变量是一个地址,而且,还是一个不可修改的常量,具体来说,就是一个地址常量。

数组变量跟枚举常量一样,都属于符号常量。数组变量这个符号,就代表了那块内存的首地址。注意,不是数组变量这个符号的值是那块内存的首地址,而是数组变量这个符号本身代表了首地址,它就是这个地址值。这就是数组变量属于符号常量的意义所在。

由于数组变量是一种符号常量,它是一个右值,而指针,作为变量,却是一个左值,一个右值永远都不是左值,那么,数组名永远都不会是指针!

举个例子,char a[] 中的 a 是常量,是一个地址,char *a 中 a 是一个变量,一个可以存放地址的变量。

具体分析

了解了数组与指针的区别之后,让我们来看看 extern 声明全局变量的内部实现;

extern 是 C/C 语言中表明函数和全局变量作用范围(可见性)的关键字,该关键字告诉编译器,其声明的函数和变量可以在本模块或其它模块中使用。

TIP :被 extern 修饰的全局变量不被分配空间,而是在链接的时候到别的文件中通过查找索引定位该全局变量的地址。

代码语言:javascript复制
extern char a[];

这是一个外部变量的声明,它声明了一个名为 a 的字符数组,编译器看到这个声明就知道不必为这个变量分配空间,这个 .cpp 文件中所有对数组 a 的引用都化为一个不包含类型的标号,具体地址的定位留给链接器完成。编译完成之后也得到一个中间文件,链接器遍历这个文件,发现有未经定位的标号,于是它搜索其他中间文件,试图寻找到一个匹配的空间地址,在此例中无疑链接器将成功地寻找到这个地址并将此中间文件中所有的这个标号替换为链接器所寻找到的地址,最终生成的可执行文件中,所有曾经的标号都应当已经被替换为地址。这是一个正常工作过程,链接出来的可执行文件至少在对于该数组的引用部分将工作得很好。

代码语言:javascript复制
extern char * a; 

这是一个外部变量的声明,它声明了一个名为 a 的字符指针,中间过程与上同,经过一番搜索,找到了一个分配过空间的名为 a 的地方(也就是我们先定义的那个字符数组),链接器并不知道它们的类型,仅仅是发现它们的名字一样,就认为应该把 extern 声明的标号链接到数组 a 的首地址上,因此链接器把指针 a 对应的标号替换为数组 a 的首地址。这里问题就出现了:由于在这个文件中声明的 a 是一个指针变量而不是数组,链接器的行为实际上是把指针 a 自身的地址定位到了另一个 .c 文件中定义的数组首地址上,而不是我们所希望的把数组的首地址赋予指针 a(这很容易理解:指针变量也需要占用空间,如果说把数组的首地址赋给了指针 a,那么指针 a 本身在哪里存放呢?)。这就是症结所在了。所以此例中指针 a 的内容实际上变成了数组 a 首地址开始的 4 字节表示的地址(如果在 16 位机上,就是 2 字节)。

上述加粗部分的可以理解为,链接器认为 a 变量本身的内存位置是数组的首地址,但其实 a 的位置是其他位置,其内容才是数组首地址。

举个例子,定义 char a[] = "abcd",则外部变量 extern char a[] 的地址是 0x12345678 (数组的起始地址),而 extern char *a 是重新定义了一个指针变量 a,其地址可能是 0x87654321,因此直接使用 extern char *a 是错误的。

通过上述分析,我们得到的最重要的结论是:使用 extern 修饰的变量在链接的时候只找寻同名的标号,不检查类型,所以才会导致编译通过,运行时出错。

extern "C"

extern "C" 包含双重含义,从字面上即可得到:

  • 首先,被它修饰的目标是 extern 的;
  • 其次,被它修饰的目标是 C 的。

1、 被 extern "C" 限定的函数或变量是 extern 类型的;

代码语言:javascript复制
extern int a;

仅仅是一个变量的声明,其并不是在定义变量 a,并未为 a 分配内存空间。变量 a 在所有模块中作为一种全局变量只能被定义一次,否则会出现连接错误。

通常,在模块的头文件中对本模块提供给其它模块引用的函数和全局变量以关键字 extern 声明。例如,如果模块 B 欲引用该模块 A 中定义的全局变量和函数时只需包含模块 A 的头文件即可。这样,模块 B 中调用模块 A 中的函数时,在编译阶段,模块 B 虽然找不到该函数,但是并不会报错,它会在连接阶段中从模块 A 编译生成的目标代码中找到此函数。

extern 对应的关键字是 static,被它修饰的全局变量和函数只能在本模块中使用。因此,一个函数或变量只可能被本模块使用时,其不可能被 extern "C" 修饰。

2、被 extern "C" 修饰的变量和函数是按照 C 语言方式编译和连接的;

未加 extern "C" 声明时的编译方式

作为一种面向对象的语言,C 支持函数重载,而过程式语言 C 则不支持。函数被 C 编译后在符号库中的名字与 C 语言的不同。例如,假设某个函数的原型为:

代码语言:javascript复制
void foo( int x, int y );

该函数被 C 编译器编译后在符号库中的名字为 _foo,而 C 编译器则会产生像 _foo_int_int 之类的名字(不同的编译器可能生成的名字不同,但是都采用了相同的机制,生成的新名字称为 “mangled name”)。

_foo_int_int 这样的名字包含了函数名、函数参数数量及类型信息,C 就是靠这种机制来实现函数重载的。例如,在 C 中,函数 void foo(int x, int y)void foo(int x, float y) 编译生成的符号是不相同的,后者为 _foo_int_float

同样地,C 中的变量除支持局部变量外,还支持类成员变量和全局变量。用户所编写程序的类成员变量可能与全局变量同名,我们以 . 来区分。而本质上,编译器在进行编译时,与函数的处理相似,也为类中的变量取了一个独一无二的名字,这个名字与用户程序中同名的全局变量名字不同。


未加 extern "C" 声明时的连接方式

假设在 C 中,模块 A 的头文件如下:

代码语言:javascript复制
// 模块A头文件 moduleA.h

#ifndef MODULE_A_H

#define MODULE_A_H

int foo(int x, int y);

#endif

在模块 B 中引用该函数:

代码语言:javascript复制
// 模块B实现文件 moduleB.cpp

#include "moduleA.h"

foo(2, 3);

实际上,在连接阶段,连接器会从模块 A 生成的目标文件 moduleA.obj 中寻找 _foo_int_int 这样的符号;


extern "C" 声明后的编译和连接方式

extern "C" 声明后,模块 A 的头文件变为:

代码语言:javascript复制
// 模块A头文件 moduleA.h

#ifndef MODULE_A_H

#define MODULE_A_H

extern "C" int foo(int x, int y);

#endif

在模块 B 的实现文件中仍然调用 foo(2, 3),其结果是:

  1. 模块 A 编译生成 foo 的目标代码时,没有对其名字进行特殊处理,采用了 C 语言的方式;
  2. 连接器在为模块 B 的目标代码寻找 foo(2, 3) 调用时,寻找的是未经修改的符号名 _foo

如果在模块 A 中函数声明了 fooextern "C" 类型,而模块 B 中包含的是 extern int foo(int x, int y),则模块 B 找不到模块 A 中的函数;反之亦然。

所以,可以用一句话概括 extern "C" 这个声明的真实目的:实现 C 与 C 及其它语言的混合编程。

后记

以上就是 【C/C 】extern 的一些注意事项 的全部内容了,希望对大家有所帮助!

0 人点赞