可執行文件的內存模型,變量的值是放在棧上還是放在堆上


楔子

作為開發者,我們一輩子會經歷很多工具、框架和語言,但是這些東西無論怎么變,底層邏輯都是不變的。所以今天我們就回頭重新思考編程中那些耳熟能詳卻又似懂非懂的基礎概念,搞清楚底層邏輯。而代碼中最基本的概念是變量和值,而存放它們的地方是內存,所以我們就從內存開始。

說到內存,很多人其實並沒有搞懂什么時候數據應該放在棧上,什么時候應該在堆上,直到工作中實際出現問題了,才意識到數據的存放方式居然會嚴重影響並發安全,無奈回頭重新補基礎,時間精力的耗費都很大。

我們以 C 語言為例,C 源文件經過預處理、編譯、匯編、鏈接,這四步之后會生成一個可執行程序。在程序還沒有運行之前,也就是程序還沒有加載到內存之前,可執行程序內部就已經分好了三個區域,分別是:代碼區(text)、初始化數據區(data)、未初始化數據區(bss)。

初始化數據區(data)和未初始化數據區(bss)整體被稱為靜態區或全局區。

代碼區:存放 CPU 執行的機器指令,通常代碼區是可共享的(即另外的執行程序可以調用它),可共享的目的是對於頻繁被執行的程序,只需要在內存中存儲一份代碼區的內容即可。並且代碼區通常是只讀的,只讀的原因是既然可以被其它程序讀取,那么就要它不會被其它程序串改。此外,代碼區還負責存放常量,也就是程序在運行的期間不能夠被改變的量,例如: 520、字符串常量 "hello world"、數組的名字等。

初始化數據區:該區包含了在程序中明確被初始化的全局變量,已經初始化的靜態變量(包括全局靜態變量和局部靜態變量)。

未初始化數據區:該區域存儲的是未初始化的全局變量和未初始化的靜態變量,而未初始化數據區的數據在程序開始執行前會被內核初始化為 0 或者 nil。

初始化數據區和未初始化數據區統稱為靜態區,而靜態區的內存和整個程序具有相同的聲明的周期,也就是說,靜態區的內存會等到程序全部結束之后才釋放。

以上這幾個區域是固定的,程序還沒運行的時候就已經划分好了。但是當運行可執行程序的時候,系統會把程序加載到內存中,然后除了上面說的幾個區域之外,還會額外增加兩個區域:堆區、棧區。

堆區:堆是一個大容器,它的容量要遠大於棧,但沒有棧那樣先進后出的順序。用於動態內存分配,堆在內存中位於 "未初始化數據區" 和 "棧區" 之間。根據語言的不同,如果 C 語言、C艹 語言,堆區內存由程序猿手動釋放;如果程序猿不釋放,那么程序結束時會由操作系統回收。但是這並不代表使用 C、C++ 語言編程就可以不管堆區內存了,如果你的程序占用內存過大並且不及時釋放的話,很有可能造成內存溢出。而像 Go、Python、Java 等語言都帶有垃圾回收機制,你盡管使用,內存管理由對應的編譯器或解釋器來做即可。雖然垃圾回收機制會占用額外的資源,但也將程序猿從內存管理的繁忙工作中解放了出來。

棧區:棧是一種先入后出的數據結構,由操作系統自動分配釋放,存放函數的參數值、返回值、局部變量等等。在程序運行過程中實時加載和釋放,因此局部變量的生命周期為相應的棧空間從申請到釋放的這段時間。

text、data、bss 代碼解析

光用文字解釋的話還不夠直觀,下面我們就用 C 語言來舉幾個栗子,分析一下這幾個區。

text

首先是代碼區:

#include <stdio.h>

void main() {
    // 定義一個 char * 類型的指針,存放字符串常量 "hello cruel world" 的首地址
    // 注意:此時的字符串是存在 "代碼區" 當中的,因為它是一個字符串常量
    char *s = "hello cruel world";
    printf("%p, %s\n", s, s);
    
    // 同樣,在代碼區創建一個新的字符串,然后讓 s 指向新字符串的首元素
    s = "hello beautiful world";
    printf("%p, %s\n", s, s);
}

我們編譯執行一下:

打印的結果是沒有問題的,而且編譯之后這些字符串就是可執行文件的一部分了,在執行的時候直接拿來用即可,沒有動態申請這一步。我們可以看一下它內部區域的大小信息:

我們看到結果顯示了代碼區(text)、初始化數據區(data)、未初始化數據區(bss)的大小信息,說明這些區域在生成可執行文件的時候就已經確定好了。

#include <stdio.h>

void main() {
    char *s = "hello cruel world...";
    printf("%p, %s\n", s, s);
    
    s = "hello beautiful world...";
    printf("%p, %s\n", s, s);
}

我們修改源文件,給每個常量字符串各自增加三個字符,然后重新生成可執行文件並查看大小信息。

我們看到代碼區(text)的大小變成了 1286,因為兩個常量字符串加起來相比之前多了 6 個字符,所以這完全符合我們的預期。然后我們再看一下地址,之前第一個常量字符串的地址是 0x400600,現在也是,說明分配的地址是相同的。但是之前第二個常量字符串的地址是 0x40061a,現在變成了 0x40061d,如果將兩者轉成 10 進制相減的話,結果相差 3。相信原因很好想,因為相比之前,我們的第一個常量字符串多了 3 字節,那么第二個常量字符串的地址相比之前自然就要往后移 3 個字節,這一切都是符合預期的。

因為我們只修改了字符串常量,所以只會影響 text,data 和 bss 則不受影響,整體還是很好理解的。然后我們還說代碼區中的常量是不能修改的,因為在編譯之后它們就已經是可執行文件的一部分了,然后在讀取的時候只需一條指令即可,效率非常高。並且和靜態區(data、bss)一樣,這些常量也和可執行文件在執行時具有相同的生命周期。

#include <stdio.h>

void main() {
    char *s = "hello cruel world";
    s[0] = 'H';
    printf("%p, %s\n", s, s);
}

我們嘗試修改常量字符串的第一個元素,將其改為 'H',看看會有什么后果。

我們看到直接段錯誤了,因為常量不允許修改。

另外,根據 size 命令我們知道常量是存在代碼區當中的,不過有時我們更喜歡將存放常量的區域單獨稱為常量區,但很明顯常量區也是代碼區的一部分。

但如果我們就是想修改字符串該怎么辦呢?答案是如果想修改,就不要將其放在常量區(代碼區),而是將其放在棧區。

#include <stdio.h>

void main() {
    // 這里我們將 char *s 改成了 char s[],那么這兩者有什么區別呢?
    // char *s = "abc" 相當於聲明了一個字符串常量 "abc",它是放在常量區當中的,然后讓指針 s 指向這個常量
    // char s[] = "abc" 等價於 char s[] = {'a', 'b', 'c', '\0'},此時的 "abc" 是放在棧區的,因為此時的 s 是一個局部數組
    // 所以要注意聲明數組和聲明指針的區別
    char s[] = "hello cruel world";
    printf("%p, %s\n", s, s);
    // 將 s[0] 進行修改
    s[0] = 'H';
    printf("%p, %s\n", s, s);
}

我們再來測試一下:

顯然數組聲明完之后,地址是固定的,同時內部元素是可修改的,因此我們成功將首字母 'h' 改成了 'H'。

此外 char *s 和 char s[] 還有一個顯著的區別,就是當作為返回值的時候。

#include <stdio.h>

char *test1() {
    char *s = "hello cruel world";
    return s;
}

char *test2() {
    char s[] = "hello cruel world";
    return s;
}

void main() {
    printf("%s\n", test1());
    printf("%s\n", test2());    
}

對於 test1 而言,代碼是沒有任何問題的,但是 test2 就不行了。原因就是 test2 里面的字符串是存放在棧區的,函數結束之后就被銷毀了,所以我們直接返回這樣一個數組是不行的;而 test1 里面的字符串是存放在常量區當中的,在整個可執行文件執行結束之前都可以使用,所以返回它的指針沒有任何問題。

打印結果告訴我們不應該返回局部變量的地址,那么如何解決呢?答案就是將字符數組 s 變成靜態的。

data

初始化數據區(data)負責存儲已初始化全局變量和靜態變量,它也是和可執行文件具有相同的聲明周期,在程序執行結束之前我們都可以使用它。

#include <stdio.h>

char *test1() {
    char *s = "hello cruel world";
    return s;
}

char *test2() {
    // 此時的 s 就是一個靜態數組,里面的元素會放在初始化數據區當中,而不會放在棧區
    static char s[] = "hello cruel world";
    return s;
}

void main() {
    printf("%s\n", test1());
    printf("%s\n", test2());    
}

此時再來執行看看有沒有問題:

正常輸出,沒有任何警告⚠️。除了靜態局部變量,還有靜態全局變量、全局變量,它們都是放在初始化數據區當中的。

#include <stdio.h>

static int a = 123;  // 靜態全局變量
int b = 234;         // 全局變量

int *test() {
    // 靜態局部變量
    static int c = 345;
    c++;
    // 返回靜態局部變量的地址沒有任何問題,因為函數結束之后不會被銷毀
    return &c;
}

void main() {
    printf("%d %d\n", a, b);
    
    for (int i=0; i < 5; i++) {
        int *p = test();
        printf("%p %d\n", p, *p);
    }
}

注意:靜態變量只會初始化一次,所以我們調用了 5 次 test,但是變量 c 只會初始化一次,因為它是靜態的。

那么問題來了,如果我們想要獲取一個局部變量的地址,那么除了將變量聲明為靜態變量之外,還有沒有其它的方法呢?因為靜態變量會伴隨着程序一直存在,但有時我們希望用完之后就將它回收掉,避免內存占用,這個時候我們就可以在堆區為變量申請內存。因為棧上的內存會隨着函數的調用完畢而被釋放,但堆則不會,它需要程序猿手動釋放,我們以 Go 為例:

package main

import "fmt"

func test() *int {
    var a int = 123
    return &a
}

func main() {
    var a *int = test()
    fmt.Println("*a =", *a)  // *a = 123
}

這段代碼如果放在 C 里面顯然是有問題的,因為在 test 里面返回了局部變量的地址,但在 Go 里面為什么是正常的呢?原因就是 Go 編譯器會進行逃逸分析,如果返回了變量的地址,就意味着該變量對應的值要被外界訪問,那么 Go 編譯器會在堆區為該變量分配內存。但是在 C 里面,我們需要手動實現這一點。

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

int *test() {
    // 在堆區分配 int 大小的內存,然后返回它的指針
    int *a = (int *)malloc(sizeof(int));
    *a = 123;
    return a;
}

void main() {
    int *a = test();
    printf("%d\n", *a);
    // 用完之后釋放掉,否則可能造成內存泄漏
    if (a != NULL) 
        free(a);
}

我們看到此時也是可以正常運行的,因此當返回一個局部變量的地址的時候,除了將其聲明為靜態變量之外,還可以通過 malloc 在堆區為其分配內存。只不過和棧不同,堆區的內存不會自動回收,需要我們調用 free 手動回收。但在 Go 里面我們貌似沒有進行 free 之類的操作,原因是 Go 是一門帶有 GC 的語言,它會自動進行垃圾回收,找出堆上哪些不會被使用的內存,然后將其釋放掉。因此雖然 Go 也是靜態編譯型語言,但因為帶有 GC,導致它的性能不如 C/C++/Rust 這類語言,但 Go 語法簡單、天生擅長並發編程,仍然是值得我們學習的。

這里我們就得出了一個結論:如果一個局部變量的地址要返回給外界,那么它的值要申請在堆上。

bss

最后是未初始化數據區 bss,它是負責存儲未初始化全局變量、未初始化靜態變量。

#include <stdio.h>

static int a;
static int b = 123;

void main() {
    static int c;
    static int d = 345;
    printf("%d %d %d %d\n", a, b, c, d);
}

變量 a、c 存儲在 bss 中,變量 b、d 存儲在 data 中,並且 bss 中的變量會被初始化為 0 或 nil。

觀察可執行文件,發現 data 的大小為 548、bss 的大小為 12。然后我們修改代碼,將變量 b、d 的默認值給刪掉,也就是只聲明、不賦初始值,那么此時 a、b、c、d 就都會存在 bss 中,而一個 int 占 4 字節,那么將變量 b、d 修改之后重新編譯,新生成的可執行文件的 data 的大小相比之前就會少 8 個字節,bss 的大小會多 8 個字節,我們看看是不是這樣子。

和我們分析的是一樣的。

以上就是可執行文件中,代碼區(text)、初始化數據區(data)、未初始化數據區(bss)的區別與作用,但顯然還沒有完。因為我們的重頭戲還沒有說(也不知道是誰的頭這么重哈),就是棧區和堆區。

棧區

棧是程序運行的基礎,每當一個函數被調用時,一塊連續的內存就會在棧頂被分配出來,供函數執行使用,這塊內存被稱為棧幀(stack frame),或者簡稱為幀。我們以一個簡單的函數調用為例:

#include <stdio.h>

int hello() {
    return world();
}

int world() {
    return 666;
}

void main() {
    printf("%d\n", hello());  // 666
}

這段程序非常簡單,但是這背后的函數調用棧是怎么一回事呢?我們來解釋一下。

首先棧是自頂向下增長的,也就是從棧頂到棧底的地址是逐漸增大的,一個程序的調用棧的最底部,除去入口對應的棧幀之外,就是 main 函數對應的棧幀。而隨着函數一層層調用,棧幀會一層一層地被創建,比如 main 函數里面調用了 hello 函數,那么就會在 main 函數對應的棧幀之上為 hello 函數創建棧幀(並保存當前 main 函數執行的上下文),然后將執行的控制權交給 hello 函數的棧幀;然后在 hello 函數內部又調用了 world 函數,那么就又會在 hello 函數的棧幀之上為 world 函數創建棧幀(並保存當前 hello 函數執行的上下文),並將執行的控制權交給 world 函數對應的棧幀。

而調用結束之后,棧幀又會一層層地被銷毀,並釋放對應的內存。比如 world 函數調用完畢之后,就會將 world 函數對應的棧幀銷毀,然后回到上一個調用者(hello 函數)對應的棧幀中,恢復其之前執行的上下文,並賦予執行的控制權。

所以整個過程就像遞歸一樣,一層一層創建,一層一層返回,但如果棧幀創建的過多,那么有可能會造成棧溢出。因為調用棧的大小是有閾值的,一旦當前程序的調用棧超出了系統允許的最大棧空間,就會無法創建新的幀,來運行下一個要執行的函數,就會發生棧溢出,這時程序會被系統終止,產生崩潰信息。比如遞歸函數沒有妥善終止,那么一個遞歸函數會不斷調用自己,而每次調用都會形成一個新的幀,最終導致棧溢出。

而在調用的過程中,一個新的幀會分配足夠的空間存儲寄存器的上下文。在函數里使用到的通用寄存器會在棧保存一個副本,當這個函數調用結束,通過副本,可以恢復出原本的寄存器的上下文,就像什么都沒有經歷一樣。此外,函數所需要使用到的局部變量,也都會在幀分配的時候被預留出來。

那么問題來了,當一個函數運行時,怎么確定究竟需要多大的幀呢?

這要歸功於編譯器,在編譯並優化代碼的時候,一個函數就是一個最小的編譯單元。在這個函數里,編譯器得知道要用到哪些寄存器、棧上要放哪些局部變量,而這些都要在編譯時確定。所以編譯器就需要明確每個局部變量的大小,以便於預留空間。

這里我們就又得出了一個結論:在編譯時,如果局部變量的大小不確定、或者大小可以改變,那么它的值就無法安全地放在棧上,應該要放在堆上。也就是在堆上為變量分配內存,在棧上分配對應的指針,引用堆上的內存。

數據放在棧上的問題

從上圖可以看到,棧上的內存分配是非常高效的,並且由操作系統自動維護。只需要改動棧指針(stack pointer),就可以預留相應的空間;把棧指針改動回來,預留的空間又會被釋放掉。預留和釋放只是動動寄存器,不涉及額外計算、不涉及系統調用,因而效率很高。

因此在 Go 里面,很多小伙伴都喜歡返回指針,因為 Go 是值傳遞,拷貝指針比拷貝值更有效率。但事實真的如此嗎?答案是不一定,因為如果拷貝值的話,那么復制是在棧上完成的,這個效率是很高的。而返回指針的話,那么就會發生內存逃逸,就會將變量的值從棧上分配改為堆上分配,這個過程反而會消耗更多的資源。

所以理論上說,我們應該把變量的值分配到棧上,這樣可以達到更好的運行速度。但是實際工作中,我們卻又避免這么做,這又是為什么呢?原因就是棧空間是有限的,分配過大的棧內存容易導致棧溢出。

所以我們又得到了一個結論:當變量的值占用內存過大時,那么優先在堆上分配。

因此變量的值究竟分配在棧上還是分配是在堆上,結論如下:

  • 1. 如果一個函數返回了局部變量的指針,那么要在堆上為其分配內存
  • 2. 如果在編譯時,局部變量的大小不確定、或者大小可以改變,那么它的值就無法安全地放在棧上,所以此時也要在堆上為其分配內存
  • 3. 如果變量的值過大,那么優先在堆上為其分配內存

堆區

棧雖然使用起來很高效,但它的局限也顯而易見。當我們需要動態大小的內存時,只能使用堆,比如我們要實現可變長度的數組,那么必須分配在堆上,否則無法擴容。而堆上分配內存時,一般都會預留一些空間,這是最佳實踐。

在堆上分配內存除了可以讓大小動態化,還可以讓生命周期動態話。我們說過,函數調用結束之后,那么函數對應的棧幀會被回收,同時相關變量對應的內存也會被回收。所以棧上內存的生命周期是不受開發者控制的,並且局限在當前調用棧。而堆則不同,堆上分配出來的每一塊內存需要顯式地釋放,這就使堆上內存有更加靈活的生命周期,可以在不同的調用棧之間共享數據,因為數據只要我們不回收,那么就始終就駐留在堆上,並且何時回收也是由我們來決定的。

數據放在堆上的問題

然而,堆內存的這種靈活性也給內存管理帶來很多挑戰。如果手工管理堆內存的話,堆上內存分配后忘記釋放,就會造成內存泄漏。一旦有內存泄漏,程序運行得越久,就越吃內存,最終會因為占滿內存而被操作系統終止運行。

如果堆上內存被多個線程的調用棧引用,該內存的改動要特別小心,需要加鎖以獨占訪問,來避免潛在的問題。比如一個線程在訪問某一個指針,但另一個線程將該指針指向的內存釋放了,此時就可能出現訪問懸空指針的情況,程序輕則崩潰,重則隱含安全隱患。根據微軟安全反應中心(MSRC)的研究,這是第二大內存安全問題。

除此之外還有堆越界(heap out of bounds),比如程序只在堆區申請的內存只能容納 5 個 int,但是我們嘗試操作第 6 個 int,此時就會引發堆越界,而堆越界是第一大內存安全問題。

垃圾回收機制是如何解決的

像一些語言帶有垃圾回收機制,它們是如何解決堆區內存回收的問題呢?

垃圾回收針對的都是堆內存,因為棧內存由操作系統維護,不需要我們關心。

首先無論何種垃圾回收機制,一般都分為兩個階段:垃圾檢測和垃圾回收。垃圾檢測是從所有的已經分配的內存中區別出 "可回收" 和 "不可回收" 的內存,而垃圾回收則是使操作系統重新掌握垃圾檢測階段所標識出來的 "可回收" 內存塊。所以垃圾回收,並不是說直接把這塊內存的數據清空了,而是將使用權從新交給了操作系統,不會自己霸占了。

那么,常見的垃圾回收算法都有哪些呢?

  • 引用計數法(reference count):記錄對象的被引用次數, 引用計數降為 0 時回收
  • 標記-清除法(mark-sweep):從根集合觸發, 遍歷所有能訪問到的對象並對其進行標記, 然后將未被標記的對象清除
  • 停止-復制法(stop-copy):將內存划分為大小相同的內存塊, 一塊用完后啟用另一塊、並將存活的對象拷貝過去, 原來那塊則整體被回收
  • 分代回收法(generational-collection):根據對象的存活時間將對象分為若干代, 並按照不同代的特征采用最合適的回收策略

以 Java 為首的一系列編程語言,采用了標記-清除法。這種方式通過定期標記(mark)找出不再被引用的對象,然后將其清除(sweep)掉,來自動管理內存,減輕開發者的負擔,因此該方法也被稱為追蹤式垃圾回收(Tracing GC)

而 Objective-C 和 Swift 則走了另一條路:引用計數(Automatic Reference Counting)法。在編譯時,它為每個函數插入 retain/release 語句來自動維護堆上對象的引用計數,當引用計數為零的時候,就通過 release 語句對象。

從效率上來講,標記清除在內存分配和釋放上無需額外操作,而引用計數法則添加了額外的代碼來處理引用計數,所以標記-清除法的效率更高,吞吐量(throughout)更大。但標記-清除法釋放內存的時機是不確定的,並且是定期批量操作,因此在釋放內存時會引發 STW(Stop The World),從而導致某些時刻延遲(latency)較高。我們使用 Android 手機偶爾感覺卡頓,就是這個原因,出現卡頓說明此時內部正在進行垃圾回收。

但引用計數法是當對象的引用計數為 0 時就立即回收,所以相當於將垃圾回收的開銷分攤在了整個運行時,因此使用 IOS 手機時始終會感覺很絲滑,不會出現卡頓。所以盡管標記清除法在分配和釋放內存的效率和吞吐量上比引用計數法要高,但因為偶爾的高延遲,導致被感知的性能較差。

小結

以上我們就分析了可執行文件的內存模型,以及棧和堆的特點。

對於存入棧上的值,它的大小在編譯期就需要確定。並且棧上存儲的變量的生命周期在當前調用棧的作用域內,無法跨調用棧引用。

堆可以存入大小未知或者動態伸縮的數據類型,堆上存儲的變量,其值的生命周期從分配后開始,一直到釋放時才結束,因此堆上的變量允許在多個調用棧之間引用。但也導致堆變量的管理非常復雜,手工管理會引發很多內存安全性問題,而自動管理,無論垃圾回收算法采用的是哪一種,都有性能損耗和其它問題。

一句話對比總結就是:棧上存放的數據是靜態的,固定大小,固定生命周期;堆上存放的數據是動態的,不固定大小,不固定生命周期。

小問題

1)如果有一個數據結構需要在多個線程中訪問,可以把它放在棧上嗎?為什么?

在多線程場景下,每個線程的生命周期是不固定的,無法在編譯期知道誰先結束誰后結束,所以不能把屬於某個線程 A 調用棧上的內存共享給線程 B,因為 A 可能先於 B 結束,所以這時應該使用堆內存。

但是有個例外,如果我們能保證結束的順序是確定的,那么可以共享,比如 scoped thread。

2)可以使用指針引用棧上的某個變量嗎?如果可以,在什么情況下可以這么做?

顯然是可以的,比如在函數中創建一個局部變量,然后再用一個指針指向它。

int test() {
    int a = 123;
    int *p = &a;  // 引用棧上的變量
    return 0;
}

只要指針的生命周期小於等於引用源就行,但如果指針的生命周期大於引用源,那么就不行了。比如我們上面的代碼不能將 p 返回,因此這樣的話,函數在結束后 a 會被銷毀,但 p 還在,所以會出現懸空指針的情況,因為此時指針 p 的生命周期超過了引用源 a。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM