p { margin-bottom: 0.21cm;
如果你是一个很自大的程序员,那么你可能发现本文是完美的灵丹妙药。
这篇文档探索用于减少简单程序中过量的字节数。当然,本文的更实用的目的是描述 ELF 文件格式和 Linux 操作系统的一些内部工作原理。但是在这个过程中你很有希望也学会怎么制作超小型 ELF 可执行文件。
请注意这里给出的信息和示例绝大部分是针对运行的 intel-386 架构上的 Linux 平台的 ELF 可执行文件。我认为这里的很多信息同样适用于其他基于 ELF 的 UNIX 变体,但是我这方面的经验确实有限,所以不敢保证确实如此。
另外,如果一点都不懂汇编代码,你可能会觉得本文的一些部分有点难懂。本文中使用的汇编大马是 Nasm 风格的,参考 http://www.nasm.us/ 。
在开始探索之前,我们需要一个程序。几乎任何程序都行,但是我们对于到底能将应用程序变得有多小而不是这个程序有什么功用感兴趣,所以它应该越简单越好。
接下来我们来做一个非常简单的程序,它仅仅返回一个数字给操作系统。
这是该程序的初始版:
/* tiny.c */
int main(void) { return 42; }
编译命令:
$ gcc -Wall tiny.c
$ ./a.out ; echo $?
42
那么编译后产生的可执行文件有多大呢?,在我自己的机器上如下:
$ wc -c a.out
3998 a.out
第一步当然是用 strip 命令参数来对可执行我嫩见瘦身:
$ gcc -Wall -s tiny.c
$ ./a.out ; echo $?
42
$ wc -c a.out
2632 a.out
明显有所提高。下一步,试试代码优化如何?
$ gcc -Wall -s -03 tiny.c
$ wc -c a.out
2616 a.out
这也有所帮助,但是效果一般般。不难理解:程序里确实没有什么可以优化的。
看起来对于压缩一个只有一行语句的 C 程序的可执行文件大小似乎没什么可以做的了。我们将不得不扔下 C ,使用汇编代码来代替。这很有希望除掉 C 程序自动造成的额外空间消耗。
所以,在我们的第二版中,我们将返回值 42 放在 eax 寄存器中然后返回:
; 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 字节。
嗯,关键还在于我们使用了 main() 接口并因此导致大量的额外空间消耗。连接器仍然会为我们往操作系统中添加一个接口,就是这个接口真正调用 main() 。那么,如果不用这个接口,我们怎样来绕开呢?
连接器默认使用的真实入口点是符号 _start 。当我们用 gcc 链接时,它自动包含例程 _start ,这个例程会建立传入参数 argc 和 argv ,还做一些其他事情,然后调用 main() 。
我们试试定义自己的例程 _start ,看能否绕开这点:
; tiny.asm
BITS 32
GLOBAL _start
SECTION .text
_start:
mov eax, 42
ret
编译一下试试:
$ 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 。注释如下:
-nostartfiles
在链接时不使用标准的系统 startup 文件。标准库仍然正常使用。
现在我们再试试:
$ 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() 会结束进程中的一些其他任务,但是这些任务还没有被启动,因为我们绕过了类库的 startup 代码。
接下来再试试 _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
几乎让可执行文件的大小减半了!