[CS50] 배열과 메모리

Gyuwon Lee·2022년 6월 12일
0

42 Seoul 7기

목록 보기
2/6
post-thumbnail

42서울 7기 라피신을 대비하여 정리한 CS50 2019 과정을 간략히 복기하여 재업로드한 내용입니다.

0. 디버깅

1) 👾 버그와 디버깅

  • 버그(bug) : 코드에 들어있는 오류
  • 디버깅(debugging) : 코드에 있는 버그를 식별하고 고치는 과정
  • 디버거 : 디버깅에 사용되는 프로그램

디버깅의 기본

  • 디버거는 프로그램을 특정 행에서 멈출 수 있게 해주기 때문에 버그를 찾는데 도움이 된다.
    • 프로그래머는 멈춰진 그 지점에서 무슨 일이 일어나는지 볼 수 있다.
    • 프로그램이 멈추는 특정 지점을 중지점이라고 한다.
    • 프로그래머가 프로그램을 한번에 한 행씩 실행할 수 있게 해 준다.
      • 즉, 프로그래머가 프로그램이 내리는 모든 결정들을 단계별로 따라갈 수 있게 된다.

1. 배열

1) 배열의 정의와 사용

  • 배열 : 같은 자료형의 데이터를 메모리상에 연이어서 저장하고 이를 하나의 변수로 관리하기 위해 사용됨

정적 배열 선언

#include <stdio.h>

const int N = 3;

int main(void)
{
    // 점수 배열 선언 및 값 저장
    int scores[N];
    scores[0] = 72;
    scores[1] = 73;
    scores[2] = 33;

    // 평균 점수 출력
    printf("Average: %i\n", (scores[0] + scores[1] + scores[2]) / N);
}
  • 크기가 전역 상수 N 으로 고정된 정수 배열 scores 를 선언 및 초기화했다.
  • 배열 각 요소에 저장될 점수 역시 코드로 직접 명시해야 한다.

배열의 동적 선언

#include <stdio.h>

float average(int length, int array[]);

int main(void)
{
    // 사용자로부터 점수의 갯수 입력
    int n = get_int("Scores:  ");

    // 점수 배열 선언 및 사용자로부터 값 입력
    int scores[n];
    for (int i = 0; i < n; i++)
    {
        scores[i] = get_int("Score %i: ", i + 1);
    }

    // 평균 출력
    printf("Average: %.1f\n", average(n, scores));
}
  • 배열의 크기 n 을 사전에 선언하지 않고, 사용자가 입력한 임의의 정수 값에 따라 결정되도록 했다.
  • 배열 각 요소에 저장될 점수 역시 사용자로부터 값을 입력받아 저장할 수 있다.
  • 이처럼 동적으로 작성된 코드는 재사용성과 범용성이 향상된다.

2) 문자열과 배열

  • 문자열이란 사실 문자(char) 자료형의 데이터들의 배열이다.
char *s = “HI!; 
  • 문자열이란 사실 문자(char) 자료형의 데이터들의 배열이다.
  • 위와 같이 문자열 s 가 정의되어 있다고 생각해 보자.
    • 문자의 배열이기 때문에 메모리상에 아래 그림과 같이 저장되고, 인덱스로 각 문자에 접근할 수 있다.

  • 이때 가장 끝의 \0널 종단 문자 또는 NUL 문자라고 한다. (NULL 이 아니다)
    • 이는 모든 비트가 0인 1바이트로, char 자료형의 0 즉 문자 0 (아스키 코드 0x30) 과는 구별된다.

널 종단 문자

  • 문자열의 끝을 알리는 역할이 왜 필요한 것일까?
char *names[4];

names[0] = "EMMA";
names[1] = "RODRIGO";
names[2] = "BRIAN";
names[3] = "DAVID";

printf("%s\n", names[0]);
printf("%c%c%c%c\n", names[0][0], names[0][1], names[0][2], names[0][3]);
  • 위 코드는 메모리상에 아래 그림과 같이 저장되어 있다:

  • 이 그림에서 알 수 있는 점은, 문자열의 배열도 문자열과 동일하게 단지 글자들의 연속된 배열일 뿐, 한 문자열이 어디서 시작하고 끝나는지 표시되어 있지 않다는 점이다.
  • 따라서 문자열의 끝을 구분하기 위해 모든 문자열의 끝에는 반드시 NUL 문자가 들어가야 하며, 문자열을 읽어들일 때 NUL 문자를 만나기 전까지를 하나의 문자열로 간주한다.

2. 💾 메모리

1) 🧐 메모리 주소

16진법으로 표기하기

  • 16진수를 사용하면 10진수보다 2진수를 간단하게 나타낼 수 있다.
    • 16진수로 표현하면 2진수로 표현했을 때보다 훨씬 간단해진다.
    • 2개의 16진수는 1byte의 2진수로 변환되기 때문이다.
    • 반대로, 1byte의 2진수 즉 8자리 2진수 (0 ~ 15) 는 2자리 16진수 (0 ~ f)로 줄여 표현할 수 있다.

*&

#include <stdio.h>

int main(void)
{
    int n = 50;
    printf("%p\n", &n);
    printf("%i\n", *&n);
}
  • C에서는 변수의 메모리상 주소를 받기 위해 & 연산자를 사용한다.
  • 반대로 * 를 사용하면 그 메모리 주소에 있는 실제 값을 얻을 수 있다.
    • 따라서 *& 과 같이 두 연산자가 연속되면 상쇄되어 아무 연산자도 붙이지 않은 것과 같다.

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 **p 처럼 생긴 포인터라도 마찬가지다. p 바로 앞의 * 만 이 변수가 포인터라는 의미를 갖고, 남겨진 int * 는 해당 타입의 변수를 가리킨다는 뜻이다.
    • int 는 이 포인터가 int 타입의 변수를 가리킨다는 의미

  • 실제 컴퓨터 메모리에서 변수 p는 위와 같이 저장될 수 있다.
    • 추상적으로 p가 n을 가리키고 있다는 개념

포인터의 크기

  • 포인터의 크기는 운영체제의 비트 수에 따라 달라진다.
    • 32비트 시스템의 포인터 크기 : 4바이트
    • 64비트 시스템의 포인터 크기 : 8바이트
  • 즉 32비트의 int *, double *, char * 의 크기는 모두 4바이트이다.
  • 64비트의 경우에는 int *, double *, char * 의 크기가 모두 8바이트다.

3) 🔡 포인터와 문자열의 정의

char *s = “EMMA”;
  • 문자열은 결국 문자의 배열이고, s[0], s[1], s[2] 와 같이 하나의 문자가 배열의 한 부분을 나타낸다.
  • 여기서 변수 s 는 결국 이러한 문자열을 가리키는 포인터가 된다.

  • 더 상세히는 문자열의 가장 첫번째 문자, 즉 주소 0x123 에 있는 s[0] 를 가리키게 된다.
  • 이는 문자열이 선언될 때의 자료형을 직관적으로 해석해봐도 알 수 있다.
    • 문자열의 자료형은 char * 으로 선언된다.
    • 즉, 배열의 이름은 char 형 변수 한 개를 가리키는 포인터인 것이다.

포인터와 인덱스

#include <stdio.h>

int main(void)
{
    char *s = "EMMA";
    
    // 배열 각 요소의 주소값을 출력
    printf("%p\n", &s[0]);
    printf("%p\n", &s[1]);
    printf("%p\n", &s[2]);
    printf("%p\n", &s[3]);

    // 배열 각 요소에 저장된 값을 출력
    printf("%c\n", *s);
    printf("%c\n", *(s+1));
    printf("%c\n", *(s+2));
    printf("%c\n", *(s+3));
}
  • 앞에 & 연산자를 사용하면 해당 값이 저장되어 있는 주소를 알 수 있다.
  • 주소 앞에 * 연산자를 사용하면 해당 주소에 저장되어 있는 값을 알 수 있다.
    • 즉, 인덱스는 값을 나타내고, 포인터는 주소를 나타낸다.
    • 이 때 인덱스와 포인터의 관계는 p[n] == *(p + n) 이다.
  • 가장 첫 번째 문자에 해당하는 주소값을 하나씩 증가시키면 바로 옆에 있는 문자의 값을 출력할 수 있다.

4) 🛠 malloc()free()

malloc()

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);
}
  • 사용자에게 입력값을 받아 문자열 s 에 저장하고, 또다른 문자열 t 를 선언해 s 로 초기화했다.
  • 그리고 t 의 첫 번째 문자를 toupper 함수를 이용하여 대문자로 바꿨다.
  • 결론적으로 위 코드는 예상과는 다르게 작동한다.
    • t 뿐만 아니라 s 도 “Emma”라고 출력된다.
  • s 라는 변수에는 emma 라는 문자열이 아닌 그 문자열이 있는 메모리의 주소가 저장되기 때문이다.
    • 엄밀히 말하면 첫 글자 e 의 주소다.
  • 따라서 s 에 저장된 값(= e 의 주소)으로 초기화된 ts 와 동일한 주소를 가리키게 됐고, t 를 통한 수정은 s 에도 그대로 반영된다.
int main(void)
{
    char *s = get_string("s: ");
    char *t = malloc(strlen(s) + 1);

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

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

    printf("s: %s\n", s);
    printf("t: %s\n", t);
}
  • 이러한 문제 해결을 위해서는 malloc 함수를 사용해서 t 를 초기화하면 된다.
    • malloc() : 정해진 크기 만큼 메모리를 할당하는 함수
  • 이렇게 공간을 할당받은 ts 와 아예 다른 메모리 공간을 가리키고 있으므로, 루프를 돌면서 s 문자열에 있는 문자 하나 하나를 t 배열에 복사해주면 값은 동일하지만 각각 별도로 존재하는 두 문자열을 얻을 수 있다.

free()

  • malloc() 함수를 이용하여 메모리를 할당한 후에는 free() 함수를 이용하여 메모리를 해제해줘야 한다.
  • 그렇지 않은 경우 메모리에 저장한 값은 쓰레기 값으로 남게 되어 메모리 용량의 낭비가 발생한다.
    • malloc() 함수는 힙(heap) 부분의 메모리를 사용하는데, 이 힙 영역은 프로그래머가 직접 공간을 할당, 해제하는 공간이므로 명시적으로 해제해주지 않으면 프로그램이 종료되어도 해당 메모리 공간을 다시 사용할 수가 없다.
    • 이러한 현상을 ‘메모리 누수’라고 일컫는다.

5) 🔄 메모리 교환, 스택, 힙

  • 메모리 안에는 데이터에 따라 저장되는 구역이 나뉘어져 있다:
    • 머신 코드 영역에는 프로그램이 실행될 때 그 프로그램이 컴파일된 바이너리가 저장된다.
    • 글로벌 영역에는 프로그램 안에서 저장된 전역 변수가 저장된다.
    • 영역에는 malloc 으로 할당된 메모리의 데이터가 저장된다.
      • 영역에서는 malloc 에 의해 메모리가 더 할당될수록, 점점 사용하는 메모리의 범위가 아래로 늘어난다.
    • 스택 에는 프로그램 내의 함수와 관련된 것들이 저장된다.
      • 스택 영역에서는 함수가 더 많이 호출 될수록 사용하는 메모리의 범위가 점점 위로 늘어난다.
      • 또는 스택 영역이 이렇게 점점 늘어나다 보면, 제한된 메모리 용량 하에서는 기존의 값을 침범하는 상황도 발생할 것이다.
      • 이를 힙 오버플로우 또는 스택 오버플로우라고 일컫는다.
#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;
}
  • 위 코드에서, main 함수의 변수 x, yswap 함수를 거치지만 결과적으로 각 변수의 값은 교환되지 않을 것이다.
    • main 함수의 변수와 swap 함수의 변수는 각각 메모리상의 서로 다른 위치에 저장되어 있기 때문이다.
    • 따라서, swap 함수는 자신의 매개변수 a, b 에 각각 x, y 을 복사해 저장했을 뿐, 그 변수들을 직접적으로 조작하고 있는 것이 아니다.
  • 이 문제를 해결하기 위해서는 ab 를 각각 xy 를 가리키는 포인터로 지정해야 한다.

6) 📝 파일 쓰기

  • 메모리의 스택 을 사용하는 예시로는, 사용자로부터 입력된 문자열이나 정수 값을 받아오는 함수를 들 수 있다.
#include <stdio.h>

int main(void)
{
    char s[5];
    printf("s: ");
    scanf("%s", s);
    printf("s: %s\n", s);
}
  • 위 코드에 사용된 함수 scanf 는 사용자로부터 형식 지정자에 해당되는 값을 입력받아 저장하는 함수다.
    • 읽어온 값을 저장할 변수를 명시하는 두 번째 인자로, 변수명이 아닌 변수의 주소를 전달한다는 점에 유의하자.
    • 스택 영역 안에 scanf 함수의 변수가 실제로 저장된 주소로 찾아가서, 사용자가 입력한 값을 저장하도록 하기 위함이다.
#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 자료형으로 불러올 수 있다.

    • FILE 구조체는 스트림의 상태에 대한 정보를 저장하고, 파일 함수에서 사용한다.
    • FILE 구조체는 stdio.h 헤더 파일에 정의된 typedef 이름이다.
  • fopen 함수의 첫번째 인자는 파일의 이름, 두번째 인자는 모드로 r 은 읽기, w 는 쓰기, a 는 덧붙이기를 의미한다.

  • fprintf()printf() 와 동일하게 stdio.h 헤더에 포함된 함수로, printf() 와는 값을 출력할 위치 가 다르다.

    • printf() 는 화면 출력, 즉 표준 출력 함수다.
    • fprintf() 는 데이터를 형식에 맞추어 스트림(파일)에 쓴다. 따라서 첫 번째 인자로 파일구조체 포인터 (FILE *) 이 온다.

7) 🔎 파일 읽기

#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;
    }
 
    unsigned char bytes[3];
    fread(bytes, 3, 1, file);

    if (bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff)
    {
        printf("Maybe\n");
    }
    else
    {
        printf("No\n");
    }
    fclose(file);
}
  • 크기가 3인 문자 배열을 만들고, fread 함수를 이용해서 파일에서 첫 3바이트를 읽어왔다.
fread(배열, 읽을 바이트 수, 읽을 횟수, 읽을 파일);
  • 이렇게 읽어들인 각 바이트가 각각 0xFF, 0xD8, 0xFF 인지를 확인해 보면, JPEG 파일인지를 확인할 수 있다.
    • 이는 JPEG 형식의 파일을 정의할 때 만든 약속으로, JPEG 파일의 시작점에 꼭 포함되어 있어야 한다.
profile
하루가 모여 역사가 된다

0개의 댓글