WangYu::Space

Study, think, create, and grow. Teach yourself and teach others.

编译与链接 - 符号可见性

创建时间:2023-07-28 19:14:01修改时间:2023-08-28 15:55:01

静态链接中,链接器将目标文件中节合并到客户二进制文件中,目标文件中的符号成为客户二进制文件符号列表的一部分,并且保留了原可见性。目标文件中全局符号成为客户二进制文件中的全局符号,目标文件中局部符号成为客户二进制文件中的局部符号。符号的可见性常常引发预期之外的问题,但是这样的问题又是如此的明显。

考虑两个动态库中包含相同的符号 A,如果一个程序依赖这两个动态库,且使用了符号 A,那么究竟会使用哪一个动态库中的符号 A 呢?或者是不是会报错呢?对于这种重复的符号,动态链接不会报错,但究竟会使用那个动态库中的符号,就取决于动态库的加载顺序了。

Linux 平台上,动态链接库中的全局符号默认都是外部可见的,使用此动态库的用户可以自由访问这些符号,但对于动态库而言,这并不是好的设计。向用户暴露过多内部实现细节不是好的做法,动态库作为模块而存在,它应该仅仅对外暴露自己所提供的接口。在加载动态库的时候,如果暴露的符号太多,加载符号也会消耗很多时间,而这是没有必要的。

因此,在设计动态库时,需要有意地控制符号的可见性,仅暴露需要对外暴露的符号。

符号的绑定属性与可见性

在 ELF 文件中,符号使用如下结构体表示:

typedef struct {
  Elf64_Word	st_name;		/* Symbol name (string tbl index) */
  unsigned char	st_info;	/* Symbol type and binding */
  unsigned char st_other;	/* Symbol visibility */
  Elf64_Section	st_shndx;	/* Section index */
  Elf64_Addr	st_value;		/* Symbol value */
  Elf64_Xword	st_size;		/* Symbol size */
} Elf64_Sym;

在 {% include link.html uid=20231551 %} 中对以上结构体的各字段做了描述,在 st_info 字段中包含符号的绑定类型,在 st_other 中包含符号的可见性属性。

符号绑定类型有如下几种:

符号的可见性(visibility)属性有如下几种:

符号的绑定类型和可见性有点让人迷惑。绑定类型用于在链接阶段控制符号能否被其他目标文件访问到,比如我们将变量使用 static 修饰,该变量的绑定类型就是 STB_LOCAL,它就只能在当前目标文件中被访问。

符号的可见性则是在运行时控制当前的动态库中的符号是否能被其他动态库看到。在实现动态库的时候,一般会有多个 .c 文件,编译后有多个 .o 目标文件,多个目标文件中会涉及到符号的相互访问,因此把符号都设置为 static 类型是不现实的,需要在其他目标文件中使用的符号的绑定类型需要被设置 STB_GLOBAL。

当动态库构造完成后,希望该动态库中的部分符号可以隐藏起来,只暴露少量需要被外部访问的符号。这个时候就可以设置符号的可见性,控制符号是否能被其他动态库或者当前加载动态库的程序访问到。

控制符号的可见性

gcc 编译器提供了多种机制来设置符号的可见性:

方法一:使用编译器选项,影响所有符号

向编译器传递编译器选项 -fvisibility=hidden,可以将目标文件中所有的符号设置为对外不可见:

// swap.c

void swap(int *a, int *b) {
    int t = *a;
    *a = *b;
    *b = t;
}

编译为目标文件后,观察其符号表:

$ gcc -c swap.c -fvisibility=hidden -o swap.o
$ readelf -s swap.o 

Symbol table '.symtab' contains 9 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS swap.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    2 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     8: 0000000000000000    45 FUNC    GLOBAL HIDDEN     1 swap

可以看到这里 swap 函数的可见性属性的值为 HIDDEN,绑定类型为 GLOBAL。

方法二:使用 __attribute__ 来修饰符号

__attribute__((visibility("hidden")))
void swap(int *a, int *b) {
    int t = *a;
    *a = *b;
    *b = t;
}

可以使用 __attribute__ 配合 visibility 来控制单个符号的可见性。

方法三:使用 #pragma 编译指令

#pragma visibility push(hidden)

void func1(void);
void func2(void);
void func3(void);

#pragma visibility pop

使用 #pragma 编译指令可以控制一组符号的可见性。

动态链接中符号重复的问题

静态链接时,多个目标文件中如果有同名的符号,在链接的时候就会报错。如果多个动态库中包含相同的符号,在运行时不会报错,但究竟使用哪一个符号取决于先搜索哪一个动态库,而动态库的搜索顺序和链接时候指定动态库的顺序有关。 下面是个例子:

// print1.c
#include <stdio.h>

int a = 1;

void print1() {
    printf("a = %d\n", a);
}
// print2.c
#include <stdio.h>

int a = 2;

void print2() {
    printf("a = %d\n", a);
}
// main.c
#include <stdio.h>

void print1();
void print2();

int main() {
    print1();
    print2();
    return 0;
}

构建链接动态库:

$ gcc print1.c -fPIC -shared -o libprint1.so
$ gcc print2.c -fPIC -shared -o libprint2.so

构建可执行文件:

$ gcc main.c -Wl,-R`pwd` libprint1.so libprint2.so -o main

执行可执行文件时,会打印如下内容:

$ ./main
a = 1
a = 1

结果或许有些意外,对 print2 的调用竟然打印了 a = 1。原因是 print2 在访问变量 a 的时候,会优先查找 libprint1.so。

如果调换一下顺序,把 liba2.so 放到前面:

$ gcc main.c -Wl,-R`pwd` libprint2.so libprint1.so -o main

执行可执行文件时,就会访问到 print2.c 中定义的变量 a

$ ./main
a = 2
a = 2

一个动态库中需要对外暴露的符号往往很少,大量的符号仅仅是用于内部实现。而多个动态库中的这些不打算对外暴露的符号,很可能出现重名,重名就很容易引发 bug。在设计动态库的时候,我们应尽量隐藏符号,仅仅把需要被用户使用的符号暴露出来。上面的例子中,变量 a 就属于实现细节,没有必要对外暴露,将其设置为 static 后,就可以避免出现前面这种不符合预期的情况了。

下面是用于隐藏符号的可行方法:

隐藏 .c/.cpp 的内部实现

对于单个 .c/.cpp 文件,不对外暴露的符号均使用 static 描述,将其绑定类型设置为 STB_LOCAL。把需要被其他 .c/.cpp 访问的函数或变量放在对应的 .h 头文件中,其中变量使用 extern 声明,并在 .c/.cpp 文件中定义。

有人说,仅把对外暴露的函数声明在 .h 文件中,这就实现了函数的隐藏,这种想法是错误的。头文件的作用是提供声明,其他 .c/.cpp 文件包含此头文件后,就获得了符号的声明,头文件对符号的可见性没有任何影响。在链接时,链接器是通过符号表来查看符号的,这和头文件没什么关系。

编译时控制目标符号的可见性

在构建动态库的时候,需要在编译目标文件时就对符号的可见性做控制。建议在编译时候使用 -fvisibility=hidden 设置所有符号的可见性为 hidden,对于需要对外暴露的符号,在代码中使用如下方式改变其可见性:

__attribute__((visibility("default")))
int foo(int x) {
  return x * x;
}

__attribute__((visibility("default")))
int bar;

如此以来,除了使用 __attribute__((visibility("default"))) 修饰过的符号外,对外均不可见。目标文件链接成动态库后,符号的可见性会被继承到动态链接库中。如此,动态库中的符号就实现了隐藏,降低了非预期的符号重名问题。

总结

本文详细描述了动态库的符号可见性问题,以我不长的工作经验来看,这个问题常常出现。在设计动态库的时候,我们要有意识地对符号做隐藏,对外暴露的符号要给起一个足够长的名称,避免重复。

评论 (评论内容仅博主可见,不会公开显示)