2.1 系统编程基础:系统调用、错误与可移植性
系统调用和库函数到底差在哪?errno 为什么不是「全局变量」?同一段 C 代码为什么在别人的机器上编译不过?sysconf/pathconf 查出来的数字为什么不能写死?
画出一次 read 从用户栈到内核栈再返回的完整路径;写出不会丢错误的 errExit;用特性测试宏控制头文件暴露的接口;用 sysconf/pathconf//proc 在运行时取得限制,而不是抄常量。
上一篇你在 shell 里敲命令,看到的是「行为」;从这一篇起,你要写产生这些行为的程序。中间隔着一层很薄的边界:系统调用(system call)。所有文件、进程、信号、socket 的能力,最终都收敛成几十个带编号的入口。
这一章不教你任何一个具体子系统,只讲三条贯穿全篇的规则:怎么进内核、怎么报告失败、怎么不把平台细节写死。后面 18 章会反复用到它们,这里的每一个错误写法都会在后面变成真实的 bug。
2.1.1 一次系统调用穿过什么
用户程序不能直接调用内核函数。CPU 提供了受控的入口:一条特殊指令把特权级从用户态(ring 3)切到内核态(ring 0),同时把控制权交给内核预先注册的入口地址。x86-64 上这条指令是 syscall;32 位时代是 int 0x80;ARM64 上是 svc #0。
一次 read(fd, buf, n) 的完整路径:
用户态 内核态
┌────────────────────────┐
│ main() │
│ read(fd, buf, n) │ ① 调用 glibc 包装函数(不是内核)
│ │ │
│ glibc: read() │ ② syscall 号 → %rax
│ mov $0, %eax │ fd → %rdi, buf → %rsi, n → %rdx
│ syscall ────────────┼──③ 特权级切换,RIP 跳到 entry_SYSCALL_64
└────────────────────────┘ │
↓
┌──────────────────────────┐
│ entry_SYSCALL_64 │ ④ 保存用户寄存器
│ → do_syscall_64 │ 按 %rax 查表
│ → ksys_read │
│ → vfs_read │
│ → file->f_op->read_iter │
└──────────────────────────┘
│
↓ ⑤ 返回值放回 %rax
┌──────────────────────────┐
│ ≥0:字节数 │
│ <0:-errno(内核约定) │
└──────────────────────────┘
用户态 │
┌────────────────────────┐ │
│ glibc: 检查 %rax │ ←──────────┘
│ 若为负:errno = -rax │ ⑥ 转成 -1 + errno
│ 返回 -1 │
└────────────────────────┘
读图时抓住两个「翻译」:内核用负数返回值表示错误(-ENOENT、-EINVAL),glibc 把它翻译成 C 世界的 -1 加 errno;系统调用号与参数寄存器是 ABI 的一部分,不同架构不同,所以永远不要自己写 syscall 指令,用 libc 包装或 syscall(2)。
内核入口本身在 arch/x86/entry/entry_64.S,分派逻辑在 arch/x86/entry/common.c 的 do_syscall_64():它从 sys_call_table 按编号取函数指针。这张表就是「系统调用号 → 实现」的全部秘密,第 3 篇会展开,这里只需知道编号是内核 ABI,不是 libc 的私有约定。
内核函数运行在 ring 0,能改页表、关中断、碰任何物理地址。如果用户程序能任意跳进去,隔离就没了。syscall 指令做的事是:只允许跳到唯一一个入口,入口再按编号查表,参数必须经过 copy_from_user 这类检查。所谓「系统调用」,本质是「一次被审查的、有固定 ABI 的远程调用」。
syscall 与 int 0x80 的区别
在 x86-64 内核上两者都能进内核,但语义不同:
| 维度 | syscall(x86-64) | int 0x80(i386 兼容) |
|---|---|---|
| 系统调用号 | arch/x86/entry/syscalls/syscall_64.tbl | syscall_32.tbl,编号不同 |
| 参数传递 | rdi/rsi/rdx/r10/r8/r9 | ebx/ecx/edx/esi/edi/ebp |
| 指针宽度 | 64 位 | 截断为 32 位 |
| 返回 | rax,负数表示错误 | eax,负数表示错误 |
| 性能 | 快(不经过中断控制器) | 慢(要走中断门) |
在 64 位程序里用 int 0x80 调 SYS_write,用的是 32 位系统调用号,参数被截断到 32 位。把栈上的指针传进去,高 32 位丢失,轻则 EFAULT,重则写坏地址。这类代码在旧教程里很常见,不要抄。
vDSO:不进入内核的「系统调用」
gettimeofday()、clock_gettime(CLOCK_REALTIME)、time()、getcpu() 这几类调用极其频繁,但只需要读内核维护的几个变量。内核把一小段代码映射到每个进程的地址空间,叫 vDSO(virtual dynamic shared object):
$ LD_SHOW_AUXV=1 /bin/true | grep AT_SYSINFO_EHDR
AT_SYSINFO_EHDR: 0x7ffd5c4fe000
$ grep vdso /proc/self/maps
7ffd5c4fe000-7ffd5c4ff000 r-xp 00000000 00:00 0 [vdso]
glibc 的 clock_gettime 会先看 vDSO 有没有提供实现;有就直接在用户态读完返回,没有系统调用、没有特权级切换。这就是为什么「取时间」比「getpid」快一个数量级(2.1.2 有测量)。vDSO 是内核与 libc 之间的性能约定,不是通用机制:你不能把自己写的函数塞进去。
2.1.2 系统调用与库函数:谁负责什么
man 手册的分节是第一道线索:
| 分节 | 内容 | 例子 |
|---|---|---|
| 2 | 系统调用(内核入口的薄包装) | man 2 read、man 2 fork |
| 3 | 库函数(libc 实现) | man 3 printf、man 3 malloc |
| 7 | 概念、协议、约定 | man 7 signal、man 7 tcp |
但分节不是本质区别。fork() 在 man 2,malloc() 在 man 3;getpid() 名义上是系统调用,实际每次都进内核;clock_gettime() 名义上是系统调用,实际常走 vDSO。判断标准只有一条:这个函数是否直接或间接执行了一条陷入内核的指令,以及它承诺的语义由谁保证。
几个常被混淆的对:
| 你调用的 | 实际发生什么 | 谁保证语义 |
|---|---|---|
printf | 格式化到用户态缓冲区,满/换行才 write | libc(C 标准) |
write | 一次系统调用,数据进 page cache | 内核(POSIX) |
malloc | 在堆上切一块,必要时 brk/mmap | libc |
mmap | 建立 VMA,建立映射 | 内核 |
getpid | 读 task_struct->pid(glibc 2.25 后每次都进内核) | 内核 |
clock_gettime | 通常读 vDSO,不陷入内核 | 内核 + libc 协作 |
syscall(2) 是 libc 提供的通用逃生口:当某个系统调用还没有包装函数(新内核特性、小众接口),可以用
long syscall(long number, ...); /* 参数按 ABI 顺序传,最多 6 个 */
它仍然会做 errno 翻译,但不做任何类型检查:参数类型、指针有效性全靠你自己。新代码优先用包装函数;只有 man 手册明确说「glibc 没有提供包装」时才用 syscall()。
最小测量:vDSO 与真正的系统调用差多少
下面这个程序用三个循环对比 syscall(SYS_getpid)、getpid() 和 clock_gettime(CLOCK_MONOTONIC)。它顺便演示了「基准测试要防止编译器优化掉结果」的写法。
/* syscall_cost.c — 测量一次系统调用的量级 */
#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <sys/syscall.h>
#define N 1000000
static double now_ns(void)
{
struct timespec ts;
if (clock_gettime(CLOCK_MONOTONIC, &ts) == -1) {
perror("clock_gettime");
return 0;
}
return (double)ts.tv_sec * 1e9 + (double)ts.tv_nsec;
}
int main(void)
{
volatile long sink = 0; /* volatile:阻止循环被整体删除 */
double t0, t1;
t0 = now_ns();
for (int i = 0; i < N; i++)
sink += syscall(SYS_getpid);
t1 = now_ns();
printf("syscall(SYS_getpid) %6.1f ns/次\n", (t1 - t0) / N);
t0 = now_ns();
for (int i = 0; i < N; i++)
sink += getpid();
t1 = now_ns();
printf("getpid() %6.1f ns/次\n", (t1 - t0) / N);
t0 = now_ns();
for (int i = 0; i < N; i++) {
struct timespec ts;
if (clock_gettime(CLOCK_MONOTONIC, &ts) == -1)
return 1;
sink += ts.tv_nsec;
}
t1 = now_ns();
printf("clock_gettime(MONOTONIC) %6.1f ns/次\n", (t1 - t0) / N);
printf("sink=%ld\n", sink); /* 让上面的累加真的发生 */
return 0;
}
编译与运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 syscall_cost.c -o syscall_cost
./syscall_cost
在 x86-64 虚拟机上的典型输出(数量级比绝对值重要):
$ ./syscall_cost syscall(SYS_getpid) 318.4 ns/次 getpid() 315.7 ns/次 clock_gettime(MONOTONIC) 19.2 ns/次 $
syscall() 与 getpid() 几乎一样,说明 getpid 没有走 vDSO;clock_gettime 快一个数量级,因为它大概率没有进内核。注意这是单次调用的成本,不含上下文切换;真正昂贵的系统调用(read 未命中、fsync、wait)时间花在等设备或等子进程上,不在这里。
- 「
printf是系统调用」——不是。它最终可能调用write,但格式化、缓冲都在用户态。strace里看到的是write,不是printf。 - 「系统调用一定切换进程」——不一定。同一进程从用户态陷入内核态再返回,叫模式切换(mode switch),不是上下文切换(context switch)。没有调度到别的进程时,开销只是保存/恢复寄存器。
- 「系统调用号可以写死」——系统调用号是架构相关的 ABI。
SYS_read在 x86-64 是 0,在 ARM64 是 63。写死数字的代码换个架构就调错函数。
2.1.3 返回值与 errno 的七条规则
C 没有异常。系统调用和大多数 libc 函数用「特殊返回值 + errno」报告失败。这套机制简单,但几乎每个初学者都会踩同样的坑。
errno 是一个线程局部的 int 左值(glibc 里展开为 (*__errno_location()))。函数失败时把它设为一个 E* 常量;成功时不保证清零。因此:
- 判断失败看返回值,不看
errno是否非零; - 只有在返回值表明失败后,读
errno才有意义; - 需要在后续调用之后继续用这个错误码时,先存进局部变量。
七条规则,按重要性排序:
- 先看返回值,再看
errno。errno == 0不代表成功;errno != 0也不代表这次失败(可能是上一次留下的)。 - 成功不清
errno。这是 POSIX 明确允许的。要复用errno做判断,先手动errno = 0。 errno是线程局部的。两个线程各自失败,互不覆盖。不要把它当进程级状态传递。- 保存后再做别的事。
fprintf、strerror、free都可能改errno;错误处理函数入口第一件事是int saved = errno;。 - 不是所有函数都设
errno。C 标准库的strtol用errno,但snprintf截断不设;pthread_*系列直接返回错误码,不碰errno。 EINTR是「被打断」,不是「失败」。可重试的调用要区分它(2.1.4 给封装)。strerror不保证线程安全。多线程用strerror_r,注意它有两个不兼容的版本:GNU 版返回char *,XSI 版返回int。
第 5 条值得单独说:POSIX 线程函数返回 0 表示成功,返回错误码表示失败,错误码就是返回值本身。写成 if (pthread_mutex_lock(&m) == -1) 永远不成立;正确写法是判断 rc != 0,再把 rc(不是 errno)传给 strerror。
一个能复现三条规则的实验
下面程序依次演示:成功不清 errno、失败时 errno 的值、EINTR 的判定。它同时给出 errMsg/errExit 的标准写法。
/* errno_demo.c — errno 的三条规则 */
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static void errMsg(const char *msg)
{
int saved = errno; /* 必须先保存 */
fprintf(stderr, "err: %s: %s\n", msg, strerror(saved));
errno = saved; /* 还原,方便调用者继续判断 */
}
static void errExit(const char *msg)
{
errMsg(msg);
exit(EXIT_FAILURE);
}
static volatile sig_atomic_t got_alarm;
static void on_alarm(int sig)
{
(void)sig;
got_alarm = 1;
}
int main(void)
{
int e;
/* 规则 1+2:失败设置 errno,成功不清除 */
errno = 0;
int fd = open("/nonexistent/file", O_RDONLY);
if (fd != -1)
errExit("open 不应成功");
e = errno;
printf("open 失败: errno=%d (%s)\n", e, strerror(e));
int ok = open("/etc/hosts", O_RDONLY);
if (ok == -1)
errExit("open /etc/hosts");
printf("成功之后 errno 仍是 %d (%s)\n", errno, strerror(errno));
close(ok);
/* 规则 6:EINTR 是打断,不是错误 */
struct sigaction sa = {0};
sa.sa_handler = on_alarm;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; /* 不设 SA_RESTART */
if (sigaction(SIGALRM, &sa, NULL) == -1)
errExit("sigaction");
int p[2];
if (pipe(p) == -1)
errExit("pipe");
alarm(1);
char c;
ssize_t n = read(p[0], &c, 1); /* 管道无数据,阻塞;1 秒后被 SIGALRM 打断 */
if (n == -1 && errno == EINTR) {
printf("read 被 SIGALRM 打断, got_alarm=%d\n", (int)got_alarm);
} else {
fprintf(stderr, "预期 EINTR, 实际 n=%zd errno=%d\n", n, errno);
return EXIT_FAILURE;
}
close(p[0]);
close(p[1]);
return EXIT_SUCCESS;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g errno_demo.c -o errno_demo
./errno_demo; echo "exit=$?"
预期输出:
$ ./errno_demo
open 失败: errno=2 (No such file or directory)
成功之后 errno 仍是 2 (No such file or directory)
read 被 SIGALRM 打断, got_alarm=1
exit=0
第二行是关键证据:open("/etc/hosts") 成功了,errno 仍然是 2。任何「errno != 0 就认为出错」的代码在这里都会误判。
static void log_and_die(const char *what)
{
FILE *f = fopen("/var/log/app.err", "a");
if (f) { fprintf(f, "%s: %s\n", what, strerror(errno)); fclose(f); }
exit(1);
}
fopen/fprintf/fclose 都可能改 errno。等 strerror(errno) 执行时,拿到的可能是 fopen 失败留下的 EACCES,而不是原始错误。修法只有一行:函数第一句 int saved = errno;,后面所有输出用 saved。
2.1.4 错误处理的工程封装
教科书示例常写 if (fd == -1) { perror("open"); exit(1); }。真实程序不能处处 exit:库函数要返回错误,守护进程要记录并继续,服务端要回一个 500 而不是退出。实践中有三层:
| 层 | 行为 | 适用 |
|---|---|---|
errMsg | 打印并返回,errno 保持不变 | 库、可恢复错误 |
errExit | 打印并 exit(EXIT_FAILURE) | 启动阶段、不可恢复 |
errno 透传 | 返回 -1,让调用者决定 | 所有中间层 |
一个够用的最小实现(err.c,每个示例都链接它):
/* err.c — 变参错误处理:默认取 errno,也可显式传错误号 */
#include <errno.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void vErr(int errnum, const char *fmt, va_list ap)
{
char buf[1024];
int saved = errno; /* 保留调用者的 errno */
vsnprintf(buf, sizeof buf, fmt, ap);
fprintf(stderr, "%s: %s\n", buf, strerror(errnum));
errno = saved; /* 返回时还原 */
}
void errMsg(const char *fmt, ...)
{
va_list ap;
int saved = errno;
va_start(ap, fmt);
vErr(saved, fmt, ap);
va_end(ap);
}
void errExit(const char *fmt, ...)
{
va_list ap;
int saved = errno;
va_start(ap, fmt);
vErr(saved, fmt, ap);
va_end(ap);
exit(EXIT_FAILURE);
}
void errExitEN(int errnum, const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
vErr(errnum, fmt, ap);
va_end(ap);
exit(EXIT_FAILURE);
}
三个设计点:
vErr先保存errno,函数返回前还原。这样调用者errMsg(...); return -1;时,errno还是原始错误。vsnprintf而不是snprintf:变参函数必须用va_list版本。缓冲区固定 1024,超长截断——错误信息不需要无限长。errExitEN接受显式错误号:用于pthread_*这类「返回错误码、不设errno」的接口。
- 这个错误调用者能恢复吗(
EAGAIN、EINTR、ENOENT)?能 → 返回-1并保留errno。 - 不能恢复,但进程还有别的事要做(一个连接坏了)?→ 记录日志,关闭该连接,继续。
- 不能恢复且继续运行会出错(配置解析失败、特权丢失)?→
errExit。 - 在信号处理器里?→ 只能用
write+ 预格式化字符串,不能用fprintf/strerror(见 2.10)。
2.1.5 特性测试宏:为什么 -D_GNU_SOURCE 是必需的
同一个头文件,在不同宏定义下暴露不同的声明。这不是发行版的怪癖,而是 POSIX 的设计:头文件必须能同时服务「严格标准模式」和「实现扩展模式」。
特性测试宏是在包含任何系统头文件之前定义的预处理宏,用来告诉 libc「我要求哪一版标准、允许哪些扩展」。它控制的是声明是否可见,不改变运行时行为。定义晚了(在 #include <stdio.h> 之后)等于没定义。
常用组合:
| 宏 | 含义 | 典型用途 |
|---|---|---|
_POSIX_C_SOURCE=200809L | 只要 POSIX.1-2008 | 可移植的库 |
_XOPEN_SOURCE=700 | POSIX + XSI,700 对应 SUSv4 | 需要 openpty 等 XSI 接口 |
_DEFAULT_SOURCE | glibc 默认扩展(MSG_NOSIGNAL、strdup 等) | 不写宏时的默认状态 |
_GNU_SOURCE | 打开全部:POSIX + BSD + GNU | Linux 上的系统程序 |
关键细节:一旦你显式定义了 _POSIX_C_SOURCE,glibc 就不再默认定义 _DEFAULT_SOURCE。于是 accept4、pipe2、dup3、signalfd、epoll_create1、MSG_NOSIGNAL 会失去声明。C17 下未声明函数是警告(-Werror 变错误),链接时可能碰巧成功——这是最坏的组合:能跑,但行为未定义。
复现这个坑:
# 只声明 POSIX.1-2008,accept4 不在其中
cc -std=c17 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -Werror -fsyntax-only accept4_demo.c
# 报错:implicit declaration of function 'accept4'; did you mean 'accept'?
# 换成 _GNU_SOURCE
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -fsyntax-only accept4_demo.c
# 通过
本书统一在编译命令里用 -D_GNU_SOURCE,源码不再重复 #define——命令行已定义时再定义一次,-Werror 下会报 macro redefined。原因不是「Linux 专用」,而是本章之后的接口有大量 Linux 扩展(epoll、inotify、signalfd、accept4、pipe2);要跨到 BSD/macOS,应把扩展隔离在平台层,核心逻辑只用 _POSIX_C_SOURCE=200809L。
- 「在
.c文件里#define _GNU_SOURCE就行」——必须在这之前没有任何#include。最稳妥是编译命令-D_GNU_SOURCE,而不是源文件里写。 - 「定义了
_GNU_SOURCE还能再定义_POSIX_C_SOURCE来限制」——不能。_GNU_SOURCE会强制打开所有扩展,后定义的_POSIX_C_SOURCE不会收回。 - 「头文件里定义宏」——头文件被包含时可能已经有系统头文件进去了,顺序不可控。特性测试宏只能出现在翻译单元的最前面或编译命令行。
- 「
-std=c17会关掉 POSIX 声明」——-std=c17把__STRICT_ANSI__打开,glibc 会因此隐藏一部分默认扩展;这正是「同一份代码-std=gnu17能编、-std=c17不能」的常见原因。本书用-std=c17加-D_GNU_SOURCE,两者同时显式写出。
2.1.6 运行时才知道的限制:sysconf 与 pathconf
OPEN_MAX、NAME_MAX、PAGESIZE 这些常量,有些在 <limits.h> 里有宏,有些没有。原因很实际:限制可能随内核配置、挂载的文件系统、进程的 RLIMIT_* 变化,编译期写死就会错。
三个查询接口:
long sysconf(int name); /* 系统级:页大小、CPU 数、fd 上限 */
long pathconf(const char *path, int name); /* 路径所在文件系统:NAME_MAX、PATH_MAX */
long fpathconf(int fd, int name); /* 同上,用已打开的 fd */
三者都返回 long,且都有三种结果:
>= 0:限制值;-1且errno == 0:该限制不确定(indeterminate),没有固定上限;-1且errno != 0:查询本身失败(EINVAL表示名字不合法)。
因为 -1 有二义性,调用前必须 errno = 0,返回后再查 errno。这是本章最容易写错的一个 API。
常用名字:
| 名字 | 含义 | 备注 |
|---|---|---|
_SC_PAGESIZE | 内存页大小 | 通常 4096,ARM64 可为 16384 |
_SC_OPEN_MAX | glibc 返回 RLIMIT_NOFILE 软限制 | 不是系统 fd 总数上限 |
_SC_CLK_TCK | times() 的时钟频率 | /proc/PID/stat 的时间单位 |
_SC_NPROCESSORS_ONLN | 在线 CPU 数 | Linux 扩展(_GNU_SOURCE) |
_SC_ARG_MAX | exec 参数+环境总长 | 实际还受栈大小限制 |
_PC_NAME_MAX | 单个文件名最大长度 | 随文件系统变,ext4 是 255 |
_PC_PATH_MAX | 路径最大长度 | Linux 上通常 4096,但不是保证 |
pathconf 的 _PC_PATH_MAX 有个陷阱:它返回的是「这个文件系统上路径的最大长度」,而 Linux 的路径解析本身没有硬上限(openat2 也只看总长)。真正可靠的结论是:不要分配 PATH_MAX 字节的栈缓冲区然后假设够用;用 getcwd(NULL, 0) 或 realpath(path, NULL) 让 libc 分配,遇 ERANGE 再扩容。
一次性把环境查清楚
/* limits.c — 查询运行时限制;注意 errno 的用法 */
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/utsname.h>
/* -1 有两种含义:调用前必须清零 errno,返回后才能区分 */
static void show(const char *label, long v)
{
if (v == -1 && errno != 0)
printf("%-24s 查询失败: %s\n", label, strerror(errno));
else if (v == -1)
printf("%-24s 不确定(无固定上限)\n", label);
else
printf("%-24s %ld\n", label, v);
}
int main(void)
{
struct utsname u;
if (uname(&u) == -1) {
perror("uname");
return EXIT_FAILURE;
}
printf("%s %s %s\n\n", u.sysname, u.release, u.machine);
errno = 0; show("_SC_PAGESIZE", sysconf(_SC_PAGESIZE));
errno = 0; show("_SC_OPEN_MAX", sysconf(_SC_OPEN_MAX));
errno = 0; show("_SC_CLK_TCK", sysconf(_SC_CLK_TCK));
errno = 0; show("_SC_ARG_MAX", sysconf(_SC_ARG_MAX));
errno = 0; show("_SC_NPROCESSORS_ONLN", sysconf(_SC_NPROCESSORS_ONLN));
putchar('\n');
errno = 0; show("_PC_NAME_MAX(/tmp)", pathconf("/tmp", _PC_NAME_MAX));
errno = 0; show("_PC_PATH_MAX(/tmp)", pathconf("/tmp", _PC_PATH_MAX));
errno = 0; show("_PC_NAME_MAX(/)", pathconf("/", _PC_NAME_MAX));
return EXIT_SUCCESS;
}
编译与运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 limits.c -o limits
./limits
预期(数值随机器变化):
$ ./limits
sysname=Linux release=6.8.0-45-generic machine=x86_64
_SC_PAGESIZE 4096
_SC_OPEN_MAX 1048576
_SC_CLK_TCK 100
_SC_ARG_MAX 2097152
_SC_NPROCESSORS_ONLN 8
_PC_NAME_MAX(/tmp) 255
_PC_PATH_MAX(/tmp) 4096
_PC_NAME_MAX(/) 255
_SC_OPEN_MAX 的语义随实现而变。Linux/glibc 的实现是返回本进程的 RLIMIT_NOFILE 软限制(Kerrisk 第 11 章,p177 明确把 _SC_OPEN_MAX 与 RLIMIT_NOFILE 对应),所以它通常与 ulimit -n 一致,而不是与 /proc/sys/fs/nr_open 一致。把它当成「系统能打开的文件总数」会得出错误结论:系统总量还受 nr_open/file-max 与其他进程占用约束。要拿本进程的软/硬限制,用 getrlimit(RLIMIT_NOFILE, &rl) 或读 /proc/self/limits。
2.1.7 uname、sysinfo、/proc 与 sysfs:拿证据的地方
uname(2):填 struct utsname,六个字段 sysname/nodename/release/version/machine/domainname。返回 0 或 -1(EFAULT)。它读的是内核启动时固定的字符串,不反映运行时变化。
sysinfo(2):填 struct sysinfo,给 uptime、负载、内存总量/可用量、进程数。两个坑:
- 内存字段单位是
mem_unit字节,不是字节;老内核上mem_unit可能大于 1; freeram不含可回收的 page cache,用MemAvailable更接近「应用还能拿多少」。
/* sysinfo_demo.c — 内存字段必须乘以 mem_unit */
#include <stdio.h>
#include <sys/sysinfo.h>
int main(void)
{
struct sysinfo si;
if (sysinfo(&si) == -1) { perror("sysinfo"); return 1; }
unsigned long unit = si.mem_unit ? si.mem_unit : 1;
printf("uptime %ld s, procs %u\n", si.uptime, si.procs);
printf("totalram %lu MiB, freeram %lu MiB\n",
si.totalram * unit >> 20, si.freeram * unit >> 20);
return 0;
}
/proc 是进程和内核状态的文本投影,sysfs(挂在 /sys)是设备与驱动的对象模型。两者都不是普通磁盘文件,读写由内核的回调函数即时生成。
| 路径 | 内容 | 典型用途 |
|---|---|---|
/proc/self/status | 进程状态、UID、内存、线程数 | 排障第一站 |
/proc/self/maps | 虚拟内存区域 | 看 [vdso]、共享库、堆 |
/proc/self/fd/ | fd 表投影 | 查泄漏、看 (deleted) |
/proc/meminfo | 内存明细 | MemAvailable、Cached |
/proc/sys/vm/* | 可调参数 | dirty_ratio、swappiness |
/sys/class/net/eth0/ | 网卡属性 | mtu、operstate、统计计数 |
/sys/fs/cgroup/ | cgroup v2 层级 | 容器资源限制 |
/proc 的数字列宽会变,不要用 awk '{print $2}' 依赖列位置;用 grep '^VmRSS:' 再取第二个字段,或者直接读结构化接口(/proc/PID/stat 的字段顺序是稳定的,但单位要换算)。
向 /proc/sys/vm/dirty_ratio 写 echo 20 > ... 会调用内核的 sysctl 处理函数,不是写磁盘。同理,读 /proc/self/mem 能读写自己的地址空间(调试器就是这么做的),但偏移必须是有效映射,否则 EIO。把 /proc 当普通文件 mmap 或 seek 到任意位置,会得到看似莫名其妙的结果。
2.1.8 可移植性检查清单
「我的机器上能跑」不是可移植性。下面这份清单按「改起来最便宜」排序:
- 特性测试宏在命令行:
-D_GNU_SOURCE或-D_POSIX_C_SOURCE=200809L,不在源文件中间#define。 - 不用裸数字:系统调用号用
SYS_*,信号用SIG*,权限用S_IRUSR这类宏,errno用E*。 - 不用
int存size_t/off_t/pid_t:用ssize_t、off_t、pid_t;打印用%zd、%jd(配intmax_t)或PRIdMAX。 - 限制运行时查:
sysconf/pathconf/getrlimit,不抄OPEN_MAX、NAME_MAX。 - 对齐与字节序:跨网络/跨文件格式用
htonl/ntohl;结构体别直接write到文件。 - 路径拼接:用
snprintf+ 检查返回值,不strcat;不假设/tmp可写、/bin/sh存在。 - 错误码按名字判断:
errno == EAGAIN,不写errno == 11。 long不等于指针:Windows LLP64 下long是 32 位;用intptr_t/uintptr_t。char的符号性:ARM 上char默认无符号,char c = getchar(); if (c == EOF)会永远不成立。用int接getchar。- 编译两次:
-std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror与-std=c17 -D_POSIX_C_SOURCE=200809L各编一次,看扩展依赖在哪。
第 9 条值得展开:EOF 是 -1,getchar() 返回 int。把返回值存进 char,在无符号 char 的平台上 0xFF 变成 255,与 -1 比较永远不等,循环不终止。这类 bug 在 x86 上测不出来,在 ARM 上必现。
一个具体的扩展边界:getopt 与 getopt_long
getopt 是 POSIX 接口,声明在 <unistd.h>;--long 形式的选项来自 GNU 扩展 getopt_long(<getopt.h>)。本书的示例大多只有一两个参数,手写 strcmp 够用;参数一多,标准库版本至少统一处理了捆绑短选项(-abc)、-n3/-n 3、-- 终止符和错误消息。
int ch;
while ((ch = getopt(argc, argv, ":n:o:h")) != -1) {
switch (ch) {
case 'n': count = atoi(optarg); break;
case 'o': out = optarg; break;
case 'h': usage(argv[0]); return 0;
case ':': fprintf(stderr, "-%c needs an argument\n", optopt); return 2;
default: fprintf(stderr, "unknown option -%c\n", optopt); return 2;
}
}
argc -= optind;
argv += optind; /* 剩下的才是操作数 */
optstring 开头的 : 关闭 getopt 自己的报错,并让缺参数返回 : 而不是 ?;optopt 保存出错的选项字符。GNU 版默认重排 argv,把选项集中到前面;要保持「遇到第一个操作数就停」,在 optstring 开头加 +(或设 POSIXLY_CORRECT)。长选项用 getopt_long 的 struct option,flag 为 NULL 时返回 val。
2.1.9 系统调用 ABI:寄存器、参数上限与返回约定
这一节回答:同一条 read 在不同架构上凭什么能编成不同的机器码,而源码不变?syscall(2) 为什么最多只能传六个参数?
系统调用是跨特权级的函数调用,但它没有 C 的函数原型可依赖。调用号放哪个寄存器、参数按什么顺序放、返回值怎么表示,全部由架构的 ABI 固定。glibc 的包装函数就是把 C 调用翻译成这套寄存器约定的一小段汇编。
| 架构 | 陷入指令 | 调用号 | 参数寄存器(依次) | 返回值 |
|---|---|---|---|---|
| x86-64 | syscall | rax | rdi、rsi、rdx、r10、r8、r9 | rax |
| i386 | int 0x80 | eax | ebx、ecx、edx、esi、edi、ebp | eax |
| ARM64 | svc #0 | x8 | x0–x5 | x0 |
| RISC-V 64 | ecall | a7 | a0–a5 | a0 |
x86-64 用 r10 而不是 rcx 传第四个参数,因为 syscall 指令本身会用 rcx 保存返回地址。这个细节无法从 C 源码看出来,但决定了手写汇编时必须用 r10。
系统调用 ABI 是「用户态与内核态之间传递调用号、参数、返回值」的二进制约定。它包括:陷入指令、调用号所在寄存器、参数寄存器顺序、返回值寄存器、错误表示法(内核用 -errno)、以及哪些寄存器由内核保证保留。ABI 一旦发布就不能改,否则所有已编译的程序都会坏掉。
为什么参数最多六个
六个参数不是内核随便定的,而是寄存器数量与返回地址占用的结果:x86-64 的调用约定里,能自由用于传参的寄存器刚好六个;再多就要用栈,而 syscall 指令切换栈的过程由内核控制,用户栈指针不能直接当参数传。因此超过六个参数的系统调用一律改成「传一个指向结构体的指针」:
| 调用 | 参数形态 | 原因 |
|---|---|---|
mmap | 六个标量 | 刚好用满 |
clone3 | struct clone_args * | 参数持续增加,用结构体带 size 做版本演进 |
openat2 | struct open_how * | 同上,size 字段允许未来扩展 |
io_uring_setup | struct io_uring_params * | 输入输出共用一块结构 |
bpf | union bpf_attr * | 命令多、参数差异大 |
结构体方案还解决了扩展性:clone3 的 size 字段让内核知道用户态用的是哪个版本的结构,新字段可以追加而不破坏旧程序。这是「ABI 只能加不能改」的具体实现手法。
/* abi_probe.c — 观察 ABI 常量、错误返回与 >6 参数的结构体入口 */
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/syscall.h>
#include <time.h>
#include <unistd.h>
int main(void)
{
printf("sizeof(off_t)=%zu sizeof(time_t)=%zu sizeof(long)=%zu\n",
sizeof(off_t), sizeof(time_t), sizeof(long));
/* 1. 坏指针不会 SIGSEGV,内核返回 EFAULT */
errno = 0;
long r = syscall(SYS_getrandom, (void *)0x1, (size_t)16, 0u);
printf("getrandom(bad) = %ld, errno=%d (%s)\n",
r, errno, strerror(errno));
/* 2. syscall() 也做 errno 翻译:内核返回 -ENOSYS,这里看到 -1 */
errno = 0;
r = syscall(SYS_getpid);
printf("syscall(SYS_getpid)=%ld errno=%d\n", r, errno);
/* 3. 调用号是架构相关的,必须用 SYS_* 而不是数字 */
printf("SYS_read=%d SYS_write=%d SYS_openat=%d\n",
(int)SYS_read, (int)SYS_write, (int)SYS_openat);
#ifdef SYS_statx
printf("SYS_statx=%d\n", (int)SYS_statx);
#endif
#ifdef SYS_clone3
printf("SYS_clone3=%d(参数是 struct clone_args *)\n", (int)SYS_clone3);
#endif
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 abi_probe.c -o abi_probe
./abi_probe
# x86-64 上典型输出:
# sizeof(off_t)=8 sizeof(time_t)=8 sizeof(long)=8
# getrandom(bad) = -1, errno=14 (Bad address)
# syscall(SYS_getpid)=48211 errno=0
# SYS_read=0 SYS_write=1 SYS_openat=257
# SYS_statx=332
# SYS_clone3=435(参数是 struct clone_args *)
第一行输出在 32 位系统上会不同:sizeof(time_t) 可能是 4,sizeof(off_t) 也可能是 4。这不是「编译器的选择」,而是 ABI 与特性宏共同决定的(2.1.11 展开)。
EFAULT:内核如何对待坏指针
用户态传一个野指针给 read,进程不会收到 SIGSEGV;内核的 copy_to_user/copy_from_user 会捕获缺页,返回未拷贝的字节数,系统调用最终返回 -EFAULT。这条规则非常重要:
- 内核不能用用户态的解引用指令直接访问用户内存,否则一个恶意指针就能让内核崩溃;
access_ok()只检查地址范围是否落在用户空间,不保证页已映射;- 真正的映射检查发生在拷贝时,失败就返回
EFAULT。
因此「用 read 探测一个地址是否可写」不可靠:EFAULT 只说明这次拷贝失败,不说明地址永久无效;反过来,access_ok 通过也不代表不会 EFAULT。
- 「
syscall()是直接执行syscall指令」——glibc 的syscall()是一段 C/汇编包装,负责按 ABI 排寄存器并做errno翻译;某些架构上它还会走 vDSO 或做指针检查。 - 「内核返回 -1 表示错误」——内核返回的是
-errno(如-2表示ENOENT);只有 libc 包装才把它变成-1加errno。看strace的= -1 ENOENT是 libc 视角。 - 「系统调用号可以跨架构复用」——
SYS_read在 x86-64 是 0,在 ARM64 是 63。写死数字的代码换架构就调错函数,且编译器不会报错。 - 「被 seccomp 拦截会收到信号」——默认动作是杀进程,但
SECCOMP_RET_ERRNO可以让调用直接返回指定错误码,看起来像普通失败;容器里「openat突然返回EPERM」常常是 seccomp 而不是权限。
32 位时间与 EOVERFLOW
time_t 在 32 位 ABI 上是 32 位有符号数,2038 年会溢出。Linux 5.1 起提供了一整套 *time64 系统调用,glibc 2.34 起在 32 位上默认用 64 位 time_t 编译(_TIME_BITS=64)。这带来两个可观察后果:
- 32 位程序读大文件的
stat可能返回EOVERFLOW,因为st_size放不下;用stat64或定义_FILE_OFFSET_BITS=64解决。 - 直接
syscall(SYS_clock_gettime, ...)在 32 位新 glibc 上可能调错——用户态结构是 64 位的,内核旧入口期望 32 位。不要绕过包装函数,除非你在写 libc。
2.1.10 errno 的深水区:strerror_r、别名与错误映射
这一节回答:为什么同一个 strerror_r 有两种签名?EAGAIN 和 EWOULDBLOCK 是同一个数吗?错误码该怎样变成 HTTP 状态码或退出码?
2.1.3 给了七条规则,但真实代码还要处理三件事:把错误码转成字符串、识别等价的错误码、把错误码映射成对外协议。
strerror_r 的两个不兼容版本
POSIX 版(XSI)返回 int:0 成功,非 0 是错误码。GNU 版返回 char *:可能指向你给的缓冲区,也可能指向静态字符串。同一个函数名,签名不同,取决于 _GNU_SOURCE:
/* POSIX/XSI:返回 int */
int strerror_r(int errnum, char *buf, size_t buflen);
/* GNU:返回 char *,可能忽略 buf */
char *strerror_r(int errnum, char *buf, size_t buflen);
写可移植代码的稳妥做法是用 strerror_l(POSIX.1-2008)或自己判断:
static const char *err_text(int e, char *buf, size_t n)
{
#if defined(__GLIBC__) && defined(_GNU_SOURCE)
return strerror_r(e, buf, n); /* GNU:直接返回字符串 */
#else
if (strerror_r(e, buf, n) != 0)
snprintf(buf, n, "errno %d", e);
return buf;
#endif
}
GNU 版有一个坑:当错误码未知时,它返回的可能是 "Unknown error 1234" 这样的静态字符串,而不是写进 buf;调用者不能假设返回值一定等于 buf,也不能 free 它。
等价的错误码
| 名字对 | Linux 上是否相等 | 说明 |
|---|---|---|
EAGAIN / EWOULDBLOCK | 相等(11) | 非阻塞「暂时不可用」 |
ENOTSUP / EOPNOTSUPP | 相等(95) | 操作不支持;POSIX 允许不同 |
EDEADLK / EDEADLOCK | 相等(35) | 历史别名 |
ENODATA / ENOATTR | Linux 只有 ENODATA | macOS 用 ENOATTR,跨平台代码要 #ifdef |
ENOTSUP / ENOSYS | 不相等 | 前者「这个对象不支持」,后者「内核没这个调用」 |
判断时写名字,不写数字;但不要假设两个名字一定不等。例如 if (errno == ENOTSUP || errno == EOPNOTSUPP) 在 Linux 上是重复条件,编译器可能警告 -Wlogical-op(GCC),写成 errno == EOPNOTSUPP 就够,跨平台再包一层宏。
错误码到对外语义的映射
服务端不能把 errno 直接返回给客户端,但也不该一律 500。一张最小映射表:
errno | HTTP | 是否重试 | 备注 |
|---|---|---|---|
ENOENT | 404 | 否 | 资源不存在 |
EACCES / EPERM | 403 | 否 | 权限或 LSM 拒绝 |
EINVAL | 400 | 否 | 参数非法,常是调用方 bug |
ENOSPC / EDQUOT | 507 | 否 | 磁盘/配额满 |
EMFILE / ENFILE | 503 | 是(退避) | 本进程/系统 fd 耗尽 |
EAGAIN / EWOULDBLOCK | 503 + Retry-After | 是 | 暂时不可用 |
ETIMEDOUT | 504 | 视语义 | 上游超时 |
EINTR | — | 是 | 不应到达协议层,循环内重试 |
EIO | 500 | 否 | 设备/文件系统错误,需告警 |
进程退出码则用 sysexits.h 的 EX_USAGE(64)、EX_DATAERR(65)、EX_NOINPUT(66)、EX_UNAVAILABLE(69)、EX_OSERR(71)、EX_IOERR(74)。退出码只有 8 位,exit(256) 实际变成 0,这是脚本里「程序失败了但 $? 是 0」的常见原因。
/* strerror_demo.c — 三种取错误文本的方式与等价错误码 */
#include <errno.h>
#include <stdio.h>
#include <string.h>
static void show(int e)
{
char buf[128];
const char *s = strerror_r(e, buf, sizeof buf); /* GNU 版 */
printf("%-12s %3d %s\n", "errno", e, s);
}
int main(void)
{
show(ENOENT);
show(EACCES);
show(EINVAL);
show(ENOSPC);
printf("\nEAGAIN=%d EWOULDBLOCK=%d equal=%d\n",
EAGAIN, EWOULDBLOCK, EAGAIN == EWOULDBLOCK);
printf("ENOTSUP=%d EOPNOTSUPP=%d equal=%d\n",
ENOTSUP, EOPNOTSUPP, ENOTSUP == EOPNOTSUPP);
errno = ENOENT;
printf("\n%%m 直接打印当前 errno: %m\n"); /* glibc 扩展 */
printf("显式错误码: %s\n", strerror(ENOSPC));
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 strerror_demo.c -o strerror_demo
./strerror_demo
%m 是 glibc 的 printf 扩展,等价于 strerror(errno),不消耗参数。它方便,但不可移植;日志库里用 strerror_r 更稳。
- 「
strerror_r返回 0 就是成功」——只有 XSI 版如此;GNU 版返回指针,永远非 0。 - 「
strerror返回的字符串可以长期保存」——它可能指向静态缓冲区,下一次调用会被覆盖;多线程下更不安全。 - 「错误码是负数」——用户态
errno是正的;负数只出现在内核返回值里。 - 「
errno会被printf保留」——printf内部可能调用malloc/write,失败时改写errno;打印错误前先保存。 - 「
EINTR应该报告给用户」——它表示「被信号打断」,绝大多数情况应重试,不应变成 500。
2.1.11 特性测试宏矩阵与 ABI 宏
这一节回答:_GNU_SOURCE、_POSIX_C_SOURCE、_XOPEN_SOURCE、_DEFAULT_SOURCE 之间是什么关系?_FILE_OFFSET_BITS=64 为什么能改变结构体布局?
2.1.5 讲了「宏控制声明可见性」。但特性测试宏不只控制函数声明,还控制类型定义与结构体布局。最典型的是 _FILE_OFFSET_BITS=64:它让 off_t、stat、mmap 的偏移参数全部变成 64 位,从而改变 struct stat 的大小和 stat 符号的版本。
宏之间的关系
| 你定义的 | glibc 等价打开 | 典型可见接口 |
|---|---|---|
_POSIX_C_SOURCE=200809L | POSIX.1-2008 | posix_memalign、clock_gettime、sigaction |
_XOPEN_SOURCE=700 | POSIX.1-2008 + XSI | openpty、log2、crypt |
_DEFAULT_SOURCE | POSIX + BSD + SVID 默认集 | strdup、strcasecmp、MSG_NOSIGNAL |
_GNU_SOURCE | 上面全部 + GNU 扩展 | accept4、pipe2、memfd_create、%m |
_ISOC11_SOURCE | C11 库扩展 | aligned_alloc |
_FILE_OFFSET_BITS=64 | 大文件 ABI | off_t 变 64 位,stat 重定向到 stat64 |
_TIME_BITS=64 | 64 位 time_t(需同时定义 _FILE_OFFSET_BITS=64) | 32 位系统上的 2038 安全 |
_GNU_SOURCE 会隐式定义 _DEFAULT_SOURCE、_POSIX_C_SOURCE=200809L、_XOPEN_SOURCE=700、_ISOC99_SOURCE、_ISOC11_SOURCE。反过来不成立:定义了 _POSIX_C_SOURCE 之后,glibc 不再默认给 _DEFAULT_SOURCE,BSD 扩展全部消失。
顺序:宏必须在第一个系统头之前
/* 正确:翻译单元最前面 */
#define _GNU_SOURCE
#include <stdio.h>
/* 错误:features.h 已经被间接包含,宏来晚了 */
#include <stdio.h>
#define _GNU_SOURCE
#include <unistd.h> /* 看不到 accept4 */
<features.h> 是 glibc 的「总开关」,任何系统头都会先包含它。它只在第一次被包含时根据当时的宏计算 __USE_* 系列,之后再定义特性宏不会重新计算。所以本书统一用编译命令 -D_GNU_SOURCE,从翻译单元的第 0 个字符就生效。
用 _Static_assert 锁住 ABI
特性宏配错时,最可怕的不是编译失败,而是静默用错 ABI。例如 32 位程序没有定义 _FILE_OFFSET_BITS=64,off_t 是 32 位,lseek 到 3 GiB 会失败或截断。可以用编译期断言把假设写死:
#include <assert.h>
#include <sys/types.h>
_Static_assert(sizeof(off_t) == 8, "需要 64 位 off_t:加 -D_FILE_OFFSET_BITS=64");
_Static_assert(sizeof(time_t) >= 8, "需要 64 位 time_t:加 -D_TIME_BITS=64");
这比运行时报错早得多,也比注释可靠。
/* feature_matrix.c — 打印当前翻译单元实际生效的特性宏 */
#include <features.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>
int main(void)
{
printf("glibc %d.%d\n", __GLIBC__, __GLIBC_MINOR__);
#ifdef __USE_GNU
printf("__USE_GNU=1(GNU 扩展可见)\n");
#else
printf("__USE_GNU=0\n");
#endif
#ifdef __USE_XOPEN2K8
printf("__USE_XOPEN2K8=1(POSIX.1-2008 可见)\n");
#else
printf("__USE_XOPEN2K8=0\n");
#endif
#ifdef __USE_MISC
printf("__USE_MISC=1(BSD/SVID 默认集可见)\n");
#else
printf("__USE_MISC=0\n");
#endif
printf("sizeof(off_t)=%zu sizeof(time_t)=%zu\n",
sizeof(off_t), sizeof(time_t));
#ifdef _FILE_OFFSET_BITS
printf("_FILE_OFFSET_BITS=%d\n", _FILE_OFFSET_BITS);
#else
printf("_FILE_OFFSET_BITS 未定义(32 位上意味着 32 位 off_t)\n");
#endif
return 0;
}
# 三种组合各编一次,比较输出
cc -std=c17 -Wall -Wextra -Werror -O2 feature_matrix.c -o fm_default
cc -std=c17 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -Werror -O2 feature_matrix.c -o fm_posix
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 feature_matrix.c -o fm_gnu
./fm_default; ./fm_posix; ./fm_gnu
三者最明显的差别是 __USE_GNU 与 __USE_MISC:默认编译(-std=c17 且无宏)连 strdup 都可能没有声明,而 _GNU_SOURCE 全开。
- 「
-std=gnu17等于_GNU_SOURCE」——不等。gnu17只关掉__STRICT_ANSI__,让 GCC 扩展可用;glibc 的_GNU_SOURCE仍要显式定义。 - 「
_FILE_OFFSET_BITS=64只是宏,不改 ABI」——它会把stat/fstat/lseek/mmap的声明重定向到 64 位版本,struct stat布局随之改变;混用不同宏编译的目标文件会破坏 ABI。 - 「头文件里可以定义
_GNU_SOURCE」——头文件可能在被包含前已经引入了系统头,顺序不可控;只放在源文件第一行或命令行。 - 「
_TIME_BITS=64可以单独用」——glibc 要求同时定义_FILE_OFFSET_BITS=64,否则编译报错。 - 「
__USE_GNU可以手写」——它由<features.h>计算,手写无效且可能破坏头文件逻辑;只能通过特性宏间接控制。
2.1.12 限制查询:sysconf、pathconf 与 getrlimit
这一节回答:sysconf(_SC_OPEN_MAX)、ulimit -n、/proc/sys/fs/nr_open 三个数为什么有时相同、有时不同?程序该信哪一个?
2.1.6 讲了 sysconf 的返回值二义性。真实程序还要面对三层限制:
| 层 | 查询方式 | 例子 | 谁受限 |
|---|---|---|---|
| 内核全局上限 | /proc/sys/fs/nr_open、/proc/sys/fs/file-max | nr_open 默认 1048576 | 所有进程 |
| 进程资源限制 | getrlimit(RLIMIT_NOFILE) | 软限制 1024,硬限制 1048576 | 单个进程 |
| POSIX 查询接口 | sysconf(_SC_OPEN_MAX) | glibc 返回软限制;其他实现可能不同 | 本进程 |
在 Linux/glibc 上,sysconf 不是独立的第三层,而是 RLIMIT_NOFILE 软限制的投影(Kerrisk 第 11 章)。要判断还能开多少 fd,仍应以 getrlimit 为准:
struct rlimit rl;
if (getrlimit(RLIMIT_NOFILE, &rl) == -1)
errExit("getrlimit");
printf("soft=%llu hard=%llu\n",
(unsigned long long)rl.rlim_cur, (unsigned long long)rl.rlim_max);
/* 在硬限制内提高软限制;超过硬限制返回 EPERM */
rl.rlim_cur = rl.rlim_max;
if (setrlimit(RLIMIT_NOFILE, &rl) == -1 && errno == EPERM)
fprintf(stderr, "没有权限提高 fd 上限\n");
常用 sysconf 名字
| 名字 | 含义 | 典型值 | 注意 |
|---|---|---|---|
_SC_PAGESIZE | 页大小 | 4096 | 必须用它,不能写死 |
_SC_PHYS_PAGES | 物理页数 | 可变 | 乘页大小得内存 |
_SC_AVPHYS_PAGES | 可用页数 | 可变 | 不含可回收缓存 |
_SC_NPROCESSORS_ONLN | 在线 CPU | 8 | 容器里可能被 cgroup 限制 |
_SC_CLK_TCK | times() 频率 | 100 | /proc/PID/stat 的 jiffies |
_SC_ARG_MAX | 参数+环境上限 | 2097152 | 还受栈 RLIMIT_STACK 限制 |
_SC_IOV_MAX | readv 段数上限 | 1024 | 不是 IOV_MAX 宏 |
_SC_SEM_VALUE_MAX | 信号量最大值 | 2147483647 | |
_SC_SIGQUEUE_MAX | 排队信号数 | 可变 | 受 RLIMIT_SIGPENDING |
_SC_ATEXIT_MAX | atexit 注册数 | 2147483647 | 实际受内存限制 |
_SC_LOGIN_NAME_MAX | 登录名长度 | 256 | |
_SC_HOST_NAME_MAX | 主机名长度 | 64 | |
_SC_SYMLOOP_MAX | 符号链接层数 | 40 |
pathconf 是「这个文件系统」的限制
pathconf(path, name) 作用于路径所在的文件系统。_PC_NAME_MAX 在 ext4 是 255,在 eCryptfs 可能只有 143;_PC_PATH_MAX 通常是 4096,但 Linux 的路径解析并没有硬性总长限制,openat2 只看实际字符串长度。_PC_PIPE_BUF 更有用:它决定一次 write 到管道是否原子(≤ PIPE_BUF 保证不交错)。
long pipe_buf = fpathconf(pipefd[1], _PC_PIPE_BUF);
/* 常见 4096;超过它的一次 write 可能被拆开,与其他写者交错 */
pathconf 的 -1 同样有二义性:errno == 0 表示「不确定」,否则是错误。它比 sysconf 更容易踩这个坑,因为「不确定」在文件系统上很常见。
/* limits2.c — 三层限制一起看:rlimit、sysconf、pathconf */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
#include <unistd.h>
static void sc(const char *name, int key)
{
errno = 0;
long v = sysconf(key);
if (v == -1 && errno != 0)
printf("%-24s error: %s\n", name, strerror(errno));
else if (v == -1)
printf("%-24s 不确定\n", name);
else
printf("%-24s %ld\n", name, v);
}
static void pc(const char *name, int fd, int key)
{
errno = 0;
long v = fpathconf(fd, key);
if (v == -1 && errno != 0)
printf("%-24s error: %s\n", name, strerror(errno));
else if (v == -1)
printf("%-24s 不确定\n", name);
else
printf("%-24s %ld\n", name, v);
}
int main(void)
{
struct rlimit rl;
if (getrlimit(RLIMIT_NOFILE, &rl) == -1) { perror("getrlimit"); return 1; }
printf("RLIMIT_NOFILE soft=%llu hard=%llu\n",
(unsigned long long)rl.rlim_cur, (unsigned long long)rl.rlim_max);
if (getrlimit(RLIMIT_NPROC, &rl) == 0)
printf("RLIMIT_NPROC soft=%llu hard=%llu\n",
(unsigned long long)rl.rlim_cur, (unsigned long long)rl.rlim_max);
if (getrlimit(RLIMIT_MEMLOCK, &rl) == 0)
printf("RLIMIT_MEMLOCK soft=%llu hard=%llu\n",
(unsigned long long)rl.rlim_cur, (unsigned long long)rl.rlim_max);
putchar('\n');
sc("_SC_PAGESIZE", _SC_PAGESIZE);
sc("_SC_OPEN_MAX", _SC_OPEN_MAX);
sc("_SC_IOV_MAX", _SC_IOV_MAX);
sc("_SC_ARG_MAX", _SC_ARG_MAX);
sc("_SC_NPROCESSORS_ONLN", _SC_NPROCESSORS_ONLN);
int fd = open("/tmp", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (fd == -1) { perror("open /tmp"); return 1; }
putchar('\n');
pc("_PC_NAME_MAX(/tmp)", fd, _PC_NAME_MAX);
pc("_PC_PATH_MAX(/tmp)", fd, _PC_PATH_MAX);
pc("_PC_PIPE_BUF(/tmp)", fd, _PC_PIPE_BUF);
close(fd);
int p[2];
if (pipe(p) == -1) { perror("pipe"); return 1; }
int sz = fcntl(p[1], F_GETPIPE_SZ); /* Linux 扩展 */
printf("\npipe capacity=%d bytes\n", sz);
printf("F_SETPIPE_SZ max=%d\n", 1048576); /* /proc/sys/fs/pipe-max-size */
close(p[0]);
close(p[1]);
/* 试超硬限制:EPERM */
if (getrlimit(RLIMIT_NOFILE, &rl) == 0) {
rlim_t old = rl.rlim_max;
rl.rlim_cur = old;
rl.rlim_max = old + 1;
if (setrlimit(RLIMIT_NOFILE, &rl) == -1)
printf("setrlimit 超硬限制: %s\n", strerror(errno));
}
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 limits2.c -o limits2
./limits2
ulimit -n; cat /proc/sys/fs/nr_open
三条命令里,ulimit -n 与 _SC_OPEN_MAX 应一致(都反映本进程软限制);nr_open 是内核全局上限,通常远大于两者。程序应按软限制分配 fd 表,而不是按 nr_open;否则会先撞上 EMFILE。
- 先看进程:
cat /proc/PID/limits,确认 soft/hard 与Max open files。 - 再看内核:
sysctl fs.nr_open fs.file-max、/proc/sys/fs/pipe-max-size。 - 再看文件系统:
stat -f /path、fpathconf的NAME_MAX/PIPE_BUF。 - 最后看容器/cgroup:
systemd的LimitNOFILE=、Kubernetes 的ulimits、/sys/fs/cgroup/.../pids.max。 - 不要用
_SC_OPEN_MAX推断系统总量;glibc 上它等于本进程软限制,其他 libc 可能不同。
RLIMIT_* 全表:超限时到底发生什么
getrlimit/setrlimit 不只用于 fd。每个资源超限的后果不同:有的返回 errno,有的发信号,有的直接 SIGKILL。
| 资源 | 限制什么 | 超限后果 |
|---|---|---|
RLIMIT_CPU | CPU 秒数(软/硬) | 软限发 SIGXCPU;硬限发 SIGKILL |
RLIMIT_FSIZE | 单个文件最大字节数 | 写超限发 SIGXFSZ;忽略信号则 write 返回 EFBIG |
RLIMIT_CORE | core 文件最大字节数 | 超过则截断 core;0 表示不写 |
RLIMIT_DATA | brk/数据段大小 | brk/sbrk 返回 ENOMEM;mmap 是否计入取决于内核 |
RLIMIT_STACK | 主线程栈大小 | 栈溢出 SIGSEGV;pthread 栈不受它限制 |
RLIMIT_NOFILE | fd 数量 | open/socket 返回 EMFILE |
RLIMIT_NPROC | 每 real UID 的进程/线程数 | fork/clone 返回 EAGAIN |
RLIMIT_MEMLOCK | mlock/mmap(MAP_LOCKED) 的字节数 | mlock 返回 ENOMEM/EPERM |
RLIMIT_AS | 虚拟地址空间总字节数 | mmap/brk 返回 ENOMEM;不是 RSS 上限 |
RLIMIT_SIGPENDING | 每 real UID 排队信号数 | sigqueue 返回 EAGAIN |
RLIMIT_MSGQUEUE | POSIX mq 字节数 | mq_open/mq_send 返回 EMFILE/EAGAIN |
RLIMIT_NICE | nice 可调范围 | setpriority 被限制;EPERM |
RLIMIT_RTPRIO | 实时优先级上限 | sched_setscheduler 返回 EPERM |
RLIMIT_RTTIME | 实时任务连续运行微秒数 | 超限发 SIGXCPU/SIGKILL |
规则:
- 软限制是当前生效值,硬限制是软限制的上限;
rlim_cur <= rlim_max。 - 非特权进程可以降低硬限制,但不能再升回;提高硬限制需要
CAP_SYS_RESOURCE。 fork/exec继承;exec会保留限制(setuid 程序也一样,除非被 PAM/系统策略改)。prlimit(2)可以跨进程读写另一个进程的限制(需要权限),比setrlimit更适合排障。
2.1.13 命令行解析:getopt_long 的完整用法
这一节回答:怎样写一个既支持 -n 3、-n3、--count=3,又能在出错时给出正确退出码的参数解析器?
2.1.8 给了一个 getopt 片段。真实工具还需要长选项、可选参数、子命令和「选项与操作数混排」的控制。
struct option 与返回值
struct option {
const char *name; /* 长选项名,不带 "--" */
int has_arg; /* no_argument / required_argument / optional_argument */
int *flag; /* NULL:返回 val;非 NULL:把 val 写进 *flag,返回 0 */
int val; /* 返回值或写入 *flag 的值 */
};
getopt_long 的返回值:
- 短选项字符:正常;
val(当flag == NULL):长选项匹配;0(当flag != NULL):已把值写进*flag;'?':未知选项或参数缺失(opterr为 0 时不打印);':':optstring以:开头且缺少参数;-1:解析结束。
长选项支持唯一前缀缩写:--cou 能匹配 --count,但如果有 --count 和 --color 两个前缀相同的选项,缩写会返回 '?' 并报「ambiguous」。脚本里不要依赖缩写,版本一加选项就可能歧义。
三个影响解析顺序的开关
| 写法 | 行为 |
|---|---|
getopt(argc, argv, "n:o:") | GNU 默认重排 argv,把选项移到前面,操作数留在 optind 之后 |
getopt(argc, argv, "+n:o:") | 遇到第一个非选项就停止,保持 POSIX 顺序 |
环境变量 POSIXLY_CORRECT | 等价于加 +,全局生效 |
-- | 强制结束选项解析,后面的都当操作数 |
optind 是下一个待处理参数的下标。解析结束后,argv[optind] 起是操作数。GNU 扩展允许把 optind 重置为 0 来重新解析(optind = 0 会重新初始化,包括 POSIXLY_CORRECT 检查);optind = 1 只是回到开头,不重新初始化内部状态。
/* opts.c — getopt_long:短选项、长选项、可选参数、错误处理 */
#include <errno.h>
#include <getopt.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void usage(const char *prog)
{
fprintf(stderr,
"usage: %s [-v] [-n COUNT] [-o FILE] [--color[=WHEN]] INPUT...\n"
" -n, --count=N 重复次数(默认 1)\n"
" -o, --output=FILE 输出文件(默认 stdout)\n"
" -v, --verbose 打印解析结果\n"
" --color[=WHEN] auto/always/never\n", prog);
}
static long parse_count(const char *s)
{
char *end = NULL;
errno = 0;
long v = strtol(s, &end, 10);
if (errno != 0 || end == s || *end != '\0' || v <= 0 || v > INT_MAX) {
fprintf(stderr, "invalid count: %s\n", s);
exit(EXIT_FAILURE);
}
return v;
}
int main(int argc, char *argv[])
{
static const struct option longopts[] = {
{ "count", required_argument, NULL, 'n' },
{ "output", required_argument, NULL, 'o' },
{ "verbose", no_argument, NULL, 'v' },
{ "color", optional_argument, NULL, 'c' },
{ "help", no_argument, NULL, 'h' },
{ NULL, 0, NULL, 0 },
};
long count = 1;
const char *output = NULL;
const char *color = "auto";
int verbose = 0;
int ch;
opterr = 0; /* 自己打印错误,避免 glibc 的措辞 */
while ((ch = getopt_long(argc, argv, ":n:o:vh", longopts, NULL)) != -1) {
switch (ch) {
case 'n':
count = parse_count(optarg);
break;
case 'o':
output = optarg;
break;
case 'v':
verbose = 1;
break;
case 'c':
color = optarg ? optarg : "always";
if (strcmp(color, "auto") && strcmp(color, "always") &&
strcmp(color, "never")) {
fprintf(stderr, "bad --color value: %s\n", color);
return 2;
}
break;
case 'h':
usage(argv[0]);
return 0;
case ':':
fprintf(stderr, "option -%c requires an argument\n", optopt);
return 2;
case '?':
default:
if (optopt)
fprintf(stderr, "unknown option -%c\n", optopt);
else
fprintf(stderr, "unknown option: %s\n", argv[optind - 1]);
return 2;
}
}
if (optind >= argc) {
fprintf(stderr, "%s: missing INPUT\n", argv[0]);
return 2;
}
if (verbose) {
printf("count=%ld output=%s color=%s operands=%d\n",
count, output ? output : "(stdout)", color, argc - optind);
for (int i = optind; i < argc; i++)
printf(" operand[%d]=%s\n", i - optind, argv[i]);
}
/* 这里只演示解析;真实程序在此处理 argv[optind..argc-1] */
return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 opts.c -o opts
./opts -v -n3 a.txt b.txt
./opts --count=2 --color --output=/tmp/x a.txt
./opts --count # 退出码 2:缺参数
./opts --col=never a.txt # 唯一前缀缩写
./opts -- -n # "-n" 是操作数,不是选项
POSIXLY_CORRECT=1 ./opts a.txt -v # -v 不再被解析
最后一条命令展示了 POSIXLY_CORRECT 的影响:在 POSIX 模式下,a.txt 之后的 -v 被当成操作数。很多脚本依赖 GNU 重排,换到 POSIX 模式或 BSD 上行为会变,所以选项应写在操作数前面,不要依赖重排。
- 「
optarg可以长期保存」——它指向argv内部,生命周期与argv相同,通常没问题;但不要free它,也不要假设下一次解析后还指向同一个字符串。 - 「
optind初始一定是 1」——库函数或多次解析后不是;重新解析前设optind = 0(GNU)或保存/恢复。 - 「
getopt是线程安全的」——它用全局状态(optind/optarg/optopt),多线程同时解析会互相踩。 - 「长选项缩写总是可用」——只有唯一前缀才行;新增一个同前缀选项就会让旧脚本报 ambiguous。
- 「
-n 3和-n3行为一样」——对必需参数是的;对optional_argument长选项,--color always不会把always当参数,必须写--color=always。
2.1.14 内核路径:openat 与 read 落到文件系统
这一节回答:从 openat 到 ext4 真正读磁盘,中间经过哪些函数?出问题时该在哪个层次加观测?
2.1.1 给了入口与分派的轮廓。这里把两条最常用的路径补到函数级(Linux 6.8)。
openat 的路径
| 层次 | 位置 | 关键动作 |
|---|---|---|
| libc | sysdeps/unix/sysv/linux/openat.c | 排寄存器、syscall |
| 入口 | arch/x86/entry/entry_64.S | entry_SYSCALL_64 保存寄存器 |
| 分派 | arch/x86/entry/common.c | do_syscall_64 → sys_call_table[257] |
| 系统调用 | fs/open.c | do_sys_openat2:build_open_flags、getname、get_unused_fd_flags |
| 路径解析 | fs/namei.c | do_filp_open → path_openat → link_path_walk |
| 打开 | fs/open.c | vfs_open → do_dentry_open → file->f_op->open |
| 文件系统 | fs/ext4/file.c | ext4_file_open(或 generic_file_open) |
| 返回 | fs/file.c | fd_install 把 struct file * 装进 fd 表 |
do_sys_openat2() 的形状(删节):
static long do_sys_openat2(int dfd, const char __user *filename,
struct open_how *how)
{
struct open_flags op;
int fd = build_open_flags(how, &op);
struct filename *tmp;
if (fd)
return fd;
tmp = getname(filename);
if (IS_ERR(tmp))
return PTR_ERR(tmp);
fd = get_unused_fd_flags(how->flags);
if (fd >= 0) {
struct file *f = do_filp_open(dfd, tmp, &op);
if (IS_ERR(f)) {
put_unused_fd(fd);
fd = PTR_ERR(f);
} else {
fd_install(fd, f);
}
}
putname(tmp);
return fd;
}
关键点是先分配 fd,再打开文件:get_unused_fd_flags 在 fd 表里占一个号,do_filp_open 失败就 put_unused_fd 归还。这样返回给用户态的 fd 在安装前不会被其他线程抢走。fd_install 之后,fd 才真正可用。
read 的路径
| 层次 | 位置 | 关键动作 |
|---|---|---|
| libc | sysdeps/unix/sysv/linux/read.c | syscall(SYS_read, ...) |
| 系统调用 | fs/read_write.c | ksys_read → vfs_read |
| 检查 | fs/read_write.c | FMODE_READ、access_ok、rw_verify_area、MAX_RW_COUNT 截断 |
| 文件系统 | include/linux/fs.h | file->f_op->read_iter |
| ext4 | fs/ext4/file.c | ext4_file_read_iter → generic_file_read_iter |
| 页缓存 | mm/filemap.c | filemap_read → filemap_get_pages → page_cache_sync_readahead |
| 缺页/读盘 | mm/filemap.c | filemap_read_folio → address_space->a_ops->read_folio |
| 块层 | fs/ext4/inode.c | ext4_mpage_readpages / ext4_bio_write_page |
vfs_read() 的核心只有十几行:
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
ssize_t ret;
if (!(file->f_mode & FMODE_READ))
return -EBADF;
if (!(file->f_mode & FMODE_CAN_READ))
return -EINVAL;
if (unlikely(!access_ok(buf, count)))
return -EFAULT;
ret = rw_verify_area(READ, file, pos, count);
if (ret)
return ret;
if (count > MAX_RW_COUNT)
count = MAX_RW_COUNT;
return file->f_op->read_iter(file, buf, count, pos);
}
由此可以解释三个现象:
read的count超过约 2 GiB 会被截断。MAX_RW_COUNT是INT_MAX & PAGE_MASK(约 2 GiB),一次read不可能读更多。read对只写 fd 返回EBADF,而不是EINVAL;对不支持读的 fd(如某些设备)返回EINVAL。- 坏缓冲区返回
EFAULT,因为access_ok先拦一层,真正的拷贝失败再由copy_to_user返回。
struct file 里与性能相关的字段:f_mode(读/写能力)、f_flags(O_NONBLOCK 等)、f_pos(共享偏移)、f_op(方法表)、f_mapping(page cache 的 address_space)、f_inode。fdget() 是 read 系列的第一步:它把 fd 转成 struct file * 并禁止并发关闭;fdget_pos() 额外锁住 f_pos,用于需要偏移的调用。
strace 只观察系统调用边界,看到的是 read(3, ...) = 12。vfs_read、filemap_read、ext4_file_read_iter 都在内核里,要观察它们得用 perf probe、bpftrace 或 ftrace。例如:
sudo bpftrace -e 'kfunc:vfs_read { @[comm] = count(); }'
sudo perf probe -k /usr/lib/debug/boot/vmlinux-$(uname -r) vfs_read
符号可能被内联(vfs_read 在部分配置下是 static inline 或被 LTO 合并),探不到时换 ksys_read 或 __x64_sys_read。
2.1.15 两个实验:用 strace 与 /proc 拿证据
这一节回答:怎么在不改代码、不装调试器的情况下,看清一个程序到底调了哪些系统调用、fd 处于什么状态?
实验一:strace 看文件打开与 fd 复用
strace -f -yy -s 128 -e trace=%file,%desc ./limits2 2>&1 | head -30
-f 跟线程/子进程,-yy 把 fd 翻译成路径,-s 128 放宽字符串截断,-e trace=%file,%desc 只看文件与 fd 相关调用。典型输出:
$ strace -f -yy -e trace=%file,%desc ./limits2 2>&1 | head -12
execve("./limits2", ["./limits2"], 0x7ffd... /* 42 vars */) = 0
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3</etc/ld.so.cache>
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3</lib/x86_64-linux-gnu/libc.so.6>
openat(AT_FDCWD, "/tmp", O_RDONLY|O_DIRECTORY|O_CLOEXEC) = 3</tmp>
pipe2([4<pipe:[123456]>, 5<pipe:[123456]>], O_CLOEXEC) = 0
fcntl(5<pipe:[123456]>, F_GETPIPE_SZ) = 65536
close(3</tmp>) = 0
close(4<pipe:[123456]>) = 0
读法:
= 3后面的</etc/ld.so.cache>是-yy的功劳,说明 fd 3 被复用:动态链接器关闭后,/tmp又拿到 3。fd 号是复用的,日志里只记数字会指错对象。O_RDONLY|O_DIRECTORY|O_CLOEXEC是strace对 flags 的解码;看不到O_LARGEFILE是因为 64 位平台不需要。pipe2(..., O_CLOEXEC)说明pipe()在 glibc 内部用了pipe2,原子设置CLOEXEC。- 失败行会写成
= -1 ENOENT (No such file or directory);ENOENT是符号名,括号里是strerror文本。
常用变体:
# 只统计耗时与调用次数
strace -c -e trace=openat,read,write ./limits2
# 只看某个路径
strace -e trace=openat -P /etc/hosts ./limits2
# 故障注入:让第一次 openat 返回 EACCES,验证错误分支
strace -e inject=openat:error=EACCES:when=1 ./limits2
-e inject 是测试错误路径的利器:不用改代码、不用造权限,就能让 openat 返回指定 errno。注意 when=1 是「第 1 次匹配」,+ 后缀可表示「从第 N 次起每次都注入」(如 when=2+)。
实验二:/proc/self/fdinfo 读 fd 状态
/proc/self/fd 给的是符号链接,/proc/self/fdinfo/N 给的是这个 fd 的内核状态:
exec 3</etc/hosts
cat /proc/self/fdinfo/3
# pos: 0
# flags: 0100000
# mnt_id: 28
# ino: 1835009
flags 是八进制,0100000 是 O_LARGEFILE(64 位平台上的历史位),O_RDONLY 是 0;打开写时能看到 0100002(O_RDWR)或 0100001(O_WRONLY)。pos 是共享偏移的当前值,mnt_id 是挂载 ID,可以和 /proc/self/mountinfo 的字段对上。
# 十六进制/八进制互转,确认 flag 位
printf '%o\n' 0x241 # 1101
cat /proc/self/mountinfo | awk -v id=28 '$1==id'
另一个有用的文件是 /proc/self/syscall:它显示当前线程正在执行的系统调用号与参数。对一个阻塞在 read 的进程:
cat /proc/self/syscall
# 0 0x3 0x7ffd... 0x10 0x0 0x0 0x0 0x7f...
第一列是调用号(0 = read),后面是六个参数寄存器,最后是栈指针与程序计数器。它只在进程被暂停/采样时有意义,但配合 cat 能快速确认「它到底卡在哪个系统调用」。
strace -f -yy -e trace=%file ./app看最后一个失败的openat与errno。- 若
errno=ENOENT但路径明明存在,检查是否chroot/mount namespace 不同:ls -l /proc/PID/root/path。 - 若
errno=EACCES,用namei -l path逐级看目录x位;再查 LSM:dmesg | grep -i apparmor。 - 若
errno=EMFILE,ls /proc/PID/fd | wc -l与/proc/PID/limits对比。 - 若
errno=ELOOP,namei -l会显示符号链接链;超过 40 层即报错。
2.1.16 本章陷阱清单
前面各节的 errata 分散在具体 API 旁。这里按「现象 → 原因 → 修复」汇总 12 条跨节陷阱,作为提交前的检查表。
- 现象:
if (!fd) { /* 当成失败 */ },偶尔把成功当失败。原因:fd 0 是合法描述符(stdin)。修复:判断fd == -1,不要用真假值。 - 现象:
errno打印出来是上一次的错误。原因:成功不清errno,且printf可能改写它。修复:失败后立刻保存int saved = errno。 - 现象:换到 ARM 机器后
getchar循环不结束。原因:char无符号,EOF(-1) 变成 255。修复:用int接返回值。 - 现象:
printf("%d", strlen(s))在 64 位下输出负数。原因:size_t是无符号 64 位,%d按 32 位有符号解释。修复:用%zu。 - 现象:
syscall(0, ...)在 ARM 上行为诡异。原因:写死了 x86-64 的调用号。修复:用SYS_read等宏。 - 现象:
_SC_OPEN_MAX与ulimit -n不一致。原因:可能查询的是另一个进程/另一 libc,或查询后软限制被改。修复:以getrlimit(RLIMIT_NOFILE)为准。 - 现象:
strerror_r编译报「返回类型不匹配」。原因:GNU 版与 XSI 版签名不同。修复:用strerror_l,或按_GNU_SOURCE分支。 - 现象:定义了
_POSIX_C_SOURCE后strdup/accept4找不到声明。原因:显式 POSIX 宏关闭了_DEFAULT_SOURCE。修复:加_DEFAULT_SOURCE/_GNU_SOURCE,或用编译命令统一。 - 现象:32 位程序
stat大文件返回EOVERFLOW。原因:off_t是 32 位。修复:-D_FILE_OFFSET_BITS=64,并用_Static_assert锁住。 - 现象:
access(path, W_OK)通过,open却EACCES。原因:access用 real UID,open用 effective UID;或检查与打开之间有 TOCTOU。修复:直接open并处理错误;需要按 effective 检查时用faccessat(..., AT_EACCESS)。 - 现象:容器里
openat对合法路径返回EPERM。原因:seccomp 过滤器返回EPERM,不是文件权限。修复:查docker inspect的SecurityOpt/seccomp,或strace确认调用被拦。 - 现象:子进程
exit()后日志重复。原因:fork复制了 stdio 缓冲区,父子各 flush 一次。修复:fork前fflush,子进程用_exit(详见 2.4)。
这 12 条的共同点是:错误不在语法,而在对「谁保证什么」的误解。ABI 保证调用号与寄存器,libc 保证 errno 翻译,内核保证 -errno 与 EFAULT;越过任何一层做假设,都会在换架构、换 libc、换内核时暴露。
2.1.17 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 画出系统调用路径 | 能标出 syscall 指令、入口、-errno 约定 | 2.1.1 |
说清 syscall 与 int 0x80 的差别 | 至少说出调用号与指针宽度两点 | 2.1.1 |
写出 errMsg 并解释为什么先存 errno | 代码能通过编译 | 2.1.3、2.1.4 |
解释 sysconf 返回 -1 的两种含义 | 说出「先清 errno」 | 2.1.6 |
说出 _GNU_SOURCE 打开了什么 | 至少举出 accept4/pipe2/signalfd | 2.1.5 |
用 /proc/self/status 拿到 RSS | 能指出单位是 kB | 2.1.7 |
| 说出 x86-64 与 ARM64 的调用号/参数寄存器差异 | 各说出两个寄存器 | 2.1.9 |
用 getrlimit 取本进程 fd 上限 | 不把 sysconf 当答案 | 2.1.12 |
写出 getopt_long 的 :/? 分支 | 能处理缺参数与未知选项 | 2.1.13 |
Q1 open("/etc/hosts", O_RDONLY) 成功后,errno 一定为 0 吗?为什么?
Q2 为什么 clock_gettime(CLOCK_MONOTONIC) 通常比 getpid() 快?这和「系统调用一定陷入内核」矛盾吗?
Q3 在 glibc 上,sysconf(_SC_OPEN_MAX) 返回的到底是什么?为什么不能把它当成「系统能打开的文件总数」?
Q4 写了 #define _POSIX_C_SOURCE 200809L 之后 accept4 报「implicit declaration」,但链接居然成功。为什么链接能成功?运行时可能出什么问题?
Q5 为什么 errExit 里要在 fprintf 之前保存 errno?给出一个不保存就会出错的场景。
答案
A1 不一定。POSIX 只要求失败时设置 errno,成功时不要求清零。所以成功返回后 errno 可能是上一次调用留下的值。判断成功只看返回值。
A2 因为 clock_gettime 对 CLOCK_REALTIME/CLOCK_MONOTONIC 走 vDSO:内核把读时钟的代码映射到用户空间,调用它不需要 syscall 指令、不需要特权级切换。这不矛盾——vDSO 是内核提供的、运行在用户态的代码,它读的是内核与用户共享的数据页。getpid 没有 vDSO 版本,每次都要进内核。
A3 glibc 的 sysconf(_SC_OPEN_MAX) 返回本进程 RLIMIT_NOFILE 的软限制(Kerrisk 第 11 章)。它不是系统文件总数上限:系统还受 fs.nr_open、fs.file-max 与其他进程占用约束,而且软限制可被 setrlimit 改动。正确做法是 getrlimit(RLIMIT_NOFILE, &rl) 取 rl.rlim_cur/rlim_max,或读 /proc/self/limits;setrlimit 可在硬限制内提高软限制。
A4 链接能成功是因为 glibc 的共享库里确实有 accept4 这个符号,链接器找得到;编译期只是没看到声明,按 C 的隐式声明规则把它当成「返回 int、参数未定」的函数。运行时在 x86-64 上通常「碰巧」能跑,但在参数包含指针或 64 位整数、或用了不同调用约定的架构上,参数会被错误解释,行为未定义。修法是加 -D_GNU_SOURCE。
A5 因为 fprintf/strerror 内部可能调用 malloc、write、locale 相关函数,这些都可能改写 errno。例如:open 失败留下 ENOENT,errExit 先调用 fprintf,fprintf 为了分配缓冲区失败而把 errno 改成 ENOMEM,随后 strerror(errno) 打印的就是「Cannot allocate memory」,真实原因丢失。保存后打印 saved 才是原始错误。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 1–3、11、12 章 | 2.1.1–2.1.16 | 完整(标准、ABI、errno、特性宏、限制、/proc、openat/read 内核路径);K14 的设备/文件系统见 0.10/3.12 |
| C | 第 8 章 8.3、第 10 章 | 2.1.1、2.1.2 | 对照(异常控制流、系统级 I/O 分层) |
| O | 第 6 章 直接执行 | 2.1.1 | 对照(模式切换与陷阱) |
| G | 第 5 章(文件权限)、第 16 章(程序管理) | 2.1.8 | 背景(ulimit、ps 等命令行证据) |
| N | 第 3 章(内核与用户进程协作) | 2.1.1 | 背景(阻塞、系统调用与进程协作) |
下一站:2.2 文件 I/O 把这套规则用到最基础的五个函数上;如果你已经熟悉
open/read,可以直接跳到 2.3 高级文件 I/O,那里开始处理原子性、mmap与文件锁。