در این مقاله قصد داریم به معرفی و تفسیر دو کانونشن معروف فراخوانی توابع، یعنی STDCALL (بخوانید اِس تی دی کال) و CDECL (بخوانید سی دِکِل) بپردازیم و تفاوتهای آنرا بررسی کنیم. این پست تخصصی بوده و برنامهنویسان سطح بالا معمولاً با آن برخوردی ندارند، اما برنامهنویسان سطح پایین، کرکرها، هکرها و در کل تمام کسانی که در حوزه مهندسی معکوس نرمافزار فعالیت دارند باید با این مفاهیم آشنا باشند.
لازم به ذکر است که این قوانین در زبانهای برنامهنویسی سطح بالا نیز در پشت پرده انجام میگیرد، اما برنامهنویسان به لحاظ سطح بالای زبان خود با آن برخورد نمیکنند و کامپیایلر این موارد را انجام میدهد.
تمامی مثالهای این پست بر اساس سینتکس FASM (که بنظر من بهترین اسمبلر حال حاضر هست) برای شما دوستان فراهم شده است. توجه داشته باشید که این پست یک پست تخصصی و برای برنامهنویسان سطح پایین است و اگر برنامهنویس سطح بالا هستید ممکن است برای شما گنگ و نامفهوم باشد، اما خواندن آن حتی برای برنامهنویسان سطح بالا خالی از لطف نیست.
همانطور که میدانید برای ارسال پارامتر به یک تابع میتوانیم از چند روش استفاده میکنیم، میتوانیم توابع را در رجیسترها قرار دهیم (که در معماری 32 بیت به دلیل تعداد کم رجیسترها معمولاً اینکار انجام نمیشه ولی در معماری 64 بیت به دلیل بزرگتر بودن رجیستر و همچنین موجود بودن تعداد بیشتری رجیستر عملی متداول است)، یا میتوانیم از متغیرهای سراسری جهت قرار دادن آرگمانها استفاده کنیم (که باعث میشد قابلیت جابجایی و استفاده مجدد آن کد کم شود، چرا که هر وقت بخواهیم از آن تابع در برنامه دیگر استفاده کنیم باید متغیرهای مربوط به تابع را در نیز سکشن BSS اعلان کنیم) به عنوان راه سوم میتوانیم از مفهوم قدرتمندی به نام استک استفاده کنیم که روشی بسیار متداول بوده و برنامهنویسان معمولاً از این روش جهت ارسال پارامتر به تابع استفاده میکنند.
اما سوالی که به هنگام استفاده از استک جهت ارجاع پارامتر استفاده میشود این است که چه کسی مسئول تمیز کردن و بالانس کردن استک است؟ آیا این وظیفه بر عهده Caller است یا بر عهده Callee؟ همانطور که میدانید به هنگام CALL کردن یک تایع، آدرس برگشت در استک PUSH میشود و اگر بخواهیم در تابع خودمون با استفاده از دستور POP آرگمانها را بازگردانی کنیم به مشکل بر میخوریم، چراکه آدرس برگشت از بین میرود، لذا مجبور هستیم آرگمانها را با استفاده از ثبات ESP بازیابی کنیم و در انتها استک را به درستی بالانس کنیم.
Caller و Callee کیست؟
به برنامهنویسی که تایع را فراخوانی میکند Caller و به برنامهنویسی که تابع او توسط دیگر برنامهنویسان فراخوانی میشود Callee گفته میشود. در واقع Callee شخصی است که تابع را نوشته و Caller شخصی است که تابع را فراخوانی میکند.
بطور مثال تابعی داریم که دو آرگمان از ورودی میگیرد و پس از تفریق دو آرگمان ورودی، نتیجه را در EAX ذخیره میکند. اگر مسئول تمیز کردن استک Caller باشد این برنامه بصورت زیر نوشته میشود:
PUSH 5 ; آرگمان اول PUSH 2 ; آرگمان دوم CALL function ADD ESP, 8 ; تمیز کردن استک و حذف دو آرگمان ورودی جهت بالانس استک function: MOV EAX, dword[ESP + 8] ; ESP + 8 = محل آرگمان اول در استک SUB EAX, dword[ESP + 4] ; ESP + 4 = محل آرگمان دوم در استک RET
توضیح: با PUSH کردن آرگمان ها دو DWORD به استک اضافه میشوند. با فراخوانی تابع function با استفاده از دستور CALL آدرس دستور بعدی (یعنی ADD ESP, 8) در استک PUSH میشود و EIP به آدرس function پرش میکند. از آنجایی که اگر از دستور POP استفاده کنیم آدرس بازگشت را از دست میدهیم بصورت دستی و بوسیله اشارهگر استک (یا همون ESP) مقادیر را بازیابی میکنیم. هر DWORD برابر 4 بایت است. در خانهای از استک که هم اکنون ESP به آن اشاره دارد آدرس بازگشت قرار گرفته است. اگر به این اشارهگر 4 واحد اضافه کنیم به آرگمان دوم (از اونجایی که معماری استک LIFO است) و اگر 4 واحد دیگه (یعنی در مجموع 8 واحد) اضافه کنیم به مقدار آرگمان اول رسیده ایم. این دو مقدار از یکدیگر کم میشوند و نتیجه در EAX قرار میگیرد. با اجرای دستور ret درواقع دستور POP فراخوانی میشود (آدرس بازگشت بازیابی میشود) و مقدار آن در EIP قرار میگیرید و بنابراین دستور ADD ESP, 8 اجرا میشود. از آنجایی که این دو آرگمان POP نشدهاند و همچنان در استک حضور دارند، با افزودن 8 واحد به ESP (دو تا آرگمان DWORD یعنی دو تا 4 بایت، یعنی در مجموع 8 بایت) اشارهگر استک در مکانی قرار میگیرد که قبل از PUSH کردن این دو عنصر قرار داشت، بنابراین اگر از قبل در برنامه خودمان چیزی را PUSH کرده باشیم (مثلاً مقدار EAX قبل از فراخوانی تابع) میتوانیم به درستی آنرا POP کنیم. اگر استک را بالانس نمیکردیم با اولین دستور POP مقدار آرگمان دوم بازیابی میشد که چیزی نیست که در ادامه برنامه خودمان انتظار داریم، چرا که 5 و 2 تنها آرگمان های ما بوده و انتظار داریم فقط در داخل تابع بازیابی شوند و نه در بدنه برنامه اصلی
همانطور که میبینید Caller (کسی که تابع را فراخوانی کرد) مجبور شد با دستور ADD ESP, 8 استک را بالانس کند.
حال اگر بخواهیم Callee استک را بالانس کند باید به این شکل عمل کنیم:
PUSH 5 PUSH 2 CALL function function: MOV EAX, dword[ESP + 8] MOV EAX, dword[ESP + 4] RET 8
همانطور که میبینید در این شبه کد پس از CALL اقدام به تمیز کردن استک نکرده ایم (در واقع Caller استک را تمیز نکرد)، اما بجای دستور RET در تابع فوق نوشتهایم RET 8
با آرگمان دادن به دستور RET ابتدا همانند دستور RET بدون آرگمان آدرس بازگشت از استک POP میشود و در EIP قرار میگیرد، و در ادامه مقدار ESP به اندازه مقداری که در آرگمان RET مشخص کردهایم (در این مثال 8 واحد) افزایش میابد و استک بالانس میشوند.
بنابراین در این مثال شخص Caller مجبور نبود استک را تمیز کند و این کار بر عهده نویسنده تابع یا همون Callee بود.
این موضوع که چه کسی باید استک را تمیز کند باعث معرفی دو کانونشن بسیار معروف که بحث امروز ماست شده است، یعنی STDCALL و CDECL
قاعده STDCALL همان قاعدهای است که به هنگام استفاده از API ویندوز از آن استفاده میکنیم، یعنی به هنگام برنامهنویسی و کارکردن با توابع API ویندوز نیازی نیست زحمت تمیز کردن استک را بکشیم و اینکار توسط Callee که همون مایکروسافت باشه انجام شده است.
قاعده CDECL همونجوری که از اسمش پیداشت قاعدهای است که در زبان C و ++C از آن استفاده میشود و اگر قصد فراخوانی توابع کتابخانههای C را داشته باشیم (بطور مثال دستور printf از کتابخانه msvcrt.dll) باید خودمان اقدام به تمیز کردن استک کنیم و در واقع بالانس استک بر عهده Caller است.
شخصی که تابعی را در اختیار ما میگذارد باید در توضیحات آن مشخص کرده باشد تابع او از قوانین CDECL پیروی میکند یا STDCALL و این موضوع که از کدام قانون پیروی کنیم بحثی سلیقهای است (شخصاً طرفدار STDCALL هستم) و تفاوتی از لحاظ سرعت و عملکرد در آن وجود ندارد. باید توجه داشت که هم در کانونشن STDCALL و هم CDECL مقدار بازگشتی تابع باید در ثبات EAX (یا RAX اگر 64 بیتی باشه) قرار بگیره.
نکته: جهت بررسی دقیقتر مثالها از دستورات ENTER و LEAVE جهت Prologue و Epilogue استفاده نکردهایم تا دقیقاً بررسی کنیم چه اتفاقی در پشت پرده رخ میده.