1. 程式人生 > >轉:Linux 程式編譯過程的來龍去脈

轉:Linux 程式編譯過程的來龍去脈

轉自:https://blog.csdn.net/p23onzq/article/details/81977367

大家肯定都知道計算機程式設計語言通常分為機器語言、組合語言和高階語言三類。高階語言需要通過翻譯成機器語言才能執行,而翻譯的方式分為兩種,一種是編譯型,另一種是解釋型,因此我們基本上將高階語言分為兩大類,一種是編譯型語言,例如C,C++,Java,另一種是解釋型語言,例如Python、Ruby、MATLAB 、JavaScript。

 

本文將介紹如何將高層的C/C++語言編寫的程式轉換成為處理器能夠執行的二進位制程式碼的過程,包括四個步驟:

  • 預處理(Preprocessing)

  • 編譯(Compilation)

  • 彙編(Assembly)

  • 連結(Linking)

640?wx_fmt=png

 

GCC 工具鏈介紹

通常所說的GCC是GUN Compiler Collection的簡稱,是Linux系統上常用的編譯工具。GCC工具鏈軟體包括GCC、Binutils、C執行庫等。

 

GCC

GCC(GNU C Compiler)是編譯工具。本文所要介紹的將C/C++語言編寫的程式轉換成為處理器能夠執行的二進位制程式碼的過程即由編譯器完成。

 

Binutils

一組二進位制程式處理工具,包括:addr2line、ar、objcopy、objdump、as、ld、ldd、readelf、size等。這一組工具是開發和除錯不可缺少的工具,分別簡介如下:

  • addr2line:用來將程式地址轉換成其所對應的程式原始檔及所對應的程式碼行,也可以得到所對應的函式。該工具將幫助偵錯程式在除錯的過程中定位對應的原始碼位置。

  • as:主要用於彙編,有關彙編的詳細介紹請參見後文。

  • ld:主要用於連結,有關連結的詳細介紹請參見後文。

  • ar:主要用於建立靜態庫。為了便於初學者理解,在此介紹動態庫與靜態庫的概念:

    • 如果要將多個.o目標檔案生成一個庫檔案,則存在兩種型別的庫,一種是靜態庫,另一種是動態庫。

    • 在windows中靜態庫是以 .lib 為字尾的檔案,共享庫是以 .dll 為字尾的檔案。在linux中靜態庫是以.a為字尾的檔案,共享庫是以.so為字尾的檔案。

    • 靜態庫和動態庫的不同點在於程式碼被載入的時刻不同。靜態庫的程式碼在編譯過程中已經被載入可執行程式,因此體積較大。共享庫的程式碼是在可執行程式執行時才載入記憶體的,在編譯過程中僅簡單的引用,因此程式碼體積較小。在Linux系統中,可以用ldd命令檢視一個可執行程式依賴的共享庫。

    • 如果一個系統中存在多個需要同時執行的程式且這些程式之間存在共享庫,那麼採用動態庫的形式將更節省記憶體。

  • ldd:可以用於檢視一個可執行程式依賴的共享庫。

  • objcopy:將一種物件檔案翻譯成另一種格式,譬如將.bin轉換成.elf、或者將.elf轉換成.bin等。

  • objdump:主要的作用是反彙編。有關反彙編的詳細介紹,請參見後文。

  • readelf:顯示有關ELF檔案的資訊,請參見後文瞭解更多資訊。

  • size:列出可執行檔案每個部分的尺寸和總尺寸,程式碼段、資料段、總大小等,請參見後文瞭解使用size的具體使用例項。

 

C執行庫

C語言標準主要由兩部分組成:一部分描述C的語法,另一部分描述C標準庫。C標準庫定義了一組標準標頭檔案,每個標頭檔案中包含一些相關的函式、變數、型別宣告和巨集定義,譬如常見的printf函式便是一個C標準庫函式,其原型定義在stdio標頭檔案中。

C語言標準僅僅定義了C標準庫函式原型,並沒有提供實現。因此,C語言編譯器通常需要一個C執行時庫(C Run Time Libray,CRT)的支援。C執行時庫又常簡稱為C執行庫。與C語言類似,C++也定義了自己的標準,同時提供相關支援庫,稱為C++執行時庫。

準備工作

由於GCC工具鏈主要是在Linux環境中進行使用,因此本文也將以Linux系統作為工作環境。為了能夠演示編譯的整個過程,本節先準備一個C語言編寫的簡單Hello程式作為示例,其原始碼如下所示:

#include <stdio.h> 

//此程式很簡單,僅僅列印一個Hello World的字串。
int main(void)
{
  printf("Hello World! \n");
  return 0;
}

 

編譯過程

1.預處理

預處理的過程主要包括以下過程:

  • 將所有的#define刪除,並且展開所有的巨集定義,並且處理所有的條件預編譯指令,比如#if #ifdef #elif #else #endif等。

  • 處理#include預編譯指令,將被包含的檔案插入到該預編譯指令的位置。

  • 刪除所有註釋“//”和“/* */”。

  • 新增行號和檔案標識,以便編譯時產生除錯用的行號及編譯錯誤警告行號。

  • 保留所有的#pragma編譯器指令,後續編譯過程需要使用它們。
    使用gcc進行預處理的命令如下:

$ gcc -E hello.c -o hello.i // 將原始檔hello.c檔案預處理生成hello.i
                        // GCC的選項-E使GCC在進行完預處理後即停止

hello.i檔案可以作為普通文字檔案開啟進行檢視,其程式碼片段如下所示:

// hello.i程式碼片段

extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
# 942 "/usr/include/stdio.h" 3 4

# 2 "hello.c" 2


# 3 "hello.c"
int
main(void)
{
  printf("Hello World!" "\n");
  return 0;
}

 

2.編譯

編譯過程就是對預處理完的檔案進行一系列的詞法分析,語法分析,語義分析及優化後生成相應的彙編程式碼。

使用gcc進行編譯的命令如下:

$ gcc -S hello.i -o hello.s // 將預處理生成的hello.i檔案編譯生成彙編程式hello.s
                        // GCC的選項-S使GCC在執行完編譯後停止,生成彙編程式

上述命令生成的彙編程式hello.s的程式碼片段如下所示,其全部為彙編程式碼。

// hello.s程式碼片段

main:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $.LC0, %edi
    call    puts
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc


3.彙編

彙編過程呼叫對彙編程式碼進行處理,生成處理器能識別的指令,儲存在後綴為.o的目標檔案中。由於每一個彙編語句幾乎都對應一條處理器指令,因此,彙編相對於編譯過程比較簡單,通過呼叫Binutils中的彙編器as根據彙編指令和處理器指令的對照表一一翻譯即可。

當程式由多個原始碼檔案構成時,每個檔案都要先完成彙編工作,生成.o目標檔案後,才能進入下一步的連結工作。注意:目標檔案已經是最終程式的某一部分了,但是在連結之前還不能執行。

使用gcc進行彙編的命令如下:

$ gcc -c hello.s -o hello.o // 將編譯生成的hello.s檔案彙編生成目標檔案hello.o
                        // GCC的選項-c使GCC在執行完彙編後停止,生成目標檔案
//或者直接呼叫as進行彙編
$ as -c hello.s -o hello.o //使用Binutils中的as將hello.s檔案彙編生成目標檔案

注意:hello.o目標檔案為ELF(Executable and Linkable Format)格式的可重定向檔案。

4.連結

連結也分為靜態連結和動態連結,其要點如下:

  • 靜態連結是指在編譯階段直接把靜態庫加入到可執行檔案中去,這樣可執行檔案會比較大。連結器將函式的程式碼從其所在地(不同的目標檔案或靜態連結庫中)拷貝到最終的可執行程式中。為建立可執行檔案,連結器必須要完成的主要任務是:符號解析(把目標檔案中符號的定義和引用聯絡起來)和重定位(把符號定義和記憶體地址對應起來然後修改所有對符號的引用)。

  • 動態連結則是指連結階段僅僅只加入一些描述資訊,而程式執行時再從系統中把相應動態庫載入到記憶體中去。

    • 在Linux系統中,gcc編譯連結時的動態庫搜尋路徑的順序通常為:首先從gcc命令的引數-L指定的路徑尋找;再從環境變數LIBRARY_PATH指定的路徑定址;再從預設路徑/lib、/usr/lib、/usr/local/lib尋找。

    • 在Linux系統中,執行二進位制檔案時的動態庫搜尋路徑的順序通常為:首先搜尋編譯目的碼時指定的動態庫搜尋路徑;再從環境變數LD_LIBRARY_PATH指定的路徑定址;再從配置檔案/etc/ld.so.conf中指定的動態庫搜尋路徑;再從預設路徑/lib、/usr/lib尋找。

    • 在Linux系統中,可以用ldd命令檢視一個可執行程式依賴的共享庫。

 

由於連結動態庫和靜態庫的路徑可能有重合,所以如果在路徑中有同名的靜態庫檔案和動態庫檔案,比如libtest.a和libtest.so,gcc連結時預設優先選擇動態庫,會連結libtest.so,如果要讓gcc選擇連結libtest.a則可以指定gcc選項-static,該選項會強制使用靜態庫進行連結。以Hello World為例:

  • 如果使用命令“gcc hello.c -o hello”則會使用動態庫進行連結,生成的ELF可執行檔案的大小(使用Binutils的size命令檢視)和連結的動態庫(使用Binutils的ldd命令檢視)如下所示:

    $ gcc hello.c -o hello
    $ size hello  //使用size檢視大小
       text    data     bss     dec     hex filename
       1183     552       8    1743     6cf     hello
    $ ldd hello //可以看出該可執行檔案連結了很多其他動態庫,主要是Linux的glibc動態庫
            linux-vdso.so.1 =>  (0x00007fffefd7c000)
            libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fadcdd82000)
            /lib64/ld-linux-x86-64.so.2 (0x00007fadce14c000)
  • 如果使用命令“gcc -static hello.c -o hello”則會使用靜態庫進行連結,生成的ELF可執行檔案的大小(使用Binutils的size命令檢視)和連結的動態庫(使用Binutils的ldd命令檢視)如下所示:

    $ gcc -static hello.c -o hello
    $ size hello //使用size檢視大小
         text    data     bss     dec     hex filename
     823726    7284    6360  837370   cc6fa     hello //可以看出text的程式碼尺寸變得極大
    $ ldd hello
           not a dynamic executable //說明沒有連結動態庫
    

連結器連結後生成的最終檔案為ELF格式可執行檔案,一個ELF可執行檔案通常被連結為不同的段,常見的段譬如.text、.data、.rodata、.bss等段。

分析ELF檔案

1.ELF檔案的段

ELF檔案格式如下圖所示,位於ELF Header和Section Header Table之間的都是段(Section)。一個典型的ELF檔案包含下面幾個段:

  • .text:已編譯程式的指令程式碼段。

  • .rodata:ro代表read only,即只讀資料(譬如常數const)。

  • .data:已初始化的C程式全域性變數和靜態區域性變數。

  • .bss:未初始化的C程式全域性變數和靜態區域性變數。

  • .debug:除錯符號表,偵錯程式用此段的資訊幫助除錯。

640?wx_fmt=jpeg

可以使用readelf -S檢視其各個section的資訊如下:

$ readelf -S hello
There are 31 section headers, starting at offset 0x19d8:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
……
  [11] .init             PROGBITS         00000000004003c8  000003c8
       000000000000001a  0000000000000000  AX       0     0     4
……
  [14] .text             PROGBITS         0000000000400430  00000430
       0000000000000182  0000000000000000  AX       0     0     16
  [15] .fini             PROGBITS         00000000004005b4  000005b4
……

 

2.反彙編ELF

由於ELF檔案無法被當做普通文字檔案開啟,如果希望直接檢視一個ELF檔案包含的指令和資料,需要使用反彙編的方法。

使用objdump -D對其進行反彙編如下:

$ objdump -D hello
……
0000000000400526 <main>:  // main標籤的PC地址
//PC地址:指令編碼                  指令的彙編格式
  400526:    55                          push   %rbp 
  400527:    48 89 e5                mov    %rsp,%rbp
  40052a:    bf c4 05 40 00          mov    $0x4005c4,%edi
  40052f:    e8 cc fe ff ff          callq  400400 <[email protected]>
  400534:    b8 00 00 00 00          mov    $0x0,%eax
  400539:    5d                      pop    %rbp
  40053a:    c3                          retq   
  40053b:    0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)
……

使用objdump -S將其反彙編並且將其C語言原始碼混合顯示出來:

$ gcc -o hello -g hello.c //要加上-g選項
$ objdump -S hello
……
0000000000400526 <main>:
#include <stdio.h>

int
main(void)
{
  400526:    55                          push   %rbp
  400527:    48 89 e5                mov    %rsp,%rbp
  printf("Hello World!" "\n");
  40052a:    bf c4 05 40 00          mov    $0x4005c4,%edi
  40052f:    e8 cc fe ff ff          callq  400400 <[email protected]>
  return 0;
  400534:    b8 00 00 00 00          mov    $0x0,%eax
}
  400539:    5d                          pop    %rbp
  40053a:    c3                          retq   
  40053b:    0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)
……