عنوان:

‫ساخت DLLهای بومی ویندوز (WinAPI) با استفاده از NET Native AOT.


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۳/۳۰ ۰۹:۲۰
آدرس: www.dntips.ir
توسعه و نگهداری کتابخانه‌های پویا (DLL) در ویندوز با استفاده از C++/C، همواره استاندارد طلایی برنامه‌نویسی سیستمی بوده‌است. با این حال، مدیریت وابستگی‌های سنگین محیط ++C (مانند Windows SDK، ATL یا نسخه‌های مختلف کامپایلر Visual Studio) چالش‌های نگهداری فراوانی را به همراه دارد. ارتقای نسخه‌های کامپایلر اغلب به خرابی وابستگی‌ها و هدر رفتن زمان توسعه‌دهندگان منجر می‌شود.
با معرفی و تکامل قابلیت Native AOT (Ahead-Of-Time) در دات‌نت (به‌ویژه در نسخه .NET 10)، اکنون امکان کامپایل کد #C به یک کتابخانه کاملاً بومی (Native DLL) با ساختار فراخوانی استاندارد (Standard Calling Convention یا همان StdCall) فراهم شده‌است. این قابلیت به توسعه‌دهندگان اجازه می‌دهد بدون نیاز به کامپایلر ++C، خروجی‌هایی تولید کنند که از طریق هر زبان یا محیطی که توانایی فراخوانی توابع WinAPI را دارد (مانند برنامه‌های سنتی C++/C، پایتون یا اسکریپت‌های سیستمی)، قابل استفاده باشند. این مقاله به بررسی نحوه راه‌اندازی، پیاده‌سازی و چالش‌های این رویکرد می‌پردازد.

۱. پیکربندی پروژه AOT برای تولید Native DLL
برای ساخت یک کارپوشه (Project) دات‌نت که خروجی آن یک DLL بومی ویندوز باشد، به یک پروژه از نوع Class Library (.NET 10 یا بالاتر) نیاز داریم. کلید اصلی این فرآیند، تنظیم درست فایل پروژه (.csproj) است.

تنظیمات فایلcsproj
کدهای زیر تنظیمات حیاتی برای فعال‌سازی کامپایل بومی را نشان می‌دهند:
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        
        <PublishAot>true</PublishAot>
        <NativeLib>Shared</NativeLib>
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    </PropertyGroup>
</Project>
  • PublishAot: به کامپایلر اعلام می‌کند که کدها را به صورت پیش‌فرض به زبان ماشین (Ahead-of-Time) تبدیل کند.
  • NativeLib: با مقداردهی Shared مشخص می‌کند که خروجی باید یک کتابخانه پویا (.dll در ویندوز) باشد، نه یک فایل اجرایی مستقل.
  • AllowUnsafeBlocks: برای مدیریت اشاره‌گرها (Pointers) و کدهای ناامن که در تعاملات بومی (P/Invoke) اجتناب‌ناپذیر هستند، الزامی است.

تعریف متدهای اکسپورت‌شده (Exported Methods)
در مرحله بعد، باید متدهای خود را به صورت static تعریف کرده و با استفاده از ویژگی [UnmanagedCallersOnly] آن‌ها را برای محیط بیرون قابل دسترسی کنیم:
using System.Runtime.InteropServices;

public static class NativeExports
{    
    [UnmanagedCallersOnly(EntryPoint = "AddNumbers", CallConvs = new[] { typeof(CallConvStdcall) })]
    public static int AddNumbers(int a, int b)
    {
        return a + b;
    }
}
نکته فنی: ویژگی UnmanagedCallersOnly به دات‌نت می‌گوید که این متد مستقیماً توسط کدهای غیرمدیریت‌شده (Unmanaged) صدا زده می‌شود. آرگومان CallConvs نوع قراردارد فراخوانی را روی CallConvStdcall (استاندارد WinAPI) تنظیم می‌کند که برای سازگاری کامل با کتابخانه‌های ویندوزی ضروری است.

فرآیند انتشار (Publish)
یک dotnet build معمولی تنها یک فایل اسمبلی دات‌نتی استاندارد تولید می‌کند. برای تولید DLL بومی، باید از دستور dotnet publish به همراه مشخص کردن معماری هدف (Target Runtime) استفاده کنید:
# انتشار برای معماری ۶۴ بیتی ویندوز
dotnet publish -c Release -r win-x64

# انتشار برای معماری ۳۲ بیتی ویندوز
dotnet publish -c Release -r win-x86
یک تابع ساده مانند متد بالا، خروجی در حدود ۸۵۰ کیلوبایت تولید می‌کند. این حجم با توجه به حذف کامل وابستگی به دات‌نت ران‌پایمِ نصب‌شده روی سیستم، بسیار بهینه‌است. با این حال، با افزودن کتابخانه‌های جانبی، فرآیند Trimming (حذف کدهای استفاده‌نشده) وارد عمل می‌شود؛ اما هرچه تنوع کدهای مورد استفاده بیشتر باشد، حجم خروجی به سرعت به بالای ۱.۵ مگابایت افزایش خواهد یافت.

۲. تعامل با داده‌ها: فراتر از انواع داده اولیه
دات‌نت در پشت صحنه مدیریت حافظه پیچیده‌ای دارد، اما در لایه Native DLL، همه چیز باید شبیه به ساختار زبان C رفتار کند. انواع داده عددی اولیه (int، long، double) مستقیماً جابه‌جا می‌شوند، اما برای رشته‌ها و اشیاء پیچیده، باید از اشاره‌گرها و فرآیند Marshaling استفاده کرد.

مدیریت رشته‌ها (Strings)
در ویندوز، متداول‌ترین روش این است که برنامه فراخوان (مانند یک کلاینت نوشته‌شده با ++C معمولی)، آدرس یک بافر خالی را به همراه طول آن به DLL بفرستد و DLL آن بافر را پر کند.
[UnmanagedCallersOnly(EntryPoint = "TransformString", CallConvs = new[] { typeof(CallConvStdcall) })]
public static int TransformString(IntPtr inputPtr, IntPtr outputPtr, int outputBufferLength)
{
    // تبدیل اشاره‌گر ورودی به رشته دات‌نتی
    string inputStr = Marshal.PtrToStringAnsi(inputPtr) ?? string.Empty;

    if (outputPtr == IntPtr.Zero || outputBufferLength < 1)
        return 0; // بافر خروجی معتبر نیست

    // انجام عملیات روی رشته
    string result = "Processed: " + inputStr;

    // تبدیل رشته دات‌نت به بایت‌های ANSI
    byte[] resultBytes = System.Text.Encoding.ASCII.GetBytes(result);
    int bytesToCopy = Math.Min(resultBytes.Length, outputBufferLength - 1);

    // کپی کردن داده‌ها در بافر خروجی
    Marshal.Copy(resultBytes, 0, outputPtr, bytesToCopy);
    
    // قرار دادن کاراکتر انتهای رشته (Null-terminator)
    Marshal.WriteByte(outputPtr, bytesToCopy, 0);

    return bytesToCopy; // تعداد بایت‌های نوشته‌شده
}

انتقال اشیاء و ساختارها (Structures)
برای انتقال داده‌های ساختاریافته پیچیده، بهترین راهبرد استفاده از ساختارهایی با چیدمان حافظه صریح (LayoutKind.Sequential) است.
[StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Ansi)]
public struct EmployeeInfo
{
    public int Id;
    public double Salary;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
    public string Name;
}

public static class StructureProcessor
{
    [UnmanagedCallersOnly(EntryPoint = "ProcessEmployee", CallConvs = new[] { typeof(CallConvStdcall) })]
    public static int ProcessEmployee(IntPtr structPtr)
    {
        if (structPtr == IntPtr.Zero)
            return -1;

        // خواندن ساختار از اشاره‌گر حافظه
        var employee = Marshal.PtrToStructure<EmployeeInfo>(structPtr);

        // اعمال تغییرات بر روی داده‌ها
        employee.Id += 1000;
        employee.Salary += 500.50;
        employee.Name = "Updated: " + employee.Name;

        // بازنویسی ساختار تغییر یافته در همان آدرس حافظه
        Marshal.StructureToPtr(employee, structPtr, false);

        return 0; // موفقیت‌آمیز
    }
}

۳. فراخوانی DLL از محیط ++C/C بومی
برنامه‌های کلاینت به راحتی می‌توانند این توابع را درست مانند توابع بومی ویندوز فراخوانی کنند. در ادامه یک نمونه کد استاندارد به زبان C++/C برای فراخوانی متد ساختار ارائه‌شده آورده شده است:
#include <windows.h>
#include <iostream>

// تعریف ساختار مطابق با چیدمان دات‌نت
#pragma pack(push, 1)
struct EmployeeInfo {
    int Id;
    double Salary;
    char Name[64];
};
#pragma pack(pop)

// تعریف امضای تابع موجود در DLL
typedef int(__stdcall* ProcessEmployeeFunc)(EmployeeInfo*);

int main() {
    // بارگذاری کتابخانه تولید شده توسط دات‌نت
    HMODULE hDll = LoadLibrary(TEXT("DotNetNativeLib.dll"));
    if (!hDll) {
        std::cout << "خطا در بارگذاری DLL" << std::endl;
        return 1;
    }

    // یافتن آدرس تابع
    ProcessEmployeeFunc ProcessEmployee = (ProcessEmployeeFunc)GetProcAddress(hDll, "ProcessEmployee");
    
    if (ProcessEmployee) {
        // مقداردهی اولیه ساختار
        EmployeeInfo emp = { 1, 2500.0, "Ali" };
        
        // فراخوانی تابع
        ProcessEmployee(&emp);

        // چاپ نتایج تغییر یافته توسط دات‌نت
        std::cout << "New ID: " << emp.Id << "\n";
        std::cout << "New Salary: " << emp.Salary << "\n";
        std::cout << "New Name: " << emp.Name << std::endl;
    }

    FreeLibrary(hDll);
    return 0;
}

۴. چالش‌ها و محدودیت‌های پنهان Native AOT
در نگاه اول، جایگزینی کامل ++C با #C شگفت‌انگیز به نظر می‌رسد، اما توسعه‌دهندگان باید از محدودیت‌های معماری زیر آگاه باشند:
  • محدودیت‌های Trimming و انعکاس (Reflection): در حالت AOT، مکانیزم JIT وجود ندارد. این یعنی استفاده از رفتارهای پویا مانند Reflection، سیستم‌های تزریق وابستگی مفرط (Heavy DI) یا سریال‌سازی‌های مبتنی بر رفلکشن (مثل نسخه قدیمی NewtonSoft.Json) یا کار نخواهند کرد یا خروجی را به شدت حجیم می‌کنند. باید همواره از Source Generatorها استفاده شود.
  • مدیریت حافظه (Garbage Collection): اگرچه شما خروجی Native می‌گیرید، اما بخش کوچکی از ران‌تایم دات‌نت (به نام GC اختصاصی AOT) همچنان درون DLL تعبیه می‌شود تا حافظه داخلی کدهای #C را مدیریت کند. با این حال، حافظه‌ای که از طریق IntPtr از برنامه کلاینت گرفته می‌شود، خارج از لایه نظارت GC است و مدیریت یا آزادسازی آن (Memory Management) همچنان بر عهده برنامه فراخوان خواهد بود.
  • عدم پشتیبانی از سناریوهای پیچیده COM: برخلاف دات‌نت کلاسیک، Native AOT بستر پیش‌فرضی برای اکسپورت کردن مستقیم رابط‌های پیچیده COM یا آبجکت‌های پویا ندارد. واسط‌ها باید در پایین‌ترین سطح ارتباطی (توابع بومی C-Style) حفظ شوند.

نتیجه‌گیری
استفاده از .NET Native AOT جهت ساخت کتابخانه‌های WinAPI، یک راهکار بسیار عالی اما محدود به بازارهای هدف خاص (Niche Feature) است. این رویکرد برای توسعه‌دهندگانی که تمایل دارند فرآیند کامپایلر سنگین ++C، را از فرآیند توسعه (Build Pipeline) خود حذف کنند یا برای سناریوهای راه‌اندازی اولیه سیستم (Bootstrapping) بدون نیاز به نصب پیش‌نیازهای حجیم، گزینه‌ای بی‌نظیر است. با این وجود، اگر محیط فراخوان شما توانایی پشتیبانی از COM Interop را دارد، استفاده از رویکردهای سنتی تعامل دات‌نت احتمالاً رابط‌های برنامه‌نویسی (API) بسیار تمیزتری را نسبت به کار با اشاره‌گرهای سختِ سیستمی در اختیار شما قرار می‌دهد. در نهایت، Native AOT ابزاری قدرتمند است که شکاف میان سادگی توسعه #C و کارایی و بی‌نیازی محیط‌های Native را به شکل چشمگیری پر کرده‌است.