컴파일러가 C를 타깃으로 코드를 생성할 때 도움이 되었던 실전 패턴 여섯 가지와, 그 접근의 한계에 대한 메모.
URL: https://wingolog.org/archives/2026/02/09/six-thoughts-on-generating-c
컴파일러 일을 한다는 건, 프로그램을 프로그램으로 번역하는 프로그램을 작성한다는 뜻이다. 때로는 어셈블리 같은 낮은 수준의 언어가 아니라 더 높은 수준의 언어를 타깃으로 삼고 싶을 때가 있고, 그 언어가 종종 C다. C를 “손으로 작성”하는 것보다 “생성”하는 게 덜 위험한데, 생성기는 수동으로 C를 쓸 때 주의해야 하는 정의되지 않은 동작(undefined behavior)의 함정을 종종 피할 수 있기 때문이다. 그래도 나는 좋은 결과를 얻는 데 도움이 되는 몇 가지 패턴을 발견했다.
오늘의 메모는 내가 유용하다고 느낀 것들을 빠르게 요약한 것이다. “모범 사례(best practices)”라고 부를 만큼 허세를 부리진 않겠지만, 이건 내 방식이고, 원한다면 당신도 가져다 쓸 수 있다.
내가 C를 배웠을 때는 GStreamer의 초창기였는데(아, 세상에 아직도 같은 웹 페이지를 쓰고 있다!), 전처리기 매크로를 정말 많이 썼다. 시간이 지나면서 우리는 많은 매크로 사용이 인라인 함수였어야 했다는 걸 대체로 깨달았다. 매크로는 토큰 붙이기(token-pasting)와 이름 생성에 쓰는 것이지, 데이터 접근이나 다른 구현을 위해 쓰는 게 아니다.
하지만 내가 훨씬 나중에야 제대로 이해한 점은, 항상 인라인(always-inline) 함수가 데이터 추상화에 따르는 성능 페널티의 가능성을 제거해 준다는 것이다. 예를 들어 Wastrel에서는 WebAssembly 메모리의 경계가 있는(bounded) 구간을 memory 구조체로, 그 메모리에 대한 접근을 다른 구조체로 표현할 수 있다:
cstruct memory { uintptr_t base; uint64_t size; }; struct access { uint32_t addr; uint32_t len; };
그리고 그 메모리에 대한 쓰기 가능한 포인터가 필요하면 이렇게 할 수 있다:
c#define static_inline \ static inline __attribute__((always_inline)) static_inline void* write_ptr(struct memory m, struct access a) { BOUNDS_CHECK(m, a); char *base = __builtin_assume_aligned((char *) m.base_addr, 4096); return (void *) (base + a.addr); }
(Wastrel은 보통 BOUNDS_CHECK를 위한 코드를 생략하고, 메모리가 적절한 크기의 PROT_NONE 영역에 매핑되어 있다는 사실에 의존한다. 여기서 매크로를 쓰는 이유는, 경계 검사에 실패해서 프로세스를 죽일 때 __FILE__과 __LINE__을 활용할 수 있으면 좋기 때문이다.)
명시적인 경계 검사가 켜져 있든 없든, static_inline 속성은 추상화 비용이 완전히 사라지도록 보장한다. 그리고 경계 검사가 생략되는 경우에는 memory의 size나 access의 len이 필요 없으니, 아예 할당되지도 않는다.
만약 write_ptr이 static_inline이 아니라면, 어디선가 이 구조체 값 중 하나가 메모리를 경유해 전달(pass through memory)될까봐 조금 걱정할 것이다. 이는 주로 구조체를 값으로 반환하는 함수에서 걱정되는 문제다. 예컨대 AArch64에서는 struct memory를 반환할 때가 void (*)(struct memory) 호출이 인자를 위해 사용하는 것과 같은 레지스터를 쓰지만, SYS-V x64 ABI는 반환값에 쓸 수 있는 범용 레지스터를 두 개만 할당한다. 나는 이런 종류의 병목에 대해 웬만하면 생각하고 싶지 않은데, static inline 함수가 그런 걱정을 덜어준다.
C는 기본 정수 변환 규칙이 묘하다. 예를 들어 uint8_t를 signed int로 승격(promote)시키기도 하고, signed 정수의 경계 조건도 이상한 면이 있다. C를 생성할 때는 이런 규칙을 정면으로 상대하기보다는, 명시적으로 처리하는 편이 좋다. 즉, static inline u8_to_u32, s16_to_s32 같은 변환 함수를 정의하고, -Wconversion을 켜자.
static inline 캐스트 함수들을 사용하면, 생성된 코드가 피연산자가 특정 타입임을 단언(assert)하도록 만들 수도 있다. 이상적으로는 모든 캐스트가 헬퍼 함수 안에만 존재하고, 생성된 코드에는 캐스트가 하나도 없게 된다.
Whippet은 C로 작성된 가비지 컬렉터다. 가비지 컬렉터는 모든 데이터 추상을 가로지른다: 객체는 때로는 절대 주소로, 혹은 페이징된 공간에서의 범위로, 혹은 정렬(aligned)된 영역의 시작으로부터의 오프셋으로, 등등으로 보게 된다. 이런 개념들을 전부 size_t나 uintptr_t 같은 타입으로 표현하면 곤란해진다. 그래서 Whippet에는 struct gc_ref, struct gc_edge 같은 것들이 있다. 단일 멤버를 가진 구조체들로, 가능한 연산들의 집합을 분리해서 혼동을 막는 것이 목적이다. gc_edge_address 호출은 절대로 struct gc_ref에 적용되지 않으며, 다른 타입과 연산들도 마찬가지다.
이 패턴은 수작업 코드에도 좋지만, 컴파일러에서는 특히 강력하다. 종종 “타입이나 종류(kind)가 이미 알려진 항(term)”을 컴파일하게 되는데, 그럴 때 잔여화(residualized)된 C 코드에서 실수를 피하고 싶기 때문이다.
예를 들어 WebAssembly를 컴파일할 때 struct.set의 연산 의미론을 보자. 텍스트 렌더링에는 “Assert: Due to validation, val is some ref.struct structaddr.”라고 적혀 있다. 이 단언이 C로도 옮겨질 수 있다면 좋지 않을까? 이 경우에는 가능하다: (WebAssembly가 그렇듯) 단일 상속(single-inheritance) 기반의 서브타이핑이 있다면 포인터 서브타입들의 숲(forest)을 만들 수 있다.
ctypedef struct anyref { uintptr_t value; } anyref; typedef struct eqref { anyref p; } eqref; typedef struct i31ref { eqref p; } i31ref; typedef struct arrayref { eqref p; } arrayref; typedef struct structref { eqref p; } structref;
그래서 (type $type_0 (struct (mut f64)))에 대해 나는 이렇게 생성할 수 있다:
ctypedef struct type_0ref { structref p; } type_0ref;
그 다음 $type_0에 대한 필드 setter를 생성한다면, 그것이 type_0ref를 인자로 받게 만든다:
cstatic inline void type_0_set_field_0(type_0ref obj, double val) { ... }
이렇게 하면 타입 정보가 소스 언어에서 타깃 언어로 이어진다. 실제 객체 표현(representation) 쪽에도 유사한 타입 숲이 있다:
ctypedef struct wasm_any { uintptr_t type_tag; } wasm_any; typedef struct wasm_struct { wasm_any p; } wasm_struct; typedef struct type_0 { wasm_struct p; double field_0; } type_0; ...
그리고 필요할 때 type_0ref와 type_0* 사이를 오가는 작은 캐스트 루틴을 생성한다. 모든 루틴이 static inline이므로 오버헤드는 없다. 게다가 포인터 서브타이핑을 공짜로 얻는다: struct.set $type_0 0 명령이 $type_0의 서브타입을 전달받는다면, 컴파일러는 타입 검사를 통과하는 업캐스트(upcast)를 생성할 수 있다.
WebAssembly에서 선형 메모리(linear memory)에 대한 접근은 반드시 정렬되어 있지 않으므로, 주소를 (예: int32_t*)로 캐스트해서 역참조할 수 없다. 대신 memcpy(&i32, addr, sizeof(int32_t))를 사용하고, 컴파일러가 가능하면(그리고 실제로 가능하다) 정렬되지 않은 로드(unaligned load) 하나로 내보내 줄 거라고 믿으면 된다. 더 말할 것도 없다!
마침내 GCC에 attribute((musttail))이 생겼다: 찬양하라. 하지만 WebAssembly를 컴파일할 때, 인자 30개짜리 함수나 반환값 30개짜리 함수를 컴파일하게 될 수도 있다. 나는 C 컴파일러가 이런 함수들로의/로부터의 꼬리 호출(tail call)에서 서로 다른 스택 인자 요구사항을 신뢰성 있게 섞어 맞춰(shuffle) 줄 거라고 믿지 않는다. musttail 의무를 충족할 수 없다고 파일 컴파일 자체를 거부할 수도 있는데, 타깃 언어로서는 좋은 특성이 아니다.
진짜로는 모든 함수 매개변수가 레지스터에 배치되길 원한다. 이를 보장하는 한 가지 방법은, 예를 들어 처음 _n_개 값만 레지스터로 전달하고, 나머지는 전역 변수로 전달하는 것이다. 스택으로 전달할 필요가 없다. 피호출자(callee)가 프롤로그에서 전역 변수에서 다시 로컬로 로드하게 만들면 된다.
재미있는 점은, 이 방식이 C로 컴파일할 때 다중 반환값(multiple return values)도 깔끔하게 가능하게 한다는 것이다. 프로그램에서 사용되는 함수 타입들의 집합을 훑어서, 모든 반환값을 저장할 수 있을 만큼의 적절한 타입 전역 변수를 할당하고, 함수 에필로그에서 “초과” 반환값—첫 번째 반환값(있다면)을 넘어서는 값들—을 전역 변수에 저장하게 한다. 호출자는 호출 직후에 그 값들을 다시 로드하면 된다.
C 생성은 로컬 최적점(local optimum)이다. GCC나 Clang의 산업급(industrial-strength) 명령 선택(instruction selection)과 레지스터 할당(register allocation)을 얻고, 수많은 피프홀(peephole) 스타일 최적화를 직접 구현할 필요도 없고, 인라인될 수도 있는 C 런타임 루틴에 링크할 수도 있다. 이 설계 지점을 약간만 개선해서 더 나은 무언가를 만들기는 어렵다.
물론 단점도 있다. Schemer로서 내게 가장 큰 짜증거리는 스택을 제어할 수 없다는 점이다. 어떤 함수가 얼마나 많은 스택을 필요로 하는지 알 수 없고, 프로그램 스택을 합리적인 방식으로 확장할 수도 없다. 스택을 반복(iterate)해서 박혀 있는 포인터들을 정밀하게 열거할 수도 없다(하지만 아마 괜찮을지도). 구분된 연속(delimited continuation)을 캡처하기 위해 스택을 슬라이스하는 건 더더욱 불가능하다.
또 다른 큰 짜증거리는 사이드 테이블(side table)이다. 이른바 제로-코스트 예외(zero-cost exceptions)를 구현하고 싶을 수 있는데, 컴파일러와 툴체인의 지원 없이는 불가능하다.
마지막으로, 소스 수준 디버깅이 까다롭다. 잔여화한 코드에 대응하는 DWARF 정보를 끼워 넣고 싶은데, C를 생성하는 방식으로는 어떻게 해야 할지 모르겠다.
(왜 Rust는 아니냐고 묻는가? 물론 그렇게 묻겠지. 내 경험으로는 라이프타임은 프런트엔드 이슈다. 소스 언어에 명시적 라이프타임이 있다면, 출력이 입력과 같은 보장을 갖는지 기계적으로 검사할 수 있으니 Rust 출력을 고려해 볼 것이다. 또는 Rust 표준 라이브러리를 쓰는 상황이라면 더더욱. 하지만 화려한 라이프타임이 없는 언어로부터 컴파일한다면 Rust에서 무엇을 얻을 수 있을까? 암묵적 변환이 줄어드는 건 맞지만, 꼬리 호출 지원은 덜 성숙하고, 컴파일 시간은 더 길고… 결국 비슷비슷하다고 생각한다.)
뭐, 완벽한 건 없고, 언제나 눈을 크게 뜨고 들어가는 게 최선이다. 여기까지 읽었다면, 이 메모들이 당신의 생성 작업에 도움이 되길 바란다. 내 경우에는 생성된 C가 타입 체크만 통과하면 동작했다. 디버깅이 거의 필요 없었다. 해킹이 항상 이렇진 않지만, 이런 순간이 오면 기꺼이 받아들이겠다. 다음에 또 보자. 즐거운 해킹을!