Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

0.1 Linux 世界观:内核、GNU、发行版与标准

本章回答

Linux 到底是什么?内核、GNU 用户态、发行版、POSIX/FHS 各占哪一层?为什么说「Linux 严格来说只是内核」,而你在服务器上敲的每一条命令,几乎都不由内核提供?

读完你会

从 uname、/etc/os-release、getconf 的输出里拆出内核与用户态;用 modules.builtin 与 /proc/cmdline 判断模块和启动方式;解释一个 ELF 程序从 libc 到系统调用的路径;判断某个「Linux 上能跑」的说法是内核保证、libc 保证,还是发行版约定。

你输入 ls,看到一列文件名。这条命令背后至少有四层东西在工作:终端把按键交给 Shell,Shell 在 $PATH 里找到 /usr/bin/ls,内核执行这个 ELF 文件并把目录内容读出来,glibc 把内核返回的字节翻译成你看到的格式。四层里只有第三层是 Linux 内核。

日常说「Linux 系统」时,指的是这四层打包在一起的发行版。这个含混的说法在运维里够用,在排障时会误导人:内核 panic、glibc 版本不兼容、发行版把默认配置改坏,是三类完全不同的问题,证据和修复路径也不同。

这一章先建立一张分层地图。后面的权限、进程、磁盘、systemd、网络,都会反复回到这张图上问一句:这件事由谁负责,边界在哪里。读完这一章不要求你记住所有标准编号,只要求你能把「内核行为」「C 库行为」「发行版策略」分开说。

0.1.1 从一次 uname -a 读起

先看一台 Ubuntu 24.04 机器上的真实输出:

$ uname -a
Linux lab 6.8.0-45-generic #45-Ubuntu SMP PREEMPT_DYNAMIC Fri Oct  4 15:16:00 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux
$ uname -s
Linux
$ uname -r
6.8.0-45-generic
$ uname -m
x86_64
$ uname -o
GNU/Linux

uname -a 的字段依次是:内核名、主机名、内核 release、内核 version 字符串、机器硬件架构、处理器架构、硬件平台、操作系统名。排障时真正有用的是第 3 和第 5 个字段:前者决定你能用哪些内核特性,后者决定你该下载哪个架构的二进制包。

注意 uname -o 输出的是 GNU/Linux,不是 Linux。这不是文字游戏:uname 这个程序本身来自 GNU coreutils,它知道整套用户态主要来自 GNU。内核并不关心自己叫什么都,它只提供一个系统调用接口。

三个容易混淆的版本号:

来源命令例子变化频率说明
内核uname -r6.8.0-45-generic随内核更新决定驱动、syscall、cgroup 版本
libcldd --version2.39随发行版大版本决定符号版本与部分 POSIX 行为
发行版/etc/os-release 的 VERSION_ID24.04每半年或数年决定包版本、默认配置、支持周期

一个常见误判:看到 6.8.0-45-generic 就以为系统是「Linux 6.8」。Ubuntu 会在同一个主版本上打几百个补丁,-45-generic 是 Ubuntu 的 ABI 编号,不是上游 6.8 的第 45 个版本。要判断某个上游特性是否存在,应当查 uname -r 的主次版本加上发行版 backport 说明,而不是猜。

0.1.2 内核的职责与边界

定义 0.1.1 · 内核(kernel)

内核是常驻内存、运行在 CPU 最高特权级(x86-64 的 ring 0)的那部分代码。它管理进程与调度、虚拟内存、文件系统、网络协议栈、设备驱动与中断,并通过系统调用(system call)向用户态提供唯一合法的入口。用户态代码不能直接访问页表、中断控制器或磁盘控制器。

把内核的职责拆成五块,每块都对应后面的一篇:

职责内核里做什么用户可见的接口本书展开
进程与调度创建/销毁任务、上下文切换、CPU 分配fork/execve/wait4、/proc第 1、3 篇
虚拟内存页表、缺页、映射、回收mmap/brk、/proc/meminfo1.9、2.12
文件系统inode、目录项、页缓存、VFSopen/read/write、mount2.2–2.6、0.10
网络socket、TCP/IP、netfilter、驱动socket/sendto、/proc/net第 4 篇、0.13
设备与中断驱动、DMA、中断处理、电源/dev、/sys、ioctl3.x、0.10

内核的边界同样重要。下面这些不是内核提供的:

  • printf、malloc、fopen:glibc 或 musl 提供,内核只有 write、brk/mmap、open。
  • 管道符 |、通配符 *、引号、变量展开:Shell 提供,内核只看到展开后的 argv。
  • apt、systemctl、ps:用户态程序,通过 /proc、/sys、D-Bus 与内核或守护进程对话。
  • 「开机自启」「日志轮转」「防火墙默认拒绝」:发行版策略,由 systemd、logrotate、nftables 规则实现。
机制与策略分开

内核提供机制(能做这件事),发行版和用户态决定策略(默认做不做、对谁做)。同一个 open 系统调用,在 Ubuntu 和 Alpine 上语义相同;但默认 umask、默认挂载选项、默认防火墙规则可以完全不同。排障时先问「这是机制坏了,还是策略配错了」,能省一半时间。

Linux 是宏内核(monolithic kernel):驱动、文件系统、网络栈都跑在内核地址空间里,共享同一套页表。好处是调用路径短,坏处是一个驱动越界就可能拖垮整机。内核模块(.ko)能在运行时加载,但仍然运行在 ring 0,不是沙箱。把「可加载模块」理解成「可选装的内核代码」而不是「安全隔离的插件」,是读第 3 篇的前提。

内核还通过两个伪文件系统把内部状态投影给用户态:/proc 面向进程与内核参数,/sys 面向设备与驱动模型。它们不是磁盘上的文件,读写由内核代码现场生成。cat /proc/cpuinfo 不是在读一个文件,而是在触发一段内核代码。

┌──────────────────────────────────────────────────────────────┐
│ 应用:bash、nginx、python、你的程序                          │
├──────────────────────────────────────────────────────────────┤
│ 用户态运行库:glibc / musl(syscall 包装、malloc、NSS)      │
├─────────────── 系统调用边界(syscall instruction)───────────┤
│ 内核:调度器 │ 虚拟内存 │ VFS │ 网络栈 │ 驱动 │ 安全模块     │
├──────────────────────────────────────────────────────────────┤
│ 硬件:CPU │ 内存 │ NVMe │ 网卡 │ 中断控制器 │ 固件           │
└──────────────────────────────────────────────────────────────┘

这张图的读法是:越靠上越可替换,越靠下越难替换。换 Shell 只影响一个用户,换 libc 要重编整个用户态,换内核要重启并承担驱动风险。线上事故里,「升级了一个用户态包」和「换了内核」的爆炸半径不在一个量级。

0.1.3 GNU 用户态:内核之外的另一半

1991 年 Linux 内核出现时,GNU 计划已经写了十年用户态工具,但缺一个能跑的内核。两者结合,才有了今天能用的系统。这段历史解释了一个现实:你在命令行里用的绝大多数命令,版权页上写的是 GNU,不是 Linus Torvalds。

GNU 用户态的核心组件:

组件代表你每天都在用的部分
ShellBash变量、管道、作业控制、脚本
coreutilsls、cp、mv、rm、sort、date文件与文本的基本操作
文本工具grep、sed、awk、diff第 0.5、0.6 章的全部内容
编译工具链gcc、binutils、make把源码变成 ELF
C 库glibc系统调用包装、malloc、DNS 解析、locale

C 库是最容易被忽略的一层。它至少做四件事:

  1. 系统调用包装:write(2) 在你眼里是函数,在 CPU 眼里是一条 syscall 指令加寄存器约定。glibc 负责搬参数、处理 errno、在必要时重试 EINTR。
  2. 内存管理:malloc 在用户态维护空闲链表,只有不够时才向内核要页。这就是为什么 free 看到的 RSS 和程序「申请了多少」经常对不上。
  3. 名字服务:getaddrinfo 读 /etc/nsswitch.conf,再决定查 /etc/hosts、DNS 还是 systemd-resolved。ping 能通但浏览器打不开,常常卡在这一层。
  4. ABI 与符号版本:同一个 libc.so.6 里可以有多个版本的 memcpy 符号,加载器按二进制所需的版本选择。这保证了旧程序在新系统上还能跑。

换一个 libc,行为会变。Alpine 用 musl,二进制更小、启动更快,但 getaddrinfo 的并发行为、malloc 的回收策略、locale 支持都与 glibc 不同。把 glibc 上编译的二进制直接丢进 Alpine 镜像,通常连加载器都找不到:

$ ldd --version | head -1
ldd (Ubuntu GLIBC 2.39-0ubuntu8.3) 2.39
$ file /bin/ls
/bin/ls: ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
$ docker run --rm alpine:3.20 ldd --version 2>&1 | head -1
musl libc (x86_64)

第一行告诉你 libc 版本;第二行的 interpreter 告诉你动态加载器的路径,这个路径是编译时写死的,不能靠 LD_LIBRARY_PATH 改。第三行说明 Alpine 用的是 musl。跨发行版分发二进制时,这三行比 uname 更有诊断价值。

0.1.4 发行版:把零件装成可运维的系统

定义 0.1.2 · 发行版(distribution)

发行版是把内核、C 库、用户态工具、包管理器、初始化系统与一套默认配置打包、签名、测试并维护的整体。它承诺的是「这个组合经过集成测试,且有安全更新渠道」,而不只是「这些软件能放在同一台机器上」。

判断两个系统是不是「同一个发行版」,看五件事:

组成Ubuntu 24.04Fedora 40ArchAlpine
内核Ubuntu 6.8Fedora 6.9滚动6.6(lts)
C 库glibc 2.39glibc 2.39glibcmusl
包格式.deb / apt.rpm / dnf.pkg.tar.zst / pacman.apk / apk
initsystemd 255systemd 255systemdOpenRC
默认策略偏保守、LTS偏新、半年上游原样极小、容器优先

这张表说明:发行版的差异主要不在内核,而在包管理、默认配置与支持周期。同一份 C 源码,在这四个系统上通常都能编译;但同一份 .deb 只在 Debian 系能用,同一套 systemd unit 在 Alpine 上不存在对应服务。

发行版还决定了三件运维上的事:

  • 版本冻结策略:Ubuntu LTS 冻结主版本、backport 安全补丁;Arch 滚动更新。前者可预测,后者新特性快,但每次 pacman -Syu 都可能需要人工介入。
  • 默认安全策略:AppArmor(Ubuntu)与 SELinux(Fedora/RHEL)的默认配置不同;防火墙前端不同;umask 与 sudo 默认组不同。
  • 支持周期:Ubuntu 24.04 标准支持到 2029,ESM 更长;Fedora 每个版本约 13 个月。把「能跑」当「能长期维护」是生产环境最常见的误判。
关于「Linux 是什么」的五个误解
  1. 「Linux 就是整个操作系统」——严格说 Linux 是内核。完整系统还需要 libc、Shell、coreutils 与包管理;uname -o 输出 GNU/Linux 就是在提醒这一点。
  2. 「发行版只是换个桌面主题」——发行版还决定包版本、ABI、默认安全策略与支持周期。把 Ubuntu 的二进制直接复制到 Alpine,通常连动态加载器都对不上。
  3. 「容器里的 uname -r 是容器自己的内核」——容器与宿主机共享内核,uname -r 显示的是宿主机内核;/etc/os-release 显示的才是镜像里的用户态。
  4. 「POSIX 认证过,行为就完全一致」——POSIX 规定接口语义,不规定性能、错误文本、扩展接口与默认配置。ps 的列、grep -P、systemd 都不在 POSIX 里。
  5. 「源码能编译,二进制就能随便拷」——二进制兼容取决于架构、libc、动态加载器路径与符号版本,四者任一不匹配都可能失败。

0.1.5 标准:POSIX、FHS、LSB 各保证什么

标准解决的是「换一个 Unix-like 系统,程序还要不要改」。答案取决于你依赖哪一层标准。

标准管什么不管什么怎么验证
POSIX.1系统调用、C 库接口、Shell 语法与工具行为性能、默认配置、扩展接口getconf、posixoptions、编译时定义 _POSIX_C_SOURCE
FHS目录层级与用途(/etc、/var、/usr)具体文件、包管理器布局man 7 hier、file-hierarchy(7)
LSB旧式的发行版二进制接口与初始化脚本现代 systemd 系统已基本废弃,仅历史参考
x86-64 SysV ABI函数调用约定、寄存器用途、栈对齐系统调用号(那是内核 ABI)编译器与链接器默认遵守

POSIX 的价值在源码级可移植。你在 Ubuntu 上写的 open/read/fork 程序,只要不碰 Linux 扩展,拿到 macOS 或 FreeBSD 上重编也能跑。代价是你要放弃一些好用的 Linux 专有接口,比如 epoll、signalfd、inotify、O_TMPFILE。

写可移植代码时,用特性测试宏把扩展显式打开:

#define _POSIX_C_SOURCE 200809L   /* 只要 POSIX.1-2008 接口 */
#define _GNU_SOURCE               /* 或者:要 Linux/GNU 扩展,二者取一 */
#include <unistd.h>
#include <fcntl.h>

_GNU_SOURCE 会顺带打开 _POSIX_C_SOURCE,所以本书的用户态示例统一用它。反过来,如果你在写一个要进 BSD 的库,就不要用 _GNU_SOURCE,并定期用 -std=c17 -pedantic 编译一次,让编译器帮你抓扩展。

FHS 是目录约定,不是内核强制。/bin 在现代 Ubuntu 上已经是指向 /usr/bin 的符号链接(usrmerge),这是为了简化容器镜像与只读根文件系统。老脚本里写死 /bin/ls 通常还能跑,但 ls -l /bin 会暴露它是链接。/etc 放配置、/var 放可变数据、/run 放本次启动的运行时状态、/proc 与 /sys 是内核投影——这条规则在排查「配置到底改哪了」时非常有用。

LSB 曾经规定 init 脚本的返回码、lsb_release 命令与包命名。systemd 普及后它基本退出历史舞台,只在旧认证资料里出现。看到 lsb_release 不存在不必惊讶,读 /etc/os-release 更可靠。

0.1.6 给机器做身份卡

排障第一步不是敲命令,而是确认「我在哪台机器、什么用户态、什么架构」。四个命令足够:

$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.1 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.1 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
UBUNTU_CODENAME=noble
$ getconf LONG_BIT
64
$ getconf PAGE_SIZE
4096
$ dpkg --print-architecture
amd64

ID 与 ID_LIKE 是给脚本用的:ID=ubuntu、ID_LIKE=debian,表示它兼容 Debian 的包管理习惯。VERSION_ID 是字符串,不要当浮点数比较("24.04" 和 24.4 不是一回事)。PRETTY_NAME 只适合给人看,解析它会因为语言和版本格式变化而失败。

脚本里读这些字段,正确姿势是 source 它,而不是 grep + cut:

#!/usr/bin/env bash
set -euo pipefail

# /etc/os-release 是 shell 变量格式,source 比正则稳
if [[ -r /etc/os-release ]]; then
    . /etc/os-release
    printf 'distro=%s version=%s like=%s\n' \
        "${ID:-unknown}" "${VERSION_ID:-unknown}" "${ID_LIKE:-none}"
else
    printf 'distro=unknown (no /etc/os-release)\n'
fi

printf 'kernel=%s arch=%s page=%s\n' \
    "$(uname -r)" "$(uname -m)" "$(getconf PAGE_SIZE)"

关键点有三处:[[ -r ... ]] 先确认可读;${ID:-unknown} 给缺失字段兜底;"$(...)" 全部加引号,防止值里有空格。这个脚本在 Ubuntu、Fedora、Alpine 上都能跑,区别只是输出内容。

在容器里要特别小心:

# 容器内:用户态是镜像的,内核是宿主机的
docker run --rm ubuntu:24.04 sh -c 'cat /etc/os-release | head -1; uname -r'
# Ubuntu 24.04.1 LTS
# 6.8.0-45-generic   ← 这是宿主机的内核

所以「容器里升级内核」是一个不成立的操作。容器能换的是用户态和内核暴露的接口视图(namespace、cgroup),换不了内核本身。需要不同内核时,用虚拟机,不是容器。

0.1.7 x86-64 与 ARM64:只讲会影响实验的差异

本书的命令在两种架构上都成立,但有四处差异会直接影响实验结果:

差异x86-64ARM64影响
页大小通常 4 KiB4/16/64 KiB(依内核配置)mmap 对齐、stat 块计数、性能测试
设备发现ACPI设备树(DT)为主,服务器用 ACPI/sys/firmware 内容、驱动绑定方式
系统调用号独立编号独立编号汇编、strace 原始输出不同;C 源码不受影响
多架构包amd64arm64容器镜像、.deb 包、预编译二进制必须匹配

页大小最容易被忽略。getconf PAGE_SIZE 在 Apple Silicon 的 Linux 虚拟机上可能返回 16384,而常见 x86 服务器是 4096。一个按 4 KiB 对齐、假设「一页就是一个块」的程序,在 16 KiB 页的系统上可能浪费 4 倍内存,或者 mmap 失败。写底层代码时用 sysconf(_SC_PAGESIZE),不要写死 4096。

设备树的差异只在读 /sys/firmware/devicetree 或写驱动时出现。日常运维感知不到,除非你在 ARM 服务器上排查网卡、PCIe 或中断亲和性。

系统调用号不同,但 C 库把差异屏蔽了。strace 在两种架构上都能显示 openat(AT_FDCWD, ...),因为它是按架构解析的;只有你手写汇编或看内核原始日志时,才会遇到编号差异。

多架构是容器时代最实际的坑:

# 在 amd64 上构建 arm64 镜像(需要 qemu binfmt)
docker buildx build --platform linux/arm64 -t app:arm64 .
# 查看一个二进制或镜像支持什么架构
file ./app
docker image inspect app:arm64 --format '{{.Architecture}}'

在 Apple Silicon 上拉一个只提供 amd64 的镜像,Docker 会用 qemu 模拟,能跑但慢一个数量级;涉及 perf、io_uring、某些 ioctl 的实验可能直接失败。看到「同样的命令在同事机器上很快」时,先确认架构与模拟层,再怀疑代码。

0.1.8 把一条命令拆回四层

分层图容易画,难的是在真实系统里指出每一层的证据。strace 能把一个进程发出的系统调用逐条打印出来,正好用来验证「哪些事是内核做的」。

$ strace -f -e trace=execve,openat,getdents64,write ls /etc >/dev/null
execve("/usr/bin/ls", ["ls", "/etc"], 0x7ffd3c2a1e10 /* 58 vars */) = 0
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/etc", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3
getdents64(3, 0x55d1c0e2a2a0, 32768) = 328
write(1, "adduser.conf\n", 13) = 13
write(1, "alternatives\n", 13) = 13
$ strace -c -e trace=openat ls /etc >/dev/null
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
100.00    0.000021           3         8           openat
------ ----------- ----------- --------- --------- ----------------
100.00    0.000021           3         8          0 total

逐行读这段输出:

  1. execve 是 Shell 调用的最后一个系统调用。它把 /usr/bin/ls 换成当前进程的地址空间;ls 自己并没有「启动」,它只是替换了 Shell 的子进程。
  2. 第一个 openat 打开 /etc/ld.so.cache,这是动态加载器在找库。此时 ls 的 main 还没开始执行。
  3. 第二个 openat 打开 libc.so.6。openat 的第三个参数 O_CLOEXEC 表示执行新程序时关闭这个 fd,避免泄漏。
  4. openat("/etc", O_DIRECTORY) 才是 ls 的业务:打开目录。
  5. getdents64 读目录项。目录内容由内核的文件系统代码提供,ls 只负责格式化。
  6. write(1, ...) 把格式化结果写到 fd 1。你在终端看到的内容,最终都是这样一组 write。

注意 ls 从未直接读磁盘。它打开目录、读目录项,内核的页缓存负责把磁盘块变成内存里的数据。如果目录刚被访问过,getdents64 可能完全不触发 I/O。这就是为什么「ls 很快」不能证明磁盘很快——它可能只证明了缓存很热。

这张表把四层与证据对上:

层在这次跟踪里的证据如果这一层出问题,你会看到
Shellexecve 之前的 fork/clone(本例未显示)命令找不到、通配符没展开
动态加载器ld.so.cache、libc.so.6 的 openatnot found、undefined symbol
libcwrite 的参数格式化(strace 只看到字节)输出错乱、locale 相关异常
内核openat、getdents64、write 的返回值EACCES、ENOENT、ENOSPC

0.1.9 文档即证据:man 的分区与查法

Linux 的文档不是「一本书」,而是按区(section)组织的 man page。同一件事在不同区里由不同层解释,读错区就会把 libc 行为当成内核行为。

区内容例子谁维护
1用户命令ls(1)coreutils
2系统调用open(2)、read(2)内核文档 / man-pages
3C 库函数printf(3)、malloc(3)glibc
5文件格式与配置passwd(5)、fstab(5)、proc(5)发行版 / 内核
7概览与约定path_resolution(7)、hier(7)、signal(7)man-pages
8管理命令mount(8)、systemctl(8)util-linux / systemd

查系统调用必须用 man 2。man read 在多数系统上会落到 read(2),但 man open 可能先命中 openvt(1) 之类的命令;显式写区号可以避免这种歧义。查 C 库包装则用 man 3,两者的差别正是本章强调的边界:

man 2 read      # 内核视角:返回值、EINTR、短读
man 3 fread     # stdio 视角:缓冲、EOF 标志、线程安全
man 7 signal    # 信号语义、默认动作、可捕获性
man 7 path_resolution   # 一个路径字符串如何变成 inode

man 7 path_resolution 值得在学权限之前读一遍。它解释了符号链接、.、..、挂载点如何逐段解析,以及为什么 openat 的 dirfd 能改变解析起点。这些规则由内核实现,不是 Shell 的约定。

man page 也有边界。它描述接口,不描述你的发行版默认配置;man 5 fstab 不会告诉你 Ubuntu 安装器写了什么。遇到配置问题,还要看 /usr/share/doc/<pkg>/、systemctl cat、/etc 下的实际文件。遇到内核行为问题,man 2 加内核源码比二手博客可靠。

不知道命令名时,man 本身帮不上忙。两套索引命令解决「先找到页,再读页」:

命令查什么例子
apropos <词> / man -k <词>在 man page 的简述里搜关键词apropos copy
whatis <命令> / man -f <命令>给出一行简述,确认是不是要找的页whatis cp
help <内建>bash 内建命令的帮助(不在 man 里)help cd、help -d
info <主题>GNU 项目的 Info 文档,通常比 man 更详细info coreutils

apropos 读的是 whatis 数据库;新装的包如果搜不到,先 sudo mandb 重建索引。help 只对内建命令有效,不带参数会列出全部内建;man cd 在多数系统上找不到,因为 cd 是 Shell 内建而不是独立程序。

版本与证据

本书的结论默认标注环境:Ubuntu 24.04、Linux 6.8、glibc 2.39、systemd 255、bash 5.2。换版本时要复核三类东西:系统调用语义(很少变)、libc 实现(偶尔变)、发行版默认配置(经常变)。man page 末尾的 Linux man-pages 6.06 之类的版本号,是你判断「这条结论有多新」的线索。

0.1.10 发行版对比:支持周期、ABI 与选型

这一节回答:同样叫 Linux,为什么 Ubuntu 的二进制在 Alpine 上跑不起来,为什么「能装」不等于「能维护五年」。

上一节的分层图只说明有哪些零件。真正决定一台机器行为的是这些零件的组合与维护承诺。把发行版当成「主题不同的同一个系统」,是选型和排障时最贵的误解。

先看八个常见发行版的硬差异:

发行版上游基础C 库init包管理内核策略支持窗口(量级)升级模型
Ubuntu 24.04 LTSDebianglibc 2.39systemd 255.deb / apt6.8 GA,另有 HWE 轨道5 年 + ESM固定版本 + backport
Debian 12独立glibc 2.36systemd 252.deb / apt6.1 LTS3 年安全 + 2 年 LTS固定版本
Fedora 40独立glibc 2.39systemd 255.rpm / dnf更新较快约 13 个月快速迭代
RHEL 9Fedora 派生glibc 2.34systemd 252.rpm / dnf5.14 长期 backport10 年固定 + backport
AlmaLinux 9RHEL 重建同 RHEL 9同 RHEL 9.rpm / dnf同 RHEL 9约 10 年跟随 RHEL
Alpine 3.20独立musl 1.2OpenRC.apk / apk6.6约 2 年固定版本
Arch Linux独立最新 glibcsystemd.pkg.tar.zst / pacman上游最新滚动滚动更新
NixOS 24.11独立glibcsystemdNix上游较新约 7 个月声明式、可回滚

这张表说明:发行版的差异主要不在内核版本,而在 libc、包管理、默认策略与支持周期。四者里,libc 决定二进制能不能跑,支持周期决定出了 CVE 谁来修,默认策略决定你排障时看到的是 AppArmor 还是 SELinux。

选型可以压成三条线:

  1. 二进制兼容线:你的产物要跑在哪些机器上?如果交付二进制,构建环境的 glibc 不能高于目标环境。经验规则是「在最老的受支持发行版上构建」,或者用容器/静态链接把 libc 依赖消掉。Python 的 manylinux 轮子、Go 的默认静态链接,都是这条线的工程化答案。
  2. 支持周期线:服务器跑几年?Ubuntu LTS 的 5 年标准支持、RHEL 的 10 年、Fedora 的 13 个月,对应的是完全不同的升级预算。把 Fedora 装到生产数据库上,不是技术错误,是维护模型错配。
  3. 运维习惯线:团队熟悉 apt 还是 dnf?用 SELinux 还是 AppArmor?有没有 Satellite/ Landscape 这类集中管理?换发行版最贵的往往不是装系统,而是重写 runbook 与监控项。

同一个 C 项目在四个发行版上,装依赖的命令就不同:

需求Debian/UbuntuFedora/RHELAlpineArch
编译工具链build-essentialgcc make(或 @development-tools)build-basebase-devel
OpenSSL 头文件libssl-devopenssl-developenssl-devopenssl
装构建依赖apt build-dep .dnf builddep foo.specapk add --virtualmakepkg -s
查文件属于哪个包dpkg -S /pathrpm -qf /pathapk info --who-owns /pathpacman -Qo /path
查包装了哪些文件dpkg -L pkgrpm -ql pkgapk info -L pkgpacman -Ql pkg

这张表说明:「源码能编译」只是起点,构建脚本要为每个发行版写分支。CMake/Meson 能屏蔽一部分差异,但包名和 pkg-config 路径仍然要按发行版处理。

支持周期不是一张营销表,它会变成具体的补丁行为。以 OpenSSL 为例:RHEL 9 会在整个 10 年里 backport 安全修复,但主版本可能一直不变;Arch 会直接升级到新主版本,依赖它的程序要跟着重编。前者稳定但缺少新特性,后者新但每次 pacman -Syu 都可能需要人工介入。判断「这个漏洞修没修」,不能只看版本号,要看发行版的勘误(Ubuntu 的 USN、RHEL 的 RHSA)。

发行版内核不是上游 vanilla

uname -r 的 6.8.0-45-generic 里,6.8.0 是上游基线,-45 是 Ubuntu 的 ABI 编号,generic 是配置风味(还有 lowlatency、kvm、aws)。同一台上游特性是否存在,要查 /boot/config-$(uname -r) 与发行版 backport 说明:

grep -E 'CONFIG_(HZ|PREEMPT|CGROUP|PSI)=' /boot/config-$(uname -r)
uname -v        # #45-Ubuntu SMP PREEMPT_DYNAMIC ... 构建字符串

CONFIG_HZ=250 与 CONFIG_HZ=1000 会改变定时精度与上下文切换开销;CONFIG_PREEMPT_DYNAMIC 表示抢占模式可在启动时切换。这些差异不会出现在 uname -a 的主版本号里,却直接影响性能实验的结论。

0.1.11 内核模块:运行在 ring 0 的可插拔代码

这一节回答:.ko 是什么,modprobe 与 insmod 差在哪,为什么「加载一个模块」可能直接影响整机稳定性。

定义 0.1.3 · 内核模块(kernel module)

内核模块是一个可被内核在运行时链接的 ELF 可重定位目标文件(.ko,旧式后缀 .o),包含驱动或内核子系统的代码与元数据。它由内核的模块加载器 init_module(2)/finit_module(2) 映射进内核地址空间,仍运行在 ring 0、共享内核页表,不是用户态插件,也没有地址空间隔离。模块可以卸载(delete_module(2)),前提是引用计数为零。

模块与「编进内核」的区别只有加载时机,没有权限级别:

形态位置能否卸载典型例子
内建(built-in)vmlinuz 内否ext4、scheduler、netfilter 核心
可加载模块/lib/modules/$(uname -r)/kernel/是(引用计数为 0 时)loop、nvme、kvm、nfs
initramfs 内模块/boot/initrd.img-* 的解压树启动早期可加载根文件系统驱动、LUKS、RAID

判断一个功能是内建还是模块,不要靠猜:

# modules.builtin 列出编进内核的模块名;modules.dep 列出可加载模块的依赖
grep -w 'loop' /lib/modules/$(uname -r)/modules.builtin && echo builtin
modinfo -F filename loop 2>/dev/null || echo "no loop.ko"

modinfo 输出 filename: (builtin) 时是内建;输出 .ko 或 .ko.zst 路径时是可加载模块。Ubuntu 用 zstd 压缩模块,modinfo 会自动解压读取元数据。

日常只需要六个命令,但每个的边界要清楚:

命令做什么关键点失败时常见 errno
lsmod列出已加载模块读 /proc/modules,第三列是引用计数,第四列是使用者—
modinfo m读模块元数据depends、vermagic、signer、parm 都能单独取ENOENT(模块不存在)
modprobe m按依赖加载读 modules.dep,自动加载依赖;支持别名ENOENT、ENODEV、EPERM
modprobe -r m卸载模块及其不再被用的依赖引用计数不为 0 时失败EBUSY
insmod file.ko直接加载指定文件不解析依赖,不建别名,参数格式是 k=vEINVAL、ENOEXEC、EPERM
rmmod m直接卸载不处理依赖;-f 已废弃且危险EBUSY、ENOENT

modprobe 是「按名字和依赖加载」,insmod 是「按文件加载」。自己编译的 .ko 用 insmod 调试可以,但正式安装应放进 /lib/modules/$(uname -r)/ 后跑 depmod -a,再用 modprobe,否则依赖与别名都不会被记录。

模块的参数有三条设置路径,优先级从高到低:

# 1) 启动参数:内核命令行,最早生效,适合内置驱动
#    GRUB 里临时加:loop.max_loop=16
cat /proc/cmdline | tr ' ' '\n' | grep '\.'

# 2) 配置文件:/etc/modprobe.d/*.conf,modprobe 时生效
printf 'options loop max_loop=16\n' | sudo tee /etc/modprobe.d/loop.conf

# 3) 运行时:/sys/module/<name>/parameters/,仅部分参数可写
ls -l /sys/module/loop/parameters/ 2>/dev/null

不是所有参数都能运行时改。/sys/module/*/parameters/* 的权限位是 0444 时只读,0644 时可写;写入是否立即生效由模块自己的 module_param 权限与回调决定。改完 dmesg | tail 看模块是否接受了新值。

实验 0.1-A · 加载、观察、卸载一个模块

目的:用 loop 模块走完「元数据 → 依赖 → 加载 → 参数 → 引用计数 → 卸载」全链路。需要 root;在虚拟机或一次性容器里做。

#!/usr/bin/env bash
# module-lab.sh —— 只读观察 + 可选的加载/卸载
set -euo pipefail
mod=${1:-loop}
echo "== kernel: $(uname -r)"
echo "== builtin?"; grep -w "$mod" "/lib/modules/$(uname -r)/modules.builtin" || echo "not builtin"
echo "== modinfo"
modinfo "$mod" | grep -E '^(filename|license|description|depends|vermagic|signer):' || true
echo "== dry run(不加载,只打印将执行的 insmod)"
modprobe -n -v "$mod" || true
echo "== load"
if ! lsmod | awk '{print $1}' | grep -qx "$mod"; then
    sudo modprobe "$mod"
fi
echo "== loaded?"
lsmod | awk -v m="$mod" 'NR==1 || $1==m'
printf 'refcnt='; cat "/sys/module/$mod/refcnt" 2>/dev/null || echo '?'
echo "== parameters"
for p in /sys/module/"$mod"/parameters/*; do
    [[ -e $p ]] || continue
    printf '%s = %s (%s)\n' "$(basename "$p")" "$(cat "$p")" "$(stat -c %a "$p")"
done
echo "== dmesg tail"
sudo dmesg | tail -5
# 可选卸载:确认没有 loop 设备/挂载在用
if [[ ${DO_UNLOAD:-0} == 1 ]]; then
    sudo modprobe -r "$mod" && echo "unloaded $mod" || echo "unload failed (in use?)"
fi

预期:modprobe -n -v 打印一行 insmod /lib/modules/.../loop.ko.zst 但不改变 lsmod;真正 modprobe 后 lsmod 出现 loop,refcnt 为 0 或正数,parameters/ 下能看到 max_loop、max_part 等(具体项随内核版本变化,以本机输出为准)。默认不卸载;要看卸载路径就 DO_UNLOAD=1 ./module-lab.sh loop。如果 modprobe -r loop 报 Module loop is in use,说明有 loop 设备或挂载仍引用它,先 losetup -a 查。

模块相关的五个边界
  1. 「lsmod 里没有就是没编进内核」——内建模块不出现在 lsmod,要查 modules.builtin。
  2. 「insmod 会帮我解决依赖」——不会。insmod 只加载指定文件,缺依赖时报 Unknown symbol,用 modprobe 或先手动加载依赖。
  3. 「模块卸载失败是权限问题」——多数是 EBUSY:引用计数不为零,有设备、文件系统或内核线程还在用它。
  4. 「加载模块是安全的」——模块在 ring 0 运行,一个空指针就能 panic 整机;kernel.modules_disabled=1 是不可逆的加固开关,开了就只能重启。
  5. 「Secure Boot 下所有模块都能加载」——启用 Secure Boot 后,未签名或密钥不在 MOK 里的模块会被拒绝,dmesg 会看到 module verification failed;modinfo -F signer 能看签名者。

0.1.12 启动链路:五段、五个负责人、五种证据

这一节回答:从按下电源到出现登录提示符,控制权在谁手里换了五次;每次换手留下什么可查的证据。

排启动问题最常见的错误,是机器还没进用户态就去翻 journalctl。日志服务本身还没起来,当然没有记录。正确做法是先判断「卡在哪一段」,再选那一段的工具。

段谁在执行持久产物证据命令典型失败
1 固件UEFI/BIOSNVRAM 启动项、ESP 分区efibootmgr -v、ls /sys/firmware/efi找不到启动项、Secure Boot 拒绝
2 引导器GRUB / systemd-boot/boot/grub/grub.cfg、/etc/default/grubgrep menuentry /boot/grub/grub.cfggrub>、菜单缺失、内核路径错
3 内核vmlinuz/boot/vmlinuz-*、/proc/cmdlinecat /proc/cmdline、dmesg驱动缺失、根设备找不到
4 initramfs/init(用户态第一阶段)/boot/initrd.img-*lsinitramfsWaiting for root device、LUKS 密码错
5 initsystemd(PID 1)unit、target 链接systemctl get-default、systemd-analyzeemergency mode、依赖成环

这张表的读法是:每一段都有自己的「持久产物」。固件把启动项写进 NVRAM,引导器把菜单写进 grub.cfg,内核把命令行留在 /proc/cmdline,initramfs 是一个 cpio 归档,systemd 把默认目标做成符号链接。排障就是找到「最后一段有证据的地方」,问题就在它和下一段之间。

第一段:UEFI 还是 BIOS,决定你用哪套工具。

# UEFI 系统:这个目录存在
ls -d /sys/firmware/efi && echo "UEFI boot"
# 启动项与顺序;BootCurrent 是本次启动项
sudo efibootmgr -v
# Secure Boot 状态(需要 mokutil)
mokutil --sb-state 2>/dev/null || true

UEFI 从 NVRAM 里的启动项找到 ESP(EFI System Partition,通常是 /boot/efi,FAT32),再执行 /EFI/<vendor>/grubx64.efi。启动项丢了,系统会退到可移动介质路径 /EFI/BOOT/BOOTX64.EFI;这个 fallback 文件在,机器就还能起来。efibootmgr -v 的 HD(1,GPT,...) 是分区定位,File(\EFI\ubuntu\grubx64.efi) 是引导器路径,两者任一不对都应先修启动项,而不是重装。

传统 BIOS 从 MBR 的前 512 字节读第一阶段引导代码,GRUB 的 core.img 通常放在 MBR 之后、第一个分区之前的空隙里。这段空隙一旦被分区工具或 dd 覆盖,系统就停在 grub> 或 GRUB rescue>。GPT 盘上还有一块 protective MBR,不要把它当成可随意改写的分区表。

第二段:GRUB 的配置是生成物,不是手改对象。

# 真正的配置来源
cat /etc/default/grub
ls /etc/grub.d/
# 重新生成;不同发行版命令名不同
sudo update-grub            # Debian/Ubuntu
sudo grub2-mkconfig -o /boot/grub2/grub.cfg   # RHEL 系

要改内核命令行,改 /etc/default/grub 的 GRUB_CMDLINE_LINUX_DEFAULT,再重新生成。直接编辑 grub.cfg 能生效一次,下次内核升级就被覆盖。临时调试则在 GRUB 菜单按 e 编辑当前条目,改完 Ctrl-X 启动;这次修改不落盘,重启即失效,正好适合试 systemd.unit=rescue.target 或 rd.break。

第三段:内核拿到的命令行,决定了它怎么找根、开哪些调试开关。

cat /proc/cmdline
# BOOT_IMAGE=/vmlinuz-6.8.0-45-generic root=UUID=... ro quiet splash

常见参数按用途分类:

参数作用什么时候用
root=UUID=...指定根文件系统根设备名变化、克隆虚拟机
ro / rw初始以只读/读写挂载根默认 ro,由 systemd 重新挂载
systemd.unit=rescue.target覆盖默认 target正常启动失败,进单用户救援
rd.break在 initramfs 阶段停下重置密码、修 /etc/fstab、修 LUKS
fsck.mode=force强制 fsck怀疑文件系统损坏
nomodeset不用内核模式设置显卡驱动导致黑屏
init=/bin/bash直接以 bash 为 PID 1最后手段;无服务、无挂载管理

init=/bin/bash 不是「单用户模式」,它跳过 systemd,/ 可能还是只读,/proc 没挂,网络没有。用完必须 mount -o remount,rw / 再改文件,否则改动不会落盘。

第四段:initramfs 是一个临时根文件系统,任务是「把真正的根挂上」。

ls -lh /boot/initrd.img-$(uname -r)
# 列出归档内容(不展开)
lsinitramfs /boot/initrd.img-$(uname -r) | head -20
# 看关键驱动是否在里面
lsinitramfs /boot/initrd.img-$(uname -r) | grep -E 'nvme|ext4|dm-crypt'

initramfs 里有一个 /init,它挂载 /proc、/sys,加载根设备所需模块,按 root= 找到设备,再 switch_root 到真正的根。根盘是 NVMe、RAID、LUKS 或网络存储时,对应驱动与工具必须在 initramfs 里;不在,就会卡在 Waiting for root device 或 Unable to find a medium containing a live file system。改完 /etc/initramfs-tools/ 或 /etc/dracut.conf.d/ 后要重新生成:

sudo update-initramfs -u -k all     # Debian/Ubuntu
sudo dracut -f --regenerate-all     # RHEL/Fedora

第五段:systemd 用 target 组织服务,最后交给 getty 或显示管理器。

systemctl get-default
systemctl list-dependencies --reverse multi-user.target | head
systemctl list-units --type=target --state=active
loginctl list-sessions

default.target 是一个符号链接,指向 multi-user.target(无图形)或 graphical.target(图形)。sysinit.target 负责早期系统初始化,basic.target 负责基础服务,multi-user.target 拉起网络、SSH、定时任务,graphical.target 再加显示管理器。启动失败进 emergency.target 时,先看 systemctl --failed 与 journalctl -b -p err,再决定是修 fstab、修 unit 还是绕过某个服务。

实验 0.1-B · 给自己的机器画一张启动链路图

目的:不依赖发行版文档,用本机证据把五段串起来,输出可直接贴进排障记录的五行摘要。

#!/usr/bin/env bash
# boot-chain.sh —— 只读:采集本机启动链路的五段证据
set -euo pipefail
export LC_ALL=C
printf '== 1 firmware\n'
if [[ -d /sys/firmware/efi ]]; then
    echo "mode=UEFI"
    if command -v efibootmgr >/dev/null; then
        efibootmgr 2>/dev/null | sed -n '1,6p' || true
    else
        echo "efibootmgr not installed"
    fi
else
    echo "mode=BIOS (no /sys/firmware/efi)"
fi
printf '\n== 2 bootloader\n'
for f in /boot/grub/grub.cfg /boot/grub2/grub.cfg; do
    [[ -r $f ]] && { echo "cfg=$f"; grep -m1 'menuentry' "$f" || true; }
done
[[ -r /etc/default/grub ]] && grep -E '^GRUB_(DEFAULT|CMDLINE|TIMEOUT)' /etc/default/grub || true
printf '\n== 3 kernel\n'
echo "release=$(uname -r)"
cat /proc/cmdline
[[ -r /boot/config-$(uname -r) ]] && \
    grep -E '^CONFIG_(HZ|PREEMPT|CGROUP|PSI)=' "/boot/config-$(uname -r)" || true
printf '\n== 4 initramfs\n'
for f in /boot/initrd.img-$(uname -r) /boot/initramfs-$(uname -r).img; do
    [[ -r $f ]] && ls -lh "$f"
done
if command -v lsinitramfs >/dev/null; then
    n=$(lsinitramfs /boot/initrd.img-$(uname -r) 2>/dev/null | wc -l)
    echo "initramfs_entries=$n"
fi
printf '\n== 5 userspace\n'
echo "default_target=$(systemctl get-default 2>/dev/null || echo n/a)"
systemctl is-system-running 2>/dev/null || true
if command -v systemd-analyze >/dev/null; then
    systemd-analyze 2>/dev/null | sed -n '1,2p' || true
fi

预期:UEFI 机器第一段打印 mode=UEFI 和启动项;/proc/cmdline 里能看到 root=UUID=;第四段打印 initramfs 大小与条目数;第五段打印 default_target=graphical.target 或 multi-user.target,以及 systemd-analyze 的四段耗时。没有 lsinitramfs 或 efibootmgr 时脚本只跳过该段,不让整条链中断。

启动链路的六个误判
  1. 「进不了系统就是内核坏了」——先看有没有 GRUB 菜单。菜单都没有,问题在固件或引导器;菜单有但内核起不来,才轮到内核与 initramfs。
  2. 「journalctl 没有日志就是没启动」——内核日志在 journald 起来之前由 dmesg//dev/kmsg 承载;用 journalctl -k -b 也读不到时,直接 dmesg。
  3. 「root=UUID= 一定比 /dev/sda1 稳」——UUID 在 mkfs/克隆后会变;PARTUUID 与分区表绑定,更适合定位分区,但恢复分区表后也可能变。
  4. 「改 grub.cfg 就行了」——它是生成物,内核或 grub 包升级会重写;改 /etc/default/grub 再 update-grub。
  5. 「initramfs 越大越安全」——越大启动越慢、占 /boot 越多;MODULES=most 适合通用镜像,专用机器用 dep 加显式列表更可控。
  6. 「init=/bin/bash 等于单用户模式」——它跳过 systemd,没有服务、没有 /proc、根可能只读;能进 rescue.target 就不要用它。

0.1.13 系统调用接口:唯一合法入口的形状

这一节回答:一次 write 在 CPU 上到底发生了什么;为什么 syscall() 的返回值和 write() 不一样;为什么有些 libc 函数根本不进内核。

定义 0.1.4 · 系统调用接口(system call interface)

系统调用是用户态请求内核服务的唯一合法指令级入口。调用方按 ABI 把系统调用号放进指定寄存器、参数放进参数寄存器,执行陷入指令(x86-64 的 syscall、ARM64 的 svc #0),CPU 切到内核态,由 entry_SYSCALL_64 或 el0_svc 进入分发表,按号调用 sys_*/__x64_sys_* 处理函数。返回值放在返回寄存器;-1 到 -4095 表示错误,其绝对值是 errno。

三个架构的寄存器约定不同,但模型相同:

项x86-64ARM64RISC-V 64
调用号寄存器raxx8a7
参数寄存器rdi、rsi、rdx、r10、r8、r9x0–x5a0–a5
返回值raxx0a0
陷入指令syscallsvc #0ecall
错误表示rax ∈ [-4095, -1]x0 同范围a0 同范围

这张表说明:系统调用号是架构相关的,C 源码不是。你在 x86-64 上写 SYS_write,交叉编译到 ARM64 时头文件会给出另一个数字;strace 也会按架构解析成同一个名字。手写汇编或看内核原始日志时才需要关心数字。

glibc 在中间做了四件事,每一件都可能改变你看到的行为:

  1. 寄存器搬运与陷入:把 C 参数按 ABI 放进寄存器,执行 syscall,再把负返回值翻译成 -1 并设置 errno。
  2. EINTR 重试:部分慢系统调用(read、write、wait、nanosleep)在收到信号时返回 EINTR;glibc 对其中一些做了透明重启,但 signal(7) 列出的 never restarted 调用不会。
  3. 取消点:read、write、open 等是 pthread 取消点;直接 syscall() 绕过 glibc 就没有这层语义。
  4. vDSO 加速:clock_gettime、gettimeofday、time、getcpu 在支持时由内核映射一段用户态代码(linux-vdso.so.1),不执行陷入指令。所以 strace 看不到它们,性能也不是系统调用级的。

这张表把「libc 函数」与「真正的系统调用」分开:

你写的实际发生失败语义
printf用户态缓冲,满或 fflush 时 write返回负数,errno 由 write 设置
malloc用户态空闲链表;不够时 brk/mmap返回 NULL,errno=ENOMEM
fopenopenat + 用户态 FILE 结构返回 NULL,errno 来自 openat
getaddrinfo读 /etc/nsswitch.conf,可能 socket/sendto/recvfrom返回 EAI_*,不是 errno
sleepnanosleep(被信号打断会提前返回)返回剩余秒数
time通常走 vDSO,不陷入内核几乎不失败

下面这个程序同时走 libc 包装与裸 syscall(),用来对比返回值与 errno:

/* rawsys.c —— 对比 libc 包装与裸系统调用;Linux 专用,编译时加 -D_GNU_SOURCE */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>

int main(void)
{
    /* 1) 裸 getpid:成功时返回 PID,不设置 errno */
    long pid = syscall(SYS_getpid);
    printf("syscall(SYS_getpid) = %ld, libc getpid() = %d\n", pid, (int)getpid());

    /* 2) 裸 write:参数与 write(2) 相同,但没有 stdio 缓冲 */
    const char msg[] = "raw write\n";
    long n = syscall(SYS_write, STDOUT_FILENO, msg, sizeof msg - 1);
    if (n < 0) {
        fprintf(stderr, "raw write failed: %s\n", strerror(errno));
        return 1;
    }
    printf("raw write returned %ld\n", n);

    /* 3) 失败路径:打开不存在的文件,观察 -1 与 errno */
    errno = 0;
    long fd = syscall(SYS_openat, AT_FDCWD, "/no/such/file", O_RDONLY, 0);
    printf("raw openat = %ld, errno = %d (%s)\n", fd, errno, strerror(errno));

    /* 4) 同一个失败走 libc:返回值同为 -1,但错误来源更明确 */
    errno = 0;
    int cfd = open("/no/such/file", O_RDONLY);
    printf("libc open  = %d, errno = %d (%s)\n", cfd, errno, strerror(errno));
    return 0;
}

编译与运行:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g rawsys.c -o rawsys
./rawsys
strace -e trace=getpid,write,openat ./rawsys 2>&1 | grep -E 'getpid|write|openat'

预期:程序打印两行相同的 ENOENT;strace 里能看到 getpid() 与两次 openat,但看不到 printf 内部的所有 write(stdio 缓冲把多次输出合并了)。syscall(SYS_getpid) 在 strace 里显示为 getpid(),因为 strace 按系统调用号反解,不区分你是从 libc 还是裸 syscall 进来的。

手写汇编才能看清 ABI 本身。下面是同一件事的两个架构版本:

# getpid_x86_64.s —— 只演示 ABI;可汇编:cc -nostdlib -static getpid_x86_64.s -o getpid
    .text
    .globl _start
_start:
    movq    $39, %rax        # __NR_getpid(x86-64)
    syscall                  # 陷入内核;返回地址在 rcx,标志在 r11
    movq    %rax, %rdi       # 退出码 = PID 低 8 位
    movq    $60, %rax        # __NR_exit
    syscall
// getpid_arm64.s —— 可汇编:clang --target=aarch64-linux-gnu -nostdlib -static getpid_arm64.s -o getpid
    .text
    .globl _start
_start:
    mov     x8, #172         // __NR_getpid(ARM64 通用号)
    svc     #0               // 陷入内核;返回在 x0
    mov     x0, x0           // 退出码 = PID 低 8 位
    mov     x8, #93          // __NR_exit
    svc     #0

两个版本都只依赖内核 ABI,不链接 libc。注意系统调用号不同(39 与 172),这正是「源码可移植、ABI 不可移植」的具体样子。

实验 0.1-C · 验证 vDSO 与系统调用计数

目的:证明「libc 函数不一定进内核」,并学会用 strace -c 读调用次数。

# 1) clock_gettime 通常走 vDSO:下面这条 trace 往往一行都没有
strace -c -e trace=clock_gettime date >/dev/null
# 2) 强制走系统调用(关闭 vDSO)再对比
sudo sh -c 'echo 0 > /proc/sys/vm/vdso_enabled' 2>/dev/null || \
    echo "vdso_enabled 不存在(6.x 已移除该开关,改用其他方式)"
# 3) 看某个进程是否启用了 seccomp
grep -E '^(Seccomp|NoNewPrivs):' /proc/self/status
# 4) 统计一次 ls 的系统调用分布
strace -c -f ls /etc >/dev/null

预期:第 1 条通常输出 clock_gettime 0 次(vDSO),而 date 的其他调用照常出现;第 3 条在 Docker 默认配置里常见 Seccomp: 2(过滤模式),普通 shell 是 0;第 4 条能看出 openat、newfstatat、getdents64、write 的占比。不同内核版本对 vDSO 开关的支持不同,vm.vdso_enabled 在较新内核上可能不存在,这本身就是「版本差异」的证据。

系统调用层的六个误解
  1. 「syscall() 返回 -1 就是内核返回 -1」——内核返回的是 -errno(如 -ENOENT);glibc 的 syscall() 包装把它翻译成 -1 并设置 errno。直接看寄存器才能看到原始负值。
  2. 「所有 libc 函数都会进内核」——printf 的缓冲、malloc 的空闲链表、gettimeofday 的 vDSO 都在用户态完成。
  3. 「系统调用号在 x86-64 和 ARM64 上一样」——完全不同;只有 C 头文件里的 SYS_* 宏能跨架构。
  4. 「errno 是系统调用设置的」——errno 是 glibc 的线程局部变量;裸汇编没有 errno,只有返回寄存器里的负值。
  5. 「strace 看到的就是全部内核行为」——vDSO、缺页、调度、中断都不以系统调用形式出现;strace 只覆盖 syscall 入口。
  6. 「seccomp 只影响容器」——任何进程都可以装 seccomp 过滤器;Seccomp: 2 表示已启用,被拦的调用返回 EPERM 或直接 SIGSYS。

0.1.14 FHS:目录约定与它的证据

这一节回答:为什么配置在 /etc、状态在 /var、运行时在 /run;这些约定由谁保证,破坏之后会怎样。

FHS(Filesystem Hierarchy Standard)是约定,不是内核强制。内核只认挂载点与 inode;/etc 放配置、/var 放可变数据,是发行版、打包规则和运维习惯共同维持的。理解这一点,才不会把「某程序把数据写到 /usr」当成灵异事件。

目录放什么可变?证据命令只读根/容器注意
/根文件系统少量findmnt /只读根时只能写挂载点
/usr系统程序、库、共享数据随包升级du -sh /usr应与 / 同分区(usrmerge)
/usr/local管理员本地安装是echo $PATH容器里常被镜像层覆盖
/etc主机配置是dpkg -S /etc/ssh/sshd_config配置应挂 ConfigMap/卷
/var可变状态:日志、缓存、队列是du -sh /var/*必须单独卷,否则撑爆根
/var/lib程序持久状态(数据库、包数据库)是ls /var/lib/dpkg备份重点
/var/log日志是journalctl --disk-usage常与 journald 双份
/run本次启动的运行时状态是(tmpfs)findmnt /run重启即失,别放持久数据
/tmp临时文件是(可能 tmpfs)findmnt /tmpsystemd-tmpfiles 会清理
/opt第三方整包软件是ls /opt有些商业软件只认这里
/srv服务数据是ls /srv约定弱,很多发行版空着
/home用户主目录是findmnt /home容器里通常不挂
/rootroot 的主目录是ls -ld /root权限 0700
/boot内核、initramfs、引导器随升级ls -lh /boot独立分区时易满
/proc进程与内核参数投影否(虚拟)cat /proc/self/status读不是免费操作
/sys设备、驱动、cgroup 投影部分可写ls /sys/class/net写等于操作内核
/dev设备节点否(devtmpfs)ls -l /dev/null容器里由 runtime 造

这张表说明:「可变性」是选目录的第一原则。配置放 /etc,因为要能改;运行状态放 /run,因为重启该清;持久数据放 /var/lib,因为要备份;只读的程序与库放 /usr,因为要能整体做镜像与校验。

现代 Ubuntu 已经 usrmerge:/bin、/sbin、/lib 都是指向 /usr 下对应目录的符号链接。

ls -ld /bin /sbin /lib /lib64
# lrwxrwxrwx 1 root root 7 ... /bin -> usr/bin
readlink -f /bin/ls
# /usr/bin/ls

usrmerge 的好处是「一个 /usr 就能构成完整系统」,容器镜像与只读根更好做。代价是老脚本、chroot、自建 initramfs 里写死 /bin/sh 时,若只复制了 /usr/bin/sh 而没建链接,就会 No such file or directory。这个报错看起来像「文件不存在」,实际是解释器路径不存在,与 0.1.3 的 Alpine 例子同源。

配置的优先级也常被误解。包安装的默认配置在 /usr/lib(如 /usr/lib/systemd/system/*.service、/usr/lib/sysctl.d/*.conf),管理员改动用 /etc 覆盖:

# systemd:/etc 下的同名文件优先,drop-in 再叠加
systemctl cat ssh.service
# sysctl:/etc/sysctl.d 覆盖 /usr/lib/sysctl.d
sysctl --system
# 看某个配置最终由哪些文件合成
systemd-analyze cat-config systemd/journald.conf

systemd-analyze cat-config 会把所有目录里的片段按优先级拼出来,并标注来源文件。排查「我明明改了配置却没生效」时,它比逐个 cat 可靠。

FHS 不保证的事

FHS 不规定包管理器数据库位置(Debian 在 /var/lib/dpkg,RPM 在 /var/lib/rpm),不规定容器镜像布局,也不规定 /srv 下面怎么组织。man 7 hier 是 FHS 的实现说明,man 7 file-hierarchy 是 systemd 对「只读 /usr + 可写 /etc//var」的现代解释。两者冲突时,以你的发行版实际布局为准。

0.1.15 学习路线:从这条命令到内核源码

这一节回答:这本书六个部分怎么串起来;每一段应该练到什么程度;什么时候才该去读内核源码。

先把「知道」与「做到」分开。下面每一阶段都给出可验收的证据,没有证据就不算学完:

阶段对应章节能做到验收证据建议投入
1 命令行0.2–0.8不查资料完成文件、文本、权限、进程、脚本任务独立写出 100 行带 set -euo pipefail 的脚本2–3 周
2 系统编程第 2 篇用 open/read/write/mmap/fork/exec 写工具,处理 EINTR/短读/信号一个能编译、有错误路径、能被 strace 解释的程序6–8 周
3 内核第 3 篇解释一次系统调用到驱动/文件系统的路径能画出 read 的 VFS→页缓存→块层路径并指出锁6–10 周
4 网络第 4 篇解释一次 HTTP 请求的 socket、TCP、路由、netfilter 路径用 ss/tcpdump/bpftrace 定位一次超时4–6 周
5 云原生第 5 篇把服务容器化、编排、可观测一个带探针、资源限制、日志与指标的部署4–6 周

这张表说明:顺序不能倒。没写过 fork 就读调度器,只能背名词;没抓过包就学 eBPF,只能抄脚本。每阶段结束做一个「可展示的产物」,比看完多少页可靠。

实验环境只需要一台能快照的虚拟机:

# Ubuntu 24.04 Server,2 vCPU / 4 GB / 40 GB,另加两块 5 GB 空盘做 LVM/RAID
sudo apt update
sudo apt install -y build-essential git strace ltrace gdb \
    linux-tools-common "linux-tools-$(uname -r)" \
    sysstat lsof psmisc man-db manpages-dev \
    bpftrace bcc-tools auditd
# 关掉自动升级,避免实验中途内核变化
sudo systemctl disable --now unattended-upgrades 2>/dev/null || true

两块空盘是刻意的:0.10 的 LVM、RAID、quota 实验都需要「可以随便格式化」的设备,用 loop 文件也能做,但真盘的 lsblk、/sys/block、blkid 输出更接近生产。每做一次危险操作前打快照,比事后恢复便宜。

读内核源码的时机与方法:

  1. 先有用户态证据,再读源码。先用 strace 看到 openat 返回 EACCES,再去读 fs/open.c 的权限检查;否则会在几万行里迷路。
  2. 按函数名跳转,不按目录通读。elixir.bootlin.com/linux/v6.8 支持符号搜索与交叉引用;本地可以用 make tags 生成 ctags,vim -t do_sys_openat2 直接跳。
  3. 用 git log -S 找变更原因。一个奇怪的判断往往对应某次修复;git log --oneline -S 'condition' -- path 比注释更能说明「为什么这么写」。
  4. 固定版本。本书以 6.8 为准;发行版内核带 backport,读上游 6.8 与读 Ubuntu 6.8.0-45 不是同一份代码。结论要写清「在哪个版本、哪个配置下」。
六周最小路线(可复制到日历)
周 读 做 交付
1 0.1–0.4 装 VM、跑通 0.1 的三个实验 一张本机启动链路图
2 0.5–0.8 写一个带 trap、日志、--dry-run 的脚本 脚本 + shellcheck 通过
3 2.1–2.5 用 open/read/write 实现 cp,处理短读短写 可编译程序 + strace 记录
4 2.6–2.9 实现 ls -l 的权限与时间格式化 对比 coreutils 输出
5 3.1–3.4 读 read 路径,画 VFS→页缓存→块层图 一张带函数名的图
6 4.1–4.3 抓一次 TCP 握手与超时 tcpdump + ss 证据

0.1.16 十个跨层误判

这一节把本章最容易混的十个判断集中成表,每条都给「看起来像」与「实际是」。

#看起来像实际是一句话验证
1改了 /usr/lib 下的 unit 却没生效/etc 下有同名文件或 drop-in 覆盖systemctl cat <unit>
2容器里 /bin/sh 不存在usrmerge 后只复制了 /usr/bin/sh,没建链接readlink -f /bin/sh
3cat /proc/meminfo 只是读文件每次读都触发内核生成快照,有开销perf stat -e cpu-clock cat /proc/meminfo
4blacklist 了模块就不会加载内建模块不受 blacklist 影响grep -w m /lib/modules/$(uname -r)/modules.builtin
5update-grub 是通用命令Debian 包装脚本;RHEL 用 grub2-mkconfigcommand -v update-grub
6换了存储控制器,重启就能认盘initramfs 里没有新驱动,卡在 Waiting for root devicelsinitramfs ... | grep <driver>
7syscall() 与 write() 只差一个前缀绕过缓冲、取消点、EINTR 重启与符号版本strace 对比两者
8strace 没看到 clock_gettime 就是没调用vDSO 在用户态完成,不陷入内核ldd 里有 linux-vdso.so.1
9getconf PAGE_SIZE 与编译期常量一致运行期内核配置决定,跨架构可变getconf PAGE_SIZE vs sysconf(_SC_PAGESIZE)
10ldd 只是「看依赖」它会用 loader 执行目标,恶意二进制可借机运行代码改用 objdump -p / readelf -d

第 10 条值得展开:ldd 的实现是设置 LD_TRACE_LOADED_OBJECTS=1 再执行 loader。对普通动态二进制,loader 只打印依赖就退出;但对静态链接或手写 ELF,loader 可能真的运行它。巡检脚本里对来源不明的文件,用 objdump -p file | grep NEEDED 或 readelf -d file,不要用 ldd。

0.1.17 本章出口检查

检查项达标线没达标去哪
画出内核/ libc / 应用 / 硬件的分层能指出系统调用边界在哪0.1.2 的布局图
说出内核不管的三件事至少包括 printf、管道、包管理0.1.2
解释 uname -r 与发行版版本的区别能说出 Ubuntu 的 -45-generic 是什么0.1.1
从 /etc/os-release 取发行版信息脚本能处理字段缺失0.1.6
解释容器为什么不能换内核说出共享内核与 namespace 的区别0.1.6
说出 POSIX 保证与不保证的各一项能举 epoll 或 grep -P 为例0.1.5
从 modules.builtin 判断内建还是模块能解释 lsmod 为什么看不到内建模块0.1.11
画出五段启动链路并各给一条证据每段至少一条可执行命令0.1.12
解释 syscall() 与 write() 的 errno 差异能指出内核负返回值范围0.1.13
判断一个目录该放配置还是状态用「可变性」回答并举例0.1.14
即时自测 · 0.1

Q1 一台机器 uname -r 是 5.15.0-119-generic,/etc/os-release 的 VERSION_ID=24.04。它跑的到底是 5.15 还是 6.8 内核?为什么会出现这种组合?

Q2 你在 Ubuntu 上编译了一个动态链接的二进制,复制到 Alpine 容器里执行,报 not found。ls 明明能看到这个文件,为什么说「找不到」?

Q3 ping 能通,但 curl https://example.com 超时。按本章的分层,至少列出三个可能出问题的层,以及各层的第一条验证命令。

Q4 为什么 getconf PAGE_SIZE 在写底层程序时比 4096 这个字面量可靠?

Q5 lsmod 里没有 ext4,但根文件系统就是 ext4。这矛盾吗?给出验证命令。

Q6 一个模块 modprobe 报 Unknown symbol in module,但 modinfo 显示 depends: 为空。最可能是什么原因?

答案

A1 跑的是 5.15 内核。/etc/os-release 描述的是用户态发行版,内核可以独立升级或降级;Ubuntu 的 HWE 与 GA 内核轨道、云厂商自定义镜像、容器宿主都会造成「新用户态 + 旧内核」的组合。判断内核能力看 uname -r,不要看发行版号。

A2 报错里的 not found 通常不是文件不存在,而是动态加载器不存在。Ubuntu 二进制需要 /lib64/ld-linux-x86-64.so.2,Alpine 没有这个路径(musl 用 /lib/ld-musl-x86_64.so.1)。用 file 看 interpreter 一行即可确认。

A3 ① 链路/地址层:ip -br addr;② 路由层:ip route get 1.1.1.1;③ DNS 层:getent hosts example.com 或 dig example.com;④ 端口/防火墙层:nc -vz example.com 443;⑤ TLS/应用层:curl -v --max-time 10 https://example.com。ping 只验证 ICMP 可达,不验证 TCP 443、DNS 或 TLS。

A4 页大小是内核配置与架构相关的运行时参数,不是 ABI 常量。ARM64 可以是 4/16/64 KiB,x86 在开启大页或特殊配置时也不是固定值。写死 4096 会在 16 KiB 页的系统上导致对齐错误、越界或性能异常;sysconf(_SC_PAGESIZE) 取的是当前内核的真实值。

A5 不矛盾。ext4 很可能被编进内核(built-in),内建模块不出现在 lsmod。验证:grep -w ext4 /lib/modules/$(uname -r)/modules.builtin,或 modinfo -F filename ext4 看是否输出 (builtin);/proc/filesystems 里出现 ext4 也说明内核已支持它。

A6 depends 为空只说明模块元数据没声明依赖,不代表运行时符号都能解析。常见原因是:模块与当前内核版本不匹配(vermagic 不符)、依赖的符号属于另一个未加载模块、或编译时用了不同的配置导致符号被裁剪。先看 dmesg 的完整报错与 modinfo -F vermagic,再 modprobe --show-depends。

母本对应

母本章节PDF 页本书落点程度
鸟哥第 0–1 章 计算机概论、Linux 是什么p.32–1200.1.1–0.1.4、0.1.10背景(历史与组成)
鸟哥第 19 章 开机流程、模块管理与 Loaderp.921–9700.1.11–0.1.12完整(启动链路、模块)
shell-book第 1 章 初识 Linux shellp.21–340.1.2–0.1.4、0.1.10完整(内核、GNU、发行版)
Kerrisk第 1–2 章 历史与 Linux 基础—0.1.2、0.1.5、0.1.13精选(内核边界、标准、syscall)
Shotts第 1 章 什么是 Shellp.22–250.1.3背景(用户态视角)
man-pagessyscall(2)、hier(7)、file-hierarchy(7)man0.1.13–0.1.14完整(ABI、目录约定)
LSB / FHS 规范现行文档在线0.1.5、0.1.14一句带过(已废弃/约定)

下一站:0.2 终端、Shell 与命令行工作流把「你敲的字符如何变成内核里的系统调用」这条链路走完;之后所有章节都假设你能分清这三层。

下一站:0.2 终端、Shell 与命令行工作流把「你敲的字符如何变成内核里的系统调用」这条链路走完;之后所有章节都假设你能分清这三层。