C 코드베이스가 비표준 확장과 컴파일러별 동작에 의존하는 방식, 그리고 대안 C 컴파일러가 마주하는 현실적인 이식성 문제를 살펴봅니다.
C를 작성해 본 사람이라면 누구나 완전한 ISO C 표준 준수 코드가 비현실적으로 드문 존재라는 것을 안다. 실제 세계의 대부분의 C 코드는 정도의 차이는 있어도 비표준 동작과 언어 확장에 의존하며, 그 중 많은 부분은 추가 기능을 위해서가 아니라 서로 다른 컴파일러와 라이브러리의 버그와 공백을 우회하기 위해서다. 많은 코드베이스가 주로 전처리기 검사와 가드를 통해 다양한 환경을 어느 정도 지원하려고 시도하지만, 이런 시도는 잘해 봐야 까다롭고 최악의 경우 아예 망가져 있다.
나는 내 C 컴파일러를 작업하면서 이런 상황을 많이 겪었고, 그래서 그중 일부를 짧게 정리해 보려 한다.
유용한 C 컴파일러를 목표로 할 때 시스템의 C 라이브러리 헤더는 첫 번째 "장애물"이다. <stdio.h>를 전처리하고 파싱할 수 없다면 hello world도 넘기지 못한다. 내가 GNU/Linux를 사용하므로, 이는 곧 glibc를 뜻한다. 공정하게 말하자면 glibc는 비GCC 컴파일러에서도 헤더의 호환성을 유지하려고 시도는 한다. 모든 libc 헤더에 간접적으로 포함되는 괴물 같은 sys/cdefs.h에서, 컴파일러가 미리 정의하는 매크로를 검사하는 온갖 전처리기 조건을 사용해 어떤 종류의 컴파일러 확장이 지원되는지 판별하고, 지원되지 않으면 #define으로 없애 버린다.
안타깝게도 이건 가끔 그냥 망가져 있다. 예를 들어 Linux에서 sys/epoll.h의 struct epoll_event는 packed struct인데, 여기에는 GNU의 __attribute__((packed))가 사용된다. 이것은 구조체 레이아웃을 바꾸기 때문에(64비트에서), ABI를 깨뜨리지 않고는 무시할 수 없다. 좋다, 그렇다면 컴파일러에 __attribute__((packed)) 지원을 구현한다고 하자. 하지만 이것만으로는 충분하지 않은데, 앞서 언급한 sys/cdefs.h에는 다음 코드가 들어 있다:
/* GCC, clang, and compatible compilers have various useful declarations
that can be made with the '__attribute__' syntax. All of the ways we use
this do fine if they are omitted for compilers that don't understand it. */
#if !(defined __GNUC__ || defined __clang__ || defined __TINYC__)
# define __attribute__(xyz) /* Ignore */
#endif
당신이 gcc, clang, 또는 tcc가 아니라면, 운이 없을 뿐이다.
물론 epoll 헤더는 Linux 전용이므로, 여기에 C 표준의 이식성 기준을 적용하는 것은 공정하지 않다고 주장할 수도 있다.
일부 C 헤더는 프리스탠딩 구현에서도 존재해야 하므로 컴파일러가 제공해야 하고, 컴파일러 내부 정의에 의존한다. 예를 들어 내 컴퓨터에서는 GCC의 경우 /usr/lib/gcc/x86_64-pc-linux-gnu/16.1.1/include/, clang의 경우 /usr/lib/clang/22/include/에 있다. 이런 내장 헤더에는 stddef.h, stdint.h, limits.h, float.h 등이 포함된다. 하지만 POSIX는 limits.h가 표준 C 상수 외에도 몇몇 POSIX 전용 상수를 정의하도록 요구한다. 그래서 여전히 컴파일러의 헤더 위에 플랫폼 전용 limits.h가 필요하다.
glibc의 <limits.h>는 대략 이렇게 생겼다:
...
/* If we are not using GNU CC we have to define all the symbols ourself.
Otherwise use gcc's definitions (see below). */
#if !defined __GNUC__ || __GNUC__ < 2
/* We only protect from multiple inclusion here, because all the other
#include's protect themselves, and in GCC 2 we may #include_next through
multiple copies of this file before we get to GCC's. */
# ifndef _LIMITS_H
# define _LIMITS_H 1
/* We don't have #include_next. Define ANSI <limits.h> for standard 32-bit words. */
/* These assume 8-bit `char's, 16-bit `short int's, and 32-bit `int's and `long int's. */
# define CHAR_BIT 8
...
# endif /* limits.h */
#endif /* GCC 2. */
#endif /* !_LIBC_LIMITS_H_ */
/* Get the compiler's limits.h, which defines almost all the ISO constants.
We put this #include_next outside the double inclusion check because
it should be possible to include this file more than once and still get
the definitions from gcc's header. */
#if defined __GNUC__ && !defined _GCC_LIMITS_H_
/* `_GCC_LIMITS_H_' is what GCC's file defines. */
# include_next <limits.h>
#endif
/* The <limits.h> files in some gcc versions don't define LLONG_MIN, LLONG_MAX,
and ULLONG_MAX. */
#if defined __USE_ISOC99 && defined __GNUC__
# ifndef LLONG_MIN
# define LLONG_MIN (-LLONG_MAX-1)
# endif
...
#endif
#ifdef __USE_POSIX
/* POSIX adds things to <limits.h>. */
# include <bits/posix1_lim.h>
#endif
...
이것은 gcc 전용 내장 limits.h가 일부 매크로를 정의해 주는 데 의존하여 제대로 동작하며, 거기에 #include_next 확장까지 사용한다. 심지어 clang조차도 이 황당함을 우회해야 한다.
SDL_endian.h에는 바이트 스와핑 함수를 위한 우스꽝스러운 기능 감지 코드가 있다. 목적은 가능하면 컴파일러 내장 함수나 인라인 어셈블리를 사용하고, 정말 마지막 수단으로만 이식 가능한 일반 비트 연산 구현으로 폴백하는 것이다. 그런데 이를 구현하는 논리는 다음과 같다:
__has_builtin(__builtin_bswapX) 이면 → 내장 함수 사용#pragma 사용__x86_64__ 같은 ISA별 매크로 정의됨) 이면 -> 인라인 어셈블리 사용이 말은 곧, GCC나 clang은 아니지만 ISA별 사전정의 매크로를 정의하는 경우(그럴 만한 충분한 이유가 있다), __has_builtin 특수 연산자를 제공하고 bswap 내장 함수도 갖추고 있더라도 (확장) 인라인 어셈블리를 사용하려 든다는 뜻이다. 정체불명의 컴파일러가 GCC 스타일 확장 인라인 어셈블리를 지원하리라고 기대하는 건 조금 이상해 보인다.
일부 OpenBSD 헤더에는 최적화 시 컴파일러가 선택적으로 사용하도록 의도된 인라인 함수 정의가 들어 있다. 이것들은 __only_inline 매크로로 정의되며, 예를 들면 다음과 같다:
__only_inline int sigemptyset(sigset_t *__set)
{
*__set = 0;
return (0);
}
그리고 컴파일러가 실제로 이것을 인라인하지 않을 경우에는 '진짜' 외부 심볼로 폴백하도록 되어 있다. 즉, 외부 링키지를 가진 인라인 함수다. 이런 것들은 대체로 엉망인데, C99에 명시되어 있기는 하지만 그 표준 동작이 C99 이전의 비표준 GCC 동작(4.2 이전 기본값)과 충돌하기 때문이다. 짧게 말하면, 헤더의 인라인 정의는 함수 본문과 함께 extern inline을 사용해야 하며, 이것은 실제로 내보내는 함수를 생성하지 않는다. 그리고 트랜슬레이션 유닛에서는 그 함수를 정의를 내보내기 위해 단지 inline으로 선언해야 한다. 혼란을 더하자면 inline의 의미는 C++와 C에서 서로 다르다. 자세한 내용은 Youtao Guo의 좋은 글을 참고하라.
그래서 OpenBSD는 GCC의 인라인 의미론에 의존하며, GCC 버전 차이를 덮기 위해 sys/cdefs.h의 __only_inline 매크로에서 최신 GCC 버전일 경우 오래된 gnu89 인라인 의미론을 명시하기 위해 __attribute__를 명시적으로 사용한다. 하지만 비GNU 컴파일러에서는 이것이 static 링키지로 정의되며, 그러면 상충하는 링키지를 가진 함수를 선언/정의하게 되어 망가진다.
다행히 그들은 _ANSI_LIBRARY라는 매크로를 존중하며, 이것이 정의되면 signal.h 같은 표준 헤더에서 이 망가진 __only_inline 정의의 사용을 완전히 생략한다. 그래서 "최적화된 버전"은 얻지 못하지만(사실 큰 차이도 없을 것이다), 적어도 동작은 한다.
나는 또한 Guile과 nano를 빌드할 때 Gnulib의 extern inline 호환성 코드를 보게 되었는데, 이것은 C의 이 구석진 경우에 대해 얼마나 많은 망가진 구현과 이상한 구현이 존재하는지를 잘 보여 준다. 설명 주석은 extern-inline.m4을 보면 되지만, 여기 일부를 인용하자면:
#if (((defined __APPLE__ && defined __MACH__) \
|| defined __DragonFly__ || defined __FreeBSD__) \
&& (defined HAVE___HEADER_INLINE \
? (defined __cplusplus && defined __GNUC_STDC_INLINE__ \
&& ! defined __clang__) \
: ((! defined _DONT_USE_CTYPE_INLINE_ \
&& (defined __GNUC__ || defined __cplusplus)) \
|| (defined _FORTIFY_SOURCE && 0 < _FORTIFY_SOURCE \
&& defined __GNUC__ && ! defined __cplusplus))))
# define _GL_EXTERN_INLINE_STDHEADER_BUG
#endif
#if ((__GNUC__ \
? (defined __GNUC_STDC_INLINE__ && __GNUC_STDC_INLINE__ \
&& !defined __PCC__) \
: (199901L <= __STDC_VERSION__ \
&& !defined __HP_cc \
&& !defined __PGI \
&& !(defined __SUNPRO_C && __STDC__))) \
&& !defined _GL_EXTERN_INLINE_STDHEADER_BUG)
# define _GL_INLINE inline
# define _GL_EXTERN_INLINE extern inline
# define _GL_EXTERN_INLINE_IN_USE
#elif (2 < __GNUC__ + (7 <= __GNUC_MINOR__) && !defined __STRICT_ANSI__ \
&& !defined __PCC__ \
&& !defined _GL_EXTERN_INLINE_STDHEADER_BUG)
# if defined __GNUC_GNU_INLINE__ && __GNUC_GNU_INLINE__
/* __gnu_inline__ suppresses a GCC 4.2 diagnostic. */
# define _GL_INLINE extern inline __attribute__ ((__gnu_inline__))
# else
# define _GL_INLINE extern inline
# endif
# define _GL_EXTERN_INLINE extern
# define _GL_EXTERN_INLINE_IN_USE
#else
# define _GL_INLINE _GL_UNUSED static
# define _GL_EXTERN_INLINE _GL_UNUSED static
#endif
...좋다. 참 아름답다.
bionic은 Android의 libc다. 독창적이게도, 이 헤더들은 gcc 대신 clang을 강하게 가정한다. 널 가능성 검사를 위한 _Nonnull, _Null_unspecified1 같은 clang 전용 확장과 다른 것들로 가득하다. 다행히 이것들은 명령줄 플래그로 #define해서 없애 버리기 어렵지 않다.
그리고 내가 이것을 겪은 유일한 이유는 Termux가 있는 내 Android 폰을 네이티브 aarch64 개발 환경으로 사용하고 있었기 때문이다 (ㅋㅋ). 거기에는 bionic 헤더가 있다.
결국 나는 주로 libc 헤더에 대해 쓰게 되었지만, 사실 예시는 끝도 없이 더 들 수 있다. 다만 곧 시험이 있고, 나는 이미 충분히 미루고 있었다.
많은 오픈소스 프로젝트가 본질적이지 않은 것들을 위해 컴파일러별 비표준 확장과 동작에 의존하는 방식은 다루기 매우 짜증나지만, 그렇다고 모든 개발자에게 생소하거나 작은 컴파일러까지 포함해 여러 컴파일러로 자기 C 코드를 테스트하라고 요구하는 것도 공정하지 않다. C 이식성은 원래도 충분히 어렵다. 컴파일러를 작성하는 사람의 관점에서 가능한 해결책은 다음과 같다:
이런 비호환성을 업스트림에서 패치하려고 시도한다.
개발자들이 당신의 컴파일러를 위한 전용 #ifdef 검사와 기본 테스트를 추가할 만큼 충분한 인기를 확보한다.
(어떤 버전의) GCC인 척하고 그 확장들을 구현한다.
(1)은 가망 없는 싸움처럼 보이고, (3)은 가장 쉽고, (4)는 사용자의 컴파일러 사용 경험과 해당 코드베이스 개발자에게 주는 혼란을 최소화하면서 많은 코드베이스를 지원하는 현실적인 방법이다(물론 고되지만). 예를 들어 clang은 GCC 4.2.1과의 호환성을 주장하기 위해 __GNUC__=4(그리고 __GNUC_MINOR__=2, __GNUC_PATCHLEVEL__=1)를 정의한다. 비록 이 시점의 clang은 (2)에 해당하지만, 예를 들어 clang으로 Linux 커널을 컴파일할 수 있게 만드는 데에도 양쪽 프로젝트의 패치가 필요할 정도로 상당한 노력이 들어갔다.
물론 (4)의 문제는 #ifdef __GNUC__를 검사한 뒤, 버전 검사는 하지 않은 채 그 매크로가 정의되어 있다는 이유만으로 온갖 더 새로운 GCC 확장을 마음껏 사용하는 코드베이스도 많다는 점이다. 그래서 결국 따라잡기 게임을 하게 되는데, 이것이 clang이 4.2.1보다 새로운 GNU 확장을 지원함에도 __GNUC__ 매크로 값을 올리지 않는 이유 중 하나다(이 토론 참고).
이상적으로는 __has_builtin, __has_feature, __has_attribute, 심지어 __STDC_NO_VLA__ 같은 표준 매크로까지도 컴파일러별 가드나 버전 검사 대신 더 널리 사용되어야 한다.
현재로서는 GCC/clang 준복점 체제가 *NIX 세계의 현상 유지다. 좋든 싫든 말이다. 더 작고 독립적인 C 컴파일러를 만드는 개발자들에게 경의를: tcc, cproc, scc, vbcc, nwcc, kefir, 그 외 더 많은 것들.
편집: lobsters에서의 논의와, slimcc의 개발자가 든 더 많은 사례를 보라.
편집 II: autotools에 대해 말하는 것을 잊었다. 복잡성의 거미줄이라는 비판을 많이 받긴 하지만, 기능 감지를 위해 "컴파일러에 이것저것 던져 보고 컴파일되는지 보는" 접근은 부정할 수 없다고 생각한다. 아마 정확히 그것을 위해 설계된 것일 테다. 안타깝게도 공개 헤더 문제에는 도움이 되지 못한다.