2.3 高级文件 I/O:原子性、fcntl、mmap 与文件锁
fcntl 能改什么、不能改什么?mmap 与 read 该怎么选?flock 和 fcntl 记录锁的语义差在哪?怎样让一次配置发布在崩溃后要么是旧版、要么是新版?
用 pread/pwrite 消除共享偏移竞态;用 mmap 读写文件并解释 SIGBUS;用记录锁实现单实例;写出「临时文件 + fsync + rename + 目录 fsync」的原子发布。
2.2 给了你五个函数和三条纪律:短读要循环、短写要循环、EINTR 要重试。那是最小可用的文件 I/O。真实程序很快会遇到它解决不了的问题:两个线程共用一个 fd,偏移互相踩;想给日志文件加锁,却不知道 flock 和 fcntl 哪个才对;配置文件写到一半机器断电,重启后读到半个 JSON。
这一章补上三块能力:不共享偏移的读写、把文件映射进内存、让多个进程安全地共享文件。它们都围绕同一个主题:多条系统调用之间没有原子性,必须用内核原语把「检查 + 修改」压成一步。fcntl 是门把手——它既能改 fd 标志,也是记录锁的入口。
2.3.1 fcntl:一个函数,五类操作
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */);
第三个参数的类型由 cmd 决定:可能是 int、struct flock *,也可能不用。编译器不检查类型,传错就是未定义行为。
| 类别 | cmd | 第三个参数 | 成功返回 | 典型失败 |
|---|---|---|---|---|
| 读文件状态标志 | F_GETFL | 无 | flags | EBADF |
| 改文件状态标志 | F_SETFL | int flags | 0 | EBADF、EINVAL |
| 读/改 fd 标志 | F_GETFD / F_SETFD | int flags | flags / 0 | EBADF |
| 复制 fd | F_DUPFD / F_DUPFD_CLOEXEC | 最小可用 fd | 新 fd | EBADF、EINVAL、EMFILE |
| 记录锁 | F_GETLK / F_SETLK / F_SETLKW | struct flock * | 0 | EACCES、EAGAIN、EINVAL、EDEADLK |
F_GETFL/F_SETFL 只能改状态位
访问模式(O_RDONLY/O_WRONLY/O_RDWR)在 open 时固定;能改的是 O_APPEND、O_NONBLOCK、O_ASYNC、O_DIRECT、O_NOATIME。
int flags = fcntl(fd, F_GETFL);
if (flags == -1) errExit("F_GETFL");
/* 正确:读-改-写,保留其他状态位 */
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1)
errExit("F_SETFL");
/* 错误:把访问模式位塞进去。返回 0,但 fd 仍然只读 */
fcntl(fd, F_SETFL, flags | O_RDWR);
- 「
fcntl(fd, F_SETFL, O_NONBLOCK)就够了」——这会清掉O_APPEND等其他状态位。必须先F_GETFL。 - 「
F_SETFL能改访问模式」——不能,内核静默忽略访问模式位。 - 「
F_GETFL+F_SETFL原子」——不是。两个线程同时读-改-写会丢更新;启动时一次设好,或用互斥锁保护。
FD_CLOEXEC 与 F_DUPFD_CLOEXEC
fd 标志只有一位有意义:FD_CLOEXEC。open(O_CLOEXEC) 在创建时原子设置;已打开的 fd 只能事后补:
int fdflags = fcntl(fd, F_GETFD);
if (fdflags == -1) errExit("F_GETFD");
if (fcntl(fd, F_SETFD, fdflags | FD_CLOEXEC) == -1)
errExit("F_SETFD");
事后补有竞态窗口:多线程下另一个线程可能在这两步之间 fork + exec,fd 漏给子程序。F_DUPFD_CLOEXEC 的第三个参数是「新 fd 的下界」,不是指定号:
int newfd = fcntl(fd, F_DUPFD_CLOEXEC, 3); /* 新 fd ≥ 3,且 exec 时关闭 */
很多人把它当 dup2 用,结果拿到一个不是自己想要的号。
2.3.2 dup、dup2、dup3:复制「打开文件描述」
dup 家族复制 fd 表项,不复制 struct file。复制出的两个 fd 共享偏移、状态标志和 flock 锁。
| 调用 | 语义 | 失败 |
|---|---|---|
dup(fd) | 取最小可用 fd,不带 CLOEXEC | EBADF、EMFILE |
dup2(oldfd, newfd) | 复制到指定号;newfd 已打开则先原子关闭 | EBADF、EINTR(POSIX 允许) |
dup3(oldfd, newfd, flags) | 同上,flags 只接受 O_CLOEXEC;两参数相等返回 EINVAL | EBADF、EINVAL、EMFILE |
dup2 的关键是原子:close(1); dup(fd); 在多线程下,另一个线程可能抢走 fd 1;dup2(fd, 1) 一步完成关闭与复制。
int saved = dup(STDOUT_FILENO);
if (saved == -1) errExit("dup");
fflush(stdout); /* 关键:先刷 stdio 缓冲 */
if (dup2(logfd, STDOUT_FILENO) == -1)
errExit("dup2");
printf("这行进日志文件\n");
fflush(stdout); /* 切回之前再刷一次 */
dup2(saved, STDOUT_FILENO);
close(saved);
dup2 只换 fd,不搬缓冲区;不 fflush 就会把旧缓冲区写进新目标(见 2.4)。dup2(fd, fd) 直接返回 fd,dup3 则返回 EINVAL。
struct file,各有自己的偏移;如果它们由 dup() 或 fork() 产生,则共享同一个 struct file,偏移也共享——这是「两个进程同时写日志却互相覆盖」的根源。读图:fd 3 与 fd 4 指向同一个 struct file 时,f_pos 只有一份。dup/fork 产生这种关系;两次 open 产生两个 struct file,偏移各自独立。
2.3.3 pread/pwrite 与 readv/writev
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
pread/pwrite 在 offset 处读写,不改变 f_pos。返回值与 read/write 相同:可能短读/短写;-1 时 errno 为 EBADF、EINVAL(负偏移或 O_DIRECT 未对齐)、ESPIPE(管道/socket)、EINTR。
readv/writev 把多段缓冲区拼成一次 I/O,返回总字节数,可能短读/短写;errno 为 EINVAL(iovcnt < 0 或超过 IOV_MAX)、EFAULT、EINTR、EAGAIN。Linux 的 IOV_MAX 是 1024,用 sysconf(_SC_IOV_MAX) 查。
短写后不能重试整个数组,要推进 iovec。下面程序的 writev_full() 就是正确写法:每轮只提交剩余段,按已写字节数推进或切短当前段。
/* iov_log.c — writev 写头+体;pwrite 原地改 seq */
#include <errno.h>
#include <fcntl.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/uio.h>
#include <unistd.h>
struct rec_hdr { uint32_t len, seq; };
static ssize_t writev_full(int fd, struct iovec *iov, int iovcnt)
{
size_t total = 0;
int i = 0;
while (i < iovcnt) {
ssize_t n = writev(fd, &iov[i], iovcnt - i);
if (n == -1) {
if (errno == EINTR) continue;
return -1;
}
if (n == 0) { errno = EIO; return -1; }
total += (size_t)n;
size_t left = (size_t)n;
while (i < iovcnt && left >= iov[i].iov_len) {
left -= iov[i].iov_len;
i++;
}
if (i < iovcnt) {
iov[i].iov_base = (char *)iov[i].iov_base + left;
iov[i].iov_len -= left;
}
}
return (ssize_t)total;
}
int main(void)
{
const char *msg = "disk full on /var; retry scheduled";
struct rec_hdr h = { .len = (uint32_t)strlen(msg), .seq = 0 };
struct iovec iov[2] = {
{ .iov_base = &h, .iov_len = sizeof h },
{ .iov_base = (void *)msg, .iov_len = strlen(msg) },
};
int fd = open("iov_demo.bin", O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
if (fd == -1) { perror("open"); return 1; }
if (writev_full(fd, iov, 2) == -1) { perror("writev"); return 1; }
uint32_t seq = 1;
if (pwrite(fd, &seq, sizeof seq, offsetof(struct rec_hdr, seq)) == -1) {
perror("pwrite"); return 1;
}
if (fsync(fd) == -1) { perror("fsync"); return 1; }
struct rec_hdr back;
char body[64] = {0};
ssize_t n;
do { n = pread(fd, &back, sizeof back, 0); } while (n == -1 && errno == EINTR);
if (n != (ssize_t)sizeof back || back.len >= sizeof body) {
fprintf(stderr, "bad header\n"); return 1;
}
do { n = pread(fd, body, back.len, (off_t)sizeof back); } while (n == -1 && errno == EINTR);
if (n != (ssize_t)back.len) { fprintf(stderr, "bad body\n"); return 1; }
printf("seq=%u len=%u body=%s\n", back.seq, back.len, body);
close(fd);
unlink("iov_demo.bin");
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 iov_log.c -o iov_log
./iov_log # seq=1 len=30 body=disk full on /var; retry scheduled
POSIX 规定 pwrite 忽略 O_APPEND,在指定偏移写。Linux 不遵守:fd 带 O_APPEND 时,pwrite 仍追加到末尾,offset 被忽略(见 man 2 pwrite 的 BUGS)。可移植代码不要在 O_APPEND fd 上用 pwrite,或先清掉 O_APPEND。
2.3.4 mmap:把文件页映射进地址空间
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
int msync(void *addr, size_t length, int flags);
mmap 在进程地址空间建一个 VMA(vm_area_struct),把 [offset, offset+length) 按页映射进来。访问映射区就是访问 page cache;缺页时由 filemap_fault() 读入文件页。它既不「把文件读进内存」,也不绕过内核。
进程虚拟地址空间 page cache 磁盘 ┌────────────────────┐ │ 代码 / 堆 / 栈 │ │ ┌────────────────┐ │ VMA: vm_start..vm_end │ │ mmap 区域 │─┼────► 页表项 ─────► page cache 页 ─────► 文件块 │ │ addr .. addr+n │ │ 缺页时 filemap_fault 读入 │ └────────────────┘ │ └────────────────────┘ MAP_SHARED:写页 = 改 page cache;msync/回写后落盘,其他进程可见 MAP_PRIVATE:写页触发 COW,改动只在本进程,文件不变
| 参数 | 取值 | 含义 |
|---|---|---|
prot | PROT_READ/WRITE/EXEC/NONE | 映射区权限,不能超过 fd 的打开模式 |
flags | MAP_SHARED / MAP_PRIVATE | 写回文件 / 写时复制 |
MAP_ANONYMOUS | 不关联文件,fd 传 -1 | |
MAP_FIXED | 强制地址,会覆盖已有映射,危险 | |
MAP_POPULATE | 预读全部页 |
成功返回映射区首地址;失败返回 MAP_FAILED((void *)-1),不是 NULL。errno:EACCES(fd 模式与 prot 冲突)、EINVAL(offset 未页对齐或 length==0)、ENODEV(文件系统不支持)、ENOMEM、EOVERFLOW。
两个硬约束:offset 必须页对齐;length 向上取整到页,最后一页超出 EOF 的部分读为 0,但写回去不会扩展文件。
SIGBUS:映射后文件被截断
mmap 只建映射,不锁文件大小。若另一个进程 truncate 了文件,访问超出新 EOF 的整页会触发 SIGBUS,默认终止进程。read 在同样情况下只返回 0。这就是「mmap 不能假设文件大小不变」的原因。
int fd = open("bus.bin", O_RDWR | O_CREAT | O_TRUNC, 0644);
ftruncate(fd, 8192);
char *p = mmap(NULL, 8192, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
ftruncate(fd, 4096); /* 另一个进程做同样的事也会触发 */
printf("%c\n", p[4096]); /* SIGBUS */
正确做法:访问前 fstat 比对 st_size,或处理 SIGBUS 并重建映射。不要把「文件不会被截断」当前提,除非你持有写锁。
msync(addr, len, MS_SYNC) 把脏页写回并等待;MS_ASYNC 只排队;MS_INVALIDATE 让其他映射失效。addr 必须页对齐,否则 EINVAL。munmap 不保证写回。madvise 只是提示:MADV_SEQUENTIAL 加大预读、MADV_WILLNEED 提前读、MADV_DONTNEED 丢页。
完整示例:原地改写并验证
/* mmap_scan.c — 映射临时文件、原地大写、msync 后读回验证 */
#include <ctype.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h> /* mkstemp */
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
int main(void)
{
char path[] = "/tmp/mmap_demo.XXXXXX";
int fd = mkstemp(path);
if (fd == -1) { perror("mkstemp"); return 1; }
const char *text = "hello mmap\nsecond line\n";
size_t len = strlen(text);
if (write(fd, text, len) != (ssize_t)len) { perror("write"); return 1; }
struct stat st;
if (fstat(fd, &st) == -1) { perror("fstat"); return 1; }
char *p = mmap(NULL, (size_t)st.st_size, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
for (off_t i = 0; i < st.st_size; i++)
p[i] = (char)toupper((unsigned char)p[i]);
if (msync(p, (size_t)st.st_size, MS_SYNC) == -1)
perror("msync"); /* 记录;munmap 后仍可能回写 */
if (munmap(p, (size_t)st.st_size) == -1)
perror("munmap");
char buf[64] = {0};
ssize_t n = pread(fd, buf, sizeof buf - 1, 0);
if (n == -1) { perror("pread"); return 1; }
printf("file now: %s", buf);
close(fd);
unlink(path);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 mmap_scan.c -o mmap_scan
./mmap_scan # file now: HELLO MMAP / SECOND LINE
适合:随机访问大文件、多进程共享只读数据、把文件当数组。
不适合:小文件(建映射比读一次贵)、流式处理(read + 预读更简单)、网络文件系统(缺页延迟不可控)、O_DIRECT 场景、32 位地址空间里映射超大文件。
映射建立之后:mprotect、mlock、mincore 与 mremap
mmap 建好映射后,还可以改保护、锁页、查询驻留、扩缩容。这组调用把「虚拟内存操作」补齐,边界比 mmap 更细:
int mprotect(void *addr, size_t len, int prot); /* 改权限,addr/len 页对齐 */
int mlock(const void *addr, size_t len); /* 锁在 RAM,不换出 */
int munlock(const void *addr, size_t len);
int mincore(void *addr, size_t len, unsigned char *vec); /* 每页一字节,bit0=驻留 */
void *mremap(void *old, size_t oldsz, size_t newsz, int flags, ...);
mprotect 最常见的用途是「先读写、后只读」和 guard page:mmap(PROT_NONE) 一块,再对中间 mprotect(PROT_READ|PROT_WRITE)。给 MAP_SHARED 的文件映射加 PROT_WRITE 时,fd 必须以可写方式打开,否则 EACCES;PROT_EXEC 还受 noexec 挂载与 W^X 策略限制。
mlock 把页固定在物理内存,避免实时路径被换页打断;未特权进程受 RLIMIT_MEMLOCK 限制(systemd 服务还有 MemoryLock=)。锁不跨 fork,execve 时自动解除。mincore 只告诉你「现在在不在内存」,结果随时会变,适合监控而不是控制流。mremap 用 MREMAP_MAYMOVE 扩缩映射,可能搬家并返回新地址;realloc 对大块内存会走它。
2.3.5 零拷贝:sendfile、splice、copy_file_range
| 接口 | 输入 | 输出 | 典型用途 |
|---|---|---|---|
sendfile | 文件 fd | socket(2.6.33 起也可为文件) | 静态文件服务器 |
splice | 至少一端是管道 | 文件/socket | 代理、转发 |
copy_file_range | 文件 fd | 文件 fd | 同机复制、reflink |
三者都返回「实际传输字节数」,可能为 0(EOF)或短于请求,必须循环。errno:EINVAL(fd 类型不支持、源目标相同)、EXDEV(跨文件系统)、EOPNOTSUPP(文件系统不支持)、ENOSYS(内核太老)、EAGAIN/EPIPE(管道)。
/* 返回复制字节数;-2 表示应回退到 read/write,-1 表示真错误 */
static off_t copy_range(int in, int out, off_t len)
{
off_t done = 0;
while (done < len) {
ssize_t n = copy_file_range(in, NULL, out, NULL,
(size_t)(len - done), 0);
if (n == -1) {
if (errno == EINTR) continue;
if (errno == EXDEV || errno == EOPNOTSUPP || errno == ENOSYS)
return -2;
return -1;
}
if (n == 0) break;
done += n;
}
return done;
}
- 「零拷贝 = 完全不拷贝」——数据仍要从 page cache 到网卡(DMA),省掉的是「内核 → 用户 → 内核」那一份。
- 「
copy_file_range一定更快」——同文件系统且支持 reflink 时快;跨文件系统可能退化或返回EXDEV。 - 「
splice可任意连两个 fd」——至少一端必须是管道,否则EINVAL。
2.3.6 文件锁:flock 与 fcntl 记录锁
| 维度 | flock(BSD) | fcntl 记录锁(System V) |
|---|---|---|
| 锁范围 | 整个文件 | 字节区间 |
| 归属 | 打开文件描述 | 进程 |
fork 后 | 子进程继承并共享 | 子进程不继承 |
| 释放 | LOCK_UN 或最后一个 fd 关闭 | 进程关闭任意指向该文件的 fd 即释放全部 |
| 阻塞控制 | LOCK_NB | F_SETLK 非阻塞 / F_SETLKW 阻塞 |
/proc/locks | FLOCK | POSIX |
struct flock {
short l_type; /* F_RDLCK / F_WRLCK / F_UNLCK */
short l_whence; /* SEEK_SET / SEEK_CUR / SEEK_END */
off_t l_start; /* 相对 l_whence 的起点 */
off_t l_len; /* 0 表示到文件末尾,含未来增长 */
pid_t l_pid; /* F_GETLK 返回持锁者 */
};
F_SETLK 冲突时返回 -1,errno 为 EACCES 或 EAGAIN(Linux 用 EAGAIN);F_SETLKW 可能被信号打断返回 EINTR,检测到死锁返回 EDEADLK。F_GETLK 不加锁:无人阻挡时把 l_type 改成 F_UNLCK,否则填入阻挡者信息。
/* lockfile.c — 用记录锁实现单实例;第二个实例退出码 2 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
if (argc != 2) { fprintf(stderr, "usage: %s LOCKFILE\n", argv[0]); return 1; }
int fd = open(argv[1], O_RDWR | O_CREAT | O_CLOEXEC, 0644);
if (fd == -1) { perror("open"); return 1; }
struct flock fl = { .l_type = F_WRLCK, .l_whence = SEEK_SET,
.l_start = 0, .l_len = 0 };
if (fcntl(fd, F_SETLK, &fl) == -1) {
if (errno == EAGAIN || errno == EACCES) {
fprintf(stderr, "another instance holds %s\n", argv[1]);
return 2;
}
perror("fcntl"); return 1;
}
printf("pid %ld holds the lock; sleeping 30s\n", (long)getpid());
sleep(30); /* 关闭 fd 或进程退出即释放 */
close(fd);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 lockfile.c -o lockfile
./lockfile /tmp/demo.lock & # 第一个实例持锁
./lockfile /tmp/demo.lock; echo "exit=$?" # exit=2
grep demo /proc/locks
$ ./lockfile /tmp/demo.lock & [1] 48211 pid 48211 holds the lock; sleeping 30s $ ./lockfile /tmp/demo.lock; echo "exit=$?" another instance holds /tmp/demo.lock exit=2 $ grep demo /proc/locks 1: POSIX ADVISORY WRITE 48211 08:01:7340033 0 EOF
这一行的字段依次是:序号、锁类型(POSIX/FLOCK/OFDLCK)、ADVISORY、READ/WRITE、持有者 PID、major:minor:inode、起始字节、结束字节(EOF 表示到文件末尾)。OFD 锁的 PID 是 -1,因为锁属于打开文件描述而不是某个进程;lslocks 的输出更易读。/proc/locks 从 Linux 4.9 起按 PID 命名空间过滤,容器里看不到宿主机的锁。不要按 MANDATORY 设计程序:强制锁在 Linux 5.15 已被移除。
三个必须记住的陷阱:
- 关闭任意 fd 就释放全部记录锁。同一文件打开两次,
close其中一个,另一个 fd 上的锁也没了。解决办法:加锁后别再open同一文件,或改用F_OFD_SETLK(Linux 3.15+,锁归属打开文件描述)。 fork后子进程不继承记录锁,但继承flock。用flock做守护进程锁时,子进程共享锁,父进程退出不会释放。- 锁与 stdio 缓冲的顺序:加锁 → 写 →
fflush→ 解锁。否则另一个进程拿到锁时看不到你的输出。
lockf():老代码里的 fcntl 简写
lockf(fd, F_LOCK, len) 是 fcntl(F_SETLK/F_SETLKW) 的便捷封装:F_LOCK 等价「写锁 + 阻塞」,F_TLOCK 非阻塞,F_ULOCK 解锁,F_TEST 测试。它只能锁从当前偏移开始的区间,不能指定起始偏移;要锁任意区间,仍用 fcntl 的 struct flock。
#include <unistd.h>
lseek(fd, 0, SEEK_SET);
if (lockf(fd, F_TLOCK, 0) == -1) { /* len=0 表示到文件末尾 */
if (errno == EACCES || errno == EAGAIN) { /* 已被占用 */ }
}
...
lockf(fd, F_ULOCK, 0);
lockf 与 fcntl 锁在 Linux 上共享同一套 POSIX 记录锁;flock 是另一套(BSD)。三者混用不会互斥,这是老代码迁移时最常见的坑。
2.3.7 原子发布:临时文件 + fsync + rename + 目录 fsync
O_TRUNC 先把文件清空;崩溃时留下空文件或半个文件,读进程也可能看到半成品。正确做法是写新的,再原子替换:
int fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0644); /* 同目录 */
write_full(fd, data, len);
fsync(fd); /* 1. 数据落盘 */
close(fd);
rename(tmp, dst); /* 2. 原子替换(同一文件系统) */
fsync(dirfd); /* 3. 目录项落盘 */
第 3 步常被忽略:rename 改的是目录项。只 fsync 文件,断电后可能出现「新文件内容在、名字还是旧的」甚至「两个名字都丢」。rename 在同一文件系统内保证 dst 要么旧、要么新,不会不存在;跨文件系统返回 EXDEV。
/* publish.c — 取排他锁,写临时文件,fsync,rename,fsync 目录 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
static int write_full(int fd, const char *buf, size_t len)
{
size_t done = 0;
while (done < len) {
ssize_t n = write(fd, buf + done, len - done);
if (n == -1) { if (errno == EINTR) continue; return -1; }
if (n == 0) { errno = EIO; return -1; }
done += (size_t)n;
}
return 0;
}
static int fsync_parent(const char *path)
{
char dir[4096];
const char *slash = strrchr(path, '/');
if (!slash) snprintf(dir, sizeof dir, ".");
else if (slash == path) snprintf(dir, sizeof dir, "/");
else snprintf(dir, sizeof dir, "%.*s", (int)(slash - path), path);
int fd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd == -1) return -1;
int rc = fsync(fd), saved = errno;
close(fd);
errno = saved;
return rc;
}
int main(int argc, char *argv[])
{
if (argc != 3) { fprintf(stderr, "usage: %s DEST CONTENT\n", argv[0]); return 1; }
char lock[4096], tmp[4096];
snprintf(lock, sizeof lock, "%s.lock", argv[1]);
snprintf(tmp, sizeof tmp, "%s.tmp.%ld", argv[1], (long)getpid());
int lockfd = open(lock, O_RDWR | O_CREAT | O_CLOEXEC, 0644);
if (lockfd == -1) { perror("lock"); return 1; }
struct flock fl = { .l_type = F_WRLCK, .l_whence = SEEK_SET,
.l_start = 0, .l_len = 0 };
if (fcntl(lockfd, F_SETLK, &fl) == -1) { perror("lock"); return 1; }
int fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0644);
if (fd == -1) { perror("open tmp"); return 1; }
if (write_full(fd, argv[2], strlen(argv[2])) == -1 ||
write_full(fd, "\n", 1) == -1 ||
fsync(fd) == -1) {
perror("write/fsync");
close(fd);
unlink(tmp);
return 1;
}
if (close(fd) == -1) { perror("close"); unlink(tmp); return 1; }
if (rename(tmp, argv[1]) == -1) { perror("rename"); unlink(tmp); return 1; }
if (fsync_parent(argv[1]) == -1)
perror("fsync dir"); /* 某些文件系统不支持目录 fsync */
printf("published %zu bytes to %s\n", strlen(argv[2]) + 1, argv[1]);
close(lockfd);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 publish.c -o publish
./publish /tmp/app.conf '{"workers":4}' && cat /tmp/app.conf
strace -e trace=openat,write,fsync,rename,close ./publish /tmp/app.conf '{"workers":8}' 2>&1 | tail -8
- 「临时文件放
/tmp再 rename」——跨文件系统rename返回EXDEV,临时文件必须与目标同目录。 - 「
fsync文件就够了」——目录项也要fsync。 - 「
O_TRUNC+ 写是原子的」——不是,先清空再写,崩溃留空文件。 - 「
rename后要删临时文件」——临时名字已经消失,unlink(tmp)返回ENOENT,别当错误。
O_TMPFILE 可以连临时名字都不要:
int fd = open("/srv/app", O_TMPFILE | O_WRONLY | O_CLOEXEC, 0644);
write_full(fd, data, len);
fsync(fd);
char proc[64];
snprintf(proc, sizeof proc, "/proc/self/fd/%d", fd);
linkat(AT_FDCWD, proc, AT_FDCWD, "/srv/app/conf.new", AT_SYMLINK_FOLLOW);
linkat(fd, "", ..., AT_EMPTY_PATH) 更直接,但需要 CAP_DAC_READ_SEARCH;普通进程用 /proc/self/fd 这条。
2.3.8 O_DIRECT、fallocate 与 ftruncate
O_DIRECT 绕过 page cache,代价是对齐:缓冲区地址、偏移、长度都必须是逻辑块大小(通常 512 或 4096)的整数倍,否则返回 EINVAL。它适合自建缓存、数据库 WAL;普通应用用它通常更慢。
void *buf;
if (posix_memalign(&buf, 4096, 4096) != 0) errExit("posix_memalign");
ssize_t n = pread(fd, buf, 4096, 0); /* 三者都按 4096 对齐 */
fallocate(fd, mode, offset, len) 真正分配磁盘块:默认扩展文件大小,FALLOC_FL_KEEP_SIZE 只分配不扩展,FALLOC_FL_PUNCH_HOLE|FALLOC_FL_KEEP_SIZE 打洞,FALLOC_FL_ZERO_RANGE 清零。失败:EOPNOTSUPP、ENOSPC、EINVAL、EFBIG。
ftruncate(fd, len) 截断或扩展;扩展部分是稀疏空洞,读为 0,不占块。这正是 st_size 与 st_blocks 不同的原因(见 2.5):
truncate -s 1G sparse.img && ls -lh sparse.img && du -h sparse.img # 1.0G / 0
ftruncate 之后,已有 mmap 访问超出新 EOF 的页会触发 SIGBUS(2.3.4)。
2.3.9 mmap 参数与映射类型全表
这一节回答:MAP_SHARED 与 MAP_PRIVATE 之外,那些 MAP_* 标志各自改变什么?匿名映射、文件映射、巨页映射在 fork 后如何表现?
2.3.4 给了 mmap 的基本模型。Kerrisk 第 49 章把 flags 分成「必须二选一」和「可叠加」两类(p882、p893):MAP_SHARED/MAP_PRIVATE 必须有且只有一个,其余是可选的修饰位。
| flag | 作用 | 边界/失败 |
|---|---|---|
MAP_SHARED | 写反映到底层文件,对其他映射可见 | 文件映射要求 PROT_WRITE 时 fd 可写 |
MAP_PRIVATE | 写触发 COW,改动只在本进程 | fd 只读也可写(只要 PROT_WRITE) |
MAP_SHARED_VALIDATE | 同 MAP_SHARED,但未知 flag 返回 EOPNOTSUPP | 用于探测新特性 |
MAP_ANONYMOUS | 无底层文件,fd 传 -1 | 内容初始化为 0(除非 MAP_UNINITIALIZED) |
MAP_FIXED | 强制使用 addr,覆盖已有映射 | 危险;地址冲突会静默替换 |
MAP_FIXED_NOREPLACE | 地址被占用则 EEXIST,不覆盖 | Linux 4.17+ |
MAP_POPULATE | 建映射时预读/预缺页 | 不保证之后不被换出 |
MAP_NONBLOCK | 只与 MAP_POPULATE 合用,不阻塞预读 | 可能什么都没填充 |
MAP_LOCKED | 等价于建映射后 mlock | 受 RLIMIT_MEMLOCK 约束 |
MAP_NORESERVE | 不为交换空间预留 | 与 overcommit_memory 交互 |
MAP_HUGETLB | 使用巨页 | 需要预留巨页/权限,否则 ENOMEM |
MAP_STACK | 提示内核这是栈 | 目前基本无效果 |
MAP_GROWSDOWN | 向下增长的映射(旧栈语义) | 易被滥用,现代程序不用 |
MAP_SYNC | 写直达持久内存(DAX) | 只对支持 DAX 的文件有效 |
MAP_DENYWRITE/MAP_EXECUTABLE | 历史兼容位 | Linux 忽略 |
MAP_FIXED 与 MAP_FIXED_NOREPLACE 的区别值得单独记:前者会解除已有映射再建立新映射,后者在目标区间已被占用时返回 EEXIST。做「把文件的一段映射到预留地址」时,正确的顺序是先用 mmap(NULL, ...) 建匿名保护区,再用 MAP_FIXED 覆盖其中一段;不要用 MAP_FIXED 去赌一个「应该空闲」的地址。
四种映射类型
| 类型 | 创建方式 | 写操作 | fork 后 |
|---|---|---|---|
| 私有文件映射 | MAP_PRIVATE + fd | COW,不写回文件 | 父子各自 COW |
| 共享文件映射 | MAP_SHARED + fd | 写 page cache,最终回写文件 | 共享同一物理页 |
| 私有匿名映射 | `MAP_PRIVATE | MAP_ANONYMOUS` | 本进程私有,初始 0 |
| 共享匿名映射 | `MAP_SHARED | MAP_ANONYMOUS` | 所有映射者共享 |
第四种是 fork 后父子通信最便宜的方式之一(不需要文件、不需要 shm_open);memfd_create + MAP_SHARED 则把「共享匿名内存」变成了「有 fd、可传、可加 seal」的对象(2.3.12)。
prot 与 fd 打开模式的精确规则
Kerrisk 49.4.4(p846)把交互规则讲得很细,比「prot 不能超过 fd 模式」更精确:
| fd 模式 | MAP_PRIVATE | MAP_SHARED |
|---|---|---|
O_RDONLY | 任意 prot(写只影响私有副本) | 只能 PROT_READ(可加 PROT_EXEC) |
O_WRONLY | 任何 prot 都 EACCES | 任何 prot 都 EACCES |
O_RDWR | 任意 prot | 任意 prot |
原因在硬件:很多架构不支持「只写可映射」的页,PROT_WRITE 隐含可读;而 O_WRONLY 打开的文件不允许读出初始内容,所以内核直接拒绝。mmap 成功后可以立刻 close(fd),映射仍然有效——struct file 的引用计数还在。
- 「
MAP_PRIVATE写会写回文件」——不会,COW 后的页只在本进程;想落盘用MAP_SHARED+msync。 - 「
MAP_ANONYMOUS必须传 fd=0」——Linux 忽略 fd,但可移植代码应传 -1;MAP_ANONYMOUS需要_GNU_SOURCE/_DEFAULT_SOURCE。 - 「
MAP_POPULATE保证之后不缺页」——只保证建映射时预读;内存压力下仍会被换出。 - 「
MAP_LOCKED不需要权限」——受RLIMIT_MEMLOCK约束,超限ENOMEM/EPERM。 - 「
MAP_FIXED会失败而不是覆盖」——它会静默解除重叠映射,踩掉别人的映射;要安全用MAP_FIXED_NOREPLACE。
2.3.10 缺页路径与 SIGBUS/SIGSEGV 的分界
这一节回答:访问映射区时,什么时候是 SIGSEGV,什么时候是 SIGBUS?为什么「文件被截断」比「越界」更难防?
mmap 只建立 VMA(vm_area_struct),不读文件。第一次访问触发缺页异常,内核走 handle_mm_fault():
| 情况 | 内核路径 | 结果 |
|---|---|---|
| 页在 page cache | filemap_fault → filemap_map_pages | 建立 PTE,无磁盘 I/O |
| 页不在 cache | filemap_fault → page_cache_sync_readahead/read_folio | 读盘后映射 |
| 私有映射写 | do_wp_page(COW) | 复制一页,改 PTE 为可写 |
| 共享映射写 | do_shared_fault → ->page_mkwrite | 标记页脏,等待回写 |
| 地址不在任何 VMA | do_user_addr_fault → bad_area | SIGSEGV |
| 地址在 VMA 内但超出文件 EOF 的整页 | filemap_fault 返回 VM_FAULT_SIGBUS | SIGBUS |
关键分界(Kerrisk 49.4.3,p845):
- 超出映射长度的地址 →
SIGSEGV(不在 VMA 里)。 - 在映射长度内、但落在文件 EOF 之后的整页 →
SIGBUS(VMA 有,文件没有对应页)。 - EOF 所在页的尾部(同一页内、超出 EOF 的字节)→ 可访问,读为 0,写入不落盘、不与其他进程共享。
所以「映射 8 KiB、文件实际 5 KiB」时,偏移 5000–8191 可读为 0,偏移 8192 起才 SIGBUS;而「另一个进程把文件截断到 4 KiB」会让原来有效的第 5 KiB 变成 SIGBUS。read 在同样情况下只返回 0,这是 mmap 与 read 最重要的行为差异。
/* mmap_bus.c — 子进程访问被截断文件的页,父进程报告 SIGBUS */
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
const char *path = "mmap_bus.bin";
int fd = open(path, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
if (fd == -1) { perror("open"); return 1; }
if (ftruncate(fd, 8192) == -1) { perror("ftruncate"); return 1; }
char *p = mmap(NULL, 8192, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
int sync[2];
if (pipe(sync) == -1) { perror("pipe"); return 1; }
pid_t pid = fork();
if (pid == -1) { perror("fork"); return 1; }
if (pid == 0) {
close(sync[1]);
char c;
if (read(sync[0], &c, 1) != 1) _exit(2); /* 等父进程截断 */
volatile char x = p[4096]; /* 第 5 KiB:SIGBUS */
printf("child read %d (unexpected)\n", (int)x);
_exit(0);
}
close(sync[0]);
/* 父进程把文件截断到 4 KiB,再放行子进程 */
if (ftruncate(fd, 4096) == -1) { perror("ftruncate 2"); return 1; }
if (write(sync[1], "x", 1) != 1) { perror("write pipe"); return 1; }
close(sync[1]);
int status;
if (waitpid(pid, &status, 0) == -1) { perror("waitpid"); return 1; }
if (WIFSIGNALED(status))
printf("child killed by signal %d (%s)\n",
WTERMSIG(status), strsignal(WTERMSIG(status)));
else
printf("child exited normally, status=%d\n", WEXITSTATUS(status));
munmap(p, 8192);
close(fd);
unlink(path);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 mmap_bus.c -o mmap_bus
./mmap_bus
# child killed by signal 7 (Bus error)
SIGBUS 的默认动作是终止并 core dump;生产程序若必须使用 mmap,要么在访问前 fstat 检查 st_size,要么安装 SIGBUS 处理器用 sigsetjmp/siglongjmp 跳出(注意 siglongjmp 不在 POSIX 的 async-signal-safe 列表里,只能在「没有其他线程、且能接受未定义行为风险」的受限场景用)。更稳的做法是不把文件大小当不变式:每次访问前重新 fstat,或用文件锁阻止截断。
SIGSEGV 表示「你访问了不属于你的地址」,SIGBUS 表示「地址属于你,但背后的对象不在了」。前者是程序 bug,后者是外部状态变化(文件被截断、设备被拔)。分开报错,调试时才能区分「越界」与「文件变了」。
2.3.11 msync、madvise、mlock、mincore 与 mremap 全表
这一节回答:映射建立之后,怎样控制回写、预读、驻留与扩缩容?哪些调用只是「提示」,哪些有强制语义?
msync:只对共享映射有意义
int msync(void *addr, size_t length, int flags);
成功返回 0,失败 -1;EINVAL(addr 未页对齐、flags 非法)、ENOMEM(区间无映射)、EBUSY(MS_INVALIDATE 与进行中的 I/O 冲突)。MS_SYNC 等数据落到设备;MS_ASYNC 只保证对其他进程可见(进了 page cache),不保证落盘;MS_INVALIDATE 让缓存副本失效,下次访问重新从文件读。
Linux 是统一页缓存(unified page cache):mmap 与 read/write 看到的是同一份 page cache,所以 msync 的主要作用是强制回写,而不是「让其他进程看见」。Kerrisk 49.5(p847)特别指出,SUSv3 不要求统一页缓存,可移植程序仍需在映射与 read 混用时正确使用 msync/MS_INVALIDATE。
madvise:一组「建议」,不是命令
| advice | 语义 | 注意事项 |
|---|---|---|
MADV_NORMAL | 默认预读 | |
MADV_RANDOM | 随机访问,禁用预读 | |
MADV_SEQUENTIAL | 顺序访问,激进预读 | 可能提前释放已读页 |
MADV_WILLNEED | 预读进内存 | 不保证保留多久 |
MADV_DONTNEED | 不再需要这些页 | 私有映射会丢弃修改,见下 |
MADV_FREE | 页可回收,但保留内容直到压力 | Linux 4.5+;与 DONTNEED 不同 |
MADV_REMOVE | 释放并打洞(共享映射) | 需要文件系统支持 |
MADV_PAGEOUT | 主动回收页 | Linux 5.4+ |
MADV_COLD | 标记为冷页 | Linux 5.4+ |
MADV_HUGEPAGE/MADV_NOHUGEPAGE | 控制 THP | |
MADV_DONTFORK/MADV_DOFORK | fork 时是否保留 | 影响子进程地址空间 |
MADV_WIPEONFORK/MADV_KEEPONFORK | 子进程是否清零 | Linux 4.14+ |
MADV_MERGEABLE/MADV_UNMERGEABLE | KSM 合并 | 有侧信道风险 |
MADV_DONTNEED 在 Linux 上的语义是破坏性的:对 MAP_PRIVATE 匿名映射,它丢弃页上的修改,下一次访问读回 0;对文件私有映射,重新从文件读。Kerrisk 50.4(p866)明确提醒:可移植程序不应依赖这种语义,其他 UNIX 可能只是「建议换出」。
mlock/mlockall:锁进物理内存
int mlock(const void *addr, size_t len);
int mlock2(const void *addr, size_t len, int flags); /* MLOCK_ONFAULT */
int mlockall(int flags); /* MCL_CURRENT|MCL_FUTURE|MCL_ONFAULT */
mlock 以页为单位,addr 不必页对齐(内核向下取整),len 向上取整。失败:ENOMEM(物理内存不足或超 RLIMIT_MEMLOCK)、EPERM(无 CAP_IPC_LOCK 且超限)、EINVAL(flags 非法)。
语义要点(Kerrisk 50.2,p861–863):
- 非特权进程受
RLIMIT_MEMLOCK软限制;2.6.9 起默认是 8 页(32 KiB on x86-32),很多发行版调高。 - 锁不被
fork继承,exec时解除;munmap/MAP_FIXED覆盖也会解除。 - 锁不叠加:同一页
mlock两次,munlock一次就解锁;但同一文件映射到两个地址分别加锁,要两次munlock。 mlockall(MCL_FUTURE)会让后续mmap/sbrk可能失败,甚至栈增长触发SIGSEGV。
/proc/PID/status 的 VmLck 字段给出当前锁住的字节数;ulimit -l 是 RLIMIT_MEMLOCK 的 shell 视图。
mincore:查询驻留,不保证未来
int mincore(void *addr, size_t length, unsigned char *vec);
vec 每页一个字节,最低位为 1 表示该页当前在 RAM。length 向上取整到页;vec 至少要 (length + PAGE_SIZE - 1) / PAGE_SIZE 字节。失败:ENOMEM(区间无映射)、EFAULT、EINVAL(addr 未页对齐)。结果是快照:查询返回后页可能立刻被换出,只能用于监控/启发式,不能用于正确性判断。
mremap:扩缩映射
void *mremap(void *old_address, size_t old_size, size_t new_size, int flags, ...);
MREMAP_MAYMOVE 允许内核搬家(返回新地址);不带它且原地空间不足返回 ENOMEM。MREMAP_FIXED(需同时 MAYMOVE)把映射搬到指定地址,额外参数 new_address。MREMAP_DONTUNMAP(5.7+)把映射搬到新地址但保留旧映射,用于 userfaultfd 场景。
成功返回新起始地址,可能不等于旧地址;因此映射里的指针必须用偏移量保存,不能保存绝对地址。realloc 对大块 mmap 内存会走 mremap,避免整块复制。
/* mmap_ops.c — mincore/mprotect/madvise/mlock/mremap 的行为 */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
int main(void)
{
long ps = sysconf(_SC_PAGESIZE);
size_t len = (size_t)ps * 2;
char *p = mmap(NULL, len, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
/* 1. mincore:未触碰的匿名页不在 RAM */
unsigned char vec[8] = {0};
size_t pages = len / (size_t)ps;
if (mincore(p, len, vec) == -1) perror("mincore 1");
printf("before touch: vec[0]=%u vec[1]=%u\n", vec[0], vec[1]);
p[0] = 'A';
if (mincore(p, len, vec) == -1) perror("mincore 2");
printf("after touch: vec[0]=%u vec[1]=%u\n", vec[0], vec[1]);
/* 2. madvise(DONTNEED):私有匿名页的修改被丢弃 */
p[0] = 'B';
if (madvise(p, (size_t)ps, MADV_DONTNEED) == -1) perror("madvise");
printf("after DONTNEED: p[0]=%d (0 表示已丢弃)\n", (int)p[0]);
/* 3. mprotect:改保护位;这里只验证调用成功,不故意触发 SIGSEGV */
if (mprotect(p + ps, (size_t)ps, PROT_READ) == -1)
perror("mprotect");
else
printf("mprotect second page -> PROT_READ ok\n");
/* 恢复可写,否则下一步 mremap 的区间跨两个不同保护的 VMA */
if (mprotect(p + ps, (size_t)ps, PROT_READ | PROT_WRITE) == -1)
perror("mprotect restore");
/* 4. mlock:可能因 RLIMIT_MEMLOCK 失败,失败不是致命 */
errno = 0;
if (mlock(p, (size_t)ps) == -1)
printf("mlock: %s\n", strerror(errno));
else
printf("mlock first page: ok\n");
/* 5. mremap:扩到 4 页,允许搬家 */
char *q = mremap(p, len, (size_t)ps * 4, MREMAP_MAYMOVE);
if (q == MAP_FAILED) { perror("mremap"); return 1; }
printf("mremap: %s, new page writable\n", q == p ? "same address" : "moved");
q[3 * ps] = 'C';
munmap(q, (size_t)ps * 4);
(void)pages;
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 mmap_ops.c -o mmap_ops
./mmap_ops
# before touch: vec[0]=0 vec[1]=0
# after touch: vec[0]=1 vec[1]=0
# after DONTNEED: p[0]=0 (0 表示已丢弃)
# mprotect second page -> PROT_READ ok
# mlock first page: ok
# mremap: moved, new page writable
madvise(DONTNEED) 后 p[0] 变回 0,是 Linux 私有匿名映射的破坏性语义;如果这是业务数据,调用它就是数据丢失。
- 「
msync对MAP_PRIVATE也能把修改写回文件」——不能;私有映射的修改从不落盘。 - 「
madvise(DONTNEED)只是提示,不会丢数据」——在 Linux 的私有匿名映射上会丢。 - 「
mincore返回 1 就保证访问不缺页」——结果只是快照,页可能立刻被回收。 - 「
mremap返回的地址一定不变」——带MREMAP_MAYMOVE时可能搬家,绝对指针失效。 - 「
mlock能锁任意多内存」——非特权进程受RLIMIT_MEMLOCK限制,超限ENOMEM。
2.3.12 memfd_create 与 seals:可封印的匿名共享内存
这一节回答:怎样在不落盘、不依赖 /tmp 的情况下,把一块内存变成可 fork、可传 fd、还能防止被改写的对象?
memfd_create(Linux 3.17)创建一个匿名文件,返回 fd。它没有路径,只在 /proc/PID/fd/N 下可见;可以 mmap、write、fork 继承、通过 SCM_RIGHTS 传给其他进程。加上 MFD_ALLOW_SEALING 后,还能用 fcntl 加 seal(封印),把「内容不可变」变成内核强制。
int memfd_create(const char *name, unsigned int flags);
| flag | 含义 |
|---|---|
MFD_CLOEXEC | exec 时关闭 |
MFD_ALLOW_SEALING | 允许后续 F_ADD_SEALS |
MFD_HUGETLB | 使用巨页(可配合 MFD_HUGE_2MB 等) |
失败:EINVAL(未知 flag)、EMFILE/ENFILE、ENOMEM、EPERM(巨页无权限)、ENOSYS(旧内核)。
seals:让「只读」变成不可逆
int fcntl(int fd, F_ADD_SEALS, int seals);
int fcntl(int fd, F_GET_SEALS);
| seal | 效果 |
|---|---|
F_SEAL_SEAL | 禁止再添加任何 seal(包括自己) |
F_SEAL_SHRINK | 禁止 ftruncate 缩小 |
F_SEAL_GROW | 禁止 ftruncate 扩大 |
F_SEAL_WRITE | 禁止写(write 返回 EPERM;新建可写映射 EPERM) |
F_SEAL_FUTURE_WRITE | 允许已有可写映射继续写,禁止新的写/可写映射 |
F_SEAL_WRITE 要求当前没有可写映射,否则 EBUSY;F_SEAL_FUTURE_WRITE 就是为「已有可写映射」准备的。加 seal 后不可撤销(除非 F_SEAL_SEAL 尚未设置时……实际上 seal 一旦加上就不能移除,F_SEAL_SEAL 只是阻止继续加)。这让它适合做「配置快照」「共享库镜像」「密封的证书包」。
/* memfd_demo.c — 创建 memfd,写入,加 seal,验证写/截断被拒 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
#ifndef MFD_ALLOW_SEALING
#define MFD_ALLOW_SEALING 0x0002U
#endif
int main(void)
{
int fd = memfd_create("sealed-demo", MFD_CLOEXEC | MFD_ALLOW_SEALING);
if (fd == -1) { perror("memfd_create"); return 1; }
const char *msg = "sealed payload";
if (write(fd, msg, strlen(msg)) != (ssize_t)strlen(msg)) {
perror("write"); return 1;
}
int seals = F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE;
if (fcntl(fd, F_ADD_SEALS, seals) == -1) { perror("F_ADD_SEALS"); return 1; }
printf("seals=%#x\n", fcntl(fd, F_GET_SEALS));
errno = 0;
ssize_t n = write(fd, "x", 1);
printf("write after seal: n=%zd errno=%d (%s)\n", n, errno, strerror(errno));
errno = 0;
int rc = ftruncate(fd, 1);
printf("ftruncate after seal: rc=%d errno=%d (%s)\n", rc, errno, strerror(errno));
/* 只读映射仍然可以读;fork 后子进程继承同一个 memfd */
char *p = mmap(NULL, (size_t)strlen(msg), PROT_READ, MAP_SHARED, fd, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
printf("read via mmap: %.*s\n", (int)strlen(msg), p);
pid_t pid = fork();
if (pid == 0) {
printf("child sees: %.*s\n", (int)strlen(msg), p);
_exit(0);
}
waitpid(pid, NULL, 0);
munmap(p, strlen(msg));
close(fd);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 memfd_demo.c -o memfd_demo
./memfd_demo
# seals=0xe
# write after seal: n=-1 errno=1 (Operation not permitted)
# ftruncate after seal: rc=-1 errno=1 (Operation not permitted)
# read via mmap: sealed payload
# child sees: sealed payload
seals=0xe 是 F_SEAL_SHRINK(2) | F_SEAL_GROW(4) | F_SEAL_WRITE(8);F_SEAL_SEAL(1) 只有显式添加才会出现。memfd + MFD_ALLOW_SEALING + fexecve 是「从内存执行程序、执行前校验哈希」的常用组合;注意 fexecve 需要 /proc 可用。
2.3.13 fallocate 模式全表与打洞
这一节回答:posix_fallocate 与 Linux fallocate 差在哪?怎样在不改变文件大小的前提下预分配,或把一段数据变成空洞?
int fallocate(int fd, int mode, off_t offset, off_t len);
mode 为 0 时预分配并扩展文件大小;其余模式按位组合:
| mode | 作用 | 限制 |
|---|---|---|
0 | 分配块,文件大小至少到 offset+len | 空间不足 ENOSPC |
FALLOC_FL_KEEP_SIZE | 只分配,不改 st_size | 后续写不会因空间不足失败 |
FALLOC_FL_PUNCH_HOLE | 把区间打成空洞 | 必须与 KEEP_SIZE 合用 |
FALLOC_FL_ZERO_RANGE | 区间清零(可能分配块) | 有些文件系统用写 0 实现 |
FALLOC_FL_COLLAPSE_RANGE | 删除区间并把后面数据前移 | 块对齐,否则 EINVAL |
FALLOC_FL_INSERT_RANGE | 插入空洞并把后面数据后移 | 块对齐 |
FALLOC_FL_UNSHARE_RANGE | 解除共享 extent(reflink) | 5.13+ |
失败:EOPNOTSUPP(文件系统不支持该 mode)、ENOSPC、EDQUOT、EINVAL(对齐/参数)、EFBIG、EPERM(immutable/append-only)、ETXTBSY。fallocate 不改变文件偏移,也不经过 page cache 的常规写路径。
posix_fallocate(fd, offset, len) 是 POSIX 版本,语义等价于「保证这段空间可写」,但 glibc 在没有 fallocate 的文件系统上会退化成「逐块写 0」,可能非常慢;它返回错误码而不是设置 errno(成功返回 0)。
/* fallocate_demo.c — 预分配、打洞,并用 SEEK_HOLE 验证 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
static void show(const char *tag, int fd)
{
struct stat st;
if (fstat(fd, &st) == -1) { perror("fstat"); return; }
printf("%-12s size=%lld blocks=%lld (%lld KiB)\n", tag,
(long long)st.st_size, (long long)st.st_blocks,
(long long)st.st_blocks * 512 / 1024);
}
int main(void)
{
const char *path = "fallocate_demo.bin";
int fd = open(path, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
if (fd == -1) { perror("open"); return 1; }
const off_t size = 4 * 1024 * 1024;
if (ftruncate(fd, size) == -1) { perror("ftruncate"); return 1; }
show("sparse", fd);
/* 真正分配前 1 MiB,文件大小不变 */
if (fallocate(fd, FALLOC_FL_KEEP_SIZE, 0, 1024 * 1024) == -1)
printf("fallocate KEEP_SIZE: %s\n", strerror(errno));
else
show("prealloc", fd);
char page[4096];
memset(page, 'D', sizeof page);
if (pwrite(fd, page, sizeof page, 512 * 1024) != (ssize_t)sizeof page) {
perror("pwrite"); return 1;
}
show("written", fd);
/* 把中间 1 MiB 打成洞 */
if (fallocate(fd, FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE,
1024 * 1024, 1024 * 1024) == -1) {
printf("fallocate PUNCH_HOLE: %s\n", strerror(errno));
} else {
show("punched", fd);
}
off_t off = 0;
while (off < size) {
off_t data = lseek(fd, off, SEEK_DATA);
if (data == -1) {
if (errno == ENXIO) break;
perror("SEEK_DATA"); break;
}
off_t hole = lseek(fd, data, SEEK_HOLE);
if (hole == -1) { perror("SEEK_HOLE"); break; }
printf("data [%lld, %lld)\n", (long long)data, (long long)hole);
if (hole <= off) break;
off = hole;
}
close(fd);
unlink(path);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 fallocate_demo.c -o fallocate_demo
./fallocate_demo
# sparse size=4194304 blocks=0 (0 KiB)
# prealloc size=4194304 blocks=2048 (1024 KiB)
# written size=4194304 blocks=2056 (1028 KiB)
# punched size=4194304 blocks=1032 (516 KiB)
# data [0, 1048576) ← 具体分段随文件系统而异
blocks 的变化说明 fallocate 真的分配了磁盘块;PUNCH_HOLE 后又释放了一部分。不同文件系统(ext4/XFS/btrfs)对 SEEK_DATA 的粒度不同,输出可能把相邻数据段合并;不要按固定输出写测试断言。
- 「
fallocate等于ftruncate」——不。ftruncate只改逻辑大小,可能全是空洞;fallocate真正分配块。 - 「
PUNCH_HOLE可以单独用」——必须与FALLOC_FL_KEEP_SIZE组合,否则EINVAL。 - 「
posix_fallocate一定快」——文件系统不支持时会退化为写 0,可能比普通写还慢。 - 「打洞后
st_size会变小」——KEEP_SIZE下大小不变,只是块被释放。
2.3.14 零拷贝:sendfile、splice、tee 与 copy_file_range
这一节回答:哪些「零拷贝」真的省掉了用户态拷贝?各自的 fd 类型限制和降级路径是什么?
先明确「零拷贝」省的是什么:普通 read+write 的路径是「磁盘 → page cache → 用户缓冲 → socket 缓冲 → 网卡」,零拷贝省掉的是内核 ↔ 用户态那两次拷贝,磁盘 ↔ page cache ↔ 网卡仍要走 DMA。
| 接口 | 输入 | 输出 | 关键限制 |
|---|---|---|---|
sendfile | 文件(可 mmap) | socket(2.6.33 起也可为文件) | 不能 socket→socket;in_fd 必须支持 mmap |
splice | 至少一端是管道 | 文件/socket/管道 | 两端不能都是非管道 |
tee | 管道 | 管道 | 复制管道内容,不消耗源 |
vmsplice | 用户内存 | 管道 | 把用户页「借」给管道,生命周期要小心 |
copy_file_range | 文件 | 文件 | 同文件系统可 reflink;跨 fs 可能 EXDEV |
sendfile
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
offset == NULL:从in_fd当前偏移读,并推进它;offset != NULL:从*offset读,不改变in_fd的偏移,返回时*offset更新到已传最后一个字节之后;- 返回实际传输字节数,可能小于
count;0表示 EOF;-1看errno:EINVAL(fd 类型不支持)、EFAULT、ENOSYS、EOVERFLOW、EAGAIN。
splice
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out,
size_t len, unsigned int flags);
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);
splice 把数据从「管道的一端」移到另一端,至少一端必须是管道。flags:SPLICE_F_MOVE(提示移动页而不是复制)、SPLICE_F_NONBLOCK(不阻塞)、SPLICE_F_MORE(后续还有数据)。返回实际移动字节数,0 表示 EOF;EINVAL(两端都不是管道、偏移用于管道)、EAGAIN、EPIPE。
tee 只复制管道内容到另一个管道,源管道的数据不被消费,适合「一份输入,两路处理」。vmsplice 把用户页映射进管道,避免拷贝,但内核可能随时引用这些页,调用者不能马上修改/释放它们——除非用 SPLICE_F_GIFT 明确赠送。
copy_file_range
ssize_t copy_file_range(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out,
size_t len, unsigned int flags);
flags 必须为 0(否则 EINVAL)。同一文件系统上,支持 reflink 的文件系统(XFS/btrfs)可以只复制元数据;ext4 可能退化为普通复制。off_in/off_out 为 NULL 时使用并推进文件偏移。失败:EXDEV(跨文件系统,老内核)、EOPNOTSUPP(文件系统不支持)、ENOSYS(旧内核)、EBADF、EINVAL(重叠/同文件)、EFBIG。
从 Linux 5.3 起,跨文件系统复制在部分场景被允许(内核回退到通用复制),但不要把 copy_file_range 当成可靠的跨设备复制:收到 EXDEV/EOPNOTSUPP 就退回 read/write 循环。
/* zerocopy.c — splice 文件→管道→文件,再试 copy_file_range */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static int write_pattern(int fd, size_t len)
{
char buf[4096];
memset(buf, 'Z', sizeof buf);
size_t done = 0;
while (done < len) {
size_t n = len - done < sizeof buf ? len - done : sizeof buf;
ssize_t w = write(fd, buf, n);
if (w == -1) {
if (errno == EINTR) continue;
return -1;
}
done += (size_t)w;
}
return 0;
}
static off_t splice_copy(int in, int out, size_t len)
{
int p[2];
if (pipe(p) == -1) return -1;
size_t done = 0;
off_t total = 0;
while (done < len) {
ssize_t n = splice(in, NULL, p[1], NULL, len - done, 0);
if (n == -1) {
if (errno == EINTR) continue;
perror("splice in");
break;
}
if (n == 0) break;
size_t left = (size_t)n;
while (left > 0) {
ssize_t m = splice(p[0], NULL, out, NULL, left, 0);
if (m == -1) {
if (errno == EINTR) continue;
perror("splice out");
left = 0;
break;
}
left -= (size_t)m;
total += m;
}
done += (size_t)n;
}
close(p[0]);
close(p[1]);
return total;
}
int main(void)
{
const char *src = "zc_src.bin", *dst = "zc_dst.bin";
int in = open(src, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
if (in == -1) { perror("open src"); return 1; }
const size_t size = 1024 * 1024;
if (write_pattern(in, size) == -1) { perror("write pattern"); return 1; }
if (fsync(in) == -1) { perror("fsync"); return 1; }
if (lseek(in, 0, SEEK_SET) == -1) { perror("lseek"); return 1; }
int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
if (out == -1) { perror("open dst"); return 1; }
off_t n = splice_copy(in, out, size);
printf("splice copied %lld bytes\n", (long long)n);
/* copy_file_range 到第三个文件 */
int out2 = open("zc_dst2.bin", O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
if (out2 != -1) {
if (lseek(in, 0, SEEK_SET) == -1) perror("lseek");
errno = 0;
ssize_t m = copy_file_range(in, NULL, out2, NULL, size, 0);
if (m == -1)
printf("copy_file_range: %s\n", strerror(errno));
else
printf("copy_file_range copied %zd bytes\n", m);
close(out2);
}
close(in);
close(out);
unlink(src);
unlink(dst);
unlink("zc_dst2.bin");
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 zerocopy.c -o zerocopy
./zerocopy
# splice copied 1048576 bytes
# copy_file_range copied 1048576 bytes
splice 的循环要先 splice 到管道,再把管道内容 splice 到输出;不能假设一次就搬完。copy_file_range 返回短计数时也要继续循环;上面的示例为了简洁只调用一次,生产代码应写成与 splice_copy 类似的循环。
- 「零拷贝完全不拷贝」——省的是用户态拷贝;DMA、page cache 仍然参与。
- 「
sendfile可以 socket→socket」——不能,in_fd必须是可mmap的文件。 - 「
splice可以任意两个 fd」——至少一端是管道,否则EINVAL。 - 「
copy_file_range跨文件系统一定可用」——老内核EXDEV,部分文件系统EOPNOTSUPP;必须回退。 - 「
vmsplice后可以立刻复用用户缓冲」——内核可能仍引用这些页;除非SPLICE_F_GIFT,否则要等管道消费完。
2.3.15 文件锁的完整语义:flock、POSIX 记录锁与 OFD 锁
这一节回答:flock 与 fcntl 记录锁在继承、释放、冲突上到底差在哪?OFD 锁解决了哪个历史缺陷?
2.3.6 给了对照表。这里补三组细节:锁的归属、释放时机、冲突检测(Kerrisk 第 55 章,p918–935)。
三种锁
| 维度 | flock | POSIX 记录锁(F_SETLK) | OFD 锁(F_OFD_SETLK) |
|---|---|---|---|
| 范围 | 整个文件 | 字节区间 | 字节区间 |
| 归属 | 打开文件描述 | 进程 + inode | 打开文件描述 |
fork 后 | 子进程共享同一把锁 | 不继承 | 子进程共享(同一 OFD) |
dup 后 | 共享 | 共享(同进程) | 共享 |
| 关闭任意 fd | 只有最后一个引用关闭才释放 | 关闭该文件任意 fd 就释放全部 | 只有该 OFD 最后一个引用关闭才释放 |
exec | 保留(除非 CLOEXEC 关掉最后一个引用) | 保留 | 保留 |
| 冲突范围 | 仅与其他 flock | 与其他 POSIX/OFD 锁 | 与其他 POSIX/OFD 锁 |
POSIX 记录锁的「关闭任意 fd 释放全部」是历史设计缺陷:一个库函数加的锁,会被调用者关闭另一个 fd 时顺手释放。OFD 锁(Linux 3.15+,F_OFD_GETLK/F_OFD_SETLK/F_OFD_SETLKW)把归属改成打开文件描述,解决了这个问题,也让 fork 后的子进程能共享锁。
冲突与死锁
F_SETLK冲突返回-1,errno为EACCES或EAGAIN(Linux 用EAGAIN);F_SETLKW阻塞,可能被信号打断返回EINTR。- 内核检测到死锁时让其中一个请求返回
EDEADLK;但「检测」不是万能的,复杂的环形等待仍可能靠超时/人工干预。 - 多个读锁可能饿死一个写锁;Linux 不保证 FIFO,也不保证写者优先(Kerrisk 55.3.6,p935)。
F_GETLK只报告其他进程的冲突锁,自己进程的锁不会出现在结果里;l_pid是持锁者 PID。
/proc/locks 的字段
1: POSIX ADVISORY WRITE 48211 08:01:7340033 0 EOF
依次是:序号、锁类型(POSIX/FLOCK/OFDLCK)、ADVISORY/MANDATORY、READ/WRITE、PID(OFD 锁为 -1)、major:minor:inode、起始、结束(EOF 表示到文件末尾)。lslocks 输出更友好。
强制锁(mandatory locking)在 Linux 5.15 已被移除;旧代码里「setgid + 关闭 group-execute」的技巧不再有效,不要按它设计。
/* oflock.c — OFD 锁在两个 fd 间冲突;POSIX 锁在同一进程内不冲突 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
static int lock_ofd(int fd, short type)
{
struct flock fl = { .l_type = type, .l_whence = SEEK_SET,
.l_start = 0, .l_len = 10 };
return fcntl(fd, F_OFD_SETLK, &fl);
}
int main(void)
{
const char *path = "oflock_demo.lock";
int a = open(path, O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0600);
int b = open(path, O_RDWR | O_CLOEXEC);
if (a == -1 || b == -1) { perror("open"); return 1; }
if (ftruncate(a, 100) == -1) { perror("ftruncate"); return 1; }
/* 同一进程、两个 fd:OFD 锁互相冲突 */
if (lock_ofd(a, F_WRLCK) == -1) { perror("OFD lock a"); return 1; }
errno = 0;
int rc = lock_ofd(b, F_WRLCK);
printf("OFD lock on second fd: rc=%d errno=%d (%s)\n",
rc, errno, strerror(errno));
/* POSIX 锁:同一进程内再次加锁会合并,不冲突 */
struct flock fl = { .l_type = F_WRLCK, .l_whence = SEEK_SET,
.l_start = 20, .l_len = 10 };
if (fcntl(a, F_SETLK, &fl) == -1) { perror("POSIX lock a"); return 1; }
struct flock fl2 = fl;
errno = 0;
rc = fcntl(b, F_SETLK, &fl2);
printf("POSIX lock on second fd: rc=%d errno=%d (%s)\n",
rc, errno, strerror(errno));
close(a);
close(b);
unlink(path);
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 oflock.c -o oflock
./oflock
# OFD lock on second fd: rc=-1 errno=11 (Resource temporarily unavailable)
# POSIX lock on second fd: rc=0 errno=0 (Success)
同一进程的两个 fd 上,OFD 锁冲突而 POSIX 锁合并——这就是「锁归属」的可见差异。用 ./oflock & 配合 cat /proc/locks 还能看到 OFDLCK 的 PID 是 -1。
- 「
flock与fcntl锁会互相冲突」——Linux 上两套锁独立,混用不保证互斥;一个程序只用一套。 - 「关闭加锁的 fd 才释放锁」——POSIX 记录锁关闭该文件任意 fd 就释放全部;用 OFD 锁避免。
- 「
F_GETLK能看到自己的锁」——只报告其他进程的冲突锁。 - 「锁能阻止
unlink」——不能;有目录写权限就能删除文件。 - 「强制锁还能用」——Linux 5.15 移除了强制锁;不要依赖
setgid技巧。
2.3.16 内核路径:mmap 建映射与缺页处理
这一节回答:mmap 之后内核到底做了什么?为什么第一次访问会慢,之后又快?
建映射
| 层次 | 位置 | 关键动作 |
|---|---|---|
| 系统调用 | arch/x86/entry/syscalls/syscall_64.tbl | mmap → __x64_sys_mmap |
| 入口 | mm/mmap.c | ksys_mmap_pgoff → vm_mmap_pgoff |
| 地址空间 | mm/mmap.c | do_mmap → mmap_region,拿 mmap_lock 写锁 |
| VMA | mm/mmap.c | vma_merge/vma_link 插入 mm_struct->mm_mt(maple tree) |
| 文件 | mm/mmap.c | call_mmap → file->f_op->mmap |
| 文件系统 | mm/filemap.c | generic_file_mmap 设置 vm_ops = &file_vm_ops |
| 驱动/DAX | fs/dax.c | dax_mmap(持久内存) |
mmap 只登记 VMA,不读文件页;MAP_POPULATE 才会在建立时预缺页。vm_area_struct 记录 vm_start/vm_end/vm_flags/vm_pgoff/vm_file/vm_ops,是后续缺页处理的依据。
缺页
handle_mm_fault(vma, address, flags, regs)
→ __handle_mm_fault
→ do_fault
→ do_read_fault
→ filemap_fault
→ page_cache_sync_readahead / filemap_read_folio
→ filemap_map_pages(fault-around,一次映射多页)
写私有页走 do_wp_page(COW):分配新页、复制旧内容、改 PTE 为可写、减旧页引用。写共享页走 do_shared_fault → ->page_mkwrite(ext4 的 ext4_page_mkwrite),把页标记为脏并等待可能进行中的回写。
msync/fsync 最终都落到 vfs_fsync_range → file->f_op->fsync → ext4 的 ext4_sync_file;mmap 的脏页由回写线程(kworker/flush)按 dirty_expire_centisecs 自动刷。
perf stat -e page-faults,minor-faults,major-faults ./mmap_scan
# major 需要读盘;minor 只建立 PTE 或从 page cache 映射
cat /proc/self/smaps | grep -A8 mmap_demo
# Rss/Pss/Shared_Clean/Private_Dirty/Anonymous/Swap
smaps 的 Pss 把共享页按映射者数量均摊,比 Rss 更适合判断真实内存占用。
2.3.17 两个实验:strace 看映射调用与 smaps 看驻留
这一节回答:怎样用现成工具确认「映射建了没有、页在不在、回写发生在哪」?
实验一:strace 跟踪 mmap/msync
strace -yy -e trace=mmap,munmap,mprotect,msync,madvise,mlock ./mmap_scan 2>&1 | head -20
典型输出:
$ strace -e trace=mmap,munmap,msync,madvise ./mmap_scan 2>&1 | tail -8
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, 3</tmp/mmap_demo.XXXX>, 0) = 0x7f...
madvise(0x7f..., 4096, MADV_SEQUENTIAL) = 0
msync(0x7f..., 4096, MS_SYNC) = 0
munmap(0x7f..., 4096) = 0
读法:
mmap的第四个参数是flags,MAP_SHARED与MAP_PRIVATE必须出现一个;PROT_*是第三个参数。msync的第三个参数是MS_SYNC/MS_ASYNC/MS_INVALIDATE的组合;MS_SYNC会阻塞到回写完成。munmap的 length 会被向上取整到页;解除部分映射会把原 VMA 切分。- 没有
mmap行但有brk/mremap,说明内存来自malloc的堆或大块mremap。
实验二:smaps 与 mincore
# 1. 看映射的驻留与共享情况
grep -A12 'mmap_demo' /proc/$$/smaps 2>/dev/null | head -15
# 2. 看进程锁住的内存
grep VmLck /proc/$$/status
# 3. 用 fincore 看文件页是否在 cache(util-linux)
fincore --pages=false /etc/hosts
smaps 的关键字段:
| 字段 | 含义 |
|---|---|
Size | VMA 逻辑大小 |
Rss | 当前驻留的物理页 |
Pss | 按共享者均摊后的驻留 |
Shared_Clean/Shared_Dirty | 与其他进程共享的干净/脏页 |
Private_Clean/Private_Dirty | 本进程私有页 |
Anonymous | 匿名页(堆、栈、匿名映射) |
Swap | 被换出的页 |
VmFlags | 如 rd wr mr mw me sd(可读/可写/可映射/可共享/软脏) |
mincore 与 smaps 的 Rss 都是快照;要观察变化,用 watch -n1 grep Rss /proc/PID/smaps 或在程序里循环调用。Pss 对容器/多进程共享库的内存核算更有意义。
strace -e trace=mmap,munmap,mprotect,msync看调用是否成功、flags 是否写错。cat /proc/PID/maps看 VMA 的权限与文件偏移,确认映射落在预期地址。grep -A12 <name> /proc/PID/smaps看Rss/Pss/Private_Dirty,判断是否真的驻留。- 收到
SIGBUS:fstat比对st_size与映射长度,检查是否有别的进程截断。 - 收到
SIGSEGV:检查访问是否越过length,或mprotect改成了PROT_NONE。
2.3.18 本章陷阱清单
- 现象:
mmap返回(void *)-1,程序却按 NULL 判断。原因:MAP_FAILED不是 NULL。修复:用p == MAP_FAILED。 - 现象:
mmap偏移 100 返回EINVAL。原因:offset未按页对齐。修复:offset取sysconf(_SC_PAGESIZE)的整数倍。 - 现象:
MAP_SHARED|PROT_WRITE用O_RDONLY打开的文件映射失败。原因:共享可写映射要求 fd 可写。修复:O_RDWR,或改MAP_PRIVATE。 - 现象:访问映射区收到
SIGBUS。原因:文件被截断,或映射超出 EOF 整页。修复:访问前fstat,或加锁阻止截断。 - 现象:
msync后另一个进程仍看不到修改。原因:用了MAP_PRIVATE,或只调了MS_ASYNC且对方读的是旧缓存。修复:共享映射 +MS_SYNC,必要时MS_INVALIDATE。 - 现象:
madvise(DONTNEED)后数据变 0。原因:Linux 私有匿名映射的破坏性语义。修复:不要对需要保留的数据用DONTNEED。 - 现象:
mlock返回ENOMEM。原因:超过RLIMIT_MEMLOCK。修复:getrlimit/ulimit -l,或申请CAP_IPC_LOCK。 - 现象:
mremap后旧指针崩溃。原因:MREMAP_MAYMOVE搬家了。修复:只保存偏移,使用返回值。 - 现象:
sendfile返回EINVAL。原因:in_fd不是可mmap的文件,或out_fd不是 socket(老内核)。修复:用普通文件 + socket,或改splice。 - 现象:
splice返回EINVAL。原因:两端都不是管道。修复:至少一端用pipe()。 - 现象:
copy_file_range返回EXDEV。原因:跨文件系统或文件系统不支持。修复:回退到read/write循环。 - 现象:OFD 锁关闭一个 fd 后仍然持有。原因:锁归属打开文件描述,只有最后一个引用关闭才释放。修复:这是设计行为;要按进程释放用 POSIX 锁。
2.3.19 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
说出 F_SETFL 能改与不能改的标志 | 至少说出访问模式不可改 | 2.3.1 |
解释 dup2 为什么比 close+dup 安全 | 说出原子关闭 | 2.3.2 |
写出 writev 的短写循环 | 能推进 iovec 数组 | 2.3.3 |
解释 mmap 的 SIGBUS 触发条件 | 说出「文件被截断」与整页越界 | 2.3.4、2.3.10 |
说出 MAP_SHARED 与 MAP_PRIVATE 的写语义 | 能联系到 COW 与回写 | 2.3.9 |
解释 madvise(DONTNEED) 在私有映射上的后果 | 说出「修改被丢弃」 | 2.3.11 |
区分 flock、POSIX 锁与 OFD 锁 | 至少说对归属与释放两点 | 2.3.6、2.3.15 |
说出 fallocate 与 ftruncate 的差别 | 一个分配块,一个只改大小 | 2.3.13 |
| 写出原子发布的四步 | 含目录 fsync | 2.3.7 |
Q1 fcntl(fd, F_SETFL, flags | O_RDWR) 返回 0,但 fd 仍然只读。为什么?
Q2 两个线程共享一个 fd,都用 lseek + read。会出什么问题?换成 pread 呢?
Q3 mmap 一个 8 KiB 文件,另一个进程把它截断到 4 KiB,访问第 5 KiB 会怎样?改用 read 呢?
Q4 进程 A 用 fcntl 给文件加写锁后 fork;子进程继承这把锁吗?A 又 open 同一文件并 close,锁还在吗?
Q5 原子发布为什么临时文件必须与目标同目录?只 fsync 文件、不 fsync 目录,崩溃后可能出现什么?
答案
A1 F_SETFL 只改行为位;访问模式由 open 决定,内核静默忽略传入的 O_RDWR。所以返回 0,但读写能力没变。
A2 lseek 改共享的 f_pos:线程 1 定位到 100,线程 2 定位到 200,线程 1 再读就从 200 开始,数据错位。pread 把偏移当参数,不改 f_pos,天然无竞态。
A3 mmap 访问超出新 EOF 的整页收到 SIGBUS,默认终止进程;read 只返回 0。检测到截断后应重建映射。
A4 子进程不继承 fcntl 记录锁。A 关闭任意一个指向该文件的 fd,都会释放它在该文件上的全部记录锁,即使不是加锁用的那个。要避免,用 F_OFD_SETLK 或 flock。
A5 rename 要求源和目标在同一文件系统,否则 EXDEV。不 fsync 目录,崩溃后目录项可能没提交,出现「新内容在、名字是旧的」甚至「两个名字都丢」。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 4–5 章(fcntl/pread/readv/截断)、第 13 章(缓冲/O_DIRECT)、第 14 章(VFS)、第 49 章(mmap)、第 50 章(mprotect/mlock/mincore/madvise)、第 55 章(文件锁) | 2.3.1–2.3.18 | 完整(mmap 缺页、后续操作、OFD 锁为重点) |
| K | 第 61 章 61.4(sendfile) | 2.3.14 | 精选(splice/copy_file_range 为母本成书后新增,以 man 页为准) |
| C | 第 9 章 9.8(内存映射)、第 10 章(系统级 I/O) | 2.3.3、2.3.4 | 对照(mmap 与 RIO) |
| O | 第 39–40 章(文件与目录、文件系统实现) | 2.3.7、2.3.8 | 背景(inode、块分配、崩溃一致性) |
| G | 第 5 章(文件权限)、第 7 章(磁盘与文件系统) | 2.3.7、2.3.8 | 背景(df/du/稀疏文件) |
| N | 第 3 章(内核与用户进程协作) | — | 未用:本章不涉及 socket/epoll,留待 2.18 |
下一站:2.4 缓冲与 stdio 解释
printf到底什么时候调用write,以及为什么fork之后会看到重复输出。本章的fflush与dup2例子,在那里会得到完整解释。