2.5 文件属性、扩展属性与 ACL
stat 的哪些字段会随操作变化?为什么 st_size 与 st_blocks 能差一个数量级?扩展属性(xattr)和 ACL 到底存在哪?chmod 为什么会让 ACL 的 mask 变?
用 lstat/fstatat 正确区分符号链接;解释三个时间戳与稀疏文件;用 xattr 存元数据并处理 ERANGE;读写 ACL 并说清 mask 与 chmod 的交互。
2.2 把文件当成「字节流」,2.3 加上了映射与锁。但文件还有另一半:它本身是什么——类型、权限、属主、大小、时间、扩展元数据。ls -l 显示的每一列,背后都是 stat 结构的一个字段;getfacl 显示的每条规则,落盘时是一个名为 system.posix_acl_access 的扩展属性。
这一章的主线是「元数据也是数据」:它同样占空间、同样有并发问题、同样有缓存与持久化语义。理解这一点,你才能解释为什么 du 和 ls -l 对同一个文件给出不同大小,为什么拷文件会丢 ACL,为什么 chmod 640 会悄悄改掉 ACL 的 mask。
2.5.1 stat 家族与 struct stat
#include <sys/stat.h>
int stat(const char *path, struct stat *buf); /* 跟随符号链接 */
int lstat(const char *path, struct stat *buf); /* 不跟随,看链接本身 */
int fstat(int fd, struct stat *buf); /* 已打开的 fd */
int fstatat(int dirfd, const char *path, struct stat *buf, int flags);
成功返回 0;失败返回 -1,errno 常见:
ENOENT:路径不存在(或路径中间某级不存在);EACCES:路径中某一级目录没有搜索权限;ELOOP:符号链接层数超过限制(通常 40);ENAMETOOLONG:路径或某一段太长;EOVERFLOW:文件大小/ino 超出stat结构能表示的范围(32 位程序读大文件)。
fstatat 的 flags 里最有用的是 AT_SYMLINK_NOFOLLOW(等价于 lstat)和 AT_EMPTY_PATH(对 dirfd 本身取属性,需要 CAP_DAC_READ_SEARCH)。fstatat(dirfd, "name", ..., 0) 与 stat 的区别是:路径相对 dirfd 解析,不受进程 cwd 变化影响。
字段逐个看
| 字段 | 含义 | 什么时候变 |
|---|---|---|
st_dev | 所在文件系统的设备号 | 挂载/移动文件跨设备 |
st_ino | inode 号 | 文件创建时分配,rename 不变 |
st_mode | 类型 + 权限 + 特殊位 | chmod、chown、创建 |
st_nlink | 硬链接数 | link/unlink |
st_uid/st_gid | 属主/属组 | chown |
st_rdev | 设备文件的设备号 | 仅对块/字符设备有意义 |
st_size | 文件逻辑大小(字节) | write、truncate、ftruncate |
st_blksize | 最优 I/O 块大小 | 文件系统决定,通常 4096 |
st_blocks | 已分配的 512 字节块数 | 实际占用的磁盘空间 |
st_atime | 最后访问时间 | 读(受 relatime 影响) |
st_mtime | 最后修改时间 | 写内容 |
st_ctime | inode 状态改变时间 | chmod/chown/link/rename |
st_ctime 不是「创建时间」。Linux 上 struct stat 没有创建时间;要拿 btime 用 statx(2.5.7)。ctime 会随元数据修改而更新,mtime 只在内容变时更新。
- 「
stat和lstat一样」——对符号链接不同:stat跟随到目标,lstat返回链接本身(S_IFLNK,大小是目标路径长度)。 - 「
st_size就是占用的磁盘空间」——不是。稀疏文件、压缩、块对齐都会让st_blocks * 512与st_size不同。 - 「
st_ctime是创建时间」——是 inode 状态变更时间。chmod会改它,创建后改内容不会改它(改的是mtime)。 - 「
stat返回的字段是一次快照」——是快照,但拿到后文件可能立刻被改;不要用stat的结果做安全判断(TOCTOU)。
2.5.2 文件类型、权限位与检查算法
st_mode 的高 4 位是类型,低 12 位是权限与特殊位。判断类型必须用宏,不要手写掩码:
S_ISREG(m) /* 普通文件 */
S_ISDIR(m) /* 目录 */
S_ISLNK(m) /* 符号链接(仅 lstat 能拿到) */
S_ISCHR(m) /* 字符设备 */
S_ISBLK(m) /* 块设备 */
S_ISFIFO(m) /* FIFO/管道 */
S_ISSOCK(m) /* socket */
权限位:
| 位 | 八进制 | 对文件 | 对目录 |
|---|---|---|---|
S_IRUSR | 0400 | 读内容 | 列出目录项 |
S_IWUSR | 0200 | 写内容 | 创建/删除目录项 |
S_IXUSR | 0100 | 执行 | 进入/查找 |
S_ISUID | 04000 | 执行时 euid=文件属主 | (忽略) |
S_ISGID | 02000 | 执行时 egid=文件属组 | 新文件继承目录属组 |
S_ISVTX | 01000 | (忽略) | sticky:只有属主/目录属主/root 能删 |
权限检查算法
内核检查 open/exec/mkdir 时按这个顺序:
euid == 0:读/写直接放行;执行要求至少一个x位(或CAP_DAC_OVERRIDE/CAP_DAC_READ_SEARCH)。euid == st_uid:用 owner 三位。egid == st_gid或st_gid在辅助组里:用 group 三位。- 否则用 other 三位。
目录的 x 位是「能否穿过」,r 是「能否列出名字」,w 是「能否增删目录项」。只给 r 不给 x,ls 能看到名字但 stat 失败;只给 x 不给 r,知道路径就能访问,但列不出目录。
umask 是创建时的掩码:open(..., 0666) 的实际权限是 0666 & ~umask。umask(2) 返回旧值,永不失败:
mode_t old = umask(027); /* 新文件:组只读、其他无权限 */
int fd = open(path, O_CREAT | O_WRONLY, 0666);
umask(old); /* 恢复;umask 是进程级状态,多线程共享 */
umask 是进程级(不是线程级)状态,工作线程里改它会影响其他线程;在启动时设一次,或创建后显式 fchmod。
access() 的竞态
if (access(path, W_OK) == 0) { /* 用真实 UID 检查 */
int fd = open(path, O_WRONLY); /* 用有效 UID 打开 */
}
两个问题:
- 身份不一致:
access用 real UID/GID,open用 effective。setuid 程序里access说「不可写」,open却可能成功,反之亦然。要按 effective 检查,用faccessat(AT_FDCWD, path, mode, AT_EACCESS)(或faccessat2)。 - TOCTOU:检查与打开之间,文件可能被替换或权限被改。安全检查不要用
access做门禁;直接open,失败就处理EACCES。
stat 只返回「权限位是什么」,不告诉你「当前进程能不能」。能不能取决于 euid/egid/辅助组/capability,这些是进程的属性,不是文件的。把两者混在一起,就会出现「我 stat 看到 0644,为什么还是打不开」——因为目录没有 x,或者文件系统只读,或者 LSM 拦了。
完整示例:打印 stat 的全部关键字段
/* showstat.c — 用 lstat 看文件本身,不跟随符号链接 */
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <time.h>
static char type_char(mode_t m)
{
if (S_ISREG(m)) return '-';
if (S_ISDIR(m)) return 'd';
if (S_ISLNK(m)) return 'l';
if (S_ISCHR(m)) return 'c';
if (S_ISBLK(m)) return 'b';
if (S_ISFIFO(m)) return 'p';
if (S_ISSOCK(m)) return 's';
return '?';
}
static void show_time(const char *label, time_t t)
{
struct tm tm;
char buf[32];
if (!localtime_r(&t, &tm)) return;
if (strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", &tm) == 0) return;
printf(" %s=%s", label, buf);
}
int main(int argc, char *argv[])
{
if (argc != 2) { fprintf(stderr, "usage: %s PATH\n", argv[0]); return 1; }
struct stat st;
if (lstat(argv[1], &st) == -1) { perror("lstat"); return 1; }
printf("%s\n", argv[1]);
printf(" type=%c mode=%04o nlink=%lu uid=%u gid=%u\n",
type_char(st.st_mode), (unsigned)st.st_mode & 07777,
(unsigned long)st.st_nlink, (unsigned)st.st_uid, (unsigned)st.st_gid);
printf(" size=%lld blocks=%lld blksize=%ld ino=%llu\n",
(long long)st.st_size, (long long)st.st_blocks,
(long)st.st_blksize, (unsigned long long)st.st_ino);
show_time("atime", st.st_atime);
show_time("mtime", st.st_mtime);
show_time("ctime", st.st_ctime);
putchar('\n');
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 showstat.c -o showstat
./showstat /etc/hosts
./showstat /dev/null
ln -s /etc/hosts /tmp/hosts.link && ./showstat /tmp/hosts.link # type=l, size=10
最后一行是关键:lstat 对符号链接返回 type=l,st_size 是目标路径 "/etc/hosts" 的长度(10),不是目标文件的大小。
2.5.3 st_size、st_blocks 与三个时间
稀疏文件:逻辑大小 ≠ 占用空间
truncate -s 1G sparse.img
ls -lh sparse.img # -rw-r--r-- 1 u u 1.0G ...
du -h sparse.img # 0
stat -c 'size=%s blocks=%b blksize=%B' sparse.img
# size=1073741824 blocks=0 blksize=512
st_size 是 1 GiB,st_blocks 是 0:文件有 1 GiB 的逻辑长度,但没有分配任何数据块。读这些空洞返回 0,不触发磁盘 I/O。fallocate 才会真正分配(见 2.3)。
st_blocks 的单位固定是 512 字节,与 st_blksize 无关。占用字节数 = st_blocks * 512。
三个时间
| 时间 | 更新时机 | 能否设置 |
|---|---|---|
atime | 读文件(默认 relatime:atime 早于 mtime 或超过 24h 才更新) | utimensat |
mtime | 写内容、truncate | utimensat |
ctime | inode 变更:chmod/chown/link/rename | 不能 |
#include <sys/stat.h>
int utimensat(int dirfd, const char *path, const struct timespec times[2], int flags);
int futimens(int fd, const struct timespec times[2]);
times[0] 是 atime,times[1] 是 mtime。两个特殊值:UTIME_NOW(现在)、UTIME_OMIT(不改)。flags 可带 AT_SYMLINK_NOFOLLOW。返回 0/-1;errno:EACCES(无权)、EPERM(非属主且无 CAP_FOWNER)、EROFS、EINVAL。
struct timespec ts[2] = {
{ .tv_nsec = UTIME_OMIT }, /* 不动 atime */
{ .tv_sec = 1700000000, .tv_nsec = 0 }, /* 固定 mtime */
};
if (utimensat(AT_FDCWD, path, ts, 0) == -1)
errExit("utimensat");
2.5.4 chmod、chown 与特殊位
int chmod(const char *path, mode_t mode); /* 跟随符号链接 */
int fchmod(int fd, mode_t mode);
int fchmodat(int dirfd, const char *path, mode_t mode, int flags);
int chown(const char *path, uid_t owner, gid_t group);
int fchown(int fd, uid_t owner, gid_t group);
int lchown(const char *path, uid_t owner, gid_t group); /* 不跟随 */
返回值都是 0/-1。chmod 的 errno:EPERM(不是属主且无 CAP_FOWNER)、EROFS、ENOENT。chown 额外有 EINVAL(uid/gid 非法)、EPERM;chown 成功会清掉 setuid/setgid 位(安全设计;Linux 2.2.13 起 root 也一样)。
chmod 只传低 12 位;文件类型位会被忽略。fchmodat 的 flags 在 glibc 里只支持 0 或 AT_SYMLINK_NOFOLLOW(后者在 Linux 上对符号链接返回 ENOTSUP)。
- 「
chmod 777能解决所有权限问题」——目录还需要x才能进入;文件系统可能只读;SELinux/AppArmor 可能仍然拒绝。 - 「
chown只改属主」——它还会清 setuid/setgid,可能让原本能提权的程序失效。 - 「
access()判断的是有效 UID」——它用真实 UID;setuid 程序里必须用faccessat(..., AT_EACCESS)。 - 「
umask只影响open」——mkdir、mkfifo、mknod都受它影响;它还是进程级共享状态。
2.5.5 扩展属性:xattr
xattr 是「挂在 inode 上的键值对」,名字是 namespace.name 形式。它不是文件内容,但随 inode 持久化,cp -a/tar --xattrs 才会带走。
| 命名空间 | 谁能写 | 典型用途 |
|---|---|---|
user.* | 文件属主(受文件权限约束) | 应用自定义元数据 |
trusted.* | CAP_SYS_ADMIN | 系统级标记 |
security.* | LSM/特权进程 | SELinux label、security.capability |
system.* | 内核 | POSIX ACL、system.posix_acl_access |
限制(ext4/XFS):名字 ≤ 255 字节,值 ≤ 64 KiB,所有 xattr 总和受 inode 空间限制;符号链接和特殊文件通常不允许 user.*。
#include <sys/xattr.h>
int setxattr(const char *path, const char *name, const void *value, size_t size, int flags);
ssize_t getxattr(const char *path, const char *name, void *value, size_t size);
ssize_t listxattr(const char *path, char *list, size_t size);
int removexattr(const char *path, const char *name);
返回语义:
setxattr/removexattr:0/-1;flags可带XATTR_CREATE(已存在则EEXIST)或XATTR_REPLACE(不存在则ENODATA)。getxattr:返回值是属性值长度;size不够返回 -1、errno=ERANGE;属性不存在返回 -1、errno=ENODATA。listxattr:返回名字列表的总字节数,名字之间以\0分隔;size不够ERANGE。
标准的两步查询(先问大小,再取):
ssize_t n = getxattr(path, name, NULL, 0); /* 只查长度 */
if (n == -1) { /* ENODATA / ENOTSUP */ }
char *buf = malloc((size_t)n + 1);
n = getxattr(path, name, buf, (size_t)n);
buf[n] = '\0';
getxattr(path, name, NULL, 0) 对存在的属性返回长度;对不存在的属性返回 ENODATA。listxattr 在没有任何属性时返回 0,malloc(0) 可能返回 NULL,要单独处理。
完整示例:读写与枚举
/* xattr_demo.c — set/get/list/remove user.* 扩展属性 */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/xattr.h>
static void die(const char *what)
{
fprintf(stderr, "%s: %s\n", what, strerror(errno));
exit(1);
}
int main(int argc, char *argv[])
{
if (argc != 2) { fprintf(stderr, "usage: %s FILE\n", argv[0]); return 1; }
const char *path = argv[1];
const char *name = "user.release";
const char *value = "2026.10";
if (setxattr(path, name, value, strlen(value), 0) == -1)
die("setxattr");
ssize_t n = getxattr(path, name, NULL, 0);
if (n == -1) die("getxattr size");
char *buf = malloc((size_t)n + 1);
if (!buf) die("malloc");
n = getxattr(path, name, buf, (size_t)n);
if (n == -1) die("getxattr");
buf[n] = '\0';
printf("%s = %s\n", name, buf);
free(buf);
ssize_t len = listxattr(path, NULL, 0);
if (len == -1) die("listxattr size");
if (len > 0) {
char *list = malloc((size_t)len);
if (!list) die("malloc");
len = listxattr(path, list, (size_t)len);
if (len == -1) die("listxattr");
for (ssize_t i = 0; i < len; i += (ssize_t)strlen(list + i) + 1)
printf("xattr: %s\n", list + i);
free(list);
}
if (removexattr(path, name) == -1) die("removexattr");
printf("removed %s\n", name);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 xattr_demo.c -o xattr_demo
touch /tmp/x.txt && ./xattr_demo /tmp/x.txt
getfattr -d /tmp/x.txt 2>/dev/null || true
getfattr/setfattr 来自 attr 包;没有也能用程序验证。ENOTSUP 表示文件系统不支持 xattr(例如某些 tmpfs 挂载选项、VFAT)。
2.5.6 ACL:比 rwx 更细的权限
ACL 把「owner/group/other 三组」扩展成「若干条 ACE(access control entry)」:
user::rw- # 属主
user:1001:r-- # 命名用户
group::r-- # 属组
group:dev:rw- # 命名组
mask::rw- # 掩码:限制上面所有命名用户/组 + group::
other::r-- # 其他人
关键规则:mask 是所有命名用户、命名组和 group:: 的有效权限上限。chmod g=... 改的不是 group::,而是 mask(当文件有 ACL 时),所以一次 chmod 可能让命名用户的权限「莫名其妙」变小。
setfacl -m u:alice:rw file
getfacl file
chmod 640 file # 把 mask 设成 r--,alice 的有效权限也变成 r--
getfacl file # mask::r--, user:alice:rw-(有效仍是 r--)
setfacl -n 可以不让 chmod 重算 mask,但默认行为就是上面这样。要恢复,setfacl -m m::rw file。
libacl API
#include <sys/acl.h>
acl_t acl_get_file(const char *path, acl_type_t type);
int acl_set_file(const char *path, acl_type_t type, acl_t acl);
acl_t acl_from_text(const char *text);
char *acl_to_text(acl_t acl, ssize_t *len);
int acl_create_entry(acl_t *acl, acl_entry_t *entry);
int acl_set_tag_type(acl_entry_t entry, acl_tag_t type);
int acl_set_qualifier(acl_entry_t entry, const void *qualifier);
int acl_get_permset(acl_entry_t entry, acl_permset_t *permset);
int acl_add_perm(acl_permset_t permset, acl_perm_t perm);
int acl_set_permset(acl_entry_t entry, acl_permset_t permset);
int acl_calc_mask(acl_t *acl);
void acl_free(void *obj);
type 是 ACL_TYPE_ACCESS 或 ACL_TYPE_DEFAULT(只能用于目录)。acl_get_file 失败返回 NULL,错误在 errno(ENOENT、ENOTSUP、EACCES)。acl_to_text 返回的字符串要用 acl_free 释放;acl_t 本身也要 acl_free。
acl_calc_mask() 必须在 acl_set_file 之前调用:它按当前 ACE 重算 mask,否则你可能写入一个 mask 与条目不一致的 ACL。
完整示例:给命名用户加读权限
/* acl_demo.c — 给指定用户加 ACL 读权限;编译需 -lacl */
#include <errno.h>
#include <pwd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/acl.h>
int main(int argc, char *argv[])
{
if (argc != 3) { fprintf(stderr, "usage: %s FILE USER\n", argv[0]); return 1; }
struct passwd *pw = getpwnam(argv[2]);
if (!pw) { fprintf(stderr, "no such user: %s\n", argv[2]); return 1; }
acl_t acl = acl_get_file(argv[1], ACL_TYPE_ACCESS);
if (!acl) { perror("acl_get_file"); return 1; }
acl_entry_t entry;
if (acl_create_entry(&acl, &entry) == -1) { perror("acl_create_entry"); return 1; }
if (acl_set_tag_type(entry, ACL_USER) == -1) { perror("acl_set_tag_type"); return 1; }
uid_t uid = pw->pw_uid;
if (acl_set_qualifier(entry, &uid) == -1) { perror("acl_set_qualifier"); return 1; }
acl_permset_t perms;
if (acl_get_permset(entry, &perms) == -1) { perror("acl_get_permset"); return 1; }
if (acl_clear_perms(perms) == -1 ||
acl_add_perm(perms, ACL_READ) == -1 ||
acl_set_permset(entry, perms) == -1) {
perror("set perms"); return 1;
}
if (acl_calc_mask(&acl) == -1) { perror("acl_calc_mask"); return 1; }
if (acl_set_file(argv[1], ACL_TYPE_ACCESS, acl) == -1) { perror("acl_set_file"); return 1; }
char *text = acl_to_text(acl, NULL);
if (!text) { perror("acl_to_text"); return 1; }
printf("%s", text);
acl_free(text);
acl_free(acl);
return 0;
}
# Ubuntu/Debian:sudo apt install libacl1-dev attr
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 acl_demo.c -o acl_demo -lacl
touch /tmp/a.txt && chmod 640 /tmp/a.txt
./acl_demo /tmp/a.txt "$USER"
getfacl /tmp/a.txt
acl_set_file 需要你是文件属主(或有 CAP_FOWNER),否则 EPERM。在 tmpfs 上通常可用;FAT/exFAT 不支持 ACL,会返回 ENOTSUP。
默认 ACL 与继承
目录可以设置默认 ACL(ACL_TYPE_DEFAULT):此后在该目录里新建的文件/子目录会继承它。默认 ACL 只影响新建对象,不改变目录本身的访问权限。
setfacl -d -m u:alice:rwx /srv/shared # 默认 ACL
setfacl -m u:alice:rwx /srv/shared # 访问 ACL(目录本身)
getfacl /srv/shared
touch /srv/shared/new && getfacl /srv/shared/new # 继承
新建文件时,默认 ACL 的 mask 会与 open 的 mode、umask 共同作用:文件不会因为 ACL 就获得 x 位(除非目录的默认 ACL 给了 x 且创建者请求了可执行)。
两条命令的常用选项比背 API 更常遇到:
| 命令 | 选项 | 作用 |
|---|---|---|
getfacl | -a / -d | 只看访问 ACL / 只看默认 ACL(默认两者都显示) |
getfacl | -R、-n、-t | 递归;显示数字 UID/GID;表格化输出 |
setfacl | -m、-x | 修改/增加条目;删除指定条目(如 u:alice) |
setfacl | -b、-k | 删除所有扩展 ACL(保留 base);删除默认 ACL |
setfacl | -d、-R、--set | 操作默认 ACL;递归;整体替换 |
getfacl 的输出可以直接喂回 setfacl:getfacl file1 | setfacl --set-file=- file2 复制 ACL;getfacl -R -p /srv > acl.bak 之后用 setfacl --restore=acl.bak 恢复整棵子树(-p 保留绝对路径,避免恢复时相对当前目录)。setfacl -x u:alice file 只删命名用户条目,不动 base 三项;-b 才是清空扩展条目。注意 -n 在两个命令里含义不同:getfacl -n 显示数字 ID,setfacl -n 是不重算 mask。
2.5.7 statx:更新的字段
statx(2) 补上了 stat 缺的东西:创建时间、挂载 ID、直接 I/O 对齐要求、属性掩码。
struct statx stx;
if (statx(AT_FDCWD, path, AT_STATX_SYNC_AS_STAT,
STATX_BASIC_STATS | STATX_BTIME, &stx) == -1)
errExit("statx");
if (stx.stx_mask & STATX_BTIME)
printf("birth=%lld\n", (long long)stx.stx_btime.tv_sec);
printf("ino=%llu mnt_id=%llu\n",
(unsigned long long)stx.stx_ino, (unsigned long long)stx.stx_mnt_id);
stx_mask 告诉你哪些字段有效:文件系统不支持 btime 时 STATX_BTIME 位不会被置。AT_STATX_SYNC_AS_STAT 是默认缓存行为;AT_STATX_FORCE_SYNC 强制回源,AT_STATX_DONT_SYNC 只读缓存。旧内核没有 statx 时返回 ENOSYS,要回退到 stat。
statvfs:文件系统剩余空间与 inode
stat/statx 描述「一个文件」,statvfs(fd/path, &buf) 描述「这个文件所在的文件系统」:
#include <sys/statvfs.h>
struct statvfs vfs;
if (statvfs("/srv/data", &vfs) == 0) {
unsigned long long total = (unsigned long long)vfs.f_blocks * vfs.f_frsize;
unsigned long long free = (unsigned long long)vfs.f_bavail * vfs.f_frsize;
unsigned long long inodes = vfs.f_files;
unsigned long long ifree = vfs.f_favail;
printf("total=%llu free=%llu inodes=%llu ifree=%llu\n", total, free, inodes, ifree);
}
关键字段:f_bsize(文件系统块大小)、f_frsize(片段大小,算空间用它)、f_blocks/f_bfree/f_bavail(总/空闲/非特权可用块)、f_files/f_ffree/f_favail(inode 总数/空闲/可用)、f_namemax(文件名最大长度)。
df 默认显示 1K 块,statvfs 用 f_frsize;f_bavail 已扣除 root 保留块(通常 5%),f_bfree 没有。要判断「普通用户还能写多少」,用 f_bavail,不要用 f_bfree。
2.5.8 struct stat 的 ABI 与字段布局
这一节回答:struct stat 的字段在 32 位与 64 位、glibc 与 musl 之间为什么不能混用?st_dev/st_ino 凭什么唯一标识一个文件?
stat 的字段不是「一堆整数」,而是内核与用户态共享的 ABI。内核在 cp_new_stat()/cp_statx() 里按固定布局填充,glibc 的 stat 包装再把它转成用户态的 struct stat。字段宽度随 _FILE_OFFSET_BITS、架构、libc 变化:
| 字段 | 类型 | 说明 |
|---|---|---|
st_dev | dev_t | 文件所在设备;用 major()/minor() 拆 |
st_ino | ino_t | inode 号;只在同一 st_dev 内唯一 |
st_mode | mode_t | 类型 + 权限 + 特殊位 |
st_nlink | nlink_t | 硬链接数;已删除但被打开的文件可能为 0 |
st_uid/st_gid | uid_t/gid_t | 属主/属组 |
st_rdev | dev_t | 设备文件的主/次设备号 |
st_size | off_t | 逻辑大小;符号链接是目标路径长度 |
st_blksize | blksize_t | 最优 I/O 块大小,不是文件系统块大小 |
st_blocks | blkcnt_t | 已分配的 512 字节块数 |
st_atim/st_mtim/st_ctim | struct timespec | 访问/修改/状态变更时间(纳秒) |
(st_dev, st_ino) 是文件在当前命名空间里的身份;跨挂载点/绑定挂载时,同一个 inode 可能有不同的 st_dev,而 statx 的 stx_mnt_id 才能区分「哪一次挂载」。判断「两个路径是不是同一个文件」用 stat 比较 (dev, ino),不要比较路径字符串。
32 位与大文件
32 位程序默认的 off_t 可能是 32 位;读大于 2 GiB 的文件时,stat 可能返回 EOVERFLOW。解决方式是编译时定义 _FILE_OFFSET_BITS=64,让 stat/fstat/lseek/mmap 的声明重定向到 64 位版本。这不仅是「宏」,还会改变 struct stat 的布局;混用不同宏编译的目标文件会破坏 ABI。
可以在编译期锁住假设:
_Static_assert(sizeof(off_t) == 8, "需要 64 位 off_t");
_Static_assert(sizeof(time_t) >= 8, "需要 64 位 time_t");
st_mode 的位布局
Linux 用高 4 位表示类型(S_IFMT 0xF000),低 12 位表示权限与特殊位。SUSv3 没有规定类型位的具体值,所以只用 S_IS*() 宏,不要写 (st.st_mode & 0170000) == 0100000。st_mode 的类型位与权限位是同一字段,chmod 只改低 12 位,类型位被忽略。
- 「
st_ino全局唯一」——只在同一设备内唯一;不同文件系统的 inode 号可能重复。 - 「
st_size就是磁盘占用」——稀疏文件、压缩、块对齐都会让st_blocks*512不同。 - 「
st_blksize是文件系统块大小」——是「最优 I/O 大小」,ext4 常为 4096,但语义不同。 - 「
st_nlink==0表示文件不存在」——已删除但被进程打开的文件仍是 0,lsof +L1能看到。 - 「32 位
stat和 64 位stat可以混用」——结构体布局不同,跨库传递会读错字段。
2.5.9 statx 全字段与属性位
这一节回答:stat 缺了创建时间、挂载 ID、直接 I/O 对齐,statx 怎么补?stx_mask 为什么必须检查?
statx(Linux 4.11,glibc 2.28)用一个可扩展结构返回更多字段,并允许调用者只请求需要的部分:
int statx(int dirfd, const char *path, int flags,
unsigned int mask, struct statx *buf);
flags:AT_SYMLINK_NOFOLLOW、AT_EMPTY_PATH、AT_NO_AUTOMOUNT、AT_STATX_SYNC_AS_STAT(默认)、AT_STATX_FORCE_SYNC、AT_STATX_DONT_SYNC。mask 是 STATX_* 请求位;内核在 stx_mask 里告诉哪些字段真的有效。
stx_mask 位 | 字段 | 说明 |
|---|---|---|
STATX_TYPE | stx_mode | 类型 |
STATX_MODE | stx_mode | 权限 |
STATX_NLINK | stx_nlink | 链接数 |
STATX_UID/STATX_GID | stx_uid/stx_gid | 属主/属组 |
STATX_ATIME/MTIME/CTIME | stx_atime/… | 三个时间 |
STATX_INO | stx_ino | inode 号 |
STATX_SIZE | stx_size | 逻辑大小 |
STATX_BLOCKS | stx_blocks | 512 字节块数 |
STATX_BTIME | stx_btime | 创建时间(不一定支持) |
STATX_MNT_ID | stx_mnt_id | 挂载 ID(4.11+) |
STATX_DIOALIGN | stx_dio_mem_align/stx_dio_offset_align | 直接 I/O 对齐(6.1+) |
STATX_BASIC_STATS 是常用基础字段的集合;STATX_ALL 是「全部」的旧别名(新字段增加后它不会自动包含,所以不要依赖)。
stx_attributes 是 inode 属性位,和 chattr 的 flags 对应:
| 位 | 含义 | 对应 chattr |
|---|---|---|
STATX_ATTR_COMPRESSED | 压缩 | c |
STATX_ATTR_IMMUTABLE | 不可变 | i |
STATX_ATTR_APPEND | 只追加 | a |
STATX_ATTR_NODUMP | 不备份 | d |
STATX_ATTR_ENCRYPTED | 加密 | — |
STATX_ATTR_VERITY | fs-verity | — |
STATX_ATTR_DAX | DAX | — |
STATX_ATTR_AUTOMOUNT | 自动挂载点 | — |
STATX_ATTR_MOUNT_ROOT | 挂载根 | — |
旧内核返回 ENOSYS;文件系统不支持 btime 时 STATX_BTIME 位不置,stx_btime 可能是 0。先看 stx_mask,再读字段,这是 statx 与 stat 最大的用法差异。
/* statx_full.c — 打印 statx 的 mask、btime、mnt_id 与属性位 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
static void show_ts(const char *name, const struct statx_timestamp *ts)
{
struct tm tm;
char buf[64];
if (!localtime_r(&(time_t){ ts->tv_sec }, &tm)) return;
if (!strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", &tm)) return;
printf(" %-6s %s.%09u\n", name, buf, ts->tv_nsec);
}
static void show_attrs(unsigned long long a)
{
struct { unsigned long long bit; const char *name; } tab[] = {
{ STATX_ATTR_COMPRESSED, "compressed" },
{ STATX_ATTR_IMMUTABLE, "immutable" },
{ STATX_ATTR_APPEND, "append" },
{ STATX_ATTR_NODUMP, "nodump" },
{ STATX_ATTR_ENCRYPTED, "encrypted" },
{ STATX_ATTR_VERITY, "verity" },
{ STATX_ATTR_DAX, "dax" },
};
printf(" attrs %#llx", a);
for (size_t i = 0; i < sizeof tab / sizeof tab[0]; i++)
if (a & tab[i].bit) printf(" %s", tab[i].name);
putchar('\n');
}
int main(int argc, char *argv[])
{
if (argc != 2) { fprintf(stderr, "usage: %s PATH\n", argv[0]); return 1; }
struct statx stx;
unsigned int mask = STATX_BASIC_STATS | STATX_BTIME;
#ifdef STATX_MNT_ID
mask |= STATX_MNT_ID;
#endif
#ifdef STATX_DIOALIGN
mask |= STATX_DIOALIGN;
#endif
if (statx(AT_FDCWD, argv[1], AT_STATX_SYNC_AS_STAT, mask, &stx) == -1) {
perror("statx");
return 1;
}
printf("%s\n", argv[1]);
printf(" mask %#x\n", stx.stx_mask);
printf(" ino=%llu size=%llu blocks=%llu mode=%#o nlink=%u\n",
(unsigned long long)stx.stx_ino,
(unsigned long long)stx.stx_size,
(unsigned long long)stx.stx_blocks,
stx.stx_mode, stx.stx_nlink);
printf(" uid=%u gid=%u blksize=%u\n", stx.stx_uid, stx.stx_gid, stx.stx_blksize);
if (stx.stx_mask & STATX_ATIME) show_ts("atime", &stx.stx_atime);
if (stx.stx_mask & STATX_MTIME) show_ts("mtime", &stx.stx_mtime);
if (stx.stx_mask & STATX_CTIME) show_ts("ctime", &stx.stx_ctime);
if (stx.stx_mask & STATX_BTIME) show_ts("btime", &stx.stx_btime);
else printf(" btime 不支持\n");
#ifdef STATX_MNT_ID
if (stx.stx_mask & STATX_MNT_ID)
printf(" mnt_id %llu\n", (unsigned long long)stx.stx_mnt_id);
#endif
#ifdef STATX_DIOALIGN
if (stx.stx_mask & STATX_DIOALIGN)
printf(" dio mem=%u off=%u\n",
stx.stx_dio_mem_align, stx.stx_dio_offset_align);
#endif
show_attrs(stx.stx_attributes);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 statx_full.c -o statx_full
./statx_full /etc/hosts
./statx_full /tmp # 目录:看 mnt_id
stx_mask 里没有 STATX_BTIME 时,btime 打印「不支持」,不要用 0 当「创建于 1970 年」。mnt_id 可以和 /proc/self/mountinfo 的第一列对上,用来区分绑定挂载。
- 「
statx一定返回 btime」——ext4/XFS 通常支持,tmpfs/部分网络文件系统可能不填;看 mask。 - 「
STATX_ALL包含所有字段」——它是历史别名,新字段(如STATX_DIOALIGN)不在其中。 - 「
stx_btime为 0 表示创建时间未知」——先看STATX_BTIME位;位没置时字段无意义。 - 「旧内核会自动降级」——
statx返回ENOSYS,要回退到stat/fstatat。
2.5.10 时间戳的完整语义:relatime、lazytime 与 utimensat
这一节回答:为什么 cat 一个文件后 atime 没变?ctime 为什么改不了?touch -d 到底改了哪几个时间?
三个时间戳的分工(Kerrisk 15.2,p236):
| 时间 | 更新时机 | 能否手动设置 |
|---|---|---|
atime | read、exec、readdir、mmap 读 | utimensat/futimens |
mtime | write、truncate、mmap 共享写 | utimensat/futimens |
ctime | inode 变更:chmod/chown/link/unlink/rename/setxattr | 不能 |
ctime 不是创建时间;Linux 传统 struct stat 没有创建时间,statx 的 btime 才是。ctime 会随着「元数据变化」更新,mtime 只在内容变化时更新;rename 通常改文件的 ctime 和父目录的 mtime/ctime(Kerrisk 表 15-2)。
relatime 是默认行为
挂载选项决定 atime 的更新频率:
| 选项 | 行为 |
|---|---|
strictatime | 每次读都更新 |
relatime(默认) | 仅当 atime 早于 mtime/ctime,或超过 24 小时才更新 |
noatime | 完全不更新 |
lazytime | 只更新内存中的时间,回写时才落盘 |
relatime 是「性能与语义的折中」:它保证「至少一天一次」和「mtime 变了就更新」,但不保证每次读都更新。noatime 进一步省掉 inode 写;lazytime 减少频繁写 inode,但崩溃时可能丢时间更新。用 findmnt -T /path -o OPTIONS 看当前挂载选项。
utimensat/futimens
int utimensat(int dirfd, const char *path, const struct timespec times[2], int flags);
int futimens(int fd, const struct timespec times[2]);
times[0] 是 atime,times[1] 是 mtime。特殊值 UTIME_NOW(现在)和 UTIME_OMIT(不改)。flags 可带 AT_SYMLINK_NOFOLLOW;futimens 用 fd,避免路径竞态。失败:EACCES、EPERM(非属主且无 CAP_FOWNER)、EROFS、EINVAL(tv_nsec 非法)。
struct timespec ts[2] = {
{ .tv_nsec = UTIME_OMIT }, /* 不动 atime */
{ .tv_sec = 1700000000, .tv_nsec = 0 }, /* 固定 mtime */
};
if (utimensat(AT_FDCWD, path, ts, 0) == -1) errExit("utimensat");
touch -a 只改 atime,touch -m 只改 mtime,touch -d 两个都改;它们都改不了 ctime(ctime 会变成「现在」,因为 inode 被修改了)。
struct stat 的 timespec 是纳秒字段,但文件系统未必支持纳秒。ext4 通常支持纳秒(取决于 inode 大小与挂载选项),XFS 支持;FAT 只有 2 秒精度。statx 也返回纳秒,但值可能被截断。做「文件是否变化」判断时,比较 mtime + size 比比较 ctime 可靠;要更强保证用内容哈希。
老接口:utime()、utimes()、futimes()
老代码里还会遇到秒级/微秒级接口:
#include <utime.h>
int utime(const char *path, const struct utimbuf *times); /* 秒级 */
#include <sys/time.h>
int utimes(const char *path, const struct timeval times[2]); /* 微秒级 */
int futimes(int fd, const struct timeval times[2]);
int lutimes(const char *path, const struct timeval times[2]); /* 不跟随符号链接 */
struct utimbuf { time_t actime, modtime; } 只有秒;times == NULL 表示都设为当前时间。utimes 的 timeval 带微秒,精度比 utime 高,但仍低于 utimensat 的纳秒。新代码统一用 utimensat/futimens,需要「不改 atime」时用 UTIME_OMIT;老接口没有这个语义。
2.5.11 inode 标志:chattr 与 FS_IOC_*FLAGS
这一节回答:chattr +i 为什么连 root 都改不了文件?程序里怎么读写这些标志?
inode flags(ext2 扩展属性)由 ioctl 读写:
int flags;
if (ioctl(fd, FS_IOC_GETFLAGS, &flags) == -1) errExit("FS_IOC_GETFLAGS");
flags |= FS_NODUMP_FL;
if (ioctl(fd, FS_IOC_SETFLAGS, &flags) == -1) errExit("FS_IOC_SETFLAGS");
| 常量 | chattr | 含义 | 权限 |
|---|---|---|---|
FS_IMMUTABLE_FL | i | 不可修改、不可删除/改名/改元数据 | CAP_LINUX_IMMUTABLE |
FS_APPEND_FL | a | 只能以 O_APPEND 打开写 | CAP_LINUX_IMMUTABLE |
FS_NODUMP_FL | d | dump 跳过 | 属主通常可设 |
FS_NOATIME_FL | A | 不更新 atime | 属主 |
FS_SYNC_FL | S | 同步更新 | 属主 |
FS_DIRSYNC_FL | D | 目录更新同步(仅目录) | 属主 |
FS_JOURNAL_DATA_FL | j | 数据也写日志(ext3/4) | CAP_SYS_RESOURCE |
FS_TOPDIR_FL | T | Orlov 顶层目录策略 | 属主 |
FS_COMPR_FL/FS_SECRM_FL/FS_UNRM_FL | c/s/u | 历史/未实现 | — |
FS_IMMUTABLE_FL 一旦设置,write、truncate、chmod、chown、unlink、link、rename、setxattr 全部返回 EPERM,包括 root;要改必须先 chattr -i(仍需要 CAP_LINUX_IMMUTABLE)。FS_APPEND_FL 下,不以 O_APPEND 打开写会 EPERM,truncate 也会失败。
lsattr 显示的字母与 chattr 选项对应;lsattr -d dir 看目录自身。FS_IOC_FSGETXATTR/FS_IOC_FSSETXATTR 还能读写 project ID、extent 大小等(XFS/ext4)。
/* inode_flags.c — 读 inode flags,尝试加/清 NODUMP,演示 EPERM */
#include <errno.h>
#include <fcntl.h>
#include <linux/fs.h>
#include <stdio.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
static void show(int fd, const char *tag)
{
int flags = 0;
if (ioctl(fd, FS_IOC_GETFLAGS, &flags) == -1) {
printf("%s: GETFLAGS: %s\n", tag, strerror(errno));
return;
}
printf("%s: flags=%#x%s%s%s\n", tag, flags,
(flags & FS_IMMUTABLE_FL) ? " immutable" : "",
(flags & FS_APPEND_FL) ? " append" : "",
(flags & FS_NODUMP_FL) ? " nodump" : "");
}
int main(int argc, char *argv[])
{
if (argc != 2) { fprintf(stderr, "usage: %s FILE\n", argv[0]); return 1; }
int fd = open(argv[1], O_RDONLY | O_CLOEXEC);
if (fd == -1) { perror("open"); return 1; }
show(fd, "before");
int flags = 0;
if (ioctl(fd, FS_IOC_GETFLAGS, &flags) == -1) { perror("GETFLAGS"); return 1; }
int want = flags | FS_NODUMP_FL;
errno = 0;
if (ioctl(fd, FS_IOC_SETFLAGS, &want) == -1)
printf("set NODUMP: %s(普通用户可能无权限)\n", strerror(errno));
else
show(fd, "after set");
/* 清掉刚加的位;如果前面失败,这里也可能失败 */
want = flags & ~FS_NODUMP_FL;
if (ioctl(fd, FS_IOC_SETFLAGS, &want) == -1)
printf("clear NODUMP: %s\n", strerror(errno));
else
show(fd, "after clear");
close(fd);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 inode_flags.c -o inode_flags
touch /tmp/flags.txt && ./inode_flags /tmp/flags.txt
chattr +i /tmp/flags.txt 2>/dev/null && lsattr /tmp/flags.txt
在 ext4 上,普通用户对 FS_NODUMP_FL 的设置通常成功;chattr +i 需要 root 或 CAP_LINUX_IMMUTABLE。不要在脚本里假设「属主一定能设所有 flags」。
- 「
chattr +i只防删除」——它同时禁止写、截断、改权限、改属主、改名、加 xattr。 - 「root 可以绕过 immutable」——不能;必须先清 flag(仍需 capability)。
- 「所有文件系统都支持 flags」——tmpfs 支持一部分,VFAT/NFS 可能返回
EOPNOTSUPP。 - 「
FS_APPEND_FL等于O_APPEND」——前者是 inode 级强制,后者是每次打开的 flag;FS_APPEND_FL下不用O_APPEND打开写会失败。 - 「
lsattr的字母和 ioctl 位一一对应」——大多数对应,但e(extent)等是只读展示,不能通过chattr设置。
2.5.12 xattr 的命名空间与实现限制
这一节回答:为什么 setfattr -n trusted.foo 返回 EPERM?一个文件最多能放多少 xattr?
xattr 的名字是 namespace.name,命名空间决定权限与用途(Kerrisk 16.1,p258):
| 命名空间 | 谁可读写 | 典型用途 |
|---|---|---|
user.* | 文件属主/有读写权限者 | 应用元数据 |
trusted.* | CAP_SYS_ADMIN | 系统级标记 |
system.* | 内核(部分只读) | POSIX ACL(system.posix_acl_access) |
security.* | LSM/特权 | SELinux label、security.capability |
user.* 只能用于普通文件和目录:符号链接的权限对所有用户开放,无法用权限阻止别人加 xattr;设备/FIFO/socket 的权限用于控制 I/O,不应被 xattr 权限干扰。对 sticky 目录(如 /tmp),非属主不能加 user.*。
限制(VFS 层,所有文件系统):
- 名字 ≤ 255 字节;
- 值 ≤ 64 KiB;
- ext4 上「所有 xattr 名字 + 值」总和必须放进一个 inode 块(1/2/4 KiB),所以实际能放的 ACL 条目约 500 条;XFS 的 ACL 上限是 25 条,Reiserfs/JFS 约 8191 条(Kerrisk 17.7)。
API 的 l/f 变体与 stat 家族一致:setxattr 跟随符号链接,lsetxattr 不跟随,fsetxattr 用 fd。flags:
XATTR_CREATE:已存在则EEXIST;XATTR_REPLACE:不存在则ENODATA;0:创建或替换。
getxattr 返回值的三种情况:>=0 是长度;-1/ENODATA 不存在;-1/ERANGE 缓冲区太小。标准两步法仍有竞态:第一次问长度,第二次取值时属性可能变大/被删;要循环处理 ERANGE,或直接按 64 KiB 上限分配。
listxattr 返回 name\0name\0...,总长度;size=0 只问长度。出于安全,listxattr 可能省略调用者无权读的属性(如 trusted.*),所以「list 里有名字,get 却失败」是正常的。
- 「
ENOTSUP表示名字写错」——表示文件系统不支持 xattr;名字错通常是EINVAL/ENODATA。 - 「空值等于不存在」——空字符串是合法值;
getxattr返回 0,ENODATA才是不存在。 - 「
listxattr列出的一定能get」——可能因权限被过滤,或在两次调用间被删。 - 「
cp -a会保留 xattr」——GNUcp -a保留部分 xattr,ACL 需要-a/--preserve=all;tar要--xattrs,rsync要-X。 - 「
security.capability可以随便写」——它是文件 capability 的二进制格式,写错会破坏可执行文件权限;用setcap。
2.5.13 ACL 的二进制格式与检查算法
这一节回答:getfacl 的输出在磁盘上长什么样?内核按什么顺序判断「这个用户能不能读」?
存储:一个 system xattr
扩展 ACL 存在 system.posix_acl_access 里;最小 ACL(只有 owner/group/other 三条)不占 xattr,直接存在 st_mode 的 9 位里。目录的默认 ACL 存在 system.posix_acl_default。
二进制格式(Linux include/uapi/linux/posix_acl_xattr.h):
struct posix_acl_xattr_header {
__le32 a_version; /* 2 */
};
struct posix_acl_xattr_entry {
__le16 e_tag;
__le16 e_perm;
__le32 e_id; /* 0xffffffff 表示无 qualifier */
};
每条 ACE 8 字节。e_tag 的取值:
| tag | 值 | 含义 |
|---|---|---|
ACL_USER_OBJ | 0x01 | 属主 |
ACL_USER | 0x02 | 命名用户 |
ACL_GROUP_OBJ | 0x04 | 属组 |
ACL_GROUP | 0x08 | 命名组 |
ACL_MASK | 0x10 | 掩码 |
ACL_OTHER | 0x20 | 其他 |
e_perm 是 4=读、2=写、1=执行 的位或。e_id 对 ACL_USER/ACL_GROUP 是 UID/GID;其余为 0xffffffff。
检查算法
内核的 posix_acl_permission() 按固定顺序匹配,第一个匹配的条目决定结果(Kerrisk 17.2,p267):
euid == 0:读/写直接放行;执行要求至少一个执行位。euid == st_uid:用ACL_USER_OBJ的权限。euid命中某个ACL_USER:用该条目权限 &ACL_MASK。- 进程的
egid或辅助组命中ACL_GROUP_OBJ或某个ACL_GROUP:用该条目权限 &ACL_MASK;只要有一个匹配的组条目授予了请求的权限就通过,否则拒绝(不会继续落到ACL_OTHER)。 - 否则用
ACL_OTHER。
关键点:ACL_MASK 是所有命名用户、命名组和 ACL_GROUP_OBJ 的上限;chmod 改的是 mask,不是 ACL_GROUP_OBJ。stat 的 group 权限位显示的也是 mask。所以 chmod 640 会把 mask 设成 r--,命名用户的 rw- 有效权限只剩读。
/* acl_raw.c — 直接解码 system.posix_acl_access,不依赖 libacl */
#include <endian.h>
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/xattr.h>
struct acl_hdr {
uint32_t version;
} __attribute__((packed));
struct acl_ent {
uint16_t tag;
uint16_t perm;
uint32_t id;
} __attribute__((packed));
static const char *tag_name(uint16_t tag)
{
switch (tag) {
case 0x01: return "USER_OBJ";
case 0x02: return "USER";
case 0x04: return "GROUP_OBJ";
case 0x08: return "GROUP";
case 0x10: return "MASK";
case 0x20: return "OTHER";
default: return "?";
}
}
static void perm_str(uint16_t p, char out[4])
{
out[0] = (p & 4) ? 'r' : '-';
out[1] = (p & 2) ? 'w' : '-';
out[2] = (p & 1) ? 'x' : '-';
out[3] = '\0';
}
int main(int argc, char *argv[])
{
if (argc != 2) { fprintf(stderr, "usage: %s FILE\n", argv[0]); return 1; }
ssize_t n = getxattr(argv[1], "system.posix_acl_access", NULL, 0);
if (n == -1) {
if (errno == ENODATA)
printf("%s: 无扩展 ACL(最小 ACL 在 mode 位中)\n", argv[1]);
else
perror("getxattr");
return 0;
}
unsigned char *buf = malloc((size_t)n);
if (!buf) { perror("malloc"); return 1; }
n = getxattr(argv[1], "system.posix_acl_access", buf, (size_t)n);
if (n == -1) { perror("getxattr"); free(buf); return 1; }
if ((size_t)n < sizeof(struct acl_hdr)) {
fprintf(stderr, "ACL 太短: %zd\n", n);
free(buf);
return 1;
}
const struct acl_hdr *h = (const struct acl_hdr *)buf;
printf("version=%u bytes=%zd\n", le32toh(h->version), n);
size_t off = sizeof *h;
while (off + sizeof(struct acl_ent) <= (size_t)n) {
const struct acl_ent *e = (const struct acl_ent *)(buf + off);
uint16_t tag = le16toh(e->tag);
uint16_t perm = le16toh(e->perm);
uint32_t id = le32toh(e->id);
char p[4];
perm_str(perm, p);
if (id == 0xffffffffU)
printf(" %-10s %s\n", tag_name(tag), p);
else
printf(" %-10s %s id=%u\n", tag_name(tag), p, id);
off += sizeof *e;
}
free(buf);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 acl_raw.c -o acl_raw
touch /tmp/acl.txt && chmod 640 /tmp/acl.txt
setfacl -m u:"$USER":rw /tmp/acl.txt
./acl_raw /tmp/acl.txt
# version=2 bytes=44
# USER_OBJ rw-
# USER rw- id=1000
# GROUP_OBJ r--
# MASK rw-
# OTHER r--
44 字节 = 4 字节头 + 5 条 8 字节 ACE。chmod 640 后 mask 变 r--,但命名用户的条目仍是 rw-;getfacl 会显示 effective: r--,acl_raw 只显示原始条目,所以要看 mask 才能算有效权限。
- 「
chmod 640会删掉命名用户」——不会,它改 mask,条目还在,只是有效权限变小。 - 「
ACL_MASK只是给命名组的」——它同时限制ACL_GROUP_OBJ和所有命名用户/组。 - 「组匹配失败会落到 OTHER」——不会;有扩展 ACL 时,组类匹配失败直接拒绝。
- 「ACL 条目可以无限多」——ext4 受一个 inode 块限制(约 500 条),XFS 上限 25 条。
- 「
cp -a一定保留 ACL」——GNUcp -a会尽量保留,但跨文件系统/不支持 ACL 的目标会退化;用getfacl/setfacl校验。
2.5.14 默认 ACL、umask 与创建权限的联合算法
这一节回答:目录上设了默认 ACL 后,新建文件的权限到底由谁决定——mode、umask 还是 ACL?
默认 ACL(setfacl -d)只对目录有效,作用是给新建的子项提供模板。新建普通文件时:
- 若父目录没有默认 ACL:
mode & ~umask决定权限,最小 ACL 写在 mode 位里。 - 若父目录有默认 ACL:新文件继承默认 ACL 的条目;
mode中未请求的权限会从继承条目的ACL_USER_OBJ、ACL_MASK、ACL_OTHER中清掉;umask不再单独决定结果(POSIX 语义是「默认 ACL 存在时忽略 umask」)。
新建子目录时,除了继承访问 ACL,还会继承默认 ACL 作为自己的默认 ACL(如果父目录有默认 ACL)。setfacl -d -m u:alice:rwx /srv/shared 后,/srv/shared 下新建的文件/目录都会带 alice 的条目。
mkdir -p /tmp/shared && chmod 2775 /tmp/shared
setfacl -d -m u:"$USER":rwx /tmp/shared # 默认 ACL
setfacl -m u:"$USER":rwx /tmp/shared # 目录自身的访问 ACL
umask 077
touch /tmp/shared/newfile
getfacl /tmp/shared/newfile
umask 077 下,新文件的 ACL_USER_OBJ 仍是 rw-(创建者需要能读写),ACL_MASK 可能被限制为 ---,命名用户的 rwx 有效权限变成 ---。这正是「默认 ACL 与 umask 共同作用」的可见结果:条目在,但 mask 让它们无效。用 setfacl -m m::rwx 恢复 mask,或创建时用 umask 002。
删除:setfacl -k dir 删默认 ACL;setfacl -b dir 删所有扩展 ACL(保留最小 ACL);setfacl -x u:alice file 只删该条目。
getfacl -p DIR看父目录是否有默认 ACL(default:行)。umask看当前 shell 的掩码;程序里是否改过umask。getfacl FILE看mask::与effective:;mask 可能把命名条目全禁了。stat -c '%a' FILE看 mode 位;有扩展 ACL 时 group 位显示的是 mask。- 跨文件系统/容器:目标 FS 是否挂载了
acl选项;ENOTSUP表示不支持。
2.5.15 内核路径:statx、xattr 与 ACL 落到 ext4
这一节回答:statx 的 btime、getxattr 的值、ACL 的检查,分别由内核哪些函数完成?
statx
| 层次 | 位置 | 动作 |
|---|---|---|
| 系统调用 | fs/stat.c | __do_sys_statx → do_statx |
| 公共层 | fs/stat.c | vfs_statx → vfs_getattr |
| 安全 | security/security.c | security_inode_getattr(LSM 钩子) |
| 文件系统 | fs/ext4/inode.c | ext4_getattr → generic_fillattr |
| btime | fs/ext4/inode.c | ext4_getattr 填 stat->btime = ei->i_crtime |
| 返回 | fs/stat.c | cp_statx 把内核 struct kstat 转成用户 struct statx |
statx 的 mask 是「请求」,stx_mask 是「实际提供」;vfs_getattr 会把文件系统不支持的位清掉。AT_STATX_DONT_SYNC 允许只读缓存,FORCE_SYNC 强制回源(网络文件系统有意义)。
xattr
| 层次 | 位置 | 动作 |
|---|---|---|
| 系统调用 | fs/xattr.c | __do_sys_getxattr → getxattr |
| 权限 | fs/xattr.c | xattr_permission 检查命名空间/权限 |
| 文件系统 | fs/ext4/xattr.c | ext4_xattr_get |
| 存储 | fs/ext4/xattr.c | ext4_xattr_ibody_get(inode 内)/ext4_xattr_block_get(外部块) |
ext4 的 xattr 优先存在 inode 的额外空间(i_file_acl 之外),放不下才分配外部块。设置时走 ext4_xattr_set → ext4_xattr_set_handle,在日志事务里修改,所以 setxattr 的持久化也受 fsync/日志提交影响。
ACL
| 层次 | 位置 | 动作 |
|---|---|---|
| 读取 | fs/posix_acl.c | get_acl → ext4_get_acl |
| 解码 | fs/posix_acl.c | posix_acl_from_xattr |
| 检查 | fs/namei.c | generic_permission → acl_permission_check |
| 匹配 | fs/posix_acl.c | posix_acl_permission |
acl_permission_check 先看是否为属主、是否有 CAP_DAC_OVERRIDE,再调用 posix_acl_permission 按 2.5.13 的顺序匹配。默认 ACL 的继承在创建路径上:posix_acl_create → posix_acl_create_masq,由文件系统在 ->create/->mkdir 里调用。
statx、xattr、ACL 都先经过 VFS 的通用检查(权限、命名空间、锁),再调用具体文件系统的 inode_operations/xattr_handler。所以同一个调用在 ext4 与 NFS 上的错误码可能不同:VFS 返回 EOPNOTSUPP,NFS 可能返回 ENOTSUP;ext4 的 ACL 受 inode 块限制,XFS 受 25 条限制。排障时先确认「VFS 层拒绝」还是「文件系统层拒绝」。
2.5.16 两个实验:strace 看元数据调用与 getfacl/getfattr 验证
这一节回答:怎样确认程序真的调用了 statx/getxattr?怎样把 ACL 的原始字节 dump 出来?
实验一:strace 跟踪元数据调用
strace -yy -e trace=%stat,getxattr,listxattr,setxattr ./statx_full /etc/hosts 2>&1 | head -12
典型输出:
$ strace -e trace=%stat,getxattr ./statx_full /etc/hosts 2>&1 | head -6
statx(AT_FDCWD, "/etc/hosts", AT_STATX_SYNC_AS_STAT, STATX_BASIC_STATS|STATX_BTIME, {stx_mask=STATX_BASIC_STATS|STATX_BTIME, ...}) = 0
newfstatat(AT_FDCWD, "/etc/hosts", {st_mode=S_IFREG|0644, st_size=221, ...}, 0) = 0
读法:
statx的第四个参数是请求 mask,返回结构里的stx_mask是实际提供的位;两者可能不同。- glibc 的
stat()在 64 位 Linux 上通常走newfstatat(AT_SYMLINK_NOFOLLOW或0),不是statx。 getxattr("file", "user.release", ...)失败会显示= -1 ENODATA;ENOTSUP表示文件系统不支持。%stat是 strace 的调用类,包含stat/lstat/fstat/newfstatat/statx。
实验二:getfattr/getfacl 与原始字节
# 1. 看所有 xattr(默认只看 user.*,-m - 看全部)
setfattr -n user.release -v 2026.10 /tmp/x.txt
getfattr -d -m - /tmp/x.txt
# 2. 看 ACL 文本
setfacl -m u:"$USER":rw /tmp/x.txt
getfacl -p /tmp/x.txt
# 3. dump ACL 的原始二进制(4 字节头 + 8 字节/条)
getfattr --only-values -n system.posix_acl_access /tmp/x.txt | od -An -tx1
# 4. 看 inode flags
lsattr /tmp/x.txt
getfattr -d -m - 会显示 trusted.*/security.*(如果有权限);-m - 是「匹配所有名字」的正则。--only-values 只输出值,适合接 od/xxd。getfacl -p 保留绝对路径,便于把输出喂回 setfacl --set-file。
stat -c '%d %i %h %s %b %B %a %n' file 一次给出设备号、inode、链接数、大小、块数、块单位、八进制权限;find /path -printf '%i %n %s %b\n' 可以批量核对。
stat/statx看基础字段;statx的stx_mask确认 btime/mnt_id 是否有效。getfacl看 ACL 与 mask;lsattr看 inode flags;getfattr -d -m -看 xattr。findmnt -T /path -o TARGET,SOURCE,FSTYPE,OPTIONS看挂载选项(noatime、acl、ro)。strace -e trace=%stat,getxattr,setxattr看程序实际调用了什么、返回什么 errno。- 跨容器/命名空间:
/proc/PID/mountinfo的mnt_id与statx.stx_mnt_id对照。
2.5.17 本章陷阱清单
- 现象:
stat的st_size是 1 GiB,du是 0。原因:稀疏文件。修复:看st_blocks*512。 - 现象:
cat后atime没变。原因:relatime/noatime。修复:findmnt看挂载选项;不要用 atime 做访问审计。 - 现象:
chmod 640后命名用户突然不能写。原因:chmod改了 ACL mask。修复:setfacl -m m::rw或调整 chmod。 - 现象:
getxattr返回ERANGE。原因:缓冲区太小。修复:先getxattr(..., NULL, 0)取长度,再分配重试。 - 现象:
setxattr返回EPERM。原因:trusted.*需要CAP_SYS_ADMIN,或 sticky 目录限制。修复:换user.*或提权。 - 现象:
stat大文件返回EOVERFLOW。原因:32 位off_t。修复:-D_FILE_OFFSET_BITS=64。 - 现象:
statx的 btime 是 0。原因:文件系统不支持或STATX_BTIME未置位。修复:检查stx_mask。 - 现象:
chattr +i后连 root 都删不掉。原因:immutable 是 inode 级强制。修复:chattr -i(需 capability)。 - 现象:
cp -a后 ACL 丢了。原因:目标文件系统不支持 ACL 或未保留。修复:getfacl | setfacl --set-file=-显式复制。 - 现象:
st_ino相同的两个路径却不是同一文件。原因:不同设备/挂载的 inode 号重复。修复:比较(st_dev, st_ino)或statx.stx_mnt_id。 - 现象:
rename后ctime变了但mtime没变。原因:rename改元数据不改内容。修复:这是正常语义;用statx的 btime/mtime 组合判断。 - 现象:
getfacl显示effective: r--,但条目写的是rw-。原因:mask 限制。修复:改 mask 或调整条目。
2.5.18 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
区分 stat/lstat/fstatat | 能说出符号链接与 cwd 的差别 | 2.5.1 |
解释 st_size 与 st_blocks 不同 | 能举稀疏文件 | 2.5.3 |
| 说出三个时间戳各在何时更新 | 至少说对 ctime 不是创建时间 | 2.5.3 |
解释 access 的 TOCTOU | 能给出 open + EACCES 的替代 | 2.5.2 |
用 xattr 存一个值并处理 ERANGE | 代码能编译 | 2.5.5 |
解释 ACL mask 与 chmod 的交互 | 能说出 chmod g= 改 mask | 2.5.6、2.5.13 |
用 statx 取 btime/mnt_id | 先检查 stx_mask | 2.5.9 |
| 读/写 inode flags | 知道 immutable 需要 capability | 2.5.11 |
解码 system.posix_acl_access | 能说出 4 字节头 + 8 字节/条 | 2.5.13 |
Q1 lstat 一个指向 /etc/hosts 的符号链接,st_size 是什么?st_mode 的类型是什么?
Q2 一个文件 ls -l 显示 1 GiB,du 显示 0。解释原因,并说出 st_blocks 的单位。
Q3 chmod 640 file 之后,getfacl 里 user:alice:rw- 仍在,但 alice 只能读。为什么?
Q4 access(path, W_OK) 返回 0,随后 open(path, O_WRONLY) 却返回 EACCES。列出至少两种原因。
Q5 getxattr(path, name, buf, size) 返回 -1、errno=ERANGE。怎么正确重试?
答案
A1 st_size 是链接目标字符串 /etc/hosts 的长度(10 字节),不是目标文件大小;st_mode 的类型位是 S_IFLNK(l)。
A2 稀疏文件:truncate/ftruncate 扩展出的部分是空洞,逻辑大小 1 GiB,但没有分配数据块。st_blocks 的单位是 512 字节,st_blocks * 512 才是实际占用。
A3 ACL 的 mask:: 被 chmod 640 设成了 r--。mask 是所有命名用户/组和 group:: 的有效权限上限,所以 user:alice:rw- 的有效权限只剩读。要恢复,setfacl -m m::rw file。
A4 ① access 用真实 UID,open 用有效 UID(setuid 程序);② 检查与打开之间权限被改(TOCTOU);③ 目录缺 x 导致路径解析失败;④ 只读文件系统或 LSM 拒绝。
A5 ERANGE 表示缓冲区太小。先 getxattr(path, name, NULL, 0) 取所需长度,malloc(n) 后重试;不要盲目把 size 翻倍循环。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 14 章(inode/VFS)、第 15 章(文件属性、时间戳、inode flags)、第 16 章(扩展属性)、第 17 章(ACL) | 2.5.1–2.5.17 | 完整(ACL 二进制/检查算法、xattr 限制、inode flags 为重点) |
| Linux 4.11+ | statx(2)、STATX_*(母本成书时尚未提供) | 2.5.9 | 按 man 2 statx 与内核 6.8 源码补充 |
| C | 第 10 章 10.6(文件元数据) | 2.5.1、2.5.2 | 对照(stat 与目录项) |
| O | 第 39–40 章(文件与目录、文件系统实现) | 2.5.1、2.5.3 | 背景(inode、块分配) |
| G | 第 5 章(文件权限与目录配置)、第 13 章(账号管理与 ACL) | 2.5.2、2.5.6 | 背景(ls -l、getfacl/setfacl) |
| N | — | — | 未用:本章不涉及网络栈 |
下一站:2.6 目录、链接与文件事件 把「文件」放回目录树:硬链接与符号链接的区别、
rename的原子性、inotify如何把文件变化变成可读事件。