C 언어 시리즈 #04 — 함수·스택 프레임·호출 규약(ABI)과 가변 인자
이 글의 핵심
함수 호출이 레지스터·스택에 어떻게 매핑되는지, 왜 16바이트 정렬이 걸리는지, 가변 인자가 왜 타입 안전하지 않은지를 링커·어셈블리와 연결해 설명합니다.
시리즈 안내
#04 | 📋 전체 목차 | 이전: #03 제어 · 다음: #05 포인터 이번 글은 C에서 함수(function)가 런타임에 어떻게 풀리는지—선언·스택·ABI·가변 인자·함수 포인터까지—한 번에 훑는 거야. #02 타입, #03 제어는 읽었다고 치고, 여기서는 “실수부터 짚고, 그다음에 기초” 순서로 갈게. (나는 이렇게 읽는 게 낫다고 믿음. 교과서식으로만 가면 머리에 잘 안 남아.)
먼저: 자주 터지는 질(실수부터)
conflicting types for 'foo' 나오면 거의 헤더랑 .c랑 시그니처가 엇갈린 거야. 전역 static이 두 번 박혀 있거나, include 순서 꼬여서 “내가 아는 foo”랑 “실제 foo”가 다른 것도 흔해. 헤더 한 벌로 맞추고, grep으로 원형 먼저 잡는 습관 들여. 링커가 undefined reference 욕하면 정의가 없거나, static으로 막혀 있거나, 그 .o를 링크 커맨드에서 빼먹은 거야. nm나 objdump로 심볼 있나부터 보면 됨.
가변 인자(printf류) 쪽에서 스택이 깨지는 냄새 나면, va_arg에 승격 안 된 타입 넣은 경우가 대표적이지. char는 int로 읽고, float는 double 취급하는 그 규칙, 거기에 맞춰야 해. 포맷 문자열이랑 인자 개수도 안 맞으면 그냥 지옥이니까, -Wformat 켜고, 새 코드는 가변 인자 API 자체를 줄이는 쪽을 나는 밀 거야(아래서 또 말함).
큰 struct를 값으로 쳐다 박다 보면 느려지고, 심하면 ABI랑 안 맞는 경계(다른 팀 컴파일러, 다른 플래그)에서 이상한 증상 나기도 해. 큰 건 포인터로 넘겨. 재귀에서 세그폴트면 깊이 과다이거나 베이스 케이스 누락—스택 한계 의심하고 반복으로 바꿔. inline으로 링크 지옥 봤으면, static inline으로 번역 단위에 묶거나, inline 규칙 팀이 정한 쪽으로 한 번에 정리해.
스택이 디버거에서 깨져 보이면 FPO(프레임 포인터 생략)랑 최적화, 심볼 strip이 겹친 케이스를 의심해. 운영 이슈 잡는 빌드는 -g랑, 팀이면 -fno-omit-frame-pointer 같은 정책이 있는지 보고 맞춰. 간헐적 버그, 성능, 메모리 늘어남, 배포만 실패하는 것까지는 상황마다 다르니까, (1) 최소 재현 (2) 최근 diff 좁히기 (3) 환경·의존성 비교 (4) 로그·트레이스로 가설 확인—이 순서는 나도 계속 씀.
전역 변수로 상태 끌고 다니지 마. 콜백·에러·플래그를 전부 g_foo에 얹기 시작하면, 스레드만 들어가도 끝이야. 파일 스코프 static이랑, 인자/컨텍스트 struct로 흡수하는 게 맞다고 본다. (예외를 두고 싶으면 “명시적으로 single-thread” 문서 쓰고 팀이 OK 줄 때만.)
함수 포인터로 콜백 만들다가… (지옥 한 스푼)
한때 “이벤트 루프” 비슷한 걸 C로 흉내 내려고, 콜백을 함수 포인터로 쌓아 올렸다가 레이어를 하나 더 씌울 때마다 시그니처가 미묘하게 달라지는 지옥을 본 적 있어. void (*cb)(void *)로 통일하자니 컨텍스트가 void *로만 흘러가서, 나중엔 “이게 원래 User *인지 Session *인지” 구분이 안 되고, NULL 체크는 어디서 할지, 호출 규약이 모듈 넘어갈 때 조용히 틀어지는지까지 의심하게 됨. 그게 내가 말하는 콜백 지옥의 한 형태야—기술 자체가 나쁜 건 아닌데, 타입을 포기한 순간 설계가 반쯤 망한 거지.
그래서 나는 이렇게 밀어: 콜백은 typedef로 이름 박고, void *는 최후의 수단이고, 가능하면 struct 하나에 ctx랑 vtable 느낌(함수 포인터 묶음)으로 경계를 딱 잘라. 플러그인 ABI 넘길 땐 문서에 호출 규약·정렬·struct 반환까지 쓰고, 테스트로 최소 한 번씩 호출해 봐. “컴파일만 되면 됐지”로 넘기면 production에서만 터지는 그거야.
이제 기초: 선언, 정의, 호출
선언은 “이런 시그니처가 있다”고 컴파일러한테 알리는 것뿐이고, 정의는 실제 기계어로 갈 본문이 들어간 거. 같은 .c 안에서 호출 위에 선언만 보이면 돼. 예전 암시적 int 같은 걸로 미정의 동작(UB) 가는 짓은, 요즘 컴파일러가 욕해 주니까 고마워하라고.
/* 선언(프로토타입) — 본문 없음 */
int add(int a, int b);
/* 정의 — 본문 있음 */
int add(int a, int b) {
return a + b;
}
add(1, 2) 같은 호출에서, 인자 표현식들끼리 평가 순서는 C에서 보장 안 한다는 점, 면접 말고도 실제로 버그 남. main은 int main(void) 스타일이 깔끔해. return 0은 환경에 따라 종료 코드로 전달—구체적 의미는 구현/호스트가 정한다.
함수 프로토타입과 전방 선언
프로토타입이 있어야 잘못된 인자 개수·타입을 컴파일에서 잡을 수 있고, 기본 인자 승격 뒤 타입이랑 맞출 수 있어. foo–bar 상호 재귀면 한쪽 전방 선언 올리면 됨.
struct Node; /* 불완전(incomplete) 타입 — 이후에 정의 */
void walk_post(struct Node *n);
void walk_pre(struct Node *n);
void process(struct Node *n) {
walk_pre(n);
/* ... */
walk_post(n);
}
헤더엔 include 가드나 #pragma once로 이중 include 막고, 선언만 넣어서 여러 .c가 같은 얼굴 쓰게 해. K&R 옛날 스타일 정의는 사실상 박물관이니까, 프로토타입 없이 호출하는 습관은 끊어. (나는 “호환” 핑계로 남기는 것도 싫다—새 코드는 깨끗하게.)
매개변수: 값이랑 포인터
C는 전부 값 복사야. bump 안에서 x만 늘리면 호출자 변수는 안 바뀜.
void bump(int x) {
x = x + 1; /* 호출자의 원본을 바꾸지 못함 */
}
void demo(void) {
int a = 1;
bump(a);
/* a는 여전히 1 */
}
바꾸려면 주소를 넘겨. NULL 올 수 있으면 if (px) 정도는 해.
void bump_ptr(int *px) {
if (px) {
*px = *px + 1;
}
}
배열 매개변수는 int a[]나 int *a가 함수 안에서는 똑같이 포인터로 굴러가고, 크기는 별도 인자로. const int *는 “내용 안 건드림”이고, int * const는 포인터 자체 고정(덜 씀). API는 const로 읽기 전용 의도를 박아 넣는 게 좋다—나는 이게 문서의 절반이라고 봄.
반환: return이랑 void
int 반환인데 어떤 경로는 return 없음? 그럼 미초기화 읽기 쪽 UB로 갈 수 있어. void면 return;으로 조기 종료 OK. void 아닌데 값 없이 return;? 컴파일러가 뭐라고 할 텐데, 그 말 듣고 고쳐.
int sign(int x) {
if (x < 0) return -1;
if (x > 0) return 1;
return 0;
}
시그니처랑 링크
“시그니처”는 문서에 따라 이름+매개변수만이거나, const나 호출 규약까지 포함해 부르기도 해. C++처럼 맹글링이 흔하지 않으니, 같은 이름에 다른 프로토타입을 여러 .c에 박으면 링크 단계에서 지옥행 티켓이야. 헤더 한 벌이 답. 예시:
/* api.h */
#ifndef API_H
#define API_H
#include <stddef.h>
size_t read_chunk(void *buf, size_t n);
#endif
재귀: 원리랑 가짜로 쓰지 말 것
재귀는 같은 함수가 스택에 프레임 쌓고 다시 부르는 것. 베이스 케이스 없으면 스택 터짐. 팩토리얼은 전형적이고, 피보나치 순수 이중 재귀는 교육용—큰 n이면 지수적으로 불려서 실용 아님. 반복 O(n)이 정답에 가깝지.
unsigned long long fact(unsigned n) {
if (n <= 1) {
return 1ULL;
}
return n * fact(n - 1);
}
/* 단순 이중 재귀 — n이 크면 지수적 호출; 교육용으로만 */
unsigned long long fib_naive(unsigned n) {
if (n < 2) return n;
return fib_naive(n - 1) + fib_naive(n - 2);
}
/* 실용: 반복으로 O(n) */
unsigned long long fib_iter(unsigned n) {
if (n < 2) return n;
unsigned long long a = 0, b = 1, c;
for (unsigned i = 2; i <= n; ++i) {
c = a + b;
a = b;
b = c;
}
return b;
}
꼬리 재귀는 컴파일러·옵션 믿지 말고, 임베디드면 반복·명시적 스택이 착하다.
스택 프레임이랑 호출이 실제로 하는 일 (개념)
호출되면(대략) 반환 주소 기록, 프롤로그에서 FP·callee-saved 백업, 지역·spill 슬롯 할당. 정확한 건 컴파일러/최적화마다 달라. -fomit-frame-pointer 켜면 스택 언와인드·디버거가 귀찮아질 수 있어. 개념 그림은 대충 이렇게만 잡고 가면 돼: 위쪽(높은 주소) 쪽에 이전 프레임, 그 아래 반환 주소, 저장된 FP/레지스터, 그 아래 지역·spill. 어느 쪽이 스택 “성장” 방향인지는 아키에 따라 다르니까, 머릿속은 “프레임이 층으로 쌓인다”로만.
flowchart TB
subgraph frame["현재 스택 프레임(개념)"]
R["반환 주소"]
S["저장 레지스터·FP(있을 때)"]
L["지역·임시·spill"]
end
Caller["호출자"] -->|call| R
R --> S --> L
최적화+strip 끼면 스택이 안 보이는 느낌 나는데, 그땐 DWARF나 빌드 정책 이야기가 나옴. 운영용은 -g랑 프레임 포인터 팀 룰 맞춰.
호출 규약: cdecl, stdcall, fastcall (표준이 아님)
cdecl/stdcall/fastcall은 ISO C가 아니라 MS·GCC/Clang·플랫폼 쪽 이야기야. 이식이 중요하면 원형은 표준 C로 쓰고, 필요하면 매크로로 분기해.
cdecl: x86 옛날엔 caller가 스택 정리하는 그림이 흔했고, printf 류랑 궁합 이야기가 많이 나옴. x64는 ABI 문서가 정본—그대로 가져오면 안 맞는다.
stdcall: callee가 정리—Win32 x86 API 같은 데. 가변 인자랑은 상성이 별로라 cdecl이랑 섞을 때 주의.
fastcall: 예전 “레지스터로 몇 개 보내자” 류 확장이고, 64비트에선 “몇 번째 인자를 어느 레지스터”가 ABI에 박혀 있지, 키워드 믿지 마.
System V AMD64(리눅스·많은 Unix): 정수/포인터는 rdi…r9까지 일부, 나머지 스택. float는 xmm0~ 쪽(타입·개수에 따라). 호출 전 rsp 16바이트 정렬 같은 조건이 걸리는 환경 많음.
Windows x64: 레지스터 배치·비휘발성 집합이 다르고, 32바이트 shadow space 같이 호출자가 스택에 파는 규칙이 있음. 같은 C라도 OS 바꾸면 어셈블리가 달라—JIT 경계, 콜백, 인라인 어셈블리 섞을 때 문서 읽고 갈 것.
인라인: inline은 힌트
inline이 “무조건 펼쳐라”는 뜻은 아님. 컴파일러가 정함. static inline은 번역 단위 안에 가둬 두기 좋고, 외부 심볼 안 될 수 있어. SO/바운더리에선 심볼 가시성(default/hidden)도 신경 쓰고, noinline은 핫 패스·보안·코드 크기 트레이드오프 보면서.
/* 헤더에 두되 ODR/링크 규칙을 이해할 것 */
static inline int add_inline(int a, int b) {
return a + b;
}
가변 인자: <stdarg.h>
va_list는 레지스터 save 영역·스택을 뒤지는 추상화고, 타입 한 번 틀리면 그냥 엉뚱한 비트 읽는 수준—UB 냄새 풀풀. va_copy는 같은 걸 두 번 순회할 때(예: vsnprintf 두 번).
#include <stdarg.h>
#include <stdio.h>
void print_ints(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
(void)fmt; /* 실제로는 fmt 해석에 맞춰 va_arg 타입을 고름 */
int x = va_arg(ap, int);
printf("%d\n", x);
va_end(ap);
}
나의 판단: 새 API는 가변 인자 될 수 있으면 말리고, 꼭 필요하면 printf 호환만 표준 쪽에 두고, 내부는 struct 하나 받거나 생성 매크로로 타입을 복구해. char/short/float 승격 규칙, va_arg에 그대로 반영하는 거 잊지 마.
함수 포인터: 선언·qsort·ABI
선언·대입은 대충 이렇게.
int add(int a, int b);
int main(void) {
int (*op)(int, int) = add;
return op(2, 3);
}
qsort 콜백은 void *라 타입 안전 약해. container_of 류나 태그된 union으로 최소한 방어해봐.
#include <stdlib.h>
typedef int (*cmp_fn)(const void *a, const void *b);
int icmp(const void *a, const void *b) {
int x = *(const int *)a;
int y = *(const int *)b;
return (x > y) - (x < y);
}
void sort_ints(int *a, size_t n) {
qsort(a, n, sizeof *a, icmp);
}
다른 모듈/동적 로딩에서 포인터 넘길 땴 호출 규약·정렬·struct 반환이 한 치라도 어긋나도 이상한 부패가 나니까, 최소 호출 테스트는 필수라고 봄—위 콜백 지옥 이야기랑 같은 맥락.
static 함수랑 extern
static이 붙은 함수는 그 .c 안에서만 링크. 외부 노출 심볼 줄이고 캡슐화·LTO에도 좋음.
static int internal_helper(int x) {
return x * x;
}
int public_api(int x) {
return internal_helper(x) + 1;
}
헤더에 void util_init(void);만 두고 util.c에 정의—전형적 패턴. extern은 변수 쪽에 더 자주 쓰지만 함수에도 쓰는 스타일 있음. 같은 비-static 함수를 여러 .c에 정의? 링크가 욕하거나 ODR 냄새—한 번만.
설계: 내 기준 (단호함 포함)
한 함수는 한 가지 이유로만 바꿔지게 쪼개. I/O+파싱+DB+로그를 한 덩어리에 몰면 테스트도 리뷰도 구려짐. 이름은 동사로 read_, parse_… 불리안 느낌은 is_/has_. 약어는 팀끼리 맞춰—혼자만 아는 buf vs b는 나중에 네가 본다.
계약(전제·사후·불변식)은 주석이나 assert(디버그)로 박아. 운영에선 요청 ID를 함수마다 못 넘기면 ctx struct에 로거/트레이스 싣는 식으로 자주 푸는데, 전역 last_error 같은 건… 나는 비추. (정말 C errno 스타일이면 그때는 그 API의 규칙으로 문서에 못 박고.)
에러·상태 전달 패턴
반환코드 + errno 스타일, 출력 인자로 bool/int는 성공·실패, 값은 포인터—흔한 그림. 작은 struct로 fd, flags 묶으면 시그니처 폭발도 줄어.
#include <errno.h>
long safe_div(long a, long b, long *out) {
if (!out) {
errno = EINVAL;
return -1;
}
if (b == 0) {
errno = EDOM;
return -1;
}
*out = a / b;
return 0;
}
typedef struct {
int fd;
unsigned flags;
} IoCtx;
int io_read_line(IoCtx *ctx, char *buf, size_t n);
내부 동작, 한 번에 압축
입력·요청·이벤트 → 파싱/검증 → 핵심 연산/상태 전이 → I/O·네트워크·동시성(부작용) → 결과·로그. 이 파이프라인 머릿속에 그려두고, “순수한 층”이랑 “시간/환경에 흔들리는 층”을 나누면 테스트랑 장애 분석이 쉬워짐. 경계마다 직렬화·syscall 횟수·락 같은 누적 비용을 의심해.
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
운영이랑 엮어서 볼 때 (체크, 표 대신)
실서비스면 관측(요청 ID, 에러율, 지연 분위수, 타임아웃이 보이는지), 안전성(입력 검증·권한·시크릿), 신뢰성(멱등한 것만 재시도, 백오프), 성능(캐시·풀링·백프레셔), 배포(롤백, 카나리)—이런 질문을 한 번씩 던져 봐. 표로 정리해봤자 팀이 안 읽으니까, 나는 문장으로만 씀. 간헐적 실패는 레이스·타임아웃·외부 의존성 쪽부터 의심하고, 최소 재현이 답. 성능 떨어지면 프로파일로 핫스팟 찍고, 메모리 늘면 캐시 상한·TTL·힙 추적. 빌드만 깨지면 CI랑 로컬 환경 diff 보면 됨. 권장 순서: 최소 재현 → 최근 변경 범위 → 환경·의존성 → 로그로 가설 → 수정 후 회귀·부하. (위에서 말한 거랑 같은 말인데, 운영에서 진짜로 이 순서 쓴다.)
정리
함수는 소스의 블록이 아니라 ABI가 정한 레지스터·스택 레이아웃으로 구현되는 거고, 선언/프로토타입/헤더 한 벌이 첫 방어선이야. 값/포인터, const, 재귀의 스택, FPO/최적화, 호출 규약 문서, 가변 인자의 타입 없는 걷기, 함수 포인터·static/extern—여기 묶어서 머릿속에 올려두면, 나중에 “왜 이 스택이…” 같은 장애도 덜 막막해질 거야. 전역 열화만큼은 진짜로 끊자.
다음: #05 포인터 연산·엄격 별칭
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 임베디드·OS·네이티브 라이브러리에서 ABI 불일치와 스택/가변 인자 버그는 재현이 어렵고 치명적이다. 공유 객체 경계·어셈블리 삽입·플러그인 ABI를 다룰 때 특히 유용하다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있다. C 언어 시리즈 목차에서 전체 흐름을 확인할 수 있다.
Q. 더 깊이 공부하려면?
A. cppreference와 플랫폼 ABI 문서(System V psABI, Microsoft x64 calling convention 등)를 참고한다. 말미의 내부 링크도 함께 보면 좋다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글이다.
- C 언어 시리즈 #07 — 구조체·공용체·비트필드·패딩과 ABI
- C 언어 시리즈 #03 — 제어 흐름: 분기·스위치 테이블·setjmp와 스택
- C 언어 시리즈 #02 — 타입·승격(usual arithmetic)·정수 표현과 패딩
이 글에서 다루는 키워드 (관련 검색어)
C, 호출 규약, ABI, 스택 프레임, va_arg, 함수 포인터, inline, 재귀, cdecl 등으로 검색하면 이 글이 도움이 된다.