您的浏览器不支持CSS3,建议使用Firfox、Chrome等浏览器,以取得最佳显示效果

C语言内功修炼之程序链接

硬件嵌入式相关 824℃ 2 3年前 (2014-11-16)

——–————————————————————————————————————————————
                                                                  以下是关于链接的简单说明
———————————————————————————————————————————————
程序从源代码到可执行程序会经过这么几个过程:
1 预处理器对源码进行预处理,处理宏定义以及编译选项等,源代码这时侯从.c变成.i文件。
2 C语言编译器将.i文件编译成.s汇编文件。
3 汇编器将.s文件汇编成可重定位目标文件即.o文件(这就是链接的主角)
4 链接.o文件形成可执行文件
其中第四步,链接,就是我们要讨论的内容。

链接主要做了两件事情:1 符号解析 2 重定位。

概念:
一、什么是符号解析1
符号解析就是处理文件中符号的引用和被引用的关系,将每一个符号的定义和引用对应起来,在 C语言中,诸如int a;struct obj{…};void fun(void){…},中的a ,obj, fun都是符号。符号有强符号和弱符号之别,对强弱符号的忽视是引起许多BUG的缘由。

二、符号表
每个.o文件内都有一个符号表,用于记录符号的信息。一般来说有三种符号: 1 导出的全局符号 2引入的外部符号 3本地符号(包括static局部变量)。
每个符号对应于符号表当中的一个条目,条目的基本结构如下:
typedef struct{
    int name; /*符号名字在字符串表的下标*/
    int value; /*符号的地址,实际上是对应于SECTION的偏移*/
    int size; /*大小*/
    char type:4; /*类型,可以说数据、函数*/
    binding:4; /*表示符号是全局符号还是本地符号*/
    char reserved;/*保留,没用*/
    char section; /*符号所在段在头部表索引*/
}ELF_Symbol;

我们来看一个实例,随便找来一个test.o文件,readelf -a test.o得到:
Symbol table ‘.symtab’ contains 14 entries:
Value Size Type Bind Vis Ndx Name
00000000 0 NOTYPE LOCAL DEFAULT UND
00000000 0 FILE LOCAL DEFAULT ABS test.c
00000000 160 FUNC GLOBAL DEFAULT 1 bin_div
000000a0 453 FUNC GLOBAL DEFAULT 1 merge
00000265 261 FUNC GLOBAL DEFAULT 1 merge_sort
0000036a 58 FUNC GLOBAL DEFAULT 1 main
00000000 0 NOTYPE GLOBAL DEFAULT UND printf
Value Size Type Bind Name分别于ELF_Symbol中成员一一对应。而Ndx对应于section,1表示的是.txt段。UND,ABS没有索引,表示未定义符号段和不被重定位断。因为printf是外部定义的所以属于未定义符号,而test.c这个符号是文件名,它不是程序中被引用或者定义的符号,所以不会被重定位。
符号表将在下面的符号解析中被用到。

三、什么是符号解析2
上面有说到,符号解析就是:将每一个符号的定义和引用对应起来。也就是说,把在程序中符号的名字用其具体的实现去替换掉。怎么理解呢?比如说,豆腐李拿着条子写着“黄豆三斤”的条子,去找黄豆张拿黄豆,条子一递上去,黄豆张就把三斤黄豆给了豆腐李(当然是要付钱的!),这里就叫做符号解析:黄豆张把对应于“黄豆”和“三斤”的符号替换成了实物,那么黄豆张如何把“黄豆”和那种黄色的颗粒高分子化合物对应起来的呢?当然是来自于对“黄豆”这种符号的约定描述,这大概就跟我们所写作中的侧面描写原理是一样的。假如你说“白糖”,和“提取自甘蔗和甜菜的白色结晶体,有甜味,可溶于水,可食用”,和你刚刚泡水里面喝下去的白糖其实是一样东西,第一个是引用(就是一个名字,一个符号),第二个是定义,第三个是实体。所以我们可以这么总结:实体(你喝下去的白糖)可以被属性集合(白色晶体颗粒…)定义,而属性集合可以被符号(“白糖”这个符号)引用。而程序中符号的最终实体就是储存器中的数据和处理方式等等,这个数据被符号表用:名字,长度,所在段,地址,全局变量等等来定义,这些定义又被具体的符号所引用比如说int a 中的符号a。看我扯那么多,现在是不是对程序代码有更深层次的理解了呢?
我们还可以深入下去(下面这段是分叉),想一下结构体或者类的实例化,好像有点。。。什么内在的联系?对的,类的实例化,结构体的实例化(或者说对象),实际上是用属性集合描述去得到一个表示实体的符号。想一下面向对象,如果我现在要向机器描述这么一个对象:
Typedef struct{
    salary_t low;
    figure_t rich;
    tieba_t liyi;
    int (*amusement)(struct conputer *)  game;
    int (*action)(titbe_t *,int (*picture)[240])  play;
}obj.
于是你们就明白这个对象是什么了,这时侯只要obj *diaosi = new obj;于是一个吊丝出现了,这时侯吊丝就可以上liyi吧发图了diaosi->play(diaosi->liyi,&pic);也可以打游戏了diaosi->game(&LOL),发完图了,打完游戏了,嗯?好像有什么不对,喔!注意到了:吊丝是怎么发图的?用的什么网络?电信还是联通?是wifi还是拿线牵着电脑?这就对了,我们并不在意吊丝到死怎么发图的(再回头想想面向对象编程);所以:面向对象是一种很朴素很直白很自然思想,它不是技术,也不仅仅是编程思想,其实我们写文章描述春风十里,迎面桃花,何尝不是一种面向对象的思想呢?(以上是本人胡扯,你们请选择性地相信)
总之:
符号的解析,就是用符号的属性集合,也就是符号表描述的实体(储存器内的数据)其替换符号(即对实体的引用)。

四、什么是重定位?
每一个.o文件的起始地址都是0,比如a.o,b.o,c.o都是以0地址开始的,a.o里面第一个变量 int i_a和b.o里的第一个变量int i_b地址都是0,也就是说我们要把多个.o文件粗糙地组成一个可执行文件就会出现地址上的冲突。我们动手验证这一点:

1

看到目录下有好几个.c文件。输入arm-linux-gcc -c + filename 编译得到:

oset

然后用arm-linux-objdum -S 反汇编出来是这样的:

driver.o文件反汇编

driver
s3c2440_uart.o的反汇编是这样的:

uart
看吧,第一个符号,分别是default_show和uart_open的地址都是0。这样可不行,怎么可以一个地址上有两个符号,所以链接器会解决这种冲突,我们看看链接后是什么样的。
linker
看吧,地址分别变成了0x33f80750和0x33f82154。 所以重定位就是安排好地址,将不同文件的每个符号与一个具体的储存器地址关联起来,然后修改对符号的引用指向这个具体的储存器地址。这个链接地址如何安排由链接器脚本来指定(这个文件就是嵌入式中常常需要编写的.lds文件),一般来说处理方法都是将每个.o文件中相同的段放置在一块连续的地址内。

五、从动作上看链接。
假设我们链接的文件是:a.o b.o 文件以a.o b.o的顺序输入。
1 链接器拿出一个箩筐用来装前文中已经定义的符号,假设叫做DEF_SYM
2 链接器再拿出一个箩筐用来装已经有引用但是还没定义的符号,假设叫做UDEF_SYM
3 链接器又拿出来一个箩筐用来装那些需要被链接进输出镜像的.o文件,假设叫做LINK_O
现在开始工作:
首先扫面a.o,发现是一个.o文件,于是LINK_O += a.o(这个式子表示a.o被加入LINK_O这个集合)。接着扫面a.o中的符号,把所有a.o中定义的符号加入DEF_SYM中,那些已经声明但是没有定义的符号加入UDEF_SYM中。然后尝试UDEF_SYM中的符号在DEF_SYM中匹配,匹配到就从UDEF_SYM中去除掉。接着扫描b.o,又发现是一个.o文件,于是LINK_O += b.o。然后扫描b.o中的符号,重复上面的行为,首先将定义的符号加入DEF_SYM,然后未定义的加入UDEF_SYM,这时侯再来UDEF_SYM与DEF_SYM匹配,匹配上的符号将从UDEF_SYM中去除。如果到最后UDEF_SYM不为空则发送一个错误信息。如果为空,那么就开始对LINK_O中的文件进行进一步处理。
前面我们已经看到每个.o文件中的符号对应了一个地址:比如default_show 在driver.o中对应 0 地址。open_uart在s3c2440_uart.o中对应0地址。现在我们看看在所谓的“进一步处理”也就是重定位,解决地址冲突,是如何执行的。
在前面的符号表条目的说明中,我们知道,每个符号都对应一个符号表。符号解析的时候,根据符号名称,对应到符号表,于是得到其地址,以及其它属性信息,比如说一条 a = 10;的语句,这时侯a这个符号对应到一个符号表条目,符号表条目明确描述a :地址在0x30000000 全局变量 长度是4字节,然后就可以知道要在0x30000000 ~ 0x30000004中按照小端(这里是ARM920T核)填充数据(10).但是,在一个.o文件中并不是每个符号都有确定地址的,一些被引用的符号,比如说a.o中有extern int i_a; a = 10;这样,由于是外部定义的符号,所以a.o并不知道i_a到底在那儿,只有定义i_a的那个.o文件知道,准确的说是因为a.o中没有i_a的符号表条目。这时侯为了处理这种情况,i_a被随便定义成了一个地址,比如说 i_a = 10会被翻译成 mov r0,#10;ldr r0,=0x11111111; str r0,[r1],这里显然随便给i_a定义了一个临时的地址,然后会生成一个重定位条目,在重定位的时候利用这个重定位条目去修正其临时地址。我们下面会看到重定位时是如何解决这个问题。(再次说明:把a符号对应到一个符号表条目关联起来的处理就是符号解析。现在明白了符号解析了吗?)。
接下来就该重定位了。
完成了符号解析之后,要开始重定位了,也就是对符号地址进行修正,总不能让两个符号在一个地址,会发生冲突。
首先重定位段,把a.o 和 b.o文件的.data段进行调整然后合并成一个.data段,如何调整,很简单,假设两个.data都段在地址0x10000000,那么就把其中一个.o文件中的.data段中的符号从一个地址xxxxxxxx开始排列,然后在排列的结尾处接着开始排列另外一个.o文件,那么先排哪个文件呢?这个xxxxxxxx地址又如何确定呢?这个是由链接器脚本决定的,也就是说我们可以编写脚本来控制先排哪个,从哪儿开始排列(也就是确定xxxxxxxx这个地址),所以结果就是:每个符号分别加上一个偏移,比如第一个被排列的文件中每个符号地址都加上(xxxxxxxx – 0x10000000)就行了。
然后重定位符号引用。前面我们说到对于像是extern int i_a这样的不知道地址的符号引用,生成了一个临时地址,然后生成了一个重定位条目,这个条目告诉了链接器要如何去修正这些地址,现在就要修正这些临时地址了。
到底重定位条目如何告诉链接器去修正地址的呢?来看看重定位条目的庐山真面目。

typedef struct{
    int offset;//需要被重定位的内容的段偏移
    int symbol:24,//符号
    type:8;//类型
}Elf32_Rel;h

offset是symbol这个符号在程序段中的偏移,而type是重定位类型,重定位有PC相对引用类型,和绝对地址引用类型。我们只讨论PC相对引用的类型。
上面说到,a.o不知到i_a在哪,是因为它没有i_a的符号表条目,假设i_a是在b.o中定义的,b.o中就会有i_a的符号表条目,也就有地址,所以在重定位的时候,所有.o文件被输入链接器,那么现在链接器已经i_a符号的地址了,它要根据a.o中的重定位条目和b.o中的符号表条目去修正a.o中对i_a的引用。来看这么个例子:
X86架构,有这么一条十六进制代码
6: e8 fc ff ff ff
它对应的重定位条目是 7:R_386_PC swap,对应的是offset = 7, symbol = swap , type = P_386_PC,它告诉链接器:在0x07这个偏移地址地址上要修改为对swap符号的相对引用。

这么解读:e8是一条call指令机器码,而ff ff ff fc(小端存放)由补码表示为十进制-4.了解call指令的同学都知道call指令事实上在跳转之前有一个push操作,并且call指令是一个相对跳转指令不是绝对指令,即call指令是根据PC值做出跳转的(这种代码叫做位置无关码,有兴趣拓展了解可以查阅XIP相关资料),PC + 引用地址 = 跳转地址。根据X86的流水设计,我们知道PC = 下一条指令地址。
在跳转时候的PC = 6 + 5(e8 fc ff ff ff共5字节) = 11;
跳转地址 = PC + 引用地址 = 11 – 4 = 7
于是e8 fc ff ff ff 翻译成call 7,也就是这个call指令跳转到了存放临时引用-4所在的位置,换个角度说,-4这个值,使得call跳转到了7这个位置,所以我们要跳转到某个地址x只需要将引用修改为
e8 yy yy yy yy,其中yyyyyyyy = (x – 7) + (-4).这么理解,-4 对应 7 ,那么yyyyyyyy对应的就是 yyyyyyyy – (-4) + 7 = x,所以yyyyyyyy = x – 4 – 7.

假设已经确定符号所在的段为0x10000000,而swap符号的地址在0x10000100 那么call指令所在的地址就是 0x10000000 + 6 = 0x10000006,需要修改的内容,也就是0xfffffffc所在的位置是0x10000000 + 7 = 0x10000007
然后计算出 yyyyyyyy = x – 4 – 0x10000007 = 0x10000100 – 4 – 0x10000007 = F5.
于是指令变成
e8 f5 00 00 00
翻译成汇编,e8 为 call,所在地址为0x10000006,执行到这里的时候PC为下一条指令,也就是0x1000000b,于是
跳转地址 = PC + f5 = 0x1000000b + 0xf5 = 0x10000100
所以翻译成汇编就是
e8 f5 00 00 00 = call 0x10000100
重定位完成。
现在所有的符号都在链接脚本的控制下被排列在段中,得到了一个互相不冲突的地址,并且符号间的引用已经通过重定位修正到正确的地址上,所以,链接器的工作已经完成了,镜像产生,大功告成。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!

2

评论2条

  1. yoki 回复

    虽然目前看不懂,但依旧要点赞

  2. jzj1993 回复

    :razz: 我来点个赞~

评论前:需填写以下信息,或 登录

用户登录

忘记密码?