Linux & X86上Segmentation fault原因分析

Table of Contents

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;

扫描二维码关注公众号,回复: 5810714 查看本文章

访问空指针是这种访问方式的特例,下面的代码同样属于这种访问方式:

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系统调用让被调试进程收到信号时停止运行,从而让程序员在进程被杀死之前有机会查看进程当时的状态。

猜你喜欢

转载自blog.csdn.net/yangrendong/article/details/89089338