第 10 章 · OS 实例

理论讲完,最后一章做一次「解剖实验」:挑一个活的进程,用 Linux 的手术刀(/proc 目录)把它从头到尾切开,验证前面九章的每一个概念不是抽象名词,而是这台机器上实实在在可观测的东西。这一章也是操作系统的「实验总复习」,做完它,再去学容器、虚拟化(本站的 Linux/KVM/Docker 课程)时,你会发现那些新概念只是本章零件的重新组合。

10.1 解剖一个进程

起一个睡眠中的进程,拿到 PID(假设 21453),它的全部档案就在 /proc/21453/ 下。挑几份最重要的病历看:

cat /proc/21453/status | head -8
# Name:   sleep
# State:  S (sleeping)          ← 第 2 章的五状态模型,活的
# Pid:    21453
# PPid:   19402                 ← 父进程,进程树的证据
# Threads: 1                    ← 内核级线程数

cat /proc/21453/maps
# 00400000-0040b000 r--p ... /usr/bin/sleep     ← 代码段(只读)
# 0040b000-0040c000 rw-p ... /usr/bin/sleep     ← 数据段
# 7f8a...-7f8b... r--p ... /lib/x86_64/libc.so.6 ← 共享库映射
# 7ffd3...-7ffd4... rw-p [stack]                ← 栈,高地址,向下长

maps 这张图就是第 6 章虚拟内存的实景:每个虚拟地址区间,权限位(可读 r、可写 w、可执行 x)、映射来源(文件还是匿名)一目了然。几个直观结论:代码段永远只读可执行(权限位在 MMU 那里硬件强制,计组第 8 章);栈在最高处向下长,堆在中间向上长,两者之间的大片空洞是「还没用的地址空间」;libc 是好几个进程共享的同一份映射(共享库的物理机制)。

ls -l /proc/21453/fd/
# 0 -> /dev/pts/3   ← 标准输入,连着你的终端
# 1 -> /dev/pts/3   ← 标准输出
# 2 -> /dev/pts/3   ← 标准错误

第 8 章说「一切皆文件」,fd 目录就是证据:进程的每个打开文件是一个文件描述符链接,lsof -p 21453 看到的就是这些。运维排查「文件被谁占着」(第 8 章删了文件空间不释放那个谜题),全靠它。

10.2 Linux 和 Windows:一张对照表加一点评价

LinuxWindows
内核宏内核 + 可加载模块混合内核(NT)
可执行格式ELFPE(.exe)
文件系统ext4/xfs/btrfsNTFS/ReFS
权限模型uid/gid + rwx 位 + ACLACL + SID,细到每条
设备抽象文件(/dev、/proc)对象管理器,部分文件化
进程 APIfork + exec(组合拳)CreateProcess(一步到位)
源码开放(GPL)闭源

两派的选择各有道理,fork+exec 的「先复制再替换」看着绕,却让「复制自己再干活」(fork 出 worker)和「换身程序」(exec)成了两个可独立组合的原语,shell 的管道、Redis 的快照都白嫖这个组合性;CreateProcess 一步到位,简单直接,但想「复制一个运行中的自己」反而要绕。我自己的体会:这两条设计路线的差异,比界面上那点区别更能解释两个生态的气质。

10.3 五大段的实物验证

C 指针课第 2 章画过「进程内存布局」,当时是示意图,现在用程序把它量出来:

#include <stdio.h>
#include <stdlib.h>
int g = 1;                    // 已初始化数据段
int main(void) {
    static int s = 2;         // 也在数据段
    char *lit = "hello";      // lit 在栈,字面量在只读段
    int local = 3;            // 栈
    int *p = malloc(8);       // 堆
    printf("data=%p rodata=%p stack=%p heap=%p\n", &g, lit, &local, p);
    return 0;
}

某次运行的真实输出:

data=0x5589c2e81020 rodata=0x5589c2e7e008 stack=0x7ffd9c1b4544 heap=0x5589c3a3c2a0

按地址高低排:只读段(最低,和代码段挨着)→ 数据段 → 堆(向高处长)→ 栈(最高,向低处长)。自己跑一遍,再对照 cat /proc/self/maps,教科书上的五段图和真机输出严丝合缝,这种「理论落地图成功」的瞬间是这门课最好的复习。堆在数据段之上隔了十几 GB?那是 ASLR(地址空间随机化)在捣乱,安全机制故意把各段基址随机偏移,让漏洞利用猜不到地址。关掉它(setarch -R)再跑,地址就紧挨着了。

10.4 从进程到容器:一个预告

最后把镜头拉远。本章的进程知识加上第 6 章的页表,再补一个新概念 namespace(让进程只「看见」自己这组的 PID、网络、挂载点,造成隔离假象),你就有了容器的全部原料:Docker 容器 = 受 namespace 限制 + 受 cgroup 限额的普通进程,里面跑着程序,外面共用一台 Linux 内核。没有魔法,没有微型操作系统,只是「可见性」和「配额」两项 OS 原生能力的组合拳。这也是为什么容器离不开 Linux(每家 OS 的内核机制不同),以及为什么虚拟机(第 5 章 KVM)和容器不是竞争关系:一个虚拟硬件,一个共享内核。

常见错误

  • 用 free 的 used 判断内存紧张(第 7、8 章都提过):要看 available,缓存占了的大头是可回收的。
  • 拿 kill -9 当万能钥匙。SIGKILL 不给进程收尾机会,数据库被 -9 后要靠日志重放恢复(WAL 的价值就在这);常规重启先 SIGTERM 让它善终,-9 是最后的手段。
  • 以为 /proc 只是只读展览馆。很多参数(/proc/sys/*)可写,经典的 echo 1 > /proc/sys/net/ipv4/ip_forward 打开转发,sysctl 就是它的包装。

练习

  1. 动手:解剖一个你自己的进程:status 看 State 和 Threads,maps 数一数有几个可执行段,fd 看标准输入输出去向。挑一个图形程序,看看它比命令行程序多开了哪些 fd。
  2. 动手:跑 10.3 的地址程序五次,记录五次输出,观察 ASLR 的效果;setarch -R ./a.out 关掉再跑,验证地址固定。
  3. 综合:解释 ps aux 里一个进程的 VSZ 远大于 RSS 的现象,用第 6 章(虚拟地址空间)和 malloc(按需缺页)的知识各答一句。
  4. 思考:容器里的进程在宿主机 ps 里看得见吗?用本章的 namespace 概念推理,再开个 Docker 实验验证(没有环境就看文档)。这道题是通往 Linux/KVM/Docker 课程的桥。