深入理解计算机系统-知识点

896 阅读16分钟

计算机系统是由硬件与系统软件组成的,他们共同协作以运行应用程序。计算机内部的信息是以0和1组成的位序列,它们会依据不同的上下文又有不同的解释方式。程序会被其他的程序翻译成不同的形式,开始时是ASII码,然后会被编译器和链接器翻译成二进制可执行文件。

操作系统内核是应用程序与硬件之间的媒介。它提供了三种抽象的概念:

  • 文件是对I/O设备的抽象;
  • 虚拟存储器是对磁盘和主存的抽象;
  • 进程是对处理器,主存和I/O设备的抽象;给人以应用程序独占处理器、I/O设备、主存的错觉,其实是并发执行,本质上执行上下文切换。

源程序

源程序是由程序员创建并且编辑保存的文本文件,本质上对于计算机系统而言是0和1组成的位序列,也叫比特(bit)序列,按照8个位一组组成字节(Byte),每个字节都表示一个源程序中的某个文本字符,每个文本字符最终都以ASCII码的形式表示,即每个字节的十进制数,如,字符a,表示为97;像这样以ASCII码构成的文件,我们称为文本文件,其他文件则称为二进制文件。

程序计数器

称为%eip,表示处理器将要执行的下一条指令在存储器中的地址。

文件

文件本质是字节序列,每个I/O设备,包括磁盘,键盘,显示器,甚至于网络都可以被看成文件。

网路提供了计算机系统之间通信的手段,因此从系统的角度,网络就是一种I/O设备。

程序结构和执行

三种数字编码:

无符号编码:基于传统的二进制表示法,表示大于或者等于零的数字。

二进制补码编码:是用来表示有符号的整数,有符号的整数就是为正或者为负的数字

浮点数编码:表示实数的科学计数法的以2为基数的版本

实数 是有理数与无理数的统称;

  • 有理数 :能表示两个整数之比的数。 整数 ,零,分数(无限循环小数)的统称。
  • 无理数:不能表示两个整数之比的数。比如:无限不循环的小数:π,没有任何整数之比能表示。

虚数 真实不存在的数

复数:形如z=a+bi(a,b均为实数)的数称为复数,其中a称为实部,b称为虚部,i称为虚数单位

信息存储

计算机是以字节为单位访问可寻址的存储器。

机器级程序将存储器视为一个非常大的字节数组,称为虚拟存储器。这个存储器的每个字节都会有一个唯一的数字来标识,我们称为地址。所有这些地址的集合称为虚拟地址空间

编译器和运行时系统的一个任务便是将虚拟存储器的空间划分为更可管理的单元,来存放不同的程序对象,也就是程序数据、指令和控制信息

小技巧:

x = 2^n时,x可以表示为1后n个0的二进制形式;16进制数0的二进制为0000(每个16进制数表示4个二进制位)。假定n=i+4*j,(0 =< i <= 3),则可以将x表示为16进制 2^i(1/2/4/8)后跟j个0的形式。示例:2048 = 2^11, 11= 3 + 4 * 2 即16进制表示为0x800

字长

计算机是以一组二进制序列为单元执行存储,传送或操作的,该单元称为。字的二进制位数称为字长。机器(CPU)的字长决定了虚拟地址空间的大小,字长n位,则虚拟地址空间的大小为:0 ~ 2^n-1,32位的字长,对应的虚拟地址空间大小为4千兆字节(4GB).

指针类型使用的是机器的全字长,即:32位 4字节,64位 8字节。指针表示的是地址,地址是虚拟存储器每个字节的唯一数字标识,n位机器虚拟地址空间的最大地址标识可达 2^n-1 这个数字标识的地址需要被表示,必须用n位来存储。

寻址与字节顺序

多字节对象在存储器中会被存储为连续的字节序列,对象的地址使用的是字节序列中最小的地址。 假如int类型4字节,int x = 4,&x得到的地址为0x100时,则x的4字节将被存在0x100 ~ 0x103连续的地址空间。

字节的排序规则有两种,即大端法和小端法,假如有16进制数:0x01234567,则两种表示法如下:

大端法:

地址...0x1000x1010x1020x103...
...01234567...

小端法:

地址...0x1000x1010x1020x103...
...67452301...

大端法符合阅读习惯

///Char类型更适合表示内存基本单元:字节
typedef unsigned char *CharPointer;
///指针 机器字长64位 故占8个字节,
///指针指向的char类型的对象,sizeof(char)为1字节,即每个CharPointer指针,指向一个字节序列。
///若更换为short类型,sizeof(short)为2字节,即每个CharPointer指针,指向两个个字节序列。
///亲测属实
void show_bytes(CharPointer start, int len) {
    for (int i = 0 ; i < len; i ++) {
      // start[i] 表示以start指向位置为起始的第i处的字节,
        printf("%.2x ",start[i]); //小端序输出
        printf("\n");
    }
}

void show_int(int x) {
    show_bytes((CharPointer)&x, sizeof(int));
}

- (void)test {
     //多字节对象,小端法排序,首地址->对象
    show_int(0x1234567);
     // Char* 输出 67 45 32 01
    // Short* 输出 6745 3201
}

表达式sizeof(T),表示存储一个类型T,所需的字节数。

布尔(位运算)运算 异或^:同为零,异为1。其他还有~(非),|,&

逻辑运算:&&||!,逻辑运算认为所有非零的参数都为TRUE

整数运算与布尔代数的区别

image.png

整数表示

一个字节可表示的无符号的整数值范围:0 ~ 255 ; 有符号的整数值得范围:-127 ~ 127。

有符号数的表示方式为二进制补码,它将最高位解释为符号为,1代表负值,0代表正值。

机器默认使用有符号的值,无符号需要额外标识U。

不同字长的数之间进行转换,同时保持数值不变,有两种规则:

  • 无符号数,采用0扩展规则,即表示的开头添加0

  • 有符号数(二进制补码数),采用符号扩展规则,在表示中添加最高有效位的值。理解:

    -63 二进制补码表示: 11000001,最高位1为符号位,次位1才是最高有效位,符号扩展为10位:11 11000001

    -127二进制补码表示: 10000001,最高位1为符号位,次位0才是最高有效位,符号扩展为10位:00 10000001

苹果采用二进制补码规则,存储和表示有符号数,int 类型的 -63 4个字节输出为c1 ff ff ff

二进制补码的非,即对每个二进制位取反,再加1。理解:

-5 二进制补码表示:1011 取反:0100 值为 4 ,+1操作 0101 值为5。 5为-5的相反数。

原码、反码、补码:

原码简单直观,如-63 其原码为:10111111 最高位为符号位,其余位表示为值得绝对值。

反码,正数的反码是其本身 负数的反码基于原码为符号位不变 其余位取反。如,-63 其反码为:11000000

补码,正数的补码是其本身,负数的补码基于原码为符号位不变其余位取反,最后+1,即反码+1。

补码表示为:11000001 最高位1,符号位: 表示-128 其余代表64 ,1,相加之和为-63

程序的机器级表示

Intel处理器的指令集为Intel 32位体系结构(Intel Architecture 32-bit)简称IA32,这个处理器的系列也称为X86

Intel处理器开始于16位体系结构微处理器,后发展为32位(IA32),如今已经是64位了(X86-64)。

可执行文件或者目标文件是由源程序生成的汇编代码包括的所有机器指令编码后形成的字节序列。机器实际执行的程序只是对一系列指令进行编码的字节序列。机器对产生这些程序的源代码一无所知。反汇编器是根据这些字节序列来确定汇编代码的。

#每行左边冒号后的每个字节序列,对应处理器架构的汇编指令
0000000000000000 _main:
       0: 55                           	pushq	%rbp
       1: 48 89 e5                     	movq	%rsp, %rbp
       4: 48 83 ec 20                  	subq	$32, %rsp
       8: c7 45 fc 00 00 00 00         	movl	$0, -4(%rbp)
       f: 89 7d f8                     	movl	%edi, -8(%rbp)
      12: 48 89 75 f0                  	movq	%rsi, -16(%rbp)
      16: e8 00 00 00 00               	callq	0 <_main+0x1b>
      1b: 48 89 45 e0                  	movq	%rax, -32(%rbp)
      1f: 48 8d 3d 62 00 00 00         	leaq	98(%rip), %rdi
      26: b0 00                        	movb	$0, %al
      28: e8 00 00 00 00               	callq	0 <_main+0x2d>
      2d: 48 8d 3d 74 00 00 00         	leaq	116(%rip), %rdi
      34: 48 8d 35 8d 00 00 00         	leaq	141(%rip), %rsi
      3b: b0 00                        	movb	$0, %al
      3d: e8 00 00 00 00               	callq	0 <_main+0x42>
      42: 48 8b 3d 00 00 00 00         	movq	(%rip), %rdi
      49: e8 00 00 00 00               	callq	0 <_main+0x4e>
      4e: 48 89 45 e8                  	movq	%rax, -24(%rbp)
      52: 48 8b 7d e8                  	movq	-24(%rbp), %rdi
      56: 48 8b 35 00 00 00 00         	movq	(%rip), %rsi
      5d: ff 15 00 00 00 00            	callq	*(%rip)
      63: 48 8b 7d e0                  	movq	-32(%rbp), %rdi
      67: e8 00 00 00 00               	callq	0 <_main+0x6c>
      6c: 31 c0                        	xorl	%eax, %eax
      6e: 48 83 c4 20                  	addq	$32, %rsp
      72: 5d                           	popq	%rbp
      73: c3                           	retq

数据格式

Intel处理器术语

Intel处理器用术语:字(word)表示16位数据类型,因此称32位数为双字(double word), 称64位数为四字(quad word)

下图给出了IA32对应C基本数据类型的机器表示:

IA32处理器

大多数数常用的整数与长整数,不论有无符号都是使用双字进行存储的;此外所有的指针都是双字的;处理字符串时,常用到字节。C语言的最新扩展包括long long数据类型,该数据类型使用8个字节表示。 IA32在硬件中不支持此数据类型。相反,编译器必须生成对这些数据进行操作的指令序列(32位)。

汇编代码后缀

汇编器的每个操作指令都会有一个后缀,表明操作数的大小。比如:

mov操作指令,用于传送数据;该指令在IA32架构中有三种形式:

  1. movb : 传送字节,8位二进制数
  2. movw:传送字,16位二进制数
  3. movl:传送双字,32位二进制数,后缀l,是因为许多机器上32位称为长字long word

另外在x86-64中该指令还有一种形式:movq,表示传送四字,64位二进制数。

疑惑:双精度浮点数和整数都是使用l后缀,但是前者占8字节 ,后者4字节。这其实不冲突,因为浮点数和整数使用的是不同的指令和寄存器。

X86-64对比

image.png

寄存器

寄存器文件,是一个小的存储设备,是由一些字长大小的寄存器组成,这些寄存器都有唯一的名字。

程序计数器(称为%eip):表示处理器(cpu)将要执行的下一条指令在存储器中的位置

整数寄存器文件,这些寄存器可以存储地址(对应于指针)和整数数据。

浮点寄存器文件,这些寄存器可以存储浮点数据。

条件码寄存器,保存最近执行的算术指令的状态信息。主要用来实现控制流中的条件变化。如,ifwhile

IA32寄存器

IA32整数寄存器文件:包含8个被命名的位置,分别存储32位的值;

image.png IA32整数寄存器可以以16位(字)或32位(双字)的形式访问所有八个寄存器。前四个寄存器的2个低位字节可以独立访问。兼容了16位的处理器。

X86-64寄存器

X86-64寄存器的名称以%r开头

64位的整数寄存器文件:包含8个被命名的位置,分别存储64位的值;

image.png

64位的整数寄存器,是现有的8个寄存器扩展为64位版本,并添加了8个新寄存器。每个寄存器可以访问为8位(字节),16位(字),32位(双字)或64位(四字)。实现了向后兼容

操作指示符

大多数指令都有一个或多个操作数,指示出执行一个操作需要引用的源数据,以及放置结果的目的地址。

源数据值可以以常数的形式给出,或是从寄存器或存储器中读取,结果可以放在寄存器或者存储器中

操作数一般有三种类型:

  • 立即数,也就是常数值,表示形式$后跟一个整数,比如$-67,$0xFA
  • 寄存器,表示某个寄存器的内容,对于双字操作来说可以是8个32位整数寄存器中的一个,如:%eax,对于单字节操作来说可以使8个单字节寄存器元素中的一个,如%al
  • 存储器引用,表示根据计算得出的有效地址,访问某个存储器对应位置,获取内容。

IA32操作数的表示格式

下图中使用符号解释:

  • Ea表示任意寄存器aR[Ea]表示寄存器a存放的内容。
  • M_b[Addr]表示对存储在存储器中从地址Addr开始的b字节值得引用。存储器被抽象为字节数组。为了简便b可省略。
  • Imm(Eb,Ei,S),是下图的通用形式,其他为其特殊情况。其中Imm,立即数偏移 ;Eb,基址寄存器;Ei,索引或变址寄存器;S(Scale Factor),伸缩因子,其值必须是1/2/4/8

image.png

寄存器加括号 表示使用寄存器所存地址,从存储器读取数据或者写入数据。只是对存储器的引用

练习:

假设下面的值存放在指明的存储器地址和寄存器中;

地址寄存器
0x1000xFF%eax0x100
0x1040xAB%ecx0x1
0x1080x13%edx0x3
0x10c0x11--

填写下表给出操作数的值:

操作数
%eax (寄存器寻址)0x100
0x104 (绝对寻址)0xAB
$0x108 (立即数寻址)0x108
(%eax) (寄存器间接寻址)0xFF
4(%eax) (基址+偏移量)寻址0xAB
9(%eax,%edx) 变址0x11
260(%ecx,%edx)变址0x13
0xFC(,%ecx,4)0xFF
(%eax,%edx,4)0x11

数据传送指令

image.png

movl指令,用以传送双字,有两个参数源操作数和目的操作数。源操作数指定一个值,可以是立即数,可以存放在寄存器,也可以存放在存储器中;目的操作数指定一个位置,可以是寄存器,也可以是存储器地址。

栈在处理我们程序调用的过程中起着至关重要的作用;程序的栈存放在存储器中的某个区域。pushlpopl是用来将源数据压入栈中和从栈中弹出的。在IA32中栈向低地址方向增长,越压栈顶的地址越低。这种情况下栈顶元素的地址便是栈中最低的。栈顶的元素为最新。

压栈操作:

#IA32中将%ebp的内容压栈
pushl %ebp
#此操作等价于下列操作
subl $4, %esp # 栈指针 - 4(字节)注:每个地址对应一个字节,栈指针-4,即移动4个字节 -申请空间
movl %ebp, (%esp) #传送%ebp所存地址,到存储器上,地址为%esp所存地址,- 存储数据

出栈操作:

#IA32将%ebp的内容从栈中弹出
popl %ebp
#此操作等价于下列操作
movl (%esp), %ebp #读取存储器在%esp所存地址处的内容,传送至寄存器%ebp中 - 赋值
addl $4, %esp # 最后,栈指针 + 4(字节)- 释放空间

出栈与入栈操作,核心表达式,可以帮助我们加深理解: pushl S :

  1. R[%esp] <— R[%esp] - 4

  2. M[R[%esp]] <- S

popl D

  1. D <- M[R[%esp]]
  2. R[%esp] <— R[%esp] + 4

加载有效地址

加载有效地址(全称:Load Effective Address)的指令为lea加上汇编指令后缀就会有lealleaq

指令lealmovl的变形,它的指令形式是从存储器读取数据到寄存器,但实际上它并未引用存储器。它是源操作数,看上去是一个存储器的引用,但该指令并不是从指定的位置读入数据,而是将有效的地址写入目的操作数(如寄存器)。

注意:leal的目的操作数必须是寄存器。

假设寄存器%edx的值为x。汇编leal 7(%edx,edx,4), %eax意为:将有效地址x+4x+7写入寄存器%eax中。

整数算术操作

image.png

跳转指令

orl %eax,%eax #Set %eax to 0
jmp .L1 #跳转 .L1
movl (%eax),%edx #空指针取消引用
.L1:
popl %edx

image.png

重点:

汇编器与链接器对跳转指令的编码的常用方式:跳转目标的实际地址与紧跟跳转指令后的下一条指令的地址求差,差值作为编码,指定为跳转指令的操作数。

10:  7e 01   汇编代码:jle 12 #12为实际跳转地址
11:  89 d0   汇编代码:mov %edx , %eax 
12:  29 c2   汇编代码:sub %eax , %edx

上述跳转真实地址计算:0x01+0x11 = 0x12,故 jle 12

过程

一个过程调用包括了将数据(主要为过程参数和返回值)和控制从程序的一部分传递到另一部分;除此之外还必须在进入时,为过程的局部变量分配空间,并在退出时释放这些空间。

大多数的机器,包括IA32只提供了转移控制到过程和从过程中转移出控制的指令。关于数据传递、局部变量的分配与释放是通过操作程序栈来实现的。总结一下:控制传递是由指令控制的,局部变量与数据传递是操作程序栈完成的

栈帧结构

IA32使用程序栈来支持过程调用。栈用来传递过程参数,存储返回信息,保存寄存器以供后续恢复使用,以及用于本地存储。

栈帧: 为单个过程分配的那部分栈称为栈帧。栈帧的最顶端是以两个指定寄存器定界的,帧指针寄存器%ebp和栈指针寄存器%esp

程序运行时,栈指针会随着入栈与出栈而移动,因此大多数的信息访问都是相对于帧指针的。

假设过程P(调用者)调用过程Q(被调用者),过程Q的参数存放在P的栈帧中,此外,当P调用Q时,P中的返回地址(返回地址是程序从Q返回时在P中继续执行的地址)被压入栈中,从而形成P的栈帧的末尾。Q栈帧是从保存的帧指针的值(寄存器的%ebp的副本)开始,后面是保存其他寄存器的值。

过程Q也用栈来保存其他不能存放在寄存器中的变量。这样做是因为:

  • 寄存器并不能存放所有的局部变量。
  • 有些局部变量是数组或者结构,因此必须通过数组或结构的引用来访问。
  • 要对一个局部变量使用地址操作符&时, 则必须使用栈为其产生一个地址。

栈是向低地址方向增长的,而栈指针寄存器%esp指向栈顶元素的地址,可以通过传送指令pushlpopl将数据存入栈中和从栈中取出。基于此我们也可以将栈指针的值减少适当的值来为没有指定初始值的数据分配空间,或者增加栈指针来释放空间。

转移控制指令

指令描述
call Label过程调用(直接调用)
call *Operand过程调用(间接调用)
leave为返回准备栈
ret从过程调用中返回

call指令有一个目标参数,指明被调用过程的起始位置。该指令有两个作用:

  1. 将返回地址压入栈中。
  2. 跳转被调用过程的起始位置。

返回地址,是紧随汇编程序call指令之后的指令的地址。当被调用过程返回时,执行将在此位置恢复。

ret指令从栈中弹出一个地址,并跳转到调用过程的返回地址处。

接下来通过一个示例来加深对callret的理解。

假设有两个过程,调用过程为main,被调用过程为求和函数sum

汇编代码简化后输出:

#`sum`函数的开始
08048394 <sum>:
 8048394:  55             push %ebp
...
#`sum`函数的返回
 80483a4: c3              ret
...
# `main`函数调用`sum`函数
 80483dc:  e8 b3 ff ff ff      call   8048394 <sum>        
 80483e1:  83 c4 14            add    $0x14,%esp

callret指令调用图示:

image.png

%eip程序计数器,表示程序将要执行的下一条指令在存储器中地址。

  • 图中(a)部分,%eip寄存器存储地址为0x080483dc,对应上述汇编代码的call指令,故(a)为程序将要执行call指令。

  • 图中(b)部分,%eip寄存器存储地址为0x08048394,它取自call 8048394 <sum> ,该地址对应上述汇编代码sum函数的起始位置,并且将地址0x080483e1压入栈中,故(b)为程序执行了call指令。其中0x080483e1main过程的返回地址,查看代码发现,此地址处于程序call后,为代码继续执行的地方。

  • 图中(c)部分,%eip寄存器存储地址为0x080483e1,该地址为main函数调用完call继续执行的地方,表示sum过程调用完毕,要跳转到代码继续执行的地方,并且还将0x080483e1从栈中弹出。故(c)为sum函数调用了ret之后。

总结: call指令传递控制到一个函数的开始,ret指令返回到紧随call后面的地址。

  • 要正确使用ret指令,就要使栈准备好,栈指针要指向前面call指令存储返回地址的位置(call将返回地址压入栈的地方),而leave指令就是用来使栈做好返回准备的,等价于下列代码:
# 将栈指针指向栈帧(函数)开始的位置
# 将寄存器`%ebp`中地址写到`%esp`
movl %ebp %esp 

#弹出分两步
# 1. `movl (%esp) %ebp`; 将(%esp)对应存储器地址写入到`%ebp`
# 入栈对应为`movl %ebp, (%esp)`
# 2. `addl $4 %esp`释放被调用过程的栈帧对应的栈空间,
# 故以下为恢复寄存器`%ebp`的值,并且设置栈指针`%esp`为调用者栈帧的末尾(释放空间至调用者栈帧末尾)
popl %ebp 

另外,这种准备工作也可以通过直接使用传送和弹出操作来完成。寄存器%eax可以用来返回整数或者指针。

注意:有些时候不需要使用movl %ebp %esp的操作,主要是因为并未对%esp进行改变,故直接popl即可。因为每个过程开始的时候都需要执行

pushl %ebp 保存前一个栈帧的帧指针
movl %esp %ebp 设置当前栈帧的帧指针

保存前一个栈帧的%ebp,入栈操作有两步,首先是申请栈空间,接着将%ebp中的存储地址写入这块区域,后续若是没有基于%esp申请空间的操作,如:subl $4 %esp则不需要movl %ebp %esp,因为%ebp存储的就是%esp中的地址。

寄存器使用惯例

程序的寄存器组是唯一一个被所有过程共享的资源。虽然同一时刻,只能有一个过程处于活动状态,但是我们必须保证一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖某个调用者稍后会使用的寄存器的值。为此IA32采用了一组统一的寄存器使用惯例,所有过程都必须遵守,包括程序库中的过程。

根据惯例寄存器组被划分为:

  • 调用者保存寄存器: %eax%edx%ecx

  • 被调用者保存寄存器:%ebx%edi%esi

当过程P调用过程Q时,Q可以覆盖调用者保存寄存器,而不会破坏过程P需要的数据,这意味着Q必须在覆盖之前,将这些寄存器的值保存到栈上,并在返回之前恢复它们。同理被调用者保存寄存器的覆盖也是一样。

场景释义:

int P(int x) {
   int y = x * x;
   int z = Q(y);
   return y + z;
}

过程P在调用Q之前,必须要保证y的值在Q返回后仍然有效,有两种方式:

  1. 在调用Q之前,将y的值,存放在自己的栈帧中,在过程Q返回后,从栈中取出y的值。

  2. y的值保存在被调用者寄存器中,一种情况Q或其他Q调用的程序(被调用者)想使用这个寄存器,它必须将寄存器的值,保存在栈帧中,并在返回前恢复该值;另一种情况Q或其他Q调用的程序(被调用者)没有使用这个寄存器,可以不需要额外操作;最后从Q返回P时,y的值都会在被调用者寄存器中。

GCC使用后第二种方式,因为它能尽量减少读写栈的次数。

递归过程

上述的栈与寄存器的使用惯例,使得过程能够递归调用的他们自身,称为递归过程。因为每个调用,在栈中都有自己的私有空间,多个未完成过程的局部变量,不会相互影响。过程调用时,分配局部存储,返回时,释放过程存储。要是无限递归可能会导致栈溢出,因为栈的空间是基于地址的也是有限的。

int P(int n) {
   if (n <= 1) {
      return 1
   }
   return P(n-1) + p(n-2)
}
  1. 过程P初始化栈帧:保存%ebp(入栈), 设置新的%ebp,保存需要覆盖的寄存器的值(入栈),以便返回时恢复(出栈)。
  2. 执行过程调用,继续调用P自己,故继续执行第一步操作,递归次数决定了入栈操作,直到有返回值时,出栈。

存储器的层次结构

存储器系统,是一个具有不同容量,成本和访问时间的存储设备的层次结构。如下图所示,层次结构中每一层都缓存来自较低一层的数据对象。访问速度自上而下变慢。

image.png RAM:随机访问存储器(random-access memory)分为两类静态和动态,分别为SRAMDRAMSRAMDRAM更快,但造价贵的多,SRAM用来作为高速缓存存储器,DRAM用来作为主存和图形系统的帧缓存区。

参考

www.cs.albany.edu/~sdc/CSI500…