Computer Science 기본 지식/컴퓨터 구조

[컴퓨터 구조] 함수 호출 규약

로파이 2021. 5. 1. 23:34

함수 호출 규약이란

함수 호출 시 일어나는 행동에 대한 규칙을 의미하며 함수 인자들에 대해 어떤 순서로 스택에 쌓을 것 인지, 인자를 레지스터로 이용할 것 인지 그리고 함수 종료 후 스택을 누가 정리할 것 인지에 대해 정해놓은 규칙이다.

 

어떤 함수 규약이 호출되던, 다음과 같은 일이 발생한다.

 

1. 모든 인자들은 4 바이트(8 바이트)로 확장되고 적절한 메모리 위치로 삽입된다. 이 위치들은 주로 스택 상 메모리이지만 레지스터들을 사용할 수 도 있다. 이는 호출 규약에 따른다.

 

2. 프로그램 실행은 호출된 함수의 주소로 점프한다.

 

3. 함수 안에서 보존 레지스터들이 스택에 저장된다. 함수 프롤로그라고도 하며 컴파일러가 작성한다.

 

4. 함수에 해당하는 코드들이 실행되고 return 반환값이 eax 레지스터에 저장된다.

 

5. 3에서 저장한 레지스터들을 스택에서 꺼내 복구한다. 함수 에필로그라고도 하며 컴파일러가 작성한다.

 

6. 스택에서 함수 인자들이 제거된다. 이 과정은 스택 비우기로 불리고 피호 출자 함수 내에서 실행되거나 호출자에 의해 실행된다. 이는 함수 호출 규약에 따른다.

 

- 함수 호출 규약

Segment word size Calling Convention Parameters in registers Parameter order on stack Stack cleanup by
32bits __cdecl   right to left Caller
__stdcall   Function
__fastcall ecx, edx Function
__thiscall ecx Function
64bits - rcx, rdx, r8, r9
(xmm0, xmm1, xmm2, xmm3)
Caller

 

-- 32bits 예제

 

__cdecl

#include <iostream>

int __cdecl sum(int a, int b)
{
	return a + b;
}

int main()
{
	int c = sum(2, 3);

	return 0;
}
  • 인자들은 오른쪽에서 왼쪽 순으로 스택에 저장된다.
  • 스택은 호출자에 의해 정리가 된다. (맨 위가 가장 첫번째 파라미터가 된다.)

일반적인 함수의 프롤로그와 연산 부분 그리고 에필로그 부분을 볼 수 있다. 프롤로그에서는 베이스 포인터 레지스터(ebp)를 스택에 저장하고 스택 포인터(esp)를 베이스 포인터 레지스터에 할당하는 것을 볼 수 있다.

__cdecl에서 인수들이 3, 2 순으로 스택에 push되며 함수 반환 이후 호출자에 의해 스택이 정리가 된다. (add esp, 8)

__cdecl는 Caller만 함수 인수들의 개수를 알 때 사용한다. printf와 같이 가변 인수를 사용하는 함수는 호출자만 인수 개수를 알기 때문에 호출자가 스택을 정리핟나.

 

__stdcall

#include <iostream>

int __stdcall sum(int a, int b)
{
	return a + b;
}

int main()
{
	int c = sum(2, 3);

	return 0;
}
  • 인자들은 오른쪽에서 왼쪽 순으로 스택에 저장된다.
  • 스택은 피호 출자(함수)에 의해 정리가 된다.
  • 주로 WinAPI 32 함수들이 이 호출 규약을 따른다. 

__stdcall에서 함수 종료 부분

ret 8은 반환 시 스택 포인터를 8 만큼 증가시킨다. 이는 함수가 스택을 정리하는 것을 의미한다.

 

__fastcall

#include <iostream>

int __fastcall sum(int a, int b)
{
	return a + b;
}

int main()
{
	int c = sum(2, 3);

	return 0;
}
  • 첫 두 인자들이 4 바이트보다 작거나 같다면 레지스터 edx, ecx에 담아두고 사용한다. 나머지는 스택에 저장된다.
  • 레지스터를 사용하기 때문에 함수 호출 비용을 줄일 여지가 있다.
  • 함수에 의해 스택이 정리된다. 위 예제에서는 정리할 스택 내용이 없다.

__fastcall : 레지스터에 인수들을 저장한다.

__thiscall

class Object
{
public:
	int sum(int a, int b) { return a + b; }
};
int main()
{
	Object obj;

	int c = obj.sum(2, 3);
	return 0;
}
  • 멤버 함수를 호출하는 규약으로 this 포인터는 ecx에 저장되고 나머지는 오른쪽에서 왼쪽으로 스택에 저장된다.
  • 함수에 의해 스택이 정리된다.

main()
멤버 함수 sum() 종료 부분

 

Windows 64 bits 운영체제, C++에서 __cdel, __stdcall 그리고 __fastcall의 호출 규약에서 항상 Caller가 스택을 정리한다. 또한 함수 인수들에 대해 4개의 레지스터들을 사용한다.

https://docs.microsoft.com/ko-kr/cpp/build/x64-calling-convention?view=msvc-160

Windows 64 bits 운영체제에서 함수 인자에 따른 저장되는 메모리 종류를 나타낸다. 가장 왼쪽 인자부터 rcx/xmm0, rdx/xmm1, r8/xmm2, r9/xmm3에 차례대로 레지스터에 저장되며 그 이후 인자는 스택에 저장된다.

__m128 형식, 배열 및 문자열은 값에 의해 전달되지 않고 호출자가 할당한 메모리에 포인터가 저장되어 전달된다.

 

-- 64 bits 예제

#include <iostream>

int __cdecl sum(int a, int b,int c, int d, int e, int f)
{
	return a + b + c + d + e + f;
}

int main()
{
	int c = sum(1,2,3,4,5,6);
	return 0;
}

main() 디스어셈블리
스택 포인터 값
스택 포인터 + 20h 오프셋에 5, 6값이 저장

1,2,3,4,5,6 인수를 전달했을 때, 1,2,3,4는 차례대로 ecx, edx, r8, r9 레지스터에 저장된다. 64 bits 운영체제에서는 스택에 push를 하지 않고 스택 포인터보다 높은 주소의 메모리에 나머지 인자를 4바이트 크기로 저장하고 있다.

sum() 디스어셈블리

함수 프롤로그 내용들

  • mov dword ptr [rsp+32], edx : r9값을 스택 포인터 + 32 오프셋에 저장한다. 
  • mov dword ptr [rsp+24], edx : r8값을 스택 포인터 + 24 오프셋에 저장한다. 
  • mov dword ptr [rsp+16], edx : edx값을 스택 포인터 + 16 오프셋에 저장한다. 
  • mov dword ptr [rsp+8], edx : ecx값을 스택 포인터 + 8 오프셋에 저장한다. 
  • rbp, rdi를 스택에 push 한다.
  • sub rsp, 028h

스택 포인터 값
모든 인자들을 저장한 후 메모리 상태

  • 레지스터에 담아두었던 인자들을 다시 스택 포인터의 윗 부분에 저장해 두고 있다.
  • 지역 변수들은 스택 포인터의 윗 주소에 저장되는 것으로 보인다.
  • rsp를 E8h(232) 주소만큼 감소시키고 rbp에는 [rsp] + 20h = rsp - E8h  + 20h주소를 저장한다.

함수 반환 이후

  • eax에 저장된 결과를 지역 변수 c에 저장한다.
  • 종료 직전에는 다시 rsp를 rdi와 rbp를 push하기 직후의 값으로 복원한다.
  • 이후 차례대로 스택에 저장해둔 rdi와 rbp를 꺼내온다.