首页 > 技术文章 > 如何让可执行文件更小?---摘抄过来

winafa 2021-01-07 10:43 原文

原文地址:

https://cjting.me/2020/12/10/tiny-x64-helloworld/#executable

 

正如文中所说,一个简单Hello World竟然编译之后15K,在一些环境下根本接受不了。

本文就是最好从简单原理描述,一个可执行文件是怎么来,哪些地方是可以优化的,哪些地方是必须的,有兴趣的可以仔细看看

 

说明:

本人对此方面最近比较感兴趣,对于我们实际项目(嵌入式为主),的确也存在这方面的需求,因此对此了解了下

也看过几篇其他写的相关文章,能真正比较清除无私写出来,实际上并不多

注意:每个系统,不同的工具链编译出来的效果是不一样的。比如,我是ubuntu18  gcc7.5 编译的,实际原始大小为8K+,比原文的小一半

 

以下是摘抄部分:(未经过实际验证,但感觉应该是可以的。红色字,是我补充的注解)

 

Hello World 应该是每一位程序员的启蒙程序,出自于 Brian Kernighan 和 Dennis Ritchie 的一代经典著作 The C Programming Language。

// hello.c
#include <stdio.h>

int main() {
  printf("hello, world\n");
  return 0;
}

 

这段代码我想大家应该都太熟悉了,熟悉到可以默写出来。虽然是非常简单的代码,但是如果细究起来,里面却隐含着很多细节:

  • #include <stdio.h> 和 #include "stdio.h" 有什么区别?
  • stdio.h 文件在哪里?里面是什么内容?
  • 为什么入口是 main 函数?可以写一个程序入口不是 main 吗?
  • main 的 int 返回值有什么用?是谁在处理 main 的返回值?
  • printf 是谁实现的?如果不用 printf 可以做到在终端中打印字符吗?

上面这些问题其实涉及到程序的编译、链接和装载,日常工作中也许大家并不会在意。

现代 IDE 在方便我们开发的同时,也将很多底层的细节隐藏了起来。往往写完代码以后,点击「构建」就行了,至于构建在发生什么,具体是怎么构建的,很多人并不关心,甚至根本不知道从源代码到可执行程序这中间经历了什么。

编译、链接和装载是一个巨大的话题,不是一篇博客可以覆盖的。在这篇博客中,我想使用「文件尺寸」作为线索,来介绍从 C 源代码到可执行程序这个过程中,所经历的一系列过程。

Tip: 关于编译、链接和装载,这里想推荐一本书《程序员的自我修养》。不得不说,这个名字起得非常不好,很有哗众取宠的味道,但是书的内容是不错的,值得一看。

我们先来编译上面的程序:

$ gcc hello.c -o hello
$ ./hello
hello, world
$ ll hello
-rwxr-xr-x 1 root root 16712 Nov 24 10:45 hello
Tip: 后续所有的讨论都是基于 64 位 CentOS7 操作系统。
 

我们会发现这个简单的 hello 程序大小为 16K。在今天看来,16K 真的没什么,但是考虑到这个程序所做的事情,它真的需要 16K 吗?

在 C 诞生的上个世纪 70 年代,PDP-11 的内存为 144K,如果一个 hello world 就要占 16K,那显然是不合理的,一定有办法可以缩减体积。

 
Tip:

说起 C 语言,我想顺带提一下 UNIX。没有 C 就没有 UNIX 的成功,没有 UNIX 的成功也就没有 C 的今天。诞生于上个世纪 70 年代的 UNIX 不得不说是一项了不起的创造。

这里推荐两份关于 UNIX 的资料:

  • The UNIX Time-Sharing System 1974 年由 Dennis Ritchie 和 Ken Thompson 联合发表的介绍 UNIX 的论文。不要被“论文”二字所吓到,实际上,这篇文章写得非常通俗易懂,由 UNIX 的作者们向你娓娓道来 UNIX 的核心设计理念。

  • The UNIX Operating System 一段视频,看身着蓝色时尚毛衣的 Kernighan 演示 UNIX 的特性,不得不说,Kernighan 简直太帅了。

接下来我们来玩一个游戏,目标是:在 CentOS7 64 位操作系统上,编写一个体积最小的打印 hello world 的可执行程序。

Executable

我们先来看「可执行程序」这个概念。

什么是可执行程序?按照字面意思来理解,那就是:可以执行的程序。

ELF

上面用 C 编写的 hello 当然是可执行程序,毫无疑问。

实际上,我们可以说它是真正的“可执行”程序(区别于后文的脚本),或者说“原生”程序。

因为它里面包含了可以直接用于 CPU 执行的机器代码,它的执行无需借助外部。

hello 的存储格式叫做 ELF,全称为 Executable and Linkable Format,看名称可以知道,它既可以用于存储目标文件,又可以用于存储可执行文件。

ELF 本身并不难理解,/usr/include/elf.h 中含有 ELF 结构的详细信息。难理解的是由 ELF 所掀开的底层世界,目标文件是什么?和执行文件有什么区别?链接在干什么?目标文件怎样变成可执行文件等等等等。

Shebang

接下来我们来看另外一种形式的可执行程序,脚本。

$ cat > hello.sh <<EOF
#!/bin/bash
echo "hello, world"
EOF
$ chmod +x hello.sh
$ ./helo.sh
hello, world

按照定义,因为这个脚本可以直接从命令行执行,所以它是可执行程序。

那么 hello 和 hello.sh 的区别在哪里?

可以发现 hello.sh 的第一行比较奇怪,这是一个叫做 Shebang 的东西 #!/bin/bash,这个东西表明当前文件需要 /bin/bash 程序来执行。

所以,hello 和 hello.sh 的区别就在于:一个可以直接执行不依赖于外部程序,而另一个需要依赖外部程序。

我曾经有一个误解,认为 Shebang 是 Shell 在处理,当 Shell 执行脚本时,发现第一行是 Shebang,然后调用相应的程序来执行该脚本。

实际上并不是这样,对 Shebang 的处理是内核在进行。当内核加载一个文件时,会首先读取文件的前 128 个字节,根据这 128 个字节判断文件的类型,然后调用相应的加载器来加载。

比如说,内核发现当前是一个 ELF 文件(ELF 文件前四个字节为固定值,称为魔数),那么就调用 ELF 加载器。

而内核发现当前文件含有 Shebang,那么就会启动 Shebang 指定的程序,将当前路径作为第一个参数传入。所以当我们执行 ./hello.sh 时,在内核中会被变为 /bin/bash ./hello.sh

这里其实有一个小问题,如果要脚本可以从命令行直接执行,那么第一行必须是 Shebang。Shebang 的形式固定为 #! 开头,对于使用 # 字符作为注释的语言比如 Python, Ruby, Elixir 来说,这自然不是问题。但是对于 # 字符不是注释字符的语言来说,这一行就是一个非法语句,必然带来解释错误。

比如 JavaScript,它就不使用 # 作为注释,我们来写一个带 Shebang 的 JS 脚本看看会怎么样。

$ cat <<EOF > test.js
#!/usr/bin/env node
console.log("hello world")
EOF
$ chmod +x test.js
$ ./test.js
hello world

并没有出错,所以这里是怎么回事?按道理来说第一行是非法的 JS 语句,解释器应该要报错才对。

如果把第一行的 Shebang 拷贝一份到第二行,会发现报了 SyntaxError,这才是符合预期的。所以必然是 Node 什么地方对第一行的 Shebang 做了特别处理,否则不可能不报错。

大家可以在 Node 的代码里面找一找,看看在什么地方

推荐阅读