問題核心
C# 預設字串為 **UTF-16 (Unicode)**,C++ DLL 介面若約定 **UTF-8 (char*)**,跨邊界傳遞字串必須正確轉碼,否則中文亂碼、存取違規、記憶體洩漏。
編碼概念釐清
| 術語 | 說明 |
| 字符集 | 字符集合與編號 (Unicode, GB2312, BIG5) |
| 編碼 | 編號 → 位元組序列的規則 (UTF-8, UTF-16LE, UTF-16BE, GB2312) |
| ANSI | 系統當前代碼頁 (繁中 CP950, 簡中 CP936, 英文 CP1252) |
| Unicode (C# 內部) | UTF-16LE (Windows)、UTF-16BE (部分平台),C# string 固定 UTF-16 |
| UTF-8 | 變長 1-4 位元組,ASCII 相容,網路/檔案/跨平台標準 |
P/Invoke 封送字串參數
using System.Runtime.InteropServices;
// C++ 簽名: void Process(const char* utf8Input, char** utf8Output);
// 其中 char** 為輸出緩衝區指標,由 DLL 配置記憶體,呼叫端負責釋放
public static class NativeInterop
{
// 1. 輸入字串:C# string → UTF-8 byte[] (UnmanagedType.LPUTF8Str .NET 5+)
[DllImport("MyCppDll.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
// CharSet.Ansi 告訴封送器:以 ANSI/當前代碼頁轉換,但我們手動控制 UTF-8,見下文
private static extern void ProcessUtf8(byte[] input, out IntPtr outputPtr);
// 2. 釋放 DLL 配置記憶體
[DllImport("MyCppDll.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern void FreeString(IntPtr ptr);
///
/// 安全呼叫包裝:C# string (UTF-16) → UTF-8 → 呼叫 DLL → UTF-8 → C# string
///
public static string Process(string input)
{
if (input == null) throw new ArgumentNullException(nameof(input));
// A. 手動轉 UTF-8 位元組陣列 (避免 CharSet.Ansi 使用系統代碼頁)
byte[] utf8Input = Encoding.UTF8.GetBytes(input + '\0'); // C 風格結尾 null
// B. 呼叫
ProcessUtf8(utf8Input, out IntPtr outputPtr);
try
{
if (outputPtr == IntPtr.Zero) return null;
// C. 讀取 UTF-8 字串
// 方法 1: 手動掃描 null 終止符
int len = 0;
while (Marshal.ReadByte(outputPtr, len) != 0) len++;
byte[] utf8Output = new byte[len];
Marshal.Copy(outputPtr, utf8Output, 0, len);
// D. UTF-8 → C# string (UTF-16)
return Encoding.UTF8.GetString(utf8Output);
}
finally
{
// E. 釋放非受控記憶體
FreeString(outputPtr);
}
}
}
.NET 5+ 簡化:UnmanagedType.LPUTF8Str
[DllImport("MyCppDll.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern void ProcessUtf8Modern(
[MarshalAs(UnmanagedType.LPUTF8Str)] string input, // 自動 UTF-16 → UTF-8
out IntPtr outputPtr); // 仍需手動處理輸出
輸出參數模式比較
| C++ 簽名 | C# 封送 | 記憶體責任 | 備註 |
void Out(char* buf, int bufSize) | StringBuilder / byte[] | 呼叫端配置 | 固定緩衝區,需預估長度 |
void Out(char** outStr) | out IntPtr + FreeString | DLL 配置 / 呼叫端釋放 | 彈性長度,**必須成對配置/釋放** |
BSTR Out() (COM) | [MarshalAs(UnmanagedType.BStr)] out string | COM 規則 | 自動轉 UTF-16,SysAllocString/SysFreeString |
完整雙向轉換工具類
public static class EncodingMarshaler
{
/// C# string (UTF-16) → UTF-8 byte[] (含 null 終止)
public static byte[] ToUtf8NullTerminated(string s)
=> s == null ? Array.Empty() : Encoding.UTF8.GetBytes(s + '\0');
/// 非受控 UTF-8 字串 (IntPtr, null 終止) → C# string
public static string PtrToStringUtf8(IntPtr ptr)
{
if (ptr == IntPtr.Zero) return null;
int len = 0;
while (Marshal.ReadByte(ptr, len) != 0) len++;
var bytes = new byte[len];
Marshal.Copy(ptr, bytes, 0, len);
return Encoding.UTF8.GetString(bytes);
}
/// C# string → 非受控 UTF-8 緩衝區 (呼叫端需 FreeHGlobal)
public static IntPtr StringToHGlobalUtf8(string s)
{
byte[] bytes = ToUtf8NullTerminated(s);
IntPtr ptr = Marshal.AllocHGlobal(bytes.Length);
Marshal.Copy(bytes, 0, ptr, bytes.Length);
return ptr;
}
}
常見錯誤與避坑
- ❌ 直接用 `CharSet = CharSet.Unicode` 傳 `string` 給 `char*` → 送出 UTF-16 位元組,C++ 當 UTF-8 讀取 → 亂碼/崩潰。
- ❌ 用 `CharSet = CharSet.Ansi` 依賴系統代碼頁 → 部署到不同語系機器行為不一致。
- ❌ 輸出 `char**` 未呼叫對應 `Free` 函式 → 記憶體洩漏 (C++ heap 與 C# heap 不同)。
- ❌ `StringBuilder` 容量不足導致緩衝區溢位 → 傳入足夠大小或改用 `char**` 模式。
- ❌ 結構體中嵌入字串指標 (`char*`) → 需手動 `Marshal.StructureToPtr` 配合 `IntPtr` 欄位處理。
現代化替代:C++/CLI 或 Rust/Go FFI
- C++/CLI:混合模組,直接在 C++ 專案 `#using ` 使用 .NET 類型,邊界零轉換成本,但僅限 Windows。
- Rust + `cdylib` + `cxx`/`unidiff`:Rust 端處理 UTF-8,透過 `cxx` 橋接產生 C# 友好介面,記憶體安全、跨平台。
- gRPC / protobuf:將 C++ 邏輯封裝為微服務,C# 透過 gRPC 呼叫,完全避開 P/Invoke 複雜度,適合高併發/分散式架構。