汇编语言和C语言编写的Hello World程序执行指令数量不同

5
我有一个简单的调试器(使用ptrace:http://pastebin.com/D0um3bUi),用于计算给定输入可执行程序的指令执行次数。它使用ptrace单步执行模式来计算指令。
因此,当将程序1)的可执行文件(gcc main.c生成的a.out)作为测试调试器的输入时,它打印出大约十万条指令执行结果。当我使用-static选项时,它会输出10681个指令。
现在,在2)中,我创建了一个汇编程序并使用NASM进行编译和链接,然后将此可执行文件作为测试调试器的输入,它显示8条指令计数,这是正确的。
程序1)中执行的指令数量很高,是因为运行时将程序与系统库链接起来了吗?使用了-static,将其计数减少了1/10。如何确保指令计数仅限于程序1)中的主要功能,并且这就是程序2)为调试器报告的方式?
2)
#include <stdio.h>

int main()
{
    printf("Hello, world!\n");
    return 0;
}    

我使用gcc创建可执行文件。
2)
; 64-bit "Hello World!" in Linux NASM

global _start            ; global entry point export for ld

section .text
_start:

    ; sys_write(stdout, message, length)

    mov    rax, 1        ; sys_write
    mov    rdi, 1        ; stdout
    mov    rsi, message    ; message address
    mov    rdx, length    ; message string length
    syscall

    ; sys_exit(return_code)

    mov    rax, 60        ; sys_exit
    mov    rdi, 0        ; return 0 (success)
    syscall

section .data
    message: db 'Hello, world!',0x0A    ; message and newline
    length:    equ    $-message        ; NASM definition pseudo-                             

我使用以下技术构建:

nasm -f elf64 -o main.o -s main.asm  
ld -o main main.o

2
为什么不进行公正比较,在C程序中调用write()而不是printf()。此外,如果您反汇编可执行文件(例如objdump -d [executable]),您实际上可以看到在main()之前运行的一些代码。 - EOF
是的。当然,那是一个更精细的点。 - crackerplace
不确定为什么有人想要关闭这个。 - crackerplace
为什么一个简单的五行Hello World程序需要如此多(数千条)的指令? - Matt
2个回答

6
彼得给出了一个非常好的答案,我将跟进一个令人尴尬的回复,可能会引起一些负面评价。当直接使用LD或间接使用GCC进行链接时,ELF可执行文件的默认入口点是标签_start
你的NASM代码使用了全局标签_start,因此当你运行程序时,程序中的第一条指令将是_start的指令。在使用GCC时,程序的典型入口点是函数main。隐藏在你背后的是,你的C程序也有一个_start标签,但它是由C运行时启动对象提供的。
问题是:有没有办法绕过C启动文件,避免启动代码?从技术上讲是可以的,但这是危险的领域,可能会产生未定义的行为。如果你很有冒险精神,实际上你可以告诉GCC使用命令行选项-e来改变程序的入口点。我们可以将入口点从_start更改为main,从而绕过C启动代码。既然我们绕过了C启动代码,我们也可以使用-nostartfiles选项来省略链接C运行时启动代码。
你可以使用以下命令行编译你的C程序:
gcc test.c -e main -nostartfiles

很不幸,在C代码中有一个小问题需要解决。通常在使用C运行时启动对象时,环境初始化后会调用main函数。通常main函数执行RET指令返回到C运行时代码。此时,C运行时优雅地退出程序。当使用-nostartfiles选项时,RET指令没有任何返回地址,因此可能会导致段错误。为了解决这个问题,我们可以调用C库的_exit函数来退出程序。
#include <stdio.h>

int main()
{
    printf("Hello, world!\n");
    _exit(0);  /* We exit application here, never reaching the return */

    return 0;
}   

除非省略帧指针,否则GCC会发出一些额外的指令来设置和撤销堆栈帧,但开销很小。
特别说明:上述过程似乎不适用于使用标准glibc C库进行静态构建(GCC中的-static选项)。这在此Stackoverflow答案中有所讨论。动态版本有效是因为共享对象可以注册一个函数,由动态加载器调用以执行初始化。在静态构建时,通常由C运行时完成该操作,但我们跳过了该初始化。因此,像printf之类的GLIBC函数可能会失败。有一些符合标准的替代C库可以在没有C运行时初始化的情况下运行。其中一个产品是MUSL
安装MUSL作为GLIBC的替代方案:
在Ubuntu 64位上,以下命令应该可以构建并安装64位版本的MUSL
git clone git://git.musl-libc.org/musl
cd musl
./configure --prefix=/usr/local/musl/x86-64
make
sudo make install

您可以使用MUSL封装器来与GCC一起使用MUSLC库,而不是在大多数Linux发行版上使用默认的GLIBC库。参数与GCC类似,因此您应该能够执行以下操作:

/usr/local/musl/x86-64/bin/musl-gcc -e main -static -nostartfiles test.c

当使用GLIBC生成的./a.out运行时,很可能会出现段错误。 MUSL在使用大多数C库函数之前不需要初始化,因此即使使用-static GCC选项,它也应该可以工作。

更公平的比较

你比较过程中的一个问题是,在NASM中直接调用SYS_WRITE系统调用,在C中你使用printf。用户EOF正确指出,您可能想通过在C中调用write函数而不是printf来使比较更加公平。 write的开销要少得多。 您可以修改代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    char *str = "Hello, world\n";
    write (STDOUT_FILENO, str, 13);
    _exit(0);
    return 0;
}

这将比NASM的直接SYS_WRITE系统调用产生更多的开销,但远不及printf生成的开销。


我要提醒的是,除了一些边缘软件开发案例外,这种代码和诡计可能不会在代码审查中得到认可。


在返回之前,确保准确无误。 - crackerplace
@PeterA.Schneider:我在SO上遇到了一些纯粹主义者,他们不喜欢推广可能存在问题的编程实践。这也是我在这篇文章中到处都加了警告的主要原因。;-) - Michael Petch
1
@PeterCordes:我还应该指出,我并不是试图真正回答原始问题。你的答案已经足够了(这就是我的序言)。我赞同了你的答案。我的意图只是处理某人可能会考虑跳过C运行时初始化并潜在地尝试使用“-static”的情况。我在SO上只能找到部分解决方案,所以想着我有一些脑细胞可以消耗;-) - Michael Petch
1
谢谢。它与musl一起工作,并且指令计数大幅减少,约为300,听起来很好。还得到了这个链接,它扩展了汇编http://cs.lmu.edu/~ray/notes/nasmtutorial/ - crackerplace
1
@whokares:刚看到你的评论,很高兴你尝试了MUSL进行实验。 - Michael Petch
显示剩余12条评论

6
“程序1”的指令执行次数很高,是因为在运行时将程序与系统库链接了吗?
是的,动态链接加上CRT(C运行时)启动文件。
使用了“-static”选项,将计数减少了十分之一。
所以只剩下了CRT启动文件,在调用main之前和之后执行一些操作。
如何确保程序1中指令计数仅为主函数的计数?
测量一个空的main函数,然后从未来的测量结果中减去该数字。
除非您的指令计数器更智能,并查看要跟踪进程的可执行文件中的符号,否则它将无法确定代码来自哪里。
这就是“程序2”向调试器报告的方式。
那是因为那个程序中没有其他代码。不是你以某种方式帮助调试器忽略了一些指令,而是你制作了一个没有任何你没有自己放置的指令的程序。
如果您想查看运行gcc输出时实际发生的情况,请使用“gdb a.out”,“b _start”,“r”和单步执行。一旦您深入调用树,您可能会想要使用“fin”来完成当前函数的执行,因为您不希望逐步执行文字量达到100万条指令,甚至是1万条。

相关: 如何确定C程序中执行的x86机器指令数量?显示perf stat将在静态可执行文件中计算一个NASM程序中总共3个用户空间指令,该程序执行mov eax, 231 / syscall


另一个要点是将程序1编译为汇编程序,然后与GCC链接也没有减少指令计数,这理论上应该会减少,因为它与程序2相同。 - crackerplace
@whokares:哈哈,如果你通过显式使用 gcc -O3 -S 然后汇编/链接来获得更高效的程序,那么gcc已经这样做了。gcc已经内部生成了asm输出,并将其输入到 as 中。重点在于asm源文件的内容,而不是它是asm文件的事实。如果你没看懂,应该在Stack Overflow上搜索相关问题。 - Peter Cordes
hmm。理解您的意思,但从gcc内部来看,它有从预处理到汇编再到可执行文件等步骤。不过第二个程序的.s文件源码与此有些相似。无论如何,我会重新检查的。 - crackerplace

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接