您当前的位置:首页 > 计算机 > 精彩资源

计算机组成原理之指令和运算(一)

时间:05-17来源:作者:点击数:

从编译到汇编,代码怎么变成机器码?

  • 我们平时编写的代码,到底是怎么变成一条条计算机指令,最后被 CPU 执行的呢?拿一小段真实的 C 语言程序来看看。
// test.c
int main()
{
  int a = 1; 
  int b = 2;
  a = a + b;
}
  • 要让这一段代码在Linux操作系统上面跑起来,我们需要把整个程序翻译成一个汇编语言(ASM,Assembly Language)的程序,这个过程我们一般叫编译(Compile)成汇编代码。
  • 针对汇编代码,我们可以再用汇编器(Assembler)翻译成机器码(Machine Code)。这些机器码由“0”和“1”组成的机器语言表示。这一条条机器码,就是一条条的计算机指令。这样一串串的 16 进制数字,就是我们 CPU 能够真正认识的计算机指令。

在一个 Linux 操作系统上,我们可以简单地使用 gcc 和 objdump 这样两条命令,把对应的汇编代码和机器码都打印出来。

在这里插入图片描述
test.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <main>:
#include<stdio.h>

int main()
{
   0:	55                   	push   rbp
   1:	48 89 e5             	mov    rbp,rsp
   4:	48 83 ec 10          	sub    rsp,0x10
  int a = 1; 
   8:	c7 45 fc 01 00 00 00 	mov    DWORD PTR [rbp-0x4],0x1
  int b = 2;
   f:	c7 45 f8 02 00 00 00 	mov    DWORD PTR [rbp-0x8],0x2
  a = a + b;
  16:	8b 45 f8             	mov    eax,DWORD PTR [rbp-0x8]
  19:	01 45 fc             	add    DWORD PTR [rbp-0x4],eax
  printf("a=%d\n",a);
  1c:	8b 45 fc             	mov    eax,DWORD PTR [rbp-0x4]
  1f:	89 c6                	mov    esi,eax
  21:	bf 00 00 00 00       	mov    edi,0x0
  26:	b8 00 00 00 00       	mov    eax,0x0
  2b:	e8 00 00 00 00       	call   30 <main+0x30>
  
  return 0; 
  30:	b8 00 00 00 00       	mov    eax,0x0
}
  35:	c9                   	leave  
  36:	c3                   	ret   

因为汇编代码其实就是“给程序员看的机器码”,也正因为这样,机器码和汇编代码是一一对应的。

在这里插入图片描述
  • 从高级语言到汇编代码,再到机器码,就是一个日常开发程序,最终变成了 CPU 可以执行的计算机指令的过程。

解析指令和机器码

常见的指令可以分成五大类

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

CPU 是如何执行指令的?

在这里插入图片描述

一个 CPU 里面会有很多种不同功能的寄存器,三种比较特殊的

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

除了这些特殊的寄存器,CPU 里面还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个。通常根据存放的数据内容来给它们取名字,比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。

在这里插入图片描述

从 if…else 来看程序的执行和跳转

#include <time.h>
#include <stdlib.h>
int main()
{
  srand(time(NULL));
  int r = rand() % 2;
  int a = 10;
  if (r == 0)
  {
    a = 1;
  } else {
    a = 2;
  } 
}
在这里插入图片描述

if…else 条件判断语句。对应的汇编代码是这样的:

if (r == 0)
  33:	83 7d fc 00          	cmp    DWORD PTR [rbp-0x4],0x0
   /*
   cmp 指令比较了前后两个操作数的值,这里的 DWORD PTR 代表操作的数据类型是 32 位的整数,而 [rbp-0x4] 则是一个寄存器的地址。所以,
   第一个操作数就是从寄存器里拿到的变量 r 的值。第二个操作数 0x0 就是我们设定的常量 0 的 16 进制表示。cmp 指令的比较结果,会存入到
   条件码寄存器当中去。

   如果比较的结果是 True,也就是 r == 0,就把零标志条件码(对应的条件码是 ZF,Zero Flag)设置为 1。除了零标志之外,Intel 的 CPU
    下还有进位标志(CF,Carry Flag)、符号标志(SF,Sign Flag)以及溢出标志(OF,Overflow Flag),用在不同的判断条件下。
  */
  37:	75 09                	jne    42 <main+0x42>
  /*
    jne 指令,是 jump if not equal 的意思,它会查看对应的零标志位。如果为 0,会跳转到后面跟着的操作数 42 的位置。这个 42,对应这
    里汇编代码的行号,也就是上面设置的 else 条件里的第一条指令。当跳转发生的时候,PC 寄存器就不再是自增变成下一条指令的地址,而是被
    直接设置成这里的 42 这个地址。这个时候,CPU 再把 42 地址里的指令加载到指令寄存器中来执行。
  */
  {
    a = 1;
  39:	c7 45 f8 01 00 00 00 	mov    DWORD PTR [rbp-0x8],0x1
  40:	eb 07                	jmp    49 <main+0x49>
  /*
   if 条件,如果满足的话,在赋值的 mov 指令执行完成之后,有一个 jmp 的无条件跳转指令。跳转的地址就是这一行的地址 49。我们的 main 函
   数没有设定返回值,而 mov eax, 0x0 其实就是给 main 函数生成了一个默认的为 0 的返回值到累加器里面。if 条件里面的内容执行完成之后也
   会跳转到这里,和 else 里的内容结束之后的位置是一样的。
  */
  } else {
    a = 2;
  42:	c7 45 f8 02 00 00 00 	mov    DWORD PTR [rbp-0x8],0x2
  /*
    跳转到执行地址为 42 的指令,实际是一条 mov 指令,第一个操作数和前面的 cmp 指令一样,是另一个 32 位整型的寄存器地址,以及对应的2
    的16进制值 0x2。mov 指令把 2 设置到对应的寄存器里去,相当于一个赋值操作。然后,PC 寄存器里的值继续自增,执行下一条 mov 指令
  */
  }
  return 0;  
  49:	b8 00 00 00 00       	mov    eax,0x0
  /*
   这条 mov 指令的第一个操作数 eax,代表累加寄存器,第二个操作数 0x0 则是 16 进制的 0 的表示。这条指令其实没有实际的作用,它的作用
   是一个占位符。
  */
}
  4e:	c9                   	leave  
  4f:	c3                   	ret    
在这里插入图片描述
  • 条件码寄存器会记录下当前执行指令的条件判断状态,然后通过跳转指令读取对应的条件码,修改 PC 寄存器内的下一条指令的地址,最终实现 if…else 以及 for/while 这样的程序控制流程。
  • 汇编指令的文档:http://www.unixwiz.net/techtips/x86-jumps.html

ELF和静态链接

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

  • 第一个部分由编译(Compile)汇编(Assemble)以及链接(Link)三个阶段组成。在这三个阶段完成之后,就生成了一个可执行文件。
  • 第二部分,我们通过装载器(Loader)把可执行文件装载(Load)到内存中。CPU 从内存中读取指令和数据,来开始真正执行程序。
    在这里插入图片描述
  • ELF 文件格式把各种信息,分成一个一个的 Section 保存起来。ELF 有一个基本的文件头(File Header),用来表示这个文件的基本属性,比如是否是可执行文件,对应的 CPU、操作系统等等。
    在这里插入图片描述
  • 链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里面存储的地址,进行一次修正。最后,把所有的目标文件的对应段进行一次合并,变成了最终的可执行代码。这也是为什么,可执行文件里面的函数调用的地址都是正确的。
    在这里插入图片描述

ELF其实是一种文件格式的标准,ELF文件有三类:可重定向文件、可执行文件、共享目标文件。代码经过预处理、编译、汇编后形成可重定向文件,可重定向文件经过链接后生成可执行文件。

  • 在链接器把程序变成可执行文件之后,要装载器去执行程序就容易多了。装载器不再需要考虑地址跳转的问题,只需要解析ELF 文件,把对应的指令和数据,加载到内存里面供 CPU 执行就可以了。

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

程序装载

  • 通过链接器,把多个文件合并成一个最终可执行文件。在运行这些可执行文件的时候,我们其实是通过一个装载器,解析ELF或者PE格式的可执行文件。装载器会把对应的指令和数据加载到内存里面来,让 CPU 去执行。

装载器需要满足两个要求:

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

把指令里用到的内存地址叫作虚拟内存地址(Virtual Memory Address),实际在内存硬件里面的空间地址叫物理内存地址(Physical Memory Address)。

要满足装载器的两个要求,我门可以在内存里面,找到一段连续的内存空间,然后分配给装载的程序,然后把这段连续的内存空间地址,和整个程序指令里指定的内存地址做一个映射。

内存分段

  • 这种找出一段连续的物理内存和虚拟内存地址进行映射的方法,我们叫分段(Segmentation)。这里的段,就是指系统分配出来的那个连续的内存空间。
    在这里插入图片描述
  • 分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处,第一个就是内存碎片(Memory Fragmentation)的问题。
    在这里插入图片描述
  • 内存碎片也有办法解决。解决的办法叫内存交换(Memory Swapping)。
  • 虚拟内存、分段,再加上内存交换,看起来似乎已经解决了计算机同时装载运行很多个程序的问题。不过,你千万不要大意,这三者的组合仍然会遇到一个性能瓶颈。硬盘的访问速度要比内存慢很多,而每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,如果内存交换的时候,交换的是一个很占内存空间的程序,这样整个机器都会显得卡顿。

内存分页

  • 和分段这样分配一整段连续的空间给到程序相比,分页是把整个物理内存空间切成一段段固定尺寸的大小。而对应的程序所需要占用的虚拟内存空间,也会同样切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。从虚拟内存到物理内存的映射,不再是拿整段连续的内存的物理地址,而是按照一个一个页来的。页的尺寸一般远远小于整个程序的大小。在 Linux 下,我们通常只设置成 4KB。你可以通过命令getconf PAGE_SIZE看看你的 Linux 系统设置的页的大小。
    在这里插入图片描述
  • 分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中,而是只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。当要读取特定的页,却发现数据并没有加载到物理内存里的时候,就会触发一个来自于 CPU 的缺页错误(Page Fault)。我们的操作系统会捕捉到这个错误,然后将对应的页,从存放在硬盘上的虚拟内存里读取出来,加载到物理内存里。
方便获取更多学习、工作、生活信息请关注本站微信公众号城东书院 微信服务号城东书院 微信订阅号
推荐内容
相关内容
栏目更新
栏目热门