操作系统

操作系统的学习笔记

参考:https://jyywiki.cn/OS/

操作系统概述

1940s 的计算机(ENIAC):

  • 逻辑门:真空电子管
  • 延迟线内存通过 “抛球” 来扩大存储空间
  • 没有操作系统!只需要执行程序即可

1950s:

  • 磁芯内存
  • 可以执行更复杂的任务,希望调用 API 而不是直接访问设备
  • 在只有一个 CPU 的情况下,需要管理多个程序执行

1960s:

  • 内存更大,意味着可以放多个程序到内存里
  • 需要操作系统介入,实现多程序同时运行
  • 还需要硬件变化,在多个地址隔离的程序间切换

1970s+:

  • 基本现代化

程序和编译器

状态机:数字逻辑电路的模拟器

  • 寄存器存储 + 运行逻辑 + 按照时间周期运行 –> 状态转换

  • 程序就是状态机!

程序的状态有什么?

  • 变量?全局 or 局部?
  • 函数调用、函数返回是什么? –> 栈帧的处理,调用是PUSH,返回是 POP

程序 = 计算 + syscall

  • 加上了系统调用,将操作权交给系统。

编译器

  • 优化可优化的部分,转义不可优化的部分

状态机的初始状态(lib-64-linux/…)等等都有定义,都建立在确定的基础上。

gdb、strace、binutils 追踪

并发

多处理器编程

原子性

并发的基础:多线程

  • 线程共享内存、具有独立堆栈
  • 如何验证? –> strace……

带来的问题:

  • 多线程共用变量时,发生冲突
  • 基本假设不再成立:程序不再独占处理器执行

解决:实现原子性

  • 将大人物切分为可以并行的小任务
  • 用 worker thread 去锁保护的队列里取任务

顺序

编译器默认程序时顺序执行的,以进行优化;但是多线程时顺序不一定成立

可见性

处理器同时也是编译器,能够同时执行一个线程的指令

  • 即便是汇编,也可能产生步骤乱序执行

需要放弃对旧的“程序”的理解

理解并发程序执行

Peterson 算法

举旗,两个共享变量实现并发?

image-20251107184802806

  • 用贴对方的标签来竞争

如何证明正确性?

  • 画状态机?枚举所有可能性
  • PC 的含义 –> 指令的步骤++
  • 用 python 来检测!选对语言

py 的特性,yield

只要能位系统建立模型,就能证明正确 / 找到错误

将问题转化为图论问题

在多处理器上实现线程隔离

虚拟化

程序和进程

  • 程序是状态机的静态描述
    • 描述了所有可能的程序状态
    • 程序 (动态) 运行起来,就成了进程 (进行中的程序)
    • 可以使用文件 API (“everything is a file”) 访问当前进程的 ID、状态信息、命令行参数和工作目录等元数据。

进程(状态机)管理

操作系统 = 状态机的管理者

  • 创建状态机:spawn(path, argv)
  • 销毁状态机: _exit()
  • 复制状态机: fork()
    • 完整复制状态机;新创建的状态机返回值为 0,执行 fork() 的进程会返回子进程的进程号。
    • pritf 的输出机制,输出到缓冲区
  • 复位状态机: execve()
    • 参数 path、argv、evnp

windows 与 Unix 管理进程的 API 不同

进程的地址空间

进程的初始状态

  • 寄存器

    • ABI 中规定了 initial state
    • 只规定了部分寄存器和栈 (argv 和 envp 中的字符串保存在栈中)
  • 内存

    • Binary 中指定的 PT_LOAD 段

      • 内存是分成 “一段一段” 的
      • 每一段有访问权限 (rwx)
    • 只有

      ELF 文件里声明的

      内存和一些操作系统分配的内存

      • 任何其他指针的访问都是非法的
      • 所以需要能够动态分配!

进程的地址空间管理

  • 在状态机状态上增加/删除/修改一段可访问的内存
    • MAP_ANONYMOUS: 匿名 (申请) 内存
    • fd: 把文件 “搬到” 进程地址空间中 (例子:加载器)
1
2
3
4
5
6
7
// 映射
void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);
int munmap(void *addr, size_t length);

// 修改映射权限
int mprotect(void *addr, size_t length, int prot);
  • 可以用 pmap 命令查看进程的地址空间
  • 瞬间完成内存分配
    • mmap/munmap 为 malloc/free 提供了机制
    • libc 的大 malloc 会直接调用一次 mmap 实现

pmap 命令通过读取 /proc/[pid]/maps/proc/[pid]/smaps 文件来获取进程的内存映射信息。Linux 内核将这些信息以文本形式暴露在 /proc 文件系统中,pmap 通过解析这些文件实现功能,而无需直接调用特定的系统调用。

入侵地址空间

  • gdb 的实现原理,可以任意观测、修改程序状态

小时候用的游戏修改器,需要多次读取数据,筛选后修改。原理就是监控游戏的整块内存。

访问操作系统对象

先回顾一下操作系统创建的流程:一开始只有一个进程,然后通过 fork 产生出子进程,接着用 execve 来替换子进程为目标进程,再用 mmap 来分配空间

文件描述符

  • 是指向操作系统对象的 “指针”——系统调用通过这个指针 (fd) 确定进程希望访问操作系统中的哪个对象。我们有 open, close, read/write, lseek, dup 管理文件描述符。
    • 对象的访问都需要指针
      • open, close, read/write (解引用), lseek (指针内赋值/运算), dup (指针间赋值)
    • 在 fork 的时候,文件描述符继承吗?
      • 继承(UNIX中),方便但是危险
  • “文件”:有名字的数据对象,Everything is a file

操作系统中的对象

  • 真实的设备
    • /dev/sda
  • 虚拟的设备(文件)
    • /dev/urandom,/dev/null,并没有实际文件,但是操作系统为它们实现了特别的read、write操作
  • 管道:特殊的文件流
    • 读口支持 read,写口支持 write

整合起来,就是所有的基础知识:

进程管理

  • fork, execve, waitpid, exit

内存管理

  • mmap, munmap, mprotect, msync

文件管理

  • open, close, read, write, lseek, dup

一切皆文件的利弊:

  • 一套 API 可以访问所有对象,便捷
    • 一切都可以 | grep
  • 但是涉及多义性、复杂项目下不便、耦合度高、高速设备不友好…

终端和 UNIX shell

终端的历史,其实就是直接控制硬件(打字机、繁重而昂贵的操作系统)的输入 - 输出互动的窗口

今天则用伪终端(Pseudo Terminal)来模拟

用户登录的起点

  • 系统启动 (内核 → init → getty)
  • 远程登录 (sshd → fork → openpty)
    • stdin, stdout, stderr 都会指向分配的终端
  • vscode (fork → openpty)

login 程序继承分配的终端

  • (是的,login 是一个程序)
  • fork() 会继承文件描述符 (指针)
    • 因此,子进程也会指向同一个终端

终端的机制

  • 怎么确定要控制的是那个终端/进程?(比如Ctrl + C)
  • 只管传输字符 –> 由操作系统来决定如何对“当前”进程采取行动
  • 历史遗留问题,局限性:无法预知软件的未来
    • 要做的是进行抽象,不要在把高层“意图”翻译成低层“规程”上花费脑力和时间

Shell编程

  • 基于文本替换的极简编程语言!

    • 预处理: $(), <()

    • 重定向: cmd > file < file 2> /dev/null

    • 顺序结构: cmd1; cmd2, cmd1 && cmd2, cmd1 || cmd2

    • 管道:

      1
      
      cmd1 | cmd2
      
      • 这些命令被翻译成系统调用序列 (open, dup, pipe, fork, execve, waitpid, …)

C 标准库和实现

API 的设计有一个有趣的原则:“非必要不实现” (“机制与策略分离”、“最小完备性原则”):但凡应用程序能自己搞定的功能,操作系统就不需要提供——在一定程度上,这样的设计能防止 “包揽一切” 导致代码膨胀,甚至在长期演化的过程中成为历史的包袱。

  • C 是一种 “高级汇编语言”
    • 作为对比,C++ 更好用,但也更难移植
  • 系统调用的一层 “浅封装”
  • libc
    • 大部分可以用 C 语言本身实现
    • 少部分需要一些 “底层支持”
      • 例子:体系结构相关的内联汇编
  • “最小完备性原则” 和 “机制策略分离” 的反面教材
    • “每一个 malloc 在任何可能路径上都必有一次配对的 free,且之后不再使用”
    • 在复杂系统里太难保证了

脱离 workload 做优化就是耍流氓

  • 在开始考虑性能之前,理解你需要考虑什么样的性能

可执行文件

是什么

ELF 是 Executable and Linkable Format 的缩写,是 Linux 系统上的一种可执行文件格式。

  • 一个操作系统中的对象 (文件)
  • 一个字节序列 (我们可以把它当字符串编辑)
  • 一个描述了状态机初始状态的数据结构

需要什么

  • 一个头 (类似 7f 45 4c 46; 这是一个 jg 71)
  • 一段 Trampoline Code (PIC)
    • 操作系统会给一个文件描述符 (指向文件本身)
    • 这段代码直接执行 hardcoded mmap
  • (就能实现 “加载” 的功能)

反思

  • ELF 不是一个人类友好的 “状态机数据结构描述”
    • 为了性能,彻底违背了可读 (“信息局部性”) 原则
    • Crash (SIGSEGV, SIGABRT, SIGILL, …) 时会做 core dump
      • ulimit -c unlimited:启用 Core Dump 的命令(或设一个合理的大小限制),确保程序崩溃时能生成 Core 文件
      • 适合于 production systems
      • 通过设置 core dump size,我们可以在程序发生 core dump 时保存到文件系统,并且在后续使用 gdb 调试它,但是 GDB 无法基于它继续执行
      • 用 CRIU (Checkpoint/Restore In Userspace)解决

自己设计可执行文件?

满足回归链接和加载中的核心概念:代码、符号、重定位,就可以了

生成可执行文件地过程

  1. 源代码 (.c) → 源代码 (.i)
    • Ctrl-C & Ctrl-V (#include)
    • 字符串替换
    • 今天:我们有过程宏
  2. 源代码 (.i) → 汇编代码 (.s)
    • “高级状态机” 到 “低级状态机” 的翻译
    • 最终生成带标注的指令序列
    • 汇编代码 (.s) → 目标文件 (.o)
      • 文件 = sections (.text, .data, .rodata.str.1, …)
        • 对于 ELF,每个 section 有它的权限、内存对齐等信息
      • section 中的三要素
        • 代码 (字节序列)
        • 符号:标记 “当前” 的位置
        • 重定位:暂时不能确定的数值 (链接时确定)
  3. 多个目标文件 (.o) → 可执行文件 (a.out)
    • 合并所有的 sections
      • 分别合并 .text, .data, .bss 中的代码
      • 把 sections “平铺” 成字节序列
      • 确定所有符号的位置
      • 解析全部重定位
    • 得到一个可执行文件
  4. 最后,把字节序列搬到内存,执行

操作系统和加载器

execve(path, argv, envp)

  • 操作系统内核解析 path、完成加载
  • # 注释的作用 -> 指示解释器路径

链接和加载

当开发者希望把库函数和应用程序 “分离” 开,但又希望库函数可以被调用,此时就需要动态链接

动态链接:机制

实现运行库和应用代码分离

  • 应用之间的库共享

    • 每个程序都需要 glibc

    • 但系统里只需要

      一个副本

      就可以了

      • 是的,我们可以用 ldd 命令查看
      • 运行库和应用代码还可以分别独立升级
  • 大型项目的分解

    • 改一行代码不用重新链接 2GB 的文件
    • libjvm.so, libart.so, …共享库文件
  • 只读方式 mmap 同一个文件,物理内存中只有一个副本,分页的机制,使得内存的处理更灵活,无需多个物理副本

问题:怎么判断是否需要链接进来共享库?

  • 动态链接的本质是 “运行时绑定地址”,PLT/GOT 是实现这一逻辑的底层表;环境变量 LD_PRELOAD 则是利用动态链接的加载顺序,实现库的 “运行时替换”(加入本地的库)。
Licensed under Calendar