[CS] 프로그램과 프로세스, 메모리 구조

개요


저번 시간에는 프로그램의 생성인 빌드(컴파일, 링크)에 대하여 알아 보았다.

이제 프로그램을 생성하는 과정을 알았는데, 실행은 어떻게 될까?


프로그램 VS 프로세스


위키백과에서 프로세스의 정의는 다음과 같습니다.


"프로세스는 컴퓨터에서 연속적으로 실행되고 있는 컴퓨터 프로그램을 말한다."


컴퓨터에서 프로그램을 실행시키면, 그것이 프로세스가 된다.

실제로 작업 관리자에 들어가면 내 컴퓨터에서 실행 중인 프로그램, 즉 프로세스들을 볼 수 있다.




(보통 강제 종료를 할 때 많이 쓴다.)


프로그램을 실행시키면, OS는 프로그램이 사용할 메모리의 크기를 지정한 후, RAM(주 기억 장치)에 그 프로그램을 올려 실행시킵니다. 그러면 프로세스가 된다.


위에서 말 했 듯이 OS는 프로그램이 사용할 메모리를 준비해 놓고, 프로그램을 주 메모리에 올리게 된다. 그럼 그 메모리는 어떻게 구성되어 있을까?




프로세스는 OS에게 위의 그림과 같은 메모리를 할당 받는다. 각각의 영역을 알아보자


Stack


Stack은 프로세스의 가장 높은 메모리 주소에 위치해 있다. 자료구조와 알고리즘에서 기본적으로 배우는 그 Stack과 같은 동작을 하는데, LIFO(Last In, First Out)이란 단어처럼 나중에 들어간 데이터가 제일 먼저 빠져 나오는, 쌓인 접시와 같은 동작을 한다.


이러한 Stack영역은 우리가 코드를 작성할 때, 지역 변수와 함수의 매개 변수가 저장된다.

Stack의 특성 답게 가장 먼저 선언한 지역 변수는 먼저 쌓이고(가장 높은 메모리 주소에 쌓이고), 가장 늦게 선언한 지역 변수는 스택의 맨 위에 위치하게 된다.(Stack 영역의 가장 낮은 메모리 주소)

그리고 지역 변수가 선언된 함수가 종료되면 LIFO 특성대로 가장 늦게 선언한 지역 변수부터 POP되어 사라지고, 가장 마지막으로는 맨 처음에 선언했던 변수가 POP 된다.


간단한 실습을 통해 이를 눈으로 확인해 보자


#include <stdio.h>


int main(){
    int a = 0; // 1. Stack에 처음으로 쌓임
    int b = 0; // 2. Stack에 마지막으로 쌓임
    printf("가장 먼저 선언한 a의 주소 값: %x\n",&a);
    printf("가장 늦게 선언한 b의 주소 값: %x\n", &b);


    return 0;
}


1. 위의 프로그램은 첫 번째로 변수 a를 선언하였. 그럼 Stack 영역에 4byte의 크기로 (int의 크기로) a변수가 쌓일 것이다.




2. 그 다음 두 번째로 지역 변수 b를 선언하였다. 그럼 Stack 영역에 변수 a 위에 4byte의 크기로 b 변수가 쌓일 것이다.





그럼 프로그램을 실행하여 변수 a와 변수 b의 주소 값을 %x, 16진수로 확인해 본다면?




조금 이상하다. 분명 예상 대로라면 a의 주소 값과 b의 주소 값이 4차이가 나야 할 텐데(a의 크기가 4byte int 이기 때문.), 12(16 진수로 c)가 차이 난다.(Visual Studio 환경)

왜 이런 결과가 나올까?


이는 컴파일러에 따라 결과가 다른데 Stack에 쌓이는 동작 원리는 모두 같지만, 변수의 선언 순서대로 반드시 지켜지는 것은 아니라는 것이다.

관련 내용은 아래와 같다.

링크:

https://stackoverflow.com/questions/25442458/why-are-consecutive-int-data-type-variables-located-at-12-bytes-offset-in-visual



이 문제를 임시로 해결할 수 있는 방법이 있다.

 이는 Visual Studio의 솔루션 구성을 Debug에서 release로 바꾸기만 하면 된다







(Release로 바꾸자.)


#include <stdio.h>


int main(){
    int a = 0;
    int b = 0;
    printf("가장 먼저 선언한 a의 주소 값: %x\n",&a);
    printf("가장 늦게 선언한 b의 주소 값: %x\n", &b);
    printf("두 주소 값의 차이: %x\n", (int)&a - (int)&b);
    return 0;
}


결과:



이제 우리 예상 대로 4byte크기 대로 Stack에 변수가 쌓이는 것을 확인하였다.

(b의 주소 값 dbfdbc부터 생각하면,a까지 4단계가 올라간다. dbfdbc -> dbfdbd -> dbfdbe -> dbfdc0)


디버그 모드에서는 변수의 값을 실시간으로 확인하고, 위치도 나타내는 기능이 있다.

아마 이런 기능을 위해 중간에 값이 더 추가되는 것 같다.


또한, 항상 먼저 선언한 변수가 항상 Stack에 먼저 쌓인다고 말할 수도 없다.

이는 컴파일에 따라 바뀌는데, 실제로 우리가 코드를 작성하고 난 후, 컴파일러는 기계어로 컴파일 하는 과정에 이를 최적화 하기 위해 코드를 바꿔서 컴파일 한다. 따라서 나중에 선언한 변수가 먼저 선언한 변수보다 더 높은 주소 값을 가질 수도 있다.


#include <stdio.h>


int main(){

    double a = 0;
    double b = 0;

    printf("가장 먼저 선언한 a의 주소 값: %x\n",&a);
    printf("가장 늦게 선언한 b의 주소 값: %x\n", &b);

    return 0;
}


(int가 아닌 double로 변수를 선언한 경우, 결과가 이렇게 바뀐다.)


하지만 컴파일된 코드(나중에 볼, 코드 영역에 있는 명령어)의 순서는 지금 우리가 알아본 Stack에 쌓이는 순서와 같다. 따라서 Stack LIFO는 지켜진다


이와 같이 컴파일 버전에 따라, 환경에 따라 주소 값이 바뀔 수 있으니, 이것을 맹신하여 주소 값을 잘못 참조하는 일이 없도록 하자.


Stack 영역의 또 다른 재미있는 점은 함수의 매개변수와 지역변수를 적재할 때 인데, 이는ESP와 EBP라는 포인터에 의해 이뤄진다. 이는 다음에 알아보자.


Heap


Heap 영역은 사용자의 요청에 따라 할당될 수 있는 영역이다. 이 영역은 OS가 관리하는데, 우리가 메모리가 필요할 때 OS에게 "야 OS, 이 만큼의 메모리가 필요한데, 할당 좀 해줘" 라고 말하면 그 때 메모리가 할당되는 곳이다.


따라서 우리가 원하는 시기에(보통 런타임, 프로그램 실행 중에 할당합니다.) 할당할 수 있는데, 이를 동적할당이라 한다.


그럼 이 할당을 왜 "동적"할당이라 할까?


보통 많이들 실수를 하는 것이 있는데, 그것은 지역변수 array의 크기를 사용자의 입력으로 정하는 실수 이다.


#include <stdio.h>



int main(){
    int input = 0;

    scanf("%d", &input); // 사용자의 입력을 받는다.
    int arr[input]; // 입력 만큼 지역변수 생성(?)
    return 0;
}


위의 코드는 실행은 물론, 컴파일도 되지 않는다.




이를 해결하는 방법은, 런타임 때에도 맘대로 할당할 수 있는 동적 할당을 사용하면 된다.


동적 할당은 Heap영역에, 프로그램 실행 중간에 맘대로 영역을 할당할 수 있는 방법이다. 따라서 우리는 C 언어의 malloc이라는 함수를 이용하여 사용자의 입력 만큼 arr의 크기를 동적으로 할당할 수 있게 된다.


또한 기본적으로 Stack영역 보다 더 많은 크기의 메모리를 할당할 수 있다. 따라서 더욱 큰 크기의 변수를 할당할 때도 많이 쓰이곤 한다.



#include <stdio.h>



int main(){
    int input = 0;

    printf("동적 할당할 메모리 크기 입력: ");

    scanf("%d", &input); // 사용자의 입력을 받는다.
   
    int* arr; // 동적 할당할 메모리의 주소 값.

    arr = (int*)malloc(sizeof(int) * input); // "운영체제야! int 의 size 곱하기 input 만큼 Heap 영역에 동적 할당 해줘!"
                                             // "할당한 메모리 주소 값은 arr라는 지역 변수로 기억해 놓고 있을게!"

   
    printf("\n동적 할당한 사이즈: %d\n",_msize(arr)); // 내가 동적 할당한 사이즈가 어떻게 돼?

    free(arr); //이제 그만 쓸게, 메모리 해제!


    return 0;
}


결과:



우리가 사용자의 입력 5를 받았다. 그럼 int의 size만큼의 메모리 영역을 5개 할당했다는 것이 된다.


따라서 int의 사이즈 4* 사용자 입력5 = 20 이 된다.






동적 할당을 할 때에는 주의할 점이 있는데, 그것은 메모리를 할당한 것을 해제해 주어야 한다는 것이다.

(Java, Python 등등, 많은 객체 지향 언어에서는 이를 자동으로 해 주는 Garbage Collector라는 편리한 녀석이 있어서 자동으로 메모리를 해제해 주기도 한다.)


동적 할당을 하는 Heap 영역은 사용자의 요청에 의해 할당되고, 사용자의 요청에 의해 해제된다. 따라서 해제를 요청하지 않으면 프로세스가 종료될 때 까지 Heap 영역에 남게 된다. (물론 프로세스가 종료되면 자동으로 해제 된다.)


이는 메모리 누수가 일어나는 이유가 되기도 하니, 꼭 사용 후에는 함수가 끝나기 전에 메모리를 해제해야 한다.


Data


데이터 영역은 정적 변수(static)와 전역 변수, 배열, 구조체 등이 할당되는 영역이다.

이 데이터 영역의 변수들은 프로그램이 실행될 때 생성되고, 프로그램이 종료되면 시스템에 반환 되게 된다.

Stack의 지역변수는 그 지역변수가 속해 있는 함수가 끝이 나면 사라지지만, 데이터 영역은 프로그램이 끝날 때 까지 계속 살아있기 때문에 다른 함수들 끼리 공통된 데이터 영역을 사용할 수 있다는 장점이 있다.

따라서 보통 어디에서나 사용할 법한 변수들은 모두 전역 변수나 static 변수로 선언하여 데이터 영역에 저장하게 된다.


이렇게 편리하기 때문에 쉽게 남용을 하곤 한다. 하지만 Data영역을 남용해서는 안되는 이유가 있는데 이는 객체지향, 디자인패턴과 연관이 있다. 따로 포스팅하겠다.


뭉뚱그려 Data 영역이라고 말 하지만, 사실 Data영역과 BSS라는 영역으로 나뉜다.

초기화가 된 변수는 Data 영역에, 초기화 되지 않은 영역은 BSS영역에 저장된다.


Code(Text)


마지막으로 코드(또는 Text) 영역이다.


우리가 프로그램을 만들 때에 작성한 코드들이 바로 여기에 들어간다.

당연히 완벽히 똑같이 들어가진 않고, 우리가 컴파일러로 컴파일하면 Hex 파일이나 Bin 파일으로 변환되어 이 영역에 들어간다.

그렇기 때문에 위 Stack에서 생겨난 문제점인 "왜 순서대로 Stack에 쌓이지 않지?" 의 이유가 컴파일된 코드가 들어가기 때문이다.


생각보다 컴파일러는 많은 일을 한다.

우리가 #define으로 선언한 매크로는 컴파일 시점에 모두 매크로에서 정의한 상수, 혹은 함수들로 대체 되기도 하고, for 문의 i++을 ++i로 대체해 주기도 한다.(전위 증가 방식이 후위 증가 방식보다 더욱 빠르기 때문.)


(사실 #으로 시작하는 지시자들은 "전처리기"라는 녀석이 대체해 준다. 컴파일 직전에 실행 된다. 하지만 컴파일 버튼을 누르면 실행되는 것이기도 하고, 위는 "사용자의 코드가 그대로 해석되어 code 영역에 적재된다."라는 것에 예외를 설명하기 위함이니 그저 컴파일 이라고 말하겠다.)


#include <stdio.h>


#define SOME_NUMBER 4
#define PRINT(X) printf("%d\n",X)


int main(){
    int a = SOME_NUMBER; // 컴파일 시 4로 대체.

    PRINT(a); // 컴파일 시 printf("%d\n",a)로 대체

    for (int i = 0; i < 3; i++) { // 컴파일 시 ++i로 대체.
        printf("Hello\n");
   
    }


    return 0;
}


결과:




이렇게 컴파일이 되어 data영역에 들어간 코드들은 한 줄, 한 줄 CPU가 읽어 명령을 실행 하게 된다.


CPU는 code영역의 모든 명령어들을 한번에 다 읽지 않는다.

한번에 읽게 되면, 이 프로세스가 끝날 동안 다른 프로세스들은 일을 하지 못하기 때문이다.(멀티 프로세싱)

그래서 적절한 시간이 지나거나, 컴퓨터 입장에서 느린 사용자의 입력, 출력을 기다리게 되면Interrupt를 발생 시켜 현재 어디까지의 명령어를 실행 했는지 저장한 후에 다른 프로세스의 명령을 수행하게 된다. (이를 Context Switching, 문맥 교환이라고 합니다.)


마치며

전에 포스팅 했던 컴파일과 링크, 빌드"에서 부터 프로그램이 만들어 진 후 프로세스에 올라가는 과정까지 알아 보았다.

코드를 작성할 때 이런 거 하나 하나 신경 쓰지 않고 만든다. 하지만 나중에 있을 예기치 못한 문제를 직면하였을 때, 이런 지식을 알고 있는 것과 모르고 있는 것은 그 문제를 해결하는 데에 있어 분명한 차이가 있다고 생각한다. 또한 다른 분야에 진출했을 때, 이런 공통되고 바뀌지 않는 지식을 가지고 있는 것은 분명 도음이 된다.

또한 깊게 파고드는 성질은 프로그래머가 반드시 가져야 할 덕목이라고 생각한다.



댓글