本文属于学习笔记,内容或为半成品或存在较多谬误,仅供参考。
Linux 系统上的可执行文件,分为 静态链接 的可执行文件及 动态链接 的可执行文件。
- 静态链接 的可执行文件:这类程序在执行时,不会载入任何动态库,而是直接执行其底下对应的汇编代码,不会走 Loader 等高级语言编译产物的标准过程。这类静态链接可执行文件,现已不多见。
- 动态链接 的可执行文件:只要是由高级语言编译出来的程序,一般都是 动态链接 的可执行文件,这是因为:高级语言编译出来的程序一般需要标准库的支持 (如 libc),这些标准库在系统上以动态库形式提供。
链接器 (Linkers)
链接就是将可执行文件与库文件绑定在一起的过程。这种绑定由专门的程序负责,叫做 “链接器 (Linker)”。
Linux 提供了两种 Linker。他们的行为和作用不同,有一定合作关系,不应混淆。
- ld 链接器:在编译时(compile-time)由编译器执行。
- 编译的时候 gcc、clang 等编译器会执行 ld,并给它传递一系列链接参数,比如指定需要链接什么库。
- 链接时,ld 通常按照一定顺序查找需要链接的静态/动态库:
- ld 运行参数
L
指定的库路径; - 环境变量
LIBRARY_PATH
指定的库搜索路径; - 默认库搜索路径:/lib/ 及 /usr/lib/
- ld 运行参数
- ld 的可执行文件通常在
/usr/bin/ld
。
- ld.so 链接器:在程序运行时(run-time)按需自动执行。
- ld.so 是动态链接器/加载器。如果可执行文件内有链接到指定的库的需求,那么程序运行的时候会调用 ld.so 根据指定的路径去加载指定的库。
- ld.so 看名字虽然像一个 so 库,但它是 可执行的。在 GNU/Linux 发行版系统上,ld.so 可执行文件通常在
/usr/lib/x86_64-linux-gnu/ld-[version].so
。
一句话概括他俩的区别:ld 链接器 用于将静态库嵌入可执行文件,并将运行时依赖的动态库文件路经记录在可执行文件中;ld.so 链接器 用于在 运行时 加载可执行文件依赖的动态库。
一个动态链接的 C++ 程序的执行过程
动态链接和静态链接程序执行过程有一定的区别。例如,动态链接的程序必须具有 interpreter / loader,而静态程序则不必。
为什么这里以动态链接的 C++ 程序为例?因为我们使用 C++ 编写出来的程序,只要是有 main() 函数的,就一定动态链接了标准 C 库(libc.so)。因此属于动态链接的程序。
执行细节(步骤 1 - 11):
首先,当你执行一个程序的时候,比如这个程序名为 app:
- 你会在 shell 中输入:
./app
- shell 会 fork 一个子进程出来,并将该程序 mmap 到内存中;然后,它会执行 Linux 系统调用
execve()
(见后面介绍)。调用之后,系统会在这个子进程中为运行这个程式设置栈,并且将 argc,argv 和 envp 压入栈中; execve()
会扫描程序的 ELF 头,检查其是否是合格的 ELF 文件格式,检查其所属的 ELF 种类,并从 .interp section 中找到该二进制程序的 interpreter / loader。一般而言,如果在 link 的时候未有指定,那么程序的默认 interpreter/loader 是/usr/lib/ld-[version].so
,简称ld.so
。
- 你会在 shell 中输入:
找到
ld.so
后,系统先将 ld.so mmap 到内存中,并将指令指针改成ld.so
的 入口地址,然后切换回用户态,执行 ld.so 内部的代码。ld.so 的主要功能及工序是:
载入 ELF 文件 .dynamic section 的
DT_NEEDED
记录的程序所使用的共享库(如 libgcc.so)。- 这个 .dynamic section 的内容可通过 readelf 或 ldd 命令查看(可看后文介绍:怎么查看 ELF 文件中的某个 section?怎么样查看 ELF 可执行程序/库的所有依赖项目(如 so 文件)?)
- 加载过程是递归的,即加载某个共享库所依赖的其他共享库的时候其实也是由对应的 loader (一般也是 ld.so) 来完成的
初始化 GOT 表
将共享库的 symbol table 合并到 global symbol table
启动应用程序
- 当 DT_NEEDED 所记录的所有依赖的共享库加载完毕后,ld.so 会进行一些其他初始化(太复杂了… TODO)
- 所有初始化完毕后,ld.so 的启动例程会
jmp
回到程序原本的入口点。入口点的地址由 Linker 记录在 ELF 头中:
1
2
3(base) han@han-dell:~$ readelf -h ./app | grep Entry
Entry point address:0x4010c0- 如果不在 ld 时显式指定(比如使用 ld -e _begin 可以记录使用 _begin 作为入口点),入口点默认是
_start
符号所对应的地址。
_start
:应用程序真正的入口点- 系统默认从程序的 _start 开始执行。事实上,使用 C/C++ 编写及编译的程序,_start 是已经被 C 标准库实现的例程,使用者不必手动实现它。
- _start 例程的实现在 /usr/lib/x86_64-linux-gnu/crt1.o (C Runtime)中提供。大多数平台上,_start 是使用 x86 汇编书写的程序 (可查看源码)
1
2
3
4
5
6
7
8
9
10
11
12(base) han@han-bd-dell:~$ nm /usr/lib/x86_64-linux-gnu/crt1.o
0000000000000000 D __data_start
0000000000000000 W data_start
0000000000000030 T _dl_relocate_static_pie
U _GLOBAL_OFFSET_TABLE_
0000000000000000 R _IO_stdin_used
U __libc_csu_fini
U __libc_csu_init
U __libc_start_main
U main
0000000000000000T _start- 由用户自行提供应用程序的入口点也是可行的。在编译时加入参数 -nostdlib -nostdinc,可以不将 C 标准库链入程序,这样用户就可以自己定义 _start 函数了。
- 由代码可见,标准 C 库的_start 除了能够调用 libc.so 中的 __libc_start_main 来执行用户定义的 main() 函数之外,还会
- 做一些底层的初始化,如准备寄存器,准备 Cache、MMU,确保程序执行符合 ABI。
- 初始化 C Runtime
__libc_start_main
:由 _start 调用并由标准 C 库提供,主要初始化 C 程序的运行环境(_init()),并执行那些在 main 开始之前/结束之后要完成的过程,例如:- 程序级构造函数、析构函数
- 初始化在 .rss section 中记录的尚未初始化的变量,例如一些静态变量及一些全局作用域的变量。
- 上述各种构造过程会被添加到一个 array 中,由 __libc_csu_init 函数真正执行初始化。
__libc_csu_init
:初始化 C 运行环境并执行 init array 中的构造子:- 由 __libc_csu_init 的代码 可知,该函数主要 执行 _init (),并 loop 一个 init array,挨个执行其中的函数。
- init array 中的过程由编译器提供,视乎编译器的不同具有各种命名及实现。例如在 gcc 中,这种初始化过程可命名为 ___cxx_global_var_init。
_init
:由 __libc_csu_init 执行__GLOBAL_sub_I_xxx
构造子:由 __libc_csu_init 执行,用于初始化全局作用域变量___cxx_global_var_init
:由 __GLOBAL_sub_I_xxx 执行,是 clang 生成的全局变量初始化函数__attribute__((constructor))
类型的构造子:由 __libc_csu_init 执行,是程序级构造函数、析构函数(此处省略了一大堆其他初始化过程)__libc_csu_init 结束执行后,由 __libc_start_main 执行 main() 函数。main 函数的返回值直接传入 exit() 函数中,跳回 _start 例程来处理。
1
2exit(main(argc, argv));
参考资料
- How programs get run: ELF binaries
- 2. main函数和启动例程
- A General Overview of What Happens Before main() - Embedded Artistry
- Analyzing The Simplest C++ Program
- 程序执行流程 - CTF Wiki
- Linux X86 程序启动 – main函数是如何被执行的? – 落木萧萧的博客
Tricks
怎么查看 ELF 文件中的某个 section?
使用 readelf
命令。例如,想要查看 .dynamic section,可使用 “readelf -d ./app”
1 | (base) han@han-bd-dell:~$ readelf -d ./app |
怎么样查看 ELF 可执行程序/库的所有依赖项目(如 so 文件)?
ldd [app/lib name] – 可获取程序/库所需的【所有】依赖
1 | (base) han@han-bd-dell:~$ldd ./app |
我怎么在 main() 函数执行之前及之后执行我的函数?
方式 1: 利用静态构造/析构在 main() 之外的特性:
1 | namespace { |
方式 2: 利用程序级构造、析构函数。
1 |
|
什么是 execve 系统调用?
先上源码吧:
1 |
|
函数定义:https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/exec.c#n2087
1 | SYSCALL_DEFINE3(execve, |
可见,execve 系统调用直接调用了 do_execve
1 | static int do_execve(struct filename *filename, |
- do_execveat_common => bprm_execve
- bprm_execve => exec_binprm
- exec_binprm => search_binary_handler
- 主要辨识当前二进制文件所属类型,然后寻找支持处理该二进制类型的可执行程序处理过程。
- search_binary_handler => load_elf_binary
- 辨识到 Binary 属于 ELF 格式 后,使用 load_elf_binary 过程来处理该二进制文件。
load_elf_binary 主要流程:
- 检查并获取 ELF 文件的头部信息。
- 如果目标文件采用动态链接,则使用头中的
.interp
section 来确定loader
的路径。
- 如果目标文件采用动态链接,则使用头中的
- 将 program header 中记录的相应的 segment 映射到内存中。program header 中有以下重要信息
- 每一个 segment 需要映射到的地址
- 每一个 segment 相应的权限。
- 记录哪些 section 属于哪些 segment。