Linux & X86上Segmentation fault原因分析
我的部落格:http://blog.striveforfreedom.net
Table of Contents
- 1 簡介
- 2 導致段錯誤的3種常見記憶體訪問方式
- 2.1 使用者模式訪問核心空間
- 2.2 訪問尚未建立的記憶體空間
- 2.3 寫訪問唯讀空間
- 3 系統對段錯誤的處理
- 3.1 CPU對段錯誤的捕獲
- 3.2 核心對段錯誤的處理
- 3.3 使用者程式對段錯誤的處理
- 4 小結
1 簡介
在Linux上寫C程式,段錯誤(Segmentation fault)很常見,估計每個程式員都曾碰到過,進程碰到段錯誤直接原因是進程收到了SIGSEGV訊號並且沒有捕獲這個訊號。這裡主要考慮常見的非法訪問記憶體導致的段錯誤,不考慮其它導致段錯誤的方式如使用者模式執行特權指令。下文先給出了3種主要非法訪問記憶體的方式,然後給出了系統對段錯誤的處理方式,希望能減少段錯誤的發生或者段錯誤發生之後能快速定位原因。本文只考慮X86平台,假定[0, 3G)為使用者虛擬空間,[3G, 4G)為核心虛擬空間1。其他平台的情況應該類似。
2 導致段錯誤的3種常見記憶體訪問方式
進程出現段錯誤通常是因為進程非法訪問記憶體。但哪種形式的記憶體訪問會導致段錯誤,一部分程式員可能不是很瞭解。下面給出了會導致段錯誤的3種記憶體訪問方式,以及相應的範例程式碼。
2.1 使用者模式訪問核心空間
3G及以上的虛擬空間屬於核心空間,使用者模式訪問就會導致段錯誤,範例程式碼如下:
int* p = (int*)(unsigned long)-1;*p = 0;
2.2 訪問尚未建立的記憶體空間
進程只能訪問進程主動申請的或者核心自動為進程分配的記憶體地區,除此之外的記憶體地區都屬於未建立的,只要訪問的虛擬位址落入這些未建立的記憶體空間便會導致段錯誤2。至於哪些記憶體地區屬於未建立的,下文會細說。
int* p = NULL;*p = 0;
訪問null 指標是這種訪問方式的特例,下面的代碼同樣屬於這種訪問方式:
int* p = (int*)100;*p = 0;
2.3 寫訪問唯讀空間
一般來說進程的代碼和唯讀資料都屬於唯讀空間,對其進行寫訪問都會導致段錯誤。寫存取碼範例程式碼如下:
char* p = (char*)&main;*p = '\0';
寫訪問唯讀資料範例程式碼如下:
const char* p = "abcd";*(char*)p = 'a';
很顯然地,程式員一般都不會故意寫出如上代碼,一般都是指標亂指造成類似上述代碼的行為,下面給出了造成段錯誤的主要原因:
- 使用未初始設定變數
- 使用已釋放的記憶體
- 越界訪問(如數組越界,緩衝區溢位等)
3 系統對段錯誤的處理
上一節說明了段錯誤發生的原因,這一節說明系統是怎樣處理段錯誤的。系統對段錯誤的處理大致可以分為三個部分:CPU對段錯誤的捕獲,核心對段錯誤的處理,和使用者程式對段錯誤的處理。
3.1 CPU對段錯誤的捕獲
非法訪問記憶體導致的段錯誤實際上是頁面異常的一種,對這種段錯誤的捕獲是包含在對頁面異常的捕獲中的。不太古老的X86 CPU都提供了段式和頁式記憶體管理,Linux結合X86的段式保護機制和頁式保護機制實現了對頁面異常的捕獲。X86的段式保護機制給CPU提供了4種運行層級(0~3),Linux核心只使用了其中的兩種運行層級,核心模式(kernel mode)對應0級,使用者模式(user mode)對應3級。CPU的當前運行層級Intel稱之為CPL(Current Privilege Level),CPL儲存在段寄存器CS中。X86的頁式保護機制提供了頁表機制,CPU藉助頁表來實現虛擬位址到物理地址的轉換和檢測頁面異常。考慮兩級頁表,頁目錄項(Page-Directory Entry)和頁表項(Page-Table Entry),如果頁目錄項或頁表項尚未建立(值為0),則對相應虛擬位址進行地址轉換時會觸發頁面異常。頁目錄項和頁表項都包含了R/W位和U/S位,如果某個虛擬位址對應的頁目錄項或頁表項的的R/W為0,表示該虛擬位址對應的物理頁面為唯讀,對其進行寫訪問會觸發頁面異常。如果某個虛擬位址對應的頁目錄項或頁表項的U/S位為0,則當CPL為3時訪問該虛擬位址會觸發頁面異常,即只允許核心訪問該頁面。大體上CPU根據CPL和當前進行轉換的虛擬位址對應的頁目錄項/頁表項的值來確定是否發生頁面異常,如果發生頁面異常,CPU會自動儲存現場,並把錯誤碼(錯誤碼中包含頁面異常發生時CPU的一些狀態資訊)壓入核心棧中,之後會跳轉到核心對頁面異常的處理常式並開始執行。
3.2 核心對段錯誤的處理
非法訪問記憶體導致的段錯誤發生後,CPU會自動跳轉到頁面例外處理常式處執行。需要注意的是,引發頁面異常的記憶體訪問並不都是非法的,合法的記憶體訪問有時也會觸發頁面異常。核心需要區分哪些記憶體訪問是合法的,哪些是非法的。常見的導致頁面異常的合法記憶體訪問有:
- 匿名記憶體還未分配物理頁面或者物理頁面已被交換到交換分區/分頁檔
- 通過mmap映射的檔案還未讀入記憶體或者已讀入記憶體但由於記憶體緊張導致頁面已被回收
- copy on write的實現也依賴於頁面異常
本文不考慮這些合法情況,不再細說。核心根據如下3個因素來判斷此次頁面異常是否非法即是否發生了段錯誤:
- 觸發頁面異常的虛擬位址,頁面異常發生時這個地址儲存在寄存器CR2裡
- 頁面異常時CPU所處的模式即處於核心模式還是使用者模式,記憶體訪問模式即讀訪問還是寫訪問,這些資訊都儲存在異常發生時的錯誤碼中
- 進程已建立的虛擬位址地區,這些地區包括使用者進程主動建立的,如通過系統調用brk和mmap建立的,還包或核心自動為進程建立的,如進程程式碼片段/資料區段/動態庫/棧。這些虛擬地區有一些屬性,如可寫/唯讀等。舉個例子,程式碼片段就是唯讀。在數量很小時這些虛擬地區以鏈表的形式組織起來,當數量變多時,核心會以二叉樹(早期是AVL樹,後改為red-black樹)來組織這些地區,以加快尋找速度。進程的使用者空間有3G,這些已建立的虛擬地區只是這3G中的一部分,進程能合法訪問的只有這些已建立的虛擬地區,3G中的剩餘部分便屬於未建立的地區,進程訪問便會引發段錯誤。
核心會依據下列條件來判斷是否發生了段錯誤:
- 頁面異常發生時CPU處於使用者模式,並且觸發頁面異常的虛擬位址大於或等於3G
- 觸發頁面異常的虛擬位址不在任何已建立的虛擬地區內
- 觸發頁面異常的虛擬位址屬於某個已建立的虛擬地區,但該虛擬地區為唯讀,並且導致頁面異常的是寫訪問
這三種情況依次對應上述的三種範例程式碼,當核心發現情況滿足以上任一條件時,就知道引發異常的是非法記憶體訪問,於是向當前進程發送SIGSEGV訊號。
3.3 使用者程式對段錯誤的處理
從核心對段錯誤的處理可以看到,核心會對發生段錯誤的進程的發一個SIGSEGV訊號,而程式一般不會捕獲這個訊號,從而會得到預設處理,結果就是進程被殺死。於是我們通常會在螢幕上看到可恨的"Segmentation fault"。然而出人意料的是SIGSEGV訊號屬於可以捕獲的訊號3(不能捕獲的訊號只有SIGKILL和SIGSTOP),再考慮到CPU對異常的處理特性(段錯誤是異常的一種)——異常處理代碼執行完畢之後CPU會重新執行導致異常的指令,我們便可以寫出如下有趣的代碼,下面的代碼沒有任何跳躍陳述式,但是卻會進入死迴圈:
#include <stdlib.h>#include <signal.h>static void foo(int sig){ (void)sig; return;}int main(int argc, char* argv[]){ struct sigaction action; action.sa_handler = foo; sigemptyset(&action.sa_mask); action.sa_flags = 0; if(sigaction(SIGSEGV, &action, NULL) == -1){ return -1; } int* p = NULL; *p = 0; return 0;}4 小結
作為小結,這裡給出一張進程虛擬空間的分布圖(大致上是這樣子的,細節上有出入):
特別指出一下,標有hole的黑色地區屬於未建立的虛擬空間,這些地區既不屬於程式主動申請的記憶體,也不屬於核心自動為進程分配的記憶體。沒有給出唯讀資料區,因為唯讀資料通常會放在程式碼片段。
Footnotes:
1 某些版本的Linux核心提供了虛擬空間劃分方式的配置選項,編譯核心前可以選擇,使用者可以選擇1G核心空間/3G使用者空間的劃分方式,也可以選擇2G核心空間/2G使用者空間的劃分方式。
2 如果訪問的虛擬位址沒有落在棧對應的虛擬地區內,但離棧頂指標(esp)很近(差值小於32位元組),並且棧大小還沒達到限制(值可以通過ulimit -s查看),進程總的虛擬記憶體大小也沒達到限制(值可以通過ulimit -v查看),則棧會自動擴充,該次訪問不會引發段錯誤。
3 很多人可能有這樣一個疑問:既然發生段錯誤的進程是不可能往下繼續推進執行了,為什麼還要讓SIGSEGV成為可以捕獲的訊號呢?讓SIGSEGV成為不可捕獲的訊號豈不是更好?我的想法是,這麼做提供了一個機會讓程式如gdb可以通過ptrace系統調用讓被調試進程收到訊號時停止運行,從而讓程式員在進程被殺死之前有機會查看進程當時的狀態。