返回

Linux 如何查找文件/目录对应的块设备?(含代码)

Linux

如何通过文件/目录路径找到对应的块设备文件

咱们在 Linux 系统上操作文件时,有时候会想知道某个文件或者目录到底存在哪个物理设备上。比如说,你想知道 /tmp 目录具体是在 /dev/sda1 还是 /dev/nvme0n1p2 上。命令行里用 df /tmp/ 能轻松看到这个信息:

$ df /tmp/
Filesystem     1K-blocks      Used Available Use% Mounted on
/dev/sdc       1055762868 173695480 828363916  18% /

上面的输出告诉我们,/tmp/ 所在的挂载点 / 是由块设备 /dev/sdc 提供的。问题是,怎么在程序里(比如用 C 语言)获取这个 /dev/sdc 呢?

为啥需要这么做?

理解文件、文件系统和块设备的关系是关键。文件和目录并不是直接存储在硬盘上的某个扇区,它们是文件系统这个抽象层的一部分。文件系统(比如 ext4, XFS, Btrfs)负责组织数据、管理元数据(文件名、权限、时间戳等)。

而文件系统本身,需要被“挂载”到某个目录(挂载点)上才能访问。这个文件系统的数据,最终是存储在某个块设备上的。这个块设备可以是一个物理硬盘分区(/dev/sda1)、一个 LVM 卷、一个 RAID 阵列,甚至是网络存储设备。

所以,从文件路径找到块设备的过程,实际上是:

  1. 找到文件所在的文件系统。
  2. 找到该文件系统的挂载点。
  3. 找到挂载该文件系统的块设备源。

df 命令就是干这个的。那程序里该怎么实现呢?

解决方案

有几种办法可以实现这个需求,各有优劣。

方法一:使用 stat 系列系统调用和 /sys/proc 文件系统

这是最底层、最接近系统内核工作方式的方法。它不依赖外部命令,比较健壮。

原理和作用

  1. 获取设备 ID (st_dev) : 对于给定的文件或目录路径,可以使用 stat() 或者 lstat() 系统调用(如果是符号链接本身,用 lstat;如果是链接指向的目标,用 stat)。这两个函数会填充一个 struct stat 结构体。其中,st_dev 成员包含了文件所在设备的唯一标识符(设备号)。这个设备号是一个整数,由主设备号(major number)和次设备号(minor number)组成。主设备号通常标识设备驱动类型(比如 SCSI 硬盘、NVMe 硬盘),次设备号则用来区分同一类型的多个设备。
  2. 查找设备名称 : 获取 st_dev 后,咱们需要找到哪个块设备文件(比如 /dev/sda1)也拥有相同的设备号。这些块设备文件的信息通常可以在 /sys/block/ 目录下或者 /proc/partitions 文件里找到。
    • /sys/block/: 这是比较现代的方式。可以遍历 /sys/block/ 下的每个设备目录(如 /sys/block/sda/),再检查里面的分区目录(如 /sys/block/sda/sda1/)。每个分区目录下都有一个 dev 文件,里面存的就是该分区的主次设备号(格式通常是 major:minor)。
    • /proc/partitions: 这是一个文本文件,列出了内核识别的所有分区及其主次设备号。解析这个文件也可以达到目的。格式一般是 major minor #blocks name

通过比较 stat() 获取的 st_dev (分解成主次设备号)和 /sys/proc 中找到的主次设备号,就能匹配到具体的设备名称。

C 代码示例

#include <stdio.h>
#include <stdlib.h>
#include <sys/sysmacros.h> // For major() and minor()
#include <sys/stat.h>
#include <string.h>
#include <dirent.h> // For opendir, readdir, closedir

// 函数:根据设备号(dev_t)查找块设备名称
char* find_device_name(dev_t target_dev) {
    DIR *block_dir;
    struct dirent *entry;
    char path[1024];
    static char device_name[256]; // Use static buffer for simplicity, be careful in multi-threaded env

    // 尝试 /sys/block/ 方式
    block_dir = opendir("/sys/block");
    if (block_dir) {
        while ((entry = readdir(block_dir)) != NULL) {
            if (entry->d_name[0] == '.') continue; // Skip "." and ".."

            snprintf(path, sizeof(path), "/sys/block/%s", entry->d_name);
            DIR *device_dir = opendir(path);
            if (device_dir) {
                struct dirent *part_entry;
                while ((part_entry = readdir(device_dir)) != NULL) {
                    if (part_entry->d_name[0] == '.') continue;

                    // 检查设备本身(如 sda)和分区(如 sda1)
                    // 分区目录名通常包含设备名
                    if (strstr(part_entry->d_name, entry->d_name) != NULL) {
                        snprintf(path, sizeof(path), "/sys/block/%s/%s/dev", entry->d_name, part_entry->d_name);

                        FILE *dev_file = fopen(path, "r");
                        if (dev_file) {
                            unsigned int maj, min;
                            if (fscanf(dev_file, "%u:%u", &maj, &min) == 2) {
                                fclose(dev_file);
                                if (makedev(maj, min) == target_dev) {
                                    snprintf(device_name, sizeof(device_name), "/dev/%s", part_entry->d_name);
                                    closedir(device_dir);
                                    closedir(block_dir);
                                    return device_name;
                                }
                            } else {
                                fclose(dev_file);
                            }
                        }
                        // 同时检查设备自身,例如整个磁盘 sda, nvme0n1
                        snprintf(path, sizeof(path), "/sys/block/%s/dev", entry->d_name);
                         dev_file = fopen(path, "r");
                        if (dev_file) {
                            unsigned int maj, min;
                            if (fscanf(dev_file, "%u:%u", &maj, &min) == 2) {
                                fclose(dev_file);
                                if (makedev(maj, min) == target_dev) {
                                     snprintf(device_name, sizeof(device_name), "/dev/%s", entry->d_name);
                                    closedir(device_dir);
                                    closedir(block_dir);
                                    return device_name;
                                }
                            } else {
                                 fclose(dev_file);
                             }
                        }
                    }
                }
                closedir(device_dir);
            }
        }
        closedir(block_dir);
    }

    // 如果 /sys 没找到,可以尝试 /proc/partitions (这里省略代码,原理类似,解析文件内容)
    // ...

    return NULL; // 未找到
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <file_or_directory_path>\n", argv[0]);
        return 1;
    }

    const char *path = argv[1];
    struct stat file_stat;

    // 使用 stat 获取文件信息
    if (stat(path, &file_stat) == -1) {
        perror("stat failed");
        return 1;
    }

    printf("Path: %s\n", path);
    printf("Device ID (st_dev): %lu (major: %u, minor: %u)\n",
           (unsigned long)file_stat.st_dev,
           major(file_stat.st_dev),
           minor(file_stat.st_dev));

    // 查找对应的块设备名
    char *device_path = find_device_name(file_stat.st_dev);

    if (device_path) {
        printf("Mapped Block Device: %s\n", device_path);
    } else {
        printf("Could not find matching block device in /sys/block/.\n");
        // 这里可以加上 /proc/partitions 的查找逻辑
    }

    return 0;
}

编译和运行:

gcc find_dev.c -o find_dev
./find_dev /tmp
# 可能的输出:
# Path: /tmp
# Device ID (st_dev): 64769 (major: 253, minor: 1)
# Mapped Block Device: /dev/dm-1 # 注意:可能是 LVM 或其他设备映射
# 或者
# Path: /home/user
# Device ID (st_dev): 66305 (major: 259, minor: 1)
# Mapped Block Device: /dev/nvme0n1p1

命令行等效操作

不写 C 代码,用 shell 命令也能模拟这个过程:

  1. 获取文件的设备号(十六进制格式):

    stat -c '%D' /tmp/
    # 输出类似 fd01 (这是十六进制的设备号 major=253, minor=1)
    

    或者获取十进制主次设备号:

    stat -c 'Maj:%t Min:%T' /tmp/
    # 输出类似 Maj:fd Min:1 (这里用 %t 和 %T 显示的是十六进制)
    # 要获取十进制可以用 C 代码里的 major() minor() 或者间接通过 /proc/mounts
    

    一个更直接的方式可能是结合 dfstat:

    # 获取文件所在的文件系统挂载点
    mount_point=$(df --output=target "$path" | tail -n 1)
    # 获取挂载点的信息,包含设备号
    stat -c '%n: DeviceID=%d (Maj=%t, Min=%T)' "$mount_point"
    
  2. 查找匹配的设备:

    # 假设上面 stat 得到主设备号 253, 次设备号 1 (fd:1)
    # 在 /sys/block 中查找
    find /sys/block/ -name dev -exec sh -c '
        dev_info=$(cat "$1"); maj=$(echo "$dev_info" | cut -d: -f1); min=$(echo "$dev_info" | cut -d: -f2);
        # 目标是 253:1
        if [ "$maj" -eq 253 ] && [ "$min" -eq 1 ]; then
            dev_path=$(dirname "$1");
            dev_name=$(basename "$dev_path");
            # 处理整个设备和分区的情况
            parent_dir=$(dirname "$dev_path")
            if [ "$(basename $parent_dir)" = "block" ]; then # 如 /sys/block/sda/dev
                echo "/dev/$dev_name"
            else # 如 /sys/block/sda/sda1/dev
                 echo "/dev/$dev_name"
             fi
        fi
    ' sh {} \;
    
    # 或者解析 /proc/partitions
    awk '$1 == 253 && $2 == 1 {print "/dev/"$4}' /proc/partitions
    

这些命令比较繁琐,不如 C 代码灵活,但在脚本中临时用用还行。

安全建议

  • 权限:读取 /sys/block/proc/partitions 通常需要普通用户权限即可。但访问某些特殊设备信息可能需要 root 权限。stat() 系统调用本身对能访问的文件/目录都有效。
  • 路径处理:代码需要健壮地处理用户提供的路径,防止路径遍历等安全问题(虽然这里主要是读取信息)。对 C 代码,注意缓冲区溢出,使用 snprintf 等安全函数。
  • 错误处理:上面的 C 示例比较基础,实际使用中应添加更完善的错误检查(比如 opendirfopenfscanf 的返回值检查)。

进阶使用技巧

  • statvfs() : 如果你更关心文件系统层面的信息(比如文件系统类型、总空间、可用空间),而不是非得拿到块设备名,statvfs() 是个好选择。它操作的是挂载的文件系统,其返回的 struct statvfs 中没有直接的设备号 st_dev,但提供了文件系统 ID f_fsid。这在某些场景下(如 NFS)可能更有用,但在本地块设备映射上不如 stat 直接。
  • 符号链接 : 注意 stat()lstat() 的区别。如果给定的路径是个符号链接,stat() 会解析链接,获取目标文件的信息(包括 st_dev),而 lstat() 获取的是链接文件本身的信息。链接文件通常和它所在的目录共享 st_dev。你需要根据具体需求选择用哪个。
  • 复杂存储 : LVM(逻辑卷管理)、Device Mapper(如 dm-crypt 加密卷)、RAID 等技术会创建虚拟块设备(比如 /dev/mapper/vg-lv/dev/md0)。stat().st_dev 通常会指向这个虚拟设备的主次设备号。find_device_name 函数也需要能正确处理 /sys/block/dm-*//sys/block/md*/ 等目录下的 dev 文件。上面示例代码中的查找逻辑已经考虑了遍历 /sys/block/ 下的所有设备,理论上能覆盖这些情况。

方法二:调用并解析 dffindmnt 命令的输出

这种方法更简单直接,特别是在脚本语言(如 Shell, Python)中,但有其局限性。

原理和作用

利用现成的工具,比如 dffindmnt,它们内部已经实现了从路径到块设备的查找逻辑。咱们只需要执行这些命令,然后用文本处理工具(如 awk, grep, sed)或者编程语言的字符串处理功能,从输出中提取所需的信息(通常是第一列的设备名)。

  • df <path>: 显示指定路径所在文件系统的磁盘使用情况,第一列就是设备名(或源)。
  • findmnt -n -o SOURCE --target <path>findmnt -n -o SOURCE <path>: findmnt 是一个更现代、功能更强的工具,专门用来查找挂载点信息。-n 去掉表头,-o SOURCE 只输出源设备,--target <path> 查找包含该路径的最内层挂载点,或者直接用 findmnt <path> 让它自动查找。

命令行示例

# 使用 df
df --output=source /tmp/ | tail -n 1
# 输出: /dev/sdc (或者对应的设备名)

# 使用 findmnt (推荐)
# 查找包含 /tmp 的挂载点的源设备
findmnt -n -o SOURCE --target /tmp/
# 输出: /dev/sdc

# 或者直接让 findmnt 找出路径对应的设备
findmnt -n -o SOURCE /tmp/some/deep/file.txt
# 输出: /dev/sdc (或其他设备)

Shell 脚本示例

#!/bin/bash

target_path="$1"

if [ -z "$target_path" ]; then
  echo "Usage: $0 <file_or_directory_path>"
  exit 1
fi

# 检查路径是否存在
if [ ! -e "$target_path" ]; then
  echo "Error: Path '$target_path' does not exist."
  exit 1
fi

# 使用 findmnt 获取设备名
device_name=$(findmnt -n -o SOURCE --target "$target_path")

# 备选:使用 df (如果findmnt不可用或行为不符合预期)
# device_name=$(df --output=source "$target_path" | tail -n 1)

if [ -n "$device_name" ]; then
  echo "Path: $target_path"
  echo "Mapped Block Device: $device_name"
else
  echo "Could not determine the block device for '$target_path'."
  # 可能是路径无效、权限问题,或者是非常规挂载(如 FUSE)
  exit 1
fi

exit 0

优缺点

  • 优点 : 实现简单快速,代码量少,尤其适合脚本。
  • 缺点 :
    • 依赖外部命令 (df, findmnt) 必须存在且行为符合预期。命令输出格式如果发生变化(虽然不太可能大幅变动),脚本就可能失效。
    • 性能开销比直接调用系统调用大(需要创建进程、执行命令、捕获输出)。
    • 错误处理相对麻烦,需要解析命令的退出码和标准错误输出。
    • 不够底层,无法获取更详细的设备信息(比如主次设备号)。

安全建议

  • 路径注入:如果脚本接受外部输入的路径,务必进行校验,防止恶意构造的路径(比如包含命令注入字符)被传递给 dffindmnt。虽然这些工具本身相对安全,但好的编程习惯是校验所有外部输入。
  • 确保 dffindmnt 来自可信来源,防止被替换成恶意版本。

其他考虑因素

  • 网络文件系统 (NFS, CIFS等) : 对于挂载的网络文件系统上的文件,stat().st_dev 可能是一个伪设备号,或者 df / findmnt 会显示服务器端的路径(如 server:/share),而不是本地块设备。这符合预期,因为数据确实不在本地块设备上。
  • 绑定挂载 (Bind Mounts) : mount --bind olddir newdir 会让 newdir 看起来和 olddir 内容一样。对于 newdir 下的文件,stat().st_devdf/findmnt 通常会正确地指向 olddir 原始文件系统所在的块设备。
  • 容器/命名空间 : 在 Docker 等容器环境中,文件系统和设备的视图可能与宿主机不同。在容器内执行上述方法,通常会得到容器内看到的设备名或挂载源,这可能是一个虚拟设备,也可能通过命名空间映射到宿主机的某个设备,具体取决于容器的配置。

选择哪种方法取决于你的具体需求:

  • 如果是在 C/C++ 程序中需要高可靠性、高性能,并且可能需要处理复杂情况(如特定错误处理、获取更底层信息),优先选择方法一:使用 stat/sys
  • 如果只是在 Shell 脚本中快速实现功能,或者对性能要求不高,方法二:调用 dffindmnt 通常更方便。 findmntdf 在解析上可能更可靠。