8. 포인터
 
이번 장은 C를 배울 때 가장 어렵다는 포인터에 대한 내용입니다. 포인터는 메모리에 대한 이해만 있으면, 그다지 어렵지 않습니다.

컴퓨터는 아래와 같이 동작합니다. 하드디스크에서 메모리로 데이터를 읽거나 쓰고, CPU는 메모리에 있는 명령어들과 데이터를 레지스터라고 불리우는 저장소로 불러와 처리합니다.
사용자 삽입 이미지
우리가 만든 하드디스크에 있는 실행파일은 실행시에 코드와 데이터 모두 메모리에 저장된 후 사용됩니다. 그렇기 때문에 선언하는 전역, 지역 변수들 모두 할당된 크기로 메모리에 저장됩니다. 

CPU는 메모리에 접근하기 위하여 1byte단위로 메모리에 주소를 할당해 놓고 사용합니다. 우리는 사용하기 쉽게 int a; 로 선언하고 a라는 변수명으로 사용하지만, 실제 컴퓨터는 a라는 이름 대신 메모리 주소로 접근합니다. 이 때 사용하는 주소를 포인터라고 보시면 됩니다.


8.1 변수의 메모리 주소

사용자 삽입 이미지
포인터는 간단히 이야기 하면 메모리 상에서 주소를 저장하는 변수 입니다.  좌측 그림과 같이 메모리가 1byte 단위로 길게 늘어선 구조로 생각하고, 메모리에 있는 데이터에 접근을 하기 위해서는 메모리 상의 주소가 있어야 합니다.

int a = 3;
위의 코드가 실행되면 int 크기인 4byte만큼의 메모리가 할당되고, 그 할당된 메모리에는 3이 저장됩니다. 이것을 그림으로 좌측과  같습니다.

1011로 시작되는 숫자가 메모리상의 주소를 나타냅니다. 이 숫자는 제가 임의로 나타낸 것이고, 프로그램 실행 시 OS에 의해서 할당됩니다. 한 칸은 1byte입니다.

a의 주소는 1014이며, int는 4바이트이므로 1014~1017까지 위치합니다.
 


 이제 실제로 a변수의 주소를 출력해 보겠습니다.
#include <stdio.h>

int main()
{
    int a = 3;

    printf("A addr: 0x%u => %d", &a, a);

    return 0;
}

printf("A addr: 0x%u => %d", &a, a);
%u는 unsigned int를 출력합니다. 메모리 주소는 일반적으로 10진수 보다 16진수(%x)를 일반적으로 사용하지만, 지금은 10진수를 사용하겠습니다.

눈여겨 보아야 할 것은 주소 연산자(&) 입니다. 변수 앞에 &를 붙이면 그 변수에 들어 있는 값이 아니라, 그 변수가 메모리에서 위치한 주소를 나타냅니다. 위를 컴파일하면 아래와 같이 나옵니다.  
 
> A addr: 1014 => 3

1014가 변수 a의 메모리상의 위치 입니다. 1014는 제가 만든 임의의 수이고 실행 해 보시면, 실제 a의 메모리 주소가 출력됩니다.


8.2 포인터(*)의 이해

1. 포인터의 선언

위와 같은 메모리상의 주소를 보관하기 위한 변수가 포인터입니다. 위의 소스에 포인터 변수를 사용해 보겠습니다. 포인터는 변수 앞에 *를 넣어 선언합니다.

#include <stdio.h>

int main()
{
    int a = 3;
    int *p;
    
    p = &a;
    
    printf("A addr: 0x%u => %d", p, *p);

    return 0;
}
 
int *p;
a 변수의 주소를 저장하기 위해 포인터 변수 p를 선언합니다. 포인터 변수의 선언에서 *의 위치는 변수형과 변수명 사이에 위치하며, 아래의 선언 모두 유효합니다.
int* p;
int *p;
int * p;

p = &a;
포인터 변수 p에 a의 주소를 넣습니다.주의할 점은 a의 값 3이 들어가는 것이 아니라, 메모리 주소가 들어 갑니다.

printf("A addr: 0x%u => %d", p, *p);
포인터 변수는 p처럼 그냥 사용하면 주소가 들어 가지만, 앞에 * 표시를 해주면 그 주소에 있는 값을 나타냅니다. p는 a 변수의 주소를 가지고 있기 때문에 *p는 a의 값인 3입니다.


2) 포인터 변수의 크기

포인터는 주소를 나타내기 때문에 동일한 크기를 가지고 있으면 됩니다. 32비트 주소 체계를 가지고 있는 컴퓨터에선 32비트의 크기만 있으면 됩니다.

그런데 포인터는 모든 변수 타입에 줄 수 있습니다. 그러면 char *, int *, float*는 어떤 의미가 있을 까요? 일단 아래의 소스로 각 타입의 포인터 변수의 크기를 알아 보겠습니다.

#include <stdio.h>
int main()
{
    printf("%d, %d, %d\n", sizeof(int *), sizeof(char *), sizeof(float *));

    return 0;
}

위의 소스를 실행하면 셋 모두 동일한 크기를 출력하며, 대부분의 컴퓨터에서 4(4byte = 32bit)를 출력합니다.

그러면 포인터 변수는 int와 char등과 달리 같은 크기를 가지고 있는데, 선언 시에 변수타입은 어떤 의미가 있는지 알아 보겠습니다.

#include <stdio.h>

int main()
{
    int n = 3;
    char c = 'c';

    int* pn = &n;
    char* pc = &c;

    printf("char point: %u, %u, %c\n", pc, pc + 1, *pc + 1);
    printf("int point: %u, %u, %d\n", pn, pn + 1, *pn + 1);

    return 0;
}

위의 코드를 실행 시키면 아래와 같이 출력됩니다.

> char point: 3221224580, 3221224581, d
> int point: 3221224576, 3221224580, 4

위에서 주소의 숫자는 중요하지 않고, 컴퓨터 마다 다르게 나옵니다.

주소 부분을 눈여겨 보면 char형 포인터는 1 증가시 3221224580에서 3221224581로 1이 증가되었습니다. 하지만 int형 포인터는 3221224576에서 3221224580로 4가 증가 되었습니다.

포인터 변수에 1을 더하면 그 타입의 크기만큼 주소가 증가 합니다. pn + 2이면 2X4=8로 12가 증가되게 됩니다. 이유는 int형 데이터를 다루기 위해서는, 4byte가 한 단위가 되어야 합니다. 1씩 증가되면 올바르게 메모리에서 int형 자료들을 다룰 수가 없습니다.

이는 아래의 그림과 같습니다.
사용자 삽입 이미지


3) 포인터 변수의 초기화

모든 변수들이 정확한 값을 가져야 하지만, 특히 메모리에 직접 접근하는 포인터 변수는 반드시 초기화 된 후에 사용하여야 합니다. 아래의 소스를 보겠습니다.

#include <stdio.h>

int main()
{
    int* pn;
    
    printf("%u, %d", pn, *pn);
    
    return 0;
}  

위와 같이 포인터 변수가 초기화 되지 않고 사용할 경우에는 매우 위험합니다.  지역변수 int* pn은 스택 메모리에 자리잡기 때문에, int * 를 위해 4byte가 할당되지만, 그 안에 내용은 이전에 쓰던 내용이 있을지 어떤 값이 있을지 모릅니다.
 
 *pn으로 그 주소의 4byte int형을 출력하는데, 주소에 들어있는 값이 초기화 되지 않은 쓰레기값이기 때문에 어떤 주소를 참조할지, 그 주소에는 어떤 값이 있을지 모르기 때문입니다.
 
잘못된 포인터(메모리)의 사용은 프로그램 실행시에 치명적인 오류를 일으킬 수 있기 때문에, 항상 주의하여 사용하여야 합니다.


4) 메모리 할당과 해제

우리가 지금까지 보았던 변수들의 최고 크기는 double로 8바이트까지 저장할 수 있습니다. 그러면 10mb되는 이미지를 메모리로 불러와서 작업을 할려면 어떻게 해야 할까요?
 
답은 malloc이란 함수로 시스템으로 부터 메모리를 할당 받아야 합니다. malloc은 다음과 같이 선언되어 있습니다.

void * malloc(size_t size);
여기서 size_t는 unsigned int로 보시면 됩니다. (후에 typedef가 나올 때, 설명하겠습니다.) size는 필요한 메모리의 byte수 입니다.

함수에서 void는 반환값이 없다는 뜻이지만, void 포인터(void*) 는 형없이 반환할테니, 필요에 따른 변수타입의 포인터로 형변환(type casting)해서 사용하라는 뜻입니다. 시스템은 size만큼의 메모리 할당에 성공하면 그 메모리가 시작하는 포인터를 반환하고, 메모리 부족 등의 이유로 할당에 실패할 경우에는 NULL을 반환합니다. NULL은 0(typedef)이며 포인터 등 메모리와 관련해선 0 대신 NULL로 사용합니다. 

아래의 소스를 보겠습니다.

#include <stdio.h>
#include <stdlib.h>

int main()
{
    int i;  
    char* p;

    p = (char *)malloc(8);
    if(p == NULL)
    {
        printf("fail to alloc\n");
        return 0;
    }

    *p = 'H';
    *(p+1) = 'e';
    *(p+2) = 'l';
    *(p+3) = 'l';
    *(p+4) = 'o';
    *(p+5) = '\n';
    *(p+6) = '\0';

    printf("%s", p);
    
    free(p);

    return 0;
}

p = (char *)malloc(8);
malloc으로 8byte의 메모리를 요구합니다. 시스템은 다른 곳에서 사용하지 않는 8byte 메모리를 할당해서 그 시작 주소를 반환합니다.

 NULL이 반환되어 메모리 할당에 실패하였을 경우에는 오류메시지를 출력하고, 종료합니다.

사용자 삽입 이미지
*p = 'H';              // *(1013) = 'H';
*(p+1) = 'e';        // *(1014) = 'e';
*(p+2) = 'l';         // *(1015) = 'l';
*(p+3) = 'l';         // *(1016) = 'l';
*(p+4) = 'o';        // *(1017) = 'o';
*(p+5) = '\n';      // *(1018) = '\n';
*(p+6) = '\0';       // *(1019) = '\0';

메모리에 "Hello\n" 문자를 한바이트 씩 할당 합니다. *p+1로 하면 포인터(*)가 우선순위에 있으므로 값(*p)에 1을 더하는 의미가 됩니다.

그러므로 좌측 변수가 올자리에 와서는 안되기 때문에 컴파일 시 오류가 발생합니다. 그래서 주소가 1 증가되는 곳에 할당 시에는 괄호를 사용하여 *(p+1) = 'e'; 로 사용하여야 합니다.

C에서 문자열은 '\0'로 끝나는 char형 배열입니다. 배열은 이 다음장에 설명을 하겠습니다. 문자열은 마지막에 반드시 '\0'로 막아야 한다는 것을 명심해야 합니다. (NULL == '\0' == 0)

printf("%s", p);
printf의 %s는 문자열을 출력할때 사용합니다. 위의 소스를 실행하면 Hello\n를 출력합니다.

free(p);
마지막 free 함수는 이제 메모리 사용이 끝났으니, 반환하겠다는 의미입니다. freep이 후로는 포인터 p를 사용하여서는 안됩니다. free는 malloc과 한쌍으로 malloc으로 할당 받은 메모리는 반드시 free로 반환을 해야 합니다.

특히 반복되는 작업에서 사용한 메모리를 반환하지 않으면, 메모리 누수가 계속 일어나 프로그램과 OS에 치명적인 오류를 일으킬 수 있습니다.

이상 포인터에 대해서 간단히 설명하였습니다. 포인터는 다음 장에 설명 할 배열과 밀접한 관계에 있기 때문에, 다음 장에서 다시 논해 보도록 하겠습니다.

'프로그래밍 강좌 > C 언어 기초' 카테고리의 다른 글

10. struct, union, enum, typedef  (2) 2007.06.21
9. 배열 (array)  (0) 2007.06.17
8. 포인터 (pointer)  (4) 2007.06.16
7. C 함수 (function)  (4) 2007.06.15
6. 제어문  (0) 2007.06.14
5. 연산자  (0) 2007.06.13