Fil-C의 단순화한 모델

5 hours ago 2
  • C/C++ 포인터에 AllocationRecord 메타데이터를 함께 붙여 추적하고, 역참조 시 메모리 경계 검사를 수행하는 구조
  • 포인터 대입, 산술, 함수 인자 전달, 반환, malloc·free 호출까지 원래 포인터 값과 대응 메타데이터를 함께 이동하거나 Fil-C 전용 호출로 변환하는 방식
  • 힙 메모리 안의 포인터 메타데이터는 invisible_bytes에 별도로 저장하며, 포인터 로드·스토어 시 값과 메타데이터를 함께 읽고 쓰고 정렬 검사도 추가 적용
  • filc_free는 visible_bytes와 invisible_bytes만 해제하고 AllocationRecord 자체는 유지하며, 이후 정리는 가비지 컬렉터가 맡고 주소 탈출 가능성이 있는 지역 변수는 힙 승격 처리
  • 스레드, 함수 포인터, 메모리·성능 최적화 같은 실제 구현 복잡성이 남아 있지만, 대규모 C/C++ 코드의 메모리 안전성 검증이나 pointer provenance의 구체적 시스템 예시로 활용 가능성 존재

단순화한 Fil-C 모델

  • Fil-C는 C/C++ 코드를 메모리 안전하게 다루기 위해 포인터와 함께 AllocationRecord* 메타데이터를 추적하는 구조 사용
    • 실제 구현은 LLVM IR 재작성 방식이지만, 단순 모델은 C/C++ 소스 코드를 자동 변환하는 형태
    • 각 함수의 포인터형 지역 변수마다 대응하는 AllocationRecord* 지역 변수 추가
    • 예시로 T1* p1에는 AllocationRecord* p1ar = NULL 추가 형태
  • 포인터 지역 변수에 대한 단순 대입과 계산은 원래 포인터 값과 함께 AllocationRecord*도 같이 이동하는 방식
    • p1 = p2는 p1 = p2, p1ar = p2ar로 변환
    • p1 = p2 + 10 역시 p1ar = p2ar 동반
    • 정수에서 포인터로의 캐스트는 메타데이터를 NULL로 설정
    • 포인터를 정수로 바꾸는 캐스트는 그대로 유지
  • 함수 인자 전달과 반환에서도 포인터와 함께 AllocationRecord*를 추가로 전달하며, 특정 표준 라이브러리 호출은 Fil-C 전용 함수로 치환
    • malloc과 free 호출은 각각 filc_malloc, filc_free 형태로 변환
    • 예시로 p1 = malloc(x); free(p1);는 {p1, p1ar} = filc_malloc(x); filc_free(p1, p1ar); 형태
  • filc_malloc은 요청한 메모리 하나만 할당하지 않고 세 개의 할당 수행
    • AllocationRecord 객체 할당
    • 실제 데이터용 visible_bytes 할당
    • 보이지 않는 메타데이터 저장용 invisible_bytes를 calloc으로 할당
    • AllocationRecord는 visible_bytes, invisible_bytes, length 필드 포함

역참조와 경계 검사

  • 포인터 역참조 시 동반된 AllocationRecord*를 사용해 경계 검사 수행
    • 포인터 메타데이터가 NULL이 아닌지 확인
    • 현재 포인터 위치와 visible_bytes 시작 주소의 차이를 계산
    • 오프셋이 전체 길이보다 작은지 확인
    • 남은 길이가 역참조 대상 크기보다 충분한지 확인
  • 읽기와 쓰기 모두 같은 검사 절차 적용
    • x = *p1 전에도 검사 수행
    • *p2 = x 전에도 동일한 형태의 검사 수행
  • 이 구조로 포인터가 가리키는 대상이 할당 범위를 벗어나는 접근 차단

힙 안의 포인터와 invisible_bytes

  • 힙 메모리에 저장된 포인터는 지역 변수처럼 컴파일러가 직접 별도 변수로 관리할 수 없기 때문에 invisible_bytes 사용
    • visible_bytes + i 위치에 포인터가 있다면 대응하는 AllocationRecord*는 invisible_bytes + i 위치에 저장
    • 즉 invisible_bytes는 요소 타입이 AllocationRecord*인 배열처럼 동작
  • 포인터 값을 메모리에서 읽거나 쓸 때는 일반 경계 검사 외에 정렬 검사 추가
    • 오프셋 i가 sizeof(AllocationRecord*)의 배수인지 확인
    • 이 조건이 맞아야 invisible_bytes를 AllocationRecord** 배열처럼 안전하게 접근 가능
  • 포인터 로드 시 데이터 포인터와 함께 메타데이터도 함께 로드
    • p2 = *p1은 p2 = *p1 뒤에 p2ar = *(AllocationRecord**)(p1ar->invisible_bytes + i) 추가
  • 포인터 스토어 시 포인터 값뿐 아니라 대응 메타데이터도 함께 저장
    • *p1 = p2는 실제 데이터 저장 후 *(AllocationRecord**)(p1ar->invisible_bytes + i) = p2ar 수행

filc_free와 가비지 컬렉터

  • filc_free는 포인터가 NULL이 아닐 때 AllocationRecord와의 일치성 검사 후 두 개의 메모리만 해제
    • par != NULL 확인
    • p == par->visible_bytes 확인
    • visible_bytes와 invisible_bytes 해제
    • 이후 visible_bytes, invisible_bytes를 NULL로, length를 0으로 변경
  • filc_malloc이 세 개를 할당하지만 filc_free는 AllocationRecord 객체 자체를 해제하지 않음
    • 이 차이는 가비지 컬렉터가 처리
  • 단순 모델에는 stop-the-world GC면 충분하며, 실제 Fil-C는 병렬 동시 점진 수집기 사용
    • GC는 AllocationRecord 객체를 따라가며 추적
    • 도달 불가능한 AllocationRecord를 해제 대상으로 처리
  • GC는 추가로 두 가지 작업 수행
    • 도달 불가능한 AllocationRecord를 해제할 때 filc_free 호출
    • length가 0인 AllocationRecord를 가리키는 모든 포인터를 길이 0의 단일 정규 AllocationRecord로 변경
  • 이 동작으로 free를 호출하지 않아도 메모리 누수로 이어지지 않음
    • GC가 자동 해제 수행
    • 다만 free 호출은 GC보다 더 이른 시점의 메모리 해제를 가능하게 함
  • free 이후 해당 AllocationRecord는 결국 도달 불가능 상태가 되어 나중에 정리 가능

지역 변수 주소 탈출과 힙 승격

  • GC가 존재하면 지역 변수의 주소를 안전하게 취급할 수 있는 범위 확대
    • 지역 변수의 주소가 취해졌고, 그 주소가 변수 수명 밖으로 탈출하지 않는다는 증명을 컴파일러가 하지 못하면 힙 할당으로 승격
  • 이런 지역 변수는 스택 대신 malloc을 통해 할당
    • 대응하는 free를 별도로 삽입할 필요 없음
    • GC가 수거 담당

Fil-C 버전 memmove

  • C 표준 라이브러리의 memmove는 임의 메모리를 다루기 때문에 컴파일러가 그 안의 포인터 존재 여부를 알 수 없는 문제 존재
  • 이를 위해 휴리스틱 적용
    • 임의 메모리 안의 포인터는 그 메모리 범위 안에 완전히 포함되어 있어야 함
    • 포인터는 올바르게 정렬되어 있어야 함
  • 이 규칙 때문에 같은 8바이트 이동이라도 동작 차이 발생
    • 정렬된 8바이트를 한 번에 memmove하면 대응 구간의 invisible_bytes도 함께 이동
    • 1바이트씩 8번 나눠서 memmove하면 invisible_bytes는 이동하지 않음

실제 구현에서 추가되는 복잡성

  • 스레드

    • 동시성은 GC 복잡도를 높이는 요소
    • filc_free는 즉시 메모리를 해제할 수 없음
      • 해제 중인 스레드와 다른 스레드의 동일 메모리 접근이 경쟁 상태일 수 있기 때문
    • 포인터에 대한 원자적 연산도 추가 처리가 필요
      • 기본 재작성은 포인터 로드/스토어를 두 번의 로드/스토어로 바꾸므로 원자성 파괴
  • 함수 포인터

    • AllocationRecord의 추가 메타데이터로 visible_bytes가 일반 데이터가 아니라 실행 코드 포인터인지 표시
    • 함수 포인터 p1을 통한 호출은 p1 == p1ar->visible_bytes 확인과 함께 p1ar가 함수 포인터를 나타내는지 검사
    • 함수 포인터에 대한 타입 혼동 공격 방지를 위해 호출 ABI에서도 타입 시그니처 검증 필요
    • 한 가지 방법은 모든 함수가 동일한 타입 시그니처를 갖게 만드는 방식
      • 모든 인자를 구조체에 담아 메모리로 전달하는 것처럼 처리
      • ABI 경계에서는 각 함수가 그 구조체에 대응하는 단일 AllocationRecord 하나만 받는 형태
  • 메모리 사용 최적화

    • filc_malloc이 invisible_bytes를 즉시 할당하지 않고 필요 시점에 지연 할당하는 방식 고려 가능
    • AllocationRecord와 visible_bytes를 하나의 할당으로 같이 배치하는 방식도 고려 가능
    • 기반 malloc이 각 할당 앞부분에 메타데이터를 붙인다면 그 메타데이터를 AllocationRecord에 넣는 방식도 고려 대상
  • 성능 최적화

    • Fil-C의 메모리 안전성은 성능 비용 수반
    • 잃어버린 성능을 일부 되찾기 위한 다양한 기법 적용 여지 존재

Fil-C 사용 시점

  • 대규모 C/C++ 코드가 동작은 하는 것처럼 보여도 메모리 안전성 검증이 없고, 메모리 안전성을 위해 GC 도입과 큰 성능 저하를 감수할 수 있는 경우 사용 가능
    • Java, Go, Rust로 재작성하기 전까지의 임시 조치 가능성 언급
  • ASan처럼 메모리 버그 탐지 목적으로도 Fil-C 실행 가능
    • C/C++ 코드를 Fil-C 아래에서 실행해 메모리 버그 확인 가능
  • 컴파일 타임 언어와 런타임 언어가 같고 컴파일 타임 안전성이 강한 언어에서는 안전한 컴파일 타임 평가 용도 가능
    • 예시로 Zig 언급
    • 런타임 평가는 안전하지 않더라도 컴파일 타임 평가는 Fil-C 구성을 사용할 수 있음
  • pointer provenance를 다루는 구체적 시스템 사례로도 의미 존재
    • p1과 p2 타입이 같을 때 if (p1 == p2) { f(p1); }를 if (p1 == p2) { f(p2); }로 바꾸는 최적화 가능성 질문 제시
    • Fil-C에서는 f에 전달되는 AllocationRecord*가 달라지므로 답은 명확히 아니라고 명시
    • 이 점에서 Fil-C는 pointer provenance를 갖는 구체적 시스템 예시 역할
Read Entire Article