谈谈程序链接及分段那些事
The following article is from 程序喵大人 Author 程序喵大人
程序构建大概需要经历四个过程:预处理、编译、汇编、链接,这里主要介绍链接这一过程。
链接链的是什么?
链接链的就是目标文件,什么是目标文件?目标文件就是源代码编译后但未进行链接的那些中间文件,如Linux下的.o,它和可执行文件的内容和结构很相似,格式几乎是一样的,可以看成是同一种类型的文件,Linux下统称为ELF文件,这里介绍下ELF文件标准:
可重定位文件:Linux中的.o,这类文件包含代码和数据,可被链接成可执行文件或共享目标文件,例如静态链接库。
可执行文件:可以直接执行的文件,如/bin/bash文件。
共享目标文件:Linux中的.so,包含代码和数据,一种是链接器可以使用这种文件和其它的可重定位文件和共享目标文件链接,另一种是动态链接器可以将几个这种共享目标文件和可执行文件结合,作为进程映像的一部分来执行。
core dump文件:进程意外终止时,系统可以将该进程的地址空间的内容和其它信息存到coredump文件用于调试,如gdb。
我们可以使用command file来查看文件的格式:
file test.o; file /bin/bash;
目标文件的构成
目标文件主要分为文件头、代码段、数据段和其它。文件头:描述整个文件的文件属性(文件是否可执行、是静态链接还是动态链接、入口地址、目标硬件、目标操作系统等信息),还包括段表,用来描述文件中各个段的数组,描述文件中各个段在文件中的偏移位置和段属性。代码段:程序源代码编译后的机器指令。数据段:数据段分为.data段和.bss段。.data段内容:已经初始化的全局变量和局部静态变量.bss段内容:未初始化的全局变量和局部静态变量,.bss段只是为未初始化的全局变量和局部静态变量预留位置,本身没有内容,不占用空间。除了代码段和数据段,还有.rodata段、.comment、字符串表、符号表和堆栈提示段等等,还可以自定义段。
.bss段不占用存储空间?
看下面代码:
#include <stdio.h>
int a[1000];
int b[1000] = {1};
int main() {
printf("程序喵\n");
return 0;
}
我们查看下文件大小和各个段大小:
$ gcc testlink.c -o test
$ ls -l test
-rwxrwxrwx 1 wzq wzq 12368 May 30 08:48 test
$ size test
text data bss dec hex filename
1512 4616 4032 10160 27b0 test
再看这段初始化的代码:
#include <stdio.h>
int a[1000] = {1};
int b[1000] = {1};
int main() {
printf("程序喵\n");
return 0;
}
再查看下文件大小和各个段大小:
$ gcc testlink.c -o test
$ ls -l test
-rwxrwxrwx 1 wzq wzq 16368 May 30 08:49 test
$ size test
text data bss dec hex filename
1512 8616 8 10136 2798 test
可以看到仅仅是做了一次初始化,文件大小就从12368变成了16368,正好是初始化了的那a[1000]的大小,这4000字节从.bss段移动到了.data段,程序大小增加了,这里可以看出.bss段不占据磁盘空间。
既然.bss段不占据空间,那它的大小和符号存在哪呢?
.bss段占据的大小存放在ELF文件格式中的段表(Section Table)中,段表存放了各个段的各种信息,比如段的名字、段的类型、段在elf文件中的偏移、段的大小等信息。同时符号存放在符号表.symtab中。
.bss不占据实际的磁盘空间,只在段表中记录大小,在符号表中记录符号。当文件加载运行时,才分配空间以及初始化。
其实程序里还有好多系统保留段,还可以自定义段,将某个变量放在自定义段,如下:
__attribute__((section("Custom"))) int global = 1;
可以使用一些工具查看ELF文件头以及各个段的内容:
查看文件头:
$ readelf -h test.o
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 720 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 13
Section header string table index: 12
可以使用readelf查看文件头:ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度和段的数量。
查看段表的方法:使用objdump查看ELF文件中包含的关键的段:
$ objdump -h test.o
test.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000017 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 00000057 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000057 2**0
ALLOC
3 .rodata 00000010 0000000000000000 0000000000000000 00000057 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002a 0000000000000000 0000000000000000 00000067 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 00000091 2**0
CONTENTS, READONLY
6 .eh_frame 00000038 0000000000000000 0000000000000000 00000098 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
使用readelf查看ELF文件中包含的段:
$ readelf -S test.o
There are 13 section headers, starting at offset 0x2d0:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000017 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000220
0000000000000030 0000000000000018 I 10 1 8
[ 3] .data PROGBITS 0000000000000000 00000057
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .bss NOBITS 0000000000000000 00000057
0000000000000000 0000000000000000 WA 0 0 1
[ 5] .rodata PROGBITS 0000000000000000 00000057
0000000000000010 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 00000067
000000000000002a 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 00000091
0000000000000000 0000000000000000 0 0 1
[ 8] .eh_frame PROGBITS 0000000000000000 00000098
0000000000000038 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000250
0000000000000018 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 000000d0
0000000000000120 0000000000000018 11 9 8
[11] .strtab STRTAB 0000000000000000 000001f0
000000000000002b 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 00000268
0000000000000061 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
objdump只能查看关键的段,而readelf可以查看所有段。其中,.rela.text是针对.text段的重定位表,链接器在处理目标文件时,需要对目标文件中的某些部位进行重定位,即代码段和数据段那些对绝对地址的引用的位置,这些重定位的信息都会放在.rela.text中,.rel开头的都是用于重定位。LINK表示符号表的下标,INFO表示它作用于哪个段,值是相应段的下标。字符串表(.strtab):保存普通字符串,比如符号名字。段表字符串表(.shstrtab):保存段表中用到的字符串,比如段名。ELF文件头和段表都有各自的结构体,这里不列举,只需要知道它里面存储的是什么东西就好。
程序为什么要分成数据段和代码段
数据和指令被映射到两个虚拟内存区域,数据段对进程来说可读写,代码段是只读,这样可以防止程序的指令被有意无意的改写。
有利于提高程序局部性,现代CPU缓存一般被设计成数据缓存和指令缓存分离,分开对CPU缓存命中率有好处。
代码段是可以共享的,数据段是私有的,当运行多个程序的副本时,只需要保存一份代码段部分。
经典语录:
真正了不起的程序员对自己程序的每一个字节都了如指掌。
链接器通过什么进行的链接
链接的接口是符号,在链接中,将函数和变量统称为符号,函数名和变量名统称为符号名。链接过程的本质就是把多个不同的目标文件之间相互“粘”到一起,像玩具积木一样各有凹凸部分,有固定的规则可以拼成一个整体。可以将符号看作是链接中的粘合剂,整个链接过程基于符号才可以正确完成,符号有很多类型,主要有局部符号和外部符号,局部符号只在编译单元内部可见,对于链接过程没有作用,在目标文件中引用的全局符号,却没有在本目标文件中被定义的叫做外部符号,以及定义在本目标文件中的可以被其它目标文件引用的全局符号,在链接过程中发挥重要作用。可以使用一些命令来查看符号信息:command nm:
$ nm test.o
U _GLOBAL_OFFSET_TABLE_
0000000000000000 T main
U puts
command objdump:
objdump -t test.o
test.o: file format elf64-x86-64
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 test_c.cc
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .rodata 0000000000000000 .rodata
0000000000000000 l d .note.GNU-stack 0000000000000000 .note.GNU-stack
0000000000000000 l d .eh_frame 0000000000000000 .eh_frame
0000000000000000 l d .comment 0000000000000000 .comment
0000000000000000 g F .text 0000000000000017 main
0000000000000000 *UND* 0000000000000000 _GLOBAL_OFFSET_TABLE_
0000000000000000 *UND* 0000000000000000 puts
command readelf:
readelf -s test.o
Symbol table '.symtab' contains 12 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS test_c.cc
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000000 0 SECTION LOCAL DEFAULT 7
7: 0000000000000000 0 SECTION LOCAL DEFAULT 8
8: 0000000000000000 0 SECTION LOCAL DEFAULT 6
9: 0000000000000000 23 FUNC GLOBAL DEFAULT 1 main
10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts
有些符号在程序中并没有被定义,但是可以直接声明并且引用的符号称为特殊符号,这些符号其实是定义在ld链接器脚本中的,如下面代码中的符号:
#include <stdio.h>
extern char __executable_start[];
extern char etext[], _etext[], __etext[];
extern char edata[], _edata[];
extern char end[], _end[];
int main() {
printf("Executable Start %X \n", __executable_start);
printf("Text End %X %X %X \n", etext, _etext, __etext);
printf("Data End %X %X \n", edata, _edata);
printf("Executable End %X %X \n", end, _end);
return 0;
}
输出:
$ ./a.out
Executable Start 68800000
Text End 6880075D 6880075D 6880075D
Data End 68A01010 68A01010
Executable End 68A01018 68A01018
为什么需要extern "C"
C语言函数和变量的符号名基本就是函数名字变量名字,不同模块如果有相同的函数或变量名字就会产生符号冲突无法链接成功的问题,所以C++引入了命名空间来解决这种符号冲突问题。同时为了支持函数重载C++也会根据函数名字以及命名空间以及参数类型生成特殊的符号名称。由于C语言和C++的符号修饰方式不同,C语言和C++的目标文件在链接时可能会报错说找不到符号,所以为了C++和C兼容,引入了extern "C",当引用某个C语言的函数时加extern "C"告诉编译器对此函数使用C语言的方式来链接,如果C++的函数用extern "C"声明,则此函数的符号就是按C语言方式生成的。以memset函数举例,C语言中以C语言方式来链接,但是在C++中以C++方式来链接就会找不到这个memset的符号,所以需要使用extern "C"方式来声明这个函数,为了兼容C和C++,可以使用宏来判断,用条件宏判断当前是不是C++代码,如果是C++代码则extern "C"。
#ifdef __cplusplus
extern "C" {
#endif
void *memset(void *, int, size_t);
#ifdef __cplusplus
}
#endif
这种技巧几乎在所有的系统头文件中都会被用到。
强符号和弱符号
我们经常编程中遇到的multiple definition of 'xxx',指的是多个目标中有相同名字的全局符号的定义,产生了冲突,这种符号的定义指的是强符号。有强符号自然就有弱符号,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。__attribute__((weak))可以定义弱符号。
extern int ext;
int weak; // 弱符号
int strong = 1; // 强符号
__attribute__((weak)) int weak2 = 2; // 弱符号
int main() {
return 0;
}
链接器规则:
不允许强符号被多次定义,多次定义就会multiple definition of 'xxx'
一个符号在一个目标文件中是强符号,在其它目标文件中是弱符号,选择强符号
一个符号在所有目标文件中都是弱符号,选择占用空间最大的符号,int类型和double类型选double类型
强引用和弱引用
一般引用了某个函数符号,而这个函数在任何地方都没有被定义,则会报错error: undefined reference to 'xxx',这种符号引用称为强引用。与此对应的则有弱引用,链接器对强引用弱引用的处理过程几乎一样,只是对于未定义的弱引用,链接器不会报错,而是默认其是一个特殊的值。
__attribute__ ((weak)) void foo();
int main() {
foo();
return 0;
}
这里可以编译链接成功,运行此可执行程序,会报非法地址错误,所以可以做下面的改进:
__attribute__ ((weak)) void foo();
int main() {
if (foo) {
foo();
}
return 0;
}
这种强引用弱引用对于库来说十分有用,库中的弱引用可以被用户定义的强引用所覆盖,这样程序就可以使用自定义版本的库函数,可以将引用定义为弱引用,如果去掉了某个功能,也可以正常连接接,想增加相应功能还可以直接增加强引用,方便程序的裁剪和组合。如下:
// test2.c
#include <stdio.h>
void foo() {
printf("foo2\n");
}
// test3.c
#include <stdio.h>
void foo() {
printf("foo3\n");
}
使用如下方式链接:
$ gcc test.c -o a.out
$ ./a.out
什么都不会输出
$ gcc test.c test2.c -o a.out
$ ./a.out
foo2
$ gcc test.c test3.c -o a.out
$ ./a.out
foo3
对于弱符号和弱引用,其都仅是GNU工具链GCC对C语言语法的扩展,并不是C本身的语言特性。
- EOF -
关注『CPP开发者』
看精选C++技术文章 . 加C++开发者专属圈子
点赞和在看就是最大的支持❤️