Linux编译与链接过程

目录

一:目标文件

二:FILE文件

​编辑三:ELF从形成到加载轮廓 

3.1 ELF形成可执行

3.2ELF可执行文件加载 

四:理解链接与加载

4.1静态链接

4.2动态链接与动态库加载

4.2.1进程如何看到动态库

4.2.2进程间如何共享库的 

4.3 动态链接

4.3.1概要

4.3.2操作过程

4.3.3动态库中的相对地址

4.3.4我们的程序,怎么和库具体映射起来的 

​编辑 4.3.5我们的程序,怎么进行库函数调用

​编辑

4.3.6全局偏移量表GOT 

4.4总结 

五:ELF加载与进程地址空间 


一:目标文件

编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们一般都是⼀键构建非常方便, 但⼀旦遇到错误的时候呢,尤其是链接相关的错误,很多人就束手无策了。在Linux下,我们之前也学习过如何通过gcc编译器来完成这一系列操作。

接下来我们深入探讨⼀下编译和链接的整个过程,来更好的理解动静态库的使用原理。 先来回顾下什么是编译呢?编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码。 比如:在一个源文件 hello.c里简单输出"hello world!",并且调用⼀个run函数,而这个函数被定义在另⼀个原文件 code.c 中。这里我们就可以调用gcc -c 来分别编译这两个源文件。

// hello.c
#include <stdio.h>
void run();
int main()
{
    printf("hello world!\n");
    run();
    return 0;
}

// code.c
#include <stdio.h>
void run()
{
    printf("running...\n");
}
// 编译两个源⽂件 
gcc -c hello.c
gcc -c code.c

可以看到,在编译之后会生成两个扩展名为 .o 的文件,它们被称作目标文件。要注意的是如果我们修改了一个源文件,那么只需要单独编译它这⼀个,而不需要浪费时间重新编译整个工程。目标文件是⼀个二进制的文件,文件的格式是 ELF ,是对二进制代码的一种封装。


二:FILE文件

要理解编译链链接的细节,我们不得不了解一下ELF文件。

其实有以下四种文件其实都是ELF文件:

1.可重定位文件(Relocatable File) :即xxx.o文件。包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。

2.可执行文件(Executable File) :即可执行程序。

3.共享目标文件(Shared Object File) :即xxx.so文件。

4.内核转储(core dumps) ,存放当前进程的执行上下文,用于dump信号触发。

一个ELF文件由以下四部分组成:

1.ELF头(ELF header) :描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文件的其他部分。

2.程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。表里记着每个段的开始的位置和位移(offset)、长度,毕竟这些段,都是紧密的放在二进制文件中, 需要段表的描述信息,才能把他们每个段分割开。

3.节头表(Section header table) :包含对节(sections)的描述。

4.节(Section ):ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和 数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。 最常见的节: 代码节(.text):用于保存机器指令,是程序的主要执行部分。

                       数据节(.data):保存已初始化的全局变量和局部静态变量。


 


三:ELF从形成到加载轮廓 

3.1 ELF形成可执行

step-1:将多份 C/C++ 源代码,翻译成为目标 .o 文件+动静态库(ELF)

step-2:将多份 .o 文件section进行合并(链接)


3.2ELF可执行文件加载 

一个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成segment

合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等

这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起

很显然,这个合并工作也已经在形成 ELF 的时候,合并方式已经确定了,具体合并原则被记录在 了 ELF的程序头表(Program header table) 中

# 查看可执⾏程序的section 
readelf -S +文件名 
#查看表头
readelf -h +文件名
#查看内存布局
readelf -l + 文件名

查看section合并的segment : 

为什么要将section合并成为segment?

Section合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并, 假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果.text部分 为4097字节,.init部分为512字节,那么它们将占用3个页面,而合并后,它们只需2个页面。此外,操作系统在加载程序时,会将具有相同属性的section合并成一个大的segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。

对于 程序头表 和 节头表 又有什么用呢,其实ELF文件提供2个不同的视图/视角来让我们理解这两个部分:

1.链接视图(Linking view) :对应节头表 Section header table 文件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候一般关注的是链接视图,能够理解ELF文件中包含的各个部分的信息。为了空间布局上的效率,将来在链接目标文件时,链接器会把很多节(section)合并,规整成可执行的段(segment)、可读写的段、只读段等。合并了后,空间利用率就高了,否则,很小的很小的⼀段,未来物理内存页浪费太大(物理内存页分配⼀般都是整数倍⼀块给 你,比如4k),所以,链接器趁着链接就把小块们都合并了。

2.执行视图(execution view) :对应程序头表 Program header table  告诉操作系统,如何加载可执行文件,完成进程内存的初始化。一个可执行程序的格式中, 一定有 program header table 。

 简单来说就是:一个在链接时作用,一个在运行加载时作用。

从 链接视图 来看:命令 readelf -S hello.o 可以帮助查看ELF文明件的节头表。

1  .text节 :是保存了程序代码指令的代码节。

2 .data节 :保存了初始化的全局变量和局部静态变量等数据。

3 .rodata节 :保存了只读的数据,如一行C语言代码中的字符串。由于.rodata节是只读的,所以只能存在于一个可执行文件的只读段中。因此,只能是在text段(不是data段)中找到.rodata 节。

4 .BSS节 :为未初始化的全局变量和局部静态变量预留位置

5 .symtab节 : Symbol Table 符号表,就是源码里面那些函数名、变量名和代码的对应关系。

6 .got.plt节 (全局偏移表-过程链接表):.got节保存了全局偏移表。

.got节和.plt节一起提供了对导入的共享库函数的访问入口,由动态链接器在运行时进行修改。使用 readelf 命令查看.so文件可以看到该节。

从 执行视图 来看:

1.告诉操作系统哪些模块可以被加载进内存。

2.加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执行的。

我们可以在 ELF头中找到文件的基本信息,以及可以看到ELF头是如何定位程序头表和和节头表的。例如我们查看下hello.o这个可重定位文件的主要信息:

查看可执行程序:


四:理解链接与加载

4.1静态链接

无论是自己的 .o ,还是静态库中的 .o ,本质都是把.o文件进行连接的过程

所以:研究静态链接,本质就是研究 .o 是如何链接的

链接其实就是将编译之后的所有目标文件连同用到的⼀些静态库运行时库组合,拼装成一个独立的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我 们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程


4.2动态链接与动态库加载

4.2.1进程如何看到动态库

这样动态库只需要记载一次,其他程序之后均可在内存中找到


4.2.2进程间如何共享库的 


4.3 动态链接

4.3.1概要

动态链接其实远比静态链接要常用得多。比如我们查看下 hello 这个可执行程序依赖的动态库,会发现它就用到了⼀个c动态链接库:

这里的 libc.so 是C语言的运行时库,里面提供了常用的标准输入输出文件字符串处理等等这些功 能。

那为什么编译器默认不使用静态链接呢?静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成⼀个独立的可执行文件,它不需要额外的依赖就可以运行。照理来说应该更加方便才对是吧?

静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间。

 这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成⼀个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留⼀份副本,可以被不同的进程所共享。

动态链接到底是如何工作的??

首先,动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配⼀段内存。 当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了


4.3.2操作过程

在C/C++程序中,当程序开始执行时,它首先并不会直接跳转到 main 函数。实际上程序的入口点 是 _start ,这是一个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数。

在 _start 函数中,会执行⼀系列初始化操作,这些操作包括:

1. 设置堆栈:为程序创建⼀个初始的堆栈环境。

2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位 置,并清零未初始化的数据段。

3. 动态链接:这是关键的⼀步, _start 函数会调用动态链接器的代码来解析和加载程序所依赖的 动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用变量访问能够正确地映射到动态库中的实际地址。

4. 调用 __libc_start_main :一旦动态链接完成, _start 函数会调用__libc_start_main (这是glibc提供的一个函数)。 __libc_start_main 函数负责执行一些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。

5. 调用main 函数:最后, __libc_start_main 函数会调用程序的 main 函数,此时程序的执行控制权才正式交给用户编写的代码。

6. 处理 main 函数的返回值:当 main 函数返回时, __libc_start_main 会负责处理这个返回值,并最终调用 _exit 函数来终止程序。

上述过程描述了C/C++程序在 main 函数之前执行的⼀系列操作,但这些操作对于大多数程序员来说是透明的。程序员通常只需要关注 main 函数中的代码,而不需要关心底层的初始化过程。


4.3.3动态库中的相对地址

动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法,统⼀编址, 采用相对编址的方案进行编制的(其实可执行程序也⼀样,都要遵守平坦模式,只不过exe是直接加载 的)。

# ubuntu下查看任意⼀个库的反汇编 
objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less

# Cetnos下查看任意⼀个库的反汇编 
$ objdump -S /lib64/libc-2.17.so | less

对于任何一个文件,文件的内容就是一个巨大的“一维数组”,标识任何一个区域均采用偏移量+大小的方式

平坦模式:逻辑地址 = 起始地址(0)+ 偏移量   线性地址

ELF在没有加载之前,虚拟地址已经按照[0000 ,FFFF]进行编址了(编译器一旦编译就会形成虚拟地址)

逻辑地址(磁盘EIF) == 虚拟地址(加载到内存)


4.3.4我们的程序,怎么和库具体映射起来的 

动态库也是⼀个文件,要访问也是要被先加载,要加载也是要被打开的

让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中


 4.3.5我们的程序,怎么进行库函数调用

库已经被我们映射到了当前进程的地址空间中

库的虚拟起始地址我们也已经知道了

库中每⼀个方法的偏移量地址我们也知道

所有:访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的法

而且:整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行的

由此可以看出我们的进程执行库方法,是在自己的地址空间内跳转运行 


4.3.6全局偏移量表GOT 

也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该 提前知道

然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置 (这个叫做加载地址重定位)

 等等,修改的是代码区?不是说代码区在进程中是只读的吗?怎么修改?能修改吗?

所以:动态链接采用的做法是在 .data (可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。 因为.data区域是可读写的,所以可以支持动态进行修改

1. 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表

2. 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利用CPU的相对寻址来找到GOT表。

3. 在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。

4. 这种方式实现的动态链接就被叫做 PIC 地址无关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享


4.4总结 

静态链接的出现,提高了程序的模块化水平。对于⼀个大的项目,不同的人可以独立地测试和开发 自己的模块。通过静态链接,生成最终的可执行文件。

静态链接会将编译产生的所有目标文件,和用到的各种库合并成⼀个独立的可执行文件, 其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。

动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行⼀个程序,操作系 统会首先将程序的数据代码连同它用到的⼀系列动态库先加载到内存,其中每个动态库的加载地址 都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间。


五:ELF加载与进程地址空间 

1.mm_struct是如何初始化的?

通过读取可执行程序,记载可执行程序的各个数据对应的具体地址,从而形成mm_struct

2.从虚拟到物理的闭环

a.CPU从内存中执行程序 ,PC获得虚拟地址

b.PC将地址交给MMU(内存管理单元)处理,再通过CR3找到页表,查找对应的物理地址

c.在物理地址中,找到对应的指令,放在EIP(指令寄存器)中

d.PC根据指令长度,加上对应的长度,得到下一个指令的虚拟地址,重复

3.为什么要有虚拟地址和虚拟地址的空间?

编译器编码时不用考虑物理内存,在编译的时候,只需线性编址即可。编址后,代码统一使用虚拟地址,以线性的形式看待整个代码 ---> OS和编译器解耦

mm_struct 是宏观上的划分,vm_area_struct是细节上的划分,划分区域的本质:创建一个mm_area_struct连接到链表中(包含end和start)

加载程序时,可以只加载程序入口,然后CPU在执行的时候,在对应的页表中(页表中断)再加载对应的section --->懒加载

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

玖剹

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值