深入程序编译链接和装载过程

C 程序设计 专栏收录该内容
26 篇文章 412 订阅 ¥39.90 ¥99.00

目录

预编译

编译

汇编

链接

深入编译链接和运行

CPU、内存 与 I/O

32位4GLinux虚拟地址空间布局

指令和数据

分析二进制可重定位目标文件 main.o 的组成 

强符号与弱符号

符号表

链接过程分析

可执行文件分析 

run可执行文件的组成格式分析

程序的运行——进程 

VP与PP


在Linux下使用GCC来编译 Hello World 程序时,只需使用最简单的命令(假设源代码文件名为 hello.c

事实上,上述过程可以分解为4个步骤:

预处理(Prepressing) 编译(Compilation) 汇编(Assembly) 链接(Linking) 

  • 预处理,生成预编译文件(.i文件): gcc –E hello.c –o hello.i
  • 编译,生成汇编代码(.s文件):gcc –S hello.i –o hello.s
  • 汇编,生成目标文件(.o文件):gcc –c hello.s –o hello.o
  • 链接,生成可执行文件:gcc hello.o –o hello

预编译

首先是源代码文件hello.c和相关的头文件,如 stdio.h 等被预编译器cpp预编译成一个 .i 的文件(对于C++,源代码文件的扩展名可能是 .cpp 或 .cxx,头文件扩展名可能是 .hpp,而预编译后的文件扩展名是 .ii)第一步预编译的过程相当于如下命令( -E 表示只进行预编译):

$gcc -E hello.c -o hello.i

或者:

$cpp hello.c > hello.i

预编译过程主要处理那些源代码文件中的以 " # " 开始的预编译指令。比如 " #include "、" #define "等,主要处理规则如下:

  1. 将所有的" #define "删除,并且展开所有的宏定义;
  2. 处理所有条件编译指令,如" #if "," #ifdef "," #elif "," #else "," #endif ";
  3. 处理" #include "预编译指令,将被包含的文件插入到该预编译指令的位置。注意:该过程递归进行,也就是说被包含的文件可能还包含其他文件。
  4. 删除所有的注释" // "和  " /* */ ";
  5. 添加行号和文件标识,如 #2 " hello.c " 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号;
  6. 保留所有的 #pragma 编译器指令,因为编译器须要使用它们;

经过编译后的 .i 文件不包含任何宏定义,因为所有的宏已经被展开并且包含的文件也已经被插入到 .i 文件。所以,当我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问题。

编译

编译过程通过词法和语法分析,确认所有指令符合语法规则(否则报编译错),之后翻译成对应的中间码,在Linux中被称为RTL(Register Transfer Language),通常是平台无关的,这个过程也被称为编译前端编译后端对RTL树进行裁减,优化,得到在目标机上可执行的汇编代码gcc采用as作为其汇编器,所以汇编码是AT&T格式的,而不是Intel格式,所以在用gcc编译嵌入式汇编时,也要采用AT&T格式。

编译是程序构建的核心部分,也最复杂。需要在这个过程中完成词法分析语法分析语义分析优化产生的汇编代码

gcc -S hello.i -o hello.s

现在一般GCC把预编译和编译都合在一起了。但是也可以分开实现,GCC的命令其实是对一些编译程序的包装而已,它根据不同的参数去调用预编译程序(cc1)汇编器(as)链接器(ld)。

汇编

汇编器将上面生成的汇编代码转为机器可以执行的指令。每一条汇编语句对应一条机器指令。所以汇编过程比编译过程简单,没有复杂的语法、语义,也不需要优化指令。只是根据汇编指令和机器指令的对照表一一翻译就可以了,“汇编”这个名字也来源于此,上面的汇编过程我们可以调用汇编器as来完成,最终输出的是目标文件.o:

$as hello.s -o hello.o

或者:

$gcc -c hello.s -o hello.o

 

如上,由于汇编产生的hello.o的文件内容为机器码,不能以文本形式方便的呈现。

链接

在成功编译之后,就进入了链接阶段。在这里涉及到一个重要的概念:函数库

在Hello World 程序中并没有定义 " printf " 的函数实现,且在预编译中包含进的 " stdio.h " 中也只有该函数的声明,而没有定义函数的实现,那么,是在哪里实现" printf " 函数的呢?

最后的答案是:系统把这些函数实现都被做到名为libc.so.6库文件中去了,在没有特别指定时,gcc会到系统默认的搜索路径 " /usr/lib " 下进行查找,也就是链接到libc.so.6库函数中去,这样就能实现函数" printf " 了,而这也就是链接的作用。

 函数库一般分为静态库动态库两种。静态库是指编译链接时,把库文件的代码全部加入到可执行文件中,因此生成的文件比较大,但在运行时也就不再需要库文件了。其后缀名一般为 " .a " 。动态库与之相反,在编译链接时并没有把库文件的代码加入到可执行文件中,而是在程序执行时由运行时链接文件加载库,这样可以节省系统的开销。动态库一般后缀名为 " .so ",如前面所述的libc.so.6就是动态库。gcc在编译时默认使用动态库

链接器ld将各个目标文件组装在一起,解决符号依赖,库依赖关系,并生成可执行文件。链接的时候还会用到静态链接库动态连接库静态库和动态库都是 .o目标文件 的集合

如何调用链接器ld才可以产生一个能够正常运行的 Hello World 程序:

如果把所有路径都省略掉,那么上面的命令就是:

ld –static crt1.o crti.o crtbeginT.ohello.o –start-group –lgcc –lgcc_eh –lc-end-group crtend.o crtn.o  

下面的指令可以生成静态链接库hello.a

ar -v -q hello.a hello.o

动态库在链接时只创建一些符号表,而在运行的时候才将有关库的代码装入内存映射到运行时相应进程的虚地址空间。如果出错,如找不到对应的.so文件,会在执行的时候报动态连接错(可用LD_LIBRARY_PATH指定路径)。

深入编译链接和运行

CPU、内存 与 I/O

        首先能称得上是计算机系统,必须具备以下:CPU、内存、I/O;对于这三个部分,我们可以使用不同厂商的硬件, 但是操作系统为了屏蔽底层硬件的差异,使应用层的用户在编写程序的时候使用统一的接口,就像我们使用的open不仅可以打开一个文件还可以打开一个socket,还可以打开一个字符设备,因为操作系统给我们提供了很多抽象的技术:基于I/O层,操作系统提供了VFS虚拟文件系统屏蔽了I/O层的差异为了屏蔽内存和I/O,作为资源分配的管理单位,提供了虚拟内存为了屏蔽CPU、内存、I/O底层的差异,作为这三个部件的资源调度单位,操作系统提供了进程的概念。VFS、虚拟内存、进程也是操作系统内核离不开的三个部分。 

        操作系统内核将底层的硬件都屏蔽起来了,那么只要有操作系统在,我们写的代码生成的可执行文件是不可能直接被加载到物理内存上去的,他会被加载到虚拟内存上虚拟内存的大小,与CPU的位数有关在x86 32bit的Linux内核上CPU是32位;那么它的虚拟内存的大小就是2的32次方也就是4G; 

  • 32的CPU,它的数据总线和地址总线都是32条; 
  • 16位的CPU,数据总线16条,地址总线20条; 
  • 8位CPU数据总线8条,地址总线16条。CPU的位数与地址总线的条数无关。 

       CPU主要是用来运算数据的,CPU的位数是指它一次性能加以运算的最长的整数的宽度,CPU是在算术逻辑单元ALU中运算的,那么CPU的位数也就是算术逻辑单元的宽度,运算的数据是从数据总线来的,那么CPU的位数也就是数据总线的条数

        程序都是运行在虚拟内存上,那么虚拟内存在哪里?虚拟内存这个概念是IBM提出的,为了给大家普及虚拟内存的概念,IBM提出了几句话:它存在,你能看得见,它是物理的;它存在,你看不见,它是透明的;它不存在,你却看得见,它是虚拟的实际上虚拟内存是不存在的

32位4GLinux虚拟地址空间布局

       每个程序在运行时,操作系统都会给它分配一个与CPU位数有关的虚拟地址空间,以32位系统为例;其中低3G为用户空间是供用户的应用程序执行的高1G为内核空间主要是供内核代码运行的每一个进程的用户控件都是独立的,所有进程的内核空间都是共享的。下图表示了虚拟地址空间的分配情况。 

其中用户空间依次为: 
0x00000000-0x08048000128M禁止访问的,操作系统在加载程序的时候都是从0x08048000开始加载的; 
.text段存放指令。 
.data段存放已初始化且初始值不为0的数据。 
.bss段存放未被初始化或者初始值为0的数据
接下来是堆区空间在堆还没有被申请的时候,为堆区预留的空间称作空洞; 
如果程序中有使用到库函数,那么在堆区和栈区中间存放共享库; 
在共享库的高地址方向就是供函数运行的.stack栈区; 
栈区上方(栈的高地址方向)存放命令行参数和环境变量。 
内核空间分为3部分

  • 低16M:ZONE_DMA直接访问内存
  • 中间的892M:ZONE_NORMAL
  • 高的128M:ZONE_HIGHMEM高端内存

.text .data .bss在程序运行起来以后是固定不变的程序运行起来的时候还没有堆执行到malloc或者new的时候,才会给它分配堆,程序运行起来必需的内存是代码段、数据段还有栈

指令和数据

关于指令和数据,用任何语言写代码,无非就产生了两种东西:数据和指令局部变量属于指令全局变量、静态全局变量、静态局部变量属于数据

int gdata1 = 10;   //数据
int gdata2 = 0;    //数据  强符号
int gdata3;//数据
static int gdata4 = 11;   //数据
static int gdata5 = 0;    //数据
static int gdata6;        //数据

int main()  //指令
{
    int a = 12;   //指令
    int b = 0;    //指令
    int c;//指令

    static int d = 13;  //数据
    static int e = 0;   //数据
    static int f;       //数据
    return 0;           //指令
}

将上述代码写在linux下进行分析: 
main.c 源文件 编译和链接 
编译过程分为: 
预编译:得到 main.i ,删除注释,以 # 开头的预编译头,不做任何安全检查。
编译:得到 main.s,进行代码优化,汇总所有的符号。 
汇编:得到 main.o,根据汇编指令转换成特定平台的机器码,构建 .o文件的格式。 
最终产生.o文件也就是二进制可重定位目标文件

链接分为两步: 
1、合并所有obj文件的段,并调整段偏移和段长度,合并符号表,进行符号解析,分配内存地址。 
2、链接的核心:符号的重定位

分析二进制可重定位目标文件 main.o 的组成 

查看obj文件主要段:objdump -h main.o 


其中,data段存放已初始化且初始值不为0的数据,上述代码中一共三个这种数据,占12个字节所以在.o文件中.data段的大小为0x0000000c; 
从段的信息上来看,虚拟内存地址跟加载的内存地址都是0,所以仅仅在编译阶段,是不给符号分配内存地址的是在链接时分配的,符号解析完成后,就分配内存地址

查看文件头 : readelf -h main.o


二进制可重定位目标文件以及可执行文件,文件的开始都是ELF Header这样一个文件头。文件头中包含了程序运行的平台、体系;程序的入口地址,这里显示的是0地址,0地址是不可访问的,所以obj文件是不可能运行的文件头ELF Header的大小为52个字节,转换成16进制是0x34; 
.text段的段偏移是00000034,也就是对于文件组成来说,ELF Header之后就是.text段text段的大小为0x1b34+1b=4f,下一个段的偏移是50,因为这里的对齐方式是2的2次方4f不能被4整除,所以这里补了一个字节

接下来就是数据段.data,大小为0x0c,下一个段的起始地址就是5c。

接下来是.bss段,它的大小是0x14,20个字节,也就是有5个未初始化或初始值为0的数据在bss段,可是所写的代码中却有6个,这是为什么?

从段信息中可以看出.bss段的下一个段.comment段,它的文件偏移和.bss段一样都是0x5c,也就是说,data段下来根本就不是bss段而是.comment段bss段不占文件的空间

è¿éåå¾çæè¿°

问题:既然 .o文件并没有存储bss段,那么程序运行时又如何得知存储在bss段上的那么未被初始化的或初始值为0的数据呢? 
在obj文件的文件头中有一Start of section headers : 208个字节,十六进制是d0,表示段表(section table)在文件中的偏移量obj 文件段(下图)信息的第一行There are 9 section headers, starting at offset 0xd0,读文件头就可以知道文件的段表的位置,段表中记录了当前obj文件里边都有哪些段,段的起始偏移量,以及段的大小。也就是段表中记录了所有段的详细信息,为什么叫bss段为better save space呢?因为它不需要存初始值,所有数据的初始值都是0,那操作系统又如何知道这些数据的存在呢,就是将它的信息,将来需要占多大的内存,都记录在段表中就可以了.data段的数据每个初始值都不一样,不记录初始值是不行的,所以这些数据需要单独存储。

查看obj文件所有的段:readelf -S main.o

最后一个段的偏移量加上段本身的大小就是整个obj文件的大小 348 + 4c结果是916个字节。使用 ll 查看mian.o的大小发现刚好是916个字节。

打印.o文件中各个段的内容:objdump -s main.o其中.data的内容0a000000 0b000000 0d000000 就对应代码中的gdata1=10,gdata4=11,d=13。

问题:如果程序里有一指针指向一个常量字符串:char *p = “hello world”,那么这个常量字符串存在哪里?


存放在rodata只读数据段。通过查看段信息发现,数据段是可读可写代码段是可读并且可执行

强符号与弱符号

问题:.bss段,它的大小是0x14,20个字节,也就是有5个未初始化或初始值为0的数据在bss段,可是所写的代码中却有6个,这是为什么呢?哪个数据没有被存储呢? 
首先看一个例子: 

text1.c

int x;
void func()
{
    x = 20;
    //第一步:编译时 把 20 往x的内存上写 写4个字节
}

text2.c

#include<stdio.h>
short x = 10;   //第三部:小端模式写值:14 00 00 00把20写到x中,1400给了x,0000给了y
short y = 10;
void func();
int main()
{
    func();//   //第二步:链接时选择强符号(short x) 把 20 往x的内存上写 写4个字节
    printf("x = %d y=%d\n",x,y); //20,0
    return 0;
}

编译时,各文件是分开编译的,此时text1.c里面的x是弱符号
func函数里x=20被编译成: mov dword ptr[x], 14h    往x的内存上写14h,写4字节。

在链接时,发现test2.c里面有一个同名的强符号x,在调用func函数的时候取了short x的地址。因为指令已经在编译阶段编译,所以,这个14h就写在了short x的地址上。但是short x只有两个字节,其他的部分就写在了内存紧挨着的short y的内存上,覆盖了y原有的数据。小端模式,14h在内存上的保存方式是:00010100 00000000 00000000 00000000。因此,x=20,y=0。

一个工程里的多个文件是分离式编译的,text1.c编译产生text1.obj,text2.c编译产生text2.obj,这个工程在cpp下是不能通过编译的,因为cpp不允许有同名的全局变量在c语言中可以,因为c语言有强符号弱符号的概念。简单来说,有初始化的都叫强符号未初始化的都叫弱符号。C语言的规则是:

  • 不能出现多个同名的强符号
  • 出现了同名的强符号和弱符号选择强符号
  • 出现同名的弱符号选择内存占用量大的弱符号

在最初的 main.c 中有一  int gdata3 未初始化的全局变量,这个gdata3没有被存储在.bss段,这是一个弱符号,在链接时,其他的obj文件中可能会有名为gdata3的强符号或者内存占用量更大的弱符号,不一定会选择这个gdata3。static int gdata6由于加了static 只有本文件可见,所以不考虑。 
链接器在链接时,只对所有obj文件的global符号进行处理local的符号不做任何处理。加static关键字的都是local的符号,链接器不做处理。

符号表

符号表:数据是一定产生符号的函数只产生一个符号,就是函数名局部变量(指令)不产生符号,也就是是说指令只产生一个符号就是函数名

查看符号表:objdump -t main.o

从符号表中可以看到gdata3在COMMON块并没有在任何的段中,COMMON的意思就是表示它现在是一个未决定的符号,因为它现在是一个弱符号链接时才等待选择。局部变量a,b,c是指令不产生符号,所有的指令中只有函数名才产生符号

链接过程分析

       把没有分配到具体的段的符号比如UND、COM块的符号都叫符号的引用看不到这些符号定义的地方。 
       在链接的时候,进行符号解析,意思就是,所有obj符号表中对符号引用的地方都要找到该符号定义的地方。比如UND *  gdata10,一定在链接的过程中在其他的obj文件中找到gdata10的定义。 

如下的两个.c文件: 

sum.c

int gdata10 = 13;
int sum(int a,int b)
{
    return a+b;
}

main1.c

int gdata1 = 10;
int gdata2 = 0;
int gdata3;
static int gdata4 = 11;
static int gdata5 = 0;
static int gdata6;

int main()
{
    extern gdata10;
    int a = 12;
    int b = 0;
    int c = gdata10;
    static int d = 13;
    static int e = 0;
    static int f;
    sum(a,b);
    return 0;
}

sum.o中主要的段信息:

sum.o的符号表:

main1.o的符号表:其中UND表示未定义的

查看汇编指令objdump -d mian1.o

int c = gdata10;
19:  a1 00 00 00 00          mov    0x0,%eax
这行汇编指令中显示的gdata10是0地址,0地址是不可访问的,说明在编译的过程中是不给符号分配内存地址的。

sum(a,b);
31:  e8 fc ff ff ff          call   32 <main+0x32>
这行汇编指令显示sum函数的地址为fffffffc(小端模式),这是一内核空间的地址(不可访问),sum函数不可能在这个地址,说明在编译的过程中是不给符号分配内存地址的(在链接进行符号解析时才分配内存地址

程序新运行的过程中,pc寄存器中存放了下一行指令的地址,由于下一行指令是0地址,减四就得到了sum函数的地址。
总结:在编译过程中,所有数据填的都是0地址函数填的都是跟下一行指令地址的偏移量(-4)。函数的跳转地址是根据pc寄存器中的内容+偏移量的得到的(函数在符号表中存储的就是其偏移地址)
 

总结链接过程 
1、合并所有obj文件的段: 
obj文件是按4字节对齐的,但是可执行文件是按页面对齐的,32位系统的一个页面是4096个字节 = 4kb,链接过程合并所有obj文件的段所有相同属性的段进行合并组织在一个页面上。相同属性比如data和bss他们都是数据都可读可写,所以就把他们合并到一块,这样合并比纯粹的按文件堆积合并效率高,最主要的是可执行文件的大小会小一些,因为按页面对齐,所以我们写一个简单的hello world 也会占好几kb的空间。 
2、符号的重定位

总结:

链接过程:所有相同属性的段进行合并,组织在一个页面上,调整段偏移和段长度,进行符号表的合并,并进行符号解析,给符号分配内存地址

进行符号的重定位(编译过程不给符号分配内存地址,链接结束后,每个符号都得到了自己的虚拟内存地址空间)

可执行文件分析 

链接所有.o文件  ld -e main -o run *.o,得到可执行文件run 
查看段信息 objdump -h run,可以看到bss段大小为18,即24个字节6个整型变量也包括了gdata3

查看符号表 objdump -t run :每个符号都有内存地址了。

每个符号都有了虚拟地址空间上的地址,这个时候 objdump -d run 查看汇编指令,发现所有数据符号都填的是绝对地址函数符号,因为要涉及指令跳转,所以存的都是偏移量。 


80480c5: e8 0a 00 00 00         call 80480d4 
80480ca: b8 00 00 00 00         mov $0x0,%eax 
pc寄存器中存的是下一行指令的地址也就是80480ca,这个地址加上偏移量0a000000等于080480d4符号表中显示的sum函数的地址刚好就是080480d4。CPU在进行指令访问的时候,永远是从pc寄存器中取地址,当运行到当前指令的时候,pc寄存器里放的是下一行指令的地址call指令涉及的是指令的跳转,意思也就是从call以后要调到其他的代码段去执行,而不是继续运行下一行。跳转的位置就是偏移量加下一行指令的地址

run可执行文件的组成格式分析

首先是文件头ELF Header 用命令 readelf -h run 来查看 
这里的Entry point address已经不是0了而是0x8048094,这是main函数第一行指令的地址

程序的运行——进程 

./a.out 

  1. 创建虚拟地址空间到物理内存的映射(创建内核地址映射结构体),创建页目录页表 
  2. 加载代码段数据段。 
  3. 可执行文件的入口地址写到CPU的pc寄存器里面。

查看可执行文件的段信息  objdump -h run

问题1:text段的大小是4e,Size of this header:52字节也就是0x34,可是text段的偏移却是0x94,说明在ELF Header和text中间还有一块,那么这里存的又是什么呢? 
文件头里有一个Start of program headers:52bytes,也就是0x34,所以通过偏移量得知在ELF Header和text中间的就是program header。 
Number of program headers : 3 
Size of program header : 32 
program header的总大小是  3*32=96字节 (0x60),它的大小(0x60)加上偏移量52字节(0x34) = 0x94;刚好就是text的偏移量。 
 
一共有3个 program header,用命令 readelf -l run 查看它的内容,有两个LOAD页,它的对齐方式是0x1000就是4k,也就是按页面对齐的。其中 00 是 .text,01是.data .bss 。这两个LOAD页就指示了操作系统的加载器应该把当前程序的哪些东西加载到内存中去,以及哪些东西(相同属性)在加载时组织在一个页面

VP与PP

当程序运行的时候,磁盘上的可执行文件,它本身的存储结构还是按段存储的,只是有两个LOAD项这两个LOAD项指明了在加载的时候哪些段应该组织在一个页面上。下图中的DP1、DP2就是这两个LOAD项。 
磁盘上的Disk page往内存上加载的时候是先加载到虚拟地址空间VP上然后再从虚拟地址空间上映射到物理内存PP

物理内存和虚拟内存的管理方式都是页面。可执行文件为什么要将段按页面组织?因为虚拟地址空间跟物理内存的基本单位都是页面,是为了方便映射。 

  • 使用mmap函数磁盘上的页面映射到虚拟地址空间,也就是在虚拟地址空间上开辟内存的方式是使用mmap函数
  • 虚拟地址空间上的虚拟页是通过多级页表的方式映射到物理内存上的

参考资料:程序员的自我修养、深入理解计算机系统

  • 17
    点赞
  • 2
    评论
  • 22
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 成长之路 设计师:Amelia_0503 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值