[CS] TIL 17. 파일 쓰기, 파일 읽기


  1. get_long, get_float, get_char도 비슷한 방식으로 직접 구현할 수 있을까?
  2. JPEG 외에 다른 파일 형식도 그 형식임을 알려주는 약속이 있을까?


boostcourse모두를 위한 컴퓨터 과학 (CS50 2019) 강의를 듣고 정리한 필기입니다. 😀


1. 파일 쓰기

title

지난 강의에서 위 그림과 같은 메모리 구조를 간략하게 배웠다. 다시 복습하면, 머신 코드 영역에는 우리 프로그램이 실행될 때 그 프로그램이 컴파일된 바이너리가 저장된다. 글로벌 영역에는 프로그램 안에서 저장된 전역 변수가 저장된다. 힙 영역에는 malloc으로 할당된 메모리의 데이터가 저장되고 스택에는 프로그램 내의 함수와 관련된 것들이 저장된다.

title

위 그림을 보자. 메모리는 유한하므로, heap과 stack은 언젠가 서로 부딪힐 것이다. 계속해서 malloc을 호출하면 heap 영역의 메모리에 데이터가 쌓이게 될 것이고, 메모리는 화살표 방향으로 범위가 늘어날 것이다. 하지만 동시에 스택도 커질 수 있다. 이렇게 점점 늘어나다 보면 제한된 메모리 용량에서는 기존의 값을 침범하는 상황도 발생할 것이다. 이를 힙 오버플로우또는 스택 오버플로우라고 부른다.

자기 자신을 계속 호출하는 버그가 있는 프로그램을 실행하면 스택이 넘칠 수 있다. 이것이 스택 오버플로우라고 하며, 힙 오버플로우는 반대로 malloc을 계속 호출하는 바람에 너무 많은 메모리를 할당해 메모리 속 다른 내용을 덮어쓰게 되는 것을 말한다. 이외에도 버퍼 오버플로우가 있는데, 컴퓨터가 너무 많은 메모리를 쓰다 보면 파일이나 사진이 열리지 않거나, 화면이 정지해 아예 동작하지 않은 상황을 말한다.

1.2 사용자에게 입력받기

get_int, get_float, get_string 등등 cs50 라이브러리에 있는 함수들은 포인터를 사용하며, 스택이 사용된다. 이런 함수들을 직접 구현해보며 어떻게 사용되는지에 대해서 생각해보자.

1.2.1 get_int

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

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

우리는 그동안 CS50 라이브러리에 기대어 위와 같은 방식으로 사용자에게 값을 입력받았을 것이다.

#include <stdio.h>

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

하지만, CS50 라이브러리가 없었다면 사용자로부터 정수를 입력받기 위해서 get_int 함수가 아니라, scanf라는 함수를 썼을 것이다. scanf는 사용자로부터 형식 지정자에 해당되는 값을 입력받아 저장하는 함수이다. 형식지정자를 쓰면 그 형식대로 입력을 받으며(%i는 int 자료형을 입력받을 것이다.), 그리고 그 옆에 사용자에게 입력받은 것을 저장하고 싶은 변수의 주소(&x)를 적으면, 그 주소에 사용자가 입력한 것을 저장한다.

scanf에는 에러 확인 기능이 없기 때문에, 위 프로그램에서 사용자가 emma와 같이 정수가 아닌 자료형을 입력한다면, 포르그램이 죽거나 예상치 못한 방식으로 동작하게 된다.

그리고 scanf를 사용할 때, 변수의 값이 아니라 변수의 주소를 적는 이유는 swap함수와 같다. 남이 작성한 코드로 변수의 값을 바꾸고 싶다면 값이 아니라 주소로 전달을 해줘야 한다. 어제도 배웠듯이 값을 전달하면 복사하고 원본에는 영향을 미치지 않기 때문이다. 만약, 위 코드에서 x의 주소가 아니라 x를 적었으면, 빈값이 scanf에 의해서 메모리 어딘가에 저장될 것이다.

1.2.2 get_string

#include <stdio.h>

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

위 코드에서 보면, scanf 함수에서 변수 s의 주소가 아닌 값을 적은 것을 볼 수 있다. 이것은 s라는 변수를 선언할 때 char *를 통해 이미 포인터 변수로 선언했기 때문이다.

title

하지만 직접 실행해보면, 에러가 뜬다. 먼저 변수 s를 주소로 초기화하지 않고 사용한다.

#include <stdio.h>

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

입력받은 값을 둘 주소를 미리 알면 좋지만, 그럴 수 없기 때문에 빈 공간을 의미하는 NULL이라고 쓰자. NULL은 특별한 포인터로 가리키는 곳이 없다는 뜻으로 모두 0이다.

title

컴파일은 잘 되지만, s에 무엇을 입력해도 Null이라고 출력이 된다. 심지어 첫 글자도 저장하지 않았다.

title

심지어 무수히 많은 단어를 입력하면 프로그램은 고장 나버리고 만다. 왜 그런 걸까? 위 코드에서 변수 schar *를 이용해 포인터 변수로 만들었다. 포인터 변수는 메모리 영역의 주소를 저장할 수 있는 변수를 말하지만, 그 변수에 할당된 내용은 NULL이다. NULL은 메모리 공간이 할당되지 않았다는 뜻이기 때문에, char *s = NULL은 사용자에게 입력받은 것이 저장된 공간이 할당되지 않았음을 의미한다.

#include <stdio.h>

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

이제 해야 할 것은 입력한 문자열의 크기를 가정하는 것이다. River를 입력한다고 하면 크기가 6인 문자 배열을 선언해야 한다. 그래서 위 코드에서 char *s = NULL을 대신해서 char s[6]를 입력한 것이다.

요약해보면 오늘 배운 내용은 배열과 포인터라는 사실과 연관되어있다. 배열은 메모리가 연속적으로 할당된 공간이고, 문자열은 문자가 연속적으로 있는 것이다. 그리고 문자열은 사실 그 메모리 공간 첫 번째 주소를 의미한다. 따라서 이 관계에 의해서 최소한 위 코드에서의 포인터는 배열과 같다고 볼 수 있다.

크기가 6인 배열을 선언하면, 컴파일러는 문자 배열의 이름을 포인터처럼 다루게 된다. 즉 scanfs라는 배열 첫 바이트 주소를 넘겨주는 것이 된다.

title

위 코드를 컴파일 후 실행해보면 River나 Emma는 잘 입력 받지만, Everthing that ever happend or ever will.를 입력하면 프로그램이 멈추지는 않지만, 의도대로 동작하지 않는다. 충분한 공간을 할당하지 않았기 때문이다. 이것보다 더 긴 문장을 입력한다면, 프로그램이 멈추거나 세그멘테이션 오류가 발생하게 될 것이다.

1.2 파일 쓰기

이제 사용자로부터 입력받아 파일에 저장하는 프로그램을 작성할 것이다.

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

int main(void)
{
    // Open file
    FILE *file = fopen("phonebook.csv", "a");
    
    // Get string form user
    char *name = get_string("Name: ");
    char *number = get_string("Number: ");
    
    // Print (write) string to file
    fprintf(file, "%s,%s\n", name, number);
    
    // Close file
    fclose(file);
}

위는 사용자로부터 입력받아, 파일에 저장하는 프로그램이다. 우리의 목표는 전화번호부를 만들어 사용자로부터 이름과 번호를 입력받아 텍스트 파일에 덧붙이는 것이다. 하나의 데이터베이스처럼 사람들의 전화번호를 추적할 수 있도록 말이다. 이제 코드를 하나하나 뜯어보자.

FILE *file = fopen("phonebook.csv", "a");

위 코드는 file이라는 파일을 만들고 fopen이라는 함수를 사용해서 phonebook.csv 를 여는 코드이다. FILE *file는 대문자로 쓰인 FILE이라는 새로운 자료형을 가리키는 포인터 변수 file을 만든다. 즉, file은 변수의 이름이고, 파일 내용을 저장해줄 것이다. 엄밀히 말하면 아니지만, 임시로 그렇다고 하자. fopen은 첫 번째 인자로는 열고 싶은 파일 이름을, 두 번째 인자로는 r, w, a를 받는다. r은 읽기, w는 쓰기, a는 덧붙이기를 말한다. 그리고 fopen은 해당 파일을 가리키는 포인터를 반환한다. 확장자명이 csv인 파일을 연 이유는 무엇일까? csv는 쉼표로 분리된 값이다. 간단한 엑셀이나 Numbers 같은 프로그램으로 열 수 있다.

char *name = get_string("Name: ");
char *number = get_string("Number: ");

위 두 줄은 각각, 이름과 전화번호를 받는 변수이다. string이 아니라, char *을 사용했으며, 편의를 위해 get_string을 사용해서 사용자에게 이름과 전화번호를 받는다.

fprintf(file, "%s,%s\n", name, number);
fclose(file);

printf 라는 함수와 별개로 fprintf라는 함수가 있다. 파일용 printf라며 생각하면 된다. scanf를 써도 되지만, 에러 확인을 더 많이 해야 할 것이다.

이제 파일을 출력해볼 텐데, %s, %s\n을 사용해 이름과 번호를 출력해야한다. 그리고 fclose(file);로 파일을 닫을 것이다.

title

이제 이것을 컴파일링 후 실행해보면, 이름과 전화번호를 입력하라고 한다. 각각 River와 01012345678을 입력했다.

title

그러고 나면 phonebook.csv 라는 파일이 생겼다. 파일을 열어보면 아래와 같은 내용이 나온다. 쉼표로 입력한 이름과 번호가 구분되어있다는 것을 알 수 있을 것이다.

title



2. 파일 읽기

파일의 내용일 읽어서 파일의 형식이 JPEG인지 아닌지를 검사하는 프로그램을 작성해볼 것이다.

#include <stdio.h>

int main(int argc, char *argv[])
{
    // Ensure user ran grogram with two words at prompt
    if (argc != 2)
    {
        return 1;
    }
    
    //Open file
    FILE *file = fopen(argv[1], "r");

    if (file == NULL)
    {
        return 1;
    }
    
    // Read 3 bytes from file
    unsigned char bytes[3];
    fread(bytes, 3, 1, file);

    // Check if bytes are 0xFF, 0xD8, 0xFF 
    if (bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff)
    {
        printf("Maybe\n");
    }
    else
    {
        printf("No\n");
    }
    fclose(file);
}

작성하고 나면 위와 같은 코드가 된다. 이제 하나하나 뜯어보자.

int main(int argc, char *argv[])

main함수를 보면 사용자로부터 파일 이름을 입력받는 것을 알 수 있다.

if (argc != 2)
{
    return 1;
}

만약 argc가 2가 아니라면, 파일명이 입력되지 않았거나 파일명 외의 다른 인자가 입력되었기 때문에 1을 출력함으로써 오류가 있다는 것을 알려준다.

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

FILE를 통해서 사용자가 입력해준 파일명으로 파일을 열 것이라는 말이다. 사용자가 입력한 두 번째 문자열이니 argv[1]을 씀으로써 두 번째 문자열에 접근할 수 있도록 한다. 그리고 이번에는 입력받은 파일명을 한 줄씩 덧붙이는 게 아니라, 읽어야 하니, r키워드를 사용한다.

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

그리고 에러를 확인하고 넘어가 하는데, 아직 배우지 않았지만, fopen이나 malloc, 혹은 get_string과 같은 함수는 에러가 생기면 NULL이라는 값을 돌려준다. 때문에 조건문을 이용해서 에러가 있는지 없는지를 확인하고, 에러가 있다면 1을 반환하고 프로그램을 종료한다.

unsigned char bytes[3];
fread(bytes, 3, 1, file);

파일이 잘 열렸다면, 크기가 3인 문자 배열을 만들고, fread라는 함수를 사용해서 파일의 첫 3바이트를 읽어온다. 말 그대로 파일의 첫 24비트, 즉 3바이트를 읽어온다. fread라는 함수는 배열, 읽을 바이트 수, 읽을 횟수, 그리고 읽을 파일을 인자로 받는 함수이다. char앞에 unsigned를 붙인 이유는 -128부터 127이 아닌 0부터 255 범위의 값을 의미하기 때문이다.

if (bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff)
{
   printf("Maybe\n");
}
else
{
    printf("No\n");
}
fclose(file);

그리고 마지막으로 읽어 들인 각 바이트가 각각 0xFF, 0xD8, 0xFF 인지를 확인한다. (왜 세 개를 확인해야 하냐면 색을 만들 때 빨강, 파랑, 초록을 섞기 때문이다.) JPEG 형식에 관한 설명 문서를 보면 우리가 카레로 찍으면 생기는 모든 JPEG 파일의 첫 세 바이트는 무조건 FF, D8, FF로 시작한다. JPEG 개발자들이 정한 일종의 매직 넘버로써 파일의 시작점에서 이 파일이 JPEG 사진이라는 것을 알려주는 것이다.




© 2020. by RIVER

Powered by RIVER