鸿蒙原生C++ Crash排查笔记:config空指针的地址解析、LLDB断点溯源与修复
最近在排查一个 HarmonyOS 应用的原生层崩溃:应用进入某个业务页面时偶发闪退,没有任何 JS 异常,Log 里只留了一段 C++ 层的墓碑信息。用 SDK 自带的 llvm-addr2line 解析后,崩溃点落在一行 config 指针的解引用上——典型的空指针解引用。光知道崩溃行还不够,我更想搞清楚 config 为什么会是空,于是按官方 IDE 源码调试文档把断点调试链路完整配了一遍,从日志解析一路追到指针的初始化环节。把整个过程整理成这篇笔记,分成「定位崩溃点、断点溯源、修复验证、预防措施」四部分。
一、用 llvm-addr2line 把崩溃地址还原到代码行
1. 提取手机端崩溃日志
手机开启开发者模式和 USB 调试后连上电脑,在 DevEco Studio 底部 Log 面板切到 Native Log,按 SIGSEGV、Crash、pc 等关键词过滤,可以拿到类似下面的核心信息:
Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 in tid 12345 (myapp)
Crash stack:
#00 pc 000000000003c218 /data/app/ohos/com.example.myapp/lib/arm64/libentry.z.so
#01 pc 000000000002a100 /system/lib64/libc.so
Load libentry.z.so at 0x7c9b000000
这里有三个关键量:崩溃模块是本地编译的 libentry.z.so;pc 值 0x3c218 是该模块内的相对偏移;0x7c9b000000 是本次运行的加载基址。
2. 配置源码路径映射
如果 so 是在另一台编译机上出的包,调试机源码路径与编译时不一致,符号解析和断点会找不到源文件,需要配置 source map,命令行和可视化二选一。
命令行方式,在 DevEco 底部 Terminal 的 LLDB 会话里执行(前者是编译机路径,后者是本地路径):
settings set target.source-map D:/build_machine/MyHarmonyProject/src/main/cpp D:/MyHarmonyProject/src/main/cpp
可视化方式是官方更推荐的:Run > Edit Configurations,选中调试配置切到 Debugger 标签页,在 Source maps 区域点「+」,Old path 填编译机源码绝对路径,New path 填本地路径(可以直接用 P R O J E C T D I R PROJECT_DIR PROJECTDIR/src/main/cpp 自动适配),Apply 保存即可。
3. 执行地址解析:输入的是库内偏移,不是运行时绝对地址
llvm-addr2line 在 SDK 的 DevEco Studio 安装目录/Sdk/toolchains/llvm/bin/ 下。这里有一个我自己踩过的坑:日志同时给了加载基址和偏移,直觉上会把两者相加(0x7c9b000000 + 0x3c218)拿运行时绝对地址去查,这是错的。addr2line 查的是静态文件内的地址,应该直接传 #00 pc 给出的库内偏移 0x3c218;传入运行时重定位后的绝对地址,解析出的行号反而对不上。基址相加只在调试器里手工换算时有用。
cd D:\DevEco Studio\Sdk\toolchains\llvm\bin
# -e 指定带调试符号的 so(用 build 目录下未 strip 的 debug 产物)
# -f 显示函数名,-i 展开内联函数,-C 还原 C++ 修饰名
llvm-addr2line.exe -e D:\MyHarmonyProject\build\intermediates\cmake\debug\obj\arm64-v8a\libentry.z.so -f -i -C 0x3c218
4. 确认崩溃根因
输出直接给到函数和代码行:
get_config_value
D:/MyHarmonyProject/src/main/cpp/business.cpp:156
打开 business.cpp 第 156 行,是一次 config->value 读取,而此时 config 为 NULL,fault addr 0x0 也与空指针解引用完全吻合。崩溃点确认,但「config 为什么为空」还得靠断点调试回答。## 二、LLDB 断点调试:溯源 config 为什么会变成空
1. 设备端与 IDE 的前置准备
设备侧:设置 > 系统和更新 > 开发者选项里打开 USB 调试、调试信息查看、仅充电模式下允许 ADB 调试;HarmonyOS 4.0 以上版本还要打开「本机调试」。连好后 DevEco 右上角设备列表显示机型且状态为 Online 才算通。
IDE 侧:File > Settings > SDK 的 SDK Tools 标签页确认装好与项目 API 版本一致的 Build Tools,以及 LLDB(建议 15.0 以上),装完重启 IDE。
2. Debug 编译参数:保留符号、关掉优化
断点漂移、变量看不到,九成是编译参数不对。build-profile.json5 里 buildType 必须是 debug:
{
"app": {
"compileSdkVersion": 11,
"compatibleSdkVersion": 10,
"products": [
{ "name": "default", "buildType": "debug" }
]
}
}
src/main/cpp/CMakeLists.txt 里给 Debug 模式加上调试参数:-g 生成完整调试信息,-O0 禁止优化,-fno-omit-frame-pointer 保留栈帧方便看调用链。改完点 Sync Project 同步。
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -O0 -fno-omit-frame-pointer")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -g -O0 -fno-omit-frame-pointer")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -g")
endif()
网上有些文章还会建议加 -fno-pic -no-pie 关掉地址随机化,我自己的习惯是先不动它——pc 偏移的解析方式本来就不受随机化影响,只有在调试器地址换算确实对不上时才作为兜底手段。
3. 调试启动配置
Run > Edit Configurations,新建一个 HarmonyOS App 配置,Module 选 entry,Deployment Target 选已连接的手机,Debugger 标签页关键项如下:
| 配置项 | 推荐值 |
|---|---|
| Debug type | Native(C++ 原生调试,区别于 JS/ArkTS 调试) |
| LLDB path | Sdk/toolchains/llvm/bin/lldb.exe |
| Auto attach to native processes | 勾选,自动附加原生进程 |
| Source maps | 按前文配置,自动加载路径映射 |
| Debugger backend | Default |
4. 五类断点的配法(针对空指针排查)
所有断点都可以在 Run > View Breakpoints 里统一管理。针对这次 config 空指针,我实际用到的是下面五类:
| 断点类型 | 配置入口 | 在本次排查里的用法 |
|---|---|---|
| 行断点 | 代码行左侧 gutter 点击 | 打在崩溃行 business.cpp:156、config 初始化行和函数调用行,直接看指针值 |
| 函数断点 | View Breakpoints > + > Function Breakpoint | 填 load_config_from_file / get_config_value,在函数入口拦截入参 |
| 条件断点 | 右键行断点 > Edit Breakpoint > Condition | 条件填 config == nullptr,只在指针为空时暂停;可勾 Log message 打印 [DEBUG] config is NULL at %F:%L |
| 监视点 Watchpoint | + > Watchpoint,选 Write | 监视 config 被写入的动作,指针在哪里被置空或释放一目了然,是本次最关键的手段 |
| 异常断点 | + > Exception Breakpoint > C/C++ Exceptions | 勾选 Caught/Uncaught,在 SIGSEGV 发生前提前暂停 |
5. 调试操作与溯源路径
点工具栏 Debug(绿色虫子)以调试模式启动应用后,我主要在四个面板之间切换:Frames 看调用栈,从崩溃点向上回溯 config 的传递链路(get_config_value → init_business → load_config_from_file);Variables 看当前帧变量,确认 config = 0x0;Watches 手动加一个 config,跨作用域持续监视;Native Debug Console 里可以直接敲 LLDB 命令,p config 打印指针、bt 打完整调用栈、frame n 切栈帧。单步用 F8 步过、F7 步入、F9 恢复。
具体溯源按四步走:程序停在崩溃行时在 Variables 里确认 config == 0x0;在 Frames 里切到上游帧,找到赋值来源 Config* config = load_config_from_file(…);F7 步入赋值函数逐行看初始化为什么失败;如果初始化时非空、传着传着变空,就上 Write 监视点,抓出是哪一行把指针置空或提前释放的。
第三步在鸿蒙上有一个高频原因值得单独说:应用不能用硬编码绝对路径访问文件,正确的沙箱目录要由 ArkTS 侧用 context.getFilesDir() 取到应用私有目录,再通过 NAPI 把路径字符串传给 C++ 层。路径错了 fopen 必然返回 NULL,若调用方不检查返回值,后面就是一次标准的空指针崩溃。此外还要逐个排查 fopen 返回值、new 的返回值、解析失败分支是否正确返回并释放内存。## 三、修复:根因处理加空指针全局防护
修复原则是两层:根因层面解决加载失败(路径、返回值、内存),使用层面对所有裸指针加判空防护。日志用鸿蒙原生标准的 OH_LOG_Print(hilog/log.h),注意 HiLog 要求用 {public} 显式标注可打印的动态参数,默认是隐藏的。下面是整理后的参考实现:
#include <cstring>
#include <new>
#include <cstdio>
#include <errno.h>
#include <hilog/log.h>
#define LOG_TAG "ConfigDebug"
#define LOG_DOMAIN 0xD002B00 // 替换为自己应用的 domain
#define LOGI(...) OH_LOG_Print(LOG_APP, LOG_INFO, LOG_DOMAIN, LOG_TAG, __VA_ARGS__)
#define LOGE(...) OH_LOG_Print(LOG_APP, LOG_ERROR, LOG_DOMAIN, LOG_TAG, __VA_ARGS__)
Config* load_config_from_file(const char* path) {
// 1. 入参判空
if (path == nullptr || strlen(path) == 0) {
LOGE("config file path is empty");
return nullptr;
}
// 2. 打开文件并校验返回值
FILE* fp = fopen(path, "r");
if (fp == nullptr) {
LOGE("open config failed, errno=%{public}d", errno);
return nullptr;
}
// 3. nothrow 分配,失败返回 nullptr 而不是抛异常
Config* config = new (std::nothrow) Config();
if (config == nullptr) {
LOGE("alloc Config failed");
fclose(fp);
return nullptr;
}
// 4. 解析失败:释放资源后返回,不留野指针
if (read_config_data(fp, config) != 0) {
LOGE("parse config failed");
delete config;
fclose(fp);
return nullptr;
}
fclose(fp);
LOGI("load config success");
return config;
}
void get_config_value(Config* config) {
// 使用前全局判空,为空直接返回,杜绝解引用崩溃
if (config == nullptr) {
LOGE("config is null, refuse to dereference");
return;
}
int value = config->value;
// 其余业务逻辑……
}
其他根因按同一思路处理:多线程并发读写就加 std::mutex 或换智能指针;提前释放就梳理释放时序,delete 之后立刻置空;沙箱路径问题则统一从 ArkTS 侧取目录传进来:
// filesDir 由 ArkTS 侧 context.getFilesDir() 取得,经 NAPI 传入 C++
std::string configPath = std::string(filesDir) + "/config.json";
Config* config = load_config_from_file(configPath.c_str());
修复验证
以 Debug 模式重新运行并复现原操作路径,应用不再闪退;Native Log 按标签 ConfigDebug 过滤能看到 load config success,指针地址为非 0x0;条件断点 config == nullptr 不再命中;再跑一遍原崩溃场景的 addr2line 解析,已无空指针相关栈帧。确认 Debug 没问题后,再出 Release 包灰度验证。
四、预防措施:团队内沉淀成检查清单
这次之后我把相关注意事项整理成了 C++ 代码评审的固定检查项:
- 指针入参、返回值、成员指针使用前一律判空,核心函数保留空参提前返回;
- 能用 std::unique_ptr / std::shared_ptr 的地方不用裸指针,从机制上消灭野指针和重复释放;
- Debug 保留 -g,Release 可带 -g1 轻量符号,线上崩溃才有的可查;
- 禁止硬编码路径,文件目录统一走 context.getFilesDir() / getCacheDir() 经 NAPI 传入;
- 指针的初始化、传递、释放节点补 HiLog,带上 {public} 标记的地址和 errno;
- 多线程共享指针加锁或用原子操作;
- 动态分配统一 new (std::nothrow) 并检查返回值;
- 评审时重点扫一遍 fopen/new/解析函数的错误分支是否都正确释放并返回。
五、小结
整条链路回顾下来其实是三段:提取墓碑日志、按库内偏移用 llvm-addr2line 还原崩溃行;按官方规范配好源码映射和 LLDB,用调用栈、条件断点和 Write 监视点追出指针置空的真正位置;最后按「根因修复 + 全局判空」两层修,并把经验沉淀成评审清单。这套方法不只适用于空指针,野指针、use-after-free、各类 SIGSEGV 段错误的排查路径基本一致,区别主要在断点和监视点的组合方式。调试规范参考官方文档:IDE 源码调试指南。文中路径和函数名均为示例,替换成自己项目的即可,也欢迎评论区交流更优的排查姿势。
更多推荐



所有评论(0)