iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 23
0

前情提要


目前為止我們已經有了(差強人意的)readelf、objdump、nm、和 as。隨著鐵人賽接近尾聲,筆者決定將時間投資在最沒有確定性的最後一片拼圖上:連結器。今天就讓我們開始認識連結這個動作的重要性。

程式:從 0 到 100


筆者多年前還在念碩士班的時候,有一天有個隔壁實驗室的新生跑來問問題,因為那天他們那裡還沒有學長姊出席讓他可以諮詢,老師也沒有明確的任務下達,所以就變成來問我了。他的情況特殊,原本是外系的學生,碩班才轉到資工來,會寫一些 C 語言的程度。

他的問題是這樣的:「有沒有什麼參考資料可以告訴我,電腦從開機開始之後作的每一件事情依序是什麼?有沒有什麼工具可以讓我看看記憶體裡面任意位置是什麼樣的資料?程式從寫出來到編譯完再到可以跑,中間發生了什麼事情?」說來慚愧,筆者當時的回答是:「這些問題都非常大,你要不要跟你們老師確認一下明確的研究內容是什麼?」

真所謂他山之石可以攻玉,至少筆者身邊曾經都是資工領域的同學,卻從來沒有聽過有誰問過這些問題。如果沒有問過問題是因為知道答案那也就罷了,偏偏至少筆者自己當時根本不知道這些大哉問該從何答起。到了今天,筆者又已經虛長了幾歲,前兩個問題仍然是心裡面不願面對的艱鉅挑戰,最後一個問題卻終於有一點心得;當然,那也是本系列、對於 ELF 的構造有興趣的讀者們必然也會感興趣的問題。

**程式從寫出來到編譯完再到可以跑,中間發生了什麼事情?**以 C 語言為例,最方便的指令 gcc 一鍵完成編譯,然後再於命令列執行產物執行檔就可以了。gcc 其實是一個 compiler driver,引用諸多工具以建立可執行檔,其中步驟包含:

  • 前置處理:將 # 開頭的指令處理掉,比方說引用標頭檔、展開巨集等。
  • 編譯:將 C 語言轉換為組合語言
  • 組譯:將組合語言轉換為物件檔

最後一個步驟,也就是今天的重點,連結

連結

之前我們在 as 的範例中,完成了 add.o 的物件檔輸出之後,我們還需要仰賴以下指令:

$ riscv64-unknown-linux-gnu-ld --sysroot=/home/riscv/sysroot -dynamic-linker /lib/ld-linux-riscv64-lp64d.so.1 add.o main.o  -o main /home/riscv/sysroot/usr/lib/crt1.o -lc

其中參數請參考第七日的說明。

我們這個領域應該非常能夠體會,沒有人是一個人在戰鬥,所有的人都站在巨人的肩膀上,或是得知於人者太多。總之,我們雖然寫程式,但往往產出的東西都是集非常多歷史的積累才得以完成。其中有前人的函式庫(比方說標準 C 函式庫)、關鍵的系統檔案(crt1.o)、開發的部份(比方說這裡的 add.o 物件檔是我們開發的部份)、既有的程式本體(比方說這裡的 main.c,add.o 的功能相當於它需要的外掛)。

連結正是如其字面意義一般,將這些互不隸屬,甚至可能根本就沒有什麼關係的物件檔,一個一個按照其中的引用關係,將之連結起來,就好像是在說:main 函式呼叫了 add 函式?行,等到處理完 add.o 就能夠幫你處理好那個呼叫。

那麼 main.o 原本長什麼樣子呢?大概如下:

$ riscv64-unknown-linux-gnu-objdump -dr ~/main.o

/root/main.o:     file format elf64-littleriscv


Disassembly of section .text:

0000000000000000 <main>:
   0:   1141                    addi    sp,sp,-16
                        0: R_RISCV_ALIGN        *ABS*
   2:   e406                    sd      ra,8(sp)
   4:   e022                    sd      s0,0(sp)
   6:   0800                    addi    s0,sp,16
   8:   4589                    li      a1,2
   a:   4505                    li      a0,1
   c:   00000097                auipc   ra,0x0
                        c: R_RISCV_CALL add
                        c: R_RISCV_RELAX        *ABS*
  10:   000080e7                jalr    ra
  14:   87aa                    mv      a5,a0
  16:   85be                    mv      a1,a5
  18:   000007b7                lui     a5,0x0
                        18: R_RISCV_HI20        .LC0
                        18: R_RISCV_RELAX       *ABS*
  1c:   00078513                mv      a0,a5
                        1c: R_RISCV_LO12_I      .LC0
                        1c: R_RISCV_RELAX       *ABS*
  20:   00000097                auipc   ra,0x0
                        20: R_RISCV_CALL        printf
                        20: R_RISCV_RELAX       *ABS*
  24:   000080e7                jalr    ra
  28:   4781                    li      a5,0
  2a:   853e                    mv      a0,a5
  2c:   60a2                    ld      ra,8(sp)
  2e:   6402                    ld      s0,0(sp)
  30:   0141                    addi    sp,sp,16
  32:   8082                    ret

原本的程式碼則是:

#include<stdio.h>

extern int add(int a, int b);
int main(){
        printf("%d\n", add(1, 2));
}

這裡非常有趣的是 -r 參數第一次在本系列文中登場,代表重定位項目(relocation)。這裡可以看到剛才提到的 addprintf 函數的標籤都有出現在 R_RISCV_CALL 項目之後,但儘管如此,他們前後都只有最陽春的 auipc-jalr 指令對,前者沒有配給高位 20 位元給 ra 暫存器,後者也沒有計算相關的 offset。

這些東西都連結成功之後,變成這樣(使用 objdump 工具觀察產出的可執行檔):

...
0000000000010430 <main>:
   10430:       1141                    addi    sp,sp,-16
   10432:       e406                    sd      ra,8(sp)
   10434:       e022                    sd      s0,0(sp)
   10436:       0800                    addi    s0,sp,16
   10438:       4589                    li      a1,2
   1043a:       4505                    li      a0,1
   1043c:       fe1ff0ef                jal     ra,1041c <add>
   10440:       87aa                    mv      a5,a0
   10442:       85be                    mv      a1,a5
   10444:       67c1                    lui     a5,0x10
   10446:       4b878513                addi    a0,a5,1208 # 104b8 <__libc_csu_fini+0x6>
   1044a:       f07ff0ef                jal     ra,10350 <printf@plt>
   1044e:       4781                    li      a5,0
   10450:       853e                    mv      a0,a5
   10452:       60a2                    ld      ra,8(sp)
   10454:       6402                    ld      s0,0(sp)
   10456:       0141                    addi    sp,sp,16
   10458:       8082                    ret
...

原本需要重定位的地方,這裡都已經變成個 jal 指令,並且指向具體的目標位址了!不僅如此,還有許多奇奇怪怪的東西,比方說,為什麼不是呼叫到 printf 函式,而是什麼 printf@plt?為什麼 lui 指令原本後面的 mv 變成了 addi?怎麼會這樣?這就讓我們留到之後再慢慢探討吧。

連結之後,執行呢?


還記得那位不知名學弟的問題嗎?他可不是只有問到可執行檔形成之時而已,他的好奇心更進入到執行,那個部份又怎麼樣呢?說來慚愧,在筆者原本還沒有被鐵人賽的艱辛壓垮之前,原本是預計挑戰這最後一片拼圖的,那就是動態連結器。其實我們也看過幾次,尤其是在一開始展示 readelf 輸出結果時。

我們時常可以看見一個特殊的區段叫做 .interp,它的內容只有一個 C 語言字串,以我們之前用過的可執行檔為例,裡面的值是 /lib/ld-linux-riscv64-lp64d.so.1。這個檔案是一個非常特殊的 ELF 檔,它能夠讀取一個 ELF 可執行檔,替該檔案完成動態連結函式庫的載入,然後再將程式的執行權交付給該程式的進入點位址。

無論如何,這是更困難的挑戰,因而也無法在這個鐵人賽的系列期間完成嘗試,但筆者有一天會試著挑戰看看的。

小結


今天簡單介紹了連結的意義,還有一般的使用方法。明天我們繼續就這個主題深入探討 RISC-V 相關的規矩。各位讀者,我們明天再會!


上一篇
第二十二日:objdump 實作之完結
下一篇
第二十四日:RISC-V 的重定向項目
系列文
與妖精共舞:在 RISC-V 架構上使用 GO 語言實作 binutils 工具包30

尚未有邦友留言

立即登入留言