Love Every Moment
〔CS50 / C언어〕메모리: 포인터, 문자열, 메모리 할당과 해제, 파일 쓰고 읽기 본문
1. 메모리 주소
(1) 16진수(Hexadecimal)
16진법에서 a 부터 f 까지는 각각 10 부터 15까지의 수를 의미하며 0x 는 16진수를 나타내는 형식이다.
예를 들어 위의 사진에서 255 는 16 x f(15) + 1 x f(15) 와 같으므로 0xff 로 나타낼 수 있다.
알파벳 대문자 A 는 아스키 코드로 65 에 해당하므로 0x41 로 나타낼 수 있다.
다음의 표는 0 ~ 19 를 16진수로 표현하는 방식을 보여주는 예시이다.
10진법 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
16진법 | 0x0 | 0x1 | 0x2 | 0x3 | 0x4 | 0x5 | 0x6 | 0x7 | 0x8 | 0x9 |
10진법 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ||||
16진법 | 0xa | 0xb | 0xc | 0xd | 0xe | 0xf | 0x10 | 0x11 | 0x12 | 0x13 |
(2) 포인터(Pointer)
#include <stdio.h>
int main(void)
{
int n = 50;
printf("%p\n", &n);
printf("%i\n", *&n);
}
'&' 라는 연산자는 변수의 메모리상 주소를 나타내는 역할(Show the address)을 한다.
반면에 '*' 라는 연산자는 해당 메모리 주소에 있는 실제 값을 가리키는 역할(Go to the address→)을 한다.
첫번째 명령문을 실행하면 변수 n이 저장된 메모리 주소 "0x12345678" 를 출력한다.
%p 는 변수의 주소를 출력하는 기능을 하므로 &n 처럼 주소를 입력해야한다.
두번째 명령문을 실행하면 변수 n에 저장된 값인 "50" 을 출력한다.
&n 은 변수 n이 저장된 메모리 주소를 나타내고 * 은 해당 메모리 주소(&n)에 저장된 값을 가리킨다.
결국 *&n 을 출력하는 것과 n 을 출력하는 것에는 차이가 없다.
#include <stdio.h>
int main(void)
{
int n = 50;
int *p = &n;
printf("%p\n", p);
printf("%i\n", *p);
}
* 연산자를 이용하여 포인터 역할을 하는 변수를 선언할 수도 있다.
위의 예시에서 변수 n 는 컴퓨터 메모리 어딘가에 4바이트(∵ int)만큼의 자리를 차지하며 저장되어 있다.
포인터 변수로 선언된 p는 변수 n의 주소 "0x12345678" 를 값으로 가지게 된다.
첫번째 명령문(p)을 실행하면 p 에 저장된 값인 "0x12345678" 가 그대로 출력된다.
두번째 명령문(*p)을 실행하면 p 에 저장된 메모리 주소로 이동하여 해당 주소에 저장된 n 값 "50" 가 출력된다.
2. 문자열(String)
(1) 새로운 자료형의 선언
#include <cs50.h>
#include <stdio.h>
int main(void)
{
string s = "EMMA";
printf("%s\n", s);
}
#include <stdio.h>
int main(void)
{
char *s = "EMMA";
printf("%s\n", s);
}
string 은 사실 원래부터 존재하는 자료형이 아니라 <cs50.h> 라이브러리에서 새로운 자료형을 정의한 것이다.
해당 라이브러리에 들어가면 typedef char *string 처럼 새로운 자료형으로 정의되어 있다.
새로운 자료형을 정의하는 typedef 함수를 사용하고 char * 로써 string 이라는 이름의 문자형 포인터 변수를 선언하였다.
첫번째 예시는 <cs50.h> 라이브러리에 포함되어 있는 string 자료형을 이용하여 문자열을 출력하였다.
두번째 예시는 포인터 변수를 이용하여 문자열을 출력하였으며 둘의 출력 결과는 "EMMA"로 같다.
여기서 포인터 변수 s 는 문자열의 가장 첫 번째 값인 "E" 가 저장된 메모리 주소를 값으로 가진다.
%s 는 해당 메모리 주소에 저장된 값부터 \0 (NULL) 까지를 하나의 문자열로 인식하여 출력하는 기능을 한다.
(2) 문자열 출력하기
#include <stdio.h>
int main(void)
{
char *s = "EMMA";
printf("%p\n", s);
printf("%p\n", &s[0]);
printf("%p\n", &s[1]);
printf("%p\n", &s[2]);
printf("%p\n", &s[3]);
}
문자열이 가지는 원소 각각이 저장된 메모리 주소를 출력하여 비교해보자.
만약 현재 컴퓨터에 "E"가 저장된 메모리 주소가 "0x111111" 이라고 가정한다면,
각각의 명령문은 "0x111111", "0x111111", "0x111112", "0x111113", "0x111114"을 출력할 것이다.
포인터 변수는 문자열의 첫번째 원소인 "E"의 메모리 주소를 값으로 가지기에 s 와 &s[0] 의 출력값은 동일하다.
#include <stdio.h>
int main(void)
{
char *s = "EMMA";
printf("%s\n", s);
printf("%c\n", *s);
printf("%c\n", *(s+1));
printf("%c\n", *(s+2));
printf("%c\n", *(s+3));
}
문자열 전체와 문자열의 원소 각각을 출력하여 비교해보자.
첫번째 명령문은 "EMMA", 두번째부터 마지막까지는 "E", "M", "M", "A" 를 각각 출력할 것이다.
위의 예시에서 보았듯이 문자열의 원소는 바로 앞의 원소가 저장된 메모리 주소의 바로 다음 주소에 저장되기 때문이다.
(3) 문자열 비교하기
#include <stdio.h>
#include <cs50.h>
int main(void)
{
char *s = get_string("s: ");
char *t = get_string("t: ");
for (int i = 0; *(s+i) != '\0' || *(t+i) != '\0'; i++)
{ if( *(s+i) != *(t+i) )
{
printf("Different\n");
return 1;
}
}
printf("Same\n");
return 0;
}
두 개의 문자열이 같은지 비교하기 위해서는 문자열이 저장된 변수를 바로 비교해서는 안 된다.
만약 if (s == t) 이면 "Same" 아니면 "Different" 를 출력하도록 코드를 작성한다면 설령 사용자가 실제로 같은 문자열인 "EMMA" 와 "EMMA" 를 입력한다고 하더라도 실행 결과는 "Different" 가 출력된다.
왜냐하면 첫번째 "EMMA" 가 저장된 메모리 주소와 두번째 "EMMA"가 저장된 메모리 주소가 다르기 때문이다.
따라서 위와 같이 포인터를 이용하여 각각의 원소를 비교해야한다.
char * 은 <cs50.h> 라이브러리에서 string 과 동일하기 때문에 char *s 대신 string s 로 작성해도 된다.
3. 메모리 할당과 해제
(1) 메모리 할당(Memory Allocation)
#include <stdio.h>
#include <stdlib.h>
#include <cs50.h>
#include <string.h>
#include <ctype.h>
int main(void)
{
char *s = get_string("s: ");
char *t = malloc(strlen(s) + 1);
int n = strlen(s);
for (int i = 0; i < n + 1; i++)
{
t[i] = s[i];
}
t[0] = toupper(t[0]);
printf("%s\n", s);
printf("%s\n", t);
free(t);
}
문자열 복사하기: 사용자로부터 "emma" 라는 입력을 받아 첫글자를 대문자로 바꾸고 원래의 입력과 함께 출력해보자.
만약 char *t = s; 처럼 새로운 문자열 t 에 원래의 문자열 s 를 할당하고 t 의 첫글자를 대문자로 바꾼다면,
원하는 결과가 아닌 "Emma" "Emma" 가 출력될 것이다.
이는 t[] 라는 문자열이 의미하는 바가 "s[]가 가리키는 메모리 주소로 가서 값을 가져오라"이기 때문에 t[0] 을 대문자로 바꾸면 같은 메모리 주소의 값을 가리키는 s[0]까지 함께 대문자 "E" 로 변하기 때문이다.
따라서 원래 의도대로 "emma" "Emma"를 출력하기 위해서는 t[] 가 가리키는 새로운 메모리 주소를 할당해주어야 한다.
<stdlib.h> 라이브러리에 포함된 함수 malloc 을 이용하면 새로운 메모리 공간을 할당할 수 있다.
char *t = malloc(strlen(s) + 1); 에서 문자열 s의 길이에 1을 더하는 것은 마지막 "\0"를 저장할 공간이 필요하기 때문이다.
예시에서는 "emma\0" 가 차지하는 메모리가 5 바이트 이므로 t 에도 5 바이트 만큼의 메모리가 할당된다.
이렇게 t[] 에 새로운 메모리 공간을 할당해주고 for 반복문을 이용해 t[] 에 s[] 의 값을 복사해주고 t[0]을 대문자로 바꾼다면,
s 와 t 가 가리키는 메모리 주소가 다르므로 t[0] 의 "e" 만이 "E" 로 바뀌게 되며 s[0] 은 그대로 "e" 로 유지된다.
strcpy(t, s); 함수를 이용함으로써 for 반복문을 통한 문자열 복사를 대신할 수도 있다.
(2) 메모리 해제(Memory Deallocation)
#include <stdlib.h>
void f(void)
{
int *x = malloc(10 * sizeof(int));
x[9] = 0;
free(x);
}
int main(void)
{
f();
return 0;
}
valgrind 프로그램은 이러한 메모리 용량의 낭비가 있는지 확인해주는 역할을 한다.
valgrind ./파일명 을 입력하면 오른쪽의 사진과 같이 검사 결과를 보여주는데 두 가지의 문제가 발생하였다.
첫번째는 x[10] = 0; 으로 인해 발생한 버퍼 오버플로우(Buffer Overflow) 문제이다.
예를 들어 {1, 2, 3, 4, 5, 6, 7, 8, 9, 0} 과 같이 열 개의 정수가 들어갈 수 있는 배열을 위한 메모리를 할당하였는데 x[10] 에 '0' 을 할당하려고 한다면 존재하지 않는 11번째 인덱스에 접근할 수가 없으므로 오류가 발생한다.
따라서 x[10] 을 x[0]~x[9] 사이의 인덱스로 변경해준다면 오류를 해결할 수 있다.
두번째는 메모리 누수(Memory Leak) 문제이다.
메모리를 할당한 후에 해제하지 않으면 메모리에 저장한 값이 쓰레기 값으로 남아 메모리 누수 현상이 발생한다.
따라서 malloc 함수로 메모리를 할당한 후에는 free 함수를 이용하여 메모리를 해제하는 과정이 필요하다.
예시에서는 free(x); 라는 코드를 추가하여 메모리 누수 현상을 해결하였다.
4. 메모리 구조
(1) 메모리 교환
x 에 1을 입력하고 y에 2를 입력하여 서로의 값을 맞바꾸어 출력하는 함수를 선언해보자.
첫번째 사진처럼 코드를 작성한다면 의도와 다르게 "x is 1, y is 2" "x is 1, y is 2" 라고 출력된다.
그 이유는 a와 b가 각각 x와 y의 값을 복제하여 다른 메모리 주소에 저장되었기 때문이다.이미 다른 메모리 주소에 저장된 a와 b의 값을 swap 함수를 통해 교환해봤자 x와 y의 값에는 아무런 영향도 가지 않는다.
그러므로 아래와 같이 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;
}
(2) 메모리 구조
Machine Code | 프로그램을 컴파일하면 생기는 바이너리가 저장되는 영역 |
Globals | 프로그램 안에서 저장된 전역 변수가 저장되는 영역 |
Heap | malloc 을 통해 할당된 메모리의 데이터가 저장되는 영역 |
Stack | 프로그램 내의 함수와 관련된 것들이 저장되는 영역 |
힙(Heap) 영역에서 사용하는 메모리의 범위는 malloc 에 의해 메모리가 더 할당될수록 아래로 늘어난다.
스택(Stack) 영역에서 사용하는 메모리의 범위는 함수가 더 많이 호출될수록 위로 늘어난다.
이렇게 메모리의 범위가 늘어나다가 기존의 값을 침범하는 상황을 힙 오버플로우 또는 스택 오버플로우 라고 한다.
힙 영역은 런 타임에 크기가 결정되는 반면 스택 영역은 컴파일 타임에 크기가 결정된다.
5. 파일 쓰기
#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 이라는 자료형으로 불러올 수 있다.
첫번째 인자에는 "phonebook" 이라는 이름의 csv(Comma Seperated Values) 파일을 적었다.
두번째 인자인 모드에는 r(읽기), w(쓰기), a(덧붙이기) 세 가지의 모드가 있는데 a 를 수행하기로 선택했다.
fprint(파일명, "%s", 변수) 함수를 이용하면 선택한 파일에 직접 내용을 출력할 수 있다.모든 작업이 끝나면 fclose(파일명) 함수를 통해 파일에 대한 작업을 종료해야 한다.
6. 파일 읽기
#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);
}
사용자로부터 입력 받은 파일의 형식이 JPEG 이미지인지 확인하기 위해 작성된 프로그램이다.
여기서 main 함수는 파일의 이름을 입력으로 받고 있다.
argc != 2 이면 프로그램을 종료한다는 것은 만약 사용자가 파일명 없이 ./jpeg 만 입력하여 argc == 1 이거나
사용자가 ./jpeg apple.jpg cat.jpg 처럼 다른 인자를 입력하는 경우를 의미한다.
argc == 2 라면 프로그램이 그대로 진행되어 입력 받은 파일명(argv[1])을 '읽기(r)' 모드로 불러온다.
만약 파일이 제대로 열리지 않으면 fopen 함수는 NULL 을 리턴하게 되므로 프로그램을 종료한다.
파일이 정상적으로 열리면 fread(배열, 읽을 바이트 수, 읽을 횟수, 읽을 파일) 함수를 통해 파일에서 첫 3 바이트를 읽는다.
만약 그것들이 각각 0xFF 0xD8 0xFF 라면 해당 파일은 JPEG 이미지 파일이라고 판단할 수 있다.
※ 네이버 부스트 코스 모두를 위한 컴퓨터 과학(CS50)의 강의를 참고하여 작성하였습니다.
컴퓨터 코딩 프로그래밍 교육
'PROGRAMMING::LANGUAGE > C' 카테고리의 다른 글
〔C언어〕size_t 와 unsigned int 의 차이? (0) | 2021.05.07 |
---|---|
〔CS50 / C언어〕자료구조: 메모리 할당, 연결 리스트, 해시 테이블, 트라이, 스택, 큐, 딕셔너리 (0) | 2021.02.26 |
〔CS50 / C언어〕알고리즘: 선형·이진 검색, 버블·선택·병합 정렬, 재귀 함수 (0) | 2021.02.02 |
〔CS50 / C언어〕배열: 컴파일링, 디버깅, 문자열, 명령행 인자 (0) | 2021.01.20 |
〔CS50 / C언어〕 조건문(if), 루프(for/while), 사용자 정의 함수 (0) | 2021.01.15 |