关于课程计划
由于临近期末(一月初考试),同时我们(助教)最近在编写操作系统(可以看到我又换回了 Arch Linux 进行工作)上占用大量时间,所以我们决定尽快结束课程。预计下一周是最后一周课程。计划下周讲解协程及 Async/Await 等内容。原计划第 13 周讲解的 Dynamic Dispatch 被取消。因为大家对 OOP 的理解不够深入,而且 Rust 的 OOP 与传统 OOP 有很大不同,所以我们决定取消这一部分内容。
同时,Dynamic Dispatch 也不是原计划的内容,原计划的内容仍然会在下周讲解完毕。
关于作业
从本周开始,不再布置作业。仅有最后一份期末作业,涉及到本学期所有的内容以及编写操作系统所需要的所有基本知识。Rustlings 和期末作业的参考答案我已经完成,下周一并发布。期末作业大家自行完成即可,不需要提交。有疑问还是请及时联系助教。
https://github.com/Loongson-neuq/2024-neuq-os-2024-neuq-final-assignment-1
目前这份作业中涉及的内容只有以下部分还没有讲解:
- 系统调用
- 锁/原子操作
- 协程
- Async/Await
前三个部分会在这节课讲解完毕,协程和 Async/Await 会在下周讲解。
后面的路怎么走?
有很多同学很迷茫,总是问我学完了能会什么,或者学完了就能编写操作系统了吗?
我可以负责任地告诉大家,大部分同学仍然离操作系统编写很远。但是知识是日积月累而不是一蹴而就的。你要清楚一个事实,操作系统是一个非常庞大的系统,涉及到的知识非常多,而且很多知识都是非常深入的。你花的几个月的间断的学习,凭什么能超过别人数年甚至数十年的积累?更别说别人经验比你丰富,基础比你扎实,会的知识面比你广泛。
再问自己一些问题:
- 你有丰富的软件工程经验吗? 你能写出高质量的代码吗?高质量意味者代码可读性好,可维护性好,性能好,安全性好。你能写出这样的代码吗?
- 你有大型项目的经验吗?你最多的一个项目写过几万行代码?你有没有一个人参与过一个项目的设计,开发,测试,部署,维护的全过程?
- 你有多少编程语言的经验?你有多少编程范式的经验?你有多少编程工具的经验?
这些只是编程的基础,编写操作系统,要求你的编程基础非常扎实,更需要的是对操作系统原理的深入理解,对硬件的深入理解,对软硬件协同的深入理解。操作系统是软件与硬件高度耦合的产物。你既要对软件有深入理解,又要对硬件有深入理解。你要知道你的代码是如何在硬件上运行的,你的代码是如何与硬件交互的。
对软件的深入理解意味着你要对编程语言有深入理解,你看着高级语言的代码,就能知道它是如何在底层运行的。会执行哪些指令,会访问哪些内存,会调用哪些系统调用。
对硬件的深入理解意味着你要对计算机组成原理有深入理解,你要知道计算机是如何工作的,CPU 是如何工作的,内存是如何工作的,外设是如何工作的。对软硬件协同的深入理解意味着你要知道操作系统是如何管理硬件的,是如何与硬件交互的,是如何保证软硬件的正确协同工作的。
那是不是就搞不出来了呢?当然不是。你需要慢慢积累,慢慢学习。并且我同样可以负责任地告诉你,学习操作系统是提升底层编程能力的最快途径。只是需要一定知识基础的。
**关于 rcore 实验,如果你已经明显感觉到自己难以理解某些操作系统的概念,我的建议是立即停止。以提升软件工程能力为主,等你有了一定的软件工程能力,再回过头来学习操作系统,会事半功倍。**否则目前的学习只会让你感到困惑,所谓的学习也只是抄代码,能学到的东西非常有限。
最早的计划是要包含对软件工程的训练的,要求大家独立完成一个大型项目,同时包含设计模式的训练和课程,考虑到大家还是比较急于学习操作系统,所以这部分内容被取消了,只保留了编写操作系统需要的主线知识。
上次作业
上次的作业的完成的情况我就不多说了,大家自己清楚,也没有人来问问题。我只能认为大家可能还有些困难,那这节课我来带着大家完成,顺便讲解一下如何手动使用系统调用。
简答题
关于简答题,大部分都是上次课件里的内容,直接参考课件即可。这里我只讲解一下一些补充内容。
手动使用 brk 系统调用
题目:“尝试使用 brk 或 sbrk 分配 1024 字节,并尝试访问第 1024 ~ 4095 字节的内存,说明为什么可以访问这些内存?”
这里以 brk 系统调用为例,讲解一下系统调用是什么,如何使用系统调用。
系统调用
系统调用是操作系统提供给用户程序的接口,用户程序通过系统调用可以请求操作系统提供服务。系统调用是用户态程序与内核态程序之间的桥梁。用户态程序通过系统调用请求内核态程序执行某些操作,比如读写文件,分配内存等。
看着比较抽象?实际上,就跟你调用外部库的一个函数一样。只不过这个函数是操作系统提供的,你需要通过一些特殊的方式调用。
下面我们从另一个角度,来看看系统调用到底是怎么特殊的。
系统调用是操作系统的一种异常。而异常和中断在操作系统中都属于Trap。Trap 是一种异步事件,它会打断 CPU 的正常执行流程,转而执行操作系统内核中的一段代码。这段代码就是系统调用的实现。
本质上来说,当Trap发生的一瞬间,会发生一下改变:
- CPU特权级被提升,从用户态提升到内核态 x86 中是从 ring3 提升到 ring0。RISC-V 中是从 U 模式提升到 S 模式。
- PC 指针被修改,指向内核态的代码。PC 指针是 CPU 核心当前/或下一条执行指令的地址,操作它就是操作 CPU 的执行流程。
这两者是同时且瞬间发生的,从发生异常的指令到内核态代码执行,这个过程是没有空隙的,是瞬间完成的。
当Trap发生后,也就是 PC 来到了操作系统的代码,操作系统会首先保存用户态的寄存器状态,然后根据 Trap 的类型,执行相应的操作。
如果是系统调用,操作系统会根据用户传递的参数,执行相应的操作,然后将结果返回给用户程序。这个过程是一个系统调用的过程。例如 brk 就是给用户程序分配内存的系统调用,exit 是退出程序的系统调用。
系统调用由特殊汇编指令触发,在 x86 中是 syscall 指令,在 RISC-V 中是 ecall 指令。在进入操作系统 Trap 后,操作系统可以读取 CPU 特权寄存器,判断 Trap 到底是 Exception 还是 Interrupt。Exception 是 Syscall,还是其他异常。根据这些信息,操作系统可以执行相应的操作。
如果是其他不可恢复的异常,比如除零异常,操作系统会直接终止用户程序的执行(由于信号机制的出现,操作系统现在通常不会直接杀死进程),然后将控制权交给其他程序。操作系统也会利用异常来实现一些功能,比如页错误异常,就是操作系统用来实现虚拟内存和COW的一种方式。
而中断是一种异步事件,分为硬中断和软中断。外部中断是由硬件设备发出的,比如键盘中断(仅针对PS接口,USB接口是轮询机制),网卡中断等,目的是让 CPU 处理硬件设备的事件。时钟中断是由CPU时钟发出的,目的是让 操作系统从用户程序中夺回控制权,进行调度。如果没有时钟中断,CPU 就会一直执行用户程序,操作系统就无法进行调度。假如我在这里写一个死循环,那么其他进程就无法被执行,因为同一时刻一个CPU的PC只能指向一个地址。同时操作系统的代码也永远无法执行,也就意味着操作系统无法杀死这个进程。
因此,异常是操作系统与用户程序交互的方式,中断是操作系统夺回 CPU 控制权的方式。
下面来看看我们如何触发一个系统调用。
在 x86 中,我们可以使用 syscall 指令来触发系统调用。使用系统调用,我们需要做以下几件事:
- 在指定寄存器中存放系统调用号,这个号码是操作系统用来查找系统调用的实现。因为所有系统调用都是通过syscall指令触发的,所以操作系统需要根据这个号码来查找对应的系统调用实现。
- 在指定寄存器中存放系统调用的参数,这些参数是用户程序传递给操作系统的,操作系统根据这些参数来执行相应的操作。
参数必须是寄存器宽度的,因为我们只能通过寄存器传递参数。在 x86 中,系统调用的参数是通过 rax, rdi, rsi, rdx, r10, r8, r9 这几个寄存器传递的。rax 寄存器存放系统调用号,rdi, rsi, rdx, r10, r8, r9 分别存放系统调用的参数。在 RISC-V 中,系统调用的参数是通过 a0, a1, a2, a3, a4, a5 这几个寄存器传递的。 a7 寄存器存放系统调用号。上面的期末作业中给出了一些系统调用的号码,这些号码是操作系统用来查找系统调用实现的。
这里给出一个 x86_64 的 syscall 表,包含系统调用的寄存器使用约定: https://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64/
可以看到,系统调用的返回值是存放在 rax 寄存器中的。系统调用 id 也被放在 rax 寄存器中。然后我们还需要在 rdi 中放置第一个参数。
|
|
你可以在这里找到这个代码的完整版本。
使用
|
|
来编译运行
你可以看到,刚好在第 4096 行的时候,程序会被操作系统杀死。这是因为我们访问了超出分配的内存,操作系统检测到了这个错误,就会杀死程序。
但是我们明明只要了 1024 个字节,为什么我们可以访问 4096 字节呢?这是因为操作系统会按照页的大小来分配内存。在 x86_64 上,页的大小是 4096 字节。所以我们分配 1024 字节的时候,实际上操作系统会分配 4096 字节的内存。这个内存是连续的,所以我们可以访问 1024 ~ 4095 字节的内存。按页分配内存是为了提高内存的使用效率,因为内存是按页来管理的,所以我们只能按页来分配内存。分页机制同时也是虚拟内存和内存权限控制的基础。
上面你可以看到我使用汇编调用exit
, write
系统调用的实现,这是因为我们在 no_std
环境下,没有标准库,所以我们需要自己实现这些功能。这里我使用了汇编来调用系统调用。
探索调用栈 - 手动栈展开
接下来看下一道题:https://github.com/Loongson-neuq/mem-management-01/tree/main/explore-call-stack
题目要求我们通过修改栈,来实现修改控制流。
让我们先来看看这个题的简化版本,也是一个经典的返回地址攻击:
|
|
这需要如何实现呢?我们首先需要知道函数是什么。
函数也叫子过程,是一段代码的集合,从汇编或者说内存的角度说,就是一片内存区域,这片内存区域储存了指令。函数实际上就只是这片内存区域的地址,或者说这些指令集合的第一条指令的地址。而其他的参数,返回值都是编译器设计的约定,实际上就是一些寄存器或者内存的操作。如果我们自己编写汇编代码,则需要自己实现以及遵守这些约定。
函数的特性包括以下几点:
- 传递参数给子过程
- 转移控制权给子过程
- 子过程返回返回值
- 子过程返回控制权给父过程
我们今天不关注 1 和 3,有兴趣的同学请自己搜索 Calling Conventions
。
2 和 4 都涉及控制权的转移,通过上面的内容,你应该知道,实际上就是修改 PC 指针。
对于 2,通常由一条 “call” 指令实现,call 指令是一条比较复杂的伪指令,实际上是多条指令的组合。
让我先介绍一个简单跳转指令 jmp
。它的作用就只是修改PC到目标地址,没有任何其他操作。
接下来再说说 call
指令。call
指令的作用是将当前的指令的下一条指令的地址压入栈,然后跳转到目标地址。
也就是
|
|
这样就实现了函数调用。
那返回时呢?返回时我们需要从栈中弹出地址,然后跳转到这个地址。
|
|
这就是 ret
指令。ADDRESS_OF_NEXT_INSTRUCTION 被称为 Return Address。某些架构例如 RISCV 使用专用寄存器保存 Return Address,但是对于嵌套函数,仍然需要将 Return Address 压入栈中。
在 ret
前,需要清空当前栈帧,这样pop弹出的才是正确的地址。而不是局部变量。
这就是函数调用的原理。
接下来再让我们从高处看看栈的结构,以上面的代码为例:
|
|
这是 main 的栈帧,在进入 main 时,就确定了大小和位置。在调用 foo 前,栈的结构是这样的。
接下来我们执行 call foo
指令的第一件事,压入 Return Address,栈的结构变成这样:
|
|
接下来我们执行 jmp foo
指令,控制权转移到 foo 函数。但是还没有执行 foo 函数的代码,所以 foo 的栈帧还没有建立,栈的结构保持不变。
我们知道,函数具有 prelude 代码,包含以下两条指令:
|
|
这是为了建立当前函数的栈帧。其中,push 将 rsp 指令 +8,然后将 rbp 的值压入栈中。然后将 rsp 的值赋给 rbp。这样就建立了当前函数的栈帧。现在栈的结构变成这样:
|
|
你可以看到,foo 的栈帧建立完成。现在我们可以执行 foo 的代码了。
现在回来考虑返回地址攻击,其实非常简单,只要修改 Return Address 就可以了。我们可以通过修改 Return Address 来控制程序的控制流。那如何知道 Return Address 的地址呢?事实上,对于本函数的 Return Address,我们是很容易知道的,它就位于 SAVED RBP 的正上方。而 SAVED RBP 的地址是 rbp 的值。
也就是说,RETURN ADDRESS 的地址就是 rbp + 8。
我们使用((size_t*)(rbp + 8)) = bar
,就能够修改 Return Address 为 bar 的地址。
这下,在 foo 返回时,pop 出来的地址就是 bar 的地址,程序就会跳转到 bar 函数。
这就是返回地址攻击的原理。
现在让我们回来看我们的题目。我们的题目稍微复杂了一些,因为我们不再是修改自己的返回地址了,而是修改自己上方某个函数的返回地址。这就需要我们知道上方函数的栈帧的大小,以及 Return Address 的地址。那我们如何知道上方函数的栈帧的大小呢?我们可以遍历基指针实现。
还是考虑这个例子,我们知道,main 也是被别人调用的,也有 RETURN ADDRESS 和 SAVED RBP。
|
|
rbp 寄存器指向的是当前函数的栈帧的底部,也就是 SAVED RBP 的地址。那我们对 rbp 进行解引用,就可以得到上一个函数的 rbp 的地址。再次解引用,就可以得到上上一个函数的 RBP。如此循环下去,直到某一个 SAVED RBP 为 0,就说明到了栈的底部。这就是栈的展开的原理。
这就是我们的题目的原理。我们需要遍历栈,找到我们要修改的函数的栈帧,然后修改 Return Address。
需要补充的一点是,现代编译器并不总是会保存 RBP,因此这种遍历栈的方法并不总是有效。他们使用 fda 来保存栈帧的大小,这样就不需要 RBP 了。这样就能够多出一个通用寄存器。在 RISCV 中,基指针 fp 有两个名字,在保存基指针时,叫做 fp,如果不保存基指针,叫做 s0,即 saved register 0。多一个通用寄存器的好处是显著的,因为通用寄存器是非常宝贵的资源。但是进行栈展开就变得困难了,因为解析 fda 是非常困难的。但是你可以对指示链接器强制保存基指针。
汇编视角的栈内存
|
|
这里我使用的是动态 sp,某些编译器不会使用动态 sp,而是使用 rbp + 固定偏移,也就是课件中代码的形式。 使用哪种方式完全取决于编译器的实现,这里只是为了说明栈内存的分配。并且现代编译器也是多种方式混合使用,例如保存 rbp 的时候都是使用的 push/pop,但是局部变量的偏移是使用 rbp + 固定偏移的方式。
补充:寻址模式
我们进行内存访问,总是需要一个根,然后再加上一个偏移量。这个根叫做基址,这种寻址方式叫做基址寻址。基址寻址是一种非常常见的寻址方式,但是并不是唯一的寻址方式。
我们还有一种寻址方式叫做立即数寻址,这种寻址方式是直接使用一个立即数作为地址。这种寻址方式通常用于访问全局变量,因为全局变量的地址是固定的。
这里稍微说一下基址寻址。除了全局符号外,所有的寻址都是基址寻址。对于堆内存,它的根总是栈上的某个局部变量,这也是 Gargage Collection 的基础,对于引用托管堆对象的局部变量被视为 GC 根,通过遍历 GC 根,可以确定所有可达的托管对象。然后其他被判定为不可达的对象就可以被回收。
而对于栈内存,它的根是栈顶指针,也就是 rsp 或者 rbp。因此,不管是堆内存还是栈内存,通过寄存器来访问内存,无非是一次跳转和多次跳转的区别。
继续 Rust 所有权原理的内容
回到上次的课件