포인터 / 포인터 선언 / 초기화 / 연산 / 문자열

2022. 2. 28. 00:37IoT Embeded 강의

다른 언어와 다르게 C언어의 가장 강력한 무기는 바로 포인터입니다. 포인터의 장점 중 하나는 메모리를 효율적으로 다룰 수 있다는 것입니다. 임베디드 분야에서 C언어가 필수인 이유도 메모리 관리가 용이하기 때문입니다.

제가 소스 코드를 작성하면서 많은 양의 데이터를 다루기 위해 배열을 선언한다고 가정합시다. 배열의 크기는 한번 선언하면 이는 중간에 바꿀 수 없습니다. 만일 제가 800byte의 배열을 선언하면 이는 프로그램이 종료될 때까지 메모리에 공간을 차지하고 있습니다. 만약 들어오는 데이터가 일정하게 이 공간을 채우지 못한다면 낭비되거나 또는 공간이 더 필요한 상황이 나타날 수 있습니다. 만일 다루는 데이터 양이 적거나 컴퓨터 성능이 여유롭다면 이런 걱정을 안해도 되겠지만 임베디드에서 사용하는 컴퓨터, 메모리는 이보다 협소한 상황에서 이루어집니다. 때문에 메모리 공간 활용을 효율적으로 해야만 합니다. 

포인터는 배열과 다르게 중간에 할당되는 메모리 공간을 변경할 수 있습니다. 이를 동적 메모리 할당이라고 합니다. 또한 포인터의 개념을 배우면 메모리 주소와 공간, 참조에 대해서 더 자세히 알게 됩니다. 이는 다른 언어의 프로그램을 배울 때에도 큰 도움이 될 것입니다. 이번 글에서는 먼저 포인터 변수 선언과 초기화, 연산에 대해 다루겠습니다.

 

포인터란

포인터는 데이터가 저장되어 있는 위치를 나타내는 메모리 주소를 말합니다. 이 전에 변수를 선언하면 메모리 공간에 그 변수의 공간이 자료형의 크기만큼 할당된다고 설명했습니다.

메모리에는 일반적으로 1byte(한 칸)의 공간마다 각각 주소가 있습니다. 포인터는 이 주소 자체를 저장하는 특수한 변수라고 생각하면 됩니다. 데이터 자체가 아닌 데이터를 담고 있는 메모리가 어디 있는지(주소)를 저장한 것입니다. 

 

 

포인터 변수 선언과 초기화

포인터 변수는 아래와 같이 선언합니다.

1
2
3
4
5
6
7
#include <stdio.h>
 
int main()
{
int *ptr;
    return 0;
}
cs

 

 

포인터 변수를 선언하면 일반 변수를 선언한 것처럼 메모리에 공간을 차지하게 됩니다. 또한 자료형도 명시해줘야 하는데 포인터 변수에게 자료형은 포인터가 가르키는 메모리 공간의 데이터를 봤을 때 int형으로 해석할지, char 형으로 해석할지를 결정하는 것입니다. 일반 변수는 자료형에 따라 메모리를 점유하는 크기가 달라지지만 포인터 변수는 자료형에 상관없이 4byte(32bit운영체제) 또는 8byte(64bit운영체제) 입니다. 왜냐하면 포인터에 저장되는 값은 어떤 변수의 주소값이지 어떤 데이터나 자료가 아니기 때문입니다. 이제 일반 변수를 하나 선언하고 포인터 변수에 이 변수의 주소를 대입하겠습니다.

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
 
int main()
{
int x;
x=10;
int *ptr;
ptr = &x;
    return 0;
}
cs

int형 변수x가 선언됐으므로 메모리에 4byte의 공간을 차지하게 됩니다. 이때 그 곳의 주소를 ptr변수에 대입한 것입니다. 여기서 &는 '주소연산자'로 일반 변수에 쓰면 그 변수의 주소값을 나타내게 됩니다. 즉, ptr이라는 포인터 변수에 x라는 변수의 주소값이 대입됐습니다.

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
 
int main()
{
int x;
x=10;
int *ptr;
ptr = &x;
    return 0;
}
cs

참조연산자

여기서 포인터 변수를 이용해 x에 저장된 '10'이라는 값을 불러올 수 있습니다. 출력함수에서는 일반 변수를 이용해 출력할수도 있지만 포인터를 이용해 출력할 수 있습니다. 참조연산자 '*'를 포인터 변수에 붙여주면 포인터가 가르키는 곳의 데이터를 나타내게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
 
int main()
{
int x;
x=10;
int *ptr;
ptr = &x;
 
printf("%d",x);
prtinf("%d"*ptr);
 
printf("%p",&x);
prtinf("%p", ptr);
 
    return 0;
}
cs

 

형식지정자

주소를 출력하기 위해서는 형식지정자 %p를 사용합니다. 메모리 주소를 16진수로 표현합니다. 위의 예시에서 출력 결과를 보면 x =*ptr, &x = ptr 로 같음을 알 수 있습니다. 

 

포인터 연산

포인터 변수도 연산을 할 수 있지만 일반 변수와는 조금 다릅니다. 포인터 변수는 주소를 저장하는 변수이기 때문에 변수에 곱셈이나 나눗셈 연산을 할 수 없습니다. 주로 덧셈과 뺄셈을 이용해 메모리 공간을 이동하는 방식으로 사용합니다. 

예를들어 int형 포인터 변수ptr 에 '1000'이라는 주소값이 들어갔다고 가정하겠습니다. 여기서 '1'을 더한다는 의미는 실제 1을 더한다는 의미보다는 그 다음 주소로 이동한다는 의미가 됩니다. ptr 포인터 변수는 int형 자료를 가르키고 있으므로 다음 주소는 int형의 자료형 크기(4byte)만큼 떨어져 있습니다. 따라서 다음 주소인 1004가 연산 결과가 됩니다. 

ptr + 1 = 1001(x)

ptr + 1 = 1004(o)

 

만약 참조연산자*를 쓴 뒤 덧셈을 하면 어떻게 될까요? 이때는 포인터가 가르키는 데이터 값 자체에 '1'이 그대로 더해지게 됩니다. 참조연산자로 먼저 데이터에 접근한 뒤 연산을 하는 것이기 때문입니다. 

*ptr = 500

*ptr +1 = 501

 

그렇다면 포인터 변수를 이용해 메모리에 연속적으로 접근할 수 있을 것입니다. 만약 배열이 선언되어 있다면 그 배열의 인덱스에 맞게 차례대로 접근할 수 있습니다. 또한 주소값도 배열과 포인터 변수의 자료형의 크기만큼 커질 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
 
int main()
{
    int Array[10= { 1,2,3,4,5,6,7,8,9,10 };
    int* ptr = Array;
    printf("%d\n"*ptr);
    printf("%d\n"*(ptr + 1));
    printf("%d\n"*(ptr + 2));
    printf("%d\n"*(ptr + 3));
    printf("%d\n"*(ptr + 4));
    printf("%d\n"*(ptr + 5));
    printf("%d\n"*(ptr + 6));
    printf("%d\n"*(ptr + 7));
    printf("%d\n"*(ptr + 8));
    printf("%d\n"*(ptr + 9));
    return 0;
}
cs

 

또한 주소값도 배열과 포인터 변수의 자료형의 크기만큼 커질 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
 
int main()
{
    int Array[10= { 1,2,3,4,5,6,7,8,9,10 };
    int* ptr = Array;
    printf("%p\n", ptr);
    printf("%p\n", (ptr + 1));
    printf("%p\n", (ptr + 2));
    printf("%p\n", (ptr + 3));
    printf("%p\n", (ptr + 4));
    printf("%p\n", (ptr + 5));
    printf("%p\n", (ptr + 6));
    printf("%p\n", (ptr + 7));
    printf("%p\n", (ptr + 8));
    printf("%p\n", (ptr + 9));
    return 0;
}
cs

위의 참조연산자를 사용할 때는 소괄호로 먼저 포인터 이동을 한 뒤에 참조연산을 하도록 하는 번거로움이 있습니다. 이를 브래킷[] 연산자를 사용하면 참조연산자와 이동을 한번에 표현할 수 있습니다. 

*(ptr+2) = ptr[2] // 같은 의미의 다른 표현입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
 
int main()
{
    int Array[10= { 1,2,3,4,5,6,7,8,9,10 };
    int* ptr = Array;
    printf("%d\n", ptr[0]);
    printf("%d\n", ptr[1]);
    printf("%d\n", ptr[2]);
    printf("%d\n", ptr[3]);
    printf("%d\n", ptr[4]);
    printf("%d\n", ptr[5]);
    printf("%d\n", ptr[6]);
    printf("%d\n", ptr[7]);
    printf("%d\n", ptr[8]);
    printf("%d\n", ptr[9]);
    return 0;
}
 
cs

 

[]연산자를 사용하면서부터 포인터는 배열과 그 사용법이 거의 비슷합니다. 이처럼 포인터는 배열의 기능을 할 수 있습니다. 가장 큰 차이점은 배열은 그 크기를 중간에 바꿀 수 없지만 포인터는 동적 메모리 할당을 통해 메모리를 점유하는 정도를 조절할 수 있습니다. 사실 배열과 포인터는 컴파일을 하고나면 컴퓨터 입장에서는 굉장히 비슷해보입니다. 왜냐하면 배열이름 자체는 그 배열의 첫번째 인덱스 주소값을 갖고 있기 때문입니다. 즉, 배열 이름자체가 포인터입니다. 하지만 한번 선언하면 바꿀 수 없는 상수의 성질을 갖고 있어 '포인터 상수'라고도 합니다.

 

배열 이름이 포인터, 주소값이기 때문에 입력함수 scanf 에서 &를 붙이지 않는 것입니다. 포인터 변수가 배열을 가르키게 하려면 포인터 변수에 배열이름을 &없이 대입해주기만 하면 됩니다.
ptr = Array;

 

배열 이름 자체를 굳이 연산자들을 써서 표현하면 아래와 같습니다.

 

Array 는 &Array[0] 와 같다. 

 

배열은 []연산자로 인덱스 번호를 붙이면 이제 배열안에 한 칸을 차지하는 일반 변수입니다. 거기에 &연산자를 붙여 배열의 첫번째 인덱스의 주소가 Array 라는 배열이름 자체와 같은 것입니다.

 

변수와 상수 / 문자열 상수와 배열, 포인터

일반 변수와 초기화를 해보겠습니다.

1
2
3
4
5
6
7
8
#include <stdio.h>
 
int main()
{
   int num =3;
    return 0;
}
 
cs

 

이렇게 보면 메모리에 num의 공간만 할당된 것 같지만 사실 상수 '3' 도 메모리에 공간이 할당됩니다. 하지만 상수가 있는 메모리에 뭔가를 대입하거나 데이터를 덮어 씌울 수 없습니다. 위의 상황은 num이라는 메모리 공간에 '3'이 저장된 메모리 공간에서 '3'을 가져와 덮어 씌운 것입니다.

3 = 5+2 ; //오류 

문자열도 문자열 상수라는 것이 있습니다. 

1
2
3
4
5
6
7
8
#include <stdio.h>
 
int main()
{
   char array[5] = "Golf";


array[3] = 'd';
printf("%s\n",array);

    return 0;
}
 
cs

array 라는 배열과 동시에 "Golf"라는 문자열도 메모리 공간을 차지하게 됩니다. 그리고 "Golf"가 저장된 메모리에서 데이터를 가져와 array라는 배열 안에 복사하는 것입니다. 여기서 array[3]의 데이터를 바꾸면 "Gold"가 출력됩니다. 위처럼 이중 따옴표 안에 문자열을 쓰게 되면 문자열 상수가 됩니다. 문자열 상수를 배열이라는 그릇에 복사한 뒤 배열안의 데이터를 바꿔서 출력한 것이기 때문에 문자열 상수가 변한 것이 아니라 복사된 배열 안의 데이터가 바뀐 것입니다.

이번엔 문자열 상수를 포인터를 사용해 데이터를 변경해보겠습니다.

1
2
3
4
5
6
7
8
#include <stdio.h>
 
int main()
{
   char *ptr = "Golf";

ptr[3] = 'd';// 오류
printf("%s\n", ptr);
    return 0;
}
 
cs

배운대로라면 ptr의 4번째 위치에 접근하여 'd'라는 문자를 대입하였으므로 "Golf"가 "Gold"로 바뀌어야 할 것 같지만 이는 오류입니다. 왜냐하면 배열과 다르게 포인터는 직접 가르키는 것이기 때문에 문자열 상수가 저장된 위치에 직접 대입하는 의미가 됩니다. 하지만 상수가 저장된 메모리에 다른 데이터를 입력할 수 없는 것처럼 문자열 상수도 그 값을 바꿀 수 없습니다. 이처럼 문자열 상수를 변경해야할 때는 문자열을 먼저 배열에 복사한 뒤 배열 안의 값을 처리하도록 합니다. 

 

포인터 변수는 초기화가 중요

포인터 변수는 다른 일반 변수와 달리 초기화를 꼭 해야합니다. 만일 초기화 하지 않은 포인터를 사용할 시에 포인터에 가비지 값이 들어가 있으면 전혀 엉뚱한 위치를 참조하기 때문입니다. 그래서 전혀 관련 없는 곳에서 오류 발생의 원인이 되기도 합니다. 또한 초기값이 잘못되면 그 뒤에 연속적으로 참조하는 데이터들도 잘못된 데이터들이므로 초기화를 해주는 것이 좋습니다. 포인터 변수는 NULL로 초기화 하는 것이 좋습니다.

1
2
3
4
5
6
7
8
#include <stdio.h>
 
int main()
{
   char *ptr1 = NULL;
char *ptr2 = "";
    return 0;
}
 
cs

위에서 ptr1과 ptr2는 다른 상황임을 알아야합니다. ptr1에는 포인터 변수에 주소값이 0으로 들어가 있습니다. 이는 아직 어떤 유효한 메모리 공간의 주소를 갖고 있지 않다는 의미입니다. 즉, 빈껍데기입니다. 반대로ptr2 는 실제 문자열 상수의 주소를 갖고 있습니다. ""은 공백을 의미하는 NULL로만 이루어진 문자열입니다. 이 문자열은 메모리에 유효한 공간을 차지하고 있으며 주소도 갖고 있습니다. 이 주소를 ptr2가 갖고 있는 것입니다. 포인터 변수 초기화는 ptr1처럼 해야합니다. 

다음으로는 포인터의 동적 메모리 할당에 대해 알아보겠습니다.

'IoT Embeded 강의' 카테고리의 다른 글

2022.02.28 유틸리티 함수, 수학 함수  (0) 2022.03.01
동적 메모리 할당  (0) 2022.02.28
자료형과 해석  (0) 2022.02.24
배열과 문자열  (0) 2022.02.14
1차원 배열  (0) 2022.02.14