目标文件和可执行文件(三)

一、链接的接口-符号

        链接过程的本质是将多个不同的目标文件组合在一起,使它们能够相互引用和共享代码和数据。这就涉及到符号的定义和引用。在链接过程中,目标文件中的函数和变量被视为符号,每个符号都有一个唯一的符号名(Symbol Name)。当一个目标文件引用另一个目标文件中的符号时,我们说目标文件引用了该符号(Symbol Reference),同时被引l用的目标文件中定义了该符号(Symbol Definition)。目标文件之间对地址的引用,即对函数和变量地址的引用。

        在链接过程中,符号起着关键的作用,它们就像粘合剂一样将不同的目标文件连接在一起。每个目标文件都有自己的符号表,其中记录了该目标文件所用到的所有符号,包括函数、变量以及其他可能出现的符号类型。

        符号表中的每个符号都有一个符号值(Symbol Value),对于变量和函数来说,这个符号值就是它们在内存中的地址。符号表中的符号还有其他属性,例如符号的大小、类型等,这些属性在链接过程中起着重要的作用。

        我们可以把符号视为链接中的胶合剂,正是基于符号,整个链接过程才能正确完成。在此过程中,符号的管理是一个关键部分。每个目标文件都有一个对应的符号表(Symbol Table),这个表记录了目标文件中所有使用到的符号。每个定义的符号都有一个对应的值,称为符号值(Symbol Value)。对于变量和函数而言,符号值就是它们的地址。除了函数和变量,还有其他几种不常用到的符号。

我们可以把符号表中的所有符号进行分类,它们可能是以下类型之一:

  1. 在本目标文件中定义的全局符号,其他目标文件可以使用。例如SimpleSection.o中的"funcl"、“main” 和 "global_init_var"。
  2. 本目标文件中引用的全局符号,但并未在本文件中定义,这种被称为外部符号(External Symbol)。就像我们之前谈到的符号引用。例如SimpleSection.o中的"printf"。
  3. 段名,这种符号通常由编译器生成,其值就是这段的起始地址。例如SimpleSection.o中的".text"、".data"等。
  4. 其他编译单元内部可见的局部符号,如 SimpleSection.o 里的“static_.var”和“static_var2”。调试器可以使用这些符号来分析程序,或者当程序崩溃时分析核心转储文件。这些局部符号对链接过程没有作用,链接器通常也会忽略它们。
  5. 行号信息,即目标文件指令与源代码中代码行的对应关系,这是可选的。
nm SimpleSection.o

我们可以使用nm查看SimpleSection.o的符号结果。

      1. ELF符号表结构  

        ELF文件中的符号表通常是一个名为“.symtab”的段,它是一个由Elf32_Sym结构(对于32位ELF文件)组成的数组。每个Elf32_Sym结构对应一个符号。这个数组的第一个元素(下标为0的元素)通常表示一个无效的“未定义”符号。

typedef struct {
    Elf32_Word st_name;   // 符号的名称在字符串表中的偏移量
    Elf32_Addr st_value;  // 符号的值,通常是地址
    Elf32_Word st_size;   // 符号的大小
    unsigned char st_info;    // 符号类型和绑定信息
    unsigned char st_other;   // 保留字段
    Elf32_Half st_shndx;  // 符号关联的节索引
} Elf32_Sym;
  • st_name: 符号的名称在字符串表中的偏移量,通过这个偏移量可以找到符号的名称。
  • st_value: 符号的值,通常是一个地址,表示该符号所在的内存地址或偏移量
  • st_size: 符号的大小,表示该符号占据的字节数。
  • st_info: 包含了符号的类型和绑定信息。具体含义由宏定义进行解析。
  • st_other: 保留字段,暂未使用,通常设置为0。
  • st_shndx: 符号关联的节索引,指示该符号属于哪个节(section)。
<1>  符号类型和绑定信息(st_info)

        在ELF文件中,st_info字段用一个字节来表示符号的类型(Symbol Type)和绑定信息(Symbol Binding)。一般来说,低4位表示符号的类型,高28位表示符号的绑定信息。符号类型指明符号是一个函数、变量、还是其他类型的符号;而符号绑定信息则指明符号是全局可见的,还是局部的。

<2>  符号所在段(st_shndx)

    st_shndx字段是一个16位的值,在ELF文件中用来表示符号所在的段在段表中的下标。对于符号定义在本目标文件中的情况,st_shndx是该符号所在的段在段表中的索引位置

<3>  符号值(st_value)

        每个符号都有一个对应的值,如果这个符号是一个函数或变量的定义,那么符号的值就是这个函数或变量的地址,可以按以下几种情况对待:

在目标文件中:

  • 如果符号是一个函数或变量的定义,并且该符号不是"COMMON块"类型的(即st_shndx不为SHN_COMMON),那么 st_value 表示该符号在所在段中的偏移量。这意味着该符号对应的函数或变量位于由 st_shndx 指定的段,偏移 st_value
  • 如果符号是"COMMON块"类型的(即 st_shndxSHN_COMMON),那么 st_value 表示该符号的对齐属性,而不是具体的偏移量。这种情况通常用于未初始化的全局变量。

在可执行文件中:

  • st_value 表示符号的虚拟地址(Virtual Address)。在可执行文件中,符号的虚拟地址用于动态链接器(dynamic linker),在动态链接过程中,将符号地址映射到加载到内存中的位置。这是为了使得可执行文件能够正确在内存中运行。

        总结起来,符号值 st_value 在目标文件中表示偏移量或对齐属性,而在可执行文件中表示虚拟地址。这些信息对于链接和加载过程以及动态链接器的工作至关重要,确保程序正确地在内存中执行,并能正确地访问所需的函数和变量。

        我们可以分析各个符号在符号表中的状态。这里使用readelf工具来查看ELF文件的符号,虽然objdump工具也可以达到同样的目的

readelf -s SimpleSection.o

        readelf的输出格式与上面描述的Elf32_Sym的各个成员几乎一一对应,第一列Num表示符号表数组的下标,从0开始,共l6个符号;第二列Value就是符号值,即st_value:第三列Size为符号大小,即st_size:第四列和第五列分别为符号类型和绑定信息,即对应st_info的低4位和高28位:第六列Vs目前在C/C++语言中未使用,我们可以暂时忽略它:第七列Ndx即st_shndx,表示该符号所属的段:当然最后一列也最明显,即符号名称。从上面的输出可以看到,第一个符号,即下标为0的符号,永远是一个未定义的符号。对于另外几个符号解释如下。

  1. funclmain函数是定义在SimpleSection.c中的,它们都位于代码段(.text)。因为是函数,所以符号类型为STT_FUNC,它们是全局可见的,所以符号绑定信息为STB_GLOBALSize字段表示函数指令所占的字节数,而Value字段表示函数相对于代码段起始位置的偏移量。

  2. printf是在SimpleSection.c中被引用但没有被定义的符号,所以它的NdxSHN_UNDEF,表示未定义的符号。

  3. global_init_var是已初始化的全局变量,它被定义在.bss段,即下标为3的段。global_uninit_var是未初始化的全局变量,它是一个SHN_COMMON类型的符号,它本身并没有存在于.bss段。

  4. static_.var.l533static_.var2.1534是两个静态变量,它们的绑定属性是STB_LOCAL,即只在编译单元内部可见。它们的变量名被修饰成static_var.1533static_var2.1534,这种符号修饰在后续的章节中会详细介绍。

  5. 对于STT_SECTION类型的符号,它们表示下标为N的段的段名。这些符号的符号名没有显示,实际上它们的符号名即它们的段名。比如2号符号的Ndx为1,表示.text段的段名,该符号的符号名应该就是.text

  6. SimpleSection.c这个符号表示编译单元的源文件名。

2. 特殊符号

        特殊符号是在链接器的链接脚本中定义的,它们在链接过程中起到重要的作用。这些特殊符号是在链接器中预定义的,通常由链接器根据链接脚本中的定义和规则进行解析和处理。这些符号对于程序的链接和加载过程非常关键。

  1. _start:这是程序的入口点,在执行可执行文件时,系统会从_start符号处开始执行程序。它是链接器的默认入口符号,但不是入口地址。

  2. __bss_start__bss_end:这些符号表示未初始化数据段(.bss)的开始和结束地址。在程序加载时,未初始化的全局变量和静态变量将被初始化为0。

  3. _edata:这个符号表示数据段(.data)的结束地址,也就是数据段中最后一个已初始化的全局变量或静态变量的结束地址。

  4. _end:这个符号表示程序的结束地址,也就是可执行文件的末尾。它的值通常是整个程序的大小。

  5. __libc_start_main:这个符号表示C语言库的启动入口点,在C语言程序中,C库的启动代码将在这里执行。

  6. __stack_chk_fail:这是用于栈溢出检测的特殊符号,在程序发生栈溢出时,系统会调用这个函数。

  7. __gmon_start__:这个符号用于性能分析,例如使用gprof工具进行程序性能分析时会用到。

        需要注意的是,这些特殊符号只有在使用链接器来将程序最终链接成可执行文件时才会存在,并且它们在链接脚本中有特定的定义和处理规则。在编写代码时,您无需显式定义这些特殊符号,但是您可以声明并使用它们。链接器会根据链接脚本的设置为这些符号分配正确的值。

        这些特殊符号是链接器链接过程中的关键组成部分,确保程序正确地启动和执行。

3. 符号修饰与函数签名

        在早期的编译器和操作系统中,由于存在多种编程语言以及使用汇编编写的库和目标文件,会导致符号名冲突的问题。为了减少这种冲突,UNIX下的C语言和Fortran语言采用了一种简单的方式来处理符号名:

  1. C语言:C语言源代码中的所有全局变量和函数在编译后,相对应的符号名前加上下划线“_”。例如,一个C语言函数 "foo" 在编译后的符号名为 "_foo"。

  2. Fortran语言:Fortran语言的源代码经过编译后,所有符号名前加上下划线 "_ ",并在后面再加上一个下划线 "_ "。例如,一个Fortran语言函数 "foo" 在编译后的符号名为 "_foo _"

        这种方式在某种程度上减少了不同编程语言之间的符号冲突,但并没有从根本上解决所有可能的符号冲突问题。对于同一种语言编写的目标文件,如果不同模块之间的命名规范不严格,仍然有可能导致冲突。随着时间的推移和编译器技术的进步,后来设计的语言(如C++)开始引入名称空间(Namespace)的概念来解决多模块的符号冲突问题。名称空间允许开发人员在不同的命名空间中定义符号,以避免全局符号冲突。在现代的编译器和操作系统中,对于C语言,通常不再在符号名前加上下划线,而是直接使用原始的符号名。这使得C语言更加符合现代编程环境的标准,并减少了与其他语言以及库的集成时的一些问题。然而,对于特定的操作系统和编译器,可能仍然会保留早期的命名约定。例如,Windows平台下的编译器(如Visual C++编译器)可能仍然在C语言符号前加上下划线。GCC编译器在Windows平台下的版本(如Cygwin、MinGW)也可能会在C语言符号前加下划线。为了控制是否在C语言符号前加下划线,GCC编译器可以使用参数选项“-fleading-underscore”或“-fno-leading-underscore”来打开或关闭这种行为。总的来说,随着编译器技术的不断发展,符号名冲突问题已经得到了一定程度的解决,而现代编译器更加灵活和智能,可以更好地处理不同编程语言和库之间的符号集成。在C++中,函数重载和命名空间的支持使得相同函数名或符号名可以对应于不同的参数类型或位于不同的名称空间中。这增加了符号管理的复杂性。为了区分这些不同的函数,C++引入了符号修饰(Name Decoration)或符号改编(Name Mangling)的机制。符号修饰是一种编译器生成特定的符号名的过程,它通过在函数名或变量名中添加一些额外的信息来区分不同的函数和符号。这些额外的信息通常基于参数类型、返回类型、以及名称空间等。每个编译器都有自己的符号修饰规则,因此不同编译器生成的符号名可能不同。

        首先出现的一个问题是C++允许多个不同参数类型的函数拥有一样的名字,就是所谓的函数重载:另外C++还在语言级别支持名称空间,即允许在不同的名称空间有多个同样名字的符号。

int func(int);
float func(float);
class C {
    int func(int);
    class C2{
        int fun(int);
    };
};
namespace N {
    int func(int);
    class c {
        int func(int);
    };
}

        这段代码中有6个同名函数叫fuc,只不过它们的返回类型和参数及所在的名称空间不同。我们引入一个术语叫做函数签名(Function Signature),函数签名包含了一个函数的信息,包括函数名、它的参数类型、它所在的类和名称空间及其他信息。函数签名用于识别不同的函数,就像签名用于识别不同的人一样,函数的名字只是函数签名的一部分。由于上面 6个同名函数的参数类型及所处的类和名称空间不同,我们可以认为它们的函数签名不同。在编译器及链接器处理符号时,它们使用某种名称修饰的方法,使得每个函数签名对应一个修饰后名称(Decorated Name)。编译器在将C++源代码编译成目标文件时,会将函数和变量的名字进行修饰,形成符号名,也就是说,C++的源代码编译后的目标文件中所使用的符号名是相应的函数和变量的修饰后名称。C++编译器和链接器都使用符号来识别和处理函数和变量,所以对于不同函数签名的函数,即使函数名相同,编译器和链接器都认为它们是不同的函数。GCC的基本C++名称修饰方法如下:所有的符号都以“Z”开头,对于嵌套的名字(在名称空间或在类里面的),后面紧跟“N”,然后是各个名称空间和类的名字,每个名字前是名字字符串长度,再以“E”结尾。比如N:C:func经过名称修饰以后就是_ZNINIC4 funcE。对于一个函数来说,它的参数列表紧跟在“E”后面,对于t类型来说,就是字母“i”。所以整个N:C:func(int)函数签名经过修饰为ZNIN1C4 funcEi。更为其体的修饰方法我们在这里不详细介绍,有兴趣的读者可以参考GCC的名称修饰标准。幸好这种名称修饰方法我们平时程序开发中也很少手工分析名称修饰问题,所以无须很详细地了解这个过程。binutils里面提供了一个叫“c++filt”的工其可以用来解析被修饰过的名称,比如:

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

        签名和名称修饰机制是C++的一个关键特性,用于区分不同的函数,全局变量和静态变量。例如,全局变量和函数都是全局可见的名称,且都遵循这种修饰机制。在这个机制下,名为“bar”的全局变量,位于名为“foo”的命名空间中,修饰后的名称为“_ZN3foo3baE”。值得注意的是,变量的类型并没有加入到修饰后的名称中。所以,无论变量是整数类型,浮点类型,还是全局对象,它的修饰后的名称都是相同的。这种修饰机制也用于防止静态变量的名称冲突。例如,如果“main”函数和“func()”函数都有一个名为“foo”的静态变量,为了区分这两个变量,GCC会分别修饰它们的名称,使它们成为“_ZZ4mainE3foo”和“_ZZ4funcvE3foo”,从而实现区分。由于不同的编译器采用不同的名字修饰方法,必然会导致由不同编译器编译产生的目标文件无法正常相互链接,这是导致不同编译器之间不能互操作的主要原因之一。

4. extern"C"

        在C++中,为了与C兼容并进行符号的管理,可以使用extern "C"关键字来声明或定义一个C的符号。这样做的主要目的是为了确保C++编译器按照C的命名和调用约定处理这些符号,以便与C代码进行正确的链接。

声明一个C的函数符号和全局变量符号:

extern "C" int func(int); // 声明一个返回类型为int,参数为int的C函数func
extern "C" int var; // 声明一个类型为int的C全局变量var

在使用extern "C"时,需要注意以下几点:

  1. extern "C"大括号内部的函数和变量声明或定义将按照C语言的命名和调用约定进行处理,不受C++的名称修饰机制影响。
  2. 在不同的编译器平台下,C++编译器可能会对C语言的符号进行不同的修饰。在Visual C++平台下,通常会在C语言符号前面添加下划线作为修饰,而在Linux版本的GCC编译器下通常不会添加下划线。
  3. 单独声明某个函数或变量为C语言的符号时,可以使用如下格式:extern "C" int func(int);extern "C" int var。

        总结来说,extern "C"关键字可以在C++代码中用来声明C语言风格的函数和变量,确保它们遵循C的命名和调用约定,以便与C代码正确链接和交互。在使用extern "C"时,具体的符号修饰方式可能因编译器和平台而异。在C++中使用string.h中的函数时,比如memset,由于C++支持函数重载和名称修饰,编译器会将这些函数名进行符号修饰,导致与C语言库中的函数名不一致,从而导致链接错误。使用条件宏__cplusplus是一种常见且有效的解决方法。__cplusplus是C++编译器提供的预定义宏,在编译C++程序时会默认定义,而在编译C程序时不会定义。这样,我们可以利用这个宏来区分C++代码和C代码,并在适当的情况下使用extern "C"来声明C语言的函数。

#ifdef __cplusplus
extern "C" {
#endif

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

#ifdef __cplusplus
}
#endif

5. 弱符号与强符号

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

b.o:(.data+0x0);multiple definition of`globa1'
a.o:(.data+0x0)}$:first defined here

。在C/C++语言中,符号可以分为强符号(Strong Symbol)和弱符号(Weak Symbol)。这是链接器(linker)在进行符号解析和链接时的概念。

  1. 强符号(Strong Symbol):

    • 函数和初始化了的全局变量(例如已经赋初值的全局变量)通常被视为强符号。
    • 强符号在链接过程中,如果存在多个同名的强符号定义,链接器会报错,因为它无法确定要使用哪个定义。
  2. 弱符号(Weak Symbol):

    • 未初始化的全局变量(例如声明了但没有赋初值的全局变量)通常被视为弱符号。
    • 在某些特定情况下,通过GCC的__attribute__((weak))属性,可以将强符号定义声明为弱符号,即使已经初始化了。
extern int ext;                  // 声明一个名为 'ext' 的外部(extern)整数变量
int weak;                        // 声明一个名为 'weak' 的整数变量(默认为强符号)
int strong = 1;                  // 声明并初始化一个名为 'strong' 的整数变量(强符号)
__attribute__((weak)) int weak2 = 2; // 声明并初始化一个名为 'weak2' 的弱整数变量
int main()                       // 主函数
{                                // 主函数开始
    return 0;                    // 返回0,表示执行成功
}                                // 主函数结束

下面对每个声明进行解释:

  1. extern int ext;:这声明了一个名为 'ext' 的外部整数变量。实际的定义应该在另一个翻译单元(源文件)中,在链接阶段找到。

  2. int weak;:这声明了一个名为 'weak' 的整数变量。默认情况下,它被认为是强符号,意味着它不是弱定义的。

  3. int strong = 1;:这声明并初始化了一个名为 'strong' 的整数变量。由于它被初始化了,它是一个强符号。

  4. __attribute__((weak)) int weak2 = 2;:这声明并初始化了一个名为 'weak2' 的弱整数变量。这意味着如果在另一个翻译单元中存在 'weak2' 的强符号定义,链接器将选择强符号定义而不是这个弱定义。

  5. int main():这是主函数,程序的入口点。

  6. return 0;:这从主函数返回0,表示执行成功。

规则1:不允许强符号被多次定义:

  • 强符号在整个程序中只能有一个定义。如果存在多个目标文件中具有相同名字的强符号定义,则链接器会报告符号重复定义错误。

规则2:选择强符号优先:

  • 如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,链接器会选择强符号的定义作为最终的符号定义。

规则3:选择占用空间最大的弱符号:

  • 如果一个符号在所有目标文件中都是弱符号,链接器会选择其中占用空间最大的一个作为最终的符号定义。
  • 这意味着如果多个目标文件都定义了同名的全局变量,并且它们都是弱符号,链接器会选择占用空间最大的那个定义来作为符号的实际定义。
  • 例如,在目标文件A定义全局变量global为int型,占4个字节;目标文件B定义global为double型,占8个字节;那么在链接A和B后,符号global会占用8个字节,因为8个字节的double类型占用了更大的空间。

        对于规则3,尽量不要使用多个不同类型的弱符号,因为这可能导致很难发现的程序错误,如数据类型不匹配或内存访问错误。在实际编程中,尽量避免使用多个不同类型的弱符号,以确保程序的正确性和可维护性。

        强引用是指在目标文件中对外部符号的引用,链接器在最终将目标文件链接成可执行文件或共享库时,必须能够找到该符号的定义,否则会报符号未定义错误。这种情况下,链接器将对找到的外部符号引用进行决议,确保所有引用都能正确链接到其定义。

        弱引用则是对外部符号的一种引用,但对于未定义的弱引用,链接器不会报错。如果该符号在其他目标文件中有定义,链接器会选择其中的强符号作为引用的定义,如果未找到该符号的定义,链接器通常会将其解释为0或者特殊的值,以便程序代码能够识别。

        弱引用和弱符号主要用于库的链接过程。在链接库时,有时库函数可能有一些可选的功能,这些功能对于使用该库的程序并非必需。在这种情况下,库函数可以使用弱引用来引用一些未必存在的外部符号,如果程序没有提供这些符号的定义,链接器不会报错,而是选择默认值进行链接,从而实现对可选功能的支持。

        弱符号与链接器的COMMON块概念密切相关,COMMON块是用于处理全局未初始化的弱符号的一种机制。在后续的“深入静态链接”章节中,我们会更加详细地讨论弱符号和COMMON块的概念。这些机制在链接阶段起着重要的作用,确保程序能够正确地链接和执行。

        GCC 中,你可以使用 __attribute__((weakref)) 这个扩展关键字来声明对一个外部函数的引用为弱引用。这个特性允许你将一个函数引用指定为一个弱引用,这样如果这个函数没有被定义,链接器不会报错,并且会使用默认的弱引用目标。这在一些场景中非常有用,特别是在处理库函数的链接时。

__attribute__((weakref)) void foo(); // 弱引用声明,这里使用 __attribute__((weakref))

int main() {
    foo(); // 调用 foo() 函数
    return 0;
}

        在上面的代码中,__attribute__((weakref)) 这个特性被应用于 foo() 函数的声明,将它声明为一个弱引用。如果在链接时没有找到 foo() 函数的定义,链接器将不会报错,并且会使用默认的弱引用目标。这样在实际运行时,如果 foo() 函数没有被定义,程序会继续执行,但调用 foo() 函数时会执行默认的弱引用目标。

        需要注意的是,__attribute__((weakref)) 是 GCC 特有的扩展语法,在其他编译器中可能不被支持。如果你的代码需要在不同的编译器或平台上移植,最好避免使用特定于编译器的扩展特性。在需要跨平台的情况下,应该考虑使用更标准的方法来处理函数引用。

参考《程序员的自我修养》俞甲子

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值