segmentfault谈起
前言
提起segmentfault估计大家都不陌生,搞linux下C,C++程序员都知道,出现这个错误原因无外乎有几种
1,内存越界踩到了别人家的内存。
2,不小心除零了或者运算浮点数的时候异常了,比如:1/0。
3,堆栈溢出,比如:递归的时候没有设置边界条件。
主要这几种,感觉出来吧这种错误每个都很严重呀!确实如此,出现这种错误,程序基本gg思密达,当你开启coredump功能的时候,比如用下面的命令:
ulimit -c unlimited //unlimited含义是信息有多大我打印多大,无限制。
程序临死之前会打印coredump信息到文件里面,找问题的时候一般用gdb调试coredump大部分可能会还原出当时崩溃时候函数调用堆栈还有其他各种信息,当然如果你运气不好,程序跑飞把调用堆栈破坏了,那就尴尬了,除非一些特殊技巧,问题也很难查。另外万一程序是很重要的程序,并且重启代价很大,比如那种有cache的程序需要预热或者加载大量数据的程序,启动都要花一段时间,恢复处理起来也很棘手,比如前段时间部门除了一个P1事故就是因为一个插件有问题导致整个十几台服务器程序崩溃,导致服务终端一小段时间,大写的尴尬。
那么问题来了有没有可以像超级try,catch的功能能够捕捉住C,C++程序的崩溃点,然后跳过这段代码执行备份代码呢,想想就是一件美好的事情,那必须有呀!
Linux程序运行结构
X86-64下linux进程的地址结构如下图所示:
左边的图很详细介绍了进程地址空间的具体排列。右边的图是用户空间详细的地址分配细节。
- 其中左图中在栈地址和mmap地址中有两个Random stack offset,Random mmap offset两个标志,这个是linux为了防止缓冲区溢出等恶意程序所做的特殊保护,Linux通过对栈、内存映射段和堆的起始地址加上了随机偏移量来打乱布局,防止恶意程序推算出程序的栈和函数地址来获取执行权限具体实现细节可以参考这篇文章GCC 中的编译器堆栈保护技术
- 用户进程部分分段存储内容如下表所示(按地址递减顺序):
名称 | 存储内容 |
---|---|
栈 | 局部变量、函数参数、返回地址等 |
堆 | 动态分配的内存 |
BSS段 | 未初始化或初值为0的全局变量和静态局部变量 |
数据段 | 已初始化且初值非0的全局变量和静态局部变量 |
代码段 | 可执行代码、字符串字面值、只读变量 |
在系统运行程序的时候,操作系统会先代码段、数据段和BSS段加载到内存中,栈的运行也是由系统来分配和管理,堆需要程序显示的分配和释放。
-
栈(stack)
程序中堆栈主要有三个作用:
- 函数中的非静态的局部变量分配内存空间。
- 栈还能记录当前运行的函数调用过程的维护性信息,每一个这样的一段内存简称为栈帧(stack frame)。它包括函数的返回地址,不适合存入寄存器的函数参数、保存一些寄存器的值或者状态。其实除了函数递归,栈也不是必须的,因为编译的时候很多信息已经知道了。
- 栈还可以作为临时储存区,比如对于一些很长的计算公式中间结果或者alloca()函数分配的栈内空间。
函数栈的使用对于当前的cpu多级缓存架构有很大的优势(当然也可能是CPU针对程序优化的结果), 程序重用堆栈有助于活跃的数据保存在CPU的缓存中,并且有很大优势在CPU的一级缓存中,CPU内部的缓存使用的SRAM技术制作(SRAM这种存储器不需要刷新电路,这种存储器的读取速度有多快呢,和CPU同频率和寄存器同频,不过实际中会稍微比寄存器慢一些,不过这种内存代价也蛮高的,需要四个场效应管才能存一个bit),所以堆栈的结构对CPU的高效率执行简直不要太爽。
-
内存映射段(mmap)
记得在读《Linux程序设计》这本书知道mmap技术的时候第一感觉是很惊讶,第二感觉是当时linux开发这个技术只是目的是什么,只是为了实现内存映射才开发了一系列这个功能吗?显然不是,linux上的很多API都是在解决实际问题而留下的精华。linux内存也为什么会有mmap的地址段呢,这个就要从最以前说起了,Linux系统在运行的时候需要经常把内存页换出或者换入,大多数情况下系统的页交换会有以下两种方式:
- 长期闲置的内存页不被访问,操作系统会调用kswap线程来把内存中的页换出到磁盘上的交换区内,当进程访问该页的时候操作系统发现缺页会进入交换分区内进行查找,并且最后把交换分区内的页换入到系统内存中。
- 针对某个被打开的磁盘文件在内存中的页缓冲,在内存资源不足而需要增加空闲页面时,由内核线程bdflush在系统空闲而得到调度时按照LRU算法将“脏”页面写回磁盘文件以回收空闲页面(或由用户强制“刷出”页面),或者因进程所读文件某段数据不在内存而启动磁盘IO读文件数据到内存中的文件缓冲区。
其实这两中情况透露了一个事实,内核已经在内存和磁盘之间的搭起了通道桥梁,本来就是通的,很容易开放给用户自己来实现内存的映射。具体的内存映射实现可以参考《深入理解计算机系统(原书第三版3》或者这篇文章Linux进程间通信–内存映射
对象的共享技术很自然的就用到了动态链接库上了,其实程序运行有好多函数基础库都是要复用的比如说:printf,要是每个程序都有一个printf函数的拷贝,那系统绝对要炸锅,在虚拟的地址空间里设置内存映射段,可以直接把动态库链接到这个区域,具体加载器是如何将动态链接库映射到内存中的具体详细实现细节请参考(深入理解计算机系统(原书第三版3 580-586。
-
堆(heap)
-
堆用于存放进程运行时动态分配的内存段,可动态扩张或缩减。堆中内容是匿名的,只能通过指针间接访问。当进程调用malloc(C)/new(C++)等函数分配内存时,新分配的内存动态添加到堆上(扩张);当调用free(C)/delete(C++)等函数释放内存时,被释放的内存从堆中剔除(缩减) 。
-
分配的堆内存是经过字节对齐的空间,以适合原子操作。堆管理器通过链表管理每个申请的内存,由于堆申请和释放是无序的,最终会产生内存碎片。堆内存一般由应用程序分配释放,回收的内存可供重新使用。若程序员不释放,程序结束时操作系统会自动回收。
-
堆的末端由break指针标识,当堆管理器需要更多内存时,可通过系统调用brk()和sbrk()来移动break指针以扩张堆,一般由系统自动调用。
-
-
BSS段
在C语言中,未显式初始化的静态分配变量被初始化为0(算术类型)或空指针(指针类型)。由于程序加载时,BSS会被操作系统清零,所以未赋初值或初值为0的全局变量都在BSS中。BSS段仅为未初始化的静态分配变量预留位置,在目标文件中并不占据空间,这样可减少目标文件体积。但程序运行时需为变量分配内存空间,所以目标文件必须记录所有未初始化的静态分配变量大小总和(通过start_bss和end_bss地址写入机器代码)。当加载器(loader)加载程序时,将为BSS段分配的内存初始化为0。在进入main()函数之前BSS段被C运行时系统映射到初始化为全零的内存。
BSS(Block Started by Symbol)段中通常存放程序中以下符号
- 未初始化的全局变量和静态局部变量。
- 初始值为0的全局变量和静态局部变量(依赖于编译器实现)。
- 未定义且初值不为0的符号。
-
数据段(Data)
数据段通常用于存放程序中已初始化且初值不为0的全局变量和静态局部变量。数据段属于静态内存分配(静态存储区),可读可写。
-
代码段(text)
-
代码段通常用于存放程序执行代码(即CPU执行的机器指令)。一般C语言执行语句都编译成机器代码保存在代码段。通常代码段是可共享的,因此频繁执行的程序只需要在内存中拥有一份拷贝即可。代码段通常属于只读,以防止其他程序意外地修改其指令(对该段的写操作将导致段错误)。某些架构也允许代码段为可写,即允许修改程序。代码段指令根据程序设计流程依次执行,对于顺序指令,只会执行一次(每个进程);若有反复,则需使用跳转指令;若进行递归,则需要借助栈来实现。
-
代码段指令中包括操作码和操作对象(或对象地址引用)。若操作对象是立即数(具体数值),将直接包含在代码中;若是局部数据,将在栈区分配空间,然后引用该数据地址;若位于BSS段和数据段,同样引用该数据地址。
-
-
保留区
保留区就是地址空间最下面没有使用的部分:
-
位于虚拟地址空间的最低部分,未赋予物理地址。任何对它的引用都是非法的,用于捕捉使用空指针和小整型值指针引用内存的异常情况。它并不是一个单一的内存区域,而是对地址空间中受到操作系统保护而禁止用户进程访问的地址区域的总称。大多数操作系统中,极小的地址通常都是不允许访问的,如NULL。C语言将无效指针赋值为0也是出于这种考虑,因为0地址上正常情况下不会存放有效的可访问数据。
-
在64位X86架构的Linux系统中,用户进程可执行程序一般从虚拟地址空间0x00400000开始加载。该加载地址由ELF文件头决定,可通过自定义链接器脚本覆盖链接器默认配置,进而修改加载地址。0x00400000以下的地址空间通常由C动态链接库、动态加载器ld.so和内核VDSO(内核提供的虚拟共享库)等占用。通过使用mmap系统调用,可访问0x00400000以下的地址空间。
-
总结
- 本章节介绍进程地址详细分布,包括各个段的具体功能。
下一章节
下一章节介绍函数调用的详细过程和堆栈回退的实现细节。
代码地址:https://github.com/xiaobazhang/nocoredump.git