콘텐츠로 이동

Rust FFI: Rust에서 C 함수 호출하기

FFI는 Foreign Function Interface(외부 함수 인터페이스)의 줄임말이에요. Rust가 다른 언어로 쓰인 코드와 대화하는 방법이에요. 그 다른 언어는 거의 언제나 C이거나, C의 규칙을 따르는 무언가예요. C는 거의 모든 언어와 운영 체제가 알아듣는 “공용어”거든요.

모국어가 서로 다르지만 둘 다 영어를 조금 할 줄 아는 두 사람을 떠올려 보세요. C가 바로 그 공용어예요. FFI를 쓰면 Rust에서 오랫동안 다듬어진 C 라이브러리를 쓸 수도 있고, Python, Node.js, C 프로그램이 호출할 수 있는 빠른 Rust 라이브러리를 만들 수도 있어요. 이 페이지는 unsafe Rust를 바탕으로 하니, unsafe 블록이 처음이라면 그 페이지부터 읽어 보세요.

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 바이트를 발견했다”는 뜻이에요.

놀라운 점이 두 가지 있는데, 둘 다 중요해요.

  1. strlen("héllo")는 5가 아니라 6이에요. C는 바이트를 세는데, é는 UTF-8에서 2바이트를 차지하거든요.
  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(", 세상")
None
Some(".org")
▶ 직접 실행해 보기

to_string_lossy()는 C의 바이트를 Rust 텍스트로 바꿔요. C 문자열은 UTF-8이라는 보장이 없어서, 올바르지 않은 UTF-8은 �로 바꿔 넣어요. 차라리 에러를 받고 싶다면 .to_str()을 쓰세요.

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, 나머지 2
▶ 직접 실행해 보기

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;
}
Terminal window
$ cc main.c -L target/release -lmathlib -o main
$ LD_LIBRARY_PATH=target/release ./main
2 + 3 = 5

C 헤더를 손으로 쓰다 보면 금방 지겨워져요. cbindgen (영어)이 Rust 코드에서 헤더를 만들어 줘요.

build.rs와 cc로 직접 만든 C 코드 컴파일하기

섹션 제목: “build.rs와 cc로 직접 만든 C 코드 컴파일하기”

지금까지는 이미 링크되어 있는 함수만 썼어요. 직접 만든 C 파일을 쓰려면 Cargo가 그 파일을 컴파일하게 하세요. 프로젝트 루트에 있는 build.rs라는 파일은 빌드 스크립트예요. Cargo는 크레이트를 컴파일하기 전에 이 스크립트를 실행해요. cc (영어) 크레이트를 쓰면 몇 줄이면 끝나요.

Terminal window
cargo new hello-c
cd hello-c
cargo add --build cc
src/greet.c
#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);
}
build.rs
fn main() {
// src/greet.c를 정적 라이브러리로 컴파일하고 링크해요.
cc::Build::new().file("src/greet.c").compile("greet");
// C 파일이 바뀔 때만 이 스크립트를 다시 실행해요.
println!("cargo::rerun-if-changed=src/greet.c");
}
src/main.rs
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()) };
}
Terminal window
$ cargo run -q
2 + 3 = 5
C에서 보내는 인사예요, 페리스!

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