#
CS杂物 2026-04-02

YSOS 实验记录(报告

By lbyxiaolizi 168 Views 228 MIN READ 0 Comments

和学长聊天机缘巧合被拉来玩玩YSOS这个操作系统的实验课,由于我不是正式选这门课的学生,所以也不用写实验报告,于是决定写博文来记录下来我的试验记录~

内容顺序可能不会严格按照实验的顺序,会在部分位置插入我想了解以及去了解了的知识。

0x00

0x00的实验就是使用rust写几个小函数熟悉rust编程,我没有严格按照实验要求来写,做了一些自以为是的优化

函数编写

*错误处理

pub enum OsErr {
    FileNotFound,
    Unknown,
    PermissionDenied,
    ErrorCode(i32),
}
impl OsErr {
    pub fn as_str(&self) -> &'static str{
        match self{
            OsErr::FileNotFound => "File not found!",
            OsErr::PermissionDenied => "You don't have enough permission!",
            OsErr::Unknown => "Unknown error!",
            OsErr::ErrorCode(code) => {
                if *code  == -99 {"Invalid argument!"}
                else {
                    "Specific error"
                }
            },
        }
    }

    pub fn errono(&self) -> i32{
        match self {
            OsErr::FileNotFound => -2,
            OsErr::PermissionDenied => -3,
            OsErr::Unknown => -1,
            OsErr::ErrorCode(code) => *code,
        }
    }

    pub fn is_not_found(&self) -> bool{
        *self == OsErr::FileNotFound
    }
    
}

impl fmt::Display for OsErr {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f,"Os Error: {}",self.as_str())
    }
}

pub type OSResult<T> = Result<T,OsErr>;

创建一个函数 count_down(seconds: u64)

该函数接收一个 u64 类型的参数,表示倒计时的秒数。

函数应该每秒输出剩余的秒数,直到倒计时结束,然后输出 Countdown finished!

fn count_down(seconds: u64) {
    for i in (0..=seconds).rev(){
        println!("{} seconds remaining...", i);
        if i == 0{
            println!("Countdown finished!");
        }
    }
}

创建一个函数 read_and_print(file_path: &str)

该函数接收一个字符串参数,表示文件的路径。

函数应该尝试读取并输出文件的内容。如果文件不存在,函数应该使用 expect 方法主动 panic,并输出 File not found!

尝试使用 io::Result<()> 作为返回值,并使用 ? 将错误向上传递。
fn read_and_print(file_path: &str) -> OSResult<()> {
    use std::fs;
    use std::io::ErrorKind;

    let file_content = fs::read_to_string(file_path).map_err(|e| {
        match e.kind(){
            ErrorKind::NotFound => OsErr::FileNotFound,
            _ => OsErr::Unknown,
        }
    })?;
    println!("The content of {file_path} is {file_content}");
    Ok(())
}

创建一个函数 file_size(file_path: &str) -> Result<u64, &str>

该函数接收一个字符串参数,表示文件的路径,并返回一个 Result

函数应该尝试打开文件,并在 Result 中返回文件大小。如果文件不存在,函数应该返回一个包含 File not found! 字符串的 Err。

尝试将 std::io::Result 转换为 std::Result,你可能需要 map_err 等函数。
fn file_size(file_path: &str) -> OSResult<u64> {
    use std::fs;
    use std::io::ErrorKind;

    let file_len=fs::metadata(file_path).map(|meta| meta.len() )
        .map_err(|e| {
            match e.kind(){
                ErrorKind::NotFound => OsErr::FileNotFound,
                _ => OsErr::Unknown,
        }
    })?;
    
    Ok(file_len)
}

这里我用自定义错误类型替代了题目要求的文件不存在时返回的错误。

实现一个进行字节数转换的函数,并格式化输出:

  1. 实现函数 humanized_size(size: u64) -> (f64, &'static str) 将字节数转换为人类可读的大小和单位

    使用 1024 进制,并使用二进制前缀(B, KiB, MiB, GiB)作为单位

  2. 补全格式化代码,使得你的实现能够通过如下测试:

    #[test]
    fn test_humanized_size() {
        let byte_size = 1554056;
        let (size, unit) = humanized_size(byte_size);
        assert_eq!("Size :  1.4821 MiB", format!(/* FIXME */));
    }
fn byte_to(size:u64) -> (f64,&'static str){
    let units=["B","KiB","MiB","GiB","TiB"];
    let mut unit_idx=0;
    let mut temp_size=size;

    while temp_size >= 1024 && unit_idx < units.len() -1 {
        temp_size >>= 10;
        unit_idx +=1;
    }
    let size_f=size as f64 / (1_u64 << (unit_idx*10)) as f64;

    (size_f,units[unit_idx])

}

这里我本来是考虑用给出的字节数/n个1024去达到目的,但是又担心在循环中的性能损耗,在与G老师的一些交流后,想起来了还有位运算这种神奇运算,wow!

$2^{10}=1024$,所以除以1024等于位运算右移10位,这不比做除法快?

在大多数 CPU 架构中,DIV(除法)指令需要几十个时钟周期,而 SHR(右移)只需要 1 个时钟周期

但是这时候又出现了一个问题,位运算只适用于整数,那么小数应该怎么办?

经过查询,发现可以使用 定点数 的方法:先放大、再位移、最后整数取模得到原先的小数。

#### 在 main 函数中,按照如下顺序调用上述函数:

  • 首先调用 count_down(5) 函数进行倒计时
  • 然后调用 read_and_print("/etc/hosts") 函数尝试读取并输出文件内容
  • 最后使用 std::io 获取几个用户输入的路径,并调用 file_size 函数尝试获取文件大小,并处理可能的错误。
fn main() -> OSResult<()> {
    count_down(5);
    println!("\n");
    read_and_print("/etc/hosts")?;
    println!("Pls input some paths (input exit() to exit)");
    loop {
        println!("The path of the file is :");
        io::stdout().flush().map_err(|_| OsErr::Unknown)?;

        let mut input_path = String::new();
        io::stdin().read_line(&mut input_path)
            .map_err(|_| OsErr::Unknown)?;

        let path=input_path.trim();

        if path=="exit()" {
            println!("Exiting……");
            break;
        } 
        if path.is_empty(){
            continue;
        } 
        
        match file_size(path) {
            Ok(size) => println!("The size of {} is {}",path,size),
            Err(e) => println!("Error! {}",e)
        }
        
        
    }

    
    Ok(())
}

自行搜索学习如何利用现有的 crate 在终端中输出彩色的文字

为了不引入其他库,所以我们只能使用ANSI 转义序列实现。

3/4位颜色

初始的规格只有8种颜色,只给了它们的名字。SGR参数30-37选择前景色,40-47选择背景色。

实现为更明亮的颜色而不是不同的字体,从而提供了8种额外的前景色,但通常情况下并不能用于背景色,虽然有时候反显(SGR代码7)可以允许这样。

名称前景色代码背景色代码VGA[nb 2]CMD[nb 3]Terminal.app)PuTTYmIRCxtermX[nb 4]Ubuntu[nb 5]
30400,0,01,1,1
3141170,0,0128,0,0194,54,33187,0,0127,0,0205,0,0255,0,0222,56,43
绿32420,170,00,128,037,188,360,187,00,147,00,205,00,255,057,181,74
3343170,85,0[nb 6]128,128,0173,173,39187,187,0252,127,0205,205,0255,255,0255,199,6
34440,0,1700,0,12873,46,2250,0,1870,0,1270,0,238[15]0,0,2550,111,184
品红3545170,0,170128,0,128211,56,211187,0,187156,0,156205,0,205255,0,255118,38,113
36460,170,1700,128,12851,187,2000,187,1870,147,1470,205,2050,255,25544,181,233
3747170,170,170192,192,192203,204,205187,187,187210,210,210229,229,229255,255,255204,204,204
亮黑(灰)9010085,85,85128,128,128129,131,13185,85,85127,127,127127,127,127 128,128,128
亮红91101255,85,85255,0,0252,57,31255,85,85255,0,0255,0,0 255,0,0
亮绿9210285,255,850,255,049,231,3485,255,850,252,00,255,0144,238,1440,255,0
亮黄93103255,255,85255,255,0234,236,35255,255,85255,255,0255,255,0255,255,224255,255,0
亮蓝9410485,85,2550,0,25588,51,25585,85,2550,0,25292,92,255[16]173,216,2300,0,255
亮品红95105255,85,255255,0,255249,53,248255,85,255255,0,255255,0,255 255,0,255
亮青9610685,255,2550,255,25520,240,24085,255,2550,255,2550,255,255224,255,2550,255,255
亮白97107255,255,255255,255,255233,235,235255,255,255255,255,255255,255,255 255,255,255

使用示例:

const COLOR_RED: &str = "\x1b[31m";
const COLOR_GREEN: &str = "\x1b[32m";
const COLOR_YELLOW: &str = "\x1b[33m";
const COLOR_BLUE: &str = "\x1b[34m";
const COLOR_RESET: &str = "\x1b[0m";

println!("{}This is RED text{}", COLOR_RED, COLOR_RESET);

实现一个元组结构体 UniqueId(u16)

使得每次调用 UniqueId::new() 时总会得到一个新的不重复的 UniqueId。 - 你可以在函数体中定义 static 变量来存储一些全局状态 - 你可以尝试使用 std::sync::atomic::AtomicU16 来确保多线程下的正确性(无需进行验证,相关原理将在 Lab 5 介绍,此处不做要求) - 使得你的实现能够通过如下测试:

#[test]
fn test_unique_id() {
    let id1 = UniqueId::new();
    let id2 = UniqueId::new();
    assert_ne!(id1, id2);
}
struct UniqueID(u16);

impl UniqueID {
    pub fn new() -> Self{
        static COUNTER:AtomicU16 =AtomicU16::new(0);
        let id = COUNTER.fetch_add(1, Ordering::SeqCst);
        UniqueID(id)
    }
}

YSOS 启动

crates/boot/src/main.rs

#![no_std]
#![no_main]

#[macro_use]
extern crate log;
extern crate alloc;

use core::arch::asm;
use uefi::{Status,entry};

#[entry]
fn efi_main() -> Status{
    uefi::helpers::init().expect("Failed to initialize utilities");
    log::set_max_level(log::LevelFilter::Info);

    let std_num = 25302031;

    loop{
        info!("Hello World! \n from UEFI bootloader @ {}",std_num);

        for _ in 0..0x10000000{
            unsafe{
                asm!("nop");
            }
        }
    }
}

image-20260315194233990

补充阅读

BIOS——UEFI与Legacy

什么是BIOS?

BIOS(英文:Basic Input/Output System),即基本输入输出系统,是在通电启动阶段执行硬件初始化,以及为操作系统提供运行时服务的固件。

现在,其作用是初始化和测试硬件组件,以及从存储设备中加载启动程序(GRUB等?),在由启动程序加载操作系统;当加载操作系统后,BIOS通过系统管理模式为操作系统提供硬件抽象。

系统管理模式(System Management mode)(以下简称SMM)是Intel在80386SL(一款x86系列CPU)之后引入x86体系结构的一种CPU的执行模式。

系统管理模式只能通过系统管理中断(System Management Interrupt, SMI)进入,并只能通过执行RSM指令退出。SMM模式对操作系统透明,换句话说,操作系统根本不知道系统何时进入SMM模式,也无法感知SMM模式曾经执行过。为了实现SMM,Intel在其CPU上新增了一个引脚SMI# Pin,当这个引脚上为高电平的时候,CPU会进入该模式。在SMM模式下一切被都屏蔽,包括所有的中断。SMM模式下的执行的程序被称作SMM处理程序,所有的SMM处理程序只能在称作系统管理内存(System Management RAM,SMRAM)的空间内运行。可以通过设置SMBASE的寄存器来设置SMRAM的空间。SMM处理程序只能由系统固件(如BIOS或UEFI)实现。(From Wikipedia)

SMM的作用

During platform initialization the PC's firmware (BIOS, UEFI) has complete control of the system and can perform whatever configuration operations are required to prepare the system for an OS to take over. Once an OS is running it expects that it has complete control of the system. If the firmware developer would like the firmware to continue to have some control over the system, SMM is used.

SMM is intended to be completely transparent to the OS. When the system enters SMM, the firmware preserves the state of the CPU in a region of RAM designated as "SMRAM". During SMM the firmware performs low-level management operations like changing fan speeds, checking thermal zones, adjusting the CPU speed, etc. Before leaving SMM, the firmware restores the state of the CPU from SMRAM. From the perspective of the OS, those low-level management operations are happening atomically and automatically in the background.

(From OsDev

SMM is designed so that OS developers do not need to be aware of it, other than understanding its relationship with ACPI.(SMM 的设计使操作系统开发者无需了解它,只需理解它与 ACPI 的关系即可。)

由于在 SMM 中执行的代码在正常情况下无法作系统检测到,SMM 成为高级恶意软件的有吸引力的载体。SMM 的 CPU 实现和 BIOS 固件实现可能被检测、滥用或干扰,是重大的安全问题。待办事项——包含一些隐形事物的链接和一些 SMM 黑客论文。

: CTF可能会出这方面的题?

硬件抽象层(英语:Hardware Abstraction Layer,缩写HAL)是软件层的例行程序包,用于模拟特定系统平台的细节使程序可以直接访问硬件的资源。将硬件方面的不同抽离操作系统的核心,核心模式的代码就不必因为硬件的不同而需要修改。因此硬件抽象层可加大软件的移植性。

之所以有硬件抽象(Hardware abstraction)这个概念,是由于数字电脑具体的硬件操作相当繁杂,因此将具体的硬件操作抽象化简,避免由于直接以具体的机器代码撰写程序,而在将程序移植到不同硬件时,需要重写整个程序。其概念与目的,类似于数据结构中的抽象数据类型(Abstract data type),皆为保护程序免受变化的冲击。

前述的现象可借由语言现象获得一些启示,当人在记忆时,会抽象地记忆,而非具体地将具体的消息记下,在记忆时,并不会记忆文章具体的长相,而是抽象的内容。如果不是如此,当需要以另一种语言重现该篇文章时,仍然需要将其抽象化,再将其转译为另一语言的写法。在记忆谈话时,也类似于此。(From Wikipedia)

我很想在这里继续深入了解BIOS的相关知识,但是难度和深度好像有一些超乎我的预期了,所以喜提一只鸽子。

什么是UEFI

统一可扩展固件接口(英语:Unified Extensible Firmware Interface,缩写UEFI)是一种个人电脑系统规格,用来定义操作系统与系统固件之间的软件界面,作为BIOS的替代方案[2]。可扩展固件接口负责加电自检(POST)、联系操作系统以及提供连接操作系统与硬件的界面。

UEFI的前身是Intel在1998年开始开发的Intel Boot Initiative,后来被重命名为可扩展固件接口(Extensible Firmware Interface,缩写EFI)。

怎么什么都是Intel推进的,但是Intel最后好像什么都没吃上(

UEFI和BIOS的区别:

二者显著的区别就是UEFI是用模块化,C语言风格的参数堆栈传递方式,动态链接的形式构建的系统,较BIOS而言更易于实现,容错和纠错特性更强,缩短了系统研发的时间。它可以执行于x86-64、IA32、ARM等架构上(在个人电脑上通常是x86-64平台),突破传统16位代码的寻址能力,达到处理器的最大寻址。它利用加载UEFI驱动程序的形式,识别及操作硬件,不同于BIOS利用挂载实模式中断的方式增加硬件功能。后者必须将一段类似于驱动程序的16位代码(如RAID卡的Option ROM)放置在固定的0x000C0000至0x000DFFFF之间存储区中,运行这段代码的初始化部分,它将挂载实模式下约定的中断向量向其他程序提供服务。

UEFI将引导数据存储在 .efi 文件中,而不是固件中。你经常会在新款的主板中找到 UEFI 启动模式。UEFI 启动模式包含一个特殊的 EFI 分区,用于存储 .efi 文件并用于引导过程和引导加载程序。

与操作系统的关系:

只是硬件和预启动软件间的接口规范

UEFI环境下不提供中断的机制,也就是说每个UEFI驱动程序必须用轮询(polling)的方式来检查硬件状态,并且需要以解释的方式运行,较操作系统下的机械码驱动效率更低;

UEFI系统不提供复杂的缓存器保护功能,它只具备简单的缓存器管理机制,具体来说就是指运行在x64或x86处理器的长模式或保护模式下,以最大寻址能力为限把缓存器分为一个平坦的段(Segment),所有的程序都有权限访问任何一段位置,并不提供真实的保护服务。

当UEFI所有组件加载完毕时,便会启动操作系统的启动程序,如果UEFI固件内置UEFI Shell,也可以启动UEFI Shell命令提示。UEFI应用程序(UEFI Application)和UEFI驱动程序(UEFI driver)是PE格式的.efi文件,可用C语言编写。

组成:

  1. Pre-EFI初始化模块(PEI)
  2. UEFI驱动程序执行环境(DXE)
  3. UEFI驱动程序(UEFI driver)
  4. 兼容性支持模块(CSM)
  5. UEFI高层应用(UEFI Application)
  6. GUID磁盘分区表
  7. 系统管理模式(SMM)

:一样,后面在回来学UEFI部分的内容吧(

Makefile 文件

Makefile由若干条规则(Rule)构成,每一条规则指出一个目标文件(Target),若干依赖文件(prerequisites),以及生成目标文件的命令。

#规则 目标文件: 依赖文件1 依赖文件2
m: a b 
    cat a b >m 
#要生产m,依赖a和b ,使用cat命令合并了a与b,并写入到m
在Makefile的规则中,命令必须以Tab开头,不能是空格。

make执行时,默认执行第一条规则,所以,我们把规则x.txt放到前面。

x: m c
    cat m c > x

m: a b
    cat a b > m

make默认执行第一条规则,也就是创建x,但是由于x依赖的文件m不存在(另一个依赖c已存在),故需要先执行规则m创建出m文件,再执行规则x。执行完成后,当前目录下生成了两个文件mx

把默认执行的规则放第一条,其他规则的顺序是无关紧要的,因为make执行时自动判断依赖。

make使用文件的创建和修改时间来判断是否应该更新一个目标文件。

mx 都是make自动生成的文件,可以安全的删除

clean:
    rm -f m
    rm -f x

clean没有依赖文件,所以必须用make clean运行。

但是如果我们有一个叫做clean的文件,那么clean规则就不会执行了

如果我们希望makeclean不要视为文件,可以添加一个标识:.PHONY: clean

.PHONY: clean
clean:
    rm -f m
    rm -f x

一个规则可以有多条命令,如:

cd:
    pwd
    cd ..
    pwd

输出:

$ make cd
pwd
/home/ubuntu/makefile-tutorial/v1
cd ..
pwd
/home/ubuntu/makefile-tutorial/v1

发现cd ..命令执行后,并未改变当前目录,两次输出的pwd是一样的,这是因为make针对每条命令,都会创建一个独立的Shell环境,类似cd ..这样的命令,并不会影响当前目录。

解决办法是把多条命令以;分隔,写到一行:

cd_ok:
    pwd; cd ..; pwd;

再执行cd_ok目标就得到了预期结果:

$ make cd_ok
pwd; cd ..; pwd
/home/ubuntu/makefile-tutorial/v1
/home/ubuntu/makefile-tutorial

可以使用\把一行语句拆成多行,便于浏览:

cd_ok:
    pwd; \
    cd ..; \
    pwd

另一种执行多条命令的语法是用&&,它的好处是当某条命令失败时,后续命令不会继续执行:

cd_ok:
    cd .. && pwd

如果我们不想打印某一条命令,可以在命令前加上@,表示不打印命令

no_output:
    @echo 'not display'
    echo 'will display'

有些时候,我们想忽略错误,继续执行后续命令,可以在需要忽略错误的命令前加上-

ignore_error:
    -rm zzz.txt #对于执行可能出错,但不影响逻辑的命令,可以用-忽略。
    echo 'ok'

变量定义用变量名 = 值或者变量名 := 值,通常变量名全大写,?=若变量未定义,則替它指定新的值,否則用原有的值。

+=

CFLAGS = -Wall -g
CFLAGS += -O2
# 此時 CFLAGS 的值就變成 -Wall -g -O2

引用变量用$(变量名)

自动变量:$@表示目标文件,$^表示所有依赖文件,$(wildcard *.c)为通配符,$<表示规则中第一个依赖文件。

因此,我们可以这么写:

world.out: hello.o main.o
    cc -o $@ $^

在没有歧义时可以写$@,也可以写$(@),有歧义时必须用括号,例如$(@D)

# 模式匹配规则:当make需要目标 xyz.o 时,自动生成一条 xyz.o: xyz.c 规则:
%.o: %.c
    @echo 'compiling $<...'
    cc -c -o $@ $<

解读实验仓库中的makefile

# 变量命名
OVMF := assets/OVMF.fd 
ESP := esp
BUILD_ARGS :=
QEMU_ARGS := -m 64M
QEMU_OUTPUT := -nographic
MODE ?= release
CUR_PATH := $(shell pwd)
DBG_INFO := false

ifeq (${MODE}, release)
    BUILD_ARGS := --release
endif

# 伪目标声明
.PHONY: build run debug clean launch intdbg \
    target/x86_64-unknown-uefi/$(MODE)/ysos_boot.efi

run: build launch

# 启动qemu
launch:
    @qemu-system-x86_64 \
        -bios ${OVMF} \
        -net none \
        $(QEMU_ARGS) \
        $(QEMU_OUTPUT) \
        -drive format=raw,file=fat:${ESP} \
        -snapshot
# 中断调试模式,用于排查故障、三重故障
intdbg:
    @qemu-system-x86_64 \
        -bios ${OVMF} \
        -net none \
        $(QEMU_ARGS) \
        $(QEMU_OUTPUT) \
        -drive format=raw,file=fat:${ESP} \
        -snapshot \
        -no-reboot -d int,cpu_reset
# GDB调试
debug:
    @qemu-system-x86_64 \
        -bios ${OVMF} \
        -net none \
        $(QEMU_ARGS) \
        $(QEMU_OUTPUT) \
        -drive format=raw,file=fat:${ESP} \
        -snapshot \
        -s -S

clean:
    @cargo clean

build: $(ESP)

$(ESP): $(ESP)/EFI/BOOT/BOOTX64.EFI

$(ESP)/EFI/BOOT/BOOTX64.EFI: target/x86_64-unknown-uefi/$(MODE)/ysos_boot.efi
    @mkdir -p $(@D) # 提取目标文件所在的目录部分 $@指目标文件,D指目录
    cp $< $@

target/x86_64-unknown-uefi/$(MODE)/ysos_boot.efi: crates/boot
    cd crates/boot && cargo build $(BUILD_ARGS)

0x01

实验任务

crates/kernel 目录下运行 cargo build --release,之后找到编译产物,并使用 readelf 命令查看其基本信息,回答以下问题:

  • 请查看编译产物的架构相关信息,与配置文件中的描述是否一致?
  • 找出内核的入口点,它是被如何控制的?结合源码、链接、加载的过程,谈谈你的理解。
  • 请找出编译产物的 segments 的数量,并且用表格的形式说明每一个 segments 的权限、是否对齐等信息。

直接运行 cargo build --release发现cargo不能成功运行,报错

error: `.json` target specs require -Zjson-target-spec

原因是项目使用了自定义JSON target,而构建命令默认不放行json target,需要手动加上-Zjson-target-spec,最后使用cargo build -Zjson-target-spec --release构建。

$ readelf ysos_kernel -h
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0xffffff00000010d0
  Start of program headers:          64 (bytes into file)
  Start of section headers:          34960 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         7
  Size of section headers:           64 (bytes)
  Number of section headers:         12
  Section header string table index: 10$ file ysos_kernel
ysos_kernel: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped

编译产物架构为X86-64,与配置文件中"arch": "x86_64"描述相符。

main.rs开始分析,从调用crate/boot/src/lib.rsboot::entry_point宏开始

#[macro_export]
macro_rules! entry_point {
    ($path:path) => {
        #[unsafe(export_name = "_start")]
        pub extern "C" fn __impl_start(boot_info: &'static $crate::BootInfo) -> ! {
            // validate the signature of the program entry point
            let f: fn(&'static $crate::BootInfo) -> ! = $path;

            f(boot_info)
        }
    };
}

kernel_main导出为_startkernel.ld中指出的入口ENTRY(_start)),然后从此开始。

接着对boot_info调用引入的ysos_kernelinit方法,随后循环输出消息Hello World from YatSenOS v2!,然后在0到0x10000000内一直保持CPU空转。

segment指ELF文件中给加载器看的装在单元,是操作系统真正会按他映射到内存的区域。

对 bootloader 来说,关键的是 LOAD segment,因为它们决定“从文件哪个偏移读数据,放到哪个虚拟地址,用什么权限映射,内存里占多大”。
$ readelf ysos_kernel -lW

Elf file type is EXEC (Executable file)
Entry point 0xffffff00000010d0
There are 7 program headers, starting at offset 64

Program Headers:
  Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  LOAD           0x001000 0xffffff0000000000 0xffffff0000000000 0x0005a4 0x0005a4 R   0x1000
  LOAD           0x002000 0xffffff0000001000 0xffffff0000001000 0x001ee5 0x001ee5 R E 0x1000
  LOAD           0x004000 0xffffff0000003000 0xffffff0000003000 0x001020 0x001020 RW  0x1000
  LOAD           0x006000 0xffffff0000005000 0xffffff0000005000 0x000000 0x000020 RW  0x1000
  GNU_RELRO      0x005000 0xffffff0000004000 0xffffff0000004000 0x000020 0x000020 R   0x1
  GNU_EH_FRAME   0x00157c 0xffffff000000057c 0xffffff000000057c 0x00000c 0x00000c R   0x4
  GNU_STACK      0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW  0

 Section to Segment mapping:
  Segment Sections...
   00     .rodata .eh_frame_hdr .eh_frame
   01     .text
   02     .data .got
   03     .bss
   04     .got
   05     .eh_frame_hdr
   06
#TypeVirtAddrOffsetFileSizFlgAlign对齐内容
0LOAD0xffffff00000000000x0010000x0005a4R0x1000.rodata .eh_frame_hdr .eh_frame
1LOAD0xffffff00000010000x0020000x001ee5R E0x1000.text
2LOAD0xffffff00000030000x0040000x001020RW0x1000.data .got
3LOAD0xffffff00000050000x0060000x000000RW0x1000.bss
4GNU_RELRO0xffffff00000040000x0050000x000020R0x1地址本身页对齐.got
5GNU_EH_FRAME0xffffff000000057c0x00157c0x00000cR0x4.eh_frame_hdr
6GNU_STACK0xffffff00000000000x0000000x000000RW0/栈权限声明,不映射文件内容

补全代码

// FIXME: root page table is readonly, disable write protect (Cr0) 处,我一先没看到文档中给出的示例写法,询问AI后给出了

let cr0_flags_bk= Cr0::read();
unsafe{
    Cr0::write(cr0_flags_bk & !Cr0Flags::WRITE_PROTECT);
} 

的写法,使用了位与运算,可读性相对较低。

于是后续改回了文档示例的写法

let cr0_flags_bk= Cr0::read();
unsafe{
    Cr0::update(|f| f.remove(Cr0Flags::WRITE_PROTECT));
} 

后续的FIXME可以根据lib.rs中的信息修补

    // FIXME: map physical memory to specific virtual address offset
    elf::map_physical_memory(config.physical_memory_offset, max_phys_addr, &mut page_table, &mut allocator);
    
    // FIXME: load and map the kernel elf file
    elf::load_elf(&elf, config.physical_memory_offset, &mut page_table, &mut allocator)
    .expect("Failed to load kernel");

    // FIXME: map kernel stack
    elf::map_range(config.kernel_stack_address, config.kernel_stack_size, &mut page_table, &mut allocator)
    .expect("Failed to map kernel");

    // FIXME: recover write protect (Cr0)
    unsafe {
        Cr0::update(|f| f.insert(Cr0Flags::WRITE_PROTECT));
    }

lib.rsload_segment的代码修补

分别对可写可执行权限进行映射

为什么是不可执行,而不是可执行?

早期x86中没有单独的可执行权限位,CPU默认可读即可执行。后续为了防止缓冲溢出攻击才引入了NX 也即 不可执行位

再再再记一遍位运算,每每每次都会忘记

  • 位或 |(OR) ,有1则1,全0才0。 例:0001 | 0010 = 0011 用于增加某个权限而不破坏原有的权限。
  • 位与 & (AND),全1才1,有0则0。 例:0011 & 0010 = 0010 用于检查某个变量是否包含某个权限。若上例中 0011为存在+可写,用其与0010做位与运算得到的1处便是有的权限;0处便是没有的权限。
  • 按位取反 ~(Rust整数中用 !) 0 1互换。 例:~(0010) = 1101 常和位与合用,撤销某个操作。如:0011为存在+可写,要清除可写0010,使用0011 & ~(0010)=0011 1101 = 0001,便只剩下存在权限,去除了可写权限。
  • 移位(包括:左移 <<右移 >> 把二进制位整体向左或向右移动指定的位数,在数学上等价于乘(除)$2^n$。 例:a= 1<< 0; (a=0001);b=1<<1 (b=0010)

    对于4KB的页 c = 0x12345678; d = c >> 12 则d=0x12345

// FIXME: handle page table flags with segment flags
    let seg_flags=segment.flags();

    if seg_flags.is_write(){
        page_table_flags |= PageTableFlags::WRITABLE;
    }
    if !seg_flags.is_execute(){
        page_table_flags |= PageTableFlags::NO_EXECUTE;
    }

调试内核

先按教程装gef!!!

然后写.gdbinit文件

file esp/KERNEL.ELF
gef-remote localhost 1234
tmux-setup
b ysos_kernel::init
  • 使用 make build DBG_INFO=truepython ysos.py build -p debug 编译内核,确保编译时开启了调试信息。
  • 使用 make debug 启动 QEMU 并进入调试模式
  • 在另一个终端中,使用 gdb -q 命令进入 GDB 调试环境。
  • 使用 c 命令继续执行,你将会看到 QEMU 窗口中的输出,同时 GDB 将会在断点处停下。


分别用x/10i $pc info symbol $pc查看断点处的汇编和符号。

其中x代表examine(检查),i是instruction(指令) $pc是当前的程序计数器/指令指针寄存器RIP。

ysos_kernel::init + 8 in section .text

vmmapreadelf检查加载情况

前者在gdb下运行,输出

后者直接在终端下运行

lbyxiaolizi@XiaoliLaptop:~/YSOS/lab$ readelf -l esp/KERNEL.ELF

Elf file type is EXEC (Executable file)
Entry point 0xffffff00000010d0
There are 7 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000001000 0xffffff0000000000 0xffffff0000000000
                 0x00000000000005a4 0x00000000000005a4  R      0x1000
  LOAD           0x0000000000002000 0xffffff0000001000 0xffffff0000001000
                 0x0000000000001ee5 0x0000000000001ee5  R E    0x1000
  LOAD           0x0000000000004000 0xffffff0000003000 0xffffff0000003000
                 0x0000000000001020 0x0000000000001020  RW     0x1000
  LOAD           0x0000000000006000 0xffffff0000005000 0xffffff0000005000
                 0x0000000000000000 0x0000000000000020  RW     0x1000
  GNU_RELRO      0x0000000000005000 0xffffff0000004000 0xffffff0000004000
                 0x0000000000000020 0x0000000000000020  R      0x1
  GNU_EH_FRAME   0x000000000000157c 0xffffff000000057c 0xffffff000000057c
                 0x000000000000000c 0x000000000000000c  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x0

 Section to Segment mapping:
  Segment Sections...
   00     .rodata .eh_frame_hdr .eh_frame
   01     .text
   02     .data .got
   03     .bss
   04     .got
   05     .eh_frame_hdr
   06

问答:

  1. set_entry 函数做了什么?为什么它的实现是 unsafe 的(下附实现)
#[cfg(feature = "boot")]
static mut ENTRY: usize = 0;
#[inline(always)]
#[cfg(feature = "boot")]
pub fn set_entry(entry: usize) {
    unsafe {
        ENTRY = entry;
    }
}

答:set_entry函数从解析的ELF文件头中提取内核入口地址。

他的实现将entry传给了static mut ENTRY,对全局可变静态变量的读取和修改都是unsafe的,为了防止数据竞争

  1. jump_to_entry 函数做了什么?要传递给内核的参数位于哪里?查询 call 指令的行为和 x86_64 架构的调用约定,借助调试器进行说明。
#[cfg(feature = "boot")]
pub fn jump_to_entry(bootinfo: *const BootInfo, stacktop: u64) -> ! {
    unsafe {
        assert!(ENTRY != 0, "ENTRY is not set");
        core::arch::asm!("mov rsp, {}; call {}", in(reg) stacktop, in(reg) ENTRY, in("rdi") bootinfo);
    }
    unreachable!()
}

答:jump_to_entry 函数通过汇编语言把CPU的栈指针寄存器修改为stacktop,跳转到前面set_entry保存的内核入口。

  • in("rdi") bootinfobootinfo的指针传进rdi寄存器里
  • in(reg) stacktopstacktop挑一个随便的空闲通用寄存器扔进去
  • in(reg) ENTRY同样挑一个寄存器把ENTRY的地址扔进去
  • 最后执行汇编mov rsp, {}; call {}

要传递给内核的参数

  1. entry_point! 宏做了什么?内核为什么需要使用它声明自己的入口点?

答:entry_point!真是不好找,在本实验中,他出现在./crates/kernel/src/main.rs:11:boot::entry_point!(kernel_main);,查询其相关资料,可以在Writing an OS in rust中找到其是bootloader crates提供的宏

#[macro_export]
macro_rules! entry_point {
    ($path:path) => {
        #[export_name = "_start"]
        pub extern "C" fn __impl_start(boot_info: &'static $crate::bootinfo::BootInfo) -> ! {
            // 1. 验证用户提供的入口函数的签名是否严格匹配
            let f: fn(&'static $crate::bootinfo::BootInfo) -> ! = $path;

            // 2. 调用用户的内核入口函数
            f(boot_info)
        }
    };
}

他首先定义了导出名称为_start,告诉BL入口,然后用C中的调用约定,函数的第一个参数必须从rdi中获取,在检测内核入口函数的类型是否正确。

  1. 如何为内核提供直接访问物理内存的能力?你知道几种方式?代码中所采用的是哪一种?

答:随着内存大小的增长,诞生出了虚拟内存技术,他把物理内存的地址抽象成了一段独立的内存区域,在访问真实的物理内存之前需要先进行内存转换。其中转换前的叫做虚拟地址,转换后的叫做物理地址。(显而易见的,我们可以因此对一段物理内存通过虚拟内存实现内存复用,并且由此会产生更多的安全问题

由此诞生了内存分段->内存分片 和 内存分页->页表->多级页表 技术。

1. 恒等映射:虚拟地址=物理地址
1. 线性偏移映射:虚拟地址=物理地址+固定偏移量(Offset),偏移量通常需要打到把所有物理内存搬到虚拟地址的高半区
1. 动态映射:内核按需临时映射特地的物理页,用完即丢。

代码中用了第二种线性偏移映射,用固定的Offset进行偏移。

详见:https://os.phil-opp.com/zh-CN/paging-introduction/

  1. 为什么 ELF 文件中不描述栈的相关内容?栈是如何被初始化的?它可以被任意放置吗?

答:ELF文件描述的是静态的程序布局,而栈是动态的运行时资源,其大小和位置不需要被硬编码进ELF中。

栈的初始化和放置:

  • BL从配置文件中读到内核地址和大小
elf::load_elf(&elf, config.physical_memory_offset, &mut page_table, &mut allocator)
    .expect("Failed to load kernel");
  • 随后申请物理页并配置页表,把内存映射为RW
elf::map_range(config.kernel_stack_address, config.kernel_stack_size, &mut page_table, &mut allocator)
    .expect("Failed to map kernel");
  • 最后,计算栈顶stacktop,通过跳转汇编修改CPU的rsp寄存器

能否随意放置? 理论上可以放在随便的空闲地方,但是为了安全,现代OS会把其放在内核的高半区,并且可以在栈底设置一个未映射的Guard Page防止栈溢出损坏其他数据。


  • 请解释指令 layout asm 的功能。倘若想找到当前运行内核所对应的 Rust 源码,应该使用什么 GDB 指令?

答:layout asm是GDB的TUI命令,将终端窗口的上半部分显示当前pc(程序计数器)附近的机器指令汇编代码。

查找Rust源码应该使用layout src,可以直接使用list在命令行中打印当前执行点附近的源码。

UART 与日志输出

实验中自旋锁与互斥锁的内容在rs圣经中有详细讲解

UART 16550有8个寄存器,它们共享一组端口地址,通过偏移量区分,其中一些寄存器在DLAB(线路控制寄存器的第7位)开启或关闭时有不同含义。

  • Data Register/Division Latch Low(+0)

​ DLAB=0时为数据口;DLAB=1时为波特率低位。

  • Interrupt Enable Register/Divisor Latch High(+1) IER

​ 控制哪些事件能打断CPU的工作

位 (Bit)名称描述
Bit 0ERBFI接收数据中断。设为 1 时,每收到一个字节都会触发中断。
Bit 1ETBEI发送持有寄存器空中断。设为 1 时,只要能发数据了就触发中断。
Bit 2ELSI接收状态中断。当发生错误(如奇偶校验错)时触发。
Bit 3EDSSIModem 状态中断。线路状态变化(如断开)时触发。
Bit 0-7DLM仅当 DLAB=1 时: 存储波特率除数的高 8 位。
  • FIFO Control Register (+2) FCR(只写)

​ 控制硬件内部的16字节缓冲区,优化高频数据传输。

位 (Bit)名称描述
Bit 0Enable是否启用 FIFO。不启用时,一次只能处理 1 字节。
Bit 1Clear R设为 1 会清空接收 FIFO。
Bit 2Clear T设为 1 会清空发送 FIFO。
Bit 6-7Trigger触发阈值。00: 1字节, 01: 4字节, 10: 8字节, 11: 14字节。
  • Line Control Register (+3) LCR

​ 串口通信的协议说明书,两端必须配置一致才能正常通信

位 (Bit)名称描述
Bit 0-1Word Len数据长度。00: 5位, 01: 6位, 10: 7位, 11: 8位(常用)。
Bit 2Stop Bit停止位。0: 1位, 1: 1.5位或2位。
Bit 3-5Parity奇偶校验。控制是否校验以及如何校验(奇、偶、固定位等)。
Bit 6Break开启后,发送端会发送一个长间隔信号。
Bit 7DLAB核心开关。1:访问波特率寄存器;0:访问数据/中断寄存器。
  • Modem Control Register (+4) MCR

​ 控制与外部设备的握手信号和芯片模式

位 (Bit)名称描述
Bit 0DTRData Terminal Ready。设为 1 时,通知对方设备:本设备已准备好。
Bit 1RTSRequest To Send。设为 1 时,请求发送数据。
Bit 2OUT1辅助输出 1。在普通 PC 上通常不接,但在某些机器上用于控制硬件。
Bit 3OUT2辅助输出 2。非常关键,在 PC 架构中,这一位必须设为 1 才能允许串口触发硬件中断(IRQ)。
Bit 4Loop自检模式(环回模式)。设为 1 时,串口发送的数据会直接被自己接收,用于测试。
  • Line Status Register (+5) LSR

​ 内核代码中sendreceive函数最常查询的寄存器,用来判断缓冲区数据状态。

位 (Bit)名称描述
Bit 0DRData Ready。为 1 表示接收缓冲区有数据,可以读取。
Bit 1OEOverrun Error。没及时读取,新数据把旧数据顶掉了。
Bit 5THRETransmitter Empty。发送缓冲区空,可以写下一个字符了。
Bit 7ErrFIFO 数据错误标识。
  • Modem Status Register (+6) MSR

​ 显示当前的线路的物理状态,在Modem控制或流控中使用。

位 (Bit)名称描述
Bit 0DCTS清除发送(CTS)状态改变。
Bit 4CTS当前 CTS 信号线的电平状态。
Bit 5DSR数据设备准备就绪信号。
  • Scratch Register (+7)

​ 硬件完全不使用的临时存储位

位 (Bit)名称描述
Bit 0-7Scratch你可以写任何东西进去,再读出来。如果读写一致,说明该端口确实存在 UART 芯片。

在阅读完了上述信息后,就可以着手补全代码了(虽然我顺序好像是反着来的

使用**更好的写法**,常量泛型,对照实验任务文档中的c语言代码来实现

use core::fmt;

use x86_64::instructions::port::Port;

/// A port-mapped UART 16550 serial interface.
pub struct SerialPort<const BASE_ADDR: u16>{

}

impl<const BASE_ADDR: u16> SerialPort<BASE_ADDR> {
    pub const fn new() -> Self {
        Self { }
    }

    /// Initializes the serial port.
    pub fn init(&self) -> Result<(), &'static str>{
        // FIXME: Initialize the serial port
        let mut data=Port::<u8>::new(BASE_ADDR);
        let mut ier= Port::<u8>::new(BASE_ADDR+1);
        let mut lcr=Port::<u8>::new(BASE_ADDR+3);
        let mut fcr= Port::<u8>::new(BASE_ADDR+2);
        let mut mcr=Port::<u8>::new(BASE_ADDR+4);

        unsafe{
            ier.write(0x00); // +1 禁用所有中断
            lcr.write(0x80); // +3 启用DLAB
            data.write(0x83); // +0 波特率除数低位
            ier.write(0x00); // +1 波特率除数高位
            lcr.write(0x03); //+3 禁用DLAB,设置8位数据,无校验,1位停止位
            fcr.write(0xC7); // +2 启用FIFO,清空,设置14字节触发阈值
            mcr.write(0x0B); //+4 0000 1011 设置RTS/DSR,启用辅助输出OUT2(用于触发中断)
            mcr.write(0x1E); //+4 0001 1110 自检,设置为环回模式
            data.write(0xAE); //+0 发送测试字节0xAE

            if data.read()!=0xAE{
                return Err("Serial hardware faulty or not found.");
            }
            mcr.write(0x0F); //禁用环回,启用所有控制位
        }
        Ok(())
    }

    /// Sends a byte on the serial port.
    pub fn send(&mut self, data: u8) {
        // FIXME: Send a byte on the serial port
        let mut lsr=Port::<u8>::new(BASE_ADDR+5);
        unsafe {
            while (lsr.read() & 0x20)==0 {}
            Port::<u8>::new(BASE_ADDR).write(data);
        }
    }

    /// Receives a byte on the serial port no wait.
    pub fn receive(&mut self) -> Option<u8> {
        // FIXME: Receive a byte on the serial port no wait
        let mut lsr=Port::<u8>::new(BASE_ADDR+5);
        unsafe {
            if (lsr.read()&1)==0 {
                None
            }else {
                Some(Port::<u8>::new(BASE_ADDR).read())
            }
        }
        
    }
}

impl<const BASE_ADDR: u16> fmt::Write for SerialPort<BASE_ADDR> {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        for byte in s.bytes() {
            self.send(byte);
        }
        Ok(())
    }
}

Better logger

use log::{Level, Metadata, Record};

pub fn init() {
    static LOGGER: Logger = Logger;
    log::set_logger(&LOGGER)
        .map(|()|{
            log::set_max_level(log::LevelFilter::Info);
        })
        .expect("Faild to initialize logger");

    // FIXME: Configure the logger


    info!("Logger Initialized.");
}

struct Logger;

impl log::Log for Logger {
    fn enabled(&self, _metadata: &Metadata) -> bool {
        _metadata.level() <= log::max_level()
    }

    fn log(&self, record: &Record) {
        // FIXME: Implement the logger with serial output
        if self.enabled(record.metadata()){
            let level = record.level();
            let target = record.target();
            let file = record.file_static().unwrap_or("unknown file");
            let line = record.line().unwrap_or(0);
            let args = record.args();
            let color_code= match level {
                Level::Error => "\x1b[31m", //red
                Level::Warn => "\x1b[33m", //yellow
                Level::Info => "\x1b[32m", //green
                Level::Debug => "\x1b[36m", //blue
                Level::Trace => "\x1b[90m", //grey
            };
            let reset_code = "\x1b[0m";

            println!(
            "{}[{:5}]{} [{} @ {}:{}]: {}",
            color_code,
            level,
            reset_code, // 及时重置颜色,只给级别上色
            target,
            file,
            line,
            args
        );
        }
    }

    fn flush(&self) {}
}

思考题

  1. 在根目录的 Cargo.toml 中,指定了依赖中 boot 包为 default-features = false,而它会被内核引用,禁用默认 feature 是为了避免什么问题?请结合 crates/bootCargo.toml 谈谈你的理解。

答:禁用默认feature是为了防止把BL专用的环境配置污染给内核,导致命名空间冲突(好像不应该用这个词

  1. crates/boot/src/main.rs 中参考相关代码,聊聊 max_phys_addr 是如何计算的,为什么要这么做?
let max_phys_addr = mmap
        .entries()
        .map(|m| m.phys_start + m.page_count * 0x1000)
        .max()
        .unwrap()
        .max(0x1_0000_0000); // include IOAPIC MMIO area

答:用闭包获取内存中的m,用起始地址+分页数*页大小计算最大内存地址。

为了给内核建立物理内存的直接映射提供范围界限

最后.max(0x1_0000_0000)是十六进制的4GB边界。在x86架构中,CPU和各种外部硬件通信是将硬件的寄存器直接映射到物理内存的地址空间中的(MMIO Memory-Mapped I/O),通常被强制安放在物理地址的4GB边界下方,因此必须映射到4GB边界,防止内核启动后配置某些硬件的寄存器是找不到页表映射而触发的Page Fault。

  1. 串口驱动是在进入内核后启用的,那么在进入内核之前,显示的内容是如何输出的?

答:内核启动前显示的内容是由UEFI固件输出的。

  1. 在 QEMU 中,我们通过指定 -nographic 参数来禁用图形界面,这样 QEMU 会默认将串口输出重定向到主机的标准输出。
  • 假如我们将 Makefile 中取消该选项,QEMU 的输出窗口会发生什么变化?请观察指令 make run QEMU_OUTPUT= 的输出,结合截图分析对应现象。
  • 在移除 -nographic 的情况下,如何依然将串口重定向到主机的标准输入输出?请尝试自行构造命令行参数,并查阅 QEMU 的文档,进行实验。
  • 如果你使用 ysos.py 来启动 qemu,可以尝试修改 -o 选项来实现上述功能。

答:

  1. 去掉-nographic参数后就会弹出一个GUI窗口
  2. 需要显示传递-serial stdio参数给QEMU,以使得QEMU把com1重定向到stdio

结合本实验中 ELF 映射、页表与串口初始化的实现,讨论:

  • 你会如何划分“最小可启动路径”和“可调试增强路径”?
  • 若只能保留 3 个关键日志点,你会放在哪些阶段,为什么?

答:

  1. 最小可启动路径:只包含加载ELF、页表映射、栈与控制权移交

​ 可调试增强路径:多包含权限控制、串口和日志系统、Panic Handler和一些一致性判断

  1. 只能保留三个关键日志点:

    1. BL移交控制权
    2. 内核串口初始化
    3. Panic Handler入口
若内核映射时误把某段权限设置为可写+可执行,你预期这会对系统安全性和调试复杂度带来什么影响?

答:

系统安全性:破坏W^X 原则(Write XOR Execute),即一段内存要么只写、要么只执行。

如果即可写又可执行,那么内核中存在的缓冲区溢出漏洞可以被很容易的利用。

调试复杂度:如果可执行程序没有W权限,那么会触发Page Fault异常,如果是W+X那么会默认写入成功,等其他程序执行到时才会发现。

假设引导阶段必须在“更快启动”和“更多一致性检查”之间二选一,你会如何取舍并给出依据?

更多一致性检查。调试时发现错误修复的耗时和难度都远小于实际业务中发现错误时修复。

0x02

实验任务

参考上下文,在 src/memory/gdt.rs 中补全 TSS 的中断栈表,为 Double Fault 和 Page Fault 准备独立的栈。

pub *const* IST_SIZES: [usize; 3] = [0x1000, 0x1000, 0x1000];

tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize]={
          const STACK_SIZE:usize=IST_SIZES[1];
          static mut STACK:[u8;STACK_SIZE]=[0;STACK_SIZE];
          let stack_start=VirtAddr::from_ptr(addr_of_mut!(STACK));
          let stack_end=stack_start+STACK_SIZE as u64;
          info!(
            "Double Fault IST : 0x{:016x}-0x{:016x}",
            stack_start.as_u64(),
            stack_end.as_u64()
          );
          stack_end
        };

和前面给出的privilege_stack_table的写法几乎一模一样,都向CPU申请一段连续的4KB内存。

为什么要有RSP和IST栈?

操作系统永远不信任用户态,当用户程序触发中断时CPU需要将现场保存起来并push到栈上后去执行内核代码,而用户的栈不被信任(指向非法地址/满栈/被恶意修改),所以强制直接从TSS中获取RSP的地址并将RSP指向。

IST是为了处理Double Fault的,当内核在使用RSP栈时发生了如栈溢出的问题,若在往上push数据就会发生Page Fault,而CPU执行缺页异常处理函数也需要push栈用于保存RIP、CS等,在处理异常时触发了同样的异常时会触发Double Fault(#DF),于是把RSP切换到TSS的IST栈中正常执行错误处理程序。

如果没有IST,内核会继续尝试在RSP上push,会触发Triple Fault,导致主板重启,无法执行正常的错误处理程序。

注册中断处理程序

src/interrupt 目录下创建 exceptions.rsclock.rsserial.rs 三个文件:

exceptions.rs 中描述了 CPU 异常的处理,这些异常由 CPU 在内部生成,用于提醒正在运行的内核需要其注意的事件或情况。x86_64InterruptDescriptorTable 中为这些异常处理函数提供了定义,如 divide_errordouble_fault 等。

对于中断请求(IRQ)和硬件中断,将在独立的文件中进行处理。clock.rs 中描述了时钟中断的处理,serial.rs 中描述了串口输入中断的处理。

首先修补已经创建好的exceptions.rs文件,增加更多异常行为

idt.general_protection_fault
        .set_handler_fn(general_protection_fault_handler);
idt.general_protection_fault
        .set_handler_fn(breakpoint_handler);
idt.general_protection_fault
        .set_handler_fn(invalid_opcode_handler);


pub extern "x86-interrupt" fn general_protection_fault_handler(
    stack_frame: InterruptStackFrame,
    err_code: u64
){
    panic!(
        "EXCEPTION: GENERAL PROTECTION FAULT: \n\nERROR CODE:{:#x}\n{:#?}",
        err_code,
        stack_frame
    );
}

pub extern "x86-interrupt" fn breakpoint_handler(
    stack_frame: InterruptStackFrame
){
    println!("EXCEPTION: BREAKPOINT:\n{:#?}",stack_frame);
}

pub extern "x86-interrupt" fn invalid_opcode_handler(
    stack_frame: InterruptStackFrame
){
    panic!("EXCEPTION: INVALID OPCODE\n{:#?}",stack_frame);
}

为什么有的异常情况用.set_handler_fn()准备了一个独立的栈,有的没有?

对于新增的GPF、Breakpoint、Invalid Opcode等一般性异常来说,他们通常不会导致栈指针RSP损坏,而Double Fault和Page Fault会导致前文所述的栈损坏,必须使用IST的新栈区解决。而硬件在TSS中仅设计了7个IST槽位,不足以给不需要新栈的普通异常也配上IST槽位。

从此开始我对本次实验的路程变得奇怪了起来,但是又说不上哪里奇怪

注意到文档中有提示内存地址映射,于是查看src/memory/address.rs找到了函数physical_to_virtual(此时的我还没有发现它有什么用

接着补全APIC,这里文档几乎给出了每一步的所有代码(???

你需要在 src/interrupt/apic/xapic.rs 中补全 APIC 的初始化代码,以便在后续实验中使用 APIC 实现时钟中断和 I/O 设备中断。
fn cpu_init(&mut self) {
        unsafe {
            // FIXME: Enable local APIC; set spurious interrupt vector.
            let mut spiv=self.read(0xF0);
            spiv |= 1<<8; // 第8位设为1,启用APIC的总开关
            spiv &= !(0xFF); //计算好的伪中断向量号填入低8位
            spiv |= Interrupts::IrqBase as u32 + Irq::Spurious as u32;
            self.write(0xF0, spiv);
            // FIXME: The timer repeatedly counts down at bus frequency
            let mut lvt_timer =self.read(0x320); //0x320为APIC内部LVT Timer Register 本地向量表-定时器寄存器的偏移地址

            lvt_timer &= !(0xFF);
            lvt_timer |= Interrupts::IrqBase as u32 +Irq::Timer as u32; //设置中断向量号 加上IrqBase是因为在x86中,0-31号中断是被CPU内部异常占用的,所以要将硬件中断映射到32位及以上的位置
            lvt_timer &= !(1<<16);
            lvt_timer |= 1<<17; //LVT的17-18位控制定时器的工作模式 00-单次;01-周期模式,到0中断后自动重置;10-高级模式,绑定CPU时间戳计时器
            
            self.write(0x320, lvt_timer);
            self.write(0x3E0, 0b1011); //速率数值
            self.write(0x380, 0x20000); //倒计时初始值
            //0x320 决定了:以什么方式响?响几号警报?
            //0x3E0 决定了:倒数的流逝速度有多快?
            //0x380 决定了:从多少开始倒数?

            // FIXME: Disable logical interrupt lines (LINT0, LINT1)
            //禁用LINT(Local Interrupt)本地中断引脚
            //在老式的CPU上真实存在 8259A 芯片(Legacy PIC)管理中断,为了预防其与xAPIC冲突,所以将其禁用
            self.write(0x350, 1<<16); //LINT0
            self.write(0x360, 1<<16); //LINT1
            // FIXME: Disable performance counter overflow interrupts (PCINT)
            self.write(0x340, 1<<16);//禁用性能计数器
            // FIXME: Map error interrupt to IRQ_ERROR.
            self.write(0x370, Interrupts::IrqBase as u32+Irq::Error as u32); //0x370位LVT Error Register 错误中断寄存器,将中断错误其映射到其
            // FIXME: Clear error status register (requires back-to-back
            // writes).
            self.write(0x280, 0); //0x280为Error Status Register(ESR 错误状态寄存器)
            self.write(0x280, 0);
            //第一次并不会直接清除错误,而是作为触发信号传给APIC,把底层隐藏的错误状态同步刷新到可见的ESR寄存器上
            //第二次写入才会真正清除ESR里的错误记录

            // FIXME: Ack any outstanding interrupts.
            self.write(0x00B0, 0); // 为End of Interrupt 中断结束寄存器,在内核启动时先发一个EOI防止以前遗留在ISR寄存器的任务干扰
            
            // FIXME: Send an Init Level De-Assert to synchronise arbitration ID's.
            // Arbitration 仲裁是早期计算机总线上的机制。
            //在多核架构中,多个APIC共享同一根总线,如果两个CPU在同一瞬间都想发送中断消息,此时需要通过仲裁来确认谁的消息生效。
            // Intel在奔腾4以后的处理器就废弃了这条指令,因为更换了前端总线或者环形总线来进行点对点通信而不需要仲裁了
            self.write(0x310, 0);
            const BCAST:u32=1<<19; //BCAST为广播,ICR的18-19位控制Destination Shorthand,10使19位为1,表示广播给包括自己的所有人
            const INIT:u32=5<<8; //8-10位控制Delivery Mode 5为101,代表这是一个INIT级别的控制消息
            const TMLV:u32=1<<15; //15位为Trigger Mode 1表示电平触发,14位为Level 0表示取消断言
            self.write(0x300, BCAST | INIT | TMLV);
            const DS: u32 = 1<<12; //把数据写入到0x300时APIC自动把DS(Delivery Status)设置为1,消息被CPU接收后自动回0
            while self.read(0x300) & DS != 0 {} //自旋锁

            // FIXME: Enable interrupts on the APIC (but not on the processor).
            self.write(0x0080, 0); //把Task Priority Register 任务优先级寄存器的阈值降到最低,使其让APIC接收所有级别的中断
        }

        // NOTE: Try to use bitflags! macro to set the flags.
    }

接着继续修补clock.rs

在顺利配置好 XAPIC 并初始化后,APIC 的中断就被成功启用了。为了响应时钟中断,需要为 IRQ0 Timer 设置中断处理程序。

创建 src/interrupt/clock.rs 文件,参考如下代码,为 Timer 设置中断处理程序

use super::consts::*;
use core::sync::atomic::{AtomicU64,Ordering};
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};

pub unsafe fn register_idt(idt: &mut InterruptDescriptorTable){
    idt[Interrupts::IrqBase as u8 + Irq::Timer as u8]
        .set_handler_fn(clock_handler);
}

pub extern "x86-interrupt" fn clock_handler(_sf: InterruptStackFrame){
    x86_64::instructions::interrupts::without_interrupts(||{
        if inc_counter() % 0x1000 ==0{
            info!("Tick! @{}",read_counter());
        }
        super::ack();
    });
}

static COUNTER:AtomicU64=AtomicU64::new(0);

#[inline]
pub fn read_counter()->u64{
    // FIXME: load counter value
    COUNTER.load(Ordering::Relaxed)
}

#[inline]
pub fn inc_counter() -> u64{
    // FIXME: read counter value and increase it
    COUNTER.fetch_add(1,Ordering::Relaxed)+1
}

首先是修补全局计数器的类型,啊内核安全并发安全,那就是原子类型了。

下面由于要求不高,使用Relaxed排序即可

补全上述代码任务,并尝试修改你的代码,调节时钟中断的频率,并观察 QEMU 中的输出。

说明你修改了哪些代码,如果想要中断的频率减半,应该如何修改?

答:回到xapic.rs中,找到:

// 1. 设置模式为 Periodic (周期性)
self.write(0x320, vector | (1 << 17)); 
// 2. 设置分频系数 (TDCR)
self.write(0x3E0, 0b1011); // Divide by 1 (不分频)
// 3. 设置初始计数值 (TICR)
self.write(0x380, 0x20000);

使中断频率减半可以使TICR翻倍,或者TDCR从by1 变为by2

使得QEMU中打印速度变慢

串口输入中断:

为了开启串口设备的中断,你需要参考如下 C 语言代码,在 src/drivers/uart16550.rsinit 函数末尾为串口设备开启中断

回到0x01uart16550中,在最后把之前关闭的中断打开

pub fn init(&self) -> Result<(), &'static str>{
        // FIXME: Initialize the serial port
        let mut data=Port::<u8>::new(BASE_ADDR);
        let mut ier= Port::<u8>::new(BASE_ADDR+1);
        let mut lcr=Port::<u8>::new(BASE_ADDR+3);
        let mut fcr= Port::<u8>::new(BASE_ADDR+2);
        let mut mcr=Port::<u8>::new(BASE_ADDR+4);

        unsafe{
            ier.write(0x00); // +1 禁用所有中断
            lcr.write(0x80); // +3 启用DLAB
            data.write(0x83); // +0 波特率除数低位
            ier.write(0x00); // +1 波特率除数高位
            lcr.write(0x03); //+3 禁用DLAB,设置8位数据,无校验,1位停止位
            fcr.write(0xC7); // +2 启用FIFO,清空,设置14字节触发阈值
            mcr.write(0x0B); //+4 0000 1011 设置RTS/DSR,启用辅助输出OUT2(用于触发中断)
            mcr.write(0x1E); //+4 0001 1110 自检,设置为环回模式
            data.write(0xAE); //+0 发送测试字节0xAE

            if data.read()!=0xAE{
                return Err("Serial hardware faulty or not found.");
            }
            mcr.write(0x0F); //禁用环回,启用所有控制位
            
            ier.write(0x01); //启用中断(0x02)
        }
        Ok(())
    }

接着继续修补src/drivers/input.rs

为了承接全部(可能的)用户输入数据,并将它们统一在标准输入,需要为输入准备缓冲区,并将其封装为一个驱动,创建 src/drivers/input.rs 文件,使用 crossbeam_queue crate 实现一个无锁输入缓冲区。

  1. 使用你喜欢的数据结构存储用户输入的数据。

此缓冲区大小和存储的数据类型由你自行决定,一个参考的缓冲区大小为 128。

推荐使用 crossbeam_queue::ArrayQueue 作为缓冲区的实现,它是一个无锁的、固定大小的队列,可以在多线程环境下安全地进行读写操作。

  1. 处理数据结构的初始化,暴露基本功能。

    初始化 INPUT_BUFFER,你可以直接使用 lazy_static 初始化

  2. 实现并暴露 pop_key 函数。

    利用 try_pop_key 函数,从缓冲区中阻塞取出数据:循环等待,直到缓冲区中有数据,并返回获取到的数据。

  3. 实现并暴露 get_line 函数。

    从缓冲区中阻塞取出数据,并将其实时打印出来。直到遇到换行符 \n。将数据转换为 String 类型,并返回。

    对于 0x080x7F 字符,表示退格,你需要对其进行特殊处理。若当前字符串不为空,则删除最后一个字符,并将其从屏幕上删除。

    删除操作可以通过发送 0x080x200x08 序列实现。你可以在串口驱动中将它封装为 backspace 函数。

    Note: String::with_capacity 可以帮助你预先分配足够的内存。

这里开放了Key的类型供自己选择,这里我选择使用pc-keyboard,为未来支持键盘符号输入做好预备。

use alloc::string::String;
use crossbeam_queue::ArrayQueue;
use pc_keyboard::DecodedKey;
use core::hint::spin_loop;

use crate::drivers::uart16550::SerialPort;

type Key=DecodedKey;

lazy_static!{
    static ref INPUT_BUF: ArrayQueue<Key>=ArrayQueue::new(128);
}

#[inline]
pub fn push_key(key:Key){
    if INPUT_BUF.push(key).is_err(){
        warn!("Input buffer is full. Dropping key '{:?}'",key);
    }
}

#[inline]
pub fn try_pop_key() -> Option<Key>{
    INPUT_BUF.pop()
}

#[inline]
pub fn pop_key()->Key{
    loop {
        if let Some(key) = try_pop_key() {
            return key;
        }
        spin_loop();
    }
}

pub fn get_line() -> String{
    let mut line = String::with_capacity(64);

    loop {
        let key = pop_key();

        match key {
            DecodedKey::Unicode('\n') => {
                print!("\n");
            break;
            },
            DecodedKey::Unicode('\x08') => {
                if !line.is_empty(){
                    line.pop();
                    let serial= SerialPort::<0x3F8>::new();
                    serial.backspace();
                }
            },
            DecodedKey::Unicode(c) => {
                if c.is_ascii() && !c.is_ascii_control() {
                    line.push(c);
                    print!("{}",c);
                }   
            },
            DecodedKey::RawKey(_) => {

            }
        }

    }
    line
}

按照文档接着继续修补src/interrupt/serial.rs

这里最复杂的就是自定义enum下的换行符处理:

use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use x86_64::instructions::port::Port;
use pc_keyboard::DecodedKey;
use super::consts::*;

pub unsafe fn register_idt(idt : &mut InterruptDescriptorTable){
    idt[Interrupts::IrqBase as u8+Irq::Serial0 as u8]
        .set_handler_fn(serial_handler);
}

pub extern "x86-interrupt" fn serial_handler(_st: InterruptStackFrame){
    receive();
    super::ack();
}

/// Receive character from uart 16550
/// Should be called on every interrupt

fn receive(){
    let mut data_port = Port::<u8>::new(0x3F8);
    let mut lsr_port = Port::<u8>::new((0x3F8) + 5);

    unsafe {
        while lsr_port.read() & 0x01 !=0 {
            let byte = data_port.read();
            let key=match byte {
                0x08 | 0x7F => DecodedKey::Unicode('\x08'),
                b'\r' | b'\n' => DecodedKey::Unicode('\n'),
                _ => DecodedKey::Unicode(byte as char)
            };
            crate::drivers::input::push_key(key);
        }
    }
}

接着最后完善用户交互测试

在完善了输入缓冲区后,可以在 src/main.rs 中,使用 get_line 函数来获取用户输入的一行数据,并将其打印出来、或进行更多其他的处理,实现响应用户输入的操作。
#![no_std]
#![no_main]

#[macro_use]
extern crate log;

use core::arch::asm;
use ysos::*;
use ysos_kernel as ysos;

boot::entry_point!(kernel_main);

pub fn kernel_main(boot_info: &'static boot::BootInfo) -> ! {
    ysos::init(boot_info);

    loop {
        info!("Hello World from YatSenOS v2!");

        print!(">");
        let input=input::get_line();

        match input.trim() {
            "exit" => break ,
            _ => {
                println!("You said: {}",input);
                println!("The counter value is {}",interrupt::clock::read_counter());
            }
        }
    }

    ysos::shutdown();
}

好了,完事大吉了……?

这里make了一下发现爆了一大堆error,才发现为什么觉得怪怪的了……

没修src/interrupt/mod.rs

这里我们前面发现的内存映射函数终于派上了用场

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        unsafe {
            exceptions::register_idt(&mut idt);
            clock::register_idt(&mut idt);
            serial::register_idt(&mut idt);
        }
        idt
    };
}

/// init interrupts system
pub fn init() {
    IDT.load();

    // FIXME: check and init APIC
    unsafe {
        let apic_phys_base = 0xFEE00000;
        let apic_virt_base=crate::memory::physical_to_virtual(apic_phys_base);
        let mut local_apic = XApic::new(apic_virt_base);
        local_apic.cpu_init();
    }

    // FIXME: enable serial irq with IO APIC (use enable_irq)
    enable_irq(Irq::Serial0 as u8, 0);
    
    info!("Interrupts Initialized.");
}

以及很奇怪的一个error,应该是预期行为,我太菜了(

unresolved import boot::BootMemoryMap
no BootMemoryMap in the root

0x01的文件boot/src/lib.rs中导出ArrayVec<MemoryDescriptor, 256>BootMemoryMap即可

pub type BootMemoryMap = ArrayVec<MemoryDescriptor, 256>;

思考题

  1. 为什么需要在 clock_handler 中使用 without_interrupts

答:防止中断嵌套导致栈溢出和死锁

  1. 时钟中断频率(HZ)设置的影响

答:太快会消耗系统的过多资源,太慢会导致响应延迟过高

  1. 为什么 receive 操作时无法输出日志?如果强行输出会怎样?

答:自旋锁陷入死锁。日志宏底层会调用到锁,如果内核正在正常运行,恰好执行到了某处info!,准备发送数据时,接收到键盘导致的串口中断,使CPU跳入serial_handler进而调用receive。如果在receive里调用info!会导致锁嵌套形成死锁。

对串口和互斥锁的认识:中断上下文(Interrupt Context)是极度危险的区域。在这里绝对不能使用可能引起阻塞或自旋等待外部释放的锁。串口这种慢速 I/O 设备,最好采用“上半部(中断里只把数据塞进无锁队列)+ 下半部(主循环从队列读出再慢慢处理)”的异步架构。
  1. 输入缓冲区何时会满?满了会怎样?

答:用户输入速度>内核pop的速度时会满,来不及输入缓冲区的数据会被直接丢弃

  1. 进行下列尝试,并在报告中保留对应的触发方式及相关代码片段:

    • 尝试用你的方式触发 Triple Fault,开启 intdbg 对应的选项,在 QEMU 中查看调试信息,分析 Triple Fault 的发生过程。
    • 尝试触发 Double Fault,观察 Double Fault 的发生过程,尝试通过调试器定位 Double Fault 发生时使用的栈是否符合预期。
    • 通过访问非法地址触发 Page Fault,观察 Page Fault 的发生过程。分析 Cr2 寄存器的值,并尝试回答为什么 Page Fault 属于可恢复的异常

答:

  1. 如果在 TSS 中为中断分配的栈空间不足,会发生什么情况?请分析 CPU 异常的发生过程,并尝试回答什么时候会发生 Triple Fault。

答:发生过程:x86_64 架构下,为了防止普通任务的栈溢出影响到关键异常(如 Double Fault 或 NMI),我们会在 TSS 的 IST(Interrupt Stack Table)中为它们分配独立的已知好栈。如果这个栈空间不足(例如只分配了 16 字节):

  1. 中断发生,CPU 硬件强行切换到 TSS 配置的栈顶。
  2. CPU 试图压入 SS, RSP, RFLAGS, CS, RIP 等上下文信息(需要约 40 字节)。
  3. 压栈过程中越界,碰到未映射内存,触发 Page Fault。
  4. CPU 试图调用 Page Fault Handler,又要压栈,再次失败,升级为 Double Fault。
  5. Double Fault 也需要压栈,再次失败 $\rightarrow$ Triple Fault,系统重启
  6. 未使用 set_stack_index 时中断处理程序的栈在哪里?

答:原先的栈空间内

  1. 结合本实验的 APIC 初始化与中断路径,分析:

    • 哪些初始化步骤必须严格按顺序执行?
    • 如果你故意交换其中两步,最可能出现的首个可观测故障是什么?

答:

  • 严格顺序分配并加载 IDT $\rightarrow$ 初始化 APIC $\rightarrow$ 配置 IOAPIC $\rightarrow$ sti (开启中断)
  • 交换后果:如果把 sti 放到 IDT.load() 之前,只要此时系统中任何硬件(哪怕是偶然的电平波动)触发了一个中断,CPU 会立刻去查找毫无准备的 IDT,直接导致不可预期的跳转,瞬间抛出 Triple Fault 并崩溃。这叫做“裸奔”。

0x03

根据本节实验文档描述,本节的实验设计对后续实验有核心影响,故从本节开始,我将在实验报告中增添知识摘抄与总结部分,以便回顾。

本节实验的流程图:

flowchart TD A[时钟中断 clock_handler] --> B[proc::switch] B --> C[save_current] C --> D[ready_queue: VecDeque] D --> E[switch_next] E --> F[restore next process] G[阻塞事件完成 / 唤醒点] --> H[将进程标记 Ready] H --> I[可选: 立即触发调度] I --> B J[Page Fault 中断] --> K[exceptions::page_fault_handler] K --> L[proc::handle_page_fault] L --> M[ProcessManager::handle_page_fault] M --> N{进程是否 Dead?} N -- 是 --> O[返回 false / 交给上层 panic] N -- 否 --> P[ProcessVm::handle_page_fault] P --> Q[Stack::handle_page_fault] Q --> R[必要时扩栈并映射页] S[进程退出 kill/process_exit] --> T[status = Dead] T --> U[proc_vm = None] T --> V[proc_data = None] U --> W[后续缺页/调度访问可能失效] V --> W

前置知识

进程模型设计

不同的操作系统有不同的进程模型设计,在macOS xnu和windows NT中,将线程作为调度执行的基本单位,进程作为资源分配的基本单位;而在Linux中没有线程的概念,使用进程作为调度执行的基本单位。

特性Windows NT / macOS (XNU)Linux (Standard)
内核对象明确区分 Process 和 Thread统一使用 task_struct
调度对象线程 (Thread)任务 (Task / LWP)
资源界限进程边界非常硬(隔离彻底)边界是灵活的(通过标志位组合)
实现方案原生支持线程模型通过轻量级进程模拟线程 (NPTL)
在YSOS的实验中,将使用类似Linux的进程模型设计
![[Pasted image 20260503185040.png]]

进程控制块

进程控制块(Process Control Block , PCB)是操作系统中用于描述进程的一种数据结构,包含了进程的所有信息。
实验中用Process结构表示一个进程,其含有pidinner两个字段,分别表示进程的ID和内部数据。
inner 字段是一个 RwLock<ProcessInner> 类型的字段,它包含了一个 ProcessInner 结构体,这个结构体包含了进程的其他信息,包括进程的状态、调度计次、退出返回值、内存空间、父子关系、中断上下文、文件描述符表等等。
pid作为进程的唯一标识符,用 struct ProcessId(pub u16)实现,在最基本的实现中,只需要保证每次的ProccessID::new()都会返回一个不同的ProcessID即可。

进程上下文

在抢占式操作系统中,进程的调度是通过中断实现的。当一个进程的时间片用完后,操作系统会触发一个时钟中断,进程调度器会被唤醒,它会根据进程的状态和调度策略来决定下一个要运行的进程。在调度器决定好下一个要运行的进程后,它会将当前进程的上下文保存起来,然后将下一个进程的上下文恢复,从而使得下一个进程得以运行。
在之前的实验中,描述过了在x86_64架构下的中断发生时,CPU会将:

  • instruction_pointer:指令指针,保存了中断发生时 CPU 正在执行的指令的地址。
  • code_segment:代码段寄存器,保存了当前正在执行的代码段的选择子。
  • cpu_flags:CPU 标志寄存器,保存了中断前的 CPU 标志状态。
  • stack_pointer:栈指针,保存了中断前的栈指针。
  • stack_segment:栈段寄存器,保存了中断前的栈段选择子,在 x86_64 下总是为 0。
    作为上下文保存到内核栈中,然后跳转到终端处理函数。
    而在进行进程切换时,还需要保存和恢复更多的上下文,主要包括通用寄存器和浮点寄存器(这里为了简化,禁用了浮点寄存器)。

进程页表

进程的页表是通过向 Cr3 寄存器写入页表的物理地址来实现控制的,因此在进程切换时,需要将进程的页表物理地址写入 Cr3 寄存器
除了内核进程的页表在启动时被初始化外,其他进程的页表都是在进程创建时被初始化的。它们通常通过克隆内核进程来实现,这样做的目的是当程序陷入中断时,CPU 能够正常访问到内核的代码和数据,从而能够正常的进行系统调用。

如何克隆一个页表?
假设页表的页面在映射后不被修改,且将内核映射到高的地址空间、用户进程映射到低的地址。在这种情况下不需要复制整颗页表树,只需要复制根节点即可。

进程调度

目的: 从就绪队列中选取一个进程分配给选定的CPU核心运行。
实现: 实现一个简单的FIFO调度器,将就绪队列中的第一个进程选出来,分配给CPU核心运行,当该进程的时间片用完后,调度器将其中断并放回就绪队列的末尾。

本实验中使用0表示没有进程中运行

ProcessManager被设计为不可变的,所以需要通过MutexRwLock为其提供内部可变性。

内核进程

在初始化进程管理器时,为了让内核始终和其他进程有一样的获取时间片的机会,需要将其作为第一个进程加入进程列表。并在之后的调度中,作为普通的进程参与调度的选中、保存与执行。
因此,ProcessManager 初始化时需要传入一个 init 进程,它会作为内核进程加入进程列表。

时钟中断的处理

在时钟中断发生时,进程调度器被运行,CPU通过IDT调用中断处理程序完成进程切换。
完成一次进程切换的过程需要先关闭中断,然后:

  • 保存当前进程的上下文
  • 更新当前进程的状态
  • 将当前进程放在就绪队列的末尾
被调度的进程状态可能在就绪队列中时发生了改变,因此需要进行一些检查。
  • 从就绪队列选取下一个进程
  • 切换进程上下文和页表。

进程的内存布局

在启用虚拟内存后,操作系统拥有了极其庞大的地址空间,可以分别赋予他们不同的功能和管理。
在前面的实验中有一些预设的内存布局:

  • 物理内存偏移:0xFFFF800000000000

    通过定义物理内存偏移,借助 2MB 的页映射,将物理内存线性映射到了这一偏移量所对应的的地址空间中。在内核中,当需要访问一个物理内存地址,如 0x1000 时,就可以通过 0xFFFF800000001000 来访问。

  • 内核空间起始地址:0xFFFFFF0000000000

    在操作系统中,一般会将内核地址映射到高偏移,而将用户地址映射到低偏移。在实验中,内核空间的起始地址被定义在了 0xFFFFFF0000000000,这相当于为内核预留了 1TiB 的地址空间。

    通过 kernel.ld 链接器脚本,将内核的起始地址设置为了这一地址,实验中编译的操作系统内核会被链接到从这一地址开始的地址空间中。同时,通过 bootloader 中的内核加载函数,读取 ELF 文件的描述,并将这些内容加载到了对应的地址空间中。

  • 内核栈地址:0xFFFFFF0100000000

    内核栈的起始地址通过配置文件被定义在了 0xFFFFFF0100000000,距离内核起始地址 4GiB。默认大小为 512 个 4KiB 的页面,即 2MiB。

    在虚拟内存的规划中,任意进程的栈地址空间大小为 4GiB。以内核为例,内核栈所对应的内存区域的起始地址为 0xFFFFFF0100000000,结束地址为 0xFFFFFF0200000000

实验任务

首先合并仓库中0x03给出的代码,给出了src/procsrc/utils的增量代码。

进程管理器的初始化

首先来到src/proc/mod.rs,先初始化进程管理器,这里需要修补内核进程的创建。
我们去到process.rs看到Process::new()接受

pub fn new(
    name: String,
    parent: Option<Weak<Process>>,
    proc_vm: Option<ProcessVm>,
    proc_data: Option<ProcessData>,
)

这几个参数,于是将name命名为"kernel",内核进程的父进程为None,proc_vm和proc_data在上文被获取好了,用Some()包裹取出来传入函数即可。

pub fn init() {
    let proc_vm = ProcessVm::new(PageTableContext::new()).init_kernel_vm();
    trace!("Init kernel vm: {:#?}", proc_vm);
    // kernel process
    let proc_data=ProcessData::new();
    let kproc = {
        /* FIXME: create kernel process */
        Process::new("kernel".into(), None, Some(proc_vm), Some(proc_data))
    };
    manager::init(kproc);
    info!("Process Manager Initialized.");
}

接着,继续修补manager.rsinit函数,先将inner设为调用init的读写锁的write成员进行try_write_internal,接着用resume()函数将ProgramStatus设为Running状态。
接着调用processor::set_pid设置当前的pid为init.pid()

pub fn init(init: Arc<Process>) {
    // FIXME: set init process as Running
    {
        let mut inner=init.write();
        inner.resume();
    }
    // FIXME: set processor's current pid to init's pid
    processor::set_pid(init.pid());
    PROCESS_MANAGER.call_once(|| ProcessManager::new(init));
}

记得实现pid的新生成,在proc/pid.rs

impl ProcessId {
 pub fn new() -> Self {
     // FIXME: Get a unique PID
     ProcessId(NEXT_PID.fetch_add(1, Ordering::Relaxed))
 }
}

进程调度的实现

实验要求去除上一节的时钟中断计数器,并且参照DOUBLE_FAULT_IST_INDEX的样式,在TSS中声明一块新的中断处理栈,并加载到时钟中断的IDT里。
首先修改memory/gdt.rs

// 参考DOUBLE_FAULT_IST_INDEX在TSS中声明一块新的中断处理栈
tss.interrupt_stack_table[INTERRUPT_IST_INDEX as usize]={
    const STACK_SIZE:usize=IST_SIZES[2];
    static mut STACK:[u8;STACK_SIZE]=[0;STACK_SIZE];
    let stack_start=VirtAddr::from_ptr(addr_of_mut!(STACK));
    let stack_end=stack_start+STACK_SIZE as u64;
    info!(
        "INTERRUPT IST : 0x{:016x}-0x{:016x}",
        stack_start.as_u64(),
        stack_end.as_u64()
    );
    stack_end
};

为什么需要为时钟中断分配独立的栈空间?

思考题:中断的处理过程默认是不切换栈的,即在中断发生前的栈上继续处理中断过程,为什么在处理缺页异常和时钟中断时需要切换栈?如果不为它们切换栈会分别带来哪些问题?请假设具体的场景、或通过实际尝试进行回答。

名词解释:

  • mmap:映射虚拟内存页 (Memory map)
  • vma:有效存储地址(Valid Memory Address)
  • MMU:内存管理单元(Memory management unit)
  • PGD:进程顶级页目录 (Page Global Directory)
缺页中断产生的原因

当内存映射系统调用成功返回以后,内核只是为进程分配了一段[vm_start,vm_end)的虚拟内存区域,由于还未与物理内存发生关联,此时进程页表中与内存映射的虚拟内存相关的各级也目录和页表项都还是空的。
当CPU访问这段由mmap映射出来的虚拟内存区域vma的仍以虚拟地址时,MMU在便利进程页表时发现该虚拟内存地址在PGD中的对应页目录项pgd_t为空,而并没有指向像一节的页目录PID。也即,此时的进程页表中只有一张顶级页目录表PGD,而上层页目录PUD、中间页目录PMD、一级页表内核都没有创建。
所以现在访问到的虚拟内存地址对应的pgd_t是空的,进程的四级页表体系还未建立,所以MMU会产生缺页中断。
![[Pasted image 20260505175319.png]]

为什么缺页异常要切换栈

在处理缺页异常时,需要先从用户态切换至内核态。在异常发生时,CPU运行在用户态,此时的用户态的栈空间可能存在已满、被破坏等问题,如果继续使用用户栈,可能出现内核栈溢出或破坏用户栈现场的上下文,进而导致后续无法正常恢复等问题。
内存管理部直接分配物理内存,只有真正使用的时候才开始分配,所以在第一次使用时,往往会遇到缺页中断,并以此创建相应的页表项。

什么是时钟中断

在Linux中,OS时钟的物理产生原因是可编程技术器产生的输出脉冲,这个脉冲送入CPU就会引发一个中断请求信号,称作时钟中断。
时钟中断十分重要,操作系统需要依靠它维持系统时间、促使环境的切换、保证所有进程共享CPU等进程调度、系统维护、定时任务活动……

为什么时钟中断需要分配独立的栈空间

由于时钟中断十分重要,所以系统在这里无法信任用户栈,以防止用户栈空间不足或者被破坏等导致的问题。
时钟中断发生时,程序可以发生在任何状态,为了准确的恢复,需要在中断时保存现场的上下文,把上下文压入内核栈,同时也防止了中断冲突的发生。
时钟中断的一个重要作用时实现时间片轮转调度,当中断处理程序决定要切换进程时,他需要保存当前进程的内核栈顶指针,并switch到下一个进程。如果不用内核栈道话,在进程切换后,用户栈空间已经属于另一个进程,而前一个进程的状态将无法保存。

但是,如果我们目前是一个 User Process,它使用 User Stack,那么这样操作可能会 "cause a fatal error"。我学习了一下,这个fatal error问题主要是因为,User Stack是有大小的。一般的,一旦用户栈用尽,就会page fault,然后OS给它分个新的。但是如果是在context switch 中有这个问题,那我们多余的寄存器也无法存储了。另一个点是,如果OS在进程切换时可以碰User Stack,那么OS有权限检查和甚至有机会修改User Stack上的数据,这会是一个安全问题。我们的OS最好不仅”用户不能碰OS“,并且“OS最好也别碰用户”。

所以,我们可能需要一个新的栈:Kernel Stack,用来存储这些寄存器,来防止以上问题。不过这里也提到,这样做的缺点就是MMU会改变,并且缓存、TLB也会改变,这会使性能下降。

x86进程切换时为什么要有Kernel Stack?

接着去修改interrupts/clock.rs,修改中断函数以去除原先的时钟计时器,并把新添加的中断处理栈加载到时钟中断的IDT里。
注释上次添加的内容以及clock_handler(),新增clock()函数进行switch,用as_handler()!宏生成clock_handler()函数。

pub unsafe fn register_idt(idt: &mut InterruptDescriptorTable){
    unsafe {
        idt[Interrupts::IrqBase as u8 + Irq::Timer as u8]
        .set_handler_fn(clock_handler)
        .set_stack_index(gdt::INTERRUPT_IST_INDEX);
    }
}

fn clock(mut context: ProcessContext){
// 中断函数
    proc::switch(&mut context);
    super::ack();
}

as_handler!(clock);

接着,去proc/mod.rs补全刚刚使用的switch函数。

pub fn switch(context: &mut ProcessContext) {
    x86_64::instructions::interrupts::without_interrupts(|| {
        // FIXME: switch to the next process
        //      - save current process's context
        //      - handle ready queue update
        //      - restore next process's context
        
        // 保存当前context
        let manager=get_process_manager();
        manager.save_current(context);
        // 判断current状态,如果没死就push回就绪队列
        let current=manager.current();
        {
            let mut inner=current.write();
            if inner.status()!=ProgramStatus::Dead{
                inner.pause();
                manager.push_ready(current.pid());
            }
        }
        // 从队列中进一个新进程
        manager.switch_next(context);
    });
}

在补全过程中,我们使用到了未补全的save_currentswitch_next,于是前去补全。

save_current()先更新当前进程的时间刻,然后保存进程的上下文。

switch_next()是循环读取下一个进程的pid,检查其是否ready,如果ready就switch到下一个进程,并且返回其pid。

pub fn save_current(&self, context: &ProcessContext) {
        // FIXME: update current process's tick count
        let current=self.current();
        let mut inner=current.write();
        inner.tick();
        // FIXME: save current process's context
        inner.save(context);
    }

pub fn switch_next(&self, context: &mut ProcessContext) -> ProcessId {
        loop {
            // FIXME: fetch the next process from ready queue
            let next_pid={
                let mut queue=self.ready_queue.lock();
                queue.pop_front()
            };

            // FIXME: check if the next process is ready,
            //        continue to fetch if not ready

            // 防止就绪队列任务不足
            let next_pid=next_pid.expect("Ready queue is empty!");
            let next=match self.get_proc(&next_pid) {
                Some(proc) => proc,
                None => continue,
            };
            // 跳过不是ready的进程
            let mut inner = next.write();
            if !inner.is_ready(){
                continue;
            }

            // FIXME: restore next process's context
            inner.restore(context);

            // FIXME: update processor's current pid
            processor::set_pid(next_pid);

            // FIXME: return next process's pid
            return next_pid;
        }
        
    }

进程信息的获取

环境变量

这里要补全proc/mod.rs的环境变量函数,使外部函数可以获取到当前进程的环境变量。

先获取当前进程的读锁,然后查询其env()函数。

pub fn env(key: &str) -> Option<String> {
    x86_64::instructions::interrupts::without_interrupts(|| {
        // FIXME: get current process's environment variable
        let current=get_process_manager().current();
        current.read().env(key)
    })
}

进程返回值

接着,补全utils/mod.rswait函数。这里需要判断进程是否退出以及其返回值。所以我们考虑使用exit_code来进行编写,并且将其包裹在Option<>中进行模式匹配,如果返回None则说明进程尚未退出,如果为Some()则取出其中的值则为该进程的返回值。

于是我们需要先去proc/manager.rs里为ProcessManager实现exit_code的方法。

impl ProcessManager{
  pub fn exit_code(&self,pid:ProcessId) -> Option<isize>{
          self.get_proc(&pid).and_then(|proc| proc.read().exit_code())
      }
}

回来补完utils/mod.rs中的wait函数

fn wait(pid: ProcessId) {
    loop {
        // FIXME: try to get the status of the process
        let exited = x86_64::instructions::interrupts::without_interrupts(|| {
            manager::get_process_manager().exit_code(pid)
        });

        // HINT: it's better to use the exit code

        if !exited.is_some() {
            x86_64::instructions::hlt();
        } else {
            break;
        }
    }
}

内核线程的创建

本次实验被称做“内核线程”,其原因在于将要创建的进程并不需要对页表、权限、代码段等进行特殊的设置,它们的内存空间和内核是共享的,因此可以当作“线程”来看待。

需要注意的是,这和后续的“用户进程”、“线程的创建”等概念是不同的,本次实验所关注的核心是创建一个可以被调度执行的最小单位,在后续的实验中,将逐步对其他功能进行完善。

回到proc/manager.rs,我们来补全spawn_kernel_thread()函数

在这里的修补过程中可能会遇到一些问题,比如我们想要直接获取proc的context,但是他是private的,无法被直接获取,所以我们去proc/process.rsProcess实现了init_stack_frame()方法

impl Process{
  pub fn init_stack_frame(&self,entry:VirtAddr,stack_top:VirtAddr) {
        self.write().context.init_stack_frame(entry,stack_top);
    }
}

随后在调用时,直接使用proc的该方法初始化栈帧。然后取pid,加入进程池,push进就绪队列,最后返回新进程的pid。

pub fn spawn_kernel_thread(
        &self,
        entry: VirtAddr,
        name: String,
        proc_data: Option<ProcessData>,
    ) -> ProcessId {
        let kproc = self.get_proc(&KERNEL_PID).unwrap();
        let page_table = kproc.read().clone_page_table();
        let proc_vm = Some(ProcessVm::new(page_table));
        let proc = Process::new(name, Some(Arc::downgrade(&kproc)), proc_vm, proc_data);

        // alloc stack for the new process base on pid
        let stack_top = proc.alloc_init_stack();

        // FIXME: set the stack frame
        // 规避inner.context为私有字段
        proc.init_stack_frame(entry,stack_top);

        // FIXME: add to process map
        let pid=proc.pid();
        self.add_proc(pid, proc.clone());

        // FIXME: push to ready queue
        self.push_ready(pid);

        // FIXME: return new process pid
        pid
    }

缺页异常的处理

在操作系统进行虚拟内存管理的时候经常会遇到缺页中断,作为可恢复的异常,它发生的可能性有很多:

  • 内存页被标记为懒分配,只有当进程访问到这一页面时才会被分配。
  • 部分可执行的代码段尚未被加载到内存中,需要从磁盘文件进行加载。
  • 内存被交换到了磁盘上,再次使用需要交换回来。
  • 内存页面被标记为只读,在进程尝试写页面的时候触发了 COW(Copy on Write)机制,需要进行页面的复制。
  • 进程访问量权限不允许的内存区域,比如用户态进程尝试访问内核空间的内存。
  • ……

在本实验设计中,并不会完全的实现上述的所有功能,只实现一个功能来作为缺页异常处理的示例:为栈空间进行自动扩容。

之前的内容中,提及了有关内存布局的相关设定,在本实验的 OS 中,每一个进程的栈空间最大为 4GiB。在初始化时,从栈顶(此进程具有的 4 GiB 的最大位置)开始,向下分配了 4 KiB 的栈空间。当进程使用的栈一直增长,直到超过了 4 KiB 的栈空间时,就会触发缺页异常。

在触发缺页异常时,尝试访问的地址会被保存在 Cr2 寄存器中,同时缺页异常的错误码也会随着中断栈一起传递给中断函数。

实验要求我们先去修改上一节中给出的缺页异常的处理函数interrupts/exception.rs,并且增加打印出导致缺页异常的程序。

因为handle_page_fault的参数要求一个VirtAddr类型的值,而文档中直接给出的Cr2::read()的类型为Result<VirtAddr, VirtAddrNotValid>,所以在前面进行模式匹配,将取到的值记为addr

pub extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    err_code: PageFaultErrorCode,
) {
    let addr=match Cr2::read() {
        Ok(addr) => addr,
        Err(_) => {
            panic!("EXCEPTION: PAGE FAULT, but failed to read CR2");
        },
    };
    // FIXME: print info about which process causes page fault?
    if !crate::proc::handle_page_fault(addr, err_code) {
        let current_pid= crate::proc::processor::get_pid();
        let manager = crate::proc::manager::get_process_manager();
        let process_info = manager.get_proc(&current_pid)
            .map(|proc| format!("{}#{}", proc.read().name(), current_pid))
            .unwrap_or_else(|| format!("Process #{}", current_pid));

        warn!(
            "EXCEPTION: PAGE FAULT, ERROR_CODE: {:?}\n\nTrying to access: {:#x}\n{:#?}",
            err_code,
            addr,
            stack_frame
        );
        
        panic!("Cannot handle page fault!");
    }
}

接着跟随IDE顺藤摸瓜,修补其他缺页中断函数

proc/manager.rshandle_page_fault函数去抓页错误,处理几个非预期的情形,然后将进程丢给process.rs中的handle_page_fault处理 -> proc/vm/mod.rshandle_page_fault -> proc/vm/stack.rshandle_page_fault处理

pub fn handle_page_fault(&self, addr: VirtAddr, err_code: PageFaultErrorCode) -> bool {
        // FIXME: handle page fault
        // 不是栈扩容时的缺页,向上抛
        if err_code.contains(PageFaultErrorCode::PROTECTION_VIOLATION){
            return false;
        }
        // 取当前进程
        let current = self.current();
        // 如果进程死了,抛回false
        if current.read().status()==ProgramStatus::Dead{
            return false;
        }
        // 丢给当前进程的栈处理
        let mut inner =current.write();
        inner.handle_page_fault(addr)
    }

vm/mod.rs中修补ProcessVm的初始化进程栈的计算逻辑。

pub fn init_proc_stack(&mut self, pid: ProcessId) -> VirtAddr {
        // FIXME: calculate the stack for pid
        let stack_top = STACK_MAX - u64::from(pid.0) * STACK_MAX_SIZE;
        let stack_bottom = stack_top - STACK_DEF_SIZE;

        let mut mapper = self.page_table.mapper();
        let alloc = &mut *get_frame_alloc_for_sure();
        self.stack.init(stack_bottom,STACK_DEF_SIZE,&mut mapper,alloc);

        VirtAddr::new(stack_top - 8)
    }

vm/stack.rs中接应vm/mod.rs中初始化进程栈调用的初始化栈的参数。

// 修改参数与mod.rs中init一致
    pub fn init(
        &mut self, 
        stack_bottom: u64,
        page_count:u64,
        mapper: MapperRef, 
        alloc: FrameAllocatorRef
    ) {
        debug_assert!(self.usage == 0, "Stack is not empty.");

        self.range = elf::map_range(STACK_INIT_BOT, STACK_DEF_PAGE, mapper, alloc).unwrap();
        self.usage = STACK_DEF_PAGE;
    }

接着补全栈增长的实现。先获取当前地址所在的页面,判断是否需要扩充,用elf::map_range进行扩充大小,并且修改现有的range和usage为新扩充后的。

fn grow_stack(
        &mut self,
        addr: VirtAddr,
        mapper: MapperRef,
        alloc: FrameAllocatorRef,
    ) -> Result<(), MapToError<Size4KiB>> {
        debug_assert!(self.is_on_stack(addr), "Address is not on stack.");

        // FIXME: grow stack for page fault
        let fault_page = Page::<Size4KiB>::containing_address(addr); // 获取当前地址所在的页面
        if fault_page >= self.range.start{
            return Ok(());
        }
        let grow_pages = self.range.start - fault_page;
        elf::map_range(fault_page.start_address().as_u64(),grow_pages,mapper, alloc)?;
        self.range = Page::range(fault_page,self.range.end);
        self.usage+=grow_pages;

        Ok(())
    }

进程的退出

在正常的操作系统中,进程的退出通常是由一个系统调用实现的。

系统调用也会和时钟中断类似地保存进程的上下文,之后调用内核中的函数,完成进程的退出和切换,因此可以很好的确保进程不会再次被调度。

但是目前我们没有实现系统调用,内核线程的退出是它主动调用内核的 process_exit 来实现的,这样的函数调用没有进程上下文的存储和恢复过程,因此需要进行一些额外的处理,并等待下一次的时钟中断来完成进程的切换。

总体来说,实现进程的退出要完成如下几件事:

  1. 在进程退出时,将进程的状态设置为 Dead,并删除进程运行时需要的部分数据,如 ProcessData
  2. 确保进程不会被再次调度,这可以在切换时添加检查、防止进程再次进入就绪队列来实现。
  3. 存储进程的返回值,以便其他进程可以利用它来查询进程的退出状态。

最后,我们来到proc/process.rs里修补最后几个关于进程保存与退出的函数

        /// Save the process's context
    /// mark the process as ready
    pub(super) fn save(&mut self, context: &ProcessContext) {
        // FIXME: save the process's context
        self.context.save(context);
    }

    /// Restore the process's context
    /// mark the process as running
    pub(super) fn restore(&mut self, context: &mut ProcessContext) {
        // FIXME: restore the process's context
        self.context.restore(context);
        // FIXME: restore the process's page table
        if let Some(vm)=&self.proc_vm {
            vm.page_table.load();
        }
        self.resume();
    }

    pub fn kill(&mut self, ret: isize) {
            // FIXME: set exit code
            self.exit_code=Some(ret);

            // FIXME: set status to dead
            self.status=ProgramStatus::Dead;

            // FIXME: take and drop unused resources
            self.proc_vm=None;
            self.proc_data=None;
            self.children.clear();
        }

顺带,我们把proc/manager.rs里的print_process_list的TODO补全

pub fn print_process_list(&self) {
        let mut output = String::from("  PID | PPID | Process Name |  Ticks  | Status\n");

        self.processes
            .read()
            .values()
            .filter(|p| p.read().status() != ProgramStatus::Dead)
            .for_each(|p| output += format!("{}\n", p).as_str());

        // TODO: print memory usage of kernel heap
        let heap=ALLOCATOR.lock();
        let (used_size,used_init) = crate::humanized_size(heap.used() as u64);
        let (free_size,free_init) = crate::humanized_size(heap.free() as u64);

        output += format!("Kernel Heap : used {:>7.*} {}, free {:>7.*} {}\n",
        3,used_size,used_init,
        3,free_size,free_init
        ).as_str();

        output += format!("Queue  : {:?}\n", self.ready_queue.lock()).as_str();

        output += &processor::print_processors();

        print!("{}", output);
    }

最后,别忘了在src/lib.rs里补上

proc::init();

初始化进程管理器

思考题

为什么在初始化进程管理器时需要将它置为正在运行的状态?能否通过将它置为就绪状态并放入就绪队列来实现?这样的实现可能会遇到什么问题?

答:必须在进程管理器初始化时就设置为正在运行的状态,如果将其设置为就绪状态并放入就绪队列,那么就可能发生进程管理器抢占失败导致无法正常运行导致的死锁。

src/proc/process.rs 中,有两次实现 Deref 和一次实现 DerefMut 的代码,它们分别是为了什么?使用这种方式提供了什么便利?

答:两次实现 Deref 分别是为ProcessProcessInner,而一次实现 DerefMut 是为ProcessInner

目的是为了把Process当成(转发为?)其内部的锁来使用,这样就可以直接对&Process使用&RwLock<ProcessInner>上的方法,而不用手动拆self.inner

ProcessInner实现了两种实现了对 ProcessDataDerefDerefMut。意思是把 ProcessInner 继续“展开”成它里面的 proc_data,只要 proc_data 还在,就能像直接操作 ProcessData 一样读写它的内容。

例如将原先self.inner.write().proc_data.as_mut().unwrap()简化为self.write()

结合本实验中的线程调度,比较以下两种策略:

  • 仅在时钟中断时触发调度;
  • 在时钟中断 + 阻塞唤醒点共同触发调度。

讨论它们对响应延迟、公平性和实现复杂度的影响。

答:仅在时钟中断时触发调度实现起来最简单,但是强依赖于硬件时钟的计划任务,可以轻松的维护公平性,但是有着较高的相应延迟;而时钟中断+阻塞唤醒点的响应会更快。一个线程从阻塞变为就绪是可以立即触发一次重新调度,更有灵活性,但是实现更为复杂。

若调度器改为“优先级 + 时间片”混合策略,你认为当前数据结构最先需要改动的部分是什么?

答:应该修改就绪队列的部分,将就绪队列修改为按优先级组织的数据结构,如堆等。

缺页处理若与进程退出并发发生,可能出现哪些一致性问题?你会如何加以约束?

答:可能出现悬空访问/空解包、一方准备扩栈一方变为dead的相反状态、或者重复释放又或是竞态资源等问题。

应当把进程状态切换与资源释放分开,受同一把锁/状态机约束。

本文由 lbyxiaolizi 原创

采用 CC BY-NC-SA 4.0 协议进行许可

转载请注明出处:https://blog.vh.gs/cs/YSOS.html

TAGS: OS rust

相关推荐

  • 暂无相关推荐,看看别的吧。

0 评论

发表评论