Linux下多线程程序崩溃时如何提取出所有线程的函数调用栈(一)

2019-07-13 08:41发布

   若果你是一名多年的嵌入式linux开发者,在实际的应用开发开发中一定会用到多线程的设计方式,同样你也会遇到当你make完以后,高心的把二进制文件烧写到板子上的 flash以后,断电开电或看门狗复位,板子还没有送数据时候一切都安然无恙,可是数据流送进板子以后,突然板子莫名的重启、有时时间不定的重启、或是没有规律的重启(而且也没报什么段错误),或是莫名的异常!反正就TMD 重启了(恭喜你,当您看到我这边文章时,你那颗纠结的心或许会得到一丝丝的缓解,哪怕只是一丝丝的.......)。    刨根问底是一名优秀工程师的普遍特征(当然也还有很多.....比如一直单身中......)。所以我不会直接的直奔问题的本质,而是像一些国外的作者一样先说问题的来源和背景(同样包括我们处在一个什么样的环境),国内的很多写书的一上来就将一些特定用例刷刷的写了几页 也不知道要讲什么,没有很清晰的来龙去脉,美其名曰授人以渔,其实自己是东抄抄,西凑凑来的一本烂书!言归正传。    我会以以下几个部分来讲解:     首先:会花一章的篇幅来介绍 linux 下面线程的一线概念与背景,这点请可以参阅我的博客文章《 线程的概念》  一文;     其次:要重点的讲解 linux中的进程线程在内存中的详细布局以及两者之间的关系,这部分重要是因为,所有的代码都是运行在内存中的,如果你对内存的布局空间有个很清晰的了解。不管对你写代码还是面对一个问题是都有很大的帮助,要知道这个世界上BUG 是永远不会消失的。(注意思路和思想才是最重要的!)。 第一节:段       一般现在的操作系统都有一种很好的方式来定义可执行文件的格式。一种常见的方法就是为可执行文件分段,一般来说把程序指令的内容放在.text段中,把程序中的数据内容放在. data段中,把程序中未初始化的数据放在.bss段中。这种做法的好处有很多,可以让操作系统内核来检查程序防止有严重错误的程序破坏整个运行环境。比如:某个程序想要修改.text段中的内容,那么操作系统就会认为这段程序有误而立即终止它的运行,因为系统会把.text段的内存标记为只读。在. bss段中的数据还没有初始化,就没有必要在可执行文件中浪费储存空间。在.bss中只是表明某个变量要使用多少的内存空间,等到程序加载的时候在由内核把这段未初始化的内存空间初始化为0。这些就是分段储存可执行文件的内容的好处。现代的Unix类系统使用都是elf格式(Executable and Linking Format的, 好了到了这里你应该至少有个段的概念了。说白了就是把程序分成几个合理易于内核管理的段。    一个linux进程被分为几个部分通常也是以段的形式来命名的(从一个进程的地址空间的低地址向高地址增长来看整个映射图)那么可以分为以下几部分(整个进程地址空间布局如下图所示:): 1.text: 就是存放代码,可读可执行不可写,也称为正文段,代码段(说白了就是指令)。
2.data:存放已初始化的全局变量和已初始化的static变量(不管是局部static变量还是全局static变量)
3.bss: 存放全局未初始化变量和未初始化的static变量(也是不区分局部还是全局static变量) 以上这3部分是确定的,也就是不同的程序,以上3部分的大小都各不相同,因程序而异,若未初始化的全局变量定义的多了,那么bss区就大点,反之则小点。 4.heap:也就是堆,堆在进程空间中是自低地址向高地址增长,你在程序中通过动态申请得到的内存空间(c中一般为malloc/free,c++中一般为new/delete),就是在堆中动态分配的。
5.stack:程序中每个函数中的局部变量,都是存放在栈中,栈是自高地址向低地址增长的。起初,堆和栈之间有很大一段空间,然后随着,程序的运行(不断的动态申请),堆不断向高地址增长,栈不断向低地址增长(用多很多的临时变量或数组),这样,堆跟栈之间的空间总有一个最大界限,超过这个最大界限,就会出现堆跟栈重叠,就会出错,所以一般来说,Linux下的进程都有其最大空间的(ulimit -a 会发现最大只有8M,但是你超过8M时编译运行直接段错误
6.再往上:也就是一个进程地址空间的顶部,存放了命令行参数和环境变量。 第二节:堆与栈    栈-是硬件。主要作用表现为一种数据结构,是只能在某一端插入和删除的特殊线性表。它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据(最后一个数据被第一个读出来)。栈是允许在同一端进行插入和删除操作的特殊线性表。允许进行插入和删除操作的一端称为栈顶(top),另一端为栈底(bottom);栈底固定,而栈顶浮动;栈中元素个数为零时称为空栈。插入一般称为进栈(PUSH),删除则称为退栈(POP)。 栈也称为先进后出表。栈可以用来在函数调用的时候存储断点,做递归时要用到栈!    堆-是一种动态存储结构,实际上就是数据段中的自由存储区,它是C语言中使用的一种名称,常常用于动态数据的存储分配。堆中存入一数据,总是以2字节的整数倍进行分配,地址向增加方向变动。堆可以不断进行分配直到没有堆空间为止,也可以随时进行释放、再分配,不存在次序问题。
    所谓动态数组是指在程序运行期间确定其大小的,如常用到的动态数组,它们是在程序执行过程中动态进行变化的,即在程序开始部分没有说明大小,只有在程序运行期间用堆的分配函数为其分配存储空间,分配的大小可根据需要而定,这些数据使用过后,可释放它们占用的堆空间,并可进行再分配。不过堆的分配是凌乱的,这样的结果就是会产生一个内存碎片的问题。  堆和栈在使用时相向生长,栈向上生长,即向小地址方向生长,而堆向下增长,即向大地址方向,其间剩余部分是自由空间。使用过程中要防止增长过度而导致覆盖。一般的程序我们都是使用小内存模式。 c的默认函数压栈操作为:参数是从右向左压栈的,默认四字节对齐,函数里面定义的变量是默认对齐方式----变量首地址是自身结构体里边最大标准数据类型字节的整数倍。 第三节:堆栈帧 ESP 与 EBP (基于X86平台)    程序运行开始,由系统为进程地址空间中的text/data/bss段进行映射,由系统的缺页异常处理程序按需将磁盘上程序文件中的真正代码、数据写入进程。此外,bss区域中的所有变量都会被清零。   通过上面的讲述,我们知道了程序的代码,全局的变量,static变量是怎么在进程空间中分配空间的,接下来讲一下局部变量是怎么分配空间,函数是怎么调用的。其实也就是讲解栈区的具体使用过程。
首先,我们要知道,栈中存放的是一个个被调函数所对应的堆栈帧,当函数fun1被调用,则fun1的堆栈帧入栈,fun1返回时,fun1的堆栈帧出栈。什么是堆栈帧呢?堆栈帧其实就是保存被调函数返回时下一条执行指令的指针、主调函数的堆栈帧的指针、主调函数传递给被调函数的实参(如果有的话)、被调函数的局部变量以及被调函数返回地址和被调函数的一些临时变量等信息的一个结构。 堆栈帧结构如图所示:    首先,我们要说明的是如何区分每个堆栈帧;于是你要理解以下几点的概念:    132位系统中,堆栈每个数据单元的大小为4字节。小于等于4字节的数据,比如字节、字、双字和布尔型,在堆栈中都是占4个字节的;大于4字节的数据在堆栈中占4字节整数倍的空间。    2和堆栈的操作相关的两个寄存器是EBP寄存器和ESP寄存器的,本文中,你只需要把EBPESP理解成2个指针就可以了。ESP寄存器总是指向堆栈的栈顶,执行PUSH命令向堆栈压入数据时,ESP4,然后把数据拷贝到ESP指向的地址;执行POP命令时,首先把ESP指向的数据拷贝到内存地址/寄存器中,然后ESP4EBP寄存器是用于访问堆栈中的数据的,它指向堆栈中间的某个位置(具体位置后文会具体讲解),函数的参数地址比EBP的值高,而函数的局部变量地址比EBP的值低,因此参数或局部变量总是通过EBP加减一定的偏移地址来访问的,比如,要访问函数的第一个参数为EBP+8    3堆栈中到底存储了什么数据? 包括了:函数的参数,函数的局部变量,寄存器的值(用以恢复寄存器),函数的返回地址以及用于结构化异常处理的数据(当函数中有try…catch语句时才有,本文不讨论)。这些数据是按照一定的顺序组织在一起的,我们称之为一个堆栈帧(Stack Frame)。一个堆栈帧对应一次函数的调用。在函数开始时,对应的堆栈帧已经完整地建立了(所有的局部变量在函数帧建立时就已经分配好空间了,而不是随着函数的执行而不断创建和销毁的);在函数退出时,整个函数帧将被销毁。    4在文中,我们把函数的调用者称为Caller(调用者),被调用的函数称为Callee(被调用者)。之所以引入这个概念,是因为一个函数帧的建立和清理,有些工作是由Caller完成的,有些则是由Callee完成的。     再来讲一下上图,一个堆栈帧的最顶部,是实参,然后是return address,这个值是由主调函数中的call命令在call调用时自动压入的,不需要我们关心,previous frame pointer,就是主调函数的堆栈帧指针,也就是主调函数的ebp值。ebp偏移为正的都是被调函数的参数。 下面就以一个具体的实例来验证上面的说法是否靠谱: int function(int a, int b, int c)   {       char buffer[14];       int     sum;       sum = a + b + c;       return sum;   }       void main()   {       int     i;       i = function(1,2,3);   }  Linux下,我们通过 gcc -S example.c 来生成汇编文件,然后我们查看上面这个程序对应的汇编程序: 1     .file   "example1.c"  //文件名 4     .text               //表示代码段 5     .align 4            //表示四个字节对齐 6 .globl function           //当前的函数名 7     .type    function,@function 8 function: 9      pushl %ebp  //ebp这时指向的还是上一个堆栈帧,这个时候将ebp压入为的是返回的时候能够复原上一个堆栈帧 10     movl %esp,%ebp  //上一步中已将ebp(上一个堆栈帧的ebp)压入保存,因此这时ebp能够腾出来指向新的堆栈帧了 11     subl $20,%esp        //esp下移20个字节,就是申请20个字节的空间(buffer16字节,sum4字节) 12     movl 8(%ebp),%eax    //ebp8(为什么不是加4呢?4个是指向这)后,指向第1个实参,放入eax. 13     addl 12(%ebp),%eax   //ebp+12后,指向第2个实参,与上一步中的第一个实参相加,放入eax. 14     movl 16(%ebp),%edx   //ebp+12,第3个实参放入edx 15     addl %eax,%edx       //3个实参与上上步中的结果相加,就是3个实参相加,结果存入edx 16     movl %edx,-20(%ebp)  //将结果放入刚才申请的sum 17     movl -20(%ebp),%eax 18     jmp .L1              //跳转到标号.L1 19     .align 4 20 .L1: 21     leave            //leaveret见下面的解析 22     ret 23 .Lfe1: 24     .size    function,.Lfe1-function 25     .align 4 26 .globl main 27     .type    main,@function 28 main: 29     pushl %ebp 30     movl %esp,%ebp 31     subl $4,%esp        //申请4个字节(给局部变量i) 32     pushl $3            //压入实参3 33     pushl $2            //压入实参2 34     pushl $1            //压入实参1 35     call function         //调用function函数,执行这条命令的时候还会将下一条指令的指针压栈 36     addl $12,%esp       //加上12就释放了刚才压入的3个实参 37     movl %eax,%eax     //function返回值在eax 38     movl %eax,-4(%ebp)  //返回值赋值给了刚才申请的空间(变量i) 39 .L2: 40     leave 41     ret 42 .Lfe2: 43     .size    main,.Lfe2-main 44     .ident  "GCC: (GNU) 2.7.2.3" 其中函数function的堆栈帧

注释:
1.function,buffer14个字节,sum4个字节,照例应该是申请18个字节,但是第11行,程序申请了20个字节。这是时间效率和空间效率之间的一种折衷,因为Intel i38632位的处理器PS: 在32位系统中,堆栈每个数据单元的大小为4字节。小于等于4字节的数据,比如字节、字、双字和布尔型,在堆栈中都是占4个字节的;大于4字节的数据在堆栈中占4字节整数倍的空间。】,其每次内存访问都必须是4字节对齐的,而高30位地址相同的4个字节就构成了一个机器字。因此,如果为了填补 buffer[14]留下的两个字节而将sum分配在两个不同的机器字中,那么每次访问sum就需要两次内存操作,这显然是无法接受的。这些都是跟编译器相关的优化技术。 2.我们再来看一下在函数function中是如何将abc的和赋给sum的。前面已经提过,在函数中访问实参和局部变量时都是以堆栈帧指针为基址,再加上一个偏移,而Intel i386体系结构下的堆栈帧指针就是ebp,为了清楚起见,我们在图7中标出了堆栈帧中所有成分相对于堆栈帧指针ebp的偏移。这下图中1216的计算就一目了然了,8(%ebp)12(%ebp)16(%ebp)-20(%ebp)分别是实参abc和局部变量sum的地址,几个简单的 add指令和mov指令执行后sum中便是abc三者之和了。另外,在gcc编译生成的汇编程序中函数的返回结果是通过eax传递的,因此在图6中第 17行将sum的值拷贝到eax中。 3.我们再来看一下函数function执行完之后与其对应的堆栈帧是如何弹出堆栈的。图中第21行的leave指令将堆栈帧指针 ebp拷贝到esp中,于是在堆栈帧中为局部变量buffer[14]sum分配的空间就被释放了;除此之外,leave指令还有一个功能,就是从堆栈中弹出一个机器字并将其存放到ebp中,这样ebp就被恢复为main函数的堆栈帧指针了。第22行的ret指令再次从堆栈中弹出一个机器字并将其存放到指令指针eip中,这样控制就返回到了第36main函数中的addl指令处。addl指令将栈顶指针esp加上12,于是当初调用函数 function之前压入堆栈的三个实参所占用的堆栈空间也被释放掉了。至此,函数function的堆栈帧就被完全销毁了。前面刚刚提到过,在gcc编译生成的汇编程序中通过eax传递函数的返回结果,因此图6中第38行将函数function的返回结果保存在了main函数的局部变量i. 【PS】:注意这是在X86 下面的 汇编分析结果,作为嵌入式开发者而言。一般都是跑在ARM 体系结构平台下,所以用的编译器就有区别了,而且汇编指令也不一样,其实这个分析X86 的过程只是我引用了 其他作者 以此来达到一个抛砖引玉的作用。其实在工作中更多的还是会分析自己平台下面的 一些反汇编文件。 若果 读者还不是很明白 可以参考我的 另两篇文章《浅谈C/C++堆栈指引——C/C++堆栈很强大 》和《C函数调用与堆栈的变化 第四节:进程-用户空间与内核空间    每个进程有各自的私有用户空间(03G),这个空间对系统中的其他进程是不可见的。最高的1GB内核空间则为所有进程以及内核所共享。另外,进程的用户空间也叫地址空间,在后面的叙述中,我们对这两个术语不再区分。
用户空间不是进程共享的,而是进程隔离的。每个进程最大都可以有3GB的用户空间。一个进程对其中一个地址的访问,与其它进程对于同一地址的访问绝不冲突。比如,一个进程从其用户空间的地址0x1234ABCD处可以读出整数8,而另外一个进程从其用户空间的地址0x1234ABCD处可以读出整数20,这取决于进程自身的逻辑。
   任意一个时刻,在一个CPU上只有一个进程在运行。所以对于此CPU来讲,在这一时刻,整个系统只存在一个4GB的虚拟地址空间,这个虚拟地址空间是面向此进程的。当进程发生切换的时候,虚拟地址空间也随着切换。由此可以看出,每个进程都有自己的虚拟地址空间,只有此进程运行的时候,其虚拟地址空间才被运行它的CPU所知。在其它时刻,其虚拟地址空间对于CPU来说,是不可知的。所以尽管每个进程都可以有4 GB的虚拟地址空间,但在CPU眼中,只有一个虚拟地址空间存在。虚拟地址空间的变化,随着进程切换而变化。
   从上面我们知道,一个程序编译连接后形成的地址空间是一个虚拟地址空间,但是程序最终还是要运行在物理内存中。因此,应用程序所给出的任何虚地址最终必须被转化为物理地址,所以,虚拟地址空间必须被映射到物理内存空间中,这个映射关系需要通过硬件体系结构所规定的数据结构来建立。这就是我们所说的段描述符表和页表,Linux主要通过页表来进行映射。
于是,我们得出一个结论,如果给出的页表不同,那么CPU将某一虚拟地址空间中的地址转化成的物理地址就会不同。所以我们为每一个进程都建立其页表,将每个进程的虚拟地址空间根据自己的需要映射到物理地址空间上。既然某一时刻在某一CPU上只能有一个进程在运行,那么当进程发生切换的时候,将页表也更换为相应进程的页表,这就可以实现每个进程都有自己的虚拟地址空间而互不影响。所以,在任意时刻,对于一个CPU来说,只需要有当前进程的页表,就可以实现其虚拟地址到物理地址的转化。 .内核空间到物理内存的映射     内核空间对所有的进程都是共享的,其中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据,不管是内核程序还是用户程序,它们被编译和连接以后,所形成的指令和符号地址都是虚地址(参见2.5节中的例子),而不是物理内存中的物理地址。
虽然内核空间占据了每个虚拟空间中的最高1GB字节,但映射到物理内存却总是从最低地址(0x00000000)开始的,如图4.2所示,之所以这么规定,是为了在内核空间与物理内存之间建立简单的线性映射关系。其中,3GB0xC0000000)就是物理地址与虚拟地址之间的位移量,在Linux代码中就叫做PAGE_OFFSET 第五节:进程与线程中堆栈   终于 讲到我们文章的 、标题所要表达的意思了。回归正题!   好了有了之前所介绍的这些概念和例子之后接下的内容 应该不会很难理解。接下来的要讲解的更偏向于我们实际工作中遇到的问题,以及这些问题的解决思路的探讨!