这次问题发生在“什么都没改”的发布候选包上: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 库的工程,这比记住某个符号名更有复用价值。

参考资料:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐