南京大学操作系统课程
硬件视角下的操作系统
状态机一定要有初始状态。在初始状态之后,硬件就是个无情的执行指令的机器。
systemcall的每一次调用都需要陷入内核态,因此会产生调用开销。
在操作系统的API中有一些特殊的指令可以与外界发生交互。可以让内部的程序感知外置的世界。
操作系统会定时中断用户设置的程序。
图中为树莓派和外部程序发生交互的方式。老计算机有reset按钮,按下reset之后会对计算机状态进行重置。
每一个寄存器都存在一个默认状态,intel x86就有这样的手册,其中会展示每个计算器的值。几百页的手册,阅读起来非常的expensive。
每个架构都有不同的Reset状态。ARM 规定了CPU Reset,但没有规定设备计算器的映射
RISC-V对于初始PC无规定,这是为了省电路,软件能做的硬件绝对不管。
在硬件层面没有操作系统的概念。如果有多个处理器,就是每次选一个处理器执行一条命令。见什么指令执行什么指令。
首款的多核商用CPU是2001年IMB发布的。05年后多核CPU才在消费市场被普及。人类处理单核cpu的时间是比多核cpu来的长的。
操作系统启动的时候,操作系统就变成了软件的中断调度器。一旦执行了一行非法的命令,整个状态机就会陷入混乱。
CPU在启动的时候必须指向一个合法的代码,才能执行程序。那么到底是执行了什么代码?代码是谁放在这里的?
答案:系统厂商代码,把一个特殊的存储器memory-map到CPU reset后的代码这段代码出生就有机器完整的控制权。
固件:厂商固定在计算机里的代码,早期固件是ROM,要升级必须换芯片(如今可以直接升级固件了)
最开始的这段代码叫Firmware。运行程序前的计算机系统配置。如CPU电压、内存时序、接口开关。
不严格地说Firmware加载操作系统。
这些配置生效可能要重启计算机。固件会根据用户的输入加载操作系统,比如bios(basic input OutPut system)就是一种Firmware。bios就可以设置各种硬件的状态。Firmware就可以看做一个小的操作系统。 CPU reset后初始化硬件;对接操作系统boot loader。这个系统也需要具备基本的功能,比如上网,不然就不能从网络启动一个操作系统。早期电脑没有键盘甚至不能开机。
BIOS分为legacy 和UEFI
- BIOS :IBM PC 所有设备BIOS中断是由specification的16-bit DOS时代BIOS常驻内存,提供I/O功能
- UEFI(Unified Extensible Firmware interface):提供更丰富的支持,如驱动设备程序:指纹锁、USB蓝牙转接器连接的蓝牙键盘(在BIOS下无法加载)。
早期Firmware通常是只读的。Firmware非常重要,因为出现bug导致死循环就无法进入系统。所以才有固件更新烧成砖的情况。
Intel 430TX芯片组允许写入PROM,有序主板有写保护的条线,但默认可写。
史上影响最大的病毒之一,就是打开写保护,然后写一个死循环。
因此今天的固件更新只允许信任的固件,比如RSA签名机制。
1983年PC-DOS2.0,Firmware会加载磁盘的前512字节到0x7c00,如果这512字节最后是0x55,0xAA就会进行执行。
Firmware通过加载固定位置的引导程序加载MBR,然后MBR再加载硬盘。理论上我们可以看到程序加载的过程。
我们可以写446字节的16-bit代码
- 446=512-2(55 aa)- 64(分区表)
Grand Unified Bootloader的例子
- 扫描磁盘,找到附近的ELF文件头,加载到内存,根据文件系统可能会多一步。
- 这个ELF文件是Grub;弹出熟悉的选择系统窗口
- 加载Linux的内核。
现在的UEFI要复杂一点,不限制512字节,可以写任意的大小。只要在efi分区中放可执行文件,这个可执行文件就可以引导系统启动。MBR有boot文件夹。
数学视角下的操作系统
程序=状态机,every thing is state meachine
gdb 单步执行=状态迁移。程序是一种数学严格的对象f(s)=s'。
当我们想证明一个程序的正确性的时候,理论上可以枚举所有可能的情况,也可以证明程序的正确新。
操作系统也是状态机,操作系统是状态机的管理器,状态机是可以被数学定义的。
包括systemcall也是对于状态机的修改。在陷入操作系统的时候,操作系统会冻结当前状态机的状态。
操作系统是最实用的并发程序,操作系统要保证每个进程只能访问自己的状态机
计算机系统的不确定性,多核处理器的选择是无法控制的,操作系统可以主动随机选择状态机执行一步。
系统外的输入有不确定性,read返回的结果也有俩种可能性。
系统的调度和io以外一切都是确定的
操作系统所有不确定性的唯一来源,程序的所有不确定性都来源于操作系统
程序定义了状态机。状态机可以看做是一个有向图(由于这个状态可以回到之前,因此可能存在环)。我们可以定义系统的一些异常状态为特定的点,当永远不可能走到异常状态的时候,就说明程序是对的。
Model Checking: Algorithmic Verification and Debugging就是在干这件事情。
可以通过找环的方式避免无限多的状态机切换。
程序与进程
操作系统提供了虚拟的环境,每个进程都试做自己独占这个进程。
除了程序状态,操作系统还会保存一些额外的(只读)状态。比如进程的pid,进程状态,ppid(父pid) uid内存大小 进程名,工作目录等 。
进程如果想知道自己的pid,需要进行systemcall。
pid是递增的,通常是32位数,能取值42亿,随着进程的不断创建会递增,当到达系统设定的最大值后,系统会循环并分配比较小的已经释放的PID。在现在的linux内核中通常是2的22次方。
我们可以死循环轮询鼠标的操作,来控制图形的输出。
unix可以通过fork()立即复制一个一模一样的状态机;俩个进程所有的内存都是一样的,唯一的区别就是心的机场南pid号为0;
假设A创建了B,B创建了C,如果B终止了C的PID是什么。如果正常终止是一直往上找祖先进程,如果异常终止就是给linux的1号进程,这个过程叫做托孤。
有些进程会被托孤到1867进程,也就是systemd进程。现在系统对于内存异常占用时,系统就会找到合适的进程杀死。
unix选择只给一个复位的状态机的API, unix选择只给一个复位的状态机。execve是唯一能够执行程序的系统调用。
与 fork()
不同,execve()
不会创建新进程,而是在当前进程上下文中加载并执行新程序(类似reset操作)。
execve
是 C 语言中的一个系统调用函数,主要用于执行程序替换。它属于 POSIX 标准的一部分,通常通过 <unistd.h>
头文件提供。
mmap
计算机中对内存只有俩个操作,只有load和store。理论上可以打印出所有进程的当前状态机的样子(寄存器等)。部分内容是只读的,部分内容是可读可写的。每个部分都有开始位置和结束位置,其他位置访问都会报段错误。虽然关于操作系统信息的每一次调用在理论上都会陷入内核态,进行一次systemcall,但是操作系统可以主动把一部分状态暴露给进程。因此一部分系统调用可以不陷入内核(理论上所有的系统调用都可以不进入内核)。引导文件理论上是可以在各种系统上运行的。
初始状态有随机数种子等信息,还有一些参数。
ELF(Executable and Linkable Format,可执行与可链接格式)是一种用于二进制文件(如可执行程序、共享库、目标代码等)的标准文件格式,广泛应用于类Unix系统(如Linux、BSD)以及许多嵌入式系统中。
进程初始只有ELF文件里声明的内存和一些操作系统分配的内存,其他任何指针的访问都是非法的。
如果从输入读一个size,然后malloc(size),内存从哪来呢?
因此一定有一个系统调用可以改变进程的地址空间。现代C库(如glibc的ptmalloc)会预先通过系统调用(如brk或mmap)向操作系统申请一大块内存(称为堆或内存池),然后由malloc在用户态管理这些内存块。后续的malloc请求会优先从这些预分配的内存中划分,无需频繁触发系统调用。
mmap(memory map)函数可以将文件或设备映射到内存中。参数
- addr:起始地址
- length:映射程度
- prot:指定内存保护方式,可读可写等
- flags:映射控制行为
- fd:需要映射的文件描述符。mmap也可以把文件映射到地址空间。
- offset:文件中的偏移量
现代操作系统(如 Linux)会优化 fork()
的性能:子进程共享父进程的内存页,直到某一方尝试修改数据时,内核才会复制该内存页。此时修改操作会作用在独立的拷贝上。
子进程在fork的时候,会使用mmap分配共享的内存,子进程所有的写入都是写到共享内存中的。linux提供了pmap可以查看进程中的地址空间都打印出来。pmap使用的是/proc/[pid]/maps该文件包含了进程的内存映射信息,如地址范围,权限偏移量设备,inode和路径名等。也有对应的munmap释放内存。
mmap可以映射一个大文件,只访问以其中的一小部分。操作系统在分配内存的时候不需要真的分配内存,因为理论上不访问就可以不分配。比如可以直接把1T的文件映射到内存中。常说linux一切皆文件,可以将整个磁盘映射到内存里,然后就可以修改了。有些时候top里面看内存会发现shadow memory,甚至可能超过物理内存。
在linux可以通过/proc/[pid]/mem修改另一个进程的地址空间。
状态机是一个封闭的设计,能不能允许一个进程对其他进程的地址空间有访问权。GDB就是这样实现的
- 核心机制:GDB 使用
ptrace(PTRACE_ATTACH, pid)
附加到目标进程,此时目标进程会被暂停,控制权转移给调试器。 - 权限要求:调试进程需要具有相同用户权限或 root 权限(CAP_SYS_PTRACE)。
- 操作示例:
ptrace(PTRACE_PEEKDATA, pid, addr)
可读取目标进程的指定内存地址。
同样的GDB也可以修改其他进程的内存地址。类似,DMA外挂也是这样做的。
访问操作系统中的对象
操作系统中有很多对象,比如文件(字节序列),字节流。
比如读取/dev/urandom可以输出随机字符,它就是一个字节流。
操作系统用一个open打开文件,返回一个文件描述符,然后通过这个文件描述符就可以实现对文件的读取。文件描述符是只想操作系统对象的指针
malloc是找当前渎职空间最小的。freopen可以用来将一个已打开的流,重新关联到另一个文件或者设备上。可以用dup交换控制器。
ulimit -n能展示打开文件描述符的限制。
文件描述符是进程状态的一部分。程序只能通过整数编号访问。文件描述符自带一个offset。
当出现fork和dup之后,文件描述符共享offset吗?答案是当一个进程写入时造成的offset便宜,会同步都到另一个进程中, 日志不会相互覆盖。
windows中文件描述符被称作handle句柄(误翻译导致)。Windows中的Handler是不继承的,和Unix的默认继承是相反的。
linux引入了O_CLOEXEC来自定义子进程是否继承父进程的文件描述符。
任何可读可写的东西都可以是文件。
管道是一个特殊的文件流。
init pipe (int pipef[2]);
管道有一个写入口有一个读取口。会有俩个文件描述符被创建,一个是读口一个是写口。pipefd[0]是读口,pipefd[1]是写口。
当读取的时候,没有数据可以被读到,那么读取管道就会阻塞。可以通过fork来创建子进程,之后父进程就可以关闭读口。之后子进程就可以等待父进程往管道写入数据。 通过管道可以通过数据的控制。
shell一套API可以访问所有对象,但是稍微大一点的项目就应该用高级一点的语言。
一切皆文件
- 优点:优雅文本接口
- 缺点:和各种api紧密耦合,对高速设备不友好。
POSIX(Portable Operating System Interface,可移植操作系统接口)是一系列由 IEEE(电气和电子工程师协会)制定的标准,旨在提升不同类 Unix 操作系统之间的兼容性和可移植性。
Win32 API就支持posix子系统。原生不支持完整 POSIX现代 Windows 10/11 默认不提供 POSIX 子系统,且系统 API(如 Win32)与 POSIX 不兼容。
每个系统都有不同的实现,比如Windows没有fork。macOS:Cocoa API内部有BSD子系统。兼容没办法做到百分之一百。
WSL1系统就是实现了posix,将每个linux的操作翻译成Windows的系统操作(翻译每个systemcall,但是有非常多不兼容)。理论上兼容所有的linuxapi就能运行所有的linux程序。WSL2是通过hyper-v的虚拟机。类似的Linux subsystem for windows通过将每个windows api转换成linux的调用实现兼容。
然而linux的API看起来很少,但是有些API是非常复杂的。
终端 进程组
shift,使用字锤字模向上移动一段距离,切换字符集。
r是将打印头移回行首,n是将纸张向上移动一行
比如print('hel\rlo')
会答应出lol
最早电传打字机是为了发电报设计(当时还没有计算机)。随着显示器技术的发展1978年出了最初的显示器。
今天:伪终端(pseudo terminal),一对管道提供双向通讯信道,主设备(PTY MAster连接到终端模拟器),从设备(PTY slave)连接到shell或其他程序。
如今计算机中可以创建任意多个终端。可以往其他终端中写入数据。现在的系统有openpty,找到一个可用的伪终端(通过/dev/ptmx)。当设计linux程序时,可能会直接操作某个文件对象,因此即便兼容了poxis标准,一些操作也是不可移植的。因此文件系统也是不可移植的。
我们可以用任何变成语言来实现终端。
终端可以用ctrl+c终止前台的进程,但是不应该终止后台的进程。默认情况下fork出的所有进程都是在一个进程组里面的。
ctrl+z可以暂停前台进程,在后台挂起(类似最小化)。
给进程引入了一个额外的编号Session ID(大分组)
子进程会集成父进程的Session ID。一个Session关联一个控制终端。leader退出时,全体进程收到Hang UP
再引入另一个编号(Process Group ID 小分组)
只能有一个前台进程组
操作系统收到ctrl-c向前台进程组发送SIGINT。即便这个进程已经托孤了,也能杀死原来的子进程。
特性 | 进程组(Process Group) | 会话(Session) |
---|---|---|
范围 | 一组相关进程(如管道命令) | 一组相关进程组(如 Shell 及其子任务) |
ID | PGID(通常等于组长进程的 PID) | SID(等于会话首进程的 PID) |
终端关联 | 可能不关联终端 | 关联一个控制终端(可选) |
创建方式 | setpgid() | setsid() |
典型用途 | 作业控制(如 fg /bg 命令) | 登录会话、守护进程 |
在安卓中,每个app都是不同的用户,强行停止=沙雕这个用户的所有进程
在snap中程序都是在隔离的沙箱运行。
当前ctrl+c实际做的是遍历当前全部的前台程序,并进行终止。
unix shell是基于文本替换的极简编程语言。早期unix要用expr才能进行算术。
c标准库的实现
我们现在学习了fork,execve,exit,waitpid。内存管理的mmap;文件对象管理的open,read,write,dup,close,pipe。这些API的设计有一个有趣的原则叫做非必要不实现。但凡应用程序能自己搞定的功能,操作系统就不需要提供。这样的设计能防止包揽一切导致代码膨胀,甚至在长期演化中成为历史包袱。
c语言的代码,基本可以翻译成汇编代码,而c++代码则稍微复杂,比如虚方法调用。现在基本上所有的操作系统都提供c的运行环境。
lib c上大部分可以用c语言本身实现,少部分需要一些底层支持(可能是为了效率)
lib C是ISO IEC标准的一部分。·<stdio.h>,<string.h>,<stdlib.h>,<math.h>,<time.h>·
包含了内存管理字符串,时间日期等部分。
- glibc - GNU C 库,Linux 系统使用
- musl - 轻量级替代实现
- uclibc - 嵌入式系统常用
- BSD libc - BSD 系统使用
- Microsoft C 运行时库 - Windows 平台
malloc和free()
可以使用mmap让系统给自己分配大段的内存。
Freelist是一种通过链表管理空闲内存块的数据结构。它维护了一个包含所有可用内存块的链表,当需要分配内存时,从链表中取出一个合适的块;当内存被释放时,将块重新加入链表。
静态链接和加载
可执行文件的初始状态可以看做状态机的初始状态。可执行文件是一个字节序列。
ELF(Executable and Linkable Format)是一种用于可执行文件、目标代码、共享库和核心转储(core dumps)的标准文件格式。它最初由 Unix 系统实验室(USL)开发,现已成为类 Unix 系统(如 Linux、FreeBSD、Solaris 等)中最常见的二进制文件格式之一。
可执行文件头是操作系统加载和执行程序的关键数据结构,它位于可执行文件的开头,包含了让操作系统正确加载和运行程序所需的信息。不同平台的可执行文件格式有不同的头部结构。
ELF不是一个人类友好的状态机数据结构描述。当时存储成本是很贵的。
core dump文件也是一个ELF文件,可以做崩溃时内存情况的记录。可以做瞬间的快照。
2018年有一篇论文就是根据当前状态机的状态往前走。理论上状态机在任何时刻都可以进行一个快照。但是有些状态可能和操作系统有关,比如pid。还有不同的文件在不同电脑也可能不一样。 也可以给没有保存功能的app设置保存功能。
CRIU(Checkpoint/Restore In Userspace)是一个用于 Linux 系统的开源工具,主要功能是对运行中的应用程序创建检查点。CRIU可以冻结容器并保存其状态,然后迁移到另一台主机上回复运行。(如 Kubernetes 的 Pod Checkpointing
功能)
- 静态链接:在编译时完成,将代码直接复制到可执行文件中。
- 动态链接:在运行时完成,可执行文件仅保留对动态库的引用。
静态链接库需要重新编译整个程序,适合嵌入式等无依赖环境。
动态链接,更新库文件,如漏洞修复后,所有以程序自动使用新版本。
静态链接
gcc -static main.c -o main # 输出包含库代码的二进制文件
动态链接
gcc main.c -o main # 输出依赖 libc.so 等动态库
dll和so文件就是动态链接库。
动态链接能减少可执行文件的大小。静态链接的兼容性是更好的,无论换什么版本都能运行。
c和c++没有自带包管理器。类似go和java都不需要重新编译第三发库。
c语言中有几种典型的方式进行编译
源码引入
将第三方库源码直接添加到项目中。将头文件放入目录
和.c文件一同编译,调试方便。项目体积可能增大。项目体积可能增大,需要处理可能的依赖冲突。
静态链接
使用预编译的静态库(.a或.lib文件)
下载或编译生成静态库文件,在代码中通过#include <xxx.h>
引入头文件。
编译时链接静态库
gcc main.c -L/path/to/lib -lxxx -o program
优点哭代码直接嵌入可执行文件,分发简单。
缺点:更新库需要重新编译程序
go语言默认生成的可执行文件就是静态链接的(除了一些使用cgo的情况)
动态链接
dynamic linking,使用共享库(.so
或.dll
文件)
确保动态链接库安装在系统路径(/usr/lib)。
代码中包含头文件
gcc main.c -lxxx -o program
节省内存,库可独立更新。
动态链接器默认不会立即加载整个库,而是等到程序首次调用某个函数时才加载对应的代码页(通过 PLT/GOT 机制实现)。
依赖系统环境配置。
#include <openssl/ssl.h> // 动态库的头文件
当多个程序使用一个库,操作系统之会将库的代码段在物理内存中保存一份。静态链接时,每个程序都会将代码复制到自己的二进制文件中,导致同一份代码在内存中有多份重复占用。若 100 个进程都静态链接了 1MB 的 libc
,内存占用约 100MB;动态链接后,可能只需 1MB(库代码) + 100×(各进程私有数据)。
静态链接和动态链接通常是混用的。
包管理工具
通过系统包管理器(如apt
、yum
、vcpkg
等)安装库。
- 直接通过
#include <xxx.h>
和编译链接(-lxxx
)使用。 - 优点:自动处理依赖路径
- 缺点:依赖系统包管理
动态链接和加载
ls就是一个动态链接。运行库和应用代码就可以独立升级。改一行代码不需要重新链接2GB的文件。
.o
文件:- 静态链接:在编译时直接合并到最终的可执行文件中(通过
ld
链接器)。 - 链接后,
.o
文件的代码会成为可执行文件的一部分,程序运行时不再依赖原.o
文件。
- 静态链接:在编译时直接合并到最终的可执行文件中(通过
.so
文件:- 动态链接:在程序运行时由动态链接器(如
ld-linux.so
)加载到内存。 - 多个程序可以共享同一个
.so
文件的同一份内存副本,节省资源。 - 程序运行时必须能找到对应的
.so
文件(通过LD_LIBRARY_PATH
或系统库路径)。
- 动态链接:在程序运行时由动态链接器(如
操作系统上的应用生态
最早的unix版本中没有fork行为
- shell关闭所有打开的文件,然后为0,1 fd打开终端文件
- 从终端读取命令行
- 打开文件,把加载器代码复制到内存并执行
- exit会重新加载shell
所有很简单的系统都是从很原始的版本迭代的。
minix1在1978年被发明,这是实现linux的起点。它同时兼容16bit和32bit。我们只需要给c语言提供一套api就好了。没有人关心实际的system call是不是和unix一一对应的。minix只有发送和接受俩个api。minix2 在1997年提供了poxis兼容。源代码很小,甚至可以被硬刷在一本书上。
minix是一个微内核的操作系统。微内核的系统调用实现,十分的简单,只有俩个系统调用,绝大部分的代码都运行在用户态。
宏内核的效率更高,因为微内核每次systemcall时,system本身相当于一个独立的进程。每次调用要涉及程序之间的切换。对性能有影响。
minix3在2006年发布, 提供了posix和NetBSD兼容。一度是应用最广的操作系统。因为intel的固件中藏了一个minix。
1991 linus在论坛上发布了linux。说自己做了比minix更号的免费自由的操作系统。
GNU 程序是指由 GNU 项目(GNU's Not Unix) 开发或维护的自由软件程序,遵循 自由软件基金会(FSF, Free Software Foundation) 的准则,强调用户可以自由运行、研究、修改和分发软件。
它甚至还依赖minix的工具链。跑的都是GNU的程序:gcc,bash
在那之后,个人电脑云计算经历了快速的增长。当时在贴吧引起了广泛的讨论。
之后
- linux2.0引入多处理器
- linux2.4引入内核并行
- 2002年引入RCU(read copy update)无锁同步
- 2003年2.6发布,随后云计算开始起飞
当时最需要的就是不要钱的操作系统。
linux
kernel会加载第一个进程,相当于操作系统中防止一个位于初始状态的状态机。
包含一些进程可以操纵的操作系统对象
除此之外,什么也没有。
最开始状态机只有一个init文件,可以是任意的binary,包括自己实现的。系统启动后linux会增加/dev和/dev/console
需要给stdin/stdout/stderr一个地方
initramfs (Initial RAM File System) 是 Linux 系统启动过程中使用的一个临时根文件系统,它在内核加载后、真正的根文件系统挂载前被使用。它是一个 cpio 格式的归档文件,通常会被压缩(如 gzip 或 xz)。
BusyBox 是 Linux 系统中一个高度集成的开源工具集,被称为“嵌入式 Linux 的瑞士军刀”。它的核心特点如下:BusyBox 将超过 300 个常用 Linux 命令(如 ls
、cp
、grep
、vi
等)压缩成一个可执行文件(通常仅几百 KB 到几 MB)。它为嵌入式设备或精简环境提供了 GNU Coreutils、Shell 等工具的简化版本。
系统启动时busyBox会被加载。在linux中tty代表Teletypewriter,是用户与系统的 交互接口
Storage Device Asda
是 Linux 内核识别的第一块物理磁盘设备的命名。它是 SCSI/SATA/USB 或虚拟磁盘的通用命名规则的一部分。
简单理解为操作系统启动的时候会先启动一个小世界,然后再启动整个操作系统。
busybox是真实系统中一个高度集成的工具。linux可以调用pivot_root
中进入真正的操作系统。
如今的操作系统用是的
/sbin/init->/lib/systemd/systemd
pivot_root的作用是在系统启动时,从初始的临时文件系统(如 initramfs
)切换到最终的根文件系统(如 /dev/sda1
上的 ext4 文件系统)。
systemd
是现代 Linux 系统的初始化系统(init system)和服务管理器,负责:
- 启动和管理用户空间的服务(如网络、日志、定时任务等)。123213
- 提供并行化启动、依赖管理、日志收集(journald)等功能。
在大多数情况下,pivot_root
的操作由 initramfs
中的脚本(如 dracut
或 mkinitcpio
)完成,之后才启动 systemd
。
/etc/fstab
(File System Table)是 Linux 系统中一个重要的配置文件,用于定义系统启动时自动挂载的文件系统信息。它的主要作用是告诉操作系统哪些存储设备(如硬盘分区、光盘、网络共享等)需要在启动时挂载,以及挂载到哪个目录、使用什么选项等。
在systemd开始之后initramfs会被释放。
初始状态:initramfs中的对象/dev/console+加载的init
dpkg是底层包管理器,apt会自动帮忙安装依赖,卸载,查询软件包。