关闭

链接器和加载器

531人阅读 评论(0) 收藏 举报

功能:把抽象的名字和底层更抽象的地址绑定起来,形成符号表。

编译形成的目标代码地址是从0地址开始的,而链接器需要将代码重定位到主内存当中运行。还需要对程序中的数据和指令中的地址和偏移量进行修改。链接器要修改每个指令中引用的数据内存地址,每个跳转指令引用的地址,要被加载或存储的数据的地址,或指令要跳转到的地址等。。具体定位到哪个地址和计算机的 体系结构有关。

硬件体系结构的两个方面影响到链接器:程序寻址和指令格式。

这里写图片描述
这里写图片描述

这里写图片描述

MMU的作用

1.实现多任务

2.内存保护

3.提供更大内存

最近正在重温《程序员的自我修养》一书,由于水平比以前有所提升,所以读书的收获也不一样。

下面针对该书3.3.3节BSS段的内容进行更细节的探讨——该节内容不在本文中重复说明了,只说一下结论。对于全局变量来说,如果初始化了不为0的值,那么该全局变量则被保存在data段,如果初始化的值为0,那么将其保存在bss段,如果没有初始化,则将其保存在common段,等到链接时再将其放入到BSS段。关于第三点不同编译器行为会不同,有的编译器会把没有初始化的全局变量直接放到BSS段。

关于上面这个结论,我就不重复进行探讨了,下面探讨一下更细节点的问题。从上面的内容上看,尽管未初始化的全局变量有可能在编译阶段被保存在common段,但是最终还是会放到BSS段。那么我们是否可以将未初始化的全局变量与初始为0的全局变量等同起来呢?

  MMU ( Memory Management Unit )是内存管理单元的简称,读者朋友在学习嵌入式的时候应该听说过 µCLinux ,这是适合没有 MMU 的微控制器使用的嵌入式 Linux 操作系统,比如 ARM7 。由于没有 MMU ,所以在 µCLinux 上实现多任务功能是一个非常棘手的问题。从而引出了本节的关注点: MMU 的作用是什么?简单地说, MMU 的作用有两点:地址翻译、内存保护。

1 、地址翻译

  在处理器上一般会运行一个操作系统,如 Linux ,用户编写的源程序需要经过编译、链接得到可执行文件,然后被操作系统加载执行。编译、链接的过程在第 2 章实验环境搭建中有过描述,在链接的时候需要指定一个链接描述脚本,链接描述脚本有很多作用,其中一项是控制可执行文件中 Section 和符号的内存布局,也就是控制可执行程序在内存中是如何放置的,操作系统会按照可执行文件的要求将其加载到内存对应地址并执行。假如用户 A 编写了程序 ProgramA ,并且 ProgramA 占用的内存空间是 0x100-0x200 ,用户 B 编写了程序 ProgramB ,并且 ProgramB 要求的内存空间也是 0x100-0x200 ,这是完全有可能的,因为给操作系统提供程序的用户很多,不可能限定每个用户使用不同部分的内存。这样当 ProgramA 被加载执行时, ProgramB 就不能被加载执行,一旦 ProgramB 也被加载了就会破坏 ProgramA 的执行,因为后者会覆盖 ProgramA 占用的内存。为了解决这个问题,将操作系统和处理器都做了修改,添加了 MMU ,在其中进行地址翻译,程序加载入内存的时候为其建立地址翻译表,处理器执行不同程序的时候使用不同的地址翻译表,如图 10.1 所示。


  ProgramA 被加载到地址 0x500-0x600 处, ProgramB 被加载到地址 0x700-0x800 处,同时建立了各自的地址翻译表,当处理器要执行 ProgramB 时,会使用 ProgramB 对应的地址翻译表,比如读取 ProgramB 地址 0x100 处的指令,那么经过地址翻译表可知 0x100 对应实际内存的 0x700 处,所以实际读取的就是 0x700 处的指令。同样的,当处理器要执行 ProgramA 时,会使用 ProgramA 对应的地址翻译表,这样就避免了之前提到的内存冲突问题,有了 MMU 的支持,操作系统就可以轻松实现多任务了。

  图 10.1 中 CPU 给出的地址称之为虚拟地址( OR1200 中称之为有效地址 EA ),经过 MMU 翻译后的地址称之为物理地址。

  MMU 的地址翻译功能还可以为用户提供比实际大得多的内存空间。用户在编写程序的时候并不知道运行该程序的计算机内存大小,如果在链接的时候指定程序被加载到地址 Addr 处,而运行该程序的计算机内存小于 Addr ,那么程序就无法执行,有了 MMU 后,程序员就不用关心实际内存大小,可以认为内存大小就是“ 2^ 指令地址宽度”。 MMU 会将超过实际内存的虚拟地址翻译为物理地址进行访问。

  地址翻译表存储在内存中,如果采用图 10.1 中的方式:地址翻译表的表项是一个虚拟地址对应一个物理地址,那么会占用太多的内存空间,为此,需要修改翻译方式,常用的有三种:页式、段式、段页式,这也是三种不同的内存管理方式。

  页式内存管理将虚拟内存、物理内存空间划分为大小固定的块,每一块称之为一页,以页为单位来分配、管理、保护内存。此时 MMU 中的地址翻译表称为页表( Page Table ),每个任务或进程对应一个页表,页表由若干个页表项( PTE : Page Table Entry )组成,每个页表项对应一个虚页,内含有关地址翻译的信息和一些控制信息。在页式内存管理方式中地址由页号和页内位移两部分组成,其地址翻译方式如图 10.2 所示。

MMU 的作用及工作过程

  MMU ( Memory Management Unit )是内存管理单元的简称,读者朋友在学习嵌入式的时候应该听说过 µCLinux ,这是适合没有 MMU 的微控制器使用的嵌入式 Linux 操作系统,比如 ARM7 。由于没有 MMU ,所以在 µCLinux 上实现多任务功能是一个非常棘手的问题。从而引出了本节的关注点: MMU 的作用是什么?简单地说, MMU 的作用有两点:地址翻译、内存保护。

1 、地址翻译

  在处理器上一般会运行一个操作系统,如 Linux ,用户编写的源程序需要经过编译、链接得到可执行文件,然后被操作系统加载执行。编译、链接的过程在第 2 章实验环境搭建中有过描述,在链接的时候需要指定一个链接描述脚本,链接描述脚本有很多作用,其中一项是控制可执行文件中 Section 和符号的内存布局,也就是控制可执行程序在内存中是如何放置的,操作系统会按照可执行文件的要求将其加载到内存对应地址并执行。假如用户 A 编写了程序 ProgramA ,并且 ProgramA 占用的内存空间是 0x100-0x200 ,用户 B 编写了程序 ProgramB ,并且 ProgramB 要求的内存空间也是 0x100-0x200 ,这是完全有可能的,因为给操作系统提供程序的用户很多,不可能限定每个用户使用不同部分的内存。这样当 ProgramA 被加载执行时, ProgramB 就不能被加载执行,一旦 ProgramB 也被加载了就会破坏 ProgramA 的执行,因为后者会覆盖 ProgramA 占用的内存。为了解决这个问题,将操作系统和处理器都做了修改,添加了 MMU ,在其中进行地址翻译,程序加载入内存的时候为其建立地址翻译表,处理器执行不同程序的时候使用不同的地址翻译表,如图 10.1 所示。


  ProgramA 被加载到地址 0x500-0x600 处, ProgramB 被加载到地址 0x700-0x800 处,同时建立了各自的地址翻译表,当处理器要执行 ProgramB 时,会使用 ProgramB 对应的地址翻译表,比如读取 ProgramB 地址 0x100 处的指令,那么经过地址翻译表可知 0x100 对应实际内存的 0x700 处,所以实际读取的就是 0x700 处的指令。同样的,当处理器要执行 ProgramA 时,会使用 ProgramA 对应的地址翻译表,这样就避免了之前提到的内存冲突问题,有了 MMU 的支持,操作系统就可以轻松实现多任务了。

  图 10.1 中 CPU 给出的地址称之为虚拟地址( OR1200 中称之为有效地址 EA ),经过 MMU 翻译后的地址称之为物理地址。

  MMU 的地址翻译功能还可以为用户提供比实际大得多的内存空间。用户在编写程序的时候并不知道运行该程序的计算机内存大小,如果在链接的时候指定程序被加载到地址 Addr 处,而运行该程序的计算机内存小于 Addr ,那么程序就无法执行,有了 MMU 后,程序员就不用关心实际内存大小,可以认为内存大小就是“ 2^ 指令地址宽度”。 MMU 会将超过实际内存的虚拟地址翻译为物理地址进行访问。

  地址翻译表存储在内存中,如果采用图 10.1 中的方式:地址翻译表的表项是一个虚拟地址对应一个物理地址,那么会占用太多的内存空间,为此,需要修改翻译方式,常用的有三种:页式、段式、段页式,这也是三种不同的内存管理方式。

  页式内存管理将虚拟内存、物理内存空间划分为大小固定的块,每一块称之为一页,以页为单位来分配、管理、保护内存。此时 MMU 中的地址翻译表称为页表( Page Table ),每个任务或进程对应一个页表,页表由若干个页表项( PTE : Page Table Entry )组成,每个页表项对应一个虚页,内含有关地址翻译的信息和一些控制信息。在页式内存管理方式中地址由页号和页内位移两部分组成,其地址翻译方式如图 10.2 所示。

由于UNIX会自动将新分配的内存清0,所以未初始化的全局变量和初始化为0的全局变量不必在a.out文件中存储,而只需要记录其符号,等待加载到内存中运行时再分配。而栈分配的内存,由于每次存储变量的地址不确定,所以初始值可以是任意值。所以如果不存在操作系统,则需要程序员软件手工清理BSS段,即把全局变量都置0.从而保持一致。

只要地址是页对齐的,并且能够与链接器和加载器打成一致,加载到哪里都没有关系。

这里写图片描述

这里写图片描述

C语言 强弱符号,强弱引用

首先我表示很悲剧,在看《程序员的自我修养--链接、装载与库》之前我竟不知道C有强符号、弱符号、强引用和弱引用。在看到3.5.5节弱符号和强符号时,我感觉有些困惑,所以写下此篇,希望能和同样感觉的朋友交流也希望高人指点。

  首先我们看一下书中关于它们的定义。

  引入场景:(1)文件A中定义并初始化变量i(int i = 1), 文件B中定义并初始化变量i(int i = 2)。编译链接A、B时会报错b.o:(.data+0x0): multiple definition of `i';a.o:(.data+0x0): multiple definition of `i'。(2)在文件C中定义并初始化两个变量i(int i = 1; int i = 2), 编译链接时会报错c.c:2:5: error: redefinition of ‘i'; c.c:1:5: note: previous definition of ‘i' was here。

  强符号:像场景中这样的符号定义被称为强符号,对于C/C++来说,编译器默认函数和初始化的全局变量为强符号。
  弱符号:接上文,为初始化的全局变量为弱符号。
  编译器关于强弱符号的规则有:(1)强符号不允许多次定义,但强弱可以共存;(2)强弱共存时,强覆盖弱;(3)都是弱符号时,选择占用空间最大的,如选择  double类型的而不选择int类型的。

  由以上定义所以有我之前没有想到的场景:
  代码a.c:

1 int i = 2;
  代码b.c:


复制代码 代码如下:
#include<stdio.h>

int i;
int main(int argc, char** argv)
{
      printf("i = %d\n", i);
      return 0;      
}



  编译文件a和b并链接,结果输出i为2而不是0。
  并且在同一个文件中定义但未初始化两个相同的变量不会报错,只有在使用变量时才会报错。
  对于GCC编译器来说,还允许使用__attribute__((weak))来将强符号定义为弱符号,所已有
  代码c.c


复制代码 代码如下:
 #include<stdio.h>

  __attribute__((weak)) int i = 1;

  int main(int argc, char** argv)
  {
       printf("i = %d\n", i);
       return 0;   
  }


  结果i的输出仍未2而不是1。

  那么对于函数而言是不是也这样呢?先不看函数,而是先看由强弱符号而进一步引入的强弱引用。书中关于强弱引用的概述是对于强引用若未定义则链接时肯定会报错,而对于弱引用则不会报错,链接器默认其为0(这一点对于函数好理解,即函数符号所代表入口地址为0;对于变量就要注意了,既然是引用那自然就是地址了,所以同函数一样变量的地址为0而不是变量的值为0)。此时对于强弱引用是不是还没有什么明确的概念呢?到底什么是引用?引用和符号又是什么关系?这里我说一下我的理解(欢迎指正),在定义和声明处指定的函数名、变量名即为对应的符号,而在代码其他处调用函数或使用变量时,则把函说明和变量名看作引用,这样一来符号和引用在代码层面上其实就是一个东西,只是根据环境而叫法不同而已。那么强符号对应强引用,弱符号对应弱引用。

  有上面的强弱引用的特点可看出,当一个函数为弱引用时,不管这个函数有没有定义,链接时都不会报错,而且我们可以根据判断函数名是否为0来决定是否执行这个函数。这样一来,包含这些函数的库就可以以模块、插件的形式和我们的引用组合一起,方便使用和卸载,并且由于强符号可以覆盖弱符号和强弱符号与强弱引用的关系可知,我们自己定义函数可以覆盖库中的函数,多么美妙。

  先看根据条件判断是否执行函数:
  代码d.c


复制代码 代码如下:
 #include<stdio.h>

void func()
{
     printf("func()#1\n");
}


  代码e.c


复制代码 代码如下:
 #include<stdio.h>

 __attribute__((weak)) void func();

 int main(int argc, char** argv)
 {
      if (func)
          func();
      return 0;
 }

  编译d.c,cc -c d.c 输出d.o;编译e.c并链接d.o,cc d.o e.c -o e输出可执行文件e,运行e正常执行函数func。编译e.c但不链接d.o,此时并不会报错,只不过func不会执行,因为没有它的定义所以if(func)为假。
  再看函数覆盖:
  代码f.c


复制代码 代码如下:
 #include<stdio.h>

 __attribute__((weak)) void func()
 {
      printf("func()#1\n");
 }


  代码g.c


复制代码 代码如下:
 #include<stdio.h>

 void func()
 {
      printf("func()#2\n");
  }

 int main(int argc, char** argv)
 {
      func();
      return 0;
 }
 ~       


  编译链接,结构输出"func()#2"。

  以上可以说明函数和变量是保持一致的,其实对应变量也可以像使用函数那样先判断再使用,只不过不是判断变量的值而是变量的地址,如
  代码v1.c


复制代码 代码如下:
int i = 2;


  代码v2.c


复制代码 代码如下:
 #include<stdio.h>

 __attribute__((weak)) extern int i;

 int main(int argc, char** argv)
 {
      if (&i)
          printf("i = %d\n", i);
     return 0;
 }
 ~       


  编译并链接v1时,输出2;编译但不链接v1时无输出。这样做时要分清定义和声明的区别,__attribute__((weak)) int i 是定义变量并转换为弱符号,这样i是分配了空间的,而__attribute__((weak)) extern int i 则将原来定义的变量i由强符号转换为弱符号,导致使用i时不是强引用而是弱引用。不过虽然变量可以这么做但没有函数那样有意义。

  上面关于强弱引用仍旧使用的是GCC提供的__attribute__((weak)),而书中还提到了__attribute__((weakref)),后者貌似更能体现“引用”这一关键词。而我之所以使用前者来介绍强弱引用,是因为我对关于强弱符号与强弱引用对应关系的理解。关于__attribute__((weakref))的使用方法,这里介绍一种(两者都有不同的使用方法)。
  代码a.c


复制代码 代码如下:
 #include<stdio.h>

 void bar()
 {
      printf("foo()\n");
 }


  代码b.c


复制代码 代码如下:
 #include<stdio.h>

 static void foo() __attribute__((weakref("bar")));

  int main(int argc, char** argv)
 {
      if (foo)
         foo();

      return 0;
 }


  注意函数foo的static修饰符,没有的话会报错,这样将函数foo限制在只有本文件内可使用。

  好了,夜已深,写的有点凌乱,我也凌乱了。

加载是讲一个程序放到主存里使其运行的过程。

0
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:22986次
    • 积分:1142
    • 等级:
    • 排名:千里之外
    • 原创:81篇
    • 转载:15篇
    • 译文:0篇
    • 评论:1条
    最新评论