16진수
컴퓨터과학에서는 숫자를 10진수나 2진수 대신 16진수(Hexadecimal)로 표현하는 경우가 많다.
컴퓨터에서 데이터를 처리하기 위해 16진수를 사용할 때 장점이 있기 때문이다.
16진수와 일상생활에서 우리가 사용하는 10진수와 비교하면 그 차이를 알 수 있다.
16진수를 사용하면 10진수보다 2진수를 간단하게 나타낼 수 있다.
10진수를 16진수로 바꾸어보기
JPG 이미지 파일은 항상 255 216 255 로 시작되고 이것은 10진수이다.
하지만 실제 컴퓨터 내에서는 10진수를 사용하지 않는다.
컴퓨터는 0과 1만을 이해할 수 있기 때문이다.
먼저 255 216 255를 2진수로 나타내보면 위와 같다.
2진수로 모든 데이터를 표현하기에는 너무 길어지므로 16진수로 바꾸어 보면 2^4 은 16이기 때문에 4bits씩 두 덩어리로 나누어 보면 0000 부터 1111까지는 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진수로 변환되기 때문에 정보를 표현하기 매우 유용하다.
메모리 주소
정수형 변수 n에 50이라는 값을 저장하고 출력한다고 생각해 보자.
이 n 이라는 값은 int 타입이므로,
아래 그림과 같이 우리 컴퓨터의 메모리 어딘가에 4바이트 만큼의 자리를 차지하며 저장되어 있을 것이다.
C에서는 변수의 메모리상 주소를 받기 위해 ‘&’이라는 연산자를 사용할 수 있다.
#include <stdio.h>
int main(void)
{
int n = 50;
printf("%p\n", &n);
}
예를 들어, 위와 같은 코드를 실행하면 ‘0x7ffe00b3adbc’와 같은 값을 얻을 수 있고,
이는 변수 n의 16진법으로 표현된 메모리의 주소이다.
반대로 ‘*’를 사용하면 그 메모리 주소에 있는 실제 값을 얻을 수 있다.
#include <stdio.h>
int main(void)
{
int n = 50;
printf("%i\n", *&n);
}
위 코드는 먼저 n의 주소를 얻고, 또 다시 그 주소에 해당하는 값을 얻어와 출력한 것이므로 결국 ‘50’이라는 값이 출력된다.
‘*’ 연산자는 어떤 메모리 주소에 있는 값을 받아오게 해준다.
이 연산자를 이용해서 포인터 역할을 하는 변수를 선언할 수도 있다.
#include <stdio.h>
int main(void)
{
int n = 50;
int *p = &n;
printf("%p\n", p);
printf("%i\n", *p);
}
위 코드를 보면 정수형 변수 n에는 50이라는 값이 저장되어 있다.
그리고 *p라는 포인터 변수에 &n 이라는 값, 즉 변수 n의 주소를 저장한다.
int *p 에서 p앞의 *는 이 변수가 포인터라는 의미이고, int 는 이 포인터가 int 타입의 변수를 가리킨다는 의미이다.
따라서 첫 번째 printf문과 같이 포인터 p의 값, 즉 변수 n의 주소를 출력하거나, 두 번째 printft문과 같이 포인터 p가 가리키는 변수의 값, 즉 변수 n의 값을 출력할 수도 있다.
실제 컴퓨터 메모리에서 변수 p는 아래와 같이 저장될 수 있다.
하지만 아래 그림과 같이 실제로 p의 값, 즉 n의 주소값을 생각하지 않고, 추상적으로 단지 p가 n을 가리키고 있다는 것만 생각해도 된다.
이런 포인터를 기반으로 해서 다양한 데이터 구조를 정의하고 사용할 수 있다.
나는 CS50의 컴퓨터 과학 수업을 들으면서
문자열을 저장할 때 제공되는 CS50 라이브러리의 string 자료형을 사용했다.
아래와 같이 s에 “EMMA”라는 값을 저장한다고 생각해 보자.
string s = “EMMA”;
문자열은 결국 문자의 배열이고, s[0], s[1], s[2], … 와 같이 하나의 문자가 배열의 한 부분을 나타낸다.
가장 마지막의 \0은 0으로 이루어진 바이트로, 문자열의 끝을 표시하는 약속이다.
여기서 변수 s는 결국 이러한 문자열을 가리키는 포인터가 된다.
더 상세히는 문자열의 가장 첫번째 문자, 즉 주소 0x123에 있는 s[0]를 가리키게 된다.
실제 CS50 라이브러리를 보면 string 자료형은 아래와 같이 정의되어 있다.
typedef char *string
여기서 typedef는 새로운 자료형을, char *은 문자에 대한 포인터를, string은 자료형의 이름을 의미한다.
따라서 아래 두 코드는 동일하게 동작할 것이다.
- string 자료형을 이용하여 “EMMA” 출력
#include <cs50.h> #include <stdio.h> int main(void) { string s = "EMMA"; printf("%s\n", s); }
- char 포인터를 이용하여 “EMMA” 출력
#include <stdio.h> int main(void) { char *s = "EMMA"; printf("%s\n", s); }
2번 코드의 char *s에서 s라는 변수는 문자에 대한 포인터가 되고, “EMMA”라는 문자열의 가장 첫 번째 값을 저장하기 때문이다.
#include <stdio.h>
int main(void)
{
char *s = "EMMA";
printf("%p\n", s);
}
위 코드를 실행하면, s라는 포인터의 값, 즉 “EMMA”라는 문자열의 가장 첫 값인 “E”에 해당하는 메모리 주소를 출력하게 된다.
그렇다면 아래 코드들은 무엇을 출력할까?
printf("%p\n", &s[0]);
printf("%p\n", &s[1]);
printf("%p\n", &s[2]);
printf("%p\n", &s[3]);
s가 가리키는 곳을 시작으로 “EMMA”라는 문자열로 이루어진 문자들의 배열이 있으니, 각각
s라는 문자열의 첫 번째 문자에 해당하는 주소값,
s라는 문자열의 두 번째 문자에 해당하는 주소값,
s라는 문자열의 세 번째 문자에 해당하는 주소값,
s라는 문자열의 네 번째 문자에 해당하는 주소값을 출력하게 된다.
이를 좀 더 자세히 들여다보면 &s[0]는 “E”의 주소값을, &s[1]은 “M”의 주소값을, &s[2]은 “M”의 주소값을,
마지막으로 &s[3]은 “A”의 주소값을 의미한다.
문자열은 첫번째 문자를 시작으로 메모리상에서 바로 옆에 저장되어 있다.
다시 말해, 가장 첫 번째 문자에 해당하는 주소값을 하나씩 증가시키면 바로 옆에 있는 문자의 값을 출력할 수 있는 것이다.
따라서 아래 코드는 E M M A를 순서대로 출력할 것이다.
printf("%c\n", *s);
printf("%c\n", *(s+1));
printf("%c\n", *(s+2));
printf("%c\n", *(s+3));
문자열을 비교할 때도 아래 코드와 같이 문자열이 저장된 변수를 바로 비교하게 되면 그 변수가 저장되어 있는 주소가 다르기 때문에 다르다는 결과가 나올 것이다.
정확한 비교를 위해서는 실제 문자열이 저장되어 있는 곳으로 이동하여, 각 문자를 하나하나씩 비교해야 한다.
#include <cs50.h>
#include <stdio.h>
int main(void)
{
// 사용자로부터 s와 t 두 개의 문자열 입력받아 저장
string s = get_string("s: ");
string t = get_string("t: ");
// 두 문자열을 비교 (각 문자들을 비교)
if (s == t)
{
printf("Same\n");
}
else
{
printf("Different\n");
}
}
문자열을 복사하기 위해 아래 코드를 실행하면...
#include <cs50.h>
#include <ctype.h>
#include <stdio.h>
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);
}
먼저, 사용자에게 입력값을 받아 string s에 저장하고, string t를 s로 정의한다.
그리고 t의 첫 번째 문자를 toupper 함수를 이용하여 대문자로 바꾼다면 s와 t는 각각 어떻게 출력 될까?
입력값으로 “emma”를 주게 된다면, 단순한 예상과는 다르게 s와 t 모두 “Emma”라고 출력이 된다.
그 이유는 s라는 변수에는 “emma”라는 문자열이 아닌 그 문자열이 있는 메모리의 주소가 저장되기 때문이다.
string s 는 char *s 와 동일한 의미라는걸 떠올려보면 된다.
따라서 t도 s와 동일한 주소를 가리키고 있고, t를 통한 수정은 s에도 그대로 반영이 되게 되는 것!
그렇다면 두 문자열을 실제로 메모리상에서 복사하려면 어떻게 해야 할까?
아래 코드와 같이 메모리 할당 함수를 사용하면 된다.
#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);
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 이라는 함수는 정해진 크기 만큼 메모리를 할당하는 함수이다.
즉 s 문자열의 길이에 널 종단 문자(\0)에 해당하는 1을 더한 만큼 메모리를 할당한다.
그리고 루프를 돌면서 s 문자열 배열에 있는 문자 하나 하나를 t 배열에 복사해주면 된다.
이 코드를 컴파일 후 실행시키고 입력값으로 “emma”를 주면 우리가 예상한 대로 s는 “emma”가, t는 “Emma”가 출력되게 된다.
성공적으로 복사가 된 것을 확인할 수 있다.
malloc 함수를 이용하여 메모리를 할당한 후에는 free라는 함수를 이용하여 메모리를 해제해줘야 한다.
그렇지 않은 경우 메모리에 저장한 값은 쓰레기 값으로 남게 되어 메모리 용량의 낭비가 발생하게 되기 때문이다.
이러한 현상을 ‘메모리 누수’라고 일컫는다.
valgrind 라는 프로그램을 사용하면 우리가 작성한 코드에서 메모리와 관련된 문제가 있는지를 쉽게 확인할 수 있다.
help50 valgrind ./filename
와 같은 명령어를 사용하면 filename 파일에 대한 valgrind의 검사 내용을 쉽게 확인할 수 있다.
아래와 같은 코드가 있다고 생각해보자.
#include <stdlib.h>
void f(void)
{
int *x = malloc(10 * sizeof(int));
x[10] = 0;
}
int main(void)
{
f();
return 0;
}
f 함수를 살펴보면 먼저 포인터 x에는 int형의 사이즈(4바이트)에 10배에 해당하는 크기의 메모리, 즉 40바이트를 할당한다.
그리고 x의 10번째 값으로 0을 할당한다.
그리고 main 함수에서 f를 실행하게 되는데,
이 코드를 valgrind 로 검사해보면 버퍼 오버플로우와 메모리 누수 두 가지 에러를 확인할 수 있다.
먼저 버퍼 오버플로우는 x[10] = 0; 코드로 인해 발생한다.
우리는 10개의 int형의 배열을 만들었는데 배열의 인덱스가 0부터 시작한다는 점을 감안하면 인덱스 10은 11번째 인덱스에 접근하겠다는 의미이고, 이는 정의되지 않은 것이기 때문에 버퍼 오버플로우가 발생하는 것이다.
따라서 이 오류는 0에서 9 사이의 인덱스를 사용하면 해결할 수 있다.
또한 메모리 누수는 x라는 포인터를 통해 할당한 메모리를 해제하기 위해 free(x) 라는 코드를 추가해줌으로써 해결할 수 있다.
'공부 STUDY > CS' 카테고리의 다른 글
CS | 자료구조(1) - malloc 과 포인터 복습, 배열의 크기 조정, 연결 리스트 [도입, 코딩] (0) | 2022.06.29 |
---|---|
CS50 | 메모리(2) - 메모리 교환, 스택, 힙, 파일 쓰기/ 읽기 (0) | 2022.06.28 |
CS50 | 알고리즘 - 버블 정렬, 선택 정렬, 정렬 알고리즘의 실행 시간 (0) | 2022.06.26 |
CS50 | 알고리즘 - 검색 알고리즘, 알고리즘 표기법, 선형 검색 (0) | 2022.06.25 |
CS50 | 배열 Array (2) - 배열, 문자열과 배열, 문자열의 활용, 명령행 인자 (0) | 2022.06.23 |