返回

解决 Unity iOS C++ 静态库的 EntryPointNotFoundException

IOS

搞定 Unity iOS C++ 静态库的 EntryPointNotFoundException

在 Unity 项目里,想用 C++ 写点高性能或者平台相关的功能,然后打包成静态库给 C# 调用,这挺常见的。特别是在 iOS 平台上,搞个 .a 静态库 вроде 是个不错的选择。但有时候,明明 C++ 代码编译好了,库也放对了地方,C# 里一调用,啪,一个 EntryPointNotFoundException 就砸脸上了,告诉你找不到那个 C++ 函数。

就像遇到的这个问题:C++ 写了个 IsUserSubscribed 函数,放进了 libMonetization.a 静态库。库文件也乖乖躺在 Unity 项目的 Plugins/iOS 目录下。C# 代码里用 [DllImport("__Internal")] 去声明这个函数,签名看着也没错:

[DllImport("__Internal")]
private static extern bool IsUserSubscribed(string userId);

编译 C++ 库用的命令是:

clang -c Monetization.cpp -o Monetization.o -std=c++17
ar rcs libMonetization.a Monetization.o

结果一运行,尤其是在真机上,EntryPointNotFoundException: IsUserSubscribed 就来了。而且,还是在不直接用 Xcode IDE,而是通过命令行构建项目的时候出的问题。这到底是咋回事呢?怎么才能让 Unity 正确找到并调用 C++ 静态库里的函数?

问题根源分析

EntryPointNotFoundException 这个异常,说白了就是:你要调用的那个函数(入口点),在指定的地方(这里是 __Internal,代表主程序本身)找不到。在 Unity iOS 集成 C++ 静态库这个场景下,可能的原因有这么几个:

  1. C++ 函数名“对不上号”: C++ 有个叫“名字修饰”(Name Mangling)的玩意儿,编译器会根据函数名、参数类型、命名空间等信息,把你的函数名变得面目全非,这样才能支持函数重载。但 C# 的 DllImport 默认找的是 C 风格的、未修饰的函数名。
  2. 静态库压根没链接进去: 虽然你把 .a 文件放到了 Plugins/iOS,Unity 在生成 Xcode 项目时通常会自动处理。但如果你用的是自定义的命令行构建流程,可能某个环节跳过了链接库的关键步骤,导致最终的可执行文件里根本没有包含静态库里的代码。
  3. 函数签名不匹配: C# DllImport 里的函数签名(参数类型、返回值类型)必须和 C++ 里的函数声明严格对应,特别是涉及到字符串、复杂结构体等类型时。
  4. 编译架构问题: 你编译 C++ 静态库时使用的目标架构(比如只编译了模拟器 x86_64arm64),和你运行 Unity App 的设备架构(比如真机 arm64)不一致。
  5. 符号被意外剥离: 在 Xcode 项目的构建设置里,有一些关于符号剥离(Symbol Stripping)的选项。如果设置不当,可能在链接或最终打包过程中,把外部需要调用的函数符号给优化掉了。

既然知道了可能的原因,我们就可以对症下药了。

解决方案

下面分几个步骤来排查和解决这个问题。

一、确保 C++ 函数以 C 方式导出

这是最常见的原因。为了防止 C++ 的名字修饰,你需要告诉编译器,这个函数要用 C 的方式暴露出来。

原理:

extern "C" 是一个 C++ ,它告诉编译器,被它修饰的代码(可以是一个函数声明/定义,或一个代码块)要遵循 C 语言的调用约定和命名规则,也就是不进行名字修饰。这样,C# 的 DllImport 就能根据你写的函数名直接找到了。

操作步骤:

  1. 修改 C++ 头文件 (.h 或 .hpp):
    在你的 C++ 函数声明前加上 extern "C"。如果头文件可能被 C 代码包含,最好加上条件编译指令。

    // Monetization.h 或类似头文件
    
    #ifdef __cplusplus
    extern "C" {
    #endif
    
    // 确保你的函数声明在这里
    bool IsUserSubscribed(const char* userId); // 注意:这里用 const char* 通常比 std::string 更容易跨语言传递
    
    #ifdef __cplusplus
    }
    #endif
    
  2. 修改 C++ 实现文件 (.cpp):
    确保实现文件里的函数定义也匹配这个声明。注意参数类型,从 C# 传递 string 到 C++,通常对应的是 const char*。你需要处理这个 C 风格字符串。

    // Monetization.cpp
    #include "Monetization.h"
    #include <string>
    #include <iostream>
    // 包含你的 HTTP 请求库等
    
    // 如果头文件已经加了 extern "C",这里不需要重复加
    // 如果没有头文件,或者直接在 .cpp 定义,可以这样写:
    // extern "C" bool IsUserSubscribed(const char* userId) { ... }
    
    bool IsUserSubscribed(const char* userId) {
        if (userId == nullptr) {
            // 处理空指针情况
            return false;
        }
        std::string userIdStr(userId); // 将 C 风格字符串转为 C++ string 使用
        std::cout << "Checking subscription for user: " << userIdStr << std::endl;
    
        // ... 这里是你的 HTTP 请求逻辑 ...
        // 假设你的逻辑返回一个 bool 值
        bool subscribed = false; // 示例返回值
        // ...
    
        return subscribed;
    }
    
  3. 重新编译静态库:
    使用你之前的命令重新编译。

    clang -c Monetization.cpp -o Monetization.o -std=c++17 -arch arm64  # 举例:为 arm64 架构编译
    ar rcs libMonetization.a Monetization.o
    

    (关于架构,后面会详细说)

  4. 更新 C# DllImport 声明:
    确认 C# 端的签名和 C++ extern "C" 声明一致。如果 C++ 改用了 const char*,C# 端通常仍然可以用 string,因为 P/Invoke (Platform Invocation Services) 会自动处理字符串的封送(Marshalling)。

    using System.Runtime.InteropServices; // 需要这个命名空间
    
    // ...
    
    [DllImport("__Internal", CallingConvention = CallingConvention.Cdecl)] // 显式指定 C 调用约定是个好习惯
    private static extern bool IsUserSubscribed([MarshalAs(UnmanagedType.LPStr)] string userId); // 明确指定封送为 C 风格字符串 (LPStr)
    
    // ... 调用时正常传递 C# string 即可
    bool isSubscribed = IsUserSubscribed("some_user_id");
    

    使用 MarshalAs(UnmanagedType.LPStr) 可以更清晰地控制字符串如何传递给 C++。CallingConvention.Cdecl 明确指定了 C 语言常用的调用约定(参数从右到左入栈,调用者清理栈),这通常是 extern "C" 的默认约定。

二、检查静态库在 Unity 中的位置和设置

虽然你已经把 .a 文件放到了 Plugins/iOS,但还是检查一下确保万无一失。

原理:

Unity 会自动识别 Plugins/iOS 目录下的原生代码和库文件,并在生成 Xcode 项目时,将它们配置到合适的 Build Phases 中。对于 .a 静态库,它应该被添加到 Xcode Target 的 "Link Binary With Libraries" 构建阶段。

操作步骤:

  1. 确认路径: 确保你的 libMonetization.a 文件确实位于 Unity 项目的 Assets/Plugins/iOS/ 目录下。路径大小写要正确。
  2. 检查 Unity Inspector 设置:
    • 在 Unity 编辑器里,选中 Assets/Plugins/iOS/libMonetization.a 文件。
    • 查看 Inspector 面板。
    • 确保 "Select platforms for plugin" 中勾选了 "iOS"。
    • 通常对于 .a 文件,不需要设置其他特别的东西(比如 "Add to Embedded Binaries",那是给动态库 Framework 用的)。Unity 应该能自动处理链接。

Unity Inspector for .a file (这里想象一张 Unity Inspector 面板截图,显示了 .a 文件的平台设置)

三、搞定命令行构建时的链接问题

这是针对你提到的“从终端构建项目”的关键点。

原理:

当你通过命令行(比如使用 xcodebuild)构建 Unity 生成的 Xcode 项目时,必须确保链接器 (linker) 被正确告知要去链接你的 libMonetization.a 库。静态库的代码是在链接阶段被合并到最终的可执行文件里的。如果链接器不知道这个库的存在,或者找不到它,那你的 C++ 函数符号自然就不会出现在最终的 App 里,导致 EntryPointNotFoundException

__Internal 这个特殊的库名告诉 DllImport,函数不是在外部动态库里,而是直接在主程序模块内部。这依赖于静态库被成功链接。

操作步骤:

  1. Unity 的标准构建流程:

    • 如果你使用的是 Unity 的 BuildPipeline.BuildPlayer C# API 或者 Unity Cloud Build,并且没有做特别奇怪的定制,Unity 应该 在生成 Xcode 项目时,自动在 Xcode Target 的 "Link Binary With Libraries" 中添加 libMonetization.a
    • 然后,你直接用 xcodebuild 命令编译这个由 Unity 生成的 .xcodeproj 文件,链接应该就是配置好的。
  2. 检查 Xcode 项目设置(即使是命令行构建也要看):

    • 你可以先用 Unity 正常构建出一个 Xcode 项目,然后用 Xcode IDE 打开这个 .xcodeproj 文件检查一下。
    • 选中你的主 Target (通常是 'Unity-iPhone') -> "Build Phases" -> "Link Binary With Libraries"。确认 libMonetization.a 是否在这里列出。
    • 再检查 "Build Settings" -> "Linking" -> "Other Linker Flags"。Unity 可能会在这里添加 -lMonetization 这样的标志(注意,-l 后面的名字是去掉了 lib 前缀和 .a 后缀的库名)。
  3. 处理自定义命令行构建脚本:

    • 如果你用的是自定义脚本,完全绕开了 Unity 的 Xcode 项目生成或者严重修改了它 ,那问题很可能出在这里。
    • 你需要在调用链接器(通常是通过 xcodebuild 的一部分)时,明确告诉它去链接你的库
    • 这通常通过设置 OTHER_LDFLAGS (Other Linker Flags) 来实现。你需要添加一个 -l 标志,后面跟上库名(去掉 lib.a)。
    • 例如,你的命令行构建命令可能需要类似这样加入参数:
    # 这是一个非常简化的示例,具体取决于你的构建脚本
    xcodebuild ... OTHER_LDFLAGS="-lMonetization -L/path/to/your/libs" ...
    
    • -lMonetization 告诉链接器去查找名为 libMonetization.a (或 libMonetization.dylib) 的库。
    • -L/path/to/your/libs 告诉链接器去哪里找这个库。不过,如果库放在 Plugins/iOS,Unity 生成的 Xcode 项目通常会把这个路径自动加到 Library Search Paths,你可能只需要 -lMonetization
    • 关键点: 确认你的命令行构建过程包含了正确的链接器参数。查阅你使用的构建工具(可能是 make, CMake, Fastlane, 或纯 shell 脚本配合 xcodebuild)的文档,了解如何传递链接器标志。

进阶技巧:查看构建日志

在命令行构建时,增加详细输出(比如 xcodebuild-verbose 选项),然后仔细查看日志,找到链接步骤(通常会调用 ld 命令)。确认命令行里包含了 -lMonetization 以及正确的库搜索路径。

四、编译适配目标架构的静态库

iOS 设备和模拟器使用不同的 CPU 架构。你的静态库必须包含你运行 App 的目标环境所需的代码。

原理:

  • iOS 真机设备现在普遍是 arm64 架构。
  • iOS 模拟器在 Intel Mac 上是 x86_64,在 Apple Silicon Mac 上是 arm64
  • 你的 .a 文件需要包含 App 可能运行的所有架构的代码,或者至少包含当前构建目标所需的架构。一个包含多种架构代码的库被称为“胖库”(Fat Library)。

操作步骤:

  1. 确定目标架构: 你是要在真机 (arm64) 运行?还是模拟器?还是两者都要支持?

  2. 分开编译不同架构:
    使用 clang-arch 参数为每个需要的架构编译出 .o 文件。

    # 为真机编译
    clang -c Monetization.cpp -o Monetization_arm64.o -std=c++17 -arch arm64 -isysroot $(xcrun --sdk iphoneos --show-sdk-path) -mios-version-min=11.0 # 指定目标系统和最低版本
    
    # 为 Apple Silicon Mac 上的模拟器编译
    clang -c Monetization.cpp -o Monetization_sim_arm64.o -std=c++17 -arch arm64 -isysroot $(xcrun --sdk iphonesimulator --show-sdk-path) -mios-simulator-version-min=11.0
    
    # 为 Intel Mac 上的模拟器编译
    clang -c Monetization.cpp -o Monetization_sim_x86_64.o -std=c++17 -arch x86_64 -isysroot $(xcrun --sdk iphonesimulator --show-sdk-path) -mios-simulator-version-min=11.0
    

    注意:需要根据你的 Xcode 环境调整 SDK 路径和最低版本号。

  3. 合并成胖库 (Fat Library):
    使用 lipo 工具将不同架构的 .o 文件打包进各自的 .a 文件,或者更常见地,将不同架构的 .a 文件合并成一个 .a 文件。假设我们先为每个架构生成 .a

    ar rcs libMonetization_arm64.a Monetization_arm64.o
    ar rcs libMonetization_sim_arm64.a Monetization_sim_arm64.o
    ar rcs libMonetization_sim_x86_64.a Monetization_sim_x86_64.o
    

    然后合并成一个最终的 libMonetization.a

    lipo -create libMonetization_arm64.a libMonetization_sim_arm64.a libMonetization_sim_x86_64.a -output libMonetization.a
    
  4. 验证胖库包含的架构:
    使用 lipo -info 命令查看你的 .a 文件。

    lipo -info libMonetization.a
    # 期望输出类似:Architectures in the fat file: libMonetization.a are: x86_64 arm64 (for simulator) arm64 (for device)
    # 或者根据你实际包含的架构显示
    
  5. 将这个最终的、包含所需所有架构的 libMonetization.a 放入 Assets/Plugins/iOS/ 目录下。

五、检查符号可见性与剥离设置

虽然相对少见,但也有可能是符号被错误地隐藏或剥离了。

原理:

编译器和链接器有选项可以控制哪些符号(函数名、变量名)是外部可见的。同时,为了减小最终 App 的体积,Xcode 构建过程可能会剥离掉调试符号或所有未使用的符号。如果 IsUserSubscribed 函数被意外标记为内部符号,或者在链接后被剥离了,也会导致找不到。

操作步骤:

  1. 检查 C++ 代码中的符号可见性设置(较少见于简单函数):

    • 在 C++ 中,可以使用 __attribute__((visibility("default"))) (GCC/Clang) 来确保函数是外部可见的。但通常对于简单的 extern "C" 函数,默认就是可见的,不需要特别设置。
  2. 使用 nm 工具检查库文件中的符号:

    • nm 是一个命令行工具,可以列出目标文件或库文件中的符号表。
    • 在终端里运行:
    nm libMonetization.a | grep IsUserSubscribed
    
    • 你应该能看到类似 U _IsUserSubscribed 或者 T _IsUserSubscribed 这样的输出。
      • T 表示符号在文本(代码)段被定义(这是我们期望的!)。
      • U 表示符号是未定义的(即它引用了其他地方的符号)。
      • 注意函数名前面可能有一个下划线 _ ,这是 C 语言符号在某些平台(包括 iOS)上的常见约定。确保你的 C# DllImport 声明中的函数名("IsUserSubscribed")是没有 下划线的,P/Invoke 会处理这个。
    • 如果在 nm 输出里完全找不到 _IsUserSubscribedIsUserSubscribed (带 T 标志),那说明函数要么没编译进去,要么因为某种原因被隐藏或剥离了。回头检查第一步(extern "C")和编译命令。
  3. 检查 Xcode 构建设置中的符号剥离选项:

    • 在 Unity 生成的 Xcode 项目中(或者你的自定义构建配置中),查找 "Build Settings" 下的这些选项:
      • Strip Debug Symbols During Copy (通常设为 Yes for Release builds)
      • Strip Style (控制剥离级别,如 All Symbols, Non-Global Symbols, Debugging Symbols)
      • Deployment Postprocessing (设为 Yes 会启用剥离等优化)
    • 对于 Release 构建,剥离调试符号是正常的。但要确保 "Strip Style" 不是设为 "All Symbols",否则可能把你需要 P/Invoke 的 C 函数也剥离掉。通常设为 "Non-Global Symbols" 或 "Debugging Symbols" 是安全的。
    • 如果你怀疑是符号剥离问题,可以尝试临时将这些选项禁用(比如把 Deployment Postprocessing 设为 No),然后重新构建,看问题是否消失。但这只是定位问题的手段,最终发布时不应随意关闭优化。

安全建议:

当 C++ 代码处理用户 ID 或执行网络请求时:

  • 务必通过 HTTPS 发送请求,防止数据在传输过程中被窃听或篡改。
  • 对从 C# 传入的 userId 进行有效性验证和必要的清理,防止潜在的安全风险(比如注入)。
  • 处理好网络请求的错误情况和超时。

把这些步骤都过一遍,特别是 extern "C" 和命令行构建时的链接器设置,应该就能解决 EntryPointNotFoundException 的问题,让你的 C# 代码顺利调用 C++ 静态库里的函数了。