C 프로그래밍 입문/변수의 유효범위와 저장 위치

위키책, 위키책

변수의 유효범위와 저장 위치[+/-]

변수는 선언되는 위치에 따라 사용 가능한 영역이 정해지며, 필요한 경우 사용자가 변수를 저장할 위치와 특성을 지정할 수 있도록 되어있다.

  1. 변수는 기본적으로 선언된 블럭과 그 하위 블럭에서 사용할 수 있다.
  2. 하위 블럭에 동일한 이름으로 변수가 선언되면 하위 블럭 내에서는 하위 블럭에서 선언된 변수가 사용된다.

C 에서 말하는 '블럭'이란 프로그램 코드 단위를 말한다. 앞서 단순한 함수를 만들때 함수의 시작과 끝을 표시하기 위해 '{'와 '}'를 사용했던 것을 기억할 것이다. 이 '{'와 '}'로 묶여진 영역이 바로 블럭이다.

아래의 프로그램 코드를 잠시 보자:

#include <stdio.h>
int main (int argc, char * argv[])
{
    int var = 10;
    printf("The value before: %d\n", var);
    {
        int var = 20;
        printf("The value in block: %d\n", var);
    }
    printf("The value after: %d\n", var);
    return 0;
}

int some_subfunction()
{
    /*
        여기에선
            1. var 라는 변수가 선언되지 않았고
            2. main 과는 나란히 놓여진 별개의 영역이기 때문에
        main에서 선언된 변수 var를 사용할 수 없습니다.
        var = 20;
        이라고 무작정 값을 넣으려 시도하면 컴파일시에 에러가 납니다.
    */
}

프로그램을 실행해 보면 선언된 변수의 유효 범위에 대해 알 수 있을 것이다. some_subfunction()안에 var에 값을 넣으려는 시도를 하는 코드를 넣을 수도 있겠지만, 컴파일을 시도하면 그런 변수 선언한 적 없다는 에러 메시지가 표시되고 컴파일을 중단하게 될 것이기 때문에 넣지는 않았다.

위의 프로그램에서 선언되고 사용된 변수 var은 '지역변수(local variable)'라고 부르기도 한다. 말 그대로 선언된 영역 내에서만 유효하기 때문에 지역 변수라 부른다. 실제로 이 지역변수를 선언한 선언문에는 생략된 내용이 있다. 다음 문단을 보자.

auto[+/-]

auto 키워드는 거의 사용되지 않는 키워드 중 하나인데, 다른 조건이 없을 경우 암시적으로 auto를 사용하기 때문이다. '프로그램 최적화시에 가장 빠르게 동작할 수 있는 메모리를 선택하라'는 의미이다. 지역변수들은 기본적으로 메모리내의 '스택영역'에 위치하며, 블럭영역 안에 들어갈 때 생성 되었다가, 블럭 영역을 빠져나가게 되면 '자동적으로' 제거 된다. auto로 선언된 변수들은 CPU 레지스터중 활용이 가능한 레지스터가 존재하는 경우에는 스택에 변수를 할당하는 대신에 레지스터에 변수를 할당한다. 레지스터는 메모리에 비해 액세스 속도가 월등하게 빠르기 때문에 자주 사용되는 변수가 레지스터에 할당되면 프로그램이 상대적으로 빠르게 동작하게 된다. 문제는 auto 키워드를 사용하는 경우에는 어떤 변수가 레지스터에 할당될지 모른다는 데 있다. 만약에 특정 변수를 레지스터에 할당하고 싶다면 auto 키워드 대신에 register 키워드를 사용하면 된다. 다음의 두 선언은 같은 의미를 지닌다.

int var1;
auto int var2;
register[+/-]

register 키워드를 사용하여 선언된 변수는 가용 레지스터가 있는 경우 우선적으로 레지스터에 할당된다. 당연히 가용 레지스터가 없는 경우에는 스택에 할당된다. 그렇기 때문에 register 키워드를 남용하면 전혀 사용하지 않는 것과 별다른 차이가 없게 되기 때문에 한 블럭 내에서는 register 키워드를 사용한 변수의 수를 반드시 필요한 최소한으로 제한 하는 것이 좋다.

register int var = 10;
static[+/-]

앞서 설명한 바와 같이 auto 변수들은 블럭에 들어가는 순간에 생성 되었다가 블럭을 빠져나가는 순간 제거된다. 다음 프로그램을 실행해 보자:

#include <stdio.h>
int main (int argc, char * argv[])
{
    some_subfunction();
    some_subfunction();
    return 0;
}

int some_subfunction()
{
   int var = 0;
   var += 10;
   printf ("A variable in subfunction: %d\n", var);
   return 0;
}

some_subfunction() 이라는 함수 블럭 내에 선언된 auto 변수 var는 함수 블럭에 들어가는 순간 - 이 경우 함수가 호출되는 순간에 생성되었다가 함수블럭에서 빠져나오는 순간 - 이 경우 return 되는 순간 제거 된다. 그렇기 때문에 아무리 var의 값을 증가 시키더라도 증가 될 수 없다.

이제 소스를 다음과 같이 수정해서 실행해 보자

#include <stdio.h>
int main (int argc, char * argv[])
{
    some_subfunction();
    some_subfunction();
    return 0;
}

int some_subfunction()
{
   static int var = 0;
   var += 10;
   printf ("A variable in subfunction: %d\n", var);
   return 0;
}

이와 같이 static으로 선언된 변수는 블럭을 벗어나도 제거되지 않는다. 실제로 auto 변수는 블럭에 들어가는 순간 메모리 공간이 할당되고 지정된 값으로 초기화 된다. 그러나 static 변수는 위에서 언급한 메모리 영역중 '스태틱 데이터' 영역에 위치하며 프로그램이 실행될 때 메모리 공간이 할당되고 지정된 값으로 초기화 된다. 물론 static 변수가 제거되는 시점은 프로그램이 종료되는 순간에 제거된다.

전역변수(global variable)[+/-]

지역변수와 대비되는 변수는 '전역변수(global variable)'라는 것이 있다. 전역 변수는 말 그대로 프로그램 내의 모든 영역에서 액세스가 가능한 변수이다. 전역변수로 선언하기 위한 특별한 키워드가 있는 것은 아니고, 함수들의 외부에 변수를 선언하면 전역변수가 된다. 전역변수는 기본적으로 스태틱 데이터 영역에 배치된다. 전역변수는 auto 키워드나 register 키워드를 사용할 수 없다. 일단 다음 코드를 실행시켜 보자:

#include <stdio.h>
int var = 0;
int main (int argc, char * argv[])
{
    printf("The variable: %d\n", var);
    some_subfunction();
    some_subfunction();
    printf("The variable: %d\n", var);
    return 0;
}

int some_subfunction()
{
   var += 10;
   printf ("The variable in subfunction: %d\n", var);
   return 0;
}

프로그램의 실행 결과를 확인해 보면 쉽게 알 수 있겠지만, 전역변수는 프로그램 내에서 제한없이 액세스가 가능하다. 그렇기 때문에 전역변수는 잘못 사용하면 양날검과 같아서 아주 이해하기 힘들고, 문제가 생겼을때 문제의 해결을 불가능에 가깝게 몰아가는 특성을 가지고 있다. 그렇기 때문에 전역변수는 1. 반드시 필요한 경우에만, 2. 전역변수의 값을 할당하는 함수는 최소한으로만 해서 써야 한다.

여러 파일에서 통용되는 전역변수[+/-]

프로그램을 작성하다보면 모든 코드를 파일 하나에만 몰아 넣을 수 없는 경우가 대부분이다. 연습용으로 만드는 프로그램 코드 정도야 길어야 100줄도 안되겠지만, 수십, 수백만줄에 달하는 프로그램 코드를 파일 하나에 몰아넣어 사용할 수 는 없는 노릇이다. 그렇기 때문에 프로그램을 여러개의 파일로 분할해서 사용하게 되는데, 이때 파일이 달라지면 다른 파일에서 선언한 전역변수를 액세스 할 수 없다. 그런데, 액세스 할 수 없는 이유는 전역변수 자체가 액세스 할 수 없도록 막혀있는 것이 아니라, 그러한 전역 변수가 존재한다는 사실 자체를 모르기 때문에 사용할 수 없는 것이다. 다음의 두 코드를 각각 입력해서 함께 컴파일해 보자.

mainfile.c

#include <stdio.h>
int some_subfunction();
int global_variable = 0;
int main (int argc, char * argv[])
{
  printf("The variable: %d\n", global_variable);
  some_subfunction();
  some_subfunction();
  global_variable += 10;
  printf("The variable: %d\n", global_variable);
}

subfile.c

#include <stdio.h>
extern int global_variable;
int some_subfunction()
{
  global_variable += 10;
  printf("The variable in subfunction: %d\n", global_variable);
  return 0;
}

mainfile.c 쪽의 2번 라인에 있는 int some_subfunction(); 는 그런 함수가 존재한다는사실을 컴파일러에게 미리 알려주는 역할을 한다. 이경우 별도의 extern 키워드를 사용하지 않아도 된다. 많은 컴파일러가 함수의 프로토타입을 미리 선언해 주지 않아도 정상적으로 처리해 주기는 하지만, 원래 프로토타입을 미리 선언해 주지 않으면 컴파일이 되지 않는 것이 정상이므로 가능하면 함수를 호출하는 함수가 호출되는 함수보다 먼저 나오거나, 다른 파일에 호출되는 함수가 정의되어 있는 경우에는 함수의 프로토타입을 미리 선언해 주는 것이 좋다. subfile.c 쪽의 2 번라인에는 mainfile.c 에서 선언된 변수 global_variable 이 존재한다는 사실을 컴파일러에게 알려줌으로서 subfile.c 파일내 코드에서 해당 전역변수를 사용할 수 있도록 준비하도록 한다.

주의사항:

전역변수나 함수에 사용되는 static 키워드는 로컬 변수에서 사용되는 static 키워드는 의미가 다르다. 전역 변수나 함수에 static 키워드가 사용되면 해당 변수나 함수는 현재 파일 외부에서 액세스가 불가능해진다. 다시 말해서 만일 위의 샘플코드에서 전역변수 global_variable 의 속성을 static으로 지정해주면 아무리 subfile.c 에서 extern으로 전역변수를 사용하겠다고 선언해 줘도 실제 외부에서 액세스 하도록 허용하지 않은 셈이 되기 때문에 그런 전역변수가 없다는 내용의 에러메시지를 받게 될 것이다. 마찬가지로 함수의 속성을 static으로 주면 파일 외부에서 해당 함수를 호출할 수 없게 된다.

volatile[+/-]

마지막으로 다룰 변수의 속성은 volatile 이다. 이 속성은 변수에만 할당되며, 가장 기본적인 개념은 '현재 코드 밖에서 변수의 값을 수정할 수 있기 때문에 컴파일러가 컴파일시에 최적화를 하지 않도록 한다'이다. 컴파일러가 하는 일은 단순히 소스 코드를 기계가 실행할 수 있는 형태로 바꾸는 작업만 하는 것은 아니다. 필요 없는 코드를 제거 한다던가, 생략이 가능한 부분은 생략해서 코드를 단순화 한다던가, 더 빠른 처리를 위해 메모리 대신 레지스터를 사용하게 한다던가 하는 등의 작업을 수행한다. 이렇게 컴파일러에 의해 코드가 최적화 되면 경우에 따라서 이런 작업이 프로그램의 정상적인 동작을 방해하기도 하는데, 하드웨어를 제어하는 프로그램을 만든다던가 멀티 쓰레드 프로그램을 만드는 경우 가장 많은 영향을 받게된다. 다음 예를 보자:

#include <stdio.h>
int main (int argc, char * argv[])
{
    static int target;
    int cnt;

    target = 10;
    target = 20;
    
    for(cnt = 0; cnt < 1024; cnt++)
    {
        target += 10;
    }
    return 0;
}

사실 의미있는 샘플 코드가 되려면 target이 변수가 아니라 포인터 이어야만 하지만 아직 익숙하지 않은 사람을 위해 그냥 변수로 대체를 했다. 어찌 되었든 target 변수가 앞서 말한 것 처럼 하드웨어를 액세스 하기 위해 매핑된 메모리라 가정을 하고 설명 하겠다. target에 값을 써 넣으면 하드웨어가 그 값을 이용해서 어떤 처리를 한다고 가정 해보자. 실제 위 코드의 7번과 8번 라인의 의미는 하드웨어에게 먼저 10이라는 정보를 보내고, 다시 20이라는 정보를 보낸다는 의미가 된다. 그러나, 컴파일러는 의미 없이 10을 넣고 연이어 20을 넣는 작업을 하는 것으로 인식하기 때문에 컴파일 시에 7번라인을 제거하고 8번 라인만 남긴채 컴파일을 하게 된다. 그러면 하드웨어는 프로그래머의 의도와는 다르게 10과 20을 연이어 받는 것이 아니라 20만 받게 된다. 일반적으로 디버그 코드는 최적화를 하지 않기 때문에 디버그 코드를 넣어 개발작업을 할 때엔 제대로 잘 동작하던 프로그램이 릴리즈를 위해 최적화 작업만 거치면 제대로 동작하지 않는 이상한 상황을 맞게 되는 것이다.

유사한 상황은 10번 라인에서 13번 라인에 걸쳐서도 일어나게 된다. 앞서 이야기한 맥락대로 라면 먼저 10과 20을 보낸다음에 계속해서 10씩 증가된 값을 1024번 하드웨어에게 전달하는 의미로 코드가 작성된 것 이지만, 컴파일러는 단순이 10씩 증가시키는 코드라 간주하고 네줄의 코드를 실행하는 형태로 컴파일 하는 대신에 실제 연산을 미리 수행하고 결과만 대입하게 하거나, 속도를 향상하기 위해 여분의 레지스터를 하나 할당해서 target의 값을 옮겨 넣은 후 for 루프를 수행하고, 그 결과를 다시 target 변수에 할당하는 형태로 프로그램을 만들 수 도 있다. 두 경우 모두 target과 연결된 하드웨어는 for 루프를 수행한 결과값만 한 번 넘겨받게 될 것이다. 물론 일반적인 상황이라면 이러한 형태의 최적화는 프로그램 코드를 단순화 하고 처리 속도를 향상하는 효과가 있지만, 전술한 바와 같이 하나하나 데이터를 전달해야 하는 경우에는 전혀 의도하지 않은 결과를 얻게 된다.[1]

하드웨어를 조작하는 프로그램을 만들거나 나중에 설명할 멀티 쓰레드 프로그램을 만드는 경우에는 메모리와 관련된 문제가 발생하는 경우가 다반사 이니, 관련된 작업을 수행할 시에는 volatile 속성에 대해 한 번쯤 생각해 볼 수 있었으면 좋겠다. 많은 경우에 최적화 - 메모리와 관련된 문제가 발생하면 최적화를 아예 수행하지 못하도록 컴파일 옵션에서 지정해버리는 경우도 많은데, 어찌 생각해보면 빈대 잡자고 초가삼간 태우는 꼴이 될런지도 모르겠다.

위의 코드는 입력해서 실행을 시켜도 큰 의미는 없을 것이며, volatile 형의 변수가 필요한 이유에 대해서는 다른 곳에서 쓰레드 프로그래밍을 설명할 때 언급하고 의미있는 코드를 작성하게 될 것 이다.


주석 및 참고자료[+/-]

  1. 유사하게, 의미 없이 루프를 돌려 시간을 벌고자 하는 경우 최적화된 코드에 의해 아무 의미 없게 되는 경우도 종종 있습니다. 대다수의 경우에 빈 루프는 백번을 돌리던 백만번을 돌리던 수행 시간의 차이는 없게 됩니다.