Fil-C 간소화 모델: 메모리 안전한 C/C++ 구현 원리
최근 화제가 된 C/C++의 메모리 안전 구현체인 Fil-C의 작동 방식을 이해하기 쉽게 소개한 글입니다. 포인터 변수마다 메모리 할당 기록(AllocationRecord)을 추적하여, 컴파일러가 자동으로 바운드 검사(bounds check) 코드를 삽입하는 원리를 코드 변환 예시로 설명합니다. 이를 통해 개발자는 기존 C/C++ 코드를 크게 수정하지 않고도 메모리 안전성을 확보할 수 있습니다.
최근 메모리 안전한 C/C++ 구현체를 표방하는 Fil-C에 대한 이야기를 많이 접했습니다. 어떻게 이를 달성했는지에 대한 복잡한 세부 사항을 읽어볼 수도 있지만, 처음 접하는 사람들에게는 단순화된 버전을 보여주는 것이 가치 있다고 생각합니다. 단순화된 버전을 이해하고 나면, 실제 프로덕션 수준의 버전을 이해하는 것도 정신적으로 훨씬 수월해지기 때문입니다.
실제 Fil-C는 LLVM IR을 재작성하는 컴파일러 패스(pass)를 사용하지만, 이 간소화된 모델은 C/C++ 소스 코드의 자동 재작성 방식입니다. 즉, 안전하지 않은 코드(unsafe code)가 안전한 코드(safe code)로 변환됩니다.
첫 번째 변환은 모든 함수 내에서 포인터 타입의 모든 지역 변수에 AllocationRecord* 타입의 동반 지역 변수가 추가되는 것입니다. 예를 들어 다음과 같습니다.
[원본 소스코드] void f() { T1* p1; T2* p2; uint64_t x; ...
[Fil-C 변환 후] void f() { T1* p1; AllocationRecord* p1ar = NULL; T2* p2; AllocationRecord* p2ar = NULL; uint64_t x; ...
여기서 AllocationRecord는 다음과 같은 형태입니다:
struct AllocationRecord {
char * visible_bytes;
char * invisible_bytes;
size_t length;
};
포인터 타입 지역 변수에 대한 사소한 연산들은 AllocationRecord*도 함께 이동하도록 재작성됩니다.
[원본 소스코드] p1 = p2; p1 = p2 + 10; p1 = (T1*)x; x = (uintptr_t)p1;
[Fil-C 변환 후] p1 = p2, p1ar = p2ar; p1 = p2 + 10, p1ar = p2ar; p1 = (T1*)x, p1ar = NULL; x = (uintptr_t)p1;
포인터가 함수로 전달되거나 함수에서 반환될 때, 코드는 원래 포인터와 함께 AllocationRecord*를 포함하도록 재작성됩니다. 특정 표준 라이브러리 함수 호출은 추가로 해당 함수의 Fil-C 버전을 호출하도록 재작성됩니다.
이를 종합하면 다음과 같습니다.
[원본 소스코드] p1 = malloc(x); ... free(p1);
[Fil-C 변환 후] {p1, p1ar} = filc_malloc(x); ... filc_free(p1, p1ar);
filc_malloc의 (간소화된) 구현은 실제로 요청된 할당 하나 대신 세 가지 개별 할당을 수행합니다:
void * filc_malloc(size_t length) {
AllocationRecord* ar = malloc(sizeof(AllocationRecord));
ar->visible_bytes = malloc(length);
ar->invisible_bytes = calloc(length, 1);
ar->length = length;
return {ar->visible_bytes, ar};
}
포인터 변수가 역참조(dereference)될 때, 동반되는 AllocationRecord*가 바운드 검사(bounds check)를 수행하는 데 사용됩니다:
[원본 소스코드] x = *p1; ... *p2 = x;
[Fil-C 변환 후] assert(p1ar != NULL); uint64_t i = (char*)p1 - p1ar->visible_bytes; assert(i < p1ar->length); assert((p1ar->length - i) >= sizeof(*p1)); x = p1; ... assert(p2ar != NULL); uint64_t i = (char)p2 - p2ar->visible_bytes; assert(i < p2ar->length); assert((p2ar->length - i) >= sizeof(*p2)); *p2 = x;
저장되거나 로드되는 값 자체가 포인터인 경우 상황은 더 흥미로워집니다. 이미 본 것처럼, 포인터 타입의 지역 변수에는 컴파일러에 의해 동반되는 AllocationRecord* 변수가 삽입됩니다. 컴파일러는 모든 지역 변수에 대한 완전한 제어权和 가시성을 가지고 있기 때문에 이 작업을 수행할 수 있습니다.
포인터가 지역 변수가 아닌 힙(heap)에 존재하게 되면 상황은 더 어려워지지만, 이때 invisible_bytes가 사용됩니다. visible_bytes + i에 포인터가 있다면, 그에 동반되는 AllocationRecord*는 invisible_bytes + i에 위치하게 됩니다. 다시 말해, invisible_bytes는 요소 타입이 AllocationRecord*인 배열입니다. 이 배열에 대한 정상적인 접근을 보장하기 위해 i는 sizeof(AllocationRecord*)의 배수여야 합니다. 이를 위한 추가 로직은 다음과 같습니다.
[원본 소스코드] p2 = *p1; ... *p1 = p2;
[Fil-C 변환 후] assert(p1ar != NULL); uint64_t i = (char*)p1 - p1ar->visible_bytes; assert(i < p1ar->length); assert((p1ar->length - i) >= sizeof(p1)); assert((i % sizeof(AllocationRecord)) == 0); p2 = p1; p2ar = (AllocationRecord)(p1ar->invisible_bytes + i); ... assert(p1ar != NULL); uint64_t i = (char*)p1 - p1ar->visible_bytes; assert(i < p1ar->length); assert((p1ar->length - i) >= sizeof(p1)); assert((i % sizeof(AllocationRecord)) == 0); p1 = p2; (AllocationRecord)(p1ar->invisible_bytes + i) = p2ar;
아직 살펴보지 않은 한 가지는 fi...(원문 일부 누락)