iOS 程序員的自我修養 — 讀《程序員的自我修養 連接、裝載與庫》

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

關於書名

書名源於俄羅斯的演員斯坦尼斯拉夫斯基創做的《演員的自我修養》,做者爲了寫這本書前先後後修改了三十年之久,臨終前才贊成不在修改,拿去出版。使用這個書名一方面書單內容的確不是介紹一門新的編程語言或是展現一些實用的編程技術,而是介紹程序運行背後的機制和由來,能夠看作是程序員的一種「修養」;另外一方面是向斯坦尼斯拉夫斯基致敬,向他對做品精益求精的精神致敬。 -- 本書的序言三·餘甲子linux

本書的組織

本書分爲4大部分,分別以下。ios

第一部分 簡介

第1章 溫故而知新

介紹基本的背景知識,包括硬件、操做系統、線程等。程序員

第二部分 靜態鏈接

第2章 編譯和連接

介紹編譯和連接的基本概念和步驟。算法

第3章 目標文件裏有什麼

介紹 COFF 目標文件格式和源代碼編譯後如何在目標文件中存儲。數據庫

第4章 靜態連接

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

第5章 Windows PE/COFF

介紹Windows平臺下的目標文件和可支持文件格式小程序

第三部分 裝載與動態連接

第6章 可執行文件的裝載過程

介紹進程的概念、進程地址空間的分佈和可執行文件映射裝載過程。windows

第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

目標文件什麼樣子

這種類型的圖在講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、P三、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機制向操做系統申請空間。

結尾

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

相關文章
相關標籤/搜索