C 中的链接
不同于 C++,C 在链接方面有一些语法是不同的,它们在一定程度上让问题更加复杂化。
显著区别
在需要注意的 C 特性中,我提到了 C 里只读变量拥有外部连接而非内部连接。所以,在 C 中符号连接的问题变得更加简单:它总默认是外部连接的。
C 没有命名空间,更没有匿名命名空间。所以,将 C 中名字声明为内部连接只有一种形式:用 static
修饰。
C 没有类的成员函数,也没有静态成员。这简化了我们后续的讨论。
试探性定义
在 C++ 中,这样的声明:
int a;
是变量 a
的定义,且是外部连接的。但在 C 中,问题变得复杂:C 称不带初始化器且不带 static
或 extern
的全局变量声明为试探性定义(Tentative definition)。
试探性定义的特点是它有时仅是声明,而有时又是定义。如同物理学中的量子,同时表现为波和粒子,仅仅取决于你观测的方式。
当试探性定义与一个同名的“正常定义”同时出现时,则试探性定义成为声明。如果始终没有同名的“正常定义”,则这些试探性定义中某一个成为定义,其余成为声明。
在标准中,上文“同时出现”的范围是同一个翻译单元内。但在某些实现(如 Linux 下的 ELF 格式)中,“同时出现”的范围可以是多个翻译单元。也就是说在这种实现下,若翻译单元 a 中含有试探性定义却不带“正常”定义,翻译单元 b 中带“正常定义”,在链接时会将 a 中的试探性定义视为声明。
在上述 ELF 实现下,称“正常定义”的符号为强符号,试探性定义的符号为弱符号。在链接时,强符号只能出现一次(对应单一定义原则);多个弱符号可伴随一个强符号同时链接(这些弱符号——试探性定义——退化为声明);没有强符号的多个弱符号中,挑选其中一个为定义。
比如一个试探性定义和一个“正常定义”同时出现时,试探性定义退化为声明:
int a; /* 试探性定义,退化为声明 */
int a = 0; /* “正常定义” */
这不违反单一定义原则。又比如多个试探性定义:
int a; /* 试探性定义 */
int a; /* 试探性定义 */
那么,编译器(或者链接器)会选择其中一个成为定义(零初始化或不初始化),而另一个随之退化为声明。这也不会违反单一定义原则。
名字重整与语言连接
在 C 中,由于没有函数重载,链接器在链接时只需函数名就可以找到正确的函数地址。
/* f.c */
void f(void) {
/* [...] */
}
/* main.c */
void f(void);
int main(void) {
f(); /* 链接时找到 f.o 中定义的 f */
}
但在 C++ 中,函数重载导致仅有函数名可能找不到正确的函数:
// f0.cpp
void f() {
// [...]
}
// f1.cpp
void f(int x) {
// [...]
}
// main.cpp
void f();
void f(int);
int main() {
f(); // 这个函数的定义在 f0.o
f(42); // 这个函数的定义在 f1.o
// 但链接器仅凭借 f 这个名字无法分辨哪个是哪个
}
所以,在 C++ 中,所有的函数在链接时需要经过额外的步骤:名字重整(Name mangling)。
名字重整做的事情很简单,就是编译的时候将重载函数的不同重载换一个名字。比如上例中,void f();
的名字换成 _Z1fv
,void f(int);
的名字换成 _Z1fi
;调用的时候,直接调用这些重整后的名字。比如刚刚的 main
函数编译为汇编是这样的:
main:
subq $8, %rsp
call _Z1fv # 对 f() 的调用
movl $42, %edi
call _Z1fi # 对 f(int) 的调用
movl $0, %eax
addq $8, %rsp
ret
重整之后,链接器就能分辨两个函数了;就可以到对象文件找对应的定义了。
一切看上去十分美好,但问题出现在混合编程上。有写库是 C 编写的,它们的对象文件中是 f
这个符号名;然而我自己的代码是 C++ 编写的,它期望一个名字叫 _Z1fv
函数以调用。那如果不加任何改动地将 C 库和 C++ 代码链接,就会给出“符号未定义”的错误。
解决这个问题的办法就是提供语言链接(Language linkage)。语言链接的本意是:这些声明,不是用 C++ 定义的,而是用其它编程语言定义的。它的语法是:
extern 编程语言名 { 声明序列 }
标准规定了 编程语言名
可以是 "C++"
或者 "C"
。extern "C" { 声明 }
就表示 声明
的定义是用 C 语言编写的。链接器在遇到 extern "C"
的声明时不会进行名字重整,然后链接就能正常完成了。比如:
/* f.c */
/* 这个函数是 C 语言定义的,编译时没有名字重整 */
void f(void) {
/* [...] */
}
// main.cpp
// 告诉编译器,f 这个函数是 C 函数
extern "C" {
void f(void);
}
int main() {
f(); // 不会名字重整为 _Z1fv
}
一些 C 库在提供头文件时必须要考虑这一点(否则它们的库无法在 C++ 程序中使用)。常见的操作还会用到 __cplusplus
宏。这个宏只在 C++ 编译时提供,于是乎:
/* f.h */
/* 典型的 C/C++ 通用库头文件结构 */
#ifndef F_H
#define F_H
#ifdef __cplusplus
extern "C" {
#endif
void f(void);
/* [...] */
#ifdef __cplusplus
}
#endif
#endif /* F_H */
这样,在 C++ 编译时,头文件预处理会为其中的所有声明加上 extern "C"
修饰,从而保证不会发生名字重整而导致的链接失败。