一 os综合概述
什么是os
如果你对os有过一点了解,或者说你上过os的课,就知道os是“管理软/硬件资源、为程序提供服务” 的程序。
这个定义有用但好像有没什么用,虽然他足够正确。
如果要知道什么是os,那要从os诞生到发展至今天的一步步。我相信很多教科书也是这么想的,但是老师上课的时候却可能有问题。还记得我上os课的时候还背过这么一个知识点,什么批处理系统,多道系统。(背知识点有没有感觉很熟悉,而且不得不说背的就是背的,你看根本不记得)
其实安排并没有错,从os的变化来加深学生对os的认知,可是很多学生或者老师把他变成了一个记忆点。
很多人都说os的东西很多,一下子没法跟讲清什么是操作系统。那我认为这个知识点就是干这个事情的。从最开始的没有os到后面的批处理,到后面的多道。这其中就是因为人们认识到了计算机的威力,所以人们需要计算机处理的事情有很多,换言之就是对计算机需求大。那么需要一个程序来帮助他们,比如如何做到运行更多的程序,比如让程序跟comfortable的运行。这个程序就是操作系统,在人们越需要计算机的性能时,这个程序就越复杂。正所谓责任越大,能力越要越大。
举个例子,当时计算机非常昂贵,可是很多都有用计算机的需求,那么在计算机上能运行多个程序就是一件必要的事情。比如a程序在机器上跑遇到io,那它就要停下来。可是机器的时间不能浪费,这时候为什么不趁着a暂停的时间跑b程序呢。事实上我们的确需要,那为了能这么做,我们的os不就要有能中断的能力。感觉人和os的关系像一对母子关系,还是母亲十分溺爱孩子的那种,孩子说什么,母亲就去实现,而且还越来越强。
小tips:在古老的计算机时期,那时候还没disk,拿人们是怎么实现内存的?事实上,人们用来一种叫延迟线的技术。这个延迟线有点像序列发生器。jyy用一张图片(throw-ball.gif (500×389) (jyywiki.cn))来形容,我觉得它像小学的应用题,说有一个泳池,这边加水那边放水,那这个池子里的水不就保存下来了吗。
但是上面那个定义还有有一点用的,至少它向我们揭露了一件事,就是os不光跟软件有关,还跟硬件有关。
那我们在思考什么是os时就要从软件和硬件两个方面来考虑了
对软件来说,os既要给程序支持又要管理程序,就像母亲一样就要支持你又要管着你。管理程序倒挺好理解,那支持从何谈起。我认为是现在的程序要做的事情,相比于没有os的时候,要难太多了,所以才需要支持。
那os怎么提供支持,又是怎么进行管理的呢?
管理就是把程序的数据拟合成一个对象,支持就是提供一些api。api就相当于开关,要做某些事情只需要打开开关就好。
对硬件来说,os就是c程序。上面说os给程序提供api的支持,那么os就要通过硬件的功能来实现这些api。所以在程序运行的时候os就变为所谓api的实现者。换句话说os在完成初始化后就成为 interrupt/trap/fault handler
举个例子,我们在eda课上要做实验,在一个板子上实现一些功能。这个时候没有os,也自然没有所谓的api,想要实现功能就要用板子自身的功能,要对你用到的引脚编程。也侧面显式了os对程序的支持,在没有api的情况下实现这么简单的功能都显得困难。
或者在我们编写c程序时想要输出,用write这个api就可以实现输出。这个时候我们并没有感知到os的存在,他只是默默地帮我们把工作做完。
二os上的程序
状态机
在数电中介绍了一种状态机模型,要表示它可以用数学公式,可以用图,当然也可以用代码来表示。
#define REGS_FOREACH(_) _(X) _(Y)
#define RUN_LOGIC X1 = !X && Y; \
Y1 = !X && !Y;
#define DEFINE(X) static int X, X##1;
#define UPDATE(X) X = X##1;
#define PRINT(X) printf(#X " = %d; ", X);
int main() {
REGS_FOREACH(DEFINE);
while (1) { // clock
RUN_LOGIC;
REGS_FOREACH(PRINT);
REGS_FOREACH(UPDATE);
putchar('\n'); sleep(1);
}
}
这个代码初看有点难以接受,一来是marco(宏),而是写法也与正常写法不同,给我一种高阶函数的感觉。
就是这几个函数都是对寄存器的操作,那就意味着他们有着共同的模式,将这种公共的模式抽离出来变成一个函数"REGS_FOREACH",再将要做的操作(操作就是函数或者叫过程)传递给这个公共模式。 REGS_FOREACH(DEFINE);
但这是我的感觉,它的实现其实很简单,并不是上面说的高阶函数,只是简单把宏展开。只是我觉得用高阶函数的视角去看更容易理解。
用gcc -E展开后的代码
int main() {
static int X, X1; static int Y, Y1;;
while (1) {
X1 = !X && Y; Y1 = !X && !Y;;
printf("X" " = %d; ", X); printf("Y" " = %d; ", Y);;
X = X1; Y = Y1;;
putchar('\n'); sleep(1);
}
}
到后面jyy的关于输出数码管的例子,给我感觉跟我在上eda时,做的作业有点像,只是jyy是用python来实现功能,我是用板子自己的功能。
当然,我第一次看的时候,这个例子给我幼小的心灵带来极大的震撼。
到这里jyy给出了他的结论,也是我认为他视频的母题之一,状态机。从电子系统到我们的程序都是状态机。
后面给出他给出有关于程序是状态机的证明,从静态的c语言和动态的程序两方面来证明。其实我觉得直接听不太容易接受,加上jyy始终在给新东西,容易听的累。当然他们自己学生从pa一路跟上来的当然不会有什么问题。我觉得如果能先把状态机的理念给搞懂会好懂一点。
要搞懂状态机,我推荐这篇blog(“浅尝”状态机(finite-state machine) - 掘金 (juejin.cn))(夹带私货)和yzh的视频(计算机系统的状态机模型 [第五期“一生一芯”计划 - P3]哔哩哔哩bilibili)
在jyy证明时我觉得有意思的点:
1.用gdb并且从状态机的视角来分析程序的运行。gdb我是在做bomb lab的时候学会使用的,但是从没想过从状态机的视角去看程序。
具体gdb的使用一方面当然可以像jyy说的一样从gdb的manual,第二个也可以看blog(bomb lab(假图文版) - 掘金 (juejin.cn)),然后做bomb lab
2.在上面程序的状态机的视角下有一个小问题,就是程序只能计算。单凭我们程序自己跑,程序最终会进入两种状态,一是进到以前的状态重复执行(因为内存是有限的),另一种就是进入到一种 undefined behavior(未定义行为是指C语言标准未做规定的行为。编译器可能不会报错,但是这些行为编译器会自行处理,所以不同的编译器会出现不同的结果,什么都有可能发生,这是一个极大的隐患,所以我们应该尽量避免这种情况的发生。)
程序需要os的支持(在第一章os综合概述我们已经提到了),这个时候有一个叫syscall的指令。让os完全掌握程序的状态,肆意的做出修改,来实现程序做不到的行为,比如输入输出,访问设备。
在syscall时,程序和os的关系就有点像gtv中的作弊行为,我们玩家就像程序,os就是gtv这个游戏框架。我们(程序)想要一辆跑车(输出)只要输入作弊码(中断号),这个时候游戏(os)就会处理。我们做为玩家(程序)并不需要知道系统(os)是怎么实现的。这和上面说的os在完成初始化后就成为 interrupt/trap/fault handler是一样的
具体有关syscall的内容可以看csapp的第八章异常控制流(exception control flow)
我们与游戏的关系和程序与os的关系是一样的,后者在前者眼中只是内容的provider。
程序 = 计算+syscall + 计算 +...
最小的hello world
要做到最小的hello world,首先不能用库函数,要直接找os。在游戏里也是一样,攒资源不如开挂。(当然在游戏里开挂不对)
所有输入输出必须要直接用syscall
#include <sys/syscall.h>
.globl _start
_start:
movq $SYS_write, %rax # write(
movq $1, %rdi # fd=1,
movq $st, %rsi # buf=st,
movq $(ed - st), %rdx # count=ed-st
syscall # );
movq $SYS_exit, %rax # exit(
movq $1, %rdi # status=1
syscall # );
st:
.ascii "\033[01;31mHello, OS World\033[0m\n"
ed:
如果不会用汇编也可以用c语言,直接用SYS_write函数和SYS_exit也行。运行结果是一样的

但是这样就不符合最小的定义了,只是有这个意思罢了。他和直接用printf和return的文件大小是一样的。因为程序都是从main开始并且都是函数调用。
a是用SYS_write函数和SYS_exit,b是用printf和return。
_start起手其实是ld规定的,具体关于link的事我也不是很清楚。
前面jyy在前面的硬来版本中利用gdb找到错误的手法也值得学习。
程序视角之间的切换
这个先看我上面放的yzh的视频会好理解很多。
我也没学过编译原理,也没了解过。关于编译的问题,我是一点也不懂,没什么想法。
操作系统中的一般程序
操作系统中眼中程序和上面的mini.S没什么不同,大家都是计算指令+syscall
程序中的操作系统也就是interrupt/trap/fault handler
程序的第一条语句是什么?
这个yzh的视频也是有讲到的
用gdb的starti发现是在ld(在elf中有规定)中,发现是先进行ld的自举。
在main函数之前用了哪些api
可以用strace
而且不得不说用strace来跟踪gcc正是太震撼了
程序在os上运行依赖于os中的对象,利用api来实现特异功能。(和第一章中提的是一样的)
ps:后面我可能会跳过并发的部分,直接到后面进程的内容。因为ucore是这样的顺序,至于有没有影响,只能等我回看的时候才知道。或者某个看过的好兄弟来提醒我。
9 os的状态机模型
cpu reset
我们都知道程序是个状态机,程序的初态是由manual来规定的。
那计算机系统也是状态机,那他的初态是什么?换言之,我们按下开机键后发生了什么?
和程序一样,计算机系统的初态也是被manual规定的。在cpu reset后计算机系统就处于初态了。
jyy将qemu停在第一条语句,就是模拟计算机在cpu reset后所处状态,再通过gdb来观察,发现我们的pc(其实是cs和ip)是ffff0(现在处于实模式)
fireware&&bootloader
ffff0 通常是一条向 firmware 跳转的 jmp 指令,所谓 firmware 就是我们rom中放的软件
在ucore中firmware 就是bios,他的目的就是找到我们在lab1做的启动盘(通过最后的0x55 0xaa来确定启动盘,为什么是55aa?好像是因为55aa的二进制是01交替出现,来检验错误)中的bootloader(启动盘开头的512byte),然后把bootloader放到内存的0x7c00(为什么是7c00?因为那个时候内存的最高地址是0x7fff,因为bootloader也只是为了引导os,所以应该把bootloader的代码放到内存的高地址,方便后面覆盖。然后bootloader大小是512byte,再给boot loader的stack留512byte大小。0x7fff-512-512+1=0x7c00),之后 firmware的使命就结束了。
具体看bios的代码,我们在ucore lab1中的练习二就做过了(ucore lab1 - 掘金 (juejin.cn)),其实包括fireware和bootloader lab1都讲了
贴张结果图

操作系统的状态机模型

经过上面的bios和bootloader,我们的os已经成功启动了。记下来os就按c语言的语义继续执行。
am对c语义的补充 就是硬件对os的api支持。具体的am可以先看后面abstract machine的部分
在多处理器的视角下,中断就是在相应的cpu上保存context 和把context传递给handler(你会发现在状态机的视角下看中断是如此的简单),这些我们概念上原本应该由os实现的功能,现在都是由am支持,可能这就是为什么jyy说写os不困难的原因。
后面都是阅读代码的小tips:
1.makefile的优化,我直接用typora打开makefile也能做到一样的效果。(偷懒)
2.make -n 只是打印,进行变量的展开
3.在intellisense插件中带上正确的编译选项进行vscode正确的跳转
4.sed帮助我们阅读。:%s/ /\r /g 把空格变成换行
5.生成img和ucore是一样的。在thread-os是怎么传参数传递给main函数:把stdin放到img的1024个0
利用makefile生成的img文件
6.thread-os的理解:首先代码部分就是定义两个线程,每个线程执行预先定义好的代码。(是通过设置entry的方式。os加载程序利用loader把pc放在elf entry)。代码依靠am提供的api实现这些功能(我们印象中操作系统的应该有的功能)。由makeflie生成img文件。
如果你做过ucore lab1,你会发现thread-os简直就是我们lab1的精简集合,这就是为什么可以说thread-os是最小的操作系统。
我感觉jyy上课真的是教学生How to Think Like a Computer Scientist: system Version
abstract machine
复杂系统中隐藏的秩序,jyy讲了一个关于航母的故事,讲这个故事的也是告诉我们如何在大型系统下生成 ,在越早的系统中他的秩序就越清晰
所以jyy从图灵机开始讲解计算机系统中的秩序
trm 自动机纸带,自动机 = 状态机(状态机的模型居然在trm上也是适用的)
但是刚开始的时候,程序还是硬布线的,很不方便,后来天降猛男冯诺依曼提出了存储程序,使得计算机变得更加的通用,其实也回答了我以前的问题,到底谁才是计算机之父?这样看如果图灵算计算机之父,那冯诺依曼就可以算通用计算机之父
现在计算机已经足够的通用,所以他要和这个世界打交道,于是计算机加上了io
因为io的缓慢,所以有了中断(硬件驱动的函数调用),因此我们的指令周期是这样的,
在中断后,计算机进入中断处理程序(这和前面讲的os在完成初始化后就成为 interrupt/trap/fault handler是一样的)
计算机有了中断,就可以进行程序的调度,换言之,现在在计算机上就可以运行多个程序了。
关于并发,我觉得有个东西可以很好的解释它,就是快银(如果你看过x-man 天启)最经典的出场。

快银利用他的超能力救人的时候,实际上它是一个一个救人的,但是在被救人的视角看来,一切都是一起发送的。
并发就是os利用它的超能力(time out,现在一秒钟cpu可以发生一千次time out),来完成很多事情,但是在我们的眼中,好像事情是一起发生的。
后面讲的中断就是把寄存器现场保存到内存,就是上面提到的context。
现在有了同时执行,就有隔离的请求,于是便有了虚拟内存
有了分时和虚拟内存就有了进程
到这里,计算机经过几个阶段,而am 就是硬件的模拟层,根据计算机系统不同阶段给程序提供不同api的支持。
上面的提到的thread-os,就是利用am提供的api实现的。
关于am,我找到yzh的ppt(s4plus.ustc.edu.cn/_upload/art…),其中就解释了am



10 状态机模型的应用
状态机和物理世界
jyy举了一个生命游戏(威廉证明了其图灵等价)的例子,后面还找到一个更酷的例子
用OTCA Metapixel 实现了简单的电路,然后以此为基础搭建了一个处理器,并为此设计了一个汇编语言和一个高级语言。
状态机模型:理解编译器和现代 CPU
在有关状态机模型的讲解中就讲了编译器就是进行状态机的翻译工作,把c程序的状态机翻译成指令集的状态机。编译器就是要保证其中syscall的指令要翻译一致,但其中的计算指令就可以所编译器优化,比如编译器可以一次性执行多个计算指令。这个叫超标量 (superscalar)/乱序执行处理器,允许进行状态机的跳转。
查看状态机执行
strace和gdb来查看状态机执行
1.record不同state的diff,来实现状态的回退 ,工具:gdb

例子:

2.通过记住结果(non-deterministic)和指令数量(deterministic)来实现replay

工具:rr(rr-debugger/rr: Record and Replay Framework (github.com))
扩充:在os这个状态机模型下,只要记录下所有中断(包括io)的结果和指令数就可以replay system的结果
3.利用状态机来查看程序性能,工具perf

4.自动发现程序的执行 工具:model check
11.操作系统上的进程
操作系统启动后到底做了什么
在我没听jyy的课之前,我对操作系统有很多的迷惑,它好像无所不能,又好像无处不在。
操作系统到底是什么?操作系统到底干了什么?
在之前jyy已经给出了第一个问题的答案:操作系统就是c程序,是一个状态机。
现在jyy告诉我第二个问题的答案:提供一些api,然后加载 “第一个程序”。然后,Linux Kernel 就进入后台,成为 “中断/异常处理程序”
或许你不相信,不过也可以理解。毕竟它现在的样子和我们印象中那个无所不能的os不太一样。
没事,jyy举了一个最小Linux的例子
定制最小的linux
代码在ppt中
自己如果想要玩,解压后直接make编译完再直接make run运行
结果:

上面不是说os就是加载 “第一个程序“,而这个里面init就是运行busybox中的bash,所以你会看到上面这个模样。但是现在我们机器上只有init进程,所以你在上面这个shell打什么命令都是没反应的。
但是我们可以在这里面只要用busybox中的程序
后面jyy相当于做了一个链接,把我们输入的命令直接接到busybox里面去了。

这样就可以直接运行了。
既然说是操作系统了,那这个最小的linux自然也能运行程序。首先在我们的文件系统(initramfs)中添加一个code文件夹,再把我们之前的mini.S编译出来。

然后在qemu上运行我们的最小的linux,可以发现我们的程序可以运行
这样我们的操作系统是名副其实了。
你是不是感觉这个最小的linux有点os的样子了,但和我们印象中的os不太像。其实是一样的,我们印象中的os有很多很多的程序,但是根据上面我们的理论,这些程序可以分为两类,第一个init程序和其他。在我们这个最小的linux系统中这些其他程序被合成成busybox。
但是你可能还有一个问题:好,我承认os就是加载 “第一个程序”,但其他程序是怎么创建出来的呢?
注:不过我们这里是用init做为第一个进程(包括我现在正在用的wsl2),但是还有一种systemd的启动方式(jyy也是这个)。
不过好像wsl也可以用systemd启动。
Systemd support is now available in WSL! - Windows Command Line (microsoft.com)

答案:用syscall。其实关于os干了什么的问题的完整答案是提供了一些api,然后加载 “第一个程序”。然后,Linux Kernel 就进入后台,成为 “中断/异常处理程序”。我们的init进程就是用syscall的方法来使用这些api,通过这些api可以创建出这个五彩缤纷的世界(jyy原话)
这张图我认为算得上是这节课的重点。
管理进程的api
在讲进程管理的api之前要一个共识:程序就是状态机。用这个视角去看管理进程的api,那这些api就是操作状态机。
除此之外,用状态机的视角看os就会发现os就是在组织多个状态机,os的操作不过是选择一个状态机执行。我现在明白为什么jyy要先上并发再上虚拟化了
那么又有个问题:什么时候os选择?怎么选择?
社么时候:在time out
怎么选择:通过schedule
fork
folk:创建新进程的api,但是如果从状态机的视角看folk,folk只是进行状态机的复制。但是父进程和子进程关于fork的返回值不同(子进程返回0,父进程返回子进程的PID),他们的pid也不一样

具体代码的部分,等我做完ucore lab4:to do
fork bomb
:() { # 格式化一下
: | : &
}; :
fork() { # bash: 允许冒号作为标识符……
fork | fork &
}; fork
bomb() { # bash: 允许冒号作为标识符……
bomb | bomb &
}; bomb
执行这行代码,你的电脑就会卡死,我这里wsl都没响应了,不过重启一下就好了。重启大法好呀
我觉得这段代码还是写成bomb比较好,因为他虽然是fork了,不过他的fork是通过管道。我第一次看的时候还以为fork函数和fork有关呢,其实他只是用管道来fork。
状态图(伪)
所以这个fork bomb是以2的指数倍增长的。递归让他能往下或者说一直进行,fork是程序变多的关键。
fork-demo
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t pid1 = fork();
pid_t pid2 = fork();
pid_t pid3 = fork();
printf("Hello World from (%d, %d, %d)\n", pid1, pid2, pid3);
}
你觉得会输出什么?(子进程返回0,父进程返回子进程的PID)
状态图:

解释:自下向上的箭头是fork的返回值,最后一共有8个程序,所以要输出8行
结果:
fork-print
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc, char *argv[]) {
int n = 2;
for (int i = 0; i < n; i++) {
fork();
printf("Hello\n");
}
for (int i = 0; i < n; i++) {
wait(NULL);
}
}
你按上面的经验一下就算出会输出6个hello,结果有点出乎意料
结果:
这是魔法吗?
计算机系统里没有魔法。机器永远是对的。
这是因为我们的库函数printf实现输出的时候,为了减少系统调用的次数(为了减小开销),会把字符放在缓冲区。当我们输出的地方不同,对缓冲区的处理方法也不同。
当我们输出到终端(就是直接./a.out),tty是linear buffer 每遇到一个换行就直接把buffer输出。如果输出到pipe||file(| cat)是full buffer,只有当buff大小到了4096nb才会输出buffer
状态图:

execve
有fork只是有原来程序的副本,要想有别的程序,就必须要有api来实现程序的载入。
从状态机的视角看execve,他就是重置某一个程序的状态机,使其处于初态。就相当于cpu reset的地位。
换句话说,fork创建状态机,execve reset 状态机
所以strace的第一条syscall 都是execve
exit
exit就是状态机的销毁,一个程序执行了exit,os中就没有他的状态机了。
我们有很多的exit:
1.libc库中的 会调用atexit,会flush buffer
2.syscall中的exit
_exit(0) - glibc 的 syscall wrapper
-
执行 “
exit_group” 系统调用终止整个进程 (所有线程),不会调用atexit3.适用线程的exit __exit
-
syscall(SYS_exit, 0)- 执行 “
exit” 系统调用终止当前线程 - 不会调用
atexit
- 执行 “