Operating Sys
Merry Christmas!
operating sys
瞎记乱学 这里所记的是南京大学Jyy2024春 虚拟化前的笔记
部分理解的内容有遗漏 或者记在其他文档上了
存储相关内容TBD 有可能跑去学mit6.s081了
应用层面
所有的程序本身只有数据,而在程序运行中只有数据的处理。程序本身是没有能力打开或关闭的 需要依赖于系统调用(system call)
在操作系统的上下文中,程序本身是由数据和代码组成的静态实体,它只有在被加载和执行时才会成为一个动态的进程。程序本身没有能力自行打开或关闭,而是依赖于操作系统提供的系统调用(system call)来执行这些操作。
所有的程序都可以理解为一个黑盒子 sandbox
所有的软件都只是设计了对应数据的IO
窗口 浏览器等页面也是同理 软件本身并不具备打开窗口的能力
而是各操作系统上提供了一个任务管理器 此任务管理器提供对应的api给各软件
当某个软件需要创建新窗口的时候 他就会将创建对应窗口的参数传给任务管理器
任务管理器将此创建新的窗口并同统一对其进行管理
任务管理器到底算是操作系统的一部分还是软件的一部分?
如果站在软件上看 它也是IO对应的数据 并将其进行处理 只是它所output的方式是图形化界面而已
如果是操作系统 它通过syscall获取对某些系统资源(CPU占用率)的访问权限。也实现了属于自身的系统调用 (它可以新建 & 结束某些其他进程)
综上,虽然应用管理器本身只是一个用户空间的应用程序,但他通过与操作系统内核的紧密交互实现了类似“系统调用的功能”
上述只是本人一些思考
万物皆是状态机
如上所说 所有的程序都是无非都是数据的处理 同时也是状态的切换。
任何一个程序 包括从高级语言到c语言到汇编
基本上都是围绕着状态的切换
从这一角度上看 c语言中的编译 无非就是讲状态切换的逻辑转换描述方式 罢了
M1 打印进程树 pstreehttps://jyywiki.cn/OS/2024/labs/M1.md
Everything is file
首先展示pstree运行效果
此命令将正在运行的进程与其父进程方式 以树的方式输出
根据上方的思路,我们可以设想所有的进程及其信息,包括其窗口化的信息等。也许会统一存储在某个文件当中
在linux当中,存储在/proc
目录下,进入目录可以看到许多以数字为编号的文件夹。可以料想到每一个编号其实就是每一个进程的编号(PID)。
下方使用linux中ps命令查看 正在运行的进程
然后在proc目录下进入对应2237目录 vim打开status文件 (此文件存储进程的名字)
此处的PPID即是对应进程的父进程
根据上方目录可知道所有进程的父进程都是systemd 且其进程PID为1
进入对应目录可发现它的父ID是0
综合以上的信息,我们可以用proc来记录,获取每一个进程的运行状态和他的父进程。之后只需要以递归,遍历树的方式将其组织输出出来就好了。
硬件层面
依然以状态机模型思考 计算机系统运行的过程及其中的任何步骤其实都可以以状态机模型进行思考
由数字电路中的与非等逻辑 直至 汇编代码 高级语言
每一行语句的逻辑处理 本质上都是一种状态的切换
现在已整个计算机启动的角度 以状态机模型进行思考 可以分析为
初始状态:由硬件 & 操作系统设计者自定义 (CPU Reset)
状态: 每个内存 & 寄存器中存储的数值
状态转移:执行指令 (由PC寄存器中取出)
一个现代的计算机中 会同时存在多核多线程
但是他们是共享同一块内存的 在这样的情况下
可以往状态机模型上加多一步 : 在执行指令的时候 每次选一个处理器执行一条指令
之后会详细研究
接下来 将解析一部分操作系统运行时的启动流程 (很多概念)
忽略硬件的实际运行细节 以QEMU模拟计算机启动时的硬件条件 (不严谨的可以将其理解为VMware)
QEMU(Quick Emulator)是一种通用且开源的机器仿真和虚拟化工具。它可以模拟多种硬件平台,使得用户能够在一种架构上运行另一种架构的软件,例如在x86系统上运行ARM软件。QEMU主要有两种模式:用户模式仿真和系统模式仿真。以下是对QEMU的详细介绍:
QEMU的主要功能
- 用户模式仿真:允许在一种架构上运行另一种架构的用户空间程序。例如,可以在x86架构的计算机上运行ARM架构的程序。通常用于跨平台开发和调试。
- 系统模式仿真:可以模拟整个计算机系统,包括CPU、内存、硬盘、网络接口等。这种模式下,QEMU可以运行完整的操作系统,如Linux、Windows等。常用于虚拟化环境和操作系统开发.
于是可以有:
- QEMU启动,指定虚拟机的配置信息,模拟出一个计算机系统所需的组件.
- 根据QEMU中的配置,加载BIOS或UEFI固件.
固件是什么?
固件是当计算机系统硬件运行时 或 虚拟环境启动时,所自动执行的代码
它启动初始化各计算机设备 并加载计算机系统本身的引导程序(GRUB)
BIOS 和 UEFI 是什么?
BasicInputOutputSystem & Unified Extensible Firmware Interface
见名知义 两者是不同规范的固件接口
不同配置的计算机硬件接口不一样 从而产生不同标准的固件规范
BIOS主要支持16位 UEFI可支持32位或64位 但一般现代计算机也兼容BIOS
在计算机实际启动的过程中,两者的工作流程也并不完全一样.
(此处先忽略两者工作流程的异同,仅大致描述运行机制)
- BIOS或UEFI进行内存,CPU的初始化工作. 扫描可引导的设备和分区(跟操作系统没有直接关系)
- BIOS或UEFI加载GRUB引导程序
什么是GRUB?
GRUB是在计算机启动的时候 给用户的一个引导加载程序
在启动时 若有多个操作系统 用户可以选择登录进任意一个
这便是GRUB的功劳
回顾一下以上的多个步骤 目前已经初始化了硬盘等设备的相关信息.并且成功加载了引导程序.于是有:
- GRUB读取配置文件,得到可执行的操作系统的列表,给予用户进行选择.(注意,这一过程本质上和上方BIOS或UEFI初始化 没有任何关系)
- GRUB加载所选操作系统内核,启动
在这过程中,还有很多细节可以进行细究.
- 譬如说GRUB并不是一个单独实体,而是一个分阶段运行的引导加载程序,只是他们在启动的不同部分都起到了引导的作用
GRUB的启动过程
- BIOS初始化和MBR加载
- BIOS执行POST,初始化硬件。
- BIOS从启动设备的MBR加载并执行GRUB的Stage 1代码。
- Stage 1加载Stage 1.5或Stage 2
- Stage 1代码执行,从磁盘空闲区域加载Stage 1.5代码(如果存在)。
- Stage 1.5提供文件系统支持,从文件系统中加载Stage 2。
- 如果没有Stage 1.5,Stage 1直接加载Stage 2。
- Stage 2执行
- Stage 2代码被加载到内存中并执行。
- Stage 2读取配置文件,显示引导菜单。
- 用户选择操作系统或进入命令行模式。
- GRUB加载操作系统内核和初始内存盘,设置内核参数。
- 转移控制权
- GRUB将控制权转移给操作系统内核,内核开始执行,完成系统启动。
譬如说UEFI和BIOS的具体工作流程其实是有差别的
譬如说在GRUB加载时,会将CPU的控制权由GRUB本身转移到操作系统内核(Linux)等——CPU控制权限在启动系统时的转移流程
以上两个问题其实可以综合进行讨论
控制权的定义和转移过程
**控制权(Control)**指的是计算机系统中由哪个程序或代码块在某一时刻掌握CPU的执行权,控制系统的运行流程。当我们讨论启动过程中的控制权转移时,指的是从一个阶段的代码执行完毕后,将CPU的执行权交给下一个阶段的代码。
控制权转移的具体过程
1. 固件初始化(BIOS或UEFI)
- BIOS模式:
- BIOS初始化:BIOS执行POST(Power-On Self-Test),初始化硬件并检测启动设备。
- 加载MBR:BIOS从启动设备的MBR(主引导记录)加载并执行引导代码。此时,控制权从BIOS转移到MBR中的引导代码。
- UEFI模式:
- UEFI初始化:UEFI固件初始化硬件,执行POST。
- 加载EFI应用程序:UEFI从EFI系统分区(ESP)加载GRUB的EFI应用程序。控制权从UEFI固件转移到GRUB的EFI应用程序。
2. GRUB引导加载程序
- 加载和执行GRUB:
- BIOS模式下的GRUB:
- Stage 1:MBR中的引导代码加载GRUB的Stage 1代码。
- Stage 1.5(如果存在):Stage 1代码加载Stage 1.5代码,提供文件系统支持。
- Stage 2:Stage 1或Stage 1.5加载GRUB的Stage 2代码。此时,控制权转移到GRUB的Stage 2。
- UEFI模式下的GRUB:
- GRUB EFI应用程序:UEFI固件加载并执行GRUB的EFI应用程序。此时,控制权转移到GRUB的EFI应用程序。
- BIOS模式下的GRUB:
- GRUB执行:
- GRUB选择操作系统内核和初始内存盘并进行加载
3. 操作系统内核启动
- 加载操作系统内核:
- GRUB将操作系统内核和初始内存盘加载到内存中,设置内核参数。
- 转移控制权到内核:GRUB完成内核加载后,将CPU的执行权交给操作系统内核的入口点。此时,控制权从GRUB转移到操作系统内核。
- 操作系统内核初始化:
- 操作系统内核开始执行,初始化硬件和系统资源,启动内核服务。
- 内核完成初始化后,启动用户空间的进程和服务。
具体的控制权转移点
- BIOS或UEFI初始化完成后:
- BIOS模式:BIOS加载并执行MBR中的引导代码,控制权转移到MBR中的GRUB Stage 1代码。
- UEFI模式:UEFI加载并执行GRUB的EFI应用程序,控制权转移到GRUB的EFI应用程序。
- GRUB加载并执行:
- GRUB读取配置文件,显示引导菜单,加载操作系统内核。
- GRUB将操作系统内核加载到内存中,控制权转移到操作系统内核。
- 操作系统内核启动:
- 内核接管控制权,初始化硬件和系统资源,启动用户空间的进程。
其实就是 BIOS/UEFI -> GRUB -> 操作系统内核
什么是MBR?😂
主引导记录(扇区)
- MBR的定义:
- 主引导记录(Master Boot Record, MBR)是硬盘上的第一个扇区(通常是512字节),它包含引导加载程序和分区表。MBR的主要作用是在系统启动时引导操作系统。
- MBR的组成:
- 引导代码(Boot Code):前446字节,包含用于启动引导加载程序的代码。
- 分区表(Partition Table):64字节,包含硬盘上最多四个主分区的分区信息。
- 签名(Signature):最后2字节,通常是0x55和0xAA,用于标识MBR的有效性。
- MBR的作用:
- 引导系统:在计算机启动时,BIOS加载MBR并执行其中的引导代码,引导代码负责定位和加载引导加载程序(如GRUB)。
- 管理分区:MBR中的分区表记录了硬盘上各个分区的起始位置和大小。
以上GPT 一般来说 只需要知道MBR确定了 文件的信息 以及合法性即可
数学视角的操作系统
程序的正确性 & 数学严格
依然是从状态机入手,程序是一种“数学严格”的对象
本质上:程序 = 初始状态 + 迁移函数
$$
f(s)=s′
$$
程序运行的每一步逻辑修改 都可以看作是执行特定函数之后的变化
ps:jyy老师此处的引入论述非常有意思
人类本身不擅长数学严格
于是创建了程序 程序本身是数学严格的
程序辅助人类进行数学严格的工作
使人类世界更好实现数学严格的需求
也因此 初学者对“机器严格”普遍不太适应
他们并不擅长程序的编写 对程序的行为没有100%的掌控
相似的 我们也可以使用证明数学正确性的方法来证明程序的正确性 不在此处展开说明
- 暴力枚举
- 写出证明
操作系统建模
以两个视角看待操作系统:
应用视角(自顶向下):系统调用服务的提供者
操作系统 = 对象 + API
应用通过 syscall访问操作系统
机器视角(自底向上):运行的一个程序
从硬件的视角看 所有的程序本质上都是一样的
在之前的分析中说到 程序本身就是一个状态机。而操作系统本身也是一个状态机(操作系统本身也是一个程序),程序是运行在操作系统上的。在这个视角下,操作系统 = 状态机的管理者
而程序的行为也已分析过,其本质上就是数据的IO。站在系统调用的角度上,即是提供给程序 Read & Write 的API。但是仅此还不够,操作系统作为一个管理者,具有创建或销毁指定的线程的能力。在多核的情况下,也具有上下文切换,调度等能力
当以一个状态机的视角来理解操作系统。无非就分为三点:状态、初始状态、迁移
- 初始状态:仅有一个”main”状态机,此状态机就是操作系统这一程序本身的初始状态
- 状态:运行多个不同执行阶段的程序
- 迁移:选择一个状态机进行程序的运行(其实就是调度)
但是在计算机系统运行中,存在不确定性
调度:操作系统会根据某种策略自行选择下次执行的状态机。
IO:执行程序时的输入不确定
但是如果程序的正确性得到了保证。我们就可以模拟出程序运行时所有的不确定性情况,并将其构建为一张图(模型)。图中的每一个节点,记载此时操作系统的状态。每一边,记载到达此状态的流程(a -> b)。而我们更可以利用BFS等算法,就可以轻松获得状态机到达某个状态的路线(情况 & 缘由)。
太妙了
按照这一思路,我们理论上可以创建任何操作系统的模型。
模块 | 系统调用 | 行为 |
---|---|---|
基础 | choose(xs) | 返回一个 xs 中的随机的选择 |
基础 | write(s) | 向调试终端输出字符串 s |
并发 | spawn(fn) | 创建从 fn 开始执行的线程 |
并发 | sched() | 切换到随机的线程/进程执行 |
虚拟化 | fork() | 创建当前状态机的完整复制 |
持久化 | bread(k) | 读取虚拟磁盘块 k 的数据 |
持久化 | bwrite(k, v) | 向虚拟磁盘块 k 写入数据 v |
持久化 | sync() | 将所有向虚拟磁盘的数据写入落盘 |
持久化 | crash() | 模拟系统崩溃 |
信号量with并发同步
使用锁来进行并发同步的控制
假设现在有多个线程,我需要等待一个时机,等他们都执行之后,执行同一个同步的操作
一个方案是可以利用锁作为信号量来实现
Acquire-Release实现计算图
- 为每一条边e=(u,v)分配一个互斥锁日
- 初始时,全部处于锁定状态 ·对于一个节点,它需要获得所有入边的锁才能继续 。
- 可以直接计算的节点立即开始计算
- 计算完成后,释放所有出边对应的锁
初始条件:
- 没有入边的节点(即没有依赖的节点)可以直接开始计算,因为它们没有需要等待的锁。
获取锁:
- 一个节点要开始计算,必须先获取所有入边的锁。
- 只有当这些锁都被成功获取,节点才能开始执行其任务。
释放锁:
- 节点完成计算后,会释放它所有出边的锁。
- 释放锁后,其他依赖该节点的任务就可以尝试获取这些锁,进而开始自己的计算。
本质上就是 给这个节点发送一个信号 这个信号的体现就是互斥锁 如果成功获取到了这把锁 意味着此时是可以进行同步操作的
跟普通的信号机制相比的话 是不是保证了及时性和正确性 但是他的是比较笨重的是吗? 因为锁本身需要耗费一定的资源
条件变量 vs. 信号量
条件变量本质上就是量化边界共享资源的一条等式
信号量本质上是对共享资源“可用性”的计数器或余额的一条等式。
一般使用条件变量的场景是 需要对同一个资源进行修改
而信号量则是对多个共享资源的一个调度
特性 | 条件变量 | 信号量 |
---|---|---|
关注点 | 资源的状态(是否满足某条件) | 资源的数量(是否有可用资源) |
等待机制 | 线程等待某个条件成立时才能继续 | 线程等待资源可用时才能继续 |
适用资源范围 | 单个共享资源 | 多个共享资源 |
通知机制 | 必须显式 signal 或 broadcast 来唤醒等待线程 |
信号量值会自动影响线程的阻塞和唤醒 |
记录信号 | 不记录,signal 只有在有线程等待时生效 |
记录信号,未消耗的信号量会累计 |
典型用途 | 状态同步、线程间依赖协调 | 资源调度、限流、并发控制 |
两者在实现上没有本质的依赖 但是可以依靠对方进行实现
对于哲学家问题,用信号量实现确实较为困难,主要是因为哲学家问题的本质是共享资源的争用问题,而不是典型的资源调度问题。
相比信号量,条件变量更适合哲学家问题,因为条件变量可以灵活表达“资源争用”的状态条件。
虚拟化
如何理解创建进程?
进程本身是一个独立的状态机,而这个状态机会经过初始化,服务,销毁等过程(可以结合生命周期函数进行思考)。
而在操作系统中,有三个函数,完整构建了这整个过程。
fork 创建一个新的进程,并且创建的进程就是本身的父进程
execve 传入一个进程,并将当前的进程修改为目标的这个进程
exit 退出的函数,退出某个进程
完整的回顾以上流程,可以思考到当创建一个新的进程的时候,首先是父进程通过fork创建同样的子进程,根据需要传入所谓的命令行参数和环境变量,然后使用execve函数,将其修改为所需的特定子程序(不同于父进程的,服务业务所需要的子进程)。最后在运行之后,通过exit退出并销毁这个函数的运行。
exit可以注册钩子等 。
win上的可执行文件,本质上就是调用execve时,所传入的文件形式。换句话来说,他是你所想要运行的这个子程序(状态机)的初始化状态。不严谨的说,甚至可以类比为CPU reset
当程序执行一个新的进程的时候,首先创建这一个进程。然后站在操作系统的 视角,他不知道应该加载的是什么资源,而可执行文件告诉了他答案,规定了他接下来要加载的资源的路径和位置等
initramfs和Linux与其内核(Linux加载的全过程)
所谓的操作系统的加载过程:
首先会有一层硬件,这层硬件加载了内核资源,提供了许多系统调用的API。(OS+API)
在此基础上我们可以对这些API进行封装,一个经典的体现是lib.c
他将系统调用的常用行为进行封装,成为c语言的标准库
在此基础上,我们会对其进行二次封装,体现则有Shell
等,通过对库函数的有机组合,实习了一些常用命令,并且这些命令间也可以有机组合(command with chan)
命令行的功能很强大,但是由于他的不易读性和门槛,开发出了GUI界面。
这可以概括为整个计算机系统的发展路线
而在加载操作系统这一步骤的时候也有很多操作细节
当某个Linux想要启动的时候,首先会执行一个初始化脚本(initramfs),这个初始化脚本调动了内核(硬件)的资源,
启动了内核中相关的代码(firmware)。
这个启动脚本会规定使Linux运行起来的一些基本配置,例如说挂载的磁盘,处理器等信息。它本身是一个独立的处理脚本,通过调度内核中部分的代码,使内核运行时能准确挂载到对应的磁盘,处理器等。而在内核成功启动的时候,此时已经划分好她所使用的磁盘等资源了。
综合理解一下,可以理解成内核首先提供了与硬件交互的API和强大能力,而initramfs则是内核的调度器。两者一同为Linux的启动,提供了一个运行环境。
而在成功启动这个运行环境(成功运行起Linux)之后,initramfs会将这个执行的权限交给systemd,这也是为什么如果用pstree等命令想要查看进程信息的话,他们的父进程总是systemd
那么,initramfs和内核是如何将对应的挂载信息告诉linux操作系统的呢? 可以理解为:本身Linux内核就是一个小型的操作系统,但是他没有文件系统,没有任何的外设,所有的信息都是存在内存当中的。而initramfs中有一个操作(pivot_root),在磁盘等信息注册好之后,将挂载点信息由原来的内核迁移到注册好的磁盘镜像当中。如何迁移?记录好fork的镜像的路径信息,直接更换对应的路径点(switch_root)
链接 & 加载 4 ELF
ELF文件是Linux上的可执行文件格式 可类比成windows上的exe
对于ELF文件,它本身就是一个软件的所有元信息和实际信息,会有一个4kb(4096)的头介绍该文件的元信息,eg大小长度等
静态链接
此处以C为例子,当将一个C的工程项目编译为可执行文件的时候,会经过多个环节。
这多个环节分别是 预编译,编译,汇编,链接,重定向
- 预编译:将代码中含有的宏先全部替换
- 编译:将代码编译成为汇编语言
- 汇编:将汇编代码进一步翻译成为机器码
- 链接:在我们写工程项目的时候,一般都会有多个文件,而我们生成的一般只有一个可执行文件。所以我们需要将每个文件的代码整合起来,链接到同一个文件之内
- 重定向:整合之前,有某些变量的声明和使用并不在同一个文件内,因此,站在单个文件的角度,我们是无法找到它的声明语句的,所以我们会先将其标记为类似
?
。而在文件整合之后,我们就可以找到每一个变量的声明语句了(如果语法合法的话),我们就可以将这个?
替换为它实际声明处的地址空间。
而在重定向结束之后,我们就由单纯的代码生成了一个可执行文件(ELF)。
我们可以回顾一下每一步中的细节:代码首先会先将宏进行替换,替换宏之后编译为机器代码没什么好说的。而静态链接的特性是,将使用到的所有外部库都一同加载进来(在链接这一步中)。相当于说,在运行中,她所接触到的所有资源都是静态的,无需进行其他处理,突出一个处理速度快。
而在重定向所有操作执行完后,整个ELF就变成了一个二进制字节序列化,在他的头部,他会记载_start函数的位置(c语言程序的真正入口),当该可执行文件被执行的时候,他会根据这个位置,去地址空间中找到函数并进行执行。
执行的时候,之前说过创建进程的本质是fork() -> execuve()
,此处创建了子进程。而执行execuve()的主体是子进程,子进程通过这个ELF文件中记录的初始状态,会到虚拟内存中申请对应的空间(mmap),申请的空间包括argc[],argv[]
,即应用的执行参数与所需环境变量。首先会把两类数据在栈中存放,在下方放入程序需执行的实际申请所需内存空间。至此,一个新的应用程序执行所需的所有条件和资源都满足了。
注意 所谓的静态链接 就是单个程序打包时,把它所接触到的所以包都一起打包 所以打出的包会特别大,非常占用空间。
并且不同的程序间不能对他进行服用,每一个程序都需要对同样的共同包保留一个副本
所以就衍生出了动态链接
动态链接
大白话目的就是给通用的包都抽取出来 工程化复用
数据在我们的磁盘中的存储并不是连续平坦的,但是我们结合动态链接,可以呈现给操作系统一个相对平坦的存储表象。
假如CPU当前下那个要取得某一个数据,或者是某个程序执行的时候需要用到哪一个库,并且给出地址空间的时候。操作系统会将其翻译为真正的存储位置。(呈现的地址和实际存储的地址间存在映射关系)
https://www.cnblogs.com/pannengzhi/p/2018-04-09-about-got-plt.html
对于静态链接,我们会在提前规定好每一个函数相关代码的地址,并且可以直接根据这个地址跳转到对应的函数中进行执行。
但是会造成空间浪费,可维护性差等缺点。
而对于动态链接,我们相对应的,存储这些函数相关代码位置的会先置为NULL,当要用了再去查。本质上是一种懒加载的思想。
对于静态链接,我们的环境变量、运行参数和可执行文件间是紧凑的
而对于动态链接,在环境变量和运行参数底下,我们会预留一个8kb大小的空间,用于存放上方所说的相关代码映射信息。而在这8kb底下才是真正可执行文件的相关代码。
假设现在有一个main.c其中调用了hello方法,hello中调用了putchar,三者分别在不同的包中。
初始状态下,每个不同包方法中的地址空间是NULL,此时会让PLT去GOT中查询是否有对应的地址空间。如果没有的话,GOT表就需要通过动态链接器的符号解析函数(_dl_runtime_resolve
)查询该函数真正所在的地址。查询到了在自己(GOT表)中进行存储,并且将这个结果重新返回PLT。PLT将原为NULL的地址空间,修改为查询到的,函数真实所在的地址空间处。在第二次查询的时候,假如判断两个函数相同,则可以直接从GOT处进行查询。返回结果,减少解析次数。
对于重载等情况,ELF文件会有一个自己的机制(类似校验和),判断函数是否相同。
而对于上方若三者分别在不同的包中,则会递归进行解析和查询。
Q:也就是说 GOT的作用只有存储和解析 而真正是PLT给GOT发布解析任务
ChatGPT 说:
是的,GOT(Global Offset Table) 的主要作用确实是 存储符号解析的结果,而 PLT(Procedure Linkage Table) 的职责是 协调符号解析任务的触发和管理。可以把它们的工作关系理解为:
- GOT:是一个记录本,存储符号的解析结果(例如函数地址)。
- PLT:是一个调度员,负责触发符号解析任务,并在解析完成后通知 GOT 去记录结果。
所以综上,个人理解本质上就是将GOT作为一个存储介质kv,PLT在其中查询是否有值,若无则进行遍历查询。
实现了一个懒加载的效果,减少了程序的大小,减少模块依赖升级时的维护难度。
在实际应用中,也是会多个程序共享同一份lib的副本。
他实现了一层抽象,当外部系统调用查找的时候,不能知道真实的地址,而这个真实的地址,就是通过GOT进行解析和映射,通过PLT调度去寻找的。
虚拟地址空间
通过虚拟内存映射物理内存的一种方式。类似上方的动态链接的思维。
动态链接指的是通过导入外部运行库,动态指定对应代码所在的地址空间。
可以先举个例子说明,游戏的时候或者开启很多进程的时候,可以看到某个磁盘的空间会变少。(默认是C盘,本质上是虚拟内存的挂载盘)。
这就是虚拟内存在发力。上方提到过,当启动可执行文件的时候,并不是直接将整个可执行文件进行加载。而没加载的部分,则会记录一个虚拟地址。注意,不同的程序间,这个所谓的虚拟地址可能是相同的。到需要调用该地址空间的函数位置,会通过该虚拟地址,计算出一个真实地址。
这个计算的过程会结合当前程序的pid,虚拟地址信息等进行计算并会在一个类似b数的数据结构中查询。
这个数据结构包含页目录索引、页表项、页偏移量
此处以32位为例,32位中一个指针为4字节。
而32位中,单个页的大小为4096字节。根据指针分的话,每个指针一叉,可以分为1024叉树。
并且通过首层页目录索引,查询到对应索引下的真实页位置。
通过这个页位置加上原有的偏移量,就可以计算出单个地址空间的真实位置。
而对于64位,64位中一个指针为8字节。即2的9次方
可以分为9 9 9 12的结构,又因为它的寻址空间足够充足,所以可以分为多层目录索引。此处则一般为三层页目录索引和单层页内值
不作详细描述
注意上述所说的这些表,包括什么页目录索引,业内查询,都是以进程为单位的。
只需要通过保证该虚拟地址映射出的物理的地址是唯一的即可
也就是说,同一个进程共享同一个pid,共享同一个页表的前提下。
虚拟内存映射是一致的。对于某一个虚拟内存地址,他们所映射的物理地址是唯一的。
有了这个机制之后,我们就可以将内存映射到磁盘,什么SSD,机械硬盘本质上都是可以的。
这也是为什么电脑上虽然只有16 32GB的内存,但是可以同时可以运行起来百来个进程。
涉及到这一部分的话还有缓存等机制。
而当某些数据不会再被用到时,则会被淘汰掉,这就是所谓的页面置换算法
而为了进行映射 我们需要通过api mmap将文件从虚拟空间映射到物理内存。
中断 with 操作系统内核
在之前,我们知道操作系统经常会活用不同存储介质中的特性,贯彻缓存热点数据读写速度快的思想。
而对于具体执行调度程序的时候,CPU也会这么做。假如现在有单个处理器,但是多个进程运行。会出现抢占CPU的情况
在之前实现过的用户级协程之中,我们是调用了yield()的方法,让线程主动交出控制权。
但是操作系统是利用中断完成的(上下文切换)。
首先进程的状态可以大致分为就绪运行阻塞
假如某一刻程序上一个运行的进程由于时间片用完了等原因修改了状态。此时处理器会根据调度策略选出一个进程,将原运行的进程的相关信息都封存到寄存器当中。并且将接下来要执行的进程的资源加载到寄存器当中。运行。运行完后,封存它的状态,依此类推。
这只是单核抢占CPU,抢占成功后会发生的流程。
结合前面所说的虚拟地址空间是以进程为单位,单个线程内资源共享的事实
引入一个CR3寄存器,可以理解为它所存储的是CPU当前状态下 虚拟空间与物理空间间的映射关系
*CopyOnWrite
当我们同时开启多个同一可执行文件的时候,按照未优化前的操作系统,会一直创建不同的进程。
但是操作系统做了优化, 在启动的时候,假如某个程序的大致上不会变。可以将两个程序的都设置为读原有的父进程(旧副本)
而对于新版本,只需要将写的部分独立出来,保证读取的时候读取到新版本就可以了。
这样就可以节省一大部分交集旧版本内存
这里所说的是以可执行文件为单位,也可以作为思路拓宽成为线程等等…
Copy-on-Write(COW)的基本概念:
在COW机制下,当多个进程或线程需要访问同一块内存区域时,系统并不会立即为每个进程创建一份独立的内存副本。相反,操作系统会共享相同的内存页,直到某个进程尝试修改这块内存,才会为该进程创建一份数据的副本,这时才进行实际的拷贝操作。