Devin.KR

C · 기본

메모리 그림으로 배우는 C

C 함수와 호출 스택 - 값 전달 스택 프레임 static 지역 변수 (C 기초 5장)

인자가 복사되는 값 전달, 재귀 호출마다 쌓이는 스택 프레임을 주소 차이로 측정하고, static 지역 변수의 수명과 지역 변수 주소 반환의 위험을 본다.

개발자 · 원고 갱신

이 장에서 배우는 것

4장에서 반복문 안의 변수가 회차마다 새로 만들어지는 것을 봤다. 함수도 마찬가지다. 함수를 부를 때마다 스택에 그 함수만의 작업 공간인 스택 프레임이 생기고, 함수가 끝나면 사라진다. 이 그림 하나로 "왜 swap 함수가 값을 못 바꾸는가", "왜 지역 변수의 주소를 돌려주면 안 되는가", "재귀가 깊으면 왜 죽는가"가 모두 설명된다.

  • 함수 호출 시 인자가 값으로 복사되어 전달됨을 확인한다.
  • 재귀 호출에서 프레임이 쌓이는 모습을 주소 차이로 직접 측정한다.
  • 지역 변수·static 지역 변수·전역 변수의 수명 차이를 안다.
  • 지역 변수의 주소를 돌려주는 버그를 컴파일러 경고로 잡는다.

문제 상황

정렬 코드를 짜다가 두 값을 바꾸는 swap(x, y) 함수를 만들었다. 함수 안에서 찍어 보면 분명히 바뀌었는데, 돌아와서 보면 그대로다. 또 설정 코드를 돌려주는 함수를 만들면서 지역 변수의 주소를 반환했더니, 컴파일러가 경고를 하고 실행할 때마다 값이 이상하다. 두 문제 모두 함수가 끝나면 그 프레임이 통째로 무효가 된다는 사실을 놓쳐서 생긴다.

메모리 그림: 호출 스택

main 이 swap(x, y) 를 부른 순간 (스택은 높은 주소에서 낮은 주소로 자란다)

 높은 주소
 ┌──────────── main 의 프레임 ────────────┐
 │ x = 1        y = 2                     │
 │ price = 10000   with_tax = ?           │
 ├──────────── swap 의 프레임 ────────────┤  ← 호출할 때 생김
 │ a = 1 (x 의 복사본)  b = 2 (y 의 복사본)  │
 │ tmp                                    │
 │ 돌아갈 위치(main 의 다음 줄)              │
 └────────────────────────────────────────┘  ← 스택 꼭대기
 낮은 주소

 swap 이 a, b 를 바꿔도 main 의 x, y 칸은 건드리지 않는다.
 swap 이 return 하면 이 프레임 전체가 "빈 칸"으로 돌아간다.

factorial(4) 재귀 (frames.c 측정값, 이 기계·이 컴파일 옵션 기준)
 main          start       기준 0
 factorial(4)  marker      56바이트 아래
 factorial(3)  marker     120바이트 아래   ← 한 프레임 64바이트
 factorial(2)  marker     184바이트 아래
 factorial(1)  marker     248바이트 아래

swap 을 부르면 새 프레임에 x, y 의 복사본 a, b 가 생긴다. 복사본을 바꿔도 main 의 x, y 는 그대로이고 프레임은 return 과 함께 사라진다.

swap 을 부르면 새 프레임에 x, y 의 복사본 a, b 가 생긴다. 복사본을 바꿔도 main 의 x, y 는 그대로이고 프레임은 return 과 함께 사라진다.

factorial 을 한 단계 부를 때마다 64바이트 프레임이 낮은 주소 쪽으로 쌓였다(이 기계·-O0 측정값). 깊이 × 프레임 크기가 스택 한도를 넘으면 스택 오버플로다.

factorial 을 한 단계 부를 때마다 64바이트 프레임이 낮은 주소 쪽으로 쌓였다(이 기계·-O0 측정값). 깊이 × 프레임 크기가 스택 한도를 넘으면 스택 오버플로다.

완성 코드

값 전달

#include <stdio.h>

static void swap(int a, int b) {
    int tmp = a;
    a = b;
    b = tmp;
    printf("swap 안:  a=%d b=%d\n", a, b);
}

static int add_tax(int price) {
    price = price + price / 10;
    return price;
}

int main(void) {
    int x = 1;
    int y = 2;
    swap(x, y);
    printf("main 안:  x=%d y=%d\n", x, y);

    int price = 10000;
    int with_tax = add_tax(price);
    printf("price=%d with_tax=%d\n", price, with_tax);
    return 0;
}

재귀 호출의 프레임 측정

#include <stdio.h>
#include <stdint.h>

static uintptr_t base;

static long factorial(int n) {
    char marker;
    printf("factorial(%d) 프레임: 첫 프레임보다 %ld바이트 아래\n",
           n, (long)(base - (uintptr_t)&marker));
    if (n <= 1) {
        return 1;
    }
    long rest = factorial(n - 1);
    return n * rest;
}

int main(void) {
    char start;
    base = (uintptr_t)&start;
    printf("결과: %ld\n", factorial(4));
    return 0;
}

변수의 수명: 지역·static 지역·전역

#include <stdio.h>

static int total_calls = 0;

static int next_ticket(void) {
    static int ticket = 100;
    int local = 0;
    ticket++;
    local++;
    total_calls++;
    printf("ticket=%d local=%d total_calls=%d\n", ticket, local, total_calls);
    return ticket;
}

int main(void) {
    next_ticket();
    next_ticket();
    next_ticket();
    return 0;
}

줄별 해설

  • static void swap(int a, int b) — 함수 앞의 static은 "이 파일 안에서만 보이는 함수"라는 뜻이다. 1장에서 본 링커의 기호표에 외부용으로 올라가지 않아 다른 파일의 같은 이름과 충돌하지 않는다. 작은 도우미 함수에는 습관적으로 붙인다.
  • swap(x, y)x, y 1과 2가 새 프레임의 a, b로 복사된다. C의 함수 인자는 예외 없이 값으로 전달된다. 원본을 바꾸려면 원본의 주소를 넘겨야 하고, 그 방법이 7장의 포인터다.
  • price = price + price / 10; — 함수 안에서 매개변수를 바꿔도 호출한 쪽의 price는 그대로다. 결과를 돌려받으려면 return을 쓴다.
  • static uintptr_t base; — 주소를 정수로 담는 자료형 uintptr_tmain의 지역 변수 주소를 저장한다. 서로 다른 변수의 주소를 포인터끼리 빼는 것은 표준에서 허용되지 않으므로, 정수로 바꿔서 거리를 잰다. 거리 값은 플랫폼 의존이며 관찰용이다.
  • char marker; — 각 factorial 프레임 안의 지역 변수다. 호출마다 새 marker가 생기므로 주소가 다르다.
  • long rest = factorial(n - 1); — 자기 자신을 부르면 현재 프레임 위(주소로는 아래)에 새 프레임이 쌓인다. 이 호출이 돌아와야 현재 프레임의 곱셈을 할 수 있다.
  • static int ticket = 100; — 함수 안에 있지만 스택이 아니라 정적 데이터 영역에 한 번만 만들어진다. 초기화도 프로그램 시작 전에 한 번만 된다. 그래서 호출할 때마다 값이 이어진다.
  • int local = 0; — 호출할 때마다 프레임에 새로 생기고 0으로 초기화된다. 항상 1이 찍히는 이유다.
  • static int total_calls — 파일 범위의 전역 변수(파일 안에서만 보이도록 static)다. 수명은 ticket과 같이 프로그램 전체지만, 파일 안의 모든 함수가 접근할 수 있다.

실제 실행 결과

$ ./by_value
swap 안:  a=2 b=1
main 안:  x=1 y=2
price=10000 with_tax=11000
$ ./frames
factorial(4) 프레임: 첫 프레임보다 56바이트 아래
factorial(3) 프레임: 첫 프레임보다 120바이트 아래
factorial(2) 프레임: 첫 프레임보다 184바이트 아래
factorial(1) 프레임: 첫 프레임보다 248바이트 아래
결과: 24

재귀가 한 단계 깊어질 때마다 marker가 정확히 64바이트씩 낮은 주소에 있다. 스택이 아래로 자란다는 것, 그리고 이 함수의 프레임이 64바이트라는 것을 직접 잰 셈이다. 이 숫자는 CPU, 컴파일러, 최적화 옵션에 따라 달라진다(예를 들어 -O2에서는 컴파일러가 프레임 크기를 바꾸거나 재귀를 반복문으로 바꿀 수도 있다). 중요한 것은 "호출 하나마다 일정한 크기의 칸이 쌓인다"는 구조다. 리눅스의 기본 스택 크기는 보통 8MB이고 임베디드 RTOS의 작업(task) 스택은 수 KB인 경우가 흔하다. 재귀 깊이 × 프레임 크기가 이 한도를 넘으면 스택 오버플로로 프로그램이 죽는다.

$ ./counter
ticket=101 local=1 total_calls=1
ticket=102 local=1 total_calls=2
ticket=103 local=1 total_calls=3

지역 변수의 주소를 돌려주면

#include <stdio.h>

static int *make_code(void) {
    int code = 42;
    return &code;
}

int main(void) {
    int *p = make_code();
    printf("%d\n", *p);
    return 0;
}
$ clang -std=c17 -Wall -Wextra dangling.c -o dangling
dangling.c:5:13: warning: address of stack memory associated with local variable 'code' returned [-Wreturn-stack-address]
    5 |     return &code;
      |             ^~~~
1 warning generated.

clang은 기본 설정만으로도 이 코드를 경고한다. make_code가 끝나는 순간 code가 있던 프레임은 무효가 되고, 그 자리는 다음 함수 호출(printf)의 프레임으로 재사용된다. 그 주소를 읽는 것은 UB이며, 실행해 보면 42가 나올 수도, 엉뚱한 값이 나올 수도, 최적화 옵션에 따라 전혀 다르게 동작할 수도 있다. 결과가 우연에 달린 코드라서 이 책에서는 실행 결과를 싣지 않는다. 고치는 방법은 셋 중 하나다. 값을 그대로 return code;로 돌려주거나, 호출한 쪽이 준비한 변수의 주소를 받아 채우거나(7장), 힙에 할당해서 돌려준다(9장).

한눈에 보기

변수가 사는 곳과 수명
선언사는 곳수명초기값을 안 주면
함수 안 지역 변수스택블록이 끝날 때까지쓰레기 값
함수 안 static정적 영역프로그램 내내0
파일 범위 전역정적 영역프로그램 내내0
malloc 블록free 할 때까지쓰레기 값
함수가 결과를 돌려주는 방법
방법좋은 점주의
return가장 단순, 복사라 안전값 하나만
출력 매개변수(포인터)여러 결과와 성공 여부를 함께NULL 검사, 7장
힙에 할당해 주소 반환크기를 실행 중에 정함호출한 쪽이 free, 9장
지역 변수의 주소 반환없음UB — 절대 쓰지 않는다

실무에서 자주 틀리는 것

  • 큰 배열을 지역 변수로 만든다. char buf[1024 * 1024];는 1MB를 스택에 잡는다. 데스크톱에서는 운 좋게 돌아가도, 스택이 작은 스레드나 임베디드 작업에서는 바로 넘친다. 큰 버퍼는 static이나 힙에 둔다.
  • static 지역 변수를 쓰는 함수를 여러 스레드에서 부른다. 모든 호출이 한 칸을 공유하므로 동시에 부르면 값이 꼬인다. strtok 같은 옛 표준 함수가 이 이유로 스레드 안전하지 않다.
  • 종료 조건이 없는 재귀. 기저 조건(n <= 1)을 빠뜨리거나 음수 입력을 고려하지 않으면 스택이 넘친다. 입력 범위를 검사하고, 깊이가 입력 크기에 비례하는 재귀는 반복문으로 바꾸는 것을 고려한다.
  • "함수 안에서 바꿨는데 왜 안 바뀌지?" — 인자는 복사본이다. 바꾼 결과를 돌려받으려면 return하거나 포인터를 넘긴다.

연습 문제

  1. by_value.cadd_tax를 호출한 뒤 price도 11000이 되게 하려면 main에서 무엇을 바꾸면 되는가? (포인터를 쓰지 않는 방법)
  2. frames.c의 측정 결과(프레임당 64바이트)를 그대로 믿는다면, 스택이 8MB일 때 factorial은 대략 몇 단계까지 재귀할 수 있는가? 이 계산이 왜 대략값인지도 말하라.
  3. counter.cstatic int ticket = 100;에서 static을 지우면 출력이 어떻게 바뀌는가?

정답

  1. price = add_tax(price);로 반환값을 원래 변수에 다시 대입한다. 함수는 복사본만 바꾸므로, 결과를 return으로 받아 호출한 쪽이 직접 대입해야 한다.
  2. 8MB = 8 × 1024 × 1024 = 8,388,608바이트, 64로 나누면 약 13만 단계(131,072)다. 대략값인 이유는 ① main 이전의 시작 코드와 printf 같은 호출도 스택을 쓰고 ② 스택 한도는 환경(ulimit -s, 스레드 설정)마다 다르며 ③ 최적화 옵션에 따라 프레임 크기가 바뀌기 때문이다. 게다가 long 결과는 21단계에서 이미 넘친다.
  3. ticket이 호출마다 스택에 새로 생겨 100으로 초기화되므로 세 줄 모두 ticket=101이 찍힌다. local=1total_calls=1, 2, 3은 그대로다.

다음 장에서는 같은 자료형의 값을 연속된 칸에 늘어놓는 배열과, 그 특별한 형태인 문자열을 본다.

READER FEEDBACK

질문·오탈자·의견

내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.