靜態(tài)鏈接中的包順序

原文地址:https://eli.thegreenplace.net/2013/07/09/library-order-in-static-linking
首先我們來(lái)看一個(gè)范例:

volatile char src[] = {1, 2, 3, 4, 5};
volatile char dst[50] = {0};

void* memcpy(void* dst, void* src, int len);

int main(int argc, const char* argv[])
{
    memcpy(dst, src, sizeof(src));
    return dst[4];
}

這段代碼會(huì)如我們所愿返回5。如果假設(shè)這段代碼是某個(gè)大型項(xiàng)目的一部分,二這個(gè)大型項(xiàng)目又包含了一些其他的庫(kù),其中一個(gè)庫(kù)包含了下面的這樣一段代碼:

void memcpy(char* aa, char* bb, char* cc) {
    int i;
    for (i = 0; i < 100; ++i) {
        cc[i] = aa[i] + bb[i];
    }
}

如果之前的那段代碼與這個(gè)庫(kù)鏈接在一起,將會(huì)發(fā)生什么呢?仍然返回5還是返回其他值,或者是閃退。答案是:看情況,有可能返回正確的值,或者是段錯(cuò)誤。這取決于項(xiàng)目中的對(duì)象與庫(kù)在鏈接器中被處理的順序。如果你完全理解了為什么這需要取決于鏈接順序,以及如何避免這種問(wèn)題(有時(shí)候是一些更嚴(yán)峻的問(wèn)題,如循環(huán)引用),那么你就可以跳過(guò)這篇文章了。

基礎(chǔ)知識(shí)

首先澄清一下,本文的所有示例都是使用Linux上的gcc和binutils toolchain,對(duì)于clang也同樣適用;本文僅僅針對(duì)編譯和鏈接時(shí)刻的靜態(tài)鏈接過(guò)程進(jìn)行討論。
為了理解為什么鏈接順序如此重要,首先就要知道鏈接器是如何工作的。第一個(gè)概念就是一個(gè)對(duì)象文件會(huì)導(dǎo)出兩張符號(hào)表,exported符號(hào)表(供其他對(duì)象和庫(kù)使用),imported符號(hào)表,表示引用了哪些其他的對(duì)象或庫(kù)。在C語(yǔ)言中,如:

int imported(int);

static int internal(int x) {
    return x * 2;
}

int exported(int x) {
    return imported(x) * internal(x);
}

這里特意使用兩種符號(hào)表的名字來(lái)命名函數(shù)名,編譯后可以看到如下的符號(hào)表:

$ gcc -c x.c
$ nm x.o
000000000000000e T exported
                                 U imported
0000000000000000 t internal

這里exported是external符號(hào),在對(duì)象文件中定義,對(duì)外部可見(jiàn);imported是未定義的符號(hào),也就是說(shuō)需要鏈接器從某些地方找到他們。在接下來(lái)我們討論的鏈接器工作流程中,“為定義”符號(hào)就是用來(lái)表示需要鏈接器從某處找到他們的符號(hào)。internal表示在對(duì)象哪定義但是外部不可見(jiàn)。
一個(gè)庫(kù)文件就是一堆對(duì)象文件的集合,創(chuàng)建一個(gè)庫(kù)文件的過(guò)程,就是把一堆的對(duì)象文件放到一起,別無(wú)其他。

鏈接過(guò)程

鏈接命令

gcc main.o -L/some/lib/dir -lfoo -lbar -lbaz

c或者c++的鏈接通常是通過(guò)編譯器gcc來(lái)驅(qū)動(dòng)的,因?yàn)間cc知道如何向鏈接器提供正確的命令行參數(shù),包括支持的庫(kù)等。
命令行中的參數(shù)順序就是鏈接器鏈接的順序,鏈接器會(huì)做如下的工作:

  • 鏈接器維護(hù)一個(gè)符號(hào)表,符號(hào)表中主要維護(hù)了兩個(gè)列表
    --目前為止所有對(duì)象和庫(kù)所提供的exported符號(hào)
    --目前為止所有對(duì)象和庫(kù)需要用到的未定義符號(hào)
  • 當(dāng)鏈接器鏈接一個(gè)新的對(duì)象文件的時(shí)候
    -- 該文件生成的exported符號(hào)被添加到上面提到的exported符號(hào)表中,如果未定義符號(hào)表中有相同的符號(hào),那么從為定義符號(hào)表中刪除,因?yàn)楝F(xiàn)在它已經(jīng)被找到了。如果在exported符號(hào)表中已經(jīng)存在該符號(hào),那么會(huì)得到一個(gè)“重復(fù)定義”的錯(cuò)誤,不同的對(duì)象生導(dǎo)出了相同的符號(hào),鏈接器無(wú)法工作
    --該文件需要的imported符號(hào)中,那些無(wú)法從現(xiàn)有的exported符號(hào)表中找到的符號(hào),會(huì)被添加到未定義符號(hào)表中
  • 當(dāng)鏈接器鏈接一個(gè)新的庫(kù)文件的時(shí)候,情況會(huì)有所不同。鏈接器會(huì)遍歷庫(kù)中的所有文件,針對(duì)每一個(gè)文件執(zhí)行下面的動(dòng)作
    -- 如果該文件的exported符號(hào)中的任何一個(gè)在未定義符號(hào)表中可以被找到,那么該對(duì)象被鏈接,并執(zhí)行如下的步驟
    -- 如果對(duì)象被鏈接,那么將會(huì)按照單個(gè)對(duì)象文件的流程,將它的未定義和exported符號(hào)添加到符號(hào)表中
    -- 最后,如果庫(kù)中的任何一個(gè)文件被鏈接了,那么將會(huì)重新掃描該庫(kù),因?yàn)閹?kù)中的某個(gè)文件所需要的未定義符號(hào)可能正好是庫(kù)中其他的文件所生成的exported 符號(hào),只不過(guò)第一次掃描的時(shí)候,因?yàn)槲募樞虻膯?wèn)題,該對(duì)象被跳過(guò)了(因?yàn)樵谖炊x符號(hào)表中還未出現(xiàn)它),沒(méi)有被鏈接
    當(dāng)所有的鏈接完成后,鏈接器會(huì)檢查符號(hào)表,如果在未定義表中還有未被鏈接的符號(hào),那么鏈接器會(huì)拋出一個(gè)“未定義”錯(cuò)誤。例如,當(dāng)你創(chuàng)建了一惡搞可執(zhí)行程序,但是卻忘了包含main方法,那么你就會(huì)得到如下報(bào)錯(cuò):
/usr/lib/x86_64-linux-gnu/crt1.o: In function '_start':
(.text+0x20): undefined reference to 'main'
collect2: ld returned 1 exit status

這里需要注意的是,當(dāng)鏈接器對(duì)某個(gè)庫(kù)工作了之后,就不會(huì)再管他了。就算它本可以導(dǎo)出被其他庫(kù)需要的符號(hào)。鏈接器重新掃描庫(kù)文件的情況就只有一種,就是上面提到的,庫(kù)中有文件被鏈接到程序中的時(shí)候,庫(kù)中的所有其他文件都會(huì)被重新掃描一遍。當(dāng)然, 向鏈接器傳入不同的flag參數(shù)可以修改默認(rèn)的流程,后面會(huì)再講。
另外需要注意的一點(diǎn)事,當(dāng)庫(kù)中的某個(gè)對(duì)象文件所導(dǎo)出的exported符號(hào)已經(jīng)在符號(hào)表的exported列表中存在的時(shí)候,這個(gè)文件是會(huì)被略過(guò)不被鏈接的。這是靜態(tài)鏈接中非常重要的一點(diǎn)。C庫(kù)就非常依賴這個(gè)特性,基本上都是以函數(shù)作為切分對(duì)象文件的單元。因此,如果你的代碼中只使用了strlen,那么libc.a中只有strlen.o會(huì)被鏈接,你的可執(zhí)行單元會(huì)非常小。

示例

首先來(lái)定義兩個(gè)對(duì)象

$ cat simplefunc.c
int func(int i) {
    return i + 21;
}

$ cat simplemain.c
int func(int);

int main(int argc, const char* argv[])
{
    return func(argc);
}

$ gcc -c simplefunc.c
$ gcc -c simplemain.c
$ gcc simplefunc.o simplemain.o
$ ./a.out ; echo $?
22

一起工作正常,因?yàn)檫@里都是對(duì)象文件,因此鏈接的順序無(wú)關(guān)緊要,對(duì)象總是會(huì)被鏈接到程序中。將他們調(diào)換順序,依然可以正常工作:

$ gcc simplemain.o simplefunc.o
$ ./a.out ; echo $?
22

現(xiàn)在我們將simplefunc編譯成一個(gè)靜態(tài)庫(kù)

$ ar r libsimplefunc.a simplefunc.o
$ ranlib libsimplefunc.a
$ gcc  simplemain.o -L. -lsimplefunc
$ ./a.out ; echo $?
22

一切正常,但是如果此時(shí)我們將鏈接的順序調(diào)換一下:

$ gcc  -L. -lsimplefunc  simplemain.o
simplemain.o: In function 'main':
simplemain.c:(.text+0x15): undefined reference to 'func'
collect2: ld returned 1 exit status

通過(guò)上面的講解,這個(gè)問(wèn)題就很容易理解了。當(dāng)鏈接器遇到libsimplefunc.a的時(shí)候,還沒(méi)有處理過(guò)simplemain.o,因此func從未出現(xiàn)在未定義符號(hào)表中,當(dāng)鏈接器檢查靜態(tài)庫(kù)中的simplefunc.o的時(shí)候,發(fā)現(xiàn)他的exported的符號(hào)未func,但是符號(hào)表中并不需要這個(gè)符號(hào),因此這個(gè)對(duì)象文件就不會(huì)被鏈接到程序中。后面當(dāng)鏈接器搜索simplemain.o的時(shí)候,發(fā)現(xiàn)了需要func符號(hào),將它添加到未定義符號(hào)表中,此時(shí)鏈接器鏈接完所有的文件,發(fā)現(xiàn)仍存在未定義符號(hào),于是報(bào)錯(cuò)。
在正常工作的順序下,simplemain.o掀背處理,func被添加到未定義符號(hào)表中,然后鏈接靜態(tài)庫(kù)的時(shí)候,發(fā)現(xiàn)simplefunc.o導(dǎo)出的func符號(hào)正好是在未定義符號(hào)表中。
這里我們看到,在鏈接的過(guò)程中非常重要的一條準(zhǔn)則

  • 如果庫(kù)a需要庫(kù)b中的符號(hào),那么在鏈接命令的參數(shù)中,a應(yīng)該出現(xiàn)在b之前

循環(huán)依賴

雖然上面的準(zhǔn)則非常簡(jiǎn)單,但是在現(xiàn)實(shí)中,a與b相互依賴的情況還是非常常見(jiàn)的,那么此時(shí)應(yīng)該怎么辦呢?是否可以在參數(shù)列表中讓a同時(shí)出現(xiàn)的b的前面和后面呢?
來(lái)看如下的兩個(gè)文件:

$ cat func_dep.c
int bar(int);

int func(int i) {
    return bar(i + 1);
}
$ cat bar_dep.c
int func(int);

int bar(int i) {
    if (i > 3)
        return i;
    else
        return func(i);
}

這兩個(gè)文件相互依賴,如果按照如下的順序鏈接,會(huì)報(bào)錯(cuò):

$ gcc  simplemain.o -L.  -lbar_dep -lfunc_dep
./libfunc_dep.a(func_dep.o): In function 'func':
func_dep.c:(.text+0x14): undefined reference to 'bar'
collect2: ld returned 1 exit status

如果反過(guò)來(lái)就ok:

$ gcc  simplemain.o -L. -lfunc_dep -lbar_dep
$ ./a.out ; echo $?
4

按照上面的流程,這解釋得通。然后將這個(gè)例子再?gòu)?fù)雜一點(diǎn):

$ cat bar_dep.c
int func(int);
int frodo(int);

int bar(int i) {
    if (i > 3)
        return frodo(i);
    else
        return func(i);
}

$ cat frodo_dep.c
int frodo(int i) {
    return 6 * i;
}

然后重新編譯這些文件,并創(chuàng)建libfunc_dep.a庫(kù):

$ ar r libfunc_dep.a func_dep.o frodo_dep.o
$ ranlib libfunc_dep.a

這個(gè)時(shí)候的依賴關(guān)系如下:



這種情況下,不管怎么樣的順序,都是會(huì)報(bào)錯(cuò)的

$ gcc  -L. simplemain.o -lfunc_dep -lbar_dep
./libbar_dep.a(bar_dep.o): In function 'bar':
bar_dep.c:(.text+0x17): undefined reference to 'frodo'
collect2: ld returned 1 exit status
$ gcc  -L. simplemain.o -lbar_dep -lfunc_dep
./libfunc_dep.a(func_dep.o): In function 'func':
func_dep.c:(.text+0x14): undefined reference to 'bar'
collect2: ld returned 1 exit status

這個(gè)時(shí)候可以在參數(shù)列表中重復(fù)提供參數(shù),來(lái)保證所有的符號(hào)被找到:

$ gcc  -L. simplemain.o -lfunc_dep -lbar_dep -lfunc_dep
$ ./a.out ; echo $?
24

通過(guò)flag控制鏈接過(guò)程

之前提到過(guò)可以通過(guò)flag參數(shù)來(lái)控制鏈接的過(guò)程。例如針對(duì)互相依賴的情況,可以通過(guò)--start-group和--end-group參數(shù)(man ld中對(duì)這兩個(gè)參數(shù)的解釋):

--start-group archives --end-group

The specified archives are searched repeatedly until no new undefined references are created. Normally, an archive is searched only once in the order that it is specified on the command line. If a symbol in that archive is needed to resolve an undefined symbol referred to by an object in an archive that appears later on the command line, the linker would not be able to resolve that reference. By grouping the archives, they all be searched repeatedly until all possible references are resolved.

Using this option has a significant performance cost. It is best to use it only when there are unavoidable circular references between two or more archives.

針對(duì)上面的例子:

$ gcc simplemain.o -L. -Wl,--start-group -lbar_dep -lfunc_dep -Wl,--end-group
$ ./a.out ; echo $?
24

注意到上面的"significant performance cost"警告,這也是為什么默認(rèn)流程不支持互相引用的原因。如果讓鏈接器反復(fù)重新掃描庫(kù)文件,直到不會(huì)導(dǎo)出新的exported符號(hào)為止,這回非常影響效率。因?yàn)殒溄邮蔷幾g程序中非常重要的一個(gè)環(huán)節(jié),他針對(duì)整個(gè)程序同時(shí)還非常消耗內(nèi)存,因此最好是能夠在大部分情況以最高效的方式完成,針對(duì)特殊情況使用參數(shù)的形式來(lái)處理。
針對(duì)循環(huán)引用的情況,還可以通過(guò)--undefined標(biāo)記來(lái)告訴鏈接器,我想要將它添加到未定義列表中,這樣可以在只提供一次庫(kù)參數(shù)的情況下,完成鏈接:

$ gcc simplemain.o -L. -Wl,--undefined=bar -lbar_dep -lfunc_dep
$ ./a.out ; echo $?
24

回到開(kāi)始的例子

回到文章開(kāi)始的例子,假設(shè)我們?cè)诹硗獾膸?kù)libstray_memcpy.a中定義了memcpy方法,同時(shí)被鏈接到程序中

$ gcc  -L. main_using_memcpy.o -lstray_memcpy
$ ./a.out
Segmentation fault (core dumped)

出錯(cuò)是因?yàn)?lstray_memcpy在main_using_memcpy.o之后,他被連接到了程序中,但是如果我們將順序反轉(zhuǎn)一下:

$ gcc  -L. -lstray_memcpy main_using_memcpy.o
$ ./a.out ; echo $?
5

程序運(yùn)行正常,因?yàn)殡m然我們沒(méi)有顯示地要求,但是gcc還會(huì)讓鏈接器去鏈接C庫(kù)。gcc完整的鏈接觸發(fā)命令是非常復(fù)雜的,可以通過(guò)傳入-###參數(shù)來(lái)查看,但是在這種情況下基本上類似于:

$ gcc  -L. -lstray_memcpy main_using_memcpy.o -lc

當(dāng)鏈接器遇到-lstray_memcpy的時(shí)候,因?yàn)榇藭r(shí)的未定義符號(hào)表中尚未出現(xiàn)memcpy,因此自定義的對(duì)象文件不會(huì)被鏈接到程序中,直到處理main_using_memcpy.o的時(shí)候,才會(huì)發(fā)現(xiàn)自己需要memcpy符號(hào),然后在處理-lc的時(shí)候,標(biāo)準(zhǔn)庫(kù)中可以導(dǎo)出memcpy符號(hào)的文件會(huì)被鏈接到程序中,因此此時(shí)memcpy在未定義符號(hào)表中。

總結(jié)

鏈接器處理對(duì)象文件和庫(kù)文件的方式非常簡(jiǎn)單,只要理解了,那么鏈接中的很多錯(cuò)誤都很好理解。如果還是遇到了無(wú)法理解的錯(cuò)誤,那么文中提到的兩個(gè)命令可以幫助你調(diào)試問(wèn)題:nm(查看整個(gè)對(duì)象文件或庫(kù)的符號(hào)表);gcc的-### flag參數(shù),可以完整的展示出傳遞給底層的參數(shù)

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容