跳过正文

栈帧/栈回溯

·895 字·5 分钟
jiladahe1997
作者
jiladahe1997
To see the world as it is, and to love it

堆栈虽然是常用的内容,但是一般都浅尝辄止,很多人都只理解到:

  • 栈上存放的是局部变量等
  • 堆上存放的是申请的变量

实际上,堆栈除了上述特点以外,还有一些很重要的特性。

  • 栈上还保存了函数的返回地址
  • 栈上还(有可能,不使用寄存器传参的话)保存了传递的参数
  • 局部的const 变变量
  • 发生中断时,会生成并使用中断栈的概念。
  • linux中还区分了用户空间栈和内核栈



栈的结构
#

int foo() {
    return 1;
}

int sub(int a,int b) {
    foo();
    return a-b;
}

int add(int a, int b, int c, int d, int e, int f, int g,  int h, int i, int j,int k, int l, 
        int m, int n) {
    int aa = m+n;
    sub(a,b);
    return 2;
}

int main() {
    int a=1;
    int b=2;
    return add(1, 2,3,4,5,6,7,8,9,10,11,12,13,14);
}
foo:
        str     fp, [sp, #-4]!
        add     fp, sp, #0
        mov     r3, #1
        mov     r0, r3
        add     sp, fp, #0
        ldr     fp, [sp], #4
        bx      lr
sub:
        push    {fp, lr}
        add     fp, sp, #4
        sub     sp, sp, #8
        str     r0, [fp, #-8]
        str     r1, [fp, #-12]
        bl      foo
        ldr     r2, [fp, #-8]
        ldr     r3, [fp, #-12]
        sub     r3, r2, r3
        mov     r0, r3
        sub     sp, fp, #4
        pop     {fp, lr}
        bx      lr
add:
        push    {fp, lr}
        add     fp, sp, #4
        sub     sp, sp, #24
        str     r0, [fp, #-16]
        str     r1, [fp, #-20]
        str     r2, [fp, #-24]
        str     r3, [fp, #-28]
        ldr     r2, [fp, #36]
        ldr     r3, [fp, #40]
        add     r3, r2, r3
        str     r3, [fp, #-8]
        ldr     r1, [fp, #-20]
        ldr     r0, [fp, #-16]
        bl      sub
        mov     r3, #2
        mov     r0, r3
        sub     sp, fp, #4
        pop     {fp, lr}
        bx      lr
main:
        push    {fp, lr}
        add     fp, sp, #4
        sub     sp, sp, #48
        mov     r3, #1
        str     r3, [fp, #-8]
        mov     r3, #2
        str     r3, [fp, #-12]
        mov     r3, #14
        str     r3, [sp, #36]
        mov     r3, #13
        str     r3, [sp, #32]
        mov     r3, #12
        str     r3, [sp, #28]
        mov     r3, #11
        str     r3, [sp, #24]
        mov     r3, #10
        str     r3, [sp, #20]
        mov     r3, #9
        str     r3, [sp, #16]
        mov     r3, #8
        str     r3, [sp, #12]
        mov     r3, #7
        str     r3, [sp, #8]
        mov     r3, #6
        str     r3, [sp, #4]
        mov     r3, #5
        str     r3, [sp]
        mov     r3, #4
        mov     r2, #3
        mov     r1, #2
        mov     r0, #1
        bl      add
        mov     r3, r0
        mov     r0, r3
        sub     sp, fp, #4
        pop     {fp, lr}
        bx      lr



栈帧 Frame Pointer / 堆栈跟踪
#

参考文档:

  • MCU学习笔记/11gcc 单片机开发.md — # 发生函数调用时,栈如何变化和工作
  • 参考:https://alexkalmuk.medium.com/how-stack-trace-on-arm-works-5634b35ddca1
  • 参考:https://www.brendangregg.com/blog/2024-03-17/the-return-of-the-frame-pointers.html

正文:

栈回溯的不外乎其原理就是两种:第一种是基于硬件的栈回溯,这种要求占用一个寄存器frame pointer指针,实时的保存当前栈顶的位置,且规定当前栈顶一定存放了上一个栈的fp和lr。这种原理对硬件有要求,且对栈的结构也有要求。例如arm64的aaps规范就规定了栈的结构。

第二种是基于软件的栈回溯,编译程序的同时生成对应的栈调试信息,比如unwind table、DWARF或者ORC(x86架构)等,当发生异常时,需要软件去这些信息中读取调试信息,确定栈的大小和返回地址,并进行栈回溯。 值得一提的是,这种方式需要每个栈都必须读取一次ram,所以并不适用于离线分析。

下面以linux中arm32的栈回溯代码举例:

dump_stack //kernel
->show_stack //arch/arm/kernel/traps.c
-->dump_backtrace   //这里根据有没有生成unwind table,走不同的编译分支
--->unwind_backtrace
---->unwind_frame   //到unwind table中寻找

再以arm64的栈回溯代码举例:

dump_stack //kernel
->show_stack //arch/arm64/kernel/stacktrace.c
-->dump_backtrace
--->arch_stack_walk
---->kunwind_stack_walk
----->do_kunwind
------>kunwind_next
------->unwind_next_frame_record

	state->fp = READ_ONCE(*(unsigned long *)(fp));
	state->pc = READ_ONCE(*(unsigned long *)(fp + 8));



示例:在MCU中利用FP指针进行栈回溯
#

为了使得发生异常时,可以追溯调用栈,GNU提出了栈帧 Frame Pointer的概念。 当函数调用和返回时,按以下流程工作:

调用:

  1. 保存当前的old_LR、old_FP。 使得新的new_FP指向此位置。
  2. 向上移动SP,留出空间
  3. 赋值等,计算

返回

  1. 检查当前的 new_FP, 其位置应该指向 old_LR、old_FP
  2. 将old_LR加载到PC;将old_FP加载到FP;将new_FP+8加载到SP。
  3. 返回上一个函数执行

如果按照上述流程工作,当发生异常时,我们可以按照返回的流程去反向查找出调用栈。

也可以不按上述流程工作,那就需要编译器在汇编代码中,手动计算SP、LR的位置,是可以的,只是一旦出错后,将无法查找调用栈。 -fomit-frame-pointer 编译选项可以关闭栈帧。


示例
#

我们随便找一个单片机工程,在编译选项中增加 -fno-omit-frame-pointer -mapcs-frame

注:这里的apcs指 ARM Procedure Call Standard


然后查看反汇编文件:

arm-none-eabi-objdump -S -g -x xxx.elf > objdump.output

可以看到每个函数的开头,都增加了

push {.... fp ip lr pc}

这样的代码。说明Frame pointer开启成功了

然后就是栈的追溯:

common_exception_handle:
  /*出现错误时,需要知晓的信息:1.错误代码行LR 2.对应错误参数:R0~R13. 对应的调用栈SP*/
  /*编译选项中加入-mapcs-frame可以强制使用frame pointer进行栈回溯
  /*保存R0-R12,LR到栈上.*/
  STMFD
  SP!, {R0-R12}

  /* 读取CPSR */
  MRS R0, CPSR

  /*判断Ibit*/
  AND R1, R0, #0x20
  CMP R1, #0
  BEQ cpsr_is_arm
  ADD LR, LR ,4/* thumb指令集PC位置需要+4*/
  cpsr_is_arm:
  PUSH {LR}

  /*切换CPSR,读取SP*/
  ORR R1, R0, #0x1F
  MSR CPSR, R1
  MOV R1, SP
  MSR CPSR, 
  PUSH {R1}

  PUSH {fp}
  MRS R0, SPSR
  PUSH {R0}

/*调用函数的时候传递一个参数*/
MOV R0, SP
b dabort_main

struct abort_info {
  uint32 SPSR;
  uint32 FP;
  uint32 SP;
  uint32 LR;
  uint32 R0;
  uint32 R1;
  uint32 R2;
  uint32 R3;
  uint32 R4;
  uint32 R5;
  uint32 R6;
  uint32 R7;
  uint32 R8;
  uint32 R9;
  uint32 R10;
  uint32 R11;
  uint32 R12;
};
struct frame_pointer {
  uint32 pre_fp;
  uint32 ip;
  uint32 LR;
  uint32 PC;
}

void.dabort_main (void.* sp){
/* 栈上保存的内容为(由高地址到低地址)
RO~R12
LR
SP
FP
SPSR*/
hprintf(TSENV, "DATA ABORT ERROR! sp at 0x%x\n", sp) 
hprintf(TSENV, "SPSR = 0x%x\n", ((struct abort_info*)sp)->SPSR);
hprintf(TSENV, "FP.=0x%x\n", ((struct abort_info.*)sp)->FP);
hprintf(TSENV, "SP.= 0x%x\n", ((struct abort_info *)sp)->SP)
hprintf(TSENV, "R0.= 0x%x\n", ((struct abort_info *)sp)->R0)
...
...


hprintf(TSENV, "Start back trace\n");
/*@TODO
@WARNING!!!
gcc.version-9.2.1.20191025 (release).[ARM/arm-9-branch.r[evision 277599] (GNU Tools for Arm Embe
不同gcc版本,fp的位置不同,此版本fp的位置位于sp-1个byte
所以这里需要再-3个byte
4803464: ela0c00d
4803468: e92dd8f0
480346c: e24cb004
mov ip, sp
push
{r4, r5, r6, r7, fp, ip, ip, lr,.pc}
sub fp, ip, .#4 */

#define.FP_OFFSET_GCC_QUIRKS 3
uint32 * fp_ptr=(uint32* )((struct abort_info.*)sp)->FP;
fp_ptr = fp_ptr - FP_OFFSET_GCC_QUIRKS;
struct frame_pointer* fp = fp_ptr;
#define.VALID_FP_ADDRESS_LOW.0x48000000
#define.VALID FP_ADDRESS_HI 0X4880000
for(;;).{
  if((fp > VALID_FP_ADDRESS_HI) || (fp < VALID_FP_ADDRESS_LOW))
      break;
  hprintf(TSENV, "fp:0x%x, prep->fp:0x%x ip: 0x%x, LR: 0xx, PC: 0x%x\n", fp, fp->pre_fp, fp->ip
  fp = fp->pre_fp;
while(1);






附录A:GDB中的backtrace如何工作
#

下载arm gcc源码:https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads

总结:GDB针对不同的架构有不同的栈回溯方式,通常是Framepointer的方式。 但是对于32bit arm,采用的是逐行进行指令分析的方式。

add_com ("backtrace", backtrace_command...)  //binutils-gdb/gdb/stack.c
->backtrace_command_1()

for (fi = trailing; fi && count--; fi = get_prev_frame (fi))
{
    QUIT;

    /* Don't use print_stack_frame; if an error() occurs it probably
        means further attempts to backtrace would fail (on the other
        hand, perhaps the code does or could be fixed to make sure
        the frame->prev field gets set to NULL in that case).  */

    print_frame_info (fp_opts, fi, 1, LOCATION, 1, 0);
    if ((flags & PRINT_LOCALS) != 0)
    print_frame_local_vars (fi, false, NULL, NULL, 1, gdb_stdout);

    /* Save the last frame to check for error conditions.  */
    trailing = fi;
}

-->get_prev_frame()
--->get_prev_frame_always_1()
---->frame_register_unwind_location()
----->frame_register_unwind()
------>frame_unwind_register_value()
------->arm_prologue_prev_register() //跟架构强相关binutils-gdb/gdb/arm-tdep.c
-------->arm_make_prologue_cache()
--------->arm_scan_prologue()
---------->arm_analyze_prologue() //解析指令,反推SP FP LR


  for (current_pc = prologue_start;
       current_pc < prologue_end;
       current_pc += 4)
    {
      uint32_t insn = insn_reader.read (current_pc, byte_order_for_code);

      if (insn == 0xe1a0c00d)		/* mov ip, sp */
	{
	  regs[ARM_IP_REGNUM] = regs[ARM_SP_REGNUM];
	  continue;
	}

      else if ((insn & 0xffff0fff) == 0xe52d0004)	/* str Rd,
							   [sp, #-4]! */
	{
	  if (stack.store_would_trash (regs[ARM_SP_REGNUM]))
	    break;
	  regs[ARM_SP_REGNUM] = pv_add_constant (regs[ARM_SP_REGNUM], -4);
	  stack.store (regs[ARM_SP_REGNUM], 4,
		       regs[bits (insn, 12, 15)]);
	  continue;
	}
      else if ((insn & 0xffff0000) == 0xe92d0000)
	/* stmfd sp!, {..., fp, ip, lr, pc}
	   or
	   stmfd sp!, {a1, a2, a3, a4}  */
	{




附录B CPU栈初始化
#

在CPU启动时,必须要尽可能早的指定栈在内存中的位置,之后CPU会自动的使用此位置来作为栈。

对于cortex-m0(armv6) 和 cortex-M7(armv7)系列,通过vector向量表中的offset+0B~4B来指定栈的位置。如下图所示: