.NET跨平台本地庫引入實戰

.NET跨平台本地庫引入實戰

深入解析 .NET 專案如何優雅引入第三方本地庫,支援 Windows、Linux 多平台,避坑指南

最後更新 2026/4/20 下午11:18
沙漠尽头的狼
預計閱讀 20 分鐘
分類
.NET
標籤
.NET C# 跨平台 Native Library

在做 .NET 開發時,偶爾需要呼叫第三方提供的原生函式庫(Native Library),例如硬體 SDK、加密函式庫或底層通訊元件。這篇文章透過一個實際的 Demo 專案,分享我在引入跨平台原生函式庫時的兩大方案和避坑經驗。

1. 專案準備

Demo 專案使用了一個簡單的 C++ 動態函式庫 TimeMeaning,它提供了一個 API:

// 傳入秒級時間戳記,回傳一段人生/時間意境文案
const char* GetTimeMeaning(int timestampSecond);

時間戳記取模 10 後回傳對應文案,寓意每個時刻都有不同的人生感悟:

static const char* TIME_MEANINGS[] = {
    "黎明破曉,萬物甦醒,新的一天帶來新的希望",
    "晨光熹微,思緒清晰,適合規劃一天的行程",
    "日出東方,陽光燦爛,充滿活力與朝氣",
    "上午時光,精力充沛,專注做事效率高",
    "正午時分,陽光明媚,適合休息片刻",
    "午後暖陽,慵懶愜意,時光靜靜流淌",
    "夕陽西下,餘暉滿天,美好的黃昏時分",
    "夜幕降臨,星光點點,思緒開始沉澱",
    "夜深人靜,皓月當空,適合反思與冥想",
    "午夜時分,萬籟俱寂,夢想在黑暗中萌芽"
};

第三方函式庫通常會提供針對不同平台的版本,目錄結構如下:

Lib/
├── x64/
│   ├── TimeMeaning.dll        # 64 位元 Windows
│   └── (lib)TimeMeaning.so    # 64 位元 Linux
├── x86/
│   └── TimeMeaning.dll        # 32 位元 Windows
└── arm64/
    └── (lib)TimeMeaning.so    # ARM64 Linux

我們希望:

  • 程式碼保持統一:不需要針對每個平台寫不同的呼叫程式碼
  • 自動適配:編譯或發佈時自動選擇對應平台的函式庫檔案

Directory.Build.props 全域巨集定義

首先,我們在方案根目錄建立 Directory.Build.props,預設定義 PLATFORM_WIN_X86 條件編譯巨集:

<Project>
	<PropertyGroup>
		<DefineConstants>$(DefineConstants);PLATFORM_WIN_X86</DefineConstants>
	</PropertyGroup>
</Project>

然後透過 publish.bat 發佈指令碼呼叫 SetPlatformMacro.ps1,在發佈前根據目標平台修改全域巨集定義:

# SetPlatformMacro.ps1
$macro = ""
switch ($Platform) {
    "linux-x64" { $macro = "PLATFORM_LINUX_X64" }
    "linux-arm64" { $macro = "PLATFORM_LINUX_ARM64" }
    "win-x64" { $macro = "PLATFORM_WIN_X64" }
    "win-x86" { $macro = "PLATFORM_WIN_X86" }
}
$content = $content -replace '<DefineConstants>.*?</DefineConstants>', "<DefineConstants>`$(DefineConstants);$macro</DefineConstants>"

這樣在程式碼中就可以方便地區分當前編譯的平台版本:

#if PLATFORM_WIN_X64
var platform = "Windows X64";
#elif PLATFORM_WIN_X86
var platform = "Windows X86";
#elif PLATFORM_LINUX_X64
var platform = "Linux X64";
#elif PLATFORM_LINUX_ARM64
var platform = "Linux ARM64";
#else
var platform = "Unknown";
#endif

2. 兩大方案概述

引入原生函式庫主要分為兩大方案

  1. 動態載入:使用 NativeLibrary API 執行階段手動載入
  2. 靜態載入:使用 DllImport 特性宣告(做了 3 種情況測試)

VC-LTL 和 YY-Thunks(所有範例通用)

所有 4 個範例程式都引入了以下兩個 NuGet 套件,目前測試的範例都支援 Win7 及以上 Windows 版本,以及 Linux 平台:

Windows 7 執行原理:雖然微軟官方 .NET 10 已不再支援 Windows 7,但透過使用 net10.0-windows 目標架構配合 AOT 發佈,可讓程式在 Windows 7 上正常執行。AOT 將 .NET 程式碼靜態編譯為原生可執行檔,完全擺脫對 .NET 執行階段的依賴;配合 VC-LTL 和 YY-Thunks 分別提供輕量 C 執行階段支援和舊版 Windows API 相容層,實現跨版本相容。

<PackageReference Include="VC-LTL" Version="5.3.1" />
<PackageReference Include="YY-Thunks" Version="1.2.1-Beta.4" />
  • VC-LTL:使用開源的 VC 執行階段程式庫,無需安裝系統修補程式,大幅減少程式體積,相容舊系統。配合 AOT( PublishAot=true )發佈可擺脫 .NET 執行階段依賴,直接產生原生可執行檔
  • YY-Thunks:為舊版 Windows 提供新 API 的相容層,讓現代程式碼也能在 Win7/XP 上正常執行(XP 未測試文章範例)

3. 方案一:動態載入(✅ 成功)

動態載入是最靈活的方式,使用 NativeLibrary API 在執行階段手動載入原生函式庫。這種方式的優勢是可以完全自訂函式庫的載入邏輯,能夠處理相同函式庫但儲存在不同目錄、使用不同檔案命名的複雜場景。對於某些特殊需求,比如需要在執行階段根據條件選擇載入不同的版本,或者函式庫的路徑需要動態計算,動態載入是最好的選擇。

程式碼實作

using System.Runtime.InteropServices;

namespace csharp.test.dynamic;

internal static class TimeMeaningNative
{
    // 根據目前作業系統判斷函式庫檔名:Windows 用 dll,Linux 用 so
    private static readonly string DllName = OperatingSystem.IsWindows()
        ? "TimeMeaning.dll"
        : "libTimeMeaning.so";

    // 定義與 C++ 函式相同呼叫慣例的委派,用於後續轉換
    [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
    private delegate IntPtr GetTimeMeaningDelegate(int timestampSecond);

    // 用於儲存轉換後的委派(呼叫時會用到)
    private static GetTimeMeaningDelegate? _getTimeMeaning;
    // 用於儲存函式庫的控制代碼(用於後續釋放)
    private static IntPtr _handle;

    // 靜態建構函式,在第一次使用該類別時自動執行
    static TimeMeaningNative()
    {
        // 拼接函式庫的完整路徑:應用程式根目錄 + Lib 子目錄 + 檔名
        var dllPath = Path.Combine(AppContext.BaseDirectory, "Lib", DllName);

        // NativeLibrary.Load:載入指定路徑的原生函式庫,回傳函式庫控制代碼
        _handle = NativeLibrary.Load(dllPath);

        // NativeLibrary.GetExport:從已載入的函式庫中取得指定名稱的函式位址
        var funcPtr = NativeLibrary.GetExport(_handle, "GetTimeMeaning");

        // Marshal.GetDelegateForFunctionPointer:將函式指標轉換為可呼叫的委派
        _getTimeMeaning = Marshal.GetDelegateForFunctionPointer<GetTimeMeaningDelegate>(funcPtr);
    }

    // 提供手動釋放函式庫的方法,避免記憶體洩漏
    public static void Free()
    {
        if (_handle == IntPtr.Zero) return;
        NativeLibrary.Free(_handle);
        _handle = IntPtr.Zero;
    }

    // 封裝對外的呼叫介面,回傳字串結果
    public static string GetTimeMeaningString(int timestampSecond)
    {
        if (_getTimeMeaning == null)
        {
            throw new InvalidOperationException("動態函式庫未正確載入");
        }

        // 呼叫委派,得到 C++ 函式回傳的指標
        var ptr = _getTimeMeaning(timestampSecond);
        // 將 UTF8 編碼的字元指標轉換為 C# 字串
        return Marshal.PtrToStringUTF8(ptr) ?? string.Empty;
    }
}

程式碼說明

  • NativeLibrary.Load - 核心 API,傳入完整路徑載入原生函式庫
  • NativeLibrary.GetExport - 從已載入函式庫中取得匯出函式的指標
  • Marshal.GetDelegateForFunctionPointer - 將非受控函式指標轉換為 .NET 委派,這樣就可以像呼叫一般方法一樣呼叫原生函式了
  • Marshal.PtrToStringUTF8 - 將 C++ 回傳的 UTF8 字串指標轉換為 C# 字串

專案設定(csproj)

<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<OutputType>Exe</OutputType>
		<TargetFrameworks>net10.0;net10.0-windows</TargetFrameworks>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>

		<WindowsSupportedOSPlatformVersion>6.1</WindowsSupportedOSPlatformVersion>
		<TargetPlatformMinVersion>6.1</TargetPlatformMinVersion>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="CodeWF.Log.Core" Version="11.3.14" />
		<PackageReference Include="VC-LTL" Version="5.3.1" />
		<PackageReference Include="YY-Thunks" Version="1.2.1-Beta.4" />
	</ItemGroup>

	<ItemGroup>
		<!-- 偵錯狀態預設複製 Win x64 的函式庫,便於本機偵錯 -->
		<None Update="Lib\x64\TimeMeaning.dll" Condition="'$(Configuration)' == 'Debug'">
			<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
			<Link>Lib\TimeMeaning.dll</Link>
		</None>
	</ItemGroup>
	<ItemGroup>
		<None Update="Lib\x64\libTimeMeaning.so" Condition="'$(RuntimeIdentifier)' == 'linux-x64'">
			<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
			<Link>Lib\libTimeMeaning.so</Link>
		</None>
		<None Update="Lib\x64\TimeMeaning.dll" Condition="'$(RuntimeIdentifier)' == 'win-x64'">
			<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
			<Link>Lib\TimeMeaning.dll</Link>
		</None>
		<None Update="Lib\x86\TimeMeaning.dll" Condition="'$(RuntimeIdentifier)' == 'win-x86'">
			<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
			<Link>Lib\TimeMeaning.dll</Link>
		</None>
	</ItemGroup>

</Project>

csproj 設定說明

  • Condition="'$(Configuration)' == 'Debug'" - 在 Debug 模式下,預設複製 Windows x64 版本的函式庫,方便直接在 Visual Studio 中偵錯執行
  • Condition="'$(RuntimeIdentifier)' == 'linux-x64'" - 當使用 -r linux-x64 發佈時,複製 Linux 版本的函式庫
  • Link 屬性指定了函式庫在輸出目錄中的路徑,確保與程式碼中載入的路徑一致

關鍵點說明

動態載入流程

  • 執行階段根據作業系統判斷函式庫檔名
  • 拼接完整路徑載入函式庫
  • 取得匯出函式位址
  • 轉換為委派呼叫

4. 方案二:靜態載入

靜態載入使用 DllImport 特性宣告,這是 .NET 中呼叫原生函式庫的標準方式。我們做了 3 種情況測試:主要是測試三方函式庫封裝程式碼是直接放在主專案,還是提取出來透過 NuGet 散佈時,使用條件編譯巨集是否可行、路徑靈活度如何。

條件編譯巨集的兩種定義方式

使用條件編譯巨集前,需要先定義 PLATFORM_XXX 巨集,有兩種方式:

方式 1:在發佈設定檔(pubxml)中定義

Properties/PublishProfiles/*.pubxml 檔案的 <PropertyGroup> 中新增:

<PropertyGroup>
    <DefineConstants>$(DefineConstants);PLATFORM_WIN_X64</DefineConstants>
</PropertyGroup>

方式 2:在專案檔(csproj)中定義

在專案檔的 <PropertyGroup> 中新增:

<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'linux-x64'">
    <DefineConstants>$(DefineConstants);PLATFORM_LINUX_X64</DefineConstants>
</PropertyGroup>

<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'linux-arm64'">
    <DefineConstants>$(DefineConstants);PLATFORM_LINUX_ARM64</DefineConstants>
</PropertyGroup>

<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64'">
    <DefineConstants>$(DefineConstants);PLATFORM_WIN_X64</DefineConstants>
</PropertyGroup>

<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x86'">
    <DefineConstants>$(DefineConstants);PLATFORM_WIN_X86</DefineConstants>
</PropertyGroup>

<PropertyGroup Condition="'$(RuntimeIdentifier)' == ''">
    <DefineConstants>$(DefineConstants);PLATFORM_EMPTY</DefineConstants>
</PropertyGroup>

重要說明:方式 2 在專案檔中新增的條件編譯巨集定義,僅對主專案有效。因為子專案編譯時 $(RuntimeIdentifier) 是空的(不會繼承主專案的 RID),所以子專案會符合 $(RuntimeIdentifier) == '' 條件,定義 PLATFORM_EMPTY 巨集。這表示在子專案中無法透過這種方式正確區分平台。如果需要在子專案中使用平台巨集,需要配合發佈指令碼動態修改 Directory.Build.props 全域巨集定義(情況 2)。

情況 1:單專案 + 條件編譯(✅ 成功)

直接在主專案中使用 DllImport,透過條件編譯巨集設定函式庫路徑,適合不封裝為類別庫的場景,例如小工具或不需要重複使用率低的專案。

靜態載入使用條件編譯巨集的優勢:可以靈活處理不同平台函式庫名稱完全不同的情況,當然也包括不同目錄(實際場景有可能),例如 Windows 用 Lib/Windows x64/TimeMeaning.dll,Linux 用 Lib/Linux x64/libTimeMeaning.so,這與方案一的動態載入有點像,都能處理複雜的路徑差異。

程式碼實作

using System.Runtime.InteropServices;

namespace csharp.test.static_;

internal static class TimeMeaningNative
{
#if PLATFORM_WIN_X64 || PLATFORM_WIN_X86
    const string DLL = "Lib/TimeMeaning.dll";
#elif PLATFORM_LINUX_X64 || PLATFORM_LINUX_ARM64
    const string DLL = "Lib/libTimeMeaning.so";
#else
    const string DLL = "Lib/TimeMeaning.dll";
#endif

    [DllImport(DLL, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
    private static extern IntPtr GetTimeMeaning(int timestampSecond);

    public static string GetTimeMeaningString(int timestampSecond)
    {
        var ptr = GetTimeMeaning(timestampSecond);
        return Marshal.PtrToStringUTF8(ptr) ?? string.Empty;
    }
}

結果

成功:在單專案場景下,條件編譯巨集正常運作,Windows 和 Linux 都能正確載入對應函式庫檔案。


情況 2:多專案 + 條件編譯(✅ 成功,推薦)

將函式庫呼叫封裝到獨立的類別庫專案,再由主專案參考。透過 publish.bat 發佈指令碼在發佈前呼叫 SetPlatformMacro.ps1 修改全域巨集定義,解決了類別庫不繼承條件編譯巨集的問題。

類別庫程式碼(TimeMeaningNative.csproj)

using System.Runtime.InteropServices;

namespace TimeMeaningNative;

public static class TimeMeaningApi
{
#if PLATFORM_WIN_X64 || PLATFORM_WIN_X86
    const string DLL = "Lib/TimeMeaning.dll";
#elif PLATFORM_LINUX_X64 || PLATFORM_LINUX_ARM64
    const string DLL = "Lib/libTimeMeaning.so";
#else
    const string DLL = "Lib/TimeMeaning.dll";
#endif

    [DllImport(DLL, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
    private static extern IntPtr GetTimeMeaning(int timestampSecond);

    public static string GetTimeMeaningString(int timestampSecond)
    {
        var ptr = GetTimeMeaning(timestampSecond);
        return Marshal.PtrToStringUTF8(ptr) ?? string.Empty;
    }
}

發佈指令碼(publish.bat)

for %%p in (%platforms%) do (
    set "tfm="
    set "pubxml="

    if "%%p"=="linux-x64" set "tfm=net10.0" & set "pubxml=FolderProfile_linux-x64.pubxml"
    ...

    powershell -ExecutionPolicy Bypass -File "SetPlatformMacro.ps1" -Platform "%%p"

    for %%d in (%project_paths%) do (
        dotnet publish "%%d" -f !tfm! /p:PublishProfile="%%d\Properties\PublishProfiles\!pubxml!"
    )
)

成功原因

成功publish.bat 發佈前呼叫 SetPlatformMacro.ps1 指令碼修改 Directory.Build.props 中的全域巨集定義,使子專案也能正確取得平台巨集定義(如 PLATFORM_LINUX_X64),而不是依賴編譯時的 RuntimeIdentifier

備註:具體指令碼可在測試存放庫檢視,存放庫位址在文末。

⚠️ 重要:NuGet 封裝限制

注意:此方案僅適用於同一原始碼存放庫內的多專案參考。如果將此類別庫專案封裝為 NuGet 套件散佈給其他專案使用,會存在以下問題:

  1. Directory.Build.props 不會被封裝:NuGet 套件中不會包含存放庫根目錄的 Directory.Build.props,上游使用者無法繼承巨集定義
  2. 巨集已在編譯時固定:封裝時,程式碼已按當時的巨集條件完成分支裁剪,產生的 dll 不再受上游巨集影響
  3. 上游無法改變行為:安裝 NuGet 套件的專案,即使定義了自己的 PLATFORM_XXX 巨集,也無法改變已封裝函式庫的程式碼分支

解決方案:如需透過 NuGet 散佈跨平台函式庫,推薦使用方案四(多專案+僅函式庫名稱),或者使用 NuGet build props 機制(詳見下方補充說明)。


情況 3:多專案 + 僅函式庫名稱(✅ 推薦)

這是最推薦的方案,相較動態載入方式,減小了使用難度。類別庫中不使用條件編譯巨集,只指定函式庫名稱(不加副檔名),解決跨平台函式庫參考問題。

類別庫程式碼(TimeMeaningNative.csproj)

using System.Runtime.InteropServices;

namespace TimeMeaningNative;

public static class TimeMeaningApi
{
    const string DLL = "Lib/TimeMeaning";
    [DllImport(DLL, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
    private static extern IntPtr GetTimeMeaning(int timestampSecond);

    public static string GetTimeMeaningString(int timestampSecond)
    {
        var ptr = GetTimeMeaning(timestampSecond);
        return Marshal.PtrToStringUTF8(ptr) ?? string.Empty;
    }
}

主專案設定(csproj)

這裡有一個關鍵技巧:如果 Linux 函式庫有 lib 等前置詞,需要去掉,和 Windows dll 改為相同檔案名稱,Linux 下複製時去掉 lib 前置詞!

<ItemGroup>
    <!-- Linux 下關鍵:複製時去掉 lib 前置詞! -->
    <None Update="Lib\x64\libTimeMeaning.so" Condition="'$(RuntimeIdentifier)' == 'linux-x64'">
        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
        <Link>Lib\TimeMeaning.so</Link>
    </None>
    ...
</ItemGroup>

工作原理

  • WindowsDllImport("Lib/TimeMeaning") 自動尋找 Lib/TimeMeaning.dll
  • LinuxDllImport("Lib/TimeMeaning") 會尋找 Lib/TimeMeaningLib/TimeMeaning.so不會尋找 Lib/libTimeMeaning.so
  • 所以 Linux 下需要透過 <Link>Lib\TimeMeaning.so</Link>libTimeMeaning.so 複製為 TimeMeaning.so

結果

成功:這是最推薦的方案,簡單可靠,懶得定義條件編譯巨集,只需要確保不同平台的函式庫檔案名稱統一(Linux 下去掉 lib 前置詞)。


方案對比總結

類別 方案 做法 結果 適用場景
動態載入 NativeLibrary 動態載入 程式碼中手動判斷作業系統並載入函式庫 ✅ 全平台可用 需要靈活控制載入路徑
靜態載入 單專案 + 條件編譯 #if PLATFORM_WIN_X64 條件編譯 ✅ 成功 僅主專案使用,不封裝類別庫,支援不同路徑函式庫
靜態載入 多專案 + 條件編譯 發佈前透過指令碼全域設定巨集 成功(配合發佈指令碼) 推薦,需要不同平台函式庫路徑不同
靜態載入 多專案 + 僅函式庫名稱(無副檔名) 類別庫只寫函式庫名稱 + csproj 條件複製(Linux 去 lib 前置詞) 跨平台完美成功 最推薦,簡單可靠,希望統一函式庫檔案名稱

6. 核心經驗

  1. 推薦使用 DllImport 常數函式庫名稱(不加副檔名),這是最穩定可靠的方案,重點是簡單好理解。方案一動態載入也可行,只是使用上稍微麻煩一點
  2. 靜態載入使用條件編譯巨集能處理函式庫名稱不同的情況,適用於單專案和多專案(多專案需要配合發佈指令碼全域設定巨集)
  3. 多專案場景下巨集不繼承的問題可以透過發佈指令碼解決:使用 publish.bat + SetPlatformMacro.ps1 在發佈前修改全域巨集
  4. 不要依賴類別庫編譯時的 RuntimeIdentifier,因為類別庫編譯時可能沒有 RuntimeIdentifier 上下文,導致條件編譯巨集不生效
  5. Linux 下注意去掉 lib 前置詞,透過 csproj 的 <Link> 機制重新命名
  6. 需要支援 Windows 7 時,安裝 VC-LTL 和 YY-Thunks NuGet 套件
  7. 可以將函式庫檔案放在 Lib 子目錄,不一定非要在根目錄
  8. ⚠️ 重要:Directory.Build.props 全域巨集不支援 NuGet 散佈:如果將使用條件編譯巨集的類別庫封裝為 NuGet,上游專案完全繼承不到該巨集,且 NuGet 套件內部程式碼也會在封裝時就固定編譯分支。如需透過 NuGet 散佈,推薦使用方案四(僅函式庫名稱,不依賴條件編譯巨集)

補充說明:對於初學者來說,先掌握情況 3(多專案+僅函式庫名稱)是最好的,這是最穩妥且容易理解的方式。如果確實需要靈活處理路徑差異,再考慮動態載入或情況 2。

7. 補充:NuGet 封裝與條件編譯巨集

7.1 Directory.Build.props 與 NuGet 的限制

結論Directory.Build.props 是原始碼存放庫層級的建置設定,不屬於專案本身,更不屬於 NuGet 套件。

生效範圍

場景 是否生效 說明
本機多專案開發 ✅ 是 MSBuild 自動從專案目錄向上遍歷載入
封裝時編譯 ✅ 是 封裝時編譯階段會應用巨集,裁剪分支
NuGet 套件內部(執行階段) ❌ 否 巨集已在編譯時消失,dll 分支已固定
上游專案安裝後 ❌ 否 NuGet 不包含 Directory.Build.props,無法繼承

核心原因

  1. NuGet 本質是編譯產物:包含 dll + 中繼資料,不包含建置設定
  2. 巨集是編譯時概念#if 在編譯時已完成分支裁剪,執行階段不再存在
  3. Directory.Build.props 不封裝:只在本機存放庫結構中生效

7.2 NuGet 散佈的替代方案

方案 A:使用 NuGet build props(向下游傳遞巨集)

如果確實需要透過 NuGet 向下游專案傳遞巨集定義,可以在函式庫專案中新增 build/ 資料夾:

<!-- build/YourPackageName.props -->
<Project>
  <PropertyGroup>
    <!-- 基於 RuntimeIdentifier 自動定義巨集 -->
    <DefineConstants Condition="'$(RuntimeIdentifier)'=='win-x86'">
      $(DefineConstants);PLATFORM_WIN_X86
    </DefineConstants>
    <DefineConstants Condition="'$(RuntimeIdentifier)'=='win-x64'">
      $(DefineConstants);PLATFORM_WIN_X64
    </DefineConstants>
    <DefineConstants Condition="'$(RuntimeIdentifier)'=='linux-x64'">
      $(DefineConstants);PLATFORM_LINUX_X64
    </DefineConstants>
    <DefineConstants Condition="'$(RuntimeIdentifier)'=='linux-arm64'">
      $(DefineConstants);PLATFORM_LINUX_ARM64
    </DefineConstants>
  </PropertyGroup>
</Project>

然後在函式庫專案的 csproj 中設定封裝:

<ItemGroup>
  <None Include="build\**" Pack="true" PackagePath="build\" />
</ItemGroup>

注意:這只能讓下游專案獲得巨集定義,但無法改變已封裝函式庫內部的程式碼分支(因為函式庫 dll 已在編譯時固定)。

方案 B:函式庫內部不使用條件編譯巨集(推薦)

最穩妥的 NuGet 散佈方案是避免在函式庫程式碼中使用條件編譯巨集,改用方案四(僅函式庫名稱)或執行階段判斷:

// 推薦:函式庫內使用執行階段判斷或僅函式庫名稱方式
public static class TimeMeaningApi
{
    const string DLL = "Lib/TimeMeaning"; // 不加副檔名

    [DllImport(DLL, CallingConvention = CallingConvention.Cdecl)]
    private static extern IntPtr GetTimeMeaning(int timestampSecond);

    // ...
}

方案 C:按 RID 分別封裝 NuGet

為每個平台單獨封裝 NuGet,使用不同的套件 ID 或版本:

  • YourLibrary.win-x64
  • YourLibrary.linux-x64
  • 等等

不過這會增加維護成本,通常不推薦。

7.3 最終建議

  • 同一存放庫內多專案:可以使用 Directory.Build.props + 發佈指令碼(情況 2)
  • 需要 NuGet 散佈:強烈推薦方案四(僅函式庫名稱,不依賴條件編譯巨集)
  • 必須用條件編譯 + NuGet:考慮 NuGet build props + 執行階段判斷結合

8. 常見問題 Q&A

Q1: Linux 下為什麼要去掉 lib 前置詞?

A: 分兩種情況:

  • 函式庫在根目錄DllImport("TimeMeaning") 在 Linux 下會尋找 TimeMeaningTimeMeaning.solibTimeMeaning.so,無需去掉前置詞
  • 函式庫在子目錄DllImport("Lib/TimeMeaning") 在 Linux 下僅會尋找 Lib/TimeMeaningLib/TimeMeaning.so不會尋找 Lib/libTimeMeaning.so**,因此需要透過 csproj 的 機制將libTimeMeaning.so複製為TimeMeaning.so`

Q2: 函式庫檔案必須和可執行檔在同一目錄嗎?

A: 不需要!可以放在子目錄(如 Lib/),只需要在 DllImport 中指定子目錄路徑,如 DllImport("Lib/TimeMeaning")。注意使用子目錄時 Linux 不會尋找帶 lib 前置詞的函式庫。

Q3: CallingConvention 是什麼意思?

A: 定義了函式呼叫時參數傳遞和堆疊清理的方式。常見的有:

  • Cdecl:C 語言預設慣例,呼叫者清理堆疊(Linux 常用)
  • StdCall:Windows API 常用,被呼叫者清理堆疊
  • Winapi:平台預設(Windows 下是 StdCall,Linux 下是 Cdecl)

Q4: macOS 支援嗎?

A: 支援!macOS 使用 .dylib 後綴,同樣可以用 DllImport("Lib/TimeMeaning"),系統會自動尋找 Lib/TimeMeaning.dylib。csproj 設定中增加 osx-x64osx-arm64 的設定即可。

Q5: 偵錯時如何知道函式庫檔案是否正確載入?

A: 可以透過以下方式:

  1. 檢查輸出目錄的 Lib/ 子目錄是否有正確的函式庫檔案
  2. 使用 Process Monitor(Windows)或 lsof(Linux)監視函式庫檔案載入
  3. 在程式碼中呼叫 NativeLibrary.TryLoad 測試載入是否成功

以上內容基於實際 Demo 專案整理,包含 C++ 函式庫程式碼及四大方案的完整程式碼範例,如有錯誤或更好的方案,歡迎在留言區留言指正!

開源專案網址:https://github.com/dotnet9/DotnetCrossPlatformNativeLibrary

繼續探索

延伸閱讀

更多文章
同分類 / 同標籤 2026/2/7

AOT使用經驗總結

從專案建立伊始,就應養成良好的習慣,即只要添加了新功能或使用了較新的語法,就及時進行 AOT 發布測試。

繼續閱讀