25、深入探索:不安全的Rust与外部函数接口

深入探索:不安全的Rust与外部函数接口

1. 技术要求

在开始学习不安全的Rust和外部函数接口(FFI)之前,需要确保相关工具已正确安装:
- 验证 rustup rustc cargo 是否正确安装,使用以下命令:

rustup --version
rustc --version
cargo --version
  • 由于涉及C代码的编译和二进制文件的生成,需要在开发机器上设置C开发环境。设置完成后,运行以下命令验证安装是否成功:
gcc --version

如果该命令执行不成功,请重新检查安装过程。
- 对于在Windows平台上开发的用户,建议使用Linux虚拟机来尝试本文中的代码。代码已在Ubuntu 20.04(LTS)x64上测试,应该可以在其他Linux变体上正常工作。代码的Git仓库可以在 这里 找到。

2. 引入不安全的Rust

Rust语言在编译时强制实施内存和类型安全,防止各种未定义行为,如内存溢出、空指针或无效指针构造以及数据竞争,这就是安全的Rust。Rust标准库为编写安全、惯用的Rust代码提供了很好的工具和实用程序,有助于保持程序的安全性。

然而,在某些情况下,编译器可能会成为障碍。Rust编译器对代码进行保守的静态分析,可能会拒绝一些实际上安全的代码。例如,系统调用、类型强制转换和内存指针的直接操作等,这些操作在系统软件的开发中经常使用,但编译器可能会认为它们有风险而拒绝。

为了支持这些操作,Rust语言提供了 unsafe 关键字。对于作为系统编程语言的Rust来说,让程序员能够编写底层代码,直接与操作系统交互是很重要的,这就是不安全的Rust,它不遵循借用检查器的规则。

不安全的Rust可以被看作是安全的Rust的超集,它允许在标准Rust中可以做的所有事情,还可以做一些Rust编译器禁止的事情。实际上,Rust自己的编译器和标准库中也包含了精心编写的不安全Rust代码。

2.1 区分安全和不安全的Rust代码

Rust提供了一种方便直观的机制,使用 unsafe 关键字将一段代码封装在 unsafe 块中。例如:

fn main() {
    let num = 23;
    let borrowed_num = # // 对num的不可变引用
    let raw_ptr = borrowed_num as *const i32; // 将引用borrowed_num转换为原始指针
    assert!(*raw_ptr == 23);
}

使用 cargo check 编译这段代码,会看到以下错误信息:

error[E0133]: dereference of raw pointer is unsafe and requires unsafe function or block

将原始指针的解引用操作封装在 unsafe 块中修改代码:

fn main() {
    let num = 23;
    let borrowed_num = # // 对num的不可变引用
    let raw_ptr = borrowed_num as *const i32; // 将引用borrowed_num转换为原始指针
    unsafe {
        assert!(*raw_ptr == 23);
    }
}

此时编译成功,尽管这段代码可能会导致未定义行为。因为一旦将代码封装在 unsafe 块中,编译器期望程序员确保不安全代码的安全性。

2.2 不安全的Rust中的操作

不安全的Rust中有五种关键操作:
1. 解引用原始指针 :不安全的Rust有两种新的指针类型, *const T 对应安全Rust中的 &T (不可变引用类型), *mut T 对应 &mut T (可变引用类型)。与Rust引用类型不同,这些原始指针可以同时有不可变和可变指针指向一个值,或者同时有多个指针指向内存中的同一个值。当这些指针超出作用域时,不会自动清理内存,并且这些指针可以为空或指向无效的内存位置。例如:

fn main() {
    let mut a_number = 5;
    // 创建一个指向值5的不可变指针
    let raw_ptr1 = &a_number as *const i32;
    // 创建一个指向值5的可变指针
    let raw_ptr2 = &mut a_number as *mut i32;
    unsafe {
        println!("raw_ptr1 is: {}", *raw_ptr1);
        println!("raw_ptr2 is: {}", *raw_ptr2);
    }
}

创建原始指针不需要 unsafe 块,但解引用它们需要,因为解引用原始指针可能会导致不可预测的行为,借用检查器不负责验证其有效性或生命周期。
2. 访问或修改可变静态变量 :静态变量有固定的内存地址,可以标记为可变。但如果静态变量被标记为可变,访问和修改它是不安全的操作,必须封装在 unsafe 块中。例如:

static mut THREAD_COUNT: u32 = 4;
use std::env::var;
fn change_thread_count(count: u32) {
    unsafe {
        THREAD_COUNT = count;
    }
}
fn main() {
    if let Some(thread_count) = var("THREAD_COUNT").ok() {
        change_thread_count(thread_count.parse::<u32>().unwrap());
    };
    unsafe {
        println!("Thread count is: {}", THREAD_COUNT);
    }
}

这段代码声明了一个可变静态变量 THREAD_COUNT ,初始值为4。在 main 函数中,检查环境变量 THREAD_COUNT ,如果存在则调用 change_thread_count 函数修改静态变量的值,最后打印出该值。
3. 实现不安全的trait :例如,有一个包含原始指针的自定义结构体,要在多个线程之间发送或共享它,需要实现 Send Sync trait。对于原始指针,需要使用不安全的Rust来实现这些trait:

struct MyStruct(*mut u16);
unsafe impl Send for MyStruct {}
unsafe impl Sync for MyStruct {}

使用 unsafe 关键字的原因是原始指针的所有权是未跟踪的,需要程序员负责跟踪和管理。

3. 引入外部函数接口(FFI)

FFI可以帮助解决不同编程语言之间的交互问题。例如,有一个用Rust编写的快速机器学习算法,Java或Python开发者想要使用这个Rust库;或者想要在不使用Rust标准库的情况下进行Linux系统调用。

3.1 从Rust调用C函数

Rust使用 extern 关键字来设置和调用C函数的FFI。例如,调用 getenv 函数来获取环境变量的值:

use std::ffi::{CStr, CString};
use std::os::raw::c_char;
extern "C" {
    fn getenv(s: *const c_char) -> *mut c_char;
}

fn main() {
    let c1 = CString::new("MY_VAR").expect("Error");
    unsafe {
        println!("env got is {:?}", CStr::from_ptr(getenv(c1.as_ptr())));
    }
}

main 函数中,调用外部C函数 getenv 来获取 MY_VAR 环境变量的值。首先创建一个 CString 类型的实例,将其转换为所需的函数输入参数类型,然后使用 CStr::from_ptr 函数将返回值转换为Rust兼容的类型。

运行程序:

MY_VAR="My custom value" cargo -v run --bin ffi

会在控制台看到 MY_VAR 的值。

3.2 定义和访问联合结构体的字段

C语言中使用的联合结构体(union)不是内存安全的,因为可以将联合的实例设置为一种类型,却以另一种类型访问它。Rust在安全的Rust中不直接提供联合类型,但有标记联合(tagged union),在安全的Rust中实现为枚举类型。例如:

#[repr(C)]
union MyUnion {
    f1: u32,
    f2: f32,
}
fn main() {
    let float_num = MyUnion {f2: 2.0};
    let f = unsafe { float_num.f2 };
    println!("f is {:.3}",f);
}

使用 #[repr(C)] 注解告诉编译器, MyUnion 联合中的字段顺序、大小和对齐方式与C语言中相同。创建一个联合实例,用浮点类型初始化它,然后从 unsafe 块中访问其值。

如果将代码修改为以整数类型访问联合:

let f = unsafe { float_num.f1 };

再次运行程序,会看到一个无效的值,因为内存位置中的值原本是浮点类型,现在被解释为整数类型。

4. 创建安全的FFI接口的准则

在使用FFI与其他语言交互时,需要遵循以下准则:
| 准则 | 说明 |
| ---- | ---- |
| extern 关键字 | 任何在Rust中使用 extern 关键字定义的外部函数本质上都是不安全的,必须从 unsafe 块中调用。 |
| 数据布局 | Rust不保证数据在内存中的布局,在与其他语言交互时,显式使用C兼容的布局(使用 #repr(C) 注解)对于维护内存安全很重要。只应使用C兼容的类型作为外部函数的参数或返回值。 |
| 平台相关类型 | C有许多平台相关的类型,如 int long ,其长度根据平台架构而异。与使用这些类型的C函数交互时,可以使用Rust标准库的 std::raw 模块提供的跨平台类型别名,或者使用 libc crate。 |
| 引用和指针 | 由于C的指针类型和Rust的引用类型不同,在使用FFI时,Rust代码应使用指针类型而不是引用类型。任何解引用指针类型的Rust代码在使用前必须进行空检查。 |
| 内存管理 | 每种编程语言都有自己的内存管理方式,在不同语言之间传输数据时,必须明确哪种语言负责释放内存,以避免双重释放或使用后释放的问题。 |

以下是创建安全的FFI接口的流程:

graph TD;
    A[定义外部函数] --> B[使用extern关键字和C兼容类型];
    B --> C[封装在extern "C"块中];
    C --> D[调用外部函数];
    D --> E[使用unsafe块];
    E --> F[处理数据和内存管理];
    F --> G[确保类型兼容和空检查];

总之,理解不安全的Rust和FFI对于在不同场景下编写高效、灵活的代码非常重要。通过遵循相关准则,可以在保证代码安全的前提下,实现Rust与其他编程语言的有效交互。

深入探索:不安全的Rust与外部函数接口

5. 从C调用Rust函数(项目)

前面我们介绍了如何从Rust调用C函数,接下来看看如何实现从C调用Rust函数。

5.1 编写Rust代码

首先,我们要编写一个简单的Rust函数,并且确保它可以被C调用。为了实现这一点,我们需要使用 #[no_mangle] 注解,它可以防止Rust编译器对函数名进行混淆,这样C代码就能正确找到这个函数。同时,我们要使用 extern "C" 来指定调用约定,确保函数的调用方式符合C语言的规范。

#[no_mangle]
pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 {
    a + b
}

在上述代码中, add_numbers 函数接受两个 i32 类型的参数,将它们相加并返回结果。 #[no_mangle] 注解保证了函数名在编译后不会被改变, extern "C" 则指定了C语言的调用约定。

5.2 编译Rust代码为静态库

接下来,我们要把Rust代码编译成静态库,以便C代码可以链接使用。在项目根目录下,使用以下命令进行编译:

cargo build --release --lib

这个命令会在 target/release 目录下生成一个静态库文件,文件名通常类似于 lib<项目名>.a

5.3 编写C代码

现在我们来编写C代码,调用刚才编译好的Rust函数。

#include <stdio.h>

// 声明Rust函数
extern int add_numbers(int a, int b);

int main() {
    int result = add_numbers(3, 5);
    printf("The result is: %d\n", result);
    return 0;
}

在这段C代码中,我们首先声明了 add_numbers 函数,然后在 main 函数中调用它,并将结果打印输出。

5.4 编译和链接C代码

最后,我们要把C代码和Rust静态库一起编译和链接。使用以下命令:

gcc -o main main.c target/release/lib<项目名>.a

这里的 <项目名> 需要替换为你的Rust项目的实际名称。编译成功后,会生成一个可执行文件 main ,运行它就能看到调用Rust函数的结果。

6. 理解应用二进制接口(ABI)

应用二进制接口(ABI)定义了不同程序模块之间如何进行交互,包括函数调用约定、数据类型的大小和对齐方式等。在使用FFI时,理解ABI非常重要,因为不同的编程语言和平台可能有不同的ABI。

6.1 repr(C) 注解

在前面的例子中,我们已经使用过 #[repr(C)] 注解。这个注解告诉Rust编译器,结构体或联合体的字段顺序、大小和对齐方式要遵循C语言的规则。例如:

#[repr(C)]
struct MyStruct {
    field1: u32,
    field2: f32,
}

在这个结构体中, #[repr(C)] 确保了 MyStruct 的内存布局和C语言中的结构体一致,这样在与C代码交互时就不会出现问题。

6.2 数据布局约定

不同的平台和编译器可能有不同的数据布局约定,比如字节序、对齐方式等。在使用FFI时,要确保Rust和其他语言的数据布局一致,避免数据错误。例如,在某些平台上, int 类型可能是32位,而在另一些平台上可能是64位。为了保证跨平台的兼容性,我们可以使用Rust标准库中的 std::raw 模块提供的类型别名,或者使用 libc crate。

6.3 调用约定

调用约定规定了函数调用时参数的传递方式、栈的清理方式等。在Rust中,使用 extern "C" 来指定C语言的调用约定。例如:

extern "C" {
    fn some_c_function(arg1: i32, arg2: f32) -> u32;
}

这里的 extern "C" 告诉Rust编译器, some_c_function 遵循C语言的调用约定。

7. 总结

通过本文的学习,我们深入了解了不安全的Rust和外部函数接口(FFI)的相关知识。以下是我们学习的主要内容总结:

主题 要点
不安全的Rust 提供了在特定场景下突破编译器限制的能力,包括解引用原始指针、访问或修改可变静态变量、实现不安全的trait等操作。使用 unsafe 关键字将不安全代码封装在 unsafe 块中。
外部函数接口(FFI) 允许Rust与其他编程语言进行交互,如从Rust调用C函数、从C调用Rust函数。使用 extern 关键字设置和调用外部函数,注意数据类型的转换和内存管理。
创建安全的FFI接口准则 遵循 extern 关键字的使用规则、确保数据布局兼容、处理平台相关类型、正确使用引用和指针、明确内存管理责任。
应用二进制接口(ABI) 理解 repr(C) 注解、数据布局约定和调用约定,保证不同程序模块之间的正确交互。

以下是整个知识体系的流程图:

graph LR;
    A[不安全的Rust] --> B[解引用原始指针];
    A --> C[访问或修改可变静态变量];
    A --> D[实现不安全的trait];
    E[外部函数接口(FFI)] --> F[从Rust调用C函数];
    E --> G[从C调用Rust函数];
    H[创建安全的FFI接口准则] --> I[extern关键字使用];
    H --> J[数据布局兼容];
    H --> K[处理平台相关类型];
    H --> L[引用和指针使用];
    H --> M[内存管理];
    N[应用二进制接口(ABI)] --> O[repr(C)注解];
    N --> P[数据布局约定];
    N --> Q[调用约定];
    B --> F;
    F --> H;
    H --> N;
    G --> H;

掌握不安全的Rust和FFI可以让我们在更广泛的场景中发挥Rust的优势,编写高效、灵活且与其他语言兼容的代码。在实际应用中,要谨慎使用不安全的代码,遵循相关准则,确保代码的安全性和可靠性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值