사전 스택 프레임
개념

스택 프레임

gabury1고친 사람 github-actions[bot]

스택 프레임은 함수를 한 번 부를 때마다 그 호출만 쓰는 작업 공간을 따로 떼어 줍니다. 함수가 받은 값과 함수 안에서 만든 값, 그리고 끝난 뒤 돌아갈 곳이 여기에 들어갑니다. 함수가 끝나면 이 공간은 사라집니다.

쉽고 빠른 이해

무슨 일을 하는 물건인가 — 함수 호출 하나가 쓸 값을 한 칸에 모아 둡니다. 두 수를 더하는 함수를 부르면 그 두 수와 더한 결과가 이 칸에 들어갑니다.

왜 이렇게 하나 — 호출마다 제 칸이 없으면 같은 함수를 두 번 불렀을 때 두 호출의 값이 서로 덮어씁니다. 함수가 끝나고 어디로 돌아가야 하는지도 적어 둘 곳이 필요합니다.

어떻게 도나

  1. 함수를 부르면 더미 맨 위에 칸을 하나 얹습니다
  2. 그 함수가 도는 동안 값은 그 칸 안에서만 읽고 씁니다
  3. 함수가 끝나면 칸을 치우고 적어 둔 곳으로 돌아갑니다

대가 — 칸을 쌓을 공간에는 끝이 있습니다. 호출이 너무 깊어지면 더 쌓을 곳이 없어 프로그램이 멈춥니다. 칸이 사라진 뒤에도 그 안을 가리키면 이미 없는 값을 읽게 됩니다.

상세

책을 읽다 모르는 것이 나오면 그 책을 펼친 채 두고 다른 책을 꺼내 그 위에 얹습니다. 아래 책들은 펼친 자리 그대로 멈춰 있습니다. 손이 닿는 것은 맨 위 한 권뿐이어서, 그 책을 다 보고 치워야 아래 책의 멈춰 둔 자리로 돌아갑니다.

함수를 한 번 부르면 그 호출이 쓸 값을 둘 공간이 필요합니다. 그 공간 한 칸이 스택 프레임입니다. 스택에 쌓이기 때문에 이 이름이 붙었고, 그냥 프레임이나 호출 프레임이라고도 부릅니다.

프레임은 함수가 시작할 때 생기고 끝날 때 없어집니다. 그래서 프레임 안의 값은 그 호출이 도는 동안만 삽니다.

호출마다 공간을 따로 잡는 까닭은 하나입니다. 한 공간을 같이 쓰면 먼저 부른 호출이 적어 둔 값을 나중 호출이 덮어씁니다. 그러면 안쪽 함수가 끝나 돌아왔을 때 바깥 함수가 쥐고 있던 값이 이미 바뀌어 있습니다.

프레임에 담기는 것

프레임 하나에는 그 호출이 혼자 쓰는 값들이 들어갑니다. 대개 다섯 가지입니다.

담기는 것 무엇에 쓰나
인자 부른 쪽이 넘긴 값
지역 변수 함수 안에서 만들어 쓰는 값
중간 계산값 식을 계산하는 동안 잠깐 쥐는 값
반환 주소 함수가 끝나면 이어서 돌 곳
아래 프레임의 시작점 아래 프레임으로 되돌아갈 기준

이 가운데 반환 주소가 프레임을 단순한 변수 보관함과 가릅니다. 부른 쪽의 주소를 프레임이 들고 있어서, 함수가 끝났을 때 프로그램이 어디로 돌아갈지 스스로 알 수 있습니다.

인자와 중간 계산값을 레지스터에 두고 프레임을 안 쓰는 경우도 많습니다. 레지스터는 프로세서 안에 있는 작은 저장 칸이고, 메모리를 거치지 않고 바로 읽고 씁니다. 다만 개수가 적어서 값이 많아지면 프레임으로 밀려납니다.

무엇을 레지스터에 두고 무엇을 프레임에 둘지는 호출 규약이 정합니다. 호출 규약은 부르는 쪽과 불리는 쪽이 미리 맞춰 둔 약속입니다. 인자를 어디에 놓을지, 결과를 어디로 돌려줄지, 프레임을 누가 치울지를 이 약속이 정합니다.

프레임이 쌓이고 빠지는 순서

함수가 또 함수를 부르면 프레임이 하나씩 위로 쌓입니다. 이렇게 프레임이 쌓인 더미가 호출 스택입니다.

아래는 main 이 handleOrder 를 부르고, handleOrder 가 compute 를 부른 뒤의 모습입니다. 프레임 옆의 이름은 그 함수가 쓰는 값입니다.

flowchart TD
    subgraph S["호출 스택 · 위가 지금 도는 함수"]
        F3["compute 의 프레임 · a · b · sum"]
        F2["handleOrder 의 프레임 · order · total"]
        F1["main 의 프레임 · args"]
        F3 --- F2 --- F1
    end
    F3 -->|compute 가 끝나면| X["프레임이 빠지고 handleOrder 가 이어서 돈다"]

맨 위 프레임의 함수만 지금 돌고 있습니다. 아래 프레임들은 위 함수가 끝나기를 기다립니다. 나중에 부른 함수가 먼저 끝나므로 쌓고 빼는 순서가 스택과 맞아떨어집니다.

프레임이 빠질 때 그 안의 값이 지워지지는 않는 것이 보통입니다. 다음 호출이 같은 공간을 다시 쓰면서 덮어쓸 뿐입니다. 그래서 없어진 프레임을 가리키는 값은 한동안 멀쩡해 보이다가 어느 순간 엉뚱한 것을 내놓습니다.

변수를 찾는 기준점

프로그램은 프레임 안의 변수를 이름으로 찾지 않습니다. 프레임이 시작하는 곳에서 몇 칸 떨어졌는지로 찾습니다. 그 시작점을 들고 있는 것이 프레임 포인터입니다.

더미의 맨 위가 어디인지는 스택 포인터가 가리킵니다. 프레임 포인터는 지금 도는 프레임의 시작을, 스택 포인터는 쌓인 더미의 끝을 가리킵니다.

두 수를 더해 돌려주는 함수를 봅시다.

C
int compute(int a, int b) {
    int sum = a + b;   // sum 은 이 프레임 안에 산다
    return sum;        // 값을 넘기고 프레임이 빠진다
}

a 와 b 와 sum 셋은 전부 이 호출의 프레임 안에 있습니다. 같은 함수를 스레드 둘에서 동시에 부르면 프레임도 둘 생기고, 두 sum 은 서로 다른 칸을 씁니다. 스레드는 한 프로그램 안에서 따로 도는 실행 흐름 하나입니다.

프레임에 두면 안 되는 값

프레임은 함수가 끝나면 없어집니다. 그러니 호출이 끝난 뒤에도 남아야 하는 값은 프레임에 두면 안 됩니다. 그런 값은 힙에 둡니다.

힙은 프로그램이 필요할 때 얻고 다 쓰면 돌려주는 메모리입니다. 함수가 끝나는 것과 수명이 묶여 있지 않아서, 부른 쪽에 넘겨 주고 나서도 값이 남습니다.

지역 변수의 주소를 함수 밖으로 돌려주는 것이 이 실수의 대표입니다. 받은 쪽은 이미 없어진 프레임을 가리키는 주소를 쥐게 됩니다. 가비지 컬렉션이 있는 언어는 객체를 힙에 두고 참조가 남아 있는 동안 살려 두므로 이 실수가 잘 안 납니다.

너무 깊이 쌓였을 때

스택이 쓸 수 있는 메모리에는 정해진 끝이 있습니다. 프레임을 계속 쌓다 그 끝을 넘으면 프로그램이 멈춥니다. 이 오류가 스택 오버플로입니다.

재귀는 함수가 자기 자신을 다시 부르는 것입니다. 부를 때마다 프레임이 하나씩 더 쌓이므로, 멈추는 조건이 잘못되면 금세 끝에 닿습니다.

스레드는 저마다 스택을 따로 가집니다. 프레임이 섞이지 않는 대신, 스레드를 많이 띄우는 서버에서는 스택 크기가 곧 메모리 사용량이 됩니다.

쌓인 프레임을 읽는 도구

예외가 났을 때 찍히는 스택 트레이스는 그때 쌓여 있던 프레임을 위에서 아래로 적은 것입니다. 한 줄이 프레임 하나입니다. 맨 윗줄이 오류가 난 함수이고, 아래로 갈수록 그 함수를 부른 쪽입니다.

디버거는 멈춘 지점에서 프레임을 하나씩 오르내리며 그 안의 값을 보여 줍니다. 프로파일러는 짧은 간격으로 호출 스택을 들여다보고 어느 함수가 오래 잡고 있었는지 셉니다.

두 도구가 같은 것을 읽습니다. 프레임이 들고 있는 반환 주소와 아래 프레임의 시작점을 따라 한 칸씩 내려가면 호출한 순서가 그대로 나옵니다.

프레임이 생기지 않는 호출

컴파일러는 프레임을 아예 안 만드는 쪽을 고르기도 합니다. 함수가 아주 짧으면 그 내용을 부른 쪽에 펼쳐 넣습니다. 이것이 인라이닝입니다. 펼쳐 넣은 코드에는 호출이 없으니 프레임도 없습니다.

꼬리 호출은 함수가 마지막으로 하는 일이 다른 함수를 부르는 것뿐인 경우입니다. 돌아와서 할 일이 없으므로 지금 프레임을 새 호출에 그대로 넘겨 쓸 수 있습니다.

이렇게 바꿔 주는 것을 꼬리 호출 최적화라고 부릅니다. 언어와 컴파일러에 따라 해 주기도 하고 안 해 주기도 하므로, 깊은 재귀가 안전한지는 쓰는 쪽에서 확인해야 합니다.

가상 머신과 코루틴의 프레임

프레임은 프로세서가 직접 다루는 것만 있는 게 아닙니다. JVM(Java Virtual Machine, 자바 가상 머신) 같은 실행 환경도 메서드를 부를 때마다 제 프레임을 만듭니다. 자바에서 호출이 너무 깊어질 때 나오는 StackOverflowError 가 그 프레임이 넘친 것입니다.

코루틴처럼 중간에 멈췄다 나중에 이어서 도는 것은 프레임을 스택에만 둘 수 없습니다. 멈춰 있는 동안에도 그 값들이 살아 있어야 하기 때문입니다.

그래서 실행 환경이 프레임에 해당하는 것을 힙에 따로 보관해 두었다가, 다시 돌 때 스택에 되살립니다. 멈춘 함수가 수천 개 있어도 스택이 안 넘치는 까닭이 이것입니다.

관련 항목

스택 프레임을 이루는 구성 요소

반환 주소 · 매개변수 · 지역 변수 · 프레임 포인터 · 스택 포인터 · 저장 레지스터

스택 프레임이 놓이는 메모리 구조

스택 · 호출 스택 · 힙 · 메모리 · 가상 메모리 · 스레드 · 프로세스

프레임 모양을 정하는 약속과 표준

호출 규약 · ABI · 함수 프롤로그 · 함수 에필로그 · 스택 정렬 · 어셈블리 · x86

프레임에서 비롯되는 오류

스택 오버플로 · 무한 재귀 · 버퍼 오버플로 · 스택 스매싱 · 허상 포인터 · 세그멘테이션 폴트

쌓인 프레임을 읽어 보여 주는 도구

스택 트레이스 · 백트레이스 · 디버거 · 프로파일러 · 플레임 그래프 · 코어 덤프

프레임을 줄이거나 없애는 최적화

인라이닝 · 꼬리 호출 · 프레임 포인터 생략 · 레지스터 할당 · 컴파일러

프레임을 제 방식으로 다루는 실행 환경

JVM · 인터프리터 · 코루틴 · 그린렛 · 가비지 컬렉션 · 비동기 함수

한 번의 호출이 거치는 처리 단계

함수 · 재귀 · 예외 처리 · 문맥 교환 · 시스템 콜

다른 이름: stack frame · 호출 프레임