Ech*_*Ray 4 c linux assembly gcc x86-64
我正在玩弄并试图了解计算机和程序的低级操作。为此,我正在尝试链接 Assembly 和 C。
我有2个程序文件:
“callee.c”中的一些C代码:
#include <unistd.h>
void my_c_func() {
write(1, "Hello, World!\n", 14);
return;
}
Run Code Online (Sandbox Code Playgroud)
我在“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
Run Code Online (Sandbox Code Playgroud)
我可以像这样构建和执行程序:
$ 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!
Run Code Online (Sandbox Code Playgroud)
一路上,我遇到了一些问题。如果我不设置 -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
Run Code Online (Sandbox Code Playgroud)
为什么是这样?我的系统上的链接器默认设置有问题吗?我如何/应该如何修复它?
此外,静态链接不起作用。
$ 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'
Run Code Online (Sandbox Code Playgroud)
为什么是这样?write() 不应该只是系统调用“write”的 ac 库包装器吗?我该如何解决?
我在哪里可以找到有关 C 函数调用约定的文档,以便我可以了解如何来回传递参数等...?
最后,虽然这似乎适用于这个简单的例子,但我在初始化 C 堆栈时做错了什么吗?我的意思是,现在,我什么都不做。在开始尝试调用函数之前,我是否应该从内核中为堆栈分配内存、设置边界以及设置 %rsp 和 %rbp。或者内核加载器是否为我处理了所有这些?如果是这样,Linux 内核下的所有架构都会为我处理吗?
小智 5
尽管 Linux 内核提供了一个名为 的系统调用write,但这并不意味着您会自动获得一个同名的包装函数,您可以从 C 调用 as write()。事实上,如果您不使用 libc,您需要内联汇编来从 C 调用任何系统调用,因为 libc 定义了这些包装函数。
与其显式地将您的二进制文件与 链接起来,不如ld让gcc我们为您做。它甚至可以组装汇编文件(在内部执行合适版本的as),如果源以.s后缀。看起来您的链接问题只是 GCC 假设的内容与您自己通过 LD 执行此操作的方式之间的分歧。
不,这不是错误;的ld默认路径ld.so不是现代 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架构处理器补充(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 $@
Run Code Online (Sandbox Code Playgroud)
Makefile 对它们的缩进很讲究,但 SO 将制表符转换为空格。因此,在粘贴上述内容后,运行sed -e 's|^ *|\t|' -i Makefile将缩进修复回制表符。
上述 Makefile 和所有以下文件中的 SPDX 许可标识符告诉您这些文件是在知识共享零许可下许可的:也就是说,这些文件都专用于公共领域。
使用的编译标志:
-Wall -Wextra:启用所有警告。这是一个很好的做法。
-O2: 优化代码。这是一个常用的优化级别,通常被认为是足够的而不是太极端。
-march=x86-64 -mtune=generic -m64:编译为 64 位 x86-64 AKA AMD64 架构。这些是默认值;你可以-march=native用来优化你自己的系统。
-ffreestanding:编译目标是独立的 C环境。告诉编译器它不能假设strlenormemcpy或其他库函数可用,因此不要将循环、结构副本或数组初始化优化为对strlen、memcpy、 或 的调用memset。如果您确实提供了 gcc 可能想要发明调用的任何函数的 asm 实现,则可以将其省略。(特别是如果您正在编写将在操作系统下运行的程序)
-nostdlib -nostartfiles: 不要在标准 C 库或其启动文件中链接。(实际上,-nostdlib已经 "includes" -nostartfiles,所以-nostdlib单独就足够了。)
接下来,让我们创建一个头文件,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;
}
Run Code Online (Sandbox Code Playgroud)
nolib_exit()使用exit_group系统调用而不是系统调用的原因exit是exit_group结束整个过程。如果你在 下运行一个程序strace,你会看到它exit_group在最后也调用了syscall。(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);
}
Run Code Online (Sandbox Code Playgroud)
nolib_put()只是一个包装器nolib_write(),它找到要写入的字符串的末尾,并根据它计算要写入的字符数。如果参数是 NULL 指针,则打印(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
Run Code Online (Sandbox Code Playgroud)
我们不需要声明c_function为 extern,因为无论如何 GNU 都将所有未知符号视为外部符号。我们可以添加Call Frame Information 指令,至少.cfi_startproc和.cfi_endproc,但我把它们排除在外,所以它不会那么明显,我只是用 C 编写了原始代码,让 GCC 将其编译为汇编,然后稍微美化一下。(我有没有把它大声写出来?哎呀!但是说真的,编译器输出通常是某个东西的手写 asm 实现的一个很好的起点,除非它在优化方面做得非常糟糕。)
该subq $8, %rsp调整堆栈所以,这将是16的倍数c_function。(在 x86-64 上,堆栈会变小,因此要保留 8 字节的堆栈,您需要从堆栈指针中减去 8。)调用返回后,addq $8, %rsp将堆栈恢复到原始状态。
有了这四个文件,我们就准备好了。要构建示例二进制文件,请运行 eg
reset ; make clean all
Run Code Online (Sandbox Code Playgroud)
运行./static-prog或./dynamic-prog将输出
asm_function(0) returns 'C function', and asm_function(1) returns 'One'.
Run Code Online (Sandbox Code Playgroud)
这两个二进制文件的大小只有 2 kB(静态)和 6 kB(动态)左右,尽管您可以通过剥离不需要的东西使它们更小,
strip --strip-unneeded static-prog dynamic-prog
Run Code Online (Sandbox Code Playgroud)
它从它们中删除了大约 0.5 kB 到 1 kB 不需要的东西——确切的数量取决于你使用的 GCC 和 Binutils 的版本。
在其他一些架构上,我们还需要链接 libgcc(通过-lgcc),因为某些 C 功能依赖于内部 GCC 函数。各种体系结构上的 64 位整数除法(命名为 udivdi 或类似的)就是一个典型的例子。
正如评论中提到的,上述示例的第一个版本有一些需要解决的问题。他们不会阻止示例按预期执行或工作,并且被忽略了,因为这些示例是为这个答案从头开始编写的(希望其他人稍后通过网络搜索找到这个问题可能会发现这很有用),而我不完美。:)
memory 内联程序集的clobber 参数,在 syscall 预处理器宏中
添加"memory"破坏列表告诉编译器内联程序集可以访问(读取和/或写入)除参数列表中指定的内存之外的内存。显然,写系统调用需要它,但它实际上对所有系统调用都很重要,因为内核可以在从系统调用返回之前在同一线程中传递例如信号,并且信号传递可以/将访问内存。
正如 GCC 文档所提到的,这个破坏者的行为也像编译器的读/写内存屏障(但不是处理器!)。换句话说,通过内存破坏,编译器知道它必须在内联汇编之前将变量等的任何更改写入内存中,以及不相关的变量和其他内存内容(在内联汇编输入、输出或clobbers)也可能会改变,并且会生成我们真正想要的代码,而不会做出错误的假设。
-fPIC -pie: 为简单起见省略
位置无关代码通常只与共享库相关。在实际项目的 Makefile 中,您需要对将被编译为动态库、静态库、动态链接可执行文件或静态可执行文件的对象使用一组不同的编译标志,作为所需的属性(因此编译器/链接器标志)不同。
在这样的例子中,最好尽量避免这些无关紧要的事情,因为这是一个合理的问题(“当需要Y时,使用哪些编译器选项来实现X?”),以及答案取决于所需的功能和上下文。
在大多数现代发行版中,PIE 是默认设置,您可能希望-fno-pie -no-pie简化调试/反汇编。 x86-64 Linux 中不再允许 32 位绝对地址?
-nostdlib 确实暗示(或“包括”) -nostartfiles
我们可以使用很多整体选项和链接选项来控制代码的编译和链接方式。
GCC 支持的许多选项都是分组的。例如,-O2实际上是您可以明确指定的优化功能集合的简写。
在这里,保留两者的原因是提醒人类程序员对代码的期望:没有标准库,也没有起始文件/对象。
-march=x86-64 -mtune=generic -m64 是 x86-64 上的默认值
同样,这更多是作为代码期望的提醒。如果没有特定的体系结构定义,人们可能会产生错误的印象,即代码通常应该是可编译的,因为 C 通常不是特定于体系结构的!
该nolib.h头文件的确包含预处理器检查(使用预先定义的编译器的宏来检测操作系统和硬件体系结构),停止所述汇编与其他操作系统和硬件体系结构的误差。
大多数 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 标签 wikilayout 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 字节和未对齐访问具有不同的指令,对齐访问或某些处理器明显更快。(为什么 System V/AMD64 ABI 要求 16 字节堆栈对齐?)。使用对齐的 SIMD 指令(例如movaps未对齐的地址)会导致进程崩溃。(例如,当从与 RSP 不对齐的函数调用时,glibc scanf 出现分段错误,这是当您出错时会发生什么的真实示例。)
但是,当我们进行此类堆栈操作时,我们确实应该添加 CFI(调用帧信息)指令以确保调试和堆栈展开等工作正常进行。在这种情况下,对于一般 CFI,我们.cfi_startproc在汇编函数中的第一条指令之前,以及汇编函数中.cfi_endproc的最后一条指令之后。对于规范帧地址 CFA,我们.cfi_def_cfa_offset N在修改堆栈指针的任何指令之后添加。本质上,N在函数的开头是 8,随着%rsp减少而增加,反之亦然。有关更多信息,请参阅这篇文章。
在内部,这些指令根据其他编译标志生成存储在ELF 对象文件和二进制文件中的.eh_frame和.eh_frame_hdr节中的信息(元数据)。
因此,在这种情况下, thesubq $8, %rsp后面应该跟.cfi_def_cfa_offset 16, 和addq $8, %rspby .cfi_def_cfa_offset 8,.cfi_startproc在final的开头asm_function和.cfi_endproc之后加上ret。
请注意,您经常可以看到rep ret而不仅仅是rep在程序集源中。这只不过是某些处理器在跳转到或通过 JCC 跳转到ret指令时存在分支预测性能问题的解决方法。该rep前缀不执行任何操作,除非它确实解决这些问题,否则处理器可能有这样的跳跃。最近的 GCC 版本在默认情况下停止执行此操作,因为受影响的 AMD CPU 非常老旧,而且现在不那么相关了。 “rep ret”是什么意思?
"key" 选项, -ffreestanding, 是一个选择C "方言"的选项
C 编程语言实际上分为两种不同的环境:托管和独立。
在托管环境是一个地方标准C库是可用的,当你写的程序,应用程序,或C.守护程序使用
在独立的环境是一个地方的标准C库是无法使用。当您为微控制器或嵌入式系统编写内核、固件、实现(部分)您自己的标准 C 库或某些其他 C 派生语言的“标准库”时,会使用它。
例如,Arduino编程环境基于独立C++的子集。标准 C++ 库不可用,并且不支持 C++ 的许多特性,如异常。事实上,它非常接近带有类的独立 C。该环境还使用了一个特殊的预处理器,例如,它会自动添加函数声明,而无需用户编写它们。
可能最著名的独立 C 示例是 Linux 内核。不仅标准 C 库不可用,而且由于某些硬件考虑,内核代码实际上也必须避免浮点运算。
为了更好地理解独立的 C 环境对于程序员来说到底是什么样的,我认为最好的办法是查看语言标准本身。截至目前(2020 年 6 月),最新的标准是 ISO C18。虽然标准本身不是免费的,但最终草案是;对于 C18,它是草案 N2176 (PDF)。
| 归档时间: |
|
| 查看次数: |
220 次 |
| 最近记录: |