字符串
雖然只有一種 .Net 字符串類型,但這種字符串類型在非托管應用中卻有幾項獨特之處。可以使用具有內嵌字符數組的字符指針和結構,其中每個數組都需要正確的封送處理。
在 Win32 中還有兩種不同的字符串表示:
ANSI
Unicode
最初的 Windows 使用單字節字符,這樣可以節省存儲空間,但在處理很多語言時都需要復雜的多字節編碼。Windows NT? 出現後,它使用雙字節的 Unicode 編碼。為解決這一差別,Win32 API 采用了非常聰明的做法。它定義了 TCHAR 類型,該類型在 Win9x 平台上是單字節字符,在 WinNT 平台上是雙字節 Unicode 字符。對於每個接受字符串或結構(其中包含字符數據)的函數,Win32 API 均定義了該結構的兩種版本,用 A 後綴指明 Ansi 編碼,用 W 指明 wide 編碼(即 Unicode)。如果您將 C++ 程序編譯為單字節,會獲得 A 變體,如果編譯為 Unicode,則獲得 W 變體。Win9x 平台包含 Ansi 版本,而 WinNT 平台則包含 W 版本。
由於 P/Invoke 的設計者不想讓您為所在的平台操心,因此他們提供了內置的支持來自動使用 A 或 W 版本。如果您調用的函數不存在,互操作層將為您查找並使用 A 或 W 版本。
通過示例能夠很好地說明字符串支持的一些精妙之處。
簡單字符串
下面是一個接受字符串參數的函數的簡單示例:
BOOL GetDiskFreeSpace(
LPCTSTR lpRootPathName, // 根路徑
LPDWord lpSectorsPerCluster, // 每個簇的扇區數
LPDWord lpBytesPerSector, // 每個扇區的字節數
LPDWord lpNumberOfFreeClusters, // 可用的扇區數
LPDWord lpTotalNumberOfClusters // 扇區總數
);
根路徑定義為 LPCTSTR。這是獨立於平台的字符串指針。
由於不存在名為 GetDiskFreeSpace() 的函數,封送拆收器將自動查找“A”或“W”變體,並調用相應的函數。我們使用一個屬性來告訴封送拆收器,API 所要求的字符串類型。
以下是該函數的完整定義,就象我開始定義的那樣:
[DllImport("kernel32.dll")]
static extern bool GetDiskFreeSpace(
[MarshalAs(UnmanagedType.LPTStr)]
string rootPathName,
ref int sectorsPerCluster,
ref int bytesPerSector,
ref int numberOfFreeClusters,
ref int totalNumberOfClusters);
不幸的是,當我試圖運行時,該函數不能執行。問題在於,無論我們在哪個平台上,封送拆收器在默認情況下都試圖查找 API 的 Ansi 版本,由於 LPTStr 意味著在 Windows NT 平台上會使用 Unicode 字符串,因此試圖用 Unicode 字符串來調用 Ansi 函數就會失敗。
有兩種方法可以解決這個問題:一種簡單的方法是刪除 MarshalAs 屬性。如果這樣做,將始終調用該函數的 A 版本,如果在您所涉及的所有平台上都有這種版本,這是個很好的方法。但是,這會降低代碼的執行速度,因為封送拆收器要將 .Net 字符串從 Unicode 轉換為多字節,然後調用函數的 A 版本(將字符串轉換回 Unicode),最後調用函數的 W 版本。
要避免出現這種情況,您需要告訴封送拆收器,要它在 Win9x 平台上時查找 A 版本,而在 NT 平台上時查找 W 版本。要實現這一目的,可以將 CharSet 設置為 DllImport 屬性的一部分:
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
在我的非正式計時測試中,我發現這一做法比前一種方法快了大約百分之五。
對於大多數 Win32 API,都可以對字符串類型設置 CharSet 屬性並使用 LPTStr。但是,還有一些不采用 A/W 機制的函數,對於這些函數必須采取不同的方法。
字符串緩沖區
.Net 中的字符串類型是不可改變的類型,這意味著它的值將永遠保持不變。對於要將字符串值復制到字符串緩沖區的函數,字符串將無效。這樣做至少會破壞由封送拆收器在轉換字符串時創建的臨時緩沖區;嚴重時會破壞托管堆,而這通常會導致錯誤的發生。無論哪種情況都不可能獲得正確的返回值。
要解決此問題,我們需要使用其他類型。StringBuilder 類型就是被設計為用作緩沖區的,我們將使用它來代替字符串。下面是一個示例:
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
public static extern int GetShortPathName(
[MarshalAs(UnmanagedType.LPTStr)]
string path,
[MarshalAs(UnmanagedType.LPTStr)]
StringBuilder shortPath,
int shortPathLength);
使用此函數很簡單:
StringBuilder shortPath = new StringBuilder(80);
int result = GetShortPathName(@"d:\test.jpg", shortPath, shortPath.Capacity);
string s = shortPath.ToString();
請注意,StringBuilder 的 Capacity 傳遞的是緩沖區大小。
具有內嵌字符數組的結構
某些函數接受具有內嵌字符數組的結構。例如,GetTimeZoneInformation() 函數接受指向以下結構的指針:
typedef struct _TIME_ZONE_INFORMATION {
LONG Bias;
WCHAR StandardName[ 32 ];
SYSTEMTIME StandardDate;
LONG StandardBias;
WCHAR DaylightName[ 32 ];
SYSTEMTIME DaylightDate;
LONG DaylightBias;
} TIME_ZONE_INFORMATION, *PTIME_ZONE_INFORMATION;
在 C# 中使用它需要有兩種結構。一種是 SYSTEMTIME,它的設置很簡單:
struct SystemTime
{
public short wYear;
public short wMonth;
public short wDayOfWeek;
public short wDay;
public short wHour;
public short wMinute;
public short wSecond;
public short wMilliseconds;
}
這裡沒有什麼特別之處;另一種是 TimeZoneInformation,它的定義要復雜一些:
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct TimeZoneInformation
{
public int bias;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string standardName;
SystemTime standardDate;
public int standardBias;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string daylightName;
SystemTime daylightDate;
public int daylightBias;
}
此定義有兩個重要的細節。第一個是 MarshalAs 屬性:
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
查看 ByValTStr 的文檔,我們發現該屬性用於內嵌的字符數組;另一個是 SizeConst,它用於設置數組的大小。
我在第一次編寫這段代碼時,遇到了執行引擎錯誤。通常這意味著部分互操作覆蓋了某些內存,表明結構的大小存在錯誤。我使用 Marshal.SizeOf() 來獲取所使用的封送拆收器的大小,結果是 108 字節。我進一步進行了調查,很快回憶起用於互操作的默認字符類型是 Ansi 或單字節。而函數定義中的字符類型為 WCHAR,是雙字節,因此導致了這一問題。
我通過添加 StructLayout 屬性進行了更正。結構在默認情況下按順序布局,這意味著所有字段都將以它們列出的順序排列。CharSet 的值被設置為 Unicode,以便始終使用正確的字符類型。
經過這樣處理後,該函數一切正常。您可能想知道我為什麼不在此函數中使用 CharSet.Auto。這是因為,它也沒有 A 和 W 變體,而始終使用 Unicode 字符串,因此我采用了上述方法編碼。
具有回調的函數
當 Win32 函數需要返回多項數據時,通常都是通過回調機制來實現的。開發人員將函數指針傳遞給函數,然後針對每一項調用開發人員的函數。
在 C# 中沒有函數指針,而是使用“委托”,在調用 Win32 函數時使用委托來代替函數指針。
EnumDesktops() 函數就是這類函數的一個示例:
BOOL EnumDesktops(
HWINSTA hwinsta, // 窗口實例的句柄
DESKTOPENUMPROC lpEnumFunc, // 回調函數
LPARAM lParam // 用於回調函數的值
);
HWINSTA 類型由 IntPtr 代替,而 LPARAM 由 int 代替。DESKTOPENUMPROC 所需的工作要多一些。下面是 MSDN 中的定義:
BOOL CALLBACK EnumDesktopProc(
LPTSTR lpszDesktop, // 桌面名稱
LPARAM lParam // 用戶定義的值
);
我們可以將它轉換為以下委托:
delegate bool EnumDesktopProc([MarshalAs(UnmanagedType.LPTStr)] string desktopName,int lParam);
完成該定義後,我們可以為 EnumDesktops() 編寫以下定義:
[DllImport("user32.dll", CharSet = CharSet.Auto)]
static extern bool EnumDesktops(
IntPtr Windowstation,
EnumDesktopProc callback,
int lParam);
這樣該函數就可以正常運行了。
在互操作中使用委托時有個很重要的技巧:封送拆收器創建了指向委托的函數指針,該函數指針被傳遞給非托管函數。但是,封送拆收器無法確定非托管函數要使用函數指針做些什麼,因此它假定函數指針只需在調用該函數時有效即可。
結果是如果您調用諸如 SetConsoleCtrlHandler() 這樣的函數,其中的函數指針將被保存以便將來使用,您就需要確保在您的代碼中引用委托。如果不這樣做,函數可能表面上能執行,但在將來的內存回收處理中會刪除委托,並且會出現錯誤。