在C和C++語言開發中,指標、記憶體一直是學習的重點。因為C語言作為一種偏底層的中低階語言,提供了大量的記憶體直接操作的方法,這一方面使程式的靈活度最大化,同時也為bug埋下很多隱患。
因此,無論如何,我們都要對記憶體有一個清晰的理解。
一、對內的分配
32位作業系統支援4GB記憶體的連續訪問,但通常把記憶體分為兩個2GB的空間,每個程式在執行時最大可以使用2GB的私有記憶體(0x00000000—0x7FFFFFFF)。即理論上支援如下的大陣列:
1 |
char szBuffer[2*1024*1024*1024]; |
當然,由於在實際執行時,程式還有程式碼段、臨時變數段、動態記憶體申請等,實際上是不可能用到上述那麼大的陣列的。
至於高階的2GB記憶體地址(0x80000000—0xFFFFFFFF),作業系統一般內部保留使用,即供作業系統核心程式碼使用。在Windows和Linux平臺上,一些動態連結庫(Windows的dll,Linux的so)以及ocx控制元件等,由於是跨程式服務的,因此一般也在高2GB記憶體空間執行。
可以看到,每個程式都能看到自己的2GB記憶體以及系統的2GB記憶體,但是不同程式之間是無法彼此看到對方的。當然,作業系統在底層做了很多工作,比如磁碟上的虛擬記憶體交換(請看下以標題),不同的記憶體塊動態對映等等。
二、虛擬記憶體
虛擬記憶體的基本思想是:用廉價但緩慢的磁碟來擴充快速卻昂貴的記憶體。在一定時刻,程式實際需要使用的虛擬記憶體區段的內容就被載入實體記憶體中。當實體記憶體中的資料有一段時間未被使用,它們就可能被轉移到硬碟中,節省下來的實體記憶體空間用於載入需要使用的其他資料。
在程式執行過程中,作業系統負責具體細節,使每個程式都以為自己擁有整個地址空間的獨家訪問權。這個幻覺是通過“虛擬記憶體”實現的。所有程式共享機器的實體記憶體,當記憶體使用完時就用磁碟儲存資料。在程式執行時,資料在磁碟和記憶體之間來回移動。記憶體管理硬體負責把虛擬地址翻譯為實體地址,並讓一個程式始終執行於系統的真正記憶體中,應用程式設計師只看到虛擬地址,並不知道自己的程式在磁碟與記憶體之間來回切換。
從潛在的可能性上說,與程式有關的所有記憶體都將被系統所使用,如果該程式可能不會馬上執行(可能它的優先順序低,也可能是它處於睡眠狀態),作業系統可以暫時取回所有分配給它的實體記憶體資源,將該程式的所有相關資訊都備份到磁碟上。
程式只能操作位於實體記憶體中的頁面。當程式引用一個不在實體記憶體中的頁面時,MMU就會產生一個頁錯誤。記憶體對此事做出響應,並判斷該引用是否有效。如果無效,核心向程式發出一個“segmentation violation(段違規)”的訊號,核心從磁碟取回該頁,換入記憶體中,一旦頁面進入記憶體,程式便被解鎖,可以重新執行——程式本身並不知道它曾經因為頁面換入事件等待了一會。
三、記憶體的使用
對於程式設計師,我們最重要的是能理解不同程式間私有記憶體空間的含義。C和C++的編譯器把私有記憶體分為3塊:基棧、浮動棧和堆。如下圖:
(1)基棧:也叫靜態儲存區,這是編譯器在編譯期間就已經固定下來必須要使用的記憶體,如程式的程式碼段、靜態變數、全域性變數、const常量等。
(2)浮動棧:很多書上稱為“棧”,就是程式開始執行,隨著函式、物件的一段執行,函式內部變數、物件的內部成員變數開始動態佔用記憶體,浮動棧一般都有生命週期,函式結束或者物件析構,其對應的浮動棧空間的就拆除了,這部分內容總是變來變去,記憶體佔用也不是固定,因此叫浮動棧。
(3)堆:C和C++語言都支援動態記憶體申請,即程式執行期可以自由申請記憶體,這部分記憶體就是在堆空間申請的。堆位於2GB的最頂端,自上向下分配,這是避免和浮動棧混到一起,不好管理。我們用到malloc和new都是從堆空間申請的記憶體,new比malloc多了物件的支援,可以自動呼叫建構函式。另外,new建立物件,其成員變數位於堆裡面。
我們來看一個例子:
1 2 3 4 5 6 7 |
const int n = 100; void Func(void) { char ch = 0; char* pBuff = (char*)malloc(10); //… } |
這個函式如果執行,其中n由於是全域性靜態變數,位於基棧,ch和pBuff這兩個函式內部變數,ch位於浮動棧,而pBuff指向的由malloc分配的記憶體區,則位於堆疊。
在記憶體理解上,最著名的例子就是執行緒啟動時的引數傳遞。
函式啟動一個執行緒,很多時候需要向執行緒傳引數,但是執行緒是非同步啟動的,即很可能啟動函式已經退出了,而執行緒函式都還沒有正式開始執行,因此,絕不能用啟動函式的內部變數給執行緒傳參。道理很簡單,函式的內部變數在浮動棧,但函式退出時,浮動棧自動拆除,記憶體空間已經被釋放了。當執行緒啟動時,按照給的引數指標去查詢變數,實際上是在讀一塊無效的記憶體區域,程式會因此而崩潰。
那怎麼辦呢?我們應該直接用malloc函式給需要傳遞的引數分配一塊記憶體區域,將指標傳入執行緒,執行緒收到後使用,最後執行緒退出時,free釋放。
我們來看例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
//這個結構體就是參數列 typedef struct _CListen_ListenAcceptTask_Param_ { Linux_Win_SOCKET m_nSocket; //其他參量… … }SCListenAcceptTaskParam; //習慣性寫法,設定結構體後,立即宣告結構體的尺寸,為後續malloc提供方便 const ULONG SCListenAcceptTaskParamSize = sizeof(SCListenAcceptTaskParam); //這裡接收到連線請求,申請引數區域,將關鍵資訊帶入引數區域,幫助後續執行緒工作。 bool CListen::ListenTaskCallback(void* pCallParam,int& nStatus) { //正常的函式邏輯… … //假定s是accept到的socket,需要傳入後續執行緒工作 //在此準備一塊引數區域,從遠堆上申請 SCListenAcceptTaskParam* pParam = (SCListenAcceptTaskParam*) malloc(SCListenAcceptTaskParamSize); //給引數區域賦值 pParam->m_nSocket = s; //此處啟動執行緒,將pParam傳遞給執行緒… … //正常的函式邏輯… … } //這是執行緒函式,負責處理上文accept到的socket bool CListen::ListenAcceptTask(void* pCallParam,int& nStatus) { //第一句話就是強制指標型別轉換,獲得外界傳入的引數區域 SCListenAcceptTaskParam* pParam= (SCListenAcceptTaskParam*)pCallParam; //正常的函式邏輯… … //退出前,必須要做的工作,確保資源不被洩露 close(pParam->m_nSocket); //關閉socket free(pCallParam); // free傳入的引數區域 //… … } |
四、記憶體bug
無規則的濫用記憶體和指標會導致大量的bug,程式設計師應該對記憶體的使用保持高度的敏感性和警惕性,謹慎地使用記憶體資源。
使用記憶體時最容易出現的bug是:
(1)壞指標值錯誤:在指標賦值之前就用它來引用記憶體,或者向庫函式傳送一個壞指標,第三種可能導致壞指標的原因是對指標進行釋放之後再訪問它的內容。可以修改free語句,在指標釋放之後再將它置為空值。
1 |
free(p); p = NULL; |
這樣,如果在指標釋放之後繼續使用該指標,至少程式能在終止之前進行資訊轉儲。
(2)改寫(overwrite)錯誤:越過陣列邊界寫入資料,在動態分配的記憶體兩端之外寫入資料,或改寫一些堆管理資料結構(在動態分配記憶體之前的區域寫入資料就很容易發生這種情況)
1 |
p = malloc(256); p[-1] = 0; p[256] = 0; |
(3)指標釋放引起的錯誤:釋放同一個記憶體塊兩次,或釋放一塊未曾使用malloc分配的記憶體,或釋放仍在使用中的記憶體,或釋放一個無效的指標。一個極為常見的與釋放記憶體有關的錯誤就是在 for(p=start;p=p->next) 這樣的迴圈中迭代一個連結串列,並在迴圈體內使用 free(p) 語句。這樣,在下一次迴圈迭代時,程式就會對已經釋放的指標進行解除引用操作,從而導致不可預料的結果。
我們可以這樣迭代:
1 2 3 4 5 6 |
struct node *p, *tart, *temp; for(p = start; p ; p = temp) { temp = p->next; free(p); } |
總結:這些知識都是本人最近看書總結出來的,可能有很多是個人主觀,歡迎拍磚…