调试技巧: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代码,大致流程是:
- 编译时加上
-g和-O0,保留调试符号,关闭优化。 - 把编译好的so文件和符号表文件(.sym)推到设备上。
- 用
gdbserver附加到进程,然后从主机连接。
我记得早期做某个项目时,遇到一个非常隐蔽的内存越界。GDB单步跟踪了整整一下午,最后发现是memcpy的长度算错了。那时候没有LLDB,GDB就是唯一的依靠。
3. LLDB调试:Android Studio的默认调试器
LLDB现在是Android Studio里调试NDK代码的默认引擎。它比GDB更快,支持更多特性,而且和Android Studio集成得很好。
在Android Studio里调试NDK代码,你只需要:
- 在CMakeLists.txt里确保
CMAKE_BUILD_TYPE是Debug。 - 在代码里打上断点。
- 点击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行。接下来去检查那行代码,问题基本就水落石出了。
另外,ndk-stack工具也可以做类似的事情。它能把完整的崩溃日志解析成可读的调用栈:
ndk-stack -sym /path/to/symbols -dump crash.log
输出会显示每个地址对应的函数名和源码位置,比手动一个个查要方便得多。
知识体系总览
下面这张图,我把本章涉及的调试手段和它们之间的关系梳理了一下。你可以把它当作一个调试工具箱,遇到不同场景时,知道该用哪个工具。
调试这件事,说白了就是「大胆假设,小心求证」。日志帮你缩小范围,断点帮你确认问题,栈回溯帮你事后复盘。把这套组合拳打熟了,NDK开发中的大部分问题都能迎刃而解。
公众号:蓝海资料掘金营,微信deep3321