C語言 | 棧區空間初探

棧的定義

棧(stack)又名堆棧,堆棧是一個特定的存儲區或寄存器,它的一端是固定的,另一端是浮動的 。對這個存儲區存入的數據,是一種特殊的數據結構。所有的數據存入或取出,只能在浮動的一端(稱棧頂)進行,嚴格按照“先進後出”的原則存取,位於其中間的元素,必須在其棧上部(後進棧者)諸元素逐個移出後才能取出。在內存儲器(隨機存儲器)中開闢一個區域作爲堆棧,叫軟件堆棧;用寄存器構成的堆棧,叫硬件堆棧。

  • 棧(操作系統):由操作系統自動分配釋放 ,存放函數的參數值,局部變量的值等。其操作方式類似於數據結構中的棧。
  • 堆(操作系統): 一般由程序員分配釋放, 若程序員不釋放,程序結束時可能由OS(操作系統)回收,分配方式倒是類似於鏈表。

本文目錄

棧的特點——先進後出

棧是一種一端受限的線性表,它只接受從同一端插入或刪除,並且嚴格遵守先進後出的準則。什麼意思呢,我們這樣來理解,棧就是一個倒置的桶,這個桶有一定的空間,我們可以用這個桶來做裝很多東西。在C語言中有着形如 int 類型佔4個字節的空間,char 類型佔1個字節空間等等的不同大小的變量類型。而我們在函數中定義一個變量 int a = 10; ,等於我們把一個名爲 a 的佔據4個字節空間的物體放入棧這個桶中,那麼我們再來定義一個變量 char c = 'x',同樣也放如棧中。那麼我們模擬出棧的佈局應是如下所示
在這裏插入圖片描述
我們可以看到,在棧中變量c在變量a的上方。因此,出棧的時候只能先出 c 變量再出 a 變量,而我們知道,a 變量是先於 c 變量入棧的,所以棧的特點爲 先進後出

好了,關於棧的數據結構部分的討論暫且先停下,下面我們要討論的是內存中的棧區,而不是我們所說的數據結構棧。

函數內部的變量在棧區申請

我們說函數、全局變量、靜態變量的虛擬地址在編譯時就可確定,而在函數中使用的變量在運行時確定,函數形參在函數調用時確定。

那麼這句話是什麼意思呢?函數的入口地址、全局變量在編譯階段就確定的地址,這是編譯鏈接的知識我們今天先不討論,今天主要討論一下棧區以及棧區的使用。

棧區空間分佈

我們所說的棧區和平時我們用的數據結構中的棧還是有一些不同的。在內存中棧又叫堆棧,它分佈在虛擬地址空間中,僅佔一小部分。一個程序運行時擁有一個自己的虛擬地址空間,在32位計算機上爲 2^32次方大小(4G)的一塊內存空間。在這塊空間中所有的地址都是邏輯地址,即虛擬的地址,在程序運行到哪一部分空間時把相應的內存頁映射到真實的物理地址上。整個虛擬地址空間分佈大致如下圖所示
在這裏插入圖片描述

在定義變量之前,我們首先要知道,函數中使用的變量在棧上申請空間,至於原因我們下次在討論。那麼對於棧這種數據結構來說,它是由高地址向低地址生長的一種結構。像我們平時在 main函數或是普通的函數中定義的變量都是由棧區來進行管理的。下面進行幾個實例以便於我們更加了解棧區的使用。

字符串在棧中申請空間的方式

編寫如下C程序:

int main()
{

	char str[] = { "hello world" };
	char str2[10];

	printf("%s \n",str);
	printf("%s\n",str2);

	return 0;
}

在 VS 2019中運行
在這裏插入圖片描述
我們在C源碼中,給 str 賦值爲“Hello World”,而 str2 沒有進行賦值。

這裏要說明一點,在函數內部會根據函數所用到的空間大小生成函數的棧幀,而後對其內存空間進行 0xcccc cccc 的初始化賦值。而'cc' 在中文編碼下就是“燙”字符。有時候我們會說申請的局部變量(函數的作用域下)沒有進行賦值其內容會是隨機值。這麼說其實也沒錯,原因很簡單,在內存中的某個內存塊上,無時無刻不伴隨着大量程序的使用,而在程序使用過後就會在該內存塊處留下一些數據,這些數據我們無法使用在我們看來就是隨機值。而在 VS 編譯器中爲了防止隨機值對程序運行結果造成干擾,就通過用初始化爲 0xcccc cccc的方式進行統一的初始化。而字符串的輸出時靠字符串末尾的 \0 結束符來確定的,str2 ,中並沒有該字符,因此在輸出時一直順着棧向高地址尋找,直到找到 str 中的 \0 結束符。
在這裏插入圖片描述

變量在棧中申請空間的方式

從上圖我們也可以看到字符串在棧中是連續申請的。此外,變量、數組等也是在棧中連續申請的,請看下面的實例:

在Linux 上編寫如下代碼:

#include <stdio.h>
void main(void)
{
		//整型變量
        int a = 10;
        int b = 10;
		//字符型變量
        char c = a;
        char ch = b;
		//數組型變量
        int arr[10] = { 1,2,3,4,5,6,7,8,9,0 };
        int d = 10;

        // 輸出源碼
        char str[] =" \
        int a = 10;\n \
        int b = 10;\n \
        \n \
        char c = a;\n \
        char ch = b;\n \
        \n \
        int arr[10] = { 1,2,3,4,5,6,7,8,9,0 };\n \
        int d = 10;\n";

        printf("%s\n",str);

        printf("&a = %x \t &b = %x\n",&a, &b);
        printf("&c = %x \t &ch = %x\n",&c, &ch);
        for(int i = 0; i < 10; ++i)
        {
                printf("&arr[%d] = %x   \t arr[%d] = %d\n", i, &arr[i], i,  arr[i]);
        }

        printf("---&arr[10]  &arr[11]  &arr[12]  &arr[13]  &arr[14]  &arr[15] \n");
        printf("---%x  %x  %x  %x  %x  %x\n", &arr[10], &arr[11], &arr[12], &arr[13], &arr[14], &arr[15]);

        exit(0);
}

在Linux 上編譯運行 gcc -std=c99 -o test test.c ./test 其中 -std=c99 表示使用C99語法規則。

在這裏插入圖片描述
我們把輸出結果複製到畫圖板上進行分析。另外,程序每次運行時,程序執行的起始地址不同,最終每次輸出的結果也不同(如畫圖板上輸出結果爲重新運行的結果),但是這並我影響我們觀察的結果。
在這裏插入圖片描述

分析:

如圖所示,在標註出的綠色部分是申請的兩個整型變量,根據棧的特性和申請的順序,變量a在靠近高地址處,變量b在靠近低地址處。a、b都是整型變量,佔4字節,所以在內存中因該是 e105a30---e105a33爲b變量佔據的內存空間,e105a34---e105a37 爲a變量佔據的內存地址。
注:在圖中僅標註出了起始地址。

對於 char 型字符變量 c 和 ch ,他們都只有一個字節的內存空間,但是由於內存對齊機制,他們兩個一共佔據了 e105a2c---e105a2f 4個字節。雖然如此,在使用時他們任然只使用一個字節。c 對應內存地址爲 e105a2f ,ch 對應內存地址爲 e105a2e

通過觀察我們發現,數組中的 arr[11]、arr[12]、arr[13]的地址與我們定義的 ch 、b、a的首地址相同,這是怎麼回事呢?

數組在棧中的排布

數組比較特殊,按照我們的理解,棧從高地址位向低地址爲生長。按理來說,arr[0] 作爲數組的首元素應該在高地址爲最先被申請,而 arr[9] 爲數組的末位元素理應在棧的低地址位被申請。然而,根據程序打印出的結果我們畫出的棧內存佈局顯示,數組在棧中是順着地址增長的方向排布的。😥其實,我們不妨想一想,在平時我們使用指針訪問數組時 int *p = &arr[0] ,通過 p++(ps: 指針➕偏移實質上是,指針指向的地址+sizeof(指針類型) × n) 就能改變指針指向到下一元素,這恰恰可以證明數組在棧中是按照地址增長的順序排布的(棧的低地址到高地址)。

關於數組在棧中申請空間的猜想:數組在棧區進行空間申請的時候,編譯器因該是把數組作爲一個整體來看待的,編譯器向棧區申請 sizeof(arr) 個字節的空間,然後從該空間的起始位置開始賦值,arr[0]、arr[1]、……一直到數組的最後一個元素。

該數組從 e105a00 處到 e105a27 處,一共佔據 4×10 = 40個字節(0x28),其中每一個元素佔據四個字節,但是我們發現,在 e105a28---e105a2b 處有一個多出的內存塊是我們沒有申請的,也就是我們沒有在代碼中列出的變量。我們在代碼中申請完 ch 字符變量後緊接着申請了數組,那麼說是不是兩個字符數組在字節對齊的時候進行了8字節對齊呢?我們口說無憑,編寫一段代碼來測試下吧。

探究數組在棧中的佈局

編寫如下程序:
先申請一個整型變量,緊接着申請一個字符變量和一個整型變量觀察他們的地址變化;
再申請一個整型變量,緊接着申請兩個字符變量和一個整型變量觀察他們的地址變化;

#include <stdio.h>

int main()
{
        int a;
        char c;
        int b;
        printf("&a=%x  &c=%x  &b=%x \n",&a, &c, &b);

        int aa;
        char cc1;
        char cc2;
        int bb;
        printf("&aa=%x  &cc1=%x  &cc2=%x  &b=%x \n",&aa, &cc1, &cc2, &bb);

        return 0;
}

在Linux 上編譯運行,輸出如下
在這裏插入圖片描述
地址分析:

  • &a=dcb6aacc &c=dcb6aacb &b=dcb6aac4
    a的首地址爲dcb6aacc ,佔據內存空間爲:dcb6aacc---dcb6aadf
    a的首地址爲dcb6aacb ,佔據內存空間爲:dcb6aac8---dcb6aacb   4字節對齊
    a的首地址爲dcb6aac4 ,佔據內存空間爲:dcb6aac4---dcb6aac7
  • &aa=dcb6aac0 &cc1=dcb6aabf &cc2=dcb6aabe &b=dcb6aab8
    aa 的首地址爲dcb6aac0 ,佔據內存空間爲:dcb6aac0---dcb6aac3
    cc1的首地址爲dcb6aabf ,cc2的首地址爲dcb6aabe
                 佔據內存空間爲:dcb6aabc---dcb6aabf   4字節對齊
    bb 的首地址爲dcb6aab8 ,佔據內存空間爲:dcb6aab8---dcb6aabb

從上面的結論我們已經看到,不論是一個 char、還是兩個char,都是不滿四字節向四字節內存對齊。(向後對齊原則,爲了保證在該char 類型之後的申請的變量能夠是2的整數倍地址,char變量內存對齊時與緊隨其後的變量類型相關。該例中,如果bb的類型爲double,那麼cc1與cc2將向8字節內存對齊)。

關於棧中申請的數組末尾多出一塊地址空間被佔用的問題分析

可以看到不是因爲 c 與 ch 內存對齊導致e105a28---e105a2b 內存塊被佔用,那麼會不會是編譯器自己生成的呢。因爲處理數組類問題時常常會遇到越界訪問的問題,通常這類問題會出現在使用 for( ; ;)語句或是while() 語句中循環條件處理不當會造成,數組越界訪問從而修改了其他數據的值。那麼會不會是編譯器或者數組本身申請的時候就加入了這種機制呢?

我們使用 sizeof 運算符時,得到的是整個數組的空間大小,與我們定義時完全一致。那麼我暫且猜想,是編譯器的一種安全機制,避免我們因操作不當而不小心修改了其他變量的值進行的一種維護手段。
注:此處爲推論,暫未查得資料證實。歡迎評論補充,一起探討學習。

一個小測試
在Linux 下編寫如下程序進行測試

#include <stdio.h>

int main()
{
        int i;
        printf(" &i = %x\n",&i);
        int arr[10];
        for(i=0;i<=10;i++)	// 越界了
        {
                arr[i] = 0;
                printf(" %d  %x\n",i,&arr[i]);
        }
        return 0;
}

可以看到如上程序在 i <= 10 處越界了。那麼我們運行程序觀察結果
在這裏插入圖片描述
我們發現即使程序越界了,arr[10] 已經超出了數組的範圍,i 從0輸出到 10。並且我們觀察到 arr[10]的地址爲203231c8 與 i 的地址 203231cc 相鄰在一起。換句話說,如果我們繼續向下修改內存中的值的話,很可能就會改變 i 的值,而 i 是我們的循環條件一旦改變將會產生不可預料的後果。那麼,我們試着把 for(i=0;i<=10;i++) 的條件改變成 for(i=0;i<=11;i++) 試一試會發生什麼效果。

修改循環條件,編譯運行
在這裏插入圖片描述
程序變成了死循環,???,怎麼成死循環了?

別急,讓我們一步一步來分析:
在這裏插入圖片描述
每次循環的內部,都會做一件事 arr[i] = 0,相對於前十次在數組內算是正常操作,而第十一次開始就已經屬於越界操作了,但相對來說還是安全的,因爲該處沒有變量使用,而相對第十二次操作來說,直接修改了變量 i 的值,並且通過循環 i 的值會從 0 增加到 10,這就形成了一個死循環。

可能有人會問了(怎麼又有人問了?誰問了?誰?誰?😜站出來),你一會用Windows 的VS 編譯器,一會兒有轉戰 Linux 的gcc,你到底幾個意思啊。

在這裏不得不說一下VS是真的強大👍,在VS中編譯的程序都進行了優化,有些變量的地址連它自己都不知道在哪兒(玩笑話😋),並且VS對一些錯誤的包容性很好,甚至是幫你處理掉了,所以在VS下進行試驗可能會誤導我們的判斷。並且就拿上述代碼來說在VS下運行,編譯器直接就報錯了,哪裏會讓你有機會死循環。所以說VS是真的香,搞得我現在一旦離開VS就不會編寫程序了,最後BUG一堆不說還半天找不到問題原因😭。看來我還是太年輕、太依賴編譯器了。

好了,讓我們來看一下VS下的執行結果吧。
在這裏插入圖片描述
這裏使用的 vs 2019,直接把錯誤彈出了。有編譯器就是方便啊,雖然說平時使用經常報錯感覺不太友好的亞子,但是這也說明了我們的編程基本功不到家啊。嗯嗯,要不以後試試手寫代碼😝。

棧區空間大小

好了,題外話不在多說了。現在終於搞懂變量在棧中是怎麼存儲的了,那麼對於內存中的棧區而言,他的大小有多大呢?這個問題問的好(誰問了?誰…🔪🔪🔪)。

在Linux下可以通過 ulimit -s 查看棧的大小,可以看到默認有8m大小。
在這裏插入圖片描述
這個棧區大小也是可以通過實際代碼測試出來的,通過遞歸函數的特點我們可以設計一種測試棧容量的程序。

#include <stdio.h>

void GetMem()
{
	char mem[1024 * 1024];		// 1M
	printf("1M  %x \n", &mem[0]);
	GetMem();
}

int main()
{
	GetMem();
	
	return 0;
}

測試結構如下:
在這裏插入圖片描述
我們可以看到在程序第七次申請空間結束後,第八次申請空間時發生了棧溢出。每次申請的空間大小爲 1024×1024個字節大小,也就是 1M的空間大小。此外,調用函數本身也會有一定的函數棧幀開銷。例如,在使用棧進行求斐波那契數列時,超過一定的範圍就會致使棧溢出。因此,在平時的編程中,一定要慎用遞歸函數。

總結:

本次對內存棧區空間初探,主要通過實驗的方式編寫了一部分簡單代碼。通過對變量、字符串、數組、以及字符變量在棧區的內存地址分析,以及通過遞歸函數測試棧的容量等簡單操作,對內存中的堆棧進行了一個初步的認識,主要總結爲以下幾點:

  • 棧區的申請的變量均爲連續空間
  • 局部變量在棧中申請空間
  • 字符串在棧中輸出時如果沒有結束字符會一直輸出
    C語言中字符串主要爲字符數組
  • 數組在棧區申請的空間,在最後一個元素的末尾空出一個內存單元
  • 棧區的空間大小約爲 8M,據說可以修改(我還不會😮)
  • 使用遞歸函數會增加棧的開銷

在探索棧區空間的過程中,涉及到了有關函數的棧幀的概念、函數棧幀的開闢、實參入棧、以及棧幀的回退等編譯鏈接方面的問題。此外,還有關於計算機的虛擬地址空間分佈、內存映射、以及內存分頁機制等計算機基礎方面的知識。

最後:推薦閱讀《程序員的自我修養:鏈接、裝載與庫》。歡迎大家一起評論討論互相學習。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章