<-- Home |--rust |--rfe

RFE-003: 工程师(粗)Rust入门之栈爆破

栈和值

在目前的范围内,Rust简直是可爱。有小龙同学的手速,配合Rust对变量名称的重复使用,把所有的值都放在栈内存中,简直是爽歪歪(小龙爽不爽?我就当他爽……)。什么内存,我们只计算值,值从本质上是没有变化的,当然一个值可以被用于构造另外一个值。每一个值都是独一无二的!骄傲不!

我们目前掌握的Rust变量使用方式,就是单纯值编程范式(Pure Value Programming Paradigm)。在这种范式下,变量名只是值的标签,变量名可以重复使用,变量名所指向的值是不可变的。每次使用let语句定义一个变量名时,实际上是创建了一个新的值,并将该值与变量名关联起来。之前的变量名所关联的值并没有被修改或者删除,而是依然存在于内存中,直到不再被引用时才会被回收。

当然,这个跟Rust放在栈区域的变量是一一对应的。Rust能够放在栈内存中的变量,有一个Copy trait约束,而Copy trait约束又是Clone trait约束的子集,Clone trait约束又是Sized trait约束的子集。所以呢,栈区域的主要特征就是线性内存空间、放置Copy类型的变量,而Copy类型的变量,必然实现知道尺寸。

栈区域这么好用,分配速度快,访问有序……那我们都用栈区域吼不吼啊,那当然吼啊~~~

那栈区域内存的边界在哪里呢?栈区域内存管理方法的问题在哪里呢?其实就是栈的大小限制,程序或者线程的栈区域必须实现分配好,毕竟能够全自主管理的内存需要赋予所有权(!第一次出现这个术语!)。每个线程都有自己的栈空间,栈的大小在创建线程时就确定了,通常是几百KB到几MB不等。如果栈空间不够用,就会发生栈溢出(stack overflow),导致程序崩溃。这个问题,工程师应该会很容易提出一个解决方案!给程序的栈空间增加大小不就行了嘛!

这个解决方案的问题,连小龙都知道,不能一开始把所有的扫描阀都给他……别人也要用嘛!其实类似于计算器就很好办,只有一个线程嘛,控制所有的内存……不过我们的电脑现在还是相当复杂的,操作系统、各种后台服务、各种前台应用程序,哪能把所有的栈空间都给一个程序用啊!

这也是单纯值编程范式的一个局限性,值编程范式要求所有的变量都放在栈内存中,栈内存空间有限,当我们需要处理的问题规模变大时,栈内存空间就会不够用,导致栈溢出错误。最大的问题还是,如果一个程序处理的数据量事先不清楚,就很难权衡如何分配栈内存空间。

栈爆破

对于我们目前掌握的命令式程序设计范式(如果有这个范式)来说,要搞栈爆破还得我这个师傅出马,因为小龙他还不会Array,也不会Loop,也不会函数。

哈哈哈哈!

在了解最小知识的情况下,如何把命令范式的程序搞出栈爆破呢?有个技术路线:

  1. 申请一个非常大的数组,虽然不知道数组是什么
  2. 循环申请i128变量,虽然不知道循环是什么
  3. 递归函数调用,虽然不知道函数是什么

看来,如论如何,小龙除了搞点当作值用的变量做计算产生新的值之外,必须得学会一些新东西了。

数组

Rust中数组是一种固定大小、同类型元素的集合。数组在栈内存中分配,适合存储少量数据。数组的大小在编译时确定,不能动态改变,因此,很符合我们工程师的视角,这是一个值!比如我们工程师要考虑一个位置的三维坐标,可以用一个包含三个f64类型元素的数组来表示:

1let position: [f64; 3] = [1.0, 2.0, 3.0];

因为大小和类型在编译时就确定了,Rust可以在栈内存中高效地分配和访问数组元素。对于小规模的数据处理,数组是一个非常合适的选择。

数组的标注方式是[T; N],其中T是元素类型,N是数组大小(元素个数)。数组的索引从0开始,可以通过索引访问数组元素。

数组初始化的方式有两种:1)显式指定每个元素的值:let arr = [1, 2, 3, 4, 5];;2)使用重复元素初始化数组:let arr = [0; 10];表示创建一个包含10个元素,值都为0的数组。

循环与条件判断

这两个玩意儿,一下子把我们命令式程序设计范式给干掉了,我们正式要进入结构化程序设计范式的领域了。

循环允许我们重复执行一段代码,直到满足某个条件为止。Rust中有三种主要的循环结构:loopwhilefor

loopwhile本质上是一样的,都是重复执行代码块,区别在于:前者无限循环,直到通过break语句退出;后者当条件为真时重复执行代码块。 突然发现一个问题,在不依赖可变的量的情况下,怎么搞循环呢?

那算了,小龙,这个对你来说太难了,我们先搞for循环吧。

1fn main() {
2 let nums = [1, 2, 3, 4, 5];
3 for n in nums.iter() {
4     println!("Number: {}", n);
5 }
6}

这是我们循环数组。我们还可以按照一定的范围循环:

1fn main() {
2    for i in 1..=5 {
3        println!("Index: {}", i);
4    }
5}

如果去掉等号=,就是14

函数

函数,在过程式程序设计范式中是一个非常重要的概念。函数允许我们将一段代码封装起来,赋予一个名称,以便在需要时重复使用。函数可以接受参数,并且可以返回值。

按照我们这里值编程的理念,函数的参数和返回值都是值,而不是引用或者指针。函数调用时,参数的值会被复制到函数内部,函数执行完毕后,返回值会被复制回调用者。这也是为什么函数调用不会改变外部变量的值。并且,Rust中栈内存的数据有什么Copy trait约束,函数参数和返回值也必须满足这个约束。

 1fn add(a: i32, b: i32) -> i32 {
 2    let result = a + b;
 3    result
 4}
 5fn main() {
 6    let a = 5;
 7    let b = 10;
 8    let result = add(a, b);
 9    println!("Result: {}", result);
10}

我们如果按照前面的办法主程序中的变量(值)地址打印出来,把函数调用内部的变量(值)地址打印出来,会发现函数调用时,参数值被复制到函数内部,函数执行完毕后,返回值被复制回调用者。这个认知很关键,纯函数调用不会改变外部变量的值。

爆栈大法

有了上面的基础,我们就可以搞栈爆破了!

大数组

最简单的办法,就是申请一个非常大的数组:

 1    let a = [0_i128; 62_765];
 2    println!("Array address: {:p}", &a);
 3    println!("Array size: {}", std::mem::size_of_val(&a));
 4    println!("Array alignment: {}", std::mem::align_of_val(&a));
 5    println!("Array type: {}", std::any::type_name_of_val(&a));
 6    println!("Array length: {}", a.len());
 7    // println!("Array elements: {:?}", a); // too large to print all
 8
 9    // pause to see output
10    println!("Press Enter to continue...");
11    std::io::stdin().read_line(&mut String::new()).unwrap();

大概这么点就能爆栈了,不同的计算机环境,栈大小不一样,能爆的大小也不一样。

因为Rust的编译器相当聪明,对变量的使用区间非常清楚,循环加码的办法没办法搞爆栈。只好去搞函数递归调用了。

递归函数

循环调用函数,是搞爆栈的终极大法!

1    fn deep(n: usize) {
2        println!("{:p} n={}", &n, n);
3        if n > 0 {
4            deep(n - 1)
5        }
6    }
7
8    deep(100_000);

实际上,我的电脑算到25207就爆栈了,大概深度就是74793差不多的数字。不同的计算机环境,栈大小不一样,能爆的大小也不一样。

1# ....
20x7ffd34f2d378 n=25207
3
4thread 'main' has overflowed its stack
5fatal runtime error: stack overflow, aborting
6Aborted (core dumped)

总结

好了,值编程在Rust中还是挺好玩的。只要我们理解了变量名只是值的标签,变量名可以重复使用,变量名所指向的值是不可变的,我们就可以在栈内存中愉快地进行计算。


文章标签

|-->rust |-->engineering |-->tutorial |-->stack |-->variables |-->memory |-->stack overflow |-->value


GitHub