Open cisen opened 3 years ago
最近在某个RTOS上遇到一个系统BUG,几经折腾,终于将其斩于马下。结局美好,过程却很曲折,在分析定位问题的时候,顺便把ARM上C函数调用stack frame机制捋了一遍,记录并分享一下。
栈:
1)从数据结构的角度来理解,栈是一种描述先进后出的数据结构;
2)从进程的内存空间角度来理解,栈是一种特殊的内存段,用于存放局部变量、函数参数、返回值等;
第一种角度,用来描述本身的特性,第二种角度,是将这种数据结构的特性用于实际的内存空间中。
栈帧:每个进程都会有自己的栈空间,而进程中的各个函数也会维护自己本身的一个栈的区域,这个区域就是栈帧。那么一个函数的栈帧的区域是如何来界定的呢?当然,我首先会普及ARM的几个特殊寄存器功能。
R11:frame pointer,FP寄存器
R12:IP寄存器,用于暂存SP
R13:stack pointer,SP寄存器
R14:link register,LR寄存器
R15:PC寄存器
而在ARM上,函数的栈帧是由SP寄存器和FP寄存器来界定的,相信你应该见过下边这张比较经典的图了:
上图描述的是main函数调用func1函数的栈帧情况,从图可知,当main函数调用func1函数时,func1函数会先将PC、LR、SP、FP四个寄存器压到栈上边,其中SP和FP的值分别指向main函数栈帧的两个边界,LR的值保 存的是func1调用结束之后的返回值,PC值表示的是当前执行到的指令地址,放置的是进入func1后的指令地址。紧接着就会在栈上分配一片区域,用于放置局部变量等。
如果func1中还调用了func2子函数,那么也会为func2创建一个栈帧,并且func2的SP和FP会指向func1栈帧的两个边界。这样当函数返回的时候,参数进行出栈,也能找到Caller函数,这个也就是backtrace的原理了。
反汇编分析某段代码,如下图所示:
红色部分,表明进入到函数时先将几个特殊的寄存器压栈
黄色部分,sub sp, sp, #16,表明开辟一个4 x 32bit大小的栈区域
蓝色部分,将传入的参数压栈,在ARM ATPCS中规定,寄存器R0-R3用来传参
绿色部分,调用子函数
那么,我们顺道看看子函数的栈帧区域吧:
从图中可以看出,机制是一样的,当最终queue_push函数调用结束后,栈上的数据进行出栈,根据fp和ip,便能找到workflow_gather_input函数的栈帧了。
当然,并不是所有函数调用都需要先push {fp, ip, lr, pc},当子函数调用过程中,并不会去改变这些值的时候,就不需要压栈,说白了,压栈的目的就是为了在使用完的时候能恢复原来的状态。我会再次提供一个例子:
strlen函数中没有子函数的调用,所以进入函数后,直接就在栈上分配4 * 32bit大小的区域了。
栈里边能分析出每个参数的值,以及函数调用时的传参,这对分析与定位问题很有帮助。成熟的系统可能会提供一堆工具来dump stack,并去分析调用关系,但是在RTOS上,很多却并不完善,需要一定的低层知识去分析才能解决问题。
介绍 arm平台的调用栈与x86平台的调用栈大致相同,稍微有些区别,主要在于栈帧的压栈内容和传参方式不同。在arm平台的不同程序,采用的编译选项不同,程序运行期间的栈帧也会不同。有些工具在对arm的调用栈回溯时,可能会遇到无法回溯的情况。例如gdb在使用bt查看core dump文件调用栈时,有时会出现Backtrace stoped的情况,有可能就是栈空间的压栈顺序导致的。当工具无法回溯时,就需要人工结合汇编代码对栈进行回溯,或者使用unwind进行回溯。
arm栈帧结构 通常情况下,arm的调用栈大致结构与x86相同,都是从高地址向低地址扩张。上图是其中一种内存分布。
pc, lr, sp, fp是处理器的寄存器,其含义如下:
pc, program counter,程序计数器。程序当前运行的指令会放入到pc寄存器中 fp, 即frame pointer,帧指针。通常指向一个函数的栈帧底部,表示一个函数栈的开始位置。 sp, stack pointer,栈顶指针。指向当前栈空间的顶部位置,当进行push和pop时会一起移动。 lr, link register。在进行函数调用时,会将函数返回后要执行的下一条指令放入lr中,对应x86架构下的返回地址。 调用栈从高地址向低地址增长,当函数调用时,分别将分别将pc, lr, ip和 fp寄存器压入栈中,然后移动sp指针,为当前程序开辟栈空间。
arm官方手册描述如下:
一个arm程序,在任一时刻都存在十五个通用寄存器,这取决于当前的处理器模式。 它们分别是 r0-r12、sp、lr。 sp(或 r13)是堆栈指针。 C 和 C++ 编译器始终将 sp 用作堆栈指针。 在 Thumb-2 中,sp 被严格定义为堆栈指针,因此许多对堆栈操作无用而又使用了 sp 的指令会产生不可预测的结果。 建议您不要将 sp 用作通用寄存器。 在用户模式下,lr(或 r14)用作链接寄存器 (lr),用于存储调用子例程时的返回地址。 如果返回地址存储在堆栈上,则也可将 r14 用作通用寄存器。 在异常处理模式下,lr 存放异常的返回地址;如果在一个异常内执行了子例程调用,则 lr 存放子例程的返回地址。如果返回地址存储在堆栈上,则可将 lr 用作通用寄存器。
除了官方手册中描述的sp,lr寄存器,通常r12还会作为fp寄存器。fp寄存器对于程序的运行没有帮助,主要用于对栈帧的回溯。因为sp时刻指向的栈顶,通过fp得知上一个栈帧的起始位置。
上图的调用栈对应的汇编代码如下。
8514行将当前的sp保存在ip中(ip只是个通用寄存器,用来在函数间分析和调用时暂存数据,通常为r12);
8518行将4个寄存器从右向左依次压栈。
851c行将保存的ip减4,得到当前被调用函数的fp地址,即指向栈里的pc位置。
8520行将sp减8,为栈空间开辟出8个字节的大小,用于存放局部便令。
00008514
Generate a stack frame that is compliant with the ARM Procedure Call Standard for all functions, even if this is not strictly necessary for correct execution of the code. Specifying -fomit-frame-pointer with this option causes the stack frames not to be generated for leaf functions. The default is -mno-apcs-frame. This option is deprecated.
也就是说,该编译选项会产生(push {fp, ip, lr, pc}),保证栈帧的格式。如果没有-mapcs-frame,则不保证帧格式和当前帧格式,GCC生成的指令可能会发生各种变化。在AAPCS发布之后[附录1],1993年的APCS就已经太旧了,所以 在gcc5.0之后,该选项已经被废弃。gcc5.0的更新记录写到:
The options -mapcs, -mapcs-frame, -mtpcs-frame and -mtpcs-leaf-frame which are only applicable to the old ABI have been deprecated. 至于该参数在将来是否会被gcc移除,那就不知道了。
将第一节中的程序重新使用默认编译选项,用4.7版本的gcc编译,结果如下。这时,fp还在,调用栈push了fp和lr到栈空间,新的fp指向了lr在栈中的位置。
00008514
0000854c
000103c8
000103ec
下边的内容是一段core dump中的寄存器和调用栈,本节将对这段内容进行回溯。
Reg: r9, Val = 0xf7578000; Reg: r10, Val = 0x00000001;
Reg: fp, Val = 0x827d3104; Reg: ip, Val = 0xf7578ae0;
Reg: sp, Val = 0x827d30e0; Reg: lr, Val = 0xf7549990;
Reg: pc, Val = 0xf7548c20; Reg: cpsr, Val = 0x60000210;
0x827d30e0: 0x00000031 0x827d31a0 0x00000001 0xd5dff060
0x827d30f0: 0xd5e0e6b1 0xd5dec134 0xf7578000 0xf7577c40
0x827d3100: 0x827d313c 0xf7549990
0x827d3140: 0x00000000 0xd5dec104 0xf7568514 0x00000002
0x827d3150: 0xd5dec104 0xf7577c40 0xf7577c38 0xd5de9224
0x827d3160: 0x827d31a0 0xf757a084 0xf7577c40 0xd5df6dd4
0x827d3170: 0x827d3194 0x00000001 0xd5e0e678 0xd5dec104
0x827d3180: 0xd5de9224 0xf7568548 0x00000000 0xf7568550
当前sp地址为0x827d30e0,fp地址为0x827d3104,从而得知当前函数frame0的栈帧。fp指向的地址0x827d3104为frame1的lr,0x827d3100为上一个栈帧的fp。
0x827d30e0: 0x00000031 0x827d31a0 0x00000001 0xd5dff060
0x827d30f0: 0xd5e0e6b1 0xd5dec134 0xf7578000 0xf7577c40
0x827d3100: 0x827d313c(fp) 0xf7549990(lr)
从frame0的fp地址0x827d313c可知,frame1的调用栈起始地址,去掉frame0的内容,得到frame1的栈帧。
0x827d312c 0xf7530c14
0x827d3110: 0xd5dff060 0x0000002c 0xd5e0e6b1 0xd5e0e6b1
0x827d3120: 0x00000001 0xd5e0e6b1 0xd5dff060 0xd5dec134
0x827d3130: 0xf7578000 0xf7577c40 0x827d3194(fp) 0xf754ad0c(lr)
依次类推,依次得到frame2、frame3...的栈帧。
当汇编代码的函数调用使用push {fp, ip, lr, pc}时,则上一个栈帧的fp2在当前栈帧的(fp - #4)位置。栈帧的回溯要结合程序的汇编代码具体分析,有可能程序并不使用fp指针,也有可能栈中根本没有保存fp。
unwind方法回溯 TODO
附录1-函数调用标准缩略语 PCS Procedure Call Standard. AAPCS Procedure Call Standard for the ARM Architecture (this standard). APCS ARM Procedure Call Standard (obsolete). TPCS Thumb Procedure Call Standard (obsolete). ATPCS ARM-Thumb Procedure Call Standard (precursor to this standar 参考资料 ARM 体系结构概述 Procedure Call Standard for the ARM® Architecture GCC 5 Release Series 转载于:https://www.cnblogs.com/your2b/p/9698216.html
我们往往在进行嵌入式开发的过程中,需要借助一些调试手段进行相关调试,比如在调试stm32的时候,可以在keil中利用jtag或者stlink进行硬件上的仿真与调试,一些高频的arm芯片也会使用jtag之类的硬件调试工具,还有trace32等等,但是这些往往需要借助一些硬件工具进行分析。当然,我们可以进行软件层面的分析。定位问题的方式通常有以下三点:
1.通过串口打印信息进行业务逻辑的梳理,结合代码设计进行分析
2.在程序死机的时候,输出的函数调用栈关系进行分析,结合符号文件进行跟踪定位
3.在程序死机时输出内存镜像,利用gdb还原死机现场
一般来讲,这三种方法都有一定的优缺点。
第一种靠串口输出信息一般比较有限,而且对于有些情况,串口输出没办法进行准确的定位,但是比较方便,实现起来比较容易。
第二种可以查看到函数调用的关系,根据这些调用关系,就可以非常方便的跟踪到出问题的地方,然后进行一定的跟踪。但是需要理解寄存和汇编之类的知识。
第三种的信息最全,调用关系和参数信息都有,但是对工具链和系统都提出了一些要求。往往在嵌入式开发过程中,涉及到业务逻辑非常复杂的时候可以进行分析。但是一般的情况不会用到coredump。
第一种可以不用讲,现在主要讲一下backtrace。
backtrace就是回溯堆栈,简单的说就是可以列出当前函数调用关系。在理解backtrace之前我们需要理解一下函数执行过程的中的压栈过程。
ARM微处理器共有37个寄存器,其中31个为通用寄存器,6个为状态寄存器。但是往往这些寄存器都不能同时被访问,需要在特定的模式下访问特定的指令。
但在任何时候,通用寄存器R0~R15、一个或两个状态寄存器都是可访问的。有三个特殊的通用寄存器:R13:在ARM指令中常用作堆栈指针SPR14:也称作子程序连接寄存器(Subroutine Link Register)即连接寄存器LRR15:也称作程序计数器PC
还有一个寄存器
R11:栈基址FP
THUMB2下为R7。
当函数main调用func1的时候其栈的过程如上图所示,每个函数都有自己的栈空间,这一部分我们称为栈帧,在函数被调用的时候创建,在函数返回后销毁。
其中我们看到这其中涉及到四个比较关键的寄存器:PC、LR、SP、FP。需要注意的是,每个栈帧中的PC、LR、SP、FP都是寄存器的历史值,而不是当前值。
PC寄存器和LR寄存器均指向代码段,PC表示当前的代码指向到何处,LR表示当前函数返回后要到哪里去继续执行。
SP和FP用于维护函数的栈空间,其中SP指向栈顶,FP指向上一个函数栈帧的栈顶。
如上图所示
依次为当前函数指针PC、返回指针LR、栈指针SP、栈基址FP、传入参数个数及指针、本地变量和临时变量。如果函数准备调用另一个函数,跳转之前临时变量区先要保存另一个函数的参数。
在栈回溯的过程中,我们主要是利用的是这个FP寄存器进行回溯,因为根据FP寄存器就可以找到下一个FP寄存器的栈底,获得PC指针,然后固定偏移,又可以回溯到上个PC指针,这样回溯下去,然后就可以完全的跟踪到函数的运行过程了。然后利用addr2line工具,就可以详细跟踪到函数的执行过程了。
当程序出现异常或者死机的时候,我们可以读取当前寄存器的状态,找到当前pc指针的情况,但是这些往往还不能说明问题,我们有时需要跟踪函数的执行过程。
栈的回溯又分为两种:APCS(ARM Procedure Call Standard)与unwind。
栈回溯的实现依赖编译器的特性,与特定的平台相关。以linux内核实现arm栈回溯为例, 通过向gcc传递选项-mapcs或-funwind-tables,可选择APCS或unwind的任一方 式实现栈回溯。
gcc的有些编译优化命令,会让FP寄存器优化掉,比如-fomit-frame-pointer这个优化会让fp寄存器节省下来给其他的地方使用。所以要充分考虑这些问题。
ARM过程调用标准规范了arm寄存器的使用、过程调用时 出栈和入栈的约定。如下图示意。
栈回溯中输出的寄存器的值是入栈时保存起来的寄存器值。它通过解析指令码得到哪个 寄存器压栈了,在栈中的位置。
如果编译器遵循APCS,形成结构化的函数调用栈,就可以解析当前栈(callee)结构,从 而得到调用栈(caller)的结构,这样就输出了整个回溯栈。
对于APCS来说,优点是分析起来比较简单,跟踪起来也可以很容易。缺点就是指令过多,栈消耗大,占用的寄存器也过多,比如每次调用 都必须将r11,r12,lr,pc入栈。为了解决这个问题,提出了第二种方案:
使用unwind就能避免这些问题,生产指令的效率要有用的多。unwind是最新的编译器(>gcc-4.5)为arm支持的新特性。它的原理是记录每个函数的入栈指令(一般比APCS的入栈要少的多)到特殊的段.ARM.unwind_idx .ARM.unwind_tab。
所以如果我们要使用unwind,就必须在链接文件中定义这个段
.ARM.exidx : {
__exidx_start = .;
*(.ARM.exidx* .gnu.linkonce.armexidx.*)
__exidx_end = .;
}
我们也可以通过arm-none-eabi-readelf -u xxxxx.elf查看其内容。
以上面两个为例,set_date的函数的地址是0xc007c4a0,而set_time的函数的地址是0xc00a0fb0。
而r11也就是fp地址在unwind_tab段中,也就是位于0xc00a0fa4地址处。
回溯时根据pc值到段中得到对应的编码,解析这些编码计算出lr在栈中的位置,进而计算得到调用者的执行地址。
一般来说,我们使用unwind优势比使用apcs更好,因为采用apcs时,会产生更多的代码指令,对性能有影响,但是使用unwind方式只会产生一个额外的段空间,并不会影响性能,所以大多数情况下,使用unwind更加有利。
unwind回溯的过程可以总结为三部分:
1.根据pc找到函数unwind的段内存地址
2.根据unwind段中信息找到指令相关的编码数据
3.根据入栈地址,分析函数上一级的栈底保存的sp和lr。
栈回溯的过程中,往往需要符号表来进行操作,此时需要开启-mpoke-function-name这个编译选项。
使用这个选项编译出的二进制程序中可以包含 C 语言函数名称的信息,以方便函数调用链回溯时记录信息的可读性。
比如在Linux中,系统死机后,可以打印出栈的地址和函数的名称,根据这个进行回溯操作就可以进行使用了。
基本原理就是加上-mpoke-function-name后,在每段代码段后面,都会附加一个函数的符号,我们需要使用的时候,就根据函数的pc指针,然后找到相关的偏移量,之后将这个代码段的符号获取到了。
对于arm32体系架构的backtrace基本原理可以参考如上的描述,其中最核心的部分是每个函数的栈中寄存器地址指向的是上个函数的地址,所以利用这个特性,就可以一级一级的跟踪下去,从而实现栈的回溯功能。这样我们在分析和定位问题的时候,就会更加的高效。
总结