编译器链接过程中的强符号和弱符号

编译器链接过程中的强符号和弱符号,第1张

属性声明

GNU C通过__attribute__声明weak属性,可以将一个强符号转换为弱符号。

使用方法如下:

__attribute__((weak)) void func(void);
__attribute__((weak)) int num;

编译器在编译源程序时,无论你是变量名,函数名,在它眼里,都是一个符号而已,用来表征一个地址。

编译器会将这些符号集中,存放到一个叫符号表的section中。

一个软件工程项目中,可能有多个源文件。

如果A.c源文件中定义了一个全局变量num,而B.c源文件中也定义了一个全局变量num,那么当程序中打印变量的num的时候,该打印哪个值呢?

想回答这个问题,那我们先来回个一下编译的基本过程,主要分为三个阶段:

  •  编译阶段:编译器以源文件为单位,将每一个源文件编译为一个.o后缀的目标文件,每一个目标文件由代码段、数据段、符号表等组成
  • 链接阶段:链接器将各个目标文件组成成一个大目标文件。

    链接器将各个目标文件中的代码段组装在一起,组成一个大的代码段;各个数据段组装在一起,组成一个大的数据段;各个符号表也会集中在一起,组成一个大的符号表。

    最后再将合并后的代码段、数据段、符号表等组合长一个大的目标文件。

  • 重定位:因为各个目标文件重新组装,各个目标文件中的变量、函数的地址都发生了变化, 所以要重新修正这些函数、变量的地址。

    这个过程称为重定位。

    重定位结束后,就生成了可以在机器上运行的可执行程序。

上面举例的工程项目,在编译过程中的链接阶段,可能就会出现问题:A.c 和 B.c 文件中都定义了一个同名变量 num,那链接器到底该用哪一个呢?

这个时候,就需要引入强符号和弱符号的概念了。

强符号和弱符号

强符号:函数名、初始化的全局变量名

弱符号:未初始化的全局变量名

在一个工程项目中,对于相同的全局变量名、函数名,我们一般可以归结为下面三种场景。

  • 强符号+强符号
  • 强符号+弱符号
  • 弱符号+弱符号

强符号和若符号在解决程序编译连接过程中,出现的多个同名变量、函数的冲突问题非常有用。

一般我们遵循下面三个规则:

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

一山不容二虎

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

强弱可以共处。

规则3:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。

比如目标文件A定义全局变量global为int型,占4个字节;目标文件B定义global为double型,占8个字节,那么目标文件A和B链接后,符号global占8个字节(尽量不要使用多个不同类型的弱符号,否则容易导致很难发现的程序错误)。

体积大者胜出。

//func.c
int a = 1;
int b;
void func(void)
{
    printf("func:a = %d\n", a);
    printf("func: b = %d\n", b);
}
​
//main.c
int a;
int b = 2;
void func(void);
int main(void)
{
    printf("main:a = %d\n", a);
    printf("main: b = %d\n", b);
    func();
    return 0;
}

程序编译,可以看到运行结果

$ gcc -o a.out main.c func.c
main: a = 1
main: b = 2
func: a = 1
func: b = 2

我们在 main.c 和 func.c 中分别定义了两个同名全局变量 a 和 b,但是一个是强符号,一个是弱符号。

链接器在链接过程中,看到冲突的同名符号,会选择强符号,所以你会看到,无论是 main 函数,还是 func 函数,打印的都是强符号的值。

一般来讲,不建议在一个工程中定义多个不同类型的弱符号,编译的时候可能会出现各种各样的问题,这里就不举例了。

在一个工程中,也不能同时定义两个同名的强符号,即初始化的全局变量或函数,否则就会报重定义错误。

但是我们可以使用 GNU C 扩展的 weak 属性,将一个强符号转换为弱符号。

//func.c
int a __attribute__((weak)) = 1;
void func(void)
{
    printf("func:a = %d\n", a);
}
​
//main.c
int a = 4;
void func(void);
int main(void)
{
    printf("main:a = %d\n", a);
    func();
    return 0;
}

编译程序,可以看到程序运行结果。

$ gcc -o a.out main.c func.c
main: a = 4
func: a = 4

我们通过 weak 属性声明,将 func.c 中的全局变量 a,转换为一个弱符号,然后在 main.c 里同样定义一个全局变量 a,并初始化 a 为4。

链接器在链接时会选择 main.c 中的这个强符号,所以在两个文件中,打印变量 a 的值都是4。

函数的强符号和弱符号

链接器对于同名变量冲突的处理遵循上面的强弱规则,对于函数同名冲突,同样遵循相同的规则。

函数名本身就是一个强符号,在一个工程中定义两个同名的函数,编译时肯定会报重定义错误。

但我们可以通过weak属性声明,将其中一个函数转换为弱符号。

//func.c
int a __attribute__((weak)) = 1;
void __attribute__((weak)) func(void)
{
    printf("func:a = %d\n", a);
}
​
//main.c
int a = 4;
void func(void)
{
    printf("I am a strong symbol!\n");
}
int main(void)
{
    printf("main:a = %d\n", a);
    func();
    return 0;
}

编译程序,可以看到程序运行结果:

$ gcc -o a.out main.c func.c
main: a = 4
func: I am a strong symbol!

在这个程序示例中,我们在 main.c 中重新定义了一个同名的 func 函数,然后将 func.c 文件中的 func() 函数,通过 weak 属性声明转换为一个弱符号。

链接器在链接时会选择 main.c 中的强符号,所以我们在 main 函数中调用 func() 时,实际上调用的是 main.c 文件里的 func() 函数。

弱引用的用途

在一个源文件中引用一个变量或函数,当我们只声明,而没有定义时,一般编译时可以通过的。

这是因为编译是以文件为单位的,编译器会将一个个源文件首先编译为.o目标文件。

编译器只要能看到函数或变量的声明,会认为这个变量或函数的定义可能会在其他的文件中,所以不报错。

甚至如果你没有包含头文件,连个声明也没有,编译器也不会报错,顶多就是给一个警告信息,但链接阶段是要报错的,链接器在各个目标文件、库中都找不到这个变量或函数的定义,一般就会报未定义错误。

当函数被声明为一个弱符号时,会有一个奇特的地方:当链接器找不到这个函数的定义时,也不会报错。

编译器会将这个函数名,即弱符号,设置为0或者一个特殊值。

只有当程序运行时,调用到这个函数,跳转到0地址或一个特殊的地址才会报错。

//func.c
int a __attribute__((weak)) = 1;
​
//main.c
int a = 4;
void __attribute__((weak)) func(void);
int main(void)
{
    printf("main:a = %d\n", a);
    func();
    return 0;
}

编译程序, 可以看到程序运行结果。

$ gcc -o a.out main.c func.c
main: a = 4
Segmentation fault (core dumped)

在这个示例程序中,我们没有定义 func() 函数,仅仅是在 main.c 里作了一个声明,并将其声明为一个弱符号。

编译这个工程,你会发现是可以编译通过的,只是到了程序运行时才会出错。

为了防止函数运行出错,我们可以在运行这个函数之前,先做一个判断,即看这个函数名的地址是不是0,然后再决定是否调用、运行。

这样就可以避免段错误了,示例代码如下。

//func.c
int a __attribute__((weak)) = 1;
​
//main.c
int a = 4;
void __attribute__((weak)) func(void);
int main(void)
{
    printf("main:a = %d\n", a);
    if (func)
        func();
    return 0;
}

编译程序,可以看到程序运行结果。

$ gcc -o a.out main.c func.c
main: a = 4

函数名的本质就是一个地址,在调用 func 之前,我们先判断其是否为0,为0的话就不调用了,直接跳过。

你会发现,通过这样的设计,即使这个 func() 函数没有定义,我们整个工程也能正常的编译、链接和运行!

弱符号的这个特性,在库函数中应用很广泛。

比如你在开发一个库,基础的功能已经实现,有些高级的功能还没实现,那你可以将这些函数通过 weak 属性声明,转换为一个弱符号。

通过这样设置,即使函数还没有定义,我们在应用程序中只要做一个非0的判断就可以了,并不影响我们程序的运行。

等以后你发布新的库版本,实现了这些高级功能,应用程序也不需要任何修改,直接运行就可以调用这些高级功能。

弱符号还有一个好处,如果我们对库函数的实现不满意,我们可以自定义与库函数同名的函数,实现更好的功能。

比如我们 C 标准库中定义的 gets() 函数,就存在漏洞,常常成为黑客堆栈溢出攻击的靶子。

int main(void)
{
    char a[10];
    gets(a);
    puts(a);
    return 0;   
}

C 标准定义的库函数 gets() 主要用于输入字符串,它的一个 Bug 就是使用回车符来判断用户输入结束标志。

这样的设计很容易造成堆栈溢出。

比如上面的程序,我们定义一个长度为10的字符数组用来存储用户输入的字符串,当我们输入一个长度大于10的字符串时,就会发生内存错误。

接着我们定义一个跟 gets() 相同类型的同名函数,并在 main 函数中直接调用,代码如下。

#include
​
 char * gets (char * str)
 {
     printf("hello world!\n");
     return (char *)0;
 }
​
int main(void)
{
    char a[10];
    gets(a);
    return 0;   
}

程序运行结果如下。

hello world!

通过运行结果,我们可以看到,虽然我们定义了跟 C 标准库函数同名的 gets() 函数,但编译是可以通过的。

程序运行时调用 gets() 函数时,就会跳转到我们自定义的 gets() 函数中运行。

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

在Linuxx程序的设计中,如果一个程序被设计成可以支持单线程或者多线程的模式,就可以通过弱引用的方法来判断当前的程序是链接到了单线程Glibc库还是多线程Glibc库(编译时有-lpthread选项),从而 执行单线程版本的程序或多线程版本的程序。

我们可以在程序中定义一个pthread_create函数的弱引用,然后程序在运行时动态判断是否链接到pthread库从而决定执行多线程版本还是单线程版本。

#include 
#include 
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");
    }else{
        printf("This is single-thread version\n");
    }
}

程序编译及运行如下:

gcc -o pt main.c 
./pt
输出:This is single-thread version
gcc -o pt main.c -lpthread  #链接pthread库
 ./pt
输出:This is multi-thread version

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/langs/674867.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-19
下一篇 2022-04-19

发表评论

登录后才能评论

评论列表(0条)

保存