为什么说获取堆栈从来就不是一件简单的事情

·  阅读 3380

碎碎谈

为了不让文章看上去过于枯燥,笔者考虑了一下,特意增加了碎碎谈环节!自从上次这篇文章发出去后 黑科技!让Native Crash 与ANR无处发泄!,就挺受读者欢迎的呀,收藏数大于点赞数是什么鬼,嘿嘿!从我的角度出发,本来Signal出发的目的就是想建造一个类似于安全气囊的装置,保证crash后第一时间重启恢复,达到一个应用稳定的目的,但是慢慢写着写着,发现很多crash监控平台的也是用了相同的核心原理(大部分还没开源噢),只是作用的目标不同,那么为什么不把Signal打造成一个通用的基础件呢!无论是安全气囊还是监控,其实都是上层的应用不同罢了!嗯!有了这个想法之后,给Signal补充一些日志监控逻辑,就更加完善了!所以就有了本篇文章!算是一个补充文!如果没看过黑科技!让Native Crash 与ANR无处发泄!这篇文章的新朋友,请先阅读!(如果没有ndk开发经验也没关系,里面也不涉及很复杂的c知识)

获取堆栈

获取堆栈!可能很多新朋友看到这个就会想,这有什么难的嘛!直接new 一个Throwable获取不就可以了嘛,或者Thread.currentThread().stackTrace(kotlin)等等也可以呀!嗯!是的!我们在java层通常会有很固定的获取堆栈方式,这得益于java虚拟机的设计,也得益于java语言的设计,因为屏蔽了多平台底层的差异,我们就可以用相对统一的api去获取当前的堆栈。这个堆栈也特指java虚拟机堆栈!

但是对于native的堆栈,问题就来了!我们知道native层通常跟很多因素有关,比如链接器,编译器,还有各种库的版本,各种abi等等影响,获取一个堆栈消息,可没有那么简单,因为太多因素干扰了,这也是历史的包袱!还有对于我们android来说,android官方在对堆栈获取的方式,也是有历史变化的

4.1.1以上,5.0以下,android native使用系统自带的libcorkscrew.so,5.0开始,系统中没有了libcorkscrew.so 高版本的安卓源码中就使用了他的优化版替换libunwind。同时对于ndk来说,编译器的版本也在不断变化,从默认的gcc变成clang(ndk >=13),可以看到,我们会在众多版本,众多因素下,找一个统一的方式,还真的不简单!不过呀!在2022的今天,google早已推出了一个计划统一库 breakpad ,嗯!虽然能不能成为标准还未定,但是也是一个生态的进步

Signal的选择

前面介绍了这么多方案,breakpad是不是Signal的首选呢!虽然breakpad不错,但是里面覆盖了太多其他系统的编译,比如mac,window等等标准,还有就是作为一个开源库,还是希望减少这些库的导入,所以跟大多数主流方案一直,我们选择用unwind.h去实现堆栈打印,因为这个就直接内置在我们的默认编译中了,而且这个在在android也能用!下面我们来看一下实现!即Signal项目的unwind-utils的实现。那么我们要考虑一些什么呢!

堆栈大小

日志当然需要设定追溯的堆栈大小,内容太多不好(过于臃肿,排查困难),内容太少也不好(很有可能漏掉关键crash堆栈),所以Signal默认设置30条,可以根据实际项目修改

std::string backtraceToLogcat() {
    默认30const size_t max = 30;
    void *buffer[max];
    //ostringstream方便输出string
    std::ostringstream oss;
    dumpBacktrace(oss, buffer, captureBacktrace(buffer, max));
    return oss.str();
}
复制代码

_Unwind_Backtrace

_Unwind_Backtrace是unwind提供给我们堆栈回溯函数

_Unwind_Reason_Code _Unwind_Backtrace(_Unwind_Trace_Fn, void *);
复制代码

那么这个_Unwind_Trace_Fn是个啥,其实点进去看

typedef _Unwind_Reason_Code (*_Unwind_Trace_Fn)(struct _Unwind_Context *,
                                                void *);
复制代码

其实这就代表一个函数,对于我们常年写java的朋友有点不友好对吧,以java的方式,其实意思就是传xxx(随便函数名)( _Unwind_Context *,void *)这样的结构的函数即可,这里的意思就是一个callback函数,当我们获取到地址信息就会回调该参数,第二个就是需要传递给参数一的参数,这里有点绕对吧,我们怎么理解呢!参数一其实就是一个函数的引用,那么这个函数需要参数怎么办,就通过第二个参数传递!

我们看个例子:这个在Signal也有

static _Unwind_Reason_Code unwindCallback(struct _Unwind_Context *context, void *args) {
    BacktraceState *state = static_cast<BacktraceState *>(args);
    uintptr_t pc = _Unwind_GetIP(context);
    if (pc) {
        if (state->current == state->end) {
            return _URC_END_OF_STACK;
        } else {
            *state->current++ = reinterpret_cast<void *>(pc);
        }
    }
    return _URC_NO_REASON;
}


size_t captureBacktrace(void **buffer, size_t max) {
    BacktraceState state = {buffer, buffer + max};
    _Unwind_Backtrace(unwindCallback, &state);
    // 获取大小
    return state.current - buffer;
}
复制代码
struct BacktraceState {
    void **current;
    void **end;
};
复制代码

我们定义了一个结构体BacktraceState,其实是为了后面记录函数地址而用,这里有两个作用,end代表日志限定的大小,current表示实际日志条数大小(因为堆栈条数可能小于end)

_Unwind_GetIP

我们在unwindCallback这里拿到了系统回调给我们的参数,关键就是这个了 _Unwind_Context这个结构体参数了,这个参数的作用就是传递给_Unwind_GetIP这个函数,获取我们当前的执行地址,即pc值!那么这个pc值又有什么用呢!这个就是我们获取堆栈的关键!native堆栈的获取需要地址去解析!(不同于java)我们先有这个概念,后面会继续讲解

dladdr

经过了_Unwind_GetIP我们获取了pc值,这个时候就用上dladdr函数去解析了,这个是linux内核函数,专门用于地址符号解析

The function dladdr() determines whether the address specified in
       addr is located in one of the shared objects loaded by the
       calling application.  If it is, then dladdr() returns information
       about the shared object and symbol that overlaps addr.  This
       information is returned in a Dl_info structure:

           typedef struct {
               const char *dli_fname;  /* Pathname of shared object that
                                          contains address */
               void       *dli_fbase;  /* Base address at which shared
                                          object is loaded */
               const char *dli_sname;  /* Name of symbol whose definition
                                          overlaps addr */
               void       *dli_saddr;  /* Exact address of symbol named
                                          in dli_sname */
           } Dl_info;

       If no symbol matching addr could be found, then dli_sname and
       dli_saddr are set to NULL.
复制代码

可以看到,每个地址会的解析信息会保存在Dl_info中,如果有运行符号满足,dli_sname和dli_saddr就会被设定为相应的so名称跟地址,dli_fbase是基址信息,因为我们的so库被加载到程序的位置是不固定的!所以一般采用地址偏移的方式去在运行时寻找真正的so库,所以就有这个dli_fbase信息。

Dl_info info;
if (dladdr(addr, &info) && info.dli_sname) {
    symbol = info.dli_sname;

}
os << " #" << idx << ": " << addr << " " <<"  "<<symbol <<"\n" ;
复制代码

最终我们可以通过dladdr,一一把保存的地址信息解析出来,打印到native日志中比如Signal中demo crash信息(如果需要打印so名称,也可以通过dli_fname去获取,这里不举例)

image.png

native堆栈产生过程

通过上面的日志分析(最好看下demo中的app演示crash),我们其实在MainActivity中设定了一个crash函数

private external fun throwNativeCrash()
复制代码

按照堆栈日志分析来看,只有在第16条才出现了调用符号,这跟我们在日常java开发中是不是很不一样!因为java层的堆栈一般都是最近的堆栈消息代表着错误消息,比如应该是第0条才导致的crash,但是演示中真正的堆栈crash却隐藏在了日志海里面!相信有不少朋友在看native crash日志也是,是不是也感到无从下手,因为首条日志往往并不是真正crash的主因!我们来看一下真正的过程:我们程序从正常态到crash,究竟发生了什么!

image.png

可以看到,我们真正dump_stack前,是有很多前置的步骤,为什么会有这么多呢!其实这就涉及到linux内核中断的原理,这里给一张粗略图

image.png crash产生后,一般会在用户态阶段调用中断进入内核态,把自己的中断信号(这里区分一下,不是我们signal.h里面的信号)放在eax寄存器中(大部分,也有其他的寄存器,这里仅举例)

然后内核层通过传来的中断信号,找到信号表,然后根据对应的处理程序,再抛回给用户态,这个时候才进行sigaction的逻辑

所以说,crash产生到真正dump日志,其实会有一个过程,这里面根据sigaction的设置也会有多个变化,我们要了解的一点是,真正的crash信息,往往藏在堆栈海中,需要我们一步步去解析,比如通过addr2line等工具去分析地址,才能得到真正的原因,而且一般的android项目,都是依赖于第三方的so,这也给我们的排查带来难度,不过只要我们能识别出特定的so(dli_fname信息就有),是不是就可以把锅甩出去了呢,对吧!

最后

看到这里,读者朋友应该有一个对native堆栈的大概模型了,当然也不用怕!Signal项目中就包含了相关的unwind-utils工具类,直接用也是可以的,不过目前打印的信息比较简单,后续可以根据大家的实际,去添加参数!代码都在里面,求star求pr !Signal,当然,看完了本文,别忘了留下你的赞跟评论呀!

往期推荐

听说Compose与RecyclerView结合会有水土不服?

Android gradle迁移至kts

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改