連結的思考

kangheng發表於2019-05-08

引言

最近做一些工程,經常遇到連結錯誤,為此翻閱了相關的資料,梳理了一下編譯連結的流程和原理。程式語言分為編譯型和解釋型,編譯型語言是用編譯器將高階語言翻譯成計算機可執行的低階語言;而解釋型語言是使用直譯器是將低階語言“提升”成高階語言。解釋型語言一次執行一句,缺少程式的全域性資訊,直譯器中包含大量的“if”“else”判斷,因此速度較慢,但是一次執行一句的方式卻增加了語言靈活性,直觀來說python使用起來比c++方便多了吧;而編譯型語言,需要將程式整體進行編譯,編譯器知道程式的全域性資訊,可以省略一些“if”、“else”的判斷。所以編譯型語言比解釋型語言使用麻煩些,但是執行速度卻要快得多。C/C++ 就屬於編譯型語言,一個大的C/C++工程往往由大量的原始碼檔案組成,這些原始碼通過編譯後會得到一個個“.o”的“零部件”(可重定位檔案),而工程中的“.h”檔案就像是這些零部件的連線指南,連結器的工作就是將“零部件”通過“.h”.檔案組裝成可執行檔案。有時候這些“零部件”太多了,將這些相關的“零部件”放在一起就組成了一個庫(.lib or .a),“零部件”組合起來生成一個庫檔案的過程我們就叫做“打包”。
那麼連結器如何將這些“零部件”組裝起來呢?其實每個“零部件”(可重定位檔案)都有許多對外顯示的“名字”(例如函式名字,全域性變數),這些名字就是這個“零部件”對外的“觸角”,有的名字是這個零部件定義好了的,而另一些名字是這個“零部件”內沒有定義的,連結器要做的就是要將不同零部件的未定義的名字與其他零部件已定義的名字關聯上,就能得到一個可執行的檔案。

一個例項

現在舉一個簡單的c語言例子來說明,這個程式非常簡單分別有main.c、function1.h、function1.c、function2.h、function2.c、function3.h、function3.c、function31.h、function31.c、function32.h、function32.c 幾個檔案,各個檔案中程式碼如下:

main.c:

#include <stdio.h>
#include "function1.h"
#include "function2.h"
#include "function3.h"
extern int i;
int main()
{
    printf("%d\n",i);
    function1();
    function2();
    function3();
}

function1.h:

void function1();

function1.c:

#include <stdio.h>
#include "function1.h"
void function1()
{
    printf("function1\n");
}

function2.h

void function2();

function2.c

#include <stdio.h>
#include "function2.h"
void function2()
{
    printf("function2\n");
}

function3.h

void function3();

function3.c

#include <stdio.h>
#include "function3.h"
#include "function31.h"
#include "function32.h"

int i = 123;
void function3()
{
    function31();
    function32();
    printf("function3\n");
}

function31.h

void function3();

function31.c

#include <stdio.h>
#include "function31.h"
void function31()
{
    printf("function31\n");
}

function32.h

void function32();

function32.c

#include <stdio.h>
#include "function32.h"
void function32()
{
    printf("function32\n");
}

可以看到main.c分別引用了function1.c 、function2.c、function3.c中的函式,而function3.c中又引用了function31.c和function32.c的函式,它們之間通過各自的.h檔案和#include引用“粘合”在一起。
現在我們通過gcc -c main.c function1.c function2.c function3.c function31.c function32.c命令編譯出各個.c檔案對應的.o檔案(可重定位檔案),這些.o檔案是我們可執行檔案的零部件。連結的工作是將這些零部件“拼裝”起。上文說了連結器是將不同零部件的名字對應上實現連結。在linux中可以通過nm命令檢視零部件的名字。例如我們要看main.o的名字,使用gcc -c main.o,結果如下:

                 U function1
                 U function2
                 U function3
                 U i
0000000000000000 T main
                 U printf

其中標誌U代表未定義的符號,連結器要把這些未定義的名字在其他零部件.o檔案中或者零部件的集合——庫檔案中對應上才能實現連結,生成可執行檔案。我們再看看function3.o的名字gcc -c function3.o:

0000000000000000 T function3
                 U function31
                 U function32
0000000000000000 D i
                 U puts

和預料中的一樣吧,可以看出main.o 中未定義的function3 和 i 都能在function3.o中找到。接下我們利用這些“零部件”生成可執行檔案gcc -o main *.o,可以看出程式沒有報錯,並且正常執行。
現在我們再試一下我們故意將function2.c中的function2 改成functionX:

#include <stdio.h>
#include "function2.h"
void functionX()
{
    printf("function2\n");
}

我們重新進行之前的實驗,這次能夠成功編譯生成.o檔案,但是連線時出現如下錯誤:

main.o: In function `main':
main.c:(.text+0x2b): undefined reference to `function2'
collect2: error: ld returned 1 exit status

可以看出這次聯結器報錯,在main.o中未定義的function2在其他零部件中找不到對應名字。

示意圖

以下對上述簡單例子的直觀示意圖

  • 編譯的過程:
    連結的思考
  • 連結的過程:
    連結的思考

思考

這篇文章是對連結過程的直觀思考,寫得並不嚴謹,但希望能對讀者有所啟發,主要參考了《深入理解計算機系統》《c專家程式設計》兩本書。仔細想來這種將一個大的工程分解成一個個的“零部件”的做法在現實中也是廣泛存在的,比如飛機、汽車這些複雜的機器哪個不是由零部件組成的?試想使用板鋼一塊製造一輛汽車其難度得有多大。所以連結器的存在也是合情合理。

相關文章