不安全Rust: unsafe编程

 Rust  unsafe  全局变量 󰈭 3841字

rCore-OS: 批处理系统 - rqdmap | blog初次见到了unsafe的相关代码, 当时并未系统学习, 只是草草了解, 这里补上相关的内容知识. Rust编译器提供了强大的编译期安全保障, 不过其仍然为我们提供了unsafe关键字, 供我们写一些 “不安全的” Rust黑魔法.

unsafe简介

为什么会有unsafe的需求?

  • 过强且保守的编译器, unsafe允许程序员自己对自己的代码负责

  • 特定任务的需求, 如系统/底层编程

unsafe可以带给我们什么?

  • 解引用裸指针

  • 调用一个 unsafe 或外部的函数

  • 访问或修改一个可变的静态变量

  • 实现一个 unsafe 特征

  • 访问 union 中的字段

unsafe安全吗? 虽然名称自带不安全, 但是 Rust 依然提供了强大的安全支撑:

  • unsafe 并不能绕过 Rust 的借用检查, 也不能关闭任何 Rust 的安全检查规则

  • 仅仅在使用unsafe提供的5种能力时编译器才不会进行内存安全方面的检查

unsafe能力说明

解引用裸指针

裸指针有两种形式*const T*mut T, 分别代表不可变和可变的指针形式. 注意其中的*并不是解引用, 而是裸指针名称的一部分而已.

相比于引用与智能指针, 裸指针:

  • 可以绕过 Rust 的借用规则,可以同时拥有一个数据的可变、不可变指针,甚至还能拥有多个可变的指针

    • 此处应该是绕过编译期的借用检查, 运行时的检查则不可避免
  • 并不能保证指向合法的内存; 可以是 null

  • 没有实现任何自动的回收 (drop)

裸指针与C语言的指针很像, 使用它需要牺牲安全性, 但是可以获得更好的性能, 并与其他语言或硬件打交道

有三种方式创建裸指针, 一种是基于引用的, 通过as类型转换即可创建裸指针:

1let mut num = 5;
2
3let r1 = &num as *const i32;
4let r2 = &mut num as *mut i32;

第二种方式是直接通过地址创建:

1let address = 0x012345usize;
2let r = address as *const i32;
  • 不过这种方式十分不安全, 试图使用任意的地址往往会是一种未定义行为, 但是它也可以通过编译

最后一种是通过智能指针的:

1let a: Box<i32> = Box::new(10);
2// 解引用a再转换为引用, 最后隐式转换为裸指针
3let b: *const i32 = &*a;
4// 直接使用 into_raw 来创建
5let c: *const i32 = Box::into_raw(a);

总的说来, 三种方式创建裸指针都不需要unsafe块包裹(尽管其中一个并不安全), 只有解引用裸指针时才需要unsafe帮忙

调用unsafe函数

如果一个函数被定义为unsafe, 那么调用者必须使用unsafe进行包裹:

1unsafe fn dangerous() {}
2fn main() {
3	unsafe {
4	    dangerous();
5	}
6}
  • 强制调用者加上 unsafe 语句块, 就可以让他清晰的认识到, 正在调用一个不安全的函数, 需要小心看看文档, 看看函数有哪些特别的要求需要被满足.

  • unsafe 无需俄罗斯套娃, 在 unsafe 函数体中可以不使用 unsafe 语句块.

下面看一个实际的例子, 希望将一个数组分成两个切片, 分隔的位置可以任意指定, 通常的安全Rust无法做到, 因为不可避免会对同一个数组进行两次可变借用, 不安全的Rust可以这么做:

 1use std::slice;
 2
 3fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
 4    let len = slice.len();
 5    let ptr = slice.as_mut_ptr();
 6
 7    assert!(mid <= len);
 8
 9    unsafe {
10        (
11            slice::from_raw_parts_mut(ptr, mid),
12            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
13        )
14    }
15}
16
17fn main() {
18    let mut v = vec![1, 2, 3, 4, 5, 6];
19
20    let r = &mut v[..];
21
22    let (a, b) = split_at_mut(r, 3);
23
24    assert_eq!(a, &mut [1, 2, 3]);
25    assert_eq!(b, &mut [4, 5, 6]);
26}
  • as_mut_ptr会返回指向 slice 首地址的裸指针, 类型为*mut i32

  • from_raw_parts_mut通过裸指针和长度创建一个新的切片, 由于其使用了裸指针作为参数, 因而其是一个unsafe fn, 使用它就必须包裹在unsafe中.

  • ptr.add()用于获取第二个切片的起始地址, rust中指针的加减运算不像C中的可以自行推断元素的大小, 因而第二个切片的起始地址实际上应该是ptr + 4 * mid, 不过这样子写并不好, 更好的做法是直接使用add方式

  • L7的asserts语句使得该unsafe代码块变得安全, mid不会指向乱七八糟的地址, 保证了从合法的数据创建合法的指针, 因而整体代码不用标注为unsafe的, 而是可以写成一段包裹unsafe代码的安全抽象

调用外部函数/FFI

FFI(Foreign Function Interface)可以用来与其它语言进行交互, unsafe 的另一个重要目的就是对 FFI 提供支持, 通过 FFI 我们的 Rust 代码可以跟其它语言的外部代码进行交互.

下面是一个例子, 演示了Rust如何调用C库中的 abs 函数:

1extern "C" {
2    fn abs(input: i32) -> i32;
3}
4
5fn main() {
6    unsafe {
7        println!("Absolute value of -3 according to C: {}", abs(-3));
8    }
9}
  • extern "C"代码块中, 我们列出了想要调用的外部函数的签名. 其中"C"定义了外部函数所使用的应用二进制接口ABI (Application Binary Interface): ABI 定义了如何在汇编层面来调用该函数. 在所有 ABI 中, C 语言的是最常见的.

  • 比较神奇的地方是, 该代码可以直接cargo run起来, 而不需要像C那样include一些标准库才可以运行, 这是为什么? TBD

有关FFI这块的例子, 在rCore-OS: 批处理系统 - rqdmap | blog的相关章节还可以看到一些(如: 引用ASM汇编符号等), 这里就不再赘述了.

访问与操作一个全局变量

有关全局变量其实是个单独的坑, 因为rust的全局变量问题多多, Rust貌似也并不推荐使用, 所以需要单独花费一些笔墨进行说明, 这里考虑对其进行一些介绍, 主要参考: 全局变量 - Rust语言圣经(Rust Course)

全局变量的生命周期?

首先有一点可以肯定, 全局变量的生命周期肯定是’static, 但是不代表它需要用static来声明, 例如常量、字符串字面值等无需使用static进行声明, 原因是它们已经被打包到二进制可执行文件中.

全局变量简介

全局变量大多数在编译期初始化, 但也有一些场景下必须要动态初始化(比如rCore中动态读取App数量后再初始化AppManager的过程).

下面几种全局变量类型是在编译期进行初始化的:

  • 静态常量. 全局的静态常量可以在程序任何一部分使用, 顾名思义, 其不可变:

    1const MAX_ID: usize =  usize::MAX / 2;
    2fn main() {
    3   println!("用户ID允许的最大值是{}",MAX_ID);
    4}
    • 常量有一些特殊的地方, 如关键字是const而不是let

    • 常量定义时必须指明类型, 同时命名时一般全是大写.

    • 常量可以在任何作用于进行定义, 编译器会尽量在编译期间将其内联到代码中, 因而不同地方对同一个常量的引用并不能保证引用到同一个内存地址

    • 常量的赋值必须是常量表达式/数学表达式, 如果运行时才能得出结果的东西(如函数)则不能赋给常量表达式

    • 变量允许发生遮蔽情况, 但是常量不允许出现重复的定义

  • 静态变量. 静态变量通过static关键字声明, 访问与修改static变量必须使用unsafe包裹; 因为在多线程时对静态变量的访会不可避免的遇到脏数据.

    1static mut REQUEST_RECV: usize = 0;
    2fn main() {
    3   unsafe {
    4	REQUEST_RECV += 1;
    5	assert_eq!(REQUEST_RECV, 1);
    6   }
    7}
    • 与静态常量一样, 静态变量在定义时也必须赋值为一个编译期可以计算出的结果.

    • 静态变量不会被内联, 因而整个程序过程中只有一个静态变量的实例, 所有的引用都指向一个地址.

    • 静态变量中的值必须实现Sync特征

  • 原子类型; 原子类型可以线程安全地进行全局计数或状态控制, 不过这里不再深入研究了该类型了.

静态初始化的致命问题在与无法用函数进行初始化, 动态初始化应运而生. 以下面的Mutex锁的初始化过程, 其需要用到Mutex::new函数进行初始化:

1use std::sync::Mutex;
2static NAMES: Mutex<String> = Mutex::new(String::from("Sunface, Jack, Allen"));
3
4fn main() {
5    let v = NAMES.lock().unwrap();
6    println!("{}",v);
7}

不过报错:

1error[E0015]: cannot call non-const fn `<String as From<&str>>::from` in statics
2 --> src/main.rs:2:42
3  |
42 | static NAMES: Mutex<String> = Mutex::new(String::from("Sunface, Jack, Allen"));
5  |                                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
6  |
7  = note: calls in statics are limited to constant functions, tuple structs and tuple variants
8  = help: add `#![feature(const_trait_impl)]` to the crate attributes to enable
9  = note: consider wrapping this expression in `Lazy::new(|| ...)` from the `once_cell` crate: https://crates.io/crates/once_cell

有一些解决方案:

  • 编译器提供了一些help, 比如#![feature(const_trait_impl)], 莫非这是比较新的feature? 不过在教程里还没看到相关的说明=.=

  • 一个社区提供的初始化方式是lazy_static, 该宏在rCore2中也出现过, 作用是在运行期第一次访问该静态变量时开始初始化它:

     1use std::sync::Mutex;
     2use lazy_static::lazy_static;
     3lazy_static! {
     4    static ref NAMES: Mutex<String> = Mutex::new(String::from("Sunface, Jack, Allen"));
     5}
     6
     7fn main() {
     8    let mut v = NAMES.lock().unwrap();
     9    v.push_str(", Myth");
    10    println!("{}",v);
    11}
lazy_static的性能损失?

使用lazy_static在每次访问静态变量时,会有轻微的性能损失,因为其内部实现用了一个底层的并发原语std::sync::Once,在每次访问该变量时,程序都会执行一次原子指令用于确认静态变量的初始化是否完成。

  • 另一种方案可以使用Box的leak关联函数, 在Rust中的智能指针 - rqdmap | blog有一个例子, 这样就可以通过函数在运行期初始化一个全局的静态变量了.

最后回到unsafe Rust中, 总的来说, 对于可变的静态变量而言对其的访问与修改均需要包裹上unsafe才行.

实现unsafe特征

Send和Sync是一些典型的unsafe特征, 他们是Rust安全并发的重中之重, 实现Send的类型可以在线程间安全的传递其所有权, 实现Sync的类型可以在线程间安全的共享(通过引用).

之所以会有 unsafe 的特征, 是因为该特征至少有一个方法包含有编译器无法验证的内容, 比如Send特征就是因为Rust无法验证我们的类型能否在线程间安全传递, 因而就要求程序员标记成unsafe自行验证.

unsafe特征的声明也很简单:

1unsafe trait Foo {
2    // 方法列表
3}
4
5unsafe impl Foo for i32 {
6    // 实现相应的方法
7}
8
9fn main() {}

访问union中的字段

union所有的字段都在一个空间中, 初始化时也只接受一个参数:

1union MyUnion {
2    f1: u32,
3    f2: f32,
4}
5
6fn main() {
7    let _union: MyUnion = MyUnion { f1: 5u32 };
8    println!("{}", unsafe { _union.f2 });
9}

对union字段的访问是不安全的, 需要加上unsafe, 因为 Rust 无法保证当前存储在 union 实例中的数据类型.

使用union其实主要目的是跟C进行交互, 此时一般还会为union结构体加上#[repr(C)]属性标志. 这里便不再对union过多展开了.

一些使用工具与库

五种兵器 - Rust语言圣经(Rust Course)中还介绍了许多用于处理unsafeFFI场景的工具, 这里仅附上链接作为参考.

嗨! 这里是 rqdmap 的个人博客, 我正关注 GNU/Linux 桌面系统, Linux 内核, 后端开发, Python, Rust 以及一切有趣的计算机技术! 希望我的内容能对你有所帮助~
如果你遇到了任何问题, 包括但不限于: 博客内容说明不清楚或错误; 样式版面混乱等问题, 请通过邮箱 rqdmap@gmail.com 联系我!
修改记录:
  • 2023-08-28 22:51:32unsafe-Rust初步
不安全Rust: unsafe编程