iOS堆栈信息解析(函数地址与符号关联)

任务Mach-Task

描述:一个机器无关的thread的执行环境抽象
作用:task可以理解为一个进程,包含它的线程列表
结构体:
task_threads
task_threads将traget_task任务下的所有线程保存在act_list数组中,数组个数为act_listCnt

kern_return_t task_threads
(
  task_t traget_task,
  thread_act_array_t *act_list,                     //线程指针列表
  mach_msg_type_number_t *act_listCnt  //线程个数
)

thread_info线程信息

kern_return_t thread_info
(
  thread_act_t target_act,
  thread_flavor_t flavor,
  thread_info_t thread_info_out,
  mach_msg_type_number_t *thread_info_outCnt
);

如何获取线程的堆栈数据
1.所有线程:调用内核API函数task_threads获取指定task线程列表,即act_list
2.指定线程:调用API函数thread_info获得对应线程信息thread_info
3.线程信息:调用thread_get_state获得指定线程上下问信息_STRUCT_MCONTEXT。thread_get_stateAPI两个参数随着cpu架构不同而改变。_STRUCT_MCONTEXT结构存储当前线程栈顶指针(sp)和最顶部的栈帧指针(frame pointer),从而获得整个线程的调用栈`。

函数调用栈原理

指令指针

  • 指令指针IP:指令寄存器存储,指向处理器下条等待执行的指令地址(代码内的偏移量),每次执行完 IP会增加
  • 堆栈栈顶指针SP:堆栈指令寄存器存储,系统栈的栈顶地址
  • 栈帧指针FP:栈帧基址指令寄存器存储,每个栈帧都有一个对应的栈帧基地址,局部变量和函数参数都可以通过FP确定,因为它们到FP的距离不会受到压栈和出栈操作影响。

为了访问函数局部变量,必须能定位每个变量。局部变量相对于堆栈指针SP的位置在进入函数时就已确定,理论上变量可用SP加偏移量来引用,但SP会在函数执行期随变量的压栈和出栈而变动。尽管某些情况下编译器能跟踪栈中的变量操作以修正偏移量,但要引入可观的管理开销。而且在有些机器上(如Intel处理器),用SP加偏移量来访问一个变量需要多条指令才能实现,由此设计了栈帧指针FPFP两侧分别记录函数参数,及局部变量。

函数调用栈内部布局
栈帧:函数(运行中且未完成)占用的一块独立的连续内存区域。
函数调用通常是嵌套的,当调用函数时逻辑栈帧被压入堆栈, 当函数返回时逻辑栈帧被从堆栈中弹出。栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据等。

编译器利用栈帧,使得函数参数和函数中局部变量的分配与释放对程序员透明。编译器将控制权移交函数本身之前,插入特定代码将函数参数压入栈帧中,并分配足够的内存空间用于存放函数中的局部变量。使用栈帧的一个好处是使得递归变为可能,因为对函数的每次递归调用,都会分配给该函数一个新的栈帧,这样就巧妙地隔离当前调用与上次调用。

栈帧的边界由栈帧基地址指针EBP和堆栈指针ESP界定(指针存放在相应寄存器中)。EBP指向当前栈帧底部(高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动。因此函数中对大部分数据的访问都基于EBP进行。

函数出入栈过程

  • BP栈帧指针地址:间隔被调用函数(局部变量内存空间)和调用函数(被调函数参数,调用函数地址,指令指针)
  • BP栈帧指针值:上一个栈帧的地址值,便于被调函数释放后,回到调用函数
  • BP栈帧入栈时机:函数被调用,申请内存空间来存储前一个栈帧的地址值

函数调用栈内部布局.png

从图中可以看出,函数调用时入栈顺序为:
实参N-1→主调函数返回地址→主调函数帧基指针EBP→被调函数局部变量1-N 。
注意:内存地址降序

函数定义

  • caller(主调函数,紫色)
  • callee(被调函数,蓝色)

入栈过程

  • 1.caller未调用callee,内存分布如下:
    EBP:caller EBP
    ESP:caller的LocalVariables

  • 2.caller调用callee
    callee函数的参数入栈(由caller提供)
    caller的函数地址(vm_add), EIP入栈(代码偏移量offset)。备注:代码位置=vm_add+offset

  • 3.callee栈帧指针入栈
    申请栈帧指针空间
    存储caller的栈帧指针地址

  • 4.申请callee局部变量空间
    为局部变量申请足够的内存空间
    Local Variable#1,Local Variable#2,Local Variable#3...Local Variable#n
    EBP:callee的EBP
    ESP:Local Variable#n

出栈过程

  • 1.callee调用完毕
    callee局部变量空间释放
    EBP:callee ebp -> caller ebb
    ESP:caller ebp

  • 2.caller函数执行复原
    代码执行复原:ip+return address = 代码位置
    callee函数空间释放:Argumne #1,Argumne #2,...,Argumne #1n
    EBP:caller ebb
    ESP:caller Load Variables

函数调用地址获取

获取thread
API函数task_thread获取线程数组地址线程个数
API函数task_thread声明

kern_return_t task_threads
(
  task_t traget_task,
  thread_act_array_t *act_list,   //线程指针列表
  mach_msg_type_number_t *act_listCnt  //线程个数
)

使用代码

thread_act_array_t threads;
mach_msg_type_number_t thread_count=0;
task_threads(mach_task_self(),  &thrads, &thread_count);

thread的内存上下文
API函数thread_get_state获取内存上下文,上下文信息存储在_struct_mcontext结构体内

kern_return_t thread_get_state
(
    thread_act_t target_act,  //thread
    thread_state_flavor_t flavor,
    thread_state_t old_state, 
    mach_msg_type_number_t *old_stateCnt
);

备注:target_act和old_stateCnt配套使用,与cpu类型相关

使用代码

bool fillThreadStateIntoMachineContext(thread_t thread, _STRUCT_MCONTEXT * machineContext) {
    mach_msg_type_number_t state_count = LSL_THREAD_STATE_COUNT;
    kern_return_t kr = thread_get_state(thread, LSL_THREAD_STATE, (thread_state_t)&machineContext->__ss, &state_count);
    return (kr == KERN_SUCCESS);
}

thread_get_state传入thread,_STRUCT_MCONTEXT->__ss(寄存器指针结构体),以及cpu相关常量(target_act,old_stateCnt),来实现_STRUCT_MCONTEXT赋值

堆栈指针获取
_STRUCT_MCONTEXT结构体获取堆栈指针
如x86_64为_STRUCT_MCONTEXT->__ss结构体如下

#define DETAG_INSTRUCTION_ADDRESS(A) (A)
#define LSL_THREAD_STATE_COUNT x86_THREAD_STATE64_COUNT //thread_get_state函数参数
#define LSL_THREAD_STATE x86_THREAD_STATE64 //thread_get_state函数参数
#define LSL_FRAME_POINTER __rbp
#define LSL_STACK_POINTER __rsp
#define LSL_INSTRUCTION_ADDRESS __rip

指令指针

_STRUCT_MCONTEXT->__ss.LSL_INSTRUCTION_ADDRESS //rip 指令指针

栈顶指针

_STRUCT_MCONTEXT->__ss.LSL_STACK_POINTER  //bsp 栈顶指针

栈帧指针

_STRUCT_MCONTEXT->__ss.LSL_FRAME_POINTER  //rbp 栈帧指针

栈帧结构体
栈帧结构体StackFrameEntry

typedef struct StackFrameEntry{
    const struct StackFrameEntry *const previous;  //前一个栈帧地址
    const uintptr_t return_address;  //栈帧的函数返回地址
} StackFrameEntry;

首个栈帧结构体赋值
API函数vm_read_overwrite

kern_return_t vm_read_overwrite
(
    vm_map_t target_task,  //task任务
    vm_address_t address,  //栈帧指针FP
    vm_size_t size,  //结构体大小 sizeof(StackFrameEntry)
    vm_address_t data,  //结构体指针StackFrameEntry
    vm_size_t *outsize  //赋值大小
);

使用代码


//参数src:栈帧指针
//参数dst:StackFrameEntry实例指针
//参数numBytes:StackFrameEntry结构体大小
kern_return_t lsl_mach_copyMem(const void * src, const void * dst, const size_t numBytes) {
    vm_size_t bytesCopied = 0;
//   调用api函数,根据栈帧指针获取该栈帧对应的函数地址
    return vm_read_overwrite(mach_task_self(), (vm_address_t)src, (vm_size_t)numBytes, (vm_address_t)dst, &bytesCopied);
}

函数地址
参考上一步,完成首个栈帧结构体赋值后
1.通过栈帧结构体StackFrameEntry->previous,遍历所有栈帧
2.API函数vm_read_overwrite对栈帧结构体赋值,获取当前栈帧函数
伪代码

//循环遍历,停止条件MAX_FRAME_NUMBER栈帧个数
    for (; idx < MAX_FRAME_NUMBER; idx++) {
 栈帧函数赋值
        backtraceBuffer[idx] = frame.return_address;
        
        if (backtraceBuffer[idx] == FAILED_UINT_PTR_ADDRESS ||
            frame.previous == NULL ||
//        根据当前的栈帧的previous,获取前一个栈帧地址
            lsl_mach_copyMem(frame.previous, &frame, sizeof(frame)) != KERN_SUCCESS) {
            break;
        }

线程函数地址获取小结

  • 1.找到目标thread,方法:API函数task_threads
  • 2.获得thread的内存上下文_STRUCT_CONTEXT,方法:API函数thread_get_state
  • 3.获取指针栈帧结构体_STRUCT_CONTEXT._ss,解析得到对应指令指针_STRUCT_CONTEXT._ss.ip;首次个栈帧指针_STRUCT_CONTEXT._ss.bp;栈顶指针_STRUCT_CONTEXT._ss.sp
    1. 首个栈帧结构体赋值,方法:API函数vm_read_overwrite(_STRUCT_CONTEXT._ss.bp...),完成首个栈帧结构体赋值StackFrameEntry
    1. 遍历StackFrameEntry获取所有栈帧及对应的函数地址

代码逻辑解析

流程图

image.png
  • Setp1:
    调用API函数task_threads,获取线程数组栈帧threads,线程个数thread_count
task_threads(mach_task_self(), &threads, &thread_count)
  • Setp2:
    调用API函数thread_get_state,实例化结构体STRUCT_MCONTEXT,STRUCT_MCONTEXT->__ss包含栈帧指针fp,指令指针ip,栈顶指针sp
//thread:线程
//LSL_THREAD_STATE:cpu相关的定量
//machineContext->__ss:设备上下文,__ss结构体存储了`fp`,ip,sp
//state_count:cpu相关的定量
thread_get_state(thread, LSL_THREAD_STATE, (thread_state_t)&machineContext->__ss, &state_count)
  • Setp3:
    调用API函数vm_read_overwrite,实例化StackFrameEntry结构体,StackFrameEntry存储首个栈帧的函数地址,以及前一个栈帧地址从而通过遍历堆栈所有函数地址的获取
typedef struct StackFrameEntry{
    //    前一个栈帧地址
    const struct StackFrameEntry * const previous;
    //    函数地址
    const uintptr_t return_address;
} StackFrameEntry;

//mach_task_self:task对象
//src:fp栈帧指针
//numBytes:sizeof(StackFrameEntry)
//dst:StackFrameEntry指针
//bytesCopied://cpye字节大小
vm_read_overwrite(mach_task_self(), (vm_address_t)src, (vm_size_t)numBytes, (vm_address_t)dst, &bytesCopied)
  • Setp4:
    遍历StackFrameEntry(遍历条件StackFrameEntry.previous),来获取堆栈所有栈帧地址,及函数地址(add)并存储在函数地址数组backTrackBuffer

  • Setp5:
    获得函数的实现地址,由于函数地址无法进行阅读,需要通过符号表(nlist)来解析为函数名(Setp6-Setp15操作目标),从而进行程序定位。

  • Setp6:
    调用API函数_dyld_image_count(void) ,获取images文件总数,即mach-o文件总数,Setp6-Setp9遍历获取mach-o target index(目标mach-o镜像文件)。

  • Setp7:
    调用API函数_dyld_get_image_header(imageIndex)获取mach-o文件的header对象,header对象存储load command个数及大小;
    调用API函数_dyld_get_image_vmadd_slide(imageIndex)的mach-o文件的随机内存地址偏移量

  • Setp8:
    补充
    函数地址:add,函数真实的实现地址
    函数虚拟地址:vm_add
    ALSR:slide函数虚拟地址加载到进程内存的随机偏移量,每个mach-o的slide各不相同
    关系:vm_add + slide = add
    已知参数:add,slide因此通过关系换算得到vm_add

  • Setp9:
    image index:函数对应的mach-o镜像文件image索引index
    遍历:遍历mach-o下所有loadCommand(LC_SEGMENT),循环条件header->ncmds(load command个数)。
    目标:函数地址对应的mach-o镜像文件image。
    查询条件:vm_add=[image(index).segment(i).vmadd, image(index).segment(i).vmadd+image(index).segment(i).vmsize],其中index=image index,i=cmd index

  • Setp10:
    调用API函数_dyld_get_image_vmaddr_slide(index),获取目标image的slide用来换算基址。不同的mach-o的slide不同

  • Setp11:
    获得函数对应的mach-o的镜像image(index)文件后,计算程序链接基址,从而获取符号表地址symbolTab_Add,字符串表地址strTab_Add。
    base_add = segmet(LINKEDIR).vmadd - segment(LINKEDIT).fileoff + slide
    函数对应的镜像文件image(index),遍历loadcommadn,获得cmd.segname=LINKEDIT的segment,提取vmadd(虚拟地址),fileoff(文件偏移量)

  • Setp12:
    获得符号表地址
    symbolTab_add:符号表地址,一块连续的地址来存储mach-o所有的函数符号,存储结构为nlis
    base_add:程序链接时基址,通过LINKEDIT计算得到
    symoff:符号表偏移地址,存储在LC_SYMTAB的cmd中,symoff为相对基址的偏移量
    关系:symbolTab_add = base_add + symoff

  • Setp13:
    获得字符串表地址
    strTab_add:符号表地址,一块连续的地址来存储mach-o所有的字符串指针base_add:程序链接时基址,通过LINKEDIT计算得到stroff:符号表偏移地址,存储在LC_SYMTAB的cmd中,stroff为相对基址的偏移量
    关系:strTab_add = base_add + stroff

  • Setp14:
    符号表结构体nlist

// 位于系统库 头文件中 struct nlist {
  union {
     uint32_t n_strx;  //符号名在字符串表中的偏移量
  } n_un;
  uint8_t n_type;
  uint8_t n_sect;
  int16_t n_desc; 
  uint32_t n_value; //符号在内存中的地址,类似于函数虚拟地址指针   
};

符号表以nlist的结构体连续存储mach-o文件下所有函数符号,nlist结构体将函数虚拟地址,与函数名进行关联。

  • Setp15:
    符号结构体nlist关联了函数虚拟地址和函数名(n_vaule函数虚拟地址,n_um_strx字符串表偏移量),目前已知函数地址,因此可以遍历所有的nlist获得对应的n_um_strx。
    函数虚拟地址vm_add: vm_add = add - slide
    符号表注册函数虚拟地址n_value:nlist(index).n_value
    index遍历条件:vm_add >= n_value && min(vm_add - n_value),满足上述条件的符号index即为函数对应的nlist(index)

  • Setp16:
    获得函数对应的符号表索引后,得到函数名起始地址nlist(inde).n_um.n_strx + strTab_add

至此完成函数地址与函数名的关联~

推荐阅读更多精彩内容