← cd ../posts

内核与用户态:你的 `ls` 怎么走到硬盘上的

2026-06-15

这是 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

  1. 用户进程把"我要做什么"(syscall 编号)放到 rax 寄存器
  2. 参数放到 rdi, rsi, rdx, r10, r8, r9
  3. 执行 syscall 指令
  4. CPU 切到 ring 0,跳到内核预设的入口
  5. 内核根据 rax 找到对应的处理函数(如 sys_read
  6. 内核去碰硬件、做事、把结果填回寄存器
  7. 内核执行 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。其中:

  • openat 18 次 → 打开当前目录、各种动态链接库(libc.so 等)
  • read 21 次 → 实际读目录的 entry
  • write 几次 → 把结果写到 stdout
  • close 19 次 → 关闭文件描述符

每一次都是一次用户态 → 内核态 → 用户态的小切换。

关键 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 奇怪。