调试技巧:GDB调试NDK代码、LLDB调试、Android Studio调试器、日志调试、addr2line与栈回溯

调试,说白了就是跟代码的「鬼」打交道。我做了这么多年底层开发,最深的体会就是:写代码只占三成功力,调试占七成。尤其是NDK开发,C/C++代码一旦崩了,连个像样的报错都没有,直接给你一个信号量——SIGSEGV,然后进程就没了。嗯,这时候你要是不会几手调试功夫,那真是寸步难行。

这一章,我把常用的调试手段都捋一遍。从最基础的日志调试,到专业的LLDB断点调试,再到崩了之后的栈回溯分析。你想想看,把这些都掌握了,以后遇到再诡异的Bug,心里也有底。

1. 日志调试:最朴素也最有效

我个人习惯,写NDK代码时第一件事就是把日志宏准备好。别小看这玩意儿,很多问题其实用日志就能定位。

Android的NDK日志是通过__android_log_print函数输出的。你需要链接log库,然后在代码里调用它。

先看CMakeLists.txt里怎么配:

find_library( log-lib log )
target_link_libraries( mynative ${log-lib} )

然后在C/C++代码里:

#include <android/log.h>
#define LOG_TAG "MyNative"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)

void someFunction() {
    LOGD("进入 someFunction,参数 value=%d", value);
    // ... 业务逻辑 ...
    LOGE("发生错误,errno=%d", errno);
}
我的习惯:我会在关键函数的入口和出口都打日志,参数和返回值都打印出来。这样一旦出问题,看日志就能知道是哪个函数、什么参数导致的。

日志级别我一般用DEBUG和ERROR。DEBUG用于跟踪流程,ERROR用于记录异常。上线前把DEBUG日志关掉就行,不影响性能。

2. GDB调试:老牌工具,但已逐渐被LLDB取代

GDB是Linux上的经典调试器。在Android NDK早期,大家主要靠它。但现在Google官方推荐的是LLDB。不过,GDB的一些思路还是值得了解的。

用GDB调试NDK代码,大致流程是:

  1. 编译时加上-g-O0,保留调试符号,关闭优化。
  2. 把编译好的so文件和符号表文件(.sym)推到设备上。
  3. gdbserver附加到进程,然后从主机连接。

我记得早期做某个项目时,遇到一个非常隐蔽的内存越界。GDB单步跟踪了整整一下午,最后发现是memcpy的长度算错了。那时候没有LLDB,GDB就是唯一的依靠。

注意:GDB在Android上的支持已经不太好了。新版的NDK甚至不再包含gdbserver。如果你还在用GDB,建议尽快迁移到LLDB。

3. LLDB调试:Android Studio的默认调试器

LLDB现在是Android Studio里调试NDK代码的默认引擎。它比GDB更快,支持更多特性,而且和Android Studio集成得很好。

在Android Studio里调试NDK代码,你只需要:

  1. 在CMakeLists.txt里确保CMAKE_BUILD_TYPEDebug
  2. 在代码里打上断点。
  3. 点击Debug按钮,选择"Debug 'app'"。

Android Studio会自动处理符号表的加载、断点的命中、变量的查看。你甚至可以实时修改变量的值,然后继续执行。这个功能我经常用,调试循环逻辑时特别方便。

LLDB的命令行模式也很强大。如果你不想用IDE,可以直接用lldb命令附加到进程:

# 在设备上启动lldb-server
adb shell lldb-server platform --listen "*:12345" --server

# 在主机上连接
lldb
(lldb) platform select remote-android
(lldb) platform connect connect://localhost:12345
(lldb) target create /path/to/your/library.so
(lldb) breakpoint set --name your_function
(lldb) continue

嗯,这里要注意:lldb-server需要和你的目标架构匹配。arm64的设备就用arm64版本的server,别搞混了。

4. Android Studio调试器:可视化断点调试

Android Studio的调试器,说白了就是把LLDB的底层能力包装成了图形界面。你不需要记那些复杂的命令,点一点就能完成大部分调试工作。

常用的功能有:

  • 条件断点:右键断点,设置条件表达式,比如i == 5。只有满足条件时才会停下来。
  • 日志断点:不中断执行,只打印日志。适合在循环里跟踪变量变化。
  • 求值表达式:在断点停下来后,可以在"Evaluate Expression"窗口里执行任意C/C++表达式。
  • 调用栈查看:崩溃时自动显示调用栈,双击每一帧可以跳转到对应代码。
避坑指南:我曾经在调试一个多线程问题时,发现断点总是被某个线程命中,但另一个线程却一直不触发。后来才发现,原来条件断点里用了一个全局变量,而这个变量被多个线程同时修改了。条件断点的求值是在调试器进程里做的,不是在被调试进程里,所以要注意线程安全。

5. addr2line与栈回溯:崩溃后的「法医鉴定」

很多时候,崩溃发生在用户设备上,你没法直接连调试器。这时候,崩溃日志里的调用栈就是唯一的线索。

Android的Native崩溃日志长这样:

pid: 12345, tid: 12346, name: Thread-1  >>> com.example.app <<<
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0xdeadbeef
backtrace:
    #00 pc 00012345  /data/app/com.example.app/lib/arm64/libnative.so (offset 0x1000)
    #01 pc 00023456  /data/app/com.example.app/lib/arm64/libnative.so (offset 0x2000)
    #02 pc 00034567  /system/lib64/libc.so (__pthread_start+32)

看到这些地址了吗?pc 00012345就是程序计数器,指向崩溃时的指令地址。但光有地址没用,你得把它翻译成函数名和行号。这时候就需要addr2line了。

用法很简单:

# 假设你的so文件带符号表
arm-linux-androideabi-addr2line -f -e libnative.so 00012345

输出:

your_function
/path/to/source.c:42

这样你就知道,崩溃发生在your_function的第42行。接下来去检查那行代码,问题基本就水落石出了。

我的经验:发布release版本时,一定要保留带符号表的so文件。我一般会在构建服务器上自动归档一份,标注好版本号和构建时间。这样以后拿到用户崩溃日志,直接去对应版本找符号表,用addr2line解析。没有符号表的话,你只能看到一堆地址,完全没法定位。

另外,ndk-stack工具也可以做类似的事情。它能把完整的崩溃日志解析成可读的调用栈:

ndk-stack -sym /path/to/symbols -dump crash.log

输出会显示每个地址对应的函数名和源码位置,比手动一个个查要方便得多。

知识体系总览

下面这张图,我把本章涉及的调试手段和它们之间的关系梳理了一下。你可以把它当作一个调试工具箱,遇到不同场景时,知道该用哪个工具。

NDK调试工具箱 NDK调试 日志调试 LLDB调试 AS调试器 栈回溯 __android_log_print 日志级别:DEBUG/ERROR 命令行模式 lldb-server附加 条件断点 日志断点 / 求值表达式 addr2line解析地址 ndk-stack批量解析 场景驱动:开发时用AS调试器,崩溃后用栈回溯

调试这件事,说白了就是「大胆假设,小心求证」。日志帮你缩小范围,断点帮你确认问题,栈回溯帮你事后复盘。把这套组合拳打熟了,NDK开发中的大部分问题都能迎刃而解。

最后说一句:调试工具只是手段,真正重要的是你对代码逻辑的理解。工具再强,如果不知道代码应该怎么跑,那也看不出问题在哪。所以,写代码时多想想「这里可能会出什么错」,调试时就会轻松很多。

公众号:蓝海资料掘金营,微信deep3321