操作系统概述
1940s 的计算机(ENIAC):
- 逻辑门:真空电子管
- 延迟线内存通过 “抛球” 来扩大存储空间
- 没有操作系统!只需要执行程序即可
1950s:
- 磁芯内存
- 可以执行更复杂的任务,希望调用 API 而不是直接访问设备
- 在只有一个 CPU 的情况下,需要管理多个程序执行
1960s:
- 内存更大,意味着可以放多个程序到内存里
- 需要操作系统介入,实现多程序同时运行
- 还需要硬件变化,在多个地址隔离的程序间切换
1970s+:
- 基本现代化
程序和编译器
状态机:数字逻辑电路的模拟器
-
寄存器存储 + 运行逻辑 + 按照时间周期运行 –> 状态转换
-
程序就是状态机!
程序的状态有什么?
- 变量?全局 or 局部?
- 函数调用、函数返回是什么? –> 栈帧的处理,调用是PUSH,返回是 POP
程序 = 计算 + syscall
- 加上了系统调用,将操作权交给系统。
编译器
- 优化可优化的部分,转义不可优化的部分
状态机的初始状态(lib-64-linux/…)等等都有定义,都建立在确定的基础上。
gdb、strace、binutils 追踪
并发
多处理器编程
原子性
并发的基础:多线程
- 线程共享内存、具有独立堆栈
- 如何验证? –> strace……
带来的问题:
- 多线程共用变量时,发生冲突
- 基本假设不再成立:程序不再独占处理器执行
解决:实现原子性
- 将大人物切分为可以并行的小任务
- 用 worker thread 去锁保护的队列里取任务
顺序
编译器默认程序时顺序执行的,以进行优化;但是多线程时顺序不一定成立
可见性
处理器同时也是编译器,能够同时执行一个线程的指令
- 即便是汇编,也可能产生步骤乱序执行
需要放弃对旧的“程序”的理解
理解并发程序执行
Peterson 算法
举旗,两个共享变量实现并发?

- 用贴对方的标签来竞争
如何证明正确性?
- 画状态机?枚举所有可能性
- 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 文件里声明的内存和一些操作系统分配的内存
- 任何其他指针的访问都是非法的
- 所以需要能够动态分配!
- Binary 中指定的 PT_LOAD 段
进程的地址空间管理
-
在状态机状态上增加/删除/修改一段可访问的内存
-
MAP_ANONYMOUS: 匿名 (申请) 内存
-
fd: 把文件 “搬到” 进程地址空间中 (例子:加载器)
-
|
|
-
可以用 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 - 管道:
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)解决
-
-
自己设计可执行文件?
满足回归链接和加载中的核心概念:代码、符号、重定位,就可以了
生成可执行文件地过程
-
源代码 (.c) → 源代码 (.i)
-
Ctrl-C & Ctrl-V (#include)
-
字符串替换
-
今天:我们有过程宏
-
-
源代码 (.i) → 汇编代码 (.s)
-
“高级状态机” 到 “低级状态机” 的翻译
-
最终生成带标注的指令序列
-
汇编代码 (.s) → 目标文件 (.o)
- 文件 = sections (.text, .data, .rodata.str.1, …)
- 对于 ELF,每个 section 有它的权限、内存对齐等信息
- section 中的三要素
- 代码 (字节序列)
- 符号:标记 “当前” 的位置
- 重定位:暂时不能确定的数值 (链接时确定)
- 文件 = sections (.text, .data, .rodata.str.1, …)
-
-
多个目标文件 (.o) → 可执行文件 (a.out)
- 合并所有的 sections
- 分别合并 .text, .data, .bss 中的代码
- 把 sections “平铺” 成字节序列
- 确定所有符号的位置
- 解析全部重定位
- 得到一个可执行文件
- 合并所有的 sections
-
最后,把字节序列搬到内存,执行
操作系统和加载器
execve(path, argv, envp)
- 操作系统内核解析 path、完成加载
#注释的作用 -> 指示解释器路径