본문 바로가기

C/기초와 문법

[C] 변수와 상수 고급 이론

반응형

🔊 해당 포스팅은 인프런의 널널한 개발자님의 독하게 시작하는 C 프로그래밍 강의를 듣고 개인적인 복습 목적 하에 작성된 글입니다. 해당 포스팅에 사용된 모든 자료는 필자가 직접 재구성하였음을 알립니다.
 

C언어를 배워보자!


저번 포스팅까지 강의에서 소개하는 C언어의 기초적인 내용은 모두 끝이 났다. 이제 앞으로 해당 포스팅을 포함해 3개의 포스팅에서는 C언어에서 고급 이론에 해당하는 내용들을 배워보려고 한다. 그 첫번째 내용으로는 변수와 상수에서의 고급 이론에 대한 내용이다.

1. 컴파일러 최적화에 기여하는 요소 : 형한정어(Type Qualifier)

컴파일러 최적화란 무엇일까? C언어를 배우는 첫 포스팅에서 우리는 C언어가 총 3가지 시점을 갖는다고 했다. 잠시 그 때의 자료를 가져와보자.

 

C언어는 총 3가지 시점을 갖는다

 

컴파일러는 위 단계 중 컴파일 타임에 관여한다. 참고로 링크 타임에서는 링커라는 것이 관여한다. 어쨌건 우리가 이해하려고 하는 '컴파일러 최적화'는 컴파일 타임에 발생하는 행동(action)이라고 하며, 구체적으로 어떤 행동이냐라고 묻는다면 C 소스코드를 목적파일 즉, 기계어로 번역할 때 최적화에 관여한다는 것이다. 그렇다면 여기서 수행한다는 '최적화 관여'가 어떤 것을 의미할까? 이를 이해하기 위해서 우선 아래의 IDE 화면을 살펴보자. 먼저 사용되는 소스코드는 아래와 같다. 매우 간단하다.

 

#include <stdio.h>

int main(void) {
    int res = 0;
    int a = 3, b = 4;

    res = a + b;
    printf("%d\n", res);
    return 0;
}

 

CLion IDE 화면에서 빨간색 네모칸 부분을 보자

 

IDE 화면에서 빨간색 네모칸을 눌러보면 Cmake Profiles 항목이 뜨면서 Debug, Release 라는 키워드가 존재하는 것을 볼 수 있다. 이는 빌드의 종류를 의미하는데, 결론부터 말하면 Debug 모드 빌드는 컴파일러가 C 소스코드를 최적화하지 않고 빌드하는 것이고, Release 모드 빌드는 컴파일러가 C 소스코드를 최적화한 후 빌드하는 것을 의미한다.

 

일단 Debug 모드 빌드로 선택한뒤, 해당 소스코드가 기계어로 번역되었을 때 구체적으로 어떻게 기계어로 수행되었는지 보기 위해 디스어셈블리 코드를 살펴보도록 하자. 저 빨간색 네모칸에서 Debug 키워드를 클릭한 후, 소스코드에 커서를 두고 오른쪽 클릭 후, Show Assembly 를 클릭하면 아래 사진처럼 디스어셈블리 코드가 나타난다. 그리고 C 소스코드의 어떤 영역이 디스어셈블리 코드의 어떤 영역에 매핑되는지 쉽게 식별할 수 있도록 표시가 된다. 아래 두 개의 사진은 하나는 Debug 모드로 빌드, 하나는 Release 모드로 빌드 했을 때의 디스어셈블리 코드 비교이다.

 

Debug 모드 vs Release 모드 일 때의 디스어셈블리 코드 비교

 

Debug 모드로 빌드했을 경우, 하얀색 점선 네모칸으로 칠한 것처럼 레지스터에 어떤 값을 복사하는 과정이 여러 번 수행되는 것을 볼 수 있다. 반면에, Release 모드로 빌드했을 경우, Debug 모드 빌드일 때 존재했던 레지스터에 값을 복사하는 과정이 모두 없어진 것을 볼 수 있다. 즉, 컴파일러가 C 소스코드에 대해서 최적화를 진행한 것이다. 

 

소스코드를 보면 a, b 변수에 각각 정수 3, 4를 할당했고, res 라는 변수에 a + b를 할당했다. 하지만 사람이 보기에도 이 소스코드에서 a, b 변수를 정의한 게 불필요해 보인다. 그냥 한 번에 res 라는 변수에 3 + 4를 해주면 되는거 아닌가? 싶다. 바로 이렇게 사람이 생각하는 최적화 작업을 컴파일러가 알아서 똑같이 수행한다. Release 모드에서의 사진 속 하얀색 점선 칸을 보면 w8 이라는 레지스터에 곧바로 바로 상수 7을 저장하는 것을 볼 수 있다. 여기서 7이 바로 3과 4를 더한 값이다. 즉, 컴파일러는 소스코드 상에서 a, b에 3, 4를 할당했지만, 이를 불필요한 코드라고 인식하고 기계어로 번역할 때는 바로 3 과 4를 더해버린 뒤 레지스터에 넣어버린다는 것이다.

 

바로 이것이 컴파일러 최적화라는 작업이다. 그리고 이런 컴파일러 최적화를 디버그 모드에서도 실행하도록 하기 위해 형한정어라는 것을 선언해주는 것이다. 

 

형한정어의 종류로는 const, volatile, extern, typedef 가 있다. 

2. 심볼릭 상수 : const 와 #define 

형한정어의 종류로 const 라는 키워드가 존재한다. 이 const는 심볼릭 상수를 선언하는 것이고, 이 심볼릭 상수를 선언하는 또 다른 방법으로는 #define 으로 만들 수도 있다.

 

우선 심볼릭 상수란, 변수를 상수화시키는 문법이다. '변수'는 말 그대로 변할 가능성이 있는 값인데, 이를 변하지 않는 고정적인 값인 '상수'로 만드는 것이다. 이렇게 const 나 #define 을 통해 변수를 심볼리 상수로 만든다면 변수의 개수가 적어지기 때문에 컴파일러 최적화에 유리하도록 만들어준다. 

 

또 다른 장점으로는 '심볼릭' 이라는 키워드에서 알 수 있듯이, 변수를 상수화시키되 의미를 부여하는 역할을 한다. 결국, 그 상수화 시킨 변수가 어떤 의미를 갖고 있는지를 명시함에 따라 코드의 가독성을 높일 수 있게 된다.

 

#include <stdio.h>

int main(void) {
    const int a = 3, b = 4;
    int res = 0;

    res = a + b;
    printf("%d\n", res);
    return 0;
}

 

#define 을 사용하는 예시코드는 아래와 같다.

 

#include <stdio.h>

#define a 3
#define b 4

int main(void) {
    int res = 0;

    res = a + b;
    printf("%d\n", res);
    return 0;
}

 

다음으로 알아볼 부분은 상수형 포인터이다. 위에서 우리는 심볼릭 상수를 정의할 때 정수와 같이 데이터(자료)를 상수화시켰다. 하지만 심볼릭 상수는 데이터 뿐만 아니라 포인터에 대해서도 상수화를 할 수 있다. 이를 상수형 포인터라고 하는데, 상수형 포인터를 정의할 수 있는 방법은 2가지이다. 다만, 이 2가지 방법 간에 수행하는 동작이 다르다.

 

먼저 포인터 변수 자체를 상수화하는 것이다. 앞서 배운것처럼 포인터 변수는 메모리 주소를 담고 있는 변수이고, 이 메모리 주소는 어떤 메모리가 담겨있는 위치 정보를 나타낸다. 그런데 이 포인터 변수에 담겨있는 메모리 주소는 언제든지 overwrite가 가능하다. 이렇게 메모리 주소가 다른 주소로 overwrite 되는 것을 사전에 아예 막아버리기 위해서 포인터 변수 자체를 상수화시킬 수 있다. 예시코드는 아래와 같다.

 

#include <stdio.h>

int main(void) {
    char szBuffer[32] = "Hello World";
    char* const pszBuffer = szBuffer;

    pszBuffer = NULL;
    printf("%p\n", pszBuffer);
    return 0;
}

 

위 소스코드를 실행하면 빌드할 때 에러가 발생한다. 문자열 포인터 변수 pszBuffer를 상수화시켰기 때문에 이후에 pszBuffer에 NULL을 단순대입을 하려니 에러가 발생하게 된다.

 

두번째 방법은 포인터 변수가 가리키는 데이터(자료)를 상수화하는 것이다. 엄밀히 말하면 위에서 살펴본 정수를 할당한 변수 앞에 const 키워드를 넣는 거랑 결과는 비슷할 수 있지만 이번에는 포인터 변수가 관여한다는 점이다. 예시 코드는 아래와 같다.

 

#include <stdio.h>

int main(void) {
    int nData = 10;
    const int* pnData = &nData;

    *pnData = 9999;
    printf("%d\n", *pnData);
    return 0;
}

 

위 코드도 빌드하면 에러가 발생한다.

3. 심볼릭 상수 : 열거형 상수

심볼릭 상수의 또 다른 종류로 열거형 상수가 있다. Python에서도 자주 사용하는 Enum과 동일하다. C언어에서는 enum이다. enum은 기본적으로 정수형으로 선언된다. 정수형으로 선언되는 특징 때문에 switch-case 문과 자주 사용된다. enum을 사용하는 예시코드는 아래와 같다.

 

#include <stdio.h>

enum ACTION { MOVE, JUMP, ATTACK };
typedef enum COLOR { RED = 100, GREEN, BLUE } COLOR;
typedef enum ANIMAL { LION, TIGER = 999, DOG } ANIMAL;

int main(void) {
    printf("%d %d %d\n", MOVE, JUMP, ATTACK);  // 0, 1, 2
    printf("%d %d %d\n", RED, GREEN, BLUE);    // 100, 101, 102
    printf("%d %d %d\n", LION, TIGER, DOG);    // 0, 999, 1000
    return 0;
}

 

특징은 enum도 구조체, 공용체에서 사용했던 것처럼 형재선언 키워드인 typedef를 같이 사용할 수 있다. 그리고 enum을 정의할 때 선언만 한다면 자동으로 0부터 1씩 증가하는 값이 할당된다. 그리고 만약 초기값도 같이 할당하게 된다면 초기값이 할당된 값 기준으로 뒤에 오는 상수들은 1씩 증가한다.

4. 최적화를 하지 않도록 설정 : volatile

다음으로 알아볼 형한정어 종류는 volatile 이다. volatile는 직전에서 살펴본 const 와는 반대의 성격을 갖는다. 즉, volatile로 선언한 변수는 컴파일러가 최적화하는 대상에 포함시키지 않는다는 것이다. 대체 어떤 경우에 최적화 대상에 포함시키지 않는 것일까? 우선 아래 예시 소스코드 2개의 각 어셈블리 코드를 보면서 살펴보자. 단, 반드시 Release 모드 빌드로 수행해서 컴파일러가 최적화를 하도록 상황을 만들어주는 것도 잊지말자.

 

디스어셈블리 코드 부분을 보자

 

디스어셈블리 코드 부분을 보면 컴파일러가 최적화를 진행한 것을 볼 수 있다. 주목할 부분은 컴파일러가 최적화를 진행함에 따라 소스코드 상에서 a, b 라는 변수에 할당된 상수 3, 4를 레지스터나 스택 메모리에 저장하는 기계어 코드를 디스어셈블리 코드에서 찾아볼 수 없다. 즉, 1번 목차에서 언급한 것처럼 컴파일러가 알아서 최적화를 시킨 것이다.

 

그런데 만약 소스코드 레벨이 아닌 외부의 어떠한 원인에 의해서 변수 a의 값이 3에서 다른 값으로 수정될 가능성이 있다고 해보자. 만약 이러한 경우가 발생할 가능성이 1%라도 있다면 변수 a의 값에 어떤 값이 저장되어 있는지 트래킹을 할 수 있어야 한다. 하지만 위처럼 Release 모드로 빌드를 하게 되면 변수 a의 값은 컴파일러가 소스코드를 최적화 하는 과정에서 사라지게 된다.

 

그래서 추적할 변수가 만약 a라면, 변수 a를 컴파일 타임에도 살려두기 위해 변수 a에 volatile을 명시해주면 된다. 아래 IDE 화면처럼 소스코드를 수정한 뒤, Release 모드 빌드 상태에서 디스어셈블리 코드를 살펴보자.

 

 

위 사진 속 초록색 점선 칸을 보자. 디스어셈블리 코드에서 volatile 키워드로 정의한 구문과 그 바로 윗줄을 보면 w8 이라는 레지스터에 상수값 3을 저장하고, 이후에 w8 레지스터에 있는 값을 x29 라는 레지스터에 저장하는 기계어 코드를 볼 수 있다. 결국, a라는 변수 값에 저장되어 있는 값 3을 기계어에도 흔적을 남기기 때문에 변수 a에 할당된 값이 어떤 값인지 계속 트래킹이 가능해지는 것이다.

 

다만, 이러한 volatile을 사용하는 케이스는 보통 임베디드 시스템에서 자주 활용되고 일반적인 PC 프로그램이나 우리가 흔히 사용하는 애플리케이션 코드에서는 잘 사용되지는 않는다고 한다. 이유는 임베디드의 경우, 프로그램이 직접 하드웨어에 접근하지만 일반적인 PC 프로그램의 경우, OS를 통해서 하드웨어에 접근하기 때문이다. 

5. 형 재선언(typedef)과 extern 선언

형 재선언은 앞서 많이 살펴보았으므로 사용 방법은 따로 설명하지 않겠다. 주의할 점은 형 재선언을 너무 남발하면 가독성을 매우 떨어뜨리는 코드가 되니 이 점만 참고하도록 하자.

 

extern 선언도 사실 이전에 분할 컴파일 내용을 배울 때 이미 살펴본 내용이다. 물론 그 때 당시 포스팅에서 extern 키워드를 사용하지 않았지만, 기본적으로 extern을 명시하지 않아도 내부적으로 extern을 생성해서 동작한다. extern은 다른 C 소스코드 파일에 정의되어 있는 전역 변수에 접근 하기 위해 사용된다.

 

 

반응형