RNOH 踩内存 DFX 分析指导文档
鸿蒙 DFX 踩内存(Memory Stomp / Misuse)检测能力用于监控 React Native for OpenHarmony(RNOH)框架及其接入应用运行期的内存误用问题。当发生堆/栈越界读写、释放后使用(UaF)、作用域后使用、重复释放时,可通过 ASAN(AddressSanitizer)/ HWASAN(HWAddressSanitizer) 两类编译期插桩检测器捕获故障,生成 FaultLog 日志;在保留匹配的未剥离 SO、BuildId 和源码版本时,可进一步符号化到具体代码行。生产环境另有 GWP-ASan 概率性采样检测器。
RNOH 的原生引擎层由 C++ 实现,核心 SO 包括 librnoh_core.so、librnoh_core_package.so、libreactnative.so、libjsvmtooling.so(JSVM 引擎)和 libhermestooling.so(Hermes 引擎)。踩内存问题在 RNOH 场景中高发于跨线程回调晚到、实例销毁后残留引用、NAPI/JSI 引用未释放、VSync/动画驱动与对象生命周期不同步等路径,历史修复记录覆盖 0.72 / 0.77 / 0.82 多个版本。
1 机制描述
鸿蒙 DFX 踩内存检测依赖 LLVM sanitizer 系列。ASan/HWASan 需要在编译期对目标 Native 模块插桩,并在应用侧开启对应运行环境;GWP-ASan 由系统堆分配器对部分分配进行采样。三类检测器互斥开启,按构建与场景择一。
1.1 ASan(AddressSanitizer)
机制:影子内存(shadow memory)——每 8 字节应用内存对应 1 字节 shadow,标记可访问性。栈/堆分配两侧插入红区(redzone),free 后内存进入 quarantine 隔离区保留影子标记;interceptor 拦截 libc(malloc/free/memset/memcpy 等)检查 shadow。
检测类型:堆/栈越界(heap/stack-buffer-overflow)、heap-use-after-free、stack-use-after-scope、double-free、invalid-free。对已插桩代码及其可观察到的分配/访问路径属于确定性检测;未插桩的第三方预编译 SO 不能视为全量覆盖。
RNOH 场景典型应用:RNOH 的核心 C++ 代码(RNOH/、RNOHCorePackage/)全部以源码参与构建,可通过 CMake 编译参数开启 ASan 插桩。libreactnative.so(上游 RN C++ 代码)同样以源码构建,可一并插桩。但 libjsvm.so(系统预编译)和 libhermesvm.so(预产物)属于未插桩的第三方 SO,其内部访问不在 ASan 检测范围内。
开销与适用:高开销(~2x 内存 + 减速),适合 debug 构建开发调试。
报告:ERROR: AddressSanitizer: <类型>,含访问栈 + 分配/释放栈 + shadow byte legend,详见 4.4 ASan 报告解读。
1.2 HWASan(HWAddressSanitizer)
机制:利用 AArch64 Top-Byte-Ignore(TBI)在指针高位保存地址标签,并为内存粒度保存内存标签;编译器在访问点插入标签校验,指针 tag 与内存 tag 不匹配时报告 tag-mismatch。不能简单理解为必须由 ARM MTE 指令在硬件中完成全部校验。
检测类型:heap/stack-buffer-overflow、use-after-free、double-free、invalid-free 等标签不匹配类问题。覆盖范围仍取决于目标 SO 是否按 HWASan 编译。
RNOH 场景典型应用:相对 ASan 内存开销更低,适合开发态真机调试与压测,尤其适合复现 RNOH 中偶发的 UAF 问题——如 ImageComponentInstance 图片回调晚到、JSVMPointerValue 销毁阶段 UAF、EventBeat 依赖的 RuntimeScheduler 已销毁等场景。鸿蒙官方当前说明要求 ROM 版本 5.0.0.107 及以上,并要求与其他 Sanitizer 互斥;具体支持范围以目标设备和 SDK 文档为准。
报告:ERROR: HWAddressSanitizer: tag-mismatch,含 tags(ptr/mem) + freed/allocated 栈 + Memory tags,详见 4.5 HWASan 报告解读。
1.3 GWP-ASan
机制:在堆分配器中采样一部分分配并放入受保护槽位,仅对命中采样的分配记录分配/释放信息并进行保护。槽位数量是配置项,API 20 的 GwpAsanOptions.maxSimutaneousAllocations 官方默认值为 1000,槽位用尽后新分配不再受监控,已释放槽位可复用。
检测类型:heap-use-after-free、heap-buffer-overflow、double-free、invalid/wild-free 等,仅限被采样且进入受保护槽位的分配(概率性,非全量)。
RNOH 场景典型应用:适合 Release 长期在线或灰度检测,尤其适合捕获生产环境中偶发的 UAF——如长时间运行后 MountingManagerCAPI 的悬空 componentName、RNInstanceInternal 销毁后 UITick 回调访问已释放实例等问题。需足够分配量与运行时长才有概率命中,偶发/短运行场景易漏检。官方 API 20 配置中 sampleRate 默认 2500,即约 1/sampleRate 的分配采样概率;实际 CPU/内存开销随采样率和槽位数变化。
开启:可在 app.json5 配置 "GWPAsanEnabled": true,API 20 及以上还可通过 HiDebug 的 enableGwpAsanGrayscale 配置灰度时长、采样率和槽位数;无需为每个 Native 模块增加 GWP-ASan 编译期插桩参数(不可与 ASan/HWASan 同时开启)。
报告:类 ASan 格式,首行含 GWP-ASan 标识,附访问栈 + 分配/释放栈,详见 4.6 GWP-ASan 日志解读。
GWP-ASan 为概率检测,不命中不代表无 bug;定位根因仍需 ASan/HWASan 在 debug 构建复现。
1.4 检测局限:预编译 SO 与 C++ 栈分配
RNOH 的核心 C++ 代码(RNOH/*.cpp、RNOHCorePackage/*.cpp)和上游 RN 代码(third-party/rn/)均以源码参与 CMake 构建,开启 ASan 时可对这些模块的 load/store 插入 __asan_* 检查。但以下组件属于预编译产物,不能通过 asanEnabled 或 -DOHOS_ENABLE_ASAN=ON 补上访问插桩:
RNOH C++ 源码 (RNOH/, RNOHCorePackage/, third-party/rn/)
→ 编译器对 load/store/mem intrinsic 插入 __asan_* / __hwasan_* 检查
→ 最终部署的 librnoh_core.so / librnoh_core_package.so / libreactnative.so 保留插桩
→ 运行时访问进入检测器
预编译第三方 SO
→ libjsvm.so (系统库, /system/lib64/ndk/)
→ libhermesvm.so (预产物, third-party/prebuilt/)
→ libv8_shared.so, libclang_rt.asan.so 等运行库
→ 仅配置 app.json5 / build-profile.json5 不能给这些 SO 补上 load/store 检查
区分错误类型:ASan 的 stack-buffer-overflow / stack-use-after-scope 针对编译器识别的 C/C++ 栈对象。RNOH 中常见的 ArkTSChannel 跨线程传递的 folly::dynamic、ComponentInstance 的 m_children vector 等属于堆分配,其越界或 UAF 通常表现为 heap-buffer-overflow 或 heap-use-after-free,不应预设为"栈红区"报告。
JSVM/Hermes 引擎内部访问:JS 引擎内部的 GC、对象分配和 JIT 代码访问不在 ASan/HWASan 的插桩范围内。当崩溃栈完全落在 libjsvm.so / libv8_shared.so 内部时,应优先检查 RNOH 侧的 JSVMPointerValue / HostObjectProxy 引用管理是否正确,而非期望引擎内部被 Sanitizer 检测。
结论:C/C++ 栈级报告与 JS 引擎内部访问问题应分开描述。分析 RNOH 踩内存问题时,先验证最终
librnoh_core.so等产物的访问插桩(详见 4.2),再结合实际 FaultLog 判断检测边界;业务代码仍必须保证指针不逃逸、长度不越界,不能把 Sanitizer 未报告当作安全证明。
2 问题概述
2.1 RNOH 踩内存问题背景
RNOH 框架采用 C++ + ArkTS + JS 三层架构。原生引擎层(C++)负责渲染调度、组件管理、JS 引擎封装和 TurboModule 桥接。由于框架涉及大量跨线程交互(JS 线程、UI 线程、Worker 线程)、异步回调(VSync、Timer、HTTP、NAPI)和复杂的对象生命周期管理(shared_ptr/weak_ptr、NAPI reference、JSVM 引用),踩内存问题主要集中在以下路径:
- 跨线程回调晚到:VSync/Display/Timer 回调在对象销毁后仍执行;
- 实例销毁后残留引用:
ComponentInstance销毁但EventEmitter仍被回调; - NAPI/JSI 引用未释放:
napi_ref/napi_value超出 scope 或跨线程使用; - 临时对象
c_str()逃逸:std::string临时对象销毁后指针悬空; - JSVM 引用析构时序:
JSVMPointerValue在 env 销毁后仍操作 reference。
2.2 常见踩内存类型与场景
| 踩内存类型 | RNOH 典型场景 |
|---|---|
| 堆越界(heap-buffer-overflow) | folly::dynamic 数组下标越界;m_children.insert 位置超过容器大小;TextLayoutManager::measureLines 读取错误位置索引 |
| 栈越界(stack-buffer-overflow) | C++ 局部数组 char buf[N] 访问超过 N;ShadowView.componentName 临时 c_str() 指针逃逸到块外后被访问 |
| 堆释放后使用(heap-use-after-free) | VSync 回调晚到访问已销毁 AnimatedNodesManager;图片回调读 m_imageSource.uri.c_str() 悬空;JSVMPointerValue 销毁阶段 UAF |
| 栈作用域后使用(stack-use-after-scope) | C++ 局部变量地址逃逸(如 isCAPIComponentByTag 中临时 std::string 的 c_str() 被填入 ShadowView 后延迟消费) |
| 重复释放(double-free) | NAPI napi_delete_reference 与 JSVMPointerValue 析构双重释放;napi_wrap 对象手动释放与引擎回收冲突 |
| 非法释放(invalid-free) | free(ptr+N) 释放非 malloc 起始地址;手动释放 napi_get_arraybuffer_info 返回的引擎管理 buffer |
| 跨线程裸指针访问 | Worker TurboModule 线程直接操作主线程 ComponentInstance;runUpdates 在非 JS 线程访问已销毁动画节点 |
| NAPI/JSI 引用越界使用 | 超出 napi_handle_scope 生命周期继续使用 napi_value;跨线程使用 napi_env / napi_ref;HostObjectProxy Unwrap 空指针 |
2.3 三类检测器对比与适用场景
| 检测器 | 机制 | 开销 | 适用阶段 | RNOH 典型用途 |
|---|---|---|---|---|
| ASan | 影子内存 + 红区 | 高(~2x 内存) | debug 开发调试 | 确定性复现 UAF/越界;验证修复 |
| HWASan | TBI 指针标签校验 | 中(低于 ASan) | 开发态真机压测 | 偶发 UAF 真机复现;EventBeat/ImageComponentInstance |
| GWP-ASan | 堆分配采样保护 | 低 | Release 灰度/长期 | 生产环境偶发 UAF 捕获;需长时间运行 |
3 排查思路与整体分析
3.1 整体排查流程
拿到 faultlog 日志后,按以下顺序提取关键证据,逐步缩小根因范围:
日志首行 Reason + WRITE/READ 行
↓
① 确认故障类型和访问动作(读/写、大小、线程、地址区域)
↓
② 找"违规访问栈"(#0 首个业务帧 = 谁踩了这块内存)
↓
③ 找"释放栈"(freed by ... = 对象在哪个线程结束生命周期)
↓
④ 找"分配栈"(previously allocated by ... = 内存块真正所有者)
↓
⑤ 读取工具专属证据(shadow bytes / tags / Potential Cause)
↓
根因定位 → 解决方案
① 确认故障类型和访问动作
关注日志首行与 WRITE/READ 行,先确定:
- 是读还是写(
WRITE of size 4/READ of size 8); - 访问大小;
- 检测线程是谁(
thread T0为主线程 /thread T240等,RNOH 中 JS 线程名通常为RNOH_JS); - 地址属于堆、栈还是全局区(shadow bytes / tag 区域);
- 是越界、释放后使用还是重复释放(
heap-buffer-overflow/heap-use-after-free/double-free)。
② 找"违规访问栈"
这是发现错误的访问点,不一定是对象创建或释放点。日志中 WRITE of size 4 at 0x... 下方紧跟的调用栈即违规访问栈。在 RNOH 中,违规访问栈的首个业务帧通常落在以下模块:
| 栈帧模块特征 | 可能的踩内存场景 |
|---|---|
rnoh::MountingManagerCAPI::handleMutation | findByTag() 返回空后未判空;m_children 越界 |
rnoh::ComponentInstance::insertChild | m_children.insert 位置越界 |
rnoh::ImageComponentInstance::onComplete | 回调晚到读 m_imageSource.uri.c_str() 悬空 |
rnoh::AnimatedNodesManager::runUpdates | VSync 回调访问已销毁动画节点 |
rnoh::JSVMPointerValue::~JSVMPointerValue | 销毁阶段访问已失效 env |
rnoh::EventBeat::induce | RuntimeScheduler 已销毁 |
③ 找"释放栈"
UAF 日志一般包含 freed by thread ...,下方为释放栈。这一步回答:对象在哪个线程、通过什么路径结束生命周期。
如果访问线程和释放线程不同,应优先检查:
- 异步回调晚到(VSync/Display 回调、HTTP 回调、Timer 回调);
- 跨线程裸指针(Worker TurboModule 直接操作主线程对象);
- Native 对象与 ArkTS/JS 对象生命周期不一致(
ComponentInstance销毁但EventEmitter仍被回调); - 回调注销、任务取消、
weak_ptr/napi_ref释放缺失; - 对象析构与后台任务并发(
RNInstanceInternal析构时UITicker仍在触发回调)。
④ 找"分配栈"
日志一般包含 previously allocated by thread ...,下方为分配栈。这一步用于确定:
- 内存块真正的所有者;
- 分配 API 与释放 API 是否匹配(如
malloc↔free、new↔delete、napi_create_reference↔napi_delete_reference、JSVMPointerValue::New↔ 析构释放); - 释放方是否越权;
- 缓冲区原始大小及访问偏移(越界时看合法区间与错误地址的偏移量,判断越界方向与距离)。
⑤ 读取工具专属证据
三类检测器的报告各有专属字段,按检测器类型读取:
ASan:
- Shadow bytes 中
fd通常表示已释放堆内存(Freed heap region); fa等值表示堆红区(Heap left redzone);f1/f2/f3分别对应栈对象周围不同红区(left/mid/right);- 对 overflow 要同时看对象合法区间
[32, 72)和错误地址偏移offset 72,判断越界方向与距离。
HWASan:
- 先看
HWAddressSanitizer: tag-mismatch; - 比较
tags: d2/11 (ptr/mem)中的指针 tag 与内存 tag; Potential Cause/Cause是候选原因,不能只凭这一行定案;- 结合访问栈、释放栈、分配栈和地址范围,区分 UAF 与 heap-buffer-overflow。
GWP-ASan:
- 日志通常直接给出
Use After Free、Double Free或Invalid (Wild) Free; - 依次读取违规访问栈、释放栈、分配栈;
- 因为是采样检测,复现一百次没报告也不能证明问题不存在——需结合长期运行或 ASan 复现。
3.2 选择检测器的决策路径
怀疑踩内存(崩溃地址不稳定 / 多次崩溃位置不同)
↓
是否能在 debug 构建稳定复现?
├─ 是 → 开启 ASan,确定性检测
└─ 否(偶发)→ 真机 ROM ≥ 5.0.0.107?
├─ 是 → 开启 HWASan,低开销真机压测
└─ 否 → 开启 GWP-ASan,Release 灰度采样
↓
GWP-ASan 命中 → 切 ASan/HWASan 在 debug 复现定位根因
GWP-ASan 未命中 → 不能排除问题,需增加运行时长或改 ASan
4 分步排查过程(工具使用)
4.1 开启 Sanitizer 检测
4.1.1 使用约束
- 任一模块使能 ASan 时,entry 模块必须同时使能,否则应用启动闪退并出现 CppCrash;
- ASan / HWASan / GWP-ASan 互斥,不可同时开启;
- 优化可能删除无可观察效果的故障测试代码;验证用例应通过
noinline/volatileC helper 或降低优化等级保留真实访问,不能用一次未上报证明没有问题; - RNOH 的
librnoh_core.so、librnoh_core_package.so、libreactnative.so、libjsvmtooling.so(或libhermestooling.so)均以源码构建,开启 ASan 时可一并插桩;libjsvm.so和libhermesvm.so为预编译产物,不受影响。
4.1.2 ASan 检测开启方式
方式一 调试窗口快速使能(DevEco Studio):运行调试窗口 → Diagnostics → 勾选 Address Sanitizer。引用本地 library 时,需在 library 模块 build-profile.json5 配置 "arguments": "-DOHOS_ENABLE_ASAN=ON" 以 ASan 模式编译 so。
方式二 配置文件方式使能:
AppScope/app.json5添加"asanEnabled": true;- 模块级
build-profile.json5添加"arguments": "-DOHOS_ENABLE_ASAN=ON",对该模块本次参与构建的 C/C++ 源码开启 ASan 编译;该参数不会改写已经预编译好的 SO。
RNOH 模块配置示例(react_native_openharmony/build-profile.json5):
{
"buildOptionSet": {
"debug": {
"nativeCompiler": "BiSheng",
"cppFlags": "-fstack-protector-strong -Wl,-z,noexecstack",
"arguments": "-DOHOS_ENABLE_ASAN=ON"
}
},
"targets": {
"default": {
"debugSymbol": {
"strip": true,
"exclude": [
"librnoh_core.so",
"librnoh_core_package.so",
"libreactnative.so",
"libjsvmtooling.so",
"libhermestooling.so"
]
}
}
}
}
debugSymbol.exclude列表中的 SO 保留未剥离符号,用于 FaultLog 符号化回溯。开启 ASan 时建议保留该配置。
4.1.3 HWASan / GWP-ASan 检测开启方式
| 检测器 | app.json5 开关 | 模块 build-profile.json5 arguments |
|---|---|---|
| ASan | "asanEnabled": true | -DOHOS_ENABLE_ASAN=ON |
| HWASan | "hwasanEnabled": true | -DOHOS_ENABLE_HWASAN=ON |
| GWPASan | "GWPAsanEnabled": true | 无 |
HWASan 当前官方约束包括 ROM 版本 5.0.0.107 及以上,并与 ASan、TSan、UBSan、GWP-ASan 互斥。API 20 及以上如需灰度控制 GWP-ASan,可使用 HiDebug enableGwpAsanGrayscale(options, duration);其中 sampleRate 默认 2500,maxSimutaneousAllocations 默认 1000。
4.1.4 ASAN_OPTIONS 运行时配置
ASAN_OPTIONS 在运行时配置 ASan 行为(检测级别、输出格式、报告详细度)。支持在 app.json5 的 appEnvironments 配置(优先级高),或在 Run/Debug Configurations 配置,两种方式都配则以 app.json5 为准。
app.json5 配置示例:
{
"app": {
"appEnvironments": [
{ "name": "ASAN_OPTIONS", "value": "log_exe_name=true:abort_on_error=0:print_cmdline=true" }
]
}
}
示例参数(按需配置,不建议整组照搬):
abort_on_error=0
halt_on_error=0
log_exe_name=true
detect_stack_use_after_return=0
malloc_context_size=30
不建议直接复制关闭
handle_segv、report_globals、alloc_dealloc_mismatch等检查项的整组配置,否则可能主动缩小检测范围。参数默认值还可能随系统运行库版本变化,应以目标 ROM 的官方文档和实际日志为准。
常用参数详见附录 B。
4.2 RNOH C++ 产物插桩验证
RNOH 工程必须同时满足两层条件:
- CMake 编译层:通过
-DOHOS_ENABLE_ASAN=ON(或-DOHOS_ENABLE_HWASAN=ON)对RNOH/、RNOHCorePackage/、third-party/rn/等源码目录的 C++ 编译开启 sanitizer,生成带插桩的librnoh_core.so等; - HarmonyOS 应用层:配置
app.json5开关和 Native 模块构建参数,使应用加载匹配的 Sanitizer 运行环境。
仅完成第 2 层,不能给预编译的 libjsvm.so / libhermesvm.so 补上普通 load/store 检查。构建后必须检查最终打入 HAP 的文件,不要只检查 build-profile.json5 参数:
LLVM_BIN=/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/native/llvm/bin
RNOH_CORE=packages/tester/harmony/react_native_openharmony/src/main/cpp/libs/arm64-v8a/librnoh_core.so
# 检查动态依赖中是否包含 sanitizer 运行库
"$LLVM_BIN/llvm-readelf" -d "$RNOH_CORE" | grep -Ei 'asan|hwasan'
# 检查未定义符号中是否包含 sanitizer 访问检查函数
"$LLVM_BIN/llvm-nm" -u "$RNOH_CORE" | grep -E '__asan_|__hwasan_'
判断要点:
- ASan 产物应能看到
libclang_rt.asan.so依赖或__asan_*相关符号,HWASan 同理检查libclang_rt.hwasan.so/__hwasan_*; - 还应反汇编故障访问函数,确认普通读写附近存在 Sanitizer 检查调用;只看到
malloc/freeinterceptor 不能证明普通访问已插桩; - 对
librnoh_core_package.so、libreactnative.so、libjsvmtooling.so重复上述检查,确认所有以源码构建的 RNOH SO 均已完成插桩; libjsvm.so、libhermesvm.so等预编译产物检查结果应为空,这是预期行为,不表示这些库内部访问被检测。
4.3 故障日志获取
官方给出三条主要路径:
- DevEco Studio 的 FaultLog。
- HiAppEvent 订阅
hiAppEvent.event.ADDRESS_SANITIZER,从事件参数external_log获取日志。 - 打开开发者选项后,通过 hdc 导出:
hdc file recv /data/log/faultlog/faultlogger/XX.log ./faultlogger/
日志默认位于:
/data/log/faultlog/faultlogger/
文件名通常为:
[检测器类型]-[bundleName]-[uid]-[happenedTime].log
RNOH 崩溃日志中的关键 SO 标识:
librnoh_core.so(RNOH 核心引擎)、librnoh_core_package.so(核心组件包)、libreactnative.so(上游 RN 核心)、libjsvmtooling.so(JSVM 引擎封装)、libhermestooling.so(Hermes 引擎封装)。符号化时需使用对应 BuildId 的未剥离版本,使用llvm-addr2line还原函数名和代码行:
LLVM_BIN=/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/native/llvm/bin
"$LLVM_BIN/llvm-addr2line" -e librnoh_core.so 0x58f50
# 输出示例: /path/to/RNOH/MountingManagerCAPI.cpp:123
4.4 ASan 报告解读
ASAN 报告首行 ERROR: AddressSanitizer: <类型>,后跟访问点 + 影子内存区域 + 分配/释放栈。常见类型:
| ASAN 类型 | 含义 | RNOH 触发条件 |
|---|---|---|
heap-buffer-overflow | 堆越界 | folly::dynamic 数组越界;m_children.insert 越界 |
stack-buffer-overflow | 栈越界 | C++ 局部数组访问越过对象边界(红区 f1/f2/f3) |
heap-use-after-free | 堆释放后使用 | VSync 回调访问已销毁 AnimatedNodesManager;图片回调读悬空 uri |
stack-use-after-scope | 栈作用域后使用 | isCAPIComponentByTag 中临时 std::string 的 c_str() 逃逸 |
double-free | 重复释放 | napi_delete_reference 与析构双重释放 |
attempt-free-nonallocated-memory | 非法释放 | 手动释放 napi_get_arraybuffer_info 返回的 buffer |
日志规格示例(含字段注释,以 RNOH 中常见的 heap-use-after-free 为例):
Device info:XXX <- 设备信息
Build info:XXX-XXXX x.x.x.xx(xxxxxxx) <- 版本信息
Fingerprint:77cdc69cef71... <- 特征信息
Timestamp:xxxx-xx-xx xx:xx:xx.xxx <- 时间戳
Module name:com.rnoh.tester <- 模块名
Version:1.0.0 <- 版本号
Pid:62642 <- 进程号
Uid:20020185 <- 用户ID
Reason:heap-use-after-free <- 触发原因
==appspawn==62642==ERROR: AddressSanitizer: heap-use-after-free on address 0x0002013c0100 at pc 0x007c7e718f54 bp 0x007fffd59710 sp 0x007fffd59708 <- 问题概述
WRITE of size 4 at 0x0002013c0100 thread T240 (RNOH_JS) <- 越界大小, RNOH JS 线程
#0 0x7c7e718f50 (/data/storage/el1/bundle/libs/arm64/librnoh_core_package.so+0x58f50) (BuildId: ...) <- 调用栈, 首个业务帧
#1 0x7b5710235c (/data/storage/el1/bundle/libs/arm64/libreactnative.so+0x4235c) (BuildId: ...)
#2 0x7b74ee79a4 (/system/lib64/module/arkcompiler/stub.an+0x4f89a4)
#3 0x7b749fa8c0 (/system/lib64/module/arkcompiler/stub.an+0xb8c0)
Shadow bytes around the buggy address: <- 影子内存信息
0x001ffffab2c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x001ffffab2d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x001ffffab2e0: 00 00 00 00 fa fa fa fa[fd]fd fd fd fd fd fd fd <- [fd] 即已释放堆内存 (Freed heap region)
0x001ffffab2f0: fd fd fd fd 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
ASan internal: fe
==appspawn==62642==ABORTING
freed by thread T0 here: <- 释放栈 (主线程销毁实例)
#0 0x7c7e718de4 (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x58de4) (BuildId: ...)
#1 0x7c7e718abc (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x58abc) (BuildId: ...)
previously allocated here: <- 申请栈
#0 0x7c7e718000 (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x58000) (BuildId: ...)
#1 0x7c7e717fcc (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x57fcc) (BuildId: ...)
ASAN 报告关键部分:
- 日志头:Device/Build/Fingerprint/Timestamp/Module/Pid/Uid/Reason,Reason 即踩内存类型;
- 问题概述:
ERROR: AddressSanitizer: <类型> on address ... at pc ...,含访问地址 + PC + BP + SP;- 访问点:
WRITE/READ of size N at ... thread T0/T240,N 为访问字节数,RNOH JS 线程名为RNOH_JS;- 调用栈:
#0..#N即越界点调用路径,首个业务帧是优先排查的违规访问点;so+偏移需结合匹配 BuildId 的未剥离 SO 做llvm-addr2line符号化;- 影子内存:
Shadow bytes around the buggy address+ legend,[fd]等标记即越界到的区域类型;- 三栈对照:访问栈(
#0)+ 分配栈(allocated by ... here)+ 释放栈(freed by ... here),用于定位 free 点与误用点的时间差和线程差异。
4.5 HWASan 报告解读
HWASAN 利用 AArch64 TBI 保存指针标签,并通过编译期插桩校验指针标签与内存标签。日志格式与 ASan 相近,但额外输出指针标签与内存块标签(tags: ptr/mem),通过对比标签判断非法访问。
日志规格示例(含字段注释,以 RNOH 中 EventBeat UAF 为例):
Device info:XXX <- 设备信息
Build info:XXX-XXXX x.x.x.xx(xxxxxxx) <- 版本信息
Fingerprint:77cdc69cef71... <- 特征信息
Timestamp:xxxx-xx-xx xx:xx:xx.xxx <- 时间戳
Module name:com.rnoh.tester <- 模块名
Pid:62642 <- 进程号
Uid:20020185 <- 用户ID
Reason:use-after-free <- 触发原因
==appspawn==62642==ERROR: HWAddressSanitizer: tag-mismatch on address 0x0002013c0100 at pc 0x00651a5c0fa0 <- 问题概述
WRITE of size 4 at 0x0002013c0100 tags: d2/11 (ptr/mem) in thread T240 (RNOH_JS) <- 指针tag/内存tag 不匹配
#0 0x651a5c0fa0 (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x2c0fa0) (BuildId: ...) <- 调用栈
#1 0x651a49bfb0 (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x19bfb0) (BuildId: ...)
...
[0x0002013c0100,0x0002013c0140) is a small allocated heap chunk; size: 64 offset: 0 <- 被踩内存信息
Potential Cause: use-after-free <- 可能的越界原因1
0x0002013c0100 (old ptr tags: 5d) is located 0 bytes inside of 48-byte region [0x0002013c0100,0x0002013c0130)
freed by thread T0 here: <- 释放栈
#0 0x5a3f868f98 (/system/asan/lib64/libclang_rt.hwasan.so+0x28f98) (BuildId: ...)
#1 0x650009d6b4 (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x1d6b4) (BuildId: ...)
...
previously allocated here: <- 申请栈
#0 0x5a3f868ce8 (/system/asan/lib64/libclang_rt.hwasan.so+0x28ce8) (BuildId: ...)
#1 0x650009a840 (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x1a840) (BuildId: ...)
...
Memory tags around the buggy address (one tag corresponds to 16 bytes): <- tag memory 储存 tag,与指针 tag 比对
0x005c2013bf90: b6 b6 08 00 1f 1f 08 00 5a 5a 08 00 45 45 08 00
...
=>0x005c2013c010:[11] 11 08 00 a5 a5 08 00 13 13 08 00 2e 2e 08 00 <- [11] 即被踩内存的 tag,与指针 tag d2 不匹配
...
Registers where the failure occurred (pc 0x00651a5c0fa0): <- 故障时寄存器
x0 ec00000201e4ee88 x1 3c000004004c6b38 x2 0000000000000000 x3 0000000000000000
...
SUMMARY: HWAddressSanitizer: tag-mismatch (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x2c0fa0)
Process memory map follows: <- 故障时进程内存空间
0x000000010000-0x000100020000 rw-p 00000000 [anon:SizeClassAllocator]
...
HWASAN 报告关键部分(与 ASan 的差异):
- **
tags: d2/11 (ptr/mem)**:指针 tag(d2)与内存 tag(11)不匹配即触发,是 HWASAN 的核心判据;Potential Cause/ **Cause**:HWASan 只报 tag-mismatch,具体子类型(use-after-free / heap-buffer-overflow)在此推断,结合freed by ... here释放栈 +previously allocated here申请栈定位;- **
Memory tags around the buggy address**:tag memory 储存的 tag 列表,每 tag 对应 16 字节,[11]即被踩内存的 tag;Registers where the failure occurred+ **Memory near registers**:故障寄存器与周边内存,辅助排查;- 真机
hwasanEnabled开启后适合开发态复现偶发 UaF(如 RNOH 中EventBeat/RuntimeScheduler销毁后UITicker触发回调的场景)。
4.6 GWP-ASan 日志解读
GWP-ASan 的日志格式较为简洁,以下示例为典型的 Use-After-Free 问题日志:
Device info:XXX <- 设备信息
Build info:XXX-XXXX x.x.x.xx(xxxxxxx) <- 版本信息
Fingerprint:c41391f9c18acc1121ea519ffdba5698bfb5342ae7125e20ebf2865e31249f1a <- 特征信息
Timestamp:xxxx-xx-xx xx:xx:xx.xxx <- 时间戳
Module name:com.rnoh.tester <- 模块名
Version:1.0.0 <- 版本号
Pid:13305 <- 进程号
Uid:20020181 <- 用户ID
Reason:GWP-ASAN <- Reason 固定为 GWP-ASAN
*** GWP-ASan detected a memory error ***
Use After Free at 0x5b46ddaff0 (0 bytes into a 16-byte allocation at 0x5b46ddaff0) by thread 13305 here: <- 问题概述(UAF)
#0 0x5c474f049c (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x3049c) <- 违规访问栈
#1 0x5c474fa8c0 (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x3a8c0)
...
0x5b46ddaff0 was deallocated by thread 13305 here: <- 释放栈
#0 0x5aa0c0be2c (/lib/ld-musl-aarch64.so.1+0x13de2c)
#1 0x5aa0c0b97c (/lib/ld-musl-aarch64.so.1+0x13d97c)
#2 0x5c474f0494 (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x30494)
...
0x55b46ddaff0 was allocated by thread 13305 here: <- 分配栈
#0 0x5aa0c0be2c (/lib/ld-musl-aarch64.so.1+0x13de2c)
#1 0x5aa0c0b77c (/lib/ld-musl-aarch64.so.1+0x13d77c)
#2 0x5aa0c22e5c (/lib/ld-musl-aarch64.so.1+0x154e5c)
#3 0x5c474f047c (/data/storage/el1/bundle/libs/arm64/librnoh_core.so+0x3047c)
...
* End GWP-ASan report *
GWP-ASan 日志三栈结构与 ASan 一致:违规访问栈(
Use After Free ... by thread ... here)+ 释放栈(was deallocated ... here)+ 分配栈(was allocated ... here)。Reason固定为GWP-ASAN,与 ASan 的heap-buffer-overflow/ HWASan 的tag-mismatch区分。因采样检测,同一代码路径需多次复现才有概率命中。
5 根因定位
结合 faultlog 报告首行类型、访问栈 #0、分配/释放栈,按以下模式逐项定位根因。以下案例均基于 RNOH 历史修复记录,涵盖 0.72 / 0.77 / 0.82 版本。
5.1 堆释放后使用:VSync 回调晚到访问已销毁动画节点
日志特征:报告 ERROR: AddressSanitizer: heap-use-after-free,访问栈 #0 指向 AnimatedNodesManager::runUpdates(JS 线程),释放栈指向 RNInstanceInternal 析构(主线程)。
三栈分析:
- 访问栈:
runUpdates→ 访问已销毁的动画节点对象; - 释放栈:
RNInstanceInternal::~RNInstanceInternal()→ 主线程销毁实例,释放了AnimatedNodesManager; - 分配栈:
RNInstanceInternal::onAnimationStarted()→ 注册 UITick 回调时捕获了this裸指针。
根因:VSync/UITick 回调捕获了实例的裸指针(this),在实例销毁后仍执行 runUpdates,访问已释放的 AnimatedNodesManager 节点。访问线程(JS)与释放线程(主线程)不同,是典型的跨线程回调晚到 UAF。
历史案例:0.77.18 — fe72ed2ed / !790
5.2 堆释放后使用:图片回调读悬空 URI
日志特征:报告 ERROR: AddressSanitizer: heap-use-after-free,访问栈指向 ImageComponentInstance::onComplete 中 strlen + jsi::String::createFromAscii(JS 线程),释放栈指向主线程实例销毁。
三栈分析:
- 访问栈:ON_LOAD 回调 →
m_imageSource.uri.c_str()→ 解引用悬空指针; - 释放栈:主线程
ImageComponentInstance::~ImageComponentInstance()→uri字符串已释放; - 分配栈:
ImageComponentInstance::setImageSource()→m_imageSource.uri分配。
根因:JS 线程执行 ON_LOAD 回调时直接读取 m_imageSource.uri.c_str(),但主线程已销毁 ImageComponentInstance 实例,uri 字符串已释放。回调中通过 [=] 捕获了 this 指针,但 this 指向的对象已不存在。
历史案例:
- 0.82.3:409e3d355 / !1985
- 0.77:0.77 内存异常 #10
5.3 栈作用域后使用:ShadowView 组件名悬空指针
日志特征:报告 ERROR: AddressSanitizer: stack-use-after-scope,访问栈指向 MountingManagerCAPI::isCAPIComponentByTag。
三栈分析:
- 访问栈:后续调用链消费
ShadowView.componentName(const char*)→ 读到失效内存; - 释放栈:无显式 free——临时
std::string在语句结束时自动析构; - 分配栈:
instance->getComponentName()返回const std::string&,c_str()取其内部 buffer 指针。
根因:isCAPIComponentByTag 先从组件实例取出组件名(返回 const std::string&),再把临时字符串的 c_str() 填给 ShadowView.componentName。临时对象在语句结束后析构,c_str() 指针悬空。后续调用链延迟消费该字段时读到失效内存。
历史案例:0.82.18 — ab62e9d2f / !2384
5.4 空指针与越界:ComponentInstance 子节点操作
日志特征:报告 SIGSEGV 或 heap-buffer-overflow,访问栈指向 ComponentInstance::insertChild 或 MountingManagerCAPI::removeChild。
三栈分析:
- 访问栈:
parent->removeChild(child)或m_children.insert(pos, child)→ 解引用空指针或越界写; - 释放栈:组件已被
ComponentInstanceRegistry注销或 surface stop 时清除; - 分配栈:
ComponentInstanceRegistry::insert(tag, instance)→ 返回的shared_ptr已过期。
根因:findByTag() 在组件未注册或已销毁时返回空指针,调用方未判空直接使用;或 m_children.insert(pos, child) 的 pos 超过 m_children.size(),越界写入导致 heap-buffer-overflow。崩溃地址不稳定时优先考虑踩内存并启用 ASan。
历史案例:
- 0.72:0.72 内存异常 #18-#21
- 0.77:0.77 内存异常 #6, #9
5.5 JSVM 引用生命周期:销毁阶段 UAF
日志特征:报告 ERROR: AddressSanitizer: heap-use-after-free 或 libjsvm.so Mapping error82,访问栈指向 JSVMPointerValue::~JSVMPointerValue 或 HostObjectProxy::Enumerator。
三栈分析:
- 访问栈:
JSVMPointerValue析构 →OH_JSVM_DeleteReference(env, reference)→ env 已失效; - 释放栈:
JSVMRuntime::~JSVMRuntime()→OH_JSVM_DestroyEnv(env)→ env 被关闭; - 分配栈:
JSVMPointerValue::New(env, value)→ 创建引用。
根因:JSVM 环境销毁期间,JSVMPointerValue 仍尝试删除引用或清理包装对象,导致 OH_JSVM_DestroyEnv 过程中访问已失效的 C++/JSVM 状态。JSVMRuntime::~JSVMRuntime 的析构顺序错误——先释放了指针值链表,导致 env 仍在使用中被销毁;或 HostObjectProxy::Enumerator 中 Unwrap 非 wrapped host object 得到空对象后直接解引用。
历史案例:
5.6 NAPI 引用与 Buffer 误释放
日志特征:报告 ERROR: AddressSanitizer: double-free 或 attempt-free-nonallocated-memory。
三栈分析:
- 访问栈:第二次
free或delete同一地址 → double-free;或free(data)释放引擎管理的 buffer → invalid-free; - 释放栈:
napi_wrapfinalizer →delete static_cast<MyClass*>(hint)→ 引擎 GC 回收时释放; - 分配栈:业务代码手动
new MyClass()+napi_wrap()→ 或napi_create_arraybuffer()→ 引擎分配 buffer。
根因:
napi_wrap对象在手动delete与引擎 GC 回收时双重释放——finalizer 和手动释放同时存在;- 手动释放
napi_get_arraybuffer_info/napi_get_buffer_info返回的 buffer 指针——ArrayBuffer 内存由引擎管理,手动free会 double free; - 跨线程使用已失效的
napi_env或超出napi_handle_scope生命周期继续使用napi_value。
历史案例:RNOH 稳定性编码规范 NAPI 章节覆盖此类模式(稳定性编码规范)。
5.7 悬空引用:EventBeat 关闭阶段访问已销毁调度器
日志特征:报告 SIGSEGV 或 heap-use-after-free,访问栈指向 EventBeat::induce。
三栈分析:
- 访问栈:
UITicker触发 →EventBeat::induce()→ 访问RuntimeScheduler已释放对象; - 释放栈:shutdown 过程 →
RuntimeScheduler先于EventBeat被销毁; - 分配栈:
EventBeat构造时持有RuntimeScheduler的裸指针或引用。
根因:shutdown 过程中 UITicker 仍可能触发 EventBeat::induce(),而其依赖的 RuntimeScheduler 已先被销毁,导致悬空引用。EventBeat 和 RuntimeScheduler 的析构顺序没有保证 EventBeat 先于 RuntimeScheduler 停止回调。
历史案例:0.77.22 — 2df264852 / !1595
6 解决方案
6.1 VSync 回调晚到:弱引用 + 回调注销
// 问题代码:回调捕获裸指针
void AnimatedTurboModule::startListening() {
m_vsyncListener->setOnVSync([this]() {
m_animatedNodesManager->runUpdates(); // ← this 可能已销毁
});
}
// 修复:使用 weak_ptr 防止销毁后访问
void AnimatedTurboModule::startListening() {
std::weak_ptr<AnimatedTurboModule> weakSelf = weak_from_this();
m_vsyncListener->setOnVSync([weakSelf]() {
auto self = weakSelf.lock();
if (!self) return; // ← 实例已销毁,安全退出
self->m_animatedNodesManager->runUpdates();
});
}
修复要点:
- 异步回调(VSync、Display、Timer、HTTP、NAPI)在对象销毁后仍可能执行,回调入口必须检查对象有效性;
- 使用
weak_ptr<T> weakSelf = weak_from_this(),回调中auto locked = weakSelf.lock(); if (!locked) return;; - 析构函数或
destroy()方法中必须停止所有异步源:VSync 回调注销、Timer 取消、EventBeat 解绑、observer 反注册; - 参考 0.77.18 修复 fe72ed2ed / !790。
6.2 图片回调悬空 URI:按值拷贝进 lambda capture
// 问题代码:回调直接读取实例成员的 c_str()
void ImageComponentInstance::onComplete() {
m_eventEmitter->emitEvent("onLoad", [=]() {
auto event = jsi::Object(rt);
event.setProperty(rt, "uri", m_imageSource.uri.c_str()); // ← 实例可能已销毁
return event;
});
}
// 修复:按值拷贝进 lambda capture,使回调持有自己的字符串副本
void ImageComponentInstance::onComplete() {
std::string uriCopy = m_imageSource.uri; // ← 按值拷贝
m_eventEmitter->emitEvent("onLoad", [uriCopy]() {
auto event = jsi::Object(rt);
event.setProperty(rt, "uri", uriCopy.c_str()); // ← 使用自己的副本
return event;
});
}
修复要点:
- 跨线程回调中不得直接读取实例成员的指针或
c_str(),应按值拷贝进 lambda capture; - 图片加载回调天然异步且晚到,必须假设回调执行时实例可能已销毁;
- ASan 通过 quarantine 隔离区检测已释放内存访问;HWASan 通过重新标记内存并检查 tag mismatch;
- 参考 0.82.3 修复 409e3d355 / !1985。
6.3 ShadowView 组件名悬空:按值保存延长生命周期
// 问题代码:临时字符串 c_str() 逃逸到 ShadowView
bool MountingManagerCAPI::isCAPIComponentByTag(Tag tag) {
auto instance = m_componentInstanceRegistry->findByTag(tag);
auto name = instance->getComponentName(); // ← 返回 const std::string&
ShadowView shadowView;
shadowView.componentName = name.c_str(); // ← 临时对象销毁后悬空
// ... 后续延迟消费 shadowView.componentName
}
// 修复:先保存到局部 std::string,用该字符串构造 ShadowView
bool MountingManagerCAPI::isCAPIComponentByTag(Tag tag) {
auto instance = m_componentInstanceRegistry->findByTag(tag);
if (!instance) return false; // ← 同时补充空指针检查
std::string componentName = instance->getComponentName(); // ← 按值保存
ShadowView shadowView;
shadowView.componentName = componentName.c_str(); // ← 作用域内有效
// ...
}
修复要点:
c_str()返回的指针生命周期与原std::string绑定,临时对象销毁后即为悬空指针;- ASan 的
stack-use-after-scope(f8 标记)描述 C/C++ 栈对象作用域结束后访问; - 按值保存到局部变量,延长生命周期到整个作用域结束;
- 参考 0.82.18 修复 ab62e9d2f / !2384。
6.4 ComponentInstance 越界:判空 + 边界校验
// 问题代码:findByTag 返回空后直接使用
void MountingManagerCAPI::removeChild(Tag parentTag, Tag childTag) {
auto parent = m_componentInstanceRegistry->findByTag(parentTag);
auto child = m_componentInstanceRegistry->findByTag(childTag);
parent->removeChild(child); // ← parent 或 child 可能为空
}
// 修复:判空保护
void MountingManagerCAPI::removeChild(Tag parentTag, Tag childTag) {
auto parent = m_componentInstanceRegistry->findByTag(parentTag);
auto child = m_componentInstanceRegistry->findByTag(childTag);
if (!parent || !child) return; // ← 空指针保护
parent->removeChild(child);
}
修复要点:
findByTag()在组件未注册或已销毁时返回空,所有调用方必须判空;m_children.insert(pos, child)的pos必须在[begin, end]范围内,越界插入导致heap-buffer-overflow或未定义行为;- 普通运行中
SIGSEGV不一定由 ASan 检测,但崩溃地址不稳定时优先考虑踩内存并启用 ASan; - 参考 0.72 / 0.77 历史修复(0.72 内存异常 #18-#21、0.77 内存异常 #6, #9)。
6.5 JSVM 销毁阶段 UAF:调整析构顺序 + 原型链查找
// 问题代码:销毁阶段仍从 JSVMPointerValue 调用 OH_JSVM_DeleteReference
JSVMPointerValue::~JSVMPointerValue() {
if (reference != nullptr) {
OH_JSVM_DeleteReference(env, reference); // ← env 可能已失效
}
}
// 修复:销毁阶段不再触碰正在关闭的 env,调整析构顺序
JSVMRuntime::~JSVMRuntime() {
// 1. 先销毁 JSVM env/VM
OH_JSVM_DestroyEnv(env);
OH_JSVM_DestroyVM(vm);
// 2. 再释放指针值链表
ReleasePointerValueList(); // ← 移到 VM 销毁之后
}
// 问题代码:Enumerator Unwrap 失败直接解引用空指针
JSVM_Value HostObjectProxy::Enumerator(JSVM_Env env, JSVM_Value properties, JSVM_Value object) {
auto hostObject = Unwrap(object); // ← 非 wrapped host object 时返回空
hostObject->getKeys(keys); // ← 空指针解引用
}
// 修复:Unwrap 失败时沿原型链继续查找
JSVM_Value HostObjectProxy::Enumerator(JSVM_Env env, JSVM_Value properties, JSVM_Value object) {
auto hostObject = Unwrap(object);
if (!hostObject) {
hostObject = FindHostObjectOnPrototypeChain(object); // ← 沿原型链查找
if (!hostObject) {
// 确实找不到时抛错,而非直接解引用空指针
throw JSVMError("Not a wrapped HostObject");
}
}
hostObject->getKeys(keys);
}
修复要点:
- JSVM 引用包装对象的析构顺序必须保证 env 存活时才操作 reference;
OH_JSVM_DestroyEnv期间不应从JSVMPointerValue调用OH_JSVM_DeleteReference;HostObjectProxy::Enumerator中 Unwrap 失败时应沿原型链继续查找,不应直接对空指针执行枚举逻辑;- 参考 0.82.17-0.82.18 修复 7029f4747 / !2337、431297d2e / !2361、15c664801 / !2387。
6.6 NAPI 引用误释放:唯一所有者 + 引擎管理边界
// 问题代码:手动释放引擎管理的 buffer
napi_value arraybuffer;
void* data;
size_t length;
napi_get_arraybuffer_info(env, arraybuffer, &data, &length);
free(data); // ← double-free:ArrayBuffer 内存由引擎管理
// 修复:不要手动释放引擎管理的 buffer
// data 的生命周期与 ArrayBuffer 对象绑定,由引擎 GC 回收
// 问题代码:napi_wrap 对象双重释放
napi_wrap(env, jsObject, nativePtr, [](napi_env, void* hint) {
delete static_cast<MyClass*>(hint); // ← 引擎回收时调用
}, nullptr, nullptr);
// ... 其他地方又手动 delete nativePtr ← double-free
// 修复:由唯一所有者释放一次
// 要么由 wrap 的 finalizer 负责释放,要么手动释放后调用 napi_remove_wrap
修复要点:
napi_create_reference必须配对napi_delete_reference;napi_get_arraybuffer_info/napi_get_buffer_info拿到的 buffer 指针由引擎管理,手动free会 double free;napi_wrap的 finalizer 和手动释放只能存在一个,不能同时执行;手动释放后应调用napi_remove_wrap;napi_value的生命周期受napi_handle_scope控制,超出 scope 后不应继续使用;napi_env/napi_ref/napi_value均不可跨线程使用(非 JS 线程调用 NAPI 直接引发崩溃)。
6.7 EventBeat 悬空引用:weak_ptr + 析构顺序
// 问题代码:EventBeat 持有 RuntimeScheduler 裸指针
class EventBeat {
RuntimeScheduler* m_scheduler; // ← 裸指针,可能悬空
public:
void induce() {
m_scheduler->scheduleWork(); // ← m_scheduler 可能已销毁
}
};
// 修复:改用 weak_ptr + 析构时停止回调
class EventBeat {
std::weak_ptr<RuntimeScheduler> m_schedulerWeak;
std::mutex m_mutex; // ← 保护 listener 并发访问
public:
void setScheduler(std::shared_ptr<RuntimeScheduler> scheduler) {
m_schedulerWeak = scheduler;
}
void induce() {
auto scheduler = m_schedulerWeak.lock();
if (!scheduler) return; // ← 调度器已销毁,安全退出
scheduler->scheduleWork();
}
};
修复要点:
EventBeat和RuntimeScheduler的析构顺序必须保证EventBeat先停止回调;- 使用
weak_ptr代替裸指针,回调中lock()后判空; - 对
m_unsubscribeUITickerListener等共享访问的成员加互斥锁,避免一个线程解绑时另一个线程同时注册或访问; - 参考 0.77.22 修复 2df264852 / !1595。
6.8 修复验证
修复后至少完成以下验证闭环:
- 相同 sanitizer + 相同复现场景:使用与发现问题时一致的 sanitizer 和复现路径,确认不再上报 CppCrash;
- 扩大边界覆盖:增大循环次数并覆盖前后台切换、页面销毁、并发回调等生命周期边界(RNOH 中重点覆盖 Surface stop/reload、RNInstance 销毁、动画中断、图片快速切换);
- 交叉验证:用另一种工具交叉验证,例如 HWASan 修复后用 GWP-ASan 长时间压测,或 ASan 修复后切 HWASan 真机验证;
- 保留构建产物:保留精确的未剥离 SO(
librnoh_core.so、librnoh_core_package.so、libreactnative.so、libjsvmtooling.so)、BuildId、源码版本和构建参数,确保日志栈可随时回溯符号化。
GWP-ASan 特别注意:不能把"一次未复现"作为 GWP-ASan 问题关闭依据——采样检测不命中不代表无 bug,需足够运行时长与分配量。
7 经验总结
7.1 RNOH 踩内存高频模块速查
基于 0.72 / 0.77 / 0.82 历史修复记录(40+ 条内存异常修复),以下模块是踩内存问题的高频区域,分析日志时应优先关注:
| 模块 | 高频问题类型 | 典型触发场景 |
|---|---|---|
AnimatedNodesManager / AnimatedTurboModule | UAF、空指针 | VSync 回调晚于实例销毁 |
ImageComponentInstance | 悬空指针、UAF | 图片回调晚于实例销毁,读 m_imageSource.uri |
MountingManagerCAPI | 悬空指针、空指针、越界 | isCAPIComponentByTag 临时字符串逃逸;findByTag 返回空 |
ComponentInstance / ComponentInstanceRegistry | 空指针、越界 | insertChild/removeChild 时 findByTag 返回空;m_children.insert 越界 |
EventBeat / RuntimeScheduler / UITicker | UAF、悬空引用 | shutdown 时 UITicker 触发 EventBeat::induce(),依赖的 RuntimeScheduler 已销毁 |
JSVMRuntime / JSVMPointerValue | UAF、双重释放、构造时序 | 销毁阶段访问已失效 env;clone/lockWeakObject 不一致;构造初始化顺序错误 |
HostObjectProxy | 空指针 | Enumerator Unwrap 非 wrapped host object 得到空对象 |
RNInstanceInternal | UAF、析构顺序 | 成员析构顺序错误;ArkTSChannel 销毁阶段解绑不彻底 |
EventEmitter | UAF、空指针 | m_eventEmitter 释放后仍被回调访问 |
TextLayoutManager / TextMeasurer | 越界、空指针 | measureLines 读取错误位置索引;text info 为空 |
ScrollViewComponentInstance | 越界、空指针 | 全部删除子项后仍读第一个可见子节点 |
7.2 日志聚类规则
当日志量较大(多份 CppCrash)时,鸿蒙官方推荐按以下规则提取故障特征做聚类分析(参考 OpenHarmony AddrSanitizer 聚类):
| 故障类型 | 聚类提取字段 |
|---|---|
| UAF | 故障类型 + 访问栈前两帧业务栈 + 释放栈前两帧业务栈 |
| Double Free | 故障类型 + 第一次释放栈前两帧 + 第二次释放栈前两帧 |
| 其他越界 | 故障类型 + 访问栈前两帧业务栈 |
聚类前过滤:聚类前过滤 libc.so、libc++.so、libclang_rt.asan.so、libclang_rt.hwasan.so、libv8_shared.so、libjsvm.so 等运行库帧,只保留 RNOH 业务 SO(librnoh_core.so、librnoh_core_package.so、libreactnative.so、libjsvmtooling.so)和相对偏移,确保聚类结果指向业务代码而非运行时。
7.3 防护模式总结
基于 RNOH 历史修复,以下防护模式应作为编码规范固化:
| 问题类型 | 防护模式 |
|---|---|
| 销毁后回调(UAF) | 回调持 weak_ptr<T>;回调入口检查 destroyed_ 标志;析构中停止所有异步源 |
跨线程 c_str() 逃逸 | 按值拷贝到局部变量或 lambda capture;不在跨线程回调中读实例成员的 c_str() |
findByTag 空指针 | 所有调用方判空;insert/remove 前检查 pos 在 [begin, end] 范围内 |
| JSVM 析构顺序 | 先销毁 env/VM,再释放指针值链表;析构中不触碰正在关闭的 env |
| NAPI 引用管理 | napi_create_reference ↔ napi_delete_reference 配对;不手动释放引擎管理的 buffer;napi_wrap finalizer 和手动释放只存一个 |
| EventBeat 悬空引用 | weak_ptr 代替裸指针;析构顺序保证先停回调;共享成员加互斥锁 |
7.4 关键教训
- Sanitizer 未报告不等于安全——预编译 SO 内部访问不在检测范围内,业务代码仍必须保证指针不逃逸、长度不越界;
- SIGSEGV 不一定是空指针——崩溃地址不稳定时优先考虑踩内存(UAF/越界),并启用 ASan;
- GWP-ASan 一次未复现不能关闭问题——采样检测不命中不代表无 bug;
- 跨线程回调是最常见的 UAF 根因——RNOH 中 VSync、HTTP、NAPI、Timer 回调均可能在对象销毁后执行;
c_str()逃逸是隐蔽的悬空指针来源——临时std::string销毁后,c_str()指针即悬空,后续延迟消费时崩溃位置可能不稳定。
附录
附录 A 参考文档
- OpenHarmony:AddrSanitizer(地址越界)检测
- 华为 HarmonyOS:使用 ASan 检测内存错误
- 华为 HarmonyOS:使用 HWASan 检测内存错误
- 华为 HarmonyOS:使用 GWP-ASan 检测内存错误
- OpenHarmony:应用配置文件 app.json5
- RNOH 应用稳定性问题定位指南
- RNOH 稳定性概览
- RNOH 稳定性分析方法
- RNOH 0.72 内存异常历史修复
- RNOH 0.77 内存异常历史修复
- RNOH 0.82 内存异常历史修复
- RNOH 稳定性编码规范
附录 B ASAN_OPTIONS 常用参数表
| 参数 | 默认值 | 说明 |
|---|---|---|
log_exe_name | - | 日志文件名中附加执行文件名,按目标 ROM 文档配置 |
abort_on_error | 0 | 0 用 _exit();1 用 abort() 同时生成 cppcrash 日志 |
halt_on_error | 0 | 0 检测后继续运行;1 结束运行 |
detect_stack_use_after_return | 0 | 是否检查已释放栈空间访问 |
quarantine_size_mb | - | UaF 隔离区大小(MB);增大可提高延迟复用场景命中率,也会增加内存开销 |
malloc_context_size | - | 错误发生时显示的调用栈层数 |
strip_path_prefix | - | 日志文件路径去除前缀(如 /data/storage/el1) |
suppressions | "" | 屏蔽文件名 |
更多参数见 asan_flags。
附录 C RNOH 关键 SO 库速查
| SO 名称 | CMake target | 用途 | 源码构建 | 可 ASan 插桩 |
|---|---|---|---|---|
librnoh_core.so | rnoh_core | RNOH 核心引擎 | 是 | 是 |
librnoh_core_package.so | rnoh_core_package | 核心组件包 | 是 | 是 |
libreactnative.so | reactnative | 上游 RN 核心 | 是 | 是 |
libjsvmtooling.so | jsvmtooling | JSVM 引擎封装 | 是 | 是 |
libhermestooling.so | hermestooling | Hermes 引擎封装 | 是 | 是 |
libjsvm.so | 系统预编译 | JSVM 运行时 | 否 | 否 |
libhermesvm.so | 预产物 | Hermes VM | 否 | 否 |
libv8_shared.so | 系统预编译 | V8 运行时(JSVM 底层) | 否 | 否 |
更多推荐

所有评论(0)