포인터 변수는 다른 변수나 데이터 요소의 메모리 주소를 저장하는 변수를 의미한다.
C 언어에서 변수가 선언되면 해당 변수는 메모리 상에 공간을 할당 받는데, 이 메모리 공간의 주소를 담고 있는 변수가 바로 포인터 변수이다.
메모리 주소를 알고 있으면 주소 버스를 통해 해당 공간에 접근할 수 있으므로, 포인터 변수를 사용하면 포인터가 가리키는 변수에 간접적으로 접근하여 해당 값을 읽거나 변경할 수 있다.
(변수 이름을 통해 실제 값에 접근하는 것을 직접 접근이라하고, 포인터 변수를 통해 실제 값에 접근하는 것을 간접 접근이라 한다.)
포인터 변수는 x86-64, 즉 64 비트 머신에서 8 바이트의 길이를 가진다.
포인터는 아래와 같이 *
연산자를 사용하여 선언한다.
// 변수의 데이터 유형 *변수명;
char *pA;
int *pB;
double *pC;
그리고 포인터 변수의 값을 초기화할 때는 주소화 연산자 &
를 사용해서 변수의 주소값을 받아와서 포인터 변수에 할당하면 된다.
#include <stdio.h>
int main(void)
{
int b = 100;
int *pB = &b;
printf("b = %d\n", b); // b의 값 100
printf("&b = %x\n", (void *) &b); // b의 주소값
printf("pB = %x\n", (void *) pB); // b의 주소값
return 0;
}
포인터에도 char, int, long 등의 자료형을 붙여서 선언해야 한다.
그런데 포인터에 이러한 자료형이 필요한 이유는 무엇일까?
포인터는 메모리 상의 시작지점을 가리키는데, 만약 자료형 정보가 없다면 시작지점으로부터 어디까지 몇 바이트를 읽어와야 할지 알 수 없기 때문이다.
앞서 *
연산자는 포인터 변수를 선언할 때 사용됐는데, *
연산자는 포인터가 가리키는 메모리 위치에 접근하여 그 값을 읽거나 변경할 때에도 사용된다.
이처럼 포인터가 가리키는 메모리 위치에 접근하여 해당 위치의 값을 읽거나 변경하는 동작을 "역참조 (dereferencing)"라고 한다.
역참조 연산자 *
는 어느 위치에서 쓰이는지에 따라 그 동작 방식이 달라진다.
(1) *
연산자가 =
의 오른쪽에서 사용된 경우
역참조 연산자 *
가 등호를 기준으로 오른쪽에서 사용되면 읽기 연산을 수행한다.
즉, 해당 포인터 변수가 가리키는 메모리 위치에 있는 값을 읽어온다.
(2) *
연산자가 =
의 왼쪽에서 사용된 경우
역참조 연산자 *
가 등호를 기준으로 왼쪽에서 사용되면 쓰기 연산을 수행한다.
즉, 포인터 변수가 가리키는 메모리 위치로 가서 =
의 오른쪽에서 할당된 값으로 해당 메모리 위치의 값을 바꿔준다.
아래의 코드를 살펴보면 *
연산자의 위치에 따른 차이를 명확히 이해할 수 있다.
🔻 예제코드 1. 역참조 연산자를 사용하여 x와 y의 값 바꾸기
#include <stdio.h>
long exchange(long *xp, long y);
int main(void)
{
long a = 4;
// 주소화 연산자 &는 포인터를 만들어준다.
// 아래 코드에서 &는 지역변수 a를 저장하고 있는 위치의 주소를 생성한다.
long b = exchange(&a, 3);
printf("a = %ld, b = %ld\n", a, b);
}
/*
인자 xp는 long int의 포인터
y는 long integer
*/
long exchange(long *xp, long y)
{
// 역참조 1: 읽기 연산
// xp로 표시되는 위치에 저장된 값을 읽어서 지역변수 x에 저장한다.
// => 역참조 연산자 *가 할당문의 우변에 위치하므로 읽기 연산을 의미
long x = *xp;
// 역참조 2: 쓰기 연산
// 매개변수 y의 값을 xp가 지정하는 위치에 쓴다.
// => 역참조 연산자 *가 할당문의 좌변에 위치하므로 쓰기 연산을 의미
*xp = y;
return x;
}
포인터의 작동 방식을 좀 더 심도 있게 살펴보기 위해 어셈블리 코드(Assembly Code)를 살펴보자.
C 언어로 작성된 코드는 기계어로 변환되기 전에 먼저 어셈블리 코드로 변환되는데, 어셈블리 코드는 CPU에서 실행되는 레지스터 조작 등의 작업을 보여주기 때문에 포인터의 작동 방식을 이해하는 데에 도움이 되었다.
위의 [예제코드 1]을 어셈블리 코드로 변환하면 대략 [예제코드 2]와 같은 상태가 된다.
코드를 살펴보기에 앞서 레지스터 %rdi
는 인자 xp
, %rsi
는 인자 y
를 보관하고 있다고 가정하자.
(1) movq (%rdi), %rax
첫 번째 줄부터 살펴보면 movq
는 "move quadword"의 약자로, 64비트 정수 데이터를 복사하거나 저장하는 데에 사용되는 명령어이다.
(%rdi)
에서 괄호는 간접 참조를 나타낸다. 즉, %rdi
레지스터에 저장된 값을 메모리 주소로 사용하라는 의미이다.
종합하면 movq (%rdi), %rax
는%rdi
레지스터가 가리키는 메모리 위치의 값을 가져와 %rax
레지스터로 복사하라는 의미를 가진다.
즉 xp
의 메모리 주소를 참조하여 x
의 값을 받아와서 %rax
레지스터에 저장하는 것을 의미한다.
(2) movq %rsi , (%rdi)
movq %rsi , (%rdi)
는 %rsi
레지스터의 값을 가져와 %rdi
레지스터가 가리키는 메모리 위치에 저장하라는 의미이다. y
의 값을 xp
변수가 가리키는 메모리 위치에 저장하는 것이다.🔻 예제코드 2. 예제코드 1의 어셈블리 코드
exchange:
movq (%rdi), %rax
movq %rsi , (%rdi)
ret
이중 포인터(Double Pointer)는 포인터를 가리키는 포인터를 의미한다.
썸네일의 그림은 이중 포인터의 개념을 잘 나타내고 있다.
이중 포인터는 **
기호를 사용하여 선언하고, 단일 포인터 변수의 주소를 할당하여 초기화한다.
그렇다면 이중 포인터의 역참조는 어떻게 이루어질까?
이중 포인터의 역참조는 *
연산자를 한 번 사용하는 경우와 두 번 사용하는 경우가 있다.
1) *pp
이중 포인터 변수에 대해 *
연산자를 한 번 사용하면, 해당 이중 포인터 변수가 가리키는 단일 포인터의 값에 접근한다.
아래 [예제코드 3]을 살펴보면 *pp
를 출렸했을 때 변수 a
의 주소값이 나옴을 확인할 수 있다.
이는 단일 포인터가 가리키는 위치의 메모리는 변수 a
의 주소값을 담고 있기 때문이다.
2) **pp
이중 포인터 변수에 대해 *
연산자를 두 번 사용하면, 이중 포인터 변수가 가리키는 단일 포인터의 주소에 접근한 다음, 단일 포인터가 가리키는 값을 참조하게 된다.
즉 단일 포인터가 가리키는 메모리 위치에 저장된 실제 값을 참조하게 된다.
아래 [예제코드 3]을 살펴보면 **pp
를 출렸했을 때 변수 a
의 실제 값이 나옴을 확인할 수 있다.
🔻 예제코드 3. 이중 포인터 이해하기
#include <stdio.h>
int main(void)
{
int a;
int *p;
int **pp;
a = 100;
p = &a;
pp = &p;
printf("a의 주소값(&a): %x\n\n", &a);
printf("p의 값: %x\n", p); // 변수 a의 주소값
printf("*p의 값: %d\n", *p); // 변수 a의 값
printf("p의 주소값: %x\n\n", &p); // 포인터 변수 p의 주소값
printf("pp의 값: %x\n", pp); // 포인터 변수 p의 주소값
printf("*pp의 값: %x\n", *pp); // 변수 a의 주소값
printf("**pp의 값: %d\n", **pp); // 변수 a의 값
}
🔻 [예제코드 3] 실행 결과