2022. 12. 10. 04:32ㆍ카테고리 없음
1. 스택 메모리
스택 메모리는 함수 내의 지역 변수 등을 저장하는 메모리라는 것을 C/C++을 배웠던 사람이라면 누구나 배웠을 법한 내용이다. 다만 이 글에서는 어떤 메모리가 어떤 녀석인지까지 처음부터 적지는 않을 것이고 그냥 내가 몰랐던 부분만 몇 개만 짚고 넘어갈 것이다. 우선 용어 정리부터 간단하게 해보자.
스택 프레임: 각 함수가 사용하는 스택의 범위
EBP (Extended Base Pointer): 현재 스택 프레임이 사용하고 있는 첫 시작 주소
ESP (Extended Stack Pointer): 스택 메모리 내에서 현재 사용하고 있는 곳까지의 주소
기본적으로 프로그램이 실행되면 그 프로세스를 위한 메모리로 용도에 따라 코드, 데이터, 힙, 스택으로 4개의 영역으로 구분되는 것을 볼 수 있다. 이 글에서는 4개의 메모리 영역 중 스택에 대해서 알아볼 것이다. 프로그램이 실행되었을 때 그 프로그램에게 필요한 전체 스택의 크기는 프로그램 빌드 시에 결정되며 어느 정도의 크기를 할당해주는지는 컴파일러마다 다르다. 스택이 할당되는 위치는 프로그램 실행 시에 결정되며 프로그래밍 돌아가는 도중 어떤 함수가 호출되면 EBP와 ESP라는 것을 이용해서 스택 프레임을 만들게 된다.
스택이라는 메모리는 그 특성상 오버플로우가 일어나지 않게 조심해야 한다. 방금 말한 내용이지만 프로그램에게 필요한 전체 스택의 크기는 프로그램 빌드 시에 결정되며 컴파일러마다 다르게 스택을 할당해준다는 내용을 말했었다. 이 말은 컴파일러마다 쓸 수 있는 스택 메모리가 서로 다르다는 뜻이 되고 내가 어떤 컴파일러에서 너무 큰 배열을 선언해서 너무 많은 스택 메모리를 사용했을 때, 그 컴파일러에서는 돌아가도 다른 컴파일러에서는 돌아가지 않을 수 있다는 것이다. 또한 재귀 함수가 너무 많은 루프를 돌 때도 스택 오버플로우가 일어날 수 있으니 조심해야 한다. 함수 호출 스택 역시 스택 메모리를 사용하기 때문이다. 이런 스택 오버플로우는 잡아주는 경우도 있고 잡아주지 않는 경우도 있는데 컴파일러마다 다르기 때문에 그냥 그런 코드를 최대한 짜지 않으려고 노력해야 한다.
여담으로 멀티 쓰레드 환경에서 프로그래밍을 한다면 각 쓰레드마다 함수 호출 스택이 따로 존재하기 때문에 어떤 쓰레드가 다른 쓰레드의 함수를 대신 처리할 수 없다는 사실을 기억해놓자. 각 쓰레드별로 호출하는 함수의 호출, 반환하는 위치가 정해져있기 때문이라는 사실도 기억해놓으면 좋을 것이다. 이제 어셈블리어를 통해서 함수가 호출되었을 때 실제로 어떻게 동작하는지를 알아볼 것이다.
아래의 코드는 C로 짠 소스 코드이다.
int Add(int a, b)
{
int res = a + b;
return res;
}
int main(void)
{
int a = 1;
int b = 2;
Add(a, b);
return 0;
}
이제 이걸 컴파일러를 통해서 어셈블리어로 변환하면 다음과 같은 방식으로 나온다. 참고로 어셈블리어부터는 기계마다 달라질 수 있기때문에 다른 사람의 컴퓨터에서 돌렸을 때 이것과 다른 어셈블리어가 나올 수도 있으니 참고하자.
00011000 push ebp
00011001 mov ebp, esp
00011003 sub esp, 14h
00011006 mov dword ptr[ebp-4], 0
0001100D mov dword ptr[ebp-8], 1
00011014 mov dword ptr[ebp-0Ch], 2
0001101B mov eax, dword ptr[ebp-0Ch]
0001101E mov ecx, dword ptr[ebp-8]
00011021 mov dword ptr[esp], ecx
00011024 mov dword ptr[esp+4], eax
00011028 call 00011040
0001102D add esp, 14h
00011030 pop ebp
00011031 ret
2. 어셈블리어
이제 위에서 어셈블리어로 짠 것을 한 줄씩 살펴보도록 하자. 여기서 push ebp는 함수의 스택 프레임 시작을 위해 현재 ESP가 가리키는 메모리 주소에 현재 호출된 함수가 끝나고 돌아가야 할 주소를 저장하는 것이다. 이렇게 해야 나중에 함수가 끝나고 나서 함수가 끝난 이후의 작업을 처리할 수 있기 때문에 ESP를 통해서 그걸 저장하는 것이다. 그리고 mov ebp, esp는 ebp와 esp를 같은 값으로 세팅해주는 장면이다. 이러면 EBP와 ESP가 같은 메모리 주소를 가리키게 된다.
00011000 push ebp
00011001 mov ebp, esp <- 여기까지 실행됨
00011003 sub esp, 14h
00011006 mov dword ptr[ebp-4], 0
0001100D mov dword ptr[ebp-8], 1
00011014 mov dword ptr[ebp-0Ch], 2
0001101B mov eax, dword ptr[ebp-0Ch]
0001101E mov ecx, dword ptr[ebp-8]
00011021 mov dword ptr[esp], ecx
00011024 mov dword ptr[esp+4], eax
00011028 call 00011040
0001102D add esp, 14h
00011030 pop ebp
00011031 ret
그림으로 따지면 다음과 같은 상황이 된다. main함수가 시작되면 아직 아무것도 하지 않았기 때문에 EBP와 ESP가 같은 곳을 가리키고 있는 상황이고 스택 메모리는 아직 아무도 사용하고 있지 않은 상황이다.
다음으로 sub esp, 14h는 esp의 값을 14h만큼 빼겠다는 뜻이고 14뒤에 h가 붙은 이유는 16진수이기 때문이다. 프로그램이 시작하는 단계에서 esp의 값을 뺀다는 것은 main함수가 호출되어서 main함수를 위한 스택 프레임을 잡는 것이다. 스택 프레임을 잡는 과정에서 메모리가 14h크기만큼 필요해서 14h를 빼준 것이다.
00011000 push ebp
00011001 mov ebp, esp
00011003 sub esp, 14h <- 여기까지 실행됨
00011006 mov dword ptr[ebp-4], 0
0001100D mov dword ptr[ebp-8], 1
00011014 mov dword ptr[ebp-0Ch], 2
0001101B mov eax, dword ptr[ebp-0Ch]
0001101E mov ecx, dword ptr[ebp-8]
00011021 mov dword ptr[esp], ecx
00011024 mov dword ptr[esp+4], eax
00011028 call 00011040
0001102D add esp, 14h
00011030 pop ebp
00011031 ret
그림으로 보면 다음과 같다. main함수를 위한 메모리 공간을 스택 포인터를 이용해서 할당하는 것이다.
mov dword ptr[ebp-4], 0은 ebp로부터 4만큼 떨어진 곳에 0을 집어넣는 다는 뜻이다. 근데 C언어로 짠 소스 코드를 보면 알겠지만 어디에도 0을 넣는 부분이 없다... 강의에서는 아무 의미없는 거니까 그냥 다음 줄로 넘어갔었다. 그 밑에 있는 코드는 우리가 선언했던 a, b라는 변수에 각각 1, 2를 넣었는데 그걸 의미한다고 생각하면 된다. 각 변수 a, b의 위치를 ebp-4, ebp-8에 설정해놓고 거기에 값을 집어넣는 것이다.
00011000 push ebp
00011001 mov ebp, esp
00011003 sub esp, 14h
00011006 mov dword ptr[ebp-4], 0
0001100D mov dword ptr[ebp-8], 1
00011014 mov dword ptr[ebp-0Ch], 2 <- 여기까지 실행됨
0001101B mov eax, dword ptr[ebp-0Ch]
0001101E mov ecx, dword ptr[ebp-8]
00011021 mov dword ptr[esp], ecx
00011024 mov dword ptr[esp+4], eax
00011028 call 00011040
0001102D add esp, 14h
00011030 pop ebp
00011031 ret
그림으로 보면 다음과 같이 EBP와 가까운 순서대로 0, 1, 2라는 값이 들어간 것을 볼 수 있다.
이제 eax, ecx에 mov 명령을 통해서 값을 집어넣는 것을 볼 수 있다. 여기서 eax, ecx는 CPU에 있는 임의의 레지스터를 의미한다. 이렇게 값을 집어넣는 이유는 매개 변수로 값을 전달하기 위해서이고 매개 변수로 값을 전달하는 부분이 mov dword ptr[esp], ecx와 mov dword ptr[esp+4], eax라는 부분이다. 그림으로 보면 다음과 같다.
00011000 push ebp
00011001 mov ebp, esp
00011003 sub esp, 14h
00011006 mov dword ptr[ebp-4], 0
0001100D mov dword ptr[ebp-8], 1
00011014 mov dword ptr[ebp-0Ch], 2
0001101B mov eax, dword ptr[ebp-0Ch]
0001101E mov ecx, dword ptr[ebp-8]
00011021 mov dword ptr[esp], ecx
00011024 mov dword ptr[esp+4], eax <- 여기까지 실행됨
00011028 call 00011040
0001102D add esp, 14h
00011030 pop ebp
00011031 ret
컴파일러마다 기계마다 다르겠지만 여기서는 매개 변수를 통해 값을 전달하기 위해 아까처럼 EBP를 사용하지 않고 ESP를 사용한 것을 볼 수 있다. 다시 말하지만 어셈블리어 단계부터는 기계마다 컴파일러마다 같은 내용이더라도 코드는 충분히 달라질 수 있다. 애초에 어셈블리어가 기계어와 1:1로 대응이 되는 언어이기 때문이다. 그래서 기계마다 다른 것이다.
매개 변수에 값을 전달하기 위한 작업까지 마쳤으니 이제 함수를 호출시킬 수 있다. call 00011040이라는 부분이 호출시킬 함수의 코드가 존재하는 위치이다. 그래서 Add라는 함수를 호출해서 실행하기 위해 CPU내에 존재하는 PC라는 레지스터가 00011040으로 점프해서 거기에 있는 코드를 실행하는 것이다.
00011000 push ebp
00011001 mov ebp, esp
00011003 sub esp, 14h
00011006 mov dword ptr[ebp-4], 0
0001100D mov dword ptr[ebp-8], 1
00011014 mov dword ptr[ebp-0Ch], 2
0001101B mov eax, dword ptr[ebp-0Ch]
0001101E mov ecx, dword ptr[ebp-8]
00011021 mov dword ptr[esp], ecx
00011024 mov dword ptr[esp+4], eax
00011028 call 00011040 <- 여기까지 실행됨
0001102D add esp, 14h
00011030 pop ebp
00011031 ret
그림으로 보면 다음과 같이 현재 ESP가 가리키고 있는 주소에 지금 호출한 함수가 끝나면 돌아가야 하는 곳의 주소를 저장하는 모습을 볼 수 있다. 아까 전에 main함수를 호출시켰을 때도 이것과 똑같은 원리였다.
이제부터 볼 어셈블리어 코드는 Add함수에 대한 내용이다. main함수를 봤을 때와 크게 달라질 것은 없다.
00011040 push ebp <- 여기까지 실행됨
00011041 mov ebp, esp
00011043 sub esp, 0Ch
00011046 mov eax, dword ptr[ebp+0Ch]
00011049 mov ecx, dword ptr[ebp+8]
0001104C mov edx, dword ptr[ebp+8]
0001104F add edx, dword ptr[ebp+0Ch]
00011052 mov dword ptr[ebp-4], edx
00011055 mov edx, dword ptr[ebp-4]
00011058 mov dword ptr[ebp-8], eax
0001105B mov eax, edx
0001105D mov dword ptr[ebp-0Ch], ecx
00011060 add esp, 0Ch
00011063 pop ebp
00011064 ret
Add함수가 호출되어 push ebp가 되었기 때문에 현재 EBP가 가리키는 주소를 ESP에 넣어주는 모습이다. 함수가 끝나서 pop이 이루어지면 해당 메모리 주소를 통해서 EBP의 값이 main함수 스택 프레임의 시작 지점으로 돌아가게 된다.
함수 호출로 ebp의 값을 변경시켰으면 이제 Add함수의 스택 프레임을 잡아줄 차례이다. sub esp, 0Ch라고 되어있는 부분이 Add함수가 필요한 메모리만큼 esp의 값을 빼준다. 그럼 앞서 main함수와 마찬가지로 스택 메모리가 할당되는 것이다.
00011040 push ebp
00011041 mov ebp, esp
00011043 sub esp, 0Ch <- 여기까지 실행됨
00011046 mov eax, dword ptr[ebp+0Ch]
00011049 mov ecx, dword ptr[ebp+8]
0001104C mov edx, dword ptr[ebp+8]
0001104F add edx, dword ptr[ebp+0Ch]
00011052 mov dword ptr[ebp-4], edx
00011055 mov edx, dword ptr[ebp-4]
00011058 mov dword ptr[ebp-8], eax
0001105B mov eax, edx
0001105D mov dword ptr[ebp-0Ch], ecx
00011060 add esp, 0Ch
00011063 pop ebp
00011064 ret
그림으로 표현하면 이렇게 된다. Add함수에서 필요한만큼 ESP의 값을 빼서 밑으로 내려가있는 모습이다.
비슷한 내용이니 많이 건너 뛰었다. 앞에서 매개 변수로 받은 부분을 레지스터로 받아오는 부분이 mov eax, dword ptr[ebp+0Ch]와 mov ecx, dword ptr[ebp+8]에 대한 부분이다. 그리고 int res = a + b; 라는 코드를 본 적이 있을 것이다. 여기서는 edx라는 레지스터에 두 값을 더한 값이 들어간 모습이다.
00011040 push ebp
00011041 mov ebp, esp
00011043 sub esp, 0Ch
00011046 mov eax, dword ptr[ebp+0Ch]
00011049 mov ecx, dword ptr[ebp+8]
0001104C mov edx, dword ptr[ebp+8]
0001104F add edx, dword ptr[ebp+0Ch]
00011052 mov dword ptr[ebp-4], edx <- 여기까지 실행됨
00011055 mov edx, dword ptr[ebp-4]
00011058 mov dword ptr[ebp-8], eax
0001105B mov eax, edx
0001105D mov dword ptr[ebp-0Ch], ecx
00011060 add esp, 0Ch
00011063 pop ebp
00011064 ret
그래서 그림으로 나타내면 다음과 같다. 밑에 2칸이 더 있는데 솔직히 있을 필요가 없다. 왜냐하면 매개 변수에 대한 부분때문에 2칸을 따로 잡은 것인데 저것에 대한 연산은 이미 앞에서 끝났기 때문에 정말 효율적으로 최적화를 한다면 저 2칸은 원래 존재할 필요가 없는 것이다. 하지만 왜 그런 식으로 돌았냐고 하면... 나도 모른다. 다시 말하지만 어셈블리어는 기계마다 작성되는 코드가 다르며 이 경우도 그냥 그 기계에서 그 컴파일러가 그렇게 연산을 한 것이다.
함수가 끝났을 때, 컴퓨터에서 스택 메모리를 해제하는 방식은 정말 간단하다. 그냥 esp의 값을 기존에 할당했던 스택 프레임의 크기만큼 더한다... 이게 끝이다. 그래놓고 기존에 썼던 부분은 값이 그대로 남아있지만 그냥 쓰레기 값으로 남겨놓는 것이다. 그래서 이론상 그곳에 있는 데이터를 읽어올 수는 있지만 절대 그렇게 코드를 짜면 안된다.
00011040 push ebp
00011041 mov ebp, esp
00011043 sub esp, 0Ch
00011046 mov eax, dword ptr[ebp+0Ch]
00011049 mov ecx, dword ptr[ebp+8]
0001104C mov edx, dword ptr[ebp+8]
0001104F add edx, dword ptr[ebp+0Ch]
00011052 mov dword ptr[ebp-4], edx
00011055 mov edx, dword ptr[ebp-4]
00011058 mov dword ptr[ebp-8], eax
0001105B mov eax, edx
0001105D mov dword ptr[ebp-0Ch], ecx
00011060 add esp, 0Ch <- 여기까지 실행됨
00011063 pop ebp
00011064 ret
그림으로 보면 이렇게 된다. ESP의 값을 더해준 것으로 EBP와 ESP의 값이 같아진 것을 볼 수 있다. 그리고 pop이 일어나면 EBP의 값은 현재 가리키고 있는 주소의 데이터를 근거로 이전에 있던 위치로 다시 되돌아간다.
이후에 동작하는 것들은 전부 비슷한 원리로 동작하는 것들이라 따로 적지는 않았다. 어셈블리어로된 코드를 보고 어셈블리어를 따로 공부해야 하는가를 생각할 수 있다. 그런데 C언어를 배운지 얼마 되지않았고 그냥 복습만 하고 있는 정도라면 물론 알면 좋지만 개인적으로 굳이 어셈블리어를 따로 공부하고 싶지는 않고 그냥 "이게 이런 뜻입니다."라고 이야기했을 때 "아, 그냥 그런가 보다." 정도로 이해하고 넘어갈 수준이면 충분하다고 생각한다. 어차피 컴파일러마다 기계의 CPU마다 작성되는 어셈블리어가 달라질 수 있기 때문에 본인이 직접 작성하는 것보다 대충 간단한 것이라도 읽고 이해하는 것이 더 중요할 것이다.