跳转到内容

所有权和内存布局

在本次实验中,我们使用 Rust 语言编写操作系统内核. 为此同学们需要自行学习 Rust 语言.

在本章中,我们会大致讲解 Rust 核心特性及 OS 相关性较大的语言特性,其余的内容需同学们自行学习.

本节将讲解 Rust 所有权 (ownership) 与内存布局 (memory layout).

栈 (Stack) 和堆 (Heap) 都是程序在运行时可供使用的内存部分,但拥有不同的组织方式:

  • 栈以获取值的顺序存储数据,并以相反的顺序移除数据. 这被称为后进先出 (LIFO). 所有存储在栈上的数据必须具有编译期已知的固定大小,例如 i32, [u8; 64].
  • 对于将数据放入堆的操作,需要请求一定大小的空间. 内存分配器会在堆中找到一个足够大的空闲位置,将其标记为已使用,并返回一个指针,即该位置的地址. 这个过程被称为在堆上分配内存,在编译时大小未知或大小可能变化的数据必须存储在堆上,例如 Vec, String, Box.

将数据压入栈中比在堆上分配内存要快,这是因为内存分配器不用寻找存储新数据的位置,它知道该位置始终位于栈顶,在汇编层面,分配栈空间仅仅是栈指针寄存器 (sp) 的减少;而在堆上分配空间,分配器必须先找到足够大的空间来存放数据. 访问堆上的数据通常比访问栈上的数据慢,因为访问堆必须通过指针来定位.

一旦理解了所有权,通常就不需要经常考虑栈和堆了. 但了解所有权的主要目的是管理堆数据,这有助于解释其运作原理. 栈上数据的生命周期严格绑定于其作用域 (Scope),而堆的生命周期由程序员自己控制,很容易导致内存泄漏和悬垂指针的问题.

例如对于 let s = String::from("Rust");,在 64 位系统上,这段代码创造了两个部分:

内存布局演示

其中变量 s 本身位于栈上,它是一个“胖指针”结构体,包含三个 64 位字段:

  • ptr: 指向堆上实际数据的指针
  • len: 当前字符串长度
  • capacity: 堆上已分配的总缓冲区大小

所有的所有权规则,操作的都是栈上的这 24 字节元数据. 至于此处为什么是 24 字节请同学们自行思考.

首先请谨记以下规则

  1. Rust 中的每一个值都有一个所有者.
  2. 同一时间只能有一个所有者.
  3. 当所有者离开作用域,这个值将被丢弃.

当你将一个变量赋值给另一个变量,或者将其作为参数传递给函数时,所有权发生了转移. 如果你对 C++ 的移动语义 std::move 有所了解,不妨进行类比.

let s1 = String::from("hello");
let s2 = s1;                    // Move occurs here
// println!("{}", s1);          // Error: value borrowed here after move
println!("{}", s2);

如果你觉得比较抽象,暂时先在此停下,查看 Rustlings 练习的相关部分后应当会有更好的理解. 阅读至这里,你应当对函数、控制流等常见的编程概念以及结构体、枚举等都有所了解,然后再继续阅读接下来的内容.

如果你对于这种操作在内存处理上的疑问,不妨阅读 Rust Book 的这个部分 或者 变量绑定背后的数据交互.

对于那些完全存储在栈上、不拥有堆内存资源的简单类型(如 i32, bool, f64, char),Rust 允许它们实现 Copy trait. 如果一个类型拥有 Copy trait,一个旧的变量在被赋值给其他变量后仍然可用.

可以理解为,任何基本类型的组合可以 Copy,不需要分配内存或某种形式资源的类型是可以 Copy 的. 如果你想查阅那些基础类型是实现了 Copy trait 的,可以查阅文档.

let x = 5;
let y = x;                      // Copy occurs here
println!("x: {}, y: {}", x, y); // Valid!

CopyMove 在物理操作上是完全一样的,都是栈上的位复制. 区别在于语义,实现了 Copy 的类型,旧变量在复制后依然被认为是有效的,因为销毁它们不需要任何特殊的清理逻辑,没有堆内存要释放.

RAII (Resource Acquisition Is Initialization) 是系统编程的一种好的编程范式. 在 Rust 中,它体现为 Drop trait.

当一个值或其拥有者,离开其作用域时,Rust 会自动调用其 drop 方法.

struct MyResource {
    name: String,
}

impl Drop for MyResource {
    fn drop(&mut self) {
        println!("Releasing resource: {}", self.name);
    }
}

{
    let _r = MyResource { name: String::from("FileA") };
    // drop _r
}

_r 离开作用域时,将会自动调用 Drop::drop(&mut _r) 来释放内部 String,也就是释放堆内存.

在 OS 内核中,我们不仅管理内存,还管理中断、设备句柄等系统资源. Drop 极大方便了资源管理,而不依赖于程序员的手动 free() 释放.

RAII 保证了无论函数如何返回,清理逻辑都会执行. 如果你现在还不太明白,这很正常而且也没有关系. 在完成实验后,可能你会对此理解更多.

在机器码层面,引用 &T&mut T 就是 C 语言中的指针,此处 T 为任意数据类型. 它们没有任何运行时开销.

考虑如下代码:

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = calculate_length(s1);
    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String
    (s, length)
}

问题在于,s1 被移动到了 calculate_length 中. 因此,我们考虑传入 String 值的引用. 引用类似于指针,它是一个地址,我们可以通过该地址访问存储在该地址的数据.

定义一个以对象引用作为参数,而非获取值所有权的 calculate_length 函数:

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);
    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

其中 & 符号表示引用,它们允许引用某个值而不取得其所有权.

String引用

某些时候,你需要修改引用的值,这就需要可变引用:

fn main() {
    let mut s = String::from("hello");
    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

注意,如果你有一个值的可变引用,就不能再有其他对该值的引用. 如果对同一个值拥有不可变引用时,不能同时拥有可变引用. 这些限制此处不进行展开,可以查看 Mutable References.

同时悬垂引用不会被编译器检查通过:

fn dangle() -> &String {
    let s = String::from("hello");
    &s // 返回对 s 的引用
} // 在作用域外 s 将被释放,但我们想返回对 s 的引用.

总结对于引用的规则:

  1. 在任何给定时间,要么只能有一个可变引用 (&mut T),要么有任意数量的不可变引用 (&T).
  2. 引用必须总是有效的.