함수 호출 규약
- 함수 호출 규약(Calling Convention): 함수의 호출 및 반환에 대한 약속
- 어느 함수에서 다른 함수를 호출(Calling)할 때 프로그램의 실행 흐름이 다른 함수로 이동한다. 그 후 호출한 함수가 반환되면 다시 원래 함수로 돌아와서 기존 실행 흐름을 이어간다.
- 그렇기 때문에 함수를 호출할 때는 반환된 이후의 실행 흐름을 이어나가기 위해 Caller 의 Stack frame 와 Return Address 를 저장해야한다. 그리고 Caller 는 Callee 가 요구하는 인자는 전달해줘야 하고 Callee 의 실행이 종료될 때는 반환값을 전달받아야 한다.
- 함수 호출 규약이 적용되는 것은 보통 개발자가 고수준 언어로 코드를 짜면 컴파일러에 의해서 함수들이 Calling Convention 에 맞춰서 컴파일 된다.
Calling Convention 의 종류
x86
- cdecl
- stdcall
- fastcall
- thiscall
x86-64
- System V AMD64 ABI의 Calling Convention
- MS ABI의 Calling Convention
x86 Calling Convention
전통적으로 매개변수는 스택으로 저장되었는데 x86 에서 x86-64 로 확장하면서 스택에 비해 속도가 빠른 레지스터를 주로 활용하게 되었다.
레지스터의 개수는 한정적이어서 현대 Calling Convention 에선 보통 레지스터와 스택이 함께 사용된다.
Caller 와 Callee
함수 호출 규약을 이해할 때 가장 중요한 개념은 Caller 와 Callee 이다.
- Caller: 함수를 호출하는 함수
- Callee: 호출당한 함수
예를 들어 다음과 같은 코드가 있다고 하면
int add(int a, int b) {
return a + b;
}
int main() {
int result = add(1, 2);
}
main() 함수는 add() 함수를 호출하기 때문에 Caller 이고, add() 함수는 호출당했기 때문에 Callee 이다.
함수 호출이 일어날 때 Caller 와 Callee 는 서로 역할이 나뉜다.
- Caller 는 Callee 에게 인자를 전달해야 한다.
- Callee 는 전달받은 인자를 사용해서 함수를 실행한다.
- Callee 는 함수 실행이 끝나면 반환값을 Caller 에게 전달한다.
- Caller 는 반환값을 받은 뒤 기존 실행 흐름을 이어간다.
이 과정에서 스택, 레지스터, Return Address, Stack Frame 등이 사용된다.
Stack Frame
Stack Frame 은 함수가 실행될 때 해당 함수가 사용하는 스택 영역이다.
함수가 호출되면 보통 해당 함수만의 Stack Frame 이 만들어지고, 함수 실행이 끝나면 해당 Stack Frame 은 정리된다.
Stack Frame 에는 일반적으로 다음과 같은 정보들이 저장된다.
- 함수의 지역 변수
- 이전 함수의 Stack Frame 정보
- Return Address
- 함수 인자
- 임시 데이터
x86 기준으로는 보통 ebp 와 esp 레지스터를 이용해서 Stack Frame 을 관리한다.
- esp: 현재 스택의 top 을 가리키는 레지스터
- ebp: 현재 함수의 Stack Frame 기준점을 가리키는 레지스터
일반적인 함수 프롤로그는 다음과 같은 형태로 나타난다.
push ebp
mov ebp, esp
sub esp, 0x10
위 코드는 이전 함수의 ebp 값을 스택에 저장하고, 현재 esp 값을 ebp 로 옮긴 뒤, 지역 변수를 위한 공간을 스택에 확보하는 코드이다.
함수 에필로그는 보통 다음과 같은 형태로 나타난다.
mov esp, ebp
pop ebp
ret
또는 다음과 같이 leave 명령어로 줄여서 나타나기도 한다.
leave
ret
leave 는 내부적으로 mov esp, ebp 와 pop ebp 를 수행한다.
Return Address
Return Address 는 Callee 함수의 실행이 끝난 뒤 다시 돌아가야 할 주소이다.
Caller 가 Callee 를 호출할 때 call 명령어를 사용하면, CPU 는 다음에 실행할 명령어의 주소를 스택에 저장한다. 이 값이 Return Address 이다.
예를 들어 다음과 같은 코드가 있다고 하면
call add
mov [result], eax
call add 명령어가 실행될 때, mov [result], eax 의 주소가 스택에 저장된다.
그 후 add 함수가 종료되면서 ret 명령어가 실행되면, 스택에 저장되어 있던 Return Address 를 꺼내서 다시 그 주소로 이동한다.
즉, 함수 호출과 반환은 대략 다음과 같은 흐름으로 진행된다.
- Caller 가 call 명령어로 Callee 를 호출한다.
- CPU 가 Return Address 를 스택에 저장한다.
- Callee 함수로 실행 흐름이 이동한다.
- Callee 함수가 실행된다.
- Callee 함수가 ret 명령어를 실행한다.
- 스택에 저장된 Return Address 로 다시 이동한다.
- Caller 의 기존 실행 흐름이 이어진다.
함수 인자 전달 방식
Calling Convention 에서 중요한 것 중 하나는 함수 인자를 어떻게 전달하는지이다.
함수 인자는 크게 두 가지 방식으로 전달될 수 있다.
- 스택을 통한 인자 전달
- 레지스터를 통한 인자 전달
x86 에서는 보통 함수 인자를 스택에 push 해서 전달한다.
push 2
push 1
call add
위 코드는 add(1, 2) 와 같은 함수 호출을 어셈블리 레벨에서 나타낸 예시이다.
인자를 스택에 넣을 때는 보통 오른쪽 인자부터 먼저 push 한다. 그래서 2 를 먼저 push 하고, 그 다음 1 을 push 한다.
이렇게 하는 이유는 함수 내부에서 첫 번째 인자를 [ebp+8], 두 번째 인자를 [ebp+12] 와 같은 고정된 위치로 접근하기 쉽게 만들기 위해서이다.
예를 들어 x86 cdecl 기준으로 함수 내부에서는 다음과 같이 인자를 참조할 수 있다.
mov eax, [ebp+8]
mov edx, [ebp+12]
add eax, edx
여기서 [ebp+8] 은 첫 번째 인자, [ebp+12] 는 두 번째 인자를 의미한다.
x86-64 에서는 레지스터를 이용한 인자 전달이 주로 사용된다.
System V AMD64 ABI 기준으로 정수형 인자는 다음 순서의 레지스터를 통해 전달된다.
- rdi
- rsi
- rdx
- rcx
- r8
- r9
예를 들어 다음과 같은 함수 호출이 있다고 하면
add(1, 2);
System V AMD64 ABI 기준으로는 대략 다음과 같이 인자가 전달된다.
mov rdi, 1
mov rsi, 2
call add
첫 번째 인자 1 은 rdi 로 전달되고, 두 번째 인자 2 는 rsi 로 전달된다.
반환값 전달 방식
함수가 실행을 끝내면 Caller 에게 반환값을 전달해야 한다.
반환값도 Calling Convention 에 따라 정해진 위치를 통해 전달된다.
x86 에서는 일반적으로 반환값이 eax 레지스터에 저장된다.
mov eax, 3
ret
위 코드는 함수가 3 을 반환하는 것과 비슷한 의미이다.
x86-64 에서는 일반적으로 반환값이 rax 레지스터에 저장된다.
mov rax, 3
ret
즉, 함수 호출 후 Caller 는 eax 또는 rax 값을 확인해서 Callee 의 반환값을 얻을 수 있다.
Stack Cleanup
x86 Calling Convention 에서 중요한 차이점 중 하나는 스택 정리를 누가 담당하는지이다.
함수 호출을 위해 스택에 인자를 push 했다면, 함수 호출이 끝난 뒤 해당 인자들을 스택에서 정리해야 한다.
이때 스택 정리를 Caller 가 하느냐, Callee 가 하느냐에 따라 Calling Convention 이 달라진다.
대표적으로 cdecl 은 Caller 가 스택을 정리한다.
push 2
push 1
call add
add esp, 8
위 코드에서 add esp, 8 은 Caller 가 스택에 넣었던 인자 2개를 정리하는 코드이다.
인자 하나가 4바이트이고, 인자가 2개이기 때문에 총 8바이트를 정리한다.
반면 stdcall 은 Callee 가 스택을 정리한다.
ret 8
ret 8 은 Return Address 로 이동한 뒤, 추가로 스택에서 8바이트를 정리하는 명령어이다.
즉, cdecl 과 stdcall 의 큰 차이는 스택 정리를 누가 하느냐이다.
- cdecl: Caller 가 스택 정리
- stdcall: Callee 가 스택 정리
cdecl
cdecl 은 x86 에서 자주 사용되는 Calling Convention 이다.
cdecl 의 특징은 다음과 같다.
- 인자는 오른쪽에서 왼쪽 순서로 스택에 push 한다.
- 반환값은 eax 에 저장된다.
- 스택 정리는 Caller 가 담당한다.
- 가변 인자 함수를 사용할 수 있다.
예를 들어 다음과 같은 함수 호출이 있다고 하면
add(1, 2);
cdecl 에서는 대략 다음과 같은 형태가 된다.
push 2
push 1
call add
add esp, 8
Caller 가 인자를 스택에 넣고, 함수 호출 후 add esp, 8 로 직접 스택을 정리한다.
printf 같은 가변 인자 함수는 인자의 개수가 호출 시점마다 달라질 수 있기 때문에 Callee 가 스택을 정리하기 어렵다.
그래서 cdecl 처럼 Caller 가 스택을 정리하는 방식이 가변 인자 함수에 적합하다.
stdcall
stdcall 은 Windows API 에서 많이 사용되던 x86 Calling Convention 이다.
stdcall 의 특징은 다음과 같다.
- 인자는 오른쪽에서 왼쪽 순서로 스택에 push 한다.
- 반환값은 eax 에 저장된다.
- 스택 정리는 Callee 가 담당한다.
예를 들어 다음과 같은 함수 호출이 있다고 하면
push 2
push 1
call add
Caller 쪽에서는 함수 호출 후 add esp, 8 같은 스택 정리 코드를 넣지 않는다.
대신 Callee 함수가 반환할 때 다음과 같이 스택을 정리한다.
ret 8
즉, stdcall 은 Callee 가 인자 크기만큼 스택을 정리한다.
fastcall
fastcall 은 일부 인자를 스택이 아니라 레지스터로 전달해서 함수 호출 속도를 높이기 위한 Calling Convention 이다.
x86 에서는 레지스터 개수가 많지 않기 때문에 모든 인자를 레지스터로 전달하지는 못하고, 일부 인자만 레지스터로 전달한다.
Microsoft fastcall 기준으로는 보통 첫 번째 인자와 두 번째 인자가 각각 ecx, edx 로 전달된다.
- 첫 번째 인자: ecx
- 두 번째 인자: edx
- 나머지 인자: 스택
예를 들어 다음과 같은 함수 호출이 있다고 하면
func(1, 2, 3);
대략 다음과 같은 형태로 인자가 전달될 수 있다.
mov ecx, 1
mov edx, 2
push 3
call func
레지스터를 사용하면 스택 메모리에 접근하는 것보다 빠를 수 있기 때문에 함수 호출 성능을 높일 수 있다.
thiscall
thiscall 은 C++ 의 멤버 함수 호출에서 사용되는 Calling Convention 이다.
C++ 에서 클래스의 멤버 함수는 내부적으로 객체 자신을 가리키는 this 포인터를 사용한다.
예를 들어 다음과 같은 코드가 있다고 하면
obj.func(1);
func 는 멤버 함수이기 때문에 어떤 객체의 함수인지 알아야 한다.
이때 객체 자신을 가리키는 포인터가 this 이다.
x86 Microsoft thiscall 기준으로는 보통 this 포인터가 ecx 레지스터로 전달된다.
mov ecx, obj
push 1
call func
즉, thiscall 은 C++ 멤버 함수에서 this 포인터를 어떻게 전달할지 정하는 Calling Convention 이라고 볼 수 있다.
System V AMD64 ABI
System V AMD64 ABI 는 Linux, macOS 등 Unix 계열 x86-64 환경에서 주로 사용되는 Calling Convention 이다.
정수형 인자는 다음 순서로 전달된다.
- rdi
- rsi
- rdx
- rcx
- r8
- r9
반환값은 보통 rax 로 전달된다.
인자가 6개보다 많으면 나머지 인자는 스택을 통해 전달된다.
예를 들어 다음과 같은 함수 호출이 있다고 하면
func(1, 2, 3, 4, 5, 6, 7);
System V AMD64 ABI 에서는 대략 다음과 같이 인자가 전달된다.
mov rdi, 1
mov rsi, 2
mov rdx, 3
mov rcx, 4
mov r8, 5
mov r9, 6
push 7
call func
첫 번째부터 여섯 번째 인자까지는 레지스터로 전달되고, 일곱 번째 인자는 스택으로 전달된다.
MS x64 Calling Convention
MS x64 Calling Convention 은 Windows x64 환경에서 사용되는 Calling Convention 이다.
정수형 인자는 다음 순서로 전달된다.
- rcx
- rdx
- r8
- r9
반환값은 보통 rax 로 전달된다.
인자가 4개보다 많으면 나머지 인자는 스택을 통해 전달된다.
예를 들어 다음과 같은 함수 호출이 있다고 하면
func(1, 2, 3, 4, 5);
MS x64 Calling Convention 에서는 대략 다음과 같이 인자가 전달된다.
mov rcx, 1
mov rdx, 2
mov r8, 3
mov r9, 4
push 5
call func
첫 번째부터 네 번째 인자까지는 레지스터로 전달되고, 다섯 번째 인자는 스택으로 전달된다.
System V AMD64 ABI 와 MS x64 Calling Convention 은 둘 다 x86-64 환경에서 사용되지만, 인자를 전달하는 레지스터 순서가 다르다.
- System V AMD64 ABI: rdi, rsi, rdx, rcx, r8, r9
- MS x64: rcx, rdx, r8, r9
Caller-saved 와 Callee-saved Register
Calling Convention 은 레지스터 보존 규칙도 정의한다.
함수를 호출할 때 레지스터 값이 바뀔 수 있기 때문에, 어떤 레지스터를 누가 보존해야 하는지 정해둘 필요가 있다.
레지스터는 크게 Caller-saved Register 와 Callee-saved Register 로 나눌 수 있다.
- Caller-saved Register: Caller 가 필요하면 직접 저장해야 하는 레지스터
- Callee-saved Register: Callee 가 사용하기 전에 저장하고, 반환 전에 복구해야 하는 레지스터
Caller-saved Register 는 함수 호출 이후 값이 바뀌어도 이상하지 않은 레지스터이다.
따라서 Caller 가 해당 레지스터 값을 함수 호출 이후에도 사용해야 한다면, 함수 호출 전에 직접 저장해야 한다.
Callee-saved Register 는 Callee 가 값을 바꾸면 안 되는 레지스터이다.
만약 Callee 가 해당 레지스터를 사용해야 한다면, 함수 시작 시점에 기존 값을 저장하고 함수 종료 전에 원래 값으로 복구해야 한다.
예를 들어 Callee 가 ebx 를 사용해야 한다면 다음과 같은 형태가 될 수 있다.
push ebx
mov ebx, 1
pop ebx
ret
이런 규칙이 있기 때문에 Caller 와 Callee 는 서로의 레지스터 사용을 예측할 수 있다.
Calling Convention 이 중요한 이유
Calling Convention 은 단순히 함수 호출 방식만 정하는 것이 아니라, 바이너리 분석과 익스플로잇에서도 매우 중요하다.
리버싱을 할 때 Calling Convention 을 알면 다음과 같은 정보를 파악할 수 있다.
- 함수의 인자가 몇 개인지
- 인자가 어디에 저장되어 있는지
- 반환값이 어디로 전달되는지
- 함수 호출 후 스택이 어떻게 정리되는지
- 함수가 어떤 레지스터를 보존해야 하는지
예를 들어 x86 바이너리에서 함수 호출 후 add esp, 0x10 이 보이면, Caller 가 스택을 정리하고 있으므로 cdecl 방식일 가능성이 있다.
push 4
push 3
push 2
push 1
call func
add esp, 0x10
반대로 함수가 ret 0x10 으로 끝난다면, Callee 가 스택을 정리하는 방식일 가능성이 있다.
ret 0x10
또한 x86-64 바이너리에서 함수 호출 직전에 rdi, rsi, rdx 등에 값이 들어가는 것을 보면 System V AMD64 ABI 환경일 가능성이 있다.
mov rdi, 1
mov rsi, 2
mov rdx, 3
call func
Windows x64 바이너리에서는 함수 호출 직전에 rcx, rdx, r8, r9 가 사용되는 경우가 많다.
mov rcx, 1
mov rdx, 2
mov r8, 3
mov r9, 4
call func
즉, Calling Convention 을 알면 어셈블리 코드에서 함수의 구조와 데이터 흐름을 더 정확하게 분석할 수 있다.
정리
Calling Convention 은 함수 호출과 반환에 대한 약속이다.
함수 호출 과정에서는 다음과 같은 요소들이 정해져야 한다.
- 인자를 어디로 전달할지
- 반환값을 어디로 전달할지
- 스택 정리를 누가 할지
- 어떤 레지스터를 보존해야 할지
- Return Address 와 Stack Frame 을 어떻게 관리할지
x86 에서는 주로 스택을 통해 인자를 전달하고, x86-64 에서는 주로 레지스터를 통해 인자를 전달한다.
하지만 레지스터의 개수는 제한되어 있기 때문에 인자가 많아지면 스택도 함께 사용된다.
Calling Convention 을 이해하면 C 코드가 어셈블리에서 어떻게 함수 호출로 변환되는지 이해할 수 있고, 리버싱이나 바이너리 익스플로잇을 할 때 함수의 인자, 반환값, 스택 구조를 분석하는 데 도움이 된다.
'공부한 것' 카테고리의 다른 글
| Stack Canary (0) | 2026.05.18 |
|---|---|
| Shellcode - orw Shellcode (0) | 2026.05.17 |
| CVE-2026-28466 (0) | 2026.05.14 |
| CVE-2026-26954 (0) | 2026.05.13 |
| CVE-2026-3672 (0) | 2026.05.12 |