使用Rust打Android的so库

252 阅读13分钟

一、Rust的历史与现状:

1.历史
  • 2006年,软件开发者Graydon HoareMozilla(火狐浏览器最为知名)工作期间,开始了Rust作为一个个人项目。根据他在麻省理工技术评论的一次采访,Rust的灵感来自于Hoare公寓楼里一个坏掉的电梯。电梯操作系统的软件崩溃了,Hoare意识到这类问题通常源于程序如何使用内存的问题。通常,这些类型设备的软件是用CC++编写的,但这些语言需要大量的内存管理,可能导致系统崩溃的错误。因此,Hoare着手研究如何创建一种既紧凑又无内存错误的编程语言
  • Google已将Rust应用到Chromium、Android和FuchsiaOS中,其中Chromium对Rust的支持处于实验阶段。开发者可以使用Rust来开发适用于Android和FuchsiaOS的组件,并且Rust在Android和FuchsiaOS的内部代码中使用的比例相当大,特别是FuchsiaOS,Rust代码已经超过50%。 由于内部Cpp代码量较大,2022年10月,谷歌推出了基于开源RISC-V芯片的新型安全操作系统KataOS。Sparrow是KataOS的参考实现,运行在seL4上,几乎完全用Rust编写。该操作系统不是为台式电脑或智能手机设计的,而是为物联网设计的,可用于智能家居设备。目标是为嵌入式硬件或边缘设备构建可验证的安全操作系统,例如捕获图像的网络连接摄像头,这些图像在设备上或云中处理以进行机器学习。在2022年发布的Android 13版本中,谷歌还宣布Android13版本中大约21%的新原生代码(C/C++/Rust)是RustAOSP拥有约150万行Rust代码,涵盖了新功能和组件。此外,Android的Rust代码中已发现零内存安全漏洞。为了实现提高Android内部安全性、稳定性和质量的目标,Android团队还表示,Rust应该用在代码库中需要原生代码的任何地方

更多相关历史可以参考如下文章

tonybai.com/2024/04/22/…

2.影响力:

二、Why Rust?

1.特性

image.png

a.高性能

Rust通过零成本抽象、移动语义、泛型编程等特性,Rust 速度极快,内存效率极高,无需运行时或垃圾收集器(通过所有权系统来管理内存),它可以为性能关键型服务提供支持,在嵌入式设备上运行,并轻松与其他语言集成,使得程序能够在运行时达到与C、C++等传统系统编程语言相当的性能。

b.内存安全&线程安全(编译时)

Rust通过所有权(ownership)、生命周期(lifetime)和借用(borrowing)等特性,通过一整套基础设施和类型检查在编译时最大程度地检查出这些错误,从而保证程序的内存安全和线程安全。

c.生产力

Rust 拥有出色的文档、带有有用错误消息的友好编译器和一流的工具 - 集成的包管理器Cargo和构建工具、具有自动完成和类型检查的智能多编辑器支持、自动格式化程序等,使得开发者能够更轻松地编写、调试和维护Rust程序。

2.缺点

跟设计模式的出现一样,并不是为了使大家更便捷的开发,而是使大家更有规范的开发。

所以Rust能在编译阶段保证内存跟线程的安全,自然也存在一些限制和缺点:

  • 学习曲线陡峭:Rust 拥有复杂的语法和严格的规则,对于初学者来说可能会感到困难和挑战。
  • 编译时间长:由于 Rust 的编译器进行了大量的静态检查和优化,编译时间可能相对较长。
  • 错误处理繁琐:Rust 采用了 Result 和 Option 等类型来处理错误和空值,这要求开发人员进行显式的错误处理,导致一些额外的编码工作量。
  • Android的生态并不是很完善(AS没有插件):Google和Rust官方对于适配到Android应用项目的相关文档还不是很丰富。
  • Rust 无法于 C++ 直接混编,但是 Rust 可以支持 C 代码相互调用。
3.Rust和C++的区别:

一句话概述:在某些场景下当做better c++

Rust 与 C++ ,孰优孰劣?_rust c++-CSDN博客

a.目标与理念
  • Rust:由 Mozilla 主导开发,目标是构建一种既快速又安全的系统级编程语言,特别是解决 C 和 C++ 中常见的内存错误,如悬挂指针、数据竞争和空指针解引用等问题。Rust 强调安全性、并发安全和高效的内存管理,适合开发高性能的服务、系统工具、浏览器组件和其他要求严格控制资源和低级别操作的软件。
  • C++:是一种通用的、静态类型的、编译式的、多范式的编程语言,它源自C语言,旨在保留C语言的高效和底层控制力,同时引入了面向对象编程、模板元编程和现代编程概念。C++ 支持多种编程风格,包括面向对象、泛型编程、过程化编程,适合开发操作系统、游戏引擎、高性能计算、嵌入式系统等领域应用。
b.内存安全
  • Rust:通过所有权系统、借用 checker 和生命周期机制确保内存安全。编译器强制执行严格的规则,阻止数据竞争和未初始化的内存读写。Rust 不使用垃圾回收器,而是通过类型系统在编译时保证内存安全。
  • C++:虽然有智能指针等手段可以增强内存安全,但在默认情况下,C++ 仍然容易导致内存泄漏、悬挂指针等问题。开发者需要手动管理内存并通过RAII(Resource Acquisition Is Initialization)原则减少资源泄露,但编译器不会强制检测所有潜在的内存错误。
c.并发模型
  • Rust:提供强大的并发原语,如 std::sync 中的互斥锁、原子类型和通道。Rust 的所有权和借用系统天然支持线程安全编程,编译器会在编译时防止数据竞争。
  • C++:也有丰富的并发和多线程库,如C++11之后的标准库中的std::thread、std::mutex、std::atomic等,但是线程安全主要依赖程序员的经验和设计,编译器不能像Rust那样在编译时完全防止数据竞争。
d.语法与学习曲线
  • Rust:语法相对新颖,拥有简洁的宏系统(macros),强类型和模式匹配等特点。尽管Rust的学习曲线陡峭,尤其对于所有权和生命周期的理解,但其编译时的严格约束有助于编写出更为健壮的代码。
  • C++:语法复杂度高,功能丰富,拥有悠久的历史和庞大的生态系统。C++ 学习曲线同样陡峭,尤其是因为涉及到复杂的模板元编程、STL容器和算法等,而且错误可能导致难以调试的问题。
e.性能与优化
  • Rust:由于没有垃圾回收器,性能接近C/C++,并且其编译器优化能力强,能够生成高度优化的机器码。在某些基准测试中,Rust 的性能可以与C++相媲美,甚至在某些场景下表现更优。
  • C++:因其底层性和灵活性,C++ 在性能方面非常出色,可以直接操控硬件资源,且有许多成熟的编译器和优化技术。不过,性能也很大程度上取决于程序员对语言特性的理解和优化技巧。
f.社区与生态
  • Rust:社区活跃且注重友善,近年来发展迅速,拥有一系列高质量的开源库和框架,特别在WebAssembly、系统编程、网络服务等方面表现出色。
  • C++:拥有广泛的工业和学术背景支持,有大量的库、框架和成熟的应用案例,尤其是在游戏开发、金融、高性能计算等领域有深厚积累。

三、如何学习

直接从Rust 语言新人入门指南中找相关的资料进行学习即可 Rust 程序设计语言 简体中文版

简单讲两个特性

  1. 所有权

Rust 中的数据类型包括基本类型(例如整型、浮点型、布尔型等)和复合类型(例如数组、元组、结构体、枚举等),因为Rust不是面向对象型语言,所以没有对象这种说法。基本类型都是固定大小的简单值,通过栈来存储,不需要在堆上分配内存。因此,这些基本类型的值没有所有权的概念,也不需要在程序中显式地管理其内存。对于所有的复合类型,需要在堆上分配内存,Rust通过所有权机制来解决内存泄漏、悬垂指针等问题。

  • 作用域
fn main() {
    let s = "hello rust"; // s 拥有 "hello rust" 的所有权
    // s 离开作用域后,"hello rust" 将自动被丢弃
}

在这个简单的例子中,变量 s 拥有它所指向的字符串的所有权。当 s 离开作用域时,它所指向的数据会被自动清理。

  • 移动
 let x = 5;
 let y = x;
 // 因为整数是有已知固定大小的简单值,所以这两个 5 被放入了栈中。
 
 
 let s1 = String::from("hello");
 let s2 = s1;
 println!("{}, world!", s1); // 错误!s1 已经失效
 ```

由于上面讲到的作用域的影响,当 s2s1 离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放double free)的错误,也是内存安全性 bug 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。

2.错误处理

Rust 将错误分为两大类:可恢复的recoverable)和 不可恢复的unrecoverable)错误。对于一个可恢复的错误,比如文件未找到的错误,我们很可能只想向用户报告问题并重试操作。不可恢复的错误总是 bug 出现的征兆,比如试图访问一个超过数组末端的位置,因此我们要立即停止程序。

大多数语言并不区分这两种错误,并采用类似异常这样方式统一处理它们。Rust 没有异常。相反,它有 Result<T, E> 类型,用于处理可恢复的错误,还有 panic! 宏,在程序遇到不可恢复的错误时停止执行。

fn main() {
    panic!("crash and burn");
}
enum Result<T, E> {
    Ok(T),
    Err(E),
}


use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {e:?}"),
            },
            other_error => {
                panic!("Problem opening the file: {other_error:?}");
            }
        },
    };
}

3.异步编程

与Kotlin的协程写法一样

async fn async_task() {
    println!("do async task");
}

async fn main() {
    let handle = async_task();
    handle.await;
}

async_task是一个异步函数,main函数通过.await等待它的完成。

四、与Andorid结合

1.开发工具

同C/C++一样,Rust也是通过JNI来实现与Android交互的

但是由于AS中没有Rust语言相关的插件,所以目前只能用vscode或者idea开发后打包成so image.png image.png image.png

2.搭建环境+创建项目
  • 首先是下载和初始化,通过 curl 脚本即可快速下载 Rust。

image.png

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs
  • 使用rustc --version确认安装成功
  • 然后通过cargo new xxxx(项目名)创建一个rust项目

image.png

文件的作用和含义如下:

  • src/: 这是源代码目录,包含了项目的Rust源文件。

    • main.rs: 每个语言创建时都有的main函数。
  • target/: 这是编译后的文件目录,包含了编译过程的输出,例如二进制文件和库。

    • debug/: 在这个目录下通常包含了调试模式下编译的输出。
    • .rustc_info.json: 这是一个内部文件,用于存储编译器版本信息等元数据。
    • CACHEDIR.TAG: 这是用来指示某些缓存管理工具这个目录是用于缓存的。
  • Cargo.toml: 这是Cargo的清单文件,它定义了项目的依赖项和一些其他的元数据。(build.gradle)

  • Cargo.lock: 这个文件锁定了项目依赖项的版本,以确保所有开发者和部署都使用相同版本的依赖项。 通过cargo run运行程序会打印Hello,world! image.png

3.使用Rust编写JNI代码

官方论坛demo rustcc.cn/article?id=…

1.添加相关依赖

cdylib: 这个库类型用于创建一个C兼容的动态库,只有在 lib.rs 中配置的 Rust 代码才会编译成动态库,最后使用命令编译成.so文件。所以要有一个lib.rs,它类似于 android 清单文件,需要将 JNI 方法注册到这里边。

否则会提示 Caused by:can't find library android_rust_demo, rename file to src/lib.rs or specify lib.path image.png rust的JNI版本 docs.rs/releases/se… (所有的依赖版本都可以在这里看到)

2.引入ndk

在 Rust 项目的根目录创建 .cargo/config(config.toml) 文件并指定 Android SDK 中的 NDK 路径,需要配置 linker 和 ar 这两个工具的路径,这两个工具的说明如下:

  • linker:用于链接二进制文件的工具。链接器负责将编译生成的目标文件(object files)合并成一个最终的可执行文件或共享库(.so 文件)。
  • ar:指定用于创建和修改存档文件(如静态库)的工具。它负责将多个目标文件打包成一个单独的归档文件。 image.png cargo1.38版本以后,可以把config改成config.toml

这个配置后续在编译成.so动态链接库时用到32位 & 64位的编译器。

[target.aarch64-linux-android]
linker = "/Users/xxx/Library/Android/sdk/ndk/21.4.7075529/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android21-clang"

[target.armv7-linux-androideabi]
linker = "/Users/xxx/Library/Android/sdk/ndk/21.4.7075529/toolchains/llvm/prebuilt/darwin-x86_64/bin/armv7a-linux-androideabi21-clang"
3.写代码

我们接着在代码中创建 JNI 函数,规则和我们调用 C++的 JNI 函数是一样的,Java 下划线+包名+类名+方法名,包括传参规则也是一样,比如 JNI 中的 int 类型为 jint,String 类型为 jstring。

use jni::JNIEnv;
use jni::objects::{JClass};
use log::{info};

#[no_mangle]   // 禁用编译时的重命名,否则在java中会找不到方法
pub extern "C" fn Java_com_example_rust_RustTest_helloRust(_env: JNIEnv, _: JClass) {
    info!("Hello, Rust!");
}
mod test;

pub use test::Java_com_example_rust_RustTest_helloRust;
4.打so

配置好 linker 和 ar 后,在命令窗口中执行 rustup target add aarch64-linux-android(config中配置的) 指令,用于为 Rust 项目添加一个 64 位 so 库的编译目标。

最后在命令窗口中执行 cargo build --target aarch64-linux-android --release 执行,就可以生成 64 位的 release 版本的 so 库了。生成的 so 文件在 target/aarch64-linux-anroid/release 目录中,so的名字就是项目名

如果遇到ndk的错误,不用上网搜,直接去AS上下一个高版本的再重新试一下即可 image.png

5.项目调用
class RustTest {

    external fun helloRust()

    companion object {
        fun init() {
            System.loadLibrary("android_rust_sample");
        }
    }
}

参考资料: