HarmonyOS 7 NDK+CMake:HAR合并libc++版本漂移与依赖闭包
这次问题发生在“什么都没改”的发布候选包上:Debug 真机能启动,Release HAP 安装后刚进首页就退出,HiLog 只有 symbol not found: __emutls_get_address。主工程用同一套源码重新编译过,最后才发现新接入的 vision_bridge.har 带来了一份旧 SDK 构建的 Native 库,合包时又混入另一份 libc++_shared.so。代码审查没法看出它们的 ABI 年代差,直到动态链接器装载才爆出来。
我把复现工程命名为 AbiClosureLab,页面叫 NativeLinkPage,任务号 ABI-0931。初始包里有 14 个 .so、27 条 DT_NEEDED 边,扫描到两个 libc++_shared.so 候选,旧候选摘要 3fe2b801,新候选摘要 91ad76c4;libvision_pipe.so 引用了新符号,运行时却命中了旧库。修复目标不是把那一个库手动删掉,而是让 Release 产物只保留同 SDK 生成的一份 C++ 运行库,并在合包后验证完整依赖闭包。

一、源代码一致,不等于运行时 C++ 环境一致
HarmonyOS 把系统 C++ 标准库和应用 C++ 标准库区分开,应用 Native 库依赖随应用发布的 libc++_shared.so。这能隔离系统镜像变化,却也意味着主应用、HAR 与三方 .so 必须在可兼容的 SDK 基线上构建。HAR 自带的运行库版本若与应用不同,最终只能有一个文件落到安装目录,覆盖顺序就变成隐蔽风险。
这个故障最初被误判成“符号表被 strip”。但 strip 影响调试符号,不会凭空补出动态符号。真正证据来自三组数据:每个库的 DT_NEEDED、未定义动态符号集合、包内 libc++_shared.so 摘要。旧库不提供 __emutls_get_address,libvision_pipe.so 却声明需要它,闭包在构建阶段已经不成立。
与之前只校验 Build ID 的符号包流程不同,这次关注的是可装载性:库是否唯一、依赖是否能在 app/ndk namespace 中解析、同一 ABI 目录里是否存在两个来源不同的 C++ runtime。Build ID 对上也不能证明动态依赖能闭合。
二、CMake 先收窄依赖出口
排查时发现一个工程习惯放大了问题:业务库把内部 C++ 类型放在公开头文件里,HAR 的另一个 .so 因此与不同版本 libc++ 产生耦合。修复先把跨库边界改成 C ABI,只让 libvision_pipe.so 暴露 extern "C" 的简单结构;内部继续使用 C++,但不让 STL 类型穿过 NAPI 或库边界。
下面的 CMake 片段没有手工链接系统 libc++.so,应用库使用 SDK 工具链提供的运行库;所有内部库统一由同一 toolchain 构建。--no-undefined 让缺失符号在链接阶段尽早失败,$ORIGIN 只用于包内同级/子目录,不把开发机路径写进 RUNPATH。
# entry/src/main/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.5.0)
project(AbiClosureLab)
add_library(vision_core STATIC vision_core.cpp)
add_library(vision_pipe SHARED vision_pipe.cpp napi_bridge.cpp)
target_compile_features(vision_core PRIVATE cxx_std_17)
target_compile_features(vision_pipe PRIVATE cxx_std_17)
target_compile_options(vision_pipe PRIVATE -fvisibility=hidden)
target_link_options(vision_pipe PRIVATE
"-Wl,--no-undefined"
"-Wl,-z,relro,-z,now"
"-Wl,-rpath,\$ORIGIN")
target_link_libraries(vision_pipe PRIVATE
vision_core
libace_napi.z.so
libhilog_ndk.z.so)
这里不要为了消掉错误把 libc++_shared.so 当普通业务库复制到多个模块。CMake toolchain 会根据目标生成正确依赖,最终应用只应携带一份匹配的 runtime。若接入预编译 HAR,解决办法是让提供方用兼容 SDK 重新构建,而不是在主工程用链接参数掩盖 ABI 差异。
静态库也不是万能答案。把多个冲突库全改成 static 可能引入重复全局状态、许可证和包体问题;更重要的是,它仍不能修复已经编译好的 HAR。工程判断应基于边界所有权:内部实现可静态合入,跨模块公开 ABI 仍应保持 C 接口和单一运行库。
三、扫描器在 HAP 落盘后建立依赖闭包
源目录检查不到合包覆盖,所以门禁必须针对最终 HAP 解包目录执行。ElfScanner.ts 对 arm64-v8a 下 14 个库运行 SDK 随附的 llvm-readelf -d -Ws,解析 NEEDED、RUNPATH、undefined symbols,并为 libc++_shared.so 计算摘要。系统 NDK allowlist 与包内库集合共同构成可解析域。
// tools/ElfScanner.ts
export async function scanElf(soPath: string): Promise<ElfNode> {
const dynamicText = await execFileText(LLVM_READELF, ['-d', soPath])
const symbolText = await execFileText(LLVM_READELF, ['-Ws', soPath])
const needed = [...dynamicText.matchAll(/\(NEEDED\).*\[(.+?)\]/g)].map(m => m[1])
const runpath = dynamicText.match(/\((?:RUNPATH|RPATH)\).*\[(.*?)\]/)?.[1] ?? ''
const undefinedSymbols = symbolText.split('\n')
.filter(line => line.includes(' UND '))
.map(line => line.trim().split(/\s+/).at(-1) ?? '')
.filter(Boolean)
return {
name: path.basename(soPath),
sha256: await sha256File(soPath),
needed, runpath, undefinedSymbols
}
}
export function validateRunpath(node: ElfNode): string[] {
if (!node.runpath) return []
return node.runpath.split(':').filter(p =>
p.startsWith('/') || p.includes('..') || !p.startsWith('$ORIGIN'))
}
RUNPATH 检查和 libc++ 版本检查放在一起,是因为它们最终都回答“运行时会从哪里拿库”。$ORIGIN 指向当前 .so 所在目录,允许的子目录需要显式列入策略;绝对开发机路径、../debug 一类跨目录路径直接阻断。不同产品可以有不同 allowlist,但规则必须针对产物而不是源码猜测。
扫描器只收集事实,不直接打印 PASS。这样它可以被 CI、Hvigor 自定义任务和本地诊断页复用。解析 readelf 文本时要锁定 SDK 工具版本,避免不同主机 readelf 输出格式差异;更稳妥的实现可改成 ELF 解析库,但 Demo 保留可读的文本证据。
四、闭包验收同时检查库和符号
仅验证 NEEDED 文件存在仍不够:libvision_pipe.so 的 libc++_shared.so 文件存在,却可能不提供需要的版本符号。ClosureGate 先保证 runtime 唯一,再把每条 NEEDED 边解析到包内库或 NDK allowlist,最后对应用库的 undefined symbols 做提供者核对。
// tools/ClosureGate.ts
export function verifyClosure(nodes: ElfNode[], ndkAllow: Set<string>): GateReport {
const packaged = new Map(nodes.map(node => [node.name, node]))
const runtimes = nodes.filter(node => node.name === 'libc++_shared.so')
const issues: GateIssue[] = []
if (runtimes.length !== 1) {
issues.push({ code: 'CPP_RUNTIME_COUNT', detail: `expected=1 actual=${runtimes.length}` })
}
for (const node of nodes) {
for (const bad of validateRunpath(node)) {
issues.push({ code: 'RUNPATH_ESCAPE', detail: `${node.name}:${bad}` })
}
for (const dep of node.needed) {
if (!packaged.has(dep) && !ndkAllow.has(dep)) {
issues.push({ code: 'NEEDED_MISSING', detail: `${node.name}->${dep}` })
}
}
}
const runtime = runtimes[0]
if (runtime && !provides(runtime, '__emutls_get_address')) {
issues.push({ code: 'CPP_SYMBOL_MISSING', detail: '__emutls_get_address' })
}
return { taskId: 'ABI-0931', soCount: nodes.length,
neededEdges: nodes.reduce((n, it) => n + it.needed.length, 0), issues,
passed: issues.length === 0 }
}
这个示例专门核对故障符号,正式工程应对所有非弱引用的 UND 符号建立提供者集合,并识别符号版本。还要注意 dlopen 的动态库名不一定出现在 DT_NEEDED 中,业务若使用 dlopen,需维护一份显式清单一并验收,否则静态闭包会漏掉运行时分支。
第一次扫描结果是 CPP_RUNTIME_COUNT=2 和 CPP_SYMBOL_MISSING,任务以 exit code 23 阻断。重新用当前 SDK 构建 vision_bridge.har,并删除 HAR 中重复携带的 runtime 后,第二次结果变为:14 个库、27 条边、runtime 1、missing 0、RUNPATH escape 0、undefined unresolved 0。

五、手机页不是替代构建门禁,而是做发布复核
我额外做了 NativeLinkPage,只在内部验证包中调用 NAPI 的 probe(),展示实际装载的 runtime 摘要、SDK 基线和启动耗时。它不能代替产物扫描,因为页面能打开只能证明当前路径加载成功,不能覆盖未触发的 dlopen 分支;它的价值是让测试同学确认安装的确实是经过门禁的那一包。
最终运行值为任务 ABI-0931,状态 PASSED,构建基线 API 23,HAP entry-default-signed.hap,arm64-v8a,14 个 .so、27 条 NEEDED 边,libc++ runtime 1,摘要 91ad76c4,missing 0、RUNPATH violation 0、unresolved 0,Native probe 启动 146 ms。HiLog 中同样打印 CLOSURE PASS,没有再出现 __emutls_get_address。

六、什么时候必须阻断,什么时候可以告警
runtime 数量不为 1、NEEDED 找不到、符号无法解析、RUNPATH 逃逸,这四类问题都会影响装载确定性,Release 构建应直接阻断。库未 strip、文件偏大、存在未使用导出符号可以先告警,因为它们更偏包体与暴露面治理,不一定导致启动失败。门禁必须把“会崩”和“可优化”分开,否则团队很快会习惯性忽略红灯。
多 ABI 产品要逐目录扫描,不能拿 arm64-v8a 的闭包结论替代 x86_64 或其他目标。HAR 升级、SDK 升级和 Native 三方库替换都应使门禁缓存失效;增量构建若只看文件名,会漏掉同名 runtime 内容变化,至少要把文件摘要放进任务输入。
这次故障最后没有靠“再打一次包”解决,而是把一个运行时偶发退出改造成了产物级证据链:C ABI 收窄边界,CMake 统一工具链,HAP 落盘后扫描依赖,闭包失败就阻断,内部页再核对安装结果。对含 HAR 与 Native 库的工程,这比记住某个符号名更有复用价值。
参考资料:
更多推荐



所有评论(0)