C에 초능력을 부여하기: 사용자 정의 헤더 파일(safe_c.h)
(hwisnu.bearblog.dev)- safe_c.h는 C 언어에 C++과 Rust의 안전성과 편의 기능을 추가하는 600줄짜리 사용자 정의 헤더 파일로, 메모리 누수 없는 스레드 안전 grep(cgrep) 구현에 사용됨
- RAII, 스마트 포인터, 자동 정리(cleanup) 속성을 통해 수동 free() 호출 없이 자원 관리 자동화
- 벡터, 뷰, Result 타입, 계약 매크로 등으로 버퍼 오버플로, 오류 처리, 전제 조건 검증을 안전하게 수행
- 뮤텍스 자동 해제, 스레드 스폰 매크로, 분기 예측 최적화 등으로 동시성과 성능을 유지하면서 안전성 확보
- 결과적으로 동일한 성능(-O2 수준)으로 누수·세그폴트 없는 C 코드 작성 가능성을 입증
safe_c.h 개요
- safe_c.h는 C++과 Rust의 기능을 C 코드에 이식하는 헤더 파일
- C23의
[[cleanup]]속성을 지원하지 않는 컴파일러(GCC 11, Clang 18 등)에서도 동일한 RAII(자동 정리) 동작 제공 -
CLEANUP(func)매크로로 함수 종료 시 자원 자동 해제 -
LIKELY()와UNLIKELY()매크로로 핫패스 분기 예측 최적화
- C23의
메모리 관리: UniquePtr과 SharedPtr
-
UniquePtr은 단일 소유 스마트 포인터로, 스코프 종료 시 자동으로
free()호출-
AUTO_UNIQUE_PTR()매크로로 선언 시, 오류 발생이나 조기 반환에도 메모리 자동 해제
-
-
SharedPtr은 자동 참조 카운팅 구조로, 마지막 참조가 해제될 때 자원 자동 파괴
-
shared_ptr_init()과shared_ptr_copy()로 참조 증가·감소 자동 처리 - 스레드 간 안전한 공유 구조체 관리에 사용
-
버퍼 오버플로 방지: Vector와 View
-
DEFINE_VECTOR_TYPE() 매크로로 타입 안전한 자동 확장 벡터 생성
- 재할당, 용량 관리, 정리(cleanup)를 자동 처리
-
AUTO_TYPED_VECTOR()로 선언 시 스코프 종료 시 자동 해제
-
StringView와 Span은 비소유 참조 구조체로, 별도 malloc 없이 문자열·배열 슬라이스 처리
-
DEFINE_SPAN_TYPE()으로 타입별 Span 정의 - 경계 검사 포함으로 안전한 배열 접근 보장
-
오류 처리: Result 타입과 RAII
-
Result 구조체는 Rust의
Result<T, E>와 유사한 성공/실패 구분형 반환 타입-
DEFINE_RESULT_TYPE()으로 타입별 결과 구조 생성 -
RESULT_IS_OK()와RESULT_UNWRAP_ERROR()로 명확한 오류 처리
-
-
CLEANUP속성과 결합해 함수 종료 시 자원 자동 해제-
AUTO_MEMORY()매크로로 malloc된 메모리 자동 정리
-
계약과 안전 문자열
-
requires() / ensures() 매크로로 함수의 전·후 조건 명시
- 실패 시 명확한 오류 메시지 출력
-
safe_strcpy() 는 버퍼 크기 검사 포함 복사 함수로, 오버플로 방지
- 실패 시 false 반환으로 안전한 오류 처리
동시성: 자동 잠금 해제와 스레드 매크로
-
CLEANUP기반 mutex 자동 해제 함수로 데드락 방지- 스코프 종료 시
pthread_mutex_unlock()자동 호출
- 스코프 종료 시
-
SPAWN_THREAD()와JOIN_THREAD()매크로로 스레드 생성·조인 단순화- cgrep의 파일 처리 스레드 풀 구현에 사용
성능 최적화
-
LIKELY()/UNLIKELY()매크로로 핫패스 분기 예측 제공- PGO 수준의 최적화 효과를 -O2 빌드에서도 확보
- 안전 기능이 추가되어도 성능 저하 없음
결론
- safe_c.h를 사용한 cgrep은 2,300줄의 C 코드로, 50회 이상의 수동 free() 호출을 제거
- 동일한 어셈블리와 실행 속도를 유지하면서 메모리 누수와 세그폴트 없는 안전한 C 코드 구현
- C의 단순함과 자유로움을 유지하면서 현대적 안전성을 결합한 사례
- 작성자는 이후 글에서 cgrep이 ripgrep보다 2배 이상 빠르고 메모리 사용량은 20배 적은 이유를 다룰 예정
- safe_c.h는 새 프로젝트에 적합, 매크로 기반이라 디버깅 난이도 상승 가능성 언급
- 다양한 정적 분석기(GCC analyzer, ASAN, UBSAN, Clang-tidy 등)로 정확성과 안전성 검증 수행
Hacker News 의견
-
이 글은 C에서 안전한 추상화(safe abstraction) 를 구현할 때 생기는 비용 문제를 보여줌
공유 포인터 구현이 POSIX mutex를 사용해서 (1) 플랫폼 독립적이지 않고 (2) 단일 스레드에서도 mutex 오버헤드를 지불하게 됨
즉, ‘zero-cost abstraction’이 아님
C++의 shared_ptr도 같은 문제를 가지지만, Rust는 Rc와 Arc 두 가지 타입으로 이를 구분해 해결함- C++의 shared_ptr은 mutex가 아니라 atomic 연산을 사용함
Rust의 Arc와 유사하며, 블로그의 구현은 단순히 비효율적일 뿐임
다만 C++에는 Rc에 해당하는 타입이 없어서, 단순 참조 카운팅 포인터를 원할 때는 여전히 비용이 발생함 - glibc와 libstdc++ 환경에서는 pthreads를 링크하지 않으면 shared_ptr이 스레드 안전하지 않음
런타임에서 pthread 심볼을 찾아 atomic 또는 비-atomic 경로를 선택함
차라리 항상 atomic을 쓰는 편이 낫다고 생각함 - 나는 코드가 크래시하지 않게 만드는 게 훨씬 중요하다고 느낌
크로스플랫폼은 대부분의 경우 ‘있으면 좋은’ 수준임
mutex 오버헤드는 짜증나지만, 현대 CPU에서는 감당 가능한 수준임
Rust가 훌륭하다는 건 알지만, C 생태계가 너무 방대해서 완전히 대체하기는 어려움 - mutex 대신 C11 atomic 연산으로 참조 카운트를 구현할 수도 있음
이 경우 mutex가 주는 이점이 무엇인지 잘 모르겠음 - POSIX mutex는 이미 여러 플랫폼에서 구현되어 있어서, 오히려 더 범용적인 API라고 생각함
- C++의 shared_ptr은 mutex가 아니라 atomic 연산을 사용함
-
Fil(aka pizlonator)이 만든 FUGC라는 가비지 컬렉터로 C를 메모리 안전하게 만드는 프로젝트가 있음
기존 코드에 거의 수정 없이 적용 가능하며, C/C++을 메모리 안전 언어로 바꿔줌
관련 HN 글과 공식 사이트 참고- 덕분에 이 프로젝트를 처음 알게 되었음. 정말 멋진 시도라고 생각함
- 하지만 가비지 컬렉터의 성능 저하를 감수하고 싶지는 않음
-
이 글은 메모리 안전성의 핵심을 다소 잘못 표현한 것 같음
지역 변수 자동 해제나 경계 검사만으로는 충분하지 않음
프로그램 전체의 메모리 수명 관리가 진짜 문제임
예를 들어 UniquePtr을 반환하거나 SharedPtr을 복사할 때 참조 카운트를 잊지 않는지, intrusive list의 원소 수명은 누가 관리하는지 등
결국 이 글의 접근은 예전의#define xfree(p)패턴과 크게 다르지 않다고 느낌- UniquePtr은 구조체를 값으로 반환할 수 있으니 가능함
하지만 SharedPtr 복사는 참조 카운트 증가를 자동으로 처리하지 않음 -
#define xfree(p)패턴이 왜 나쁜지 궁금함
- UniquePtr은 구조체를 값으로 반환할 수 있으니 가능함
-
C23이 [[cleanup]] 속성을 도입했다고 하지만, 실제로는 GCC 확장 기능이며
[[gnu::cleanup()]]로 써야 함
예시 코드 참고- 관련 정보를 찾기 어렵던데, 결국 문법만 바뀐 것이고 기능 자체는 여전히 확장 기능인 듯함
-
“C++: 다른 언어들이 내 힘의 일부라도 흉내 내려면 얼마나 고생하는지 보라”는 농담이 있었음
매크로로 C++을 흉내내는 이유가 궁금하지만, 어쨌든 흥미로운 시도임- C++의 모든 기능을 넣지 않고도 더 안전한 C를 만드는 과정이 흥미로웠음
다만 결국 C++17의 기능까지 흉내내는 걸 보면, 그냥 C++을 쓰는 게 낫지 않을까 싶음 - 나는 파싱 가능한 언어를 원함
C는 여전히 다루기 쉽지만, C++은 너무 복잡해서 프론트엔드 없이는 접근이 어려움 - C는 단순해서 해킹하기 좋은 언어임
C++로 넘어가면 빌드 체인, 네임 맹글링, libstdc++ 의존성 등으로 복잡해짐 - 이 프로젝트는 C++의 일부 기능만 허용해 제한된 문법을 강제할 수 있음
반면 C++을 C 스타일로 쓰면 그런 제약이 없음 - 임베디드 CPU 벤더들이 C++ 컴파일러를 제공하지 않는 것도 현실적인 제약임
- C++의 모든 기능을 넣지 않고도 더 안전한 C를 만드는 과정이 흥미로웠음
-
setjmp/longjmp 기반 예외 처리와는 호환되지 않음
대신 POSIX의 pthread_cleanup_push에서 영감을 받은 cleanup 매크로 쌍으로 통합할 수 있음
cleanup_push(fn, type, ptr, init)과cleanup_pop(ptr)을 사용해 스택 기반 정리 루틴을 구현함
이 방식은 컴파일 타임에 균형 오류를 잡아주는 장점이 있음 -
safeclib의 진짜 safec.h와 혼동하지 말아야 함
safeclib 헤더 참고- Annex K 구현을 유지보수하려는 이유가 궁금함
전역 constraint handler 때문에 설계 실패로 평가받고 있으며, 대부분의 툴체인이 지원하지 않음
관련 문서 참고
- Annex K 구현을 유지보수하려는 이유가 궁금함
-
Nim 언어를 쓰면 safe_c.h가 제공하는 기능을 모두 얻을 수 있음
Nim은 C로 컴파일되며, 안전성과 성능을 동시에 제공함
ARC 기반의 자동 참조 카운팅,defer,Option[T], bounds-checking,likely/unlikely등 다양한 기능을 기본 제공함
공식 사이트, ARC 소개, view types, Option 문서, likely 템플릿 참고 -
이 접근법이 이식성을 목표로 한다면, 현실적으로는 C99에 머무르는 게 안전함
MSVC의 C 컴파일러는 까다롭지만, 크로스플랫폼을 위해선 거의 필수임
나도 비슷한 헤더를 만들었지만, 이식성 문제 때문에 cleanup 유틸리티는 넣지 않았음- 매크로로 C++ 코드(소멸자 기반)를 생성하게 하면 cleanup 속성 없이도 가능함
C 코드가 C++로도 컴파일된다면 잘 동작함 - Windows에서도 MSYS2 + GCC로 충분히 개발 가능함
패키지 매니저도 함께 제공됨 - 참고로 MSVC는 이제 C17을 지원함
- 매크로로 C++ 코드(소멸자 기반)를 생성하게 하면 cleanup 속성 없이도 가능함
-
글에서 여러 번 언급된 cgrep의 코드 링크가 없음
GitHub에 같은 이름의 프로젝트가 많지만 대부분 다른 언어로 작성되어 있음- 나도 어떤 cgrep을 말하는 건지 모르겠고, 직접 써보고 싶음