[C] 메모리와 포인터
🔊 해당 포스팅은 인프런의 널널한 개발자님의 독하게 시작하는 C 프로그래밍 강의를 듣고 개인적인 복습 목적 하에 작성된 글입니다. 해당 포스팅에 사용된 모든 자료는 필자가 직접 재구성하였음을 알립니다.
이번 포스팅에서는 드디어 C언어 학습의 고비(?)인 메모리와 포인터에 대해 배워보도록 하자.
1. 여기서 메모리는 '가상 메모리'이다.
앞으로 언급할 '메모리'라는 것은 모두 가상 메모리(Virtual Memory)를 의미한다. 예전 OS 관련 포스팅에서 가상 메모리에 대해 배웠던 적이 있다. 가상 메모리가 등장한 이유를 알아보려면 사실 옛날로 돌아가야 하는데, 관련해서 널널한 개발자님이 본인 유튜브에 공개한 영상이 있어서 10분 남짓되는 영상이니 꼭 한번 시청해보고 글을 읽는 것을 제안한다.
영상을 보았다고 가정하고, 가상 메모리는 생성되는 프로세스 1개당 1개의 가상 메모리가 부여된다. 그리고 만약 32bit 운영체제의 컴퓨터라면 4GB 크기의 메모리를 운용할 수 있기 때문에 하나의 프로세스 당 부여되는 가상 메모리는 총 4GB의 용량을 부여받는다. 엇 그렇다면 컴퓨터에서 돌아가는 프로세스 개수가 1,2개도 아니고 수십개가 되는데, 이 수십개의 프로세스가 하나 당 정말 4GB 용량의 메모리를 부여 받는 것일까?
엄밀히 말하면 아니다. 물론 4GB 용량의 메모리가 하나의 프로세스에 부여된다 하더라도 수십개의 프로세스들 중에 정말 4GB 메모리를 100% 알차게 사용하고 있는 것은 거의 드물 것이다. 막말로 웹 브라우저에 Hello World 문자열을 출력하는 기능을 하는 웹서버를 실행하고 있는 프로세스가 있다고 해보자. 해당 프로세스가 4GB 용량의 메모리를 다 사용할까? 아마 10%도 사용하지 않을 것이다.
따라서 OS가 겉보기에는 각 프로세스 당 4GB 용량의 메모리를 할당한 듯 보이지만 실제로는 존재하지도 않은 양의 메모리가 있다고 하는 것이다. 정확히는 수십 개의 프로세스가 4GB 용량의 메모리를 나누어 가지는 것이다. 만약 메모리를 많이 잡아먹는 프로세스가 소수 있다라고 가정해서 4GB 용량의 메모리를 초과하게 되면 그 때는 메모리 스왑(Swap)이 일어나 부족한 메모리 공간을 SDD나 하드디스크를 활용하게 된다. 메모리 스왑에 대한 설명은 이 포스팅을 참고하도록 하자.
2. 가상 메모리의 구성 요소
위에서 가상 메모리에 대한 개념을 알아보았으니, 이제 가상 메모리가 어떤 요소로 구성되어 있는지 살펴보도록 하자. 아래 그림을 보자.
가상 메모리가 4GB 용량이라고 가정했을 때, OS의 User 모드와 Kernel 모드에 각각 2GB 씩 나누어 갖는다. 그리고 User 모드에 할당된 2GB 중 초록색으로 칠해진 0.2GB 정도는 OS가 사용하는 메모리 공간이며, 그 나머지인 약 1.8GB 되는 공간이 우리가 앞으로 배울 가상 메모리의 구성요소들이다.
먼저 가상 메모리는 크게 데이터와 실행 코드로 구성된다. 그리고 데이터는 Stack(스택 자료구조를 갖는) 메모리와 Heap 메모리 영역이 존재한다. Stack 메모리에는 지역변수인 자동변수가 저장이 되며 보통 1MB 용량 밖에 되지 않는다. 이렇게 적은 용량인 이유는 Stack 메모리에서 변수가 생겼다 없어졌다를 계속 반복하기 때문이다. 일례로 호출된 함수 내에서 지역변수가 할당되었을 때에는 Stack 메모리에 저장이 되지만, 함수가 종료되면 Stack 메모리에 저장되었던 변수가 없어진다.
반면에 Heap 메모리 영역은 동적 할당 메모리라고도 한다. 왜 동적 할당일까? 예를 들어, 어떤 프로그램이 있는데 사용자의 입력을 받을 수 있는 프로그램이라고 해보자. 어떤 사용자는 해당 프로그램에 1KB 짜리의 데이터를 넣을 수도 있다. 하지만 다른 사용자는 10MB, 100MB 라는 상대적으로 훨씬 크기가 큰 데이터를 넣을 수도 있다. 그런데 이 사용자의 입력이 1KB일지, 10MB일지는 소스코드의 '런타임' 때 결정이 될 수 밖에 없다. 따라서 평소에 1KB 크기의 데이터만 넣는 사용자만 존재하다가, 갑작스레 10MB 크기의 데이터를 넣는 사용자가 등장했을 경우 즉, 런타임에 메모리가 더 필요할 경우 해당 프로그램을 실행하는 프로세스는 운영체제에게 추가 메모리를 요청하고 할당을 받게 된다. 결국, 런타임에 필요할 때 마다 그때 그때 메모리를 추가로 할당받는다 라고 하여 '동적 할당 메모리' 라고 부르는 것이다. 동적 메모리를 할당받는 함수로는 대표적으로 malloc(), calloc() 등이 있는데, 이에 대해서는 하단에서 배우도록 하자.
다음은 실행코드 영역이다. 실행 코드에는 크게 2가지 영역이 존재하는데, 하나는 말 그대로 실행 코드 그 자체 즉, 기계어가 담겨 있는 텍스트 영역이다. 나머지 영역은 데이터 영역이다. 데이터 영역에는 또 크게 읽기만 가능한 것이 있고, 읽기/쓰기가 모두 가능한 것이 있다. 읽기만 가능한 것은 대표적으로 문자열 상수가 담긴다. 읽기/쓰기가 모두 가능한 영역은 정적 메모리라고 불리며 이곳엔 전역 변수가 저장이 된다.
3. 포인터 변수
가상 메모리와 가상 메모리의 구성요소를 이론적으로 알아보았다. 이제 그러면 메모리 주소를 담는 변수인 포인터 변수에 대해 알아보도록 하자. 우선 64bit의 운영체제 컴퓨터에서는 기본적으로 포인터 변수는 8바이트 즉 64비트의 크기로 할당된다.
그러면 포인터 변수를 정의하는 법을 알아보기 위해, 문자 'A'에 대한 포인터 변수를 생성하고 코드를 디버그 모드로 실행해서 메모리를 직접 관찰해보도록 하자. 일단 소스코드는 아래와 같다.
#include <stdio.h>
int main(void) {
char ch = 'A';
char* pCh = &ch;
printf("%p\n", pCh);
return 0;
}
ch 라는 문자 변수에 'A' 라는 값을 할당했고, pCh 라는 포인터 변수를 선언했다. 포인터 변수를 선언할 때는 포인터 변수가 가리키는 데이터가 어떤 유형인지 정의하고 asterisk(*)를 붙여 정의한다. 이렇게 선언하는 방식을 간접 지정 방식이라고 한다.(참고로 직접 지정 방식은 말 그대로 메모리 주소를 직접 정의하는 방식이다.) 위 예시에서는 pCh 라는 포인터 변수가 ch 라는 문자 변수의 메모리 주소(&) 값으로 정의되었다. 그러므로 pCh 라는 포인터 변수에 저장된 메모리 주소를 따라가면 'A' 라는 문자가 있다는 것을 의미하고, 이렇게 "메모리 주소를 따라가 보면 나오는 데이터의 유형은 문자야" 라는 것을 나타내기 위해 asterisk(*) 앞에 char 가 붙은 것이다. 이를 '문자 포인터 변수' 라고도 부른다.
자 그러면 실제로 pCh 라는 문자 포인터 변수를 따라가면 정말 'A' 문자가 담겨있는지 확인하기 위해'A'가 존재하는 메모리 주소가 담겨있는지 살펴볼 차례다. 아래 화면처럼 break point 를 수행해서 디버그 모드를 실행해보았다.
위 그림은 'A' 라는 문자가 담긴 메모리 주소를 확인한 것이다. 빨간색 네모칸을 보면 &ch 라고 해서 ch 변수의 메모리 주소를 검색했더니 옆에 0x000000016f07b0db 라는 주소가 등장했다. 이 문자를 잘 기억해두자.
그리고 다음 break point 로 이동하기 전에 아직은 정의하지 않은 pCh 라는 문자 포인터 변수의 메모리 주소도 한번 살펴보기 위해 &pCh 라고 검색해보았다.
주목할 부분은 pCh 라는 포인터 변수의 메모리 주소보다는 초록색으로 칠해져있는 부분 을 보자. 초록색으로 칠해져있는 부분이 2자리씩 8개 즉, 8바이트 크기를 의미한다. 아까 위에서 포인터 변수가 64비트 크기로 할당된다고 했는데 정말임을 알 수 있다.
아직 pCh 라는 포인터 변수에 값이 할당되지 않은 상태기 때문에 초록색 영역의 값엔 의미 없는 값들이 존재하는 상태다. 이제 다음 break point로 이동을 위해 한 step 실행버튼을 누른 뒤의 화면을 보자.
초록색 영역 내에서 빨간색 영역으로 변경된 부분이 있다. 빨간색 영역의 값을 포함해서 초록색 영역까지 순서대로 읽으면 db b0 07 6f 01 00 00 00 이다. 어딘가 익숙하지 않은가? 아까 위에서 ch 변수의 메모리 주소 즉 &ch 를 수행해서 얻은 주소 값인 0x000000016f07b0db 중 맨 앞에 '0x'는 다음의 숫자가 16진수임을 의미하는 것이니까 빼보자. 그러면 000000016f07b0db 이다. 이 값을 두 자리씩 뛰워보자. 00 00 00 01 6f 07 b0 db 가 된다. 이를 거꾸로 한 것이 위 사진의 연두색 네모칸의 값과 일치하는 것을 볼 수 있다!(이 때, 위 사진에서 연두색 네모칸이 거꾸로 되어 있는 이유는 이전 포스팅에서 배웠던 Little 엔디안 형식이기 때문이다)
따라서 정말로 포인터 변수인 pCh 에는 ch 변수의 메모리 주소가 담겨있는 것을 우리 눈으로 직접 확인할 수 있었다!
4. 포인터와 1차원 배열
좀 더 구체적으로, 1차원 배열에 대한 single 포인터와 1차원 배열은 간접 지정 연산(*)을 활용함으로써 서로 100% 호환이 가능하다. 이게 무슨 말일까? 우선 아래와 같이 정수 5개를 담고 있는 배열이 있고, 그 배열의 메모리 주소를 할당한 정수 포인터 변수를 정의해보자.
#include <stdio.h>
int main(void) {
int nData[5] = { 1, 2, 3, 4, 5 };
int* pnData = nData;
return 0;
}
이 때, 우리는 pnData 라는 포인터 변수에 또 간접지정(*) 연산을 취해줌으로써 마치 1차원 배열처럼 만들 수 있게 된다. 따라서 위의 경우, pnData에 간접지정 연산을 수행해주면 nData 와 같은 정수 자료형을 담고 있는 1차원 배열이 되게 된다. 그래서 아래 소스코드를 실행하면 에러가 발생하지 않고 잘 출력이 된다.
#include <stdio.h>
int main(void) {
int nData[5] = { 1, 2, 3, 4, 5 };
int* pnData = nData;
for (int i = 0; i < 5; ++i) {
printf("%d\n", *pnData);
}
return 0;
}
그런데 위 소스코드 출력을 보면 알겠지만 모두 1만 출력된다. 왜냐하면 pnData 라는 포인터 변수는 현재 nData 즉 배열의 메모리 주소인데, 그 메모리 주소는 배열의 기준 요소의 메모리 주소이다. 따라서 for loop 안에서 *pnData 라고 간접지정 연산을 해주게 되면 결국 배열의 기준 요소만 출력되는 것이다.
그러면 나머지 2,3,4,5를 나오게 하기 위해서는 어떻게 할 수 있을까? 이를 위해서는 포인터 변수에 덧셈, 뺄셈 또는 단항 증가/감소 연산을 수행하는 것이다. 아래처럼 *pnData에 단항 증가 연산자를 후위식 표기법으로 수행하면 nData의 배열과 똑같이 1,2,3,4,5가 출력된다.
#include <stdio.h>
int main(void) {
int nData[5] = { 1, 2, 3, 4, 5 };
int* pnData = nData;
for (int i = 0; i < 5; ++i) {
printf("%d\n", *pnData++); // 덧셈연산으로 바꾸려면 printf("%d\n", *pnData+i)로 해도됨
}
return 0;
}
위 예시에서는 단항 증가 연산을 수행했지만 덧셈을 하는 케이스에 대해서도 알아보자. 아래의 소스코드를 실행했을 때, 각기 어떤 결과가 출력될지 한번 맞춰보자.
#include <stdio.h>
int main(void) {
char* p = "KOREA";
printf("%s\n", p);
printf("%s\n", p+3);
printf("%c\n", *p);
printf("%c\n", *(p+3));
printf("%c\n", *p+3);
return 0;
}
하나씩 풀이해보자. 우선 가장 첫번째 출력은 문자열 포인터 변수 p를 문자열 형식문자 %s로 출력하면 해당 문자열의 끝인 null이 나올때까지 쭉 출력된다. 그러므로 출력은 KOREA가 된다.
두번째는 포인터 변수 p에다가 +3을 했다. 덧셈 연산을 수행한 셈인데, 여기서 +3이 무엇을 의미할까? 우선 p가 무엇이였는지 다시 생각해보자. p에는 메모리 주소가 담겨있었다. 고로 메모리 주소에다가 +3을 하는 셈인데, 메모리 주소에 +3을 하게 되면 '메모리 크기'가 3만큼 커진다는 뜻이다. 위 예시에서는 문자열이기 때문에 문자 하나당 1바이트이기 때문에 +3은 3바이트 메모리를 증가시킨 것이다. 그림으로 표현하면 아래와 같다.
결국 p+3을 문자열 형식문자 %s로 출력하라고 하면 KOREA 문자열에서 기준요소로부터 3만큼 떨어진 문자부터 문자열의 끝인 null이 나올 때까지 출력하라는 것을 의미한다. 따라서 두번째 printf의 출력은 EA가 된다.
그러면 세번째 줄인 *p 는 무엇일까? 아까 위에서 배운 포인터 변수에 간접지정 연산(*)을 수행한 것이다. p에는 KOREA 중 기준요소인 K 라는 문자의 메모리 주소가 담겨있고 이에 간접지정 연산을 했기 때문에 결국 문자가 된다. 따라서 출력 결과는 K가 된다.
네번째 줄인 *(p+3)은 뭘까? 방금 포인터 변수 p+3 에 대해서 알아보았다. 즉, p+3은 KOREA의 기준요소로부터 3번째 떨어진 요소를 의미하는데, 여기다가 간접 연산을 지정하니 문자가 된다. 그리고 형식문자를 %c인 문자로 출력하라고 했으니 출력 결과는 E가 된다.
마지막 줄인 *p+3이다. 위에서 배운 *p는 결과가 K라고 했다. K+3 이 뭘까? 이는 ASCII 코드와 연결지어야 한다. K는 인간이 보기에 문자이지만 컴퓨터가 보기엔 정수라고 했다. 따라서 K의 ASCII 코드표에서 십진수 값에 +3을 더한 뒤 그 십진수에 해당하는 ASCII 코드 문자가 출력된다. ASCII 코드표는 다음과 같다.
문자 K는 십진수로 75이다. 75에 +3을 더하면 78이다. 78의 ASCII 코드 문자는 N이다. 따라서 최종 출력은 N이 된다.
5. 메모리의 동적 할당 및 관리
메모리를 동적 할당 받는 방법에 대해 배워보자. 앞서서 위 목차에서 메모리를 동적으로 할당받아야 하는 상황에 대해서 이야기했다. 즉, 프로그램이 돌아가고 있는 시점 즉, 런타임 시점에 메모리를 할당받는 것은 동적 할당이라고 한다.
메모리를 동적 할당 받을 때는 Heap 메모리 영역을 사용하게 된다. 그리고 이 메모리를 할당받기 위해서는 OS에 요청하는데 이때 반드시 "나 얼마큼 메모리가 필요해" 라고 알려주는 듯이 할당받을 메모리 사이즈를 같이 전달해주어야 한다. 그리고 할당 받은 메모리에는 임의의 값이 들어있다. 물론 이 임의의 값을 0 같은 값으로 초기화시켜도 되나, 메모리의 사이즈가 GB 단위로 커진다면 초기화시키는 과정이 오히려 좋지 않을 수 있다.
참고로 하드웨어 수준에서 RAM 메모리를 관리하는 주체는 CPU인데, 이 때 관리하는 단위가 64KB가 된다. 그런데 이 때 우리가 OS에 메모리를 동적 할당해달라고 요청할 경우, OS는 메모리를 4KB 단위로 떼어서 전달해준다.
이제 메모리를 동적 할당 받는 함수인 malloc() 과 할당 받은 메모리를 OS에 다시 반납하는 free() 함수에 대해 알아보자. malloc은 memory + allocation 의 줄임말로 '말록' 이라고도 한다. malloc() 함수는 할당받은 메모리 주소를 반환한다. 사용법을 이해하기 위해 일단 아래 소스코드를 보자.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int* pList = NULL;
pList = (int*)malloc(sizeof(int) * 3);
pList[0] = 10;
pList[1] = 20;
pList[2] = 30;
for (int i = 0; i < 3; ++i) {
printf("%d\n", pList[i]);
}
free(pList);
return 0;
}
위 소스코드를 아래와 같이 break point를 찍고 디버그 모드로 실행하면서 메모리 뷰를 살펴보자.
소스코드의 5번째 줄의 라인이 아직 실행되지 않은 상태이다. 이 때, 메모리 뷰에 정수 포인터 변수인 pList를 입력해보자. 그러면 사진 속 초록색 영역으로 총 4바이트의 크기가 차지하는 것을 볼 수 있다. 이유는 pList가 '정수' 포인터 변수이기 때문이다. pList 라는 변수에는 ff c3 00 d1 이라는 데이터가 들어있고, 이는 임의로 초기화된 값이다. 그리고 이 데이터가 위치하는 주소값은 사진 속 빨간색 네모칸으로 표시한 0x000000010084beb8 이다. 현재 이 상태를 마치 메모리가 2차원이라고 가정하고 도식화해보면 다음과 같다.
이제 메모리 뷰에 정수 포인터 변수 pList에 주소 연산자(&)를 씌운 후 입력해보자. pList에 주소 연산자를 씌우고 메모리 뷰에 입력하는 행위는 곧 "pList 라는 정수 포인터 변수에 담겨있는 데이터가 위치한 메모리 주소를 알려줘!" 이다. 앞서서 포인터 변수란, 메모리 주소를 담고 있는 변수라고 했다. 다시 말해서, pList 라는 정수 포인터 변수에 담겨 있는 메모리 주소(이게 '데이터')가 위치한 메모리 주소를 알려달라는 것이다. pList에 주소 연산자를 씌운 후 메모리 뷰는 다음과 같다.
주목할 부분은 초록색으로 칠해진 영역이다. 포인터 변수에 담겨있는 데이터인 메모리 주소는 기본적으로 8바이트를 차지한다. 따라서 위 사진을 보면 초록색 영역이 2자리씩 8개를 차지하는 것을 볼 수 있다. 그리고 초록색 영역의 값을 살펴보자. b8 be 84 00 01 00 00 00 이다. 아까 위에서 메모리 뷰에 pList를 입력했을 때의 메모리 주소인 0x000000010084beb8 을 거꾸로 한것과 동일하다. 이를 도식화 하면 아래와 같다.
이제 다음 break point로 한 step 코드를 실행시켜보자.
5번 라인의 코드가 실행되었다. 즉, pList 라는 정수 포인터 변수에 NULL 값이 할당되었다. 메모리 뷰를 보면 여전히 &pList를 검색하고 있고, 변경된 부분이 빨간색으로 표시된다. 모두 00으로 바뀌었다. 왜냐하면 NULL은 정수로 0이고, NULL을 할당했기 때문에 모두 0으로 변경되었다.
그런데 여기서 한 가지 궁금증이 생겼다. 이 순간에 그러면 메모리 뷰에 pList를 입력하면 어떻게 될까? 아래 그림을 보자.
메모리 뷰에 pList를 입력하니까 메모리를 로드할 수 없다는 메세지가 발생했다. 대체 왜 그럴까? 이유는 위에서 pList 라는 포인터 변수에 들어있는 데이터(메모리 주소)가 모두 NULL 즉 0으로 초기화되었는데, 이 0으로 초기화된 메모리는 존재하지 않기 때문이다! 그림으로 도식화하면 아래와 같다.
그러면 이제 다음 break point 로 한 step 실행해보자. 실행된 소스코드는 malloc() 함수로 메모리를 동적할당하는 함수다. 위 소스코드 상으로 int 자료형 3개만큼을 할당받았으니 int 자료형 하나 당 4바이트이기 때문에 총 12바이트를 할당받은 셈이다. malloc() 함수는 메모리 주소를 반환하는데, 이 주소에 간접 지정 연산(*)을 수행하고 int를 명시하여 곧 1차원 배열과 100% 호환이 되도록 설정하였다.
그러면 malloc() 함수로 정말 12바이트의 메모리를 할당 받았는지 보기 위해서 메모리 뷰에 pList 를 입력해보자.
그러면 위 사진에서 초록색 영역으로 표시된 부분을 볼 수 있다. 2자리씩 4개이니까 4바이트 즉, 정수 1개 만큼의 메모리가 표시된다. 이제 10번 라인 코드까지 코드를 실행시켜보고 메모리 뷰가 어떻게 변화하는지 살펴보자.
보면 16진수로 0a(0a는 10진수로 10이다)라는 값이 들어갔고, 다음 4바이트 만큼의 메모리 영역에는 16진수로 14 즉, 십진수로는 20이 들어가 있다. 마지막 4바이트 메모리 영역에는 16진수로 1e이다. 10진수로는 30을 의미한다. 따라서 우리는 malloc() 으로 할당받은 12 바이트의 메모리에 데이터 10, 20, 30을 write 한 것이다.
그리고 printf() 함수가 실행한뒤 free() 함수를 실행시킨 뒤의 break point 까지 코드를 실행시켜보자. free() 함수는 OS로부터 할당받은 메모리를 다시 OS에 반납하는 것이다.
빨간색 영역을 보면 위에서 우리가 10, 20, 30 원소값을 넣었던 것들이 모두 다른 값으로 초기화된 것을 볼 수 있다. 즉, OS에게 이전에 할당받은 메모리를 반납한 것이다.
이렇게 해서 우리는 C언어에서 메모리를 동적으로 할당 받고, 반납하는 과정을 간단하게 실습해보았다.
6. 메모리 초기화, 복사, 비교
C언어에서 메모리 복사는 크게 2가지로 나뉠 수 있다. Python 언어에서도 익숙하게 했듯이 단순 대입 연산자(=)를 사용하는 것이다. 예를 들어, 아래와 같이 말이다.
#include <stdio.h>
int main(void) {
int a = 100, b = 0;
b = a;
printf("a: %p\nb: %p\n", &a ,&b);
return 0;
}
하지만 이렇게 단순 대입 연산자를 사용해서 메모리를 복사하는 것은 배열에서는 불가능하다. 왜냐하면 배열 이름은 메모리 주소이고, 이에 따라 L-value가 될 수 없기 때문이다. 예를 들어, 아래와 같은 코드는 불가능하다.
#include <stdio.h>
int main(void) {
char srcBuffer[5] = { "zedd" };
char dstBuffer[5] = { 0 };
dstBuffer = srcBuffer;
puts(srcBuffer);
puts(dstBuffer);
return 0;
}
빌드하면 아래와 같은 에러가 발생한다. 따라서 배열일 때는 단순대입연산자가 아닌 다른 방식으로 메모리를 복사해야 한다. 6-1, 6-2번에서는 그 방법에 대해 알아보도록 하자.
error: array type 'char[5]' is not assignable
6-1. 메모리 복사 직접 구현 해보기
다음은 메모리를 복사하고 비교하는 방법에 대해서 알아보자. 우선 메모리 복사이다. 사실 메모리 복사하기 위해서 C언어에서 제공되는 함수가 있다. 하지만 해당 함수들이 내부적으로 메모리를 실질적으로 어떻게 복사하는지 구현 동작을 보기 위해서 우리는 마치 직접 메모리를 복사하는 방식의 코드부터 살펴보기로 하자. 아래 소스코드는 문자열 Hello를 담고 있는 메모리를 복사하는 소스코드이다.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char szBuffer[] = { "Hello" };
char* pszBuffer = "Hello";
char* pszData = NULL;
pszData = (char*)malloc(sizeof(char) * 6);
pszData[0] = 'H';
pszData[1] = 'e';
pszData[2] = 'l';
pszData[3] = 'l';
pszData[4] = 'o';
pszData[5] = '\0';
puts(szBuffer);
puts(pszBuffer);
puts(pszData);
free(pszData);
return 0;
}
szBuffer는 문자열 Hello 가 담겨있는 문자 배열이다. 그리고 pszBuffer는 문자열 상수 "Hello"의 메모리 주소를 담고 있는 문자 포인터 변수이다. 마지막으로 pszData는 NULL 이라는 메모리 주소를 담고 있는 문자 포인터 변수이다.(메모리 주소가 NULL이라는 것은 메모리 주소 값이 0임을 의미한다) 이제 우리는 아래처럼 break point를 찍고 디버그 모드로 실행하면서 라인 바이 라인 실행될 때 메모리 도식화가 어떻게 변화하는지 살펴보자.
가장 먼저 메모리 뷰에 szBuffer에 메모리 주소 연산(&)을 실행해서 szBuffer 메모리에 어떤 데이터가 들어있는지 살펴보았다. 메모리 뷰에서 빨간색 영역으로 칠해진 것처럼 Hello 라는 문자열이 들어가있다.(이때, 문자열 마지막에 NULL도 들어가 있어서 빨간색 마지막 부분이 00인 것도 잊지말자) 현재 상태의 메모리를 도식화해보면 아래와 같다.
그러면 이제 다음 break point로 실행시켜보자. 그리고 pszBuffer라는 포인터 변수를 메모리 뷰에 찍어보자.
그러면 빨간색 네모칸에 보면 역시 16진수로 Hello 라는 단어가 들어가 있음을 알 수 있다. 그리고 이 pszBuffer의 메모리 주소는 0x000000010274ffa8 이다. 참고로 pszBuffer 에는 문자열 상수 "Hello" 로 할당했기 때문에 아까 정의한 szBuffer 와는 달리 메모리가 정적 영역에 들어가 있을 것이다. szBuffer는 main 이라는 함수 내 지역변수이기 때문에 스택 메모리에 들어가 있는 차이점에 대해서도 알아두자.
그런 다음 이제 메모리 뷰에 pszBuffer 라는 포인터 변수에 담겨있는 데이터를 보기 위해 pszBuffer에 주소 연산자를 넣어 입력시켜보자.
그러면 메모리 뷰에 빨간색과 녹색 영역으로 된 값을 보자. a8 ff 74 02 01 00 00 00 이다. 이는 아까 위에서 메모리 뷰에 pszBuffer를 입력했을 때 나온 메모리 주소 0x000000010274ffa8 를 거꾸로 한 것과 동일하다. 즉, pszBuffer 라는 포인터 변수에 저장되어 있는 값은 0x000000010274ffa8 주소인 셈이고, 이 주소를 따라가면 "Hello" 라는 문자열이 있다는 뜻이다. 이를 도식화 하면 아래와 같다.
(단, 아래 그림에서는 이해를 위해 "Hello" 라는 문자열 상수가 szBuffer 와 같은 메모리 공간에 있다고 가정)
이제 아래 그림 속 break point 까지 코드를 실행한 뒤 NULL로 초기화한 pszData 라는 문자 포인터 변수를 메모리 뷰에 입력시켜보자. 즉, pszData 라는 포인터 변수에 어떤 데이터가 있는지 보자.
메모리 뷰에 빨간색과 녹색 영역을 보면 모두 0으로 초기화 된 것을 볼 수 있다. 이유는 소스코드에서 pszData 에 NULL을 할당했기 때문이다. 이 부분은 [5번 목차] 에서 살펴본 것과 동일하다. 이 부분도 도식화에 반영하면 아래와 같다.
이제 malloc() 함수로 동적 메모리 할당을 받고 간접 지정 연산을 수행해준다. 이렇게 되면 pszData 라는 싱글 포인터 변수는 1차원 배열과 똑같다고 했다. 메모리 뷰에 pszData 를 입력해보고, 아래 break point까지 코드를 실행시켜보자.
그러면 위 소스코드에서 0번 인덱스부터 5번 인덱스까지 Hello 와 마지막 NULL 문자열까지 집어넣은 것이 메모리 뷰에 모두 반영된 것을 알 수 있다. 그리고 이 메모리의 주소가 0x0000600000538020 인 것을 기억해두자. 그리고 다시 메모리 뷰에 &pszData를 입력해서 pszData 라는 포인터 변수에 어떤 데이터가 남겨있는지 봐보자.
초록색 네모칸 친 부분을 보면 20 80 53 00 00 60 00 00 이다. 이는 방금 메모리 뷰에 pszData를 입력시켰을 때 봤던 메모리 주소 0x0000600000538020 를 거꾸로 한 것과 동일한 것을 알 수 있다. 이를 도식화에 반영해보면 아래와 같다.
위 그림을 보면 결국 Hello 라는 문자열에 대해서 메모리 복사가 이루어진 것이다. 그리고 마지막으로 free() 함수를 이용해서 동적으로 할당받은 메모리를 반납하면 된다.
이것이 메모리 복사를 직접적으로 구현하는 방식이다. 이제 이 귀찮은(?) 방식을 하나의 함수로 해결할 수 있는 것들에 대해 배워보도록 하자.
6-2. 메모리 복사 함수에 맡기기!
메모리를 복사하는 함수에는 memcpy() 라는 것이 존재한다. memcpy 사용법은 인자를 3개 넣는데, memcpy(destination, source, number of byte) 이다. 여기서 source가 복사할 원본, destination이 복사할 대상, 그리고 number of byte는 복사할 바이트 수를 의미한다. 예시 소스코드는 다음과 같다.
#include <stdio.h>
#include <string.h>
int main(void) {
char szBuffer[12] = { "HelloWorld" };
char szNewBuffer[12] = { 0 };
memcpy(szNewBuffer, szBuffer, 4);
puts(szNewBuffer);
memcpy(szNewBuffer, szBuffer, 6);
puts(szNewBuffer);
memcpy(szNewBuffer, szBuffer, sizeof(szBuffer));
puts(szNewBuffer);
return 0;
}
만약 memcpy() 함수를 직접 구현한다고 하면 아래와 같이 구현할 수있다.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char szBuffer[12] = { "HelloWorld" };
char* szNewBuffer = NULL;
szNewBuffer = (char*)malloc(sizeof(szBuffer));
for (int i = 0; i < sizeof(szBuffer); ++i) {
szNewBuffer[i] = szBuffer[i];
}
puts(szBuffer);
puts(szNewBuffer);
return 0;
}
6-3. 메모리 초기화
다음은 메모리를 초기화 하는 방법이다. 초기화 하는 방법에는 크게 2가지가 있다. 첫 번째는 malloc() 함수와 memset() 함수를 사용하는 방법, 두 번째는 첫 번째의 2가지 함수를 한 번에 수행해주는 calloc() 함수를 쓰는 방법이 있다.
무선 malloc() 함수와 memset() 함수를 같이 쓰는 방법을 알아보자. malloc()은 앞서 배웠다 시피 메모리를 동적할당 받는 역할을 하고, memset() 함수는 그 할당받은 메모리의 값을 어떤 값으로 초기화할지 역할을 수행한다. 예시코드는 아래와 같다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
int* pList = NULL;
int aList[3] = { 0 };
pList = (int*)malloc(sizeof(aList));
memset(pList, 0, sizeof(aList));
for (int i = 0; i < 3; ++i) {
printf("%d\n", pList[i]);
}
return 0;
}
memset() 함수에는 3가지 인자가 필요한데, 첫번째는 초기화할 배열, 두번째는 어떤 값으로 초기화할지, 세번째는 해당 배열의 메모리 크기인 바이트 수이다.
두 번째 방법은 calloc() 함수이다. calloc() 함수는 메모리 동적할당과 값을 0으로 초기화하는 것 모두 수행해준다. 단, calloc() 함수는 어떤 값으로 초기화할지는 인자로 넣을 수 없고 무조건 적으로 0으로 초기화 한다. 예시코드는 아래와 같다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
int* pList = NULL;
int aList[3] = { 0 };
pList = (int*)calloc(3, sizeof(int));
for (int i = 0; i < 3; ++i) {
printf("%d\n", pList[i]);
}
return 0;
}
6-4. 메모리 비교
두 개의 메모리가 있을 때 메모리에 있는 값을 비교할 수가 있다. 그리고 메모리를 비교할 때는 memcmp() 함수를 사용해야 한다. 아래 소스코드를 보자.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
char szBuffer[12] = { "HelloZedd" };
char* pszBuffer = "HelloZedd";
printf("%d\n", memcmp(szBuffer, pszBuffer, 10));
return 0;
}
로직은 간단하다. 하나는 szBuffer 라는 문자배열이고, 하나는 pszBuffer 로 문자 포인터변수를 정의했고, 그 포인터 변수가 가리키는 데이터는 HelloZedd 라는 문자열 상수이다. 이 두 개 간의 메모리를 비교해보자. 여기서 "비교"를 한다는 것은 컴퓨터에게 "두 피연산자를 뺄셈"한다는 것이다. 고로, "메모리를 비교" 한다라는 것은 아래 그림처럼 두 메모리에 들어있는 각 데이터 간에 뺄셈을 수행하는 것이다. 여기선 각 요소가 문자이기 때문에 문자 간의 뺄셈은 곧 ASCII 코드표에서 해당하는 정수 값 간의 뺄셈을 의미한다. 그림으로 도식화 하면 아래와 같다.
즉, pszBuffer 와 szBuffer 간에 같은 위치 끼리의 문자를 뺄셈하는 것이다. 만약 문자열이 다르다면 그 만큼 차이의 ASCII 코드 숫자값이 출력된다. 예시코드는 아래와 같다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
char szBuffer[12] = { "HelloZedd" };
char* pszBuffer = "HelloTedd";
printf("%d\n", memcmp(szBuffer, pszBuffer, 10));
return 0;
}
7. 문자열 복사, 비교, 검색
다음으로는 문자열을 복사하고 비교, 검색하는 방법에 대해 알아보자. 문자열은 문자 배열의 줄임말이다. 따라서 위에서 배열일 때와 마찬가지로 포인터를 사용해서 문자열을 복사, 비교, 검색을 해야 한다. 하나씩 알아보자.
7-1. 문자열 복사
우선 예시 코드부터 살펴보자.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
char szBuffer[] = "Hello";
char* pszBuffer = szBuffer;
char* pszHeap = malloc(sizeof(char)*16);
strcpy(pszHeap, pszBuffer);
puts(pszHeap);
free(pszHeap);
return 0;
}
Hello 라는 문자열 상수는 szBuffer 변수에 정의했다. 그리고 pszBuffer 라는 문자 포인터 변수에 szBuffer의 메모리 주소를 정의했다. 그리고 pszHeap 이라는 문자 포인터 변수를 정의했는데, 이 때 malloc() 함수를 이용해서 16 바이트 만큼의 메모리를 동적할당 받았다. 딱 이 순간까지의 상황을 메모리로 도식화해보면 다음과 같다.(아래 그림에서 메모리 주소는 임의로 정했다)
그런 뒤 이제 strcpy() 함수를 사용해서 문자열을 복사하게 된다. stcpy() 함수는 필자가 사용하는 Clang 컴파일러 기준으로 2개의 인자를 받는데, 첫번째는 복사의 destination에 해당하는 포인터 변수, 두번째는 복사의 source에 해당하는 포인터 변수를 넣어준다. strcpy() 라인의 소스코드가 실행된다면 아래와 같이 메모리 도식화에 변경사항이 반영된다.
이렇게 복사되는 것을 깊은 복사(Deep Copy)라고 한다. 즉, 문자열을 복사하되 별도의 메모리가 생성되었기 때문이다. 하지만 만약 위 소스코드에서 strcpy() 함수를 사용하지 않고 단순 대입 연산자를 사용하게 되면 얕은 복사(Shallow Copy)가 수행되게 된다. 얕은 복사를 수행하는 소스코드는 아래와 같다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
char szBuffer[] = "Hello";
char* pszBuffer = szBuffer;
char* pszHeap = malloc(sizeof(char)*16);
pszHeap = pszBuffer; // 얕은 복사 수행
puts(pszHeap);
free(pszHeap);
return 0;
}
얕은 복사를 할 때의 메모리 도식화는 아래와 같다.
7-2. 문자열 비교
다음은 문자열 비교이다. 문자열 비교도 위에서 배운 메모리 비교에서 사용한 memcmp() 함수와 유사하다. 문자열 비교는 strcmp() 함수를 사용한다. 예시 코드부터 살펴보자.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
char szBuffer[12] = { "TestString" };
char* pszData = "TestString";
printf("%d\n", strcmp(szBuffer, pszData));
printf("%d\n", strcmp("TestString", pszData));
printf("%d\n", strcmp("Test", pszData)); // 출력: -83
return 0;
}
strcmp() 함수는 2가지 인자로 받는데, 둘 다 문자열을 가리키는 포인터 변수를 넣거나 문자열 상수를 넣을 수 있다. 이것도 '비교' 연산이기 때문에 컴퓨터는 뺄셈을 이용해서 비교 연산을 한다. 위에서 배운 메모리 비교처럼 말이다. 그래서 위 소스코드에서 printf() 문의 첫번째, 두번째는 두 문자열이 같기 때문에 결과값이 0이 나온다. 마치 같은 수를 서로 뺀 결과처럼 말이다.
반면에 세번째 printf() 문은 결과가 -83이 나온다. 왜냐하면 Test 문자열에서 TestString 문자열을 빼는데 서로 다른 문자열이기 때문에 음수가 나온다. 그렇다면 왜 83인가? 83의 숫자는 두 문자열이 다르기 시작한 최초의 문자인 S의 ASCII 코드 숫자를 의미한다. 여기서 그러면 왜 나머지 tring 문자열에 대해서도 ASCII 코드 숫자는 신경스지 않는가? 라고 생각할 수도 있다. 추측컨데 strcmp() 함수 기능의 목적이 두 문자열이 동일한지 아닌지 여부를 검증하는 것이기 때문에 다르기 시작한 최초 문자가 등장하면 그대로 함수가 종료되는 것 같다.
7-3. 문자열 검색
다음은 문자열 검색이다. 문자열 검색은 strstr() 함수를 사용한다. 예시코드는 아래와 같다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
char szBuffer[32] = { "I am a boy" };
printf("%p\n", szBuffer);
printf("%p\n", strstr(szBuffer, "am"));
printf("%p\n", strstr(szBuffer, "I"));
printf("%p\n", strstr(szBuffer, "girl"));
printf("Index: %d\n", strstr(szBuffer, "am") - szBuffer);
printf("Index: %d\n", strstr(szBuffer, "I") - szBuffer);
printf("Index: %d\n", strstr(szBuffer, "girl") - szBuffer);
return 0;
}
strstr() 함수는 2개를 인자로 받는다. 첫번째는 검색 대상의 문자열이, 두번째는 검색 키워드의 문자열을 넣는다. 여기서 strtsr() 함수는 검색 키워드의 문자열이 시작하는 메모리 주소를 반환한다. 인덱스가 아님에 주의하자. 만약 인덱스를 출력하려면 메모리 주소의 뺄셈(-) 연산을 활용하면 된다. 즉, strstr() 함수가 반환한 메모리 주소에다가 검색 대상 문자열의 메모리 주소를 빼면 된다. 이게 가능한 이유는 [목차 4번]에서 배운 내용과 동일하니 별도 설명은 생략하겠다.