技术创想105 | 触碰unsafe禁区:链接Rust与C

文摘   科技   2024-05-16 17:20   北京  

概述

Rust是一门FFI友好语言。但在与外部语言交互过程中,不可避免会触碰到Rust与其他语言在memory management上理念的不同。

我最近在使用Rust实现Openconnect C Library的safe wrapper过程中,逐渐掌握了如何正确链接C库、如何跨语言调用,并且也加深了对Rust - C的内存管理细节。

本文是进阶指南,假设你已经对C语言的内存分配、编译链接有最基本的概念,并且已经读完Rust官方教程《The Rust Programming Language》,知晓ownership和borrow checker的概念。

使用cc crate编译并链接C文件

1.1 引入cc crate

cc crate(https://crates.io/crates/cc)是一个使用系统cc命令(对应到gcc或者clang)编译c/c++代码到目标文件的Rust库。它将编译后的目标文件输出到OUT_DIR,以方便在链接期进行链接。

我们在Cargo.toml中引用cc crate

[package]name = "example"
# ...
[build-dependencies]cc = "^1.0.94"

1.2 创建一个c程序

在项目根目录下创建c-src文件夹,其中包括lib.c和lib.h文件,我们以int add(int a, int b)为例,将c的方法提供给rust使用。

// c-src/lib.h
int add(int a, int b);
// c-src/lib.c
int add(int a, int b) { return a + b;}

1.3 编译c程序到目标文件

在项目根目录下创建build.rs,其作用为自定义Rust项目的构建脚本(https://doc.rust-lang.org/cargo/reference/build-scripts.html)

fn main() {    println!("cargo:rerun-if-changed=c-src/lib.c");    println!("cargo:rerun-if-changed=c-src/lib.h");
cc::Build::new().file("c-src/lib.c").compile("ffi");}

现在我们尝试运行cargo build,我们便可以在target/debug/build下面以项目名开头的文件夹中找到好几个out目录,比如我们这里package name是example,那么在以example-xxxx命名的几个目录中,可以找得到某个目录存在一个out文件夹,下面存放了xxxx-lib.o的文件。该文件就是我们用cc crate构建出来的c目标文件。它可以在后期自动被rust调用的linker发现并且进行链接。

cc crate也可以编译c++代码,具体操作请参考cc crate的官方文档。

1.4 定义Rust接口并调用

为了能让Rust调用上文中的add方法,我们需要在Rust中定义方法的ffi接口。我们使用extern "C",并规定入参和出参都是::std::os::raw::c_int类型(它实际上会被Rust标准库识别为i32)

extern "C" {    pub fn add(a: ::std::os::raw::c_int, b: ::std::os::raw::c_int) -> ::std::os::raw::c_int;}
#[test]fn test_add() { let result = unsafe { add(1, 2) }; assert_eq!(result, 3); // pass}

接下来我们使用unsafe block去调用add方法,执行build之后,我们使用C实现的方法就可以被链接器链接到Rust侧,即Rust可以正确调用该方法了。

使用rust-bindgen自动生成ffi定义

对于上述方式我们依然需要人工定义Rust侧的ffi接口,我们可以有更方便的方式——使用rust-bindgen(https://rust-lang.github.io/rust-bindgen/)自动生成add接口的方法。

引入bindgen

[build-dependencies]bindgen = "^0.69.4"

在上文的build.rs中增加bindgen生成代码

fn main() {    println!("cargo:rerun-if-changed=c-src/lib.c");    println!("cargo:rerun-if-changed=c-src/lib.h");
cc::Build::new().file("c-src/lib.c").compile("ffi");
bindgen::Builder::default() .header("c-src/lib.h") .generate() .expect("Unable to generate bindings") .write_to_file("src/bindings.rs") .expect("Couldn't write bindings!");}

执行cargo build之后,我们可以看到src目录下出现了bindings.rs的文件

/* automatically generated by rust-bindgen 0.69.4 */
extern "C" { pub fn add(a: ::std::os::raw::c_int, b: ::std::os::raw::c_int) -> ::std::os::raw::c_int;}

同理我们也可以在Rust侧调用c的方法

mod bindings;
#[test]fn test_add() { let result = unsafe { bindings::add(1, 2) }; assert_eq!(result, 3);}

注意,这里的rust-bindgen不是编译、链接c程序的工具,它的目的只是为了生成Rust侧绑定接口。

更复杂的情况?

上文我只是用cc crate介绍了如何在Rust构建脚本中编译c代码。但真实场景中我们通常需要将一个开源的c/c++库引入使用。对于在Rust构建中链接c/c++库,基本原理和方法与我们在c/c++中去链接外部库是类似的。
这里其实没有一个通行的办法。我们需要分析具体场景。下面我列举cmake或者gnu automake的例子。
  1. 提供了cmake的情况
    1.   我们需要检查CMakeLists.txt中是否支持了以static或者shared目标的编译方式。如果支持了的话,我们需要先试用cmake命令将该项目编译至动态/静态库,并且在build.rs中增加link search,并且增加库名到链接器。
  2. 提供了automake/autoconf的情况
    1.   与提供了cmake类似,我们需要了解项目是否支持编译到static或者shared目标。我们需要依据项目提供的概述,生成configure文件,并从configure生成到Makefile,将项目编译至动态/静态库,后续改动build.rs与cmake类似。

对于build script增加link search和link lib,可以参考build script的官方文档

https://doc.rust-lang.org/stable/cargo/reference/build-scripts.html#rustc-link-lib

你也可以模仿现有的Rust sys binding库,比如我最近在实践的openconnect-rs

https://github.com/hlhr202/Openconnect-RS/blob/main/crates/openconnect-sys/build/main.rs

世界的本质是不安全的

上面的一系列操作只是迈出了第一步,而难度更高的山峰等着我们攀爬——内存管理。内存管理是c/c++的必修课,更是unsafe rust的进阶训练。本文的讲解不会更多纠结于细节,而是让我们尝试去理解Rust设计unsafe的理由。

Rust从诞生之初就宣传它以编译期内存管理来解决c/c++的复杂度。具体地说,通过所有权(ownership)、借用规则(borrow checker),使得需要分配(malloc)的资源的生命周期,在编译期就能被计算出来。这样编译器会帮助我们自动插入free方法。在Rust中实现Drop方法就类似在c++中实现析构函数。Rust这套机制非常类似于C++的RAII,不过由于C++对于变量赋值默认为copy的做法,其实要实现完全安全的内存管理并不是很方便。

4.1 内存布局

刚进入Low level的世界,我们需要理解的第一件事情是"Where is the bytes?"。Rust具有对内存的完全控制,可以使用offset方式去位移指针(原始指针)。

限于篇幅问题,我们这里不过多纠结Rust内存对齐方式细节。Rust对于两个具有相同类型定义的struct,并不100%保证他们的数据在内存上具有相同的字段排序。对于C而言,同样的compiler和同样的构建环境,两个具有相同定义的struct的内存分布是确定的。由于Rust中定义的struct和C struct的内存布局有很大不同,所以Rust在做面向C-ffi的操作时,需要一个兼容C的“确定的”内存布局。例如:

#[repr(C)]struct CompatStruct {    a: ::std::os::raw::c_int,    b: *const ::std::os::raw::c_char,}

这里的repr(C)会告诉rust编译器,下面的struct需要使用C的内存布局。该Rust定义可以一比一等同于C中的

struct CompatStruct {    int a;    const char* b;};

4.2 原始指针

你可能注意到了,上文的CompatStruct - b是一个对应到c char的原始指针,在Rust中,它等效于*const i8,这是由于c语言中,一个char会占用8位内存空间并表现为一个signed integer。

Rust中对于原始指针的绝大部分操作都是非安全的,因为Rust编译器不会为原始指针检查生命周期和所有权,也不会插入自动释放。举例,如果我们希望在Rust中定义一个C的char,需要怎么做呢?

let str: [i8; 2] = [97, 0]; // "a\0",a的ASCII code是97let c_str = b.as_ptr(); // 将i8 array转换为原始指针,*const i8println!(unsafe { ::std::ffi::CStr::from_ptr(c_str).to_str().unwrap() }); // 转换回rust的&str并且输出它

这里,我们如果想使用c_str这个类型为*const i8的原始指针,我们需要将CStr::from_ptr包裹进unsafe block才能通过编译。Rust认为我们使用原始指针,大部分目的是人为操作内存,或者将其在Rust - C之间传递。那么这里可能出现的一个问题就是,C传递给Rust的原始指针,有没有存在悬垂指针或者空指针的问题呢?或者说我们使用某些Rust当中可以对原始指针take ownership的方法,是否会在Rust中释放掉C当中依然会使用到的内存空间呢?

举例,对于raw string pointer,Rust中有两个不同的转换方式。

  1. ::std::ffi::CStr::from_ptr(some_raw_ptr: *const i8),该方式不会获取some_raw_ptr的所有权,而只是创造了一次借用(或者可以理解为视图)。该原始指针最后依然需要在某个地方手动释放。CStr类似于Rust中的&str

  2. ::std::ffi::CString::from_raw(some_raw_ptr: *mut i8),该方式会获取some_raw_ptr的所有权,在变量退出作用域之后,会在Rust中自动释放,之后再使用该原始指针则是未定义行为(UB)。CString类似于Rust中的String

对于需要与外部语言沟通的场景,Rust无法证明对内存的操作是安全的,所以它强制要求我们使用unsafe block来绕过编译器证明的过程。穿越safe barrier的安全保障由我们自己完成。

4.3 管理混乱

可能看到上文的状况,你的血压开始升高了,你不禁开始思考,我们要怎样才能管理好这样的混乱?

其实策略总结起来还是比较简单的,没错如果你经常参与多人的C/C++项目,你会知道如何遵守管理内存的规则。这对于Rust而言其实是类似的。我们需要保证

  • 谁申请的资源谁释放。或者

  • 作为库提供者,在我们不能自行释放或者无法确定使用者会在什么时候会释放资源的时候,我们需要提供释放的接口并以文档告知使用者。

正如上文所述的CStr和CString,如果你确定字符串(动态)是由C ffi创建的,那你不应该使用CString来获取它的所有权,而必须使用CStr只创建一个Rust侧的借用。相对应的,如果你的字符串(多为String)在Rust侧创建,而你需要向C传递指针,也能确保C不会释放你的指针,那么你应该使用CString来获取指针的所有权,在退出作用域后释放掉,以避免内存泄漏。

另外在Rust中,如果我们需要使用C的某个接口申请资源,并且要在结束的时候使用另一个C的接口释放资源的时候,我们可以采取impl Drop的方式去管理,和C++的RAII几乎一致。这样变量在Rust中退出作用域的时候,编译器会自动帮我们调用drop方法,去释放C中申请的资源。

例如我们在实现Openconnect Wrapper的时候,C提供了两个函数openconnect_vpninfo_newopenconnect_vpninfo_free,在Rust的实现中,我们可以使用下面的策略

#[repr(C)]struct VpnInfo {    vpninfo: *mut openconncet_info,}
impl VpnInfo { pub fn new(/* some args */) -> Self { let vpninfo = unsafe { openconnect_vpninfo_new(/* some args */) }; Self { vpninfo } }}
impl Drop for VpnInfo { fn drop(&mut self) { unsafe { openconnect_vpninfo_free(self.vpninfo) }; }}

这样封装过后的VpnInfo实例对于Rust而言就是带有ownership/borrow checking的变量了。

当然上述例子通常需要程序员合作时有不成文的默契,是出于经验主义和共识总结出来的。真实环境中可能也会遇到千奇百怪的管理方式,需要具体场景具体分析。


What's more?

对于更复杂的情况,比如我们还有Rust中跨线程调用一个C方法的需要,或者我们需要向C传递一个Arc包装的变量,都有对应的策略。

Rust的智能指针都提供了into_rawfrom_raw_ptr等方式,可以将rust的实例转为原始指针向安全边界外传递。不过我们必须要注意,由于from_raw_ptr使用了内存偏移的方式来确定原始指针具体是由某种类型的智能指针转换而来的,以便计算ref count,我们必须使用同一类智能指针的from_raw_ptr对原始指针进行转换,即你的原始实例是从Arc转换到raw ptr,那么你就必须使用Arc的from_raw_ptr从raw转换回Arc,而不能转换到Rc或者Box。

对于有具体需求的情况,我们可以查阅into_raw和from_raw_ptr的文档以了解关于atomic ref counting和所有权的问题。

总结

看到这里,可能你已经隐隐感觉到unsafe rust的强大之处了,但学习它确实并非易事。理解unsafe barrier之后,不管是对于操作Rust,还是操作C/C++,都有不小的帮助,也帮助我们理解内存管理的复杂度。如果你想展开了解unsafe Rust的细节,你可以继续阅读官方的进阶指南——《The Rustonomicon》,该书会一步一步引导你理解"The Dark Arts of Unsafe Rust"。

祝读到这里的大家都能随心所欲管理好内存复杂度。

Reference

https://github.com/hlhr202/Openconnect-RS

https://docs.rs/cc/latest/cc/

https://rust-lang.github.io/rust-bindgen/

https://doc.rust-lang.org/stable/cargo/reference/build-scripts.html

https://doc.rust-lang.org/nomicon/


关于领创集团

(Advance Intelligence Group)
领创集团成立于 2016年,致力于通过科技创新的本地化应用,改造和重塑金融和零售行业,以多元化的业务布局打造一个服务于消费者、企业和商户的生态圈。集团旗下包含企业业务和消费者业务两大板块,企业业务包含 ADVANCE.AI 和 Ginee,分别为银行、金融、金融科技、零售和电商行业客户提供基于 AI 技术的数字身份验证、风险管理产品和全渠道电商服务解决方案;消费者业务 Atome Financial 包括亚洲领先的数字金融服务平台 Atome 等。2021年 9月,领创集团宣布完成超4亿美元 D 轮融资,融资完成后领创集团估值已超 20亿美元,成为新加坡最大的独立科技创业公司之一。




领创集团Advance Group
领创集团是亚太地区AI技术驱动的科技集团。
 最新文章