[C언어 강좌] #18 동적 메모리 할당과 가변 인자


안녕하세요? 파일입니다. 어김없이 또 C언어 학습에 있어 새로운 챕터가 시작되었군요.

이번 챕터와 다음 챕터를 마치게 되면 제 C언어 강좌는 끝을 내게 됩니다.

지금 강의는 31편째지만 이 많은 글들을 제가 작성했다는게 참 대단하고 가슴이 웅장해집니다.

 

마지막에 가까워졌다는게 느껴지실까요?

 

오늘은 동적 메모리 할당과 가변 인자에 대해 배워봅시다.

 


프로그램에서 사용되는 메모리에는 정적 메모리 동적 메모리가 있습니다. 지금부터 본문에서 사용하는 용어인 메모리는 RAM을 지칭하는 것 입니다. 스택 영역, 데이터 영역 같은 정적 메모리는 메모리가 언제 할당되고 해제될지 그리고 요구되는 메모리의 크기가 컴파일할 때 결정되는 메모리 입니다.

 

그러나 정적 메모리는 프로그램 실행 시간(런타임) 중에 메모리의 크기를 변경하는게 불가능합니다. 

이러한 문제점을 해결 하기 위해 힙 영역 같은 동적 메모리가 요구됩니다. 따라서 이번장에서는 힙 영역에 메모리를 할당하고 해제하는 동적 메모리 할당 함수와 해제 함수를 함께 공부해보겠습니다.

 

int a = 0;
printf("문자의 갯수를 입력해주세요 : ");
scanf("%d", &a);

char string[a]; //Error

 

 

위 예제같은거 한번쯤 생각해보지 않으셨나요? 

 

문자열을 배울때 char 배열로 문자열을 만들때 예를 들어서 저장할 문자열이 10글자인데 배열크기가 char string[5] 정도밖에 되지 않으면 문자열이 다 들어가지 못하고 짤려버리죠.

 

그래서 넉넉하게 100글자 만들어줬더니 저장할 배열의 크기가 100글자를 훨씬 초과해버린다면? 아니면 10글자만 들어왔는데 100바이트나 공간을 만든다는건 메모리 낭비죠.

 

포인터 문자열을 생각할 수 있겠지만 일단 포인터로 가리킨 메모리 공간(문자열)은 읽기 전용이라 읽는거만 되고 쓰는건 안되고.. 그래서 생각을 좀 해본결과 생각해본게 위 예제란것이죠.

 

문자열의 갯수를 입력받아서 배열 크기를 변수크기로 할당하는 겁니다.

그런데 저 소스코드가 잘 작동하던가요? 오류가 발생할겁니다.

 

일반적으로 C와 C++에서는 배열의 크기를 컴파일 시간에 결정합니다. 따라서 배열 크기는 일반 변수로 정할 수 없으며 컴파일 타임 상수가 되어야 합니다.

 

그런데 동적할당을 배우면 위와 같은 가변적인 배열 크기를 구현가능합니다. (물론 위와 같이 구현하지는 않습니다.)

사실 동적할당을 배우는 가장 큰 목적도 저 배열 크기를 프로그램 실행 중에 마음대로 잡아내기 위함이죠.

 

한번 위 소스를 컴파일 해보겠습니다.

 

 

?? 컴파일이 되네요

 

어라.. 이게 어떻게 된걸까요?

분명 안된다고 했는데..

 

똑같은 코드를 한번 Visual Studio 에서 실행시켜 봅시다.

 

이녀석은 오류를 발생시키네요.

우리가 알던대로 상수값이 있어야 한다고 합니다.

 

단순히 이러한 문제는 컴파일러의 문제일까요? 뭐 솔직히 말하면 컴파일러 문제가 맞긴 합니다만..

원래 상식적으로 배열 크기에 상수값 이외에 다른 값이 들어가면 안됩니다.

 

그런데 C99 표준부터 VLA 라는 기능이 추가되어서 C99 이상의 성능을 가진 컴파일러라면 VLA 사용이 가능합니다. (gcc, clang은 지원하나 msvc는 지원하지 않습니다.)

 

VLA가 뭐냐 하면 위처럼 배열 크기에 변수를 적을 수 있는걸 말합니다.

 

우리가 지금 배울 동적할당을 이용하면 프로그램 실행 시간(런타임) 중에 배열 크기를 결정할 수 있습니다만 힙 공간이라는 별도의 메모리 공간에 우리가 관리해줘야 하고 메모리 사용을 다 했으면 소멸시켜줘야 하는 번거로움이 있습니다.

그런데 VLA는 메모리 공간을 관리해줄 필요도 없고 알아서 소멸됩니다. 

 

??? : 그러면 동적 할당은 배울필요가 없나요? 동적할당을 가장 배우는 큰 목적이 가변적인 배열크기 때문이라면서요. 그냥 VLA쓰면 되는거 아닌가요

 

지금까지 설명을 들어보면 이런 의문이 들 수 있습니다. 동적 할당을 배우기전에 VLA란걸 아니 뭔가 배울필요가 없어지는 느낌. 하지만 VLA를 사용하는건 그렇게 좋은 생각이 아닙니다.

 

왜냐면 VLA는 동적할당이 힙 공간에 메모리를 잡는데 반면에 스택 공간에 메모리를 잡아서 Stack OverFlow라는 중대한 문제가 발생할 수 있고 일부 컴파일러에선 지원하지 않으며, 심지어 스탠다드 C++에선 이 기능을 버렸습니다.

 

대부분 개발자들이 장점에 비해 단점이커서 잘 사용하지 않으려는 분위기입니다. 다 같이 쓰지 않는데 자기혼자만 써서 코드를 공유하면, 다른 사람이 이 코드를 받았을때 실행되지 않는 등의 문제가 발생할 수 있습니다.

 

결론 : VLA를 사용하지 않고 동적할당을 하는 것이 일반적이다.

 

지금 글을 쓰면서 스택 공간, 힙 공간 등에 대해 따로 설명하지 않고 설명드렸습니다만, VLA말고 동적할당을 써야 하는 이유를 설명드리다보니 미리 배우지 않은 용어를 사용하게 되었네요.

 

그러나 걱정하지마세요. 이제 여기서 나온 용어들에 대해 차근 차근 알아볼거니깐요!

 

동적 메모리 할당

프로세스 메모리 구조

 

프로그램은 기본적으로 하드 디스크, SSD 같은 저장매체에 잠들어 있다가 *.exe 와 같은 실행 파일 형식을 실행시키면 생명을 얻어 저장매체에서 메모리(RAM) 로 데이터를 불러들이게 되고 실행되게 됩니다.

(데이터를 불러오는 이 과정을 우리는 흔히 로딩이라고 표현합니다.)

 

CPU는 이 RAM에서 데이터를 읽어들이게 되구요.

HDD, SSD에서 프로그램을 바로 실행하지 않고 RAM으로 불러와서 실행하는 이유는 HDD,SDD가 CPU에 비해 너무나도 느리기 때문입니다. 반면에 RAM은 HDD, SSD와 비교도 되지 않게 빠르죠.

 

HDD,SSD <-> CPU 간의 속도 격차를 완충하기 위해 중간제로 램이 존재하게 되는겁니다.

 

속도 차이를 예로 들자면 요즘 가장 빠른 저장매체라고 볼 수 있는 nvme ssd 의 경우 최상위 모델이 읽기 속도가 5~6000MB/S 라면

램은 듀얼 채널 구성을 하면 가볍게 40000~50000MB/S를 상외하는 성능을 가지고 있습니다.

(단순 수치 비교만해도 6배가 넘는 차이가 납니다.)

 

이렇게 실행 중인 프로그램을 프로세스라고 부르는데, 위와 같이 작업 관리자에서 프로세스를 확인해볼 수 있습니다.

 

 

프로그램이 실행되서 RAM(메모리)에 담기고 그 안에서 변수의 할당, 수정 등이 이루어진다고 했는데 메모리안에 어떤식으로 저장되고 있는지 궁금하진 않으셨나요?

 

프로세스의 메모리 공간은 위 그림과 같이 코드 영역, 스택 영역, 데이터 영역, 힙 영역 네 부분으로 나누어져 있습니다.

 

  • 코드 영역 : 프로그램 실행 코드 또는 함수들이 저장되는 영역입니다.
  • 스택 영역 : 함수 호출에 의한 매개 변수와 지역 변수, 그리고 함수, 반복문, 조건문 등 중괄호 내부에 정의된 변수들이 저장되는 영역으로 잠깐 사용하고 메모리에서 소멸시킬 데이터가 저장되는 영역입니다.
  • 데이터 영역 : 전역 변수와 정적 변수들이 저장되는 영역으로 프로그램이 종료될 때까지 유지되어야 하는 데이터가 저장되는 영역입니다.
  • 힙 영역 : 프로그램이 실행되는 동안에 프로그래머가 동적으로 메모리를 할당할 수 있는 영역으로, 프로그래머가 마음대로 사용할 수 있는 영역입니다. (대신 프로그래머의 관리가 필요함.)

 

지금까진 프로세스(실행중인 프로그램)의 메모리 구조에 대해 몰랐지만 이제 또 한번 시각이 넓어지게 되었습니다.

 

만약에 전역변수로 int a = 10; 을 만든다면 해당 값은 데이터 영역에 저장되는 것이고,

지역 변수 int b = 3; 을 만든다면 스택 영역에 저장되게 되는거겠죠!

코드 영역은 프로그램 실행 코드가 들어있는 곳이고, 힙 영역은 오늘 배울 동적할당을 할때 사용하는 메모리 영역입니다.

 

*참고 : 요 스택 영역이 꽉차서 스택 메모리가 너무 커져버리면 다른 메모리를 침범하게 되는 Stack Overflow라는 문제가 발생합니다. 이 이름을 따서 만든 유명 프로그래밍 커뮤니티인 Stack Overflow가 있죠.

 

예제를 가지고 각 변수들이 어떤 영역에 저장되어 있는지 확인해보겠습니다.

 

#include <stdio.h>

int a = 10; //전역 변수 a
 
int main() {
	int num1 = 10, num2 = 20; //지역 변수 num1, num2
	static int s=20; //정적 변수 s
	
	printf("코드 영역 : %x %x \n", main, printf); //함수 이름
	printf("스택 영역 : %x %x \n", &num1, &num2); //지역 변수
	printf("데이터 영역 : %x %x \n", &a, &s);	
	return 0;
}


코드 영역 : 401530 402b68
스택 영역 : 62fe1c 62fe18
데이터 영역 : 403010 403014

--------------------------------
Process exited after 0.004691 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .

 

위 소스에서 전역변수 a는 데이터 영역에 저장됩니다.

 

또 6행에서 선언된 지역 변수 num1, num2는 스택 영역에 저장됩니다.

그 다음줄에서 선언된 정적변수 s는 데이터 영역에 저장됩니다.

 

또 16진수로 함수의 이름을 출력하고 있는데 함수의 이름은 코드 영역에 나타냅니다.

 

(함수의 이름은 메모리 주소와 같은데 함수를 호출할때 그 이름이 주소기 때문에 함수가 호출되면 그 주소로 찾아가서 명령어를 실행하는 것입니다. 배우진 않았으나 이 특성을 이용해 함수를 가리킬 수 있는 함수 포인터란것도 있습니다. 참고차 알아두세요.)

 

지역변수 num1, num2의 주소도 출력하고 있으며 아까도 말했듯이 지역 변수는 스택영역에 나타냅니다. 마지막으로 전역 변수, 정적변수의 주소를 출력하고 있는데 이들은 데이터 영역에 저장됩니다.

 

위에 나오는 주소는 운영체제, 실행환경마다 상이하기 때문에 실행할때마다 다른값이 나와서 크게 신경은 안쓰셔도 됩니다만, 확실히 각기 주소의 영역이 다른게 보입니다.

코드 영역, 데이터 영역은 비슷하게 보이는데 스택 영역은 확실히 다른곳에 있네요.

 

지금 위에서 배운 프로세스 메모리 공간 4개 중 코드, 스택, 데이터 영역에 대해 예제로 알아봤습니다만 그럼 힙 영역은 어떨까요? 힙 영역은 언급했듯이 프로그래머가 동적으로 메모리를 할당할 수 있는 영역입니다. 프로그래머가 자율적으로 사용할 수 있는 공간이죠.

 

동적 메모리 할당의 필요성

동적 메모리 할당이 왜 필요한지는 아까도 설명을 드렸습니다.

가장 큰 이유는 지역변수로 선언한 배열은 크기가 무조건 상수크기로 결정해야 하고, 프로그램 실행중에 변수값 등으로 바꿔낼 수 없죠. 그런데 동적할당을 배우면 배열 크기를 실행중에 정할 수 있습니다.

 

예를 들어서 문자 100개를 받아야 하는 상황이 나오면 char 배열 100개를 동적할당해서 만들고 거기에 저장하면 되죠.

*VLA라는 예외가 있으나 장점보다 단점이 커서 안쓰는게 좋고 동적할당을 쓰는게 일반적이라고 말씀드렸습니다.

 

동적 메모리 할당은 컴파일에 결정되는게 아니라 런타임 중 즉, 프로그램 실행 시간에 이루어지는데 프로그래머가 동적 메모리 할당을 요구해서 동적 메모리 할당이 이루어지면 힙 영역에 메모리가 할당됩니다.

이렇게 힙 영역에 할당된 동적 메모리는 일반 변수가 아닌 포인터를 통해 접근할 수 있습니다.

 

C언어는 힙 영역에 동적 메모리를 할당하기 위해 메모리 할당 함수와 메모리 해제 함수를 제공합니다. 일반적으로 사용하는 함수는 아래와 같습니다.

 

종류 함수 성공 실패
메모리 할당 함수 #include <stdlib.h>
void * malloc(size_t size)
할당된 메모리의
시작 주소 반환
NULL 반환
#include <stdlib.h>
void * calloc(size_t num, size_t size)
NULL 반환
#include <stdlib.h>
void * realloc(void * p, size_t size)
재할당된 메모리의
시작 주소 반환
NULL 반환
메모리 해제 함수 #include <stdlib.h>
void free(void * p)
할당된 메모리 해제  

 

동적 메모리 할당 함수와 해제 함수는 헤더 파일 <stdlib.h> 에 선언되어 있습니다. 

C프로그램에서는 런타임 시 힙 영역에 동적 메모리를 할당하기 위해 malloc() calloc() realloc() 을 제공하고,

해제 하기 위해선 free() 함수를 사용합니다.

 

malloc() 함수와 free() 함수

일반적으로 동적할당을 배운다고 하면 먼저 배우는 함수가 malloc() 함수와 free() 함수입니다.

사실 이 2개만 알아도 동적 할당을 하는덴 무리가 없기 때문에 2개만 알고 끝내는 경우도 있습니다.

 

malloc은 memory allocation (메모리 할당) 의 약자로 엠얼록이라고 부르는 사람도 있고 말록 또는 멀록이라고 부르는 사람도 있습니다.

 

저는 동적할당 학부 수업을 듣기 전까진 말록이라고 그냥 불렀었는데 수업에선 교수님이 엠얼록이라고 읽더군요. 그래서 읽는 방법을 바꿨냐 하면 전 바꾸진 않았습니다.

 

뭔가 말록이라고 부르면 하스스톤의 멀록이 생각나기도 하고 항상 부르던 방법이라 그런지 이게 더 편하더군요 ㅎㅎ..

어떻게 읽던 크게 상관은 없습니다.

 

#include <stdlib.h>

void * malloc(size_t size); //동적 메모리 할당
void free(void* p); //동적 메모리 해제

 

malloc() 함수는 호출 성공 시 메모리에 인자값 만큼 메모리 공간을 할당한 뒤 할당한 메모리의 시작 주소를 반환하고

호출 실패(할당할 메모리 공간이 없을 경우)시 NULL을 반환합니다.

 

그리고 free() 는 malloc() 뿐만 아니라 메모리 할당 함수를 사용하고 나서 사용을 끝내면 반드시 써줘야 하는 함수입니다. 

 

free() 를 통해 메모리 공간의 주소를 제공하면 그 메모리 할당을 해제합니다.

여기서 주소란 malloc() 으로 할당받아서 return 된 주소를 말합니다.

 

fopen() 으로 파일스트림을 생성하고 파일을 연다음에, 파일 입출력 함수로 쓰기를 하고 그 다음에 반드시 fclose() 로 스트림을 닫아줬던 것 기억나시죠?

 

동적할당에서도 마찬가지입니다. 동적 메모리 할당을 하고 다 사용했으면 free() 로 지워줘야 합니다.

힙 영역은 프로그래머가 자유롭게 사용할 수 있는 공간인 만큼 프로그래머가 메모리 영역을 직접 관리해줘야 합니다. 만약에 free를 안쓰고 계속해서 힙 영역에 데이터를 쓰다간 메모리 누수가 발생할 수 있습니다.

 

참고로 malloc의 size_t 라는 자료형은 예전에도 설명드렸지만 unsinged int 로 음수값이 없는 0부터 음수값의 표현범위를 전부 양수에 넘긴 0 ~ 4,294,967,295 까지입니다.

 

즉 malloc 에 들어갈 수 있는 값들은 0이상의 값이란것이죠.

만약에 malloc(4) 를 한다면 힙 영역에 4바이트를 동적할당하겠다는 의미가 됩니다.

그리고 이를 소스코드와 사진으로 나타내면 아래와 같습니다.

 

 

#include <stdio.h>
#include <stdlib.h>

int main() {
	int * p = (int*)malloc(4);
	return 0;
}

소스코드를 보니 어떤가요? 생각과는 조금 다르신가요.

malloc() 함수의 출력 형태는 void * 형 포인터로 void 형 포인터 입니다. 할당한 메모리의 주소값은 반환하긴 하는데 반환되는 주소 값은 어떤 자료형의 주소인지 결정하지 못합니다.

 

예를 들어서 malloc(4)와 같이 동적 메모리를 할당했다면, 4바이트 메모리를 할당한뒤에 char형으로 1바이트씩 4번 읽어야 할지 int형으로 4바이트씩 1번 읽어야 할지 함수내부에서 결정하지 못합니다.

 

그래서 그냥 malloc() 함수로는 동적 메모리로 힙 영역에 N바이트만큼 할당만 하고 반환되는 주소값을 프로그래머가 필요한 자료형으로 알아서 형 변환해서 쓰면 됩니다.

 

예를 들어서 int형은 일반적으로 4바이트(32)비트니깐 int * p = (int*)malloc(4); 로 써주면 malloc으로 할당한 4바이트라는 메모리 공간을 (int *) 을 통해 int형 포인터로 명시적으로 변환해버리면

 

컴퓨터는 "아 이 4바이트 공간을 int형 포인터로 4바이트씩 접근하는거구나!" 라고 이해하게 됩니다.

그리고 그걸 int형 포인터로 받아서 저장하면 malloc() 으로 나온 메모리 공간을 주소(p)로써 접근할 수 있는것이죠.

 

(포인터의 자료형은 포인터가 주소를 접근할때 char형으로 1바이트씩 접근해야하는지, int형으로 4바이트씩 접근해야 하는지 모르기 때문에 접근 근거를 마련해주기 위해 프로그래머가 적어주는것이였습니다.)

 

이 코드 구문을 그림으로 나타내면 다음과 같습니다.

malloc() 에 의해 힙 영역에 4바이트 만큼 메모리 공간이 할당되고 그 시작 주소가 000001 이라고 가정하면 malloc() 함수가 이 주소값을 반환할것이고 포인터 변수 p가 이 주소값을 받아서 가리키게 되겠죠.

 

이제 포인터 p를 이용해 힙 영역에 할당된 메모리에 접근할 수 있게된 것입니다.

 

정리하면, malloc() 은 메모리 공간만 만들어주지 어떤 자료형으로 접근해야할지는 알려주지 않으므로

프로그래머가 필요한대로 명시적으로 변환해서 쓴다라고 이해하시면 됩니다.

 

#include <stdio.h>
#include <stdlib.h>

int main() {
        int * p = (int*)malloc(sizeof(int)); //4바이트 메모리 공간 할당
        *p = 10;

        printf("%d", *p);
        free(p); //메모리 할당 해제
        return 0;
}

10
--------------------------------
Process exited after 0.005242 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .

 

다음으로, malloc() 으로 4바이트를 할당하고 int형 포인트로 4바이트씩 읽으라고 지시했는데

지금 우리가 할당해서 만든 4바이트 공간에 값은 어떻게 적을까요?

 

본 소스코드는 우선 힙영역에 4바이트만큼 동적 할당하고 거기에 정수를 저장하는게 목적이라고 합시다.

우선 소스코드를 조금 수정했는데 malloc(4) 로 직접 4바이트 공간을 할당해주는게 아니라

sizeof(int) 로 int형의 크기를 제공해서 할당했습니다.

 

운영체제마다 int의 메모리 크기값이 다를 수 있으므로 이렇게 써주는게 4로 하드코딩하는것보다 훨씬 좋습니다.

 

기본적으로 p가 지금 malloc() 이 할당한 4바이트 공간의 메모리 주소를 가리키고 있으므로 우리가 지금까지 배웠던 포인터 문법을 사용해서 값을 저장해주면 됩니다.

 

*p = 10; 를 통해 4바이트 공간에 10이라는 정수값을 저장해보았습니다.

이 구문은 *p와 같은 구문은 정말 포인터 편에서 수도없이 설명드렸으므로 여기서 자세한 설명은 생략토록 하겠습니다.

 

p에 저장된 값을 출력하고 마지막으로 공간을 다써줬으므로 메모리를 free() 로 반드시 날려줘야 합니다.

물론 프로그램을 꺼버리면 동적 할당된 메모리던, 변수값이던 전부 날라가게 되는데 왜 free() 로 명시적으로 지워줘야 하냐고 물어보실 수 있는데 

 

프로그램이 커지고 계속 실행되다 보면 말이 달라지게 됩니다. 계속 할당만 하고 free() 로 메모리를 해제해 주지 않으면 실행중에 메모리가 넘치는등 문제가 발생할 수 있습니다.

 

 

<참고: 가비지 컬렉터>

더보기

C언어에 비해 상대적으로 고수준인 Python, Java와 같은 언어는 가비지컬렉터(Garbage Collector 혹은 GC) 란것이 있어서 C언어 처럼 프로그래머가 free() 로 관리해주지 않아도 더 이상 사용되지 않는 값을 알아서 지워주는 쓰레기 처리기가 있습니다.

 

이런 말을 들으면 다른 언어가 킹이고 갓이고 C는 구려보이지만, C언어 자체는 저런 편의 기능을 제공하지 않는 대신에 기계와 가까운 저수준의 언어로써 포인터와 같은 문법으로 메모리에 직접 접근할 수 있으며 속도가 매우 빠르며 경량화 되어있다는 장점이 있습니다.

 

모든 프로그래밍 언어에는 장단점이 있고 사용 용도가 있습니다. C는 제가 강의 제일 처음에도 말씀드렸듯이 모든 언어의 베이스가 되며 우리가 평소에 생각하지 않았던 컴퓨팅 구조에 대해 생각하게 해주기 때문에 컴퓨터를 학습할때 매~우 중요하다고 생각됩니다.

 

만약에 우리가 C언어는 배우지 않고 파이썬, Java와 같은 언어만 썼다면 GC가 알아서 사용되지 않는 값을 지워줘서 아마 힙 영역, free() 와 같은 함수의 존재에 대해 아예 몰랐을 가능성이 크겠죠.

 

동적할당은 알겠고 malloc() 함수를 쓰는법도 대충은 알겠습니다.

근데 저희 목적은 처음에 이게아니였죠?

원래 처음 목적은 동적할당으로 가변적인 배열을 만들어내는 것이었습니다.

 

그 예제를 한번 살펴보겠습니다.

 

#include <stdio.h>
#include <stdlib.h>

int main() {
	
	int total = 0;
	printf("학생 수를 입력해주세요 : ");

	scanf("%d", &total);
	int * p = (int*)malloc(sizeof(int) * total); //입력받은 값 x 4bytes 크기로 동적할당
	
	//배열 처럼 이용 
	for(int i = 0; i < total; i++){
		p[i] = 0;
	} 
	
	//출력
	for(int i = 0; i < total; i++){
		if(i % 5 == 0)
			printf("\n");
		printf("%d ", p[i]);
	} 
    
    free(p); //메모리 할당 해제
	return 0;
}

학생 수를 입력해주세요 : 100

0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
--------------------------------
Process exited after 1.466 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .

학생수를 받아서 그 크기만큼 동적할당하고 배열처럼 사용하는 예시 입니다.

 

int * p = (int*)malloc(sizeof(int) * total); //입력받은 값 x 4bytes 크기로 동적할당

이 소스에서 동적할당을 하는 부분은 이 부분인데요.

보시면 malloc() 을 이용해 학생수 x int형 크기 만큼 할당을 하고 있습니다

예를 들어서 학생수가 20명이면 int형 크기가 일반적으로 4바이트이므로 20x4 = 80바이트가 할당이 되겠죠.

 

그리고 int형 포인터(int *)로 저장하고 있으므로 4바이트씩 접근을 할겁니다.

정리하면 80바이트 공간을 4바이트씩 포인터로 탐색하게 되겠죠.

 

어라.. 이거 어디서 많이 본거 아닌가요

이거 배열도 마찬가지 아닌가요?

 

배열도 int arr[20]; 로 만들면 4바이트씩 20개로 연속적인 메모리 공간으로 총 80바이트가 잡혀서

arr[0], arr[1] 로 인덱스 접근을 하면 실제로 4바이트씩 건너뛰면서 접근해서 값을 읽어낼 수 있었죠.

 

배열은 내부적으로 포인터로 구현되어있다고 했습니다.

배열의 이름은 시작주소였고, 인덱스로 각 요소를 접근할 수 있었듯이

 

포인터로 이 배열의 시작주소를 가리키면 그 포인터도 마찬가지로 인덱스 접근을 해서

배열처럼 사용할 수 있었습니다. 

또 포인터 연산을 이용해서 n바이트씩 건너뛰어서 값을 읽어낼 수도 있었죠.

 

malloc() 으로 할당한 공간도 마찬가지입니다. malloc() 으로 연속적인 메모리 공간을 할당시키고 malloc() 은 메모리의 시작주소를 반환하니깐,

이걸 포인터로 접근하면 그게 사실상 배열인것이죠. (정확히 이야기하면 1차원 배열입니다.)

 

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
int main(){

	char * str_array[3]; //포인터 배열
	char tmp[100]; //문자열 임시 저장공간 
    
   for(int i = 0; i < 3; i++){
      printf("문자열을 입력해주세요 : ");
      gets(tmp);
      
      str_array[i] = (char*)malloc(strlen(tmp) + 1);
      strcpy(str_array[i], tmp);
   }

   for(int i = 0; i < 3; i++)
      printf("%s\n", str_array[i]);

   for(int i = 0; i < 3; i++)
      free(str_array[i]);

   return 0;
}

문자열을 입력해주세요 : Apple
문자열을 입력해주세요 : Bannna
문자열을 입력해주세요 : Fruits
Apple
Bannna
Fruits

--------------------------------
Process exited after 7.847 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .

동적할당을 이용해서 문자열 배열을 만들어낸 예시입니다.

기존에 동적할당을 배우기전에 "Apple", "Bannna", "Fruits" 라는 문자열 배열을 만드는 방법은

 

char * str_array[3] = {"Apple", "Bannna", "Fruits"}; 처럼 포인터 문자열을 이용하거나

char str_array[3][20] = {"Apple", "Bannna", "Fruits"}; 다음과 같이 2차원 배열을 이용하는것이였습니다.

 

근데 2가지 방법다 조금씩 하자가 있었는데 일단 포인터 문자열로 한번 저장을 해버리면

str_array[0] 으로 "Apple" 이라는 문자열에 접근할때 예를 들어서 A라는 글자를 바꾸고 싶으면 str_array[0][0] 과 같은 방법으로 접근해서 바꿔내야 하는데 포인터로 구현한 문자열은 기본적으로

 

읽기 전용이라 메모리를 수정하는게 불가능했었고,

 

char str_array[3][20] = {"Apple", "Bannna", "Fruits"}; 이렇게 2차원 배열을 만들어 쓰면 

문제는 저기 [20] 이라는 글자수 제한이 걸려서 이 글자수를 넘어가면 문자열을 담아낼 수 없고, 부족하면 메모리가 낭비되는 문제가 있었죠.

 

그런데 동적 할당을 사용하면 이런 에로사항을 조금 해결할 수 있게 됩니다.

소스코드를 한번 살펴보겠습니다.

 

char * str_array[3]; //포인터 배열

우선 문자열의 주소들을 각각 저장하기 위해 포인터 배열을 만듭니다.

문자열은 총 3개 저장할것입니다.

 

   for(int i = 0; i < 3; i++){
      printf("문자열을 입력해주세요 : ");
      gets(tmp);
      
      str_array[i] = (char*)malloc(strlen(tmp) + 1);
      strcpy(str_array[i], tmp);
   }

for문으로 반복을 돌면서 tmp에 문자열을 임시 저장하고 malloc을 통해 tmp의 글자갯수 + 1 만큼 (NULL 문자 포함)

동적할당한 다음에 아까 만들었던 포인터 배열에 주소를 저장합니다.

 

그리고 strcpy() 를 통해서 malloc() 으로 만든 비어있는 공간에 문자열을 집어넣습니다.

tmp에 있는 문자열을 동적할당한 공간에 저장합니다.

만약에 포인터로 문자열을 구현했으면 Read-Only 상태라 strcpy() 가 안먹혔겠지만 동적할당하면 쓰기가 가능하기 때문에 문제가 없습니다.

 

그리고 입력한 문자열 크기에 맞춰서 메모리 크기를 할당했기 때문에 2차원 배열처럼 크기가 낭비되는 문제도 없습니다.

 

   for(int i = 0; i < 3; i++)
      printf("%s\n", str_array[i]);

   for(int i = 0; i < 3; i++)
      free(str_array[i]);

%s 서식문자를 통해 문자열을 각각 출력하고 

메모리 활용이 끝났으면 free() 함수를 통해 동적 메모리를 해제 시켜줍니다.

 

calloc() 함수와 free() 함수

calloc() 함수는 malloc() 함수와 같은 기능을 하는데 사용방법만 약간 다릅니다.

이 함수 역시 힙 영역에 동적 메모리를 할당합니다.

 

void* calloc(size_t num, size_t size);
void free(void * p);

보시면 인자값이 num과 size가 있습니다.

 

한 문장으로 정리해서 calloc() 함수는 num * size 만큼의 크기를 할당합니다.

 

예를 들어서 아래 2구문은 같은 구문입니다.

int * p1 = calloc(4, 4); //4x4 바이트만큼 할당
int * p2 = malloc(16); //16바이트 만큼 할당

malloc은 인자값에 들어온 크기만큼 할당했다면 calloc() 은 단순히 몇곱하기 몇으로 할당하는지만 지정하는 겁니다.

 

#include <stdio.h>
#include <stdlib.h>

int main() {
	
	int total = 0;
	printf("학생 수를 입력해주세요 : ");

	scanf("%d", &total);
	int * p = (int*)calloc(sizeof(int) , total); //입력받은 값 x 4bytes 크기로 동적할당
	
	//배열 처럼 이용 
	for(int i = 0; i < total; i++){
		p[i] = 0;
	} 
	
	//출력
	for(int i = 0; i < total; i++){
		if(i % 5 == 0)
			printf("\n");
		printf("%d ", p[i]);
	} 
    
    free(p); //메모리 할당 해제
	return 0;
}

아까 예제를 다시 가져와봤습니다.

 

int * p = (int*)calloc(total, sizeof(int)); //입력받은 값 x 4bytes 크기로 동적할당

바꾼건 딱 이 라인밖에 없습니다. calloc() 역시 반환값이 void형 포인터라서 어떤 자료형으로 접근할지 (int *) 처럼 명시적으로 변환해줘야 하고

 

malloc() 은 sizeof(int)*total 기호로 나타냈었는데 calloc() 은 인자값을 저렇게 2개로 주면 알아서 sizeof(int) * total 을 해서 동적할당 해줍니다.

 

아까 malloc() 으로 나타낸것보단 코드가 확실히 명확해진게 보이긴합니다.

아까 malloc() 으로 동적할당을 할때는 int크기로 total갯수만큼 할당하라는게 한줄에 적혀있어서 가독성이 좀 떨어졌는데 calloc() 은 인자값이 2개라 뭐랑 뭐를 곱해서 동적할당하는게 명확하게 보이긴합니다.

 

void* calloc(size_t num, size_t size);

그런데 순서에 주의하셔야 합니다.

일반적으로 4바이트씩 10개 할당, 그래서 calloc에 calloc(4,10); 으로 쓰면 될거 같지만 위 calloc() 원형을 보시면

갯수(num)가 먼저 나오고 크기(size)는 나중에 나옵니다.

 

그러니깐 4바이트씩 10개를 할당하고 싶으면 calloc(10, 4); 로 써야한다는 말입니다.

어짜피 곱하는거라 수학적 관점에선 교환법칙이라 상관없다고 생각하겠지만 인자값에서 이름을 지정해놨으니깐 그것대로 따라가는게 좋습니다.

 

malloc() 과 calloc() 의 차이는 이것뿐?

지금까지 설명을 들으면 그냥 malloc() 에서 곱하는 행위를 calloc() 에선 단순히 인자값으로 찢어놓은것밖엔 보이지 않네요.

 

그런데 calloc() 과 malloc() 은 한가지 차이가 더 있습니다.

#include <stdio.h>
#include <stdlib.h>

int main() {
	int * p1 = (int*)calloc(1, sizeof(int));
	int * p2 = (int*)malloc(4);
	
	printf("p1 : %d\n", *p1);
	printf("p2 : %d\n", *p2);
	
	free(p1); free(p2);
	p1 = NULL; p2 = NULL;
	return 0;
}

p1 : 0
p2 : 7363824

--------------------------------
Process exited after 0.004966 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .

보시면 p1과 p2를 이용해 4바이트 공간을 할당하고 있습니다.

그리고 p1과 p2값을 출력해봤더니 p1은 계속 실행해봐도 0이 나오는데 p2는 이상한 쓰레기 값이 나옵니다.

 

calloc() 함수는 메모리로 할당된 영역을 자동으로 0으로 초기화 해주지만

malloc() 함수로 메모리를 할당하면 영역을 자동으로 초기화 해주지 않습니다.

초기화 하지 않으면 C언어는 쓰레기 값(이상한 값)이 저장되어 있던것 기억나시죠?

 

calloc() 으로 초기화를 하면 일단 num , size 를 인자값으로 주어서 배열을 만들때 유용하고, 0으로까지 초기화 해주기 때문에 간편하다고 할 수 있습니다.

 

	//배열 처럼 이용 
	for(int i = 0; i < total; i++){
		p[i] = 0;
	}

설명드리진 않았지만, 제일 처음에 malloc() 으로 배열처럼 사용할때는 인덱스를 돌면서 0으로 초기화 하던 작업이 필요했었습니다.

 

realloc() 함수와 free() 함수

malloc() 함수와 calloc() 함수로 메모리를 동적 할당하면 원하는 크기대로 메모리 공간을 잡을 수 있다는 장점이 있습니다만 할당이후에 메모리의 크기를 변경하지 못한다는 단점이 있습니다.

 

realloc() 함수를 이용하면 이를 해결할 수 잇습니다.

realloc() 함수는 동적 메모리로 할당되어 있는 영역에서 size만큼 재할당해 줍니다.

 

그런다음 재할당된 메모리의 시작 주소를 반환합니다.

#include <stdlib.h>
void * realloc(void * p, size_t size); //동적 메모리 재할당
void free(void * p); //동적 메모리 해제

realloc() 의 인자값은 두번째인데 첫번째는 동적 할당한 메모리 주소(포인터 변수)를 주면 되고, 

size는 재할당할 크기입니다.

 

정리해서 p가 참조하고 있는 동적 메모리의 크기를 size만큼 재할당 하라는 의미입니다.

 

아래 예제는 힙 영역에 8바이트만큼 동적 메모리를 할당한 후에 realloc() 함수를 이용해 16바이트로 재할당하는 함수입니다.

 

#include <stdio.h>
#include <stdlib.h>

int main() {
	int i = 0;
	
	int * p = (int*)malloc(sizeof(int) * 2); //8바이트 할당
	p[0] = 10;
	p[1] = 20;
	
	p = (int*)realloc(p, sizeof(int) * 4); //16바이트 재할당 (8바이트 추가 확장)
	p[2] = 30;
	p[3] = 40;
	
	for(int i = 0; i < 4; i++)
		printf("p[%d] : %d \n", i, p[i]);
		
	free(p); p = NULL;
	return 0;
}

 

	int * p = (int*)malloc(sizeof(int) * 2); //8바이트 할당
	p[0] = 10;
	p[1] = 20;

우선 이부분까진 이 앞전에 학습한 내용입니다.

8바이트 할당하고, 포인터로 가르켜 배열처럼 사용가능하므로

인덱스 접근해서 4바이트씩 int형 값을 저장하고 있습니다.

 

근데 8바이트 공간에 4바이트 값 2개를 썼으니깐

이제 더이상 값을 적을 수 없죠.

이것을 한번 재할당해보겠습니다. realloc() 함수를 이용합니다. 

 

	p = (int*)realloc(p, sizeof(int) * 4); //16바이트 재할당 (8바이트 추가 확장)
	p[2] = 30;
	p[3] = 40;

realloc() 을 이용해서 16바이트로 재할당했습니다.

기존에 8바이트를 16바이트로 재할당했으므로 8바이트만큼 메모리가 확장된 셈입니다.

그래서 4바이트값 2개를 더 저장할 수 있게 되었습니다.

30, 40을 저장했습니다.

 

마지막은 출력, 사용이 끝나면 free() 역시 동일합니다.

 

참고 : realloc() 으로 확장하면 반환하는 메모리 주소에 관해

더보기

기존 동적할당한 메모리 공간을 realloc() 함수로 확장시킬때, 만약에 메모리가 충분해서 기존 할당 공간 뒤에 메모리 공간을 이어 붙여서 더 할당할 수 있으면 realloc() 으로 확장하고 같은 시작 주소를 반환하고 (뒤에 메모리 공간만 추가로 할당했기 때문에)

 

만약에 공간이 부족해서 뒤에 이어붙여서 더 할당할 수 없으면 메모리에서 다른 부분을 찾아서 다시 할당하기 때문에 기존 할당했던 공간과 다른 메모리 주소가 반환되게 됩니다.

 

지금 case의 경우 realloc() 으로 확장을 하는 예시이지만 만약에 축소시키는건 어떻게 될까요?

아래 예제를 보겠습니다.

 

#include <stdio.h>
#include <stdlib.h>

int main() {
	int i = 0;
	
	int * p = (int*)malloc(sizeof(int) * 2); //8바이트 할당
	p[0] = 10;
	p[1] = 20;
	
	p = (int*)realloc(p, sizeof(int) * 1); //4바이트 재할당 (이전보다 4바이트 축소)
	p[0] = 30;
	
	printf("p[0] : %d \n", p[0]);
		
	free(p); p = NULL;
	return 0;
}
	int * p = (int*)malloc(sizeof(int) * 2); //8바이트 할당
	p[0] = 10;
	p[1] = 20;

우선 이 부분은 아까와 동일합니다.

 

	p = (int*)realloc(p, sizeof(int) * 1); //4바이트 재할당 (이전보다 4바이트 축소)
	p[0] = 30;

realloc() 으로 재할당을 시행합니다.

그런데 아까는 16바이트를 재할당해서 크기가 기존보다 더 커졌는데 이번엔 4바이트로 재할당해서 4바이트만큼 크기가 줄어들었습니다.

 

기존 8바이트 공간에 10과 20이 저장되어 있었지만 4바이트를 축소시키면 끝에 20 값은 잘리게 되고 처음 저장했던 4바이트 값 10만 남게 됩니다.

 

그리고 p[0] = 30; 을 통해 값 10을 30으로 덮어씌우고 출력합니다.

 

정리

- 프로그램(C 프로그램을 포함해서)을 실행하면 그것을 프로세스라고 부르며, 램 위로 올라와서 실행됨.

- 프로세스의 메모리 영역에는 코드 영역, 스택 영역, 데이터 영역, 힙 영역이 있음.

 

- 코드 영역, 스택 영역, 데이터 영역은 메모리가 언제 할당되고 그리고 얼마나 할당되는지에 대한 크기를 컴파일 중에 결정되는 메모리 영역임.

(메모리 관리 역시 컴파일러가 책임짐)

 

- 반면에 동적 메모리 할당에 사용되는 힙 영역은 메모리가 언제 할당되고 언제 해제될지 그리고 얼마나 할당되는지에 대한 크기를 런타임 중에 결정, 따라서 프로그래머가 메모리 관리를 책임져야 한다.

 

:: 할당은 malloc(), calloc(), realloc() 함수를 통해 하고 사용이 끝났으면 반드시 free() 를 통해 해제시켜줘야 한다.

 

특징 코드 영역, 스택 영역, 데이터 영역 힙 영역
메모리 할당 컴파일 중에 런타임 중에
메모리 해제 자동 free() 함수
메모리 관리 컴파일러 프로그래머

 

가변 인자

일반적으로 함수의 인자(매개변수)를 결정하면 인자의 수가 고정되고 그 형식에 맞춰서 함수를 사용해야 합니다

 

그런데 printf() 나 scanf() 함수는 인자값이 정해져 있지 않고 서식 문자에 따라 얼마든지 인자값을 추가 가능합니다.

이렇게 매번 함수에 들어가는 인자의 갯수가 변하는 것을 가변 인자라고 합니다.

 

가변 인자는 함수 인자 수를 고정하지 않고 가변적으로 함수를 호출할 수 있는 방법입니다.

다음 예제와 같이 인자의 수를 바꾸면서 호출할 수 있습니다.

 

* 가변인자는 개념이 다소 어렵고 사용할 일이 많아서 너무 이해하려 집착하지 않으셔도 됩니다.

게다가 지금 강의가 책 한권을 기준으로 쓰고 있는데 너무 오래되서인지 작동을 전혀 하지 않아서 아예 새로 작성했습니다 -.-

 

아래 내용을 작성한데 참고한 출처글 : https://norux.me/19, https://dojang.io/mod/page/view.php?id=577

 

가변 인자 조건

우선 가변 인자 함수를 만들기 위해선 <stdarg.h> 파일을 인클루드 해야 합니다.

이 헤더 파일에 가변 인자 함수를 만들때 필요한 각종 매크로 들이 정의되어 있습니다.

 

최소 1개 이상의 고정 인수가 있어야 하며 가변인자를 나타낼때는 ... 기호로 표현을 합니다.

예를 들어서 sum() 이라는 함수가 첫번째는 인자의 갯수, 뒤부턴 제공한 인자들로 합을 출력하는 함수를 만든다고 하면

 

int sum(int n, ...) 과 같이 표현합니다.

n은 인자의 갯수, 뒤부턴 가변적으로 들어올 수 있다고 해서 가변 인자를 표현할땐 ... 기호로 표현을 하게 됩니다.

이 ... 기호는 매개 변수 순서상 가장 마지막에 위치해야 합니다.

 

아래 예제를 보고 한번 사용법을 익혀보겠습니다.

 

#include <stdio.h>
#include <stdarg.h>

int sum(int n, ...)
{
    int result = 0;
    va_list ap; //char * ap	
    va_start(ap, n); //가변인자 중 첫 번째 인자의 주소를 제공 
    printf("%d\n", *ap);
    
    for(int i=0; i < n; i++)
        result += va_arg(ap, int); //4바이트씩 다음 가변 인자로 이동 

    va_end(ap); // 가변 인자 목록 포인터를 NULL로 초기화

    return result;
}

int main()
{
    printf("%d\n", sum(10, 1,2,3,4,5,6,7,8,9,10));

    return 0;
}

1
55

--------------------------------
Process exited after 0.004407 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .

 

사용전에 stdarg.h 파일에서 사용하는 생소한 함수들이나 자료형이 보일것인데 미리 짚고 넘어가겠습니다.

 

  • va_list: 가변 인자 목록. 가변 인자의 메모리 주소를 저장하는 포인터입니다. (내부적으로 char * 와 동일)
  • va_start: 가변 인자를 가져올 수 있도록 포인터를 설정합니다.
  • va_arg: 가변 인자 포인터에서 특정 자료형 크기만큼 값을 가져옵니다.
  • va_end: 가변 인자 처리가 끝났을 때 포인터를 NULL로 초기화합니다.

 

뭔가 많죠? 쫄필요는 없습니다.

그냥 사용법을 C언어 레퍼런스에서 정해줬기 때문에 우리는 그것만 따르면 됩니다.

 

원리는 간단하게 설명해서 인자값이 가변적이므로 포인터로 하나씩 가리키면서 탐색을 하고 읽어내는것이라고 보시면 됩니다.

 

int sum(int n, ...)

우선 함수 선언 형태를 보시면 다음과 같이 int n 은 고정 인수고 뒤부턴 ... 으로 가변인자로 표현했습니다.

n 뒤부턴 몇개씩 받을것인지 정해져 있지 않다는것을 이렇게 표현합니다.

n은 뒤에 ...에 받을 인자의 갯수라고 보시면 됩니다.

 

va_list ap; //char * ap	
va_start(ap, n); //가변인자 중 첫 번째 인자의 주소를 제공

그리고 함수 내부에 처리를 보겠습니다.

우선 va_list 자료형의 ap라는것을 만들었습니다.

이름이 멋져보이지만 별건 없고 그냥 char * 로 정의되어 있습니다.

이 ap를 이용해서 우리는 가변 인자값을 읽어낼 것입니다.

 

그러면 ap에 인자값 주소를 가르켜줘야겠죠?

근데 우리는 정확히 어디에 있는지 모릅니다.

이럴때 va_start() 함수를 사용하면 됩니다.

va_start(ap , n); 을 하는데 저기 n에는 고정인수가 들어가는데 마지막 고정 인수가 들어가야 합니다.

 

가변인자 조건에 보시면 아까 최소 고정 인수가 1개는 있어야 한다고 했는데 이 글에선 하나니깐 마지막 고정 인수도 n이겠네요.

 

어쨌든 va_start(ap , n); 을 실행하게 되면 ap가 고정 인수 n 바로 다음으로 들어온 요소를 가르키게 됩니다.

 

    printf("%d\n", sum(10, 1,2,3,4,5,6,7,8,9,10));

잠시 main() 함수를 살펴봅니다.

main() 함수의 sum() 호출의 경우 이런 형태였으니깐

10 바로 다음인 1을 가르키고 있겠네요.,

 

    for(int i=0; i < n; i++)
        result += va_arg(ap, int); //4바이트씩 다음 가변 인자로 이동

그리고 loop() 를 돌면서 va_arg 라는 매크로 함수를 사용하고 있습니다.

* 매크로의 경우 다음장에서 설명드릴건데 그냥 함수랑 같은거다 라고 보시면 됩니다.

 

va_arg를 하면 ap 위치에서 값을 읽어오고 2번째 인자값으론 int를 줬는데 저러면 ap 포인터를 4바이트 뒤로 옮깁니다.

 

첫번째 loop에서는 우선 ap위치에서 값을 읽어오니 ap가 1을 가리키고 있다고 했으니 result += 1을 시행하고 4바이트 뒤로 옮깁니다. 그러면 ap가 2를 가리키고 있겠군요.

 

두번째 loop에서는 우선 ap위치에서 갚을 읽는데 저번 루프에서 2를 가리키게 했으니 2를 읽고 다시 4바이트 옮겨서 3을 가리키게 됩니다

 

.

.

.

 

(과정 생략)

그렇게 해서 n만큼 반복을 쫙 돌다보면 인자값을 전부 읽어내서 합을 구했을겁니다.

 

va_end(ap); // 가변 인자 목록 포인터를 NULL로 초기화

 ap 포인터는 사용이 끝났으니 va_end 를 이용해서 NULL로 초기화 해줍니다.

왜 ap = NULL; 을 안쓰고 이것을 사용하는진 잘 모르겠습니다만 이렇게 정해논 이유가 있을것이니 따르도록 합니다.

 

return result;

마지막으로 결과값을 return 합니다.

 


이번 강의는 한편에서 끝내려고 하다보니 양이 많아졌군요.

그럼 다음 시간엔 해당 C언어 강좌의 마지막장인 '전처리기와 파일 분할 컴파일' 편에서 뵙겠습니다.

 

감사합니다.

COMMENT WRITE