当前位置: 首页 > news >正文

编译和链接【四】链接详解

文章目录

  • 编译和链接【四】链接详解
    • 前言
    • 系列文章入口
    • 符号表和重定位表
    • 链接过程
      • 分段组装
      • 符号决议
      • 重定位

编译和链接【四】链接详解

前言

在我大一的时候, 我使用VC6.0对C语言程序进行编译链接和运行 , 然后我接触了VS, Qt creator等众多IDE, 这些IDE界面友好, 使用方便, 例如我最喜欢的VS,一键编译运行。对于大一的我,不需要了解编译的整个过程就可以运行,这无疑是非常棒的,并且增加了我对编程的兴趣,同时也简化了我后续的软件开发, 我只需要关心业务和功能代码即可。

但是今天, 我不想“逃课了”,欢迎来到我的频道,本系列 将会介绍编译中的一系列细节。

在正式开始之前,我要推荐两本书,一本是《程序员的自我修养》,另一本是《鲸书》,这两本书对编译的整个过程做了非常详细,非常完备的介绍,但是恰恰如此,我想很多时候,很多知识在工作上是用不到的,也许这句话在很多年多的我会反驳,但是站在工作一年的现在,我将会给你介绍,我所了解的编译和链接。

系列文章入口

关注我~持续更新

编译和链接【一】总述

编译和链接【二】预处理

编译和链接【三】编译过程

符号表和重定位表

在链接过程中,符号表和重定位表是非常重要的两个表。在汇编阶段,汇编器会分析汇编语言中各个section的信息,收集各种符号,生成符号表,将符号在section内的偏移地址也填充到符号表里。

使用 readelf -s main.o 查看目标文件的符号表信息

在这里插入图片描述

在符号表里,可以看到许多符号信息,比如符号的地址,类型和占用空间的大小。

符号表本质上一个结构体数组,在Arm平台下,定义在Linux内核的/arch/arm/include/asm/elf.h里

typedef struct elf32_sym
{Elf32_word st_name;Elf32_Addr st_value;Elf32_word st_size;unsigned char st_info;unsigned cahr st_other;Elf32_Half st_shndx;
}

符号的类型主要有:

  • OBJECT:对象类型,一般用来标识变量
  • FUNC:函数
  • FILE:当前目标文件的名称
  • SECTION:代表一个section,用来重定位
  • COMMON:公用块数据对象,是一个全局弱符号,在当前文件中未分配空间
  • TLS:表示该符号对应的变量存储在线程局部存储

在 C/C++中,编译器是是源文件为翻译单元进行编译的,如果在我们的程序中,我们引用了其他文件的函数或者全局变量,那么编译器会不会报错呢?

其实是不会的,只要你在调用之前进行声明,那么编译器就会认为你的这个函数或者全局变量在其他文件中定义,编译阶段是不会报错的,链接器会尝试在其他文件或者库里查找这个符号的具体定义,但是如果此时还没找到,那么就会报连链接错误。

main.cpp:undefined reference to ‘Addr’

编译器在给每个目标文件生成符号表的过程中,如果没用找到符号的定义,那么也会把这些符号搜集在一起并保存到一个单独的符号表中,这个符号表就是重定位符号表

使用 readelf -s main.o 查看目标文件的符号表信息

在这里插入图片描述

在这个表的Type列,类型为NOTYPE属于未定义状态,需要后续填充,同时在main.o中会使用一个重定位表**.rel.text**来记录这些需要重定位的符号。使用readelf查看重定位表和section header table信息

readelf -S main.o # 查看section header table信息

readelf -r main.o # 查看重定位信息

可以看到:

Relocation section '.rela.text' at offset 0x4c0 contains 2 entries:Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000000004  000200000002 R_X86_64_PC32     0000000000000000 printf - 4
00000000000c  000300000002 R_X86_64_PC32     0000000000000000 puts - 4

我们看到了需要重定位的符号:printf和puts,在后续的链接过程中经过重定位,会更新为新的实际地址

链接过程

在前面的文章里,我介绍了目标文件是由:代码段,数据段,BSS段,符号表等section组成的,这些section从目标文件的零地址处开始顺序排放,而每个符号相对于零地址的偏移,就是每个符号的地址,但是这个地址是暂时的。

在后续的链接过程中,这些目标文件的section会重新拆分组装,每个section的起始参考地址会发生变化,导致每个section定义的函数、全局变量等符号的地址也随之变化,需要重新修改,即:重定位

这些函数、变量等符号被编译器收集并放在符号表,符号表又被放在目标文件中,这些目标文件是不可指定的,它们需经过链接器链接、重定位才能运行。

而整个链接过程主要分为三步:

  • 分段组装
  • 符号决议
  • 重定位

分段组装

顾名思义,在链接的第一步就是将各个目标文件重新分解组装,代码段放在一起,形成最终可执行文件的代码段,其他的section也是如此。

在这里插入图片描述

而需要特别关注的section就是符号表,链接器会在可执行文件里创建一个全局的符号表。通过这步操作,一个可执行文件的所有符号都有的自己的地址,并保存在全局符号表中,但是此时全局符号表的地址还是原来在各个目标文件中的地址,即:相对于零地址的偏移。

显然,当前的任务是需要修改这个地址,而需要确定这个地址,就需要先明白,可执行文件最终是要被加载到内存中执行的,那么会被加载到什么地址呢?

一般来说,会在链接时,指定一个链接地址,链接地址也是程序要加载到内存中的地址。

各个段在可执行文件中先后组装顺序也要是一个需要考量的问题。这个问题一般是通过链接脚本来解决。

链接脚本本质上是一个脚本文件,在这个文件里,不仅规定了各个段的组装顺序、起始地址、位置对齐信息,同时对输出的可执行格式、运行平台、入口地址都有描述。

链接器就是根据链接脚本的规则来组装可执行文件的,并最终将这些信息以section的形式保存在可执行文件的ELF Header中。

下面展示一个简单的链接脚本:

OUTPUT_FORMAT("elf32-littlearm") ;输出ELF文件格式
OUTPUT_ARCH("ARM")	; 运行在arm平台
ENTRY(_start) ;程序入口地址
SECTIONS
{.= 0x60000000 	; 代码段的起始地址.text: {*(.text)}	; 代码段.= 0x6020000	; 数组段起始地址.data: {*(data)}	; 数据段.bss: {*(.bss)}	; BSS段
}

程序运行时,加载器首先会解析可执行文件中的ELFHeader头部信息,验证程序的运行平台和加载地址信息,然后将可执行文件加载到内存中对应的地址,程序就可以正常运行了。

使用ld --verbose来查看链接器默认的链接脚本

在这里插入图片描述

不同的编译器默认的链接地址也是不一样的,在一个由带有MMU的系统中,程序的链接起始地址往往都是一个虚拟地址,程序运行过程中还需要地址转换,通过MMU将虚拟地址转换为物理地址,然后才能访问内存,这部分内容属于CPU硬件底层要关心的内容,和编译原理是不冲突的。

符号决议

当我们在翻译单元里,统一了相同命名的符号的时候,就会发生符号冲突,那么最终的可执行文件会使用哪一个呢?

这就是符号决议的内容,一般规则为:

  • 强符号不能相同命名
  • 强符号可以和弱符号共存
  • 弱符号可以共存。

函数名,初始化的全局变量就是强符号,而未初始化的全局变量则是弱符号。

在一个工程项目里,强符号不能多次定义,否则就会发生重定义错误,而强符号和弱符号可以共存,当共存时,强符号会覆盖弱符号,链接器会选择强符号作为可执行文件的最终符号。

main.c

#include <stdio.h>int Addr;int main()
{int a = 3, b = 4;int c = a + b;printf("Addr=%d\n", Addr);return 0;
}

source.c

int Addr = 1;

使用gcc source.c main.c则可通过编译

链接器在进行符号决议时,选择了强符号(source.c源文件中定义的i符 号),丢弃了弱符号(main.c源文件中定义的未初始化的全局符号i)。如果修改程序,将main.c文件中的Addr也赋一个初值,再去重新编译这两个源文件,就会发现链接器会报重定义错误,因为此时一个项目中出现了两个同名的强符号。

在这里插入图片描述

当然,这段代码在C++中是无法通过编译的,C++对弱符号的定义有所不同,如果此时Addr声明为extern,则可以通过编译。

链接器也允许一个项目中出现多个弱符号共存。在程序编译期间,编译器在分析每个文件中未初始化的全局变量时,并不知道该符号在链接阶段是被采用还是被丢弃,因此在程序编译期间,未初始化的全局变量并没有被直接放置在BSS段中,而是将这些弱符号放到一个叫作COMMON的临时块中,在符号表中使用一个未定义的COMMON来标记,在目标文件中也没有给它们分配存储空间。

在链接期间,链接器会比较多个文件中的弱符号,选择占用空间最大的那一个,作为可执行文件中的最终符号,此时弱符号的大小已经确定,并被直接放到了可执行文件的BSS段中。

main.c

#include <stdio.h>char Addr;int main()
{int a = 3, b = 4;int c = a + b;return 0;
}

source.c

double Addr = 1;

在这里插入图片描述

在main.c里,我将Addr定义为char类型,而source.c里,我定义为double类型,在我的电脑上,double类型占8个字节,那么可以在目标文件里看到实际大小为8个字节,但是在source.o这个目标文件里,可以看到大小为1个字节。

但是最终生成的可执行目标文件的大小为8个字节,符合我说的结论。

如果在项目中有特殊需求,我们也可以将一些强符号显式转化为弱符号。GNU C编译器在ANSI C语法标准的基础上扩展了一系列C语言语法,如提供了一个__attribute__关键字用来声明符号的属性。通过下面的命令,可以将一个强符号转化为弱符号。

_attribute_((weak)) int n = 100;

_attribut_((weak)) void func();

下面进行验证:

main.c

#include <stdio.h>__attribute__((weak)) int Addr = 20;int main()
{printf("Addr = %d\n", Addr);return 0;
}

source.c

int Addr = 10;

在这里插入图片描述

现在在C/C++中,都能通过编译了。

和强符号、弱符号对应的,还有强引用、弱引用的概念。在一个程序中,我们可以定义多个函数和变量,变量名和函数名都是符号,这些符号的本质,或者说这些符号值,其实就是地址。在另一个文件中,我们可以通过函数名去调用该函数,通过变量名去访问该变量。 我们通过符号去调用一个函数或访问一个变量,通常称之为引用(reference),强符号对应强引用,弱符号对应弱引用。

在程序链接过程中,若对一个符号的引用为强引用,链接时找不到其定义,链接器将会报未定义错误;若对一个符号的引用为弱引用,链接时找不到其定义,则链接器不会报错,不会影响最终可执行文件的生成。可执行文件在运行时如果没有找到该符号的定义才会报错。

利用链接器对弱引用的处理规则,我们在引用一个符号之前可以先判断该符号是否存在(定义)。这样做的好处是:当我们引用一个未定义符号时,在链接阶段不会报错,在运行阶段通过判断运行,也可以避免运行错误。

举个例子:我们想实现一个加法模块,并封装成库的形式给应用程序开发者调用,在模块实现的过程中,我们可以将提供给用户的一系列API函数声明为弱符号。

这样做的好处就是:

  • 当我们对某些API的实现不满意的时候,我们可以定义和其同名的函数,这样直接调用不会发生冲突
  • 在库的实现过程中,我们可以将某些还没完成的API定义为弱引用,应用程序在调用之前先判断该函数是否实现,然后才调用,这样,在未来发布新版本的时候,无论这些函数是否实现或者已经删除,都不会影响应用程序的正常链接和运行。

例如:

header.h

#pragma once__attribute__((weak)) int add(int a, int b);

source.c

#include "header.h"__attribute__((weak)) int add(int a, int b )
{return a + b;
}

main.c

#include <stdio.h>
#include "header.h"__attribute__((weak)) int Addr = 20;int main()
{if (add)printf("add(1, 2) = %d\n", add(1, 2));return 0;
}

在上面的代码片里,我们实现了一个加法库,并把接口声明为弱引用,而在main.c里,我们调用了add函数,但是在调用之前,我们先判断了符号这样做的好处就是无论程序是否存在都不影响运行。

在这里插入图片描述

在这里插入图片描述

程序的运行结果也从侧面验证了上面的理论分析是正确的。

重定位

经过符号决议,我们解决了链接过程中多文件符号冲突的问题。经过处理之后,可执行文件的符号表中的每个符号虽然都确定下来了,但是还存在一个问题:符号表中的每个符号值,也就是每个函数、全局变量的地址,还是原来各个目标文件中的值,还都是基于零地址的偏移。链接器将各个目标文件重新分解组装后,各个段的起始地址都发生了变化。

那么各个段中的符号地址也要跟着发生变化。编译器生成的各个目标文件,以零地址为起始地址放置各个函数的指令代码,各个函数相对于零地址的偏移就是各个函数的入口地址。

链接器在链接程序时一般会基于某个链接地址link_addr进行链接,所以最后main()函数和sub()函数的真实地址就被改变了

程序经过重新分解组装后,无论是代码段,还是数据段,各个符号的真实地址都发生了变化。而此时可执行文件的全局符号表中,各个符号的值还是原来的地址,所以接下来还要修改全局符号表中这些符号的值,将它们的真实地址更新到符号表中。修改完毕后,当我们想通过符号引用去调用一个函数或访问一个变量时,就能找到它们在内存中的真实地址了。

在这里插入图片描述

链接器怎么知道哪些符号需要重定位呢?不要忘了,在各个目标文件中还有一个重定位表,专门记录各个文件中需要重定位的符号。重定位的核心工作就是修正指令中的符号地址,是链接过程中的最后一步,也是最核心、最重要的一步,前面两步的操作,其实都是为这一步服务的。

在编译阶段,编译器在将各个C源文件生成目标文件的过程中,遇到未定义的符号一般不会报错,编译器会认为这些符号可能会在其他地方定义。在链接阶段,链接器在其他地方找不到该符号的定义,才会报链接错误。编译器在链接阶段会搜集这些未定义的符号,生成一个重定位表,用来告诉链接器,这些符号在文件中被引用,但是在本文件中没有找到定义,有可能在其他文件或库中定义,“我就先不报错了,你链接的时候找找看”。

无论是代码段,还是数据段,只要这个段中有需要重定位的符号 , 编 译 器 都 会 生 成 一 个 重 定 位 表 与 其 对 应 : .rel.text或.rel.data。这些重定位表记录各个段中需要重定位的各种符号,并以section的形式保存在各个目标文件中。我们可以通过readelf或objdump命令来查看一个目标文件中的重定位表信息。

重定位表中有一个信息比较重要:需要重定位的符号在指令代码中的偏移地址offset,链接器修正指令代码中各个符号的值时要根据这个地址信息才能从茫茫的二级制代码中找到它们。链接器读取各个目标文件中的重定位表,根据这些符号在可执行文件中的新地址,进行符号重定位,修改指令代码中引用这些符号的地址,并生成新的符号表。重定位过程中的地址修正其实很简单,如下所示。

重定位的新地址 = 新的段基址 + 段内偏移

至此,整个链接过程就结束了,我们跟踪的整个编译流程也就结束了。最终生成的文件就是一个可执行目标文件。


http://www.mrgr.cn/news/90586.html

相关文章:

  • 逻辑分析仪的使用-以STM32C8T6控制SG90舵机为例
  • Linux系统调用
  • MySQL 入门大全:数据类型
  • DataBase【MySQL基础夯实使用说明(下)】
  • 浏览器网络请求全流程深度解析
  • Llama_Index核心组件介绍
  • 【设计模式】【行为型模式】状态模式(State)
  • Redis7.0八种数据结构底层原理
  • Spring Boot + ShardingSphere 踩坑记
  • 前缀树算法篇:前缀信息的巧妙获取
  • 动态规划LeetCode-416.分割等和子集
  • 动态规划LeetCode-1049.最后一块石头的重量Ⅱ
  • 计算机网络和操作系统常见面试题目(带脑图,做了延伸以防面试官深入提问)
  • 小白零基础如何搭建CNN
  • UGUI Canvas为Overlay模式下的UI元素的position和localPosition
  • 【Matlab算法】基于人工势场的多机器人协同运动与避障算法研究(附MATLAB完整代码)
  • C++病毒(^_^|)(2)
  • 变化检测相关论文可读list
  • 位运算算法篇:异或运算
  • 2、k8s 二进制安装(详细)