Bỏ qua để đến nội dung

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.

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) = 42
▶ Tự chạy thử

Và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_int là tên Rust dành cho int của C. Module std::ffi có các kiểu như c_int, c_long và 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ết extern "C" { ... }. Phiên bản có unsafe chạ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)
None
▶ Tự chạy thử

Chú 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 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 / &str củ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: 3
▶ Tự chạy thử

Cá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:

  1. strlen("héllo") là 6, không phải 5. C đếm byte, và é chiếm hai byte trong UTF-8.
  2. CString::new thất bại nếu văn bản chứa byte 0. 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ý do CString::new trả về một Result.

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")
None
Some(".org")
▶ Tự chạy thử

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 đó.

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ư 2
▶ Tự chạy thử

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]
▶ Tự chạy thử

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

Viế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.

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("Hello from C, %s!\n", name);
fflush(stdout);
}
build.rs
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");
}
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("Ferris").unwrap();
unsafe { greet(name.as_ptr()) };
}
Terminal window
$ cargo run -q
2 + 3 = 5
Hello 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.

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 maturin build và phát hành chúng như các gói pip bì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++: cdylib cộ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.

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 function

Thô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.

  • Khai báo hàm C trong một khối unsafe extern "C" { ... }, dùng các kiểu C trong std::ffi như c_int và 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ữ CString cò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" fn cộng với #[no_mangle] (#[unsafe(no_mangle)] ở edition 2024) cho phép C gọi Rust; cdylib build một thư viện cho việc đó.
  • build.rs + cc biên dịch code C của bạn; bindgen tạo khai báo; PyO3 và napi-rs nối Rust với Python và Node.js.

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