符号管理

1.符号的作用

链接的接口:在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。比如目标文件B要用到了目标文件A中的函数“foo”,那么我们就称目标文件A定义(Define)了函数“foo”,称目标文件B引用(Reference)了目标文件A中的函数“foo”。这两个概念也同样适用于变量。每个函数或变量都有自己独特的名字,才能避免链接过程中不同变量和函数之间的混淆。在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)。

我们可以将符号看作是链接中的粘合剂,整个链接过程正是基于符号才能够正确完成。链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表(SymbolTable),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。

2.符号的分类

(1)定义在本目标文件的全局符号,可以被其他目标文件引用。

(2)在本目标文件中引用的全局符号。

(3)段名,这种符号往往由编译器产生,它的值就是该段的起始地址。

(4)局部符号,这类符号只在编译单元内部可见。比如局部的静态变量

(5)行号信息,即目标文件指令与源代码中代码行的对应关系,它也是可选的。

3.符号表的结构

wps213.tmp

wps252.tmp

wps2A1.tmp

wps2C1.tmp

符号值(st_value) 我们前面已经介绍过,每个符号都有一个对应的值,如果这个符号是一个函数或变量的定义,那么符号的值就是这个函数或变量的地址,更准确地讲应该按下面这几种情况区别对待。

(1)在目标文件中,如果是符号的定义并且该符号不是“COMMON块”类型的,则st_value表示该符号在段中的偏移。即符号所对应的函数或变量位于由st_shndx指定的段,偏移st_value的位置。这也是目标文件中定义全局变量的符号的最常见情况,比如SimpleSection.o中的“func1”、“main”和“global_init_var”。

在目标文件中,如果符号是“COMMON块”类型的(即st_shndx为SHN_COMMON),则st_value表示该符号的对齐属性。比如SimpleSection.o中的“global_uninit_var”。

(2)在可执行文件中,st_value表示符号的虚拟地址。

我们以SimpleSection.o程序符号表分析:

wps2F1.tmp

wps311.tmp

wps360.tmp

(1)第一列Num表示符号表数组的下标,从0开始,共15个符号;

(2)第二列Value就是符号值,即st_value;

(3)第三列Size为符号大小,即st_size;

(4)第四列Type为符号类型

(5)第五列Bind为绑定信息

(6)第七列Ndx为该符号所属的段

(7)第8类为符号名

符号分析:

(1)func1和main函数都是定义在SimpleSection.c里面的,它们所在的位置都为代码段,所以Ndx为1,即SimpleSection.o里面,.text段的下标为1。这一点可以通过readelf –a或objdump –x得到验证。它们是函数,所以类型是STT_FUNC;它们是全局可见的,所以是STB_GLOBAL;Size表示函数指令所占的字节数;Value表示函数相对于代码段起始位置的偏移量。

(2)printf这个符号,该符号在SimpleSection.c里面被引用,但是没有被定义。所以它的Ndx是SHN_UNDEF。

(3)global_init_var是已初始化的全局变量,它被定义在.bss段,即下标为3。

(4)global_uninit_var是未初始化的全局变量,它是一个SHN_COMMON类型的符号,它本身并没有存在于BSS段;关于未初始化的全局变量具体请参见“COMMON块”

(5)static_var.1533和static_var2.1534是两个静态变量,它们的绑定属性是STB_LOCAL,即只是编译单元内部可见。

(6)对于那些STT_SECTION类型的符号,它们表示下标为Ndx的段的段名。它们的符号名没有显示,其实它们的符号名即它们的段名。

4特殊符号

这些符号并没有在你的程序中定义,但是你可以直接声明并且引用它,我们称之为特殊符号。其实这些符号是被定义在ld链接器的链接脚本中几个很具有代表性的特殊符号如下。

__executable_start,该符号为程序起始地址,注意,不是入口地址,是程序的最开始的地址。

__etext或_etext或etext,该符号为代码段结束地址,即代码段最末尾的地址。

_edata或edata,该符号为数据段结束地址,即数据段最末尾的地址。

_end或end,该符号为程序结束地址。以上地址都为程序被装载时的虚拟地址。

5符号修饰与函数签名

约在20世纪70年代以前,编译器编译源代码产生目标文件时,符号名与相应的变量和函数的名字是一样的。比如一个汇编源代码里面包含了一个函数foo,那么汇编器将它编译成目标文件以后,foo在目标文件中的相对应的符号名也是foo。当后来UNIX平台和C语言发明时,已经存在了相当多的使用汇编编写的库和目标文件。这样就产生了一个问题,那就是如果一个C程序要使用这些库的话,C语言中不可以使用这些库中定义的函数和变量的名字作为符号名,否

则将会跟现有的目标文件冲突。比如有个用汇编编写的库中定义了一个函数叫做main,那么我们在C语言里面就不可以再定义一个main函数或变量了。同样的道理,如果一个C语言的目标文件要用到一个使用Fortran语言编写的目标文件,我们也必须防止它们的名称冲突。为了防止类似的符号名冲突,UNIX下的C语言就规定,C语言源代码文件中的所有全局的变量和函数经过编译以后,相对应的符号名前加上下划线“_”。而Fortran语言的源代码经过编译以

后,所有的符号名前加上“_”,后面也加上“_”。比如一个C语言函数“foo”,那么它编译后的符号名就是“_foo”;如果是Fortran语言,就是“_foo_”。这种简单而原始的方法的确能够暂时减少多种语言目标文件之间的符号冲突的概率,但还是没有从根本上解决符号冲突的问题。比如同一种语言编写的目标文件还有可能会产生符号冲突,当程序很大时,不同的模块由多个部门(个人)开发,它们之间的命名规范如果不严格,则有可能导致冲突。于是像C++这样的后来设计的语言开始考虑到了这个问题,增加了名称空间(Namespace)的方法来解决多模块的符号冲突问题。

(1)C++对于函数的修饰

wps3A0.tmp

wps3D0.tmp

这段代码中有6个同名函数叫func,只不过它们的返回类型和参数及所在的名称空间不同。我们引入一个术语叫做函数签名(Function Signature),函数签名包含了一个函数的信息,包括函数名、它的参数类型、它所在的类和名称空间及其他信息。函数签名用于识别不同的函数,就像签名用于识别不同的人一样,函数的名字只是函数签名的一部分。

wps3F0.tmp

GCC的基本C++名称修饰方法如下:所有的符号都以“_Z”开头,对于嵌套的名字(在名称空间或在类里面的),后面紧跟“N”,然后是各个名称空间和类的名字,每个名字前是名字字符串长度,再以“E”结尾。比如N::C::func经过名称修饰以后就是_ZN1N1C4funcE。对于一个函数来说,它的参数列表紧跟在“E”后面,对于int类型来说,就是字母“i”。所以整个N::C::func(int)函数签名经过修饰为_ZN1N1C4funcEi。

(2)C++对于全局变量的修饰

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

(3)不同的编译器厂商,修饰方法不同

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

wps410.tmp

6.强符号和弱符号

对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。

wps430.tmp

上面这段程序中,“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,或者是一个特殊的值,以便于程序代码能够识别。

7符号解析

在多数目标格式中,重定位项标识了程序中对符号的引用。在最简单的情况下,即链接器使用绝对地址来创建输出文件(如UNIX链接器中的数据引用),解析仅仅是用符号地址来替换符号的引用。如果符号被解析到地址20486处,则链接器会将相应的引用替换为20486。

实际情况要复杂得多。诸如,引用一个符号就有很多种方法,通过数据指针,嵌入到指令中,甚至通过多条指令组合而成。此外,链接器生成的输出文件本身经常还是可以再次链接的。这就是说,如果一个符号被解析为数据区段中的偏移量426,那么在输出中引用该符号的地方要被替换为可重定位引用的[数据段基址+426]。输出文件通常也拥有自己的符号表,因此链接器还要新创建一个在输出文件中符号的索引向量,然后将输出重定位项中的符号编号映射到这些新的索引中。

8维护调试信息

(1)行号信息

所有基于符号的调试器都必须将程序地址和源代码行号对应起来。这样就可以通过调试器将断点放入代码的适当位置来实现用户基于源代码行号的断点设置,并可以让调试器将调用堆栈中的程序地址和错误报告中的源代码行号关联起来。

除优化编译代码外,行号信息是很简单的。优化编译的代码中会去除一些代码,导致目标文件中的代码序列与源代码行号的序列不匹配。对于编译器生成代码所对应源代码文件中的每一行语句,编译器会产生一个行号项(包括行号和代码开始位置)。如果一个程序地址跨越了两个行号项,调试器会将两个行号中较小的报告出来。行号还需要被文件名称(包括源文件名称和头文件名称)限定。有一些格式会通过创建一个文件列表并将文件索引放入每一个行号项中来实现这一点,行号列表中的“begin include”和“end include”项,内在的维护了有行号成员组成的栈。当编译器优化根据语句生成不连续的代码时,一些目标格式(DWARF)让编译器将每一个字节都映射回源代码中的一行,这会占用进程的大量空间,而其它格式则仅仅产生一个大概的位置。

(2)符号和变量信息

编译器还要为每一个程序变量生成名称、类型和位置。调试符号信息某种程度上要比名称修改更为复杂,因为它不仅要对类型名称编码,还有定义类型时的数据结构类型,这样才能保证调试器能够正确处理一个数据结构中的所有子域的格式。

符号信息可以是一个隐式或显式的树结构。每个文件的最顶层是在最顶层定义的类型、变量和函数的列表,每一个内部是数据结构的子域,或函数内部定义的变量,诸如此类。在函数内部,包含“begin block”和“end block”的树标识了对行号的引用,这样调试器就可以指出程序中每一个变量的范围了。

符号信息中最有趣的部分是位置信息。静态变量的位置不会改变,但一个例程中的局部变量可能是静态的,可能在栈里、在寄存器里、在优化后的代码里,在例程的不同部分可能会从一个地方移动到另一个地方。在多数体系结构上,标准的例程调用序列会为每一个嵌套的例程维护保存堆栈和框指针(frame pointer)的链,每个例程中的局部栈变量存放在相对于框指针的已知偏移量处。在叶子例程或者没有分配局部栈变量的例程中,有一个通常使用的优化就是跳过对框指针的设置。为了正确解释栈的调用轨迹并在没有框指针的例程中寻找局部变量,调试器就必须清楚这些。Codeview通过一个由没有框指针的例程组成的队列来做到这一点。

9总结

记录了一个函数使用的符号以及对外提供的符号,是记录了每个符号的地址。当进行链接的时候,要根据符号的地址,对符号的地址进行修正。