int b(int arg1, int arg2) {
return arg1 + arg2;
}
void a(void) {
b(1,2);
}
프로그래밍을 하다보면 위와 같이 함수 내에서 서브루틴을 자주 호출합니다.
어떻게 처리되는 걸까요?
위 코드에서 b 함수를 호출하는 a를 caller라고 하고 호출 되는 b는 callee라고 합니다.
함수 호출을 처리할 때 caller와 callee 사이 상호작용을 함수 호출규약 ( Calling Convetion ) 이라고 합니다.
함수 호출 규약은 여러가지가 존재하는데 이 포스팅에선 cdecl에 대해 다룹니다.
C Declaration
x86 architecture는 cdecl이라는 calling convention을 사용합니다.
따로 calling convention을 지정하지 않으면 기본으로 cdecl이 사용됩니다.
caller가 stack을 통해 전달하며 리턴 값은 eax를 통해 전달받습니다.
caller가 stack의 argument를 정리합니다.
위 코드를 컴파일 후 핵심이 되는 부분만 남겨주면 아래과 같습니다.
b:
push ebp
mov ebp, esp
mov edx, DWORD PTR 8[ebp]
mov eax, DWORD PTR 12[ebp]
add eax, edx
pop ebp
ret
a:
push ebp
mov ebp, esp
push 2
push 1
call b
add esp, 8
nop
leave
ret
argument는 right-to-left로 전달됩니다.
이는 LIFO한 Stack의 특징을 생각해보시면 쉽게 이해할 수 있습니다.
push 2
push 1
after push |
---|
1 |
2 |
이 후 call instruction을 실행하면
내부적으로 아래와 같이 진행됩니다.
// call offset
push eip + sizeof(call instruction)
add eip, offset
💡 offset = callee instruction - call instruction - sizeof(call instruction)
우선 함수 호출 이 후 caller로 돌아올 수 있게 return address를 stack에 저장합니다.
이 후 instruction pointer를 callee의 instruction을 가르키게 합니다.
after call instruction |
---|
return addr |
1 |
2 |
함수를 호출 시 jmp instruction을 쓰는 경우도 있습니다.
optimized tail-call이 그 예인데요
void a() {
return b()
}
아래와 같이 서브 루틴의 리턴 값을 리턴하는 구조로 되어있을 때 b의 에필로그 이후 a로 돌아올 이유가 없습니다.
바로 a의 caller의 return address로 돌아가면 되기 때문에 a의 ret을 저장할 필요가 없어집니다.
함수가 호출되면 prolog라고 불리는 아래의 과정이 진행됩니다.
push ebp
mov ebp, esp
함수가 호출되었을 때 그 함수가 가지는 논리적인 공간을 stack frame이라고 합니다.
또한 stack frame내 ebp (base pointer)를 그 stack frame pointer라고 부릅니다.
caller인 a의 stack frame으로 돌아올 수 있게 a의 sfp를 저장 후 b의 stack frame을 생성합니다.
이 과정이 끝나면 아래와 같은 구조가 됩니다.
Stack |
---|
B's Stack Frame |
A's Stack Frame |
Main's Stack Frame |
A와 B사이 stack은 다음과 같습니다.
after prolog |
---|
caller(a) sfp |
return addr |
1 |
2 |
stack은 커널영역을 보호하기 위해 높은 주소에서 낮은 주소로 진행됩니다.
ebp보다 먼저 저장된 데이터에는 ebp + offset 을 통해 전달받은 인자에 접근합니다.
mov edx, DWORD PTR 8[ebp] ; 8[ebp] == ebp+8
mov eax, DWORD PTR 12[ebp]
add 연산 결과 eax에 return value가 저장됩니다.
이제 b함수는 eplilog라고 불리는 아래의 과정을 진행합니다.
pop ebp
ret
after pop ebp |
---|
return addr |
1 |
2 |
caller(a)의 stack frame으로 되돌리기 위해 ebp를 a의 sfp로 바꾸어주고 ret 명령을 수행합니다.
pop eip
jmp eip
after ret |
---|
return addr |
1 |
2 |
이렇게 callee가 종료되면 caller는 call과정에서 사용한 스택을 정리해줍니다.
add esp, 8
이렇게 cdecl에 대해 간단히 알아보았습니다.
다음 포스트에선 x86-64 architecture의 기본 호출 규약인 fast-call에 대해 알아보겠습니다.
--- 0918 추가 ---
https://stackoverflow.com/questions/56676840/is-pop-eip-legal-instruction
내부적으로 pop eip, jmp eip
가 수행된다고 작성했었는데 오늘 피드백해주다가 새로운 사실을 알게되어 작성합니다.
내부적으로 처리되는게 저런 방식이란 거지 실제로 변환되서 하는 건 아닙니다.
eip가 general purpose register가 아니고 아예 ret의 opcode는 따로 있습니다.