内核与用户态:你的 `ls` 怎么走到硬盘上的
这是 Linux 系列的第 2 篇。上一篇讲了 Linux 为什么赢,这一篇讲它长什么样——从你敲一条命令到机器干完活,中间发生了什么。
0. 一个看似简单的问题
你打开终端敲了 ls,回车,得到一个文件列表。这件"日常到不能再日常"的小事,机器内部到底走了几步?
$ ls
README.md src/ package.json
直觉答案:ls 程序读了硬盘,把结果打印出来。
真实答案:ls 程序根本碰不到硬盘。它跟一个叫"内核"的东西说"我想读这个目录",内核才去碰硬盘。这中间的"说"——就是 syscall。
这篇就把这层窗户纸捅破。
1. 操作系统是夹在你和硬件之间的"代理人"
想象你写程序时如果没有操作系统,会是什么样:
- 想读磁盘?自己写 ATA / NVMe 协议、自己处理中断
- 想发网络包?自己写 TCP / IP / 网卡驱动
- 想在屏幕画字?自己驱动 GPU
- 别的程序也在跑?自己跟它协调内存、CPU、外设
你会疯掉。OS 出现的意义就是:把硬件的复杂封进一个统一接口,再公平地分给所有应用使用。
┌────────────────────────────────────────┐
│ 应用程序:bash / ls / vim / curl / chrome │ ← 这里是你写的代码
├────────────────────────────────────────┤
│ 系统调用(syscall)边界 ━━━━━━━━━━━━━━━━━ │ ← 唯一允许的过桥点
├────────────────────────────────────────┤
│ 内核:进程调度 / 内存管理 / 文件系统 / 网络栈 │ ← Linus 管的那一坨
├────────────────────────────────────────┤
│ 硬件抽象(驱动) │
├────────────────────────────────────────┤
│ CPU / 内存 / 磁盘 / 网卡 / GPU │ ← 真正干活的物理东西
└────────────────────────────────────────┘
这条 syscall 边界不是程序员画在白板上的虚线——它是 CPU 硬件强制执行的真分界线。
2. CPU 的"特权环":内核为什么动得了硬件
x86 CPU 有 4 个特权级,从 ring 0(上帝模式)到 ring 3(普通模式)。Linux 只用了两个:
| 环 | 谁住这里 | 能做什么 |
|---|---|---|
| ring 0(kernel mode) | Linux 内核、驱动 | 全部 CPU 指令、直接访问硬件、改页表、关中断、读所有内存 |
| ring 3(user mode) | 你的 ls / bash / chrome | 不能直接 I/O、不能读别的进程内存、不能改页表 |
ring 3 想干 ring 0 的事——CPU 立刻甩一个异常给内核,内核会优雅地告诉你 "Segmentation fault"(段错误)或 "Operation not permitted"。
这就是 Linux 安全的根:
- 你写的程序就算崩了,碰不到内核
- 别的用户的进程就算被黑了,碰不到你的内存
- 容器跑垮了,碰不到宿主机
ring 0 这把锁不是软件能绕过的——是硅片上焊死的。
3. Syscall:从环 3 跳到环 0 的唯一合法通道
那你的程序想读文件、发网络包、起新进程,怎么办?答:叫内核帮你做。叫的方式叫 syscall(系统调用)。
x86_64 下,syscall 是一条 CPU 指令 syscall:
- 用户进程把"我要做什么"(syscall 编号)放到
rax寄存器 - 参数放到
rdi, rsi, rdx, r10, r8, r9 - 执行
syscall指令 - CPU 切到 ring 0,跳到内核预设的入口
- 内核根据
rax找到对应的处理函数(如sys_read) - 内核去碰硬件、做事、把结果填回寄存器
- 内核执行
sysret,切回 ring 3,进程拿到结果继续跑
这一切发生在几百纳秒内——你完全没感觉。
ls 一次到底有几次 syscall?
直接用 strace 实地观察(strace 拦截所有 syscall 并打印):
$ strace -c ls > /dev/null
-c 是统计模式,跑完会出表:
% time seconds usecs/call calls errors syscall
------ ----------- ----------- ------ ------- --------
23.45 0.000124 7 18 openat
18.20 0.000096 5 19 close
12.10 0.000064 3 21 read
10.50 0.000055 4 14 mmap
8.31 0.000044 5 8 fstat
...
------ ----------- ----------- ------
175 total
光是 ls 一次就发了 175 次 syscall。其中:
openat18 次 → 打开当前目录、各种动态链接库(libc.so 等)read21 次 → 实际读目录的 entrywrite几次 → 把结果写到 stdoutclose19 次 → 关闭文件描述符
每一次都是一次用户态 → 内核态 → 用户态的小切换。
关键 syscall 速览(认得这些后面看 strace 会顺很多)
进程:fork / clone / execve / exit / wait / kill
文件:open / openat / read / write / close / lseek / stat
内存:mmap / munmap / brk
网络:socket / bind / listen / accept / connect / send / recv
信号:signal / sigaction / kill
时间:gettimeofday / clock_gettime / nanosleep
Linux 全部 syscall 约 400 个(man syscalls 全列)。日常 90% 的事情十几个就够了。
4. 用户态和内核态的"内存隔离"
不止 CPU 指令分级,内存空间也分两边:
高地址 ┌─────────────────────────┐
│ 内核空间(kernel space) │ ← 进程看不到,访问就 segfault
├─────────────────────────┤ ← 64 位上这条线大约在 0xFFFF800000000000
│ │
│ 用户进程的内存(user) │ ← 这就是你的程序的全部世界
│ │
低地址 └─────────────────────────┘
每个用户进程都有自己的 0~`0xFFFF800000000000` 这一大段虚拟地址空间,互相隔离。进程 A 写 0x1000 跟进程 B 写 0x1000 完全是两个物理位置——这是虚拟内存 + MMU 帮你做的。
这就是为什么"杀进程"那么放心——这个进程的所有内存随它一起被回收,不会留下任何残骸去影响别人。
5. 用 /proc 亲眼看一眼内核怎么暴露自己
/proc 是个特殊的"伪文件系统"——它不是磁盘上的目录,是内核暴露自己状态的窗口(下一篇 "一切皆文件" 会专门讲)。
打开终端跟着敲:
# 1. 看你的 shell 进程在内核里长什么样
$ echo $$
12345 # 这是你 shell 的 pid
$ ls /proc/$$/
attr cgroup cmdline comm cwd environ exe fd maps mem net ns oom_score
root stack stat statm status syscall ...
# 2. 看进程当前在哪个 syscall 里卡着(x86_64)
$ cat /proc/$$/syscall
0 0x0 0x... 0x1 0x... ... # 第一列就是 syscall 号(十进制),0 = read
# 常见 x86_64 编号:read=0 / write=1 / openat=257 / futex=202 / epoll_wait=232
# 3. 看系统里所有运行中的进程
$ ls /proc | grep '^[0-9]' | wc -l
217 # 现在有 217 个进程在跑
# 4. 看内核版本(kernel 自己暴露的)
$ cat /proc/version
Linux version 5.15.0-92-generic (buildd@ubuntu) (gcc-11) ...
每一次 cat /proc/...,本质上是 内核动态生成内容塞给你——不在磁盘,只在内存里。
这个能力让 Linux 不需要任何 GUI 管理工具就能完整观察自己。
6. 几个"原来如此"
这层心智模型一旦建立,很多事情立刻通了:
- 段错误(Segmentation fault) = 你访问了不属于你的虚拟内存,MMU 报警 → 内核给你发 SIGSEGV
- OOM Killer = 内存吃光了,内核根据 oom_score 干掉某个进程腾内存(看
/proc/<pid>/oom_score) sudo= 一种合法的方式把进程的 uid 切成 0(root),切换后 syscall 检查放宽- Docker 容器 = 同一个内核,只是把每个容器的 user space 框在了 namespace + cgroup 里。容器之间共用 ring 0,所以容器逃逸是大事件
- WSL2 = Windows 上跑了真的 Linux 内核(轻量 VM)+ Linux userspace,syscall 是原汁原味的
- macOS 不是 Linux = 它的 kernel 叫 XNU(Mach + BSD),但syscall 接口和 Linux 90% 不兼容——这就是为什么 Docker on Mac 实际是个轻量 Linux VM
7. 现在做两件事
① 看下你机器最近的 syscall 调用图
$ strace -c -p 1 2>&1 & # systemd 在干啥(Linux)
$ sleep 5
$ kill %1
不熟悉的 syscall 名字 man 2 <name> 可以查(如 man 2 epoll_wait)。
② 算一下 syscall 有多便宜
# Linux:跑 1000 万次 getpid(最简单的 syscall)
$ time perl -e 'getpid() for 1..10_000_000'
real 0m1.524s
1.5 秒做 1000 万次 → 每次 syscall ~150 纳秒。便宜得离谱——所以你的 ls 才能毫无延迟地跑完 175 次。
下一篇:一切皆文件——
/proc是文件,硬盘是文件,网络连接是文件,连键盘都是文件。这条哲学贯穿整个 Linux,不懂它你会觉得很多 API 奇怪。