[rCore学习笔记 09]为内核支持函数调用

在[[08 内核第一条指令|上一节]]我们使用了编写entry.asm函数中编写了内核的第一条指令,但是我们使用的汇编.这里注意我们仍然是嵌入了这段asm代码到我们的rust代码之中,然后进行编译.但是即使连使用fn main都不被允许,因此我们如果希望使用rust来编写内核代码,因此我们最好为内核提供函数调用.

开发方式

  1. 使用asm编写汇编代码进行初始化,然后将控制权交给rust编写的内核入口.
  2. 使用rust进行主要的内核开发工作

asm内核初始化

asm内核初始化是分为两个阶段进行的:

  1. 设置栈空间
  2. 在内核内使能函数调用
  3. 直接调用使用rust的内核入口参数

本节知识清单

官方的知识清单非常的不错:

  1. 我们需要关注知识清单的内容是我们需要关注的内容
  2. 知识清单的问题方式往往不是直接照本宣科一个问题对应一个答案的,必须消化知识之后才能回答这样的问题
  3. 可以看到rCore其实包含:os,RISC-V,rust,asm,计算机组成原理的内容,我们只是学习到和这个项目沾边的内容是不够的,还需要继续精进才能应对貌似"没来头"的面试问题.
  • 如何使得函数返回时能够跳转到调用该函数的下一条指令,即使该函数在代码中的多个位置被调用?
  • 对于一个函数而言,保证它调用某个子函数之前,以及该子函数返回到它之后(某些)通用寄存器的值保持不变有何意义?
  • 调用者函数和被调用者函数如何合作保证调用子函数前后寄存器内容保持不变?调用者保存和被调用者保存寄存器的保存与恢复各自由谁负责?它们暂时被保存在什么位置?它们于何时被保存和恢复(如函数的开场白/退场白)?
  • 在 RISC-V 架构上,调用者保存和被调用者保存寄存器如何划分的?
  • sp 和 ra 是调用者还是被调用者保存寄存器,为什么这样约定?
  • 如何使用寄存器传递函数调用的参数和返回值?如果寄存器数量不够用了,如何传递函数调用的参数?

函数调用与栈

  1. 如果考虑目前的函数调用是按照一个列表来执行的,这个列表保存着当前的指令的物理地址.
    1. 把所有需要执行的指令保存在一个顺序列表里,但是思考一下,如果每一个指令都是确定的,那分支结构和循环结构怎么实现呢,那怎么实时和物理世界交互呢?
    2. 因此需要用一个控制器决策下一条指令的地址,然后再执行那一条指令.
    3. 那么再进一步,更加复杂的函数调用更需要一个控制器的决策
      1. 其它的控制流只需要跳转到一个固定的位置
      2. 函数的调用需要跳转到一个在运行时决定的位置
  2. 官方文档中聊了指令怎么解决函数调用问题的,和在多层函数调用时的局限性,这里需要好好看看原文
  3. 得出结论
    1. 在调用子函数之前,我们需要在物理内存中的一个区域 保存 (Save) 函数调用上下文中的寄存器;而在函数执行完毕后,我们会从内存中同样的区域读取并 恢复 (Restore) 函数调用上下文中的寄存器。
    2. 实际上,这一工作是由子函数的调用者和被调用者(也就是子函数自身)合作完成。函数调用上下文中的寄存器被分为如下两类:
      1. 被调用者保存(Callee-Saved) 寄存器 :被调用的函数可能会覆盖这些寄存器,需要被调用的函数来保存的寄存器,即由被调用的函数来保证在调用前后,这些寄存器保持不变;
      2. 调用者保存(Caller-Saved) 寄存器 :被调用的函数可能会覆盖这些寄存器,需要发起调用的函数来保存的寄存器,即由发起调用的函数来保证在调用前后,这些寄存器保持不变。

调用规范

关于 调用者保存寄存器被调用者保存寄存器 , 两者的刚刚被提到的时候,我一度以为这是两种实现模式,类似于冯诺依曼和哈佛构型的不同.实际上真的是有划分的.

调用规范就是规定这三点:

  1. 函数的输入参数和返回值如何传递;
  2. 函数调用上下文中调用者/被调用者保存寄存器的划分;
  3. 其他的在函数调用流程中对于寄存器的使用方法。

关于 内存中的一块区域 实际上指的就是 栈区 .
sp 寄存器常用来保存 栈指针 (Stack Pointer),它指向内存中栈顶地址.

原文中这里比较难以理解,我这样理解他,在函数调用的过程中需要保存不止一个寄存器的内容,那么两次函数调用之间实际上不是差了一个地址,而是占用了栈里的一块空间,因此叫栈帧.
在一个函数中,作为起始的开场代码负责分配一块新的栈空间,即将 sp 的值减小相应的字节数即可,于是物理地址区间 新旧[新sp,旧sp) 对应的物理内存的一部分便可以被这个函数用来进行函数调用上下文的保存/恢复,这块物理内存被称为这个函数的 栈帧 (Stack Frame)。

保留fp信息

仔细阅读官方文档可以发现,fp是父亲栈帧的结束地址 fp ,是一个被调用者保存寄存器,栈上多个 fp 信息实际上保存了一条完整的函数调用链,通过适当的方式我们可以实现对函数调用关系的跟踪。
那么要保留其信息需要修改编译选项,这时候要修改.cargo/config,重点关注"-Cforce-frame-pointers=yes".
(其实在之前的章节这句话都被添加上了)

// .cargo/config

[build]
target = "riscv64gc-unknown-none-elf"

[target.riscv64gc-unknown-none-elf]
rustflags = [
    "-Clink-args=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
]

分配并使用启动栈

修改entry.asm文件

# os/src/entry.asm
    .section .text.entry
    .globl _start
_start:
    la sp, boot_stack_top
    call rust_main
    
    .section .bss.stack
    .globl boot_stack_lower_bound
boot_stack_lower_bound:
    .space 4096 * 16
    .globl boot_stack_top
boot_stack_top:

在第 11 行在内核的内存布局中预留了一块大小为 4096 * 16 字节也就是 64KiB 的空间用作接下来要运行的程序的栈空间,其栈顶的label为boot_stack_top栈底的label为boot_stack_lower_bound
在 RISC-V 架构上,栈是从高地址向低地址增长
这里插一句,之前玩过STM32Cube IDE的里边可以设置stackheap空间大小,实际上就是和这里相关的,要融会贯通好.

那么为什么Rust会把我们规定的这块64KiB的内存认为是启动栈呢,这里要考虑sp指针的作用,la sp, boot_stack_top意为把栈指针放置到刚刚设置好的boot_stack_top.

重新观察linker.ld

这段官方文档说的很清楚了,要仔细观察boot_stack_lower_boundboot_stack_top两个label.
还有一点非常需要注意的,第6行写明了,调用了rust部分的内核入口函数,从此就把控制权交给了rust.

编写rust代码

内核入口函数

内核入口函数rust_main,#[no_mangle] 以避免编译器对它的名字进行混淆.

// os/src/main.rs
#[no_mangle]
pub fn rust_main() -> ! {
    loop {}
}

detail

在Rust编程语言中,#[no_mangle]是一个属性(attribute),用于控制编译器对函数名称的处理方式。当我们不在函数前面加上这个属性时,Rust编译器会采用名称修饰(name mangling)技术,对函数名称进行修改,以支持诸如泛型、命名空间、重载等功能。这样的名称在链接时对于C语言或其他不使用相同名称修饰规则的语言来说是不可见的。
然而,当需要与C语言或其他语言编写的代码进行互操作,或者需要从外部直接调用Rust编写的函数时,就需要保证Rust函数的名称在编译后不被改变。这时就可以使用#[no_mangle]属性来避免名称修饰,确保函数名在编译后的二进制文件中保持原样,便于外部代码识别和调用。

清空.bss

.bss段的数据初始应该是0,因此需要清零.
这时候我们有了rust的内核入口,因此可以用rust编写一个清理函数了.
main.rs编写为:

// os/src/main.rs
#![no_std]
#![no_main]
mod sbi;
mod lang_items;

use core::arch::global_asm;
global_asm!(include_str!("entry.asm"));

#[no_mangle]
pub fn rust_main() -> ! {
    clear_bss();
    loop {}
}

fn clear_bss() {
    extern "C" {
        fn sbss();
        fn ebss();
    }
    (sbss as usize..ebss as usize).for_each(|a| {
        unsafe { (a as *mut u8).write_volatile(0) }
    });
}

detail

  1. 函数签名:

    fn clear_bss() {
    

    定义了一个名为clear_bss的函数,没有参数也没有返回值。

  2. 外部链接:

    extern "C" {
        fn sbss();
        fn ebss();
    }
    

    这部分声明了两个外部函数sbssebss,它们没有实际的实现,但预计在链接阶段会由链接器解析到具体的地址。extern "C"表示这两个函数遵循C调用约定,这是为了与C语言编写的代码或链接脚本兼容,确保名字不会被Rust的名称修饰规则修改。
    这里注意官方文档:
    extern “C” 可以引用一个外部的 C 函数接口(这意味着调用它的时候要遵从目标平台的 C 语言调用规范)。但我们这里只是引用位置标志并将其转成 usize 获取它的地址。由此可以知道 .bss 段两端的地址。

  3. 清零BSS段:

    (sbss as usize..ebss as usize).for_each(|a| {
        unsafe { (a as *mut u8).write_volatile(0) }
    });
    

    这里使用了范围表达式(sbss as usize..ebss as usize)来创建一个从sbss地址到ebss地址的迭代器,这两个地址通常标志着BSS段的起始和结束。for_each遍历这个地址范围内的每一个地址,并对每个地址执行闭包中的操作。

    • unsafe块:由于直接操作内存地址是不安全的,这部分代码需要用unsafe关键字包裹。这是告诉Rust编译器这里包含的手动内存管理操作需要程序员自己保证安全性。
    • (a as *mut u8):将当前地址a转换为指向u8类型的可变指针。
    • .write_volatile(0):通过write_volatile方法将该地址的内存设置为0。volatile关键字在此用于告诉编译器不对这块内存进行优化,确保每次写入操作都实际执行到硬件层面,这对于与硬件交互或多线程环境中的共享内存尤其重要。
posted @ 2024-07-09 19:53  winddevil  阅读(7)  评论(0编辑  收藏  举报