C 언어 시리즈 #04 — 함수·스택 프레임·호출 규약(ABI)과 가변 인자
이 글의 핵심
함수 호출이 레지스터·스택에 어떻게 매핑되는지, 왜 16바이트 정렬이 걸리는지, 가변 인자가 왜 타입 안전하지 않은지를 링커·어셈블리와 연결해 설명합니다.
시리즈 안내
이번 글은 C 함수가 컴파일·링크·실행 단계에서 어떻게 다뤄지는지를 다룹니다. 선언과 정의, 값 전달, 재귀 같은 기초에서 시작해 스택 프레임, x86-64의 호출 규약, inline의 링크 규칙, 가변 인자, 함수 포인터까지 이어집니다. #02 타입의 정수 승격 규칙을 알고 있다고 가정합니다.
선언, 정의, 호출
선언은 “이런 이름과 시그니처의 함수가 있다”고 컴파일러에 알리는 것이고, 정의는 실제 기계어로 번역될 본문까지 포함한 것입니다. 호출하는 위치보다 앞에 선언이 보이면 정의는 다른 파일에 있어도 됩니다.
/* 선언(프로토타입) — 본문 없음 */
int add(int a, int b);
/* 정의 — 본문 있음 */
int add(int a, int b) {
return a + b;
}
C89에서는 선언 없이 함수를 호출하면 컴파일러가 int f()로 암시적으로 선언했지만, C99에서 이 규칙이 삭제되었습니다. 그런데도 많은 컴파일러가 호환성 때문에 경고만 내고 컴파일을 계속해 왔고, 이때 실제 함수가 double이나 포인터를 반환하면 반환값이 엉뚱한 레지스터에서 읽혀 깨집니다. 64비트에서 malloc의 선언(<stdlib.h>)을 빠뜨리면 반환된 포인터가 int로 잘려 상위 32비트가 사라지는 것이 대표적인 예입니다. GCC 14부터는 암시적 함수 선언이 기본으로 오류가 되었습니다.
호출 f(a(), b())에서 a()와 b() 중 무엇이 먼저 평가되는지는 C가 정하지 않습니다(unspecified). 두 함수가 같은 전역 상태나 같은 스트림을 건드린다면 컴파일러나 최적화 수준에 따라 결과가 달라질 수 있습니다. main은 int main(void) 또는 int main(int argc, char *argv[])로 쓰고, main에서 0(또는 EXIT_SUCCESS)을 반환하면 표준에 따라 성공 종료로 처리됩니다.
프로토타입과 전방 선언
프로토타입이 보이는 상태에서 함수를 호출하면, 인자는 매개변수 타입으로 변환되고 인자 개수와 타입이 맞지 않으면 컴파일 오류가 납니다. 프로토타입이 없으면(또는 int f()처럼 매개변수 목록이 빈 옛 스타일 선언이면) 인자에 기본 인자 승격만 적용되어 그대로 넘어가므로, float를 기대하는 함수에 double이 전달되는 식의 불일치를 컴파일러가 잡을 수 없습니다. C23에서는 int f()가 int f(void)와 같은 뜻이 되었지만, 이전 표준을 쓰는 코드에서는 매개변수가 없는 함수를 반드시 (void)로 선언해야 합니다.
서로 호출하는 함수나, 정의 순서와 사용 순서가 다른 경우에는 전방 선언을 위에 둡니다. 구조체도 포인터로만 쓴다면 불완전 타입으로 미리 선언할 수 있습니다.
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);
}
여러 .c 파일이 공유하는 함수의 선언은 헤더 하나에만 두고, 정의가 있는 .c 파일도 그 헤더를 include해야 합니다. 그래야 선언과 정의가 어긋났을 때 컴파일러가 conflicting types for 'foo' 오류로 알려 줍니다.
/* api.h */
#ifndef API_H
#define API_H
#include <stddef.h>
size_t read_chunk(void *buf, size_t n);
#endif
이것이 중요한 이유는 C에 C++ 같은 이름 맹글링이 없기 때문입니다. 오브젝트 파일의 심볼 이름은 read_chunk 하나뿐이고 타입 정보가 없으므로, a.c가 int read_chunk(int)로 선언해 호출하고 b.c가 size_t read_chunk(void *, size_t)로 정의해도 링커는 아무 오류 없이 연결합니다. 결과는 실행 시점의 미정의 동작입니다. 헤더를 정의 쪽에서도 include하는 습관이 이런 불일치를 컴파일 단계로 끌어올리는 유일한 장치입니다.
매개변수: 값과 포인터
C는 모든 인자를 값으로 복사해 넘깁니다. 함수 안에서 매개변수를 바꿔도 호출자의 변수는 바뀌지 않습니다.
void bump(int x) {
x = x + 1; /* 호출자의 원본에는 영향 없음 */
}
void demo(void) {
int a = 1;
bump(a);
/* a는 여전히 1 */
}
호출자의 변수를 바꾸려면 주소를 넘깁니다. 이 경우에도 복사되는 것은 포인터 값이고, 함수는 그 주소를 통해 원본에 접근합니다.
void bump_ptr(int *px) {
if (px) {
*px = *px + 1;
}
}
배열 매개변수는 int a[]로 쓰든 int a[10]으로 쓰든 int *a로 조정되므로, 함수 안에서 sizeof a는 포인터 크기입니다. 원소 개수는 별도 인자로 넘겨야 합니다. const int *p는 “p를 통해 가리키는 값을 바꾸지 않는다”는 약속이고, int *const p는 포인터 변수 자체를 바꾸지 않는다는 뜻입니다. API의 입력 버퍼에 const를 붙여 두면 호출자는 버퍼가 수정되지 않는다는 것을 시그니처만 보고 알 수 있고, 함수 구현에서 실수로 수정하면 컴파일러가 잡아 줍니다.
구조체도 값으로 복사되므로 큰 구조체를 값으로 넘기면 매 호출마다 전체가 복사됩니다. x86-64 System V에서는 16바이트를 넘는 구조체가 메모리(스택)로 전달되고 반환할 때는 호출자가 마련한 버퍼의 주소가 숨은 첫 인자로 넘어갑니다. 큰 구조체는 const 포인터로 넘기는 것이 일반적입니다.
반환
void가 아닌 함수가 return 없이 끝까지 실행되면, 호출자가 그 반환값을 사용할 때 미정의 동작이 됩니다. 컴파일러는 대개 경고(-Wreturn-type)만 내고, 실행해 보면 그 시점에 반환 레지스터에 남아 있던 아무 값이 나옵니다. 디버그 빌드에서는 우연히 맞는 값이 나오다가 최적화 빌드에서 틀려지는 경우가 많아 원인을 찾기 어렵습니다.
int sign(int x) {
if (x < 0) return -1;
if (x > 0) return 1;
return 0; /* 모든 경로에서 반환 */
}
재귀
재귀 호출은 호출마다 새 스택 프레임을 쌓습니다. 기저 조건이 없거나 깊이가 너무 깊으면 스택 한계(리눅스 메인 스레드의 기본 8MB, 스레드마다 지정한 크기)를 넘어 세그멘테이션 폴트로 끝납니다.
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;
}
fib_naive(n)은 같은 하위 문제를 반복해서 다시 계산하므로 호출 횟수가 대략 피보나치 수 자체만큼 늘어납니다. n = 50이면 수백억 번의 호출이 필요합니다. fib_iter는 n번 반복으로 끝나지만, unsigned long long(64비트)으로는 fib(93)까지만 표현되고 그 이후는 랩어라운드됩니다. fact도 21!부터 64비트를 넘습니다.
GCC와 Clang은 최적화를 켜면 꼬리 호출을 점프로 바꾸는 경우가 많지만, C 표준이 보장하는 동작은 아니고 -O0에서는 일어나지 않습니다. 재귀 깊이가 입력 크기에 비례한다면(예: 연결 리스트 순회, 균형이 보장되지 않는 트리) 반복문이나 명시적 스택으로 바꾸는 것이 안전합니다.
스택 프레임
x86-64에서 call 명령은 반환 주소를 스택에 넣고 함수로 점프합니다. 함수의 프롤로그는 필요하면 이전 프레임 포인터(rbp)를 저장하고, 사용할 callee-saved 레지스터를 저장한 뒤, rsp를 줄여 지역 변수와 레지스터가 부족할 때 값을 내려놓는 spill 슬롯을 확보합니다. x86-64와 대부분의 현대 아키텍처에서 스택은 높은 주소에서 낮은 주소 방향으로 자랍니다.
높은 주소
| 호출자의 프레임 |
| 스택으로 전달된 인자 | (7번째 이후 정수 인자 등)
| 반환 주소 | ← call이 넣음
| 저장된 rbp (있을 때) | ← 프롤로그
| callee-saved 레지스터 |
| 지역 변수·spill 슬롯 |
| | ← rsp
낮은 주소
프레임 포인터는 각 프레임의 시작을 가리키는 rbp 체인입니다. 체인이 있으면 디버거나 프로파일러가 rbp를 따라가기만 하면 호출 스택을 복원할 수 있습니다. GCC와 Clang은 최적화 수준에서 기본으로 -fomit-frame-pointer를 적용해 rbp를 일반 레지스터로 씁니다. 이 경우에도 gdb와 C++ 예외 처리는 .eh_frame의 CFI(Call Frame Information)로 스택을 복원하므로 대개 문제가 없지만, 프레임 포인터 체인만 따라가는 도구(기본 설정의 perf record, eBPF 기반 프로파일러 등)는 호출 스택이 끊기거나 엉뚱하게 나옵니다. 운영 환경에서 프로파일링을 해야 하는 서비스라면 -fno-omit-frame-pointer로 빌드하는 것이 흔한 선택이고, 비용은 레지스터 하나와 함수당 몇 개의 명령입니다. 바이너리를 strip해 심볼까지 지우면 주소를 함수 이름으로 바꿀 수 없으므로, 디버그 정보(-g)는 별도 파일로 분리해 보관하는 것이 좋습니다.
호출 규약
호출 규약(calling convention)은 인자를 어느 레지스터와 스택 위치에 둘지, 반환값을 어디에 둘지, 어떤 레지스터를 호출 후에도 보존해야 하는지, 스택을 누가 정리하는지를 정한 규칙입니다. ISO C 표준이 아니라 플랫폼 ABI 문서와 컴파일러가 정합니다. 서로 다른 컴파일러로 만든 오브젝트 파일이 링크되어 동작하는 것은 모두 같은 ABI를 따르기 때문입니다.
32비트 x86 시절에는 여러 규약이 공존했습니다. cdecl은 인자를 모두 스택으로 넘기고 호출자가 스택을 정리하므로, 호출자만 인자 개수를 아는 가변 인자 함수에 맞습니다. stdcall은 피호출자가 ret N 명령으로 스택을 정리하므로 코드가 조금 작아지지만 가변 인자 함수에는 쓸 수 없고, Win32 API가 이 규약을 씁니다. MSVC의 fastcall은 처음 두 개의 정수 인자를 ecx, edx로 넘깁니다. 선언과 정의의 규약이 다르면 스택 포인터가 호출마다 어긋나 곧 크래시가 납니다.
x86-64에서는 플랫폼마다 규약이 하나로 정해져 있어서 이런 키워드가 대부분 의미를 잃었습니다. 대신 리눅스·macOS와 Windows의 규약이 서로 다릅니다.
| 항목 | System V AMD64 (리눅스, macOS, BSD) | Windows x64 |
|---|---|---|
| 정수·포인터 인자 레지스터 | rdi, rsi, rdx, rcx, r8, r9 (6개) | rcx, rdx, r8, r9 (4개) |
| 부동소수점 인자 레지스터 | xmm0~xmm7 (8개, 정수와 별도로 셈) | xmm0~xmm3 (인자 위치를 정수와 공유) |
| 반환값 | rax(+rdx), xmm0(+xmm1) | rax, xmm0 |
| 호출 직전 스택 정렬 | 16바이트 | 16바이트 |
| 호출자가 마련하는 공간 | 없음, 대신 rsp 아래 128바이트 red zone | 32바이트 shadow space |
| 피호출자가 보존할 레지스터 | rbx, rbp, r12~r15 | rbx, rbp, rdi, rsi, r12r15, xmm6xmm15 |
16바이트 정렬은 “call 명령을 실행하는 순간 rsp가 16의 배수”라는 뜻입니다. call이 8바이트 반환 주소를 넣으므로 함수 진입 직후에는 rsp가 16의 배수보다 8 작고, 프롤로그가 이를 맞춥니다. 이 규칙 덕분에 컴파일러는 16바이트 정렬을 요구하는 SSE 명령(movaps)으로 지역 변수를 다룰 수 있습니다. 직접 작성한 어셈블리나 JIT 코드가 이 정렬을 깨뜨리면, 한참 뒤 printf 안의 movaps에서 크래시가 나서 원인과 증상의 위치가 멀리 떨어지게 됩니다.
Windows x64의 shadow space는 피호출자가 레지스터로 받은 네 인자를 저장할 수 있도록 호출자가 항상 확보해 두는 32바이트입니다. 인자가 하나뿐인 함수를 호출해도 확보해야 합니다. System V의 red zone은 rsp 아래 128바이트를 시그널 핸들러 등이 건드리지 않도록 보장하는 영역으로, 다른 함수를 호출하지 않는 말단 함수는 rsp를 움직이지 않고 이 영역을 지역 변수로 씁니다. 리눅스 커널 코드는 인터럽트가 같은 스택을 쓰기 때문에 -mno-red-zone으로 컴파일합니다.
같은 C 코드라도 OS가 바뀌면 생성되는 어셈블리가 다르므로, 인라인 어셈블리나 JIT, 다른 언어와의 FFI 경계에서는 대상 플랫폼의 ABI 문서(System V AMD64 psABI, Microsoft x64 calling convention)를 기준으로 작성해야 합니다.
inline과 링크
inline은 “이 함수를 호출 위치에 펼쳐도 된다”는 힌트일 뿐이고, 실제로 펼칠지는 컴파일러가 판단합니다. inline이 없어도 최적화 빌드에서는 작은 함수가 자동으로 인라인되고, inline이 있어도 재귀 함수나 큰 함수는 펼쳐지지 않을 수 있습니다.
inline에서 실제로 문제가 되는 것은 링크 규칙입니다. C99의 규칙에서 헤더에 inline int f(void) { ... }만 쓰면 이것은 “인라인 정의”일 뿐 외부 정의가 아니어서, 컴파일러가 인라인하지 않은 호출(예: -O0 빌드)에서 undefined reference to 'f' 링크 오류가 납니다. C++의 inline과 규칙이 달라 자주 혼동되는 부분입니다. 해결 방법은 두 가지입니다.
/* 방법 1: 헤더에 static inline — 각 번역 단위가 자기만의 사본을 가짐 */
static inline int add_inline(int a, int b) {
return a + b;
}
/* 방법 2: 헤더에는 inline 정의, .c 파일 하나에서 extern 선언으로 외부 정의를 만듦 */
/* math_util.h */
inline int add_inline(int a, int b) { return a + b; }
/* math_util.c */
#include "math_util.h"
extern inline int add_inline(int a, int b);
static inline은 단순하지만, 인라인되지 않은 경우 번역 단위마다 함수 사본이 생기고 함수 주소를 비교하면 파일마다 다르게 나옵니다. 공유 라이브러리를 만들 때는 내보낼 함수만 보이도록 -fvisibility=hidden으로 빌드하고 공개 API에만 __attribute__((visibility("default")))를 붙이면, 내부 함수 호출이 PLT를 거치지 않아 빨라지고 심볼 충돌도 줄어듭니다.
가변 인자: <stdarg.h>
가변 인자 함수는 고정 인자 뒤에 ...을 두고, va_start로 순회를 시작해 va_arg로 하나씩 꺼낸 뒤 va_end로 정리합니다. 함수는 인자가 몇 개이고 각각 무슨 타입인지 스스로 알 수 없으므로, 개수나 타입 정보를 고정 인자(개수, 포맷 문자열, 끝 표시값)로 따로 받아야 합니다.
#include <stdarg.h>
/* count개의 int를 더한다 */
long sum_ints(int count, ...) {
va_list ap;
va_start(ap, count);
long total = 0;
for (int i = 0; i < count; ++i) {
total += va_arg(ap, int);
}
va_end(ap);
return total;
}
/* sum_ints(3, 1, 2, 3) == 6 */
가변 인자 위치의 값에는 기본 인자 승격이 적용됩니다. char와 short는 int로, float는 double로 바뀌어 전달되므로 꺼낼 때도 va_arg(ap, int), va_arg(ap, double)을 써야 합니다. va_arg(ap, float)는 미정의 동작이고, GCC는 이 경우 경고하며 실행 시 중단 명령을 넣기도 합니다. 반대로 sum_ints(2, 1L, 2L)처럼 long을 넘기면 받는 쪽은 int로 읽으므로 역시 미정의 동작이지만, 이번에는 컴파일러가 아무것도 알려 주지 않습니다.
x86-64 System V에서 va_list는 단순한 포인터가 아니라 구조체입니다. 가변 인자 함수의 프롤로그는 인자 레지스터 6개와 xmm0~xmm7을 레지스터 저장 영역(register save area)에 내려 두고, va_list는 그 영역에서 정수와 부동소수점 인자를 몇 개 읽었는지(gp_offset, fp_offset)와 스택으로 넘어온 나머지 인자의 위치(overflow_arg_area)를 기록합니다. va_arg는 요청한 타입이 정수 계열이면 정수 쪽 오프셋을, double이면 부동소수점 쪽 오프셋을 따라갑니다. 그래서 타입을 잘못 지정하면 단순히 비트 해석이 틀리는 정도가 아니라, 정수 레지스터 영역을 읽어야 할 때 부동소수점 영역을 읽는 식으로 아예 다른 값을 가져옵니다. 호출하는 쪽은 사용한 벡터 레지스터 개수를 al에 넣어 전달하는데, 프로토타입 없이 가변 인자 함수를 호출하면 이 값이 설정되지 않아 부동소수점 인자가 깨질 수 있습니다. 가변 인자 함수를 반드시 프로토타입과 함께 호출해야 하는 이유입니다.
같은 인자 목록을 두 번 순회해야 하면 va_copy로 복사본을 만들어야 합니다. 한 번 va_arg로 진행한 va_list는 되감을 수 없고, 구조체이므로 대입으로 복사하는 것도 이식성이 없습니다. 출력 길이를 먼저 계산한 뒤 버퍼를 할당하는 패턴이 대표적인 예입니다.
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
char *format_alloc(const char *fmt, ...) {
va_list ap, ap2;
va_start(ap, fmt);
va_copy(ap2, ap);
int len = vsnprintf(NULL, 0, fmt, ap); /* 필요한 길이만 계산 */
va_end(ap);
char *buf = NULL;
if (len >= 0 && (buf = malloc((size_t)len + 1)) != NULL) {
vsnprintf(buf, (size_t)len + 1, fmt, ap2);
}
va_end(ap2);
return buf;
}
printf 계열처럼 포맷 문자열을 받는 함수를 직접 만들었다면 GCC·Clang의 __attribute__((format(printf, 1, 2)))를 선언에 붙이세요. 그러면 -Wformat이 표준 printf와 똑같이 포맷과 인자 타입의 불일치를 검사해 줍니다. 그 외의 새 API라면 가변 인자보다 배열과 개수, 또는 옵션 구조체를 받는 쪽이 타입 검사를 받을 수 있어 안전합니다.
함수 포인터
함수 이름은 대부분의 문맥에서 함수 포인터로 변환됩니다. 포인터 타입은 반환 타입과 매개변수 타입을 모두 포함하므로, typedef로 이름을 붙이면 읽기 쉬워집니다.
typedef int (*binop_fn)(int, int);
int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }
int apply(binop_fn op, int x, int y) {
return op(x, y);
}
/* apply(add, 2, 3) == 5, apply(mul, 2, 3) == 6 */
표준 라이브러리의 qsort는 비교 함수를 함수 포인터로 받습니다. 비교 함수는 원소 타입을 모르는 qsort를 위해 const void *를 받으므로, 잘못된 타입으로 캐스팅해도 컴파일러가 잡지 못합니다.
#include <stdlib.h>
static 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);
}
비교 결과를 return x - y;로 쓰는 예제가 많지만, x가 큰 양수이고 y가 큰 음수이면 뺄셈이 오버플로(미정의 동작)되어 부호가 뒤집힙니다. (x > y) - (x < y)는 -1, 0, 1만 반환하므로 오버플로가 없습니다.
콜백을 받는 API는 보통 함수 포인터와 함께 void * 컨텍스트를 받아, 호출자가 원하는 상태를 콜백에 전달할 수 있게 합니다. 이 void *는 타입 정보를 잃기 때문에, 등록할 때 넘긴 포인터 타입과 콜백 안에서 캐스팅하는 타입이 어긋나도 컴파일러가 알 수 없습니다. 콜백 계층이 깊어질수록 이런 불일치를 찾기 어려워지므로, 콜백 타입마다 typedef를 두고 관련 함수 포인터와 컨텍스트를 하나의 구조체로 묶어 경계를 명확히 하는 것이 좋습니다.
함수 포인터를 다른 타입의 함수 포인터로 캐스팅해 호출하는 것은 미정의 동작입니다. 예를 들어 int f(int *)를 int (*)(void *)로 캐스팅해 qsort류에 넘기는 코드는 x86-64에서는 우연히 동작하지만, Clang의 CFI(-fsanitize=cfi-icall)처럼 간접 호출의 타입을 검사하는 보호 기능을 켜면 즉시 중단됩니다. dlsym으로 플러그인의 함수를 가져올 때도 선언한 타입과 실제 정의가 같은지는 사람이 보장해야 하므로, 플러그인 인터페이스에는 버전 번호를 두고 로드 직후 확인하는 것이 일반적입니다.
static 함수와 외부 연결
파일 범위의 static 함수는 내부 연결(internal linkage)을 가져 해당 번역 단위 안에서만 보입니다. 다른 파일과 이름이 겹쳐도 충돌하지 않고, 컴파일러는 이 함수의 호출자를 모두 알기 때문에 인라인하거나 사용되지 않으면 제거하기 쉽습니다.
static int internal_helper(int x) {
return x * x;
}
int public_api(int x) {
return internal_helper(x) + 1;
}
static이 없는 함수는 외부 연결을 가지므로 프로그램 전체에서 정의가 하나뿐이어야 합니다. 두 .c 파일에 같은 이름의 비-static 함수를 정의하면 링커가 multiple definition 오류를 냅니다. 반대로 undefined reference는 정의가 아예 없거나, 정의가 static이라 다른 파일에서 보이지 않거나, 정의가 든 오브젝트 파일이나 라이브러리를 링크 명령에서 빠뜨렸을 때 납니다. nm file.o로 심볼을 보면 T(외부 연결 정의), t(내부 연결 정의), U(정의되지 않은 참조)로 구분되므로 원인을 빨리 좁힐 수 있습니다. 함수 선언에 extern을 붙이는 것은 기본값과 같아서 생략해도 의미가 같습니다.
에러 전달 패턴
C에는 예외가 없으므로 함수는 반환값으로 성공·실패를 알리고, 결과 값은 출력 매개변수로 돌려주는 패턴이 흔합니다.
#include <errno.h>
#include <limits.h>
/* 성공 시 0, 실패 시 -1과 errno 설정 */
int safe_div(long a, long b, long *out) {
if (!out) {
errno = EINVAL;
return -1;
}
if (b == 0) {
errno = EDOM;
return -1;
}
if (a == LONG_MIN && b == -1) {
errno = ERANGE; /* 결과가 LONG_MAX를 넘음 */
return -1;
}
*out = a / b;
return 0;
}
0으로 나누기만 검사하는 예제가 많지만, 2의 보수 정수에서 LONG_MIN / -1은 결과가 표현 범위를 넘는 오버플로이고 미정의 동작입니다. x86-64에서는 실제로 idiv 명령이 예외를 일으켜 프로세스가 SIGFPE로 종료됩니다.
errno는 스레드마다 따로 있으므로 멀티스레드에서도 안전하지만, 성공한 함수가 errno를 0으로 되돌려 놓지는 않습니다. 그래서 errno는 함수가 실패를 반환했을 때만 확인해야 합니다. 라이브러리 전체에서 쓰는 전역 last_error 변수는 스레드가 들어오는 순간 서로 덮어쓰므로, 에러 정보가 더 필요하면 에러 코드 열거형을 반환하거나 호출자가 넘긴 컨텍스트 구조체에 기록하는 편이 낫습니다.
다음: #05 포인터 연산·엄격 별칭
같이 보면 좋은 글
- C 언어 시리즈 #07 — 구조체·공용체·비트필드·패딩과 ABI
- C 언어 시리즈 #03 — 제어 흐름: 분기·스위치 테이블·setjmp와 스택
- C 언어 시리즈 #02 — 타입·승격(usual arithmetic)·정수 표현과 패딩