我打算用 C 编写一个小内核,我真的不希望它因不必要的指令而膨胀。
我有两个名为main.c
和hello.c
的 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();
}
的hello.c
void hello(){}
lscript.ld
SECTIONS{
.text 0x1000 : {*(.text)}
}
答案 0 :(得分:10)
正如我在评论中提到的那样:
前几行(加上push ecx)是为了确保堆栈在Linux System V i386 ABI所需的16字节边界上对齐。主要
pop ecx
之前的lea
和ret
是撤消对齐工作的。
@RossRidge提供了另一个Stackoverflow answer的链接,详细说明了这一点。
在这种情况下,您似乎正在进行真正的模式开发。 GCC 并不适合这个,但它可以工作,我会假设你知道你在做什么。我在这个Stackoverflow answer中提到了使用-m16
的一些陷阱。我在关于使用 GCC 的实模式开发的答案中提出了这个警告:
这样做有很多陷阱我建议反对。
如果你仍未被阻止并希望继续前进,你可以做一些事情来最小化代码。在进行函数调用时,堆栈的16字节对齐是最近Linux System V i386 ABIs
的一部分。由于您要为非Linux环境生成代码,因此可以使用编译器选项-mpreferred-stack-boundary=2
将堆栈对齐更改为4。 GCC manual说:
-mpreferred堆栈边界= 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 ebp
和mov 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上,如果regparm属性在寄存器EAX,EDX和ECX中是整数类型而不是在堆栈上,则regparm属性会使编译器将第一个参数传递给数字。采用可变数量参数的函数将继续传递给堆栈中的所有参数。
我用代码使用__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
如果使用main.bin
使用 NDISASM 转储ndisasm -b16 -o 0x1000 main.bin
,您会看到:
00001000 E90100 jmp word 0x1004
00001003 90 nop
00001004 66C3 o32 ret
我不能强调这一点,但你应该考虑使用 GCC 交叉编译器。 OSDev Wiki有关于构建一个的信息。它还有这个说明原因:
为什么我需要交叉编译器?
除非您在自己的操作系统上进行开发,否则您需要使用交叉编译器。编译器必须知道正确的目标平台(CPU,操作系统),否则会遇到麻烦。如果您使用系统附带的编译器,那么编译器就不会知道它正在完全编译其他内容。一些教程建议使用您的系统编译器并将许多有问题的选项传递给编译器。这肯定会在将来给你带来很多问题,解决方案就是构建一个交叉编译器。