如果我要用汇编语言编写程序,这个HelloWorld汇编代码的哪些部分是必不可少的?

11

我有这个简短的 Hello World 程序:

#include <stdio.h>

static const char* msg = "Hello world";

int main(){
    printf("%s\n", msg);
    return 0;
}

我使用gcc将其编译成以下汇编代码:

    .file   "hello_world.c"
    .section    .rodata
.LC0:
    .string "Hello world"
    .data
    .align 4
    .type   msg, @object
    .size   msg, 4
msg:
    .long   .LC0
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    pushl   %ebp
    .cfi_def_cfa_offset 8
    .cfi_offset 5, -8
    movl    %esp, %ebp
    .cfi_def_cfa_register 5
    andl    $-16, %esp
    subl    $16, %esp
    movl    msg, %eax
    movl    %eax, (%esp)
    call    puts
    movl    $0, %eax
    leave
    .cfi_restore 5
    .cfi_def_cfa 4, 4
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 4.8.4-2ubuntu1~14.04.3) 4.8.4"
    .section    .note.GNU-stack,"",@progbits
我的问题是:如果我要将此程序写成汇编语言(而不是先用C语言编写,然后编译成汇编语言),这段代码的所有部分是否都是必要的?我理解汇编指令,但有些部分我不理解。例如,我不知道.cfi*是什么,并且想知道是否需要包含它来编写这个程序的汇编版本。

2
.cfi 是汇编器的指令,而不是汇编指令。 - Jabberwocky
您可以删除所有以.cfi开头的行和所有以.type开头的行,以及以.file开头的行。.LFE0以下的所有内容都可以删除。 - Michael Petch
3
如果您使用GCC选项-fno-asynchronous-unwind-tables进行编译,您可能会看到以.cfi开头的那些行被删除。 - Michael Petch
2个回答

15
这个平台上能够正常运行的最基本要求是:
        .globl main
main:
        pushl   $.LC0
        call    puts
        addl    $4, %esp
        xorl    %eax, %eax
        ret
.LC0:
        .string "Hello world"

但这会违反许多ABI要求。 符合ABI的程序的最低要求为

        .globl  main
        .type   main, @function
main:
        subl    $24, %esp
        pushl   $.LC0
        call    puts
        xorl    %eax, %eax
        addl    $28, %esp
        ret
        .size main, .-main
        .section .rodata
.LC0:
        .string "Hello world"

除了编译器未尽可能紧密地优化代码或写入到目标文件的可选注释之外,您对象文件中的所有内容都是这些。

特别是,.cfi_*指令是可选注释。当且仅当函数在C++异常抛出时可能在调用堆栈上时,它们才是必需的,但在任何需要提取堆栈跟踪的程序中都很有用。如果要手动使用汇编语言编写复杂代码,则可能值得学习如何编写它们。不幸的是,它们的文档非常差; 我目前没有找到任何值得链接的东西。

.section    .note.GNU-stack,"",@progbits

如果您手写汇编代码,了解它也很重要;这是另一个可选注释,但非常有价值,因为它的意思是“此目标文件中没有需要堆栈执行的内容”。如果程序中的所有目标文件都具有此注释,则内核将不会将堆栈设置为可执行,从而提高了一些安全性。

(如果要指示需要堆栈可执行,可以使用“x”代替“”。如果使用GCC的“嵌套函数”扩展,可能会这样做。(不要这样做。))

值得一提的是,在GCC和GNU binutils默认使用的“AT&T”汇编语法中,有三种类型的行:以冒号结尾的单个令牌的行是标签。(我不记得标签中可以出现哪些字符的规则。)第一个标记以点开头并且不以冒号结尾的行是汇编器的某种指令。其他任何内容都是汇编指令。


1
具体来说,在应用于Ubuntu 14.04的32位代码中,ABI将是System V i386 ABI,可以在此处找到:https://01.org/sites/default/files/file_attach/intel386-psabi-1.0.pdf。 - Michael Petch
非常好的答案。非常感谢! - Connor
如果您不关心返回0,那么可以使用尾调用简化代码,因为main()有参数,可以在堆栈上替换它们。 - Peter Cordes
1
main(){return puts("Hello World");} 是一个有效的程序。它的退出状态未定义,但它肯定会打印输出。 (我希望puts在成功时返回特定的内容,但是文档只说“非负数”,这可能超出0..255范围,因此我们无法说明程序在x86 System V ABI中的退出状态。)我想我最好在我的回答中添加一个警告 :/ - Peter Cordes
@PeterCordes 好的,我承认这样一个程序在 C 语言中没有“未定义行为”,但我仍然坚持认为不将退出状态设置为确定性且有意义的值是一个严重的错误,这样的程序不能说是“工作”的。 - zwol
显示剩余2条评论

4

相关文章: 如何从GCC/clang汇编输出中去除“噪音”? .cfi 指令对您来说并不直接有用,程序可以在没有它们的情况下工作。(这是异常处理和回溯所需的堆栈展开信息,因此可以默认启用 -fomit-frame-pointer。是的,即使是C语言,gcc也会发出这个指令。)


对于生成Hello World程序所需的汇编源代码行数,显然我们希望使用libc函数来为我们完成更多的工作。

@Zwol的答案具有您原始C代码的最短实现。

如果您不关心程序的退出状态,只想让它打印出您的字符串,那么可以手动执行以下操作:

# Hand-optimized asm, not compiler output
    .globl main            # necessary for the linker to see this symbol
main:
    # main gets two args: argv and argc, so we know we can modify 8 bytes above our return address.
    movl    $.LC0, 4(%esp)     # replace our first arg with the string
    jmp     puts               # tail-call puts.

# you would normally put the string in .rodata, not leave it in .text where the linker will mix it with other functions.
.section .rodata
.LC0:
    .asciz "Hello world"     # asciz zero-terminates

等效的C代码(你只是要求最短的Hello World,而不是具有相同语义的代码):

int main(int argc, char **argv) {
    return puts("Hello world");
}

它的退出状态是实现定义的,但它肯定会打印。puts(3)返回“非负数”,可能超出0..255范围,因此我们不能确定程序在Linux中的退出状态为0 /非零(在这种情况下,进程的退出状态是传递给exit_group()系统调用的整数的低8位(由调用main()的CRT启动代码传递)。

使用JMP实现尾调用是一种标准实践, 在函数不需要在另一个函数返回后执行任何操作时常用。 puts() 最终会返回到调用 main() 的函数中,就像如果 puts() 返回到 main(),然后 main() 返回一样。 main() 的调用者仍然必须处理它为 main() 放在堆栈上的参数,因为它们仍然存在(但已经被修改了,我们可以这样做)。

gcc 和 clang 不会生成修改堆栈上传递参数空间的代码。虽然如此,它仍然是完全安全和ABI兼容的:函数“拥有”它们在堆栈上的参数,即使它们是const类型的。如果你调用一个函数,你不能假设你放在堆栈上的参数仍然存在。要使用相同或类似的参数进行另一个调用,你需要再次存储它们所有。

还要注意,这将以与进入main()时相同的堆栈对齐方式调用puts(),因此我们在保留16B对齐时符合现代x86-32(也称为i386 System V ABI,用于Linux)的ABI。

.string会在字符串末尾加上空字符,与.asciz相同,但我不得不查一下。建议使用.ascii.asciz以确保数据是否有终止字节。(如果与显式长度函数一起使用,如write(),则不需要终止字节)


在x86-64 System V ABI(和Windows)中,参数通过寄存器传递。这使得尾调用优化变得更加容易,因为您可以重新排列参数或传递更多的参数(只要不用完寄存器)。这使得编译器愿意在实践中进行优化。(因为正如我所说,它们目前不喜欢生成修改堆栈中传入参数空间的代码,即使ABI明确允许他们这样做,并且编译器生成的函数确实假设被调用者会破坏其堆栈参数。) 对于x86-64,clang或gcc -O3将进行此优化,您可以在Godbolt编译器浏览器上看到
#include <stdio.h>
int main() { return puts("Hello World"); }

# clang -O3 output
main:                               # @main
    movl    $.L.str, %edi
    jmp     puts                    # TAILCALL

 # Godbolt strips out comment-only lines and directives; there's actually a .section .rodata before this
.L.str:
    .asciz  "Hello World"

静态数据地址总是适合于地址空间的低31位,且可执行文件不需要位置无关代码,否则mov将会是lea .LC0(%rip), %rdi。(如果使用了--enable-default-pie进行配置以生成位置无关可执行文件,则可从gcc中获得此信息。)
如何在GNU Assembler中将函数或标签的地址加载到寄存器中,请参阅此文

使用32位x86 Linux int 0x80系统调用直接输出Hello World,不使用libc库

参见使用Linux系统调用编写汇编语言的Hello World程序? 我在那里的回答最初是为SO文档编写的,后来由于SO文档关闭而转移到这里。它并不真正属于这里,所以我将其移动到另一个问题。


相关链接: Linux下创建微小的ELF可执行文件龙卷风教程。你能运行的最小二进制文件只包含一个退出(exit())系统调用。这是关于最小化二进制文件大小,而不是源文件大小甚至只是实际运行的指令数量。


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