Linux:库的原理
库的原理
我们之前学过,库是由多个.o文件链接而成的。这些.o文件、以及我们常见的动静态库、可执行文件,都属于同一种文件格式 ——ELF(可执行与链接格式)
一、ELF 文件的基本组成
一个 ELF 格式的文件,主要包含以下几个部分:
- ELF Header(ELF 头):记录文件的整体信息(比如是否是可执行文件、适配的系统架构等)。
- Program Header Table(程序头表):描述文件加载到内存后的 “段” 信息。
- Section(数据节):存放实际内容的片段(比如代码、数据等)。
- Section Header Table(节头表):描述所有数据节的详细信息(比如位置、大小、属性等)。
二、常见的 Section(数据节)
我们可以通过size命令查看 ELF 文件中各段的大小,比如:
会显示类似 “text(代码段)、data(数据段)、bss(未初始化数据段)” 的大小。这三个是最核心的 Section:
1. .text(代码段)
存放程序的机器指令(代码),比如函数实现、逻辑运算等。这部分内容在程序运行时通常是只读的(防止意外修改)。
2. .data(数据段)
存放已初始化的全局变量和静态变量。比如代码中写int a = 10;,这个a就会存在这里 —— 因为它有初始值 “10”。
3. .bss(未初始化数据段)
存放未初始化的全局变量和静态变量。比如定义int b; int c;(没有赋值),系统会把它们暂时 “打包” 成一个整体(比如int[50]),统一初始化为 0 存放在这里。等程序加载到内存后,再 “拆包” 成独立的变量。
简单说:.data 是 “带初始值的行李”,.bss 是 “空箱子”(用的时候再装东西),两者都属于数据,只是存储方式不同。
三、ELF 文件的 “组装” 过程
当多个.o文件链接成可执行文件时,系统会把属性相同的 Section 合并:
- 所有.o文件的.text合并成一个大的.text(代码集中存放);
- 所有.o文件的.data合并成一个大的.data(已初始化数据集中存放);
- 同理,.bss也会合并。
- 这样做的目的是减少冗余,节省空间。
四、Section Header Table 与 Program Header Table
观察 ELF 文件有两个重要视角:
1. 编译链接视角:Section Header Table
它就像 “零件清单”,详细记录每个 Section 的位置、大小、类型等。比如用readelf -S 文件名命令,可以看到.text在文件中的偏移量、大小等信息 —— 这是编译器和链接器关注的内
2. 执行视角:Program Header Table
当文件加载到内存中运行时,系统会根据 Program Header Table 把合并后的 Section 再 “打包” 成更大的Segment(段)。比如把.text和只读数据合并成一个 “代码段”,把.data和.bss合并成一个 “数据段”。
这样做是为了简化内存管理—— 系统只需按 Segment 分配权限(比如代码段只读可执行,数据段可读可写)。
ELF Header:
用于记录该ELF文件的总体信息
Magic(魔术):在OS执行ELF文件时,首先要确认你是ELF文件格式才能执行,而magic就是用于表示该文件的格式。
五、.o文件链接的细节:
如上图,有一个hello.c与run.c文件,接下来将它们编译成.o文件后进行反汇编查看它们的汇编代码。
观察上图可以看到,当hello.s还没有被链接时候,在函数里面call的地址都是0地址,只有在链接后call的地址才会转成真正的地址。
从上图可以看到,当我们链接形成main.exe文件后,使用readelf -s 查看main.exe文件的数据节,可以看到 main函数与run函数都放在第13个数据节里。再使用 readelf -S 查看节表头,可以看到第13个数据节正是.text数据节里。
对main.exe 进行反汇编可以看到,在main函数call了400540地址,而这个地址正是run函数的地址。
而在之前,main函数里call的地址都还是0地址,现在已经变成正确的地址了。所以我们的.o文件又称之为可重定向文件。
六、地址的那些事儿:逻辑地址、虚拟地址、物理地址
我们在 ELF 文件中看到的 “地址”,其实有不同的含义:
逻辑地址:
其实从之前的图就能发现,我们的main.exe明明还没有加载到内存里,却已经有了地址。那这个地址是什么地址呢?先说结论,main.exe还没加载到内存时的地址是逻辑地址。
为什么要有逻辑地址?
如上图,我们每一个segment的都有属于自己的基地址。而段内地址如同上图 a=10与fun函数都是段内基地址加上它们自身的偏移量。
如果我们将每一个segment的基地址都设置成0,那么所有的函数以及变量的编址都是从0开始加上偏移量。
所以一个程序中所有的函数与变量统一从0地址开始向下进行编址。
如上图main.exe文件的起始地址为4003e0开始一路往下进行线性编址,虽然不是从0地址开始,因为0地址还要被其他方法占用。
那么这种线性编址的地址我们就称之为逻辑地址,而这种编址模式称之为平坦模式编址。
虚拟地址:
程序加载到内存后,CPU 实际使用的地址是虚拟地址。它和逻辑地址本质上是一回事,只是换了个场景(运行时)。
物理地址:
内存中真正的硬件地址(比如内存芯片上的存储单元位置)。虚拟地址会通过系统的 “页表” 映射到物理地址,这样程序不用关心实际存在内存的哪个角落,由系统统一管理。
所以ELF文件从磁盘加载进入内存时,将ELF文件里的每一个segment加载进内存,而ELF又已经经过绝对编址,所以每一个segment上面都拥有虚拟地址,所以用每一个segment的起始与结束的地址来初始化,PCB里mm_struct中每一个vm_area_struct里的start与end,然后构建页表映射关系,然后把ELF Header里的 Entry_point_address的地址喂给cpu,cpu就能通过通过MMU进行自动查找虚拟地址与物理地址的映射位置,就能运行代码转起来。
七、程序是怎么 “启动” 的?
这就不得不回到我们之前说的ELF Header
当我们使用 readelf -h main.exe 查看ELF表头时候,会发现一个名为
Entry point address的地址,而这个地址正是记录程序入口的起始地址,所以当该文件运行时候,OS就会查找该文件的ELF表头,将里面记录的程序入口地址交给CPU,CPU就开始运行该可执行文件。
所以当我们修改一个文件时完整步骤应该为:
通过open打开文件,OS对当前文件的路径与文件名进行拼接。通过该文件的绝对地址去内存中目录缓冲dentry树里查找是否对应的文件名,如果没有就去磁盘上查找。当找到与之对应的文件名时,就拿到文件名对应该文件的inode信息,通过文件的inode信息将文件数据从磁盘加载到内存。
加载到内存后,PCB就会初始化它的mm_Struct,构建页表中物理地址与虚拟地址的映射关系。
接着通过mm_struct去查找你所修改的数据在文件的哪个区域,找到mm
_struct中对应的vm_area_struct,通过vm_area_struct中的file指针找到该文件对应的dentry树,通过dentry拿到该文件inode,最后通过inode找到对应的data_block进行修改。
八、动态库与可执行文件的关联:
这里先不谈动态库的加载,我们先谈谈动态库与可执行文件如何关联。
我们的可执行文件如果是动态链接库时,那么在文件中有一块区域是共享区,共享区里存放着与库函数相关的映射地址。
库函数想要被调用时同样需要加载到内存,而库加载到内存时也会拥有物理地址,那么同样的对库文件建立物理地址与虚拟地址的映射关系后,当程序调用到库文件时,就通过文件的共享区找到库的虚拟地址,再通过页表映射找到库的物理地址,再把参数通过cpu传入到库中与之相对的函数里进行函数调用,当调用完后再把参数放入cpu中进行返回。
而我们也会有多个程序调用同一种库的情况
所以当多个程序调用同一个库时,虽然它们的虚拟地址不一样,但库在内存中只有一份,多个PCB就可以通过不同的虚拟地址(它们独有的)通过页表映射找到同一个物理地址后进行调用。
所以Linux下很多库都是动态库调用,基本没有静态库。这么做也是为了节省内存空间。
九、动态库的加载与链接:
在程序运行前可以使用ldd命令查看当前文件所依赖的是哪个库。
但我们之前只提到libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f3077e98000)这一段代码,这段代码是查看当前我们绑定的是什么库。而没有说过它的下一段代码 /lib64/ld-linux-x86-64.so.2 (0x00007f3078099000)
这段代码指的是动态链接器(加载器)
动态连接器:
动态链接器是一个至关重要的系统组件,在程序运行时发挥着关键作用:
加载共享库:它会查找并加载程序所依赖的全部共享库(像 libc.so.6 这类)。
符号解析:负责将程序中的符号引用(例如函数调用)与实际的共享库函数进行关联。
初始化运行环境:在程序的入口点(main() 函数)被执行之前,完成一系列初始化操作。
其实我们动态库的加载是被推迟到程序加载过程中的,当运行程序时,并不会直接运行main函数,而运行的是_start函数
在程序的开始函数(_start)会调用动态加载器(ld-linux.so),动态加载器就会根据当前代码中需要调用到哪些库,然后帮我在系统里,把需要调用的库加载到内存里。当程序启动时,动态连接器会解析程序中的动态依赖库,并加载这些库到内存中。
十、动态库如何载入内存:
动态库本质也是.o文件,所以和普通ELF文件一样都是通过路径查询,找到库文件对应名字,找到与之映射的inode,找到inode后就能从磁盘中找到动态库数据,载入内存,构建页表映射关系。
假设我们调用的puts函数的偏移量地址为0x112233,在还没链接的时,call的地址是libc.so@+偏移量。但为什么当形成可执行文件时,该地址就被修改成一个具体地址了呢?再说了我们的代码是被放在文件的.text区域,而我们知道.text区域是不可以被修改的,为什么就修改了呢?
十、GOT全局偏移量表:
动态链接采⽤的做法是在 .data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数 的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址。
因为.data区域是可以修改的。因此我们就可以在GOT表里进行KV映射,我们将需要调用的库函数名称放入GOT表里,在程序加载时GOT中就存入函数名对应的虚拟地址
所以当我们调用库函数时,在call时候根本添加的根本不是函数的虚拟地址,而添加的是got表里与之对应的地址+偏移量。
总结
ELF 文件是程序在 Linux 中的 “标准包装格式”,无论是.o文件、库还是可执行文件,都遵循这个格式。理解它的组成(Section、Program Header 等)和地址映射(逻辑地址、虚拟地址),能帮我们搞懂程序从编译到运行的全过程。而动态库的共享机制和 GOT 表,则是实现高效复用的关键 —— 让程序更轻便,也更节省资源。
---------本篇文章就到这里,感谢各位观看