Chính xác thì callstack hoạt động như thế nào?


103

Tôi đang cố gắng hiểu sâu hơn về cách hoạt động của các hoạt động cấp thấp của ngôn ngữ lập trình và đặc biệt là cách chúng tương tác với OS / CPU. Có lẽ tôi đã đọc mọi câu trả lời trong mọi chủ đề liên quan đến ngăn xếp / đống ở đây trên Stack Overflow và chúng đều rất tuyệt vời. Nhưng vẫn còn một điều mà tôi vẫn chưa hiểu hết.

Hãy xem xét hàm này trong mã giả có xu hướng là mã Rust hợp lệ ;-)

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

    // line X

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

Đây là cách tôi giả định ngăn xếp trông giống như trên dòng X:

Stack

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

Bây giờ, mọi thứ tôi đã đọc về cách ngăn xếp hoạt động là nó tuân thủ nghiêm ngặt các quy tắc LIFO (nhập sau cùng, xuất trước). Cũng giống như kiểu dữ liệu ngăn xếp trong .NET, Java hoặc bất kỳ ngôn ngữ lập trình nào khác.

Nhưng nếu đúng như vậy, thì điều gì sẽ xảy ra sau dòng X? Bởi vì rõ ràng, điều tiếp theo chúng ta cần là làm việc với ab, nhưng điều đó có nghĩa là OS / CPU (?) Phải bật ra dctrước tiên để quay lại ab. Nhưng sau đó nó sẽ tự bắn vào chân, bởi vì nó cần cdở dòng tiếp theo.

Vì vậy, tôi tự hỏi những gì chính xác xảy ra đằng sau hậu trường?

Một câu hỏi liên quan khác. Hãy xem xét chúng ta chuyển một tham chiếu đến một trong các hàm khác như sau:

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

    // line X

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

Theo cách tôi hiểu mọi thứ, điều này có nghĩa là các tham số trong doSomethingvề cơ bản đang trỏ đến cùng một địa chỉ bộ nhớ như abtrong foo. Nhưng một lần nữa, điều này có nghĩa là không có cửa sổ bật lên nào cho đến khi chúng ta đến ab diễn ra.

Hai trường hợp đó khiến tôi nghĩ rằng tôi đã không hoàn toàn hiểu chính xác cách hoạt động của ngăn xếp và cách nó tuân thủ nghiêm ngặt các quy tắc LIFO .


14
LIFO chỉ quan trọng đối với việc dành không gian trên ngăn xếp. Bạn luôn có thể truy cập bất kỳ biến nào ít nhất là trên khung ngăn xếp của bạn (được khai báo bên trong hàm) ngay cả khi nó nằm dưới rất nhiều biến khác
VoidStar

2
Nói cách khác, LIFOcó nghĩa là bạn chỉ có thể thêm hoặc xóa các phần tử ở cuối ngăn xếp và bạn luôn có thể đọc / thay đổi bất kỳ phần tử nào.
HolyBlackCat

12
Tại sao bạn không tháo rời một hàm đơn giản sau khi biên dịch với -O0 và xem các hướng dẫn đã tạo? Nó khá, tốt, mang tính hướng dẫn ;-). Bạn sẽ thấy rằng mã sử dụng tốt phần R của RAM; nó truy cập trực tiếp các địa chỉ theo ý muốn. Bạn có thể coi một tên biến như một phần bù cho thanh ghi địa chỉ (con trỏ ngăn xếp). Như những người khác đã nói, ngăn xếp chỉ là LIFO đối với việc xếp chồng (tốt cho đệ quy, v.v.). Nó không phải là LIFO đối với việc truy cập nó. Truy cập là hoàn toàn ngẫu nhiên.
Peter - Phục hồi Monica.

6
Bạn có thể tạo cấu trúc dữ liệu ngăn xếp của riêng mình bằng cách sử dụng một mảng và chỉ lưu trữ chỉ mục của phần tử trên cùng, tăng nó khi bạn đẩy, giảm nó khi bạn bật. Nếu bạn đã làm điều này, bạn vẫn có thể truy cập bất kỳ phần tử riêng lẻ nào trong mảng bất kỳ lúc nào mà không cần đẩy hoặc bật nó, giống như bạn luôn làm với mảng. Điều tương tự cũng đang xảy ra ở đây.
Crowman

3
Về cơ bản, việc đặt tên ngăn xếp / đống là không may. Chúng có rất ít điểm giống nhau trong thuật ngữ cấu trúc dữ liệu stack và heap, vì vậy việc gọi chúng giống nhau là rất khó hiểu.
Siyuan Ren

Câu trả lời:


117

Ngăn xếp cuộc gọi cũng có thể được gọi là ngăn xếp khung.
Những thứ được xếp chồng sau nguyên tắc LIFO không phải là các biến cục bộ mà là toàn bộ khung ngăn xếp ("lệnh gọi") của các hàm được gọi . Các biến cục bộ được đẩy và xuất hiện cùng với các khung đó trong cái gọi là phần mở đầuphần kết của hàm , tương ứng.

Bên trong khung, thứ tự của các biến hoàn toàn không xác định; Các trình biên dịch "sắp xếp lại" vị trí của các biến cục bộ bên trong khung một cách thích hợp để tối ưu hóa sự liên kết của chúng để bộ xử lý có thể tìm nạp chúng nhanh nhất có thể. Thực tế quan trọng là độ lệch của các biến liên quan đến một số địa chỉ cố định là không đổi trong suốt thời gian tồn tại của khung - vì vậy, nó đủ để lấy một địa chỉ neo, ví dụ, địa chỉ của chính khung và làm việc với các hiệu số của địa chỉ đó để các biến. Một địa chỉ neo như vậy thực sự được chứa trong cái gọi là con trỏ khung hoặc cơ sởđược lưu trữ trong sổ đăng ký EBP. Mặt khác, các hiệu số được biết rõ ràng tại thời điểm biên dịch và do đó được mã hóa cứng thành mã máy.

Hình ảnh này từ Wikipedia cho thấy ngăn xếp cuộc gọi điển hình được cấu trúc như 1 :

Hình ảnh của một ngăn xếp

Thêm phần bù của một biến mà chúng tôi muốn truy cập vào địa chỉ chứa trong con trỏ khung và chúng tôi nhận được địa chỉ của biến của chúng tôi. Như đã nói ngắn gọn, mã chỉ truy cập chúng trực tiếp thông qua hiệu số thời gian biên dịch không đổi từ con trỏ cơ sở; Đó là số học con trỏ đơn giản.

Thí dụ

#include <iostream>

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

gcc.godbolt.org cung cấp cho chúng tôi

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

.. cho main. Tôi chia mã thành ba phần phụ. Phần mở đầu hàm bao gồm ba hoạt động đầu tiên:

  • Con trỏ cơ sở được đẩy lên ngăn xếp.
  • Con trỏ ngăn xếp được lưu trong con trỏ cơ sở
  • Con trỏ ngăn xếp được trừ đi để nhường chỗ cho các biến cục bộ.

Sau đó cinđược chuyển vào thanh ghi EDI 2getđược gọi; Giá trị trả về là EAX.

Càng xa càng tốt. Bây giờ điều thú vị xảy ra:

Byte bậc thấp của EAX, được chỉ định bởi thanh ghi 8-bit AL, được lấy và lưu trữ trong byte ngay sau con trỏ cơ sở : Nghĩa là -1(%rbp), độ lệch của con trỏ cơ sở là -1. Byte này là biến của chúng tôic . Phần bù là âm vì ngăn xếp tăng dần xuống trên x86. Hoạt động tiếp theo lưu trữ ctrong EAX: EAX được chuyển đến ESI, coutđược chuyển đến EDI và sau đó toán tử chèn được gọi với coutclà các đối số.

Cuối cùng,

  • Giá trị trả về của mainđược lưu trữ trong EAX: 0. Đó là do returncâu lệnh ngầm định . Bạn cũng có thể thấy xorl rax raxthay vì movl.
  • rời đi và trở lại địa điểm cuộc gọi. leaveđang viết tắt phần kết này và ngầm hiểu
    • Thay thế con trỏ ngăn xếp bằng con trỏ cơ sở và
    • Dừng con trỏ cơ sở.

Sau khi thao tác này và retđược thực hiện, khung đã được xuất hiện một cách hiệu quả, mặc dù người gọi vẫn phải xóa các đối số vì chúng ta đang sử dụng quy ước gọi cdecl. Các quy ước khác, ví dụ như stdcall, yêu cầu callee dọn dẹp, ví dụ bằng cách chuyển số lượng byte sang ret.

Thiếu con trỏ khung

Cũng có thể không sử dụng hiệu số từ con trỏ cơ sở / khung mà thay vào đó là từ con trỏ ngăn xếp (ESB). Điều này làm cho thanh ghi EBP có thể chứa giá trị con trỏ khung để sử dụng tùy ý - nhưng nó có thể khiến việc gỡ lỗi không thể thực hiện được trên một số máy và sẽ bị tắt ngầm đối với một số chức năng . Nó đặc biệt hữu ích khi biên dịch cho các bộ xử lý chỉ có ít thanh ghi, bao gồm cả x86.

Tối ưu hóa này được gọi là FPO (bỏ qua con trỏ khung) và được thiết lập bởi -fomit-frame-pointerGCC và -Oytrong Clang; lưu ý rằng nó được kích hoạt ngầm bởi mọi cấp độ tối ưu hóa> 0 nếu và chỉ khi việc gỡ lỗi vẫn có thể xảy ra, vì nó không có bất kỳ chi phí nào ngoài điều đó. Để biết thêm thông tin, hãy xem tại đâyđây .


1 Như đã chỉ ra trong các nhận xét, con trỏ khung có lẽ được dùng để trỏ đến địa chỉ sau địa chỉ trả về.

2 Lưu ý rằng thanh ghi bắt đầu bằng R là bản sao 64-bit của thanh ghi bắt đầu bằng E. EAX chỉ định bốn byte bậc thấp của RAX. Tôi đã sử dụng tên của các thanh ghi 32-bit để rõ ràng.


1
Câu trả lời chính xác. Vấn đề với việc giải quyết dữ liệu bằng hiệu số là một chút thiếu sót đối với tôi :)
Christoph

1
Tôi nghĩ rằng có một sai sót nhỏ trong bản vẽ. Con trỏ khung sẽ phải nằm ở phía bên kia của địa chỉ trả về. Để lại một chức năng thường được thực hiện như sau: di chuyển con trỏ ngăn xếp đến con trỏ khung, bật con trỏ khung người gọi khỏi ngăn xếp, quay lại (tức là bật bộ đếm chương trình người gọi / con trỏ lệnh từ ngăn xếp.)
kasperd

kasperd là hoàn toàn đúng. Bạn hoàn toàn không sử dụng con trỏ khung (tối ưu hóa hợp lệ và đặc biệt đối với các kiến ​​trúc không có đăng ký như x86 cực kỳ hữu ích) hoặc bạn sử dụng nó và lưu trữ cái trước đó trên ngăn xếp - thường là ngay sau địa chỉ trả về. Cách thiết lập và loại bỏ khung phụ thuộc rất nhiều vào kiến ​​trúc và ABI. Có khá nhiều kiến ​​trúc (xin chào Itanium) nơi mà toàn bộ mọi thứ là .. thú vị hơn (và có những thứ như danh sách đối số có kích thước thay đổi!)
Voo

3
@Christoph Tôi nghĩ bạn đang tiếp cận vấn đề này từ quan điểm khái niệm. Đây là một nhận xét hy vọng sẽ làm sáng tỏ điều này - RTS, hoặc RunTime Stack, hơi khác so với các ngăn xếp khác, ở chỗ nó là một "ngăn xếp bẩn" - thực sự không có bất kỳ điều gì ngăn cản bạn nhìn vào một giá trị không phải là ' t trên đầu trang. Lưu ý rằng trong sơ đồ, "Địa chỉ trả lại" cho phương thức màu xanh lá cây - phương thức màu xanh lam cần có! là sau các tham số. Làm thế nào để phương thức màu xanh lam nhận được giá trị trả về, sau khi khung trước đó được bật lên? Chà, đó là một đống bẩn thỉu, nên nó có thể thò tay vào và lấy nó.
Riking

1
Con trỏ khung thực sự không cần thiết vì người ta luôn có thể sử dụng các hiệu số từ con trỏ ngăn xếp. GCC nhắm mục tiêu kiến ​​trúc x64 theo mặc định sử dụng con trỏ ngăn xếp và giải phóng rbpđể thực hiện công việc khác.
Siyuan Ren

27

Bởi vì rõ ràng, điều tiếp theo chúng ta cần là làm việc với a và b nhưng điều đó có nghĩa là OS / CPU (?) Phải bật ra d và c trước để quay lại a và b. Nhưng sau đó nó sẽ tự bắn vào chân vì nó cần c và d ở dòng tiếp theo.

Nói ngắn gọn:

Không cần phải bật các đối số. Tất cả các đối số được truyền bởi người gọi foođến hàm doSomethingvà các biến cục bộ trong đó doSomething đều có thể được tham chiếu như một phần bù từ con trỏ cơ sở .
Vì thế,

  • Khi một lệnh gọi hàm được thực hiện, các đối số của hàm được PUSHed trên ngăn xếp. Các đối số này được tham chiếu thêm bởi con trỏ cơ sở.
  • Khi hàm trở về trình gọi của nó, các đối số của hàm trả về được POPed khỏi ngăn xếp bằng phương thức LIFO.

Chi tiết:

Quy tắc là mỗi lần gọi hàm dẫn đến việc tạo một khung ngăn xếp (với địa chỉ tối thiểu là địa chỉ để trả về). Vì vậy, nếu funcAcác cuộc gọi funcBfuncBcuộc gọi funcC, ba khung ngăn xếp được thiết lập chồng lên nhau. Khi một hàm trả về, khung của nó trở nên không hợp lệ . Một chức năng hoạt động tốt chỉ hoạt động trên khung ngăn xếp của chính nó và không xâm phạm vào khung khác. Nói cách khác, POPing được thực hiện đối với khung ngăn xếp ở trên cùng (khi quay trở lại từ hàm).

nhập mô tả hình ảnh ở đây

Ngăn xếp trong câu hỏi của bạn được thiết lập bởi người gọi foo. Khi doSomethingdoAnotherThingđược gọi, sau đó họ thiết lập ngăn xếp của riêng mình. Hình có thể giúp bạn hiểu điều này:

nhập mô tả hình ảnh ở đây

Lưu ý rằng, để truy cập các đối số, thân hàm sẽ phải duyệt xuống (địa chỉ cao hơn) từ vị trí lưu trữ địa chỉ trả về và để truy cập các biến cục bộ, thân hàm sẽ phải duyệt qua ngăn xếp (địa chỉ thấp hơn ) liên quan đến vị trí lưu trữ địa chỉ trả hàng. Trên thực tế, mã được tạo bởi trình biên dịch điển hình cho hàm sẽ thực hiện chính xác điều này. Trình biên dịch dành một thanh ghi được gọi là EBP cho điều này (Con trỏ cơ sở). Một tên khác cho tương tự là con trỏ khung. Trình biên dịch thường, như là thứ đầu tiên cho thân hàm, đẩy giá trị EBP hiện tại vào ngăn xếp và đặt EBP thành ESP hiện tại. Điều này có nghĩa là, khi điều này được thực hiện, trong bất kỳ phần nào của mã hàm, đối số 1 là EBP + 8 (4 byte cho mỗi EBP của người gọi và địa chỉ trả về), đối số 2 là EBP + 12 (thập phân), các biến cục bộ EBP-4n đi.

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

Hãy xem đoạn mã C sau để biết sự hình thành khung ngăn xếp của hàm:

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

Khi người gọi gọi nó

MyFunction(10, 5, 2);  

mã sau sẽ được tạo

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

và mã lắp ráp cho hàm sẽ được (thiết lập bởi callee trước khi trả về)

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

Người giới thiệu:


1
Cảm ơn bạn vì câu trả lời. Ngoài ra các liên kết được thực sự mát mẻ và giúp tôi làm sáng tỏ hơn vào câu hỏi không bao giờ kết thúc như thế nào máy tính thực sự làm việc :)
Christoph

Điều gì làm bạn có nghĩa là "đẩy giá trị EBP hiện tại vào stack" và cũng làm con trỏ ngăn xếp được lưu trữ trong sổ đăng ký hoặc quá chiếm một vị trí trong ngăn xếp ... i am chút bối rối
Suraj Jain

Và đó không phải là * [ebp + 8] không phải [ebp + 8].?
Suraj Jain

@Suraj Jain; Bạn có biết những gì là EBPESP?
haccks 14/09/2016

esp là con trỏ ngăn xếp và ebp là con trỏ cơ sở. Nếu tôi có một số kiến ​​thức bị bỏ sót, xin vui lòng sửa nó.
Suraj Jain

19

Giống như những người khác đã lưu ý, không cần phải bật các tham số, cho đến khi chúng vượt ra khỏi phạm vi.

Tôi sẽ dán một số ví dụ từ "Con trỏ và trí nhớ" của Nick Parlante. Tôi nghĩ rằng tình huống đơn giản hơn một chút so với bạn hình dung.

Đây là mã:

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)
}

Các điểm trong thời gian T1, T2, etc. được đánh dấu trong mã và trạng thái của bộ nhớ tại thời điểm đó được hiển thị trong hình vẽ:

nhập mô tả hình ảnh ở đây


2
Giải thích trực quan tuyệt vời. Tôi đã truy cập và tìm thấy bài báo ở đây: cslibrary.stanford.edu/102/PointersAndMemory.pdf Bài báo thực sự hữu ích!
Christoph

7

Các bộ xử lý và ngôn ngữ khác nhau sử dụng một vài thiết kế ngăn xếp khác nhau. Hai mẫu truyền thống trên cả 8x86 và 68000 được gọi là quy ước gọi Pascal và quy ước gọi C; mỗi quy ước được xử lý giống nhau trong cả hai bộ xử lý, ngoại trừ tên của các thanh ghi. Mỗi thanh ghi sử dụng hai thanh ghi để quản lý ngăn xếp và các biến liên quan, được gọi là con trỏ ngăn xếp (SP hoặc A7) và con trỏ khung (BP hoặc A6).

Khi gọi chương trình con bằng một trong hai quy ước, mọi tham số sẽ được đẩy lên ngăn xếp trước khi gọi quy trình. Sau đó, mã của quy trình sẽ đẩy giá trị hiện tại của con trỏ khung lên ngăn xếp, sao chép giá trị hiện tại của con trỏ ngăn xếp vào con trỏ khung và trừ đi số byte được sử dụng bởi các biến cục bộ từ con trỏ ngăn xếp [nếu có]. Khi điều đó được thực hiện, ngay cả khi dữ liệu bổ sung được đẩy vào ngăn xếp, tất cả các biến cục bộ sẽ được lưu trữ tại các biến có độ dịch chuyển âm liên tục từ con trỏ ngăn xếp và tất cả các tham số được người gọi đẩy lên ngăn xếp có thể được truy cập tại một độ dịch chuyển dương không đổi khỏi con trỏ khung.

Sự khác biệt giữa hai quy ước nằm ở cách chúng xử lý một lần thoát khỏi chương trình con. Trong quy ước C, hàm trả về sao chép con trỏ khung vào con trỏ ngăn xếp [khôi phục nó về giá trị mà nó có ngay sau khi con trỏ khung cũ được đẩy], bật giá trị con trỏ khung cũ và thực hiện trả về. Bất kỳ tham số nào mà người gọi đã đẩy vào ngăn xếp trước cuộc gọi sẽ vẫn ở đó. Trong quy ước Pascal, sau khi bật con trỏ khung cũ, bộ xử lý sẽ bật địa chỉ trả về của hàm, thêm vào con trỏ ngăn xếp số byte tham số do người gọi đẩy, rồi chuyển đến địa chỉ trả về được bật. Trên 68000 ban đầu, cần sử dụng chuỗi 3 lệnh để loại bỏ các tham số của người gọi; 8x86 và tất cả các bộ xử lý 680x0 sau bộ xử lý gốc bao gồm "ret N"

Quy ước Pascal có ưu điểm là tiết kiệm một chút mã ở phía trình gọi, vì trình gọi không phải cập nhật con trỏ ngăn xếp sau một lệnh gọi hàm. Tuy nhiên, nó yêu cầu hàm được gọi biết chính xác giá trị bao nhiêu byte của các tham số mà người gọi sẽ đặt vào ngăn xếp. Việc không đẩy đủ số lượng tham số vào ngăn xếp trước khi gọi một hàm sử dụng quy ước Pascal gần như được đảm bảo sẽ gây ra sự cố. Tuy nhiên, điều này được bù đắp bởi thực tế là một chút mã bổ sung trong mỗi phương thức được gọi sẽ lưu mã tại nơi phương thức được gọi. Vì lý do đó, hầu hết các quy trình hộp công cụ Macintosh ban đầu đều sử dụng quy ước gọi Pascal.

Quy ước gọi C có ưu điểm là cho phép các quy trình chấp nhận một số lượng tham số thay đổi và mạnh mẽ ngay cả khi một quy trình không sử dụng tất cả các tham số được truyền vào (người gọi sẽ biết giá trị của bao nhiêu byte tham số mà nó đã đẩy, và do đó sẽ có thể làm sạch chúng). Hơn nữa, không cần thiết phải thực hiện dọn dẹp ngăn xếp sau mỗi lần gọi hàm. Nếu một quy trình gọi bốn hàm theo thứ tự, mỗi hàm sử dụng bốn byte giá trị tham số, thì nó có thể - thay vì sử dụng ADD SP,4sau mỗi lần gọi, hãy sử dụng một ADD SP,16sau lần gọi cuối cùng để xóa các tham số khỏi cả bốn lần gọi.

Ngày nay, các quy ước gọi được mô tả được coi là cổ hủ. Vì các trình biên dịch đã trở nên hiệu quả hơn trong việc sử dụng thanh ghi, nên thông thường các phương thức chấp nhận một vài tham số trong thanh ghi hơn là yêu cầu tất cả các tham số được đẩy lên ngăn xếp; nếu một phương thức có thể sử dụng các thanh ghi để chứa tất cả các tham số và biến cục bộ, thì không cần sử dụng con trỏ khung và do đó không cần phải lưu và khôi phục cái cũ. Tuy nhiên, đôi khi cần sử dụng các quy ước gọi cũ hơn khi gọi các thư viện được liên kết để sử dụng chúng.


1
Chà! Tôi có thể mượn bộ não của bạn trong một tuần hoặc lâu hơn. Cần giải nén một số thứ nitty-gritty! Câu trả lời chính xác!
Christoph

Khung và con trỏ ngăn xếp được lưu trữ ở đâu trong chính ngăn xếp hoặc bất kỳ nơi nào khác?
Suraj Jain

@SurajJain: Thông thường, mỗi bản sao đã lưu của con trỏ khung sẽ được lưu trữ ở một độ dịch chuyển cố định so với giá trị con trỏ khung mới.
supercat

Thưa ông, tôi đã nghi ngờ điều này trong một thời gian dài. Nếu trong hàm của tôi, tôi viết if (g==4)then int d = 3gtôi lấy đầu vào bằng cách sử dụng scanfsau đó tôi xác định một biến khác int h = 5. Bây giờ, làm thế nào để trình biên dịch cung cấp d = 3không gian trong ngăn xếp. Làm thế nào để bù đắp được thực hiện bởi vì nếu gkhông 4, thì sẽ không có bộ nhớ cho d trong ngăn xếp và chỉ cần bù đắp sẽ được cấp cho hvà nếu g == 4thì bù đắp sẽ là đầu tiên cho g và sau đó cho h. Làm thế nào để biên dịch thực hiện điều đó tại thời gian biên dịch, nó không biết đầu vào của chúng tôi chog
Suraj Jain

@SurajJain: Các phiên bản đầu tiên của C yêu cầu tất cả các biến tự động trong một hàm phải xuất hiện trước bất kỳ câu lệnh thực thi nào. Giảm nhẹ việc biên dịch phức tạp đó một chút, nhưng có một cách tiếp cận là tạo mã ở đầu một hàm trừ đi giá trị của nhãn được khai báo trước từ SP. Trong chức năng, trình biên dịch có thể tại mỗi điểm trong mã theo dõi số lượng byte giá trị của các cục bộ vẫn còn trong phạm vi và cũng theo dõi số lượng byte giá trị tối đa của các cục bộ có trong phạm vi. Vào cuối của hàm, nó có thể cung cấp giá trị cho trước đó ...
supercat

5

Đã có một số câu trả lời thực sự tốt ở đây. Tuy nhiên, nếu bạn vẫn lo lắng về hành vi LIFO của ngăn xếp, hãy nghĩ về nó như một ngăn xếp các khung, thay vì một ngăn xếp các biến. Ý tôi muốn đề xuất là, mặc dù một hàm có thể truy cập các biến không nằm trên cùng của ngăn xếp, nhưng nó vẫn chỉ hoạt động trên mục ở trên cùng của ngăn xếp: một khung ngăn xếp duy nhất.

Tất nhiên, có những ngoại lệ cho điều này. Các biến cục bộ của toàn bộ chuỗi cuộc gọi vẫn được cấp phát và có sẵn. Nhưng chúng sẽ không được truy cập trực tiếp. Thay vào đó, chúng được chuyển qua tham chiếu (hoặc bởi con trỏ, thực sự chỉ khác nhau về mặt ngữ nghĩa). Trong trường hợp này, có thể truy cập một biến cục bộ của khung ngăn xếp ở phía dưới. Nhưng ngay cả trong trường hợp này, hàm hiện đang thực thi vẫn chỉ hoạt động trên dữ liệu cục bộ của chính nó. Nó đang truy cập một tham chiếu được lưu trữ trong khung ngăn xếp của chính nó, có thể là tham chiếu đến một thứ gì đó trên heap, trong bộ nhớ tĩnh hoặc xa hơn nữa trong ngăn xếp.

Đây là một phần của sự trừu tượng hóa ngăn xếp làm cho các hàm có thể gọi theo bất kỳ thứ tự nào và cho phép đệ quy. Khung ngăn xếp trên cùng là đối tượng duy nhất được mã truy cập trực tiếp. Mọi thứ khác đều được truy cập gián tiếp (thông qua một con trỏ nằm trong khung ngăn xếp trên cùng).

Việc xem xét lắp ráp chương trình nhỏ của bạn có thể mang tính hướng dẫn, đặc biệt nếu bạn biên dịch mà không tối ưu hóa. Tôi nghĩ rằng bạn sẽ thấy rằng tất cả việc truy cập bộ nhớ trong hàm của bạn xảy ra thông qua một độ lệch từ con trỏ khung ngăn xếp, đó là cách mã cho hàm sẽ được trình biên dịch viết. Trong trường hợp truyền theo tham chiếu, bạn sẽ thấy các lệnh truy cập bộ nhớ gián tiếp thông qua một con trỏ được lưu trữ tại một số điểm bù so với con trỏ khung ngăn xếp.


4

Ngăn xếp cuộc gọi không thực sự là một cấu trúc dữ liệu ngăn xếp. Đằng sau hậu trường, các máy tính chúng tôi sử dụng là triển khai của kiến ​​trúc máy truy cập ngẫu nhiên. Vì vậy, a và b có thể được truy cập trực tiếp.

Phía sau hậu trường, máy làm:

  • lấy "a" bằng cách đọc giá trị của phần tử thứ tư bên dưới đỉnh ngăn xếp.
  • lấy "b" bằng việc đọc giá trị của phần tử thứ ba bên dưới đỉnh ngăn xếp.

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


1

Đây là sơ đồ tôi đã tạo cho ngăn xếp cuộc gọi của C. Nó chính xác và hiện đại hơn các phiên bản hình ảnh của google

nhập mô tả hình ảnh ở đây

Và tương ứng với cấu trúc chính xác của sơ đồ trên, đây là phần gỡ lỗi của notepad.exe x64 trên windows 7.

nhập mô tả hình ảnh ở đây

Các địa chỉ thấp và địa chỉ cao được hoán đổi để ngăn xếp tăng dần lên trong sơ đồ này. Màu đỏ cho biết khung chính xác như trong sơ đồ đầu tiên (sử dụng màu đỏ và đen, nhưng màu đen hiện đã được thay thế); màu đen là không gian nhà; màu xanh lam là địa chỉ trả về, là một phần bù vào hàm người gọi đối với lệnh sau cuộc gọi; màu cam là căn chỉnh và màu hồng là nơi con trỏ hướng dẫn trỏ ngay sau lệnh gọi và trước lệnh đầu tiên. Không gian nhà + giá trị trả về là khung nhỏ nhất được phép trên windows và vì căn chỉnh rsp 16 byte ngay khi bắt đầu hàm được gọi phải được duy trì, điều này cũng luôn bao gồm căn chỉnh 8 byte.BaseThreadInitThunk và như thế.

Các khung hàm màu đỏ phác thảo những gì hàm callee 'sở hữu' + đọc / sửa đổi một cách hợp lý (nó có thể sửa đổi một tham số được truyền trên ngăn xếp quá lớn để truyền vào một thanh ghi trên -Ofast). Các đường màu xanh lá cây phân giới không gian mà hàm tự phân bổ từ đầu đến cuối của hàm.


RDI và các args thanh ghi khác chỉ bị tràn vào ngăn xếp nếu bạn biên dịch ở chế độ gỡ lỗi và không có gì đảm bảo rằng một biên dịch chọn thứ tự đó. Ngoài ra, tại sao các args ngăn xếp không được hiển thị ở đầu sơ đồ cho lệnh gọi hàm cũ nhất? Không có ranh giới rõ ràng trong sơ đồ của bạn giữa khung nào "sở hữu" dữ liệu nào. (Một callee sở hữu các args ngăn xếp của nó). Việc bỏ qua các args ngăn xếp từ trên cùng của sơ đồ thậm chí còn khó thấy rằng "các tham số không thể được truyền trong các thanh ghi" luôn nằm ngay trên địa chỉ trả về của mọi hàm.
Peter Cordes

@PeterCordes goldbolt asm đầu ra hiển thị tiếng kêu và gcc đẩy một tham số được truyền trong một thanh ghi vào ngăn xếp như hành vi mặc định, vì vậy nó có một địa chỉ. Trên gcc sử dụng registertham số đằng sau sẽ tối ưu hóa điều này, nhưng bạn nghĩ rằng dù sao đi nữa thì điều đó cũng sẽ được tối ưu hóa vì địa chỉ không bao giờ được sử dụng trong hàm. Tôi sẽ sửa khung trên cùng; phải thừa nhận rằng tôi nên đặt dấu chấm lửng trong một khung trống riêng biệt. 'a callee sở hữu các args ngăn xếp của nó', bao gồm cả những cái mà người gọi đẩy nếu chúng không thể được chuyển vào sổ đăng ký?
Lewis Kelsey

Vâng, nếu bạn biên dịch với tính năng tối ưu hóa bị vô hiệu hóa, callee sẽ tràn ra đâu đó. Nhưng không giống như vị trí của các args ngăn xếp (và được cho là đã lưu-RBP), không có gì được chuẩn hóa về vị trí. Re: callee sở hữu các args ngăn xếp của nó: vâng, các hàm được phép sửa đổi các args đến của chúng. Các args reg mà nó tự đổ ra không phải là stack args. Các trình biên dịch đôi khi làm điều này, nhưng IIRC thường họ lãng phí không gian ngăn xếp bằng cách sử dụng không gian bên dưới địa chỉ trả về ngay cả khi họ không bao giờ đọc lại đối số. Nếu một người gọi muốn thực hiện một cuộc gọi khác với cùng một args, để an toàn, họ phải lưu trữ một bản sao khác trước khi lặp lạicall
Peter Cordes

@PeterCordes Chà, tôi đã đặt các đối số là một phần của ngăn xếp người gọi vì tôi đang phân định ranh giới các khung ngăn xếp dựa trên điểm rbp. Một số sơ đồ hiển thị điều này như một phần của ngăn xếp callee (như sơ đồ đầu tiên trong câu hỏi này) và một số hiển thị nó như là một phần của ngăn xếp người gọi, nhưng có lẽ sẽ hợp lý khi biến chúng thành một phần của ngăn xếp callee được coi là phạm vi tham số người gọi không thể truy cập bằng mã cấp cao hơn. Vâng, có vẻ như registervà sự consttối ưu chỉ tạo ra sự khác biệt trên -O0.
Lewis Kelsey

@PeterCordes Tôi đã thay đổi nó. Tuy nhiên, tôi có thể thay đổi nó một lần nữa
Lewis Kelsey
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.