C언어

12) 프로세스와 메모리 할당

SleeveStar 2021. 4. 10. 14:02
반응형

프로그램과 프로세스

 

C언어로 작성한 소스 파일은 컴파일 작업과 링크 작업을 거쳐서 기계어로 이루어진 실행 파일(.exe)이 된다.

 

이렇게 프로그래머가 만든 프로그램 실행 파일을 컴퓨터 사용자들은 프로그램이라고 부른다.

 

그런데 실행 파일에 있는 명령들은 cpu가 직접 실핼할 수 없다. cpu가 이 명령들을 실행하려면 먼저 운영체제가 실행 마일의 명령들을 읽어서 메모리에 재구성하게 되는데 이것을 프로세스라고 한다.

 

이렇게 메모리에 프로세스가 구성되면 CPU는 프로세스에 저장된 명령들을 실행할 수 있다. 그래서 프로세스를 '실행중인 프로그램' 이라고도 한다.

 

프로세스는 단순히 실행할 명령들로만 이루어져 있는 것이 아니라 다음 그림처럼 여러가지 정보나 사용자가 입력한 데이터를 기억하는 메모리 공간도 포함하고 있다. 이런 공간을 세그먼트 라고 한다.

 

프로세스는 세그먼트의 집합으로 구성되어 있으며, 코드 세그먼트, 데이터 세그먼트, 스택 세그먼트는 각각 한 개 이상의 세그먼트로 구성된다.

 

*세그먼트는 64Kbytes 이하의 메모리 블럭이다.

 

기계어 명령문 문자열 상수 목록 동적 메모리 할당
(Heap)
전역 변수
(0으로 초기화)
static 전역 번수
(초기화 안 됨)
지역 변수
(Stack)

 

 

 

코드 세그먼트

 

컴파일러는 C언어 소스를 기계어로 된 명령문으로 번역해서 실행파일을 만든다. 실행 파일이 실행되어 프로세스가 만들어지면 이 기계어 명령들은 프로세스의 '코드 세그먼트'에 복사되어 프로그램 실행에 사용된다.

 

 

데이터 세그먼트

 

프로그램이 시작해서 끝날 때까지 계속 사용되는 데이터는 '데이터 세그먼트'에 보관된다. 이 영역에 있는 데이터는 컴파일 할 때 정해지며 C언어에서 사용한 문자열 상수나 전역 변수, static 변수가 이 영역을 사용한다.

 

 

스택 세그먼트

 

'스택 세그먼트'는 프로그램 실행 중에 필요한 임시 데이터를 저장하는 데 사용하는 메모리 영역이다. 함수가 호출될 때 함수 안에 선언한 지역 변수가 이 영역에 할당된다. 이번 장에서 배우게 될 동적 메모리 할당 기술을 사용해서 할당된 메모리도 이 영역에 할당된다.

 

스택 세그먼트는 지역 변수가 놓이는 스택(stack)과 동적으로 할당되는 메모리 공간인 힙(Heap)으로 나뉜다. 

 

 

정적 메모리 할당

 

컴파일러가 C 언어 소스 코드의 변수 선언 부분을 번역할 때, 데이터 세그먼트나 스택 세그먼트에 해당 변수를 저장할 메모리 영역을 배정한다. 이렇게 컴파일러가 코드를 기계어로 번역하는 시점에 변수를 저장할 메모리의 위치를 배정하는 것을 정적 메모리 할당 이라고 한다.

 

*번역하는 시점에 결정되기 때문에 할당된 메모리 크기나 개수를 변경하려면 코드를 변경하고 다시 컴파일 해야 한다.

 

이렇게 할당 받은 메모리는 지연 변수인지 전역 변수인지에 따라 유지되는 시간도 다르다. 그래서 메모리의 효율적인 관리를 위해 변수 종류에 따라 사용하는 메모리 공간의 위치도 달라진다.

 

전역변수를 저장하는 공간은 데이터세그먼트, 지역변수는 스택 세그먼트에 메모리가 할당된다.

 

 

 

정적으로 할당된 메모리를 관리하는 법

 

전역 변수는 프로그램이 시작해서 끝날 때까지 할당된 메모리 크기나 주소가 바뀌지 않기 때문에 전역 변수가 어디에 할당되어 있는지에 대한 추가정보가 필요 없다.

하지만 지역 변수는 함수 호출에 따라 메모리 할당과 해체가 반복되기 때문에 변수 주소가 계속해서 바뀐다. 따라서 지역 변수를 사용하려면 그 변수의 현재 주소를 알아야 한다.

 

int Test()

{

     int a, b, c, d;

 

     a = 5; //start 주소에 5를 넣음

     c = 3; //start + 8 주소에 3을 넣음

}

...
a
b
c
d
...

                                                   

*a, b, c, d 변수들은 정수형이라서 4바이트 공간을 차지하기 때문에 주소가 4씩 증가한다.

 

 

 

지역 변수와 스택

 

스택

 

스택은 자료 구조의 한 종류이며 두개의 포인터로 많은 양의 데이터를 효과적으로 관리하는 이론이다.

스택은 베아스 포인터(Base Pointer, BP)를 기준으로 데이터가 추가될 때마다 순서대로 쌓아 올리는 구조이며 새로운 데이터가 추가될 위치를 스택 포인터(Stack Ppinter, SP)가 가리키게 된다.

 

지역 변수 관리 방식과 비교하자면 베이스 포인터가 START 포인터, 스택 포인터가 END 포인터가 된다. 

 

스택에 데이터를 추가하면 스택 포인터가 가리키는 주소의 메모리에 대입되고 스택 포인터의 주소는 4(32비트 운영체제)만큼 증가한다. 이렇게 데이터를 추가하는 작업을 PUSH라고 한다.

 

그리고 데이터를 꺼낼 때는 가장 마지막에 추가된 값을 제거하고 스택 포인터 주소가 4만큼 감소한다. 이 작업은 POP라고 한다.

 

*스택에서 한 가지 주의해야 할 점이 있다. 이론적으로 스택을 표현할 때는 PUSH가 스택에 데이터를 추가한다는 뜻이기 때문에 스택 포인터에 저장된 주소가 증가하도록 만든다. 그런데 실제 컴퓨터 시스템에서 PUSH 명령을 수행하면 스택 포인터 주소가 감소하도록 만들어져 있다.

따라서 앞에서는 PUSH 명령을 수행하면 스택 포인터 주소가 증가하는 것으로 설명했지만 지금부터는 스택 포인터 주소가 감소 한다고 설명할 것이다.

 

  자료 구조(이론) 컴퓨터 시스템(실제)
PUSH 스택 포인터 주소 증가 스택 포인터 주소 감소
POP 스택 포인터 주소 감소 스택 포인터 주소 증가

 

 

컴파일러가 지역 변수를 저장할 메모리 공간을 확보하는 방법

 

void Test()

{

     int a, b, c;

}

 

이 Test 함수가 시작되는 시점에 a, b, c 변수를 저장할 메모리 공간을 스택 영역에 확보해야 하므로 따라서 컴파일러는 변수를 위한 메모리 공간을 확보하기 위해 ax레지스터를 세 번 PUSH 하는 코드를 기계어로 만든다. 이렇게 하면 프로그램이 실행될 때 PUSH가 3번 수행되어 4바이트 크기의 메로기 공간 3개가 스택에 추가된다. 즉 스택 포인터의 주소가 4씩 3번 감소하여 베이스 포인터와 스택 포인터 사이에 12바이트의 메모리 공간이 생긴다는 뜻이다.

 

그리고 Test 함수가 종료되는 시점에 pop 명령을 세 번 호출하여 a, b, c 변수를 저장하기 위해 스택에 할당했던 공간을 제거하면 된다. 하지만 이런 방법을 사용하면 지역 변수가 많으면 많을수록 PUSH코드와 POP코드도 많아진다 그러면 당연히 프로그램 효율이 떨어지기 때문에 사실 컴파일러는 이와같은 방법을 사용하지 않는다.

 

 

 

Sub 명령과 Add 명령을 사용하는 방법

 

현실적인 방법을 사용해 보자. 스택에 변수를 할당할 때 push명령을 사용하면 스택 포인터에 저장된 주소가 4씩 감소한다. push를 세 번 하면 스택 포인터에 저장된 주소가 12만큼 줄어든다. 그런데 꼭 push명령을 사용해서 스택 포인터에 저장된 주소 값을 줄여야 할까?

 

스택 포인터에 저장된 주소는 기계어 명령으로 직접 변경할 수 있는 값이기 때문에 다음 코드처럼 push 명령을 사용하지 않고 뺄셈을 실행하는 sub 명령으로 스택 포인터 값에서 12를 빼도 된다. 이렇게 하면 베이스 포인터와 스택 포인터 주소 값의 차이가 12가 되면서 결과적으로 push명령을 3번수행한것 과 같아진다.

 

또 함수가 끝날 때 pop 명령을 세 번 실행한 것은 스택 포인터에 저장된 주소 값을 12만큼 증가시키는 것과 같다. 따라서 컴파일러는 덧셈을 수행하는 add 명령을 사용하여 스택 포인터 주소 값에 12를 더해주면 된다.

 

 

컴파일러가 스택에 할당된 지역 변수를 사용하는 원리

 

C 언어 컴파일러는 지역 변수가 선언된 순서대로 메모리를 할당하기 때문에 앞의 그림처럼 스택에 블록 a, 블록 b, 블록 c 순서로 메모리를 할당한다. 그리고 스택에 저장된 데이터를 꺼내려면 가장 최근에 저장된 스택 포인터(SP) 바로 아래의 데이터부터 차례대로 꺼내야 한다. 예를 들어 데이터가 a, b, c 순서로 저장되어 있다면 c, b, a 순서로 데이터를 꺼내야 한다는 뜻이다. 

 

ex) 변수 b의 값을 변경하려면 블록 b를 꺼내와야한다. 그런데 블록b 를 꺼내려면 먼저 블록 c부터 꺼내야 한다. pop 명령을 사용하여 블록 c를 꺼내 bx 레지스터에 저장하고 블록 b를 꺼내 ax 레지스터에 저장한다. 그리고 ax 레지스터에서 블록 b값을 5로 변경한 후 스택에 다시 push하고, pop했던 블록 c 값을 다시 스택에 push해줘야 한다.

 

pop  bx //스택의 가장 위에 있는 c값을 bx에 저장하고 스택에서 c영역을 제거함
pop  ax // 스택의 가장 위에 있는 b 값을 ax에 저장하고 스택에서 b영역을 제거함
mov  ax, 5 // b 값을 변경하기 위해 ax에 값 5를 대입함
push  ax // ax값을 스택에 추가하여 b영역에 값 5가 저장됨
push  bx // bx에 보관하고 있던 c 값을 다시 스택에 추가함

하지만 이런 방법으로 C 언어의 지역 변수가 처리되었다면 지금처럼 수행 속도가 빠르지 않았을 것이다. 왜냐하면 함수에 지역 변수를 많이 선언하면 변수 값 하나를 변경하는 데 수십 개의 push, pop명령을 반복해야 하기 때문이다.

 

 

베이스 포인터를 사용하여 스택에 할당된 지역 변수 사용하기

 

컴파일러가 지역 변수를 사용하기 위해 push, pop 명령을 수십 번 반복하는 것은 굉장히 비효율적인 작업이다.

스택 메모리도 결국 메모리이기 때문에 컴파일러가 해당 변수의 주소를 알면 간접 주소 지정 방식(포인터) 개념을 사용해서 a, b, c 영역의 값을 읽거나 저장할 수 있다는 것이다.

 

* 스택에 데이터를 추가하면 포인터가 가리키는 주소가 감소한다.

 

함수를 호출할 때 스택 메모리가 변화하는 과정

 

C언어로 작성한 프로그램은 한 개 이상의 함수로 이루어져 있고, 이 함수들 중에 main 함수가 호출되면서 프로그램이 시작된다. 그리고 main 함수가 다른 함수를 호출하고 호출된 함수가 또 다른 함수를 호출하면서 프로그램이 진행되는 것이다. 예를 들어 main, Test, Show 함수로 구성된 프로그램이 있다면  main 함수가 호출되면서 프로그램이 시작되고 main 함수에서 Test함수를 호출한다.

 

#include <stdio.h>

void Show()

{

     int n, i, j;

     printf("Show Fuction\n");

}

 

void Test()

{

     int y;

     Show();

}

 

void main()

{

    int a, b ,c;

    Test();

}

 

 

1. main 함수가 Test 함수 호출하기

 

Test 함수를 호출하기 위해 제일 먼저 해야 할 일은 Test 함수가 호출이 끝났을 때 다시 main 함수의 실행위치로 돌아오기 위해서 현재 실행위치를 기억하는 인스트럭션 포인터 레지스터 값을 스택에 저장하는 것이다.

그런 다음 베이스 포인터와 스택 포인터에 저장된 주소를 Test 함수 기준으로 변경한다.

그런데 베이스 포인터의 주소를 Test 함수 기준에 맞도록 다른 주소를 대입하면 원래 저장되어 있던 main 함수의 베이스 포인터 주소를 잃어버리게 된다. Test 함수의 실행이 끝났을 때 다시 main 함수로 돌아가야 하는데 main 함수의 베이스 포인터는 이미 잃어버렸기 때문에 문제가 생긴다.

 

이 문제는 베이스 포인터에 Test 함수를 실행하기 위한 주소를 대입하기 전에, 현재 사용하던 main 함수의 베이스 포인터 주소를 스택에 저장해 두면 간단하게 해결할 수 있다. 그러면 Test 함수의 호출이 끝났을 때 스택에 저장했던 베이스 포인터 주소를 가져올 수 있기 때문에 Test 함수 호출 전에 사용하던 main 함수의 베이스 포인터 주소가 자연스럽게 복구 된다.

 

그리고 Test 함수에는 지역변수가 1개(4바이트)밖에 없으니 스택 포인터에 저장된 주소를 4만큼 감소시켜 y를 저장할 메모리를 할당한다. 이렇게 하면 Test 함수를 실행하기 위한 설정이 완료된다.

 

 

 

2. Test 함수가 Show 함수 호출하기

 

이제 main 함수에서 Test 함수를 호출한 것처럼 Test 함수에서 Show 함수를 호출하는 작업을 진행한다. 먼저 Show 함수가 끝나면 Test 함수로 복귀하기 위해 현재 사용중이던 인스트럭션 포인터 레지스터의 값과 Test 함수가 사용하던 베이스 포인터 주소 값을 스택에 저장한다.

 

Test 함수의 인스트럭션 포인터 레지스터와 베이스 포인터를 스택에 push하여 보관했으니, 스택포인터를 기준으로 show 함수의 베이스 포인터와 스택 포인터를 설정한다. Show 함수의 베이스 포인터는 현재 스택 포인터 위치를 이용해서 가리키면 되기 때문에 현재 스택 포인터의 주소를 베이스 포인터에 대입한다.

그리고 Show 함수에는 지역변수가 3개(12바이트)있기 때문에 스택 포인터에 저장된 주소값을 12만큼 감소시켜서 n, i, j를 저장하기 위한 메모리를 할당한다.

 

3. Show 함수 종료하기

 

앞 예의 메모리 상태에서 show 함수가 끝나면 어떻게 될까?  Show 함수는 test 함수가 불렀다. 따라서 Show 함수가 사용하던 베이스 포인터와 스택 포인터를 Test 함수가 사용하던 베이스 포인터와 스택 포인터로 복구시켜야 한다. 이 작업은 지금까지 한 작업을 거꾸로 진행하면 된다.

 

먼저 스택포인터에 베이스 포인터의 주소를 대입한다. 그렇게 하면 n, i, j를 위해 할당되었던 메모리 공간이 사라진다.

그리고 Test 함수의 베이스 포인터를 복구하기 위해 pop 명령을 사용하여 스택에 저장되어 있는 Test 함수의 베이스 포인터 값을 읽는다. 그리고 다시 한 번 pop 명령을 사용하여 Test 함수가 사용하던 IP값을 읽는다. 이렇게 하면 IP 레지스터 값이 복구되어 Test 함수에서 Show 함수를 호출한 다음 명령으로 이동하게 된다.

 

pop IP 이후를 보면 Test 함수가 Show 함수를 호출하기전으로 스택 메모리의 상태가 복구되었다. 그리고 Test 함수가 끝나면 위 작업과 같은 방법으로 베이스 포인터의 주소를 스택 포인터에 대입하고 main 함수의 베이스 포인터를 스택에서 읽어오면 된다. 그리고 main 함수가 사용하던 인스트럭션 포인터 레지스터 값도 pop 명령으로 스택에서 읽어 오면 main 함수로 실행위치가 복구된다.

 

 

 

스택 프레임이란?

 

이렇게 함수를 호출할 때 일어나는 스택의 변화를 스택 프레임이라고 한다. 앞에서 컴파일러가 C 언어로 작성한 소스 코드에서 변수를 선언한 부분을 기계어로 번열할 때, 변수를 저장할 메모리 위치를 배정하는 것을 '정적 메모리 할당'이라고 했다. 스택 프레임은 컴파일러가 C 언어 코드를 기계어로 번역하는 시점에 결정되기 때문에 이런 형식의 메모리 할당 역시 정적 메모리 할당이다. 그래서 지역 변수를 추가하거나 배열 크기를 변경하려면 스택프레임이 스정되어야 하기 때문에 C언어 코드를 다시 컴파일 해야 한다. 그리고 앞에서도 이야기 했지만 배열을 선언할 때 []안에 반드시 상수를 적어야 하는 이유도 [ ] 안에 변수가 오면 정적으로 할당할 크기를 고정할 수 없어서 스택 프레임을 구성할 수 없기 때문이다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

반응형

'C언어' 카테고리의 다른 글

13) 다차원 포인터  (0) 2021.06.01
12-1) 동적 메모리 할당 및 해제  (0) 2021.05.01
11-2) 배열 시작 주소  (0) 2021.03.14
11) 배열과 포인터 표기법  (0) 2021.03.05
10-2) 표준입력함수 - scanf  (0) 2021.03.03