callstack ทำงานอย่างไร?


103

ฉันพยายามทำความเข้าใจให้ลึกซึ้งยิ่งขึ้นว่าการทำงานของภาษาโปรแกรมระดับต่ำทำงานอย่างไรและโดยเฉพาะอย่างยิ่งวิธีที่พวกเขาโต้ตอบกับ OS / CPU ฉันอาจอ่านทุกคำตอบในทุกเธรดที่เกี่ยวข้องกับสแต็ก / ฮีปที่นี่ใน Stack Overflow และพวกเขาทั้งหมดยอดเยี่ยม แต่ยังมีสิ่งหนึ่งที่ฉันยังไม่เข้าใจ

พิจารณาฟังก์ชันนี้ในรหัสหลอกซึ่งมีแนวโน้มที่จะเป็นรหัสสนิมที่ถูกต้อง ;-)

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

    // line X

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

นี่คือวิธีที่ฉันถือว่าสแต็กมีลักษณะเป็นบรรทัด X:

Stack

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

ตอนนี้ทุกสิ่งที่ฉันได้อ่านเกี่ยวกับวิธีการทำงานของสแต็กคือการปฏิบัติตามกฎ LIFO อย่างเคร่งครัด (เข้าก่อนออกก่อน) เช่นเดียวกับประเภทข้อมูลสแต็กใน. NET, Java หรือภาษาโปรแกรมอื่น ๆ

แต่ถ้าเป็นอย่างนั้นจะเกิดอะไรขึ้นหลังจากบรรทัด X? เพราะเห็นได้ชัดว่าจำเป็นที่จะต้องเป็นสิ่งที่เราต่อไปคือการทำงานร่วมกับaและbแต่ที่จะหมายความว่า OS / CPU (?) จะปรากฏออกมา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เป็นหลักชี้ไปที่อยู่หน่วยความจำเช่นเดียวกันaและในb fooแต่แล้วอีกครั้งนั่นหมายความว่าจะไม่มีป๊อปอัพสแต็กจนกว่าเราจะไปถึงaและbเกิดขึ้น

ทั้งสองกรณีทำให้ฉันคิดว่าฉันยังไม่เข้าใจอย่างถ่องแท้ว่าสแต็กทำงานอย่างไรและปฏิบัติตามกฎLIFOอย่างเคร่งครัดอย่างไร


14
LIFO มีความสำคัญสำหรับการจองพื้นที่บนสแต็กเท่านั้น คุณสามารถเข้าถึงตัวแปรใด ๆ ที่เป็นอย่างน้อยในสแต็กเฟรมของคุณ (ประกาศไว้ในฟังก์ชัน) แม้ว่าจะอยู่ภายใต้ตัวแปรอื่น ๆ มากมาย
ก็ตาม

2
กล่าวอีกนัยหนึ่งLIFOหมายความว่าคุณสามารถเพิ่มหรือลบองค์ประกอบที่ส่วนท้ายของสแต็กเท่านั้นและคุณสามารถอ่าน / เปลี่ยนแปลงองค์ประกอบใด ๆ ได้ตลอดเวลา
HolyBlackCat

12
ทำไมคุณไม่แยกส่วนฟังก์ชันง่ายๆหลังจากคอมไพล์ด้วย -O0 และดูคำแนะนำที่สร้างขึ้น มันสวยดีให้คำแนะนำ ;-) คุณจะพบว่าโค้ดใช้ประโยชน์จากส่วน R ของ RAM ได้เป็นอย่างดี เข้าถึงที่อยู่ได้โดยตรงตามต้องการ คุณสามารถคิดว่าชื่อตัวแปรเป็นค่าชดเชยของการลงทะเบียนที่อยู่ (ตัวชี้สแต็ก) ดังที่คนอื่น ๆ กล่าวว่าสแต็กเป็นเพียง LIFO เมื่อเทียบกับการซ้อน (เหมาะสำหรับการเรียกซ้ำ ฯลฯ ) ไม่ใช่ LIFO ที่เกี่ยวกับการเข้าถึง การเข้าถึงเป็นแบบสุ่ม
Peter - คืนสถานะ Monica

6
คุณสามารถสร้างโครงสร้างข้อมูลสแตกของคุณเองโดยใช้อาร์เรย์และเพียงแค่จัดเก็บดัชนีขององค์ประกอบด้านบนเพิ่มขึ้นเมื่อคุณผลักดันลดลงเมื่อคุณป๊อปอัป หากคุณทำเช่นนี้คุณจะยังคงสามารถเข้าถึงองค์ประกอบใด ๆ ในอาร์เรย์ได้ตลอดเวลาโดยไม่ต้องผลักดันหรือโผล่ออกมาเช่นเดียวกับที่คุณสามารถทำได้กับอาร์เรย์ สิ่งเดียวกันกำลังเกิดขึ้นที่นี่
Crowman

3
โดยพื้นฐานแล้วการตั้งชื่อ stack / heap เป็นสิ่งที่ไม่ดี พวกเขามีความคล้ายคลึงกันเล็กน้อยในการสแต็กและฮีปในคำศัพท์ของโครงสร้างข้อมูลดังนั้นการเรียกมันแบบเดียวกันจึงทำให้สับสนมาก
Siyuan Ren

คำตอบ:


117

call stack อาจเรียกได้ว่าเป็น frame stack
สิ่งที่ซ้อนกันหลังจากหลักการ LIFO ไม่ใช่ตัวแปรภายใน แต่เป็นสแต็กเฟรมทั้งหมด ("การโทร") ของฟังก์ชันที่ถูกเรียกใช้ ตัวแปรโลคัลจะถูกผลักและโผล่ร่วมกับเฟรมเหล่านั้นในฟังก์ชันอารัมภบทและบทส่งท้ายที่เรียกว่าตามลำดับ

ภายในกรอบลำดับของตัวแปรไม่ได้ระบุไว้อย่างสมบูรณ์ คอมไพเลอร์"จัดลำดับใหม่" ตำแหน่งของตัวแปรโลคัลภายในเฟรมอย่างเหมาะสมเพื่อปรับการจัดตำแหน่งให้เหมาะสมเพื่อให้โปรเซสเซอร์สามารถดึงข้อมูลได้โดยเร็วที่สุด ความจริงที่สำคัญก็คือค่าชดเชยของตัวแปรที่สัมพันธ์กับที่อยู่คงที่บางส่วนจะคงที่ตลอดอายุการใช้งานของเฟรมดังนั้นจึงเพียงพอที่จะใช้ที่อยู่จุดยึดพูดที่อยู่ของเฟรมเองและทำงานกับออฟเซ็ตของที่อยู่นั้นเพื่อ ตัวแปร ที่อยู่จุดยึดดังกล่าวมีอยู่จริงในตัวชี้ฐานหรือกรอบซึ่งถูกเก็บไว้ในทะเบียน EBP ในทางกลับกันการชดเชยเป็นที่ทราบกันอย่างชัดเจนในเวลาคอมไพล์ดังนั้นจึงมีการเข้ารหัสฮาร์ดโค้ดลงในรหัสเครื่อง

กราฟิกจากWikipedia นี้แสดงให้เห็นว่า call stack ทั่วไปมีโครงสร้างอย่างไร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จะถูกย้ายไปที่ EDI register 2และgetเรียกว่า; ค่าส่งคืนอยู่ใน EAX

จนถึงตอนนี้ดีมาก ตอนนี้สิ่งที่น่าสนใจเกิดขึ้น:

ไบต์ต่ำใบสั่งของ EAX กำหนดโดย 8 บิตลงทะเบียน AL ถูกถ่ายและเก็บไว้ในไบต์ขวาหลังจากที่ตัวชี้ฐาน : นั่นคือการชดเชยของตัวชี้ฐานเป็น-1(%rbp) ไบต์นี้เป็นตัวแปรของเรา ค่าชดเชยเป็นลบเนื่องจากสแต็กขยายตัวลงบน x86 ที่เก็บการดำเนินการถัดไปใน EAX: EAX ถูกย้ายไปที่ ESI ย้ายไปที่ EDI จากนั้นตัวดำเนินการแทรกจะถูกเรียกด้วยและเป็นอาร์กิวเมนต์-1cccoutcoutc

สุดท้าย

  • ค่าที่ส่งคืนของmainถูกเก็บไว้ใน EAX: 0 นั่นเป็นเพราะreturnคำสั่งโดยนัย นอกจากนี้คุณยังอาจเห็นแทนxorl rax raxmovl
  • ออกและกลับไปที่ไซต์การโทร leaveกำลังย่อบทส่งท้ายนี้และโดยปริยาย
    • แทนที่ตัวชี้สแต็กด้วยตัวชี้ฐานและ
    • แสดงตัวชี้ฐาน

หลังจากการดำเนินการนี้และretดำเนินการไปแล้วเฟรมจะถูก popping อย่างมีประสิทธิภาพแม้ว่าผู้เรียกจะยังคงต้องล้างอาร์กิวเมนต์ในขณะที่เราใช้หลักการเรียก cdecl การประชุมอื่น ๆ เช่น stdcall ต้อง callee retเพื่อเป็นระเบียบเรียบร้อยขึ้นเช่นโดยการส่งผ่านปริมาณของไบต์ที่

ตัวชี้กรอบการละเว้น

นอกจากนี้ยังเป็นไปได้ที่จะไม่ใช้การชดเชยจากตัวชี้ฐาน / เฟรม แต่ใช้จากตัวชี้สแต็ก (ESB) แทน นี้จะทำให้ EBP ลงทะเบียนที่อาจจะมีค่าตัวชี้กรอบที่มีอยู่สำหรับการใช้งานโดยพลการ - แต่ก็สามารถทำให้การแก้จุดบกพร่องเป็นไปไม่ได้ในเครื่องที่บางและจะหันไปโดยปริยายปิดสำหรับฟังก์ชั่นบางอย่าง มีประโยชน์อย่างยิ่งเมื่อคอมไพล์สำหรับโปรเซสเซอร์ที่มีรีจิสเตอร์เพียงไม่กี่ตัวรวมถึง x86

การเพิ่มประสิทธิภาพนี้เรียกว่า FPO (การละเว้นตัวชี้เฟรม) และกำหนดโดย-fomit-frame-pointerใน GCC และ-Oyในเสียงดัง โปรดทราบว่าทุกระดับการเพิ่มประสิทธิภาพ> 0 จะถูกเรียกใช้โดยปริยายหากและเฉพาะในกรณีที่การดีบักยังคงเป็นไปได้เนื่องจากไม่มีค่าใช้จ่ายใด ๆ นอกเหนือจากนั้น สำหรับข้อมูลเพิ่มเติมโปรดดูที่นี่และที่นี่


1ตามที่ระบุไว้ในความคิดเห็นตัวชี้กรอบน่าจะหมายถึงชี้ไปที่ที่อยู่หลังที่อยู่สำหรับส่งคืน

2โปรดสังเกตว่าการลงทะเบียนที่ขึ้นต้นด้วย R เป็นคู่แบบ 64 บิตของรายการที่ขึ้นต้นด้วย EAX กำหนด RAX สี่ไบต์ลำดับต่ำ ฉันใช้ชื่อของรีจิสเตอร์ 32 บิตเพื่อความชัดเจน


1
คำตอบที่ดี สิ่งที่เกี่ยวกับการระบุข้อมูลโดยการชดเชยเป็นบิตที่ขาดหายไปสำหรับฉัน :)
Christoph

1
ฉันคิดว่ามีข้อผิดพลาดเล็กน้อยในการวาด ตัวชี้กรอบจะต้องอยู่อีกด้านหนึ่งของที่อยู่สำหรับส่งคืน โดยปกติการออกจากฟังก์ชันจะทำได้ดังนี้: ย้ายตัวชี้สแต็กไปยังตัวชี้เฟรม, ดึงตัวชี้เฟรมของผู้โทรออกจากสแต็ก, ส่งคืน (เช่นเปิดตัวนับโปรแกรมผู้โทร / ตัวชี้คำสั่งจากสแต็ก)
kasperd

kasperd ถูกต้อง คุณไม่ได้ใช้ตัวชี้เฟรมเลย (การเพิ่มประสิทธิภาพที่ถูกต้องและโดยเฉพาะอย่างยิ่งสำหรับสถาปัตยกรรมที่ได้รับการลงทะเบียนเช่น x86 มีประโยชน์อย่างยิ่ง) หรือคุณใช้มันและเก็บอันก่อนหน้านี้ไว้ในสแต็ก - โดยปกติจะอยู่หลังที่อยู่สำหรับส่งคืน วิธีการตั้งค่าและลบเฟรมขึ้นอยู่กับสถาปัตยกรรมและ ABI เป็นอย่างมาก มีสถาปัตยกรรมไม่กี่แห่ง (สวัสดี Itanium) ที่สิ่งทั้งหมดคือ .. น่าสนใจยิ่งขึ้น (และมีหลายสิ่งเช่นรายการอาร์กิวเมนต์ขนาดตัวแปร!)
Voo

3
@ คริสตอฟฉันคิดว่าคุณกำลังเข้าใกล้สิ่งนี้จากมุมมองของแนวคิด นี่คือความคิดเห็นที่หวังว่าจะทำให้สิ่งนี้ชัดเจนขึ้น - RTS หรือ RunTime Stack นั้นแตกต่างจากสแต็กอื่น ๆ เล็กน้อยเนื่องจากเป็น "สแต็คสกปรก" - ไม่มีอะไรที่ป้องกันไม่ให้คุณมองไปที่ค่าที่ไม่ใช่ ' เสื้อด้านบน สังเกตว่าในแผนภาพ "ที่อยู่ที่ส่งคืน" สำหรับเมธอดสีเขียว - ซึ่งจำเป็นสำหรับเมธอดสีน้ำเงิน! อยู่หลังพารามิเตอร์ เมธอดสีน้ำเงินได้รับค่าส่งคืนอย่างไรหลังจากที่เฟรมก่อนหน้านี้โผล่ขึ้นมา มันเป็นกองขยะดังนั้นมันจึงสามารถเอื้อมมือเข้าไปคว้ามันได้
Riking

1
ไม่จำเป็นต้องใช้ตัวชี้เฟรมเนื่องจากสามารถใช้ออฟเซ็ตจากตัวชี้สแต็กแทนได้ตลอดเวลา โดยค่าเริ่มต้น GCC กำหนดเป้าหมายสถาปัตยกรรม x64 จะใช้ตัวชี้สแต็กและมีอิสระrbpในการทำงานอื่น ๆ
Siyuan Ren

27

เพราะเห็นได้ชัดว่าสิ่งต่อไปที่เราต้องการคือการทำงานกับ a และ b แต่นั่นหมายความว่า OS / CPU (?) จะต้องแสดง d และ c ก่อนเพื่อกลับไปที่ a และ b แต่จากนั้นมันจะยิงเองที่เท้าเพราะมันต้องการ c และ d ในบรรทัดถัดไป

ในระยะสั้น:

ไม่จำเป็นต้องปรากฏข้อโต้แย้ง ข้อโต้แย้งผ่านไปได้โดยโทรfooไปยังฟังก์ชันdoSomethingและตัวแปรในท้องถิ่นdoSomething ทั้งหมดจะสามารถอ้างอิงเป็นชดเชยจากตัวชี้ฐาน
ดังนั้น,

  • เมื่อมีการเรียกใช้ฟังก์ชันอาร์กิวเมนต์ของฟังก์ชันจะถูกผลักลงบนสแต็ก อาร์กิวเมนต์เหล่านี้อ้างอิงเพิ่มเติมโดยตัวชี้ฐาน
  • เมื่อฟังก์ชันกลับไปยังผู้เรียกอาร์กิวเมนต์ของฟังก์ชันที่ส่งคืนจะถูก POPed จากสแตกโดยใช้วิธี LIFO

ในรายละเอียด:

กฎคือการเรียกใช้ฟังก์ชันแต่ละครั้งจะส่งผลให้มีการสร้างสแต็กเฟรม (โดยขั้นต่ำคือแอดเดรสที่จะกลับไป) ดังนั้นหากมีการfuncAโทรfuncBและการfuncBโทรfuncCจะมีการตั้งค่าสแต็กเฟรมสามเฟรมซ้อนทับกัน เมื่อกลับมาทำงานกรอบของมันจะไม่ถูกต้อง ฟังก์ชั่นที่มีพฤติกรรมดีจะทำหน้าที่เฉพาะในสแต็กเฟรมของตัวเองและไม่ล่วงเกินของอื่น กล่าวอีกนัยหนึ่งคือ POPing จะดำเนินการกับสแต็กเฟรมด้านบน (เมื่อกลับจากฟังก์ชัน)

ใส่คำอธิบายภาพที่นี่

fooสแต็คในคำถามของคุณคือการติดตั้งโดยผู เมื่อใดdoSomethingและdoAnotherThingถูกเรียกใช้พวกเขาจะตั้งค่าสแต็กของตนเอง ตัวเลขอาจช่วยให้คุณเข้าใจสิ่งนี้:

ใส่คำอธิบายภาพที่นี่

โปรดทราบว่าในการเข้าถึงอาร์กิวเมนต์เนื้อหาของฟังก์ชันจะต้องเคลื่อนที่ลง (ที่อยู่ที่สูงกว่า) จากตำแหน่งที่จัดเก็บที่อยู่ส่งคืนและในการเข้าถึงตัวแปรโลคัลเนื้อหาของฟังก์ชันจะต้องสำรวจสแต็กขึ้นไป (ที่อยู่ด้านล่าง ) สัมพันธ์กับตำแหน่งที่จัดเก็บที่อยู่สำหรับส่งคืน. ในความเป็นจริงคอมไพเลอร์ทั่วไปสร้างโค้ดสำหรับฟังก์ชันจะทำเช่นนี้ คอมไพเลอร์อุทิศการลงทะเบียนที่เรียกว่า EBP สำหรับสิ่งนี้ (Base Pointer) อีกชื่อหนึ่งที่เหมือนกันคือตัวชี้กรอบ โดยทั่วไปคอมไพเลอร์เป็นสิ่งแรกสำหรับเนื้อหาของฟังก์ชันจะผลักค่า EBP ปัจจุบันไปยังสแต็กและตั้งค่า EBP เป็น ESP ปัจจุบัน ซึ่งหมายความว่าเมื่อดำเนินการเสร็จสิ้นในส่วนใดส่วนหนึ่งของรหัสฟังก์ชันอาร์กิวเมนต์ 1 จะอยู่ห่างจาก EBP + 8 (4 ไบต์สำหรับ EBP ของผู้โทรและที่อยู่ที่ส่งกลับ) อาร์กิวเมนต์ 2 คือ EBP + 12 (ทศนิยม) ห่างออกไปตัวแปรโลคัล อยู่ห่างออกไป EBP-4n

.
.
.
[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) 

ดูรหัส C ต่อไปนี้สำหรับการสร้างสแต็กเฟรมของฟังก์ชัน:

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  

และรหัสแอสเซมบลีสำหรับฟังก์ชันจะเป็น (ตั้งค่าโดย callee ก่อนกลับ)

^
| _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
 

อ้างอิง:


1
ขอบคุณสำหรับคำตอบ. ลิงค์ยังเจ๋งมากและช่วยให้ฉันกระจ่างมากขึ้นในคำถามที่ไม่สิ้นสุดว่าคอมพิวเตอร์ทำงานอย่างไร :)
Christoph

คุณหมายถึงอะไรโดย "ดันค่า EBP ปัจจุบันไปยังสแต็ก" และตัวชี้สแต็กยังถูกเก็บไว้ในรีจิสเตอร์หรือว่าอยู่ในตำแหน่งในสแต็กมากเกินไป ... ฉันสับสนเล็กน้อย
Suraj Jain

และไม่ควรเป็น * [ebp + 8] ไม่ใช่ [ebp + 8]
Suraj Jain

@ สุรัชเชน; คุณรู้หรือไม่ว่าคืออะไรEBPและESP?
haccks

esp คือตัวชี้สแต็กและ ebp เป็นตัวชี้ฐาน หากฉันมีข้อผิดพลาดโปรดแก้ไขให้ถูกต้อง
Suraj Jain

19

เช่นเดียวกับคนอื่น ๆ ที่ระบุไว้คุณไม่จำเป็นต้องแสดงพารามิเตอร์จนกว่าจะอยู่นอกขอบเขต

ฉันจะแปะตัวอย่างบางส่วนจาก "Pointers and Memory" โดย Nick 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จุดในเวลา ถูกทำเครื่องหมายในรหัสและสถานะของหน่วยความจำในขณะนั้นจะแสดงในภาพวาด:

ใส่คำอธิบายภาพที่นี่


2
คำอธิบายภาพที่ยอดเยี่ยม ฉัน googled และพบบทความที่นี่: cslibrary.stanford.edu/102/PointersAndMemory.pdf กระดาษที่มีประโยชน์จริงๆ!
Christoph

7

โปรเซสเซอร์และภาษาที่แตกต่างกันใช้การออกแบบสแต็กที่แตกต่างกันเล็กน้อย รูปแบบดั้งเดิมสองรูปแบบทั้ง 8x86 และ 68000 เรียกว่าการเรียกแบบปาสคาลและแบบฟอร์มการโทร C แต่ละอนุสัญญาได้รับการจัดการในลักษณะเดียวกันในโปรเซสเซอร์ทั้งสองยกเว้นชื่อของรีจิสเตอร์ แต่ละตัวใช้รีจิสเตอร์สองตัวเพื่อจัดการสแต็กและตัวแปรที่เกี่ยวข้องเรียกว่าสแต็กพอยน์เตอร์ (SP หรือ A7) และตัวชี้เฟรม (BP หรือ A6)

เมื่อเรียกรูทีนย่อยโดยใช้รูปแบบใด ๆ พารามิเตอร์ใด ๆ จะถูกพุชบนสแต็กก่อนที่จะเรียกรูทีน จากนั้นโค้ดของรูทีนจะดันค่าปัจจุบันของตัวชี้เฟรมไปยังสแต็กคัดลอกค่าปัจจุบันของตัวชี้สแต็กไปยังตัวชี้เฟรมและลบจำนวนไบต์ที่ตัวแปรโลคัลใช้ออกจากตัวชี้สแต็ก [ถ้ามี] เมื่อดำเนินการเสร็จแล้วแม้ว่าข้อมูลเพิ่มเติมจะถูกผลักลงในสแต็กตัวแปรภายในทั้งหมดจะถูกเก็บไว้ที่ตัวแปรที่มีการกระจัดลบคงที่จากตัวชี้สแต็กและพารามิเตอร์ทั้งหมดที่ถูกผลักลงบนสแต็กโดยผู้เรียกอาจเข้าถึงได้ที่ a การกระจัดบวกคงที่จากตัวชี้เฟรม

ความแตกต่างระหว่างอนุสัญญาทั้งสองอยู่ที่วิธีจัดการกับการออกจากรูทีนย่อย ในรูปแบบ C ฟังก์ชันส่งคืนจะคัดลอกตัวชี้เฟรมไปยังตัวชี้สแต็ก [เรียกคืนเป็นค่าที่มีหลังจากที่ตัวชี้เฟรมเก่าถูกผลัก] ป๊อปค่าตัวชี้เฟรมแบบเก่าและดำเนินการส่งคืน พารามิเตอร์ใด ๆ ที่ผู้โทรผลักลงบนสแต็กก่อนการโทรจะยังคงอยู่ที่นั่น ในหลักการ Pascal หลังจากเปิดตัวชี้เฟรมแบบเก่าโปรเซสเซอร์จะแสดงที่อยู่ที่ส่งคืนของฟังก์ชันเพิ่มจำนวนไบต์ของพารามิเตอร์ที่ผู้เรียกใช้ให้กับตัวชี้สแต็กจากนั้นไปที่ที่อยู่ส่งคืนที่โผล่ขึ้นมา ใน 68000 ดั้งเดิมจำเป็นต้องใช้ลำดับ 3 คำสั่งเพื่อลบพารามิเตอร์ของผู้โทร โปรเซสเซอร์ 8x86 และ 680x0 ทั้งหมดหลังจากเดิมมี "ret N"

แบบแผน Pascal มีข้อดีในการบันทึกโค้ดเล็กน้อยในฝั่งผู้โทรเนื่องจากผู้โทรไม่จำเป็นต้องอัปเดตตัวชี้สแต็กหลังจากเรียกฟังก์ชัน อย่างไรก็ตามมันต้องการให้ฟังก์ชันที่เรียกนั้นรู้ว่าพารามิเตอร์ที่ผู้เรียกจะวางบนสแต็กมีค่ากี่ไบต์ การไม่ผลักดันจำนวนพารามิเตอร์ที่เหมาะสมไปยังสแต็กก่อนที่จะเรียกใช้ฟังก์ชันที่ใช้หลักการ Pascal นั้นเกือบจะรับประกันได้ว่าจะทำให้เกิดข้อขัดข้อง อย่างไรก็ตามนี่เป็นการชดเชยโดยข้อเท็จจริงที่ว่าโค้ดพิเศษเล็กน้อยภายในแต่ละวิธีที่เรียกว่าจะบันทึกโค้ดในตำแหน่งที่เรียกเมธอด ด้วยเหตุนี้รูทีนกล่องเครื่องมือดั้งเดิมของ Macintosh ส่วนใหญ่จึงใช้รูปแบบการโทรแบบปาสคาล

รูปแบบการเรียก C มีข้อได้เปรียบในการอนุญาตให้รูทีนยอมรับจำนวนพารามิเตอร์ที่แปรผันและมีประสิทธิภาพแม้ว่ารูทีนจะไม่ใช้พารามิเตอร์ทั้งหมดที่ส่งผ่านไป (ผู้เรียกจะทราบว่ามีการพุชพารามิเตอร์มูลค่ากี่ไบต์และ จึงจะสามารถทำความสะอาดได้) นอกจากนี้ไม่จำเป็นต้องทำการล้างข้อมูลสแต็กหลังจากเรียกใช้ฟังก์ชันทุกครั้ง หากรูทีนเรียกใช้ฟังก์ชันสี่ฟังก์ชันตามลำดับซึ่งแต่ละฟังก์ชันใช้พารามิเตอร์ที่มีมูลค่าสี่ไบต์อาจใช้แทนการใช้ไฟล์ADD SP,4หลังการโทรแต่ละครั้งให้ใช้หนึ่งครั้งADD SP,16หลังจากการเรียกครั้งสุดท้ายเพื่อล้างพารามิเตอร์จากการเรียกทั้งสี่

ปัจจุบันรูปแบบการเรียกที่อธิบายไว้ถือว่าค่อนข้างโบราณ เนื่องจากคอมไพเลอร์มีประสิทธิภาพมากขึ้นในการใช้งานรีจิสเตอร์จึงเป็นเรื่องปกติที่จะมีเมธอดยอมรับพารามิเตอร์บางตัวในรีจิสเตอร์แทนที่จะกำหนดให้ดันพารามิเตอร์ทั้งหมดบนสแตก หากเมธอดสามารถใช้รีจิสเตอร์เพื่อเก็บพารามิเตอร์และตัวแปรโลคัลทั้งหมดได้ก็ไม่จำเป็นต้องใช้ตัวชี้เฟรมและไม่จำเป็นต้องบันทึกและกู้คืนพารามิเตอร์เก่า ถึงกระนั้นบางครั้งก็จำเป็นต้องใช้รูปแบบการโทรที่เก่ากว่าเมื่อเรียกไลบรารีที่เชื่อมโยงเพื่อใช้งาน


1
ว้าว! ฉันขอยืมสมองคุณสัก 1 สัปดาห์ได้ไหม ต้องแยกสิ่งที่น่าสนใจออกมา! ตอบโจทย์มาก!
Christoph

ตัวชี้เฟรมและสแต็กเก็บไว้ที่ใดในสแต็กหรือที่อื่น ๆ
Suraj Jain

@SurajJain: โดยทั่วไปสำเนาที่บันทึกไว้ของตัวชี้เฟรมแต่ละชุดจะถูกจัดเก็บไว้ที่การเคลื่อนที่คงที่โดยสัมพันธ์กับค่าตัวชี้เฟรมใหม่
supercat

ครับผมสงสัยเรื่องนี้มานานแล้ว ถ้าในการทำงานของฉันฉันเขียนถ้า(g==4)แล้วint d = 3และgฉันใช้เวลาการป้อนข้อมูลโดยใช้scanfหลังจากนั้นผมกำหนดตัวแปรอื่นint h = 5หลังจากนั้นผมกำหนดตัวแปรอื่นตอนนี้คอมไพเลอร์ให้d = 3พื้นที่ในสแต็กอย่างไร อย่างไรชดเชยทำเพราะถ้าgไม่ได้4แล้วจะมีหน่วยความจำสำหรับ d ในกองและไม่มีการชดเชยจะได้รับเพียงhและหากชดเชยแล้วจะเป็นครั้งแรกสำหรับกรัมแล้วสำหรับg == 4 hคอมไพเลอร์ทำอย่างไรในเวลาคอมไพล์มันไม่ทราบข้อมูลของเราสำหรับg
Suraj Jain

@SurajJain: C รุ่นก่อนกำหนดให้ตัวแปรอัตโนมัติทั้งหมดภายในฟังก์ชันต้องปรากฏก่อนคำสั่งปฏิบัติการใด ๆ ผ่อนคลายการคอมไพล์ที่ซับซ้อนเล็กน้อย แต่แนวทางหนึ่งคือการสร้างโค้ดที่จุดเริ่มต้นของฟังก์ชันซึ่งลบออกจาก SP ค่าของเลเบลที่ประกาศไปข้างหน้า ภายในฟังก์ชั่นคอมไพเลอร์สามารถในแต่ละจุดในโค้ดเพื่อติดตามจำนวนชาวบ้านที่ยังอยู่ในขอบเขตที่มีมูลค่ากี่ไบต์และยังติดตามจำนวนไบต์สูงสุดที่มีค่าของชาวบ้านที่เคยอยู่ในขอบเขต ในตอนท้ายของฟังก์ชั่นสามารถจ่ายค่าสำหรับก่อนหน้านี้ ...
supercat

5

มีคำตอบที่ดีอยู่แล้วที่นี่ อย่างไรก็ตามหากคุณยังคงกังวลเกี่ยวกับพฤติกรรม LIFO ของสแต็กให้คิดว่ามันเป็นสแต็กของเฟรมแทนที่จะเป็นสแต็กของตัวแปร สิ่งที่ฉันหมายถึงแนะนำคือแม้ว่าฟังก์ชันอาจเข้าถึงตัวแปรที่ไม่ได้อยู่ด้านบนสุดของสแต็ก แต่ก็ยังคงทำงานบนรายการเท่านั้นที่ด้านบนสุดของสแต็ก: เฟรมสแต็กเดียว

แน่นอนว่ามีข้อยกเว้นสำหรับเรื่องนี้ ตัวแปรโลคัลของห่วงโซ่การโทรทั้งหมดยังคงถูกจัดสรรและพร้อมใช้งาน แต่จะไม่สามารถเข้าถึงได้โดยตรง แต่จะส่งผ่านโดยการอ้างอิง (หรือโดยตัวชี้ซึ่งแตกต่างกันทางความหมายเท่านั้น) ในกรณีนี้สามารถเข้าถึงตัวแปรโลคัลของสแต็กเฟรมได้อีกมาก แต่ถึงแม้ในกรณีนี้ฟังก์ชันการเรียกใช้งานในปัจจุบันยังคงทำงานบนข้อมูลในเครื่องของตัวเองเท่านั้น กำลังเข้าถึงข้อมูลอ้างอิงที่เก็บไว้ในสแต็กเฟรมของตัวเองซึ่งอาจเป็นการอ้างอิงถึงบางสิ่งบนฮีปในหน่วยความจำแบบคงที่หรือเพิ่มเติมลงไปในสแต็ก

นี่คือส่วนหนึ่งของสแต็กนามธรรมที่ทำให้ฟังก์ชันสามารถเรียกใช้ในลำดับใดก็ได้และอนุญาตให้เรียกซ้ำ กรอบสแต็กด้านบนเป็นวัตถุเดียวที่เข้าถึงโดยตรงโดยรหัส สิ่งอื่นใดถูกเข้าถึงโดยทางอ้อม (ผ่านตัวชี้ที่อยู่ในกรอบสแต็กด้านบน)

อาจเป็นประโยชน์ในการดูส่วนประกอบของโปรแกรมเล็ก ๆ ของคุณโดยเฉพาะอย่างยิ่งหากคุณรวบรวมโดยไม่ปรับให้เหมาะสม ฉันคิดว่าคุณจะเห็นว่าการเข้าถึงหน่วยความจำทั้งหมดในฟังก์ชันของคุณเกิดขึ้นผ่านการชดเชยจากตัวชี้สแต็กเฟรมซึ่งเป็นวิธีที่คอมไพเลอร์เขียนโค้ดสำหรับฟังก์ชัน ในกรณีของการอ้างอิงแบบ pass by คุณจะเห็นคำแนะนำการเข้าถึงหน่วยความจำทางอ้อมผ่านตัวชี้ที่จัดเก็บไว้ที่ออฟเซ็ตบางส่วนจากตัวชี้สแต็กเฟรม


4

call stack ไม่ใช่โครงสร้างข้อมูลสแต็กจริงๆ เบื้องหลังคอมพิวเตอร์ที่เราใช้เป็นการนำสถาปัตยกรรมเครื่องเข้าถึงโดยสุ่มมาใช้ ดังนั้น a และ b สามารถเข้าถึงได้โดยตรง

เบื้องหลังเครื่องทำ:

  • รับ "a" เท่ากับการอ่านค่าขององค์ประกอบที่สี่ด้านล่างสแต็กท็อป
  • รับ "b" เท่ากับการอ่านค่าขององค์ประกอบที่สามด้านล่างสแต็กท็อป

http://en.wikipedia.org/wiki/Random-access_machine


1

นี่คือแผนภาพที่ฉันสร้างขึ้นสำหรับ call stack ของ C มีความแม่นยำและร่วมสมัยกว่าเวอร์ชันรูปภาพของ Google

ใส่คำอธิบายภาพที่นี่

และสอดคล้องกับโครงสร้างที่แน่นอนของแผนภาพด้านบนนี่คือการดีบักของ notepad.exe x64 บน windows 7

ใส่คำอธิบายภาพที่นี่

ที่อยู่ต่ำและที่อยู่สูงจะถูกสลับเพื่อให้สแต็กปีนขึ้นไปในแผนภาพนี้ สีแดงหมายถึงกรอบตรงกับในแผนภาพแรก (ซึ่งใช้สีแดงและสีดำ แต่ตอนนี้สีดำถูกนำมาใช้ใหม่) สีดำคือพื้นที่บ้าน สีน้ำเงินคือที่อยู่ที่ส่งกลับซึ่งเป็นการชดเชยในฟังก์ชันผู้โทรไปยังคำสั่งหลังการโทร สีส้มคือการจัดตำแหน่งและสีชมพูคือตำแหน่งที่ตัวชี้คำสั่งชี้ไปทางขวาหลังจากการโทรและก่อนคำสั่งแรก ค่า homespace + return เป็นเฟรมที่เล็กที่สุดที่อนุญาตบน windows และเนื่องจากต้องคงการจัดตำแหน่ง rsp 16 ไบต์ไว้ที่จุดเริ่มต้นของฟังก์ชันที่เรียกซึ่งรวมถึงการจัดตำแหน่ง 8 ไบต์ด้วยเช่นกันBaseThreadInitThunk และอื่น ๆ

กรอบฟังก์ชันสีแดงจะแสดงให้เห็นว่าฟังก์ชัน callee 'เป็นเจ้าของ' อย่างมีเหตุผล + อ่าน / แก้ไข (สามารถแก้ไขพารามิเตอร์ที่ส่งผ่านไปยังสแต็กที่ใหญ่เกินกว่าที่จะส่งผ่านในรีจิสเตอร์บน -Ofast) เส้นสีเขียวแบ่งเขตพื้นที่ที่ฟังก์ชันจัดสรรเองตั้งแต่ต้นจนจบฟังก์ชัน


RDI และอาร์เรย์รีจิสเตอร์อื่น ๆ จะรั่วไหลไปยังสแต็กเท่านั้นหากคุณคอมไพล์ในโหมดดีบักและไม่รับประกันว่าคอมไพล์จะเลือกคำสั่งนั้น นอกจากนี้เหตุใด stack args จึงไม่แสดงที่ด้านบนของแผนภาพสำหรับการเรียกใช้ฟังก์ชันที่เก่าที่สุด ไม่มีการแบ่งเขตที่ชัดเจนในแผนภาพของคุณว่าเฟรมใด "เป็นเจ้าของ" ข้อมูลใด (callee เป็นเจ้าของสแต็ก args) การละเว้นสแต็ก args จากด้านบนของแผนภาพทำให้ยากที่จะเห็นว่า "พารามิเตอร์ที่ไม่สามารถส่งผ่านในรีจิสเตอร์" จะอยู่เหนือที่อยู่ส่งคืนของทุกฟังก์ชันเสมอ
Peter Cordes

@PeterCordes goldbolt เอาต์พุต asm แสดง clang และ gcc callees ผลักดันพารามิเตอร์ที่ส่งผ่านในรีจิสเตอร์ไปยังสแต็กเป็นพฤติกรรมเริ่มต้นดังนั้นจึงมีแอดเดรส ใน gcc โดยใช้registerด้านหลังพารามิเตอร์จะเพิ่มประสิทธิภาพให้ดีที่สุด แต่คุณคิดว่าจะได้รับการปรับให้เหมาะสมที่สุดอย่างไรก็ตามเนื่องจากที่อยู่จะไม่ถูกนำมาใช้ในฟังก์ชัน ฉันจะแก้ไขกรอบด้านบน เป็นที่ยอมรับว่าฉันควรใส่จุดไข่ปลาในกรอบว่างแยกต่างหาก 'ผู้โทรเป็นเจ้าของ stack args' รวมถึงสิ่งที่ผู้โทรกดหากไม่สามารถส่งผ่านในการลงทะเบียนได้?
Lewis Kelsey

ใช่ถ้าคุณคอมไพล์โดยปิดการใช้งานการเพิ่มประสิทธิภาพแคลลี่จะทำอะไรบางอย่าง แต่แตกต่างจากตำแหน่งของ stack args (และเนื้อหาที่บันทึกไว้ - RBP) ไม่มีอะไรเป็นมาตรฐานเกี่ยวกับตำแหน่ง Re: callee เป็นเจ้าของ stack args: ใช่ฟังก์ชันได้รับอนุญาตให้แก้ไข args ขาเข้า reg args มันรั่วไหลเองไม่ใช่ stack args บางครั้งคอมไพเลอร์ทำเช่นนี้ แต่ IIRC มักจะเสียพื้นที่สแต็กโดยใช้ช่องว่างด้านล่างที่อยู่ที่ส่งคืนแม้ว่าจะไม่อ่านอาร์กิวเมนต์ซ้ำ หากผู้โทรต้องการโทรออกอีกครั้งโดยใช้สายเดียวกันเพื่อความปลอดภัยพวกเขาต้องจัดเก็บสำเนาอื่นก่อนที่จะทำซ้ำcall
Peter Cordes

@PeterCordes ฉันทำให้อาร์กิวเมนต์เป็นส่วนหนึ่งของกลุ่มผู้โทรเพราะฉันกำลังแบ่งเขตสแต็กเฟรมตามจุด rbp ไดอะแกรมบางส่วนแสดงสิ่งนี้เป็นส่วนหนึ่งของ callee stack (ตามที่แผนภาพแรกของคำถามนี้ทำ) และบางส่วนแสดงให้เห็นว่าเป็นส่วนหนึ่งของ caller stack แต่อาจจะสมเหตุสมผลที่จะทำให้เป็นส่วนหนึ่งของ callee stack โดยเห็นว่าเป็นขอบเขตพารามิเตอร์ ผู้โทรไม่สามารถเข้าถึงได้ในรหัสระดับที่สูงกว่า ใช่ดูเหมือนว่าregisterและการconstเพิ่มประสิทธิภาพจะสร้างความแตกต่างใน -O0 เท่านั้น
Lewis Kelsey

@PeterCordes ฉันเปลี่ยนมัน ฉันอาจจะเปลี่ยนอีกครั้ง
Lewis Kelsey
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.