Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.tblsyscall_32.tbl,编号不同
参数传递rdi/rsi/rdx/r10/r8/r9ebx/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格式化到用户态缓冲区,满/换行才 writelibc(C 标准)
write一次系统调用,数据进 page cache内核(POSIX)
malloc在堆上切一块,必要时 brk/mmaplibc
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)时间花在等设备或等子进程上,不在这里。

关于系统调用的四个误解
  1. 「printf 是系统调用」——不是。它最终可能调用 write,但格式化、缓冲都在用户态。strace 里看到的是 write,不是 printf。
  2. 「系统调用一定切换进程」——不一定。同一进程从用户态陷入内核态再返回,叫模式切换(mode switch),不是上下文切换(context switch)。没有调度到别的进程时,开销只是保存/恢复寄存器。
  3. 「系统调用号可以写死」——系统调用号是架构相关的 ABI。SYS_read 在 x86-64 是 0,在 ARM64 是 63。写死数字的代码换个架构就调错函数。

2.1.3 返回值与 errno 的七条规则

C 没有异常。系统调用和大多数 libc 函数用「特殊返回值 + errno」报告失败。这套机制简单,但几乎每个初学者都会踩同样的坑。

定义 2.1.1 · `errno` 与失败约定

errno 是一个线程局部的 int 左值(glibc 里展开为 (*__errno_location()))。函数失败时把它设为一个 E* 常量;成功时不保证清零。因此:

  • 判断失败看返回值,不看 errno 是否非零;
  • 只有在返回值表明失败后,读 errno 才有意义;
  • 需要在后续调用之后继续用这个错误码时,先存进局部变量。

七条规则,按重要性排序:

  1. 先看返回值,再看 errno。errno == 0 不代表成功;errno != 0 也不代表这次失败(可能是上一次留下的)。
  2. 成功不清 errno。这是 POSIX 明确允许的。要复用 errno 做判断,先手动 errno = 0。
  3. errno 是线程局部的。两个线程各自失败,互不覆盖。不要把它当进程级状态传递。
  4. 保存后再做别的事。fprintf、strerror、free 都可能改 errno;错误处理函数入口第一件事是 int saved = errno;。
  5. 不是所有函数都设 errno。C 标准库的 strtol 用 errno,但 snprintf 截断不设;pthread_* 系列直接返回错误码,不碰 errno。
  6. EINTR 是「被打断」,不是「失败」。可重试的调用要区分它(2.1.4 给封装)。
  7. 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 就认为出错」的代码在这里都会误判。

例 2.1.1 · 一个看起来正确、实际会丢错误的函数
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);
}

三个设计点:

  1. vErr 先保存 errno,函数返回前还原。这样调用者 errMsg(...); return -1; 时,errno 还是原始错误。
  2. vsnprintf 而不是 snprintf:变参函数必须用 va_list 版本。缓冲区固定 1024,超长截断——错误信息不需要无限长。
  3. errExitEN 接受显式错误号:用于 pthread_* 这类「返回错误码、不设 errno」的接口。
选择错误处理方式的顺序
  1. 这个错误调用者能恢复吗(EAGAIN、EINTR、ENOENT)?能 → 返回 -1 并保留 errno。
  2. 不能恢复,但进程还有别的事要做(一个连接坏了)?→ 记录日志,关闭该连接,继续。
  3. 不能恢复且继续运行会出错(配置解析失败、特权丢失)?→ errExit。
  4. 在信号处理器里?→ 只能用 write + 预格式化字符串,不能用 fprintf/strerror(见 2.10)。

2.1.5 特性测试宏:为什么 -D_GNU_SOURCE 是必需的

同一个头文件,在不同宏定义下暴露不同的声明。这不是发行版的怪癖,而是 POSIX 的设计:头文件必须能同时服务「严格标准模式」和「实现扩展模式」。

定义 2.1.2 · 特性测试宏(feature test macro)

特性测试宏是在包含任何系统头文件之前定义的预处理宏,用来告诉 libc「我要求哪一版标准、允许哪些扩展」。它控制的是声明是否可见,不改变运行时行为。定义晚了(在 #include <stdio.h> 之后)等于没定义。

常用组合:

宏含义典型用途
_POSIX_C_SOURCE=200809L只要 POSIX.1-2008可移植的库
_XOPEN_SOURCE=700POSIX + XSI,700 对应 SUSv4需要 openpty 等 XSI 接口
_DEFAULT_SOURCEglibc 默认扩展(MSG_NOSIGNAL、strdup 等)不写宏时的默认状态
_GNU_SOURCE打开全部:POSIX + BSD + GNULinux 上的系统程序

关键细节:一旦你显式定义了 _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。

特性测试宏的四个坑
  1. 「在 .c 文件里 #define _GNU_SOURCE 就行」——必须在这之前没有任何 #include。最稳妥是编译命令 -D_GNU_SOURCE,而不是源文件里写。
  2. 「定义了 _GNU_SOURCE 还能再定义 _POSIX_C_SOURCE 来限制」——不能。_GNU_SOURCE 会强制打开所有扩展,后定义的 _POSIX_C_SOURCE 不会收回。
  3. 「头文件里定义宏」——头文件被包含时可能已经有系统头文件进去了,顺序不可控。特性测试宏只能出现在翻译单元的最前面或编译命令行。
  4. 「-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 */
定义 2.1.3 · `sysconf`/`pathconf` 的返回值语义

三者都返回 long,且都有三种结果:

  • >= 0:限制值;
  • -1 且 errno == 0:该限制不确定(indeterminate),没有固定上限;
  • -1 且 errno != 0:查询本身失败(EINVAL 表示名字不合法)。

因为 -1 有二义性,调用前必须 errno = 0,返回后再查 errno。这是本章最容易写错的一个 API。

常用名字:

名字含义备注
_SC_PAGESIZE内存页大小通常 4096,ARM64 可为 16384
_SC_OPEN_MAXglibc 返回 RLIMIT_NOFILE 软限制不是系统 fd 总数上限
_SC_CLK_TCKtimes() 的时钟频率/proc/PID/stat 的时间单位
_SC_NPROCESSORS_ONLN在线 CPU 数Linux 扩展(_GNU_SOURCE)
_SC_ARG_MAXexec 参数+环境总长实际还受栈大小限制
_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` 是可写的,但不是文件

向 /proc/sys/vm/dirty_ratio 写 echo 20 > ... 会调用内核的 sysctl 处理函数,不是写磁盘。同理,读 /proc/self/mem 能读写自己的地址空间(调试器就是这么做的),但偏移必须是有效映射,否则 EIO。把 /proc 当普通文件 mmap 或 seek 到任意位置,会得到看似莫名其妙的结果。

2.1.8 可移植性检查清单

「我的机器上能跑」不是可移植性。下面这份清单按「改起来最便宜」排序:

提交前的可移植性检查
  1. 特性测试宏在命令行:-D_GNU_SOURCE 或 -D_POSIX_C_SOURCE=200809L,不在源文件中间 #define。
  2. 不用裸数字:系统调用号用 SYS_*,信号用 SIG*,权限用 S_IRUSR 这类宏,errno 用 E*。
  3. 不用 int 存 size_t/off_t/pid_t:用 ssize_t、off_t、pid_t;打印用 %zd、%jd(配 intmax_t)或 PRIdMAX。
  4. 限制运行时查:sysconf/pathconf/getrlimit,不抄 OPEN_MAX、NAME_MAX。
  5. 对齐与字节序:跨网络/跨文件格式用 htonl/ntohl;结构体别直接 write 到文件。
  6. 路径拼接:用 snprintf + 检查返回值,不 strcat;不假设 /tmp 可写、/bin/sh 存在。
  7. 错误码按名字判断:errno == EAGAIN,不写 errno == 11。
  8. long 不等于指针:Windows LLP64 下 long 是 32 位;用 intptr_t/uintptr_t。
  9. char 的符号性:ARM 上 char 默认无符号,char c = getchar(); if (c == EOF) 会永远不成立。用 int 接 getchar。
  10. 编译两次:-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-64syscallraxrdi、rsi、rdx、r10、r8、r9rax
i386int 0x80eaxebx、ecx、edx、esi、edi、ebpeax
ARM64svc #0x8x0–x5x0
RISC-V 64ecalla7a0–a5a0

x86-64 用 r10 而不是 rcx 传第四个参数,因为 syscall 指令本身会用 rcx 保存返回地址。这个细节无法从 C 源码看出来,但决定了手写汇编时必须用 r10。

定义 2.1.4 · 系统调用 ABI

系统调用 ABI 是「用户态与内核态之间传递调用号、参数、返回值」的二进制约定。它包括:陷入指令、调用号所在寄存器、参数寄存器顺序、返回值寄存器、错误表示法(内核用 -errno)、以及哪些寄存器由内核保证保留。ABI 一旦发布就不能改,否则所有已编译的程序都会坏掉。

为什么参数最多六个

六个参数不是内核随便定的,而是寄存器数量与返回地址占用的结果:x86-64 的调用约定里,能自由用于传参的寄存器刚好六个;再多就要用栈,而 syscall 指令切换栈的过程由内核控制,用户栈指针不能直接当参数传。因此超过六个参数的系统调用一律改成「传一个指向结构体的指针」:

调用参数形态原因
mmap六个标量刚好用满
clone3struct clone_args *参数持续增加,用结构体带 size 做版本演进
openat2struct open_how *同上,size 字段允许未来扩展
io_uring_setupstruct io_uring_params *输入输出共用一块结构
bpfunion 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。

关于系统调用入口的四个误解
  1. 「syscall() 是直接执行 syscall 指令」——glibc 的 syscall() 是一段 C/汇编包装,负责按 ABI 排寄存器并做 errno 翻译;某些架构上它还会走 vDSO 或做指针检查。
  2. 「内核返回 -1 表示错误」——内核返回的是 -errno(如 -2 表示 ENOENT);只有 libc 包装才把它变成 -1 加 errno。看 strace 的 = -1 ENOENT 是 libc 视角。
  3. 「系统调用号可以跨架构复用」——SYS_read 在 x86-64 是 0,在 ARM64 是 63。写死数字的代码换架构就调错函数,且编译器不会报错。
  4. 「被 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)。这带来两个可观察后果:

  1. 32 位程序读大文件的 stat 可能返回 EOVERFLOW,因为 st_size 放不下;用 stat64 或定义 _FILE_OFFSET_BITS=64 解决。
  2. 直接 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 / ENOATTRLinux 只有 ENODATAmacOS 用 ENOATTR,跨平台代码要 #ifdef
ENOTSUP / ENOSYS不相等前者「这个对象不支持」,后者「内核没这个调用」

判断时写名字,不写数字;但不要假设两个名字一定不等。例如 if (errno == ENOTSUP || errno == EOPNOTSUPP) 在 Linux 上是重复条件,编译器可能警告 -Wlogical-op(GCC),写成 errno == EOPNOTSUPP 就够,跨平台再包一层宏。

错误码到对外语义的映射

服务端不能把 errno 直接返回给客户端,但也不该一律 500。一张最小映射表:

errnoHTTP是否重试备注
ENOENT404否资源不存在
EACCES / EPERM403否权限或 LSM 拒绝
EINVAL400否参数非法,常是调用方 bug
ENOSPC / EDQUOT507否磁盘/配额满
EMFILE / ENFILE503是(退避)本进程/系统 fd 耗尽
EAGAIN / EWOULDBLOCK503 + Retry-After是暂时不可用
ETIMEDOUT504视语义上游超时
EINTR—是不应到达协议层,循环内重试
EIO500否设备/文件系统错误,需告警

进程退出码则用 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 更稳。

`errno` 转换的五个坑
  1. 「strerror_r 返回 0 就是成功」——只有 XSI 版如此;GNU 版返回指针,永远非 0。
  2. 「strerror 返回的字符串可以长期保存」——它可能指向静态缓冲区,下一次调用会被覆盖;多线程下更不安全。
  3. 「错误码是负数」——用户态 errno 是正的;负数只出现在内核返回值里。
  4. 「errno 会被 printf 保留」——printf 内部可能调用 malloc/write,失败时改写 errno;打印错误前先保存。
  5. 「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=200809LPOSIX.1-2008posix_memalign、clock_gettime、sigaction
_XOPEN_SOURCE=700POSIX.1-2008 + XSIopenpty、log2、crypt
_DEFAULT_SOURCEPOSIX + BSD + SVID 默认集strdup、strcasecmp、MSG_NOSIGNAL
_GNU_SOURCE上面全部 + GNU 扩展accept4、pipe2、memfd_create、%m
_ISOC11_SOURCEC11 库扩展aligned_alloc
_FILE_OFFSET_BITS=64大文件 ABIoff_t 变 64 位,stat 重定向到 stat64
_TIME_BITS=6464 位 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 全开。

特性宏与 ABI 的五个坑
  1. 「-std=gnu17 等于 _GNU_SOURCE」——不等。gnu17 只关掉 __STRICT_ANSI__,让 GCC 扩展可用;glibc 的 _GNU_SOURCE 仍要显式定义。
  2. 「_FILE_OFFSET_BITS=64 只是宏,不改 ABI」——它会把 stat/fstat/lseek/mmap 的声明重定向到 64 位版本,struct stat 布局随之改变;混用不同宏编译的目标文件会破坏 ABI。
  3. 「头文件里可以定义 _GNU_SOURCE」——头文件可能在被包含前已经引入了系统头,顺序不可控;只放在源文件第一行或命令行。
  4. 「_TIME_BITS=64 可以单独用」——glibc 要求同时定义 _FILE_OFFSET_BITS=64,否则编译报错。
  5. 「__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-maxnr_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在线 CPU8容器里可能被 cgroup 限制
_SC_CLK_TCKtimes() 频率100/proc/PID/stat 的 jiffies
_SC_ARG_MAX参数+环境上限2097152还受栈 RLIMIT_STACK 限制
_SC_IOV_MAXreadv 段数上限1024不是 IOV_MAX 宏
_SC_SEM_VALUE_MAX信号量最大值2147483647
_SC_SIGQUEUE_MAX排队信号数可变受 RLIMIT_SIGPENDING
_SC_ATEXIT_MAXatexit 注册数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。

限制排查顺序
  1. 先看进程:cat /proc/PID/limits,确认 soft/hard 与 Max open files。
  2. 再看内核:sysctl fs.nr_open fs.file-max、/proc/sys/fs/pipe-max-size。
  3. 再看文件系统:stat -f /path、fpathconf 的 NAME_MAX/PIPE_BUF。
  4. 最后看容器/cgroup:systemd 的 LimitNOFILE=、Kubernetes 的 ulimits、/sys/fs/cgroup/.../pids.max。
  5. 不要用 _SC_OPEN_MAX 推断系统总量;glibc 上它等于本进程软限制,其他 libc 可能不同。

RLIMIT_* 全表:超限时到底发生什么

getrlimit/setrlimit 不只用于 fd。每个资源超限的后果不同:有的返回 errno,有的发信号,有的直接 SIGKILL。

资源限制什么超限后果
RLIMIT_CPUCPU 秒数(软/硬)软限发 SIGXCPU;硬限发 SIGKILL
RLIMIT_FSIZE单个文件最大字节数写超限发 SIGXFSZ;忽略信号则 write 返回 EFBIG
RLIMIT_COREcore 文件最大字节数超过则截断 core;0 表示不写
RLIMIT_DATAbrk/数据段大小brk/sbrk 返回 ENOMEM;mmap 是否计入取决于内核
RLIMIT_STACK主线程栈大小栈溢出 SIGSEGV;pthread 栈不受它限制
RLIMIT_NOFILEfd 数量open/socket 返回 EMFILE
RLIMIT_NPROC每 real UID 的进程/线程数fork/clone 返回 EAGAIN
RLIMIT_MEMLOCKmlock/mmap(MAP_LOCKED) 的字节数mlock 返回 ENOMEM/EPERM
RLIMIT_AS虚拟地址空间总字节数mmap/brk 返回 ENOMEM;不是 RSS 上限
RLIMIT_SIGPENDING每 real UID 排队信号数sigqueue 返回 EAGAIN
RLIMIT_MSGQUEUEPOSIX mq 字节数mq_open/mq_send 返回 EMFILE/EAGAIN
RLIMIT_NICEnice 可调范围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 上行为会变,所以选项应写在操作数前面,不要依赖重排。

`getopt` 的五个坑
  1. 「optarg 可以长期保存」——它指向 argv 内部,生命周期与 argv 相同,通常没问题;但不要 free 它,也不要假设下一次解析后还指向同一个字符串。
  2. 「optind 初始一定是 1」——库函数或多次解析后不是;重新解析前设 optind = 0(GNU)或保存/恢复。
  3. 「getopt 是线程安全的」——它用全局状态(optind/optarg/optopt),多线程同时解析会互相踩。
  4. 「长选项缩写总是可用」——只有唯一前缀才行;新增一个同前缀选项就会让旧脚本报 ambiguous。
  5. 「-n 3 和 -n3 行为一样」——对必需参数是的;对 optional_argument 长选项,--color always 不会把 always 当参数,必须写 --color=always。

2.1.14 内核路径:openat 与 read 落到文件系统

这一节回答:从 openat 到 ext4 真正读磁盘,中间经过哪些函数?出问题时该在哪个层次加观测?

2.1.1 给了入口与分派的轮廓。这里把两条最常用的路径补到函数级(Linux 6.8)。

openat 的路径

层次位置关键动作
libcsysdeps/unix/sysv/linux/openat.c排寄存器、syscall
入口arch/x86/entry/entry_64.Sentry_SYSCALL_64 保存寄存器
分派arch/x86/entry/common.cdo_syscall_64 → sys_call_table[257]
系统调用fs/open.cdo_sys_openat2:build_open_flags、getname、get_unused_fd_flags
路径解析fs/namei.cdo_filp_open → path_openat → link_path_walk
打开fs/open.cvfs_open → do_dentry_open → file->f_op->open
文件系统fs/ext4/file.cext4_file_open(或 generic_file_open)
返回fs/file.cfd_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 的路径

层次位置关键动作
libcsysdeps/unix/sysv/linux/read.csyscall(SYS_read, ...)
系统调用fs/read_write.cksys_read → vfs_read
检查fs/read_write.cFMODE_READ、access_ok、rw_verify_area、MAX_RW_COUNT 截断
文件系统include/linux/fs.hfile->f_op->read_iter
ext4fs/ext4/file.cext4_file_read_iter → generic_file_read_iter
页缓存mm/filemap.cfilemap_read → filemap_get_pages → page_cache_sync_readahead
缺页/读盘mm/filemap.cfilemap_read_folio → address_space->a_ops->read_folio
块层fs/ext4/inode.cext4_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);
}

由此可以解释三个现象:

  1. read 的 count 超过约 2 GiB 会被截断。MAX_RW_COUNT 是 INT_MAX & PAGE_MASK(约 2 GiB),一次 read 不可能读更多。
  2. read 对只写 fd 返回 EBADF,而不是 EINVAL;对不支持读的 fd(如某些设备)返回 EINVAL。
  3. 坏缓冲区返回 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` 看不到 `vfs_read`

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 能快速确认「它到底卡在哪个系统调用」。

用证据定位「打开失败」
  1. strace -f -yy -e trace=%file ./app 看最后一个失败的 openat 与 errno。
  2. 若 errno=ENOENT 但路径明明存在,检查是否 chroot/mount namespace 不同:ls -l /proc/PID/root/path。
  3. 若 errno=EACCES,用 namei -l path 逐级看目录 x 位;再查 LSM:dmesg | grep -i apparmor。
  4. 若 errno=EMFILE,ls /proc/PID/fd | wc -l 与 /proc/PID/limits 对比。
  5. 若 errno=ELOOP,namei -l 会显示符号链接链;超过 40 层即报错。

2.1.16 本章陷阱清单

前面各节的 errata 分散在具体 API 旁。这里按「现象 → 原因 → 修复」汇总 12 条跨节陷阱,作为提交前的检查表。

系统编程基础的十二个坑
  1. 现象:if (!fd) { /* 当成失败 */ },偶尔把成功当失败。原因:fd 0 是合法描述符(stdin)。修复:判断 fd == -1,不要用真假值。
  2. 现象:errno 打印出来是上一次的错误。原因:成功不清 errno,且 printf 可能改写它。修复:失败后立刻保存 int saved = errno。
  3. 现象:换到 ARM 机器后 getchar 循环不结束。原因:char 无符号,EOF(-1) 变成 255。修复:用 int 接返回值。
  4. 现象:printf("%d", strlen(s)) 在 64 位下输出负数。原因:size_t 是无符号 64 位,%d 按 32 位有符号解释。修复:用 %zu。
  5. 现象:syscall(0, ...) 在 ARM 上行为诡异。原因:写死了 x86-64 的调用号。修复:用 SYS_read 等宏。
  6. 现象:_SC_OPEN_MAX 与 ulimit -n 不一致。原因:可能查询的是另一个进程/另一 libc,或查询后软限制被改。修复:以 getrlimit(RLIMIT_NOFILE) 为准。
  7. 现象:strerror_r 编译报「返回类型不匹配」。原因:GNU 版与 XSI 版签名不同。修复:用 strerror_l,或按 _GNU_SOURCE 分支。
  8. 现象:定义了 _POSIX_C_SOURCE 后 strdup/accept4 找不到声明。原因:显式 POSIX 宏关闭了 _DEFAULT_SOURCE。修复:加 _DEFAULT_SOURCE/_GNU_SOURCE,或用编译命令统一。
  9. 现象:32 位程序 stat 大文件返回 EOVERFLOW。原因:off_t 是 32 位。修复:-D_FILE_OFFSET_BITS=64,并用 _Static_assert 锁住。
  10. 现象:access(path, W_OK) 通过,open 却 EACCES。原因:access 用 real UID,open 用 effective UID;或检查与打开之间有 TOCTOU。修复:直接 open 并处理错误;需要按 effective 检查时用 faccessat(..., AT_EACCESS)。
  11. 现象:容器里 openat 对合法路径返回 EPERM。原因:seccomp 过滤器返回 EPERM,不是文件权限。修复:查 docker inspect 的 SecurityOpt/seccomp,或 strace 确认调用被拦。
  12. 现象:子进程 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/signalfd2.1.5
用 /proc/self/status 拿到 RSS能指出单位是 kB2.1.7
说出 x86-64 与 ARM64 的调用号/参数寄存器差异各说出两个寄存器2.1.9
用 getrlimit 取本进程 fd 上限不把 sysconf 当答案2.1.12
写出 getopt_long 的 :/? 分支能处理缺参数与未知选项2.1.13
即时自测 · 2.1

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 与文件锁。