令人头疼的Segmentation fault到底是怎么产生的

码农有道公众号

共 5786字,需浏览 12分钟

 ·

2022-08-22 10:35

底层原理系列文章链接如下,持续更新中:

彻底理解Linux文件系统(一)

彻底理解Linux文件系统(二)

彻底理解Linux文件系统(三)

彻底理解Linux文件系统(四)

一道高频腾讯面试题:tcp数据发送问题

Linux swap area是个什么东西

今天来聊聊Linux环境下开发时经常遇到的“Segmentation fault”的问题其究竟是怎么产生的。

Linux上开发时最让人头疼的问题之一就是遇到“Segmentation Fault”错误。为什么这么说,很多人看到这个错误后心里第一反应是程序访问的非法的内存,这固然没错,可这里有个比较模糊的概念了:什么叫“非法”的内存

在Linux下,每个进程都有自己的虚拟地址,理论上说进程应该可以随便使用才对,为什么还会出现这个错误呢?这里就涉及到程序的装载过程及原理。

做过C/C++开发的同学应该知道, Linux中可执行文件的格式是ELF ,编译过程中的中间文件*.o文件、动态共享库*.so文件其实也是ELF格式的。 在链接器看来 ,它对*.o文件、动态共享库*.so文件这两种ELF格式的文件以链接视图(Linking View)进行看待

当程序最终需要被装载成进程时,装载器就出场了,装载器将可执行文件以装载视图进行对待处理。这些概念不太理解的同学可以去阅读一本叫程序员的自我修养--链接、装载与库的书籍,这是国人为数不多写的比较好的一本技术书籍。

前面说了*.o、*.so和可执行文件都是ELF格式的文件,那么链接器和装载器是如何区分哪个ELF文件是可执行文件,哪个文件又是.so文件?首先强调的是肯定不是通过文件名后缀来区分的,Linux系统下对文件名后缀是不敏感的,我们还是先看一个简单的例子:f8da950d822715e3d0f794b6986d3373.webp

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

再看一下动态共享库的EFL文件的头部信息 46da45c9049b1b7f2a1011bf9b1aefe3.webp

在Linux下动态共享库被当作可执行文件来处理,虽然它不能单独执行,但某些应用程序的运行离不了它。

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

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

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

进程运行的时候,它虚拟地址空间和对应的物理内存到底是怎么关联上的了?一般是通过一个叫做MMU(内存管理单元)的东东完成了从进程虚拟地址到物理地址之间的映射。

进程虚拟地址空间的任何地址,在使用前都必须通过MMU将其映射到物理内存上一个实实在在的存储单元上才是合法有效的。而对于那些没有经过MMU映射过的虚拟空间的地址,在对其进行读写操作时,对于操作系统来说都是一个错误的非法内存访问,就会报“Segmentation Fault”的错误提示信息并强行终止进程。

那么,问题又来了,到底哪些地址才是合法有效或者能被MMU映射的呢?下面是一个Linux进程虚拟地址空间分布图:b25a9fbd06e0511a47f11622c3971f25.webp

上图是32位系统下Linux进程虚拟地址空间的布局结构图,其中0x0804800为进程运行时的地址入口(你的程序的第一条指令的入口地址),当进程运行时,完成进程环境空间的初始化工作之后就会跳到这个地址执行我们程序里的第一条指令。

0x0804800这个地址一般由链接器在生成可执行文件时就已经固定了,通常无需更改。当然,如果你一定要修改也是有办法修改的,这个不在本文的讨论范围内。上图中,当用户的程序直接访问0x084800以前的地址、0xC0000000以后的地址或者free空间里的地址都会触发“Segmentation Fault”。原因如下:

  • • 0x084800以前的地址、0xC0000000以后的地址:由于权限的问题, 不允许进程直接访问 ,操作系统对其进行保护。

  • • free地址段的空间就是前面说的,由于没有经过MMU将其映射到物理内存的实际存储单元上,当程序访问System break(也就是常说的brk)之后的地址就会引发段错误。brk一般是进程堆空间结束的地方。那么,我们如何知道当前进程的brk在什么地方呢?可以通过库函数sbrk()来获取。与此配套的还有一个函数brk()用来设置System break的位置。

现代很多操作系统实现中,为了防止溢出攻击,都做了随机地址保护:当程序运行时,代码段、堆栈段的装载起始地址并不是固定不变的,而是每次运行进程时都会加上一个随机的偏移量,这会影响我们的测试效果。

为了不影响我们的测试效果,后续的实验中,我们将内核的随机地址保护机制关掉,关闭它的方法很简单:
      
        [root@localhost ~]#echo "0" > /proc/sys/kernel/randomize_va_space
      
    
如果/proc/sys/kernel/randomize_va_space为0则表示,进程每次启动运行时,其虚拟地址空间里的值就是它在ELF文件里所指定的值,下面的一段代码是网上流传比较多的一段很典型的代码,这里就直接借鉴过来用了:8d25c01cf8b6805664e106d1987dad90.webp由于全局变量bssvar未初始化,所以当程序运行时它会被放置在.bss段,占4字节。sbrk(0)函数返回当前brk的值。为了便于观察,我们在程序中调用了sleep函数睡眠几秒。然后用readelf看一下可执行文件被装载时,Segement的情况将会是什么样子:bf24c5c318bf08d392e7b4ead4877fde.webp还需要说明一点的是,内存分配时是以页为单位,一般情况下页大小为4096字节,所以从0x08048000开始是代码段,共占内存0x00628,即1576个字节,虽然不足一个页,但是数据页的分配也必须从下一个页开始,即从0x0804900开始。但上面显示却说数据页从0x08049628开始,但注意最后一列Allign,指明了对其方式,正好是4096字节。验证一下:cec633a7fbdd735350baf7c96173ca3a.webp这里我们看到操作系统确实是以页为单位进行内存分配。这里需要注意的一点是,对应heap来说,默认情况,.bss段结束地址就是heap的开始地址。当你的代码中没有调用malloc()之类的动态内存分配函数时,在查看进程的内存映射时是看不到heap的。此时的进程虚拟地址空间的布局下:2f9348da0b23e8e7dce7f60b8d3c47a2.webp我们可以知道,当程序访问0x0848000~0x0849FFF之间的所有数据都是OK的,当访问到0x084A000及其之后的地址就会报“Segmentation Fault”,因为我们的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;
        for(;;i++)
                printf("At:0x%x-0x%x\n",i,*((char*)i));
}
重新编译运行该程序,最后出现“Segmentation Fault”时应该是下面这个样子:f5ad3b6188f3b2407c77707afb6ce3df.webp

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

      
        #include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int bssvar;

int main(int argc,char** argv)
{
        void *ptr = NULL;

        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处,再编译运行,你就会得到下面这样的结果:7a1ef0385ab20aa0a5fc4c6b989a14d3.webp

至于是为什么不在0x804A123处报“Segmentation Fault”而是要跑到0x804B000处才报,原因已经不止一次的强调了,如果还是不清楚的同学建议从头再看一遍。

好了,现在我们知道了,程序之所以会出现“Segmentation Fault”,其原因就是因为进程访问到了没有访问权限的地方。

所以,对于程序员特别是C/C++程序员来说,良好的关于指针的使用习惯是,使用之前先判断其是否为NULL,所有已经归还给操作系统的内存,其访问指针都要及时置为NULL,防止所谓的“野指针”到处飞的情况,代码中尽量使用智能指针等等。

本文示例代码环境:
内核:2.6.32-279
glibc版本:2.12
GCC版本: 4.4.6

—  —

欢迎关注原创技术号↓ ↓↓ 如有帮助,辛苦点赞和在看
浏览 109
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报