ویرگول
ورودثبت نام
سید عمید قائم مقامی
سید عمید قائم مقامیبرنامه نویسی سیستم ویندوز و مهندسی معکوس و علاقه مند به آموزش.
سید عمید قائم مقامی
سید عمید قائم مقامی
خواندن ۹ دقیقه·۱ روز پیش

مهندسی معکوس (مقدماتی بر IDA ):

فریم‌های پشته (Stack Frames)

یکی از مفاهیم سطح پایین، فریم پشته (stack frame) است. فریم‌های پشته بلوک‌های حافظه‌ای هستند که در پشته زمان اجرا (runtime stack) برنامه اختصاص داده می‌شوند و به یک فراخوانی مشخص از یک تابع تعلق دارند. برنامه‌نویسان معمولاً دستورات اجرایی را در واحدهایی به نام توابع (که به آن‌ها پروسیجرها، زیرروال‌ها یا متدها نیز گفته می‌شود) گروه‌بندی می‌کنند. در برخی موارد، این ممکن است یک الزام زبان مورد استفاده باشد. در بیشتر موارد، ساخت برنامه‌ها از چنین واحدهای تابعی به عنوان بهترین شیوه برنامه‌نویسی تلقی می‌شود.

زمانی که یک تابع در حال اجرا نیست، معمولاً نیاز چندانی به حافظه ندارد. اما وقتی یک تابع فراخوانی می‌شود، ممکن است به دلایل مختلف به حافظه نیاز داشته باشد. اولاً، فراخواننده تابع ممکن است بخواهد اطلاعاتی را به تابع منتقل کند (پارامترها یا آرگومان‌ها) و این پارامترها باید جایی ذخیره شوند که تابع بتواند به آن‌ها دسترسی پیدا کند. ثانیاً، تابع ممکن است به فضای ذخیره‌سازی موقت برای انجام کار خود نیاز داشته باشد. این فضای موقت اغلب توسط برنامه‌نویس از طریق اعلام متغیرهای محلی اختصاص داده می‌شود که می‌توان درون تابع از آن‌ها استفاده کرد، اما پس از اتمام تابع دیگر قابل دسترسی نیستند.

کامپایلرها از فریم‌های پشته (که به آن‌ها رکوردهای فعال‌سازی (activation records) نیز گفته می‌شود) استفاده می‌کنند تا تخصیص و آزادسازی پارامترها و متغیرهای محلی تابع برای برنامه‌نویس شفاف باشد. کامپایلر کدی را وارد می‌کند تا پارامترهای تابع را قبل از انتقال کنترل به خود تابع، در فریم پشته قرار دهد و سپس کدی برای اختصاص حافظه کافی برای متغیرهای محلی تابع اضافه می‌کند.

به دلیل نحوه ساخت فریم‌های پشته، آدرس بازگشت تابع نیز در فریم جدید ذخیره می‌شود. یکی از نتایج مفید استفاده از فریم‌های پشته، امکان بازگشتی بودن (recursion) است، زیرا هر فراخوانی بازگشتی تابع فریم پشته خود را دارد و هر فراخوانی به‌طور مرتب از فراخوانی قبلی جدا می‌شود.

مراحل اجرای یک تابع هنگام فراخوانی:

1. فراخواننده، هر پارامتر مورد نیاز تابع فراخوانی‌شده را در مکان‌هایی قرار می‌دهد که توسط قوانین فراخوانی (calling convention) تابع مشخص شده است. این عملیات ممکن است موجب تغییر اشاره‌گر پشته (stack pointer) شود اگر پارامترها روی پشته زمان اجرا قرار داده شوند.

2. فراخواننده کنترل را به تابع فراخوانی‌شده منتقل می‌کند، معمولاً با دستوراتی مانند CALL در x86 یا JAL در MIPS. آدرس بازگشت معمولاً روی پشته یا در یک رجیستر CPU ذخیره می‌شود.

3. در صورت نیاز، تابع فراخوانی‌شده اقدام به پیکربندی فریم پوینتر (frame pointer) می‌کند و هر مقدار رجیستری که فراخواننده انتظار دارد بدون تغییر باقی بماند، ذخیره می‌کند.

4. تابع فراخوانی‌شده، فضای مورد نیاز برای متغیرهای محلی خود را اختصاص می‌دهد، اغلب با تنظیم اشاره‌گر پشته برای رزرو فضای لازم.

5. تابع عملیات خود را انجام می‌دهد و ممکن است نتیجه‌ای تولید کند. در طول عملیات، تابع ممکن است به پارامترهای منتقل‌شده توسط فراخواننده دسترسی داشته باشد. اگر تابع نتیجه‌ای بازگرداند، معمولاً در رجیستر یا رجیسترهای مشخصی قرار می‌گیرد تا فراخواننده پس از بازگشت بتواند آن را بررسی کند.

6. پس از اتمام عملیات تابع، هر فضای پشته‌ای که برای متغیرهای محلی رزرو شده بود آزاد می‌شود، اغلب با معکوس کردن اقدامات انجام‌شده در مرحله ۴.

7. هر رجیستری که به نمایندگی از فراخواننده ذخیره شده بود (مرحله ۳) به مقادیر اصلی خود بازگردانده می‌شود، از جمله بازگرداندن فریم پوینتر فراخواننده.

8. تابع فراخوانی‌شده کنترل را به فراخواننده بازمی‌گرداند. دستورات معمول شامل RET در x86 و JR در MIPS است. بسته به قوانین فراخوانی، این عملیات ممکن است باعث پاک شدن یک یا چند پارامتر از پشته شود.

9. پس از بازگشت کنترل به فراخواننده، ممکن است لازم باشد پارامترها از پشته حذف شوند. در این حالت، ممکن است نیاز به تنظیم پشته برای بازگرداندن اشاره‌گر پشته به مقدار قبل از مرحله ۱ باشد.

مراحل ۳ و ۴ آن‌قدر در ابتدای تابع معمول هستند که پروالوگ تابع (function prologue) نامیده می‌شوند. به‌طور مشابه، مراحل ۶ تا ۸ در انتهای تابع به قدری رایج‌اند که اپی‌لوگ تابع (function epilogue) را تشکیل می‌دهند. به جز مرحله ۵ که بدنه تابع است، تمامی این عملیات، هزینه اضافی (overhead) مرتبط با فراخوانی یک تابع را تشکیل می‌دهند.

قراردادهای فراخوانی (Calling Conventions)

با داشتن درک ابتدایی از فریم‌های پشته (stack frames)، می‌توانیم نگاهی دقیق‌تر به ساختار آن‌ها بیندازیم. مثال‌های زیر به معماری x86 و رفتار مرتبط با کامپایلرهای رایج x86 مانند Microsoft Visual C/C++ یا gcc/g++ مربوط هستند.

یکی از مهم‌ترین مراحل ایجاد فریم پشته، قرار دادن پارامترهای تابع روی پشته توسط تابع فراخواننده است. تابع فراخواننده باید پارامترها را دقیقاً همان‌گونه که تابع فراخوانی‌شده انتظار دارد، ذخیره کند؛ در غیر این صورت، مشکلات جدی پیش خواهد آمد. توابع نحوه دریافت آرگومان‌های خود را با انتخاب و رعایت یک قرارداد فراخوانی مشخص (calling convention) مشخص می‌کنند.

یک قرارداد فراخوانی دقیقاً تعیین می‌کند که فراخواننده باید پارامترهای مورد نیاز تابع را در کجا قرار دهد. قراردادهای فراخوانی ممکن است الزام کنند که پارامترها در رجیسترهای مشخص، روی پشته برنامه یا هم در رجیسترها و هم روی پشته قرار داده شوند.

به همان اندازه که مکان قرارگیری پارامترها اهمیت دارد، تعیین مسئول حذف آن‌ها از پشته پس از اتمام تابع فراخوانی‌شده نیز مهم است. برخی قراردادهای فراخوانی تعیین می‌کنند که فراخواننده مسئول حذف پارامترهایی است که روی پشته قرار داده، در حالی که برخی دیگر مشخص می‌کنند که تابع فراخوانی‌شده خود این کار را انجام می‌دهد.

رعایت قراردادهای فراخوانی اعلام‌شده برای حفظ یکپارچگی اشاره‌گر پشته برنامه (program stack pointer) ضروری است.

قرارداد فراخوانی C (The C Calling Convention)

قرارداد فراخوانی پیش‌فرضی که توسط اکثر کامپایلرهای C برای معماری x86 استفاده می‌شود، قرارداد فراخوانی C نام دارد. برنامه‌های C/C++ می‌توانند از _cdecl modifier استفاده کنند تا کامپایلر را مجبور کنند از قرارداد فراخوانی C استفاده کند، حتی اگر قرارداد پیش‌فرض جایگزین شده باشد. از این پس به این قرارداد، قرارداد فراخوانی cdecl گفته می‌شود.

قرارداد cdecl مشخص می‌کند که فراخواننده (caller) باید پارامترهای تابع را روی پشته به ترتیب راست به چپ (right-to-left) قرار دهد و فراخواننده، نه تابع فراخوانی‌شده، باید پس از اتمام تابع، پارامترها را از پشته حذف کند.

یکی از نتایج قرار دادن پارامترها به ترتیب راست به چپ این است که چپ‌ترین پارامتر (پارامتر اول تابع) همیشه در بالای پشته هنگام فراخوانی تابع قرار دارد. این باعث می‌شود پیدا کردن پارامتر اول ساده باشد، بدون توجه به تعداد پارامترهایی که تابع انتظار دارد، و قرارداد فراخوانی cdecl را برای توابعی که می‌توانند تعداد متغیری از آرگومان‌ها را بپذیرند (مانند printf) ایده‌آل می‌کند.

اینکه تابع فراخواننده مسئول حذف پارامترها از پشته باشد، به این معناست که اغلب خواهید دید که دستورات تنظیم اشاره‌گر پشته برنامه (program stack pointer) درست پس از بازگشت از تابع فراخوانی‌شده اجرا می‌شوند.

در مورد توابعی که می‌توانند تعداد متغیری از آرگومان‌ها را دریافت کنند، فراخواننده بهترین گزینه برای انجام این تنظیم است، زیرا فراخواننده دقیقاً می‌داند چه تعداد آرگومان به تابع ارسال کرده و می‌تواند به‌راحتی تنظیم درست را انجام دهد، در حالی که تابع فراخوانی‌شده هیچ‌گاه از قبل نمی‌داند چه تعداد پارامتر دریافت خواهد کرد و انجام تنظیم لازم پشته برایش دشوار خواهد بود.

در مثال‌های زیر، ما به فراخوانی تابعی با نمونه (prototype) زیر توجه خواهیم کرد:

به‌صورت پیش‌فرض، این تابع از قرارداد فراخوانی cdecl استفاده خواهد کرد، به‌طوری که انتظار دارد چهار پارامتر به ترتیب راست به چپ روی پشته قرار داده شوند و فراخواننده مسئول پاک کردن پارامترها از پشته باشد.

یک کامپایلر ممکن است کد فراخوانی این تابع را به صورت زیر تولید کند:

چهار عملیات push که از آدرس مشخص شروع می‌شوند، باعث تغییر خالص ۱۶ بایتی در اشاره‌گر پشته برنامه (ESP) می‌شوند (۴ × اندازه یک int در معماری ۳۲ بیتی)، که این تغییر پس از بازگشت از تابع demo_cdecl معکوس می‌شود.

اگر demo_cdecl پنجاه بار فراخوانی شود، هر فراخوانی با تنظیمی مشابه پس از بازگشت دنبال خواهد شد.

مثال زیر نیز با رعایت قرارداد فراخوانی cdecl نوشته شده است، اما نیازی به پاک کردن صریح پارامترها توسط فراخواننده پس از هر فراخوانی demo_cdecl ندارد.

در این مثال، کامپایلر در پروالوگ تابع فضای حافظه‌ای را برای پارامترهای demo_cdecl در بالای پشته از قبل اختصاص داده است. وقتی پارامترهای demo_cdecl روی پشته قرار می‌گیرند، اشاره‌گر پشته برنامه تغییری نمی‌کند و این باعث می‌شود پس از اتمام فراخوانی demo_cdecl نیازی به تنظیم اشاره‌گر پشته نباشد.

کامپایلرهای GNU (gcc و g++) از این تکنیک برای قرار دادن پارامترهای تابع روی پشته استفاده می‌کنند.

توجه داشته باشید که هر دو روش باعث می‌شوند که اشاره‌گر پشته به پارامتر چپ‌ترین (اولین) آرگومان تابع اشاره کند هنگام فراخوانی تابع.

قرارداد فراخوانی استاندارد (The Standard Calling Convention)

واژه استاندارد (Standard) در اینجا کمی نامناسب است، زیرا این نام توسط مایکروسافت برای قرارداد فراخوانی اختصاصی خود ایجاد شده است. این قرارداد با استفاده از _stdcall modifier در اعلام یک تابع مشخص می‌شود، همان‌طور که در مثال زیر نشان داده شده است:

برای جلوگیری از هرگونه ابهام پیرامون کلمه standard، در ادامه این سلسله پست ها به این قرارداد فراخوانی، قرارداد فراخوانی stdcall گفته خواهد شد.

مانند قرارداد cdecl، stdcall نیز نیاز دارد که پارامترهای تابع به ترتیب راست به چپ روی پشته برنامه قرار داده شوند. تفاوت اصلی در stdcall این است که تابع فراخوانی‌شده مسئول پاک کردن پارامترهای تابع از پشته پس از اتمام اجرا است.

برای اینکه تابع بتواند این کار را انجام دهد، باید دقیقاً بداند چند پارامتر روی پشته وجود دارد. این تنها برای توابعی امکان‌پذیر است که تعداد ثابتی از پارامترها را دریافت می‌کنند. در نتیجه، توابع با تعداد متغیر آرگومان‌ها مانند printf نمی‌توانند از قرارداد stdcall استفاده کنند.

به عنوان مثال، تابع demo_stdcall سه پارامتر از نوع integer دریافت می‌کند که مجموعاً ۱۲ بایت روی پشته اشغال می‌کنند (۳ × sizeof(int) در معماری ۳۲ بیتی). یک کامپایلر x86 می‌تواند از نسخه خاصی از دستور RET استفاده کند تا همزمان آدرس بازگشت را از بالای پشته خارج کرده و ۱۲ واحد به اشاره‌گر پشته اضافه کند تا پارامترهای تابع پاک شوند.

در مورد تابع demo_stdcall، ممکن است از دستور زیر برای بازگشت به فراخواننده استفاده شود:

مزیت اصلی استفاده از stdcall، حذف نیاز به کد برای پاک کردن پارامترها از پشته پس از هر فراخوانی تابع است، که منجر به برنامه‌هایی کمی کوچکتر و کمی سریع‌تر می‌شود.

طبق قرارداد، مایکروسافت از قرارداد stdcall برای تمام توابع با آرگومان ثابت که از فایل‌های کتابخانه مشترک (DLL) صادر می‌شوند، استفاده می‌کند. این نکته‌ای مهم است که باید به یاد داشته باشید اگر قصد دارید نمونه‌های تابع (function prototypes) یا جایگزین‌های باینری‌سازگار برای هر یک از اجزای کتابخانه مشترک تولید کنید.

قراردادی که از stdcall مشتق شده است، قرارداد فراخوانی fastcall تا دو پارامتر را به جای قرار دادن روی پشته برنامه، در رجیسترهای CPU منتقل می‌کند. کامپایلرهای Microsoft Visual C/C++ و GNU gcc/g++ (نسخه ۳.۴ و بعد از آن) از fastcall modifier در اعلام توابع پشتیبانی می‌کنند.

وقتی fastcall مشخص شود، دو پارامتر اول تابع به ترتیب در رجیسترهای ECX و EDX قرار می‌گیرند. هر پارامتر باقی‌مانده روی پشته به ترتیب راست به چپ مانند stdcall قرار می‌گیرد.

همچنین مشابه stdcall، توابع fastcall مسئول پاک کردن پارامترها از پشته پس از بازگشت به فراخواننده خود هستند.

اعلامیه زیر نشان‌دهنده استفاده از fastcall modifier است:

یک کامپایلر ممکن است برای فراخوانی demo_fastcall کد زیر را تولید کند:

توجه داشته باشید که پس از بازگشت از فراخوانی demo_fastcall نیازی به تنظیم پشته نیست، زیرا demo_fastcall مسئول پاک کردن پارامترهای y و z از پشته هنگام بازگشت به فراخواننده است. لازم است درک کنید که به دلیل اینکه دو آرگومان در رجیسترها منتقل می‌شوند، تابع فراخوانی‌شده تنها نیاز دارد ۸ بایت از پشته را پاک کند، حتی اگر تابع چهار آرگومان داشته باشد.

Telegram: @CaKeegan
Gmail : amidgm2020@gmail.com

مهندسی معکوسویندوز
۲
۰
سید عمید قائم مقامی
سید عمید قائم مقامی
برنامه نویسی سیستم ویندوز و مهندسی معکوس و علاقه مند به آموزش.
شاید از این پست‌ها خوشتان بیاید