[C 언어 기초] 포인터

Youngeui Hong·2023년 9월 2일
0
post-thumbnail

🫵 포인터

📍 포인터란?

포인터 변수는 다른 변수나 데이터 요소의 메모리 주소를 저장하는 변수를 의미한다.

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)

앞서 * 연산자는 포인터 변수를 선언할 때 사용됐는데, * 연산자는 포인터가 가리키는 메모리 위치에 접근하여 그 값을 읽거나 변경할 때에도 사용된다.

이처럼 포인터가 가리키는 메모리 위치에 접근하여 해당 위치의 값을 읽거나 변경하는 동작을 "역참조 (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] 실행 결과

0개의 댓글