Linux ELF 详解4 -- 深入 Symbol【转】

转自:https://blog.csdn.net/helowken2/article/details/113792555

Symbol 的分类
从链接器的角度看,Symbol 可以分为3类(这里的类别不同于 Symbol Type)

Global Symbol Def:定义在当前对象文件中,可以被其他对象文件引用。例如定义在当前对象文件中的非 static 的函数或者全局变量。
Global Symbol Ref:定义在其他对象文件中,被当前对象文件所引用。又被称作 externals,例如定义在其他对象文件中的非 static 的函数或者全局变量。
Local Symbol:定义和引用都在当前对象文件中。例如 static 函数和 static 全局变量。这些 Symbol 对当前对象文件的任何地方都可见,但是不能被其他对象文件引用。
Local Symbol & 局部变量
Local Symbol 不是局部变量:

“.symtab” Section 不会包含任何局部变量
局部变量是运行期间在 Stack 上进行分配的
static 变量不会在 Stack 上进行分配,而是在编译期间,由编译器在 “.data” 或 “.bss” Section 中分配空间,然后在 “.symtab” Section 中创建 Symbol,这些 Symbol 名字都是唯一的。
局部变量的空间分配
下面查看局部变量 d(在 main 函数中)是如何在 stack 上进行分配的

$ cat program.c
...
extern int a;
...
int main() {
int d = function(100) + a;
...
}

# 反汇编后的代码
$ objdump -d program.o
...
0000000000000000 <main>:
main():
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp # stack 上预留 16字节用于存储局部变量 d(16字节是 x86-64 架构上的 ABI 规范)
8: bf 64 00 00 00 mov $0x64,%edi # 放入参数 100
d: e8 00 00 00 00 callq 12 <main+0x12> # 调用 function
12: 89 c2 mov %eax,%edx # 把 function 的返回值放到 edx
14: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 1a <main+0x1a> (把外部变量 a 的值放到 eax)
1a: 01 d0 add %edx,%eax # 计算 a + function 的返回值,计算结果放入 eax
1c: 89 45 fc mov %eax,-0x4(%rbp) # 把计算结果从 eax 放入 stack。[rbp - 4, rbp) 就是局部变量 d 在 stack 中所占的空间
...

static 变量的空间分配
// local_linker_symbol.c
int func1() {
static int a = 0;
return a;
}

int func2() {
static int a = 2;
return a;
}

$ gcc -c local_linker_symbol.c
$ readelf -sW local_linker_symbol.o
...
Num: Value Size Type Bind Vis Ndx Name
...
5: 0000000000000000 4 OBJECT LOCAL DEFAULT 4 a.1832
6: 0000000000000000 4 OBJECT LOCAL DEFAULT 3 a.1835
...

由上可知

func1 中 a 的 Section Header index = 4,名字叫 “a.1832”
func2 中 a 的 Section Header index = 3,名字叫 “a.1835”
你可能会有疑问:怎么确定哪个 Symbol a 是 func1,哪个是 func2的?毕竟从名字上来看是完全看不出的,Symbol 顺序也不一定跟程序的一致。

其实可以从它们的 Section Header index 来分辨。

$ readelf -SW local_linker_symbol.o
...
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
...
[ 3] .data PROGBITS 0000000000000000 000058 000004 00 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 00005c 000004 00 WA 0 0 4
...

func1 中 a 的初始值为0,所以它指向 “.bss” Section。而 func2 中 a 的初始值为2(不为0),所以它指向 “.data” Section。(当如果2个 a 的初始值都为0时,那么就没法从这里分辨了,必须通过重定位表来进一步查看,这些是后面章节的内容。)

COMMON Symbol & “.bss” 的区别
在 program.c 中,有个全局变量 c 没有初始化。

$ cat program.c
...
char c[10];
...

$ readelf -sW program.o
...
Num: Value Size Type Bind Vis Ndx Name
...
10: 0000000000000008 10 OBJECT GLOBAL DEFAULT COM c
...

为什么 c 的 Section Header index 是 SHN_COMMON(Ndx=COM),而不是指向 “.bss” Section Header index?另外 SHN_COMMON 的 Symbol 也表示关联到一个未初始化的公共块,那 COMMON 和 “.bss” 的区别是什么?

这里有个规范:

COMMON:对应没初始化的全局变量。
“.bss”:对应没初始化的 static 变量,初始值为 0 的 static 变量,初始值为 0 的全局变量。
看个例子:

// symbols.c
int a;
int a2 = 0;
int a3 = 3;
static int a4;
static int a5 = 0;
static int a6 = 4;

$ gcc -c symbols.c
$ readelf -sW symbols.o
...
Num: Value Size Type Bind Vis Ndx Name
...
5: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 a4
6: 0000000000000008 4 OBJECT LOCAL DEFAULT 3 a5
7: 0000000000000004 4 OBJECT LOCAL DEFAULT 2 a6
...
10: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM a
11: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 a2
12: 0000000000000000 4 OBJECT GLOBAL DEFAULT 2 a3

# 查看 Ndx = 2,3 时的 Section Header
$ readelf -SW symbols.o
...
Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
...
[ 2] .data PROGBITS 0000000000000000 000040 000008 00 WA 0 0 4
[ 3] .bss NOBITS 0000000000000000 000048 00000c 00 WA 0 0 4
...

由上可知:

a 是未初始化的全局变量,所以是个 COMMON Symbol(规范1)
a2 是初始值为0的全局变量,所以分配在 “.bss” (规范2)
a3 是初始值为3(不为0)的全局变量,所以分配在 “.data”
a4 是未初始化的 static 变量,所以分配在 “.bss”(规范2)
a5 是初始值为0的 static 变量,所以分配在 “.bss”(规范2)
a6 是初始值为4(不为0)的 static 变量,所以分配在 “.data”
这个规范源于链接器做 Symbol 解析时的方式。

Symbol 解析
链接器通过关联对 Symbol 的引用和它的定义来完成 Symbol 的解析。

这对于 Local Symbol 而言比较简单,因为编译器会保证 Local Symbol 的定义在同一对象文件中只有一份,否则编译就会报错。而 static 变量,只要不在同一作用域内出现重复定义(否则编译报错),那么就算在同一对象文件中出现多次,编译器也会为它们生成不同的 Symbol(上面已经提及)。

但是对于 Global Symbol,这个情况就有点复杂了。当编译器遇到一个 Symbol,却又没法在当前对象文件中找到它的定义,那么编译器就认为这个 Symbol 的定义存放在其他对象文件中,于是编译器就为这个 Symbol 在 Symbol Table 中插入一个对应的记录,然后留给链接器去处理。

那么,现在问题来了:如果链接器解析 Symbol 时,发现有多个定义可以跟它匹配(名字一样,但定义可以完全不同,譬如一个 int,一个是 char),那该如何选择?

以下是 Linux 编译系统所采取的策略:

编译器在给汇编器导出 Global Symbol 时,会给每个 Global Symbol 都附带上一个信息:strong 或者 weak;
汇编器再把这些信息编码到 Relocatable file 的 Symbol Table 中;
函数和已经初始化的全局变量都属于 strong Symbol,而没有初始化的全局变量则属于 weak Symbol。
查看 symbols.c 编译后的汇编文件:

$ gcc -S symbols.c
$ cat symbols.s
.file "symbols.c"
.comm a,4,4 ; .comm 表示 a 是个未初始化的 Global Symbol(weak)
# -----------------------------------
.globl a2 ;.globl + .bss 表示 a2 是个初始值为0的 Global Symbol(strong),分配在 .bss
.bss
.align 4
.type a2, @object
.size a2, 4
a2:
.zero 4
# -----------------------------------
.globl a3 ; .globl + .data 表示 a3 是个初始值不为0的 Global Symbol(strong),分配在 .data
.data
.align 4
.type a3, @object
.size a3, 4
a3:
.long 3
# -----------------------------------
.local a4 ; .local + .comm 表示 a4 是个未初始化或初始值为0的 Local Symbol,分配在 .bss
.comm a4,4,4
# -----------------------------------
.local a5 ; .local + .comm 表示 a5 是个未初始化或初始值为0的 Local Symbol,分配在 .bss
.comm a5,4,4
# -----------------------------------
.align 4
.type a6, @object ; 表示 a6 是个初始值不为0的 Local Symbol,分配在 .data
.size a6, 4
a6:
.long 4
# -----------------------------------
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits

汇编器就是根据这些信息对 symbols.o 中的 Symbol Table 进行编码。

有了 strong 和 weak 的概念后,当链接器遇到多个同名 Symbol 时,将使用以下规则进行处理:

多个 strong Symbol => 报错
1个 strong Symbol + 多个 weak Symbol => 使用 strong Symbol 的定义
没有 strong Symbol,只有多个 weak Symbol => 使用其中 size 最大的那个 weak Symbol 的定义
举例说明

多个 strong Symbol
例子1
// func.c
int func() {
return 0;
}

void main() {
func();
}


// func2.c
char func() {
return 'a';
}

$ gcc -o func func.c func2.c
/tmp/cct8WbLt.o: In function `func':
func2.c:(.text+0x0): multiple definition of `func'
/tmp/ccxdAjJx.o:func.c:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status

func 在两个对象文件中都是函数,都是 strong Symbol,因此链接期间会报错。

例子2
// fSym3.c
int func = 3;

$ gcc -o func func.c fSym3.c
/tmp/cc7LGOuc.o:(.data+0x0): multiple definition of `func'
/tmp/ccO9f6UR.o:func.c:(.text+0x0): first defined here
/usr/bin/ld: Warning: size of symbol `func' changed from 11 in /tmp/ccO9f6UR.o to 4 in /tmp/cc7LGOuc.o
/usr/bin/ld: Warning: type of symbol `func' changed from 2 to 1 in /tmp/cc7LGOuc.o
collect2: error: ld returned 1 exit status

func 在 func.c 是函数,在 fSym3.c 是初始化了的全局变量,都是 strong Symbol,因此链接期间会报错。

例子3
// global_var.c
int a = 3;

void main() {
a = 4;
}

// global_var2.c
char a = 'a';

$ gcc -o gv global_var.c global_var2.c
/tmp/ccqVwxwi.o:(.data+0x0): multiple definition of `a'
/tmp/cchohOvY.o:(.data+0x0): first defined here
/usr/bin/ld: Warning: size of symbol `a' changed from 4 in /tmp/cchohOvY.o to 1 in /tmp/ccqVwxwi.o
collect2: error: ld returned 1 exit status

同理,2个初始化了的同名全局变量,链接期间也会报错。

1 strong Symbol + 多个 weak Symbol
例子1
// f1.c
#include <stdio.h>

short a;

void main() {
printf("a: 0x%04x\n", a); // 打印 a 的16进制
}

// f2.c
char a = 0x01;

$ gcc -o f f1.c f2.c && ./f
/usr/bin/ld: Warning: alignment 1 of symbol `a' in /tmp/cc0bLEUk.o is smaller than 2 in /tmp/ccJKP7Jw.o
/usr/bin/ld: Warning: size of symbol `a' changed from 2 in /tmp/ccJKP7Jw.o to 1 in /tmp/cc0bLEUk.o
a: 0x0001

Warning 的意思是,Symbol a 在 f1.o 中是2字节,链接后找到的定义是1字节。这其实会引发奇怪的问题,下面会讲到。

例子2
// f3.c
short a = 0x0201;

$ gcc -o f f1.c f3.c && ./f
a: 0x0201

运行正常,没有 warning。

例子3
// f4.c
int a = 0x10000;

$ gcc -o f f1.c f4.c && ./f
/usr/bin/ld: Warning: size of symbol `a' changed from 2 in /tmp/ccHqp1Bm.o to 4 in /tmp/cc3gcmpj.o
a: 0x0000

Warning 告诉我们 Symbol a 在 f1.o 中是作为2字节使用的,但链接后实际占有了4字节的空间。f4.o 中 a 的值为 0x10000,对应到4个字节(地址从低往高)分别是:“00 00 01 00”(little endian),但 f1.o 还是只提取了最低地址的2个字节,也就是 “00 00”。

例子4
// f5.c
char a = 0x01;
char b = 0x02;

$ gcc -o f f1.c f5.c && ./f
/usr/bin/ld: Warning: alignment 1 of symbol `a' in /tmp/cc5QqKXe.o is smaller than 2 in /tmp/cc3CUiVm.o
/usr/bin/ld: Warning: size of symbol `a' changed from 2 in /tmp/cc3CUiVm.o to 1 in /tmp/cc5QqKXe.o
a: 0x0201

Warning 告诉我们,Symbol a 在 f1.o 中作为2字节使用,但是链接后 a 实际只占有1字节的空间,这时问题就来了。

$ objdump -d f1.o
...
0000000000000000 <main>:
...
4: 0f b7 05 00 00 00 00 movzwl 0x0(%rip),%eax # b <main+0xb>
...

从 f1.o 的汇编代码中可以看到用于读取 a 的指令是:movzwl。它的意思是:读取1个 word(w, 2 bytes),然后0扩展(z)成4字节(l),因此就算现在 a 实际只占有1字节的空间,指令还是照样读取2字节,而除 a 之外的那个字节,在内存中是紧跟在 a 之后。

$ readelf -SW f5.o
...
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
...
[ 2] .data PROGBITS 0000000000000000 000040 000002 00 WA 0 0 1

$ hexdump -C -s0x40 -n2 f5.o
00000040 01 02 |..|
00000042

于是,movzwl 指令实际读取到的是 “01 02” 2个字节,由于是 litte endian,所以最终解析出来的数值就是 0x0201。

例子5
// ff1.c
#include <stdio.h>

char a = 0x01;
char b = 0x02;

void f(void);

void main() {
f();
printf("a: 0x%02x\n", a);
printf("b: 0x%02x\n", b);
}

// ff2.c
short a;

void f() {
a = 0x0304;
}

Warning 告诉我们, Symbol a 在 ff1.o 中只占1字节,但是在 ff2.o 中是作为2字节使用的。当 ff2.o 中的 f 函数运行时,就会错误地改写了除 a 之外的字节,这个字节紧跟在 a 之后。

# 可以看到 b 紧跟着 a
$ readelf -sW ff1.o
...
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
...
9: 0000000000000000 1 OBJECT GLOBAL DEFAULT 3 a
10: 0000000000000001 1 OBJECT GLOBAL DEFAULT 3 b
...


$ objdump -d ff2.o
...
0000000000000000 <f>:
...
4: 66 c7 05 00 00 00 00 movw $0x304,0x0(%rip) # d <f+0xd>
...

f 函数中的指令 “movw $0x304,0x0(%rip)”,意思把 “0x04 0x03”(little endian) 写入从 a 开始连续的2个字节。从上图可以看出 b 紧跟在 a 之后,于是 b 也被改写了。

$ gcc -o ff ff1.c ff2.c && ./ff
/usr/bin/ld: Warning: alignment 1 of symbol `a' in /tmp/ccZ3iN36.o is smaller than 2 in /tmp/ccni1l3E.o
a: 0x04
b: 0x03

由此可见,weak Symbol 带来的问题是不少的,而且很容易引发 bug。
如果想避免,可以加上 -fno-common:

$ gcc -fno-common -o f f1.c f5.c
/tmp/ccQWsBi2.o:(.data+0x0): multiple definition of `a'
/tmp/cch62Cgr.o:(.bss+0x0): first defined here
/usr/bin/ld: Warning: size of symbol `a' changed from 2 in /tmp/cch62Cgr.o to 1 in /tmp/ccQWsBi2.o
collect2: error: ld returned 1 exit status

那么链接的时候就会报错。

0 strong Symbol + 多个 weak Symbol
当全部都是 weak Symbol 时,选择 size 最大的那个定义。

例子1
// c1.c
int aaaaa;

void main() {
}

// c2.c
char* aaaaa;

// c3.c
short aaaaa;

// c4.c
long aaaaa;

# int 比 char* 要小,所以选 char*
$ gcc -o c c1.c c2.c && readelf -sW c | grep aaaaa
53: 0000000000601038 8 OBJECT GLOBAL DEFAULT 26 aaaaa

# int 比 short 要大,所以选 int
$ gcc -o c c1.c c3.c && readelf -sW c | grep aaaaa
53: 0000000000601034 4 OBJECT GLOBAL DEFAULT 26 aaaaa

# int 比 long 要小,所以选 long
$ gcc -o c c1.c c4.c && readelf -sW c | grep aaaaa
53: 0000000000601038 8 OBJECT GLOBAL DEFAULT 26 aaaaa

从上面的论述可知,weak Symbol 和 strong Symbol 都是 Global Symbol。weak Symbol 其实就是 COMMON Symbol(st_shndx = SHN_COMMON);而 strong Symbol 则是一开始就分配好的(要么在 “.data”,要么在 “.bss”)。

Symbol Binding: STB_WEAK
从前面的章节已知 Symbol Binding 主要有 STB_LOCAL,STB_GLOBAL 和 STB_WEAK。而 STB_LOCAL 和 STB_GLOBAL 我们已经论述过了,接下来要讲的是 STB_WEAK。

STB_WEAK Symbol 跟上面说的 weak Symbol 很容易混淆,但其实它们完全不是一回事。为了清楚描述,下面用 COMMON Symbol 来代替 weak Symbol。

STB_WEAK Symbol 的作用是提供默认实现,当链接到其他包含有同名 strong Symbol 的对象文件时,STB_WEAK Symbol 则会被 strong Symbol 所替代。例如有些库外露了一些接口,同时提供了它的默认实现。开发者可以提供遵循这个接口的专有实现,在运行的时候就能替换掉默认实现。如果没有提供额外的实现,运行时也能使用默认的实现而不会报错。

以下是 STB_WEAK Symbol 的一些特定规则:

STB_WEAK Symbol 遇上 strong Symbol,选择 strong Symbol;
STB_WEAK Symbol 遇上 COMMON Symbol,选择 COMMON Symbol;
只有 STB_WEAK Symbol,没有其他 Symbol,选择首次出现的 WEAK Symbol;
STB_WEAK Symbol 和 strong Symbol 一样,都是一开始就分配好的(在 “.data” 或 “.bss”);
如果 STB_WEAK Symbol 未初始化,则值为0。
例子1
// default.c
#include <stdio.h>

__attribute__((weak)) int a;
__attribute__((weak)) int a2 = 0;
__attribute__((weak)) int a3 = 1;
int a4;

__attribute__((weak)) void f() {
printf("weak func, a=%d, a2=%d, a3=%d, a4=%d\n", a, a2, a3, a4);
}

void main() {
f();
}

$ gcc -o ft default.c && ./ft
weak func, a=0, a2=0, a3=1, a4=0


$ readelf -sW default.o
...
Num: Value Size Type Bind Vis Ndx Name
...
9: 0000000000000000 4 OBJECT WEAK DEFAULT 4 a
10: 0000000000000004 4 OBJECT WEAK DEFAULT 4 a2
11: 0000000000000000 4 OBJECT WEAK DEFAULT 3 a3
12: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM a4
...


$ readelf -SW default.o
...
Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
...
[ 3] .data PROGBITS 0000000000000000 000084 000004 00 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 000088 000008 00 WA 0 0 4
...

由上可知,a, a2, a3 都是 STB_WEAK Symbol,a4 是 COMMON Symbol。a 和 a2 都是分配在 “.bss”,a3 分配在 “.data”。(规则3,4,5)

例子2
// custom_func.c
#include <stdio.h>

void f() {
printf("custom func.\n");
}

$ gcc -o ft default.c custom_func.c && ./ft
custom func.

custom_func.o 中的 f 函数是个 strong Symbol,所以替代了 default.o 中的默认实现。(规则1)

例子3
// custom_var.c
int a = 100;
int a2 = 200;
int a3 = 300;
int a4 = 400;

$ gcc -o ft default.c custom_var.c && ./ft
weak func, a=100, a2=200, a3=300, a4=400

由上可知,custom_var.o 中的 a, a2, a3 和 a4 都是 strong Symbol,替代了 default.o 中的默认实现以及 COMMON Symbol。(规则1)

例子4
// weak.c
__attribute__((weak)) int a4 = 111;

$ gcc -o ft default.c weak.c && ./ft
weak func, a=0, a2=0, a3=1, a4=0

a4 在 default.o 中是个 COMMON Symbol,所以选择 COMMON Symbol。(规则2)

例子5
// weak2.c
__attribute__((weak)) int a3 = 333;

$ gcc -o ft default.c weak2.c && ./ft
weak func, a=0, a2=0, a3=1, a4=0

$ gcc -o ft weak2.c default.c && ./ft
weak func, a=0, a2=0, a3=333, a4=0

a3 在 default.o 和 weak2.o 中都是 STB_WEAK Symbol,所以哪个先出现就用哪个。(规则3)

小结
Symbol Table 中的 Symbol 主要有:

LOCAL Symbol,对应 static 函数和 static 变量,分配在 “.data” 和 “.bss”
GLOBAL Symbol,分成两类:
strong Symbol,对应函数和初始化了的全局变量,分配在 “.data” 和 “.bss”
COMMON symbol(weak Symbol),对应未初始化的全局变量
STB_WEAK Symbol,对应附带了 “attribute((weak))” 的函数和全局变量,分配在 “.data” 和 “.bss”
下一篇章,我们将讲解与 Symbol 密切相关的重定位。
————————————————
版权声明:本文为CSDN博主「懒惰的劳模」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/helowken2/article/details/113792555

posted @ 2022-01-19 00:04  Sky&Zhang  阅读(1059)  评论(0编辑  收藏  举报