記憶體區段錯誤
記憶體區段錯誤(英語:Segmentation fault,經常被縮寫為segfault),又譯為記憶體段錯誤,也稱存取權限衝突(access violation),是一種程式錯誤。
它會出現在當程式企圖存取CPU無法定址的記憶體區段時。當錯誤發生時,硬體會通知作業系統產生了記憶體存取權限衝突的狀況。作業系統通常會產生核心轉儲(core dump)以方便程式員進行除錯。通常該錯誤是由於調用一個地址,而該地址為空(NULL)所造成的,例如鏈表中調用一個未分配地址的空鏈錶單元的元素。數組訪問越界也可能產生這個錯誤。
概述
當程序試圖訪問不允許訪問的內存位置,或試圖以不允許的方式訪問內存位置(例如,嘗試寫入只讀位置,或覆蓋操作系統的一部分)時,會產生儲存器段錯誤。
術語「分段」在計算中有多種用途;「儲存器段錯誤」是自1950年代以來就一直使用的術語。[1]當有內存保護時,只有程序自己的地址空間是可讀的,其中只有堆棧和程序數據段的可讀寫部分是可寫的,而只讀數據和代碼段是不可寫的。因此,嘗試讀取程序地址空間之外的數據或寫入至只讀內存段時,會導致儲存器段錯誤。
在使用硬件內存分段來提供虛擬內存的系統上,當硬件檢測到嘗試引用不存在的段、或引用段界限外的內存或引用無訪問權限的內存段中的數據時,會發生儲存器段錯誤。在僅使用內存分頁的系統上,無效內存頁錯誤通常會導致儲存器段錯誤,而儲存器段錯誤和內存頁錯誤都是虛擬內存管理系統引發的錯誤。儲存器段錯誤也可以獨立於內存頁錯誤發生:非法訪問有效的內存頁是會導致儲存器段錯誤,而非無效內存頁錯誤。並且段錯誤可能發生在內存頁中間(因此沒有內存頁錯誤),例如處於同一內存頁內但非法覆蓋內存的緩衝區溢出。
在硬件級別,在非法訪問時,如果引用的內存存在,錯誤最初由內存管理單元(MMU)拋出,作為其內存保護功能的一部分,或無效內存頁錯誤(如果引用的內存不存在)。如果問題不是無效的邏輯地址而是無效的物理地址,則會引發總線錯誤,儘管並不總是能夠區分這些錯誤。
在操作系統級別,這個錯誤會被捕獲,並傳遞一個信號給有問題的進程,激活該進程的信號處理程序。不同的操作系統有不同的信號名稱來表示發生了儲存器段錯誤。在類Unix操作系統上,一個被稱為SIGSEGV的信號被發送到該進程。在Microsoft Windows上,該進程會收到STATUS_ACCESS_VIOLATION異常。
錯誤原因
儲存器段錯誤產生的條件和表現方式取決於硬件和操作系統:不同的硬件會在產生不一樣的錯誤,且不同的操作系統會將這些錯誤轉換成不同的信號發送給線程。 確定儲存器段錯誤的根本原因在某些情況下十分容易(例如:訪問空指針所指向的內存空間),程序會不斷導致儲存器段錯誤。在其他的一些情況,儲存器段錯誤可能難以重現或者在意料不到的時候出現,這會讓尋找儲存器段錯誤的根本原因變得困難。
以下是一些導致儲存器段錯誤的一般原因:
- 試圖訪問不存在的內存空間(進程內存空間以外)
- 試圖訪問沒有權限的內存空間(例如:訪問操作系統內核的內存地址)
- 試圖寫入至只讀內存段(例如:代碼段)
以下是一些導致儲存器段錯誤的一般編程錯誤:
- 引用空指針
- 引用未初始化的野指針
- 引用已經被調用free()函數釋放了的懸空指針
- 緩衝區溢出
- 堆棧溢出
- 運行未正確編譯的程序(儘管存在編譯時錯誤,某些編譯器依然會輸出可執行文件)
在C代碼中,由於容易錯誤地使用指針,儲存器段錯誤最常發生。尤其是在C的動態內存分配中。 試圖訪問空指針所指向的內存區域總是會導致儲存器段錯誤。而野指針和懸空指針則有時會導致儲存器段錯誤,有時則不會。這是因為野指針和懸空指針所指向的內存可能存在也可能不存在,可能可寫入也可能不可寫入。這會導致儲存器段錯誤會出現在意料不到的時候。
char *p1 = NULL; // 空指针
char *p2; // 野指针:未被初始化的指针
char *p3 = malloc(10 * sizeof(char)); // 获取动态内存并初始化指针(假设malloc函数没有出错)
free(p3); // p3所指向的动态内存被释放掉,p3变成悬空指针
char c1 = *p1; // 试图访问空指针所指向的内存总是会导致储存器段错误
char c2 = *p2; // 试图访问野指针所指向的内存会导致随机数据
char c3 = *p3; // 试图访问悬空指针所指向的内存可能会导致随机数据
試圖訪問這些指針中的任何一個所指向的內存都可能導致分段錯誤:試圖訪問空指針通常會導致儲存器段錯誤;訪問野指針所指向的內存可能會導致隨機數據,因為指針未被初始化,指針所指向的內存地址是隨機數;而訪問懸空指針所指向的內存可能會在一定時間內訪問到有效數據,但是當該數據被覆蓋掉之後會導致隨機數據。
處理儲存器段錯誤
儲存器段錯誤或總線錯誤的默認操作是異常終止觸發該錯誤的進程。可能會生成核心文件以幫助調試,並且還可能執行依賴於其他平台的操作。例如,使用grsecurity補丁的Linux系統可能會記錄SIGSEGV信號(當發生儲存器段錯誤時,Linux系統會產生一個SIGSEGV信號,發生該錯誤的進程會捕獲到該信號並異常終止該進程或者調用該進程與該信號綁定函數),以便監視可能使用緩衝區溢出來的非法入侵。
在某些系統上,例如Linux和Windows,程序本身可以處理儲存器段錯誤。[2]根據體系結構和操作系統的不同,正在運行的程序不僅可以處理事件,還可以提取一些有關其狀態的信息,例如獲取堆棧跟蹤、處理器寄存器值、觸發時的源代碼行、無效訪問的內存地址,[3]以及該操作是讀取還是寫入。[4]
儘管儲存器段錯誤通常意味着程序存在需要修復的錯誤,但也可能出於測試、調試以及模擬需要直接訪問內存的平台的目的而故意導致此類故障。在後一種情況下,系統必須能夠允許程序在發生故障後繼續運行。在這種情況下,當系統允許時,可以處理該事件並增加處理器程序計數器以「跳過」錯誤的指令以繼續執行。[5]
例子
寫入至只讀內存段
試圖寫入至只讀內存段會引發儲存器段錯誤。在代碼錯誤的級別,當程序將數據寫入至其代碼段的或只讀數據段時,就會發生儲存器段錯誤。 以下是一個ANSI C代碼示例,此段代碼通常會在具有內存保護的平台上導致儲存器段錯誤。它試圖修改字符串文字,根據ANSI C標準,這是未定義的行為。大多數編譯器不會在編譯時捕獲它,並將其編譯成會崩潰的可執行代碼:
int main(void){
const char *s = "hello world\n";
*s = 'H';
printf("%s", s);
return 0;
}
編譯包含此代碼的程序時,字符串「hello world」被放置在程序可執行文件的rodata部分:數據段的只讀部分。加載該程序時,操作系統將它與其他字符串和常量數據一起放在內存的只讀段中。執行時,指針s被設置為指向字符串的位置,並試圖通過該指針將H字符寫入至只讀內存段,從而導致儲存器段錯誤。使用編譯時不檢查只讀位置分配的編譯器來編譯這樣的程序,並在類Unix操作系統上運行會產生以下運行時錯誤:
$ gcc segfault.c -g -o segfault
$ ./segfault
储存器段错误
GDB產生的核心文件:
Program received signal SIGSEGV, Segmentation fault.
0x1c0005c2 in main () at segfault.c:6
6 *s = 'H';
可以使用字符數組來代替字符指針以更正代碼,因為該字符串會儲存在堆棧中,而非只讀數據段中:
int main(void){
char s[] = "hello world\n";
*s = 'H';
printf("%s", s);
return 0;
}
編譯並運行以上代碼:
$ gcc no_segfault.c -g -o no_segfault
$ ./segfault
Hello world
儘管不應該修改字符串中的文字(這在C標準中具有未定義的行為),但在C中它們是static char []
類型,[6][7][8]因此原始代碼中沒有隱性轉換(指向該數組的字符指針),而在 C++ 中,它們是static const char []
類型,存在隱性轉換,因此編譯器通常會捕獲該錯誤。
試圖訪問空指針所指向的內存
在C和類C語言中,空指針用於表示「沒有對象的指針」並作為錯誤指示符,而試圖讀取或寫入空指針所指向的內存是非常常見的程序錯誤。C標準並沒有指明空指針與指向內存地址0的指針相同,儘管在實踐中可能是這種情況。大多數操作系統把空指針映射至會產生儲存器段錯誤的內存。C標準不保證此行為。試圖讀取或寫入空指針所指向的內存在C標準中是未定義的行為。
以下示例代碼創建一個空指針,然後試圖訪問其值(讀取該值)。運行這段代碼會導致多數操作系統產生儲存器段錯誤:
int *p_num = NULL;
printf("%d\n", *p_num);
以下示例代碼創建一個空指針,並試圖寫入數據。運行這段代碼會導致儲存器段錯誤:
int *p_num = NULL;
*p_num = 1;
以下的代碼包含一個空指針的解引用,但編譯時通常不會導致儲存器段錯誤,因為該值未被使用,因此該解引用通常會被當做死代碼被消除掉以優化代碼:
int *p_num = NULL;
*p_num;
緩衝區溢出
堆棧溢出
以下代碼是一個沒有出口的遞歸:
int main(void){
main();
return 0;
}
這會導致堆棧溢出,從而導致儲存器段錯誤。[9]根據編程語言、編譯器執行的優化和代碼的確切結構,無限遞歸不一定會導致堆棧溢出。在這種情況下,無法訪問代碼(return 語句)的行為是未定義的,因此編譯器可以消除它並且使用可能導致不用堆棧的尾調優化。其他優化可能包括將遞歸轉換為迭代,鑑於示例函數的結構,程序將永遠運行下去,同時大概率不會導致堆棧溢出。
參考資料
- ^ Debugging Segmentation Faults and Pointer Problems - Cprogramming.com. www.cprogramming.com. [2021-02-03]. (原始內容存檔於2022-07-10).
- ^ Cleanly recovering from Segfaults under Windows and Linux (32-bit, x86). [2020-08-23]. (原始內容存檔於2021-09-13).
- ^ Implementation of the SIGSEGV/SIGABRT handler which prints the debug stack trace.. [2020-08-23]. (原始內容存檔於2021-09-13).
- ^ How to identify read or write operations of page fault when using sigaction handler on SIGSEGV?(LINUX). [2020-08-23]. (原始內容存檔於2021-09-13).
- ^ LINUX – WRITING FAULT HANDLERS. [2020-08-23]. (原始內容存檔於2021-09-13).
- ^ 6.1.4 String literals. ISO/IEC 9899:1990 - Programming languages -- C.
- ^ 6.4.5 String literals. ISO/IEC 9899:1999 - Programming languages -- C.
- ^ 6.4.5 String literals. ISO/IEC 9899:2011 - Programming languages -- C. [2021-09-13]. (原始內容存檔於2022-04-21).
- ^ What is the difference between a segmentation fault and a stack overflow? (頁面存檔備份,存於網際網路檔案館) at Stack Overflow