티스토리 뷰

# 포인터(Pointer)


 포인터는 C언어의 가장 기본이라고 할 수 있다. 포인터는 C에 존재하는 모든 자료형에 대해 존재하는데, 예를 들면 int에 대한 포인터, char에 대한 포인터, float에 대한 포인터 등의 형태로 사용된다.


 포인터의 본질은 메모리의 주소값을 가진다는 점이다. 포인터에 관련해서는 아래에 보이는 두 개의 중요한 연산자가 있다.

  • & 주소 연산자 (the address operator)
  • * 간접지정 연산자 (the dereferencing or indirection operator) 

 이 연산자들의 의미를 살펴보기 위해 아래와 같이 변수를 선언했다고 해보자.


int i, *pi;

// int i; int *pi; 이 두문장을 따로 작성한 것과 동일하다. 위와 같이 쓴 이유는 간결성을 위해서다.


 여기서 i는 int형 변수(integer variable)이고 pi는 int형에 대한 포인터(a pointer to integer)다. 포인터 변수는 메모리의 주소값을 가진다고 했으므로 아래와 같이 사용할 수 있다.


pi = &i;


 &는 주소연산자라고 했는데, 이 연산자를 사용할 경우 해당 변수의 메모리 주소값을 반환한다. 따라서 pi에는 i의 메모리 주소값이 저장이 된다.


/* 변수라는 건 결국 메모리 주소에 불과하다. 변수에는 데이터를 담을 수 있다는 의미는 결국 메모리에 데이터를 저장할 수 있다는 것과 동일하다. 일일이 0x00ffcc12 등과 같이 주소를 지정해서 쓰면 오류도 나기 쉽고 매우 번거롭기에 변수라는 기능을 사용해서 메모리에 저장하는 방식을 택하는 거다. */


 포인터의 의미를 좀 더 명확하게 하기 위해 아래의 예시를 확인해보자.


i = 10;

*pi = 10;

pi = 10;


 먼저 위의 두 가지 경우 의미가 동일하다. i가 가리키고 있는 메모리 주소에 정수로 10을 저장해주세요 라는 의미다. *pi = 10; 구문은 pi에 저장되어 있는 i의 주소값으로 가서 정수 10을 저장해주세요 라고 해석한다. 자기자신이 i는 아니지만, i가 가리키는 메모리에 접근하여 값을 저장하고 쓸 수 있기 때문에 *는 간접지정연산자로 불리는 것이다.


 세 번째 구문이 초심자들에게 살짝 헷갈릴 수 있다. 세 번째 연산은 잘못된 연산일까? 전혀 그렇지 않다. 어찌됐건 포인터변수도 하나의 변수이고, 값을 저장할 수 있는 공간이다. 포인터변수 자체의 메모리 주소도 물론 있다. 다만 포인터 변수에 담긴 값이 주소로 해석될 뿐이다. 10이라는 데이터는 0x0000000a 형태로 메모리에 잘 저장된다. 문법적으로 이상이 없다.


 다만, 세 번째 구문에서 주의해야할 점은 10을 저장할 수는 있지만 간접지정은 할 수 없다는 얘기다. 할 수는 있지만 큰 문제가 발생할 것이다. (물론, 비주얼스튜디오와 같은 개발도구를 사용한다면 이런 경우를 알아서 막아준다.) 이 주소에 대한 간접지정이 위험한 이유는 주로 메모리값 초기 앞부분은 커널영역이 차지하고 있기 때문이다. 커널영역이라 함은 컴퓨터 프로그램의 아주 베이스인 OS(운영체제) 등이 차지하고 있는 영역을 의미한다. 이 영역을 건드리면 우리가 당장 사용하는 Windows, 맥OS, Linux 등이 정상적으로 작동할 수 없을 것이다.


 모쪼록 포인터는 주소값을 가지는 변수이기 때문에 다양한 자료형에 대한 주소를 가질 수 있다. 또한 주소값의 경우 언제나 양수이기 때문에 (주소는 0x00000000 부터 시작해 사용할 때마다 1씩 증가한다. 즉, 0이상의 값을 가질 수밖에 없다.) 포인터를 가지고 더하기, 빼기, 곱하기, 나눗셈 연산 등도 얼마든지 가능하다. 물론 값이기 때문에 비교연산(<, >, >= 등) 또한 가능하다.


 포인터의 크기는 보통 4bytes지만, 컴퓨터에 따라 다를 수 있다. 어떤 컴퓨터에서는 char에 대한 포인터의 크기가 float에 대한 포인터 크기보다 더 크다.


/* 포인터의 사이즈가 4bytes(32bits)인 이유는 주소값의 형태가 보통 0x00ff00ff 형식으로 되어 저장되기 때문이다. 0x 이후로 8개의 숫자가 있고 각 자리들이 16진수(4bits)로 구성되어 있기에 4bits x 8 = 32bits (4bytes)가 되는 것이다. */


 null pointer(널 포인터)에 대한 개념을 살펴보자. null pointer는 아무것도(변수, 객체, 함수 등) 가리키지 않는 것을 의미한다. 즉, 주소값을 저장할 수 있는 하나의 변수지만 아무런 주소값을 가지고 있지 않다. C에서는 이 null pointer를 특정한 값으로 취급한다. 대개 null pointer는 정수 0으로 표현된다. 그래서 C는 null pointer를 NULL 로 따로 상수정의를 해두었다. NULL을 우리는 이제 정수 0으로서 사용한다.


/* 상수정의란 정수에 이름을 붙여주는 것이라 생각하면 편합니다. #define 이름 상수값 형태로 문법을 사용하며 변수랑은 개념이 완전히 다르다. 변수는 데이터를 저장할 수 있는 하나의 메모리 저장공간이지만, 매크로로 정의된 상수는 데이터를 저장할 수 없다. 그냥 이름만 존재하는 것이다. 변수의 경우 l-value 라고 보통 부르고, 매크로 등과 같이 상수의 경우는 r-value라고 부른다. int 변수 = 10; 의 형태로 저장되기에 왼쪽 오른쪽에다 각각 value를 붙여준 것이다. */


 0의 값을 갖는 null pointer, NULL 매크로를 우리는 C에서 아래와 같이 사용할 수 있다.


if (pi == NULL)

or

if (!pi)


 둘 다 의미는 동일하다. 만약 포인터 변수 pi가 아무런 값도 가리키고 있지 않다면 (즉, 0x00000000 값을 가지고 있다면?) 이란 뜻이다. 아래의 경우 !는 not 연산자다. 0이외의 어떤 값이든 !을 만나면 0으로 바뀌고, 0이 !을 만나면 1로 값이 바뀐다. 이진수에서 0과 1은 반대값이니까. 따라서 !pi는 pi에 0이 아닌 값이 담겨져 있으면 0이 반환되고, 0이 담겨 있었으면 1이 반환된다. 그래서 if (!pi)는 만약 pi가 null pointer가 아니라면? 라고 해석되는 것이다.



# 동적 메모리 할당


우리는 프로그램을 짤 때 완벽하게 설계할 수 없음을 스스로 잘 알고 있다. 얼마나 메모리 공간을 필요로 하는지도 모르고, 만약 안다고 해도 뒤에가서 바뀔 수도 있다. 이렇게 가변적인 상황이 발생할 수 있기 때문에 C에서는 힙(heap) 이라는 매커니즘을 제공한다. 힙은 런타임시 (실행시) 메모리를 할당해주는 역할을 한다.


 우리는 메모리가 필요할 때마다 malloc 함수 호출을 통해 원하는 만큼의 메모리를 요청할 수 있다. 메모리 공간에 여유가 있어 요청이 성공적으로 받아들여지면, 내가 요청한 크기의 메모리 덩어리의 시작주소가 반환된다. 만약 내가 malloc 함수로 4bytes 만큼의 메모리를 요구했다고 가정해보자. 그럼 컴퓨터는 메모리 어딘가 (예를 들어, 0x00ffcc01 ~ 0x00ffcc08 숫자 하나당 4bits, 총 32bits = 4bytes) 까지를 할당해주기로 정했다고 해보자. 이 경우 malloc으로 반환되는 값은 0x00ffcc01 이라는 뜻이다.


 만약 메모리 공간이 부족하거나 다른 이유로 요청이 거절당했을 경우, NULL이 리턴된다. NULL은 아까 nullpointer를 정수 0으로 상수정의 한 것이라고 설명했다. 0이 리턴된다고 봐도 무방하다.


 메모리가 더 이상 필요 없어졌을 경우, free 함수호출로 메모리를 시스템에 반환할 수 있다. 그냥 malloc을 사용하면 무조건 free를 해줘야한다는 사실을 일단 기억해놓자. 한 번 메모리가 반환되면, 다시 사용할 수 없다. 물론, 그 주소가 어디인지 정확히 값으로 알고 있다면 언제든지 가능하다. 그러나 보통 우리는 주소를 변수 형식으로 사용하고 주소는 운영체제가 알아서 할당해주므로, 그 위치를 다시 찾기란 거의 불가능하다.


 동적할당에 대한 개념과 포인터의 개념을 정리해 볼겸 아래의 프로그램을 살펴보자.

/*************************************************
 ** 포인터와 동적할당에 대한 프로그램 예시
*************************************************/

#include <stdio.h>
#include <malloc.h>

void main()
{
	int i, *pi; // int형 변수 i와 int형 포인터변수 pi를 선언
	float f, *pf; // float형 변수 f와 float형 포인터변수 pf를 선언
	pi = (int *)malloc(sizeof(int));
	pf = (float *)malloc(sizeof(float));
	*pi = 1024;
	*pf = 3.14;
	printf("an integer = %d, a float = %f\n", *pi, *pf);
	free(pi);
	free(pf);
}

/*************************************************
 ** End Line
*************************************************/

malloc 함수를 호출할 때는 그 인자로 얼마만큼의 데이터를 어떤 형식으로 저장할건지를 명시해줘야 한다. malloc 함수의 반환값은 시스템마다 다르다. 어떤 시스템에서는 char*로 반환되기도 한다. 하지만,  American National Standards Institute (ANSI) C를 사용하는 유저라면 그 반환값은 void*다. 포인터이긴 포인터인데, void형을 가리키는 포이터다. 우리가 이 예시에서 사용하는 건 int와 float로 void와는 다르다.


 따라서 목적성에 맞게 사용하기 위하여 malloc함수 앞에 강제형변환 (type casat)문을 넣어줬다. void *로 반환된 값이 각각 (int *), (float *)로 변환되어 pi, pf에 저장된다. free의 경우 매개변수는 void * 타입을 기대한다. 그렇다고 void 포인터만 매개변수로 전달해야만 하는 뜻으로 받아들이면 곤란하다. 어떤 타입의 포인터도 괜찮다라는 의미로 받아들여야 한다. 매개변수로 넘어 온 포인터는 void *로 강제형변환 될테니, 어떤 포인터든 줘봐라 라는 뜻이다.


/* 몇몇 C버전에서는 free의 매개변수로 char *를 요구하기도 하지만, 이건 무시해도 좋을 듯하다. */


 malloc함수 호출의 경우 메모리 부족 등의 이유로 실패할 수 있기 때문에 코드 작성시 이런 부분까지 고려해주면 좋다. 아래의 두 가지 버전의 코드를 살펴보자.

/*************************************************
 ** 동적할당 에러를 고려한 코드 작성 예시
*************************************************/

#include <stdlib.h>
#include <malloc.h>

if ((pi = (int *)malloc(sizeof(int)) == NULL) ||
		(pf = (float *)malloc(sizeof(float)) == NULL))
	{
		fprintf(stderr, "메모리 부족(Insufficient memory)");
		exit(EXIT_FAILURE);
	}
	
	if (!(pi = malloc(sizeof(int))) ||
		!(pf = malloc(sizeof(float))))
	{
		fprintf(stderr, "메모리 부족(Insufficient memory");
		exit(EXIT_FAILURE); // stdlib.h 헤더파일에 1로 상수정의 되어 있다.
	}

/*************************************************
 ** End Line
*************************************************/

 조건문을 살펴 보면, 조건문 앞에 malloc 함수 호출 구문이 포함되어 있다. 즉, 조건문을 비교할 때 malloc은 이미 호출되고 그 결과들을 비교하여 에러가 났으면 에러처리를 하는 방법이다. OR 연산자( || )를 사용했기 때문에 둘 중 하나라도 할당을 받지 못하면 에러가 난다. 아래의 조건문은 단지 Not 연산자 !를 사용했을 뿐이다.


/* Not 연산자의 경우 0이 아닌 상수들과 결합되면 0을 리턴한다. 0을 만났을 때는 1을 리턴한다. */


 malloc 연산자의 경우 C로 프로그램을 작성할 때 빈번하게 사용되므로 하나의 매크로(Macro)로 정의해두는 것도 괜찮은 방법 중 하나다. 매크로를 활용하여 위의 프로그램을 재작성 해보았다.


/* 매크로(Macro)란 일련의 명령이나 코드를 하나의 명령으로 치환하는 것으로, 코드를 좀 더 편리하게 작성할 수 있다. 그리고 매크로를 마치 함수처럼 보이도록 할 수도 있지만, 실제로는 함수가 아니기 때문에 함수를 호출하는 것보다 수행 속도가 좀 더 빠른 장점이 있다. */

/*************************************************
 ** 매크로를 이용하여 재구성한 malloc 프로그램
*************************************************/

#include <stdio.h>
#include <malloc.h>
#include <stdlib.h>
#define MALLOC(p,s) if (!((p) = malloc(s))) { fprintf(stderr, "메모리 부족(Insufficient memory)"); exit(EXIT_FAILURE);}

void main()
{
	int i, *pi; // int형 변수 i와 int형 포인터변수 pi를 선언
	float f, *pf; // float형 변수 f와 float형 포인터변수 pf를 선언

	MALLOC(pi, sizeof(int));
	MALLOC(pf, sizeof(float));

	*pi = 1024;
	*pf = 3.14;
	printf("an integer = %d, a float = %f\n", *pi, *pf);
	free(pi);
	free(pf);
}

/*************************************************
 ** End Line
*************************************************/

 매크로를 정의할 때 쓸데 없이 왜 변수에 죄다 괄호를 해놨지? 라고 생각할 수 있다. 매크로의 기능적 특성은 정의되어 있는 코드를 그대로 그 위치에 삽입한다는 점이다. MALLOC 이라고 써져 있는 본문에 if 부터 exit까지 쫘악 복사 붙여넣기가 되는 것이다. 그러면서 발생할 수 있는 다양한 오류의 가능성을 줄이기 위해 일부러 괄호를 덕지덕지 붙여놨다. 어떤 오류가 발생할지에 대해서는 다른 포스팅에서 다루도록 하겠다.



# 포인터 사용시 주의사항


항상 C 프로그래밍을 하면서 포인터 변수를 선언할 때는 NULL로 초기화 시켜놓는 것이 좋다. 이유는 프로그램의 영역을 넘어서는 혹은 비정상적인 영역에 대한 접근을 원천적으로 차단하기 위해서다.


 신기하게도 몇몇 컴퓨터에서는 null pointer로 간접지정이 가능하게 해놨고 그 결과값은 NULL (0)이다. 실행이 가능하게까지 만들어 놨다. 다른 컴퓨터에서는 null point에 간접지정을 하는 것만으로도 심각한 에러를 발생시키기도 한다.



댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday