环境
操作系统: 乌班图22.04
rust: rustc 1.75.0-nightly
QEMU emulator version 7.0.0
应用程序与基本执行环境
应用程序执行环境与基本操作平台
执行应用程序
我们先在Linux上开发并运行一个简单的 “Hello, world” 应用程序,看看一个简单应用程序从开发到执行的全过程。
cargo new os --bin
我们加上了 --bin 选项来告诉 Cargo 我们创建一个可执行程序项目而不是函数库项目。此时,项目的文件结构如下:
tree os os ├── Cargo.toml └── src └── main.rs 1 directory, 2 files
其中 Cargo.toml 中保存着项目的配置,包括作者的信息、联系方式以及库依赖等等。显而易见源代码保存在 src 目录下,目前为止只有 main.rs 一个文件,让我们看一下里面的内容:
最简单的 Rust 应用
fn main() { println!("Hello, world!"); }
进入 os 项目根目录下,利用 Cargo 工具即可一条命令实现构建并运行项目:
cargo run Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os) Finished dev [unoptimized + debuginfo] target(s) in 1.15s Running `target/debug/os` Hello, world!
如我们预想的一样,我们在屏幕上看到了一行 Hello, world! 。
应用程序执行环境
如下图所示,现在通用操作系统(如 Linux 等)上的应用程序运行需要下面多层次的执行环境栈的支持,图中的白色块自上而下(越往下则越靠近底层,下层作为上层的执行环境支持上层代码的运行)表示各级执行环境,黑色块则表示相邻两层执行环境之间的接口。
我们的应用位于最上层,它可以通过调用编程语言提供的标准库或者其他三方库对外提供的函数接口,使得仅需少量的源代码就能完成复杂的功能。但是这些库的功能不仅限于此,事实上它们属于应用程序执行环境(Execution Environment) 的一部分。在我们通常不会注意到的地方,这些软件库还会在执行应用之前完成一些初始化工作,并在应用程序执行的时候对它进行监控。我们在打印Hello, world!时使用的println!
宏正是由Rust标准库std提供的。
从操作系统内核的角度看来,它上面的一切都属于用户态软件,而它自身属于内核态软件。无论用户态应用如何编写,是手写汇编代码,还是基于某种高级编程语言调用其标准库或三方库,某些功能总要直接或间接的通过操作系统内核提供的系统调用(SystemCall)来实现。因此系统调用充当了用户和内核之间的边界。内核作为用户态软件的执行环境,它不仅要提供系统调用接口,还需要对用户态软件的执行进行监控和管理。
目标平台与目标三元组
对于一份用某种编程语言实现的应用程序源代码而言,编译器在将其通过编译、链接得到可执行文件的时候需要知道程序要在哪个平台(Platform)上运行。这里平台主要是指CPU类型、操作系统类型和标准运行时库的组合。从上面给出的应用程序执行环境栈可以看出:
• 如果用户态基于的内核不同,会导致系统调用接口不同或者语义不一致;
• 如果底层硬件不同,对于硬件资源的访问方式会有差异。特别是如果ISA不同,则向软件提供的指令集和寄存器都不同。
它们都会导致最终生成的可执行文件有很大不同。需要指出的是,某些编译器支持同一份源代码无需修改就可编译到多个不同的目标平台并在上面运行。这种情况下,源代码是跨平台的。而另一些编译器则已经预设好了一个固定的目标平台。
移除标准库依赖
移除 println! 宏
println! 宏所在的Rust标准库std需要通过系统调用获得操作系统的服务,而如果要构建运行在裸机上的操作系统,就不能再依赖标准库了。所以我们第一步要尝试移除println!宏及其所在的标准库。
由于后续实验需要rustc编译器缺省生成RISC-V64的目标代码,所以我们首先要给rustc添加一个target: riscv64gc-unknown-none-elf 。这可通过如下命令来完成:
rustup target add riscv64gc-unknown-none-elf
然后在os目录下新建.cargo目录,并在这个目录下创建config文件,并在里面输入如下内容:
# os/.cargo/config [build] target = "riscv64gc-unknown-none-elf"
这会对于Cargo工具在os目录下的行为进行调整:现在默认会使用riscv64gc作为目标平台而不是原先的默认x86_64-unknown-linux-gnu。
提供 panic_handler 功能应对致命错误
我们重新编译简单的os程序,之前的println宏缺失的错误消失了,但又出现了如下新的编译错误:
$ cargo build Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os) error: `#[panic_handler]` function required, but not found
在使用Rust编写应用程序的时候,我们常常在遇到了一些无法恢复的致命错误(panic),导致程序无法继续向下运行。这时手动或自动调用panic!宏来打印出错的位置,让软件能够意识到它的存在,并进行一些后续处理。
在标准库std中提供了关于panic!宏的具体实现,其大致功能是打印出错位置和原因并杀死当前应用。但本章要实现的操作系统不能使用还需依赖操作系统的标准库std,而更底层的核心库core中只有一个panic!宏的空壳,并没有提供panic!宏的精简实现。因此我们需要自己先实现一个简陋的panic处理函数.
注解: #[panic_handler]
#[panic_handler] 是一种编译指导属性,用于标记核心库core中的panic!宏要对接的函数(该函数实现对致命错误的具体处理)。该编译指导属性所标记的函数需要具有fn(&PanicInfo)-> !函数签名,函数可通过PanicInfo数据结构获取致命错误的相关信息。这样Rust编译器就可以把核心库core中的panic!宏定义与#[panic_handler]指向的panic函数实现合并在一起,使得no_std程序具有类似std库的应对致命错误的功能。
我们创建一个新的子模块lang_items.rs实现panic函数,并通过#[panic_handler]属性通知编译器
用panic 函数来对接panic!宏。为了将该子模块添加到项目中,我们还需要在main.rs的#![no_std]的下方加上mod lang_items;,相关知识可参考Rust模块编程:
// os/src/lang_items.rs use core::panic::PanicInfo; #[panic_handler] fn panic(_info: &PanicInfo)-> ! { loop {} }
在把panic_handler配置在单独的文件os/src/lang_items.rs后,需要在os/src/main.rs文件中添加以下内容才能正常编译整个软件:
// os/src/main.rs #![no_std] mod lang_items; // ... other code
移除 main 函数
我们再次重新编译简单的os程序,之前的#[panic_handler]函数缺失的错误消失了,但又出现了如下新的编译错误:… error:
.. code-block:: $ cargo build Compiling os v0.1.0 (/home/shinbokuow/workspace/v3/rCore-Tutorial-v3/os) error: requires `start` lang_item
编译器提醒我们缺少一个名为start的语义项。我们回忆一下,之前提到语言标准库和三方库作为应用程序的执行环境,需要负责在执行应用程序之前进行一些初始化工作,然后才跳转到应用程序的入口点开始执行。事实上start语义项代表了标准库std在执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库,编译器也就找不到这项功能的实现了。
最简单的解决方案就是压根不让编译器使用这项功能。我们在main.rs的开头加入设置#![no_main]告诉编译器我们没有一般意义上的main函数,并将原来的main函数删除。在失去了main函数的情况下,编译器也就不需要完成所谓的初始化工作了。
编译运行
# 编译 cargo build --release # 运行 qemu-system-riscv64 -machine virt -nographic -bios ../bootloader/rustsbi-qemu.bin -device loader,file=target/riscv64gc-unknown-none-elf/release/os,addr=0x80200000
内核指令
程序内存布局与编译流程
在我们将源代码编译为可执行文件之后,它就会变成一个看似充满了杂乱无章的字节的一个文件。但我们知道这些字节至少可以分成代码和数据两部分,在程序运行起来的时候它们的功能并不相同:代码部分由一条条可以被 CPU 解码并执行的指令组成,而数据部分只是被 CPU 视作可读写的内存空间。事实上我们还可以根据其功能进一步把两个部分划分为更小的单位: 段 (Section) 。不同的段会被编译器放置在内存不同的位置上,这构成了程序的 内存布局 (Memory Layout)。一种典型的程序相对内存布局如下所示:
- 已初始化数据段保存程序中那些已初始化的全局数据,分为 .rodata 和 .data 两部分。前者存放只读的全局数据,通常是一些常数或者是 常量字符串等;而后者存放可修改的全局数据。
- 未初始化数据段 .bss 保存程序中那些未初始化的全局数据,通常由程序的加载者代为进行零初始化,即将这块区域逐字节清零;
- 堆 (heap)区域用来存放程序运行时动态分配的数据,如 C/C++ 中的 malloc/new 分配到的数据本体就放在堆区域,它向高地址增长;
- 栈 (stack)区域不仅用作函数调用上下文的保存与恢复,每个函数作用域内的局部变量也被编译器放在它的栈帧内,它向低地址增长。
rcore 笔记 第一个裸机程序(二)https://developer.aliyun.com/article/1391889