虽然 Linux 内核提供了一个名为 write 的系统调用,但这并不意味着您会自动获得一个与您可以从 C 中调用的名称相同的包装函数 write()。事实上,如果您不使用 libc,则需要内联汇编才能从 C 中调用任何系统调用,因为 libc 定义了这些包装函数。
不要将您的二进制文件与ld 明确链接,而是让gcc 为您完成。如果源代码以.s 后缀结尾,它甚至可以组装汇编文件(在内部执行适当版本的as)。看起来您的链接问题只是 GCC 的假设与您自己通过 LD 的方式之间存在分歧。
不,这不是错误; ld.so 的 ld 默认路径不是现代 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 ABI。 AMD64 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 环境。告诉编译器它不能假定strlen 或memcpy 或其他库函数可用,因此不要将循环、结构复制或数组初始化优化为对strlen、memcpy 或@ 的调用例如 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 options 和link options 来控制代码的编译和链接方式。
GCC 支持的许多选项都是分组的。例如,-O2 实际上是您可以明确指定的优化功能集合的简写。
这里,保留两者的原因是为了提醒人类程序员对代码的期望:没有标准库,没有启动文件/对象。
-
-march=x86-64 -mtune=generic -m64 是 x86-64 上的默认值
同样,这更多地是为了提醒代码的期望。如果没有特定的架构定义,可能会产生错误的印象,即代码通常应该是可编译的,因为 C 通常不是特定于架构的!
nolib.h 头文件确实包含预处理器检查(使用pre-defined compiler macros 检测操作系统和硬件架构),在其他操作系统和硬件架构出现错误时暂停编译。
-
大多数 Linux 发行版在 <asm/unistd.h> 中提供系统调用号,如 __NR_name。
这些来自实际的内核源代码。然而,对于任何给定的架构,这些都是稳定的用户空间 ABI,不会改变。可能会添加新的。只有在某些特殊情况下(可能是无法修复的安全漏洞?),系统调用才能被弃用并停止运行。
使用内核中的系统调用号总是更好,最好是通过前面提到的头文件,但也可以只使用 GCC,不安装 glibc 或 Linux 内核头文件来构建这个程序。对于编写自己的标准 C 库的人,他们应该包含该文件(来自 Linux 内核源代码)。
我确实知道 Debian 衍生产品(Ubuntu、Mint 等)都提供了 <asm/unistd.h> 文件,但是还有很多很多其他 Linux 发行版,我只是不确定所有这些发行版。我选择只定义这两个(exit_group 和 write),以尽量减少出现问题的风险。
(编者注:该文件可能位于文件系统中的不同位置,但如果安装了正确的头包,<asm/unistd.h> 包含路径应该始终有效。它是内核用户空间 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 16,addq $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 编程语言实际上分为两种不同的环境:hosted 和 freestanding。
托管环境是标准 C 库可用的环境,在您用 C 语言编写程序、应用程序或守护程序时使用。
独立环境是标准 C 库不可用的环境。当您为微控制器或嵌入式系统编写内核、固件、实现(部分)您自己的标准 C 库或其他一些 C 派生语言的“标准库”时使用它。
例如,Arduino 编程环境基于独立 C++ 的一个子集。标准 C++ 库不可用,并且不支持 C++ 的许多功能(如异常)。事实上,它非常接近于带类的独立 C。该环境还使用了一个特殊的预处理器,例如,它会自动预先添加函数声明,而无需用户编写它们。
独立 C 语言最著名的例子可能是 Linux 内核。由于某些硬件方面的考虑,不仅标准 C 库不可用,而且内核代码实际上也必须避免浮点运算。
为了更好地理解独立的 C 环境对程序员来说究竟是什么样的,我认为最好的办法是查看语言标准本身。截至目前(2020 年 6 月),最新标准是 ISO C18。虽然标准本身不是免费的,但最终草案是;对于 C18,它是 draft N2176(PDF)。