CS50 - 5. 메모리

1. 메모리 주소

16진수

컴퓨터과학에서는 숫자를 10진수나 2진수 대신 16진수(Hexadecimal)로 표현하는 경우가 많습니다. 컴퓨터에서 데이터를 처리하기 위해 16진수를 사용할 때 장점이 있기 때문입니다. 16진수와 일상생활에서 우리가 사용하는 10진수와 비교하면 그 차이를 알 수 있습니다.

16진수를 사용하면 10진수보다 2진수를 간단하게 나타낼 수 있습니다.

10진수를 16진수로 바꾸기

255 -> 1111 1111 -> 0xff
216 -> 1101 1000 -> 0xd8

16진수: 0 1 2 3 4 5 6 7 8 9 A B C D E F

16진수를 표기할 때는 0x 를 같이 표기하도록 합니다.

메모리 주소

#include <stdio.h>

int main(void)
{
    int n = 50;
		// &은 메모리상 주소를 가져옵니다. p는 포인터를 의미합니다.
		// *은 그 주소로 갑니다.
    printf("%p\n", &n);
		printf("%i\n", *&n);
}

// 0x7ffe00b3adbc
// 50

2. 포인터

include <stdio.h>

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

int *p 에서 p앞의 *는 이 변수가 포인터라는 의미이고

int 는 이 포인터가 int 타입의 변수를 가리킨다는 의미입니다.

포인터는 최신형 컴퓨터에 맞춰 32bit로 2배로 저장됩니다.

image

위 그림처럼 p가 n을 가리키고 있다고 생각할 수 있습니다.

3. 문자열

string s = "EMMA";

문자열은 다음과 같이 배열에 저장됩니다.

image

여기서 변수 s는 문자열을 가리키는 포인터라고 볼 수 있습니다.

image

→ 변수 s는 문자열의 가장 첫번째 문자의 주소 0x123으로 s[0]을 가리키게 됩니다.

지금까지 작동한 CS50을 이용한 string 타입은 다음처럼 변경이 됩니다.

// cs50.h
typedef char *string

// emma.c
#include <cs50.h>
#include <stdio.h>

int main(void)
{
    string s = "EMMA";
    printf("%s\n", s);
}

// CS50 헤더파일 삭제
#include <stdio.h>

int main(void)
{
    char *s = "EMMA";
    printf("%s\n", s);
}

4. 문자열 비교

  • 문자열에서 문자(Char)만 출력하기
#include <stdio.h>

int main(void)
{
    char *s = "EMMA";
    printf("%p\n", *s);
		printf("%c\n", *s);
		printf("%c\n", *(s+1));
		printf("%c\n", *(s+2));
		printf("%c\n", *(s+3));
}

// E의 주소가 출력
// E
// M
// M
// A

문자열 비교

#include <cs50.h>
#include <stdio.h>

int main(void)
{
    // 사용자로부터 s와 t 두 개의 문자열 입력받아 저장
    string s = get_string("s: ");
    string t = get_string("t: ");

    // 두 문자열을 비교 (각 문자들을 비교)
    if (s == t)
    {
        printf("Same\n");
    }
    else
    {
        printf("Different\n");
    }
}

// s: EMMA t: EMMA
// DIfferent(다르다고 출력)

문자열을 비교할 때도 아래 코드와 같이 문자열이 저장된 변수를 바로 비교하게 되면 그 변수가 저장되어 있는 주소가 다르기 때문에 다르다는 결과가 나올 것입니다.

정확한 비교를 위해서는 실제 문자열이 저장되어 있는 곳으로 이동하여, 각 문자를 하나하나씩 비교해야 합니다.

5. 문자열 복사

#include <cs50.h>
#include <ctype.h>
#include <stdio.h>

int main(void)
{
    string s = get_string("s: ");
    string t = s;

    t[0] = toupper(t[0]);

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

// 입력: emma
// Emma
// Emma

모두 Emma라고 출력이 됩니다.

그 이유는  s라는 변수에는 “emma”라는 문자열이 아닌 그 문자열이 있는 메모리의 주소가 저장되기 때문입니다.

string s 는 char *s 와 동일한 의미라는걸 떠올려보면 됩니다.

따라서 t도 s와 동일한 주소를 가리키고 있고, t를 통한 수정은 s에도 그대로 반영이 되게 되는 것입니다.

  • 메모리 할당 함수
#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    char *s = get_string("s: ");
    char *t = malloc(strlen(s) + 1); // +1은 널 종단 문자때문에 추가

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

	 // strcpy(t, s): 문자열 복사 함수

    t[0] = toupper(t[0]);

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

// 입력: emma
// emma
// Emma

6. 메모리 할당과 해제

malloc 함수를 이용하여 메모리를 할당한 후에는 free라는 함수를 이용하여 메모리를 해제해줘야 합니다.

그렇지 않은 경우 메모리에 저장한 값은 쓰레기 값으로 남게 되어 메모리 용량의 낭비가 발생하게 되기 때문입니다.

이러한 현상을 ‘메모리 누수’라고 일컫습니다.

  • valgrind 라는 프로그램을 사용하면 우리가 작성한 코드에서 메모리와 관련된 문제가 있는지를 쉽게 확인할 수 있습니다.
valgrind ./{filename}
  • free 사용하여 해제
#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    char *s = get_string("s: ");
    char *t = malloc(strlen(s) + 1); // +1은 널 종단 문자때문에 추가

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

	 // strcpy(t, s): 문자열 복사 함수

    t[0] = toupper(t[0]);

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

		free(t);
}

메모리 할당 관련 예제

#include <stdlib.h>

void f(void)
{
    int *x = malloc(10 * sizeof(int)); // sizeof 는 데이터 타입의 크기를 알려줍니다.
    x[10] = 0;  // 버퍼 오버플로우 발생. 인덱스는 0~9로 사이즈를 할당 받았기 때문.
		// free(x); 메모리 해제할 필요가 있습니다.
}

int main(void)
{
    f();
    return 0;
}

7. 메모리 교환, 스택, 힙

#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의 값이 바뀌지 않은채 그대로 출력됨을 알 수 있습니다.

사실 swap 함수는 교환 작업을 제대로 수행하고 있는데요, 문제는 교환하는 대상이 x, y 그 자체가 아닌 함수 내에서 새롭게 정의된 a, b라는 것이었습니다.

a와 b는 각각 x와 y의 값을 복제하여 가지게 됩니다. 서로 다른 메모리 주소에 저장됩니다.

image

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

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

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

 영역에는 malloc으로 할당된 메모리의 데이터가 저장됩니다. 그리고 스택에는 프로그램 내의 함수와 관련된 것들이 저장됩니다.

→ a와 b를 바꾸는 것은 x와 y를 바꾸는 것에 아무런 영향도 미치지 않습니다.

image

a와 b를 각각 x와 y를 가리키는 포인터로 지정하면 원하는 결과값이 나올 수 있습니다. 코드는 다음과 같습니다.

#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;
}

8. 파일 쓰기

힙 영역에서는 malloc 에 의해 메모리가 더 할당될수록, 점점 사용하는 메모리의 범위가 아래로 늘어납니다.

마찬가지로 스택 영역에서도 함수가 더 많이 호출 될수록 사용하는 메모리의 범위가 점점 위로 늘어납니다.

이렇게 점점 늘어나다 보면 제한된 메모리 용량 하에서는 기존의 값을 침범하는 상황도 발생할 것입니다.

이를 힙 오버플로우 또는 스택 오버플로우라고 일컫습니다.

사용자에게 입력 받기

  • CS50 제외하고 입력 직접 구현하기
// get_int()
#include <stdio.h>

int main(void)
{
    int x;
    printf("x: ");
    scanf("%i", &x);
    printf("x: %i\n", x);
}

// get_string()
#include <stdio.h>

int main(void)
{
    char s[5];
    printf("s: ");
    scanf("%s", s);
    printf("s: %s\n", s);
}

scanf라는 함수는 사용자로부터 형식 지정자에 해당되는 값을 입력받아 저장하는 함수입니다.

get_int 코드에서 int x를 정의한 후에 scanf에 x가 아닌 &x로 그 주소를 입력해주는 부분을 주의해야 합니다.

→ swap() 똑같은 이유로 &를 추가하는 것입니다.

scanf 함수의 변수가 실제로 스택 영역 안에 x가 저장된 주소로 찾아가서 사용자가 입력한 값을 저장하도록 하기 위함입니다.

반면 get_string 코드에서는 scanf에 그대로 s를 입력해줬습니다.

그 이유는 s를 크기가 5인 문자열, 즉 크기가 5인 char 자료형의 배열로 정의하였기 때문입니다.

clang 컴파일러는 문자 배열의 이름을 포인터처럼 다룹니다.

즉 scanf에 s라는 배열의 첫 바이트 주소를 넘겨주는 것이죠.

파일 쓰기

#include <cs50.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    FILE *file = fopen("phonebook.csv", "a");
    char *name = get_string("Name: ");
    char *number = get_string("Number: ");
    fprintf(file, "%s,%s\n", name, number);
    fclose(file);
}

fopen이라는 함수를 이용하면 파일을 FILE이라는 자료형으로 불러올 수 있습니다.

fopen 함수의 첫번째 인자는 파일의 이름, 두번째 인자는 모드로 r은 읽기, w는 쓰기, a는 덧붙이기를 의미합니다.

사용자에게 name과 number라는 문자열을 입력 받고, 이를 fprintf 함수를 이용하여 printf에서처럼 파일에 직접 내용을 출력할 수 있습니다.

작업이 끝난 후에는 fclose함수로 파일에 대한 작업을 종료해줘야 합니다.

9. 파일 읽기

다음은 JPEG 이미지 검사 프로그램 코드입니다.

#include <stdio.h>

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        return 1;
    }

    FILE *file = fopen(argv[1], "r");

    if (file == NULL)
    {
        return 1;
    }

		// 3바이트의 파일 읽기
    unsigned char bytes[3]; // -128부터가 아닌 0 ~ 255로 설정하기 위한 unsigned
    fread(bytes, 3, 1, file);

		// 0xff 0xd8 0xff 를 확인하여 JPEG인지 검사
    if (bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff)
    {
        printf("Maybe\n");
    }
    else
    {
        printf("No\n");
    }
    fclose(file);
}

만약 argc가 2가 아니라면, 파일명이 입력되지 않았거나 파일명 외의 다른 인자가 입력되었기 때문에 1(오류)을 리턴하고 프로그램을 종료합니다.

만약 argc가 2라면 프로그램이 그대로 진행됩니다.

입력받은 파일명(argv[1])을 ‘읽기(r)’ 모드로 불러옵니다.

만약 파일이 제대로 열리지 않으면 fopen 함수는 NULL을 리턴하기 때문에 이를 검사해서 file을 제대로 쓸 수 있는지를 검사하고, 아니라면 역시 1(오류)를 리턴하고 프로그램을 종료합니다.

만약 파일이 잘 열렸다면, 프로그램이 계속 진행됩니다.

그 후 크기가 3인 문자 배열을 만들고, fread 함수를 이용해서 파일에서 첫 3바이트를 읽어옵니다.

fread 함수의 각 인자는 (배열, 읽을 바이트 수, 읽을 횟수, 읽을 파일)을 의미합니다.

그리고 마지막으로 읽어들인 각 바이트가 각각 0xFF, 0xD8, 0xFF 인지를 확인합니다.

이는 JPEG 형식의 파일을 정의할 때 만든 약속으로, JPEG 파일의 시작점에 꼭 포함되어 있어야 합니다.

따라서 이를 검사하면 JPEG 파일인지를 확인할 수 있습니다.

출처


Written by@Sunny Son
개발자는 오늘도 뚠뚠

GitHubFacebook