CPU扫盲
目录指令集
指令集概念
指令集顾名思义就是一套指令的集合。
在CPU的世界里只有0和1,指令就是通过特定顺序、特定数量的0和1的来定义相加、赋值等行为。
指令集则是一系列行为的集合,通过他们就可以指挥CPU为各种事物编码。
指令集是一个CPU的基石,要实现CPU 计算和控制功能,就必须定义好一系列与硬件电路相匹配的指令系统.指令就是我们交代CPU 要执行的操作,指令集就可以简单理解为指令的集合。我们把cpu 能够识别的指令汇总在一起就构成了一个指令集。不同的CPU 有不同的指令集,根据他们的繁简程度可以分为两种:复杂指令集CISC 和精简指令集 RISC
CPU与指令集中提到创造指令集并不难,难的是如何将指令集推广,那这篇文章我们就从创作者的角度去理解它,我们尝试创造一个只包含几个指令的指令集。当然这篇文章的目的不是摒弃全部现有指令集概念完全闭门造车,而是站在现有指令集概念的肩膀上从创造者的角度去更深入的理解它。
目前界内的指令集分为两个派系CISC (Complex Instruction Set Computer / 复杂指令集计算机)和RISC (Reduced Instruction Set Computer / 精简指令集计算机),CISC和RISC 并不是具体的指令集,而是两种不同的指令体系,相当于指令集中的门派,是指令的设计思想。举个例子,就像中医与西医,中医讲究从整体上调理身体,西医则更多的是解决器质性病变。这就是两种不同的医疗思路,类似于 CISC 和 RISC 这两种指令体系。那什么是指令集呢?拿中医举例,像华伦、张仲景这两位医圣,他们虽然都是基于中医的思想治病,但医术各有特色,水平也不尽相同,这就相当于不同的指令集。
CISC系列指令集的出现远早于RISC,那时候设计指令集就是摸着石头过河,考虑比较局限,当时的程序员还都在使用汇编语言编写代码,总期望着一个指令可以多干一些事情,把工作转移给硬件电路,这样程序员爽歪歪,这样做的结果就是指令越来越复杂,长短不一,参数繁多。CPU硬件电路的制造工艺虽然不断进步但其电路的设计始终被CISC的指令集限制,最终成为CPU性难以能提升、尺寸难以缩小的瓶颈。此时诞生了RISC。其实最开始并没有CISC这个名字,只是后面出现了RISC为了作区分就将RISC之前的指令集统称CISC。CISC中的指令集就是大杂烩,长短不一、使用频率不一、没有规则限定,而RISC相当于对CISC的一次重构,借鉴了 CISC 的经验,取其精华,弃其糟柏。在RISC中采用定长指令,大大提升译码效率;将复杂指令拆分成多个简单指令,减少了硬件电路的复杂性,给予CPU微架构设计更多的发挥空间(苹果的M1);限制每个指令最多一个内存寻址操作数,推崇寄存器到寄存器的操作,保证每个指令都能在单个时钟周期内完成... RISC旨在提高每个指令的执行时间,以此来提升CPU工作流水线整体性能。
无论是CISC还是RISC都是采用操作码+操作数的设计思路,指令集中的操作数可以是寄存器、立即数、内存地址三种,也对应了CPU寻址的三大类:
- 寄存器寻址:寄存器寻址就是操作数是某一个寄存器,CPU执行指令时需要从寄存器中获取或写入”数据“。如:mov ax, bx,将bx寄存器中的值写入ax寄存器。
- 内存寻址:内存寻址就是操作数是一个内存地址,CPU执行指令时需要从内存中获取或写入”数据“。内存寻址又分为直接寻址、基址寻址、变址寻址、基址变址寻址。我们这里只用最容易理解直接寻址,就是将直接在操作数中给出的数字作为内存地址,告诉 CPU 取此地址中的”数据“作为操作数。如:mov ax, [0x3000],将0x3000 地址中的”数据“写入ax寄存器。
- 立即数寻址:立即数寻址就是操作数是一个常数。只所以叫立即数就是凸显这个”数据“CPU拿来立即可以使用,在执行指令时无需去内存或寄存器中寻址。如:mov ax, 0x18,将数据 0x18写入ax寄存器。
接下来我们尝试来设计一个指令集,我们这里为了凸显寻址方式的不同多加个一个”寻址方式“字段,我们的指令集规则如下:
11代表操作数为内存地址,10代表操作数为寄存器,00代表操作数为立即数。如:11 00 10 代表操作数1是内存地址,操作数2是立即数,操作数2是寄存器。
现在开始设计指令完成一个内存变量和立即数相加后并赋值给内存变量的操作:C=A+B,A是内存变量值8内存地址是0x3000,B是立即数6,C的内存地址是0x3008。可以用寄存器有Ra、Rb、Rc。
- 指令一:从内存获取数据存入寄存器,假设指令为 load
- 指令二:将两个操作数相加并存入寄存器,假设指令为 add
- 指令三:将寄存器中数据写入内存,假设指令为 store
像 load 0x002c Ra 0x3000
这就是我这个指令集所对应的汇编语言,但操作码以及寄存器名称都是随便取的,因为汇编语言只是给人看的助记符,CPU并不关心这个,CPU只关心二进制的机器码。上图中寻址方式以及立即数和内存地址使用的是16进制,最终转换成2进制供CPU执行。操作码和寄存器的二进制机器指令对应关系如下:
至此我们操作数和操作码都有了,其实指令集己经完成了。
不过在一长串的二进制01中,哪些是操作码,哪些是操作数呢?
这就是指令格式的由来。我们人为规定个格式,规定操作码和操作数的大小及位置,然后CPU 硬件电路中写死这些规则,让 CPU 在硬件一级上识别这些格式,从而能识别出操作码和操作数。列如:操作码定长2bit、寻址方式定长6bit、操作数是寄存器时占2bit,是内存地址或立即数时占16bit(虽然操作数不定长,但当译码器分析出操作码后就能确定这个指令存在几个参数,分析完寻址方式就能确定每个操作数的长度)。
按上面的指令格式我们需要执行的指令如下:
- 步骤一:机器指令是
00 101100 00 0011000000000000
,CPU识别出指令的前两位是00就知道是load指令,需要两个参数,然后再往后识别4位为10 11 CPU就能知道操作数1
是寄存器寻址,操作数2
是内存寻址,只有两个参数所以跳过寻址方式的后两位,整个译码过程都是写死在硬件中的规则,不同的指令有不同的规则。这时CPU已经知道该如何执行这条指令了,首先去0x3000地址获取”数据“,然后将数据存入Ra寄存器。 - 步骤二:机器指令是
01 100010 00 0000000000000110 01
,CPU识别出指令的前两位是01就知道是add指令,需要三个参数,然后再往后识别6位为 10 00 10 CPU就知道三个操作数分别是 寄存器、立即数、寄存器。然后CPU执行指令将立即数6与寄存器Ra中的值相加并存入寄存器Rc。 - 步骤三:机器指令是
10 11000 0011000000001000 01
,CPU识别出指令前两位是10就知道是store指令,需要两个参数,然后再往后识别4位为11 10 CPU就知道操作数1
是内存地址寻址,操作数2
是寄存器寻址,只有两个参数所以跳过寻址方式的后两位。然后CPU执行指令将Rc中的值写入内存0x3008。
至此一个非常简易的指令集模型已经创造完毕,相信大家对指令集的理解更深刻了一些。假设上文自创的指令集不仅仅包含着三个指令,它的指令已经很完善了,并且已经可以量产使用此指令集的CPU了,这就代表自研指令集成功了嘛?
答案是否定的,这仅仅是开始而已,相信大家能发现,因为不管是 操作码+寻址方式+操作数的指令格式,还是load、add、store这些汇编指令都是我随便设计的,也就是说这个CPU只有我懂怎么用,现有的软件在此CPU上都无法运行。相同的代码在不同指令集的编译器下,最终呈现的编译结果是不同的,虽然都是由01组成的二进制数字,但是长短和顺序是不同的,这就代表着不同指令集的CPU直接是语言不通,一个计算机换了不同指令集的CPU后原先的软件就不能用了,所以需要有人帮我去写各种编译器和解释器,将高级语言按照我的指令集规则进行编译,我的CPU才能正常工作,显然没人愿意。这些工作往往只能由指令集的厂商进行牵头推动,我显然没这么大的脸。通过这个自研的过程回过头来再看一下CPU与指令集中提到的“创造指令集并不难,难的是如何将指令集推广”这句话是否很有道理。指令集背后不仅是个计算机生态链,更重要的是全球经济链。
指令集架构
指令架构(Instruction Set Architecture, 缩写为ISA),是软件和硬件的接口,不同的应用需求,会有不同的指令架构。要设计一款CPU 指令体系就是设计的出发点。
CPU和指令集的关系
指令集架构就像是特定的CPU的设计图纸,它规定了这个CPU需要支持那些指令、寄存器有那些状态以及输入输出模型。根据指令集结构的设计,在CPU上通过硬件电路进行实现,就得到了支持该指令集的CPU。指令集就像是我们编程语言中的接口,只定义规范和标准,不做具体的实现,同一个指令集架构可以有多种不同的实现方式,但只要是基于同一指令集架构的应用程序可以相互移植。所以我们上层应用只需要关注CPU的指令集架构即可,具体的CPU实现由厂商去关注。
很多非开发的同学指令集名称与CPU名称分不清。这是因为一种 CPU 只能识别一种指令集,所以很多情况下 CPU 都以其支持的指令集名称来称呼,比如当我们要下载软件时通常关注的就是指令集的名称x86、ARM等,但是我们在买电脑时厂商备注的却是酷睿12代、龙芯3C5000、飞腾2000等,所以导致有的同学会把龙芯、飞腾、酷睿与x86、ARM搞混。这里的龙芯、飞腾、酷睿指的是CPU的名称,名字可以有CPU的厂商来取。而x86、ARM则是指令集的名称,基本都是由国外最早的几家处理器厂商创造并命名的如:x86是美国Intel公司、ARM是英国的ARM公司。
CPU本身只是在块硅晶片上所集成的超大规模的集成电路,集成的晶体管数量可达到上亿个,是由非常先进复杂的制造工艺制造出来的,所以CPU的性能不仅仅受指令集的影响,相同的指令集下好的CPU厂商和差的CPU厂商制造出来的CPU性能也是天差地别的,如:苹果自研的m1芯片,使用的就是ARM指令集,但其性能吊打很多使用ARM指令集的CPU厂商(这个不得不承认)。
相信大家也知道国内有很多优秀CPU厂商如:龙芯、飞腾、鲲鹏、海光、申威、兆芯等,目前只有老大哥龙芯自研了LoongArch指令集。为何其他厂商都选择走捷径采用的国外的指令集呢?指令集的创造真的那么难吗?虽然创造一套指令集并不容易,但也没有到望而却步的程度,真正难的是指令集背后的生态和推广。就像我自己可以发明一门语言,语言本身没什么问题,问题是我用自己发明的语言和别人交流,谁听得懂呢,谁又愿意去学这门语言呢?大家都很忙,不通用的东西没人愿意花精力去学。同一段机器指令010001110(随便写的),在A指令集下这组机器语言可能表示赋值,在B指令集下,这组机器语言可能表示循环,所以相同一段源代码,在不同指令集的编译器下,最终呈现的编译结果是不同的,虽然都是由01组成的二进制数字,但是长短和顺序是不同的,所以每个指令集都要有对应的编译器、汇编器、解释器(后文暂且统称编译器),编译后的汇编语言和机器语言是不能跨指令集使用的。试想如果要自立门户创造新的指令集,那有谁愿意给它写编译器呢?这可不是一个小工程,他要跟着指令集的迭代而迭代,也要自己不断的优化迭代,需要大量的人力成本和时间成本。即使自己完成了编译器,要知道每种指令集都有他自己的新特性以及一些特殊指令,不然也没必要重复造轮子,如果一些软件使用了ARM或者x86的特殊指令(一些内嵌汇编语言的软件,如:jvm的模版解释器就使用了汇编语言),那这些软件就要自己修改源代码来适配新的指令集,软件的开发程序员自然骂娘。所以自研指令集并不难,难的是如何推广,让大家用起来。这里要再吹一下老大哥龙芯,龙芯发布LoongArch指令集的同时,完成了针对GCC、LLVM、GoLang三大编译器的开发,完成了针对Java、JavaScript、.NET三大虚拟机的开发,使操作系统厂商和应用伙伴,可基于龙芯的软件环境,更方便地开发应用。龙芯为了能够更好的适应市场,也为了避免软件程序员骂娘,投入巨大成本完成ARM和x86指令集的二进制翻译工作,使运用了ARM和x86特性的软件也不用修改源码即可适配,但指令集翻译本身只是过渡的手段,为了增加用户群体,我相信随着龙芯的独立软件生态逐渐强大,指令集翻译就会越来越边缘化,成为锦上添花之举。龙芯的自研指令集也为全面国产化奠定了牢固的基础,在此也希望国内更多软件厂商能给与龙芯支持,待到中华腾飞日,且让世界听龙吟。
至此CPU和指令集的关系已经讲解完毕,我们在下载软件时除了要选择指令集之外,还要选择32位还是64位如: * * * .x86_32、 * * *.x86_64,我们已经知道了x86代表的是指令集,那32位和64位是指什么呢?
这里32 位和 64 位中的“位”就是bit,也叫字长,是指的是cpu一次能处理的数据的长度(也就是寄存器的位数)。这里有一个误区很多人一直认为32位和64位代表的是CPU地址总线引脚的数量,因为谈起32位和64位CPU的不同第一个想到的就是寻址空间不同,32位CPU最大可以寻址4GB的内存地址,64位CPU寻址范围可以远超4GB,而地址总线的数量对应着CPU的寻址能力(由于32位CPU地址总线引脚是32条,每根地址总线的状态只能是高电平或低电平,也就是只能是1或0。 所以32位地址总线一共有232种状态,每种状态代表一个内存地址,每个内存地址为1字节,所以232种状态一共可以表示4GB的内存地址),所以这个误区坑骗了很多人。在Intel x86指令集架构下的第一款CPU 80x86中,CPU是16位但地址引线却是20条,通过将段基址寄存器中段基地址左移4位再加段内偏移地址的方式实现了20位地址寻址,所以说32和64指的并不是地址总线的数量。至于为什么现在CPU不管是32位的还是64位其地址总线引脚数量都等于其位数,是因为现在不管是资源还是制造工艺都远胜当年,没必要因为几根地址总线再去做段基地址左移这种设计上的妥协,不然每次寻址前都要先做一次乘法对CPU的性能还是影响很大的。
CPU如何执行指令以及流水线技术
在CPU扫盲-CPU与指令集中阐述了CPU与指令集之间的关系,并在CPU扫盲-自研指令集中以创造者的身份深入讲解了指令集,这篇文章则是针对CPU的专场,以x86架构下的CPU为例具体分析一下CPU如何执行指令。
指令系统组成
地址空间
处理器可访问的地址空间包括寄存器空间和系统内存空间。寄存器空间包括通用寄存器、专用寄存器和控制寄存器。寄存器空间通过编码于指令中的寄存器号寻址,系统内存空间通过访存指令中的访存地址寻址。
- 通用寄存器是处理器中最常用的存储单元,包括浮点和整数寄存器,一个处理器周期可以同时读取多条指令需要的多个寄存器值(对于多发射处理器而言)。
- 除了通用寄存器外,有的指令系统还会定义一些专用寄存器,仅用于某些专用指令或专用功能。如MIPS指令系统中定义的HI、LO寄存器就仅用于存放乘除法指令的运算结果。
- 控制寄存器用于控制指令(如特权指令)执行的环境,比如是核心态还是用户态。其数量、功能和访问方式依据指令系统的定义各不相同。
- 广义的系统内存空间包括IO空间和内存空间,不同指令集对系统内存空间的定义各不相同。X86指令集包含独立的IO空间和内存空间,对这两部分空间的访问需要使用不同的指令:内存空间使用一般的访存指令,IO空间使用专门的in/out指令。而MIPS、ARM、LoongArch等RISC指令集则通常不区分IO空间和内存空间,把它们都映射到同一个系统内存空间进行访问,使用相同的load/store指令。处理器对IO空间的访问不能经过Cache,因此在使用相同的load/store指令既访问IO空间又访问内存空间的情况下,就需要定义load/store指令访问地址的存储访问类型,用来决定该访问能否经过Cache。如果采用页式地址映射方式,那么同一页内的地址定义为相同的存储访问类型,通常作为该页的一个属性信息记录在页表项中,如MIPS指令集中的页表项含有CCA域,LoongArch指令集中的页表项含有MAT域。如果采用段式地址映射方式,那么同一段内的地址定义为相同的存储访问类型。
根据指令使用数据的方式,指令系统可分为堆栈型、累加器型和寄存器型。寄存器型又可以进一步分为寄存器-寄存器型(Register-Register)和寄存器-存储器型(Register-Memory)。下面分别介绍各类型的特点:
-
堆栈型。堆栈型指令又称零地址指令,其操作数都在栈顶,在运算指令中不需要指定操作数,默认对栈顶数据进行运算并将结果压回栈顶。
-
累加器型。累加器型指令又称单地址指令,包含一个隐含操作数——累加器,另一个操作数在指令中指定,结果写回累加器中。
-
寄存器-存储器型。在这种类型的指令系统中,每个操作数都由指令显式指定,操作数为寄存器和内存单元。
-
寄存器-寄存器型。在这种类型的指令系统中,每个操作数也由指令显式指定,但除了访存指令外的其他指令的操作数都只能是寄存器。
下图给出了四种类型的指令系统中执行C=A+B的指令序列,其中A、B、C为不同的内存地址,R1、R2等为通用寄存器。
寄存器-寄存器型指令系统中的运算指令的操作数只能来自寄存器,不能来自存储器,所有的访存都必须显式通过load和store指令来完成,所以寄存器-寄存器型又被称为load-store型。
操作数
计算机中常见的数据类型包括整数、实数、字符,数据长度包括1字节、2字节、4字节和8字节。下图是不同指令集整数类型的名称和数据长度:
在执行访存指令时,必须考虑的问题是访存地址是否对齐和指令系统是否支持不对齐访问。所谓对齐访问是指对该数据的访问起始地址是其数据长度的整数倍,例如访问一个4字节数,其访存地址的低两位都应为0。对齐访问的硬件实现较为简单,若支持不对齐访问,硬件需要完成数据的拆分和拼合。但若只支持对齐访问,又会使指令系统丧失一些灵活性,例如串操作经常需要进行不对齐访问,只支持对齐访问会让串操作的软件实现变得较为复杂。以X86为代表的CISC指令集通常支持不对齐访问,RISC类指令集在早期只支持对齐访问,现在也开始支持不对齐访问。
不同的机器可能使用大尾端或小尾端,最高有效字节的地址较小的是大尾端,最低有效字节的地址较小的是小尾端。Motorola的68000系列和IBM的System系列指令系统采用大尾端,X86、VAX和LoongArch等指令系统采用小尾端,ARM、SPARC和MIPS等指令系统同时支持大小尾端。
寻址方式指如何在指令中表示要访问的内存地址。下表列出了计算机中常用的寻址方式,其中数组mem表示存储器,数组regs表示寄存器,mem[regs[Rn]]表示由寄存器Rn的值作为存储器地址所访问的存储器值:
指令操作和编码
现代指令系统中,指令的功能由指令的操作码决定,可分为四大类:
- 第一类为运算指令,包括加减乘除、移位、逻辑运算等;
- 第二类为访存指令,负责对存储器的读写;
- 第三类是转移指令,用于控制程序的流向;
- 第四类是特殊指令,用于操作系统的特定用途。
转移指令包括条件转移、无条件转移、过程调用和过程返回等类型。转移条件和转移目标地址是转移指令的两个要素,两者的组合构成了不同的转移指令:条件转移要判断条件再决定是否转移,无条件转移则无须判断条件;相对转移是程序计数器(PC)加上一个偏移量作为转移目标地址,绝对转移则直接给出转移目标地址;直接转移的转移目标地址可直接由指令得到,间接转移的转移目标地址则需要由寄存器的内容得到。程序中的switch语句、函数指针、虚函数调用和过程返回都属于间接转移。由于取指译码时不知道目标地址,因此硬件结构设计时处理间接跳转比较麻烦。
转移指令有几个特点:第一,条件转移在转移指令中最常用;第二,条件转移通常只在转移指令附近进行跳转,偏移量一般不超过16位;第三,转移条件判定比较简单,通常只是两个数的比较。条件转移指令的条件判断通常有两种实现方式:采用专用标志位和直接比较寄存器。采用专用标志位方式的,通过比较指令或其他运算指令将条件判断结果写入专用标志寄存器中,条件转移指令仅根据专用标志寄存器中的判断结果决定是否跳转。采用直接比较寄存器方式的,条件转移指令直接对来自寄存器的数值进行比较,并根据比较结果决定是否进行跳转。X86和ARM等指令集采用专用标志位方式,RISC-V指令集则采用直接比较寄存器方式,MIPS和LoongArch指令集中的整数条件转移指令采用直接比较寄存器方式,而浮点条件转移指令则采用专用标志位方式。
下图是LoongArch指令集的编码格式:
CPU如何执行一条指令
计算机基本硬件由控制器、储存器、运算器、输入设备、输出设备五大部件组成,其中运算器、控制器、部分储存器被集成在一起统称为中央处理单元(Central Processing Unit,CPU)。CPU 大体上可以划分为三个部分,它们是控制单元、运算单元、存储单元,他们互相配合执行一条指令。
控制单元由指令寄存器IR( Instruction Register )、指令译码器 ID (Instruction oder )、操作控制器 OC (Operation Controller) 组成。程序被加载到内存后,指令这时都在内存中了,指令指针寄存器IP(cs:ip)指向内存中下一条待执行指令的地址,操作控制器 OC根据cs:ip的指向,将位于内存中的指令装载到指令寄存器IR中。然后指令译码器ID将位于指令寄存器IR中的指令按照指令集格式(对指令集不熟悉的可以先看下CPU扫盲-自研指令集)来解码,分析操作码和操作数,如果指令中的操作数为内存寻址,需要将内存中的数据取出暂存到储存单元。储存单元指的是CPU内部的L1/L2缓存(SRAM 缓存)以及寄存器,很多同学在感观上觉得寄存器是 CPU 直接使用的存储单元,所以寄存器比SRAM 更快 其实它们在速度上是同一级别的东西,因为寄存器和 SRAM 都是用相同的存储电路实现的,用的都是触发器,它工作速度极快,属于纳秒级别。CPU的寄存器分为可见和不可见两大类。可见就是程序可见,可以通过指令控制的寄存器如:PC寄存器、通用寄存器、段基址寄存器等;不可见的就是硬件直接使用,程序不可见不可操控的寄存器如:指令寄存器IR,L1/L2缓存也可看作特殊的不可见寄存器。
现在操作码和操作数都准备好了,就差执行指令了,如果这个指令是加减乘除等算数运算,操作控制器OC会给运算单元发送信号执行指令;如果指令是赋值、函数调用等操作则由操作控制器 OC直接执行。就这样一条指令执行完毕,整个过程包括取码、译码、执行,接着控制单元又要取下一条指令了,流程回到了本段开头, CPU的工作就是这样一天天的重复、循环。
程序内存空间(代码段、数据段、堆栈段)_代码段和数据段的区别
编译器在编译程序的时候,将程序中的所有的元素分成了一些组成部分,各部分构成一个段,所以说段是可执行程序的组成部分。
- 代码段:代码段就是程序中的可执行部分,直观理解代码段就是函数堆积组成的。
- 数据段(也被称为数据区、静态数据区、静态区):数据段就是程序中的数据,直观理解就是C语言程序中的全局变量。(注意:全局变量才算是程序的数据,局部变量不算程序的数据,只能算是函数的数据)
- bss段(.data)和bss的区别和联系:二者本来没有本质区别,都是用来存放c程序中的全局变量。区别在于把显示初始化为非零的全局变量存在.data段,而把显示初始化为0或者并未显示初始化(C语言规定未显示初始化的全局变量值默认为0)的全局变量存在bss段。
CPU如何执行多条指令
上文介绍了CPU如何执行一条指令的。那接下来看下CPU是如何执行多条指令的
有同学会疑问上面不是说了嘛,执行完当前指令再去获取下一条指令不断重复循环,多循环几次不就是多条指令了嘛。注意上文的重点是CPU执行一条指令时控制单元、运算单元、存储单元之间是如何配合的,将一条指令的执行划分为取码、译码、执行三个步骤,这更像是理论,为了方便大家理解,但真正CPU厂商在设计CPU电路时会复杂的多,能将一个指令的执行划分为几十个步骤。如:x86系列CPU由于其指令不定长缘故,取码的过程就拆分成了多个小步骤,假设CPU为32位,指令指针寄存器IP(cs:ip)为 0x00000000,当前指令长24bit。
指令指针寄存器IP(Instruction Pointer,X86型CPU)相当于ARM型CPU中的程序计数器PC,用于控制程序中指令的执行顺序。正常运行时,IP中含有BIU要取的下一条指令(字节)的偏移地址,一般情况下,每从内存中存取一次指令码,IP就自动加1,从而保证指令的顺序执行。IP实际上是指令机器码存放内存单元的地址指针,IP的内容可以被转移类指令(如JMP)强迫改写,以改变程序执行的顺序。
- 内存获取指令:由于CPU数据总线宽32bit,所以一次寻址获取32bit数据,将数据暂存SRAM 缓存
- 指令预处理:由于x86指令非定长,所以需要预处理,对指令进行部分译码分析出操作码判断出指令长度为24bit
- 存入指令寄存器IR:在32bit数据中取出有效部分存入指令寄存器IR
- 更新指令指针寄存器IP:当前指令长度为24bit=3字节,所以下一条指令地址应为0x00000003,更新指令指针寄存器IP为 0x00000003
借助这个例子也想强调一下cs:ip的值在取码完成后就会被更新为下一条指令的地址,并非必须等当前指令执行完毕才更新。这个地方如果误解了后续CPU流水线技术的学习就不容易理解。
CPU在电路设计上实现了多个独立电路,支持每个步骤独立执行,CPU 可以一边执行指令,一边取指令,一边译码。虽然CPU在同一时刻只能执行一条指令,由于cs:ip的值在取码完成后就会被更新为下一条指令的地址,所以在执行这条指令的同时可以去翻译下一条指令,同时去获取下下条指令。这样的流水线模式大大提升CPU的执行效率,下面我们用取码、译码、执行三级流水线为例来讲一下CPU的流水线技术,见下图:
以上在第2周期后,虽然在一个时钟周期内 CPU 同时干了三件事,但一定要清楚,这三件事不属于一个指令,是三个指令重叠在一起了。同时完成的是当前指令的第三步、下一条指的第二步、第三条指令的第一步。 CPU 中每条指令必须经过取指、译码、执行三步才算完成。三级流水线模型就是要保证取码、译码、执行三个独立的电路始终在运行状态不要停下来。就拿周期3来说,在这一时钟周期里, CPU 同时完成了“执行”、“译码”、“取指” 三件事。其中“执行电路”处理第一条指令的执行步骤,“译码电路”处理第二条指令的译码步骤,”取指电路“处理第三条指令的取码步骤。
如果CPU不使用流水线技术的话执行一个指令始终需要三个周期,执行10000条指令需要 10000(指令数) x 3 (单个步骤耗时) + 0(3单条指令总耗时-3单步骤耗时)=30000
个周期;在使用三级流水线技术时(如上图),将每条指令的执行划分为三个步骤,因为每条指令执行的总耗时相同都为三个周期,所以每个步骤耗时一个周期,相当于从第三个周期开始每执行一条指令只需要一个周期,执行10000条指令需要 10000(指令数) x 1 (单个步骤耗时) + 2(3单条指令总耗时-1单步骤耗时)=10002
个周期;试想如果使用10级流水线技术(如下图),将每条指令的执行划分为10个步骤,每条指令执行的总耗为3周期不变的前提下,10级流水线每个步骤耗时0.3周期,相当于从第3.0个周期开始每执行一条指令只需要0.3个周期,此时执行10000条指令只需要 10000(指令数) x 0.3 (单个步骤耗时) + 2.7(3单条指令总耗时-0.3单步骤耗时)=3002.7
个周期,这大大提升了CPU的性能,此时 CPU10级流水线的性能相较与CPU不使用流水线技术时的性能已经提升了一个数量级。
按照上述论证,CPU的支持的流水线条数和CPU的性能是成正比的,CPU的流水线条数真的越多越好嘛?
很可惜CPU的流水线条数并不是越多越好,有两个主要原因:
- CPU多流水线的原理是将CPU划分为多个独立电路,每个电路负责指令执行的一个步骤。流水线条数越多CPU的电路就越复杂,CPU的体积就会越大,CPU的能耗就会越高,这种高能耗CPU靠电池供电的移动设备就无法接受,因为相比性能过剩的CPU人们更看重它的续航表现,所以CPU设计人员需要在性能和功耗之间做一个很好的平衡,不能一味的追求性能。
- 上述CPU流水线条数和CPU性能成正比的结论是在程序指令依次执行的情况下,但当程序指令出现跳转或分支结构时就要另当别论了。比如当上图的第一条指令是
jmp
(无条件的转移到指令指定的地址去执行从该地址开始的命令)指令时,jmp
指令会直接修改指令指针寄存器IP中的指令地址,所以当第3.0个周期jmp
指令执行时,指令指针寄存器IP中的指令地址会被更新,下一条指令应该是更新后新地址的第一条指令,之前流水线中的指令二到指令十都是无效指令了,这个时候就需要清空流水线中的指令,就意味着清空所有的独立电路以及相关寄存器,这个过程显然流水线越多 耗时越多 代价越大。当然CPU也会有分支预测等手段来尽量避免这种清空流水线的情况发生(不能完全避免),但同样需要复出很大代价比如:电路更加复杂,CPU成本更高。所以CPU的流水线并不是越多越好,CPU的设计师要兼顾方方面面。
RISC-V
指令集架构
RISC-V 指令有以下特点:
- 完全开放
- 指令简单
- 模块化设计,易于扩展
名称 | 类别 | 说明 |
---|---|---|
RV32I | 基础指令 | 整数指令:包含算法、分支、逻辑、访存指令,有32个32位寄存器。能寻址32位地址空间 |
RV64I | 基础指令 | 整数指令:包含算法、分支、逻辑、访存指令,有32个64位寄存器。能寻址64位地址空间 |
RV128I | 基础指令 | 整数指令:包含算法、分支、逻辑、访存指令,有32个128位寄存器。能寻址128位地址空间 |
RV32E | 基础指令 | 与RV32I一样,只不过只使用前16个(0~15)32位寄存器 |
M | 扩展指令 | 包含乘法、除法、取模求余指令 |
F | 扩展指令 | 单精度浮点指令 |
D | 扩展指令 | 双精度浮点指令 |
Q | 扩展指令 | 四倍精度浮点指令 |
A | 扩展指令 | 原子操作指令:比如比较并交换,读改写等指令 |
C | 扩展指令 | 压缩指令:单指令长度为16位,主要用于改善程序大小 |
P | 扩展指令 | 单指令多数据(Packed-SIMD)指令 |
B | 扩展指令 | 位操作指令 |
H | 扩展指令 | 支持(Hypervisor)管理指令 |
J | 扩展指令 | 支持动态翻译语言指令 |
L | 扩展指令 | 十进制浮点指令 |
N | 扩展指令 | 用户中断指令 |
G | 通用指令 | 包含I、M、A、F、D 指令 |
要满足现在操作系统和应用程序的基本运行,RV32G指令集或者RV64G指令集就够了。RV32G和RV64G指令集只有寄存器位宽和寻址大小不同。这些指令按照功能可以分为如下几类:
- 整数运算指令:算术、逻辑、比较等基础运算功能。
- 分支转移指令:实现条件转移、无条件转移操作
- 加载存储指令:实现字节、半字(half word)、字(word)、双字(RV64I)的加载,存储操作,采用的都是寄存器相对寻址方式
- 控制与状态寄存器访问指令:实现对系统控制与系统状态寄存器的原子读-写、原子读-修改、原子读-清零等操作
- 系统调用指令:实现系统调用功能。
- 原子指令:用于各种同步锁
- 单双浮点指令:实现浮点运算操作
从上表我们可以看到,RISC-V 指令集具有模块化特点。这就允许我们根据自己的需求,选择一个基础指令集,加上若干个扩展指令集灵活搭配,就可以得到我们想要的指令集架构,进而根据这样的指令架构,设计出贴合我们需求的CPU.
作为初学者,我们了解RISC-V 的核心即可。它的最核心部分是一个基础指令集,叫做RV32I.
RV32I 包含的指令是固定不变的,这为编译器设计人员,操作系统开发人员和汇编语言程序员提供了稳定的基础框架。
RV32I 指令集:
RV32I 指令集如图所示,把带下划线的字母从左至右连接组合就是组成了RV32I指令。{}表示集合中垂直方向的每个项目指令不同变体。变体用下划线字母或者下划线表示表示,如果大括号里面只有下划线,则表示对此变体不需要用字母表示
我们结合具体例子来看:下图表示了bge、blt、bgeu、bltu四个指令。
寄存器
在RISC-V 32I的规范里面定义了32 个通用寄存器。其中31个是常规寄存器,1个恒为0值的x0寄存器。
0值寄存器是为了满足汇编语言程序员和编译器编写者的使用需要,他们可以使用x0寄存器作为操作数,来完成功能相同的操作。
addi x0,x0,0 ; 0 = 0 + 0,相当于 nop 空指令
RSIC-V32I 寄存器说明
寄存器 | ABI 名称 | 说明 |
---|---|---|
x0 | zero | 0值寄存器,硬编码为0,写入数据忽略,读取数据为0 |
x1 | ra | 用于返回地址(return address) |
x2 | sp | 用于栈指针(stack pointer) |
x3 | gp | 用于通用指针 (global pointer) |
x4 | tp | 用于线程指针 (thread pointer) |
x5 | t0 | 用于存放临时数据或者备用链接寄存器 |
x6~x7 | t1~t2 | 用于存放临时数据寄存器 |
x8 | s0/fp | 需要保存的寄存器或者帧指针寄存器 |
x9 | s1 | 需要保存的寄存器 |
x10~x11 | a0~a1 | 函数传递参数寄存器或者函数返回值寄存器 |
x12~x17 | a2~a7 | 函数传递参数寄存器 |
x18~x27 | s2-s11 | 需要保存的寄存器 |
x28~x31 | t3~t6 | 用于存放临时数据寄存器 |
RAISC指令比较
指令格式比较
下图是五种RISC指令集的指令编码格式。在寄存器类指令中,操作码都由操作码(OP)和辅助操作码(OPX)组成,操作数都包括两个源操作数(RS)和一个目标操作数(RD);立即数类指令都由操作码、源操作数、目标操作数和立即数(Const)组成,立即数的位数各有不同;
下图是RISC-V 指令格式,从下图可以看到RSIC-V共六种指令格式。
- opcode :指令操作码
- imm:代码立即数
- func3和funct7:代表指令对应的功能
- rs1:源寄存器1
- rs2:源寄存器2
- rd:目标寄存器(RSIC-V 一个指令可以提供三个寄存器操作)
六种指令格式作用如下:
序号 指令类型 作用 1 R 型指令 用于寄存器和寄存器操作 2 I 型指令 用于短立即数和内存载入指令load操作 3 S 型指令 用于内存存储store操作 4 B 型指令 用于有条件跳转操作 5 U 型指令 用于长立即数操作 6 J 型指令 用于无条件跳转操作
寻址方式比较
MIPS、SPARC和LoongArch只支持四种常用的寻址方式,PowerPC和PA-RISC支持的寻址方式较多。
公共指令功能
load-store指令。load指令将内存中的数据取入通用寄存器,store指令将通用寄存器中的数据存至内存中。当从内存中取回的数据位宽小于通用寄存器位宽时,后缀没有U的指令进行有符号扩展,即用取回数据的最高位(符号位)填充目标寄存器的高位,否则进行无符号扩展,即用数0填充目标寄存器的高位。
ALU指令。ALU指令都是寄存器型的,常见的ALU指令包括加、减、乘、除、与、或、异或、移位和比较等。
控制流指令。控制流指令分为绝对转移指令和相对转移指令。相对转移的目标地址是当前的PC值加上指令中的偏移量立即数;绝对转移的目标地址由寄存器或指令中的立即数给出。在条件转移指令中,转移条件的确定有两种方式:判断条件码和比较寄存器的值。SPARC采用条件码的方式,整数运算指令置条件码,条件转移指令使用条件码进行判断。MIPS和LoongArch的定点转移指令使用寄存器比较的方式进行条件判断,而浮点转移指令使用条件码。
RAISC常见指令类型
[RISC-V的常见指令 ](https://zhuanlan.zhihu.com/p/496767749#:~:text=RISC-V的常见指令 1 运算指令 2 算术运算 3 逻辑运算 4,5 数据传输指令 6 比较指令 7 条件分支指令 8 无条件跳转指令)
基础整数指令
算数运算类指令
加减乘除、取余、比较、及特殊运算(alsl、pcaddi等)。
add rd,rs1,rs2; 将寄存器rs1与rs2的值相加并写入寄存器rd
sub rd,rs1,rs2; 将寄存器rs1与rs2的值相减并写入寄存器rd
addi rd,rs1,imm; 将寄存器rs1的值与立即数imm相加并存入寄存器rd
mul rd,rs1,rs2; 将寄存器rs1与rs2的值相乘并写入寄存器rd
div rd,rs1,rs2; 将寄存器rs1除以寄存器rs2的值,向零舍入并写入寄存器rd
rem rd,rs1,rs2; 将寄存器rs1模寄存器rs2的值并写入寄存器rd
---------------------比较指令-----------------------
有符号数:
slt rd,rs1,rs2; 若rs1的值小于rs1的值,rd置为1,否则置为0
slti rd,rs1,imm; 若rs1的值小于立即数imm,rd置为1,否则置为0
无符号数:
sltu rd,rs1,rs2; 若rs1的值小于rs1的值,rd置为1,否则置为0
sltiu rd,rs1,imm; 若rs1的值小于立即数imm,rd置为1,否则置为0
----------------------------------------------------
以上运算发生溢出时会自动截断高位。乘法可以用mulh
,mulhu
获得两个32位数乘积的高32位,细节不赘述。
逻辑运算指令
位运算(与、或、异或)
and rd,rs1,rs2; 将寄存器rs1与rs2的值按位与并写入寄存器rd
andi rd,rs1,imm; 将寄存器rs1的值与立即数imm的值按位与并写入寄存器rd
or rd,rs1,rs2; 将寄存器rs1与rs2的值按位或并写入寄存器rd
ori rd,rs1,imm; 将寄存器rs1的值与立即数imm的值按位或并写入寄存器rd
xor rd,rs1,rs2; 将寄存器rs1与rs2的值按位异或并写入寄存器rd
xori rd,rs1,imm; 将寄存器rs1的值与立即数imm的值按位异或并写入寄存器rd
移位运算累指令
逻辑左移、逻辑右移、算数右移、循环右移。
sll rd,rs1,rs2; 将寄存器rs1的值左移寄存器rs2的值这么多位,并写入寄存器rd
slli rd,rs1,imm; 将寄存器rs1的值左移立即数imm的值这么多位,并写入寄存器rd
srl rd,rs1,rs2; 将寄存器rs1的值逻辑右移寄存器rs2的值这么多位,并写入寄存器rd
srli rd,rs1,imm; 将寄存器rs1的值逻辑右移立即数imm的值这么多位,并写入寄存器rd
sra rd,rs1,rs2; 将寄存器rs1的值算数右移寄存器rs2的值这么多位,并写入寄存器rd
srai rd,rs1,imm; 将寄存器rs1的值算数右移立即数imm的值这么多位,并写入寄存器rd
左移会在右边补0,逻辑右移会在最高位添0,算数右移在最高位添加符号位。
区分算数右移和逻辑右移,是从计算的角度考虑的:左移一位等于乘2,右移一位等于除2是算数的规律;无论正数负数,在右边补0都等于乘2;而负数进行逻辑右移的结果不等于除以2,需要用算数右移;而若只有算术右移,则无符号数的运算又会受影响。
位操作指令
截取某个范围的数据,如ext.b rd rj将rj的[7:0]符号扩展后写入rd。计量连续比特1或0的个数,如clo.w rd rj对rj的[31:0]数据,从第31位方向开始向第0位统计连续比特1的个数,clz.w是连续比特0。
转移指令
-
所有的跳转都是相对跳转,也就是指令中的目标值左移2位、符号扩展后与PC相加。
-
有条件跳转,一种是两个寄存器之间比较,一种单个寄存器与0比较,根据比较结果决定是否跳转至目标地址。
-
RV32I 中的条件跳转就是通过比较两个寄存器的值,进行分支跳转:
- beq:相等
- bne:不相等
- bge/bgeu:大于等于
- blt/bltu:小于
-
-
无条件跳转,一种是跳转至目标地址后不回来,一种是跳转至目标地址后要回来,即将下一条指令的地址存放到返回寄存器(ra = PC + 4)或栈上。
-
j label; 程序直接跳转到lable处继续执行 jal rd,label; 用于调用函数,把下一条指令的地址保存在rd中(通常用x1),然后跳转到label处继续执行 jalr rd,offset(rs); 可用于函数返回,把下一条指令的地址存到rd中,然后跳转到rs+offset地址处的指令继续执行。若rd=x0就是单纯的跳转(x0不能被修改)
这里详细解释一下
jal
和jalr
。在调用函数时,我们希望函数返回后,继续执行下一条指令,所以要把这下一条指令的地址存起来,再跳转到函数的代码块。函数执行完之后,根据先前存起来的指令地址,再跳回到调用处继续执行。
-
普通访存指令(数据传输指令)
{ld/sd}.{b/h/w/d}[u] rd, rj, si12
等
前面讲到,想要对主存中的数据进行运算,需要先将其取至寄存器,数据传输指令实现了这个目的。
现代计算机以字节(byte,1byte=8bits)为基本单位,而内存本身可被视作由byte组成的一维数组,地址从0开始。字(word)则是存取数据的另一个单位,在RISC-V中1word=4Bytes=32bits,在其他体系结构中可能会发生变化。
- 从内存中取回数据放入寄存器,或将寄存器中的值放入内存。若访存地址自然对齐,不触发非对齐例外。若访存地址不自然对齐,如果硬件支持非对齐访问,且当前运算环境配置也允许对齐访问,则不触发非对齐例外,否则触发。
- 从内存预取一个Cache行的数据进入Cache中,访存地址落在待预取的Cache行内,见preld和preldx指令。该类指令不会触发任何与MMU或是地址相关的例外。
lb rd,offset(rs1); 从地址为寄存器rs1的值加offset的主存中读一个字节,符号扩展后存入rd
lh rd,offset(rs1); 从地址为寄存器rs1的值加offset的主存中读半个字,符号扩展后存入rd
lw rd,offset(rs1); 从地址为寄存器rs1的值加offset的主存中读一个字,符号扩展后存入rd
lbu rd,offset(rs1); 从地址为寄存器rs1的值加offset的主存中读一个无符号的字节,零扩展后存入rd
lhu rd,offset(rs1); 从地址为寄存器rs1的值加offset的主存中读半个无符号的字,零扩展后存入rd
lwu rd,offset(rs1); 从地址为寄存器rs1的值加offset的主存中读一个无符号的字,零扩展后存入rd
sb rs1,offset(rs2); 把寄存器rs1的值存入地址为寄存器rs2的值加offset的主存中,保留最右端的8位
sh rs1,offset(rs2); 把寄存器rs1的值存入地址为寄存器rs2的值加offset的主存中,保留最右端的16位
sw rs1,offset(rs2); 把寄存器rs1的值存入地址为寄存器rs2的值加offset的主存中,保留最右端的32位
l是load的首字母,即加载数据(相当于read);
s是store的缩写,即存储数据(相当于write);
b,h,w分别是byte,half word,word的首字母,除此之外还有存取双字的d,即double word。
举例:
long long A[100];
A[10] = A[3] + a;
假设数组A首地址在寄存器x3,a在x2:
ld x10,24(x3); long long占64bits=8bytes,A[3]的地址为A[0]+3*8
add x10,x2,x10;
sd x10,80(x3);
条件分支指令
这部分用来实现控制流,即if语句,循环等。汇编中没有C等高级语言中的{}
语句块,而是用Lable:
的形式,下面会举例说明:
beq rs1,rs2,lable; 若rs1的值等于rs2的值,程序跳转到lable处继续执行
bne rs1,rs2,lable; 若rs1的值不等于rs2的值,程序跳转到lable处继续执行
blt rs1,rs2,lable; 若rs1的值小于rs2的值,程序跳转到lable处继续执行
bge rs1,rs2,lable; 若rs1的值大于等于rs2的值,程序跳转到lable处继续执行
blt
和bge
也有无符号版本bltu
和bgeu
举例
int i = 0;
do{
i++;
}while(i<10)
add x2,x0,10 # x2 = 10
add x3,x0,0 # i = 0存储在x3
Loop:
add x3,x3,1 # i++
blt x3,x2,Loop # i<10则继续循环
边界检查访存指令
{ld/sd}{gt/le}.{b/h/w/d} rd rj rk
。从内存中取回数据放入寄存器。访存地址直接从rj中取,且要自然对齐,否则触发非对齐例外。还要比较寄存器rj和rk的值的大小,如果指令预设条件,则进行访存,不满足条件则会终止访存并触发边界检查例外。
原子访存指令:am{swap/add/and/or/xor/max/min}[_db].{w/d} rd rj rk
等
- 原子访存指令能够原子的完成对某个内存单元的“读-修改-写”操作序列。具体来说,其将内存指定地址处的旧值取回并写入通用寄存器rd,同时将这个内存中的旧值与通用寄存器rk中的值进行一些简单的运算操作,然后将运算结果写回到内存的指定地址处。访存地址直接从rj中取,且要自然对齐,否则触发非对齐例外。
内存屏障指令
dbar
、ibar
在大部分场景下,我们不用特意关注内存屏障的,特别是在单处理器系统里,虽然CPU内部支持乱序执行以及预测式的执行,但是总体来说,CPU会保证最终执行结果符合程序员的要求。在多核并发编程的场景下,程序员需要考虑是不是应该用内存屏障指令。下面是一些需要考虑使用内存屏障指令的典型场景。
- 在多个不同CPU内核之间共享数据。在弱一致性内存模型下,某个CPU乱的内存访问次序可能会产生竞争访问。
- 执行和外设相关的操作,例如DMA操作。启动DMA操作的流程通常是这样的:第一步,把数据写入DMA缓冲区里;第二步,设置DMA相关寄存器来启动DMA。如果这中间没有内存屏障指令,第二步的相关操作有可能在第一步前面执行,这样DMA就传输了错误的数据。
- 修改内存管理的策略,例如上下文切换、请求缺页以及修改页表等。
- 修改存储指令的内存区域,例如自修改代码的场景。
总之,我们使用内存屏障指令的目的是想让CPU按照程序代码逻辑来执行,而不是被CPU乱序执行和预测执行打乱了代码的执行次序。LoongArch有两个内存屏障指令:
- dbar 0是一个完全功能的同步屏障。只有等到之前所有load/store访存操作彻底执行完毕后,dbar 0指令才能开始执行;且只有dbar 0执行完毕后,其后所有load/store访存操作才能开始执行。
- ibar 0使用完成单个处理器核内部store操作与取值操作之间的同步。ibar 0能保证其之后的取值一定能观察到其之前所有store操作的执行结果。
基础浮点指令
浮点运算类指令:浮点加减乘除运算。
浮点比较指令:大于、等于、小于以及无法比较,当有两个操作数中至少有一个NaN时,这两个数就无法比较。比较结果会写入指定的条件标志寄存器。
浮点转换指令:单精度与双精度之间转换、定点与浮点之间的转换(舍入模式)。
浮点搬运指令:浮点寄存器之间搬运(直接搬运:fmov.s fd, fj;条件搬运:fsel fd, fj, fk, ca,若ca为0,fd = fj,否则fd = fk)、浮点与通用寄存器之间搬运、浮点与条件标志寄存器 之间搬运、通用与条件标志寄存器 之间搬运、通用与浮点控制状态寄存器 之间搬运。
浮点分支指令:只有条件分支,根据条件标志寄存器 的值决定是否跳转目标地址。
浮点普通访存指令:从内存中取回数据放入浮点寄存器,或将浮点寄存器中的值放入内存。
浮点边界检查访存指令:检查有效地址是否越界,从内存中取回数据放入浮点寄存器,或将浮点寄存器中的值放入内存。
特权指令
所谓特权指令是指有特权权限的指令,由于这类指令的权限最大,如果使用不当,将导致整个系统崩溃。常见的特权指令有:
- CSR访问指令。CSR,即Control and Status Register,[控制与状态寄存器](https://zhuanlan.zhihu.com/p/379723433#:~:text=CSR ,即 C ontrol,and S tatus R egister,控制与状态寄存器,属于CPU自带的一类寄存器(注意这里需要跟前文所述的通用数据寄存器区别开来,后文对这种寄存器称之为数据寄存器,以示区分)。),属于CPU自带的一类寄存器。关于控制状态寄存器可参考。
- Cache维护指令。主要对Cache进行初始化以及一致性维护。
- TLB维护指令,软件页表遍历指令等。
为了保证系统安全,这类指令只能用于操作系统或其他系统软件,不直接提供给用户使用。为了防止用户程序中使用特权指令,用户态下只能使用非特权指令,核心态下可以使用全部指令。当在用户态下使用特权指令时,将产生中断以阻止用户使用特权指令。所以把用户程序放在用户态下运行,而操作系统中必须使用特权指令的那部分程序在核心态下运行,保证了计算机系统的安全可靠。从用户态转换为核心态的唯一途径是中断或异常。