【问题标题】:gcc subtracting from esp before callgcc 在调用之前从 esp 中减去
【发布时间】:2017-09-21 14:46:00
【问题描述】:

我打算用 C 写一个小内核,我真的不希望它因不必要的指令而膨胀。

我有两个 C 文件,分别称为 main.chello.c。我使用以下 GCC 命令编译并链接它们:

gcc -Wall -T lscript.ld -m16 -nostdlib main.c hello.c -o main.o

我正在使用以下 OBJDUMP 命令转储 .text 部分:

objdump -w -j .text -D -mi386 -Maddr16,data16,intel main.o

并获得以下转储:

00001000 <main>:
    1000:   67 66 8d 4c 24 04       lea    ecx,[esp+0x4]
    1006:   66 83 e4 f0             and    esp,0xfffffff0
    100a:   67 66 ff 71 fc          push   DWORD PTR [ecx-0x4]
    100f:   66 55                   push   ebp
    1011:   66 89 e5                mov    ebp,esp
    1014:   66 51                   push   ecx
    1016:   66 83 ec 04             sub    esp,0x4
    101a:   66 e8 10 00 00 00       call   1030 <hello>
    1020:   90                      nop
    1021:   66 83 c4 04             add    esp,0x4
    1025:   66 59                   pop    ecx
    1027:   66 5d                   pop    ebp
    1029:   67 66 8d 61 fc          lea    esp,[ecx-0x4]
    102e:   66 c3                   ret    
00001030 <hello>:
    1030:   66 55                   push   ebp
    1032:   66 89 e5                mov    ebp,esp
    1035:   90                      nop
    1036:   66 5d                   pop    ebp
    1038:   66 c3                   ret  

我的问题是:为什么会生成以下行的机器代码? 可以看到减法和加法是互相补全的,但是为什么会生成呢?我没有要在堆栈上分配的任何变量。我会很感激有一个资源来了解 ECX 的用法。

1016:   66 83 ec 04             sub    esp,0x4
1021:   66 83 c4 04             add    esp,0x4

main.c

extern void hello();


void main(){
    hello();
}

你好.c

void hello(){}

lscript.ld

SECTIONS{

    .text 0x1000 : {*(.text)}
}

【问题讨论】:

  • -O0 产生的无关操作很难说。它肯定比其他任何东西都更能反映编译器内部。尝试(至少)-Og
  • 因为它确实如此(gcc 源代码是数千行......其中一些确实产生了这个,可能是一些关于堆栈框架或调试的潜在脚手架,最终没有被使用)。你为什么在乎?
  • 然后命令它删除不必要的指令,开启优化。但是你必须正确定义所有“外部影响”部分,所以没有什么重要的东西被优化为多余的,我认为这可能比编写普通应用程序 C 更难(有明显的 I/O 操作告诉编译器传播哪些结果“外部”),虽然内核的“输出”对优化器来说可能不太明显,但可能需要一些 volatile 在这里和那里,或者其他在机器上获得正确效果的方法(你也可能想要调整一些内部选项朝着更小的代码大小)。
  • 前几行(加上push ecx)是为了确保堆栈在 Linux i386 ABI 所需的 16 字节边界上对齐。 ret 之前的 pop ecxlea 在 main 中用于撤消该对齐工作。如果您要创建一个名为 main 以外的入口点,您将看不到它。您还应该使用优化进行编译(如-O3)。我强烈建议您使用 GCC 交叉编译器(例如 i686-elf-gcc),而不是主机环境的 GCC 工具链。\

标签: c gcc assembly x86 stack


【解决方案1】:

正如我在我的 cmets 中提到的:

前几行(加上 push ecx)是为了确保堆栈在 Linux System V i386 ABI 要求的 16 字节边界上对齐。 ret 之前的 pop ecxlea 在 main 中用于撤消该对齐工作。

@RossRidge 提供了一个指向另一个 Stackoverflow answer 的链接,其中详细说明了这一点。

在这种情况下,您似乎在进行实模式开发。 GCC 不太适合这个,但它可以工作,我假设你知道你在做什么。我在这个Stackoverflow answer 中提到了使用-m16 的一些陷阱。我在那个关于使用 GCC 的实模式开发的答案中提出了这个警告:

这样做有很多陷阱,我建议不要这样做。


如果您保持不动并希望继续前进,您可以做一些事情来最小化代码。在进行函数调用时堆栈的 16 字节对齐是更新的Linux System V i386 ABIs 的一部分。由于您正在为非 Linux 环境生成代码,因此您可以使用编译器选项 -mpreferred-stack-boundary=2 将堆栈对齐更改为 4。 GCC manual 说:

-mpreferred-stack-boundary=num

尝试保持堆栈边界与 2 提升到 num 字节边界对齐。如果未指定 -mpreferred-stack-boundary,则默认值为 4(16 字节或 128 位)。

如果我们将它添加到您的 GCC 命令中,我们会得到 gcc -Wall -T lscript.ld -m16 -nostdlib main.c hello.c -o main.o -mpreferred-stack-boundary=2:

00001000 <main>:
    1000:       66 55                   push   ebp
    1002:       66 89 e5                mov    ebp,esp
    1005:       66 e8 04 00 00 00       call   100f <hello>
    100b:       66 5d                   pop    ebp
    100d:       66 c3                   ret

0000100f <hello>:
    100f:       66 55                   push   ebp
    1011:       66 89 e5                mov    ebp,esp
    1014:       66 5d                   pop    ebp
    1016:       66 c3                   ret

现在所有在 16 字节边界上的额外对齐代码都消失了。我们留下了典型的函数帧指针序言和结尾代码。这通常采用push ebpmov ebp,esp pop ebp 的形式。我们可以使用GCC manual 中的-fomit-frame-pointer 定义删除这些:

选项 -fomit-frame-pointer 删除所有可能使调试更困难的函数的帧指针。

如果我们添加该选项,我们会得到 gcc -Wall -T lscript.ld -m16 -nostdlib main.c hello.c -o main.o -mpreferred-stack-boundary=2 -fomit-frame-pointer:

00001000 <main>:
    1000:       66 e8 02 00 00 00       call   1008 <hello>
    1006:       66 c3                   ret

00001008 <hello>:
    1008:       66 c3                   ret

然后您可以使用-Os 优化大小。 GCC 手册是这样说的:

-Os

针对大小进行优化。 -Os 启用通常不会增加代码大小的所有 -O2 优化。它还执行旨在减少代码大小的进一步优化。

这有一个副作用,main 将被放入一个名为 .text.startup 的部分中。如果我们同时显示objdump -w -j .text -j .text.startup -D -mi386 -Maddr16,data16,intel main.o,我们会得到:

Disassembly of section .text:

00001000 <hello>:
    1000:       66 c3                   ret

Disassembly of section .text.startup:

00001002 <main>:
    1002:       e9 fb ff                jmp    1000 <hello>

如果您在单独的对象中有函数,您可以更改调用约定,以便前 3 个 Integer 类参数在寄存器而不是堆栈中传递。 Linux 内核也使用这种方法。这方面的信息可以在GCC documentation

regparm(数字)

在 Intel 386 上,如果参数在寄存器 EAX、EDX 和 ECX 中而不是堆栈中是整数类型,则 regparm 属性会导致编译器将参数从 1 传递到编号。接受可变数量参数的函数将继续在堆栈上传递其所有参数。

我用 __attribute__((regparm(3))) 的代码写了一个 Stackoverflow 答案,这可能是进一步信息的有用来源。


其他建议

我建议您考虑单独编译每个对象,而不是全部编译。这也是有利的,因为以后可以更轻松地在Makefile 中完成。

如果我们使用上面提到的额外选项查看您的命令行,您将拥有:

gcc -Wall -T lscript.ld -m16 -nostdlib main.c hello.c -o main.o \
    -mpreferred-stack-boundary=2 -fomit-frame-pointer -Os

我建议你这样做:

gcc -c -Os -Wall -m16 -ffreestanding -nostdlib -mpreferred-stack-boundary=2 \
    -fomit-frame-pointer main.c -o main.o
gcc -c -Os -Wall -m16 -ffreestanding -nostdlib -mpreferred-stack-boundary=2 \
    -fomit-frame-pointer hello.c -o hello.o

-c 选项(我将其添加到开头)强制编译器仅从源文件生成目标文件而不执行链接。您还会注意到-T lscript.ld 已被删除。我们在上面创建了.o 文件。我们现在可以使用 GCC 将所有这些链接在一起:

gcc -ffreestanding -nostdlib -Wl,--build-id=none -m16 \
    -Tlscript.ld main.o hello.o -o main.elf

-ffreestanding 将强制链接器不使用 C 运行时,-Wl,--build-id=none 将告诉编译器不要在可执行文件中为构建注释生成一些噪音。为了让它真正起作用,您需要一个稍微复杂一点的链接器脚本,将.text.startup 放在.text 之前。该脚本还添加了.data 部分、.rodata.bss 部分。 DISCARD 选项删除异常处理数据和其他不需要的信息。

ENTRY(main)
SECTIONS{

    .text 0x1000 : SUBALIGN(4) {
        *(.text.startup);
        *(.text);
    }
    .data : SUBALIGN(4) {
        *(.data);
        *(.rodata);
    }
    .bss : SUBALIGN(4) {
        __bss_start = .;
        *(COMMON);
        *(.bss);
    }
    . = ALIGN(4);
    __bss_end = .;

    /DISCARD/ : {
        *(.eh_frame);
        *(.comment);
        *(.note.gnu.build-id);
    }
}

如果我们查看带有objdump -w -D -mi386 -Maddr16,data16,intel main.elf 的完整OBJDUMP,我们会看到:

Disassembly of section .text:

00001000 <main>:
    1000:       e9 01 00                jmp    1004 <hello>
    1003:       90                      nop

00001004 <hello>:
    1004:       66 c3                   ret

如果您想将main.elf 转换为可以放入磁盘映像并读取它的二进制文件(即通过 BIOS 中断 0x13),您可以这样创建它:

objcopy -O binary main.elf main.bin

如果您使用ndisasm -b16 -o 0x1000 main.bin 转储main.binNDISASM,您会看到:

00001000  E90100            jmp word 0x1004
00001003  90                nop
00001004  66C3              o32 ret

交叉编译器

我不能强调这一点,但您应该考虑使用 GCC 交叉编译器。 OSDev Wiki 有关于建造一号的信息。这也说明了原因:

为什么需要交叉编译器?

除非您在自己的操作系统上进行开发,否则您需要使用交叉编译器。编译器必须知道正确的目标平台(CPU、操作系统),否则你会遇到麻烦。如果您使用系统附带的编译器,那么编译器将不知道它正在完全编译其他东西。一些教程建议使用您的系统编译器并将许多有问题的选项传递给编译器。这肯定会在未来给你带来很多问题,解决办法就是构建一个交叉编译器。

【讨论】:

  • 对我来说,OSDev Wiki 摘录听起来有点像 FUD。确切地说,交叉编译器会提供哪些您无法从“有问题的选项”-ffreestanding -nostdlib(当然还有适当的目标架构)中得到的东西?我的意思是,我想如果您正在开发自己的可执行格式,那么您将需要一个交叉编译器,但这有点愚蠢。即使重新发明轮子,也不必重新发明整辆汽车。
  • @CodyGray :过去已知主机平台的libgcc 需要主机平台的用户空间功能,而通用精灵目标libgcc 不依赖于主机。这取决于编译器以及您的代码可能使用的依赖于libgcc 的功能。
猜你喜欢
  • 1970-01-01
  • 2020-07-06
  • 1970-01-01
  • 1970-01-01
  • 2015-08-16
  • 2018-08-07
  • 2015-08-23
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多