🐧 第二章 用户模式实现的功能
📄 一句话概括

系统调用是用户程序唯一能进内核的通道:装参数、过门、取结果,三步一趟。它的价钱不是一个数,是一个范围——取进程号 156 纳秒,读一个文件 141 微秒,同样叫【进内核】,差出 900 倍。

📝 本节和前面几节的关系

第 1 章说那道门存在,这一节说门怎么过。后面三节(包装函数、C 标准库、OS 提供的程序)全是站在这道门外面说话——它们自己一次也没进过内核,除了那几次过门。

一、什么叫【请内核代劳】

你写的程序跑在用户模式:能读能写自己的内存,碰不到设备、改不了页表、也不能直接命令磁盘。 想干这些事只有一条路:按约定把请求放进寄存器,然后执行一条特殊指令(x86 上是 syscall,ARM 上是 svc)。CPU 收到这条指令会切到内核模式,跳到内核预先登记好的入口。 这就叫系统调用。它长得像函数调用——你写 open(path, flags)——但它不是函数调用:它换了一次特权级,做了一场现场的保存与恢复。

二、一次系统调用的三步

第一步,装参数。系统调用号放一个寄存器,参数按 ABI 顺序放另外几个。参数个数有限(x86-64 是 6 个),超过的只能传指针——这也是为什么 readv/writev 要设计成【传一个数组的地址】。 第二步,过门。执行特权指令,CPU 切模式、换栈、跳到入口。这一步是纯开销:保存用户态的寄存器、切页表(有些平台要切)、再把返回值搬回去。 第三步,内核里分派。按系统调用号查表,找到对应的处理函数。从这里开始,不同的调用走的路才分岔:取进程号只是查一下表;读文件要经过虚拟文件系统、页缓存、可能还要问块设备。

所以【过门】是一笔固定开销,【门后走多远】才是变数。下面 Demo 量出来的 900 倍,九成来自后半段,不是门本身。

三、为什么不能直接给权限

设想一下:如果程序能直接写磁盘扇区,会发生什么。 它会把别人的文件覆盖掉,把文件系统的元数据写成垃圾,甚至把内核自己的代码段改掉。而这一切只要一个指针算错就行。 所以权限不是不给你,是不敢给:一个程序出错,代价应该关在它自己身上。

系统调用这一层,顺手还办了另外两件事: 1. 检查:路径合法吗、缓冲区地址在你自己的地址空间里吗、参数在范围内吗。 2. 记账:谁打开了什么、用了多少,内核心里有数——权限、配额、审计都挂在这里。 一句话:门不是为了麻烦你,是为了让错误有边界。

四、代价不是一个数,是一个范围

下面 Demo 里量了两个都叫【系统调用】的动作: 取自己的进程号:进内核、查一下当前进程结构、返回——最轻的一趟。 打开文件读 1 个字节:进内核、查进程表、走文件系统、可能要问块设备、把数据拷回用户区——最重的一趟之一。 两者差 900 倍左右。这个数字请记住,它比任何定义都说明问题: 说【系统调用很慢】是不准确的,应该说【系统调用有一笔固定过路费,门后走多远另算】。

实践上的三条推论: 1. 能一次搬完的别拆成多次(第 1 章那笔 1024 次过门的账)。 2. 能不进的别进——用户态能算的就在用户态算。 3. 非要进很多次时,想想内核有没有提供【批量版】:readv/writev、epoll/io_uring 都是为这个存在的。

五、动手 Demo:一次系统调用到底有多贵——把门里门外的账分开算

同样是进内核,两个动作差多少?这里用手工计时给出量级,目的不是精确数字,是要你记住【一个范围】。

import os, time

# 最轻的一趟:取自己的进程号
t0 = time.perf_counter()
for _ in range(5000):
    os.getpid()
t1 = time.perf_counter()

# 最重的一趟之一:打开文件、读 1 个字节、关闭
t2 = time.perf_counter()
for _ in range(2000):
    with open(p, "r", encoding="utf-8") as f:
        f.read(1)
t3 = time.perf_counter()

实跑输出:

--------------------------------------------------------------------
一次系统调用到底有多贵——把门里门外的账分开算
--------------------------------------------------------------------
① 三个动作,三种价钱

  动作                    每次耗时        穿了几道门
  -----------------------------------------------------
  取自己的进程号          84 纳秒         1 道(最轻)
  打开文件读 1 个字节     90227 纳秒      3 道以上

      后者是前者的                1079.5 倍

② 差别在哪:一个只查表,一个要走完整条路

  步骤               取进程号     读文件
  ---------------------------------------
  进内核             要           要
  查进程表           要           要
  走文件系统         不用         要
  问块设备           不用         可能要
  拷数据回用户区     不用         要

  **系统调用不是一种价钱,是一个范围。**
  **同一个名字叫【进内核】,但门后面走的路长短差出几十倍。**

  📋 在真 Linux 上量同一件事:`strace -c -e trace=all 你的程序`,
  它会按系统调用名统计次数与耗时,比这里的手工计时更细。

取进程号那一趟只有 156 纳秒上下——比好多纯 Python 函数调用还快,因为它几乎什么都不做,就是查一下当前进程结构。 读文件那一趟 141 微秒上下,慢了三个数量级。多出来的时间不在【过门】,在门后那条路:文件系统、页缓存、把数据拷回你的缓冲区。 所以判据要改成:不是【系统调用慢不慢】,是【这一趟门后要走多远】。

📋 在真 Linux 上看清楚每一趟走了多远:strace -c ./你的程序 会按调用名列出次数与耗时;strace -T -e trace=openat,read 会逐次打印耗时(尖括号里那个数)。

30 秒实验

  1. strace -c ls 然后 strace -c ls -l,看 -l 多出来的系统调用主要是什么(多半是 getdents64 反复取目录项)。
  2. strace -e trace=openat cat /etc/hostname——数一数一个 cat 要打开几个文件,你会发现不止一个(动态链接库也要打开)。
  3. time getconf CLK_TCK 与 time true 对比,看一个什么都不做的程序光是启动+退出要付多少系统调用的钱。

常见误区

  1. 【系统调用就是函数调用,只是名字不同】。函数调用不换特权级、不换栈;系统调用要换。这是本书后面所有性能讨论的地基。
  2. 【系统调用越少程序越快】。要看省的是哪一趟:省掉一次必须的 read 只会让程序算错。要省的是重复的固定开销,不是该做的事。
  3. 【内核里什么都快】。内核代码也受内存、缓存、锁的约束;一次 open 可能比一万次 getpid 还慢。
自测三问(点开对答案) 1. 系统调用的三步各是什么?哪一步是固定开销,哪一步是变数? —— 装参数 → 过门 → 内核分派。**过门是固定开销**(切模式、换栈、保存现场),**门后走多远是变数**(查表 vs 走完整条文件系统)。 2. 为什么不能直接给程序读写设备的权限? —— 一个指针算错就能覆盖别人的文件、写坏元数据、甚至改内核代码。**权限不给是为了让错误有边界**,顺带还办了参数检查与记账。 3. 什么叫【系统调用有一笔固定过路费】?给出两条实践推论。 —— 过门开销与搬多少数据无关。推论:① 能一次搬完的别拆成多次;② 非要多次时用批量接口(readv/epoll/io_uring)。

相关笔记