C 함수와 호출 스택 - 값 전달 스택 프레임 static 지역 변수 (C 기초 5장)
이 장에서 배우는 것
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 과 함께 사라진다.
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_t에main의 지역 변수 주소를 저장한다. 서로 다른 변수의 주소를 포인터끼리 빼는 것은 표준에서 허용되지 않으므로, 정수로 바꿔서 거리를 잰다. 거리 값은 플랫폼 의존이며 관찰용이다.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하거나 포인터를 넘긴다.
연습 문제
by_value.c의add_tax를 호출한 뒤price도 11000이 되게 하려면main에서 무엇을 바꾸면 되는가? (포인터를 쓰지 않는 방법)frames.c의 측정 결과(프레임당 64바이트)를 그대로 믿는다면, 스택이 8MB일 때factorial은 대략 몇 단계까지 재귀할 수 있는가? 이 계산이 왜 대략값인지도 말하라.counter.c의static int ticket = 100;에서static을 지우면 출력이 어떻게 바뀌는가?
정답
price = add_tax(price);로 반환값을 원래 변수에 다시 대입한다. 함수는 복사본만 바꾸므로, 결과를return으로 받아 호출한 쪽이 직접 대입해야 한다.- 8MB = 8 × 1024 × 1024 = 8,388,608바이트, 64로 나누면 약 13만 단계(131,072)다. 대략값인 이유는 ①
main이전의 시작 코드와printf같은 호출도 스택을 쓰고 ② 스택 한도는 환경(ulimit -s, 스레드 설정)마다 다르며 ③ 최적화 옵션에 따라 프레임 크기가 바뀌기 때문이다. 게다가long결과는 21단계에서 이미 넘친다. ticket이 호출마다 스택에 새로 생겨 100으로 초기화되므로 세 줄 모두ticket=101이 찍힌다.local=1과total_calls=1, 2, 3은 그대로다.
다음 장에서는 같은 자료형의 값을 연속된 칸에 늘어놓는 배열과, 그 특별한 형태인 문자열을 본다.