assembly - कॉलस्टैक वास्तव में कैसे काम करता है?




cpu callstack (4)

क्योंकि जाहिर है, हमें जिस चीज की आवश्यकता है वह ए और बी के साथ काम करना है, लेकिन इसका मतलब यह होगा कि ओएस / सीपीयू (?) को डी और सी को पहले ए और बी पर वापस जाने के लिए पॉप आउट करना होगा। लेकिन फिर यह पैर में खुद को गोली मार देगा क्योंकि इसे अगली पंक्ति में सी और डी की जरूरत है।

संक्षेप में:

तर्कों को पॉप करने की कोई आवश्यकता नहीं है। काम करने के लिए कॉलर foo द्वारा पारित तर्क doSomething और स्थानीय चरों को करने में doSomething कुछ मूल सूचक से ऑफसेट के रूप में संदर्भित किया जा सकता है
इसलिए,

  • जब कोई फ़ंक्शन कॉल किया जाता है, तो फ़ंक्शन के तर्क स्टैक पर पुश किए जाते हैं। इन तर्कों को आधार सूचक द्वारा आगे संदर्भित किया जाता है।
  • जब फ़ंक्शन अपने कॉलर पर वापस आता है, तो रिटर्निंग फ़ंक्शन के तर्क LIFO विधि का उपयोग करके स्टैक से पीओपी किए जाते हैं।

विस्तार से:

नियम यह है कि प्रत्येक फंक्शन कॉल स्टैक फ्रेम के निर्माण में परिणाम देता है (न्यूनतम के साथ पते पर वापस जाने के साथ)। इसलिए, अगर funcA कॉल funcB और funcB कॉल funcC , तो तीन स्टैक फ्रेम एक दूसरे के शीर्ष पर सेट होते हैं। जब कोई फ़ंक्शन लौटाता है, तो उसका फ्रेम अमान्य हो जाता है । एक अच्छी तरह से व्यवहार किया गया कार्य केवल अपने स्वयं के ढेर फ्रेम पर कार्य करता है और किसी अन्य पर उल्लंघन नहीं करता है। दूसरे शब्दों में, पीओपीइंग शीर्ष पर स्टैक फ्रेम (फ़ंक्शन से लौटने पर) पर किया जाता है।

आपके प्रश्न में ढेर कॉलर foo द्वारा स्थापित किया गया है। जब doSomething और doAnotherThing हैं doAnotherThing कहा जाता है, तो वे अपना खुद का ढेर सेट करते हैं। यह आंकड़ा आपको यह समझने में मदद कर सकता है:

ध्यान दें कि, तर्कों तक पहुंचने के लिए, फ़ंक्शन बॉडी को उस स्थान से नीचे (उच्च पते) को पार करना होगा जहां रिटर्न पता संग्रहीत किया जाता है, और स्थानीय चरों तक पहुंचने के लिए, फ़ंक्शन बॉडी को स्टैक को पार करना होगा (निचले पते ) उस स्थान के सापेक्ष जहां रिटर्न पता संग्रहीत किया जाता है । वास्तव में, फ़ंक्शन के लिए सामान्य कंपाइलर जेनरेट कोड ठीक से ऐसा करेगा। कंपाइलर इस (बेस पॉइंटर) के लिए ईबीपी नामक एक रजिस्टर को समर्पित करता है। इसके लिए एक और नाम फ्रेम सूचक है। आमतौर पर कंपाइलर, फ़ंक्शन बॉडी के लिए पहली चीज़ के रूप में, वर्तमान ईबीपी मान को स्टैक पर धक्का देता है और ईबीपी को वर्तमान ईएसपी पर सेट करता है। इसका अर्थ यह है कि, एक बार यह किया जाता है, फ़ंक्शन कोड के किसी भी हिस्से में, तर्क 1 ईबीपी + 8 दूर होता है (कॉलर के प्रत्येक ईबीपी और वापसी पते के लिए 4 बाइट), तर्क 2 ईबीपी +12 (दशमलव) दूर है, स्थानीय चर ईबीपी -4 एन दूर हैं।

.
.
.
[ebp - 4]  (1st local variable)
[ebp]      (old ebp value)
[ebp + 4]  (return address)
[ebp + 8]  (1st argument)
[ebp + 12] (2nd argument)
[ebp + 16] (3rd function argument) 

फ़ंक्शन के स्टैक फ्रेम के निर्माण के लिए निम्न सी कोड पर नज़र डालें:

void MyFunction(int x, int y, int z)
{
     int a, int b, int c;
     ...
}

जब कॉलर इसे कॉल करें

MyFunction(10, 5, 2);  

निम्नलिखित कोड उत्पन्न किया जाएगा

^
| call _MyFunction  ; Equivalent to: 
|                   ; push eip + 2
|                   ; jmp _MyFunction
| push 2            ; Push first argument  
| push 5            ; Push second argument  
| push 10           ; Push third argument  

और फ़ंक्शन के लिए असेंबली कोड (लौटने से पहले कैली द्वारा सेट अप किया जाएगा)

^
| _MyFunction:
|  sub esp, 12 ; sizeof(a) + sizeof(b) + sizeof(c)
|  ;x = [ebp + 8], y = [ebp + 12], z = [ebp + 16]
|  ;a = [ebp - 4] = [esp + 8], b = [ebp - 8] = [esp + 4], c = [ebp - 12] =   [esp]
|  mov ebp, esp
|  push ebp

संदर्भ:

मैं प्रोग्रामिंग भाषाओं के निम्न स्तर के संचालन कैसे काम करता हूं और विशेष रूप से वे ओएस / सीपीयू के साथ कैसे बातचीत करते हैं, इस बारे में गहरी समझ प्राप्त करने की कोशिश कर रहा हूं। मैंने शायद स्टैक ओवरफ़्लो पर हर स्टैक / ढेर से संबंधित थ्रेड में हर उत्तर पढ़ा है, और वे सभी शानदार हैं। लेकिन अभी भी एक बात है कि मैं अभी तक पूरी तरह से समझ में नहीं आया।

छद्म कोड में इस फ़ंक्शन पर विचार करें जो मान्य जंग कोड होना चाहिए ;-)

fn foo() {
    let a = 1;
    let b = 2;
    let c = 3;
    let d = 4;

    // line X

    doSomething(a, b);
    doAnotherThing(c, d);
}

इस तरह मैं लाइन एक्स की तरह दिखने के लिए ढेर मानता हूं:

Stack

a +-------------+
  | 1           | 
b +-------------+     
  | 2           |  
c +-------------+
  | 3           | 
d +-------------+     
  | 4           | 
  +-------------+ 

अब, मैंने जो कुछ भी पढ़ा है, उसके बारे में मैंने पढ़ा है कि यह लिफ़ो नियमों का सख्ती से पालन करता है (आखिरी बार, पहले बाहर)। .NET, जावा या किसी अन्य प्रोग्रामिंग भाषा में एक स्टैक डेटाटाइप की तरह।

लेकिन अगर ऐसा है, तो लाइन एक्स के बाद क्या होता है? क्योंकि जाहिर है, हमें जिस चीज की आवश्यकता है वह a और b साथ काम करना a , लेकिन इसका मतलब यह होगा कि ओएस / सीपीयू (?) को d और c पहले a और b वापस जाने के लिए पॉप आउट करना होगा। लेकिन फिर यह पैर में खुद को गोली मार देगा, क्योंकि इसे अगली पंक्ति में c और d जरूरत है।

तो, मुझे आश्चर्य है कि दृश्यों के पीछे वास्तव में क्या होता है?

एक और संबंधित सवाल। विचार करें कि हम इस तरह के अन्य कार्यों में से एक का संदर्भ पास करते हैं:

fn foo() {
    let a = 1;
    let b = 2;
    let c = 3;
    let d = 4;

    // line X

    doSomething(&a, &b);
    doAnotherThing(c, d);
}

मैं चीजों को कैसे समझता हूं, इसका मतलब यह होगा कि कुछ में पैरामीटर अनिवार्य रूप से उसी स्मृति पते को इंगित कर रहे हैं जैसे doSomething और foo । लेकिन फिर फिर इसका मतलब यह है कि जब तक हम a और b हो जाते हैं तब तक कोई ढेर नहीं होता है।

उन दो मामलों में मुझे लगता है कि मैंने पूरी तरह से समझ नहीं लिया है कि स्टैक कैसे काम करता है और यह एलआईएफओ नियमों का कड़ाई से पालन कैसे करता है।


कॉल स्टैक को फ्रेम स्टैक भी कहा जा सकता है।
एलआईएफओ सिद्धांत के बाद जो चीजें हैं, वे स्थानीय चर नहीं हैं बल्कि कार्यों के पूरे ढेर फ्रेम ("कॉल") कहा जाता है । स्थानीय चर को क्रमशः तथाकथित फ़ंक्शन प्रस्तावना और epilogue में उन फ़्रेम के साथ धक्का दिया जाता है।

फ्रेम के अंदर चर के क्रम पूरी तरह से निर्दिष्ट नहीं है; कंपाइलर्स अपने संरेखण को अनुकूलित करने के लिए उपयुक्त रूप से एक फ्रेम के अंदर स्थानीय चर की स्थिति को "पुन: व्यवस्थित करें" ताकि प्रोसेसर जितनी जल्दी हो सके उन्हें ला सके। महत्वपूर्ण तथ्य यह है कि कुछ निश्चित पते के सापेक्ष चर के ऑफसेट फ्रेम के पूरे जीवनकाल में स्थिर है - इसलिए फ्रेम के पते को कहने के लिए यह एक एंकर पता लेने के लिए पर्याप्त है, और उस पते के ऑफसेट के साथ काम करता है चर ऐसा एंकर पता वास्तव में तथाकथित आधार या फ्रेम सूचक में निहित है जो ईबीपी रजिस्टर में संग्रहीत होता है। दूसरी तरफ, ऑफ़सेट, संकलित समय पर स्पष्ट रूप से ज्ञात हैं और इसलिए मशीन कोड में हार्डकोड किए गए हैं।

Wikipedia से यह ग्राफिक दिखाता है कि ठेठ कॉल स्टैक को 1 :

वेरिएबल का ऑफसेट जोड़ें जिसे हम फ्रेम पॉइंटर में निहित पते तक पहुंचना चाहते हैं और हमें हमारे चर का पता मिलता है। तो जल्द ही कहा, कोड बस आधार सूचक से निरंतर संकलन-समय ऑफसेट के माध्यम से सीधे उन्हें पहुंचाता है; यह सरल सूचक अंकगणित है।

उदाहरण

#include <iostream>

int main()
{
    char c = std::cin.get();
    std::cout << c;
}

gcc.godbolt.org हमें देता है

main:
    pushq   %rbp
    movq    %rsp, %rbp
    subq    $16, %rsp

    movl    std::cin, %edi
    call    std::basic_istream<char, std::char_traits<char> >::get()
    movb    %al, -1(%rbp)
    movsbl  -1(%rbp), %eax
    movl    %eax, %esi
    movl    std::cout, %edi
    call    [... the insertion operator for char, long thing... ]

    movl    $0, %eax
    leave
    ret

main लिए .. मैंने कोड को तीन उपखंडों में विभाजित किया। फ़ंक्शन प्रस्तावना में पहले तीन ऑपरेशन होते हैं:

  • आधार सूचक स्टैक पर धकेल दिया जाता है।
  • स्टैक पॉइंटर बेस पॉइंटर में सहेजा जाता है
  • स्थानीय चर के लिए जगह बनाने के लिए स्टैक पॉइंटर घटाया जाता है।

फिर cin को ईडीआई रजिस्टर 2 में ले जाया जाता है और उसे कॉल किया जाता है; वापसी मूल्य ईएक्स में है।

अब तक सब ठीक है। अब दिलचस्प बात होती है:

8-बिट रजिस्टर एएल द्वारा नामित ईएक्स का निम्न-आदेश बाइट बेस पॉइंटर के बाद बाइट दाएं में लिया जाता है और संग्रहीत किया जाता है : यह -1(%rbp) , आधार सूचक का ऑफसेट -1यह बाइट हमारे चर c । ऑफ़सेट नकारात्मक है क्योंकि स्टैक x86 पर नीचे की ओर बढ़ता है। अगला ऑपरेशन ईएक्स में c स्टोर करता है: ईएक्स को ईएसआई में ले जाया जाता है, cout ईडीआई में ले जाया जाता है और फिर सम्मिलन ऑपरेटर को cout और c तर्क कहा जाता है।

आखिरकार,

  • main का वापसी मूल्य ईएक्स में संग्रहीत है: 0. यह अंतर्निहित return स्टेटमेंट के कारण है। आप xorl rax rax बजाय xorl rax rax भी देख सकते हैं।
  • छोड़ें और कॉल साइट पर वापस आएं। leave इस उपन्यास और निस्संदेह संक्षेप में है
    • बेस पॉइंटर के साथ स्टैक पॉइंटर को बदलता है और
    • आधार सूचक पॉप्स।

इस ऑपरेशन के बाद और फिर से प्रदर्शन किया गया है, फ्रेम प्रभावी रूप से पॉप किया गया है, हालांकि कॉलर को अभी भी तर्कों को साफ करना है क्योंकि हम सीडीईसी कॉलिंग सम्मेलन का उपयोग कर रहे हैं। अन्य सम्मेलनों, उदाहरण के लिए stdcall, कोली को साफ करने की आवश्यकता होती है, उदाहरण के लिए बाइट्स की मात्रा को पार करने के लिए।

फ्रेम सूचक चूक

बेस / फ्रेम पॉइंटर से ऑफ़सेट का उपयोग करना भी नहीं बल्कि स्टैक पॉइंटर (ईएसबी) से भी संभव है। यह ईबीपी-रजिस्टर बनाता है जो अन्यथा मनमाने ढंग से उपयोग के लिए फ्रेम सूचक मूल्य उपलब्ध होगा - लेकिन यह कुछ मशीनों पर असंभव डिबगिंग कर सकता है, और कुछ कार्यों के लिए निहित रूप से बंद कर दिया जाएगा । X86 सहित कुछ रजिस्टरों के साथ प्रोसेसर के लिए संकलन करते समय यह विशेष रूप से उपयोगी होता है।

इस अनुकूलन को एफपीओ (फ्रेम सूचक चूक) के रूप में जाना जाता है और जीसीसी में -fomit-frame-pointer द्वारा सेट किया गया है और - -Oy में; ध्यान दें कि यह प्रत्येक ऑप्टिमाइज़ेशन स्तर> 0 द्वारा स्पष्ट रूप से ट्रिगर किया गया है और यदि केवल डिबगिंग अभी भी संभव है, क्योंकि उसके पास इसके अलावा कोई लागत नहीं है। अधिक जानकारी के लिए here और here देखें।

1 टिप्पणियों में बताया गया है कि फ्रेम सूचक संभावित रूप से वापसी पते के बाद पते को इंगित करने के लिए है।

2 ध्यान दें कि आर के साथ शुरू होने वाले पंजीयक ई-ईएक्स के साथ शुरू होने वाले 64-बिट समकक्ष हैं जो आरएक्स के चार निम्न-आदेश बाइट्स को निर्दिष्ट करते हैं। मैंने स्पष्टता के लिए 32-बिट रजिस्टरों के नामों का उपयोग किया।


दूसरों की तरह ध्यान दिया गया, जब तक वे दायरे से बाहर नहीं जाते, तब तक पैरामीटर पॉप करने की कोई आवश्यकता नहीं होती है।

मैं निक Parlante द्वारा "पॉइंटर्स और मेमोरी" से कुछ उदाहरण पेस्ट करेंगे। मुझे लगता है कि परिस्थिति की तुलना में स्थिति थोड़ा अधिक सरल है।

यहां कोड है:

void X() 
{
  int a = 1;
  int b = 2;

  // T1
  Y(a);

  // T3
  Y(b);

  // T5
}

void Y(int p) 
{
  int q;
  q = p + 2;
  // T2 (first time through), T4 (second time through)
}

समय T1, T2, etc में अंक। कोड में चिह्नित हैं और उस समय स्मृति की स्थिति ड्राइंग में दिखाया गया है:


यहां पहले से ही कुछ वाकई अच्छे जवाब हैं। हालांकि, अगर आप अभी भी ढेर के लिफ़ो व्यवहार के बारे में चिंतित हैं, तो इसे चर के ढेर के बजाय फ्रेम के ढेर के रूप में सोचें। मेरा सुझाव देने का मतलब यह है कि, हालांकि एक फ़ंक्शन वेरिएबल्स तक पहुंच सकता है जो स्टैक के शीर्ष पर नहीं हैं, फिर भी यह केवल स्टैक के शीर्ष पर आइटम पर काम कर रहा है: एक स्टैक फ्रेम।

बेशक, इसमें अपवाद हैं। संपूर्ण कॉल श्रृंखला के स्थानीय चर अभी भी आवंटित और उपलब्ध हैं। लेकिन उन्हें सीधे एक्सेस नहीं किया जाएगा। इसके बजाए, वे संदर्भ द्वारा पारित किए जाते हैं (या सूचक द्वारा, जो वास्तव में केवल अलग-अलग अर्थ है)। इस मामले में एक स्टैक फ्रेम के एक स्थानीय चर को और भी नीचे पहुंचा जा सकता है। लेकिन इस मामले में, वर्तमान में निष्पादन समारोह अभी भी केवल अपने स्थानीय डेटा पर काम कर रहा है। यह अपने स्वयं के ढेर फ्रेम में संग्रहीत संदर्भ तक पहुंच रहा है, जो ढेर पर, स्थिर स्मृति में, या ढेर के नीचे कुछ का संदर्भ हो सकता है।

यह स्टैक अबास्ट्रक्शन का हिस्सा है जो किसी भी क्रम में कॉल करने योग्य बनाता है, और रिकर्सन की अनुमति देता है। शीर्ष स्टैक फ्रेम एकमात्र ऑब्जेक्ट है जिसे सीधे कोड द्वारा एक्सेस किया जाता है। कुछ भी अप्रत्यक्ष रूप से उपयोग किया जाता है (एक सूचक के माध्यम से जो शीर्ष ढेर फ्रेम में रहता है)।

यह आपके छोटे कार्यक्रम की असेंबली को देखने के लिए निर्देशक हो सकता है, खासकर यदि आप अनुकूलन के बिना संकलित करते हैं। मुझे लगता है कि आप देखेंगे कि आपके फ़ंक्शन में सभी मेमोरी एक्सेस स्टैक फ्रेम पॉइंटर से ऑफ़सेट के माध्यम से होती है, जो कि फ़ंक्शन के लिए कोड को संकलक द्वारा लिखा जाएगा। संदर्भ द्वारा पास के मामले में, आप एक पॉइंटर के माध्यम से अप्रत्यक्ष मेमोरी एक्सेस निर्देश देखेंगे जो स्टैक फ्रेम पॉइंटर से कुछ ऑफसेट पर संग्रहीत है।







calling-convention