編譯鏈接是如何得到可執行文件的呢?

盤古開天闢地!我們寫了個C語言源文件,那從源文件到可執行程序這中間又發生了什麼?編譯,鏈接這些概念又是什麼意思?帶着對這些問題的好奇,我查了一些資料。其中,主要參考的是《程序員的自我修養》這本書和一些網上的博客。

windows下經常只需要單擊Run或者Debug就可以運行一個C語言程序,這種便利隱藏了背後的複雜機制,而我想知道這背後到底發生了什麼。

本文所使用的系統是ubuntu,但這些概念也適用於windows下。

1. 編譯源文件的四個階段

假如我們寫了一個很簡單的helloworld.c程序:

#include <stdio.h>

int main(int argc, char *argv[])
{
    printf("Hello,World!\n");
    return 0;
}

我們都知道運行命令

gcc helloworld.c -o helloworld

便可以對這個文件進行編譯,並命名可執行文件爲helloworld。然後運行

./helloworld
Hello,World!

便可以執行該文件,但是這背後又經歷了什麼呢?

注意:

本文並不是一篇嚴謹的探討編譯過程的文章,只是我對這個問題了解過程的一個梳理。

1.1 預處理(preprocessing)

在預處理階段,我們可以簡單理解就是處理以"#"開始的那些預處理指令,比如說:

#define,#include,#if,#elif,#else,#endif

預處理器會按照這些指令的意義進行處理,將#define定義的宏進行替換展開,將#include包含的文件整體替換進來。

可以運行命令

gcc -E helloworld.c -o helloworld.i

來得到經過預處理後的文件,檢查可以發現預處理確實幫我們把#include的文件包含進來了,另外在文件中還包含了一些行號信息,以便之後程序出錯提示錯誤所在的位置。

1.2 編譯(compile)

這一步是將上一步得到的*.i進行編譯,得到彙編代碼,可以運行命令

gcc -S helloworld.i -o helloworld.s

來得到經過彙編後的文件,該文件的其中一部分如下:

main:
    ...
    leaq    .LC0(%rip), %rcx
    call    puts
    ...

正好對應我們在主程序中調用的函數printf,於是我們知道在這一步是生成了彙編文件。

1.3 彙編(assembly)

這一步是將上一步的彙編代碼彙編爲具體的機器代碼,可以運行命令

gcc -c helloworld.s -o helloworld.o

生成的helloworld.o可以稱爲目標文件,下面我們對目標文件來檢查,幫助理解鏈接過程。

1.3.1 目標文件的結構

上一步中生成的是目標文件,但這個目標文件還沒有經過鏈接,也就是它其中的一些符號還無法確定,比如說在上面的printf我們就無法確定在哪裏去尋找這個函數的具體定義,通過頭文件stdio.h我們只是知道了它的定義形式,知道如何去調用它,但是具體執行的時候是需要代碼的,那麼去哪裏找呢?尋找printf並將它的地址寫入到我們的程序中就是鏈接的作用。

我們在系統中經常打交道的文件有

  1. 可執行文件(Executable File),比如Windows下的.exe,或者linux/bin/bash文件
  2. 共享目標文件(Shared Object File),比如Windows下的.dll,或者linux.so文件
  3. 可重定位文件(Relocatable File),我們上面生成的文件便是這種文件,可重定位指的是程序中的一些位置的符號(函數名,變量名)的地址還未確定,在之後的鏈接階段需要重新定位

Linux下可以使用命令file來查看文件的具體格式,讓我們運行

$ file helloworld.o
helloworld.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

那麼具體來說,目標文件到底包含什麼呢?首先一定會包含代碼,其次是數據(定義的變量),除此以外,我們還關心的是文件中包含的符號表,它是我們後續執行鏈接最重要的內容了。

運行命令

$ readelf -S helloworld.o

可以查看我們目標文件的段表,關於段表的詳細介紹請查看《程序員的自我修養》這本書。

There are 13 section headers, starting at offset 0x2d8:

節頭:
  [號] 名稱              類型             地址              偏移量
       大小              全體大小          旗標   鏈接   信息   對齊
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000022  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  00000228
       0000000000000030  0000000000000018   I      10     1     8
  [ 3] .data             PROGBITS         0000000000000000  00000062
       0000000000000000  0000000000000000  WA       0     0     1
  [ 4] .bss              NOBITS           0000000000000000  00000062
       0000000000000000  0000000000000000  WA       0     0     1
  [ 5] .rodata           PROGBITS         0000000000000000  00000062
       000000000000000d  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  0000006f
       000000000000002c  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  0000009b
       0000000000000000  0000000000000000           0     0     1
  [ 8] .eh_frame         PROGBITS         0000000000000000  000000a0
       0000000000000038  0000000000000000   A       0     0     8
  [ 9] .rela.eh_frame    RELA             0000000000000000  00000258
       0000000000000018  0000000000000018   I      10     8     8
  [10] .symtab           SYMTAB           0000000000000000  000000d8
       0000000000000120  0000000000000018          11     9     8
  [11] .strtab           STRTAB           0000000000000000  000001f8
       000000000000002e  0000000000000000           0     0     1
  [12] .shstrtab         STRTAB           0000000000000000  00000270
       0000000000000061  0000000000000000           0     0     1

我們關心的是上述段表中的2號段表:.rela.text可重定位表。正如我們之前所說的,在鏈接階段要對可重定位文件中的一些符號進行重定位,所以我們必須瞭解哪些符號需要進行定位,而.rela.text就是用來記錄相應的符號。

其中,符號表中會包含幾種符號:

  1. 在本文件中定義的符號,可以被其它目標文件所引用
  2. 在本文件中引用的符號,但卻沒有在本文件中定義
  3. ...

我們先運行命令

$ nm helloworld.o
                 U _GLOBAL_OFFSET_TABLE_
0000000000000000 T main
                 U puts

來查看我們的目標文件中的符號表,可以看到我們兩個符號mainputs之所以不是printf可能是編譯中進行了改變。

讓我們運行另外一個命令來詳細查看符號表:

$ readelf -s helloworld.o
Symbol table '.symtab' contains 12 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     ......
     9: 0000000000000000    34 FUNC    GLOBAL DEFAULT    1 main
    10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND puts

又看到了我們熟悉的兩個符號,由於main是在本文件中定義的所以它的類型是FUNC函數,且Ndx=1可以得知位於代碼段,而puts由於未定義,所以Ndx=UND(undefine),因此通過符號表我們便可以獲得哪些符號是在本文件中定義的,哪些符號是需要進行重定位的。

1.4 鏈接(link)

上面我們知道了符號表的存在,下面我們詳細說明下鏈接的過程。

假設我們有了兩個文件,a.cb.c。例子來自於《程序員的自我修養》。

/* a.c */
extern int shared;
int main(){
    int a=100;
    swap(&a, &shared);
    return 0;
}

/* b.c */
int shared = 1; // default is global variable, can be accessed by external program

void swap(int *a, int *b){
    *a ^= *b ^= *a ^= *b; // swap value
}

首先使用gcc編譯這兩個文件

$ gcc -c a.c b.c

然後我們會得到兩個文件a.ob.o,分別查看這兩個文件的符號表

$ readelf -s a.o
Symbol table '.symtab' contains 13 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     ......
     8: 0000000000000000    81 FUNC    GLOBAL DEFAULT    1 main
     9: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND shared
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND swap
    
$ readelf -s b.o
Symbol table '.symtab' contains 10 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     ......
     8: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    2 shared
     9: 0000000000000000    75 FUNC    GLOBAL DEFAULT    1 swap

於是,我們可以看出,在a.o中只定義了一個全局符號main,而sharedswap都是未定義,而在b.o中,sharedswap則是定義了的。

我們將採用的鏈接命令爲

$ ld a.o b.o -e main -o ab
  • -e 表示main作爲主函數入口
  • -o 表示輸出文件名

然後查看分配前後地址的分配情況

$ objdump -h a.o
a.o:     文件格式 elf64-x86-64

節:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000051  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000000  0000000000000000  0000000000000000  00000091  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  ......

$ objdump -h b.o
b.o:     文件格式 elf64-x86-64
節:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000004b  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         00000004  0000000000000000  0000000000000000  0000008c  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  ......

我嘗試了好幾遍運行命令

$ ld a.o b.o -e main -o ab

但是都提示一個錯誤

a.o:在函數‘main’中:
a.c:(.text+0x4b):對‘__stack_chk_fail’未定義的引用

不知道爲什麼,於是我只好使用命令

$ gcc a.o b.o -o ab

但是生成後的文件和作者的就不太一樣了,如下

 節:
 Idx Name          Size      VMA               LMA               File off  Algn
 ......
 13 .text         00000222  0000000000000560  0000000000000560  00000560  2**4
 ......
 22 .data         00000014  0000000000201000  0000000000201000  00001000  2**3
                  CONTENTS, ALLOC, LOAD, DATA
 23 .bss          00000004  0000000000201014  0000000000201014  00001014  2**0
                  ALLOC
 24 .comment      0000002b  0000000000000000  0000000000000000  00001014  2**0
                  CONTENTS, READONLY

但是仍然是可以看出VMA(虛擬內存地址)已經被賦值了,而在之前的a.ob.o中都是沒有賦值的。

到這一步的意思是經過鏈接,我們將兩個目標文件合成到一個文件中了,並且每個函數都有自己的相對地址,這時候我們就可以給每一個符號賦予地址了。

運行命令

$ readelf -s ab

來查看符號表,只列出相關的內容

Symbol table '.symtab' contains 66 entries:
    Num:    Value          Size Type    Bind   Vis      Ndx Name
    59: 000000000000066a    81 FUNC    GLOBAL DEFAULT   14 main

    62: 00000000000006bb    75 FUNC    GLOBAL DEFAULT   14 swap

    65: 0000000000201010     4 OBJECT  GLOBAL DEFAULT   23 shared

我們可以看出相關符號已經被賦予了具體的地址空間,也就是我們完成了鏈接過程。

在完成上述過程後,我們運行命令來反彙編查看

$ objdump -d ab
 000000000000066a <main>:
 66a:    55                       push   %rbp
 66b:    48 89 e5                 mov    %rsp,%rbp
 66e:    48 83 ec 10              sub    $0x10,%rsp
 672:    64 48 8b 04 25 28 00     mov    %fs:0x28,%rax
 679:    00 00 
 67b:    48 89 45 f8              mov    %rax,-0x8(%rbp)
 67f:    31 c0                    xor    %eax,%eax
 681:    c7 45 f4 64 00 00 00     movl   $0x64,-0xc(%rbp)
 688:    48 8d 45 f4              lea    -0xc(%rbp),%rax
 68c:    48 8d 35 7d 09 20 00     lea    0x20097d(%rip),%rsi        # 201010 <shared>
 693:    48 89 c7                 mov    %rax,%rdi
 696:    b8 00 00 00 00           mov    $0x0,%eax
 69b:    e8 1b 00 00 00           callq  6bb <swap> # <swap> 6bb
 6a0:    b8 00 00 00 00           mov    $0x0,%eax
 6a5:    48 8b 55 f8              mov    -0x8(%rbp),%rdx
 6a9:    64 48 33 14 25 28 00     xor    %fs:0x28,%rdx
 6b0:    00 00 
 6b2:    74 05                    je     6b9 <main+0x4f>
 6b4:    e8 87 fe ff ff           callq  540 <__stack_chk_fail@plt>
 6b9:    c9                       leaveq 
 6ba:    c3                       retq  

注意到swap以及變量shared的地址已經被正確地賦值給了程序,作爲對比我們查看下在鏈接之前程序的內容

$ objdump -d a.o
a.o:     文件格式 elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
   0:    55                       push   %rbp
   1:    48 89 e5                 mov    %rsp,%rbp
   4:    48 83 ec 10              sub    $0x10,%rsp
   8:    64 48 8b 04 25 28 00     mov    %fs:0x28,%rax
   f:    00 00 
  11:    48 89 45 f8              mov    %rax,-0x8(%rbp)
  15:    31 c0                    xor    %eax,%eax
  17:    c7 45 f4 64 00 00 00     movl   $0x64,-0xc(%rbp)
  1e:    48 8d 45 f4              lea    -0xc(%rbp),%rax
  22:    48 8d 35 00 00 00 00     lea    0x0(%rip),%rsi        # 29 <main+0x29>
  29:    48 89 c7                 mov    %rax,%rdi
  2c:    b8 00 00 00 00           mov    $0x0,%eax
  31:    e8 00 00 00 00           callq  36 <main+0x36>
  36:    b8 00 00 00 00           mov    $0x0,%eax
  3b:    48 8b 55 f8              mov    -0x8(%rbp),%rdx
  3f:    64 48 33 14 25 28 00     xor    %fs:0x28,%rdx
  46:    00 00 
  48:    74 05                    je     4f <main+0x4f>
  4a:    e8 00 00 00 00           callq  4f <main+0x4f>
  4f:    c9                       leaveq 
  50:    c3                       retq  

我們要注意的是偏移22和偏移31分別對應着sharedswap的調用,而第二列的十六進制代表這條指令,每個指令的後四個字節爲地址,可以看出這些地址都是0,這說明在文件a.o中,由於無法確定具體的地址,此時編譯器只是將其賦了一個特殊的地址0x0,然後在最後的鏈接階段再完成正確的地址賦值。

我們還可以運行命令

$ objdump -r a.o
a.o:     文件格式 elf64-x86-64

RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE              VALUE 
0000000000000025 R_X86_64_PC32     shared-0x0000000000000004
0000000000000032 R_X86_64_PLT32    swap-0x0000000000000004
000000000000004b R_X86_64_PLT32    __stack_chk_fail-0x0000000000000004

其中的offset描述了要重定位的位置。

2. 總結

事實上,在《程序員的自我修養》這本書中作者對於細節的探討很深入,要想完全理解掌握實在太難。

我主要想總結下關於鏈接部分。大概的過程就是:

  1. 鏈接器接收到輸入文件
  2. 收集每個輸入文件的段表,合成一個全局符號表,這張表裏包含所有定義的符號
  3. 如果是靜態鏈接,將多個輸入文件合併,進行地址空間的分配,在這一步完成之後所有符號的具體地址就定了
  4. 然後再對每一個輸入文件中需要重定位的符號重新定位到正確的地址處
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章