[assembly] 콜 스택은 정확히 어떻게 작동합니까?

프로그래밍 언어의 저수준 작업이 어떻게 작동하는지, 특히 OS / CPU와 상호 작용하는 방법에 대해 더 깊이 이해하려고합니다. 나는 아마도 스택 오버플로의 모든 스택 / 힙 관련 스레드에서 모든 답변을 읽었으며 모두 훌륭합니다. 하지만 아직 완전히 이해하지 못한 것이 하나 있습니다.

유효한 Rust 코드 인 의사 코드에서이 함수를 고려하십시오 😉

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 행 뒤에 무슨 일이 일어날까요? 분명하기 때문에, 다음 일이 우리의 필요와 작업하는 것입니다 ab하지만은 OS / CPU가 (?) 튀어한다는 것을 의미 d하고 c처음으로 돌아 가야 a하고 b. 이 필요하기 때문에 그러나 그것은 발에 자신을 쏠 것 cd다음 줄에.

그래서 뒤에서 정확히 무슨 일이 일어나는지 궁금 합니다.

또 다른 관련 질문입니다. 다음과 같은 다른 함수 중 하나에 대한 참조를 전달한다고 가정합니다.

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

    // line X

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

내가 일을 이해하는 방법에서, 이것은의 매개 변수는 것을 의미 doSomething본질적으로 같은 동일한 메모리 주소를 가리키는 abfoo. 하지만 다시이 방법이 더 있다는 것을 우리가 얻을 때까지 스택을 팝업하지 ab 일어날 .

이 두 가지 경우 는 스택이 정확히 작동하는 방식과 LIFO 규칙을 엄격하게 따르는 방식을 완전히 이해하지 못했다고 생각 하게합니다 .



답변

호출 스택은 프레임 스택이라고도합니다. LIFO 원칙 이후
스택 되는 것은 지역 변수가 아니라 호출되는 함수의 전체 스택 프레임 ( “호출”)입니다 . 지역 변수는 소위 함수 프롤로그에필로그 에서 해당 프레임과 함께 푸시 및 팝됩니다. 각각 .

프레임 내에서 변수의 순서는 완전히 지정되지 않습니다. 컴파일러 는 프레임 내에서 지역 변수의 위치를 적절하게 “재정렬”하여 정렬을 최적화하여 프로세서가 최대한 빨리 가져올 수 있도록합니다. 중요한 사실은 일부 고정 주소에 대한 변수의 오프셋이 프레임의 수명 내내 일정하다는 것입니다. 따라서 프레임 자체의 주소와 같은 앵커 주소를 가져 와서 해당 주소의 오프셋을 사용하여 작업하는 것으로 충분합니다. 변수. 이러한 앵커 주소는 실제로 소위 기본 또는 프레임 포인터에 포함되어 있습니다.EBP 레지스터에 저장됩니다. 반면 오프셋은 컴파일 타임에 명확하게 알려져 있으므로 기계 코드에 하드 코딩됩니다.

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

.. for main. 코드를 세 개의 하위 섹션으로 나누었습니다. 함수 프롤로그는 처음 세 가지 작업으로 구성됩니다.

  • 기본 포인터가 스택으로 푸시됩니다.
  • 스택 포인터는 기본 포인터에 저장됩니다.
  • 스택 포인터는 지역 변수를위한 공간을 만들기 위해 뺍니다.

이어서 cin레지스터 EDI로 이동 2get호출; 반환 값은 EAX입니다.

여태까지는 그런대로 잘됐다. 이제 흥미로운 일이 발생합니다.

8 비트 레지스터 AL로 지정된 EAX의 하위 바이트를 취해 기본 포인터 바로 뒤의 바이트에 저장합니다 . 즉 , 기본 포인터-1(%rbp) 의 오프셋은입니다 -1. 이 바이트는 우리의 변수c 입니다. x86에서 스택이 아래쪽으로 커지기 때문에 오프셋은 음수입니다. 다음 작업 저장 cEAX에서는 : ESI EAX가 이동되어, cout함께 EDI로 이동하고, 삽입 조작을 호출 cout하고 c인자 인.

드디어,

  • 의 반환 값은 mainEAX : 0에 저장됩니다. 이는 암시 적 return문 때문입니다 . xorl rax rax대신을 볼 수도 있습니다 movl.
  • 나가고 콜 사이트로 돌아갑니다. leave이 에필로그를 축약하고 암시 적으로
    • 스택 포인터를 기본 포인터로 교체하고
    • 기본 포인터를 팝합니다.

이 작업을 ret수행하고 수행 한 후에 는 프레임이 효과적으로 팝되었지만 호출자는 cdecl 호출 규칙을 사용하므로 인수를 여전히 정리해야합니다. stdcall과 같은 다른 규칙에서는 호출 수신자가 바이트 양을에 전달하여 정리해야합니다 ret.

프레임 포인터 생략

기본 / 프레임 포인터의 오프셋을 사용하지 않고 대신 스택 포인터 (ESB)의 오프셋을 사용하는 것도 가능합니다. 이로 인해 프레임 포인터 값을 포함하는 EBP 레지스터는 임의적으로 사용할 수 있지만 일부 시스템 에서는 디버깅을 불가능 하게 만들 수 있으며 일부 기능에 대해서는 암시 적으로 해제됩니다 . x86을 포함하여 레지스터가 거의없는 프로세서를 컴파일 할 때 특히 유용합니다.

이 최적화를 FPO (프레임 포인터 생략)라고하며 -fomit-frame-pointerGCC 및 -OyClang 에서 설정합니다 . 디버깅이 여전히 가능한 경우에만 모든 최적화 수준> 0에 의해 암시 적으로 트리거됩니다. 그와는 별도로 비용이 들지 않기 때문입니다. 자세한 내용은 여기여기를 참조 하십시오 .


1 주석에서 지적했듯이 프레임 포인터는 아마도 반환 주소 뒤의 주소를 가리키는 것을 의미합니다.

2 R로 시작하는 레지스터는 E로 시작하는 레지스터의 64 비트 대응 요소입니다. EAX는 RAX의 하위 4 바이트를 지정합니다. 명확성을 위해 32 비트 레지스터의 이름을 사용했습니다.


답변

분명히 다음으로 필요한 것은 a와 b로 작업하는 것이지만 이는 OS / CPU (?)가 a와 b로 돌아가려면 먼저 d와 c를 튀어 나와야 함을 의미합니다. 그러나 다음 줄에 c와 d가 필요하기 때문에 발에 스스로 쏠 것입니다.

요컨대 :

인수를 팝할 필요가 없습니다. foo함수 호출자 에 의해 전달 된 인수 doSomething와의 지역 변수 doSomething 는 모두 기본 포인터 의 오프셋으로 참조 될 수 있습니다 .
그래서,

  • 함수 호출이 이루어지면 함수의 인수가 스택에 PUSH됩니다. 이러한 인수는 기본 포인터에 의해 추가로 참조됩니다.
  • 함수가 호출자에게 반환되면 반환 함수의 인수가 LIFO 메서드를 사용하여 스택에서 POP됩니다.

상세히:

규칙은 각 함수 호출 결과 스택 프레임이 생성 된다는 것입니다 (최소값은 반환 할 주소 임). 그래서, 경우에 funcA호출 funcBfuncB통화 funcC, 세 스택 프레임은 또 다른 하나의 위에 설정됩니다. 함수가 반환되면 해당 프레임이 유효하지 않게 됩니다. 잘 작동하는 함수는 자체 스택 프레임에서만 작동하며 다른 프레임에서는 침입하지 않습니다. 즉, POPing은 상단의 스택 프레임에 수행됩니다 (함수에서 복귀 할 때).

여기에 이미지 설명 입력

질문의 스택은 호출자에 의해 설정됩니다 foo. doSomethingdoAnotherThing호출 되면 자체 스택을 설정합니다. 그림은이를 이해하는 데 도움이 될 수 있습니다.

여기에 이미지 설명 입력

그 주 (기능 체 기능 체는 스택을 통과 할 것이며, 리턴 어드레스가 저장되어있는 위치에서 (상위 어드레스) 아래로 통과 할 수있을 것이며, 로컬 변수를 액세스하기 위해, 하위 어드레스를 인수 액세스 할 ) 반송 주소가 저장된 위치에 상대적. 사실, 함수에 대한 일반적인 컴파일러 생성 코드는 정확히이 작업을 수행합니다. 컴파일러는이를 위해 EBP라는 레지스터를 지정합니다 (Base Pointer). 동일한 다른 이름은 프레임 포인터입니다. 컴파일러는 일반적으로 함수 본문에 대한 첫 번째 작업으로 현재 EBP 값을 스택에 푸시하고 EBP를 현재 ESP로 설정합니다. 즉,이 작업이 완료되면 함수 코드의 어느 부분에서든 인수 1은 EBP + 8 (호출자의 EBP 및 반환 주소 각각에 대해 4 바이트), 인수 2는 EBP + 12 (10 진수) 거리, 지역 변수입니다. 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

함수의 어셈블리 코드는 다음과 같습니다 (반환하기 전에 호출 수신자가 설정).

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


참조 :


답변

다른 사람들이 언급했듯이 매개 변수가 범위를 벗어날 때까지 매개 변수를 팝할 필요가 없습니다.

Nick Parlante의 “Pointers and Memory”에서 몇 가지 예를 붙여 넣겠습니다. 상황은 당신이 생각했던 것보다 조금 더 간단하다고 생각합니다.

다음은 코드입니다.

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. 코드에 표시되고 그 당시의 메모리 상태가 그림에 표시됩니다.

여기에 이미지 설명 입력


답변

다른 프로세서와 언어는 몇 가지 다른 스택 디자인을 사용합니다. 8×86 및 68000의 두 가지 전통적인 패턴을 Pascal 호출 규칙과 C 호출 규칙이라고합니다. 각 규칙은 레지스터 이름을 제외하고 두 프로세서에서 동일한 방식으로 처리됩니다. 각각은 스택 포인터 (SP 또는 A7) 및 프레임 포인터 (BP 또는 A6)라고하는 스택 및 관련 변수를 관리하기 위해 두 개의 레지스터를 사용합니다.

두 규칙 중 하나를 사용하여 서브 루틴을 호출 할 때 루틴을 호출하기 전에 모든 매개 변수가 스택에 푸시됩니다. 그런 다음 루틴의 코드는 프레임 포인터의 현재 값을 스택에 푸시하고 스택 포인터의 현재 값을 프레임 포인터에 복사 한 다음 스택 포인터에서 로컬 변수가 사용하는 바이트 수를 뺍니다. 이 작업이 완료되면 추가 데이터가 스택에 푸시 되더라도 모든 로컬 변수는 스택 포인터에서 일정한 음의 변위를 사용하여 변수에 저장되고 호출자가 스택에 푸시 한 모든 매개 변수에 액세스 할 수 있습니다. 프레임 포인터에서 일정한 양의 변위.

두 규칙의 차이점은 서브 루틴에서 종료를 처리하는 방식에 있습니다. C 규칙에서 반환 함수는 프레임 포인터를 스택 포인터에 복사하고 [이전 프레임 포인터를 누른 직후의 값으로 복원], 이전 프레임 포인터 값을 팝하고 반환을 수행합니다. 호출하기 전에 호출자가 스택에 푸시 한 모든 매개 변수는 그대로 유지됩니다. Pascal 규칙에서 이전 프레임 포인터를 팝한 후 프로세서는 함수 반환 주소를 팝하고 호출자가 푸시 한 매개 변수의 바이트 수를 스택 포인터에 추가 한 다음 팝된 반환 주소로 이동합니다. 원래 68000에서는 호출자의 매개 변수를 제거하기 위해 3 개의 명령어 시퀀스를 사용해야했습니다. 8×86 및 원본 이후의 모든 680×0 프로세서에는 “ret N”이 포함되었습니다.

Pascal 규칙은 호출자가 함수 호출 후 스택 포인터를 업데이트 할 필요가 없기 때문에 호출자 측에서 약간의 코드를 절약 할 수 있다는 장점이 있습니다. 그러나 호출 된 함수는 호출자가 스택에 넣을 매개 변수의 바이트 수를 정확히 알고 있어야합니다. Pascal 규칙을 사용하는 함수를 호출하기 전에 적절한 수의 매개 변수를 스택에 푸시하지 못하면 충돌이 발생할 가능성이 거의 보장됩니다. 그러나 이것은 호출 된 각 메서드 내의 약간의 추가 코드가 메서드가 호출되는 위치에 코드를 저장한다는 사실로 인해 상쇄됩니다. 이러한 이유로 대부분의 원래 Macintosh 도구 상자 루틴은 Pascal 호출 규칙을 사용했습니다.

C 호출 규칙은 루틴이 가변 개수의 매개 변수를 허용하고 루틴이 전달 된 모든 매개 변수를 사용하지 않더라도 견고하다는 장점이 있습니다 (호출자는 푸시 한 매개 변수의 바이트 수를 알 수 있습니다. 따라서 정리할 수 있습니다). 또한 모든 함수 호출 후 스택 정리를 수행 할 필요가 없습니다. 루틴이 4 개의 함수를 순서대로 호출하는 경우 각각 4 바이트에 해당하는 매개 변수를 사용하는 경우 ADD SP,4, 각 호출 후 를 사용하는 대신 ADD SP,16마지막 호출 이후에 하나씩 사용하여 4 개의 호출 모두에서 매개 변수를 정리할 수 있습니다.

오늘날 설명 된 호출 규칙은 다소 구식으로 간주됩니다. 컴파일러는 레지스터 사용에서 더 효율적이 되었기 때문에 모든 매개 변수를 스택에 푸시 할 것을 요구하는 것보다 메소드가 레지스터에서 몇 개의 매개 변수를 받아들이도록하는 것이 일반적입니다. 메서드가 레지스터를 사용하여 모든 매개 변수와 지역 변수를 보유 할 수 있다면 프레임 포인터를 사용할 필요가 없으므로 이전 포인터를 저장하고 복원 할 필요가 없습니다. 그럼에도 불구하고 라이브러리를 사용하기 위해 링크 된 라이브러리를 호출 할 때 이전 호출 규칙을 사용해야하는 경우가 있습니다.


답변

이미 여기에 정말 좋은 답변이 있습니다. 그러나 스택의 LIFO 동작에 대해 여전히 염려한다면 변수 스택이 아닌 프레임 스택으로 생각하십시오. 제가 제안하고자하는 것은 함수가 스택의 맨 위에 있지 않은 변수에 액세스 할 수 있지만 여전히 스택 맨 위에있는 항목 ( 단일 스택 프레임) 에서만 작동한다는 것입니다 .

물론 여기에는 예외가 있습니다. 전체 콜 체인의 로컬 변수는 여전히 할당되고 사용 가능합니다. 그러나 직접 액세스 할 수는 없습니다. 대신 참조 (또는 실제로 의미 상 다른 포인터)에 의해 전달됩니다. 이 경우 훨씬 더 아래에있는 스택 프레임의 로컬 변수에 액세스 할 수 있습니다. 그러나이 경우에도 현재 실행중인 함수는 자체 로컬 데이터에서만 작동합니다. 힙, 정적 메모리 또는 스택 아래에있는 항목에 대한 참조 일 수있는 자체 스택 프레임에 저장된 참조에 액세스합니다.

이것은 함수를 임의의 순서로 호출 가능하게 만들고 재귀를 허용하는 스택 추상화의 일부입니다. 최상위 스택 프레임은 코드에서 직접 액세스하는 유일한 개체입니다. 다른 것은 간접적으로 (맨 위 스택 프레임에있는 포인터를 통해) 액세스됩니다.

특히 최적화없이 컴파일하는 경우 작은 프로그램의 어셈블리를 살펴 보는 것이 유익 할 수 있습니다. 함수의 모든 메모리 액세스는 컴파일러가 함수에 대한 코드를 작성하는 방법 인 스택 프레임 포인터의 오프셋을 통해 발생한다는 것을 알 수 있습니다. 참조에 의한 전달의 경우 스택 프레임 포인터에서 일부 오프셋에 저장된 포인터를 통해 간접 메모리 액세스 명령을 볼 수 있습니다.


답변

호출 스택은 실제로 스택 데이터 구조가 아닙니다. 이면에서 우리가 사용하는 컴퓨터는 랜덤 액세스 머신 아키텍처의 구현입니다. 따라서 a와 b에 직접 액세스 할 수 있습니다.

이면에서 기계는 다음을 수행합니다.

  • get “a”는 스택 맨 아래 네 번째 요소의 값을 읽는 것과 같습니다.
  • get “b”는 스택 맨 아래 세 번째 요소의 값을 읽는 것과 같습니다.

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


답변

다음은 C의 호출 스택을 위해 만든 다이어그램입니다. Google 이미지 버전보다 더 정확하고 현대적입니다.

여기에 이미지 설명 입력

그리고 위 다이어그램의 정확한 구조에 따라 Windows 7에서 notepad.exe x64의 디버그가 있습니다.

여기에 이미지 설명 입력

하위 주소와 상위 주소가 스왑되어 스택이이 다이어그램에서 위로 올라갑니다. 빨간색은 첫 번째 다이어그램과 똑같은 프레임을 나타냅니다 (빨간색과 검정색을 사용했지만 이제 검정색이 용도가 변경되었습니다). 검은 색은 가정 공간입니다. 파란색은 반환 주소로, 호출 후 명령에 대한 호출자 함수의 오프셋입니다. 주황색은 정렬이고 분홍색은 명령 포인터가 호출 직후와 첫 번째 명령 이전을 가리키는 위치입니다. homespace + return 값은 창에서 허용되는 가장 작은 프레임이며 호출 된 함수의 시작 부분에서 16 바이트 rsp 정렬이 유지되어야하므로 항상 8 바이트 정렬도 포함됩니다.BaseThreadInitThunk 등등.

빨간색 함수 프레임은 피 호출자 함수가 논리적으로 ‘소유’하고 읽고 / 수정하는 내용을 설명합니다 (-Ofast의 레지스터에 전달하기에는 너무 큰 스택에 전달 된 매개 변수를 수정할 수 있음). 녹색 선은 함수가 함수의 시작부터 끝까지 할당하는 공간을 구분합니다.