,linux后端程序员必备技能,大家都知道函数调用是通过栈实现的,函数的局部变量存放在栈中。但是栈的实现细节可能不太清楚。本文将介绍函数栈在Linux平台下是如何实现的。有的同学可能会觉得没必要理解的那么深,其实不然。根据我们多年的经验,了解系统深层次的原理,对于分析疑难问题很有帮助。图0函数栈正如熟悉抓包是解决网络通信问题的高级武器一样,熟悉函数调用栈也是分析程序内存问题的高级武器。本文以Linux64位操作系统下的C语言开发为例,介绍应用程序调用栈的实现原理,并通过实例和GDB工具分析某程序的调用栈内容。在介绍具体的调用栈之前,先介绍一些基础知识,这是理解后续函数调用栈的基础。X86CPU的寄存器CPU的寄存器是需要了解的基础知识,因为在X64系统中函数的参数都是通过寄存器传递的。图1是X86CPU寄存器列表及其功能的简要说明。图1IntelX86CPU寄存器的用途我们知道IntelCPU在设计时是向前兼容的,即在老一代CPU上编译的程序可以在新一代CPU上运行。为了保证兼容性,新一代的CPU保留了老一代寄存器的别名。以16位寄存器AX为例,AL表示低8位,AH表示高8位。32位CPU出现后,32位寄存器用一个名为EAX的寄存器表示,AX仍然保留。以此类推,RAX代表一个64位的寄存器。图2不同寄存器名称的地址空间操作系统通过虚拟内存为所有应用程序提供统一的内存映射地址。如图3所示,从上到下分别是用户栈、共享库内存、运行时堆和代码段。当然,这是一个大概的细分,实际的细分可能比这稍微复杂一些,但整体格局并没有太大的变化。图3应用程序的地址空间从图中可以看出,用户栈是从上到下增长的。即用户栈会先占用高地址空间,再占用低地址空间。目前我们可以有个大概的了解,后面会详细分析用户栈的细节。函数调用和汇编指令为了了解函数调用堆栈的细节,有必要了解函数调用在汇编程序中的实现。函数调用主要分为两部分,一是调用,二是返回。在汇编语言中,函数调用是通过call指令完成的,返回是通过ret指令完成的。汇编语言中的call指令相当于执行了两步操作,即:1)将当前IP或CS和IP压入栈中;2)跳转,类似于jmp指令。同样,ret指令也分为2步,即:1)将栈中的地址弹出到IP寄存器;2)跳转执行后续指令。这基本上就是函数调用的原理。函数调用除了代码之间的跳转,往往还需要传递一个参数,处理完成后可能会有返回值。这些数据的传输是通过寄存器进行的。函数调用前,参数存放在上述寄存器中,函数返回前,返回结果存放在RAX寄存器(32位系统为EAX)中。另外一个重要的知识点是函数调用过程中与栈相关的寄存器RSP和RBP。这两个寄存器主要实现栈位置的记录。具体作用如下:RSP:栈指针寄存器(reextendedstackpointer),存放的是一个始终指向系统栈顶栈帧顶部的指针。RBP:基地址指针寄存器(reextendedbasepointer),存放一个始终指向系统栈顶层栈帧底部的指针。寄存器的名称与体系结构有关。本文是64位系统,所以寄存器为RSP和RBP。如果是32位系统,寄存器的名字就是ESP和EBP。应用程序调用栈我们整体来看一下函数调用栈的主要内容,如图4所示,函数栈主要包括函数参数表、局部变量表、栈基地址和函数退货地址。这里的栈基地址就是上一个栈帧的基地址,因为这个函数中需要用这个基地址来访问栈的内容,所以需要把上一个栈帧中的基地址压上去首先是堆栈。图4函数调用栈概览为了便于理解,我们以一个具体的程序为例。这个程序很简单,主要是模拟多个函数的函数调用关系和参数传递。另外,在函数func_2中定义了两个形参来模拟传递多个参数的过程。图5函数栈汇编分析在这个例子中,main函数调用了func_1函数。我们从main函数开始分析,可以先看右边的C语言代码。首先是函数参数的准备过程。main函数调用func_1时,传入的参数依次为1、2、3、4+g,需要计算最后一个参数。根据红框的虚线,我们可以看到对应的汇编器。在汇编器中,先处理最后一个参数,再处理倒数第二个,以此类推(函数参数的处理顺序是日常开发中需要注意的重点)。同时,我们看到存放参数的寄存器的名字和前面的是一致的。准备好参数后,调用func_1函数,也就是汇编语言中的callfunc_1行。虽然它只是一行汇编指令,但实际上它内部做了一些事情。这个我们在上一篇介绍call指令的时候介绍过。可以参考上一篇文章。接着进入func_1函数的处理逻辑。首先是pushq%rbp汇编器,这条指令的作用是将RBP压入函数栈。这句话压栈,更新后的RBP值(moveq%rsp,%rbp)就是这个函数的栈帧头。后续访问这个堆栈帧的内容是通过帧头(RBP)来执行的。接下来就是参数入栈和初始化局部变量的过程。具体分配可以参考图5中的绿框和红框。在函数中完成运算后,***将运算结果放入寄存器EAX中,然后调用指令leave和ret。这里需要说明的是leave指令,相当于下面两条汇编指令。大家可以对比一下函数入口的汇编指令,其实两者是对称的。leave命令将此帧的堆栈基地址分配给堆栈指针(图6中的步骤2),然后将内容弹出到RBP中(图6中的步骤3)。其实RBP指向的是上一帧(caller)的栈帧,这是一个恢复过程。movl%ebp%esppopl%ebp图6函数返回示意图这样,函数返回后,寄存器RBP和RSP从被调用者的栈帧切换到调用者的栈帧。通过GDB分析函数调用栈以上就是通过反汇编分析函数的调用栈和栈帧。我们还可以通过gdb动态分析函数栈和栈帧的使用情况。我们还是以main函数调用func_1函数为例来分析。这里我们在函数func_1的入口设置了一个单点,然后运行程序,程序停在断点处。如图7所示,我们一步一步的实现就是函数栈的变化过程。具体细节这里就不赘述了,大家可以实际操作一下。图7函数栈的变化过程本文的目的是让大家对函数调用栈有一个整体的认识,以便以后对程序的疑难杂症有更多的解决办法。因为在实际生产环境中有很多栈相关的问题,比如局部变量过多导致栈溢出,或者踩内存问题导致栈损坏等等。因此,了解了函数栈的原理之后,你就会遇到所谓的莫名其妙的问题会有新的想法。往往很多问题并不是问题本身莫名其妙,而是我们的知识储备不够,感觉莫名其妙而已。
