上一篇:ELF 详解3 – Symbol Table & Symbol
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 密切相关的重定位。