30 天拿下 Rust 之 unsafe 代码
💡 如果想阅读最新的文章,或者有技术问题需要交流和沟通,可搜索并关注微信公众号“希望睿智”。
概述
在 Rust 语言的设计哲学中,"安全优先" 是其核心原则之一。然而,在追求极致性能或者与底层硬件进行交互等特定场景下,Rust 提供了 unsafe 关键字。unsafe 代码允许开发者暂时脱离 Rust 的安全限制,直接操作内存和执行低级操作。虽然 unsafe 代码在某些情况下是必要的,但使用它时必须格外小心,以避免引入难以调试的内存错误。
什么是 unsafe 代码
在 Rust 中,unsafe 关键字用于标记那些可能破坏 Rust 的内存安全保证的代码块,使用 unsafe 关键字编写的代码块或函数被称为 unsafe 代码。unsafe 代码允许程序员执行诸如裸指针操作、类型转换和直接内存访问等低级别操作。由于这些操作可能导致未定义行为或内存安全漏洞,Rust 编译器不会对它们进行常规的安全性检查。
unsafe 代码主要用于以下三个场景。
性能优化:在某些性能关键的应用中,程序员可能会选择使用 unsafe 代码来绕过 Rust 的一些安全检查,以获得更高的性能。
底层系统编程:在操作系统开发、设备驱动或嵌入式系统编程中,可能需要直接操作硬件或使用特定的内存布局,这时就需要使用 unsafe 代码。
与 C 语言库交互:当使用 Rust 调用 C 语言编写的库时,可能需要执行一些不安全的操作来正确地管理内存和调用约定。
在 Rust 中,unsafe 代码的使用主要涉及以下三个方面:使用裸指针、使用外部函数接口、实现不安全 Trait,下面分别进行介绍。
使用裸指针
在 Rust 中,裸指针是一种可以绕过 Rust 的常规所有权和借用检查机制的低级工具。它允许程序员直接操作内存地址,从而进行更为底层和灵活的操作。然而,正因为裸指针绕过了 Rust 的内存安全保证,使用时必须格外小心,以避免引入未定义行为或内存安全问题。
裸指针有两种主要类型:*const T(指向常量数据的裸指针)和*mut T(指向可变数据的裸指针)。前者用于读取数据,后者用于读取和修改数据。
裸指针通常通过取址操作符 &和类型转换来创建。在下面的示例代码中,我们首先创建了一个整数 x 和一个可变的整数 y。然后,我们使用取址操作符 &获取它们的地址,并通过类型转换将它们转换为裸指针 raw_ptr 和 mut_raw_ptr 。获取裸指针并不是 unsafe 代码,解引用裸指针才是 unsafe 代码。
解引用裸指针是通过在裸指针前使用*操作符来完成的,这允许我们读取或修改裸指针指向的值。注意:解引用裸指针时,必须确保指针是有效的,否则会导致未定义行为。
在下面的示例代码中,我们使用 unsafe 块来解引用裸指针。在 unsafe 块内,我们打印出 raw_ptr 指向的值,并将 mut_raw_ptr 指向的值修改为 1024。
使用外部函数接口
在 Rust 中,使用 unsafe 关键字的一个常见场景是调用 C 语言或其他语言编写的库函数。Rust 通过 extern 块和 extern 关键字提供了对外部函数的支持,而这些函数的调用通常需要标记为 unsafe。这是因为,Rust 编译器无法验证这些外部函数的行为是否符合 Rust 的内存安全规则。
假如我们有下面的 C 语言库,其 Add 接口为计算两个整数的和。
在下面的示例代码中,我们首先引入了 libc 库。这是 Rust 提供的一个包含 C 语言类型的库,使得我们可以使用与 C 兼容的类型。然后,我们使用 extern "C"块来声明 C 语言中的 Add 函数。注意:extern "C"告诉 Rust 编译器这个函数是用 C 语言的链接约定来链接的。
在 main 函数中,我们使用 unsafe 块来调用这个外部函数。这是必须的,因为 Rust 编译器无法验证这个 C 函数是否遵守 Rust 的内存安全规则。如果 C 函数违反了这些规则(比如解引用空指针或写入只读内存),那么 Rust 程序可能会崩溃或产生未定义行为。
最后,编译和运行这个 Rust 程序需要确保实现 Add 函数的 C 库是可用的。我们可能需要编译这个 C 库为动态链接库或静态库,并在编译 Rust 程序时链接这个库。另外,我们还需要在 Cargo.toml 文件中添加类似下面的依赖性以引入 libc 库:libc = "0.2"。
实现不安全 Trait
在 Rust 中,可以直接声明一个 Trait 是不安全的,即整个 Trait 都带有 unsafe 修饰符。也可以不声明 Trait 为不安全的,而在 Trait 的具体实现中使用 unsafe 来执行不安全的操作。这意味着,我们可以安全地定义一个 Trait,但在其某个或某些具体实现中执行不安全操作。
在下面的示例代码中,UnsafeTrait 声明了一个 unsafe_method 方法。CustomStruct 实现了这个 Trait,并提供了 unsafe_method 的一个默认实现,该实现是 unsafe 的。在 main 函数中,我们使用 unsafe 块来调用这个方法,因为我们知道这个调用可能涉及不安全操作。
重要的是,即使 unsafe_method 是在 Trait 中定义的,调用它的责任仍然落在调用者身上。调用者必须确保在调用 unsafe 方法时遵循所有安全准则,比如:确保传递给方法的参数是有效的,并处理任何可能由 unsafe 操作引起的错误或未定义行为。
通常,应该尽量避免在 Trait 中使用 unsafe,除非确实需要执行一些低级的、不安全的操作,并且调用者能够清楚地理解并处理这些不安全操作可能带来的风险。在大多数情况下,更好的做法是:使用安全的 Rust 特性来实现相关的需求。
unsafe 代码的安全抽象
unsafe 代码的安全抽象是一种设计模式,它允许开发者在不安全代码和安全代码之间建立清晰的边界。这种抽象通过封装不安全操作在安全的接口之后来实现,使得库的使用者能够在不了解或不关心内部实现细节的情况下安全地使用库的功能。这种设计模式的关键在于:将不安全代码限制在尽可能小的范围内,并通过安全的接口暴露给使用者。这样,库的使用者可以依赖这些安全的接口,而无需担心底层可能的不安全操作。
在下面的示例代码中,unsafe_operation 函数执行一些不安全操作。然而,它并没有直接公开给库的使用者,而是被封装在 safe_operation 函数中。safe_operation 函数是一个安全的接口,它内部使用 unsafe 块来调用 unsafe_operation,但在调用前后可以添加额外的安全检查或清理工作。这样,库的使用者只需要调用 safe_operation,而无需关心其内部是否使用了 unsafe。
通过安全抽象这种方式,库的设计者可以确保库的使用者不会误用不安全操作,同时仍然能够利用不安全代码提供的性能优势或底层功能。在构建大型 Rust 项目或库时,将不安全代码限制在最小的必要范围内,并通过安全的接口暴露功能是非常重要的。这有助于减少错误和漏洞的风险,同时提高代码的可维护性和可理解性。
注意事项
虽然 unsafe 并非完全不受控制,但它确实把内存安全的责任交还给了程序员。在编写 unsafe 代码时,我们需要特别注意以下几点。
1、最小化 unsafe 代码的使用。尽量将 unsafe 代码的使用限制在必要的范围内,并尽量避免在库或模块的公共 API 中使用它。
2、仔细审查 unsafe 代码。对 unsafe 代码进行严格的代码审查和测试,以确保它不会引入内存安全漏洞。
3、文档化 unsafe 代码。为 unsafe 代码提供清晰的文档说明,解释为什么需要使用它,以及使用它时需要注意的事项。
4、使用 Rust 的安全抽象。尽可能利用 Rust 提供的所有权模型、生命周期和借用检查器等安全抽象来减少 unsafe 代码的使用。
总结
Rust 的 unsafe 代码是强大且必要的工具,它让 Rust 能够在提供高级抽象的同时,依然保留对底层资源的精细控制能力。然而,unsafe 代码也是一个潜在的危险源。使用 unsafe 代码需要开发者具备足够的经验和谨慎,始终坚守 Rust 的内存和类型安全准则。只有这样,我们才能充分利用 Rust 的优势,构建出既高效又安全的系统级软件。
版权声明: 本文为 InfoQ 作者【希望睿智】的原创文章。
原文链接:【http://xie.infoq.cn/article/cb20eff3bd89e7e2feeac613b】。文章转载请联系作者。
评论