記憶體區段錯誤

記憶體區段錯誤(英語: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 陳述式)的行為是未定義的,因此編譯器可以消除它並且使用可能導致不用堆疊的尾調最佳化。其他最佳化可能包括將遞歸轉換為迭代,鑑於範例函數的結構,程式將永遠執行下去,同時大概率不會導致堆疊溢位。

參考資料

編輯
  1. ^ Debugging Segmentation Faults and Pointer Problems - Cprogramming.com. www.cprogramming.com. [2021-02-03]. (原始內容存檔於2022-07-10). 
  2. ^ Cleanly recovering from Segfaults under Windows and Linux (32-bit, x86). [2020-08-23]. (原始內容存檔於2021-09-13). 
  3. ^ Implementation of the SIGSEGV/SIGABRT handler which prints the debug stack trace.. [2020-08-23]. (原始內容存檔於2021-09-13). 
  4. ^ How to identify read or write operations of page fault when using sigaction handler on SIGSEGV?(LINUX). [2020-08-23]. (原始內容存檔於2021-09-13). 
  5. ^ LINUX – WRITING FAULT HANDLERS. [2020-08-23]. (原始內容存檔於2021-09-13). 
  6. ^ 6.1.4 String literals. ISO/IEC 9899:1990 - Programming languages -- C. 
  7. ^ 6.4.5 String literals. ISO/IEC 9899:1999 - Programming languages -- C. 
  8. ^ 6.4.5 String literals. ISO/IEC 9899:2011 - Programming languages -- C. [2021-09-13]. (原始內容存檔於2022-04-21). 
  9. ^ What is the difference between a segmentation fault and a stack overflow?頁面存檔備份,存於互聯網檔案館) at Stack Overflow