查看Linux内核代码时,经常能看到一些编译器选项如__attribute__((weak),起初不太了解,经过查资料,算是对gcc的这个编译属性有了初步的认识,现在总结如下。

编译器在编译源程序时,无论你是变量名、函数名,在它眼里,都是一个符号而已,用来表示一个地址。编译器会将这些符号集中存放到一个叫符号表的 section 中。

程序编译链接的基本过程总结起来其实很简单,大概分为如下三个阶段:


(资料图片仅供参考)

编译阶段:编译器以源文件为单位,将每一个源文件编译为一个.o后缀的目标文件。每一个目标文件由代码段、数据段、符号表等组成。链接阶段:链接器将各个目标文件组装成一个大目标文件。链接器将各个目标文件中的代码段组装在一起,组成一个大的代码段;各个数据段组装在一起,组成一个大的数据段;各个符号表也会集中在一起,组成一个大的符号表。最后再将合并后的代码段、数据段、符号表等组合成一个大的目标文件。重定位:由于链接阶段各个目标文件重新组装,各个目标文件中的变量和函数的地址都发生了变化,所以要重新修正这些函数及变量的地址,这个过程称为重定位。重定位结束后,才生成了可以在机器上运行的可执行程序。

一、weak属性

attribute((weak))表示为弱符号属性,所谓的弱符号是针对于强符号来说的,我们定义的全局已初始化变量及全局函数等都是属于强符号,在链接时如果有多个强符号就会报错误;而弱符号主要指未初始化的全局变量或通过__attribute__((weak))来显式声明的变量或函数。

在日常编程过程中,我们可能会碰到一种符号重复定义的情况。如果多个目标文件中含有相同名字的全局变量的定义,那么这些目标文件链接的时候就会出现符号重复定义的错误。比如在源文件date.c 和源文件weak_attr.c都定义了一个全局整型变量year,并且均已初始化,那么当date.c和weak_attr.c链接时会报错:

multiple definition of "xxx"

重复定义的源码文件如下:

/* 头文件date.h */#ifndef __DATE_H__#define __DATE_H__void currentYear();#endif/* 源文件date.c */#include < stdio.h >#include "date.h"int year=2023;        void currentYear(){    printf("This year is %d.\\n", year);}/* 源文件weak_attr.c */#include < stdio.h >#include "date.h"int year=2022;        int main(){    currentYear();    return 0;}

gcc编译输出结果如下:

[root@localhost 119]# gcc -o weak_attr date.c weak_attr.c/tmp/ccpmkhms.o:(.data+0x0): multiple definition of `year"/tmp/ccsxbab2.o:(.data+0x0): first defined herecollect2: error: ld returned 1 exit status

那么该如何解决这个问题呢?让我们继续往下看,下文会给出解决之道。

二、强符号弱符号

在程序中,无论是变量名,还是函数名,在编译器的眼里,都只是一个符号而已。符号可以分为强符号和弱符号。

强符号:函数名、初始化的全局变量名;弱符号:未初始化的全局变量名、 attribute_((weak)修饰的变量或函数

强符号和弱符号在解决程序编译链接过程中,出现的多个同名变量、函数的冲突问题非常有用。一般我们遵循下面三个规则:

一山不容二虎强弱可以共处体积大者为主

上面为方便记忆总结的3点原则,具体表述如下:

强弱符号总结规则如下:

规则1:不允许强符号被重复多次定义,但是强弱符号可以共存。

规则2:如果一个符号在某个目标文件中是强符号,但在其他文件中都是弱符号,那么编译时以强符号的值为准。

规则3:如果一个符号在所有的目标文件中都是弱符号,那么选择其中占用空间最大的一个。这个其实很好理解,编译器不知道编程者的用意,选择占用空间大的符号至少不会造成诸如溢出、越界等严重后果。

下面我们以一个例子说明强弱符号,如下:

#include < stdio.h >extern int temp;        // 非强符号也非弱符号int weak;               // 弱符号int strong = 1;         // 强符号__attribute__((weak)) int weak_attr = 2;    // 弱符号int main(){    //代码    return 0;   }

在默认的符号类型情况下,强符号和弱符号是可以共存的,类似于这样:

/* 源文件test.c */#include< stdio.h >int year;               /* 弱符号 */int year = 2023;        /* 强符号 */int main(){    printf("Current year is %d\\n",year);    return 0;}

gcc编译执行输出结果如下:

[root@localhost 119]# gcc -o test test.c[root@localhost 119]# ./testCurrent year is 2023

编译不会报错,在编译时year的取值将会是2023。

这里我们回到本文最初的例子,我们将源文件weak_attr.c中的year=2022使用__attribute___((weak)修饰,则会将源文件weak_attr.c中的year=2022由强符号转换为弱符号,此时,程序编译链接则不会报错,源文件以及编译链接和执行情况如下:

/* 头文件date.h */#ifndef __DATE_H__#define __DATE_H__void currentYear();#endif/* 源文件date.c */#include < stdio.h >#include "date.h"int year=2023;        void currentYear(){    printf("This year is %d.\\n", year);}/* 源文件weak_attr.c */#include < stdio.h >#include "date.h"int __attribute__((weak)) year=2022;int main(){    printf("The value of year is : %d\\n.",year);    currentYear();    return 0;}

gcc编译输出结果如下:

[root@localhost 119]# gcc -o weak_attr date.c weak_attr.c -g[root@localhost 119]#[root@localhost 119]# ./weak_attrThe value of year is : 2023.This year is 2023.

由此可见,当不同源文件中存在定义同名变量的情况下,要想编译不报错,则可根据具体场景将强符号转换为弱符号,虽然这样可能没有太大意义,并且容易引发问题,但是也不失为一种解决办法。

但是使用__attribute__((weak))将强符号转换为弱符号时,却不能在同一个文件中同时存在同名的强符号,类似于这样:

/* 源文件test.c */#include< stdio.h >int __attribute__((weak)) year = 2022;int year=2023;int main(){    printf("Current year is %d\\n",year);    return 0;}

编译器将报重复定义错误。

[root@localhost 119]# gcc -o test test.ctest.c:4:5: error: redefinition of ‘year’ int year = 2023;     ^~~~test.c:3:27: note: previous definition of ‘year’ was here int __attribute__((weak)) year = 2022;

编程时,通常容易被忽略或者错误认识的一个点是: 全局变量不进行初始化,编译器在编译时会自动初始化为0。如下即为编译器在编译期间自动为未初始化的全局变量初始化为0的案例:

[root@localhost 119]# gcc -o test test.c -g[root@localhost 119]# gdb ./testGNU gdb (GDB) Red Hat Enterprise Linux 8.2-19.el8Copyright (C) 2018 Free Software Foundation, Inc.License GPLv3+: GNU GPL version 3 or later < http://gnu.org/licenses/gpl.html >This is free software: you are free to change and redistribute it.There is NO WARRANTY, to the extent permitted by law.Type "show copying" and "show warranty" for details.This GDB was configured as "x86_64-redhat-linux-gnu".Type "show configuration" for configuration details.For bug reporting instructions, please see:< http://www.gnu.org/software/gdb/bugs/ >.Find the GDB manual and other documentation resources online at:    < http://www.gnu.org/software/gdb/documentation/ >.For help, type "help".Type "apropos word" to search for commands related to "word"...Reading symbols from ./test...done.(gdb) list1       #include< stdio.h >23       //int __attribute__((weak)) year = 2022;4       int year;56       int main()7       {8           printf("Current year is %d\\n",year);9           return 0;10      }(gdb)Line number 11 out of range; test.c has 10 lines.(gdb) print year$1 = 0(gdb)

大部分人都认为 C 程序中的未初始化全局变量会在程序编译的期间被默认初始化为 0,因此不需要在程序中执行初始化操作。这个观点既正确又不完全正确。此话怎讲,因为该观点是有前提条件的,即 该全局变量在项目工程内全局唯一时,则编译器在编译时会自动将该全局变量初始化为0。否则,一旦该全局变量在项目工程内不唯一,且在另一个文件内有已被初始化的另一同名全局变量时,则该变量的值为被初始化的全局变量的值,而非0。

请看如下案例,一个全局变量year在文件weak_attr.c中被定义并初始化为2023,而在文件date.c中被定义但没有初始化,通过上文的讨论可以知道,这并不会报错,此时date.c文件中的全局变量year(弱符号)被覆盖,但是它的值并不会是预想中的被初始化为 0,而是weak_attr.c中初始化的值,这种情况下就可能造成一些问题。

/* 源文件date.c */#include < stdio.h >#include "date.h"int year=2023;void currentYear(){    printf("This year is %d.\\n", year);}/* 源文件weak_attr.c */#include < stdio.h >#include "date.h"int year;int main(){    printf("The value of year is : %d\\n.",year);    currentYear();    return 0;}

gcc编译调试输出如下:

[root@localhost 119]# gdb ./weak_attrGNU gdb (GDB) Red Hat Enterprise Linux 8.2-19.el8Copyright (C) 2018 Free Software Foundation, Inc.License GPLv3+: GNU GPL version 3 or later < http://gnu.org/licenses/gpl.html >This is free software: you are free to change and redistribute it.There is NO WARRANTY, to the extent permitted by law.Type "show copying" and "show warranty" for details.This GDB was configured as "x86_64-redhat-linux-gnu".Type "show configuration" for configuration details.For bug reporting instructions, please see:< http://www.gnu.org/software/gdb/bugs/ >.Find the GDB manual and other documentation resources online at:    < http://www.gnu.org/software/gdb/documentation/ >.For help, type "help".Type "apropos word" to search for commands related to "word"...Reading symbols from ./weak_attr...done.(gdb) l1       #include < stdio.h >2       #include "date.h"34       int year;56       int main()7       {8           printf("The value of year is : %d\\n.",year);9           currentYear();10          return 0;(gdb)11      }(gdb) print year$1 = 2023(gdb)

执行程序输出结果如下:

[root@localhost 119]# ./weak_attrThe value of year is : 2023.This year is 2023.

从上述结果可看出,year的值被初始化为2023,而并非为0。

当然,这并一定就说明所有全局变量在定义时就应该初始化为 0,毕竟未初始化的全局变量被放置在 bss 段,对于某些数据结构将会节省大量空间,这是有意义的。只是我们在思考是否需要对全局变量进行初始化的时候需要将上面可能出现的问题考虑进去,根据实际的场景选择合适的方案。

三、函数的强符号和弱符号

链接器对于同名变量冲突的处理遵循上面的强弱规则,对于同名函数的冲突,也遵循相同的规则。函数名本身就是一个强符号,在一个工程中定义两个同名的函数,编译时肯定会报重定义错误。但我们可以通过 weak 属性声明,将其中一个函数转换为弱符号。

/* function.c */int num __attribute__((weak)) = 1;void __attribute__((weak)) func(void){   printf("func:num = %d\\n", num);}/* main.c */#include < stdio.h >int num = 4;void func(void){   printf("I am a strong symbol!\\n");}int main(void){   printf("main:num = %d\\n", num);   func();   return 0;}

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

[root@localhost 130]# gcc -o main main.c function.c[root@localhost 130]#[root@localhost 130]# ./mainmain:num = 4I am a strong symbol!

在这个程序示例中,我们在 main.c 中重新定义了一个同名的 func 函数,然后将 function.c 文件中的 func() 函数,通过 weak 属性声明转换为一个弱符号。链接器在链接时会选择 main.c 中的强符号,所以我们在 main 函数中调用 func() 时,实际上调用的是 main.c 文件里的 func() 函数。

四、弱符号的作用

在一个源文件中引用一个变量或函数,当我们仅声明而未定义时,一般编译是可以通过的。因为编译是以文件为单位的,编译器会将一个个源文件首先编译为 .o 目标文件。编译器只要能看到函数或变量的声明,就会认为这个变量或函数的定义可能会在其它的文件中,所以不会报错。甚至如果你没有包含头文件,连个声明也没有,编译器也不会报错,顶多就是给你一个警告信息。但链接阶段是要报错的,链接器在各个目标文件、库中都找不到这个变量或函数的定义,一般就会报未定义错误。

当函数被声明为一个弱符号时,会有一个特别的地方: 当链接器找不到这个函数的定义时,也不会报错。编译器会将这个函数名,即弱符号,设置为0或一个特殊的值。只有当程序运行时,调用到这个函数,跳转到0地址或一个特殊的地址才会报错。

/* function.c */int num __attribute__((weak)) = 1;/* main.c */int num = 5;void __attribute__((weak)) func(void);int main(void){   printf("main:num = %d\\n", num);   func();   return 0;}

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

[root@localhost 130]# gcc -o main main.c function.c[root@localhost 130]#[root@localhost 130]# ./mainmain:num = 5Segmentation fault (core dumped)

在这个示例程序中,我们没有定义 func() 函数,仅仅是在 main.c 里作了一个声明,并将其声明为一个弱符号。编译这个工程,你会发现是可以编译通过的,只是到了程序运行时才会出错。

为防止函数执行出错,可以在执行函数之前,先做一个判断,即判断函数名的地址是不是0,再决定是否调用、运行,如果是0,则不进行调用。这样就可以避免段错误了,示例代码如下:

/* function.c */int a __attribute__((weak)) = 1;/* main.c */#include < stdio.h >int num = 5;void __attribute__((weak)) func(void);int main(void){   printf("main:num = %d\\n", num);   if(func)   {      func();   }   return 0;}

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

[root@localhost 130]# gcc -o main main.c function.c[root@localhost 130]# ./mainmain:num = 5

实际上函数名的本质就是一个地址,在调用 func 之前,我们先判断其是否为0,为0的话就不调用该函数,直接跳过。通过这样的设计,即使这个 func() 函数没有定义,我们整个工程也能正常的编译、链接和运行。

弱符号的这个特性,在库函数中应用很广泛。比如你在开发一个库,基础的功能已经实现,有些高级的功能还没实现,那你可以将这些函数通过 weak 属性声明,转换为一个弱符号。通过这样设置,即使函数还没有定义,我们在应用程序中只要做一个非0的判断就可以了,并不影响我们程序的运行。等以后你发布新的库版本,实现了这些高级功能,应用程序也不需要任何修改,直接运行就可以调用这些高级功能。

弱符号还有一个好处,如果我们对库函数的实现不满意,我们可以自定义与库函数同名的函数,实现更好的功能。比如我们 C 标准库中定义的 gets() 函数,就存在漏洞,常常成为黑客堆栈溢出攻击的靶子。

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

编译时会出现一个warning,建议我们不要使用gets函数了。

[root@localhost 130]# gcc -o test test.ctest.c: In function ‘main’:test.c:7:4: warning: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Wimplicit-function-declaration]    gets(a);    ^~~~    fgets/tmp/cckNkApE.o: In function `main":test.c:(.text+0x15): warning: the `gets" function is dangerous and should not be used.

我们暂时不管他,先直接运行看结果:

[root@localhost 130]# ./testhello,my name is localhost, nice to meet youhello,my name is localhost, nice to meet youSegmentation fault (core dumped)

C 标准定义的库函数 gets() 主要用于输入字符串,它的一个bug就是使用回车符来判断用户输入结束标志。这样的设计很容易造成堆栈溢出。比如上面的程序,我们定义一个长度为5的字符数组用来存储用户输入的字符串,当我们输入一个长度大于5的字符串时,就会发生内存错误。

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

/* test.c */#include < stdio.h >char * gets (char * str){    printf("my custom function!\\n");    return (char *)0;}int main(void){   char a[5];   gets(a);   puts(a);   return 0;}

编译运行,程序执行结果如下:

[root@localhost 130]# gcc -o test test.c[root@localhost 130]# ./testmy custom function!

通过运行结果,我们可以看到,虽然我们定义了跟 C 标准库函数同名的 gets() 函数,但编译是可以通过的。程序运行时调用 gets() 函数时,就会跳转到我们自定义的 gets() 函数中运行,从而实现了漏洞攻击。

关键词: