为什么我的Hello World程序如此之大?
- 📚 本书官网及作者联系方式
访问本书网站: Under The Hood Of Executables
联系作者: chessMan786
在开启 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 theprintf
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)
-
动态链接支持 (~4KB)
- 动态符号表
- 字符串表
- 全局偏移表(译者注:GOT)
- 过程链接表(译者注:PLT)
(我们将在第9章中探讨这些内容)
-
运行时支持 (~3KB)
- 异常处理帧
- 初始化/终止函数数组
- 调试信息
-
元数据及头部 (~1KB)
- ELF头
- 程序头
- 节头
-
对齐填充 (~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 章 “自定义布局:链接器脚本简介“中探讨这些技术。
为什么要保留这些“开销 ”?
虽然我们的可执行文件看似臃肿,但每个组件都有其至关重要的作用:
-
动态链接支持
- 实现程序之间的代码共享
- 促进安全更新
- 减少内存使用
(详见第9章)
-
运行时基础设施
- 确保程序正确初始化
- 优雅地处理错误
- 支持调试和性能分析
(在第4章中探讨)
-
平台兼容性
- 确保跨系统加载的一致性
- 支持各种安全特性
- 支持高级调试工具
(在第2章至第13章中详细讨论)
小结
我们对 “Hello, World!” 程序的探索之旅表明,现代可执行文件是一个复杂的容器,它不仅封装了我们的代码,还封装了以下所需的基础设施:
- 正确加载程序
- 链接共享库
- 初始化运行时环境
- 优雅地处理错误
- 支持调试和性能分析
- 确保平台兼容性
在接下来的章节中,我们将深入探讨这些方面:
- 第 2 章将详细探讨 ELF 格式
- 第 3 章将研究不同类型代码和数据的组织方式
- 第 4 章将揭示调用 main() 之前发生的事情
- 第 5-8 章将介绍链接、符号和内存布局
- 第 9-12 章将深入探讨动态链接和一些高级主题
理解这些概念使我们能够:
- 更有效地调试程序
- 优化可执行文件的大小和加载时间
- 在链接和加载方面做出明智的决策
- 编写更高效和更易于维护的代码
准备好深入探索了吗?让我们在第2章“ELF:揭开可执行格式的神秘面纱”中继续探索。
延伸阅读
man elf
: 关于ELF 格式的详细文档info gcc
: gcc手册- Linux 文档项目中的程序加载指南
如本文对你有些许帮助,欢迎大佬支持我一下(点赞+收藏+关注、关注公众号等),您的支持是我持续创作的竭动力
支持我的方式