iOS 程式設計師的自我修養 — 讀《程式設計師的自我修養 連結、裝載與庫》

李劍飛發表於2017-12-13

2016年國慶假期終於把此書過完,整理筆記和體會於此。

關於書名

書名源於俄羅斯的演員斯坦尼斯拉夫斯基創作的《演員的自我修養》,作者為了寫這本書前前後後修改了三十年之久,臨終前才同意不在修改,拿去出版。使用這個書名一方面書單內容的確不是介紹一門新的程式語言或是展示一些實用的程式設計技術,而是介紹程式執行背後的機制和由來,可以看做是程式設計師的一種“修養”;另一方面是向斯坦尼斯拉夫斯基致敬,向他對作品精益求精的精神致敬。 -- 本書的序言三·餘甲子

本書的組織

本書分為4大部分,分別如下。

第一部分 簡介

第1章 溫故而知新

介紹基本的背景知識,包括硬體、作業系統、執行緒等。

第二部分 靜態連線

第2章 編譯和連結

介紹編譯和連結的基本概念和步驟。

第3章 目標檔案裡有什麼

介紹 COFF 目標檔案格式和原始碼編譯後如何在目標檔案中儲存。

第4章 靜態連結

介紹靜態連結與靜態連結庫的過程和步驟。

第5章 Windows PE/COFF

介紹Windows平臺下的目標檔案和可支援檔案格式

第三部分 裝載與動態連結

第6章 可執行檔案的裝載過程

介紹程式的概念、程式地址空間的分佈和可執行檔案對映裝載過程。

第7章 動態連結

以Linux下的.so共享庫為基礎詳細分析了動態連結的過程。

第8章 Linux共享庫的組織

介紹Linux下共享檔案的分佈和組織

第9章 Windows下的動態連結

介紹Windows系統下的DLL動態連結機制

第四部分 庫與執行時

第10章 記憶體

主要介紹堆與棧,堆的分配演算法,函式呼叫棧分佈。

第11章 執行庫

主要介紹執行庫的概念、C/C++執行庫、Glibc 和 MSVC CRT、執行庫如何實現C++全域性構造和析構以及fread()庫函式為例對執行庫進行剖析。

第12章 系統呼叫與API

主要介紹Linux和Windows的系統呼叫以及Windows的API。

第13章 執行庫的實現

主要實現了一個支援堆、基本檔案操作、格式化字串、基本輸入輸出、C++new/delete、C++string、C++全域性構造和析構的Mini CRT。

重點閱讀章節

  • 第1章 溫故而知新
  • 第2章 編譯和連結
  • 第3章 目標檔案裡有什麼
  • 第4章 靜態連結
  • 第6章 可執行檔案的裝載過程
  • 第7章 動態連結
  • 第10章 記憶體

讀書筆記

溫故而知新

正如第一章的標題一樣,溫故而知新,本章主要講述了計算機硬體和軟體的歷史發展背景。主要有幾點:

  • CPU的頻率目前的“天花板”是4GHz,從2004年後就不再按照摩爾定律增長,因為CPU的製造工藝沒有本質的突破。
  • 理論上講,增加CPU的數量就可以提高運算速度,並且理想情況下,速度的提高與CPU的數量成正比。但實際上並非如此,因為我們的程式不都能分解成若干個完全不相干的子問題。就如一個女人可以花10個月生出一個孩子,但是10個女人並不能在一個月生出一個孩子。

在第二節中,書中講計算機系統軟體的體系結構,有一句至理名言:“電腦科學領域的任何問題都可以通過增加一個間接的中間層來解決”,洋文是:“Any problem in conputer science can be solved by anther layer of indirection.”

看下計算機軟體體系結構圖,理解下這句話的魅力。

2016090617511ComputerSoftwareArchitecture.png

每個層次之間都須要相互通訊,既然須要通訊就必須有一個通訊的協議,我們一般稱為介面(Inerface),介面的下面那層是介面的提供者,又它定義介面;介面的上面那層是介面的使用者,它使用該介面來實習所需要的功能。在層次體系中,介面是被精心設計過的,儘量保持穩定不變,那麼理論上層次之間只要遵循這個介面,任何一個層都可以被修改或被替換。除了硬體和應用程式,其他都是所謂的中間層,每個中間層都是對它下面那層的包裝和擴充套件。

書中歸納了功能,作業系統做什麼?

  • 提供抽象介面;
  • 管理硬體資源;

書中以不要讓CPU打盹這樣一個標題,引出了CPU發展過程中的幾種程式協作模式:

  • 多道程式;
  • 分時系統;
  • 多工系統;

關於記憶體不夠,書中提出了一個問題:如何將計算機上有限的實體記憶體分配給多個程式使用?使用簡單的記憶體分配策略,遇到的幾個問題:

  • 地址空間不隔離;
  • 記憶體使用率低;
  • 程式執行的地址不穩定;

解決這個幾個問題的思路就是我們使用前文提到過的法寶:增加中間層,即使用一種間接的地址訪問方法。整個想法是這樣的,我們把程式給出的地址看做是一種虛擬地址(Virtual Address),然後通過某些對映的方法,將這個虛擬地址轉換成實際的實體地址。這樣只要我們能夠妥善地控制這個虛擬地址到實體地址的對映過程,就可以保證任意一個程式所能夠訪問的實體記憶體區域跟另外一個程式互相不重疊,已達到地址空間隔離的效果。

虛擬地址是為了解決上面的那三個問題,虛擬地址的發展過中,有兩種思路來解決:

  • 分段;把一段與程式所需要的記憶體空間大小的虛擬空間對映到某個地址空間。這個方案解決了第一個和第三個問題,但是沒有解決第二個問題,記憶體的使用效率。
  • 分頁;分頁的基本方法是吧地址空間人為的等分成固定大小的頁,每一頁的大小又硬體決定,或硬體支援多種大小的頁,由作業系統選擇決定頁的大小。

虛擬記憶體的實現需要依靠硬體的支援,對於不同的CPU來說是不同的,但是幾乎所有的硬體都採用了一個MMU(Memory Management Unit)的部件來進行頁對映,流程如:CPU->Virtual Address->MMU->Physical Address->Physical Memory,一般MMU都整合在CPU內部了,不會以單獨的部件存在。

讀到這的時候,我想起了看過的一片部落格:alloc、init你弄懂50%了嗎?

分頁的思想,很像位元組對齊,apple的文件Tips for Allocating Memory是這樣描述的:

When allocating any small blocks of memory, remember that the granularity for blocks allocated by the malloc library is 16 bytes. Thus, the smallest block of memory you can allocate is 16 bytes and any blocks larger than that are a multiple of 16. For example, if you call malloc and ask for 4 bytes, it returns a block whose size is 16 bytes; if you request 24 bytes, it returns a block whose size is 32 bytes. Because of this granularity, you should design your data structures carefully and try to make them multiples of 16 bytes whenever possible.

意思就是:

當我們分配一塊記憶體的時候,假設需要的記憶體小於16個位元組,作業系統會直接分配16個位元組;加入需要的記憶體大於16個位元組,作業系統會分配a*16個位元組。舉個栗子,如果你呼叫malloc並且需要4個位元組,系統會給你一塊16個位元組的記憶體塊;如果你呼叫malloc並且需要24個位元組,系統會給你一塊32個位元組的記憶體塊。

第一章中還講了一些執行緒的基礎知識。什麼是執行緒?

執行緒(Thread),有時被稱為輕量級程式(Lightweight Process,LWP),是程式執行流程的最小單元。

程式內的執行緒如圖:

2016091265404ThreadsWithinTheProcess.png
多個執行緒可以互相不干擾地並併發執行,並共享程式的全域性變數和堆的資料,使用多執行緒的原因有以下幾點:

  • 某個操作可能會陷入長時間等待,等待執行緒會進入睡眠狀態,無法繼續執行。多執行緒執行可以有效利用等待的時間。典型的例子是等待網路響應,這可能要花費數秒甚至數十秒。
  • 某個操作(常常是計算)會消耗大量時間,如果只有一個執行緒,程式和使用者之間的互動會中斷。多執行緒可以讓一個執行緒負責互動,另一個執行緒負責計算。
  • 程式邏輯本身就要求併發操作,例如一個多端下載軟體。
  • 多CPU或多核計算機,本身具備同時執行多個執行緒的能力,因此單執行緒程式無法全面的發揮計算機的全部能力。
  • 相對應多程式應用,多執行緒在資料共享方面效率要高很多。

執行緒的訪問許可權

執行緒的訪問非常自由,它可以訪問程式記憶體裡的所有資料,甚至包括其他執行緒的堆疊,但是實際運用中,執行緒也擁有自己的私有儲存空間,包括以下幾個方面:

  • 棧(儘管並非完全無法被其他執行緒訪問,但一般情況下仍然可以認為是私有的資料)
  • 執行緒區域性儲存(Thread Local Stroage,TLS)。執行緒區域性儲存是某些作業系統為執行緒單獨提供的私有空間,但通常只具有有限的容量。
  • 暫存器(包括PC暫存器),暫存器是執行流的基本資料,因此為此執行緒所有。

![2016091435675Threads and processes data.png](http://7xraw1.com1.z0.glb.clouddn.com/2016091435675Threads and processes data.png)

執行緒的排程與優先順序

還是先說明一下並行和併發的區別

匯流排程數 <= CPU數量:並行執行 匯流排程數 > CPU數量:併發執行

併發是一種模擬出來的狀態,作業系統會讓這些多執行緒程式輪流執行,這的一個不斷在處理器上切換不同的執行緒的行為稱之為執行緒排程(Thread Schedule),線上程排程中,執行緒通常擁有至少三種狀態,分別是:

  • 執行(Running):此時執行緒正在執行;
  • 就緒(Ready):此時執行緒可以立刻執行,但CPU已經被佔用。
  • 等待(Waiting):此時執行緒正在等待某一事件(通常是I/O或同步)發生,無法執行。

處於執行中的執行緒擁有一段可以執行的時間,這段時間稱之為時間片(Time Slice)。 ![2016091477250Thread state switch.png](http://7xraw1.com1.z0.glb.clouddn.com/2016091477250Thread state switch.png)

執行緒排程的方式,主要是以下兩種:

  • 優先順序排程(Priority Schedule)
  • 輪轉法(Round Robin)

執行緒的優先順序改變一般有三種情況:

  • 使用者指定優先順序
  • 根據進入等待狀態的頻繁程度提升或降低優先順序
  • 長時間得不到執行而被提升優先順序

執行緒在用盡時間片之後會被強制剝奪繼續執行的權利,而進入就緒狀態,這個過程叫做搶佔(Preemption),即之後執行別的執行緒搶佔了當前執行緒。

執行緒安全

我們把單指令的操作稱之為原子的,因為無論如何,單條指令的執行是不會被打斷了。

為了避免多個執行緒同時讀寫一個資料而產生不可預料的後果,我們需要將各個執行緒對同一個資料的訪問同步(Synchronization)。所謂同步,既是指在一個執行緒訪問資料未結束的時候,其他執行緒不得對同一個資料進行訪問。如此,對資料的訪問被原子化了。

同步的最常見方法是使用鎖(Lock)。鎖是一種非強制機制。每一個執行緒在訪問資料或資源之前首先試圖獲取(Acqurie),並在訪問結束之後**釋放(Release)**鎖。在鎖已經被佔用的時候試圖獲取鎖時,執行緒會等待,直到鎖重新可用。

這裡總結下 iOS 中常用的幾種鎖:

  • @synchronized

    NSObject *obj = [[NSObject alloc] init];
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        @synchronized(obj) {
            NSLog(@"需要執行緒同步的操作1 開始");
            sleep(3);
            NSLog(@"需要執行緒同步的操作1 結束");
        }
    });
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        sleep(1);
        @synchronized(obj) {
            NSLog(@"需要執行緒同步的操作2");
        }
    });
    
    複製程式碼

    @synchronized(obj)指令使用的obj為該鎖的唯一標識,只有當標識相同時,才為滿足互斥,如果執行緒2中的@synchronized(obj)改為@synchronized(self),剛執行緒2就不會被阻塞,@synchronized指令實現鎖的優點就是我們不需要在程式碼中顯式的建立鎖物件,便可以實現鎖的機制,但作為一種預防措施,@synchronized塊會隱式的新增一個異常處理例程來保護程式碼,該處理例程會在異常丟擲的時候自動的釋放互斥鎖。所以如果不想讓隱式的異常處理例程帶來額外的開銷,你可以考慮使用鎖物件。

    上面結果的執行結果為:

    需要執行緒同步的操作1 開始
    需要執行緒同步的操作1 結束
    需要執行緒同步的操作2
    複製程式碼
  • NSLock

    NSLock *lock = [[NSLock alloc] init];
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        //[lock lock];
        [lock lockBeforeDate:[NSDate date]];
            NSLog(@"需要執行緒同步的操作1 開始");
            sleep(2);
            NSLog(@"需要執行緒同步的操作1 結束");
        [lock unlock];
    
    });
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        sleep(1);
        if ([lock tryLock]) {//嘗試獲取鎖,如果獲取不到返回NO,不會阻塞該執行緒
            NSLog(@"鎖可用的操作");
            [lock unlock];
        }else{
            NSLog(@"鎖不可用的操作");
        }
    
        NSDate *date = [[NSDate alloc] initWithTimeIntervalSinceNow:3];
        if ([lock lockBeforeDate:date]) {//嘗試在未來的3s內獲取鎖,並阻塞該執行緒,如果3s內獲取不到恢復執行緒, 返回NO,不會阻塞該執行緒
            NSLog(@"沒有超時,獲得鎖");
            [lock unlock];
        }else{
            NSLog(@"超時,沒有獲得鎖");
        }
    
    });
    
    複製程式碼

    NSLock是Cocoa提供給我們最基本的鎖物件,這也是我們經常所使用的,除lock和unlock方法外,NSLock還提供了tryLock和lockBeforeDate:兩個方法,前一個方法會嘗試加鎖,如果鎖不可用(已經被鎖住),剛並不會阻塞執行緒,並返回NO。lockBeforeDate:方法會在所指定Date之前嘗試加鎖,如果在指定時間之前都不能加鎖,則返回NO。 上面程式碼的執行結果為:

    需要執行緒同步的操作1 開始
    鎖不可用的操作
    需要執行緒同步的操作1 結束
    沒有超時,獲得鎖
    複製程式碼
  • NSRecursiveLock 遞迴鎖

    //NSLock *lock = [[NSLock alloc] init];
    NSRecursiveLock *lock = [[NSRecursiveLock alloc] init];
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    
        static void (^RecursiveMethod)(int);
    
        RecursiveMethod = ^(int value) {
    
            [lock lock];
            if (value > 0) {
    
                NSLog(@"value = %d", value);
                sleep(1);
                RecursiveMethod(value - 1);
            }
            [lock unlock];
        };
    
        RecursiveMethod(5);
    });
    
    複製程式碼

    NSRecursiveLock實際上定義的是一個遞迴鎖,這個鎖可以被同一執行緒多次請求,而不會引起死鎖。這主要是用在迴圈或遞迴操作中。

    這段程式碼是一個典型的死鎖情況。在我們的執行緒中,RecursiveMethod是遞迴呼叫的。所以每次進入這個block時,都會去加一次鎖,而從第二次開始,由於鎖已經被使用了且沒有解鎖,所以它需要等待鎖被解除,這樣就導致了死鎖,執行緒被阻塞住了。偵錯程式中會輸出如下資訊:

    value = 5
    -[NSLock lock]: deadlock (<NSLock: 0x7fd811d28810> '(null)')
    Break on _NSLockError() to debug.
    複製程式碼

    在這種情況下,我們就可以使用NSRecursiveLock。它可以允許同一執行緒多次加鎖,而不會造成死鎖。遞迴鎖會跟蹤它被lock的次數。每次成功的lock都必須平衡呼叫unlock操作。只有所有達到這種平衡,鎖最後才能被釋放,以供其它執行緒使用。

    如果我們將NSLock代替為NSRecursiveLock,上面程式碼則會正確執行。

    value = 5
    value = 4
    value = 3
    value = 2
    value = 1
    複製程式碼
  • NSConditionLock 條件鎖

    NSMutableArray *products = [NSMutableArray array];
    
    NSInteger HAS_DATA = 1;
    NSInteger NO_DATA = 0;
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        while (1) {
            [lock lockWhenCondition:NO_DATA];
            [products addObject:[[NSObject alloc] init]];
            NSLog(@"produce a product,總量:%zi",products.count);
            [lock unlockWithCondition:HAS_DATA];
            sleep(1);
        }
    
    });
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        while (1) {
            NSLog(@"wait for product");
            [lock lockWhenCondition:HAS_DATA];
            [products removeObjectAtIndex:0];
            NSLog(@"custome a product");
            [lock unlockWithCondition:NO_DATA];
        }
    
    });
    
    複製程式碼

    當我們在使用多執行緒的時候,有時一把只會lock和unlock的鎖未必就能完全滿足我們的使用。因為普通的鎖只能關心鎖與不鎖,而不在乎用什麼鑰匙才能開鎖,而我們在處理資源共享的時候,多數情況是隻有滿足一定條件的情況下才能開啟這把鎖:

    線上程1中的加鎖使用了lock,所以是不需要條件的,所以順利的就鎖住了,但在unlock的使用了一個整型的條件,它可以開啟其它執行緒中正在等待這把鑰匙的臨界地,而執行緒2則需要一把被標識為2的鑰匙,所以當執行緒1迴圈到最後一次的時候,才最終開啟了執行緒2中的阻塞。但即便如此,NSConditionLock也跟其它的鎖一樣,是需要lock與unlock對應的,只是lock,lockWhenCondition:與unlock,unlockWithCondition:是可以隨意組合的,當然這是與你的需求相關的。

    上面程式碼執行結果如下:

    wait for product
    produce a product,總量:1
    custome a product
    wait for product
    produce a product,總量:1
    custome a product
    wait for product
    produce a product,總量:1
    custome a product
    複製程式碼
  • NSCondition

    NSCondition *condition = [[NSCondition alloc] init];
    
    NSMutableArray *products = [NSMutableArray array];
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        while (1) {
            [condition lock];
            if ([products count] == 0) {
                NSLog(@"wait for product");
                [condition wait];
            }
            [products removeObjectAtIndex:0];
            NSLog(@"custome a product");
            [condition unlock];
        }
    
    });
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        while (1) {
            [condition lock];
            [products addObject:[[NSObject alloc] init]];
            NSLog(@"produce a product,總量:%zi",products.count);
            [condition signal];
            [condition unlock];
            sleep(1);
        }
    
    });
    
    複製程式碼

    一種最基本的條件鎖。手動控制執行緒wait和signal。

    [condition lock];一般用於多執行緒同時訪問、修改同一個資料來源,保證在同一時間內資料來源只被訪問、修改一次,其他執行緒的命令需要在lock 外等待,只到unlock ,才可訪問

    [condition unlock];與lock 同時使用

    [condition wait];讓當前執行緒處於等待狀態

    condition signal];CPU發訊號告訴執行緒不用在等待,可以繼續執行

    上面程式碼執行結果如下:

    wait for product
    produce a product,總量:1
    custome a product
    wait for product
    produce a product,總量:1
    custome a product
    wait for product
    produce a product,總量:1
    custome a product
    複製程式碼
  • pthread_mutex

    __block pthread_mutex_t theLock;
    pthread_mutex_init(&theLock, NULL);
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            pthread_mutex_lock(&theLock);
            NSLog(@"需要執行緒同步的操作1 開始");
            sleep(3);
            NSLog(@"需要執行緒同步的操作1 結束");
            pthread_mutex_unlock(&theLock);
    
    });
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            sleep(1);
            pthread_mutex_lock(&theLock);
            NSLog(@"需要執行緒同步的操作2");
            pthread_mutex_unlock(&theLock);
    
    });
    複製程式碼

    c語言定義下多執行緒加鎖方式。

    1. pthread_mutex_init(pthread_mutex_t mutex,const pthread_mutexattr_t attr); 初始化鎖變數mutex。attr為鎖屬性,NULL值為預設屬性。
    2. pthread_mutex_lock(pthread_mutex_t mutex);加鎖
    3. pthread_mutex_tylock(*pthread_mutex_t *mutex);加鎖,但是與2不一樣的是當鎖已經在使用的時候,返回為EBUSY,而不是掛起等待。
    4. pthread_mutex_unlock(pthread_mutex_t *mutex);釋放鎖
    5. pthread_mutex_destroy(pthread_mutex_t* mutex);使用完後釋放

    程式碼執行操作結果如下:

    需要執行緒同步的操作1 開始
    需要執行緒同步的操作1 結束
    需要執行緒同步的操作2
    複製程式碼
  • pthread_mutex(recursive)

    __block pthread_mutex_t theLock;
    //pthread_mutex_init(&theLock, NULL);
    
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
    pthread_mutex_init(&lock, &attr);
    pthread_mutexattr_destroy(&attr);
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    
        static void (^RecursiveMethod)(int);
    
        RecursiveMethod = ^(int value) {
    
            pthread_mutex_lock(&theLock);
            if (value > 0) {
    
                NSLog(@"value = %d", value);
                sleep(1);
                RecursiveMethod(value - 1);
            }
            pthread_mutex_unlock(&theLock);
        };
    
        RecursiveMethod(5);
    });
    複製程式碼

    這是pthread_mutex為了防止在遞迴的情況下出現死鎖而出現的遞迴鎖。作用和NSRecursiveLock遞迴鎖類似。

    如果使用pthread_mutex_init(&theLock, NULL);初始化鎖的話,上面的程式碼會出現死鎖現象。如果使用遞迴鎖的形式,則沒有問題。

  • OSSpinLock

    __block OSSpinLock theLock = OS_SPINLOCK_INIT;
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        OSSpinLockLock(&theLock);
        NSLog(@"需要執行緒同步的操作1 開始");
        sleep(3);
        NSLog(@"需要執行緒同步的操作1 結束");
        OSSpinLockUnlock(&theLock);
    
    });
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        OSSpinLockLock(&theLock);
        sleep(1);
        NSLog(@"需要執行緒同步的操作2");
        OSSpinLockUnlock(&theLock);
    
    });
    複製程式碼

    OSSpinLock 自旋鎖,效能最高的鎖。原理很簡單,就是一直 do while 忙等。它的缺點是當等待時會消耗大量 CPU 資源,所以它不適用於較長時間的任務。 不過YY在自己的部落格不再安全的 OSSpinLock 中說明了OSSpinLock已經不再安全。

關於同步,還有一種二元訊號量(Binary Semaphore)是最簡單的一種鎖,它只有兩種狀態,佔用與非佔用。它適合只能被唯一一個執行緒單獨訪問的資源。當二元訊號量處於非佔用狀態時,第一個試圖獲取該二元訊號量的執行緒會獲得該鎖,並將二元訊號量置為佔用狀態,此後其他所有試圖獲取該二元訊號量的執行緒將會等待,指導改鎖被釋放。

對於允許多個執行緒併發訪問的資源,多元訊號量簡稱訊號量(Semaphore),它是一個很好的選擇。一個初始值為N的訊號量允許N個執行緒併發訪問。執行緒訪問資源的時候首先獲取訊號量,進行如下操作:

  • 將訊號量的值減1
  • 如果訊號量的值小於0,則進入等待狀態,否則繼續執行。

訪問完資源之後,執行緒釋放訊號量,進行如下操作:

  • 將訊號量的值加1
  • 如果訊號量的值小於1,喚醒一個等待中的執行緒。

iOS 中訊號量的相關用法為 dispatch_semaphore

dispatch_semaphore_t signal = dispatch_semaphore_create(1);
    dispatch_time_t overTime = dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC);

    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        dispatch_semaphore_wait(signal, overTime);
            NSLog(@"需要執行緒同步的操作1 開始");
            sleep(2);
            NSLog(@"需要執行緒同步的操作1 結束");
        dispatch_semaphore_signal(signal);
    });

    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        sleep(1);
        dispatch_semaphore_wait(signal, overTime);
            NSLog(@"需要執行緒同步的操作2");
        dispatch_semaphore_signal(signal);
    });

複製程式碼

dispatch_semaphore是GCD用來同步的一種方式,與他相關的共有三個函式,分別是dispatch_semaphore_create,dispatch_semaphore_signal,dispatch_semaphore_wait。

  • dispatch_semaphore_create

    dispatch_semaphore_t dispatch_semaphore_create(long value);
    複製程式碼

傳入的引數為long,輸出一個dispatch_semaphore_t型別且值為value的訊號量。  值得注意的是,這裡的傳入的引數value必須大於或等於0,否則dispatch_semaphore_create會返回NULL。 ```

  • dispatch_semaphore_signal

    long dispatch_semaphore_signal(dispatch_semaphore_t dsema)
    複製程式碼

這個函式會使傳入的訊號量dsema的值加1; ```

  • dispatch_semaphore_wait

    long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout);
    這個函式會使傳入的訊號量dsema的值減1;這個函式的作用是這樣的,如果dsema訊號量的值大於0,該函式所處執行緒就繼續執行下面的語句,並且將訊號量的值減1;如果desema的值為0,那麼這個函式就阻塞當前執行緒等待timeout(注意timeout的型別為dispatch_time_t,不能直接傳入整形或float型數),如果等待的期間desema的值被dispatch_semaphore_signal函式加1了,且該函式(即dispatch_semaphore_wait)所處執行緒獲得了訊號量,那麼就繼續向下執行並將訊號量減1。如果等待期間沒有獲取到訊號量或者訊號量的值一直為0,那麼等到timeout時,其所處執行緒自動執行其後語句。
    複製程式碼

dispatch_semaphore 是訊號量,但當訊號總量設為 1 時也可以當作鎖來。在沒有等待情況出現時,它的效能比 pthread_mutex 還要高,但一旦有等待情況出現時,效能就會下降許多。相對於 OSSpinLock 來說,它的優勢在於等待時不會消耗 CPU 資源。

如上的程式碼,如果超時時間overTime設定成>2,可完成同步操作。如果overTime<2的話,線上程1還沒有執行完成的情況下,此時超時了,將自動執行下面的程式碼。

上面程式碼的執行結果為:

需要執行緒同步的操作1 開始
需要執行緒同步的操作1 結束
需要執行緒同步的操作2
複製程式碼

如果把超時時間設定為<2s的時候,執行的結果就是:

需要執行緒同步的操作1 開始
需要執行緒同步的操作2
需要執行緒同步的操作1 結束
複製程式碼

YY 關於這幾種鎖的效能測試(定性分析)結果如下圖:

2016091483051lock_benchmark.png

多執行緒內部情況

執行緒的併發執行是由多處理器或作業系統排程來實現的。但實際情況要更為複雜一些:大多數作業系統,包括windows和Linux,都在核心裡提供執行緒的支援,核心執行緒也是由多處理器或排程來實現併發。然後使用者實際使用的執行緒並不是核心執行緒,而是存在於使用者態的使用者執行緒。使用者執行緒並不一定在作業系統核心裡對應同等數量的核心執行緒,例如某些輕量級的執行緒庫,對使用者來說如果有三個執行緒同時在執行,對核心來說很可能只有一個執行緒。

使用者態和核心態的三種執行緒模型如下:

  • 一對一模型 ![2016091444276One to one thread model.png](http://7xraw1.com1.z0.glb.clouddn.com/2016091444276One to one thread model.png) 這樣使用者執行緒就具有了和核心執行緒一致的優點,執行緒之間的併發是真正的併發,一個執行緒因為某種原因阻塞時,其他執行緒執行不會受到影響。

    一般直接使用API或系統建立的執行緒均為一對一的執行緒。

    一對一執行緒有兩個缺點:

    • 由於許多作業系統限制了核心執行緒的數量,因此一對一執行緒會讓使用者的執行緒數量受到限制。
    • 許多作業系統核心執行緒排程時,上下文切換的開銷較大,導致使用者執行緒的執行效率下降。
  • 多對一模型 ![2016091487869More of a process model.png](http://7xraw1.com1.z0.glb.clouddn.com/2016091487869More of a process model.png) 多對一模型將多個使用者執行緒對映到一個核心執行緒上,執行緒之間的切換由使用者態的程式碼來進行,因此相對於一對一模型,多對一模型的執行緒切換要快許多。 多對一模型的一大問題是,如果其中一個使用者執行緒阻塞,那麼所有的執行緒都將無法執行,因為此時核心裡的執行緒也隨之阻塞了。另外,在多處理器系統上,處理器的增多對多對一模型的執行緒效能也不會有明顯的幫助。但同時,多對一模型得到的好處是高效的上下文切換和幾乎無限制的執行緒數量。

  • 多對多模型 ![2016091439628More for multithreaded model.png](http://7xraw1.com1.z0.glb.clouddn.com/2016091439628More for multithreaded model.png) 多對多模型結合了多對一模型和一對一模型的特點,將多個使用者執行緒對映到少數但不止一個核心執行緒上。在多對多模型中,一個使用者執行緒阻塞並不會使得所有使用者執行緒阻塞,因為此時還有別的執行緒可以被排程來執行。另外,多對多模型對使用者執行緒的數量也沒什麼限制,在多處理器其他上,多對多模型的執行緒也能得到一定效能的提升,不過提升的幅度不如一對一模型高。

編譯和連結

對於平常的應用開發,我們很少關注編譯和連結的過程,因為通常的開發環境都是流程的整合開發環境(IDE)。IDE 一般都將編譯和連結的過程一步執行完成,通常這種編譯和連結合併到一起的過程稱為構建(Build) 即使使用命令列來編譯一個原始碼檔案,簡單的一句"gcc hello.c"命令就包含了非常複雜的過程。

IDE和編譯器提供的預設配置、編譯和連結引數對於大部分的應用程式開發而言已經足夠使用了。但是在這樣的開發過程中,我們往往會被這些複雜的整合工具提供強大的功能所迷惑,很多系統軟體的執行機制與機理被掩蓋,其程式的很多莫名其妙的錯誤讓我們無所適從,而對程式執行時種種效能瓶頸我們素手無策。如果能夠深入瞭解這些機制,那麼解決這些問題就能夠遊刃有餘,收放自如了。

#include "hello.h"

int main()
{
    printf("Hello World\n");
    return 0;
}
複製程式碼

在Linux下,我們使用GCC來編譯程式時,只須使用最簡單的命令

gcc hello.c
./a.out
Hello World
複製程式碼

上述過程可以分解為4步:

  • 預處理(Prepressing)
  • 編譯(Compilation)
  • 彙編(Assembly)
  • 連結(Linking) ![2016091428338GCC compiler process decomposition.png](http://7xraw1.com1.z0.glb.clouddn.com/2016091428338GCC compiler process decomposition.png)

預編譯

預編譯過程主要處理那些原始碼檔案中以"#"開頭的預編譯指令。主要規則如下:

  • 將所有的 "#define" 刪除,並且展開所有的巨集定義。
  • 處理所有條件預編譯指令,比如:"#if","#ifdef","elif","#else","#endif"。
  • 處理"#include"預編譯指令,將被包含的檔案插入到改預編譯指令的位置。注意,這個過程是遞迴進行的,也就是說被包含的檔案可能還包含其他檔案。
  • 刪除所有的註釋 "//" 和 "/**/"。
  • 新增行號和檔案標識,比如 #2"helloc.c"2,以便於編譯時編譯器產生試用的行號資訊及用於編譯時產生便於錯誤或警告時能夠顯示的行號。
  • 保留所有的 #pargma 編譯指令,因為編譯器需要使用它們。

經過預編譯後的 .i 檔案不包含任何巨集定義,因為所有的巨集已經被展開,並且包含的檔案也已經被插入到 .i 檔案中。所以當我們無法判斷巨集定義是否正確或標頭檔案包含是否正確時,可以檢視預編譯後的檔案來確定問題。

第一步預編譯的過程相當於如下命令:

gcc -E hello.c -o hello.i
複製程式碼

編譯

編譯過程就是把預處理的檔案進行一系列詞法分析、語法分析、語義分析以及優化後生產相應的彙編程式碼檔案,這個過程往往是我們所說的整個程式構建的核心部分,也是最複雜的部分之一。上面的編譯過程相當於如下命令:

gcc -S hello.i -o hello.s
複製程式碼

最直觀的角度,編譯器就是將高階語言翻譯成機器語言的一個工具。高階語言使得程式設計師能夠更加關注程式邏輯的本身,而儘量少考慮計算機本身的限制。高階程式語言的出現使得程式開發的效率大大提高,據研究,高階語言的開發效率是組合語言和機器語言的5倍以上。

編譯過程一般可以分為6步:掃描,語法分析,語義分析,原始碼優化,程式碼生成和目的碼優化,整個過程如下: ![2016091652955The build process.png](http://7xraw1.com1.z0.glb.clouddn.com/2016091652955The build process.png)

彙編

彙編器是將彙編程式碼轉變成機器可以執行的指令,每一個彙編語句幾乎都對應一條機器指令。所以彙編器的彙編過程相對於編譯器來講是比較簡單,它沒有複雜的語法,也沒用語法,也沒有語義,也不需要做指令優化,只是根據彙編指令和機器指令的對照表一一翻譯就可以了,“彙編”這個名字也源於此。

上面的彙編過程可以呼叫匯編器as來完成:

as hello.s -o hello.o
複製程式碼

或者

gcc -c hello.s -o hello.o
複製程式碼

連結

連結通常是一個讓人比較費解的過程,為什麼彙編器不直接輸出可執行檔案而是輸出一個目標檔案呢?連結過程到底包含了什麼內容?為什麼要連結?

書中簡單的回顧了下計算機發展的歷史,簡單來講就是隨之軟體規模越來越大,每個程式被分成了多個模組,這些模組的拼接過程就叫:連結(Linking)。

連結過程主要包括了:

  • 地址和空間的分配(Address and Storage Alloction)
  • 符號決議(Symbol Resolution)Ps:"決議"更傾向於靜態連結,而"繫結"更傾向於動態連結。
  • 重定位(Relocation)

最基本的靜態連結過程如下圖: ![2016091665800Link process.png](http://7xraw1.com1.z0.glb.clouddn.com/2016091665800Link process.png) 每個模組的原始碼檔案(如.c)檔案經過編譯器編譯成目標檔案(Object File,一般副檔名為.o 或.obj),目標檔案和庫一起連結形成最終的可執行檔案。

重定位的過程如下:

假設有個全域性變數叫做var,它在目標檔案A裡面。我們在目標檔案B裡面要訪問這個全域性變數。由於在編譯目標檔案B的時候,編譯器並不知道變數var的目標地址,所以編譯器在沒法確定的情況下,將目標地址設定為0,等待連結器在目標檔案A和B連線起來的時候將其修正。這個地址修正的過程被叫做重定位,每個被修正的地方叫一個重定位入口

目標檔案

編譯器編譯原始碼後生成的檔案叫做目標檔案

現在PC平臺流行的**可執行檔案(Executable)**主要是Windows下的PE(Portable Executable)和Linux的ELF(Executable Linkabel Format),它們都是COFF(Common file format)格式的變種。

目標檔案就是原始碼編譯後但未進行連結的那些中間檔案(Widdows的.obj和Linux下的.o)。

從廣義上看,目標檔案與可執行檔案的格式其實幾乎一樣,所以我們可以統稱他們為PE-COFF檔案格式。在Linux下,我們可以將他們統稱為ELF檔案。

動態連結庫(DLL,Dynamic Linking Library)(Windows的.dll和Linux的.so)以及靜態連結庫(Static Linking Library)(Windows的.lib 和Linux的.a)檔案都按照可執行檔案格式儲存。

靜態連結庫稍有不同,它是把很多目標檔案的檔案捆綁在一起形成一個檔案,再加上一些索引,簡單的理解:一個包含有很多目標檔案的檔案包。

ELF 檔案型別 說明 例項
可重定位檔案(Relocation File) 這類檔案包含了程式碼和資料,可以被用來連結成可執行檔案或共享目標檔案,靜態連結庫也可以歸為這一類 Linux的.o Windows的.obj
可執行檔案(Executable File) 這類檔案包含了可以直接執行的程式,它的代表就是ELF可執行檔案,它們一般都是沒有副檔名 比如/bin/bash 檔案 Windows 的.exe
共享目標檔案(Shared Object File) 這種檔案包含了程式碼和資料,可以在以下兩種情況下使用。一種是連結器可以使用這種檔案跟其他可重定位和共享目標檔案連結,產生新的目標檔案。第二種是動態連結器可以將幾個這種共享目標檔案與可執行檔案結合,作為程式映像的一部分來執行 Linux的.so 如/lib/glibc-2.5.so Windows的DLL
核心轉存檔案(Core Dump File) 當程式意外終止時,系統可以將該程式的地址空間的內容以及終止時的一些其他資訊轉儲到核心轉儲檔案 Linux下的 core dump

目標檔案什麼樣子

iOS 程式設計師的自我修養 — 讀《程式設計師的自我修養 連結、裝載與庫》

這種型別的圖在講block或者其他記憶體問題時經常能看到的。

程式原始碼編譯後的機器指令經常被放在程式碼段(Code Section),程式碼段常見的名字有".code",或".text";全域性和區域性靜態變數資料經常被放在資料段(Data Section),資料段的一般名字都叫".data"。

看一個簡單的程式被編譯成目標檔案後的結構。

![2016091941627Program and the target file.png](http://7xraw1.com1.z0.glb.clouddn.com/2016091941627Program and the target file.png)

其他段大家一看就明白。未初始化的全域性變數和區域性靜態變數一般被放在一個叫".bss"的段裡(BSS - Block Started by Symbol)。未初始化的全域性變數和區域性靜態變數預設值都為0,本來它們也可以被放在.data端裡,但是因為它們都是0,所有為它們在.data端分開空間並且儲存資料0是沒有必要的。程式執行的時候它們的確是要站儲存空間的,並且可執行檔案必須記錄所有未初始化的全域性變數和區域性靜態變數的大小總和,記為.bss段。**所以.bss段只是未初始化的全域性變數和區域性靜態變數預留位置而已,**它並沒有內容,所以它在檔案中也不佔據空間。

總體來說,程式源代被編譯以後主要分成兩種段:程式指令和程式資料。程式碼段屬於程式指令,而資料端和.bss段屬於程式資料。

為什麼要把程式的指令和資料的存放分開?

  • 當程式被裝載後,資料和指令分別被對映到了兩個虛存區域。資料區域對於程式來說是可讀寫的,而指令區域對於程式來說是隻讀的,所以這兩個虛存區域的許可權可以被分別設定成可讀寫和只讀。這樣可以防止程式指令被有意或無意的改寫。

  • 現代CPU有這極為強大的快取體系,所以程式必須儘量提高快取的命中率。指令區和資料區的分離有利於提高程式的區域性性。現代CPU的快取一般都被設計成資料快取和指令快取分離,所以程式的指令和資料被分開存放對CPU的快取命中率提高有好處。

  • 最後一點也是最重要的一點,就是當系統中執行著多個改程式的副本時,它們的指令都是一樣的,所有記憶體中只須要儲存一份改程式的指令部分。對於指令這種只讀的區域來說是這樣的,對於其他的只讀資料也是一樣。當然每個副本程式的資料區域是不一樣的,它們是程式私有的。

除了.text、.data、.bss這三個最常用的段之外,ELF檔案也可能含有其他段,用來儲存於程式相關的其他資訊。 ![2016092063938Other segments.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092063938Other segments.png)

ELF檔案結構

省去EFL一些繁瑣的結構,把最重要的結構提取出來,形成了下面的EFL檔案的基本結構圖。 ![2016092093390EFL structure.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092093390EFL structure.png) EFL目標檔案格式的還包含了一下內容:

  • EFL標頭檔案(EFL Header) 它包含描述了整個檔案的基本屬性,比如ELF檔案版本、目標機器型號、程式入口地址等。
  • 各個段
  • 段表(Sextion Header Tabel) 描述了EFL檔案包含的所有段的資訊,比如每個段的段名、段的長度、在檔案中的偏移、讀寫許可權以及段的其他屬性。EFL檔案的段結構就是由段表決定的,編譯器、連結器和裝載器都是依靠段表來定位和訪問各個段的屬性的。
  • 重定位表 連結器在處理目標檔案時,須要對目標檔案中的某些部位進行重定位,即程式碼段和資料段中那些絕對地址的引用的位置。這些重定位資訊都記錄在EFL檔案的重定位表裡,對於每個須要重定位的程式碼段,都會有一個響應的重定位表。
  • 字串表 ELF 檔案中用到了很多字串,比如段名、變數名等。因為字串的長度往往是不定的,所以用固定的結構來標示它比較困難。一種很常見的做法是把字串集中起來放到一個表,然後使用字串在表中的偏移量來引用字串。一般字串表分別為字串表(String Table)和段表字串表(Section Header String Tabel)。顧名思義,字串表是用來儲存普通的字串,段表字串表是用來儲存段表中用到的字串。

連結的介面--符號

連結的過程本質就是要把多個不同的目標檔案之間相互"粘到一起",或著說像一個玩具積木一樣,可以拼裝成一個整體。為了使不同目標檔案之間能夠相互粘合,這些目標檔案之間必須有固定的規則才行,就像積木模組必須有凹凸的部分才能夠拼合。

在連結中目標檔案之間互相拼合實際上是目標檔案之間對地址的引用,即對函式和變數地址的引用。比如目標檔案B要用到目標檔案A中的函式“foo”,那麼我們就稱目標檔案A**定義(Define)了函式“foo”,稱目標檔案B引用(Reference)**了目標檔案A中的函式“foo”。這樣的概念同樣適用於變數。每個函式或變數都有自己獨特的名字,才能避免連結過程中不同變數和函式之間的混淆。

在連結中,我們將函式和變數統稱為符號(Symbol),函式名或變數名就是符號名(Symbol Name)

符號修飾和函式簽名

約在20世紀70年代以前,編譯器編譯原始碼產生的目標檔案時,符號名與相應的變數函式的名字是一樣的。為了防止類似的符號名衝突,Unix下的C語言就規定,C語言原始碼檔案中的所以全域性變數和函式經過編譯以後,相對於的符號名前加上下劃線“_”。當然這也成為了歷史。

簡單的例子,兩個相同名字的函式func(int)和func(double),儘管函式名稱相同,但是引數列表不同,這是C++裡面函式過載的最簡單的一種情況,那麼編譯器和連結器在連結過程中如何區分這兩個函式呢?為了支援C++這些複雜的特性,人們發明了**符號修飾(Name Decoration)符號改編(Name Mangling)**的機制。

兩個同名函式func,只不過它們的返回型別和引數及所在的名稱空間不同。引入了一個術語叫做函式簽名(Function Signature),函式簽名包含了一個函式的資訊,包括函式名、引數、它所在的類和名稱空間以及其他資訊。在編譯器以及連結器處理符號時,使用了名稱修飾的方法,使得每個函式簽名對應一個修飾後的名稱(Decorated Name)

弱符號和強符號 | 弱引用和強引用

我們經常在程式設計中碰到了一種情況叫符號重複定義。多個目標檔案中含有相同名字全域性符號的定義,那麼這些目標檔案連結的時候將會出現符號重複定義的錯誤。

出現衝的符號的定義可以被稱為強符號(Strong Symbol)。有些符號的定義可以被稱為弱符號(Weak Symbol)。對於C/C++語言來說,編譯器預設函式和初始化的全域性變數為強符號,未初始化的全域性變數為弱符號。我們也可以通過GCC的“attribute((weak))”來定義任何一盒強符號為弱符號。

注意:強符號和弱符號都是針對定義來說的,不是針對符號的引用。例如下面這段程式:

extern int ext;

int weak ;
int strong = 1;
__attribute__((weak)) weak2 = 2;

int main() 
{
    return 0;
}

複製程式碼

"weak"和"weak2"是弱符號(我測試了下,weak2 改成weak 加了編譯屬性,不會引起符號重複定義報錯),"strong"和"main"是強符號,而"ext"既非強符號也非弱符號,因為他是一個外部變數引用。針對強弱符號的概念,連結器會按如下規則處理與選擇被多次定義的符號:

  • 規則1:不允許強符號被多次定義(即不同的目標檔案中不能有同名的強符號);如果有多個強符號定義,則連結器報符號重複定義的錯誤;
  • 規則2:如果一個符號在某個目標檔案中是強符號,在其他檔案中都是弱符號,那麼現在強符號。
  • 規則3:如果一個符號在所有目標檔案中都是弱符號,那麼選擇其中佔用空間最大的一個。

弱引用和強引用。目前我們所看到的對外部目標檔案的符號引用在目標檔案被最終連結成執行檔案時,他們需要被正確的決議,如果沒有找到該符號的定義,連結器就會報符號未定閱的錯誤,這種被稱為強引用(Strong Reference)。與之相對應的還有一種弱引用(Weak Reference),在處理弱引用時,如果該符號有定義,則連結器將該符號的引用決議;如果該符號未被定義,則連結器對於該引用不報錯。連結器處理強引用和弱引用的過程幾乎一樣,只是對於未定義的弱引用,連結器不認為它是一個錯誤。一般對於未定義的弱引用,連結器預設其為0,或者一個特殊的值,以便於程式程式碼能夠識別。

使用“attribute((weak))”來宣告對一個外部函式的引用為弱引用,例子:

__attribute__((weakref)) void foo()
int main()
{
    foo();
}
複製程式碼

它可以編譯成一個可執行檔案,GCC並不會報連結錯誤。但是當我們執行這個可執行檔案時,會發生執行時錯誤。因為當main函式試圖呼叫foo函式時,foo函式的地址為0,於是發生了非法地址訪問的錯誤。改進後:

__attribute__((weakref)) void foo()
int main()
{
    if(foo) foo();
}
複製程式碼

弱引用和弱符號主要用於庫的連結過程。比如庫中定義的弱符號可以被使用者定義強符號所覆蓋,從而使得程式可以使用自定義版本中的函式庫;或者程式可以對某些擴充套件功能模組的引用定義為弱引用,當我們將擴充套件模組與程式連結在一起時候,功能模組就可以正常使用;如果我們去掉了某些功能模組,那麼程式也可以正常的連結,只是缺少了響應發功能,這使得程式的功能更加容易剪裁和組合。

除錯資訊

目標檔案裡面還有可能儲存的是除錯資訊。幾乎所有的現代編譯器都支援原始碼級別的除錯,比如我們可以在函式裡面設定斷點,可以監聽變數變化,可以單步進行等,前提是編譯器必須提前將原始碼與目的碼之間的關係等,比如目的碼中的地址對於原始碼中的哪一行、函式和變數的型別、結構體的定義、字串儲存到目標檔案裡面。設定有些高階的編譯器和偵錯程式支援檢視STL容器的內容。想想xcode在除錯時就支援檢視各種容器的內容,還有image影象等。

除錯資訊在目標檔案和可執行檔案中佔很大的空間,往往比程式和資料本身大好幾倍,所以當我們開發完程式並要將它釋出的時候,須要吧這些對於使用者沒有用的除錯資訊去掉,以節省大量空間。在Linux下,我們可以使用“strip”命令來去掉ELF檔案中的除錯資訊;

strip foo
複製程式碼

想想Xcode在build Configuration 的時候,也會選擇Debug 還是 release,選擇release時,在執行的時候,程式crash了,就不會再xcode中提示crash原因和位置。

靜態連結

由於連結形式的不同,產生了靜態連結和動態連結。當我們有兩個目標檔案時,如何將它們連結起來形成一個可執行檔案?這個過程中發生了什麼?這基本就是連結的核心內容。

整個連結過程分為兩步:

  • 第一步:空間與地址的分配 掃描所有的輸入目標檔案,並獲得它們各個段的長度、屬性和位置,並且將輸入目標檔案中的符號表中所有的符號定義和符號引用收集起來,統一放到一個全域性符號表。這一步中,連結器將能夠獲得所有輸入目標檔案的段長度,並且將它們合併,計算出輸出檔案中各個段合併後的長度與位置,並建立對映關係。
  • 第二步:符號解析與重定位 使用上面第一步中收集到的所有資訊,讀取輸入檔案中斷的資料、重定位資訊,並且進行符號解析與重定位、調整程式碼中的地址等。事實上第二步是連結過程的核心,特別是重定位過程。

空間與地址的分配

連結器為目標檔案分配地址和空間,實際的空間分配策略是:相似段合併,如下圖所示: ![2016092091451Space allocation.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092091451Space allocation.png)

連結器為目標檔案分配地址和空間,這句話中的“地址和空間”,其實有兩個含義:

  • 輸出的可執行檔案中的空間;
  • 裝載後的虛擬地址中的虛擬空間。

對於有實際資料的段,比如“.text”和“.data”來說,它們在檔案中和虛擬地址中要分配空間,因為它們在這兩者中都存在;而對於“.bss”這樣的段來說,分配空間的意義只侷限於虛擬地址空間,因為它在檔案中並沒有內容。事實上,我們這裡談到的空間分配只關注於虛擬地址的分配,以為這個關係到連結器後面關於地址計算的步驟,而可執行檔案本身的空間分配與連結過程的關係並不是很大。

符號解析與重定位

在我們通常的觀念裡,之所以要連結是因為我們目標檔案中用到的符號被定義在其他目標檔案中,所以要將它們連結起來。

符號沒有被定義是我們平時在編寫程式的時候最常碰見的問題之一,就是連結時符號未定義。導致整個問題的原因很多,最常見的一般都是連結時缺少了某個庫,或者輸入目標檔案路徑不正確或符號的宣告與定義不一樣。所以從普通程式設計師的角度看,符號的解析佔據了連結過程的主要內容。

其實重定位的過程也伴隨著符號解析的過程,每個目標檔案都可能定義一些符號,也可能引用到定義在其他目標檔案的符號。重定位的過程中,每個重定位的入口都對一個符號引用,那麼當連結器須要對某個符號的引用進行重定位是,它就要確定這個符號的目標地址。這時候連結器就會去查詢由所有輸入目標檔案的符號表組成的全域性符號表,找到相應的符號進行重定位。

靜態庫連結

一個靜態庫可以簡單地看成一組目標檔案的集合,即很多目標檔案經過壓縮打包後形成的一個檔案。比如我們在Linux中最常用的C語言靜態庫libc位於/usr/lib/libc.a它屬於glibc專案的一部分。

靜態庫的連結如下圖所示: ![2016092173427Static library link.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092173427Static library link.png)

為什麼的靜態執行庫裡面一個目標檔案包含一個函式?比如lib.c裡面printf.o只有printf()函式,為什麼要這樣組裝?

連結器在連結靜態庫的時候是以目標檔案為單位的。比如我們引用了靜態庫中的printf()函式,那麼連結器就會把庫中包含printf()函式的那個目標檔案連結進來,如果很多函式都放在一個目標檔案中,很可能很多沒有的函式都被遺棄連結進了輸出結果中,由於執行庫有成百上千個函式,數量非常龐大,每個函式獨立地放在一個目標檔案中可以儘量減少空間的浪費,那些沒有被用到的目標檔案(函式)就不要連結到最終的輸出檔案中。

接下來書中也講了連結過程控制,這部分操作性比較強,我就不一一展開了。

提示:

很多地方都提到了作業系統核心。從本質上講,它本身也是一個程式。比如Windows的核心ntokrnl.exe就我我們平常看到的PE檔案,它的位置位於\WINDOWS\system32\ntoskrnl.exe。很多人誤以為Windows作業系統核心很龐大,由很多檔案組成。這是一個誤解,其實真正的Windows核心機是這個檔案。

可執行檔案的裝載與程式

介紹可執行檔案的裝載與程式開始,有幾個問題。

  • 什麼是程式的虛擬地址空間?
  • 為什麼程式要有自己獨立的虛擬地址空間?
  • 裝載的方式有哪些?
  • 程式虛擬地址的分佈情況?比如程式碼段、資料段、BSS段、堆、棧分別在程式地址空間中的怎麼分佈,它們的位置和長度如何決定?

程式的虛擬地址空間

程式和程式的區別什麼? 程式(或者狹義上講的可執行檔案)是一個靜態的概念,它就是一些預先編譯好的指令和資料集合的一個檔案;程式則是一個動態的概念,它是程式執行時的一個過程,很多時候把動態庫叫做執行時(Runtime)也有一定的含義。

每個程式執行起來以後,它將擁有自己獨立的虛擬地址空間(Virtual Address Space)這個虛擬地址空間的大小由計算機的硬體決定,具體地說是由CPU的位數決定。硬體決定了地址空間的最大理論上限,即硬體的定址空間大小,比如32位的硬體平臺決定了虛擬地址的地址為0到【2的32次方】-1,即0x00000000~0xFFFFFFFF,也就是我們常說的4GB虛擬空間大小;而64位的硬體平臺具有64位定址能力,它的虛擬地址空間達到了【2的64次方】位元組,即0x0000000000000000~0xFFFFFFFFFFFFFFFF,總共17 179 869 184 GB,這個定址能力現在來看,幾乎是無限的,但是歷史總是會嘲弄人,或許有一天我們會覺得64位的地址空間很小,就像我們現在覺得32位地址不夠用一樣。

其實從程式的角度看,我們可以通過判斷C語言程式中指標的所佔空間來計算虛擬地址的大小。一般來說C語言指標大小的位數與虛擬空間的位數相同,如32位平臺下的指標為32位,即4位元組;64位平臺下的指標為64為,即8位元組。

那麼32位平臺下的4GB虛擬空間,我們的程式是否可以任意使用呢?很遺憾,不行。因為程式在執行的時候處於作業系統的監管下,作業系統為了達到監控程式執行等一系列的目的,程式的虛擬空間都在作業系統的掌控之中。程式只能使用那些系統分配給程式的地址,如果訪問了未經執行的空間,那麼作業系統就會捕獲到這些訪問,將程式這種訪問當作非法操作,強制結束程式。我們經常在Windows在碰到令人討厭的“程式因非法操作需要關閉”或Linux下的“Segmentation fault”很多時候是因為程式訪問了未經允許的地址。

32位CPU下,程式使用的空間能不能超過4GB呢?這個問題其實應該從兩個角度來看。

  • 問題裡的“空間”如果是指虛擬地址空間,那麼答案是“否”。因為32位的CPU只能用32位的指標,它最大的定址範圍是0到4GB。
  • 如果問題裡是“空間”指計算機的記憶體空間,那麼答案是“是”。Intel 自從1995年的Pentium Pro CPU開始採用36為的實體地址,也就是可以訪問高達64GB的實體記憶體。

從硬體層面上來講,首先的32位地址線只能訪問最多4GB的實體記憶體。但是自從擴充套件至36位地址之後,Intel修改了頁對映的方式,使得新的對映方式可以訪問到更多的實體記憶體。Intel 把這個地址擴充套件方式叫做PAE(Physical Address Extension)。當然這只是一種補救32位地址空間不夠大時的非常規手段,真正的解決方法還是應該使用64位的處理器和作業系統。

裝載的方式

程式執行時所需要的指令和資料必須在記憶體中才能夠正常執行,最簡單的辦法就是將程式執行時所需要的指令和資料全部裝入記憶體中,這樣程式就可以順利執行,這是最簡單的靜態裝入的辦法。但是很多情況下程式需要的記憶體數量大於實體記憶體的數量,當記憶體的數量不夠時,根本的解決辦法就是新增記憶體。相對應磁碟來說,記憶體是昂貴且稀有的,這種情況自計算機磁碟誕以來一直如此。所以人們想盡各種辦法,希望能夠在不新增記憶體的情況下讓更多的 的程式執行起來,儘可能有效的利用記憶體。後來研究發現,程式執行時是由區域性性原理的,所以我們可以將程式最常用的部分留在記憶體中,而將一些不太常用的資料存放在磁碟裡,這就是動態裝入的基本原理。

裝載的方式有哪些?**覆蓋裝入(overlay)頁對映(paging)**是兩種很典雅的動態裝載方法,它們所採用的思想都差不多,原則上都是利用了程式的區域性性原理。 動態裝入的思想是程式用到了哪個模組,就將那個模組裝入記憶體,如果不用就暫時不裝入,存放在磁碟中。

  • 覆蓋裝入(overlay) 覆蓋裝入在沒有發明虛擬儲存之前使用比較廣泛,現在幾乎已經被淘汰了。覆蓋裝入的方法把挖掘記憶體潛力的任務交給了程式設計師,程式設計師在編寫程式的時候會必須手工的將程式分割成若干塊,然後編寫一個輔助程式碼來管理這些模組何時應該駐留在記憶體,何時應該被替換掉。這個小的輔助程式碼就是所謂的覆蓋管理器(Overlay Manager)。
  • 頁對映(paging) 頁對映是虛擬儲存機制的一部分。與覆蓋裝入的原理相似,頁對映也不是一下子就把程式的所有資料和指令都裝入記憶體,而是將記憶體和所有的磁碟中的資料和指令按照“頁(Page)”為單位劃分成若干個頁,以後所有裝載和操作的單位就是頁。

理解頁對映:

為了演示頁對映的基本機制,假設我們的32位機器有16KB的記憶體,每個頁大小為4096位元組,則共有4個頁。假設程式所有的指令和資料總和為32KB,那麼程式總共被分為了8個頁。我們將它們編號為P0~P7。很明顯,16KB的記憶體無法開始同時將32KB的程式裝入,那麼我們將按照動態裝入的原理來進行整個裝入的過程。如果程式剛開始執行時的入口地址在P0,這時裝載管理器發現程式P0不在記憶體中,於是將記憶體F0分配給P0,並且將P0的內容裝入F0;執行一段時間以後,程式需要用到P5,於是裝載管理器將P5裝入F1;就這樣,當程式用到P3和P6的時候,它們分別被裝入到了F2和F3,它們的對映關係如下圖。 ![2016092197064Mapping and the pages load.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092197064Mapping and the pages load.png) 很明顯,如果這時候程式只需要P0、P3、P5和P6這個4個頁,那麼程式就能一直執行下去。但是問題很明顯,如果這時候程式要訪問P4,那麼裝載管理器必須做出抉擇,它必須放棄目前正在使用的4個記憶體頁中的其中一個來裝載P4。至於選擇哪個頁,我們有很多種演算法可以選擇,比如可以選擇F0,因為它是第一個被分配掉的記憶體頁(這個演算法我們可以稱之為FIFO,先進先出);假設裝載管理器發現F2很少被訪問到,那麼我們可以選擇F2(這種演算法可以稱之為LUR,最少使用演算法)。假設我們放棄P0,那麼這時候F0就裝入了P4。程式接著按照這樣的方式執行。

其例項子中這個所謂的裝載管理器就是現代的作業系統,更加準確地講就是作業系統的儲存管理器。目前幾乎所有的主流作業系統都是按照這種方式裝載可執行檔案的。我們熟悉的Windows對PE檔案的裝載及Linux對ELF檔案的裝載都是這樣完成的。

程式的建立

從作業系統的角度來看,一個程式最關鍵的特徵是它擁有獨立的虛擬地址空間,這使得它有別與其他程式。很多時候一個程式被執行同時都伴隨著一個新的程式的建立,那麼我們就來看看這種最通常的情形:建立一個程式,然後裝載響應的可執行檔案並且執行。在有虛擬儲存的情況下,上述過程最開始只需要做三件事情:

  • 建立一個獨立的虛擬地址空間; 我們知道一個虛擬空間由一組頁對映函式將虛擬空間的各個頁對映至相應的物理空間,那麼建立一個虛擬空間實際上並不是建立空間而是建立對映函式所需要的相應的資料結構。
  • 讀取可執行檔案頭,並且建立虛擬空間與可執行的對映關係; 上面那一步的頁對映關係函式是虛擬空間到實體記憶體的對映關係,這一步所做的是虛擬空間與可執行檔案的對映關係。當程式執行發生頁錯誤時,作業系統將從實體記憶體中分配一個物理頁,然後將該“缺頁”從磁碟中讀取到記憶體中,再設定缺頁的虛擬頁和物理頁的對映關係,這樣程式才得以正常執行。但是很明顯的一點是,當作業系統捕獲到缺頁錯誤時,它應該知道程式當前所需要的頁在可執行檔案中的哪一個位置。這就是虛擬空間與可執行檔案之間的對映關係。這一步是是整個裝載過程中最重要的一步,頁是傳統意義上“裝載”的過程。 Linux 中程式虛擬空間中的一個段叫做虛擬記憶體區域(VMA,Virtual Memory Area),Windows中將這個叫做虛擬段(Virtual Secion),其實它們都是同一個概念。比如,作業系統建立程式後,會在程式相應的資料結構中設定一個.text段的 VMA 。VMA 是一個很重要的概念,它對於我們理解程式的裝載執行和作業系統如何管理程式的虛擬空間由非常重要的幫助。
  • 將CPU的指令暫存器設定成可執行檔案的入口地址,啟動執行。 第三步其實也是最簡單的一部,作業系統通過設定CPU的指令暫存器控制權轉交給程式,由此程式開始執行。這一步看似簡單,實際上是在作業系統層面上比較複雜,它涉及核心推展和使用者堆疊的切換、CPU執行許可權的切換。不過從程式的角度看這一步可簡單地認為作業系統執行了一條跳轉指令,直接跳轉到可執行檔案的入口地址。

程式虛存空間分佈

在一個正常的程式中,可執行的檔案中包含的往往不止程式碼段,還有資料段、BSS等,所以對映到程式虛擬空間的往往不止一個段。當段的數量增多時,就會產生空間浪費的問。EFL檔案被對映時,是系統的頁長度作為單位,那麼每個段在對映時的長度應該都是系統頁長度的整數倍;如果不是,那麼多餘的部分也將佔有一個頁。一個EFL檔案中往往有幾十個段,那麼記憶體空間的浪費是可想而知的。

當我們站在作業系統裝載可執行檔案的角度看問題時,可以發現它實際上並不關心可執行檔案各個段所包含的實際內容,作業系統只關心一些跟裝載相關的問題。最主要的是段的許可權(可讀、可寫、可執行)。ELF檔案中,段的許可權往往只有為數不多的幾種組合,基本上是三種:

  • 以程式碼段為代表的許可權可讀可執行的段。
  • 以資料段和BSS段為代表的許可權可讀可寫的段。
  • 以只讀資料段為代表的許可權為只讀段。

那麼我們可以找到一個很簡單的方案就是:對於相同的許可權的段,把他們合併到一起當作一個段來進行對映。 段合併在一起看作是一個“Segment”,那麼裝載的時候就可以將它們看作是一個整體一起對映,這樣做的好處是可以很明顯的減少內部碎片,從而節省了記憶體空間。

“Segment”的概念實際上是從裝載的角度重新劃分了ELF是各個段。在目標檔案連結成可執行檔案的時候,連結器會進來把相同許可權屬性的段分配在一個空間。比如可讀可執行的段都放在一起,這種段的典型是程式碼段;可讀寫的段都放在一起,這種段的典型是資料段。

那我們在之前理解的.text和.data是不是這裡說的"Segment"呢?

看下圖: ELF可執行檔案與程式虛擬空間的對映關係 ![2016092346799The ELF executable file with the process of virtual space mapping relation.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092346799The ELF executable file with the process of virtual space mapping relation.png)

在ELF檔案中的這些段,我們稱之為"section"。所以“Segment”和“Section”是從不同的角度來劃分同一個ELF檔案。這個在ELF中被稱為不同的檢視(View),從“Section”的角度來看ELF檔案就是連結檢視(Linking View),從“Segemnt”的角度來看就是執行檢視(Execution View)。當我們談ELF裝載時,“段”專門指“Segment”;而在其他情況下“段”指的是“Section”。

堆和棧

在作業系統中,VMA除了被用來對映可執行檔案中的各個“Segment”以外,它還可以有其他的作用,作業系統通過VMA來對程式的地址空間進行管理。我們知道程式在執行的時候它還需要用到棧(Stack)、堆(Heap)空間,事實上它們在程式虛擬空間也是以VMA的形式存在的,很多情況下,一個程式中中的棧和堆分別都有一個對應的VMA,而且它們沒有對映到檔案中,這種VMA叫做匿名虛擬記憶體區域(Anonymous Virtual Memory Area)

小結一下關於程式虛擬地址空間的概念:作業系統通過給程式空間劃分出一個個VMA來管理程式的虛擬空間;基本原則上將相同許可權屬性的、有相同映像檔案的對映成一個VMA;一個程式基本上可以分為如下VMA區域:

  • 程式碼VMA,許可權只讀、可執行;有映像檔案;
  • 資料VMA,許可權可讀寫、可執行;有映像檔案;
  • 堆 VMA,許可權可讀寫,可執行;無映像檔案,匿名,可向上擴充套件。
  • 棧 VMA,許可權可讀寫,不可執行;無映像檔案,匿名,可向下擴充套件。

再讓我們來看一個常見的程式虛擬空間是怎麼樣的,如下圖: ![2016092374781The ELF executable file with the process of virtual space mapping relation.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092374781The ELF executable file with the process of virtual space mapping relation.png)

堆的最大申請數量

Linux下虛擬地址空間給程式的本身是3GB(Windows預設是2GB),那麼程式真正可以用到的有多少呢?我們知道,一般程式中使用malloc()函式進行地址空間的申請,那麼malloc()到底最大可以申請多少記憶體呢?用下面這個小程式可以測試malloc最大記憶體的申請數量:

#include <stdio.h>
#include <stdlib.h>

unsigend maximum = 0;

int main(int argc, const char * argv[]) {
    // insert code here...
    unsigned blocksize[] = {1024 * 1024,1024,1};
    int i,count;
    for (i = 0; i < 3; i++){
        for (count = 1;; count++) {
            void *block = malloc(maximum + blocksize[i] * count);
            if (block) {
                maximum = maximum + blocksize[i] * count;
                free(block);
            }else{
                break;
            }
        }
    }
    printf("maximum malloc size = %lf GB\n",maximum*1.0 / 1024.0 / 1024.0 / 1024.0);
    
    return 0;
}
複製程式碼

作者在Linux機器上,執行上面這個程式的結果大概是2.9GB左右的空間;Windows下執行這個乘車的結果大概是1.5GB。那麼malloc的最大申請數量會受到哪些因素的影響?實際上,具體是數值會受到作業系統版本、程式本身大小、用到的動態/共享庫數量大小、程式棧數量、大小等,甚至可能每次執行的結果都會不同,因為有些作業系統使用了一種叫做隨機地址空間分佈的技術(主要是出於安全考慮,防止程式受到惡意攻擊),程式的堆空間變小。

段對齊

可執行檔案最終是要被作業系統裝載執行的,這個裝載的過程一般是通過虛擬記憶體頁對映機制完成的。在對映的過程中,頁是對映的最小單位。一段實體記憶體和程式地址空間之間建立對映關係,這段記憶體空間長度必須是頁記憶體的整數倍。由於有著長度和起始地址的限制,對於可執行檔案來說,它應該儘量優化自己的空間和地址的安排,以節省空間。

為了解決這種問題,有些UNIX系統採用了一個很取巧的辦法,就是讓那些各個段壤接部分共享一個物理頁面,然後將該物理頁面分別對映兩次。這裡解釋一下,我剛剛看到這裡的時候,也沒有明白是是什麼意思,我們想來看一下圖:

![2016092638493Period of unincorporated situation for executable files.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092638493Period of unincorporated situation for executable files.png)

假設虛擬記憶體SEG0 page和 虛擬記憶體SEG1 page,都是未被分配滿的記憶體頁,那SEG0和SEG1的接壤部分的那個物理頁,系統將它們對映兩份到虛擬地址空間。如下圖:

![2016092655277The ELF file section of mergers.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092655277The ELF file section of mergers.png)

因為段地址對齊的關係,各個段的虛擬的虛擬地址往往不是系統頁面長度的整數倍了。

為什麼要動態連結

靜態連結使得不同程度程式開發者和部門能夠相對獨立的開發和測試自己的程式模組,從某種意義上來講大大促進了程式開發的效率,原先限制程式的模組也隨之擴大,但是慢慢地靜態連結的諸多缺點也逐步暴漏出來,比如浪費記憶體和磁碟空間、模組更新困難等問題,使得人們不得不尋找一種更好的方式來組織程式的模組。

記憶體和磁碟空間

靜態連結這種方法的確很簡單,原理上很容易理解,實踐上很難實現,在作業系統和硬體不發達的早期,絕大部分系統採用這種方案。隨著計算機軟體的發展,這種方法的缺點很快就暴漏出來了,那就是靜態連結對於計算機記憶體和磁碟的空間浪費非常嚴重。特別是多程式作業系統的情況下,靜態連結極大的浪費了記憶體和空間,想象一下每個程式內部除了都保留著print()函式、scanf()函式、strlen()等這樣的公用函式庫,還有數量相當可觀的其他庫以及它們所需要的輔助資料結構。

比如Program1和Program2分別包含Program1.o和Program2.o兩個模組,在靜態連結的情況下因為Program1和Program2都用到Lib.o這個模組,所以它們同時在連結輸出的可執行檔案Program1和Program2有兩個副本。當我們同時執行Program1和Program2時,Lib.o在磁碟中和記憶體中都有兩個副本。當系統中存在大量的類似Lib.o的多個程式共享目標檔案時,其中很大一部分空間就被浪費了。在靜態連結中,C語言的靜態庫是很典型的浪費空間的例子,還有其他數以千計的庫,如果都需要靜態連結,那麼空間浪費無法想象。

程式開發和釋出

空間浪費是靜態連結的一個問題,另一個問題是靜態連結對程式的更新、部署和釋出也會帶來很多麻煩。比如Program1所使用的Lib.o是由一個第三方廠商提供的,當該廠商更新了Lib.o的時候(比如修復了lib.o裡面包含的一個bug),那麼Program1的廠商就需要拿到最新版的Lib.o,然後將其與Program1.o連結後將新的Program1整個釋出給使用者。這樣做的缺點很明顯,即一旦程式中有任何模組更新,整個程式就要重新連結、釋出給使用者。

動態連結

要解決空間浪費和更新困難這兩個問題的辦法就是把程式模組互相分割開來,行程獨立的檔案,而不再將它他們靜態地連結在一起。簡單是將,就是不對哪些組成程式的目標檔案進行連結,等到程式要執行時才進行連結。也就是說,把連結這個過程推遲到了執行時再進行,這就是動態連結(Dynamic Linking)的基本思想。

還是Program1和Program2為例,Program1和lib.o都加入了記憶體,這時如果我們需要執行Program2,那麼系統只要載入Program2.o,而不需要重新載入Lib.o,因為記憶體中已經存在一份Lib.o的副本,系統做的只是將Program2.o和Lib.o連結起來。另外在記憶體中共享一個目標檔案模組的好處不僅僅是節省記憶體,它還可以減少物理頁面的換入和換出,也可以增進CPU快取的命中率,因為不同程式間的資料和指令都幾種在了同一個共享模組上。

動態連結的方案也可以使程式的升級變動更加容易,當我們要升級程式庫或程式共享的某個模組時,理論上只要簡單地將舊的目標檔案覆蓋點,而無需將所有的程式再重新連結一遍,當程式下一次執行的時候,新版本的目標檔案會被自動裝載到記憶體並且連結起來,程式就完成了升級的目標。

程式可擴充套件性和相容性

動態連結還有一個特點就是在程式執行時可以動態的選擇載入各種模組程式,這個優點就是後來被人名用來製作程式的外掛(Plug-in)

動態連結還可以加強程式的相容性。一個程式在不同平臺執行時可以動態連結到有作業系統提供的動態連結庫,這些動態連結庫相當於在程式和作業系統之間加了一箇中間層,從而消除了程式對不同平臺之間依賴的差異性。

動態連結也有諸多的問題令人煩惱和費解。很常見的一個問題是,當程式所依賴的某個模組更新後,由於新的模組與舊的模組之間介面不相容,導致原有的程式無法執行。

動態連結的基本實現

動態連結的基本思想就是把程式模組拆分成各個相對獨立的部分,在程式執行時才將它們連結在一起形成一個完整的程式,而不是像靜態連結一樣把所有的程式模組都連結成一個單獨的可執行的檔案。

動態連結涉及執行時的連結及多個檔案的裝載,必須要有作業系統的支援,因為動態連結的情況下,程式的虛擬地址空間的分佈會比靜態連結更為複雜,還有儲存管理、記憶體共享、程式執行緒等機制在動態連結下也會有微妙的變化。在Linux系統中,ELF動態連結檔案被稱為動態共享物件,簡稱共享物件,一般以".so"為副檔名的一些檔案。

在Linux中,常用的C語言的執行庫glibc,它的動態連結形式的版本儲存在"/lib"目錄下,檔名叫做"lib.so"。整個系統只保留一份C語言的動態連結檔案"lib.so",而所有C語言編寫的、動態連結的程式都可以在執行時使用它。當程式被裝載的時候,系統的動態連結器會將程式中所需要的所有動態連結庫(最基本的就是libc.so)裝載到程式的地址空間,並且將程式中所有未決議的符號繫結到相應的動態連結庫中,並進行重定位工作。

程式與libc.so 之間真正的連結工作是由動態連結器完成的,動態連結把連結這個過程從本來的程式裝載前被推遲到了裝載的時候。這樣的做法的確很靈活,但是程式每次都被裝載時都要進行重新連結,是不是很慢?的確,動態連結會導致程式在效能的一些損失,但是對動態連結的連結過程可以進行優化。據估算,動態連結與靜態連結相比,效能損失大約5%以下。這點效能用來換取程式在空間上的節省和程式構建升級的靈活性,是相當值得的。

動態連結過程

![2016092812881Dynamic linking process.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092812881Dynamic linking process.png) Lib.c 被編譯成了Lib.so共享檔案,Programl1.c被編譯成Program1.o之後,連結成可執行檔案Program1。圖中有一個步驟與靜態連結不一樣,那就是Program.o被連線這一步連結過程成可執行檔案的這一步,在靜態連結中會把Program1.o和Lib.o連結到一起,並且產生出輸出可執行檔案Program1。但是在這裡,Lib.o沒有被連結進來,連結的輸入目標檔案只有Program1.o(當然還有C語音的執行庫,這裡暫時忽略)。但是,Lib.so也參與了連結過程,這是這麼回事兒?

當程式模組Program1.o被編譯成Program1.o時,編譯器還不知道 foobar() 函式的地址。當連結器將Program1.o連結成可執行檔案時,這個時候連結器必須確定Program1.o中所引用的 foobar() 函式的性質。如果 foobar() 是定義與其他靜態目標模組中的函式,那麼連結器將會按照靜態連結的規則,將Program1.o中的foobar()地址引用重定位;如果foobar()是一個定義在某個動態共享物件中的函式,那麼連結器就會將這個符號的引用標記為一個動態連結的符號,不對它進行地址重定位,把這個過程留到裝載時再進行。

那麼問題又來了,連結器如何知道foobar的引用是一個靜態符號還是一個動態符號?這實際上就是我們要用到Lib.so的原因。Lib.so儲存了完整的符號資訊(因為執行時進行動態連結還須使用符號資訊),把Lib.so也作為連結的輸入檔案之一,連結器在解析符號時就可以知道:foobar()一個定義在Lib.so的動態符號,這樣連結器就可以對foobar()用作特殊的處理,使它成為一個對動態符號的引用。

關於模組

在靜態連結時,整個程式最終只有一個可執行檔案,它是一個不可以分割的整體;但是在動態連結下,一個程式被分成了若干個檔案,有程式的主要部分,即可執行檔案(Program1)和程式所依賴的共享物件(Lib.so),很多時候我們也把這些部分分稱為模組,即動態連結下的可執行檔案和共享物件都可以看作是一個程式的模組。

動態連結程式執行時的地址分佈

共享物件的最終裝載地址在編譯時是不確定的,而是在裝載時,裝載器根據當前的地址空間的空閒情況,動態分配一塊足夠大小的虛擬地址空間給響應的共享物件。

地址無關程式碼

我們知道重定位是根據符號來進行重定位的,裝載時重定位是解決動態模組有絕對地址引用的辦法之一,但是它有一個很大的缺點,是指令部分無法在多個程式之間共享,這樣就失去了動態連結節省記憶體的一大優勢。其實我們的的目的很簡單,希望程式模組中共享的指令部分在裝載時不需要因為裝載地址的改變而改變,所以事先的基本想法就是把指令中那些需要被修改的部分分離出來,跟資料部分放在一起,這樣指令部分就可以保持不變,而資料部分可以做在每個程式中擁有副本,這種方案就是目前被稱為**地址無關程式碼的技術(PIC,Position-independent Code)**的技術。

我們先來分析模組中各種型別地址的引用方式,我們把共享物件模組中的地址引用按照是否為跨模組分為兩類:模組內部引用和模組外部引用;按照不同的引用方式又可以分為指令引用和資料訪問,這樣我們就得到了入下圖中的4中情況。

![2016092821717Four kinds of addressing mode.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092821717Four kinds of addressing mode.png)

  • 第一種是模組內部的函式呼叫、跳轉等。
  • 第二種是模組外部的資料訪問,比如模組中定義的全域性變數,靜態變數。
  • 第三種是模組外部的函式呼叫、跳轉等。
  • 第四種是模組外部的資料訪問,比如其他模組中定義的全域性變數。

在編譯上面這段程式碼時,編譯器實際上不能確定變數b和函式ext()是模組外部還是模組內部,因為它們有可能被定義在同一個共享物件的其他目標檔案中。由於沒法確定,編譯器只能把它們都當作模組外部的函式和變數來處理。

  • 模組內部呼叫或跳轉 第一種型別應該是最簡單的,那就是模組內部呼叫。因為被呼叫的函式與呼叫者處於同一個模組,它們之間的相對位置是固定的,所以這種情況比較簡單。對於現代系統來講,模組內部的跳轉、函式呼叫都是可以是相對地址呼叫,或者是基於暫存器的相對呼叫,所以對於這種指令是不需要重定位的。

  • 模組內部資料訪問 接著來看看第二種型別,模組內部的資料訪問。很明顯,指令中不能直接包含資料的絕對地址,那麼唯一的辦法就是定址。我們知道,一個模組前面一般是若干個頁的程式碼,後面緊跟著若干個頁的資料,這些頁之間的相對位置是固定的,也就是說,任何一條指令與它需要訪問的模組內部資料之間相對位置是固定的,那麼只需要相對於當前指令加上固定的偏移量就可以訪問模組內部資料了。

  • 模組間資料訪問 模組間的資料訪問比模組內部稍微麻煩一點,因為模組加的資料訪問目標地址要等到裝載時才能決定,比如上面例子中的變數b,它被定義在其他模組中,並且該地址在裝載時才能確定。我們前面提到要使得程式碼地址無關,基本思想就是把跟地址相關的部分放到資料段裡面,很明顯,這些其他模組的全域性變數的地址是跟模組裝載地址有關的。ELF的做法是在資料段裡面建立一個指向這些變數的指標陣列,也被稱為全域性偏移表(Global Offset Table),當程式碼需要引用改全域性變數時,可以通過GOT中的相對的項間接引用,它的基本機制如下圖: ![2016092880002The data access between modules.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092880002The data access between modules.png) 當指令中需要訪問變數b時,程式會先找到GOT,然後根據GOT中變數所對應的項找到變數的目標地址。每個變數都對應一個4個位元組的地址,連結器在裝載模組的時候會查詢每個變數所在的地址,然後填充GOT中的各個項,以確保每個指標所指向的地址正確。由於GOT本身是放在資料段的,所以它可以在模組裝載時被修改,並且每個程式都可以有獨立的副本,互相不受影響。

    那GOT是如何做到指令的地址無關性的呢?從第二種型別的資料訪問我們瞭解到,模組在編譯時可以確定模組內部變數相對於當前指令的偏移量,那麼我們也可以在編譯時確定GOT相對於當前指令的偏移。確定GOT的位置跟上面的訪問變數a的方法基本一樣,通過得到PC值然後加上一個偏移量,就可以得到GOT的位置。然後我們根據變數地址在GOT中的偏移量就可以得到變數的地址,當然GOT中的每個地址對於哪個變數是由編譯器決定的。

  • 模組間呼叫、跳轉 對於模組間的呼叫和跳轉,我們也可以採用上面型別四的方法來解決。與上面的型別有所不同的是,GOT中相應的項儲存的是目標函式的地址,當模組需要呼叫目標函式時,可以通過GOT中的項進行間接跳轉,基本原理入下圖所示: ![2016092872480Call and jump between modules.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092872480Call and jump between modules.png) 這種方法很簡單,但是存在一些效能問題,實際上ELF採用了一種更為複雜和精巧的方法,在動態連結優化中進行介紹。 ![2016092867796Various ways to address references.png](http://7xraw1.com1.z0.glb.clouddn.com/2016092867796Various ways to address references.png)

Q&A

Q:如果一個共享物件的lib.so中定義了一個全域性變數G,而程式A和程式B都使用了lib.so,那麼當程式A改變了這個全域性變數G的值時,程式B中的G會受到影響嗎? A:不會。因為當lib.so被兩個程式載入時,它的資料段部分在每個程式中都有獨立的副本,從這個角度看,共享物件中的全域性變數實際上和定義在程式內部的全域性變數沒有什麼區別。任何一個程式訪問的只是自己的那個副本,而不會影響其他程式。那麼如果我們把這個問題的條件改成同一個程式中的執行緒A和執行緒B,它們是否看得到對方對lib.so中全域性變數G的修改呢?對於同一個程式的兩個執行緒來說,它們訪問的是同一個程式地址空間,也就是同一個lib.so的副本,所以它們對G的修改,對方都是看得見的。

那麼我們可不可以做到跟前面相反的情況呢?比如要求兩個程式共享一個共享物件的副本或要求兩個執行緒訪問全域性變數的不同副本,這兩種需求都是存在的,比如多個進行可以共享一個全域性變數就可以用來實現程式間的通訊;而多個執行緒訪問全域性變數的不同副本可以防止不同執行緒之間對全域性變數的干擾。實際上這兩種需求都是有相應的解決方法的,多程式共享全域性變數又被叫做"共享資料段"。而多個執行緒訪問不同的全域性變數副本又被叫做"執行緒私有儲存"(Thread Local Stroage)。

影響動態連結效能的主要問題

  • 動態連結下對全域性和靜態的資料都有進行GOT定位,然後間接定址;對於模組間的呼叫也要先定位GOT,然後再進行間接跳轉,如此一來,程式的執行速度必然會減慢。;
  • 動態連結的連結工作在執行時完成,即程式開始執行使,動態連結器都要進行一次連結工作,動態連結器會尋找並裝載所需要的共享物件,然後進行符號查詢重定位等工作,這些工作勢必減慢程式的啟動速度。

延遲繫結(PLT)

在動態連結下,程式模組之間包含了大量的函式引用(全域性變數往往比較少,因為大量全域性變數會導致模組間耦合變大)。所以在程式開始執行之前,動態連結會消耗不少時間用於解決模組之間的函式引用的符號查詢以及重定位,這也是我們上面提到的減慢動態連結的第二個原因。不過可以想象,在一個程式執行過程中,可能很多函式在程式執行完都不會被用到,比如一些錯誤處理函式或是一些使用者很少用到的功能模組等,如果一開始就把所有的函式都連結好實際上是一種浪費。所以ELF採用了一種叫做**延遲繫結(Lazy Binding)**的做法,基本的思想就是當函式第一次被用到才進行繫結(符號查詢、重定位等),如果沒有用到則不進行繫結。這樣的做法可以大大加快程式的啟動速度,特別有利於一些大量函式引用和大量模組的程式。ELF 使用PLT(Procedure Linkage Table)的方法來實現,這種方法使用了一些很精巧的指令程式來完成。

看到這裡想到了iOS 中NSObject類的+load和+initialize這兩個方法。

在程式啟動時,Runtime會去載入所有的類。在這一時期,如果類或者類的分類實現了+load方法,則會去呼叫這個方法。

而+initialize方法是在類或子類第一次接收訊息之前會被呼叫,這包括類的例項物件或者類物件。如果類一直沒有被用到,則這個方法不會被呼叫。

基於這兩個方法的特殊性,我們可以將類使用時所需要的一些前置條件在這兩個方法中處理。不過,如果可能,應該儘量放在+initialize中。因為+load方法是在程式啟動時呼叫,勢必會影響到程式的啟動時間。而+initialize方法可以說是懶載入呼叫,只有用到才會去執行。

動態連結器

動態連結情況下,可執行檔案的裝載與靜態連結情況基本一樣。首先作業系統會讀取可執行檔案的頭部,檢查檔案的合法性,然後從頭部中的“Progeam Header”中讀取每個“Segment”的虛擬地址、檔案地址和屬性,並將它們對映到虛擬空間的相應位置,這些步驟跟前面的靜態連結情況裝載基本無異。在靜態連結情況下,作業系統接著就可以把控制權轉交給可執行檔案的入口地址,然後開始執行。

但是在動態連結的情況下,作業系統還不能在裝載完成可執行檔案之後就把控制權交給可執行檔案,因為我們知道可執行檔案依賴於很多共享物件。這時候,可執行檔案裡對很多外部符號的引用還處於無效地址狀態,即還沒有相應的共享物件中的實際位置連結起來,所以在對映完可執行檔案之後,作業系統會先啟動一個動態連結器(Dynamaic Linker)。

在Linux下,動態連結器ld.so實際上是一個共享物件,作業系統同樣通過對映的方式將它載入到程式地址空間中。作業系統在載入完動態連結器之後,就將控制權交給動態連結的入口地址。當動態連結器得到控制權之後,它開始執行一系列自身的初始化操作,然後根據當前的環境引數,開始對可執行檔案進行動態連結工作。當所有連結工作完成以後,動態連結器會將控制權轉交給可執行檔案的入口地址,程式開始正式執行。

關於動態連結器本身的細節實雖然不再展開,但是作為一個非常有特點的,也很特殊的共享物件,關於動態連結器的實現的幾個問題還是很值得思考的:

  1. 動態連結器本身是動態連結還是靜態連結的? 動態連結器本身應該是靜態連結的,它不是依賴於其他共享物件,動態連結器本身是用來幫助其他ELF檔案解決共享物件的依賴問題,如果它也是依賴於其他共享物件,那麼誰來幫它解決依賴問題?所以它本身必須不依賴與其他共享物件。這一點可以使用 ldd 來判斷:
ldd /lib/ld-linux.so.2
    staticall linked
複製程式碼
  1. 動態連結器本身必須是PIC的嗎? 是不是PIC對於動態連結器來說並不關鍵,動態連結器可以是PIC的也可以不是,但往往使用PIC會更加簡單一些。一方面,如果不是PIC的話,會使得程式碼段無法共享,浪費記憶體,另一方面也會是ld.so本身初始化更加複雜,因為自舉時還需要對程式碼段進行重定位。實際上ld-linux.so.2是PIC的。
  2. 動態連結器可以被當做可執行檔案執行,那麼裝載地址應該是多少? ld.so的裝載地址跟一般的共享物件沒區別,即0x00000000。這個裝載地址是一個無效的裝載地址,作為一個共享庫,核心在裝載它時會為其選擇一個合適的裝載地址。

".interp" 段

那麼作業系統中哪個才是動態連結器呢,它的位置由誰決定?是不是所有的*NIX系統的動態連結器都位於/lib/ld.so呢?實際上,動態連結器的位置既不是又系統配置指定,也不是由環境引數決定,而是由ELF可執行檔案決定。在動態連結的ELF可執行檔案中,有一個專門的段叫做“.interp”段(“interp”是“interpreter”(直譯器)的縮寫)。

“.interp”的內容很簡單,裡面儲存的就是一個字串,這個字串就是可執行檔案所需要的動態連結器的路徑,在Linux下,可執行檔案所需要的動態連結器的路徑幾乎都是“lib/ld-linux.so.2”,其他*nix作業系統可能會有不同的路徑。

".dynamic"段

類似於“.interp”這樣的段,ELF中還有幾個段也是專門用與動態連結的,比如“.dynamic”段和“.dynsym”段等

動態連結ELF中最重要的結構應該是“.dynamic”段,這個段裡面儲存了動態連結器所需要的基本資訊,比如依賴那些共享物件、動態連結符號表的位置、動態連結重定位表的位置、共享物件初始化程式碼的地址等。

動態符號表

為了完成動態連結,最關鍵的還是所依賴的符號和相關檔案的資訊。我們知道在靜態連結中,有一個專門的段叫做符號表“.symbtab”(Symbol Table),裡面儲存了所有關於該目標的檔案的符號的定義和引用。動態連結的符號表示實際上它和靜態連結十分相似,比如前面列子中的Program1程式依賴於Lib.so,引用到了裡面的foobar()函式。那麼對於Progarml來說,我們往往稱Progarm1**匯入(Import)**了foobar函式,foobar是Program1的匯入函式(Import Function);而站在Lib.so的角度來看,它實際上定義了foobar()函式,並且提供給其他模組使用,我們往往稱Lib.so匯出(Export)了foobar()函式,foobar是Lib.so的匯出函式(Export Function)。把這種匯入匯出關係放到靜態連結的情形下,我們可以把它們叫看作普通的函式定義和引用。

為了表示動態連結這些模組之間的符號匯入匯出的關係,ELF專門有一個叫做動態符號表(Dynamic Symbol Table)d段用來儲存這些資訊,這個段的段名通常叫做".dynsym"(Dynamic Symbol)。與".symtab"不同的是,".dynsym"只儲存了與動態連結相關的符號,對於那些模組內部的符號,比如模組私有變數則不儲存。很多時候動態連結的模組同時擁有".dynsym"和".symtab"兩個表,".symtab"中往往包含了所有符號,包括".dynsym"中的符號。

與".symtab"類似,動態符號表也需要一些輔助的表,比如用於儲存符號的字串表。靜態連結時叫做符號字串表“symtab”(String Table),在這裡就是動態符號字串".dynstr"(Dynamic Stirng Table);由於動態連結下,我們需要在程式執行時查詢符號,為了加快符號的查詢過程,往往還有輔助的符號雜湊表(“.hash”)。

動態連結符號表的結構與靜態連結符號表幾乎一樣,我們可以簡單地將匯入函式看作是對其他目標檔案中函式的引用;把匯出函式看作是在本目標檔案定義的函式就可以了。

動態連結重定位表

共享物件需要重定位的主要原因是匯入符號的存在。動態連結下,無論是可執行檔案或共享物件,一旦它依賴其他共享物件,也就是說有匯入的符號是,那麼它的程式碼或資料中就會有對於匯入符號的引用。在編譯時這些匯入符號的地址未知,在靜態連結中,這些未知的地址引用在最終的連結時被修正。但是在動態連結中,匯入符號的地址在執行時才確定,所以需要在執行時將這些匯入的引用修正,即需要重定位。

動態連結的步驟

  1. 啟動動態連結器本身;
  2. 裝載所需要的共享物件;
  3. 重定位和初始化;

顯示執行時連結

支援動態連結的系統問問都支援一種更加靈活的模組載入方式,叫做顯示執行時連結(Explicit Run-time Linking),有時候也叫做執行時載入。也就是讓程式自己在執行時控制載入指定的模組,並且可以在不需要的時候將該模組解除安裝。從前面的瞭解到的來看,如果動態連結器可以在執行時將共享模組裝載進記憶體並且可以進行重定位操作,那麼這種執行時載入在理論上也是很容易實現的。而且一般的共享物件不需要然後修改就可以進行執行時裝載,這種共享物件往往被叫動態裝載庫(Dynamic Loading Library),其實本質上它跟一般共享物件沒什麼區別,只是程式開發者使用它的角度不同。

這種執行時載入使得程式的模組組織更加靈活,可以用來實現一些諸如外掛、驅動等功能。當程式需要用到某個外掛或者驅動的時候,才講相應的模組裝載進行,而不需要從一開始就講他們全部裝載進來,從而減少了程式啟動時間和記憶體。並且程式可以在執行的時候重新載入某個模組,這樣使得程式本身不必重新啟動而實現模組的增加、刪除、更新等,這對於很多需要長期執行的程式來說是很大的優勢。最常見的例子是Web 伺服器程式,對於Web伺服器程式來說,它需要根據配置來選擇不同的指令碼直譯器。資料庫連線驅動等,對於不同的指令碼直譯器分別做成一個獨立的模組,當Web伺服器需要某種指令碼直譯器的時候可以將其載入進來;這對於資料庫連線的驅動程式也是一樣的原理。另外對於一個可靠的Web伺服器來說,長期的執行是必要的保證,如果我們需要增加某種指令碼直譯器,或者摸個指令碼直譯器需要升級,則可以通知Web伺服器程式重新裝載該共享模組以實現相應的目的。

在Linux中,從檔案本身的格式上來看,動態庫實際上跟一般的共享物件沒區別。主要的區別是共享物件是由動態連結器在程式啟動之前負責裝載和連結的,這一系列步驟都是由動態連結器自動完成,對於程式本身是透明的;而動態庫的裝載則是通過一系列又動態連結器提供API,具體講共有4個函式:

  • 開啟動態庫(dlopen()) dlopen()函式用來開啟一個動態庫,並將其載入到程式的地址空間,完成初始化過程,它的C原型定義為

    void * dlopen(const char *filename,int flag);
    複製程式碼

    第一個引數是被載入的路徑,如果是路徑是絕對路徑(以“/”開始的路徑),則該函式將會嘗試直接開啟該動態庫;如果是相對路徑,那麼dlopen()會嘗試在以一定的順序去查詢該動態庫檔案:

    第二個引數flag表示函式符號的解析方式。

    • RTLD_LAZY:表示使用延遲繫結,函式第一次被用到時才進行繫結,即PLT機制;
    • RTLD_NOW:表示當模組被載入時即完成所有的函式繫結工作,如果有任何未定義的符號音樂繫結工作沒法完成,那麼dlopen()就返回錯誤;
    • RTLD_GLOBAL:可以跟上面兩者任意一個一起使用(通過常量的“或”操作),它表示將被載入的模組的全域性符號合併到程式的全域性符號中,使得以後載入的模組可以使用這些符號。

    dlopen的返回值是被載入的模組的控制程式碼,這個控制程式碼在後面使用的dlsym或者dlclose時需要用到。如果記載模組失敗,則返回NULL。如果模組已經通過dlopen被載入過了,那麼返回的是同一個控制程式碼。另外如果被載入的模組之間有依賴關係,比如模組A依賴於模組B,那麼程式設計師需要手動載入被依賴的模組,比如先載入B,再載入A。

  • 查詢符號(dlsym()) dlsym 函式基本是執行時裝載的核心部分,我們可以通過這個函式找到所需要的符號,它的定義如下:

    void * dlsym(void * handle, char * symbol);
    複製程式碼

    定義非常簡潔,兩個引數,第一個引數是由dlopen()返回的動態庫的控制程式碼; 第二個引數即所需要查詢的符號的名字,一個以“\0”結尾的C字串。如果dlsym()找到了相應的符號,則返回該符號的值,沒有找到相應的符號,則返回NULL。dlsym()返回的值對於不同型別的符號,意義是不同的。如果查詢的符號是個常量,那麼它返回的是該常量的值。這裡有一個問題是:如果常量的值剛好是NULL或者0呢,我們如何判斷dlsym()是否找到了該符號呢?這個問題就要用到下面介紹的dlerror()函式了。如果符號找到了,那麼dlerror()返回NULL,如果沒找到,deerror就會返回相應的錯誤資訊。 這裡說一下符號優先順序,當許多共享模組中的符號同名衝突時,先裝入的符號優先,我們把這種優先順序方式成為裝載序列(Load Ordering)。 當我們使用dlsym()進行符號的地址查詢工作時,這個函式是不是也是按照裝載序列的優先進行符號的查詢呢?實際的情況是,dlsym()對符號的查詢優先順序分為兩種型別。

    • 裝載序列:如果我們是全域性符號表中進行符號查詢,即dlopen()時,引數filename為NULL,那麼由於全域性符號使用的裝載序列,所以dlsym()使用的也是裝載序列。
    • 依賴序列(Dependency Ordering):我們是對某個通過dlopen()開啟的共享物件進行符號查詢的話,那麼採用依賴序列,它是以被dlopen()開啟的那個共享物件為根節點,對它所有依賴的共享物件進行廣度優先遍歷,直到找到符號為止。
  • 錯誤處理(dlerror()) 每次我們呼叫dlopen()、dlsym()或dlclose以後,我們都可以呼叫dlerror()函式來判斷上一次呼叫是否成功。dlerror()返回型別是char*,如果返回NULL,則表示上一次呼叫成功;如果不是,則返回相應的錯誤資訊。

  • 關閉動態庫(dlclose()) dlclose()的作用跟dlopen()剛還相反,它的作用是將一個已經記載好的模組解除安裝。系統會維持一個載入引用計數器每次使用dlopen()載入某個模組時,相應的計數器被加一;每次使用dlclose()解除安裝某個模組是,相應的計數器減一。只有當計數器值減到0時,模組才被真正地解除安裝掉。

程式可以通過這幾個API對動態庫進行操作。這幾個API的實現是在/lib/libdl.so.2裡面,它們的宣告和相關常量被定義在系統標準標頭檔案<dlfcn.h>。

程式的記憶體佈局

一般來講:應用程式使用的記憶體空間裡有如下“預設”的區域:

  • 棧:棧用於維護函式呼叫的上下文,離開了棧函式呼叫就沒辦法實現。棧通常在使用者空間的最高地址處分配,通常有數兆位元組的大小;
  • 堆:堆是用來容納應用程式動態分配的記憶體區域,當程式使用malloc或new分配記憶體時,得到的記憶體來自堆裡。堆通常存在於棧的下方(低地址方向),在某些時候,堆也可能沒有固定統一的儲存區域。堆一般比棧大很多,可以有幾十至數百兆位元組的容量。
  • 可執行檔案的映像:這裡儲存著可執行檔案在記憶體裡的映像。由裝載器在裝載時將可執行檔案的記憶體讀取或映像到這裡。
  • 保留區:保留區並不是一個單一的記憶體區域,而是對記憶體中受到保護而禁止訪問的記憶體區域的總稱,例如,大多數作業系統裡,極小的地址通常都是不允許訪問的,如NULL。通常C語言將無效指標賦值為0也是出於這個考慮,因為0地址上正常情況下不可能有有效的可訪問資料。

Linux下一個程式裡典型的記憶體佈局如下: ![201609295828Linux process address space layout.png](http://7xraw1.com1.z0.glb.clouddn.com/201609295828Linux process address space layout.png) 上圖中,有一個沒有介紹的區域:“動態連結庫對映區”,這個區域用於對映裝載的動態連結庫。在Linux下,如果可執行檔案依賴其他共享庫,那麼系統就會為它從0x40000000開始的地址分配相應的空間,並將共享庫裝載入到該空間。

圖中箭頭標明瞭幾個大小可變的區的尺寸增長方向,在這裡可以清晰地看出棧向低地址增長,堆向高地址增長。當棧或者堆現有的大小不夠用時,它將按照圖中的增長方向擴大自身的尺寸,直到預留空間被用完為止。

Q&A

Q:寫程式時常常出現“段錯誤(segment fault)”或“非法操作,改記憶體地址不能read/write”的錯誤,這是怎麼回事兒?

A:這是典型的非法指標解引用造成的錯誤。當指標指向一個不允許讀或寫的記憶體地址時,而程式卻試圖利用指標來讀或寫該地址的時候,就會出現這個錯誤。在Linux或Windows的記憶體佈局中,有些地址是始終不能讀寫的,例如0地址。還有些地址是一開始不允許讀寫,應用程式必須事先請求獲取這些地址的讀寫權,或者某些地址一開始並沒有對映到實際的實體記憶體,應用程式必須事先請求將這些地址對映到實際的實體地址(commit),之後才能夠自由地讀寫這篇記憶體。當一個指標指向這些區域的時候,對它指向的記憶體進行讀寫就會引發錯誤。造成這樣的最普遍的原因有兩種:

  1. 程式設計師將指標初始化為NULL,之後卻沒有給它一個合理的值就開始使用指標。
  2. 程式設計師沒有初始化棧上的指標,指標的值一般會是隨機數,之後就直接開始使用指標。

因此,如果你的程式出現了這樣的錯誤,請著重檢查指標的使用情況。

棧(stack)是現代計算機程式裡最為重要的概念之一,幾乎每個程式都使用了棧,沒有棧就沒有函式,沒有區域性變數,也就沒有我們如今能夠看見的所有計算機語言。先了解一下傳統棧的定義:

在經典的電腦科學中,棧被定義為一個特殊的容器,使用者可以將資料壓入棧中(入棧push),也可以將已經壓入棧中的資料彈出(出棧,pop),但棧這個容器必須遵循一條規則:先入棧的資料後出棧(First In Last Out,FILO)。

在計算機系統中,棧是一個具有以上屬性的動態記憶體區域。程式可以將資料壓入棧中,也可以將資料從棧頂彈出。壓棧操作使得棧增大,而彈出操作使棧減小。

棧總是向下增長的。在i386下,棧頂由稱為esp的暫存器進行定位。壓棧的操作棧頂的地址減小,彈出的操作使得棧頂的地址增大。

棧在程式執行中具有舉足輕重的地位。最重要的,棧儲存了一個函式呼叫所需要的維護資訊,這常常稱為堆疊幀(Stack Frame)或活動記錄(Activate Record)。堆疊幀一般包括如下幾個方面內容:

  • 函式的返回地址和引數。
  • 臨時變數:包括函式的非靜態區域性變數以及編譯器自動生成的其他臨時變數。
  • 儲存的上下文:包括在函式呼叫前後需要保持不變的暫存器。

棧的呼叫慣例

毫無疑問,函式的呼叫方和被呼叫方對於函式如何呼叫須要有一個明確的約定,只有雙方都準守同樣的約定,函式才能被正確的呼叫,這樣的約定就稱為呼叫慣例(Call Convention)。一個呼叫慣例一般會規定如下幾個方面的內容。

  • 函式引數的傳遞順序和方式 函式引數的傳遞有很多種方式,最常見的一種是通過棧的傳遞。函式的呼叫方將引數壓入棧中,函式自己在從棧中將引數取出。對於有多個引數的函式,呼叫慣例要規定函式呼叫方將引數壓棧的順序:是從左至右,還是從右至左。有些呼叫慣例還允許使用暫存器傳遞引數,以提高效能。
  • 棧的維護方式 在函式將引數壓棧之後,函式體會被呼叫,此後需要將被壓入棧中的引數全部彈出,以使得棧在函式呼叫前後保持一致。這個彈出的工作可以由函式的呼叫方來完成,也可以由函式本身來完成。
  • 名字修飾(Name-mangling)的策略 為了連結的時候對呼叫慣例進行區分,呼叫管理要對函式本身的名字進行修飾。不同的呼叫管理有不同的名字修飾策略。

函式返回值傳遞

書中的例子和探討很長,這裡我講一下我自己的理解 例如:

test(a,b)
{
	return a + b;
}
int main()
{
	int a = 1;
	int b = 1;
	
	int n = test(a,b);
	return 0;
}
複製程式碼
  • 首先main函式在棧上額外開闢了一片,並將這塊空間的一部分作為傳遞返回值的臨時物件,這裡稱為temp。
  • 將temp物件的地址作為隱藏引數傳遞給test函式。
  • test函式將資料拷貝給temp物件,並將temp物件的地址傳出。
  • test返回之後,main函式將temp物件的內容拷貝給n。

當然以上過程會有一些彙編程式碼,這裡省去了彙編程式碼的解釋。

光有棧對於程式導向的程式設計還遠遠不夠,因為棧上的資料函式返回的時候就會被釋放掉,所以無法將資料傳遞至函式外部。而全域性變數沒有辦法動態產生。只能在編譯的時候定義,有很多情況下缺乏表現力。在這種情況下,堆(Heap)是唯一的選擇。

堆是一塊巨大的記憶體空間,常常佔據整個虛擬空間的絕大部分。在這片空間裡,程式可以請求一塊連續記憶體,並自由地使用,這塊記憶體在程式主動放棄之前都會一直保持有效。

如果每次程式申請或者釋放堆空間都需要系統呼叫,實際這這樣的做法是比較耗費效能的。所以程式向作業系統申請一塊適當大小的堆空間,然後由程式自己管理這塊空間。管理著堆的空間分配往往是程式的執行庫。

執行庫相當於是向作業系統“批發”了一塊較大的堆空間,然後“零售”給程式用。當全部“售完”或程式有大量的記憶體需求時,再根據實際需求向作業系統“進貨”。當然執行庫向程式零售堆空間時,必須管理它批發的堆空間,不能把同一塊地址出售兩次,導致地址衝突。於是執行庫需要一個演算法來管理堆空間,這個演算法就是堆的分配演算法。

堆分配演算法

如何管理一大塊連續的記憶體空間,能夠按照需求分配、釋放其中的空間,這就是堆分配的演算法。堆的分配演算法有很多種,有很簡單的(比如下面介紹的這幾種演算法),也有很複雜的、適應於某些高效能或者有其他特殊要求的場合。

  1. 空閒連結串列 **空閒連結串列(Free List)**的方法實際上就是把堆中各個空閒的塊安裝連結串列的方式連線起來,當使用者請求一塊空間時,可以遍歷整個列表,直到找到合適大小的塊並且將它拆分;當使用者釋放空間將它合併到空閒連結串列中。 我們首先需要一個資料結構來登記空間裡所有的空閒空間,這樣才能知道程式請求空間的時候該分配給它那一塊記憶體。這樣的結構有很多種,這裡介紹最簡單的一種--空閒連結串列。 空閒連結串列是這樣一種結構,在堆裡的每一個空閒空間的開頭(或結尾)有一個頭(header),頭結構裡記錄了上一個(prev)和下一個(next)空閒塊的地址,也就是說,所有的空閒塊形成了一個連結串列。如下圖: ![2016100616640The distribution of free list.png](http://7xraw1.com1.z0.glb.clouddn.com/2016100616640The distribution of free list.png) 在這樣的結構下如何分配空間呢? 首先在空閒連結串列裡查詢足夠容納請求大小的一個空閒塊,然後將這個塊分兩部分,一部分為程式請求的空間,另一部分為剩餘下來的空閒空間。下面將連結串列裡對應的空閒塊的結構更新為新的剩下的空閒塊,如果剩下的空閒塊大小為0,則直接將這個結構從連結串列裡刪除。下圖演示了使用者請求一塊和空閒塊恰好相等的記憶體空間後堆的狀態。 ![2016100612103The distribution of free list 2.png](http://7xraw1.com1.z0.glb.clouddn.com/2016100612103The distribution of free list 2.png) 這樣的空閒連結串列實現儘管簡單,但是在釋放空間的時候,給定一個已分配塊的指標,堆無法確定這個塊的大小。一個簡單的解決方法是當使用者請求k個位元組空間的時候,我們實際上分配k+4個位元組,這4個位元組用於儲存該分配的大小,即k+4。這樣釋放該記憶體的時候只要看這4個位元組的值,就能知道該記憶體的大小,然後將其插入到空閒連結串列裡就可以了。 當然這僅僅是最簡單的一種分配策略,這樣的思路存在很多問題。例如,一旦連結串列被破壞,或者記錄長度的那4位元組被破壞,整個堆就無法正常工作,而這些資料恰恰很容易被越界讀寫所接觸到。

  2. 點陣圖 針對空閒連結串列的弊端,另一種分配方式顯得更賤穩健。這種方式稱為點陣圖(Bitmap)。其核心思想是將整個堆劃分為大量的塊(block),每個塊的大小相同。當使用者請求記憶體的時候,總是分配整數個塊的空間給使用者,第一個塊我們稱為已分配區域的頭(Head),其餘的稱為已分配區域的主體(Body)。而我們可以使用一個整數陣列來記錄塊的使用情況,由於每個塊只有頭/主體/空閒三種狀態,因此僅僅需要兩位即可表示一個塊,因此稱為點陣圖。 假設堆的大小為1MB,那麼我們讓一個塊的大小為128位元組,那麼總共就有1M/128=8K個塊,可以用8k/(32/2)=512個int來儲存。這有512個int陣列就是一個點陣圖,其中每兩個位代表一個塊。當使用者請求300位元組的記憶體時,堆分配給使用者3個塊,並將點陣圖的相應位置標記為頭或軀體。下圖為一個這樣的堆的例項。 ![2016100612565Figure bit allocation.png](http://7xraw1.com1.z0.glb.clouddn.com/2016100612565Figure bit allocation.png) 這個堆分配了3片記憶體,分別有2/4/1個塊,用虛線框標出。其對應的點陣圖將是: (HIGH)11 00 00 10 10 10 10 11 00 00 00 00 00 00 00 10 11 (LOW) 其中 11 表示 H(Head),10表示主題(Body),00表示空閒(Free)。 這樣的實現方式有幾個優點:

  • 速度快:由於整個堆的空閒資訊儲存在一個陣列內,因此訪問該陣列是cache容易命中。
  • 穩定性好:為了避免使用者越界讀寫資料破壞,我們只須簡單的備份一下點陣圖即可。而且即使部分資料被破壞,也不會導致整個堆無法工作。
  • 塊不需要額外資訊,易於管理。

當然缺點也是顯而易見的:

  • 分配記憶體的時候容易產生碎片。例如分配300個位元組,實際分配了3個塊即384個位元組,浪費了84個位元組。
  • 如果堆很大,或者設定的一個塊很小(這樣可以減少碎片),那麼這個點陣圖將會很大,可能失去cache命中率高的優勢,而且也會浪費一定的空間。針對這種情況,我們可以使用多級點陣圖。
  1. 物件池 以上介紹的堆管理方法是最為基本的兩種,實際上在一些場合,被分配物件的大小是較為固定的幾個值,這時候我們可以針對這樣的特徵設計一個更為高效的堆演算法,稱為物件池。 物件池的思路很簡單,如果每一次分配的空間大小都一樣,那麼久可以按照這個每次請求分配的大小作為一個單位,把整個堆空間劃分為大量的小塊,每次請求的時候只需要找到一個小塊就可以了。 物件池的管理方法可以採用空閒連結串列,也可以採用點陣圖,與它們的區別僅僅在於她假設了每次請求的都是一個固定的大小,因此實現起來很容易。由於每次總是隻請求一個單位的記憶體,因此請求得到滿足的速度非常快,無須查詢一個足夠大的空間。 實際上很多現實應用中,堆的分配演算法往往是採取多種演算法符合而成的。比如對於glibc來說,它對小於64位元組的空間申請是採用類似於物件池的方法;而對於大於512位元組的空間申請採用的是最佳適配演算法;對於大於64位元組而小於512位元組的,它會根據情況採取上述方法中的最佳折中策略;對於大於128KB的申請,它會使用mmap機制向作業系統申請空間。

結尾

本書前前後後小生讀了兩遍,第一遍讀的實體書,沒有做筆記;第二遍讀的電子版,邊看邊做筆記。書讀百遍其義自見,第一邊看書讓我對整部書有了一個大致的瞭解,第二遍細讀和做筆記讓我理解了很多之前工作中不明白的地方。但仍還有很多不明白之處,還需在今後的職業生涯中慢慢消化,慢慢體會。

相關文章