浅谈 操作系统原理
注 : 文中讲述的原理是推理和探讨 , 和现实中的实现不一定完全相同 。
操作系统 , 主要分为 8 个部分 :
1 引导程序
2 设备驱动
3 控制台
4 进程调度
5 虚拟内存
6 文件系统
7 网络通信
8 编译器
引导程序, 按照现在的 业界标准, 大概是 接通电源 -> BIOS 启动 -> 引导程序 。 引导程序 是 磁盘开头的 一段 字节 存储的 代码 。 BIOS 启动后 就将 控制权交给这段代码, 或者说加载这段代码进入内存, 并执行这段代码 。 引导程序 加载到内存里应该也是存储在 内存的 低位地址 附近, 比如 从 地址为 0 , 或者 1 的 内存单元 开始 存储 。 不过我到现在都有一个疑问, 内存地址 可以使用 “0” 这个地址吗 ? C / C++ / C# 好像都是用 0 来表示 空指针(null) 的 。
引导完了, 要显示一个 界面 给 用户看, 最基本的就是 控制台 。 要显示控制台, 需要操作显示器, 所以 这就是 需要 设备驱动 。
当然, 还需要 键盘 鼠标 的 输入, 最起码要有 键盘 的 输入, 这也是 设备驱动 。
所以 控制台 就是 设备驱动 加上一点小小的 控制程序 就可以啦 。
这就是一个 简单的 小小 操作系统 了 。
看起来跟 Dos 很像 ?
我们再来看看 设备驱动 如何实现,
设备驱动 就是 给 设备发送 指令, 以及 和 设备 的 数据传输 。
CPU 应该有给 设备 发送指令 的 指令 。 据说 设备 会被映射成 寄存器 ? 或者一个 内存地址 ?
CPU 和 设备 之间的 通信 分为 CPU 操作设备 和 设备通知 CPU 。
CPU 操作设备 很简单, 就向 设备 发出指令, 该写数据写数据, 该读数据读数据 就行 。
设备通知 CPU 这个 有点复杂,
比如 鼠标 键盘 网卡 , 这些 交互式 的设备, 以及 和外部通信 的 设备 都会 通知 CPU 。
比如 用户对 鼠标 移动 按键 等, 就会通知 CPU, 网卡 接收到 网络 传输过来的数据,也会通知 CPU ,
由 CPU 对数据做出 处理(响应) 。
这个通知的方式 是 中断, 即 设备 需要 通知 CPU 时, 发起一个 中断, CPU 接收到 中断 会转入 中断处理程序, 接下来就可以对 设备 的数据进行处理 。
中断 是 CPU 硬件实现的一个机制, 所以效率很高 。
我前段时间看过一篇文章, 说 早期 的 CPU 也是没有 中断 的, 那时的 操作系统 是 通过 轮询 的 方式 来 检查 设备 是否有数据(通知 (Announce)) ,
看到这里, 我笑了 。
严格来讲, 转入 中断处理程序 时要保存 当前程序 的 上下文, 所以, 中断处理程序 是一个 进程, 或者说, 转入 中断处理程序 是 跨进程 的 。
而 在 执行 中断处理程序 的 过程中, 如果 又发生了中断 , 怎么办 ?
好像可以 嵌套 执行 中断, 就好像 函数嵌套 一样, 新的 中断 发生, 就 转入 新中断 的 处理程序, 处理完以后, 再回到 原来的 中断 处理程序 继续执行 。
还有就是忽略 中断 中的 中断, 这大概是 级别 比较高的 系统核心 中断 会这么做。
也许还可以有 中断 排队 。
当然 这些 就是 操作系统 要处理 的 逻辑 。
进程调度,
现代操作系统 都是 多进程 多线程 的 架构 。
有的文章说 Linux 里的 线程 是 小进程, 有的文章说 Windows 里是以 线程 为 调度单位 。
不管 小进程 还是 线程, 我们 以 线程 来看好了 。
我们这样来设计 :
系统的 调度 单元 是 线程, 一个进程可以包含多个线程, 最少会有一个线程 。
进程的 动态性 由 线程 来表现, 进程 作为一个 静态的 资源边界 。
这跟 Windows 比较像吧 ?
因为 各个厂商 各个型号 的 设备 的 操作方式 不同, 所以 操作系统 定义一个规范, 可以由 厂商 和 开发者 自己编写 设备驱动程序, 来支持 设备 。
操作系统 只要 和 设备驱动程序 交互就行 。
而 设备驱动程序 的 规范中 一个 重要的部分 就是 上述 的 中断原理 。
当 CPU 接收到 设备发出的 中断后, 转入 中断处理程序, 但并不需要在 中断处理程序 中 进行 具体的 处理逻辑, 中断处理程序 只需要将 负责具体处理逻辑 的 驱动程序 线程 加入 就绪队列 就可以, 这样 驱动程序线程 很快就可以执行, 进行具体的处理了 。 驱动程序线程 平时 是 挂起(Suspend) 的 状态 。
进程作为一个 静态边界, 主要就是内存里的 数据段 代码段 , 广义的说, 还有 线程池 等等 资源 。
线程共用 的 堆 和 每个线程各自 的 栈, 应该都是在 数据段 里吧 ~~ ?
那么 如何来 调度 进程(线程) 呢 ?
我觉得 平均主义 最简单,
对于 就绪队列 里的 线程, 每个分配 1000 纳秒的 时间片, 这样 轮流执行, 这样, 1 秒钟 可以 执行 100万 个 线程 , 当然 每个线程 只能 分到 1 个 时间片 。
如果是 1万 个线程, 那么 每个线程 可以分到 100 个 时间片, 累计时间是 100 微秒 = 0.1 毫秒 。
如果是 1千 个线程, 那么 每个线程 可以分到 1000 个 时间片, 累计时间是 1000 微秒 = 1 毫秒 。
如果是 100 个线程, 那么 每个线程 可以分到 1 万 个 时间片,累计时间是 1万 微秒 = 10 毫秒 。
当然 这是 理论上的, 并没有 把 线程切换 等的 时间花费 算进去 。
大家会问, 对于 不怎么运行的 线程, 平均分配 会不会 被 不怎么运行 的 线程 占用比较多的 时间片, 造成浪费 ?
这是因为 Windows (Linux ?) 有一个 “抢占式多任务” 的 概念 吧, 意思就是 对于 使用时间片 越多 的 线程 就 分配 更多 的 时间片 给它 。
但我觉得这个问题不存在,
不运行的 线程 就 挂起嘛 , 不管 是 Sleep, 还是 挂起, Sleep 也是一种 挂起 。
挂起了 就 不占用 时间片 了 ,所以不存在 浪费 一说 。
对于在 就绪队列 中的 线程, 均等的给予时间片, 保证 实时响应性 。
有一个基本的问题是, 应用程序进程 在 运行时是 占用了 CPU 的, 那么, 由谁来调度进程 ? 应用程序进程 怎么 切换到 其它进程 ?
还是用上面说的 中断 的 方法 。
操作系统 会 在 CPU 里 设置一个 中断, 我们可以称之为 “系统中断”, 可以设定为每隔一个时间片(比如 1000 纳秒) 发起一次 中断,
这是 CPU 自己发出的 中断,
中断后, 转入 系统中断处理 程序, 即 系统中断 进程,
在 系统中断进程 里, 可以进行 进程调度, 根据 调度算法, 系统中断进程 将 CPU 交给 下一个 等待执行的 进程 。
在 Windows 的 任务管理器 里, 可以看到一个 “系统中断” 的 进程, 也许就是我们上面说的 系统中断进程 吧 ~ !
在 系统比较繁忙, 比如 开了比较多的 程序 时, 会看到 任务管理器 里的 “系统中断” 进程 会占用 比较多的 CPU, 可能是 忙于 虚拟内存 的 页 载入载出,
如果是这样的话, Windows 里的 “系统中断” 还包含了 虚拟内存 的 功能 。
接下来说说 虚拟内存,
虚拟内存 里, 页 的大小(Size) 是一个 关键 的 参数 。
页 太大了 不好, 页 太小了 也不好 。
我提议用 线性表 作为页表, 假设有 1M 个 页表项, 每个 页 的大小(Size)是 1M , 这样 虚拟内存 空间可以达到 1M * 1M = 1T ,
如何 ?
页表项 的 内容是 1 当前页 是在 物理内存 还是 在 磁盘页文件, 2 如果在 物理内存, 页 的 物理内存地址, 如果在 磁盘页文件, 页 在 页文件 里的 地址(Position) 。
1T 的 地址空间 大概是用 40位 的 地址 可以表示, 再加上用 一个位 表示 在 物理内存 还是 磁盘页文件, 页表项 可以用 41 位 来表示,
我们可以放宽一点,用 64 位(8 个字节) 来表示,
这样, 1M 个 页表项 就占用 1M * 8 = 8M 的空间, 或者说, 页表 需要占用 8M 的空间 。
也就是说, 8M 的 页表 可以管理 1T 的 虚拟内存 空间 。
线性表 的 优点 是 查找快 。
实际上 页表项 还可以再小一点, 因为 页的大小 是固定的, 所以我们可以用 编号 来表示 页在 物理内存 和 磁盘页文件 中的位置 。
比如
编号 * 1M = 页在 磁盘页文件 中的 位置,
编号 * 1M + 起始地址 = 页在 物理内存 中的位置, 起始地址 是 物理内存 开始用来存储 页 的 地址
这样 页表项 只要有 21 位 就可以了, 20 位 表示 1M 范围内的 编号, 1 位 表示 页 在 物理内存 还是 磁盘页文件 。
但是这样 需要多一个计算的过程,就是 上面说的,
编号 * 1M = 页在 磁盘页文件 中的 位置,
编号 * 1M + 起始地址 = 页在 物理内存 中的位置, 起始地址 是 物理内存 开始用来存储 页 的 地址
要 多一次 计算 才能知道 页在 磁盘页文件 或者 页在 物理内存 中的位置 。
虚拟内存地址 换算成 物理内存地址 的 算法 是, 虚拟地址 / 除以 页的大小(Size) , 商 = 页的序号, 余数 = 地址在 页 里的 偏移量 。
根据 页的序号 在 页表 中 查找 页表项,
因为 页表 是 线性表, 所以根据 页的序号 在 页表中 查找 页表项 相当于 查找 数组 。
找到 页表项 后, 可以知道 页 在 物理内存 还是 磁盘页文件,
如果在 物理内存, 则可以知道 页的 物理地址, 页的物理地址 + 地址在 页 里的 偏移量 = 虚拟地址的换算结果
虚拟地址的换算结果 就是 虚拟地址 对应的 物理地址 。
如果 页 在 磁盘页文件, 则需要 将 页 加载 到 物理内存, 再根据上述算法 将 虚拟地址 转换成 物理地址 。
因为 物理内存 空间有限, 所以 将 页 从 磁盘页文件 载入 物理内存 的 同时, 也会将 页 从 物理内存 移除, 载入 磁盘页文件 。
所以 就存在一个 “命中算法”, 优先载入哪些页, 优先载出哪些页, 使得效率更高 。
当然 常用的留下, 不常用的载出, 这大概是大原则 。
命中算法 其实 随便怎么玩都可以, 不是大问题 。
现在的 虚拟内存 的 地址转换 是在 CPU 的 存储管理部件 中完成的, 也就是硬件完成的, 操作系统 只要设置好 页表 就好 。
我想, 早期 的 虚拟内存 应该是由 操作系统 提供一个 地址转换 的 原语,
编译器 在编译的时候, 对 每次 寻址操作 , 都编译成 先调用 地址转换原语, 将 虚拟地址 转换成 物理地址, 再 用 物理地址 执行 具体操作 。
这是 软件方式 实现的 虚拟地址 转换, 当然 比起 硬件实现 的 方式, 效率比较低 。
这种方式 可能主要 存在于 早期 的 实验室 里 。
文件系统 是 线性表 + 链表 的 经典案例 。
文件 是 连续的 顺序的, 所以, 在 磁盘上,我们也会 连续的 顺序的 来 存储文件 。
但如果 1M 的文件, 磁盘上有 500K 和 600K 这样 2 个不连续 的 空闲空间, 那要怎么存储 ?
当然是把 文件 分为 2 部分, 每部分 500K, 部分 1 存 500K 的 空闲空间, 部分 2 存 600K 的 空闲空间 。
在 部分 1 的 末尾, 会保存一个指针, 指向 部分 2 的 起始地址 。
以此类推, 文件 在 磁盘上的 物理拓扑 是, 一个用 链表 方式 连接起来的 多个线性表 。
这也是 磁盘使用 一段时间后 “磁盘碎片增多, 读写效率变低” 的 原因 。
这一点在 机械硬盘 上 尤为明显 。
这是 文件 的 存储 。
文件系统 还包含 文件 和 目录 表, 用于(根据名字)检索 文件 和 目录 。
文件目录表 通常会在 磁盘 的 开头 划定一块 固定区域 来 保存 。
文件目录表 的 格式 和 这块 固定区域 的 大小 决定了 文件目录表 最多能 管理 多少个 文件 。
这也是通常我们会看到 “xx 文件系统 最多 支持 yy 个文件 , zz 个 目录” 的原因 吧 !
文件目录表 会采用 索引 来 检索 文件 和 目录 。
索引 的 特点 是 检索 的 时间花费 与 文件(目录) 数量无关, 只与 文件(目录) 名字长度 有关 。
这也是 Dos 只支持 8个英文字符 的 文件(目录)名, 而 Windows 支持 很长的 文件(目录)名 的 原因吧 。
有关 索引, 我在 《我发起了一个 .Net 开源数据库项目 SqlNet》 https://www.cnblogs.com/KSongKing/p/9501739.html 一文中有论述 。
网络通信 的 基础 是 网卡驱动, 网卡驱动 也是 设备驱动, 设备驱动 的 部分在上文 简单的 说了 。
网卡驱动 解决了, 网络通信 就简单了,
只要按照 协议 格式 分析数据, 拆包, 将数据 转发 给应用程序 就可以了 。
操作系统 应该提供至少一个 编译器, 比如 C 语言编译器, 这样 开发者 可以在 操作系统 上 编写程序 。
有关 编译器, 请参考我写的另一篇文章 《漫谈编译原理》 https://www.cnblogs.com/KSongKing/p/9683831.html
计算机技术发展到现在, 也是 卷帙浩繁, 是个 大工程 。
不过 从 工程学 的角度来看, 也不复杂,
我们可以盖一座大楼, 就能盖两座大楼, 能盖两座大楼, 就能盖三座大楼, 能盖三座大楼, 就能盖四座大楼, ……
盖十座大楼也是可以的嘛 。