本文基于《The Linux Programming Interface》第14章,力求用最通俗的语言解释所有概念。

目录

  1. 设备特殊文件
  2. 磁盘与分区
  3. 文件系统结构
  4. I-node(索引节点)
  5. 虚拟文件系统 VFS
  6. 日志文件系统
  7. 挂载与卸载
  8. 高级挂载特性
  9. tmpfs 内存文件系统
  10. 获取文件系统信息

1. 设备特殊文件

核心概念

把操作系统想象成一个餐厅的厨房:

  • 设备驱动程序 = 厨师,每种菜(设备)对应一个专属厨师
  • 设备文件 = 菜单上的菜名,用户点菜不用管厨师怎么做
  • 统一接口 = 不管你点什么菜,下单方式(open/read/write/close)都一样
用户程序
   |
   | open() / read() / write() / close()
   v
设备驱动程序(内核代码)
   |
   v
真实硬件(鼠标、磁盘、键盘...)

设备的两种类型


类型 说明 举例
字符设备 一个字节一个字节地处理数据,像流水 键盘、终端
块设备 一块一块地处理数据(通常512字节为单位) 磁盘、磁带驱动器

设备ID(主设备号 & 次设备号)

每个设备文件有两个编号:

  • 主设备号(major ID):标识设备类别,内核靠它找到对应的驱动程序
  • 次设备号(minor ID):在同类设备中唯一标识某个具体设备
    ls -l /dev 就能看到:
brw-rw---- 1 root disk 8, 1 /dev/sda1
                        ^  ^
                        |  次设备号
                        主设备号

Linux 2.6 之前,主/次设备号各用 8 位,最多 256 个。
Linux 2.6 之后,主设备号用 12位,次设备号用 20位,大幅扩展了设备数量上限。

2. 磁盘与分区

机械硬盘的物理结构

         磁盘盘面(俯视图)
         ╭─────────────────╮
        ╱   ╭─────────╮     ╲
       │   ╱  ╭─────╮  ╲    │   <-- 磁道(同心圆)
       │  │  ╱  [磁头] ╲ │   │
       │  │ │    中心   │ │  │
       │  │  ╲  旋转  ╱  │  │
       │   ╲  ╰─────╯  ╱   │
        ╲   ╰─────────╯   ╱
         ╰─────────────────╯
  磁道(track) → 扇区(sector) → 物理块(physical block, 通常512字节)

读写一次数据需要三个时间:
T t o t a l = T s e e k + T r o t a t i o n + T t r a n s f e r T_{total} = T_{seek} + T_{rotation} + T_{transfer} Ttotal=Tseek+Trotation+Ttransfer
其中:

  • T s e e k T_{seek} Tseek:磁头移动到目标磁道的时间(寻道时间)
  • T r o t a t i o n T_{rotation} Trotation:等待目标扇区转到磁头下方的时间(旋转延迟)
  • T t r a n s f e r T_{transfer} Ttransfer:实际读写数据的时间(传输时间)
    总时间通常是毫秒级,而现代CPU在这段时间能执行数百万条指令,差距巨大。

磁盘分区

一块磁盘可以分成多个互不重叠的分区,每个分区对内核来说就像一个独立设备。

物理磁盘
┌────────────────────────────────────────┐
│  /dev/sda1  │  /dev/sda2  │ /dev/sda3  │
│  (分区1)    │  (分区2)    │  (分区3)   │
└────────────────────────────────────────┘

每个分区可以存放:

内容 说明
文件系统 存放普通文件和目录
原始数据区 某些数据库直接用原始块设备
交换区(swap) 内核用于内存管理的虚拟内存扩展

3. 文件系统结构

文件系统是什么

文件系统就是磁盘上数据的组织方式,像图书馆的分类编目系统:没有它,磁盘上只是一堆混乱的0和1。
使用 mkfs 命令创建文件系统。

Linux 支持的文件系统类型

Linux 的强大之处在于支持多种文件系统:

  • 传统:ext2、Minix、System V、BSD
  • Windows:FAT、FAT32、NTFS
  • 光盘:ISO 9660
  • Mac:HFS
  • 网络:NFS、SMB、NCP
  • 日志型:ext3、ext4、Reiserfs、JFS、XFS、Btrfs

ext2 文件系统的内部结构

磁盘分区
┌──────────────────────────────────────────────────┐
│ 启动块  │ 超级块  │  I-node表  │     数据块区域    │
│ (boot) │(super) │ (i-list)  │  (data blocks)   │
└──────────────────────────────────────────────────┘
  1块       1块      若干块         大量块

各部分说明:
启动块(Boot Block)

  • 每个文件系统都有,但只有根文件系统的启动块真正被使用
  • 存放操作系统引导信息
    超级块(Superblock)
  • 记录整个文件系统的"户口本":
    • i-node 表的大小
    • 逻辑块的大小
    • 文件系统总的逻辑块数
      I-node 表(I-node Table)
  • 每个文件/目录对应一个 i-node 条目
  • 记录文件的所有元数据(但不包含文件名!)
    数据块(Data Blocks)
  • 实际存放文件内容和目录内容的地方

ext2 实际上比上面描述的更复杂:分区被分成多个等大小的块组(block group),每个块组都包含超级块副本、块组参数、i-node 表和数据块。这样做的目的是把一个文件的所有块尽量放在同一块组内,减少磁头移动距离,提高顺序访问速度。

逻辑块大小

文件系统分配空间的基本单位是逻辑块,ext2 的逻辑块大小为 1024、2048 或 4096 字节(创建时用 mkfs 参数指定)。

4. I-node(索引节点)

I-node 存储什么信息

I-node 是文件的"身份证",包含:

字段 说明
文件类型 普通文件、目录、符号链接、字符设备等
所有者 UID 文件属于哪个用户
所属组 GID 文件属于哪个用户组
访问权限 owner/group/other 三组权限
三个时间戳 最后访问时间、最后修改时间、最后状态变更时间
硬链接数 有多少个目录项指向这个 i-node
文件大小 字节数
已分配块数 实际占用的 512 字节块数量
数据块指针 指向实际数据在磁盘上的位置

注意:文件名不存储在 i-node 里,而是存储在目录中!

ext2 的数据块指针结构

这是 ext2 最精妙的设计之一。每个 i-node 有 15 个指针

i-node
┌─────────────────┐
│  文件元数据      │
├─────────────────┤
│  直接指针 0     │──────────────→ 数据块 0
│  直接指针 1     │──────────────→ 数据块 1
│  ...            │
│  直接指针 11    │──────────────→ 数据块 11
├─────────────────┤
│  一级间接指针   │──→ [指针块]──→ 数据块 12, 13...
├─────────────────┤
│  二级间接指针   │──→ [指针块]──→ [指针块]──→ 数据块...
├─────────────────┤
│  三级间接指针   │──→ [指针块]──→ [指针块]──→ [指针块]──→ 数据块...
└─────────────────┘

各级指针能访问多大文件?(以 4096 字节块大小为例,每个指针 4 字节)
每个指针块能存放的指针数:
n = 4096  bytes 4  bytes/pointer = 1024  个指针 n = \frac{4096 \text{ bytes}}{4 \text{ bytes/pointer}} = 1024 \text{ 个指针} n=4 bytes/pointer4096 bytes=1024 个指针

指针类型 可访问块数 对应文件大小
12个直接指针 12块 12 × 4096 = 48  KB 12 \times 4096 = 48\ \text{KB} 12×4096=48 KB
一级间接指针 1024块 1024 × 4096 ≈ 4  MB 1024 \times 4096 \approx 4\ \text{MB} 1024×40964 MB
二级间接指针 1024 2 1024^2 10242 1024 2 × 4096 ≈ 4  GB 1024^2 \times 4096 \approx 4\ \text{GB} 10242×40964 GB
三级间接指针 1024 3 1024^3 10243 1024 3 × 4096 ≈ 4  TB 1024^3 \times 4096 \approx 4\ \text{TB} 10243×40964 TB

理论上最大文件大小约为:
F m a x ≈ 1024 3 × 4096 ≈ 4  TB F_{max} \approx 1024^3 \times 4096 \approx 4 \text{ TB} Fmax10243×40964 TB

这个设计的三大优点

  1. i-node 大小固定,但文件大小可以任意
  2. 支持文件空洞:中间没有数据的部分,指针置0,不占实际磁盘空间
  3. 小文件访问极快:80% 以上的文件可以只用12个直接指针找到,无需间接寻址

5. 虚拟文件系统 VFS

VFS 解决什么问题

假设你要写一个程序,需要同时支持 ext4、NTFS、NFS 三种文件系统。没有 VFS 的话,你需要为每种文件系统写不同的读写代码,工作量巨大。
VFS(Virtual File System,虚拟文件系统)是内核提供的抽象层

用户程序
    |  open() read() write() ...(统一接口)
    v
┌──────────────────────────────────┐
│         VFS 抽象层               │
└──────────────────────────────────┘
    |         |         |        |
    v         v         v        v
  ext2      ext3     Reiserfs   NTFS   NFS ...
(各自实现 VFS 接口)

程序只和 VFS 打交道,不需要知道底层是哪种文件系统。
VFS 接口包括所有常用系统调用:open()read()write()lseek()close()stat()mkdir()link()unlink()symlink()rename() 等。

6. 日志文件系统

传统文件系统的问题

想象你正在写文件,突然停电。文件系统的元数据(i-node、目录条目、块指针)可能只更新了一半,导致数据不一致
重启后必须运行 fsck(文件系统检查工具)扫描整个磁盘,大磁盘可能需要数小时,对服务器来说完全无法接受。

日志文件系统的解决方案

类比:在真正做事之前,先把计划写进日记本(journal log)。

写文件流程:
传统文件系统:
  直接写元数据 ──→ 如果中途崩溃,元数据损坏
日志文件系统:
  1. 先写日志(记录"我要做什么")
  2. 再做实际更新
  3. 成功后标记日志完成
  崩溃恢复:重放日志,秒级恢复

系统崩溃后:

  • 传统文件系统:fsck 全盘扫描,数小时
  • 日志文件系统:重放日志,几秒钟

主要日志文件系统对比


文件系统 特点 加入内核版本
Reiserfs 首个日志FS,支持尾部压缩(小文件节省空间) 2.4.1
ext3 ext2 平滑升级,可无缝迁移 2.4.15
JFS IBM 开发 2.4.20
XFS SGI 开发,最初用于 Irix 2.4.24
ext4 ext3 后继者,支持更大文件和更多特性 2.6.19
Btrfs 全新设计,支持快照、校验和等现代特性 2.6.29

日志的代价:每次文件更新需要额外写日志,有一定性能开销,但设计良好的实现开销很低。

7. 挂载与卸载

单一目录树

Linux 所有文件系统共享一棵目录树,根是 /

/
├── bin/
├── home/
│   └── mtk/          ← sda8 文件系统挂载在这里
│       └── test/     ← sda9 (reiserfs) 挂载在这里
├── windows/
│   └── C/            ← sda1 (vfat) 挂载在这里
└── proc/             ← 虚拟文件系统

挂载命令:

mount device directory
# 例如:
mount /dev/sda8 /home

三个重要文件


文件 说明
/proc/mounts 内核维护,当前挂载信息,最准确
/etc/mtab mount/umount 命令维护,比 /proc/mounts 详细一点
/etc/fstab 管理员手动维护,描述系统中所有可用文件系统

/proc/mounts 文件格式(每行6个字段):

/dev/sda9  /boot  ext3  rw  0  0
   1          2     3    4  5  6

字段 含义
1 设备名
2 挂载点
3 文件系统类型
4 挂载标志(rw/ro 等)
5 dump 备份控制(仅 fstab 有意义)
6 fsck 检查顺序(仅 fstab 有意义)

mount() 系统调用

#include <sys/mount.h>
// 函数原型
int mount(const char *source,      // 设备路径,如 "/dev/sda1"
          const char *target,      // 挂载点,如 "/mnt/disk"
          const char *fstype,      // 文件系统类型,如 "ext4"
          unsigned long mountflags,// 挂载标志(见下表)
          const void *data);       // 文件系统特定选项字符串
// 成功返回 0,失败返回 -1

常用挂载标志:

标志 含义
MS_RDONLY 只读挂载
MS_NOSUID 禁用 set-user-ID 程序
MS_NOEXEC 禁止执行文件系统上的程序
MS_NODEV 禁止访问设备文件
MS_SYNCHRONOUS 所有写操作同步完成
MS_REMOUNT 重新挂载(修改已挂载文件系统的标志)
MS_BIND 创建绑定挂载
MS_NOATIME 访问文件时不更新最后访问时间,提升性能
MS_RELATIME 只在访问时间早于修改时间时才更新(Linux 2.6.20+ 的默认行为)

完整可运行的 C++ 示例:mount 命令行工具

// t_mount.cpp
// 功能:提供 mount(2) 系统调用的命令行接口
// 编译:g++ -o t_mount t_mount.cpp
// 用法:sudo ./t_mount [-t fstype] [-o data] [-f flags] source target
#include <sys/mount.h>   // mount() 函数和 MS_* 标志
#include <stdio.h>       // fprintf, stderr
#include <stdlib.h>      // exit, EXIT_SUCCESS, EXIT_FAILURE
#include <string.h>      // strlen
#include <unistd.h>      // getopt
#include <errno.h>       // errno
#include <string.h>      // strerror
// 打印使用说明并退出
static void usageError(const char *progName, const char *msg) {
    if (msg != NULL)
        fprintf(stderr, "%s\n", msg);
    fprintf(stderr, "Usage: %s [options] source target\n\n", progName);
    fprintf(stderr, "Available options:\n");
    fprintf(stderr, "  -t fstype        文件系统类型,如 'ext4' 或 'vfat'\n");
    fprintf(stderr, "  -o data          文件系统特定选项,如 'bsdgroups'\n");
    fprintf(stderr, "  -f mountflags    挂载标志组合,可包含:\n");
    fprintf(stderr, "      b - MS_BIND         创建绑定挂载\n");
    fprintf(stderr, "      d - MS_DIRSYNC      目录更新同步\n");
    fprintf(stderr, "      l - MS_MANDLOCK     允许强制锁\n");
    fprintf(stderr, "      m - MS_MOVE         原子移动挂载点\n");
    fprintf(stderr, "      A - MS_NOATIME      不更新访问时间\n");
    fprintf(stderr, "      V - MS_NODEV        不允许设备访问\n");
    fprintf(stderr, "      D - MS_NODIRATIME   不更新目录访问时间\n");
    fprintf(stderr, "      E - MS_NOEXEC       不允许执行程序\n");
    fprintf(stderr, "      S - MS_NOSUID       禁用 set-user/group-ID\n");
    fprintf(stderr, "      r - MS_RDONLY       只读挂载\n");
    fprintf(stderr, "      c - MS_REC          递归挂载\n");
    fprintf(stderr, "      R - MS_REMOUNT      重新挂载\n");
    fprintf(stderr, "      s - MS_SYNCHRONOUS  同步写入\n");
    exit(EXIT_FAILURE);
}
int main(int argc, char *argv[]) {
    unsigned long flags = 0;   // 挂载标志,初始为0(无特殊标志)
    char *data   = NULL;       // 文件系统特定选项字符串
    char *fstype = NULL;       // 文件系统类型字符串
    int opt;
    // 解析命令行参数
    // getopt 返回识别到的选项字符,-1 表示解析结束
    while ((opt = getopt(argc, argv, "o:t:f:")) != -1) {
        switch (opt) {
        case 'o':
            // -o 后面跟文件系统选项,如 "bsdgroups,noatime"
            data = optarg;
            break;
        case 't':
            // -t 后面跟文件系统类型
            fstype = optarg;
            break;
        case 'f':
            // -f 后面跟标志字母组合,如 "Rr" 表示 MS_REMOUNT|MS_RDONLY
            for (int j = 0; j < (int)strlen(optarg); j++) {
                switch (optarg[j]) {
                case 'b': flags |= MS_BIND;        break; // 绑定挂载
                case 'd': flags |= MS_DIRSYNC;     break; // 目录同步
                case 'l': flags |= MS_MANDLOCK;    break; // 允许强制锁
                case 'm': flags |= MS_MOVE;        break; // 移动挂载点
                case 'A': flags |= MS_NOATIME;     break; // 不更新访问时间
                case 'V': flags |= MS_NODEV;       break; // 禁止设备访问
                case 'D': flags |= MS_NODIRATIME;  break; // 不更新目录访问时间
                case 'E': flags |= MS_NOEXEC;      break; // 禁止执行
                case 'S': flags |= MS_NOSUID;      break; // 禁用 SUID
                case 'r': flags |= MS_RDONLY;      break; // 只读
                case 'c': flags |= MS_REC;         break; // 递归
                case 'R': flags |= MS_REMOUNT;     break; // 重挂载
                case 's': flags |= MS_SYNCHRONOUS; break; // 同步写入
                default:
                    usageError(argv[0], "未知的标志字符");
                }
            }
            break;
        default:
            usageError(argv[0], NULL);
        }
    }
    // 解析完选项后,剩余参数应恰好为 2 个:source 和 target
    if (argc != optind + 2) {
        usageError(argv[0], "错误:需要且仅需要 source 和 target 两个参数");
    }
    // 调用 mount() 系统调用
    // argv[optind]     = source(设备路径)
    // argv[optind + 1] = target(挂载点)
    if (mount(argv[optind], argv[optind + 1], fstype, flags, data) == -1) {
        fprintf(stderr, "mount 失败: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }
    printf("挂载成功: %s -> %s\n", argv[optind], argv[optind + 1]);
    exit(EXIT_SUCCESS);
}

umount() 和 umount2()

#include <sys/mount.h>
// 卸载文件系统
int umount(const char *target);   // target 是挂载点路径
// 带控制选项的卸载
int umount2(const char *target, int flags);

umount2 的 flags:

标志 含义
MNT_DETACH 懒惰卸载:标记为不可新访问,已有进程用完后再真正卸载
MNT_EXPIRE 标记为过期:下次调用时如果没有进程使用则真正卸载
MNT_FORCE 强制卸载(仅 NFS),可能导致数据丢失
UMOUNT_NOFOLLOW 不解引用符号链接,用于安全场景

注意:如果文件系统处于"忙碌"状态(有进程打开了其上的文件,或某个进程的当前目录在其中),umount() 会返回 EBUSY 错误。

8. 高级挂载特性

8.1 一个文件系统挂载到多个挂载点

Linux 2.4 以后,同一个文件系统可以同时挂载到多个目录:

mount /dev/sda12 /testfs   # 第一个挂载点
mount /dev/sda12 /demo     # 第二个挂载点
touch /testfs/myfile       # 通过第一个挂载点创建文件
ls /demo                   # 通过第二个挂载点也能看到 myfile

两个挂载点看到的是同一份数据,任何修改都互相可见。

8.2 多个文件系统叠加到同一挂载点(栈式挂载)

mount /dev/sda12 /testfs   # 底层:sda12
touch /testfs/myfile       # 创建文件
mount /dev/sda13 /testfs   # 在同一挂载点再挂一个:sda13 盖住了 sda12
ls /testfs                 # 只能看到 sda13 的内容
umount /testfs             # 弹出 sda13
ls /testfs                 # 现在又能看到 sda12 的 myfile 了
挂载栈(/testfs):
┌─────────────────┐  ← 当前可见:sda13
│    sda13        │
├─────────────────┤  ← 被遮住:sda12
│    sda12        │
└─────────────────┘

实际用途:可以在忙碌的挂载点上叠加新挂载,已有进程继续用旧的,新进程用新的,实现平滑迁移。

8.3 绑定挂载(Bind Mount)

绑定挂载让一个目录(或文件)同时出现在两个位置:

mkdir d1 && touch d1/x
mkdir d2
mount --bind d1 d2   # d1 的内容通过 d2 也可访问
ls d2        # 看到 x
touch d2/y   # 在 d2 创建文件
ls d1        # d1 也能看到 y,因为它们是同一个东西

绑定挂载 vs 硬链接:

特性 硬链接 绑定挂载
跨文件系统 不支持 支持
针对目录 不支持 支持

实际用途:在 chroot 监狱中,用绑定挂载(只读)提供 /lib 等目录,不需要复制文件。

8.4 递归绑定挂载

默认绑定挂载不包含子挂载点,加 --rbind 选项递归复制:

# 目录结构:top/ 挂载了 src1,top/sub/ 挂载了 src2
mount --bind top dir1     # 普通绑定:dir1/sub/ 是空的(src2没有复制)
mount --rbind top dir2    # 递归绑定:dir2/sub/ 包含 src2 的内容

9. tmpfs 内存文件系统

tmpfs 是什么

tmpfs 是一种驻留在内存中的文件系统,对用户程序来说和普通文件系统完全一样,但因为不需要磁盘 I/O,速度极快。
特点:

  • 使用 RAM 存储,RAM 不够时用交换空间(swap)
  • 系统崩溃或卸载后所有数据丢失(这就是"tmp"的含义)
  • 不需要 mkfs 预先创建,mount 时自动初始化
# 在 /tmp 上叠加一个 tmpfs(栈式挂载,不影响原有 /tmp 内容)
mount -t tmpfs newtmp /tmp
# 验证
cat /proc/mounts | grep tmp
# newtmp /tmp tmpfs rw 0 0

默认大小限制为 RAM 的一半,可以用 size= 选项修改:

mount -t tmpfs -o size=512m newtmp /tmp   # 限制为 512MB

tmpfs 的内核内部用途

除了用户使用,内核内部也用 tmpfs:

  1. 实现 System V 共享内存shmget 等)
  2. 实现共享匿名内存映射
  3. /dev/shm:用于 POSIX 共享内存和 POSIX 信号量

10. 获取文件系统信息

statvfs() 函数

#include <sys/statvfs.h>
// 通过路径获取文件系统信息
int statvfs(const char *pathname, struct statvfs *statvfsbuf);
// 通过文件描述符获取文件系统信息
int fstatvfs(int fd, struct statvfs *statvfsbuf);

返回的结构体 struct statvfs

struct statvfs {
    unsigned long f_bsize;    // 文件系统块大小(字节)
    unsigned long f_frsize;   // 基本块大小(字节,通常和 f_bsize 相同)
    fsblkcnt_t    f_blocks;   // 文件系统总块数(以 f_frsize 为单位)
    fsblkcnt_t    f_bfree;    // 空闲块总数
    fsblkcnt_t    f_bavail;   // 普通用户可用的空闲块数(超级用户保留部分除外)
    fsfilcnt_t    f_files;    // i-node 总数
    fsfilcnt_t    f_ffree;    // 空闲 i-node 数
    fsfilcnt_t    f_favail;   // 普通用户可用的空闲 i-node 数
    unsigned long f_fsid;     // 文件系统 ID
    unsigned long f_flag;     // 挂载标志
    unsigned long f_namemax;  // 文件名最大长度
};

保留块:超级用户专用的空间,文件系统满了普通用户无法写入,但管理员仍能登录处理问题:
保留块数 = f _ b f r e e − f _ b a v a i l \text{保留块数} = f\_bfree - f\_bavail 保留块数=f_bfreef_bavail

完整可运行的 C++ 示例:查询文件系统信息

// t_statvfs.cpp
// 功能:查询并显示指定路径所在文件系统的详细信息
// 编译:g++ -o t_statvfs t_statvfs.cpp
// 用法:./t_statvfs /path/to/file_or_dir
#include <sys/statvfs.h>  // statvfs 结构体和函数
#include <stdio.h>        // printf, fprintf
#include <stdlib.h>       // exit
#include <errno.h>        // errno
#include <string.h>       // strerror
int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "用法: %s <路径>\n", argv[0]);
        exit(EXIT_FAILURE);
    }
    struct statvfs sb;  // 用于接收文件系统信息的结构体
    // 调用 statvfs,填充 sb
    if (statvfs(argv[1], &sb) == -1) {
        fprintf(stderr, "statvfs 失败: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }
    printf("=== 文件系统信息:%s ===\n\n", argv[1]);
    // 块大小信息
    printf("块大小 (f_bsize):          %lu 字节\n", sb.f_bsize);
    printf("基本块大小 (f_frsize):     %lu 字节\n", sb.f_frsize);
    // 空间信息(转换为 MB 方便阅读)
    // 总空间 = 总块数 * 块大小
    double total_mb  = (double)sb.f_blocks * sb.f_frsize / (1024 * 1024);
    // 空闲空间(包含超级用户保留)
    double free_mb   = (double)sb.f_bfree  * sb.f_frsize / (1024 * 1024);
    // 普通用户可用空间(超级用户保留块已扣除)
    double avail_mb  = (double)sb.f_bavail * sb.f_frsize / (1024 * 1024);
    // 超级用户保留空间
    double reserv_mb = free_mb - avail_mb;
    printf("\n--- 空间信息 ---\n");
    printf("总空间:                    %.2f MB (%lu 块)\n", total_mb, (unsigned long)sb.f_blocks);
    printf("空闲空间:                  %.2f MB (%lu 块)\n", free_mb,  (unsigned long)sb.f_bfree);
    printf("普通用户可用:              %.2f MB (%lu 块)\n", avail_mb, (unsigned long)sb.f_bavail);
    printf("超级用户保留:              %.2f MB\n", reserv_mb);
    // 使用率
    if (sb.f_blocks > 0) {
        double used_ratio = 100.0 * (sb.f_blocks - sb.f_bfree) / sb.f_blocks;
        printf("空间使用率:                %.1f%%\n", used_ratio);
    }
    // I-node 信息
    printf("\n--- I-node 信息 ---\n");
    printf("总 i-node 数:              %lu\n", (unsigned long)sb.f_files);
    printf("空闲 i-node 数:            %lu\n", (unsigned long)sb.f_ffree);
    if (sb.f_files > 0) {
        double inode_used = 100.0 * (sb.f_files - sb.f_ffree) / sb.f_files;
        printf("I-node 使用率:             %.1f%%\n", inode_used);
    }
    // 其他信息
    printf("\n--- 其他信息 ---\n");
    printf("文件名最大长度:            %lu 字符\n", sb.f_namemax);
    printf("挂载标志 (f_flag):         0x%lx\n", sb.f_flag);
    // 检查只读标志(ST_RDONLY = 1)
    if (sb.f_flag & ST_RDONLY)
        printf("挂载模式:                  只读\n");
    else
        printf("挂载模式:                  读写\n");
    return 0;
}

总结

Linux 文件系统体系(从底到顶)
物理磁盘
  └── 分区(partition)
        └── 文件系统(ext4 / xfs / btrfs ...)
              ├── 启动块
              ├── 超级块(文件系统元参数)
              ├── I-node 表(每个文件一个条目)
              └── 数据块(实际文件内容)
访问路径:
用户程序 → VFS(统一接口)→ 具体文件系统实现 → 设备驱动 → 硬件

概念 一句话总结
设备文件 /dev 下的特殊文件,是访问硬件的入口
磁盘分区 把磁盘切成多个独立区域,各自可有不同文件系统
I-node 文件的身份证,存元数据不存文件名
VFS 抽象层,让程序不用关心底层文件系统类型
日志文件系统 写前记日志,崩溃后秒级恢复
绑定挂载 让一个目录同时出现在两个路径
tmpfs 内存文件系统,速度快但掉电丢失

Linux 扩展属性(Extended Attributes)详解(第16章)

本文基于《The Linux Programming Interface》第16章,力求用最通俗的语言解释所有概念。

目录

  1. 什么是扩展属性
  2. 命名空间
  3. 从命令行操作扩展属性
  4. 实现细节与限制
  5. 系统调用接口
  6. 完整示例程序
  7. 练习:实现简易 setfattr
  8. 总结

1. 什么是扩展属性

通俗理解

把一个文件想象成一本书:

  • 普通文件属性(stat 里的那些)= 书的封面信息:标题、作者、出版日期、页数
  • 扩展属性(EA, Extended Attributes) = 贴在书上的便利贴:可以写任何你想记录的信息,比如"这本书是借来的"、“上次读到第50页”、“MIME类型是text/plain”
    扩展属性是以**名值对(name-value pair)**形式附加到文件 i-node 上的任意元数据,从 Linux 2.6 开始支持。

EA 的典型用途


用途 说明
访问控制列表(ACL) 内核用 system 命名空间 EA 实现,见第17章
文件能力(capabilities) 内核用 security 命名空间 EA 实现,见第39章
MIME 类型 记录文件内容类型,如 text/html
字符集 记录文件编码,如 UTF-8
版本号 记录文件的自定义版本信息
图标 存储指向图形图标的指针

支持 EA 的文件系统

EA 需要底层文件系统支持,目前支持的有:
Btrfs、ext2、ext3、ext4、JFS、Reiserfs(2.6.7+)、XFS

2. 命名空间

命名规则

每个扩展属性的名字格式为:

namespace.name

例如:user.commentsystem.posix_acl_accesssecurity.selinux
namespace 决定了这个 EA 属于哪个功能类别,name 在该命名空间内唯一标识这个 EA。

四种命名空间详解

EA 命名空间
├── user        普通用户可用,受文件权限控制
├── trusted     仅特权进程(CAP_SYS_ADMIN)可操作
├── system      内核使用(如 ACL)
└── security    安全模块使用(如 SELinux、文件能力)

命名空间 谁能操作 典型用途
user 普通用户(需要文件读/写权限) 用户自定义元数据
trusted 特权进程(需 CAP_SYS_ADMIN) 系统管理员专用元数据
system 内核控制 ACL(访问控制列表)
security 安全模块(如 SELinux) 安全标签、文件能力

注意:在 ext2/ext3/ext4/Reiserfs 上使用 user 命名空间的 EA,需要挂载时加 user_xattr 选项:

mount -o user_xattr /dev/sda1 /mnt/data

user EA 的特殊限制

user 命名空间的 EA 只能放在普通文件和目录上,不能放在:

  • 符号链接:符号链接的权限对所有用户全开且不可修改,用权限控制 EA 访问会失效
  • 设备文件、socket、FIFO:这些文件的权限用于控制 I/O 访问,用来控制 EA 会产生冲突
    还有一个规则:不能对其他用户拥有的、设置了粘滞位(sticky bit)的目录添加 user EA。比如 /tmp 目录是全球可写的但有粘滞位,这防止了用户随意在 /tmp 上挂 EA。

3. 从命令行操作扩展属性

基本操作演示

# 创建测试文件
touch tfile
# 设置 user.x 属性,值为字符串
setfattr -n user.x -v "The past is not dead." tfile
# 设置 user.y 属性
setfattr -n user.y -v "In fact, it's not even past." tfile
# 获取单个属性的值
getfattr -n user.x tfile
# 输出:
# file: tfile
# user.x="The past is not dead."
# 显示所有 user EA(默认只显示 user 命名空间)
getfattr -d tfile
# 输出:
# file: tfile
# user.x="The past is not dead."
# user.y="In fact, it's not even past."
# 把 user.x 的值改为空字符串(注意:空字符串 != 不存在)
setfattr -n user.x tfile
getfattr -d tfile
# 输出:
# file: tfile
# user.x          ← 值为空字符串
# user.y="In fact, it's not even past."
# 删除 user.y
setfattr -x user.y tfile
getfattr -d tfile
# 输出:
# file: tfile
# user.x          ← 只剩 user.x,且值为空字符串

关键区分:空字符串 vs 未定义

user.x = ""    →  EA 存在,值为空字符串
user.y         →  EA 不存在(已被删除)

这两种状态是完全不同的,就像字典里"键存在但值为空"和"键根本不存在"的区别。

显示所有命名空间的 EA

# -m 指定匹配 EA 名字的正则表达式,默认是 ^user\.
# 用 - 表示匹配所有
getfattr -m - tfile

4. 实现细节与限制

VFS 层面的通用限制

Linux VFS 对所有文件系统施加的上限:

限制项 上限
EA 名字长度 255 字符
EA 值大小 64 KB

各文件系统的额外限制


文件系统 额外限制
ext2/ext3/ext4 单个文件所有 EA 的名字+值总字节数不超过一个逻辑块(1024/2048/4096 字节)
JFS 单个文件所有 EA 的名字+值总字节数不超过 128 KB

ext2/ext3/ext4 的限制比较严格,一个逻辑块(通常 4096 字节)要同时存放所有 EA 的名字和值,因此实际能存的元数据量有限。

5. 系统调用接口

系统调用分三组(set/get/remove/list),每组都有三个变体:

xxx attr()   → 通过路径访问,会解引用符号链接
lxxx attr()  → 通过路径访问,不解引用符号链接(直接操作符号链接本身)
fxxx attr()  → 通过文件描述符访问

5.1 设置(创建/修改)EA

#include <sys/xattr.h>
int setxattr(const char *pathname, const char *name,
             const void *value, size_t size, int flags);
int lsetxattr(const char *pathname, const char *name,
              const void *value, size_t size, int flags);
int fsetxattr(int fd, const char *name,
              const void *value, size_t size, int flags);
// 成功返回 0,失败返回 -1

参数说明:

参数 说明
pathname / fd 目标文件的路径或文件描述符
name EA 名字,以 null 结尾的字符串,如 "user.comment"
value 指向 EA 值的缓冲区
size value 缓冲区的字节长度
flags 控制行为,见下表

flags 取值:

flags 值 行为
0 默认:不存在则创建,已存在则替换
XATTR_CREATE 仅创建:若 EA 已存在则失败(EEXIST
XATTR_REPLACE 仅替换:若 EA 不存在则失败(ENODATA

5.2 读取 EA 值

#include <sys/xattr.h>
ssize_t getxattr(const char *pathname, const char *name,
                 void *value, size_t size);
ssize_t lgetxattr(const char *pathname, const char *name,
                  void *value, size_t size);
ssize_t fgetxattr(int fd, const char *name,
                  void *value, size_t size);
// 成功返回 EA 值的字节数,失败返回 -1

技巧:两步读取法
由于不知道 EA 值有多大,可以先用 size=0 查询大小,再分配缓冲区:

第一步:getxattr(path, name, NULL, 0)  → 返回需要的字节数 n
第二步:分配 n 字节的缓冲区
第三步:getxattr(path, name, buf, n)   → 真正读取值

注意:两步之间其他进程可能修改了 EA,所以第二步仍可能失败,需要做错误处理。
常见错误:

错误码 含义
ENODATA 该名字的 EA 不存在
ERANGE 提供的 size 太小,装不下 EA 值

5.3 删除 EA

#include <sys/xattr.h>
int removexattr(const char *pathname, const char *name);
int lremovexattr(const char *pathname, const char *name);
int fremovexattr(int fd, const char *name);
// 成功返回 0,失败返回 -1
// 若 EA 不存在,返回 ENODATA 错误

5.4 列出文件的所有 EA 名字

#include <sys/xattr.h>
ssize_t listxattr(const char *pathname, char *list, size_t size);
ssize_t llistxattr(const char *pathname, char *list, size_t size);
ssize_t flistxattr(int fd, char *list, size_t size);
// 成功返回复制到 list 的字节总数,失败返回 -1

返回的 list 是一串连续的 null 结尾字符串,格式如下:

user.x\0user.y\0trusted.foo\0
^      ^^      ^^           ^
|      ||      ||           末尾
名字1  分隔  名字2  分隔   名字3

遍历方式:每次跳过 strlen(当前名字) + 1 个字节,直到超过总长度。
权限说明

  • 调用 listxattr 只需要对文件路径中各目录有执行权限,不需要对文件本身有权限
  • 出于安全考虑,返回的列表可能省略调用进程无权访问的 EA(如非特权进程看不到 trusted.*
  • 但这不是强制要求,因此后续用返回的名字调用 getxattr 时仍可能因权限不足而失败

6. 完整示例程序

6.1 原书示例:显示文件所有 EA(C++完整实现)

// xattr_view.cpp
// 功能:显示命令行指定文件的所有扩展属性名和值
// 编译:g++ -o xattr_view xattr_view.cpp
// 用法:./xattr_view [-x] file1 [file2 ...]
//   -x:以十六进制显示 EA 值(默认显示为文本)
#include <sys/xattr.h>   // listxattr, getxattr 等
#include <stdio.h>       // printf, fprintf
#include <stdlib.h>      // exit
#include <string.h>      // strlen
#include <unistd.h>      // getopt
#include <errno.h>       // errno
#include <stdint.h>      // uint8_t
// EA 名字列表和值各自的最大缓冲区大小
// 实际中应动态分配,这里为简单起见用固定大小
#define XATTR_SIZE 10000
static void usageError(const char *progName) {
    fprintf(stderr, "用法: %s [-x] file...\n", progName);
    fprintf(stderr, "  -x  以十六进制显示 EA 值\n");
    exit(EXIT_FAILURE);
}
int main(int argc, char *argv[]) {
    char list[XATTR_SIZE];   // 存放所有 EA 名字(连续 null 结尾字符串)
    char value[XATTR_SIZE];  // 存放单个 EA 的值
    int hexDisplay = 0;      // 是否以十六进制显示
    int opt;
    // 解析命令行选项
    while ((opt = getopt(argc, argv, "x")) != -1) {
        switch (opt) {
        case 'x':
            hexDisplay = 1;  // 开启十六进制显示
            break;
        case '?':
        default:
            usageError(argv[0]);
        }
    }
    // optind 之后必须至少有一个文件参数
    if (optind >= argc)
        usageError(argv[0]);
    // 遍历命令行上的每个文件
    for (int j = optind; j < argc; j++) {
        // 获取该文件所有 EA 名字,存入 list
        // list 的格式:name1\0name2\0name3\0...
        ssize_t listLen = listxattr(argv[j], list, XATTR_SIZE);
        if (listLen == -1) {
            fprintf(stderr, "listxattr(%s) 失败: %s\n", argv[j], strerror(errno));
            continue;  // 跳过这个文件,继续处理下一个
        }
        printf("%s:\n", argv[j]);
        // 遍历 list 中的每个 EA 名字
        // ns 是当前名字在 list 中的起始位置
        for (int ns = 0; ns < listLen; ns += strlen(&list[ns]) + 1) {
            // &list[ns] 指向当前 EA 名字字符串
            printf("    name=%s; ", &list[ns]);
            // 获取该 EA 的值
            ssize_t valueLen = getxattr(argv[j], &list[ns], value, XATTR_SIZE);
            if (valueLen == -1) {
                // 可能因为权限不够,或者该 EA 在两次调用之间被删除
                printf("(无法获取值: %s)", strerror(errno));
            } else if (!hexDisplay) {
                // 文本模式:直接打印(注意 EA 值不保证有 null 结尾,用 %.*s 限定长度)
                printf("value=%.*s", (int)valueLen, value);
            } else {
                // 十六进制模式:逐字节打印
                printf("value=");
                for (int k = 0; k < valueLen; k++) {
                    // 转成 unsigned 避免打印负值
                    printf("%02x ", (uint8_t)value[k]);
                }
            }
            printf("\n");
        }
        printf("\n");  // 文件之间空一行
    }
    return 0;
}

运行示例

# 设置一些 EA
setfattr -n user.x -v "The past is not dead." tfile
setfattr -n user.y -v "In fact, it's not even past." tfile
# 文本模式查看
./xattr_view tfile
# 输出:
# tfile:
#     name=user.x; value=The past is not dead.
#     name=user.y; value=In fact, it's not even past.
# 十六进制模式查看
./xattr_view -x tfile
# 输出:
# tfile:
#     name=user.x; value=54 68 65 20 70 61 73 74 ...
#     name=user.y; value=49 6e 20 66 61 63 74 ...

7. 练习:实现简易 setfattr

原书练习 16-1 要求实现一个简单的 setfattr 命令。以下是完整实现:

// my_setfattr.cpp
// 功能:创建或修改文件的 user 扩展属性(setfattr 的简化版)
// 编译:g++ -o my_setfattr my_setfattr.cpp
// 用法:./my_setfattr <文件> <EA名字> <EA值>
//   例:./my_setfattr myfile.txt user.comment "这是注释"
//       ./my_setfattr myfile.txt user.version "1.0"
#include <sys/xattr.h>   // setxattr, XATTR_CREATE, XATTR_REPLACE
#include <stdio.h>       // printf, fprintf, perror
#include <stdlib.h>      // exit
#include <string.h>      // strlen, strerror
#include <errno.h>       // errno
int main(int argc, char *argv[]) {
    // 检查参数数量:程序名 + 文件 + EA名 + EA值 = 4个
    if (argc != 4) {
        fprintf(stderr, "用法: %s <文件> <EA名字> <EA值>\n", argv[0]);
        fprintf(stderr, "  EA名字 格式为 namespace.name,如 user.comment\n");
        fprintf(stderr, "示例:\n");
        fprintf(stderr, "  %s myfile.txt user.comment \"这是备注\"\n", argv[0]);
        exit(EXIT_FAILURE);
    }
    const char *filepath = argv[1];  // 目标文件路径
    const char *ea_name  = argv[2];  // EA 名字,如 "user.comment"
    const char *ea_value = argv[3];  // EA 值字符串
    size_t      ea_size  = strlen(ea_value);  // EA 值的字节长度
    // 调用 setxattr:
    //   flags=0 表示默认行为:不存在则创建,已存在则覆盖
    //   若只想创建(不覆盖已有值),可用 XATTR_CREATE
    //   若只想修改(不新建),可用 XATTR_REPLACE
    if (setxattr(filepath, ea_name, ea_value, ea_size, 0) == -1) {
        // 打印具体错误信息
        fprintf(stderr, "setxattr 失败 [文件=%s, EA=%s]: %s\n",
                filepath, ea_name, strerror(errno));
        // 给出常见错误的提示
        if (errno == ENOTSUP) {
            fprintf(stderr, "提示:该文件系统不支持扩展属性,"
                            "或挂载时未加 user_xattr 选项\n");
        } else if (errno == EACCES) {
            fprintf(stderr, "提示:没有对文件 %s 的写权限\n", filepath);
        } else if (errno == ENOSPC || errno == EDQUOT) {
            fprintf(stderr, "提示:磁盘空间不足或超出配额\n");
        }
        exit(EXIT_FAILURE);
    }
    printf("成功设置扩展属性:\n");
    printf("  文件: %s\n", filepath);
    printf("  名字: %s\n", ea_name);
    printf("  值:   %s\n", ea_value);
    return 0;
}

运行示例

# 编译
g++ -o my_setfattr my_setfattr.cpp
# 创建测试文件
touch myfile.txt
# 设置 EA
./my_setfattr myfile.txt user.comment "这是一个测试文件"
# 输出:
# 成功设置扩展属性:
#   文件: myfile.txt
#   名字: user.comment
#   值:   这是一个测试文件
# 验证
getfattr -n user.comment myfile.txt
# # file: myfile.txt
# user.comment="这是一个测试文件"
# 覆盖已有值
./my_setfattr myfile.txt user.comment "修改后的备注"
getfattr -n user.comment myfile.txt
# user.comment="修改后的备注"

8. 总结

整体结构一览

文件 i-node
├── 普通属性(stat 可查)
│   ├── 类型、大小、权限、时间戳...
│   └── 数据块指针
└── 扩展属性(EA)
    ├── user.comment = "备注内容"
    ├── user.version = "1.0"
    ├── system.posix_acl_access = <二进制ACL数据>
    └── security.selinux = "unconfined_u:object_r:..."

系统调用速查

操作       通过路径(解引用)    通过路径(不解引用)   通过fd
设置EA     setxattr()          lsetxattr()           fsetxattr()
读取EA     getxattr()          lgetxattr()           fgetxattr()
删除EA     removexattr()       lremovexattr()        fremovexattr()
列出EA名   listxattr()         llistxattr()          flistxattr()

各命名空间权限要求

要操作 user.*    →  需要对文件有读(get)或写(set)权限
要操作 trusted.* →  需要 CAP_SYS_ADMIN 特权
要操作 system.*  →  由内核控制,用户程序一般不直接操作
要操作 security.*→  由安全模块(SELinux等)控制

关键注意点

  1. EA 名字区分大小写:user.Xuser.x 是两个不同的 EA
  2. EA 值是任意二进制数据,不限于文本字符串
  3. 空字符串值("")和不存在的 EA 是不同的状态
  4. listxattr 返回的名字列表中可能缺少无权访问的 EA
  5. 两步读取(先查大小再读值)之间 EA 可能被其他进程修改,需处理 ERANGE
  6. ext2/ext3/ext4 上单文件的所有 EA 总大小受一个磁盘块的严格限制

Linux 访问控制列表(ACL)详解(第17章)

本文基于《The Linux Programming Interface》第17章,力求用最通俗的语言解释所有概念。

目录

  1. 什么是 ACL
  2. ACL 条目的结构
  3. ACL 权限检查算法
  4. ACL 的文本表示格式
  5. ACL_MASK 条目与组类
  6. getfacl 和 setfacl 命令演示
  7. 默认 ACL 与文件创建
  8. ACL 实现限制
  9. ACL API 编程接口
  10. 完整示例程序
  11. 练习:查询指定用户/组的 ACL 权限
  12. 总结

1. 什么是 ACL

传统权限模型的局限

Linux 传统文件权限只有三组:owner(文件主)、group(文件组)、other(其他人),每组各有 rwx 三个权限位。
这种模型在很多场景下不够用。举个例子:

你有一个项目文件,想让 alice 能读写,让 bob 只能读,让 charlie 完全无权访问。
传统权限做不到——它只能控制"文件主"、“某个组”、“其他所有人”,无法精确到某个具体用户。
ACL(Access Control List,访问控制列表) 就是解决这个问题的机制,它允许对任意数量的用户和组分别设定权限。
Linux 从内核 2.6 开始支持 ACL。

ACL 与扩展属性的关系

ACL 在底层是用**系统扩展属性(system EA)**实现的:

  • 访问 ACL 存储在扩展属性 system.posix_acl_access
  • 默认 ACL 存储在扩展属性 system.posix_acl_default
    要在 ext2/ext3/ext4/Reiserfs 上使用 ACL,需要挂载时加选项:
mount -o acl /dev/sda1 /mnt/data

2. ACL 条目的结构

每个 ACL 条目由三部分组成

ACL 条目
┌──────────────┬─────────────────┬──────────────┐
│  标签类型    │  标签限定符     │  权限集      │
│ (tag type)   │ (tag qualifier) │ (permissions)│
│              │                 │              │
│ ACL_USER_OBJ │  (无)         │  rwx         │
│ ACL_USER     │  用户ID (uid)   │  r-x         │
│ ACL_GROUP_OBJ│  (无)         │  rw-         │
│ ACL_GROUP    │  组ID  (gid)    │  r--         │
│ ACL_MASK     │  (无)         │  rw-         │
│ ACL_OTHER    │  (无)         │  r--         │
└──────────────┴─────────────────┴──────────────┘

六种标签类型详解


标签类型 说明 数量限制 对应传统权限
ACL_USER_OBJ 文件主的权限 恰好 1 个 owner 权限位
ACL_USER 指定用户的权限(由 uid 指定) 0 个或多个 (无对应)
ACL_GROUP_OBJ 文件所属组的权限 恰好 1 个 group 权限位(有 MASK 时除外)
ACL_GROUP 指定组的权限(由 gid 指定) 0 个或多个 (无对应)
ACL_MASK 组类条目权限的上限 最多 1 个 group 权限位(有 MASK 时显示 MASK)
ACL_OTHER 不匹配任何条目的用户的权限 恰好 1 个 other 权限位

最小 ACL vs 扩展 ACL

最小 ACL:只包含 ACL_USER_OBJACL_GROUP_OBJACL_OTHER 三个条目,语义上与传统权限完全等价。新文件默认创建最小 ACL,权限信息直接存在传统权限位里,不需要单独的扩展属性。
扩展 ACL:在最小 ACL 基础上,还包含 ACL_USERACL_GROUPACL_MASK 条目。ls -l 会在权限位后面显示 + 号提示。

3. ACL 权限检查算法

检查顺序(短路求值,匹配到即停止)

进程访问文件

是特权进程?
CAP_SYS_ADMIN

授予所有权限
执行权须至少一条目有x

进程有效UID
== 文件主UID?

使用 ACL_USER_OBJ 权限

有效UID 匹配
某个 ACL_USER 条目?

使用该 ACL_USER 权限
AND ACL_MASK

进程任意GID匹配
文件组或某个ACL_GROUP?

找到匹配且权限满足?

使用该条目权限
AND ACL_MASK

拒绝访问

使用 ACL_OTHER 权限

步骤详细说明

步骤1:特权进程(CAP_SYS_ADMIN)直接获得所有权限(执行权例外:至少要有一条 ACL 条目授予 x)。
步骤2:进程有效 UID 与文件主 UID 匹配 → 使用 ACL_USER_OBJ 权限,不经过 MASK 过滤
步骤3:进程有效 UID 匹配某个 ACL_USER 条目的 uid → 权限 = 该条目权限 AND ACL_MASK
步骤4:进程任意 GID(有效GID 或补充GID)匹配文件组或某个 ACL_GROUP 条目:

  • 逐一检查所有匹配的组条目
  • 找到一个能满足所请求权限的条目 → 权限 = 该条目权限 AND ACL_MASK
  • 没有任何条目能满足 → 拒绝访问
    步骤5:以上都不匹配 → 使用 ACL_OTHER 权限,不经过 MASK 过滤

具体例子

以图 17-1 所示的 ACL 为例(文件组ID=100):

ACL_USER_OBJ   rwx
ACL_USER  1007 r--
ACL_USER  1010 rwx
ACL_GROUP_OBJ  rwx   (文件组ID=100)
ACL_GROUP  102 r--
ACL_GROUP  103 -w-
ACL_MASK       rw-
ACL_OTHER      r--

例子A:进程 GID=100,调用 access(file, R_OK)

  • 步骤4:GID=100 匹配 ACL_GROUP_OBJ,该条目有 r 权限
  • 实际权限 = rwx AND rw- = rw-,包含 r,成功
    例子B:进程 GID=100,调用 access(file, R_OK|W_OK|X_OK)
  • 步骤4:GID=100 匹配 ACL_GROUP_OBJ,该条目有 rwx
  • 实际权限 = rwx AND rw- = rw-,没有 x失败(EACCES)
    实际权限 = 条目权限 ∩ ACL_MASK \text{实际权限} = \text{条目权限} \cap \text{ACL\_MASK} 实际权限=条目权限ACL_MASK
    例子C:进程 GID=102 且补充GID=103,调用 access(file, R_OK)
  • 步骤4:匹配 ACL_GROUP 102(有r)→ 成功(r AND rw- = r-,包含r)
    例子D:同上进程,调用 access(file, R_OK|W_OK)
  • 步骤4:检查 GID=102 对应条目(只有r),不能同时满足 R+W
  • 检查 GID=103 对应条目(只有w),不能同时满足 R+W
  • 没有一个条目能同时满足 R 和 W,失败

4. ACL 的文本表示格式

格式规则

每个条目的文本格式:

tag-type:[tag-qualifier]:permissions

tag-type 文本 是否有 qualifier 对应标签类型 含义
uuser ACL_USER_OBJ 文件主
uuser 有(uid或用户名) ACL_USER 指定用户
ggroup ACL_GROUP_OBJ 文件组
ggroup 有(gid或组名) ACL_GROUP 指定组
mmask ACL_MASK 权限上限掩码
oother ACL_OTHER 其他用户

两种文本格式

长文本格式:每行一个条目,可以有 # 注释,getfacl 命令输出这种格式:

# file: tfile
# owner: alice
# group: staff
user::rw-
user:paulh:r-x
group::r--
mask::r-x
other::---

短文本格式:逗号分隔,适合命令行:

# 等价于传统权限 0650
u::rw-,g::r-x,o::--
# 包含具体用户和组
u::rw,u:paulh:rw,u:annabel:rw,g::r,g:teach:rw,m::rwx,o::

5. ACL_MASK 条目与组类

组类(Group Class)是什么

组类包含以下所有条目:ACL_USERACL_GROUP_OBJACL_GROUP
ACL_MASK 是组类所有条目权限的上限——即使某个条目写了 rwx,只要 MASK 是 r–,实际最多只能有 r。

为什么需要 ACL_MASK

问题背景:很多老程序(ACL 无感知程序)会调用 chmod() 修改文件权限。如果没有 MASK 机制,chmod(file, 0700) 本意是"只有文件主有权限",但已有的 ACL_USER 和 ACL_GROUP 条目仍然有效,语义就被破坏了。
MASK 的解决方案
当 ACL 有 ACL_MASK 条目时:

  • chmod() 修改权限位 → 实际修改的是 ACL_MASK,不动 ACL_GROUP_OBJ
  • stat() 返回的权限位 → 显示的是 ACL_MASK 的值,不是 ACL_GROUP_OBJ
    这样 chmod(file, 0700) 会把 MASK 设为 ---,所有组类条目实际权限都变成 ---,语义正确,而且 ACL 里具体条目的值还保留着,以后可以恢复。

MASK 的运作示意

ACL_USER  paulh: r-x         (条目设置的权限)
ACL_MASK       : rw-         (掩码)
实际权限       : r-- (r-x AND rw- = r--)
getfacl 显示:
user:paulh:r-x          #effective:r--

注意 #effective:r-- 注释:它告诉你经过 MASK 过滤后实际生效的权限。
实际生效权限 = 条目权限 & ACL_MASK \text{实际生效权限} = \text{条目权限} \mathbin{\&} \text{ACL\_MASK} 实际生效权限=条目权限&ACL_MASK

MASK 自动计算

setfacl 在你添加/删除组类条目时会自动调整 MASK,使其等于所有组类条目权限的并集
ACL_MASK = ACL_USER 1 ∪ ACL_USER 2 ∪ ⋯ ∪ ACL_GROUP_OBJ ∪ ACL_GROUP 1 ∪ ⋯ \text{ACL\_MASK} = \text{ACL\_USER}_1 \cup \text{ACL\_USER}_2 \cup \cdots \cup \text{ACL\_GROUP\_OBJ} \cup \text{ACL\_GROUP}_1 \cup \cdots ACL_MASK=ACL_USER1ACL_USER2ACL_GROUP_OBJACL_GROUP1
若不想让 setfacl 自动调整 MASK,加 -n 选项。

6. getfacl 和 setfacl 命令演示

完整 shell 会话演示

# 设置 umask 为已知值,创建测试文件
umask 022
touch tfile
# 查看初始 ACL(最小 ACL)
getfacl tfile
# # file: tfile
# # owner: mtk
# # group: users
# user::rw-
# group::r--
# other::r--
# 用 chmod 修改权限,ACL 同步更新
chmod u=rwx,g=rx,o=x tfile
getfacl --omit-header tfile
# user::rwx
# group::r-x
# other::--x
# 添加 ACL_USER 条目(用户 paulh)和 ACL_GROUP 条目(组 teach)
sudo useradd paulh
sudo useradd teach
setfacl -m u:paulh:rx,g:teach:x tfile
getfacl --omit-header tfile
# user::rwx
# user:paulh:r-x         ← ACL_USER 条目
# group::r-x
# group:teach:--x        ← ACL_GROUP 条目
# mask::r-x              ← setfacl 自动创建的 ACL_MASK
# other::--x
# ls -l 显示 + 号,表示有扩展 ACL
ls -l tfile
# -rwxr-x--x+ 1 mtk users 0 Dec 3 15:42 tfile
# 把 MASK 改为只有 x
setfacl -m m::x tfile
getfacl --omit-header tfile
# user::rwx
# user:paulh:r-x          #effective:--x  ← 经MASK过滤后只剩x
# group::r-x              #effective:--x  ← 同上
# group:teach:--x
# mask::--x
# other::--x
# ls -l 显示的组权限位反映 MASK(--x),不是 ACL_GROUP_OBJ(r-x)
ls -l tfile
# -rwx--x--x+ 1 mtk users 0 Dec 3 15:42 tfile
# 删除特定条目(不需要指定权限)
setfacl -x u:paulh,g:teach tfile
getfacl --omit-header tfile
# user::rwx
# group::r-x
# mask::r-x              ← setfacl 自动调整为 ACL_GROUP_OBJ 的权限
# other::--x
# 删除所有扩展条目,回到最小 ACL
setfacl -b tfile

7. 默认 ACL 与文件创建

默认 ACL 是什么

默认 ACL 只能设置在目录上,它不影响该目录本身的访问权限,而是决定在该目录下新建的文件和子目录的 ACL。
存储在扩展属性 system.posix_acl_default 中。

继承规则

父目录有默认 ACL
├── 新建子目录 → 继承父目录的默认ACL,作为子目录的默认ACL(向下传播)
└── 新建文件/子目录 → 继承父目录的默认ACL,作为新文件的访问ACL
      └── 访问ACL中对应传统权限的条目,还要与 open()/mkdir() 的 mode 参数做 AND
          影响的条目:ACL_USER_OBJ、ACL_MASK(或ACL_GROUP_OBJ)、ACL_OTHER

当目录有默认 ACL 时,进程的 umask 不参与新建文件权限的计算。

演示:默认 ACL 的继承

mkdir sub
# 设置 sub 目录的默认 ACL
setfacl -d -m u::rwx,u:paulh:rx,g::rx,g:teach:rwx,o::- sub
getfacl -d --omit-header sub
# user::rwx
# user:paulh:r-x
# group::r-x
# group:teach:rwx
# mask::rwx              ← setfacl 自动生成
# other::---

现在用 mode=rwx--x--x(即 0711)在 sub 下创建文件:

open("sub/tfile", O_RDWR | O_CREAT, S_IRWXU | S_IXGRP | S_IXOTH);

新文件的访问 ACL:

默认ACL条目          AND    mode(0711)     =  实际ACL
user::rwx            AND    rwx            =  rwx   ← ACL_USER_OBJ
user:paulh:r-x       (不参与AND)         =  r-x   ← ACL_USER
group::r-x           AND    --x            =  --x   ← ACL_GROUP_OBJ(经MASK)
group:teach:rwx      (不参与AND)         =  rwx   ← ACL_GROUP
mask::rwx            AND    --x            =  --x   ← ACL_MASK 与 mode 的 group 位 AND
other::---           AND    --x            =  ---   ← ACL_OTHER
getfacl --omit-header sub/tfile
# user::rwx
# user:paulh:r-x          #effective:--x
# group::r-x              #effective:--x
# group:teach:rwx         #effective:--x
# mask::--x
# other::---

删除目录的默认 ACL:

setfacl -k sub

父目录无默认 ACL 时

新文件遵循传统规则:实际权限 = mode AND NOT(umask),结果是最小 ACL。

8. ACL 实现限制

各文件系统的 ACL 条目数限制


文件系统 ACL 条目数上限 原因
ext2/ext3/ext4 约 500 个(4KB块时) 所有EA名+值必须放入一个磁盘块,每个ACL条目占8字节
XFS 25 个 硬性限制
Reiserfs/JFS 8191 个 VFS对EA值大小限制为64KB,每条8字节
Btrfs 约 500 个 开发中,可能变化

ext2/ext3/ext4 的计算:
最大条目数 ≈ 块大小(4096字节) − EA名字等开销 8  字节/条目 ≈ 500 \text{最大条目数} \approx \frac{\text{块大小(4096字节)} - \text{EA名字等开销}}{8 \text{ 字节/条目}} \approx 500 最大条目数8 字节/条目块大小(4096字节)EA名字等开销500

为什么要控制 ACL 条目数

  1. 管理复杂度:条目太多,维护时容易出错
  2. 性能:检查权限时需要线性扫描所有条目,条目越多越慢
    T c h e c k = O ( n ) , n = ACL条目数 T_{check} = O(n), \quad n = \text{ACL条目数} Tcheck=O(n),n=ACL条目数
    最佳实践:在系统 group 文件中定义合适的用户组,在 ACL 中使用组条目而不是大量用户条目,这样可以把条目数控制在合理范围。

9. ACL API 编程接口

数据结构层次

acl_t(整个ACL的句柄)
└── acl_entry_t(单个条目的句柄) × N
    ├── acl_tag_t(标签类型:整数)
    │     ACL_USER_OBJ / ACL_USER / ACL_GROUP_OBJ
    │     ACL_GROUP / ACL_MASK / ACL_OTHER
    ├── void*(标签限定符:uid_t* 或 gid_t*)
    └── acl_permset_t(权限集句柄)
          ACL_READ / ACL_WRITE / ACL_EXECUTE

函数分组速查

磁盘 <-> 内存
  acl_get_file()      读取文件ACL到内存
  acl_set_file()      把内存ACL写回磁盘
文本 <-> 内存
  acl_from_text()     文本格式 → 内存ACL
  acl_to_text()       内存ACL → 文本格式
遍历条目
  acl_get_entry()     获取第一个/下一个条目的句柄
条目属性
  acl_get_tag_type()  获取标签类型
  acl_set_tag_type()  设置标签类型
  acl_get_qualifier() 获取标签限定符(uid/gid指针)
  acl_set_qualifier() 设置标签限定符
  acl_get_permset()   获取权限集句柄
  acl_set_permset()   设置权限集
权限集操作
  acl_get_perm()      检查某权限是否开启
  acl_add_perm()      添加权限
  acl_delete_perm()   删除权限
  acl_clear_perms()   清空所有权限
条目增删
  acl_create_entry()  新增条目
  acl_delete_entry()  删除条目
其他工具
  acl_calc_mask()     重新计算并设置 ACL_MASK
  acl_valid()         验证 ACL 是否合法
  acl_free()          释放内存(必须调用!)
  acl_init()          创建空ACL
  acl_dup()           复制ACL
  acl_delete_def_file() 删除目录的默认ACL

编译要求

程序需要包含头文件并链接 libacl:

g++ -o my_program my_program.cpp -lacl

10. 完整示例程序

10.1 显示文件 ACL(getfacl 的简化版)

// acl_view.cpp
// 功能:显示文件的访问ACL或目录的默认ACL
// 编译:g++ -o acl_view acl_view.cpp -lacl
// 用法:./acl_view [-d] filename
//   -d  显示默认ACL(仅目录有默认ACL)
#include <sys/acl.h>      // acl_t, acl_entry_t, acl_get_file 等核心ACL类型和函数
#include <acl/libacl.h>   // acl_get_perm()(Linux扩展,不在POSIX.1e草案中)
#include <sys/types.h>    // uid_t, gid_t
#include <pwd.h>          // getpwuid(uid转用户名)
#include <grp.h>          // getgrgid(gid转组名)
#include <stdio.h>        // printf, fprintf
#include <stdlib.h>       // exit
#include <unistd.h>       // getopt
#include <string.h>       // strerror
#include <errno.h>        // errno
// 工具函数:把 uid 转换为用户名字符串,找不到则返回 NULL
static const char* userNameFromId(uid_t uid) {
    struct passwd *pw = getpwuid(uid);
    return pw ? pw->pw_name : NULL;
}
// 工具函数:把 gid 转换为组名字符串,找不到则返回 NULL
static const char* groupNameFromId(gid_t gid) {
    struct group *gr = getgrgid(gid);
    return gr ? gr->gr_name : NULL;
}
static void usageError(const char *progName) {
    fprintf(stderr, "用法: %s [-d] filename\n", progName);
    fprintf(stderr, "  -d  显示默认ACL(仅适用于目录)\n");
    exit(EXIT_FAILURE);
}
int main(int argc, char *argv[]) {
    // type 决定读取访问ACL还是默认ACL
    acl_type_t type = ACL_TYPE_ACCESS;  // 默认:访问ACL
    int opt;
    while ((opt = getopt(argc, argv, "d")) != -1) {
        switch (opt) {
        case 'd':
            type = ACL_TYPE_DEFAULT;  // 切换为默认ACL
            break;
        case '?':
        default:
            usageError(argv[0]);
        }
    }
    // 必须恰好有一个文件参数
    if (optind + 1 != argc)
        usageError(argv[0]);
    const char *filepath = argv[optind];
    // 从磁盘读取ACL到内存,返回 acl_t 句柄
    acl_t acl = acl_get_file(filepath, type);
    if (acl == NULL) {
        fprintf(stderr, "acl_get_file(%s) 失败: %s\n", filepath, strerror(errno));
        exit(EXIT_FAILURE);
    }
    printf("文件: %s\n", filepath);
    printf("%-12s %-10s %s\n", "标签类型", "限定符", "权限");
    printf("%-12s %-10s %s\n", "--------", "------", "----");
    // 遍历所有ACL条目
    // 第一次用 ACL_FIRST_ENTRY,之后用 ACL_NEXT_ENTRY
    acl_entry_t entry;
    for (int entryId = ACL_FIRST_ENTRY; ; entryId = ACL_NEXT_ENTRY) {
        // acl_get_entry 返回 1=成功获取,0=没有更多条目,-1=出错
        int ret = acl_get_entry(acl, entryId, &entry);
        if (ret == 0) break;    // 没有更多条目,正常结束循环
        if (ret == -1) {
            fprintf(stderr, "acl_get_entry 失败: %s\n", strerror(errno));
            break;
        }
        // 获取并打印标签类型
        acl_tag_t tag;
        if (acl_get_tag_type(entry, &tag) == -1) {
            fprintf(stderr, "acl_get_tag_type 失败: %s\n", strerror(errno));
            continue;
        }
        // 根据标签类型打印名称(左对齐,占12字符)
        const char *tagName;
        switch (tag) {
        case ACL_USER_OBJ:  tagName = "user_obj";  break;
        case ACL_USER:      tagName = "user";       break;
        case ACL_GROUP_OBJ: tagName = "group_obj";  break;
        case ACL_GROUP:     tagName = "group";      break;
        case ACL_MASK:      tagName = "mask";       break;
        case ACL_OTHER:     tagName = "other";      break;
        default:            tagName = "???";        break;
        }
        printf("%-12s ", tagName);
        // 打印标签限定符(仅 ACL_USER 和 ACL_GROUP 有)
        if (tag == ACL_USER) {
            // 获取 uid_t 指针(用完后必须 acl_free)
            uid_t *uidp = (uid_t *)acl_get_qualifier(entry);
            if (uidp == NULL) {
                fprintf(stderr, "acl_get_qualifier 失败: %s\n", strerror(errno));
                printf("%-10s ", "?");
            } else {
                // 尝试把 uid 转换为用户名
                const char *name = userNameFromId(*uidp);
                if (name)
                    printf("%-10s ", name);
                else
                    printf("%-10u ", (unsigned)*uidp);
                acl_free(uidp);   // 释放 acl_get_qualifier 分配的内存
            }
        } else if (tag == ACL_GROUP) {
            // 获取 gid_t 指针
            gid_t *gidp = (gid_t *)acl_get_qualifier(entry);
            if (gidp == NULL) {
                fprintf(stderr, "acl_get_qualifier 失败: %s\n", strerror(errno));
                printf("%-10s ", "?");
            } else {
                const char *name = groupNameFromId(*gidp);
                if (name)
                    printf("%-10s ", name);
                else
                    printf("%-10u ", (unsigned)*gidp);
                acl_free(gidp);   // 释放内存
            }
        } else {
            printf("%-10s ", "");  // 无限定符,打印空白占位
        }
        // 获取权限集句柄
        acl_permset_t permset;
        if (acl_get_permset(entry, &permset) == -1) {
            fprintf(stderr, "acl_get_permset 失败: %s\n", strerror(errno));
            printf("???\n");
            continue;
        }
        // 逐位检查并打印 r/w/x
        // acl_get_perm 返回 1=该权限已设置,0=未设置,-1=出错
        int r = acl_get_perm(permset, ACL_READ);
        int w = acl_get_perm(permset, ACL_WRITE);
        int x = acl_get_perm(permset, ACL_EXECUTE);
        printf("%c%c%c\n",
               (r == 1) ? 'r' : '-',
               (w == 1) ? 'w' : '-',
               (x == 1) ? 'x' : '-');
    }
    // 释放 acl_get_file 分配的内存(很重要,否则内存泄漏)
    acl_free(acl);
    return 0;
}

运行示例

touch tfile
setfacl -m 'u:annie:r,u:paulh:rw,g:teach:r' tfile
./acl_view tfile
# 文件: tfile
# 标签类型     限定符     权限
# --------     ------     ----
# user_obj               rw-
# user         annie      r--
# user         paulh      rw-
# group_obj              r--
# group        teach      r--
# mask                   rw-
# other                  r--

11. 练习:查询指定用户/组的 ACL 权限

原书练习 17-1:编写程序,显示 ACL 中特定用户或组对应条目的权限,若该条目属于组类,还要显示经 MASK 过滤后的实际权限。

// acl_query.cpp
// 功能:查询文件ACL中指定用户或组的权限
// 编译:g++ -o acl_query acl_query.cpp -lacl
// 用法:./acl_query <u|g> <用户名/uid 或 组名/gid> <文件>
//   u  查询用户(ACL_USER_OBJ 或 ACL_USER)
//   g  查询组(ACL_GROUP_OBJ 或 ACL_GROUP)
// 示例:
//   ./acl_query u paulh tfile       查询用户 paulh 的权限
//   ./acl_query g teach tfile       查询组 teach 的权限
//   ./acl_query u 1007 tfile        也可以用数字ID
#include <sys/acl.h>      // ACL相关类型和函数
#include <acl/libacl.h>   // acl_get_perm (Linux扩展)
#include <sys/types.h>    // uid_t, gid_t
#include <pwd.h>          // getpwnam, getpwuid
#include <grp.h>          // getgrnam, getgrgid
#include <stdio.h>        // printf, fprintf
#include <stdlib.h>       // exit, strtoul
#include <string.h>       // strerror, strcmp
#include <errno.h>        // errno
// 把字符串转换为 uid_t(支持用户名和数字两种形式)
// 成功返回 uid,失败返回 (uid_t)-1
static uid_t parseUid(const char *str) {
    // 先尝试按用户名查找
    struct passwd *pw = getpwnam(str);
    if (pw) return pw->pw_uid;
    // 再尝试按数字解析
    char *endp;
    unsigned long id = strtoul(str, &endp, 10);
    if (*endp == '\0') return (uid_t)id;
    return (uid_t)-1;
}
// 把字符串转换为 gid_t(支持组名和数字两种形式)
static gid_t parseGid(const char *str) {
    struct group *gr = getgrnam(str);
    if (gr) return gr->gr_gid;
    char *endp;
    unsigned long id = strtoul(str, &endp, 10);
    if (*endp == '\0') return (gid_t)id;
    return (gid_t)-1;
}
// 把权限集打印为 rwx 格式(如 "rw-")
static void printPermset(acl_permset_t permset) {
    printf("%c%c%c",
           acl_get_perm(permset, ACL_READ)    == 1 ? 'r' : '-',
           acl_get_perm(permset, ACL_WRITE)   == 1 ? 'w' : '-',
           acl_get_perm(permset, ACL_EXECUTE) == 1 ? 'x' : '-');
}
int main(int argc, char *argv[]) {
    if (argc != 4 ||
        (argv[1][0] != 'u' && argv[1][0] != 'g') ||
        argv[1][1] != '\0') {
        fprintf(stderr, "用法: %s <u|g> <用户名/uid 或 组名/gid> <文件>\n", argv[0]);
        fprintf(stderr, "  u  查询用户权限\n");
        fprintf(stderr, "  g  查询组权限\n");
        exit(EXIT_FAILURE);
    }
    int queryUser = (argv[1][0] == 'u');  // true=查用户,false=查组
    const char *idStr    = argv[2];
    const char *filepath = argv[3];
    // 解析用户/组标识
    uid_t targetUid = (uid_t)-1;
    gid_t targetGid = (gid_t)-1;
    if (queryUser) {
        targetUid = parseUid(idStr);
        if (targetUid == (uid_t)-1) {
            fprintf(stderr, "无法识别用户: %s\n", idStr);
            exit(EXIT_FAILURE);
        }
    } else {
        targetGid = parseGid(idStr);
        if (targetGid == (gid_t)-1) {
            fprintf(stderr, "无法识别组: %s\n", idStr);
            exit(EXIT_FAILURE);
        }
    }
    // 读取文件的访问ACL
    acl_t acl = acl_get_file(filepath, ACL_TYPE_ACCESS);
    if (acl == NULL) {
        fprintf(stderr, "acl_get_file(%s) 失败: %s\n", filepath, strerror(errno));
        exit(EXIT_FAILURE);
    }
    // 同时找到目标条目和 MASK 条目(组类需要 MASK)
    acl_permset_t targetPermset = NULL;
    acl_permset_t maskPermset   = NULL;
    int found = 0;
    int inGroupClass = 0;  // 目标条目是否属于组类
    // 获取文件主uid和文件组gid,用于匹配 ACL_USER_OBJ / ACL_GROUP_OBJ
    struct stat st;
    // 我们需要文件的uid/gid,用 acl 句柄暂时无法直接拿到,用stat获取
    // 注意:这里需要 #include <sys/stat.h>
    // 为简化,若查找 USER_OBJ/GROUP_OBJ,直接在遍历中处理
    acl_entry_t entry;
    for (int entryId = ACL_FIRST_ENTRY; ; entryId = ACL_NEXT_ENTRY) {
        int ret = acl_get_entry(acl, entryId, &entry);
        if (ret != 1) break;  // 0=没有更多,-1=出错,都退出
        acl_tag_t tag;
        acl_get_tag_type(entry, &tag);
        // 收集 MASK 条目的权限集(供后续使用)
        if (tag == ACL_MASK) {
            acl_permset_t ps;
            acl_get_permset(entry, &ps);
            // 复制权限集的内容:用一个临时变量记录各位
            // 实际上 acl_permset_t 是句柄,这里直接记录指针(在acl_free前有效)
            maskPermset = ps;
            continue;
        }
        // 检查当前条目是否是我们要查的
        int match = 0;
        if (queryUser) {
            if (tag == ACL_USER) {
                uid_t *uidp = (uid_t *)acl_get_qualifier(entry);
                if (uidp && *uidp == targetUid) {
                    match = 1;
                    inGroupClass = 1;  // ACL_USER 属于组类
                }
                if (uidp) acl_free(uidp);
            }
            // ACL_USER_OBJ 对应文件主,通过stat获取uid比较
            // 此处简化:不处理 USER_OBJ(通常你查的是具体用户名,不是文件主本身)
        } else {
            if (tag == ACL_GROUP) {
                gid_t *gidp = (gid_t *)acl_get_qualifier(entry);
                if (gidp && *gidp == targetGid) {
                    match = 1;
                    inGroupClass = 1;  // ACL_GROUP 属于组类
                }
                if (gidp) acl_free(gidp);
            } else if (tag == ACL_GROUP_OBJ) {
                // ACL_GROUP_OBJ 也属于组类,但没有限定符
                // 要匹配的话需要知道文件的gid,这里跳过(让用户直接查组名)
            }
        }
        if (match) {
            found = 1;
            acl_permset_t ps;
            acl_get_permset(entry, &ps);
            targetPermset = ps;
            // 不 break,继续找 MASK 条目
        }
    }
    if (!found) {
        printf("在文件 %s 的ACL中未找到 %s '%s' 的条目\n",
               filepath, queryUser ? "用户" : "组", idStr);
        acl_free(acl);
        return 1;
    }
    // 打印原始权限
    printf("文件: %s\n", filepath);
    printf("%s '%s' 的ACL条目权限: ", queryUser ? "用户" : "组", idStr);
    printPermset(targetPermset);
    printf("\n");
    // 如果属于组类且存在MASK,打印过滤后的实际权限
    if (inGroupClass && maskPermset != NULL) {
        // 计算实际权限 = 条目权限 AND MASK
        // acl_get_perm 返回 0 或 1
        int r = (acl_get_perm(targetPermset, ACL_READ)    == 1)
             && (acl_get_perm(maskPermset,   ACL_READ)    == 1);
        int w = (acl_get_perm(targetPermset, ACL_WRITE)   == 1)
             && (acl_get_perm(maskPermset,   ACL_WRITE)   == 1);
        int x = (acl_get_perm(targetPermset, ACL_EXECUTE) == 1)
             && (acl_get_perm(maskPermset,   ACL_EXECUTE) == 1);
        printf("MASK 权限:                  ");
        printPermset(maskPermset);
        printf("\n");
        printf("经MASK过滤的实际生效权限:   %c%c%c\n",
               r ? 'r' : '-',
               w ? 'w' : '-',
               x ? 'x' : '-');
    } else if (!inGroupClass) {
        printf("(该条目不属于组类,不受MASK影响)\n");
    }
    acl_free(acl);
    return 0;
}

运行示例

# 准备测试
touch tfile
setfacl -m u:paulh:rw,g:teach:rx,m::r tfile
# 查询用户 paulh
./acl_query u paulh tfile
# 文件: tfile
# 用户 'paulh' 的ACL条目权限: rw-
# MASK 权限:                  r--
# 经MASK过滤的实际生效权限:   r--
# 查询组 teach
./acl_query g teach tfile
# 文件: tfile
# 组 'teach' 的ACL条目权限: r-x
# MASK 权限:                  r--
# 经MASK过滤的实际生效权限:   r--

12. 总结

ACL 整体结构

文件
├── 访问ACL (system.posix_acl_access)
│   ├── ACL_USER_OBJ   : rwx      ← 文件主
│   ├── ACL_USER  alice: r-x      ← 具体用户(组类)
│   ├── ACL_USER  bob  : rw-      ← 具体用户(组类)
│   ├── ACL_GROUP_OBJ  : r-x      ← 文件组(组类)
│   ├── ACL_GROUP staff: rwx      ← 具体组(组类)
│   ├── ACL_MASK       : rw-      ← 组类上限(有USER/GROUP时必须有)
│   └── ACL_OTHER      : ---      ← 其他所有人
└── 默认ACL (system.posix_acl_default)  ← 仅目录有
    └── (同上结构,决定子文件的继承ACL)

权限检查优先级(高到低)

特权进程  >  文件主(USER_OBJ)  >  具体用户(USER & MASK)  >
文件组/具体组(GROUP_OBJ/GROUP & MASK)  >  其他人(OTHER)

关键点速记

  1. ACL_MASK 是组类(USER + GROUP_OBJ + GROUP)的权限上限,chmod 改的是 MASK 不是 GROUP_OBJ
  2. getfacl#effective: 注释显示经 MASK 过滤后的实际权限
  3. ls -l 后面的 + 号表示有扩展ACL
  4. 默认ACL只在目录上设置,决定子文件的初始访问ACL,umask 在此场景下失效
  5. 所有用 ACL API 分配的内存(句柄)都要调用 acl_free() 释放
  6. 编译时必须加 -lacl

Linux 目录与链接详解(第18章)

本文基于《The Linux Programming Interface》第18章,力求用最通俗的语言解释所有概念。

目录

  1. 目录与硬链接
  2. 符号链接(软链接)
  3. 创建和删除硬链接
  4. 重命名文件:rename()
  5. 创建和读取符号链接
  6. 创建和删除目录
  7. 删除文件或目录:remove()
  8. 读取目录内容
  9. 目录树遍历:nftw()
  10. 进程的当前工作目录
  11. 基于目录文件描述符的操作
  12. 更改进程的根目录:chroot()
  13. 解析路径名:realpath()
  14. 拆分路径名:dirname() 和 basename()
  15. 完整示例程序
  16. 总结

1. 目录与硬链接

目录的本质

目录在文件系统中的存储方式和普通文件类似,但有两点不同:

  • i-node 里的文件类型字段标记为"目录"
  • 目录的内容是一张文件名 → i-node 编号的映射表
    可以把目录想象成一本电话本:
  • 电话本条目 = 目录条目(文件名 + i-node号)
  • 电话号码 = i-node 编号
  • 实际联系人信息 = i-node(存放文件的所有属性)
目录文件(/etc 目录的内容)
┌─────────────────┬──────────────┐
│    文件名        │  i-node 编号  │
├─────────────────┼──────────────┤
│    .            │      7        │  ← 指向自身
│    ..           │      2        │  ← 指向父目录(/)
│    passwd       │    6422       │
│    group        │     282       │
│    hosts        │     ...       │
└─────────────────┴──────────────┘

i-node 编号从 1 开始的原因

  • 0:目录条目中 i-node 号为 0 表示该条目未被使用(空槽)
  • 1:记录文件系统的坏块信息
  • 2:根目录 / 始终在 i-node 2,内核解析路径时从这里出发

重要结论:文件名不存储在 i-node 里

i-node 里只有文件的元数据(大小、权限、时间戳、数据块指针等),没有文件名。文件名只存在于目录条目中。
这带来一个重要推论:同一个 i-node 可以有多个文件名(目录条目)指向它,这就是"硬链接"。

硬链接演示

# 创建文件 abc,写入内容
echo -n 'It is good to collect things,' > abc
# 查看 i-node 编号(第一列)和链接计数(第三列)
ls -li abc
# 122232 -rw-r--r-- 1 mtk users 29 Jun 15 17:07 abc
#          链接计数=1^
# 创建硬链接 xyz,指向同一个 i-node
ln abc xyz
# 通过 xyz 追加内容
echo ' but it is better to go on walks.' >> xyz
# 通过 abc 读取——能看到通过 xyz 写入的内容!
cat abc
# It is good to collect things, but it is better to go on walks.
# 两个文件名,i-node 编号相同,链接计数变为 2
ls -li abc xyz
# 122232 -rw-r--r-- 2 mtk users 63 Jun 15 17:07 abc
# 122232 -rw-r--r-- 2 mtk users 63 Jun 15 17:07 xyz
#          链接计数=2^
i-node 表
┌──────────┐
│  122232  │ ← 链接计数 = 2
│ type=file│
│ size=63  │
│ data...  │
└──────────┘
     △          △
     │          │
  abc(目录条目)  xyz(目录条目)

删除 abc 后,xyz 和文件继续存在,链接计数降为 1。只有当链接计数降为 0,i-node 和数据块才真正被回收。
文件真正删除 ⇔ 链接计数 = 0 \text{文件真正删除} \Leftrightarrow \text{链接计数} = 0 文件真正删除链接计数=0

硬链接的两个限制


限制 原因 解决方案
必须在同一文件系统内 i-node 编号只在同一文件系统内唯一 使用符号链接
不能链接到目录 防止出现循环引用,使目录树成为有向无环图 使用符号链接

2. 符号链接(软链接)

符号链接是什么

符号链接是一种特殊文件类型,它的"数据内容"就是另一个文件的路径字符串。

i-node 表
┌──────────┐           ┌──────────┐
│    61    │           │   309    │
│ type=file│           │type=symlink│
│link cnt=2│           │link cnt=1│
│ size=518 │           │size=16   │
└──────────┘           └──────────┘
      △    △                  │
      │    │         数据:"/home/erena/this"
/home/erena/this  /home/allyn/that
(硬链接1)       (硬链接2)    /home/kiran/other
                              (符号链接,指向字符串)

两个硬链接(thisthat)直接指向 i-node 61,链接计数 = 2。
符号链接 other 有自己的 i-node(309),不计入 i-node 61 的链接计数。

符号链接 vs 硬链接


特性 硬链接 符号链接
是否独立文件 不是(共享i-node) 是(有自己的i-node)
链接计数 计入目标i-node 不计入目标i-node
跨文件系统 不支持 支持
链接到目录 不支持 支持
目标不存在时 不可能(目标必须先存在) 允许(悬空链接)
删除目标后 链接本身仍有效(还有数据) 成为悬空链接(dangling link)

悬空链接(Dangling Link)

符号链接指向的文件名可以在创建链接后被删除,此时链接变成"悬空链接"。访问悬空链接通常返回 ENOENT 错误。

ln -s /tmp/nonexistent /tmp/mylink   # 创建指向不存在文件的符号链接(合法)
cat /tmp/mylink                      # 错误:No such file or directory

符号链接的链式解引用

符号链接可以链接到另一个符号链接:a → b → c → 实际文件
内核在解析路径时会逐级解引用。为防止无限循环和栈溢出:

  • Linux 2.6.18 之前:单条链路径最多解引用 5
  • Linux 2.6.18 及之后:最多 8 次(满足 SUSv3 要求)
  • 整个路径名总计最多 40 次解引用

ext2/ext3/ext4 的优化:快速符号链接

如果符号链接字符串足够短(≤ 60 字节),直接存在 i-node 的数据块指针区域,不需要额外分配磁盘块。作者测量的一个系统中,97% 的符号链接长度 ≤ 60 字节,因此这个优化非常有效。

系统调用对符号链接的处理

多数系统调用会自动解引用符号链接(跟随链接到最终目标)。少数不解引用。当需要两种行为时,通常提供两个版本:

解引用版本 不解引用版本 说明
stat() lstat() l 前缀表示"link",操作链接本身
chown() lchown() 同上
getxattr() lgetxattr() 同上

路径名中目录部分的符号链接总是被解引用,无论调用什么系统调用。例如 /a/b/c 中,ab 总会被解引用,c 取决于具体系统调用。

3. 创建和删除硬链接:link() 和 unlink()

link() 系统调用

#include <unistd.h>
int link(const char *oldpath, const char *newpath);
// 成功返回 0,失败返回 -1
  • oldpath:已有文件的路径
  • newpath:新链接的路径(不能已存在,否则 EEXIST
  • Linux 上,link() 不解引用符号链接(与 SUSv3 不符,注意移植性)

unlink() 系统调用

#include <unistd.h>
int unlink(const char *pathname);
// 成功返回 0,失败返回 -1
  • 删除一个文件名(目录条目),并将对应 i-node 的链接计数减 1
  • 链接计数降为 0 时,i-node 和数据块才真正释放
  • 不能用 unlink() 删除目录,需要用 rmdir()
  • 不解引用符号链接(删除的是链接文件本身,不是其指向的文件)

关键特性:open 的文件被删除后仍可使用

内核除了维护链接计数,还维护每个 i-node 的打开文件描述符计数。只有当链接计数 AND 打开文件描述符计数都降为 0,文件才真正被删除。
这允许一个常用技巧:创建临时文件后立即 unlink,然后继续通过文件描述符使用它。程序退出(或关闭 fd)时文件自动消失,无需手动清理。

// 创建临时文件的惯用法
int fd = open("/tmp/tmpfile", O_RDWR | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR);
unlink("/tmp/tmpfile");   // 立即删除文件名,文件数据还在
// ... 通过 fd 继续读写 ...
close(fd);                // 此时文件数据才真正消失

4. 重命名文件:rename()

#include <stdio.h>
int rename(const char *oldpath, const char *newpath);
// 成功返回 0,失败返回 -1

rename() 只操作目录条目,不移动文件数据,因此效率极高(即使"移动"一个大文件,只要在同一文件系统内,也是瞬间完成)。

行为规则


情况 结果
newpath 已存在(文件) 自动覆盖
oldpathnewpath 指向同一文件 不做任何操作,返回成功
oldpath 是符号链接 重命名链接本身,不解引用
newpath 是符号链接 覆盖掉这个符号链接
oldpath 是文件,newpath 是目录 失败(EISDIR)
oldpath 是目录 newpath 必须不存在或是空目录
跨文件系统 失败(EXDEV),mv 命令在这种情况下会做复制+删除

同时移动文件到另一目录并改名:

rename("sub1/x", "sub2/y");   # x 被移到 sub2 并改名为 y

5. 创建和读取符号链接:symlink() 和 readlink()

symlink()

#include <unistd.h>
int symlink(const char *filepath, const char *linkpath);
// 成功返回 0,失败返回 -1
  • filepath:链接指向的路径(可以是绝对或相对路径,可以不存在)
  • linkpath:新建符号链接的路径(不能已存在,否则 EEXIST

readlink()

open() 一个符号链接会自动跟随到目标文件,如果想读取链接本身存储的字符串,需要用 readlink()

#include <unistd.h>
ssize_t readlink(const char *pathname, char *buffer, size_t bufsiz);
// 成功返回写入 buffer 的字节数,失败返回 -1

注意:readlink() 返回的字符串不带末尾 null 字节,需要手动添加:

char buf[PATH_MAX];
ssize_t n = readlink("/path/to/symlink", buf, sizeof(buf) - 1);
if (n != -1) {
    buf[n] = '\0';   // 手动添加 null 终止符
    printf("链接指向: %s\n", buf);
}

如果链接字符串长度超过 bufsiz,返回的是截断后的字符串(长度恰好等于 bufsiz)。由于没有 null 终止符,无法区分"恰好填满"和"被截断"这两种情况,建议用 PATH_MAX 作为缓冲区大小。

6. 创建和删除目录:mkdir() 和 rmdir()

mkdir()

#include <sys/stat.h>
int mkdir(const char *pathname, mode_t mode);
// 成功返回 0,失败返回 -1
  • 创建的目录自动包含 .(指向自身)和 ..(指向父目录)两个条目
  • mode 参数与进程 umask 做 AND 运算:实际权限 = mode AND NOT(umask)
  • 只能创建路径的最后一级目录,父目录必须已存在(相当于 mkdir 而非 mkdir -p
    特殊权限位的处理:

权限位 mkdir 的处理
S_ISUID(set-user-ID) 始终关闭(对目录无意义)
S_ISGID(set-group-ID) mode 中的设置被忽略;若父目录有 S_ISGID,新目录自动继承
S_ISVTX(粘滞位) 若 mode 中有设置,则生效

rmdir()

#include <unistd.h>
int rmdir(const char *pathname);
// 成功返回 0,失败返回 -1
  • 目录必须为空才能删除
  • 不解引用符号链接(如果 pathname 最后一部分是符号链接,返回 ENOTDIR

7. 删除文件或目录:remove()

#include <stdio.h>
int remove(const char *pathname);
// 成功返回 0,失败返回 -1

remove() 是个"智能"包装函数:

  • 如果 pathname 是普通文件 → 调用 unlink()
  • 如果 pathname 是目录 → 调用 rmdir()
    适合用于"不知道要删的是文件还是目录"的场景,比 unlink()/rmdir() 更通用。也不解引用符号链接。

8. 读取目录内容:opendir() 和 readdir()

函数概览

opendir(路径) 或 fdopendir(fd)
    │
    ▼
DIR* dirp(目录流句柄)
    │
    ├── readdir(dirp) → struct dirent*(逐条读取)
    ├── rewinddir(dirp)(回到开头)
    ├── dirfd(dirp) → int(获取关联的文件描述符)
    └── closedir(dirp)(关闭目录流)

opendir()

#include <dirent.h>
DIR *opendir(const char *dirpath);
// 成功返回目录流句柄,失败返回 NULL

opendir() 会自动给关联的文件描述符设置 FD_CLOEXEC 标志,确保 exec() 时自动关闭。

readdir()

#include <dirent.h>
struct dirent *readdir(DIR *dirp);
// 成功返回指向 dirent 结构的指针,到达末尾或出错返回 NULL

返回的 struct dirent静态分配的,每次调用都会被覆盖:

struct dirent {
    ino_t d_ino;    // 文件的 i-node 编号
    char  d_name[]; // 文件名(null 结尾字符串)
    // 还有非标准的 d_type 字段(Linux/BSD 特有):
    // DT_REG(普通文件)、DT_DIR(目录)、DT_LNK(符号链接)、DT_FIFO等
};

readdir() 返回的文件顺序不是字母序,而是文件在目录中存储的物理顺序(取决于文件创建顺序和文件系统实现)。ls -f 会以同样的顺序列出文件。

区分"到达末尾"和"出错"

readdir() 在末尾和出错时都返回 NULL,需要用 errno 区分:

errno = 0;               // 先把 errno 清零
struct dirent *dp = readdir(dirp);
if (dp == NULL) {
    if (errno != 0) {
        // 真正出错了
    } else {
        // 正常到达目录末尾
    }
}

rewinddir()、closedir()、dirfd()

void rewinddir(DIR *dirp);          // 回到目录流开头
int closedir(DIR *dirp);            // 关闭目录流,释放资源
int dirfd(DIR *dirp);               // 获取目录流关联的文件描述符

9. 目录树遍历:nftw()

什么是 nftw

nftw()(new file tree walk)允许程序递归遍历整个目录树,对树中每个文件调用一个用户定义的回调函数。

#define _XOPEN_SOURCE 500
#include <ftw.h>
int nftw(const char *dirpath,
         int (*func)(const char *pathname,
                     const struct stat *statbuf,
                     int typeflag,
                     struct FTW *ftwbuf),
         int nopenfd,
         int flags);
// 成功完整遍历返回 0,出错返回 -1,或返回 func 的第一个非零返回值

参数说明

nopenfd:nftw 同时保持打开的最大文件描述符数(建议 10 或更多)。若目录深度超过此值,nftw 会关闭并重新打开描述符(速度变慢)。
flags:可以是以下标志的组合(用 | 连接):

标志 含义
FTW_CHDIR 在处理每个目录内容前,先 chdir 进去
FTW_DEPTH 后序遍历(先处理目录内容,再处理目录本身)。默认是前序遍历
FTW_MOUNT 不跨越挂载点(不进入其他文件系统)
FTW_PHYS 不解引用符号链接(默认会解引用)

回调函数的参数

回调函数 func 接收四个参数:
pathname:当前文件的路径名(相对或绝对,取决于 dirpath 的形式)
statbuf:指向该文件的 stat 结构(包含 i-node、大小、权限等信息)
typeflag:文件类型标志:

typeflag 值 含义
FTW_D 这是一个目录(前序遍历时)
FTW_F 普通文件(包括所有非目录、非符号链接的文件类型)
FTW_DNR 无权读取的目录,不会遍历其内容
FTW_DP 后序遍历时的目录(FTW_DEPTH 标志下)
FTW_SL 符号链接(仅在 FTW_PHYS 标志下出现)
FTW_SLN 悬空符号链接(FTW_PHYS 未指定时出现)
FTW_NS 无法 stat 的文件(statbuf 无效)

ftwbuf:指向 struct FTW

struct FTW {
    int base;   // pathname 中文件名部分(最后一个/之后)的偏移量
    int level;  // 相对于遍历起点的深度(起点为 level 0)
};

回调函数的返回值

  • 返回 0:继续遍历
  • 返回非零:立即停止遍历,nftw 返回同样的非零值
    不要用 longjmp() 退出回调函数,否则会导致内存泄漏(nftw 内部使用动态分配的数据结构)。

前序 vs 后序遍历示意

目录树:
dir/
├── a
├── b
├── sub/
│   └── x
└── sub2/(不可读)
前序遍历(默认):dir → a → b → sub → x → sub2
后序遍历(FTW_DEPTH):a → b → x → sub → sub2 → dir

10. 进程的当前工作目录

每个进程有两个目录相关属性:

属性 含义 影响
根目录(root directory) 绝对路径的起点 默认是文件系统的真实根 /
当前工作目录(cwd) 相对路径的起点 chdir() 变化

获取当前工作目录:getcwd()

#include <unistd.h>
char *getcwd(char *cwdbuf, size_t size);
// 成功返回 cwdbuf 指针,失败返回 NULL
  • cwdbuf 至少应为 PATH_MAX 字节(Linux x86-32 上是 4096)
  • 路径超过 size 时,返回 NULL 并设置 errno = ERANGE
    也可以通过读取 /proc/PID/cwd 符号链接来获取任意进程的当前工作目录。

切换当前工作目录:chdir() 和 fchdir()

#include <unistd.h>
int chdir(const char *pathname);   // 通过路径名切换
int fchdir(int fd);                // 通过文件描述符切换(需先 open 目录)

保存和恢复工作目录的两种方法对比
方法一(用 fchdir,更高效):

int fd = open(".", O_RDONLY);  // 记住当前目录
chdir(somepath);               // 切换到别处
// ... 做一些工作 ...
fchdir(fd);                    // 返回原目录
close(fd);

方法二(用 getcwd + chdir):

char buf[PATH_MAX];
getcwd(buf, PATH_MAX);   // 记住当前目录的路径字符串
chdir(somepath);         // 切换到别处
// ... 做一些工作 ...
chdir(buf);              // 返回原目录(需要路径解析,比方法一慢)

方法一(fchdir)更高效,因为它直接使用已打开的文件描述符,不需要路径名解析。

11. 基于目录文件描述符的操作

从内核 2.6.16 开始,Linux 提供了一组新系统调用,在传统系统调用基础上增加了 dirfd 参数,代表性的是 openat()

#define _XOPEN_SOURCE 700
#include <fcntl.h>
int openat(int dirfd, const char *pathname, int flags, ...);
// 成功返回文件描述符,失败返回 -1

dirfd 的三种用法:

dirfd 的值 pathname 行为
目录的文件描述符 相对路径 相对于 dirfd 指向的目录解析
AT_FDCWD 相对路径 相对于进程当前工作目录(等同于 open())
任意值 绝对路径 dirfd 被忽略

同类函数一览(新接口 vs 传统接口):

新接口 传统接口
openat() open()
mkdirat() mkdir()
unlinkat() unlink()
renameat() rename()
fstatat() stat()
faccessat() access()
linkat() link()
symlinkat() symlink()
readlinkat() readlink()

这类函数的两大用途

  1. 避免竞态条件open() 时路径中的目录部分可能在两次系统调用之间被修改。先打开目录 fd,再用 openat() 可以避免这种竞态。
  2. 多线程的"虚拟工作目录":当前工作目录是进程级别的,所有线程共享。各线程可以维护各自的目录 fd,用 openat() 模拟各自的工作目录。

12. 更改进程的根目录:chroot()

#define _BSD_SOURCE
#include <unistd.h>
int chroot(const char *pathname);
// 成功返回 0,失败返回 -1(需要 CAP_SYS_CHROOT 特权)

chroot() 改变进程解析绝对路径的起点,将进程限制在文件系统的一个子树中,这被称为 chroot 监狱(chroot jail)
典型用例:匿名 FTP 服务器通过 chroot() 把用户限制在特定目录,防止访问系统其他部分。

chroot 监狱的局限性

问题1chroot() 不改变当前工作目录,因此需要立即 chdir("/")

chroot("/jail");
chdir("/");    // 必须!否则可以用相对路径访问监狱外的文件

问题2:持有监狱外目录的文件描述符可以突破监狱:

// 突破监狱的代码示例(安全演示,勿滥用)
int fd = open("/", O_RDONLY);    // 先记住真正的根目录
chroot("/home/mtk");              // 进入监狱
fchdir(fd);                       // 切换到真实根目录
chroot(".");                      // 把当前目录(真实根)设为新根,逃出监狱!

问题3:通过 UNIX domain socket 接收来自监狱外进程的文件描述符也可以突破监狱。
因此,不应在 chroot 监狱中运行 set-user-ID-root 程序,且必须关闭所有指向监狱外目录的文件描述符。

13. 解析路径名:realpath()

#include <stdlib.h>
char *realpath(const char *pathname, char *resolved_path);
// 成功返回指向解析结果的指针,失败返回 NULL

realpath() 做三件事:

  1. 解引用路径中所有符号链接
  2. 解析 .(当前目录)
  3. 解析 ..(父目录)
    最终返回一个规范的绝对路径。
# 示例:
# 当前目录 /home/mtk
# x 是普通文件,y 是指向 x 的符号链接
readlink(y)"x"              # 仅读取链接内容
realpath(y)"/home/mtk/x"   # 完整解析到绝对路径

resolved_path 应该是至少 PATH_MAX 字节的数组。glibc 实现允许传入 NULL,此时自动分配缓冲区(需要调用者 free())。

14. 拆分路径名:dirname() 和 basename()

#include <libgen.h>
char *dirname(char *pathname);   // 返回目录部分
char *basename(char *pathname);  // 返回文件名部分

把路径拆成目录部分和文件名部分:

/home/britta/prog.c
       ↓           ↓
dirname()      basename()
/home/britta   prog.c

重新组合:dirname(path) + "/" + basename(path) = 完整路径

特殊情况


输入 dirname() basename()
/ / /
/usr/bin/zip /usr/bin zip
/etc/passwd//// /etc passwd
etc/passwd etc passwd
passwd . passwd
NULL . .

注意dirname()basename() 会修改传入的字符串,因此必须传入副本:

char *p1 = strdup(path);
char *p2 = strdup(path);
printf("%s + %s\n", dirname(p1), basename(p2));
free(p1);
free(p2);

15. 完整示例程序

15.1 unlink 演示:打开的文件被删除后仍可使用

// t_unlink.cpp
// 功能:演示文件被 unlink 后,只要还有进程持有打开的fd,数据仍然存在
// 编译:g++ -o t_unlink t_unlink.cpp
// 用法:./t_unlink /tmp/tfile [块数]
//   块数:可选,要写入的 1KB 块数,默认 100000
#include <sys/stat.h>   // stat, S_IRUSR, S_IWUSR
#include <fcntl.h>      // open, O_WRONLY, O_CREAT, O_EXCL
#include <unistd.h>     // unlink, write, close
#include <stdio.h>      // printf, snprintf, system, perror
#include <stdlib.h>     // exit, atoi
#include <string.h>     // strcmp, strerror
#include <errno.h>      // errno
#define CMD_SIZE  200
#define BUF_SIZE  1024
int main(int argc, char *argv[]) {
    if (argc < 2 || strcmp(argv[1], "--help") == 0) {
        fprintf(stderr, "用法: %s temp-file [num-1kB-blocks]\n", argv[0]);
        exit(EXIT_FAILURE);
    }
    // 要写入的块数(每块 1KB),默认 100000 块 = 约 100MB
    int numBlocks = (argc > 2) ? atoi(argv[2]) : 100000;
    // 创建文件,O_EXCL 确保文件不存在时才创建(防止意外覆盖)
    int fd = open(argv[1], O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }
    // 立即删除文件名
    // 此时目录中看不到这个文件了,但 fd 仍然有效,数据仍然存在
    if (unlink(argv[1]) == -1) {
        perror("unlink");
        exit(EXIT_FAILURE);
    }
    printf("文件 %s 已被 unlink(从目录中删除),但 fd=%d 仍然有效\n", argv[1], fd);
    // 通过 fd 向文件写入大量数据(文件仍然存在于磁盘,只是没有名字)
    char buf[BUF_SIZE];   // 用于写入的缓冲区(内容是未初始化的"垃圾"数据)
    for (int j = 0; j < numBlocks; j++) {
        if (write(fd, buf, BUF_SIZE) != BUF_SIZE) {
            fprintf(stderr, "write 失败或不完整\n");
            exit(EXIT_FAILURE);
        }
    }
    // 用 df 命令查看文件系统使用情况(此时文件数据还在磁盘上)
    char shellCmd[CMD_SIZE];
    snprintf(shellCmd, CMD_SIZE, "df -k `dirname %s`", argv[1]);
    printf("\n关闭 fd 之前,磁盘使用情况:\n");
    system(shellCmd);
    // 关闭文件描述符——这才是文件数据真正被删除的时刻
    if (close(fd) == -1) {
        perror("close");
        exit(EXIT_FAILURE);
    }
    printf("\n********** 已关闭文件描述符,文件数据现在真正被删除了\n\n");
    // 再次查看磁盘使用情况(应该减少了)
    printf("关闭 fd 之后,磁盘使用情况:\n");
    system(shellCmd);
    return 0;
}

15.2 扫描目录内容

// list_files.cpp
// 功能:列出命令行指定目录(或当前目录)的内容
// 编译:g++ -o list_files list_files.cpp
// 用法:./list_files [目录1] [目录2] ...
#include <dirent.h>     // opendir, readdir, closedir, DIR, struct dirent
#include <stdio.h>      // printf, fprintf
#include <stdlib.h>     // exit
#include <string.h>     // strcmp, strerror
#include <errno.h>      // errno
// 列出 dirpath 目录下的所有文件(跳过 . 和 ..)
static void listFiles(const char *dirpath) {
    // 判断是否是当前目录,影响输出格式
    int isCurrent = (strcmp(dirpath, ".") == 0);
    // 打开目录,获取目录流句柄
    DIR *dirp = opendir(dirpath);
    if (dirp == NULL) {
        fprintf(stderr, "opendir(%s) 失败: %s\n", dirpath, strerror(errno));
        return;
    }
    // 循环读取目录条目
    for (;;) {
        errno = 0;   // 每次读取前清零 errno,以便区分"末尾"和"出错"
        struct dirent *dp = readdir(dirp);
        if (dp == NULL) {
            // readdir 返回 NULL 有两种情况:到达末尾 或 出错
            break;
        }
        // 跳过 . 和 .. 两个特殊条目
        if (strcmp(dp->d_name, ".") == 0 || strcmp(dp->d_name, "..") == 0)
            continue;
        // 打印:如果不是当前目录,先打印目录路径前缀
        if (!isCurrent)
            printf("%s/", dirpath);
        printf("%s\n", dp->d_name);
    }
    // 检查是否因出错退出循环
    if (errno != 0) {
        fprintf(stderr, "readdir(%s) 出错: %s\n", dirpath, strerror(errno));
    }
    // 关闭目录流,释放资源
    if (closedir(dirp) == -1) {
        fprintf(stderr, "closedir(%s) 失败: %s\n", dirpath, strerror(errno));
    }
}
int main(int argc, char *argv[]) {
    if (argc == 1) {
        // 没有参数,列出当前目录
        listFiles(".");
    } else {
        // 列出命令行指定的每个目录
        for (int i = 1; i < argc; i++) {
            listFiles(argv[i]);
        }
    }
    return 0;
}

15.3 目录树遍历:nftw 演示

// nftw_dir_tree.cpp
// 功能:递归显示目录树,每个文件显示类型、nftw类型标志、i-node号和文件名
// 编译:g++ -o nftw_dir_tree nftw_dir_tree.cpp
// 用法:./nftw_dir_tree [-d] [-m] [-p] [目录路径]
//   -d  使用 FTW_DEPTH(后序遍历)
//   -m  使用 FTW_MOUNT(不跨越挂载点)
//   -p  使用 FTW_PHYS(不解引用符号链接)
#define _XOPEN_SOURCE 600        // 启用 nftw() 和 S_IFSOCK 的声明
#include <ftw.h>                 // nftw, struct FTW, FTW_* 常量
#include <sys/stat.h>            // struct stat, S_IF* 常量
#include <stdio.h>               // printf, fprintf
#include <stdlib.h>              // exit
#include <unistd.h>              // getopt
#include <string.h>              // strerror
#include <errno.h>               // errno
// 打印使用说明并退出
static void usageError(const char *progName, const char *msg) {
    if (msg) fprintf(stderr, "%s\n", msg);
    fprintf(stderr, "用法: %s [-d] [-m] [-p] [目录路径]\n", progName);
    fprintf(stderr, "  -d  后序遍历(FTW_DEPTH)\n");
    fprintf(stderr, "  -m  不跨越挂载点(FTW_MOUNT)\n");
    fprintf(stderr, "  -p  不解引用符号链接(FTW_PHYS)\n");
    exit(EXIT_FAILURE);
}
// nftw() 的回调函数:对树中每个文件调用一次
static int dirTree(const char *pathname,       // 文件路径
                   const struct stat *sbuf,    // 文件的 stat 信息
                   int type,                   // 文件类型标志(FTW_*)
                   struct FTW *ftwb)           // 遍历状态(偏移、深度)
{
    // 打印文件类型字符(类似 ls -l 的第一个字符)
    switch (sbuf->st_mode & S_IFMT) {
    case S_IFREG:  printf("-"); break;   // 普通文件
    case S_IFDIR:  printf("d"); break;   // 目录
    case S_IFCHR:  printf("c"); break;   // 字符设备
    case S_IFBLK:  printf("b"); break;   // 块设备
    case S_IFLNK:  printf("l"); break;   // 符号链接
    case S_IFIFO:  printf("p"); break;   // 命名管道(FIFO)
    case S_IFSOCK: printf("s"); break;   // socket
    default:       printf("?"); break;
    }
    // 打印 nftw 类型标志的字符串表示
    printf(" %-3s ",
           (type == FTW_D)   ? "D"   :    // 目录(前序)
           (type == FTW_DNR) ? "DNR" :    // 无权读取的目录
           (type == FTW_DP)  ? "DP"  :    // 目录(后序)
           (type == FTW_F)   ? "F"   :    // 普通文件
           (type == FTW_SL)  ? "SL"  :    // 符号链接(FTW_PHYS 下)
           (type == FTW_SLN) ? "SLN" :    // 悬空符号链接
           (type == FTW_NS)  ? "NS"  :    // 无法 stat
           "?");
    // 打印 i-node 编号(FTW_NS 时 stat 无效,打印空白)
    if (type != FTW_NS)
        printf("%7ld  ", (long)sbuf->st_ino);
    else
        printf("         ");
    // 根据深度缩进(每层缩进 4 个空格)
    printf("%*s", 4 * ftwb->level, "");
    // 打印文件名(basename 部分:ftwb->base 是文件名在路径中的偏移)
    printf("%s\n", &pathname[ftwb->base]);
    // 返回 0 告诉 nftw 继续遍历
    return 0;
}
int main(int argc, char *argv[]) {
    int flags = 0;
    int opt;
    // 解析命令行选项
    while ((opt = getopt(argc, argv, "dmp")) != -1) {
        switch (opt) {
        case 'd': flags |= FTW_DEPTH; break;   // 后序遍历
        case 'm': flags |= FTW_MOUNT; break;   // 不跨文件系统
        case 'p': flags |= FTW_PHYS;  break;   // 不解引用符号链接
        default:  usageError(argv[0], NULL);
        }
    }
    // 最多一个目录参数(可选,默认使用当前目录)
    if (argc > optind + 1)
        usageError(argv[0], "参数过多");
    const char *startDir = (argc > optind) ? argv[optind] : ".";
    // 调用 nftw 遍历目录树
    // 10:最多同时保持 10 个打开的文件描述符
    if (nftw(startDir, dirTree, 10, flags) == -1) {
        perror("nftw");
        exit(EXIT_FAILURE);
    }
    return 0;
}

15.4 读取和解析符号链接:readlink() 和 realpath()

// view_symlink.cpp
// 功能:显示符号链接的原始内容和解析后的绝对路径
// 编译:g++ -o view_symlink view_symlink.cpp
// 用法:./view_symlink <符号链接路径>
#include <sys/stat.h>   // lstat, struct stat, S_ISLNK
#include <unistd.h>     // readlink
#include <stdlib.h>     // realpath, exit
#include <limits.h>     // PATH_MAX
#include <stdio.h>      // printf, fprintf
#include <string.h>     // strcmp, strerror
#include <errno.h>      // errno
int main(int argc, char *argv[]) {
    if (argc != 2 || strcmp(argv[1], "--help") == 0) {
        fprintf(stderr, "用法: %s <符号链接路径>\n", argv[0]);
        exit(EXIT_FAILURE);
    }
    // 用 lstat 获取文件信息,lstat 不解引用符号链接,可以获取链接本身的信息
    struct stat statbuf;
    if (lstat(argv[1], &statbuf) == -1) {
        perror("lstat");
        exit(EXIT_FAILURE);
    }
    // 确认是符号链接
    if (!S_ISLNK(statbuf.st_mode)) {
        fprintf(stderr, "%s 不是符号链接\n", argv[1]);
        exit(EXIT_FAILURE);
    }
    // readlink:读取符号链接本身存储的路径字符串(不解引用)
    // 注意:readlink 不添加 null 终止符,需要手动添加
    char buf[PATH_MAX];
    ssize_t numBytes = readlink(argv[1], buf, sizeof(buf) - 1);
    if (numBytes == -1) {
        perror("readlink");
        exit(EXIT_FAILURE);
    }
    buf[numBytes] = '\0';   // 手动添加 null 终止符!
    printf("readlink: %s --> %s\n", argv[1], buf);
    // realpath:完整解析路径(解引用所有符号链接,解析 . 和 ..)
    // 结果存入 buf(已经用过,这里复用)
    if (realpath(argv[1], buf) == NULL) {
        perror("realpath");
        exit(EXIT_FAILURE);
    }
    printf("realpath: %s --> %s\n", argv[1], buf);
    return 0;
}

运行示例

# 编译
g++ -o view_symlink view_symlink.cpp
# 准备测试
cd /home/mtk
touch x
ln -s x y            # y 是指向 x 的符号链接
# 运行
./view_symlink y
# readlink: y --> x              (符号链接存储的原始字符串)
# realpath: y --> /home/mtk/x   (完整解析后的绝对路径)
# 悬空链接的情况
ln -s nonexistent z
./view_symlink z
# readlink: z --> nonexistent
# realpath: realpath: No such file or directory

16. 总结

核心概念关系

解引用

磁盘文件数据

i-node
元数据+数据块指针

目录条目1
文件名A → i-node编号

目录条目2
文件名B → i-node编号

目录条目N
文件名N → i-node编号

硬链接

符号链接i-node
数据=目标路径字符串

指向某个路径名

链接计数与文件删除

文件真正删除 ⇔ 链接计数 = 0  且 打开的fd数 = 0 \text{文件真正删除} \Leftrightarrow \text{链接计数} = 0 \text{ 且 } \text{打开的fd数} = 0 文件真正删除链接计数=0  打开的fd=0

系统调用速查


操作 系统调用/函数 备注
创建硬链接 link() 同一文件系统,不能链接目录
删除链接/文件 unlink() 不能删除目录
重命名 rename() 同一文件系统内,原子操作
创建符号链接 symlink() 可跨文件系统,可链接目录
读取符号链接内容 readlink() 不解引用,返回链接存储的字符串
创建目录 mkdir() 只创建最后一级
删除目录 rmdir() 目录必须为空
删除文件或空目录 remove() 自动选择 unlink 或 rmdir
打开目录 opendir() 返回 DIR* 句柄
读取目录条目 readdir() 逐条读取,非字母序
关闭目录 closedir() 释放资源
目录树遍历 nftw() 回调函数模式,可前序/后序
获取当前目录 getcwd() 返回绝对路径
切换目录(路径) chdir() 改变进程工作目录
切换目录(fd) fchdir() 比 chdir 更高效
更改根目录 chroot() 需要特权,创建 chroot jail
解析路径 realpath() 解引用所有符号链接,返回绝对路径
拆分路径 dirname() / basename() 注意会修改传入字符串,需传副本

Linux inotify 文件事件监控机制

一、为什么需要文件事件监控?

在日常开发中,有很多场景需要"盯着"某个文件或目录:

  • 图形文件管理器:需要知道目录里是否新增或删除了文件,及时刷新界面
  • 后台服务(daemon):需要知道配置文件是否被改动,以便热重载
  • 版本控制工具:需要感知哪些文件发生了变化
    Linux 从内核 2.6.13 开始提供了 inotify 机制,专门用于监控文件事件。它取代了更早的 dnotify 机制(功能是 inotify 的子集)。

注意:inotify 是 Linux 专有的。BSD 系统有类似的 kqueue API。
如果需要跨平台,可以考虑 FAM 或 Gamin 这类封装库,它们在底层会使用 inotify。

二、整体工作流程

                    用户程序
                       |
          1. inotify_init()  →  创建监控实例,返回 fd
                       |
          2. inotify_add_watch()  →  告诉内核监控哪些路径
                       |
          3. read(fd, ...)  →  阻塞等待,有事件时读取
                       |
          4. close(fd)  →  关闭,自动清理所有监控项

用 Mermaid 流程图表示:

程序启动

inotify_init()
创建 inotify 实例
返回文件描述符 fd

inotify_add_watch(fd, 路径, 事件掩码)
添加监控项
返回 watch descriptor wd

还有更多路径?

read(fd, buf, size)
阻塞等待事件

解析 inotify_event 结构体
处理事件

继续监控?

close(fd)
自动移除所有监控项

三、核心数据结构关系

一个 inotify 实例(由 fd 标识)可以包含多个监控项,每个监控项对应一个路径和一组事件掩码:

inotify 实例 (fd)
├── 监控项 1  →  路径: /tmp/dir1  +  掩码: IN_CREATE | IN_DELETE
├── 监控项 2  →  路径: /etc/nginx.conf  +  掩码: IN_MODIFY
└── 监控项 3  →  路径: /var/log  +  掩码: IN_ALL_EVENTS
      ↑
      每个监控项有唯一的 watch descriptor (wd)

四、API 详解

4.1 创建 inotify 实例

#include <sys/inotify.h>
int inotify_init(void);
// 成功返回文件描述符(fd),失败返回 -1

从内核 2.6.27 起,还有增强版:

int inotify_init1(int flags);
// flags 可以是:
//   IN_CLOEXEC   → 设置 close-on-exec 标志(exec 后自动关闭 fd)
//   IN_NONBLOCK  → 设置非阻塞模式(read 没有事件时立刻返回 EAGAIN)

4.2 添加/修改监控项

int inotify_add_watch(int fd, const char *pathname, uint32_t mask);
// fd       → inotify_init() 返回的文件描述符
// pathname → 要监控的文件或目录路径
// mask     → 要监控的事件类型(位掩码)
// 成功返回 watch descriptor (wd),失败返回 -1

关键规则

  • 调用时需要对 pathname读权限(之后即使权限变化也不影响已建立的监控)
  • pathname 已在监控列表中,则修改其掩码,返回原有 wd
  • pathname 是新路径,则新建监控项,返回新 wd

4.3 删除监控项

int inotify_rm_watch(int fd, uint32_t wd);
// 移除指定 wd 对应的监控项
// 移除后会产生一个 IN_IGNORED 事件
// 成功返回 0,失败返回 -1

五、事件类型表


事件位 可设为输入 会出现在输出 含义说明
IN_ACCESS 文件被读取(read())
IN_ATTRIB 文件元数据改变(权限/属主/硬链接数/扩展属性等)
IN_CLOSE_WRITE 以写模式打开的文件被关闭
IN_CLOSE_NOWRITE 以只读模式打开的文件被关闭
IN_CREATE 在被监控目录内创建了文件或子目录
IN_DELETE 在被监控目录内删除了文件或子目录
IN_DELETE_SELF 被监控的文件/目录本身被删除
IN_MODIFY 文件内容被修改
IN_MOVE_SELF 被监控的文件/目录本身被重命名/移动
IN_MOVED_FROM 文件从被监控目录中移出(重命名源)
IN_MOVED_TO 文件移入被监控目录(重命名目标)
IN_OPEN 文件被打开
IN_ALL_EVENTS 上述所有输入事件的简写
IN_CLOSE IN_CLOSE_WRITE | IN_CLOSE_NOWRITE 的简写
IN_MOVE IN_MOVED_FROM | IN_MOVED_TO 的简写
IN_DONT_FOLLOW 不解引用符号链接(监控链接本身)
IN_MASK_ADD 将新掩码与现有掩码做 OR,而非替换
IN_ONESHOT 只监控一次事件,触发后自动移除监控项
IN_ONLYDIR 仅当路径是目录时才监控,否则报 ENOTDIR
IN_IGNORED 监控项被移除(主动或被动)
IN_ISDIR 事件的对象是一个目录
IN_Q_OVERFLOW 事件队列溢出
IN_UNMOUNT 被监控对象所在的文件系统被卸载

六、读取事件:inotify_event 结构体

每次 read() 从 inotify fd 读取,返回的缓冲区里可能包含一个或多个事件,每个事件的格式如下:

struct inotify_event {
    int      wd;       // 触发事件的 watch descriptor(对应 inotify_add_watch 的返回值)
    uint32_t mask;     // 描述发生了什么事件的位掩码
    uint32_t cookie;   // 关联事件的 cookie(仅用于 rename 操作)
    uint32_t len;      // name 字段实际分配的字节数(含填充)
    char     name[];   // 可变长度:触发事件的文件名(仅监控目录时有效)
};

各字段解释

wd 字段:标识是哪个监控项触发了事件。程序需要自己维护一个 wd → 路径 的映射表。
mask 字段:描述发生的事件类型(对应表中"会出现在输出"的那些位)。
cookie 字段:用于关联 rename 的两个事件。当文件被重命名时,会先产生 IN_MOVED_FROM,再产生 IN_MOVED_TO,这两个事件的 cookie 值相同,程序可据此将它们配对。
len 字段name 字段实际占用的字节数(包括结尾的 \0 和填充字节)。单个事件的总大小为:
事件总大小 = sizeof ( inotify_event ) + len \text{事件总大小} = \text{sizeof}(\texttt{inotify\_event}) + \text{len} 事件总大小=sizeof(inotify_event)+len
name 字段:当被监控的是目录,且事件是目录内某个文件触发的,这里存放该文件名(以 \0 结尾)。若事件是监控对象本身触发的,则 len = 0name 为空。

缓冲区布局示意

read() 返回的缓冲区:
+------------------+  <-- 偏移 0
|  inotify_event 1 |
|  wd, mask, ...   |
|  name: "foo.txt" |
|  (padding bytes) |
+------------------+  <-- 偏移 sizeof(event1) + len1
|  inotify_event 2 |
|  wd, mask, ...   |
|  name: "bar.c"   |
|  (padding bytes) |
+------------------+
|  inotify_event 3 |
|  ...             |
+------------------+

遍历缓冲区的方法

每次读一个事件后,指针要向后移动:
p next = p current + sizeof ( inotify_event ) + event->len p_{\text{next}} = p_{\text{current}} + \text{sizeof}(\texttt{inotify\_event}) + \text{event->len} pnext=pcurrent+sizeof(inotify_event)+event->len

缓冲区最小尺寸

为了保证每次 read() 至少能容纳一个事件,缓冲区大小应不小于:
BUF_MIN = sizeof ( inotify_event ) + NAME_MAX + 1 \text{BUF\_MIN} = \text{sizeof}(\texttt{inotify\_event}) + \texttt{NAME\_MAX} + 1 BUF_MIN=sizeof(inotify_event)+NAME_MAX+1
其中 NAME_MAX \texttt{NAME\_MAX} NAME_MAX 是文件名的最大长度(通常为 255), + 1 +1 +1 是结尾的空字节。

七、重要行为细节

7.1 监控目录时

监控一个目录时,程序会收到:

  • 目录自身的事件(如目录被删除 IN_DELETE_SELF
  • 目录内文件的事件(如目录内某文件被创建 IN_CREATE

7.2 监控不是递归的

inotify 不自动递归监控子目录。如果需要监控整个目录树,必须对树中每个子目录分别调用 inotify_add_watch()

/root
├── /root/dir1        ← 需要单独 add_watch
│   ├── /root/dir1/sub1  ← 需要单独 add_watch
│   └── /root/dir1/sub2  ← 需要单独 add_watch
└── /root/dir2        ← 需要单独 add_watch

7.3 事件合并

内核在向事件队列尾部追加新事件时,若新事件与队列末尾事件的 wdmaskcookiename 完全相同,则合并(不重复入队)。
这意味着:无法用 inotify 精确统计某个事件发生了多少次。

7.4 IN_IGNORED 事件

以下两种情况会产生 IN_IGNORED 事件:

  • 程序主动调用 inotify_rm_watch() 移除监控项
  • 内核自动移除(被监控对象被删除,或所在文件系统被卸载)
    注意:IN_ONESHOT 触发后自动移除不会产生 IN_IGNORED

7.5 IN_Q_OVERFLOW 溢出

当事件队列满了,内核会丢弃后续事件,并产生一个 IN_Q_OVERFLOW 事件(其 wd = -1)。

7.6 可与 select/poll/epoll 配合

inotify fd 可以用于 select()poll()epoll(内核 2.6.25+ 还支持信号驱动 I/O)。有事件可读时,这些接口会将 inotify fd 标记为可读。

八、完整示例代码(C++)

以下是对原文 C 示例的 C++ 改写版,加入了详细注释,完整可编译运行:

// inotify_demo.cpp
// 编译: g++ -o inotify_demo inotify_demo.cpp
// 运行: ./inotify_demo /path/to/dir1 /path/to/dir2
#include <sys/inotify.h>   // inotify 相关 API
#include <unistd.h>        // read(), close()
#include <limits.h>        // NAME_MAX
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cerrno>
#include <stdexcept>
// 缓冲区大小:能容纳 10 个最大尺寸的事件
// 每个最大事件大小 = sizeof(inotify_event) + NAME_MAX + 1
static const int BUF_LEN = 10 * (sizeof(struct inotify_event) + NAME_MAX + 1);
// 根据事件掩码打印事件类型的可读名称
void displayInotifyEvent(const struct inotify_event* event) {
    printf("  wd=%2d", event->wd);
    // cookie 用于关联 rename 操作的 MOVED_FROM 和 MOVED_TO 两个事件
    if (event->cookie > 0) {
        printf("  cookie=%4u", event->cookie);
    }
    printf("  mask=");
    // 以下是所有可能的输出事件类型
    if (event->mask & IN_ACCESS)        printf("IN_ACCESS ");
    if (event->mask & IN_ATTRIB)        printf("IN_ATTRIB ");
    if (event->mask & IN_CLOSE_NOWRITE) printf("IN_CLOSE_NOWRITE ");
    if (event->mask & IN_CLOSE_WRITE)   printf("IN_CLOSE_WRITE ");
    if (event->mask & IN_CREATE)        printf("IN_CREATE ");
    if (event->mask & IN_DELETE)        printf("IN_DELETE ");
    if (event->mask & IN_DELETE_SELF)   printf("IN_DELETE_SELF ");
    if (event->mask & IN_IGNORED)       printf("IN_IGNORED ");
    if (event->mask & IN_ISDIR)         printf("IN_ISDIR ");   // 事件对象是目录
    if (event->mask & IN_MODIFY)        printf("IN_MODIFY ");
    if (event->mask & IN_MOVE_SELF)     printf("IN_MOVE_SELF ");
    if (event->mask & IN_MOVED_FROM)    printf("IN_MOVED_FROM ");
    if (event->mask & IN_MOVED_TO)      printf("IN_MOVED_TO ");
    if (event->mask & IN_OPEN)          printf("IN_OPEN ");
    if (event->mask & IN_Q_OVERFLOW)    printf("IN_Q_OVERFLOW ");
    if (event->mask & IN_UNMOUNT)       printf("IN_UNMOUNT ");
    printf("\n");
    // 如果 len > 0,说明事件是目录内的文件触发的,name 字段有效
    if (event->len > 0) {
        printf("    name = %s\n", event->name);
    }
}
int main(int argc, char* argv[]) {
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <pathname>...\n", argv[0]);
        return EXIT_FAILURE;
    }
    // 步骤1:创建 inotify 实例
    // inotify_init() 返回一个文件描述符,之后所有操作都通过它进行
    int inotifyFd = inotify_init();
    if (inotifyFd == -1) {
        perror("inotify_init");
        return EXIT_FAILURE;
    }
    // 步骤2:为命令行中每个路径添加监控项
    for (int j = 1; j < argc; j++) {
        // IN_ALL_EVENTS 监控所有类型的事件
        // 返回值 wd 是这个监控项的唯一标识,后续事件中会用到
        int wd = inotify_add_watch(inotifyFd, argv[j], IN_ALL_EVENTS);
        if (wd == -1) {
            fprintf(stderr, "inotify_add_watch(%s): %s\n", argv[j], strerror(errno));
            close(inotifyFd);
            return EXIT_FAILURE;
        }
        printf("正在监控: %s  (wd=%d)\n", argv[j], wd);
    }
    char buf[BUF_LEN];
    // 步骤3:循环读取事件
    for (;;) {
        // read() 会阻塞,直到有事件发生
        // 返回实际读取的字节数,缓冲区里可能包含多个事件
        ssize_t numRead = read(inotifyFd, buf, BUF_LEN);
        if (numRead == 0) {
            fprintf(stderr, "read() 返回 0,异常退出\n");
            break;
        }
        if (numRead == -1) {
            perror("read");
            break;
        }
        printf("从 inotify fd 读取了 %ld 字节\n", (long)numRead);
        // 遍历缓冲区中的所有 inotify_event
        // 每个事件的实际大小 = sizeof(inotify_event) + event->len
        for (char* p = buf; p < buf + numRead; ) {
            struct inotify_event* event = reinterpret_cast<struct inotify_event*>(p);
            displayInotifyEvent(event);
            // 指针跳到下一个事件
            // sizeof(inotify_event) 是固定头部,event->len 是可变的 name 字段长度
            p += sizeof(struct inotify_event) + event->len;
        }
    }
    // 步骤4:关闭 inotify fd,所有监控项自动被内核清理
    close(inotifyFd);
    return EXIT_SUCCESS;
}

九、运行示例演示

假设我们运行 ./inotify_demo dir1 dir2 &,然后执行一系列操作:

ASCII 时序图:
命令行操作                    inotify_demo 输出
─────────────────────────────────────────────────────────
./inotify_demo dir1 dir2 &   正在监控: dir1 (wd=1)
                              正在监控: dir2 (wd=2)
cat > dir1/aaa               读取 64 字节
                              wd= 1  mask=IN_CREATE  name=aaa
                              wd= 1  mask=IN_OPEN    name=aaa
[输入 Hello world]           读取 32 字节
                              wd= 1  mask=IN_MODIFY  name=aaa
[按 Ctrl+D]                  读取 32 字节
                              wd= 1  mask=IN_CLOSE_WRITE  name=aaa
mv dir1/aaa dir2/bbb         读取 64 字节
                              wd= 1  cookie=548  mask=IN_MOVED_FROM  name=aaa
                              wd= 2  cookie=548  mask=IN_MOVED_TO    name=bbb
                              ↑ 相同 cookie 表示这两个事件是同一次 rename
mkdir dir2/ddd               读取 32 字节
                              wd= 2  mask=IN_CREATE IN_ISDIR  name=ddd
                              ↑ IN_ISDIR 表示新建的是目录而非文件
rmdir dir1                   读取 32 字节
                              wd= 1  mask=IN_DELETE_SELF
                              wd= 1  mask=IN_IGNORED
                              ↑ IN_IGNORED 表示内核已自动移除该监控项

十、队列限制与内核参数

inotify 的事件队列需要占用内核内存,因此有如下限制(可通过 /proc/sys/fs/inotify/ 中的文件调整):

文件名 含义 典型默认值
max_queued_events 单个 inotify 实例的最大排队事件数,超出时产生 IN_Q_OVERFLOW 16384
max_user_instances 每个真实用户 ID 可创建的 inotify 实例数上限 128
max_user_watches 每个真实用户 ID 可创建的监控项数上限 8192

查看当前值:

cat /proc/sys/fs/inotify/max_queued_events
cat /proc/sys/fs/inotify/max_user_instances
cat /proc/sys/fs/inotify/max_user_watches

临时修改(需 root):

echo 32768 > /proc/sys/fs/inotify/max_queued_events

十一、inotify vs dnotify 对比

dnotify 是 Linux 2.4 引入的旧机制,已被 inotify 淘汰。下面是两者的对比:

比较维度 dnotify(旧) inotify(新)
通知方式 发送信号(signal),设计复杂 读取文件描述符,简单直接
监控粒度 只能监控目录 可监控目录或单个文件
文件描述符消耗 每个目录需要一个 fd,大量监控时消耗很多 一个 inotify 实例只需一个 fd
文件系统卸载 占用 fd 导致文件系统无法卸载 无此问题
事件信息精度 只知道目录有变化,不知道是哪个文件 精确告知哪个文件、什么事件
可靠性 某些情况下不可靠 更可靠

十二、练习题解析(文末习题)

原文习题要求:写一个程序,记录某目录树下所有文件的创建、删除、重命名事件,并用 nftw() 递归初始化所有子目录的监控。
关键思路:

IN_CREATE + IN_ISDIR
新建了子目录

IN_DELETE_SELF
目录被删除

IN_CREATE / IN_DELETE
/ IN_MOVED_FROM / IN_MOVED_TO

用 nftw() 遍历整个目录树

对每个子目录调用 inotify_add_watch()

进入事件循环 read()

事件类型?

对新目录再次调用
inotify_add_watch()
加入监控

调用 inotify_rm_watch()
移除监控项

记录日志

十三、总结

inotify 的核心逻辑非常清晰:

  1. inotify_init() 创建一个"监控中心"(返回 fd)
  2. inotify_add_watch() 向监控中心注册感兴趣的路径和事件类型
  3. read() 阻塞等待,有事件时批量读取 inotify_event 结构体
  4. 解析事件:通过 wd 找到是哪个路径,通过 mask 知道发生了什么
  5. 用完后 close(fd) 清理
    inotify 监控不是递归的,需要手动对每个子目录调用 inotify_add_watch(),这是使用时最容易踩的坑。

Linux 信号(Signal)机制

一、什么是信号?

信号是内核或其他进程向某个进程发送的一种通知,告诉它"发生了某件事"。可以把信号理解为软件层面的中断——就像硬件中断会打断 CPU 正在做的事一样,信号会在不可预知的时刻打断进程的正常执行流程。

信号的三种来源

信号的来源
├── 硬件异常  →  执行非法指令、除以零、访问非法内存地址
├── 用户按键  →  Ctrl+C (SIGINT)、Ctrl+Z (SIGTSTP)、Ctrl+\ (SIGQUIT)
└── 软件事件  →  定时器到期、子进程结束、管道对端关闭等

每个信号都有一个唯一的整数编号(从 1 开始),以及一个形如 SIGxxxx 的符号名称,定义在 <signal.h> 中。代码里永远用符号名,不用数字(数字在不同平台可能不同)。

二、信号的生命周期

产生 (Generated)  →  待决 (Pending)  →  投递 (Delivered)  →  处理 (Handled)

用 Mermaid 表示:

事件发生
信号被产生

信号是否
被屏蔽?

信号投递给进程
执行默认动作或处理函数

信号进入待决状态
(pending)

解除屏蔽后
立即投递

  • 产生:某事件触发,信号被生成
  • 待决:信号已产生但尚未被投递(被屏蔽了)
  • 投递:信号真正送达进程,进程做出响应

三、进程收到信号后的默认动作

进程对每种信号都有一个默认动作(disposition),共有五种:

默认动作 含义
term(终止) 进程被杀死,正常终止
core(核心转储+终止) 生成 core dump 文件(供调试器分析),然后终止
ignore(忽略) 内核丢弃该信号,进程毫无感知
stop(停止) 进程执行被挂起
cont(继续) 恢复一个已停止的进程

进程可以改变默认动作

程序可以为某个信号设置新的处置方式(disposition),有三种选择:

  1. 恢复默认动作(SIG_DFL
  2. 忽略该信号(SIG_IGN
  3. 安装一个信号处理函数(signal handler),信号到来时自动调用

注意:SIGKILLSIGSTOP 是"必杀"信号,无法被忽略、屏蔽或捕获,永远执行默认动作。

四、标准信号速查表


信号名 编号(x86) 默认动作 说明
SIGABRT 6 core 调用 abort() 时发送
SIGALRM 14 term 实时定时器(alarm/setitimer)到期
SIGBUS 7 core 内存访问错误(如 mmap 越界)
SIGCHLD 17 ignore 子进程终止或停止
SIGCONT 18 cont 恢复已停止的进程
SIGFPE 8 core 算术异常(如整数除零)
SIGHUP 1 term 终端断开;守护进程常用此信号触发热重载
SIGILL 4 core 非法机器指令
SIGINT 2 term 用户按 Ctrl+C
SIGKILL 9 term 必杀信号,不可屏蔽/忽略/捕获
SIGPIPE 13 term 向无读端的管道/socket写入
SIGQUIT 3 core 用户按 Ctrl+\
SIGSEGV 11 core 非法内存引用(段错误)
SIGSTOP 19 stop 必停信号,不可屏蔽/忽略/捕获
SIGTERM 15 term 标准终止信号(kill 命令默认发此信号)
SIGTSTP 20 stop 用户按 Ctrl+Z(作业控制停止)
SIGUSR1 10 term 用户自定义信号1
SIGUSR2 12 term 用户自定义信号2
SIGWINCH 28 ignore 终端窗口大小改变

五、设置信号处理:signal() 和 sigaction()

5.1 signal()(简单但不推荐)

#include <signal.h>
// 函数原型(用 typedef 简化后)
typedef void (*sighandler_t)(int);
sighandler_t signal(int sig, sighandler_t handler);
// 成功返回之前的处置函数指针,失败返回 SIG_ERR

handler 可以是:

  • 自定义函数的地址(处理函数接受一个 int 参数,即信号编号)
  • SIG_DFL:恢复默认动作
  • SIG_IGN:忽略该信号
    为什么不推荐 signal()?
    不同 UNIX 实现的行为不一致(信号处理函数执行期间是否自动屏蔽该信号等细节各有差异)。移植性差,应优先用 sigaction()

5.2 sigaction()(推荐)

#include <signal.h>
int sigaction(int sig,
              const struct sigaction *act,
              struct sigaction *oldact);
// 成功返回 0,失败返回 -1
struct sigaction {
    void     (*sa_handler)(int);  // 处理函数地址,或 SIG_IGN/SIG_DFL
    sigset_t sa_mask;             // 处理函数执行期间额外屏蔽的信号集
    int      sa_flags;            // 控制选项(位掩码)
    void     (*sa_restorer)(void);// 内核内部使用,应用不要动
};

sa_flags 常用选项:

标志 含义
SA_RESTART 被信号中断的系统调用自动重启
SA_NODEFER 处理函数执行期间不自动屏蔽当前信号
SA_RESETHAND 处理函数执行后自动重置为默认动作(只触发一次)
SA_SIGINFO 处理函数接收额外的信号信息结构体
SA_ONSTACK 在备用栈上执行处理函数

六、信号处理函数的执行流程

主程序正常执行
    │
    │  ... 执行到第 m 条指令 ...
    │
    ↓  ← 此时信号到来,内核打断主程序
    │
    ├──→ [信号处理函数开始执行]
    │         处理函数代码...
    │         return;
    │    [信号处理函数返回]
    │
    ↓  ← 主程序从第 m+1 条指令继续执行
    │
    ... 程序继续 ...

关键点:处理函数返回后,主程序从被中断的地方继续执行(除非处理函数内调用了 exit() 等)。

七、发送信号的方式

7.1 kill():向其他进程发送信号

#include <signal.h>
int kill(pid_t pid, int sig);
// 成功返回 0,失败返回 -1

pid 参数的含义:

pid 值 目标
> 0 发送给进程 ID 为 pid 的进程
= 0 发送给与调用进程同组的所有进程
= -1 发送给调用进程有权限发信号的所有进程(广播,除 init 和自身)
< -1 发送给进程组 ID 为 `

权限规则(非特权进程):发送方的真实用户ID 或 有效用户ID 必须匹配目标进程的真实用户ID 或 保存的设置用户ID

7.2 空信号(null signal):检测进程是否存在

kill(pid, 0);  // sig=0,不实际发送任何信号,只做权限检查
  • 返回 0:进程存在且有权限发信号
  • 返回 -1,errno = EPERM:进程存在,但没权限
  • 返回 -1,errno = ESRCH:进程不存在

7.3 raise():向自己发信号

#include <signal.h>
int raise(int sig);
// 成功返回 0,失败返回非零值

单线程程序中等价于 kill(getpid(), sig)
多线程程序中等价于 pthread_kill(pthread_self(), sig)(信号发给调用线程本身)。
调用 raise() 后,信号立即投递(在 raise() 返回前)。

7.4 killpg():向进程组发送信号

#include <signal.h>
int killpg(pid_t pgrp, int sig);
// 等价于 kill(-pgrp, sig)

八、信号集(Signal Set)

很多系统调用需要同时表示多个信号,使用 sigset_t 类型的信号集。

信号集操作函数

#include <signal.h>
// 初始化(必须先初始化!不能用 memset)
int sigemptyset(sigset_t *set);   // 清空信号集
int sigfillset(sigset_t *set);    // 填入所有信号
// 增删
int sigaddset(sigset_t *set, int sig);   // 添加一个信号
int sigdelset(sigset_t *set, int sig);   // 移除一个信号
// 查询
int sigismember(const sigset_t *set, int sig);
// 返回 1 表示 sig 在集合中,返回 0 表示不在

GNU 扩展(需 _GNU_SOURCE):

int sigandset(sigset_t *dest, sigset_t *left, sigset_t *right); // 交集
int sigorset(sigset_t *dest, sigset_t *left, sigset_t *right);  // 并集
int sigisemptyset(const sigset_t *set);                          // 是否为空

九、信号屏蔽字(Signal Mask)

每个进程都有一个信号屏蔽字——一个信号集,其中的信号在投递时会被阻塞(延迟),直到从屏蔽字中移除为止。

sigprocmask():修改信号屏蔽字

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
// 成功返回 0,失败返回 -1

how 参数:

how 值 效果
SIG_BLOCK 将 set 中的信号加入屏蔽字(取并集)
SIG_UNBLOCK 将 set 中的信号从屏蔽字移除
SIG_SETMASK 用 set 直接替换整个屏蔽字

  • oldset 不为 NULL 时,返回修改前的屏蔽字
  • set 为 NULL 时,仅获取当前屏蔽字(how 被忽略)
  • SIGKILLSIGSTOP 无法被屏蔽,尝试屏蔽会被内核静默忽略
    临时屏蔽某信号的标准写法
sigset_t blockSet, prevMask;
sigemptyset(&blockSet);
sigaddset(&blockSet, SIGINT);
// 屏蔽 SIGINT,保存原屏蔽字
sigprocmask(SIG_BLOCK, &blockSet, &prevMask);
// ... 不希望被 SIGINT 打断的关键代码 ...
// 恢复原屏蔽字(解除屏蔽)
sigprocmask(SIG_SETMASK, &prevMask, NULL);

十、待决信号(Pending Signals)

如果一个信号在被屏蔽期间到来,它会被记录为**待决(pending)**状态,等解除屏蔽后立即投递。

sigpending():查询当前待决信号

#include <signal.h>
int sigpending(sigset_t *set);
// 成功返回 0,将待决信号集写入 set

重要:标准信号不排队!

待决信号集本质是一个位掩码,每种信号只有"有/没有"两种状态,不记录次数。

如果同一信号在被屏蔽期间被发送了 100 万次,解除屏蔽后它只会被投递一次
实验验证:

发送进程向接收进程发送 SIGUSR1 共 1000000 次
接收进程屏蔽信号 15 秒后解除屏蔽
结果:signal 10 (SIGUSR1) caught 1 time

即使不屏蔽信号,发送速度太快时,接收进程来不及调度,多个信号也会被合并:

发送 1000000 次 SIGUSR1
结果:signal 10 caught 52 times(实际收到次数远少于发送次数)

十一、等待信号:pause()

#include <unistd.h>
int pause(void);
// 永远返回 -1,errno 设为 EINTR

pause() 让进程挂起,直到任意信号被处理后才返回(返回值永远是 -1)。

十二、完整示例代码(C++)

示例一:安装 SIGINT 处理函数

// signal_demo1.cpp
// 编译: g++ -o signal_demo1 signal_demo1.cpp
// 运行后按 Ctrl+C 触发处理函数,按 Ctrl+\ 退出
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <unistd.h>
// 信号处理函数:接收信号编号作为参数
// 注意:在处理函数中调用 printf 是不安全的(Section 21.1.2),
// 这里仅用于演示目的
static void sigHandler(int sig) {
    // sig 就是触发本次调用的信号编号
    printf("收到信号 %d (SIGINT),输出 Ouch!\n", sig);
    // 返回后主程序从被中断处继续执行
}
int main() {
    // 用 signal() 为 SIGINT 安装处理函数
    // signal() 返回之前的处置方式(此处我们不关心)
    if (signal(SIGINT, sigHandler) == SIG_ERR) {
        perror("signal");
        return EXIT_FAILURE;
    }
    // 主循环:每隔 3 秒打印一次计数
    for (int j = 0; ; j++) {
        printf("%d\n", j);
        sleep(3);
    }
}

示例二:用 sigaction() 捕获两种信号(推荐写法)

// signal_demo2.cpp
// 编译: g++ -o signal_demo2 signal_demo2.cpp
// 按 Ctrl+C 捕获 SIGINT,按 Ctrl+\ 捕获 SIGQUIT 并退出
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <unistd.h>
// static:限制变量可见性
// volatile:告诉编译器不要对此变量做优化(信号处理函数和主程序都会访问它)
// sig_atomic_t:保证读写是原子操作(在信号处理上下文中安全)
static volatile sig_atomic_t gotSigint = 0;
static int sigintCount = 0;
// 一个处理函数捕获两种信号
// 通过 sig 参数区分是哪个信号触发的
static void sigHandler(int sig) {
    if (sig == SIGINT) {
        sigintCount++;
        // 注意:实际项目不应在信号处理函数中调用 printf
        printf("捕获到 SIGINT(第 %d 次)\n", sigintCount);
        // 返回后主程序继续执行
    } else if (sig == SIGQUIT) {
        printf("捕获到 SIGQUIT,程序退出\n");
        exit(EXIT_SUCCESS);
    }
}
int main() {
    struct sigaction sa;
    // 设置处理函数
    sa.sa_handler = sigHandler;
    // sa_mask:处理函数执行期间额外屏蔽的信号集
    // 这里清空,不额外屏蔽其他信号
    // (触发处理函数的信号本身会被自动屏蔽)
    sigemptyset(&sa.sa_mask);
    // sa_flags = 0:使用默认行为
    sa.sa_flags = 0;
    // 为 SIGINT 安装处理函数
    if (sigaction(SIGINT, &sa, nullptr) == -1) {
        perror("sigaction SIGINT");
        return EXIT_FAILURE;
    }
    // 为 SIGQUIT 安装同一处理函数
    if (sigaction(SIGQUIT, &sa, nullptr) == -1) {
        perror("sigaction SIGQUIT");
        return EXIT_FAILURE;
    }
    printf("程序运行中...\n按 Ctrl+C 触发 SIGINT,按 Ctrl+\\ 触发 SIGQUIT\n");
    // 循环等待信号
    // pause() 在每次信号处理函数返回后被 EINTR 打断,然后重新调用
    for (;;) {
        pause();  // 挂起,等待信号
    }
}

示例三:信号屏蔽与待决信号演示

// signal_mask_demo.cpp
// 编译: g++ -o signal_mask_demo signal_mask_demo.cpp
// 运行后程序屏蔽 SIGINT 5 秒,期间按 Ctrl+C,解除后会看到信号只投递一次
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <unistd.h>
static volatile sig_atomic_t sigintReceived = 0;
static void sigintHandler(int sig) {
    sigintReceived++;
    printf("SIGINT 被投递!(第 %d 次)\n", (int)sigintReceived);
}
int main() {
    // 为 SIGINT 安装处理函数
    struct sigaction sa;
    sa.sa_handler = sigintHandler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    sigaction(SIGINT, &sa, nullptr);
    // 构建包含 SIGINT 的信号集
    sigset_t blockSet, prevMask;
    sigemptyset(&blockSet);
    sigaddset(&blockSet, SIGINT);  // 只屏蔽 SIGINT
    // 屏蔽 SIGINT,保存原来的屏蔽字
    if (sigprocmask(SIG_BLOCK, &blockSet, &prevMask) == -1) {
        perror("sigprocmask block");
        return EXIT_FAILURE;
    }
    printf("SIGINT 已被屏蔽,请在 5 秒内多次按 Ctrl+C ...\n");
    sleep(5);
    // 查询当前待决信号
    sigset_t pendingSet;
    if (sigpending(&pendingSet) == -1) {
        perror("sigpending");
        return EXIT_FAILURE;
    }
    // 检查 SIGINT 是否在待决集中
    if (sigismember(&pendingSet, SIGINT)) {
        printf("SIGINT 现在处于待决状态(即将在解除屏蔽后投递)\n");
    } else {
        printf("没有待决的 SIGINT\n");
    }
    // 恢复原屏蔽字,解除对 SIGINT 的屏蔽
    // 待决的 SIGINT 会立刻被投递(不管按了多少次 Ctrl+C,只投递一次)
    printf("解除屏蔽...\n");
    if (sigprocmask(SIG_SETMASK, &prevMask, nullptr) == -1) {
        perror("sigprocmask restore");
        return EXIT_FAILURE;
    }
    printf("解除屏蔽完成,SIGINT 共被投递 %d 次\n", (int)sigintReceived);
    return EXIT_SUCCESS;
}

示例四:用 kill() 发信号 / 检测进程是否存在

// kill_demo.cpp
// 编译: g++ -o kill_demo kill_demo.cpp
// 用法: ./kill_demo <pid> <sig>
//   sig=0 时检测进程是否存在
//   例:./kill_demo 1234 0   检测 PID 1234 是否存在
//   例:./kill_demo 1234 15  向 PID 1234 发 SIGTERM
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cerrno>
#include <sys/types.h>
int main(int argc, char* argv[]) {
    if (argc != 3) {
        fprintf(stderr, "用法: %s <pid> <sig>\n", argv[0]);
        fprintf(stderr, "  sig=0 检测进程是否存在\n");
        return EXIT_FAILURE;
    }
    pid_t pid = (pid_t)atol(argv[1]);
    int   sig = atoi(argv[2]);
    int result = kill(pid, sig);
    if (sig != 0) {
        // 实际发送信号
        if (result == -1) {
            perror("kill");
            return EXIT_FAILURE;
        }
        printf("信号 %d 已发送给进程 %ld\n", sig, (long)pid);
    } else {
        // sig=0:空信号,用于检测进程是否存在
        if (result == 0) {
            printf("进程 %ld 存在,且我们有权向它发信号\n", (long)pid);
        } else {
            if (errno == EPERM) {
                printf("进程 %ld 存在,但我们没有权限向它发信号\n", (long)pid);
            } else if (errno == ESRCH) {
                printf("进程 %ld 不存在\n", (long)pid);
            } else {
                perror("kill");
                return EXIT_FAILURE;
            }
        }
    }
    return EXIT_SUCCESS;
}

十三、信号处理的整体架构图

SIG_DFL

SIG_IGN

自定义处理函数

term

core

stop

cont

ignore

进程运行中

收到信号?

信号是否
在屏蔽字中?

记录为待决信号
(pending set)

屏蔽解除?

信号投递

进程的处置方式?

默认动作类型?

信号被忽略
进程无感知

执行信号处理函数
处理函数返回后
继续执行主程序

进程终止

产生 core dump
进程终止

进程挂起

进程恢复执行

十四、关键概念对比总结


概念 解释
信号产生 某事件触发,信号被创建
信号待决 信号已产生但被屏蔽,尚未投递
信号投递 信号真正送达进程,进程执行相应动作
信号屏蔽字 每个进程独有的"黑名单",其中的信号暂时不投递
信号处理函数 程序员定义的函数,信号来临时自动被内核调用
信号不排队 标准信号只有"有/没有",重复发送只投递一次
SIGKILL/SIGSTOP 无敌信号,不可屏蔽、不可忽略、不可捕获

十五、常见坑总结

坑1:signal() 的可移植性问题
不同平台行为不一致,始终用 sigaction() 代替。
坑2:在信号处理函数里调用不安全的函数
printf()malloc() 等都不是"异步信号安全(async-signal-safe)"函数,在信号处理函数中调用可能死锁。实际项目应只在处理函数中修改 volatile sig_atomic_t 类型的标志变量,在主循环中检查标志。
坑3:用 kill(pid, 0) 检测进程存在性不可靠

  • 内核会重用进程 ID:原进程死亡后,同一 PID 可能被新进程占用
  • 进程可能已成为**僵尸(zombie)**状态(已死但父进程尚未 wait)
    坑4:忘记初始化信号集
    不能用 memset 把信号集清零,必须用 sigemptyset()sigfillset() 初始化。
    坑5:SIGTERM vs SIGKILL
    应先发 SIGTERM(给程序机会优雅退出),只有程序不响应时才用 SIGKILL 强杀。

十六、练习题思路(文末习题)

20-1:用 sigaction() 改写 signal()
signal(n, handler) 替换为:

struct sigaction sa;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(n, &sa, nullptr);

20-2:验证待决信号改为 SIG_IGN 后不会被投递
屏蔽信号 → 发送信号(进入待决)→ 改 disposition 为 SIG_IGN → 解除屏蔽 → 信号消失,处理函数不会被调用。
20-3:验证 SA_RESETHAND 和 SA_NODEFER

  • SA_RESETHAND:处理函数只会被调用一次,之后自动恢复为 SIG_DFL
  • SA_NODEFER:处理函数执行期间,同一信号再次到来不会被屏蔽(可能递归调用处理函数)
    20-4:用 sigaction() 实现 siginterrupt()
    siginterrupt(sig, 1) 的效果是:被 sig 中断的系统调用不自动重启,对应 sa_flags 不设 SA_RESTART
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐