YSOS-rust lab1
YSOS-lab1
1. 实验要求
- 请参考 代码与提交规范 进行实验代码编写。
- 依据 实验任务 完成实验。
- 代码编写任务:观察提供的代码,完善所有标记为
FIXME:
的部分,并验证结果是否符合预期。请在报告中介绍实现思路,截图展示关键结果。 - 思考任务:完成 “思考题” 和 “实验任务” 部分的内容,在报告中简要进行回答。注:思考题可能也是理解代码、实现功能的重要提示。
- Bonus 加分项:学有余力的同学可以任选 Bonus 部分完成,尝试完成更多的功能,并在报告中进行展示。这部分内容不是必须的要求。
- 代码编写任务:观察提供的代码,完善所有标记为
- 请在实验报告中涵盖相关任务的实现截图、实验任务对应问题的解答、实验过程中遇到的问题与解决方案等内容。
2. 实验过程
初始化项目
YatSenOS-Tutorial-Volume-2/src/0x00
文件夹复制到当前目录下,名称为0x00
将YatSenOS-Tutorial-Volume-2/src/0x01
文件夹下的所有内容复制到0x00
文件夹,替换其中重复的文件
编译内核ELF
在 pkg/kernel
目录下运行 cargo build --release
,在target
目录下得到release/
、.rustc_info.json
、x86_64-unknown-none/
执行readelf -a target/x86_64-unknown-none/release/ysos_kernel > readelf.txt
,可以查看编译产物的基本信息
实验任务
- 请查看编译产物的架构相关信息,与配置文件中的描述是否一致?
readelf.txt
第9行 Machine: Advanced Micro Devices X86-64
说明架构是X86-64, 和配置文件pkg/kernel/config/x86_64-unknown-none.json
第8行"arch": "x86_64"
一致
- 找出内核的入口点,它是被如何控制的?结合源码、链接、加载的过程,谈谈你的理解。
源码:
- 在
pkg/kernel/src/main.rs
目录下定义了内核的入口 - 它定义了一个 入口点(entry point),即
boot::entry_point!(kernel_main);
。这表示在程序启动时,kernel_main
函数将被调用。 - 查看
pkg/boot/src/lib.rs
中对boot::entry_point!
宏的定义,- 它要求入口函数必须拥有签名
fn(&'static BootInfo) -> !
- 这个宏会创建一个名为
_start
的函数。链接器会将这个函数作为程序的入口点。 - 使用这个宏的优势在于,它确保了函数和参数类型的正确性。
- 它要求入口函数必须拥有签名
链接:
查看pkg/kernel/config/kernel.ld
, 这是一个链接器脚本, 它定义了如何将不同的代码和数据段组合成最终的可执行内核映像
其中第一行为ENTRY(_start)
:这行指令指定了程序的入口点,即从 _start
标签处开始执行
加载:
-
操作系统运行用户程序时将其映射到内存中;
-
当它看到可执行文件中的
PT_INERP
时,操作系统将PT_INTERP
指定的动态链接器映射进内存,并通过栈向其传递它所需要的参数,并跳到动态链接器的入口处开始执行; -
动态连接器的入口是
_start
, 这样源码中_start
指定的kernel_main
函数就能开始执行
- 请找出编译产物的 segments 的数量,并且用表格的形式说明每一个 segments 的权限、是否对齐等信息。
执行readelf -l target/x86_64-unknown-none/release/ysos_kernel
, 可以看到程序有8个segments
- R表示可读
- W表示可写
- E表示可执行
Segment | 权限 | 对齐 |
---|---|---|
00 | R | 0x1000 |
01 | R E | 0x1000 |
02 | RW | 0x1000 |
03 | RW | 0x1000 |
04 | RW | 0x8 |
05 | R | 0x1 |
06 | R | 0x4 |
07 | RW | 0x0 |
在UEFI中加载内核
代码见关键代码部分, [点击跳转](# 3. 关键代码)
完成代码后,输入python ysos.py build -p debug
以编译内核
使用 python ysos.py launch -d
启动 QEMU 并进入调试模式,这时候 QEMU 将会等待 GDB 的连接。
在VScode
中安装好CodeLLDB
,然后启动调试,就可以进入调试环境,如下面的截图
实验任务
1.set_entry
函数做了什么?为什么它是 unsafe 的?
源码如下
1 |
|
首先,代码调用了elf.header.pt2.entry_point()
,可以在/root/.cargo/registry/src/index.crates.io-6f17d22bba15001f/xmas-elf-0.9.1/src/header.rs
查看函数的源码
源码是一个getter
宏: getter!(entry_point, u64);
, 用于获取结构体字段值, 它会被展开为类似以下的形式:
1 |
|
可以看出, 调用elf.header.pt2.entry_point()
会返回一个u64
作为内核的入口地址, 它会被类型转换为usize
类型
在pkg/boot/src/lib.rs
可以看到set_entry()
函数的源码
1 |
|
可以看出, set_entry()
函数需要修改静态可变变量ENTRY
的值, 它可能会被多个线程同时改变,造成未定义的行为, 所以是危险的, 必须要放在unsafe块中才能通过编译.
除此之外, 从代码注释可以看出, 函数不安全的原因是调用者必须保证内核跳转地址是有效的, 否则, 程序可能会跳转到未知的内存区域执行, 是不安全的
2.jump_to_entry
函数做了什么?要传递给内核的参数位于哪里?查询 call
指令的行为和 x86_64 架构的调用约定,借助调试器进行说明
jump_to_entry
函数首先判断变量ENTRY
是否为0, 如果为0, 则说明内核入口ENTRY
未被成功修改
随后执行以下汇编指令:
-
mov rsp, {}
将栈指针rsp
设置为stacktop
的值 -
call {}
调用ENTRY
所指向的函数。这里使用了rdi
寄存器传递bootinfo
的地址
最后,有 unreachable!()
。这是一个永远不会执行的代码,用于标记函数的返回类型为 !
(即“never”类型)。这意味着函数不会正常返回,而是直接终止程序。
如下是调试jump_to_entry
函数可以看到的汇编代码
代码asm!("mov rsp, {}; call {}", in(reg) stacktop, in(reg) ENTRY, in("rdi") bootinfo);
对应的汇编指令位于043BB3D5
, 指令如下:
1 |
|
%rcx
是作为函数参数传入的bootinfo
, 即BootInfo
结构体的地址, 它被赋值给%rdi
%rdx
是作为函数参数传入的stacktop
, 它被赋值给%rsp
,rsp
寄存器指向了当前栈顶的地址,所以这句指令可以实现设置栈顶地址的目的
callq *%rax
会从 %rax
中加载一个**四字(64位)**作为地址,然后调用从该地址开始的函数。
x86_64 架构的调用约定,用call调用函数时,前 6 个整型参数通过寄存器传递,分别存放在 %rdi
、%rsi
、%rdx
、%rcx
、%r8
、%r9
。
BootInfo
结构体的地址就被存在%rdi
中,可以传递给内核
3.entry_point!
宏做了什么?内核为什么需要使用它声明自己的入口点?
entry_point!
宏接受一个参数作为内核入口函数的函数名,它会生成一个新的函数, 名为__impl_start
, 这个函数会验证传递的函数的签名, 确保它接受一个 'static
生命周期的 BootInfo
引用, 并且返回一个 !
类型
然后, __impl_start
函数会调用传递的函数,将 boot_info
参数传递给它(BootInfo
结构体的地址已经被存在%rdi
寄存器中了, 它对应函数的第一个参数)
内核为什么需要使用它声明自己的入口点?
-
链接器会将标记有
_start
的函数作为内核的入口点, 这个宏会让__impl_start
函数带上_start
标记, 以便链接器找到内核入口 -
比起直接创建一个标记了
_start
的函数, 这个宏还能保证内核入口函数接受的参数类型是正确的
4.如何为内核提供直接访问物理内存的能力?你知道几种方式?代码中所采用的是哪一种?可以参考这篇文章进行学习。
几种方式:
-
Identity Mapping: 页表的物理地址也是有效的虚拟地址
-
Map at a Fixed Offset: 虚拟地址=物理地址+固定的偏移量
-
Map the Complete Physical Memory: 映射整个物理内存到虚拟内存,这样内核就能够访问到任意物理内存
-
Temporary Mapping: 对于物理内存非常小的设备,我们只能在需要访问页表帧时临时映射它们。
-
Recursive Page Tables: 将页表中的条目映射到表本身
代码采用了Map the Complete Physical Memory方式,由esp/EFI/BOOT/boot.conf
中的配置可知, 有一项为physical_memory_offset=0xFFFF800000000000
, 设置了整个物理内存到虚拟内存的偏移量. map_physical_memory
函数会根据这一设置映射物理内存
5.为什么 ELF 文件中不描述栈的相关内容?栈是如何被初始化的?它可以被任意放置吗?
因为ELF 文件的目标是让处理器正确执行我们编写的代码,而栈是在程序运行时动态分配的. 栈的大小和位置在运行时由操作系统决定,不是在编译或链接阶段确定的. 因此,ELF 文件不直接描述栈的内容,而是依赖于操作系统在加载时设置栈的相关信息.
所以在加载内核时, 需要手动映射内核栈, 通过下面的代码实现
1 |
|
它不能被任意放置, 而要根据配置文件中设置的栈起始地址kernel_stack_address
, 栈初始分配大小kernel_stack_auto_grow
和栈总大小kernel_stack_size
决定
根据上述调试过程,回答以下问题,并给出你的回答与必要的截图:
请解释指令 layout asm
的功能。倘若想找到当前运行内核所对应的 Rust 源码,应该使用什么 GDB 指令?
layout asm
是 GDB 中的一个强大命令,用于在调试会话期间显示汇编代码窗口, 可以在 GDB 中同时查看源代码和对应的汇编代码。效果如下图
使用list
命令可以查看当前运行内核对应的rust源码,如下图
假如在编译时没有启用 DBG_INFO=true
,调试过程会有什么不同?
重新编译, 执行python ysos.py build
(去掉了-p debug
参数)
然后执行python ysos.py launch -d
, 启动gdb
gdb窗口中只能显示汇编代码, 而无法显示rust源码
默认情况下,编译生成的可执行文件中没有可供 GDB 调试使用的特殊信息。为了将必要的调试信息整合到可执行文件中,我们需要在编译阶段加上DBG_INFO=true
你如何选择了你的调试环境?截图说明你在调试界面(TUI 或 GUI)上可以获取到哪些信息?
我使用了2种调试环境
- gef
可以获得的信息如下图指出
- VScode
可以获得的信息如下图指出
UART和日志输出
IO端口偏移 | DLAB的设置 | 映射到该端口的寄存器 | Register mapped to this port |
---|---|---|---|
+0 | 0 | 数据寄存器。读取该寄存器是从接收缓冲区读取的。写入该寄存器将写入发送缓冲区。 | Data register. Reading this registers read from the Receive buffer. Writing to this register writes to the Transmit buffer. |
+1 | 0 | 中断使能寄存器。 | Interrupt Enable Register. |
+0 | 1 | 当 DLAB 设置为 1 时,这是用于设置波特率的除数值的最低有效字节。 | With DLAB set to 1, this is the least significant byte of the divisor value for setting the baud rate. |
+1 | 1 | 当 DLAB 设置为 1 时,这是除数值的最高有效字节。 | With DLAB set to 1, this is the most significant byte of the divisor value. |
+2 | - | 中断识别和 FIFO 控制寄存器 | Interrupt Identification and FIFO control registers |
+3 | - | 线路控制寄存器。该寄存器的最高有效位是 DLAB。 | Line Control Register. The most significant bit of this register is the DLAB. |
+4 | - | 调制解调器控制寄存器。 | Modem Control Register. |
+5 | - | 线路状态寄存器。 | Line Status Register. |
+6 | - | 调制解调器状态寄存器。 | Modem Status Register. |
+7 | - | 暂存寄存器。 | Scratch Register. |
编写串口驱动,代码见关键代码部分, [点击跳转](# 3.1-串口驱动的实现)
设置日志输出格式,代码见关键代码部分, [点击跳转](# 3.2-日志输出)
思考题
1.在 pkg/kernel
的 Cargo.toml
中,指定了依赖中 boot
包为 default-features = false
,这是为了避免什么问题?请结合 pkg/boot
的 Cargo.toml
谈谈你的理解。
pkg/boot
的 Cargo.toml
中有如下内容
1 |
|
当在 pkg/kernel
的 Cargo.toml
指定依赖中 boot
包为 default-features = false
, 上面的特性会被禁用
根据uefi-services
的官方文档, It includes a panic handler, a logger, and a global allocator.
设置不包含它,可以避免自己定义的panic handler, logger 与之发生冲突
另外,其使用的logger crate会使用到std crate, 与#![no_std]
冲突
2.在 pkg/boot/src/main.rs
中参考相关代码,聊聊 max_phys_addr
是如何计算的,为什么要这么做?
相关代码如下
1 |
|
这段代码是在操作系统启动过程中,用于获取内存映射信息的
-
首先,从
system_table
中获取引导服务(boot_services()
)。 -
然后,调用
memory_map(mmap_storage)
来获取内存映射信息。这个函数返回一个内存映射的结构体,其中包含了系统中不同内存区域的信息。 -
接下来,对内存映射的每个条目进行处理。对于每个条目m,计算了物理地址的上限(
max_phys_addr
)。具体计算如下:-
m.phys_start
表示该内存区域的起始物理地址。 -
m.page_count
表示该区域占用的页数(每页大小为 4KB,所以乘以0x1000
)。 -
将这两者相加,得到了该内存区域的结束物理地址。
-
-
接着,使用
.max()
函数找到了所有内存区域中的最大物理地址。这个值将作为系统的最大物理地址。 -
最后,使用
.max(0x1_0000_0000)
来确保最大物理地址至少是0x1_0000_0000
(即 4GB)。这是因为在某些系统中,IOAPIC(输入输出高级可编程中断控制器)的内存映射区域位于这个地址之上。
3.串口驱动是在进入内核后启用的,那么在进入内核之前,显示的内容是如何输出的?
在进入内核之前, 由引导加载程序,即UEFI负责实现串口驱动
4.在 QEMU 中,我们通过指定 -nographic
参数来禁用图形界面,这样 QEMU 会默认将串口输出重定向到主机的标准输出。
- 假如我们将
Makefile
中取消该选项,QEMU 的输出窗口会发生什么变化?请观察指令make run QEMU_OUTPUT=
的输出,结合截图分析对应现象。
现象:会弹出QEMU窗口, 输出在窗口中,但是进入内核后就停止了输出
- 在移除
-nographic
的情况下,如何依然将串口重定向到主机的标准输入输出?请尝试自行构造命令行参数,并查阅 QEMU 的文档,进行实验。
执行下面的命令
1 |
|
其中关键是-serial stdio
, 将串口重定向到主机的标准输入输出
效果如下
可以在主机的控制台看到内核的输出
加分项
1.😋 线控寄存器的每一比特都有特定的含义,尝试使用 bitflags
宏来定义这些标志位,并在 uart16550
驱动中使用它们。
参考文档https://www.lammertbies.nl/comm/info/serial-uart, 可以定义如下的bitflags宏
1 |
|
在 uart16550
驱动中使用它们,代码如下
1 |
|
2.😋 尝试在进入内核并初始化串口驱动后,使用 escape sequence 来清屏,并编辑 get_ascii_header()
中的字符串常量,输出你的学号信息。
对pkg/kernel/src/drivers/serial.rs
添加如下,用于清屏
1 |
|
对pkg/kernel/src/utils/mod.rs
修改如下
1 |
|
效果如图
清屏是成功的,而且学号信息打印了出来
3.🤔 尝试添加字符串型启动配置变量 log_level
,并修改 logger
的初始化函数,使得内核能够根据启动参数进行日志输出。
UEFI读取数字部分的参考文献:https://stackoverflow.com/questions/73113685/how-do-i-read-user-input-in-uefi-rs-uefi-rust-wrapper
代码详见关键代码部分, [点击跳转](# 加分项3)
UEFI执行过程中会停止, 要求用户输入一个数字表示log等级
如果输入3,代表info,结果如下, info可以正常输出
如果输入1, 代表Error,结果如下, info不能输出
4.🤔尝试使用调试器,在内核初始化之后(ysos::init
调用结束后)下断点,查看、记录并解释如下的信息:
- 内核的栈指针、栈帧指针、指令指针等寄存器的值。
如图,分别输出了栈指针指向的内存地址及所在地址的数据, 栈帧指针指向的内存地址及所在地址的数据, 以及指令指针寄存器的值
- 内核的代码段、数据段、BSS 段等在内存中的位置。
执行readelf -Sl esp/KERNEL.ELF
,结果如下图
5.🤔 “开发者是愿意用安全换取灵活的”,所以,我要把代码加载到栈上去,可当我妄图在栈上执行代码的时候,却得到了 Segment fault
,你能解决这个问题吗?
如下图, 将代码加载到了栈上, 然后执行
正常编译的结果会导致segment fault,但是如果在编译时加上-z execstack
, 允许程序在栈上执行, 就不会导致segment fault, 还能正常输出
分别用两种编译方法, 使用readelf查看Program Headers的权限,如下图
只有第二张图编译的结果可以在栈上执行代码, 可以看出其GNU_STACK
是具有执行权限的
3. 关键代码
2.1-加载相关文件
加载配置文件
pkg/boot/src/main.rs
1 |
|
加载内核 ELF
pkg/boot/src/main.rs
1 |
|
2.2-更新控制寄存器
pkg/boot/src/main.rs
1 |
|
2.3-映射内核文件
pkg/boot/src/main.rs
1 |
|
补全load_segment
函数: 根据段是否可读,可写,可执行的要求设置page_table_flags
pkg/elf/src/lib.rs
1 |
|
3.1-串口驱动的实现
pkg/kernel/src/drivers/uart16550.rs
1 |
|
3.2-日志输出
pkg/kernel/src/utils/logger.rs
1 |
|
加分项3
pkg/boot/src/main.rs
: 这段代码在UEFI启动阶段要求用户输入一个数字1-5, 表示log_level
, 结果储存在u8变量level中
1 |
|
结尾: 将level的地址传递给jump_to_entry
函数
1 |
|
pkg/boot/src/lib.rs
: 修改jump_to_entry函数, 将level的地址作为第二个参数传给内核(放在rsi
寄存器中)
1 |
|
然后修改entry_point宏, 允许内核main函数接收第二个参数
1 |
|
pkg/kernel/src/main.rs
修改kernel_main
函数, 允许接收第二个参数,并传给ysos::init
1 |
|
pkg/kernel/src/lib.rs
1 |
|
pkg/kernel/src/utils/logger.rs
根据数字设置不同的log等级
1 |
|
4. 实验结果
如图,内核成功启动,并且输出了log
5. 总结
学习了如何编写boot引导启动操作系统内核, 并且学会了编写串口驱动, 对rust项目开发更加熟悉