引言
对于一个app来说,检测主线程的卡顿是性能优化的一个重点,而其中一个重要的操作就是当在子线程监控到主线程卡顿时需要抓取主线程的堆栈来进行后续的操作,一般来说都是使用BSBacktraceLogger工具在子线程抓取主线程的堆栈。今天主要来探索一下BSBacktraceLogger的原理,开拓一下自己的眼界。
函数调用栈
本段通过图解来简单描述函数调用栈,已经了解相关知识的可以跳到下一段。
大家都知道函数的调用是通过栈来进行了,我们通过以下例子来说明:
首先了解一下和本文有关的一些arm64知识: 在arm64架构下,有34个寄存器
| 寄存器 | 位数 | 描述 |
|---|---|---|
| x0-x30 | 64 | 通用寄存器,当做32位时为W0-W30 |
| x29(FP) | 64 | 当前函数栈帧的栈底地址 |
| x30(LR) | 64 | 指向当前函数结果后调用者要执行的下一条指令, |
| SP | 64 | 当前函数栈帧的栈顶,移动SP可以改变当前函数栈帧的大小 |
| PC | 64 | 程序计数器,总是指向即将要执行的下一条指令 |
| CPSR | 64 | 状态寄存器 |
我们查看当前断点的汇编代码,来大致了解一下函数调用栈的原理。
接下来通过图解来看一下每一步的操作:
假设当前函数栈如下图:
内存地址从上往下依次递减
2. sub sp, sp, #0x20
SP向低地址方向移动0x20(2*16=32)个字节
3. stp x29, x30, [sp, #0x10]
将x29、x30寄存器的值存储到当前栈顶SP+0x10的位置,分别保存的是函数c的栈底和a函数返回后的下一条指令
4. add x29, sp, #0x10
当前栈顶SP+0x10存入x29寄存器中,相当于FP指向了SP+0x10,此时FP指向了函数a调用栈的栈底,其中存储的是调用方c函数的栈底
5. mov w8, #0x1
该条指令是一个简单的赋值操作,w8即x8寄存器的低8位存储常数1
6. stur w8, [x29, #-0x4]
将w8的值存储在FP往下偏移4个字节的位置
7. bl 0x104aa9f10
bl:带返回的跳转指令, 返回的地址保存到LR(X30)
跳转到0x104aa9f10 位置的指令。
此时我们点击
stepinto按钮,进入b函数的实现,可以看到b函数的第一条指令的地址就是
bl指令后面的地址。我们继续分析
sub sp, sp, #0x10
SP继续下移
mov w8, #0x1
同上w8赋值
str w8, [sp, #0xc]
同样都是赋值操作,和
a函数不同的是,a函数是存储在FP-0x4的位置,b函数存储在SP+0xc的位置,还可以发现在b函数的汇编代码中,没看到保存a函数的FP(x29)和LR(x30)的指令,猜测是因为b函数已经处于整个调用链的最后,它没有调用其他的函数,因此不需要专门记录了,只需要在执行完毕之后返回到LR的指令就可以了
add sp, sp, #0x10
SP往上移动2个字节,和b函数的第一条指令对应,一个是入栈,一个是出栈,此时b函数的调用已经结束,ret返回到LR(x30)的位置,当前调用栈还原如下图:
同时,回到
a函数中继续执行下一条指令。
8. ldp x29, x30, [sp, #0x10]
从SP+0x10的位置读取数据,存入x29和x30寄存器中,从上图可以看出,SP+0x10的位置确实存储的是c函数的x29和x30数据,其实也就是还原现场。
9. add sp, sp, #0x20
SP往上偏移0x20,也和b函数的第一条指令对应,此时a函数的调用堆栈结束,ret返回到LR(x30)的位置。
上述图解只涉及到了汇编的初级知识,旨在尽可能简单的描述函数调用栈,大家应该可以简单的了解到函数调用栈的相关知识。
c函数调用a函数是一个入栈出栈的过程,调用开始的时候入栈,同时需要保存c函数的FP(x29)和LR(x30)在a函数的FP和FP+8的位置,即当前函数a的FP位置保存的就是调用方的FP位置,a函数调用结束时返回到LR的位置继续执行下一条指令,而这条指令属于c函数,因此我们可以通过FP来建立整个调用链的关系,通过LR来确认调用方函数的符号。
尽管如此,有两种情况是获取不到调用堆栈的,一种是尾调用优化,一种是内联函数。
BSBackTracelogger
原理
在线程中,我们可以使用[NSThread callStackSymbols]来获取当前线程的调用堆栈,但是在子线程中获取主线程的堆栈这种方法就行不通了,只能另辟蹊径。
上文中我们谈到了函数调用栈,那么通过调用栈,只要我们可以拿到主线程的相关寄存器,就可以通过调用关系一步一步拿到主线程的调用堆栈,这也正是BSBackTracelogger的原理所在
源码分析
@interface BSBacktraceLogger : NSObject
+ (NSString *)bs_backtraceOfAllThread;
+ (NSString *)bs_backtraceOfCurrentThread;
+ (NSString *)bs_backtraceOfMainThread;
+ (NSString *)bs_backtraceOfNSThread:(NSThread *)thread;
@end
头文件一共有4个方法,可以看出BSBacktraceLogger目标很特定,我就是要抓取各个线程的函数调用栈。
我们采用BSBacktraceLogger示例demo中的调用方式如下:
bs_machThreadFromNSThread
根据断点,我们最终会进入这么一个方法
+ (void)load {
main_thread_id = mach_thread_self();
}
#pragma -mark Convert NSThread to Mach thread
thread_t bs_machThreadFromNSThread(NSThread *nsthread) {
char name[256];
mach_msg_type_number_t count; // 线程的个数
thread_act_array_t list; // 存储线程的列表
task_threads(mach_task_self(), &list, &count); // 获取全部的mach thread信息
NSTimeInterval currentTimestamp = [[NSDate date] timeIntervalSince1970];
NSString *originName = [nsthread name];
[nsthread setName:[NSString stringWithFormat:@"%f", currentTimestamp]]; // 将当前线程设置name
if ([nsthread isMainThread]) {
//main_thread_id是在load方法中赋值,确保一定是主线程
return (thread_t)main_thread_id;
}
for (int i = 0; i < count; ++i) {
pthread_t pt = pthread_from_mach_thread_np(list[i]);
if ([nsthread isMainThread]) {
if (list[i] == main_thread_id) {
return list[i];
}
}
if (pt) {
name[0] = '\0';
// 从线程的列表中遍历线程,寻找name匹配的线程返回
pthread_getname_np(pt, name, sizeof name);
if (!strcmp(name, [nsthread name].UTF8String)) {
[nsthread setName:originName];
return list[i];
}
}
}
[nsthread setName:originName];
return mach_thread_self();
}
该方法用了一个很巧妙的方法,将需要抓取的线程设置一个特定的名字,然后在mach thread的列表中遍历,通过名字的对比来找到当前的NSThread对应的pthread_t。
_bs_backtraceOfThread
来到了BSBackTracelogger的核心方法,获取mach_thread的调用栈信息
#pragma -mark Get call backtrace of a mach_thread
NSString *_bs_backtraceOfThread(thread_t thread) {
uintptr_t backtraceBuffer[50];
int i = 0;
NSMutableString *resultString = [[NSMutableString alloc] initWithFormat:@"Backtrace of Thread %u:\n", thread];
_STRUCT_MCONTEXT machineContext;
// 获取当前线程的上下文信息
if(!bs_fillThreadStateIntoMachineContext(thread, &machineContext)) {
return [NSString stringWithFormat:@"Fail to get information about thread: %u", thread];
}
// 得到PC寄存器(当前函数的下一条指令)地址
const uintptr_t instructionAddress = bs_mach_instructionAddress(&machineContext);
backtraceBuffer[i] = instructionAddress;
++i;
// 得到LR寄存器(当前函数返回后调用方的下一条指令)地址,位于调用方的代码中
uintptr_t linkRegister = bs_mach_linkRegister(&machineContext);
if (linkRegister) {
backtraceBuffer[i] = linkRegister;
i++;
}
if(instructionAddress == 0) {
return @"Fail to get instruction address";
}
BSStackFrameEntry frame = {0};
// 得到FP寄存器,通过FP可以得到整个函数的调用关系
const uintptr_t framePtr = bs_mach_framePointer(&machineContext);
if(framePtr == 0 ||
// 对frame结构体进行赋值
bs_mach_copyMem((void *)framePtr, &frame, sizeof(frame)) != KERN_SUCCESS) {
return @"Fail to get frame pointer";
}
// 反向遍历得到函数堆栈
for(; i < 50; i++) {
backtraceBuffer[i] = frame.return_address;
if(backtraceBuffer[i] == 0 ||
frame.previous == 0 ||
bs_mach_copyMem(frame.previous, &frame, sizeof(frame)) != KERN_SUCCESS) {
break;
}
}
int backtraceLength = i;
Dl_info symbolicated[backtraceLength];
// 对当前的堆栈进行符号化
bs_symbolicate(backtraceBuffer, symbolicated, backtraceLength, 0);
for (int i = 0; i < backtraceLength; ++i) {
[resultString appendFormat:@"%@", bs_logBacktraceEntry(i, backtraceBuffer[i], &symbolicated[i])];
}
[resultString appendFormat:@"\n"];
return [resultString copy];
}
将上述函数分为以下几部分:
- 获取线程的上下文信息
- 读取上下文信息中的寄存器,通过寄存器信息来建立完整的函数调用关系的数组
- 将数组中的都是指令地址进行符号化,得到最终的输出效果。 接下来分步骤来读源码
1. 获取线程的上下文信息
_STRUCT_MCONTEXT machineContext;
if(!bs_fillThreadStateIntoMachineContext(thread, &machineContext)) {
return [NSString stringWithFormat:@"Fail to get information about thread: %u", thread];
}
声明了一个_STRUCT_MCONTEXT类型的变量
_STRUCT_MCONTEXT64
{
_STRUCT_ARM_EXCEPTION_STATE64 __es;
_STRUCT_ARM_THREAD_STATE64 __ss;
_STRUCT_ARM_NEON_STATE64 __ns;
};
其中的__ss结构体保存了线程的寄存器相关信息
调用bs_fillThreadStateIntoMachineContext通过指针传递来赋值。
#pragma -mark HandleMachineContext
bool bs_fillThreadStateIntoMachineContext(thread_t thread, _STRUCT_MCONTEXT *machineContext) {
mach_msg_type_number_t state_count = BS_THREAD_STATE_COUNT;
kern_return_t kr = thread_get_state(thread, BS_THREAD_STATE, (thread_state_t)&machineContext->__ss, &state_count);
return (kr == KERN_SUCCESS);
}
thread_get_state 返回目标线程的执行状态,例如寄存器。
kern_return_t thread_get_state
(
thread_act_t target_act,
thread_state_flavor_t flavor,
thread_state_t old_state,
mach_msg_type_number_t *old_stateCnt
);
2. 读取上下文信息中的寄存器,建立完整的调用关系。
寄存器信息主要保存在_STRUCT_MCONTEXT64结构体的__ss变量中,它是_STRUCT_ARM_THREAD_STATE64类型的结构体,里面保存了当前线程的寄存器信息。
_STRUCT_ARM_THREAD_STATE64
{
__uint64_t __x[29]; /* General purpose registers x0-x28 */ 通用寄存器
__uint64_t __fp; /* Frame pointer x29 */ 栈底指针
__uint64_t __lr; /* Link register x30 */ 返回地址
__uint64_t __sp; /* Stack pointer x31 */ 栈顶指针
__uint64_t __pc; /* Program counter */ PC
__uint32_t __cpsr; /* Current program status register */
__uint32_t __pad; /* Same size for 32-bit or 64-bit clients */
};
可以看到我们需要的寄存器信息都可以从__ss中拿到。其中:
- pc 在当前的函数实现内部
- fp fp位置存储的是当前函数调用方的栈底,fp+8位置存储的是当前函数返回之后需要在调用方函数中继续执行的一条指令也就是lr,是属于调用方函数内部。
typedef struct BSStackFrameEntry{
const struct BSStackFrameEntry *const previous;
const uintptr_t return_address;
} BSStackFrameEntry;
BSBackTracelogger声明了结构体BSStackFrameEntry,第一个变量为结构体指针previous,第二个变量为uintptr_t类型的return_address,我们获取到fp的地址之后,通过bs_mach_copyMem函数调用系统函数vm_read_overwrite函数从fp的位置开始读取内存,给BSStackFrameEntry结构体赋值,就可以得到当前调用方函数的fp和lr的值,通过这种方式进行循环,得到整个函数的调用关系。
kern_return_t bs_mach_copyMem(const void *const src, void *const dst, const size_t numBytes){
vm_size_t bytesCopied = 0;
return vm_read_overwrite(mach_task_self(), (vm_address_t)src, (vm_size_t)numBytes, (vm_address_t)dst, &bytesCopied);
}
3. 符号化
上述操作建立的调用关系数组中存储的都是指令的地址,我们如何将地址转化为对应的符号名称呢?这就需要借助到macho文件了。
#pragma -mark Symbolicate
void bs_symbolicate(const uintptr_t* const backtraceBuffer,
Dl_info* const symbolsBuffer,
const int numEntries,
const int skippedEntries){
int i = 0;
// 第一个存储的是pc寄存器
if(!skippedEntries && i < numEntries) {
bs_dladdr(backtraceBuffer[i], &symbolsBuffer[i]);
i++;
}
// 后面存储的是lr
for(; i < numEntries; i++) {
bs_dladdr(CALL_INSTRUCTION_FROM_RETURN_ADDRESS(backtraceBuffer[i]), &symbolsBuffer[i]);
}
}
bs_dladdr是符号化的核心函数,主要就是在当前的可执行文件以及动态库中进行遍历,确定我们的指令address到底处于什么位置,最后通过符号表以及字符串表得到符号的名称,操作步骤如下:
- 遍历各个
镜像image文件,通过macho文件的loadCommands中对各个segment的描述,确认address位于哪个image中。 - 遍历
image的符号表symbolTable,符号表中记录各个符号的开始位置,其实就是函数的第一条指令的地址,遍历符号表可以找到adress所在的符号。 - 符号表中除了记录符号的起始位置之外还记录了当前符号在字符串表中的索引,拿到adress所在符号之后,再通过对应的索引去字符串表中读取对应的符号名称,就可以完成对整个
address的符号化。
bool bs_dladdr(const uintptr_t address, Dl_info* const info) {
info->dli_fname = NULL;
info->dli_fbase = NULL;
info->dli_sname = NULL;
info->dli_saddr = NULL;
// 得到adress所在的image的索引
const uint32_t idx = bs_imageIndexContainingAddress(address);
if(idx == UINT_MAX) {
return false;
}
const struct mach_header* header = _dyld_get_image_header(idx); // 得到当前image的header
const uintptr_t imageVMAddrSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(idx); // 得到aslr
const uintptr_t addressWithSlide = address - imageVMAddrSlide; // 得到在macho中的真实位置
const uintptr_t segmentBase = bs_segmentBaseOfImageIndex(idx) + imageVMAddrSlide;
if(segmentBase == 0) {
return false;
}
info->dli_fname = _dyld_get_image_name(idx);
info->dli_fbase = (void*)header;
// Find symbol tables and get whichever symbol is closest to the address.
const BS_NLIST* bestMatch = NULL;
uintptr_t bestDistance = ULONG_MAX;
uintptr_t cmdPtr = bs_firstCmdAfterHeader(header);
if(cmdPtr == 0) {
return false;
}
for(uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) {
const struct load_command* loadCmd = (struct load_command*)cmdPtr;
if(loadCmd->cmd == LC_SYMTAB) {
const struct symtab_command* symtabCmd = (struct symtab_command*)cmdPtr;
const BS_NLIST* symbolTable = (BS_NLIST*)(segmentBase + symtabCmd->symoff);
const uintptr_t stringTable = segmentBase + symtabCmd->stroff;
for(uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++) {
// If n_value is 0, the symbol refers to an external object.
if(symbolTable[iSym].n_value != 0) {
uintptr_t symbolBase = symbolTable[iSym].n_value; // 符号对应的指令在text段的位置
uintptr_t currentDistance = addressWithSlide - symbolBase;
if((addressWithSlide >= symbolBase) &&
(currentDistance <= bestDistance)) {
bestMatch = symbolTable + iSym;
bestDistance = currentDistance;
}
}
}
if(bestMatch != NULL) {
// 去字符串表中寻找对应的符号名称,记录符号的虚拟地址+aslr
info->dli_saddr = (void*)(bestMatch->n_value + imageVMAddrSlide);
info->dli_sname = (char*)((intptr_t)stringTable + (intptr_t)bestMatch->n_un.n_strx);
// 去掉下划线
if(*info->dli_sname == '_') {
info->dli_sname++;
}
// This happens if all symbols have been stripped.
if(info->dli_saddr == info->dli_fbase && bestMatch->n_type == 3) {
info->dli_sname = NULL;
}
break;
}
}
cmdPtr += loadCmd->cmdsize;
}
return true;
}
uintptr_t bs_firstCmdAfterHeader(const struct mach_header* const header) {
switch(header->magic) {
case MH_MAGIC:
case MH_CIGAM:
return (uintptr_t)(header + 1);
case MH_MAGIC_64:
case MH_CIGAM_64:
return (uintptr_t)(((struct mach_header_64*)header) + 1); // 通过指针+1的方式进行寻址
default:
return 0; // Header is corrupt
}
}
uint32_t bs_imageIndexContainingAddress(const uintptr_t address) {
const uint32_t imageCount = _dyld_image_count(); // 遍历image
const struct mach_header* header = 0;
for(uint32_t iImg = 0; iImg < imageCount; iImg++) {
header = _dyld_get_image_header(iImg); // 得到image_header
if(header != NULL) {
// Look for a segment command with this address within its range.
uintptr_t addressWSlide = address - (uintptr_t)_dyld_get_image_vmaddr_slide(iImg); // 得到减去aslr之后的地址
uintptr_t cmdPtr = bs_firstCmdAfterHeader(header); // 得到loadCommands的位置
if(cmdPtr == 0) {
continue;
}
// 遍历loadCommands,确认adress是否落在当前image的某个segment中
for(uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) {
const struct load_command* loadCmd = (struct load_command*)cmdPtr;
if(loadCmd->cmd == LC_SEGMENT) {
const struct segment_command* segCmd = (struct segment_command*)cmdPtr;
if(addressWSlide >= segCmd->vmaddr &&
addressWSlide < segCmd->vmaddr + segCmd->vmsize) {
return iImg;
}
}
else if(loadCmd->cmd == LC_SEGMENT_64) {
const struct segment_command_64* segCmd = (struct segment_command_64*)cmdPtr;
if(addressWSlide >= segCmd->vmaddr &&
addressWSlide < segCmd->vmaddr + segCmd->vmsize) {
return iImg;
}
}
cmdPtr += loadCmd->cmdsize;
}
}
}
return UINT_MAX;
}
uintptr_t bs_segmentBaseOfImageIndex(const uint32_t idx) {
const struct mach_header* header = _dyld_get_image_header(idx);
// Look for a segment command and return the file image address.
uintptr_t cmdPtr = bs_firstCmdAfterHeader(header);
if(cmdPtr == 0) {
return 0;
}
for(uint32_t i = 0;i < header->ncmds; i++) {
const struct load_command* loadCmd = (struct load_command*)cmdPtr;
if(loadCmd->cmd == LC_SEGMENT) {
const struct segment_command* segmentCmd = (struct segment_command*)cmdPtr;
if(strcmp(segmentCmd->segname, SEG_LINKEDIT) == 0) {
// LINKEDIT的虚拟内存-偏移量得到当前image的基址,此时包含了aslr
return segmentCmd->vmaddr - segmentCmd->fileoff;
}
}
else if(loadCmd->cmd == LC_SEGMENT_64) {
const struct segment_command_64* segmentCmd = (struct segment_command_64*)cmdPtr;
if(strcmp(segmentCmd->segname, SEG_LINKEDIT) == 0) {
return (uintptr_t)(segmentCmd->vmaddr - segmentCmd->fileoff);
}
}
cmdPtr += loadCmd->cmdsize;
}
return 0;
}
到这里,BSBackTracelogger的实现原理以及源码也就分析的差不多了,看其他的博客有说到这个工具由于是好几年前写的了,对于内存以及cpu架构某些地方可能处理的不过完善,不属于本次的范围哈。本片文章只是通过梳理BSBackTracelogger的原理来增加自己对于操作系统的一些认识以及对于mach-o文件的了解。只要能学到一些之前不知道的知识,就足够了。