[CS50] 5.메모리, Memory

5 분 소요

16진수

컴퓨터과학에서는 10진수와 2진수 외에도 16진수(Hexadeciaml)를 사용하는 경우가 많다. 그 이유는 컴퓨터의 기본 언어인 2진법을 16진수로 보다 간단하게 나타낼 수 있기 때문이다.

이미 알고있듯이 1byte 안에는 8bit가 있고 각 비트에는 2진수 값 0 또는 1이 들어가서 1byte로는 최대 ‘11111111(2)’, 즉 10진수로 ‘255’까지 표현이 가능하다. 그런데 0부터 15까지 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F] 로 표현하는 16진수를 사용하면 ‘11111111(2)’을 (16¹x15)+(1x15)인 ‘FF(16)’로 표현이 가능하다. (컴퓨터에서는 수학적 표기법 FF(16) 대신 16진수 앞에 ‘0x’를 붙여 0xff 로 표기한다)

한마디로, 2개의 16진수는 1byte의 2진수로 변환되기 때문에 정보를 표현하기 유용하다.

1

메모리 주소

변수 n에 50이라는 숫자를 저장하고 출력할 경우 n은 int 타입이기 때문에 컴퓨터 메모리 어딘가에 4byte 만큼의 자리를 차지하여 저장이 된다.

#include <stdio.h>

int main(void)
{
    int n = 50;
    printf("%i\\n", n);
}

위에서 printf() 를 사용하여 50을 출력하려고 할 경우 컴퓨터는 메모리에서 변수 n의 값이 저장되어 있는 위치를 확인하여 50이라는 값을 가져오게 되는데, 이 위치는 메모리 주소를 통해 알 수 있다.

C에서는 ‘&‘연산자를 통해 메모리 주소를 받아올 수 있는데, 이 때 자료형은 포인터, %p 를 사용한다.

또, ‘*****’ 연산자를 사용해서 메모리 주소에 있는 실제 값을 가져올 수도 있다.

#include <stdio.h>

int main(void)
{
    int n = 50;
    printf("%p\\n", &n);
}
#include <stdio.h>

int main(void)
{
    int n = 50;
    printf("%i\\n", *&n);
}

포인터, Pointer

위에서 말했듯 포인터는 메모리 주소를 담는 자료형이다. 그리고 포인터 자료형을 사용해면 메모리 주소가 담긴 변수를 선언할 수도 있다.

#include <stdio.h>

int main(void)
{
   int n = 50;
   int *p = &n;
   printf("%p\\n", p);
   printf("%i\\n", *p);
}

위에서는 p라는 포인터 변수에 변수 n의 메모리 주소를 저장했다. 이 때 특이한 점은, p가 포인터 변수라는 것을 표시하기 위해서 * 를 붙여주고 자료형은 n에 담긴 값 50의 자료형인 int를 써준다는 것이다. 즉, 변수 *p는 int 타입의 변수를 가리키는 포인터다.

이 것을 활용해서 첫 번째 printf문 처럼 포인터 p의 값인 변수 n의 주소를 출력하거나, 두 번째 printf문 처럼 포인터 p가 가리키는 변수의 값, 50을 출력할 수도 있다. (*p == *&n)

중요한 것은, 포인터 안에 저장된 주소가 무엇인지 파악하는 것보다는 추상적으로 포인터가 무엇을 가리키고 있는가를 알고 있으면 된다는 것이다.

문자열의 주소

문자열(string)이란 문자(char)의 배열로, 메모리에 저장될 때는 연속된 문자들+문자열의 끝을 알리는 종단문자 ‘\0’ 의 형태로 저장된다.

만약 변수에 문자열을 담을 경우, 변수는 문자열 전체를 담는 것이 아니라 문자열의 시작을 가리키는 포인터가 된다. 즉, s라는 변수에 ‘text’ 라는 문자열을 담는 코드를 작성했다면 실제 컴퓨터에서는 문자 ‘t’ ‘e’ ‘x’ ‘t’ ‘\0’ 를 메모리 어딘가에 연속적으로 저장을 하고, s라는 포인터를 따로 저장하여 그 안에 ‘t’의 메모리 주소를 담는 것이다.

이후에 변수 s가 호출될 경우 s는 문자열의 시작점인 ‘t’를 가리키고, 컴퓨터는 ‘t’ 부터 순서대로 문자들을 읽어나가다가 종단 문자 ‘\0’을 보고 문자열의 끝임을 확인한 뒤 ‘text’라는 문자열을 출력하게 된다.

C에서는 이러한 문자열을 저장하고 출력할 때 다음과 같이 표현한다.

#include <stdio.h>

int main(void)
{
    char *s = "EMMA";
    printf("%s\\n", s);
		printf("%c\\n", *s);
		printf("%p\\n", s);
    printf("%p\\n", &s[0]);
}

변수 s는 문자 자료형의 시작점을 가리키는 포인터이므로 ‘char *s’ 라고 정의를 하게 된다.

첫번째 printf문은 문자열 자료형(%s)을 출력하도록 했기 때문에 앞서 설명한 프로세스에 따라 ‘E’부터 종단문자 ‘\0’까지 확인한 뒤 EMMA를 출력하게 되고, 두번째 printf문은 문자형(%c)인 메모리 주소 s의 값을 출력하도록 했으므로 E를 출력해준다.

그리고 세번째와 네번째 printf 문은 결과적으로 같은 메모리 주소가 출력되는 것을 확인할 수 있다.

문자열의 비교 & 복사

s라는 변수에 ‘text’라는 문자열을 넣고, t라는 변수에 ‘text’라는 문자열을 넣은 뒤 s와 t가 같은지 확인하면 같지 않다는 결과를 받게 된다. 왜? 우리가 보기에는 둘 다 ‘text’로 같아 보이지만 실제로는 서로 다른 메모리 위치에 별도로 저장이 되어있기 때문이다.

원래 의도대로 둘 다 ‘text’라는 문자열인지를 비교하기 위해서는 각 문자 하나하나씩 비교를 해야한다.

반대로, s라는 변수에 ‘text’라는 문자열을 넣고 t = s 라고 해주면 t라는 변수 s의 값인 ‘text’ 복사해주게 된다. 이 때, t의 값은 실제로 ‘text’가 아니라 문자열 포인터인 s에 저장된 ‘text’의 주소다.

이는 파이썬을 공부하며 배운 얕은복사의 개념과 같으며, t를 호출하여 값에 변화를 주면 같은 주소를 참조하고 있는 s도 영향을 받게된다. 서로 영향을 주지 않으려면 deepcopy를 해줘야 하는데 C 에서는 메모리 할당 함수인 malloc()을 사용해서 아래와 같이 복사하거나, 파이썬의 deepcopy() 함수처럼 C에 이미 구현되어 있는 함수인 strcpy() 를 사용하면 된다.

#include <stdio.h>

int main(void)
{
    char *s = "EMMA";
    char *t = malloc(strlen(s) + 1);

    for (int i = 0, n = strlen(s); i < n + 1; i++)
    {
        t[i] = s[i];
    }

    printf("%s\\n", s);
    printf("%s\\n", t);
}

메모리 할당과 해제

위에서 malloc() 을 통해 C에서 메모리를 할당하는 방법을 보았다. 그런데 이렇게 메모리를 할당하고, 사용을 끝마친 후에는 반드시 free() 함수를 통해 메모리를 해제해줘야 한다. 그렇지 않으면 메모리에 저장한 값은 사용하지 않는 쓰레기 값이 되어 메모리 용량을 낭비하게 된다. 이러한 현상을 메모리 누수라고 한다.

또한 할당되지 않은 메모리에 접근하는 경우에도 문제가 발생하는데, 예를 들어 배열 x의 길이가 10일 때 x[10]에 값을 넣으려 할 경우 11번째 인덱스에 접근하여 배열 x에 할당된 메모리를 초과하게 된다. 이런 경우를 버퍼 오버플로우라고 한다.

메모리 교환, 힙, 스택

메모리 안에는 데이터가 저장되는 구역이 나뉘어져 있다.

머신코드 영역에는 프로그램이 실행될 때 그 프로그램이 컴파일된 바이너리코드가 저장된다.

글로벌 영역에는 프로그램 안에서 저장된 전역 변수가 저장된다.

힙 영역에는 malloc으로 할당된 메모리의 데이터가 저장되며, 할당된 메모리가 커질수록 힙 영역 또한 확장된다.

스택에는 지역변수처럼 프로그램 내의 함수와 관련된 것들이 저장되며, 스택이 쌓일수록 영역 또한 확장된다.

1

문제는, 위와 같은 구조에서 heap 또는 stack에 계속해서 메모리가 할당되다보면 서로 더 이상 영역을 확장할 수 없게되는 상황이 생기게 된다. 스택이 계속 쌓여서 더 이상 쓸 수 있는 메모리가 없게 될 경우를 스택 오버플로우라고 하고, 힙에 계속 메모리가 할당되어 더 이상 쓸 수 있는 메모리가 없게 될 경우를 힙 오버플로우라고 한다.

메모리 교환

main이라는 함수가 호출되면 스택 제일 아래에 main 함수를 위한 스택 프레임이라는 공간이 주어지고 그 안에 main 함수를 위한 변수들이 저장된다. 그리고 main에서 또다른 함수를 호출할 경우 스택 영역의 main 스택 프레임 위에 해당 함수를 위한 스택 프레임을 쌓아올리게 된다.

#include <stdio.h>

void swap(int *a, int *b);

int main(void)
{
    int x = 1;
    int y = 2;

    printf("x is %i, y is %i\\n", x, y);
    swap(&x, &y);
    printf("x is %i, y is %i\\n", x, y);
}

void swap(int *a, int *b)
{
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

위와 같이 swap이라는 함수를 정의하여 정수 x와 y의 값을 교환하는 프로그램을 작성했을 경우 아래와 같이 동작하게 된다.

먼저 main 스택 프레임에 변수 x, y가 저장되고, main이 swap함수를 호출하면 그 위에 swap 스택 프레임이 쌓이며 변수 a, b, tmp가 저장된다. 그 다음 a가 x가 가리키는 주소의 값인 1을 가져와 tmp에 저장하고, b가 y가 가리키는 주소의 값인 2를 가져와 x가 가리키는 주소의 값(a)으로 저장한 뒤, tmp에 저장 되어있던 1을 y가 가리키는 주소의 값(b)으로 저장한다. 마지막으로 swap 함수 실행이 모두 끝나면 swap 스택 프레임은 스택에서 제거된다.

1

댓글남기기