南京大学操作系统课程

硬件视角下的操作系统

状态机一定要有初始状态。在初始状态之后,硬件就是个无情的执行指令的机器。

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的例子

  1. 扫描磁盘,找到附近的ELF文件头,加载到内存,根据文件系统可能会多一步。
  2. 这个ELF文件是Grub;弹出熟悉的选择系统窗口
  3. 加载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 及其子任务)
IDPGID(通常等于组长进程的 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>·

包含了内存管理字符串,时间日期等部分。

  1. glibc - GNU C 库,Linux 系统使用
  2. musl - 轻量级替代实现
  3. uclibc - 嵌入式系统常用
  4. BSD libc - BSD 系统使用
  5. 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×(各进程私有数据)。

静态链接和动态链接通常是混用的。

包管理工具

通过系统包管理器(如aptyumvcpkg等)安装库。

  • 直接通过#include <xxx.h>和编译链接(-lxxx)使用。
  • 优点:自动处理依赖路径
  • 缺点:依赖系统包管理

动态链接和加载

ls就是一个动态链接。运行库和应用代码就可以独立升级。改一行代码不需要重新链接2GB的文件。

  • .o文件

    • 静态链接:在编译时直接合并到最终的可执行文件中(通过ld链接器)。
    • 链接后,.o文件的代码会成为可执行文件的一部分,程序运行时不再依赖原.o文件。
  • .so文件

    • 动态链接:在程序运行时由动态链接器(如ld-linux.so)加载到内存。
    • 多个程序可以共享同一个.so文件的同一份内存副本,节省资源。
    • 程序运行时必须能找到对应的.so文件(通过LD_LIBRARY_PATH或系统库路径)。

操作系统上的应用生态

最早的unix版本中没有fork行为

  1. shell关闭所有打开的文件,然后为0,1 fd打开终端文件
  2. 从终端读取命令行
  3. 打开文件,把加载器代码复制到内存并执行
  4. 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 命令(如 lscpgrepvi等)压缩成一个可执行文件(通常仅几百 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中的脚本(如 dracutmkinitcpio)完成,之后才启动 systemd

/etc/fstab(File System Table)是 Linux 系统中一个重要的配置文件,用于定义系统启动时自动挂载的文件系统信息。它的主要作用是告诉操作系统哪些存储设备(如硬盘分区、光盘、网络共享等)需要在启动时挂载,以及挂载到哪个目录、使用什么选项等。

在systemd开始之后initramfs会被释放。

初始状态:initramfs中的对象/dev/console+加载的init

dpkg是底层包管理器,apt会自动帮忙安装依赖,卸载,查询软件包。

Last modification:August 14, 2025
如果觉得我的文章对你有用,请随意赞赏