[C] 가변인자와 호출규약
last update 2023.10.21
틀린 내용이 있다면 댓글 또는 슬랙 @kyungjle로 연락 부탁드립니다!!
서론
42 본과정의 ft_printf는 가변 인자 함수(Variadic Function)를 소개한다. 인자(Parameter)의 개수와 형식이 정해져 있는 일반적인 함수와 달리, 가변 인자 함수는 임의의 인자를 자유롭게 받을 수 있다.
다만, C의 가변 인자 함수에는 몇 가지 제약이 있다. 우선 Callee의 입장에서는 (1) 몇 개의 인자가 있는지와 (2) 각 인자가 무슨 형식인지 알 수 없다. 그렇기에 올바른 개수와 형식의 인자를 넣어 주는 것은 Caller의 책임이다.
이 글은 실제로 가변인자는 어떻게 동작하는지 디버깅하며 살펴본다. 42서울 슬랙의 스레드에도 자세히 나와있지만, 호출 규약(Calling Convention)에 초점을 두고 살펴보려고 한다.
이 글은 x86-64 환경의 WSL 위에 실행중인 Ubuntu 20.04 LTS 기준으로 작성되었습니다.
•
ldd v2.35
•
gcc v11.4.0
•
Ubuntu 20.04 LTS (5.15.90.1-microsoft-standard-WSL2)
기본적인 GDB 사용법
우분투 환경에는 llvm 대신 gnu 계열의 컴파일러를 사용하기 때문에 이 글에서는 LLDB가 아닌 GDB를 기준으로 설명합니다.
컴파일 단계에서 -g 옵션을 이용해 심볼을 남긴 채로 컴파일하면 쉽게 리버스 엔지니어링 할 수 있다. 꼭 심볼을 남기고 디버거에 넣자!
•
r 프로세스 재시작
•
set disassembly-flavor intel: 어셈블리어 코드를 AT&T 문법이 아니라 인텔 문법으로 표현한다.
•
b main b *main + 120 b *0x00004012: 특정 주소에 bp(break point)를 설정한다.
함수 심볼을 이용하면 함수의 프롤로그 직후로 bp가 설정되는데, 오프셋을 이용해 편하게 bp를 걸 수도 있다.
•
info b, clear: bp 정보를 보거나 설정된 bp를 제거할 때 사용한다.
•
c: 다음 bp(또는 프로그램 종료)까지 명령어를 실행한다.
bp에 걸린 이후에 주로 사용한다.
•
ni, si: 각 명령 단위로 실행할 때 사용한다.
ni는 step over, si는 step in으로,
- 특정 함수를 호출하는 명령에서 그 안으로 들어가 개별 인스트럭션을 실행하느냐
- 아니면 그 함수의 종료까지 넘어가느냐
의 차이가 있다.
•
info stack: 함수의 호출 스택(call stack)을 볼 때 사용한다.
•
x/i $rip, disas $rip disas main: 기계어를 어셈블리어로 변환해 출력해준다.
disas 명령 뒤에는 rip(instruction pointer)가 아니라 함수의 심볼을 넣어서 볼 수도 있다.
•
info register, p $rbp: 레지스터 정보를 볼 때 사용한다.
•
x/20x $rbp - 0x2c, x/s $rbp - 0x3b: 메모리를 볼 때 사용한다. 스택은 낮은 주소에서 높은 주소로 쌓이므로, rbp 레지스터에서 오프셋만큼 뺀 위치를 보면 스택 변수를 볼 수 있다.
접미사로 붙는 x는 메모리 내용을 16진수로, s는 ASCII 문자열로 반환하여 출력한다. 이 외에도 w, i 등 다양한 옵션이 있다.
다음 소스코드를 컴파일하고, 분석해보자.
x86 호출 규약 알아보기 (cdecl)
다른 함수를 호출하는 부분이 어셈블리어로 번역될 때, 언어와 상관없이 호출 규약에 맞추어 동일하게 변역된다. 대표적인 호출 규약에는 32비트 운영체제에서 사용하는 stdcall, cdecl, fastcall와 64비트 운영체제에서 사용하는 System V ABI, vectorcall 등이 있다.
이 글에서는 32비트 운영체제에서 사용하는 cdecl 호출 규약을 다룬다.
왜냐면 sysv 호출 규약은 레지스터랑 스택 섞어써서 보기 어렵거든요
cdecl은 C DECLaration의 약어로, C와 C++에서 사용하는 호출 규약이다. 인자는 스택을 통해 전달되고, 함수의 실행결과는 레지스터를 통해 반환된다.
함수의 동작 이후 스택을 Callee가 아닌 Caller가 정리한다는 특징을 가지고 있다.
cf. Callee가 스택을 정리해야 하는 stdcall, fastcall은 가변인자를 지원하지 않는다.
다음 코드가 어떻게 번역되는지를 보면 cdecl 호출 규약을 쉽게 이해할 수 있다.
va_arg 메크로는?
#if defined(_M_IX86)
#define _INTSIZEOF(n) ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))
#define _crt_va_start(v,l) ((v) = (va_list)_ADDRESSOF(l) + _INTSIZEOF(l))
#define _crt_va_arg(v,l) (*(l *)(((v) += _INTSIZEOF(l)) - _INTSIZEOF(l)))
#define _crt_va_end(v) ((v) = (va_list)0)
#define _crt_va_copy(d,s) ((d) = (s))
C
복사
va_list 자료형은 하나의 포인터나 다름없다. cdecl에서 인자를 스택에 담아 전달한다는 것을 생각하면 어떻게 구현되는지 이해할 수 있다.
가변 인자 함수를 선언하기 위해서는 하나의 고정된 인자(named parameter)와, 그 이후에 가변 인자가 오는 것이 문법이다. va_start는 고정 인자 바로 다음 위치를 v에 저장한다. va_arg는 v에 저장된 위치에 있는 정보를 반환하는 동시에, 자료형 크기만큼 v를 이동시킨다.
주목할 점은 _INTSIZEOF의 구현이다. 단순히 sizeof(n)을 하고 끝나는게 아니라, 연산을 더 하는 것을 볼 수 있다. 이는 가변인자를 전달할 때는 워드 단위로 전달하기 때문이다. 따라서 1바이트인 char 형을 전달할 때도 스택에서 4바이트를 차지하고, 13바이트짜리 어떤 구조체를 전달할 때에는 스택에서 16바이트를 차지하게 되는 것이다.
워드 단위로 전달한다는 점에서 재밌는 사실을 생각해볼 수 있다. 64비트 환경에서 실행하면, 다음 코드의 출력 결과는 어떻게 될까?
cdecl과 variadic
다음 코드를 컴파일하고, 어떻게 작동하는지 살펴보자.
// gcc -g -O0 -m32 -fno-stack-protector test4.c -o test4
#include <stdarg.h>
#include <stdio.h>
int add(int cnt, ...)
{
int res = 0;
va_list va;
va_start(va, cnt);
for (int i=0; i<cnt; i++)
{
res += va_arg(va, int);
}
va_end(va);
return (res);
}
int main(void)
{
int res = add(5, 0x10, 0x20, 0x30, 0x40, 0x50);
printf("res: %d\\n", res);
}
C
복사
cdecl
main에서 variadic 함수를 호출하는 부분이다.
=> 0x565561ff <+29>: push 0x50
0x56556201 <+31>: push 0x40
0x56556203 <+33>: push 0x30
0x56556205 <+35>: push 0x20
0x56556207 <+37>: push 0x10
0x56556209 <+39>: push 0x5
0x5655620b <+41>: call 0x5655619d <add>
0x56556210 <+46>: add esp,0x18
Assembly
복사
RTL 방식으로 스택에 넣는 것을 볼 수 있다. 함수 호출 후에는 다시 esp 레지스터를 호출 전 상태로 돌리기 위해 더해준다. (6개 변수 * 워드 크기 4 = 24바이트)
0x56556213 <+49>: mov DWORD PTR [ebp-0xc],eax
Plain Text
복사
cdecl에서 리턴값은 eax 레지스터에 저장된다. 이 레지스터의 값을 로컬 변수 위치에 저장하는 명령이다. 즉, $ebp - 0xc에는 variadic(5, 0x10, ...)의 결과가 저장된다. 실제로 값을 확인해보면:
(gdb) x $ebp - 0xc
0xffffce1c: 0x000000a0
Plain Text
복사
실행 결과가 저장되어 있다.
variadic
va_start 메크로를 사용한 부분이다. va 변수는 $ebp - 0xc에 위치한다.
0x565561b4 <+23>: lea eax,[ebp+0xc]
0x565561b7 <+26>: mov DWORD PTR [ebp-0xc],eax
Assembly
복사
$ebp + 0x8이 마지막 named argument인 cnt의 위치이므로, 그 다음 워드인 $ebp + 0xc를 가변인자의 시작점으로 저장한다.
va_arg 메크로를 사용한 부분이다. res 변수는 $ebp - 0x4에 있는데, res 변수에 va_arg 값을 더하기 위해 가변인자의 값을 레지스터에 저장한다.
0x565561c3 <+38>: mov eax,DWORD PTR [ebp-0xc]
0x565561c6 <+41>: lea edx,[eax+0x4]
0x565561c9 <+44>: mov DWORD PTR [ebp-0xc],edx
0x565561cc <+47>: mov eax,DWORD PTR [eax]
0x565561ce <+49>: add DWORD PTR [ebp-0x4],eax
Assembly
복사
•
38: eax 레지스터에는 현재 인자의 위치가 들어간다
•
41: edx 레지스터에는 그 다음 인자의 위치가 들어간다(va_arg(va, int)이므로 4바이트씩 이동)
•
44: va 변수의 값을 바꾼다
•
47: eax 레지스터에 현재 인자를 역참조해서 값을 읽는다
•
49: res에 eax 레지스터의 값, 즉 가변인자의 값을 더한다
즉, 워드 단위의 정해진 크기만큼 va_list 포인터가 가리키는 위치를 높은 주소로 올리고 있으며, 그 값을 역참조해서 내부 변수에 넣는 방식이다.
마치며
긴 글 읽어주셔서 감사합니다. 재밌네요..!
ps. caller의 스택 정리과정에서 esp에 더하는 값이 이상해서 살펴봤는데.. 스택 보호 때문인 것 같습니다. -fno-stack-protector 옵션을 넣어주니 해결됐네요. 이 블로그 글의 댓글을 참고해주세요. ssp가 이런 역할도 하는줄은 몰랐네요.. PIE, ASLR, SSP 등등 꺼놔야 할 옵션이 참 많은 것 같습니다 ㅎㅎ;