Li Sheng | Backend / Distributed Storage Engineer Li Sheng | Backend / Distributed Storage Engineer
Home
Resume
Projects
Topics
Notes
GitHub (opens new window)
Home
Resume
Projects
Topics
Notes
GitHub (opens new window)
  • Go语言

  • C++

  • 算法题

  • 存储系统

    • 导航

    • 单机IO基础

      • 命令ls的执行过程
        • 从命令文本到新的进程
        • 路径解析把名字变成对象
        • ls 如何读取目录内容
        • 输出不是文件系统路径的结束
        • 把整条链路串起来
        • 进一步讨论
      • mmap 与 write
      • 命令write的执行过程
    • IO语义与持久化

    • 块层与高速路径

    • Ceph

    • DAOS

    • 归档

  • CephFS

  • 分布式系统

  • 计算机网络

  • Redis与缓存

  • Kubernetes

  • 技术笔记
  • 存储系统
  • 单机IO基础
lisheng
2024-09-10
目录

命令ls的执行过程

这篇文章讨论的不是 ls 的命令行参数用法,而是一次普通 ls 从 shell 输入到目录内容显示出来,中间穿过了哪些系统边界。它适合作为一篇桥接型文章:上游连接进程创建和可执行文件加载,下游连接 VFS 路径解析、目录项读取、inode 元数据获取和终端输出。

为了让主线收住,下面默认讨论用户在 shell 中执行不带参数的 ls,也就是查看当前工作目录。带路径参数、ls -l、符号链接、权限错误、挂载点跨越和目录项缓存命中都会让路径分叉,但它们都可以先理解为这条主路径上的局部变体。

# 从命令文本到新的进程

用户在 shell 中输入 ls 后,最先处理这段文本的不是文件系统,而是 shell。shell 会把命令行拆成命令名和参数,并判断 ls 不是内建命令,于是沿着 $PATH 中的目录逐个查找可执行文件。这个查找本身已经会触发路径解析:例如 shell 可能尝试 /usr/local/bin/ls、/usr/bin/ls 或 /bin/ls,每一次都需要内核判断这个路径是否存在、是否是普通文件、当前用户是否有执行权限。

找到可执行文件后,shell 通常先通过 fork() 创建子进程。此时子进程还没有真正变成 ls,它只是 shell 的一个副本,继承了父进程的环境变量、当前工作目录、打开的文件描述符以及标准输入输出。随后子进程调用 execve(),把自己的用户态地址空间替换为 ls 可执行文件对应的程序映像。内核会再次通过 VFS 找到这个 ELF 文件的 inode,读取 ELF 头、程序段和动态链接器信息,建立新的虚拟内存映射,然后返回用户态开始执行 ls 的入口代码。

这里需要注意一个容易混淆的点:fork() 解决的是“谁来执行新程序”,execve() 解决的是“把当前进程替换成哪个程序”。真正把 /bin/ls 这个路径转换成磁盘上某个可执行文件的过程,发生在 execve() 进入内核之后,并且仍然要依赖后面会反复出现的路径解析机制。

# 路径解析把名字变成对象

文件系统里真正稳定存在的对象不是路径字符串,而是目录项、inode 和数据块。路径字符串只是用户态传给内核的名字序列,例如 /bin/ls 可以被拆成根目录、bin、ls 三段。VFS 的路径解析要做的事情,就是从一个起点目录出发,沿着每个路径组件逐级查找,把“名字”转换成内核可以操作的对象。

绝对路径从根目录开始,相对路径从进程的当前工作目录开始。每解析一段名字,VFS 都先在 dentry cache 中查找是否已有这个名字到 inode 的映射。如果缓存命中,内核可以少走一次具体文件系统的目录读取路径;如果缓存未命中,VFS 会调用底层文件系统的 lookup 实现,让 ext4、xfs 或其他文件系统到目录文件的数据结构中查找对应目录项。

以 ext4 为例,目录本身也是一种文件,它的数据内容是一组目录项。目录项保存文件名和 inode 号,有些文件系统还会在目录项中保存文件类型。找到目录项之后,文件系统就拿到了下一段路径对应的 inode 号,再根据 inode 号定位 inode 表或等价的元数据结构,读取权限、文件类型、大小、时间戳以及数据块位置信息。路径中的每一段都会重复这个过程,直到最后一个组件被解析出来。

目录项和 inode 在这里承担的是两种不同职责。目录项解决命名问题,它回答“在这个目录下,名字 ls 指向哪个 inode”。inode 解决对象问题,它回答“这个文件或目录的类型、权限、大小和数据位置是什么”。同一个 inode 可以被多个目录项指向,这就是硬链接存在的基础;重命名文件通常改变的是目录项中的名字或目录项位置,而不是文件内容对应的 inode。

缓存会让这条路径在真实系统中看起来没有那么重。常见命令和常用目录的 dentry、inode 往往已经在内存中,ls 不一定每次都要从磁盘读取目录块和 inode 块。但模型上仍然要把缓存看成加速层,而不是语义来源:缓存命中时少做 I/O,缓存未命中时仍要回到底层文件系统的数据结构中验证名字和对象。

# ls 如何读取目录内容

当 execve() 完成后,ls 已经是一个普通用户态进程。对于不带参数的 ls,它要展示的是当前工作目录的目录项列表。用户态程序不能直接读取内核里的目录结构,也不能绕过 VFS 自己去解析 ext4 目录块,因此它需要通过系统调用请求内核代办。

典型路径可以概括为:ls 打开目标目录,读取目录项,把目录项整理成输出文本,必要时再查询每个条目的 inode 元数据。打开目录时,用户态库函数最终会进入 openat() 一类系统调用。内核根据传入路径找到目标目录的 dentry 和 inode,检查它确实是目录并且当前进程有读取权限,然后创建一个代表“这次打开”的 file 对象,并把它挂到进程的文件描述符表中。用户态拿到的整数 fd,只是这个内核 file 对象的索引。

随后 ls 读取目录内容。现代 Linux 上目录读取最终会走到 getdents64() 这类系统调用,内核通过 VFS 调用具体文件系统的迭代目录实现,把目录文件中的目录项转换成用户态可见的记录。返回给 ls 的核心信息主要是名字、inode 号和文件类型提示。也就是说,读取目录项并不等价于把每个文件的完整 inode 元数据都取出来;在普通 ls 场景下,很多时候名字列表已经足够生成输出。

如果用户执行的是 ls -l,路径会变重。ls -l 需要权限位、链接数、所有者、大小和时间戳等信息,这些信息主要来自 inode,因此 ls 通常还要对目录中的每个名字发起 stat 或相关变体。这样一来,getdents64() 负责把“目录里有哪些名字”列出来,stat 负责进一步追问“每个名字指向的对象有什么元数据”。这两个动作都经过 VFS,但它们解决的问题不同。

这里也能看出 inode 信息为什么不能被简单塞进目录项里。目录项追求的是把目录下的名字组织起来,便于查找和遍历;inode 承载的是对象状态,它会随着写入、权限修改、链接计数变化和时间戳更新而改变。把两者分开,文件系统就可以让多个名字共享同一个对象,也可以在重命名时只移动命名关系,而不搬动文件自身的元数据和数据块。

# 输出不是文件系统路径的结束

当 ls 收集并排序好要显示的名字后,它还要把结果写到标准输出。标准输出在子进程创建时从 shell 继承而来,通常指向一个终端设备、伪终端、管道或重定向文件。ls 调用 write() 后,内核根据 fd 找到对应的 file 对象,再按照这个 file 对象背后的类型分发到不同实现。

如果标准输出是终端,写入会进入字符设备和 tty/pty 相关路径,最终显示在终端窗口里。如果标准输出被重定向到普通文件,write() 又会进入文件写路径,数据可能先进入 Page Cache,再由后续回写机制刷新到存储设备。如果标准输出接到管道,数据会进入内核管道缓冲区,等待下游进程读取。因此,ls 的目录读取路径和结果输出路径并不是同一种 I/O:前者是文件系统元数据读取,后者取决于标准输出所连接的对象类型。

ls 完成输出后退出。子进程释放用户态资源,内核关闭它持有的文件描述符并减少相关 file 对象引用计数。父进程 shell 通过 wait 系列调用回收子进程退出状态,然后重新显示提示符。到这里,一次命令执行才真正闭合。

# 把整条链路串起来

一次 ls 可以看成三次名字到对象的转换叠在一起。第一次发生在 shell 查找可执行文件时,命令名 ls 被 $PATH 中的候选路径解释成某个可执行文件。第二次发生在 execve() 中,内核把这个可执行文件路径解析成 inode,并加载 ELF 映像。第三次发生在 ls 自己读取目标目录时,当前目录或参数路径被解析成目录 inode,再由 getdents64() 把目录文件中的目录项带回用户态。

这条链路里的关键状态也在不断变化。命令文本先变成 shell 的解析结果,再变成子进程;子进程通过 execve() 变成 ls 程序;路径字符串经过 VFS 变成 dentry、inode 和 file;目录文件里的目录项变成用户态缓冲区中的名字列表;最后这些名字又通过标准输出变成终端上的文本。理解 ls 的价值不在于记住某一个系统调用名称,而在于看到用户态程序、VFS 抽象、具体文件系统元数据和设备输出之间怎样接力。

在这个模型里,inode、目录项和 dentry 的边界尤其重要。目录项是磁盘或文件系统持久结构中的名字映射,inode 是文件系统对象的元数据主体,dentry 是 VFS 在内存中为了加速路径解析而维护的名字缓存和连接对象。它们经常在一次路径查找中同时出现,但不是同一种东西。把这三者分清楚,后续理解 open()、stat()、rename()、硬链接、目录遍历和文件删除都会容易很多。

# 进一步讨论

【待展开:dentry cache 如何参与路径解析|dentry-cache-path-lookup】:路径解析的性能和语义边界很大程度取决于 dentry cache、负 dentry、引用计数和失效机制,适合独立展开。

【待展开:getdents 与 stat 的分工边界|getdents-stat-boundary】:目录遍历和 inode 元数据查询经常在 ls 中连续出现,但它们解决的问题不同,适合作为理解目录读取路径的细节节点。

Edit (opens new window)
Last Updated: 2026/04/27, 14:16:46
存储系统总览
mmap 与 write

← 存储系统总览 mmap 与 write→

最近更新
01
待完成专题池
04-28
02
待完成专题池
04-28
03
为什么 Kubernetes CSI 插件架构要拆成 Controller、Node 与 Sidecar
04-28
更多文章>
Theme by Vdoing
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式