深入理解C語言的指針

起源

之前在知乎上看瞭一句話,指針是C的精髓,也是初學者的一個坎。換句話說,內存管理是C的精髓,C/C++可以直接跟OS打交道,從性能角度出發,開發者可以根據自己的實際使用場景靈活進行內存分配和釋放。雖然在C++中自C++11引入瞭smart pointer,雖然很大程度上能夠避免使用裸指針,但仍然不能完全避免,最重要的一個原因是你不能保證組內其他人不適用指針,更不能保證合作部門不使用指針。

那麼為什麼C/C++中會存在指針呢?

這就得從進程的內存佈局說起。

進程內存佈局

上圖為32位進程的內存佈局,從上圖中主要包含以下幾個塊:

  • 內核空間:供內核使用,存放的是內核代碼和數據
  • stack:這就是我們經常所說的棧,用來存儲自動變量(automatic variable)
  • mmap:也成為內存映射,用來在進程虛擬內存地址空間中分配地址空間,創建和物理內存的映射關系
  • heap:就是我們常說的堆,動態內存的分配都是在堆上
  • bss:包含所有未初始化的全局和靜態變量,此段中的所有變量都由0或者空指針初始化,程序加載器在加載程序時為BSS段分配內存
  • ds:初始化的數據塊
    • 包含顯式初始化的全局變量和靜態變量
    • 此段的大小由程序源代碼中值的大小決定,在運行時不會更改
    • 它具有讀寫權限,因此可以在運行時更改此段的變量值
    • 該段可進一步分為初始化隻讀區和初始化讀寫區
  • text:也稱為文本段
    • 該段包含已編譯程序的二進制文件。
    • 該段是一個隻讀段,用於防止程序被意外修改
    • 該段是可共享的,因此對於文本編輯器等頻繁執行的程序,內存中隻需要一個副本

由於本文主要講內存分配相關,所以下面的內容僅涉及到棧(stack)和堆(heap)。

棧一塊連續的內存塊,棧上的內存分配就是在這一塊連續內存塊上進行操作的。編譯器在編譯的時候,就已經知道要分配的內存大小,當調用函數時候,其內部的遍歷都會在棧上分配內存;當結束函數調用時候,內部變量就會被釋放,進而將內存歸還給棧。

class Object {
  public:
    Object() = default;
    // ....
};
void fun() {
  Object obj;
  // do sth
}

在上述代碼中,obj就是在棧上進行分配,當出瞭fun作用域的時候,會自動調用Object的析構函數對其進行釋放。

前面有提到,局部變量會在作用域(如函數作用域、塊作用域等)結束後析構、釋放內存。因為分配和釋放的次序是剛好完全相反的,所以可用到堆棧先進後出(first-in-last-out, FILO)的特性,而 C++ 語言的實現一般也會使用到調用堆棧(call stack)來分配局部變量(但非標準的要求)。

因為棧上內存分配和釋放,是一個進棧和出棧的過程(對於編譯器隻是一個移動指針的過程),所以相比於堆上的內存分配,棧要快的多。

雖然棧的訪問速度要快於堆,每個線程都有一個自己的棧,棧上的對象是不能跨線程訪問的,這就決定瞭棧空間大小是有限制的,如果棧空間過大,那麼在大型程序中幾十乃至上百個線程,光棧空間就消耗瞭RAM,這就導致heap的可用空間變小,影響程序正常運行。

設置

在Linux系統上,可用通過如下命令來查看棧大小:

ulimit -s
10240

在筆者的機器上,執行上述命令輸出結果是10240(KB)即10m,可以通過shell命令修改棧大小。

ulimit -s 102400

通過如上命令,可以將棧空間臨時修改為100m,可以通過下面的命令:

/etc/security/limits.conf

分配方式

靜態分配

靜態分配由編譯器完成,假如局部變量以及函數參數等,都在編譯期就分配好瞭。

void fun() {
  int a[10];
}

上述代碼中,a占10 * sizeof(int)個字節,在編譯的時候直接計算好瞭,運行的時候,直接進棧出棧。

動態分配

可能很多人認為隻有堆上才會存在動態分配,在棧上隻可能是靜態分配。其實,這個觀點是錯的,棧上也支持動態分配,該動態分配由alloca()函數進行分配。棧的動態分配和堆是不同的,通過alloca()函數分配的內存由編譯器進行釋放,無序手動操作。

特點

  • 分配速度快:分配大小由編譯器在編譯器完成
  • 不會產生內存碎片:棧內存分配是連續的,以FIFO的方式進棧和出棧
  • 大小受限:棧的大小依賴於操作系統
  • 訪問受限:隻能在當前函數或者作用域內進行訪問

堆(heap)是一種內存管理方式。內存管理對操作系統來說是一件非常復雜的事情,因為首先內存容量很大,其次就是內存需求在時間和大小塊上沒有規律(操作系統上運行著幾十甚至幾百個進程,這些進程可能隨時都會申請或者是釋放內存,並且申請和釋放的內存塊大小是隨意的)。

堆這種內存管理方式的特點就是自由(隨時申請、隨時釋放、大小塊隨意)。堆內存是操作系統劃歸給堆管理器(操作系統中的一段代碼,屬於操作系統的內存管理單元)來管理的,堆管理器提供瞭對應的接口_sbrk、mmap_等,隻是該接口往往由運行時庫進行調用,即也可以說由運行時庫進行堆內存管理,運行時庫提供瞭malloc/free函數由開發人員調用,進而使用堆內存。

分配方式

正如我們所理解的那樣,由於是在運行期進行內存分配,分配的大小也在運行期才會知道,所以堆隻支持動態分配,內存申請和釋放的行為由開發者自行操作,這就很容易造成我們說的內存泄漏。

特點

  • 變量可以在進程范圍內訪問,即進程內的所有線程都可以訪問該變量
  • 沒有內存大小限制,這個其實是相對的,隻是相對於棧大小來說沒有限制,其實最終還是受限於RAM
  • 相對棧來說訪問比較慢
  • 內存碎片
  • 由開發者管理內存,即內存的申請和釋放都由開發人員來操作

堆與棧區別

理解堆和棧的區別,對我們開發過程中會非常有用,結合上面的內容,總結下二者的區別。

對於棧來講,是由編譯器自動管理,無需我們手工控制;對於堆來說,釋放工作由程序員控制,容易產生memory leak

  • 空間大小不同
    • 一般來講在 32 位系統下,堆內存可以達到4G的空間,從這個角度來看堆內存幾乎是沒有什麼限制的。
    • 對於棧來講,一般都是有一定的空間大小的,一般依賴於操作系統(也可以人工設置)
  • 能否產生碎片不同
    • 對於堆來講,頻繁的內存分配和釋放勢必會造成內存空間的不連續,從而造成大量的碎片,使程序效率降低。
    • 對於棧來講,內存都是連續的,申請和釋放都是指令移動,類似於數據結構中的進棧和出棧 
  • 增長方向不同
    • 對於堆來講,生長方向是向上的,也就是向著內存地址增加的方向
    • 對於棧來講,它的生長方向是向下的,是向著內存地址減小的方向增長
  • 分配方式不同
    • 堆都是動態分配的,比如我們常見的malloc/new;而棧則有靜態分配和動態分配兩種。
    • 靜態分配是編譯器完成的,比如局部變量的分配,而棧的動態分配則通過alloca()函數完成
    • 二者動態分配是不同的,棧的動態分配的內存由編譯器進行釋放,而堆上的動態分配的內存則必須由開發人自行釋放
  • 分配效率不同
    • 棧有操作系統分配專門的寄存器存放棧的地址,壓棧出棧都有專門的指令執行,這就決定瞭棧的效率比較高
    • 堆內存的申請和釋放專門有運行時庫提供的函數,裡面涉及復雜的邏輯,申請和釋放效率低於棧

截止到這裡,棧和堆的基本特性以及各自的優缺點、使用場景已經分析完成,在這裡給開發者一個建議,能使用棧的時候,就盡量使用棧,一方面是因為效率高於堆,另一方面內存的申請和釋放由編譯器完成,這樣就避免瞭很多問題。

擴展

終於到瞭這一小節,其實,上面講的那麼多,都是為這一小節做鋪墊。

在前面的內容中,我們對比瞭棧和堆,雖然棧效率比較高,且不存在內存泄漏、內存碎片等,但是由於其本身的局限性(不能多線程、大小受限),所以在很多時候,還是需要在堆上進行內存。

我們先看一段代碼:

#include <stdio.h>
#include <stdlib.h>
int main() {
  int a;
  int *p;
  p = (int *)malloc(sizeof(int));
  free(p);
  return 0;
}

上述代碼很簡單,有兩個變量a和p,類型分別為int和int *,其中,a和p存儲在棧上,p的值為在堆上的某塊地址(在上述代碼中,p的值為0x1c66010),上述代碼佈局如下圖所示:

總結

本篇文章就到這裡瞭,希望能夠給你帶來幫助,也希望您能夠多多關註WalkonNet的更多內容!

推薦閱讀: