Rust FFI: Rust에서 C 함수 호출하기
FFI는 Foreign Function Interface(외부 함수 인터페이스)의 줄임말이에요. Rust가 다른 언어로 쓰인 코드와 대화하는 방법이에요. 그 다른 언어는 거의 언제나 C이거나, C의 규칙을 따르는 무언가예요. C는 거의 모든 언어와 운영 체제가 알아듣는 “공용어”거든요.
모국어가 서로 다르지만 둘 다 영어를 조금 할 줄 아는 두 사람을 떠올려 보세요. C가 바로 그 공용어예요. FFI를 쓰면 Rust에서 오랫동안 다듬어진 C 라이브러리를 쓸 수도 있고, Python, Node.js, C 프로그램이 호출할 수 있는 빠른 Rust 라이브러리를 만들 수도 있어요. 이 페이지는 unsafe Rust를 바탕으로 하니, unsafe 블록이 처음이라면 그 페이지부터 읽어 보세요.
C 함수 호출하기
섹션 제목: “C 함수 호출하기”Linux, macOS, Windows에서 Rust 프로그램에는 C 표준 라이브러리가 항상 링크되어 있어요. 그래서 C 표준 라이브러리 함수로 처음 실험해 보기 딱 좋아요. C 함수를 호출하려면 extern "C" 블록 안에 함수를 선언해요. 이름, 매개변수, 반환 타입을 적는 거예요. 그다음 unsafe 안에서 호출해요.
use std::ffi::c_int;
unsafe extern "C" { // C의 `int abs(int x);`를 Rust로 쓴 거예요. fn abs(x: c_int) -> c_int;}
fn main() { let n = unsafe { abs(-42) }; println!("abs(-42) = {n}");}실행 결과:
abs(-42) = 42눈여겨볼 점이 몇 가지 있어요.
"C"는 ABI를 가리켜요. 인자를 어떻게 넘기고 값을 어떻게 돌려받는지에 대한 규칙이에요. C의 규칙은 모두가 동의하는 규칙이에요.c_int는 C의int를 Rust에서 부르는 이름이에요.std::ffi모듈에는c_int,c_long,c_char같은 타입이 있어서, 어느 플랫폼에서든 선언이 C와 정확히 맞아요.unsafe extern "C"라고 써요. 2024 에디션에서는 여기의unsafe가 필수예요. 선언이 올바르다고 여러분이 약속하는 것이기 때문이에요. 오래된 코드(그리고 2021 에디션)에서는 그냥extern "C" { ... }라고 쓴 경우가 많아요.unsafe를 붙인 버전은 두 에디션 모두에서 동작하니, 이 페이지에서는 어디서나 이걸 써요.
모든 호출이 unsafe인 이유와 그걸 숨기는 방법
섹션 제목: “모든 호출이 unsafe인 이유와 그걸 숨기는 방법”Rust 컴파일러는 C 코드를 읽을 수 없어요. abs가 정말로 int를 받는지, 프로그램을 죽일 수도 있는지, 넘겨준 포인터를 계속 쥐고 있는지 전혀 몰라요. 그래서 외부 함수 호출은 모두 unsafe예요. 여러분이 올바르게 쓸 책임을 지는 거예요.
좋은 소식은, 깊이 고민하는 건 한 번이면 된다는 거예요. 보통은 작은 안전한 래퍼(safe wrapper)를 만들어요. 규칙을 확인한 뒤 unsafe 호출을 하는 평범한 Rust 함수예요. 다른 사람들은 그 래퍼만 호출하면 돼요.
실제 예를 볼게요. C에서 abs(INT_MIN)은 정의되지 않은 동작이에요. 답이 너무 커서 int에 들어가지 않거든요. 우리 래퍼는 그 경우를 먼저 확인해요.
use std::ffi::c_int;
unsafe extern "C" { fn abs(x: c_int) -> c_int;}
// 안전한 래퍼예요. 호출하는 쪽에서는 `unsafe`가 필요 없어요.fn safe_abs(x: i32) -> Option<i32> { // C에서 abs(i32::MIN)에는 올바른 답이 없으니 거절해요. if x == i32::MIN { return None; } // SAFETY: x가 i32::MIN이 아니니 abs의 결과가 올바르게 나와요. Some(unsafe { abs(x) })}
fn main() { println!("{:?}", safe_abs(-42)); println!("{:?}", safe_abs(i32::MIN));}실행 결과:
Some(42)None// SAFETY: 주석은 따라 할 만한 Rust의 습관이에요. 이 unsafe 블록이 왜 괜찮은지 설명해 두면, 다음에 읽는 사람이 짐작할 필요가 없어요.
어떤 입력에도 지켜야 할 규칙이 전혀 없는 C 함수라면, unsafe extern 블록 안에서 safe fn으로 표시하고 unsafe 없이 호출할 수 있어요. abs는 그런 함수가 아니에요(INT_MIN을 기억하세요). 그래서 unsafe로 두고 입력을 직접 확인해요.
문자열: CString, CStr, 그리고 NUL 바이트
섹션 제목: “문자열: CString, CStr, 그리고 NUL 바이트”FFI 버그는 대부분 문자열에서 생겨요. Rust와 C가 문자열을 저장하는 방식이 다르기 때문이에요.
- Rust의
String/&str는 자기 길이를 알고 있고,0을 포함해 어떤 바이트든 담을 수 있어요. - C 문자열은 그냥 바이트를 가리키는 포인터예요. 문자열은 첫 번째
0바이트(NUL이라고 해요)에서 끝나요. 길이는 따로 저장되지 않아요.
그래서 &str를 C에 바로 넘길 수는 없어요. CString (영어)으로 NUL로 끝나는 복사본을 소유하는 형태로 만들고, .as_ptr()로 C가 원하는 *const c_char를 얻어요.
use std::ffi::{c_char, CString};
unsafe extern "C" { fn strlen(s: *const c_char) -> usize;}
fn c_strlen(text: &str) -> Option<usize> { // CString::new가 끝에 NUL을 붙여 줘요. let c_text = CString::new(text).ok()?; // SAFETY: c_text는 NUL로 끝나는 올바른 문자열이고, 이 함수가 끝날 때까지 살아 있어요. Some(unsafe { strlen(c_text.as_ptr()) })}
fn main() { let hello = CString::new("hello").unwrap(); println!("{:?}", hello.as_bytes_with_nul());
println!("{:?}", c_strlen("hello")); println!("{:?}", c_strlen("héllo"));
match CString::new("hel\0lo") { Ok(_) => println!("괜찮아요"), Err(e) => println!("에러: {e}"), }}실행 결과:
[104, 101, 108, 108, 111, 0]Some(5)Some(6)에러: nul byte found in provided data at position: 3이 예제에서는 바이트 수를 세기 때문에 문자열을 영어로 두었어요. 한글은 UTF-8에서 한 글자에 3바이트라서, 예를 들어 c_strlen("안녕")은 Some(6)이 돼요. 마지막 줄의 에러 메시지는 Rust가 직접 출력하는 것으로, “주어진 데이터의 3번 위치에서 nul 바이트를 발견했다”는 뜻이에요.
놀라운 점이 두 가지 있는데, 둘 다 중요해요.
strlen("héllo")는 5가 아니라 6이에요. C는 바이트를 세는데,é는 UTF-8에서 2바이트를 차지하거든요.- 텍스트에
0바이트가 들어 있으면CString::new는 실패해요. C는 문자열이 거기서 끝난다고 생각해서"lo"를 조용히 잘라 버릴 거예요. Rust는 그런 일이 일어나게 두지 않고 거절해요. 그래서CString::new가Result를 반환하는 거예요.
반대 방향으로, C가 여러분에게 char *를 건네줄 때는 CStr (영어)로 감싸요. CStr와 CString의 관계는 &str와 String의 관계와 같아요. 빌려 온 뷰(view)예요. C의 strchr는 처음으로 일치하는 문자를 가리키는 포인터를 반환하는데, 시연용으로 딱 좋아요.
use std::ffi::{c_char, c_int, CStr, CString};
unsafe extern "C" { fn strchr(s: *const c_char, c: c_int) -> *mut c_char;}
fn find_from(text: &CStr, ch: u8) -> Option<String> { let ptr = unsafe { strchr(text.as_ptr(), ch as c_int) }; if ptr.is_null() { return None; // C는 널 포인터로 "찾지 못했음"을 알려요 } // SAFETY: ptr은 `text` 안을 가리키고, text는 살아 있으며 NUL로 끝나요. let found = unsafe { CStr::from_ptr(ptr) }; Some(found.to_string_lossy().into_owned())}
fn main() { let text = CString::new("안녕, 세상").unwrap(); println!("{:?}", find_from(&text, b',')); println!("{:?}", find_from(&text, b'z'));
// c"..." 리터럴은 NUL이 이미 붙어 있는 &CStr예요. println!("{:?}", find_from(c"rust-lang.org", b'.'));}실행 결과:
Some(", 세상")NoneSome(".org")to_string_lossy()는 C의 바이트를 Rust 텍스트로 바꿔요. C 문자열은 UTF-8이라는 보장이 없어서, 올바르지 않은 UTF-8은 �로 바꿔 넣어요. 차라리 에러를 받고 싶다면 .to_str()을 쓰세요.
#[repr(C)]로 구조체 공유하기
섹션 제목: “#[repr(C)]로 구조체 공유하기”Rust는 공간을 아끼려고 구조체 필드의 순서를 바꿀 수 있어요. C는 절대 그러지 않아요. 필드는 쓴 순서대로 메모리에 놓이고, 사이사이에 패딩(padding)이 들어가요. 예를 들어 u8, u32, u8 필드를 가진 구조체는 지금의 Rust 레이아웃으로는 8바이트지만(Rust가 두 u8을 붙여 놓거든요), C 레이아웃으로는 12바이트예요. C와 Rust가 레이아웃에 대해 서로 다르게 생각하면 C는 엉뚱한 바이트를 읽어요. 그리고 아무도 경고해 주지 않아요.
그러니 구조체가 경계를 넘나들 때는 #[repr(C)]로 C의 레이아웃을 쓰라고 Rust에게 알려 주세요. C의 div 함수는 int 필드 두 개(quot와 rem)를 가진 div_t 구조체를 반환해요.
use std::ffi::c_int;
// C의 typedef struct { int quot; int rem; } div_t;와 맞춰요.#[repr(C)]#[derive(Debug)]struct DivT { quot: c_int, rem: c_int,}
unsafe extern "C" { fn div(numerator: c_int, denominator: c_int) -> DivT;}
fn main() { let result = unsafe { div(17, 5) }; println!("{result:?}"); println!("17 / 5 = 몫 {}, 나머지 {}", result.quot, result.rem);}실행 결과:
DivT { quot: 3, rem: 2 }17 / 5 = 몫 3, 나머지 2C에서 Rust 호출하기
섹션 제목: “C에서 Rust 호출하기”FFI는 양방향으로 동작해요. (블록 없이) extern "C" fn이라고 쓰면 C의 호출 규칙을 따르는 Rust 함수가 생기고, C 코드가 그 함수를 호출할 수 있어요. C의 qsort는 비교 함수를 인자로 받는데, 여기에 Rust 함수를 넘겨줄 수 있어요.
use std::ffi::{c_int, c_void};
unsafe extern "C" { fn qsort( base: *mut c_void, count: usize, size: usize, compare: extern "C" fn(*const c_void, *const c_void) -> c_int, );}
// C가 호출할 Rust 함수예요.extern "C" fn compare_i32(a: *const c_void, b: *const c_void) -> c_int { // SAFETY: qsort는 우리 i32 배열 안을 가리키는 포인터만 넘겨줘요. let (a, b) = unsafe { (*(a as *const i32), *(b as *const i32)) }; a.cmp(&b) as c_int // Less = -1, Equal = 0, Greater = 1}
fn sort_with_c(numbers: &mut [i32]) { unsafe { qsort( numbers.as_mut_ptr() as *mut c_void, numbers.len(), std::mem::size_of::<i32>(), compare_i32, ); }}
fn main() { let mut numbers = [42, 7, 19, -3, 0]; sort_with_c(&mut numbers); println!("{:?}", numbers);}실행 결과:
[-3, 0, 7, 19, 42](실제 코드에서는 그냥 sort()를 호출하면 돼요. 이건 C가 Rust를 다시 호출하는 모습을 보여 주기 위한 예제일 뿐이에요.)
C 프로그램이 여러분의 함수를 이름으로 찾아야 한다면 #[no_mangle]을 붙이세요. 보통 Rust는 서로 다른 크레이트끼리 이름이 절대 겹치지 않도록 이름을 _ZN4main11add_numbers17h...E 같은 형태로 “맹글링(mangling)“해요. #[no_mangle]을 붙이면 add_numbers라는 원래 이름이 그대로 유지돼요.
#[no_mangle]pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 { a + b}2024 에디션 참고: 이 사이트의 Playground 예제는 2021 에디션을 써서 #[no_mangle]이라고 써도 괜찮아요. 2024 에디션(새로 cargo new한 프로젝트의 기본값)에서는 #[unsafe(no_mangle)]이라고 써야 해요. 맹글링하지 않은 같은 이름의 함수가 두 개 있으면 프로그램이 망가지기 때문이에요. 새 표기법은 2021 에디션에서도 동작해요.
C가 링크할 수 있는 라이브러리를 만들려면 Cargo.toml에서 크레이트 타입을 cdylib로 설정하세요.
[lib]crate-type = ["cdylib"]// src/lib.rs (2024 에디션)#[unsafe(no_mangle)]pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 { a + b}mathlib이라는 이름의 크레이트에서 cargo build --release를 실행하면 target/release/libmathlib.so(macOS에서는 .dylib, Windows에서는 .dll)가 만들어져요. 그러면 C 프로그램은 함수를 선언하고 이 라이브러리에 링크해요.
#include <stdio.h>#include <stdint.h>
int32_t add_numbers(int32_t a, int32_t b);
int main(void) { printf("2 + 3 = %d\n", add_numbers(2, 3)); return 0;}$ cc main.c -L target/release -lmathlib -o main$ LD_LIBRARY_PATH=target/release ./main2 + 3 = 5C 헤더를 손으로 쓰다 보면 금방 지겨워져요. cbindgen (영어)이 Rust 코드에서 헤더를 만들어 줘요.
build.rs와 cc로 직접 만든 C 코드 컴파일하기
섹션 제목: “build.rs와 cc로 직접 만든 C 코드 컴파일하기”지금까지는 이미 링크되어 있는 함수만 썼어요. 직접 만든 C 파일을 쓰려면 Cargo가 그 파일을 컴파일하게 하세요. 프로젝트 루트에 있는 build.rs라는 파일은 빌드 스크립트예요. Cargo는 크레이트를 컴파일하기 전에 이 스크립트를 실행해요. cc (영어) 크레이트를 쓰면 몇 줄이면 끝나요.
cargo new hello-ccd hello-ccargo add --build cc#include <stdio.h>
int add_ints(int a, int b) { return a + b;}
void greet(const char *name) { printf("C에서 보내는 인사예요, %s!\n", name); fflush(stdout);}fn main() { // src/greet.c를 정적 라이브러리로 컴파일하고 링크해요. cc::Build::new().file("src/greet.c").compile("greet"); // C 파일이 바뀔 때만 이 스크립트를 다시 실행해요. println!("cargo::rerun-if-changed=src/greet.c");}use std::ffi::{c_char, c_int, CString};
unsafe extern "C" { fn add_ints(a: c_int, b: c_int) -> c_int; fn greet(name: *const c_char);}
fn main() { let sum = unsafe { add_ints(2, 3) }; println!("2 + 3 = {sum}");
let name = CString::new("페리스").unwrap(); unsafe { greet(name.as_ptr()) };}$ cargo run -q2 + 3 = 5C에서 보내는 인사예요, 페리스!C 컴파일러(gcc, clang, 또는 Windows의 MSVC)가 설치되어 있어야 해요. cc가 알아서 찾아 줘요.
함수가 수백 개인 큰 C 라이브러리라면, extern 선언을 하나하나 손으로 쓰는 건 느리고 틀리기도 쉬워요. bindgen (영어)은 C 헤더 파일을 읽어서 Rust 선언을 만들어 줘요. 보통 이것도 build.rs에서 실행해요. crates.io에 있는 -sys 크레이트(openssl-sys 같은) 대부분이 이렇게 만들어져요.
C 너머로: Python, Node.js, 그리고 웹
섹션 제목: “C 너머로: Python, Node.js, 그리고 웹”C가 공용어이기 때문에, 같은 아이디어로 Rust는 다른 많은 언어와도 대화할 수 있어요. 이런 경우에는 extern "C" 코드를 직접 쓸 일이 거의 없어요. 크레이트가 unsafe한 부분을 처리하고, 안전하고 편한 API를 제공해 줘요.
- Python: PyO3 (영어)를 쓰면 Rust로 Python 모듈을 만들 수 있어요(Rust에서 Python을 호출할 수도 있어요). 함께 쓰는 도구인
maturin은 그 모듈을 평범한pip패키지로 빌드하고 배포해 줘요. Polars, pydantic-core 같은 라이브러리가 이걸 써요. - Node.js: napi-rs (영어)는 Rust로 Node.js 네이티브 애드온을 만들고, TypeScript 타입도 자동으로 만들어 줘요.
- C와 C++ 프로젝트: 위에서 본 것처럼
cdylib와 cbindgen (영어)을 써요. - 브라우저: C 의미의 FFI는 아니지만,
wasm-bindgen이 비슷한 역할을 해요. Rust와 WebAssembly를 참고하세요.
자주 하는 실수
섹션 제목: “자주 하는 실수”unsafe 밖에서 외부 함수 호출하기.
unsafe extern "C" { fn abs(x: i32) -> i32;}
fn main() { let n = abs(-7); println!("{n}");}error[E0133]: call to unsafe function `abs` is unsafe and requires unsafe function or block --> src/main.rs:6:13 |6 | let n = abs(-7); | ^^^^^^^ call to unsafe function“unsafe 함수 호출은 unsafe 함수나 블록 안에서만 할 수 있다”는 에러예요. 호출을 unsafe { ... }로 감싸서 고치세요. 아니면 더 좋게, 위에서 본 것처럼 안전한 래퍼 함수로 감싸세요.
이미 사라진 CString을 가리키는 포인터. 컴파일은 되지만, 진짜 버그예요.
use std::ffi::{c_char, CString};
unsafe extern "C" { fn strlen(s: *const c_char) -> usize;}
fn main() { let ptr = CString::new("hello").unwrap().as_ptr(); let len = unsafe { strlen(ptr) }; println!("{len}");}warning: this creates a dangling pointer because temporary `CString` is dropped at end of statement --> src/main.rs:8:46 |8 | let ptr = CString::new("hello").unwrap().as_ptr(); | ------------------------------ ^^^^^^ pointer created here | | | this `CString` is dropped at end of statement | = help: bind the `CString` to a variable such that it outlives the pointer returned by `as_ptr`경고는 “임시 CString이 문장 끝에서 버려지기 때문에 댕글링 포인터가 생긴다”는 뜻이에요. CString이 임시 값이라서 ;에서 해제돼요. 이제 ptr은 해제된 메모리를 가리키고 있어요. CString을 별도의 변수에 담고(let s = CString::new("hello").unwrap();), s가 살아 있는 동안 s.as_ptr()을 호출해서 고치세요. 이 경고는 절대 무시하지 마세요.
2024 에디션 프로젝트에서 그냥 extern "C" 쓰기. 오래된 튜토리얼에서 복사한 코드는 이렇게 실패해요.
error: extern blocks must be unsafe --> src/main.rs:3:1 |3 | / extern "C" {4 | | fn add_ints(a: c_int, b: c_int) -> c_int;5 | | fn greet(name: *const c_char);6 | | } | |_^“extern 블록은 unsafe여야 한다”는 뜻이에요. unsafe extern "C" { ... }라고 써서 고치세요.
#[repr(C)] 깜빡하기. 에러가 전혀 나지 않아요. C가 그냥 쓰레기 값을 읽을 뿐이에요. 경계를 넘나드는 모든 구조체와 열거형에 #[repr(C)]를 붙이세요.
- C 함수는
unsafe extern "C" { ... }블록 안에 선언하고,c_int,c_char같은std::ffi의 C 타입을 써요. - 외부 함수 호출은 모두
unsafe예요. 규칙을 확인하는 안전한 Rust 함수로 한 번 감싸고,// SAFETY:주석을 달아 두세요. - C 문자열은 NUL 바이트에서 끝나요. C에 텍스트를 보낼 때는
CString을, C에서 텍스트를 읽을 때는CStr를 써요. C가 포인터를 쓰는 동안CString을 살려 두세요. - Rust는 필드 순서를 바꿀 수 있으니, C와 공유하는 구조체에는
#[repr(C)]를 써요. extern "C" fn과#[no_mangle](2024 에디션에서는#[unsafe(no_mangle)])을 쓰면 C가 Rust를 호출할 수 있어요. 그런 라이브러리는cdylib로 빌드해요.build.rs와cc로 C 코드를 컴파일하고,bindgen으로 선언을 만들어요. PyO3와 napi-rs는 Rust를 Python과 Node.js에 연결해 줘요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. extern "C" 블록에 있는 함수를 호출할 때마다 unsafe가 필요한 이유는 무엇일까요?
2. CString::new("hel\0lo")를 실행하면 어떻게 될까요?
3. C와 공유하는 구조체에 #[repr(C)]가 필요한 이유는 무엇일까요?
4. 2024 에디션 크레이트에서, C를 위해 함수의 원래 이름을 유지하는 속성은 어떻게 쓸까요?
점수: 0 / 4
더 알아보기
섹션 제목: “더 알아보기”- FFI — The Rustonomicon (영어): 콜백, 링크, 널이 될 수 있는 포인터 등을 깊이 다루는 공식 문서예요.
std::ffi— 표준 라이브러리 문서 (영어):CString,CStr,c_int,c_char등을 예제와 함께 설명해요.- External blocks — The Rust Reference (영어):
extern블록, ABI,safe/unsafe항목에 대한 정확한 규칙이에요. - The bindgen User Guide (영어): C 헤더에서 Rust 바인딩을 만드는 방법이에요.
- The PyO3 User Guide (영어): Rust로 Python 확장을 만드는 방법이에요.