第二章 进程加载启动原理
在上⼀章中我们介绍了使⽤ fork 来创建进程,不过这种⽅式下创建出来的进程所使⽤的代码和数据都和创建它的进程⼀样。所以只适⽤于 Nginx 这种 master 创建 worker,所有进程所使⽤的代码都是⼀样的应⽤场景。
那对于创建出来的新进程如果使⽤的是不同的代码呢,⽐如下⾯这个最简单的 Hello World 程序来举例,如何把它跑起来,显然只⽤我们前⾯讲过的 fork 是不⾏的。
我们知道,在写完代码后,进⾏简单的编译,然后在 shell 命令⾏下就可以把它启动起来。
那我们来思考⼏个问题:
- 编译链接后⽣成的可执⾏程序是⻓什么样⼦的?
- shell 是如何将这个程序是如何执⾏起来的?
- 程序的⼊⼝是我们熟知的
main函数吗?
今天就让我们来深⼊地探寻⼀下,学习完本章后你将对上⾯⼏个程序加载相关的问题会有深⼊理解。
2.1 可执⾏⽂件格式
源代码在编译后会⽣成⼀个可执⾏程序⽂件,我们先来了解⼀下编译后的⼆进制⽂件是什么样⼦的。
接下来我们分⼏个⼩节挨个介绍⼀下。接下来的⼩节中,我会⽤ file、readelf 来带⼤家查看⼀个具体的可执⾏⽂件。⽂中的可执⾏⽂件是我写的⼀个简单的 helloworld 程序,它在书的配套源码 chapter-02/test01 中。简单编译⼀下。
#include <stdio.h>
int main()
{
printf("Hello, World!\n");
return 0;
}# gcc main.c -o helloworld
# ./helloworld
Hello, World!这个代码⾮常简单,你也⾃⼰写⼀个也可以。或者直接使⽤你⼿头任何其它的可执⾏⽂件都⾏。
我们⾸先使⽤ file 命令查看⼀下这个可执⾏⽂件的格式。
# gcc main.c -o helloworld
# file helloworld
helloworld: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), ...file 命令给出了这个⼆进制⽂件的概要信息,其中 ELF 64-bit LSB executable 表示这个⽂件是⼀个 ELF 格式的 64 位的可执⾏⽂件。 ELF 的全称是 Executable Linkable Format,是⼀种⼆进制⽂件格式。Linux 下的⽬标⽂件、可执⾏⽂件和 CoreDump 都按照该格式进⾏存储。LSB 的全称是 Linux Standard Base,是 Linux 标准规范。其⽬的是制定⼀系列标准来增强 Linux 发⾏版的兼容性。x86-64 表示该可执⾏⽂件⽀持的 CPU 架构。
ELF ⽂件由四部分组成,分别是 ELF ⽂件头(ELF header)、Program header table、Section 和 Section header table。
NOTE
原始⼆进制⾮常不便于观察。不过我们有趁⼿的⼯具 ——
readelf,这个⼯具可以帮我们查看 ELF ⽂件中的各种信息。
接下来我们详细看每⼀个部分。
2.1.1 ELF ⽂件头
ELF ⽂件头记录了整个⽂件的属性信息。我们使⽤ readelf 的 --file-header(-h)选项即可查看。
# readelf --file-header helloworld
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: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x401040
Start of program headers: 64 (bytes into file)
Start of section headers: 23264 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 11
Size of section headers: 64 (bytes)
Number of section headers: 30
Section header string table index: 29ELF ⽂件头包含了当前可执⾏⽂件的概要信息,我把其中关键的⼏个拿出来给⼤家解释⼀下。
- Magic:⼀串特殊的识别码,主要⽤于外部程序快速地对这个⽂件进⾏识别,快速地判断⽂件类型是不是 ELF。
- Class:表示这是 ELF64 ⽂件。
- Type:为
EXEC表示是可执⾏⽂件,其它⽂件类型还有REL(可重定位的⽬标⽂件)、DYN(动态链接库)、CORE(系统调试 coredump ⽂件)。 - Entry point address:程序⼊⼝地址,这⾥显⽰⼊⼝在
0x401040位置处。 - Size of this header:ELF ⽂件头的⼤⼩,这⾥显⽰是占⽤了 64 字节。
以上⼏个字段是 ELF 头中对 ELF 的整体描述。另外 ELF 头中还有关于 program headers 和 section headers 的描述信息。
- Start of program headers:表示 Program header 的位置。
- Size of program headers:每⼀个 Program header ⼤⼩。
- Number of program headers:总共有多少个 Program header。
- Start of section headers:表示 Section header 的开始位置。
- Size of section headers:每⼀个 Section header 的⼤⼩。
- Number of section headers:总共有多少个 Section header。
2.1.2 Program Header Table
NOTE
ELF ⽂件内部最重要的组成单位是⼀个⼀个的 Section。每⼀个 Section 都是由编译链接器⽣成的,都有不同的⽤途。例如编译器会将我们写的代码编译后放到 .text Section 中,将全局变量放到 .data 或者是 .bss Section 中。
但是对于操作系统来说,它不关注具体的 Section 是啥,它只关注这块内容应该以何种权限加载到内存中,例如读,写,执⾏等权限属性。因此相同权限的 Section 可以放在⼀起组成 Segment,以⽅便操作系统更快速地加载。
NOTE
由于 Segment 和 Section 翻译成中⽂的话,意思太接近了,⾮常不利于理解。所以本⽂中我就直接使⽤ Segment 和 Section 原汁原味的概念,⽽不是将它们翻译成段或者是节,这样太容易让⼈混淆了。
Program headers table 就是作为所有 Segments 的头信息,⽤来描述所有的 Segments 的。
使⽤ readelf ⼯具的 --program-headers(-l)选项可以解析查看到这块区域⾥存储的内容。
# readelf --program-headers helloworld
Elf file type is EXEC (Executable file)
Entry point 0x401040
There are 11 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040
0x0000000000000268 0x0000000000000268 R 0x8
INTERP 0x00000000000002a8 0x00000000004002a8 0x00000000004002a8
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x0000000000000438 0x0000000000000438 R 0x1000
LOAD 0x0000000000001000 0x0000000000401000 0x0000000000401000
0x00000000000001c5 0x00000000000001c5 R E 0x1000
LOAD 0x0000000000002000 0x0000000000402000 0x0000000000402000
0x0000000000000138 0x0000000000000138 R 0x1000
LOAD 0x0000000000002e10 0x0000000000403e10 0x0000000000403e10
0x0000000000000220 0x0000000000000228 RW 0x1000
DYNAMIC 0x0000000000002e20 0x0000000000403e20 0x0000000000403e20
0x00000000000001d0 0x00000000000001d0 RW 0x8
NOTE 0x00000000000002c4 0x00000000004002c4 0x00000000004002c4
0x0000000000000044 0x0000000000000044 R 0x4
GNU_EH_FRAME 0x0000000000002014 0x0000000000402014 0x0000000000402014
0x000000000000003c 0x000000000000003c R 0x4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x0000000000002e10 0x0000000000403e10 0x0000000000403e10
0x00000000000001f0 0x00000000000001f0 R 0x1
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr
.gnu.version .gnu.version_r .rela.dyn .rela.plt
03 .init .plt .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .dynamic .got .got.plt .data .bss
06 .dynamic
07 .note.gnu.build-id .note.ABI-tag
08 .eh_frame_hdr
09
10 .init_array .fini_array .dynamic .got上⾯的结果显示总共有 11 个 program headers。对于每⼀个段,输出了 Type、Offset、VirtAddr、FileSiz、Flags 等描述当前段的信息。其中:
- Offset:表示当前段在⼆进制⽂件中的开始位置。
- VirtAddr:表示加载到虚拟内存中后的地址。
- FileSiz:表示当前段的⼤⼩。
- Flag:表示当前的段的权限类型,
R表示可读、E表示可执⾏、W表示可写。
在最下⾯,还把每个段是由哪⼏个 Section 组成的给展示了出来,⽐如 03 号段是由 .init .plt .text .fini 四个 Section 组成的。
其中段的 Type 类型虽然包括有 PHDR、INTERP、LOAD、DYNAMIC、NOTE、GNU_EH_FRAME、GNU_STACK、GNU_RELRO 等多种,但只有 LOAD 是需要被加载到内存中供运⾏时使⽤的。
2.1.3 Section Header Table
和 Program Header Table 不⼀样的是,Section header table 直接描述每⼀个 Section。这⼆者描述的其实都是各种 Section ,只不过⽬的不同,⼀个针对加载,⼀个针对链接。
使⽤ readelf ⼯具的 --section-headers(-S)选项可以解析查看到这块区域⾥存储的内容。
# readelf --section-headers helloworld
There are 30 section headers, starting at offset 0x5b10:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
......
[13] .text PROGBITS 0000000000401040 00001040
0000000000000175 0000000000000000 AX 0 0 16
......
[23] .data PROGBITS 0000000000404020 00003020
0000000000000010 0000000000000000 WA 0 0 8
[24] .bss NOBITS 0000000000404030 00003030
0000000000000008 0000000000000000 WA 0 0 1
......
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), oC (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
```c
// 函数代码经过编译链接后会放到.text中
void somefunc()
{
...
}
int main(void)
{
...
}回忆前⾯我们在 ELF ⽂件头中看到 Entry point address 显示的⼊⼝地址为 0x401040.这说明,程序的⼊⼝地址就是 .text 段的地址.
另外两个值得关注的 Section 是 .data 和 .bss.代码中的全局变量数据在编译后将在在这两个 Section 中占据⼀些位置.如下简单代码所示.
// 未初始化的内存区域位于 .bss 段
int data1 ;
// 已经初始化的内存区域位于 .data 段
int data2 = 100 ;
int main(void)
...2.1.5 ⼊⼝进⼀步查看
接下来,我们想再查看⼀下我们前⾯提到的程序⼊⼝ 0x401040,看看它到底是啥.我们这次再借助 nm 命令来进⼀步查看⼀下可执⾏⽂件中的符号及其地址信息.-n 选项的作⽤是显⽰的符号以地址排序,⽽不是名称排序.
# nm -n helloworld
w __gmon_start__
U __libc_start_main@@GLIBC_2.2.5
U printf@@GLIBC_2.2.5
......
0000000000401040 T _start
......
0000000000401126 T main通过以上输出可以看到,程序⼊⼝ 0x401040 指向的是 _start 函数的地址.这是 glibc 提供的函数,是 glibc 的源码中实现的,是⼀个汇编函数.在本书的前⾔中我提供了 glibc 的源码下载地址.我们找到它的源码,它⼀开始执⾏了⼀些寄存器和栈操作,让后进⼊到 __libc_start_main 进⾏启动过程.
// file:sysdeps/x86_64/elf/start.S
.text
.globl _start
.type _start,@function
_start:
...
/* Extract the arguments as encoded on the stack and set up
the arguments for __libc_start_main (int (*main) (int, char **, char **),
int argc, char *argv,
void (*init) (void), void (*fini) (void),
void (*rtld_fini) (void), void *stack_end).
The arguments are passed via registers and on the stack:
main: %rdi
argc: %rsi
argv: %rdx
init: %rcx
fini: %r8
rtld_fini: %r9
stack_end: stack. */
...
movq $__libc_csu_fini, %r8
movq $__libc_csu_init, %rcx
movq $BP_SYM (main), %rdi
call BP_SYM (__libc_start_main)_start 函数中作了⼀些准备⼯作,将 argc、argv、程序的构造函数 __libc_csu_init、析构函数 __libc_csu_fini 和 main 函数都通过参数传递给 __libc_start_main.
是的,没错,并不只是 C++ 才有构造析构的概念,这在 C 中也是存在的,含义是⼀样的.其中在 __libc_csu_init 执⾏的时候,会调⽤⼀个 _do_global_ctors_aux 函数执⾏所有全局对象的构造,也会建⽴打开⽂件表初始化标准输⼊输出流.
接着就进⼊到 __libc_start_main.
在 __libc_start_main 的源码开头看到了这样⼀⾏注释“The main work is done in the generic function”,也就是说主要⼯作都是在 generic_start_main 中完成的.
//file:sysdeps/powerpc/elf/libc-start.c
/* The main work is done in the generic function. */
#define LIBC_START_MAIN generic_start_main
int BP_SYM (__libc_start_main) (...)
{
...
return generic_start_main (stinfo->main, argc, ubp_av, auxvec,
stinfo->init, stinfo->fini, rtld_fini,
stack_on_entry);
}// file:sysdeps/generic/libc-start.c
STATIC int
LIBC_START_MAIN (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
int argc, char *__unbounded *__unbounded ubp_av,
ElfW(auxv_t) *__unbounded auxvec,
__typeof (main) init,
void (*init) (void),
void (*fini) (void),
void (*rtld_fini) (void), void *__unbounded stack_end)
{
// 注册退出析构函数
if (__builtin_expect (rtld_fini != NULL, 1))
__cxa_atexit ((void (*) (void *)) rtld_fini, NULL, NULL);
if (fini)
__cxa_atexit ((void (*) (void *)) fini, NULL, NULL);
...
// 初始化
if (init)
(*init) (
#ifdef INIT_MAIN_ARGS
argc, argv, __environ MAIN_AUXVEC_PARAM
#endif
);
// 真正进⼊main函数处理
result = main (argc, argv, __environ MAIN_AUXVEC_PARAM);
exit (result);
}LIBC_START_MAIN 是⼀个宏,实际上就是 generic_start_main。这个函数的参数中接收了 main 函数、初始化函数 init、析构函数 fini 等为参数。对初始化函数进⾏调⽤,对退出析构函数进⾏了注册。最后才进⼊了我们所熟知的 main 函数。另外当 exit 被调⽤的时候,各种析构函数会被调⽤执⾏。
好了,⼏个关键的部分我们都看完了,这个 helloworld 程序的⼤概总体结构是下⾯这个样⼦的。我把它画出来,⽅便你有更形象的理解。
[此处应有示意图,描述 helloworld 程序的总体结构:ELF Header → Program Header Table (Segments) → Sections (.text, .data, .bss 等) → Section Header Table;以及程序入口 _start → __libc_start_main → main 的调用关系]