1.7. FFI - extern "C" / CStr / CString / CPtr
rok::bare::ffi 모듈은 C ABI 와 호환되는 외부 함수 및 데이터 정의 메커니즘을 제공합니다. 이는 refs-lang 5.9 extern 블록 이 정의하는 extern "C" { ... } 블록과 함께 동작합니다.
본 장에서는 전통적인 C FFI 만 다루며, WASM Component Model 기반의 FFI 는 1.8 wit 에서 별도로 서술합니다.
1.7.1. extern "C" block
가장 기본적인 형태는 다음과 같습니다.
extern "C" {
// 함수 선언
fn malloc(size: usize) -> CPtr<u8>;
fn free(ptr: CPtr<u8>);
fn strlen(s: ConstPtr<c_char>) -> usize;
// 글로벌 (read-only)
static errno: i32;
// 글로벌 (mutable) - `mut` 키워드 _이 자리에서만_ 허용
static mut atexit_count: u64;
}
여기에서 등장하는 mut 키워드는 refs-lang 1.5 키워드 에서 정의되며, 오직 이 자리에서만 의미를 가집니다. 일반 변수에는 사용할 수 없다는 점에 유의해야 합니다.
#[link("name")] - library 지정
라이브러리를 명시적으로 지정하려면 #[link] 어트리뷰트를 활용합니다.
#[link("c")]
extern "C" {
fn malloc(size: usize) -> CPtr<u8>;
fn free(ptr: CPtr<u8>);
}
#[link("m")]
extern "C" {
fn cos(x: f64) -> f64;
fn sin(x: f64) -> f64;
}
linker 가 -lc 또는 -lm 같은 플래그를 자동으로 부착합니다.
호출 규약 (calling convention)
플랫폼과 인터페이스에 따라 다양한 호출 규약을 선택할 수 있습니다.
extern "C" { ... } // System V (Unix) / Microsoft x64 (Windows)
extern "system" { ... } // Windows: stdcall on x86, C on x64 / aarch64
extern "fastcall" { ... } // x86 fastcall
extern "stdcall" { ... } // x86 stdcall
extern "aapcs" { ... } // ARM AAPCS
extern "thiscall" { ... } // C++ this pointer (x86 only)
대부분의 사용자는 기본값인 "C" 만 사용해도 충분합니다. 그 밖의 규약은 주로 Windows API 나 C++ 인터페이스를 다룰 때 등장합니다.
str 매개변수와 반환
extern "C" 함수의 str 매개변수는 NUL 로 끝나는 char* 로 전달되고, 반환된 char* 는 strlen 으로 길이를 재어 str 이 됩니다 (refs-lang 5.9.4). (ptr, len) 전달은 런타임 ABI extern "rok" 의 것입니다 (5.9.2).
extern "C" {
fn puts(s: str) -> i32; // "hi" → NUL 로 끝나는 char*
fn getenv(name: str) -> CPtr<c_char>; // null 가능 - CPtr 로 받음
}
1.7.2. CStr / CString - null-terminated string
C 문자열을 다루는 빌림(borrow) 형태의 타입이 CStr 입니다.
struct CStr {
// 내부: null-terminated UTF-8 또는 raw bytes (Unix 은 bytes, Windows 는 UTF-16 별도)
}
impl CStr {
fn from_ptr(p: ConstPtr<c_char>) -> CStr | none;
// null pointer → none
// null-terminator 없으면 - undefined (호출자 책임)
fn from_bytes_with_nul(bytes: Slice<u8>) throws (FromBytesWithNulError) -> CStr;
// 검증 - 마지막이 0, 중간 0 없음
fn from_bytes_with_nul_unchecked(bytes: Slice<u8>) -> CStr;
fn as_ptr(self) -> ConstPtr<c_char>;
fn as_bytes(self) -> Slice<u8>; // null-terminator 제외
fn as_bytes_with_nul(self) -> Slice<u8>; // null-terminator 포함
fn to_str(self) throws (Utf8Error) -> str; // UTF-8 변환
fn len(self) -> usize; // null-terminator 제외
}
from_ptr 는 C 문자열을 복사해 들입니다. CStr / CString 은 바이트와 NUL 을 GC 객체 하나에 담으며, as_ptr 는 str::as_ptr 와 같은 동안 유효합니다 (2.15.2): 포인터 값이 나올 수 있는 함수에서 그 CStr / CString 을 담은 변수가 scope 안에 있는 동안입니다.
CString - owned, null-terminated
소유권을 가지는 형태가 필요하다면 CString 을 사용합니다.
struct CString { /* GC heap, null-terminated */ }
impl CString {
fn new(bytes: Slice<u8>) throws (NulError) -> CString;
// 중간 null byte 거부
fn from_str(s: str) throws (NulError) -> CString;
fn as_c_str(self) -> CStr;
fn as_ptr(self) -> ConstPtr<c_char>;
fn into_bytes(self) -> List<u8>; // owned bytes (null 제외)
fn into_bytes_with_nul(self) -> List<u8>;
}
사용 예 - strlen
extern "C" {
fn strlen(s: ConstPtr<c_char>) -> usize;
}
let s: str = "hello";
let cs = try CString::from_str(s);
let n = unsafe { strlen(cs.as_ptr()) }; //→ 5
참고
str 와 CStr 사이의 변환에는 할당이 필요합니다 (null-terminator 를 추가해야 하기 때문입니다). str 는 일반적으로 non-null-terminated 형태이므로 FFI 자리에서만 명시적으로 변환하는 편이 권장됩니다.
1.7.3. CPtr<T> 와 nullable
권위 있는 정의는 1.1.5 CPtr 에 있습니다. C 함수가 T* 를 반환할 때에는 null 가능성을 명시할 필요가 있는데, 그 자리에 등장하는 타입입니다.
extern "C" {
fn fopen(path: ConstPtr<c_char>, mode: ConstPtr<c_char>) -> CPtr<FILE>;
fn fclose(file: CPtr<FILE>) -> i32;
}
struct FILE; // opaque
let path = try CString::from_str("data.txt");
let mode = try CString::from_str("r");
let f: CPtr<FILE> = unsafe { fopen(path.as_ptr(), mode.as_ptr()) };
match f.as_ptr() {
file: Ptr<FILE> => {
// 사용
unsafe { fclose(f) };
}
none => panic("fopen failed"),
}
1.7.4. using 블록 - RAII
GC 는 finalizer 를 보장하지 않습니다. 이는 모든 GC 언어를 학습할 때 짚게 되는 원칙으로, 외부 자원의 RAII 는 명시적인 using 으로 처리해야 합니다.
using f = try rok::bare::ffi::fopen(path, "r") {
let buf = try f.read_to_end();
process(buf);
} // ← scope 끝에서 자동 fclose (Drop)
// without using - 자원 누수 가능
let f = try rok::bare::ffi::fopen(path, "r");
try process(f); // 에러 전파 시 fclose 안 됨
rok::bare::ffi::fclose(f); // _explicit_ 만 보장
using 구문 자체는 refs-lang 3.17 using 표현식 이 권위를 가지며, 2.13 marker 의 Drop 트레잇과 통합되어 작동합니다.
1.7.5. 안전 wrapper 패턴 - sys + safe 분리
Rust 생태계의 표준 관용구는 *-sys 크레이트(raw FFI) 와 동명 크레이트(safe wrapper) 를 분리하는 것입니다. rok 도 같은 패턴을 권장합니다.
// libc-sys 모듈 - raw FFI
mod c {
#[link("c")]
extern "C" {
pub fn malloc(size: usize) -> CPtr<u8>;
pub fn free(ptr: CPtr<u8>);
pub fn strdup(s: ConstPtr<c_char>) -> CPtr<c_char>;
}
}
// libc - safe wrapper
pub fn alloc_buffer(size: usize) throws (AllocError) -> List<u8> {
let p = unsafe { c::malloc(size) };
match p.as_ptr() {
ptr: Ptr<u8> => {
// unsafe → safe 경계
let bytes = unsafe { Slice::from_raw_parts(ptr, size).to_list() };
unsafe { c::free(p) };
bytes
}
none => { throw AllocError::OutOfMemory; }
}
}
이러한 분리는 unsafe 코드를 좁은 영역에 가두는 효과를 가집니다. 그 결과 사용자 코드에서는 safe wrapper 만 호출하게 되어 안전성이 자연스럽게 확보됩니다.
1.7.6. 자동 binding 생성
rokc bindgen (refs-lang 9.1) 은 libclang 으로 C 헤더에서 rok 선언을 생성합니다. libclang 은 toolchain 이 그 lib/ 에 함께 배포하며, 이 명령만 로드합니다.
rokc bindgen libfoo.h > libfoo_sys.rok
rokc bindgen libfoo.h --target x86_64-pc-windows-msvc -o libfoo_sys.rok -- -I include -DFOO=1
출력은 target 하나 (--target, 기본값은 host) 를 위한 것입니다: 크기, 레이아웃, 정수 폭은 그 target 에 대한 clang 의 것입니다. -- 뒤의 인자는 clang 에 넘깁니다. 기계의 시스템 헤더는 그 기계 자신의 플랫폼, Apple target (SDK), MSVC host 의 MSVC target 에 씁니다. 그 밖의 target 에서는 --sysroot 를 주지 않는 한 clang 이 자기 내장 헤더만 받으며, target 의 헤더는 -- 뒤에 (-isystem <dir>) 넘깁니다. 헤더와 그것이 include 하는 시스템 아닌 헤더의 선언을 씁니다 (시스템 디렉토리의 헤더라면 자기 디렉토리 트리에서 include 하는 헤더의 선언도). 그것들이 쓰는 다른 헤더의 선언도 함께 씁니다. clang 이 거부하는 헤더는 clang 의 진단과 함께 에러 (exit 1) 입니다.
매핑 규약은 다음과 같습니다.
- C 의 정수와 부동소수 타입은 그 target 에서 같은 크기의 rok 원시 타입이 됩니다 (
int→i32,long→i32또는i64,char→c_char,_Bool→bool).size_t/uintptr_t→usize,ssize_t/intptr_t/ptrdiff_t→isize. 시스템 헤더가 그런 타입에 붙인 typedef 는 그 타입 자체입니다. - typedef 는 rok 의 type alias 로 변환됩니다.
- struct 와 union 은
#[repr(C)] struct/union에 대응합니다 (clang 의 레이아웃이 필요로 하면#[repr(C, align(N))]이나#[repr(packed)]). 연속된 bitfield 는 그 저장소를 덮는 바이트 배열 필드_bitfieldN하나가 됩니다. rok 가 표현할 수 없는 레이아웃 (N > 1 인#pragma pack(N), union 안의 bitfield) 은 같은 크기와 정렬로_opaque: [u8; N]을 담은 struct 가 됩니다. 선언만 되고 정의되지 않은 struct 는struct Name;(opaque) 가 됩니다. - enum 은 그 정수 타입의 alias 와 enumerator 마다
const하나가 됩니다 (pub type color = u32; pub const RED: color = 0;). 이름 없는 enum 은 그 상수들입니다. - 객체 포인터 (
T*,const T*) 는CPtr<T>가 되며,void*는CPtr<u8>가 됩니다. - 함수는
extern "C" fn이 됩니다 (C variadic 은...). 32-bit x86 Windows 의 stdcall 함수는extern "system"에 들어갑니다. 전역 변수는static이 됩니다 (const 가 아니면static mut). static 과 inline 함수는 건너뜁니다 (링크할 심볼이 없음). - 함수 포인터는
unsafe extern "C" fn(...) -> R | none이 됩니다 (refs-lang 5.9.4; NULL =none). - 정수, 부동소수, 문자열 상수로 평가되는 object-like 매크로는
const항목이 됩니다. - rok 키워드나 원시 타입 이름인 C 이름에는 뒤에
_가 붙습니다. 함수와 static 은#[link_name]으로 C 심볼을 유지합니다. - 그 밖의 것 (128-bit 정수,
_Float16, 8 바이트보다 넓은long double, vector 와 block 타입, variadic 함수 포인터, thread-local 변수, 다른 호출 규약) 은// skipped ...주석과 함께 건너뜁니다. 바이트로 적은 struct (_opaque) 는 결코 값으로 넘기지 않습니다: 그것을 직접 또는 다른 struct 안에 담아 받거나 돌려주는 함수나 함수 포인터는 건너뜁니다. C 는 그것을 필드로 분류하기 때문입니다.
1.7.7. 호출 안전 책임 분배
호출 자리에서 누가 무엇을 책임지는지 정리하면 다음과 같습니다.
| 항목 | 책임자 |
|---|---|
| Pointer null 검사 | C → rok 시 rok 코드 |
| Buffer 길이 검증 | rok 코드 (length 알고 있어야) |
| String null-termination | CString 생성 시 자동 |
| GC pin | rok 코드 (long-running FFI 시) |
메모리 해제 (free) | rok 코드 또는 safe wrapper |
| 호출 규약 일치 | extern 선언자 |
| Thread safety | C 라이브러리 문서 따름 |
| Errno / GetLastError | 호출 직후 즉시 read |
1.7.8. 다른 언어와의 비교
비교의 시야를 넓히면 rok 의 위치가 더 분명해집니다.
| 측면 | C | Rust | Zig | Go | rok |
|---|---|---|---|---|---|
| 외부 함수 | (직접) | extern "C" { ... } | extern "..." | import "C" (cgo) | extern "C" { ... } |
| 호출 규약 | (one default) | extern "system" etc. | extern "C" etc. | "C" only | 다중 ("C"/"system"/etc.) |
| C string | char* | CStr / CString | [*:0]u8 | C.CString / C.GoString | CStr / CString |
| nullable ptr | (default) | Option<*const T> | ?*T | *C.struct_T (always nullable) | CPtr<T> |
| binding 도구 | none | bindgen / cbindgen | @cImport | cgo | rokc bindgen |
| GC pin | N/A | N/A | N/A | runtime.Pinner (1.21+) | rok::bare::gc::pin |
| RAII / using | (manual) | Drop | defer | defer | using block (Drop) |
rok 의 가장 큰 차이점은 다음 세 가지로 압축됩니다.