为Linux创建极小的ELF可执行文件(或者,“大小就是一切”)
摘要
本教程演示了如何使用汇编代码和优化技术在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头之后出现,后者出现在文件末尾附近。两个表有相似的目的,都是标识文件的组成部分。然而,节头表更专注于标识程序各部分在文件中的位置,而程序头表描述这些部分如何加载到内存中。简而言之,节头表供编译器和链接器使用,而程序头表供程序加载器使用。程序头表对于目标文件是可选的,实际上从不出现。同样,节头表对于可执行文件也是可选的——但几乎*总是*存在!
所以,这是我们第一个问题的答案。我们程序开销的很大一部分是完全不必要的节头表,以及一些同样无用的、对程序内存映像没有贡献的节。
所以,我们
相似文章
迷你可执行文件再探
本文重新审视了在 Linux 上创建极小 ELF 可执行文件的技术,探讨如何通过滥用头部字段和重叠结构将大小缩减至 45 字节,同时保持与 ELF 规范的兼容性。
Zig ELF 二进制文件代码高尔夫 (2025)
深入技术探讨如何缩小 Zig ELF 二进制文件的大小,从 2180K 缩减至 500 字节以下,通过去除调试信息、切换到 ReleaseSmall 以及使用 freestanding 目标。
HelloAssembly:最小的完整Windows应用程序
该项目旨在使用汇编语言创建最小可能的完整Windows应用程序,并提供优化版本和详细的构建说明。
可执行文件是SQLite数据库
文章探讨了用SQLite替换ELF可执行文件格式,介绍了一个名为SELF的原型,使得可执行文件可以成为SQLite数据库,并讨论了其益处和技术影响。
用 x86_64 汇编写成的 Linux 桌面
一位开发者借助 Claude Code,用纯 x86_64 汇编重建了完整的 Linux 桌面栈——从 shell、终端、窗口管理器到各种工具,实现微秒级启动,并延长数小时续航。