44 수명과 저장 기간
먼저 알아야 할 것
돌아보기
24장에서 스코프 — 이름이 보이는 범위 — 를 배웠다. 그런데 43장에서 “함수 호출의 복귀 장부”라는 말이 나왔다. 이름이 안 보이게 되는 것과, 그 기억이 사라지는 것은 같은 일인가?
답. 다른 일이고, 이 구별이 이 장의 뼈대다. 스코프는 컴파일 시점의 개념(어디서 그 이름을 쓸 수 있는가)이고, 수명은 실행 시점의 개념(그 기억이 언제부터 언제까지 유효한가)이다. 대개는 함께 가지만, 어긋나는 순간이 사고가 된다 — 이름은 사라졌는데 주소가 남아 있는 경우다(아래 오개념 블록).
이 장의 필요성과 맥락
malloc 이 편의가 아니라 필요로 읽힌다. 사고 하나(사라진 것의 주소)도 여기서 먼저 보여 준다.이 장이 끝나면
이 장에서 답할 질문
- 그러면
const static int x;는 어떻게 되는가? 둘을 함께 적었는데. bss라는 이상한 이름은 무엇인가? 그리고data와 왜 나누는가?- 전역 변수 — 함수 밖에 선언한 변수 — 도 정적 수명인가?
44.1 표준의 네 축 — 저장 기간, 유효범위, 연결, 저장 클래스
여기서 용어를 정식으로 정리하고 간다. C 표준은 이름과 객체에 대해 서로 독립적인 네 가지 성질을 정의하는데, 이것을 뭉뚱그리면 뒤에서 반드시 헷갈린다(특히 static의 두 얼굴이 그렇다).
| 표준 용어 | 무엇을 정하는가 | 언제의 개념인가 |
|---|---|---|
| 저장 기간(storage duration) | 객체가 언제부터 언제까지 존재하는가 | 실행 시점 |
| 유효범위(scope) | 그 이름을 어디서 쓸 수 있는가 | 번역 시점 |
| 연결(linkage) | 다른 곳의 같은 이름과 같은 것인가 | 번역·링크 시점 |
| 저장 클래스 지정자(storage-class specifier) | 위 셋을 어떻게 적는가 | 문법 |
표 44.1
마지막 줄이 중요하다. static·extern·auto·register·typedef, 그리고 C11의 _Thread_local(C23의 thread_local)은 문법적으로 한 자리를 차지하는 낱말들이고, 그중 무엇을 적느냐에 따라 앞의 세 성질이 정해진다. 한 선언에 이 자리는 하나뿐이라 static extern int x;는 문법 오류다.
44.1.1 저장 기간 — 넷
| 저장 기간 | 언제 태어나 언제 죽는가 | 어떻게 만드나 |
|---|---|---|
| 자동(automatic) | 블록에 들어설 때 나갈 때 | 블록 안의 보통 선언 |
| 정적(static) | 프로그램 시작 전 끝날 때 | 파일 유효범위 선언, 또는 static |
| 스레드(thread) | 그 갈래가 시작 끝날 때 | thread_local(C11) |
| 할당(allocated) | malloc free | 46장 |
표 44.2
표준의 낱말은 동적이 아니라 할당 저장 기간이다. 흔히 “동적 할당”이라 부르는 것이 이것이고, 이 책도 관행을 따라 그렇게 부르되 표준 용어를 여기 적어 둔다.
44.1.2 유효범위 — 넷
이름이 보이는 범위다. C 표준은 넷으로 나눈다.
| 유효범위 | 어디까지 보이는가 | 예 |
|---|---|---|
| 블록(block) | 선언한 자리부터 그 블록의 끝까지 | 함수 안의 지역 변수 |
| 파일(file) | 선언한 자리부터 그 번역 단위의 끝까지 | 함수 밖의 선언 |
| 함수(function) | 그 함수 전체 | 레이블 이름만(goto의 대상) |
| 함수 원형(function prototype) | 원형의 괄호 안 | 원형에 적은 매개변수 이름 |
표 44.3
세 번째가 낯설 텐데, 레이블은 함수 안 어디에 적혀 있든 그 함수 전체에서 보인다는 특례다. 네 번째는 void f(int count);처럼 원형에만 적은 이름이 어디까지 사는지를 정한 것 — 괄호를 벗어나면 사라지므로, 원형의 매개변수 이름은 사실상 주석이다.
안쪽이 바깥을 가린다. 같은 이름이 겹치면 안쪽 블록의 것이 이긴다.
int n = 1; /* 파일 유효범위 */
void f(void) {
int n = 2; /* 블록 유효범위 — 바깥의 n 을 가린다 */
{ int n = 3; use(n); } /* 여기서는 3 */
use(n); /* 여기서는 2 */
}44.1.3 연결 — 셋
같은 이름이 여러 곳에 나올 때 하나의 같은 객체를 가리키는가를 정한다. 54장(여러 파일)의 밑바탕이다.
| 연결 | 뜻 | 어떻게 되나 |
|---|---|---|
| 외부(external) | 번역 단위 전체를 가로질러 같은 것 | 파일 유효범위 기본값, extern |
| 내부(internal) | 이 번역 단위 안에서만 같은 것 | 파일 유효범위의 static |
| 없음(none) | 선언마다 별개 | 블록 안의 보통 변수, 매개변수, typedef 이름 |
표 44.4
반례. static의 두 얼굴을 같은 것으로 읽기
같은 낱말이 어디에 적히느냐에 따라 전혀 다른 일을 한다. 표준 용어로 보면 헷갈릴 여지가 없다.
#idx("내부") static int counter; /* 파일 유효범위: 연결을 *내부*로 (수명은 원래 정적) */
void f(void) {
static int calls; /* 블록 유효범위: 저장 기간을 *정적*으로 (연결은 없음) */
}첫 번째 static은 수명을 바꾸지 않는다 — 파일 유효범위 변수는 어차피 정적 저장 기간이다. 바꾸는 것은 연결이고, 뜻은 “이 파일 밖에서는 이 이름을 못 쓴다”다. 두 번째 static은 연결과 무관하다 — 지역 이름은 원래 연결이 없다. 바꾸는 것은 저장 기간이다.
한 문장으로 외우면 된다: 파일 유효범위의 static은 숨기는 것, 블록 유효범위의 static은 살려 두는 것.
44.1.4 저장 클래스 지정자 일곱 — 한자리에 모아 놓고
이 자리에 올 수 있는 낱말은 C23 기준으로 일곱이고, 이것이 전부다. 여기가 그 목록을 모아 두는 자리다.
examples/ch44/storage_class.c
/* 저장 클래스 지정자가 정하는 것을 눈으로 확인한다.
한 선언에 이 자리는 하나뿐이라는 규칙과, 그 유일한 예외도 함께 본다. */
#include <stdio.h>
static int file_only = 1; /* 내부 연결 — 이 파일 안에서만 보인다 */
extern int shared; /* 예고만 — 실물은 아래에 있다 */
int shared = 2; /* 정의 */
thread_local int per_thread = 3; /* 갈래마다 따로 */
static int bump(void)
{
static int calls = 0; /* 정적 저장 기간 — 호출 사이에 살아남는다 */
return ++calls;
}
int main(void)
{
auto int local = 4; /* C17 까지의 뜻: 자동 저장 기간(기본값) */
register int hot = 5; /* 옛 부탁. 주소는 못 얻는다 */
constexpr int fixed = 6; /* C23 — 컴파일 시간 상수 */
static_assert(fixed == 6, "constexpr is a constant expression");
printf("file_only=%d shared=%d per_thread=%d local=%d hot=%d fixed=%d\n",
file_only, shared, per_thread, local, hot, fixed);
/* 한 문장에 여러 번 부르면 평가 순서가 미지정이라 순서가 뒤집힌다(34장).
그래서 한 줄에 하나씩 부른다. */
int b1 = bump(), b2 = bump(), b3 = bump();
printf("bump() three times: %d %d %d <- a static local survives\n", b1, b2, b3);
/* static extern int bad; <- 오류: multiple storage classes */
/* int *p = &hot; <- 오류: address of register variable */
/* auto constexpr int c=1; <- 오류: 'auto' used with 'constexpr' */
static thread_local int ok = 7; /* 이 조합만 예외로 허용된다 */
printf("static thread_local ok=%d\n", ok);
return 0;
}
실행 결과
file_only=1 shared=2 per_thread=3 local=4 hot=5 fixed=6
bump() three times: 1 2 3 <- a static local survives
static thread_local ok=7
| 낱말 | 저장 기간 | 연결 | 무엇을 하는가 |
|---|---|---|---|
static | 정적 | 파일 수준이면 내부 | ★ 두 얼굴 — 함수 안에서는 수명, 파일 수준에서는 연결 |
extern | 정적 | 외부 | “어딘가 정의되어 있다”는 예고. 기억을 잡지 않는다 |
auto | 자동 | 없음 | 옛 뜻은 기본값 명시. ★ C23 이 타입 추론으로 재활용 |
register | 자동 | 없음 | 옛 부탁. ★ 오늘 남은 효력은 주소를 못 얻는다 하나 |
thread_local | 스레드 | 적은 대로 | 갈래마다 따로인 객체(81장) |
constexpr | 적은 대로 | 적은 대로 | C23. 컴파일 시간에 값이 정해지는 상수 |
typedef | — | — | ★ 문법 자리만 같고 하는 일이 전혀 다르다 — 타입 이름을 만든다(60장) |
표 44.5
시연이 일곱을 한 번에 보인다. 표에서 별표를 단 넷을 풀어 둔다.
static 의 두 얼굴은 앞 절에서 본 그대로다. 같은 낱말이 문맥에 따라 수명을 정하기도 하고 연결을 정하기도 한다 — C 에서 가장 유명한 낱말 재활용이다.
auto 는 뜻이 바뀐 낱말이다. 원래는 「자동 저장 기간」을 명시하는 것이었는데 어차피 기본값이라 아무도 적지 않았다. C23 이 그 빈자리를 타입 추론에 내주었다 — auto x = 1 + 2; 라고 쓰면 초기식에서 타입을 가져온다. 옛 뜻으로 쓴 auto int local; 도 여전히 통과하지만(시연이 그렇게 썼다) 오늘 새 코드에 적을 이유는 없다.
register 는 거의 죽었지만 완전히는 아니다. “되도록 레지스터에 두라”는 부탁은 오늘의 최적화기에 아무 영향이 없다 — 레지스터 배정은 컴파일러가 훨씬 잘한다(11장). ★ 그러나 규칙 하나가 남았다: register 로 선언한 객체의 주소는 얻을 수 없다. 시연의 주석이 그 자리이고, 실제로 컴파일하면 이렇게 말한다 — address of register variable 'hot' requested. 이 성질을 일부러 쓰는 사람도 있다 — “이 변수는 주소가 새어 나가지 않는다”를 컴파일러와 독자에게 못박는 용도다.
typedef 가 여기 있는 것은 문법의 사정이다. 저장 클래스 지정자와 같은 자리를 차지하지만 객체를 만들지 않는다. 그래서 이렇게 읽으면 된다 — “이 선언에서 변수가 생길 자리에, 대신 타입 이름이 생긴다.”(60장)
44.1.5 이 자리는 하나뿐이다 — 그리고 그 예외
★ 한 선언에 저장 클래스 지정자는 하나만 올 수 있다. 실측하면 이렇게 말한다.
static extern int x; → error: multiple storage classes in declaration specifiers
typedef static int T; → error: multiple storage classes in declaration specifiers
auto constexpr int k = 1; → error: 'auto' used with 'constexpr'예외는 thread_local 하나다. 이것만은 static 또는 extern 과 함께 적을 수 있다 — 스레드 저장 기간과 연결은 서로 다른 축이라, 둘을 함께 정해야 하는 경우가 있기 때문이다. 시연의 static thread_local int ok = 7; 이 그 예다.
문. 그러면 const static int x; 는 어떻게 되는가? 둘을 함께 적었는데.
답. 된다. 그리고 이 물음이 두 갈래를 가르는 좋은 시험지다. const 는 저장 클래스가 아니라 타입 한정자이므로(26장) 애초에 다른 자리를 차지한다. 그래서 static const volatile unsigned long x; 처럼 여러 갈래에서 하나씩 가져와 쌓는 것은 전부 합법이다.
못 하는 것은 같은 갈래에서 둘을 가져오는 것이다 — static extern 이 그렇다. 순서는 자유롭지만(const static 도 static const 도 같다) 관행은 저장 클래스를 먼저 적는 것이다.
44.2 두 가지 수명
C의 지역 변수는 기본적으로 자동 저장 기간(automatic)을 갖는다 — 블록에 들어설 때 태어나고 나갈 때 죽는다. 함수 호출마다 매개변수와 지역 변수가 새로 태어나는 것이 33장 재귀가 성립한 근거였다.
여기에 static을 붙이면 정적 저장 기간(static)이 된다 — 프로그램 시작 전에 한 번 태어나 끝날 때까지 산다(초기화도 딱 한 번). 시연으로 둘을 대조한다.
examples/ch44/life.c
#include <stdio.h>
int next_ticket(void)
{
static int issued = 0; /* 정적 수명: 호출 사이에도 살아남는다 */
issued += 1;
return issued;
}
int fresh_count(void)
{
int n = 0; /* 자동 수명: 호출마다 새로 태어난다 */
n += 1;
return n;
}
int main(void)
{
/* 부수효과 있는 호출은 문장을 나눈다 — 29장의 수칙 그대로 */
int t1 = next_ticket();
int t2 = next_ticket();
int t3 = next_ticket();
printf("next_ticket: %d %d %d\n", t1, t2, t3);
int f1 = fresh_count();
int f2 = fresh_count();
int f3 = fresh_count();
printf("fresh_count: %d %d %d\n", f1, f2, f3);
return 0;
}
실행 결과
next_ticket: 1 2 3
fresh_count: 1 1 1
next_ticket의 issued는 호출 사이에도 값을 간직해 1, 2, 3으로 자라고, fresh_count의 n은 호출마다 새로 태어나 언제나 1이다. 같은 “함수 안의 변수”인데 수명이 다른 것이다. (덧붙여, 시연이 호출을 문장 으로 나눈 것에 주목하면 된다 — 부수효과 있는 호출을 한 수식에 몰면 평가 순서가 미지정이라는 33장의 수칙 그대로다.)
44.3 기억의 배치 — 구역들을 눈으로
이쯤에서 프로그램의 기억이 실제로 어떻게 놓이는지 한 장의 그림으로 보아 둔다. 아래는 이 책의 검증 기계에서 직접 찍은 주소들이다.
examples/ch44/regions.c
/* 프로그램의 기억이 실제로 어디에 놓이는지 주소로 확인한다.
(구체적 값은 기계·운영체제·실행마다 다르다. 보아야 할 것은 *구역의 순서*다.) */
#include <stdio.h>
#include <stdlib.h>
const char ro_text[] = "read-only"; /* 상수 — 대개 읽기 전용 구역 */
int initialized = 7; /* 값이 있는 전역 — data */
int zeroed; /* 값이 없는 전역 — bss */
static void deeper(int depth, char *outer)
{
char here; /* 이 프레임의 지역 변수 */
if (depth == 0) {
printf(" a local one frame deeper : %p\n", (void *)&here);
printf(" difference from the outer frame : %+ld bytes\n",
(long)(&here - outer));
printf(" => the stack grows toward %s addresses\n",
(&here < outer) ? "lower" : "higher");
return;
}
deeper(depth - 1, outer);
}
int main(void)
{
static int static_local; /* 지역이지만 정적 저장 기간 */
int automatic = 1; /* 자동 저장 기간 — 스택 */
void *heap1 = malloc(64);
void *heap2 = malloc(64);
printf("code (function main) : %p\n", (void *)(void (*)(void))main);
printf("read-only string : %p\n", (void *)ro_text);
printf("global (initialized, data): %p\n", (void *)&initialized);
printf("global (zero, bss) : %p\n", (void *)&zeroed);
printf("static inside a function : %p\n", (void *)&static_local);
printf("heap (malloc 1) : %p\n", heap1);
printf("heap (malloc 2) : %p\n", heap2);
printf("stack (a local) : %p\n", (void *)&automatic);
printf("\ngap between the two heap blocks: %+ld bytes\n", (long)((char *)heap2 - (char *)heap1));
printf("distance from stack to heap : about %.1f TiB (a 64-bit address space is that wide)\n\n",
((double)((char *)&automatic - (char *)heap1)) / (1024.0 * 1024.0 * 1024.0 * 1024.0));
char anchor; /* 스택 방향을 재는 기준점 */
deeper(1, &anchor);
free(heap1);
free(heap2);
return 0;
}
실행 결과
code (function main) : 0x55e1906b61f1
read-only string : 0x55e1906b7008
global (initialized, data): 0x55e1906b9028
global (zero, bss) : 0x55e1906b9030
static inside a function : 0x55e1906b9034
heap (malloc 1) : 0x55e19d4c92a0
heap (malloc 2) : 0x55e19d4c92f0
stack (a local) : 0x7ffd5dce3a1c
gap between the two heap blocks: +80 bytes
distance from stack to heap : about 42.1 TiB (a 64-bit address space is that wide)
a local one frame deeper : 0x7ffd5dce39cf
difference from the outer frame : -76 bytes
=> the stack grows toward lower addresses
읽는 법은 값이 아니라 순서다. 낮은 주소부터 이렇게 늘어선다.
| 구역 | 무엇이 사는가 | 수명 |
|---|---|---|
| 코드 | 함수의 기계 명령 | 프로그램 내내(읽기 전용) |
| 읽기 전용 자료 | 문자열 리터럴, const 자료 | 프로그램 내내(쓰면 붕괴) |
data | 초깃값이 있는 전역·static | 프로그램 내내 |
bss | 초깃값이 없는(=0인) 전역·static | 프로그램 내내 |
| 힙 | malloc이 준 것(45장) | 해제할 때까지 |
| 스택 | 지역 변수·매개변수·복귀 주소 | 함수가 끝날 때까지 |
표 44.6
예제가 확인해 준 것이 셋이다. data와 bss가 나란히 있고, 힙은 그 위쪽에서 커지며, 스택은 아주 멀리 떨어진 높은 주소에서 아래로 자란다 (예제의 마지막 세 줄이 프레임을 하나 더 쌓아 그 방향을 실측한다).
문. bss라는 이상한 이름은 무엇인가? 그리고 data와 왜 나누는가?
답. 이름은 1950년대 어셈블러의 명령어 Block Started by Symbol의 약자다 — 뜻은 잊혔고 이름만 반세기를 건너왔다.
나누는 이유는 실용적이다. int table[1000000];처럼 초깃값이 0인 큰 전역을 생각해 보자. data에 넣으면 실행 파일 안에 0이 백만 개 들어가 파일이 4MB 커진다. bss에 넣으면 파일에는 “이만큼을 0으로 채워 달라”는 숫자 하나만 적히고, 실제 채우기는 프로그램이 시작될 때 일어난다. 그래서 bss의 변수는 “명시적으로 초기화하지 않아도 0”이라는 C의 약속을 공짜로 얻는다 — 그 0은 운영체제(또는 임베디드의 시작 코드)가 채워 준 것이다.
플랫폼 노트. 스택은 얼마나 큰가 — 리눅스와 윈도우
C 표준에는 “스택”이라는 말도, 그 크기에 대한 약속도 없다. 자동 저장 기간만 정해 두고, 그것을 어디에 어떻게 놓을지는 구현에 맡긴다. 그래서 크기는 운영체제와 도구가 정한다.
- 리눅스 — 주 스레드의 기본 한도는 대개 8 MiB다(
ulimit -s로 확인· 변경한다. 이 책의 검증 기계도 8388608바이트였다). 필요하면 늘릴 수 있고, 스레드마다 만드는 스택은pthread_attr_setstacksize로 따로 정한다. 윈도우 — 기본은 1 MiB다. 게다가 그중 실제로 확보(commit)되는 것은 처음 4 KiB뿐이고, 쓰는 만큼 늘어난다. 실행 파일을 만들 때 링커 옵션
/STACK:예약[,커밋]으로 바꾸고, 스레드는CreateThread의 인자로 지정한다.차이가 실무에서 드러나는 자리가 있다. 리눅스에서 잘 돌던 코드가 윈도우 에서 스택 넘침으로 죽는 것 — 같은 코드, 여덟 배 좁은 그릇이기 때문이다. 큰 지역 배열(
char buf[2*1024*1024];)이나 깊은 재귀가 그 후보다. 더 자세한 배치와 임베디드의 사정은 83장에서 다룬다.
44.4 스택 — 호출의 장부
자동 변수들이 사는 곳에는 이름이 있다 — 스택(stack)이다. 함수를 부를 때마다 그 호출을 위한 칸 묶음(스택 프레임)이 얹히고, 함수가 끝나면 통째로 걷힌다. 프레임에는 지역 변수·매개변수와 함께 돌아갈 주소(복귀 주소)가 들어 있다 — 43장에서 이름만 스쳤던 “복귀 장부”의 정체다.
그림이 갖춰지면 두 가지가 한 번에 설명된다. 첫째, 33장의 재귀가 왜 겹겹이 쌓이는지 — 호출마다 프레임이 하나씩 얹히기 때문이다. 그리고 너무 깊이 재귀하면 스택 공간이 바닥나 프로그램이 붕괴한다(스택 오버플로 — 무한 재귀의 대표 증상이다). 둘째, 43장의 경계 침범 공격이 왜 그토록 위험한지 — 스택에 놓인 배열을 넘쳐 쓰면 같은 프레임의 복귀 주소를 덮어쓸 수 있고, 그러면 함수가 끝날 때 공격자가 정한 곳으로 “돌아간다”. 배열 하나의 경계가 프로그램의 통제권과 이어져 있는 것이다.
흔한 오해. “함수가 끝나도 그 안의 변수를 가리키는 주소는 쓸 수 있다”
포인터를 배운 직후 가장 흔한 사고이고, 무서운 점은 한동안 잘 도는 것처럼 보인다는 것이다. 함수가 끝나면 프레임이 걷히지만 — 그 자리의 비트가 즉시 지워지는 것은 아니라서, 죽은 변수의 주소를 따라가도 잠시는 예전 값이 읽힌다. 그러다 다른 함수가 그 자리를 프레임으로 쓰는 순간 값이 뒤바뀐다 — 시간이 지나서, 엉뚱한 곳에서 터지는 버그가 되는 것이다. 이런 주소를 댕글링 포인터(dangling pointer)라 하고, 규칙은 하나다: 지역 변수의 주소는 그 함수보다 오래 살아서는 안 된다. 함수가 만든 결과를 오래 살려 보내려면 정적 수명을 쓰거나, 다음 장의 동적 메모리를 쓰거나, 호출자가 준 그릇에 채워 주는(35장의& 관용구) 셋 중 하나다. 17장의 ASan이 실행 중에 이 사고를 잡아 주는 대표 도구이기도 하다.문. 전역 변수 — 함수 밖에 선언한 변수 — 도 정적 수명인가?
답. 그렇다. 함수 밖의 변수는 정적 저장 기간을 갖고 프로그램 내내 산다. 다만 수명과 별개로 가시성의 문제가 붙는데 — 여러 파일에서 보이게 할지 이 파일에만 둘지를 정하는 것이 54장의 주제(연결)다. 그리고 실무의 권고는 오래된 것이다: 전역 가변 상태는 최소로. 어디서든 바뀔 수 있는 값은 추적이 어렵고, 12장의 멀티코어 세계에서는 사고의 근원이 된다. 함수 안의 static도 같은 성질을 작게 가진 것이라(시연의 issued), 편리하지만 남용하지 않는 것이 관행이다.
기억의 배치에서 두 자리 — 스택(자동)과 정적 영역 — 를 익혔다. 남은 한 자리가 이 부의 마지막이다: 크기를 실행 중에 정하고, 원하는 만큼 살려 두는 기억. 다음 장의 동적 메모리다.