C++

함수 호출 규약(function call convention) : _cdecl, _stdcall, _fastcall

멍텅구링 2024. 1. 27. 00:54

 

winapi를 사용하면서 winapi 함수들에 사용된 stdcall에 대해 찾아본 내용을 정리해보았다.

 

함수의 호출을 이해하기 위해서는 우선 콜 스택과 몇가지 레지스터에 대해 이해하고 있어야 한다.

 

콜 스택(Call stack)

함수 호출 시 스택에는 함수의 매개변수 + 함수 본체에서 사용하는 지역변수 + 함수 호출 후 돌아갈 반환 주소 등이 저장되어 있다. 이렇게 스택 영역에 차례대로 저장되는 데이터들을 스택 프레임(Stack Frame)이라고 한다

데이터 구조 + 스택 프레임

이때 함수마다 프레임의 크기는 다르기 때문에 함수 호출이 끝난 후, 해당 함수가 돌아가기 위해서는 바로 이전 프레임의 포인터 값을 가지고 있어야 함. 이러한 정보는 레지스터에 담아 저장해놓는데 이 레지스터들은 EBP, ESP가 있다.

 

EBP(Extended Base Pointer Register)

현재 스택 프레임의 베이스 주소 - 최하단 주소를 저장하고 있음. 따라서 함수 호출이 끝나고 돌아가야 할 이전 프레임의 주소와 같음.

 

ESP(Extended Stack Pointer Register)

현재 스택의 최상단 주소를 저장하고 있는 레지스터.

 

x86기준으로 함수 func(x,y)가 호출되는 방식을 보여주면 다음과 같다.

  1. main 함수 실행
    main함수 실행
  2. func 실행 전
    스택에 함수의 매개인자(2개)들을 할당해준다.
    인자 할당
  3. func 실행
    main 함수의 EBP를 스택에 저장하고
    EBP를 ESP 레지스터의 값으로 변경하고, ESP를 func함수의 최상단 주소 값으로 변경해준다.
  4. func함수 종료
    스택을 반환하면서 저장되어있던 main함수의 EBP를 EBP에 넣어주고
    return pointer에 저장된 메인함수의 실행부분으로 넘어간다. (이때 x,y를 스택에서 제거하는 타이밍을 결정짓는 것은 함수 호출 규약과 관련이 있음) 

 

// 부모(main) 함수
. . .           ; 함수의 매개인자 스택에 저장
call func		; 부모함수에서 함수 call

// func 함수 시작
PUSH EBP 		; EBP를 스택에 저장
MOV EBP, ESP		; 현재의 ESP를 EBP에 저장

. . .			; 함수 본체

MOV ESP, EBP		; ESP에 현재 EBP(main 함수의 ESP)를 저장
POP EBP			; 리턴되기 전에 저장해 놓았던 원래 EBP의 값으로 복원
RET			; 함수 종료 + 이전 함수로 돌아감

 

 

따라서 EBP, ESP를 통해 함수 호출 실행, 종료 후에 안전하게 원래의 스택으로 돌아올 수 있다.

함수 호출 규약 : x86 vs x64

위에서 말한 함수 인자를 스택에서 제거하는(정확하게 말하면 스택에서 반환하는) 타이밍은 함수 호출 규약과 관련이 있다. 우선 x86에서 사용하는 함수 호출 규약을 간단히 알아보면

  • _cdecl 
    c언어에서 기본으로 사용되던 방식. 인자를 오른쪽에서 왼쪽으로 저장하고 함수를 호출한 쪽(Caller)에서 스택에 인자값을 반환.
  • _stdcall
    인자를 오른쪽에서 왼쪽으로 저장하고 함수를 호출당한 쪽(Callee)에서 스택에 인자 값을 반환.
  • _fastcall
     함수 인자 2개를 레지스터 ecx, edx에 저장하고 함수를 호출당한 쪽(Callee)에서 스택에 인자 값을 반환.

함수호출이 종료되고 스택 포인터를 원래 대로 되돌릴때, ret 명령어(이전 프레임의 명령어로 돌아가는 명령어)를 통해 함수 호출을 종료하는데 어셈블리어 상으로

 

  • _cdecl ret, n : esp를 원래 main 함수(부모 함수)의 스택포인터 위치로 돌려놓고 동시에 main 함수로 돌아옴.
  • _stdcall → ret + add esp n : 함수 호출을 종료하고 main 함수에서 함수인자들을 스택포인터에서 제거해줌.

 

따라서 stdcall은 하나의 명령어로 이를 해결하므로 데이터나 속도 면으로나 이득을 볼 수 있다.

하지만 stdcall은 함수를 종료할때 스택포인터 크기를 알고 있어야 한다. 따라서 가변매개인자는 지원하지 않는다. 반면에 cdecl은 함수를 호출한 쪽에서 스택포인터를 반환하므로 해당 스택의 크기를 정확히 알고 있으므로 가변매개인자를 사용할 수 있다.

 

가변매개인자란? printf함수 같이 매개 인자의 수가 정해져 있지 않는 경우를 의미한다.

 

주로 winapi 함수들은 stdcall로 많이 선언되어 있는데 이는 stdcall을 사용하는 것이 속도나 용량면에서 약간의 이득을 볼 수 있기 때문임을 알 수 있다. (특히나 API에서 속도는 생명이니까...)

 

x86과 x64에서 차이점?

위 방식들은 x86에서 사용하는 방식이고 x64에서는 기본적인 방식으로 _fastcall을 사용한다.

x64와 x86의 차이점은 우선 ESP -> RSP, EBP -> RBP로 다른 이름을 사용한다.

x86에서는 스택 베이스 포인터(EBP)와 스택 포인터(ESP)를 이용해 각 프레임 별로 사용중인 스택 영역을 확인할 수 있었지만 x64에서는 RBP 레지스터가 더이상 스택 프레임 포인터로 사용되지 않고 일반적인 목적으로 사용된다.

 

따라서 함수 호출에서 다른점이 생기는데, x86은 스택 기반으로 인자를 전달하지만 x64는 레지스터 기반의 인자 전달 방식을 사용한다.

처음 4개의 인자는 각각 RCX,RDX,R8,R9 네개의 레지스터에 담겨서 전달하고 5번째 이후의 인자는 동일하게 스택에 저장되어 전달해준다.

 

실제 실행해보면 아래와 같이 function2에 6개의 인자를 넣어서 보낸다면 뒤에 5,6은 스택에 저장되고 나머지 1,2,3,4는 각각 ecx,dex,r8d,r9d에 저장되고 있음을 알 수 있다.

차례대로 레지스터에 저장되고 있는 모습

 

그럼 _fastcall은 항상 _stdcall보다 빠른가?

기본적으로 cdecl보다 stdcall이 빠르다면(Callee에서 스택을 반환하는 방식 때문에), 레지스터에 인자를 저장하는 방식인 fastcall이 stdcall 방식보다 항상 빨라야 하는 것 아닌가?라는 궁금증이 생겼다.

 

https://stackoverflow.com/questions/5479362/why-is-fastcall-slower-than-stdcall

 

스택오버플로우에 올라온 질문글인데 많은 답변이 달려있다.

답은 컴퓨터의 구조마다 다를 수 있고 stdcall이 fastcall에 비해 많은 시간 최적화가 되었다. 그리고 fastcall을 통해 함수를 호출하게 되면 그만큼 사용할 수 있는 레지스터의 양이 줄어드므로 그에 대한 부작용이나 artifact도 고려해야 한다고 한다.

 

범용적으로는 stdcall을 사용하는게 좋아 보인다...