深度解析程序從編譯到運行

深度解析程序從編譯到運行

前言

 

C語言算是大學裏接觸的最早,用的最"多"的語言了,對於大部分學習計算機的學生基本上是從開始學習C語言起,憑藉着一句經典的"hello, world!"邁入了計算機的世界的,初體味了一把這個世界還有個叫編程的活。作爲系統級的開發首選語言,自誕生以來就屹立不倒,C語言的重要性是不言而喻的。做爲一個菜鳥級別的程序員,使用C有些年,但對於C沒有有真正的瞭解。我想有必要從新瞭解這門古老的語言背後的東西,知其然還要知其所以然,才能更好的使用這門語言。

 

 

對於C語言編寫的Hello World程序(如下),對於程序員來說肯定如雷貫耳,就是這樣一個簡單的程序,你真的瞭解她嗎?

1 #include <stdio.h>  
2 int main()  
3 {  
4     printf("Hello World\n")  
5     return 0;  
6 }  

 

對於下面這些問題,你腦子裏能夠馬上反映出一個清晰、明顯的答案嗎?

複製代碼

1 程序爲什麼要被編譯器編譯之後纔可以運行?
2 編譯器在把C語言程序轉換成可以執行的機器碼的過程中做了什麼?怎麼做的?
3 最後編譯出來的可執行文件裏面是什麼?除了機器碼還有什麼?他們怎麼存放的?怎麼組織的?
4 #include <stdio.h>是什麼意思?把stdio.h包含進來意味着什麼?C語言庫又是什麼?它怎麼實現的?
5 不同的編譯器(Microsoft VC、GCC)和不同的硬件平臺(x86、SPARC、MIPS、ARM),以及不同的操作系統(Windows、Linux、UNIX、Solaris),最終編譯出來的結果一樣嗎?爲什麼?
6 Hello World程序是怎麼運行起來的?操作系統是怎麼裝載它的?他從哪裏開始執行?到哪兒結束?main函數之前發生了什麼?main函數結束之後又發生了什麼?
7 如果沒有操作系統,Hello World可以運行嗎?如果要在一臺沒有操作系統的機器上運行Hello World需要什麼?應該怎麼實現?
8 printf是怎麼實現的?他爲什麼可以有不定數量的參數?爲什麼它能夠在終端上輸出字符串?
9 Hello World程序在運行時,它在內存中是什麼樣子的?

複製代碼


 基本過程概覽:

 

C語言編譯主要分爲四個階段

複製代碼

1.預處理

   此階段主要完成#符號後面的各項內容到源文件的替換,往往一些莫名其妙的錯誤都是出現在頭文件中的,要在工程中注意積累一些錯誤知識。

   (1)、#ifdef等內容,完成條件編譯內容的替換

   (2)、#include中內容,在當前目錄或者指定目錄,或者默認目錄搜索頭文件,並將頭文件拷貝到源文件中。

   (3)、#define的內容,替換define的內容(包括上一步的頭文件中的define內容)

 此階段產生[.i]文件。

2.編譯

   此階段完成語法和語義分析,然後生成中間代碼,此中間代碼是彙編代碼,但是還不可執行,gcc編譯的中間文件是[.s]文件。

 在此階段會出現各種語法和語義錯誤,特別要小心未定義的行爲,這往往是致命的錯誤。

 第一個階段和第二個階段由編譯器完成。

3.彙編

 此階段主要完成將彙編代碼翻譯成機器碼指令,並將這些指令打包形成可重定位的目標文件,[.O]文件,是二進制文件。

 此階段由彙編器完成。

4.鏈接

 此階段完成文件中叼用的各種函數跟靜態庫和動態庫的連接,並將它們一起打包合併形成目標文件,即可執行文件。

 此階段由鏈接器完成。

 gcc編譯C語言主要用到以下幾個程序:C編譯器gcc、彙編器as、鏈接器ld和二進制轉換工具objcopy。

複製代碼

 


 

C程序編譯流程

編譯一個C程序可以分爲四階段,預處理階段->生成彙編代碼階段->彙編階段->鏈接階段,這裏以linux環境下gcc編譯器爲例。使用gcc時默認會直接完成這四個步驟生成可以執行的程序,但通過編譯選項可以控制值進行某些階段,查看中間的文件。

gcc指令的一般格式爲(man gcc  或者 gcc -h  查看更過選項幫助):

1      gcc [選項] 要編譯的文件 [選項] [目標文件]
2      其中,目標文件可缺省,gcc默認生成可執行的文件名爲:a.out
3      gcc main.c                               直接生成可執行文件a.out
4      gcc -E main.c -o hello.i                 生成預處理後的代碼(還是文本文件)
5      gcc –S main.c -o hello.s                生成彙編代碼
6      gcc –c main.c -o hello.o                生成目標代碼

 

 

C程序目標文件和可執行文件結構

目標文件和可執行文件可以有幾種不同的格式,有ELF(Excutable and linking Format,可執行文件和鏈接)格式,也有COFF(Common Object-File Format,普通目標文件格式)。雖然格式不一樣,但具有一個共同的概念,那就是段(segments),這裏段指二進制格式文件中的一塊區域。
linux下的可執行文件有三個段文本段(text)、數據段(data)、bss段,可用nm命令查看目標文件的符號清單。

編譯過程: 源文件-------->到可執行文件

 

 

圖引自《C專家編程》

 

其中注意的BSS段,並沒有保存未初始化段的映像,只是記錄了該段的大小(應爲該段沒有初值,不管具體值),到了運行時再到內存爲未初始化變量分配空間,這樣可以節省目標文件空間。對於data段,只是保存在目標文件中,運行時直接載入。

 


 

C程序的內存佈局

講C語言內存管理的書籍或者博客?:https://www.zhihu.com/question/29922211

  1. readelf命令: http://man.linuxde.net/readelf
  2. 面試官問我:bss段的大小記錄在哪裏?:http://bbs.csdn.net/topics/390613528
  3. 內存區劃分、內存分配、常量存儲區、堆、棧、自由存儲區、全局區:http://www.cnblogs.com/CBDoctor/archive/2011/12/24/2300624.html
  4. 常量存在內存中的那裏?:http://bbs.csdn.net/topics/390510503

 

運行過程: 可執行文件->內存空間

不管是在Linux下C程序還是Windows下C程序,他們都是由正文段、數據段、BSS段、堆、棧等段構成的,只不過可能他們的各段分配地址不一樣。Linux下的C程序正文段在低地址,而Windows下的C程序的正文段(代碼段)在高地址。所有不用擔心我用Linux環境和Windows環境共同測試帶來不正確的數據。

                  

 


 

C語言存儲空間佈局

 

C語言一直由下面部分組成:

  1. 正文段code segment/text segment,.text段):或稱 代碼段,通常是用來存放程序執行代碼的一塊內存區域。這部分區域的大小在程序運行前就已經確定,並且內存區域通常屬於只讀,某些架構也允許代碼段爲可寫,即允許修改程序。在代碼段中,也有可能包含一些只讀的常數變量,例如字符串常量等。CPU執行的機器指令部分。( 存放函數體的二進制代碼 。)
  2. 只讀數據段(RO data,.rodata):只讀數據段是程序使用的一些不會被改變的數據,使用這些數據的方式類似查表式的操作,由於這些變量不需要修改,因此只需放在只讀存儲器中。
  3. 已初始化讀寫數據段data segment,.data段):通常是用來存放程序中已初始化的全局變量的一塊內存區域。數據段屬於靜態內存分配。常量字符串就是放在這裏的,程序結束後由系統釋放(rodata—read only data)。已初始化讀寫數據段(RW data,.data):已初始化數據是在程序中聲明,並且具有初值的變量,這些變量需要佔用存儲器空間,在程序執行時它們需要位於可讀寫的內存區域,並具有初值,以供程序讀寫。
             *只讀數據段 和數據段統稱爲 數據段
  4. BSS段bss segment,.bss段):未初始化數據段(BSS,.bss)通常是指用來存放程序中未初始化的全局變量的一塊內存區域。BSS是英文Block Started by Symbol的簡稱。BSS段屬於靜態內存分配。全局變量 和 靜態變量 的存儲是放在一塊的。初始化的全局變量和靜態變量在一塊區域(.rwdata or .data),未初始化的全局變量和未初始化的靜態變量在相鄰的另一塊區域(.bss), 程序結束後由系統釋放。未初始化數據是在程序中聲明,但是不具有初值的變量,這些變量在程序運行之前不需要佔用存儲空間。
           * 在 C++中,已經不再嚴格區分bss和 data了,它們共享一塊內存區域
            * 靜態存儲區包括bbs段和data段
  5. heap):堆是用於存放進程運行中被動態分配的內存段,它的大小並不固定,可動態擴張或縮減。當進程調用malloc等函數分配內存時,新分配的內存就被動態添加到堆上(堆被擴張);當利用free等函數釋放內存時,被釋放的內存從堆上被剔除(堆被縮減)。一般由程序員分配釋放(new/malloc/calloc delete/free),若程序員不釋放,程序結束時可能由 OS 回收。注意:它與數據結構中的堆是兩回事,但分配方式倒類似於鏈表

  6. stack):棧又稱堆棧,是用戶存放程序臨時創建的局部變量,也就是我們函數大括號"{}"中定義的變量(不包括static聲明的變量)。除此以外,在函數被調用時,其參數也會被壓入發起調用的進程棧中,並且等調用結束後,函數的返回值也會被存放在回棧中。由於棧的先進先出特性,所有棧特別方便用來保存/恢復調用現場。從這個意義上講,把堆棧看成一個寄存、交換臨時數據的內存區。由編譯器自動分配釋放,存放函數的參數值,局部變量的值等。其操作方式類似於數據結構中的棧
  7.  

詳細驗證可以參看 (C語言存儲空間佈局以及static詳解:http://blog.csdn.net/thanksgining/article/details/41960369

程序和目標的對應關係

使用readelf和objdump解析目標文件(http://www.jianshu.com/p/863b279c941e

  1. 複製代碼

     1 int a = 0; // a 在 data
     2 char *p1; // p1 在 bss
     3 main()
     4 {
     5 int b; // b 在 stack
     6 char s[] = "abc"; // s 在 stack, abc\0 在常量區
     7 char *p2; // p2 在 stack
     8 char *p3 = "123456"; // p3 在 stack, 123456\0 在常量區
     9 static int c = 0; // c 在 data
    10 p1 = (char *)malloc(10); // 申請的10字節內存在 heap, bss中的指針指向heap中的內存
    11 p2 = (char *)malloc(20); // 申請的20字節內存在 heap, stack中的指針指向heap中的內存
    12 strcpy(p1, "123456"); // 123456\0 在常量區,編譯器可能會將它與 p3 所指向的 "123456\0" 優化成一塊
    13 }

    複製代碼

 


 

堆和棧的區別

 

管理方式:對於棧來講,是由編譯器自動管理;對於堆來說,釋放工作由程序員控制,容易產生 memory leak。

空間大小:一般來講在 32 位系統下,堆內存可以達到接近 4G 的空間,從這個角度來看堆內存幾乎是沒有什麼限制的。但是對於棧來講,一般都是有一定的空間大小的,例如,在 VC6 下面,默認的棧空間大小大約是 1M。

碎片問題:對於堆來講,頻繁的new/delete 勢必會造成內存空間的不連續,從而造成大量碎片,使程序效率降低;對於棧來講,則不會存在這個問題,因爲棧是先進後出的隊列,永遠都不可能有一個內存塊從棧中間彈出。

生長方向:對於堆來講,生長方向是向上的,也就是向着內存地址增加的方向;對於棧來講,它的生長方向是向下的,是向着內存地址減小的方向增長。

分配方式:堆都是動態分配的,沒有靜態分配的堆;棧有 2 種分配方式:靜態分配和動態分配。靜態分配是編譯器完成的,比如局部變量的分配,動態分配由 alloca 函數進行分配,但是棧的動態分配和堆是不同的,它的動態分配是由編譯器進行釋放,不需要我們手工實現。

分配效率:棧是機器系統提供的數據結構,計算機會在底層分配專門的寄存器存放棧的地址,壓棧出棧都有專門的指令執行,這就決定了棧的效率比較高;  堆則是 C/C++函數庫提供的,它的機制是很複雜的,例如爲了分配一塊內存,庫函數會按照一定的算法(具體的算法可以參考數據結構/操作系統)在堆內存中搜索可用的足夠大小的空間,如果沒有足夠大小的空間(可能是由於內存碎片太多),就有可能調用系統功能去增加程序數據段的內存空間,然後進行返回。顯然,堆的效率比棧要低得多。

無論是堆還是棧,都要防止越界現象的發生。

 

關於 Global 和 Static 類型的一點討論

1. static 全局變量與普通的全局變量有什麼區別 ?

全局變量(外部變量)的定義之前再冠以 static 就構成了靜態的全局變量。

全局變量本身就是靜態存儲方式, 靜態全局變量當然也是靜態存儲方式。 這兩者在存儲方式上並無不同。

這兩者的區別在於非靜態全局變量的作用域是整個源程序, 當一個源程序由多個源文件組成時,非靜態的全局變量在各個源文件中都是有效的。  而靜態全局變量則限制了其作用域,  即只在定義該變量的源文件內有效,  在同一源程序的其它源文件中不能使用它。

由於靜態全局變量的作用域侷限於一個源文件內,只能爲該源文件內的函數公用,因此可以避免在其它源文件中引起錯誤。

static 全局變量只初使化一次,防止在其他文件單元中被引用。

2. static 局部變量和普通局部變量有什麼區別 ?

把局部變量改變爲靜態變量後是改變了它的存儲方式即改變了它的生存期。把全局變量改變爲靜態變量後是改變了它的作用域,限制了它的使用範圍。             

static 局部變量只被初始化一次,下一次依據上一次結果值。

3. static 函數與普通函數有什麼區別?

static 函數與普通函數作用域不同,僅在本文件。只在當前源文件中使用的函數應該說明爲內部函數(static),內部函數應該在當前源文件中說明和定義。對於可在當前源文件以外使用的函數,應該在一個頭文件中說明,要使用這些函數的源文件要包含這個頭文件.static 函數在內存中只有一份(.data),普通函數在每個被調用中維持一份拷貝。

 

對於data段,保存的是初始化的全局變量和stataic的局部變量,直接載入內存即可。 text段保存的是代碼直接載入。BSS段從目標文件中讀取BSS段大小,然後在內存中緊跟data段之後分配空間,並且清零(這也是爲什麼全局表量和static局部變量不初始化會有0值得原因)

 

下面的圖可以讓你更直觀的瞭解目標文件:

上圖是目標文件的典型結構,實際的情況可能會有所差別,但都是在這個基礎上衍生出來的。

ELF文件頭:即上圖中的第一個段。其中的header是目標文件的頭部,裏面包含了這個目標文件的一些基本信息。如該文件的版本、目標機器型號、程序入口地址等等。
文本段:裏面的數據主要是程序中的代碼部分。
數據段:程序中的數據部分,比如說變量。

重定位段:重定位段包括了文本重定位和數據重定位,裏面包含了重定位信息。一般來說,代碼中都會存在引用了外部的函數,或者變量的情況。既然是引用,那麼這些函數、變量並沒存在該目標文件內。在使用他們的時候, 就要給出他們的實際地址(這個過程發生在鏈接的時候)。正是這些重定位表,提供了尋找這些實際地址的信息。理解了上面之後,文本重定位和數據重定位也就不難理解了。
符號表:符號表包含了源代碼中所有的符號信息 。 包括每個變量名、函數名等等。裏面記錄了每個符號的信息,比如說代碼中有“student”這個符號,對應的在符號表中就包括這個符號的信息。包括這個符號所在的段、它的屬性(讀寫權限)等相關信息。其實符號表最初的來源可以說是在編譯的詞法分析階段。在做詞法分析的時候,就把代碼中的每個符號及其屬性都記錄在符號表中。
字符串表:和符號表差不多的功能,存放了一些字符串信息。
其中還有一點要說的是:目標文件都是以二進制來存儲的,它本身就是二進制文件。
現實中的目標文件會比這個模型要複雜些,但是它的思路都是一樣的,就是按照類型來存儲,再加上一些描述目標文件信息的段和鏈接中需要的信息。

 

函數調用棧

作爲面向過程的語言,C基本的特色就是模塊化、過程化。一個C程序或一個模塊由一堆函數組成,然後程序執行,按代碼的結構調用這些函數,完成功能。那麼函數調用的背後編譯器到底爲我們做了什麼呢? 

 

複製代碼

 1 void fun(int a, double b)
 2 {
 3      int c = 300;
 4      c += 1;
 5 }
 6 int main()
 7 {
 8      fun(100, 200);
 9      return 0;
10 }
11  
12  
13 .globl _fun                             ;全局函數符號
14      .def     _fun;     
15 _fun:                                   ;函數fun入口
16      pushl     %ebp                     ;保存ebp值
17      movl     %esp, %ebp                ;採用ebp來訪問棧頂
18      subl     $4, %esp                  ;esp用來擴展堆棧分配局部變量空間
19      movl     $300, -4(%ebp)            ;局部變量賦值
20      leal     -4(%ebp), %eax            ;得到局部變量有效地址
21      incl     (%eax)                    ;訪問局部變量
22      leave                              ;相當於movl ebp, esp   pop ebp  
23      ret
24  
25 .globl _main
26      .def     _main;    
27 _main:                                   ;main函數入口
28      ;....
29      movl     $200, 4(%esp)              ; 參數入棧 
30      movl     $100, (%esp)               ; 參數入棧
31      call     _fun
32      ;.....

複製代碼

 

 

函數調用過程:

參數按從右到左順序放到棧頂上

call調用,將返回地址ip入棧保存

在棧上分配局部變量空間

執行函數操作

 

函數返回過程:

ret會從棧上彈出返回地址

執行調用前後面的代碼

 

由此得的結論是,函數調用一個動態的過程,調用的時候又有一個棧幀,調用的時候展開,結束的時候收縮。局部變量在運行到該函數的時候在棧上分配內存,這些內存實際上沒有名字的,不同於數據段,有符號名字,局部變量在函數結束就銷燬了。這也是什麼局部變量同名互補干涉的原因,因爲編譯以後 ,根本就不是通過名字來訪問的。

 


 

 

全局變量

全局變量有初始化或未初始化之分,初始化了的全局變量保存在data段,未初始化全局變量保存在BSS段,data段和BSS段都是程序的數據段

複製代碼

 1 int global1 = 100;
 2 int main()
 3 {
 4      global1 = 101;
 5      extern int global2;
 6      global2 = 201;
 7      return 0;
 8 }
 9 int global2 = 200;
10  
11      
12 .globl _global1                    ;全局符號global1
13      .data                         ;位於數據段
14      .align 4
15 _global1:
16      .long     100                 ;全局變量初值
17      ;.....
18 .globl _main                          ;全局符號main 
19      .def     _main;                  ;是一個函數
20 _main:                                ;函數入口
21      ;...
22      movl     $101, _global1          ;通過符號訪問全局變量
23      movl     $201, _global2          ;通過符號訪問全局變量,這個變量還未定義
24      movl     $0, %eax
25      leave
26      ret
27 .globl _global2               :全局符號golbal2
28      .data                    ;位於數據段
29      .align 4
30 _global2:                     ;全局變量的定義,初始化值
31      .long     200             
32  
33  
34 int global1;
35 int main()
36 {
37      global1 = 101;
38      extern int global2;
39      global2 = 201;
40      return 0;
41 }
42 int global2;
43  
44     
45 .globl _main
46      .def     _main;     
47 _main:
48     ;....
49      movl     $101, _global1  ;通過符號訪問全局變量,這個符號可以在之後,或其他文件中定義
50      movl     $201, _global2
51      movl     $0, %eax
52      leave
53      ret
54      .comm     _global1, 16     # 4         ;標明這是個未初始化全局變量,聲明多個,但最後運行時在bss段分配空間
55      .comm     _global2, 16     # 4

複製代碼

 

可以得出結論:全局變量獨立於函數存在,所有函數都可以通過符號訪問,並且在運行期,其地址不變。

 

編譯與鏈接

看下面這個程序鏈接出錯,找不符號a,print, 但生成彙編代碼並沒有問題。這是因爲編譯的時候只是把符號地址記錄下來,等到鏈接的時候該符號定義了纔會變成具體的地址。如果鏈接的時候所有符號地址都有定義,那麼生成可執行文件。如果有不確定地址的符號,則鏈接出錯。

複製代碼

 1 #include<stdio.h>
 2 int main()
 3 {
 4      extern int a ;
 5      print("a = %d\n", a);
 6      return 0;
 7 }
 8  
 9      .file     "fun.c"
10      .def     ___main;    
11      .section .rdata,"dr"
12 LC0:
13      .ascii "a = %d\12\0"
14      .text
15 .globl _main
16      .def     _main;     .
17 _main:
18      ;..
19      movl     _a, %eax          ;通過符號訪問全局變量a
20      movl     %eax, 4(%esp)
21      movl     $LC0, (%esp)
22      call     _print            ;通過符號訪問函數print
23      movl     $0, %eax
24      leave
25      ret
26      .def     _print;     ;說明print是個函數符號

複製代碼

 

全局變量的鏈接屬性

全局變量的默認是extern的,最終存放在數據段,整個程序的所有文件都能訪問,如果加上static則表明值能被當前文件訪問。

複製代碼

 1 #include<stdio.h>
 2 static int a = 10;
 3 int main()
 4 {
 5      a = 20;
 6      return 0;
 7 }
 8  
 9     
10      .data
11      .align 4
12 _a:                              ;全局變量a定義,少了glbal的聲明
13      .long     10
14      .def     ___main;    
15      .text
16 .globl _main
17      .def     _main;  
18 _main:
19      ; ...
20      movl     $20, _a
21      movl     $0, %eax
22  
23 去掉int a前面的static產生的彙編代碼爲:
24  
25 .globl _a                    ; global聲明符號 a爲全局
26      .data
27      .align 4
28 _a:
29      .long     10
30      .def     ___main
31      .text
32 .globl _main
33      .def     _main
34 _main:
35      ;...
36      call     __alloca
37      call     ___main
38      movl     $20, _a
39      movl     $0, %eax
40  
41 對於未初始化全局變量
42 #include<stdio.h>
43 static int a;
44 int main()
45 {
46      a = 20;
47      return 0;
48 }
49  
50 .globl _main
51      .def     _main;     .scl     2;     .type     32;     .endef
52 _main:
53     ;..
54      movl     $20, _a
55      movl     $0, %eax
56      leave
57      ret
58      .lcomm _a,16          ; 多了個l表明是local的未初始化全局變量
59  
60 去掉int a前面的static
61 .globl _main
62      .def     _main;     .scl     2;     .type     32;     .endef
63 _main:
64      ;..
65      movl     $20, _a
66      movl     $0, %eax
67      leave
68      ret
69      .comm     _a, 16     # 4          ;extern鏈接屬性的未初始化全局變量

複製代碼

 

static局部變量

static局部變量具備外部變量的生存期,但作用域卻和局部變量一樣,離開函數就能訪問

複製代碼

 1 #include<stdio.h>
 2 int fun()
 3 {
 4      static int a = 10;
 5      return (++a);
 6 }
 7 int main()
 8 {
 9      printf("a = %d\n",fun());
10      printf("a = %d\n",fun());
11 }
12  
13      .data
14      .align 4
15 a.0:                    ;static局部變量是放在代碼段
16      .long     10     ;分配空間初始化
17      .text
18 .globl _fun
19      .def     _fun;   
20 _fun:
21      pushl     %ebp
22      movl     %esp, %ebp
23      incl     a.0
24      movl     a.0, %eax
25      popl     %ebp
26      ret
27      .def     ___main;   
28      .section .rdata,"dr"

複製代碼

 

編譯實際還是還是把static局部變量放在數據段存儲(要麼怎麼可能在程序運行期間地址不變呢),值不過符號名會動點手腳(這樣出了函數就訪問不了了),同時候 多個函數中定義同名的static局部變量,實際上是不同的內存單元,互補干涉了。

 


 

a.out剖分

a.out是目標文件的默認名字。也就是說,當編譯一個文件的時候,如果不對編譯後的目標文件重命名,編譯後就會產生一個名字爲a.out的文件。具體的爲什麼會用這個名字這裏就不在深究了。有興趣的可以自己google。我們現在就來研究一下hello world編譯後形成的目標文件,這裏用 C 來描述。

簡單的hellow world 源碼

複製代碼

1 /*hello.c*/
2 #include<stdio.h>
3 int main()
4 {
5     int a=5;
6     printf("hello world n");
7     return 0;
8 }

複製代碼

 

 

爲了在數據段中也有數據可放,這裏增加了“int a=5”。如果在VC上的話,點擊運行便能看到結果。爲了能看清楚內部到底是如何處理的,我們使用GCC來編譯。

運行:gcc hello.c。再看我們的目錄下,就多了目標文件a.out。

現在我們想做的是看看a.out裏到底有什麼,可能有童鞋回想到用vim文本查看,當時我也是這麼天真的認爲。但a.out是何等東西,怎能這麼簡單就暴露出來呢 。是的,vim不行。“我們遇到的問題大多是前人就已經遇到並且已經解決的”,對,其中有一個很強悍的工具叫做objdump。有了它,我們就能徹底的去了解目標文件的各種細節,當然還有一個叫做readelf也很有用,這個在後面介紹。這兩個工具一般Linux裏面都會自帶有有,可以自行google
 

注:這裏的代碼主要是在Linux下用GCC編譯,查看目標文件用的是Objdump、readelf。

下面是a.out的組織結構:(每段的起始地址、、大小等等)。查看目標文件的命令是 objdump -h a.out

就和上文中描述的目標文件的格式一樣,可以看出是分類存儲的。目標文件被分爲了6段。

從左到右,第一列(Idx Name)是段的名字,第二列(Size)是大小 ,VMA爲虛擬地址,LMA爲物理地址,File off是文件內的偏移。也就是這段相對於段中某一參考(一般是段起始)的距離。最後的Algn是對段屬性的說明,暫時不用理會

“text”段:代碼段。
“data”段:也就是上面說的數據段,保存了源代碼中的數據,一般是以初始化的數據。
“bss”段:也是數據段,存放那些未初始化的數據,因爲這些數據還未分配空間,所以單獨存放。
“rodata”段:只讀數據段,裏面存放的數據是隻讀的。
“cmment”存放的是編譯器版本信息。

剩下的兩段對我們的討論沒有實際意義,就不再介紹。認爲他們包含了一些鏈接、編譯、裝在的信息就可。

注:這裏的目標文件格式只是列出實際情況中主要部分。實際情況還有一些表未列出。如果你也在用Linux,可以用objdump -X 列出更詳細的段內容。

 

深入a.out

上面部分通過實例說了目標文件中的典型的段,主要是段的信息,如大小 等相關的屬性。那麼這些段裏面究竟有些什麼東西呢,“text”段裏到底存了什麼東西,還是用我們的objdump。objdump -s a.out 通過-s選項就可以查看目標文件的十六進制格式。
查看結果如下:
如上圖所示,列出了各段的十六進制表示形式。可以看出圖中共分爲兩欄,左邊的一欄是十六進制的表示, 右邊則顯示相應的信息。比較明顯的如“rodata”只讀數據段中就有 “hello world”。。
你也可以查看“hello world”的ASCII值,對應的十六進制就是裏面的內容了。“comment”上文中說的這個段包含了一些編譯器的版本信息,這個段後面的內容就是了:GCC編譯器,後面的是版本號。

 

a.out反彙編

編譯的過程總是先把源文先變爲彙編形式,再翻譯爲機器語言。看了這麼多的a.out,再研究一下他的彙編形式是很必要的。
objdump -d a.out可以列出文件的彙編形式。不過這裏只列出了主要部分,即main函數部分,其實在main函數執行的開始和main函數執行以後都還有多工作要做。即初始化函數執行環境以及釋放函數佔用的空間等。
上面的圖中,左邊是代碼的十六進制形式,左邊是彙編形式。對彙編熟悉的童鞋應該能看懂大部分,這裏就不在多述。

 

a.out頭文件

在介紹目標文件格式的時候,提到過頭文件這個概念,裏面包含了這個目標文件的一些基本信息。如該文件的版本、目標機器型號、程序入口地址等等。
下圖是文件頭的形式:
可以用readelf -h 來查看。(下圖中查看的是 hello.o,它是源文件hello.c編譯但未鏈接的文件。 這個和查看a.out 大部分是一樣的)
圖中分爲兩欄,左邊一欄表示的是屬性,右邊是屬性值。第一行常被稱爲魔數。後面是一連串的數字,其中的具體含義就不多說了,可以自己去google。
接下來的是一些和目標文件相關的信息。由於和我們要討論的問題關係不大,這裏就不展開討論了。
上面是內容用具體的實例說了目標文件內部的組織形式,目標文件只是產生可執行文件過程中的一箇中間過程,對於程序是如何運行的還沒做討論,目標文件是如何轉變爲可執行文件以及可執行文件是如何執行的將在下面的部分中討論


 

對鏈接的簡單認識

鏈接通俗的說就是把幾個可執行文件。如果程序A中引用了文件B中定義的函數,爲了A中的函數能正常執行,就需要把B中的函數部分也放在A的源代碼中,那麼將A和B合併成一個文件的過程就是鏈接了。有專門的過程用來鏈接程序,稱爲鏈接器。他將一些輸入的目標文件加工後合成一個輸出文件。這些目標文件中往往有相互的數據、函數引用。
上文中我們看過了hello world的反彙編形式,是一個還沒有經過鏈接的文件,也就是說當引用外部函數的時候是不知道其地址的,如下圖:
上圖中,cal指令就是調用了printf()函數,因爲這時候printf()函數並不在這個文件中,所以無法確定它的地址,在十六進制中就用“ff ff ff ”來表示它的地址。等經過鏈接以後,這個地址就會變爲函數的實際地址,應爲連接後這個函數已經被加載進入這個文件中了。
鏈接的分類:按把A相關的數據或函數合併爲一個文件的先後可以把鏈接分爲靜態鏈接和動態鏈接。

 

靜態鏈接:

在程序執行之前就完成鏈接工作。也就是等鏈接完成後文件才能執行。但是這有一個明顯的缺點,比如說庫函數。如果文件A 和文件B 都需要用到某個庫函數,鏈接完成後他們連接後的文件中都有這個庫函數。當A和B同時執行時,內存中就存在該庫函數的兩份拷貝,這無疑浪費了存儲空間。當規模擴大的時候,這種浪費尤爲明顯。靜態鏈接還有不容易升級等缺點。爲了解決這些問題,現在的很多程序都用動態鏈接。

 

動態鏈接:
和靜態鏈接不一樣,動態鏈接是在程序執行的時候才進行鏈接。也就是當程序加載執行的時候。還是上面的例子 ,如果A和B都用到了庫函數Fun(),A和B執行的時候內存中就只需要有Fun()的一個拷貝。

 

對裝載的簡單解釋

我們知道,程序要運行是必然要把程序加載到內存中的。在過去的機器裏都是把整個程序都加載進入物理內存中,現在一般都採用了虛擬存儲機制,即每個進程都有完整的地址空間,給人的感覺好像每個進程都能使用完成的內存。然後由一個內存管理器把虛擬地址映射到實際的物理內存地址。
按照上文的敘述, 程序的地址可以分爲虛擬地址和實際地址。虛擬地址即她在她的虛擬內存空間中的地址,物理地址就是她被加載的實際地址。
在上文中查看段 的時候或許你已經注意到了,由於文件是未鏈接、未加載的,所以每個段的虛擬地址和物理地址都是0.
加載的過程可以這樣理解:先爲程序中的各部分分配好虛擬地址,然後再建立虛擬地址到物理地址的映射。其實關鍵的部分就是虛擬地址到物理地址的映射過程。程序裝在完成之後,cpu的程序計數器pc就指向文件中的代碼起始位置,然後程序就按順序執行。

 

 


 

預處理

 

預編譯過程主要處理那些源代碼文件中的以“#”開始的預編譯指令,如“#include”、“#define'、”#if“,並刪除註釋行,還會添加行號和文件名標識以便於編譯時編譯器產生調試用的行號信息及用於編譯時產生編譯錯誤或警告時能夠顯示行號。經過預編譯的.i文件不包含任何宏定義,因爲所有的宏已經被展開並且包含的文件也已經被插入到.i文件中。所以當我們無法判斷宏定義是否正確或頭文件包含是否正確時,可以查看已編譯後的文件來確認問題。比如hello.c中第一行的 #include<stdio.h>命令告訴預處理器讀取系統頭文件stdio.h的內容,並且把它直接插入到程序文本中,結果就得到了另一個C程序,通常是以 .i 作爲文件擴展名。在該階段,編譯器將C源代碼中的包含的頭文件如stdio.h編譯進來,用戶可以使用gcc的選項”-E”進行查看。

複製代碼

1 用法:#gcc -E main.c -o main.i
2 作用:將main.c預處理輸出main.i文件
3  
4 [user:test] ls
5 main.c
6 [user:test] gcc -E main.c -o main.i
7 [user:test] ls
8 main.c  main.i

複製代碼

 

使用GCC -E參數完成。

這裏寫圖片描述

預處理會幹什麼事情:

  • 展開所有的宏定義並刪除 #define
  • 處理所有的條件編譯指令,例如 #if #else #endif #ifndef …
  • 把所有的 #include 替換爲頭文件實際內容,遞歸進行
  • 把所有的註釋 // 和 / / 替換爲空格
  • 添加行號和文件名標識以供編譯器使用
  • 保留所有的 #pragma 指令,因爲編譯器要使用
  • ……

處理完成之後看看我們的Hello.i,發現原來8行代碼現在變成了接近700行,因爲將<stdio.h>的文件被替換進來了,在最後幾行找到了我們自己Hello.c的代碼:

這裏寫圖片描述

使用系統默認的預處理器cpp完成。

預處理除了使用GCC -E參數完成之外,我們還可以使用系統默認的預處理器cpp完成。如下所示

這裏寫圖片描述

我們看看Hello.ii的代碼:

這裏寫圖片描述

雖然Hello.i和Hello.ii的代碼對應的行數不同,但是內容卻是一模一樣的,只是中間空行的數量不同而已。

OK ,接下來,繼續向編譯出發。

 


 

編譯

編譯是將源文件轉換成彙編代碼的過程,具體的步驟主要有:詞法分析 -> 語法分析 -> 語義分析及相關的優化 -> 中間代碼生成 -> 目標代碼生成(彙編文件.s)。

具體生成過程可以參考《編譯原理》。在這個階段中,Gcc首先要檢查代碼的規範性、是否有語法錯誤等,以確定代碼的實際要做的工作,在檢查無誤後,Gcc把代碼翻譯成彙編語言。用戶可以使用”-S”選項來進行查看,該選項只進行編譯而不進行彙編,生成彙編代碼。

複製代碼

1 選項 -S
2 用法:[user]# gcc –S main.i –o main.s
3 作用:將預處理輸出文件main.i彙編成main.s文件。
4  
5 [user:test] ls
6 main.c main.i
7 [user:test] gcc -S main.i -o main.s
8 [user:test] ls
9 main.c main.i main.s

複製代碼

 

 

注意:gcc命令只是一個後臺程序的包裝,會根據不同的參數要求去調用預編譯編譯程序cc1(c)、彙編器as、連接器ld。

使用GCC -S參數完成。

這裏寫圖片描述

查看Hello.s發現已經是彙編代碼了。

這裏寫圖片描述

使用系統默認的編譯器cc1完成這個過程。

 

前面的預處理命令cpp可能大家的系統上都有,我們輸入cp,然後Tab兩下(Linux系統上表示提示補全命令),系統提示如下: 

這裏寫圖片描述

 

倒數第二個命令就是cpp了。但是我們cc同樣的過程的時候卻發現: 

這裏寫圖片描述

 

並沒有cc1這個命令,但是cc1確實是Linux系統上默認的編譯器呀,我們在系統上找找看: 

這裏寫圖片描述

 

看上圖第二條,/usr/libexec/gcc/x86_64-redhat-linux/4.8.2/cc1,嘗試着去看下: 

這裏寫圖片描述

 

有可執行權限,那爲何不試試能不能用來編譯Hello.ii呢? 

這裏寫圖片描述

 

好像沒有什麼報錯,迫不及待的看看Hello.ss的內容:

這裏寫圖片描述

發現和Hello.s的是一樣的。編譯成功。Goto 彙編。

 


 

彙編

彙編階段是把編譯階段生成的”.s”文件轉成二進制目標代碼。彙編器(as)將hello.s翻譯成機器語言指令,把這些指令打包成一種叫做可重定位目標程序的格式,並將結果保存在目標文件hello.o中。hello.o文件是一個二進制文件,它的字節編碼是機器語言指令而不是字符。如果我們在文本編譯器中打開hello.o文件,看到的將是一堆亂碼。

複製代碼

1 選項 -c
2 用法:[user]# gcc –c main.s –o main.o
3 作用:將彙編輸出文件main.s編譯輸出main.o文件。
4  
5 [user:test] ls
6 main.c main.i main.s
7 [user:test] gcc -c main.s -o main.o
8 [user:test] ls
9 main.c main.i main.o main.s

複製代碼

 

 

使用GCC -c參數完成。

這裏寫圖片描述

其實也可以查看下Hello.o的內容:

這裏寫圖片描述

只是亂碼罷了。要是想看,我們可以使用 hexedit, readelf 和 objdump 這三個工具。

hexedit 只是個將二進制文件用十六進制打開的工具,我們執行:

sudoyuminstallhexeditsudoyuminstallhexedit hexedit Hello.o

可以看到:

這裏寫圖片描述

最右邊是源文件被翻譯成可見字符,點.表示的都是不可見字符。這樣看當然沒有多大實際意義,但是一些輸出的字符串Hello World,包括整個文件的類型ELF都是可以看到的。readelf和objdump我們後面再說。

 

使用系統默認的彙編器as完成。

這裏寫圖片描述

hexedit 看看 :

這裏寫圖片描述

使用 cmp 命令比較Hello.oo和Hello.o

這裏寫圖片描述

只有極少數字符不同。可能也是格式問題。下面就要進入鏈接這個階段了,本篇博客就到這裏吧。

總結:上面的過程中,我們已經將Hello.c源程序經過預處理,編譯,彙編階段變成了二進制代碼,這三個過程我們都是用兩種方法完成的,一種是GCC + 參數的方法,另一種是使用系統默認的預處理器,編譯器,彙編器。但是這兩種方法都達到了我們的目的,那有關本文第二部分的問題GCC是什麼?的答案,我之前之所以同意第三個答案:GCC是GUN編譯系統的編譯驅動程序,就是因爲GCC編譯的過程中,真正幹活的還是我們系統默認的預處理器,編譯器,彙編器,如果你還是不信,GCC -v顯示過程看看不就好了。

最後給它加上x權限。然後運行

chmod a+x a.out
./a.out

 


 

鏈接

這階段就是把彙編後的機器指令集變成可以直接運行的文件,而對目標文件進行鏈接主要是因爲在目標文件中可能用到了在其他文件當中定義的字段(或者函數),通過鏈接來把多個不同目標文件關聯到一起。比如有2個目標文件a和b,在 b中定義了一個函數"method",而在文件a中則使用到了b文件中的函數"method",通過鏈接文件a才能調用到函數"method",不然文件a根本就不知道到函數"method"底做了些什麼操作。

hello程序調用了一個printf函數,它是每個C編譯器都會提供的標準C庫中的一個函數,printf函數存在於一個名爲printf.o的單獨預編譯好了的標準文件中,而這個文件必須以某種方式合併到我們的hello.o程序中,鏈接器(ld)就負責處理這種合併,結果就得到hello文件,他是一個可執行目標文件(簡稱:可執行文件),可以被加載到內存中,有系統執行。

複製代碼

1 gcc的無選項的編譯就是鏈接
2 用法:[user]# gcc main.o -o main.elf
3 作用:將編譯輸出文件main.o鏈接成最終可執行文件main.elf
4  
5 [user:test] ls
6 main.c main.i main.o main.s
7 [user:test] gcc main.o -o main.elf
8 [user:test] ls
9 main.c main.elf* main.i main.o main.s

複製代碼

 

 

模塊之間的通信有兩種方式:一種是模塊間的函數調用,另一種是模塊間的變量訪問。函數訪問需知道目標函數的地址,變量訪問也需要知道目標變量的地址,所以這兩種方式都可以歸結爲一種方式,那就是模塊間符號的引用。模塊間依靠符號來通信類似於拼圖版,定義符號的模塊多出一塊區域,引用該符號的模塊剛好少了那一塊區域,兩者一拼接剛好完美組合。這個模塊的拼接過程就是“鏈接”。

在鏈接中,函數和變量統稱爲符號(symbol),函數名或變量名就是符號名(symbol name)。可以將符號看做是鏈接中的粘合劑,整個鏈接過程正是基於符號才能夠正確完成。鏈接過程中很關鍵的一部分就是符號的管理,每一個目標文件都會有一個相應的符號表(symbol table),這個表裏面記錄了目標文件中所用到的所有符號。每個定義的符號有一個對應的值,叫做符號值(symbol value),對於變量和函數來說,符號值就是它們的地址。符號表中所有的符號分類:
1、定義在本目標文件的全局符號,可以被其他目標文件引用。
2、在本目標文件中引用的全局符號,卻沒有定義在本目標文件,這一般叫做外部符號(external symbol),比如printf。
3、段名,這種符號往往由編譯器產生,它的值就是該段的起始地址,比如“.text”、“.data”。
4、局部符號,這類符號只在編譯單元內部可見。這些局部符號對於鏈接過程沒有作用,鏈接器往往忽略它們。
5、行號信息,即目標文件指令與源代碼中代碼行的對應關係。

鏈接過程主要包括了地址和空間分配、符號決議和重定位。符號決議有時候也叫做符號綁定、名稱綁定、名稱決議,甚至還有叫做地址綁定、指令綁定,大體上它們的意思都一樣,但從細節角度來區分,它們之間還存在一定區別,比如“決議”更傾向於靜態鏈接,而“綁定”更傾向於動態鏈接,即它們所使用的範圍不一樣。
每個目標文件都可能定義一些符號,也可能引用到定義咋其他目標文件的符號。重定位的過程中,每個重定位的入口都是對一個符號的引用,那麼當鏈接器須要對某個符號的引用重定位時,它就是要確定這個符號的目標地址。這時候鏈接器就會去查找由所有輸入目標文件的符號表組成的全局符號表,找到相應的符號後進行重定位。

 

參考資料:

1.《編譯原理》、《程序員的自我修養》

2.  擒賊先擒王 的CSDN 博客 https://blog.csdn.net/freeking101/article/details/78257914?utm_source=copy 

3. 其他資料:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章