对比C++,Rust在内存安全上做的努力

devtools/2024/11/28 17:34:53/

简介

近年来,越来越多的组织表示,如果新项目在技术选型时需要使用系统级开发语言,那么不要选择使用C/C++这种内存不安全的系统语言,推荐使用内存安全Rust作为替代。

谷歌也声称,Android 的安全漏洞,从 2019 年的 223 个降低到 2022 年的 85 个,经过分析,谷歌认为内存漏洞减少的情况,主要与 Rust 代码的比例增加有关。在 Android 13 中,就已经有约 21%的新原生代码以 Rust 开发

微软也宣布,Rust 将正式入驻 Windows 系统内核;AWS在其基础设施中越来越多地使用 Rust ;2022 年 12 月,Linux 内核 6.1 发布,包括最初的 Rust 支持. . .

作为后来者,Rust是怎么做到内存安全,且受到越来越多人的青睐呢?要知道,换做使用C/C++开发,可能只有高级C/C++开发人员写出的代码才能如此稳定,Rust是怎么保证任何一个使用它的人都能写出内存安全的代码的呢?

下面,针对在C/C++中几种常见的内存安全问题为例,简单分析下。


悬空指针

悬空指针主要是指,在C/C++中,某个对象已经被释放了,但是在某个角落还有一个指针指向这个对象,这个指针就是一个悬空指针。当代码运行到这个地方,解引用这个悬空指针时,就会出现未定义的行为

Rust解决这个问题的办法就是Rust的精髓所在——生命周期

int main()
{std::string *ptr = nullptr;{std::string = str;ptr = &str;}printf("%s", ptr->c_str());return 0;
}

上面是典型的C++中出现野指针的场景,这段代码编译器不会发出任何抱怨。

程序入口定义了一个std::string类型的指针ptr,并初始化为nullptr。进入代码块后,在代码块中创建一个局部变量str,并且让ptr指向这个局部变量。当执行流结束这个代码块后,栈上的变量str将会被释放,但是此时指针ptr 还是指向这个局部变量str,代码块后续任何解引用指针ptr的地方都将是一个不可预期的行为。

我们用Rust实现一下这段代码。

rust">fn main()
{let str_ref;{let str_obj: String = String::new();str_ref = &str_obj;}println!("{str_ref }");
}

相同的逻辑,只是Rust中将指针改为了引用(引用就是一个指针)。当执行流结束代码块之后str_obj将会被释放,但是此时str_ref 还指向这个局部变量。尝试编译一下。

在这里插入图片描述

不出意外的,Rust的生命周期检查器发现了这个问题,报错信息是borrowed value does not live long enough,他说str_obj的生命周期不够长,引用str_ref在str_obj的生命结束后还在使用。

Rust在编译时会尝试为每个引用和被引用的对象分配一个生命周期,生命周期完全是Rust在编译期虚构的产物,在运行期,引用就是一个地址,所以生命周期不会有任何运行期开销。有了生命周期,在编译期,生命周期检查器就会对比被引用对象和引用之间的生命周期关系,如下:

在这里插入图片描述

黄色框表示str_obj的生命周期;引用str_ref 的生命周期是,str_ref 从初始化开始到str_ref 最后被使用的地方之间的代码块就是str_ref 的生命周期,所以这里白色方块表示引用str_ref 的生命周期。生命周期检查器准则之一是,引用的最大生命周期不能超过被引用对象的生命周期,很明显,这里违反了这条规则,所以无法通过编译。

Rust解决野指针最重要的方法就是生命周期,这里只是介绍了最简单的一个场景,在学习Rust时,一定要理解生命周期的含义。


缓冲区溢出

在C++中,以vector为例,想要以索引的方式访问某个对象时,我们通常会使用vector的at方法进行访问,at方法会进行数组越界检测,这很安全

但是vector可以通过data方法返回一个C/C++的原生数组,当我们对原生数组进行索引操作时,完全是一种走钢丝的行为。

因为没有任何越界检测,此时如果发生缓冲区溢出,将会是一个未定义的行为。如果影响了其他变量,那么这将会是一个非常难排查的问题;如果改动了不可写的地址,那么会导致程序崩溃;如果运气好溢出的部分没有影响到任何对象,那看起来将会是一切安好,但是我们并不总是有那么好的运气。

这种未定义的行为绝对不是我们想要的。来看看Rust是怎么做的。

rust">fn main() {let vec: Vec<i32> = vec![1,2,3];let vec_ref: &[i32] = &vec[0 ..];for i in 0 .. 4 {println!("{}", vec_ref[i]);}

上面是在rust中创建了一个vector——vec,其长度为3(内容为1、2、3),然后一个引用vec_ref(指针)指向这个vec。

紧接着使用引用vec_ref故意进行了一次缓冲区溢出的轮询操作, 此时我们能够正常通过编译。这当然能够编译通过,千万不要妄想Rust能够在编译期解决缓冲区溢出这种主要在运行期出现的问题

但是cargo run运行时

在这里插入图片描述

可以清楚的看到导致了panic,提示长度是3,但是index也是3,出现了缓冲区溢出的访问。也就是说Rust对于缓冲区溢出的访问会有一个已定义的行为——导致线程panic。但是新问题又来了,为什么一个引用(指针)vec_ref也有长度信息呢?

如果只是一个普通的引用当然不会有长度信息,但是这里的引用vec_ref是对一个连续数据vec的引用。在Rust中,vec_ref准确的说是一个切片。对一个连续数据的引用(切片),引用本身是一个胖指针,即该引用占两个机器字(普通引用只是一个普通指针,内存上只占用一个机器字)的内存,第一个机器字是被引用的连续数据的首地址;第二个机器字是连续数据的长度。

下面是打印两种引用占用内存大小的代码。

rust">fn main() {let vec: Vec<i32> = vec![1,2,3];let vec_ref: &[i32] = &vec[0 ..];let num: i32 = 3;let num_ref: &i32 = &num;println!("vec_ref size_of:{} num_ref size_of:{}",std::mem::size_of_val(&vec_ref), std::mem::size_of_val(&num_ref))
}

输出为vec_ref size_of:16 num_ref size_of:8。说明,引用(切片)vec_ref占用16Bytes,引用num_ref占用8Bytes,我的电脑是64位的电脑,刚好是两个机器字和一个机器字。

除了切片之外,Rust中的原生数组也是带有长度信息的,所以在使用原生数组出现缓冲区溢出时,也会导致已定义的行为。

综上,因为缓冲区溢出主要是一个运行期的行为,所以Rust也没办法做到在编译期解决这个问题,但是通过胖指针的方式,Rust做到了在运行期如果出现缓冲区溢出,那一定会有一个已定义的行为——线程panic。这肯定好过C/C++中缓冲区溢出后,各种未定义的奇葩问题。


对空指针进行解引用

C/C++中对空指针解引用导致的崩溃问题更多的是开发人员个人编程习惯导致的。

在C/C++中,一个更好的编程习惯是在解引用指针之前,先对指针进行判空操作,但是这样简单的一个判断逻辑常常因为开发同学的“自信”,导致在很多地方偷懒忽略,然后直接对指针解引用后开始操作。往往越是自信不会为空的地方越是会给我们带来最承重的打击。

针对空指针解引用,首先Safe Rust中只有引用没有指针,这里的引用和C++中的引用类似,本质也是一个指针。Safe Rust中,在使用一个引用之前,必须对引用赋值,否则无法通过rustc的检测

rust">fn main() {let s: String = String::new();let s_ref: &String = &s;
}

s是一个String类型的变量,s_ref是对s的一个引用。只有对s_ref赋值后才能对s_ref进行使用。rustc通过强制检测你的编码实现,杜绝了空指针的使用。

当然,一定存在一个场景。某个引用,其需要引用的对象可能在程序运行之初并没有被创建,随着程序的运行才创建,创建后还需要让这个引用指向这个刚创建的对象,也就是说需要Rust支持引用一开始为空,随着程序的运行才被赋值的情况。

上面这种场景下需要采用OptionRust中,一切可能为空的东西都需要使用Option进行包裹,不仅仅是引用。

rust">fn main() {let mut s_ref_option: Option<&String> = None;let s: String = String::new();s_ref_option = Some(&s);
}

这一次s_ref_option因为可能为空,所以被声明为Option<&String>类型的None,语义为,有一个T&String类型的Option,这个Option目前包裹的值是None,但是后面可能会赋值,所以后续要想获取s_ref_option中包裹的&String时,你需要进行检查,因为不确定后面会不会赋值。

紧接着,s才被创建,然后使用Some包裹后赋值给s_ref_option。

通过Option获取其包裹的值通常有两种做法,一种是安全的,一种是不安全的。安全的操作是在使用之前对Option进行判空,显而易见这很安全

rust">// 使用 s_ref_option 时判空
if let Some(v) = s_ref_option {//... v 是&String
}

但这在Rust中也不是强制的,开发人员也可以以一种不安全的方式使用Option——直接获取Option中包裹的值。

rust">// 不判空直接获取Option中包裹的值
let s_ref: &String = s_ref_option.unwrap();

这和C/C++中直接进行空指针解引用并没有什么区别。

但是好在可以通过rustc中内置的静态代码检测工具clippy,对代码进行扫描,如果检测到代码中有使用unwrap,那么直接报error,clippy帮助检查代码中是否有这种危险的使用。这可以理解为是Rust编程的一种规范,让不写unwrap作为Rust编程规范的一部分。

clippy中可以通过设置clippy::restriction集中的unwrap_used这条规范达到我们的目的,具体可以看我的另一篇博客 Rust代码静态分析工具Clippy浅析

综上,Rust通过编译器,强制检测引用(指针)在使用之前必须赋值解决了这个问题。对于可能为空的对象,配合clippy使用,对于是否可以直接解引用可能为空的对象的选择权留给开发者,也不为是一种比较好的方案。


非法释放内存

C/C++中存在非法释放内存的情况,比如double free、非法释放栈上的内存等等,这些操作都会导致程序的崩溃。

作为非GC系的语言,Rust也面临释放内存资源的问题。但是当你真正开始使用Safe Rust时会发现,你基本不需要关心内存的释放,因为Rust将C++中的精华RAII发挥到了极致。

对于需要进行内存管理的对象类型,其都会实现Drop 特型,定义如下:

rust">pub trait Drop {// Required methodfn drop(&mut self);
}

实现该特型的类型,其实例在被释放前都会调用这个方法,类型的实现者可以在drop中释放自己管理的资源,这和C++中的析构函数一样。RAII在Rust中被大量采用,所以作为一个Rust的开发者,在Safe Rust中,你基本不需要再去进行内存管理。

总结

Rust作为一颗冉冉升起的新星,已经得到了越来越多人的认可,将其压入你的技术栈,一定会是一个不错的选择。



http://www.ppmy.cn/devtools/137720.html

相关文章

利用Java爬虫获取1688商品类目:技术解析与代码示例

在电商领域&#xff0c;1688作为中国领先的B2B电商平台&#xff0c;其商品类目的数据对于商家来说具有极高的价值。通过自动化的爬虫技术&#xff0c;我们可以高效地获取这些数据&#xff0c;为市场分析、价格监控和库存管理等提供支持。本文将详细介绍如何使用Java编写爬虫程序…

Flask 基于wsgi源码启动流程

1. 点击 __call__ 进入到源码 2. 找到 __call__ 方法 return 执行的是 wsgi方法 3. 点击 wsgi 方法 进到 wsgi return 执行的是 response 方法 4. 点击response 方法 进到 full_dispatch_request 5. full_dispatch_request 执行finalize_request 方法 6. finalize_request …

YOLOv10改进,YOLOv10添加RFAConv卷积创新空间注意力和标准卷积,包括RFCAConv, RFCBAMConv,二次创新C2f结构,助力涨点

摘要 空间注意力已广泛应用于提升卷积神经网络(CNN)的性能,但它存在一定的局限性。作者提出了一个新的视角,认为空间注意力机制本质上解决了卷积核参数共享的问题。然而,空间注意力生成的注意力图信息对于大尺寸卷积核来说是不足够的。因此,提出了一种新型的注意力机制—…

uniapp生命周期:应用生命周期和页面生命周期

文章目录 1.应用的生命周期2.页面的生命周期 1.应用的生命周期 生命周期的概念&#xff1a;一个对象从创建、运行、销毁的整个过程被称为生命周期 生命周期函数&#xff1a;在生命周期中每个阶段会伴随着每一个函数的出发&#xff0c;这些函数被称为生命周期函数 所有页面都…

2022 年 3 月青少年软编等考 C 语言三级真题解析

目录 T1. 和数思路分析T2. 生理周期思路分析T3. 分解因数T4. 文件结构 “图”思路分析T5. 矩形数量思路分析T1. 和数 给定一个正整数序列,判断其中有多少个数,等于数列中其他两个数的和。比如,对于数列 1 2 3 4 1\ 2\ 3\ 4 1 2 3 4,这个问题的答案就是 2 2 2,因为…

C++模板(入门)

文章目录 泛型编程函数模板函数模板的概念函数模板格式函数模板的原理函数模板的实例化隐式实例化显示实例化模板参数的匹配 类模板为什么有类模板类模板的定义格式类模板的实例化Stack模板类的简单实现&#xff08;不涉及深拷贝&#xff09; 模板的注意问题模板不支持分离编译…

Android studio 利用cmake编译和使用so文件

1.编译出so文件 1.1 创建支持c的项目 需要在sdk-tools下载ndk和cmake Android studio会自动给一个含有jni的demo&#xff0c;运行打印出 hello c&#xff1b; //这边你文件project static {System.loadLibrary("withnewest");} //声明需要调用的方法 public nativ…

Vscode进行Java开发环境搭建

Vscode进行Java开发环境搭建 搭建Java开发环境(Windows)1.Jdk安装2.VsCode安装3.Java插件4.安装 Spring 插件5.安装 Mybatis 插件5.安装Maven环境6.Jrebel插件7.IntelliJ IDEA Keybindings8. 收尾 VS Code&#xff08;Visual Studio Code&#xff09;是由微软开发的一款免费、开…