. 问题背景与现象

发布时间:2026/6/29 22:34:00
. 问题背景与现象 在近期开发 AR 程序时受限于公司测试设备的匮乏笔者只能使用一台多年前的旧机型 Huawei P30 进行真机调试。相比之下我个人的 vivo X Fold5 在 AR 能力上远不及这台 P30新不如旧原因未知因此它成为了本次适配的核心测试机。值得一提的是这台 Huawei P30 已升级至鸿蒙系统。理论上由于众所周知的历史原因鸿蒙设备无法安装谷歌的 ARCore 框架。但诡异的是这台早期机型却成功安装了该框架。推测是早年 ARCore 曾对 P30 做过专项适配而在华为后续的新机型中才彻底切断了支持。这种由于历史遗留问题导致的兼容性断层确实给开发者的环境搭建带来了不少困扰。然而真正的挑战出现在应用运行阶段。我的 Unity 工程集成了部分第三方原生库以及自研的底层库。在我的 vivo X Fold5 上程序运行一切正常但在切换到这台 Huawei P30 时应用却直接崩溃并抛出了以下异常DllNotFoundException: Unable to load DLL libmyso起初笔者怀疑是打包配置遗漏或文件路径错误。但经过反复核对包体结构确认so文件均完整存在。由此笔者基本排除了常规的打包问题将焦点锁定在这大概率是一个由设备、ROM 差异引发的底层动态库装载兼容性问题。2. 原生库显式加载测试既然 C# 侧抛出了DllNotFoundException为了进一步剥离 Unity 引擎的干扰我们需要在更底层的 Java 环境中验证动态库的加载情况。最直接的手段就是绕过 Unity通过原生 Android API 进行显式加载测试。具体而言我们在 Unity 工程中挂载了一个用于探测的 C# 脚本NativeLoadProbe。该脚本会在应用启动时通过 JNI 机制调用一个自定义的 Java 类using UnityEngine; public class NativeLoadProbe : MonoBehaviour { void Start() { #if UNITY_ANDROID !UNITY_EDITOR using var cls new AndroidJavaClass(com.egova.nativecheck.NativeLoadTest); cls.CallStatic(testLoadAll); #endif } }与之对应的 Java 探针类被放置在\Assets\Plugins\Android\src\com\egova\nativecheck\NativeLoadTest.java目录下。在这个类中我们模拟了应用启动时的加载顺序依次调用System.loadLibrary()来加载核心的基础依赖库package com.egova.nativecheck; import android.util.Log; public class NativeLoadTest { private static final String TAG NativeLoadTest; public static void testLoadAll() { load(png16); load(gdal); load(libmyso); } private static void load(String name) { try { System.loadLibrary(name); Log.i(TAG, loadLibrary OK: name); } catch (Throwable t) { Log.e(TAG, loadLibrary FAIL: name , msg t.getMessage(), t); } } }随后我们通过 ADB 工具过滤并抓取底层日志./adb logcat -s NativeLoadTest Unity随着日志的滚动真正的“元凶”终于浮出水面。终端中并未出现常规的库缺失提示而是弹出了一些类似以下的报错dlopen failed: cant enable GNU RELRO protection for .../libexpat.so: Out of memory dlopen failed: cant enable GNU RELRO protection for .../libmyso1.so: Out of memory这些错误不仅出现在主业务库上甚至蔓延到了诸多基础依赖库及其子依赖上。至此排查方向彻底明朗这并非 Unity C# 侧的调用逻辑问题也不是简单的文件丢失而是基础动态库在华为设备的系统装载器Loader阶段就遭遇了严重的兼容性失败。3. RELRO 装载机制与底层兼容性问题日志中反复出现的cant enable GNU RELRO protection ... Out of memory极具迷惑性。在排查初期我们很容易将其误判为设备的物理内存耗尽。但事实上这里的“Out of memory”指的是虚拟地址空间或内存映射Memory Mapping的分配失败。那么另一个关键点RELRO指的是什么RELRORelocation Read-Only是 Linux/Android 下的一种内存保护机制。它要求动态链接器在加载动态库时先完成所有的符号重定位然后将包含重定位信息的内存页标记为“只读”。这能有效防止攻击者篡改 GOT 表进行劫持。由于我们使用的基础库数量不少问题产生的原因可能是碎片化装载的代价项目中存在大量独立的小型 so 文件。每一个独立的 so 在被dlopen时都需要系统为其分配独立的内存空间来建立重定位表和只读保护。ROM 实现的差异相比于原生 AOSP 较为宽容的装载器鸿蒙系统的底层 Loader 实现在处理这种“海量小型库并发装载”时可能触发了某种内部限制或碎片化瓶颈导致无法再为新的 RELRO 保护段申请到合适的连续虚拟内存。既然明确了症结在于“大量独立 so 文件的装载压力”我们的解决思路就必须从构建源头入手优化编译选项减小动态库的体积与重定位开销。3.1 优化原生库构建策略此前我们在构建基础第三方库时仅考虑了 Android 15 所需的 16KB 内存页对齐问题# 旧的链接标志 $LINKER_FLAGS -Wl,-z,max-page-size16384,-z,common-page-size16384为了从根本上解决鸿蒙上的 RELRO 报错我们对链接器和编译器参数进行了全面升级。新的$LINKER_FLAGS修改为$LINKER_FLAGS -Wl,-z,max-page-size16384,-z,common-page-size16384,--pack-dyn-relocsandroidrelr,--use-android-relr-tags,--gc-sections新增参数的核心含义--pack-dyn-relocsandroidrelr这是最关键的优化。它将传统的、占用空间较大的重定位记录压缩为更紧凑的 RELR 格式。这直接减小了 so 文件中的重定位段大小从而降低了装载时的内存映射压力。--use-android-relr-tags使用 Android 平台专用的 RELR 标签确保与安卓/鸿蒙的动态链接器完全兼容。--gc-sections启用垃圾回收机制自动剔除代码中未被引用的函数和数据段进一步缩减最终产物体积。与此同时我们在 C/C 的编译阶段也增加了相应的瘦身标志-DCMAKE_C_FLAGS_RELEASE-DNDEBUG -Oz -fdata-sections -ffunction-sections, -DCMAKE_CXX_FLAGS_RELEASE-DNDEBUG -Oz -fdata-sections -ffunction-sections,其中-Oz代表极致优化体积而-fdata-sections和-ffunction-sections则是将每个数据或函数放入独立的段中配合链接器的--gc-sections实现精准的无用代码剔除。另外在修改构建参数时最好确保-DANDROID_PLATFORM的设置与 Unity 项目的配置保持一致。当前 Unity 工程设置为android-29这决定了编译时可用的 Android API 范围。如果构建脚本中的 API 级别与 Unity 不符可能会导致运行时找不到特定 API 的符号或因系统调用差异引发难以预料的崩溃。完整的 CMake 构建脚本cmake-build.ps1更多完整脚本可参看这个项目如下所示。通过这套现代化的构建管线我们生成的动态库不仅体积更小其内部的内存布局也更加紧凑# cmake-build.ps1 (修改版) param( [Parameter(Mandatory$true)][string]$PackageName, [Parameter(Mandatory$true)][string]$InstallDir, [string[]]$CMakeExtraArgs (), [bool]$ForceRebuild $false, [bool]$CleanupAfterBuild $true, [bool]$EnableParallel $true ) # 1. 从环境变量获取 NDK 路径 if (-not $env:UNITY_NDK) { Write-Error 错误环境变量 UNITY_NDK 未设置请使用 build.ps1 入口脚本运行或手动设置该变量。 exit 1 } $UNITY_NDK $env:UNITY_NDK # 再次验证路径有效性 if (-not (Test-Path $UNITY_NDK)) { Write-Error 错误UNITY_NDK 指向的路径不存在$UNITY_NDK exit 1 } Write-Host 使用 NDK: $UNITY_NDK -ForegroundColor Gray # 全局配置 $SourceBaseDir $pwd\..\Source $BuildBaseDir $pwd # 派生路径 $ZipPath $SourceBaseDir\$PackageName.zip $SourceDir $SourceBaseDir\$PackageName $BuildDir $BuildBaseDir\$PackageName $InstallMarker $InstallDir\installed\$PackageName.installed # 通用链接器标志 (Android 15 16KB Page Size RELRO 优化) $LINKER_FLAGS -Wl,-z,max-page-size16384,-z,common-page-size16384,--pack-dyn-relocsandroidrelr,--use-android-relr-tags,--gc-sections # CMake 公共参数 $CommonCMakeArgs ( -S, $SourceDir, -B, $BuildDir, -G, Ninja, -DCMAKE_TOOLCHAIN_FILE$UNITY_NDK/build/cmake/android.toolchain.cmake, -DANDROID_ABIarm64-v8a, -DANDROID_PLATFORMandroid-29, -DCMAKE_FIND_ROOT_PATH$InstallDir, -DCMAKE_PREFIX_PATH$InstallDir, -DCMAKE_INSTALL_PREFIX$InstallDir, -DCMAKE_BUILD_TYPERelease, -DCMAKE_C_FLAGS_RELEASE-DNDEBUG -Oz -fdata-sections -ffunction-sections, -DCMAKE_CXX_FLAGS_RELEASE-DNDEBUG -Oz -fdata-sections -ffunction-sections, -DCMAKE_SHARED_LINKER_FLAGS_RELEASE$LINKER_FLAGS, -DCMAKE_EXE_LINKER_FLAGS_RELEASE$LINKER_FLAGS, -DCMAKE_MODULE_LINKER_FLAGS_RELEASE$LINKER_FLAGS ) # 2. 检查安装标记 if (-not $ForceRebuild -and (Test-Path $InstallMarker)) { Write-Host -ForegroundColor Green Write-Host [$PackageName] 检测到安装标记跳过构建! -ForegroundColor Green Write-Host 标记路径$InstallMarker Write-Host 如需重建请使用 -ForceRebuild $true Write-Host -ForegroundColor Green exit 0 } if ($ForceRebuild) { Write-Host [$PackageName] 强制重建模式 (ForceRebuild$ForceRebuild) if (Test-Path $InstallMarker) { Write-Host 正在移除旧的安装标记... Remove-Item -Path $InstallMarker -Force } } else { Write-Host [$PackageName] 未检测到安装标记开始构建流程... } # 3. 源码准备 (解压) if (-not (Test-Path $SourceDir)) { Write-Host [$PackageName] 源目录不存在正在解压... if (-not (Test-Path $ZipPath)) { Write-Error 错误找不到压缩包 $ZipPath exit 1 } $ExtractPath Split-Path -Path $ZipPath -Parent Add-Type -AssemblyName System.IO.Compression.FileSystem try { [System.IO.Compression.ZipFile]::ExtractToDirectory($ZipPath, $ExtractPath) } catch { Write-Error 解压失败: $_ exit 1 } if (-not (Test-Path $SourceDir)) { $PotentialDirs Get-ChildItem -Path $ExtractPath -Directory | Where-Object { $_.Name -like *$PackageName* -or $_.Name -like $PackageName* } if ($PotentialDirs) { $RealSource $PotentialDirs[0].FullName Rename-Item -Path $RealSource -NewName $PackageName Write-Host 自动重命名目录为 $PackageName } else { Write-Error 错误解压后仍未找到目录 $SourceDir请检查 Zip 内部结构。 exit 1 } } Write-Host [$PackageName] 解压完成 } else { Write-Host [$PackageName] 源目录已存在跳过解压 } # 4. 清理构建目录 if (Test-Path $BuildDir) { Write-Host [$PackageName] 清理旧构建目录... Remove-Item -Recurse -Force $BuildDir } New-Item -ItemType Directory -Force -Path $BuildDir | Out-Null # 5. 配置 CMake Write-Host [$PackageName] 开始配置 CMake... $AllCMakeArgs $CommonCMakeArgs $CMakeExtraArgs cmake AllCMakeArgs if ($LASTEXITCODE -ne 0) { Write-Error [$PackageName] CMake 配置失败! exit 1 } # 6. 构建与安装 Write-Host [$PackageName] 开始构建... $BuildArgs (--build, $BuildDir) if ($EnableParallel) { $BuildArgs --parallel Write-Host 并行构建已启用