(备注:我的glibc版本是2.12,GCC版本4.4.6,内核版本2.6.32-279) Linux上开发时最恼火的就是遇到“Segmetation Fault”错误。为什么这么说,很多人看到这个错误后心里第一反应是程序访问的非法的内存,导致其被操作系统强行终止。这固然没错,可这里有个比较模糊的概念了:什么叫“非法”的内存? 程序运行时,每个进程都有自己的虚拟地址,理论上说进程应该可以随便使用才对,为什么还会出现这个错误呢?这里就涉及到程序的装载过程及原理。 先澄清几个概念: 程序:一般是一组CPU指令的集合构成的文件,静态存储在诸如硬盘之类的存储设备上。 进程:当一个程序要被计算机运行时,就是在内存中产生该程序的一个运行时实例,我们就把这个实例叫做进程。 装载:上述从硬盘上的静态“程序”到内存中动态的“进程”之间的转变过程就叫做装载。往通俗里讲,就是启动一个进程。 本文的主要目的是在简单了解进程的内存布局的情况下,从装载的过程入手,深入了解一下Segmetation Fault在操作系统层面是如何产生的,以及程序开发过程中应该如何避免这样的错误。 众所周知Linux中可执行文件的格式是ELF,其实编译过程中的中间文件*.o文件、动态共享库*.so文件也是ELF格式的。在链接器看来,当它通过*.o或者配合*.so文件来生成可执行文件时,它对ELF格式的文件以链接视图(Linking View)进行看待。也就是说链接器以Section的形式来对待和处理ELF文件,诸如我们常见说的代码段(.text)、数据段(.data和.bss)等待概念。当程序最终需要被装载成进程时,装载器就出场了,装载器将可执行文件以装载视图(Executive View)进行看待。装载器将以Segment的形式来处理ELF文件。网上很多教程也是这样说的,大家可能还是理解的不是很明白,后面我们通过实例的方式将进一步向大家来澄清这两者的区别。 既然*.o、*.so和可执行文件都是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”都有数据。 再看一下动态共享库: ![]() ![]() 所以,我们可以得到这样一个结论:一个具体的ELF文件,其文件头部中的某些属性值,指明了它到底是可执行文件还是可重定位文件(*o和*.so的统称)。这样,链接器和装载器通过分析ELF文件头部就可以知道它该怎么处理该文件了。用比较直观的、方便理解的图来表示它们的区别就是: ![]() ![]() 上图是很多资料上说的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刚好到这里。不信??好吧,把上面程序简单调整一下: 点击(此处)折叠或打开
重新编译运行memlayout,最后出现“Segmetation Fault”时应该是下面这个样子: ![]() 当你的源代码中有用到诸如malloc()之类的动态内存申请函数时,brk的值会被相应的往高端内存的位置进行调整,这样调整出来的一段内存就被所谓的内存管理器,也就是著名的buddy system纳入管理范围了。这样当我们再访问这些地址时,就不会报“Segmetation Fault”了。其实如果你看过Glibc源码你就会惊奇的发现,malloc()最终也是通过调用brk()系统掉用来实现堆的管理。所以,如果我们把上述代码再做一下简单修改: 点击(此处)折叠或打开
我们用brk()系统调用,手动把brk调整到0x804A123处,再编译运行,你就会得到下面这样的结果: ![]() 至于是为什么不在0x804A123处报“Segmetation Fault”而是要跑到0x804B000处才报,原因已经不止一次的强调了,脑袋犯迷糊的童鞋还是从头再认真看一遍吧。 又到了该总结的时候了,可能有些童鞋都忘了这篇博文是要讨论什么话题了: 程序之所以会时不时的出现“Segmetation Fault”的根本原因是进程访问到了没有访问权限的地方,诸如内核区域或者其0x08048000之前的地方,或者由于要访问的内存没有经MMU进行映射所导致。而这种问题比较多的是出在malloc()之类的动态内存申请函数申请完内存,释放后,没有将指针设置为NULL,而其他地方在继续用先前申请的那块内存时,由于内存管理系统已经将其收回,所以才会出现这样的问题。良好的关于指针的使用习惯是,使用之前先判断其是否为NULL,所有已经归还给操作系统的内存,其访问指针都要及时置为NULL,防止所谓的“野指针”到处飞的情况,不然在大型项目里,光是围剿“Segmetation Fault”就要耗费不少兵力。 |
|