Proven C Book←↑→

62 함수 선언과 호출식 — 부르는 일의 안쪽

먼저 알아야 할 것

25장 함수 선언과 정의 · 원형·정의·inline
34장 함수의 의미 · 값 복사와 평가 순서
57장 링크와 ABI · 소스에 안 적히는 기계 수준의 약속 — application binary interface 의 줄임이다

돌아보기

34장에서 「인자가 평가되는 순서는 정해져 있지 않다」고 배웠다. 그런데 정해지지 않았다는 말은 정확히 무슨 뜻인가 — 컴파일러가 마음대로 해도 된다는 뜻인가, 아니면 아무 일이나 일어나도 된다는 뜻인가?

답. 둘은 다르고, 그 차이가 이 장의 첫 매듭이다. 인자의 평가 순서는 미지정(unspecified)이다 — 컴파일러는 몇 가지 허용된 순서 중 하나를 고르고, 어느 쪽을 골랐든 프로그램은 멀쩡히 돈다. 반면 같은 값을 한 호출 안에서 두 번 고치는 일은 미정의(undefined)다 — 그때는 아무 일이나 일어나도 된다. 이 장은 그 경계를 실물로 보여 준다.

이 장의 필요성과 맥락

22장에서 함수를 부르는 법을 배웠고, 25장에서 선언과 정의를 갈랐고, 57장에서 「호출 규약이라는 약속이 있다」는 사실을 알았다. 그런데 정작 한 번의 호출 안에서 무슨 일이 벌어지는가를 한자리에 모아 본 적이 없다. 이 장이 그 자리다. 그리고 하필 여기인 데에는 이유가 있다 — 바로 다음 장의 가변 인자 함수는 이 장의 규칙이 무너지는 자리이기 때문이다. 무엇이 무너지는지 알려면 먼저 서 있는 모습을 봐야 한다.

이 장이 끝나면

선언·정의·호출식 셋을 갈라 놓고, 호출식이 어떤 연산자인지 본다. 인자가 원형을 만나 어떻게 조율되는지, 평가 순서가 컴파일러마다 어떻게 갈리는지를 실측으로 확인한다. 그다음 한 번의 호출을 다섯 걸음으로 쪼개어 인자가 레지스터와 스택 중 어디에 실리는지를 열두 기계에서 견주고 — ARM 과 임베디드 쪽의 기묘한 사례가 여기서 나온다 — 구조체를 주고받는 값, 함수 포인터를 돌려주는 함수, 그리고 이 언저리에서 자주 나는 사고들로 닫는다.

이 장에서 답할 질문

  1. 함수 포인터를 다른 타입으로 캐스트해서 부르는 코드를 실제로 본 적이 있다. 콜백을 등록하는 자리에서 흔한데, 그것도 위험한가?

62.1 셋을 갈라 놓는다 — 선언, 정의, 호출식#

같은 이름이 세 자리에 나온다. 세 자리가 하는 일은 전혀 다르다.

무엇을 적는가기억을 차지하는가언제 쓰이는가
선언(declaration)이름과 타입 — int add(int, int);아니오컴파일러가 호출을 검사할 때
정의(definition)선언에 더해 본문 { … }예 — 기계어가 놓인다링커가 이름을 이을 때
호출식(call expression)add(2, 3) — 부르는 식아니오프로그램이 돌 때

표 62.1 — 한 이름의 세 자리

선언은 약속이고, 정의는 물건이고, 호출식은 사건이다. 이 장은 셋째 — 사건 — 을 주로 다루지만, 사건이 제대로 일어나려면 앞의 둘이 서로 어긋나지 않아야 한다. 어긋나도 컴파일러가 아무 말을 못 하는 경우가 있고, 그것이 이 언저리에서 가장 험한 사고를 낸다(57장).

62.1.1 호출식은 연산자다#

add(2, 3) 을 「함수를 부르는 문법」이라고만 보면 놓치는 것이 있다. C 문법에서 이것은 후위 연산자 () 를 쓴 식이고, 피연산자가 둘로 갈린다.

자리무엇이 오는가예
함수 지시자함수를 가리키는 식 — 이름이든, 포인터든, 배열에서 꺼낸 것이든add, tbl[i], pick('+')
인자 목록식 0개 이상, 쉼표로 나눈 것2, 3

표 62.2 — 호출식의 피연산자

이 사실에서 바로 따라 나오는 결과가 있다. 함수 지시자 자리에 식이 올 수 있으므로, 그 식 자체에도 부수효과가 있을 수 있다 — tbl[i++](x) 처럼. 그리고 함수 이름은 쓰이는 순간 함수 포인터로 무너지므로(64장), add(2,3) 과 (*add)(2,3) 과 (&add)(2,3) 은 모두 같은 뜻이다.

★ 쉼표로 나눈 인자 목록의 쉼표는 쉼표 연산자가 아니다. f(a, b) 는 인자 둘이고, f((a, b)) 는 인자 하나다 — 안쪽 괄호 속의 쉼표가 그제야 연산자가 되어 a 를 평가해 버리고 b 를 값으로 준다. 매크로가 인자를 세는 방식도 이 구분을 따른다(61장).

62.1.2 C23 이 정리한 옛 자리#

원형 없는 함수는 오래된 C 의 유산이었다. C23 이 그 유산을 걷어 냈다.

적은 것C17 까지C23
int f(); — 빈 괄호「인자 정보를 안 밝힘」int f(void) 와 같은 뜻
선언 없이 f(1) 호출암묵적 선언 — int f() 로 간주오류
int f(a, b) int a; int b; { … }옛 정의 형식 — 허용표준에서 삭제

표 62.3 — 원형을 둘러싼 규칙의 변화

이 기계의 gcc 로 확인하면 차이가 그대로 드러난다. static int f() { return 1; } 를 두고 f(2) 를 부르면 -std=c17 에서는 아무 말이 없지만 -std=c23 에서는 too many arguments to function 'f' 로 잘린다. 빈 괄호가 「모른다」에서 「없다」로 바뀐 것이다.

★ 반대 방향의 어긋남도 있다. 표의 둘째 줄 — 선언 없는 호출 — 은 C23 이 오류로 정한 것인데, 이 기계의 gcc 14 는 -std=c17 에서도 이미 오류로 낸다. 표준이 정하기 전에 컴파일러가 먼저 막은 것이다.

★ 「없앴다」와 「컴파일러가 거부한다」는 다르다. 옛 정의 형식은 표준에서 사라졌지만 이 기계의 gcc 는 -std=c23 에서도 경고 하나(-Wold-style-definition) 로 받아 준다 — 옛 코드를 살려 두려는 배려다. 반면 clang 은 같은 자리에서 오류를 낸다. 표준이 무엇을 없앴는가와 내 컴파일러가 무엇을 받아 주는가는 따로 확인해야 한다.

62.2 인자는 대입하듯 들어간다#

원형이 있으면 컴파일러는 인자 하나하나를 매개변수 타입으로 대입하듯 바꾼다(30장). 반환값도 마찬가지로 반환 타입으로 바뀐다. 소리 없이 일어나는 일이라 눈으로 한 번 봐 두는 편이 좋다.

examples/ch62/convert.c

// 원형이 있으면 인자는 "대입하듯" 변환되어 들어간다.
#include <stdio.h>

static void takes_int(int n)
{
    printf("  the parameter holds %d\n", n);
}

static void takes_unsigned(unsigned n)
{
    printf("  the parameter holds %u\n", n);
}

// 반환값도 마찬가지다 --- 돌려주는 식은 반환 타입으로 변환된다.
static int truncating_return(void)
{
    return (int)3.99;              // 명시적으로 적어 두면 읽는 사람이 안다
}

static char narrowing_return(void)
{
    int wide = 321;
    return (char)wide;             // 321 은 char 에 들어가지 않는다
}

int main(void)
{
    puts("a double handed to an int parameter:");
    takes_int(3.7);                // 3 으로 잘려서 들어간다

    puts("a negative int handed to an unsigned parameter:");
    takes_unsigned(-1);            // 감싸 올라간다

    printf("returning 3.99 as int: %d\n", truncating_return());
    printf("returning 321 as char: %d\n", narrowing_return());

    // 원형이 없으면 이 조율이 일어나지 않는다. C23 부터는 원형 없는 호출
    // 자체가 오류이므로, 남은 "조율 없는 자리"는 ... 뿐이다.
    return 0;
}

실행 결과

a double handed to an int parameter:
  the parameter holds 3
a negative int handed to an unsigned parameter:
  the parameter holds 4294967295
returning 3.99 as int: 3
returning 321 as char: 65

3.7 이 3 이 되어 들어가고, -1 이 unsigned 자리에서 감싸 올라가고, 321 이 char 로 좁혀지며 65 가 된다. 어느 것도 오류가 아니다 — 대입이 그러하듯 조용히 변환될 뿐이다.

자리무엇이 일어나는가기대는 것
원형이 있는 매개변수대입하듯 변환원형의 타입
... 를 건너는 인자기본 인자 승격 — float→double, 작은 정수→int받는 쪽은 모른다(63장)
return 하는 식반환 타입으로 변환함수의 반환 타입
배열 이름을 인자로첫 원소의 포인터로 무너진다—
함수 이름을 인자로함수 포인터로 무너진다—

표 62.4 — 인자와 반환값에 붙는 조율

배열이 무너지는 자리는 특히 자주 데인다. 매개변수에 int a[10] 이라 적어도 받는 것은 포인터 하나다.

examples/ch62/params.c

// 매개변수에 적은 것과 실제로 받는 것은 다를 수 있다.
#include <stdio.h>

// [10] 이라고 적었지만 받는 것은 포인터 하나다.
// (여기서 sizeof a 를 쓰면 gcc 가 -Wsizeof-array-argument 로 나무란다.)
static void looks_like_array(int a[10])
{
    int *p = a;                    // 같은 것이다 --- 대입에 경고가 없다
    printf("  inside: sizeof(the parameter) = %zu\n", sizeof p);
}

// 이렇게 적어야 "적어도 10개는 있다"가 계약이 된다 (C99 부터).
static int sum_ten(int a[static 10])
{
    int total = 0;
    for (int i = 0; i < 10; i++)
        total += a[i];
    return total;
}

// 길이를 따로 받는 것이 정석이다.
static int sum_n(const int *a, size_t n)
{
    int total = 0;
    for (size_t i = 0; i < n; i++)
        total += a[i];
    return total;
}

int main(void)
{
    int v[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

    printf("outside: sizeof(v) = %zu\n", sizeof v);
    looks_like_array(v);
    printf("  the array itself never crossed the call --- only its address did\n");

    printf("sum via [static 10]: %d\n", sum_ten(v));
    printf("sum via pointer+length: %d\n", sum_n(v, sizeof v / sizeof v[0]));
    return 0;
}

실행 결과

outside: sizeof(v) = 40
  inside: sizeof(the parameter) = 8
  the array itself never crossed the call --- only its address did
sum via [static 10]: 55
sum via pointer+length: 55

바깥에서 40바이트였던 것이 안에서는 8바이트다 — 배열은 호출을 건넌 적이 없다. 넘어간 것은 주소뿐이다. 그래서 안쪽에서 sizeof 로 길이를 알아내려는 시도는 헛되고, gcc 는 아예 이 자리를 짚어 준다: 'sizeof' on array function parameter 'a' will return size of 'int *'.

★ int a[static 10] 은 다르다. 여전히 받는 것은 포인터지만, 「적어도 10개는 있다」가 계약으로 적힌다(53장). 컴파일러는 이 약속을 근거로 최적화하고, 어기면 미정의 동작이다 — 약속을 적는 자리이지 검사를 붙이는 자리가 아니다.

62.3 순서 — 표준이 정하지 않은 것#

이제 이 장의 첫 매듭이다. 한 호출식 안에서 인자들은 어떤 순서로도 평가될 수 있다. 실물로 본다.

examples/ch62/order.c

// 인자가 평가되는 순서는 표준이 정해 두지 않았다.
// 같은 소스가 컴파일러마다 다른 순서로 돌 수 있다.
#include <stdio.h>

static int step = 0;

// 부르면 자기 이름을 찍고 몇 번째로 불렸는지를 돌려준다.
static int mark(const char *who)
{
    step += 1;
    printf("  evaluated %s (step %d)\n", who, step);
    return step;
}

static void take(int a, int b, int c)
{
    printf("  the callee got a=%d b=%d c=%d\n", a, b, c);
}

int main(void)
{
    puts("call with three arguments:");
    take(mark("the first argument"),
         mark("the second argument"),
         mark("the third argument"));

    // 한 인자 *안에서*의 순서는 또 다른 이야기다.
    step = 0;
    puts("one argument built from two calls:");
    take(mark("left of +") + mark("right of +"), 0, 0);
    return 0;
}

실행 결과

call with three arguments:
  evaluated the third argument (step 1)
  evaluated the second argument (step 2)
  evaluated the first argument (step 3)
  the callee got a=3 b=2 c=1
one argument built from two calls:
  evaluated left of + (step 1)
  evaluated right of + (step 2)
  the callee got a=3 b=0 c=0

이 기계의 gcc 는 마지막 인자부터 거꾸로 평가했다. 같은 소스를 clang 으로 빌드하면 첫 인자부터 순서대로 평가한다. 최적화 수준을 -O0·-O2·-Os 로 바꿔 봐도 각자의 버릇은 그대로였다.

컴파일러인자 세 개show(i++, i++) 의 결과최적화를 바꾸면
gcc 14 (x86-64)셋째 → 둘째 → 첫째a=1 b=0-O0·-O2·-Os 모두 같음
clang 22.1 (x86-64)첫째 → 둘째 → 셋째a=0 b=1모두 같음

표 62.5 — 같은 소스, 다른 순서 — 이 기계에서 잰 것

여기서 두 가지를 갈라 읽어야 한다.

순서가 갈리는 것 자체는 사고가 아니다. 인자를 평가하는 순서는 미지정이고, 어느 쪽을 골라도 프로그램은 정의된 대로 돈다 — 다만 내가 기대한 순서에 기대어 쓴 코드가 무너질 뿐이다. 실제로 위 시연의 둘째 부분을 보면, 바깥 인자는 거꾸로 가면서 한 인자 안쪽의 두 호출은 소스 순서대로 갔다. 「이 컴파일러는 오른쪽부터 한다」는 요약조차 정확하지 않은 것이다.

값을 두 번 고치는 것은 사고다. show(i++, i++) 는 한 값을 순서 없이 두 번 고치므로 미정의 동작이다. 위 표의 a=1 b=0 과 a=0 b=1 은 「두 답 중 하나」가 아니라 「아무 답이나 나와도 되는 자리에서 나온 두 예」다.

반례. 한 호출 안에서 같은 것을 두 번 건드리기

printf("%d %d\n", i, i++);     /* 미정의 동작 */
put(buf[k], buf[k++]);          /* 미정의 동작 */

고치는 법은 언제나 같다 — 줄을 나눈다.

int before = i;
i += 1;
printf("%d %d\n", before, i);

두 컴파일러 모두 -Wall 만으로 이 자리를 짚어 준다 — gcc 는 operation on 'i' may be undefined(-Wsequence-point), clang 은 unsequenced modifications to 'i'(-Wunsequenced)다. 경고를 켜 두는 것으로 대부분 걸린다.

62.3.1 순서가 있는 자리#

전부 자유로운 것은 아니다. 호출과 호출 사이에는 분명한 규율이 있다.

무엇과 무엇관계뜻
인자들 서로미지정 순서어느 것이 먼저일지 모른다
함수 지시자와 인자들미지정 순서tbl[i] 를 언제 읽을지도 모른다
인자 전부와 함수 본문순서가 있다인자가 모두 평가된 뒤에 본문이 시작한다
함수 호출 두 개겹치지 않는다한쪽이 끝나야 다른 쪽이 시작한다

표 62.6 — 호출 언저리의 순서 규칙

마지막 줄이 중요하다. f(g(), h()) 에서 g 와 h 중 누가 먼저인지는 모르지만, g 의 절반이 돌다가 h 로 넘어갔다 되돌아오는 일은 없다. 순서는 미지정이되 뒤섞임은 금지다. 표준이 이 관계에 따로 이름을 붙여 둘 만큼 중요한 보장인데 — 순서는 확정되지 않았지만 하나씩 차례로 일어난다는 뜻으로 「부정 순서」 (indeterminately sequenced)라 부른다 — 이것이 없으면 재진입하지 않는 함수를 인자 자리에서 부르는 일이 전부 위험해진다.

62.4 한 번의 호출, 다섯 걸음#

이제 사건의 안쪽이다. add(2, 3) 한 줄이 기계 수준에서 무엇을 시키는가.

걸음누가 하는가무슨 일
① 인자 배치부르는 쪽약속된 레지스터에 싣고, 넘치면 스택에 쌓는다
② 호출 명령부르는 쪽돌아올 주소를 남기고 뛴다
③ 프롤로그불린 쪽자기 프레임을 잡고, 망가뜨릴 레지스터를 저장한다
④ 본문불린 쪽일한다 — 이 안에서 또 남을 부를 수 있다
⑤ 에필로그·복귀불린 쪽반환값을 약속된 자리에 두고, 프레임을 걷고, 돌아온다

표 62.7 — 호출 한 번의 다섯 걸음

②의 「돌아올 주소」가 함수라는 장치의 심장이다. 이 주소가 스택에 남기 때문에 호출은 겹겹이 쌓일 수 있고, 재귀가 성립하고, 그리고 — 그 자리를 넘어 쓰면 프로그램이 남의 주소로 돌아간다(44장).

③에서 갈리는 또 하나의 약속이 누가 무엇을 지키는가다.

갈래뜻x86-64 System V 의 예
부르는 쪽이 지킨다호출 뒤에도 값이 필요하면 미리 치워 둔다 — 불린 쪽은 마음껏 쓴다rax, rcx, rdx, rsi, rdi, r8–r11
불린 쪽이 지킨다쓰려면 먼저 저장했다가 돌아가기 전에 되돌려 놓는다rbx, rbp, r12–r15

표 62.8 — 레지스터를 지키는 책임

이 구분이 있어야 호출이 싸진다. 모든 레지스터를 매번 저장하면 호출 한 번이 수십 번의 기억 접근이 된다. 절반씩 나눠 맡기면 대개는 아무것도 저장하지 않고 지나간다.

62.5 레지스터인가 스택인가 — 예산이 있다#

인자를 레지스터로 보내면 빠르다. 그러나 레지스터는 유한하다. 그래서 모든 호출 규약은 예산을 정해 둔다 — 몇 개까지 레지스터로 보내고, 그 뒤는 스택으로 보낸다.

C 에는 「이 인자는 레지스터로 왔다」를 묻는 문법이 없다. 그래도 간접적으로 볼 수는 있다. 최적화를 끄면 레지스터로 받은 인자는 함수가 자기 프레임에 나란히 내려놓고, 스택으로 실려 온 인자는 부른 쪽이 놓아 둔 자리를 그대로 쓴다. 두 무리는 멀리 떨어져 있으므로 이웃한 인자의 주소 차이를 재면 경계가 드러난다.

examples/ch62/where.c

// 인자는 어디에 놓여 있는가 --- 레지스터로 온 것과 스택으로 온 것.
//
// C 에는 "이 인자는 레지스터로 왔다"를 묻는 문법이 없다. 그러나 최적화를
// 끄면 컴파일러는 레지스터로 받은 인자를 자기 프레임에 나란히 내려놓고,
// 스택으로 실려 온 인자는 호출한 쪽이 놓아 둔 자리를 그대로 쓴다. 두
// 무리는 서로 멀리 떨어져 있으므로, *이웃한 인자의 주소 차이*를 재면
// 경계가 어디인지 드러난다.
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>

static void eight(int a, int b, int c, int d, int e, int f, int g, int h)
{
    const int *arg[8] = { &a, &b, &c, &d, &e, &f, &g, &h };
    ptrdiff_t widest = 0;
    int at = 0;

    for (int i = 0; i + 1 < 8; i++) {
        uintptr_t p = (uintptr_t)arg[i], q = (uintptr_t)arg[i + 1];
        ptrdiff_t gap = p > q ? (ptrdiff_t)(p - q) : (ptrdiff_t)(q - p);
        printf("  argument %d to %d: %td bytes apart\n", i + 1, i + 2, gap);
        if (gap > widest) { widest = gap; at = i + 1; }
    }
    printf("the widest jump is between argument %d and %d\n", at, at + 1);
}

int main(void)
{
    puts("a call with eight arguments:");
    eight(1, 2, 3, 4, 5, 6, 7, 8);
    puts("that jump is the seam between the register group and the stack group.");
    return 0;
}

실행 결과

a call with eight arguments:
  argument 1 to 2: 4 bytes apart
  argument 2 to 3: 4 bytes apart
  argument 3 to 4: 4 bytes apart
  argument 4 to 5: 4 bytes apart
  argument 5 to 6: 4 bytes apart
  argument 6 to 7: 152 bytes apart
  argument 7 to 8: 8 bytes apart
the widest jump is between argument 6 and 7
that jump is the seam between the register group and the stack group.

여섯째와 일곱째 사이에서 자리가 크게 뛴다. 그 자리가 이 ABI(application binary interface) 의 정수 인자 예산 — 여섯 — 이 끝나는 지점이다. 같은 프로그램을 clang 으로 빌드해도 뛰는 폭은 달랐지만 뛰는 자리는 같았다. 컴파일러의 취향이 아니라 규약이 정한 경계이기 때문이다.

규약정수 인자실수 인자넘치면치우는 쪽
System V x86-64 — 리눅스·macOSrdi rsi rdx rcx r8 r9 (6)xmm0–xmm7 (8)스택에 밀어 넣는다부르는 쪽
Microsoft x64 — 윈도rcx rdx r8 r9 (4)xmm0–xmm3 (4)32바이트 그림자 공간 뒤 스택부르는 쪽
AAPCS64 — AArch64x0–x7 (8)d0–d7 (8)스택부르는 쪽
cdecl — 32비트 x86없음 — 전부 스택전부 스택—부르는 쪽

표 62.9 — 책상 위의 네 규약 — 이 기계의 컴파일러가 낸 것

윈도의 「그림자 공간」은 레지스터로 인자를 받은 함수가 그것을 내려놓을 자리를 부르는 쪽이 미리 비워 두는 약속이다. 인자가 하나도 없어도 32바이트를 비운다. 같은 CPU 에 같은 기계어인데 약속이 다른 것이다(57장).

62.5.1 치우는 쪽이 갈리면 이름도 갈린다#

32비트 윈도에는 규약이 여럿 공존했다. 인자를 스택에 쌓는 것은 같은데 누가 치우는가가 달랐고, 그래서 잘못 이으면 스택이 조용히 어긋난다. 그 시절의 해법이 이름에 흔적을 남기는 것이었다.

규약인자스택을 치우는 쪽심볼 이름
cdecl전부 스택부르는 쪽 (add $28, %esp)_f
stdcall전부 스택불린 쪽 (ret $8)_f@8 — 인자 바이트 수가 이름에
fastcall앞의 둘은 ecx·edx불린 쪽@f@8

표 62.10 — 32비트 윈도의 세 규약 — int f(int,int) 를 이 기계의 mingw 로 빌드해서

ret $8 한 줄이 「돌아가면서 스택 8바이트도 함께 걷는다」는 뜻이다. 그리고 이름 뒤의 @8 덕분에, 인자 개수가 다른 선언으로 잘못 부르면 링커가 이름을 못 찾는다 — 조용한 파손 대신 링크 오류가 난다. 이름 장식이 안전 장치 노릇을 한 드문 예다.

62.6 기묘한 사례들 — ARM 과 임베디드#

책상 위의 규약만 보면 「예산이 몇 개인가」 정도의 차이로 보인다. 작은 기계로 내려가면 훨씬 기묘한 규칙들이 나온다. 아래는 모두 한 줄의 같은 선언을 서로 다른 표적으로 빌드해 확인한 것이다.

long long mix(int a, long long b, int c);   /* mix(1, 2, 3) 을 부른다 */
표적a, b, c 가 실린 자리눈에 띄는 점
ARM (AAPCS)r0 / r2:r3 / 스택64비트 값은 짝수 레지스터에서 시작해야 해서 r1 을 비워 둔다 — 그 바람에 셋째 인자가 스택으로 밀린다
RISC-V 32a0 / a1:a2 / a3같은 자리에서 레지스터를 비우지 않는다 — 전부 레지스터에 들어간다
AVR (8비트)r25:r24 / r23:r16 / r15:r148비트 기계라 16비트 int 하나가 레지스터 두 개, 64비트 값 하나가 여덟 개를 쓴다 — 인자 셋에 열두 레지스터다
MSP430r12 / 스택 / r13소스 순서대로 레지스터를 채우지 않는다 — 큰 값만 메모리로 빠지고 뒤 인자가 앞 레지스터를 쓴다
SPARC%o0 / %o1:%o2 / %o3보낼 때는 %o 레지스터, 받는 쪽은 같은 것을 %i 로 본다 — 레지스터 창
m68k전부 스택레지스터 예산이 아예 0 이다

표 62.11 — 같은 한 줄이 여섯 기계에서 실려 가는 모습

실제 사례. 한 아키텍처, 두 개의 서로 못 만나는 약속

ARM 에서 double 을 넘기는 방법은 하나가 아니다. 부동소수점 장치를 안 쓰기로 한 쪽 — soft-float 이라 부른다 — 에서는 double 두 개가 정수 레지스터 r0:r1 과 r2:r3 에 실린다. 부동소수점 레지스터를 쓰기로 한 쪽 — hard-float — 에서는 같은 두 값이 d0·d1 로 간다. 같은 소스, 같은 CPU, 명령까지 같은데 값이 실리는 자리가 전혀 다르다.

그래서 두 약속으로 빌드한 목적 파일을 섞으면 함수는 자기가 받지 않은 레지스터를 읽는다 — 프로그램은 죽지 않고 그저 엉뚱한 수로 계산한다. 요즘 도구는 ELF(executable and linkable format) 파일에 약속을 표시해 두고 링커가 거절하지만, 배포된 이진 라이브러리 하나를 잘못 골라 이 사고를 내는 일이 임베디드 쪽에서는 아직도 흔하다. 리눅스 배포판 이름에 붙은 armhf 의 hf 가 바로 이 약속을 가리킨다.

플랫폼 노트. 작은 기계의 더 기묘한 규칙들

Thumb 의 홀수 주소. ARM 의 Thumb 모드에서 함수 주소는 최하위 비트를 1 로 둔다. 그 비트는 주소의 일부가 아니라 「돌아가서 Thumb 으로 실행하라」는 표시다(4장의 태그 포인터가 실물로 쓰인 자리다). 함수 포인터를 정수로 바꿔 찍어 보면 늘 홀수라서 처음 보면 당황한다.

스택이 없는 기계. PIC10/12/16 계열 같은 아주 작은 마이크로컨트롤러는 자료 스택이 없다시피 하다. 그 세계의 컴파일러는 매개변수와 지역 변수를 컴파일 시간에 고정된 자리에 배정한다(compiled stack). 그러면 같은 함수가 자기 자신을 부르는 순간 그 자리를 덮어쓰므로 — 재귀가 성립하지 않는다. 8051 쪽 컴파일러가 reentrant 같은 낱말을 따로 두는 이유도 같다. C 표준이 재귀를 요구하는데도 이런 구현이 존재하는 것은, 그 기계에서 표준을 온전히 지키느니 지키지 못하는 채로 쓰는 편이 쓸모 있었기 때문이다.

이 두 문단은 이 기계에서 재 본 것이 아니라 각 도구의 문서가 정한 것이다 — 잰 것과 읽은 것을 섞지 않으려고 자리를 나누어 적는다.

62.7 구조체를 주고받을 때#

값 하나가 레지스터에 안 들어가면 어떻게 되는가. 구조체가 그 경계에 있다.

넘기는 것x86-64 System VAArch64읽는 법
struct { int x, y; } (8바이트)rdi 하나에 둘을 담아x0 하나에작은 구조체는 레지스터에 포장된다
struct { double a, b; } (16바이트)xmm0·xmm1d0·d1실수는 실수 레지스터로 — 두 벌의 예산은 따로 센다
struct { long v[5]; } (40바이트)스택에 복사복사본의 주소를 x0 로크면 기억으로 — 그런데 방식이 또 다르다
40바이트를 돌려줄 때부른 쪽이 자리를 잡아 rdi 로 넘긴다같은 자리를 x8 로 넘긴다반환값에도 숨은 인자가 붙는다

표 62.12 — 구조체 하나를 넘길 때 — 컴파일러가 낸 것

큰 구조체를 값으로 주고받는 코드는 소스에서 한 글자도 그런 티를 내지 않지만, 실제로는 복사와 숨은 인자가 따라붙는다. 그래서 성능이 걸린 자리에서는 포인터로 넘기고 const 를 붙이는 관용구가 자리 잡았다(48장).

★ 「16바이트 이하면 레지스터」 같은 어림은 규약마다 다르고 예외도 많다 — 구조체 안에 실수와 정수가 섞이면 SysV 는 8바이트 조각별로 갈래를 매겨 어떤 조각은 정수 레지스터로, 어떤 조각은 실수 레지스터로 보낸다. 이 규칙을 외울 필요는 없다. 외울 것은 하나다 — 값의 크기와 구성이 실려 가는 방식을 바꾼다.

62.8 함수 포인터를 돌려주는 함수#

호출식의 함수 지시자 자리에 식이 올 수 있다면, 그 식을 함수가 만들어 줄 수도 있다. 선언자를 읽는 연습으로도 좋은 자리다.

examples/ch62/retfp.c

// 함수 포인터를 돌려주는 함수 --- 같은 것을 세 가지 표기로 적는다.
#include <stdio.h>

static int add(int a, int b) { return a + b; }
static int mul(int a, int b) { return a * b; }

// ① 날것의 선언자. 안쪽부터 읽는다:
//    pick 은 (char) 를 받는 함수이고, 그 결과는
//    (int, int) 를 받아 int 를 주는 함수를 가리키는 포인터다.
static int (*pick(char op))(int, int)
{
    return op == '+' ? add : mul;
}

// ② 이름을 붙여 두면 같은 뜻이 한 줄로 읽힌다.
typedef int binop(int, int);      // 함수 타입
static binop *pick_typedef(char op)
{
    return op == '+' ? add : mul;
}

// ③ 반환 타입만 이름 붙이는 흔한 절충.
typedef int (*binop_ptr)(int, int);
static binop_ptr pick_ptr(char op)
{
    return op == '+' ? add : mul;
}

int main(void)
{
    printf("raw declarator: %d\n", pick('+')(3, 4));
    printf("function typedef: %d\n", pick_typedef('*')(3, 4));
    printf("pointer typedef: %d\n", pick_ptr('+')(10, 32));

    // 돌려받은 포인터는 값이므로 변수에 담아 두었다가 나중에 불러도 된다.
    binop_ptr f = pick('*');
    printf("stored and called later: %d\n", f(6, 7));

    // (*f)(...) 와 f(...) 는 같은 뜻이다 --- 이름은 어차피 포인터로 무너진다.
    printf("both spellings agree: %d %d\n", (*f)(2, 3), f(2, 3));
    return 0;
}

실행 결과

raw declarator: 7
function typedef: 12
pointer typedef: 42
stored and called later: 42
both spellings agree: 6 6

가장 안쪽 것부터 읽는다. int (*pick(char op))(int, int) 에서 pick 은 char 하나를 받는 함수이고, 그 결과는 포인터이며, 그 포인터가 가리키는 것은 int 둘을 받아 int 를 주는 함수다.

적는 법모습언제
날것의 선언자int (*pick(char))(int, int);헤더 한 줄로 끝내야 할 때
함수 타입에 이름typedef int binop(int,int); → binop *pick(char);「함수 타입」을 여러 곳에서 말할 때
포인터 타입에 이름typedef int (*binop_ptr)(int,int); → binop_ptr pick(char);가장 흔한 절충

표 62.13 — 같은 것을 적는 세 가지

흔한 오해. 괄호는 장식이 아니다

int *f(int); 와 int (*f)(int); 는 전혀 다르다. 앞의 것은 int 포인터를 돌려주는 함수이고, 뒤의 것은 함수를 가리키는 포인터다. 괄호 한 쌍이 「무엇이 함수인가」를 바꾼다. 돌려주는 것이 함수 포인터일 때 두 모양이 한 선언에 겹쳐 나오므로 — int (*pick(char))(int,int) — 읽기 어려워지는 것이다. 65장에서 이 규칙을 끝까지 다룬다.

62.9 여기서 잘 나는 사고들#

이 장의 규칙이 어긋나는 자리를 한자리에 모은다. 대부분 컴파일러가 잡아 주지만, 잡아 주지 못하는 것이 몇 개 있고 그것들이 험하다.

사고증상누가 잡아 주는가고치는 법
한 호출 안에서 같은 값을 두 번 고침컴파일러마다 다른 답-Wall 이 대개 잡는다줄을 나눈다
평가 순서에 기댄 코드최적화나 컴파일러를 바꾸면 무너진다아무도순서가 중요하면 임시 변수로 못박는다
두 파일이 같은 함수를 다르게 선언조용히 엉뚱한 값 — 또는 우연히 정상아무도 — 헤더를 쓰면 컴파일러선언은 헤더 한 곳에만 두고 양쪽이 그것을 포함한다
타입이 다른 함수 포인터로 호출엉뚱한 레지스터를 읽는다-fsanitize=function (clang)포인터 타입을 맞춘다 — 캐스트로 덮지 않는다
지역 변수의 주소를 돌려줌돌아온 뒤 그 자리는 남의 것-Wreturn-local-addr호출한 쪽이 자리를 주거나 할당한다(45장)
값을 돌려주기로 해 놓고 안 돌려줌읽는 쪽이 쓰레기를 본다-Wreturn-type모든 경로에서 돌려준다
배열 매개변수에 sizeof늘 포인터 크기-Wsizeof-array-argument길이를 인자로 따로 받는다
... 자리에 크기가 어긋난 값읽는 쪽이 다른 것을 꺼낸다printf 계열만 -Wformat63장의 규칙을 지킨다

표 62.14 — 호출 언저리의 사고

셋째 줄이 이 표에서 가장 위험하다 — 아무도 잡아 주지 않는 자리이기 때문이다.

examples/ch62/mismatch/main.c

// 두 파일이 같은 함수를 서로 다르게 알고 있다.
//
// 경고: 이 프로그램은 미정의 동작이다. 컴파일러도 링커도 아무 말을 하지
// 않는다 --- 각자 자기 파일만 보기 때문이다. 아래 출력은 "옳은 결과"가
// 아니라 이 기계의 호출 규약에서 우연히 맞아떨어진 모습일 뿐이다.
#include <stdio.h>

// 정의는 int 둘인데 여기서는 long 둘이라고 선언했다.
void report(long id, long value);

int main(void)
{
    puts("calling a function this file has mis-declared:");
    report(1, 2);
    puts("it linked, it ran, and it even looks right --- that is the danger.");
    return 0;
}

실행 결과

calling a function this file has mis-declared:
  report() as defined here: int id=1, int value=2
it linked, it ran, and it even looks right --- that is the danger.

두 파일은 서로를 본 적이 없다. 컴파일러는 각자 자기 파일만 보고, 링커는 이름만 본다 — report 라는 이름이 있고, 누군가 그것을 부른다, 끝이다. 그래서 이 프로그램은 경고 하나 없이 빌드되고, 이 기계에서는 심지어 맞는 답을 찍는다. long 으로 보낸 값을 int 로 받았는데, 이 규약에서는 같은 레지스터의 아래쪽 절반을 읽는 것이라 우연히 들어맞은 것이다.

우연이 깨지는 자리도 같은 이유로 설명된다. 만약 정의가 void report(double) 이고 선언이 void report(long) 이었다면, 부르는 쪽은 값을 정수 레지스터에 싣고 불린 쪽은 실수 레지스터를 읽는다 — 아무도 쓴 적 없는 자리를. 값이 크게 틀리는 것이 아니라 아예 무관한 수가 나온다.

반례. 선언을 두 번 적기

/* a.c */                    /* b.c */
void report(long, long);     void report(int id, int value) { … }

선언이 두 곳에 있으면 언젠가 한쪽만 고쳐진다. 정석은 하나뿐이다 — 선언은 헤더 한 곳에 두고, 정의하는 파일도 그 헤더를 포함한다. 그러면 정의와 선언이 어긋나는 순간 컴파일러가 그 파일 안에서 잡는다. 56장에서 이 규율을 「한 번만 적는다」로 불렀다.

문. 함수 포인터를 다른 타입으로 캐스트해서 부르는 코드를 실제로 본 적이 있다. 콜백을 등록하는 자리에서 흔한데, 그것도 위험한가?

답. 위험하다. 함수 포인터끼리는 서로 변환할 수 있고 원래 타입으로 되돌리면 값이 온전하지만, 원래와 다른 타입으로 부르는 것은 미정의 동작이다. 이 기계에서는 대개 아무 일 없이 도는데 — 인자 하나짜리 함수를 인자 없는 타입으로 불러도 레지스터는 그대로이므로 — 그것이 함정이다. 규약이 다른 기계로 옮기거나, 제어 흐름 무결성 검사를 켜면 그제야 터진다. qsort 의 비교 함수를 int cmp(const int *, const int *) 로 적어 캐스트해 넘기는 관용구가 이 사고의 교과서적 예다. 정석은 const void * 로 받아 안에서 형을 되돌리는 것이다.

62.10 되짚기#

복습 정리

  • 선언은 약속, 정의는 물건, 호출식은 사건이다. 셋이 어긋나도 컴파일러가 못 잡는 자리가 있다 — 파일이 다를 때다.
  • 호출식은 연산자다. 함수 지시자와 인자 목록이 피연산자이고, 둘 사이의 순서도 정해져 있지 않다.
  • 인자는 원형을 만나 대입하듯 변환된다. 배열과 함수 이름은 그 자리에서 포인터로 무너진다.
  • 평가 순서가 갈리는 것은 미지정, 같은 값을 두 번 고치는 것은 미정의다. gcc 는 오른쪽부터, clang 은 왼쪽부터 — 그러나 그 요약에조차 기대면 안 된다.
  • 호출 한 번은 다섯 걸음이다: 인자 배치 → 돌아올 주소를 남기고 뛰기 → 프롤로그 → 본문 → 반환값과 복귀.
  • 규약은 레지스터 예산을 정한다. 예산을 넘으면 스택으로 가고, 그 경계는 프로그램 안에서도 주소 차이로 보인다.
  • 작은 기계일수록 규칙이 기묘하다 — 짝수 레지스터 정렬, 두 개의 실수 약속, Thumb 의 홀수 주소, 재귀가 안 되는 컴파일된 스택.
  • 구조체는 크기와 구성에 따라 레지스터에 포장되거나 기억으로 간다. 큰 반환값에는 숨은 인자가 붙는다.

이 장은 「원형이 있고, 개수와 타입이 맞는」 호출을 전제로 했다. 다음 장은 그 전제가 무너지는 자리다 — 받는 쪽이 인자의 개수도 타입도 모르는 함수, 가변 인자 함수다. 이 장에서 본 조율이 사라지면 무엇을 대신 약속해야 하는지가 그 장의 이야기다.