C프로그래머가 알아야 할 것들 - 05 메모리와 포인터

C프로그래머가 알아야 할 것들 - 05 메모리와 포인터

메모리를 알자

우리가 계산을 할 때에 일반적으로 데이터와 연산자가 필요합니다.

예를 들어, 1 + 2 라는 식을 계산 하기 위해선, 1과 2라는 데이터가 필요하고, + 라는 연산자가 필요하죠.

우리가 노트에 계산을 할 때에는 계산 결과를 노트에 표기 합니다.

계산 결과를 기록해 두는 이유는 그 계산 결과를 가지고 다른 연산을 해야 하거나, 그 결과 자체가 의미를 갖기 때문입니다.

컴퓨터에서 계산된 결과를 위해서 어떻게 해야 할까요?

바로 변수에 저장하면 됩니다.

int a = 1 + 2;

이렇게 하면, 1 + 2 의 결과가 변수 a에 저장 됩니다.

아쉽게도(?) 변수 a도 결국 어딘가에 저장이 되어야 합니다. 대부분의 C언어 문법 서적들에선, 변수가 데이터를 저장하는 곳이라고 배웁니다. 하지만, 좀 더 자세히 파고들어서 설명하자면 변수의 이름 a는 데이터가 저장된 위치(주소)의 이름이지, 데이터를 저장하는 곳이 아닙니다.

데이터가 실제로 저장 되는 곳은 바로 메모리(Memory)입니다.

메모리에는 변수, 구조체 등의 데이터만 기록되는 것아 아니고, 우리가 사용하는 명령어도 저장됩니다.

int a = 1 + 2;

int b = a + 3;

위 코드를, 디스 어셈블 하면 다음과 같은 코드를 얻습니다.

int a = 1 + 2;
0041137E   mov     dword ptr[a],3

int b = a + 3;
00411385   mov     eax, dword ptr[a]
00411388   add      eax, 3
0041138B   mov     dword ptr[b], eax

디스 어셈블 된 코드를 보시면, 1 + 2된 결과인 3을 a에 옮기고 (mov), eax(누산기 레지스터)에 a의 값인 3을 대입한 후, eax 에 3 을 더한 후 (add), 그 값을 다시 b에 옮기는 (mov) 과정을 보여주고 있습니다.

지금 보았던 연산 과정이 모두 메모리에 담겨 있는 명령어를 통해서 이루어 집니다.

메모리에는 데이터뿐만 아니라 명령어들도 담겨 있습니다. 우리가 프로그램을 실행 시키면, 해당 프로그램의 코드가 메모리에 담기고, 코드의 흐름에 따라 여러 코드가

이제 메모리에 대해 감이 오시나요?

변수와 포인터의 차이점

변수는 데이터가 저장 된 메모리 위치(주소)의 다른 이름이고, 포인터는 메모리(=메모리 주소)를 가리키는 변수입니다.

포인터는 데이터가 아닌 메모리 주소를 가지고 있어서, 그 메모리의 주소에 있는 데이터를 제어 할 수 있죠. 아래 표는, 변수와 포인터의 차이점을 정리한 것입니다.

변수의 포인터의 차이점

|분류|변수|포인터| |—|—|—| |가지고 있는 값|데이터|메모리 주소| |사이즈|데이터 형에 따라 다름|4Byte (32비트 운영체제에서)|

포인터는 메모리를 가리킬 때 데이터가 있는 곳만을 가리켜야 합니다. 그래서, 대부분 포인터를 NULL로 초기화 (NULL포인터라 부릅니다.) 한 후, 사용할 때 포인터의 NULL 검사를 통해서, 데이터가 담긴 포인터인지, 아닌지를 검사하고 사용합니다.

변수를 사용할 때, 0으로 초기화 하는 것과 비슷한 이유지만, 포인터에서 잘못된 데이터를 사용할 때의 문제가 더 크기 때문에, 변수보다 조금 더 신중하게 사용하셔야 합니다.

포인터를 써보자

포인터에 대해 알아보았으니 이제 포인터를 사용해봅시다. 포인터 선언은 다음과 같이 해주면 됩니다.

int *ptr; //int 형 포인터 pointer 선언

포인터는 메모리 주소를 가지는 변수이기 때문에, 포인터 변수에는 주소 값을 대입해 주어야 합니다.

int no = 10;

int *ptr = &no; //포인터를 선언 하면서 주소를 대입할 때

포인터를 선언한 후에, 변수의 주소를 대입하는 코드입니다.

int no = 10;
int *pointer; //포인터를 선언만 함

pointer = &no; //포인터를 미리 선언 한 후에 주소를 대입할 때

포인터도 데이터 형(int, char, float, 구조체형 등)을 가지고 있으며, 그 형식에 맞는 데이터만을 가리킬 수 있죠.

포인터에 데이터 형이 있는 이유는 메모리에는 명령어와 데이터가 함께 담기고, 데이터도 크기에 맞춰서 (데이터 형의 크기만큼) 배치 되어 있는데, 실제 데이터가 존재하는 메모리를 그 크기만큼 가리키고 사용해야만 하기 때문입니다. (캐스팅 연산자를 이용하여 포인터 형 강제 변환이 그래서 위험합니다)

예외로 void형 포인터가 있는데, void형 포인터는 데이터 형에 관계 없이 데이터의 시작 주소를 저장하기 위해서 사용됩니다.

포인터에 주소를 대입해 주고 나면, 이제부터 포인터가 가리키는 주소의 데이터를 제어 할 수 있습니다. 아래의 표는 포인터의 표현 방식을 보여주는데요, 포인터 자체의 주소는 별로 쓰이지 않는 편이지만, 나머지 두 표현 방식은 빈번하게 쓰이기 때문에, 반드시 이해를 해두세요.

포인터의 세가지 표현 방식

|분류|의미| |—|—| |*ptr|포인터가 가리키고 있는 주소의 값| |ptr|포인터가 가리키는 주소| |002|포인터 자체의 주소|

포인터를 쓰는 이유

포인터의 사용법에 대해 알아보았으니, 이번에는 포인터를 왜 사용하는지 알아보겠습니다.

개인 신상정보 (이름, 생년월일, 성별, 전화번호, 주소, 취미 등)를 담고 있는 데이터가 있습니다. 그런데, 친구들의 주소록을 구성해야 해서, 신상 정보 중에, 이름, 전화번호, 주소가 필요합니다.

주소록에서 필요로 하는 이름, 전화번호, 주소 모두 이미 신상 정보로써 존재하는 데이터입니다. 이 데이터를 복사해서 주소록과, 신상 정보의 데이터를 각각 따로 가져 보겠습니다. (변수를 생성하여 데이터 복사) 데이터를 각각 따로 가지고 관리 할 때엔 이사를 가거나, 전화번호가 바뀌어서 정보를 수정할 때 두 곳의 정보를 모두 갱신해 주어야 합니다. 만약 실수로 한 곳의 정보만 갱신해주고, 다른 한곳의 정보는 그대로 놔둔다면, 두 정보는 일치해야만 하는 정보임에도 불구하고, 서로 다른 정보를 가질 수도 있다는 문제가 생기는 것이죠.

이번에는 데이터를 복사하지 않고, 신상 정보에 있는 데이터를 가져다 써 봅시다. (해당 데이터가 있는 주소를 가리키는 포인터를 사용) 신상 정보나, 주소록 어디에서 수정을 하던, 바뀐 전화번호나 주소는, 동일하게 적용되므로, 데이터에 대한 신빙성을 높여주며, 동일한 데이터를 중복해서 갖고 있지 않기 때문에 메모리도 절약할 수 있습니다.

포인터는 이미 존재하는 데이터를 가리키기 때문에, 데이터가 필요 할 때, 가리키고 있는 주소에서 데이터를 읽어옴으로써, 데이터의 중복을 막을 수 있는 것이죠.

함수에 값을 받을 때도, 포인터로 매개변수를 전달받으면, 주소가 전달 (call by reference) 되기 때문에, 값의 전달(call by value)할 때 변수가 복사되면서, 이뤄지는 부하가 없습니다.

매개변수로 주소를 전달하게 되면, 그 위치에 있는 데이터를 직접 사용하는 것과 같기 때문에 값이 변할 수 있는데, 값을 변하지 않게 하려면 포인터 상수를 매개변수를 받으면 됩니다.

아래 코드는 성립하며, 함수 호출 후에, src의 값이 src + dest로 변합니다.

void add(int *src, int *dest)
{
    *src = *src + *dest;
}

아래 코드는 컴파일 되지 않습니다. 상수인 src에 값을 대입할 수 없기 때문입니다.

void add(const int *src, const int *dest)
{
    //*src = *src + *dest; 불가능함.
}

포인터를 쓰는 또 다른 이유는 동적 메모리 할당을 위해서 입니다. 변수나, 배열을 사용하기 위해 메모리 할당을 받는 크기가 정해지는 시점은, 컴파일시기 입니다. 프로그램 실행 시, 변수의 경우 해당 데이터 형의 크기만큼 할당 받고, 배열의 경우는 (배열의 크기 * 데이터 형의 크기)만큼 메모리 할당을 받습니다. 할당 받은 배열의 크기를 벗어나 데이터를 사용하면 오류가 발생하기 때문에, 평균적으로 사용될 크기가 아닌, 만약을 대비하여 충분히 큰 크기를 할당해야 하기 때문에, 메모리 낭비가 될 때가 많게 되죠. 그리고 프로그램이 자동으로 메모리를 할당하였기 때문에, 원하는 시기에 메모리를 해제하는 것도 불가능합니다. 이 것이 정적 메모리 할당 (Static Memory Allocate)입니다.

정적 메모리 할당의 단점을 해결하기 위해, 프로그램 실행 도중 필요한 크기만큼 메모리를 할당 받을 수 있는 동적 메모리 할당 (Dynamic Memory Allocate)이 있습니다.

아래 코드는 a*b (입력 받은 두 수의 곱) 만큼 메모리를 할당해서 char형 변수 str에 메모리 위치를 저장하는 코드입니다. 입력 받는 수는 컴파일 시점에 알 수 없고, 프로그램 실행 도중 알 수 있기 때문에, 동적 메모리 할당이라 부르는 것이죠.

int a,b;
char* str;
scanf(“%d%d”,&a,&b);
str = (char*)malloc(a*b);
if(str != NULL)
printf(“동적 메모리 할당 성공”);
else
printf(“동적 메모리 할당 실패”);
free(str);

메모리를 할당해 주는 함수인 malloc은, 메모리 할당 실패 시 NULL을 리턴 해주기 때문에, NULL인지 여부를 검사해서, 메모리 할당에 성공했는지 알아내야 합니다.

메모리 할당에 성공하면, 힙(heap)에 할당된 메모리의 시작 주소를 반환하게 되고, 그 주소를 포인터에 저장한 후, 할당 받은 메모리를 사용할 수 있습니다. 메모리 영역의 데이터를 사용한 후, 더 이상 사용하지 않게 되었을 때는 free함수를 써서 할당 해제 시켜주면 됩니다.

유의할 점은, 이 힙에 할당된 메모리는 전역적으로 접근이 가능하다는 것입니다. 할당된 힙의 주소를 안다면, 어디든지 접근이 가능하기에 사용시 주의를 기울여야 합니다.

  • 힙 (Heap) : 프로그램이 사용할 수 있는 메모리 영역으로써, 임시로 사용되는 값들이나, 지금과 같이 동적으로 할당한 데이터가 존재할 수 있는 데이터 영역.

DOS시절의 메모리와 포인터

예전 도스 시절에는, 세그먼트와 오프셋이라는 개념으로 메모리를 제어 했습니다. 세그먼트는 주 기억장치를 나눈 영역을 의미합니다. 세그먼트 주소는, 그 나눈 영역을 가리키는 주소를 의미하죠. 오프셋은 세그먼트 영역 내의 세부 주소를 의미합니다.

AF00 : 0002 (세그먼트 주소 : 오프셋 주소) = AF00 0002 (실제 주소)
* 32비트 메모리 환경에서의 메모리 주소

도시 시절에는 16비트로는 65535byte (64Kb)만큼의 메모리 영역밖에는 표현할 수 없었기에, 좀 더 큰 메모리 영역을 사용하기 위해서, 주소를 표현할 때 20비트(2^20. 1,048,576Byte = 1Mb)로 표현하게 됐죠. 문제는 20비트의 주소를 16비트로 표현하는 것이었습니다. 그래서, 세그먼트와 오프셋의 값을 계산해서 20비트의 메모리 주소를 표현했습니다.

AF00 (세그먼트 주소)
+ 0002 (오프셋 주소)
------------------
AF002 (실제 주소)


AF00 : 0002 (세그먼트 주소 : 오프셋 주소) = AF002 (실제 주소)
*16비트 메모리 환경에서 20비트 메모리 주소를 표현

그 당시 프로그래밍할 때 C언어에서 사용하던 포인터는 두 종류가 있었습니다. 하나는 near포인터로, 16비트 포인터(표현 가능 범위: 0x0000)입니다. 이 포인터에는 오프셋 주소만을 저장할 수 있기 때문에, 현재 세그먼트 영역에 있는 데이터만을 가리키는 포인터입니다.

int near *ptr; //near포인터 선언

다른 하나는 far포인터로 32비트 포인터(표현 가능 범위: 0x0000 0000)이며, 세그먼트 16비트, 오프셋 16비트씩 값을 가지고, 이를 통해 20비트로 이뤄진 메모리 영역 전체를 가리킬 수 있는 포인터입니다.

int far *ptr; //far포인터 선언

지금은 32비트(2^32, 4,294,967,296 = 4Gb) 메모리 영역을 사용하게 되면서, near포인터, far포인터가 의미 없어졌지만, 아직도 여기저기 그 흔적이 남아있는 만큼, 알아두는 것도 나쁘지 않겠죠?

배열과 포인터

배열이 같은 데이터 형을 가진 데이터 집합이라는 사실은 다들 아실 겁니다.

배열은 같은 데이터 형을 가진 데이터를 한번에 생성 해준다는 것 외에, 메모리 관점에서의 장점도 있습니다.

// int 형 크기 10의 배열 i
int i[10] = {1,2,3,4,5,6,7,8,9,10};


// 배열 i 의 주소
&i 0x0012FF3C


// 배열 i 에 담겨 있는 값
0x0012FF3C 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 05 00 00 00 06 00 00 00 07 00 00 00 08 00 00 00 09 00 00 00 0a 00 00 00

보시다시피, 배열로 잡은 데이터는, 메모리상에 연속되어 데이터가 위치하고 있습니다. 메모리는 선형 구조 (linear)이기 때문에, 가까운 데이터에 접근 하는 것이 더 빠르게 동작합니다.

배열도 선형 구조이기 때문에, 빠르게 동작하는 효율적인 자료 구조이며, C언어는 문자열도 char형 배열로 처리합니다.

배열의 이름이 의미하는 것은 배열의 시작 주소를 가리키는 포인터 상수입니다.

즉, 배열의 이름을 포인터처럼 다룰 수 있다는 이야기입니다.

int main(int argc, char *argv[])
{
    int number[10] = {1,2,3,4,5,6,7,8,9,10}, i;
    int *pNumber = number;

    for(i = 0; i < 10; i++)
    {
        printf("number 배열을 첨자로 출력. %d번째 수는 %d\n", i, number[i]);

        printf("number 배열의 주소로 찾아가 출력. %d번째 수는 %d\n", i, (*number) + i);

        //printf("number 배열을 증가 연산자로 가리키는 위치를 증가 시키며 출력. %d번째 수는 %d\n", i, (*number)++); 불가능

        printf("pNumber 배열을 첨자로 출력. %d번째 수는 %d\n", i, pNumber[i]);

        printf("pNumber 배열의 주소로 찾아가 출력. %d번째 수는 %d\n", i, (*pNumber) + i);

        printf("pNumber 배열을 증가 연산자로 가리키는 위치를 증가 시키며 출력. %d번째 수는 %d\n", i, (*pNumber)++);

    }
}

위 코드를 보시면 아시겠지만, 배열의 이름은 포인터 상수인 특성에 따라, 증가 연산자를 통한 포인터 값 증가가 불가능 한 것을 제외하면 포인터와 동일하게 사용 할 수 있습니다.

유심히 보시면 포인터도 배열에서 사용하는 연산자인 [] (첨자 연산자)를 사용한 것을 보실 수 있는데, 이 것은 포인터가 가리키는 주소를 기준으로 첨자 안에 쓰여진 위치의 데이터를 가리키는 역할을 하는 것으로, 첨자 연산자가 배열에서 쓰일 때, 배열 시작 주소를 기준해서 특정 위치에 접근하는 것이지, 배열만을 위한 연산자가 아니라는 것을 알 수 있게 해주죠.

배열의 특징

1. 배열은 같은 형식의 데이터를 메모리 상에 연속적으로 나열한 데이터 집합이다.

2. 배열의 데이터 접근 속도는 빠르다.

3. 배열의 이름은, 배열의 시작 주소를 가리키는 포인터 상수다.

함수 포인터

포인터 중에는 변수만이 아니라, 함수의 위치를 가리킬 수 있는 함수 포인터(Function Pointer)라는 것도 있습니다. 함수 포인터는 대상 함수와, 반환 형과 매개변수 형이 같다면, 그 함수를 가리킬 수 있습니다.

int (*pfunc)(int,int);  //int형 반환 값과, int형 매개변수 2개를 갖는 함수를 가리킬 수 있는 함수 포인터


float divide(float value1, float value2)
{
return value1 / value2;
}

//pfunc = divide; //불가능. 함수 포인터와 대상 함수는 반환 형, 매개변수 형이 같아야 함.

int multiple(int value1,int value2) //int 형 반환 값과, int형 매개변수 2개를 갖는 함수 선언.
{

return value1 * value2;
}           

pfunc = multiple; //함수 포인터 pfunc에, multiple 함수 주소를 대입.

int result; //결과 값을 저장할 변수 result 선언

result = pfunc(1,4); //pfunc함수 포인터를 통해, multiple 함수 호출. 1*4된 결과값 4가 반환됨.

C언어에서는 함수 자체를 매개변수로 넘길 수 없는데, 함수 포인터를 이용하게 되면 함수를 다른 함수의 매개변수로 전달할 수 있게 됩니다.

함수 포인터도 포인터처럼 여러 개의 함수 중에서 선택적으로 가리킬 수 있기 때문에, 이를 이용해 코드 분기 기능을 가질 수 있습니다. 위에 코드에서는 pfunc가 multiple함수를 가리키는 함수 포인터였습니다. 이번에는 pfunc가 add함수를 가리키게 한후, pfunc함수를 호출해 보겠습니다.

int add(int value1,int value2) //덧셈함수 add 선언. int형 반환 값을 갖고, int형 매개변수 2개를 받음.
{
    return value1 + value2;
}

pfunc = add; //함수 포인터 pfunc는 add함수를 가리킴

result = pfunc(1,4); //pfunc함수 포인터를 통해 add함수가 불려져, 1+4된 결과인 5가 반환됨

같은 코드가 실행됐지만, 결과값은 다르죠? 즉, 함수 포인터를 사용하면 같은 코드가 실행되더라도, 가리키고 있는 함수에 따라 결과가 달라지게 할 수 있는 것이죠.

이처럼 포인터를 이용하면, 메모리를 다룰 수 있기에 여러 가지 장점이 있습니다.

하지만, 포인터는 기본적으로 할당 받은 영역을 넘어서는 주소에 접근하면 오류를 발생시키기 때문에 반드시 조심해서 다뤄야 하는 점 잊지 않으시면서 사용하셨으면 좋겠습니다.

Comments

comments powered by Disqus