Por qué conseguir una pila nunca ha sido fácil

charla

Para no hacer que el artículo parezca demasiado aburrido, el autor lo pensó y agregó un poco de charla. Desde la última vez que se envió este artículo, ¡tecnología negra ! ¡Que Native Crash y ANR no tengan dónde desahogarse! , es muy popular entre los lectores, ¡qué diablos es la cantidad de favoritos más que la cantidad de me gusta, jeje! Desde mi punto de vista, el propósito original de Signal era construir un dispositivo similar a una bolsa de aire, para garantizar el primer reinicio y la recuperación después de un accidente, para lograr el propósito de la estabilidad de la aplicación, pero poco a poco, escribiendo y escribiendo, encontré mucho. de monitoreo de fallas La plataforma también utiliza los mismos principios básicos (la mayoría de ellos aún no son de código abierto), pero el propósito de la función es diferente, así que ¿por qué no hacer de Signal una pieza básica común? Ya sean bolsas de aire o monitoreo, de hecho, ¡la aplicación de la capa superior es diferente! ¡Um! Después de tener esta idea, agregue algo de lógica de monitoreo de registros a Signal , ¡y será más perfecto! ¡De ahí este artículo! ¡Es un suplemento! Si no has visto Black Technology! ¡Que Native Crash y ANR no tengan dónde desahogarse! Nuevos amigos de este artículo, ¡lea primero! (No importa si no tiene experiencia en desarrollo de ndk, y no implica un conocimiento c muy complicado)

obtener pila

¡Consigue la pila! Tal vez muchos nuevos amigos pensarán, ¡qué tiene de difícil esto! ¿Puede obtener un nuevo Throwable directamente, o Thread.currentThread().stackTrace (kotlin) y así sucesivamente? ¡Um! ¡Sí! Normalmente tenemos una forma muy fija de obtener pilas en la capa java, que se beneficia del diseño de la máquina virtual java y del diseño del lenguaje java, porque las diferencias en la parte inferior de la multiplataforma están blindadas, podemos usar una API relativamente unificada para obtener la pila actual. ¡Esta pila también se refiere específicamente a la pila de máquina virtual Java!

Pero para la pila nativa, ¡el problema viene! Sabemos que la capa nativa generalmente está relacionada con muchos factores, como el enlazador, el compilador y varias versiones de la biblioteca, varias abi, etc., y no es tan simple obtener un mensaje de pila, porque interfieren demasiados factores, esto es también una carga de la historia! Y para nuestro Android, hay cambios históricos en la forma en que Android adquiere oficialmente la pila.

En lo 4.1.1anterior y lo 5.0siguiente, Android nativo usa el que viene con el sistema Desde libcorkscrew.so5.0, no hay libcorkscrew.souna versión superior del código fuente de Android en el sistema, y ​​en su lugar se usa su versión optimizada libunwind. Al mismo tiempo, para ndk, la versión del compilador también cambia constantemente, desde el gcc predeterminado hasta clang (ndk> = 13).Se puede ver que encontraremos una forma unificada bajo muchas versiones y muchos factores, y Además, ¡realmente no es fácil! ¡Pero sí! Hoy, en 2022, Google ya ha lanzado un breakpad de biblioteca unificado planificado , ¡um! Aunque aún no se ha determinado si puede convertirse en un estándar, también es un progreso ecológico.

Elección de la señal

Con tantas soluciones presentadas anteriormente, ¿es breakpad la primera opción para Signal? Si bien breakpad es bueno, cubre la compilación de muchos otros sistemas, como Mac, Windows y otros estándares, y como biblioteca de código abierto, todavía esperamos reducir la importación de estas bibliotecas, por lo que, como ocurre con la mayoría de las soluciones principales, elija usar unwind.h para implementar la impresión de pila, porque está directamente integrado en nuestra compilación predeterminada, ¡y también se puede usar en Android! ¡Echemos un vistazo a la implementación a continuación! Es decir, la implementación de las unwind-utils del proyecto Signal. Entonces, ¿qué vamos a considerar!

tamaño de la pila

Por supuesto, el registro debe establecer el tamaño de la pila de seguimiento. Demasiado contenido no es bueno (demasiado hinchado y difícil de solucionar), y muy poco contenido no es bueno (es muy probable que se pierda la pila de fallas clave), por lo que Signal el valor predeterminado es 30, que se puede determinar de acuerdo con la situación real.

std::string backtraceToLogcat() {
    默认30个
    const 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 es la función de seguimiento de la pila proporcionada por unwind para nosotros

_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去获取,这里不举例)

imagen.png

native堆栈产生过程

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

private external fun throwNativeCrash()

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

imagen.png

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

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

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

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

Al final

Al ver esto, los lectores y amigos deberían tener un modelo aproximado de la pila nativa, por supuesto, ¡no tengan miedo! El proyecto Signal incluye la clase de herramienta relevante unwind-utils, que se puede usar directamente, pero la información impresa en la actualidad es relativamente simple, ¡y puede agregar parámetros de acuerdo con su situación real! ¡El código está todo dentro, pide estrella y pide pr! Signal , por supuesto, después de leer este artículo, ¡no olvides dejar tus Me gusta y comentarios!

Recomendado en el pasado

Escuché que la combinación de Compose y RecyclerView será incómoda.

Gradle de Android migró a kts

Estoy participando en el reclutamiento del programa de firma de creadores de la Comunidad Tecnológica de Nuggets, haga clic en el enlace para registrarse y enviar .

Supongo que te gusta

Origin juejin.im/post/7118609781832548383
Recomendado
Clasificación