Linux系统学习之---库的理解和加载(毛坯初版...)
重点:
- 大致了解静态库形成程序的过程
- ELF文件如何加载到内存 , 以及如何转化为进程(逻辑/物理/虚拟地址,虚拟地址空间).
- 动态库和可执行程序关联的方式.
- 详细了解动态库的加载方式
一.ELF文件组成:
1.一个可执行程序的生成
我们使用gcc 不带任何特殊选项生成一个 a.out可执行文件时 , 中间其实隐藏了一些过程.
源文件(.c) -> 预处理文件(.i) -> 汇编文件(.s) -> 目标文件(.o) -> 可执行文件(.bin)
其中,对源文件进行头文件展开/处理预处指令/处理宏替换生成预处理文件 , 预处理文件解析成汇编语言成为目标文件 , 至此为止都是一个文件的独角戏 .
目标文件转换为可执行文件的过程叫做链接 , 往往涉及到其他文件 , 所以肯定要有一套标准来提升链接效率—ELF文件格式就是这样一套标准 , 目标文件/可执行文件/库文件都是ELF格式的.
2.ELF格式文件
一个ELF文件由四个主要部分构成 : ELF header / program header table / section / section header table , 其中section不止一个
3.查看ELF文件相关内容的指令
readelf -S PATH 用于查看Section Header Table(大致理解为数组)内容
readelf -s PATH 用于查看符号表内容
readelf -l PATH 读取 program headers (未来合并加载的规则)
[!折叠的代码]-
an@mycloud:~/code-in-linux/c++_linux/Library_dyna_static$ readelf -l codeElf file type is DYN (Position-Independent Executable file) Entry point 0x1060 There are 13 program headers, starting at offset 64Program Headers:Type Offset VirtAddr PhysAddrFileSiz MemSiz Flags AlignPHDR 0x0000000000000040 0x0000000000000040 0x00000000000000400x00000000000002d8 0x00000000000002d8 R 0x8INTERP 0x0000000000000318 0x0000000000000318 0x00000000000003180x000000000000001c 0x000000000000001c R 0x1[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]LOAD 0x0000000000000000 0x0000000000000000 0x00000000000000000x0000000000000628 0x0000000000000628 R 0x1000LOAD 0x0000000000001000 0x0000000000001000 0x00000000000010000x0000000000000175 0x0000000000000175 R E 0x1000LOAD 0x0000000000002000 0x0000000000002000 0x00000000000020000x00000000000000f4 0x00000000000000f4 R 0x1000LOAD 0x0000000000002db8 0x0000000000003db8 0x0000000000003db80x0000000000000258 0x0000000000000260 RW 0x1000DYNAMIC 0x0000000000002dc8 0x0000000000003dc8 0x0000000000003dc80x00000000000001f0 0x00000000000001f0 RW 0x8NOTE 0x0000000000000338 0x0000000000000338 0x00000000000003380x0000000000000030 0x0000000000000030 R 0x8NOTE 0x0000000000000368 0x0000000000000368 0x00000000000003680x0000000000000044 0x0000000000000044 R 0x4GNU_PROPERTY 0x0000000000000338 0x0000000000000338 0x00000000000003380x0000000000000030 0x0000000000000030 R 0x8GNU_EH_FRAME 0x0000000000002010 0x0000000000002010 0x00000000000020100x0000000000000034 0x0000000000000034 R 0x4GNU_STACK 0x0000000000000000 0x0000000000000000 0x00000000000000000x0000000000000000 0x0000000000000000 RW 0x10GNU_RELRO 0x0000000000002db8 0x0000000000003db8 0x0000000000003db80x0000000000000248 0x0000000000000248 R 0x1Section to Segment mapping:Segment Sections...00 01 .interp 02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 03 .init .plt .plt.got .plt.sec .text .fini 04 .rodata .eh_frame_hdr .eh_frame 05 .init_array .fini_array .dynamic .got .data .bss 06 .dynamic 07 .note.gnu.property 08 .note.gnu.build-id .note.ABI-tag 09 .note.gnu.property 10 .eh_frame_hdr 11 12 .init_array .fini_array .dynamic .got
readelf -h PATH 用于读取 ELF header
[!折叠的代码]-
an@mycloud:...$ readelf -h code ELF Header:# Magic : 用于标识elf格式Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64Data: 2's complement, little endianVersion: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: DYN (Position-Independent Executable file)Machine: Advanced Micro Devices X86-64Version: 0x1# Entry point address为程序入口地址,很关键.Entry point address: 0x1060Start of program headers: 64 (bytes into file)Start of section headers: 13976 (bytes into file)Flags: 0x0Size of this header: 64 (bytes)Size of program headers: 56 (bytes)Number of program headers: 13Size of section headers: 64 (bytes)Number of section headers: 31Section header string table index: 30
objdump -d FIlename_OF_ELF 用于反汇编ELF格式文件
[!折叠的代码]-
source.o: file format elf64-x86-64
二.ELF文件的合并
- ELF多个性质相同的的节合并成一个个段 ,
- 所有段统一平坦编址 ,
- 建立内和数据结构时根据虚拟地址和对应的物理地址建立页表映射 ,
- 让段的Entry point address保存到cpu寄存器 ,
- 之后就可以正常调度
- 各个ELF文件在合并时主要依靠section header table里的内容。
- 各个ELF文件将性质相同的节合并成段,比如可读可执行的.text .rodata合并到一起
- 合并之后删除重复数据以及使命已达sectionheader table
三.静态链接 :
1. 孤立的目标文件
库文件和目标文件以及可执行文件一样,都是ELF文件格式 . 毕竟我们的程序至少包含了标准输入输出的库文件.
1.使用readelf工具的-s选项来看一个目标文件的符号表.
2.可以看到最后Name是Puts的一行 ,Type 是NOTYPE, Ndx是 UND , 说明内容还不完全.
3.这是因为puts代表的是程序里调用的printf , 而printf函数的声明和定义在其他文件里
an@mycloud:...$ readelf -s source.oSymbol table '.symtab' contains 6 entries:Num: Value Size Type Bind Vis Ndx Name0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS source.c2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text3: 0000000000000000 0 SECTION LOCAL DEFAULT 5 .rodata4: 0000000000000000 30 FUNC GLOBAL DEFAULT 1 main5: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts #UND
2.链接时重定向:
1.现在直接看最终的可执行文件.
2.使用objdump工具的-d选项来查看可执行文件中有关puts相关的内容
3.可以看见puts函数有了地址 , 这就是从 .o文生成.bin文件的链接过程中 .o文件和库文件产生链接,找到了puts函数后就有了地址.
an@mycloud:~...$ objdump -d code
......................................
0000000000001050 <puts@plt>:1050: f3 0f 1e fa endbr641054: ff 25 76 2f 00 00 jmp *0x2f76(%rip) # 3fd0 <puts@GLIBC_2.2.5>105a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)
.....................................
四.程序的加载和虚拟地址
1. 程序加载之前
物理层面
- 对于c/c++这种编译型语言 , 可执行程序文件在运行之前就乖乖躺在磁盘里了 .
- 想要真正运行,必然要经历从磁盘到内存的过程,也就是拷贝 , 于是如何高效的让程序运行,很大程度上由拷贝的效率觉得
- 文件系统和磁盘交互的单元是多个扇区组成的块 .
此时,至少在物理层面从磁盘到内存的拷贝效率较高,因为成块成块的读取IO次数少.
软件层面
- 在加载到内存之前 , 磁盘上的可执行文件就已经存在了地址 .
- 每一个ELF文件都有一个地址 , 不过与其说是地址 , 倒不如说是一个偏移量 , 且所有ELF在分配偏移量时都是以同一个起始地址为基准(可以认为是0)
2.程序加载时
- 当可执行程序往内存中加载时 , 上面提到的地址(偏移量)就排上大用场了 .
- 操作系统里维护着虚拟地址 , 于是永操作系统里虚拟地址加上可执行文件的偏移量,就得到了可执行文件的实际虚拟地址值.
- 可执行文件自带物理地址 , 于是操作系统用刚刚得到的虚拟地址值和物理地址作一个映射,就得到了页表 .
- 可执行文件ELF header部分记录的程序入口地址交给CPU的EIP寄存器,这样就能运行啦.
[!物理地址&逻辑地址&虚拟地址???]- #物理地址_逻辑地址_虚拟地址
- 物理地址不用多说 , 文件中每个字节都自带物理地址 , 只不过操作系统层面给屏蔽了.
- 逻辑地址就是上面说的可执行程序里各个部分的起始地址,也就是偏移量 , 一旦进入内存,有了首虚拟地址 , 马上就能通过偏移量得到实际的虚拟地址.
- 虚拟地址就是为了方便上层使用而抽象出来的地址 , 各个区泾渭分明 , 看似连续 , 实则通过内核里维护的页表做了虚拟地址和物理地址的映射.
- 其实可以理解逻辑地址和虚拟地址的区别很暧昧 , 甚至可以认为是一回事 , 只不过逻辑地址是磁盘层面的概念 , 虚拟地址为内存里的概念 , 二者之间的转换几乎没有成本
五.动态库的加载和动态链接:
静态库需要在生成可执行程序时将代码一同合并进去
,动态库不同 , 生成可执行文件时他没有什么动静 , 而在程序加载时 , 他会加载到物理内存虚拟地址空间的共享区 , 让程序在页表中建立映射关系.
一个程序可以通过页表映射到这个动态库 , 多个文件也可以在页表中建立和库的映射关系 .
自此就达到了多个程序共享一个库的效果 , 极大地节省了内存空间.
不同的程序虚拟地址不同 , 于是程序代码中库函数的地址必然会不同 .
一个动态库里的函数 , 本身就会有各自的地址偏移量 , 于是在运行前程序代码中库函数调用处的地址其实是这个偏移量.
当程序真的要加载了 , 就会由链接器查找共享区的这个库 , 获取相关库整体的虚拟地址 , 将其和代码里库函数的偏移量相加 , 就得到了实际的库函数的虚拟地址空间 .
但是!!! 代码段是不可修改的只读部分,此路不通…
操作系统的做法是另外维护一张表 , 叫做GOT(global offset talbel) , 即全局偏移量表 . 因此在程序加载到一个库函数时 , 会自动跳转的GOT里查找对应的偏移量,结合动态库的首地址计算出实际的虚拟地址,从而完成调用.
在计算机的体系中缓存无处不在 , 此处以PLT(procedure linkage table),即过程链接表. 他就类似于一种缓存机制 , 当一个库函数第一次调用时 , 用过其偏移量和动态库的首地址计算出的实际虚拟地址 , 然后存放到这个表里 . 下一次涉及动态库函数的调用时,优先到这个表里查找库函数对应的虚拟地址 , 免去了重复计算.
[!动过手脚的源文件]- 动过手脚的源文件
- 但从语法层面来讲 , c/c++的程序从main函数开始 , 可实际并非如此 .
- 在源代码编译后 , main函数调用之前 , 还会有一个 _start函数.
- 他会调用动态连接器 , 进行打开动态库->将其加载到内存->进行符号解析和重定位(检测包含的动态库函数调用,并计算处实际虚拟地址)->更新GOT表.
- 此时程序中动态库函数都有了调用地址后才开始执行main函数里的代码.