一次命令执行:用户态、内核态与系统调用

一次命令执行:用户态、内核态与系统调用
隔壁老康难度 ★★☆☆☆ · 前置:遇到不会的命令,先问这三个来源 | 一句话结论:用户态和内核态的隔离由 CPU 硬件强制,进内核只有系统调用一条路;库函数只是它的包装,返回负数取绝对值才是 errno。
ls 是我学 Linux 时第一个记住的命令,敲回车,一秒之内滚出几十个文件名。
很长一段时间里我都觉得这事挺轻的,屏幕刷一下而已,能有多复杂。第一次跑 strace ls 的时候我愣住了,屏幕上刷出来的不是文件名,而是几十行 openat、getdents64、newfstatat,一条接着一条往下流,原来刚才那一下刷屏,中间有几十次 CPU 从用户态切进内核、干完活再切回来,每一次都要穿过一道平时完全看不见的门。这件事我后来想过很久,因为它解释了很多别的现象,比如为什么有的程序明明只读了一个文件却慢得离谱,为什么同样一行代码在两台机器上开销能差十倍,为什么 strace 挂上去之后问题就不见了。
strace 是后面会专门讲的工具,这里先当望远镜用一下,装完跑一次就能看到大概:
1 | sudo apt install strace # Debian/Ubuntu;CentOS 上是 sudo yum install strace |
这篇想讲清楚的就是这两件事,为什么这道门非有不可,以及一个人穿过它之后,内核那边到底发生了什么。
(动笔前我也没把这两件事分开想过。)
一道门,两个态
CPU 有两种运行状态。
用户态下进程只能碰自己的地址空间,别人的内存碰不到,硬盘碰不到,内核自己的数据结构同样碰不到,想读磁盘、想发包、想创建进程,都得请内核代劳。x86-64 上内核态叫 ring 0,用户态叫 ring 3,Linux 只用这两级,ring 1 和 ring 2 一直空着,从来没派上用场。有件事值得说清楚,这些限制不是靠自觉,是 CPU 硬件强制执行的,你写错一行代码想越界读内核内存,得到的是段错误,不是数据。而系统调用是程序主动进内核的唯一合法入口,中断和异常也能让 CPU 进内核态,但那是硬件触发或者出错触发的,不是程序自己想进去就能进去的,所以你会看到一个很奇怪的现象,一个程序哪怕只干了一件极简的事,比如读一行配置,它的时间线里也密密麻麻塞满了系统调用,而那些调用里真正干活的是少数,大部分是在付过路的代价。
敲一个 ls,底下过了什么
从你按下回车到屏幕上出现文件名,这一路大概是这么走的。
shell 读到这一行,fork 出子进程,子进程 execve("/usr/bin/ls", ...) 把自己换成 ls。ls 启动,glibc 做初始化,读 locale、连动态库,这些也是系统调用。要列目录,调用 opendir("."),底下发出的是 openat。接着循环 readdir(),底下是 getdents64。每个文件名要判断类型和权限,底下是 newfstatat 或 statx。把结果打到 stdout,底下是 write(1, ...)。最后 exit_group(0)。
从第 4 步往后有个区别值得盯住,你在代码里看到的 opendir、readdir 全是库函数,CPU 真正执行的是一条 syscall 指令,这两者之间隔着整层 glibc。
graph TB
A["用户态: ls 进程"] -->|"syscall 指令<br/>rax = 257"| B["CPU 切到 ring 0"]
B --> C["内核: sys_call_table[257]"]
C --> D["sys_openat()"]
D --> E{"权限检查 + 路径解析<br/>交给具体文件系统"}
E -->|成功| F["返回 fd = 3"]
E -->|失败| G["返回 -2"]
F --> H["CPU 切回 ring 3"]
G --> H
H --> I["用户态继续执行<br/>fd 或 errno 已就位"]
x86-64 上这层转换的约定是这样的,调用号放 rax,六个参数依次放 rdi、rsi、rdx、r10、r8、r9,然后执行 syscall 指令,CPU 把返回地址存进 rcx 并跳到内核入口,内核按 rax 去 sys_call_table 取第 N 项执行,结果放回 rax,最后 sysret 返回用户态。返回值有个约定必须记住,内核返回负数表示失败,取绝对值就是 errno。所以内核给你的是 -2,而你在 C 里看到的是 -1 加上 errno == ENOENT,中间那一步是 glibc 的 wrapper 转过去的,大致长这样:
1 | /* 概念示意,不是 glibc 源码原文 */ |
看懂这一层,下面读 strace 输出时就不会被绕进去。strace 打印的是内核的原始返回值,所以它显示 -1 ENOENT 而不是 -1,那个 errno 名字是 strace 自己映射上去的,内核回的只有数字。
自己跑两遍
先造三个文件,然后只看想看的那几个调用。newfstatat 的参数太长,我截掉了,剩下的输出是这样:
1 | mkdir -p /tmp/demo && touch /tmp/demo/{a,b,c}.txt |
1 | openat(AT_FDCWD, "/tmp/demo", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3 |
逐行看。openat 返回 3,是因为 0、1、2 已经被 stdin、stdout、stderr 占了,新 fd 从最小的空闲位开始分配,这个规则后面讲 fd 那篇还会再用到。getdents64 一次取回 5 个目录项,也就是 .、.. 和三个文件,共 144 字节,第二次调用返回 0,表示目录读完了。这里有个容易漏掉的细节,getdents64 的第二个参数是用户态给的缓冲区,返回 144 说的是这次往你缓冲区里写了 144 字节,而不是目录里只有 144 字节,所以你循环调它直到返回 0,中间那几次每次都可能塞回来好几个目录项。
newfstatat 每个文件来一次,因为 ls 要判断类型、权限、大小。write(1, ...) 只有一次,三个名字共 18 字节一次写出,ls 把名字攒在用户态缓冲里,最后才交给内核,标准 IO 缓冲省下来的系统调用就省在这儿。
不同发行版、不同 coreutils 版本的输出会有差异,新版本会拿 statx 代替 newfstatat,但条数不会变。
第二个实验看的是另一个问题,一个进程此刻到底停在哪:
1 | sleep 300 & |
1 | 230 0x1 0x1 0x7ffc3d2a1e10 0x0 0x0 0x0 0x7ffc3d2a1f18 0x7f4e2b1c3a20 |
一共九个字段。第一列 230 是系统调用号,x86-64 上对应 clock_nanosleep,中间六个数是参数,第一个 0x1 是 CLOCK_MONOTONIC,第二个 0x1 是 TIMER_ABSTIME,第三个是指向 struct timespec 的指针,剩下三个没用上所以是 0。最后两列不是参数,倒数第二列 0x7ffc3d2a1f18 是用户态栈指针,最后一列 0x7f4e2b1c3a20 是 program counter,也就是这次调用是从哪条指令发出去的。一个进程任何时刻要么在跑用户态代码,要么卡在某个系统调用上,这个文件告诉你是哪一种,如果它正在用户态跑,内容就只有 running 一个词。这个文件是我查卡死问题最常用的东西之一,因为 ps 里的 D 状态只告诉你它在等,不告诉你等在哪一行,而这里连指令地址都给了。
几个看着对不上的地方
库函数和系统调用经常被混成一回事,其实 printf 不是系统调用,write 才是。malloc 也不是,它大多数时候根本不碰 brk 或 mmap,自己在用户态管着一个内存池。
想验证很简单,看哪个真的出现了 write:
1 | strace -e trace=write printf hello |
系统调用很便宜这个说法也站不住。一次 syscall 指令本身在现代 CPU 上大概几百纳秒,但这只是指令开销,没算进去以后干的活。
真正贵的是三件事,进内核时的寄存器保存与恢复、KPTI 这类缓解措施带来的 TLB 切换、以及内核里那一步真实业务,路径解析可能扫十几次 dentry 缓存。这也是为什么每一层缓冲都值得做,一次 write 写 1 字节和写 4KB,系统调用次数一样,代价差几十倍。(这几条我是拿 perf stat 一遍遍量出来的,量之前我一直以为系统调用贵在指令本身那几百纳秒,量完才看见开销全在回来那一下,TLB 被打掉了)
至于内核态是不是另一个进程,不是。还是同一个进程,只是暂时跑在内核的代码里,ps 里你看到的还是同一个 PID,/proc/PID 里的东西也都还是它的,地址空间也没变,只是 CPU 的当前特权级从 3 换成了 0。我在很长一段时间里都隐约以为进内核等于换了个东西在替我干活,直到有人问我内核态那个进程号是多少,我才发现自己答不上来。静态链接是不是就不需要内核了,也不是,静态链接只是把你的代码和库打进一个文件,open、read 照样得进内核,区别仅在于少了动态链接器那几次 mmap。
还有两条边角
说明白这道门之后,有两个地方必须补上,否则后面看实测结果会有一批对不上号的。
第一,有些东西压根不用进内核。
gettimeofday、clock_gettime、getcpu、time 这几个调用,在内核和用户态之间有一块共享内存页,就是你 cat /proc/PID/maps 时看到的那个以方括号命名的 [vdso] 段(讲内存布局那篇会带你把这个文件一列一列读一遍),内核把当前时间写进去,进程直接读,一次系统调用都不发生。所以 date 跑一万次,strace -c 里看不到 clock_gettime,不是它没调,是它没进内核。(这个结论我信了很久才敢写下来,因为 strace 什么都没显示,按经验等于什么都没发生,可代码明明调了)
这也解释了一个反直觉现象,同一个函数在不同机器上开销能差十倍,看你那个内核有没有把它放进 vDSO。这件事如果你在云上换过机器是有体感的,同一份程序从一台搬到另一台,耗时突然变了一个档次,很多时候不是 CPU 变快了,是这几条热路径上少进了一次内核。
第二,不是所有进内核都叫系统调用。
除了 syscall 指令,还有中断和异常,缺页、除零、非法指令都由 CPU 直接陷进内核,不需要你调任何函数。ioctl 则是个例外中的例外,它本身是系统调用,但一次调用里干什么完全由驱动的命令号决定,strace 只能告诉你调了 ioctl,具体语义得上 man 查那个 _IO* 宏展开出来的是个什么数。(查 ioctl 这件事我放弃过好几次,因为同一个数字在不同驱动上意思完全不一样,没有通用的答案可背)
这两条不改变系统调用是主干道这个结论,但能解释掉一批看着很怪的实测数据。至于上面那个返回值 3 到底是什么,它看着就是个普通整数,背后却连着三层结构,这三层决定了 dup 为什么共享偏移、fork 为什么不复制数据。











