10진법 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
16진법 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | A | B | C | D | E | F |
2진수는 너무 긴데 이걸 16진수로 표현하면 훨씬 간편해짐.
또한 8bit = 1byte인데 2자리의 16진수는 8자리(1byte)의 2진수로 변환되기 때문에 정보를 표현하기 쉽다. => 2자리의 16진수로 1byte의 정보를 간단히 표현 가능
int n = 50
은 메모리 어딘가에 4바이트(int 자료형의 크기)만큼의 자리를 차지하며 저장된다.
C에서는 변수의 메모리상 주소를 받기 위해 &라는 연산자를 사용한다.
#include <stdio.h>
int main(void)
{
int n = 50;
printf("%p\n", &n); //주석 참고
}
*
을 사용하면 그 메모리 주소에 있는 실제 값을 얻을 수 있다
(*
: Go to the address)
#include <stdio.h>
int main(void)
{
int n = 50;
printf("%i\n", *&n);
}
위 코드는 먼저 n의 주소를 얻고(&n
), 또 다시 그 주소에 해당하는 값을 얻어(*&n
) 출력한 것이다. 결국 코드를 돌리면 결과값은 50이 나온다.
:bulb: CS50을 16진수로 표현하기
ASCII로 CS50을 표현하면 67, 83, 50
이걸 16진수로 바꾸면 0x43, 0x53, 0x32
*
의 역할#include <stdio.h>
int main(void)
{
int n = 50;
int *p = &n; // 주석 1, 2 참고
printf("3. 변수 n이 저장된 곳의 주소(포인터 p의 값)는 %p\n", p);
printf("4. 포인터 p가 가리키는 주소에 있는 값(n의 값)은 %i\n", *p); // 주석 3 참고
}
연산자까지 써줘야 한다. 2. &n: 변수 n이 저장된 곳의 주소. n의 값이 정수이기 때문에 포인터가 가리키는 값이 정수가 되어 *p 앞에 int가 붙는다. 즉, p의 자료형은 포인터지만, p가 가리키는 변수 n의 자료형이 int이기 때문에 int *p라고 쓴다. 3. 포인터 p에 저장된 주소에 접근해 거기에 저장된 값을 읽기 위해 p 앞에 연산자
*`를 써야 한다. 그리고 n은 정수형 자료이기 때문에 형식 지정자 %i를 쓴다. 실제 컴퓨터 메모리에서 변수 p는 아래와 같이 저장된다
cf. 최신 컴퓨터에서 포인터 변수의 크기는 64bits(8bytes)로, long과 똑같은 크기임
p의 개념은 대충 위와 같다. 하지만 실전에서는 p의 값, 즉 n의 주소는 별로 중요하지 않고 p가 n을 가리키고 있다는 개념 자체가 중요하게 사용된다.
이런 포인터를 기반으로 아주 정교한 데이터 구조(트리, 배열 등)들을 만들 수 있다.
:bulb: 포인터의 크기는 메모리의 크기와 어떤 관계가 있을까요?
포인터 변수의 크기는 메모리의 크기와 비례관계에 있다.
참고: 포인터의 개념
앞에서 #include <cs50.h>
를 사용해야 입력할 수 있던 자료형 string
은 결국 char *
과 같은 의미이다.
문자열 EMMA는 결국 문자 E, M, M, A, \0의 배열이고, s[0], s[1], ...와 같이 하나의 문자가 배열의 한 부분을 나타낸다.
cf. \0: 널 종단문자. 8개의 비트(=1byte)가 모두 0으로 이루어져 있다. 00000000. 문자열의 끝을 표시한다.
결국 변수 s는 이러한 문자열을 가리키는 포인터가 되는데, 정확히 말하면 문자열 "EMMA" 에서 첫 번째 문자인 'E'(s[0])의 주소를 저장한다.
#include <stdio.h>
#include <cs50.h>
int main(void)
{
char *s = "EMMA"; // string s = "EMMA"와 동일한 코드임.
printf("%s\n", s);
printf("%p\n", s); // EMMA가 저장된 주소 출력
printf("%p\n", &s[0]); // s: pointer, s[0]: char, &s[0]: address of 'E' => 프로그램 돌려보면 s == &s[0] 인 것을 알게 될 것이다.
}
위 프로그램을 돌려보면 결과는 다음과 같이 나온다
$ ./address2
EMMA
0x55c411ef5004
0x55c411ef5004
:bulb: string 자료형을 정의해서 사용하면 어떤 장점이 있을까요?
포인터 개념을 모르는 사람도 직관적으로 문자열을 사용해 코드를 짤 수 있다.
char *s = "EMMA";
로 선언된 변수 s의 자료형은 포인터로, 문자열 EMMA의 첫 글자인 E의 주소를 저장하고 있다. 그렇다면 s[0], s[1]와 같은 구문설탕(syntactic sugar)을 사용하지 않고 EMMA의 각 문자를 출력해보자
#include <stdio.h>
int main(void)
{
char *s = "EMMA"
printf("%p\n", *s); //주석 1 참고
printf("%p\n", *(s+1)); //주석 2 참고
printf("%p\n", *(s+2));
printf("%p\n", *(s+3));
printf("%s\n", s); //주석 3 참고
}
이런 프로그램을 돌리면 어떤 결과가 나올까?
#include <stdio.h>
#include <cs50.h>
int main(void)
{
char *s = get_string("s: ");
char *t = get_string("t: ");
if (s == t)
{
printf("Same\n");
}
else
{
printf("Different\n");
}
}
이 프로그램을 실행시켜 s에 EMMA를 저장하고, t에 EMMA를 저장해도 결과는 Different라고 나온다. 그 이유를 그림으로 간략히 설명해보면 다음과 같다.
이렇듯 s와 t는 포인터고, 내용물 자체가 다르기 때문에 컴퓨터는 당연히 s와 t가 다르다고 판단한다.
:bulb: 문자열을 비교하는 코드는 어떻게 작성해야 할까요?
#include <stdio.h>
#include <cs50.h>
// 함수 프로토타입
void compare(char *str1, char *str2);
// 본문
int main(void)
{
char *s = get_string("s: ");
char *t = get_string("t: ");
compare(s, t);
}
// 함수 정의
void compare(char *str1, char *str2)
{
for (int i = 0; *(str1+i)!='\0' || *(str2+i)!='\0';i++)
{
if (*(str1+i)-*(str2+i)==0) //같은 문자는 아스키코드도 같음
{
continue;
}
else
{
printf("Different\n");
return;
}
}
printf("Same\n");
}
cf. for문의 괄호() 안에서 정의된 변수는 for문이 끝나면 사라진다
for문 전에 선언되어서 for문 안에서 조작된(ex. i++
) 변수는 for문 밖에 나와서는 조작된 형태 그대로 남아있다.
ex. int i=0
-> for문 안에서 i++로 인해 i=5로 조작됨
-> for문 빠져나가도 i=5임.
📌핵심 단어: malloc
#include <stdio.h>
#include <cs50.h>
#include <ctype.h>
int main(void)
{
char *s = get_string("s: ");
char *t = s
t[0] = toupper(t[0]);
printf("%s\n", s);
printf("%s\n", t);
이 코드를 돌렸을 때 결과는 다음과 같이, s는 emma로, t는 Emma로 출력될거라고 예상한 것과 다르게 s와 t 모두 "Emma"라고 출력된다.
$ s: emma
$ Emma
$ Emma
그 이유는 s라는 변수에는 “emma”라는 문자열이 아닌 그 문자열이 있는 메모리의 주소가 저장되기 때문이다.
string s 는 char *s 와 동일한 의미라는걸 떠올려보면 된다.
따라서 t도 s와 동일한 주소를 가리키고 있고, t를 통한 수정은 s에도 그대로 반영이 되게 되는 것이다.
그렇다면 두 문자열을 실제로 메모리상에서 복사하려면 어떻게 해야 할까?
아래 코드와 같이 메모리 할당 함수를 사용하면 된다.
#include <stdio.h>
#include <cs50.h>
#include <ctype.h>
#include <string.h>
int main(void)
{
char *s = get_string("s: ");
char *t = malloc(strlen(a)+1); //주석 1
for (int i = 0, n = strlen(s); i < n+1; i++) //주석 2,3
{
t[i] = s[i];
}
t[0] = toupper(t[0]);
printf("%s\n", s);
printf("%s\n", t);
}
이제 프로그램에 emma를 입력하고 프로그램을 돌리면 s는 emma로, t는 Emma로 출력된다.
for (int i = 0; i < strlen(s)+1; i++)
이라고 쓰면 비효율적이다. 조건문에 함수를 쓰게 되면 루프를 돌 때마다 함수를 호출하고 조건을 검사하게 되어 시간이 오래 걸리기 때문이다. 이때 n = strlen(s)
라고 함수의 값을 변수로 저장하게 되면 조건검사시 함수를 호출할 필요가 없어 더 빨라진다.위의 과정을 이미지로 표현하면 다음과 같다.
#include <stdio.h>
#include <cs50.h>
#include <ctype.h>
#include <string.h>
int main(void)
{
char *s = get_string("s: ");
char *t = malloc(strlen(a)+1);
strcpy(t, s); //for문 대신 strcpy 사용
t[0] = toupper(t[0]);
printf("%s\n", s);
printf("%s\n", t);
}
위에서 쓴 복잡한 for문 대신 strcpy(t, s)
를 사용한다. 그러면 s의 문자열이 t에 복붙된다. 이 함수는 string.h 헤더파일에 저장되어있다.
:bulb: 배운 바와 같이 메모리 할당을 통해 문자열을 복사하지 않고, 단순히 문자열의 주소만 복사했을 때는 어떤 문제가 생길까요?
사본의 문자열을 수정하는 것으로 착각하지만 사실은 원본의 문자열을 수정하게되는 참사가 일어난다.
:pushpin: 핵심 단어: valgrind, free
malloc을 사용해 5byte의 메모리를 할당한 아래 코드를 보자
#include <stdio.h>
#include <cs50.h>
#include <ctype.h>
#include <string.h>
#include <stdlib.h> //malloc이 들어있음
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\n", s);
printf("%s\n", t);
}
malloc은 할당한 메모리의 첫 바이트의 주소(포인터)를 반환하는 함수이다.
malloc 함수를 이용해 메모리를 할당한 뒤에는 free라는 함수를 이용해 메모리를 해제해줘야 한다. 그렇지 않으면 메모리에 저장한 값은 쓰레기 값으로 남게 되어 메모리 용량의 낭비(메모리 누수)가 발생하기 때문이다.
동적 메모리 할당에 대한 비디오 참고
valgrind라는 프로그램을 사용하면 우리가 작성한 코드에서 메모리와 관련된 문제가 있는지 확인할 수 있다. 터미널 창에
valgrind ./filename
을 치면 다음과 같은 결과가 나온다.
여기에서 핵심적인 내용은
5bytes in 1 blocks are definitely lost in loss record 1 of 1
이다. 즉, malloc으로 할당된 5바이트가 free를 통해 해제되지 않아서 메모리 누수가 생기고 있다는 뜻이다. 이제 코드의 마지막째 줄에 (더 이상 malloc으로 할당된 메모리가 필요없을 때) free(t)를 써서 메모리 할당을 없앤 후 valgrind를 통해 검사하면 다음과 같이 오류가 없다고 나온다.
All heap blocks were freed -- no leaks are possible
다음 코드를 보자
#include <stdlib.h>
void f(void)
{
int *x = malloc(10 * sizeof(int));
x[10] = 0;
}
int main(void)
{
f();
return 0;
}
valgrind를 써서 위 코드를 검사해보면
위와 같이 길게 에러를 설명하는 내용이 뜬다. 여기서 주목해야 할 것은
1. Invalid write of size 4
2. 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
두 내용인데 이는 각각 다음과 같은 뜻이다.
1. 코드에서는 정수 10개만을 위한 메모리를 할당했는데, x[10]을 통해 11번째 자리에 0을 할당하려고 했기 때문에 버퍼 오버플로우 발생
2. malloc을 통해 메모리를 할당한 후 free로 할당을 해제하지 않았기 때문에 메모리 누수 발생
위 오류들은 x[9]=0
으로 고치고 void f(void)코드의 마지막 줄에 free(x)
를 쓰면 해결된다.
:bulb: 제한된 메모리를 가지고 프로그래밍을 할 때 메모리를 해제하지 않으면 어떤 문제가 발생할 수 있을까요?
해제하지 않은 메모리가 쌓여서 결국 스택오버플로우가 발생한다.
:pushpin: 핵심 단어: 스택, 힙, 포인터
#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;
}
프로그램을 실행하면 에러는 안뜨지만 x와 y의 값이 바뀌지 않는다는 것을 알 수 있다. 왜 그럴까?
그 이유는 함수 swap에서 a와 b는 각각 x와 y의 값을 복제해서 가지기 때문에, 함수 실행시 a와 b의 값이 바뀌지, x와 y의 값이 바뀌는 것이 아니기 때문이다.
아래 영상을 보자
위의 영상에서 메모리의 이미지가 나오는데 위에서부터 Machine code, Global, Heap, Stack이라고 써져 있다. 하나씩 알아보자
메모리의 맨 위쪽에는 clang과 같은 컴파일러가 코드를 컴파일한 후 나온 0과 1의 값들이 저장된다.
머신코드 아래에는 프로그램이 실행되는 내내 적용되는 전역 변수(global variables)들이 저장된다
malloc과 같은 동적 할당 함수를 통해 사용자가 할당받을 수 있는 메모리 영역이다. 정보가 위에서 아래로 저장된다. 이 영역은 컴퓨터가 자동으로 관리해주지 않기 때문에 메모리의 할당/해제를 사용자가 직접 관리해줘야 한다. 잘못 관리하면 메모리 누수, 스택오버플로우와 같은 문제들이 생긴다.
함수를 호출했을 때 함수에서 사용하는 지역변수(local variables)들을 저장하는 공간이다. 함수가 종료되면 stack에 저장된 변수들도 자동적으로 지워진다.
스택오버플로우란?
그림에서 보이듯이 스택과 힙은 같은 공간에서 서로 다른 방향으로 관리되는데, 만약 저장되는 데이터를 잘 관리하지 못해서 힙 영역과 스택 영역이 만나는 경우 스택오버플로우가 발생한다.
#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;
}
이 코드를 돌리면 x와 y의 값이 정상적으로 바뀌는 것을 확인할 수 있다.
작동 원리는 다음과 같다
a와 b를 각각 x와 y를 가리키는 포인터로 지정함으로서 문제를 해결한 것이다.
:bulb: 메모리 영역을 다양하게 나누는 이유는 무엇일까요?
각 메모리에 저장되는 데이터의 종류가 다른데(코드, 전역변수, 지역변수, 사용자가 필요할 때마다 동적으로 할당하는 메모리), 이 데이터들이 저장되고 지워지는 시기는 저마다 다르다. 한 영역에서 이 모든 데이터를 관리하려면 곤란한 일이 생기므로(ex. 지워야 할 데이터가 지우면 안되는 데이터 밑에 깔려있다던지..) 메모리를 여러 영역으로 나눠서 데이터를 따로 관리하는 것이 편하다.
메모리 구조를 알아보자
:pushpin: 핵심 단어: scanf, fopen, fprintf, fclose
char *s;
#include <stdio.h>
int main(void)
{
char *s = NULL; //string s와 동일.
printf("s: ");
scanf("%s", s); // 주석 참고
printf("s: %s\n", s);
}
이 상태로 사용하면 "variable s is uninitialized when used here"이라는 에러가 뜸. 즉 문자열 변수 s를 만들고 싶다면 변수 s를 주소로 초기화해야 함
char *s = NULL;
#include <stdio.h>
int main(void)
{
char *s = NULL;
printf("s: ");
scanf("%s", s);
printf("s: %s\n", s);
}
char *s = NULL
-> NULL은 특별한 포인터로, 가리키는 곳이 없다는 뜻임.
이 코드를 돌리면 s: (null)
이라는 글자가 출력된다. 여기서 null은 입력받은 문자열이 저장될 메모리 공간이 할당되지 않았다는 뜻이다.
char *s[5];
#include <stdio.h>
int main(void)
{
char s[5]; //사용자가 EMMA를 입력한다고 가정하고 \0(널 문자)까지 고려해 크기 5의 문자 배열을 선언
printf("s: ");
scanf("%s", s); //s의 주소를 scanf에 전달해서 사용자의 입력을 받음
printf("s: %s\n", s);
}
여태까지 배운 내용을 보면 배열과 포인터는 사실 연관되어 있다
배열은 메모리가 연속적으로 할당된 공간이다.
문자열은 문자가 연속적으로 있는 것이다. 문자열은 사실 그 메모리 공간 첫 번째 바이트의 주소를 의미한다.
따라서 추이적 관계에 의해 최소한 이 문맥에서 포인터는 배열과 같다고 볼 수 있다.
clang 컴파일러는 문자 배열의 이름을 포인터처럼 다룬다. 따라서 scanf에서 &
를 붙이지 않고 s를 쓰는 것이다 scanf("%s", s)
위의 코드를 실행해서 s에 EMMA를 입력하면 정상적으로 프로그램이 돌아가지만 Emma Humphrey를 적으면 프로그램이 동작하지 않는다.(프로그램이 멈추거나 세그멘테이션 오류 발생) 그 이유는 문자열 s의 길이를 5로 제한해뒀기 때문이다 (사실 널 문자를 빼면 입력할 수 있는 문제는 딱 4글자임)
scanf는 에러 확인을 하지 못한다. 즉, scanf("%i", &a)
로 정수형을 입력받아야하는데, 사용자가 문자를 입력했다면 에러가 나고 프로그램이 멈춘다. 따라서 scanf를 사용할 때는 사용자가 제대로 된 정보를 입력했는지 에러를 확인하는 과정을 거쳐야 한다.
#include <stdio.h>
#include <cs50.h>
#include <string.h>
int main(void)
{
FIlE *file = fopen("phonebook2.csv", "a"); //fopen() 참고
char *name = get_string("Name: ");
char *number = get_string("Number: ");
fprintf(file, "%s,%s\n", name, number); //fprintf() 참고
fclose(file); //fclose() 참고
}
FIlE *file = fopen("phonebook2.csv", "a");
1. FILE이라는 새로운 자료형을 가리키는 포인터변수 file을 만들었다.
2. fopen은 첫번째 인자로 열고 싶은 파일 이름, 두 번째 인자로 r(read), w(write), a(append)를 받는다.
3. 우리의 목표는 전화번호부 프로그램을 만들어 사용자로부터 이름과 번호를 입력받아 텍스트 파일에 덧붙이는 것이다.
4. fopen은 해당 파일을 가리키는 포인터를 반환한다.
5. csv: 쉼표로 분리된 값(comma separated variable)으로, 간단한 엑셀이나 Numbers같은 프로그램으로 열 수 있는 파일 형식이다.
파일용 printf로 파일에 출력할 수 있다.
파일을 닫는다.
이 프로그램을 실행한 후 정보를 입력하고 터미널창에 ls
를 입력해보면 phonebook2.csv라는 파일이 새로 생긴 것을 볼 수 있다. 이걸 vscode에서 열어본 후 phonebook2.c를 실행해서 정보를 입력하면 실시간으로 정보가 업데이트되는 것을 볼 수 있다. 이 파일을 다운로드할 수도 있다.
:bulb: get_long, get_float, get_char도 비슷한 방식으로 직접 구현할 수 있을까요?
scanf("형식지정자", &변수명)에서 형식지정자만 바꿔주면 된다.
long: %ld
float: %f
char: %c
:pushpin: 핵심 단어: JPEG, fread
:pushpin: 학습 전 복습해야 할 내용: 3단원 8. 명령행 인자
// 파일이름: jpeg.c
include <stdio.h>
int main(int argc, char *argv[])
{
//Ensure user ran program with two words
if (argc != 2)
{
return 1;
}
// Open file
FILE *file = fopen(argv[1], "r");
if (file == NULL)
{
return 1;
}
// Read 3 bytes(24bits) 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 its JPEG file\n"); //JPEG파일이기 위한 조건이 더 있는 것 같음(..?)
}
else
{
printf("No");
}
}
에러체크: 사용자가 프로그램의 이름(./jpeg) 말고 파일명도 입력하길 바람
FILE *file = fopen(argv[1], "r");
./jpeg 파일경로/cat.jpg
-> 파일명(파일경로/cat.jpg
)은 두번째 인자이므로 argv[1]이다.if (file == NULL)
: 에러체크. fopen, malloc, get_string과 같은 함수는 에러가 생기면 NULL이라는 값을 돌려줌. 여기서 문제가 생기면 1을 반환하고 프로그램 종료unsigned char bytes[3];
unsigned char
: -128부터 128이 아닌 0부터 255 범위의 값 (원리 설명은 없었음. 코드 동작을 위해 일단 이렇게 쓴다고 했음)fread(bytes, 3, 1, file);
: 함수 인자로 배열, 읽을 바이트 수, 읽을 횟수, 읽을 파일 입력
:bulb: JPEG 외에 다른 파일 형식도 그 형식임을 알려주는 약속이 있을까요?
매직넘버(Magic number)
: 파일 형식을 식별하기 위해 파일 맨 앞에 붙이는 특정 값이다. 물론 16진수로 표현된다. 위키피디아에 검색해보면 파일 확장자별로 매직넘버를 잘 정리해놓았으니 참고.