فصل ۲۴: تعامل با Native و COM
این فصل نحوهٔ یکپارچهسازی برنامهٔ .NET با Dynamic-Link Libraryهای native یا unmanaged و componentهای COM را توضیح میدهد. مگر آنجا که خلافش گفته شود، typeهای این فصل در فضای نام System یا System.Runtime.InteropServices قرار دارند.
فراخوانی Native DLLها
P/Invoke، مخفف Platform Invocation Services، دسترسی به functionها، structها و callbackهای موجود در DLLهای unmanaged ــ یا shared libraryها در Unix ــ را فراهم میکند. برای نمونه، تابع MessageBox در user32.dll ویندوز چنین تعریف میشود:
int MessageBox (HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType);
در C# میتوان متدی static با همان نام تعریف کرد، کلیدواژهٔ extern و attributeِ DllImport را به آن افزود و مستقیماً تابع را صدا زد:
using System;
using System.Runtime.InteropServices;
MessageBox (IntPtr.Zero,
"Please do not press this again.", "Attention", 0);
[DllImport("user32.dll")]
static extern int MessageBox (IntPtr hWnd, string text, string caption,
int type);
کلاسهای MessageBox در System.Windows و System.Windows.Forms خودشان روشهای unmanaged مشابهی را فراخوانی میکنند. نمونهٔ Ubuntu Linux نیز به این صورت است:
Console.WriteLine ($"User ID: {getuid()}");
[DllImport("libc")]
static extern uint getuid();
CLR یک marshaler داخلی دارد که parameterها و return valueها را بین typeهای .NET و unmanaged تبدیل میکند. در مثال Windows، parameterهای int مستقیماً به integer چهاربایتی مورد انتظار تابع نگاشت میشوند و stringها به آرایههای null-terminated از کاراکترهای Unicode با UTF-16 تبدیل میشوند. IntPtr برای نگهداری unmanaged handle طراحی شده است و روی platformهای 32-bit، 32 بیت و روی 64-bit، 64 بیت عرض دارد. از C# 9 میتوان از nint نیز استفاده کرد که به IntPtr نگاشت میشود.
Marshaling نوع و پارامتر
Marshaling typeهای رایج
در سمت unmanaged ممکن است یک data type چند representation متفاوت داشته باشد. برای نمونه string میتواند ANSI تکبایتی یا Unicode UTF-16 باشد و بهشکل length-prefixed، null-terminated یا fixed-length ذخیره شود. با attributeِ MarshalAs میتوان دقیقاً به CLR marshaler گفت کدام variant استفاده شده است:
[DllImport("...")]
static extern int Foo ( [MarshalAs (UnmanagedType.LPStr)] string s );
enumِ UnmanagedType تمام typeهای Win32 و COM را که marshaler میشناسد دربر میگیرد. در مثال بالا، LPStr یعنی string تکبایتی ANSI و null-terminated. در سمت .NET نیز گاهی چند انتخاب دارید؛ برای مثال unmanaged handle ممکن است به IntPtr، int، uint، long یا ulong نگاشت شود.
در functionهای Win32 و POSIX معمولاً parameterهای integer میبینید که مجموعهای از constantها را میپذیرند. این constantها در headerهای C++ مانند WinUser.h تعریف میشوند. تعریف آنها بهصورت enum در C# کد را مرتبتر و static type safety را بهتر میکند.
برگرداندن string از unmanaged code به .NET نیازمند مدیریت حافظه است. اگر external method را با StringBuilder تعریف کنید، marshaler این کار را خودکار انجام میدهد:
StringBuilder s = new StringBuilder (256);
GetWindowsDirectory (s, 256);
Console.WriteLine (s);
[DllImport("kernel32.dll")]
static extern int GetWindowsDirectory (StringBuilder sb, int maxChars);
در Unix نیز مشابه است:
var sb = new StringBuilder (256);
Console.WriteLine (getcwd (sb, sb.Capacity));
[DllImport("libc")]
static extern string getcwd (StringBuilder buf, int size);
StringBuilder راحت است اما در hotspotهای performance میتواند بهسبب allocation و copy اضافی CLR ناکارآمد باشد. در چنین مواردی میتوان از char[] و array pooling استفاده کرد:
[DllImport ("kernel32.dll", CharSet = CharSet.Unicode)]
static extern int GetWindowsDirectory (char[] buffer, int maxChars);
string GetWindowsDirectory()
{
var array = ArrayPool<char>.Shared.Rent (256);
try
{
int length = GetWindowsDirectory (array, 256);
return new string (array, 0, length).ToString();
}
finally { ArrayPool<char>.Shared.Return (array); }
}
در این نسخه باید CharSet را در DllImport مشخص کنید و خروجی را پس از فراخوانی به طول واقعی trim کنید. خود مثال عمدی است، چون Windows directory را میتوان با Environment.GetFolderPath نیز گرفت.
Marshaling کلاسها و Structها
گاهی باید یک struct را به unmanaged method بدهید. تابع Win32ِ GetSystemTime یک pointer به C structِ SYSTEMTIME دریافت میکند. در C# باید class یا struct متناظری با ترتیب fieldهای یکسان تعریف کنیم:
[StructLayout(LayoutKind.Sequential)]
class SystemTime
{
public ushort Year;
public ushort Month;
public ushort DayOfWeek;
public ushort Day;
public ushort Hour;
public ushort Minute;
public ushort Second;
public ushort Milliseconds;
}
StructLayout به marshaler میگوید fieldها را چگونه به counterpart unmanaged نگاشت کند. LayoutKind.Sequential یعنی fieldها بهترتیب و روی boundaryهای pack size چیده شوند، همانگونه که در C struct انتظار میرود. نام fieldها اهمیت ندارد؛ ترتیب آنها مهم است.
SystemTime t = new SystemTime();
GetSystemTime (t);
Console.WriteLine (t.Year);
[DllImport("kernel32.dll")]
static extern void GetSystemTime (SystemTime t);
نمونهٔ Unix با clock_gettime و structِ Timespec نیز همین اصل را نشان میدهد:
static DateTime GetSystemTime()
{
DateTime startOfUnixTime =
new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc);
Timespec tp = new Timespec();
int success = clock_gettime (0, ref tp);
if (success != 0) throw new Exception ("Error checking the time.");
return startOfUnixTime.AddSeconds (tp.tv_sec).ToLocalTime();
}
[DllImport("libc")]
static extern int clock_gettime (int clk_id, ref Timespec tp);
[StructLayout(LayoutKind.Sequential)]
struct Timespec
{
public long tv_sec;
public long tv_nsec;
}
در C و C#، fieldها در offsetی نسبت به address ابتدای object قرار میگیرند. در C#، CLR این offset را با field token پیدا میکند؛ در C، نام field در زمان compile مستقیماً به offset تبدیل میشود. برای سرعت دسترسی، هر field معمولاً در offsetی قرار میگیرد که مضربی از اندازهٔ خودش است، اما این ضریب به pack size محدود میشود. pack size پیشفرض در implementation فعلی 8 byte است. برای نمونه structای با یک sbyte و سپس یک long هشتبایتی میتواند 16 byte اشغال کند و هفت byte بعد از sbyte هدر برود. با propertyِ Pack در StructLayout میتوان pack size را 1، 2، 4، 8 یا 16 تعیین کرد؛ با pack size برابر 1 همان struct فقط 9 byte میشود.
StructLayout همچنین اجازهٔ تعیین explicit field offset را میدهد که در بخش C union استفاده میشود.
In و Out Marshaling
اگر SystemTime را struct تعریف کنیم، declaration تابع باید parameter را با ref یا out بپذیرد:
[DllImport("kernel32.dll")]
static extern void GetSystemTime (out SystemTime t);
در بیشتر موارد semantics جهت parameter در C# برای external methodها نیز برقرار است: pass-by-value به داخل کپی میشود، ref in/out است و out فقط به بیرون کپی میشود. استثناهایی برای typeهای دارای conversion ویژه وجود دارد؛ arrayها و StringBuilder هنگام خروج نیازمند copy هستند و بنابراین in/out محسوب میشوند. در صورت نیاز میتوان با attributeهای In و Out رفتار را override کرد؛ مثلاً array فقط خواندنی:
static extern void Foo ( [In] int[] array);
Calling Conventionها
Unmanaged methodها argumentها و return valueها را از طریق stack و در برخی موارد CPU register منتقل میکنند. چون چند protocol ممکن است، calling conventionهای متفاوتی شکل گرفتهاند. CLR در اینجا StdCall، Cdecl و ThisCall را پشتیبانی میکند. بهطور پیشفرض convention استاندارد platform استفاده میشود؛ در Windows، StdCall و در Linux x86، Cdecl.
[DllImport ("MyLib.dll", CallingConvention=CallingConvention.Cdecl)]
static extern void SomeFunc (...)
نام CallingConvention.WinApi با وجود نام گمراهکنندهاش به convention پیشفرض platform اشاره دارد.
Callback از Unmanaged Code
C# اجازه میدهد external functionها از طریق callback به C# برگردند. دو راه اصلی وجود دارد: function pointer و delegate. برای مثال تابع Windowsِ EnumWindows همهٔ top-level window handleها را enumerate میکند و callbackِ WNDENUMPROC را برای هر window صدا میزند تا زمانی که callback مقدار false بدهد.
Callback با Function Pointer
از C# 9، اگر callback یک static method باشد، سادهترین و سریعترین گزینه function pointer است:
using System;
using System.Runtime.InteropServices;
unsafe
{
EnumWindows (&PrintWindow, IntPtr.Zero);
[DllImport ("user32.dll")]
static extern int EnumWindows (
delegate*<IntPtr, IntPtr, bool> hWnd, IntPtr lParam);
static bool PrintWindow (IntPtr hWnd, IntPtr lParam)
{
Console.WriteLine (hWnd.ToInt64());
return true;
}
}
در function pointer، callback باید static method یا static local function باشد.
UnmanagedCallersOnly
با افزودن keywordِ unmanaged به declaration function pointer و attributeِ [UnmanagedCallersOnly] به callback میتوان performance را بهتر کرد، چون runtime میداند method فقط از unmanaged code فراخوانی میشود و shortcutهایی اعمال میکند:
unsafe
{
EnumWindows (&PrintWindow, IntPtr.Zero);
[DllImport ("user32.dll")]
static extern int EnumWindows (
delegate* unmanaged <IntPtr, IntPtr, byte> hWnd, IntPtr lParam);
[UnmanagedCallersOnly]
static byte PrintWindow (IntPtr hWnd, IntPtr lParam)
{
Console.WriteLine (hWnd.ToInt64());
return 1;
}
}
در این حالت signature تنها میتواند blittable value type داشته باشد؛ typeهایی که representation یکسانی در managed و unmanaged دارند و marshaling ویژه نمیخواهند. primitive integral typeها، float، double و structهایی که فقط از چنین typeهایی ساخته شدهاند از این دستهاند. char نیز اگر در structای با StructLayout و CharSet.Unicode باشد blittable است.
اگر callback از calling convention پیشفرض platform پیروی نکند، آن را هم روی UnmanagedCallersOnly و هم در نوع function pointer مشخص کنید:
[UnmanagedCallersOnly (CallConvs = new[] { typeof (CallConvStdcall) })]
static byte PrintWindow (IntPtr hWnd, IntPtr lParam) ...
delegate* unmanaged[Stdcall] <IntPtr, IntPtr, byte> hWnd
| نام | unmanaged modifier | Supporting type |
|---|
| Stdcall | unmanaged[Stdcall] | CallConvStdcall |
| Cdecl | unmanaged[Cdecl] | CallConvCdecl |
| ThisCall | unmanaged[Thiscall] | CallConvThiscall |
Callback با Delegate
Delegateها در تمام نسخههای C# کار میکنند و callback به instance method را نیز ممکن میکنند. ابتدا delegate type با signature callback تعریف و سپس instance آن به external method داده میشود:
class CallbackFun
{
delegate bool EnumWindowsCallback (IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")]
static extern int EnumWindows (EnumWindowsCallback hWnd, IntPtr lParam);
static bool PrintWindow (IntPtr hWnd, IntPtr lParam)
{
Console.WriteLine (hWnd.ToInt64());
return true;
}
static readonly EnumWindowsCallback printWindowFunc = PrintWindow;
static void Main() => EnumWindows (printWindowFunc, IntPtr.Zero);
}
استفاده از delegate برای unmanaged callback از یک جهت خطرناک است: ممکن است callback بعد از خارجشدن delegate instance از scope رخ دهد و delegate eligible for garbage collection شود؛ نتیجه میتواند runtime exception بدون stack trace مفید باشد. برای callback به static method، نگهداری delegate در static readonly field مانند مثال بالا این خطر را کاهش میدهد. برای instance method باید reference به delegate را تا پایان تمام callbackهای محتمل حفظ کنید. اگر bug در unmanaged side callback را بعد از زمان ممنوع صدا بزند، همچنان مشکل ممکن است باقی بماند. تعریف delegate type یکتا برای هر unmanaged function میتواند diagnosis را بهتر کند چون type در exception گزارش میشود.
[UnmanagedFunctionPointer (CallingConvention.Cdecl)]
delegate void MyCallback (int foo, short bar);
شبیهسازی C Union
در struct معمولی هر field فضای مستقل دارد. زبان C نوعی struct به نام union دارد که fieldها میتوانند روی یک offset مشترک همپوشانی داشته باشند. در C# با LayoutKind.Explicit و attributeِ FieldOffset میتوان چنین رفتاری را شبیهسازی کرد.
مثال کتاب برای MIDI است. تابع Windows Multimedia API پیام 32-bit بستهبندیشدهای دریافت میکند که byteهای آن channel، note و velocity را نگه میدارند. بهجای shift/mask با operatorهای bitwise میتوان unionمانند تعریف کرد:
[DllImport ("winmm.dll")]
public static extern uint midiOutShortMsg (IntPtr handle, uint message);
[StructLayout (LayoutKind.Explicit)]
public struct NoteMessage
{
[FieldOffset(0)] public uint PackedMsg;
[FieldOffset(0)] public byte Channel;
[FieldOffset(1)] public byte Note;
[FieldOffset(2)] public byte Velocity;
}
NoteMessage n = new NoteMessage();
Console.WriteLine (n.PackedMsg); // 0
n.Channel = 10;
n.Note = 100;
n.Velocity = 50;
Console.WriteLine (n.PackedMsg); // 3302410
n.PackedMsg = 3328010;
Console.WriteLine (n.Note); // 200
Fieldهای Channel، Note و Velocity عمداً با پیام 32-bit overlap دارند؛ بنابراین خواندن و نوشتن از هر دو نمای packed و fieldی بدون calculation اضافی ممکن است.
Shared Memory
Memory-mapped file یا shared memory در Windows اجازه میدهد چند process روی یک computer داده را مشترک کنند. سرعت آن بسیار زیاد است و برخلاف pipe، random access دارد. در فصل ۱۵ کلاس MemoryMappedFile معرفی شد؛ اینجا برای نشاندادن P/Invoke مستقیماً APIهای Win32 را فراخوانی میکنیم.
CreateFileMapping shared memory را با اندازه و نام مشخص میسازد. application دیگر با همان نام OpenFileMapping را صدا میزند. هر دو method یک handle میدهند و MapViewOfFile آن را به pointer تبدیل میکند. کلاس SharedMem منبع این lifecycle را با CreateFileMapping، OpenFileMapping، MapViewOfFile، UnmapViewOfFile و CloseHandle encapsulate میکند.
public sealed class SharedMem : IDisposable
{
enum FileProtection : uint { ReadOnly = 2, ReadWrite = 4 }
enum FileRights : uint { Read = 4, Write = 2, ReadWrite = Read + Write }
static readonly IntPtr NoFileHandle = new IntPtr (-1);
[DllImport ("kernel32.dll", SetLastError = true)]
static extern IntPtr CreateFileMapping (IntPtr hFile, int lpAttributes,
FileProtection flProtect, uint dwMaximumSizeHigh,
uint dwMaximumSizeLow, string lpName);
[DllImport ("kernel32.dll", SetLastError=true)]
static extern IntPtr OpenFileMapping (FileRights dwDesiredAccess,
bool bInheritHandle, string lpName);
[DllImport ("kernel32.dll", SetLastError = true)]
static extern IntPtr MapViewOfFile (IntPtr hFileMappingObject,
FileRights dwDesiredAccess, uint dwFileOffsetHigh,
uint dwFileOffsetLow, uint dwNumberOfBytesToMap);
[DllImport ("Kernel32.dll", SetLastError = true)]
static extern bool UnmapViewOfFile (IntPtr map);
[DllImport ("kernel32.dll", SetLastError = true)]
static extern int CloseHandle (IntPtr hObject);
IntPtr fileHandle, fileMap;
public IntPtr Root => fileMap;
// Constructor maps the named region; Dispose unmaps and closes handles.
}
SetLastError=true برای APIهایی که پروتکل SetLastError دارند باعث میشود Win32Exception جزئیات خطا را داشته باشد و همچنین Marshal.GetLastWin32Error قابل استفاده باشد. یک process با new SharedMem("MyShare", false, 1000) حافظه را میسازد و process دوم با همان نام و existing=true به همان حافظه وصل میشود. در نهایت هر process یک IntPtr به همان unmanaged memory دارد.
Serialize/deserialize کردن کل object graph با UnmanagedMemoryStream برای دادهٔ بزرگ ناکارآمد است؛ اگر فقط یک integer در یک megabyte داده تغییر کند، copy کامل بیهوده خواهد بود. راه بهتر mapping مستقیم struct به shared memory است.
Mapping یک Struct به Unmanaged Memory
Struct دارای LayoutKind.Sequential یا Explicit را میتوان مستقیماً روی unmanaged memory map کرد. مثال:
[StructLayout (LayoutKind.Sequential)]
unsafe struct MySharedData
{
public int Value;
public char Letter;
public fixed float Numbers [50];
}
directiveِ fixed آرایهٔ value-type با طول ثابت را inline تعریف میکند؛ یعنی Numbers reference به array نیست، خود array داخل struct است. sizeof(MySharedData) برابر 208 میشود: 200 byte برای 50 float، چهار byte برای integer، دو byte برای char و padding تا alignment چهاربایتی.
MySharedData d;
MySharedData* data = &d;
data->Value = 123;
data->Letter = 'X';
data->Numbers[10] = 1.45f;
// Or allocate on the stack:
MySharedData* data2 = stackalloc MySharedData[1];
همین struct را میتوان بیرون از realmِ garbage collector روی unmanaged heap قرار داد:
MySharedData* data = (MySharedData*)
Marshal.AllocHGlobal (sizeof (MySharedData)).ToPointer();
data->Value = 123;
data->Letter = 'X';
data->Numbers[10] = 1.45f;
Marshal.FreeHGlobal (new IntPtr (data));
فراموشکردن FreeHGlobal یک memory leak کلاسیک ایجاد میکند. از .NET 6 میتوان بهجای آن از NativeMemory استفاده کرد که API زیربنایی جدیدتری دارد و aligned allocation را نیز پشتیبانی میکند. در ادامهٔ فصل، همین MySharedData روی block مشترک ساختهشده توسط SharedMem map میشود.