静态链接库如何在Linux上运行

作者:Eli Bendersky

http://eli.thegreenplace.net/2012/08/13/how-statically-linked-programs-run-on-linux/

在本文里我希望探究在Linux上执行一个静态链接库时会发生什么。静态链接,我的意思是一个不需要任何共享对象,即使无所不在的libc,而运行的程序。在现实中Linux上遇到的大多数程序不是静态链接的,要求一个或多个共享对象来运行。不过,这样程序的运行次序更加复杂,这是为什么我希望首先展示静态链接程序。它将作为好的理解出发点,允许我探究涉及的大多数技术,不至于被细节的缺少而妨碍。在未来的文章里我将详细讨论动态链接过程。

Linux内核

程序执行始于Linux内核。要运行一个程序,一个进程将调用来自exec家族的一个函数。这个家族里的函数都是非常类似的,仅在向被调用重新传递参数及环境变量方式上有细微的差别。它们最后执行的是向Linux内核发出sys_execve系统调用。

Sys_execve为新程序执行进行了大量的准备工作。完全解释它远超出了本文的范畴——关于内核内部的好书是有助于对理解细节的[1]。对当前的讨论,我将只关注有用的资料。

作为工作的部分,内核必须从硬盘将程序的可执行文件读入内存并为执行进行准备。内核知道如何处理许多二进制文件格式,并尝试以不同的处理句柄打开文件,直至成功为止(这发生在fs/exec.c中的函数search_binary_handler函数里)。这里我们只对ELF感兴趣;对于这个格式,活动在函数load_elf_binaray(在fs/binfmt_elf.c)里展开。

内核读入程序的ELF头,查找PT_INTERP段,看是否要求解析器。这里静态链接与动态链接的区别开始体现。对于静态链接的程序,没有PT_INTERP段。这是本文涉及的场景。

然后内核根据ELF程序头包含的信息,将程序的段映射到内存。最后,通过直接修改IP寄存器,向执行传递从程序ELF头读入的入口地址(e_entry)。参数在栈上传递给程序(负责这的代码在create_elf_tables里)。下面是程序被调用时栈的布局,对于x64:


[1] 或者阅读源代码,如果你勇敢的话。


在栈顶的是argc,命令行参数的个数。后跟参数本身(每个是一个char*),由空指针结尾。然后,排列的是环境变量(每个也是一个char*),由空指针结尾。细心的读者会发现这个参数布局不是通常在main里所期望的。这是因为main不是程序的入口,本文余下部分会展示这一点。

程序入口点

好了,Linux内核从ELF头读取程序的入口点。现在让我们探究这个地址是如何获取的。除非你正在做东西非常时髦,程序最终的二进制映像可能是由系统链接器——ld创建的。默认的,ld在链进程序的目标文件里查找一个称为_start的特殊符号,并设置入口点指向这个符号的地址。以一个汇编写的例子展示将会是最简单的(下面是NASM形式):

section    .text
    ; The _start symbol must be declared for the linker (ld)
    global _start
 
_start:
    ; Execute sys_exit call. Argument: status -> ebx
    mov     eax, 1
    mov     ebx, 42
    int     0x80

这是一个非常简单只是返回42的程序。注意到它定义了符号_start。让我们编译它,检查ELF头与其反汇编:

$ nasm -f elf64 nasm_rc.asm -o nasm_rc.o
$ ld -o nasm_rc64 nasm_rc.o
$ readelf -h nasm_rc64
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  ...
  Entry point address:               0x400080
  ...
$ objdump -d nasm_rc64
 
nasm_rc64:     file format elf64-x86-64
 
Disassembly of section .text:
 
0000000000400080 <_start>:
  400080:     b8 01 00 00 00          mov    $0x1,%eax
  400085:     bb 2a 00 00 00          mov    $0x2a,%ebx
  40008a:     cd 80                   int    $0x80

正如你看到的,ELF头里的入口点地址被设置为0x400080,它恰好也是_start的地址。

默认的ld查找_start,但这个行为可以通过—entry命令行选项,或在一个定制的链接器脚本里提供一个ENTRY命令来修改。

C代码里的入口点

不过,通常我们不编写汇编代码。对于C/C++,情形不同了,因为用户熟悉的入口点是main函数而不是_start符号。现在是时候解释两者如何关联了。

让我们以这个功能与上面展示的汇编程序相同的简单C程序开始:

int main() {
    return 42;
}

我将把这个代码编译到一个目标文件。如何尝试用ld链接它,就像我对汇编代码做的那样:

$ gcc -c c_rc.c
$ ld -o c_rc c_rc.o
ld: warning: cannot find entry symbol _start; defaulting to 00000000004000b0

噢,ld找不到入口点。它尝试猜测使用一个缺省值,但不成功——运行时程序将段错误。显然ld需要它可以从中找到入口点的额外目标文件。但这是哪些目标文件呢?幸运地,我们可以使用gcc来查找。Gcc可以作为一个完整的编译驱动器,在需要时调用ld。现在我们使用gcc来将我们的目标文件链接入一个程序。注意标记-static被传入来强制C库以及gcc运行时库的静态链接:

$ gcc -o c_rc -static c_rc.o
$ c_rc; echo $?
42

成功了。那么gcc如何做到正确链接的呢?我们可以向gcc传入-Wl, -verbose标记,这些标记将泄露gcc传递给链接器目标文件及库的列表。这样做,我们会看到额外的目标文件,像crt1.o以及整个libc.a静态库(它包含名字显示为libc-start.o的目标文件)。C代码不是生活在真空里的。要运行,它要求一些支持库,比如gcc运行时库与libc。

因为它显然正确地链接与运行,我们以gcc构建的程序应该在正确的地方有一个-start符号。让我们查一下[1]:

$ readelf -h c_rc
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00
  Class:                             ELF64
  ...
  Entry point address:               0x4003c0
  ...
 
$ objdump -d c_rc | grep -A15 "<_start"
00000000004003c0 <_start>:
  4003c0:     31 ed                   xor    %ebp,%ebp
  4003c2:     49 89 d1                mov    %rdx,%r9
  4003c5:     5e                      pop    %rsi
  4003c6:     48 89 e2                mov    %rsp,%rdx
  4003c9:     48 83 e4 f0             and    $0xfffffffffffffff0,%rsp
  4003cd:     50                      push   %rax
  4003ce:     54                      push   %rsp
  4003cf:     49 c7 c0 20 0f 40 00    mov    $0x400f20,%r8
  4003d6:     48 c7 c1 90 0e 40 00    mov    $0x400e90,%rcx
  4003dd:     48 c7 c7 d4 04 40 00    mov    $0x4004d4,%rdi
  4003e4:     e8 f7 00 00 00          callq  4004e0 <__libc_start_main>
  4003e9:     f4                      hlt
  4003ea:     90                      nop
  4003eb:     90                      nop

确实,0x4003c0是_start的地址,它就是程序的入口点。不过,_start处的代码又是什么呢?它来自哪里,它又意味着什么?

解码C代码的开始序列

上面展示的起始代码来自glibc——GNUC库,对于x64 ELF,起始代码存在于文件sysdeps/x86_64/start.S里[2]。其目标是为名为__libc_start_main函数准备参数,并调用它。这个函数也是glibc的部分,它在csu/libc-start.c里。这里是它的声明,为了清晰格式化了,添加了解释每个参数含义的注释:

int __libc_start_main(
         /* Pointer to the program's main function */
         (int (*main) (int, char**, char**),
         /* argc and argv */
         int argc, char **argv,
         /* Pointers to initialization and finalization functions */
         __typeof (main) init, void (*fini) (void),
         /* Finalization function for the dynamic linker */
         void (*rtld_fini) (void),
         /* End of stack */
         void* stack_end)

总之,根据这个声明以及手边的AMD64ABI,我们可以如下映射从_start传递给__libc_start_main的参数:

main:      rdi <-- $0x4004d4
argc:      rsi <-- [RSP]
argv:      rdx <-- [RSP + 0x8]
init:      rcx <-- $0x400e90
fini:      r8  <-- $0x400f20
rdld_fini: r9  <-- rdx on entry
stack_end: on stack <-- RSP

你还会注意到栈对齐到16字节,在压入rsp本身之前在栈顶压入一些垃圾(rax)。这是符合AMD64 ABI的。还注意地址0x4003e9处的hlt指令。它是一个保护措施,万一__libc_start_main不存在(就像我们看到,它应该存在)。Hlt不能在用户模式下执行,因此这将抛出异常并使进程崩溃。

检查反汇编代码,很容易验证0x4004d4是main,0x400e90是__libc_csu_init而0x400f20是__libc_csu_fini。有另一个内核传递给_start的参数——由共享库使用的完成函数(在rdx里)。在本文里我们忽略这个参数。

C库启动函数

现在我们理解了它是怎么被调用的,那么__libc_start_main实际上做什么呢?忽略某些可能太特殊而本文不感兴趣的细节,下面是它为一个静态链接程序所做的一系列事情:

  1. 确定环境变量在栈的何处。
  2. 如果需要,准备辅助向量(auxiliary vector)。
  3. 初始化线程相关的功能(比如pthread,TLS等)。
  4. 执行某些安全相关的簿记(这不是一个真正独立的步骤,而是遍及整个函数)。
  5. 初始化libc本身。
  6. 通过传入的指针(init)调用程序初始化函数。
  7. 为执行的退出注册程序的结束函数(fini)。
  8. 调用main (argc, argv, envp)。
  9. 以main的结果作为退出码调用exit。

离题:initfini

某些编程环境(最主要是C++,构建及析构静态及全局对象)要求在main之前之后运行客户代码。这通过编译器/链接器与C库之间的协作来实现。例如,__libc_csu_init(正如你上面看到的,它在用户的main之前调用)调用由链接器插入的特殊代码。__libc_csu_fini以及终止也是类似。

你也可以要求编译器把你的函数注册为构造器之一来执行。例如[3]:

#include <stdio.h>
 
int main() {
    return 43;
}
 
__attribute__((constructor))
void myconstructor() {
    printf("myconstructor\n");
}

Myconstructor将在main之前运行。链接器在位于.ctor节构造函数的一个特殊数组里放入它的地址。__libc_csu_init遍历这个数组并调用其中所有的函数。

结论

本文展示了如何构建在Linux上运行的静态链接库。在我看来,这是一个非常有趣的议题,因为它展示了Linux生态系统里的几大组件是如何协作来启动程序的执行过程。在这个情形里,涉及了Linux内核,编译器与链接器,以及C库。在未来的文章里,我将展示更复杂的动态链接程序,在那里另一个代理人加入游戏——动态链接器。请继续关注。


[1] 注意 因为我们将C运行时库静态链接入c_rc,它相当大(在我的64位Ubuntu系统上是800 KB)。因此我们不能简单地看反汇编,必须使用一些grep。

[2] 对于glibc2.16是这样的。

[3] 注意析构函数执行printf。这安全吗?如果你看__libc_start_main的初始化序列,你将看到C库在用户的构造函数被调用前初始化,因此,是的,它是安全的。


你可能感兴趣的:(loader,linker)