0. 资料汇总
- RISC-V
- RISC-V ELF psABI: Processor-specific application binary interface document.
- RISC-V Supervisor Binary Interface: Spec for SBI.
- RISC-V C API: RISC-V-specific predefined macros, function attributes and language extensions.
- RISC-V Assembly Programmer’s Manual: Document for pseudoinstructions and assembly directives.
- RISC-V Specifications:
- RISC-V ACLINT specification: ACLINT (Advanced Core Local Interruptor) specification defines a set of memory mapped devices which provide inter-processor interrupt and timer functionality for each HART of a multi-HART (or multi-processor) RISC-V platform.
- RISC-V Assembly Programmer’s Manual: Provide guidance to assembly programmers targeting the standard RISC-V assembly language.
- rCore
- rCore 第一章相关内容的实现记录在 Github Tag: [ch1]
- rCore source code of labs for spring 2023: rCore-Tutorial-Guide-2023S Source Code
- rCore Concise Manual: rCore-Tutorial-Guide-2023S
- rCore Detail Book: rCore-Tutorial-Book-v3
1. 为内核支持函数调用
1.1 导读问答
-
如何使得函数返回时能够跳转到调用该函数的下一条指令,即使该函数在代码中的多个位置被调用?
RISC-V 中
ra
寄存器 (即x1
寄存器) 是用来保存函数的返回地址的, 函数调用时会使用相关的跳转指令jal
或者jalr
, 这两条指令都会在函数调用前将pc+4
即下一条指令的地址存入rd
寄存器, 一般情况下rd
寄存器会选为ra
。 在函数需要返回时, 只需要通过ret
汇编伪指令, 即可使主程序继续在ra
保存的物理地址继续执行, 这里ret
伪指令会被解析为jalr x0, 0(ra)
。 但在使用ra
寄存器时需要注意函数调用上下文, 保证控制流转移前后特定的寄存器值保持不变。 -
对于一个函数而言,保证它调用某个子函数之前,以及该子函数返回到它之后(某些)通用寄存器的值保持不变有何意义?
这些特定的寄存器分为 Caller-Saved 和 Callee-Saved 两类, 对于编译器而言, 每个函数的编译是独立的, 子函数的寄存器是未知的, 这些寄存器的值的改变可能会影响整个函数的运行。 例如
ra
在嵌套函数调用中, 若没有保存通用寄存器的值, 可能会因子函数覆盖了ra
的值而使得控制流出现混乱。 因而保证这些特定的通用寄存器的值保持不变可以保证 多层嵌套调用 的正确, 以及实现对寄存器的复用 (寄存器资源非常珍贵)。 -
调用者函数和被调用者函数如何合作保证调用子函数前后寄存器内容保持不变?调用者保存和被调用者保存寄存器的保存与恢复各自由谁负责?它们暂时被保存在什么位置?它们于何时被保存和恢复(如函数的开场白/退场白)?
需要依据 Calling Convention 对各自需要保存的寄存器进行保存, 正如 Caller-Saved 和 Callee-Saved 二者的字面含义, 调用者保存寄存器就由调用函数保存, 被调用者寄存器则由被调用函数保存, 这些寄存器都被保存在 栈帧 上。 一般在被调用函数开始时会保存这些寄存器, 而在被调用函数结束时会恢复寄存器, 这对于 Caller 和 Callee 而言是一致的。
-
在 RISC-V 架构上,调用者保存和被调用者保存寄存器如何划分的?
主要还是根据 调用规范(Calling Convention)
- a0~a7(
x10~x17
), 用来传递输入参数, 其中的 a0 和 a1 还用来保存返回值。 调用者保存。 - t0~t6(
x5~x7
,x28~x31
), 作为临时寄存器使用,在被调函数中可以随意使用无需保存。 - s0~s11(
x8~x9
,x18~x27
), 作为临时寄存器使用,被调函数保存后才能在被调函数中使用。 被调用者保存。
- a0~a7(
-
sp 和 ra 是调用者还是被调用者保存寄存器,为什么这样约定?
- ra(
x1
) 是被调用者保存的。 被调用者函数可能也会调用函数, 在调用之前就需要修改ra
使得这次调用能正确返回。 因此,每个函数都需要在开头保存ra
到自己的栈帧中,并在结尾使用ret
返回之前将其恢复。 - sp(
x2
) 是被调用者保存的。sp 是栈指针 (Stack Pointer) 寄存器, 它指向下一个将要被存储的栈顶位置。 sp 寄存器和 fp 寄存器构成了当前栈帧的空间范围, 同样的被调用函数也会调用其他函数, 这会更新栈帧, 所以 sp 以及 fp 都约定为被调用者保存寄存器。
- ra(
-
如何使用寄存器传递函数调用的参数和返回值?如果寄存器数量不够用了,如何传递函数调用的参数?
a0~a7 可用以传递函数参数, 而 a0, a1 则用以保存函数返回值。 若寄存器数量不足, 可以通过栈进行参数传递, 在参数压栈结束后, 可以在 a0 或者 a1 中保存当前 sp 的值 (栈顶位置), 通过偏移获取栈中保存的参数。
1.2 程序解释与问题记录
在该章节的评论区讨论最多的是 extern "C"
以及 *mut u8
裸指针转换的问题, 下面的代码是更改完善后的。 linker script 中的全局变量定义后, 在 C 语言中可以通过 extern int x
这种形式导入, 这在 rust 中也是可以通过类似的方式实现的, 可以参考 rustsbi 中的方式。 另外, 根据 Rust 程序设计语言 中的 19.1不安全的 Rust: 解引用裸指针 中的 描述 (ptr as *mut u8)
实际是将 ptr
所指向的 64 位地址转为 u8
类型的可变指针, ptr.write_volatile(0)
方法将 0 写入指针类型 ptr
存储的地址所指向的 1 字节内存区域。
fn clear_bss() {
extern "C" {
static mut sbss: u64;
static mut ebss: u64;
}
unsafe {
(sbss as usize..ebss as usize).for_each(|ptr|{
// use volatile to avoid compiler optimization
(ptr as *mut u8).write_volatile(0);
}
);
}
}
但是这种 static mut var: u64
的实现办法后来在调用 info!
这类 log 打印的时候出现了问题, 提示找不到 ebss
所在位置, 或者有几个全局变量的值无法正确读取, 但 恢复成 fn ebss()
就可以了。
另外关于栈中在 linker script 中被置于 .bss
段也有讨论, 在 程序员的修养——链接、装载与库 中就有提及
“链接器为目标文件分配地址和空间” 这句话中的 “地址和空间” 其实有两个含义:第一个是在输出的可执行文件中的空间;第二个是在装载后的虚拟地址中的虚拟地址空间。
对于 .bss
这样的段来说, 分配空间的意义只局限于虚拟地址空间, 因为它在文件中并没有内容。 对于栈的初始化而言, 将其置于 .bss
段可避免其占用实际的 ELF 文件的空间, 但在后续加载内核内核后, 该部分栈会被分配到 VMA 中占据一定的虚拟内存空间。
2. RustSBI
SBI 是 RISC-V Supervisor Binary Interface 规范的缩写,OpenSBI 是RISC-V官方用C语言开发的SBI参考实现;RustSBI 是用 Rust 语言实现的 SBI。 RustSBI 的功能和 u-boot(SPL) 很类似但相较而言简单很多, 只需要在 boot 阶段为上层应用完成初始化工作后转移控制权给内核。 但是, RustSBI 又直接构成了内核和硬件沟通的桥梁, 为操作系统提供一系列二进制接口,以便其获取和操作硬件信息, RustSBI 能在内核运行时响应内核的请求为内核提供服务。
Western Digital 在 2019 年 12 月的一份 An Introduction to RISC-V Boot Flow 报告中有这么一幅流程图阐述了 RISC-V 的 boot 流程。
RISC-V Upstream Boot Flow, Western Digital
这里引用 dengji 在附录 C 评论中对 boot 流程以及对 RISV-V SBI 的解释:
- Loader 要干的事情,就是内存初始化, 以及加载 Runtime 和 BootLoader 程序。 而Loader自己也是一段程序,常见的Loader就包括 BIOS/UEFI, 后者是前者的继任者。
- Runtime 固件程序是为了提供运行时服务(runtime services),它是对硬件最基础的抽象,为 OS 提供服务,当我们要在同一套硬件系统中运行不同的操作系统, 或者做硬件级别的虚拟化时, 就离不开Runtime 服务的支持。 SBI 就是 RISC-V 架构的 Runtime 规范。
- BootLoader 要干的事情包括文件系统引导、 网卡引导、 操作系统启动配置项设置、 操作系统加载等等。常见的 BootLoader 包括 GRUB,U-Boot,LinuxBoot 等。
- 而 BIOS/UEFI 的大多数实现, 都是 Loader、 Runtime、 BootLoader 三合一的,所以不能粗暴的认为 SBI 跟 BIOS/UEFI 有直接的可比性。 如果把 BIOS 当做一个泛化的术语使用, 而不是指某个具体实现的话, 那么可以认为 SBI 是 BIOS 的组成部分之一。
3. print 与 println 宏
借机复习一下 macro, 被 rust-quiz 痛打的经历历历在目。 宏主要分为 macro_rules!
这样的声明宏以及 procedural
过程宏。 在一个文件中调用宏之前必须定义它, 或者将其引入作用域之中。 实现 print!
宏以及 println!
宏需要 #[macro_export]
注解表明引入改宏到作用域之中。 以 print!
宏为例, 由内向外展开分析, 需要记住 2.1 A Methodical Introduction 中 Repetition 原则, $ (...) sep rep
。
#[macro_export]
macro_rules! print {
($fmt: literal $(, $($arg: tt)+)?) => {
$crate::console::print(format_args!($fmt $(, $($arg)+)?));
}
}
#[macro_export]
macro_rules! println {
($fmt: literal $(, $($arg: tt)+)?) => {
$crate::console::print(format_args!(concat!($fmt, "\n") $(, $($arg)+)?));
}
}
-
, $($arg: tt)+
该部分会匹配由,
分隔的一段重复序列,+
表示改重复部分至少出现一次, 重复的内容是 token tree, 它将被捕获存放在元变量arg
中。 -
$($fmt: literal $(...)?)
该部分有literal
fragment-specifier, 根据 The Rust Reference - 8.2.1. Literal expression 中的描述, literal expression 是主要在编译阶段分析的常量表达式, 由单个 token 构成, 一般而言 CHAR, STRING, RAW STRING, BYTE, BYTE STRING, RAW BYTE STRING, INTEGER, FLOAT, BOOL 均为 literal 类型。 根据 Rust Reference - 3.1 Macros By Example 中的描述, 在 MacroMatch() => {}
中应当由两组匹配模式, 一组是$fmt: literal
对应$ ( IDENTIFIER_OR_KEYWORD except crate | RAW_IDENTIFIER | _ ) : MacroFragSpec
, 另一组就是$(, $($arg: tt)+)?
, 对应$ ( MacroMatch+ ) MacroRepSep? MacroRepOp
。 -
Matcher 的右侧就是 MacroTranscriber, 可以看到我们使用了外部函数
print
, 并用format_args!
宏将$fmt $(, $($arg)+)?
组装成完整的字符串, 这类似 C/C++ 中的sprintf
。 另外在println!
中使用的concat!
宏将多个字符串连接在一起。
rustwiki - Macro std::format_args
rust-lang doc - Macro std::concat
The Rust Reference
The Little Book of Rust Macros
Rust 程序设计语言 - 19.5 宏
4. 课后练习
4.1 编程题
-
实现一个linux应用程序A,显示当前目录下的文件名。(用C或Rust编程)
use std::{process::Command}; fn main() { let output = Command::new("ls").arg("-a").output().expect("failed to execute the process"); let file_list = String::from_utf8(output.stdout).unwrap(); println!("{}", file_list); }
-
实现一个linux应用程序B,能打印出调用栈链信息。(用C或Rust编程)
Stack Frame, x86根据文档描述, 栈帧存储着函数之间的调用信息, 当前栈帧的头部是 sp(x1) 指向的位置, 尾部是 fp(s0) 指向的位置。 其中
fp
寄存器中保存了父栈帧的结束地址, x86 架构的结构与 RISC-V 有所不同,rbp
存储的内容就是父栈帧的rbp
地址。 另外, 在调试过程中发现有stack overflow
的错误, 参考给出解释需要用到libunwind
库解决标准库不存储栈帧指针的问题, 遂仅进行有限个栈帧回溯。 注意需要开启force-frame-pointers
编译选项。use core::arch::asm; fn print_stack_trace_chain() { let fp: usize; println!("== STACK TRACE BEGIN"); unsafe { asm! ( "mov {fp}, rbp", fp = out(reg) fp, ); } let mut fp = fp; for _ in 0..5 { println!(" == {:#p}", (fp) as *mut usize); fp = unsafe { (fp as *const usize).offset(0).read() }; } println!("== STACK TRACE END"); }
-
实现一个基于rcore/ucore tutorial的应用程序C,用sleep系统调用睡眠5秒(in rcore/ucore tutorial v3: Branch ch1)
折腾半天读不到 MIE, SIP 寄存器的值, 暂时搁置了。
4.2 问答题
-
应用程序在执行过程中, 会占用哪些计算机资源?
CPU/GPU 计算资源, 内存/外存资源, 缓存资源等。
-
请用相关工具软件分析并给出应用程序A的代码段/数据段/堆/栈的地址空间范围。
使用
readelf -S pro1
查看 section headers 信息, 我的程序的代码段地址空间为 [0x7080, 0x4ef04], 数据段地址空间范围是 [0x65000, 0x65038] 这里有做地址对齐。 另外, 堆/栈的信息是动态分配的, 根据参考中的说明, 需要在后台查看/proc/[pid]/maps
。 -
请简要说明应用程序与操作系统的异同之处。
应用程序和操作系统其实都可以被称作一种系统程序, 不同的是二者对于硬件的支配能力。 操作系统和硬件直接相关, 能够独立地运行在硬件设备上并对硬件以及外设进行控制, 它为上层的应用程序提供了服务与接口, 从这点可以看出, 应用程序对硬件的管理是间接的, 它需要向操作系统请求各种服务与功能, 这也是操作系统的隔离性与安全性要求。 另外, 应用程序一般默认被认定为恶意的, 反之操作系统是值得信任的。
-
请基于 QEMU 模拟 RISC—V 的执行过程和 QEMU 源代码, 说明 RISC-V 硬件加电后的几条指令在哪里? 完成了哪些功能?
对 QEMU 的执行流程不是太了解, 但按照前述的 boot flow, 第一阶段肯定是模拟 ROM 中的指令进行初始化, 在
qemu7.0.0/hw/riscv/boot.c
中可以找到riscv_setup_rom_reset_vec
, 相当于 ROM 的初始化设置, 具体的配置内容如reset_vec
所示。/* reset vector */ uint32_t reset_vec[10] = { 0x00000297, /* 1: auipc t0, %pcrel_hi(fw_dyn) */ 0x02828613, /* addi a2, t0, %pcrel_lo(1b) */ 0xf1402573, /* csrr a0, mhartid */ 0, 0, 0x00028067, /* jr t0 */ start_addr, /* start: .dword */ start_addr_hi32, fdt_load_addr, /* fdt_laddr: .dword */ fdt_load_addr_hi32, /* fw_dyn: */ }; if (riscv_is_32bit(harts)) { reset_vec[3] = 0x0202a583; /* lw a1, 32(t0) */ reset_vec[4] = 0x0182a283; /* lw t0, 24(t0) */ } else { reset_vec[3] = 0x0202b583; /* ld a1, 32(t0) */ reset_vec[4] = 0x0182b283; /* ld t0, 24(t0) */ }
AUIPC (add upper immediate to pc), 其作用是计算 PC 的相对地址并将结果存储在 rd 中。 根据 9.38.3 RISC-V Assembler Modifiers 中的描述,
%pcrel_hi
以及%pcrel_lo
的定义为:- %pcrel_hi(symbol)
- The high 20 bits of relative address between pc and symbol.
- %pcrel_lo(label)
- The low 12 bits of relative address between pc and symbol.
另外需要注意的是
1b
实际上是 RISC-V 汇编 Label 的一种写法, 拆开来是 1 backward, 表示在 1 这个标签值之后的标签。 同样道理可能会出现1f
, 意思就正好相反表示在 1 这个标签之前。 也就是前两句实际是处理 Code Independent 设计的获取fw_dyn
的地址。- mhartid
- Hart ID Register (mhartid), 运行当前代码的硬件线程 (hart) 的 ID。
因而在获取
fw_dyn
的地址后, 会将当前代码硬件线程的 ID 存储到a0
寄存器中。 另外在跳转到fw_dyn
地址后, 由于我们的系统是 RISCV64, 因而ld a1, 32(t0)
会将fdt_load_addr
即 Flatten Device Tree 的地址写入a1
之后更新t0
为start_addr
后适用jr t0
指令跳转到start
也就是程序的入口地址处。[Question about%pcrel_hi and %pcrel_lo for qemu virt machine] - StackOverflow
[What do %pcrel_hi and %pcrel_lo actually do?] - StackOverflow -
RISC-V 中的 SBI 的含义和功能是啥?
SBI 是 Supervisor Binary Interface 缩写, SBI允许在所有 RISC-V 实现上, 通过定义平台(或虚拟化管理程序)特定功能的抽象, 使监管者模式 (S-mode 或 VS-mode) 的软件具备可移植性。 SBI 的设计遵循 RISC-V 的一般原则, 即核心部分小而精简, 同时具备一组可选的模块化扩展功能。 这套特权态软件和运行机器的二进制接口把机器行为抽象了, 特权态软件通过这套二进制标准向底层机器请求服务。这个特权态软件包括 host 上的内核, 也包括跑在 guest 上的内核。
-
为了让应用程序能在计算机上执行, 操作系统与编译器之间需要达成哪些协议?
操作系统为程序提供了库文件, 编译器依赖这些程序文件以生成适用操作系统与平台的特定文件格式, 在 Linux 中这种文件格式是 ELF, 在 Windows 中是 COFF 等。 这类程序文件需要为操作系统提供符号表、 段等各种必要的信息, 使得程序能够在特定的内存空间正常运行。
-
请简要说明从 QEMU 模拟的 RISC-V 计算机加电开始运行到执行应用程序的第一条指令这个阶段的执行过程。
RustSBI 会在内核加载前完成一系列的初始化操作, 例如串口, Flash 等设备, 之后会将控制权递交给 Kernel (这里没有 u-boot, RustSBI 直接加载了内核)。 同样的, 内核会对网口、内存、文件系统等进行初始化并在最后跳转到应用程序的第一条指令处。
-
为何应用程序员编写应用时不需要建立栈空间和指定地址空间?
操作系统抽象了内存管理, 一般而言 MMU 管理了应用程序的地址空间, 完成了虚拟地址到实际物理地址的映射。 栈空间和堆空间同样依赖虚拟地址映射, 而这部分底层工作已经交由操作系统分配以及管理了。
-
现代的很多编译器生成的代码, 默认情况下不再严格保存/恢复栈帧指针。 在这个情况下, 我们只要编译器提供足够的信息, 也可以完成对调用栈的恢复。… 根据给出这些信息,调试器可以如何复原出最顶层的几个调用栈信息?假设调试器可以理解编译器生成的汇编代码。
(gdb) disassemble flap Dump of assembler code for function flap: 0x0000000000010730 <+0>: addi sp,sp,-16 // 唯一入口 0x0000000000010732 <+2>: sd ra,8(sp) ... 0x0000000000010742 <+18>: ld ra,8(sp) 0x0000000000010744 <+20>: addi sp,sp,16 0x0000000000010746 <+22>: ret // 唯一出口 ... 0x0000000000010750 <+32>: j 0x10742 <flap+18> (gdb) disassemble flip Dump of assembler code for function flip: 0x0000000000010752 <+0>: addi sp,sp,-16 // 唯一入口 0x0000000000010754 <+2>: sd ra,8(sp) ... 0x0000000000010764 <+18>: ld ra,8(sp) 0x0000000000010766 <+20>: addi sp,sp,16 0x0000000000010768 <+22>: ret // 唯一出口 ... 0x0000000000010772 <+32>: j 0x10764 <flip+18> End of assembler dump. # state (gdb) p $pc $1 = (void (*)()) 0x10752 <flip> (gdb) p $sp $2 = (void *) 0x40007f1310 (gdb) p $ra $3 = (void (*)()) 0x10742 <flap+18> (gdb) x/6a $sp 0x40007f1310: ??? 0x10750 <flap+32> 0x40007f1320: ??? 0x10772 <flip+32> 0x40007f1330: ??? 0x10764 <flip+18>
根据上述信息, 当前的
pc
在flip
函数的入口位置, 此时的sp
在0x4007f1310
处, 而ra
在0x10742
处, 说明flip
函数调用后会返回到flap
函数中, 此时对应的指令为ld ra,8(sp)
, 那么读取到的ra
为0x10750
, 返回的位置仍在flap
中, 并根据指令其仍会 跳转到0x10742
处并继续从栈中取出地址0x10772
跳转到flip
中的j 0x10764
。
5. 实验练习
rCore-Tutorial-Guide-2023S 中没有 ch1 相关练习的, 作者已经实现了依赖 std::log
的打印输出, 既然这样不如直接在我们之前按照文档构建的 LibOS 上进行更改, 另外顺带把 Makefile 文件功能也补补全。
- Log Info
- 照着 docs.rs 网址中的 Crate log 的说明就可以了。 logging.rs
- Kernel Info
- 打印信息也比较简单, 仿照前面的
clear_bss
函数的写法获取全局变量的地址即可。 main.rs
Spring 2023 版的实验指导书精简了很多内容, 但提供了 ch3 到 ch6, 以及 ch8 这几个章节的 test 代码届时可以直接使用。