为Linux创建极小的ELF可执行文件(或者,“大小就是一切”)

Hacker News Top 新闻

摘要

本教程演示了如何使用汇编代码和优化技术在Linux上创建最小的ELF可执行文件,以减小文件大小。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/08/30 06:41

# 创建极小ELF可执行文件的快速指南 来源:https://www.muppetlabs.com/~breadbox/software/tiny/teensy.html (或称"大小*就是*一切") --- > 她仔细研究了大约15分钟。终于开口说道:"这上面写着东西,"她皱着眉说,"但字小得几乎看不见。" \[戴夫·巴里,《专栏作家的诡计》\] 如果你是一位对软件膨胀感到厌倦的程序员,那么希望本文能为你提供完美的解药。 本文探讨了如何从简单程序中挤出多余字节的方法。(当然,本文更实际的目的是描述ELF文件格式和Linux操作系统的部分内部工作原理。但希望你在此过程中也能学到如何创建真正微小的ELF可执行文件。) 请注意,这里提供的信息和示例大多针对运行在Intel x86架构上的Linux平台ELF可执行文件。我推测其中很多信息也适用于其他基于ELF的Unix系统,但由于经验有限,我无法确定。 另外请注意,如果你对汇编代码不太熟悉,可能会觉得本文部分内容有些难懂。(本文中出现的汇编代码使用Nasm编写;参见http://www.nasm.us/。) --- 要开始,我们需要一个程序。几乎任何程序都可以,但程序越简单越好,因为我们更关心如何让可执行文件尽可能小,而非程序的功能。 让我们从一个极其简单的程序开始,一个除了向操作系统返回数字外什么都不做的程序。有何不可?毕竟Unix已经自带了至少两个这样的程序:true和false。既然0和1已被占用,我们就用数字42吧。 所以,这是我们第一版程序: `` /* tiny.c */ int main(void) { return 42; } `` 我们可以这样编译和测试: `` $ gcc -Wall tiny.c $ ./a.out ; echo $? 42 `` 那么,它有多大呢?在我的机器上,结果是: `` $ wc -c a.out 3998 a.out `` (你的结果可能会有些不同。)诚然,按当今标准这已经很小了,但几乎可以肯定它比实际需要的要大。 显然第一步是剥离可执行文件: `` $ gcc -Wall -s tiny.c $ ./a.out ; echo $? 42 $ wc -c a.out 2632 a.out `` 这当然是个进步。下一步,试试优化? `` $ gcc -Wall -s -O3 tiny.c $ wc -c a.out 2616 a.out `` 这也有些帮助,但效果不大。这很合理:因为程序本身几乎没什么可优化的。 似乎我们对这个单语句C程序没什么可做的了。我们将不得不放弃C语言,改用汇编。希望这样能去除C程序自动产生的所有额外开销。 那么,进入我们的第二版。我们需要做的只是从main()返回42。在汇编语言中,这意味着函数应将累加器eax设置为42,然后返回: `` ; tiny.asm BITS 32 GLOBAL main SECTION .text main: mov eax, 42 ret `` 然后我们可以这样构建和测试: `` $ nasm -f elf tiny.asm $ gcc -Wall -s tiny.o $ ./a.out ; echo $? 42 `` (嘿,谁说汇编代码难了?)现在它有多大? `` $ wc -c a.out 2604 a.out `` 看起来我们只减少了微不足道的12字节。C语言自动产生的那些额外开销,不过如此,对吧? 问题在于我们仍然通过main()接口产生了大量开销。链接器仍在为我们添加与操作系统交互的接口,正是这个接口实际调用了main()。如果我们不需要它,该如何绕过? 链接器默认使用名为_start的符号作为实际入口点。使用gcc链接时,它会自动包含一个_start例程,该例程设置argc和argv等,然后调用main()。 所以,让我们看看能否绕过这个,定义自己的_start例程: `` ; tiny.asm BITS 32 GLOBAL _start SECTION .text _start: mov eax, 42 ret ``` gcc会按我们的意愿做吗? `` $ nasm -f elf tiny.asm $ gcc -Wall -s tiny.o tiny.o(.text+0x0): multiple definition of `_start' /usr/lib/crt1.o(.text+0x0): first defined here /usr/lib/crt1.o(.text+0x36): undefined reference to `main' `` 不会。好吧,实际上它会,但首先我们需要学会如何提出要求。 gcc有一个选项叫-nostartfiles。从gcc信息页: > -nostartfiles 链接时不使用标准系统启动文件。标准库照常使用。 啊哈!现在看看我们能做什么: `` $ nasm -f elf tiny.asm $ gcc -Wall -s -nostartfiles tiny.o $ ./a.out ; echo $? Segmentation fault 139 `` gcc没有报错,但程序不工作。出错了? 错误在于我们把_start当作C函数来对待,试图从中返回。实际上,它根本不是函数。它只是目标文件中的一个符号,链接器用它来定位程序入口点。当我们的程序被调用时,它是被直接调用的。如果我们检查,会看到栈顶的值是数字1,这肯定不像地址。实际上,栈上的是我们程序的argc值。之后是argv数组的元素,包括结束的NULL元素,然后是envp的元素。仅此而已。栈上没有返回地址。 那么,_start如何退出?嗯,它调用exit()函数!毕竟这就是它的作用。 实际上,我撒了谎。它真正做的是调用_exit()函数。(注意开头的下划线。)exit()需要为进程完成一些任务,但由于我们绕过了库的启动代码,这些任务从未开始。所以我们还需要绕过库的关闭代码,直接调用操作系统的关机处理。 让我们再试一次。我们将调用_exit(),这是一个接受单个整数参数的函数。所以我们需要做的就是将数字压入栈并调用该函数。(还需要声明_exit()为外部函数。)这是我们的汇编代码: `` ; tiny.asm BITS 32 EXTERN _exit GLOBAL _start SECTION .text _start: push dword 42 call _exit ``` 像之前一样构建和测试: `` $ nasm -f elf tiny.asm $ gcc -Wall -s -nostartfiles tiny.o $ ./a.out ; echo $? 42 `` 终于成功了!现在它有多大? `` $ wc -c a.out 1340 a.out `` 几乎减半!不错。相当不错。嗯...gcc还有什么其他有趣的冷门选项? 嗯,这个选项紧随-nostartfiles之后出现在文档中,肯定引人注目: > -nostdlib 链接时不使用标准系统库和启动文件。只有你指定的文件才会传递给链接器。 这值得研究: `` $ gcc -Wall -s -nostdlib tiny.o tiny.o(.text+0x6): undefined reference to `_exit' `` 哎呀。没错..._exit()毕竟是一个库函数。必须从某处获取它。 好吧。但肯定不需要libc的帮助来结束程序,对吧? 不,我们不需要。如果我们愿意放弃所有可移植性的伪装,就可以让程序退出而无需链接其他任何东西。首先,我们需要知道如何在Linux下进行系统调用。 --- Linux和大多数操作系统一样,通过系统调用为其托管的程序提供基本必需功能。这包括打开文件、读写文件句柄——当然,还有终止进程。 Linux系统调用接口是一条指令:int 0x80。所有系统调用都通过此中断完成。进行系统调用时,eax应包含指示调用哪个系统调用的数字,其他寄存器用于保存参数(如果需要)。如果系统调用有一个参数,它会在ebx中;有两个参数的系统调用将使用ebx和ecx。同样,如果需要第三个、第四个或第五个参数,将分别使用edx、esi和edi。系统调用返回时,eax将包含返回值。如果发生错误,eax将包含一个负值,其绝对值表示错误。 不同系统调用的编号列在/usr/include/asm/unistd.h中。快速查看可知exit系统调用的编号是1。与C函数一样,它接受一个参数,即返回给父进程的值,因此这个值会放入ebx。 我们现在掌握了创建下一版程序所需的所有知识,这版程序将不需要任何外部函数的帮助: `` ; tiny.asm BITS 32 GLOBAL _start SECTION .text _start: mov eax, 1 mov ebx, 42 int 0x80 ``` 开始: `` $ nasm -f elf tiny.asm $ gcc -Wall -s -nostdlib tiny.o $ ./a.out ; echo $? 42 ``` 瞧!大小呢? `` $ wc -c a.out 372 a.out `` 现在*这*才叫小!几乎是前一版的四分之一! 那么...我们还能做什么让它更小? 使用更短的指令怎么样? 如果我们为汇编代码生成列表文件,会看到: `` 00000000 B801000000 mov eax, 1 00000005 BB2A000000 mov ebx, 42 0000000A CD80 int 0x80 `` 嗯,我们不需要初始化整个ebx,因为操作系统只会使用最低字节。只设置bl就足够了,这会用两个字节而不是五个。 我们也可以通过将eax异或清零然后使用单字节递增指令来将其设置为一;这样会再节省两个字节。 `` 00000000 31C0 xor eax, eax 00000002 40 inc eax 00000003 B32A mov bl, 42 00000005 CD80 int 0x80 `` 我想可以相当有把握地说,我们无法让这个程序变得更小了。 顺便说一句,我们不妨停止使用gcc来链接可执行文件,因为我们没有用到它添加的功能,直接调用链接器ld: `` $ nasm -f elf tiny.asm $ ld -s tiny.o $ ./a.out ; echo $? 42 $ wc -c a.out 368 a.out `` 小了四字节。(嘿!我们不是减少了五字节吗?嗯,确实如此,但ELF文件内的对齐考虑使其需要额外的一个填充字节。) 那么...我们到终点了吗?这就是我们能达到的最小程度了吗? 嗯,我们的程序现在长七字节。ELF文件真的需要361字节的开销吗?这个文件里到底有什么? 我们可以用objdump查看文件内容: `` $ objdump -x a.out | less ``` 输出可能看起来像乱码,但现在让我们只关注节列表: `` Sections: Idx Name Size VMA LMA File off Algn 0 .text 00000007 08048080 08048080 00000080 2**4 CONTENTS, ALLOC, LOAD, READONLY, CODE 1 .comment 0000001c 00000000 00000000 00000087 2**0 CONTENTS, READONLY ``` 完整的.text节被列为七字节长,正如我们指定的。因此可以安全地得出结论:我们现在完全控制了程序的机器语言内容。 但还有另一个名为".comment"的节。谁要*这个*?而且它有28字节长!我们可能不确定这个.comment节是什么,但很可能它不是必需功能... .comment节被列为位于文件偏移量00000087(十六进制)处。如果使用十六进制转储程序查看文件该区域,会看到: `` 00000080: 31C0 40B3 2ACD 8000 5468 6520 4E65 7477 1.@.*...The Netw 00000090: 6964 6520 4173 7365 6D62 6C65 7220 302E ide Assembler 0. 000000A0: 3938 0000 2E73 796D 7461 6200 2E73 7472 98...symtab..str `` 好吧,好吧,好吧。谁能想到Nasm会这样破坏我们的追求?也许我们应该改用gas,尽管它是AT&T语法... 唉,如果我们改用gas: `` ; tiny.s .globl _start .text _start: xorl %eax, %eax incl %eax movb $42, %bl int $0x80 ``` ...会发现: `` $ gcc -s -nostdlib tiny.s $ ./a.out ; echo $? 42 $ wc -c a.out 368 a.out ``` ...没区别! 嗯,实际上有些区别。再次使用objdump,我们看到: `` Sections: Idx Name Size VMA LMA File off Algn 0 .text 00000007 08048074 08048074 00000074 2**2 CONTENTS, ALLOC, LOAD, READONLY, CODE 1 .data 00000000 0804907c 0804907c 0000007c 2**2 CONTENTS, ALLOC, LOAD, DATA 2 .bss 00000000 0804907c 0804907c 0000007c 2**2 ALLOC `` 没有comment节,但现在有两个用于存储不存在数据的无用节。即使这些节是零字节长,它们也产生了开销,毫无理由地增加了文件大小。 好吧,那么这些开销到底是什么,我们如何消除它们? 要回答这些问题,我们必须开始深入一些真正的魔法。我们需要理解ELF格式。 --- 描述Intel-386架构ELF格式的权威文档可以在http://refspecs.linuxbase.org/elf/elf.pdf找到。(你也可以在http://www.muppetlabs.com/~breadbox/software/ELF.txt找到该标准1.0版本的纯文本版本。)该规范涵盖了很多内容,所以如果你不想自己阅读全部,我能理解。基本上,我们需要知道: 每个ELF文件都以一个称为ELF头的结构开始。这个结构长52字节,包含描述文件内容的若干信息。例如,前十六字节包含一个"标识符",其中包括文件的魔数签名(7F 45 4C 46),以及一些单字节标志,指示内容是32位还是64位、小端序还是大端序等。ELF头中的其他字段包含诸如:目标架构;ELF文件是可执行文件、目标文件还是共享库;程序的起始地址;以及程序头表和节头表在文件中的位置。 这两个表可以出现在文件的任何位置,但通常前者紧跟在ELF头之后出现,后者出现在文件末尾附近。两个表有相似的目的,都是标识文件的组成部分。然而,节头表更专注于标识程序各部分在文件中的位置,而程序头表描述这些部分如何加载到内存中。简而言之,节头表供编译器和链接器使用,而程序头表供程序加载器使用。程序头表对于目标文件是可选的,实际上从不出现。同样,节头表对于可执行文件也是可选的——但几乎*总是*存在! 所以,这是我们第一个问题的答案。我们程序开销的很大一部分是完全不必要的节头表,以及一些同样无用的、对程序内存映像没有贡献的节。 所以,我们

相似文章

迷你可执行文件再探

Hacker News Top

本文重新审视了在 Linux 上创建极小 ELF 可执行文件的技术,探讨如何通过滥用头部字段和重叠结构将大小缩减至 45 字节,同时保持与 ELF 规范的兼容性。

Zig ELF 二进制文件代码高尔夫 (2025)

Lobsters Hottest

深入技术探讨如何缩小 Zig ELF 二进制文件的大小,从 2180K 缩减至 500 字节以下,通过去除调试信息、切换到 ReleaseSmall 以及使用 freestanding 目标。

可执行文件是SQLite数据库

Hacker News Top

文章探讨了用SQLite替换ELF可执行文件格式,介绍了一个名为SELF的原型,使得可执行文件可以成为SQLite数据库,并讨论了其益处和技术影响。

用 x86_64 汇编写成的 Linux 桌面

Lobsters Hottest

一位开发者借助 Claude Code,用纯 x86_64 汇编重建了完整的 Linux 桌面栈——从 shell、终端、窗口管理器到各种工具,实现微秒级启动,并延长数小时续航。