FFI trong Rust: gọi code C từ Rust
FFI là viết tắt của Foreign Function Interface (giao diện hàm ngoại). Đó là cách Rust nói chuyện với code viết bằng ngôn ngữ khác. Gần như lúc nào ngôn ngữ kia cũng là C, hoặc một thứ tuân theo quy tắc của C, vì C là “ngôn ngữ chung” mà hầu hết các ngôn ngữ và hệ điều hành đều hiểu.
Hãy hình dung hai người không cùng tiếng mẹ đẻ, nhưng cả hai đều biết chút tiếng Anh. C chính là thứ ngôn ngữ chung đó. Với FFI, bạn có thể dùng một thư viện C lâu đời từ Rust, hoặc viết một thư viện Rust thật nhanh để Python, Node.js hay chương trình C gọi tới. Trang này xây trên nền Unsafe trong Rust, nên nếu khối unsafe còn mới với bạn thì hãy đọc trang đó trước.
Gọi một hàm C
Phần tiêu đề “Gọi một hàm C”Thư viện chuẩn của C luôn được liên kết (link) vào chương trình Rust trên Linux, macOS và Windows, nên các hàm của nó là bài thử đầu tiên rất phù hợp. Để gọi một hàm, bạn khai báo nó trong một khối extern "C": tên, tham số và kiểu trả về. Rồi bạn gọi nó bên trong unsafe:
use std::ffi::c_int;
unsafe extern "C" { // Đây là hàm `int abs(int x);` của C, viết lại bằng Rust. fn abs(x: c_int) -> c_int;}
fn main() { let n = unsafe { abs(-42) }; println!("abs(-42) = {n}");}Kết quả:
abs(-42) = 42Vài điều cần để ý:
"C"là tên của ABI, tức bộ quy tắc về cách truyền đối số và trả về giá trị. Quy tắc của C là thứ mà ai cũng đồng ý.c_intlà tên Rust dành chointcủa C. Modulestd::fficó các kiểu nhưc_int,c_longvàc_char, để khai báo của bạn khớp chính xác với C trên mọi nền tảng.- Bạn viết
unsafe extern "C". Ở edition 2024, chữunsafeở đây là bắt buộc, vì bạn đang hứa rằng khai báo này đúng. Code cũ hơn (và edition 2021) thường chỉ viếtextern "C" { ... }. Phiên bản cóunsafechạy được ở cả hai, nên trang này dùng nó ở mọi chỗ.
Vì sao mọi lời gọi đều unsafe, và cách giấu nó đi
Phần tiêu đề “Vì sao mọi lời gọi đều unsafe, và cách giấu nó đi”Trình biên dịch Rust không đọc được code C. Nó không biết abs có thật sự nhận một int không, có thể crash không, hay có giữ lại con trỏ bạn đưa cho nó không. Vì vậy mọi lời gọi tới hàm ngoại đều là unsafe: bạn nhận trách nhiệm dùng nó cho đúng.
Tin vui là bạn chỉ phải suy nghĩ kỹ một lần. Cách làm thường gặp là viết một lớp bọc an toàn (safe wrapper) nhỏ: một hàm Rust bình thường, kiểm tra các quy tắc rồi mới thực hiện lời gọi unsafe. Mọi người khác chỉ việc gọi hàm bọc đó.
Đây là một ví dụ thật. Trong C, abs(INT_MIN) là hành vi không xác định (undefined behavior), vì kết quả quá lớn để nằm vừa trong một int. Hàm bọc của chúng ta kiểm tra trường hợp đó trước:
use std::ffi::c_int;
unsafe extern "C" { fn abs(x: c_int) -> c_int;}
// Một lớp bọc an toàn: người gọi không cần `unsafe`.fn safe_abs(x: i32) -> Option<i32> { // abs(i32::MIN) không có kết quả hợp lệ trong C, nên ta từ chối nó. if x == i32::MIN { return None; } // SAFETY: x không phải i32::MIN, nên abs có kết quả hợp lệ. Some(unsafe { abs(x) })}
fn main() { println!("{:?}", safe_abs(-42)); println!("{:?}", safe_abs(i32::MIN));}Kết quả:
Some(42)NoneChú thích // SAFETY: là một thói quen của Rust đáng học theo. Nó giải thích vì sao khối unsafe cụ thể này ổn, để người đọc sau không phải đoán.
Nếu một hàm C thật sự không có quy tắc nào phải tuân theo với bất kỳ đầu vào nào, bạn có thể đánh dấu nó là safe fn bên trong khối unsafe extern, rồi gọi nó mà không cần unsafe. abs không phải hàm như vậy (nhớ INT_MIN), nên ta giữ nó là unsafe và tự kiểm tra đầu vào.
Chuỗi: CString, CStr và byte NUL
Phần tiêu đề “Chuỗi: CString, CStr và byte NUL”Chuỗi là nơi chứa phần lớn lỗi FFI, vì Rust và C lưu chúng theo cách khác nhau:
String/&strcủa Rust biết độ dài của nó và có thể chứa bất kỳ byte nào, kể cả0.- Chuỗi C chỉ là một con trỏ tới các byte. Chuỗi kết thúc ở byte
0đầu tiên (gọi là NUL). Không có độ dài nào được lưu lại.
Vì vậy bạn không thể truyền trực tiếp một &str cho C. Hãy dùng CString (tiếng Anh) để tạo một bản sao có sở hữu, kết thúc bằng NUL, và .as_ptr() để lấy *const c_char mà C cần:
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 tự thêm NUL vào cuối cho ta. let c_text = CString::new(text).ok()?; // SAFETY: c_text là chuỗi hợp lệ kết thúc bằng NUL và sống tới cuối hàm này. 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!("ok"), Err(e) => println!("error: {e}"), }}Kết quả:
[104, 101, 108, 108, 111, 0]Some(5)Some(6)error: nul byte found in provided data at position: 3Các chuỗi trong ví dụ này được giữ bằng tiếng Anh vì kết quả phụ thuộc vào số byte của chúng. Có hai điều bất ngờ ở đây, và cả hai đều quan trọng:
strlen("héllo")là 6, không phải 5. C đếm byte, vàéchiếm hai byte trong UTF-8.CString::newthất bại nếu văn bản chứa byte0. C sẽ nghĩ chuỗi kết thúc ở đó và lặng lẽ cắt mất"lo". Rust từ chối thay vì để chuyện đó xảy ra; đó là lý doCString::newtrả về mộtResult.
Theo chiều ngược lại, khi C đưa cho bạn một char *, hãy bọc nó trong CStr (tiếng Anh). CStr đối với CString cũng giống như &str đối với String: một góc nhìn được mượn. Hàm strchr của C trả về con trỏ tới ký tự khớp đầu tiên, nên rất hợp để minh họa:
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 báo “không tìm thấy” bằng con trỏ null } // SAFETY: ptr trỏ vào bên trong `text`, vốn còn sống và kết thúc bằng NUL. let found = unsafe { CStr::from_ptr(ptr) }; Some(found.to_string_lossy().into_owned())}
fn main() { let text = CString::new("hello, world").unwrap(); println!("{:?}", find_from(&text, b',')); println!("{:?}", find_from(&text, b'z'));
// Literal c"..." là một &CStr đã có sẵn NUL ở cuối. println!("{:?}", find_from(c"rust-lang.org", b'.'));}Kết quả:
Some(", world")NoneSome(".org")to_string_lossy() biến các byte của C thành văn bản Rust. Nó thay mọi chỗ UTF-8 không hợp lệ bằng �, vì chuỗi C không hứa sẽ là UTF-8. Hãy dùng .to_str() nếu bạn muốn nhận một lỗi thay vào đó.
Dùng chung struct với #[repr(C)]
Phần tiêu đề “Dùng chung struct với #[repr(C)]”Rust được phép sắp xếp lại các trường của struct để tiết kiệm chỗ. C thì không bao giờ: các trường nằm trong bộ nhớ đúng thứ tự bạn viết, với phần đệm (padding) ở giữa. Ví dụ, một struct có các trường u8, u32, u8 hiện chiếm 8 byte với cách bố trí của Rust (Rust dồn hai u8 lại cạnh nhau), nhưng chiếm 12 byte với cách bố trí của C. Nếu C và Rust không thống nhất về cách bố trí, C sẽ đọc sai byte, và không có gì cảnh báo bạn cả.
Vì vậy khi một struct đi qua ranh giới, hãy bảo Rust dùng cách bố trí của C bằng #[repr(C)]. Hàm div của C trả về một struct div_t có hai trường int là quot và rem:
use std::ffi::c_int;
// Khớp với khai báo của 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 = {} dư {}", result.quot, result.rem);}Kết quả:
DivT { quot: 3, rem: 2 }17 / 5 = 3 dư 2Cho C gọi Rust
Phần tiêu đề “Cho C gọi Rust”FFI chạy được theo cả hai chiều. Viết extern "C" fn (không có khối) là bạn có một hàm Rust tuân theo quy tắc gọi hàm của C, để code C có thể gọi nó. Hàm qsort của C nhận một hàm so sánh làm đối số, và ta có thể đưa cho nó một hàm 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, );}
// Một hàm Rust mà C sẽ gọi.extern "C" fn compare_i32(a: *const c_void, b: *const c_void) -> c_int { // SAFETY: qsort chỉ đưa cho ta các con trỏ vào mảng i32 của ta. 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);}Kết quả:
[-3, 0, 7, 19, 42](Trong code thực tế bạn chỉ cần gọi sort(). Ví dụ này chỉ để cho thấy C gọi ngược lại vào Rust.)
Khi một chương trình C cần tìm hàm của bạn theo tên, hãy thêm #[no_mangle]. Bình thường Rust “xáo” (mangle) tên thành thứ gì đó như _ZN4main11add_numbers17h...E để các crate khác nhau không bao giờ đụng tên nhau. #[no_mangle] giữ nguyên tên add_numbers:
#[no_mangle]pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 { a + b}Lưu ý về edition 2024: các ví dụ Playground trên trang này dùng edition 2021, nơi #[no_mangle] vẫn ổn. Ở edition 2024 (mặc định cho một dự án mới tạo bằng cargo new), nó phải được viết là #[unsafe(no_mangle)], vì hai hàm có cùng tên không bị xáo sẽ làm hỏng chương trình. Cách viết mới này cũng chạy được ở edition 2021.
Để build một thư viện mà C có thể liên kết tới, đặt kiểu crate là cdylib trong Cargo.toml:
[lib]crate-type = ["cdylib"]// src/lib.rs (edition 2024)#[unsafe(no_mangle)]pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 { a + b}cargo build --release tạo ra target/release/libmathlib.so (.dylib trên macOS, .dll trên Windows) cho một crate tên mathlib. Sau đó chương trình C khai báo hàm và liên kết với thư viện:
#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 = 5Viết tay file header C đó rất nhanh chán. cbindgen (tiếng Anh) tạo nó tự động từ code Rust của bạn.
Biên dịch code C của riêng bạn với build.rs và cc
Phần tiêu đề “Biên dịch code C của riêng bạn với build.rs và cc”Đến giờ ta mới chỉ dùng những hàm đã được liên kết sẵn. Để dùng file C của riêng bạn, hãy để Cargo biên dịch nó. Một file tên build.rs ở thư mục gốc dự án là một build script: Cargo chạy nó trước khi biên dịch crate của bạn. Crate cc (tiếng Anh) biến việc đó thành vài dòng code.
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("Hello from C, %s!\n", name); fflush(stdout);}fn main() { // Biên dịch src/greet.c thành thư viện tĩnh và liên kết nó vào. cc::Build::new().file("src/greet.c").compile("greet"); // Chỉ chạy lại script này khi file C thay đổi. 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("Ferris").unwrap(); unsafe { greet(name.as_ptr()) };}$ cargo run -q2 + 3 = 5Hello from C, Ferris!Bạn cần cài sẵn một trình biên dịch C (gcc, clang, hoặc MSVC trên Windows); cc sẽ tự tìm nó.
Với một thư viện C lớn có hàng trăm hàm, tự viết từng khai báo extern vừa chậm vừa dễ sai. bindgen (tiếng Anh) đọc các file header C và tạo khai báo Rust giúp bạn, thường cũng từ build.rs. Phần lớn các crate -sys trên crates.io (như openssl-sys) được làm theo cách này.
Không chỉ C: Python, Node.js và web
Phần tiêu đề “Không chỉ C: Python, Node.js và web”Vì C là ngôn ngữ chung, cùng những ý tưởng này cho phép Rust nói chuyện với nhiều ngôn ngữ khác. Bạn hiếm khi phải viết code extern "C" thô cho chúng; một crate sẽ làm phần việc unsafe và cho bạn một API an toàn, dễ dùng:
- Python: PyO3 (tiếng Anh) cho phép bạn viết module Python bằng Rust (và gọi Python từ Rust). Công cụ đi kèm
maturinbuild và phát hành chúng như các góipipbình thường. Các thư viện như Polars và pydantic-core dùng nó. - Node.js: napi-rs (tiếng Anh) build add-on Node.js native bằng Rust, kèm kiểu TypeScript được tạo tự động.
- Dự án C và C++:
cdylibcộng với cbindgen (tiếng Anh), như ở trên. - Trình duyệt: đây không phải FFI theo nghĩa C, nhưng
wasm-bindgenđóng vai trò tương tự. Xem WebAssembly.
Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”Gọi hàm ngoại bên ngoài 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 functionThông báo lỗi nói rằng gọi hàm unsafe abs cần nằm trong một hàm hoặc khối unsafe. Cách sửa: bọc lời gọi trong unsafe { ... }, hoặc tốt hơn, trong một hàm bọc an toàn như ở trên.
Con trỏ tới một CString đã bị hủy. Code này biên dịch được, nhưng là một lỗi thật:
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`Cảnh báo này nói rằng con trỏ sẽ bị treo (dangling), vì CString tạm thời bị hủy ở cuối câu lệnh. CString là một giá trị tạm, nên nó được giải phóng ngay tại dấu ;. Lúc này ptr trỏ vào vùng nhớ đã bị giải phóng. Cách sửa: giữ CString trong một biến riêng (let s = CString::new("hello").unwrap();) và gọi s.as_ptr() khi s còn sống. Đừng bao giờ bỏ qua cảnh báo này.
extern "C" trơn trong dự án edition 2024. Code chép từ các bài hướng dẫn cũ sẽ báo lỗi như sau:
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 | | } | |_^Trình biên dịch nói rằng khối extern phải là unsafe. Cách sửa: viết unsafe extern "C" { ... }.
Quên #[repr(C)]. Không có lỗi nào cả, C chỉ đơn giản đọc ra rác. Hãy đặt #[repr(C)] lên mọi struct và enum đi qua ranh giới.
Tóm tắt
Phần tiêu đề “Tóm tắt”- Khai báo hàm C trong một khối
unsafe extern "C" { ... }, dùng các kiểu C trongstd::ffinhưc_intvàc_char. - Mọi lời gọi hàm ngoại đều là
unsafe. Hãy bọc nó một lần trong một hàm Rust an toàn có kiểm tra quy tắc, kèm chú thích// SAFETY:. - Chuỗi C kết thúc ở byte NUL. Dùng
CStringđể gửi văn bản cho C vàCStrđể đọc văn bản từ C. GiữCStringcòn sống trong lúc C dùng con trỏ. - Dùng
#[repr(C)]cho struct dùng chung với C, vì Rust có thể sắp xếp lại các trường. extern "C" fncộng với#[no_mangle](#[unsafe(no_mangle)]ở edition 2024) cho phép C gọi Rust;cdylibbuild một thư viện cho việc đó.build.rs+ccbiên dịch code C của bạn;bindgentạo khai báo; PyO3 và napi-rs nối Rust với Python và Node.js.
Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Kiểm tra nhanh
1. Vì sao mọi lời gọi tới một hàm trong khối extern "C" đều unsafe?
2. Điều gì xảy ra với CString::new("hel\0lo")?
3. Vì sao struct dùng chung với C cần #[repr(C)]?
4. Trong một crate edition 2024, bạn viết thuộc tính giữ nguyên tên hàm cho C như thế nào?
Điểm: 0 / 4
Đọc thêm
Phần tiêu đề “Đọc thêm”- FFI — The Rustonomicon (tiếng Anh): tài liệu chính thức đi sâu vào callback, liên kết, con trỏ có thể null và nhiều thứ khác.
std::ffi— tài liệu std (tiếng Anh):CString,CStr,c_int,c_charvà các kiểu liên quan, kèm ví dụ.- External blocks — The Rust Reference (tiếng Anh): quy tắc chính xác cho khối
extern, ABI và các mụcsafe/unsafe. - The bindgen User Guide (tiếng Anh): tạo binding Rust từ header C.
- The PyO3 User Guide (tiếng Anh): viết extension Python bằng Rust.