作者: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实际上做什么呢?忽略某些可能太特殊而本文不感兴趣的细节,下面是它为一个静态链接程序所做的一系列事情:
离题:init与fini
某些编程环境(最主要是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库在用户的构造函数被调用前初始化,因此,是的,它是安全的。