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 -r | 6.8.0-45-generic | 随内核更新 | 决定驱动、syscall、cgroup 版本 |
| libc | ldd --version | 2.39 | 随发行版大版本 | 决定符号版本与部分 POSIX 行为 |
| 发行版 | /etc/os-release 的 VERSION_ID | 24.04 | 每半年或数年 | 决定包版本、默认配置、支持周期 |
一个常见误判:看到 6.8.0-45-generic 就以为系统是「Linux 6.8」。Ubuntu 会在同一个主版本上打几百个补丁,-45-generic 是 Ubuntu 的 ABI 编号,不是上游 6.8 的第 45 个版本。要判断某个上游特性是否存在,应当查 uname -r 的主次版本加上发行版 backport 说明,而不是猜。
0.1.2 内核的职责与边界
内核是常驻内存、运行在 CPU 最高特权级(x86-64 的 ring 0)的那部分代码。它管理进程与调度、虚拟内存、文件系统、网络协议栈、设备驱动与中断,并通过系统调用(system call)向用户态提供唯一合法的入口。用户态代码不能直接访问页表、中断控制器或磁盘控制器。
把内核的职责拆成五块,每块都对应后面的一篇:
| 职责 | 内核里做什么 | 用户可见的接口 | 本书展开 |
|---|---|---|---|
| 进程与调度 | 创建/销毁任务、上下文切换、CPU 分配 | fork/execve/wait4、/proc | 第 1、3 篇 |
| 虚拟内存 | 页表、缺页、映射、回收 | mmap/brk、/proc/meminfo | 1.9、2.12 |
| 文件系统 | inode、目录项、页缓存、VFS | open/read/write、mount | 2.2–2.6、0.10 |
| 网络 | socket、TCP/IP、netfilter、驱动 | socket/sendto、/proc/net | 第 4 篇、0.13 |
| 设备与中断 | 驱动、DMA、中断处理、电源 | /dev、/sys、ioctl | 3.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 用户态的核心组件:
| 组件 | 代表 | 你每天都在用的部分 |
|---|---|---|
| Shell | Bash | 变量、管道、作业控制、脚本 |
| coreutils | ls、cp、mv、rm、sort、date | 文件与文本的基本操作 |
| 文本工具 | grep、sed、awk、diff | 第 0.5、0.6 章的全部内容 |
| 编译工具链 | gcc、binutils、make | 把源码变成 ELF |
| C 库 | glibc | 系统调用包装、malloc、DNS 解析、locale |
C 库是最容易被忽略的一层。它至少做四件事:
- 系统调用包装:
write(2)在你眼里是函数,在 CPU 眼里是一条syscall指令加寄存器约定。glibc 负责搬参数、处理errno、在必要时重试EINTR。 - 内存管理:
malloc在用户态维护空闲链表,只有不够时才向内核要页。这就是为什么free看到的RSS和程序「申请了多少」经常对不上。 - 名字服务:
getaddrinfo读/etc/nsswitch.conf,再决定查/etc/hosts、DNS 还是 systemd-resolved。ping能通但浏览器打不开,常常卡在这一层。 - 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 发行版:把零件装成可运维的系统
发行版是把内核、C 库、用户态工具、包管理器、初始化系统与一套默认配置打包、签名、测试并维护的整体。它承诺的是「这个组合经过集成测试,且有安全更新渠道」,而不只是「这些软件能放在同一台机器上」。
判断两个系统是不是「同一个发行版」,看五件事:
| 组成 | Ubuntu 24.04 | Fedora 40 | Arch | Alpine |
|---|---|---|---|---|
| 内核 | Ubuntu 6.8 | Fedora 6.9 | 滚动 | 6.6(lts) |
| C 库 | glibc 2.39 | glibc 2.39 | glibc | musl |
| 包格式 | .deb / apt | .rpm / dnf | .pkg.tar.zst / pacman | .apk / apk |
| init | systemd 255 | systemd 255 | systemd | OpenRC |
| 默认策略 | 偏保守、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 就是整个操作系统」——严格说 Linux 是内核。完整系统还需要 libc、Shell、coreutils 与包管理;
uname -o输出GNU/Linux就是在提醒这一点。 - 「发行版只是换个桌面主题」——发行版还决定包版本、ABI、默认安全策略与支持周期。把 Ubuntu 的二进制直接复制到 Alpine,通常连动态加载器都对不上。
- 「容器里的
uname -r是容器自己的内核」——容器与宿主机共享内核,uname -r显示的是宿主机内核;/etc/os-release显示的才是镜像里的用户态。 - 「POSIX 认证过,行为就完全一致」——POSIX 规定接口语义,不规定性能、错误文本、扩展接口与默认配置。
ps的列、grep -P、systemd都不在 POSIX 里。 - 「源码能编译,二进制就能随便拷」——二进制兼容取决于架构、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-64 | ARM64 | 影响 |
|---|---|---|---|
| 页大小 | 通常 4 KiB | 4/16/64 KiB(依内核配置) | mmap 对齐、stat 块计数、性能测试 |
| 设备发现 | ACPI | 设备树(DT)为主,服务器用 ACPI | /sys/firmware 内容、驱动绑定方式 |
| 系统调用号 | 独立编号 | 独立编号 | 汇编、strace 原始输出不同;C 源码不受影响 |
| 多架构包 | amd64 | arm64 | 容器镜像、.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
逐行读这段输出:
execve是 Shell 调用的最后一个系统调用。它把/usr/bin/ls换成当前进程的地址空间;ls自己并没有「启动」,它只是替换了 Shell 的子进程。- 第一个
openat打开/etc/ld.so.cache,这是动态加载器在找库。此时ls的main还没开始执行。 - 第二个
openat打开libc.so.6。openat的第三个参数O_CLOEXEC表示执行新程序时关闭这个 fd,避免泄漏。 openat("/etc", O_DIRECTORY)才是ls的业务:打开目录。getdents64读目录项。目录内容由内核的文件系统代码提供,ls只负责格式化。write(1, ...)把格式化结果写到 fd 1。你在终端看到的内容,最终都是这样一组write。
注意 ls 从未直接读磁盘。它打开目录、读目录项,内核的页缓存负责把磁盘块变成内存里的数据。如果目录刚被访问过,getdents64 可能完全不触发 I/O。这就是为什么「ls 很快」不能证明磁盘很快——它可能只证明了缓存很热。
这张表把四层与证据对上:
| 层 | 在这次跟踪里的证据 | 如果这一层出问题,你会看到 |
|---|---|---|
| Shell | execve 之前的 fork/clone(本例未显示) | 命令找不到、通配符没展开 |
| 动态加载器 | ld.so.cache、libc.so.6 的 openat | not found、undefined symbol |
| libc | write 的参数格式化(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 |
| 3 | C 库函数 | 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 LTS | Debian | glibc 2.39 | systemd 255 | .deb / apt | 6.8 GA,另有 HWE 轨道 | 5 年 + ESM | 固定版本 + backport |
| Debian 12 | 独立 | glibc 2.36 | systemd 252 | .deb / apt | 6.1 LTS | 3 年安全 + 2 年 LTS | 固定版本 |
| Fedora 40 | 独立 | glibc 2.39 | systemd 255 | .rpm / dnf | 更新较快 | 约 13 个月 | 快速迭代 |
| RHEL 9 | Fedora 派生 | glibc 2.34 | systemd 252 | .rpm / dnf | 5.14 长期 backport | 10 年 | 固定 + backport |
| AlmaLinux 9 | RHEL 重建 | 同 RHEL 9 | 同 RHEL 9 | .rpm / dnf | 同 RHEL 9 | 约 10 年 | 跟随 RHEL |
| Alpine 3.20 | 独立 | musl 1.2 | OpenRC | .apk / apk | 6.6 | 约 2 年 | 固定版本 |
| Arch Linux | 独立 | 最新 glibc | systemd | .pkg.tar.zst / pacman | 上游最新 | 滚动 | 滚动更新 |
| NixOS 24.11 | 独立 | glibc | systemd | Nix | 上游较新 | 约 7 个月 | 声明式、可回滚 |
这张表说明:发行版的差异主要不在内核版本,而在 libc、包管理、默认策略与支持周期。四者里,libc 决定二进制能不能跑,支持周期决定出了 CVE 谁来修,默认策略决定你排障时看到的是 AppArmor 还是 SELinux。
选型可以压成三条线:
- 二进制兼容线:你的产物要跑在哪些机器上?如果交付二进制,构建环境的 glibc 不能高于目标环境。经验规则是「在最老的受支持发行版上构建」,或者用容器/静态链接把 libc 依赖消掉。Python 的
manylinux轮子、Go 的默认静态链接,都是这条线的工程化答案。 - 支持周期线:服务器跑几年?Ubuntu LTS 的 5 年标准支持、RHEL 的 10 年、Fedora 的 13 个月,对应的是完全不同的升级预算。把 Fedora 装到生产数据库上,不是技术错误,是维护模型错配。
- 运维习惯线:团队熟悉 apt 还是 dnf?用 SELinux 还是 AppArmor?有没有 Satellite/ Landscape 这类集中管理?换发行版最贵的往往不是装系统,而是重写 runbook 与监控项。
同一个 C 项目在四个发行版上,装依赖的命令就不同:
| 需求 | Debian/Ubuntu | Fedora/RHEL | Alpine | Arch |
|---|---|---|---|---|
| 编译工具链 | build-essential | gcc make(或 @development-tools) | build-base | base-devel |
| OpenSSL 头文件 | libssl-dev | openssl-devel | openssl-dev | openssl |
| 装构建依赖 | apt build-dep . | dnf builddep foo.spec | apk add --virtual | makepkg -s |
| 查文件属于哪个包 | dpkg -S /path | rpm -qf /path | apk info --who-owns /path | pacman -Qo /path |
| 查包装了哪些文件 | dpkg -L pkg | rpm -ql pkg | apk info -L pkg | pacman -Ql pkg |
这张表说明:「源码能编译」只是起点,构建脚本要为每个发行版写分支。CMake/Meson 能屏蔽一部分差异,但包名和 pkg-config 路径仍然要按发行版处理。
支持周期不是一张营销表,它会变成具体的补丁行为。以 OpenSSL 为例:RHEL 9 会在整个 10 年里 backport 安全修复,但主版本可能一直不变;Arch 会直接升级到新主版本,依赖它的程序要跟着重编。前者稳定但缺少新特性,后者新但每次 pacman -Syu 都可能需要人工介入。判断「这个漏洞修没修」,不能只看版本号,要看发行版的勘误(Ubuntu 的 USN、RHEL 的 RHSA)。
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 差在哪,为什么「加载一个模块」可能直接影响整机稳定性。
内核模块是一个可被内核在运行时链接的 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=v | EINVAL、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 看模块是否接受了新值。
目的:用 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 查。
- 「
lsmod里没有就是没编进内核」——内建模块不出现在lsmod,要查modules.builtin。 - 「
insmod会帮我解决依赖」——不会。insmod只加载指定文件,缺依赖时报Unknown symbol,用modprobe或先手动加载依赖。 - 「模块卸载失败是权限问题」——多数是
EBUSY:引用计数不为零,有设备、文件系统或内核线程还在用它。 - 「加载模块是安全的」——模块在 ring 0 运行,一个空指针就能 panic 整机;
kernel.modules_disabled=1是不可逆的加固开关,开了就只能重启。 - 「Secure Boot 下所有模块都能加载」——启用 Secure Boot 后,未签名或密钥不在 MOK 里的模块会被拒绝,
dmesg会看到module verification failed;modinfo -F signer能看签名者。
0.1.12 启动链路:五段、五个负责人、五种证据
这一节回答:从按下电源到出现登录提示符,控制权在谁手里换了五次;每次换手留下什么可查的证据。
排启动问题最常见的错误,是机器还没进用户态就去翻 journalctl。日志服务本身还没起来,当然没有记录。正确做法是先判断「卡在哪一段」,再选那一段的工具。
| 段 | 谁在执行 | 持久产物 | 证据命令 | 典型失败 |
|---|---|---|---|---|
| 1 固件 | UEFI/BIOS | NVRAM 启动项、ESP 分区 | efibootmgr -v、ls /sys/firmware/efi | 找不到启动项、Secure Boot 拒绝 |
| 2 引导器 | GRUB / systemd-boot | /boot/grub/grub.cfg、/etc/default/grub | grep menuentry /boot/grub/grub.cfg | grub>、菜单缺失、内核路径错 |
| 3 内核 | vmlinuz | /boot/vmlinuz-*、/proc/cmdline | cat /proc/cmdline、dmesg | 驱动缺失、根设备找不到 |
| 4 initramfs | /init(用户态第一阶段) | /boot/initrd.img-* | lsinitramfs | Waiting for root device、LUKS 密码错 |
| 5 init | systemd(PID 1) | unit、target 链接 | systemctl get-default、systemd-analyze | emergency 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 还是绕过某个服务。
目的:不依赖发行版文档,用本机证据把五段串起来,输出可直接贴进排障记录的五行摘要。
#!/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 时脚本只跳过该段,不让整条链中断。
- 「进不了系统就是内核坏了」——先看有没有 GRUB 菜单。菜单都没有,问题在固件或引导器;菜单有但内核起不来,才轮到内核与 initramfs。
- 「
journalctl没有日志就是没启动」——内核日志在 journald 起来之前由dmesg//dev/kmsg承载;用journalctl -k -b也读不到时,直接dmesg。 - 「
root=UUID=一定比/dev/sda1稳」——UUID 在mkfs/克隆后会变;PARTUUID 与分区表绑定,更适合定位分区,但恢复分区表后也可能变。 - 「改
grub.cfg就行了」——它是生成物,内核或 grub 包升级会重写;改/etc/default/grub再update-grub。 - 「initramfs 越大越安全」——越大启动越慢、占
/boot越多;MODULES=most适合通用镜像,专用机器用dep加显式列表更可控。 - 「
init=/bin/bash等于单用户模式」——它跳过 systemd,没有服务、没有/proc、根可能只读;能进rescue.target就不要用它。
0.1.13 系统调用接口:唯一合法入口的形状
这一节回答:一次 write 在 CPU 上到底发生了什么;为什么 syscall() 的返回值和 write() 不一样;为什么有些 libc 函数根本不进内核。
系统调用是用户态请求内核服务的唯一合法指令级入口。调用方按 ABI 把系统调用号放进指定寄存器、参数放进参数寄存器,执行陷入指令(x86-64 的 syscall、ARM64 的 svc #0),CPU 切到内核态,由 entry_SYSCALL_64 或 el0_svc 进入分发表,按号调用 sys_*/__x64_sys_* 处理函数。返回值放在返回寄存器;-1 到 -4095 表示错误,其绝对值是 errno。
三个架构的寄存器约定不同,但模型相同:
| 项 | x86-64 | ARM64 | RISC-V 64 |
|---|---|---|---|
| 调用号寄存器 | rax | x8 | a7 |
| 参数寄存器 | rdi、rsi、rdx、r10、r8、r9 | x0–x5 | a0–a5 |
| 返回值 | rax | x0 | a0 |
| 陷入指令 | syscall | svc #0 | ecall |
| 错误表示 | rax ∈ [-4095, -1] | x0 同范围 | a0 同范围 |
这张表说明:系统调用号是架构相关的,C 源码不是。你在 x86-64 上写 SYS_write,交叉编译到 ARM64 时头文件会给出另一个数字;strace 也会按架构解析成同一个名字。手写汇编或看内核原始日志时才需要关心数字。
glibc 在中间做了四件事,每一件都可能改变你看到的行为:
- 寄存器搬运与陷入:把 C 参数按 ABI 放进寄存器,执行
syscall,再把负返回值翻译成-1并设置errno。 EINTR重试:部分慢系统调用(read、write、wait、nanosleep)在收到信号时返回EINTR;glibc 对其中一些做了透明重启,但signal(7)列出的never restarted调用不会。- 取消点:
read、write、open等是 pthread 取消点;直接syscall()绕过 glibc 就没有这层语义。 - vDSO 加速:
clock_gettime、gettimeofday、time、getcpu在支持时由内核映射一段用户态代码(linux-vdso.so.1),不执行陷入指令。所以strace看不到它们,性能也不是系统调用级的。
这张表把「libc 函数」与「真正的系统调用」分开:
| 你写的 | 实际发生 | 失败语义 |
|---|---|---|
printf | 用户态缓冲,满或 fflush 时 write | 返回负数,errno 由 write 设置 |
malloc | 用户态空闲链表;不够时 brk/mmap | 返回 NULL,errno=ENOMEM |
fopen | openat + 用户态 FILE 结构 | 返回 NULL,errno 来自 openat |
getaddrinfo | 读 /etc/nsswitch.conf,可能 socket/sendto/recvfrom | 返回 EAI_*,不是 errno |
sleep | nanosleep(被信号打断会提前返回) | 返回剩余秒数 |
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 不可移植」的具体样子。
目的:证明「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 在较新内核上可能不存在,这本身就是「版本差异」的证据。
- 「
syscall()返回 -1 就是内核返回 -1」——内核返回的是-errno(如-ENOENT);glibc 的syscall()包装把它翻译成-1并设置errno。直接看寄存器才能看到原始负值。 - 「所有 libc 函数都会进内核」——
printf的缓冲、malloc的空闲链表、gettimeofday的 vDSO 都在用户态完成。 - 「系统调用号在 x86-64 和 ARM64 上一样」——完全不同;只有 C 头文件里的
SYS_*宏能跨架构。 - 「
errno是系统调用设置的」——errno是 glibc 的线程局部变量;裸汇编没有errno,只有返回寄存器里的负值。 - 「
strace看到的就是全部内核行为」——vDSO、缺页、调度、中断都不以系统调用形式出现;strace只覆盖 syscall 入口。 - 「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 /tmp | systemd-tmpfiles 会清理 |
/opt | 第三方整包软件 | 是 | ls /opt | 有些商业软件只认这里 |
/srv | 服务数据 | 是 | ls /srv | 约定弱,很多发行版空着 |
/home | 用户主目录 | 是 | findmnt /home | 容器里通常不挂 |
/root | root 的主目录 | 是 | 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 不规定包管理器数据库位置(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 输出更接近生产。每做一次危险操作前打快照,比事后恢复便宜。
读内核源码的时机与方法:
- 先有用户态证据,再读源码。先用
strace看到openat返回EACCES,再去读fs/open.c的权限检查;否则会在几万行里迷路。 - 按函数名跳转,不按目录通读。
elixir.bootlin.com/linux/v6.8支持符号搜索与交叉引用;本地可以用make tags生成 ctags,vim -t do_sys_openat2直接跳。 - 用
git log -S找变更原因。一个奇怪的判断往往对应某次修复;git log --oneline -S 'condition' -- path比注释更能说明「为什么这么写」。 - 固定版本。本书以 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 |
| 3 | cat /proc/meminfo 只是读文件 | 每次读都触发内核生成快照,有开销 | perf stat -e cpu-clock cat /proc/meminfo |
| 4 | blacklist 了模块就不会加载 | 内建模块不受 blacklist 影响 | grep -w m /lib/modules/$(uname -r)/modules.builtin |
| 5 | update-grub 是通用命令 | Debian 包装脚本;RHEL 用 grub2-mkconfig | command -v update-grub |
| 6 | 换了存储控制器,重启就能认盘 | initramfs 里没有新驱动,卡在 Waiting for root device | lsinitramfs ... | grep <driver> |
| 7 | syscall() 与 write() 只差一个前缀 | 绕过缓冲、取消点、EINTR 重启与符号版本 | strace 对比两者 |
| 8 | strace 没看到 clock_gettime 就是没调用 | vDSO 在用户态完成,不陷入内核 | ldd 里有 linux-vdso.so.1 |
| 9 | getconf PAGE_SIZE 与编译期常量一致 | 运行期内核配置决定,跨架构可变 | getconf PAGE_SIZE vs sysconf(_SC_PAGESIZE) |
| 10 | ldd 只是「看依赖」 | 它会用 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 |
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–120 | 0.1.1–0.1.4、0.1.10 | 背景(历史与组成) |
| 鸟哥 | 第 19 章 开机流程、模块管理与 Loader | p.921–970 | 0.1.11–0.1.12 | 完整(启动链路、模块) |
| shell-book | 第 1 章 初识 Linux shell | p.21–34 | 0.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 章 什么是 Shell | p.22–25 | 0.1.3 | 背景(用户态视角) |
| man-pages | syscall(2)、hier(7)、file-hierarchy(7) | man | 0.1.13–0.1.14 | 完整(ABI、目录约定) |
| LSB / FHS 规范 | 现行文档 | 在线 | 0.1.5、0.1.14 | 一句带过(已废弃/约定) |
下一站:0.2 终端、Shell 与命令行工作流把「你敲的字符如何变成内核里的系统调用」这条链路走完;之后所有章节都假设你能分清这三层。
下一站:0.2 终端、Shell 与命令行工作流把「你敲的字符如何变成内核里的系统调用」这条链路走完;之后所有章节都假设你能分清这三层。