본문 바로가기

컴퓨터 과학/OS (+약간의 컴퓨터 구조)

프로그램이(프로세스가) 실행 될 때, 메모리의 구조 (code, text 영역)

컴퓨터의 성능을 수치로 나타낸다면 어떤 것을 수치화 해야할까. CPU의 코어 개수, 코어당 클럭, 캐시 메모리의 크기, RAM의 크기, GPU의 클럭, GPU의 로컬 메모리 등등 여러 가지 기준이 있을 수 있지만 컴퓨터에 큰 관심이 없는 사람들 기준으로 생각해보면 SSD의 용량(하드 용량), RAM의 크기 두 가지가 상당히 자주 쓰일 것이라 생각한다.

 

여러 가지 항목들 중 우선 프로그램이 실행될 때, RAM에 프로그램이 어떤 식으로 적재되고 동작 과정에서 어떻게 변하는지부터 천천히 정리하고자 한다.

 


코드(.cpp) 를 실행 가능한 프로그램 (.exe) 로 컴파일을 한 상황을 생각해보자.

 

우선 코드를 전처리한 후, 컴파일하고, 링킹을 하여 최종적으로 프로그램을 받을 수 있을 것이다. 

이때 전처리를 담당하는 프리 프로세서는 include 한 파일의 내용을 치환해준다던지, define을 이용해 정의한 값을 치환하는 등 '#' 키워드를 이용해 작성한 내용을 모두 코드로 바꾸어 줄 것이다.

 

그다음 컴파일러는 인간이 알아볼 수 있는 언어(여기서는 cpp)로 작성된 내용을 어셈블리어 - > 기계어 순으로 바꾸어준다.

컴파일러는 cpp 파일 단위로 기계어 덩어리를 만들어 내기 때문에, 여러 개의 cpp 파일을 한 번에 빌드했다면 여러 개의 기계어 덩어리가 나온다. 이 기계어 덩어리에 추가적인 정보 (예를 들면 다른 cpp 어딘가에 정의되어 있는 전역 변수, 프로젝트에 추가한 라이브러리에 있는 함수 등등)를 넣은 것을 오브젝트 파일이라고 부른다.

 

마지막으로 링커는 여러 개의 오브젝트 파일들을 묶어서 실행 가능한 프로그램(.exe) 파일로 만들어준다. 오브젝트 파일에 들어있는 추가적인 정보를 서로 엮어주는 것인데, 예를 들면

 

"어딘가에 AAA라는 함수가 있는데 그 AAA를 실행하고, 반환 값에 + 10을 해서 출력 함수에 넣어주세요"

 

라는 추가 정보를 보고 AAA라는 함수가 구현되어있는 obj를 찾아서 해당 기계어로 점프를 할 수 있도록 주소를 넣어주는 것이다. 즉 링크까지 끝난 코드는 모든 내용이 기계어로, 어셈블리어로 치환되어 저장되어 있다.

 

 

다음의 간단한 코드를 이용해 실제 컴파일이 종료된 파일에서 기계어를 찾아보자.

#include <stdio.h>
#include <limits>

unsigned long long int a = ULLONG_MAX;
unsigned long long int b = 0;
unsigned long long int c;

int main()
{
	int d = 0;
	int e = UINT_MAX;
	printf("%llu\n%llu\n%llu\n%d\n%d\n", a, b, c, d, e);

	return 0;
}

 

실행 결과

더보기

위의 코드를 실행하면 다음과 같은 결과를 얻을 수 있다.

 

 

 

visual studio를 이용해 해당 코드를 빌드한 뒤, 디버그 모드로 어셈블리를 확인해보자.

(디버그 -> 창 -> 디스어셈블리 탭을 이용)

 

 

우측 디스어셈블리 창에서 소스코드와, 메모리 번지를 제외한 아래가 컴파일된 프로그램의 어셈블리 코드, 기계어 비트이다.

48 83 EC 48          sub         rsp,48h  
C7 44 24 34 00 00 00 00 mov         dword ptr [d],0  
C7 44 24 30 FF FF FF FF mov         dword ptr [e],0FFFFFFFFh  
8B 44 24 30          mov         eax,dword ptr [e]  
89 44 24 28          mov         dword ptr [rsp+28h],eax  
8B 44 24 34          mov         eax,dword ptr [d]  
89 44 24 20          mov         dword ptr [rsp+20h],eax  
4C 8B 0D 1D 25 00 00 mov         r9,qword ptr [c (07FF6691F3628h)]  
4C 8B 05 0E 25 00 00 mov         r8,qword ptr [b (07FF6691F3620h)]  
48 8B 15 1F 1F 00 00 mov         rdx,qword ptr [a (07FF6691F3038h)]  
48 8D 0D 30 11 00 00 lea         rcx,[__xmm@ffffffffffffffffffffffffffffffff+10h (07FF6691F2250h)]  
E8 3B FF FF FF       call        printf (07FF6691F1060h)  
33 C0                xor         eax,eax  
48 83 C4 48          add         rsp,48h  
C3                   ret

이중 "E8 3B FF FF FF   call  printf (07FF6691F1060h)" 라인은 링커가 입출력 라이브러리에서 printf의 주소를 찾아서 값을 넣어준 것이다. 

 

 

이상태 (디버그 모드)에서 현재 프로그램의 메모리를 읽어보면 아래와 같이 기계어 코드가 그대로 메모리에 올라가 있음을 확인할 수 있다.

(디버그 -> 창 -> 메모리 탭을 이용)

 

위와 같이 프로그램의 실행 내용이 기계어로 저장되어있는 공간이 코드 영역이다.

 

 

 

(+) exe 파일을 열어 프로그램의 코드 영역 조작하기

더보기

코드 영역의 기계어 코드를 조작하면 연산자를 바꾸거나, 호출되는 함수를 변경시키는 등의 조작(크래킹) 이 가능하다.

 

그렇기 때문에 OS차원에서 실행 도중의 코드 영역은 read only 속성으로 보호받고 있다.

 

하지만 생각해보면, 결국 응용프로그램이 실행되기 위해서는 기계어 코드가 필요하다. 매번 cpp에서 만들어 낼 수는 없기 때문에 어딘가에 위의 내용들을 저장하고 있다는 것이다.

위의 코드 영역 값은 exe 파일에 저장되어 있는데, 빌드가 끝난 exe 파일을 hex editor 등의 에디터로 열게 되면 해당 내용을 그대로 볼 수 있다.

 

 

hex editor로 연 예제.exe

 

간단히 코드 영역을 조작해보자.

00007FF6561F10E4 C7 44 24 34 00 00 00 00 mov         dword ptr [d],0  
00007FF6561F10EC C7 44 24 30 FF FF FF FF mov         dword ptr [e],0FFFFFFFFh

위의 기계어, 어셈블리 코드는 변수 d의 주소에 0을 넣고, 변수 e의 주소에 0 FFFFFFFF를 넣는다는 의미이다.

이 기계어 코드가 어딘가에 그대로 존재한다.

 

여기서 00 00 00 00과 FF FF FF FF를 64 00 00 00, C8 00 00 00으로 바꿔 줄 수 있다. (100과 200을 리틀 엔디안 16진수로 표현한 값이다)

 

 

이후 해당 exe 파일을 다시 실행해보면 (빌드를 하면 덮어씌워 지므로 직접 디렉토리로 이동하여 실행해야 한다) 0, -1 이 출력됐었던 d, e 값이 100, 200으로 바뀐 것을 확인할 수 있다.