您可以捐助,支持我们的公益事业。

1元 10元 50元





认证码:  验证码,看不清楚?请点击刷新验证码 必填



  求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Model Center   Code  
会员   
   
 
     
   
 
 订阅
C/C++编译链接
 
 
   次浏览      
 2024-1-23
 
编辑推荐:
本文主要介绍了C/C++编译链接相关内容。 希望能为大家提供一些参考或帮助。
文章来自于知乎,由火龙果Linda编辑推荐。

前言

C语言程序从源代码到二进制行程序都经历了那些过程?本文以Linux下C语言的编译过程为例,讲解C语言程序的编译过程。

编写hello world C程序:

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

 

编译过程只需:

$ gcc hello.c # 编译
$ ./a.out # 执行
hello world!

这个过程如此熟悉,以至于大家觉得编译事件很简单的事。事实真的如此吗?我们来细看一下C语言的编译过程到底是怎样的。

上述gcc命令其实依次执行了四步操作:

预处理(Preprocessing)

编译(Compilation)

汇编(Assemble)

链接(Linking)

示例

为了下面步骤讲解的方便,我们需要一个稍微复杂一点的例子。假设我们自己定义了一个头文件mymath.h,实现一些自己的数学函数,并把具体实现放在mymath.c当中。然后写一个test.c程序使用这些函数。程序目录结构如下:

├── test.c 
└── inc     
      ├── mymath.h     
      └── mymath.c

 

程序代码如下:

// test.c
#include <stdio.h>
#include "mymath.h"// 自定义头文件
int main(){
    int a = 2;
    int b = 3;
    int sum = add(a, b); 
    printf("a=%d, b=%d, a+b=%d\n", a, b, sum);
}

头文件定义:

// mymath.h
#ifndef MYMATH_H
#define MYMATH_H
int add(int a, int b);
int sum(int a, int b);
#endif

 

头文件实现:

// mymath.c
int add(int a, int b){
    return a+b;
}
int sub(int a, int b){
    return a-b;
}

1.预处理(Preprocessing)

预处理用于将所有的#include头文件以及宏定义替换成其真正的内容,预处理之后得到的仍然是文本文件,但文件体积会大很多。gcc的预处理是预处理器cpp来完成的,你可以通过如下命令对test.c进行预处理:

gcc -E -I./inc test.c -o test.i

或者直接调用cpp命令

$ cpp test.c -I./inc -o test.i

上述命令中-E是让编译器在预处理之后就退出,不进行后续编译过程;-I指定头文件目录,这里指定的是我们自定义的头文件目录;-o指定输出文件名。

经过预处理之后代码体积会大很多:

预处理之后的程序还是文本,可以用文本编辑器打开

2.编译(Compilation)

这里的编译不是指程序从源文件到二进制程序的全部过程,而是指将经过预处理之后的程序转换成特定汇编代码(assembly code)的过程。编译的指定如下:

$ gcc -S -I./inc test.c -o test.s

上述命令中-S让编译器在编译之后停止,不进行后续过程。编译过程完成后,将生成程序的汇编代码test.s,这也是文本文件,内容如下:

// test.c汇编之后的结果test.s
    .file   "test.c"
    .section    .rodata
.LC0:
    .string "a=%d, b=%d, a+b=%d\n"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    pushl   %ebp
    .cfi_def_cfa_offset 8
    .cfi_offset 5, -8
    movl    %esp, %ebp
    .cfi_def_cfa_register 5
    andl    $-16, %esp
    subl    $32, %esp
    movl    $2, 20(%esp)
    movl    $3, 24(%esp)
    movl    24(%esp), %eax
    movl    %eax, 4(%esp)
    movl    20(%esp), %eax
    movl    %eax, (%esp)
    call    add 
    movl    %eax, 28(%esp)
    movl    28(%esp), %eax
    movl    %eax, 12(%esp)
    movl    24(%esp), %eax
    movl    %eax, 8(%esp)
    movl    20(%esp), %eax
    movl    %eax, 4(%esp)
    movl    $.LC0, (%esp)
    call    printf
    leave
    .cfi_restore 5
    .cfi_def_cfa 4, 4
    ret 
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 4.8.2-19ubuntu1) 4.8.2"
    .section    .note.GNU-stack,"",@progbits

3.汇编(Assemble)

汇编过程将上一步的汇编代码转换成机器码(machine code),这一步产生的文件叫做目标文件,是二进制格式。gcc汇编过程通过as命令完成:

$ as test.s -o test.o

等价于:

gcc -c test.s -o test.o

这一步会为每一个源文件产生一个目标文件。因此mymath.c也需要产生一个mymath.o文件

4.链接(Linking)

链接过程将多个目标文以及所需的库文件(.so等)链接成最终的可执行文件(executable file)。

命令大致如下:

$ ld -o test.out test.o inc/mymath.o ...libraries...

上面可以看到最终连接依赖了两个文件,mymath.o test.0还有其他一些依赖库

链接的详细过程如下:

合并段

在elf文件中字节对齐是以4字节对齐的,在可执行程序中是以页的方式对齐的(一个页的大小为4k),因此如果我们在链接时将各个.o文件各个段单独的加载到可执行文件中,将会非常浪费空间:

因此我们需要合并段,调整段偏移,把每个.o文件的.text段合并在一起.data段合并在一起,这样在生成的可执行文件中,各个段都只有一个,如下图,由于在链接时只需要加载代码段(.text段)和数据段(.data段和.bss段)。因此合并段之后,在系统给我们分配内存时,只需要分配两个页面大小就可以,分别存放代码和数据

调整段偏移

汇总所有符号

每个obj文件在编译时都会生成自己的符号表,我们要把这些符号都合并起来进行符号解析

完成符号的重定位

在进行合并段,调整段偏移时,输入文件的各个段在连接后的虚拟地址就已经确定了,这一步完成后,连接器开始计算各个符号的虚拟地址,因为各个符号在段内的相对位置是固定的,所以段内各个符号的地址也已经是确定的了,只不过连接器需要给每个符号加上一个偏移量,使他们能够调整到正确的虚拟地址,这就是符号的重定位过程

在 elf文件中,有一个叫重定位表的结构专门用来保存这些与从定位有关的信息,重定位表在elf文件中往往是一个或多个段

5.数据和指令

上面是代码编译链接的过程,得到了可执行的文件后,程序在内存中是如何运行的,数据和指令又分别是什么

所有的全局变量和静态变量都是数据,除此之外都是指令(包括局部变量)

int data1 = 10;
int data2 = 0;
int data3;

static int data4 = 10;
static int data5 = 0;
static int data6;

int main()
{

}

虚拟地址空间

在每个进程运行的时候,我们的操作系统都会给他分配一个固定大小的虚拟地址空间(x86,32bit,Linux内核下默认大小为4G),那这段内存分配结构如下:

整个4G的空间有1G是供操作系统使用的内核空间,用户无法访问,还有3G是我们的用户空间,以供该虚拟地址空间上进程的运行,在这3G的用户空间中又被分成了很多段,从0地址开始的128M大小是系统的预留空间,用户也是无法访问的。

接下来是.text段,该段空间中存放的是代码,然后是.data段和.bss段,这两段里面存放的都是数据,但又有不同:data段中存放的数据是已经初始化并且初始化值不为0的数据,而.bss段中存放的是未经初始化或者初始化为0的数据(注:bssbetter save space(更好的节省空间))

静态链接

动态链接

我们调用动态链接库有两种方法:一种是编译的时候,指明所依赖的动态链接库,这样loader可以在程序启动的时候,将所有的动态链接映射到内存中;一种是在运行过程中,通过dlopen和dlfree的方式加载动态链接库,动态将动态链接库加载到内存中。

从编程角度来讲,第一种是最方便的,效率上影响也不大。

在内存使用上有些差别:

第一种方式,一个库的代码,只要运行过一次,便会占用物理内存,之后即使再也不使用,也会占用物理内存,直到进程的终止。

第二中方式,库代码占用的内存,可以通过dlfree的方式,释放掉,返回给物理内存。

对于那些寿命很长,但又会偶尔调用各种库的进程有关。如果是这类进程,建议采用第二种方式调用动态链接库。

延迟绑定GOT PLT

程序运行过程中,很多函数在程序执行完也不会用到,ELF采用了一种延迟绑定的方法,就是函数第一次被用到才会进行绑定,如果没有用到就不会进行绑定,主要通过GOT(Global Offset Table)

第一次调用:GOT表是空的,会动态解析符号的绝对地址执行,同时将地址记录在GOT表中

第二次调用:GOT表中已经记录了地址,直接跳转执行

GOT和PLT在内存中位置如下:

   
次浏览       
相关文章

深度解析:清理烂代码
如何编写出拥抱变化的代码
重构-使代码更简洁优美
团队项目开发"编码规范"系列文章
相关文档

重构-改善既有代码的设计
软件重构v2
代码整洁之道
高质量编程规范
相关课程

基于HTML5客户端、Web端的应用开发
HTML 5+CSS 开发
嵌入式C高质量编程
C++高级编程

最新活动计划
C++高级编程 12-25 [线上]
白盒测试技术与工具实践 12-24[线上]
LLM大模型应用与项目构建 12-26[特惠]
需求分析最佳实践与沙盘演练 1-6[线上]
SysML建模专家 1-16[北京]
UAF架构体系与实践 1-22[北京]
 
 
最新文章
.NET Core 3.0 正式公布:新特性详细解读
.NET Core部署中你不了解的框架依赖与独立部署
C# event线程安全
简析 .NET Core 构成体系
C#技术漫谈之垃圾回收机制(GC)
最新课程
.Net应用开发
C#高级开发技术
.NET 架构设计与调试优化
ASP.NET Core Web 开发
ASP.Net MVC框架原理与应用开发
成功案例
航天科工集团子公司 DotNet企业级应用设计与开发
日照港集 .NET Framewor
神华信 .NET单元测试
台达电子 .NET程序设计与开发
神华信息 .NET单元测试