[만화가 있는 C] 6. 포인터, []연산자

6. 포인터(pointer), [] 연산자

  포인터를 이해하기 위해서는 먼저 주소address라는 용어를 이해해야 합니다. 주소는 메모리의 각각의 셀cell에 붙여진 일련번호를 말합니다. 디스크에 파일file로 존재하는 프로그램은 항상 메모리에 로드load된 다음, 실행execute됩니다.

 프로그램이 메모리에 로드된 후 실행중일 때, 프로세스process 혹은 태스크task라고 한다. 그러므로, 여러 프로그램을 동시에 실행시킬 수 있는 Windows 같은 운영체제를 멀티태스킹multitasking을 지원한다고 합니다.

  CPU가 메모리에 있는 적절한 데이터를 – 이것이 변수이든, 실행 코드이든 – 가지고 와서 명령을 실행하기 위해서는 이 값을 참조해야 합니다. 그러므로 메모리의 각각의 셀에는 일련 번호, 즉 주소가 붙여져 있으며, 우리가 참조하는 변수 이름은 실제로는 컴파일러에 의해 주소로 번역된 값입니다.

 메모리의 주소: 위의 그림에서 주소는 0~255입니다. 그러므로 주소를 나타내기 위해 8비트를 사용할 수 있습니다. 2번지에는 100이란 값이 들어 있습니다. 254번지에는 200이 들어 있습니다. 100이나 200을 정수로 생각해서는 안 됩니다. 100은 해석하기에 따라 정수, 실수 혹은 주소가 되기도 합니다. 우리가 int i=100; 이라고 코딩하면, 프로그래머의 입장에서는 i에 100이 대입되는 것으로 생각합니다. 하지만, 실제로는 컴파일러가 생성한 i의 위치 – 만약 그것이 2번지라고 하면 – 에 100이 들어가는 것입니다. 프로그램이 실행중일 때 i 자체는 어디에도 없습니다. 단지 2번지의 추상화된 이름일 뿐입니다.

  메모리 한 셀은 몇 바이트를 의미할까요? 즉 1000번지는 2개의 바이트를 가리키는 것일까요, 아니면, 1바이트를 가리키는 것일까요? 메모리 번지가 바이트 단위로 접근access 가능하면 이러한 기계를 바이트 접근가능 기계byte accessible machine라고 합니다. IBM 호환 기종의 PC는 바이트 단위의 접근이 가능 합니다. C언어로 메모리 할당을 하면, 운영체제의 제한 때문에 바이트 단위의 블록 할당은 발생하지 않을 수 있습니다. 하지만, 한 번 할당된 메모리의 모든 부분을 바이트 단위로 접근하는 것은 가능합니다.

  C에서의 포인터 변수는 일반적인 ‘가리킨다(point)’라는 의미로 쓰인 포인터 변수와는 구분되어야 합니다. 일반적으로 정수값이 어떤 부분을 ‘가리키기’위해 사용되었을 때, 이러한 변수를 포인터라고 부를 수 있습니다. 예를 들어 스택(stack)의 윗 부분 – 데이터가 들어갈 부분 – 을 가리키는 정수형 변수를 스택 포인터stack pointer라고 합니다. 문맥상 이러한 의미가 모호하다면, 포인터 변수를 주소 변수(address variable)라고 부르는 것이 좋습니다.

  포인터에 대한 이해는 다음과 같은 간단한 프로그램의 실제 동작을 이해하는 것에서부터 시작합니다.

    void main() {
        int i;
        int j=2;
        i=j;//이 문장에서 실제로 무엇이 일어나는가?
    }

  위의 프로그램은 j의 값을 i에 대입합니다. 방금 전의 문장을 주의 깊게 다시 한번 읽어 보세요. i=j라는 문장은 j를 i에 대입하는 것이 아닙니다. 또한 j의 값을 i의 값에 대입하는 것이 아닙니다. j의 값을 i에 대입하는 것입니다. 즉 등호(=)의 왼쪽에 쓰인 i와 오른쪽에 쓰인 j를 해석하는 방법이 다른 것입니다.

  컴파일러가 실행 코드를 생성할 때, 변수의 주소와 함수 호출의 적절한 코드를 생성하기 위해, 심벌 테이블(symbol table)을 생성하여 유지합니다. 물론 이 테이블은 코드를 생성하기 위해(compile-time) 만들어져서, 실행할 때(run-time)에는 필요가 없으므로 사용하지 않습니다.

 일반적으로 컴파일러는 소스 코드를 두 번 스캔scan합니다. 첫 번째 스캔에서 변수의 주소와 함수의 주소 등을 해결하기 위하여, 심벌 테이블을 구성합니다. 두 번째 스캔에서 심벌테이블을 참고하여 실제의 코드를 생성합니다. 이러한 컴파일러를 2-패스 컴파일러2-pass compiler라고 합니다.

  main()안에서는 두 개의 정수형 변수가 선언되었습니다. 컴파일러가 계산한 i,j의 메모리 주소(memory address)가 각각 100, 104였다고 가정합시다.

 변수의 주소가 몇 번지가 될는지는 정확하게 예측할 수 없습니다. 컴파일러는 최적의 코드를 생성하기 위해 적절한 변수의 주소를 결정합니다. 100을 정수 100으로 생각해서는 안 됩니다. 언어에 의해서 정의되는 포인터 표현 100을 의미합니다. 또한 j의 주소값은 반드시 104가 되어야 합니다. 정수는 4바이트를 차지하므로, i다음의 j위치는 4바이트 이후의 주소 값이기 때문입니다.

  그러면 컴파일러는 코드를 생성할 때 다음과 같은 심벌 테이블을 유지합니다.

변수/함수 이름

실제 주소

i

100

int

j

104

int

 심벌 테이블: 실제의 심벌 테이블에는 속성(attribute)이 3개만이 아닙니다. 지금은 설명을 위해 3개의 속성만을 나타내었습니다.

j=2; 라는 문장에 의해 104번지에서 시작하는 4바이트에 2라는 정수값이 들어갑니다.

 j=2; 의 실행후 메모리의 상태: [104]번지에 정수 값 2가 들어있습니다.

  i=j; 라는 문장에 의해 컴퓨터 내부에서는 무슨 일이 일어나는 것일까요? 등호의 왼쪽 i는 심벌 테이블의 100을 의미합니다. 정수와 주소를 구분하기 위해 이를 [100]으로 쓰기로 합시다. 또한 등호의 오른쪽 j는 심벌 테이블 [104]가 가리키는 값을 의미합니다. 이를 [104]*라고 쓰기로 합시다. 그러면 i=j; 는 아래와 같은 문장으로 번역됨을 알 수 있습니다.

[100]=[104]*

  우리는 위 문장에서 같은 정수 표현이 등호의 왼쪽에 쓰일 때와 등호의 오른쪽에 쓰일 때에 따라서 다르게 해석된다는 것을 알 수 있습니다. 등호의 왼쪽에 오는 값은 반드시 주소address라야 합니다. 이를 왼쪽 값(l-value, left value)이라고 합니다. 등호의 오른쪽에 오는 값은 반드시 값value이어야 합니다. 이를 오른쪽 값(r-value, right value)이라고 합니다. l-value의 의미를 명확하게 이해하고 넘어가기 바랍니다. C++에 추가된 l-value reference를 이해하는데 필요한 개념이기 때문입니다. 다음의 문장은 l-value의 제약을 어긴 에러입니다.

3=4;

 이 문장을 컴파일해 보면. “error C2106: ‘=’ : 왼쪽 피연산자는 l-value이어야 합니다”란 에러메시지를 출력합니다.

  그렇다면, i의 주소값인 [100]을 메모리에 저장하는 방법은 없을까요? 메모리에 정수값이 들어가면 정수형 변수라고 합니다. 주소값이 들어가면 주소형 변수라야 할 것입니다. 이러한 주소형 변수를 포인터pointer라고 합니다.

  만약 저장하고자 하는 주소가 정수를 가리킨다면, 정수형 주소 변수를 선언해야 합니다. 주소변수를 선언하는 방법은 형 이름과 변수 이름 사이에 별표asterisk(*)를 삽입하는 것입니다. 정수형 주소변수는 다음과 같이 선언합니다.

int * ip;

 *는 형 이름(int)과 명칭(identifier)사이에 위치합니다. 하지만, *는 구분자delimiter로서 하나의 토큰이므로, 위의 예에서 int와 혹은 ip와 흰공백(white space)으로 구분하지 않아도 됩니다. 즉, int* ip; int *ip; int * ip; 세가지 모두 좋습니다. C 스타일은 int *ip; 를 많이 사용했습니다. 하지만, C++ 스타일은 int* ip; 를 사용할 것을 권장합니다. 이것은 *가 형을 결정짓는 역할을 하기 때문에 그렇습니다. 하지만, *는 매 변수 이름마다 명시되어야 합니다. int *ip,i;는 ip를 포인터로 i를 정수로 선언한 것입니다. i도 포인터로 선언하기 위해서는 int *ip,*i; 처럼 선언해야 합니다. 그러므로 C++에서는 ip와 i를 포인터로 선언하는 방법으로 int* ip; int* i;를 권장하고 있습니다.

  위의 문장과 char * ip; 와의 차이점은 무엇일까요? 어차피 포인터 변수라면 같은 것이 아닌가요? 아닙니다. 이 질문에 대한 자세한 설명은 다음에 다루도록 하겠습니다. 하나의 변수 선언 문장으로, 여러 개의 포인터 변수를 선언하기 위해서는 변수 이름마다 *를 붙여주어야 합니다.

int *ip, *ip2;

  변수가 주소형 변수로 선언되지 않았을 때, 실제로 이 변수가 위치하는 메모리의 위치를 알 필요가 있습니다. 이것은 주소 연산자(address-of operator, &)로 가능합니다. 주소연산자는 변수나 함수 이름 앞에 앰퍼샌드(ampersand, &)를 붙여서 표현합니다. 그러므로 &i는 [100]을 의미합니다. 하지만, i=&j; 라는 문장은 가능하지 않습니다. 정수형 변수 i에 주소값을 대입하는 것은 가능하지 않기 때문입니다. i는 int *i; 로 선언되어야 할 것입니다. int *i; 라고 선언되었을 때, &i는 i값(주소값)의 실제 주소값(주소의 주소값)입니다. i는 값(주소값)입니다. *i는 i값이 가리키는 값(정수값)입니다. 우리는 포인터 변수를 선언하는 시점의 *와 사용하는 시점의 *를 구분하여야 합니다. 선언할 때, *는 그 변수가 포인터 변수임을 의미합니다. 하지만 사용할 때 *는 ‘포인터 변수가 가리키는 값’을 의미합니다. *는 메모리를 두 번 참조하여야 합니다. 그래서 *를 간접 지정 연산자indirect operator라고 합니다.

  아래의 예제를 고려해 봅시다.

    #include <stdio.h>

    void main() {
        int i;
        int j=2;
        int* ip;//*는 ip가 포인터 변수임을 의미한다

        i=j;//이 문장에서 실제로 무엇이 일어나는가?
        ip=&i;
        printf("%d,%d,%p,%p\n",j,*ip,ip,&ip);//*는 간접지정연산자이다
    }

  만들어진 심벌 테이블은 다음과 같습니다.

변수/함수 이름

실제 주소

i

100

int

j

104

int

ip

108

int*

 심벌 테이블: 프로그램에 사용된 모든 명칭(함수이름, 변수이름, 형이름 등)에 대해서 컴파일 시간에 정보를 유지합니다.

  메모리의 구조는 다음과 같습니다.

 메모리의 구조

  위의 메모리 상황에서 ip=[100]입니다. &ip=108입니다. *ip는 [100]*이므로, 2입니다. printf()에서 지원하는 Escape 시퀀스는 포인터를 출력하기 위해서 %p를 사용합니다. 그러므로 위 프로그램의 실행결과는 다음과 같습니다.

2,2,[100],[108]

실제로 [100], [108]은 주소값이 16진수로 출력됩니다. 메모리 모델에 따라 0x????:0x???? 혹은 0x???? 혹은 0x????????형태로 출력됩니다.

 ?는 any character를 의미하는 와일드 카드wild card입니다. 16bit 도스Dos 운영체제에서, 0x????형태로 출력되는 포인터는 16비트 포인터, 즉 가까운 포인터near pointer라고 했습니다 0x????:0x????형태는 32비트 포인터, 먼 포인터far pointer라고 했습니다. 예전에 도스 프로그램을 할 때는, 메모리 모델에 상관없이 가까운 포인터를 선언하려면 char near * i; 처럼 선언했습니다. 먼 포인터인 경우, char far * i; 처럼 선언했습니다. Win32 같은 32bit 운영체제에서는 주소값은 32bit 즉 16진수 8자리로 출력됩니다.


문자열(string)은 포인터 표현입니다.

  C는 문자열을 포인터로 관리합니다. 문자열 끝에 문자열의 끝(end of string, EOS)을 나타내는 특수문자 0이 위치합니다.

 C언어는 EOS를 사용하여 문자열의 끝을 표현합니다. 하지만, Pascal 같은 언어는 문자열의 선두에 문자열의 길이를 집어 넣어 문자열을 관리합니다. 그러므로 Pascal에서 문자열의 길이를 구하는 함수는 배열의 첫 번째 요소를 구하는 방식으로 구현합니다. 하지만 C에서는 문자열의 선두 포인터로부터 ‘\0’을 만날 때까지의 문자의 개수를 구하여야 합니다.

  0은 제어 문자control character이므로, ‘\0’ 처럼 표현하기도 합니다. 우리는 ‘\0’자체가 문자열에 포함되는 경우를 염려하지 않아도 됩니다. ‘\0’이 문자열의 중간에 위치한다면 C언어는 ‘\0’이 위치한 앞까지를 하나의 문자열로 취급합니다.


Q. 그러면 “hello\0world\0″는 “hello\0″와 같은 표현인가요?

A. 그렇지 않습니다. “hello\0world\0″는 메모리를 13바이트(5+\0+5+\0+\0) 차지할 것입니다. 하지만, “hello\0″는 메모리를 7바이트 차지합니다. 그렇지만, 2개의 문자열을 출력하면 모두 hello를 출력할 것입니다. 왜냐하면 표준 출력함수는 ‘\0’을 만나면 문자열의 끝으로 판단하기 때문입니다.

  다음 문장을 봅시다. s의 형은 무엇이 되어야 할까요?

s=”hello”;

  문자열이 포인터로 관리된다는 점에 주의하세요. 그러므로 s는 다음과 같이 선언되어야 합니다.

char *s;

  그렇다면 s에는 무슨 값이 들어가는 걸까요? 컴파일러는 s=”hello”; 문장을 다음과 같이 해석합니다. 메모리의 적절한 영역  – 컴파일러가 관리하는 힙(heap) – 에 “hello”+’\0’을 차례대로 집어넣습니다. 문자 5개와 0(EOS)을 포함하여 6바이트를 사용합니다. 그리고 첫 번째 문자 ‘h’의 시작 주소를 돌려줍니다. 그러므로, “hello”는 ‘h’의 시작 주소 표현입니다. 그러므로 s는 char *s; 처럼 선언되어야 하는 것입니다.

  일반적으로 C 프로그래머들은 s=”hello”; 란 문장을 해석하기를 “문자열 hello를 s에 대입한다.”라고 알고 있습니다. 하지만 그것이 아닙니다. 올바른 해석은 다음과 같습니다.

“hello”+’\0’를 메모리의 적절한 영역에 집어 넣은 다음, 첫번째 문자 ‘h’의 시작 주소를 s에 대입합니다.

 s=”hello”;의 메모리의 상태: 마지막에 항상 0이 추가된다는 사실을 주의하세요.

  그렇다면 *(s+1)은 무엇을 의미할까요? s가 1000번지이므로 [1001]*를 의미합니다. 즉 문자 ‘e’입니다. *(s+1)은 연산자 []를 사용하여, s[1]로 나타낼 수 있습니다. 다음을 기억하여 둡시다.

  []의 정확한 이름은 배열 첨자 연산자array subscript operator입니다. exp1[exp2]는 컴파일러에 의해 *((exp1)+(exp2))로 번역됩니다.

*(s+n) ≡ s[n]

  s와 n의 위치를 바꾸어 *(n+s)라고 쓸 수 있듯이, n[s]라고 쓸 수 있음에 유의하세요. 그러므로 s[1]은 1[s]라고도 쓸 수 있습니다. 다음의 예제는 우리가 연산자 []의 역할을 이해하는데 많은 도움이 될 것입니다.

#include <stdio.h>

void main() {
    char *s;

    s="hello";
    printf("%c,%c,%c,%d\n",*(s+1),s[1],1[s],s[5]);
    //      e,e,e,0 이 출력된다.
}

진보된 주제: 포인터의 포인터, 함수 포인터

  포인터에 대해서는 아직 이야기할 것이 많습니다. 하지만, 다른 장에서 필요할 때 다루도록 하겠습니다. 함수 포인터에 관한 사항은 “19. 함수 포인터”를 참고하세요.

  또한 C++의 동적 메모리 할당 연산자 new와 delete가 사용될 때는 언제나 포인터가 사용됩니다.

  C언어의 전성기 시절, 포인터는 가장 중요하지만 이해하기 어려운 주제였습니다. C++의 클래스가 중요해진 지금 시점에서도 포인터는 여전히 중요하고, 이해하기 어려운 주제입니다. Java같은 고급언어는 포인터를 지원하지 않습니다. C/C++은 포인터를 가장 완벽하게 지원하는 언어입니다. 그리고 이 포인터의 지원이 C/C++을 고급언어와 기계어의 사이에 위치시키는 이유입니다.


실습문제

1. 아래의 문장에 에러가 있다면 에러를 수정하세요(힌트: 에러가 아님).

#include <stdio.h>
void main() {
    char* s="hello"
            " world\n"
            "There was a"
            " white house in the town."
    printf(s);
}

@

만화가 있는 C

Leave a Reply