C 中的链接

不同于 C++,C 在链接方面有一些语法是不同的,它们在一定程度上让问题更加复杂化。

显著区别

需要注意的 C 特性中,我提到了 C 里只读变量拥有外部连接而非内部连接。所以,在 C 中符号连接的问题变得更加简单:它总默认是外部连接的。

C 没有命名空间,更没有匿名命名空间。所以,将 C 中名字声明为内部连接只有一种形式:用 static 修饰。

C 没有类的成员函数,也没有静态成员。这简化了我们后续的讨论。

试探性定义

在 C++ 中,这样的声明:

int a;

是变量 a 的定义,且是外部连接的。但在 C 中,问题变得复杂:C 称不带初始化器且不带 staticextern 的全局变量声明为试探性定义(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(); 的名字换成 _Z1fvvoid 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" 修饰,从而保证不会发生名字重整而导致的链接失败。

最近更新:
代码未运行