(Win32 : WindowsProgramming-4) Calling Convention

Posted by : at

Category : win32   WindowsProgramming


함수 인자 전달방식

  • 매개변수(인자)를 전달하는 방법에는 두 가지 방법이 있다
    • 레지스터에 넣기
    • 스택에 넣기
.model flat
public _asm_main
.code

_asm_main:
    call _add
    ret

_add:   ; _add 호출 시 인자를 넣어보자.
    mov eax, 100
    ret

end
  • 레지스터에 인자 넣기
  • 스택에 인자 넣기

레지스터에 인자 넣기

관례적으로 ecx edx, esi, edi 를 인자 레지스터로 많이 쓴다.

.model flat
public _asm_main
.code

_asm_main:
    mov edx, 2
    mov ecx, 2
    call _add
    ret

_add:
    mov eax, edx ; eax = edx
    add eax, ecx ; eax += ecx
    ret

end
  • 장점 : 빠르다
  • 단점 : 인자의 개수에 제한이 있다

스택에 인자 넣기

push, pop을 이용해 구현하면 될까?

.model flat
public _asm_main
.code

_asm_main:
    push 2  ; 2번째 인자
    push 1  ; 1번째 인자
    call _add
    ret

_add:
    ; 단순히 pop을 쓸 수 없는게 call은 스택에 돌아올 메모리를 담는다.
    ; 좀 더 자세히 설명하면 함수를 call하면 스택에 'push 돌아올 주소'와 동일하다
    ; 단순히 pop을 해버려서 돌아올 주소를 pop해버릴경우 돌아갈 주소를 알 수 없게 된다.

    ; 따라서 절대 pop을 쓰면 안되는게 돌아갈 주소를 알수 없게 되어버린다
    ret

end

그럼 어떻게하나??

  • ESP를 써보자
    • ESP(Extended Stack Pointer) : 가장 최근에 사용한 스택을 가리키는 레지스터
    • ESP 기준 4바이트 떨어진 주소값으로 처리해야한다.

아래와 같이 구현하면 될까?

.model flat
public _asm_main
.code

_asm_main:
    push 2
    push 1
    call _add
    ret

_add:
    mov eax, dword ptr[esp+4]
    add eax, dword ptr[esp+8]
    ; 이렇게 구현하면 에러발생
    ; why? 
    ; ret를 하며 문제가 발생한다
        ; ret는 `pop 돌아갈 주소`(스택의 바로 위 주소)
        ; `jmp 꺼낸주소`
        ; 이런식으로 동작하는데 조금더 자세한 설명을 위해 아래 참조
    ret

end
_main 으로 돌아갈 주소
2
1
_asm_main 으로 돌아갈 주소 ( <- 현재 esp )

ret호출 시

_main 으로 돌아갈 주소
2
1 ( <- 현재 esp )
_asm_main 으로 돌아갈 주소

스택에 데이터를 2개를 쌓았기에 _main 으로 돌아갈 주소로 돌아가야하는데 데이터 1로 돌아가게 된다.

  • 해결책) 함수 호출이 종료되면 인자 전달에 사용된 스택은 반드시 파괴되어야 한다.
.model flat
public _asm_main
.code

_asm_main:
    push 2
    push 1
    call _add
    add esp, 8 ; esp = esp + 8
    ; 이렇게만 해줘도 스택이 파괴된다.
    ret

_add:
    mov eax, dword ptr[esp+4]
    add eax, dword ptr[esp+8]
    ret

end
_main으로 돌아갈 주소 ( <- 현재 esp )
2
1
_asm_main 으로 돌아갈 주소

인자 전달과 스택 파괴

  • 파괴하는 방식이 두 가지다.
    • 호출자 파괴 - add esp, 8 : esp를 조정
      • 코드가 길어진다(함수 호출시 마다 추가되어야 함)
    • 피호출자 파괴 - ret 8 : 리턴과 동시에 파괴
      • 코드가 짧아지며 피호출자에서 파괴하기에 에러(메모리 오접근)가 적다
.model flat
public _asm_main
.code

_asm_main:
    push 2
    push 1
    call _add
    add esp, 8  ; (호출자 파괴) 다른 방법으로 파괴할 수 있을까?
    ret

_add:
    mov eax, dword ptr[esp+4]
    add eax, dword ptr[esp+8]
    ret

end
_add:
    mov eax, dword ptr[esp+4]
    add eax, dword ptr[esp+8]
    ret 8   ; 이렇게 파괴가능 (호출 당한 파괴)

end

Calling Convention : C언어는 인자를 어떻게 보낼까?

#include <stdio.h>

int add(int a, int b)
{
    int c = a + b;
    return c;
}

int main()
{
    int ret = 0;
    ret = add(1, 2);    // 1, 2는 레지스터로 갈까 스택으로 갈까?

    printf("result : %d\n", ret);
}

컴파일러 마다 다르겠지만

push 2
push 1      ; 스택에 넣고
call _add   ; 함수호출
add esp, 8  ; 호출한 곳에서 파괴

인라인 어셈블로 제작해보자.

int main()
{
    int ret = 0;
    
    __asm
    {
        push 2
        push 1
        call add
        add esp, 8
        mov ret, eax
    }

    printf("result : %d\n", ret);
}

정리

/*
함수 호출 규약(calling convention)
* 호출 규약에 따라 달라지는 부분은 함수 이름 규칙, 인자 전달 방법, 스택 파괴 위치 이다.
__cdecl : 함수 이름 (_f) / 인자 전달 (스택) / 파괴 위치 (호출자)
__stdcall : 함수 이름 (_f@인자크기) / 인자 전달 (스택) / 파괴 위치 (피호출자)
__fastcall : 함수 이름 (@f@인자크기) / 인자 전달 (2까지 레지스터, 3부터 스택) / 파괴 위치 (3개 이상 피호출자)

* 단, 주의할 점은 가변인자로(...) 보낼경우 무조건 __cdecl로 호출규약이 고정됨
*/
void __cdecl f1(int a, int b) {}
void __stdcall f2(int a, int b) {}
void __fastcall f3(int a, int b) {}
void __fastcall f4(int a, int b, int c, int d) {}

int main()  // __cdecl : 디폴트
{
    f1(1, 2);
    f2(1, 2);
    f3(1, 2);
    f4(1, 2, 3, 4);
}
; __cdecl

	push 2
    push 1
    call _f1
    add esp, 8

; __stdcall

	push 2
    push 1
    call _f2@8

; __fastcall

	mov edx, 2
    mov ecx, 1
    call @f@8

; __fastcall(인자 3개 이상)

	push 4
    push 3
    mov edx, 2
    mov ecx, 1
    call @f4@16
  • 차이점?
    • 결국 차이점은 스택의 파괴 위치이다.
    • __cdecl :
      • 호출자에서 스택정리
      • 가변인자(…) 처리가능
    • __stdcall :
      • 피호출자에서 ret를 통해 스택정리
      • 빠르다(스택을 별도의 명령으로 정리하지 않고 ret를 통해 정리해서)
        • 왜? 자세한 사항은 나도 모름…
    • DLL이나 OS에서는 __stdcall을 많이 사용하는데 프로그래밍 언어 사이의 표준이기에 그렇다.
      • 아마 빨라서가 아닐까?
void f1(int n, ...) {} // __cdecl

void __stdcall f2(int n, ...) {}

void __fastcall f3(int n, ...) {}

int main()
{
    f1(1, 2);           // add esp, 8
    f1(1, 2, 3, 4);     // add esp, 16

    f2(1, 2);   // 함수내에서 ret8을 해줘야 하는데 가변 매개변수는 ret 몇을 해줘야하는지 알 수 없음
    // 컴파일러가 자동으로 __cdecl로 변경한다.

    f3(1, 2);   // 상동
}

어셈블리 언어에서 C함수 호출

#include <stdio.h>

int asm_main();

int main()
{
    asm_main();
}

void __cdecl    f1(int a, int b) { printf("f1 : %d, %d\n", a, b); }
void __stdcall  f2(int a, int b) { printf("f2 : %d, %d\n", a, b); }
void __fastcall f3(int a, int b) { printf("f3 : %d, %d\n", a, b); }
void __fastcall f4(int a, int b, int c, int d)
{ 
    printf("f4 : %d, %d, %d, %d\n", a, b, c, d);
}

f1, f2, f3, f4를 어셈블리에서 호출해보자.

.model flat
public _asm_main

; c에 있는 함수를 include한다고 생각
extern _f1: proc
extern _f2@8: proc
extern @f3@8: proc
extern @f4@16: proc

; printf, MessagBox 써보기용 include
extern _printf: proc
extern _MessageBoxA@16: proc

; printf, MessagBox 써보기용 string
.data
S1 DB "hello", 10, 0 
; 참고) 10은 아스키 코드로 '\n'을 말하며 0은 string의 마지막 문자 null

.code
_asm_main:
    ; f1(1, 2) 호출
    push 2
    push 1
    call _f1
    add esp, 8

    ; f2(1,2)
    push 2
    push 1
    call _f2@8

    ; f3(1,2)
    mov edx, 2
    mov ecx, 1
    call @f3@8

    ; f4(1,2,3,4)
    push 4
    push 3
    mov edx, 2
    mov ecx, 1
    call @f4@16

    ; 추가) printf 출력
    push offset S1
    call _printf
    add esp, 4  ; __cdecl 이라서 호출자에서 스택정리

    ; MessageBoxA(0, "Hello", "Hello", MB_OK) 호출
    push 0
    push offset S1
    push offset S1
    call _MessageBoxA@16    ; __stdcall이라 피호출자에서 정리

    
    ret

end

About Taehyung Kim

안녕하세요? 8년차 현업 C++ 개발자 김태형이라고 합니다. 😁 C/C++을 사랑하며 다양한 사람과의 협업을 즐깁니다. ☕ 꾸준한 자기개발을 미덕이라 생각하며 노력중이며, 제가 얻은 지식을 홈페이지에 정리 중입니다. 좀 더 상세한 제 이력서 혹은 Private 프로젝트 접근 권한을 원하신다면 메일주세요. 😎

Star
Useful Links