1. 程式人生 > >記憶體、棧、堆的一點小總結 《程式設計師的自我修養》·筆記

記憶體、棧、堆的一點小總結 《程式設計師的自我修養》·筆記

記憶體、棧、堆

標籤:OS

記憶體、棧、堆的一點小總結

  • 程式的記憶體佈局
    【前言】在32位系統中,大家可能認為我們可以用一個32位的指標訪問任意記憶體地址。如下:

      int *p = (int *)0x12345678;
      ++*p;

      但事實上使用者可以直接讀取的記憶體大小是達不到4GB的。大多數作業系統都會將其中的一部分分配給核心使用,應用程式是無法直接訪問這一段記憶體的,這部分被稱為核心空間。Linux預設將高地址的1GB空間分配給核心;win預設下將高地址的2GB分配給核心,但是也可以人為配置成1GB。(前面已介紹)
      但是處理上述的核心空間之外的使用者空間中也有一些特殊的空間有特殊的用處,而應用程式能用的記憶體空間是如下的幾個“預設區域”:


    •   用於維護函式呼叫的上下文,離開了棧,函式的呼叫就沒辦法實現了。棧通常在使用者更高的地址空間處分配,通常有數兆位元組的大小。


    •   堆用來容納應用程式動態分配的記憶體區域,當程式使用malloc或new的時候就是得到來自堆中的記憶體。堆統稱在棧的下方(低地址方向,但是,不是緊鄰的)。堆一般比棧要更大一點,一般會達到幾十甚至是數百兆位元組。

    • 可執行檔案映像
        顧名思義,這裡儲存著可執行檔案在記憶體裡的映像。裝載器在裝載的時候會將可執行檔案讀取或者對映到這裡。

    • 保留區
        是對記憶體中受到保護而禁止訪問的記憶體區域的總稱。這個區域大家應該都比較熟悉,比如,大多數作業系統中,極小的地址區域都是不允許訪問的,如NULL


        相關的記憶體佈局如下:

        上圖中還有一個區域,“動態連結庫對映區”,這個區域用於對映裝載的動態連結庫。比如,如果可執行檔案依賴於其他的共享庫,那麼系統就會為他從0x40000000開始的地址分配相應的空間,並將該共享庫載入到該空間(動態連結共享物件的記憶體地址分配)。
        【注意】棧向低地址增長;堆向高地址增長。當棧或者堆的現有大小不夠用的時候,它將按照圖中的增長方向擴大自身的尺寸,直到預留的空間(unused)被用完。
        【補充】在Linux或者是win記憶體中,有些地址是始終不能讀寫的,例如0地址,當指標指向這些地址的時候,就會出現“段錯誤(segment fault)”。造成這種情況的兩種最普遍的原因:
        1.程式設計師將指標初始化為NULL,但是沒有賦予合理的初值就開始使用。
        2.程式設計師沒有初始化棧上的指標,指標的值一般是隨機數,之後就開始使用該指標。

    • 在經典的作業系統中,棧總是向下(低地址)增長的。

    • “堆疊幀”或“活動記錄”
        棧儲存一個函式呼叫所需要的維護資訊,常被稱為堆疊幀或者是活動記錄,堆疊幀一般包括:
      (1)函式的返回地址和引數;
      (2)臨時變數:包括函式的非靜態區域性變數以及編譯器生成的其他區域性變數;
      (3)儲存的上下文:包括在函式呼叫前後保持不變的暫存器。

    • 棧中有兩個重要的暫存器:esp和ebp
      (1)esp
        該暫存器標明棧頂,在棧上壓入資料會導致esp減小,反之esp增大。再者,減小esp的值等於在棧上開闢空間,而增大esp的值等效於在棧上回收空間。
        esp不僅僅指向棧的頂部,同時也就意味著指向當前整個函式活動記錄的頂部(見下圖)。
      (2)ebp
        ebp暫存器指向函式活動記錄的一個固定位置。ebp暫存器又被稱為幀指標。如下:

        ebp具體的固定位置如上圖所示。如圖,(注意棧向低地址增長)ebp之前是該函式的返回地址,再之前是壓入棧中的引數。而ebp指向的資料是呼叫該函式之前的ebp的值,儲存舊的ebp的值的原因是,函式返回時,可以通過讀取該值恢復到呼叫之前的ebp的值(回到之前指向的位置)。

  • 呼叫慣例

    • 概念
        函式的呼叫方和被呼叫方對於函式如何呼叫需要有一個明確的約定(比如引數入棧的順序等)。這樣的約定就被稱為呼叫慣例。

    • 呼叫慣例一般包括:
      (1)函式引數的傳遞順序和方式。可以有很多方式,最常見的就是通過棧傳遞,引數入棧的順序要事先由呼叫慣例確定;有些呼叫慣例為了提升效能還會選擇用暫存器進行引數傳遞。
      (2)棧的維護方式。引數入棧後,函式體會被呼叫,引數使用的時候會被彈出的,可以是函式呼叫方進行引數的彈出或者是由函式本身進行引數的彈出。
      (3)名字修飾的策略。C語言實際上存在多種呼叫慣例,一般預設的(沒有顯示指定的情況下)是cdecl(不同的編輯器寫法有別):
      int _cdecl foo(int n, float m);

    • foo函式佈局
      具體如下圖所示:

        當foo函式返回的時候,程式會首先使用pop恢復儲存在棧中的暫存器,然後從棧裡取出返回地址,並返回至呼叫方,呼叫方再調整esp將堆疊恢復。下面給出一個具體的多級呼叫棧的佈局:

  • 函式返回值傳遞

    • 如果返回值的可以用4個位元組表達,我們經常講返回值儲存在eax暫存器裡面。返回後,函式的呼叫方將讀取eax暫存器中的值即可。
    • 對於返回4~8位元組物件的情況,幾乎所有的呼叫慣例都採用eax和edx聯合返回的方式進行的。
    • 超過8位元組的返回值(大致思路):
      先看一段程式碼:

      
      #include<stdio.h>
      
      typedef struct big_thing
      {
        char buf[128];
      }big_thing;
      
      big_thing return_test()
      {
        big_thing b;
        b.buf[0] = 0;
        return b;
      }
      
      int main()
      {
        big_thing n = return_test();
        return 0;
      }
    • 大致解讀

      • 首先main函式在棧上額外開闢一片空間,並將這塊空間的一部分作為傳遞返回值的臨時物件,這裡稱為temp;
      • 將temp物件的地址作為隱藏引數傳遞給return_test函式;
      • return_test函式將資料拷貝給temp物件,並將temp的地址用eax傳出;
      • return_test函式返回後,main函式將eax指向的temp物件的內容拷貝給了n。

    返回值傳遞流程如下:

    【注意】結果返回值物件會被拷貝兩次,所以不到萬不得已不要返回大尺寸的物件。

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

    • malloc的實現
        不能採用系統呼叫的方式,開銷較大;較好的做法是程式向作業系統申請一塊適合大小的堆空間,然後由程式自己管理這塊空間,具體來講,管理著堆空間分配的往往是程式的執行庫。
        執行庫相當於向作業系統”批發“了一塊較大的堆空間,之後”零售“給程式使用,如全部售完或者程式有大量的記憶體需求,再根據實際情況向作業系統“進貨”。

    • Linux程序堆管理
        提供兩種堆分配方式,即兩個系統呼叫:brk()系統呼叫;mmap()系統呼叫。

      • brk()
        該函式的C語言宣告如下:
        int brk(void *end_data_segment)
          該函式申請堆的方式是:設定程序的資料段的結束地址,即可以擴大或者是縮小資料段(Linux下資料段和BSS段合稱資料段)。如果我們將資料段的結束地址向高地質移動(資料段變小),那麼擴大的那部分空間常常用來作為堆空間。

      • mmap()
          該函式的作用是向作業系統申請一段虛擬空間,這塊虛擬地址空間可以對映到某個檔案,當它不進行對映的時候,我麼又稱這塊空間為匿名空間,匿名空間就可以用來作為堆空間
          glibc的malloc函式是這樣處理使用者的空間請求的:
          (1)對於小於128kb的請求,它會在現有的堆空間裡面,按照堆空間分配演算法為其分配一塊地址並返回;
          (2)對於大於128kb的請求,它會使用mmap()函式為它分配一塊匿名空間。使用mmap()函式實現malloc的程式碼如下:

        void *malloc(size_t  nbytes)
        {
            void *ret = mmap(0,nbytes,PROT_READ | PROT_WRTIE, MAP_PRIVATE | MAP_ANONYMOUS,0,0);
            if(ret == MAP_FAILED)
                return 0;
            return ret;
        }

      【需要注意的是】mmap()函式和VirtualAloc()類似,他們都是虛擬空間的申請函式,它們申請的空間的實際地址和大小必須是系統頁的大小的整數倍

  • 堆分配演算法

    • 空閒連結串列法

      • 簡介
          該方法將堆中的各個空閒塊按照連結串列的方式連線起來,當用戶請求一塊空間的時候,可以遍歷整個連結串列,直到找到合適大小的塊並將它拆分,當用戶釋放空間的時候將他合併到空閒連結串列中。

      • 結構
          在堆裡的每一個空閒空間的開頭(或結尾)有一個頭(header),頭結構裡面有兩個指標prev和next,如下圖:

      • 操作過程
          首先在空閒連結串列查詢足夠容納請求大小的一個空閒塊,然後將這個塊分為兩部分,一部分是程式請求的空間,另一部分是剩餘的空閒空間。
        【注意】當採用空閒連結串列的方式時需要釋放空閒空間的時候,有一個簡單的解決方法:當用戶請求k個位元組的時候,我們實際分配k+4個位元組,這四個位元組用來儲存該分配的大小。

    • 點陣圖

      • 核心思想
          將整個堆劃分為大量的大小相等的“塊”。當用戶請求的時候,總是分配整數個塊的空間給使用者。分配的塊中,第一個塊我們稱為已分配區域的頭(Head),其餘的稱為已分配區域的主體(Body)。我們使用整數陣列來記錄塊的使用情況,由於每個塊只有頭/主體/空閒三種狀態,因此可以使用兩個bit位來表示一個塊。

      • 舉例
          假設堆的大小為1MB,那麼我們讓一個塊大小為128位元組,則總的塊數:8k/(32/2)=512個int來儲存,。這有512個int的陣列就是一個位圖,其中每兩個bit位代表一個塊。

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

      • 缺點
        (1)分配記憶體的時候容易產生碎片。例如,分配300位元組時,實際上分配了3個塊即384個位元組,這樣就浪費了84個位元組(128*3,按照整數個塊進行分配)。
        (2)如果堆很大但是設定的塊很小(這樣可以減少碎片的數量),但同時也會導致點陣圖的規模很大,可能會失去cache命中率高的優勢,同時也會浪費一定的空間。針對這種情況,我們可以使用多級點陣圖(不再介紹)。

    • 物件池

      • 使用情況
          被分配的大小是較為固定的幾個值。

      • 大致思路
          如果每次分配的空間大小都一樣,那麼就可以按照這個每次請求分配的大小作為一個單位,把整個堆空間劃分為大量的小塊,每次請求的時候只需要找到一個小塊就可以了。

      • 管理
          物件池的管理方法可以是空閒連結串列或者是點陣圖。

    • 實際應用
        實際的應用中,堆的分配演算法其實是採取多種演算法的複合。