Linux程序内存布局:
启动程序的默认加载点是在链接阶段才添加的,通常放在程序内存映射的起始处(这是可执行文件和动态库之间的唯一区别)。启动代码有两种不同方式:
- crt0:“纯粹”的入口点,这是程序代码的第一部分,在内核控制下执行
- crt1:更现代化的启动例程,可以在main函数执行前和程序终止后完成一些任务
链接阶段
- 重定位
将单独目标中不同类型的节拼接到程序内存映射中。将节中从0开始的地址转换成最终程序内存映射中更具体地地址范围。
目标文件中每个节的起始地址都是0
objdump -D tmp.o
root@DESKTOP-5F95GBN:~/code# objdump -D tmp.o tmp.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <main>: 0: f3 0f 1e fa endbr64 4: 55 push %rbp 5: 48 89 e5 mov %rsp,%rbp 8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # f <main+0xf> f: b8 00 00 00 00 mov $0x0,%eax 14: e8 00 00 00 00 callq 19 <main+0x19> 19: b8 00 00 00 00 mov $0x0,%eax 1e: 5d pop %rbp 1f: c3 retq Disassembly of section .rodata: 0000000000000000 <.rodata>: 0: 68 65 6c 6c 6f pushq $0x6f6c6c65 5: 20 77 6f and %dh,0x6f(%rdi) 8: 72 6c jb 76 <main+0x76> a: 64 fs ... Disassembly of section .comment: 0000000000000000 <.comment>: 0: 00 47 43 add %al,0x43(%rdi) 3: 43 3a 20 rex.XB cmp (%r8),%spl 6: 28 55 62 sub %dl,0x62(%rbp) 9: 75 6e jne 79 <main+0x79> b: 74 75 je 82 <main+0x82> d: 20 39 and %bh,(%rcx) f: 2e 34 2e cs xor $0x2e,%al 12: 30 2d 31 75 62 75 xor %ch,0x75627531(%rip) # 75627549 <main+0x75627549> 18: 6e outsb %ds:(%rsi),(%dx) 19: 74 75 je 90 <main+0x90> 1b: 31 7e 32 xor %edi,0x32(%rsi) 1e: 30 2e xor %ch,(%rsi) 20: 30 34 2e xor %dh,(%rsi,%rbp,1) 23: 31 29 xor %ebp,(%rcx) 25: 20 39 and %bh,(%rcx) 27: 2e 34 2e cs xor $0x2e,%al 2a: 30 00 xor %al,(%rax) Disassembly of section .note.gnu.property: 0000000000000000 <.note.gnu.property>: 0: 04 00 add $0x0,%al 2: 00 00 add %al,(%rax) 4: 10 00 adc %al,(%rax) 6: 00 00 add %al,(%rax) 8: 05 00 00 00 47 add $0x47000000,%eax d: 4e 55 rex.WRX push %rbp f: 00 02 add %al,(%rdx) 11: 00 00 add %al,(%rax) 13: c0 04 00 00 rolb $0x0,(%rax,%rax,1) 17: 00 03 add %al,(%rbx) 19: 00 00 add %al,(%rax) 1b: 00 00 add %al,(%rax) 1d: 00 00 add %al,(%rax) ... Disassembly of section .eh_frame: 0000000000000000 <.eh_frame>: 0: 14 00 adc $0x0,%al 2: 00 00 add %al,(%rax) 4: 00 00 add %al,(%rax) 6: 00 00 add %al,(%rax) 8: 01 7a 52 add %edi,0x52(%rdx) b: 00 01 add %al,(%rcx) d: 78 10 js 1f <.eh_frame+0x1f> f: 01 1b add %ebx,(%rbx) 11: 0c 07 or $0x7,%al 13: 08 90 01 00 00 1c or %dl,0x1c000001(%rax) 19: 00 00 add %al,(%rax) 1b: 00 1c 00 add %bl,(%rax,%rax,1) 1e: 00 00 add %al,(%rax) 20: 00 00 add %al,(%rax) 22: 00 00 add %al,(%rax) 24: 20 00 and %al,(%rax) 26: 00 00 add %al,(%rax) 28: 00 45 0e add %al,0xe(%rbp) 2b: 10 86 02 43 0d 06 adc %al,0x60d4302(%rsi) 31: 57 push %rdi 32: 0c 07 or $0x7,%al 34: 08 00 or %al,(%rax) ...
- 解析引用
将地址范围线性地转换成程序映射地址范围,并为不同部分间代码建立关联,使其成为一个整体。即不同目标文件之间相互引用了程序内存(函数入口点)或数据内存(全局数据、静态数据、外部数据)中地地址。
当引用同一目标文件中的地址时,函数或数据被拓展成相对于代码节的起始地址的相对偏移。
当引用其他文件的地址时,在生成完整的映射之前会被一直当作未解析引用。
解析符号的工作:- 检查拼接到程序内存映射中的节
- 找到哪些代码产生了外部调用
- 计算该引用在内存映射中的地址
- 将机器指令中的伪地址换为程序内存映射的实际地址
内核加载流程
- shell 委托任务后,内核通过调用exec函数族做出相应,它们最终调用 sys_execve 函数
- 函数 search_binary_handler(文件 fs/exec.c 中) 识别可执行文件
- 如果是ELF格式,调用 load_elf_binary 函数(fs/binfmt_elf.c 中)
- 定位可执行二进制文件中的 PT_INTERP 段,用于动态加载阶段
加载器
链接器是一个高度复杂的模块,它要准确的区分出各种节的属性(代码、未初始化数据、初始化数据、构造器、调试信息等)。
装载器则要简单。它将链接器创建的节复制到内存映射中,装载器不需要了解各个节的内部结构就能完全复制。它只要关心节读写属性,以及在启动前是否需要打补丁。(涉及到动态链接时还要比复制数据块更复杂一些)装载器还会根据节的相同装载需求将链接器创建的节组合成段。装载器的段一般会携带多个拥有相同访问属性的节。
可使用 readelf 检查段
暂时只讨论静态链接场景,使用 gcc 的 -static 生成静态链接代码。
- 加载器读取程序二进制文件段的头,确定每个段的地址和字节长度。
此阶段装载器仍不会向程序的内存映射写入任何数据。装载器此阶段只建立并维护一组包含可执行文件段(就是每个段的页宽)和程序内存映射关联的结构(比如 vm_are_struct) - 分配给进程的物理内存页和程序内存映射表之间的虚拟内存映射关系已经建立好后,当内核在运行时需要某个程序段时才动态加载对应的页。
程序执行入口点
装载完成后(即准备程序基本数据和复制程序必要的节到内存中),装载器会查询ELF头的 e_entry 字段的值。这个值包含的程序内存地址指定了该程序从何处开始。e_entry 值包含了 .text 节的首地址,通常就是 _start 函数的首地址。
_start() 函数为接下来需要调用的 __libc_start_main 函数准备入参
__libc_start_main 为程序启动准备环境的过程中扮演了重要角色。启动阶段,它不仅会为程序设置好运行环境,会执行以下操作:
- 启动程序的线程
- 调用 _init() 函数在main函数前完成必要的初始化操作。(gcc 利用 __attribute__((constructor)) 关键字对程序启动前的自定义操作提供支持)
- 注册 __fini() 和 rtld_fini() 函数,这些函数会在程序调用终止时调用。通常 _fini() 和 _init() 操作顺序相反
- gcc 利用 __attribute__((destructor)) 关键字对程序结束时的自定义操作提供支持
最后,所有准备操作完成时,__libc_start_main() 调用 main() 函数,启动程序。
函数调用过程:
ELF.e_entry字段 -> _start() -> int __linc_start_main() -> main()
重用概念
静态库:
动态库:
动态库的两种实现方式:
- 装载时重定位 (load time relocation, LTR)
这种方式自身二进制文件无需包含多余的代码,缺点是:如果多个程序在运行时需要同一系统功能,那么每个程序都要加载一份副本。
原因是在装载时重定位技术为了实现向应用程序特定地址映射的功能,修改了动态库中 .text 节的符号。对于其他应用程序,载入动态库的地址范围可能不同,因此之前修改过的动态库代码不能适用于另一个应用程序内的内存布局。 - 运行时重定位
仅需加载一次,其他需要这个库的程序都可以使用。利用位置无关代码(position independent code, PIC)实现通过修改动态代码库访问符号的方式,只要一份加载到某个进程中内存映射的副本,就能映射到任何应用程序进程中,通过内存映射实现共享。
虚拟内存的概念为运行时共享的实现奠定了基础。如果一个实际进程的内存映射只是从0开始的进程内存映射,那么我们必须能创建出多个不同进程组成的实际进程内存映射。这就是动态库的运行时共享机制。
动态链接详解
- 构建动态库
构建动态库生成的二进制文件本质上和可执行文件是相同的,唯一区别是动态库缺少了让其独立执行的启动例程。
Windows中构建动态库时必须对所有的引用进行解析。如果正在构建的动态库调用了其他动态库中的函数,那么在构建阶段就必须找到其他依赖的库的引用符号。
Linux中默认选项能让编译更加灵活:有些符号可不在编译阶段进行解析,而解析引用的过程可以在完成链接其他的动态库后生成最终二进制文件时再执行。也可用选项使其像windows一样严格。
在Linux中,可以修改动态库使其可以独立运行。 - 构建可执行文件(只查找符号)
与使用静态库不同,链接器会将之前已完成链接的动态库二进制文件与正在编译的项目合并。这一步链接器会把所有注意力放在动态库的符号上,这个阶段中链接器不关心任何节的细节,无论代码节(.text)还是数据节(.data 和 .bss)。链接器在这个阶段认为“所有符号都能被正确解析”。链接器只会检查二进制文件中所需的符号是否都能在动态库中找到,一旦找到所有符号,链接器就会完成任务并创建可执行程序。
这种认为“所有符号都能被正确解析”的做法即只构建接口,细节在运行时加载。 - 运行时装载和符号解析
前一个阶段已经对动态库中可执行文件所需的符号做了检验,现在运行时会检验动态库能否正常工作:
1. 程序先找到动态库位置
2. 进程将动态库载入内存映射中,这时必须确保构建阶段时链接中的符号在运行时能被正常引用。运行时动态库中的函数符号必须与构建阶段中的完整函数签名相同。
3. 运行时要将程序的符号解析到正确地址上,这个地址是动态库所映射到的进程内存映射中的地址。与一般装载时链接不同,该阶段会把动态库装载到进程的内存映射中——这就是动态链接。
动态库的特点
1. 创建动态库需要完整的构建过程
静态库只需要编译,动态库则需要编译和链接。这使得动态库更加类似可执行文件,唯一区别是可执行文件包含启动代码。
2. 动态库可以链接其他库
可执行文件和动态库都可以加载和链接动态库。
应用程序二进制接口(ABI)
由软件模块提供给客户端的接口通常是应用程序编程接口(API)。当提供给客户端的是二进制文件时,接口的定义就会发生变化,成为应用程序二进制接口。可以把ABI当作编译链接过程中根据源代码接口创建的符号集合(主要是函数入口点)。
- 在构建阶段,客户端二进制文件根据库的ABI接口进行链接。在此阶段只会检查动态库的外部符号(比如函数指针这样的ABI),并不关心任何节(函数体)。
- 为了能完成动态链接的运行,运行时使用的动态库二进制数据必须导出与构建时一致的ABI(ABI不能改变)。
静态库与动态库对比
静态库
Linux 静态库 https://www.cnblogs.com/zhh567/p/16664821.html
Windows 静态库 https://www.cnblogs.com/zhh567/p/16536626.html
注意
- 丢失符号可见性和唯一性的可能性
当静态库链接到客户端二进制文件时,静态库中符号成为客户端二进制文件符号列表中的一部分,并保留了原有的可见性,全局符号依然为全局符号,局部符号依然为局部符号。
当静态库链接到动态库时,这个规则被打破。
动态库的隐含假设是模块化,其能完全自主的管理其局部符号,为此内部对于库用户是透明的。静态库提供外部接口和内部细节,动态库只提供接口。这种设计原则会影响到静态库符号可见性。许多动态库被加载到相同的进程中,一个动态库会包含与其他动态库具有相同名称的局部符号,而链接器能避免命名的冲突。静态库的符号不会作为全局可见的符号保留,而是变为私有符号或被忽略。 - 静态库使用禁忌
- 不该使用一个链接了多个动态库的静态库(除libc),改用动态库更有利
- 如果实现的功能需要存在一个类的单例,应该封装为动态库而非静态库。原因即可能丢失符号可见性和唯一性
如果使用动态库实现功能模块,应当将日志类放到另一个动态库中
静态库链接规则
- 依次链接静态库
- 链接时从传给链接器的链接库列表中最后一个静态库开始,反方向逐个链接
- 链接器会对静态库详细的检索,所有目标文件中,只有包含客户二进制文件实际所需符号的目标文件才会进行链接
静态库转为动态库
- Linux下 ar -x <static-lib>.a
- Windows下 使用 lib.exe
- 链接器将提取出的目标文件构建动态库
原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/288712.html