함수 호출 규약은 함수의 호출 및 반환에 대한 약속이다.

한 함수에서 다른 함수를 호출할 때, 프로그램의 실행 흐름은 다른 함수로 이동한다. 그리고 호출한 함수가 반환하면, 다시 원래의 함수로 돌아와서 기존의 실행 흐름을 이어 나간다. 그러므로 함수를 호출할 때는 반환된 이후를 위해 **호출자(Caller)**의 상태(Stack Frame) 및 반환 주소(Return Address)를 저장해야 한다. 또한, 호출자는 **피호출자(Callee)**가 요구하는 인자를 전달해 주어야 하며, 피호출자의 실행이 종료될 때는 반환 값을 전달받아야 한다.

함수 호출 규약을 적용하는 것은 일반적으로 컴파일러의 몫이다. 프로그래머가 고수준 언어로 코드를 작성하면, 컴파일러에 의해 함수들이 호출 규약에 맞게 컴파일된다. 호출 규약은 여러 가지가 있는데 프로그래머가 코드에 명시하지 않는다면, 컴파일러는 지원하는 호출 규약 중에서 CPU의 아키텍처에 적합한 것을 선택한다. 따라서 대개의 프로그래머는 함수 호출 규약을 알지 않아도 코드를 작성하는데 문제가 되지 않는다.

그러나 만약 컴파일러의 도움 없이 직접 어셈블리 코드를 작성하려 하거나, 어셈블리어로 작성된 코드를 읽고자 한다면 함수 호출 규약을 알아야 할 필요가 있다. 특히 아키텍처나 컴파일러에 따라 함수 호출 규약도 달라지기 때문에, 시스템 해킹 분야에서는 필수적인 지식이므로 잘 알아 두어야 한다.

함수 호출 규약의 종류

컴파일러는 CPU 아키텍처에 맞는 호출 규약을 선택한다. 예를 들어, x86 (32bit) 아키텍처는 레지스터를 통해 피호출자의 인자를 전달하기에는 레지스터의 수가 적으므로, 스택으로 인자를 전달하는 규약을 사용한다. 반대로 x86-64 아키텍처에서는 레지스터가 많으므로 적은 수의 인자는 레지스터만 사용해 전달하고, 인자가 너무 많을 때만 스택을 사용한다.

CPU의 아키텍처가 같아도, 컴파일러가 다르면 적용하는 호출 규약이 다를 수 있다. C언어를 컴파일할 때, 윈도우에서는 MSVC를, 리눅스에서는 gcc를 많이 사용한다. 이 둘은 같은 아키텍처에 대해서도 다른 호출 규약을 적용한다. x86-64 아키텍처에서 MSVCMS x64 호출 규약을 적용하지만, gccSYSTEM V 호출 규약을 적용한다. 이 외에 같은 호출 규약을 컴파일러마다 다르게 구현하기도 한다.


x86 호출 규약

x86 호출 규약

전통적으로 매개변수는 스택을 통해 전달되었으나, x86에서 x86-64로 확장됨에 따라 스택에 비해 속도가 빠른 레지스터를 적극적으로 활용하게 되었다. 모든 매개변수를 레지스터로 넘길 수 있다면 좋겠지만, 레지스터의 개수는 정해져 있기 때문에 현대의 함수 호출 규약에서는 대개 레지스터와 스택을 함께 사용한다. 아래의 C 코드를 기반으로, x86 아키텍처에서 사용하는 호출 규약을 살펴보자.

void callee(int a1, int a2, int a3){
}

void caller(){
   callee(1, 2, 3);
}

cdecl

cdecl은 리눅스의 gcc가 x86 바이너리를 컴파일할 때 일반적으로 사용하는 호출 규약으로, 레지스터 없이 스택만으로 인자를 전달한다. 또한, 인자를 전달하기 위해 사용한 스택을 호출자가 정리하는 특징이 있다. 스택을 통해 인자를 전달할 때는, 마지막 인자부터 첫 번째 인자까지 거꾸로 스택에 push한다.

아래 코드를 어셈블리어로 컴파일한 후 확인해 보자.

// Name: cdecl.c
// Compile: gcc -fno-asynchronous-unwind-tables -nostdlib -masm=intel \
//          -fomit-frame-pointer cdecl.c -w -m32 -fno-pic -O0

void __attribute__((cdecl)) callee(int a1, int a2, int a3){ // cdecl로 호출
}

void caller(){
   callee(1, 2, 3);
}

컴파일의 정확한 의미

**컴파일(Compilation)**의 정확한 의미는 어떤 언어로 작성된 소스 코드(Source Code)를, 다른 언어의 목적 코드(Object Code)로 번역하는 것이다. 소스 코드를 어셈블리어로, 또는 소스 코드를 기계어로 번역하는 행위 모두 컴파일의 범주에 포함된다.

C언어를 실행 가능한 바이너리로 만드는 과정을 보통 전처리, 컴파일, 어셈블, 링크의 4단계로 구분하는데, 이를 합해서 ‘컴파일’이라고 부를 수 있는 것도 위와 같은 이유이다.