주소 값의 이해와 표현

이 강좌에서 배우게 될 포인터는 필자도 어렵게 생각하는 부분이며 C언어에서 가장 어렵고도 핵심인 구간입니다. 포인터에 들어와서 바로 포인터를 다루게 된다면 혼란이 생길 수 있으므로, 우선 알아야 할 것부터 알아보도록 합시다. 간단한 사항부터 알아보도록 하고, 바로 포인터라는 녀석을 사용하여 어떤 녀석인지 대충 짐작을 하도록 합시다.

포인터(Pointer)란 메모리의 주소 값을 담고 있는 변수 혹은 상수입니다. 비슷하게는 데이터의 위치를 가리키는 녀석이라고 할 수도 있습니다. 의외로 간단해 보일지도 모르겠지만 주소 값과 관련이 있어 메모리의 주소체계를 이해하지 못하면 포인터를 정확히 이해할 수 없습니다. 여기서 주소란 그 메모리의 저장장소의 위치를 나타내는 값으로 하나의 주소값은 1바이트 크기의 메모리 공간을 표현합니다.

 

<한 블럭(주소)당 1바이트의 메모리 공간 차지, 한 개의 주소는 8개의 비트가 묶임>

포인터 변수 선언

포인터 변수란 메모리 주소를 저장하는 변수이며 데이터 타입과 식별자(=변수명) 사이에 그저 * 하나만 넣어주면 포인터 변수가 됩니다. 기본적인 데이터 타입과 구조체, 배열, 공용체에 대해서도 포인터형을 만들 수가 있습니다. 그리고 & 연산자를 변수명 앞에다 가져오면 그 변수의 주소값을 반환하게 됩니다. 아래 예제를 한번 보도록 합시다.

#include <stdio.h>

int main() {
    int num = 50;
    int num1 = 72;
    int num2 = 94;
    
    printf("num의 저장 위치: %#x\n", &num); // num의 저장 위치: 0x22ff44
    printf("num1의 저장 위치: %#x\n", &num1); // num1의 저장 위치: 0x22ff40
    printf("num2의 저장 위치: %#x\n", &num2); // num2의 저장 위치: 0x22ff3c
    
    return 0;
}

8, 9, 10행에서 변수명 앞에 & 연산자를 붙여주어 그 변수의 주소값이 반환되고 16진수의 형태로 출력한다는 의미입니다. %x는 16진수로 출력시킬 때 사용하는 출력 포맷이고, 그 가운데에 들어간 #를 제외하면 앞에 0x가 붙지 않습니다. & 연산자는 '어떤 변수의 주소를 알아내는 역할'도 하는 연산자이며 상수는 메모리에 위치하지 않으므로 주소가 없으며 & 연산자를 사용할 수 없습니다.

포인터 변수를 이용하면 프로그램이 간결하고 효율적이며 그 포인터가 가리키는 변수의 자료형에 따라 타입을 맞추어 선언해야 합니다. 다음은 포인터 변수에 관한 예제입니다.

#include <stdio.h>

int main() {
    int number = 50;
    int *ptr = &number;

    printf("변수 number의 값: %d\n", number); // 변수 number의 값: 50
    printf("변수 number의 주소값: %#x\n\n", ptr); // 변수 number의 주소값: 0x22ff44

    *ptr = 60;
    printf("변수 number의 값: %d\n", number); // 변수 number의 값: 60

    return 0;
}

앞에서 말했듯이, 포인터 변수는 주소값만 저장할 수 있습니다. 지금까지 우리가 배운 * 연산자의 기능은 곱셈을 할때 사용하거나, 포인터 변수 선언 시에도 사용되었습니다. 그런데 10행에서의 * 연산자는 무슨 기능을 할까요? 이것은 간접 참조 연산자로 단항 연산자로 사용되면 이 포인터가 가리키는 메모리 공간의 접근을 의미합니다. 즉, 10행의 문장에서는 ptr이 가리키는 변수 number를 의미하며 이것은 'number=60'과 동일한 기능을 합니다. 이 간접 참조 연산자는 포인터 변수를 초기화하고 사용해야 합니다.

주의할 점은 여러개의 포인터 변수를 한 번에 선언할 때 변수마다 *를 붙여주어야 합니다. 아래와 같이 말입니다.

int *a, *b, *c;

아래와 같이 선언해버리면, b와 c는 정수형 포인터가 아닌 정수형 변수가 되어버립니다.

int *a, b, c;

만약에, *가 두번씩이나 쓰이면 어떻게 될까요?

#include <stdio.h>

int main() {
    int Num1 = 50, Num2 = 100;
    int *pNum1 = &Num1;
    int **dpNum1 = &pNum1;

    printf("정수형 변수 Num1의 값: %d\n", Num1); // 50
    printf("pNum1이 가리키는 변수의 값: %d\n", *pNum1); // 50
    printf("dpNum1이 가리키는 변수의 값: %d\n\n", **dpNum1); // 50

    *dpNum1 = &Num2; // pNum1 = &Num2

    printf("정수형 변수 Num2의 값: %d\n", Num2); // 100
    printf("pNum1이 가리키는 변수의 값: %d\n", *pNum1); // 100
    printf("dpNum1이 가리키는 변수의 값: %d\n\n", **dpNum1); // 100

    **dpNum1 += 150;

    printf("정수형 변수 Num2의 값: %d\n", Num2); // 250
    printf("pNum1이 가리키는 변수의 값: %d\n", *pNum1); // 250
    printf("dpNum1이 가리키는 변수의 값: %d\n", **dpNum1); // 250

    return 0;
}

포인터의 주소값을 저장하기 위해 포인터 형이 int **인 변수에 저장하였습니다. 포인터 변수 dpNum1에서 *를 한 번만 사용하면 dpNum1이 가리키는 포인터 pNum1의 주소값을 참고합니다. **를 두 번 사용하면 dpNum1이 가리키는 변수를 의미합니다. 이런 녀석을 이중 포인터라고 부르며, 포인터 변수를 가리키는 포인터라고 합니다.

포인터 연산

포인터끼리 더하거나 뺄수 있으며 포인터에 정수를 더하거나 빼거나, 대입마저도 가능합니다. 그렇지만 포인터끼리 더하는 건 원칙적으로 허용하지 않고 아무 의미가 없으며, 더하려고 시도하면 에러를 내보냅니다. 이것은 불가능해서가 아니라, 단순히 프로그래머가 실수해서 그런 코드를 적었을 확률이 높기 때문입니다. 물론 굳이 더하려고 한다면 더할 수 있습니다. 다만 포인터와 포인터끼리의 덧셈은 아무 의미도 없다는 것뿐입니다.

한번, 포인터와 포인터를 서로 더하고 뺀 후의 결과를 출력하는 예제를 보도록 합시다.

#include <stdio.h>

int main() {
    char Array[] = "Pointer Array";
    char *pArray1, *pArray2, *pArray3;

    pArray1 = &Array[0];
    pArray2 = &Array[11];
    // 아래 문장에서 에러가 발생하면 -fpermissive 옵션을 적용시키거나,
    // 혹은 unsigned 대신에 uintptr_t 타입으로 대체해주세요.
    pArray3 = (char *)((unsigned)pArray1 + (unsigned)pArray2);

    printf("Array1의 주소 값: %#x\n", pArray1); // 0x661ff6da
    printf("Array2의 주소 값: %#x\n\n", pArray2); // 0x661ff6e5

    printf("Array1과 Array2의 주소를 더한 값: %#x\n", pArray3); // 0xcc3fedbf
    printf("Array1(%c)과 Array2(%c)의 거리: %d\n", *pArray1, *pArray2, pArray2 - pArray1); // 11

    return 0;
}

11행의 포인터끼리의 덧셈 연산은 아무 의미도 없습니다. (포인터끼리의 곱셈 또는 나눗셈도 포함합니다). 굳이 하겠다면 두 포인터를 unsigned로 캐스팅 후 포인터 타입으로 다시 캐스팅하여 덧셈이 가능하며 명시적인 캐스트 연산자에 대해서는 컴파일러가 에러를 내보내지 않습니다. 그렇지만 뺄셈은 가능합니다. 포인터끼리의 뺄셈은 두 포인터 간의 거리를 나타냅니다. 17행에서 P와 a의 거리는 11이며, 그 사이에 'o', 'i', 'n', 't', 'e', 'r', ' ', 'A', 'r', 'r'가 존재함을 확인할 수 있습니다.

만약, 포인터에 정수를 더하거나 빼는 문장을 거친다면 어떤 결과가 출력될까요?

#include <stdio.h>

int main() {
    char *pc;
    int *pi;
    double *pd;

    pc = (char *)100;
    pi = (int *)100;
    pd = (double *)100;

    printf("pc 증가 전: %p\n", pc); // 0000000000000064
    printf("pi 증가 전: %p\n", pi); // 0000000000000064
    printf("pd 증가 전: %p\n\n", pd); // 0000000000000064

    pc++;
    pi++;
    pd++;

    printf("pc 증가 후: %p\n", pc); // 0000000000000065
    printf("pi 증가 후: %p\n", pi); // 0000000000000068
    printf("pd 증가 후: %p\n", pd); // 000000000000006c

    return 0;
}

결과를 보시면 포인터의 자료형의 크기만큼 증가한다는 것을 알 수 있습니다. char는 1, short는 2, int는 4, float는 4, double는 8로 말입니다. 만약에 2 이상의 수를 더한다면 '포인터가 가리키는 변수 데이터 타입의 크기 * 정수' 만큼 증가가 되는 걸 보실 수 있습니다. 뺄셈도 이와 마찬가지입니다.

포인터에 덧셈과 뺄셈을 하는 것 말고도, 포인터끼리의 비교도 가능합니다. 

#include <stdio.h>

int main() {
    int Num1, Num2;
    int *pNum1, *pNum2;

    pNum1 = &Num1;
    pNum2 = &Num2;

    if (pNum1 != NULL) {
        printf("pNum1은 NULL이 아닙니다.\n"); // 출력됨
    }

    if (pNum1 != pNum2) {
        printf("pNum1과 pNum2은 다릅니다.\n"); // 출력됨
    }

    if (pNum1 < pNum2) {
        printf("pNum1은 pNum2보다 앞에 있습니다.\n");
    } else {
        printf("pNum1은 pNum2보다 뒤에 있습니다.\n"); // 출력됨
    }

    return 0;
}

참고로, 10행에서의 비교는 pNum1이 널 포인터(NULL Pointer)인지를 확인하기 위해서입니다. 널 포인터는 아무 곳도 가리키지 않는 포인터를 말합니다.

포인터 배열

포인터 배열(Pointer Array)란 말 그대로 포인터 변수로 이루어진 배열을 말하는 것이며 포인터 배열의 선언방식은 우리가 알고 있는 배열 선언방식과 크게 다르지 않습니다.

#include <stdio.h>

int main() {
    int num[3];
    int *pNumArray[3]={num, num+1, num+2};
    int i;

    for(i=0; i<3; i++)
        scanf("%d", pNumArray[i]);

    printf("입력된 숫자는 각각 %d, %d, %d 입니다.\n", num[0], num[1], num[2]);
    return 0;
}

5행에서 길이가 3인 포인터 배열 pNumArray를 선언후 각각 num1, num2, num3의 주소값으로 초기화시켰습니다. 그리고 for문을 이용하여 수를 입력받게 하였는데, 9행에서 주의할 것은 pNumArray[i]은 주소값을 의미하므로 &를 붙이면 안 됩니다. 그리고 11행에서 참조하고 있는 변수의 값을 차례대로 출력하였습니다. 간단하죠? 물론 1차원 포인터 배열뿐만 아니라, 2차원 포인터 배열도 만들 수 있습니다.

배열과 포인터

포인터와 배열은 밀접한 관계가 있으며 이제부터 그 관계를 설명하고자 합니다. 배열의 이름은 사실 배열의 시작번지를 갖는 포인터 상수이며, 즉 첫번째 원소의 주소값을 나타냅니다. 아래 예제를 살펴보도록 합시다.

#include <stdio.h>

int main() {
    int Array[5]={44,77,64,13,42};
    int i;

    printf("Array: %p\n\n", Array);             // 배열의 이름 출력
    for(i=0; i<5; i++)
        printf("Array[%d]: %p\n", i, &Array[i]); // 배열 요소의 주소 출력

    return 0;
}

결과를 확인해봤더니, int형의 크기인 4바이트씩 값이 증가되는 걸 확인할 수 있으며 이것을 이용하여 배열의 접근이 가능합니다.

#include <stdio.h>

int main() {
    int Array[5]={44,77,64,13,42};
    int *p=&Array[2];

    printf("p가 가리키는 배열의 위치: %d\n", *p); // 64
    printf("p가 가리키는 배열의 위치에서 한칸 앞: %d\n", *(p+1)); // 13
    printf("p가 가리키는 배열의 위치에서 두칸 앞: %d\n", *(p+2)); // 42
    printf("p가 가리키는 배열의 위치에서 한칸 뒤: %d\n", *(p-1)); // 77
    printf("p가 가리키는 배열의 위치에서 두칸 뒤: %d\n", *(p-2)); // 44

    return 0;
}

p가 가리키는 세번째 요소의 주소값에 각각 1, 2를 더한 결과와 뺀 결과를 출력하도록 했습니다. 전에 말씀드렸듯이 '포인터 변수가 가리키는 변수 자료형의 크기 * 정수'만큼 주소값에서 값이 더해져 배열 요소의 그다음값을 가리키고 있습니다.

만약 길이가 3인 1차원 배열 a를 선언했다고 합시다. 우리는 배열명이 포인터 상수인걸 알고 있으며 이것을 이용하여 'a+i는 &a[i]이며 *(a+i)는 a [i]이다.'라는 사실을 알 수 있습니다.

심지어 포인터를 배열처럼 사용할 수도 있습니다.

#include <stdio.h>

int main() {
    int Array[3]={44,77,64};
    int *p=Array;

    printf("Array[0]:%d Array[1]:%d Array[2]:%d\n", Array[0], Array[1], Array[2]);
    printf("p[0]:%d p[1]:%d p[2]:%d\n\n", p[0], p[1], p[2]);

    p[0]=64;
    p[2]=44;

    printf("Array[0]:%d Array[1]:%d Array[2]:%d\n", Array[0], Array[1], Array[2]);
    printf("p[0]:%d p[1]:%d p[2]:%d\n", p[0], p[1], p[2]);

    return 0;
}

포인터를 사용하면 좋은게 참 많습니다. 포인터를 사용하면 원소의 주소를 계산할 필요가 없어져 인덱스 표기법보다 더 빠른 속도를 낼 수 있습니다. 참 편리하지 않습니까?

배열을 인자로 넘길때는 어떻게 넘길 수 있을까요? 다음은 배열을 인자로 넘겨 배열의 원소를 역순으로 출력합니다.

#include <stdio.h>

void ReverseArray(int *array, int len);

int main() {
    int Array[7]={44,77,64,11,20,467,500};

    ReverseArray(Array, sizeof(Array)/sizeof(Array[0])-1);

    return 0;
}

void ReverseArray(int *array, int len) {
    int i;

    for(i=len; i>=0; i--)
        printf("Array[%d]: %d\n", i, array[i]);
}

예제를 보다 8행을 보고 의문을 가지시는 분들도 계실 텐데 sizeof 연산자는 '괄호 안의 대상이 메모리를 어느 정도 차지 하는가'를 계산합니다. ReverseArray의 첫 번째 전달인자로 배열 Array(첫 번째 원소의 주소값이 전달됨)를 전달하고 두 번째로 배열의 길이를 전달하는데 배열의 전체크기를 배열 요소의 크기로 나누어 배열요소의 개수가 되었습니다. 그리고 배열의 역순출력을 위해 1을 빼준 후에 두 번째 인자로 넘겼습니다. 그리고 ReverseArray 함수에서 Array[6]에서 Array[0]까지의 수를 출력하였습니다. 참고로 매개변수에서 int *array로 선언하지 않고 int array[]를 쓰셔도 괜찮습니다.

동적 메모리 할당

동적 메모리 할당이란 실행 시간동안 사용할 메모리 공간의 할당을 뜻하며 정적 메모리 할당은 우리가 사용하지 않아도 프로그램을 실행할 때 프로그램에서 필요한 메모리 공간을 확보합니다. 예를 들어 저장하려는 데이터의 메모리 공간이 얼마인지 알고 있을 때는 정적 메모리 할당(Static Memory Allocation)을 사용합니다. 우리가 만약 전화번호를 저장한다 한다면 아무리 길어도 15자 이상은 되지 않는다 생각하고 아래와 같이 저장하기 충분한 메모리 공간을 할당합니다.

char PhoneNumber[15]; 

그런데, 정적 메모리 할당은 고정된 메모리 공간을 할당하므로 메모리 낭비가 있을수 있습니다. 반대로 동적 메모리 할당은 어떨까요?

동적 메모리 할당(Dynamic Memory Allocation)은 실행 중에 우리가 필요한 만큼 메모리를 할당시키는 기법입니다. 만약에 모든 학생의 점수를 받아 평균으로 처리하고 싶은데 어떻게 해야 할까요? 하지만 여기서 문제점은 메모리 필요량을 전혀 예측할 수 없는 경우가 존재합니다.

... int StudentNum;
scanf("%d", &StudentNum); // 학생 수를 입력받음
int StudentScore[StudentNum]; ..

위와 같은 코드를 정상적으로 컴파일 할수 있을까요? 우리는 배열의 크기에 상수만 올 수 있으며 변수가 오지 못한다는 것을 알고 있습니다. 그럼 어떻게 해야 우리가 원하는 기능을 만들 수 있을까요? 이것을 동적 메모리 할당이 해결하여 줄 수 있습니다.

#include <stdio.h>
#include <stdlib.h>

int main() {
    int StudentNum, TotalScore = 0;
    int *StudentScore;
    int i;

    printf("학생 수를 입력하세요: ");
    scanf("%d", &StudentNum);

    StudentScore = (int *)malloc(sizeof(int) * StudentNum);

    for (i = 0; i < StudentNum; i++) {
        printf("%d번 학생의 점수: ", i + 1);
        scanf("%d", &StudentScore[i]);
        TotalScore += StudentScore[i];
    }

    printf("모든 학생의 평균: %d\n", TotalScore / StudentNum);

    return 0;
}

코드 중 처음보는 녀석이 있죠? 이 malloc라는 녀석은 힙 영역에 메모리 공간을 할당할 수 있게 도와주는 함수입니다. 이 함수는 stdlib.h에 정의되어 있기 때문에 사용하려면 이 헤더를 선언해야만 합니다.

void * malloc(size_t size) // 성공 시 할당된 메모리의 주소 값, 실패시 NULL 반환 

이 malloc란 함수는 0보다 큰 숫자를 입력받고 이 숫자의 크기만큼 바이트 단위로 힙 영역에 메모리 공간을 할당합니다. 그리고 이 할당된 메모리 공간의 주소값을 반환합니다. 만약에 우리가 500 바이트가 필요하면 malloc(500);을 호출하고 1000바이트가 필요하다면 malloc(1000);를 호출하여 해결할 수 있습니다(상수가 아닌 변수의 사용도 가능함). 주의할 것은 리턴 타입이 void *형이므로 프로그래머가 직접 포인터의 형을 결정해야 합니다. 또한 malloc 함수로 메모리 공간을 할당했으면 free라는 함수로 반드시 직접 해제해야 합니다. 그렇지 않으면 메모리 공간의 낭비가 발생합니다. 다 쓴 공간을 쓸 필요가 없기에 해제하고 다른 곳에 쓰는 게 더 효율적이기 때문이죠.

void free(void * ptr) 

free 함수의 전달인자는 malloc 함수를 호출할 때 반환된 값을 인자로 전달하며 이 함수는 malloc 함수 호출시 할당되었던 메모리 공간을 전부 해제할 수 있습니다. 다시 코드로 돌아와 핵심 부분을 살펴볼까요?

StudentScore=(int *)malloc(sizeof(int)*StudentNum); 

malloc 함수를 사용하여 반환된 할당된 메모리 값의 주소 값을 포인터 StudentScore에 저장하고 있습니다. 정수형 배열을 생성할 것이므로 int형의 크기와 입력받은 학생 수를 곱합니다. 만약 5라고 입력했다면 20 바이트만큼의 메모리 공간을 할당하는 것입니다. 예제를 하나 더 보여드리겠습니다.

#include <stdio.h>
#include <stdlib.h>

int main() {
    int length;
    char *buffer;
    printf("얼마나 긴 문자열을 입력하고 싶으세요? ");
    scanf("%d", &length);

    buffer = (char*)malloc(length + 1); // 입력받은 길이만큼 메모리 할당
    if (buffer == NULL) // 메모리 할당 실패 시 프로그램 종료
        exit(1);

    for (int i = 0; i < length; i++) // 랜덤 문자열 생성
        buffer[i] = rand() % 26 + 'a';
    buffer[length] = '\0'; // 문자열 끝에 NULL 문자 삽입

    printf("랜덤 문자열: %s\n", buffer); // 생성된 문자열 출력
    free(buffer); // 할당한 메모리 해제

    return 0;
}

여기서 문자형 포인터 buffer를 선언 후 사용자로부터 길이를 입력받았습니다. 10행의 length+1는 char형의 크기는 1바이트라는걸 이미 알고 계시고 여기서 NULL 문자까지 고려하여 1을 더한 듯합니다. 만약 10을 입력했다면 11바이트 크기의 메모리 공간을 힙 영역에 할당하고 그 주소값을 반환하였습니다. 11행은 malloc 호출 시 실패하면 NULL를 반환하는데 실패 시 프로그램을 종료하는 부분입니다. 14~15행을 보시면 26 내의 난수를 생성하여 문자 'a'를 더하고 있는데 랜덤 문자를 buffer[n]에 저장시키는 부분입니다. 그 후 맨 마지막에 \0(NULL)을 저장하고 그 문자열을 출력시키고 free 함수로 메모리 공간을 해제하고 있습니다

void *calloc(size_t num, size_t size) // 성공 시 할당된 메모리의 주소 값, 실패시 NULL 반환

이 calloc란 함수는 뭘까요? malloc 함수와는 달리 두개의 인자를 받으며 첫 번째 인자는 할당할 요소의 개수를 의미하여 두 번째 인자는 요소의 크기를 의미합니다. 아래의 두 개는 같은 동일한 기능을 합니다.
-> StudentNum이 5라고 가정합니다.

// 20바이트를 메모리 공간에 할당
StudentScore = (int *)malloc(sizeof(int) * StudentNum);

// 4바이트씩 5개를 메모리 공간에 할당
StudentScore = (int *)calloc(StudentNum, sizeof(int));

또하나 다른 점은 malloc로 메모리 공간을 할당하면 할당된 메모리에 NULL(쓰레기값)이 들어있으나 calloc로 메모리 공간을 할당하면 전부 0으로 초기화합니다. 이러한 특징 때문에 calloc 함수가 자주 사용되기도 합니다.

const 포인터

const 키워드를 사용하여 포인터 상수화를 할수도 있으며 변수를 상수화 할 수도 있습니다. 상수는 변수와 달리 변하지 않는 값이며, 아래는 일반적인 const의 사용 예를 보여주고 있습니다.

const int num = 50; // 변수의 상수화
const int *num; // 상수 지시 포인터
int *const num; // 포인터 상수
const int *const num; // 포인터와 가리키는 값 모두 상수화

첫 번째 같은 경우는 변수의 상수화가 이루어지고 있으며 한번 초기화한 num의 값은 더 이상 변경할 수가 없습니다. 두 번째 같은 경우는 상수지시 포인터로 num이 가리키는 값을 변경할 수 없습니다. 세 번째 같은 경우는 포인터 상수로 포인터의 주소값을 바꾸는 행위를 허락하지 않습니다. 다만 포인터가 가리키는 값을 바꾸는 건 허용합니다. 네 번째 같은 경우는 둘 다 상수화가 이루어진 것으로 한번 초기화가 되면 가리키는 값도 바꾸지 못하고 주소값도 바꾸지 못하게 됩니다. 아래 코드를 컴파일해 볼까요?

#include <stdio.h>

int main(void) {
    char ch = 'c';
    char c = 'a';

    char *const ptr = &ch;
    ptr = &c; // 에러 발생!

    return 0;
}

컴파일을 시도했으나 아래와 같은 에러가 발생했습니다. 무슨 문제일까요?

'Cannot assign to variable 'ptr' with const-qualified type 'char *const'(const로 한정된 char *const 타입인 변수 ptr에 할당할 수 없습니다)'라는 에러가 발생하는 것을 볼 수 있습니다. 이는 위에서 말한 그대로 포인터 변수 ptr의 상수화가 이루어져있으며, 즉 포인터 변수 ptr에 저장된 주소값을 변경하지 못합니다. 아래 코드도 한번 컴파일해볼까요?

#include <stdio.h>

int main() {
    int num[2] = {10,20};
    const int *ptr = &num[0];
    *ptr = 50; // 에러 발생
    printf("%d", *ptr);
    return 0;
}

이것도 역시 컴파일을 시도했으나 위와 같은 에러가 발생했습니다.

말 그대로 읽기 전용 변수에 할당할 수 없다는 내용입니다. 즉, 5행에서 선언한 const 선언은 포인터를 이용한 값의 변경을 허용하지 않겠다는 소리입니다. 그렇지만 num[0]을 통한 값의 변경은 가능합니다. 아래의 경우는 또 어떨까요?

#include <stdio.h>

int main() {
    int num1 = 100;
    int num2 = 200;
    const int *const ptr = &num1;

    // *ptr = 150; // 에러 발생!
    // ptr = &num2; // 에러 발생!

    printf("%d", *ptr);

    return 0;
}

이것마저 에러가 발생했습니다. 무슨 이유에서 일까요?

포인터 ptr와 ptr가 가리키는 값까지 상수화가 되어버렸습니다. 이렇게 선언된 포인터 변수 ptr는 주소값을 더 이상 변경하지 못하며 가리키는 값까지 바꿀 수 없게 되어버렸습니다. 어느 정도 이해가 가셨나요?