스택 프레임 (Stack Frame)

Jaemyeong Lee·2024년 8월 2일

게임 서버1

목록 보기
23/220

이 Step에서 다루는 것

  • 스택 프레임이 “함수 1번 호출의 작업 공간”이라는 말의 정확한 의미
  • 반환 주소/매개변수/지역 변수가 스택에 어떤 구조로 묶이는지
  • 변수 주소가 “고정 주소”가 아니라 기준점(BP/FP) + 오프셋으로 계산된다는 감각
  • 32bit/64bit에서 인자 전달이 달라질 수 있는 이유(개념)
  • 디버그에서 보이는 “패턴 값”과 카나리(스택 쿠키)를 정확히 구분

학습 목표

  • 스택 프레임 구성 요소를 3개 이상 말할 수 있다.
  • “지역 변수가 함수 종료와 함께 사라지는 이유”를 스택 프레임 관점으로 설명할 수 있다.

스택 프레임이란?

  • 함수 호출 1회분의 정보를 스택에 모아둔 묶음(프레임) 입니다.
  • 함수가 호출되면 프레임이 생기고, 함수가 끝나면(리턴) 프레임이 반납됩니다.

스택 프레임에는 보통 이런 종류의 정보가 들어갑니다. (컴파일러/옵션에 따라 조금씩 달라질 수 있음)

┌────────────────────────────────────────┐
│ (상황에 따라) 인자/임시값               │  ← 스택 또는 레지스터
├────────────────────────────────────────┤
│ 반환 주소(Return Address)               │  ← 끝나면 어디로 돌아갈지
├────────────────────────────────────────┤
│ 저장된 이전 프레임 포인터(BP/FP)        │  ← 기준점(개념)
├────────────────────────────────────────┤
│ 지역 변수(Local) / 임시값 / 정렬 패딩    │
│ (필요 시) 저장된 레지스터(Spill)         │
└────────────────────────────────────────┘
  • 핵심 결론: 지역 변수의 생존 기간은 보통 “그 함수의 스택 프레임이 살아있는 동안”입니다.

스택 프레임 생성 예제

int Test(int a, int b) {
    int c = a + b;
    return c;
}

int main() {
    Test(10, 20);
}
  • main()이 실행 중 Test(10, 20)을 호출하면, 스택에는 “Test 호출 1회분” 프레임이 추가됩니다.
  • 그 프레임 안에서 a, b, c가 잠깐 쓰이고, return을 만나면 프레임이 통째로 반납됩니다.

호출 흐름을 스택으로 잡으면 이런 느낌입니다.

main() 실행 중
┌──────────────────────┐
│ main() 프레임         │
└──────────────────────┘

Test(10, 20) 호출
┌──────────────────────┐
│ main() 프레임         │
├──────────────────────┤
│ Test() 프레임         │  a=10, b=20, c=30 ...
└──────────────────────┘

Test() 종료(리턴) → Test() 프레임 반납 → main()으로 복귀

정리하면:

  • 매개변수/지역 변수는 보통 해당 함수의 스택 프레임 내부에 존재합니다.
  • 함수가 끝나면 프레임이 반납되므로, 그 안의 값들도 “유효하지 않다(더 이상 내 것이 아니다)”고 이해하면 됩니다.

주소 계산 방식

  • 스택은 고정 주소가 아닙니다. 함수 호출마다 스택 포인터가 움직이기 때문에 위치가 계속 바뀝니다.
  • 그래서 컴파일러는 보통 기준점(BP/FP 같은 레지스터) 을 잡고,
    거기서 오프셋(±얼마) 으로 지역 변수/임시값에 접근하는 코드를 만듭니다.

중요 포인트:

  • 변수 이름은 사람에게 보이기 위한 라벨
  • 실제 접근은 (기준 레지스터 + 오프셋) 형태의 주소 계산으로 일어납니다

참고: Release 최적화에서는 프레임 포인터를 생략(FPO)하는 경우도 있어서,
디버깅 시 “항상 BP가 고정 기준”처럼 보이지 않을 수도 있습니다. (개념은 동일)


32bit vs 64bit

  • 핵심 차이(개념): 레지스터 폭주소 공간이 커집니다.
  • 그 결과, 함수 호출 시 인자 전달을 “스택만” 쓰지 않고 레지스터를 적극 활용하는 방식이 흔해집니다.

인자 전달(감각만 잡기)

  • x86(32bit): 전통적으로 인자를 스택에 PUSH 해서 넘기는 형태가 흔합니다.
  • x64(64bit): 성능을 위해 “첫 몇 개 인자”를 레지스터로 전달하는 규칙이 흔합니다.

어떤 레지스터를 쓰는지는 OS/ABI에 따라 달라집니다.
예: Windows x64에서는 보통 rcx, rdx, r8, r9부터 인자를 전달합니다.

32bit의 대표 한계(상식)

  • 주소 크기가 32bit이면 이론적으로 (2^{32}) 범위까지만 주소를 표현할 수 있어, “약 4GB”가 자주 언급됩니다.

디버그 모드와 카나리(Canary) 기법

여기서 초보자가 가장 많이 헷갈리는 포인트는 “0xCC 같은 값 = 카나리” 같은 혼동입니다.
디버그 패턴 값카나리(스택 쿠키) 는 목적이 다릅니다.

디버그 패턴 값 (개발 편의)

  • 디버그 빌드에서는 버그를 빨리 드러내기 위해 메모리에 특정 패턴 값을 채워두는 경우가 있습니다.
  • 강의에서 언급된 0xCC... 같은 값이 대표적입니다.
    • “초기화 안 한 값을 읽었네?” 같은 실수를 눈치채기 쉬워집니다.

카나리(= 스택 쿠키, 스택 프로텍터) (보안/침범 감지)

  • 카나리는 스택 버퍼 오버플로우로 반환 주소 같은 중요한 값이 망가지는 것을 막기 위한 장치입니다.
  • 스택 프레임에 쿠키(비밀값) 를 심어두고, 함수 종료 시 값이 바뀌었는지 검사합니다.
    • 바뀌면: “스택 침범 발생”으로 판단 → 즉시 종료/예외

결론 2줄

  • 디버그 패턴 값 = 개발 편의(버그 노출)
  • 카나리 = 보안/침범 감지(스택 오염 탐지)

또한 스택은 리턴 시 자동 초기화하지 않기 때문에, 반납된 영역을 다시 읽으면 “쓰레기 값”이 보일 수 있습니다.


복사 전달 vs 참조 전달

스택 프레임을 이해하면 “분명히 더했는데 왜 원본이 안 바뀌지?” 같은 실수를 바로 설명할 수 있습니다.

구분복사 전달 (Call by Value)참조/주소 전달 (Reference/Pointer)
넘어가는 것값이 복사원본을 가리키는 정보(주소/참조)
함수 내부에서 수정원본 영향 없음원본이 바뀜
// 복사: 원본 hp 변경 안 됨
void AddHp(int hp, int value) {
    hp += value;
}

int main() {
    int hp = 100;
    AddHp(hp, 20);
    cout << hp << '\n';  // 100 출력
}

위 코드에서 AddHp(hp, 20)은 “hp 변수 자체”를 넘기는 게 아니라,
hp에 들어있던 값(100) 을 복사해서 넘긴 것입니다.

// 참조: 원본 변경됨
int Test(int& a) {
    a += 10;
    return a;
}

int main() {
    int x = 5;
    Test(x);
    cout << x << '\n';  // 15 출력
}
  • 참조 전달은 스택에 주소를 저장하고, 해당 주소를 따라가 원본 데이터를 변경함.
  • 지금까지 배운 문법만으로는 원본 수정 불가. return으로 새 값을 받아 대입하는 방식 사용.

(지금 단계에서 추천) return으로 돌려받아 원본 갱신하기

int AddHpNew(int hp, int value) {
    return hp + value;
}

int main() {
    int hp = 100;
    hp = AddHpNew(hp, 20);
    cout << hp << '\n';  // 120 출력
}

체크 질문 (스스로 답해보기)

  • AddHp(hp, 20)에서 “넘어가는 것”은 hp 변수인가, hp의 값인가?
  • “원본을 바꿔야 한다면” 지금 단계에서 가장 확실한 방법은?

스택 메모리 절약에 대한 오해

  • 결론부터 말하면, “지역 변수 몇 개 줄여서 메모리를 아낀다”는 집착은 보통 효과가 거의 없습니다.
    • 스택은 함수가 끝나면 프레임이 반납되므로 장기 누수처럼 쌓이지 않기 때문입니다.

하지만 “아무거나 스택에 막 올려도 된다”는 뜻은 아닙니다.

정말 위험한 케이스 2가지

  • 큰 지역 배열/큰 객체를 스택에 만들기
    • 예: int big[1000000]; 같은 건 호출 1번에 스택을 크게 먹습니다.
  • 호출 깊이가 너무 깊어지는 구조
    • 종료 조건 없는 재귀 / 과도한 중첩 호출

좋은 감각(요약)

  • “몇 개 변수 아끼기”보다, 설계(재귀 깊이/큰 지역 데이터) 를 먼저 의심하자.

profile
李家네_공부방

0개의 댓글