5. Memory
1) 메모리 주소
10진수를 16진수로 바꾸어보기
JPG 이미지 파일은 항상 255 216 255 로 시작되고 이것은 10진수입니다. 하지만 실제 컴퓨터 내에서는 10진수를 사용하지 않습니다. 컴퓨터는 0과 1만을 이해할 수 있기 때문입니다.
그림 1
먼저 255 216 255를 2진수로 나타내보면 <그림 1>과 같습니다. 2진수로 모든 데이터를 표현하기에는 너무 길어지기 때문에 16진수로 바꾸어 보겠습니다. 2^4이 16이기 때문에 4bits씩 두 덩어리로 나누어 보면 0000 부터 1111(=15)까지는 16진수로 표현할 수 있다는 것을 알 수 있습니다.
그렇다면 16진수에서 10부터 15까지는 어떻게 표기할까요? 10은 a, 11은 b, …, 15는 f를 대입하여 사용합니다. 4bits씩 16진수로 변환 후 0x를 붙혀 뒤에 오는 문자들이 16진수임을 알려줍니다.
16진수의 유용성
ASCII 코드에 의해 “A, B, C”는 10진수로 65, 66, 67에 해당합니다. 컴퓨터는 10진수를 이해할 수 없으므로 2진수로 표현해보면 "01000001 01000010 01000011"이 됩니다. 컴퓨터가 처리할 수 있어야 하기 때문에 어쩔 수 없지만 그 길이가 너무 긴 것을 알 수 있습니다.
하지만 16진수로 표현하면 2진수로 표현했을 때 보다 훨씬 간단해집니다. 또한 컴퓨터는 8개의 비트가 모인 바이트 단위로 정보를 표현합니다. 2개의 16진수는 1byte의 2진수로 변환되기 때문에 정보를 표현하기 매우 유용합니다.
5) 문자열 복사
#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);
//malloc() -> 메모리 할당.
//strlen(s) + 1 -> s의 문자길이 + null자리 1
for (int i = 0, n = strlen(s); i < n + 1; i++)
{ //n = strlen(s)-> 루프 돌 때마다 길이를 계산하지 않기 위해 초기화.
//같은 자료형이면 int n 말고 그냥 n만 써도 됨.
t[i] = s[i];
}
t[0] = toupper(t[0]);
printf("s: %s\n", s);
printf("t: %s\n", t);
}
[생각해보기]
A. 배운 바와 같이 메모리 할당을 통해 문자열을 복사하지 않고, 단순히 문자열의 주소만 복사했을 때는 어떤 문제가 생길까요?
Q. 문자열의 주소만 복사할 시, 한 문자열을 바꿨을 때 같은 주소값을 갖는 다른 문자열도 동시에 똑같이 바뀐다. 같은 주소를 공유하고 있기 때문에, 값도 그 주소가 같고 있는 데이터를 동일하게 가리키고 있기 때문이다.
따라서 주소값이 아니라, 주소 안에 들어있는 데이터를 복사하여야 한다.
6) 메모리 할당과 해제
malloc 함수를 이용하여 메모리를 할당한 후에는 free라는 함수를 이용하여 메모리를 해제해줘야 합니다.
그렇지 않은 경우 메모리에 저장한 값은 쓰레기 값으로 남게 되어 메모리 용량의 낭비가 발생하게 되기 때문이죠.
이러한 현상을 ‘메모리 누수’라고 일컫습니다.
메모리 누수는 x라는 포인터를 통해 할당한 메모리를 해제하기 위해 free(x) 라는 코드를 추가해줌으로써 해결할 수 있습니다.
valgrind 라는 프로그램을 사용하면 우리가 작성한 코드에서 메모리와 관련된 문제가 있는지를 쉽게 확인할 수 있습니다.
[생각해보기]
Q. 제한된 메모리를 가지고 프로그래밍을 할 때 메모리를 해제하지 않으면 어떤 문제가 발생할 수 있을까요?
A. 메모리 누수가 지속되어 실행 속도가 무척 느려지거나 메모리가 꽉 차 아예 작동하지 않을 것이다.
7) 메모리 교환, 스택, 힙
아래와 같은 코드가 있습니다. 함수 swap은 정수 a와 b를 입력받아 그 값을 바꾸는 일을 수행합니다.
main 함수에서는 x에 1, y에 2를 입력하고 swap 함수를 통해 그 두 값을 바꾸려고 하고 있습니다.
과연 의도대로 잘 바뀌어서 출력이 될까요?
위 코드를 컴파일하고 출력해보면 우리 의도와는 다르게 swap 함수를 거친 후에도 x와 y의 값이 바뀌지 않은채 그대로 출력됨을 알 수 있습니다.
사실 swap 함수는 교환 작업을 제대로 수행하고 있는데요, 문제는 교환하는 대상이 x, y 그 자체가 아닌 함수 내에서 새롭게 정의된 a, b라는 것이었습니다.
a와 b는 각각 x와 y의 값을 복제하여 가지게 됩니다. 서로 다른 메모리 주소에 저장되는 것이죠.
아래 그림에서와 같이 메모리 안에는 데이터 저장되는 구역이 나뉘어져 있습니다.
머신 코드 영역에는 우리 프로그램이 실행될 때 그 프로그램이 컴파일된 바이너리가 저장됩니다.
글로벌 영역에는 프로그램 안에서 저장된 전역 변수가 저장됩니다.
힙 영역에는 malloc으로 할당된 메모리의 데이터가 저장됩니다. 그리고 스택에는 프로그램 내의 함수와 관련된 것들이 저장됩니다.
위의 코드에서 a, b, x, y, tmp 모두 스택 영역에 저장되지만 a와 x, b와 y는 그 안에서도 서로 다른 위치에 저장된 변수입니다. 따라서 a와 b를 바꾸는 것은 x와 y를 바꾸는 것에 아무런 영향도 미치지 않는 것이죠.
따라서 아래 그림 및 코드와 같이 a와 b를 각각 x와 y를 가리키는 포인터로 지정함으로써 이 문제를 쉽게 해결할 수 있습니다.
[생각해보기]
Q. 메모리 영역을 다양하게 나누는 이유는 무엇일까요?
A. 메모리 공간은 한정적이므로 목적과 특성에 맞게 나누어 저장해야 메모리 누수없이 효율적으로 관리가 가능하기 때문이다.
8) 파일 쓰기
지난 강의에서 아래 그림과 같은 메모리 구조를 간략하게 배웠었습니다.
다시 복습하면, 머신 코드 영역에는 우리 프로그램이 실행될 때 그 프로그램이 컴파일된 바이너리가 저장됩니다.
글로벌 영역에는 프로그램 안에서 저장된 전역 변수가 저장됩니다.
힙 영역에는 malloc으로 할당된 메모리의 데이터가 저장됩니다. 그리고 스택에는 프로그램 내의 함수와 관련된 것들이 저장됩니다.
힙 영역에서는 malloc 에 의해 메모리가 더 할당될수록, 점점 사용하는 메모리의 범위가 아래로 늘어납니다.
마찬가지로 스택 영역에서도 함수가 더 많이 호출 될수록 사용하는 메모리의 범위가 점점 위로 늘어납니다.
이렇게 점점 늘어나다 보면 제한된 메모리 용량 하에서는 기존의 값을 침범하는 상황도 발생할 것입니다.
이를 힙 오버플로우 또는 스택 오버플로우라고 일컫습니다.
자기 자신을 계속 호출하는 버그가 있는 프로그램을 실행하면 스택 오버플로우가 발생합니다.
malloc을 계속 호출해서 너무 많은 메모리를 할당해 힙 오버플로우가 발생하면, 메모리 속 다른 내용을 덮어씁니다.
그러므로 위(스택 오버플로우/힙 오버플로우)와 같이 컴퓨터가 너무 많은 메모리를 쓰다 보면 파일이나 사진이 열리지 않거나, 화면이 정지하거나 아예 동작하지 않는 상황이 생기는 것입니다. 이 현상을 버퍼 오버플로우라고 합니다.
※버퍼 오버플로우
버퍼는 프로그램 처리 과정에서 필요한 데이터가 임시적으로 저장되는 공간으로 메모리에서 스택, 힙 영역이 여기에 속한다. 따라서 스택 오버플로우와 힙 오버플로우는 스택 버퍼 오버플로우, 힙 버퍼 오버플로우 라고도 한다.
사용자에게 입력받기
스택은 우리가 여태껏 많이 써왔던 get_int나 get_string 과 같은 함수에서도 사용됩니다.
만약 이런 함수들을 직접 구현한다면 아래와 같은 코드가 될 것입니다.
[get_string 코드]
#include <stdio.h>
int main(void)
{
char s[5];
printf("s: ");
scanf("%s", s);
printf("s: %s\n", s);
}
위 코드들에서 scanf라는 함수는 사용자로부터 형식 지정자에 해당되는 값을 입력받아 저장하는 함수입니다.
유의할 점! 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은 읽기(read), w는 쓰기(write), a는 덧붙이기(append)를 의미합니다.
- 사용자에게 name과 number라는 문자열을 입력 받고, 이를 fprintf 함수를 이용하여 printf에서처럼 파일에 직접 내용을 출력할 수 있습니다.
- 작업이 끝난 후에는 fclose함수로 파일에 대한 작업을 종료해줘야 합니다.
[생각해보기]
Q. get_long, get_float, get_char도 비슷한 방식으로 직접 구현할 수 있을까요?
A. 가능하다. 아래와 같이 하면 된다.
#include <stdio.h>
int main(void){
long a;
float b;
char c;
scanf("%li",&a);
scanf("%f",&b);
scanf("%c",&c);
}
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;
}
unsigned char bytes[3];
// unsigned : -128부터 127이 아닌 0부터 255 범위의 값을 의미합니다
fread(bytes, 3, 1, file);
if (bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff)
{
printf("Maybe\n");
}
else
{
printf("No\n");
}
fclose(file);
}
위 코드에서 main 함수를 보면 사용자로부터 입력을 받는 것을 알 수 있습니다.
여기서는 파일의 이름을 입력으로 받을 예정입니다.
만약 argc가 2가 아니라면, 파일명이 입력되지 않았거나 파일명 외의 다른 인자가 입력되었기 때문에 1(오류)을 리턴하고 프로그램을 종료합니다.
만약 argc가 2라면 프로그램이 그대로 진행됩니다.
입력받은 파일명(argv[1])을 ‘읽기(r)’ 모드로 불러옵니다.
만약 파일이 제대로 열리지 않으면 fopen 함수는 NULL을 리턴하기 때문에 이를 검사해서 file을 제대로 쓸 수 있는지를 검사하고, 아니라면 역시 1(오류)를 리턴하고 프로그램을 종료합니다.
만약 파일이 잘 열렸다면, 프로그램이 계속 진행됩니다.
그 후 크기가 3인 문자 배열을 만들고, fread 함수를 이용해서 파일에서 첫 3바이트를 읽어옵니다.
fread 함수의 각 인자는 (배열, 읽을 바이트 수, 읽을 횟수, 읽을 파일)을 의미합니다.
그리고 마지막으로 읽어들인 각 바이트가 각각 0xFF, 0xD8, 0xFF 인지를 확인합니다.
이는 JPEG 형식의 파일을 정의할 때 만든 약속으로, JPEG 파일의 시작점에 꼭 포함되어 있어야 합니다.
따라서 이를 검사하면 JPEG 파일인지를 확인할 수 있습니다.
[생각해보기]
Q. JPEG 외에 다른 파일 형식도 그 형식임을 알려주는 약속이 있을까요?
A. 있다.
파일의 형태를 결정짓는 건 내부 데이터의 시그니처가 결정한다.
파일마다 헤더(header)와 푸터(footer)에 확장자의 시그니처가 존재하고 16진수로 byte단위로 이루어져 있다.
이를 파일 시그니처 혹은 매직 넘버라 하며 디지털 포렌식, 파일복구, 악성코드 분석 등에 활용된다.
※파일 시그니처(출처 : http://forensic-proof.com/archives/300)
파일들은 각각 고유한 포맷을 가지고 있는데 포맷의 기본이 되는 내용이 파일 시그니처(File Signature)이다. 파일 시그니처는 파일의 가장 처음에 위치하는 특정 바이트들도 파일 포맷을 구분하기 위해 사용한다.
파일 시그니처는 파일의 처음에만 존재하는 파일 포맷도 있지만 파일의 마지막에도 존재하는 포맷도 있다. 파일의 처음에 존재하는 시그니처는 보통 헤더(Header) 시그니처, 파일의 마지막에 존재하는 시그니처는 푸터(Footer or Tailer) 시그니처라고 부른다. 그리고 문서에 따라 시그니처를 매직 넘버(magic number) 라고 사용하는 경우도 있다.
파일 시그니처는 파일 포맷 분석, 악성코드 분석, 파일 복구 등에 중요하게 작용한다.
위의 링크를 타고 들어가면 다양한 파일 시그니처를 분류하고 있다.
'TIL저장소' 카테고리의 다른 글
[REST API] 참고할 만한 포스트 정리 (0) | 2022.01.02 |
---|---|
[네이버 부스트코스]CS50 2019 모두를 위한 컴퓨터과학 6.데이터구조 (0) | 2021.06.28 |
[네이버 부스트코스]CS50 2019 모두를 위한 컴퓨터과학 4.Algorithms (0) | 2021.06.26 |
[스파르타코딩클럽] 웹개발 4주차 flask서버, api설계 (0) | 2021.06.25 |
[스파르타코딩클럽]웹개발 3주차 MongoDB/robo3T/pymongo/웹스크래핑(크롤링) (0) | 2021.06.22 |