glibc syscall 包装剖析

本页目录

在 strace 中看到 openat,并不意味着源代码一定调用了 openat;调用 clock_gettime,也不保证追踪中出现同名系统调用。libc 提供的是公共接口语义,内部可以选择其他系统调用、用户态快路径,或组合多个步骤。

理解这层边界有两个用途:读反汇编时知道在哪里寻找参数与错误转换;做优化时确认自己是否保留了原 API 的语义。

一次库函数调用,不一定对应一次内核调用 应用调用公共 API 例如 read、open、clock_gettime 1 libc 实现接口语义 取消点、兼容处理、参数与结构布局转换 2 选择实现路径 可用的 vDSO 路径,或真正进入内核 3 转换结果交还应用 按该公共接口约定返回结果与错误 4 公共 syscall() 也会转换 errno;只有原始汇编层直接看到内核的 -errno。

三层接口:同一个名字可能指不同东西

层次示例谁处理 ABI 与错误
公共库函数read(fd, buf, n)libc 实现该函数的完整约定
通用系统调用函数syscall(SYS_read, fd, buf, n)libc 安排寄存器并转换错误;不等同于 read 的全部语义
原始指令封装内联汇编 syscall / svc调用者按架构 ABI 传参并解释原始结果

在 x86-64 与 AArch64 Linux 原始 ABI 中,错误编码为 -errno。公共 syscall() 已经把错误转换为 -1,并写入 errno。因此看到 syscall(...) == -1 后,应读取 errno,不能把 -1 再取反当成错误号。syscall(2)

errno 是线程局部状态,成功返回不要求把它清零。只在接口返回值表明失败时解释它,并在后续可能覆盖 errno 的调用前保存。也不是所有 C 接口都通过 errno 报错,例如很多 pthread 函数直接返回错误号。

用一个失败调用看见转换

#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>

int main(void)
{
    long result = syscall(SYS_write, -1, "x", (size_t)1);
    int saved = errno;
    printf("result=%ld, errno=%d, EBADF=%d\n", result, saved, EBADF);
    return !(result == -1 && saved == EBADF);
}
cc -std=c11 -Wall -Wextra -Werror wrapper.c -o wrapper
./wrapper
strace -e trace=write ./wrapper

无效文件描述符让内核返回 EBADF。应用观察到的是 -1 与 errno=EBADF;strace 会把原始错误解码成人类可读形式。printf 自己也可能产生 write,分析追踪时要把它与 fd=-1 的目标调用区分。

若改用 x86-64 篇的 raw_write,返回值才会直接是 -EBADF。不要在一段算法中混用这两种错误约定。

错误号要和产生它的返回值一起保存

接口约定判断方式容易误读的情况
read / write / syscall返回 -1 后读取 errno成功时 errno 残留旧错误
pthread_create 等检查函数直接返回的错误号返回非零却打印了无关 errno
getauxval 等零值有歧义的接口按文档先清 errno,再结合结果仅凭零值误判不存在
原始 syscall 汇编判断架构错误区间把未转换的 -errno 当成普通 -1

例如第一次 write 失败,随后为了记录上下文又打开日志文件,这次 open 也可能改变 errno。应紧接失败保存 saved_errno,再用保存值格式化报告。设置 errno=0 并不是所有调用前的固定仪式;只有接口约定需要消除旧值歧义时才这样做。

成功之后,旧 errno 去了哪里?

先失败再成功,能同时看到返回值已经变为 3,而 errno 仍可能为 9。它不是一盏由内核自动复位的全局错误灯。需要报告失败时,应立即保存这次返回值对应的 errno,避免后续调用覆盖。

正在呈现知识画面
成功之后,旧 errno 去了哪里?

glibc syscall() 将原始 -errno 转为 -1 并设置 errno;成功不要求清零 errno。错误解释必须与同一次返回值配对。

源码里的宏解决了什么

glibc 的架构层安排调用号、输入输出寄存器与 clobber;内部调用层保留原始结果,公共封装层执行 errno 转换。具体宏名与展开方式随版本、架构和参数数量变化,不能把某份旧版宏展开当作 glibc 的永久接口。

阅读 inline asm 时重点检查:输出寄存器是否与输入调用号正确关联,第四个参数是否放到目标 ABI 要求的寄存器,硬件破坏的寄存器是否列入 clobber,以及内存可见性是否被准确描述。

asm volatile 表示该汇编具有不能随意删除等副作用;它本身不是内存屏障。"memory" clobber 告诉编译器汇编可能访问未列出的内存,限制相应编译器重排,也不自动生成 CPU 内存屏障。这些约束与 C/C++ 原子内存序是不同层的问题。GCC Extended Asm

线程取消不是“在慢调用前检查一下”

read、write 等接口具有取消点语义。glibc 的 read 实现经过 SYSCALL_CANCEL;实现需要处理取消请求与进入、阻塞及离开系统调用之间的竞争窗口。仅在调用前检查一次标志,可能在检查后收到取消请求并永久阻塞。glibc read 实现

直接改成 syscall(SYS_read, ...) 或 raw 汇编不能视为等价替换。它可能绕过 libc 的取消机制,进而改变线程停止与资源清理的行为。反过来,使用公共 read 也不意味着线程在任意位置都能被安全异步取消;应按取消类型与清理处理器的约定设计资源生命周期。

取消请求可以先挂起,再在允许的取消点被处理 工作线程:禁止取消 注册清理,准备就绪 主线程:请求取消 请求挂起,不等于已退出 工作线程:恢复取消 执行公共 read 取消点 清理处理器关闭读端;pthread_join 观察 PTHREAD_CANCELED 主线程随后关闭自己拥有的写端和销毁同步对象 本例保持默认延迟取消;两个同步关口用于确定事件顺序,不靠 sleep 猜测。

用确定的先后关系观察取消与清理

下面在 glibc/Linux 上建立管道,工作线程最终准备调用 read;主线程持有写端但不写入,所以没有数据,也不会提前出现 EOF。两个 barrier 保证先注册清理,再发出取消请求,最后恢复取消。默认延迟取消下,挂起请求在公共 read 的取消点被处理。取消状态

保存为 cancel-read.c:

#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static pthread_barrier_t ready, requested;
static int ends[2];
static int cleaned;
static void require(int code) { if (code != 0) abort(); }
static void meet(pthread_barrier_t *barrier) {
    int code = pthread_barrier_wait(barrier);
    if (code != 0 && code != PTHREAD_BARRIER_SERIAL_THREAD) abort();
}
static void cleanup(void *unused) {
    (void)unused;
    if (close(ends[0]) != 0) abort();
    cleaned = 1;
}
static void *worker(void *unused) {
    (void)unused;
    require(pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL));
    pthread_cleanup_push(cleanup, NULL);
    meet(&ready);
    meet(&requested);
    require(pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL));
    char byte;
    (void)read(ends[0], &byte, 1);
    pthread_cleanup_pop(1);
    return NULL;
}
int main(void) {
    if (pipe(ends) != 0) return 1;
    require(pthread_barrier_init(&ready, NULL, 2));
    require(pthread_barrier_init(&requested, NULL, 2));
    pthread_t thread;
    require(pthread_create(&thread, NULL, worker, NULL));
    meet(&ready);
    require(pthread_cancel(thread));
    meet(&requested);
    void *result;
    require(pthread_join(thread, &result));
    if (close(ends[1]) != 0) return 1;
    require(pthread_barrier_destroy(&ready));
    require(pthread_barrier_destroy(&requested));
    printf("canceled=%d cleaned=%d\n", result == PTHREAD_CANCELED, cleaned);
    return !(result == PTHREAD_CANCELED && cleaned == 1);
}
cc -std=c11 -Wall -Wextra -pthread cancel-read.c -o cancel-read
./cancel-read

预期 canceled=1 cleaned=1。cleaned 由工作线程写、主线程在成功 join 后读,这个读取时点保证线程已经结束;不能把 join 去掉,然后让主线程无同步地轮询普通 int。

pthread_cleanup_push/pop 在 glibc 中是需要成对使用的宏,作用域必须匹配。本例清理处理器拥有读端,主线程拥有写端;关闭责任分开后,取消路径与正常路径都不会重复关闭同一个描述符。为突出协议,线程基础设施错误用 abort 结束示例;生产代码还需安排创建失败时的资源回收。清理处理器

不要直接把本例 read 替换成原始系统调用来做“更快”版本:那会改变取消点语义,挂起请求可能不能按本例协议终止等待。若选择自行管理停止,应为等待提供明确的唤醒通道并重新设计清理协议,而不是只在循环外添加一个布尔值。

vDSO:保持 API,替换实现路径

内核向进程映射 vDSO,libc 可以解析其中具有版本的符号。对支持的时钟,clock_gettime 路径能够读取内核共享的计时信息并在用户态计算结果;遇到不支持的环境或请求时,路径可能进入内核。

这不是按 clock_id 数值大小做一个通用白名单。不同架构、time64 兼容需求、vDSO 符号及其返回错误,都会影响实现。也不能无条件忽略 vDSO 错误后再次调用:具体错误传播和回退必须符合接口。glibc clock_gettime、vdso(7)

下面在原生 64 位 Linux 上对比两条路径:

#define _GNU_SOURCE
#include <stdio.h>
#include <sys/syscall.h>
#include <time.h>
#include <unistd.h>

int main(void)
{
    struct timespec a, b;
    if (clock_gettime(CLOCK_MONOTONIC, &a) == -1) {
        perror("clock_gettime");
        return 1;
    }
    if (syscall(SYS_clock_gettime, CLOCK_MONOTONIC, &b) == -1) {
        perror("syscall clock_gettime");
        return 1;
    }
    printf("libc=%lld.%09ld, syscall=%lld.%09ld\n",
           (long long)a.tv_sec, a.tv_nsec,
           (long long)b.tv_sec, b.tv_nsec);
    return 0;
}

编译后运行 strace -e trace=clock_gettime ./clock-paths。第二次调用明确进入内核,第一次在可用的 vDSO 路径上可能不产生这条 syscall 记录。两次调用读取同一个单调时钟,后一次不应早于前一次,但时钟分辨率允许两次结果相等;调度也可能使间隔变大,不能用一个很小的固定差值作为必过条件。没有 syscall 记录不代表函数没有执行。clock_gettime(3)

包装还包含历史兼容

glibc 可以用 openat 实现 open,32 位平台上的时间接口可能需要 time64 兼容分支。源代码函数名、动态符号名与内核 syscall 名不必一一对应;seccomp 策略和追踪过滤条件应根据目标环境的实际调用路径确定。open(2)

glibc 在 2.25 移除了 getpid 的 PID 缓存,不能继续用“当前 getpid 总是从缓存直接返回”解释基准结果。getpid(2)

musl 同样需要架构参数约定、线程局部 errno、取消点和适用的 vDSO 路径。比较两种 libc 时,应选定版本、目标架构和具体函数,检查构建产物与行为;用“每个 syscall 五行对五十行”推导性能没有可靠意义。

怎样衡量封装成本

先明确测量对象是原始进入/退出、公共 API 调用,还是包含调度和设备等待的端到端操作。记录内核、libc、CPU、编译选项及虚拟化环境,并把批量循环开销与目标调用区分。

strace 适合确认路径和错误,追踪本身会显著扰动延迟,不适合直接代表未追踪程序的微基准。对时钟 API 应确认是否使用 vDSO;对 futex 必须说明等待值、是否真正睡眠及唤醒方,不能把“无争用 FUTEX_WAIT”当作一个固定耗时操作。

只有在测量显示封装成本重要、并能明确承担取消与兼容语义时,才考虑绕过公共接口。减少无必要的系统调用、合并 I/O,通常比复制一份架构相关汇编更值得先检查。