本文属于学习笔记,内容或为半成品或存在较多谬误,仅供参考。

Linux 系统上的可执行文件,分为 静态链接 的可执行文件及 动态链接 的可执行文件。

  • 静态链接 的可执行文件:这类程序在执行时,不会载入任何动态库,而是直接执行其底下对应的汇编代码,不会走 Loader 等高级语言编译产物的标准过程。这类静态链接可执行文件,现已不多见。
  • 动态链接 的可执行文件:只要是由高级语言编译出来的程序,一般都是 动态链接 的可执行文件,这是因为:高级语言编译出来的程序一般需要标准库的支持 (如 libc),这些标准库在系统上以动态库形式提供。

链接器 (Linkers)

链接就是将可执行文件与库文件绑定在一起的过程。这种绑定由专门的程序负责,叫做 “链接器 (Linker)”。

Linux 提供了两种 Linker。他们的行为和作用不同,有一定合作关系,不应混淆。

  • ld 链接器:在编译时(compile-time)由编译器执行。
    • 编译的时候 gcc、clang 等编译器会执行 ld,并给它传递一系列链接参数,比如指定需要链接什么库。
    • 链接时,ld 通常按照一定顺序查找需要链接的静态/动态库:
      1. ld 运行参数 L 指定的库路径;
      2. 环境变量 LIBRARY_PATH 指定的库搜索路径;
      3. 默认库搜索路径:/lib/ 及 /usr/lib/
    • 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):

  1. 首先,当你执行一个程序的时候,比如这个程序名为 app:

    1. 你会在 shell 中输入:./app
    2. shell 会 fork 一个子进程出来,并将该程序 mmap 到内存中;然后,它会执行 Linux 系统调用 execve() (见后面介绍)。调用之后,系统会在这个子进程中为运行这个程式设置栈,并且将 argc,argv 和 envp 压入栈中;
    3. execve() 会扫描程序的 ELF 头,检查其是否是合格的 ELF 文件格式,检查其所属的 ELF 种类,并从 .interp section 中找到该二进制程序的 interpreter / loader。一般而言,如果在 link 的时候未有指定,那么程序的默认 interpreter/loader 是 /usr/lib/ld-[version].so,简称 ld.so
  2. 找到 ld.so 后,系统先将 ld.so mmap 到内存中,并将指令指针改成 ld.so入口地址,然后切换回用户态,执行 ld.so 内部的代码。

  3. ld.so 的主要功能及工序是:

    1. 载入 ELF 文件 .dynamic section 的 DT_NEEDED 记录的程序所使用的共享库(如 libgcc.so)。

      • 这个 .dynamic section 的内容可通过 readelf 或 ldd 命令查看(可看后文介绍:怎么查看 ELF 文件中的某个 section?怎么样查看 ELF 可执行程序/库的所有依赖项目(如 so 文件)?)
      • 加载过程是递归的,即加载某个共享库所依赖的其他共享库的时候其实也是由对应的 loader (一般也是 ld.so) 来完成的
    2. 初始化 GOT 表

    3. 将共享库的 symbol table 合并到 global symbol table

    4. 启动应用程序

      • 当 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 符号所对应的地址。
  4. _start:应用程序真正的入口点

    1. 系统默认从程序的 _start 开始执行。事实上,使用 C/C++ 编写及编译的程序,_start 是已经被 C 标准库实现的例程,使用者不必手动实现它。
    2. _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

    1. 由用户自行提供应用程序的入口点也是可行的。在编译时加入参数 -nostdlib -nostdinc,可以不将 C 标准库链入程序,这样用户就可以自己定义 _start 函数了。
    2. 由代码可见,标准 C 库的_start 除了能够调用 libc.so 中的 __libc_start_main 来执行用户定义的 main() 函数之外,还会
      • 做一些底层的初始化,如准备寄存器,准备 Cache、MMU,确保程序执行符合 ABI。
      • 初始化 C Runtime
  5. __libc_start_main:由 _start 调用并由标准 C 库提供,主要初始化 C 程序的运行环境(_init()),并执行那些在 main 开始之前/结束之后要完成的过程,例如:

    1. 程序级构造函数、析构函数
    2. 初始化在 .rss section 中记录的尚未初始化的变量,例如一些静态变量及一些全局作用域的变量。
    3. 上述各种构造过程会被添加到一个 array 中,由 __libc_csu_init 函数真正执行初始化。
  6. __libc_csu_init:初始化 C 运行环境并执行 init array 中的构造子:

    1. 由 __libc_csu_init 的代码 可知,该函数主要 执行 _init (),并 loop 一个 init array,挨个执行其中的函数。
    2. init array 中的过程由编译器提供,视乎编译器的不同具有各种命名及实现。例如在 gcc 中,这种初始化过程可命名为 ___cxx_global_var_init。
  7. _init:由 __libc_csu_init 执行

  8. __GLOBAL_sub_I_xxx 构造子:由 __libc_csu_init 执行,用于初始化全局作用域变量

  9. ___cxx_global_var_init:由 __GLOBAL_sub_I_xxx 执行,是 clang 生成的全局变量初始化函数

  10. __attribute__((constructor)) 类型的构造子:由 __libc_csu_init 执行,是程序级构造函数、析构函数

  11. (此处省略了一大堆其他初始化过程)__libc_csu_init 结束执行后,由 __libc_start_main 执行 main() 函数。main 函数的返回值直接传入 exit() 函数中,跳回 _start 例程来处理。

    1
    2
    exit(main(argc, argv));

参考资料

Tricks

怎么查看 ELF 文件中的某个 section?

使用 readelf 命令。例如,想要查看 .dynamic section,可使用 “readelf -d ./app”

1
2
3
4
5
6
7
8
9
10
11
(base) han@han-bd-dell:~$ readelf -d ./app

Dynamic section at offset 0x2e00 contains 26 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libstdc++.so.6]
0x0000000000000001 (NEEDED) Shared library: [libgcc_s.so.1]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x401000
0x000000000000000d (FINI) 0x401488
...

怎么样查看 ELF 可执行程序/库的所有依赖项目(如 so 文件)?

ldd [app/lib name] – 可获取程序/库所需的【所有】依赖

1
2
3
4
5
6
7
8
(base) han@han-bd-dell:~$ldd ./app
linux-vdso.so.1 (0x00007ffe4478e000)
libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007fa94d5b4000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007fa94d599000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fa94d3a7000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007fa94d258000)
/lib64/ld-linux-x86-64.so.2 (0x00007fa94d7b3000)

我怎么在 main() 函数执行之前及之后执行我的函数?

方式 1: 利用静态构造/析构在 main() 之外的特性:

1
2
3
4
namespace {
// WIP
}

方式 2: 利用程序级构造、析构函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <cstdio>

void __attribute__ ((constructor)) construct_one(int argc,char* argv[]) {
printf("111\n");
}

void __attribute__ ((constructor)) construct_two(int argc,char* argv[],char* envp[]) {
printf("222\n");
}

void __attribute__ ((destructor)) destruct_one(int argc,char* argv[]) {
printf("333\n");
}

void __attribute__ ((destructor)) destruct_two(int argc,char* argv[]) {
printf("444\n");
}

int main(int argc,char *argv[],char *env[])
{
printf("555\n");
return 0;
}

/* output:
111
222
555
444
333
*/

什么是 execve 系统调用?

先上源码吧:

1
2
3
4
5
#include <unistd.h>

int execve(const char *pathname, char *const argv[],
char *const envp[]);

函数定义:https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/exec.c#n2087

1
2
3
4
5
6
7
8
SYSCALL_DEFINE3(execve,
const char __user *, filename,
const char __user *const __user *, argv,
const char __user *const __user *, envp)
{
returndo_execve(getname(filename), argv, envp);
}

可见,execve 系统调用直接调用了 do_execve

1
2
3
4
5
6
7
8
9
static int do_execve(struct filename *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp)
{
struct user_arg_ptr argv = { .ptr.native = __argv };
struct user_arg_ptr envp = { .ptr.native = __envp };
return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}

  • 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。