您如何从 Assembly 调用 C 函数以及如何 link 静态地调用它?

How do you call C functions from Assembly and how do you link it Statically?

我正在玩弄并试图了解计算机和程序的低级操作。为此,我正在尝试将 Assembly 和 C 链接起来。

我有 2 个程序文件:

"callee.c" 中的一些 C 代码:

#include <unistd.h>

void my_c_func() {
  write(1, "Hello, World!\n", 14);
  return;
}

我也有一些 GAS x86_64 在这里组装 "caller.asm":

.text

.globl my_entry_pt

my_entry_pt:
  # call my c function
  call my_c_func # this function has no parameters and no return data

  # make the 'exit' system call
  mov , %rax # set the syscall to the index of 'exit' (60)
  mov [=12=], %rdi # set the single parameter, the exit code to 0 for normal exit
  syscall

我可以这样构建和执行程序:

$ as ./caller.asm -o ./caller.obj
$ gcc -c ./callee.c -o ./callee.obj
$ ld -e my_entry_pt -lc ./callee.obj ./caller.obj -o ./prog.out -dynamic-linker /lib64/ld-linux-x86-64.so.2
$ ldd ./prog.out
    linux-vdso.so.1 (0x00007fffdb8fe000)
    libc.so.6 => /lib64/libc.so.6 (0x00007f46c7756000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f46c7942000)
$ ./prog.out
Hello, World!

一路走来,我遇到了一些问题。如果我不设置 -dynamic-linker 选项,它默认为:

$ ld -e my_entry_pt -lc ./callee.obj ./caller.obj -o ./prog.out
$ ldd ./prog.out
    linux-vdso.so.1 (0x00007ffc771c5000)
    libc.so.6 => /lib64/libc.so.6 (0x00007f8f2abe2000)
    /lib/ld64.so.1 => /lib64/ld-linux-x86-64.so.2 (0x00007f8f2adce000)
$ ./prog.out
bash: ./prog.out: No such file or directory

这是为什么?我系统上的链接器默认设置有问题吗? can/should 我如何修复它?

此外,静态链接不起作用。

$ ld -static -e my_entry_pt -lc ./callee.obj ./caller.obj -o ./prog.out
ld: ./callee.obj: in function `my_c_func':
callee.c:(.text+0x16): undefined reference to `write'

这是为什么? write() 不应该只是系统调用 'write' 的 c 库包装器吗?我该如何解决?

我在哪里可以找到有关 C 函数调用约定的文档,以便我可以阅读有关如何来回传递参数等内容...?

最后,虽然这似乎适用于这个简单的示例,但我在初始化 C 堆栈时是否做错了什么?我的意思是,现在,我什么都不做。在开始尝试调用函数之前,我是否应该从内核为堆栈分配内存、设置边界以及设置 %rsp 和 %rbp。还是内核加载程序为我处理了所有这些?如果是这样,Linux 内核下的所有体系结构都会为我处理它吗?

虽然 Linux 内核提供了一个名为 write 的系统调用,但这并不意味着您会自动获得一个可以从 C 调用的与 write() 同名的包装函数。事实上,如果你不使用 libc,你需要内联汇编来从 C 调用任何系统调用,因为 libc 定义了那些包装函数。

不要用 ld 显式 link 编译你的二进制文件,让 gcc 为你做。如果源代码以 .s 后缀结尾,它甚至可以 assemble 汇编文件(在内部执行 as 的合适版本)。看来您的 linking 问题只是 GCC 假设与您自己通过 LD 实现的方式之间存在分歧。

不,这不是错误; ld.sold 默认路径不是现代 x86-64 GNU/Linux 系统上使用的路径。 (/lib/ld64.so.1 可能已经在早期的 x86-64 GNU/Linux 端口上使用,然后尘埃落定 multi-arch 系统将所有内容都用于支持 i386 和 x86-64 版本的库安装在同时。现代系统使用 /lib64/ld-linux-x86-64.so.2)

Linux 使用 System V ABI. The AMD64 Architecture Processor Supplement (PDF) 描述初始执行环境(当 _start 被调用时)和调用约定。本质上,您有一个初始化的堆栈,其中存储了环境和 command-line 参数。


让我们构建一个完整的示例,包含 C 和汇编(AT&T 语法)源代码,以及最终的静态和动态二进制文件。

首先,我们需要一个 Makefile 来节省键入长命令的时间:

# SPDX-License-Identifier: CC0-1.0

CC      := gcc
CFLAGS  := -Wall -Wextra -O2 -march=x86-64 -mtune=generic -m64 \
           -ffreestanding -nostdlib -nostartfiles
LDFLAGS :=

all: static-prog dynamic-prog

clean:
    rm -f static-prog dynamic-prog *.o

%.o: %.c
    $(CC) $(CFLAGS) $^ -c -o $@

%.o: %.s
    $(CC) $(CFLAGS) $^ -c -o $@

dynamic-prog: main.o asm.o
    $(CC) $(CFLAGS) $^ $(LDFLAGS) -o $@

static-prog: main.o asm.o
    $(CC) -static $(CFLAGS) $^ $(LDFLAGS) -o $@

Makefile 的缩进很特别,但 SO 会将制表符转换为空格。因此,粘贴以上内容后,运行 sed -e 's|^ *|\t|' -i Makefile 将缩进修复为制表符。

上述 Makefile 和所有后续文件中的 SPDX 许可标识符告诉您这些文件是在 Creative Commons Zero license 下许可的:也就是说,这些文件都专用于 public 域。

使用的编译标志:

  • -Wall -Wextra:启用所有警告。这是一个很好的做法。

  • -O2:优化代码。这是一个常用的优化级别,通常被认为是足够的而不是太极端。

  • -march=x86-64 -mtune=generic -m64:编译为 64 位 x86-64 AKA AMD64 架构。这些是默认值;您可以使用 -march=native 来优化您自己的系统。

  • -ffreestanding:编译目标是 freestanding C 环境。告诉编译器它不能假定 strlenmemcpy 或其他库函数可用,因此不要优化循环、结构复制或数组初始化到对 strlen 的调用,例如 memcpymemset。如果您确实提供了 gcc 可能想要调用的任何函数的 asm 实现,则可以将其省略。 (特别是如果你正在编写一个将 运行 在 OS 下的程序)

  • -nostdlib -nostartfiles:不要在标准C库或其启动文件中link。 (实际上,-nostdlib已经"includes"-nostartfiles,所以-nostdlib就足够了。)

接下来,让我们创建一个 header 文件,nolib.h,实现 nolib_exit()nolib_write() 围绕 group_exit 的包装器并编写系统调用:

// SPDX-License-Identifier: CC0-1.0

/* Require Linux on x86-64 */
#if !defined(__linux__) || !defined(__x86_64__)
#error "This only works on Linux on x86-64."
#endif

/* Known syscall numbers, without depending on glibc or kernel headers */
#define SYS_write         1
#define SYS_exit_group  231
 // Normally you'd use
 // #include <asm/unistd.h> for __NR_write and __NR_exit_group
 // or even  #include <sys/syscall.h>   for SYS_write



/* Inline assembly macro for a single-parameter no-return syscall */
#define SYSCALL1_NORET(nr, arg1) \
    __asm__ volatile ( "syscall\n\t" : : "a" (nr), "D" (arg1) : "rcx", "r11", "memory")

/* Inline assembly macro for a three-parameter syscall */
#define SYSCALL3(retval, nr, arg1, arg2, arg3) \
    __asm__ volatile ( "syscall\n\t" : "=a" (retval) : "a" (nr), "D" (arg1), "S" (arg2), "d" (arg3) : "rcx", "r11", "memory" )

/* exit() function */
static inline void nolib_exit(int retval)
{
    SYSCALL1_NORET(SYS_exit_group, retval);
}

/* Some errno values */
#define  EINTR    4     /* Interrupted system call */
#define  EBADF    9     /* Bad file descriptor */
#define  EINVAL  22     /* Invalid argument */
 // or   #include <asm/errno.h>  to define these

/* write() syscall wrapper - returns negative errno if an error occurs */
static inline long nolib_write(int fd, const void *data, long len)
{
    long  retval;

    if (fd == -1)
        return -EBADF;
    if (!data || len < 0)
        return -EINVAL;

    SYSCALL3(retval, SYS_write, fd, data, len);

    return retval;
}

nolib_exit() 使用 exit_group 系统调用而不是 exit 系统调用的原因是 exit_group 结束了整个过程。如果你 运行 一个程序在 strace 下,你会看到它也在最后调用 exit_group 系统调用。 (Syscall implementation of exit())

接下来,我们需要一些 C 代码。 main.c:

// SPDX-License-Identifier: CC0-1.0

#include "nolib.h"

const char *c_function(void)
{
    return "C function";
}

static inline long nolib_put(const char *msg)
{
    if (!msg) {
        return nolib_write(1, "(null)", 6);
    } else {
        const char *end = msg;
        while (*end)
            end++;           // strlen
        if (end > msg)
            return nolib_write(1, msg, (unsigned long)(end - msg));
        else
            return 0;
    }
}

extern const char *asm_function(int);

void _start(void)
{
    nolib_put("asm_function(0) returns '");
    nolib_put(asm_function(0));
    nolib_put("', and asm_function(1) returns '");
    nolib_put(asm_function(1));
    nolib_put("'.\n");

    nolib_exit(0);
}

nolib_put() 只是 nolib_write() 的包装器,它找到要写入的字符串的末尾,并据此计算要写入的字符数。如果参数是 NULL 指针,则打印 (null).

因为这是一个独立的环境,入口点的默认名称是 _start,这将 _start 定义为从不 return 的 C 函数。 (它绝不能 return,因为 ABI 不提供任何 return 地址;它只会使进程崩溃。相反,必须在最后调用 exit-type 系统调用。)

C 源代码声明并调用了一个函数 asm_function,它接受一个整数参数,return 是一个指向字符串的指针。显然,我们将在汇编中实现它。

C 源代码还声明了一个函数 c_function,我们可以从汇编中调用它。

这里是组装部分,asm.s:

# SPDX-License-Identifier: CC0-1.0

    .text
    .section    .rodata
.one:
    .string     "One"       # includes zero terminator

    .text
    .p2align    4,,15
    .globl      asm_function       #### visible to the linker

    .type       asm_function, @function
asm_function:
    cmpl    , %edi
    jne     .else
    leaq    .one(%rip), %rax
    ret

.else:
    subq    , %rsp              # 16B stack alignment for a call to C
    call    c_function
    addq    , %rsp
    ret

    .size   asm_function, .-asm_function

我们不需要将 c_function 声明为外部符号,因为 GNU as 无论如何都将所有未知符号视为外部符号。我们可以添加 Call Frame Information directives,至少 .cfi_startproc.cfi_endproc,但我将它们排除在外,这样就不会那么明显 我只是用 C 编写了原始代码,然后让 GCC 将其编译为组装,然后稍微美化一下。 (我大声写出来了吗?哎呀!但说真的,编译器输出通常是 hand-written asm 实现某些东西的良好起点,除非它在优化方面做得非常糟糕。)

subq , %rsp 调整堆栈,使其成为 c_function 的 16 的倍数。 (在 x86-64 上,堆栈向下增长,因此要保留 8 个字节的堆栈,从堆栈指针中减去 8。)在调用 returns 之后,addq , %rsp 将堆栈恢复为原始状态。

有了这四个文件,我们就准备好了。要构建示例二进制文件,运行 例如

reset ; make clean all

运行 ./static-prog./dynamic-prog 将输出

asm_function(0) returns 'C function', and asm_function(1) returns 'One'.

这两个二进制文件的大小仅为 2 kB(静态)和 6 kB(动态)左右,尽管您可以通过剥离不需要的内容使它们更小,

strip --strip-unneeded static-prog dynamic-prog

从中删除了大约 0.5 kB 到 1 kB 不需要的东西——具体数量因您使用的 GCC 和 Binutils 版本而异。

在其他一些架构上,我们还需要 link 来对抗 libgcc(通过 -lgcc),因为一些 C 特性依赖于内部 GCC 函数。各种架构上的 64 位整数除法(命名为 udivdi 或类似)是一个典型的例子。


如评论中所述,上述示例的第一个版本有一些问题需要解决。它们不会阻止示例按预期执行或工作,并且被忽略了,因为这些示例是为这个答案从头开始编写的(希望其他人稍后通过网络搜索发现这个问题可能会发现这很有用),我是不完美。 :)

  • memory clobber argument 到内联汇编,在系统调用预处理器宏中

    在被破坏的列表中添加 "memory" 告诉编译器内联程序集可以访问(读取 and/or 写入)参数列表中指定的内存以外的内存。它显然是 needed for the write syscall,但它实际上对所有系统调用都很重要,因为内核可以提供例如在从系统调用 returning 之前在同一个线程中发出信号,并且信号传递 can/will 访问内存。

    正如 GCC 文档中提到的,此破坏器的行为也类似于编译器的 read/write 内存屏障(但不是处理器!)。换句话说,通过内存破坏,编译器知道它必须在内联汇编之前将变量等的任何更改写入内存,并且不相关的变量和其他内存内容(未明确列在内联汇编输入、输出或clobbers) 也可能会改变,并且会生成我们真正想要的代码,而不会做出不正确的假设。

  • -fPIC -pie:省略

    位置无关代码通常只与共享库相关。在实际项目的 Makefile 中,您需要为 objects 使用一组不同的编译标志,这些编译标志将被编译为动态库、静态库、动态 linked 可执行文件或静态可执行文件,由于所需的属性(因此 compiler/linker 标志)有所不同。

    在这样的例子中,最好尽量避免这些无关的事情,因为这是一个合理的问题,可以单独提出 ("Which compiler options to use to achieve X, when needing Y ?"),答案取决于所需的功能和上下文。

    在大多数现代发行版中,PIE 是默认的,您可能希望 -fno-pie -no-pie 简化调试/反汇编。 32-bit absolute addresses no longer allowed in x86-64 Linux?

  • -nostdlib 确实暗示(或 "include")-nostartfiles

    我们可以使用很多 overall options and link options 来控制代码的编译和 linked 方式。

    GCC 支持的许多选项已分组。例如,对于您可以显式指定的 collection 优化功能,-O2 实际上是 shorthand。

    在这里,保留两者的原因是提醒人类程序员对代码的期望:没有标准库没有开始files/objects.

  • -march=x86-64 -mtune=generic -m64 是 x86-64 上的默认值

    同样,保留它更多是为了提醒代码预期的内容。如果没有特定的体系结构定义,可能 会产生错误的印象,认为代码通常应该是可编译的,因为 C 通常不是体系结构特定的!

    nolib.h header 文件确实包含预处理器检查(使用 pre-defined compiler macros 检测操作系统和硬件架构),停止编译并出现其他错误 OSes和硬件架构。

  • 大多数 Linux 发行版在 <asm/unistd.h> 中提供系统调用编号,如 __NR_name.

    这些来自实际的内核源代码。然而,对于任何给定的架构,这些都是稳定的用户空间 ABI,不会改变。可能会添加新的。只有在某些特殊情况下(也许是无法修复的安全漏洞?)系统调用才会被弃用并停止运行。

    最好使用内核中的系统调用编号,最好是通过前面提到的 header,但也可以仅使用 GCC 构建此程序,无需 glibc 或 Linux 内核 header已安装。对于编写自己的标准 C 库的人,他们应该包含文件(来自 Linux 内核源代码)。

    我知道 Debian 衍生产品(Ubuntu、Mint 等)都提供 <asm/unistd.h> 文件,但还有很多很多其他 Linux 发行版,我只是不确定所有这些。我选择只定义这两个(exit_group 和 write),以尽量减少出现问题的风险。

    (编者注:该文件可能位于文件系统中的不同位置,但如果安装了正确的 header 包,则 <asm/unistd.h> 包含路径应​​该始终有效。它是内核的一部分user-space C/asm API.)

  • 编译标志-g增加了调试符号,这在调试时增加了很多——例如,当运行在gdb中查看二进制文件时。

    我省略了这个和所有相关的标志,因为我不想进一步扩展这个话题,而且因为这个例子很容易在 asm 级别调试并且即使没有也可以检查。请参阅 x86 tag wiki

  • 底部的 GDB asm 提示,如 layout reg
  • System V ABI 要求在 call 函数之前,堆栈对齐到 16 字节。所以在函数的顶部,RSP+-8 是 16 字节对齐的,如果有任何堆栈参数,它们将被对齐。

    call指令将当前指令指针压入堆栈,因为这是64位架构,所以也是64位=8字节。因此,为了符合 ABI,我们确实需要在调用函数之前将堆栈指针调整 8,以确保它也获得正确对齐的堆栈指针。这些最初被省略,但现在包含在程序集中(asm.s 文件)。

    这很重要,因为在 x86-64 上,SSE/AVX SIMD 向量对 aligned-to-16 字节和未对齐访问有不同的指令,对齐访问速度明显更快或某些处理器。 (Why does System V / AMD64 ABI mandate a 16 byte stack alignment?). Using aligned SIMD instructions like movaps with unaligned addresses will cause the process to crash. (e.g. glibc scanf Segmentation faults when called from a function that doesn't align RSP 是一个 real-life 例子,说明当你弄错时会发生什么。)

    但是,当我们进行此类堆栈操作时,我们确实应该添加 CFI(调用帧信息)指令以确保调试和堆栈展开等工作正常。在这种情况下,对于一般的 CFI,我们在汇编函数中的第一条指令之前添加 .cfi_startproc,在汇编函数中的最后一条指令之后添加 .cfi_endproc。对于规范帧地址 CFA,我们在任何修改堆栈指针的指令之后添加 .cfi_def_cfa_offset N。本质上,N 在函数的开头是 8,%rsp 减多少就增加多少,反之亦然。有关更多信息,请参阅 this article

    在内部,这些指令生成存储在 ELF object 文件和二进制文件的 .eh_frame.eh_frame_hdr 部分中的信息(元数据),具体取决于其他编译标志。

    所以,在这种情况下,subq , %rsp后面应该跟.cfi_def_cfa_offset 16addq , %rsp后面跟.cfi_def_cfa_offset 8,加上.cfi_startproc开头在最后的 ret.

    之后 asm_function.cfi_endproc

    请注意,您经常可以在汇编源代码中看到 rep ret 而不仅仅是 rep。这不过是某些处理器在跳转到或通过 JCC 跳转到 ret 指令时出现 branch-prediction 性能问题的解决方法。 rep 前缀什么都不做,除了它确实解决了那些处理器可能会遇到的问题。最近的 GCC 版本默认停止执行此操作,因为受影响的 AMD CPU 非常旧并且现在不那么相关。 What does `rep ret` mean?

  • "key"选项,-ffreestanding,就是选择a C "dialect"

    C 编程语言实际上分为两种不同的环境:hostedfreestanding.

    hosted 环境是标准 C 库可用的环境,当您用 C 语言编写程序、应用程序或守护进程时会用到它。

    独立环境是标准 C 库可用的环境。当您为微控制器或嵌入式系统编写内核、固件、实现(部分)您自己的标准 C 库或为其他 C-derived 语言编写 "standard library" 时会用到它。

    例如,Arduino 编程环境基于 freestanding C++ 的一个子集。标准 C++ 库不可用,并且不支持异常等 C++ 的许多功能。事实上,它非常接近具有 类 的独立 C。该环境还使用了一个特殊的 pre-preprocessor,例如自动预先声明函数而无需用户编写它们。

    可能最著名的独立 C 示例是 Linux 内核。不仅标准 C 库不可用,而且出于某些硬件考虑,内核代码实际上也必须避免 floating-point 操作。

    为了更好地理解独立的 C 环境对于程序员来说究竟是什么样子,我认为最好的办法是查看语言标准本身。截至目前(2020 年 6 月),最新的 st标准是 ISO C18。虽然标准本身不是免费的,但最终草案是免费的;对于 C18,它是 draft N2176(PDF)。

ld.so(ELF 解释器)的 ld 默认路径不是现代 x86-64 GNU/Linux 系统上使用的路径。

/lib/ld64.so.1 可能已经在早期的 x86-64 GNU/Linux 端口上使用了,在尘埃落定之前,多体系结构系统将把所有东西都放在支持安装的 i386 和 x86-64 版本库的地方同时。现代系统使用 /lib64/ld-linux-x86-64.so.2.

GNU binutils 中的默认值从来都不是更新的好时机ld;当某些系统使用默认设置时,更改它会破坏它们。多体系结构系统必须配置他们的 GCC 以将 -dynamic-linker /some/path 传递给 ld,所以他们只是这样做而不是询问并等待 ld 默认值更改。所以没有人需要 ld 默认值来改变任何东西,除了那些玩汇编和手动使用 ld 来创建动态-linked 可执行文件的人。

您可以 link 使用 gcc -nostartfiles 来省略定义 _start 的 CRT 起始代码,但仍然 link 与普通库包括 -lc-lgcc 内部辅助函数(如果需要)等

有关为定义 _start 的 asm 汇编 with/without libc 或为定义 main 的 asm 汇编 libc + CRT 的更多信息,请参阅 Assembling 32-bit binaries on a 64-bit system (GNU toolchain)。 (从 64 位的答案中省略 -m32;当使用 gcc 为您调用 asld 时,这是唯一的区别。)


ld -static -e my_entry_pt -lc ./callee.obj ./caller.obj -o ./prog.out
不会 link 因为您将 -lc 放在 引用 libc.

中符号的目标文件之前

对于静态库,linker 命令行中的顺序很重要。

然而,ld -static -e my_entry_pt ./callee.o ./caller.o -lc -o ./prog.out 会 link,但会导致程序在调用 glibc 函数时出现段错误,例如 write 而没有调用 glibc 的 init 函数。

Dynamic linking 会为你处理(glibc 有 .init 被动态 linker 调用的函数,同样的机制允许 C++ 静态初始化程序 运行 在 C++ 共享库中)。 CRT 启动代码也以正确的顺序调用这些函数,但您也将其遗漏,并编写了您自己的入口点。

@Example 的答案通过定义自己的 write 包装器而不是 link 与 -lc 一起避免了这个问题,因此它可以真正独立。


我认为 glibc 的 write 包装器函数足够简单不会崩溃,但事实并非如此。它通过从 %fs:0x18 加载来检查程序是否是多线程或其他东西。内核不会为线程本地存储初始化 FS 基;这是 user-space(glibc 的内部初始化函数)必须做的事情。

如果您没有调用 glibc 的初始化函数,

glibc 的 write() 会在 mov %fs:0x18,%eax 上出错。(在静态 linked 可执行文件中glibc 无法为您将动态 linker 转换为 运行。)

Dump of assembler code for function write:
=> 0x0000000000401040 <+0>:     endbr64                 # for CET, or NOP on CPUs without CET
   0x0000000000401044 <+4>:     mov    %fs:0x18,%eax    ### this faults with no TLS setup
   0x000000000040104c <+12>:    test   %eax,%eax
   0x000000000040104e <+14>:    jne    0x401060 <write+32>
   0x0000000000401050 <+16>:    mov    [=10=]x1,%eax        # simple case: EAX = __NR_write
   0x0000000000401055 <+21>:    syscall 
   0x0000000000401057 <+23>:    cmp    [=10=]xfffffffffffff000,%rax
   0x000000000040105d <+29>:    ja     0x4010b0 <write+112>        # update errno on error
   0x000000000040105f <+31>:    retq                               # else return

   0x0000000000401060 <+32>:    sub    [=10=]x28,%rsp               # the non-simple case:
   0x0000000000401064 <+36>:    mov    %rdx,0x18(%rsp)          # write is an async cancellation point or something
   0x0000000000401069 <+41>:    mov    %rsi,0x10(%rsp)
   0x000000000040106e <+46>:    mov    %edi,0x8(%rsp)
   0x0000000000401072 <+50>:    callq  0x4010e0 <__libc_enable_asynccancel>
   0x0000000000401077 <+55>:    mov    0x18(%rsp),%rdx
   0x000000000040107c <+60>:    mov    0x10(%rsp),%rsi
   0x0000000000401081 <+65>:    mov    %eax,%r8d
   0x0000000000401084 <+68>:    mov    0x8(%rsp),%edi
   0x0000000000401088 <+72>:    mov    [=10=]x1,%eax
   0x000000000040108d <+77>:    syscall 
   0x000000000040108f <+79>:    cmp    [=10=]xfffffffffffff000,%rax
   0x0000000000401095 <+85>:    ja     0x4010c4 <write+132>
   0x0000000000401097 <+87>:    mov    %r8d,%edi
   0x000000000040109a <+90>:    mov    %rax,0x8(%rsp)
   0x000000000040109f <+95>:    callq  0x401140 <__libc_disable_asynccancel>
   0x00000000004010a4 <+100>:   mov    0x8(%rsp),%rax
   0x00000000004010a9 <+105>:   add    [=10=]x28,%rsp
   0x00000000004010ad <+109>:   retq   
   0x00000000004010ae <+110>:   xchg   %ax,%ax

   0x00000000004010b0 <+112>:   mov    [=10=]xfffffffffffffffc,%rdx   # errno update for the simple case
   0x00000000004010b7 <+119>:   neg    %eax
   0x00000000004010b9 <+121>:   mov    %eax,%fs:(%rdx)          # thread-local errno?
   0x00000000004010bc <+124>:   mov    [=10=]xffffffffffffffff,%rax
   0x00000000004010c3 <+131>:   retq

   0x00000000004010c4 <+132>:   mov    [=10=]xfffffffffffffffc,%rdx   # same for the async case
   0x00000000004010cb <+139>:   neg    %eax
   0x00000000004010cd <+141>:   mov    %eax,%fs:(%rdx)
   0x00000000004010d0 <+144>:   mov    [=10=]xffffffffffffffff,%rax
   0x00000000004010d7 <+151>:   jmp    0x401097 <write+87>

我不完全理解 write 到底在检查或做什么。可能跟async I/O, and/or POSIX线程取消点有关