堆棧是編程中很重要的概念,相信很多人也跳過坑,然后解決之后,繼續跳坑。想整理堆棧的概念很久了。最近看了程序員自我修養,就一起整理一下吧。
本文將從幾個方面學習一下堆棧
1. 堆棧概念
2. 進程,線程概念
3. 堆棧分配
1. 堆棧概念
在32位系統,內存的尋址可以達到4G。 理論上,用戶可以使用一個32位的指針訪問任意內存地址。
int a = 3; int * p = &a; std::cout << *p << endl;
然而,事實上並非如此,在編程中我們卻常常會遇到segment fault錯誤,指示我們非法內存訪問。證明,有些位置我們是無權訪問的,或者說暫時無權訪問,只有申請了該內存才能訪問。
其實,大多數操作系統會把4G的內存空間進行划分,比如linux通常會默認把高地址的1G空間分配給內核(內核空間給內核線程執行特權操作,比如維護打開文件表等),剩余的作為內存空間。內存空間又大致分為堆空間,棧空間,代碼區等。
*棧: 用戶維護函數調用上下文。由高地址向低地址生長,通常以M為單位,由操作系統維護。
[不能申請占用過大的內存的局部變量,會導致棧爆掉而core.如果變量太大,可以考慮放到全局變量區或者使用堆]
*堆: 動態申請內存,即使用new or malloc等分配到的內存,可以比棧大很多,需用戶自己釋放
[new/delete,malloc/free成對出現,否則會導致內存泄露,可以使用查找內存泄露的工具監控或者自己寫代碼監控]
*代碼區:存放代碼的內存映像
*保留區:禁止訪問的一些區域,比如NULL
[使用*訪問置為NULL的指針會出錯,也需要小心野指針]
內核態和用戶態的區別?什么叫做系統調用時會陷入內核
內核線程和用戶線程是存在一定的對應關系,這個由線程庫來實現。內核並不感知用戶級別的線程。用戶需要做一些操作,比如訪問文件,開辟共享內存等特權操作但是用戶又沒有權限時,只能通過系統調用,讓操作系統的內核線程來做。稱為陷入內核,此時,內核會通過調用相應的中斷處理程序來執行特權操作。但是用戶往往只能拿到資源的代號而無法拿到資源的實際指針,比如文件描述符。其實是內核系統給進程維護的打開文件表數組下標,而實際的文件指針是存儲在下標中的元素。所以,用戶拿到了描述符還是只能通過系統調用來進行操作,而無法直接拿到指針進行訪問,這樣,就繞過了操作系統。這是不被允許的。
2. 進程,線程概念
實際上,linux將所有運行的實體(進程,線程)稱為任務(task),每一個task概念上類似於一個單線程的進程,具有內存空間,執行實體,文件資源等。不過。linux下不同的任務之間可以選擇共享內存空間,因而實際上可以定義,共享了一個內存空間的不同任務構成了一個進程,而這些任務則稱為進程里的線程。
接口:fork(寫時復制),exec(替換程序),clone
從數據角度來看,線程數據和進程數據可以分為:
| 線程私有 | 現場共享(進程所有) |
| 局部變量(棧,寄存器) 函數參數 TLS數據 |
全局變量 動態申請的數據(堆) 函數里的靜態變量 代碼區 打開文件列表 |
3. 堆棧分配
3.1. 堆
用於動態分配內存,c語言中使用malloc/free進行申請和釋放
int main(){
char *p = (char *)malloc(1000);
//do_something(p);
free(p);return 0;
}
那么malloc是怎么實現的呢?對於申請內存這種特權操作,肯定是操作系統內核來做比較合適,但是頻繁的進行系統調用,在用戶態和內核態之間切換,也就是頻繁的調用中斷處理程序,性能是較差的。所以,事實上都是通過c語言的運行庫封裝好的庫函數,預申請一塊設當的內存,然后零售給程序用,可以采用空閑鏈表或者位圖等來管理。這取決於運行庫的實現了。
比如glibc運行庫的malloc函數是這樣處理的:對於小於128KB的請求,他會在現有的堆里面,按照分配算法給他分配一塊空間並返回,對於大於128KB的請求,則使用mmap()為它分配一塊匿名空間(mmap可以映射到某個文件,而把使用mmap申請卻不映射到文件的空間稱為匿名空間),然后在這塊空間里面分配給用戶內存。
3.2. 棧
棧在程序運行中的作用不言而喻,非常重要。它保存了一個 函數調用所需要維護的信息,稱為活動記錄,包括
1. 函數的返回地址,參數
2. 臨時變量
3. 上下文:寄存器
在i386中,一個活動記錄用ebp和esp兩個寄存器來划定范圍,esp始終指向棧頂部,任何指令的執行都會改變esp的值,push stack的時候esp減小,pop stack的時候esp增大。而ebp則指向了一個固定位置,表示函數調用的開始。
當調用函數時,
a. 把參數按照從右至左的順序壓棧
b. 當前指令的下一條指令入棧(return address)
c. 開始執行函數體(先保存old ebp)
當返回函數時:把old ebp讀回寄存器,然后從return address開始執行
那么,函數的返回值是如何傳遞的呢?
答案:通過eax寄存器。函數將返回值存儲在eax中,然后調用方讀取eax。但是,eax本身只有4個字節,大於4個字節的返回值,則是通過eax存儲了一個指針,而實際內容在棧上的其他地方。
當返回值是大於4個字節的的數據結構時,在壓棧入參數的時候,大概會做一個這樣的事情。
假設函數調用如下
some_struct foo(int arg1, int arg2);
some_struct s = foo(1, 2);
編譯器會把把它處理成類似這樣的概念
some_struct* foo(some_struct* ret_val, int arg1, int arg2); some_struct s; // constructor isn't called foo(&s, 1, 2); // constructor will be called in foo
也就是給它安排一個隱藏的參數,大概流程如下
a. 首先在棧上額外開辟一片空間,並把這塊空間的一部分作為傳遞返回值的臨時對象
b. 把這個對象的地址作為隱藏參數傳遞給函數
c. 函數把數據拷貝給臨時對象,並把地址用eax傳出
d. 調用方再把臨時對象拷貝給返回值。

