Compiler Explorer를 통한 어셈블리 분석

2022. 3. 1. 07:27C++

 

이 포스트에서는 간단한 예제로 Compiler Explorer에서 생성된 어셈블리를 분석해본다.

생성되는 어셈블리는 각 컴파일러 마다 다르며, 함수로 인자가 넘겨지는 방식이나 스택에 메모리가 얼마나 할당되는지 등은 함수 호출규약(Calling Convention)에 따라 다르므로, 여기서는 생성된 어셈블리에서의 각 숫자에 의미를 부여하는 것 보다는 어셈블리가 어떻게 구성되는지를 보도록 한다.

 

이 포스트에서 컴파일러는 clang(x86-64)을 사용하며, 예제로 사용할 소스코드는 다음과 같다.

https://godbolt.org/z/hKrMdsa7d

 

먼저 main() 함수의 어셈블리를 보자.

 

 

빨간색으로 표시된 영역(19~21)을 함수의 프롤로그(prologue)라고 하며, 함수를 시작하기에 앞서 필요한 레지스터와 로컬 변수들 사용을 위해 필요한 스택 영역을 설정하는 역할을 한다.

 

Line 19.

레지스터들 중에서는 함수가 끝나기 전에 상태를 복구해주어야 하는 레지스터들이 있는데, rbp 레지스터가 그 중 하나이다. 따라서 함수가 끝나기전에 복구시켜 주기 위해 스택에 push 한다.

 

Line 20.
그럼 rbp에는 뭐가 저장되어 있고, 왜 복구를 시켜주어야 하는가?

 

함수 Caller가 다른 함수를 호출하기 전에 해당 함수의 인자들과 함수가 끝난 후 돌아올 위치(return address)를 레지스터 또는 스택에 할당한다. 함수를 호출하면 rsp 레지스터는 이 때 스택의 최상단(가장 낮은 주소)을 가리키고 있는 상태에서 함수가 시작되는데, 현재 상태에서는 rsp 레지스터의 주소 기준으로 -/+를 통해 함수의 인자 및 로컬 변수에 접근할 수 있다.

하지만, 호출된 함수에서 rsp를 변경할 수 있기 때문에 rsp를 기준으로 함수의 인자 및 로컬 변수에 접근하기에는 불편하다. rsp를 변경하기 전에 rbp에 현재 rsp의 값을 기록하여 rbp를 기준으로 함수의 인자 및 로컬 변수에 접근할 수있도록 하는 것이다.

 

Line 21.

main 함수의 로컬 변수 및 함수 호출에 필요한 인자들을 위한 스택 메모리 영역을 확보한다.

이 과정에서 스택의 최상단을 가리키는 스택 포인터(rsp)의 값이 변경된다.

 

 

파란색으로 표시된 영역(22~28), 함수의 바디를 보자.


Line 22~26.

레지스터에 매개변수로 전달되는 인자 값들을 할당한다.
몇 개의 레지스터를 사용하고, 어떤 레지스터를 사용할 지는 호출규약에 따라 다르며, 정해진 레지스터의 개수를 초과하는 인자들에 대해서는 스택에 푸시한다. 인자를 전달함에 있어서 레지스터를 우선으로 사용함으로써 속도에 이점을 얻을 수 있다.

 

Line 27.

foo 함수를 호출한다.

이 때, 함수 호출이 끝나면 돌아올 수 있도록 스택에 반환 주소(return address)를 푸시한다.
(call instruction에서 처리)

 

Line 28.

eax 레지스터에 저장된 foo 함수의 반환 값을 스택에 있는 로컬 변수(value) 메모리로 복사한다.

(함수 caller(main)와 callee(foo)는 각각 eax에 반환 값이 저장되고, 저장해야 한다는 것을 호출규악으로 써 알고있다.)

 

 

주황색으로 표시된 영역(29~32)을 함수의 에필로그(epilogue)라고 하며, 함수가 호출되기 전의 레지스터 및 스택의 상태로 복구하는 역할을 한다.

 

Line 29.

main 함수가 0을 리턴하도록 eax 레지스터의 값을 0으로 만든다.

(main 함수는 예외적으로 return이 없어도 자동으로 생성된다.)

mov eax, 0 으로 eax를 0으로 설정할 수 있지만, xor 명령어가 바이트 크기가 작다고 한다.

 

Line 30.

프롤로그에서 sub 명령어를 통해 확보했던 스택 메모리를 원상태로 복구한다.

 

Line 31.

main 함수를 호출한 caller는 main 함수 호출 전, 후의 rbp의 값이 변경되지 않았다고 가정하기 때문에
프롤로그에서 push 했던 rbp 레지스터의 값을 pop을 통해 복구한다. 

 

Line 32.

main 함수를 호출한 caller가 스택에 push했던 return address를 스택에서 pop한 후, 해당 return address에서 실행을
다시 시작한다. (즉, call main() 명령어의 다음 명령어에서 실행을 시작한다.)

 

 

다음으로 foo(int a, int b, int c, int d, int e) 함수의 어셈블리를 보자.

 

 

레지스터로 전달된 foo 함수의 인자들을 스택 메모리에 복사하고 있다.

이 명령어들은 컴파일러 최적화가 비활성화된 경우에 생성되며, 함수 콜 마다 레지스터의 값이 변경되기 때문에 디버깅을 목적으로 스택 메모리에 복사하는 것이다.

 

그런데, 위와 같이 스택 메모리를 사용하고 있음에도 불구하도 프롤로그에서 sub 명령어를 통해 스택 메모리 영역을 확보하고 있지 않다. 이것은 System V AMD64 ABI 호출규약에서 Red Zone 이라고 불리는 128 바이트의 영역이 최적화를 위해 rsp 레지스터 바로 아래에 예약되어 있기 때문이며, 이렇게 메모리를 예약해서 로컬 변수를 저장하는 등의 목적으로 사용함으로써 rsp 레지스터를 통해 메모리를 확보하는 명령어를 생략할 수 있다. 만약 로컬 변수들의 총 바이트 크기가 Red Zone의 크기(128 bytes)를 초과할 경우에는 프롤로그에 sub 명령어가 들어가게 된다.
(MS x64/vectorcall 호출규약에서는 비슷하게 Shadow Space 라고 불리는 영역이 있다.)

 

 

인자들을 더하는 연산을 하기 위해 임시 저장 공간으로 반환 값이 저장되는 eax 레지스터를 사용하는 것을 볼 수 있다.

mov 명령어를 통해 첫 번째 인자의 값으로 초기화 시킨 후 add 명령어를 통해 나머지 인자들을 더한다.

 

 

스택에 위치한 로컬 변수 x에 위에서 계산한 eax 레지스터 값를 복사한 후, eax의 역할이 반환 값을 저장하는 것이기 때문에 로컬 변수 x의 값을 다시 eax 레지스터에 복사하고 있다.

 

 

간단한 소스코드의 어셈블리를 분석하여 함수 콜이 어떻게 발생하는지 살펴보았다.

이제 최적화를 키기 전, 후의 생성된 어셈블리를 비교해보자.

 

-O0 (최적화 전)

최적화 전의 main 함수를 보면 로컬 변수 value의 사용처가 없음에도 foo 함수 콜을 위한 명령어들을 생성하고 있다.

또한, foo 함수는 디버깅을 위해 레지스터에 있는 인자의 값들을 스택으로 복사하고 있으며, eax 레지스터의 값을 로컬 변수에 복사하여 다시 eax 레지스터에 복사하는 등 비효율적인 작업이 일어나고 있다.

 

-O1 (최적화 후)

최적화 후의 main 함수를 보면 로컬 변수 value를 계산한다해도 사용처가 없기 때문에(사용처가 있어도 값을 반환하거나 side-effect가 있어야 어셈블리를 생성하는 것으로 보임) 반환 값 설정을 위해 eax 레지스터 값을 0으로 만들어주는 것 외에는 어떤 명령어들도 생성되지 않았다. 또한, foo 함수는 디버깅을 위한 레지스터들의 스택 복사가 사라졌으며, 연산이 레지스터들로 이루어져 있는 것을 볼 수 있다.

 

[참조]

- Just Enough Assembly for Compiler Explorer 

- https://en.wikipedia.org/wiki/Red_zone_(computing)

- https://eli.thegreenplace.net/2011/09/06/stack-frame-layout-on-x86-64