3Computer Science from the Bottom Up

操作系统

硬件与应用的抽象层

进程虚拟内存地址翻译TLB系统调用信号上下文切换

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第3章 操作系统

硬件与应用的抽象层

章节导读

假设没有操作系统,你的程序需要做什么?它需要自己初始化显卡驱动、自己管理内存分配、自己监听键盘中断、自己把数据写到磁盘的特定扇区。更糟糕的是,如果同时运行两个程序,它们会互相覆盖对方的内存,一个程序的崩溃会拖垮整台机器。操作系统(Operating System, OS)就是为了终结这种混乱而诞生的:它站在硬件和应用程序之间,向上提供简洁统一的抽象,向下管理复杂多样的硬件资源。理解操作系统,是「from the bottom up」旅程中承上启下的关键一站——它把第 1、2 章的硬件知识(内存、CPU、中断)转化为程序员可以依赖的服务。

抽象层核心概念提供的服务关键数据结构切换开销
硬件层电信号、总线、寄存器原始计算能力无-
指令集 (ISA)指令、寻址、寄存器可编程的机器程序计数器-
内核态页表、中断描述符、TCB资源管理进程控制块-
系统调用read/write/mmap/fork文件、进程、内存文件描述符表~1000 周期
用户态进程虚拟地址空间、线程独立执行环境堆、栈、代码段上下文切换 ~us
应用程序函数、对象、模块业务逻辑应用数据结构-

操作系统的历史就是一部「资源利用率与用户体验不断进化」的历史。1940 年代的计算机没有操作系统,程序员预约机时,手工装卸纸带,机器大部分时间在闲置。1950 年代的批处理系统(Batch System)把作业成批输入,由监督程序自动衔接,减少了人工干预。1960 年代的多道程序(Multiprogramming)系统让多个作业同时驻留内存,一个作业等待 I/O 时 CPU 立即切换到另一个作业,把昂贵的 CPU 利用率从 20% 提升到 70% 以上。同一时期的分时系统(Time-Sharing)——MIT 的 CTSS、贝尔实验室与通用电气合作的 Multics——让多个用户通过终端同时交互使用计算机,每个用户感觉自己独占机器。Multics 项目虽然商业上失败了,但它的一位年轻工程师 Ken Thompson 在 1969 年把其中的思想大幅简化,写出了 Unix 的第一个版本;Dennis Ritchie 随后发明了 C 语言,1973 年 Unix 内核用 C 重写,操作系统第一次摆脱了对特定硬件的依赖,可以移植到任何有 C 编译器的机器上。这直接催生了 Linux(1991 年 Linus Torvalds)、BSD、macOS 等整个 Unix 家族。实时操作系统(RTOS)则走上了另一条路:不追求平均吞吐量,而保证最坏情况下的响应时间,用于航空航天、工业控制和医疗设备。

操作系统的三大核心抽象是:进程(CPU 的抽象)、虚拟内存(物理内存的抽象)和文件(I/O 设备的抽象)。进程让每个程序都以为自己独占 CPU——实际上操作系统以毫秒级的时间片在几十上百个进程之间轮转,每次切换都要保存和恢复完整的 CPU 状态。虚拟内存让每个进程都以为自己独占一条从 0 开始的连续地址空间——实际上 MMU 硬件和操作系统的页表把虚拟地址动态翻译到物理地址,不同进程的同一个虚拟地址指向完全不同的物理内存,从而实现隔离;而共享又通过让多个进程映射同一物理页来实现,动态链接库正是靠这个机制让一份 libc 代码服务所有进程。文件抽象则把磁盘、终端、网络套接字甚至进程信息(Linux 的 /proc)统一成「字节流 + read/write/open/close」的接口,「一切皆文件」是 Unix 最深刻的设计哲学。

这些抽象不是免费的。每次系统调用都要经历用户态到内核态的特权级切换,每次进程切换都要付出寄存器保存、TLB 刷新和缓存污染的代价,每次缺页中断都要陷入内核走一遍页表甚至访问磁盘。理解这些开销的来源和数量级,你才能理解为什么高性能服务器要用 epoll 而不是每连接一个 select,为什么要用 mmap 而不是反复 read 大文件,为什么数据库要自己管理缓冲池。本章将深入进程、虚拟内存、地址翻译、系统调用和信号五大主题,把操作系统从黑盒变成你手中的透明机器。

核心概念

  • 进程
  • 虚拟内存
  • 地址翻译
  • TLB
  • 系统调用
  • 信号
  • 上下文切换

要点精析

操作系统的历史演进——批处理、多道程序、分时、实时。1956 年通用汽车研究实验室为 IBM 704 写出了第一个监控程序(GM-NAA I/O),它是操作系统的雏形:自动把上一个作业的输出和下一个作业的输入衔接起来,免去操作员手工装卸。批处理解决了「人机速度不匹配」,但 CPU 仍然要等慢速 I/O(卡片读取机比 CPU 慢千倍)。多道程序设计让多个作业同时驻留内存,CPU 在作业 A 等待 I/O 时立即转去执行作业 B,这需要内存保护(防止作业互踩)、作业调度和 I/O 缓冲等机制,是操作系统成为「资源管理者」的转折点。分时系统进一步把时间片切到几十毫秒,配合中断机制让每个终端用户感觉独占计算机——CTSS(1961)证明了可行性,Multics(1965)雄心勃勃地引入分级存储、动态链接、树形文件系统等概念,虽因过于复杂而失败,却直接启发了 Unix。Unix 的成功秘诀是「简单、组合、一切皆文件」的哲学。实时操作系统(如 VxWorks、QNX、RT-Linux)则保证硬截止时间:航空电子系统中,一个「迟到」的正确结果等于错误结果,因此 RTOS 采用优先级抢占调度、可锁定内存、有界中断延迟等专门技术。历史告诉我们:操作系统的每个特性都是为解决特定时代的资源矛盾而生。

进程的概念——独立的逻辑控制流与地址空间。进程是「运行中的程序」的抽象,操作系统通过两个假象支撑它:其一是独立的逻辑控制流——每个进程都以为自己独占 CPU,指令序列连续不断;实际上是内核把各进程的执行切片交错(interleaving),每个进程在时间片内连续运行,被抢占时保存状态。其二是独立的地址空间——每个进程都以为自己独占整条内存地址范围;实际上虚拟内存机制把每个进程的虚拟地址映射到互不重叠的物理页。在 Linux 内核中,进程由 task_struct 结构体描述(约 6KB),包含进程状态、堆栈指针、打开的文件表、信号处理表、内存描述符(mm_struct)等全部信息。进程与线程的关系:线程是进程内的执行流,同一进程的多个线程共享地址空间和文件描述符,但各有独立的栈和寄存器上下文——因此线程切换比进程切换便宜得多(不必切换页表)。进程标识方面,每个进程有唯一的 PID,fork 出的子进程继承父进程的大部分资源。Linux 的进程视图完全暴露在 /proc 文件系统下:/proc/[pid]/maps 显示地址空间布局,/proc/[pid]/status 显示状态,/proc/[pid]/fd 列出打开的文件——「一切皆文件」哲学连进程自身都抽象成了文件。

进程的生命周期——创建、运行、等待、终止。Linux 进程有五种基本状态:TASK_RUNNING(就绪或在 CPU 上执行,Linux 不区分这两者,统一放在运行队列)、TASK_INTERRUPTIBLE(可中断睡眠,等待事件如 I/O 完成,信号可唤醒)、TASK_UNINTERRUPTIBLE(不可中断睡眠,通常在等待磁盘 I/O,不响应信号——ps 命令中的 D 状态,大量 D 状态进程往往意味着存储故障)、TASK_STOPPED(收到 SIGSTOP/SIGTSTP 暂停,调试器设断点也用此状态)、EXIT_ZOMBIE(已终止但父进程尚未 wait 回收,僵尸进程保留 PID 和退出码,大量僵尸会耗尽 PID 表)。进程创建用 fork:内核复制父进程的 task_struct、文件表、信号处理表,地址空间采用写时复制(Copy-On-Write)——父子进程先共享同一批物理页并标记只读,任何一方写入时触发缺页中断才真正复制该页,因此 fork 一个占用 10GB 内存的进程几乎是瞬时的。fork 之后通常紧跟 exec 系列函数,用新程序的映像整体替换当前地址空间。进程终止时内核回收其内存和文件描述符,向父进程发送 SIGCHLD,父进程用 wait/waitpid 读取退出状态完成「收尸」。孤儿进程会被 init(PID 1)收养,由它负责回收——这套机制保证了进程表不会泄漏。

上下文切换的开销——寄存器、TLB 与缓存的三重代价。上下文切换(Context Switch)是内核把 CPU 从一个进程/线程转给另一个的过程,开销分三层。第一层是直接开销:保存当前进程的全部通用寄存器、程序计数器、栈指针到其内核栈(Linux 上约 30 个寄存器,x86-64 下几百字节),加载下一个进程的对应状态,加上内核调度器选核、切换内核栈的开销,合计约 1-3 微秒(几千个 CPU 周期)。第二层是 TLB 代价:切换进程意味着切换页表基址寄存器(x86 的 CR3),若新进程的 ASID/PCID(地址空间标识符)不在 TLB 中,之前缓存的地址翻译全部失效,切换后的头几百次访存都要走完整的页表遍历(Page Table Walk),每次多花几十到几百周期。现代 CPU 用 PCID 标记 TLB 条目属于哪个地址空间,切换 CR3 时不必清空 TLB,大幅缓解了这个问题。第三层是缓存污染:新进程的代码和数据会把旧进程在 L1/L2/L3 中的内容逐步挤出,切换回来后大面积缓存缺失,这种「间接开销」往往比直接开销大一个数量级,且难以精确测量。用 lmbench 测得的完整上下文切换延迟(含缓存效应)通常在 5-20 微秒。这就是为什么高并发服务器追求「减少切换次数」(事件驱动模型)和「CPU 亲和性」(把线程钉在固定核上保持缓存热度),也是为什么每秒几万次上下文切换会让系统吞吐显著下降。

虚拟内存的设计动机——隔离、共享、抽象与按需分配。虚拟内存解决四个问题。第一,隔离与保护:没有虚拟内存时,所有进程直接操作物理地址,一个野指针就能改写内核或其他进程的数据;虚拟内存让每个进程的地址空间由内核独立管理,进程间互不可见,越界访问触发段错误(Segmentation Fault)被内核拦截。第二,抽象与简化:每个进程看到的都是从 0 开始的连续地址空间,链接器不必关心程序将加载到哪个物理地址,编译器生成的代码可以用固定的地址布局——极大简化了程序模型。第三,高效共享:多个进程的虚拟页可以映射到同一物理页,动态链接库的代码段被所有使用者共享(一份物理内存服务 N 个进程);fork 的写时复制、共享内存 IPC(mmap MAP_SHARED)都建立在这个机制上。第四,按需分配与超额订阅:进程的地址空间可以远大于物理内存(64 位下用户空间 128TB),只有真正访问的页才分配物理内存(demand paging);不常用的页可以换出到磁盘(swap),让系统「假装」拥有比实际更多的内存。实现虚拟内存的硬件基础是 MMU(内存管理单元):它拦截每一次访存,把虚拟地址翻译成物理地址,翻译规则存放在内存中的页表里,由内核维护。页大小通常 4KB,现代系统还支持 2MB/1GB 的大页(Huge Page)以减少 TLB 压力和页表层级。虚拟内存是计算机科学最伟大的抽象之一,数据库的缓冲池、JVM 的堆管理、容器的内存限制,全都构建在它之上。

地址翻译过程——虚拟地址到物理地址的完整路径。以 x86-64 的 4 级页表为例:48 位虚拟地址被切分为 5 段——9 位 PML4 索引、9 位 PDPT 索引、9 位 PD 索引、9 位 PT 索引、12 位页内偏移(4KB 页有 2^12 个字节)。翻译过程:CPU 从 CR3 寄存器读出页表基址,用 PML4 索引取出第一级表项,得到下一级页表的物理地址;依次经过 PDPT、PD、PT 三级,每级用对应的 9 位索引选出一个 8 字节表项;最后一级表项给出物理页帧号(PFN),与 12 位偏移拼接得到物理地址。一次 TLB 缺失最多需要 4 次额外的内存访问来走页表——这就是为什么 TLB 如此重要。每个页表项(PTE)除地址外还包含权限位:Present(页是否在内存)、Read/Write(可写位)、User/Supervisor(用户态可否访问)、NX(不可执行位,防御缓冲区溢出攻击的关键)、Dirty(是否被写过)、Accessed(是否被访问过,页面置换算法的依据)。若 Present 位为 0,MMU 触发缺页异常(Page Fault),内核接管:可能是合法访问(页被换出到磁盘,需要换入;或首次触碰堆内存,需要分配物理页;或写时复制,需要复制页),也可能是非法访问(野指针,内核发送 SIGSEGV 杀死进程)。5 级页表(57 位虚拟地址)已在最新的 Intel/AMD 处理器和 Linux 4.14+ 中支持,用于超大内存服务器。

多级页表——用时间换空间的经典设计。为什么不用一张平坦的页表?64 位地址空间下,4KB 页需要 2^36 个表项,每项 8 字节,页表本身就要 512GB——比物理内存还大,显然不可行。多级页表的洞察是:真实进程的地址空间极其稀疏(代码在低地址、堆向上长、栈在顶端向下长、中间是大片空洞),平坦页表的绝大部分表项都是空的。多级结构只为「实际使用的区域」分配下级页表:4 级页表下,一个只用了 4MB 连续内存的进程只需要 1 个 PML4 页 + 1 个 PDPT 页 + 1 个 PD 页 + 2 个 PT 页,共 16KB,而不是 512GB。空洞区域对应的上级表项直接标记为不存在,其下所有页表都不必分配。代价是翻译需要多次访存(4 级页表最多 4 次),但 TLB 缓存了翻译结果,命中率通常在 99% 以上,实际影响很小。Linux 的页表管理还有两个精细设计:一是每个进程共享同一份内核页表映射(高半部分),切换进程时内核映射无需重建;二是页表页本身用伙伴系统分配,进程退出时逐级回收。ARM 的 LPA2、RISC-V 的 Sv39/Sv48/Sv57 都是同类的多级设计。理解多级页表,你就理解了「稀疏数据的分层索引」这一通用思想——它同样出现在文件系统的 inode 间接块、数据库的 B+ 树、IP 路由的最长前缀匹配中。

TLB——地址翻译的硬件缓存。每次访存都走 4 级页表显然不可接受(访存延迟翻 5 倍),因此 MMU 内置了 TLB(Translation Lookaside Buffer)缓存最近的虚拟页到物理页帧的映射。TLB 是全相联或组相联的小型高速缓存:x86-64 典型配置是 L1 iTLB 64-128 项 + L1 dTLB 64-96 项(4KB 页),L2 统一 TLB 1024-2048 项,另配少量大页(2MB/1GB)专用 TLB 项。一次 TLB 命中只需 1 个周期左右的额外延迟,而 TLB 缺失要付出 4 次页表访存(约 30-100 周期),若页表本身也不在缓存中则更糟。TLB 覆盖率 = 项数 × 页大小:1536 项 × 4KB 只有 6MB,远小于现代应用的工作集,这就是大页(Huge Page)的价值——2MB 大页让同样项数的 TLB 覆盖 3GB,数据库(如 PostgreSQL 的 huge_pages 参数)、JVM(-XX:+UseLargePages)和 DPDK 都默认启用大页。TLB 失效的场景:切换进程(PCID 缓解)、munmap/mprotect 修改映射(内核发 IPI 让所有核同步失效,即 TLB shootdown,多核下开销显著)、显式刷新(invlpg 指令)。程序员的优化手段:减少工作集的页数(数据紧凑、避免大步长跳跃访问)、使用大页、避免频繁 mmap/munmap 小区域。perf stat -e dTLB-load-misses 可以直接观测 TLB 缺失率,超过 1% 就值得关注。

系统调用的机制——用户态到内核态的受控通道。应用程序不能直接操作硬件(否则隔离形同虚设),必须通过系统调用(System Call)请求内核服务。CPU 用特权级实现这个边界:x86 的 Ring 0(内核态)可以执行特权指令(修改页表、关中断、访问 I/O 端口),Ring 3(用户态)执行特权指令会触发异常。系统调用的完整路径(以 Linux x86-64 为例):应用程序调用 libc 包装函数(如 read()),包装函数把系统调用号放入 RAX 寄存器、参数放入 RDI/RSI/RDX 等,执行 syscall 指令;CPU 硬件原子地完成:切换到 Ring 0、跳转到固定入口地址(由 MSR_LSTAR 寄存器预先设置)、切换到内核栈;内核入口代码保存用户态寄存器(构造 pt_regs),根据 RAX 中的调用号查 sys_call_table 分发到对应处理函数(如 sys_read);处理函数完成工作后执行 sysret 返回用户态,返回值放在 RAX。整个往返开销约 100-500 纳秒(Spectre 缓解措施开启后更高)。历史上 x86 用 int 0x80 软中断实现系统调用,速度慢得多,syscall/sysenter 快速路径指令是 2000 年前后引入的。减少系统调用开销的技术:批量操作(readv/writev、recvmmsg)、内存映射 I/O(mmap 后直接访存,无系统调用)、用户态协议栈(DPDK 绕过内核)、io_uring(提交队列和完成队列放在共享内存中,一次系统调用提交成百上千个 I/O 请求)。strace 命令可以追踪任何程序的全部系统调用,是理解程序与内核交互的第一工具。

信号处理——异步事件的通知机制。信号(Signal)是 Unix 的异步通知机制:内核或进程向目标进程发送一个编号(1-64),目标进程可以执行默认动作(终止、忽略、停止)、或被中断去执行注册的信号处理函数(signal handler)。常见信号:SIGINT(Ctrl+C)、SIGTERM(请求终止,可捕获清理)、SIGKILL(9,强制杀死,不可捕获不可忽略)、SIGSEGV(非法访存)、SIGPIPE(写已关闭的管道)、SIGCHLD(子进程状态变化)、SIGALRM(定时器到期)、SIGSTOP/SIGCONT(暂停/恢复)。信号处理的实现细节充满陷阱:内核在信号到达时把处理函数信息压入用户栈,中断当前执行流跳转过去,处理函数返回时通过 sigreturn 系统调用恢复现场——因此信号处理函数可能打断程序的任何位置,包括打断了 malloc 内部的临界区。这要求处理函数必须是「异步信号安全」的:只能调用 write、_exit 等少数安全函数,绝不能调用 printf、malloc(它们内部有锁和全局状态,重入会死锁或堆损坏)。正确姿势是处理函数里只设置一个 volatile sig_atomic_t 标志位(或写一个管道/self-pipe),主循环轮询标志。信号还有可靠性问题:标准信号(1-31)不排队,同种信号在处理前再次到达会丢失;实时信号(34-64,SIGRTMIN 起)支持排队和优先级。多信号掩码(sigprocmask)可以阻塞信号递送,pthread_sigmask 处理线程场景。 signo 语义的复杂性使现代代码更倾向用 signalfd(Linux)把信号转化为文件描述符事件,纳入 epoll 事件循环统一处理。

内存分配与地址空间布局——brk、mmap 与进程映像。Linux 进程的虚拟地址空间从低到高依次是:代码段(.text,只读可执行)、数据段(.data/.bss,已初始化与未初始化全局变量)、堆(brk 指针向上增长)、内存映射区(mmap 区域,共享库和大块分配在此)、栈(向下增长)、内核空间(用户态不可见)。malloc 的实现(glibc 的 ptmalloc)是双通道的:小于 128KB 的请求从堆顶 brk 扩展的区域中切分,用空闲链表和 bins 管理,free 后内存留在用户态分配器中复用(这就是为什么 free 之后进程 RSS 常常不下降);大于等于 128KB(M_MMAP_THRESHOLD)的请求直接 mmap 匿名页,free 时 munmap 立即归还内核。多线程下 ptmalloc 使用 arena 机制减少锁竞争,但碎片和跨 arena 迁移仍是内存膨胀的常见原因。mmap 的按需分配语义值得强调:mmap 返回成功时并没有分配物理内存,只建立了虚拟区域(VMA);首次访问触发缺页中断才分配物理页并清零——所以 calloc 大块内存几乎免费(mmap 的页天然清零),而 malloc 大数组后逐个赋值的过程会伴随大量缺页中断。栈的增长也是自动的:访问栈顶下方未映射区域触发缺页,内核检查是否在 RLIMIT_STACK 内,是则扩展栈 VMA。地址空间布局随机化(ASLR)每次运行都随机化栈、堆、mmap 区、共享库的基址,让攻击者无法预知跳转目标,是现代系统的基础安全机制。用 cat /proc/self/maps 可以亲眼看到自己进程的完整地址空间布局。

代码示例

/* fork 与写时复制:观察父子进程的内存独立性 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

static int global_var = 100;

int main(void) {
    int stack_var = 200;
    int *heap_var = malloc(sizeof(int));
    *heap_var = 300;

    pid_t pid = fork();
    if (pid == 0) {
        global_var += 1;
        stack_var += 2;
        *heap_var += 3;
        printf("child : pid=%d global=%d stack=%d heap=%d\n",
               getpid(), global_var, stack_var, *heap_var);
        printf("child : &global=%p &stack=%p heap=%p\n",
               (void *)&global_var, (void *)&stack_var, (void *)heap_var);
        _exit(0);
    }

    wait(NULL);
    printf("parent: pid=%d global=%d stack=%d heap=%d\n",
           getpid(), global_var, stack_var, *heap_var);
    printf("parent: &global=%p &stack=%p heap=%p\n",
           (void *)&global_var, (void *)&stack_var, (void *)heap_var);
    free(heap_var);
    return 0;
}

/* 运行结果:子进程中三个变量都被修改(101/202/303),
   父进程中三个变量纹丝不动(100/200/300)——
   证明 fork 后父子拥有完全独立的地址空间副本。
   但有趣的是打印出的虚拟地址完全相同!
   这正是虚拟内存的魔力:父子进程的同一虚拟地址
   被页表映射到不同的物理页。而且 fork 的瞬间
   并没有真正复制内存——内核把父进程的页全部
   标记为只读并让父子共享,任何一方写入时触发
   缺页中断,内核才复制那一页(Copy-On-Write)。
   这就是为什么 fork 一个 10GB 的进程只需毫秒级。
   注意子进程用 _exit 而非 exit:exit 会刷新
   stdio 缓冲区,而 fork 复制了父进程的缓冲内容,
   用 exit 可能导致缓冲区数据被输出两次。
   这个细节本身就是「理解底层才能写对代码」的例证。 */
/* 观察虚拟地址空间布局与缺页按需分配 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

static void show_maps(void) {
    FILE *f = fopen("/proc/self/maps", "r");
    char line[256];
    while (fgets(line, sizeof(line), f))
        fputs(line, stdout);
    fclose(f);
}

static long rss_kb(void) {
    FILE *f = fopen("/proc/self/statm", "r");
    long size, resident;
    if (fscanf(f, "%ld %ld", &size, &resident) != 2)
        resident = -1;
    fclose(f);
    return resident * (sysconf(_SC_PAGESIZE) / 1024);
}

int main(void) {
    printf("RSS before mmap: %ld KB\n", rss_kb());

    size_t len = 1UL << 30;
    char *p = mmap(NULL, len, PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED) { perror("mmap"); return 1; }

    printf("RSS after  mmap: %ld KB (mapped 1GB virtual)\n", rss_kb());

    for (size_t i = 0; i < len; i += 4096)
        p[i] = 1;

    printf("RSS after touch: %ld KB (every page faulted in)\n", rss_kb());
    printf("\n--- /proc/self/maps ---\n");
    show_maps();
    munmap(p, len);
    return 0;
}

/* 这个实验直观展示虚拟内存的「按需分配」语义。
   mmap 1GB 之后 RSS(常驻物理内存)几乎不变——
   内核只创建了虚拟内存区域(VMA)记录,
   没有分配任何物理页。随后每隔 4096 字节写一个字节,
   恰好触碰每一页,每次触碰都引发一次缺页中断,
   内核分配一个物理页并清零后建立映射,
   RSS 因此暴涨约 1GB。maps 输出中可以看到
   这块 1GB 的匿名映射区域,以及代码段、数据段、
   堆、栈、共享库的完整布局——对照第 2 章的
   地址空间图逐一辨认。延伸实验:用 strace -e mmap
   观察 malloc 大块内存时 glibc 何时改用 mmap;
   用 perf stat -e page-faults 统计触碰循环
   产生的缺页次数,验证恰好等于 1GB/4KB = 262144 次;
   对比 MAP_POPULATE 标志一次性预取所有页的耗时差异。 */
/* 系统调用开销测量:getpid 与函数调用对比 */
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <time.h>

static inline long long ns_now(void) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec;
}

static int dummy_call(void) { return 42; }

int main(void) {
    const int N = 10000000;

    long long t0 = ns_now();
    volatile int r1 = 0;
    for (int i = 0; i < N; i++) r1 = dummy_call();
    long long t1 = ns_now();

    volatile long r2 = 0;
    for (int i = 0; i < N; i++) r2 = syscall(SYS_getpid);
    long long t2 = ns_now();

    printf("plain function call : %6.2f ns/call\n",
           (double)(t1 - t0) / N);
    printf("getpid syscall      : %6.2f ns/call\n",
           (double)(t2 - t1) / N);
    printf("ratio               : %6.1fx\n",
           (double)(t2 - t1) / (double)(t1 - t0));
    return 0;
}

/* 普通函数调用约 1-2 纳秒(几个周期),
   而 syscall(SYS_getpid) 强制走真实的系统调用路径
   (绕过 glibc 对 getpid 的结果缓存),
   每次约 300-800 纳秒,相差数百倍。
   开销构成:syscall 指令的特权级切换、
   保存/恢复用户态寄存器、内核入口的
   Spectre/Meltdown 缓解措施(如 KPTI 页表切换
   会让开销翻倍)、系统调用号查表分发、
   sysret 返回用户态。这个数字解释了
   高性能编程的几条铁律:批量系统调用
   (writev 一次写多个缓冲区、io_uring 批量提交)、
   用户态缓冲(stdio 的 fwrite 攒够 4KB 才 write)、
   内存映射代替 read(mmap 后访存零系统调用)。
   注意 clock_gettime 本身在 vDSO 机制下
   不陷入内核(内核把时间数据映射到用户空间),
   否则测量工具本身就成了被测对象。
   vDSO 正是「为减少系统调用开销而生」的典型设计。 */
/* 信号处理的正确姿势:异步信号安全与 self-pipe */
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>

static volatile sig_atomic_t got_sigint = 0;
static int self_pipe[2];

static void handler(int signo) {
    got_sigint = 1;
    int saved_errno = errno;
    char c = 1;
    (void)write(self_pipe[1], &c, 1);
    errno = saved_errno;
}

int main(void) {
    if (pipe(self_pipe) == -1) { perror("pipe"); return 1; }

    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = handler;
    sa.sa_flags = SA_RESTART;
    sigaction(SIGINT, &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);

    printf("pid=%d, press Ctrl+C ...\n", getpid());
    while (!got_sigint) {
        char buf[16];
        ssize_t n = read(self_pipe[0], buf, sizeof(buf));
        if (n == -1 && errno == EINTR)
            continue;
        pause();
    }

    const char *msg = "caught SIGINT, cleaning up, bye\n";
    (void)write(STDOUT_FILENO, msg, strlen(msg));
    return 0;
}

/* 这段代码演示信号处理的三条军规。
   第一,处理函数中只设置 volatile sig_atomic_t
   标志位——这是标准保证原子读写的唯一类型,
   绝不调用 printf/malloc/free 等非异步信号安全函数:
   信号可能恰好打断主流程中 malloc 的临界区,
   处理函数再调 malloc 就会死锁或堆损坏。
   第二,处理函数保存并恢复 errno——
   write 等调用可能修改 errno,破坏被打断代码的状态。
   第三,self-pipe 技巧:处理函数向管道写一个字节,
   把异步信号转化为文件描述符上的可读事件,
   这样 select/poll/epoll 事件循环就能统一处理
   信号与 I/O,避免「检查标志与阻塞等待之间
   信号到达导致永久阻塞」的经典竞态。
   SA_RESTART 让被信号中断的慢速系统调用自动重启,
   但 read 管道本身仍需处理 EINTR。
   现代替代方案是 Linux 的 signalfd,
   直接以文件描述符形式接收信号。 */

实验笔记

本章实验建议在 Linux 虚拟机中完成(需要 root 权限做部分观察),配套工具:strace、ltrace、perf、procps。实验一「进程生命周期观察」:编写程序 fork 三个子进程,每个子进程 sleep 不同时长后以不同退出码结束;父进程用 waitpid 的 WNOHANG 轮询与阻塞等待两种方式回收,打印每个子进程的 PID 与退出状态;再让一个子进程先于父进程结束且不回收,用 ps 观察僵尸进程(Z 状态)及其在父进程 wait 后消失的过程;用 kill -STOP/-CONT 观察 T 状态。实验二「地址空间解剖」:运行代码示例中的 maps 观察程序,对照画出自己程序的地址空间布局图(text/data/bss/heap/mmap/stack 的起止地址与权限);用 gdb 断在 main,打印全局变量、局部变量、malloc 指针的地址,验证它们分别落在哪个段;开启与关闭 ASLR(echo 0/2 > /proc/sys/kernel/randomize_va_space)各运行三次,记录 mmap 基址的随机化范围。实验三「写时复制的实证」:父进程分配并写入 512MB 数组后 fork,用 /proc/[pid]/smaps 分别查看父子进程的 Rss 与 Pss(按比例分摊的共享内存),验证 fork 后物理内存并未翻倍;子进程逐段改写数组,每改 64MB 打印一次父子双方的 Rss,观察私有页随写入逐渐分离的过程。实验四「系统调用画像」:用 strace -c 分别统计 cat 大文件、cp 同一文件、grep 递归搜索的系统调用次数与耗时分布,找出各自的热点调用;再用 strace -e trace=read,write 观察 stdio 缓冲如何把多次 fputc 合并成一次 write;最后用代码示例的方法测量本机 getpid 系统调用的纳秒级开销,并对比开启 KPTI(/sys/devices/system/cpu/vulnerabilities/meltdown)前后的差异。每个实验写报告时回答一个「为什么」:为什么僵尸进程存在、为什么 fork 不翻倍内存、为什么 read 次数远小于 fputc 次数——能回答为什么,才算真正理解了操作系统。

延伸阅读

  • 《操作系统导论》(Operating Systems: Three Easy Pieces, Arpaci-Dusseau)——免费在线阅读的现代 OS 教材,以虚拟化、并发、持久性三条主线组织内容,代码示例丰富,是本章最好的系统性补充。
  • 《深入理解计算机系统》第 8 章「异常控制流」与第 9 章「虚拟内存」——CSAPP 对进程、信号、地址翻译的讲解与本章高度互补,配套的 Shell Lab 要求亲手实现 fork/exec/wait/信号处理的 mini shell。
  • 《Linux 内核设计与实现》(Robert Love)——薄而精的 Linux 内核入门书,进程调度、系统调用、内存管理各占一章,适合在读完本章后了解 Linux 的具体实现选择。
  • 《深入理解 Linux 内核》或《Linux 内核完全注释》(赵炯)——前者是百科全书式参考,后者逐行注释 0.11 版内核源码;阅读早期精简版内核是理解 OS 全貌的高效路径。
  • Understanding the Linux Virtual Memory Manager(Mel Gorman)——Linux 内存管理子系统的权威深度资料,页表、伙伴系统、slab、缺页路径无所不包,适合进阶。
  • Brendan Gregg《Systems Performance》——把本章的上下文切换、TLB、缺页等概念放到完整性能分析方法论中,perf/strace/vmstat 等工具的实战指南,SRE 与性能工程师必读。
  • MIT 6.S081 (xv6) 课程讲义与源码——用两千行 C 实现的教学操作系统 xv6,配套 11 个动手实验(页表、陷阱、文件系统),亲手改内核是理解 OS 的终极方式。
  • The Linux Programming Interface(Michael Kerrisk)——Linux/Unix 系统编程的百科全书,信号、进程、mmap 各章的 API 细节与陷阱讲解无出其右,适合作案头参考。
3/5操作系统
0%