MIPS backtrace的实现方案

9 篇文章 1 订阅
5 篇文章 1 订阅
  1. 概述
backtrace获取的是当前函数的纵向调用信息。一般思路是,给定当前函数的栈地址和当前运行位置(PC),通过计算得到当前函数的返回地址和上层函数(caller)的栈地址,然后以当前函数的返回地址作为caller的PC,来继续解析caller的返回地址和caller of caller的栈地址,以此类推进行纵向解析。
不管什么处理器架构,其GCC工具链提供的libgcc库中,都自带一个unwind_backtrace的函数,libc中的backtrace()函数其实就是转调用到libgcc中的unwind_backtrace接口。
然而,实际测试发现,mipsel-linux-gcc的unwind_backtrace在某些场景下无法正常工作。一种场景:
在利用ld_preload来hack libc的malloc函数时,如果我们自己实现的malloc内调用libc的backtrace函数,会发生死锁,因为mipsel-linux-gcc的unwind_backtrace内部会依赖libc的malloc。
因此我们不得不自行实现backtrace功能。

  1.  MIPS栈结构与函数对栈的处理
  1. 栈帧结构

图1 mips栈帧结构
  1. 函数对栈的处理
mipsel-linux-gcc默认遵循stdcall的函数调用标准,也就是说,本函数内要负责保存和恢复caller的栈帧。
一个非内联函数的指令执行过程分成3步,如下所示。
step1 (prologue):
调整sp寄存器,调整前sp指向caller的栈顶,调整后指向当前栈顶;
示例指令如下,
addiu sp,sp,-SIZE
其中SIZE是当前函数栈大小,编译时确定。
保存caller的相关寄存器至本函数栈中,如fp,ra等;
调整fp指向当前栈顶;
将入参从a0~an寄存器中保存至当前栈中;
初始化栈中的本地变量;

step2 (逻辑计算):
进行实际的逻辑计算,也包括子函数调用;
将返回值设给v0寄存器;

step3 (epilogue):
将本栈中保存的返回地址重新赋给ra寄存器,也即ra指向caller的下下一条指令(跳过指令延迟槽)
将本栈中的保存的fp重新赋给fp寄存器,也即fp指向caller;
调整sp使其重新指向caller的栈顶,”addiu sp,sp,SIZE” ;
跳转执行返回地址 jr ra ;

  1.  函数反汇编举例
void hello(void)
{
ret = mybacktrace(array, depth);

printf("hello. \n"); //mybacktrace 的返回地址
}

00400d0c <mybacktrace>:
400d0c: 27bdffe0 addiu sp,sp,-32 //将hello函数栈顶指针sp向下移动32字节,得到当前函数栈顶指针,意味着当前函数的栈大小为32字节
400d10: afbf001c sw ra,28(sp) //*(sp+28)=ra, 将ra寄存器的值保存至sp的32字节偏移处,意味着sp+28地址处能得到函数返回地址(hello调完mybacktrace后继续执行的指令地址),ra寄存器被jal/bal等带有链接功能的跳转/分支指令自动更新
//mybacktrace是非叶子函数,也就意味着接下来还有子函数调用,因此此处必须临时保存mybacktrace的返回地址,而叶子函数是没有类似sw ra,n(sp)的指令的
400d14: afbe0018 sw s8,24(sp) //*(sp+24)=s8, 将s8(hello的帧指针fp)保存至sp+24处
400d18: 03a0f021 move s8,sp //fp=sp
400d1c: afc40020 sw a0,32(s8) //保存第1个形参array至fp+32处
400d20: afc50024 sw a1,36(s8) //保存第2个形参depth至fp+36处

400d24: 8fc40020 lw a0,32(s8) //将fp+32处的参数array加载到a0寄存器(以传递给子函数mybacktrace_mips)
400d28: 8fc50024 lw a1,36(s8) //将fp+36处的参数depth加载到a1寄存器(以传递给子函数mybacktrace_mips)
400d2c: 0c1005a3 jal 40168c <mybacktrace_mips> //跳转执行子函数
400d30: 00000000 nop //指令延迟槽

400d34: 03c0e821 move sp,s8 //sp = s8 恢复sp的值(重新指向当前函数栈顶)
400d38: 8fbf001c lw ra,28(sp) //ra = *(sp+28) 恢复ra(重新指向mybacktrace的返回地址)
400d3c: 8fbe0018 lw s8,24(sp) //s8 = *(sp+24) 恢复fp(重新指向hello的frame)
400d40: 27bd0020 addiu sp,sp,32 //sp +=32 恢复sp,重新指向hello的栈顶
400d44: 03e00008 jr ra //返回执行hello函数
400d48: 00000000 nop //指令延迟槽
  1. 实现backtrace的要点与难点
  1.  关于栈大小
MIPS并没有专门寄存器指向栈底,栈内也没有保存当前“栈大小”,因此要想获取栈大小只能靠解析text段指令:
addiu sp,sp,-SIZE

  1. 关于ra在栈中的位置
不同的函数,ra寄存器保存在栈中的位置不固定,编译时刻决定,也需要解析text段指令来获取ra位置:
sw ra, N(sp)
  1. 关于叶子函数
叶子函数的栈中不保存ra寄存器,但好在只有在信号处理函数中才能碰到叶子函数,而信号处理函数触发时会传入当前上下文信息。
  1. 关于代码段指令的解析顺序
  1. 反向解析
反向解析沿着PC-->函数入口的方向解析。
这是一个理所当然的顺序,因为一般来说backtrace的输入参数只有栈顶地址和PC地址。此时我们是无法知道函数首地址的,只能从PC开始反向查找相关指令。

优点:能处理不在动态符号表中的函数(后面解释);
缺点:某些函数内涉及到复杂的gp+got调用,内部可能不止在入口和出口处有sp调整指令,多余的sp调整指令会干扰正常解析。

  1. 正向解析
正向解析是沿函数入口-->PC的方向解析。
如果能通过其它手段来得到函数首地址,那么正向解析无疑是更好的,因为函数入口处就有sp调整指令和ra保存指令,又快又准。

优点:很容易计算得到栈大小和ra值;
缺点:动态库中有些函数无法获取到函数首地址;

恰好,libdl库中提供了一个dladdr的函数,我们传入当前PC值,dladdr能给出其所属的函数地址。
  1. 如何获取当前函数的栈顶和PC
需要调用backtrace的有两种情况:某函数内主动调用backtrace;信号处理函数中调用backtrace。
  1.  主动backtrace
sp寄存器的值,直接用汇编来读取即可,参考函数:
inline unsigned int * __getsp(void)
{
unsigned int *sp;
__asm__ volatile ("move %0, $29" : "=r"(sp));
return sp;
}

由于MIPS没有专门的PC寄存器,因此我们无法直接通过读取寄存器来获取,但是可以用一个巧妙的方法:
定义一个非内联函数如下,该函数的作用就是返回其返回地址。
__attribute__((noinline)) //防止编译器将其优化为内联函数
static unsigned int * __getpc(void)
{
unsigned int *rtaddr;
__asm__ volatile ("move %0, $31" : "=r"(rtaddr));
return rtaddr;
}
那么当我们在某函数中调用__getpc()时,其返回值是指令 “bal __getpc” 的下下条指令,可把它认为是当前PC。

  1. 信号处理函数中backtrace
当程序运行异常时,如果我们设置了信号捕获回调,那么该回调会被触发,此回调的入参中就包括了异常点的上下文信息,我们可从中拿到栈地址和PC。然后以此为起点,进行backtrace即可。
注意:信号处理函数中,context->pc是触发信号的函数内的指令地址,context->ra是该函数的返回地址,因此,我们根据信号处理函数的上下文,一下子就获得了两层栈。应该以context->ra作为起始的PC继续进行backtrace。
  1. 实现流程
结合前面的分析,综合方案如下:
1,起始sp/pc要考虑信号处理函数。
2,代码段指令解析顺序,优先正向,其次反向。

定义接口:
typedef struct stackframe {
unsigned long sp;
unsigned long pc;
unsigned long ra;
}stackframe_t;
/*
比原生backtrace()函数多了个入参pFrame
*/
int mybacktrace_mips(void** array, unsigned int depth, stackframe_t *pFrame)
参数名
输入含义
输出含义
array
N/A
每层调用函数的返回地址
depth
最大调用深度
N/A
pFrame
第一层函数的栈信息,一般只有在信号处理函数中才为非空,其它情况pFrame==NULL
N/A
返回值
N/A
实际得到的调用深度

static int unwind_frame(struct stackframe *frame,
const unsigned int max_instr_check, unsigned long saddr, unsigned long known_ra)
参数名
输入含义
输出含义
frame->sp
当前函数栈顶
caller的栈顶
frame->pc
当前函数PC
caller的PC(也即当前函数返回地址)
frame->ra
N/A
当前函数返回地址
max_instr_check
最多解析多少条指令,仅作异常保护用
N/A
saddr
以frame->pc计算得到的当前函数入口地址
N/A
known_ra
也即mybacktrace_mips传入的pFrame->ra,第一次调用unwind_frame结束后应把此变量设为0
N/A
返回值
N/A
0:unwind成功,可继续unwind
非0:unwind失败,不能继续unwind

mybacktrace_mips函数内部会依据最大调用深度来循环调用unwind_frame,当unwind_frame返回错误时,提前退出循环。

  1. mybacktrace_mips的执行流程
图2 mybacktrace_mips循环调用unwind_frame的流程
  1. unwind_frame的执行流程


图3 反向解析指令





图4 正向解析指令

解析指令时的退出条件:
如果ra_offset和stack_size都已成功获取,当然会立刻退出解析,如果没有解析到这两个值,那么也不可能无限制解析下去,应在适当时刻退出并返回错误。
正向和反向的退出条件是不一样的,如下:
正向:
从函数首地址开始解析,最多解析到PC地址处为止,函数返回。

反向:
从PC处开始解析,当遇到如下两类指令则立刻退出并返回错误。
addiu sp,sp,SIZE //此处SIZE是正整数,我们“认为”该指令是相邻函数尾声处恢复栈帧的指令,既然已经跑到了别的函数指令段,当然应返回错误
lui gp,OFFSET // 此处OFFSET可正可负,这是位置无关代码函数必然执行的首条指令

  1.  如何将返回地址转换为易读信息
libdl的dladdr函数可以根据传入的地址解析出其所属的函数名称,前提是该函数位于动态符号表中,可以利用gcc的-rdynamic选项来强制将所有非静态函数都放入动态符号表。

  1.  调试中遇到的问题
当沿着PC-->SymbolEntry的方向反向解析指令时,有可能存在的特殊情况是:
该函数经过编译器优化之后,没有调整sp的指令,因此我们就越过了该函数继续向前解析,那么如果该函数位于当前代码段比较低地址的位置,我们在取指令时有可能越界到地址空间之外,导致产生段错误。
解决方法:从/proc/PID/maps中预先加载所有代码段(“r-xp”),在对一个PC地址进行unwind时,先判断该PC所在的代码段起始地址,unwind中不得越过代码段的起始地址。
局限性:如果在进程启动中来加载/proc/PID/maps中的代码段,那么会将一部分插件(如plugin-xxx.so)的代码段给漏掉,因为这些插件是程序运行起来之后在代码中显式加载的。
自动检测插件库的载入:拦截dlopen函数,每次调用dlopen函数中时,重新加载VAMaps


与原生backtrace的性能对比

平均栈深 = 总backtrace深度/总malloc次数
平均每帧所需时间 = (开机速度-无tcmalloc的开机速度)/总backtrace深度
 
总malloc次数
总backtrace深度
开机时间
平均栈深
平均每帧所需时间(微秒)
无tcmalloc
  
48
  
原生backtrace
70636
143712
60
2.03
(60-48)/143712 = 83 us
mybacktrace
70550
762997
145
10.8
(145-48)/762997 = 127 us
注意:其实平均每帧耗时计算的是每次malloc(或new等)比原生malloc所多耗费的时间,只不过我们 “假设“ libtcmallloc的时间主要耗费在backtrace上。

分析:
1,原生backtrace的速度有优势,但栈深没优势。
2,mybacktrace的速度没优势,但栈深有优势,很大的优势!这对调试程序有极大的帮助!。




  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值