专栏首页C/C++基础认识目标文件的符号

认识目标文件的符号

符号是链接的粘合剂,没有符号无法完成链接。每一个目标文件都会有一个相应的符号表(Symbol Table),表里记录了目标文件用到的所有符号。

1. 特殊符号

当我们使用 ld 作为链接器来链接生产可执行文件时,它会为我们定义很多特殊的符号,这些符号并没有在你的程序中定义,但是你可以直接声明并且引用它,我们称之为特殊符号。其实这些符号是被定义在 ld 链接器的链接脚本中的,我们无须定义它们,但可以声明它们并使用。链接器会在将程序最终链接成可执行文件的时候将其解析成正确的值,注意,只有使用 ld 链接生产最终可执行文件的时候这些符号才会存在。几个很具有代表性的特殊符号如下,其他的特殊符号,在此不一一列举了。

__executable_start			//该符号为程序起始地址,注意,不是入口地址,是程序的最开始的地址
__etext 或 _etext 或 etext	//该符号为代码段结束地址,即代码段最末尾的地址
_edata 或 edata				//该符号为数据段结束地址,即数据段最末尾的地址
_end 或 end					//该符号为程序结束地址

以上地址都为程序被装载时的虚拟地址。我们可以在程序中直接使用这些符号。

//
//@file:SpecialSymbol.c
//

#include <stdio.h>

extern char __executable_start[];
extern char etext[], _etext[], __etext[];
extern char edata[], _edata[];
extern char end[], _end[];

int main()
{
        printf("Executable Start %X\n", __executable_start);
        printf("Text End %X %X %X\n", etext, _etext, __etext);
        printf("Data End %X %X\n", edata, _edata);
        printf("Executable End %X %X\n", end, _end);
        return 0;
}

编译输出结果:

gcc SpecialSymbol.c
./a.out

Executable Start 400000
Text End 40061D 40061D 40061D
Data End 601034 601034
Executable End 601038 601038

2. 符号修饰与函数签名

约在20世纪70年代以前,编译器编译源代码产生目标文件时,符号名与相应的变量和函数的名字是一样的。比如汇编源代码包含了一个函数foo,那么汇编器编译成目标文件以后,foo在目标文件中对应的符号名也是foo。在后来 UNIX 和 C 被发明后,当 C 程序使用汇编语言编写的库和目标文件时,不可以使用这些库中定义的函数和变量的名字作为符号名,否则产生冲突。为了防止符号名冲突,UNIX 下的C语言就规定,C语言源代码文件中的所有全局的变量和函数经过编译以后,相对应的符号名前加上下划线 _。而 Fortran 语言的源代码经过编译以后,所有的符号名前加上 _,后面也加上 _。比如一个C语言函数 foo,那么它编译后的符号名就是 _foo;如果是 Fortran,就是 _foo_

这种简单而原始的方法暂时减少多种语言目标文件之间的符号冲突的概率,但还是没有从根本上解决符号冲突的问题。比如同一种语言编写的目标文件很有可能也会产生符号冲突,比如当程序很大时,不同的模块由多个部门(个人)开发,命名如果不够规范,则可能导致冲突。于是像后来的 C++ 语言考虑到了这个问题,增加了名称空间(Namespace)来解决多模块的符号冲突问题。

随着时间的推移,很多操作系统和编译器被完全重写了好几遍,比如 UNIX 也分化成了很多种,整个环境发生了很大的变化,上面所提到的跟 Fortran 和古老的汇编库的符号冲突问题已经不是那么明显了。在现在的 Linux 下的 GCC 编译器中,默认情况下已经去掉了在C语言符号前加 _ 的这种方式;但是 Windows 平台下的编译器还保持的这样的传统,比如 Visual C++ 编译器就会在 C 语言符号前加 _,GCC 在 Windows 平台下的版本(cygwin、mingw)也会加 _。GCC 编译器也可以通过参数选项 -fleading-underscore-fno-leading-underscore 来打开和关闭是否在 C 语言符号前加上下划线。

下面以 C++ 为例,看一下现代高级编程语言对符号的修饰方法。

C++ 支持函数重载,两个相同名字的函数 func(int)func(double) 编译时并不会报重定义错误。为了支持函数重载,C++ 使用了符号修饰(Name Decoration)或符号改编(Name Mangling)的机制。另外 C++ 还在语言级别支持名称空间,即允许在不同的名称空间有多个同样名字的符号。考察如下代码:

//
//@file:test.cpp
//

int func(int);
float func(float);

class C {
	int func(int);
	class C2 {
		int func(int);
	};
};

namespace N {
	int func(int);
	class C {
		int func(int);
	};
}

这段代码中有 6 个同名函数 func,只不过它们的返回类型和参数及所在的名称空间不同。我们引入一个术语叫做函数签名(Function Signature),函数签名能够唯一表示程序中的函数,相当于函数的 ID,由函数名、参数类型、它所在的类和名称空间及其他信息组成。编译器在将 C++ 源代码编译成目标文件时,会将函数和变量的名字进行修饰,形成函数签名。由于上面6个同名函数的参数类型及所处的类和名称空间不同,C++ 对 函数名进行修饰后产生的函数签名不同,所以即使函数名相同,编译器和链接器都认为它们是不同的函数。上面的6个函数签名在 GCC 编译器下,相对应的修饰后名称如下:

函数原型

修饰后的函数签名

int func(int)

_Z4funci

float func(float)

_Z4funcf

int C::func(int)

_ZN1C4funcEi

int C::C2::func(int)

_ZN1C2C24funcEi

int N::func(int)

_ZN1N4funcEi

int N::C::func(int)

_ZN1N1C4funcEi

GCC 对 C++ 的名称基本修饰规则如下: (1)所有的符号都以"_Z"开头,对于嵌套的名字(在名字空间或在类里面的),后面紧跟"N"; (2)然后是各个名字空间和类的名字,每个名字前是名字字符串长度; (3)后面再跟函数或者变量的名称,名称前是名称的长度; (4)后面再跟 E ; (5)如果函数有参数,则函数类型紧跟在 E 后面,比如 i 表示 int,Pv 表示void*。 比如函数 N::C::func(int) 名称修饰结果为 _ZN1N1C4funcEi。更为具体的修饰方法这里不详细介绍,有兴趣的读者可以参考GCC的名称修饰标准。binutils里面提供了一个叫 c++filt 的工具可以用来解析被修饰过的名称,比如:

c++filt _ZN1N1C4funcEi
N::C::func(int)

对于全局变量来说,它跟函数一样都是一个全局可见的名称,它也遵循上面的名称修饰机制,比如一个名称空间foo中的全局变量bar,它修饰后的名字为:_ZN3foo3barE。值得注意的是,变量的类型并没有被加入到修饰后的名称中,所以一个变量不论是整型还是浮点型,它的名称都是一样的。

名称修饰机制也被用来防止静态变量的名字冲突。比如main()函数里面有一个静态变量叫foo,而func()函数里面也有一个静态变量叫foo。为了区分这两个变量,GCC会将它们的符号名分别修饰成两个不同的名字 _ZZ4mainE3foo_ZZ4funcvE3foo,这样就区分了这两个变量。

不同的编译器厂商的名称修饰方法可能不同,所以不同的编译器对于同一个函数签名可能对应不同的修饰后名称。比如上面的函数签名中在Visual C++编译器下,它们的修饰后名称如下表:

函数原型

修饰后的函数签名

int func(int)

?func@@YAHH@Z

float func(float)

?func@@YAMM@Z

int C::func(int)

?func@C@@AAEHH@Z

int C::C2::func(int)

?func@C2@C@@AAEHH@Z

int N::func(int)

?func@N@@YAHH@Z

int N::C::func(int)

?func@C@N@@AAEHH@Z

我们以 int N::C::func(int) 这个函数可以大致推断 Visual C++ 的名称修饰规则。 (1)修饰后名字由 ? 开头,接着是函数名; (2)后面跟着由@开头的类名C和@开头的名字空间N; (3)再后面的@@AAE表示函数是类的私有成员函数; (4)接着是函数的参数类型及返回值; (5)最后由 @ Z 结束。 可以看到名称空间、类型、函数名、参数和返回值的类型都被加入了修饰后的名称,这样编译器和链接器就可以区别同名但不同参数类型或名字空间的函数,而不会导致链接时候函数多重定义。

Visual C++ 的名称修饰规则并没有对外公开,当然,一般情况下我们也无须了解这套规则,但是有时候可能须要将一个修饰后名字转换成函数签名,比如在链接、调试程序的时候可能会用到。Microsoft提供了一个 UnDecorateSymbolName() 的 API,可以将修饰后名称转换成函数签名。

3. extern “C”

由于 C++ 与 C 的符号修饰规则不同,C++ 为了与 C 兼容,在符号的管理上,C++ 使用 extern "C" 来声明或定义一个C的符号。用法如下:

extern "C" int func(int);
extern "C" int var;

//或
extern "C" {
	int func(int);
	int var;
}

C++编译器会将使用 extern “C” 修饰的代码当作C语言代码来处理。所以很明显,上面的代码中,C++的名称修饰机制将不会起作用。

由于 GCC 对 C 的变量和函数没有修饰,对 C++ 的变量进行了修饰,下面看一个使用未经修饰的 C 的符号来访问C++的变量的例子。、

//
//@file: ManualNameMangling.cpp
//

#include <stdio.h>
namespace myname {
	int var = 42;
}

extern "C" int _ZN6myname3varE;
int main()
{
	printf( "%d\n", _ZN6myname3varE );
	return 0;
}

编译输出结果如下:

g++ ManualNameMangling.cpp
./a.out
42

其中 _ZN6myname3varE 为 C++ 变量 var 修饰后的名称。

很多时候我们会碰到有些头文件声明了一些 C 语言的函数和全局变量,这个头文件既可以被 C 代码包含,也可以被 C++ 代码包含。比如 C 语言库函数中的 string.h 中声明的 memset 函数,其原型如下:

void* memset(void *, int, size_t);

如果不加任何处理,C 语言程序包含 string.h 的时候,并且用到了 memset 函数,编译器能够正确引用。但是在C++语言中,编译器会认为这个 memset 函数是一个 C++ 函数,将 memset 的符号修饰成 _Z6memsetPvii,这样链接器就无法与 C 语言库中的 memset 符号进行链接。所以对于 C++ 来说,必须使用 extern “C” 来声明 memset 函数。但是 C 语言又不支持 extern “C” 语法,如果为了兼容 C 语言和 C++ 语言定义两套头文件,未免过于麻烦。幸好我们有一种很好的方法可以解决上述问题,就是使用 C++ 的宏 “__cplusplus”,C++ 编译器会在编译 C++ 的程序时默认定义这个宏,我们可以使用条件宏来判断当前编译单元是不是 C++ 代码。实现如下:

#ifdef __cplusplus
extern "C" {
#endif

void *memset (void *, int, size_t);
#ifdef __cplusplus
}
#endif

如果当前编译单元是 C++ 代码,那么 memset 会在 extern “C” 里面被声明;如果是 C 代码,就直接声明。上面这段代码中使用条件预处理指令的技巧几乎在所有的系统头文件里面都被用到。

4.弱符号与强符号

在编程中我们经常碰到一种编译错误叫符号重复定义。多个目标文件中含有相同名字全局符号的定义,那么这些目标文件链接的时候将会出现符号重复定义的错误。比如目标文件 A 和 B 都定义了一个初始化后的全局整型变量 global,那么链接器将 A 和 B 进行链接时会报错:

b.o:(.data+0x0): multiple definition of `global'
a.o:(.data+0x0): first defined here

这种符号的定义可以被称为强符号(Strong Symbol)。有些符号的定义可以被称为弱符号(Weak Symbol)。对于 C/C++ 语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。我们也可以通过GCC的 __attribute__((weak)) 将强符号修饰为弱符号。注意,强符号和弱符号都是针对定义,不是针对符号的引用。比如下面这段程序:

extern int ext;
int weak;
int strong = 1;
__attribute__((weak)) int weak2 = 2;

int main()
{
	return 0;
}

上面这段程序中,"weak"和"weak2"是弱符号,"strong"和"main"是强符号,而"ext"既非强符号也非弱符号,因为它是一个外部变量的引用。针对强弱符号的概念,链接器就会按如下规则选择被多次定义的全局符号:

规则1:不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号);如果有多个强符号定义,则链接器报符号重复定义错误。

规则2:如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。

规则3:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。比如目标文件A定义全局变量 global 为 int 型,占4个字节;目标文件B定义 global 为 double 型,占8个字节,那么目标文件 A 和 B 链接后,符号 global 占8个字节(尽量不要使用多个不同类型的弱符号,否则容易导致很难发现的程序错误)。

目前我们所看到的对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们须要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用(Strong Reference)。与之相对应还有一种弱引用(Weak Reference),在处理弱引用时,如果该符号有定义,则链接器将该符号的引用决议;如果该符号未被定义,则链接器对于该引用不报错。链接器处理强引用和弱引用的过程几乎一样,只是对于未定义的弱引用,链接器不认为它是一个错误。一般对于未定义的弱引用,链接器默认其为0,或者是一个特殊的值,以便于程序代码能够识别。弱引用和弱符号主要用于库的链接过程,弱符号跟链接器的COMMON块概念联系很紧密。

在GCC中,我们可以通过使用 __attribute__((weakref)) 这个扩展关键字来声明对一个外部函数的引用为弱引用,比如下面这段代码:

__attribute__ ((weakref)) void foo();
int main()
{
	foo();
}

我们可以将它编译成一个可执行文件,GCC并不会报链接错误。但是当我们运行这个可执行文件时,会发生运行错误。因为当main函数试图调用foo函数时,foo函数的地址为0,于是发生了非法地址访问的错误。一个改进的例子是:

__attribute__ ((weakref)) void foo();
int main()
{
	if(foo) foo();
}

这种弱符号和弱引用对于库来说十分有用,比如库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数;或者程序可以对某些扩展功能模块的引用定义为弱引用,当我们将扩展模块与程序链接在一起时,功能模块就可以正常使用;如果我们去掉了某些功能模块,那么程序也可以正常链接,只是缺少了相应的功能,这使得程序的功能更加容易裁剪和组合。

在Linux程序的设计中,如果一个程序被设计成可以支持单线程或多线程的模式,就可以通过弱引用的方法来判断当前的程序是链接到了单线程的Glibc库还是多线程的Glibc库(是否在编译时有-lpthread选项),从而执行单线程版本的程序或多线程版本的程序。我们可以在程序中定义一个pthread_create函数的弱引用,然后程序在运行时动态判断是否链接到pthread库从而决定执行多线程版本还是单线程版本:

#include <stdio.h>
#include <pthread.h>

int pthread_create( pthread_t*, const pthread_attr_t*, void* (*)(void*), void*) __attribute__ ((weak));
int main()
{
	if(pthread_create) {
		printf("This is multi-thread version!\n");
		// run the multi-thread version
		// main_multi_thread()
	} else {
		printf("This is single-thread version!\n");    
		// run the single-thread version
		// main_single_thread()
	}
}

编译运行结果如下:

gcc pthread.c -o pt
./pt
This is single-thread version!

gcc pthread.c -lpthread -o pt
./pt
This is multi-thread version!

参考文献

[1]俞甲子,石凡,等.程序员的自我修养——链接、装载与库[M].北京:电子工业出版社,2009-04.C3.5链接的接口——符号.P85-94

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • Linux命令(63)——nm令

    nm命令是GNU Binutils二进制工具集的一员,用于显示目标文件中的符号。如果没有为nm命令指出目标文件,则nm假定目标文件是a.out。

    Dabelv
  • C++11新特性——range for

    很多编程语言都有range for语法功能,自C++11起,终于将这个重要功能加入C++标准中。range for语句,可以方便的遍历给定序列中的每个元素并对其...

    Dabelv
  • Linux 命令(74)—— top 命令

    版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 ...

    Dabelv
  • 交换二维数组

    小锋学长
  • 1020 孪生蜘蛛

    1020 孪生蜘蛛 时间限制: 1 s 空间限制: 128000 KB 题目等级 : 黄金 Gold 题目描述 Description 在G城保卫...

    attack
  • 1079 回家

    1079 回家 时间限制: 1 s 空间限制: 128000 KB 题目等级 : 白银 Silver 题目描述 Description 现在是晚餐...

    attack
  • 1722 最优乘车 未完成

    1722 最优乘车 1997年NOI全国竞赛  时间限制: 1 s  空间限制: 128000 KB  题目等级 : 大师 Master 题解 题目描述 Des...

    attack
  • 1019 集合论与图论

    1019 集合论与图论 时间限制: 1 s 空间限制: 128000 KB 题目等级 : 黄金 Gold 题目描述 Description    ...

    attack
  • LeetCode-52-N-Queens-II

    只返回N皇后问题结果的种数。 因此不需要每一个字符串置位了,只需要判断一个位置的横竖,斜45度和斜135度方向的值即可。依然采用递归的方式,这里需要注意的是,由...

    小二三不乌
  • 百炼1005

    用户7727433

扫码关注云+社区

领取腾讯云代金券