计算机组成原理--程序的编译与加载

算是读书笔记吧

极客时间--深入浅出计算机组成原理


计算机指令与CPU

  • CPU

CPU 就是一个超大规模集成电路,通过电路实现了加法、乘法乃至各种各样的处理逻辑。

  • 计算机指令--机器码

各种用二进制编码方式表示的指令,叫做机器指令码。

好比一门 CPU 能够听得懂的语言,我们也可以把它叫作机器语言(Machine Language)。

  • 汇编

程序员很难记住二进制的机器码,而汇编代码其实就是“给程序员看的机器码”。它机器码一一对应。

要让一段程序在操作系统上跑起来,我们需要把整个程序翻译成一个汇编语言(ASM,Assembly Language)的程序,这个过程我们一般叫编译(Compile)成汇编代码。之后再用汇编器(Assembler)翻译成机器码(Machine Code)。

虽然gcc 和 objdump之类的命令,编译出来的机器码是16进制,但也只是方便程序员阅读,本质还是2进制机器码。

  • 计算机指令集

不同的 CPU 能够听懂的语言不太一样,因为内部支持着两组不同的计算机指令集。

比如,我们的个人电脑用的是 Intel 的 CPU,苹果手机用的是 ARM 的 CPU。
所以电脑上写一个程序,然后把这个程序复制一下,装到自己的手机上无法正常运行。

  • 常见的计算机指令

我们日常用的 Intel CPU,有 2000 条左右的 CPU 指令。不过一般来说,常见的指令可以分成五大类。

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

编程语言是自举的,指的是说,我们能用自己写出来的程序编译自己。
但是自举,并不要求这门语言的第一个编译器就是用自己写的。

比如,先是有了 Go 语言,我们通过 C++ 写了编译器 A。然后呢,我们就可以用这个编译器 A,来编译 Go 语言的程序。接着,我们再用 Go 语言写一个编译器程序 B,然后用 A 去编译 B,就得到了 Go 语言写好的编译器的可执行文件了。
这个之后,我们就可以一直用 B 来编译未来的 Go 语言程序,这也就实现了所谓的自举了。所以,即使是自举,也通常是先有了别的语言写好的编译器,然后再用自己来写自己语言的编译器。

第一台通用计算机 ENIAC,它的各种输入都是一些旋钮,可以认为是类似用机器码在编程,后来才有了汇编语言、C 语言这样越来越高级的语言。


指令的执行

本质上写好的代码变成了指令之后,会一条一条顺序执行。

  • 寄存器

CPU 其实就是由一堆寄存器组成的。而寄存器就是 CPU 内部,由多个触发器(Flip-Flop)或者锁存器(Latches)组成的简单电路。

N 个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存器,能够保存 N 位的数据。

三种基本寄存器

  1. PC 寄存器(Program Counter Register)
    也叫指令地址寄存器(Instruction Address Register)。顾名思义,它就是用来存放下一条需要执行的计算机指令的内存地址
  2. 指令寄存器(Instruction Register)
    存放当前正在执行的指令
  3. 条件码寄存器(Status Register)
    里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。

还有很多其他的寄存器。我们通常根据存放的数据内容来给它们取名字,比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。

  • 顺序执行

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

  • if/else

以一段代码举例

int main(){ 
  srand(time(NULL)); 
  int r = rand() % 2; 
  int a = 10; 
  if (r == 0) 
  { a = 1; 
  } else { 
    a = 2;
   }
}

ifelse对应的汇编代码如下:

  if (r == 0)
  33:   83 7d fc 00             cmp    DWORD PTR [rbp-0x4],0x0  //cmp 指令的比较结果,会存入到条件码寄存器当中去。然后PC 寄存器会自动自增
  37:   75 09                   jne    42 <main+0x42>  //jne 指令,是 jump if not equal 的意思。也就是说比对如果不相等,将会跳转到42。
  {
    a = 1;
  39:   c7 45 f8 01 00 00 00    mov    DWORD PTR [rbp-0x8],0x1  //mov 指令把 1 设置到对应的寄存器里去

  } else {
    a = 2;
  42:   c7 45 f8 02 00 00 00    mov    DWORD PTR [rbp-0x8],0x2  //mov 指令把 2 设置到对应的寄存器里去
  }

jne( jump if not equal )是条件分支类指令、jmp是无条件跳转指令。二者都会让PC 寄存器就不再是自增变成下一条指令的地址,而是被直接设置成这里的对应的地址。

  • for/while

int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        a += i;
    }
}

汇编之后的代码:

    int i;
    for (i = 0; i < 3; i++)
   b:   c7 45 f8 00 00 00 00    mov    DWORD PTR [rbp-0x8],0x0
  12:   eb 0a                   jmp    1e <main+0x1e>
    {
        a += i;
  14:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
  17:   01 45 fc                add    DWORD PTR [rbp-0x4],eax
    for (i = 0; i < 3; i++)
  1a:   83 45 f8 01             add    DWORD PTR [rbp-0x8],0x1
  1e:   83 7d f8 02             cmp    DWORD PTR [rbp-0x8],0x2
  22:   7e f0                   jle    14 <main+0x14>  
    }

这里的jle也是条件跳转指令、只不过他跳转的之前的位置14,也就是循环的开始位置。

同理,goto语句也是一样的地址跳转指令


程序栈

通过加入了程序栈,我们相当于在指令跳转的过程种,加入了一个“记忆”的功能,能在跳转去运行新的指令之后,再回到跳出去的位置,能够实现更加丰富和灵活的指令执行流程。

int static add(int a, int b)
{
    return a+b;
}
 
 
int main()
{
    int x = 5;
    int y = 10;
    int u = add(x, y);
}

汇编代码:

int static add(int a, int b)
{
   0:   55                      push   rbp     //压栈  //push rbp 就把之前调用函数,也就是 main 函数的栈帧的栈底地址,压到栈顶。
   1:   48 89 e5                mov    rbp,rsp
   4:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi
   7:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi
    return a+b;
   a:   8b 55 fc                mov    edx,DWORD PTR [rbp-0x4]
   d:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
  10:   01 d0                   add    eax,edx
}
  12:   5d                      pop    rbp   //将当前的栈顶出栈
  13:   c3                      ret   //把 call 调用的时候压入的 PC 寄存器里的下一条指令出栈,更新到 PC 寄存器中,将程序的控制权返回到出栈后的栈顶。
0000000000000014 <main>:
int main()
{
  14:   55                      push   rbp //通常以 rbp 来作为参数和局部变量的基址;
  15:   48 89 e5                mov    rbp,rsp
  18:   48 83 ec 10             sub    rsp,0x10
    int x = 5;
  1c:   c7 45 fc 05 00 00 00    mov    DWORD PTR [rbp-0x4],0x5
    int y = 10;
  23:   c7 45 f8 0a 00 00 00    mov    DWORD PTR [rbp-0x8],0xa
    int u = add(x, y);
  2a:   8b 55 f8                mov    edx,DWORD PTR [rbp-0x8]
  2d:   8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  30:   89 d6                   mov    esi,edx
  32:   89 c7                   mov    edi,eax
  34:   e8 c7 ff ff ff          call   0 <add>  //跳转到add函数,并把当前的 PC 寄存器里的下一条指令的地址压栈,保留函数调用结束后要执行的指令地址。
  39:   89 45 f4                mov    DWORD PTR [rbp-0xc],eax
  3c:   b8 00 00 00 00          mov    eax,0x0
}
  41:   c9                      leave 
  42:   c3                      ret   
  • 栈帧(Stack Frame)

整个函数 A 所占用的所有内存空间,就是函数 A 的栈帧(Stack Frame)
压栈的不只有函数调用完成后的返回地址,参数数据在寄存器不够用的时候也会被压入栈中。

  • 每个线程都有一个自己的栈

  • 程序栈布局

底在最上面,顶在最下面
这样的布局是因为栈底的内存地址是在一开始就固定的。而一层层压栈之后,栈顶的内存地址是在逐渐变小而不是变大。

  • 函数内联(Inline)

把一个实际调用的函数产生的指令,直接插入到的位置,来替换对应的函数调用指令

栈帧的入栈出栈也是要耗费性能的,内存操作是很慢的相对于cpu,如果不能加载到高速缓存里面,反复直接操作主内存会耗费相当多的性能(相对而言)。

  • 叶子函数(或叶子过程)

没有调用其他函数,只会被调用的函数


程序装载

  • 虚拟内存,虚拟内存地址,物理内存,物理内存地址

程序运行时,需要一个连续的地址空间让寄存器自增。但实际上并不可能在物理层面上实现,于是有了虚拟、物理之分。

  1. 虚拟内存、物理内存
    物理内存就是内存条。虚拟内存是进程运行时所有内存空间的总和、他可以大于内存条的容量。当我们访问一个不在物理内存中的地址,实际上就是被虚拟内存映射到其他介质(比如硬盘)上去读取了。

  2. 虚拟内存地址、物理内存地址
    这张图可以很好的说明虚拟内存地址与物理内存地址的关系。它可以保证一个程序的内存地址在逻辑(虚拟)上,是连续的。

  1. 缺页错误(Page Fault)

当要读取特定的页,却发现数据并没有加载到物理内存里的时候,就会触发一个来自于 CPU 的缺页错误(Page Fault)

我们的操作系统会捕捉到这个错误,然后将对应的页,从存放在硬盘上的虚拟内存里读取出来,加载到物理内存里。如果空间不够,就用LRU找到一个最少使用的页帧。

通过引入虚拟内存、页映射和内存交换,我们的程序本身,就不再需要考虑对应的真实的内存地址、程序加载、内存管理等问题了。任何一个程序,都只需要把内存当成是一块完整而连续的空间来直接使用。

静态链接

  • 可执行文件

操作系统可以运行的,是可执行文件。而非单纯的二进制文件。

对于单个文件,其地址都是从0开始的,系统并不知道改如何调用。我们需要将不同的文件链接起来。

  • 链接

在Linux中,可执行文件的格式为ELF(Execuatable and Linkable File Format),中文名字叫可执行与可链接文件格式。对应着Mac上为Mach-O。

一个项目在编译时,需要经过编译(Compile)、汇编(Assemble)以及链接(Link)三大步。

  • 符号表(Symbols Table)

可执行文件中,符号表相当于一个地址簿,把名字和地址关联了起来。

链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。
然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里面存储的地址,进行一次修正。
最后,把所有的目标文件的对应段进行一次合并,变成了最终的可执行代码。

通过一个装载器在运行运行这些程序,解析 ELF 或者 PE 格式的可执行文件。装载器会把对应的指令和数据加载到内存里面来,让 CPU 去执行。

所以,很多软件不可以跨平台运行,就是因为对应平台的装载器无法识别其可执行文件的格式。

**

动态链接

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

静态链接有这样一个问题,就是如果所有使用到的代码都需要加载到软件的内存空间中,那么系统库代码将会被copy许多份,动态链接可以保证同样共享库只在内存中加载一份。

  • 地址无关码(Position-Independent Code)

无论加载在哪个内存地址,都能够正常执行

不同的程序在被加载进内存时,同一个动态库的虚拟地址并不相同。
动态库开始地址都是未知的,动态库内部的指令和数据都根本不知道绝对地址会是何处。不能使用绝对地址进行定位。

  • 相对地址(Relative Address)

各种指令中使用到的内存地址,给出的不是一个绝对的地址空间,而是一个相对于当前指令偏移量的内存地址。

如果绝对地址是:前进 500 米,左转进入天安门广场,再前进 500 米
相对地址就是:前进 500 米,左转,再前进 500 米


参考资料

虚拟内存,虚拟内存地址,物理内存,物理内存地址
稍微了解地址无关代码(Position-Independent Code)