Rust 與 C 傳遞字符串的 7 種方式!

關注「Rust 編程指北」,一起學習 Rust,給未來投資

摘要:Rust 得以在編程語言中火速崛起的原因之一,就是它能夠與 C 語言進行互操作。因此在本文中,作者介紹了在 Rust 與 C 之間傳遞字符串的七種方式,這些方法不僅可用於傳遞字符串,也可用於其他數據。

原文鏈接:https://dev.to/kgrech/7-ways-to-pass-a-string-between-rust-and-c-4ieb

聲明:本文爲 CSDN 翻譯,未經授權,禁止轉載。

作者 | Konstantin Grechishchev

譯者 | 彎月

出品 | CSDN(ID:CSDNnews)

Rust 和 C 語言能夠互操作,這恐怕是 Rust 最不可思議的功能之一。能夠在 C 語言中調用安全地 Rust 代碼,能夠在 Rust 中通過 C 接口使用一些流行庫,正是 Rust 能夠在整個行業迅速流行的原因之一。此外,我們還可以通過 C 接口,用不同的語言編寫代碼,這樣凡是能夠調用 C 的語言都可以使用這些代碼。

FFI 接口的編寫難度很高,新手很難成功。如何處理 into_raw 和 as_ptr 方法,纔不會導致內存泄漏或引發安全漏洞?在編寫代碼時,我們難免會使用一些不安全的關鍵字,這會令我們不安。

我將在本文中介紹有關 FFI 接口的內存處理,並提供一些我在項目中使用過的有效模式。

(注意:這裏我以字符串作爲例進行說明,實際上這些技術也適用於將字節數組或指針傳輸到 Box 或 Arc 類型的堆結構上。)

基本規則

在學習如何實現 FFI 函數之前,我想先介紹一些基本的規則。你應該在設計的過程中牢記這些規則,因爲缺少其中任何一個都可能引發各種 bug,最終導致函數全面崩潰或內存泄漏。

規則 1:一個指針,一個分配器

你可能會認爲內存分配只不過是調用一些操作系統 API。然而實際上,獲取一大塊內存,寫入緩存區是一項複雜且開銷很大的操作。編譯器和庫開發人員很想應用各種優化,比如獲得更大的內存塊以避免頻繁調用操作系統 API,而且實現方式也各異。

你不應該假設調用庫的人會使用某種類型的內存分配器。他們不一定會使用 malloc,而且也不會受限於 libc。換句話說,Rust 代碼分配的內存應該由 Rust 代碼刪除,越過 FFI 邊界獲取的指針應該交還給創建者去釋放。如果使用 malloc 分配內存,請不要將其轉換爲 Box,然後 drop。我們應該通過調用 Box::into_raw() 獲取的指針,不應該通過調用 free 來釋放。

規則 2:所有權

Rust 是一種內存安全語言,會明確指出所有權。如果在代碼中看到 Box,你就知道在你 drop Box 之後,存儲 Any 的內存會被立即釋放。相反,如果看到 void*,則無法判斷是應該調用 free 釋放內存,還是由其他人來釋放這些內存(或許這些內存壓根不需要被釋放,因爲它指向堆棧)。

在 Rust 中,將結構轉化爲原始指針的方法有一種命名約定。標準的庫,比如 Box、Arc、CStr 和 CString 提供了 as_ptr,還有一對 into_raw 和 from_raw 方法。並非每個結構都提供這三種方法,因此實際情況更加混亂。

我們來具體討論一下這些庫。首先是 CString,它提供以上三種方法,as_ptr 和 into_raw 方法都提供了相同類型的指針。然而,就像上面提到的 void * 一樣,這些指針的所有權略有不同。

as_ptr 方法以引用的形式接受 & self。這意味着,在 as_ptr 返回後,CString 實例依然會留在棧上,而數據的所有權也會保留。換句話說,返回的指針指向的數據仍歸 CString 實例所有。一旦刪除實例,指針就會懸空。在刪除 CString 實例後,你永遠不應再使用此指針。在安全的 Rust 中,指針的此屬性由引用的生命週期(類似於指針)表示,並由編譯器控制,但如果使用原始指針,一切都將變成未知。

與 as_ptr 不同,into_raw 會通過值接受並銷燬 self。那麼,會不會破壞釋放內存?事實證明,into_raw 不會調用 drop 方法。它會創建一個自己擁有的指針,然後將 Rust 分配器提供的內存塊 “泄漏” 出來,脫離 Rust 編譯器的控制範圍。如果你只刪除該指針,而不調用 from_raw 方法,就會引發內存泄漏。但是,它永遠不會懸空(除非在調用 from_raw 之前修改或克隆它)。

如果你想讓 C 暫時 “借用”Rust 的內存,則應該使用 as_ptr。它有一個巨大的優勢,因爲 C 代碼不必釋放這塊內存,而且還會限制指針的生命週期。但請不要將這個指針保存到某個全局結構中,或將其傳遞給另一個線程,也不應該將這樣的指針作爲函數調用的結果返回。

into_raw 方法會將數據的所有權轉移到 C 中。只要代碼需要,它就可以保留指針,但請務必記得將它轉移回 Rust 刪除。

字符串的內存表示

不幸的是,在 Rust 和語言 C 中,字符串的表示方式不同。C 的字符串通常是 char * 指針,指向以 /0 結尾的 char 數組。而 Rust 則會保存字符數組及其長度。

由於這個原因,Rust 的 String 和 str 類型與原始指針之間不應該互相轉換。你應該使用 CString 和 CStr 中間類型來實現。通常,我們使用 CString 將 Rust 字符串傳遞給 C 代碼,使用 CStr 將 C 的字符串轉換爲 Rust 的 & str。請注意,這種轉換並不一定會複製底層的數據。因此,通過 CStr 獲得的 & str 會指向 C 分配的數組,而且它的生命週期與指針綁定。

注意:String:new 會複製數據,但 CStr::new 不會。

項目設置

如何將 Rust 和 C 連接起來

網上有很多關於如何構建 C 代碼,以及使用 build.rs 將 C 連接到 Rust crate 的資料,但是如何將 Rust 代碼添加到 C 項目的文章卻很少。相比之下,我更喜歡用 C 語言實現主要功能,並使用 CMake 作爲構建系統。我希望 CMake 項目將 Rust crate 作爲庫,並根據 Rust 代碼生成 C 的頭文件。

通過 CMake 運行 Cargo

我建立了一個簡單的 CMake 3 控制檯應用程序。

首先,我們需要定義構建 Rust 庫的命令和保存 Rust 成果物的位置:

if (CMAKE_BUILD_TYPE STREQUAL "Debug")
set(CARGO_CMD RUSTFLAGS=-Zsanitizer=address cargo build -Zbuild-std --target x86_64-unknown-linux-gnu)
set(TARGET_DIR "x86_64-unknown-linux-gnu/debug")
else ()
set(CARGO_CMD cargo build --release)
set(TARGET_DIR "release")
endif ()
SET(LIB_FILE "${CMAKE_CURRENT_BINARY_DIR}/${TARGET_DIR}/librust_lib.a")

對於熟悉 Rust 的人來說,這個構建 crate 調試版本的命令可能看起來有點古怪。我們完全可以使用 cargo build 來代替這個命令,但是我想利用 Rust 不穩定的地址清理器功能來確保內存不會被泄漏。

其次,我們需要自定義命令和目標,讓它們根據命令輸出結果。然後,我們可以定義一個名爲 rust_lib 的靜態導入庫,並根據目標構建它:

add_custom_command(OUTPUT ${LIB_FILE}
COMMENT "Compiling rust module"
COMMAND CARGO_TARGET_DIR=${CMAKE_CURRENT_BINARY_DIR} ${CARGO_CMD}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/rust_lib)
add_custom_target(rust_lib_target DEPENDS ${LIB_FILE})
add_library(rust_lib STATIC IMPORTED GLOBAL)
add_dependencies(rust_lib rust_lib_target)

最後,我們可以使用將二進制文件與 Rust 庫(以及其他必需的系統庫)鏈接在一起。我們還在 C 代碼中啓用了地址清理器:

target_compile_options(rust_c_interop PRIVATE -fno-omit-frame-pointer -fsanitize=address)
target_link_libraries(rust_c_interop PRIVATE Threads::Threads rust_lib ${CMAKE_DL_LIBS} -fno-omit-frame-pointer -fsanitize=address)

如此一來,運行 CMake 即可自動構建 rust create,並與之鏈接。但是,我們還需要從 C 代碼中調用 Rust 的方法。

生成 C 的頭文件,並將它們添加到 CMake 項目中

最簡單的在 Rust 代碼中獲取 C 頭文件的方法是使用 cbingen 庫。

我們可以將以下代碼添加到 Rust crate 的 build.rs 文件中,以檢測 Rust 中定義的所有 extern "C" 函數,爲其生成頭文件定義,並保存到 include / 目錄下:

let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let package_name = env::var("CARGO_PKG_NAME").unwrap();
let output_file = PathBuf::from(&crate_dir)
.join("include")
.join(format!("{}.h", package_name));
cbindgen::generate(&crate_dir)
.unwrap()
.write_to_file(output_file);

此外,我們還應該在 Rust crate 的根目錄中創建 cbindgen.toml 文件,並指明 language = "C"。

接下來,CMake 需要在 Rust crate 的 include 文件夾中查找頭文件:

SET(LIB_HEADER_FOLDER "${CMAKE_CURRENT_SOURCE_DIR}/rust_lib/include")
set_target_properties(rust_lib
PROPERTIES
IMPORTED_LOCATION ${LIB_FILE}
INTERFACE_INCLUDE_DIRECTORIES ${LIB_HEADER_FOLDER})

**將 Rust 字符串傳遞到 C 的五種方式
**

一切準備就緒。下面,我們來看看如何從 Rust 的數據中獲取字符串,然後在 C 中使用。我們怎麼才能安全地傳遞字符串,同時不會造成內存泄漏?

方法 1:提供創建和刪除方法

如果不知道 C 代碼需要使用字符串多久,就可以採用這種方式。爲了將所有權移交給 C,我們可以構建 CString 對象,並使用 into_raw 將其轉換爲指針。free 方法只需要構建 CString,再 drop 這個對象就可以釋放內存:

#[no_mangle]
pub extern fn create_string() -> *const c_char {
let c_string = CString::new(STRING).expect("CString::new failed");
c_string.into_raw() // Move ownership to C
}
/// # Safety
/// The ptr should be a valid pointer to the string allocated by rust
#[no_mangle]
pub unsafe extern fn free_string(ptr: *const c_char) {
// Take the ownership back to rust and drop the owner
let _ = CString::from_raw(ptr as *mut _);
}

不要忘記調用 free_string,以避免內存泄漏:

const char* rust_string = create_string();
printf("1. Printed from C: %s\n", rust_string);
free_string(rust_string);

不要調用 libc free 方法,也不要嘗試修改此類指針指向的數據。

這個方法雖然效果很好,但如果我們想在使用內存時釋放 Rust 庫,或者在不知道 Rust 庫的代碼中釋放內存,該怎麼辦?你可以考慮以下三種方法。

方法 2:分配緩衝區並複製數據

還記得規則 1 嗎?如果我們想在 C 中使用 free 方法釋放內存,就應該使用 malloc 分配內存。但是,Rust 怎麼會知道 malloc 呢?一種解決方案是,“問一問”Rust 需要多少內存,然後爲它分配一個緩衝區:

size_t len = get_string_len();
char *buffer = malloc(len);
copy_string(buffer);
printf("4. Printed from C: %s\n", buffer);
free(buffer);

Rust 只需要告訴我們緩衝區的大小,並小心翼翼地將 Rust 字符串複製到其中(注意不要漏掉末尾的字節 0):

#[no_mangle]
pub extern fn get_string_len() -> usize {
STRING.as_bytes().len() + 1
}
/// # Safety
/// The ptr should be a valid pointer to the buffer of required size
#[no_mangle]
pub unsafe extern fn copy_string(ptr: *mut c_char) {
let bytes = STRING.as_bytes();
let len = bytes.len();
std::ptr::copy(STRING.as_bytes().as_ptr().cast(), ptr, len);
std::ptr::write(ptr.offset(len as isize) as *mut u8, 0u8);
}

這個方法的優勢在於,我們不必實現 free_string,可以直接使用 free。還有一個優點是,如有需要 C 代碼也可以修改緩衝區(這就是我們使用 * mut c_char,而不是 * const c_char 的原因)。

問題在於,我們仍然需要實現額外的方法 get_string_len,而且還需要分配一塊新內存,並複製數據(但其實 CString::new 也需要)。

如果你想將 Rust 字符串移動到 C 函數棧上分配的緩衝區,也可以使用此方法,但應該確保有足夠的空間。

方法 3:將內存分配器方法傳遞給 Rust

我們可以避免使用 get_string_len 方法嗎?有沒有其他方法在 Rust 中分配內存?一種簡單的方法是將分配內存函數傳遞給 Rust:

type Allocator = unsafe extern fn(usize) -> *mut c_void;
/// # Safety
/// The allocator function should return a pointer to a valid buffer
#[no_mangle]
pub unsafe extern fn get_string_with_allocator(allocator: Allocator) -> *mut c_char {
let ptr: *mut c_char = allocator(get_string_len()).cast();
copy_string(ptr);
ptr
}

上述示例使用了的 copy_string,接下來我們可以使用 get_string_with_allocator:

char* rust_string_3 = get_string_with_allocator(malloc);
printf("3. Printed from C: %s\n", rust_string_3);
free(rust_string_3);

這個方法與方法 2 相同,而且優缺點也一樣。

但是,我們現在必須傳遞額外的參數 allocator。其實,我們可以進行一些優化,將其保存到某個全局變量中,就可以避免向每個函數傳遞。

方法 4:從 Rust 調用 glibc

如果我們的 C 代碼會使用 malloc/free 來分配內存,則可以嘗試在 Rust 代碼中引入 libc crate,儘管這種方式有點冒險:

#[no_mangle]
pub unsafe extern fn get_string_with_malloc() -> *mut c_char {
let ptr: *mut c_char = libc::malloc(get_string_len()).cast();
copy_string(ptr);
ptr
}

C 代碼不變:

char* rust_string_4 = get_string_with_malloc();
printf("4. Printed from C: %s\n", rust_string_4);
free(rust_string_4);

在這種方式下,我們不需要提供分配內存的方法,但是 C 代碼也會受到很多限制。我們最好做好文檔記錄,儘量避免使用這種方式,除非我們確定百分百安全。

方法 5:借用 Rust 字符串

以上這些方法都是將數據的所有權傳遞給 C。但如果我們不需要傳遞所有權呢?舉個例子,Rust 代碼需要同步調用 C 方法,並向它傳遞一些數據。這時,可以考慮使用 CString 的 as_ptr:

type Callback = unsafe extern fn(*const c_char);
#[no_mangle]
pub unsafe extern fn get_string_in_callback(callback: Callback) {
let c_string = CString::new(STRING).expect("CString::new failed");
// as_ptr() keeps ownership in rust unlike into_raw()
callback(c_string.as_ptr())
}

不幸的是,即便在這種情況下,CString:new 也會複製數據(因爲它需要在末尾添加字節 0)。

C 代碼如下:

void callback(const char* string) {
printf("5. Printed from C: %s\n", string);
}
int main() {
get_string_in_callback(callback);
return 0;
}

如果有一個生命週期已知的 C 指針,則我們應該優先使用這種方式,因爲它可以保證沒有內存泄漏。

將 C 字符串傳遞給 Rust 的兩種方法

下面,我們來介紹兩種反向操作的方法,即將 C 的字符串轉換爲 Rust 的類型。主要方法有以下兩種:

這兩種方法的示例相同,因爲它們非常相似。實際上,方法 2 需要先使用方法 1。

C 代碼如下。我們在堆上分配數據,但實際上我們也可以將指針傳遞給棧:

strcpy(test, "Hello from C");
print_c_string(test);
free(test);

Rust 的實現如下:

#[no_mangle]
/// # Safety
/// The ptr should be a pointer to valid String
pub unsafe extern fn print_c_string(ptr: *const c_char) {
let c_str = CStr::from_ptr(ptr);
let rust_str = c_str.to_str().expect("Bad encoding");
// calling libc::free(ptr as *mut _); causes use after free vulnerability
println!("1. Printed from rust: {}", rust_str);
let owned = rust_str.to_owned();
// calling libc::free(ptr as *mut _); does not cause after free vulnerability
println!("2. Printed from rust: {}", owned);
}

注意,此處我們使用了 CStr,而不是 CString。如果不是 CString::into_raw 創建的指針,請不要調用 CString:from_raw。

這裏還需要注意,&str 引用的生命週期不是 “靜態” 的,而是綁定到了 c_str 對象方法。Rust 編譯器會阻止你在該方法之外返回 & str,或將其移動到全局變量 / 另一個線程,因爲一旦 C 代碼釋放內存,&str 引用就會變成非法。

如果需要在 Rust 中長時間保留數據的所有權,只需調用 to_owned() 即可獲取字符串的副本。如果不想複製,則可以使用 CStr,但我們應該確保 C 代碼不會在字符串還在使用期間釋放內存。

總結

在本文中,我們討論了 Rust 與 C 之間的互操作,並介紹了幾種跨 FFI 邊界傳遞數據的方法。這些方法不僅可用於傳遞字符串,也可用於其他數據,或者利用 FFI 將 Rust 連接到其他編程語言。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UDLRKo8uMdOQW2BsPh3rkQ