1. 程式人生 > >連結器的祕密(七)

連結器的祕密(七)

        在之前我們學習了嵌入式開發中的相關知識點,今天我們來看看連結器。我們在平時的開發中,原始檔被編譯後生成目標檔案(.o 檔案) 時,這些目標檔案時如何存在於最終的可執行程式呢?那麼此時就需要連結器來出場了。

        我們首先來看看連結器的意義:它的主要作用是把各個模組之間相互引用的部分進行一個處理,使每個模組之間能夠進行正確的銜接,進而使得最終的可執行檔案能夠正常執行。示例圖如下

圖片.png

        其實我們在平時生成的目標檔案中,具備以下幾個特徵:

            a> 各個段沒有具體的起始地址,只有段大小資訊;

            b> 各個識別符號沒有實際地址,只有段中的相對地址;

            c> 段和識別符號的實際地址需要連結器具體確定。

        那麼連結器的主要工作是幹嘛的呢?主要做的工作是將目標檔案和庫檔案整合為最終的可執行程式。體現在 a> 合併各個目標檔案中的段(.text, .data, .bss);b> 確定各個段和段中識別符號的最終地址(也就是我們所說的重定位)。我們下來以程式碼為例來進行分析。

func.c 原始碼

#include <stdio.h>

int* g_pointer;

void func()
{
    g_pointer = (int*)"D.T.Software";

    return;
}


test.c 原始碼

#include <stdio.h>

int g_global = 0;
int g_test = 1;

extern int* g_pointer;
extern void func();

int main(int argc, char *argv[])
{
    printf("&g_global = %p\n", &g_global);
    printf("&g_test = %p\n", &g_test);
    printf("&g_pointer = %p\n", &g_pointer);
    printf("g_pointer = %p\n", g_pointer);
    printf("&func = %p\n", &func);
    printf("&main = %p\n", &main);
    
    func();
    
    return 0;
}

        我們來編譯成目標檔案,看看它們生成的目標檔案的內容

圖片.png

        我們看到在 func.o 中有一個 func 函式是位於 Test 段的,起始位置是 0 處,還有個 g_pointer,但是它的型別是未知的,只能看出它的大小為 4;在 test.o 中,地址都是起始處的地址。那麼我們在平時的程式編寫中,都是有地址的,這個地址究竟是誰分配的呢?幕後主謀便是連結器了,在平時的編譯中,它是編譯器自動進行編譯連結的。因此,在現在我們進行手動編譯後,必須得手動連結才能生成正確的可執行程式。下來我們來進行連結工作

圖片.png

        我們看到在進行最後的連結後,它們的地址都被正確的分配了。

        下來我們來看一個有趣的問題,main() 函式是第一個被呼叫執行的函式嗎?在剛接觸程式設計的時候,老師就告訴我們,main() 函式是 C 程式的入口,都是以它為其實地址來進行整個程式的執行的。那麼問這個問題是否是個無聊的問題呢?這是一家公司的面試題。我們就來看看一些深層次的東西(我們是在 gcc 環境下,因此下面全部是基於 gcc 環境來進行說明的)。

        在預設情況下:1、程式載入後,_start() 是第一個被呼叫執行的函式;2、_start() 函式準備好引數後立即呼叫 __libc_start_main() 函式;3、__libc_start_main() 初始化執行環境後呼叫 main() 函式執行。注:_start() 函式的入口地址就是程式碼段(.text)的起始地址!我們以程式碼為例來進行分析說明

program.c 原始碼

#include <stdio.h>
#include <stdlib.h>

int main()
{
    printf("D.T.Software\n");
    
    exit(0);
}

        我們來看看編譯生成的最終的目標檔案以及反彙編生成的最終程式碼

圖片.png

        我們開啟 result.txt 檔案,來查詢下 main 函式的入口地址

圖片.png

        我們看到 main 函式的入口地址是 804841d,我們再次在此檔案中查詢這個入口地址是在哪裡被呼叫的

圖片.png

        我們看到是在 _start 函式中呼叫的 main 函式的入口地址。由此可見,我們前面分析的全部是正確的。我們接著向下看,我們在 result.txt 檔案中,接著向前看。在最開始的地方呼叫的是 __libc_start_main 函式,下來我們繼續來講講這個函式的作用:1、呼叫 __libc_csu_fini() 函式(完成必要的初始化操作);2、啟動程式的第一個執行緒(主執行緒),main() 為執行緒入口;3、註冊 __libc_csu_init() 函式(程式執行終止時被呼叫)。我們看到在 _start() 函式開始之後,先後 push 了兩個地址:0x80484b0 和 0x8048440,我們來搜搜這兩個地址

圖片.png

圖片.png

        我們看到分別呼叫了 __libc_csu_fini() 和 __libc_csu_init() 函式,它們的作用我們也在上面剛剛說過了,利用這兩個函式來完成 __libc_start_main() 函式的必要操作。最後便是 main() 函式的啟動了。我們再次來梳理下整個啟動的流程,如下

圖片.png

        那麼我們看到程式是由 _start() 函式開始執行的,那麼我們是不是可以自定義入口函式呢?主要保證在它內部呼叫的地址是我們指定的自定義函式就行。這種操作是存在的,gcc 提供 -e 選項用於在連結時指定入口函式,但是注意的是在自定義入口函式時必須要使用 -nostartfiles 選項進行連結。示例程式碼如下

program.c 原始碼

#include <stdio.h>
#include <stdlib.h>

int program()    // Entry Function
{
    printf("D.T.Software\n");
    
    exit(0);
}

        我們來看看編譯。連結,執行的結果

圖片.png

        我們看到在第一次加 -e 選項後,沒有加 -nostartfiles 選項便導致連結錯誤了。-e 後面的便是我們自己指定的入口函式,其實質上是將入口函式 _start() 替換成我們自己指定的函式。那麼我們來思考下,連結器到底是根據什麼原則來完成具體的工作呢?為何如此神奇,可以讓我們自己指定入口地址呢?答案便是連結指令碼了。下來我們來看看連結指令碼到底是啥玩意。

        連結指令碼,簡單來說,它是用於描述連結器處理目標檔案和庫檔案的方式。它的主要作用有以下幾點:

        a> 合併各個目標檔案中的段;

        b> 重定位各個段的起始地址;

        c> 重定位各個符號的最終地址。

        下面是一些重要的連結選項

圖片.png

        那麼連結指令碼的本質是什麼呢?它是引導連結器執行的一條規則。在整個編譯階段中所處的地位關係如下

圖片.png

        那麼連結指令碼長的什麼樣子呢?是不是很難認識呢?起始並不是。對 Linux 底層熟悉的同學應該見過一些連結指令碼,就是字尾名為 .lds 的檔案。它的大致結構如下

圖片.png

        在編寫連結指令碼的時候,有幾點注意事項我們得注意下:1、各個段的連結地址必須符合具體平臺的規範;2、連結指令碼中能夠直接定義識別符號並指定儲存地址;3、連結指令碼中能夠指定原始碼中識別符號的儲存地址;在 Linux 中,程序程式碼段(.text)的合法起始地址為[0x08048000, 0x08049000]。下來我們來寫個連結指令碼體驗下


test.c 原始碼

#include <stdio.h>

int s1;
extern int s2;

int main()
{
    printf("&s1 = %p\n", &s1);
    printf("&s2 = %p\n", &s2);
    
    return 0;
}


test.lds 原始碼

SECTIONS
{
    .text 0x08048400:
    {
        *(.text)
    }
    
    . = 0x01000000;
    
    s1 = .;
    
    . += 4;
    
    s2 = .;
    
    .data 0x0804a800:
    {
        *(.data)
    }
    
    .bss :
    {
        *(.bss)
    }
}

        我們來編譯看看結果

圖片.png

        我們看到 s1 的地址是我們指定的 0x01000000,而 s2 則是我們進行 + 操作之後的地址。在預設情況下,連結器認為程式應該載入進入同一個儲存空間;但是在嵌入式系統中,如果存在多個儲存空間,必須使用 MEMORY 進行儲存區域定義。因為在嵌入式系統中,記憶體是很寶貴的。我們一般都是將 RAM 對映到 flash 中進行執行(通過 MMU)。示例程式碼如下

圖片.png

        其中 MEMORY 命令的屬性定義如下

圖片.png

        還有一種就是 ENTRY 命令來指定入口點。我們來進行程式碼分析說明


test.c 原始碼

#include <stdio.h>
#include <stdlib.h>

int program()
{
    printf("D.T.Software\n");
    
    exit(0);
}


test.lds 原始碼

ENTRY(program)

SECTIONS
{
    .text 0x08048400:
    {
        *(.text)
    }
}

        我們在連結腳本里指定 program 為程式的入口點,我們來看看程式執行的結果

圖片.png

        我們看到已經成功運行了。我們下來來看看連結資訊

圖片.png

        我們看到 .text 段的起始地址便是我們指定的地址,我們用 nm 來檢視下資訊可執行檔案的資訊,這個地址也是 program 函式的入口地址。那麼我們再來看看 gcc 預設的連結地址什麼呢?命令是  ld --verbose ,我們將它的結果儲存到 default.lds 中,我們來檢視下它的資訊

圖片.png

        我們看到裡面記錄了很多的資訊,其中預設的入口函式是 _start,段的起始地址是 0x08048000,剩下的資訊我們就不分析了。通過對連結器的學習,總結如下: