형식 안전 모델
SafeC의 안전 보장은 지향적인 목표가 아닙니다 — 이는 구문적 타입 안전성(progress + preservation)을 갖춘 타입 시스템으로 형식화되어 있으며, Oxide(Weiss 외)와 Cyclone(Grossman 외)의 선행 연구를 기반으로 합니다.
일곱 가지 안전 속성
1. 공간 안전성
범위를 벗어난 메모리 접근이 없습니다. 배열 첨자는 상수 인덱스의 경우 컴파일 타임에, 동적 인덱스의 경우 런타임에 경계 검사됩니다.
int arr[4];
arr[5] = 1; // 컴파일 타임 오류: index 5 out of bounds for array of size 4
int i = get_index();
arr[i] = 1; // 런타임 경계 검사가 삽입됨 (위반 시 abort)
런타임 경계 검사는 unsafe {} 블록 안에서 억제될 수 있습니다.
2. 시간 안전성
use-after-free가 없습니다: 리전 표기는 참조의 라이프타임을 할당 스코프에 묶습니다. 스택 메모리에 대한 참조는 함수를 벗어날 수 없고, arena에 대한 참조는 arena_reset<R>()/arena_destroy<R>()/arena_free_to<R>()에 의해 무효화되며, &heap T 참조는 std::dealloc()에 의해 무효화됩니다 — 이 세 가지 모두 단순히 런타임뿐 아니라 컴파일 타임에도 검사됩니다.
&stack int get_local() {
int x = 42;
return &x; // 컴파일 타임 오류: stack reference escapes function
}
#include <std/mem.h>
void heap_example() {
&heap int p = new int;
std::dealloc(p);
*p = 1; // 컴파일 타임 오류: use of 'p' (&heap reference) after std::dealloc() freed it
std::dealloc(p); // 컴파일 타임 오류: double-free, same check
}
명시된 신뢰 경계 안에서 건전함
이 검사의 arena 측면과 heap 측면 모두(아레나 참조는 리셋 시 소멸합니다와
컴파일 타임 해제 후 사용 및 이중 해제 검사 참고)는
타입 검사가 끝난 뒤 모든 함수 본문에 대해 실행되는 하나의 데이터플로우
분석입니다. 이 분석은 각 할당을 하나의 추상 객체로, 각 포인터를 그것이
가리킬 수 있는 객체들의 집합으로 추적하며, 포인터마다 "해제되었을 수 있음"
표시를 따로 둡니다. 이 추적은 모든 복사(몇 단계를 거치든, 분기마다 다른
값을 대입하든), 구조체 필드, 배열 원소를 따라갑니다. 상수 인덱스는 각각
별개의 원소로 다루며, 알 수 없는 인덱스를 통한 저장은 어느 원소에든 닿을
수 있는 것으로 봅니다. 제어 흐름은 정확히 따라갑니다: if/else, switch의
fallthrough, match, ?:, &&/||(한 피연산자 안의 해제는 그 피연산자가
실행된 결과에만 영향을 줍니다), break/continue/goto/return/try,
그리고 각 반환 지점에서 실행되는 defer/errdefer를 모두 반영합니다.
루프와 레이블이 있는 함수는 고정점에 도달할 때까지 반복 분석하므로,
reset이나 해제는 다음 반복에서 루프 본문의 앞쪽 코드에 닿고, 루프 뒤의
코드에는 실제로 거칠 수 있는 출구를 통해서만 닿습니다.
arena_free_to<R>(m)은 마크 m 이후에 할당된 참조만 정확히 무효화합니다.
마크는 각각 따로 추적하므로 중첩된 마크도 정밀하게 다룹니다. 검사기가
식별할 수 없는 마크(호출자에게서 받았으나 해석할 수 없는 마크, 또는 여러
마크 중 하나일 수 있는 값)만이 그보다 넓게, 보수적으로 무효화합니다.
함수 호출은 요약(summary)을 통해 검사합니다. 본문이 있는 모든
함수(제네릭 인스턴스와 메서드 포함)에 대해 검사기는 그 함수가 무엇을 해제할
수 있는지, 어떤 리전을 reset하거나 free_to할 수 있는지(전달받은 마크를
통한 경우 포함), 인자가 가리키는 메모리에서 어떤 포인터를 읽는지, 그리고
매개변수와 전역 변수의 메모리에 무엇을 남기고 무엇을 반환하는지를 모두
매개변수 기준으로 계산합니다. 요약은 피호출 함수부터 계산하며, 재귀 함수는
고정점에 도달할 때까지 반복합니다. 매개변수를 해제하거나, 필드를 해제하거나
교체하거나, 매개변수를 반환하거나, 호출자가 이미 해제한 메모리를 읽는
피호출 함수는 호출 지점에서 검사됩니다. 피호출 함수 자체에는 문제가 없지만
인자에 문제가 있는 경우, 호출 지점에서
call to 'f' reads a &heap reference after std::dealloc() freed it(또는
이에 대응하는 &arena<R> 메시지)나
call to 'f' frees memory after std::dealloc() freed it를 보고합니다.
이 모델 안에서 검사는 건전(sound)합니다. 진짜 use-after-reset, use-after-free_to, use-after-free, double-free는 결코 놓치지 않습니다. 다음은 검사하지 않고 신뢰합니다:
- 함수 포인터나
fn값을 통한 호출,spawn이 시작하는 스레드 함수, 본문이 없는 함수(extern선언, C 임포트). 이들은 아무것도 해제하거나 reset하지 않고, 인자에 대한 포인터를 보관하지 않으며, 새 메모리를 반환한다고 가정합니다. 인자를 해제하지만 extern으로 구현된std::realloc_buf()도 여기에 포함됩니다. unsafe {}안에서 원시 포인터, 또는 참조에 대한 포인터 인덱싱을 통해 도달한 메모리.std::Vec등 std 컨테이너의 버퍼가 여기에 해당합니다. 그곳에 저장된 포인터는 추적하지 않으며, 그곳에서 다시 읽어 온 포인터는 모두 유효한 것으로 간주합니다.- 한 함수 안에서, 서로 다른 매개변수(및 전역 변수)는 그 함수가 직접 그렇게 만들지 않는 한 같은 할당을 가리키지 않는다고 가정합니다.
정밀도 한계는 두 가지가 남아 있으며, 둘 다 실제로는 올바른 코드를
보고합니다. 첫째, 분석은 제어 흐름은 따라가지만, 조건식의
&&/||/! 구조를 제외하면 그 흐름을 결정하는 값은 따라가지 않습니다.
따라서 if (owned)로 보호된 해제 뒤에 if (!owned)로 보호된 사용이 오면
여전히 보고됩니다. 둘째, 매개변수에서 포인터를 세 단계보다 더 따라간
진입 메모리와, 알 수 없는 인덱스 뒤의 원소들은 각각 하나의 객체로
요약됩니다. 그래서 연결 구조를 안전한 참조로 순회하면서 노드를 하나씩
해제하는 루프는 이미 해제한 노드를 다시 방문하지 않음을 보일 수 없으므로
보고됩니다. 이런 코드는 재귀로 작성하거나, std 컨테이너처럼
unsafe {} 안에서 원시 포인터로 순회하면 됩니다. unsafe {}는 여전히
탈출구로 남아 있습니다. 그 안에서는 아무것도 보고하지 않지만, 그 안에서
일어난 해제와 reset은 뒤따르는 코드에 그대로 반영됩니다.
3. 별칭 안전성
가변 참조는 배타적입니다. 대여 검사기는 프로그램의 어느 시점에서든 값이 하나의 가변 참조를 갖거나 임의 개수의 불변 참조를 가질 수 있지만, 결코 둘 다는 가질 수 없도록 강제합니다.
int x = 10;
&stack int a = &x; // 가변 대여
&stack const int b = &x; // error: cannot borrow 'x' as immutable: already borrowed as mutable
const &stack int b는 &stack const int b와 같은 타입입니다. const는 리전 한정자의 앞과 뒤 어느 쪽에 써도 됩니다.
4. 리전 이스케이프 안전성
참조는 자신이 가리키는 리전보다 오래 살아남을 수 없습니다. 컴파일러는 리전 스코프 깊이를 추적하며, 참조를 더 얕은 스코프로 옮기는 할당이나 반환을 거부합니다.
region R { capacity: 1024 }
&arena<R> int outer_ptr;
void demo() {
&arena<R> int p = new<R> int;
outer_ptr = p; // error: cannot assign '&arena<R> int' to variable in
// outer scope: arena reference would escape
}
5. 데이터 레이스 자유
모든 타입은 구조에서 유도되는 Send 및/또는 Sync입니다(unsafe impl Send; / impl !Send;로 덮어쓸 수 있음). spawn은 스레드가 공유해도 되는 정적 데이터만 받고, spawn_scoped는 join될 때까지 지역 변수를 빌려주며, thread_spawn과 채널은 Send 값을 이동시키고, Mutex<T>는 가드를 통해서만 값에 닿습니다 — 따라서 안전한 코드는 동기화 없이 스레드 사이에 가변 상태를 공유할 수 없습니다. 스레드 안전성을 참고하세요.
6. Null 안전성
참조는 기본적으로 non-null입니다. 안전한 SafeC 코드에는 null 참조가 없습니다. Nullable 참조는 ?&T 구문을 사용합니다; 흐름 민감적인 null 좁히기(narrowing)를 가진 언어들과 달리, 단순한 != null 비교만으로는 이후의 역참조가 안전해지지 않습니다 — nullable 참조를 읽는 것은 오직 match, .is_null(), .default(fallback), 또는 명시적인 unsafe 블록을 통해서만 허용됩니다(안전성 참고).
7. 결정론
숨겨진 런타임 비용이 없습니다. SafeC는 가비지 컬렉션도, 참조 카운팅도, 암묵적 힙 할당도 삽입하지 않습니다. 모든 할당은 소스 코드에 명시적입니다. 성능 모델은 투명합니다 — 작성한 것이 곧 실행되는 것입니다.
C, C++, Rust, Zig와의 메모리 안전성 비교
비교에는 동일한 다섯 언어에 걸친 한 줄짜리 "메모리 안전성: Yes/No" 요약이 있습니다; 이 섹션은 그 단일 행을 위 속성들로 세분화합니다. "yes"와 "no"는 각 언어가 언제(컴파일 타임 vs. 런타임 vs. 전혀 하지 않음) 그리고 얼마나 완전하게(모든 안전 모드 프로그램 vs. 옵트인 빌드 모드 vs. 아무것도 없음) 검사하는지의 실제 차이를 가리기 때문입니다.
| 속성 | C | C++ | Rust | Zig | SafeC |
|---|---|---|---|---|---|
| 공간 안전성(버퍼 오버플로) | 없음 — []에 검사 없음, OOB는 UB |
원시 배열/[]에는 없음; .at()/std::span을 통한 옵트인(예외 발생) |
상수 인덱스는 컴파일 타임, 그 외에는 런타임 패닉 — 안전 코드에서 항상 켜짐 | Debug/ReleaseSafe에서 런타임 검사; ReleaseFast/ReleaseSmall에서는 컴파일 시 제거(UB) | 상수 인덱스는 컴파일 타임, 그 외에는 런타임 검사 — unsafe 밖에서는 항상 켜짐 |
| 시간 안전성(use-after-free) | 없음 — 수동 free(), 추적 없음 |
원시 포인터/참조에는 없음; RAII(unique_ptr/shared_ptr)는 일관되게 사용될 때만 도움이 되며 강제되지 않음 |
컴파일 타임 — 대여 검사기가 move/drop 이후의 모든 사용을 거부; 건전함 | 컴파일러 측 보장 없음; GeneralPurposeAllocator가 Debug 빌드에서만 일부를 런타임에 포착 |
컴파일 타임, 리전 기반(stack/arena/heap) — 데이터플로우 분석, 명시된 신뢰 경계 안에서 건전함; unsafe {}로 옵트아웃 |
| 이중 해제 | 없음 — UB | 원시 delete는 C와 동일; 스마트 포인터는 원시 별칭에 의해 우회되지 않는 한 구조적으로 이를 방지 |
컴파일 타임 — 오직 하나의 소유자만 drop을 호출할 수 있음 | 런타임 전용, 옵트인(Debug에서 GeneralPurposeAllocator) |
컴파일 타임(위와 동일한 검사) 그리고 심층 방어로서 std::alloc/std::dealloc의 런타임 태그 헤더 가드 |
| 초기화되지 않은 읽기 | 없음 — UB | 없음 — UB | 컴파일 타임 — 확정 할당 분석 | undefined는 명시적이고 타입이 있음; Debug 빌드는 버그를 보이게 만들기 위해 이를 오염시킴(0xaa), 방지하지는 않음 |
컴파일 타임 — 확정 초기화 검사 |
| Null 포인터 역참조 | 없음 — 원시 포인터, 추적되지 않음 | 원시 포인터에는 없음; 참조는 구조상 null이 될 수 없지만 역참조된 null 포인터로부터 형성될 수 있음(UB), 포착되지 않음 | 컴파일 타임 — 안전 코드에 null이 없음, Option<T>는 매치/언래핑되어야 함 |
컴파일 타임 — 옵셔널(?T)은 명시적으로 언래핑되어야 함 |
컴파일 타임 — ?&T는 읽기 위해 match/.is_null()/.default()/unsafe가 필요함(Null 안전성 참고) |
| 데이터 레이스 | 없음 — 언어 수준의 동시성 안전성 없음 | 없음 — 레이스는 UB; atomic/뮤텍스는 옵트인 도구일 뿐 강제되지 않음 |
컴파일 타임 — Send/Sync 트레이트가 안전 코드에서 공유 상태 안전성을 강제 |
없음 — 타입 시스템 강제 없음, 수동 동기화만 | 컴파일 타임 — 유도된 Send/Sync를 spawn, spawn_scoped, 채널, std의 스레드 타입이 검사; 가변 상태 공유에는 동기화가 필요함 |
| 강제 지점 | N/A | 대체로 없음; 일부 옵트인 런타임 검사 | 컴파일 타임 우선(대여 검사기), 진짜 동적인 부분(경계, 패닉)은 런타임 | 런타임, 그리고 안전 검사가 이루어지는 빌드 모드에서만 — 동일한 바이너리를 검사 없이 컴파일할 수 있음 | 컴파일 타임 우선, 검사가 본질적으로 동적인 경우(인덱스 경계)에만 런타임 — unsafe 밖에서는 항상 켜짐 |
| 탈출구 | N/A — 언어 전체가 이런 방식임 | N/A — 언어 전체가 이런 방식임 | unsafe {} 블록/함수 |
어휘적 탈출구 없음 — 안전성은 블록 단위 옵트아웃이 아니라 빌드 전체 모드 설정 | unsafe {} 블록 — 어휘적으로 스코프가 지정되며, 전파되지 않음(아래 참고) |
핵심 요지: C와 C++는 이들 대부분을 전혀 검사하지 않습니다(정의되지 않은 동작이 대부분의 시간 동안 "검사된" 상태입니다). Zig는 이들 중 여럿을 검사하지만, 빌드 모드 선택으로서 그렇게 합니다 — 같은 소스가 검사를 갖거나 갖지 않으며, 줄 단위가 아니라 바이너리 전체에 대해 한 번 결정됩니다. Rust와 SafeC는 둘 다 기본적으로 컴파일 타임에 검사하며, 특정 코드 블록에 대해 검사를 끄려면 명시적이고 어휘적으로 스코프가 지정된 옵트아웃(unsafe {})이 필요합니다 — 이것이 바로 이 페이지 자신의 unsafe 탈출구 섹션이 설명하는 속성입니다. Rust와 SafeC가 다른 지점은 시간 안전성을 어떻게 증명하는가(소유권/대여 vs. 리전 소속 — 그 트레이드오프에 대한 자세한 내용은 Rust와 비교 참고)와 그 증명이 얼마나 완전한가입니다: Rust의 대여 검사기는 자신이 받아들이는 패턴에 대해 건전하고 완전한 정적 증명입니다(유효한 프로그램을 거부할 수는 있지만, 결코 안전하지 않은 프로그램을 받아들이지 않습니다); SafeC의 리전 및 수명 검사는 명시된 신뢰 경계(함수 포인터를 통한 호출과 본문이 없는 extern 함수 호출, unsafe {} 안에서 원시 포인터로 도달한 메모리) 안에서 건전하지만, 몇 가지 유효한 형태(서로 반대되는 조건으로 보호된 해제와 사용, 연결 구조를 노드 단위로 해제하는 루프 — 시간 안전성 참고)는 여전히 거부할 수 있으며, 더 단순하고 더 C 호환적인 사고 모델을 위해 일부 정밀함을 희생합니다.
안전 분석 단계
컴파일러는 의미 분석 중 일련의 분석 패스를 통해 이러한 속성들을 강제합니다:
| 단계 | 검사 내용 |
|---|---|
| 확정 초기화 | 모든 변수가 사용 전에 할당됨 |
| 리전 이스케이프 분석 | 스택/정적/리전 간 이스케이프가 거부됨 |
| 힙/arena 수명 분석 | std::dealloc() 이후의 사용, 이중 해제, arena_reset/arena_destroy/arena_free_to 이후의 사용을 타입 검사가 끝난 뒤 모든 함수 본문에 대한 데이터플로우 분석과 함수별 호출 요약으로 검사함(위 참고) |
| 별칭/대여 검사(NLL) | 가변 배타성이 유지됨 |
| Null 가능성 강제 | match/.is_null()/.default()/unsafe 밖에서 nullable 참조를 역참조하는 것은 거부됨 |
| 경계 검사 | 배열 접근이 범위 안에 있음 |
| 소유권 | Drop 타입의 모든 값이 모든 경로에서 정확히 한 번 drop되거나 이동됨: 누수, drop·이동 후 사용, 이중 drop 없음 |
| 스레드 안전성 | spawn, spawn_scoped, 채널, std의 스레드 타입이 다른 스레드에 넘기는 것이 Send/Sync임 |
모든 검사는 컴파일 타임에 실행됩니다. 유일한 런타임 삽입은 동적 배열·슬라이스 인덱스에 대한 경계·범위 검사이며, 이는 생성된 IR에서 눈에 보입니다.
unsafe 탈출구
모든 안전 검사는 unsafe {} 블록 안에서 지역적으로 억제될 수 있습니다:
unsafe {
int* raw = (int*)some_addr;
*raw = 42; // no bounds check, no borrow check, no region check
}
unsafe는 어휘적으로 스코프가 지정됩니다. 호출된 함수로 전파되지 않습니다. 수동 감사가 필요한 코드를 위한 검색 가능한(grep-able) 마커 역할을 합니다.
FFI 경계 규칙
외부 함수 호출(C 상호운용)은 특정 안전 규칙을 따릅니다:
extern선언은 원시 C 타입을 사용합니다 — 리전 한정자 없음&static T에서T*로의 강제 변환은unsafe {}없이 안전합니다&T/?&T(리전 한정자가 전혀 없는, "outliving reference" — 메모리와 리전 참고)도 양방향으로T*/void*와의 강제 변환이unsafe {}없이 안전합니다 — 호출이 반환된 이후에도 C 쪽에서 보유할 수 있는 포인터를 위한 타입입니다- 정적이지 않고 리전이 없지도 않은 참조를 C에 전달하려면
unsafe {}가 필요합니다 - C로부터 받은 원시 포인터는
&T/?&T타입 변수로 직접 받는 경우가 아니라면unsafe {}안에서 처리해야 합니다
이러한 규칙들은 안전한 SafeC 코드와 안전하지 않은 C 코드 사이의 경계가 항상 명시적임을 보장합니다.
형식적 토대
안전 모델은 확립된 타입 이론에 기반합니다:
- Oxide(Weiss 외, 2019) — place와 provenance를 가진 타입 있는 계산법으로서 Rust의 소유권과 대여를 형식화
- Cyclone(Grossman 외, 2002) — C와 비슷한 언어를 위한 정적 안전 보장을 갖춘 리전 기반 메모리 관리
SafeC는 이러한 형식주의들을 리전을 주요 메모리 안전 메커니즘으로 삼는 C 호환 언어에 맞게 조정합니다.
Progress와 Preservation
타입 시스템은 표준적인 구문적 안전성 속성을 만족합니다:
- Progress: 타입이 올바른 프로그램은 값이거나, 한 단계를 진행할 수 있거나, 승인된 런타임 오류(abort를 동반한 경계 검사 실패)입니다
- Preservation: 타입이 올바른 프로그램이 한 단계를 진행하면, 결과 프로그램도 타입이 올바릅니다
이 두 속성이 함께, 타입이 올바른 SafeC 프로그램이 unsafe {} 블록 밖에서는 정의되지 않은 동작을 보이지 않음을 보장합니다.