首页
社区
课程
招聘
[原创] AARCH64平台的栈回溯
发表于: 2021-12-25 14:22 30122

[原创] AARCH64平台的栈回溯

2021-12-25 14:22
30122

版权声明:本文为CSDN博主「ashimida@」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/lidan113lidan/article/details/121801335

更多内容可关注微信公众号     

1. 栈顶/栈底[1]:  栈中最后一个push,第一个被pop的位置是栈顶; 栈中最后一个被pop,且pop后当前栈为空的位置是栈底;

2. Current Function Frame Address[2]:  当前函数栈帧, 在aarch64中是当前函数执行完prologue后的栈顶地址, 其可以通过__builtin_frame_address(0)函数获取.

3. Canonical Frame Address(CFA)[3]: 标准/规范栈帧地址, 在aarch64中是当前函数父函数的栈顶地址, 在DWARF中对其定义如下:

"Call Frame"是在栈中分配的一段内存(栈帧指的是一段内存), 其可以由栈中的一个地址来表示,这个地址被称为标准栈帧地址(CFA), 通常来说标准栈帧地址CFA=caller调用callee之前的sp(stack pointer), 这个地址也就是caller函数的栈顶地址,其和当前函数的Function Frame Address可能是不同的, 在gcc中CFA可以通过__builtin_dwarf_cfa函数获取(而不是__builtin_frame_address函数,后者获取的是Function Frame Address,也就是Callee的栈顶地址).

4. prologue/epilogue: 编译器为每个函数生成的默认的函数入口/出口指令序列.

5. 最后一级栈帧/上一级栈帧: 为便于表述,这里定义对栈帧级别的描述,如 A => B => C (A中调用B,B中调用C), 在函数C中发生了栈回溯,那么后续称C函数的栈帧为最后一级栈帧, B为C的上一级栈帧, A的栈帧为函数B的上一级栈帧.

  这里仅以aarch64为例, 基于fp的栈回溯会在函数的每一个栈帧中都将其父函数的栈顶保存到当前函数栈顶位置的内存中, 并同时设置栈帧寄存器fp,整个过程可描述如下:
  1. 函数入口时其fp指向CFA, 函数的prologue负责:
     1) 为当前函数预留栈空间, 同时将父函数的fp保存到当前函数栈顶
     2) 设置fp指向当前函数栈顶
  2. 在当前函数执行过程中,fp始终指向当前函数栈顶(也就是函数栈帧 Function Frame Address)
  3. 函数返回前的epilogue负责:
     1) 从函数栈顶获取父函数的栈顶地址(即当前函数的CFA),并将其写入fp
     2) 销毁当前函数栈空间
     3) 返回父函数
  此三步对应的源码如下:

   还是以函数A=>B=>C为例, 若编译时未开启-fomit-frame-pointer, 则函数调用过程中其寄存器变化如下图所示:

这里需要注意的是:
1. gcc中编译选项-fomit-frame-pointer
  -fomit-frame-pointer可以用来忽略栈帧的保存,若开启此选项则函数prologue中不会在函数栈中保存x29(fp), 所以若开启此编译选项则无法进行基于fp的栈回溯. 
2. 关于Current Function Frame Address 和CFA:
  在aarch64中prologue和epilogue生成的汇编指令通常如下:

  不论是否指定-fomit-frame-pointer, 在aarch64的函数入口都需要先为当前callee函数预留整个栈空间,如这里的 sp=sp-32; 在不考虑动态栈分配的情况下callee中的所有局部变量都位于指令1) 处预留的空间内, 第一条指令执行完毕后,sp由指向父函数栈顶变为指向子函数栈顶. 在不忽略栈帧的情况下(未开启-fomit-frame-pointer):

  所以在刚进入一个函数时, 其:
    - sp指向父函数(caller)的栈顶
    - fp同样指向父函数(caller)的栈顶

    - LR指向父函数(caller)的下一条指令
  进入函数后就立即为当前函数(callee)预留栈帧,完成栈帧预留后:
    - sp指向子函数(callee)的栈顶
    - fp同样指向子函数(callee)的栈顶

    - LR还是指向父函数(caller)的下一条指令

根据GCC手册和DWARF标准:

   gcc源码中获取CFA和当前函数栈帧的函数如下:

3. 基于fp栈回溯的本质:
  运行时基于fp的栈回溯本质上并不是需要一个单独的fp寄存器来保存栈帧, 而是需要每个函数在入口将其父函数的栈帧保存到子函数的栈中,从而在栈回溯的过程中可以依次追溯找到每一级的栈,aarch64的特点是函数入口处先为子函数创建整个栈帧空间, 之后sp一直保持不变(不考虑动态栈分配), 所以理论上用sp来做栈回溯也是可以的,只要每个函数都将当前sp的值入栈即可。
  但在实现上(gcc)如果不关闭-fomit-frame-pointer,那么子函数入口不会将其caller的栈帧地址保存到栈中,此时虽然每个函数自身的汇编代码知道如何在返回前将sp指向父函数的栈顶:

  但动态unwind过程并不知道如何找到父函数的sp, 因为栈回溯时并不知道当前函数的栈帧的大小.
  在aarch64中基于fp栈回溯的实现是在每个子函数的入口将父函数的栈顶地址保存到了子函数的栈顶中,子函数通过简单的基于当前sp/fp的解引用即可获取到父函数的栈顶地址。
  简单说就是每个函数入口必须保存其父函数的栈顶才能实现栈回溯,而aarch64中使用基于sp还是fp来做栈回溯理论上通常没有区别(只是多一级少一级的问题)。

  在aarch64 linux中只实现了基于fp的栈回溯, 栈回溯的配置选项为CONFIG_FRAME_POINTER,在aarch64中是默认开启的:

  而内核在开启基于fp的backtrace的同时禁止了sibling call,因为一个函数(caller)若在某个路径中调用了sibling call,那么从函数(caller)入口到此sibling call的路径中都是不会保存fp/sp的, fp/sp保存工作会交由子函数(callee)完成,如:

  所以aarch64 kenrel中为了更好的backtrace,在开启了CONFIG_FRAME_POINTER(默认)的过程中直接关闭了sibling call, 最终完成栈回溯的函数是dump_backtrace,其基本原理就是基于fp的栈回溯,代码如下:

  DWARF(Debugging With Attributed Record Formats)[4]是许多编译器和调试器用来支持源码级调试的调试文件格式,其解决了许多过程语言(如C,C++,Fortran)的需求并可以扩展到其他语言。DWARF独立于体系结构,适用于任何处理器或操作系统,广泛应用于Unix,Linux和其他操作系统以及独立环境中,目前最新可用的DWARF已经到了第5个版本[4]。
  DWARF是用于调试的一个标准,栈回溯信息(Call Frame Infomation,CFI)只是此标准中的一部分,除此之外DWARF中包含的调试信息还包括:
  * 数据和类型的描述
  * 可执行代码描述
  * 行号信息
  * 宏信息
  * 调用栈信息(CFI)
  * ...
  正常来说编译时开启 -g选项即可以生成DWARF的这些调试信息,这些信息都存储在.debug_*开头节区中:

其中各个节区的作用定义如下:

.debug_abbrev

记录.debug_info段中用到的缩写(Abbreviation)

.debug_aranges

记录内存地址到编译单元的映射关系

.debug_frame

记录Call Frame Infomation

.debug_info

包含DWARF核心DIEs的数据

.debug_line

包含行号信息

.debug_loc

位置描述信息

.debug_macinfo

宏信息

.debug_pubnames

全局函数和对象的索引表

.debug_pubtypes

全局类型索引表

.debug_ranges

记录DIEs中涉及到的地址范围

.debug_str

.debug_info中的字符串表

.debug_types

类型描述信息

  需要注意的是这些所有.debug_*开头的节区都是非load的节区,也就是运行时并不会被加载到内存, 可以被直接strip掉,这也符合DWARF的本意即调试.
  因为不能加载到内存,故这些debug_*开头的节区无法用来做运行时unwind的,真正支持运行时unwind功能的是 .eh_frame节区:
  1) .eh_frame(包括.eh_frame_hdr, .gcc_except_table)是LSB标准中定义的一个节区[5],而并非DWARF标准中定义的内容, 但其中使用的数据格式是符合DWARF标准的(并在此基础上有一些扩展)。
  2) 编译时.eh_frame与调试用的debug_*节区的生成是相互独立的, 通过编译选项可以单独控制二者是否生成。
  3) .debug_*段是非load段,将其移除掉不影响程序正常运行, .eh_frame是load段,将其移除则可能导致运行时错误(发生异常处理,unwind时)。
  4) 增加.eh_frame段的目的就是为了让其加载到运行时内存中[6],这样就可以实现一系列的功能,包括:
      * C++的异常处理
      * backtrace()
      * __attribute__((__cleanup__(f)))
      * __builtin_return_address(n) for n >0
      * __attribute__((__cleanup__(f)))下的pthread_cleanup_push
      * ...
   所以运行时基于DWARF的栈回溯实际上是基于.eh_frame的栈回溯. 和基于fp的栈回溯不同的是,基于DWARF的栈回溯(理论上)还可以回溯各个寄存器在每一个栈帧中的值

  .eh_frame中的数据结构包括CIE和FDE两种, 其中CIE是多个函数共用的, 而FDE则是每个函数单独有一个。
1. CIE(Common Information Entry):
    CIE中记录的是一些公用的入口信息,包括异常的handler,数据增强信息等。本文中主要需要关注的是其中记录了一些共用的初始化代码, 每个函数都会对应一个CIE, 一个CIE可以供多个函数共用, 如果两个函数拥有相同的初始化指令序列那么他们通常指向同一个CIE,其结构体简单记录如下(详细可参考[5]):

2. FDE(Frame Description Entry):
    FDE中记录了此函数栈回溯相关的信息, 最主要的是记录了一系列指令序列(CFI,Call Frame Infomation), 此指令序列可用来确定此函数执行到其每个地址时其各个寄存器的值应该如何获取/计算, 每个函数都有且仅有一个FDE结构体,其结构体简单记录如下(详见[5]):

  二进制中.eh_frame中的信息可以通过readelf -wf/wF 来查看:

  栈回溯函数通常都存在于运行时库中, 如gcc的libgcc或clang的libunwind, 在每一级栈回溯的过程中libgcc/libunwind中都会通过上下文(_Unwind_Context)维护当前栈帧时的寄存器值,为了便于区分后续用:

  前面提到过基于fp的unwind并非需要一个fp寄存器, 而是要将父函数的栈顶地址保存在子函数栈中,因为子函数虽然不需要栈帧即可找到父函数的栈顶,但运行时的栈回溯函数并不知道此信息。基于DWARF的栈回溯的原理是, 每个函数的FDE指令序列实际已经记录了此函数每条指令执行后SPx到CFAx的偏移(SPx和CFAx的偏移在编译时即可确定), 在运行时通过查表获取CFA的计算规则, 其本质上和函数自身可以找到父函数栈帧的原理是相同的。 基于DWARF的栈回溯在运行时总是满足如下公式:
  1) CFAx=SPx+offsetx;

  2) SPx+1 = CFAx;
      在第x级函数的入口(汇编角度), 函数的CFA总是等于当前硬件寄存器sp的值, 因此栈回溯到父函数时父函数当前的SPx+1总是可以用CFAx来替换。
  所以如A(2)=>B(1)=>C(0)的整个栈回溯可以表示为:

基于DWARF的栈回溯特点如下:

   除了CFA外, 使用DWARF格式理论上还能获取到任何指令位置处任何寄存器的值(只是理论如此,实际上不破坏已有标准的情况下只会恢复每个函数入口时各个callee-saved寄存器的值)但由于寄存器是排他性资源, 故此前提是目标寄存器的值通常必须在要回溯的指令前保存到内存且执行到此指令时其值未发生变化,同时汇编代码还要插入对应的DWARF指令以在FDE中标记如何获取目标寄存器的值,如: 

   也有一些情况下目标寄存器的值不必保存到内存中,如:

  通过查看.eh_frame段的输出可以大体了解运行时DWARF指令的解析(源码分析见后),以如下函数为例:

1. readelf -wf:

   readelf -wf 显示的内容和真正函数中的CFI指令是基本一致的,看起来虽然不方便但便于理解原理:

2. readelf -wF

   -wf输出不便于阅读,故通常情况下是通过-wF来查看每个地址的栈回溯指令信息:

  需要注意的是, 通常函数中只会在prologue中保存callee-saved寄存器的值并为其生成.cfi指令, 整个函数执行期间计算这些寄存器的.cfi指令可能发生变化,但这些寄存器最终的值通常不会变化, 即不论在函数的任何位置(除了pro/epilogue外), 根据栈回溯获取的callee-saved寄存器的值都应该是相同的(否则异常处理时会有问题)。所以FDE中描述的某寄存器的值通常也可以看做是其对应函数入口时此寄存器的值。

   gcc中基于DWARF的栈回溯是在运行时库libgcc中实现的, 其异常处理函数(如_Unwind_Exception)和栈回溯函数_Unwind_Backtrace都使用了基于DWARF的栈回溯,但需要注意的是_Unwind_Backtrace是LSB标准中定义的函数[8], 而_Unwind_Exception等异常处理函数是IA-64 C++ ABI标准中定义的函数[9].

  uw_init_context函数定义如下:

  uw_frame_state_for函数负责解析caller的CIE/FDE,代码如下;

  uw_update_context负责将context从callee状态更新为caller状态,其代码如下:

   虽然都满足IA-64 C++ABI标准,但不同库对基于DWARF的unwind的实现是略有不同, 如libunwind/libgcc在context上下文初始化时的实现就有所区别,同样是_Unwind_Backtrace函数:

  实际上libgcc的实现并没有问题,只是在IA-64 ABI下的接口不太友好(_Unwind_GetGR的crash无法预测,为确保不crash只能根据AAPCS64标准选择只打印callee-saved寄存器)。

  因为打印非callee-saved寄存器通常没有意义, 这些寄存器在函数调用过程中随时都可能被修改, 若某函数没有将其保存到栈中那么即使context中有值通常也是错误的。如上面libunwind测试结果中Unwind Frame 0/1中的通用寄存器的值完全相同,但并不复合实际情况(其每次输出的都只是_Unwind_Backtrace时这些寄存器的值).

   但libgcc的实现对一些指令的解析存在影响, 如Shadow Call Stack需要在在异常处理之前插入".cfi_escape 0x16, 0x12, 0x02, 0x82, 0x78" 指令(即x18=x18-8), 此指令在libunwind中可以正常运行,但在libgcc中就会由于x18寄存器未初始化而crash[10]


[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!

最后于 2022-2-19 15:33 被ashimida编辑 ,原因:
收藏
免费 9
支持
分享
打赏 + 5.00雪花
打赏次数 1 雪花 + 5.00
 
赞赏  葫芦娃   +5.00 2023/02/09
最新回复 (5)
雪    币: 4995
活跃值: (6578)
能力值: ( LV12,RANK:200 )
在线值:
发帖
回帖
粉丝
2
学习了,为啥没人回帖
2022-8-24 18:28
0
雪    币: 0
活跃值: (20)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
写的很好,受教了~
2022-12-28 19:18
0
雪    币: 10
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
4
支持
2024-1-2 21:49
0
雪    币: 169
活跃值: (482)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
太强了
2024-1-2 22:01
0
雪    币: 11075
活跃值: (17602)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
楼主辛苦了,发了这么好的帖子。
2024-1-3 17:52
0
游客
登录 | 注册 方可回帖
返回
//