您如何从 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.so
的 ld
默认路径不是现代 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 环境。告诉编译器它不能假定 strlen
或 memcpy
或其他库函数可用,因此不要优化循环、结构复制或数组初始化到对 strlen
的调用,例如 memcpy
或 memset
。如果您确实提供了 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 16
,addq , %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 编程语言实际上分为两种不同的环境:hosted 和 freestanding.
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
为您调用 as
和 ld
时,这是唯一的区别。)
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线程取消点有关
我正在玩弄并试图了解计算机和程序的低级操作。为此,我正在尝试将 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.so
的 ld
默认路径不是现代 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 环境。告诉编译器它不能假定strlen
或memcpy
或其他库函数可用,因此不要优化循环、结构复制或数组初始化到对strlen
的调用,例如memcpy
或memset
。如果您确实提供了 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 提示,如 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 16
,addq , %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 编程语言实际上分为两种不同的环境:hosted 和 freestanding.
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)。
layout reg
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
为您调用 as
和 ld
时,这是唯一的区别。)
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 的 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线程取消点有关