Linux程序是怎么执行的——动态链接
0、前言
计算机的核心任务就是运行程序,而程序是如何运行的?这个问题一直困扰我很多年。网上有很多资料介绍程序如何被编译,如何被链接,然后装载,最后到OS中运行的,但都很分散,讲到的都是点,很少有串起来的;而串起来的又很少是基于64位的版本,大部分还是32位的程序。再加上讲原理的多,讲例子的少,读起来费劲,更不用说理解与记忆了。
比如,对于动态链接,我相信很多童鞋跟我一样,好不容易看懂了,结果过了几个月全忘了,那么我们尝试一种新的方式去讲述——讲解机制而不讲具体的策略,也就是讲原理而不是讲具体的实现细节。类似于先讲需求再去实现代码,而不是相反,这样比较容易理解。
同时,在文章中,也会穿插简单介绍Linux进程、内存的管理,毕竟一个程序要执行光靠编译器、链接器还不行,还要靠操作系统。同时也会写一些自己对软件设计的体会。
1、先看一个简单的c程序
这个程序非常"小",但是说明问题足够了
#include
#include
int main(int argc, char* argv[])
{
printf("hello\n");
printf("world\n");
return 0;
}
这里我必须更正下,就是这个"小"字。在任何的高级编程语言,不管你写的代码多简短,其实都不小。上面这段代码在gcc编译后,其实能占到9k多。为啥?我们通过反汇编看看。
1.1、先看看反汇编
我们通过gcc编译,生成got_test可执行文件:
gcc -g -c got_test got_test.c
编译以后可以看到文件,有9568个byte,就这几行代码就有9k多?编译器肯定做了很多不为人知的事情,我们可以用objdump
来反汇编看看
[root@localhost got_test]# stat got_test
文件:"got_test"
大小:9568 块:24 IO 块:4096 普通文件
设备:fd00h/64768d Inode:51394561 硬链接:1
权限:(0755/-rwxr-xr-x) Uid:( 0/ root) Gid:( 0/ root)
环境:unconfined_u:object_r:home_root_t:s0
最近访问:2019-05-23 17:05:10.836689706 +0800
最近更改:2019-05-23 17:05:03.808689706 +0800
最近改动:2019-05-23 17:05:03.808689706 +0800
创建时间:-
然后看看汇编代码:
[root@localhost got_test]# objdump -d got_test
got_test: 文件格式 elf64-x86-64
Disassembly of section .init:
00000000004003c8 <_init>:
4003c8: 48 83 ec 08 sub $0x8,%rsp
4003cc: 48 8b 05 25 0c 20 00 mov 0x200c25(%rip),%rax # 600ff8 <__gmon_start__>
4003d3: 48 85 c0 test %rax,%rax
4003d6: 74 05 je 4003dd <_init+0x15>
4003d8: e8 43 00 00 00 callq 400420 <.plt.got>
4003dd: 48 83 c4 08 add $0x8,%rsp
4003e1: c3 retq
Disassembly of section .plt:
00000000004003f0 <.plt>:
4003f0: ff 35 12 0c 20 00 pushq 0x200c12(%rip) # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
4003f6: ff 25 14 0c 20 00 jmpq *0x200c14(%rip) # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
4003fc: 0f 1f 40 00 nopl 0x0(%rax)
0000000000400400 :
400400: ff 25 12 0c 20 00 jmpq *0x200c12(%rip) # 601018
400406: 68 00 00 00 00 pushq $0x0
40040b: e9 e0 ff ff ff jmpq 4003f0 <.plt>
0000000000400410 <__libc_start_main@plt>:
400410: ff 25 0a 0c 20 00 jmpq *0x200c0a(%rip) # 601020 <__libc_start_main@GLIBC_2.2.5>
400416: 68 01 00 00 00 pushq $0x1
40041b: e9 d0 ff ff ff jmpq 4003f0 <.plt>
Disassembly of section .plt.got:
0000000000400420 <.plt.got>:
400420: ff 25 d2 0b 20 00 jmpq *0x200bd2(%rip) # 600ff8 <__gmon_start__>
400426: 66 90 xchg %ax,%ax
Disassembly of section .text:
0000000000400430 <_start>:
400430: 31 ed xor %ebp,%ebp
400432: 49 89 d1 mov %rdx,%r9
400435: 5e pop %rsi
400436: 48 89 e2 mov %rsp,%rdx
400439: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp
40043d: 50 push %rax
40043e: 54 push %rsp
40043f: 49 c7 c0 c0 05 40 00 mov $0x4005c0,%r8
400446: 48 c7 c1 50 05 40 00 mov $0x400550,%rcx
40044d: 48 c7 c7 1d 05 40 00 mov $0x40051d,%rdi
400454: e8 b7 ff ff ff callq 400410 <__libc_start_main@plt>
400459: f4 hlt
40045a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)
0000000000400460 :
400460: b8 37 10 60 00 mov $0x601037,%eax
400465: 55 push %rbp
400466: 48 2d 30 10 60 00 sub $0x601030,%rax
40046c: 48 83 f8 0e cmp $0xe,%rax
400470: 48 89 e5 mov %rsp,%rbp
400473: 77 02 ja 400477
400475: 5d pop %rbp
400476: c3 retq
400477: b8 00 00 00 00 mov $0x0,%eax
40047c: 48 85 c0 test %rax,%rax
40047f: 74 f4 je 400475
400481: 5d pop %rbp
400482: bf 30 10 60 00 mov $0x601030,%edi
400487: ff e0 jmpq *%rax
400489: 0f 1f 80 00 00 00 00 nopl 0x0(%rax)
......省略.....
000000000040051d :
40051d: 55 push %rbp
40051e: 48 89 e5 mov %rsp,%rbp
400521: 48 83 ec 10 sub $0x10,%rsp
400525: 89 7d fc mov %edi,-0x4(%rbp)
400528: 48 89 75 f0 mov %rsi,-0x10(%rbp)
40052c: bf e0 05 40 00 mov $0x4005e0,%edi
400531: e8 ca fe ff ff callq 400400
400536: bf e7 05 40 00 mov $0x4005e7,%edi
40053b: e8 c0 fe ff ff callq 400400
400540: b8 00 00 00 00 mov $0x0,%eax
400545: c9 leaveq
400546: c3 retq
400547: 66 0f 1f 84 00 00 00 nopw 0x0(%rax,%rax,1)
40054e: 00 00
0000000000400550 <__libc_csu_init>:
400550: 41 57 push %r15
400552: 41 89 ff mov %edi,%r15d
400555: 41 56 push %r14
400557: 49 89 f6 mov %rsi,%r14
40055a: 41 55 push %r13
40055c: 49 89 d5 mov %rdx,%r13
40055f: 41 54 push %r12
400561: 4c 8d 25 a8 08 20 00 lea 0x2008a8(%rip),%r12 # 600e10
.......省略,,,,,,
可见除开main函数:
000000000040051d :
40051d: 55 push %rbp
40051e: 48 89 e5 mov %rsp,%rbp
400521: 48 83 ec 10 sub $0x10,%rsp
400525: 89 7d fc mov %edi,-0x4(%rbp)
400528: 48 89 75 f0 mov %rsi,-0x10(%rbp)
40052c: bf e0 05 40 00 mov $0x4005e0,%edi
400531: e8 ca fe ff ff callq 400400
400536: bf e7 05 40 00 mov $0x4005e7,%edi
40053b: e8 c0 fe ff ff callq 400400
400540: b8 00 00 00 00 mov $0x0,%eax
400545: c9 leaveq
400546: c3 retq
400547: 66 0f 1f 84 00 00 00 nopw 0x0(%rax,%rax,1)
40054e: 00 00
以外,编译器还插入了还多了很多函数,比如:_start,__libc_start_main,__libc_csu_init这些函数。可见,我们程序的运行其实不像我们写的main这么简单,其实main函数并不是程序的真正入口,当然这不是本文的范围了,只需知道这些函数都是"助攻"的,也就是说main函数要运行,还需要很多帮手,比如准备环境变量啊,退出的时候清理内存啊啥的,所以可见linux下程序要运做是十分复杂的。
附一张图,可以看看这个过程的复杂性,感性认识下即可:
嗯嗯,很复杂,但是这并不是本文的重点,只是向大家阐述下运行程序是需要编译器,链接器,动态库与操作系统共同努力的结果,哪怕是一行简单的代码要运行起来也是十分艰难,历经艰辛。
最后,我们回来,你是否注意到了中间的"Disassembly of section .text:"字样,我们接下来的故事从这里开始……
了解静态链接的童鞋可能知道,对于linux下不是所有的文件都是可以被执行的,很多文件其实就是数据块,存储的是各种文件信息。而真正能够被执行的文件却不多,我们熟悉的cp,ls,cd这些命令,其实对应的就是一个个的可执行文件。
而一个文件要可执行在linux下就有一定的格式,或者是编译器、链接器、库函数与操作系统互相配合的一种工作协议;大家在这个协议下各司其职,各自独当一面,最终完成程序的运行大业。而这个协议叫做ELF(Executable and Linkable Format),在linux下只有elf文件才能被链接与执行。那么我们先介绍下ELF文件格式。
1.2、 ELF与段
ELF——Executable and Linkable Format,从字面上理解ELF是为执行、链接服务的一种文件格式。这个格式大概张这个样子:
可以看到ELF除开有段还有文件头与文件体,文件体还包含n个段。很多人也许跟我一样有个疑问,编译好的文件为啥这么复杂,直接顺序把代码编译成机器码不就成了,怎么这么费事呢?
我们举个例子说下,比如做软件项目,特别是商业软件项目,都有一个规律,核心运行的业务代码跟安全保障代码、辅助代码差不多多;为啥?因为需要兼容性、需要扩展性更需要满足安全性。elf文件的格式的定义也一样,需要考虑很多扩展、安全的特性。
下面我们来详细解释下为啥elf格式需要定义文件头、文件体以及为啥要分段。
1.2.1 为啥需要文件头与文件体
ELF顾名思义就是需要在CPU上执行的文件格式,而CPU只能执行内存中的代码(冯诺依曼结构的特点),那么站在操作系统的角度来看,要加载一个可执行文件到内存首先必须确定啥信息呢?
我想应该是大小,试想一下,如果我连一个可执行文件有多大我都不知道,我怎么去加载,去解析呢?
那么问题来了,怎么确定一个文件的大小呢?比如,内存中有两个可执行文件A,B;那么如何区分它们的边界呢?有人可能说,加分隔符呗,比如"|",A|B的形式来存储。当程序遇到分隔符说明一个程序到头了。
这么做到底好吗?看上去也还行……
仔细想想这么做有两个弊端:
- AB中如果本身就包含分隔符怎么处理呢?你可能说转义呗,但是对于标准来说,转义是不具备通用性与可移植性的,比较tricky,而且如果有人恶意破坏,在程序中强行插入分隔符,那么程序将会无法运行,甚至系统奔溃;必须从根源保证安全而不是被动防御;
- 安全性,如果一个恶意的攻击,写出了1G的可执行文件,就是不插入结束分隔符,那么系统就会一直加载一直加载,知道系统内存耗尽,系统崩溃。
那么,怎么解决呢?答案就是文件头+文件体的形式。一个ELF文件头中包含了这个可执行文件的元信息——长度、权限、类型、段的数量等,然后紧跟着ELF文件体。而且,更进一步来说,文件头一定是固定格式的,也就是长度固定。因为如果不是这样,还是会出现2、中说的无限大的文件头攻击。
typedef struct {
unsigned char e_ident[EI_NIDENT];
uint16_t e_type;
uint16_t e_machine;
uint32_t e_version;
ElfN_Addr e_entry;
ElfN_Off e_phoff;
ElfN_Off e_shoff;
uint32_t e_flags;
uint16_t e_ehsize;
uint16_t e_phentsize;
uint16_t e_phnum;
uint16_t e_shentsize;
uint16_t e_shnum;
uint16_t e_shstrndx;
} ElfN_Ehdr;
具体字段含义不是重点。
所以,正确的加载"姿势"应该是:当OS去读取这个ELF文件时,首先会读取文件头,然后根据这个头信息去加载、读取每个段的内容。
引伸一下:头+体的设计模式很适合用于数据块的定义,那么还有什么场景会使用数据块呢?你可能想到了——网络编程。网络程序需要在计算机网络中传输数据,这些数据包就是用头+体+非字节对齐的方式传输的,这样可以有效而安全的避免粘包与缺包的处理。
1.2.2 为啥需要段
首先我们看看got_test程序包含哪些段吧,我们使用readelf
这个工具。
[root@localhost got_test]# readelf -SW got_test
共有 36 个节头,从偏移量 0x1c60 开始:
节头:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 0] NULL 0000000000000000 000000 000000 00 0 0 0
[ 1] .interp PROGBITS 0000000000400238 000238 00001c 00 A 0 0 1
[ 2] .note.ABI-tag NOTE 0000000000400254 000254 000020 00 A 0 0 4
[ 3] .note.gnu.build-id NOTE 0000000000400274 000274 000024 00 A 0 0 4
[ 4] .gnu.hash GNU_HASH 0000000000400298 000298 00001c 00 A 5 0 8
[ 5] .dynsym DYNSYM 00000000004002b8 0002b8 000060 18 A 6 1 8
[ 6] .dynstr STRTAB 0000000000400318 000318 00003d 00 A 0 0 1
[ 7] .gnu.version VERSYM 0000000000400356 000356 000008 02 A 5 0 2
[ 8] .gnu.version_r VERNEED 0000000000400360 000360 000020 00 A 6 1 8
[ 9] .rela.dyn RELA 0000000000400380 000380 000018 18 A 5 0 8
[10] .rela.plt RELA 0000000000400398 000398 000030 18 AI 5 24 8
[11] .init PROGBITS 00000000004003c8 0003c8 00001a 00 AX 0 0 4
[12] .plt PROGBITS 00000000004003f0 0003f0 000030 10 AX 0 0 16
[13] .plt.got PROGBITS 0000000000400420 000420 000008 00 AX 0 0 8
[14] .text PROGBITS 0000000000400430 000430 000192 00 AX 0 0 16
[15] .fini PROGBITS 00000000004005c4 0005c4 000009 00 AX 0 0 4
[16] .rodata PROGBITS 00000000004005d0 0005d0 00001d 00 A 0 0 8
[17] .eh_frame_hdr PROGBITS 00000000004005f0 0005f0 000034 00 A 0 0 4
[18] .eh_frame PROGBITS 0000000000400628 000628 0000f4 00 A 0 0 8
[19] .init_array INIT_ARRAY 0000000000600e10 000e10 000008 08 WA 0 0 8
[20] .fini_array FINI_ARRAY 0000000000600e18 000e18 000008 08 WA 0 0 8
[21] .jcr PROGBITS 0000000000600e20 000e20 000008 00 WA 0 0 8
[22] .dynamic DYNAMIC 0000000000600e28 000e28 0001d0 10 WA 6 0 8
[23] .got PROGBITS 0000000000600ff8 000ff8 000008 08 WA 0 0 8
[24] .got.plt PROGBITS 0000000000601000 001000 000028 08 WA 0 0 8
[25] .data PROGBITS 0000000000601028 001028 000004 00 WA 0 0 1
[26] .bss NOBITS 000000000060102c 00102c 000004 00 WA 0 0 1
[27] .comment PROGBITS 0000000000000000 00102c 00002d 01 MS 0 0 1
[28] .debug_aranges PROGBITS 0000000000000000 001059 000030 00 0 0 1
[29] .debug_info PROGBITS 0000000000000000 001089 0000cc 00 0 0 1
[30] .debug_abbrev PROGBITS 0000000000000000 001155 00005e 00 0 0 1
[31] .debug_line PROGBITS 0000000000000000 0011b3 000040 00 0 0 1
[32] .debug_str PROGBITS 0000000000000000 0011f3 0000ce 01 MS 0 0 1
[33] .symtab SYMTAB 0000000000000000 0012c8 000678 18 34 52 8
[34] .strtab STRTAB 0000000000000000 001940 0001cd 00 0 0 1
[35] .shstrtab STRTAB 0000000000000000 001b0d 00014c 00 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), o (OS specific), E (exclude),
l (large), p (processor specific)
可以看到一共36个段。可以看到其中可以执行的段,也就是flag中带X
的段一共5个:
[11] .init PROGBITS 00000000004003c8 0003c8 00001a 00 AX 0 0 4
[12] .plt PROGBITS 00000000004003f0 0003f0 000030 10 AX 0 0 16
[13] .plt.got PROGBITS 0000000000400420 000420 000008 00 AX 0 0 8
[14] .text PROGBITS 0000000000400430 000430 000192 00 AX 0 0 16
[15] .fini PROGBITS 00000000004005c4 0005c4 000009 00 AX 0 0 4
带A
也就是需要分配内存的20几个。A+X这些就是代码+数据了,不就够了么?可是,还有很多段既不要运行也不要分配内存去加载,为啥还需要呢?
如果站在程序运行角度,也许他们就够了;但是一个商业程序往往不是跑起来就行了,还要监控、还要调试,说白了还有很多运维相关的需求要满足。
我们都知道,业务需求是无限的,客户需求总是无法百分百满足的,所以设计一个足够灵活的架构对于软件开发是至关重要的。更何况ELF这种需要大量使用的标准,可以说当今互联网上90%以上的程序都是跑的这个格式。所以扩展性十分重要,而分段就是保证扩展性的重要机制。
举个例子来说:.debug*
的段,其实都是不需要映射到内存的,因为这些段的信息就是为了调试用的,gdb要加载的符号信息.symbtab
,行号信息.debug_line
都是elf的段。而调试程序,几乎占用了一个程序员80%的工作时间,可以说20%写bug,80%修bug,呵呵;所以这几个段至关重要,关系存亡。
所以,ELF格式不只是为了运行程序而创造的,还要能够维护、调试程序。甚至,还可以加入自定义段来实现特殊的功能。
除开保证扩展性,其实分段还能有利于程序的加载,为了在CPU上执行,程序必须要首先加载到内存中,而内存也是个十分重要而稀缺的资源,我们必须要能够将其高效利用,而高效的利用内存可能会使用分页与swap技术(后面有介绍,这里不展开);而分段机制可以把elf不同类型的数据装载到内存(虚拟内存)的不同位置,从而便于将某些可以换出内存的"程序部分"交换到磁盘去,以空出内存加载更重要的数据。这是个重要的机制,而分段机制对内存的管理机制从协议就开始支持了。
除开执行,ELF还要能链接,为啥呢?现在随着应用程序越来越大,一个文件的程序已经不可能出现了,一个能运行的程序必须依赖各种各样的程序库;而最后的可执行文件也是由各种库通过链接的形式啮合到一起才能执行的,所以ELF还要定义各种跟链接相关的段,来支持静态或者动态的链接。
1.3 ELF的链接
ELF第二个字母就是——可链接。啥叫可链接?就是将不同的目标文件.o
文件链接在一起形成最后的可执行文件的过程。
1.3.1 那么为啥需要链接?
这个原因应该很好理解,现在程序肯定不是一个源文件了,所以要将目标程序所引用的分散在计算机里面的各种库文件链接起来才能形成最后的可执行文件;其中最重要的部分就是符号解析与地址重定位。举个例子来说:
#include
#include
int main(int argc, char* argv[])
{
printf("hello world\n");
return 0;
}
里面的函数printf
在当前的程序中并没有定义,上哪去找定义呢?这都是链接器的任务。
链接大致分为两类——静态链接与动态链接。
1.3.2 静态链接
静态链接是最简单的链接方式,编译器通过扫描整个代码与依赖的库的源代码,将依赖的函数的代码+数据全部收集在一起,打包在一起形成一个超大的可执行文件。如下图:
特点:
- 编译速度慢,因为需要将文件的所有符号都重定位,需要扫描大量的库源代码文件;
- 运行速度快,因为在编译期做了所有的符号解析、重定位,所以在运行时没有链接的过程,真正做到"一次编译总能运行",也就是能编译过就能跑;而且执行速度快;
- 体积大,可见,链接过程中将代码从库中拷贝到了执行代码中,这个过程无疑增大了可执行文件的体积;试想100个函数调用就是100个库的代码复制;
- 运行时占内存,如果系统中100个进程都使用了
printf
那么这个函数的代码在物理内存就有100个拷贝; - 升级困难,因为链接的是源代码,所以几乎无法确认最终生成的可执行文件到底链接了那个版本库的API函数,只要有一个库要升级,或者修bug,整个工程都要重新编译、链接;过程复杂,不科学。
我们看下上面的代码经过静态链接后的可执行文件:
1、编译
[root@localhost got_test]# gcc -g -static -o got_test_static got_test_static.c
-g
表示二进制文件包含gdb所需的debug信息;
-static
表示强制使用静态链接的方式链接形成可执行文件
2、用gdb调试got_test_static
[root@localhost got_test]# gdb got_test_static
GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-114.el7
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
For bug reporting instructions, please see:
...
Reading symbols from /home/shared/tmp/got_test/got_test_static...done.
(gdb)
3、通过gdb反汇编出main函数的汇编代码
(gdb) disass main
Dump of assembler code for function main:
0x0000000000400fde <+0>: push %rbp
0x0000000000400fdf <+1>: mov %rsp,%rbp
0x0000000000400fe2 <+4>: sub $0x10,%rsp
0x0000000000400fe6 <+8>: mov %edi,-0x4(%rbp)
0x0000000000400fe9 <+11>: mov %rsi,-0x10(%rbp)
0x0000000000400fed <+15>: mov $0x4938b0,%edi
0x0000000000400ff2 <+20>: callq 0x401ef0
0x0000000000400ff7 <+25>: leaveq
0x0000000000400ff8 <+26>: retq
End of assembler dump.
从callq这行代码可以看出printf
的调用实际是调用的puts
这个函数。
而且看到函数的虚拟地址(虚拟地址是啥先放着,后面详细介绍)是0x401ef0
,然后我们尝试反汇编下puts
(gdb) disass puts
Dump of assembler code for function puts:
0x0000000000401ef0 <+0>: push %r13
0x0000000000401ef2 <+2>: push %r12
0x0000000000401ef4 <+4>: mov %rdi,%r12
0x0000000000401ef7 <+7>: push %rbp
0x0000000000401ef8 <+8>: push %rbx
0x0000000000401ef9 <+9>: sub $0x8,%rsp
0x0000000000401efd <+13>: callq 0x40e610
0x0000000000401f02 <+18>: mov 0x2ba867(%rip),%rbx # 0x6bc770
0x0000000000401f09 <+25>: mov %rax,%rbp
0x0000000000401f0c <+28>: mov (%rbx),%eax
0x0000000000401f0e <+30>: mov %rbx,%rdi
0x0000000000401f11 <+33>: and $0x8000,%eax
0x0000000000401f16 <+38>: jne 0x401f75
0x0000000000401f18 <+40>: mov 0x88(%rbx),%r8
0x0000000000401f1f <+47>: mov %fs:0x10,%rdx
0x0000000000401f28 <+56>: cmp 0x8(%r8),%rdx
可见puts的地址0x0000000000401ef0
跟main函数中的调用点0x401ef0
是匹配的,说明puts
函数已经打包到最终的可执行文件中了,跟静态链接的方式匹配。
最后,更多关于静态链接,还可以参考这里。
1.3.3 动态链接
在计算机中内存是极其稀缺的资源,我们应该尽可能的节约再节约。我们之前分析了静态链接的缺点:编译慢、难升级还有就是浪费内存,其中浪费内存是最不能容忍的。比如上面静态链接例子中的puts
函数,我们可以看看它实际的大小:
通过objdump -t
可以得到elf文件中符号相关的信息(函数名,变量名都是符号,链接的过程就是链接器通过符号去查找、匹配最终链接的):
[root@localhost got_test]# objdump -t got_test_static|grep puts
0000000000000000 l df *ABS* 0000000000000000 ioputs.o
0000000000000000 l df *ABS* 0000000000000000 iofputs.o
0000000000401ef0 w F .text 00000000000001d0 puts
0000000000428ee0 g F .text 000000000000016a _IO_fputs
000000000042d350 g F .text 000000000000008c fputs_unlocked
0000000000401ef0 g F .text 00000000000001d0 _IO_puts
0000000000428ee0 w F .text 000000000000016a fputs
注意看这行:0000000000401ef0 w F .text 00000000000001d0 puts
解释下重要列的含义:
-
0000000000401ef0
表示puts函数的虚拟地址,函数从这个地址开始执行; -
F
表示puts是一个函数 -
.text
表示puts符号位于.text段内(.text段就是代码段,绝大部分程序的代码都位于这个段) -
00000000000001d0
表示puts共占用0x00000000000001d0=464个字节长度。
可见,仅仅puts这个函数就占用了464个字节大小,如果引用的glibc
中的库函数越多,岂不是程序在运行时内存的代码越来越庞大?而且如果got_test_static
被映射n次(启动n个进程)那么内存中的代码就是n倍增长,十分不经济。
1.3.3.1 动态链接初识
发明linux的那帮geek对内存的操作可谓是无所不用其极的,能节省就节省,从COW机制到动态链接都反映了这个思想——内存是极其宝贵的资源,要省着用!下面分析下动态链接的做法:
1、使用动态链接编译下代码
其实gcc默认链接方式就是动态链接
[root@localhost got_test]# gcc -g -o got_test_dyn got_test_static.c
看看编译后的文件大小:
[root@localhost got_test]# stat got_test_dyn
文件:"got_test_dyn"
大小:9560 块:24 IO 块:4096 普通文件
设备:26h/38d Inode:37954 硬链接:1
权限:(0770/-rwxrwx---) Uid:( 0/ root) Gid:( 995/ vboxsf)
环境:system_u:object_r:vmblock_t:s0
最近访问:2019-05-27 10:27:41.000000000 +0800
最近更改:2019-05-27 10:27:41.000000000 +0800
最近改动:2019-05-27 10:27:41.000000000 +0800
创建时间:-
只有9560
个字节,比起静态链接的857984
小了几乎100倍!
[root@localhost got_test]# stat got_test_static
文件:"got_test_static"
大小:857984 块:1680 IO 块:4096 普通文件
设备:26h/38d Inode:37953 硬链接:1
权限:(0770/-rwxrwx---) Uid:( 0/ root) Gid:( 995/ vboxsf)
环境:system_u:object_r:vmblock_t:s0
最近访问:2019-05-27 10:27:37.000000000 +0800
最近更改:2019-05-27 09:44:14.000000000 +0800
最近改动:2019-05-27 09:44:14.000000000 +0800
创建时间:-
所以内存得到了极大的利用。
2、我们进一步看看main函数的汇编代码:
(gdb) disass main
Dump of assembler code for function main:
0x000000000040051d <+0>: push %rbp
0x000000000040051e <+1>: mov %rsp,%rbp
0x0000000000400521 <+4>: sub $0x10,%rsp
0x0000000000400525 <+8>: mov %edi,-0x4(%rbp)
0x0000000000400528 <+11>: mov %rsi,-0x10(%rbp)
0x000000000040052c <+15>: mov $0x4005d0,%edi
0x0000000000400531 <+20>: callq 0x400400
0x0000000000400536 <+25>: leaveq
0x0000000000400537 <+26>: retq
End of assembler dump.
注意到这行0x0000000000400531 <+20>: callq 0x400400
相对静态链接多了一个@plt
这是啥?晕了,不要紧,后面会详细介绍~
我们先看看重定位信息,看看是否可以找到点线索。
3、跳出gdb先看看elf文件的.rel
段信息
运行readelf -r
可以获取elf的重定位表信息,如果puts
地址没有确定,肯定会在重定位表里有记录
[root@localhost got_test]# readelf -r got_test_dyn
重定位节 '.rela.dyn' 位于偏移量 0x380 含有 1 个条目:
偏移量 信息 类型 符号值 符号名称 + 加数
000000600ff8 000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
重定位节 '.rela.plt' 位于偏移量 0x398 含有 2 个条目:
偏移量 信息 类型 符号值 符号名称 + 加数
000000601018 000100000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
000000601020 000200000007 R_X86_64_JUMP_SLO 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
嗯嗯,我们可以看到000000601018 000100000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
这行,其中第4列0000000000000000
地址居然是全零,也就是puts@ptl
地址其实没有定位的。
直觉告诉我们,所以这里就是关键了,在静态链接的时候,这个地址是固定的,而在动态链接的时候是0
,说明在动态链接过程中这个符号是没有地址的。
其实这也很容易理解,既然是动态链接,而且还要在各个进程中share这个代码,肯定在编译过程中符号的地址是无法确定的,肯定是在运行时去确定这个地址,那么问题又来了,运行时怎么确定这个地址呢?动态链接到底跟静态链接有什么区别呢?我们先看看动态链接的做法,然后通过调试来加深这个过程的理解。
1.3.3.2 动态链接的过程
先看图:
这张图可以系统的解释动态链接的过程,下面解释下:
- 第一阶段——编译:gcc将源文件编译成
.o
文件,也就是二进制目标文件,其中的很多符号都没有重定位,需要链接器"助攻"; - 第二阶段——链接:
ld
是链接器,将源文件所需的外部符号链接到一个可执行文件中,比如静态链接中就需要将puts
从glibc的静态链接版本中拷贝
到可执行文件中;而动态链接也是需要这个过程的,只是将符号的地址变成0
;同时会生成静态链接没有的.interp
与.dynamic
段,用来标记动态链接器的地址与动态链接器要运行所依赖的其他库信息(这两个段是动态链接的核心,复杂但是不重要,因为本文不打算深入细节,我们只介绍机制,具体的策略细节,可以搜索更详细的文档说明);同时还会生成两个最重要的段.got
与.plt
,即global offset table与procedure Linkage table。它们是动态链接调用的核心,也是本文的主角;后面会非常详细的说明它们如何配合并完成调用的; - 第三个阶段——加载(映射),程序要执行必须要加载到内存中然后,CPU才能执行程序的命令;正因为在链接阶段某些动态地址函数的地址还未知,而且动态链接器的内存入口地址也未知,所以这个阶段需要操作系统做很多补丁工作;比如,将动态链接器的地址回写到程序的相关段地址(.got),将可执行文件所依赖的所有动态链接库(so文件)文件映射(一般是通过内存映射mmap来实现——速度快)加载到内存等等;
- 第四阶段——动态链接,动态链接器将成为主角;当程序执行到未重定位的函数,比如前面例子中的
puts
,因为地址是未确定的,可以想象成0;当程序执行到这里的时候,就会把执行交给动态链接器(跳转到动态链接器的地址解析程序),并通过动态地址解析函数将程序所需的so文件中相关的函数地址回填给应用程序镜像(.plt段中的puts@plt),等一切动态链接过程都就绪(所有的地址都解析完成),再将执行过程又交给程序本身,在之前位置继续执行(是不是很像COW机制?也很像某些中断处理程序?对,linux很多过程都是懒加载的——如果无需使用就不触及实体,最大限度的提高运行效率)。
是不是很容易?那做项目这个事来类比下,加深理解。
1.3.3.3 用例子来解释动态链接的过程
其实,动态链接的过程可以看做静态链接的扩展,举个例子来解释:
- 首先客户只是提出了静态链接的需求——我需要链接,把不同的程序组成部分合成一个可执行的文件即可;然后,我们觉得使用最直观的方式解决——把各个分散在库中的函数扫描一遍,然后copy一份代码到客户源代码中,最后将得到的一个"超大"的处理过的源文件,最后汇编成二进制代码即可;直观简单,很美好;于是撸起袖子一顿猛虎操作,很愉快的完成了;客户看到程序能够执行,也很愉快;
- 但是,突然有一天,客户说机器全瘫痪了,然后一查发现内存耗尽了;结果查看日志发现一个叫puts的函数因为被频繁使用、而且频繁的实例化,服务器中出现大量重复的puts代码,最终耗尽了内存;
- 项目组马上开会讨论解决方案,大家一致觉得,静态链接不符合未来的需求,造成大量的内存浪费;而且客户的调用需求持续增长,而内存不能无限增长,所以需要将函数代码在各个进程间共享,从而节约内存,避免再次内存耗尽;这样一来,确定了解决方案的机制——库代码运行时多进程共享机制——也就是so(share object)机制;
- 然后拆解任务——A组做编译器的改造,B组做操作系统共享内存开发,C组做动态链接器;完事后大家联调,PM分解任务后,大家开始紧张的开发;
- 2个月后……A组扩展了ELF格式,增加了.interp,.dynamic,扩展了.got,.plt增加了对动态链接器的支持;B组完善了文件内存映射机制,用于高效加载大量的.so文件;C组完成了动态链接器的开发,跟B组对接,能加载大量的so文件到内存映射区;跟A组协商了如何在运行时通过懒加载方式重写动态函数的地址;
- 联调上线,got_test_dync成功编译、链接、加载运行。
[root@localhost got_test]# ./got_test_dyn
hello world
嗯,客户很满意,不仅编译的可执行文件小了,启动多个进程也没压力了~
1.3.3.4 好像还有点疑问
对了,细心的你可能发现,还有两个疑问:
1、上图中第四阶段,got与plt好像在互相调用,这是怎么回事?不可能每次都去链接器解析地址吧?多费事啊;
2、编译、链接后的可执行文件怎么已经有地址了,不是到装载运行的时候才有地址吗?怎么链接完就有地址了?这个地址是什么地址?难道,就代表加载到这个物理地址上?
对于这两个问题,我们后面先解决第二个,然后通过一个gdb的调试例子来一步步查明got与plt的调用关系。
另外,再啰嗦两句,开发操作系统的过程中必须要有编译器的支持才行,这就好比,乔布斯开发了苹果手机,还要开发一个应用商店一样;操作系统是用来管理设备与应用程序的,两者缺一不可。所以,操作系统跟编译器必须完美整合、互相配合才能实现——你编译,我运行的效果;两者不能分离,所以在编译过程中,虚拟地址已经确定了,而且所有可执行程序的入口地址都是一样的,X64下是:0x0000000000400000。下面我们粗略介绍一下linux的内存模型。
可能有些啰嗦,知道的童鞋可以跳过。
2、linux操作系统的内存模型
这是linux 32为与64位操作系统的内存布局,可以看到,无论是64还是32,内存的地址空间被切分成两块,内核与用户态。但是。为啥要这么切分?有什么含义么?我开始学习linux的时候也会有这样的疑惑,很多书都直接告诉大家这个结果,但是没有哪本书告诉你为啥这么分。难道不能分成3个部分,4个部分?或者干脆一个部分?可能有些书会说,CPU有不同的运行级别,ring0-ring3,而linux只用了两个级别,所以分成两个块,一个内核一个用户;或者有些书说为了安全,因为要强制用户态不能访问内核态的代码(反之亦然),所以分成两个彼此隔离的区域,让彼此不可见;但是感觉还是没解释为啥要分,而且为啥分两个区。
我觉得,必须从源头来看,我们开发操作系统的目的是啥?
我觉得是为了管理设备与运行程序就像下图:
操作系统的目的就是使用户(客户程序)能通过操作系统使用硬件资源;所以操作系统要能够管理用户程序与硬件资源,所以操作系统位于这两者之间。
而如果按照供求关系来看,用户是上帝,用户的需求驱动商家不断推出新的产品与功能,这点在操作系统与硬件的发展上也成立。如,用户需要打游戏,所以推动CPU与GPU的飞速发展,商业智能也推动了硬盘、内存与操作系统的变革;如果没有需求,那么商家就不会变革,更不可能繁荣。
扯远了……其实我想说的是,做操作系统应该一开始就要把自己定位搞清楚,自己到底为谁服务?而我认为,操作系统是为应用程序服务的,它的目的是为应用程序提供硬件资源服务;其结构非常类似于我们熟知的C/S体系结构,其中C——应用程序;S——操作系统与硬件。
2.1 用户态与内核态就是服务器客户端
那么,内存的模型就可以简化成:
OS的需求大致是:
- 正如苹果手机,真正有生态价值的是运行在手机中的Apps;操作系统通过运行在其中的应用程序向外提供服务;所以一个OS的好坏与价值都体现在运行在它上面的Apps;
- Apps是OS的上帝,它应该要被服务好(32位模型中Apps可以使用3G的物理内存,而内核只有可怜的1G);所以Apps必须编程友好、运行友好;编写Apps的程序员应该要感觉容易,要低门槛,应该不需要了解细节,不用知道os后面的设备细节;总之,Apps程序员是上帝,必须要照顾好;
- 内核向Apps以API的形式提供服务,Apps不需要了解API实现细节,不需要知道自己能使用多少CPU、内存与网络;总之内核的任务就是对所有客户一视同仁、来者不拒。
- 内核是硬件的代理,硬件所有的所有服务都会通过OS的API向外暴露,最终的使用者只有Apps。
所以,这就容易解释为啥要分为用户态与内核态了:
- 因为计算机可以大致抽象成两个角色:客户端与服务器,那么内存与CPU运行态只要分两个区域就可以了,用户态——Apps使用的地盘;内核态——OS的地盘;
- 为啥要分区,因为App是上帝,它们的编程模型要简单,要大;而内核是供应商,他们要节约,要紧凑,不能占用太多。所以,就像游乐场一样,大部分的场地都是各种游乐设施,这是给用户享受的,而真正属于自己的管理区域却往往很小,可能就几间办公室;
- 彼此是不可见的,客户在游乐场玩耍,从进来到离开都没必要知道游乐场的管理部门在哪,除开出现bug或者错误才会去找管理者的麻烦;这跟Apps与OS的关系很类似——Apps在运行的时候有个假象,就是自己是唯一运行的Apps,而只有管理者(内核)才知道有多少Apps在运行。这个CS模型完美一致,一个CS的构架的程序,客户端可能成千上万,但是每个程序都不需要知道自己在服务器上的内存是在哪?还有没其他Apps在跟自己竞争资源,这都没必要知道,只需要做好自己的事情就行,而这个重任只能服务提供者OS默默承担了;
- 那么App与OS怎么通信呢?在CS的网络程序中,往往通过RPC来实现,客户端通过将local call转化成网络包发送给服务器完成对服务器资源的请求;而服务器通过解析客户端的数据包来call服务器的本地服务代理,生成结果,并转化为客户端能解析的数据包发送给客户端完成应答。Apps与OS的交互十分相似,它们的通信有个专门的名词——系统调用。Apps与OS的系统调用也会经过用户态栈与内核态栈的转换、数据拷贝来完成OS对Apps的服务,总之,它们是严格区分的,进水不犯河水,彼此都不需要知道对方的存在,职责与代码完全分离。
所以,Apps与OS一个是客户一个服务商,一个是上帝一个是服务提供者,所以OS会被分割成两个部分,两者互相隔离,通过发送系统调用来沟通。总结下,这样做的好处是:
- 可以集中力量办大事,OS可以尽力向更多的Apps提供服务;APP可以尽力不受打扰的开发各种应用程序;
- 各方可以专注办好自己的事情,OS只需要提供稳定的服务环境,Apps只需要利用OS(硬件)能力更好、更快的开发出五花八门的应用程序,完善与发展生态。
2.2 用户态的进一步划分
稍微扩展了一下,我们之前有两个疑问,其中一个就是地址,为啥要从0x0000000000400000位置开始执行?为啥puts
已经有了自己的地址了?要回答这个问题,就要了解用户态空间的布局与操作系统怎么管理用户态的地址空间。
2.2.1 虚拟地址空间
我们知道,在系统中运行的地址可以分为虚拟地址与物理地址,虚拟地址是给程序用的地址空间,而物理地址是实实在在的机器内存。往往,程序所能看到的地址空间比机器所拥有的物理地址空间要大,X64中要大很多,比如,程序只有2G内存,而应用程序能看到的是4G,而且32位系统中永远是4G。
那么为什么要分虚拟内存与物理内存呢?其实我们在上面一节已经有过回答了——为了OS能更好的提供服务,降低Apps的开发难度,你回忆下,在开发应用程序的时候你关注过能使用的内存地址编号么?:-)
大部分的童鞋应该没有关注过,只管new,malloc就行了,因为这些new出来的内存都是虚拟内存,只有访问的时候才会有物理内存分配。
当用户态的程序要读取内存的时候,内核的内存管理模块才会将其翻译成物理地址,然后把请求发送给内存IO,最后从内存中取出数据进行运算,所以其实,CPU能操作的内存也都是虚拟内存,这个过程叫做内存映射。相当于用户的代码都是虚拟内存,而os(还有CPU的mmu)来管理从虚拟内存到物理内存的映射,最后把对虚拟内存的访问转化成为物理地址。
所以,回答一下开始的那个问题,0x0000000000400000这个地址是虚拟地址,所有的64位elf程序都是从这个位置开始执行的,当程序加载OS中后,操作系统负责将其映射到具体的物理内存,保证每个应用程序彼此独立、隔离。
2.2.2 用户态虚拟内存管理
虽然这不是重点,但是我们还是要提一下,因为我们后面会有操作的部分,在涉及到具体虚拟内存的时候会更好的理解。
2.2.2.1 虚拟内存的布局
当然为了更好的服务客户,内核还需要对用户态内存空间进行划分,因为客户应用程序会有很多种不同内存的分配任务:
- 客户端程序有些内存是在运行时动态分配的,也就是编译、链接时并不知道最后会占用多少虚拟地址(物理地址),比如New,malloc出来的内存;
- 有些变量是固定的,不会动的(静态、全局变量)
- 函数调用会用到栈,而调用栈是动态变化的,随着栈指针(esp)的增加而增加(其实是减小);
- 需要多进程共享的so文件,这些so文件往往通过内存映射的方式加载到内存的地址空间里,用户可以通过操作内存数组一样来访问内存(是不是很友好的特性?)而且还很快;
- 还有就是程序的代码块(.text段),这个数据的内存肯定也不会变化,不会运行着突然程序还增大了,除开出现会自己改写自己代码的程序~
针对这些不同的内存类型,我们需要将用户态空间进行划分,分成不同的区域与其对应,这样做的好处是——分而治之,职责分离。对不同类型的内存处理应该使用不同的策略来应对,而不同策略是彼此分离的,互不相干的,它们各司其职——这就是机制与策略分离的构架思想。linux中大量使用这种思想——比如虚拟文件系统、进程调度模块等等,从而使得linux系统看上去这么简单而美妙。
那么用户虚拟内存空间的布局大致是这个样子:
可见,对于内存不会发生变化的部分:代码区(.text),全局变量区(.data),未初始化的静态变量区(.bss),是从地地址向高地址增长,位于底部;而动态的内存:栈与堆,都是位于上半部分,中间还夹着共享内存区。
这样做的好处:
- 内存的布局更紧凑,静态的与动态的内存隔开了,降低内存映射的难度;
- load程序运行的过程中更快速的分配虚拟地址空间。
2.2.2.2 分页机制
分好了大块内存区域,我们要实际使用,还得继续分,下面简单介绍著名的分页管理机制。
linux操作系统对内存并不是分段管理的(段基地址+偏移),而是通过分页(paging)机制来管理的;啥意思呢?简单来说linux操作内存不是一个字节一个字节来的,而是一块内存一块内存来的,这个一块的粒度是4k=4096byte。这就会引出一个问题,如果一个文件只有1byte的内容,在linux下也会为其分配4k的空间,而后续的文件只能在另外的4k中找空间了,这就是地址空间对齐。
举个现实中的例子,就好比班级的管理,老师并不是一个个同学来管理的,而是分了小组,每个同学都只能属于一个小组,而且每个小组都任命了小组长,负责管理本小组的成员。老师下达通知的时候会把小组长都叫来,交代完事情,然后小组长再通知给每个组员;而老师找某个特定的同学则是通过小组+组内偏移来查找的,比如A同学位于1组,第3号,则只要找到第一组组长,然后叫组长把第三号同学叫过来训话即可~
这样做的好处,还是这个原因,内存有限,必须省着用。有些物理内存页如果长期在内存而不被访问要能够换出到swap分区(磁盘),然后让更多的应用程序可以有物理内存使用,当再次访问换出的空间时,再从swap中拿出来,显然这个过程不可能一个个字节来完成,根据linux的标准设置成4k个字节为单位。
所以,OS服务"上帝"真实无所不用其极啊~所以要珍惜每一次调用,每一次内存分配。
总结下,如下图:
2.2.2.3 VMA
我们知道了分页机制,很漂亮,但是在工程中对于大量的内存管理4k的粒度又显得有些小了,比如对于4G的内存可以分配1M个=100万个页,太大了;所以,一个进程在实际管理的时候,还不是以page为单位,还有一个VMA(virtual memory area)的数据结构在page之上。一个page对于实际映射的程序来说,分分钟就能超过4k很多倍,比如大块内存的分配,所以4k的粒度太小,所以引入了VMA的管理单元,一个VMA会包含多个类型相同的page,最后的布局可能会是这样的:
我们可以通过/proc/pid/maps来查看一个进程的内存映射信息:
[root@localhost tmp]# cat /proc/19887/maps
00400000-00401000 r-xp 00000000 00:26 37913 /home/shared/tmp/vp_addr/vp2
00600000-00601000 r--p 00000000 00:26 37913 /home/shared/tmp/vp_addr/vp2
00601000-00602000 rw-p 00001000 00:26 37913 /home/shared/tmp/vp_addr/vp2
01f26000-01f47000 rw-p 00000000 00:00 0 [heap]
7f86ba48a000-7f86ba64c000 r-xp 00000000 fd:00 13236 /usr/lib64/libc-2.17.so
7f86ba64c000-7f86ba84c000 ---p 001c2000 fd:00 13236 /usr/lib64/libc-2.17.so
7f86ba84c000-7f86ba850000 r--p 001c2000 fd:00 13236 /usr/lib64/libc-2.17.so
7f86ba850000-7f86ba852000 rw-p 001c6000 fd:00 13236 /usr/lib64/libc-2.17.so
7f86ba852000-7f86ba857000 rw-p 00000000 00:00 0
7f86ba857000-7f86ba879000 r-xp 00000000 fd:00 709 /usr/lib64/ld-2.17.so
7f86baa6d000-7f86baa70000 rw-p 00000000 00:00 0
7f86baa76000-7f86baa78000 rw-p 00000000 00:00 0
7f86baa78000-7f86baa79000 r--p 00021000 fd:00 709 /usr/lib64/ld-2.17.so
7f86baa79000-7f86baa7a000 rw-p 00022000 fd:00 709 /usr/lib64/ld-2.17.so
7f86baa7a000-7f86baa7b000 rw-p 00000000 00:00 0
7ffcf139a000-7ffcf13bb000 rw-p 00000000 00:00 0 [stack]
7ffcf13bb000-7ffcf13bd000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
其中每一行表示这个进程的一个VMA段,我们可以看到stack,heap,so这些熟悉的内存块都会映射到单独的VMA中进行管理,而实际malloc的时候也会操作vma,这里不做详细展开。
3、使用gdb来调试程序,看看运行时的动态链接
我都快忘了为啥要写这篇文章了,出发太久都忘了为什么要出发了……
对,我们要讲的是动态链接,对,我们还有一个问题要回答,就是动态链接器如何在运行时找到操作系统的用户虚拟内存空间中共享内存中puts
的实际调用点的,也就是got与plt的操作。
下面我们开始:
1、先写一段C代码
#include
#include
int main(int argc, char* argv[])
{
printf("hello\n");
printf("world\n");
return 0;
}
2、编译、链接:
gcc -g -o got_test got_test.c
gcc默认使用动态链接的方式链接glibc中的库函数。
3、gdb调试
[root@localhost got_test]# gdb got_test
......
Reading symbols from /home/tmp/got_test/got_test...done.
(gdb) l
1 #include
2 #include
3
4 int main(int argc, char* argv[])
5 {
6 printf("hellow\n");
7 printf("world\n");
8 return 0;
9 }
(gdb)
l指令可以打印出源代码,说明符号加载完毕。
-
开始调试,首先我们再第6行与第7行下两个断点
(gdb) b 6 Note: breakpoint 1 also set at pc 0x40052c. Breakpoint 2 at 0x40052c: file got_test.c, line 6. (gdb) b 7 Breakpoint 3 at 0x400536: file got_test.c, line 7.
开始运行
got_test
(gdb) r
Starting program: /home/tmp/got_test/got_test
Breakpoint 1, main (argc=1, argv=0x7fffffffe468) at got_test.c:6
6 printf("hellow\n");
(gdb)
看到断点在6行停止了运行,我们看看此时pc
寄存器的指令,也就是看看当前执行到的汇编代码
(gdb) x /5i $pc
=> 0x40052c : mov $0x4005e0,%edi
0x400531 : callq 0x400400
0x400536 : mov $0x4005e7,%edi
0x40053b : callq 0x400400
0x400540 : mov $0x0,%eax
(gdb)
说明下 x
表示查看内存,5
表示显示5行输出,i
表示输出代码(instruction),可以查看gdb的参考。
可见程序执行到了0x40052c
这里,其实还在为调用puts
准备参数:
mov $0x4005e0,%edi
代表将0x4005e0
这个地址拷贝到edi
寄存器中,而edi
寄存器是函数调用的第一个参数,这是IA32构架约定的。
这里我们已经知道puts的参数是hello
这个字符串,而字符串作为程序的资源应该放在全局变量区,也就是.rodata
段。我们可以验证下;
- 验证输入
(gdb) x/8c 0x4005e0
0x4005e0: 104 'h' 101 'e' 108 'l' 108 'l' 111 'o' 119 'w' 0 '\000' 119 'w'
嗯嗯,通过使用c
打印字符串的形式可以看到正是hello
字样,证实了我们的猜测。其实还可以看看它是否位于rodata
段,我们使用readelf -SW got_test
这个命令
[16] .rodata PROGBITS 00000000004005d0 0005d0 00001d 00 A 0 0 8
由于篇幅我们截取一段,可见0X4005e0正好位于.rodata
段内,完美。
-
接着我们继续看gdb
这里要进入重点了,你可能会注意到
0x400531
这行,对这就是程序调用puts的起点,而地址应该就是: callq 0x400400 0x400400
,我们进去看看:(gdb) x /5i 0x400400 0x400400
: jmpq *0x200c12(%rip) # 0x601018 0x400406 : pushq $0x0 0x40040b : jmpq 0x4003f0 0x400410 <__libc_start_main@plt>: jmpq *0x200c0a(%rip) # 0x601020 0x400416 <__libc_start_main@plt+6>: pushq $0x1 (gdb) 这里是个跳转,到
0x601018
这个地址,慢着,我们先思考一个问题。不是代码都在.text
段吗?这里的0x400400
与0x601018
差了2100248个字节=21M空间,我有这么多代码么?何况是动态链接。这是怎么回事?我们还是老办法,readelf来看看这些地址在哪些段吧。
[12] .plt PROGBITS 00000000004003f0 0003f0 000030 10 AX 0 0 16 [13] .plt.got PROGBITS 0000000000400420 000420 000008 00 AX 0 0 8 [24] .got.plt PROGBITS 0000000000601000 001000 000028 08 WA 0 0 8 [25] .data PROGBITS 0000000000601028 001028 000004 00 WA 0 0 1
可见
0x400400
属于.plt
段,而0x601018
属于.got.plt
段。此时我们要注意到.plt
段的属性AX
——可执行可映射。也就是说,这个段中包含了可执行代码!所以这个段也是可以运行的!所以其实除开.text
外还有其他段里面包含代码啊。我们再找找有哪些包含代码呢?[11] .init PROGBITS 00000000004003c8 0003c8 00001a 00 AX 0 0 4 [12] .plt PROGBITS 00000000004003f0 0003f0 000030 10 AX 0 0 16 [13] .plt.got PROGBITS 0000000000400420 000420 000008 00 AX 0 0 8 [14] .text PROGBITS 0000000000400430 000430 000192 00 AX 0 0 16 [15] .fini PROGBITS 00000000004005c4 0005c4 000009 00 AX 0 0 4
嗯嗯,不错,都来了,一共5个段。
.init
——类似程序的构造函数,main在运行之前要先运行这个段的代码;.fini
——类似程序的析构代码,程序退出的时候会调用;.text
——主要客户代码都在这儿;plt
与.plt.got
——就是动态链接器的代码了,哈哈。- 现在我们用gdb来调试看看动态链接器怎么工作的
继续4中的
0x400400
这一句: jmpq *0x200c12(%rip) # 0x601018 我们跳转到
0x601018
看看这个地址,也就是.got.plt
段的内容:因为这个段的属性是
WA
说明不是代码,所以jmpq
实际是引用了0x601018
指向的地址:(gdb) x/8b 0x601018 0x601018: 0x06 0x04 0x40 0x00 0x00 0x00 0x00 0x00
是
0x400406
(intel处理器是小端表示二进制,所以从后往前读),这是哪?继续查段表:[12] .plt PROGBITS 00000000004003f0 0003f0 000030 10 AX 0 0 16
又跳回了
.plt
段。(gdb) x /5i *0x601018 0x400406
: pushq $0x0 0x40040b : jmpq 0x4003f0 0x400410 <__libc_start_main@plt>: jmpq *0x200c0a(%rip) # 0x601020 0x400416 <__libc_start_main@plt+6>: pushq $0x1 0x40041b <__libc_start_main@plt+11>: jmpq 0x4003f0 加个
*
可以实现跳转。注意0x40040b
这行代码,可见跳转还在继续,我们继续跟吧。: jmpq 0x4003f0 0x4003f0
这个地址好特殊,查表一看居然就是plt
的第一个字符的位置!好像快到链接器代码了,打印代码出来看看:(gdb) x/5i 0x4003f0 0x4003f0: pushq 0x200c12(%rip) # 0x601008 0x4003f6: jmpq *0x200c14(%rip) # 0x601010 0x4003fc: nopl 0x0(%rax) 0x400400
: jmpq *0x200c12(%rip) # 0x601018 0x400406 : pushq $0x0 厄,又跳回了
0x601010
,如果没记错,又回到了.got.plt
段[24] .got.plt PROGBITS 0000000000601000 001000 000028 08 WA 0 0 8
细心的读者可以看到,这实际是
.got.plt
的第二个字节处。它是啥?(gdb) x/8b 0x601010 0x601010: 0x90 0x18 0xdf 0xf7 0xff 0x7f 0x00 0x00
啊!终于到头了,因为我们看到了一个超大的地址空间——
0x7ffff7df1890
。根据上一张的虚拟内存讲解,这个虚拟地址空间应该是位于共享内存区域!也就是动态链接器的地址——因为动态链接器也是一个so。我们赶快看看吧。-
看看
got_test
的内存映射文件linux查看内存映射文件可以通过
/proc/pid/maps
这个伪文件系统来实现。先找到pid
[root@localhost got_test]# ps aux|grep got_test root 22547 0.0 0.5 175120 21012 pts/1 S+ 00:00 0:00 gdb got_test root 22565 0.0 0.0 4208 364 pts/1 t 00:03 0:00 /home/tmp/got_test/got_test root 22712 0.0 0.0 112724 988 pts/0 R+ 00:52 0:00 grep --color=auto got_test
可见第二个是实际的got_test,pid=22565;然后继续:
[root@localhost got_test]# cat /proc/22565/maps 00400000-00401000 r-xp 00000000 fd:00 51394561 /home/tmp/got_test/got_test 00600000-00601000 r--p 00000000 fd:00 51394561 /home/tmp/got_test/got_test 00601000-00602000 rw-p 00001000 fd:00 51394561 /home/tmp/got_test/got_test 7ffff7a0e000-7ffff7bd0000 r-xp 00000000 fd:00 13236 /usr/lib64/libc-2.17.so 7ffff7bd0000-7ffff7dd0000 ---p 001c2000 fd:00 13236 /usr/lib64/libc-2.17.so 7ffff7dd0000-7ffff7dd4000 r--p 001c2000 fd:00 13236 /usr/lib64/libc-2.17.so 7ffff7dd4000-7ffff7dd6000 rw-p 001c6000 fd:00 13236 /usr/lib64/libc-2.17.so 7ffff7dd6000-7ffff7ddb000 rw-p 00000000 00:00 0 7ffff7ddb000-7ffff7dfd000 r-xp 00000000 fd:00 709 /usr/lib64/ld-2.17.so 7ffff7fef000-7ffff7ff2000 rw-p 00000000 00:00 0 7ffff7ff9000-7ffff7ffa000 rw-p 00000000 00:00 0 7ffff7ffa000-7ffff7ffc000 r-xp 00000000 00:00 0 [vdso] 7ffff7ffc000-7ffff7ffd000 r--p 00021000 fd:00 709 /usr/lib64/ld-2.17.so 7ffff7ffd000-7ffff7ffe000 rw-p 00022000 fd:00 709 /usr/lib64/ld-2.17.so 7ffff7ffe000-7ffff7fff000 rw-p 00000000 00:00 0 7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0 [stack] ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
注意到这一行:
7ffff7ddb000-7ffff7dfd000 r-xp 00000000 fd:00 709 /usr/lib64/ld-2.17.so
,我们.got.plt
第二个字节的指针地址0x7ffff7df1890
就是位于这个地址范围,正好就是/usr/lib64/ld-2.17.so
动态链接器的so!!!好了,我们终于找到了动态链接器,下面,我们看看动态链接器的解析函数叫啥?
(gdb) x/5i 0x7ffff7df1890 0x7ffff7df1890 <_dl_runtime_resolve_xsave>: push %rbx 0x7ffff7df1891 <_dl_runtime_resolve_xsave+1>: mov %rsp,%rbx 0x7ffff7df1894 <_dl_runtime_resolve_xsave+4>: and $0xffffffffffffffc0,%rsp 0x7ffff7df1898 <_dl_runtime_resolve_xsave+8>: sub 0x20b411(%rip),%rsp # 0x7ffff7ffccb0 <_rtld_local_ro+176> 0x7ffff7df189f <_dl_runtime_resolve_xsave+15>: mov %rax,(%rsp) (gdb)
很好,我们看到了
_dl_runtime_resolve
的字样,这就是本尊!总结下原理:got_test在编译链接时,因为不知道puts这个调用的动态库地址,所以用0代替,并在
plt
段中埋了个调用点puts@plt
;然后程序在加载的时候(execv系统调用)会将动态库的解析器的虚拟地址插入到.got.plt
段的第2个字节处;然后程序通过跳转到这个位置找到解析器完成最后的解析。嗯嗯,完美了!- 等等,不对,好像还有点问题
别着急,我们再看一下,还有个重要的线索似乎忽略了,就是
.got.plt
的段的属性![24] .got.plt PROGBITS 0000000000601000 001000 000028 08 WA 0 0 8
对
WA
这个W是可写的意思,也就是说这个段的内容是可以在动态运行时被改写的!!!好像我们漏了很重要的信息。试想下,如果每次调用puts都走这么长一串流程,那么效率岂不是很低?对于Apps这个上帝来说,我就是要总是调,而且还要效率高。那么是不是可以将这个计算结果缓存下来呢?真实的查找过程只进行一次,只要得到这个虚拟地址,就不用查了啊,存在某个地方不就得了?
对,这就是
.got.plt
可写的原因,因为第二次以及后面的第n次调用就不用调用动态链接器了,直接从缓存的结果返回了,调用的速度跟静态链接一样快了。- 我们验证下这个缓存吧
还记得我们的
got_test.c
源代码吗?我写了两个printf,埋了个伏笔,大家猜到了么?我们再gdb中跳转到第二个printf这个断点处:(gdb) n hellow Breakpoint 3, main (argc=1, argv=0x7fffffffe468) at got_test.c:7 7 printf("world\n"); (gdb) x/5i $pc => 0x400536
: mov $0x4005e7,%edi 0x40053b : callq 0x400400 0x400540 : mov $0x0,%eax 0x400545 : leaveq 0x400546 : retq (gdb) x/5i 0x400400 0x400400 : jmpq *0x200c12(%rip) # 0x601018 0x400406 : pushq $0x0 0x40040b : jmpq 0x4003f0 0x400410 <__libc_start_main@plt>: jmpq *0x200c0a(%rip) # 0x601020 0x400416 <__libc_start_main@plt+6>: pushq $0x1 (gdb) x/5i *0x601018 0xfffffffff7a7e530: Cannot access memory at address 0xfffffffff7a7e530 (gdb) x/5i 0x601018 0x601018: xor %ah,%ch 0x60101a: cmpsl %es:(%rdi),%ds:(%rsi) 0x60101b: idiv %edi 0x60101d: jg 0x60101f 0x60101f: add %ah,%al (gdb) x/8b 0x601018 0x601018: 0x30 0xe5 0xa7 0xf7 0xff 0x7f 0x00 0x00 (gdb) 我们可以看到第一次调用puts的时候这个跳转指令
0x400400
的目标地址是:: jmpq *0x200c12(%rip) # 0x601018 (gdb) x/8b 0x601018 0x601018: 0x06 0x04 0x40 0x00 0x00 0x00 0x00 0x00
,是plt段内代码;现在变成了:
(gdb) x/8b 0x601010 0x601010: 0x30 0xe5 0xa7 0xf7 0xff 0x7f 0x00 0x00
是不是很神奇?确实是缓存了,在看看这个地址
0xfffffffff7a7e530
是不是puts的源代码?(gdb) x/5i 0x7ffff7a7e530 0x7ffff7a7e530 <_IO_puts>: push %r13 0x7ffff7a7e532 <_IO_puts+2>: push %r12 0x7ffff7a7e534 <_IO_puts+4>: mov %rdi,%r12 0x7ffff7a7e537 <_IO_puts+7>: push %rbp 0x7ffff7a7e538 <_IO_puts+8>: push %rbx (gdb)
果然是!好了这下真的结束了。我们总结下。
- 总结:
4、总结
是的,动态链接是linux程序运行的基石,可以说一个繁忙的linux时刻都在进行数百万次的动态加载与链接过程,但是要实现一个工程可行的动态链接方案,真正达到既节约内存又运行快速的目的是不容易的,其中要通过编译器、链接器、操作系统虚拟内存管理、内存映射机制、glibc库与动态链接器的联合工作才能完成。可见,轻而易举的事情在工程师眼里是极其精密与复杂的。最后希望本文对你有帮助。