天天看點

程式設計語言C++ 程式設計語言(C/C++) 目錄 内容

程式設計語言(C/C++)

都是語言,為什麼英語比C++難這麼多呢?

目錄

Chapter 1 Chapter 2 Chapter 3 Chapter 4
程式設計基礎 面向對象基礎 标準模闆庫 編譯及調試

内容

程式設計基礎

C/C++的内容又多又雜,常常看到有人羅列相關書單,覺得毫無意義,我不相信他們真的完全掌握了其中任何一本。學習任何東西,首先要掌握基本概念,基礎不牢地動山搖,因為進階的内容都是通過低級的概念來描述的。當基本概念都沒了解透,學習再多都是空中樓閣。這裡羅列了一些聽基本的問題,雖然看着不難,但是精确了解每句話中的每個詞真的并不容易。

  1. 變量聲明和定義差別?
    • 聲明僅僅是把變量的聲明的位置及類型提供給編譯器,并不配置設定記憶體空間;定義要在定義的地方為其配置設定存儲空間。
    • 相同變量可以再多處聲明(外部變量extern),但隻能在一處定義。
  2. "零值比較"?
    • bool類型:if(flag)
    • int類型:if(flag == 0)
    • 指針類型:if(flag == null)
    • float類型:if((flag >= -0.000001) && (flag <= 0. 000001))
  3. strlen和sizeof差別?
    • sizeof是運算符,并不是函數,結果在編譯時得到而非運作中獲得;strlen是字元處理的庫函數。
    • sizeof參數可以是任何資料的類型或者資料(sizeof參數不退化);strlen的參數隻能是字元指針且結尾是'\0'的字元串。
    • 因為sizeof值在編譯時确定,是以不能用來得到動态配置設定(運作時配置設定)存儲空間的大小。
  4. 同一不同對象可以互相指派嗎?
    • 可以,但含有指針成員時需要注意。
    • 對比類的對象指派時深拷貝和淺拷貝。
  5. 結構體記憶體對齊問題?
    • 結構體内成員按照聲明順序存儲,第一個成員位址和整個結構體位址相同。
    • 未特殊說明時,按結構體中size最大的成員對齊(若有double成員),按8位元組對齊。
  6. static作用是什麼?在C和C++中有何差別?
    • static可以修飾局部變量(靜态局部變量)、全局變量(靜态全局變量)和函數,被修飾的變量存儲位置在靜态區。對于靜态局部變量,相對于一般局部變量其生命周期長,直到程式運作結束而非函數調用結束,且隻在第一次被調用時定義;對于靜态全局變量,相對于全局變量其可見範圍被縮小,隻能在本檔案中可見;修飾函數時作用和修飾全局變量相同,都是為了限定通路域。
    • C++的static除了上述兩種用途,還可以修飾類成員(靜态成員變量和靜态成員函數),靜态成員變量和靜态成員函數不屬于任何一個對象,是所有類執行個體所共有。
    • static的資料記憶性可以滿足函數在不同調用期的通信,也可以滿足同一個類的多個執行個體間的通信。
    • 未初始化時,static變量預設值為0。
  7. 結構體和類的差別?
    • 結構體的預設限定符是public;類是private。

  - 結構體不可以繼承,類可以。 C++中結構體也可以繼承。

  1. malloc和new的差別?
    • malloc和free是标準庫函數,支援覆寫;new和delete是運算符,并且支援重載。
    • malloc僅僅配置設定記憶體空間,free僅僅回收空間,不具備調用構造函數和析構函數功能,用malloc配置設定空間存儲類的對象存在風險;new和delete除了配置設定回收功能外,還會調用構造函數和析構函數。
    • malloc和free傳回的是void類型指針(必須進行類型轉換),new和delete傳回的是具體類型指針。
  2. 指針和引用差別?    - 引用隻是别名,不占用具體存儲空間,隻有聲明沒有定義;指針是具體變量,需要占用存儲空間。
    • 引用在聲明時必須初始化為另一變量,一旦出現必須為typename refname &varname形式;指針聲明和定義可以分開,可以先隻聲明指針變量而不初始化,等用到時再指向具體變量。
    • 引用一旦初始化之後就不可以再改變(變量可以被引用為多次,但引用隻能作為一個變量引用);指針變量可以重新指向别的變量。
    • 不存在指向空值的引用,必須有具體實體;但是存在指向空值的指針。
  3. 宏定義和函數有何差別?
    • 宏在編譯時完成替換,之後被替換的文本參與編譯,相當于直接插入了代碼,運作時不存在函數調用,執行起來更快;函數調用在運作時需要跳轉到具體調用函數。
    • 宏函數屬于在結構中插入代碼,沒有傳回值;函數調用具有傳回值。
    • 宏函數參數沒有類型,不進行類型檢查;函數參數具有類型,需要檢查類型。
    • 宏函數不要在最後加分号。
  4. 宏定義和const差別?
    • 宏替換發生在編譯階段之前,屬于文本插入替換;const作用發生于編譯過程中。
    • 宏不檢查類型;const會檢查資料類型。
    • 宏定義的資料沒有配置設定記憶體空間,隻是插入替換掉;const定義的變量隻是值不能改變,但要配置設定記憶體空間。
  5. 宏定義和typedef差別?
    • 宏主要用于定義常量及書寫複雜的内容;typedef主要用于定義類型别名。
    • 宏替換發生在編譯階段之前,屬于文本插入替換;typedef是編譯的一部分。
    • 宏不檢查類型;typedef會檢查資料類型。
    • 宏不是語句,不在在最後加分号;typedef是語句,要加分号辨別結束。
    • 注意對指針的操作,typedef char * p_char和#define p_char char *差別巨大。
  6. 宏定義和内聯函數(inline)差別?
    • 在使用時,宏隻做簡單字元串替換(編譯前)。而内聯函數可以進行參數類型檢查(編譯時),且具有傳回值。
    • 内聯函數本身是函數,強調函數特性,具有重載等功能。
    • 内聯函數可以作為某個類的成員函數,這樣可以使用類的保護成員和私有成員。而當一個表達式涉及到類保護成員或私有成員時,宏就不能實作了。
  7. 條件編譯#ifdef, #else, #endif作用?
    • 可以通過加#define,并通過#ifdef來判斷,将某些具體子產品包括進要編譯的内容。
    • 用于子程式前加#define DEBUG用于程式調試。
    • 應對硬體的設定(機器類型等)。
    • 條件編譯功能if也可實作,但條件編譯可以減少被編譯語句,進而減少目标程式大小。
  8. 差別以下幾種變量?
    const int a;
    int const a;
    const int *a;
    int *const a;
               
    • int const a和const int a均表示定義常量類型a。
    • const int *a,其中a為指向int型變量的指針,const在 * 左側,表示a指向不可變常量。(看成const (*a),對引用加const)
    • int *const a,依舊是指針類型,表示a為指向整型資料的常指針。(看成const(a),對指針const)
  9. volatile有什麼作用?
    • volatile定義變量的值是易變的,每次用到這個變量的值的時候都要去重新讀取這個變量的值,而不是讀寄存器内的備份。
    • 多線程中被幾個任務共享的變量需要定義為volatile類型。
  10. 什麼是常引用?
    • 常引用可以了解為常量指針,形式為const typename & refname = varname。
    • 常引用下,原變量值不會被别名所修改。
    • 原變量的值可以通過原名修改。
    • 常引用通常用作隻讀變量别名或是形參傳遞。
  11. 差別以下指針類型?
    int *p[10]
    int (*p)[10]
    int *p(int)
    int (*p)(int)
               
    • int *p[10]表示指針數組,強調數組概念,是一個數組變量,數組大小為10,數組内每個元素都是指向int類型的指針變量。
    • int (*p)[10]表示數組指針,強調是指針,隻有一個變量,是指針類型,不過指向的是一個int類型的數組,這個數組大小是10。
    • int *p(int)是函數聲明,函數名是p,參數是int類型的,傳回值是int *類型的。
    • int (*p)(int)是函數指針,強調是指針,該指針指向的函數具有int類型參數,并且傳回值是int類型的。
  12. 常量指針和指針常量差別?
    • 常量指針是一個指針,讀成常量的指針,指向一個隻讀變量。如int const *p或const int *p。
    • 指針常量是一個不能給改變指向的指針。如int *const p。
  13. a和&a有什麼差別?
    假設數組int a[10];
    int (*p)[10] = &a;
               
    • a是數組名,是數組首元素位址,+1表示位址值加上一個int類型的大小,如果a的值是0x00000001,加1操作後變為0x00000005。*(a + 1) = a[1]。
    • &a是數組的指針,其類型為int (*)[10](就是前面提到的數組指針),其加1時,系統會認為是數組首位址加上整個數組的偏移(10個int型變量),值為數組a尾元素後一個元素的位址。
    • 若(int *)p ,此時輸出 *p時,其值為a[0]的值,因為被轉為int *類型,解引用時按照int類型大小來讀取。
  14. 數組名和指針(這裡為指向數組首元素的指針)差別?
    • 二者均可通過增減偏移量來通路數組中的元素。
    • 數組名不是真正意義上的指針,可以了解為常指針,是以數組名沒有自增、自減等操作。
    • 當數組名當做形參傳遞給調用函數後,就失去了原有特性,退化成一般指針,多了自增、自減操作,但sizeof運算符不能再得到原數組的大小了。
  15. 野指針是什麼?
    • 也叫空懸指針,不是指向null的指針,是指向垃圾記憶體的指針。
    • 産生原因及解決辦法:
      • 指針變量未及時初始化 => 定義指針變量及時初始化,要麼置空。
      • 指針free或delete之後沒有及時置空 => 釋放操作後立即置空。
  16. 堆和棧的差別?
    • 申請方式不同。
      • 棧由系統自動配置設定。
      • 堆由程式員手動配置設定。
    • 申請大小限制不同。
      • 棧頂和棧底是之前預設好的,大小固定,可以通過ulimit -a檢視,由ulimit -s修改。
      • 堆向高位址擴充,是不連續的記憶體區域,大小可以靈活調整。
    • 申請效率不同。
      • 棧由系統配置設定,速度快,不會有碎片。
      • 堆由程式員配置設定,速度慢,且會有碎片。
  17. delete和delete[]差別?
    • delete隻會調用一次析構函數。
    • delete[]會調用數組中每個元素的析構函數。

面向對象基礎

能夠準确了解下面這些問題是從C程式員向C++程式員進階的基礎。當然了,這隻是一部分。

  1. 面向對象三大特性?
    • 封裝性:資料和代碼捆綁在一起,避免外界幹擾和不确定性通路。
    • 繼承性:讓某種類型對象獲得另一個類型對象的屬性和方法。
    • 多态性:同一事物表現出不同僚物的能力,即向不同對象發送同一消息,不同的對象在接收時會産生不同的行為(重載實作編譯時多态,虛函數實作運作時多态)。
  2. public/protected/private的差別?
    • public的變量和函數在類的内部外部都可以通路。
    • protected的變量和函數隻能在類的内部和其派生類中通路。
    • private修飾的元素隻能在類内通路。
  3. 對象存儲空間?
    • 非靜态成員的資料類型大小之和。
    • 編譯器加入的額外成員變量(如指向虛函數表的指針)。
    • 為了邊緣對齊優化加入的padding。
  4. C++空類有哪些成員函數?
    • 首先,空類大小為1位元組。
    • 預設函數有:
      • 構造函數
      • 析構函數
      • 拷貝構造函數
      • 指派運算符
  5. 構造函數能否為虛函數,析構函數呢?
    • 析構函數:
      • 析構函數可以為虛函數,并且一般情況下基類析構函數要定義為虛函數。
      • 隻有在基類析構函數定義為虛函數時,調用操作符delete銷毀指向對象的基類指針時,才能準确調用派生類的析構函數(從該級向上按序調用虛函數),才能準确銷毀資料。
      • 析構函數可以是純虛函數,含有純虛函數的類是抽象類,此時不能被執行個體化。但派生類中可以根據自身需求重新改寫基類中的純虛函數。
    • 構造函數:
      • 構造函數不能定義為虛函數。在構造函數中可以調用虛函數,不過此時調用的是正在構造的類中的虛函數,而不是子類的虛函數,因為此時子類尚未構造好。
  6. 構造函數調用順序,析構函數呢?
    • 調用所有虛基類的構造函數,順序為從左到右,從最深到最淺
    • 基類的構造函數:如果有多個基類,先調用縱向上最上層基類構造函數,如果橫向繼承了多個類,調用順序為派生表從左到右順序。
    • 如果該對象需要虛函數指針(vptr),則該指針會被設定進而指向對應的虛函數表(vtbl)。
    • 成員類對象的構造函數:如果類的變量中包含其他類(類的組合),需要在調用本類構造函數前先調用成員類對象的構造函數,調用順序遵照在類中被聲明的順序。
    • 派生類的構造函數。
    • 析構函數與之相反。
  7. 拷貝構造函數中深拷貝和淺拷貝差別?
    • 深拷貝時,當被拷貝對象存在動态配置設定的存儲空間時,需要先動态申請一塊存儲空間,然後逐位元組拷貝内容。
    • 淺拷貝僅僅是拷貝指針字面值。
    • 當使用淺拷貝時,如果原來的對象調用析構函數釋放掉指針所指向的資料,則會産生空懸指針。因為所指向的記憶體空間已經被釋放了。
  8. 拷貝構造函數和指派運算符重載的差別?
    • 拷貝構造函數是函數,指派運算符是運算符重載。
    • 拷貝構造函數會生成新的類對象,指派運算符不能。
    • 拷貝構造函數是直接構造一個新的類對象,是以在初始化對象前不需要檢查源對象和建立對象是否相同;指派運算符需要上述操作并提供兩套不同的複制政策,另外指派運算符中如果原來的對象有記憶體配置設定則需要先把記憶體釋放掉。
    • 形參傳遞是調用拷貝構造函數(調用的被指派對象的拷貝構造函數),但并不是所有出現"="的地方都是使用指派運算符,如下:
      Student s;
        Student s1 = s;    // 調用拷貝構造函數
        Student s2;
        s2 = s;    // 指派運算符操作
                 
    注:類中有指針變量時要重寫析構函數、拷貝構造函數和指派運算符
  9. 虛函數和純虛函數差別?
    • 虛函數是為了實作動态編聯産生的,目的是通過基類類型的指針指向不同對象時,自動調用相應的、和基類同名的函數(使用同一種調用形式,既能調用派生類又能調用基類的同名函數)。虛函數需要在基類中加上virtual修飾符修飾,因為virtual會被隐式繼承,是以子類中相同函數都是虛函數。當一個成員函數被聲明為虛函數之後,其派生類中同名函數自動成為虛函數,在派生類中重新定義此函數時要求函數名、傳回值類型、參數個數和類型全部與基類函數相同。
    • 純虛函數隻是相當于一個接口名,但含有純虛函數的類不能夠執行個體化。
  10. 覆寫、重載和隐藏的差別?
    • 覆寫是派生類中重新定義的函數,其函數名、參數清單(個數、類型和順序)、傳回值類型和父類完全相同,隻有函數體有差別。派生類雖然繼承了基類的同名函數,但用派生類對象調用該函數時會根據對象類型調用相應的函數。覆寫隻能發生在類的成員函數中。
    • 隐藏是指派生類函數屏蔽了與其同名的函數,這裡僅要求基類和派生類函數同名即可。其他狀态同覆寫。可以說隐藏比覆寫涵蓋的範圍更寬泛,畢竟參數不加限定。
    • 重載是具有相同函數名但參數清單不同(個數、類型或順序)的兩個函數(不關心傳回值),當調用函數時根據傳遞的參數清單來确定具體調用哪個函數。重載可以是同一個類的成員函數也可以是類外函數。
  11. 在main執行之前執行的代碼可能是什麼?
    • 全局對象的構造函數。
  12. 哪幾種情況必須用到初始化成員清單?
    • 初始化一個const成員。
    • 初始化一個reference成員。
    • 調用一個基類的構造函數,而該函數有一組參數。
    • 調用一個資料成員對象的構造函數,而該函數有一組參數。
  13. 什麼是虛指針?
    • 虛指針或虛函數指針是虛函數的實作細節。
    • 虛指針指向虛表結構。
  14. 重載和函數模闆的差別?
    • 重載需要多個函數,這些函數彼此之間函數名相同,但參數清單中參數數量和類型不同。在區分各個重載函數時我們并不關心函數體。
    • 模闆函數是一個通用函數,函數的類型和形參不直接指定而用虛拟類型來代表。但隻适用于參個數相同而類型不同的函數。
  15. this指針是什麼?
    • this指針是類的指針,指向對象的首位址。
    • this指針隻能在成員函數中使用,在全局函數、靜态成員函數中都不能用this。
    • this指針隻有在成員函數中才有定義,且存儲位置會因編譯器不同有不同存儲位置。
  16. 類模闆是什麼?
    • 用于解決多個功能相同、資料類型不同的類需要重複定義的問題。
    • 在建立類時候使用template及任意類型辨別符T,之後在建立類對象時,會指定實際的類型,這樣才會是一個實際的對象。
    • 類模闆是對一批僅資料成員類型不同的類的抽象,隻要為這一批類建立一個類模闆,即給出一套程式代碼,就可以用來生成具體的類。
  17. 構造函數和析構函數調用時機?
    • 全局範圍中的對象:構造函數在所有函數調用之前執行,在主函數執行完調用析構函數。
    • 局部自動對象:建立對象時調用構造函數,離開作用域時調用析構函數。
    • 動态配置設定的對象:建立對象時調用構造函數,調用釋放時調用析構函數。
    • 靜态局部變量對象:建立時調用一次構造函數,主函數結束時調用析構函數。

标準模闆庫

STL内容雖然看起來很多,單獨成書都不是問題(《STL源碼剖析》),但從實際使用狀況來看,我認為隻需要知道以下幾點就可以了:

  • 怎麼用?

    各種STL基本的增删改查怎麼使用。每種容器都提供了很多操作,但實際增删改查我們通常隻需要掌握透徹一種方式即可。有些功能隻是出于通用性考慮才存在的,但對于相應的STL這些操作完全可以忽略。是以我對STL使用的看法是,不需要花太多時間去了解所有功能,隻要掌握最基本的即可,要把精力放在對需求的了解并選擇适合的資料結構。

  • 怎麼實作?

    本身STL就是封裝了我們常用的資料結構,是以最先需要了解每種資料結構的特性。而且了解實作方式對我們能夠準确、高效使用STL打下了基礎。

  • 如何避免錯誤?

    在第二階段了解了STL的實作之後,我們已經可以很清楚地知道他們底層使用的是什麼資料結構以及該資料結構做什麼操作比較高效。但還有一點需要注意的就是怎麼才能用對他們,避免一些未知的錯誤,比如疊代器失效問題。

string

vector

用法:

定義:
        vector<T> vec;

    插入元素:
        vec.push_back(element);
        vec.insert(iterator, element);

    删除元素:
        vec.pop_back();
        vec.erase(iterator);

    修改元素:
        vec[position] = element;

    周遊容器:
        for(auto it = vec.begin(); it != vec.end(); ++it) {......}

    其他:
        vec.empty();    //判斷是否空
        vec.size();    // 實際元素
        vec.capacity();    // 容器容量
        vec.begin();    // 獲得首疊代器
        vec.end();    // 獲得尾疊代器
        vec.clear();    // 清空
           

實作:

模拟Vector實作

  • 線性表,數組實作。
    • 支援随機通路。
    • 插入删除操作需要大量移動資料。
  • 需要連續的實體存儲空間。
  • 每當大小不夠時,重新配置設定記憶體(*2),并複制原内容。

錯誤避免:

疊代器失效

  • 插入元素
    • 尾後插入:size < capacity時,首疊代器不失效尾疊代實作(未重新配置設定空間),size == capacity時,所有疊代器均失效(需要重新配置設定空間)。
    • 中間插入:size < capacity時,首疊代器不失效但插入元素之後所有疊代器失效,size == capacity時,所有疊代器均失效。
  • 删除元素
    • 尾後删除:隻有尾疊代失效。
    • 中間删除:删除位置之後所有疊代失效。

map

用法:

定義:
        map<T_key, T_value> mymap;

    插入元素:
        mymap.insert(pair<T_key, T_value>(key, value));    // 同key不插入
        mymap.insert(map<T_key, T_value>::value_type(key, value));    // 同key不插入
        mymap[key] = value;    // 同key覆寫

    删除元素:
        mymap.erase(key);    // 按值删
        mymap.erase(iterator);    // 按疊代器删

    修改元素:
        mymap[key] = new_value;

    周遊容器:
          for(auto it = mymap.begin(); it != mymap.end(); ++it) {
            cout << it->first << " => " << it->second << '\n';
          }
           

實作:

RBTree實作

  • 樹狀結構,RBTree實作。
    • 插入删除不需要資料複制。
    • 操作複雜度僅跟樹高有關。
  • RBTree本身也是二叉排序樹的一種,key值有序,且唯一。
    • 必須保證key可排序。

基于紅黑樹實作的map結構(實際上是map, set, multimap,multiset底層均是紅黑樹),不僅增删資料時不需要移動資料,其所有操作都可以在O(logn)時間範圍内完成。另外,基于紅黑樹的map在通過疊代器周遊時,得到的是key按序排列後的結果,這點特性在很多操作中非常友善。

面試時候現場寫紅黑樹代碼的機率幾乎為0,但是紅黑樹一些基本概念還是需要掌握的。

  1. 它是二叉排序樹(繼承二叉排序樹特顯):
    • 若左子樹不空,則左子樹上所有結點的值均小于或等于它的根結點的值。
    • 若右子樹不空,則右子樹上所有結點的值均大于或等于它的根結點的值。
    • 左、右子樹也分别為二叉排序樹。
  2. 它滿足如下幾點要求:
    • 樹中所有節點非紅即黑。
    • 根節點必為黑節點。
    • 紅節點的子節點必為黑(黑節點子節點可為黑)。
    • 從根到NULL的任何路徑上黑結點數相同。
  3. 查找時間一定可以控制在O(logn)。
  4. 紅黑樹的節點定義如下:
    enum Color {
        RED = 0,
        BLACK = 1
    };
    struct RBTreeNode {
        struct RBTreeNode*left, *right, *parent;
        int key;
        int data;
        Color color;
    };
               

是以對紅黑樹的操作需要滿足兩點:1.滿足二叉排序樹的要求;2.滿足紅黑樹自身要求。通常在找到節點通過和根節點比較找到插入位置之後,還需要結合紅黑樹自身限制條件對子樹進行左旋和右旋。

相比于AVL樹,紅黑樹平衡性要稍微差一些,不過建立紅黑樹時所需的旋轉操作也會少很多。相比于最簡單的BST,BST最差情況下查找的時間複雜度會上升至O(n),而紅黑樹最壞情況下查找效率依舊是O(logn)。是以說紅黑樹之是以能夠在STL及Linux核心中被廣泛應用就是因為其折中了兩種方案,既減少了樹高,又減少了建樹時旋轉的次數。

從紅黑樹的定義來看,紅黑樹從根到NULL的每條路徑擁有相同的黑節點數(假設為n),是以最短的路徑長度為n(全為黑節點情況)。因為紅節點不能連續出現,是以路徑最長的情況就是插入最多的紅色節點,在黑節點數一緻的情況下,最可觀的情況就是黑紅黑紅排列......最長路徑不會大于2n,這裡路徑長就是樹高。

set

編譯及調試

編譯

預處理

  • 展開所有的宏定義,完成字元常量替換。
  • 處理條件編譯語句,通過是否具有某個宏來決定過濾掉哪些代碼。
  • 處理#include指令,将被包含的檔案插入到該指令所在位置。
  • 過濾掉所有注釋語句。
  • 添加行号和檔案名辨別。
  • 保留所有#pragma編譯器指令。

編譯

  • 詞法分析。
  • 文法分析。
  • 語義分析。
  • 中間語言生成。
  • 目标代碼生成與優化。

連結

各個源代碼子產品獨立的被編譯,然後将他們組裝起來成為一個整體,組裝的過程就是連結。被連結的各個部分本本身就是二進制檔案,是以在被連結時需要将所有目标檔案的代碼段拼接在一起,然後将所有對符号位址的引用加以修正。

  • 靜态連結

    靜态連結最簡單的情況就是在編譯時和靜态庫連結在一起成為完整的可執行程式。這裡所說的靜态庫就是對多個目标檔案(.o)檔案的打包,通常靜态連結的包名為lib****.a,靜态連結所有被用到的目标檔案都會複制到最終生成的可執行目标檔案中。這種方式的好處是在運作時,可執行目标檔案已經完全裝載完畢,隻要按指令序執行即可,速度比較快,但缺點也有很多,在講動态連結時會比較一下。

    既然靜态連結是對目标檔案的打包,這裡介紹些打包指令。

    gcc -c test1.c    // 生成test1.o
      gcc -c test2.c    // 生成test2.c
      ar cr libtest.a test1.o test2.o
               
    首先編譯得到test1.o和test2.o兩個目标檔案,之後通過ar指令将這兩個檔案打包為.a檔案,檔案名格式為lib + 靜态庫名 + .a字尾。在生成可執行檔案需要使用到它的時候隻需要在編譯時加上即可。需要注意的是,使用靜态庫時加在最後的名字不是libtest.a,而是l + 靜态庫名。
    gcc -o main main.c -ltest
               
  • 動态連結

    靜态連結發生于編譯階段,加載至記憶體前已經完整,但缺點是如果多個程式都需要使用某個靜态庫,則該靜态庫會在每個程式中都拷貝一份,非常浪費記憶體資源,是以出現了動态連結的方式來解決這個問題。

    動态連結在形式上倒是和靜态連結非常相似,首先也是需要打包,打包成動态庫,不過檔案名格式為lib + 動态庫名 + .so字尾。不過動态庫的打包不需要使用ar指令,gcc就可以完成,但要注意在編譯時要加上-fPIC選項,打包時加上-shared選項。

    gcc -fPIC -c test1.c 
      gcc -fPIC -c test2.c
      gcc -shared test1.o test2.o -o libtest.so
               
    使用動态連結的用法也和靜态連結相同。
    gcc -o main main.c -ltest
               

如果僅僅像上面的步驟是沒有辦法正常使用庫的,我們可以通過加-Lpath指定搜尋庫檔案的目錄(-L.表示目前目錄),預設情況下會到環境變量LD_LIBRARY_PATH指定的目錄下搜尋庫檔案,預設情況是/usr/lib,我們可以将庫檔案拷貝到那個目錄下再連結。

比較靜态庫和動态庫我們可以得到二者的優缺點。

  • 動态庫運作時會先檢查記憶體中是否已經有該庫的拷貝,若有則共享拷貝,否則重新加載動态庫(C語言的标準庫就是動态庫)。靜态庫則是每次在編譯階段都将靜态庫檔案打包進去,當某個庫被多次引用到時,記憶體中會有多份副本,浪費資源。
  • 動态庫另一個有點就是更新很容易,當庫發生變化時,如果接口沒變隻需要用新的動态庫替換掉就可以了。但是如果是靜态庫的話就需要重新被編譯。
  • 不過靜态庫也有優點,主要就是靜态庫一次性完成了所有内容的綁定,運作時就不必再去考慮連結的問題了,執行效率會稍微高一些。

makefile編寫

對于大的工程通常涉及很多頭檔案和源檔案,編譯起來很很麻煩,makefile正是為了自動化編譯産生的,makefile像是編譯說明書,訓示編譯的步驟和條件,之後被make指令解釋。

  • 基本規則
    A:B
      (tab)<command>
               
    其中A是語句最後生成的檔案,B是生成A所依賴的檔案,比如生成test.o依賴于test.c和test.h,則寫成test.o:test.c test.h。接下來一行的開頭必須是tab,再往下就是實際的指令了,比如gcc -c test.c -o test.o。
  • 變量

    makefile的書寫非常像shell腳本,可以在檔案中定義"變量名 = 變量值"的形式,之後需要使用這個變量時隻需要寫一個$符号加上變量名即可,當然,和shell一樣,最好用()包裹起語句來。

連結

符号解析

  • 可重定位目标檔案

    對于獨立編譯的可重定位目标檔案,其ELF檔案格式包括ELF頭(指定檔案大小及位元組序)、.text(代碼段)、.rodata(隻讀資料區)、.data(已初始化資料區)、.bss(未初始化全局變量)、.symtab(符号表)等,其中連結時最需要關注的就是符号表。每個可重定位目标檔案都有一張符号表,它包含該子產品定義和引用的符号的資訊,簡而言之就是我們在每個子產品中定義和引用的全局變量(包括定義在本子產品的全局變量、靜态全局變量和引用自定義在其他子產品的全局變量)需要通過一張表來記錄,在連結時通過查表将各個獨立的目标檔案合并成一個完整的可執行檔案。

  • 解析符号表

    解析符号引用的目的是将每個引用與可重定位目标檔案的符号表中的一個符号定義聯系起來。

重定位

  • 合并節

    多個可重定位目标檔案中相同的節合并成一個完整的聚合節,比如多個目标檔案的.data節合并成可執行檔案的.data節。連結器将運作時存儲位址賦予每個節,完成這步每條指令和全局變量都有運作時位址了。

  • 重定位符号引用

    這步修改全部代碼節和資料節對每個符号的符号引用,使其指向正确的運作時位址。局部變量可以通過進棧、出棧臨時配置設定,但全局變量("符号")的位置則是在各個可重定位目标檔案中預留好的。通過上一步合并節操作後,指令中所有涉及符号的引用都會通過一定的尋址方式來定位該符号,比如相對尋址、絕對尋址等。

可執行目标檔案

  • ELF頭部

    描述檔案總體格式,并且包括程式的入口點(entry point),也就是程式運作時執行的第一條指令位址。

  • 段頭部表

    描述了可執行檔案資料段、代碼段等各段的大小、虛拟位址、段對齊、執行權限等。實際上通過段頭部表描繪了虛拟存儲器運作時存儲映像,比如每個UNIX程式的代碼段總是從虛拟位址Ox0804800開始的。

  • 其他段

    和可重定位目标檔案各段基本相同,但完成了多個節的合并和重定位工作。

加載

  • 克隆

    新程式的執行首先需要通過父程序外殼通過fork得到一個子程序,該子程序除了pid等辨別和父程序不同外其他基本均與父程序相同。

  • 重新映射

    當子程序執行execve系統調用時會先清空子程序現有的虛拟存儲器段(簡而言之就是不再映射到父程序的各個段),之後重新建立子程序虛拟存儲器各段和可執行目标檔案各段的映射。這個階段我們可以了解為對複制來的父程序頁表程序重寫,映射到外存中可執行檔案的各個段。

  • 虛頁調入

    加載過程并沒有實際将磁盤中可執行檔案調入記憶體,所做的工作緊緊是複制父程序頁表、清空舊頁表、建立新頁表映射工作。之後加載器跳轉到入口位址_start開始執行程式,接下來的過程需要配合虛拟存儲器來完成。CPU獲得指令的虛拟位址後,若包含該指令或資料的頁尚未調入記憶體則将其從外存中調入,調入記憶體後修改頁表得到虛拟頁号和實體頁号的對應關系。之後重新取同一條指令或資料時因該頁已經被調入記憶體,是以通過虛拟位址得到虛拟頁号,虛拟頁号通過查頁表可以得到實體頁号,通過實體頁号 + 頁内偏移得到具體的實體位址,此時可以通過實體位址取得想要的資料。