【译】《可执行文件背后的原理》——第1章 可执行文件大小的奥秘

为什么我的Hello World程序如此之大?

在开启 Linux 上的 C 语言编程之旅时,开发人员通常会从最经典的“Hello, World!”程序开始。这是一个入门的仪式,也是踏入编程世界的第一步。然而,这个简单的程序却蕴含着一个迷人的奥秘,我们将在本文中揭开它的神秘面纱: 为什么这样一个小程序编译后会成为一个大得惊人的可执行文件?

我们的起点: 最简单的 C 语言程序

让我们从经典的 “Hello, World!” 程序开始:

#include <stdio.h>

int main() {
    printf("Hello, World!\n");
    return 0;
}

将其保存为 hello.c, 并用 gcc 进行编译:

gcc -o hello hello.c

现在,让我们来看看它的大小:

$ ls -l hello
-rwxr-xr-x 1 user user 16696 Oct 23 10:30 hello

16,696 字节!考虑到我们的源代码仅有 67 字节,这个数字就太惊人了。让我们正确看待这一点:

  • 源代码:67 字节
  • 可执行文件:16,696 字节
  • 比例:可执行文件的大小约为源代码的 249 倍!

ELF 格式简介

在深入了解具体细节之前,重要的是要理解我们的可执行文件采用的是 ELF(可执行和可链接格式)格式,这是 Linux 系统上可执行文件的标准二进制格式。我们将在第 2 章中详细探讨 ELF,但现在让我们先来了解一下它的基本结构。
一个ELF 文件由几个关键组件构成:
1、ELF文件头(ELF Header)
2、 程序头表(译者注:也称段表)(Program Header Table)
3、各种节(Sections)
4、节头表(Section Header Table)
现在,让我们使用 readelf 来查看 ELF头部:

$ readelf -h hello
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                           UNIX - System V
  ABI Version:                       0
  Type:                             DYN (Position-Independent Executable file)
  Machine:                          Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x1060
  Start of program headers:          64 (bytes into file)
  Start of section headers:          14960 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         13
  Size of section headers:           64 (bytes)
  Number of section headers:         31
  Section header string table index: 30

仅这个头部就有 64 个字节!我们将在第二章《ELF:揭秘可执行文件格式》中详细探讨这些字段。

可执行文件:不仅仅是你的代码

在Linux系统上,可执行文件不仅仅是你编译后的C代码的原始转储。相反,它是一个精心组织的结构,包含了操作系统加载和执行程序所必需的各种关键信息段。
这些程序段有着多种用途:

  • 代码段 (.text): 这部分是程序的核心 - 由你的C代码编译后所生成的机器指令. It’s where the printf function call and the loop logic in a more complex program would reside.
  • 数据段 (.data, .rodata, .bss): 这些段存储了程序使用的变量和常量。已初始化的全局变量存放在 .data 中,常量值(如字符串 “Hello, world!”)存储在 .rodata 中,而未初始化的全局变量则被分配到 .bss 段。
  • 头部信息::可执行文件以一个头部开始,它作为操作系统的指南。它包含了程序的关键元数据,例如:
  • 程序设计运行的架构类型(例如,x86-64)。
    *入口点:代码段中的执行起始地址。
  • 段信息:文件中各个段的布局和大小。
  • 符号表::该表在链接过程中起着至关重要的作用(我们将在后续文章中深入探讨)。它将程序中使用的函数和变量的名称映射到可执行文件中的对应地址。这个映射对于解决程序不同部分之间的引用,或者与外部库的链接,至关重要。
  • 重定位信息: 该部分在程序加载到内存时发挥作用。 它包含链接器调整代码中内存地址的指令,确保对函数、变量和数据结构的引用指向正确的位置。
  • 调试信息: 如果你在编译程序时使用了调试符号(例如,使用 gcc 编译时加上 -g 标志),可执行文件还会包含调试信息。这些信息使得调试工具(如 gdb)能够将机器指令与原始的C代码关联起来,从而可以逐行执行程序,并在执行过程中检查变量。

检查各个段

让我们使用 objdump 来查看我们可执行文件中的各个节:

$ objdump -h hello

hello:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .interp       0000001c  0000000000000318  0000000000000318  00000318  2**0
  1 .note.gnu.property 00000030  0000000000000338  0000000000000338  00000338  2**3
  2 .note.gnu.build-id 00000024  0000000000000368  0000000000000368  00000368  2**2
  3 .note.ABI-tag 00000020  000000000000038c  000000000000038c  0000038c  2**2
  4 .gnu.hash     00000024  00000000000003b0  00000000000003b0  000003b0  2**3
  5 .dynsym       000000a8  00000000000003d8  00000000000003d8  000003d8  2**3
  6 .dynstr       0000008c  0000000000000480  0000000000000480  00000480  2**0
  7 .gnu.version  0000000e  000000000000050c  000000000000050c  0000050c  2**1
  8 .gnu.version_r 00000020  0000000000000520  0000000000000520  00000520  2**3
  9 .rela.dyn     000000c0  0000000000000540  0000000000000540  00000540  2**3
 10 .rela.plt     00000018  0000000000000600  0000000000000600  00000600  2**3
 11 .init         00000017  0000000000001000  0000000000001000  00001000  2**2
 12 .plt          00000020  0000000000001020  0000000000001020  00001020  2**4
 13 .plt.got      00000008  0000000000001040  0000000000001040  00001040  2**3
 14 .text         00000195  0000000000001050  0000000000001050  00001050  2**4
 15 .fini         0000000d  00000000000011e8  00000000000011e8  000011e8  2**2
 16 .rodata       00000012  0000000000002000  0000000000002000  00002000  2**4
 17 .eh_frame_hdr 00000044  0000000000002014  0000000000002014  00002014  2**2
 18 .eh_frame     00000108  0000000000002058  0000000000002058  00002058  2**3
 19 .init_array   00000008  0000000000003db8  0000000000003db8  00002db8  2**3
 20 .fini_array   00000008  0000000000003dc0  0000000000003dc0  00002dc0  2**3
 21 .dynamic      000001f0  0000000000003dc8  0000000000003dc8  00002dc8  2**3
 22 .got          00000048  0000000000003fb8  0000000000003fb8  00002fb8  2**3
 23 .data         00000010  0000000000004000  0000000000004000  00003000  2**3
 24 .bss          00000008  0000000000004010  0000000000004010  00003010  2**0

这里有很多段!让我们来分析一下最重要的几部分,并理解它们为什么是必需的。

1. 基本代码节

.text节 (代码)
$ objdump -d hello | grep -A20 '<main>:'
0000000000001129 <main>:
    1129:       55                      push   %rbp
    112a:       48 89 e5                mov    %rsp,%rbp
    112d:       48 8d 05 d1 0e 00 00    lea    0xed1(%rip),%rax
    1134:       48 89 c7                mov    %rax,%rdi
    1137:       e8 f4 fe ff ff          call   1030 <puts@plt>
    113c:       b8 00 00 00 00          mov    $0x0,%eax
    1141:       5d                      pop    %rbp
    1142:       c3                      ret

.text 段包含实际的机器代码。请注意几个有趣的点:
1、我们对 printf的调用已经被优化为 puts(我们将在后续章节中探讨编译器优化)。
2、函数入口和出口处理了栈帧的设置。
3、实际的代码比我们简单的 C 源代码所暗示的要大得多。
我们将在第3章“你的 C 代码所在之处:理解 ELF 段”中更详细地探讨代码段的细节。

.rodata节(只读数据)
$ objdump -s -j .rodata hello
Contents of section .rodata:
 2000 01000200 48656c6c 6f2c2057 6f726c64 ....Hello, World
 2010 2100                                  !.

这个段包含了我们的字符串常量 “Hello, World!” 以及其他只读数据。字符串以空字符结束,并根据系统的要求对齐。

2. 动态链接基础设施

我们的可执行文件需要几个段来支持动态链接:

.interp节
$ readelf -p .interp hello
String dump of section '.interp':
  [     0]  /lib64/ld-linux-x86-64.so.2

这个节指定了将加载我们程序的动态链接器。我们将在第9章“C语言中的动态链接:缩小可执行文件和共享代码”中详细探讨动态链接。

动态符号节
$ readelf -s hello | grep FUNC
     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.2.5 (2)
    12: 0000000000001060    35 FUNC    GLOBAL DEFAULT   14 _start
    14: 0000000000001129    26 FUNC    GLOBAL DEFAULT   14 main~~~~
    [... additional symbols omitted ...]

这些节(.dynsym,.dynstr)包含了我们使用的来自共享库的函数信息。符号表的作用将在第7章“符号:链接器的地址簿”中详细介绍。

3. 运行时支持段

初始化和终结化
$ readelf -d hello | grep INIT
 0x000000000000000c (INIT)               0x1000
 0x0000000000000019 (INIT_ARRAY)         0x3db8
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)

这些节(.init、.init_array、.fini、.fini_array)负责处理程序的初始化和清理工作。我们将在第4章“ 在 main() 之前:C语言中全局变量的秘密生活”中探讨在调用 main() 之前这些节是如何工作的。

异常处理支持
$ readelf -w hello | grep -A2 ".eh_frame"
  [17] .eh_frame_hdr    PROGBITS         0000000000002014  00002014
       0000000000000044  0000000000000000   A       0     0     4
       [Containing entries for all functions]

.eh_frame 和 .eh_frame_hdr 这两个节用于支持 C++ 的异常处理和栈展开。尽管我们这个简单的C程序并没有使用异常,但之所以包含这些节,是为了支持与 C++ 代码的互操作性,并在程序崩溃时可以提供正确的堆栈回溯信息。

了解大小的贡献因素

让我们来分析一下这些字节的去向:

$ size --format=GNU hello
   text    data     bss     dec     hex filename
   1821     592       8    2421     975 hello

但这只能说明部分问题。让我们来看一个更为详细的视图:

$ size -A hello
hello  :
section              size    addr
.interp               28     792
.note.gnu.property    48     824
.note.gnu.build-id    36     872
[... additional sections ...]
Total               16696

可执行文件大小的主要贡献因素是:

1、核心程序组件 (~2.5KB)

  • 机器代码(.text)
  • 只读数据 (.rodata)
  • 初始化数据 (.data)
  • BSS节占位符 (.bss)
  1. 动态链接支持 (~4KB)

    • 动态符号表
    • 字符串表
    • 全局偏移表(译者注:GOT)
    • 过程链接表(译者注:PLT)
      (我们将在第9章中探讨这些内容)
  2. 运行时支持 (~3KB)

    • 异常处理帧
    • 初始化/终止函数数组
    • 调试信息
  3. 元数据及头部 (~1KB)

    • ELF头
    • 程序头
    • 节头
  4. 对齐填充 (~6KB)

    • 性能和装载效率的要求

我们能把它变小吗??

是的!让我们尝试一些优化技术:

1、基本尺寸优化

$ gcc -Os -o hello_small hello.c
$ strip hello_small
$ ls -l hello_small
-rwxr-xr-x 1 user user 14632 Oct 23 10:35 

-Os标志用于优化尺寸,而strip用于 移除调试信息。

2、静态链接 (仅用于对比)

$ gcc -static -o hello_static hello.c
$ ls -l hello_static
-rwxr-xr-x 1 user user 832632 Oct 23 10:40 hello_static

静态链接实际上会使我们的可执行文件变得更大,因为它直接包含了所有库代码!我们将在第9章探讨静态链接和动态链接之间的权衡。

3、高级优化 (预览)

$ gcc -Os -fdata-sections -ffunction-sections -Wl,--gc-sections -o hello_opt hello.c
$ strip hello_opt
$ ls -l hello_opt
-rwxr-xr-x 1 user user 14120 Oct 23 10:45 hello_opt

此处使用了链接时优化(译者注:LTO)来移除未使用的节。我们将在第 8 章 “自定义布局:链接器脚本简介“中探讨这些技术。

为什么要保留这些“开销 ”?

虽然我们的可执行文件看似臃肿,但每个组件都有其至关重要的作用:

  1. 动态链接支持

    • 实现程序之间的代码共享
    • 促进安全更新
    • 减少内存使用
      (详见第9章)
  2. 运行时基础设施

    • 确保程序正确初始化
    • 优雅地处理错误
    • 支持调试和性能分析
      (在第4章中探讨)
  3. 平台兼容性

    • 确保跨系统加载的一致性
    • 支持各种安全特性
    • 支持高级调试工具
      (在第2章至第13章中详细讨论)

小结

我们对 “Hello, World!” 程序的探索之旅表明,现代可执行文件是一个复杂的容器,它不仅封装了我们的代码,还封装了以下所需的基础设施:

  • 正确加载程序
  • 链接共享库
  • 初始化运行时环境
  • 优雅地处理错误
  • 支持调试和性能分析
  • 确保平台兼容性

在接下来的章节中,我们将深入探讨这些方面:

  • 第 2 章将详细探讨 ELF 格式
  • 第 3 章将研究不同类型代码和数据的组织方式
  • 第 4 章将揭示调用 main() 之前发生的事情
  • 第 5-8 章将介绍链接、符号和内存布局
  • 第 9-12 章将深入探讨动态链接和一些高级主题

理解这些概念使我们能够:

  • 更有效地调试程序
  • 优化可执行文件的大小和加载时间
  • 在链接和加载方面做出明智的决策
  • 编写更高效和更易于维护的代码

准备好深入探索了吗?让我们在第2章“ELF:揭开可执行格式的神秘面纱”中继续探索。

延伸阅读

  • man elf: 关于ELF 格式的详细文档
  • info gcc: gcc手册
  • Linux 文档项目中的程序加载指南

如本文对你有些许帮助,欢迎大佬支持我一下(点赞+收藏+关注、关注公众号等),您的支持是我持续创作的竭动力
支持我的方式

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

花神庙码农

你的鼓励是我码字的最大动力!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值