Learning Linux Kernel (Part 2) - Bootstrapping
Let's continue our journey to learn Linux kernel. In this part, we will talk about how Linux kernel is bootstrapped.
前言
在阅读本文之前,建议先看 Part 1,因为我在那篇文章里介绍了 Linux kernel 的核心对象和机制,这些都是理解后续内容的基础。
Linux 内核的启动
如果只能用一个最简洁的方式来理解 Linux 内核的整个生命周期,那就是把它分成两幕:
- 第一幕:Bring-up —— 把内核自己”点亮”
- 第二幕:Steady-state —— 进入长期运行的事件驱动系统
这个划分看似简单,却是理解内核最根本的分界线。我们后续会学到的内存管理(mm)、文件系统(fs)、网络协议栈(net)、设备驱动(driver)、进程调度(sched)——这些令人眼花缭乱的子系统,其实全部都属于第二幕里的不同分支。换句话说,内核的绝大部分复杂性,都发生在系统”活过来”之后。而第一幕的任务,仅仅是”让内核活过来”这件事本身。
从最粗的全景来看,整条启动链是这样展开的:
上电
-> 固件(Firmware)
-> 引导加载器(Bootloader)
-> 内核入口(arch-specific)
-> start_kernel()
-> rest_init()
-> kernel_init (PID 1) + kthreadd (PID 2)
-> 第一个用户态 init 进程
-> steady-state:syscall / exception / interrupt / kthread / workqueue / softirqplaintext其中最重要的分界线是 start_kernel():在它之前,系统还处于”早期引导世界”——很多我们习以为常的内核设施根本还不存在;在它之后,内核才逐步搭建起完整的运行环境,走向”正常内核世界”。
启动链:谁把控制权交给了谁
Firmware → Bootloader → Kernel
很多人一提到”开机”,脑子里就只有 bootloader 这一个概念。但实际上,启动是一条链,而不是一个孤立的节点。理解这条链上的每一环如何交接控制权,是理解内核启动的第一步。
上电后,CPU 会从一个硬件预设的固定地址开始取指。 那个地址上存放着固件(firmware)的代码——它早就被烧录在主板上的 Flash ROM 里。这里有一个非常朴素但重要的事实:CPU 并不会”理解 Linux 然后自动运行它”,它只会做一件事——从当前程序计数器(PC)指向的地址取指令、执行指令。所以启动的第一步,不是 Linux 自己跑起来,而是固件先跑起来。
固件做的事情非常底层:最基础的硬件初始化、内存控制器初始化、寻找启动设备……你可以把它想成机器出厂时就自带的”最底层系统软件”。它不像 Linux 那样是用户安装的操作系统本体,但它也不是纯硬件——它是介于硬件和操作系统之间的一层。在 PC/x86 的世界里,老一点的机器你会听到 BIOS 这个词,而现代机器更常见的则是 UEFI。不管叫什么名字,它们都属于 firmware 这一层。
Bootloader 则运行在 firmware 之后,它的职责是把 Linux 内核拉起来。 GRUB 就是一个广为人知的 bootloader,在现在的默认安装中,你也可以看到诸如 systemd-boot 的身影。在现代 PC 上,常见的启动链条是这样的:
UEFI firmware -> GRUB EFI program -> Linux kernelplaintext也可能是更精简的链条:
UEFI firmware -> Linux EFI stub -> Linux kernel properplaintext这里顺便提一个容易混淆的点:你可能接触过 systemd-boot。它虽然和 systemd 同属一个大项目,但二者的职责完全不同——systemd-boot 是一个 UEFI bootloader,负责”把内核拉起来”;而 systemd 是用户态的 init 系统,负责”内核起来之后,把用户态世界拉起来”(这个我们稍后会提到)。名字相似,角色截然不同。
说回 bootloader 本身。它做的两件最重要的事,概括起来就是:
- 把内核镜像放进内存 —— 内核原本存储在磁盘、Flash、甚至网络上,CPU 无法直接执行那些地方的代码,必须先把它搬到 RAM 里。
- 跳转到内核入口地址 —— 把 PC 指向 Linux 的起点,让 CPU 开始执行内核的第一条指令。
这两步的顺序不能反——如果还没把内核代码加载到内存,就跳转过去,CPU 只会遇到空内容或垃圾数据,系统立刻就会崩溃。从 bootloader 完成跳转的那一刻起,它的历史使命就结束了,执行流正式属于 Linux 内核。
这里有一个常见但值得思考的问题:为什么 bootloader 不能跳过内核,直接启动一个 shell? 答案在于,bootloader 不是操作系统。Shell 之所以能运行,是因为 Linux 为它提供了地址空间、文件系统、系统调用、进程调度、标准输入输出、设备驱动等一整套能力。这些能力不是 bootloader 能提供的。Bootloader 的职责始终只有一个:“把 Linux 请上台”,而不是”代替 Linux 运行应用”。
硬件描述:内核怎么知道机器上有什么
内核并不是天生就知道这台机器有多少内存、UART 在哪个地址、磁盘控制器用哪个中断号。它需要拿到一份”硬件清单和位置表”——没有这个,内核就像一个人走进了陌生的仓库,四面都是墙壁,却没有一张地图告诉它门、灯、工具箱分别在哪里。
不同平台提供这份清单的方式不同:x86 世界里常见的是由 firmware 提供的 ACPI 表,而在 ARM 和 RISC-V 的世界里,更常见的方式是使用 device tree。Device tree 本质上就是一份数据文件——注意,它不是代码——它以结构化的方式描述了这台机器上有哪些设备、每个设备的地址和连接关系。Bootloader 通常会在启动时将 device tree 和内核镜像一起准备好,一并交给内核。这样,内核从被唤醒的第一刻起,就已经知道了”这台机器长什么样”。
Early Boot:先让内核自己活下来
内核接管 CPU 之后,并不会直接运行你的 shell 或者启动 systemd。在那之前,它有一项更紧迫的任务:先把”自己能正常工作”这件事搞定。 这个过程分为两层,理解这个分层,对后续阅读内核源码至关重要。
汇编入口
这一层的代码通常在 arch/<arch>/kernel/head*.S 文件里——注意后缀 .S 指的是汇编源文件。之所以这里用汇编而不是 C,不是因为内核开发者偏爱汇编,大家如果有 C 写,干嘛写汇编,而是因为一个更根本的原因:C 代码运行本身就依赖一部分运行环境,而 early boot 的任务恰恰是要把这部分环境先搭建出来。
换句话说,在系统启动的最初几步,C 语言还”站不稳”。举一个最朴素的例子:你写一个 C 函数 void foo() { int x = 1; bar(x); },这短短两行代码至少需要栈来存放局部变量、返回地址和需要保存的寄存器。如果连栈指针(比如我们在 x86 中熟知的 rsp)都还没有设置好,函数调用的基本机制就完全不靠谱。所以必须先用更接近机器底层的汇编代码,把 C 语言运行所需的那些前提条件一个个搭出来。
具体来说,head.S 最核心要完成的几件事是:
- 建立一个最早期可用的栈(stack) —— 没有栈,复杂的函数调用几乎都无法稳定进行。这是让内核”先站稳”的第一步。
- 清空 BSS 段 —— BSS 段存放的是那些”默认初始值为 0 的全局和静态变量”,而内存里原本的内容可能是脏数据。如果不清零,内核里的很多全局状态从一开始就是不可预测的垃圾值,后续运行中会出现各种诡异的行为。
- 建立最小可用的页表(page table) —— 内核通常不会一直在裸物理地址模式下工作,它需要逐步进入自己正常的地址空间布局。但此刻只需要一个”最小可用”的页表——保证当前正在执行的代码和数据能被正确访问、能继续往后初始化即可。
- 设置最基本的 CPU 模式和特权级,然后跳入 C 的世界。
- 在多核机器上,先只让 boot CPU 往前走 —— 其他 CPU(secondary CPUs)在这个阶段还没有被唤醒,它们会在后续的初始化阶段才被逐个 bring-up。所以整个 early boot 过程,本质上是单核在运行的。
需要强调的是,head.S 并不是要把 early boot 的所有事情都包办——它只完成第一棒:“让 C 代码终于能稳定跑起来”。后面的 C 代码会接手真正搭建整个系统的工作。不过,即便进入了 C 阶段,仍然会偶尔插入少量汇编代码,用于读写特权寄存器、切换页表、开关中断、处理 trap 入口和返回等特别贴近硬件的步骤——这些事情不适合用 C 来做,因为 C 编译器无法精确控制到那种程度。
这里还有一个常见的误解值得纠正:很多人看到 start_kernel() 这个函数名,就以为”这是内核执行的第一行代码”。其实不是。在 start_kernel() 被调用之前,已经有一段更底层、更脆弱、更依赖具体硬件架构的 early boot 代码跑过了。start_kernel() 更像是宣告:“终于,我们进入了相对正常的内核初始化阶段。“
start_kernel() —— 把系统真正搭起来
start_kernel() 位于 init/main.c,是内核通用 C 初始化的”大入口”。你可以把它想成内核的”总装配流程”——它按照严格的依赖关系,一层一层地把系统的各项能力搭建出来。以下是它做的最重要的五件事:
第一,建立内存基础。 内核得先搞明白这台机器上有哪些 RAM 可用、哪些区域是保留的不能触碰,然后建立起基本的内存分配能力。这是整个初始化过程的基石——没有内存管理,后面的数据结构、任务对象、缓存、驱动对象统统都建不起来,一切都无从谈起。
第二,建立事件入口。 系统跑起来之后,内核会不断地被系统调用、异常、中断拉回来——这是它日常运行的基本模式。所以在正式开门营业之前,必须先把 trap、syscall、interrupt 的入口处理路径都准备好。可以用一个比喻来理解:先把所有的”门”都装好,再把”门后面接待的人和处理流程”安排到位。如果门都没有,用户程序发了系统调用没人接,设备来了中断没人接,访问内存出了错也没人接——那整个系统根本不可能稳定运行。
第三,建立任务与调度基础。 Linux 不是一个顺着一条代码从头跑到尾的系统,它本质上是一个多执行流系统。所以内核需要在这个阶段建立 task 的基本表示形式、scheduler 的基础调度能力、每个 CPU 的调度环境,以及 idle task。这一步的本质,是让系统从”只有一条启动路径在往前走”的状态,转变为”能够管理和切换多个执行流”的状态。
第四,建立公共基础设施。 这包括 timer 和 timekeeping(时间子系统)、workqueue(工作队列)、中断子系统更完整的部分、VFS 基础(虚拟文件系统)、驱动模型的基础框架等等。这些基础设施就像城市的水电煤气管网——它们本身不是最终产品,但没有它们,上层的文件系统、驱动、网络协议栈都无法正常运转。没有时间系统,调度和超时机制都难以工作;没有 workqueue,很多需要延后处理的工作没法优雅完成;没有 VFS 基础,后面就没办法统一处理”文件”这件事。
第五,切换到长期运行状态。 start_kernel() 不会永远停留在初始化阶段。当各项基础设施就绪后,它要完成最后的身份转换——从”施工阶段”切到”营业阶段”:创建后续的关键执行流、启动第一个用户程序、让 boot CPU 进入正常的调度与 idle 角色。从这一刻起,Linux 才真正开始像我们熟悉的操作系统那样长期运行。
如果你仔细观察这五步,会发现其中有一种有趣的”先有鸡还是先有蛋”的味道:想动态分配复杂对象,先得有最基础的内存管理;想运行多个执行流,先得有任务和调度能力;想处理中断,先得把入口装好。所以整个启动过程的本质,是从一个极简的初始状态出发,逐步搭建出更强大的状态——后一步总是依赖前一步已经建好的能力。这也是为什么启动代码看起来和普通内核代码有些不同:它不是”正常工作态的代码”,而是”自举代码”——你会看到一堆 __init 标记、一堆架构特殊入口、一堆 early allocator、一堆按严格顺序依次解锁系统能力的初始化调用。不是代码写得丑,而是这个问题本身就特殊——它在解决的是”操作系统如何从无到有把自己搭建起来”。
最后,用一句话总结这两层的分工:
- early boot / head.S 解决的是”内核怎么先活下来”
- start_kernel() 解决的是”内核怎么变成一个完整的系统”
rest_init():从初始化走向长期运行
当 start_kernel() 执行到后期,系统已经具备了基本的内存管理、trap/syscall/interrupt 入口、scheduler 的基本形态等关键能力。但启动并没有结束——接下来要引入三个在内核世界里具有特殊地位的角色:
- PID 0:idle / swapper —— 每个 CPU 在”没有其他 task 可运行”时所对应的 idle 执行体。在 boot CPU 上,它同时也承担着”启动路径当前执行体”的角色——也就是说,一直在执行
start_kernel()的那个执行流,其身份就是 PID 0。 - PID 1:init —— 未来的第一个用户态进程,整个用户态世界的起源。
- PID 2:kthreadd —— 内核线程体系里的核心管理者,后续几乎所有内核线程的创建都要经过它。
rest_init() 正是把系统从”初始化过程”推进到”长期运行过程”的关键函数。它会创建两个新的 task——未来的 PID 1(kernel_init)和 PID 2(kthreadd),然后 boot CPU 自身通常进入 idle loop。从这一刻起,系统不再只是”一条启动路径在孤独地往前走”,而是开始具备真正的多执行流结构——有任务在运行,有调度器在调度,系统正式”活”了起来。
你可能会好奇:为什么不在 start_kernel() 里一路把所有事情初始化完,然后直接跳到用户态?答案在于,后续的初始化工作——比如设备探测、驱动加载、文件系统准备等——本身就需要在一个具备调度和多执行流能力的环境中进行。有些初始化操作需要等待 I/O 完成,有些需要阻塞等待其他子系统就绪,有些还需要和其他内核线程协作。在一个没有调度器的单线程环境里,这些操作根本无法正常完成。所以,必须先把多执行流结构建立起来,让 boot CPU 释放出来进入正常的调度角色,后续的初始化才能在”正常的内核运行时环境”中顺利完成。
另一个细节值得注意:kernel_init 通常不会在 kthreadd 还没有准备好之前就一股脑地往前冲——因为很多后续内核线程的创建和管理都依赖 kthreadd,它们之间存在明确的协调和同步关系。
第一个用户程序是怎么来的?
这里有一个很反直觉但非常重要的事实:未来的 PID 1 一开始并不是用户程序。
它诞生时只是一个由内核创建的 task,先在内核态执行 kernel_init() 函数。只有在 kernel_init() 完成了一系列后续初始化工作之后,它才会去加载并执行真正的用户态 init 程序(如 /sbin/init 或 /init)。这不是内核凭空变出了一个正在运行用户代码的进程,而是一个清晰的两步过程:
内核创建一个 task
-> 这个 task 先跑 kernel_init()
-> kernel_init() 再通过 exec 切换到真正的用户态 init 程序plaintext在执行 exec 之前,kernel_init() 还有不少事情要忙。这个阶段的工作已经不属于 very early boot,但也还没有正式进入用户态世界——它处于二者之间的过渡地带:等关键的内核线程基础设施就绪、继续完成设备探测和驱动加载、准备文件系统、挂载 root filesystem……在源码中你会看到 kernel_init_freeable()、prepare_namespace()、run_init_process()、try_to_run_init_process() 这些函数名,它们就是这个过渡阶段的主要参与者。
内核会去运行哪个 init 程序?
这取决于启动配置,最经典的两种情况如下:
- 有 initramfs 的情况: 内核直接运行 initramfs 中的
/init。initramfs 是一份临时的根文件系统,在真正的根文件系统还没有挂载好之前,先给内核一个能运行早期用户空间程序的环境。这些程序可以帮忙加载内核模块、发现磁盘设备、解密加密分区、组装 RAID/LVM,等一切准备就绪之后,再通过switch_root或pivot_root切换到真正的根文件系统。内核启动时先把 initramfs 解包到 rootfs;如果 rootfs 里存在 /init,就以 PID 1 执行它。 - 没有 initramfs 的情况: 内核在准备好真正的 root filesystem 之后,会逐个尝试一些常见路径——
/sbin/init、/etc/init、/bin/init、/bin/sh,或者由 boot 参数init=指定的程序。如果所有路径都找不到可执行的 init 程序,内核就会报出那个经典的 panic 信息:“No working init found”。
现代 Linux 发行版几乎都走 initramfs 路径。一条典型的链条是这样的:
kernel -> initramfs 中的 /init -> 准备真正 root fs -> switch_root -> 真正系统上的 init (如 systemd)plaintext从原理上说,initramfs 并不是绝对必须的。如果内核已经内建了访问根文件系统所需的所有驱动,并且能够直接识别和挂载 rootfs,那就可以跳过这一步。但在现代通用发行版的复杂硬件环境下,initramfs 几乎是标配。
“运行第一个用户程序”在底层到底发生了什么?
当内核决定运行某个 init 程序时,它本质上要执行一次 execve 类的操作。这个过程涉及多个子系统的紧密配合:
- 通过 VFS 的路径解析机制找到目标文件
- 判断文件的格式——是 ELF 可执行文件?还是脚本?需要哪个 binary handler 来处理?
- 创建或替换进程的地址空间(
mm),建立代码段、数据段、栈等内存映射 - ELF loader 解析程序头信息,将各个 segment 映射进用户地址空间,准备初始的用户栈,并把 argv 和 envp 放到正确位置
- 设置初始寄存器状态:让用户态的程序计数器指向 ELF 的 entry point,让栈指针指向新建的用户栈
- 从内核态返回到用户态——从这一刻起,这个 task 不再执行
kernel_init()里的下一行 C 代码,而是开始在用户态执行 init 程序的第一条用户指令
可以这样概括这个过程:同一个 task 的控制流身份,从内核初始化线程,变身成了用户态 init 进程。所以从内核到第一个用户进程的过渡,本质上是 task + mm + exec 三件事的配合。在这里你也可以了解了一个 process 究竟是什么:从内核对象视角看,用户平时说的‘进程’更像是以 task_struct 为中心、再加上一组相关对象及共享关系形成的抽象。
用户态的 init 又是干什么的?
到这里,一个自然的问题是:内核已经起来了,为什么还需要用户态的 init 程序?
答案涉及操作系统设计中一个经典的分离原则。内核提供的是 mechanism(机制)——进程管理、地址空间、文件系统接口、调度、网络协议栈等底层能力。而用户态的 init 负责的是 policy(策略)和 orchestration(编排)——系统启动后具体要做什么、挂载哪些文件系统、启动哪些服务、要不要图形界面、关机和重启怎么组织……这些决策属于用户态的策略层面。/sbin/init 或 systemd 的职责,就是把内核提供的基础能力组织起来,编排成一个对用户可用的完整 Linux 系统。
简单来说:内核搭建的是”操作系统的引擎”,用户态 init 搭建的是”操作系统的环境”。 两层缺一不可——没有引擎,环境无处安放;没有环境,引擎的能力无从发挥。
User Mode 与 Kernel Mode 的来回切换
当第一个用户程序成功跑起来之后,Linux 就正式进入了 steady-state——长期运行的稳态。从此以后,系统日复一日做的事情,归结起来只有一个模式:
用户态程序运行
-> 因为 syscall / exception / interrupt 进入内核
-> 内核处理
-> 返回用户态plaintext这个简单的循环,就是 Linux 日常运行的核心节奏。
为什么需要 user mode 和 kernel mode?
User mode 和 kernel mode 的划分,是现代操作系统最根本的隔离机制。试想,如果系统里所有代码都拥有和内核一样的权限,后果会怎样?任何一个普通应用程序都可以随意修改页表、直接控制硬件设备、覆盖其他进程的内存、甚至把整个系统搞崩溃。这显然是不可接受的。
所以,CPU 在硬件层面就提供了权限级别的划分:user mode 权限较低,普通用户程序运行于此;kernel mode 权限最高,内核运行于此。用户程序不能直接执行敏感操作(比如修改页表或访问硬件寄存器),必须通过受控的入口请求内核代劳——而这,正是系统调用(syscall)存在的根本原因。
三种最重要的”进入内核”方式
Syscall(系统调用) 是用户程序主动对内核说”我需要你帮我做一件事”。比如 read、write、fork、mmap 等操作。这是一种主动的、有意识的进入方式——程序知道自己在请求内核服务。
Exception / Fault(异常) 则不同,它发生在当前指令执行时同步出了问题,CPU 发现必须交给内核来处理。最典型的例子就是 page fault。假设用户程序在执行 x = *p 这条语句,CPU 试图访问指针 p 指向的虚拟地址时,发现页表里没有有效的映射,或者当前的权限不允许这次访问,于是 CPU 产生一个 fault,自动切换到内核态。内核接手之后,会判断这个 fault 是否可以修复:
- 可修复的情况(很常见): 可能需要分配一个物理页面并建立映射,也可能需要处理 Copy-on-Write(COW)。内核修好之后,会让 触发 fault 的那条指令重新执行——注意,是重试那条原始指令,而不是跳过它。
- 不可修复的情况: 比如程序访问了一个完全非法的地址,内核会给进程发送 SIGSEGV 信号,进程通常就此崩溃终止。
这里最关键的认知是:fault 是和”当前这条指令”绑定的同步事件——它不是外面突然来了一条消息,而是当前执行流自己”绊倒”了。
Interrupt(中断) 是第三种方式,来自外部设备的异步通知——网卡收到了数据包、磁盘完成了一次 I/O 操作、定时器到了预设时间。中断和 exception 的关键区别在于它的异步性质:它可以在任意时刻打断当前正在执行的代码,不管那段代码正在做什么。
同样,和我在 Part 1 中就提到的,这三种方式是理解内核运行时行为的基础框架:syscall 是程序主动请求服务,exception 是当前指令同步出事,interrupt 是外部设备异步发消息。
从 CPU 视角看进入内核的过程
不管是通过哪种方式进入内核,从 CPU 硬件的视角来看,过程在抽象层面上都是相似的:CPU 发现需要离开当前正在执行的用户代码 → 保存最基本的返回信息 → 切换到更高的权限模式 → 跳转到内核预设的入口地址 → 内核入口代码保存更多的寄存器和上下文信息 → 根据进入的原因,分发到正确的处理函数。
这里需要引入一个重要的概念:trap frame(陷入帧)。它就是”这次陷入内核时保存下来的执行现场”——包括程序计数器、栈指针、通用寄存器、状态寄存器、trap 原因等信息。内核处理完毕后需要回到原来的用户程序继续执行,就必须依靠这份保存下来的现场信息来恢复。所以”进入内核”不是随便跳进来就行,而是一次精心编排的 保存现场 → 处理 → 恢复现场 的完整过程。
以 RISC-V 架构为例来说明:用户态程序运行在 U-mode(用户模式),当触发 trap 时,CPU 硬件自动将返回地址记录到 sepc 寄存器,将 trap 原因写入 scause 寄存器,与 fault 相关的地址或值记入 stval 寄存器,然后切换到 S-mode(监管模式),跳转到 stvec 寄存器所指定的入口地址。随后内核的入口代码保存通用寄存器、构造 trap frame,C 代码根据 scause 的值进行分发处理,处理完毕后用 sret 指令返回用户态。具体的寄存器名称因架构而异(例如 x86 有一套完全不同的 IDT/trapframe 机制),但背后的思想完全一致:CPU 把”出事了”或”有请求了”的最小状态信息交给内核,内核接管控制权、处理事件、然后恢复现场并返回。
返回用户态不一定回到”原来那个进程”
内核处理完之后,如果是一个简单的 syscall 完成或者中断处理完毕,通常就恢复 trap frame、切回用户态,让原来的程序继续执行。但事情并不总是这么直截了当。如果内核在处理过程中发现,当前这个 task 应该被抢占、有其他更高优先级的 task 需要运行,那么内核会先切换到那个更该运行的 task。原来的 task 会在未来某个时刻才再被调度回来。所以 trap 路径和调度器经常是紧密联系在一起的。一个典型的流程可能是这样的:
用户程序调用 read()
-> trap 进内核
-> 发现需要等待磁盘 I/O
-> 当前 task 进入 blocked 状态
-> scheduler 选择另一个 task 运行
-> (时间流逝……)
-> 磁盘 I/O 完成,中断到来
-> 原来的 task 被唤醒
-> 在某个合适的时刻再被调度到 CPU 上运行
-> 从系统调用返回到用户态plaintext还有一点值得补充:内核并不是”只在用户程序 trap 进来时才运行”的。 内核自己也有长期存在的执行流——kthreadd、kworker、kswapd、各种 rcu_* 内核线程等。它们不是因为某个用户程序触发 trap 才临时存在的,而是内核自己创建的、由调度器正常调度的 task。所以在 steady-state 下,CPU 在任意时刻可能正在运行三种东西中的一种:用户线程、内核线程、或者 idle task。
系统调用与函数调用的区别
在你写用户代码时,read(fd, buf, n) 看起来和调用一个普通函数没什么两样——传几个参数,拿个返回值,就完事了。但这只是 C 标准库(libc)给你的精心包装。在这层包装之下,真正发生的事情远比一次函数调用复杂:用户态代码执行一条特殊的 CPU 指令(x86 上是 syscall,RISC-V 上是 ecall,ARM 上是 svc)→ CPU 切换权限级 → 跳到内核预设的入口地址 → 保存执行现场 → 识别系统调用编号和参数 → 检查参数的合法性 → 执行真正的内核逻辑 → 可能需要在用户态和内核态之间复制数据 → 可能会阻塞 → 可能会触发调度 → 最后恢复现场,返回用户态。
和普通函数调用相比,系统调用跨越了两个关键的边界:
- 权限边界 —— 从 user mode 切换到 kernel mode
- 可信边界 —— 内核不能直接信任用户传进来的任何东西:指针可能非法、长度可能越界、文件描述符可能无效、标志位可能含有恶意值
正是因为这两个边界的存在,syscall 天然就带着 trap 进入和返回的成本、上下文的保存与恢复、安全检查、copy_to_user / copy_from_user 的数据搬运、以及可能的阻塞与调度等开销。对于像 getpid() 这样非常轻量的 syscall,真正的工作几乎可以忽略不计,进入和退出内核本身的固定开销反而占据了绝大部分时间;而对于像 read() 这样可能涉及 VFS 路径解析、page cache 查找、磁盘 I/O 等操作的 syscall,真正的工作量才是大头。
这就是为什么在高性能场景下,大家总是在强调”减少系统调用的次数”。批量 I/O(readv/writev/preadv2)、mmap、sendmmsg/recvmmsg、epoll、io_uring、zero-copy techniques……这些看起来五花八门的优化手段,本质上都在解决同一个核心问题:不要因为用户态和内核态之间的边界太昂贵,就让系统把大量时间浪费在频繁的来回切换上。
绕开 syscall 的例外:vDSO
有一些被调用频率极高、又可以安全优化的操作,Linux 选择用一种巧妙的方式来绕开 syscall 的开销。这就是 vDSO(virtual Dynamic Shared Object)机制——内核会将一小段特殊的代码和数据页映射到每个用户进程的地址空间里,让某些操作(最典型的如 clock_gettime)不必真的 trap 进内核就能完成。这个机制的存在本身就是一种反证:它证明了 syscall 的边界确实昂贵到值得专门设计一套机制来绕开它。
I/O 性能优化的几种武器
当你理解了 syscall 的边界成本之后,这些 I/O 优化手段的存在就顺理成章了。它们大多在解决三类成本中的一种或多种:syscall 太频繁导致的切换开销、数据拷贝太多导致的内存带宽浪费、等待 I/O 的方式太笨导致的 CPU 空转。
批量 I/O 不是某个单一的 API,而是一类设计思想:既然每次进内核都有固定开销,那就一次进去多做几件事。readv/writev 在一次调用中处理多个不连续的缓冲区,sendmmsg/recvmmsg 在一次调用中发送或接收多个消息,preadv2/pwritev2 支持批量的偏移量 I/O,io_uring 更是可以一次提交多个异步请求,都是这个思路的不同实例。
mmap 把文件或匿名内存映射进进程的地址空间,让你可以像访问一个大数组一样直接访问文件内容,背后由 page fault 机制按需加载对应的数据页。它不是”一个更快版本的 read”,而是一种思路的根本转换——把”文件 I/O 操作”改写成”内存地址访问”,横跨了内存管理和 VFS 两个子系统。不过 mmap 也有它的代价:page fault 的触发时机不可预测,导致性能抖动比显式 read 更难排查;延迟不一定平滑;写回和一致性语义更加复杂;而且并非所有 I/O 场景都适合用 mmap。
sendmmsg/recvmmsg 是专门针对消息型 I/O 的批量优化。它允许在一次 syscall 里发送或接收多个 datagram,在高包率的 UDP 网络场景中,可以显著减少 syscall 次数带来的开销。
epoll 帮你高效地监视大量文件描述符,告诉你哪些 fd 当前已经 ready——意思是”现在对这个 fd 做 read/write 操作大概率不会阻塞”。它提供的是一种 readiness notification 模型:内核告诉你谁准备好了,你再自己去做实际的 I/O 操作。epoll 特别适合高并发网络服务器的场景,where 你可能同时维护着数万个连接。
io_uring 是近年来 Linux I/O 领域最重要的创新之一。它在用户态和内核之间共享两组 ring buffer:一个 Submission Queue(提交队列)和一个 Completion Queue(完成队列)。用户态程序往 SQ 里填入 I/O 请求,内核处理完后把结果放进 CQ。这是一种 completion notification 模型——和 epoll 的 readiness notification 不同,它的语义是”你把请求交给我,做完了我告诉你结果”。io_uring 不是”更快的 epoll”,而是一种全新的 I/O 提交与完成模型,二者解决的问题和使用场景都有所不同。
zero-copy 同样不是单一的 API,而是一类优化目标:尽量避免在数据传输路径上做无谓的内存拷贝。常见的实现机制包括 sendfile、splice、mmap、MSG_ZEROCOPY 等。不过 zero-copy 通常会带来更复杂的 buffer 生命周期管理和 completion 语义——你省了拷贝的成本,但要付出管理复杂度的代价。
如果你尝试把这些接口按照内核子系统来归位,会发现它们各有所属:mmap 主要涉及内存管理和 VFS;epoll 属于事件通知模型和 VFS/socket 层;sendmmsg/recvmmsg 属于 socket/network 的系统调用优化;io_uring 属于 I/O 提交模型和异步 I/O 框架;而 zero-copy 则横跨了内存管理、VFS、网络和 DMA 等多个子系统。建立起这个映射关系,后续分模块深入学习时就更容易定位”这个东西在内核的哪一层”。
启动全景图
最后,用一张完整的图来为这篇文章收尾:
硬件上电
-> Firmware (BIOS/UEFI)
-> Bootloader (GRUB/systemd-boot)
-> Kernel entry (head.S): setup stack, clear BSS, minimal page table
-> start_kernel(): memory, trap, scheduler, timer, VFS, driver core
-> rest_init(): create PID 1 (kernel_init) + PID 2 (kthreadd)
-> kernel_init(): late init, prepare rootfs, exec /init or /sbin/init
-> PID 1 becomes first userspace process (often systemd)
-> Steady-state: user <-> kernel via syscall / exception / interruptplaintext这条从上电到 steady-state 的主线一旦通了,后面再去学调度、内存管理、文件系统、网络协议栈,就都有了一个稳固的坐标系。你不会再迷失在某个子系统的细节里,因为你总是能回到这条主线上,知道自己正在研究的东西处于整个系统生命周期的哪个位置。