【问题标题】:How do you call C functions from Assembly and how do you link it Statically?如何从汇编中调用 C 函数以及如何静态链接它?
【发布时间】:2020-10-03 15:49:58
【问题描述】:

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

我有 2 个程序文件:

“callee.c”中的一些 C 代码:

#include <unistd.h>

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

我在“caller.asm”中也有一些 GAS x86_64 程序集:

.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 $60, %rax # set the syscall to the index of 'exit' (60)
  mov $0, %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

这是为什么?我的系统上的链接器默认设置有问题吗?我该如何/应该如何解决它?

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

$ 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内核下的所有架构都会为我处理它吗?

【问题讨论】:

  • 使用gcc链接,而不是直接使用ld
  • @JosephSible-ReinstateMonica gcc 默认尝试链接 crt1.o,其中包括提供的 c 库“_start”。这包括对“main”的调用,这会导致链接错误,因为“main”不存在。我能够通过在“callee.c”中包含一个空的和未使用的主函数来解决这个问题,但这样做似乎很麻烦。这也意味着有一堆未使用的代码链接到我的程序中。
  • @JosephSible-ReinstateMonica 如果我静态链接它,我会在运行时遇到段错误。使用 GDB,我将问题追溯到 glibc 函数 write() 中的第一条指令。指令是“mov %fs:0x18,%eax”。失败时 %fs 的值为 0。我不知道该指令的目的是什么,但我怀疑我违反了 ABI 的某些规范,需要进行一些 Stack 初始化。
  • 为什么要使用汇编程序主程序中的 C 函数?通常情况是相反的。 -- C 标准库的函数可能需要一些初始化。如果没有 crt1,我会害怕错过重要的东西。 -- 可以使用选项-nostartfiles来防止启动代码的链接和main()的调用。
  • @EchelonX-Ray:使用gcc -nostartfiles 在不使用 CRT 的情况下进行链接,但仍然 with libc 和 ELF 解释器的正确路径。这仅适用于动态链接的可执行文件,因为 glibc 需要以某种方式初始化自身,无论是通过动态链接器挂钩还是通过您的 _start 以正确的顺序调用它的 init 函数,而您的入口点不这样做。

标签: c linux assembly gcc x86-64


【解决方案1】:

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

不要将您的二进制文件与ld 明确链接,而是让gcc 为您完成。如果源代码以.s 后缀结尾,它甚至可以组装汇编文件(在内部执行适当版本的as)。看起来您的链接问题只是 GCC 的假设与您自己通过 LD 的方式之间存在分歧。

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

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


让我们构建一个完整的工作示例,包含 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 License Identifier 和所有以下文件告诉您这些文件是在 Creative Commons Zero license 下授权的:也就是说,这些文件都专用于公共领域。

使用的编译标志:

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

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

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

  • -ffreestanding:编译的目标是独立 C 环境。告诉编译器它不能假定strlenmemcpy 或其他库函数可用,因此不要将循环、结构复制或数组初始化优化为对strlenmemcpy 或@ 的调用例如 987654364@。如果你确实提供了 gcc 可能想要发明调用的任何函数的 asm 实现,你可以忽略它。 (特别是如果您正在编写将在操作系统下运行的程序)

  • -nostdlib -nostartfiles:不要在标准 C 库或其启动文件中链接。 (实际上,-nostdlib 已经“包含”了-nostartfiles,所以单独使用-nostdlib 就足够了。)

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

// 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)

因为这是一个独立的环境,入口点的默认名称是_start,这将_start 定义为一个永不返回的C 函数。 (它永远不能返回,因为 ABI 不提供任何返回地址;它只会使进程崩溃。相反,必须在结束时调用退出类型的系统调用。)

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

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    $1, %edi
    jne     .else
    leaq    .one(%rip), %rax
    ret

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

    .size   asm_function, .-asm_function

我们不需要将c_function 声明为外部符号,因为 GNU 无论如何都会将所有未知符号视为外部符号。我们可以添加Call Frame Information directives,至少添加.cfi_startproc.cfi_endproc,但我将它们省略了,所以它不会那么明显我只是用C 编写原始代码并让GCC 将其编译为汇编,然后美化它一点点。 (我有没有把它大声写出来?哎呀!但说真的,编译器输出通常是手写 asm 实现的一个很好的起点,除非它在优化方面做得很差。)

subq $8, %rsp 调整堆栈,使其成为c_function 的 16 的倍数。 (在 x86-64 上,堆栈会向下增长,因此要保留 8 个字节的堆栈,请从堆栈指针中减去 8。)调用返回后,addq $8, %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 的版本。

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


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

  • memory clobber argument 到系统调用预处理器宏中的内联程序集

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

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

  • -fPIC -pie:为简单起见省略

    位置无关代码通常只与共享库相关。在实际项目的 Makefile 中,您需要为将编译为动态库、静态库、动态链接可执行文件或静态可执行文件的对象使用一组不同的编译标志,作为所需的属性(因此编译器/链接器标志)变化。

    在这个例子中,最好尽量避免这些无关紧要的事情,因为这是一个合理的问题(“使用哪些编译器选项来实现 X , 当需要 Y ?"),答案取决于所需的特征和上下文。

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

  • -nostdlib 确实暗示(或“包含”)-nostartfiles

    我们可以使用很多overall optionslink options 来控制代码的编译和链接方式。

    GCC 支持的许多选项都是分组的。例如,-O2 实际上是您可以明确指定的优化功能集合的简写。

    这里,保留两者的原因是为了提醒人类程序员对代码的期望:没有标准库没有启动文件/对象

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

    同样,这更多地是为了提醒代码的期望。如果没有特定的架构定义,可能会产生错误的印象,即代码通常应该是可编译的,因为 C 通常不是特定于架构的!

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

  • 大多数 Linux 发行版在 &lt;asm/unistd.h&gt; 中提供系统调用号,如 __NR_name

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

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

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

    (编者注:该文件可能位于文件系统中的不同位置,但如果安装了正确的头包,&lt;asm/unistd.h&gt; 包含路径应​​该始终有效。它是内核用户空间 C/asm API 的一部分。 )

  • 编译标志-g 添加了调试符号,这在调试时会大大增加 - 例如,在 gdb 中运行和检查二进制文件时。

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

  • System V ABI 要求在将call 传递给函数之前,堆栈与 16 字节对齐。所以在函数的顶部,RSP+-8 是 16 字节对齐的,如果有任何堆栈参数,它们就会对齐。

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

    这很重要,因为在 x86-64 上,SSE/AVX SIMD 向量对对齐到 16 字节和未对齐访问有不同的指令,对齐访问或某些处理器明显更快。 (Why does System V / AMD64 ABI mandate a 16 byte stack alignment?)。使用对齐的 SIMD 指令(如 movaps)和未对齐的地址将导致进程崩溃。 (例如,glibc scanf Segmentation faults when called from a function that doesn't align RSP 是一个真实的例子,说明当你弄错时会发生什么。)

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

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

    因此,在这种情况下,subq $8, %rsp 后面应该是 .cfi_def_cfa_offset 16addq $8, %rsp 后面应该是 .cfi_def_cfa_offset 8,加上 .cfi_startproc 开头的 asm_function 和最后一个 @ 之后的 .cfi_endproc 987654435@.

    请注意,您经常可以在汇编源代码中看到 rep ret 而不仅仅是 rep。这不过是某些处理器在跳转到或通过 JCC 到 ret 指令时出现分支预测性能问题的一种解决方法。 rep 前缀没有任何作用,但它确实解决了这些处理器在这种跳转时可能遇到的问题。最近的 GCC 版本默认停止执行此操作,因为受影响的 AMD CPU 非常老旧,如今已不那么重要。 What does `rep ret` mean?

  • “key”选项-ffreestanding是选择a C "dialect"的选项

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

    托管环境是标准 C 库可用的环境,在您用 C 语言编写程序、应用程序或守护程序时使用。

    独立环境是标准 C 库可用的环境。当您为微控制器或嵌入式系统编写内核、固件、实现(部分)您自己的标准 C 库或其他一些 C 派生语言的“标准库”时使用它。

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

    独立 C 语言最著名的例子可能是 Linux 内核。由于某些硬件方面的考虑,不仅标准 C 库不可用,而且内核代码实际上也必须避免浮点运算。

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

【讨论】:

  • 您的系统调用中确实应该有一个memory clobber。
  • -fPIC 对于 PIE 可执行文件来说太过分了。它意味着符号插入,而不仅仅是位置无关。如果这是您想要的,请使用-fPIE。 (大多数现代发行版都将 -fPIE -pie 设为默认值,您必须明确使用 -fno-pie -no-pie 来构建传统的 ELF-type = EXEC 可执行文件。)
  • 我推荐 -g 作为 gcc 选项,这样您就可以更轻松地使用调试器找到东西。此外,对于 asm 调试,-fno-pie -no-pie 往往更容易:链接时的绝对地址意味着反汇编具有它们。同样-static 将覆盖-pie。如果你想要 ASLR 但没有 ELF 解释器,你可以使用-static-pieWhat's the difference between "statically linked" and "not a dynamic executable" from Linux ldd?。在 64 位模式下,将 -fPIE 代码链接到静态可执行文件的效率仅比 -fno-pie 略低,但效率较低。
  • @PeterCordes:谢谢!我同意您的观点,并尝试应用它们(或解释为什么我想保留重言式选项)。我在这里检查了您的答案,并相信您的意见和专业知识,因此如果您确实发现错误或遗漏,请直接编辑此答案。 (如果是建议或选择问题,那么评论或单独的答案或建议可能会更好:)。
  • @EchelonX-Ray: -pie-fpic 和相关选项是一个附带问题,与共享库和地址空间布局随机化 (ASLR) 相关,而不是普通可执行文件应该担心的问题.我已经添加了一些进一步的解释或推理为什么使用这些选项(或现在省略)。如果您想扩展某些内容,请务必说出来;其他人可能想知道同样的事情。我只做了一点点,所以如果 Peter Cordes 指出了什么,我相信他们在这方面的经验比我多得多,所以请相信他们而不是我 :)。
【解决方案2】:

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 来创建动态链接的可执行文件。

您可以使用gcc -nostartfiles 链接以省略定义_start 的CRT 起始代码,但仍可链接到包括-lc-lgcc 内部在内的普通库,而不是这样做如果需要,辅助函数等。

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


ld -static -e my_entry_pt -lc ./callee.obj ./caller.obj -o ./prog.out
没有链接,因为您将 -lc 之前 放在了引用 libc 中符号的目标文件。

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

但是,ld -static -e my_entry_pt ./callee.o ./caller.o -lc -o ./prog.out 将链接,但在调用 glibc 函数(如 write)时会产生段错误,而无需调用 glibc 的 init 函数。

动态链接会为您解决这个问题(glibc 具有由动态链接器调用的 .init 函数,与允许 C++ 静态初始化程序在 C++ 共享库中运行的机制相同)。 CRT 启动代码也以正确的顺序调用这些函数,但您也将其省略了,并编写了自己的入口点。

@Example 的答案通过定义自己的 write 包装器而不是与 -lc 链接来避免该问题,因此它可以真正独立。


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

glibc 的 write() 错误在 mov %fs:0x18,%eax 上,如果您没有调用 glibc 的 init 函数。(在静态链接的可执行文件中,glibc 无法让动态链接器为您运行它们.)

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    $0x1,%eax        # simple case: EAX = __NR_write
   0x0000000000401055 <+21>:    syscall 
   0x0000000000401057 <+23>:    cmp    $0xfffffffffffff000,%rax
   0x000000000040105d <+29>:    ja     0x4010b0 <write+112>        # update errno on error
   0x000000000040105f <+31>:    retq                               # else return

   0x0000000000401060 <+32>:    sub    $0x28,%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    $0x1,%eax
   0x000000000040108d <+77>:    syscall 
   0x000000000040108f <+79>:    cmp    $0xfffffffffffff000,%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    $0x28,%rsp
   0x00000000004010ad <+109>:   retq   
   0x00000000004010ae <+110>:   xchg   %ax,%ax

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

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

我不完全理解 write 到底在检查什么或在做什么。它可能与异步 I/O 和/或 POSIX 线程取消点有关。

【讨论】:

  • 我认为endbr64 是用于 CET,而不是过时的 MPX。
  • @JosephSible-ReinstateMonica:谢谢,很好。我正在混淆我的 br 与 bnd0 之类的东西,应该用谷歌搜索而不是猜测。我没有用 CET 或 MPX 做过很多事情。
猜你喜欢
  • 2015-10-19
  • 2010-11-19
  • 1970-01-01
  • 2016-09-07
  • 2017-02-18
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-04-21
相关资源
最近更新 更多