一、计算机指令
1、指令
从软件工程师的角度来讲,CPU就是一个执行各种计算机指令(Instruction Code)的逻辑.。
这里的计算机指令,也可以叫做机器语言。
不同发CPU支持的机器语言不同,如个人电脑用的是Intel的CPU,苹果手机用的是ARM的CPU,这两种CPU各自支持的语言就是两组不同的计算机指令集。
一个计算机程序,是由成千上万条指令组成的,但是CPU里不能一直放着所有指令,所以计算机程序平时是存储在存储器中的。这种程序指令存储在存储器里的计算机,我们就叫做存储程序型计算机(Stored-program Computer)(现代计算机出世之前,有一种插线板计算机,是不能存储程序的,工程师在一个布满了各种插口合插座的板子上,用不同的电线来连接不同的插口合插座,从而完成各种计算任务)
程序编译成汇编语言,再由编译器翻译成机器码,一条机器码,就是一条计算机指令。
不同的 CPU 有不同的指令集,也就对应着不同的汇编语言和不同的机器码
常见的指令可以分为五大类
- 算术类指令:加减乘除
- 数据传输类指令:给变量赋值,在内存里读写数据
- 逻辑类指令:逻辑上的与或非
- 条件分支类指令:if-else
- 无条件跳转指令:函数调用
2、指令跳转
拿 Intel CPU 来说,里面差不多有几百亿个晶体管,我们先不管几百亿的晶体管的背后是怎么通过电路运转起来的,逻辑上,我们可以认为,CPU 其实就是由一堆寄存器组成的。而寄存器就是 CPU 内部,由多个触发器(Flip-Flop)或者锁存器(Latches)组成的简单电路(触发器和锁存器,其实就是两种不同原理的数字电路组成的逻辑门)。
N 个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存器,能够保存 N 位的数据,比方说, 64 位 Intel 服务器,寄存器就是 64 位的。
三种比较特殊的寄存器
通用寄存器既可以存放数据,又能存放地址。
一个程序执行的时候,CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。可以看到,一个程序的一条条指令,在内存里面是连续保存的,也会一条条顺序加载。
除了简单地通过 PC 寄存器自增的方式顺序执行外,条件码寄存器会记录下当前执行指令的条件判断状态,然后通过跳转指令读取对应的条件码,修改 PC 寄存器内的下一条指令的地址,最终实现 if…else 以及 for/while 这样的程序控制流程。
二、链接和装载
1、ELF和静态链接
Linux 下,可执行文件和目标文件所使用的都是一种叫 ELF(Execuatable and Linkable File Format)的文件格式,中文名字叫可执行与可链接文件格式,这里面不仅存放了编译成的汇编指令,还保留了很多别的数据。
ELF 文件格式把各种信息,分成一个一个的 Section 保存起来。ELF 有一个基本的文件头(File Header),用来表示这个文件的基本属性,比如是否是可执行文件,对应的 CPU、操作系统等等。除了这些基本属性之外,大部分程序还有这么一些 Section:
- 首先是.text Section,也叫作代码段或者指令段(Code Section),用来保存程序的代码和指令;
- 接着是.data Section,也叫作数据段(Data Section),用来保存程序里面设置好的初始化数据信息;
- 然后就是.rel.text Secion,叫作重定位表(Relocation Table)。重定位表里,保留的是当前的文件里面,哪些跳转地址其实是我们不知道的;
- 最后是.symtab Section,叫作符号表(Symbol Table)。符号表保留了我们所说的当前文件里面定义的函数名称和对应地址的地址簿。
链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里面存储的地址,进行一次修正。最后,把所有的目标文件的对应段进行一次合并,变成了最终的可执行代码。
Windows 的可执行文件格式是一种叫作 PE(Portable Executable Format)的文件格式。同样一个程序,在 Linux 下可以执行而在 Windows 下不能执行,一个非常重要的原因就是,两个操作系统下可执行文件的格式不一样。
如果有一个能够解析 PE 格式的装载器,就有可能在 Linux 下运行 Windows 程序。Linux 下著名的开源项目 Wine,就是通过兼容 PE 格式的装载器,使得我们能直接在 Linux 下运行 Windows 程序的。而现在微软的 Windows 里面也提供了 WSL,也就是 Windows Subsystem for Linux,可以解析和加载 ELF 格式的文件。
2、程序装载
在运行可执行文件的时候,其实是通过一个装载器,解析 ELF 或者 PE 格式的可执行文件。装载器会把对应的指令和数据加载到内存里面来,让 CPU 去执行。
装载需要满足两个要求:
第一,可执行程序加载后占用的内存空间应该是连续的。执行指令的时候,程序计数器是顺序地一条一条指令执行下去。这也就意味着,这一条条指令需要连续地存储在一起。
第二,我们需要同时加载很多个程序,并且不能让程序自己规定在内存中加载的位置。虽然编译出来的指令里已经有了对应的各种各样的内存地址,但是实际加载的时候,其实没有办法确保这个程序一定加载在哪一段内存地址上。因为现在的计算机通常会同时运行很多个程序,可能你想要的内存地址已经被其他加载了的程序占用了。
要满足这两个基本的要求,可以在内存里面,找到一段连续的内存空间,分配给装载的程序,然后把这段连续的内存空间地址和整个程序指令里指定的内存地址做一个映射。
指令里用到的内存地址叫作虚拟内存地址(Virtual Memory Address),实际在内存硬件里面的空间地址,叫物理内存地址(Physical Memory Address)。
我们维护一个虚拟内存到物理内存的映射表,这样实际程序指令执行的时候,会通过虚拟内存地址,找到对应的物理内存地址,然后执行。因为是连续的内存地址空间,所以我们只需要维护映射关系的起始地址和对应的空间大小就可以了。
2.1 内存分段
分段是找出一段连续的物理内存和虚拟内存地址进行映射的方法。段,就是指系统分配出来的那个连续的内存空间。
分段的办法解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处,第一个就是内存碎片(Memory Fragmentation)的问题。
解决内存碎片的办法是内存交换(Memory Swapping):可以把 Python 程序占用的那 256MB 内存写到硬盘上,然后再从硬盘读回到紧跟着已经被占用了的 512MB 内存后面的内存里。
虚拟内存、分段,再加上内存交换,看起来似乎已经解决了计算机同时装载运行很多个程序的问题,但这三者的组合仍然会遇到一个性能瓶颈:硬盘的访问速度要比内存慢很多,而每一次内存交换,都需要把一大段连续的内存数据写到硬盘上。所以,如果内存交换的时候,交换的是一个很占内存空间的程序,这样整个机器都会显得卡顿。
2.2 内存分页
内存分页(Paging)可以让内存交换的时候,需要交换写入或者从磁盘装载的数据更少一点,以解决内存交换存在的问题。
和分段这样分配一整段连续的空间给到程序相比,分页是把整个物理内存空间切成一段段固定尺寸的大小。而对应的程序所需要占用的虚拟内存空间,也会同样切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。
从虚拟内存到物理内存的映射,不再是拿整段连续的内存的物理地址,而是按照一个一个页来的。页的尺寸一般远远小于整个程序的大小。在 Linux 下,通常只设置成 4KB。
由于内存空间都是预先划分好的,就没有了不能使用的碎片,只有被释放出来的很多 4KB 的页。即使内存空间不够,需要让现有的、正在运行的其他程序,通过内存交换释放出一些内存的页出来,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,让整个机器被内存交换的过程给卡住。
更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,只在程序运行中,加载当前用到的页。
3、动态链接
程序的静态链接,是把对应的不同文件内的代码段,合并到一起,成为最后的可执行文件。链接的方式,让代码做到了“复用”,同样的功能代码只要写一次,然后提供给很多不同的程序进行链接就行了。
但是,如果有很多个程序都要通过装载器装载到内存里面,那里面链接好的同样的功能代码,也都需要再装载一遍,再占一遍内存空间。
在动态链接的过程中,我们想要“链接”的,不是存储在硬盘上的目标文件代码,而是加载到内存中的共享库(Shared Libraries)。
这个共享库会被很多个程序的指令调用到。在 Windows 下,这些共享库文件就是.dll 文件,也就是 Dynamic-Link Libary(DLL,动态链接库)。在 Linux 下,这些共享库文件就是.so 文件,也就是 Shared Object(一般我们也称之为动态链接库)
要想要在程序运行的时候共享代码,编译出来的共享库文件的指令代码,必须是地址无关码(Position-Independent Code)。换句话说就是,这段代码,无论加载在哪个内存地址,都能够正常执行。
在动态链接对应的共享库的 data section 里面,保存了一张全局偏移表(GOT,Global Offset Table)。虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。所有需要引用当前共享库外部地址的指令,都会查询 GOT,来找到当前运行程序的虚拟内存里的对应位置。而 GOT 表里的数据,则是在加载共享库的时候写进去的。