一、前言
工欲擅其事,必先利其器。当我们的应用发生错误或者崩溃时,如果有一款趁手的日志捕获工具,那将会得心应手的多。今天要学习的是来自 IQiYi 的 xCrash 日志捕获工具。这款工具不管是从质量上还是功能上,都是上乘之作。
二、xCrash 叙述
xCrash 能捕获的异常日志包括了 Java Crash、Native Crash 以及 ANR 日志,而我们在 Android 上所发生的异常,其归结起来无非就是这三种。关于这个库,按官方的解释,其主要的优点如下:
支持 Android 4.0 - 10(API level 14 - 29)。
支持 armeabi,armeabi-v7a,arm64-v8a,x86 和 x86_64。
捕获 java 崩溃,native 崩溃和 ANR。
获取详细的进程、线程、内存、FD、网络统计信息。
通过正则表达式设置需要获取哪些线程的信息。
不需要 root 权限或任何系统权限。
而站在开发的角度来看,其架构也是十分清晰的。下面是官方所提供的架构图。
三、初始化分析
1.初始化
初始化的代码如下,似乎 so easy。
public class MyCustomApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
xcrash.XCrash.init(this);
}
}
这里就不进一步贴代码了,只文字说明一下,初始化主要是获取AppId、AppVersion 等基础信息。当然,除此之外,最重要当然是对 JavaCrash Handler、NativeCrash Handler 以及 AnrCrash Handler 的初始化。
2. JavaCrash Handler 的初始化
如上图 JavaCrashHandler 实现了接口 UncaughtExceptionHandler,而它的初始化也简单。
Thread.setDefaultUncaughtExceptionHandler(this);
这样也算利用虚拟机所提供的接口,开始监控 Java Crash 了。另外比较主要的便是其实现的方法uncaughtException,后面再来说。
3. AnrHandler 的初始化
AnrHandler 的初始化除了一些参数的设定,然后就是监听 /data/anr 目录的变化。
fileObserver = new FileObserver("/data/anr/", CLOSE_WRITE) {
public void onEvent(int event, String path) {
try {
if (path != null) {
String filepath = "/data/anr/" + path;
if (filepath.contains("trace")) {
handleAnr(filepath);
}
}
} catch (Exception e) {
XCrash.getLogger().e(Util.TAG, "AnrHandler fileObserver onEvent failed", e);
}
}
};
try {
fileObserver.startWatching();
} catch (Exception e) {
fileObserver = null;
XCrash.getLogger().e(Util.TAG, "AnrHandler fileObserver startWatching failed", e);
}
当然,我们都知道,在高版本的 Android 系统中,应用已经访问不到 /data/anr 了。xCrash 是不是有提供了其他的实现方案呢?实际上它上捕获了 SIGQUIT 信号,这个是 Android App 发生 ANR 时由 ActivityMangerService 向 App 发送的信号。具体的,在后面再来分析。
4.NativeHandler 的初始化
NativeHandler 的初始化要相对复杂一些了,其分为 Java 层和 Native 层。
4.1 Java 层
Java 层相对简单,主要是加载 libxcrash.so ,以及进一步调 nativeInit() 进行 native 层的初始化。
System.loadLibrary("xcrash");
4.2 Native 层
nativeInit() 所映射的 jni 实现是 xc_jni_init()。在 xc_jni_init 又分了 3 个小步骤来进行初始化。
xc_common_init
这里面初始化了一些公共参数,如 os-kernel-version、app_version、appid、log 目录等。其中最重要的是初始化了两个文件 fd ,以应对文件 fd 被耗尽的情况。
//create prepared FD for FD exhausted case
xc_common_open_prepared_fd(1);
xc_common_open_prepared_fd(0);
这两个 fd 分别给了 xc_common_crash_prepared_fd 和 xc_common_trace_prepared_fd。但是这里要注意,它们目前打开的都是 “/dev/null”。
xc_crash_init
xcc_unwind_init 初始化 unwinder。
api_level >= 16 && api_level <= 20 则加载 libcorkscrew.so
api_level >= 21 && api_level <= 23 则加载 libunwind.so
xc_crash_init_callback 初始化 jni call back。这里主要是初始化了一个 native 的线程,然后通过 eventfd 阻塞等待 native 发生 crash 时向上层 java 发出通知。
接下来是比较重要的信号注册,通过xcc_signal_crash_register 进行。
int xcc_signal_crash_register(void (*handler)(int, siginfo_t *, void *))
{
stack_t ss;
.......
if(0 != sigaltstack(&ss, NULL)) return XCC_ERRNO_SYS;
......
for(i = 0; i < sizeof(xcc_signal_crash_info) / sizeof(xcc_signal_crash_info[0]); i++)
if(0 != sigaction(xcc_signal_crash_info[i].signum, &act, &(xcc_signal_crash_info[i].oldact)))
return XCC_ERRNO_SYS;
return 0;
}
这里看关键的几行,其中 sigalstack 是用于替换信号处理函数栈,有的说法是设置紧急函数栈。其原因是一般情况下,信号处理函数被调用时,内核会在进程的栈上为其创建一个栈帧。但是这里就会有一个问题,如果栈的增长到达了栈的资源限制值(RLIMIT_STACK,使用 ulimit 命令可以查看,一般为 8M),或是栈已经长得太大(没有 RLIMIT_STACK 的限制),以致到达了映射内存(mapped memory)边界,那么此时信号处理函数就没法得到栈帧的分配。
然后就是通过 sigaction() 进行信号的安装,这里只关注一下它安装哪一些信号。
{.signum = SIGABRT},abort发出的信号
{.signum = SIGBUS},非法内存访问
{.signum = SIGFPE},浮点异常
{.signum = SIGILL},非法指令
{.signum = SIGSEGV},无效内存访问
{.signum = SIGTRAP},断点或陷阱指令
{.signum = SIGSYS},系统调用异常
{.signum = SIGSTKFLT}栈溢出
信号的处理函数在 xc_crash_signal_handler。这个在后面再来分析。还有,这里有也准备了一个文件 fd , xc_crash_prepared_fd , 暂时还不清楚与前面 2 个的区别与关系。
xc_trace_init
trace 只是针对 Android 5.0 以上,因为其主要是用来获取 ANR 的 trace。xc_trace_init_callback() 只是获取 Java 的 methodId,进一步的主要操作在 xcc_signal_trace_register()。
int xcc_signal_trace_register(void (*handler)(int, siginfo_t *, void *))
{
......
//un-block the SIGQUIT mask for current thread, hope this is the main thread
sigemptyset(&set);
sigaddset(&set, SIGQUIT);
if(0 != (r = pthread_sigmask(SIG_UNBLOCK, &set, &xcc_signal_trace_oldset))) return r;
//register new signal handler for SIGQUIT
......
if(0 != sigaction(SIGQUIT, &act, &xcc_signal_trace_oldact))
{
pthread_sigmask(SIG_SETMASK, &xcc_signal_trace_oldset, NULL);
return XCC_ERRNO_SYS;
}
......
}
用来处理 SIG_QUIT 的响应函数是 xc_trace_handler() ,这个也是后面再来分析。函数的最后还会启动一个线程,并在线程响应函数xc_trace_dumper中等待 ANR 的发生。这里的等待机制同样是用的 eventfd。
5.初始化小结
-
初始化 JavaCrashHandler,其实现机制是通过 Thread.setDefaultUncaughtExceptionHandler() 注册一个自己的 UncaughtExceptionHandler。
-
初始化 AnrHandler,其实现机制是监听 “/data/anr” 文件夹的变化。同时对于 5.0 以上的版本,通过监听 SIGQUIT 来实现。
-
初始化 NativeHandler,预留 FD、安装一系列 signal、初始化用于 unwind 的
libcorkscrew.so 和 libunwind.so ,以及获取相关的函数。
四、异常处理分析
1.Java 异常处理
Java 的异常处理机制比较简单,只要 uncaughtException() 方法中等待异常的回调,然后收集相应的信息即可。这些都比较简单,这里就不详细分析了,感兴趣的可以自己去看。另外,其实现了一个 Util 类用来读取系统的文件,里面有很多值的学习的东西,如获取 meminfo 、获取文件所占用的 fds 等。
2.ANR 异常处理
2.1 Java 层的处理
Java 层的处理在 AnrHandler#handleAnr() 方法中,其也比较简单,就是解析 data/anr/trace.txt 文件,看看有没有自己进程的信息。感兴趣的也可以自己去分析。
2.2 Native 层的处理
关于Native 层的 anr 处理,官方有给了具体的实现架构图。那么,对照图,我们来具体看看它是如何实现的。
在 Native 初始化时,我们知道其监听了 SIGQUIT 信号来处理 ANR 的发生,并在 xc_trace_handler() 方法中来进行处理。
XCC_UTIL_TEMP_FAILURE_RETRY(write(xc_trace_notifier, &data, sizeof(data)));
其主要的实现很简单,就是通过 eventfd 发送一个通知,那这个通知的响应函数是 xc_trace_dumper(),下面来看看它的具体实现。
前面 2 步打开日志文件 xc_common_open_trace_log() 和 写入头信息 xc_trace_write_header() 感兴趣的可以自己分析。我们重点是要关注其怎么 dump art 的 trace。
xc_trace_load_symbols 加载符号表
xc_dl_create() 和 xc_dl_sym() 是里面比较重要的两个函数实现。xc_dl_create 是寻找到 so 被 mmap 所加载的虚拟地址,xc_dl_sym 是计算 so 中相应符号(函数)的虚拟地址。
其主要是从 libc++.so 中查找符号 _ZNSt3__14cerrE,对的,就是 cerr ;从 libart.so 中查找符号 _ZN3art7Runtime9instance_E 以及 _ZN3art7Runtime14DumpForSigQuitERNSt3__113basic_ostreamIcNS1_11char_traitsIcEEEE 在进程虚拟空间中的地址。针对 L 还需要 _ZN3art3Dbg9SuspendVMEv 和 _ZN3art3Dbg8ResumeVMEv。
xc_dl_create() 的具体实现在 xc_dl_find_map_start() 获取 so 的基地址、xc_dl_file_open() 通过 mmap 加载 so、xc_dl_parse_elf() 解析 so。这里的解析 so ,其实就是解析 elf 文件,这个比较复杂,需要对 elf 文件格式熟悉。这里就不深分析了。
xc_trace_libart_runtime_dump 开始 dump
相关代码如下:
if(xc_trace_is_lollipop)
xc_trace_libart_dbg_suspend();
xc_trace_libart_runtime_dump(*xc_trace_libart_runtime_instance, xc_trace_libcpp_cerr);
if(xc_trace_is_lollipop)
xc_trace_libart_dbg_resume();
xc_trace_libart_runtime_dump 就是_ZN3art7Runtime14DumpForSigQuitERNSt3__113basic_ostreamIcNS1_11char_traitsIcEEEE。也就是调用 dump 将对 SIGQUIT 的处理输出到 cerr 中。这里有一个细节,就是在 dump 节,其通过 dup2() 函数将标准的错误输出重定向到了自己的 fd 中。就在这段代码的上面,如下。
if(dup2(fd, STDERR_FILENO) < 0)
{
if(0 != xcc_util_write_str(fd, "Failed to duplicate FD.\n")) goto end;
goto skip;
}
接下来就是其他日志的处理了,感兴趣的也可以看一下,比如 logcat 日志的获取、文件 fd、网络日志等。至此,就完成了对 trace 的抓取了。
3.Native 异常处理
关于 Native 异常处理,官方给的架构图如下,流程上是很清晰的。
在初始化的时候我们分析到,当发生 native 崩溃时,会在信号处理函数 xc_crash_signal_handler() 进行处理。那么就从这个函数开始分析吧。
static void xc_crash_signal_handler(int sig, siginfo_t *si, void *uc)
{
......
pid_t dumper_pid = xc_crash_fork(xc_crash_exec_dumper);
......
int wait_r = XCC_UTIL_TEMP_FAILURE_RETRY(waitpid(dumper_pid, &status, __WALL));
}
这个函数除了做一些打开文件 fd 等基本的操作之外,其最主要做的事就是通过 xc_crash_fork() 创建一个子进程并等待子进程返回。
创建的子进程的响应函数是 xc_crash_exec_dumper()。这个函数首先通过 pipe 将一系列的参数,比如进程 pid ,崩溃线程 tid 等,写入到标准的输入当中,其目的是为了子进程从标准的输入当中去读取参数。然后通过 execl() 进入到真正的 dumper 程序。
static int xc_crash_exec_dumper(void *arg)
{
......
execl(xc_crash_dumper_pathname, XCC_UTIL_XCRASH_DUMPER_FILENAME, NULL);
}
这个其实就是通过 execl() 来运行 libxcrash_dumper.so ,当然,它不会再创建新的进程。而 libxcrash_dumper.so 的入口在 xcd_core.c 中的 main() 。可能很多人第一次在 Android 中见到我们熟悉的 C 语言中的 main() 函数吧。
下面我把 main() 函数都贴出来,整个实现言简意赅,基本反应了上面 dump 架构图的核心逻辑。
int main(int argc, char** argv)
{
(void)argc;
(void)argv;
//don't leave a zombie process
alarm(30);
//read args from stdin
if(0 != xcd_core_read_args()) exit(1);
//open log file
if(0 > (xcd_core_log_fd = XCC_UTIL_TEMP_FAILURE_RETRY(open(xcd_core_log_pathname, O_WRONLY | O_CLOEXEC)))) exit(2);
//register signal handler for catching self-crashing
xcc_unwind_init(xcd_core_spot.api_level);
xcc_signal_crash_register(xcd_core_signal_handler);
//create process object
if(0 != xcd_process_create(&xcd_core_proc,
xcd_core_spot.crash_pid,
xcd_core_spot.crash_tid,
&(xcd_core_spot.siginfo),
&(xcd_core_spot.ucontext))) exit(3);
//suspend all threads in the process
xcd_process_suspend_threads(xcd_core_proc);
//load process info
if(0 != xcd_process_load_info(xcd_core_proc)) exit(4);
//record system info
if(0 != xcd_sys_record(xcd_core_log_fd,
xcd_core_spot.time_zone,
xcd_core_spot.start_time,
xcd_core_spot.crash_time,
xcd_core_app_id,
xcd_core_app_version,
xcd_core_spot.api_level,
xcd_core_os_version,
xcd_core_kernel_version,
xcd_core_abi_list,
xcd_core_manufacturer,
xcd_core_brand,
xcd_core_model,
xcd_core_build_fingerprint)) exit(5);
//record process info
if(0 != xcd_process_record(xcd_core_proc,
xcd_core_log_fd,
xcd_core_spot.logcat_system_lines,
xcd_core_spot.logcat_events_lines,
xcd_core_spot.logcat_main_lines,
xcd_core_spot.dump_elf_hash,
xcd_core_spot.dump_map,
xcd_core_spot.dump_fds,
xcd_core_spot.dump_network_info,
xcd_core_spot.dump_all_threads,
xcd_core_spot.dump_all_threads_count_max,
xcd_core_dump_all_threads_whitelist,
xcd_core_spot.api_level)) exit(6);
//resume all threads in the process
xcd_process_resume_threads(xcd_core_proc);
#if XCD_CORE_DEBUG
XCD_LOG_DEBUG("CORE: done");
#endif
return 0;
}
里面的每一个过程就不再进行分析了,这里只说最重要的一点,其最核心的获取线程的 regs、backtrace 等信息是通过 ptrace 技术来获取的。这里面关于 ptrace,关于 elf 都相对比较复杂,因此不在这里献丑了。
五、总结
xCrash 的代码看起来非常简洁,层次也十分的清晰,感叹作者的功力之强。而由于个人水平有限,有些地方分析的可能也不是特别深入到位。有什么错误之处也请帮忙指出改正,感谢。