指令、函数调用、链接、装载

414 阅读15分钟

一、计算机指令

1.为什么要有汇编代码

我们实际在用 GCCGUC 编译器套装,GUI CompilerCollectipon)编译器的时候,可以直接把代码编译成机器码呀,为什么还需要汇编代码呢?

因为汇编代码其实就是“给程序员看的机器码”,,也正因为这样,机器码和汇编代码是一 一对应的。我们人类很容易记住 add、mov 这些用英文表示的指令,而 8b 45 f8 这样的指令,由于很难一下子看明白是在干什么,所以会非常难以记忆。

2.五类常见的指令

  • 第一类是算术类指令。我们的加减乘除,在CPU 层面,都会变成一条条算术类指令。
  • 第二类是数据传输类指令。给变量赋值、在内存里读写数据,用的都是数据传输类指令。
  • 第三类是逻辑类指令。逻辑上的 与、或、非,都是这一类指令。
  • 第四类是条件分支类指令。日常我们写的 “if/else”,其实就是条件分支类指令。
  • 第五类是无条件跳转指令。写一些大一点的程序,我们常常需要写一些函数或者方法。在调用函数的时候,其实就是发起了一个无条件跳转指令。

二、指令跳转

1.寄存器的组成

N个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存器,能够保持N 位的数据。比方说,我们用的 64 位 Intel 服务器,寄存器就是 64 位的。

2.各类寄存器及其作用

一个 CPU 里面会有很多种不同功能的寄存器。其中有三种比较特殊的寄存器,分别是PC 寄存器、指令寄存器、条件码寄存器。

image.png

除了这些特殊的寄存器,CPU 里面还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个。

我们通常根据存放的数据内容来给它们取名字,比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等等。

有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器

3.简述程序执行过程

实际上,一个程序执行的时候,CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。 可以看到,一个程序的一条条指令,在内存里面是连续保存的,也会一条条顺序加载。

而有些特殊指令,比如跳转指令,会修改 PC 寄存器里面的地址值。这样,下一条要执行的指令就不是从内存里面顺序加载的了。事实上,这些跳转指令的存在,也是我们可以在写程序的时候,使用 if…else 条件语句和 while/for 循环语句的原因。

三、函数调用

1.调用函数执行结束后怎么找到原来的指令地址?

比如函数A 调用函数B,当函数B 执行结束后,计算机怎么找到原先在函数A 的指令地址呢?

要找到原来的指令地址,那么就要把函数B 执行结束后要跳回来执行的指令地址给记录下来

2.怎么记录函数结束后跳转的指令地址

(1)能不能用寄存器存储跳转指令地址?

我们先来想想,使用寄存器来存储跳转指令地址的方式,比如我们可以设立一个 “程序调用寄存器”,来存储接下来要跳转回来执行的指令地址。等到函数调用结束,从这个寄存器里取出地址,再跳转到这个记录的地址,继续执行就好了。

这样看起来没什么问题,但是在多层函数调用里,简单只记录一个地址也是不够的。我们在调用函数A 之后,A 还可以调用函数B, B 还能调用函数C。这一层有一层的调用并没有数量上的限制。 在所有函数调用返回之前,每一次调用的返回地址都要记录下来,但是CPU 里的寄存器数量并不多。像我们一般使用的 Intel i7 CPU 只有16个64位寄存器,调用的层数一多久存不下了。

所以,不能用寄存器存储跳转指令地址。,因为CPU 的寄存器数量不多,而函数调用并没有数量上的限制。

(2)在内存中使用栈,存储跳转指令地址

最终,计算机科学家们想到一个比单独记录回来的地址更完善的办法。使用栈保存函数调用完成后的返回地址,包括函数的参数

(3)如何构造stack overflow(栈溢出)

栈的大小也是有限的。如果函数调用层数太多,我们往栈里压入它存不下的内容,程序在执行的过程中就会遇到栈溢出的错误,这就是大名鼎鼎的“stack overflow”。

要构造一个栈溢出的错误并不困难,最简单的办法,就是让函数A 调用自己,并且不设任何终止条件。这样一个无限递归的程序,在不断地压栈过程中,将整个栈空间填满,并最终遇上 stack overflow。

除了无限递归,递归层数过深,在栈空间里面创建非常占内存的变量(比如一个巨大的数组,在Java里貌似不可行,因为栈里存的是对象的引用),这些情况都很可能给你带来 stack overflow。

四、静态链接

1.高级语言->汇编代码->机器码的过程

实际上, “高级语言->汇编代码->机器码” 这个过程,在我们的计算机上进行的时候是由两部分组成的。

  • 第一部分由编译、汇编以及链接三个阶段组成,在这三个阶段完成之后,我们就生成了一个可执行文件
  • 第二部分,我们通过装载器把可执行文件装载到内存中。CPU 从内存中读取指令和数据,来开始真正执行程序。

image.png

2.为什么程序无法同时在Linux 和 Windows 下运行?

其中一个非常重要的原因就是,两个操作系统下可执行文件的格式不一样

  • Windows 的可执行文件格式是一种叫作PE(Portable Executable Format)的文件格式;
  • Linux 下的装载器只能解析 ELF 格式而不能解析 PE 格式,

但是现在有了一些程序可以打破这个局面:

  • Linux 下著名的开源项目 Wine,就是通过兼容PE 格式的装载器,使得我们能直接在 Linux 下运行 Windows 程序的;
  • 而现在微软的Windows 里面也提供了 WSL,也就是 Windows Subsystem for Linux,可以解析和加载
  • ELF 格式的文件

五、程序装载

1.程序装载面临的挑战

装载器解析 ELF 或者 PE 格式的可执行文件。装载器会把对应的指令和数据加载到内存里面,让CPU 去执行

装载器需要满足两个要求

那如何装载到内存呢,实际上装载器需要满足两个要求

  • 第一,可执行程序加载后占用的内存空间应该是连续的。因为执行心灵的时候,程序计数器是顺序地一条一条指令执行下去,这就意味着,这一条条指令需要连续地存储在一起。
  • 第二,我们需要同时加载很多程序,并且不能让程序自己规定在内存中加载的位置

如何满足这两个要求呢?

在内存里面,找到一段连续的内存空间,然后分配给装载的程序,然后把这段连续的内存空间地址,和整个程序指令里指定的内存地址做一个映射。

我们把指令里用到的内存地址叫做虚拟内存地址,实际在内存硬件里面的空间地址,我们叫物理内存地址

2.物理内存和虚拟内存地址的映射

程序里有指令和各种内存地址,我们只需要关心虚拟内存地址就行了。对于任何一个程序来说,它看到的都是同样的内存地址。我们维护一个虚拟内存到物理内存的映射表,这样实际程序指令执行的时候,会通过虚拟内存地址,找到对应的物理内存地址,然后执行。因为是连续的内存地址空间,所以我们只需要维护映射关系的起始地址和对应的空间大小就可以了。

2.1.内存分段

概念

这种找出一段连续的物理内存和虚拟内存地址进行映射的方法,我们叫分段。这里的,就是指系统分配出来的那个连续的内存空间

好处与不足

分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但是它也有一些不足之处,第一个就是内存碎片的问题。

解决内存碎片的办法

内存交换,把不连续的一个程序占用的空间从内存写到硬盘,然后再从硬盘上读回到内存里,不过读回来的时候,不再是把它加载到原来的位置,而是紧紧地跟在上一个程序所占用的内存后面。占用就解决了两个程序之间的碎片问题。

内存分段带来的性能瓶颈

虚拟内存、分段,再加上内存交换,看起来似乎已经解决了计算机同时装载运行很多个程序的问题。 但是这三者的组合仍然会遇到一个性能瓶颈,那就是硬盘的访问速度比内存慢很多,而每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,如果内存交换的时候,交换的是一个很占内存空间的程序,这样整个机器都会显得很卡顿。

2.1内存分页

概念

既然问题出在内存碎片和内存交换的空间太大上,那么解决问题的办法就是,少出现一些内存碎片。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决这个问题。这个办法,在现在计算机的内存管理里面,就叫作内存分页

(Paging)。

和分段这样分配一整段连续的空间给到程序相比,分页是把整个物理内存空间切成一段段固定尺寸的大小。而对应的程序所需要占用的虚拟内存空间,也会同样切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫(Page)。

从虚拟内存到物理内存的映射,不再是拿整段连续的内存的物理地址,而是按照一个一个页来的。页的尺寸一般远远小于整个程序的大小。在 Linux 下,我们通常只设置成 4KB。你可以通过命令看看你手头的 Linux 系统设置的页的大小。

由于内存空间都是预先划分好的,也就没有了不能使用的碎片,而只有被释放出来的很多 4KB 的页。即使内存空间不够,需要让现有的、正在运行的其他程序,通过内存交换释放出一些内存的页出来,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,让整个机器被内存交换的过程给卡住。

分页的结果

分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。

当要读取特定的页,却发现数据并没有加载到物理内存里的时候,就会触发一个来自于 CPU 的缺页错误(Page Fault)。我们的操作系统会捕捉到这个错误,然后将对应的页,从存放在硬盘上的虚拟内存里读取出来,加载到物理内存里。

通过虚拟内存、内存交换和内存分页这三个技术的组合,我们最终得到了一个让程序不需要考虑实际的物理内存地址、大小和当前分配空间的解决方案。这些技术和方法,对于我们程序的编写、编译和链接过程都是透明的。

3.JVM是怎么把程序装载到内存里的?

JVM已经是上层应用,无需考虑物理分页,一般更直接是考虑对象本身的空间大小,物理硬件管理统一由承载 JVM 的操作系统去解决。

六、动态链接

1.静态链接

程序的链接,是把对应的不同文件内的代码段,合并到一起,成为最后的可执行文件(合并代码段)。这个链接的方式,让我们在写代码的时候做到了 “复用”。同样的功能代码只要写一次,然后提供给很多不同的程序进行链接就行了。

但是,如果我们有很多个程序都要通过装载器装载到内存里面,那里面链接好的同样的功能代码,也都需要再装载一遍,再占一遍内存空间

2.动态链接

2.1.概念

在动态链接的过程中,我们想要 “链接” 的,不是存储在硬盘上的目标文件代码,而是加载到内存中的共享库(Shared Libraries)。

这个加载到内存中的共享库会被很多个程序的指令调用到

  • 在 Windows 下,这些共享库文件就是 .dll 文件,也就是 Dynamic-Link LibaryDLL,动态链接库);
  • 在 Linux 下,这些共享库文件就是.so 文件,也就是 Shared Object(一般我们也称之为动态链接库)。

要想要在程序运行的时候共享代码,也有一定的要求,就是这些机器码必须是“地址无关”的。也就是说,我们编译出来的共享库文件的指令代码,是地址无关码。

2.2.地址无关与地址相关

一段代码,无论加载在哪个内存地址,都能够正常执行,那就是地址无关代码;否则,就是地址相关代码。

  • 大部分函数库其实都可以做到地址无关,因为它们都接受特定的输入,进行确定的操作,然后给出返回结果就好了。无论是实现一个向量加法,还是实现一个打印的函数,这些代码逻辑和输入的数据在内存里面的位置并不重要。
  • 常见的地址相关的代码,比如绝对地址代码(Absolute Code)、利用重定位表的代码等等,都是地址相关的代码。

对于所有动态链接共享库的程序来讲,虽然我们的共享库用的都是同一段物理内存地址,但是在不同的应用程序里,它所在的虚拟内存地址是不同的。

我们没办法、也不应该要求动态链接同一个共享库的不同程序,必须把这个共享库所使用的虚拟内存地址变成一致。如果这样的话,我们写的程序就必须明确地知道内部的内存地址分配

3.怎么做到动态共享库编译的代码指令地址无关

动态代码库内部的变量和函数调用都很容易解决,我们只需要使用相对地址(Relative Address)就好了。各种指令中使用到的内存地址,给出的不是一个绝对的地址空间,而是一个相对于当前指令偏移量的内存地址。因为整个共享库是放在一段连续的虚拟内存地址中的,无论装载到哪一段地址,不同指令之间的相对地址都是不变的。

虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。