Proven C BookEnglish GitHub

44 수명과 저장 기간

먼저 알아야 할 것

24장 함수 선언과 정의 · 함수가 도는 동안
2장 기억의 구역들 · 스택과 정적 구역

돌아보기

24장에서 스코프 — 이름이 보이는 범위 — 를 배웠다. 그런데 43장에서 “함수 호출의 복귀 장부”라는 말이 나왔다. 이름이 안 보이게 되는 것과, 그 기억이 사라지는 것은 같은 일인가?

답. 다른 일이고, 이 구별이 이 장의 뼈대다. 스코프는 컴파일 시점의 개념(어디서 그 이름을 쓸 수 있는가)이고, 수명은 실행 시점의 개념(그 기억이 언제부터 언제까지 유효한가)이다. 대개는 함께 가지만, 어긋나는 순간이 사고가 된다 — 이름은 사라졌는데 주소가 남아 있는 경우다(아래 오개념 블록).

이 장의 필요성과 맥락

2장에서 그린 스택과 정적 구역이 여기서 C 문법과 만난다. 이 장을 45장(동적 메모리) 바로 앞에 둔 것이 순서의 핵심이다 — 자동 수명으로는 안 되는 일이 무엇인지 알아야 malloc 이 편의가 아니라 필요로 읽힌다. 사고 하나(사라진 것의 주소)도 여기서 먼저 보여 준다.

이 장이 끝나면

기억의 배치를 그린다 — 변수는 어디에 살고, 언제 태어나 언제 죽는가. 자동 수명과 정적 수명, 스택이라는 장부, 그리고 이 부에서 가장 조심 해야 할 사고(사라진 것의 주소를 간직하기)까지.

이 장에서 답할 질문

  1. 그러면 const static int x; 는 어떻게 되는가? 둘을 함께 적었는데.
  2. bss라는 이상한 이름은 무엇인가? 그리고 data와 왜 나누는가?
  3. 전역 변수 — 함수 밖에 선언한 변수 — 도 정적 수명인가?

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   free46장

표 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 staticstatic 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_ticketissued는 호출 사이에도 값을 간직해 1, 2, 3으로 자라고, fresh_countn은 호출마다 새로 태어나 언제나 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

예제가 확인해 준 것이 셋이다. databss가 나란히 있고, 힙은 그 위쪽에서 커지며, 스택은 아주 멀리 떨어진 높은 주소에서 아래로 자란다 (예제의 마지막 세 줄이 프레임을 하나 더 쌓아 그 방향을 실측한다).

문. 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), 편리하지만 남용하지 않는 것이 관행이다.

기억의 배치에서 두 자리 — 스택(자동)과 정적 영역 — 를 익혔다. 남은 한 자리가 이 부의 마지막이다: 크기를 실행 중에 정하고, 원하는 만큼 살려 두는 기억. 다음 장의 동적 메모리다.