Unity项目适配华为鸿蒙系统的原生库加载问题排查与解决

1. 问题背景与现象

在近期开发 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: can't enable GNU RELRO protection for ".../libexpat.so": Out of memory
dlopen failed: can't enable GNU RELRO protection for ".../libmyso1.so": Out of memory

这些错误不仅出现在主业务库上,甚至蔓延到了诸多基础依赖库及其子依赖上。至此,排查方向彻底明朗:这并非 Unity C# 侧的调用逻辑问题,也不是简单的文件丢失,而是基础动态库在华为设备的系统装载器(Loader)阶段就遭遇了严重的兼容性失败

3. RELRO 装载机制与底层兼容性问题

日志中反复出现的 can't enable GNU RELRO protection ... Out of memory 极具迷惑性。在排查初期,我们很容易将其误判为设备的物理内存耗尽。但事实上,这里的“Out of memory”指的是虚拟地址空间或内存映射(Memory Mapping)的分配失败

那么另一个关键点RELRO指的是什么?RELRO(Relocation Read-Only)是 Linux/Android 下的一种内存保护机制。它要求动态链接器在加载动态库时,先完成所有的符号重定位,然后将包含重定位信息的内存页标记为“只读”。这能有效防止攻击者篡改 GOT 表进行劫持。

由于我们使用的基础库数量不少,问题产生的原因可能是:

  1. 碎片化装载的代价:项目中存在大量独立的小型 so 文件。每一个独立的 so 在被 dlopen 时,都需要系统为其分配独立的内存空间来建立重定位表和只读保护。
  2. ROM 实现的差异:相比于原生 AOSP 较为宽容的装载器,鸿蒙系统的底层 Loader 实现在处理这种“海量小型库并发装载”时,可能触发了某种内部限制或碎片化瓶颈,导致无法再为新的 RELRO 保护段申请到合适的连续虚拟内存。

既然明确了症结在于“大量独立 so 文件的装载压力”,我们的解决思路就必须从构建源头入手:优化编译选项,减小动态库的体积与重定位开销。

3.1 优化原生库构建策略

此前,我们在构建基础第三方库时,仅考虑了 Android 15+ 所需的 16KB 内存页对齐问题:

# 旧的链接标志
$LINKER_FLAGS = "-Wl,-z,max-page-size=16384,-z,common-page-size=16384"

为了从根本上解决鸿蒙上的 RELRO 报错,我们对链接器和编译器参数进行了全面升级。新的 $LINKER_FLAGS 修改为:

$LINKER_FLAGS = "-Wl,-z,max-page-size=16384,-z,common-page-size=16384,--pack-dyn-relocs=android+relr,--use-android-relr-tags,--gc-sections"

新增参数的核心含义:

  • --pack-dyn-relocs=android+relr:这是最关键的优化。它将传统的、占用空间较大的重定位记录,压缩为更紧凑的 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-size=16384,-z,common-page-size=16384,--pack-dyn-relocs=android+relr,--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_ABI=arm64-v8a",
    "-DANDROID_PLATFORM=android-29",
    "-DCMAKE_FIND_ROOT_PATH=$InstallDir",
    "-DCMAKE_PREFIX_PATH=$InstallDir",
    "-DCMAKE_INSTALL_PREFIX=$InstallDir",
    "-DCMAKE_BUILD_TYPE=Release",
    "-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 ">>> 并行构建已启用"
}

cmake @BuildArgs

if ($LASTEXITCODE -ne 0) {
    Write-Error "[$PackageName] 构建失败!"
    exit 1
}

Write-Host ">>> [$PackageName] 开始安装..."
cmake --build $BuildDir --target install

if ($LASTEXITCODE -ne 0) {
    Write-Error "[$PackageName] 安装失败!"
    exit 1
}

# ================= 7. 生成安装标记 =================
try {
    $MarkerDir = Split-Path -Path $InstallMarker -Parent
    if (-not (Test-Path $MarkerDir)) {
        New-Item -ItemType Directory -Force -Path $MarkerDir | Out-Null
    }

    $Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    Set-Content -Path $InstallMarker -Value "Installed on $Timestamp via cmake-build.ps1 (Success)"
    Write-Host ">>> [$PackageName]  安装标记已生成:$InstallMarker"

} catch {
    Write-Warning "警告:无法生成安装标记文件 ($_)"
}

# ================= 8. 清理 (可选) =================
if ($CleanupAfterBuild) {
    Write-Host ">>> [$PackageName] 正在清理临时文件..."
    if (Test-Path $SourceDir) { Remove-Item -Recurse -Force $SourceDir }
    if (Test-Path $BuildDir) { Remove-Item -Recurse -Force $BuildDir }
    Write-Host ">>> [$PackageName] 清理完成"
} else {
    Write-Host ">>> [$PackageName] 保留临时文件"
}

Write-Host "=========================================" -ForegroundColor Green
Write-Host "[$PackageName]  Build completed successfully!" -ForegroundColor Green
Write-Host "输出目录:$InstallDir"
Write-Host "=========================================" -ForegroundColor Green

采用这套现代化构建策略后,我们生成的 so 文件不仅整体体积显著缩小,其内部的重定位表也被高度压缩。这使得鸿蒙系统的 Loader 能够轻松完成内存映射与只读保护的建立,从而彻底规避了令人头疼的 RELRO 装载异常。

3.2 策略性采用静态库

尽管通过优化链接器标志(如 --pack-dyn-relocs)能显著缓解装载压力,但在实际适配中我们发现,少数底层库即便经过瘦身,依然会在鸿蒙设备上触发 Out of memory 的 RELRO 报错

针对这一顽固问题,有两种办法:

  1. 关闭 RELRO 保护:通过在链接时添加 -Wl,-z,norelro 强制关闭只读重定位段。
  2. 改用静态库集成:将这些“问题库”从动态链接改为静态链接。

这里更推荐择第二种方案。因为 RELRO(重定位只读)是一种重要的安全机制,它能防止攻击者通过篡改全局偏移表(GOT)来劫持程序控制流。如果为了适配而关闭 RELRO(即使用 norelro),虽然能解决内存映射失败的问题,但会严重降低应用的抗攻击能力。在鸿蒙或安卓高版本系统中,这种做法可能导致应用被安全审计拦截,或者面临更高的运行时风险。

因此,为了在兼容性安全性之间取得平衡,最佳实践是:将那些容易触发装载异常的底层库,改为静态库(Static Library)形式进行集成。

其实在原生开发中,“上层业务使用动态库,底层依赖使用静态库”是一种被广泛推崇的架构策略,原因如下:

  • 对于底层库(推荐静态链接):

    • 消除装载碎片:底层库(如 libpng, zlib, expat 等)通常体积小、数量多。如果以动态库(.so)形式存在,每一个都会产生独立的装载开销和 RELRO 内存映射需求。将其改为静态库(.a),在链接阶段直接合并进上层动态库,可以彻底消除这些底层模块的独立装载过程,从而完美规避鸿蒙系统的装载器限制。
    • 接口稳定性:底层库的 ABI 接口通常非常稳定,很少需要像插件一样热更新,因此静态链接不会带来维护上的麻烦。
  • 对于上层业务库(推荐动态链接):

    • 热更新与模块化:上层业务逻辑复杂,可能需要通过动态加载来实现热修复或插件化。
    • 体积控制:上层库体积较大,如果静态链接会导致主程序包体急剧膨胀。

在本次适配实践中,我们将 2~3 个频繁报错的底层基础库(如 expat )从动态库修改为静态库,并在链接主业务库时将它们“打包”进去。经过这一调整,这些库不再作为独立的 so 文件出现在文件系统中,也就不再触发鸿蒙系统的 dlopen 装载流程。这就保证了在保留 RELRO 安全保护的前提下,彻底解决了 can't enable GNU RELRO protection ... Out of memory 的崩溃问题。

3.3 符号可见性与导出

在构建我们自己的编写的原生库时,通常会启用 -fvisibility=hidden 编译选项,旨在将库的内部符号隐藏起来,仅暴露必要的接口。这一举措不仅能减少动态链接的开销,还能有效避免符号冲突。然而,这一优化也带来了一个极易被忽视的问题:如果未显式标记导出接口,所有符号将默认变为“不可见”,导致上层应用(如 Unity C# 侧)无法定位到对应的函数入口。

具体来说,就是在实施了隐藏可见性优化后,如果未正确配置导出宏,应用在运行时会抛出类似以下的异常:

  • C# 侧报错EntryPointNotFoundException
  • Native 侧日志dlopen failed: cannot locate symbol "xxx"

这通常会让开发者误以为是链接阶段遗漏了库文件,但事实上,问题的根源在于符号的可见性(Visibility)被编译器“吃掉”了。在 Linux/Android (GCC/Clang) 编译器中,符号的默认可见性是 default,这意味着该符号可以被外部程序引用。而 -fvisibility=hidden 选项会将所有未显式标记的符号降级为 hidden 级别。

因此,仅仅定义一个空的宏(如 #define TERRAIN_API)是不够的。在鸿蒙/安卓平台上,我们必须显式地使用 __attribute__((visibility("default"))) 来“对抗”编译器的隐藏规则,强制将特定接口导出。

为了解决这一问题,我们需要重构头文件中的导出宏定义。以下是修正后的标准实现:

  1. CMakeLists.txt 配置
    在 Release 模式下开启隐藏可见性,同时确保 Debug 模式下保持默认以便调试。

    target_compile_options(${PROJECT_NAME} PRIVATE 
        $<$<CONFIG:Release>: 
            -DNDEBUG 
            -Oz 
            -fdata-sections -ffunction-sections 
            -fvisibility=hidden          # 隐藏默认符号
            -fvisibility-inlines-hidden # 隐藏内联函数
        >
    )
    
  2. 头文件(.h)导出宏定义
    修正后的宏定义,重点在于 Android/Linux 平台必须显式指定 visibility("default")

    #pragma once
    
    #ifdef _WIN32
        #ifdef TERRAIN_EXPORTS
            #define TERRAIN_API __declspec(dllexport)
        #else
            #define TERRAIN_API __declspec(dllimport)
        #endif
    #elif defined(__ANDROID__) || defined(__linux__)
        // 关键修复:显式声明符号为默认可见,防止被 -fvisibility=hidden 影响
        #define TERRAIN_API __attribute__((visibility("default")))
    #else
        #define TERRAIN_API
    #endif
    

除了符号可见性,C++ 与 C# 的互操作(P/Invoke)还涉及调用约定(Calling Convention)的匹配问题。

  • 使用 extern "C" 防止 C++ 名称修饰
    如果导出的接口是 C++ 类,编译器会对函数名进行“名称修饰”(Name Mangling),导致 C# 侧无法通过原生名称找到函数。建议将导出接口包裹在 extern "C" 块中,或者直接使用 C 语言风格接口。

  • 统一调用约定为 Cdecl
    在 C# 的 DllImport 声明中,务必显式指定调用约定。对于 C/C++ 动态库,通常应使用 CallingConvention.Cdecl,以避免栈溢出或参数传递错误。

    [DllImport("mylib", CallingConvention = CallingConvention.Cdecl)]
    public static extern int MyFunction(int param);
    

通过上述修正,我们既享受了 -fvisibility=hidden 带来的性能与安全性提升,又确保了关键接口能被 Unity C# 侧正确调用,彻底解决了符号找不到的顽疾。

4. 结语

本文的排查与解决方案主要基于 Huawei P30 (鸿蒙 4.0) 这一特定机型与系统版本。时值 2026 年 6 月,无论是硬件性能还是鸿蒙系统的底层架构都在不断演进,因此本文中提到的具体参数在新型号设备上未必完全适用。

然而,技术适配的本质往往万变不离其宗。尽管 P30 和鸿蒙 4.0 的环境看似有些“古老”和特殊,但我们在解决 RELRO 装载异常、DllNotFoundException 以及符号可见性问题时所采用的排查逻辑——即从日志定位、构建参数优化到链接策略调整——对于当前乃至未来的原生开发依然具有极高的参考价值。

希望这篇记录能为正在处理类似底层兼容性难题的开发者提供一些有益的思路与灵感。

posted @ 2026-06-13 22:07  charlee44  阅读(2)  评论(0)    收藏  举报