注:栈回溯无法很好的定位到未调优化的函数,需要编译前使用 -fno-optimize-sibling-calls 选项禁止尾调优化。
基于unwind的栈回溯
在 arm 架构下,不少32位系统用的是 unwind 形式的栈回溯,这种栈回溯要复杂很多。首先需要程序有一个特殊的段 .ARM.unwind_idx 或者.ARM.unwind_tab,在连接文件中增加 __start_unwind_idx,这就是 ARM.unwind_idx 段的起始地址。这个unwind段中存储着跟函数入栈相关的关键数据。当函数执行入栈指令后,在 unwind 段会保存跟入栈指令一一对应的编码数据,根据这些编码数据,就能计算出当前函数栈大小和 cpu 的哪些寄存器入栈了,已经在栈中什么位置。
当栈回溯时,首先根据当前函数中的指令地址,就可以计算出函数 unwind 段的地址,然后从 unwind 段取出跟入栈有关的编码数据,根据这些编码数据就能计算出当前函数栈的大小以及入栈时 lr 寄存器数据在栈中的存储地址。这样就可以找到 lr 寄存器数据,就是当前函数返回地址,也就是上一级函数的指令地址。此时sp一般指向的函数栈顶,SP+函数栈大小 就是上一级函数的栈顶。这样就完成了一次栈回溯,并且知道了上一级函数的指令地址和栈顶地址,按照同样的方法就能对上一级函数栈回溯,类推就能实现整个栈回溯流程。
编译时需要加入 -funwind-tables 选项,此选项在编译时会依赖 标准glibc 库,要求不能使用 -nostdlib 选项,否则会报某些函数缺失错误。
.ARM.exidx : {
__exidx_start = .;
*(.ARM.exidx* .gnu.linkonce.armexidx.*)
__exidx_end = .;
}
优缺点分析
-
缺点
需要在代码的连接脚本中增加新的 unwind 专用段,对资源要求较高,且修改连接脚本容易引发未知问题。 -
优点
暂未详细分析。
基于FP寄存器的栈回溯
APCS 规范在ARM架构上定义了程序函数调用和栈帧定义以及寄存器的使用的规范,中定义了 FP 和 IP 寄存器的作用,目前这个规范已经被 AAPCS 规范所取代,此种方式基本已经不在使用。
核心思想
通过当前获取异常时的 FP 寄存器,找到调用者的栈帧开始地址,再通过栈帧下的相对偏移找到调用栈下的LR,从而确定子函数跳转地址 PC = LR - 跳转指令大小。
回溯过程
-
异常处理函数中获取线程异常时的堆栈指针 SP(获取MSP) 和 FP;
-
通过FP寄存器找到当前栈的栈帧开始位置,并通过偏移和LR的指令特征找到 fun_B 栈帧下存放的 LR
-
通过 (PC = *LR-跳转指令大小) 确定父函数 fun_A 调用异常函数 fun_B 的地址
-
通过获取 PC 地址下的指令,通过BL/BLX解码 找到 fun_B 函数的地址
-
通过相对偏移找到函数 fun_B 栈帧下的FP,从而找到函数 fun_A 的栈帧开始地址,执行LR搜索和指令解码
-
最后会找到 main 函数栈帧的开始地址,通过main栈帧偏移找到LR地址,发现 *LR 的地址是线程 main 函数退出收尾函数时停止栈回溯。
优缺点分析
- 优点
-
实现方案简单,栈回溯效率高,能够直接确定调用者的栈帧开始地址,快速定位到调用者栈帧下的 LR;
-
栈的遍历中无需检查每个 LR 的特征,通用语Thumb 和 ARM 指令集;
- 缺点
-
目前基本不在使用APCS规范,高于gcc 5.0 版本的编译器不在支持 FP寄存器的压栈;
-
需要修改链接脚本,在编译选项中加入-fomit-frame-pointer -mapcs-frame;
-
在每个函数下都增加了 FP 等寄存器的压栈指令,调试时与实际运行程序有差别;
基于SP遍历 LR 的栈回溯
核心思想
获取异常发生时线程函数的 SP(MSP),然后逐个从栈上取出内容进行判断,在 Thumb 指令集下栈上保存的 LR 是父函数进入子函数位置的下一条指令位置 +1,这里的+1表明了栈上 LR 位置存放的一定是一个奇数,再判断这个奇数-1 是否在 .text 段范围内,筛选出奇数后判断 *LR - 4 和 *LR - 2 位置是否满足 BL/BLX 指令特征,对于 ARM 指令集下栈上保存的 LR 是父函数进入子函数位置的下一条指令位置,这个值是4字节对齐的,再判断这个奇数-1 是否在 .text 段范围内,筛选出 4 字节对齐的内容后判断 *LR - 4 和 *LR - 2 位置是否满足 BL/BLX 指令特征,然后计算出跳转指令大小 PC=(*LR-指令大小) 就是 子函数调用位置,再根据获取 PC 下的指令,对指令进行地址解码就可以找到函数开始地址了。
回溯过程
- 异常处理函数中获取线程异常时的堆栈指针 SP(获取MSP),中断/内核栈下的PC(指向异常发生时的执行指令);
- 从异常时 SP 向上遍历栈帧找到 Thumb 指令集下 LR1 位置下的内容为 0x60256b93 这是一个奇数,然后取出 0x60256b93 - 1 - 4 = 0x60256b8e ,判断这个地址是否在 .text 段范围内,再判断指令是否为 bl/blx中的一种,如果是则说明找到了父函数 fun_A 调用 fun_B 的位置,记录下来;
- 继续向上遍历栈,找到 Thumb 指令集下 LR2 位置下的内容为 0x602561e9 这是一个奇数,取出0x602561e9 - 1 -4 = 0x602561e4 地址下的内容,判断是否是 b/bl/blx中的一种,如果是则说明找到了父函数 main 调用fun_A 的位置,记录下来;
- 继续向上遍历栈,找到 Thumb 指令集下 LR3 位置,*LR3-1-4 等于线程退出收尾函数时停止栈回溯;
优缺点分析
- 优点
- 栈回溯效率相对较高,只需遍历栈找特征LR值即可;
- 无需修改连接脚本,对原始SDK侵入性较小;
- 缺点
- 严重依赖 LR 特征值,可能出现错误解析;
- 不同架构,以及Thumb 和 ARM 指令集中BL/BLX 指令格式不同,兼容较为繁琐;
基于SP 代码遍历的栈回溯
核心思想
获取异常发生时线程函数的SP 和 PC ,通过 PC 位置在 .text 上寻找函数压栈操作指令 push/stmdb 和栈内存申请指令 sub/sub.w (SUB SP minus immediate) ,计算出栈帧大小,然后确定 LR 位置,确定调用者栈底位置 SP+framesize,然后在确定调用者调用子函数的位置 PC = LR - 跳转指令大小,之后根据 PC 位置继续从调用者函数的 .text 代码段遍历栈帧操作指令。
回溯过程
- 异常中断中获取线程异常时的堆栈指针 SP(获取MSP),中断/内核栈下的PC(指向异常发生时的执行指令);
- 向上遍历异常函数 fun_B 的 .text 段内容寻找 push 指令,解析 6026d17a 处压栈的寄存器个数4个寄存器包括 lr 寄存器,以此处 push 指令特征值为 0xb500,继续遍历 6026d178 处压栈寄存器个数为 4,则相对于 lr 寄存器的偏移 offsetsize = 4,此时栈帧大小为 8;
- 从 push 指令向下搜索栈扩展指令 sub/sub.w,6026d180 处在栈上申请了 386 *4 个空间;所以栈帧总大小为 framesize = 386+8;
- 确定 LR 位置 LR = SP + framesize - offsetsize;
- 确定调用者 fun_A 函数栈帧(调用者栈)的栈帧底部位置 SP = SP + framesize;
- 再通过 (*LR - 跳转指令大小) = PC 确定 fun_A 中调用 fun_B 的位置;
- 之后继续从 fun_A 下的 .text 段的 PC 位置向上遍历,如此循环,直到找到的 *LR 是线程退出收尾函数为止;
优缺点分析
- 优点
- 对栈上内容的依赖性较小,完全通过 .text 代码节进行遍历;
- 栈的遍历中无需检查每个 LR 的特征,适用于 Thumb 和 RAM 指令集;
- 缺点
- 效率较低,需要对从函数开始到子函数跳转位置进行遍历,如果函数很长则影响效率;
- 复杂性高,需要解析栈操作指令来获取压栈和栈扩展的大小,从而确定栈帧大小;
内存泄露定位
打印栈帧还有一个应用,就是检查谁引起内存泄露:
// s_array 为全局数组
static int alloc_en(void *addr, unsigned int size);
void* malloc_wrapper(unsigned int size)
{
void *ptr = (void*)malloc(size);
alloc_en(ptr, size);
return ptr;
}
static int alloc_en(void *addr, unsigned int size)
{
for(i = 0; i < MAX_CALL; ++i){
if(NULL == s_array[i].addr)
break;
}
if(i >= MAX_CALL){
printf("no free slot");
return -1;
}
s_array[i].addr = (U32)addr;
s_array[i].size = size;
s_array[i].caller = backtrace(3);// 三级调用者是谁?
}
🌀路西法 的个人博客拥有更多美文等你来读。