مقدمه

به فصل هجدهم خوش آمدید. در این فصل، به سطح پایین‌تری از اجرای کد در .NET شیرجه می‌زنیم و با زبان اسمبلی دنیای .NET آشنا می‌شویم. همانطور که در درس‌های اولیه اشاره شد، وقتی شما کد C# (یا هر زبان .NET دیگری) را کامپایل می‌کنید، خروجی آن کد ماشین مخصوص یک پردازنده‌ی خاص نیست. در عوض، کد شما به یک زبان میانی به نام Common Intermediate Language (CIL) ترجمه می‌شود. این کد CIL سپس در یک اسمبلی به همراه فراداده ذخیره می‌گردد.

درک CIL برای اکثر برنامه‌نویسان C# ضروری نیست، اما برای کسانی که به دنبال درک عمیق‌تری از نحوه‌ی کار .NET هستند، یا می‌خواهند کدهای خود را در سطح بسیار پایینی بهینه کنند، یا با سناریوهای پیشرفته‌ای مانند تولید کد در زمان اجرا (Code Generation) سروکار دارند، آشنایی با گرامر CIL بسیار ارزشمند است. در این درس، ساختار کلی این زبان میانی، دستورات اصلی آن و نحوه‌ی کار با پشته‌ی ارزیابی را بررسی خواهیم کرد.

زبان میانی مشترک (CIL) چیست؟

CIL (که قبلاً با نام Microsoft Intermediate Language یا MSIL شناخته می‌شد) یک زبان شیءگرای مبتنی بر پشته (stack-based) است. این زبان به طور کامل از مفاهیم شیءگرایی مانند کلاس‌ها، اینترفیس‌ها و وراثت پشتیبانی می‌کند. مهم‌ترین ویژگی CIL، استقلال آن از پردازنده (CPU-agnostic) و پلتفرم است. کد CIL یکسان می‌تواند بر روی هر سیستم‌عاملی که یک پیاده‌سازی از Common Language Runtime (CLR) را داشته باشد، اجرا شود.

در زمان اجرا، کامپایلر Just-In-Time (JIT) که بخشی از CLR است، کد CIL را به کد ماشین بومی و قابل اجرای آن پردازنده‌ی خاص ترجمه می‌کند. این فرآیند به .NET اجازه می‌دهد تا هم از مزایای قابلیت حمل (portability) و هم از عملکرد بالای کد بومی بهره‌مند شود.

مشاهده‌ی کد CIL

برای مشاهده‌ی کد CIL تولید شده از کد C#، می‌توانیم از ابزاری به نام Intermediate Language Disassembler (ildasm.exe) استفاده کنیم. این ابزار بخشی از .NET SDK است و معمولاً از طریق Developer Command Prompt for VS قابل دسترس است. با اجرای دستور ildasm MyAssembly.dll یک رابط کاربری گرافیکی باز می‌شود که به شما اجازه می‌دهد ساختار اسمبلی و کد CIL متدهای آن را مشاهده کنید.

ساختار یک متد در CIL

کد CIL برای یک متد از دو بخش اصلی تشکیل شده است: دایرکتیوها (Directives) و دستورات (Opcodes).

دایرکتیوهای CIL

دایرکتیوها دستوراتی هستند که با یک نقطه (`.`) شروع می‌شوند و اطلاعات توصیفی یا فراداده را در مورد اسمبلی، کلاس یا متد فراهم می‌کنند. برخی از مهم‌ترین دایرکتیوهایی که در یک متد می‌بینید عبارتند از:

  • .method: شروع تعریف یک متد را مشخص می‌کند و شامل اطلاعاتی مانند سطح دسترسی (public, private)، ماهیت (static, virtual)، نوع بازگشتی و نام متد است.
  • .maxstack: حداکثر تعداد آیتم‌هایی که در طول اجرای این متد بر روی پشته‌ی ارزیابی قرار خواهند گرفت را مشخص می‌کند. JIT از این اطلاعات برای تخصیص بهینه‌ی حافظه استفاده می‌کند.
  • .locals init: متغیرهای محلی مورد استفاده در متد را تعریف می‌کند. کلمه‌ی کلیدی init تضمین می‌کند که این متغیرها قبل از استفاده به صفر یا null مقداردهی اولیه می‌شوند.

دستورات (Opcodes)

دستورات یا opcodes، عملیات واقعی هستند که بر روی داده‌ها انجام می‌شوند. CIL یک زبان مبتنی بر پشته است، به این معنی که بیشتر دستورات، عملوندهای خود را از بالای یک ناحیه‌ی حافظه‌ی موقت به نام پشته‌ی ارزیابی (Evaluation Stack) برداشته (pop)، عملیات را انجام داده و نتیجه را دوباره به بالای پشته اضافه می‌کنند (push).

بیایید برخی از رایج‌ترین دسته‌ها و دستورات را بررسی کنیم:

  • دستورات بارگذاری (Load): برای قرار دادن مقادیر بر روی پشته استفاده می‌شوند.
    • ldarg.0, ldarg.1, ...: آرگومان اول، دوم و ... متد را بر روی پشته قرار می‌دهد. ldarg.0 برای متدهای نمونه، به ارجاع this اشاره دارد.
    • ldloc.0, ldloc.1, ...: متغیر محلی اول، دوم و ... را بر روی پشته قرار می‌دهد.
    • ldc.i4.0, ldc.i4.1, ...: یک مقدار ثابت صحیح (0، 1 و ...) را بر روی پشته قرار می‌دهد.
    • ldstr: یک رشته‌ی متنی را بر روی پشته قرار می‌دهد.
  • دستورات ذخیره‌سازی (Store): برای برداشتن یک مقدار از پشته و ذخیره‌ی آن در یک متغیر استفاده می‌شوند.
    • stloc.0, stloc.1, ...: مقداری را از بالای پشته برداشته و در متغیر محلی اول، دوم و ... ذخیره می‌کند.
  • دستورات حسابی: مقادیر را از پشته برداشته، عملیات را انجام داده و نتیجه را به پشته برمی‌گردانند.
    • add, sub, mul, div, rem
  • دستورات فراخوانی و بازگشت:
    • call: یک متد را فراخوانی می‌کند. برای متدهای نمونه، ارجاع به شیء باید قبل از آرگومان‌ها روی پشته باشد.
    • ret: از متد فعلی خارج می‌شود. اگر متد مقدار بازگشتی داشته باشد، این مقدار باید در بالای پشته باشد.

تحلیل یک مثال ساده

بیایید کد C# زیر را در نظر بگیریم و کد CIL تولید شده برای آن را تحلیل کنیم.

Copy Icon C# Code
public int Add(int a, int b)
{
    int result = a + b;
    return result;
}

کد CIL معادل (به صورت ساده‌شده) به شکل زیر خواهد بود:

Copy Icon CIL Code
.method public instance int32 Add(int32 a, int32 b) cil managed
{
  .maxstack  2
  .locals init ([0] int32 result)
  
  IL_0000:  ldarg.1      // Load argument 'a' onto the stack
  IL_0001:  ldarg.2      // Load argument 'b' onto the stack
  IL_0002:  add          // Pop 'a' and 'b', add them, push result
  IL_0003:  stloc.0      // Pop the sum and store it in local variable 'result'
  IL_0004:  ldloc.0      // Load 'result' back onto the stack
  IL_0005:  ret          // Return the value from the top of the stack
}

بیایید اجرای این کد را بر روی پشته‌ی ارزیابی دنبال کنیم:

  1. ldarg.1: آرگومان a روی پشته قرار می‌گیرد. پشته: [a]
  2. ldarg.2: آرگومان b روی پشته قرار می‌گیرد. پشته: [a, b]
  3. add: دو مقدار بالایی پشته (a و b) برداشته شده، با هم جمع می‌شوند و نتیجه روی پشته قرار می‌گیرد. پشته: [a+b]
  4. stloc.0: مقدار بالای پشته (a+b) برداشته شده و در متغیر محلی اول (result) ذخیره می‌شود. پشته: [] (خالی)
  5. ldloc.0: مقدار متغیر محلی result روی پشته قرار می‌گیرد. پشته: [result]
  6. ret: مقدار بالای پشته (result) به عنوان مقدار بازگشتی متد برگردانده می‌شود.

همانطور که می‌بینید، حتی یک خط ساده‌ی C# به مجموعه‌ای از دستورالعمل‌های سطح پایین‌تر ترجمه می‌شود. درک این فرآیند، پایه‌ی اصلی برای مباحث پیشرفته‌تری مانند مهندسی معکوس و تولید کد دینامیک است که در درس‌های آینده به آن‌ها خواهیم پرداخت.