操作系统 - 闪客

操作系统 - 闪客

书籍地址:GitHub - dibingfa/flash-linux0.11-talk: 你管这破玩意叫操作系统源码 — 像小说一样品读 Linux 0.11 核心代码


记住,享受当下,当下你学的每一个看似没啥用的知识,都是后面豁然开朗这种感觉的基石。

这个我是深有体会

所以操作系统是一定要学习的,哪怕只是稍微过一遍


目录

开篇词


内容结构图


第一部分

什么是寄存器

寄存器是 CPU 内部的一种非常快速的存储器件,用于暂存 CPU 执行指令和数据的地方,它位于 CPU 芯片内部,是 CPU 的一部分。由于寄存器与 CPU 内部的总线相连,访问速度非常快,通常仅需要一个时钟周期即可完成数据的读取和写入。寄存器被广泛用于存储 CPU 执行指令和计算过程中的临时数据,以及保存程序状态等。由于寄存器数量非常有限,通常只有几十个到几百个,寄存器通常只用于存储最常用的数据,而且是由硬件设计师预设的。


如果想了解汇编指令的信息,可以参考 Intel 手册:

Volume 2 Chapter 3 ~ Chapter 5

比如本文出现的 sub 指令,你完全没必要去百度它的用法,直接看手册。

Intel 手册对于理解底层知识非常直接有效

中文版

https://github.com/sunym1993/flash-linux0.11-talk/tree/main/Intel 手册中文版


段寄存器 segment register

有关段寄存器的详细信息,可以参考 Intel 手册:

Volume 1 Chapter 3.4.2 Segment Registers

其中有一张图清晰地描述了三种段寄存器的作用。

CS 是代码段寄存器,就是执行代码的时候带着这里存的基地址。DS 是数据段寄存器,就是访问数据的时候带着这里的基地址。SS 是栈段寄存器,就是访问栈时带着这里的基地址。

其实操作系统在做的事情,就是给如何访问代码,如何访问数据,如何访问栈进行了一下内存的初步规划。其中访问代码和访问数据的规划方式就是设置了一个基址而已,访问栈就是把栈顶指针指向了一个远离代码位置的地方而已。


第四章

如果 16 进制数 1 代表一个字节的存储空间,那

ox100,就是 256 字节

ox400,就是 1024 字节,也就是 1kb

4k 就是 ox1000

表示起来,非常方便。

0x100000,就是 1m,也就是 1024kb

0x400 × 0x400,等于 0x100 × 0x100 × 4 × 4

4 × 4 为 16,刚好进 1,最终等于 0x100000


从 0x10000 到 0x90000 有 80000,也就是 800(16 进制)x256 个字节,也就是 512kb。


关于什么是扇区,请看《硬盘的基本知识》

寄存器的使用大部分都是基于一种约定,我把值写入到特定的寄存器里面,执行命令的时候,命令会自动去特定的寄存器里面取数据

寄存器的用法相当于入参和返回值,这里的 0x10 中断号相当于方法名。


段寄存器模式转换

我们需要从现在的 16 位的实模式转变为之后 32 位的保护模式。

现在还处于实模式下,这个模式的 CPU 计算物理地址的方式还记得么?不记得的话看一下 第一回 最开始的两行代码

就是段基址 (ds 寄存器存储的地址) 左移四位,再加上偏移地址。

CPU 切换到保护模式后,刚刚那个 ds 寄存器里存储的值,在实模式下叫做段基址,在保护模式下叫段选择子。段选择子里存储着段描述符的索引,通过段描述符索引,可以从全局描述符表 gdt 中找到一个段描述符,段描述符里存储着段基址,段基址取出来,再和偏移地址相加,就得到了物理地址

同样一段代码,实模式下和保护模式下的结果还不同,但没办法,x86 的历史包袱我们不得不考虑,谁让我们没其他 CPU 可选呢。

总结一下就是,段寄存器(比如 ds、ss、cs)里存储的是段选择子,段选择子去全局描述符表中寻找段描述符,从中取出段基址。

那么怎么告诉 CPU 全局描述符表(gdt)在内存中的什么位置呢?答案是由操作系统把这个位置信息存储在一个叫 gdtr 的寄存器中。

寄存器是保存在 CPU 中的,实际的数据是保存到内存中的,而实际的数据在内存中的地址会保存在 CPU 的寄存器中,


全局描述符表 gdt 和中断描述符表 idt。


什么是 A20 地址线呢?

简单理解,这一步就是为了突破地址信号线 20 位的宽度,变成 32 位可用。这是由于 8086 CPU 只有 20 位的地址线,所以如果程序给出 21 位的内存地址数据,那多出的一位就被忽略了,比如如果经过计算得出一个内存地址为

1 0000 00000000 00000000

那实际上内存地址相当于 0,因为高位的那个 1 被忽略了,地方不够。

当 CPU 到了 32 位时代之后,由于要考虑兼容性,还必须保持一个只能用 20 位地址线的模式,所以如果你不手动开启的话,即使地址线已经有 32 位了,仍然会限制只能使用其中的 20 位。


将 cr0 这个寄存器的位 0 置 1,模式就从实模式切换到保护模式了。

cr0,机器状态字寄存器

所以真正的模式切换十分简单,重要的是之前做的准备工作。


内存分页机制

分页机制的开关。其实就是更改 cr0 寄存器中的一位即可(31 位),0 关闭分页机制,1 开启分页机制。

还记得我们开启保护模式么,也是改这个寄存器中的一位的值。第 0 位

cr0 是机器状态寄存器


CPU 在看到我们给出的内存地址后,首先把线性地址被拆分成

高 10 位:中间 10 位:后 12 位

高 10 位负责在页目录表中找到一个页目录项,这个页目录项的值加上中间 10 位拼接后的地址去页表中去寻找一个页表项,这个页表项的值,再加上后 12 位偏移地址,就是最终的物理地址。


当时 linux-0.11 认为,总共可以使用的内存不会超过 16M,也即最大地址空间为 0xFFFFFF。

实际上,高位的 10 位,它只用了两位

而 22 位全用上,也才,4g 内存,注意,这里的单位是 bit,在网络传输中喜欢用字节,硬件中,喜欢用 bit,

而按照当前的页目录表和页表这种机制,1 个页目录表最多包含 1024 个页目录项(也就是 1024 个页表),1 个页表最多包含 1024 个页表项(也就是 1024 个页),1 页为 4KB(因为有 12 位偏移地址,2 的十次方就是 1024,也就 1kbit,2 的平方就是 4),因此,16M 的地址空间可以用 1 个页目录表 + 4 个页表搞定。

1(页目录表)_ 4(页表数)_ 1024(页表项数) * 4KB(一页大小)= 16MB

一个内存页大小为 4kbit 就是这么来的

一个页有 4kb,一个页表有 1024 个页,那就是 4m,一个页目录有 1024 个页表,也就是 4g

现在的内存很多都是 32,也就是需要 8 个页目录


Intel 体系结构的内存管理可以分成两大部分,也就是标题中的两板斧,分段和分页。

分段机制在之前几回已经讨论过多次了,其目的是为了为每个程序或任务提供单独的代码段(cs)、数据段(ds)、栈段(ss),使其不会相互干扰。

分页机制是本回讲的内容,开机后分页机制默认是关闭状态,需要我们手动开启,并且设置好页目录表(PDE)和页表(PTE)。其目的在于可以按需使用物理内存,同时也可以在多任务时起到隔离的作用,这个在后面将多任务时将会有所体会。

在 Intel 的保护模式下,分段机制是没有开启和关闭一说的,它必须存在,而分页机制是可以选择开启或关闭的。所以如果有人和你说,它实现了一个没有分段机制的操作系统,那一定是个外行。

再说说那些地址:

逻辑地址:我们程序员写代码时给出的地址叫逻辑地址,其中包含段选择子和偏移地址两部分。

线性地址:通过分段机制,将逻辑地址转换后的地址,叫做线性地址。而这个线性地址是有个范围的,这个范围就叫做线性地址空间,32 位模式下,线性地址空间就是 4G。

物理地址:就是真正在内存中的地址,它也是有范围的,叫做物理地址空间。那这个范围的大小,就取决于你的内存有多大了。

虚拟地址:如果没有开启分页机制,那么线性地址就和物理地址是一一对应的,可以理解为相等。如果开启了分页机制,那么线性地址将被视为虚拟地址,这个虚拟地址将会通过分页机制的转换,最终转换成物理地址。


第一部分这么多文章,其实可以写成一篇文章,作者应该是先确定了这一篇文章的内容,然后再拆成各个小章节的。

https://mp.weixin.qq.com/s?__biz=Mzk0MjE3NDE0Ng==&mid=2247499882&idx=1&sn=68fd16c5aeae15084be58afb1e5bd9e8&chksm=c2c5bac7f5b233d1c486fa57e9e3a2bc907a92ab69ff0344babab4d50bdaf76e7766b4b42914&cur_album_id=2123743679373688834&scene=189#wechat_redirect

最后这篇总结真的总结地非常好

整个第一部分最终要概念,就是几个寄存器

代码寄存器

数据寄存器

栈寄存器

内存中的数据大概也就这三个类型

然后是内存分段和分页。

就这几个重要概念。


写一写硬核文章/做一些硬核教程的时候,需要经常鼓励读者

将大的概念切换成一个个小的章节,然后每一个章节都鼓励读者


中断翻译成 trap,我们学 bash 的时候,学习过一个 trap 命令

https://xiashuo.xyz/posts/devops/bash/_book_bash_ryf/content/mktemp_trap/

操作系统的初始化包括内存初始化 mem_init,中断初始化 trap_init、进程调度初始化 sched_init 等等。我们知道学操作系统知识的时候,其实就分成这么几块来学的,看来在操作系统源码上看,也确实是这么划分的,那我们之后照着源码慢慢品

内存布局图一定要了然于胸。

操作系统说白了就是在内存中放置各种的数据结构,来实现“管理”的功能。


第二部分

操作系统启动

操作系统启动后,会把所有内存,从 0x100000 也就是 1m 以后的内存,全部按 4k 也就是 0x1000 一份拆成不同的块,然后用一个数组 mem_map 记录每一个块的使用次数,为 0 就是没有被使用过,不为 0 就是被用过了。

内存就是这样被管理的


操作系统启动的时候调用了 trap_init(); 这个方法里面有很多 set_xxx_gate,其作用其实就是设置终端号和对应的处理程序的映射关系


操作系统是如何读写物磁盘文件的,首先定义一个数据结构来描述磁盘操作,这个数据结构有几个核心参数,

操作类型 ∶ 读还是写,起始扇区,扇区个数,内存地址,下一个请求的地址,通过创建一个这种数据结构可以描述一个小的磁盘操作请求,而通过下一个请求的地址这个字段可以将多个请求串成一个链表

然后操作系统会有一个专门的进程来处理这个链表。只要这个链表有元素,操作系统就会执行,没有就等待。

这样,我们可以将自己读文件的请求拆分为多个 request 请求,由操作系统去执行,

这个设计真的很妙。


一个字符是如何显示在屏幕上的呢?

内存中有这样一部分区域,是和显存映射的。啥意思,就是你往上图的这些内存区域中写数据,相当于写在了显存中。而往显存中写数据,就相当于在屏幕上输出文本了。


tty 到底是什么?

https://linux.cn/article-14093-1.html

https://unix.stackexchange.com/questions/4126/what-is-the-exact-difference-between-a-terminal-a-shell-a-tty-and-a-con

terminal = tty = text input/output environment

console = physical terminal

shell = command line interpreter


所以 tty_init 加载完之后

在此之后,内核代码就可以用它来方便地在控制台输出字符啦!这在之后内核想要在启动过程中告诉用户一些信息,以及后面内核完全建立起来之后,由用户用 shell 进行操作时手动输入命令,都是可以用到这里的代码的!

说人话就是,我们就可以使用键盘,再终端中输入指令了


这是 CPU 与外设交互的一个基本玩法,就是对一个端口先 out 写一下,再 in 读一下。

CPU 与外设打交道基本是通过端口,往某些端口写值来表示要这个外设干嘛,然后从另一些端口读值来接受外设的反馈。

获取时间是跟 cmos 这个外设打交道,读取硬盘数据是跟硬盘这个外设打交道。


进程调度

进程调度的初始化方法 sched_init 实际上就是初始化了下 TSS 和 LDT

TSS 叫任务状态段,就是保存和恢复进程的上下文的,所谓上下文,其实就是各个寄存器的信息而已,这样进程切换的时候,才能做到保存和恢复上下文,继续执行

而 LDT 叫局部描述符表,是与 GDT 全局描述符表相对应的,内核态的代码用 GDT 里的数据段和代码段,而用户进程的代码用每个用户进程自己的 LDT 里得数据段和代码段。代码段和数据端的作用是帮助 CPU 找到代码和数据在内存中的物理地址

现在虽然我们还没有建立起进程调度的机制,但我们正在运行的代码就是会作为未来的一个进程的指令流。也就是当未来进程调度机制一建立起来,正在执行的代码就会化身成为进程 0 的代码。所以我们需要提前把这些未来会作为进程 0 的信息写好。等后面整个进程调度机制建立起来,并且让你亲眼看到进程 0 以及进程 1 的创建,以及它们后面因为进程调度机制而切换,你就明白这一切的意义了。

然年 ltr 是给 tr 寄存器赋值,以告诉 CPU 任务状态段 TSS 在内存的位置;lldt 一个是给 ldt 寄存器赋值,以告诉 CPU 局部描述符 LDT 在内存的位置。这样,CPU 之后就能通过 tr 寄存器找到当前进程的任务状态段信息,也就是上下文信息,以及通过 ldt 寄存器找到当前进程在用的局部描述符表信息。


sched_init 还设置了两个中断

第一个就是时钟中断,中断号为 0x20,中断处理程序为 timer_interrupt。那么每次定时器向 CPU 发出中断后,便会执行这个函数。

这个定时器的触发,以及时钟中断函数的设置,是操作系统主导进程调度的一个关键!没有他们这样的外部信号不断触发中断,操作系统就没有办法作为进程管理的主人,通过强制的手段收回进程的 CPU 执行权限。

之后进程调度就从定时器发出中断开始,先判断当前进程时间片是不是到了,如果到了就去 task[64] 数组里找下一个被调度的进程的信息,切换过去。

这就是进程调度的简单流程,也是后面要讲的一个非常精彩的环节。

第二个设置的中断叫系统调用 system_call,中断号是 0x80,这个中断又是个非常非常非常非常非常非常非常重要的中断,所有用户态程序想要调用内核提供的方法,都需要基于这个系统调用来进行。

比如 Java 程序员写一个 read,底层会执行汇编指令 int 0x80,这就会触发系统调用这个中断,最终调用到 Linux 里的 sys_read 方法。

这个过程之后会重点讲述,现在只需要知道,在这个地方,偷偷把这个极为重要的中断,设置好了。

CPU 像是一个只知道埋头干活的老黄牛,需要不停地有人提醒他该干点别的了,他才会抬起头来,进行任务切换。

有没有越来越发现,操作系统有点靠中断驱动的意思,各个模块不断初始化各种中断处理函数,并且开启指定的外设开关,让操作系统自己慢慢“活”了起来,逐渐通过中断忙碌于各种事情中,无法自拔。

恭喜你,我们已经逐渐在接近操作系统的本质了。

作者的总结如下:

你会发现操作系统就是一个靠中断驱动的死循环而已,如果不发生任何中断,操作系统会一直在一个死循环里等待。换句话说,让操作系统工作的唯一方式,就是触发中断。


从内核态切换到用户态,真的挺复杂的

https://mp.weixin.qq.com/s?__biz=Mzk0MjE3NDE0Ng==&mid=2247501522&idx=1&sn=936f7837421870a572ee2a82d745a519&chksm=c2c5bc7ff5b23569eb0b3472ac7dcfc25c5de25a0ff75b4c5e805056fa3e1492870cf59df324&cur_album_id=2123743679373688834&scene=189#wechat_redirect

细节太多了。

现在先记住

所谓用户态内核态,实际上就是 CPU 中的两个寄存器 cs 、ss 还有 esp 中的两个比特位上的数据

数据访问只能高特权级访问低特权级,代码跳转只能同特权级跳转,要想实现特权级转换,可以通过中断和中断返回来实现。

我现在理解,用户态和内核态,实际上是对 CPU 能够访问的内存范围的限制。防止用户对操作系统内核进行破坏,是一种提高操作系统安全性的措施。

但是安全往往会带来性能的损耗。

简单来说,用户态的程序和内核态的程序和数据是隔离的,用户态的程序并不能直接接触到系统内核区域的代码或者数据,而是只能通过中断进行间接的调用,我们将这个间接调用的动作称为从用户态切换到内核态,(这名字起的花里胡哨的)


操作系统通过系统调用,提供给用户态可用的功能,都暴露在 sys_call_table 里了。

系统调用统一通过 int 0x80 中断来进入,具体调用这个表里的哪个功能函数,就由 eax 寄存器传过来,这里的值是个数组索引的下标,通过这个下标就可以找到在 sys_call_table 这个数组里的具体函数。


写时复制的本质

在调用 fork() 生成新进程时,新进程与原进程会共享同一内存区。只有当其中一个进程进行写操作时,系统才会为其另外分配内存页面。

写的时候才复制,就叫写时复制。


因为分段分页的存在,我们在写代码的时候不需要关心物理内存地址,只需要写逻辑地址即可,逻辑地址代表的是相对操作系统分配给当前进程的内存空间段的起始位置的偏移。


第三部分:一个新进程的诞生

用到了大量

第一部分 进入内核前的苦力活

中的知识点,所以第一部分真的要弄懂弄透

而且这一段儿对于理解 Java 多线程其实非常关键。


第四部分:shell 程序的到来

关于硬盘的基本常识:

mount_root 直译过来就是加载根,再多说几个字是加载根文件系统,有了它之后,操作系统才能从一个根开始找到所有存储在硬盘中的文件,所以它是文件系统的基石,很重要。

为了加载根文件系统,或者说所谓的加载根文件系统,就是把硬盘中的数据加载到内存里,以文件系统的数据格式来解读这些信息。

所以第一,需要硬盘本身就有文件系统的信息,硬盘不能是裸盘,这个不归操作系统管,你为了启动我的 Linux 0.11,必须拿来一块做好了文件系统的硬盘来。

(这一点很重要,这就是为什么我们要格式化硬盘,格式化硬盘就是在硬盘上做好一个文件系统)

第二,需要读取硬盘的数据到内存,那就必须需要知道硬盘的参数信息,这就是我们本讲所做的事情的意义。

首先硬盘中的文件系统,无非就是硬盘中的一堆数据,我们按照一定格式去解析罢了。


https://mp.weixin.qq.com/s?__biz=Mzk0MjE3NDE0Ng==&mid=2247502181&idx=1&sn=b6dcbd1d2cf930002852008a1c4e6a65&chksm=c2c5b3c8f5b23ade1532b725995dbc3b0138202555e44a6e308b84d668a2ef3041eb5cf77f86&cur_album_id=2123743679373688834&scene=189#wechat_redirect

了解了文件系统到底是什么,解决了我多年的疑惑


整个操作系统只有一个 file_table,

file_table 表示进程所使用的文件,进程每使用一个文件,都需要记录在这里

file_table 是一个数组,而且大小固定,也就是说整个操作系统所有进程能打开的文件总数是有限制的。

前面我们也学习过,进程打开的所有文件都保存在 filp 数组中,元素的索引就是 fd,也就是文件描述符,同样的,单个进程的最大打开文件数是有限制的

在后面的 Linux 发行版中,单进程打开的文件的个数由 nofile 参数控制,默认为 1024


https://mp.weixin.qq.com/s?__biz=Mzk0MjE3NDE0Ng==&mid=2247502230&idx=1&sn=44e023bf0b9b37261e35a6e3722bc57f&chksm=c2c5b33bf5b23a2d10a9dd36606c497f41a1c3dced57845ce7ef12741a348fab82beba462a8a&cur_album_id=2123743679373688834&scene=189#wechat_redirect

这个文章中,我们可以知道

0 号文件描述符,复制出 1 号文件描述符,1 号文件描述符复制出 2 号文件描述符,为什么不直接创建三个文件呢?而且这样不会是的 0/1/2 这三个文件描述符的输出分不清嘛。


一个新程序开始执行呢?

其实本质上就是,代码指针 eip 和栈指针 esp 指向了一个新的地方。

代码指针 eip 决定了 CPU 将执行哪一段指令,栈指针 esp 决定了 CPU 压栈操作的位置,以及读取栈空间数据的位置,在高级语言视角下就是局部变量以及函数调用链的栈帧。


我们在 Linux 里执行一个程序,比如在命令行中 ./xxx,其内部实现逻辑都是 fork + execve 这个原理。

Linux 通过缺页中断处理过程,将 /bin/sh 的代码从硬盘加载到了内存,此时便可以正式执行 shell 程序了。

其实我就想说,shell 程序也仅仅是个程序而已,它的输出,它的输入,它的执行逻辑,是完全可以通过阅读程序源码来知道的,和一个普通的程序并没有任何区别。

shell 程序就是个死循环,它永远不会自己退出,除非我们手动终止了这个 shell 进程。

在 xv6 实现的 shell 中,shell 的逻辑很清晰,在死循环里面,shell 就是不断读取(getcmd)我们用户输入的命令,创建一个新的进程(fork),在新进程里执行(runcmd)刚刚读取到的命令,最后等待(wait)进程退出,再次进入读取下一条命令的循环中。

而 runcmd 其实就是简简单单调用了个 exec 函数,这个 exec 函数,就是我们在 Linux 0.11 源码中的,execve 函数

所以 shell 执行一个我们所指定的程序,就和我们在 Linux 0.11 里通过 fork + execve 函数执行了 /bin/sh 程序是一个道理。


第五部分

其他

bitmap 翻译为位图

一般为一个数组,用来保存状态

为何微软不在新的操作系统中让 32 位支持大于 4GB 的内存? - 北极的回答 - 知乎

https://www.zhihu.com/question/22594254/answer/42967413


学完操作系统之后,我再去学习 JVM 的相关细节,我开始有一种体会:

其实任何软件跟操作系统打交道的地方就两个: