C# 原生互操作与设备 SDK(五):调用约定、位数与 DLL 加载诊断

“DLL 明明就在目录里,为什么仍然提示找不到?”这是设备 SDK 接入中最常见、也最容易误诊的问题。报错里的库名只是加载链入口;真正失败的可能是进程位数、二级依赖、导出符号、调用约定或搜索路径。

本文建立一套从文件、架构、依赖、导出到 ABI 的诊断顺序,并说明如何用 NativeLibrary 管理多平台库选择。

1. 调用约定必须与头文件一致

调用约定(Calling Convention)规定参数如何传递、栈如何清理以及返回值如何交付。现代 64 位平台往往统一了许多历史差异,但声明仍应匹配原生构建约定,尤其是 Windows x86 和回调函数。

LibraryImport 可配合 UnmanagedCallConv 显式声明 cdecl

1
2
3
4
5
6
7
8
9
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

internal static partial class DeviceNative
{
    [LibraryImport("device_sdk", EntryPoint = "device_open")]
    [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
    internal static partial int Open(int index, out nint handle);
}

旧式 DllImport 则使用 CallingConvention 属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
using System.Runtime.InteropServices;

internal static class LegacyNative
{
    [DllImport(
        "device_sdk",
        EntryPoint = "device_open",
        ExactSpelling = true,
        CallingConvention = CallingConvention.Cdecl)]
    internal static extern int Open(int index, out nint handle);
}

不要根据函数名或别的 SDK 猜 Cdecl/StdCall。先看导出宏、函数指针 typedef、项目设置和厂商支持的目标平台。

2. 进程架构决定可加载的库

64 位操作系统可以运行 32 位进程,但单个进程不能把 32 位和 64 位机器代码混装。先输出当前进程信息:

1
2
3
4
5
6
using System.Runtime.InteropServices;

Console.WriteLine($"OS: {RuntimeInformation.OSDescription}");
Console.WriteLine($"OS architecture: {RuntimeInformation.OSArchitecture}");
Console.WriteLine($"Process architecture: {RuntimeInformation.ProcessArchitecture}");
Console.WriteLine($"64-bit process: {Environment.Is64BitProcess}");

检查的是进程架构,不是只看操作系统或 CPU。若 SDK 只有 x86 版本,应用必须以 x86 运行;若同时有 x64 和 Arm64,部署时要按 Runtime Identifier(RID)选择对应资产。

BadImageFormatException 常见于架构不匹配,但也可能表示文件损坏或目标根本不是当前平台可加载的动态库,因此仍要查看文件头。

3. 主库存在不代表依赖完整

设备 SDK 经常形成依赖链:

1
2
3
4
5
DeviceAdapter.dll
└── device_sdk.dll
    ├── vendor_runtime.dll
    ├── camera_transport.dll
    └── Microsoft Visual C++ Runtime / system libraries

vendor_runtime.dll 缺失时,.NET 仍可能把入口库报告为加载失败。还要检查:

  • 依赖库是否部署在加载器能找到的位置;
  • 依赖版本和架构是否一致;
  • Linux 的 SONAME、macOS install name 是否匹配;
  • 运行账户是否有读取/执行权限;
  • 厂商驱动或运行时是否必须单独安装。

不要通过把一堆未知 DLL 复制进系统目录来“试到能跑”。这会隐藏版本来源并污染整机环境。

4. 导出名以二进制为准

头文件中的 C++ 函数可能被名称修饰(Name Mangling),宏也可能改变导出名。常用检查命令如下:

Windows 的 Visual Studio Developer Command Prompt:

1
2
dumpbin /headers device_sdk.dll | findstr machine
dumpbin /exports device_sdk.dll

Linux:

1
2
3
file libdevice_sdk.so
ldd libdevice_sdk.so
readelf -Ws libdevice_sdk.so

macOS:

1
2
3
file libdevice_sdk.dylib
otool -L libdevice_sdk.dylib
nm -gU libdevice_sdk.dylib

这些命令应在库所在目录执行。dumpbin 随 Visual Studio C++ 工具链提供;ldd 会触发动态加载器解析,不能对来源不可信的二进制随意运行。

若导出表中只有类似 ?Open@Device@@... 的符号,说明它暴露的是编译器相关 C++ ABI。长期方案通常是增加 extern "C" 的 C 包装层,而不是把修饰名硬编码进 C#。

5. 明确管理动态库选择

固定库名适合简单部署。若不同平台或 CPU 特性需要选择不同实现,可以为当前程序集注册一个解析器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
using System.Reflection;
using System.Runtime.InteropServices;

internal static partial class DeviceNative
{
    private const string LibraryName = "device_sdk";

    static DeviceNative()
    {
        NativeLibrary.SetDllImportResolver(
            typeof(DeviceNative).Assembly,
            ResolveLibrary);
    }

    private static nint ResolveLibrary(
        string libraryName,
        Assembly assembly,
        DllImportSearchPath? searchPath)
    {
        if (libraryName != LibraryName)
        {
            return 0; // 交回默认解析流程
        }

        string fileName = OperatingSystem.IsWindows()
            ? "device_sdk.dll"
            : OperatingSystem.IsLinux()
                ? "libdevice_sdk.so"
                : OperatingSystem.IsMacOS()
                    ? "libdevice_sdk.dylib"
                    : throw new PlatformNotSupportedException();

        string architecture = RuntimeInformation.ProcessArchitecture
            .ToString()
            .ToLowerInvariant();
        string path = Path.Combine(
            AppContext.BaseDirectory,
            "native",
            architecture,
            fileName);

        return NativeLibrary.Load(path);
    }

    [LibraryImport(LibraryName, EntryPoint = "device_open")]
    internal static partial int Open(int index, out nint handle);
}

这段代码定义的是项目自己的部署布局,发布过程必须把文件放到对应目录。每个程序集只能注册一个 DllImportResolver,应集中管理,不能让多个 SDK 初始化器互相争抢。

NuGet 包可以把平台资产放在 runtimes/{rid}/native/ 下,由恢复与发布流程按 RID 选择。实际支持的 RID 应由构建和测试矩阵决定,不要根据字符串临时拼出一个未测试的平台。适配器层如何把这类平台差异挡在业务之外,见《设备软件架构与控制模型(二):硬件抽象层与设备适配器》。

6. 搜索路径也是安全边界

动态加载器搜索当前目录、应用目录、系统目录或环境变量的规则因平台和配置而异。把可写目录加入全局 PATH,或从当前工作目录加载同名 DLL,可能让攻击者或误操作放入错误二进制。

更稳妥的策略是:

  • SDK 文件随应用或受控安装器部署;
  • 使用明确的应用内路径或 RID 资产;
  • 校验安装包来源、签名或摘要;
  • 记录实际加载的文件版本与路径;
  • 不从用户上传目录、临时目录和网络共享自动加载原生库。

7. 按固定顺序定位问题

  1. 记录操作系统、进程架构和 .NET 版本;
  2. 确认入口文件真实存在且格式、架构正确;
  3. 检查所有原生依赖及驱动前置条件;
  4. 核对实际导出名;
  5. 对照头文件确认调用约定和完整签名;
  6. 用最小控制台程序调用无副作用的版本查询函数;
  7. 最后才接入复杂 UI、服务容器和设备流程。

这个顺序把“无法加载”“找不到符号”和“调用后损坏”分开,避免在 ABI 错误上反复调整文件路径。

总结

原生库加载是一条依赖链,不是一次文件查找。进程架构、依赖库、导出符号、调用约定和搜索路径必须逐层确认;NativeLibrary 能让选择逻辑显式化,但不能修复错误 ABI。

下一篇讨论反向调用:当厂商 SDK 从原生线程回调 C# 时,如何管理函数指针、对象寿命与线程切换。

参考资料

Licensed under CC BY-NC-SA 4.0