談談程序鏈接及分段那些事
掃描二維碼
隨時隨地手機看文章
如果讀過我之前的文章就會知道,程序構建大概需要經歷四個過程:預處理、編譯、匯編、鏈接,這里主要介紹鏈接這一過程。
鏈接鏈的是什么?
鏈接鏈的就是目標文件,什么是目標文件?目標文件就是源代碼編譯后但未進行鏈接的那些中間文件,如Linux下的.o,它和可執(zhí)行文件的內容和結構很相似,格式幾乎是一樣的,可以看成是同一種類型的文件,Linux下統(tǒng)稱為ELF文件,這里介紹下ELF文件標準:
可重定位文件:Linux中的.o,這類文件包含代碼和數(shù)據,可被鏈接成可執(zhí)行文件或共享目標文件,例如靜態(tài)鏈接庫。
可執(zhí)行文件:可以直接執(zhí)行的文件,如/bin/bash文件。
共享目標文件:Linux中的.so,包含代碼和數(shù)據,一種是鏈接器可以使用這種文件和其它的可重定位文件和共享目標文件鏈接,另一種是動態(tài)鏈接器可以將幾個這種共享目標文件和可執(zhí)行文件結合,作為進程映像的一部分來執(zhí)行。
core dump文件:進程意外終止時,系統(tǒng)可以將該進程的地址空間的內容和其它信息存到coredump文件用于調試,如gdb。
我們可以使用command file來查看文件的格式:
file test.o; file /bin/bash;
目標文件的構成
目標文件主要分為文件頭、代碼段、數(shù)據段和其它。
文件頭:描述整個文件的文件屬性(文件是否可執(zhí)行、是靜態(tài)鏈接還是動態(tài)鏈接、入口地址、目標硬件、目標操作系統(tǒng)等信息),還包括段表,用來描述文件中各個段的數(shù)組,描述文件中各個段在文件中的偏移位置和段屬性。
代碼段:程序源代碼編譯后的機器指令。
數(shù)據段:數(shù)據段分為.data段和.bss段。
.data段內容:已經初始化的全局變量和局部靜態(tài)變量
.bss段內容:未初始化的全局變量和局部靜態(tài)變量,.bss段只是為未初始化的全局變量和局部靜態(tài)變量預留位置,本身沒有內容,不占用空間。
除了代碼段和數(shù)據段,還有.rodata段、.comment、字符串表、符號表和堆棧提示段等等,還可以自定義段。
.bss段不占用存儲空間?
看下面代碼:
int a[1000];
int b[1000] = {1};
int main() {
printf("程序喵\n");
return 0;
}
我們查看下文件大小和各個段大?。?br>
test gcc testlink.c -o
test ls -l
-rwxrwxrwx 1 wzq wzq 12368 May 30 08:48 test
test size
text data bss dec hex filename
1512 4616 4032 10160 27b0 test
再看這段初始化的代碼:
int a[1000] = {1};
int b[1000] = {1};
int main() {
printf("程序喵\n");
return 0;
}
再查看下文件大小和各個段大小:
test gcc testlink.c -o
test ls -l
-rwxrwxrwx 1 wzq wzq 16368 May 30 08:49 test
test size
text data bss dec hex filename
1512 8616 8 10136 2798 test
可以看到僅僅是做了一次初始化,文件大小就從12368變成了16368,正好是初始化了的那a[1000]的大小,這4000字節(jié)從.bss段移動到了.data段,程序大小增加了,這里可以看出.bss段不占據磁盤空間。
既然.bss段不占據空間,那它的大小和符號存在哪呢?
.bss段占據的大小存放在ELF文件格式中的段表(Section Table)中,段表存放了各個段的各種信息,比如段的名字、段的類型、段在elf文件中的偏移、段的大小等信息。同時符號存放在符號表.symtab中。
.bss不占據實際的磁盤空間,只在段表中記錄大小,在符號表中記錄符號。當文件加載運行時,才分配空間以及初始化。
其實程序里還有好多系統(tǒng)保留段,還可以自定義段,將某個變量放在自定義段,如下:
__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魔數(shù)、文件機器字節(jié)長度、數(shù)據存儲方式、版本、運行平臺、ABI版本、ELF重定位類型、硬件平臺、硬件平臺版本、入口地址、程序頭入口和長度、段表的位置和長度和段的數(shù)量。
查看段表的方法:
使用objdump查看ELF文件中包含的關鍵的段:
objdump -h test.o
file format elf64-x86-64 :
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000017 0000000000000000 0000000000000000 00000040 2**0
ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 00000057 2**0
ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000057 2**0
ALLOC
3 .rodata 00000010 0000000000000000 0000000000000000 00000057 2**0
ALLOC, LOAD, READONLY, DATA
4 .comment 0000002a 0000000000000000 0000000000000000 00000067 2**0
READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 00000091 2**0
READONLY
6 .eh_frame 00000038 0000000000000000 0000000000000000 00000098 2**3
ALLOC, LOAD, RELOC, READONLY, DATA
使用readelf查看ELF文件中包含的段:
readelf -S test.o
There are 13 section headers, starting at offset 0x2d0:
Section Headers:
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
.symtab SYMTAB 0000000000000000 000000d0
0000000000000120 0000000000000018 11 9 8
.strtab STRTAB 0000000000000000 000001f0
000000000000002b 0000000000000000 0 0 1
.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段的重定位表,鏈接器在處理目標文件時,需要對目標文件中的某些部位進行重定位,即代碼段和數(shù)據段那些對絕對地址的引用的位置,這些重定位的信息都會放在.rela.text中,.rel開頭的都是用于重定位。
LINK表示符號表的下標,INFO表示它作用于哪個段,值是相應段的下標。
字符串表(.strtab):保存普通字符串,比如符號名字。
段表字符串表(.shstrtab):保存段表中用到的字符串,比如段名。
ELF文件頭和段表都有各自的結構體,這里不列舉,只需要知道它里面存儲的是什么東西就好。
程序為什么要分成數(shù)據段和代碼段
數(shù)據和指令被映射到兩個虛擬內存區(qū)域,數(shù)據段對進程來說可讀寫,代碼段是只讀,這樣可以防止程序的指令被有意無意的改寫。
有利于提高程序局部性,現(xiàn)代CPU緩存一般被設計成數(shù)據緩存和指令緩存分離,分開對CPU緩存命中率有好處。
代碼段是可以共享的,數(shù)據段是私有的,當運行多個程序的副本時,只需要保存一份代碼段部分。
經典語錄:真正了不起的程序員對自己程序的每一個字節(jié)都了如指掌。
鏈接器通過什么進行的鏈接
鏈接的接口是符號,在鏈接中,將函數(shù)和變量統(tǒng)稱為符號,函數(shù)名和變量名統(tǒng)稱為符號名。鏈接過程的本質就是把多個不同的目標文件之間相互“粘”到一起,像玩具積木一樣各有凹凸部分,有固定的規(guī)則可以拼成一個整體。
可以將符號看作是鏈接中的粘合劑,整個鏈接過程基于符號才可以正確完成,符號有很多類型,主要有局部符號和外部符號,局部符號只在編譯單元內部可見,對于鏈接過程沒有作用,在目標文件中引用的全局符號,卻沒有在本目標文件中被定義的叫做外部符號,以及定義在本目標文件中的可以被其它目標文件引用的全局符號,在鏈接過程中發(fā)揮重要作用。
可以使用一些命令來查看符號信息:
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鏈接器腳本中的,如下面代碼中的符號:
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語言函數(shù)和變量的符號名基本就是函數(shù)名字變量名字,不同模塊如果有相同的函數(shù)或變量名字就會產生符號沖突無法鏈接成功的問題,所以C++引入了命名空間來解決這種符號沖突問題。同時為了支持函數(shù)重載C++也會根據函數(shù)名字以及命名空間以及參數(shù)類型生成特殊的符號名稱。
由于C語言和C++的符號修飾方式不同,C語言和C++的目標文件在鏈接時可能會報錯說找不到符號,所以為了C++和C兼容,引入了extern "C",當引用某個C語言的函數(shù)時加extern "C"告訴編譯器對此函數(shù)使用C語言的方式來鏈接,如果C++的函數(shù)用extern "C"聲明,則此函數(shù)的符號就是按C語言方式生成的。
以memset函數(shù)舉例,C語言中以C語言方式來鏈接,但是在C++中以C++方式來鏈接就會找不到這個memset的符號,所以需要使用extern "C"方式來聲明這個函數(shù),為了兼容C和C++,可以使用宏來判斷,用條件宏判斷當前是不是C++代碼,如果是C++代碼則extern "C"。
extern "C" {
void *memset(void *, int, size_t);
}
這種技巧幾乎在所有的系統(tǒng)頭文件中都會被用到。
強符號和弱符號
我們經常編程中遇到的multiple definition of 'xxx',指的是多個目標中有相同名字的全局符號的定義,產生了沖突,這種符號的定義指的是強符號。有強符號自然就有弱符號,編譯器默認函數(shù)和初始化了的全局變量為強符號,未初始化的全局變量為弱符號。__attribute__((weak))可以定義弱符號。
extern int ext;
int weak; // 弱符號
int strong = 1; // 強符號
__attribute__((weak)) int weak2 = 2; // 弱符號
int main() {
return 0;
}
鏈接器規(guī)則:
不允許強符號被多次定義,多次定義就會multiple definition of 'xxx'
一個符號在一個目標文件中是強符號,在其它目標文件中是弱符號,選擇強符號
一個符號在所有目標文件中都是弱符號,選擇占用空間最大的符號,int類型和double類型選double類型
強引用和弱引用
一般引用了某個函數(shù)符號,而這個函數(shù)在任何地方都沒有被定義,則會報錯error: undefined reference to 'xxx',這種符號引用稱為強引用。與此對應的則有弱引用,鏈接器對強引用弱引用的處理過程幾乎一樣,只是對于未定義的弱引用,鏈接器不會報錯,而是默認其是一個特殊的值。
__attribute__ ((weak)) void foo();
int main() {
foo();
return 0;
}
這里可以編譯鏈接成功,運行此可執(zhí)行程序,會報非法地址錯誤,所以可以做下面的改進:
__attribute__ ((weak)) void foo();
int main() {
if (foo) {
foo();
}
return 0;
}
這種強引用弱引用對于庫來說十分有用,庫中的弱引用可以被用戶定義的強引用所覆蓋,這樣程序就可以使用自定義版本的庫函數(shù),可以將引用定義為弱引用,如果去掉了某個功能,也可以正常連接接,想增加相應功能還可以直接增加強引用,方便程序的裁剪和組合。
如下:
// test2.c
void foo() {
printf("foo2\n");
}
// test3.c
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本身的語言特性。
參考
《程序員的自我修養(yǎng)---鏈接、裝載與庫》
https://blog.csdn.net/guyongqiangx/article/details/53067434?locationNum=6&fps=1
https://blog.csdn.net/Move_now/article/details/69307890
免責聲明:本文內容由21ic獲得授權后發(fā)布,版權歸原作者所有,本平臺僅提供信息存儲服務。文章僅代表作者個人觀點,不代表本平臺立場,如有問題,請聯(lián)系我們,謝謝!