组件
计算机是一种数据处理设备,它由CPU和内存以及外部设备组成。CPU负责数据处理,内存负责存储,外部设备负责数据的输入和输出,它们之间通过总线连接在一起。CPU内部主要由控制器、运算器和寄存器组成。控制器负责指令的读取和调度,运算器负责指令的运算执行,寄存器负责数据的存储,它们之间通过CPU内的总线连接在一起。每个外部设备(例如:显示器、硬盘、键盘、鼠标、网卡等等)则是由外设控制器、I/O端口、和输入输出硬件组成。外设控制器负责设备的控制和操作,I/O端口负责数据的临时存储,输入输出硬件则负责具体的输入输出,它们间也通过外部设备内的总线连接在一起。
上面的计算机系统结构图中我们可以看出硬件系统的这种组件化的设计思路总是贯彻到各个环节。在这套设计思想(冯.诺依曼体系架构)里面,总是有一部分负责控制、一部分负责执行、一部分则负责存储,它之间进行交互以及接口通信则总是通过总线来完成。这种设计思路一样的可以应用在我们的软件设计体系里面:组件和组件之间通信通过事件的方式来进行解耦处理,而一个组件内部同样也需要明确好各个部分的职责(一部分负责调度控制、一部分负责执行实现、一部分负责数据存储)。
缓存
一个完整的CPU系统里面有控制部件、运算部件还有寄存器部件。
其中寄存器部件的作用就是进行数据的临时存储。既然有内存作为数据存储的场所,那么为什么还要有寄存器呢?答案就是速度和成本。我们知道CPU的运算速度是非常快的,如果把运算的数据都放到内存里面的话那将大大降低整个系统的性能。解决的办法是在CPU内部开辟一小块临时存储区域,并在进行运算时先将数据从内存复制到这一小块临时存储区域中,运算时就在这一小快临时存储区域内进行。我们称这一小块临时存储区域为寄存器。因为寄存器和运算器以及控制器是非常紧密的联系在一起的,它们的频率一致,所以运算时就不会因为数据的来回传输以及各设备之间的频率差异导致系统性能的整体下降。你可能又会问为什么不把整个内存都集成进CPU中去呢?答案其实还是成本问题!
因为CPU速度很快,相应的寄存器也需要存取很快,二者速度上要匹配,所以这些寄存器的制作难度大,选材精,而且是集成到芯片内部,所价格高。而内存的成本则相对低廉,而且从工艺上来说,我们不可能在CPU内部集成大量的存储单元。
运算的问题通过寄存器解决了,但是还存在一个问题:我们知道程序在运行时是要将所有可执行的二进制指令代码都装载到内存里面去,CPU每执行一条指令前都需要从内存中将指令读取到CPU内并执行。如果按这样每次都从内存读取一条指令来依次执行的话,那还是存在着CPU和内存之间的处理瓶颈问题,从而造成整体性能的下降。这个问题怎么解决呢?答案就是高速缓存。
其实在CPU内部不仅有为解决运算问题而设计的寄存器,还集成了一个部分高速缓存存储区域。高度缓存的制造成本要比寄存器低,但是比内存的制造成本高,容量要比寄存器大,但是比内存的容量小很多。虽然没有寄存器和运算器之间的距离那么紧密,但是要比内存到运算器之间的距离要近很多。一般情况下CPU内的高速缓存可能只有几KB或者几十KB那么大。
正是通过高速缓存的引入,当程序在运行时,就可以预先将部分在内存中要执行的指令代码以及数据复制到高速缓存中去,而CPU则不再每次都从内存中读取指令而是直接从高速缓存依次读取指令来执行,从而加快了整体的速度。当然要预读取哪块内存区域的指令和数据到缓存上以及怎么去读取这些工作都交给操作系统去调度完成,这里面的算法和逻辑也非常的复杂,大家可以通过学习操作系统相关的课程去了解,这里就不再展开了。可以看出高速缓存的作用解决了不同速度设备之间的数据传递问题。在实际中CPU内部可能不止设有一级高速缓存,有可能会配备两级到三级的高速缓存,越高级的高速缓存速度越快,容量越低,而越低级的高度缓存则速度越慢,但是容量越大。比如iPhoneX上的搭载的arm处理器A11里面除了固有的37个通用寄存器外,L1级缓存的容量是64KB, L2级缓存的容量达到了8M(这么大的二级缓存,都有可能在你的程序代码少时可以一次性将代码读到缓存中去运行), 没有配备三级缓存。
我们知道在软件设计上有一个所谓的空间换时间的概念,就是当两个对象之间进行交互时因为二者处理速度并不一致时,我们就需要引入缓存来解决读写不一致的问题。比如文件读写或者socket通信时,因为IO设备的处理速度很慢,所以在进行文件读写以及socket通信时总是要将读出或者写入的部分数据先保存到一个缓存中,然后再统一的执行读出和写入操作。
可以看出无论是在硬件层面上还是在软件层面上,当两个组件之间因为速度问题不能进行同步交互时,就可以借助缓存技术来弥补这种不平衡的状况
指令中的寄存器
CPU执行的每条指令都由操作码和操作数组成,简单理解就是要对谁(操作数)做什么(操作码)。在CPU内部要运算的数据总是放在寄存器中,而实际的数据则有可能是放在内存或者是IO端口中。因此我们的程序其实大部分时间就是做了如下三件事情:
- 1.把内存或者I/O端口的数据读取到寄存器中
- 2.将寄存器中的数据进行运算(运算只能在寄存器中进行)
- 3.将寄存器的内容回写到内存或者I/O端口中
这三件事情都是跟寄存器有关,寄存器就是数据存储的中转站,非常的关键,因此在CPU所提供的指令中,如果操作数有两个时至少要有一个是寄存器。
;下面部分是arm64指令示例:
mov x0, #0x100 ;将常数0x100赋值给寄存器x0
mov x1, x0 ;将寄存器x0的值赋值给寄存器x1
ldr x3, [sp, #0x8] ;将栈顶加0x8处的内存值赋值给x3寄存器
add x0, x1, x2 ;x0 = x1 + x2 可以看出运算的指令必须放在寄存器中
sub x0, x1, x2 ;r0 = x1 - x2
str x1, [sp, #0x08] ;将寄存器x1中的值保存到栈顶加0x8处的内存处。
;下面部分是x64指令示例(AT&T汇编):
mov $0x100, %rax ;将常数0x100赋值给寄存器rax
mov %rax, %rbx ;将寄存器rax的值赋值给rbx寄存器
movq 8(%rax), %rbx ;将寄存器rax中的值+8并将所指向内存中的数据赋值给rbx寄存器
寄存器的分类
寄存器是CPU中的数据临时存储单元,不同的CPU体系结构中的寄存器的数量是不一致的比如: arm64体系下的CPU就提供了37个64位的通用的寄存器,而x64体系下的CPU就提供了16个64位的通用寄存器。在说分类之前要说一下寄存器的长度问题。有时候我们看汇编代码时会发现代码中出现了x0, w0(arm64); 或者rax, eax, ax, al(x64)。 它们之间有什么关系吗? 寄存器是存储单元,意味着它具备一定的容量,也就是每个寄存器能保存的最大的数值是多少,也就是寄存器的位数。不同CPU架构下的寄存器的位数有差别,这个跟CPU的字长有关系。一般情况下64位字长的CPU提供的寄存器的容量是64个bit位,而32位字长的CPU提供的寄存器的容量是32个bit位。比如arm64体系下的CPU提供的37个通用寄存器的容量都是8个字节的,所以每个寄存器能保存的数值范围就是(0到2^64次方)。
-
对于x64系的CPU来说,如果寄存器以r开头则表明的是一个64位的寄存器,如果以e开头则表明是一个32位的寄存器,同时系统还提供了16位的寄存器以及8位的寄存器。32位的寄存器是64位寄存器的低32位部分并不是独立存在的,16位寄存器则是32位寄存器的低16位部分并不是独立存在的,8位寄存器则是16位寄存器的低8位部分并不是独立存在的。
-
对于arm64系的CPU来说, 如果寄存器以x开头则表明的是一个64位的寄存器,如果以w开头则表明是一个32位的寄存器,在系统中没有提供16位和8位的寄存器供访问和使用。其中32位的寄存器是64位寄存器的低32位部分并不是独立存在的。
不管寄存器的长度如何,它们有些用来存放将要执行的指令地址,有些用来存储要运算的数据,有些用来存储计算的结果状态,有些用来保存内存的基地址信息,有些用来保存要运算的浮点数。因此CPU中的寄存器可以按照作用进行如下分类:
1.数据地址寄存器
数据地址寄存器通常用来做数据计算的临时存储、做累加、计数、地址保存等功能。定义这些寄存器的作用主要是用于在CPU指令中保存操作数,在CPU中当做一些常规变量来使用。所以我们的代码里面看到的以及用到的最多的寄存器就是这些寄存器:
如果你仔细观察一些汇编代码中的寄存器的使用,其实你会发现一些特点:
- 在x64体系中RAX以及arm64体系中的X0一般都用来保存函数的返回值
- 在函数调用时的参数传递在x64体系中分别保存在RDI,RSI,RDX,RCX,R8,R9...;而在arm64体系中则分别保存在X0,X1,X2,....中。
- arm64体系中的XZR,WZR表示为一个特殊的寄存器,就是用来表示0
- arm64体系中的X8一般用来表示全局变量或者常量的偏移地址。而 X16,X17则有特殊的用途一般用来保存间接调用时的函数地址。
- arm64中的X29寄存器特殊用于保存函数栈的基址寄存器(X29也叫FP),所以一般不能用于其他用途。
2.Intel架构CPU的段寄存器
早期的16位实模式程序中的内存访问都是基于物理地址的,而且还把整个程序拆分为数据段、代码段、栈段、扩展段四个区域,每个内存区段内的地址编码都是相对于这个段的偏移来设置的,因此为了定位和区分这些内存区段,CPU分别设置了CS,DS,SS,ES四个寄存器来保存这些段的基地址。后来随着CPU和操作系统的发展,应用程序不再直接访问物理内存地址了,而是访问由操作系统提供的虚拟内存地址,同时也不再把整个内存空间划分为数据段和代码段了,而是提供一个从0开始的平坦连续的内存空间了,同时将程序所能访问的内存区域和操作系统内核所能访问的内存区域进行了隔离,我们称这样的程序为保护模式下运行的程序。
因此这时候里面的CS,DS,SS,ES寄存器的作用将不再用于保存内存区域的基地址了,同时还增加了FS,GS两个寄存器,这6个寄存器的作用变为了保存操作系统进入用户态还是核心态以及进行用户态和核心态之间进行切换上下文数据的功能了。也就是在保护模式下运行的程序我们将不需要也没有权利去访问这些段寄存器了。如果你想了解更加具体的内容请搜索:全局描述符表与局部描述符表 相关的知识。在arm体系的CPU中则没有专门提供这些所谓的段寄存器:
上图是平坦内存模式和分段内存模式下的应用结构
这里面需要澄清的是我们的程序内存区域虽然从物理上不再划分为代码段、数据段、栈段几个独立的内存空间。但是在平坦内存模式下我们依然保留了代码段、数据段、栈段的划分,每个段的基地址都是从0开始,只是各种类型的数据存放到了不同的内存空间中去了,也就是说程序分段的机制由硬件划分转化为了软件划分了。
3.栈寄存器
栈的概念,在学习数据结构的时候就已经有了解,栈是一块具有后进先出功能的存储区域,在进行操作时我们总是只能将数据压入栈顶,或者将数据从栈顶弹出来。
栈空间和操作
从上面可以看出要维护一个栈区域就必须要提供2个寄存器,一个寄存器用来保存栈的基地址也就是栈的底部,而一个寄存器则用来保存栈的偏移也就是栈的顶部。在一般的系统中,我们都将栈的基地址设置在内存的高位,而将栈顶地址设置在内存的低位。因此每当有进栈操作时则将栈顶地址进行递减,而当有出栈操作时则将栈顶地址递增。栈的这种特性,使得他非常适合于保存函数中定义的局部变量,以及函数内调用函数的情况。
在x64体系的CPU中,提供了一个专门的RBP寄存用来保存栈的基地址, 同时提供一个专门的RSP寄存器来保存栈的栈顶地址;;而arm64体系的CPU中则没有设置专门的栈基址寄存器而是一般用X29寄存器来保存栈的基地址(至少在iOS的64位系统里面是如此的),但是设置一个SP寄存器来保存栈的栈顶地址。
4.浮点和向量寄存器
因为浮点数的存储以及其运算的特殊性,所以CPU中专门提供FPU以及相应的浮点数寄存器来处理浮点数,除了一些浮点数状态和控制寄存器(比如四舍五入的处理方式等)外主要就是一些保存浮点数的寄存器:
现在的CPU除了支持标量运算外,还支持向量运算。向量运算在图形处理相关的领域用得非常的多。为了支持向量计算系统了也提供了众多的向量寄存器,以及SSE和SIMD指令集:
5.状态寄存器。
状态寄存器用来保存指令运行结果的一些信息,比如相加的结果是否溢出、结果是否为0、以及是否是负数等。CPU的某些指令会根据运行的结果来设置状态寄存器的状态位,而某些指令则是根据这些状态寄存器中的值来进行处理。比如一些条件跳转指令或者比较指令等等。我们在高级语言里面的条件判断最终在转化为机器指令时,机器指令就是根据状态寄存器里面的特殊位置来进行跳转的。。在x64体系的CPU中提供了一个64位的RFLAGS寄存器来作为状态寄存器;arm64体系的CPU则提供了一个32位的CPSR寄存器来作为状态寄存器。
状态寄存器的内容由CPU内部进行置位,我们的程序中不能将某个数值赋值给状态寄存器。
6.指令寄存器(程序计数器)
我们知道程序代码是保存在内存中的,那CPU又是如何知道要执行哪一条保存在内存中的指令呢?这就是通过指令寄存器来完成的。因为内存中的指令总是按线性序列保存的,CPU只是按照编制好的程序来执行指令。因此CPU内提供一个指令寄存器来记录CPU下一条将要执行的指令的内存地址,这样每次执行完毕一条指令后,CPU就根据指令寄存器中所记录的地址到内存中去读取指令并执行,同时又将下一条指令的内存地址保存到指令寄存器中,就这样就重复不断的处理来完成整个程序的执行。
但是这里面有两问题:
-
前面不是说CPU内有高速缓存吗?怎么又说每次都去访问内存呢?而且保存还是内存的地址呢。 这是没有问题的,指令寄存器中保存的确实是下一条指令在内存中的地址,但是操作系统除了将部分内存区域中的指令保存到高速缓存外还会建立一个内存地址到高速缓存地址之间的映射关系数据结构。因此即使是指令寄存器中保存的是内存地址,但是在指令真实执行时CPU就会根据指令寄存器中的内存地址以及内部建立的内存和高速缓存的映射关系来转化为指令在高速缓存中的地址来读取指令并执行。当然如果发现指令并不在高速缓存中时,CPU就会触发一个中断并告诉操作系统,操作系统再根据特定的策略从内存中再次读取一块新的内存数据到高速缓存中,并覆盖掉原先保存在高速缓存中的内容,然后CPU再次读取高速缓存中的指令后继续执行。
-
如果说指令寄存器每次都是保存的顺序执行指令的话那么怎么去实现跳转逻辑呢? 答案是跳转指令和函数调用指令的存在。我们的用户态中的代码不能去人为的改变指令寄存器的值,也就是不能对指令寄存器进行赋值,因此默认情况下指令寄存器总是由CPU内部设置为下一条指令的地址,但是跳转指令和函数调用指令例外,这两条指令的主要作用就是用来改变指令寄存器的内容,正是因为跳转功能才使得我们的程序可以不只按顺序去执行而是具有条件执行和循环执行代码的能力。
在x64体系的CPU中提供了一个64位的指令寄存器RIP,而在arm64体系的CPU中则提供了一个64位的PC寄存器。需要再次强调的是指令寄存器保存的是下一条将要执行的指令的内存地址,而不是当前正在执行的指令的内存地址。
这里再看一下arm64体系下的PC和LR寄存器,我们先看下面一张图:
PC寄存器和LR寄存器
从上面的图中我们可以看出PC寄存器和LR寄存器所表示的意义:PC寄存器保存的是下一条将要执行的指令的内存地址,而不是当前正在执行的指令的内存地址。LR寄存器则保存着最后一次函数调用指令的下一条指令的内存地址。那么LR寄存器有什么作用吗?答案就是为了做函数调用栈跟踪,我们的程序在崩溃时能够将函数调用栈打印出来就是借助了LR寄存器来实现的。具体的实现原理我会在后面的文章里面详细介绍。
7.其他寄存器
上面列出的都是我们在编程时会用到的寄存器,其实CPU内部还有很多专门用于控制的寄存器以及用于调试的寄存器,这些寄存器一般都提供给操作系统使用或者用于CPU内部调试使用。这里就不再进行介绍了,感兴趣的同学可以去下载一本x64或者arm手册进行学习和了解。
寄存器的编码
这里面需要澄清的是上述中的寄存器名称只是汇编语言里面对寄存器的一个别称或者有意义的命名,我们知道机器指令是二进制数据,一条机器指令里面无论是操作码还是操作数都是二进制编码的,二进制数据太过晦涩难以理解,所以才有了汇编语言的诞生,汇编语言是一种机器指令的助记语言,他只不过是以人类更容易理解的自然语言的方式来描述一条机器指令而已。所以虽然上面的寄存器看到的是一个个字母,但是在机器语言里面,则是通过给寄存器编号来表示某个寄存器的。还记得在我的介绍指令集的文章里面,你有看到过里面的虚拟CPU里面的寄存器的定义吗:
//定义寄存器编号
typedef enum : int {
Reg0,
Reg1,
Reg2,
Reg3
} RegNum;
上面的枚举你可以看到我们在代码里面用Reg0, Reg1...来表示虚拟的寄存器编号,但是实际的寄存器编号则分别为0,1... 真实中的CPU的寄存器也是如此编号的,我们来看下面一段代码,以及其中的机器指令:
mov x0, #0x0 ;0xD2800000
mov x1, #0x0 ;0xD2800001
mov x2, #0x0 ;0xD2800002
mov指令的二进制结构如下:
上图是arm64中的mov指令的结构
可见上面的二进制机器指令中关于寄存器部分的字段Rd分别从0到2而出现了差异,从而说明了寄存器读写的编码规则。寄存器编码的机制和内存地址编码是同样的原理和机制,CPU访问内存数据时总是要指定内存数据所在的地址,同样CPU访问某个寄存器时一样的要通过寄存器编码来完成,这些东西统统都体现在指令里面。
寄存器的复用
1.线程切换时的寄存器复用
我们的代码并不是只在单线程中执行,而是可能在多个线程中执行。那么这里你就可能会产生一个疑问?既然进程中有多个线程在并行执行,而CPU中的寄存器又只有那么一套,如果不加处理岂不会产生数据错乱的场景?答案是否定的。我们知道线程是一个进程中的执行单元,每个线程的调度执行其实都是通过操作系统来完成。也就是说哪个线程占有CPU执行以及执行多久都是由操作系统控制的。具体的实现是每创建一个线程时都会为这线程创建一个数据结构来保存这个线程的信息,我们称这个数据结构为线程上下文,每个线程的上下文中有一部分数据是用来保存当前所有寄存器的副本。
每当操作系统暂停一个线程时,就会将CPU中的所有寄存器的当前内容都保存到线程上下文数据结构中。而操作系统要让另外一个线程执行时则将要执行的线程的上下文中保存的所有寄存器的内容再写回到CPU中,并将要运行的线程中上次保存暂停的指令也赋值给CPU的指令寄存器,并让新线程再次执行。可以看出操作系统正是通过这种机制保证了即使是多线程运行时也不会导致寄存器的内容发生错乱的问题。因为每当线程切换时操作系统都帮它们将数据处理好了。下面的部分线程上下文结构正是指定了所有寄存器信息的部分:
//这个结构是linux在arm32CPU上的线程上下文结构,代码来自于:http://elixir.free-electrons.com/linux/latest/source/arch/arm/include/asm/thread_info.h
//这里并没有保存所有的寄存器,是因为ABI中定义linux在arm上运行时所使用的寄存器并不是全体寄存器,所以只需要保存规定的寄存器的内容即可。这里并不是所有的CPU所保存的内容都是一致的,保存的内容会根据CPU架构的差异而不同。
//因为iOS的内核并未开源所以无法得到iOS定义的线程上下文结构。
//线程切换时要保存的CPU寄存器,
struct cpu_context_save {
__u32 r4;
__u32 r5;
__u32 r6;
__u32 r7;
__u32 r8;
__u32 r9;
__u32 sl;
__u32 fp;
__u32 sp;
__u32 pc;
__u32 extra[2]; /* Xscale 'acc' register, etc */
};
//线程上下文结构
struct thread_info {
unsigned long flags; /* low level flags */
int preempt_count; /* 0 => preemptable, <0 => bug */
mm_segment_t addr_limit; /* address limit */
struct task_struct *task; /* main task structure */
__u32 cpu; /* cpu */
__u32 cpu_domain; /* cpu domain */
struct cpu_context_save cpu_context; /* cpu context */
__u32 syscall; /* syscall number */
__u8 used_cp[16]; /* thread used copro */
unsigned long tp_value[2]; /* TLS registers */
#ifdef CONFIG_CRUNCH
struct crunch_state crunchstate;
#endif
union fp_state fpstate __attribute__((aligned(8))); /*浮点寄存器*/
union vfp_state vfpstate; /*向量浮点寄存器*/
#ifdef CONFIG_ARM_THUMBEE
unsigned long thumbee_state; /* ThumbEE Handler Base register */
#endif
};
2.函数调用时的寄存器复用
寄存器数据被切换的问题也同样会出现在函数的调用上,举个例子来说:假设我们正在调用foo1函数,在foo1中我们的代码指令会用到x0,x1,x2等寄存器进行数据运算和存储。假设我们在foo1中的某处调用foo2函数,这时候因为foo2函数内部的代码指令也可能会用到x0,x1,x2等寄存器。。那么问题就来了,因为foo2内部的执行会改变x0,x1,x2寄存器的内容,那么当foo2函数返回并再次执行foo1下面的代码时,就有可能x0,x1,x2等寄存器的内容被改动而跟原先的值不一致了,从而导致数据错乱问题的发生。那么这又是如何解决的呢?解决的方法就是由编译器在编译出机器指令时按一定的规则进行编译(这是一种ABI规则,什么是ABI后续我会详细介绍)。 我们知道在高级语言中定义的变量无论是局部还是全局变量或者是堆内存分配的变量都是在内存中存储的。编译为机器指令后,对内存数据进行处理时则总是要将内存中的数据转移到寄存器中进行,然后再将处理的结果写回到内存中去,这种场景会发生在每次进行变量访问的情形中。我们来看如下的高级语言代码:
void foo2()
{
int a = 20;
a = a + 2;
int b = 30;
b = b * 3;
int c = a + b;
}
void foo1()
{
int a = 10;
int b = 20;
int c = 30;
a += 10;
b += 10;
c += 10;
foo2();
c = a + b;
}
虽然我们在foo1和foo2里面都定义了a,b,c三个变量,但是因为这三个变量分别保存在foo1和foo2的不同栈内存区,他们都是局部变量因此两个函数之间的变量是不会受到影响的。但是如果是机器指令则不一样了,因为运算时总是要将内存数据移动到寄存器中去,但是寄存器只有一份。因此解决的方法就是高级语言里面的每一行代码在编译为机器指令时总是先将数据从内存读取到寄存器中,处理完毕后立即写回到内存中去,中间并不将数据进行任何在寄存器上的缓存
从上面的代码对应关系可以看出,每次高级语言的赋值处理总是先读取再计算然后再写回三步,因此当调用foo2函数前,所有寄存器其实都是处于空闲的或者可以被任意修改的状态。而调用完毕函数后要访问变量时又再次从内存读取到寄存器,运算完毕后再立即写回到内存中。正是这种每次访问数据时都从内存读取到寄存器,处理后立即再写会内存的机制就足以保证了即使在函数调用函数时也不会出现数据混乱的问题发生。
上面是对寄存器复用的两种不同的策略:空间换时间和时间换空间。 在软件设计中当存在有某个共享资源被多个系统竞争或者使用时我们就可以考虑采用上面的两种不同方案来解决我们的问题。
网友评论