第二讲 实践与实验介绍
实践:裸机程序 -- LibOS
- Ⅰ. 实验目标和思路
- Ⅱ. 实验要求
- Ⅱ½. 总体思路/历史背景
- Ⅲ. 实践步骤
- Ⅳ. 代码结构
- Ⅴ. 内存布局
- Ⅵ. 基于 GDB 验证启动流程
- Ⅶ. 函数调用
- Ⅷ. LibOS 初始化
- Ⅸ. SBI调用
操作系统的演化
操作系统并不是一成不变的,而是随着历史的发展不断演变。随着新技术的引入、硬件的提升、用户需求的改变,操作系统也在相应调整。因此,学习操作系统需要理解其发展变化,并掌握相关知识。
复杂的软件架构
操作系统作为一个庞大的软件系统,涉及到软件架构的设计问题。不同于单一的算法,操作系统需要考虑如何组织结构、协调各个组件,从而保证整个系统的稳定和高效运行。
UNIX/Linux 的实践
通过学习 UNIX/Linux 系统的基本程序和应用程序,可以让学生从用户的角度理解操作系统的功能。这些程序体现了操作系统的核心任务,并帮助学生形成对操作系统的基本概念。
理论联系实践
操作系统的学习应当将理论与实践结合,在实际操作中巩固所学理论。学生应了解如何编写各种不同的操作系统,从而深刻理解其中的原理。虽然各操作系统看似多样,但它们往往是在现有系统上进行渐进式叠加发展而来,并非彼此完全独立。
操作系统与编译器
操作系统与编译器密不可分。操作系统离不开编译生成的程序,而编译器也需要操作系统来支持其运行。因此,理解编译器的工作原理和目标对于使用和开发操作系统至关重要。
操作系统与硬件
操作系统与硬件之间有着紧密的联系。作为系统软件,操作系统需要配合硬件启动和运行。它通过管理硬件资源,为应用程序提供运行环境。
实践写作 OS
在操作系统课程中,学生不仅要学习理论知识,还需通过实践掌握编写 OS 的能力。虽然编写 OS 看起来困难,但通过课程讲授与实践练习,学生能够逐步理解并掌握其中的知识,具备编写基本 OS 的能力。操作系统并非难以理解的庞然大物,而是为满足应用需求而存在的系统软件。
Compiler 和操作系统的共识
编译器和操作系统共享的共识是关于地址空间的理解。当编译器生成目标代码时,它会指定每条指令和数据的地址。而操作系统必须确保这些代码在加载并执行时,被放置到指定的地址空间。只有这样,程序才能正确运行。
编译器和操作系统之间的共识不仅限于这点,还涉及到内存管理和访问权限的设定。这种共识确保操作系统能为程序提供可靠的执行环境,同时能有效管理和保护系统资源。
硬件和软件的共识
硬件和软件之间的共识主要体现在引导启动过程上。硬件通常在只读存储器(ROM)中加载一段初始代码,即所谓的引导程序。这个引导程序会执行基本的硬件初始化,并最终跳转到操作系统的入口地址。
在这个过程中,硬件和引导程序(通常称为 Bootloader)共享起始地址的共识,Bootloader 和操作系统则共享入口地址的共识。这种共识确保硬件知道如何启动 Bootloader,而 Bootloader 知道如何正确加载和启动操作系统。
Bootloader 的作用
Bootloader 是硬件和操作系统之间的桥梁。它在启动时执行一系列任务,包括硬件检测、初始化和基本的系统配置。最终,它将跳转到操作系统的入口地址,完成操作系统的加载。
Bootloader 与硬件共享固定的起始地址,并与操作系统共享已知的入口地址,这种共识确保操作系统能顺利启动并进入正常运行状态。
系统调用和编译器
尽管系统调用(Syscall)是操作系统提供的功能,但编译器在生成代码时通常并不直接处理系统调用。因此,系统调用并不是编译器和操作系统之间的共识。
对计算机体系结构的理解
学习操作系统需要对计算机的整体体系结构有深刻理解,包括编译器、操作系统、CPU 和硬件之间的关系。系统的启动、加载和运行都依赖于它们之间的合作和协调。掌握这些知识有助于学生更全面地理解计算机系统的工作原理。
第四节:实践与实验目标
在第四节中,通过实际实验,更好地理解一个简单操作系统的工作原理和功能。主要内容包括:
- 目标与问题:了解 LibOS系统的目标和它需要解决的问题。
- 设计思路:明确 LibOS的总体设计思路,参考历史背景进行设计和改进。
- 具体操作步骤:掌握 LibOS系统的具体操作步骤和软件设计流程。
Ⅰ实验目标和思路
进化目标
设计一个简化的 LibOS系统,为应用程序提供更方便的执行环境。执行环境的概念首次提出,强调为软件提供资源的多层次软硬件系统。LibOS的进化目标包括:
- 应用与硬件的隔离:确保应用程序可以在硬件抽象层上运行,而无需直接与硬件交互。
- 简化硬件访问:通过 LibOS屏蔽底层硬件的复杂度,简化应用程序访问硬件资源的难度。
- 执行环境的定义:执行环境是为其上层软件提供功能和资源的系统。它包含硬件、操作系统、应用程序和相关组件。
设计思路
LibOS的设计需要综合考虑应用程序的需求和硬件资源的约束,整体上遵循以下思路:
- 多层次设计:从硬件层到应用层进行分层,确保各层模块职责分明。
- 资源管理:操作系统作为中间层,负责管理内存、CPU 和 I/O 设备,为应用程序提供稳定的资源访问接口。
- 隔离与安全:操作系统应该尽量隔离应用程序与硬件的直接交互,防止安全隐患。
实现与反馈
LibOS系统的实现需要具体的步骤和设计细节,这需要深入掌握每个模块的功能和相互作用。通过课堂反馈机制,学生可以及时向教师反映不清晰或困惑的概念,帮助改进课程内容并加强理解。
Ⅱ实验要求
学习目标与实践要求
学习操作系统时的目标和实践要求需要明确,以便学生在学习过程中知道要掌握什么内容:
-
编写和运行裸机程序:学习在无操作系统的环境下直接编写和运行程序,深入理解如何构建操作系统的基础设施。
-
理解裸机程序的函数调用:熟悉裸机程序的函数调用机制,深入理解函数在汇编和机器码层面的实现。了解汇编代码与机器码的对应关系以及伪汇编代码。
-
掌握混合编程:学会将高级语言和低级语言混合编程,以应对编写操作系统时经常遇到的需求。了解如何将汇编和其他高级语言有机结合。
-
初步理解 SBI 调用:理解 SBI(Supervisor Binary Interface)调用,了解其在支持操作系统运行方面的重要性。它是 ABI(Application Binary Interface)的一种扩展,为操作系统提供了更多的功能接口。
进阶知识与深层理解
通过对高级概念的理解,学生需要具备以下深入知识:
-
函数调用与计算机体系结构:深入理解在机器级别的函数调用过程,以及汇编级别的函数组织与执行方式,从更底层的角度理解计算机的结构与工作原理。
-
软件与硬件的层次关系:认识到操作系统并不总是软件的最底层,它有时依赖于 SBI 或 Bootloader 等底层组件来实现与硬件的交互和管理。
-
SBI 与操作系统:SBI 是 ABI 的扩展,为操作系统提供了底层函数接口,使其能够与硬件有效协作。掌握这一概念有助于深入了解计算机系统的组织结构。
-
在机器级层面理解函数:
- 寄存器(registers)
- 函数调用/返回(call/return)
- 函数进入/离开(enter/exit)
- 函数序言/尾声(prologue/epilogue)
学习后的综合认识
在学习这门课后,学生将具备对计算机系统的整体认识,能够以系统级视角来理解硬件与软件之间的关系。通过深入实践和理论学习,掌握从编写到执行操作系统的完整过程。
Ⅱ½总体思路/历史背景
总体设计思路
在明确课程目标与学生的目标后,总体设计思路变得更加清晰:
-
代码编写与运行:与编写一般应用程序类似,需要编写代码通过编译并成功运行。然而,这些代码属于裸机程序(Bare-metal Program),即在没有操作系统的帮助下运行。这就要求在编写过程中必须解决编译器不足的问题,自行实现所需功能。
-
栈的使用:在编写裸机程序时,需要自己编写与栈相关的功能。包括栈空间的大小、位置、申请和释放等细节,都需自行管理和优化。
-
地址空间与初始化:编写程序时需要与编译器和操作系统(或 Bootloader)之间达成地址空间的共识,确定程序在哪个地址执行。此外,在运行前需要考虑程序的初始化流程,确保正确分配资源。
历史背景
第一代操作系统的背景和设计思路为现代操作系统奠定了基础:
-
第一代 OS:最早的操作系统被认为是在欧洲由英国的一家餐饮业公司和剑桥大学联合设计的一台计算机上运行的。这一代的系统没有正式的 OS 名称,但本质上体现了 OS 的基本功能。
-
硬件约束与函数调用:由于当时硬件条件较差,寄存器和指令设计都未完善,函数调用非常困难。于是项目中的研究人员提出了子程序的概念,使得可以在相对低级的系统中实现函数调用。这一理念为操作系统的函数库和子程序奠定了基础。
-
现今的嵌入式 OS:现代的许多简单 RTOS(实时操作系统)或嵌入式操作系统仍然保持着这种设计思路,通过函数库提供基础功能。
现代应用与价值
尽管这些设计属于历史范畴,但现代很多领域仍然延续了这种设计思路:
- 实时与嵌入式系统:实时和嵌入式操作系统仍在使用函数库的理念来提供核心功能,使得它们能在资源受限的硬件上正常运行。
- 持续改进与创新:现代 OS 不断在这些基础概念上改进,并通过抽象层来实现与硬件的解耦,使得开发者能更加专注于上层应用的实现。
Ⅲ实践步骤
-
搭建开发环境:
- 首先需要在自己的计算机上搭建开发环境,以确保可以顺利进行开发和测试。
- 如果硬件条件有限,也可以利用树莓派等小型开发板,但这会影响性能。
-
按照教程操作:
- 建立基本开发环境后,学生需要按照教程中的步骤逐步操作。
- 教程可以选择
aco tutorial book
或其他类似教程,重点是了解如何脱离标准库实现基本操作系统功能。
-
移除标准库依赖:
- 学生需要移除标准库的依赖,直接操作硬件资源。
- 使用 Rust 或 C 语言都可以,但 C 语言的安全性问题需要格外注意。
-
函数与接口的实现:
- 理解和实现与系统相关的函数,确保能够支持基本的输入输出操作和系统功能,如打印字符串和关机。
-
内存空间与布局:
- 学生需要在开发过程中理解运行程序的内存空间和布局。
- 包括代码段、数据段、栈和 BSS 段的位置与作用。
-
系统信息输出:
- 运行
libOS
的示例代码时,系统会打印应用程序的相关信息,如代码段、数据段、栈段的位置。 - 系统还会执行关机操作以模拟完整的操作流程。
- 运行
操作步骤
git clone https://github.com/rcore-os/rCore-Tutorial-v3.git
cd rCore-Tutorial-v3
git checkout ch1
cd os
make run
实践结果与反馈
-
执行效果:运行后,可以看到
libOS
成功启动,并打印出Hello World
等字符串,以及系统的内存布局信息和关机操作。这表明系统在正确运行。 -
学生反馈:老师可以询问学生在实际操作中是否遇到问题,以调整教学节奏,确保每个学生都能跟上进度,掌握相关知识。
执行结果
[RustSBI output]
Hello, world!
.text [0x80200000, 0x80202000)
.rodata [0x80202000, 0x80203000)
.data [0x80203000, 0x80203000)
boot_stack [0x80203000, 0x80213000)
.bss [0x80213000, 0x80213000)
Panicked at src/main.rs:46 Shutdown machine!
除了显示 Hello, world! 之外还有一些额外的信息,最后关机。
Ⅳ代码结构
操作系统设计与实现
代码结构设计
虽然完整的操作系统代码只有 100 多行,但仍比一般算法复杂一些,因此需要合理设计结构。代码主要由两部分组成:
- Bootloader
- Bootloader 负责加载和启动操作系统,通常是由其他程序员编写的基础工具。
- 在本案例中,Bootloader 是由一名本科生编写的
rustsbi
,它主要作用是为操作系统的启动提供基本的硬件初始化。 - 虽然学习者无需深入了解它的实现细节,但需要知道它的存在及其作用。
./os/src
Rust 4 Files 119 Lines
Assembly 1 Files 11 Lines
├── bootloader(内核依赖的运行在 M 特权级的 SBI 实现,本项目中我们使用 RustSBI)
│ ├── rustsbi-k210.bin(可运行在 k210 真实硬件平台上的预编译二进制版本)
│ └── rustsbi-qemu.bin(可运行在 qemu 虚拟机上的预编译二进制版本)
- 操作系统代码
- 操作系统的主体代码使用 Rust 语言编写,但其设计思路与 C 语言类似。
- 主程序位于
main
函数中,该函数是整个操作系统的核心。 - 为支持该应用程序的运行,代码中实现了两个主要功能模块:输出库和启动入口。
├── os(我们的内核实现放在 os 目录下)
│ ├── Cargo.toml(内核实现的一些配置文件)
│ ├── Makefile
│ └── src(所有内核的源代码放在 os/src 目录下)
│ ├── console.rs(将打印字符的 SBI 接口进一步封装实现更加强大的格式化输出)
│ ├── entry.asm(设置内核执行环境的的一段汇编代码)
│ ├── lang_items.rs(提供给 Rust 编译器的一些语义项,目前包含内核 panic 时的处理逻辑)
│ ├── linker-qemu.ld(控制内核内存布局的链接脚本以使内核运行在 qemu 虚拟机上)
│ ├── main.rs(内核主函数)
│ └── sbi.rs(调用底层 SBI 实现提供的 SBI 接口)
输出库与启动入口
-
输出库
- 为应用程序提供字符串输出的功能。
- 包含类似于
printf
的功能,可以将数字转化为字符串,然后通过 SBI(Supervisor Binary Interface)向 Bootloader 发起显示请求。 - 这个请求最终会显示在串口中,供用户查看。
-
启动入口
- 启动入口是系统启动的第一段代码,负责为
main
函数准备执行环境。 - 在
main
函数执行之前,初始化代码需要设置栈、初始化数据段等资源,以确保程序能够正确运行。 - 启动入口的代码通常是用汇编语言编写,以确保能够直接与硬件进行交互。
- 启动入口是系统启动的第一段代码,负责为
架构概览
- 应用程序:
main
函数是整个操作系统的核心,包含应用程序的主要逻辑。 - 输出与服务:输出库通过与底层 SBI 接口通信,为应用程序提供输出字符串的功能。
- 启动部分:启动入口代码初始化执行环境,使
main
函数在执行时能够获得合适的资源配置。
这个架构的设计使得操作系统代码得以保持简洁,同时能够通过良好的模块化提供完善的系统功能。
Ⅴ内存布局
内存布局的定制
应用程序内存布局概览
- 程序内存布局在编译器课和程序设计课中有所涉及,但很多高级语言如 Python 或 Java 不会展示这种细节。
- 使用 C 或 Rust 编写的程序通常由代码段和数据段构成,这些段的起始地址由编译器设定,并与操作系统达成共识,确保在内存中正确加载和执行。
-
堆和栈的动态管理
- 堆(Heap)与栈(Stack)通常在程序执行时动态管理:
- 堆:编译器不会直接处理堆空间的管理,而是由系统库或操作系统负责动态分配与释放。
- 栈:由编译器或操作系统设置栈底,函数调用导致栈顶向下移动,具有动态属性。
- 堆(Heap)与栈(Stack)通常在程序执行时动态管理:
-
BSS 段的特殊性
-
BSS(Block Started by Symbol)段是存放未初始化的全局变量的区域。
-
BSS 段属于静态内存分配,因为编译时已经确定了它的大小和位置。
-
C 语言允许声明未初始化的全局变量,将它们置于 BSS 段;Rust 则要求所有变量初始化以确保安全。
-
.bss : {
*(.bss.stack)
sbss = .;
*(.bss .bss.*)
*(.sbss .sbss.*)
-
段的静态与动态属性
- 静态段:
- 代码段(Text):保存程序的指令,不随程序运行扩展或缩小。
- 数据段(Data):保存已初始化的全局变量。
- 动态段:
- 堆段:在运行时动态分配。
- 栈段:在函数调用时动态调整。
- BSS 段是静态段的一部分,因为它在编译时确定了大小和位置。
- 静态段:
-
操作系统的灵活性与动态调整
- 操作系统的定义和属性具有灵活性,因其是人设计和实现的产物。
- 虽然代码段通常不会改变大小,但恶意程序可能通过修改代码段达到扩展目的。
- 操作系统也可以调整内存段以提供更多功能,但这需要确保安全与稳定。
Data 段和 Text 段的特性
-
Data 段
- Data 段专门存放已初始化的全局变量,属于静态内存分配。
- 在编译阶段,编译器将该段的起始地址设置好。
- 在程序运行时,操作系统会将其加载到内存中。
-
Text 段
- Text 段存放程序的代码部分,是只读的。
- 编译器生成时会标记此区域为不可修改,属于只读区域。
- 在程序加载过程中,操作系统需要将该段从源文件复制到目标内存位置,在设定为只读前进行写入操作。
堆和栈的区别与使用
-
堆(Heap)
- 堆是用于动态分配内存的区域,可以随着程序运行扩展和收缩。
- 使用
malloc
和free
(在 C 中)或Box
(在 Rust 中)来动态管理堆内存。 - 堆适用于存放大小不确定的数据结构或需要长时间存储的数据。
-
栈(Stack)
- 栈主要用于存储局部变量和函数调用帧,遵循先进后出的原则。
- 进入函数时分配栈空间,退出函数时释放对应空间,栈指针简单调整即可。
- 栈的效率很高,但只能存放大小确定的变量。
数据存储与性能
-
栈与性能
- 栈具有很高的性能,因为它的分配和释放操作非常简单,适合处理局部变量和临时数据。
- 但栈只能存储在编译时已知大小的数据结构,无法灵活扩展。
-
堆与灵活性
- 堆提供了更大的灵活性,可以动态分配和释放内存。
- 适用于大小在运行时才确定的数据结构,但其管理机制较为复杂。
OS 编程与应用编程的区别
-
操作系统编程:操作系统编程要求深入理解堆和栈的底层原理,包括寄存器和指令级别的实现。这种编程需要掌握操作系统如何组织和维护堆与栈。
-
应用编程:应用程序编写一般无需了解这些底层细节,因为编译器和操作系统已经为开发者做好了堆与栈的分配与管理。了解底层机制有助于优化代码,但对普通应用程序开发者不是必需的。
静态变量的存储
- 静态变量区域
- 静态变量存储位置取决于其初始化状态。
- 已初始化的静态变量:放置于
data
段中。 - 未初始化的静态变量:放置于
BSS
段中。 data
和BSS
段都属于静态分配的区域,在编译时由编译器决定。
自定义内存布局
-
定制内存布局的必要性
- 编写应用程序时,编译器通常负责预设内存布局,无需开发者干预。
- 但编写操作系统时,编译器的默认布局不足以满足需求,必须自行定制布局。
-
链接脚本(Linker Script)
- 为了为链接器提供指导,需要编写专门的链接脚本(Linker Script)。
- 脚本明确指出 OS 应被放置的地址和各个段的布局。
- 在此示例中,OS 被放置在地址
0x80200000
,链接器确保代码、data
、BSS
等段按照脚本布局。 - 参见,链接脚本 linker.ld
-
Bootloader、QEMU 与地址共识
- Bootloader 需根据指定地址加载 OS,并在执行时跳转到该地址。
- QEMU 模拟器也需知道该地址,以正确模拟 OS 的加载与执行。
生成二进制镜像
-
ELF 格式与二进制镜像
- 操作系统代码最初生成的是 ELF(Executable and Linkable Format)文件。
- Bootloader 在加载时不解析 ELF 格式,因此需要将其转换为纯二进制镜像。
-
工具与转换流程
- 使用
objcopy
工具(或类似工具)可以将 ELF 文件转换为二进制镜像,剔除无关的元数据。 - 二进制镜像仅包含代码和数据段,以供 Bootloader 直接加载。
- 通过文件检查工具
file
可以查看文件属性,验证其是否成功转换为二进制格式。
- 使用
-
文件结构检查
- 使用
file
工具查看 OS 文件格式,确认是否成功转换为纯二进制格式。 - 如果转换正确,则会显示文件包含代码段和数据段信息。
- 使用
rust-objcopy --strip-all \
target/riscv64gc-unknown-none-elf/release/os \
-O binary target/riscv64gc-unknown-none-elf/release/os.bin
参见,Makefile - 变量定义/内核入口地址和Binutils工具/构建规则
Ⅵ基于 GDB 验证启动流程
启动过程验证
-
QEMU 与 GDB 的配合
- 使用 QEMU 模拟计算机运行环境,GDB 用于调试程序。
- QEMU 与 GDB 需要通过特定协议进行通信,以配合调试目标程序。
- 两个特殊参数
-s
和-S
用于 QEMU,指示模拟计算机在启动时暂停,等待 GDB 接管。
-
QEMU 调试启动步骤
- 配置参数:设置 QEMU 使用
-s
和-S
参数,确保计算机从 ROM 执行第一条指令时暂停。 - 启动 GDB:使用
riscv64-unknown-elf-gdb
启动调试工具。 - 加载文件:在 GDB 中,通过
file
命令加载 OS 的 ELF 文件,获取符号信息并关联到 C 或 Rust 代码。 - 连接 QEMU:通过
target remote localhost:1234
与 QEMU 建立连接。 - 调试开始:连接后,GDB 获取 QEMU 当前暂停的地址位置,可查看汇编或源代码。
- 配置参数:设置 QEMU 使用
qemu-system-riscv64 \
-machine virt \
-nographic \
-bios ../bootloader/rustsbi-qemu.bin \
-device loader,file=target/riscv64gc-unknown-none-elf/release/os.bin,addr=0x80200000 \
-s -S
riscv64-unknown-elf-gdb \
-ex 'file target/riscv64gc-unknown-none-elf/release/os' \
-ex 'set arch riscv:rv64' \
-ex 'target remote localhost:1234'
[GDB output]
0x0000000000001000 in ?? ()
Ⅶ函数调用
函数调用的支持
-
概述与重要性
- 函数调用是程序设计的核心,需要在汇编和机器码层次确保调用的正确性和规范性。
- 操作系统在支持函数调用时需考虑参数传递、返回地址和调用规范等细节。
-
指令:CALL 与 RETURN
- 函数调用和返回在汇编中分别由
call
和ret
指令实现。 call
指令保存返回地址,并跳转到目标函数;ret
指令使用堆栈恢复调用者的执行位置。
- 函数调用和返回在汇编中分别由
-
函数调用规范
- 编译器生成的汇编代码需符合系统的 ABI(Application Binary Interface)规范。
- 函数调用规范包括参数的传递方式、返回值位置、寄存器的保存策略等。
-
操作系统初始化与函数调用支持
- 在操作系统启动时,需要初始化栈和其他内存区域,以确保函数调用正常运行。
- 这包括设置堆栈指针、保存返回地址等,使系统和应用程序能够有效使用函数调用。
编译器与汇编代码
-
中间代码生成
- 编译器首先生成中间代码,表示函数调用的逻辑结构。
-
汇编代码生成
- 编译器将中间代码转换为汇编代码,确保
call
和ret
指令正确生成。
- 编译器将中间代码转换为汇编代码,确保
-
机器码生成
- 汇编代码最终转化为机器码,以在目标硬件上执行函数调用逻辑。
-
理解函数调用的构造
- 学习函数调用的构造,理解参数和返回值的存储、栈的操作、寄存器的使用等。、
- 编译原理课 -- 实现函数调用编译支持
- 快速入门RISC-V汇编的文档
保存与恢复函数上下文
- 保存与恢复机制
- 编译器负责在函数调用时保存和恢复上下文(寄存器和参数)。
- 简化的方法是将所有需要保存和恢复的寄存器存储到内存中,并通过内存传递参数。
- 工业级编译器的优化
- 工业级编译器如 Rustc 和 GCC,不仅注重正确性,还需优化性能。
- 这些编译器通常通过寄存器直接传递参数,而不依赖内存。
- 调用与返回指令
call
和ret
只是汇编伪指令,最终转换为底层机器指令。
CALL 与 RETURN 指令的底层实现
- CALL 指令
call
实际上是两条机器指令的组合:AUIPC
和JALR
。- AUIPC:
Add Upper Immediate to PC
,将立即数的上半部分添加到程序计数器(PC)以计算相对偏移量。 - JALR:
Jump and Link Register
,将计算出的返回地址存储在X1
(RA)寄存器中,并跳转到目标函数的地址。
- RETURN 指令
ret
指令映射到机器指令JALR
,即基于 RA 寄存器中的地址跳转回调用点。JALR
执行从寄存器中读取目标地址并跳转。
伪指令 | 基本指令 | 含义 |
---|---|---|
ret | jalr x0, x1, 0 | 函数返回 |
call offset | auipc x6, offset[31:12]; jalr x1, x6, offset[11:0] | 函数调用 |
函数调用的规范
- 保存返回地址
- 函数调用时,返回地址会通过
call
指令存储在 RA 寄存器中。 - 通过
return
指令从 RA 读取地址,确保正确跳转。
- 函数调用时,返回地址会通过
- 多层函数调用的上下文保存
- 在多次嵌套调用中,需将 RA 保存到内存或栈中,以防止后续调用覆盖它。
- 编译器会在调用前将 RA 存储到栈中,并在返回时恢复。
- 调用约定与规范
- 函数调用的规范包括参数传递方式、返回值位置、寄存器保存策略等。
- 虽然这些规范不是强制标准,但开发者遵循约定,可以确保程序的可移植性和一致性。
- 函数调用跳转指令
指令 | 指令功能 |
---|---|
jal rd, imm[20 : 1] | rd ← pc+4; pc < pc+imm |
jalr rd, (imm[11 :0])rs | rd ← pc+4; pc < rs+imm |
插入补充:
jal
和jalr
jal
和jalr
都是 RISC-V 中的跳转指令,用于函数调用和控制流程跳转。它们的主要区别在于目标地址的确定方式。
jal
指令
- 语法:
jal rd, imm[20:1]
- 描述:
rd
寄存器存储当前 PC 加 4 的值,这样调用函数返回时可以跳回。- PC 跳转到
PC + imm
所计算出的目标地址,imm
是一个相对偏移量,允许的偏移范围相对较大(±1 MiB)。- 主要特点:
- 适用于在当前程序计数器(PC)位置基础上跳转的情况。
- 可以用于直接跳转到固定偏移的地址,比如调用子程序。
jalr
指令
- 语法:
jalr rd, (imm[11:0])rs
- 描述:
rd
寄存器存储当前 PC 加 4 的值,为返回时提供跳转目标。- PC 跳转到
rs + imm
所计算的目标地址,rs
是寄存器,imm
是 12 位的立即数(范围 ±2 KiB)。- 主要特点:
- 用于间接跳转的情况,目标地址基于
rs
寄存器和偏移量计算。- 可以用于返回或跳转到非固定的目标地址,比如函数指针。
总结比较
地址计算:
jal
:目标地址直接基于当前 PC 和立即数偏移计算。jalr
:目标地址基于寄存器rs
和立即数偏移计算。使用场景:
jal
:适用于直接调用具有固定偏移的子程序。jalr
:用于返回函数或跳转到动态计算的地址。
插入补充:x86 与 RISC-V 中
ret
指令的区别
x86 的
ret
指令
- x86 的
ret
(Return)指令从栈中弹出返回地址,并将其加载到程序计数器(PC)中。- 栈指针
ESP
(32 位)或RSP
(64 位)管理函数调用栈的顶部,指向下一个返回地址。ret
执行时,还可能接收一个立即数参数,用于从栈中移除函数调用的参数。RISC-V 的
ret
指令
- RISC-V 并没有直接的
ret
指令,而是通过JALR
(Jump and Link Register)来实现返回功能。- 返回地址在 RISC-V 中存储于特定的寄存器
RA
(Return Address),通常是X1
。JALR
指令使用RA
寄存器中的地址来跳转回调用点。序言与尾声(Prologue 与 Epilogue)的差异
x86 序言与尾声
- 序言与尾声是函数调用过程中在函数入口和出口执行的指令集,用于设置与清理函数调用栈。
- x86 使用
PUSH
指令保存寄存器值,MOV
指令调整栈指针以分配局部变量空间。- 尾声部分使用
POP
指令恢复寄存器值,并调整栈指针回到函数入口状态,最后用ret
指令返回。RISC-V 序言与尾声
- RISC-V 通常使用
ADDI
(Add Immediate)指令调整栈指针,为局部变量分配空间。- 参数传递使用寄存器完成,超出寄存器数量的参数会存入栈中。
- 在尾声部分,通过
ADDI
恢复栈指针状态,并使用JALR
指令返回。关键区别
返回地址存储方式
- x86:返回地址存储在栈中,
ret
直接从栈中弹出并返回。- RISC-V:返回地址存储在
RA
寄存器中,JALR
从寄存器中取回地址。
RISC-V 汇编与机器码
- 快速入门
- RISC-V 汇编文档提供了基本指令的详细说明,包括
AUIPC
和JALR
。 快速入门RISC-V汇编的文档 - 理解这些指令有助于编写操作系统代码以及调试和优化程序。
- RISC-V 汇编文档提供了基本指令的详细说明,包括
函数调用约定的共识
-
参数传递与函数结构
- 参数传递和函数结构的设计是函数调用约定的核心部分。
- 函数的上下文包括输入参数、返回值、寄存器和内存状态。
- 编译器从不同层次生成函数的结构,但机器级别的上下文包括寄存器和内存,更底层和详细。
-
调用者与被调用者保存的寄存器
- 在函数调用中,一些寄存器由调用者(caller)保存,另一些由被调用者(callee)保存。
- 这种分工确保符合规范的程序能够正确运行。
-
返回值寄存器
- 返回值存储在寄存器
A0
,具体名称取决于不同平台的约定。- RISC-V32:如果返回值 64bit,则用 a0~a1 来放置。
- RISC-V64:如果返回值 64bit,则用 a0 来放置。
- 返回值存储在寄存器
栈帧的结构
-
栈帧组成
- 栈帧由调用返回地址、栈帧链、保存的寄存器和局部变量组成。
- 每次函数调用都创建一个新的栈帧,以便跟踪函数调用关系。
-
返回地址
Return Address
保存当前函数返回调用者的地址,确保函数调用结束后能正确跳转。
-
栈帧链
- 栈帧链通过
Frame Pointer
建立联系,将各栈帧串联起来。 - 动态链可通过 GDB(或其他调试工具)展示完整的函数调用关系。
- 栈帧链通过
-
保存的寄存器
- 调用者或被调用者需要保存的寄存器值,确保在函数调用期间或返回后数据不被破坏。
-
局部变量
- 栈帧的顶部通常存储函数的局部变量。
工业级编译器的作用
-
编译器构造栈帧
- 工业级编译器如 GCC 或 Rustc 会自动生成栈帧结构。
- 这使得开发者可以将主要精力放在业务逻辑上,而不必关心栈帧的细节。
-
理解栈帧结构
- 需要了解栈帧的详细结构,因为在操作系统级别进行优化或调整时,必须正确管理栈帧。
栈帧的结构与回收
-
栈帧的头和尾
- 栈帧由栈指针(SP)和帧指针(FP)标识其顶部和底部。
- 栈指针通常指向栈帧的顶部,而帧指针指向底部。
-
回收栈帧
- 当
return
(或对应的汇编指令)执行时,栈帧会被回收。 - 通过
SP = FP + FRAME_SIZE
调整栈帧,回到之前的栈帧状态。 - 返回地址(RA)存储在特定寄存器中,通过将其赋值给程序计数器(PC)跳转回调用者。
pc = return address sp = sp + ENTRY_SIZE fp = previous fp
- 当
Stack
.
.
+-> .
| +-----------------+ |
| | return address | |
| | previous fp ------+
| | saved registers |
| | local variables |
| | ... | <-+
| +-----------------+ |
| | return address | |
+------ previous fp | |
| saved registers | |
| local variables | |
+-> | ... | |
| +-----------------+ |
| | return address | |
| | previous fp ------+
| | saved registers |
| | local variables |
| | ... | <-+
| +-----------------+ |
| | return address | |
+------ previous fp | |
| saved registers | |
| local variables | |
$fp --> | ... | |
+-----------------+ |
| return address | |
| previous fp ------+
| saved registers |
$sp --> | local variables |
+-----------------+
在程序执行过程中,栈用于存储局部变量、函数返回地址以及其他调用上下文。以下是对此栈帧结构的解释:
- 栈的方向
- 栈从高地址向低地址增长。
- 这是函数调用过程中栈指针(
$sp
)的移动方向。- 栈帧(Stack Frame)
- 每个栈帧代表一个函数调用,包括其局部变量、返回地址和保存的寄存器等信息。
- 栈帧在函数调用期间分配,调用结束时释放。
- 返回地址(Return Address)
- 每个栈帧的顶部保存了返回地址,当函数结束后需要跳回调用者的执行位置。
- 该地址用于
ret
或类似指令返回到调用函数的下一条指令。- 先前的帧指针(Previous Frame Pointer)
- 指向调用者的栈帧,以便函数能够找到前一个栈帧的结构。
- 有助于调试或在堆栈中遍历整个调用链。
- 保存的寄存器(Saved Registers)
- 一些寄存器需要在调用期间保存,防止数据被覆盖。
- 保存的寄存器包括调用者或被调用者负责的寄存器。
- 局部变量(Local Variables)
- 局部变量在栈帧的底部分配,分配空间大小根据函数的需要而变化。
- 栈指针与帧指针(Stack Pointer 和 Frame Pointer)
$sp
:栈指针,指向当前栈帧的底部位置。函数返回时,栈指针恢复到之前的值。$fp
:帧指针,通常指向当前栈帧的顶部或一个固定位置。某些架构使用$fp
来指向调用者的帧。
更多内容参见,MIT6.004notebook RISC-V栈
序言与尾声
-
序言与尾声
- 函数的序言(Prologue)与尾声(Epilogue)由编译器生成,确保函数调用的上下文保存与恢复。
- 序言部分负责保存返回地址与设置局部变量空间。
- 尾声部分恢复返回地址并调整栈帧,为返回调用者做好准备。
-
函数调用规范的执行
- 序言和尾声确保函数调用规范被正确执行,使函数调用者和被调用者保持一致。
-
函数内的嵌套调用
- 当一个函数在自身内部嵌套调用其他函数时,需确保返回地址不被覆盖。
- 返回地址(RA)应该存储在固定位置,供嵌套调用使用。
.global sum_then_double
sum_then_double:
addi sp, sp, -16 # prologue
sd ra, 0(sp)
call sum_to # body part
li t0, 2
mul a0, a0, t0
ld ra, 0(sp) # epilogue
addi sp, sp, 16
ret
.global sum_then_double
sum_then_double:
call sum_to # body part
li t0, 2
mul a0, a0, t0
ret
Q: 此代码的执行与前面的代码执行相比有何不同?
RISC-V 中没有序言和尾声导致死循环的原因
-
函数调用的返回地址问题
- 在 RISC-V 中,
call
指令(由JALR
实现)将当前 PC 存储在RA
寄存器(X1
)中,并跳转到目标函数的地址。 - 如果函数调用缺乏序言和尾声,返回地址将不会被保存到栈中。因此,在嵌套或递归调用时,
RA
寄存器的值会被新调用的函数覆盖。 - 当没有尾声恢复
RA
寄存器的值时,返回指令ret
将会尝试跳转到错误的地址(上一个调用的函数操作赋予RA
的地址),导致死循环或程序崩溃。
- 在 RISC-V 中,
-
栈帧的作用
- 序言和尾声负责在栈中设置和恢复栈帧,包括保存与恢复
RA
寄存器的值。 - 缺乏序言和尾声会导致无法正确保存返回地址,并且新调用的函数会覆盖
RA
中的值。
- 序言和尾声负责在栈中设置和恢复栈帧,包括保存与恢复
x86 中类似情况的对比
x86 中的
call
与ret
- 在 x86 架构中,
call
指令会将返回地址直接推入栈中,并跳转到目标函数。ret
指令则从栈中弹出返回地址,并将其赋值给 PC,从而实现返回。序言与尾声在 x86 中的作用
- x86 序言与尾声负责设置与恢复栈帧,包括保存与恢复调用者的返回地址。
- 如果没有序言和尾声,返回地址将无法正确保存或恢复,这会导致
ret
从错误位置读取返回地址,进而跳转到不正确的内存地址。相同的风险
- RISC-V 和 x86 都需要通过序言和尾声来正确保存返回地址。
- 如果在这两个架构中都缺少这两个部分的保护机制,都会导致程序陷入死循环或崩溃,因为返回地址无法准确恢复。
x86 与 RISC-V 的区别
x86 的函数调用机制与问题
函数调用与返回机制
- 在 x86 架构中,
call
指令会将当前程序计数器(PC)的值(即下一条指令的地址)推入栈中。- 函数返回时,
ret
指令从栈中弹出先前保存的返回地址,并跳转到这个地址,继续执行调用者的代码。错误的读取和死循环问题
- 如果缺少序言与尾声,将不会正确保存或恢复栈帧,这会导致返回地址无法正确处理。
ret
指令会从错误位置的栈中读取错误的地址。- 如果这个地址指向调用函数自身或已经跳转的指令,将导致程序反复在错误的指令位置循环,形成死循环。
- 另外,错误的返回地址可能指向无效或非法的内存区域,导致程序崩溃或触发安全漏洞。
RISC-V 的函数调用机制与问题
函数调用与返回机制
- 在 RISC-V 中,
call
指令由JALR
指令实现,将当前 PC 存储在RA
(X1
)寄存器中。- 返回时,
ret
也由JALR
指令实现,通过读取RA
寄存器中的地址进行跳转。错误的读取和死循环问题
- 缺少序言与尾声导致
RA
寄存器的值没有正确保存,并可能被其他函数调用覆盖。- 返回指令使用
JALR
读取RA
的地址,并跳转到这个错误的地址。- 如果该地址是先前的函数调用点,程序将陷入无尽的循环。如果是无效地址,程序会崩溃。
主要差异
返回地址的存储位置
- x86:返回地址始终存储在栈中。
- RISC-V:返回地址存储在
RA
寄存器中,只有在必要时才存储到栈中。错误的地址来源
- x86:错误的返回地址可能来自栈指针的错误位置。
- RISC-V:错误的返回地址来自
RA
寄存器中被覆盖的地址。
ⅧLibOS 初始化
-
目标与重要性
- LibOS 初始化的目标是确保操作系统的基础组件能够顺利运行,包括设置栈空间、执行环境以及初始化
print
功能。 - 初始化过程对于确保应用程序和操作系统稳定执行至关重要。
- LibOS 初始化的目标是确保操作系统的基础组件能够顺利运行,包括设置栈空间、执行环境以及初始化
-
汇编与 Entry 点
- 初始化的起点是汇编代码的入口点(Entry),它是链接脚本中定义的符号,通常是
.text
段中的entry
函数。 - 该入口点地址由链接脚本确定,例如
0x80200000
。
- 初始化的起点是汇编代码的入口点(Entry),它是链接脚本中定义的符号,通常是
-
设置栈指针
- 在
entry
函数执行时,首先需要设置栈指针SP
,指向操作系统为其准备的栈空间。 - 栈空间大小需预先分配,例如 64KB,超出栈空间则可能导致覆盖代码或其他栈区域,引发严重错误。
- 在
-
栈帧与安全
- 使用
SP
指针设置栈空间,通常从高地址向低地址增长。 - 如果调用嵌套层数过多或递归次数过多,可能导致栈帧溢出,覆盖其他代码区域,进而产生难以调试的内存错误。
- 使用
-
内存错误与并发错误
- 内存相关的错误(如栈溢出、堆内存错误)与并发错误是操作系统编程中难以调试的两类问题。
- 应仔细规划栈与堆空间,并确保并发环境中的锁定与同步。
-
全局变量与链接
- 链接脚本负责在编译时将全局变量的地址分配到适当的内存区域。
- 初始化过程中需将这些全局变量的起始地址与运行时的物理地址对齐,以确保正确加载并执行。
通过合理的 LibOS 初始化流程,能够确保栈、全局变量等资源正常分配,构建稳定的执行环境。
- 分配并使用启动栈 快速入门RISC-V汇编的文档
# os/src/entry.asm
.section .text.entry
.globl _start
_start:
la sp, boot_stack_top
call rust_main
.section .bss.stack
.globl boot_stack
boot_stack:
.space 4096 * 16
.globl boot_stack_top
boot_stack_top:
# os/src/linker-qemu.ld
.bss : {
*(.bss.stack)
sbss = .;
*(.bss .bss.*)
*(.sbss .sbss.*)
}
ebss = .;
在链接脚本 linker.ld 中 .bss.stack 段最终会被汇集到 .bss 段中 .bss 段一般放置需要被初始化为零的数据
参见,entry.asm
LibOS 初始化的后续步骤
-
栈设置完成后
-
在设置好栈指针并为栈空间分配内存后,下一步就是执行
call rust_main
,这意味着从汇编代码跳转到 Rust 或 C 的主函数。-
将控制权转交给 Rust 代码,该入口点在 main.rs 中的
rust_main
函数。#![allow(unused)] fn main() { // os/src/main.rs pub fn rust_main() -> ! { loop {} } }
-
-
这是混合编程的一个实例,其中汇编负责初始环境的配置,而高级语言则承担应用程序逻辑。
-
-
调用约定的简化
- 初始阶段的调用者(caller)函数并不完全遵循标准的函数调用约定,因为无需保存调用者的寄存器状态。
- 初始化程序直接跳转到
rust_main
,并在该函数中执行操作系统的主循环。
-
函数注释与解释
- 在
rust_main
中进行死循环是为了保持操作系统的运行状态,防止程序意外退出。 - 函数注释可以帮助理解代码的意图和执行逻辑。
- 在
-
未定义变量的清理
- 对于未初始化的全局变量,需要确保它们的值为零。
- 在应用程序中,编译器通常负责初始化这些变量,但在操作系统编写中,需要手动清理 BSS 段。
-
清理 BSS 段
clear_bss
函数负责将 BSS 段的内存区域清零。- 使用链接脚本中定义的
bss_start
和bss_end
确定内存区域的起点和终点,然后在该范围内填充零值。
-
全局变量的地址
- BSS 段的起始和结束位置通过链接脚本中的全局变量提供,编译器在链接时将这些变量的地址传递给程序。
- 通过这些全局变量,能够确保 BSS 段的初始化和全局变量的正确使用。
-
总结初始化过程
- 初始化完成后,LibOS 已具备执行环境,能够支持函数调用和全局变量的正常使用。
print
等应用程序功能在此基础上正常工作,确保操作系统与应用程序能够顺利交互。
参见main.rs
ⅨSBI调用
SBI 服务与 OS 关系
-
SBI 的定位
- 在 RISC-V 中,操作系统不一定是最底层的软件组件,SBI(Supervisor Binary Interface)通常位于操作系统之下。
- SBI 作为一个中间层,为操作系统提供底层服务,如虚拟化和 Bootloader。
-
设计思路与目的
- SBI 的设计目的是提供底层服务,使操作系统无需直接管理硬件。
- 通过 SBI 提供的功能,操作系统可以减少硬件驱动的复杂性,直接调用已实现的接口。
-
调用机制
- SBI 服务接口类似于系统调用(syscall),但是为操作系统层提供服务。
- 调用由操作系统通过编号发起,编号对应具体的服务功能。
-
输出字符服务
- SBI 中提供了
putchar
服务,用于输出字符。 - 传递字符参数和编号,供底层服务函数解析与执行,返回值表明成功或失败。
- SBI 中提供了
-
SBI 内嵌汇编
- SBI 调用需要内嵌汇编来实现。
- 使用高级语言直接嵌入汇编代码,在 Rust 或 C 代码中执行汇编指令。
- 汇编指令通过寄存器传递参数,模拟
call
指令的参数传递逻辑。
-
封装与抽象
- 为了简化上层调用,SBI 调用通常被封装成更易用的函数,例如
console_putchar
。 - 封装后的函数隐藏底层汇编细节,方便操作系统开发者使用。
- 为了简化上层调用,SBI 调用通常被封装成更易用的函数,例如
-
高级宏与
println!
- Rust 提供了高级宏,如
println!
,可以进一步简化输出字符的逻辑。 - 宏用于生成一系列相关代码,将输入参数展开成完整的汇编调用,并确保语法和语义的一致性。
- Rust 提供了高级宏,如
重要的 SBI 服务
-
Print 服务
- 提供输出字符的
putchar
服务,让操作系统能够将字符信息打印到控制台或日志。
- 提供输出字符的
-
Shutdown 服务
shutdown
服务用于关闭虚拟机,模拟机器关机操作。- 在 QEMU 中,
shutdown
会使模拟器优雅退出。 - 此服务用于:
- 程序正常执行完毕时关机。
- 当程序遇到致命错误时,以
panic
的方式触发shutdown
服务。
-
Panic 机制
- Rust 提供
panic
机制,在程序出错时打印错误信息并调用shutdown
服务优雅退出。 panic
会触发专门的panic handler
,输出详细的错误日志,供开发者调试和排查。
- Rust 提供
第二讲总结
-
掌握 OS 的知识点
- 本课程提供了对操作系统设计与实践的简要概述,涵盖了理论和实践中需要掌握的核心知识点。
- 理论包括系统调用、内存管理等方面,实践则侧重设计与实现 OS 结构。
-
计算机系统的全面理解
- 通过学习 OS,不仅要掌握操作系统本身,还需要理解与编译器、CPU、内存和 I/O 的交互。
- 综合理解计算机系统的结构和功能是更好掌握操作系统的基础。
-
从启动到应用程序的执行流程
- 学习并理解从机器启动到应用程序打印字符串的完整流程。
- 理解汇编入口、栈设置、SBI 调用等机制,深入掌握计算机的运行原理。
-
开发三层 OS 的能力
- 课程结束时,学生应具备开发简单三层 OS 的能力。
- 理解裸机程序的编写和执行,使学生能够应对更复杂的 OS 开发和应用。