POCU C언어 정주행 6회차 - 댕글링 포인터, 포인터 연산, 캐스팅, ASLR

2022. 12. 10. 22:37C언어 복습

0. 포인터

의외로 포인터에서도 내가 몰랐던 부분들이 다수 언급되었다. 낯설게 느껴졌던 단어도 좀 있었고 보안쪽에서 나올법한 단어도 보였다. 뿐만 아니라 포인터 연산을 하는데 있어서 딱봐도 "어? 이렇게 짜면 안될것 같은데?"라고 느껴져서 짜보지 않았던 방식의 코드들도 볼 수 있었다. 어쨋든 이런 기초 부분에 있어서도 내가 부족한 것이 많았구나라는 것을 다시 한번 느끼게 되었다. 그럼 이제 정주행한 내용 중 몰랐던 부분, 중요한 부분을 정리해보자.

 

1. 댕글링 포인터 (Dangling Pointer)

댕글링 포인터는 포인터가 기존에 가리키고 있던 것에 연결 관계가 끊어졌을 때, 그 포인터를 댕글링 포인터라고 한다. dangling이라는 단어가 빳빳한 줄이 끊어졌을 때처럼 달랑거린다는 뜻을 가지고 있는데 포인터도 마찬가지로 빳빳하게 연결관계를 가지고 있던 것에 연결이 끊겨서 달랑거리는 상태가 되었다는 것을 뜻하는 것이다. 결국 유효하지 않은 메모리 주소를 가지고 있는 것이기 때문에 잘못된 접근을 하지 못하게 막아야 한다.

 

이것을 위해 유효하지 않은 메모리를 가리키는 포인터를 상대로 NULL을 대입해서 널 포인터를 만들어야 한다. char에 집어넣는 NULL과 다른 개념이니 주의하자. 이건 NULL 문자라고 하고 우리가 다루는 것은 NULL 포인터이다. 어쨋든 계속 얘기하면 이런 식으로 포인터를 사용할 때 매개 변수로 받거나 반환하는 경우에는 널이 들어오거나 반환되는 것이 가능할 때, 이것이 가능하다고 명시하고 상황에 맞춰서 예외 처리를 시켜야 한다.

 

여담으로 예외 처리라는 말이 나와서 하는 말인데 예외 처리를 위한 함수인 assert라는 것이 있다. C언어에서 쓴다면 <assert.h>라는 파일을 포함시켜야 하고 디버그 모드에서만 컴파일된다는 것을 기억해야 한다. 사용법은 assert에 매개 변수로 0을 넣으면 에러가 발생시키고 다른 값이 들어가면 에러를 발생시키지 않는다. 에러를 발생시킨다면 버그 발생 위치, call stack등을 알려준다. 그래서 아까같은 상황으로 다시 돌아간다면 NULL이 매개 변수로 들어왔을 때를 검사하고 싶다면 assert(ptr != NULL); 과 같은 코드를 미리 넣어주는 방식으로 사용할 수 있는 것이다.

 

2. 포인터의 각종 연산

우리가 보통 연산이라고 하면 가장 먼저 떠올리는 것이 서로 사칙 연산을 하는 것이다. 근데 포인터는 주소끼리 이루어지는 사칙 연산 중에 뺄셈밖에 할 수 없다. 오해하면 안되는 것이 포인터에 들어있는 주소에서 몇 칸을 뛰는 개념으로 정수를 더하고 빼는 것을 생각할 수 있는데 그런 것을 이야기하는 것이 아니다. 주소에서 주소를 빼는 것만 가능하다는 이야기이며 주소와 주소끼리의 덧셈, 곱셈, 나눗셈은 지원하지 않는다. 또한 주소에서 주소를 뺄 때, 단순히 빼기만 하는 것이 아니고 포인터가 가리키는 자료형의 메모리 크기만큼 나눠준다. 우분투 리눅스 gcc 컴파일러로 작성한 아래의 예시를 보자.

#include <stdio.h>

int main(void)
{
    int arr[10] = { 0 };
    
    /* 컴파일러에게 문법 검사를 빡세게 해달라고 했더니 %d에서 
    오류를 띄웠다. 연산 결과가 long형 정수로 나와서 %ld로 바꿨다. 
    당연히 int로 나올 줄 알았는데 의외였다. 뭔가가 있는걸까? */
    printf("%ld\n", &arr[9] - &arr[0]);
    
    return 0;
}

이러면 결과가 9가 나온다. 솔직히 int하나당 4바이트고 그것이 9개만큼 차이가 나서 4 x 9로 36이 나올줄 알았는데 그게 아니었다. 포인터가 가리키는 자료형의 크기만큼 자동으로 나눠주기 때문에 이런 경우는 9가 나온다고 한다. 36이라는 결과가 나오려면 다음과 같이 포인터를 대상으로 캐스팅을 시키면 36이라는 결과가 나온다.

#include <stdio.h>

int main(void)
{
    int arr[10] = { 0 };
    printf("%ld\n", (char*)&arr[9] - (char*)&arr[0]);
    return 0;
}

반대로도 가능하며 -36이라는 값이 정상적으로 출력된다. 여기서 궁금한 것이 생겨서 실험을 해봤는데 우분투 리눅스의 gcc컴파일러는 주소끼리의 뺄셈 연산이 long형 데이터를 반환하는 반면 Visual Studio에서는 int형 데이터를 반환하는 것을 확인했다. 즉, 주소끼리의 뺄셈 연산 결과의 자료형은 컴파일러마다 다르다는 것을 알 수 있다. 여담으로 컴파일러에서 주소끼리의 연산 중 뺄셈밖에 허용하지 않는 이유는 다른 연산을 하는 것은 아무 의미가 없는 값이 나올 수 밖에 없기 때문이며 혹시라도 이런 코드가 있다면 분명 프로그래머의 실수가 분명하니 그냥 컴파일러에서 잡아주는 것이라고 한다.

 

3. 포인터 캐스팅 사용 예시

우리가 포인터를 이용해서 연산을 할 때, (어떤 주소) + (어떤 정수)로 연산을 시키면 (어떤 정수)에 주소에 저장된 데이터의 자료형의 크기를 곱한만큼 점프하게 된다는 것을 배웠을 것이다. 그런데 조금 다른 방식으로 점프를 시키고 싶은 경우가 있을 수 있다. 아까 전에 포인터를 캐스팅하는 장면이 있었는데 포인터를 점프시킬 때, 강제로 캐스팅을 시키면 불가능하던 것도 가능하게 만들어버릴 수 있는 것이다. 아래의 예제 코드는 1바이트씩 점프하는 코드이다.

#include <stdio.h>

int main(void)
{
    const int NUM = 0x12345678;
    const char* NUM_ADDRESS = (char*)&NUM;
    size_t i = 0;
    
    for (i = 0; i < sizeof(NUM); i++)
    {
        printf("%hhx ", *(NUM_ADDRESS + i);
    }
    
    printf("\n");
    printf("NUM in hex form: 0x%x", NUM);
    return 0;
}

이러면 결과가 int형 정수 안에 있는 값을 1바이트씩 읽어오게 되는데 반복문을 돌릴 때, 리틀 엔디안인지 빅 엔디안인지에 따라서도 결과값이 다르게 나온다. 빅 엔디안에서는 12 34 56 78과 같은 결과가 나오는데 리틀 엔디안이면 78 56 34 12와 같은 결과가 나오게 된다. 이렇게 포인터를 잘만 사용한다면 아주 멋진 방법으로 불가능해 보이는 것들도 가능하게 만들 수 있지만 반대로 치명적인 오류를 발생시킬 수 있다는 점을 명심해야 할 것이다.

 

4. ASLR (Address Space Layout Randomization)

ASLR은 메모리를 보호하는 보안 기법 중 하나이며 요즘은 우리가 사용하는 어지간한 컴퓨터라면 대부분 가지고 있는 기능이다. 간단하게 설명하자면 다들 공부할 때 한 번쯤은 프로그램을 실행시켜서 변수의 주소를 찍어본 적이 있지 않은가? 그리고 그 프로그램을 그대로 다시 실행시키면 그 주소값이 다르게 찍히지 않는가? 근데 옛날에는 프로그램을 껐다가 다시 켰을 때, 할당되는 메모리의 위치가 달라지지 않았다고 한다. 그리고 이것 때문에 보안에 문제가 생겼고 이것 때문에 나온 것이 바로 ASLR이라는 것이다. ASLR은 스택, 힙, 라이브러리의 위치 등을 메모리에 랜덤으로 배치시켜서 공격에 필요한 Target Address를 예측하기 어렵게 만드는 기법이다. 아까 얘기했던 것처럼 우리가 공부할 때 프로그램을 실행시켰을 때마다 메모리의 주소가 다르게 찍혔던 이유가 바로 이것 때문인 것이다.