C++ da funksiyalarga murojaat uslublari(Calling conventions)

C++ da funksiyalarga murojaat uslublari(Calling conventions)

learner

Aslida bu faqat C++ ga emas, balki barcha kompilyatsiya qilinadigan tillarga aloqasi bor mavzu. Hozir ushbu noodatiy mavzu haqida bilganlarimni x86 arxitekturasi misolida yoritib bermoqchiman. Noodatiy deganim sababi shunda-ki bunaqa mavzuga odatiy ish davomida duch kelinmaydi. Funksiyaga qanday murojaat qilish kerakligi kompilyator tomonidan nazoratga olingan bo'ladi va dasturchi bu kabi ishlarga bosh qotirib o'tirishi shart emas. Lekin cross-platform uchun kutubxona yozayotganda turli platforma va kompilyatorlarni hisobga olgan holda funksiya qaysi murojaat uslubidan foydalanayotganligini aniq belgilab ketish kerak. Keling dastavval funksiya mashina uchun nima ekanligini ko'raylik.



Registrlar haqida qisqacha.

x86 arxitekturadagi CPU da bir qancha umumiy maqsad uchun foydalaniladigan registrlar bor: EAX, EBX, ECX, EDX. Ular 32 bitlik bo'lib, sanoqli bo'ladi va ishlash tezligi bo'yicha keshdan ham tez hisoblanadi, ya'ni eng tezkor xotira degani. Ular xuddi maxsus o'zgaruvchilarga o'xshaydi - biz ularga qiymat biriktirib arifmetik va mantiqiy amallar bajarishimiz mumkin. Shuningdek CPU da EIP(Instruction Pointer) registeri bo'lib u o'zida keyingi bajarilishi kerak bo'lgan buyruq manzilini saqlaydi. Ya'ni hozirgi buyruq bajarib bo'lingach keyingi buyruq EIP registri ko'rsatib turgan manzildan olinadi.

Stek haqida qisqacha.

Stek har bir protsess uchun alohida beriladi va o'zgarmas hajmda bo'ladi. O'sha biz bilgan LIFO(Last-In-First-Out - oxirgi kirgan birinchi chiqadi) usulida ishlaydigan ma'lumotlar strukturasidek gap. U ham RAM da saqlanadi albatta, stekni xotirani boshqarishning bir usuli sifatida qarashimiz mumkin, va quyidagicha tasavvur qilsak bo'ladi:


Stek tepasini ko'rsatib turishi uchun CPU da maxsus ESP registri bor. Stek odatda pastga qarab o'sadi - ya'ni stekka qiymat kiritganingiz sari ularning manzili kamayish tartibida ketadi - birining manzili 0xA4 bo'lsa keyingisi masalan 0xA0 bo'lishi mumkin. Bunda biz aytgan ESP qiymati ham kamayadi degani. Qiymat kiritish va qiymat chiqarish uchun PUSH va POP buyruqlari bor. PUSH - bu biror qiymatni ESP ko'rsatib turgan joydan boshlab yozadi va ESP qiymatini o'sha yozilgan qiymat hajmicha birlikda kamaytiradi, POP esa teskarisi - stek tepasidagi qiymatni biror yerga(registr yoki tezkor xotiraga masalan) yozadi va ESP qiymatini necha bayt olingan bo'lsa shunchaga oshiradi.

Biz lokal o'zgaruvchilar, funksiyalarga argumentlarni shu stekda saqlaymiz, kerak bo'lganda registrlarga yuklab, amallar bajarib, yana stekdagi joyiga qayta yozib qo'yishimiz mumkin. Stekka avval kiritilgan qiymatlarni o'qib olish uchun har doim tepadagi qiymatlarni pop qilib o'tirishimiz shartmas. Aytaylik 3 ta int tipidagi lokal o'zgaruvchilar(a,b va c deb ataylik) bor bo'lsin. Ular stekka ustma-ust kiritiladi. Masalan a ni oladigan bo'lsak biz ma'lumotlar tuzilmasidan bilgan stek kabi b va c ni stekdan pop qilib olishimiz shart emas: shunchaki esp+12 manzili orqali kerakli joydan 4 baytlik ma'lumotni o'qib/yozib qo'yamiz. 12 — 3 ta 4 baytlik o'zgaruvchilarni stekka push qilganimiz uchun, ya'ni umumiy 12 bayt stekdan ajratilgan va ESP qiymati ham 12 ga kamaygan. Xuddi shunday stekdan 10 bayt ajratish uchun push qilib o'tirish shart emas - ESP ni 10 ga kamaytirish kifoya.

Funksiyalar qanday ko'rinishda bo'ladi?

Endi tasavvur qiling - dastur degani mashina kodidan tashkil topgan juda uzun tasma. Har bir buyruq shu tasmaning bir bo'g'ini, shuningdek biz yozgan funksiyalar ham. CPU ana shu tasmadan biror buyruqni o'qiydi va keyingi buyruqni EIP ko'rsatib turgan manzildan oladi(xuddi tyuring mashinasidek). Shuningdek ma'lum shartlarga qarab ko'rsatkich tasmaning tepasiga yoki pastiga qarab "sakrashi" mumkin. Bunday buyruqlar uchun ham mashina kodi bor, assemblerda bu JMP(Jump qisqartmasi) va shunga o'xshash buyruq bilan ifodalanadi. Lekin muammo bor - biz biror joyga sakraganimizda sakralgan joyga yana qaytib kelishimiz kerak. Buning uchun esa sakrashdan oldin EIP qiymatini biror joyga yozib qo'yish kerak. Buning uchun ham mos mashina kodlari bor, assemblerda esa bu CALL buyrug'iga to'g'ri keladi.

CALL addr

Bunda avval EIP qiymati stekka kiritiladi, ESP qiymati 4 ga kamaytiriladi(EIP - 32 bitlik ma'lumot saqlaydi), va CALL ga berilgan manzil bo'yicha tasmaning o'sha qismiga o'tiladi. Xuddi shu buyruq sherigi RET(return qisqartmasi) teskari ishlaydi - stek tepasidan manzilni oladi, ESP ni 4 ga oshiradi va EIP qiymatini o'sha pop qilingan qiymatga o'zgartiradi.

Stack frame haqida qisqacha.

Mana shu funksiya chaqirilishi va funksiya ishini yakunlab chaqirilgan manzilga qaytish oralig'ida funksiyadagi lokal o'zgaruvchilar stekda vaqtincha ijarada turadi(ya'ni stekka kiritiladi). Bunda boyagi qaytish manzilimiz endi o'sha stekka kiritilgan lokal qiymatlar ostida qolib ketdi - RET uchun esa stek tepasida bo'lishi kerak edi. Funksiya ishini tugatgach bizga lokal qiymatlar endi kerak emas, ularni ijaradan chiqarib yuboraveramiz. Bu ishni shunchaki ESP ni avvalgi joyiga - lokal qiymatlar kiritilmagan paytidagi holatga qaytarib qo'yamiz. ESP ning eski qiymatini qanday eslab qolamiz? Buning uchun shunday maqsadlarda foydalanishga EBP registri bor(BP - Base Pointer). Dastavval funksiya boshida ESP ning eski qiymatini EBP ga yuklab olamiz:

push ebp ; ebp ning eski qiymatini eslab qolamiz
mov ebp, esp

Endi qo'rqmasdan stekka qiymatlarni kiritaveramiz, nechta qiymat kiritganimizni eslab qolishimiz shart emas. Funksiya oxirida:

mov esp, ebp ; esp ning avvalgi qiymatini ebp dan olamiz
pop ebp ; ebp ning eski qiymatini stekdan tiklab olamiz
ret

Stack frame deyilishiga sabab ham shunda. Stek ichida xuddi alohida joy ajratib olgandek ko'rinadi.

Funksiyalarni high-level da o'rganayotganda lokal qiymatlarni stekdan o'chirish, yoki lokal qiymatlar o'chib ketadi kabi so'zlarga oldin duch kelgan bo'lsangiz kerak. Aslida shunchaki ESP qiymatini surib qo'yamiz, o'tiramizmi o'chiriladigan qiymatlarni nollab ;) keyinroq stekka nimadir push qilganimizda ustidan yozib yuboraveradi-da.

Stack frame ham aslida stekni to'g'ri boshqarish uchun bir strategiya holos, undan foydalanmasa ham bo'ladi. Foydalanilsa debugging paytida qo'l keladi. Aynan ba'zi dasturlar reverse engineer larni chalg'itish uchun ham stack frame ni ataylab qo'llashmaydi.

Funksiyada registrlardan foydalanish tartibi.

Funksiyalar uchun nafaqat stek, balki CPU registrlaridagi qiymatlar ham umumiy bo'ladi(chunki biz bitta thread ga ega protsess haqida gaplashyapmiz). Deylik biz hisob-kitob qilyapmizda, biror funksiyani chaqirishimiz kerak bo'lib qoldi, lekin unga ham hisob-kitoblari uchun CPU registrlari kerak. Bu holda funksiyani chaqiruvchi(keyingi qismlarda shunchaki chaqiruvchi deb ketamiz) yoki chaqiriluvchi funksiya registrlarning eski qiymatlarini stekka kiritish orqali saqlab qo'yishlari kerak.


Endi savol, funksiyalarga qay tartibda argumentlar beramiz va natijani qanday olamiz?

Va nihoyat maqolaning asosiy qismiga ham yetib keldik ':)

Ixtiyorimizda bir nechta registrlar va stek bor, shulardan foydalanib ma'lumot jo'natishni kelishib olishimiz kerak. Ya'ni qay holatda registrlarda argument jo'natamiz, agar stekda jo'natadigan bo'lsak buni qanday qilamiz, yoki ham stek ham registrlar yordamida jo'natmoqchi bo'lsak-chi, unda bular qay tartibda bo'lishi kerak? Va agar CPU registrlardan chaqiriluvchi funksiya ham foydalanmoqchi bo'lsa, eski qiymatlarni avval saqlab qo'yishi kerakmi? Keyinchalik boshqa dasturchilar biz yozgan qism kodlarni ishlatishmoqchi bo'lishsa o'sha kelishuvlarga asoslanib biz yozgan kodlarga "muomala qilinadi". Calling conventions - aynan shu narsaga kerak. Ushbu muomala turini biz funksiya yozish paytida aytib ketishimiz mumkin:

return_type calling_convention function_name(args) {
//function body
}

Tepada yetarlicha nazariya bilan ezvordim, endi qo'llarimiz chigalini yozish vaqti keldi. C++ kodni assemblerdagi ko'rinishini olish uchun GCC kompilyatoriga quyidagi buyruqni beramiz:

g++ -S source.cpp

C++ da name mangling texnikasi ishlatiladi, ya'ni yozgan funksiya nomimizga qandaydir g'alati prefix/suffix lar qo'shilib qolishi mumkin. Buning oldini olish uchun kodni C faylga yozishni va gcc ishlatishni maslahat beraman. Yoki C++ kodni extern "C" bloki ichiga yozish orqali ham name mangling dan qutulsa bo'ladi. Assembler intel sintaksida yozilgan va har-xil abrakadabralarsiz tozaroq versiyasini olish uchun quyidagicha flaglar yordamida o'girib olamiz:

gcc -m32 -masm=intel -fno-dwarf2-cfi-asm -fno-asynchronous-unwind-tables -fno-pic -O0 -S source.c

source.c ni o'zingizdagi fayl nomiga almashtirib olasiz. Menda 64-bitlik bo'lgani uchun assemblerni x86 da olishga -m32 flagini berdim, agar sizdagi arxitektura x86 da bo'lsa bu shartmas. Natija bunda source.s ga yoziladi(sizdagi holatda file_name.s bo'lishi mumkin).

Umumiy ko'rmoqchi bo'lgan kodimiz quyidagicha:

int add(int a, int b, int c) {
       int res = a + b + c + 10;
       return res;
}
int caller() {                    
       int a = 1;
       int b = 2;
       int c = 3;
       return add(a,b,c) + 5;
}  

Buni biror C faylga yozib yuqoridagi gcc uchun berilgan buyruq bilan natijaviy faylni olib ko'rishingiz mumkin. Umuman, caller qay tartibda add funksiyasini chaqiryapti, bizni shu qiziqtiradi.

Natijani assemblerga o'girib tahlil qilamiz, siz foydalanayotgan kompilyator bergan natija maqoladagi namunadan farq qilishi mumkin. Maqola yozish davomida foydalanilgan kompilyator(va os):

gcc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
Copyright (C) 2021 Free Software Foundation, Inc.

cdecl

cdecl(C declarations) - ko'pgina C kompilyatorlari tomonidan x86 arxitekturasi uchun ishlatilinadigan murojaat uslubi. Bunda argumentlar stekka o'ngdan chapga qarab kiritiladi, qiymat butun son yoki qandaydir manzil bo'lsa EAX registrida qaytariladi. EAX, ECX va EDX registrlaridagi qiymatlarni chaqiruvchi saqlab olishi kerak(ya'ni agar qiymatlar kerak bo'lsa stekka avval kiritib qo'yish kerak, qolgan registrlarni chaqiriluvchi saqlab oladi. Bu uslubdan tepada ko'rsatgan kodimizni assemblerga o'girganimizda foydalanilganini ko'rsak bo'ladi:

caller tomonidan ecx va edx registrlariga ehtiyoj bo'lmaganligi uchun stekka saqlanmagan bu holatda. Shuningdek chaqiruvchi stekda uzatilgan argumentlarni funksiyani chaqirib bo'lgach o'zi bo'shatib yuboryapti(add esp, 12). Bu narsa keyinchalik executable fayl hajmiga ham ozgina(arzimagan) ta'sir qiladi, chunki aytaylik funksiya 20 ta yerdan chaqirilgan bo'lsa stek tozalash kodi o'sha 20 ta yerga funksiya chaqirilgan keyingi qismiga yozib qo'yiladi.

Savol bo'lishi mumkin: nega add funksiyasida bizga faqat res uchun 4 bayt kerak bo'lsa ham lekin 16 bayt stekdan ajratilgan(sub esp, 16)? Bu strategiyaga "stack alignment" deyiladi. Bu haqida hozircha to'xtalib o'tmoqchi emasman, chunki siz o'zingiz maqolani o'qib bo'lib bu haqida izlanib ko'rmoqchisiz, men esa shashtingizni so'ndirmay :)


Shuningdek cdecl ni quyidagicha qilib funksiya e'lon qilayotganda aniq qilib yozib qo'ysak bo'ladi:

#ifdef __GNUC__
#define __cdecl __attribute__((__cdecl__))
#endif
int __cdecl add(int a, int b, int c) {
       int res = a + b + c + 10;
       return res;
}

Bu yerdagi define linux uchun, __cdecl asosan windows development da ishlatiladi, linux dagi GCC versiyalari buni support qilmasliklari mumkin.

stdcall

Windowssevarlar uchun yana bir murojaat uslubi. WinAPI bilan ishlab ko'rganlarga menimcha quyidagi sintaksis tanish:

LRESULT CALLBACK MyWndProc(HWND hwnd, UINT msg,
    WPARAM wParam, LPARAM lParam);

Aynan shu joyidagi CALLBACK aslida makro bo'lib, __stdcall ga define qilingan. stcall ham cdecl bilan deyarli bir-xil, faqat farqi - argumentlarni chaqiruvchi emas balki chaqiriluvchi stekdan tozalaydi. C kodi:

#ifdef __GNUC__
#define __stdcall __attribute__((stdcall))   
#endif
int __stdcall add(int a, int b, int c) {
       int res = a + b + c + 10;
       return res;
}

Assemblerdagi ko'rinishi:

fastcall

Menimcha C++ builder'dan foydalanib ko'rganlarga bu keyword tanish. Microsoft fastcall(yoki __msfastcall) dastlabki 2 ta argumentni ECX va EDX registrlariga yuklaydi. Qolganlari o'ngdan chapga qarab stekka kiritiladi. x64 uchun kompilyatsiya qilinayotganda kompilyator bu keyword ni shunchaki tashlab yubora qoladi va Microsoft x64 murojaat uslubidan foydalanadi. Boshqa kompilyatorlar, masalan GCC, Clang kabilar ham fastcall ga o'xshash murojaat uslublari bor bo'lib, ular 2 tadan ko'p argumentlarni registrlarda berishi ham mumkin. Chaqiriluvchi stekda kelgan argumentlarni tozalashi kerak.

#ifdef __GNUC__
#define __fastcall __attribute__((fastcall))
#endif
int __fastcall add(int a, int b, int c) {
       int res = a + b + c + 10;
       return res;
}

Assemblerga o'girilgandagi natija:

thiscall

Nomidan taxmin qilgan bo'lsangiz kerak, ushbu murojaat uslubi static bo'lmagan metodlar uchun ishlatiladi. Metodlarda obyektga ko'rsatkich argument sifatida berilmasa-da, lekin orqa fonda aslida 1-argument sifatida obyektga ko'rsatkich keladi. Ushbu murojaat uslubi kompilyatorlarda har-xil ishlab chiqilgan. Masalan GCC da xudd cdecl dek ishlaydi: chaqiruvchi stekni argumentlardan tozalaydi, argumentlar stekka o'ngdan-chapga qarab kiritiladi. Farqi - chapdan kelganda dastlabki argument obyektga ko'rsatkich bo'ladi, ya'ni u oxirgi bo'lib stekka kiritiladi.

Microsoft Visual C++ kompilyatorida esa ko'rsatkich ECX registrida uzatiladi, stekni esa chaqiriluvchi tozalaydi.

C++ da quyidagi kodni analiz qilib ko'raylik:

class Test {
       int d = 5;
public:
       int add(int a, int b, int c) {
               int res = a + b + c;
               res += d;
               return d;
       }      
};     
int caller() {
       int a = 1;
       int b = 2;
       int c = 3;
       Test obj;
       return obj.add(a,b,c) + 10;
}

Assemblerdagi natijasi:

Ko'rib chiqadiganimiz shu asosiy 4 xil murojaat uslublari edi. Albatta boshqacha uslublar ham bor.

Muhokama uchun telegramdagi @cppuz guruhiga yozishingiz mumkin.

Foydalanilgan manbalar:

  1. https://en.wikipedia.org/wiki/X86_calling_conventions


Report Page