포인터 1

포인터는 C언어의 꽃입니다. 잘 사용하면 아주 좋지만 C 언어로 작성된 프로그램의 버그의 90%는 포인터에서 나온다고 할 수 있을 정도로 어렵고, 또 위험한 개념입니다. 이것을 사용하면 컴퓨터를 무척 세심하게 조절할 수 있지만, 앞에서 말한 위험성 때문에 최근의 고급 언어들에서는 대부분 포인터를 사용하지 않습니다. 그러나 C 언어는 컴퓨터 운영체제를 작성하기 위해 제작된 언어이므로 포인터가 반드시 필요합니다. 읽기 어려울 수도 있지만 잘 읽고 사용할 수 있길 바랍니다. 컴퓨터 구조에 관련되어 있어 조금 어려운 부분도 있는데, 그런 부분은 ☆표를 해 놓았으니 너무 어렵다면 넘어가세요.

우리가 변수를 선언하면, 컴퓨터 메모리상의 어딘가에 그 변수의 값을 담아두게 됩니다. 예를 들어 int a = 5;를 하게 되면 int는 32bit 컴퓨터에서 4바이트 정수 자료형이므로 메모리상에 a를 위한 4바이트의 공간이 a를 위해 할당되고 그 안에 5라는 값이 들어갑니다. 또, 우리는 &연산자를 사용해 a의 메모리 주소를 알 수 있습니다. 비트 AND 연산자 &와는 다릅니다(전자는 &a처럼 사용하는 단항 연산자지만 후자는 a & b처럼 사용하는 이항 연산자입니다). 또, *연산자를 사용하여 어떤 주소로부터 주소가 가리키는 값을 가져올 수 있습니다. int a = 5;라면 *&a5입니다. 어떤 자료형의 주소를 담는 자료형은 자료형*로 사용합니다. 주소의 주소는 **, 주소의 주소의 주소는 ***과 같은 식입니다. 또, 이런 주소 자료형의 크기는 32bit에서 4바이트, 64bit에서 8바이트입니다. 다음의 예시를 보겠습니다.

#include <stdio.h>
int main() {
      char arr[ 4 ] = { 0, 1, 2, 3 };
      int a = 5;
      int* b = &a;
      for(int i = 0; i < 4; ++i)
            printf("arr[ %d ]: %d...%X\n", i, arr[ i ], &arr[ i ]);
      printf("a: %d...%X\n", a, &a);
      printf("b: %X...%X...%X\n", *b, b, &b);
      return 0;
}

arr[ 0 ]: 0...61FE24
arr[ 1 ]: 1...61FE25
arr[ 2 ]: 2...61FE26
arr[ 3 ]: 3...61FE27
a: 5...61FE20
b: 5...61FE20...61FE28

모든 코드는 int가 4바이트, 주소값이 8바이트라는 전제 하에 작성되었으며, 주소값은 실행 환경에 따라 달라질 수 있다는 걸 기억하세요.

이 코드에서 각 변수가 어떻게 저장되어 있을까요?


4바이트 정수형인 a0x61FE20부터 4바이트를 차지하고 있고, 배열 arr는 1바이트 정수의 배열이기 때문에 각 원소가 1바이트씩, 0x61FE24부터 총 4바이트를 차지하고 있습니다. a에게 주어진 4바이트의 공간에는 5가, arr의 원소들에게 주어진 1바이트의 공간에는 각각 1, 2, 3, 4가 들어있네요.

주목해서 보아야 할 것은 b입니다. bint*이므로 8바이트의 크기를 갖습니다. 또한, 이 8바이트에는 0x61FE20이 저장되어 있습니다. 우리가 b&a, 즉 a의 주소를 담았기 때문에 그렇습니다. 따라서 그냥 b0x61FE20, b값(0x61FE20)이 가리키는 곳의 값은 5(a), b의 주소는 0x61FE28입니다.

참고로 리틀 엔디안 방식의 컴퓨터에서는 하위 비트(낮은 자릿수)가 낮은 주소값 쪽에, 상위 비트(높은 자릿수)가 높은 주소값 쪽에 저장되어 있으며, 빅 엔디안 방식은 그 반대입니다☆.

한 함수 안에서 각 변수들 중 어느 것이 높은 주소에 있을지, 어느 것이 낮은 주소에 있을지는 아무도 알 수 없습니다. 컴파일러가 임의로 자리를 바꿔버리기 때문입니다. 레지스터의 크기에 맞게 변수를 정렬aligned하면 더 빠르게 동작하기 때문에, 컴파일러는 변수의 위치를 막 바꿀 수도 있습니다☆. 예를 들어 SIMD 명령어 movdqa를 사용하려면 16바이트로 정렬되어야 하지요☆. '이 녀석들은 연속되어 있다!'라고 확실하게 말할 수 있는 것은 배열의 원소뿐입니다. 그렇다면 다른 함수에서 선언된 변수들이나 전역변수는 어떨까요?

#include <stdio.h>
int e = 2, f = 1;
void func() {
      char c = 4;
      static char d = 3;
      printf("c: %d...%X\n", c, &c);
      printf("d: %d...%X\n", d, &d);
}
int main() {
      char a = 6, b = 5;
      printf("a: %d...%X\n", a, &a);
      printf("b: %d...%X\n", b, &b);
      func();
      printf("e: %d...%X\n", e, &e);
      printf("f: %d...%X\n", f, &f);
      return 0;
}

a: 6...61FE4E
b: 5...61FE4F
c: 4...61FE0F
d: 3...403010
e: 2...403018
f: 1...403014


함수에서 선언한 변수(지역 변수)들은 컴퓨터의 스택 영역에 자리하고, static으로 선언한 변수나 전역 변수정적 데이터 영역에 자리합니다. 여기서는 다루지 않지만, 동적 할당한 변수들은 동적 데이터() 영역에 자리합니다☆. 여기 나와 있는 주소는 관례일 뿐, 실제로 그렇다는 보장은 없습니다. 또, 프레임 포인터($fp)의 경우, 이것을 사용하지 않는 컴파일러도 있습니다☆. GCC에서는 사용합니다.

변수들의 주소를 보면, c가 있는 곳은 ab가 있는 곳으로부터 좀 떨어져 있습니다. 둘 다 스택 영역에 있는데도 말이죠. 이 공간에는 레지스터에 넣지 못한 인자들(여기에서는 없습니다)이나 원래 레지스터에 들어있던 것들이 저장되어 있습니다☆. 따라서 함부로 이 데이터들을 수정했다가는 프로그램이 오류를 내며 종료될 것입니다.

주소 자료형을 사용할 때 주의해야 할 점이 있습니다.

int main() {
      int* a, b;        //a는 int*, b는 int
      int* c, * d;      //c는 int*, d도 int
      return 0;
}

typedef 원래 자료형 별칭을 사용해 한 자료형의 별칭을 만들 수 있는데,

#include <stdio.h>
typedef int jungsoo;
int main() {
      jungsoo a, b;     //a와 b는 jungsoo
      printf("%d %d\n", sizeof a, sizeof b);
      return 0;
}

4 4

이것을 사용하면 문제를 피해갈 수 있습니다.

#include <stdio.h>
typedef int* int_p;
int main() {
      int_p a, b; //a와 b는 int*
      printf("%d %d\n", sizeof a, sizeof b);
      return 0;
}

8 8

하지만 되려 코드의 가독성을 해칠 수 있으니 '이런 것이 있다' 정도만 알아두세요.

주소 자료형의 연산은 조금 독특합니다. 일반 정수 자료형의 덧셈과 뺄셈에서는 + 1을 하면 값이 1 증가하고 - 1을 하면 값이 1 감소했지만 주소 자료형은 다릅니다. 주소 자료형 T*에서 + 1을 하면 값이 sizeof(T)만큼 증가하고, - 1을 하면 sizeof(T)만큼 감소합니다. 예시를 볼까요?

#include <stdio.h>
int main() {
      int a = 5;
      int* b = &a;
      printf("int의 크기는 %d\n", sizeof(int));
      printf("b - 2: %X\n", b - 2);
      printf("b - 1: %X\n", b - 1);
      printf("b    : %X\n", b);
      printf("b + 1: %X\n", b + 1);
      printf("b + 2: %X\n", b + 2);
      return 0;
}

int의 크기는 4
b - 2: 61FE44
b - 1: 61FE48
b       : 61FE4C
b + 1: 61FE50
b + 2: 61FE54

주소 자료형 중에는 조금 독특한 것이 있는데, 바로 void*입니다. '없는 것을 가리키다니! 이게 무슨 짓이야!'라고 생각할 수도 있지만, void*은 모든 타입을 가리킬 때 사용할 수 있으며, 덧셈과 뺄셈을 하면 1씩 증가하고 감소합니다. 나중에 가변 인자에 대해 공부할 때 보게 될 것입니다.

지금까지 꽁꽁 숨겨왔던 비밀 그 첫 번째, 배열과 포인터의 관계를 공개합니다!

#include <stdio.h>
int main() {
      int arr[ 5 ] = { 0, };
      printf("&arr[ 0 ]: %X\n", &arr[ 0 ]);
      printf(" arr     : %X\n", arr);
      return 0;
}

&arr[ 0 ]: 61FE30
 arr          : 61FE30

배열의 이름은 사실 그 배열 첫 번째 원소의 주소를 의미합니다. 저번에 연산자를 공부하며 지나가는 말로 a[ b ]*(a + b)가 같은 뜻이라고 한 것을 기억하나요? 이 이유로 배열의 인덱스는 1이 아니라 0부터 시작합니다. 또, arr[ 0 ] 뿐만 아니라 0[ arr ] 또한 유효한 코드입니다.

const를 자료형 앞에 붙이면 그 변수가 가리키는 주소의 값을 변경할 수 없고, 뒤에 붙이면 그 변수가 가리키는 주소를 변경할 수 없습니다.

int main() {
      int a = 5, b = 10;
      const int* ptr1 = &a;
      int* const ptr2 = &a;
      //*ptr1 = 10;		불가능
      ptr1 = &b;
      *ptr2 = 10;
      //ptr2 = &b;		불가능
      return 0;
}

다차원 배열의 원소들이 사실 메모리 상에서는 일직선상에 있다고 했던 것도 기억합니까?

#include <stdio.h>
int main() {
      int arr[ 2 ][ 2 ] = { 0, };
      printf("&arr[ 0 ][ 2 ]: %X\n", &arr[ 0 ][ 2 ]);
      printf("&arr[ 1 ][ 0 ]: %X\n", &arr[ 1 ][ 0 ]);
      return 0;
}

&arr[ 0 ][ 2 ]: 61FE48
&arr[ 1 ][ 0 ]: 61FE48

아, 그리고 우리는 '배운 사람'이므로 위의 코드를 조금만 고쳐줍시다.

#include <stdio.h>
int main() {
      int arr[ 2 ][ 2 ] = { 0, };
      printf("&arr[ 0 ][ 2 ]: %X\n", arr[ 0 ] + 2);
      printf("&arr[ 1 ][ 0 ]: %X\n", arr[ 1 ]);
      return 0;
}

여러분은 포인터를 떡 주무르듯 다룰 수 있어야 합니다. 연습! 또 연습!

#include <stdio.h>
int main() {
      int a[ 3 ][ 3 ] = { { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 } };
      for(int i = 0; i < 9; ++i)
            printf("%X %d  %X %d  %X %d\n",
            *a + i,
          *(*a + i),
            (void*)a + sizeof(int) * i,
    *(int*)((void*)a + sizeof(int) * i),
            (void*)a + sizeof(int[ 3 ]) * (i / 3) + sizeof(int) * (i % 3),
    *(int*)((void*)a + sizeof(int[ 3 ]) * (i / 3) + sizeof(int) * (i % 3)));
      return 0;
}

61FE00 1 61FE00 1 61FE00 1
61FE04 2 61FE04 2 61FE04 2
61FE08 3 61FE08 3 61FE08 3
61FE0C 4 61FE0C 4 61FE0C 4
61FE10 5 61FE10 5 61FE10 5
61FE14 6 61FE14 6 61FE14 6
61FE18 7 61FE18 7 61FE18 7
61FE1C 8 61FE1C 8 61FE1C 8
61FE20 9 61FE20 9 61FE20 9

#include <stdio.h>
int main() {
      char arr[ 4 ] = { 0, };
      *(int*)arr = 0x78563412;
      for(int i = 0; i < 4; ++i)
            printf("arr[ %d ]: %X...%X\n", i, arr[ i ], arr + i);
      return 0;
}

arr[ 0 ]: 12...61FE3C
arr[ 1 ]: 34...61FE3D
arr[ 2 ]: 56...61FE3E
arr[ 3 ]: 78...61FE3F

두 번째 코드는 리틀엔디언Little-endian 방식 컴퓨터가 아니라면 12 34 56 78이 나오지 않을 수 있습니다☆.

다음 코드에서 arr1arr2, arr3의 차이를 알아맞춰 보세요.

#include <stdio.h>
int main() {
      int* arr1[ 10 ];
      int (*arr2)[ 10 ];
      printf("arr1: %d\n", sizeof arr1);
      printf("arr2: %d\n", sizeof arr2);
      return 0;
}

arr1: 80
arr2: 8

arr1은 예상했듯, int*의 배열입니다. 8 * 10으로 총 80byte의 크기를 차지하고 있지요. 하지만 arr2는 조금 의미가 다릅니다. arr2는 크기가 10인 int 배열을 가리키는 포인터입니다. 그래서 크기는 8byte지요.

자, 약속을 지킬 시간입니다. 큰 따옴표로 둘러싸인 문자열의 비밀에 대해 많이들 궁금했을 것입니다.

#include <stdio.h>
int main() {
      char* a = "BOY";
      printf("%X\n%X\n", a, "BOY");
      return 0;
}

404000
404000

여러분이 "BOY"를 만들면, 정적 데이터 영역에 'B', 'O', 'Y', '\0'으로 구성된 배열이 생깁니다. 또, "BOY" 그 자체는 그 배열 첫 번째 원소의 주소입니다. 그러니까, 조금 장난을 쳐보자면, 2[ "BOY" ]와 같은 코드도 유효하며, 그 값은 'Y'입니다. 또다시 "BOY"를 사용할 일이 있으면, 아까 만든 "BOY"를 재사용할 수 있습니다. 하지만 이 배열의 원소를 수정하지는 마십시오. 프로그램이 오류를 일으키며 종료될 수 있습니다.

scanf  함수와 printf 함수가 문자열 출력을 위해 주소값만 받는다는 사실을 떠올리십시오. %s로 문자열을 출력할 때 여러분이 printf에 보냈던 것은 배열 첫 원소의 주소입니다. 만약 arr의 두 번째 원소부터 출력하고 싶다면 printf("%s\n", arr + 1)과 같이 하면 되겠지요.


#include <stdio.h>
int main() {
      int arr1[ 16 ], arr2[ 16 ][ 16 ], arr3[ 16 ][ 16 ][ 16 ];
      printf("%d %d %d\n", sizeof arr1, sizeof arr2, sizeof arr3);                  //arr1은 int[ 16 ], arr2는 int[ 16 ][ 16 ], arr3은 int[ 16 ][ 16 ][ 16 ]
      printf("%d %d %d\n", sizeof arr1[ 0 ], sizeof arr2[ 0 ], sizeof arr3[ 0 ]);   //arr1[ 0 ]은 int, arr2[ 0 ]은 int[ 16 ], arr3[ 0 ]은 int[ 16 ][ 16 ]
      printf("%d %d\n", sizeof arr2[ 0 ][ 0 ], sizeof arr3[ 0 ][ 0 ]);              //arr2[ 0 ][ 0 ]은 int, arr3[ 0 ][ 0 ]은 int[ 16 ]
      printf("%d\n", sizeof arr3[ 0 ][ 0 ][ 0 ]);                                   //arr3[ 0 ][ 0 ][ 0 ]은 int

      char* str1 = "NYA";           //str1은 char*(포인터 자료형), "NYA" 문자열을 '가리킴'
      char str2[ 4 ] = "NYA";       //str2는 char[ 4 ](배열), { 'N', 'Y', 'A', '\0' }로 초기화

      int* a;                       //포인터
      int b[ 16 ];                  //배열

      int** c;                      //포인터의 포인터
      int* d[ 16 ];                 //포인터의 배열
      int (*e)[ 16 ];               //배열의 포인터
      int f[ 16 ][ 16 ];            //배열의 배열

      int*** g;                     //포인터의 포인터의 포인터
      int** h[ 16 ];                //포인터의 포인터의 배열
      int* (*i)[ 16 ];              //포인터의 배열의 포인터
      int* j[ 16 ][ 16 ];           //포인터의 배열의 배열
      int (**k)[ 16 ];              //배열의 포인터의 포인터
      int (*l[ 16 ])[ 16 ];         //배열의 포인터의 배열
      int (*m)[ 16 ][ 16 ];         //배열의 배열의 포인터
      int n[ 16 ][ 16 ][ 16 ];      //배열의 배열의 배열

      //*연산자가 붙으면 포인터, []연산자가 붙으면 배열입니다

      return 0;
}

이런 코드를 사용하는 일이 없으면 좋겠습니다.