1. 程式人生 > >淺談程式的記憶體分配

淺談程式的記憶體分配

ThdLee 關注 2017.04.04 20:14* 字數 1774 閱讀 202評論 0喜歡 0記憶體分配儘管現在的許多高階語言已經不需要程式設計師去直接處理記憶體分配和垃圾回收,但是記憶體的管理是學習程式設計過程中的一個很重要的概念,理解相關概念和應用能夠讓我們對程式設計和計算機有更深的理解。一般來講,程式由下面幾部分組成:棧:區域性變數以及每次函式呼叫時所需要儲存的資訊都存放在此區域中。每次函式呼叫時,其返回地址以及呼叫者的環境資訊都存放在棧中。然後最近被呼叫的函式在棧上為其區域性變數分配儲存空間。堆:堆用於存放程式執行中被動態分配的記憶體,大小並不固定。bss段:也叫未初始化資料段,存放程式中未初始化過的全域性變數和靜態變數,在程式開始執行之前,核心將此段的資料初始化為0。資料段:也叫初始化資料段,存放程式中已經明確地初始化的全域性變數和靜態變數。(如C程式中任何函式之外的宣告)正文段:存放程式的執行程式碼,它的大小在程式執行前就已經確定。通常,正文段是可以共享的,多個此程式執行的程序在記憶體中只需要一個副本。另外,正文段常常是隻讀的,以防止程式由於意外而修改指令。該段也可能包含一些只讀的常數,如字串常量等。對C程式中記憶體佈局的探索所有程式均在Linux CentOS上執行。資料段與bss段執行下面程式碼,檢視全域性變數的地址(註釋為對應地址):#include <stdio.h>

int a = 0; int b; int main(int argc, const char * argv[]) { printf("%p\n", &a); //0x601038 printf("%p\n", &b); //0x60103c return 0; } 可以看到,未初始化的b的地址正好在初始化過的a的地址之上,這是巧合嗎?我們再探索一下:#include <stdio.h>

int a = 3; int b; int c = 5; int d; int main(int argc, const char * argv[]) { printf("%p\n", &a); //0x601034 printf("%p\n", &b); //0x601044 printf("%p\n", &c); //0x601038 printf("%p\n", &d); //0x601040 return 0; } 初始化的a和c在低地址,而b和d在高地址,所以說這並不是偶然。另外,編譯器通常對bss段的處理方式是:只描述大小,不增加目標檔案體積。我們可以使用size命令來看一下編譯後的a.out的各段大小,作為對比,我們先對一個沒有宣告任何函式和變數的程式執行size命名:$ size a.out text data bss dec hex filename 1129 540 4 1673 689 a.out 接下來,我們宣告一個未初始化的全域性的陣列int a[65535];:#include <stdio.h> int bss[65535]; int main(int argc, const char * argv[]) { bss[0] = 1; return 0; } 執行size: text data bss dec hex filename 1145 540 262176 263861 406b5 a.out

很明顯,bss段變大了,再看一下a.out的大小:$ ls -l a.out -rwxr-xr-x. 1 thdlee thdlee 8848 5月 11 17:49 a.out 目標檔案的大小遠遠小於65535個int,這次直接將陣列賦一個初值int bss[65535] = {1};,再進行同樣的操作:$ size a.out text data bss dec hex filename 1129 262708 4 263841 406a1 a.out $ ls -l a.out -rwxr-xr-x. 1 thdlee thdlee 270680 5月 11 17:49 a.out 不出所料,data段增大了,檔案也變成了應有的大小。程式碼段程式碼段的記憶體地址可以用函式指標來檢測:#include <stdio.h> void foo() { }

int a; int main(int argc, const char * argv[]) { printf("%p\n", &a); \0x601034 printf("%p\n", &foo); \0x40052d return 0; } 函式是存放在程式碼段的,所以看到函式的記憶體地址比資料區還要低。另外,文字區一般還存放著字串常量,我們先來看看下面這個例子:#include <stdio.h>

int main(int argc, const char * argv[]) { char *a = “Hello World!”; char *b = “Hello World!”; char *c = “Hello”; printf("%p\n", a); \0x400630 printf("%p\n", b); \0x400630 printf("%p\n", c); \0x40063d return 0; } 從例子中可以看到,字串的地址在與函式地址差不多的地方,而且對於指向相同字串的指標變數,它們的地址是相同的。堆和棧一般來說,堆是由低地址向高地址增長的,而棧是由高地址向低地址增長。按照慣例,我們還是用一個小程式來探索一下:#include <stdio.h> #include <stdlib.h> int main(int argc, const char * argv[]) { int a = 1; int b = 2; char *p1 = malloc(16); char *p2 = malloc(16); printf("%p\n", &a); \0x7ffe145134dc printf("%p\n", &b); \0x7ffe145134d8 printf("%p\n", p1); \0x1478010 printf("%p\n", p2); \0x1478030 free(p1); free(p2); return 0; } 根據變數的宣告順序,可以看到棧是向下增長,而堆是向上增長的。這裡要注意的一點是,指標p1和p2儲存的是指向對的地址,但是它們兩個的儲存位置是在棧上的。我們再來看看函式呼叫中變數地址的變化。#include <stdio.h>

void foo2() { int c; printf("%p\n", &c); \0x7fffb97a99ec }

void foo1() { int b; printf("%p\n", &b); \0x7fffb97a9a0c foo2(); }

void bar() { int d; printf("%p\n", &d); \0x7fffb97a9a0c }

int main(int argc, const char * argv[]) { int a; printf("%p\n", &a); \0x7fffb97a9a3c foo1(); bar(); return 0; } 隨著函式的開始,棧也開始向下擴充套件。當函式結束時,分配在棧上的空間也跟著收回。更進一步地探討至此,我們就會對程式中記憶體分配和管理有了一定的瞭解,但是程式中的記憶體地址是怎麼來的呢?它們是計算機中的實體地址嗎?如果不是,那又和實體地址有什麼關係呢?這幾個問題牽扯到了編譯器的和作業系統的一些相關知識,但對這些內容深入地探討超出了本文的範圍,因此本文只能儘量描述清楚其中的關係。進入正題,編譯器將程式碼轉換為可執行程式時,必須為程式碼產生的各個值分別分配一個儲存位置。編譯器必須理解值的型別、長度、可見性和生命週期。編譯器必須考慮一系列對程式碼的記憶體處理問題來定義一組約定來解決這些問題。為分配儲存,編譯器必須理解全系統範圍內對記憶體分配和使用的約定。編譯器、作業系統和處理器協助,以確保多個程式能夠以交錯的方式(時間片)安全地執行。除了建立棧,對於大多數語言,編譯器都需要建立堆,以便為動態分配的資料結構提供記憶體。為保證高效地利用記憶體空間,堆和棧被置於開放空間的兩端,彼此相向增長,所以就出現了我們所觀察到的現象。當然,將堆和棧互換位置效果也是一樣的。下圖是單個程式編譯後所用地址空間的典型佈局,具體的實現和細節可能會因編譯器和語言的不同而不同:邏輯地址的空間佈局 這只是編譯器的視角所看到的地址空間,編譯器會為編譯的每個程式分配一個獨立的地址空間,讓程式以為自己擁有獨立的記憶體(其實也讓程式設計師以為程式有獨立的記憶體),其實這只是假象。在執行程式時,作業系統會將這些邏輯地址空間對映到處理器支援的實體地址空間中。地址空間的不同檢視 所以說,我們所看到的只是邏輯地址,我們在程式碼中對所謂記憶體的操作,也只是對邏輯地址的操作,這些操作的具體過程是由編譯器和作業系統以及硬體為我們完成的。