理解 Segmentation fault

开发的时候碰到如下的错误(PHP-FPM+apache),所以想好好理解下Segmentation fault.

参考了文章 Segmentation fault到底是何方妖孽

维基百科的解释如下:

存储器区块错误英语:Segmentation fault,经常被缩写为segfault),又译为存储器段错误,也称访问权限冲突(access violation),是一种程序错误。

它会出现在当程序企图访问CPU无法定址的存储器区块时。当错误发生时,硬件会通知操作系统产生了存储器访问权限冲突的状况。操作系统通常会产生核心转储(core dump)以方便程序员进行调试。通常该错误是由于调用一个地址,而该地址为空(NULL)所造成的,例如链表中调用一个未分配地址的空链表单元的元素。数组访问越界也可能产生这个错误。

 

网友总结的Linux开发中可能遇到Segmentation fault的情况:

 

1 使用非法的内存地址(指针),包括使用未经初始化及已经释放的指针、不存在的地址、受系统保护的地址,只读的地址等,这一类也是最常见和最好解决的段错误问题,使用GDB print一下即可知道原因。

2 内存读/写越界。包括数组访问越界,或在使用一些写内存的函数时,长度指定不正确或者这些函数本身不能指定长度,典型的函数有strcpy(strncpy),sprintf(snprint)等等。

3 对于C++对象,应该通过相应类的接口来去内存进行操作,禁止通过其返回的指针对内存进行写操作,典型的如string类的c_str()接口,如果你强制往其返回的指针进行写操作肯定会段错误的,因为其返回的地址是只读的。

4 函数不要返回其中局部对象的引用或地址,当函数返回时,函数栈弹出,局部对象的地址将失效,改写或读这些地址都会造成未知的后果。

5 避免在栈中定义过大的数组,否则可能导致进程的栈空间不足,此时也会出现段错误,同样的,在创建进程/线程时如果不知道此线程/进程最大需要多少栈空间时最好不要在代码中指定栈大小,应该使用系统默认的,这样问题比较好查,ulimit一下即可知道。这类问题也是为什么我的程序在其他平台跑得好好的,为什么一移植到这个平台就段错误了。

6 操作系统的相关限制,如:进程可以分配的最大内存,进程可以打开的最大文件描述符个数等,在Linux下这些需要通过ulimit、setrlimit、sysctl等来解除相关的限制,这类段错误问题在系统移植中也经常发现,以前我们移植Linux的程序到VxWorks下时经常遇到(VxWorks要改内核配置来解决)。

7 多线程的程序,涉及到多个线程同时操作一块内存时必须进行互斥,否则内存中的内容将不可预料。

8 在多线程环境下使用非线程安全的函数调用,例如 strerror 函数等。

9 在有信号的环境中,使用不可重入函数调用,而这些函数内部会读或写某片内存区,当信号中断时,内存写操作将被打断,而下次进入时将无法避免地出错。

10 跨进程传递某个地址,传递的都是经过映射的虚拟地址,对另外一个进程是不通用的。

11 某些有特殊要求的系统调用,例如epool_wait,正常情况下使用close关闭一个套接字后,epool会不再返回这个socket上的事件,但是如果你使用dup或dup2操作,将导致epool无法进行移除操作,此时再进行读写操作肯定是段错误的。

举例说明:

  众所周知Linux中可执行文件的格式是ELF,其实编译过程中的中间文件*.o文件、动态共享库*.so文件也是ELF格式的。在链接器看来,当它通过*.o或者配合*.so文件来生成可执行文件时,它对ELF格式的文件以链接视图(Linking View)进行看待。也就是说链接器以Section的形式来对待和处理ELF文件,诸如我们常见说的代码段(.text)、数据段(.data和.bss)等待概念。当程序最终需要被装载成进程时,装载器就出场了,装载器将可执行文件以装载视图(Executive View)进行看待。装载器将以Segment的形式来处理ELF文件。网上很多教程也是这样说的,大家可能还是理解的不是很明白,后面我们通过实例的方式将进一步向大家来澄清这两者的区别。

 readelf –h命令能够可以查看一个EFL文件的头部信息。因为viewobj.o是编译时的中间临时文件,所以它的“Start of pgrogram headers”和“Number of program headers”都为0,说明他不是一个可执行文件。取而代之的是它有9个section,所以它有“Start of section headers”和“Number of section headers”都有数据。

再看一下动态共享库:(这块不怎么理解 容后。。)
 

在Linux下动态共享库被当作可执行文件来处理,虽然它不能单独执行,但某些应用程序的运行离不了它。
   最后是可执行文件,这个就不用多说了,看图:

 

 

    
   所以,我们可以得到这样一个结论:一个具体的ELF文件,其文件头部中的某些属性值,指明了它到底是可执行文件还是可重定位文件(*o和*.so的统称)。这样,链接器和装载器通过分析ELF文件头部就可以知道它该怎么处理该文件了。用比较直观的、方便理解的图来表示它们的区别就是:

 

 

    
   也就是说链接的时候Program Header Table是可选的,但Section Header Table是必须有的。例如*.o就没有Program Header Table,而*.so就有。装载的时候Program Header Table必须有,但Section Header Table是可选的,但即使有Section Header Table,装载器也不会鸟它。

   那么,装载器为什么要采取和链接器不同的处理策略呢?最主要的原因是为了提高内存的利用率。现代操作系统在装载程序时都充分利用程序的局部性原理,那就是,当进程运行时,并不需要一下子将程序的所有代码和数据都装载到内存里,而是先装载程序的一部分到内存里运行。当进程将要执行的指令不在内存里的话,CPU便会触发一个缺页异常,操作系统捕获到这样的异常后便接管进程,然后将需要的指令“弄”到内存里,再将执行权限还给进程。

   进程运行的时候,它虚拟地址空间的布局和它所占用的物理内存到底是什么样子呢?虚拟地址空间我们还比较好理解,可实际物理地址并不是我们能直接访问到的。一般是通过一个集成在CPU内部的叫做MMU的内存管理单元完成了从进程虚拟地址到物理地址之间的映射。对这个映射过程感兴趣的童鞋可以去拜读Bean_lee兄的“Linux 从虚拟地址到物理地址”文章,那是相当之精彩。如果看不懂,就随时咨询他老人家。不过据我所知,他最近有点忙,忙得不亦乐乎,呵呵。OK,回到我们的话题上来。既然进程虚拟地址空间的任何地址,在使用前都必须通过MMU将其映射到物理内存上一个实实在在的存储单元上。那么对于任何没有经过MMU映射过的虚拟空间的地址,不管进程是执行写操作还是读操作,操作系统都会捕捉到这个错误的非法访问,然后输出一个“Segmetation Fault”的错误提示信息并强行终止进程。

   换句话说,一个进程虚拟空间里的任何地址,在进程访问它之前必须要经过MMU转换,将它映射到物理内存的某个具体的存储位置上才是合法有效的,不然操作系统就会用“Segmetation Fault”对你的进程进行宣判,然后将其kill掉。那么,问题又来了,到底哪些地址才是合法有效的呢?看一个简单的进程虚拟地址空间的布局:

 

 

    
   上图是很多资料上说的Linux进程虚拟地址空间的布局结构图,其中0x0804800为进程运行时的地址入口。注意,这里的入口地址是指你的程序的第一条指令的入口地址,但是当进程运行时,进程环境空间的初始化工作,包括建立程序虚拟地址空间和物理内存的映射、加载动态库等等操作都已经完成了。当所有准备工作就绪之后才会跳到这个地址执行我们程序里的第一条指令。这个0x0804800一般由链接器在生成可执行文件时就已经固定了,通常无需我们来更改。如果你对链接的过程和原理了如指掌,那么你肯定也知道如何修改它了。上图中,当用户的程序直接访问0x084800以前的地址、0xC0000000以后的地址或者free空间里的地址都会触发“Segmetation Fault”。原因如下:

1、0x084800以前的地址、0xC0000000以后的地址:由于权限的问题,不允许进程直接访问,操作系统对其进行保护。所以用户进程如何访问它们的话就会触发“Segmetation Fault”的错误。前面几篇博文有如何访问0xC0000000以后地址的博文,也就是用户空间和内核空间的通信问题。

2、free地址段的空间就是前面说的,由于没有经过MMU将其映射到物理内存的实际存储单元上,当程序访问System break(也就是常说的brk)之后的地址就出引发段错误。brk一般是进程堆空间结束的地方。那么,我们如何知道当前进程的brk在什么地方呢?答案就是通过一个C库函数sbrk()来获取。另外还有一个系统调用brk()用来设置System break的位置,其实sbrk()也可以设置,它只不是对brk()系统调用的一个封装而已。关于这两个函数的更多用法可以参考man手册。

   为了不影响我们的测试效果,我们需要将内核的随机地址保护模式关掉。为了方式溢出攻击,现代很多操作系统都做了这样的随机地址保护。就是,当程序运行时,代码段、堆栈段的装载起始地址并不是固定不变的,而是每次运行进程时都会加上一个随机的偏移量,这会影响我们的测试效果。关闭它的方法很简单:

    [root@localhost ~]#echo "0" > /proc/sys/kernel/randomize_va_space

    如果/proc/sys/kernel/randomize_va_space为0则表示,进程每次启动运行时,其虚拟地址空间里的值就是它在ELF文件里所指定的值;如果为1,则每次启动时只有栈的装载地址做随机保护;如果为2,表示进程每次启动时,进程的装载地址、brk和堆栈地址都会随机变化。看个例子,这是网上流传比较多的一段代码,很具有代表性,这里我又站在前人的肩膀上了:

 

 

     由于全局变量bssvar未初始化,所以当程序运行时它会被放置在.bss段,占4字节。sbrk(0)会返回当前brk的值。为了便于观察,我们用了sleep(8)。下面用readelf看一下可执行文件被装载时,Segement的情况将会是什么样子:

 

  另一方面,内存分配时是以页为单位,一般页大小为4096字节,所以从0x08048000开始是代码段,共占内存0x00628,即1576个字节,不足一个页,但必须以页为单位,所以下一个页,也就是数据页必须从0x0804900开始。但上面显示却说数据页从0x08049628开始,但注意最后一列Allign,指明了对其方式,正好是4096字节。验证一下:

     这里我们看到操作系统确实是以页(4096字节)为单位进行内存分配。有些人可能觉得奇怪,既然stack都已经有了,为什么没有heap呢?原因是,默认情况,.bss段结束地址就是heap的开始地址。当源代码中没有诸如malloc()之类的动态内存分配函数时,在查看进程的内存映射时是看不到heap的。此时的进程空间的布局应该如下:

 

 

 

    我们可以知道,当程序访问0x0848000~0x0849FFF之间的所有数据都是OK的,当访问到0x084A000及其之后的地址就会报“Segmetation Fault”,因为我们的brk刚好到这里。不信??好吧,把上面程序简单调整一下:

  1. #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    int bssvar;
    int main(int argc,char** argv)
    {
            void *ptr;
            printf("main start = %p\n",main);
            printf("bss end = %p\n",(long)&bssvar+4);
            ptr=sbrk(0);
            printf("current brk = %p\n",(long*)ptr);
            sleep(8);
            int i=0x08049628;
            for(;;i++)
                    printf("At:0x%x-0x%x\n",i,*((char*)i));
    }
    

      

    重新编译运行memlayout,最后出现“Segmetation Fault”时应该是下面这个样子:

 
 

  当你的源代码中有用到诸如malloc()之类的动态内存申请函数时,brk的值会被相应的往高端内存的位置进行调整,这样调整出来的一段内存就被所谓的内存管理器,也就是著名的buddy system纳入管理范围了。这样当我们再访问这些地址时,就不会报“Segmetation Fault”了。其实如果你看过Glibc源码你就会惊奇的发现,malloc()最终也是通过调用brk()

系统掉用来实现堆的管理。所以,如果我们把上述代码再做一下简单修改:

点击(此处)折叠或打开

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int bssvar;
int main(int argc,char** argv)
{
        void *ptr;
        printf("main start = %p\n",main);
        printf("bss end = %p\n",(long)&bssvar+4);
        ptr=sbrk(0);
        printf("current brk = %p\n",(long*)ptr);
        sleep(8);
        int i=0x08049628;
        brk((char*)0x804A123); //注意这行代码
        for(;;i++)
                printf("At:0x%x-0x%x\n",i,*((char*)i));
}

  

我们用brk()系统调用,手动把brk调整到0x804A123处,再编译运行,你就会得到下面这样的结果:

   

 

至于是为什么不在0x804A123处报“Segmetation Fault”而是要跑到0x804B000处才报,原因已经不止一次的强调了,脑袋犯迷糊的童鞋还是从头再认真看一遍吧。

   又到了该总结的时候了,可能有些童鞋都忘了这篇博文是要讨论什么话题了:
   程序之所以会时不时的出现“Segmetation Fault”的根本原因是进程访问到了没有访问权限的地方,诸如内核区域或者其0x08048000之前的地方,或者由于要访问的内存没有经MMU进行映射所导致。而这种问题比较多的是出在malloc()之类的动态内存申请函数申请完内存,释放后,没有将指针设置为NULL,而其他地方在继续用先前申请的那块内存时,由于内存管理系统已经将其收回,所以才会出现这样的问题。良好的关于指针的使用习惯是,使用之前先判断其是否为NULL,所有已经归还给操作系统的内存,其访问指针都要及时置为NULL,防止所谓的“野指针”到处飞的情况,不然在大型项目里,光是围剿“Segmetation Fault”就要耗费不少兵力。

 

posted @ 2019-03-29 17:15  wpgraceii  阅读(58423)  评论(0编辑  收藏  举报