原文鏈接 內存中的堆和棧到底是什么
引言
網絡上關於內存中各區段作用的文章有很多,但不得不吐槽一下,這些文章大多相互引用,內容大同小異,沒有把問題講解清楚。
因此,筆者想通過本文,借助匯編的知識,深入底層講解內存模型。本文的結構如下:
- 程序在內存中的存儲模型
- 編程過程中常見的幾類變量所在的位置和作用
- 堆和棧的細節
- 起到拋磚引玉作用的底層原理(這意味着你需要自己去深入研究才能真正理解清楚)
- 實驗驗證
前三小節是淺嘗輒止地引題,詳細原理請見第四小節,最后在第五小節筆者給出了可實際操作的方法,幫助大家更直觀地理解。文章可能較長,請堅持讀完,或者擇篇章閱讀。
網上的資料
首先,筆者羅列出一些質量尚可的博客,大家可以先閱讀一下。之后筆者會針對大家可能存在的疑惑,從底層來一一講解清楚。
網上很多文章都引用到了下面這段代碼:
int a = 0; //全局初始化區
char *p1; //全局未初始化區
main()
{
int b; //棧
char s[] = "abc"; //棧
char *p2; //棧
char *p3 = "123456"; //123456\0在常量區,p3在棧上
static int c =0; //全局(靜態)初始化區
p1 = (char *)malloc(10); //堆
p2 = (char *)malloc(20); //堆
}
並且如果你搜索關鍵詞“內存堆棧圖”,將很容易找到下面這張圖:

這也是筆者在查閱資料時很不滿的地方,許多文章互相引用,內容雷同,卻沒有把問題的本質講清楚。
因此,筆者將基於上述代碼以及這張圖,這兩個最常被引用的東西,來把原理講清楚。
程序在內存中的模型
注:本文所指的程序皆在用戶空間運行,即不涉及操作系統類程序和驅動程序。
目前流行的那幾種高級語言,歸根到底,底層實現的思路都是差不多的。而且當今以Intel主流的CPU架構(雖然也有ARM),其設計理念也是一脈相承的。
要講清楚內存模型,我們就要深入底層涉及到匯編,很多高級語言都會經歷翻譯到匯編這一中間過程,匯編可以直觀地使用機器指令,是最接近的底層的語言。
在一個匯編程序中,常常把一個用戶空間程序按習慣分為三個段:.data段,.bss段,.text段。
.data段
.data段包含了已經初始化了的數據項,這些數據在程序開始運行前就擁有自己的值,這些值是可執行文件的一部分,當可執行文件被加載到內存中用於執行時,這些數據也被加載到內存中。
定義的初始化數據越多,可執行文件就越大,運行它的時候也就需要更長的時間才能將它們從磁盤加載到內存。
一些全局或者靜態的,且經過定義初始化過的變量,就屬於該段。例如下面代碼中的a,指針p以及b三個變量:
int a = 2; int *p = &a;
int main ()
{
static int b = 1;
...
...
return 0;
}
.bss段
並不是所有數據項在程序開始之前都擁有值,例如你可以定義一個緩沖區來存在某些數據,這個緩沖區是.bss段中定義的。
分別定義.data段與.bss段中的數據,它們一個重要的區別就是:.data段中的數據會添加到可執行文件的大小上,而.bss段中的則不會。即便你給.bss段定義一個1M字節的緩沖區,其最終可執行文件大小也幾乎不變(除了大約50個字節用於描述外)。
程序在加載時知道哪些數據項沒有初值,它會為這些數據項分配空間,而具有初值的數據線會與其初值一同讀入。
一些全局或者靜態的,且未經過初始化的變量,屬於.bss段。例如上文中.data段段的三個變量,如果不進行初始化,就會存儲在本段中。
.text段
以上兩個段都是源程序所需要的數據,而真正組成程序的機器指令則存放在.text段中。一般情況下,在.text段中不進行數據項的定義。.text段包含名為標號(label)的符號,這些符號用於標識跳轉和調用程序代碼位置。
程序內存中的堆棧
先附上筆者在學習匯編時的一張筆記圖,字比較丑,望各位見諒。

通過該筆者圖大家能夠大概了解內存中上述三個段的位置。至於其中的堆棧以及筆記的含義請繼續閱讀后文。
編程過程中常見的幾類變量
觀察最開頭經常被引用的那段代碼,其中涵蓋了幾類最常見的變量以及其對應的存儲位置。在上一小節中,我們已經說明了全局變量和靜態變量存儲的位置取決於是否進行過初始化。對於堆棧的解釋我們留到下一小節。這里我們着重講解文字常量區。
文字常量區
考慮如下代碼:
char *p3 = "123456"; //123456\0在常量區,p3在棧上
這個文字常量區是什么?顯然它與字符串存放有關。所謂字符串是指位於連續內存區域中的一個字符序列。字符串通過在起始處關聯一個標號來進行定義。在匯編中,常見的字符串定義如下:
MSG: db "something"
它是位於.data段中的。和.data段中的所有變量一樣,它也是一種已經初始化的數據:帶有一個值,而不僅僅是一個在將來某時刻用於存放數據的內存空間。MSG標號和DB指令在內存中指定一個字節作為字符串的起點,而字符串中的字符數則告訴匯編編譯器為該字符預留多少個字節的存儲空間。
但高級語言中的字符串可能要比這里復雜一點,以C語言為例,針對printf函數中包含的字串。筆者認為其存儲於.data段和.text段之間的一個名叫.rodata段的地方。即那張常見的“堆棧內存圖”中底部綠色的“只讀區”。
大家可以發現,現在引出了更多的背后細節。因此,更為深入的說明我會留到第四個小節:底層的原理。
堆和棧的細節
下面進入第三小節,講解堆和棧,這也是最開頭代碼中仍未涉及的兩種變量存儲位置。注意:我們在匯編中常說的堆棧,其實是棧,並不包含堆。
在此之前,推薦大家看一下stackoverflow的這個問答What and where are the stack and heap?
棧
棧由系統管理。但是為什么呢?
首先,棧是一個后進先出(LIFO)結構。當把數據放入棧時,我們把數據push進入;當從棧取出數據時,我們把數據pop出來。棧隨着數據被壓入或者彈出而增長或者減小。最新壓入棧的項被認為是在“棧的頂部”。當從棧中彈出一個項時,我們得到的是位於棧最頂部的那一個。就像給彈夾上子彈,只能在頂部進行操作。
在x86體系中,棧頂由堆棧指針寄存器ESP來標記,它是一個32位寄存器,里面存放着最后一個壓入棧頂的項的內存地址。正因為有它,我們才能夠隨時操作到需要的項。需要注意的是,棧頂是朝着地內存方向增長的。
堆
再來看我拍的照片,為於.bss段和棧之間有一段空余內存,C程序經常使用這種剩余內存空間來為那些為於堆內存中的,“已經在運行中”的變量分配空間。我們常說的堆就存在於這里。
二者分別存儲什么以及原因
可以看到棧有一個ESP寄存器管理,從底層就實現了一種“自動化”,而堆似乎並沒有額外的東西來幫助管理。
此外,棧的大小需要有一定的限制,棧的增長是向低地址擴展,如照片中看到的,如果棧不斷地增加,很可能會與.bss段發生碰撞,這是不堪設想的,系統會發出錯誤並終止程序。
棧應該被看成一個短期存儲數據的地方,存在在棧中的數據項沒有名字,只是按照后進先出來操作罷了。棧經常可以用來在寄存器緊張的情況下,臨時存儲一些數據,並且十分安全。當寄存器空閑后,我們可以從棧中彈出該數據,供寄存器使用。這種臨時存放數據的特性,使得它經常用來存儲局部變量,函數參數,上下文環境等。
相反,堆相對於棧,更加強調需要進行控制。常見的就是我們手動申請,手動釋放。因此可以分配更大的空間,但開銷也會更多。
底層原理
拋磚引玉
上面三個小節對於底層原理都是淺嘗輒止,一上來就講得很深入,會增加閱讀負擔。但在這一小節,我們必須講一些底層的東西。不過筆者必須提前聲明,雖然我們會涉及很多底層的知識,但對於整個計算機系統,這仍舊是冰山一角的知識。筆者在這里更多地是起到拋磚引玉的作用,完全講解清楚,可能需要一本書的篇幅,而且筆者水平也很有限。這意味着如果你閱讀了本文,有所啟發想要一探究竟,可能就真的需要自己去探索了。
好書推薦
在這里,筆者推薦一本書:《深入理解計算機系統(原書第2版)》。我很詫異這本書竟然出到第三版了,注意第三版針對64位CPU,學習的話還是在32位下比較方便,因此推薦第二版。
可執行目標文件
程序在運行前以可執行文件的形式存儲在磁盤中,我們先來看一下這張圖:
ELF格式是類UNIX系統中可執行文件的常見格式,在眾多表項中我們重點關注:.text,.rodata,.data,.bss這四個小段(節)。可以看到.text和.rodata屬於只讀存儲器段(代碼段),而.data,.bss屬於可讀可寫存儲器段(數據段)。下面具體說明這四個小段。
.text
存放已編譯程序段機器代碼。
.rodata
存放只讀數據,如C語言中printf語句中的格式串和開關語句的跳轉表。
所謂開關語句的跳轉表,一個典型的例子就是switch(開關)語句的匯編實現,其使用了數組來映射代碼塊的地址,以此構成一張跳轉表,相關的內容存儲於只讀數據中。
.data
已初始化的全局C變量。局部C變量在運行時保存在棧中,既不出現在.data中,也不在.bss中。
.bss
未初始化的全局C變量。如前文匯編語言講解中提到的,它在目標文件中不占據實際空間,僅僅是一個占位符。目標文件格式區分初始化和未初始化變量是為了空間效率:在目標文件中,未初始化變量不需要占據任何實際的磁盤空間。
值得一提的是,.bss原本是IBM704匯編語言(大約在1957年)中Block Started by Simple指令的首字母縮寫,並沿用至今。不過在今天,我們只需要記住區別.data和.bss的最簡單的方法就是把.bss看成是“更好地節省空間”(Better Save Space)的縮寫!
有一些特例
- 標記有static靜態標志的局部變量不在棧中管理,而是根據有無初始化,在.data或者.bss中。
- 對於GCC編譯器,初始化為0的變量存儲在.bss中。
所以說,如果想真的搞清楚來龍去脈,仍舊需要你自己去閱讀各類文獻。
加載可執行目標文件
可執行文件在內存中運行時,有一個運行時存儲器印象,我們來看一下這其中的情況,如下圖:

這張圖涵蓋了本文所講的大多數知識點。相比於前文的那張匯編語言內存圖,更加細分了。
- 代碼段總是從地址0x08048000處開始。
- 數據段在接下來的下一個4KB對齊的地址處。
- 運行時堆在讀/寫段(數據段)之后接下來的第一個4KB對齊的地址處,並通過malloc庫往上(高地址方向)增長。
- 中間還有一個段是為共享庫(shared library)保留的。
- 用戶棧總是從最大的合法用戶地址開始,向下增長(低地址方向)
- 棧上方的段是為操作系統駐留存儲器部分(也就是內核)的代碼和數據保留的。
- 當程序開始運行時,加載器在可執行文件中段頭部表的指引下,將可執行文件的相關內容拷貝到代碼段和數據段。
上述的一些名詞,比如共享庫,其含義可能需要你自己去研究。Tips:Windows的.DLL。另外,筆者在參考各類文獻時發現,上述諸如數據段,data段等名字經常包含不同的含義,且經常一個概念有多種說法。例如只讀段又可以被認為是代碼段。這里大家需要注意我們所說的數據段不是指data段,而是data段和bss段。
其實完全細分的名稱會與操作系統和CPU架構有關,筆者在這里只能針對共通的地方加以概括。
動態存儲器分配
這里重點講一下堆。
動態存儲器分配維護這一個進程的虛擬存儲器區域,稱為堆(heap)。我們假設堆是一個請求二進制零的區域,它緊接在未初始化的.bss區域后開始,並向上(高地址方向)生長。對於每一個進程,內核維護這一個變量brk(讀作"break"),它指向堆堆頂部。如下圖:
分配器將堆視為一組不同大小的塊(block)的集合來維護。每一個塊就是一個連續的虛擬存儲片(chunk),要么已分配,要么是空閑的。已分配的塊顯式地保留為供應用程序使用。空閑的塊可以用來分配。空閑塊保持空閑,直到它顯式地被應用所分配。一個已分配的塊保持已分配的狀態,知道它被釋放。這種釋放要么是應用程序顯式執行的,要么是存儲器分配起自身隱式執行的。
- 顯式分配器(explicit allocator),要求應用顯式地釋放任何已分配的塊。如C中的malloc和free。C++中的new和delete。
- 隱式分配器(implicit allocator),要求分配器檢測一個已分配的塊何時不再被程序所使用,就去釋放這個塊。隱式分配器也叫做垃圾收集器(garbage collector),而自動釋放未使用的且已被分配的塊的過程叫做垃圾收集(garbage collection)。不用我說,你們也可能已經想到了Java的垃圾回收機制。
可見堆也並不是非要人工手動去管理的,文章最開始的一些說法確實是值得推敲的。
對於堆的組織方式,筆者略提一下其中的一種方式:我們可以將堆組織為一個連續的已分配塊和空閑塊的序列,我們稱這種結構為隱式空閑鏈表。空閑塊通過頭部中的大小字段隱含地連接着,分配器可以通過遍歷堆中的所有塊,從而間接遍歷整個空閑塊的集合。如下圖:

此外,筆者還想順帶說一個很容易出問題的地方:對於C語言malloc的內存區域,通過一個指針去訪問,當該片內存被free后,請務必將無效指針設為NULL!請務必將無效指針設為NULL!請務必將無效指針設為NULL!(在iOS對應的OC中,請將對象指針設為nil。)
之所以要這樣,簡而言之,在分配器的實現細節中,在調用free返回之后,指向分配區域的指針仍會指向被釋放了的塊(野指針)。現在,該塊已經實效,如果再通過該野指針去訪問,會出現可怕的后果。因此應該確保在該塊被一個新的malloc調用重新初始化之前,不再使用該野指針,最好的防治誤用的做法就是給指針置零。
分配器的設計和實現是復雜的,想要一探究竟還需要你自己去研究。
過程和棧幀
這里講述最后一點:棧。
C語言中的函數,對應匯編中的過程。一個過程調用包括將數據(以過程參數和返回值的形式)和控制從代碼的一部分傳遞到另一部分。另外,它還必須在進入時為過程的局部變量分配空間,並在退出時釋放這些空間。上述的數據傳遞,局部變量的分配和釋放通過操縱程序幀來實現。
程序用程序棧來支持過程調用。機器用棧來傳遞過程參數,存儲返回信息,保存寄存器用於以后恢復,以及本地存儲。為單個過程分配的那部分成為棧幀(stack frame)。下圖描繪了棧幀的通用結構,最頂端的棧幀以兩個指針界定,寄存器%ebp為幀指針,而寄存器%esp為棧指針。當程序執行時,棧指針可以移動,因此大多數信息訪問都是相對於幀指針的。(注:%esp與ESP是同一個寄存器的不同說法而已,%ebp同理)
假設過程P(調用者)調用過程Q(被調用者),則Q的參數放在P的棧幀中。另外,當P調用Q時,P中的返回地址被壓入棧中,形成P的棧幀的末尾。返回地址就是當P從Q返回時應該繼續執行的地方。Q的棧幀從保存的幀指針的值(例如寄存器%ebp的副本)開始,后面時保存的其他寄存器的值。
過程Q也用棧來保存其他不能存放在寄存器中的局部變量,這樣做的原因如下:
- 沒有足夠的寄存器存放所有的局部變量。和前文匯編語言部分解釋的原因相同。
- 有些局部變量是數組或者結構,因此必須通過數組或者結構引用來訪問。
- 要對一個局部變量使用地址操作符'&',我們必須能夠為它生成一個地址。
另外,Q也會用棧幀來存放它調用的其它過程的參數。參數n位於相對於%ebp偏移量為4+4n字節的地方。較大的參數(如結構體和較大的數字格式)需要棧上更大的區域。
正如前文所講,棧向低地址方向增長。棧指針%esp指向棧頂元素,可以用push存入數據,用pop取出數據。將棧指針的值減小適當的大小可以分配沒有指定初始值的數據的空間(加入數據棧頂向低地址方向移動)。類似地,可以通過增加棧指針來釋放空間(取出數據棧頂向高地址方向移動)。
實驗環節(更新於2016/12/10)
純理論的東西可能讓人沒有實感,對於各區段在內存中的模型,筆者也一直思索該如何以編程的方式展現,今天終於找到了一個好方法。
首先,請確保你有一個Linux或類Unix的系統環境,我們需要用一些命令。筆者是在Mac上實驗,發現Mac的命令有點差異,於是ssh到了自己的Ubuntu服務器。
開始實驗
考慮下述代碼:
#include <stdio.h> int a = 0; //全局初始化區 char *p1; //全局未初始化區
int main()
{
int b; //棧
char s[] = "abc"; //棧
char *p2; //棧
char *p3 = "123456"; //123456\0在常量區,p3在棧上
static int c =0; //全局(靜態)初始化區
p1 = (char *)malloc(10); //堆
p2 = (char *)malloc(20); //堆return 0;
}
其實就是那段引用爛了的代碼,筆者補全了int main()
和return 0
。我們以此為藍本,修改一些代碼來觀察生成的可執行文件的結構,以此讓大家對各區段的作用有個清晰的認識。
復制粘貼編輯,gcc編譯完成后,筆者將其命名為origin。接着在命令行中鍵入:
> size origin
可以看到如下結果:
text data bss dec hex filename
1384 568 24 1976 7b8 origin
關注前三個表項,列出了各區段的大小,請記住這些大小。
修改一(加入全局變量並初始化)
我們在main()函數前加入一個全局數組,並初始化一下,代碼如下:
#include <stdio.h> int a = 0; //全局初始化區 char *p1; //全局未初始化區
int arr[1000] = {233}; // 修改的代碼在這里,全局數組已初始化
int main()
{
int b; //棧
char s[] = "abc"; //棧
char *p2; //棧
char *p3 = "123456"; //123456\0在常量區,p3在棧上
static int c =0; //全局(靜態)初始化區
p1 = (char *)malloc(10); //堆
p2 = (char *)malloc(20); //堆return 0;
}
同樣編譯並執行size命令,筆者將其命名為addToDataSection,得到如下結果:
text data bss dec hex filename
1384 4584 24 5992 1768 addToDataSection
注意到data段大小增加了4000字節,原因就是全局數組在源碼編譯后,會直接增加到生成的可執行文件中,1000個int在32位下就是10004B = 4000B*。
修改二(加入全局變量但不初始化)
接下來,對於增加的全局數組,去掉其初始化操作,代碼如下:
#include <stdio.h> int a = 0; //全局初始化區 char *p1; //全局未初始化區
int arr[1000]; // 全局數組不進行初始化
int main()
{
int b; //棧
char s[] = "abc"; //棧
char *p2; //棧
char *p3 = "123456"; //123456\0在常量區,p3在棧上
static int c =0; //全局(靜態)初始化區
p1 = (char *)malloc(10); //堆
p2 = (char *)malloc(20); //堆return 0;
}
編譯,命名為addToBssSection,執行size命令,結果如下:
text data bss dec hex filename
1384 568 4064 6016 1780 addToBssSection
可以看到bss段增加了4000字節,別急,這並不意味着bss段增加的數組會作用於生成的可執行文件,還記得上文說過的嗎?bss段並不增加可執行文件大小,只是加入少許記錄信息。我們ls三個文件即可看到區別:
-rwxrwxr-x 1 ubuntu ubuntu 8658 Dec 10 18:53 origin*
-rwxrwxr-x 1 ubuntu ubuntu 12720 Dec 10 18:55 addToDataSection*
-rwxrwxr-x 1 ubuntu ubuntu 8695 Dec 10 18:56 addToBssSection*
可以看到bss段段增加並未顯著增大可執行文件的大小,只有data段才會有所影響,增加了大約4000字節。
修改三(加入局部變量)
在這里,我們把全局數組移入main()函數中,代碼如下:
#include <stdio.h> int a = 0; //全局初始化區 char *p1; //全局未初始化區
int main()
{
int b; //棧
char s[] = "abc"; //棧
char *p2; //棧
char *p3 = "123456"; //123456\0在常量區,p3在棧上
static int c =0; //全局(靜態)初始化區
p1 = (char *)malloc(10); //堆
p2 = (char *)malloc(20); //堆int arr[1000] = {233};// 內部數組 return 0;
}
同樣編譯命名為addLocalVariable,執行size命令,結果如下:
text data bss dec hex filename
1456 568 24 2048 800 addLocalVariable
可以看到data段和bss段都沒有什么變化,這說明局部變量不存儲於這兩個段,同時我們ls來查看一下四個文件:
-rwxrwxr-x 1 ubuntu ubuntu 8658 Dec 10 18:53 origin*
-rwxrwxr-x 1 ubuntu ubuntu 12720 Dec 10 18:55 addToDataSection*
-rwxrwxr-x 1 ubuntu ubuntu 8695 Dec 10 18:56 addToBssSection*
-rwxrwxr-x 1 ubuntu ubuntu 8668 Dec 10 18:59 addLocalVariable*
可執行文件大小也幾乎不變,說明局部變量不會保存在其中。
修改四(局部變量設置為靜態,根據是否初始化有不同的結果)
下面我們進行最后一個修改,把上述的內部數組加上static關鍵詞,代碼如下:
#include <stdio.h> int a = 0; //全局初始化區 char *p1; //全局未初始化區
int main()
{
int b; //棧
char s[] = "abc"; //棧
char *p2; //棧
char *p3 = "123456"; //123456\0在常量區,p3在棧上
static int c =0; //全局(靜態)初始化區
p1 = (char *)malloc(10); //堆
p2 = (char *)malloc(20); //堆static int arr[1000] = {233};// 靜態數組 return 0;
}
編譯命名為addStaticVariable,執行size命令,結果如下:
text data bss dec hex filename
1384 4584 24 5992 1768 addStaticVariable
仔細觀察,發現結果與增加全局初始化數組是一樣的,這說明帶有static關鍵詞的局部變量並不存放在棧中,如果未初始化則存在於data段。
大家可以去掉靜態數組的初始化語句,編譯后執行size會返回如下:
text data bss dec hex filename
1384 568 4080 6032 1790 addStaticVariable
可以看到原先data段的增量轉移到了bss段。同時在更改前后分別ls一下可以看到如下區別:
// 數組進行初始化,編譯在data段中時,其體積計算在可執行文件中 -rwxrwxr-x 1 ubuntu ubuntu 12726 Dec 11 22:20 addStaticVariable*
// 數組未初始化,編譯在bss段中時,其體積不計算在可執行文件中
-rwxrwxr-x 1 ubuntu ubuntu 8702 Dec 13 15:41 addStaticVariable*
大家可以嘗試在C程序中開一個很大的局部變量數組,看看編譯器會怎樣提示你。
之前我曾經在Win7的VS上試過,int數組若含有超過1000個元素,編譯器就總是提示編譯失敗。后來解決的辦法是利用static關鍵詞,將其編譯進bss段。因為默認的局部變量數組存放在棧中,一下子開太大會超過Windows的限制。不過顯然,生成的exe文件在執行前需要讀取更多的信息。
實驗小結
我們可以將上述實驗結果總結如下:
- data段保存在目標文件中
- bss段不保存在目標文件中(除了記錄bss段在運行時所需的大小)
- 局部變量並不進入可執行文件,它們在運行時創建,一般在棧上。
- 含有static關鍵詞修飾的變量根據有無初始化,存儲於數據段,即data段和bss段
題外話
不少公司面試喜歡問內存中堆和棧區別,以及內存模型等等。這里筆者發現了一個略有trick又不失區分度的題目:請寫一段代碼,用來指明程序中堆棧段的大致位置。
后續會公布答案,答案非常簡單也很神奇,請大家積極思考或者留言~