記憶體區段錯誤

(重定向自段错误

記憶體區段錯誤(英語: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