C에서 Pointer가 왜 어려운가?
뭐든지 개념 하나를 제대로 잡으려면 시일이 걸린다. 왜냐하면 한 번 이해했다고 그 개념이 머릿속에서 계속 이해한 채로 머물지 않기 때문이다. 인간의 뇌는 항상 기존의 것을 쫓고 원래의 상태로 복귀하려는 관성을 가지고 있기 때문이기도 하지만 본래의 지식 체계가 새로운 개념으로 짜집기 되려면 격정적인 감정의 이입이나 수많은 반복이 없이는 본래의 지식수준과 같은 실타래로 되돌려진다. 그래서 어떠한 개념을 이해하는데, 왜 이러한 과정에 의해서 이 개념이 도출되었는지에 대한 명확한 선행지식이 없다면 이해한 경로를 다시 되짚어서 그 개념을 다시 환기시키는 과정은 지난해진다. 단순히 패치(때우는)하는 수준에 머물지 않고 기존의 지식체계와 연관이 되어 자연스럽게 기억의 신경망에 자리 잡으려면 정말 어려운지부터 살펴보자. C 언어에서 Pointer 말이다.
포인터는 '무언가를 가리키는 것'이라는 뜻이다. 어떤 값(메모리에 저장되어 있는 값)을 나타내는 게 아니라, 간접적으로 답이 실려있는 자료(메모리 주소)를 알려준다는 뜻이다. 일상생활에서 무언가 의문이 생기면 '~에 대해서 알고 싶은데 포인트를 짚어주세요.'라고 질문을 해보는 것과 같은 뉘앙스다(무라야마 유키오, 2017).
위의 main() 함수의 시작점에서 선언된 char *argv[] 는 읽을 때는 "캐릭터(차) 아스테리스크 아규브이 대괄호 열고 닫고"라고 말하고, 여기서 *(아스테리스크)를 변수명 argv(argument vector) 앞에 붙여서 포인터 변수를 생성했다. 즉 포인터의 연산자인 *가 달려있는 argv[] 라는 배열의 요소들이 가리키고 있는 메모리 번지명을 차례대로 저장하라는 문자형 배열 선언이다(역시나 풀이하는 나조차 꼬인다.)
함수의 엔트리 포인터를 저장하는 포인터 변수를 생성하는 방법은 다음과 같다(계승 프로그램의 factorial 함수의 포인터의 경우)
int (*pfact)(int n);
이렇게 작성하면 pfact에 변수 및 함수의 주소를 저장할 수 있다. 포인터와 관련된 연산자로는 &, *, ( ), ->, [ ]의 5개가 있다. &는 비트 연산자, *는 곱셈과 동일한 기호로 타이핑하지만, 주소를 조작하는 연산자(포인터)의 경우에는 오퍼랜드(피연산자)가 1개뿐이기 때문에 구분이 가능하다(피연산자 사이에 &나 *가 쓰이면 &는 비트 연산자, *는 곱셈 기호이다). [ ]는 배열의 요소를 참조할 때 사용하는 연산자(화면의 예)이고, ->는 구조체를 가리키는 포인터의 멤버를 참조할 때 사용하는 연산자, ( )는 printf 등의 뒤에 작성하는 괄호이다.
C 연산자 의미
& 주소
* 저장되어 있는 값
[] 배열의 요소 값
-> 구조체를 가리키는 멤버의 값
() 함수 호출
int *pa;
short *pb;
char *pc;
pa = &a;
pb = &b;
pc = &c;
위의 코드에서 &a는 변수 a가 메모리 상의 몇 번지에 할당되어 있는지를 나타낸다. 만약 a의 기억 영역(메모리 주소)이 0x1000 번지라면 &a는 0x1000 숫자를 의미한다. 그리고 b의 메모리 번지가 0x1004, c의 메모리 번지가 0x1006라면 &b는 0x1004, &c는 0x1006 숫자와 같다.
pa라는 포인터 변수(int형으로 선언됨)를 a의 영역을 가리키도록 나타낼 때 'pa = &a'처럼 나타내고, "pa == 0x1000 번지 즉, 0x1000 숫자와 같다. 이것이 포인터의 기본이다(여기서 == 기호는 'equal'로 같다는 뜻이고 = 기호의 'assign'은 대입하다는 뜻으로 서로 의미가 다르다).
&는 주소, *는 저장되어 있는 값이다. 그래서 *pa는 변수 pa에 저장되어 있는 값인 &a 즉 0x1000 번지를 의미하며 *pb는 변수 pb에 저장되어 있는 값인 &b 즉 0x1004 주소값을 의미하고 *pc는 변수 pc에 저장되어 있는 값인 &c 즉 0x1006을 의미한다(번지==주소값==0x100X 다 같은 말).
정리하면 *p는 p가 포인터 변수일 때만 사용할 수 있는 연산 방법으로 p가 가리키고 있는 주소의 값을 의미한다. 아래 function() 함수의 시작점에서 선언된 char *str처럼 str 포인터 변수가 가리키고 있는 주소값(스트링값으로 복사하려는 이를테면 'AAAA'라는 문자열의 주소값인 0xf7a69aa8 /섹션 7-2 참조)을 의미한다. 그래서 버퍼 오버플로 공격 시, AAAA라는 입력값은 스택 메모리 데이터(버퍼) 가운데 특정 주소값에 복사된 ‘4141414100’의 16진수 값이라는 것을 확인할 수 있었다. 즉, C 언어에서 포인터 연산자의 기능으로 인해 버퍼에 임의의 값을 덮어쓰는 침범(stack overflow)이 가능했다.
중요한 것은 포인터는 번지를 가리키는 것뿐만 아니라, 가리키는 곳의 형(type)도 정해져 있다는 점이다. int형을 가리키는 포인터는 4 bytes 영역을 나타내고, short형을 가리키는 포인터는 2 bytes 영역을 나타낸다. 또한, char형을 가리키는 포인터는 (1) byte 영역을 나타낸다. 하지만 포인터 자체는 모두 4 bytes라는 것을 염두에 두자.
그런데, 왜 포인터가 필요한 것일까? 무엇에 사용하기 위한 것일까? 그것은 배열, 구조체, 동적 메모리(heap) 할당, 함수 호출 등에 이용하기 위해서이다. 이런 기능들을 사용하기 위해서는 주소를 가리킬 필요가 있기 때문이다(무라야마 유키오, 2017).
* factorial 함수를 가리키는 포인터 변수 pfact에 값을 대입하려면 다음과 같이 작성한다.
pfact = factorial;
이렇게 하면 포인터 변수 pfact에 함수의 엔트리 포인터(주소값)이 저장된다.
다음과 같이 또 작성하면 factorial 함수를 호출할 수 있다.
x = pfact(n);
"= 이 같다는 의미가 아니라, 대입한다라는 의미에서 factorial 함수의 주소값을 찾아가는 것이 포인트 변수의 역할이며, 그렇게 주소값이 저장되어 있는 포인트 변수 및 함수에 n이라는 인자를 투입한 결괏값을 저장한 변수가 x이다."
모든 언어에서 함수 호출 시 쓰이는 원리가 바로 포인터에 담겨있다는 것뿐만 아니라, C 언어 *Unmanaged Language의 특성을 갖는다는 게 포인트다.
*메모리 누수를 방지하기 위해 malloc() 함수를 이용하여 직접 메모리 할당을 하고 이후 다시 직접 할당시켰던 메모리를 삭제를 해야 하는 프로그래밍 언어 자체에 메모리 관리 기능(이를테면, 자바의 garbage collector)이 없는 언어의 특성을 일컬음.
참조
- 무라야마 유키오. 이해란 역. (2015). C를 배우기 전에 반드시 알아야 할 것들, 서울: 루비 페이퍼.