1
1
mirror of https://github.com/foxsen/archbase.git synced 2026-02-02 18:09:17 +08:00

initial import to public repository

This commit is contained in:
Zhang Fuxin
2021-10-27 19:14:51 +08:00
commit c632bed67e
362 changed files with 53748 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.Rproj.user
.Rhistory
.RData
.Ruserdata
_book
_bookdown_files
project.vim
materials/*/*.png

24
01-foreword-recommend.Rmd Normal file
View File

@@ -0,0 +1,24 @@
# 推荐序 {-}
\markboth{推荐序}{推荐序}
“计算机体系结构”Computer Architecture也称为“计算机系统结构”是计算机科学与技术一级学科下最重要的二级学科。“计算机体系结构”是研究怎么造计算机而不是怎么用计算机的学科。我国学者在如何用计算机的某些领域的研究已走到世界前列例如最近很红火的机器学习领域中国学者发表的论文数和引用数都已超过美国位居世界第一。但在如何造计算机的领域参与研究的科研人员较少科研水平与国际上还有较大差距。2016年国家自然科学基金会计算机学科的面上项目共有4863项申请但申报“计算机体系结构”F0203方向的项目只有22项占总申报项目的0.45%而申报计算机图像与视频处理方向的项目有439项。
做计算机体系结构方向研究的科研人员较少与大学及研究生的课程教育直接相关。计算机体系结构是工程性很强的学科而我国的大学老师大多没有机会实际参与设计CPU和操作系统对计算机的软硬件工作过程不能融会贯通教学时只能照本宣科学生只学到一些似懂非懂的名词概念难以培养“造计算机”的兴趣。目前全国许多高校使用从国外翻译的体系结构教材John L. Hennessy和 David A. Patterson合著的《计算机体系结构量化研究方法》已经不断改版至第5版被认为是计算机体系结构的经典教材但此书有近千页之厚本科生未必都能接受。国内也出版了不少体系结构系统结构方面的教材但多数兼顾了研究生和参考书的需求。因此迫切需要一本为本科生量身定制的计算机体系结构精品教材。
摆在读者面前的这本《计算机体系结构基础》就是为满足本科教育而编著的精品教材。过去出版的体系结构教材大多是“眼睛向上”编写的作者既考虑了做本科教材的需求又考虑了参考书的需求为了体现参考书的技术前瞻性往往会包含一些未经受考验的新技术。而本书是作者在2011年已经出版的硕士生教材《计算机体系结构》的基础上“眼睛向下”编著的本科生教材多年的研究生授课经历使作者十分明确本科生应学习哪些体系结构的基础知识。凡写进这本教科书的内容都是本科生应该掌握的知识不会为追求时髦而增加额外的内容。
与过去出版的计算机体系结构教科书相比,本书有以下几个特点:
第一个特点是特别重视知识的基础性。计算机发明至今已经70余年曾经用来造计算机的技术多如牛毛计算机期刊与会议上发表的文章数以万计但是许多技术如过往烟云已经被丢进历史的垃圾堆。我在美国读博士时一位很有权威的教授讲了一个学期计算机体系结构课基本上都是讲并行计算机的互连Interconnection结构如蝶形Butterfly互连、超立方体Hypercube互连、胖树Fat Tree互连等现在这些内容已不是计算机界普遍关心的问题。20世纪90年代计算机体系结构国际会议ISCA几乎成了专门讨论缓存Cache技术的会议但没有几篇文章提出真正可用的缓存技术以至于计算机界的权威John L. Hennessy教授1997年说出这样的话“把1990年以来计算机系统结构方面所有的论文都烧掉对计算机系统结构没有任何损失。”本书作者在“自序”中写道“计算机体系结构千变万化但几十年发展沉淀下来的原理性的东西不多希望从体系结构快速发展的很多现象中找出一些内在的、本质的东西。”毛泽东在《实践论》中归纳总结了十六个字“去粗取精去伪存真由此及彼由表及里。”本书作者遵循这十六个字的精神对几十年的计算机体系结构技术做了认真的鉴别、选择和对比、分析写进教科书的内容是经得起历史考验的基础知识。
第二个特点是强调“一以贯之”的系统性。“计算机系统结构”的关键词是“系统”而不是“结构”国外做计算机系统结构研究的学者介绍自己时往往是说“我是做系统System研究的。”计算机专业的学生应具有系统层面的理解能力能站在系统的高度解决应用问题。对计算机系统是否有全面深入的了解是区别计算机专业人才和非专业人才的重要标志。长期以来我们采用“解剖学”的思路进行计算机教学按照硬件、软件、应用等分类横切成几门相对独立的课程使得计算机系毕业的学生对整个计算机系统缺乏完整的理解。如果问已经学完全部计算机课程的学生在键盘上敲一个空格键到屏幕上的PPT翻一页在这一瞬间计算机中哪些硬件和软件在运转如何运转可能绝大多数学生都讲不清楚。本书有若干章节专门讲述计算机的软硬件协同、计算机系统的启动过程等着力培养学生的全局思维能力。为了使学生一开始就对计算机有全局的框架性认识此教材的第1章对全书内容做了尽可能通俗易懂的描述这是追求系统性教学的刻意安排。本书作者强调“一个计算机体系结构设计人员就像一个带兵打仗的将领要学会排兵布阵。要上知天文、下知地理否则就不会排兵布阵或者只会纸上谈兵地排兵布阵只能贻误军国大事。”这里讲的“天文”是指应用程序、编译程序和操作系统“地理”是指逻辑、电路和工艺。只有上下贯通才能真正掌握计算机体系结构。
第三个特点是强调能在硅上实现的实践性。由于CMOS电路集成度的指数性提高一块CPU芯片已可以集成几十亿晶体管。计算机体系结构的许多知识现在都体现在CPU中因此从某种意义上讲不懂CPU设计就不能真正明白计算机体系结构的奥妙。CPU的结构通常称为微体系结构主要在硕士课程中讲授但本科生的体系结构课程也应学习在硅上能实现的技术。陆游诗云“纸上得来终觉浅绝知此事要躬行。”只会P2P的学习从Paper到Paper的学习往往学不到真本事只有最后能“躬行”到硅上的知识才是过硬的知识。本书作者有十几年从事CPU设计的经验能正确区分哪些是纸上谈兵的知识哪些是能落实到硅上的知识这是他们独特的优势。在中国科学院大学的本科教学中计算机体系结构课程还辅以高强度的实验课实践证明这对学生真正理解课堂学到的知识大有好处。
本书内容选材还需要经过课堂教学的长期检验,需要不断听取学生的反馈意见和同行的批评建议,希望经过几年的完善修改,本书能真正成为受到众多大学普遍欢迎的精品教材。
```{r liguojie-sign, fig.align='right', echo = FALSE}
knitr::include_graphics('./images/foreword/liguojie_sign.png')
```
\newpage

27
02-foreword-author.Rmd Normal file
View File

@@ -0,0 +1,27 @@
# 自序 {-}
\markboth{自序}{自序}
计算机专业有几门“当家”的核心课程是关于“如何造计算机”的,硬件方面以计算机组成原理和计算机体系结构为主,软件方面以操作系统和编译原理为主。其他如离散数学、编程语言、数据结构、数字逻辑等计算机专业的学科基础课也很重要,除了计算机专业,其他使用计算机的专业如自动化专业、电子专业也在学。
我从2001年就开始从事龙芯处理器的研发并从2005年起在中国科学院大学教授计算机体系结构课程其间接触了很多从各高校计算机专业毕业的学生发现他们在大学时主要练就了诸如编程等“怎么用计算机”的本领对操作系统和体系结构这种“如何造计算机”的课程或者没有系统学习或者只学到一些概念。比如对于“从打开电源到计算机启动再到登录界面”或者“从按一下空格键到翻一页PPT”这样的过程如果问及计算机系统内部包括CPU、南北桥、GPU在内的硬件以及包括操作系统和应用程序在内的软件是如何协同工作的计算机专业毕业的学生几乎没有人说得明白。
我1986年到中国科学技术大学计算机系学习的时候教授我计算机体系结构课程的老师都是亲自造过计算机的他们能够讲明白计算机软硬件工作的原理性过程。改革开放以来我国主要使用国外的CPU和操作系统“攒”计算机学术界也几乎不从事CPU和操作系统这种核心技术的研究工作全国两千多个计算机专业主要使用国外教材或者翻译的国外教材教授学生“如何造计算机”。由于计算机体系结构和操作系统都是工程性很强的学科而任课老师却没有机会参与设计CPU和操作系统因此教学生的时候难免照本宣科使学生只学到一些概念难以对计算机的软硬件工作过程融会贯通。
发展以CPU和操作系统为代表的自主基础软硬件是国家的战略需求而人才培养是满足该战略需求的必要条件。因此自2005年开始我便结合龙芯CPU的实践在中国科学院研究生院开设计算机体系结构课程并于2011年依托清华大学出版社出版了《计算机体系结构》教材。2014年中国科学院大学设立并开始招收本科生要求我也给本科生讲授计算机体系结构课程。刚开始觉得难度很大因为计算机体系结构非常复杂给研究生讲清楚都不容易给本科生讲清楚就更难。
经过反复思考,我觉得可以利用这个机会,建设包括本科生、硕士生、博士生在内的计算机体系结构课程体系,由浅入深地培养“造计算机”的人才。为此,我们计划编写一套分别面向本科生、硕士生、博士生的“计算机体系结构”课程教材。
面向本科生的教材为《计算机体系结构基础》。主要内容包括作为软硬件界面的指令系统结构包含CPU、GPU、南北桥协同的计算机硬件结构CPU的微结构并行处理结构计算机性能分析等。上述面面俱到的课程安排主要是考虑到体系结构学科的完整性但重点是软硬件界面及计算机硬件结构微结构则是硕士课程的主要内容。
面向硕士生的教材为《计算机体系结构》。主要介绍CPU的微结构包括指令系统结构、二进制和逻辑电路、静态流水线、动态流水线、多发射流水线、运算部件、转移猜测、高速缓存、TLB、多核对流水线的影响等内容。
面向博士生的教材为《高级计算机体系结构》。中科院计算所的“高级计算机体系结构”课程是博士生精品课程的一部分主要强调实践性使学生通过设计真实的而不是简化的CPU运行真实的而不是简化的操作系统对结构设计、物理设计、操作系统软件做到融会贯通。
在此基础上,还将推出计算机体系结构实验平台和实验教材。
这套教材的编写突出以下特点:一是系统性,体系是“系统的系统”,很难脱离软硬件环境纯粹就体系结构本身讲解计算机体系结构,需要对体系结构、基础软件、电路和器件融会贯通;二是基础性,计算机体系结构千变万化,但几十年发展沉淀下来的原理性的东西不多,希望从体系结构快速发展的很多现象中找出一些内在的、本质的东西;三是实践性,计算机体系结构是实践性很强的学科,要设计在“硅”上运行而不是在“纸”上运行的体系结构。
胡伟武
\newpage

18
03-foreword-3rd.Rmd Normal file
View File

@@ -0,0 +1,18 @@
# 第三版序 {-}
\markboth{第三版序}{第三版序}
在中国科学院大学讲授“计算机体系结构基础”课程五年以来发现了《计算机体系结构基础》教材不少值得改进的地方。除了修订第2版的一些错误这次第3版的主要改进内容包括以下三个方面。
一是加强计算机软硬件协同方面的内容。如第4章对应用程序二进制接口Application Binary Interface简称ABI的描述更加清楚增加了操作系统中关于用户程序地址空间分布的内容并介绍了函数调用、例外处理、系统调用、线程切换、进程切换和虚拟机切换等六种场景的现场保留和恢复过程希望读者可以通过上述过程更深入地了解计算机系统软硬件的配合。又如第7章在介绍计算机系统启动过程时把串口作为一只“麻雀”进行解剖希望读者可以借此了解CPU对IO设备的访问与对内存的访问的不同。这样的地方还有不少。
二是对部分内容进行调整以使之更完整和适用。如第3章的特权指令系统部分从例外、中断、存储管理等方面更详细地分析了操作系统内核专用的特权指令系统的内容。第12章的性能分析部分在详细介绍Perf性能分析工具的基础上去掉了对Oprofile性能分析工具的介绍适当缩减了性能测试与分析的具体案例内容突出基准程序性能测试、Perf微结构数据统计和微测试程序Microbench等不同角度的方法与工具在性能分析工作中的应用。
三是在指令系统举例时使用LoongArch指令系统而不是MIPS指令系统。LoongArch是由龙芯团队在2020年推出的新型RISC指令系统。该指令系统摒弃了传统指令系统中部分不适应当前软硬件设计技术发展趋势的陈旧内容吸纳了近年来指令系统设计领域诸多先进的技术发展成果有助于硬件实现高性能低功耗的设计也有利于软件的编译优化以及操作系统、虚拟机的开发。
一门课程的成熟往往需要十年时间。上述根据五年的教学经验进行的修改肯定还不够,需要在未来的教学工作中继续进行改进。
胡伟武
2021年6月29日
\newpage

22
04-preface.Rmd Normal file
View File

@@ -0,0 +1,22 @@
# 前言 {-}
\markboth{前言}{前言}
计算机体系结构是一门比较抽象的学科很有可能经过一个学期的学习只学到一些概念。本课程教学希望达到三个目的。一是建立学生的系统观。计算机系统的复杂性体现在计算机中各部分之间的关系非常复杂。如苹果iPhone的CPU性能不如Intel的X86 CPU但用户体验明显好于桌面计算机这就是系统优化的结果。希望学生学完这门课程后能够从系统的角度看待计算机不再简单地以主频论性能或者简单地把用户体验归结于CPU的单项性能。二是掌握计算机体系结构的若干概念。计算机体系结构中的概念很多虽然抽象但是必须掌握。比如计算机体系结构的四大设计原则指令系统结构处理器流水线等等。三是掌握一些重点知识并具备一些重点能力。主要包括计算机的ABI接口存储管理中的虚实地址转换过程通过IO地址空间扫描进行IO设备初始化计算机系统的启动过程重要总线如AXI总线、内存总线、PCIE总线的信号及其时序用Verilog编写RTL代码的能力先行进位加法器的逻辑两位一乘补码乘法器逻辑用Perf进行性能分析的能力等等。
本书第一部分为引言介绍体系结构研究内容、主要性能指标、发展趋势以及设计原则。计算机体系结构Computer Architecture是描述计算机各组成部分及其相互关系的一组规则和方法是程序员所看到的计算机属性。计算机体系结构的主要研究内容包括指令系统结构Instruction Set Architecture简称ISA和计算机组织结构Computer Organization。微体系结构Micro-architecture是微处理器的组织结构并行体系结构是并行计算机的组织结构。冯·诺依曼结构的存储程序和指令驱动执行原理是现代计算机体系结构的基础。
本书第二部分介绍以指令系统结构为核心的软硬件界面包括指令系统总体介绍、指令集结构、异常与中断、存储管理、软硬件协同等内容。贯穿该部分内容的一个核心思想是建立高级语言如C语言与指令系统结构的关系。例如C语言的语句与指令系统的关系算术语句可直接映射为相关运算指令for循环映射为条件跳转switch语句映射为跳转索引和跳转表等操作系统中地址空间的组织与指令访问内存的关系静态全局变量映射到地址空间的静态数据区、局部变量映射到堆栈区、动态分配的数据则映射到进程空间的堆中操作系统中进程和线程的表示及切换在指令和地址映射方面的具体体现敲击键盘和移动鼠标等事件如何通过指令系统的外部中断传递到CPU以及指令系统对操作系统处理外部中断的必要支持等等。
本书第三部分介绍计算机硬件结构。该部分的核心思想是搞清楚计算机内部包括CPU、GPU、内存、IO之间是如何协同完成软件规定的各种操作的。例如在计算机开机过程中BIOS完成硬件初始化后把操作系统从硬盘拷贝到内存执行的过程中南北桥与CPU是如何配合的CPU和GPU是如何协同操作完成计算机屏幕显示的在显示过程中哪些活是CPU干的哪些活是GPU干的以太网接口、USB接口等各种接口的驱动在硬件上的具体体现是什么等等。
本书第四部分介绍微结构。该部分的核心思想是建立指令系统和晶体管之间的“桥梁”。微结构是决定CPU性能的关键因素。由于微结构是“计算机体系结构”硕士课程的主要内容因此本科课程的微结构内容在追求系统地介绍有关概念的基础上重点把先行进位加法器和五级静态流水线讲透希望学生通过对先行进位加法器、五级静态流水线、简单转移猜测和高速缓存原理的深入了解举一反三地了解微结构的实现方式。微结构中动态流水线、乱序执行和多发射等内容只做概念性的介绍。
本书第五部分介绍并行处理结构。应用程序的并行行为是并行处理的基础现代计算机通过多层次的并行性开发来提高性能。并行处理编程模型包括消息传递模型如MPI和共享存储模型如OpenMP等。多核处理器的设计需要考虑存储一致性模型、高速缓存一致性协议、片上互连、多核同步等核心问题。
本书第六部分介绍计算机的性能分析方法。性能不是由一两个具体指标(如主频)决定的,而是若干因素综合平衡的结果;性能评测也没有绝对合理公平的办法,不同的计算机对不同的应用适应性不一样,对某类应用甲计算机比乙计算机性能高,对另外一类应用可能反之。巨大的设计空间和工作负载的多样性,导致计算机系统的性能分析和评价成为一个非常艰巨的任务。计算机性能分析的主要方法包括理论建模,用模拟器进行性能模拟,以及对实际系统进行性能评测等。
上述面面俱到的课程安排主要是考虑体系结构学科的完整性,但本科课程重点是软硬件界面及计算机硬件结构。对于一般高校,并行处理结构和计算机系统性能分析可以不讲。
在选修本课程前学生应对C语言程序设计、数字逻辑电路有一定的基础。本课程试图说明一个完整的计算系统的工作原理其中涉及部分操作系统的知识。为了有更好的理解学生还可以同时选修操作系统课程。课程中的实例和原理介绍以LoongArch体系结构为主。与传统课程中讲授的X86体系结构相比LoongArch结构相对简单明晰而又不失全面。学生可以通过配套的实验课程自底而上构建自己的计算机系统包括硬件、操作系统以及应用软件从而对“如何造计算机”有更深刻的认识。
\newpage

13
05-online-version.Rmd Normal file
View File

@@ -0,0 +1,13 @@
# 关于本书的在线版本{-}
在第三版的改版过程中作者们引入了一个创新尝试试图将本书打造为一本活的教科书。具体来说我们采用以文本为基础的rmarkdown格式编辑书本内容相关的工具说明参见[bookdown](https://bookdown.org)用git对其进行版本管理并在互联网进行开源维护。在相应的网站上还会提供出版社提供的与纸质版本一致的电子版本以及相关的参考课件PPT和其他补充资源。我们认为这么做有几个好处
* 文字、图片和参考课件等素材的开放更方便教学使用。通过开源本书,我们期望能够它得到更广泛的采用,得到更多的批评指正意见,使得它能够更快成熟。
* 方便的版本管理系统有助于及时吸收对本书的勘误和改进。一方面,读者可以通过项目的问题管理系统或者其他渠道反馈问题,被接纳后会立即反映到在线的版本中,不必等待下一次改版印刷周期。另一方面,作者们也可以将之前对由于时间仓促未来得及完善的内容进行补充完善,或者根据产业的发展需求对内容进行适当调整。
* 新的格式能够提供更丰富的表现形式。在rmarkdown文本的基础上系统可以自动生成HTML、word和PDF等各种格式的发布版本扩大适用范围。后续还可以利用其中某些格式来实现传统纸质书本无法做到的实时交互等功能。 当然限于rmarkdown/bookdown目前的表现能力以及作者们对其的应用水平在线版本生成的发布版本排版细节质量上很可能比不上出版社提供的、与纸质版本一致的原始电子文件阅读体验上也不能替代纸质版本。有条件的读者仍然可以选择由机械工业出版社出版发行的纸质版本。
由于工具的限制,在线版本和纸质版本的版面效果并非完全一致。目前图表的编号也不一定一一对应,部分纸质书的表可能用图来代替。后续随着一些修订内容的添加,在线版本的文字和纸质版本也会有所差别。
本书内容的开源离不开出版社、龙芯中科技术股份有限公司和作者们的支持在此表示感谢。出版社提供了精心排版后的电子版本和相应资源文件并同意开放这些资源。龙芯中科技术股份有限公司提供了在线版本的一份web服务器资源以及部分经费支持。作者们接受了可能的出版收益损失。
本书电子版也得到了中国科学院大学研究生程轶涵、穆热迪力、徐淮、叶锦鹏、王铭剑按姓氏拼音顺序等同学的大力支持他们协助完成了rmarkdown格式部分源代码的编辑和校对在此表示感谢

373
11-introduction.Rmd Normal file
View File

@@ -0,0 +1,373 @@
```{=latex}
% This chunk will only be processed when producing LaTeX output.
% The \frontmatter command makes the pages numbered in lowercase roman,
% and makes chapters not numbered, although each chapters title
% appears in the table of contents; if you use other sectioning
% commands here, use the *-version (see Sectioning).
% The \mainmatter command changes the behavior back to the expected
% version, and resets the page number.
% The \backmatter command leaves the page numbering alone but switches
% the chapters back to being not numbered.
\mainmatter
```
# (PART) 引言 {-}
# 引言
要研究怎么造计算机硬件方面要理解计算机组成原理和计算机体系结构软件方面要理解操作系统和编译原理。计算机体系结构就是研究怎么做CPU的核心课程。信息产业的主要技术平台都是以中央处理器Central Processing Unit简称CPU和操作系统Operating System简称OS为核心构建起来的如英特尔公司的X86架构CPU和微软公司的Windows操作系统构成的Wintel平台ARM公司的ARM架构CPU和谷歌公司的Android操作系统构成的“AA”平台。龙芯正在致力于构建独立于Wintel和AA体系的第三套生态体系。
## 计算机体系结构的研究内容
计算机体系结构研究内容涉及的领域非常广泛纵向以指令系统结构和CPU的微结构为核心向下到晶体管级的电路结构向上到应用程序编程接口Application Programming Interface简称API横向以个人计算机和服务器的体系结构为核心低端到手持移动终端和微控制器Micro-Controller Unit简称MCU的体系结构高端到高性能计算机High Performance Computer简称HPC的体系结构。
### 一以贯之
为了说明计算机体系结构研究涉及的领域我们看一个很简单平常的问题为什么我按一下键盘PPT会翻一页这是一个什么样的过程在这个过程中应用程序WPS、操作系统Windows或Linux、硬件系统、CPU、晶体管是怎么协同工作的
下面介绍用龙芯CPU构建的系统实现上述功能的原理性过程。
按一下键盘键盘会产生一个信号送到南桥芯片南桥芯片把键盘的编码保存在南桥内部的一个寄存器中并向处理器发出一个外部中断信号。该外部中断信号传到CPU内部后把CPU中一个控制寄存器的某一位置为“1”表示收到了外部中断。CPU中另外一个控制寄存器有屏蔽位来确定是否处理这个外部中断信号。
屏蔽处理后的中断信号被附在一条译码后的指令上送到重排序缓冲Re-Order Buffer简称ROB。外部中断是例外Exception也称“异常”的一种发生例外的指令不会被送到功能部件执行。当这条指令成为重排序缓冲的第一条指令时CPU处理例外。重排序缓冲为了给操作系统一个精确的例外现场处理例外前要把例外指令前面的指令都执行完后面的指令都取消掉。
重排序缓冲向所有的模块发出一个取消信号取消该指令后面的所有指令修改控制寄存器把系统状态设为核心态保存例外原因、发生例外的程序计数器Program Counter简称PC等到指定的控制寄存器中然后把程序计数器的值置为相应的例外处理入口地址进行取指LoongArch中例外的入口地址计算规则可以参见其体系结构手册
处理器跳转到相应的例外处理器入口后执行操作系统代码操作系统首先保存处理器现场包括寄存器内容等。保存现场后操作系统向CPU的控制寄存器读例外原因发现是外部中断例外就向南桥的中断控制器读中断原因读的同时清除南桥的中断位。读回来后发现中断原因是有人敲了空格键。
操作系统接下来要查找读到的空格是给谁的有没有进程处在阻塞状态等键盘输入。大家都学过操作系统的进程调度知道进程至少有三个状态运行态、阻塞态、睡眠态进程在等IO输入时处在阻塞态。操作系统发现有一个名为WPS的进程处于阻塞态这个进程对空格键会有所响应就把WPS唤醒。
WPS被唤醒后处在运行状态。发现操作系统传过来的数据是个键盘输入空格表示要翻页。WPS就把下一页要显示的内容准备好调用操作系统中的显示驱动程序把要显示的内容送到显存由图形处理器Graphic Processing Unit简称GPU通过访问显存空间刷新屏幕。达到了翻一页的效果。
再看一个问题如果在翻页的过程中发现翻页过程非常卡顿即该计算机在WPS翻页时性能较低可能是什么原因呢首先得看看系统中有没有其他任务在运行如果有很多任务在运行这些任务会占用CPU、内存带宽、IO带宽等资源使得WPS分到的资源不够造成卡顿。如果系统中没有其他应用与WPS抢资源还会卡顿那是什么原因呢多数人会认为是CPU太慢需要升级。实际上在WPS翻页时CPU干的活不多。一种可能是下一页包含很多图形尤其是很多矢量图需要GPU画出来GPU忙不过来了。另外一种可能是要显示的内容数据量大要把大量数据从WPS的应用程序空间传给GPU使用的专门空间内存带宽不足导致不能及时传输。在独立显存的情况下数据如何从内存传输到显存有两种不同的机制由CPU从内存读出来再写到显存需要CPU具有专门的IO加速功能因为显存一般是映射在CPU的IO空间不通过CPU通过直接内存访问Direct Memory Access简称DMA的方式直接从内存传输到显存会快得多。
“计算机体系结构”课程是研究怎么造计算机,而不是怎么用计算机。我们不是学习驾驶汽车,而是学习如何造汽车。一个计算机体系结构设计人员就像一个带兵打仗的将领,要学会排兵布阵。要上知天文、下知地理,否则就不会排兵布阵,或者只会纸上谈兵地排兵布阵,只能贻误军国大事。对计算机体系结构设计来说,“排兵布阵”就是体系结构设计,“上知天文”就是了解应用程序、操作系统、编译器的行为特征,“下知地理”就是了解逻辑、电路、工艺的特点。永远不要就体系结构论体系结构,要做到应用、系统、结构、逻辑、电路、器件的融会贯通。就像《论语》中说的“吾道一以贯之”。
```{r hierarchy1, fig.cap='通用计算机系统的层次结构', fig.align='center', out.width='50%', echo = FALSE}
knitr::include_graphics('./images/chapter1/hierarchy.png')
```
图\@ref(fig:hierarchy1)给出了常见通用计算机系统的结构层次图。该图把计算机系统分成应用程序、操作系统、硬件系统、晶体管四个大的层次。注意把这四个层次联系起来的三个界面。第一个界面是应用程序编程接口APIApplication Programming Interface也可以称作“操作系统的指令系统”介于应用程序和操作系统之间。API是应用程序的高级语言编程接口在编写程序的源代码时使用。常见的API包括C语言、Fortran语言、Java语言、JavaScript语言接口以及OpenGL图形编程接口等。使用一种API编写的应用程序经重新编译后可以在支持该API的不同计算机上运行。所有应用程序都是通过API编出来的在IT产业谁控制了API谁就控制了生态API做得好APPApplication就多。API是建生态的起点。第二个界面是指令系统ISAInstruction Set Architecture介于操作系统和硬件系统之间。常见的指令系统包括X86、ARM、MIPS、RISC-V和LoongArch等。指令系统是实现目标码兼容的关键由于IT产业的主要应用都是通过目标码的形态发布的因此ISA是软件兼容的关键是生态建设的终点。指令系统除了实现加减乘除等操作的指令外还包括系统状态的切换、地址空间的安排、寄存器的设置、中断的传递等运行时环境的内容。第三个界面是工艺模型介于硬件系统与晶体管之间。工艺模型是芯片生产厂家提供给芯片设计者的界面除了表达晶体管和连线等基本参数的SPICESimulation Program with Integrated Circuit Emphasis模型外该工艺所能提供的各种IP也非常重要如实现PCIE接口的物理层简称PHY等。
需要指出的是在API和ISA之间还有一层应用程序二进制接口Application Binary Interface简称ABI。ABI是应用程序访问计算机硬件及操作系统服务的接口由计算机的用户态指令和操作系统的系统调用组成。为了实现多进程访问共享资源的安全性处理器设有“用户态”与“核心态”。用户程序在用户态下执行操作系统向用户程序提供具有预定功能的系统调用函数来访问只有核心态才能访问的硬件资源。当用户程序调用系统调用函数时处理器进入核心态执行诸如访问IO设备、修改处理器状态等只有核心态才能执行的指令。处理完系统调用后处理器返回用户态执行用户代码。相同的应用程序二进制代码可以在相同ABI的不同计算机上运行。
学习计算机体系结构的人一定要把图\@ref(fig:hierarchy1)装在心中。从一般意义上说计算机体系结构的研究内容包括指令系统结构、硬件系统结构和CPU内部的微结构。但做体系结构设计而上不懂应用和操作系统下不懂晶体管级行为就像带兵打仗排兵布阵的人不知天文、不晓地理是做不好体系结构的。首先指令系统就是从应用程序算法中抽取出来的“算子”。只有对应用程序有深入的了解才能决定哪些事情通过指令系统由硬件直接实现哪些事情通过指令组合由软件实现。其次硬件系统和CPU的微结构要针对应用程序的行为进行优化。如针对媒体处理等流式应用需要通过预取提高性能CPU的高速缓存就是利用了应用程序访存的局部性CPU的转移猜测算法就是利用了应用程序转移行为的重复性和相关性CPU的内存带宽设计既要考虑CPU本身的访存需求也要考虑由显示引起的GPU访问内存的带宽需求。再次指令系统和CPU微结构的设计要充分考虑操作系统的管理需求。如操作系统通过页表进行虚存管理需要CPU实现TLBTranslation Lookaside Buffer对页表进行缓存并提供相应的TLB管理指令CPU实现多组通用寄存器高速切换的机制有利于加速多线程切换CPU实现多组控制寄存器和系统状态的高速切换机制有利于加速多操作系统切换。最后计算机中主要的硬件实体如CPU、GPU、南北桥、内存等都是通过晶体管来实现的只有对晶体管行为有一定的了解才能在结构设计阶段对包括主频、成本、功耗在内的硬件开销进行评估。如高速缓存的容量是制约CPU主频和面积的重要因素多发射结构的发射电路是制约主频的重要因素在微结构设计时都是进行权衡取舍的重要内容。
### 什么是计算机
什么是计算机?大多数人认为计算机就是我们桌面的电脑,实际上计算机已经深入到我们信息化生活的方方面面。除了大家熟知的个人电脑、服务器和工作站等通用计算机外,像手机、数码相机、数字电视、游戏机、打印机、路由器等设备的核心部件都是计算机,都是计算机体系结构研究的范围。也许此刻你的身上就有好几台计算机。
看几个著名的计算机应用的例子。比如说美国国防部有一个ASCIAccelerated Strategic Computing Initiative计划为核武器模拟制造高性能计算机。20世纪90年代拥有核武器的国家签订了全面禁止核试验条约凡是签这个条约的国家都不能进行核武器的热试验或者准确地说不能做“带响”的核武器试验。这对如何保管核武器提出了挑战核武器放在仓库里不能做试验这些核武器放了一百年以后拿出来还能不能用会不会放着放着自己炸起来想象一下一块铁暴露在空气中一百年会锈成什么样子。这就需要依靠计算机模拟来进行核武器管理核武器的数字模拟成为唯一可以进行的核试验这种模拟需要极高性能的计算机。据美国国防部估计为了满足2010年核管理的需要需要每秒完成$10^{16}\sim{}10^{17}$次运算的计算机。现在我们桌面电脑的频率在1GHz的量级词头“G”表示$10^9$加上向量化、多发射和多核的并行现在的先进通用CPU性能大约在$10^{11}$的运算量级,即每秒千亿次运算,$10^{16}$运算量级就需要10万个CPU耗电几十兆瓦。美国在2008年推出的世界上首台速度达到PFLOPS每秒千万亿次运算其中词头“P”表示$10^{15}$FLOPS表示每秒浮点运算次数的高性能计算机Roadrunner就用于核模拟。高性能计算机的应用还有很多。例如波音777是第一台完全用计算机模拟设计出来的飞机还有日本的地球模拟器用来模拟整个地球的地质活动以进行地震方面的研究。高性能计算已经成为除了科学实验和理论推理外的第三种科学研究手段。
计算机的另外一个极端应用就是手机手机也是计算机的一种。现在的手机里至少有一个CPU有的甚至有几个。
希望大家建立一个概念,计算机不光是桌面上摆的个人计算机,它可以大到一个厅都放不下,需要专门为它建一个电站来供电,也可以小到揣在我们的兜里,充电两个小时就能用一整天。不管这个计算机的规模有多大,都是计算机体系结构的研究对象。计算机是为了满足人们各种不同的计算需求设计的自动化计算设备。随着人类科技的进步和新需求的提出,最快的计算机会越来越大,最小的计算机会越来越小。
### 计算机的基本组成
我们从小就学习十进制的运算0、1、2、3、4、5、6、7、8、9十个数字逢十进一。计算机中使用二进制只有0和1两个数字逢二进一。为什么用二进制不用我们习惯的十进制呢因为二进制最容易实现。自然界中二值系统非常多电压的高低、水位的高低、门的开关、电流的有无等等都可以组成二值系统都可以用来做计算机。二进制最早是由莱布尼茨发明的冯·诺依曼最早将二进制引入计算机的应用而且计算机里面的程序和数据都用二进制。从某种意义上说中国古人的八卦也是一种二进制。
计算机的组成非常复杂但其基本单元非常简单。打开一台PC的机箱可以发现电路板上有很多芯片。如图\@ref(fig:device-to-chip)所示一个芯片就是一个系统由很多模块组成如加法器、乘法器等而一个模块由很多逻辑门组成如非门、与门、或门等逻辑门由晶体管组成如PMOS管和NMOS管等晶体管则通过复杂的工艺过程形成。所以计算机是一个很复杂的系统由很多可以存储和处理二进制运算的基本元件组成。就像盖房子一样再宏伟、高大的建筑都是由基本的砖瓦、钢筋水泥等材料搭建而成的。在CPU芯片内部一根头发的宽度可以并排走上千根导线购买一粒大米的钱可以买上千个晶体管。
```{r device-to-chip, fig.cap='芯片、模块、逻辑门、晶体管和器件', fig.align='center', out.width="50%", echo = FALSE}
knitr::include_graphics('./images/chapter1/device_to_chip.png')
```
现在计算机结构的基本思想是1945年匈牙利数学家冯·诺依曼结合EDVAC计算机的研制提出的因此被称为冯·诺依曼结构。
我们通过一个具体的例子来介绍冯·诺依曼结构。比如说求式子3×45×7的值人类是怎么计算的呢先计算3×412把12记在脑子里接着计算5×735再计算123547。我们在计算过程中计算和记忆存储都在一个脑袋里但式子很长的时候需要把临时结果记在纸上
计算机的计算和记忆是分开的负责计算的部分由运算器和控制器组成称为中央处理器就是CPU负责记忆的部分称为存储器。存储器里存了两样东西一是存了几个数3、4、5、7、12、35、47这个叫作数据二是存储了一些指令。也就是说操作对象和操作序列都保存在存储器里。
我们来看看计算机是如何完成3×45×7的计算的。计算机把3、4、5、7这几个数都存在内存中计算过程中的临时结果12、35和最终结果47也存在内存中此外计算机还把对计算过程的描述程序也存在内存中程序由很多指令组成。表\@ref(tab:program-and-data)a给出了内存中在开始计算前数据和指令存储的情况假设数据存在100号单元开始的区域程序存在200号单元开始的区域。
```{r program-and-data, echo = FALSE, message=FALSE, tab.cap='程序和数据存储在一起'}
autonum <- run_autonum(seq_id = "tab", bkm = "program-and-data", bkm_all = TRUE)
readr::read_csv('./materials/chapter1/program_and_data.csv') %>%
flextable() %>%
set_caption(caption="程序和数据存储在一起", autonum = autonum) %>%
valign(i = NULL, j = NULL, valign = "top", part = "body") %>%
delete_part(part='header') %>%
theme_box() %>%
autofit()
```
计算机开始运算过程如下CPU从内存200号单元取回第一条指令这条指令就是“读取100号单元”根据这条指令的要求从内存把“3”读进来再从内存201号单元取下一条指令“读取101号单元”然后根据这条指令的要求从内存把“4”读进来再从内存202号单元取下一条指令“两数相乘”乘出结果为“12”再从内存203号单元取下一条指令“存入结果到104号单元”把结果“12”存入104号单元。如此往复直到程序结束。表\@ref(tab:program-and-data)b是程序执行结束时内存的内容。
大家看看刚才这个过程比我们大脑运算烦琐多了。我们大脑算三步就算完了而计算机需要那么多步又取指令又取数据挺麻烦的。这就是冯·诺依曼结构的基本思想数据和程序都在存储器中CPU从内存中取指令和数据进行运算并把结果也放到内存中。把指令和数据都存在内存中可以让计算机按照事先规定的程序自动地完成运算是实现图灵机的一种简单方法。冯·诺依曼结构很好地解决了自动化的问题把程序放在内存里一条条取进来自己就做起来了不用人来干预。如果没有这样一种自动执行的机制让人去控制计算机做什么运算拨一下开关算一下程序没有保存在内存中而是保存在人脑中就成算盘了。计算机的发展日新月异但70多年过去了还是使用冯·诺依曼结构。尽管冯·诺依曼结构有很多缺点例如什么都保存在内存中使访存成为性能瓶颈但我们还是摆脱不了它。
虽然经过了长期的发展以存储程序和指令驱动执行为主要特点的冯·诺依曼结构仍是现代计算机的主流结构。笔者面试研究生的时候经常问一个问题冯·诺依曼结构最核心的思想是什么结果很多研究生都会答错。有人说是由计算器、运算器、存储器、输入、输出五个部分组成有人说是程序计数器导致串行执行等等。实际上冯·诺依曼结构就是数据和程序都存在存储器中CPU从内存中取指令和数据进行运算并且把结果也放在内存中。概括起来就是存储程序和指令驱动执行。
## 衡量计算机的指标
怎么样来衡量一台计算机的好坏呢?计算机的衡量指标有很多,其中性能、价格和功耗是三个主要指标。
### 计算机的性能
计算机的第一个重要指标就是性能。前面说的用来进行核模拟的高性能计算机对一个国家来说具有战略意义,算得越快越好。又如中央气象台用于天气预报的计算机每天需要根据云图数据解很复杂的偏微分方程,要是计算机太慢,明天的天气预报后天才算出来,那就叫天气后报,没用了。所以性能是计算机的首要指标。
什么叫性能性能的最本质定义是“完成一个任务所需要的时间”。对中央气象台的台长来说性能就是算明天的天气预报需要多长时间。如果甲计算机两个小时能算完24小时的天气预报乙计算机一个小时就算完显然乙的性能比甲好。完成一个任务所需要的时间可以由完成该任务需要的指令数、完成每条指令需要的拍数以及每拍需要的时间三个量相乘得到。完成任务需要的指令数与算法、编译器和指令的功能有关每条指令需要的拍数与编译器、指令功能、微结构设计相关每拍需要的时间也就是时钟周期与结构、电路设计、工艺等因素有关。
完成一个任务的指令数首先取决于算法。我们刚开始做龙芯的时候计算所的一个老研究员讲过一个故事。说20世纪六七十年代的时候美国的计算机每秒可以算一亿次苏联的计算机每秒算一百万次结果算同一个题目苏联的计算机反而先算完因为苏联的算法厉害。以对N个数进行排序的排序算法为例冒泡排序算法的运算复杂度为O(N*N)快速排序算法的运算复杂度为O(N*log2(N))如果N为1024则二者执行的指令数差100倍。
编译器负责把用户用高级语言如C、Java、JavaScript等写的代码转换成计算机硬件能识别的、由一条条指令组成的二进制码。转换出来的目标码的质量的好坏在很大程度上影响完成一个任务的指令数。在同一台计算机上运行同一个应用程序用不同的编译器或不同的编译选项运行时间可能有几倍的差距。
指令系统的设计对完成一个任务的指令数影响也很大。例如要不要设计一条指令直接完成一个FFT函数还是让用户通过软件的方法来实现FFT函数这是结构设计的一个取舍直接影响完成一个任务的指令数。体系结构有一个常用的指标叫MIPSMillion Instructions Per Second,即每秒执行多少百万条指令。看起来很合理的一个指标关键是一条指令能干多少事讲不清楚。如果甲计算机一条指令就能做一个1024点的FFT而乙计算机一条指令就算一个加法。两台计算机比MIPS值就没什么意义。因此后来有人把MIPS解释为Meaningless Indication of Processor Speed。现在常用一个性能指标MFLOPSMillion FLoating point Operations Per Second即每秒做多少百万浮点运算也有类似的问题。如果数据供不上运算能力再强也没有用。
在指令系统确定后结构设计需要重点考虑如何降低每条指令的平均执行周期Cycles Per Instruction简称CPI或提高每个时钟周期平均执行的指令数Instructions Per Cycle简称IPC这是处理器微结构研究的主要内容。CPI就是一个程序执行所需要的总的时钟周期数除以它所执行的总指令数反之则是IPC。处理器的微结构设计对IPC的影响很大采用单发射还是多发射结构采用何种转移猜测策略以及什么样的存储层次设计都直接影响IPC。表\@ref(tab:spec-cpu)给出了龙芯3A1000和龙芯3A2000处理器运行SPEC CPU2000基准程序的分值。两个CPU均为64位四发射结构主频均为1GHz两个处理器运行的二进制码相同但由于微结构不同IPC差异很大总体上说3A2000的IPC是3A1000的2\~3倍。
```{r spec-cpu, echo = FALSE, message=FALSE, tab.cap='龙芯3A1000和龙芯3A2000的SPEC CPU2000分值'}
autonum <- run_autonum(seq_id = "tab", bkm = "spec-cpu", bkm_all = TRUE)
readr::read_csv('./materials/chapter1/spec_cpu2000.csv') %>%
flextable() %>%
set_caption(caption="龙芯3A1000和龙芯3A2000的SPEC CPU2000分值", autonum = autonum) %>%
valign(i = NULL, j = NULL, valign = "top", part = "body") %>%
delete_part(part='header') %>%
add_header_row(values=c('SPEC程序', '运行时间/秒','分值','运行时间/秒', '分值'), colwidths=c(1,1,1,1,1)) %>%
add_header_row(values=c('SPEC程序', '3A1000','3A2000'), colwidths=c(1,2,2)) %>%
merge_v(part='header') %>%
theme_box() %>%
autofit()
```
主频宏观上取决于微结构设计微观上取决于工艺和电路设计。例如Pentium III的流水线是10级Pentium IV为了提高主频一发猛就把流水级做到了20级还恨不得做到40级。Intel的研究表明只要把Cache和转移猜测表的容量增加一倍就能抵消流水线增加一倍引起的流水线效率降低。又如从电路的角度来说甲设计做64位加法只要1ns而乙设计需要2ns那么甲设计比乙设计主频高一倍。相同的电路设计用不同的工艺实现出来的主频也不一样先进工艺晶体管速度快主频高。
可见在一个系统中不同层次有不同的性能标准,很难用一项单一指标刻画计算机性能的高低。大家可能会说,从应用的角度看性能是最合理的。甲计算机两个小时算完明天的天气预报,乙计算机只要一小时,那乙的性能肯定比甲的好,这总对吧。也对也不对。只能说,针对算明天的天气预报这个应用,乙计算机的性能比甲的好。但对于其他应用,甲的性能可能反而比乙的好。
### 计算机的价格
计算机的第二个重要指标是价格。20世纪80年代以来电脑越来越普及就是因为电脑的价格在不断下降从一味地追求性能Performance per Second到追求性能价格比Performance per Dollar。现在中关村卖个人电脑的企业利润率比卖猪饲料的还低得多。
不同的计算机对成本有不同的要求。用于核模拟的超级计算机主要追求性能一个国家只需要一两台这样的高性能计算机不太需要考虑成本的问题。相反大量的嵌入式应用为了降低功耗和成本可能牺牲一部分性能因为它要降低功耗和成本。而PC、工作站、服务器等介于两者之间它们追求性能价格比的最优设计。
计算机的成本跟芯片成本紧密相关计算机中芯片的成本包括该芯片的制造成本和一次性成本NRE如研发成本的分摊部分。生产量对于成本很关键。随着不断重复生产工程经验和工艺水平都不断提高生产成本可以持续地降低。例如做衣服刚开始可能做100件就有10件是次品以后做1000件也不会做坏1件了衣服的总体成本就降低了。产量的提高能够加速学习过程提高成品率还可以降低一次性成本。
随着工艺技术的发展为了实现相同功能所需要的硅面积指数级降低使得单个硅片的成本指数级降低。但成本降到一定的程度就不怎么降了甚至还会有缓慢上升的趋势这是因为厂家为了保持利润不再生产和销售该产品转而生产和销售升级产品。现在的计算机工业是一个不断出售升级产品的工业。买一台计算机三到五年后就需要换一台新的计算机。CPU和操作系统厂家一起通过一些技术手段让一般用户五年左右就需要换掉电脑。这些手段包括控制芯片老化寿命不再更新老版本的操作系统而新操作系统的文档格式不与老的保持兼容发明新的应用使没有升级的计算机性能不够等等。主流的桌面计算机CPU刚上市时价格都比较贵然后逐渐降低降到200美元以下就逐步从主流市场中退出。芯片公司必须不断推出新的产品才能保持盈利。但是总的来说对同一款产品成本曲线是不断降低的。
### 计算机的功耗
计算机的第三个重要指标是功耗。手机等移动设备需要用电池供电。电池怎么用得久呢低功耗就非常重要。高性能计算机也要低功耗它们的功耗都以兆瓦MW计。兆瓦是什么概念我们上大学时在宿舍里煮方便面用的电热棒的功率是1000W左右几个电热棒一起用宿舍就停电了。1MW就是1000个电热棒的功率。曙光5000高性能计算机在中科院计算所的地下室组装调试时运行一天电费就是一万多块钱比整栋楼的电费还要高。计算机里产生功耗的地方非常多CPU有功耗内存条有功耗硬盘也有功耗最后为了把这些热量散发出去制冷系统也要产生功耗。近几年来性能功耗比Performance per Watt成为计算机非常重要的一个指标。
芯片功耗是计算机功耗的重要组成部分。芯片的功耗主要由晶体管工作产生,所以先来看晶体管的功耗组成。图\@ref(fig:power)是一个反相器的功耗模型。反相器由一个PMOS管和一个NMOS管组成。其功耗主要可以分为三类开关功耗、短路功耗和漏电功耗。开关功耗主要是电容的充放电比如当输出端从0变到1时输出端的负载电容从不带电变为带电有一个充电的过程当输出端从1变到0时电容又有一个放电的过程。在充电、放电的过程中就会产生功耗。开关功耗既和充放电电压、电容值有关还和反相器开关频率相关。
```{r power, fig.cap='动态功耗和短路功耗', fig.align='center', out.width='50%', echo = FALSE}
knitr::include_graphics('./images/chapter1/power.png')
```
短路功耗就是P管和N管短路时产生的功耗。当反相器的输出为1时P管打开N管关闭输出为0时则N管开P管闭。但在开、闭的转换过程中电流的变化并不像理论上那样是一个方波而是有一定的斜率。在这个变化的过程中会出现N管和P管同时部分打开的情况这时候就产生了短路功耗。
漏电功耗是指MOS管不能严格关闭时发生漏电产生的功耗。以NMOS管为例如果栅极有电N管就导通否则N管就关闭。但在纳米级工艺下MOS管沟道很窄即使栅极不加电压源极和漏极之间也有电流另外栅极下的绝缘层很薄只有几个原子的厚度从栅极到沟道也有漏电流。漏电流大小随温度升高呈指数增加因此温度是集成电路的第一杀手。
优化芯片功耗一般从两个角度入手——动态功耗优化和静态功耗优化。升级工艺是降低动态功耗的有效方法因为工艺升级可以降低电容和电压从而成倍地降低动态功耗。芯片工作频率跟电压成正比在一定范围内如5%\~10%降低频率可以同比降低电压因此频率降低10%动态功耗可以降低30%左右功耗和电压的平方成正比和频率成正比。可以通过选择低功耗工艺降低芯片静态功耗集成电路生产厂家一般会提供高性能工艺和低功耗工艺低功耗工艺速度稍慢一些但漏电功耗成数量级降低。在结构和逻辑设计时避免不必要的逻辑翻转可以有效降低翻转率例如在某一流水级没有有效工作时保持该流水级为上一拍的状态不翻转。在物理设计时可以通过门控时钟降低时钟树翻转功耗。在电路设计时可以采用低摆幅电路降低功耗例如工作电压为1V时用0.4V表示逻辑0用0.6V表示逻辑1摆幅就只有0.2V,大大降低了动态功耗。
芯片的功耗是一个全局量,与每一个设计阶段都相关。功耗优化的层次从系统级、算法级、逻辑级、电路级,直至版图和工艺级,是一个全系统工程。近几年在降低功耗方面的研究非常多,和以前片面追求性能不同,降低功耗已经成了芯片设计一个最重要的任务。
信息产业是一个高能耗产业,信息设备耗电越来越多。根据冯·诺依曼的公式,现在一位比特翻转所耗的电是理论值的$10^{10}$倍以上。整个信息的运算过程是一个从无序到有序的过程,这个过程中它的熵变小,是一个吸收能量的过程。但事实上,它真正需要的能量很少,因为我们现在用来实现运算的手段不够先进,不够好,所以才造成了$10^{10}$倍这么高的能耗,因此我们还有多个数量级的优化空间。这其中需要一些原理性的革命,材料、设计上都需要很大的革新,即使目前在用的晶体管,优化空间也是很大的。
有些应用还需要考虑计算机的其他指标例如使用寿命、安全性、可靠性等。以可靠性为例计算机中用的CPU可以分为商用级、工业级、军品级、宇航级等。比如北斗卫星上面的计算机价格贵点没关系慢一点也没关系关键是要可靠我国放了不少卫星有的就是由于其中的元器件不可靠报废了。因此在特定领域可靠性要求非常高。再如银行核心业务用的计算机也非常在乎可靠性只要一年少死机一次价格贵一千万元也没关系对银行来说核心计算机死机所有的储户就取不了钱这损失太大了。
因此考评一个计算机好坏的指标非常多。本课程作为本科计算机体系结构基础课程,在以后的章节中主要关注性能指标。
## 计算机体系结构的发展
从事一个领域的研究,要先了解这个领域的发展历史。计算机体系结构是不断发展的。
20世纪五六十年代由于工艺技术的限制计算机都做得很简单计算机体系结构主要研究怎么做加减乘除Computer Architecture基本上等于Computer Arithmetic。以后我们会讲到先行进位加法器、Booth补码乘法算法、华莱士树等主要是那时候的研究成果。现在体系结构的主要矛盾不在运算部件CPU中用来做加减乘除的部件只占CPU中硅面积的很小一部分CPU中的大部分硅面积用来给运算部件提供足够的指令和数据。
20世纪七八十年代的时候以精简指令集Reduced Instruction Set Computer简称RISC兴起为标志指令系统结构Instruction Set Architecture简称ISA成为计算机体系结构的研究重点。笔者上大学的时候系统结构老师告诉我们计算机体系结构就是指令系统结构是计算机软硬件之间的界面。
20世纪90年代以后计算机体系结构要考虑的问题把CPU、存储系统、IO系统和多处理器也包括在内研究的范围大大地扩展了。到了21世纪网络就是计算机计算机体系结构要覆盖的面更广了向上突破了软硬件界面需要考虑软硬件的紧密协同向下突破了逻辑设计和工艺实现的界面需要从晶体管的角度考虑结构设计。一方面计算机系统的软硬件界面越来越模糊。按理说指令系统把计算机划分为软件和硬件是清楚的但现在随着虚拟机和二进制翻译系统的出现软硬件的界面模糊了。当包含二进制动态翻译的虚拟机执行一段程序时这段程序可能被软件执行也有可能直接被硬件执行可能被并行化也可能没有被并行化。因此计算机结构设计需要更多地对软件和硬件进行统筹考虑。另一方面随着工艺技术的发展计算机体系结构需要更多地考虑电路和工艺的行为。工艺技术发展到纳米级体系结构设计不仅要考虑晶体管的延迟而且要考虑连线的延迟很多情况下即使逻辑路径很短如果连线太长也会导致其成为关键路径。
工艺技术的发展和应用需求的提高是计算机体系结构发展的主要动力。首先半导体工艺技术和计算机体系结构技术互为动力、互相促进推动着计算机工业的蓬勃发展。一方面半导体工艺水平的提高为计算机体系结构的设计提供了更多更快的晶体管来实现更多功能、更高性能的系统。例如20世纪60年代发展起来的虚拟存储技术通过建立逻辑地址到物理地址的映射使每个程序有独立的地址空间大大方便了编程促进了计算机的普及。但虚拟存储技术需要TLBTranslation Lookaside Buffer结构在处理器访存时进行虚实地址转换而TLB的实现需要足够快、足够多的晶体管。所以半导体工艺的发展为体系结构的发展提供了很好的基础。另一方面计算机体系结构的发展是半导体技术发展的直接动力。在2010年之前世界上最先进半导体工艺都用于生产计算机用的处理器芯片为处理器生产厂家所拥有如IBM和英特尔。其次应用需求的不断提高为计算机体系结构的发展提供了持久的动力。最早计算机都是用于科学工程计算只有少数人能够用20世纪80年代IBM把计算机摆到桌面大大促进了计算机工业发展21世纪初网络计算的普及又一次促进了计算机工业的发展。
在2010年之前计算机工业的发展主要是工艺驱动为主应用驱动为辅都是计算机工艺厂家先挖空心思发明出应用然后让大家去接受。例如英特尔跟微软为了利润而不断发明应用从DOS到Windows到Office到3D游戏每次都是他们发明了计算机的应用然后告诉用户为了满足新的应用需求需要换更好的计算机。互联网也一样没有互联网之前人们根本没有想到它能干这么多事情更没有想到互联网会成为这么大一个产业对社会的发展产生如此巨大的影响。在这个过程中当然应用是有拉动作用的但这个力量远没有追求利润的动力那么大。做计算机体系结构的人总是要问一个问题摩尔定律发展所提供的这么多晶体管可以用来干什么很少有人问满足一个特定的应用需要多少个晶体管。但在2010年之后随着计算机基础软硬件的不断成熟IT产业的主要创新从工艺转向应用。可以预计未来计算机应用对体系结构的影响将超过工艺技术成为计算机体系结构发展的首要动力。
### 摩尔定律和工艺的发展
**1.工艺技术的发展**
摩尔定律不是一个客观规律是一个主观规律。摩尔是Intel公司的创始人他在20世纪六七十年代说集成电路厂商大约18个月能把工艺提高一代即相同面积中晶体管数目提高一倍。大家就朝这个目标去努力还真做到了。所以摩尔定律是主观努力的结果是投入很多钱才做到的。现在变慢了变成23年或更长时间更新一代一个重要原因是新工艺的研发成本变得越来越高厂商收回投资需要更多的时间。摩尔定律是计算机体系结构发展的物质基础。正是由于摩尔定律的发展芯片的集成度和运算能力都大幅度提高。图\@ref(fig:ic-develop)通过一些历史图片展示了国际上集成电路和微处理器的发展历程。
```{r ic-develop, fig.cap='集成电路和微处理器的发展历程', fig.align='center', out.width='100%', echo = FALSE}
knitr::include_graphics('./images/chapter1/ic_develop.png')
```
图\@ref(fig:china-design)给出了由我国自行研制的部分计算机和微处理器的历史图片。可以看出随着工艺技术的发展计算机从一个大机房到一个小芯片运算能力大幅度提高这就是摩尔定律带来的指数式发展的效果。其中的109丙机值得提一下这台机器为“两弹一星”的研制立下了汗马功劳被称为功勋机。
```{r china-design, fig.cap='我国自行研制的计算机和微处理器', fig.align='center', out.width='100%', echo = FALSE}
knitr::include_graphics('./images/chapter1/china_design.png')
```
CMOS工艺正在面临物理极限。在21世纪之前的35年或者说在0.13μm工艺之前半导体场效应晶体管扩展的努力集中在提高器件速度以及集成更多的器件和功能到芯片上。21世纪以来器件特性的变化和芯片功耗密度成为半导体工艺发展的主要挑战。随着线宽尺度的不断缩小CMOS的方法面临着原子和量子机制的边界。一是蚀刻等问题越来越难处理可制造性问题突出二是片内漂移的问题非常突出同一个硅片内不同位置的晶体管都不一样三是栅氧晶体管中栅极下面作为绝缘层的氧化层厚度难以继续降低65nm工艺的栅氧厚度已经降至了1.2nm,也就是五个硅原子厚,漏电急剧增加,再薄的话就短路了,无法绝缘了。
工程师们通过采用新技术和新工艺来克服这些困难并继续延续摩尔定律。在90/65nm制造工艺中采用了多项新技术和新工艺包括应力硅Strained Silicon、绝缘硅SOI、铜互连、低kk指介电常数介电材料等。45/32nm工艺所采用的高k介质和金属栅材料技术是晶体管工艺技术的又一个重要突破。采用高k介质SiO2的k为3.9高k材料的介电常数在20以上如氧氮化铪硅HfSiON理论上相当于提升栅极的有效厚度使漏电电流下降到10%以下。另外高k介电材料和现有的硅栅电极并不相容采用新的金属栅电极材料可以增加驱动电流。该技术打通了通往32nm及22nm工艺的道路扫清工艺技术中的一大障碍。摩尔称此举是CMOS工艺技术中的又一里程碑将摩尔定律又延长了另一个1015年。Intel公司最新CPU上使用的三维晶体管FinFET为摩尔定律的发展注入了新的活力。
大多数集成电路生产厂家在45nm工艺之后已经停止了新工艺的研究一方面是由于技术上越来越难另一方面是由于研发成本越来越高。在32nm工艺节点以后只有英特尔、三星、台积电和中芯国际等少数厂家还在继续研发。摩尔定律是半导体产业的一个共同预测和奋斗目标但随着工艺的发展逐渐逼近极限人们发现越来越难跟上这个目标。摩尔定律在发展过程中多次被判了“死刑”20世纪90年代笔者读研究生的时候就有人说摩尔定律要终结了可是每次都能起死回生。但这次可能是真的大限到了。
摩尔定律的终结仅仅指的是晶体管尺寸难以进一步缩小并不是硅平台的终结。过去50年工艺技术的发展主要是按照晶体管不断变小这一个维度发展以后还可以沿多个维度发展例如通过在硅上“长出”新的材料来降低功耗还可以跟应用结合在硅上“长出”适合各种应用的晶体管来。此外伴随着新材料和器件结构的发展半导体制造已经转向“材料时代”。ITRS中提出的非传统CMOS器件包括超薄体SOI、能带工程晶体管、垂直晶体管、双栅晶体管、FinFET等。未来有望被广泛应用的新兴存储器件主要有磁性存储器MRAM、纳米存储器NRAM、分子存储器Molecular Memory等。新兴的逻辑器件主要包括谐振隧道二极管、单电子晶体管器件、快速单通量量子逻辑器件、量子单元自动控制器件、自旋电子器件Spintronic Storage、碳纳米管Carbon Nanotube、硅纳米线Silicon Nanowire、分子电子器件Molecular Electronic等。
**2.工艺和计算机结构**
由摩尔定律带来的工艺进步和计算机体系结构之间互为动力、互相促进。从历史上看,工艺技术和体系结构的关系已经经历了三个阶段。
第一个阶段是晶体管不够用的阶段。那时计算机由很多独立的芯片构成,由于集成度的限制,计算机体系结构不可能设计得太复杂。
第二个阶段随着集成电路集成度越来越高,摩尔定律为计算机体系结构设计提供“更多、更快、更省电”的晶体管,微处理器蓬勃发展。
“更多”指的是集成电路生产工艺在相同面积下提供了更多的晶体管来满足计算机体系结构发展的需求。“更快”指的是晶体管的开关速度不断提高提高了计算机频率。“更省电”指的是随着工艺进步工作电压降低晶体管和连线的负载电容也降低而功耗跟电压的平方成正比跟电容大小成正比。在0.13μm工艺之前工艺每发展一代电压就成比例下降例如0.35μm工艺的工作电压是3.3V0.25μm工艺的工作电压是2.5V0.18μm工艺的工作电压是1.8V0.13μm工艺的工作电压是1.2V。此外,随着线宽的缩小,晶体管和连线电容也相应变小。
这个阶段摩尔定律发展的另外一个显著特点就是处理器越来越快但存储器只是容量增加速度却没有显著提高。20世纪80年代这个问题还不突出那时内存和CPU频率都不高访问内存和运算差不多快。但是后来CPU主频不断提高存储器只增加容量不提高速度CPU的速度和存储器的速度形成剪刀差。什么叫剪刀差就是差距像张开的剪刀一样刚开始只差一点到后来越来越大。从20世纪80年代中后期开始到21世纪初体系结构研究的很大部分都在解决处理器和内存速度的差距问题甚至导致CPU的含义也发生了变化。最初CPU就是指中央处理器主要由控制器和运算器组成但是现在的CPU中80%的晶体管是一级、二级甚至三级高速缓存。摩尔定律的发展使得CPU除了包含运算器和控制器以外还包含一部分存储器甚至包括一部分IO接口在里面。
现在进入了第三个阶段,晶体管越来越多,但是越来越难用,晶体管变得“复杂、不快、不省电、不便宜”。
“复杂”指的是纳米级工艺的物理效应如线间耦合、片内漂移、可制造性问题等增加了物理设计的难度。早期的工艺线间距大连线之间干扰小纳米级工艺两根线挨得很近容易互相干扰。90nm工艺之前制造工艺比较容易控制生产出来的硅片工艺参数分布比较均匀90nm工艺之后工艺越来越难控制同一个硅片不同部分的晶体管也有快有慢叫作工艺漂移。纳米级工艺中物理设计还需要专门考虑可制造性问题以提高芯片成品率。此外晶体管数目继续以指数增长设计和验证能力的提高赶不上晶体管增加的速度形成剪刀差。
“不快”主要是由于晶体管的驱动能力越来越小,连线电容相对变大,连线延迟越来越大。再改进工艺,频率的提高也很有限了。
“不省电”有三个方面的原因。一是随着工艺的更新换代漏电功耗不断增加原来晶体管关掉以后就不导电了纳米级工艺以后晶体管关掉后还有漏电形成直流电流。二是电压不再随着工艺的更新换代而降低在0.13μm工艺之前电压随线宽而线性下降但到90nm工艺之后不论工艺怎么进步工作电压始终在1V左右降不下去了。因为晶体管的P管和N管都有一个开关的阈值电压很难把阈值电压降得太低而且阈值电压降低会增加漏电。三是纳米级工艺以后连线电容在负载电容中占主导导致功耗难以降低。
“不便宜”指的是在28nm之前随着集成度的提高由于单位硅面积的成本基本保持不变使得单个晶体管成本指数降低。如使用12英寸晶圆的90nm、65nm、45nm和28nm工艺每个晶圆的生产成本没有明显提高。14nm开始采用FinFET工艺晶圆生产成本大幅提高14nm晶圆的生产成本是28nm的两倍左右7nm晶圆的生产成本又是14nm的两倍左右。虽然单位硅面积晶体管还可以继续增加但单个晶体管成本不再指数降低甚至变贵了。
以前摩尔定律对结构研究的主要挑战在于“存储墙”问题“存储墙”的研究不知道成就了多少博士和教授。现在可研究的内容更多了存储墙问题照样存在还多了两个问题连线延迟成为主导要求结构设计更加讲究互连的局部性这种局部性对结构设计会有深刻的影响漏电功耗很突出性能功耗比取代性能价格比成为结构设计的主要指标。当然有新问题的时候就需要研究解决这些问题。第三阶段结构设计的一个特点是不得已向多核Multi-Core发展以降低设计验证复杂度、增加设计局部性、降低功耗。
### 计算机应用和体系结构
计算机应用是随时间迁移的。早期计算机的主要应用是科学工程计算,所以叫“计算”机;后来用来做事务处理,如金融系统、大企业的数据库管理;现在办公、媒体和网络已成为计算机的主要应用。
计算机体系结构随着应用需求的变化而不断变化。在计算机发展的初期处理器性能的提高主要是为了满足科学和工程计算的需求非常重视浮点运算能力每秒的运算速度是最重要的指标。人类对科学和工程计算的需求是永无止境的。高性能计算机虽然已经不是市场的主流但仍然在应用的驱动下不断向前发展并成为一个国家综合实力的重要标志。现在最快的计算机已经达到百亿亿次EFLOPS量级耗电量是几十兆瓦。如果按照目前的结构继续发展下去功耗肯定受不了怎么办呢可以结合应用设计专门的处理器来提高效率。众核Many-Core处理器和GPU现在常常被用来搭建高性能计算机美国的第一台千万亿次计算机也是用比较专用的Cell处理器做出来的。专用处理器结构结合特定算法设计芯片中多数面积和功耗都用来做运算效率高。相比之下通用处理器什么应用都能干但干什么都不是最好的芯片中百分之八十以上的晶体管都用来做高速缓存和转移猜测等为运算部件提供稳定的数据流和指令流的结构只有少量的面积用来做运算。现在高性能计算机越来越走回归传统的向量机这条道路专门做好多科学和工程计算部件这是应用对结构发展的一点启示。
计算机发展过程中的一个里程碑事件是桌面计算机/个人计算机的出现。当IBM把计算机从装修豪华的专用机房搬到桌面上时无疑是计算机技术和计算机工业的一个划时代革命一下子扩张了计算机的应用领域极大地解放了生产力。桌面计算机催生了微处理器的发展性价比成为计算机体系结构设计追求的重要目标。在桌面计算机主导计算机产业发展的二三十年从20世纪80年代到21世纪初CPU性能的快速提高和桌面应用的发展相得益彰。PC的应用在从DOS到Windows、从办公到游戏的过程中不断升级性能的要求。在这个过程中以IPC作为主要指标的微体系结构的进步和以主频作为主要指标的工艺的发展成为CPU性能提高的两大动力功劳不分轩轾。性能不断提高的微处理器逐渐蚕食了原来由中型机和小型机占领的服务器市场X86处理器现已成为服务器的主要CPU。在游戏之后PC厂家难以“发明”出新的应用失去了动员用户升级桌面计算机的持续动力PC市场开始饱和成为成熟市场。
随着互联网和媒体技术的迅猛发展网络服务和移动计算成为一种非常重要的计算模式这一新的计算模式要求微处理器具有处理流式数据类型的能力、支持数据级和线程级并行性、更高的存储和IO带宽、低功耗、低设计复杂度和设计的可伸缩性同时要求缩短芯片进入市场的周期。从主要重视运算速度到更加注重均衡的性能强调运算、存储和IO能力的平衡强调以低能耗完成大量的基于Web的服务、以网络媒体为代表的流处理等。性能功耗比成为这个阶段计算机体系结构设计的首要目标。云计算时代的服务器端CPU从追求高性能High Performance向追求高吞吐率High Throughput演变一方面给了多核CPU更广阔的应用舞台另一方面单芯片的有限带宽也限制了处理器核的进一步增加。随着云计算服务器规模的不断增加供电成为云服务器中心发展的严重障碍因此低功耗也成为服务器端CPU的重要设计目标。
### 计算机体系结构发展
前面分析了工艺和应用的发展趋势,当它们作用在计算机体系结构上时,对结构的发展产生了重大影响。计算机体系结构过去几十年都是在克服各种障碍的过程中发展的,目前计算机体系结构的进一步发展面临复杂度、主频、功耗、带宽等障碍。
1复杂度障碍
工艺技术的进步为结构设计者提供了更多的资源来实现更高性能的处理器芯片,也导致了芯片设计复杂度的大幅度增加。现代处理器设计队伍动辄几百到几千人,但设计能力的提高还是远远赶不上复杂度的提高,验证能力更是成为芯片设计的瓶颈。另外,晶体管特征尺寸缩小到纳米级给芯片的物理设计带来了巨大的挑战。纳米级芯片中连线尺寸缩小,相互间耦合电容所占比重增大,连线间的信号串扰日趋严重;硅片上的性能参数(如介电常数、掺杂浓度等)的漂移变化导致芯片内时钟树的偏差;晶体管尺寸的缩小使得蚀刻等过程难以处理,在芯片设计时就要充分考虑可制造性。总之,工艺所提供的晶体管更多了,也更“难用”了,导致设计周期和设计成本大幅度增加。
在过去六七十年的发展历程中计算机体系结构经历了一个由简单到复杂由复杂到简单又由简单到复杂的否定之否定过程。自从20世纪40年代发明电子计算机以来最早期的处理器结构由于工艺技术的限制不可能做得很复杂随着工艺技术的发展到20世纪60年代处理器结构变得复杂流水线技术、动态调度技术、向量机技术被广泛使用典型的机器包括IBM的360系列以及Cray的向量机20世纪80年代RISC技术的提出使处理器结构得到一次较大的简化X86系列从Pentium III开始把CISC指令内部翻译成若干RISC操作来进行动态调度内部流水线也采用RISC结构但后来随着深度流水、乱序执行、多发射、高速缓存、转移预测技术的实现RISC处理器结构变得越来越复杂现在的RISC微处理器普遍能允许数百条指令乱序执行如Intel的Sunny Cov最多可以容纳352条指令。目前包括超标量RISC和超长指令字Very Long Instruction Word简称VLIW在内的指令级并行技术使得处理器核变得十分复杂通过进一步增加处理器核的复杂度来提高性能已经十分有限通过细分流水线来提高主频的方法也很难再延续下去。需要探索新的结构技术来在简化结构设计的前提下充分利用摩尔定律提供的晶体管以进一步提高处理器的功能和性能。
2主频障碍
主频持续增长的时代已经结束。摩尔定律本质上是晶体管尺寸以及晶体管翻转速度变化的定律但由于商业的原因摩尔定律曾经被赋予每18个月处理器主频提高一倍的含义。这个概念是在Intel跟AMD竞争的时候提出来的。Intel的Pentium III主频不如AMD的K5/K6高但其流水线效率高实际运行程序的性能比AMD的K5/K6好于是AMD就拿主频说事跟Intel比主频Intel说主频不重要关键是看实际性能谁跑程序跑得快。后来Intel的Pentium IV处理器把指令流水线从Pentium III的10级增加到20级主频比AMD的处理器高了很多但是相同主频下比AMD性能要低两个公司反过来了这时候轮到Intel拿主频说事AMD反过来说主频不重要实际性能重要。那段时间我们确实看到Intel处理器的主频在翻番地提高。Intel曾经做过一个研究准备把Pentium IV的20级流水线再细分成40级也就是一条指令至少40拍才能做完做了很多模拟分析后得到一个结论只要把转移猜测表做大一倍、二级Cache增加一倍可以弥补流水级增加一倍引起的流水线效率降低。后来该项目取消了Intel说4GHz以上做不上去了改口说摩尔定律改成每两年处理器核的数目增加一倍。
事实上过去每代微处理器主频是其上一代的两倍多其中只有1.4倍来源于器件的按比例缩小另外1.4倍来源于结构的优化即流水级中逻辑门数目的减少。目前的高主频处理器中指令流水线的划分已经很细每个流水级只有1015级FO4等效4扇出反相器的延迟已经难以再降低。电路延迟随晶体管尺寸缩小的趋势在0.13μm工艺的时候也开始变慢了而且连线延迟的影响越来越大连线延迟而不是晶体管翻转速度将制约处理器主频的提高。在Pentium IV的20级流水线中有两级只进行数据的传输没有进行任何有用的运算。
3功耗障碍
随着晶体管数目的增加以及主频的提高功耗问题越来越突出。现代的通用处理器功耗峰值已经高达上百瓦按照硅片面积为12cm2计算其单位面积的热密度已经远远超过了普通的电炉。以Intel放弃4GHz以上的Pentium IV项目为标志功耗问题成为导致处理器主频难以进一步提高的直接因素。在移动计算领域功耗更是压倒一切的指标。因此如何降低功耗的问题已经十分迫切。
如果说传统的CPU设计追求的是每秒运行的次数运算速度以及每一块钱所能买到的性能性能价格比那么在今天每瓦特功耗所得到的性能性能功耗比已经成为越来越重要的指标。就像买汽车汽车的最高时速是200公里还是300公里大部分人不在意更在意的是汽车的价格要便宜百公里油耗要低。
CMOS电路的功耗与主频和规模都成正比与电压的平方成正比而主频在一定程度上又跟电压成正比。由于晶体管的特性0.13μm工艺以后工作电压不随着工艺的进步而降低加上频率的提高导致功耗密度随集成度的增加而增加。另外纳米级工艺的漏电功耗大大增加在65nm工艺的处理器中漏电功耗已经占了总功耗的30%。这些都对计算机体系结构的低功耗设计提出了挑战。降低功耗需要从工艺技术、物理设计、体系结构设计、系统软件以及应用软件等多个方面共同努力。
4带宽障碍
随着工艺技术的发展,处理器片内的处理能力越来越强。按照目前的发展趋势,现代处理器很快将在片内集成十几甚至几十个高性能处理器核,而芯片进行计算所需要的数据归根结底是来自片外。高性能的多核处理器如不能低延迟、高带宽地同外部进行数据交互,则会出现“嘴小肚子大”“茶壶里倒饺子”的情况,整个系统的性能会大大降低。
芯片的引脚数不可能无限增加。通用CPU封装一般都有上千个引脚一些服务器CPU有四五千个引脚有时候封装成本已经高于硅的成本了。处理器核的个数以指数增加封装不变意味着每个CPU核可以使用的引脚数按指数级下降。
冯·诺依曼结构中CPU和内存在逻辑上是分开的指令跟数据都存在内存中CPU要不断从内存取指令和数据才能进行运算。传统的高速缓存技术的主要作用是降低平均访问延迟解决CPU速度跟存储器速度不匹配的问题但并不能有效解决访存带宽不够的问题。现在普遍通过高速总线来提高处理器的带宽这些高速总线采用差分低摆幅信号进行传输。不论是访存总线如DDR4、FBDIMM等、系统总线如HyperTransport还是IO总线(如PCIe)其频率都已经达到GHz级有的甚至超过10GHz片外传输频率高于片内运算频率。即便如此由于片内晶体管数目的指数级增加处理器体系结构设计也要面临每个处理器核的平均带宽不断减少的情况。进入21世纪以来如果说功耗是摩尔定律的第一个“杀手”导致结构设计从单核到多核那么带宽问题就是摩尔定律的第二个“杀手”必将导致结构设计的深刻变化。一些新型工艺技术如3D封装技术、光互连技术有望缓解处理器的带宽瓶颈。
上述复杂度、主频、功耗、带宽的障碍对计算机体系结构的发展造成严重制约使得计算机体系结构在通用CPU核的微结构方面逐步趋于成熟开始往片内多核、片上系统以及结合具体应用的专用结构方面发展。
## 体系结构设计的基本原则
计算机体系结构发展很快,但在发展过程中遵循一些基本原则,这些原则包括平衡性、局部性、并行性和虚拟化。
### 平衡性
结构设计的第一个原则就是要考虑平衡性。一个木桶所盛的水量的多少由最短的木板决定一个结构最终体现出的性能受限于其瓶颈部分。计算机是个复杂系统影响性能的因素很多。例如一台个人计算机使用起来比较卡顿一般人会觉得主要是由于CPU性能不够实际上真正引起性能卡顿的可能是内存带宽、硬盘或网络带宽、GPU性能或者是CPU和GPU之间数据传输不顺等等。又如一般的CPU微结构研究专注于其中某些重要因素如Cache命中率和转移猜测命中率的改善但通用CPU微结构中影响性能的因素非常复杂重排序缓冲项数、发射队列项数、重命名寄存器个数、访存队列项数、失效队列项数、转移指令队列项数与一级Cache失效延迟、二级Cache失效延迟、三级Cache失效延迟等需要平衡设计有关队列大小应保证一级Cache和二级Cache的失效不会引起流水线的堵塞。
通用CPU设计有一个关于计算性能和访存带宽平衡的经验原则即峰值浮点运算速度MFLOPS和峰值访存带宽MB/s为11左右。表\@ref(tab:flops-and-bandwidth)给出了部分典型CPU的峰值浮点运算速度和访存带宽比。从表中可以看出一方面最新的CPU峰值浮点运算速度和访存带宽比逐步增加说明带宽已经成为通用CPU的重要瓶颈多核的发展是有限度的另一方面如果去除SIMDSingle Instruction Multiple Data的因素即去除128位SIMD浮点峰值为64位浮点的2倍256位SIMD浮点峰值为64位浮点的4倍的因素则浮点峰值和访存带宽还是基本保持着11的关系因为SIMD一般只有科学计算使用一般的事务处理不会用SIMD的浮点性能。
```{r flops-and-bandwidth, echo = FALSE, message=FALSE, tab.cap='典型CPU的浮点峰值和访存带宽比'}
autonum <- run_autonum(seq_id = "tab", bkm = "flops-and-bandwidth", bkm_all = TRUE)
readr::read_csv('./materials/chapter1/flops_bandwidth.csv') %>%
flextable() %>%
set_caption(caption="典型CPU的浮点峰值和访存带宽比", autonum = autonum) %>%
valign(i = NULL, j = NULL, valign = "top", part = "body") %>%
fontsize(size=9, part="all") %>%
theme_box() %>%
autofit()
```
计算机体系结构中有一个著名的Amdahl定律。该定律指出通过使用某种较快的执行方式所获得的性能的提高受限于不可使用这种方式提高性能的执行时间所占总执行时间的百分比例如一个程序的并行加速比最终受限于不能被并行化的串行部分。也就是性能的提升不仅跟其中的一些指令的运行时间的优化有关还和这些指令在总指令数中所占的比例有关
$$ ExTime_{new} = Extime_{old} * \left((1 - Fraction_{enhanced}) + \frac{Fraction_{enhanced}}{Speedup_{enhanced}}\right) $$
$$ Speedup_{overall} = \frac{Extime_{old}}{ExTime_{new}} $$
在计算机体系结构设计里Amdahl定律的体现非常普遍。比如说并行化一个程序中有一些部分是不能被并行化的而这些部分将成为程序优化的一个瓶颈。举一个形象的例子一个人花一个小时可以做好一顿饭但是60个人一起做不可能用一分钟就能做好因为做饭的过程有一些因素是不可被并行化的。
结构设计要统筹兼顾,抓住主要因素的同时不要忽略次要因素,否则当主要的瓶颈问题解决以后,原来不是瓶颈的次要因素可能成为瓶颈。就像修马路,在一个本来堵车的路口修座高架桥,这个路口不堵车了,但与这个路口相邻的路口可能堵起来。体系结构设计的魅力正在于在诸多复杂因素中做到统筹兼顾。
### 局部性
局部性是事物普遍存在的性质。一个人认识宇宙的范围受限于光速和人的寿命,这是一种局部性;一个人只能认识有限的人,其中天天打交道的熟悉的人更少,这也是一种局部性。局部性在计算机中普遍存在,是计算机性能优化的基础。
体系结构利用局部性进行性能优化时最常见的是利用事件局部性即有些事件频繁发生有些事件不怎么发生在这种情况下要重点优化频繁发生的事件。当结构设计基本平衡以后优化性能要抓主要矛盾重点改进最频繁发生事件的执行效率。作为设计者必须清楚什么是经常性事件以及提高这种情况下机器运行的速度对计算机整体性能有多大贡献。例如假设我们把处理器中浮点功能部件执行的性能提高一倍但是整个程序里面只有10%的浮点指令总的性能加速比是1÷0.95=1.053也就是说即使把所有浮点指令的计算速度提高了一倍总的CPU性能只提高了5%。所以应该加快经常性事件的速度。把经常性的事件找出来而且它占的百分比越高越好再来优化这些事件这是一个基本的原理。RISC指令系统的提出就是利用指令的事件局部性对频繁发生的事件进行重点优化的例子。硬件转移猜测则是利用转移指令跳转方向的局部性即同一条转移指令在执行时经常往同一个方向跳转。
利用访存局部性进行优化是体系结构提升访存指令性能的重要方法。访存局部性包括时间局部性和空间局部性两种。时间局部性指的是一个数据被访问后很有可能多次被访问。空间局部性指的是一个数据被访问后它邻近的数据很有可能被访问例如数组按行访问时相邻的数据连续被访问按列访问时虽然空间上不连续但每次加上一个固定的步长也是一种特殊的空间局部性。计算机体系结构使用访存局部性原理来提高性能的地方很多如高速缓存、TLB、预取都利用了访存局部性。
### 并行性
计算机体系结构提高性能的另外一个方法就是开发并行性。计算机中一般可以开发三种层次的并行性。
第一个层次的并行性是指令级并行。指令级并行是20世纪最后20年体系结构提升性能的主要途径。指令级并行性可以在保持程序二进制兼容的前提下提高性能这一点是程序员特别喜欢的。指令级并行分成两种。一种是时间并行即指令流水线。指令流水线就像工厂生产汽车的流水线一样汽车生产工厂不会等一辆汽车都装好以后再开始下一辆汽车的生产而是在多道工序上同时生产多辆汽车。另一种是空间并行即多发射或者叫超标量。多发射就像多车道的马路而乱序执行Out-of-Order Execution就是允许在多车道上超车超标量和乱序执行常常一起使用来提高效率。在20世纪80年代RISC出现后随后的20年指令级并行的开发达到了一个顶峰2010年后进一步挖掘指令级并行的空间已经不大。
第二个层次的并行性是数据级并行主要指单指令流多数据流SIMD的向量结构。最早的数据级并行出现在ENIAC上。20世纪六七十年代以Cray为代表的向量机十分流行从Cray-1、Cray-2到后来的Cray X-MP、Cray Y-MP。直到Cray-4后SIMD沉寂了一段时间现在又开始恢复活力而且用得越来越多。例如X86中的AVX多媒体指令可以用256位通路做四个64位的运算或八个32位的运算。SIMD作为指令级并行的有效补充在流媒体领域发挥了重要的作用早期主要用在专用处理器中现在已经成为通用处理器的标配。
第三个层次的并行性是任务级并行。任务级并行大量存在于Internet应用中。任务级并行的代表是多核处理器以及多线程处理器是目前计算机体系结构提高性能的主要方法。任务级并行的并行粒度较大一个线程中包含几百条或者更多的指令。
上述三种并行性在现代计算机中都存在。多核处理器运行线程级或进程级并行的程序每个核采用多发射流水线结构而且往往有SIMD向量部件。
### 虚拟化
所谓虚拟化,就是“用起来是这样的,实际上是那样的”,或者“逻辑上是这样的,物理上是那样的”。计算机为什么好用?因为体系结构设计者宁愿自己多费点事,也要尽量为用户提供一个友好界面的用户接口。虚拟化是体系结构设计者为用户提供友好界面的一个基本方法,虚拟化的本质就是在不好用的硬件和友好的用户界面之间架一座“桥梁”。
架得最成功的一座“桥梁”是20世纪60年代工艺的发展使处理器中可以包含像TLB这样较为复杂的结构操作系统可以支持虚拟空间大大解放了程序员的生产力。早期的计算机程序员编程的时候要直接跟物理内存和外存打交道非常麻烦虚拟存储解决了这个问题。每个进程都使用一个独立的、很大的存储空间具体物理内存的分配和数据在内存和外存的调入调出都由操作系统自动完成。这座桥架得太漂亮了给它评分肯定是“特优”。
如果说虚拟存储技术“虚拟”了内存那么多线程和虚拟机技术则“虚拟”了CPU。多线程技术尤其是同时多线程Simultaneous Multi-Threading简称SMT技术通过微结构的硬件支持如设立多组通用寄存器等使得在同一个CPU中实现两个或多个线程中的指令在流水线中混合地执行或在同一个CPU中实现线程的快速切换使用户在一个CPU上“同时”执行多个线程。虚拟机技术则通过微结构的硬件增强如设立多组控制寄存器和系统状态等实现多个操作系统的快速切换达到在同一台计算机上“同时”运行多个操作系统的目的。这座桥架得也不错作用没有虚拟存储那么明显给它评分可以得“优”。
流水线和多发射结构也是架得很成功的一座“桥梁”。20世纪七八十年代以来工艺的发展使得像流水线和多发射这样的结构得以实现在维持串行编程模型的情况下提高了速度。但由于程序中相关性的存在流水线和多发射的效率难以做得很好例如在单发射结构中IPC达到0.5就不错了在四发射结构中IPC达到1.5就不错了。流水线和多发射这座桥的评分可以得“优”。
另外一座比较成功的“桥梁”是Cache技术。CPU速度越来越快内存大但是慢通过Cache技术可以使程序员看到一个像Cache那么快、像内存那么大的存储空间不用改应用程序就能提高性能。这座桥也对程序员屏蔽了结构细节虽然程序员往往针对Cache结构进行精雕细刻的程序设计以增加局部性但代价太大现代处理器往往80%以上的晶体管都用在Cache上了所以Cache这座桥的评分只能得“良好”。
还有一座比较典型的“桥梁”是分布式共享存储系统中的Cache一致性协议。Cache一致性协议可以在分布式存储的情况下给程序员提供一个统一的编程空间屏蔽了存储器物理分布的细节但Cache一致性协议并不能解决程序员需要并行编程、原有的串行程序不能并行运行的问题。因此Cache一致性协议这座桥评分可以得“及格”。如果哪天编译技术发展到程序员只要写串行程序计算机能够自动并行化并在成千上万个处理器中运行该程序那这座桥的评分可以得“特优”。
## 本章小结
本章介绍了计算机体系结构的研究内容,包括指令系统结构和以冯·诺依曼结构为基础的计算机组织结构,以及微体系结构和并行体系结构;衡量计算机的主要指标,性能、面积、功耗的评价和优化;计算机体系结构的发展简史,工艺和应用的发展对体系结构的影响,制约体系结构发展的因素;计算机体系结构设计应遵循的基本原则,包括平衡性、局部性、并行性、虚拟化等。
## 习题
1. 计算机系统可划分为哪几个层次,各层次之间的界面是什么?你认为这样划分层次的意义何在?
2. 在三台不同指令系统的计算机上运行同一程序P时A机器需要执行$1.0\times{}10^9$条指令B机器需要执行$2.0\times{}10^9$条指令C机器需要执行$3.0\times{}10^9$条指令但三台机器的实际执行时间都是100秒。请分别计算出这三台机器的MIPS并指出运行程序P时哪台机器的性能最高。
3. 假设某程序中可向量化的百分比为P现在给处理器中增加向量部件以提升性能向量部件的加速比是S。请问增加向量部件后处理器运行该程序的性能提升幅度是多少
4. 处理器的功耗可简单分为静态功耗和动态功耗两部分其中静态功耗的特性满足欧姆定律动态功耗在其他条件相同的情况下与频率成正比。现对某处理器进行功耗测试得到如下数据关闭时钟电压1.0V时电流为100mA时钟频率为1GHz电压1.1V时电流为2100mA。请计算此处理器在时钟频率为2GHz、电压为1.1V时的总功耗。
5. 在一台个人计算机上进行SPEC CPU 2000单核性能的测试分别给出无编译优化选项和编译优化选项为-O2的测试报告。
6. 分别在苹果手机、华为手机以及X86-Windows机器上测试浏览器Octane参见https//chromium.github.io/octane/)的分值,并简单评述。
\newpage

524
12-isa.Rmd Normal file
View File

@@ -0,0 +1,524 @@
# (PART) 指令系统结构 {-}
第二部分介绍计算机软件与硬件之间的界面(或者说接口)指令系统。该部分的内容组织如下首先介绍指令系统的设计原则和发展历史随后介绍软硬件之间的关键界面——指令集以及C语言与指令之间的对应关系然后介绍异常处理、存储管理两个重要机制最后介绍软硬件协同工作的一些相关话题。希望通过该部分的介绍能帮助读者拨开计算机软硬件交互的迷雾。
# 指令系统 {#sec-ISA}
## 指令系统简介
随着技术的进步计算机的形态产生了巨大的变化从巨型机到小型机到个人电脑Personal Computer简称PC再到智能手机其基础元件从电子管到晶体管再到超大规模集成电路。虽然计算机的形态和应用场合千变万化但从用户感知的应用软件到最底层的物理载体计算机系统均呈现出层次化的结构图\@ref(fig:hierarchy)直观地展示了这些层次。
```{r hierarchy, echo=FALSE, fig.align='center', fig.cap="计算机系统的层次", out.width='50%'}
knitr::include_graphics("images/chapter2/hierarchy.png")
```
从上到下计算机系统可分为四个层次分别为应用软件、基础软件、硬件电路和物理载体。软件以指令形式运行在CPU硬件上而指令系统介于软件和硬件之间是软硬件交互的界面有着非常关键的作用。软硬件本身的更新迭代速度很快而指令系统则可以保持较长时间的稳定。有了稳定不变的指令系统界面软件与硬件得到有效的隔离并行发展。遵循同一指令系统的硬件可以运行为该指令系统设计的各种软件比如X86计算机既可运行最新软件也可运行30年前的软件反之为一个指令系统设计的软件可以运行在兼容这一指令系统的不同的硬件实现上例如同样的操作系统和应用软件在AMD与Intel的CPU上都可以运行。
指令系统包括对指令功能、运行时环境(如存储管理机制和运行级别控制)等内容的定义,涉及软硬件交互的各个方面内容,这些内容将在后续章节一一展开介绍。
## 指令系统设计原则
指令系统是软硬件的接口,程序员根据指令系统设计软件,硬件设计人员根据指令系统实现硬件。指令系统稍微变化,一系列软硬件都会受到影响,所以指令系统的设计应遵循如下基本原则:
* 兼容性。这是指令系统的关键特性。最好能在较长时间内保持指令系统不变并保持向前兼容例如X86指令系统虽然背了很多历史包袱要支持过时的指令但其兼容性使得Intel在市场上获得了巨大的成功。很多其他指令系统进行过结构上的革命导致新处理器与旧有软件无法兼容反而造成了用户群体的流失。因此保持指令系统的兼容性非常重要。
* 通用性。为了适应各种应用需求如网络应用、科学计算、视频解码、商业应用等通用CPU指令系统的功能必须完备。而针对特定应用的专用处理器则不需要强调通用性。指令系统的设计还应满足操作系统管理的需求并方便编译器和程序员的使用。
* 高效性。指令系统还要便于CPU硬件的设计和优化。对同一指令系统不同的微结构实现可以得到不同的性能既可以使用先进、复杂的技术得到较高的性能也可以用成熟、简单的技术得到一般的性能。
* 安全性。当今计算机系统的安全性非常重要,指令系统的设计应当为各种安全性提供支持,如提供保护模式等。
影响指令系统的因素有很多,某些因素的变化会显著影响指令系统的设计,因此有必要了解各方面的影响因素。
* 工艺技术。在计算机发展的早期阶段计算机硬件非常昂贵简化硬件实现成为指令系统的主要任务。到了20世纪八九十年代随着工艺技术的发展片内可集成晶体管的数量显著增加CPU可集成更多的功能功能集成度提高带来的更多可能性支持指令系统的快速发展例如从32位结构上升至64位结构以及增加多媒体指令等。随着CPU主频的快速提升CPU速度和存储器速度的差距逐渐变大为了弥补这个差距指令系统中增加预取指令将数据预取到高速缓存Cache甚至寄存器中。当工艺能力和功耗密度导致CPU主频达到一定极限时多核结构成为主流这又导致指令系统的变化增加访存一致性和核间同步的支持。一方面工艺技术的发展为指令系统的发展提供了物质基础另一方面工艺技术的发展也对指令系统的发展施加影响。
* 计算机体系结构。指令系统本身就是计算机体系结构的一部分系统结构的变化对指令系统的影响最为直接。诸如单指令多数据Single Instruction Multiple Data简称SIMD、多核结构等新的体系结构特性必然会对指令系统产生影响。事实上体系结构的发展与指令系统兼容性的基本原则要求是矛盾的为了兼容性总会背上历史的包袱。X86指令系统和硬件实现就是因为这些历史包袱而变得比较复杂而诸如PowerPC等精简指令系统都经历过彻底抛弃过时指令系统的过程。
* 操作系统。现代操作系统都支持多进程和虚拟地址空间。虚拟地址空间使得应用程序无须考虑物理内存的分配在计算机系统发展中具有里程碑意义。为了实现虚拟地址空间需要设计专门的地址翻译模块以及与其配套的寄存器和指令。操作系统所使用的异常和中断也需要专门的支持。操作系统通常具有核心态、用户态等权限等级核心态比用户态具有更高的等级和权限需要设计专门的核心态指令。核心态指令对指令系统有较大的影响X86指令系统一直在对核心态指令进行规范MIPS指令系统直到MIPS32和MIPS64才对核心态进行了明确的定义而Alpha指令系统则通过PALcode定义了抽象的操作系统与硬件的界面。
* 编译技术。编译技术对指令系统的影响也比较大。RISC在某种意义上就是编译技术推动的结果。为使编译器有效地调度指令至少需要16个通用寄存器。指令功能对编译器更加重要例如一个指令系统没有乘法指令编译器就只能将其拆成许多个加法进行运算。
* 应用程序。计算机中的各种应用程序都实现一定的算法指令是从各种算法中抽象出来的“公共算子”算法就是由算子序列组成的。指令为应用而设计因而指令系统随着应用的需求而发展。例如从早期的8位、16位到现在的32位、64位从早期的只支持定点到支持浮点从只支持通用指令到支持SIMD指令。此外应用程序对指令系统的要求还包括前述的兼容性。
总之,指令系统需遵循的设计原则和影响因素很多,指令系统的设计需要综合考虑多方因素并小心谨慎。
## 指令系统发展历程
指令系统的发展经历了从简单到复杂,再从复杂到简单的演变过程。现代指令系统在指令内容、存储管理和运行级别控制等方面都产生了一系列变化,这些变化体现了人类对计算机体系结构这个学科认知的提升。
### 指令内容的演变
依据指令长度的不同指令系统可分为复杂指令系统Complex Instruction Set Computer简称CISC、精简指令系统Reduced Instruction Set Computer简称RISC和超长指令字Very Long Instruction Word简称VLIW指令集三种。CISC中的指令长度可变RISC中的指令长度比较固定VLIW本质上来讲是多条同时执行的指令的组合其“同时执行”的特征由编译器指定无须硬件进行判断。
早期的CPU都采用CISC结构如IBM的System360、Intel的8080和8086系列、Motorola的68000系列等。这与当时的时代特点有关早期处理器设备昂贵且处理速度慢设计者不得不加入越来越多的复杂指令来提高执行效率部分复杂指令甚至可与高级语言中的操作直接对应。这种设计简化了软件和编译器的设计但也显著提高了硬件的复杂性。
当硬件复杂度逐渐提高时CISC结构出现了一系列问题。大量复杂指令在实际中很少用到典型程序所使用的80%的指令只占指令集总指令数的20%,消耗大量精力的复杂设计只有很少的回报。同时,复杂的微代码翻译也会增加流水线设计难度,并降低频繁使用的简单指令的执行效率。
针对CISC结构的缺点RISC遵循简化的核心思路。RISC简化了指令功能单个指令执行周期短简化了指令编码使得译码简单简化了访存类型访存只能通过load/store指令实现。RISC指令的设计精髓是简化了指令间的关系有利于实现高效的流水线、多发射等技术从而提高主频和效率。
最早的RISC处理器可追溯到CDC公司和其1964年推出的世界上第一台超级计算机CDC6600现代RISC结构的一些关键特性——如只通过load/store指令访存的load/store结构——都在CDC6600上显现雏形但简化结构提高效率的思想并未受到小型机和微处理器设计者的重视。1975年John Cocke在IBM公司位于约克镇的Thomas J. Watson研究中心组织研究指令系统的合理性并研制现代RISC计算机的鼻祖IBM 801现在IBM PowerPC的主要思想就源于IBM 801。参与IBM 801项目的David Patterson和John Hennessy分别回到加州大学伯克利分校和斯坦福大学开始从事RISC-1/RISC-2项目和MIPS项目它们分别成为SPARC处理器和MIPS处理器的前身。IBM 801的项目经理Joel Birnbaum在HP创立了PA-RISCDEC公司在MIPS的基础上设计了Alpha处理器。广泛使用的ARM处理器也是RISC处理器的代表之一。David Patterson教授在加州大学伯克利分校推出的开源指令系统RISC-V是加州大学伯克利分校推出的继RISC-I1981年推出、RISC-II1982年推出、SOAR1984年推出也称为RISC-III、SPUR1988年推出也称为RISC-IV之后的第五代指令系统。
RISC指令系统的最本质特征是通过load/store结构简化了指令间关系即所有运算指令都是对寄存器运算所有访存都通过专用的访存指令load/store进行。这样CPU只要通过寄存器号的比较就能判断运算指令之间以及运算指令和访存指令之间有没有数据相关性而较复杂的访存指令相关判断需要对访存的物理地址进行比较则只在执行load/store指令的访存部件上进行从而大大简化了指令间相关性判断的复杂度有利于CPU采用指令流水线、多发射、乱序执行等提高性能。因此RISC不仅是一种指令系统类型同时也是一种提高CPU性能的技术。X86处理器中将CISC指令译码为类RISC的内部操作然后对这些内部操作使用诸如超流水、乱序执行、多发射等高效实现手段。而以PowerPC为例的RISC处理器则包含了许多功能强大的指令。
VLIW结构的最初思想是最大限度利用指令级并行Instruction Level Parallelism简称ILPVLIW的一个超长指令字由多个互相不存在相关性控制相关、数据相关等的指令组成可并行进行处理。VLIW可显著简化硬件实现但增加了编译器的设计难度。
VLIW的思想最初由Josh Fisher于20世纪80年代初在耶鲁大学提出Fisher随后离开耶鲁创立了Multiflow公司并研制了TRACE系列VLIW处理器。后来Fisher和同样经历创业失败的Bob Rau加入了HP公司并主导了HP在20世纪90年代的计算机结构研究。
同时Intel在i860中实现了VLIW这也奠定了随后两家公司在Itanium处理器上的合作关系ItaniumIA-64采用的EPIC结构的思想即来源于VLIW。
```{r inst-coding, echo = FALSE, fig.cap = "RISC、CISC、VLIW指令编码特点", fig.align = 'center', out.width='80%' }
knitr::include_graphics("images/chapter2/inst_coding.png")
```
图\@ref(fig:inst-coding)直观地给出了RISC、CISC、VLIW三种结构的指令编码。MIPS三种类型的指令内部位域分配不同但总长度均为32位X86则不同指令的长度都可能不同IA-64则将三条41位定长指令合并为一条128位的“束”。
### 存储管理的演变
存储器是冯·诺依曼结构计算机的核心部件,存储管理的演变是指令系统演变的重要组成部分。
存储管理的演变经历了连续实地址、段式、页式虚拟存储等阶段。
连续实地址的管理方式是最早期也是最朴素的方式,各程序所需的内存空间必须连续存放并保证不与其他程序产生冲突。这种方式不但会带来大量的内存碎片,而且难以管理多个程序的空间分配。
段式存储管理将内存分为多个段和节地址组织为相对于段地址的偏移。段式存储主要应用于早期处理器中Burroughs公司的B5000是最早使用段式存储的计算机之一。Intel从8086处理器开始使用段式存储管理在80286之后兼容段页式但在最新的X86-64位架构中放弃了对段式管理的支持。
页式虚拟存储管理将各进程的虚拟内存空间划分成若干长度相同的页将虚拟地址和物理地址的对应关系组织为页表并通过硬件来实现快速的地址转换。现代通用处理器的存储管理单元都基于页式虚拟管理并通过TLB进行地址转换加速。
页式虚拟存储可使各进程运行在各自独立的虚拟地址空间中,并提供内存映射、公平的物理内存分配和共享虚拟内存等功能,是计算机系统发展过程中具有里程碑意义的一项技术。
下面分别介绍上述几种存储管理方式的基本方法。
段式存储管理的地址转换过程如图\@ref(fig:segment)所示。虚拟地址分为段号和段内偏移两部分,地址转换时根据段号检索段表,得到对应段的起始物理地址(由段长度和基址可得),再加上段内偏移,得到最终的物理地址。需要注意的是,段表中存有每个段的长度,若段内偏移超过该段长度,将被视为不合法地址。
```{r segment, echo = FALSE, fig.cap = "段式存储管理的地址转换过程", fig.align = 'center', out.width='60%' }
knitr::include_graphics("images/chapter2/segment.png")
```
段式存储中每段可配置不同的起始地址,但段内地址仍需要连续,当程序段占用空间较大时,仍然存在内存碎片等问题。
页式存储管理的地址转换过程如图\@ref(fig:page)所示。虚拟地址分为虚拟页号和页内偏移两部分,地址转换时根据虚拟页号检索页表,得到对应的物理页号,与页内偏移组合得到最终的物理地址。
```{r page, echo = FALSE, fig.cap = "页式存储管理的地址转换过程", fig.align = 'center', out.width='60%'}
knitr::include_graphics("images/chapter2/page.png")
```
段页式管理结合了段式和页式的特点,其地址转换过程如图\@ref(fig:seg-page)所示,虚拟地址分为段号、虚拟页号和页内偏移三部分,地址转换时首先根据段号查询段表得到对应段的页表起始地址,再根据虚拟页号查询页表得到物理页号,与页内偏移组合得到最终的物理地址。段页式同样需要检查段地址的合法性。
```{r seg-page, echo = FALSE, fig.cap = "段页式存储管理的地址转换过程", fig.align = 'center', out.width='60%'}
knitr::include_graphics("images/chapter2/seg-page.png")
```
### 运行级别的演变
作为软件指令的执行者,处理器中有各种级别的资源,比如通用寄存器、控制寄存器等。为了对软件所能访问的资源加以限制,计算机引入了运行级别的概念。运行级别经历了无管理、增加保护模式、增加调试模式、增加虚拟化支持等阶段。
早期的处理器和当今的嵌入式单片机中不包含运行级别控制所有程序都可控制所有资源。无管理的方式在安全方面毫无保障软件必须小心设计确保不会相互干扰。这通常只在规模有限、封闭可控的系统如微控制器Micro Control Unit简称MCU中使用。
现代操作系统如Linux包含保护模式将程序分为两个权限等级用户态和核心态。核心态具有最高权限可以执行所有指令、访问任意空间。在用户态下程序只能访问受限的内存空间不允许访问外围设备。用户态程序需要使用外围设备时通过系统调用提出申请由操作系统在核心态下完成访问。保护模式需要硬件支持如X86指令系统中定义了Ring0Ring3四个权限等级MIPS指令系统中定义了user、supervisor和kernel三个权限等级。LoongArch指令系统中定义了PLV0PLV3四个权限等级由当前模式信息控制状态寄存器CSR.CRMD的PLV域的值确定。在LoongArch处理器上运行的Linux操作系统其核心态程序运行在PLV0级用户态程序通常运行在PLV3级。
为了方便软硬件调试许多指令系统中还定义了调试模式和相应的调试接口如ARM的JTAG、MIPS的EJTAG。LoongArch指令系统定义了专门的调试模式、调试指令和配套的状态控制寄存器。在调试模式下处理器所执行的程序将获得最高的权限等级不过此时处理器所执行的指令是从外部调试接口中获得的并且利用专用的控制状态寄存器使得被调试程序的上下文可以无缝切换。
虚拟化技术在服务器领域特别有用一台物理主机可以支撑多台虚拟机运行各自的系统。虚拟机不绑定底层硬件可看作一个软件进程因而部署起来非常灵活。虚拟机中同样要支持不同的运行级别为了提高效率硬件辅助虚拟化成为虚拟化发展的必然趋势。IBM System/370早在1970年就增加了硬件虚拟化支持2005年以来Intel和AMD也分别提出了硬件辅助虚拟化的扩展VT和SVM。ARM的AArch64架构也定义了硬件虚拟化支持方面的内容。这些指令系统在硬件虚拟化支持中引入了新的运行级别用于运行虚拟机操作系统的核心态和用户态程序。
以LoongArch指令系统为例其运行级别主要包括调试模式Debug Mode、主机模式Host Mode和客户机模式Guest Mode。主机模式和客户机模式又各自包含PLV0PLV3四个权限等级即具有Host-PLV0Host-PLV3和Guest-PLV0Guest-PLV3这8个运行级别。所有运行级别互相独立即处理器在某一时刻只能存在于某一种运行级别中。处理器上电复位后处于Host-PLV0级随后根据需要在不同运行级别之间转换。
不同运行级别可访问并控制的处理器资源不同,图\@ref(fig:csr)给出了这种对应关系的示意。其中调试模式下具有最高的优先级可以访问并控制处理器中所有的资源Host-PLV0模式下可以访问并控制处理器中除了用于调试功能外的所有其他资源Guest-PLV0模式下只能访问部分处理器资源如客户机控制状态寄存器Host-PLV1/2/3和Guest-PLV1/2/3则只能访问更少的处理器资源。
```{r csr, echo = FALSE, fig.cap = "LoongArch各运行级别可访问控制处理器资源示意", fig.align = 'center', out.width='60%'}
knitr::include_graphics("images/chapter2/csr.png")
```
## 指令系统组成
指令系统由若干条指令及其操作对象组成。每条指令都是对一个操作的描述,主要包括操作码和操作数。操作码规定指令功能,例如加减法;操作数指示操作对象,包含数据类型、访存地址、寻址方式等内容的定义。
### 地址空间
处理器可访问的地址空间包括寄存器空间和系统内存空间。寄存器空间包括通用寄存器、专用寄存器和控制寄存器。寄存器空间通过编码于指令中的寄存器号寻址,系统内存空间通过访存指令中的访存地址寻址。
通用寄存器是处理器中最常用的存储单元,一个处理器周期可以同时读取多条指令需要的多个寄存器值。现代指令系统都定义了一定数量的通用寄存器供编译器进行充分的指令调度。针对浮点运算,通常还定义了浮点通用寄存器。表\@ref(tab:regnum)给出了部分常见指令集中整数通用寄存器的数量。
```{r regnum, echo = FALSE, message=FALSE}
autonum <- run_autonum(seq_id = "tab", bkm = "regnum", bkm_all = TRUE)
readr::read_csv('./materials/chapter2/regnum.csv') %>%
flextable() %>%
set_caption(caption="不同指令集的整数通用寄存器数量", autonum = autonum) %>%
theme_box() %>%
autofit()
```
LoongArch指令系统中定义了32个整数通用寄存器和32个浮点通用寄存器其编号分别表示为\$r0\$r31和\$f0\$f31,其中\$r0总是返回全0。
除了通用寄存器外有的指令系统还会定义一些专用寄存器仅用于某些专用指令或专用功能。如MIPS指令系统中定义的HI、LO寄存器就仅用于存放乘除法指令的运算结果。
控制寄存器用于控制指令执行的环境比如是核心态还是用户态。其数量、功能和访问方式依据指令系统的定义各不相同。LoongArch指令系统中定义了一系列控制状态寄存器Control Status Register,简称CSR将在第3章介绍。
广义的系统内存空间包括IO空间和内存空间不同指令集对系统内存空间的定义各不相同。X86指令集包含独立的IO空间和内存空间对这两部分空间的访问需要使用不同的指令内存空间使用一般的访存指令IO空间使用专门的in/out指令。而MIPS、ARM、LoongArch等RISC指令集则通常不区分IO空间和内存空间把它们都映射到同一个系统内存空间进行访问使用相同的load/store指令。处理器对IO空间的访问不能经过Cache因此在使用相同的load/store指令既访问IO空间又访问内存空间的情况下就需要定义load/store指令访问地址的存储访问类型用来决定该访问能否经过Cache。如MIPS指令集定义缓存一致性属性Cache Coherency Attribute简称CCAUncached和Cached分别用于IO空间和内存空间的访问ARM AArch64指令定义内存属性Memory AttributeDevice和Normal分别对应IO空间和内存空间的访问LoongArch指令集定义存储访问类型Memory Access Type简称MAT强序非缓存Strongly-ordered UnCached简称SUC和一致可缓存Coherent Cached简称CC分别用于IO空间和内存空间的访问。存储访问类型通常根据访存地址范围来确定。如果采用页式地址映射方式那么同一页内的地址定义为相同的存储访问类型通常作为该页的一个属性信息记录在页表项中如MIPS指令集中的页表项含有CCA域LoongArch指令集中的页表项含有MAT域。如果采用段式地址映射方式那么同一段内的地址定义为相同的存储访问类型。如MIPS32中规定虚地址空间的kseg1段地址范围0xa0000000\~0xbfffffff的存储访问类型固定为Uncached操作系统可以使用这段地址来访问IO空间。LoongArch指令集可以把直接地址映射窗口的存储访问类型配置为SUC那么落在该地址窗口就可以访问IO空间。有关LoongArch指令集中直接地址映射窗口的详细介绍请看第3章。
根据指令使用数据的方式,指令系统可分为堆栈型、累加器型和寄存器型。寄存器型又可以进一步分为寄存器-寄存器型Register-Register和寄存器-存储器型Register-Memory。下面分别介绍各类型的特点。
* 堆栈型。堆栈型指令又称零地址指令,其操作数都在栈顶,在运算指令中不需要指定操作数,默认对栈顶数据进行运算并将结果压回栈顶。
* 累加器型。累加器型指令又称单地址指令,包含一个隐含操作数——累加器,另一个操作数在指令中指定,结果写回累加器中。
* 寄存器-存储器型。在这种类型的指令系统中,每个操作数都由指令显式指定,操作数为寄存器和内存单元。
* 寄存器-寄存器型。在这种类型的指令系统中,每个操作数也由指令显式指定,但除了访存指令外的其他指令的操作数都只能是寄存器。
表\@ref(tab:isatype)给出了四种类型的指令系统中执行C=A+B的指令序列其中A、B、C为不同的内存地址R1、R2等为通用寄存器。
```{r isatype, echo = FALSE, message=FALSE}
autonum <- run_autonum(seq_id = "tab", bkm = "isatype", bkm_all = TRUE)
readr::read_csv('./materials/chapter2/isatype.csv') %>%
flextable() %>%
set_caption(caption="四类指令系统的C=A+B指令序列", autonum = autonum) %>%
theme_box() %>%
autofit()
```
寄存器-寄存器型指令系统中的运算指令的操作数只能来自寄存器不能来自存储器所有的访存都必须显式通过load和store指令来完成所以寄存器-寄存器型又被称为load-store型。
早期的计算机经常使用堆栈型和累加器型指令系统主要目的是降低硬件实现的复杂度。除了X86还保留堆栈型和累加器型指令系统外当今的指令系统主要是寄存器型并且是寄存器-寄存器型。使用寄存器的优势在于,寄存器的访问速度快,便于编译器的调度优化,并可以充分利用局部性原理,大量的操作可以在寄存器中完成。此外,寄存器-寄存器型的另一个优势是寄存器之间的相关性容易判断,容易实现流水线、多发射和乱序执行等方法。
### 操作数
#### 数据类型
计算机中常见的数据类型包括整数、实数、字符数据长度包括1字节、2字节、4字节和8字节。X86指令集中还包括专门的十进制类型BCD。表\@ref(tab:int-type)给出C语言整数类型与不同指令集中定义的名称和数据长度以字节为单位的关系。
```{r int-type, echo = FALSE, message=FALSE}
autonum <- run_autonum(seq_id = "tab", bkm = "int-type", bkm_all = TRUE)
readr::read_csv('./materials/chapter2/int_type.csv') %>%
flextable() %>%
set_caption(caption="不同指令集整数类型的名称和数据长度", autonum = autonum) %>%
footnote(j=2:3, value = as_paragraph(c("LA32和LA64分别是32位和64位LoongArch指令集")), ref_symbols = NULL, part='header') %>%
theme_box() %>%
autofit()
```
实数类型在计算机中表示为浮点类型包括单精度浮点数和双精度浮点数单精度浮点数据长度为4字节双精度浮点数据长度为8字节。
在指令中表达数据类型有两种方法。一种是由指令操作码来区分不同类型,例如加法指令包括定点加法指令、单精度浮点加法指令、双精度浮点加法指令。另一种是将不同类型的标记附在数据上,例如加法使用统一的操作码,用专门的标记来标明加法操作的数据类型。
#### 访存地址
在执行访存指令时必须考虑的问题是访存地址是否对齐和指令系统是否支持不对齐访问。所谓对齐访问是指对该数据的访问起始地址是其数据长度的整数倍例如访问一个4字节数其访存地址的低两位都应为0。对齐访问的硬件实现较为简单若支持不对齐访问硬件需要完成数据的拆分和拼合。但若只支持对齐访问又会使指令系统丧失一些灵活性例如串操作经常需要进行不对齐访问只支持对齐访问会让串操作的软件实现变得较为复杂。以X86为代表的CISC指令集通常支持不对齐访问RISC类指令集在早期发展过程中为了简化硬件设计只支持对齐访问不对齐的地址访问将产生异常。近些年来伴随着工艺和设计水平的提升越来越多的RISC类指令也开始支持不对齐访问以减轻软件优化的负担。
另一个与访存地址相关的问题是尾端Endian问题。不同的机器可能使用大尾端或小尾端这带来了严重的数据兼容性问题。最高有效字节的地址较小的是大尾端最低有效字节的地址较小的是小尾端。Motorola的68000系列和IBM的System系列指令系统采用大尾端X86、VAX和LoongArch等指令系统采用小尾端ARM、SPARC和MIPS等指令系统同时支持大小尾端。
#### 寻址方式
寻址方式指如何在指令中表示要访问的内存地址。表\@ref(tab:addressing)列出了计算机中常用的寻址方式其中数组mem表示存储器数组regs表示寄存器mem[regs[Rn]]表示由寄存器Rn的值作为存储器地址所访问的存储器值。
```{r addressing, echo = FALSE, message=FALSE}
autonum <- run_autonum(seq_id = "tab", bkm = "addressing", bkm_all = TRUE)
readr::read_csv('./materials/chapter2/addressing.csv') %>%
flextable() %>%
set_caption(caption="常用寻址方式", autonum = autonum) %>%
width(j=1:2, width=1.2) %>%
width(j=3, width=3.6) %>%
theme_box()
```
除表\@ref(tab:addressing)之外还可以列出很多其他寻址方式但常用的寻址方式并不多。John L.Hennessy在其经典名著《计算机系统结构量化研究方法第二版》中给出了如表\@ref(tab:vax-addressing)所示的数据他在VAX计算机VAX机的寻址方式比较丰富上对SPEC CPU 1989中tex、spice和gcc这三个应用的寻址方式进行了统计。
```{r vax-addressing, echo = FALSE, message=FALSE}
autonum <- run_autonum(seq_id = "tab", bkm = "vax-addressing", bkm_all = TRUE)
readr::read_csv('./materials/chapter2/vax_addressing.csv') %>%
flextable() %>%
set_caption(caption="VAX计算机寻址方式统计", autonum = autonum) %>%
theme_box() %>%
autofit()
```
从表\@ref(tab:vax-addressing)可以看出偏移量寻址、立即数寻址和寄存器间接寻址是最常用的寻址方式而寄存器间接寻址相当于偏移量为0的偏移量寻址。因此一个指令系统至少应支持寄存器寻址、立即数寻址和偏移量寻址。经典的RISC指令集如MIPS和Alpha主要支持上述三种寻址方式以兼顾硬件设计的简洁和寻址计算的高效。不过随着工艺和设计水平的提升现代商用RISC类指令集也逐步增加所支持的寻址方式以进一步提升代码密度如64位的LoongArch指令集简称LA64就在寄存器寻址、立即数寻址和偏移量寻址基础之上还支持变址寻址方式。
### 指令操作和编码
现代指令系统中,指令的功能由指令的操作码决定。从功能上来看,指令可分为四大类:第一类为运算指令,包括加减乘除、移位、逻辑运算等;第二类为访存指令,负责对存储器的读写;第三类是转移指令,用于控制程序的流向;第四类是特殊指令,用于操作系统的特定用途。
在四类指令中转移指令的行为较为特殊值得详细介绍。转移指令包括条件转移、无条件转移、过程调用和过程返回等类型。转移条件和转移目标地址是转移指令的两个要素两者的组合构成了不同的转移指令条件转移要判断条件再决定是否转移无条件转移则无须判断条件相对转移是程序计数器PC加上一个偏移量作为转移目标地址绝对转移则直接给出转移目标地址直接转移的转移目标地址可直接由指令得到间接转移的转移目标地址则需要由寄存器的内容得到。程序中的switch语句、函数指针、虚函数调用和过程返回都属于间接转移。由于取指译码时不知道目标地址因此硬件结构设计时处理间接跳转比较麻烦。
转移指令有几个特点第一条件转移在转移指令中最常用第二条件转移通常只在转移指令附近进行跳转偏移量一般不超过16位第三转移条件判定比较简单通常只是两个数的比较。条件转移指令的条件判断通常有两种实现方式采用专用标志位和直接比较寄存器。采用专用标志位方式的通过比较指令或其他运算指令将条件判断结果写入专用标志寄存器中条件转移指令仅根据专用标志寄存器中的判断结果决定是否跳转。采用直接比较寄存器方式的条件转移指令直接对来自寄存器的数值进行比较并根据比较结果决定是否进行跳转。X86和ARM等指令集采用专用标志位方式RISC-V指令集则采用直接比较寄存器方式MIPS和LoongArch指令集中的整数条件转移指令采用直接比较寄存器方式而浮点条件转移指令则采用专用标志位方式。
指令编码就是操作数和操作码在整个指令码中的摆放方式。CISC指令系统的指令码长度可变其编码也比较自由可依据类似于赫夫曼Huffman编码的方式将操作码平均长度缩小。RISC指令系统的指令码长度固定因此需要合理定义来保证各指令码能存放所需的操作码、寄存器号、立即数等元素。图\@ref(fig:loongarch-coding)给出了LoongArch指令集的编码格式。
```{r loongarch-coding, echo = FALSE, fig.cap = "LoongArch指令集的编码格式", fig.align = 'center', out.width='100%'}
knitr::include_graphics("images/chapter2/loongarch-coding.png")
```
如图\@ref(fig:loongarch-coding)所示32位的指令编码被划分为若干个区域按照划分方式的不同共包含9种典型的编码格式即3种不含立即数的格式2R、3R、4R和6种包含立即数的格式2RI8、2RI12、2RI14、2RI16、1RI21和I26。编码中的opcode域用于存放指令的操作码rd、rj、rk和ra域用于存放寄存器号通常rd表示目的操作数寄存器而rj、rk、ra表示源操作数寄存器Ixx域用于存放指令立即数即立即数寻址方式下指令中给出的数。指令中的立即数不仅作为运算型指令的源操作数也作为load/store指令中相对于基地址的地址偏移以及转移指令中转移目标的偏移量。
## RISC指令集比较
本节以MIPS、PA-RISC、PowerPC、SPARC v9和LoongArch为例比较不同RISC指令系统的指令格式、寻址模式和指令功能以加深对RISC的了解。
### 指令格式比较
五种RISC指令集的指令格式如图\@ref(fig:isa-compare)所示。在寄存器类指令中操作码都由操作码OP和辅助操作码OPX组成操作数都包括两个源操作数RS和一个目标操作数RD立即数类指令都由操作码、源操作数、目标操作数和立即数Const组成立即数的位数各有不同跳转类指令大同小异PA-RISC与其他四种差别较大。总的来说五种RISC指令集的指令编码主要组成元素基本相同只是在具体摆放位置上存在差别。
```{r isa-compare, echo = FALSE, fig.cap = "五种RISC指令集的指令编码格式", fig.align = 'center', out.width='100%'}
knitr::include_graphics("images/chapter2/isa-compare.png")
```
### 寻址方式比较
五种指令集的寻址方式如表\@ref(tab:addr-compare)所示。MIPS、SPARC和LoongArch只支持四种常用的寻址方式PowerPC和PA-RISC支持的寻址方式较多。
```{r addr-compare, echo = FALSE, message=FALSE}
autonum <- run_autonum(seq_id = "tab", bkm = "addr-compare", bkm_all = TRUE)
readr::read_csv('./materials/chapter2/addr_compare.csv') %>%
flextable() %>%
set_caption(caption="五种指令集的寻址方式比较", autonum = autonum) %>%
theme_box() %>%
autofit()
```
注:表\@ref(tab:addr-compare)中Y表示支持该寻址方式。
### 公共指令功能
RISC指令集都有一些公共指令如load-store、算术运算、逻辑运算和控制流指令。不同指令集在比较和转移指令上区别较大。
1load-store指令。load指令将内存中的数据取入通用寄存器store指令将通用寄存器中的数据存至内存中。表\@ref(tab:mem-inst)给出了LoongArch指令集的load-store指令实例。当从内存中取回的数据位宽小于通用寄存器位宽时后缀没有U的指令进行有符号扩展即用取回数据的最高位符号位填充目标寄存器的高位否则进行无符号扩展即用数0填充目标寄存器的高位。
```{r mem-inst, echo = FALSE, message=FALSE}
autonum <- run_autonum(seq_id = "tab", bkm = "mem-inst", bkm_all = TRUE)
readr::read_csv('./materials/chapter2/mem_inst.csv') %>%
flextable() %>%
set_caption(caption="LoongArch指令集的load-store指令", autonum = autonum) %>%
theme_box() %>%
autofit()
```
2ALU指令。ALU指令都是寄存器型的常见的ALU指令包括加、减、乘、除、与、或、异或、移位和比较等。表\@ref(tab:alu-inst)为LoongArch指令集的ALU指令实例。其中带有“.W”后缀的指令操作的数据位宽为32位带有“.D”后缀的指令操作的数据位宽为64位双字
```{r alu-inst, echo = FALSE, message=FALSE}
autonum <- run_autonum(seq_id = "tab", bkm = "alu-inst", bkm_all = TRUE)
readr::read_csv('./materials/chapter2/alu_inst.csv') %>%
flextable() %>%
set_caption(caption="LoongArch指令集的ALU指令", autonum = autonum) %>%
theme_box() %>%
autofit()
```
3控制流指令。控制流指令分为绝对转移指令和相对转移指令。相对转移的目标地址是当前的PC值加上指令中的偏移量立即数绝对转移的目标地址由寄存器或指令中的立即数给出。表\@ref(tab:control-inst)为LoongArch指令集中控制流指令的实例。
```{r control-inst, echo = FALSE, message=FALSE}
autonum <- run_autonum(seq_id = "tab", bkm = "control-inst", bkm_all = TRUE)
readr::read_csv('./materials/chapter2/control_inst.csv') %>%
flextable() %>%
set_caption(caption="LoongArch指令集的控制流指令", autonum = autonum) %>%
theme_box() %>%
autofit()
```
在条件转移指令中转移条件的确定有两种方式判断条件码和比较寄存器的值。SPARC采用条件码的方式整数运算指令置条件码条件转移指令使用条件码进行判断。MIPS和LoongArch的定点转移指令使用寄存器比较的方式进行条件判断而浮点转移指令使用条件码。PowerPC中包含一个条件寄存器条件转移指令指定条件寄存器中的特定位作为跳转条件。PA-RISC有多种选择通常通过比较两个寄存器的值来决定是否跳转。
RISC指令集中很多条件转移采用了转移延迟槽Delay Slot技术程序中条件转移指令的后一条指令为转移延迟槽指令。在早期的静态流水线中条件转移指令在译码时后一条指令即进入取指流水级。为避免流水线效率的浪费有些指令集规定转移延迟槽指令无论是否跳转都要执行。MIPS、SPARC和PA-RISC都实现了延迟槽但对延迟槽指令是否一定执行有不同的规定。对于当今常用的动态流水线和多发射技术而言延迟槽技术则没有使用的必要反而成为指令流水线实现时需要特殊考虑的负担。Alpha、PowerPC和LoongArch均没有采用转移延迟槽技术。
### 不同指令系统的特色
除了上述公共功能外不同的RISC指令集经过多年的发展形成了各自的特色下面举例介绍其各自的主要特色。
1MIPS部分指令特色。前面介绍过访存地址的对齐问题当确实需要使用不对齐数据时采用对齐访存指令就需要复杂的地址计算、移位和拼接等操作这会给大量使用不对齐访存的程序带来明显的代价。MIPS指令集实现了不对齐访存指令LWL/LWR。LWL指令读取访存地址所在的字并将访存地址到该字中最低位的字节拼接到目标寄存器的高位LWR指令读取访存地址所在的字并将访存地址到该字中最高位的字节拼接到目标寄存器的低位。上述字中的最低位和最高位字节会根据系统采用的尾端而变化不同尾端下LWL和LWR的作用相反。例如要加载地址1至4的内容到R1寄存器不同尾端的指令和效果如图\@ref(fig:lwl)所示。
```{r lwl, echo = FALSE, fig.cap = "不同尾端下的LWL/LWR指令效果", fig.align = 'center', out.width='80%'}
knitr::include_graphics("images/chapter2/lwl.png")
```
LWL和LWR指令设计巧妙兼顾了使用的便利性和硬件实现的简单性是MIPS指令集中比较有特色的指令。
2SPARC部分指令特色。SPARC指令系统有很多特色这里挑选寄存器窗口进行介绍。在SPARC指令系统中一组寄存器SPARC v9中规定为8\~31号寄存器可用于构成窗口窗口可有多个0\~7号寄存器作为全局寄存器。寄存器窗口的好处在于函数调用时可不用保存现场只需切换寄存器组。
3PA-RISC部分指令特色。PA-RISC指令集最大的特色就是Nullification指令除了条件转移指令其他指令也可以根据执行结果确定下一条指令是否执行。例如ADDBFadd and branch if false指令在完成加法后检查加法结果是否满足条件如果不满足就进行转移。一些简单的条件判断可以用Nullification指令实现。
4PowerPC部分指令特色。在RISC结构中PowerPC的寻址方式、指令格式和转移指令都是最多的甚至支持十进制运算因此又被称为“RISC中的CISC”。表\@ref(tab:ppc-vs-alpha)给出了分别用PowerPC指令和Alpha指令实现的简单程序示例。实现同样的循环程序PowerPC只需要6条指令Alpha则需要10条指令原因就在于PowerPC的指令功能较强。例如其中的LFUload with update和STFUstore with update指令除了访存外还能自动修改基址寄存器的值FMADD可以在一条指令中完成乘法和加法转移指令BC可同时完成计数值减1和条件转移。
```{r ppc-vs-alpha, echo = FALSE, message=FALSE, tab.cap='PowerPC和Alpha汇编对比', tab.id='ppc-vs-alpha'}
dt <- data.frame('PowerPC代码' = c(read_file('./materials/chapter2/ppc_note.txt'),read_file('./materials/chapter2/ppc.S')),
'Alpha代码' = c(read_file('./materials/chapter2/alpha_note.txt'),read_file('./materials/chapter2/alpha.S')))
autonum <- run_autonum(seq_id = "tab", bkm = "ppc-vs-alpha", bkm_all = TRUE)
flextable(dt) %>%
set_caption(caption="PowerPC和Alpha汇编对比", autonum = autonum) %>%
width(j=c(1,2), width=3) %>%
add_header_row(values = "源代码for(k=0;k<512;k++) x[k]=r*x[k]+t*y[k];", colwidths = 2) %>%
valign(i = NULL, j = NULL, valign = "top", part = "body") %>%
theme_box()
```
## C语言的机器表示
C语言等高级语言编写的程序必须经过编译器转换为汇编语言再由汇编器转换为指令码才能在CPU上执行。本节简要介绍高级语言转换为指令码涉及的一些问题为方便起见选择C语言和LoongArch汇编码进行介绍。
### 过程调用
过程调用是高级语言程序中的一个关键特性它可以让特定程序段的内容与其他程序和数据分离。过程接受参数输入并通过参数返回执行结果。C语言中过程和函数的概念相同本节后面也不进行区分。过程调用中调用者和被调用者必须遵循同样的接口约定包括寄存器使用、栈的使用和参数传递的约定等。这部分涉及内容较多将在第4章中进行详细的介绍。本节中主要介绍过程调用的流程和其中与指令集相关的内容。
在LoongArch指令集中负责函数调用的指令是BL这是一条相对转移指令。该指令在跳转的同时还将其下一条指令的地址放入1号通用寄存器记为\$ra作为函数返回地址。负责函数返回的指令是JR^[在LoongArch指令集中JR指令是JIRL指令rd=0且offs16=0时的别称。]属于间接跳转指令该指令的操作数为寄存器因此LoongArch汇编中最常见的函数返回指令是jr \$ra。
除了调用和返回的指令外,函数调用和执行过程中还需要执行一系列操作:
* 调用者S将参数实参放入寄存器或栈中
* 使用BL指令调用被调用者R
* R在栈中分配自己所需要的局部变量空间
* 执行R过程
* R释放局部变量空间将栈指针还原
* R使用JR指令返回调用者S。
默认情况下,通用寄存器\$r4\~\$r11(记为\$a0\~\$a7)作为参数输入,其中\$r4和\$r5同时也作为返回值通用寄存器\$r12\~\$r20(记为\$t0\~\$t8)作为子程序的暂存器无须存储和恢复。LoongArch中没有专门的栈结构和栈指针通用寄存器\$r3(记为\$sp)通常作为栈指针寄存器,指向栈顶。
一个简单的C语言过程调用程序及其LoongArch汇编码如表\@ref(tab:c-vs-as)所示。
```{r c-vs-as, echo = FALSE, message=FALSE, tab.cap='C程序和对应的汇编代码', tab.id='c-vs-as'}
autonum <- run_autonum(seq_id = "tab", bkm = "c-vs-as", bkm_all = TRUE)
dt <- data.frame('C代码' = read_file('./materials/chapter2/add_and_ref.c'),
'LoongArch汇编' = read_file('./materials/chapter2/add_and_ref.S'))
flextable(dt) %>%
# colformat_md(md_extensions = "+hard_line_breaks") %>%
set_caption(caption="C程序和对应的汇编代码", autonum = autonum) %>%
theme_box() %>%
autofit()
```
ref程序是add程序的调用者通过BL指令进行调用BL指令会修改$ra寄存器的值因此在ref中需要将$ra寄存器的值保存到栈中栈顶指针和RA值存放的位置遵循LoongArch函数调用规范这部分内容将在4.1节中进行介绍。add程序的返回值放在$a0寄存器中这同时也是ref程序的返回值因此无须进行更多搬运。
### 流程控制语句
C语言中的控制流语句共有9种可分为三类辅助控制语句、选择语句、循环语句如表\@ref(tab:c-control)所示。
```{r c-control, echo = FALSE, message=FALSE, tab.cap='C语言控制流语句', tab.id='c-control'}
autonum <- run_autonum(seq_id = "tab", bkm = "c-control", bkm_all = TRUE)
dt <- data.frame('set' = c(rep('控制流语句',9)),
'subset' = c(rep('选择语句',2), rep('循环语句',3), rep('辅助控制语句',4)),
'stat' = c('if ~ else', 'switch ~ case', 'for', 'while', 'do ~ while', 'break', 'continue', 'goto', 'return'))
flextable(dt) %>%
set_caption(caption="C语言控制流语句", autonum = autonum) %>%
delete_part(part='header') %>%
merge_v() %>%
theme_box() %>%
autofit()
```
1辅助控制语句
goto语句无条件地跳转到程序中某标号处其作用与无条件相对跳转指令相同在LoongArch指令集中表示为B指令跳转到一个标号。break、continue语句的作用与goto类似只是跳转的标号位置不同。return语句将过程中的变量作为返回值并直接返回在编译器中对应于返回值写入和返回操作。
2选择语句
if\~else语句及其对应的LoongArch汇编码如表\@ref(tab:if-else)所示。
```{r if-else, echo = FALSE, message=FALSE, tab.cap='if~else语句及其LoongArch汇编表示', tab.id='if-else'}
autonum <- run_autonum(seq_id = "tab", bkm = "if-else", bkm_all = TRUE)
dt <- data.frame('C代码' = read_file('./materials/chapter2/if_else.c'),
'LoongArch汇编' = read_file('./materials/chapter2/if_else.S'))
flextable(dt) %>%
# colformat_md(md_extensions = "+hard_line_breaks") %>%
set_caption(caption="if~else语句及其LoongArch汇编表示", autonum = autonum) %>%
theme_box() %>%
autofit()
```
这里的if \~ else实现采用了BEQZ指令当\$t0寄存器的值等于0时进行跳转跳转到标号.L1执行“else”分支中的操作当\$t0寄存器的值不等于0时则顺序执行“then”分支中的操作并在完成后无条件跳转到标号.L2处绕开“else”分支。
switch \~ case语句的结构更为复杂由于可能的分支数较多通常会被映射为跳转表的形式如表\@ref(tab:switch-case)所示。如果在编译选项中加入-fno-jump-tables的选项那么switch \~ case语句还可以被映射为跳转级联的形式如表\@ref(tab:switch-case-chain)所示。
```{r switch-case, echo = FALSE, message=FALSE, tab.cap='switch~case语句及其跳转表形式的LoongArch机器表示', tab.id='switch-case'}
autonum <- run_autonum(seq_id = "tab", bkm = "switch-case", bkm_all = TRUE)
dt <- data.frame('C代码' = read_file('./materials/chapter2/switch_case.c'),
'LoongArch汇编' = read_file('./materials/chapter2/switch_case.S'))
flextable(dt) %>%
footnote(i=1, j=2,
value = as_paragraph(c("alsl.d rd, rj, rk, sa所进行的操作是GR[rd]=(GR[rj]<<sa)+GR[rk]。即将rj号通用寄存器中的值先左移sa位再与rk号通用寄存器中的值相加结果写入rd号通用寄存器中。")),
ref_symbols = NULL,
part='body') %>%
# colformat_md(md_extensions = "+hard_line_breaks") %>%
set_caption(caption="switch~case语句及其跳转表形式的LoongArch机器表示", autonum = autonum) %>%
theme_box() %>%
autofit()
```
```{r switch-case-chain, echo = FALSE, message=FALSE, tab.cap='switch~case语句及其跳转级联形式的LoongArch机器表示', tab.id='switch-case-chain'}
autonum <- run_autonum(seq_id = "tab", bkm = "switch-case-chain", bkm_all = TRUE)
dt <- data.frame('C代码' = read_file('./materials/chapter2/switch_case.c'),
'LoongArch汇编' = read_file('./materials/chapter2/switch_case_chain.S'))
flextable(dt) %>%
# colformat_md(md_extensions = "+hard_line_breaks") %>%
set_caption(caption="switch-case语句及其跳转级联形式的LoongArch机器表示", autonum = autonum) %>%
theme_box() %>%
autofit()
```
在这个例子中,\$t0寄存器存放各case分支的值并依次与第一个参数a存放在\$a0寄存器中进行比较根据比较的结果分别跳转到指定标号。读者可自行分析各case分支的执行流。通过比较表\@ref(tab:switch-case)和\@ref(tab:switch-case-chain)中的汇编代码可以看到在case分支较多时采用跳转表实现有助于减少级联的转移指令。
### 循环语句
循环语句均可映射为条件跳转指令与选择语句的区别就在于跳转的目标标号在程序段已执行过的位置backward。三种循环语句的C语言及其对应的LoongArch汇编码如表\@ref(tab:loop)所示。
```{r loop, echo = FALSE, message=FALSE, tab.cap='循环语句及其LoongArch机器表示', tab.id='loop'}
autonum <- run_autonum(seq_id = "tab", bkm = "loop", bkm_all = TRUE)
dt <- data.frame('C代码' = read_file('./materials/chapter2/loop.c'),
'LoongArch汇编' = read_file('./materials/chapter2/loop.S'))
flextable(dt) %>%
# colformat_md(md_extensions = "+hard_line_breaks") %>%
set_caption(caption="循环语句及其LoongArch机器表示", autonum = autonum) %>%
theme_box() %>%
autofit()
```
## 本章小结
本章介绍了指令系统在整个计算机系统中位于软硬件界面的位置,讨论了指令系统设计的原则和影响因素,并从指令内容、存储管理、运行级别三个角度介绍指令系统的发展历程。
本章首先介绍了指令集的关键要素——地址空间定义、指令操作数、指令操作码随后对几种不同的RISC指令集进行了比较最后以LoongArch指令集为例给出了C语言和指令汇编码之间的对应关系。
## 习题
1. 请以某一种指令系统为例,说明其定义了哪些运行级别,以及这些运行级别之间的区别与联系。
2. 请用C语言伪代码形式描述一个采用段页式存储管理机制的计算机系统进行虚实地址转换的过程。说明不用描述微结构相关的内容如TLB段描述符或页表中的各种属性域均视作有效。
3. 请简述桌面电脑PPT翻页过程中用户态和核心态的转换过程。
4. 给定下列程序片段:
```
A = B + C
B = A + C
C = B + A
```
* 写出上述程序片段在四种指令系统类型(堆栈型、累加器型、寄存器-存储器型、寄存器-寄存器型)中的指令序列。
* 假设四种指令系统类型都属于CISC型令指令码宽度为x位寄存器操作数宽度为y位内存地址操作数宽度为z位数据宽度为w位。 分析指令的总位数和所有内存访问的总位数。
* 微处理器由32位时代进入了64位时代上述四种指令系统类型哪种更好
5. 写出0xDEADBEEF在大尾端和小尾端下在内存中的排列(由地址0开始
6. 在你的机器上编写C程序来得到不同数据类型占用的字节数给出程序和结果。
7. 根据LoongArch指令集的编码格式计算2RI16、1RI21和I26三种编码格式的直接转移指令各自的跳转范围。
8. 仅使用对齐访存指令写出如图\@ref(fig:lwl)的不对齐加载(小尾端)。
\newpage

390
13-privileged-isa.Rmd Normal file
View File

@@ -0,0 +1,390 @@
# 特权指令系统 {#sec-privileged-ISA}
## 特权指令系统简介
在计算机系统层次结构中,应用层^[特指直接运行在CPU上的应用把虚拟机及其上运行的应用作为整体看待。]在操作系统层之上,只能看到和使用指令系统的一个子集,即指令系统的用户态部分。每个应用程序都有自己的寄存器、内存空间以及可执行的指令。现代计算机的指令系统在用户态子集之外还定义了操作系统核心专用的特权态部分,我们称之为特权指令系统。
特权指令系统的存在主要是为了让计算机变得更好用、更安全。操作系统通过特权指令系统管理计算机使得应用程序形成独占CPU的假象并使应用间相互隔离互不干扰。应用程序只能在操作系统划定的范围内执行一旦超出就会被CPU切换成操作系统代码运行。
不同指令系统的特权态部分差别较大,但就其机制而言,可以分为以下几类:
1运行模式定义及其转换
现代计算机的操作系统都实现了保护模式至少需要用户态和核心态两种运行模式。应用运行在用户态模式下操作系统运行在核心态模式下。因此指令系统必须有相应的运行模式以做区分。比如MIPS定义了user、supervisor、kernel三种模式X86定义了Ring0\~Ring3四种模式LoongArch定义了PLV0\~PLV3四种模式。
刚开机时CPU初始化为操作系统核心态对应的运行模式执行引导程序加载操作系统。操作系统做完一系列初始化后控制CPU切换到操作系统用户态对应的运行模式去执行应用程序。应用程序执行过程中如果出现用户态对应的运行模式无法处理的事件则CPU会通过异常或中断回到核心态对应的运行模式执行操作系统提供的服务程序。操作系统完成处理后再控制CPU返回用户态对应的运行模式继续运行原来的应用程序或者调度另一个应用程序。在LoongArch指令系统中CPU当前所处的运行模式由当前模式信息控制状态寄存器CSR.CRMD的PLV域的值确定其值为03分别表示CPU正处于PLV0PLV3四种运行模式见图\@ref(fig:crmd))。
```{r crmd, echo=FALSE, fig.align='center', fig.cap="LoongArch当前模式信息控制状态寄存器格式", out.width='100%'}
knitr::include_graphics("images/chapter3/crmd.png")
```
运行模式的转换过程与虚拟存储和异常中断紧密相关,共同构建出完备的保护模式。不少指令系统还支持虚拟机模式、调试模式等,使计算机系统更为易用。
2虚拟存储管理
虚拟存储管理的基本思想是让软件包括系统软件运行在“虚地址”上与真正访问存储的“实地址”物理地址相隔离。虚实地址的转换根据地址段属性的不同有查表转换和直接映射两种方式。查表转换是应用程序使用的主要方式。不同的进程有自己独立的虚地址空间。CPU执行访存指令时根据操作系统给出的映射表来完成虚地址空间到物理内存的转换。
直接映射的方式与使用物理地址差别不大,主要给操作系统使用,因为在初始化之前负责虚存管理的代码本身不能运行在被管理的虚地址空间。通常用户态应用程序无法使用直接映射方式。
\@ref(sec-memory-management)节将对存储管理做更详细的介绍。
3异常与中断处理
异常与中断是一种打断正常的软件执行流切换到专门的处理函数的机制。它在各种运行模式的转换中起到关键的纽带作用。比如用户态代码执行过程中当出现对特权空间的访问或者访问了虚实地址映射表未定义的地址或者需要调用操作系统服务等情况时CPU通过发出异常来切换到核心态进入操作系统定义的服务函数。操作系统完成处理后返回发生异常的代码并同时切换到用户态。
\@ref(sec-exception)节将对异常与中断做更详细的介绍。
4控制状态寄存器
控制状态寄存器位于一个独立的地址空间是支撑前面3种机制的具体实现不同的指令系统差别较大。下面以LoongArch指令系统为例列出其控制状态寄存器的功能。
```{r la-csr, echo = FALSE, message=FALSE, tab.cap='LoongArch处理器的控制寄存器', tab.id='la-csr'}
autonum <- run_autonum(seq_id = "tab", bkm = "la-csr", bkm_all = TRUE)
readr::read_csv('./materials/chapter3/csr.csv') %>%
flextable() %>%
set_caption(caption="LoongArch处理器的控制寄存器", autonum = autonum) %>%
merge_v() %>%
width(j=1, width=1.0) %>%
width(j=2, width=1.0) %>%
width(j=3, width=4.0) %>%
theme_box()
```
控制状态寄存器虽然重要但对其操作的频率通常远远低于通用寄存器所以指令系统中通常不会设计针对控制状态寄存器的访存和复杂运算指令。不过大多数指令系统至少会定义若干在控制状态寄存器和通用寄存器之间进行数据搬运的指令从而可以将数据移动到通用寄存器中进行相关处理或者进一步将处理结果写回控制状态寄存器中。在LoongArch指令系统中就定义了CSRRD和CSRWR指令来完成控制状态寄存器的读写操作。例如指令“csrrd \$t0, CSR_CRMD ^[这里CSR_CRMD是一个宏定义表示一个立即数其值为CRMD控制状态寄存器的编号0x0。使用CSR_CRMD这样的宏定义是为了便于代码理解。] ”将控制状态寄存器CRMD的值读出然后写入通用寄存器\$t0中指令“csrwr \$t0, CSR_CRMD”将通用寄存器\$t0中的值写入到控制状态寄存器CRMD中同时将控制状态寄存器CRMD的旧值写入通用寄存器\$t0中。
## 异常与中断 {#sec-exception}
计算机通常按照软件的执行流进行顺序执行和跳转,但有时会需要中断正常的执行流程去处理其他任务,可以触发这一过程的事件统称为异常。
### 异常分类
从来源来看异常可分为以下6种。
1外部事件来自CPU核外部^[这里“CPU核”可以更为严格地理解为CPU核的指令流水线即旨在强调这些事件并非直接由指令引发。以定时中断为例它由一个物理上独立于CPU指令流水线而存在的定时器触发但是这个定时器既可以放置在CPU核内部也可以放置在CPU核外部。]的事件来自处理器内部其他模块或者处理器外部的真实物理连线也称为中断。中断的存在使得CPU能够异步地处理多个事件。在操作系统中为了避免轮询等待浪费CPU时间与IO相关的任务通常都会用中断方式进行处理。中断事件的发生往往是软件不可控制的因此需要一套健全的软硬件机制来防止中断对正常执行流带来影响。
2指令执行中的错误执行中的指令的操作码或操作数不符合要求例如不存在的指令、除法除以0、地址不对齐、用户态下调用核心态专有指令或非法地址空间访问等。这些错误使得当前指令无法继续执行应当转到出错处进行处理。
3数据完整性问题当使用ECC等硬件校验方式的存储器发生校验错误时会产生异常。可纠正的错误可用于统计硬件的风险不可纠正的错误则应视出错位置进行相应处理。
4地址转换异常在存储管理单元需要对一个内存页进行地址转换而硬件转换表中没有有效的转换对应项可用时会产生地址转换异常。
5系统调用和陷入由专有指令产生其目的是产生操作系统可识别的异常用于在保护模式下调用核心态的相关操作。
6需要软件修正的运算常见的是浮点指令导致的异常某些操作和操作数的组合硬件由于实现过于复杂而不愿意处理寻求软件的帮助。
下表列举了LoongArch指令系统中主要的异常。
```{r exception, echo = FALSE, message=FALSE, tab.cap='LoongArch指令系统的异常一览表', tab.id='exception'}
autonum <- run_autonum(seq_id = "tab", bkm = "exception", bkm_all = TRUE)
readr::read_csv('./materials/chapter3/exception.csv') %>%
flextable() %>%
set_caption(caption="LoongArch指令系统的异常一览表", autonum = autonum) %>%
add_header_row(values=c('异常代号','异常编号','异常说明','所属异常类别'), colwidths=c(1,2,1,1)) %>%
merge_h(part='header') %>%
merge_v(part='header') %>%
merge_v(j=c('Ecode'), part='body') %>%
width(j=1:3, width=0.8) %>%
width(j=4:5, width=1.8) %>%
theme_box()
```
### 异常处理
#### 异常处理流程
异常处理的流程包括异常处理准备、确定异常来源、保存执行状态、处理异常、恢复执行状态并返回等。主要内容是确定并处理异常同时正确维护上下文环境。异常处理是一个软硬件协同的过程通常CPU硬件需要维护一系列控制状态寄存器以用于软硬件之间的交互。LoongArch指令系统中与异常含中断处理相关的控制状态寄存器格式如图\@ref(fig:ex-csr)所示。
```{r ex-csr, echo=FALSE, fig.align='center', fig.cap="LoongArch异常处理相关控制状态寄存器", out.width='100%'}
knitr::include_graphics("images/chapter3/csr.png")
```
下面对异常处理流程的五个阶段进行介绍。
1异常处理准备。当异常发生时CPU在转而执行异常处理前硬件需要进行一系列准备工作。
首先需要记录被异常打断的指令的地址记为EPTR。这里涉及精确异常的概念指发生任何异常时被异常打断的指令之前的所有指令都执行完而该指令之后的所有指令都像没执行一样。在实现精确异常的处理器中异常处理程序可忽略因处理器流水线带来的异常发生位置问题。异常处理结束后将返回EPTR所在地址重新执行被异常打断的指令^[这只是通常的处理流程但并非始终如此存在某些异常处理场景其结束后返回执行的并非最初被该异常打断的指令。例如当执行SYSCALL指令而陷入系统调用异常处理时肯定不能在处理结束后返回触发异常的SYSCALL指令否则将陷入死循环。再譬如当发生中断并陷入操作系统核心进行处理时处理结束后操作系统可能将其他进程或线程调度到该CPU上执行显然此时返回执行的并不是最初被中断打断的那条指令。]因此需要将EPTR记录下来。EPTR存放的位置因不同指令集而不同LoongArch存于CSR.ERA^[其实TLB重填异常发生时这一信息将被记录在CSR.TLBRBERA中;机器错误异常发生时这一信息将被记录在CSR.MERRERA中。更多细节请见下文中的说明。]PowerPC存于SRR0/CSRR0SPARC存于TPC[TL]X86则用栈存放CS和EIP组合。
其次调整CPU的权限等级通常调整至最高特权等级并关闭中断响应。在LoongArch指令系统中当异常发生时硬件会将CSR.PLV置0以进入最高特权等级并将CSR.CRMD的IE域置0以屏蔽所有中断输入。
再次硬件保存异常发生现场的部分信息。在LoongArch指令系统中异常发生时会将CSR.CRMD中的PLV和IE域的旧值分别记录到CSR.PRMD的PPLV和PIE域中供后续异常返回时使用。
最后记录异常的相关信息。异常处理程序将利用这些信息完成或加速异常的处理。最常见的如记录异常编号以用于确定异常来源。在LoongArch指令系统中这一信息将被记录在CSR.ESTAT的Ecode和EsubCode域前者存放异常的一级编号后者存放异常的二级编号。除此以外有些情况下还会将引发异常的指令的机器码记录在CSR.BADI中或是将造成异常的访存虚地址记录在CSR.BADV中。
2确定异常来源。不同类型的异常需要各自对应的异常处理。处理器确定异常来源主要有两种方式一种是将不同的异常进行编号异常处理程序据此进行区分并跳转到指定的处理入口另一种是为不同的异常指定不同的异常处理程序入口地址这样每个入口处的异常处理程序自然知晓待处理的异常来源。X86由硬件进行异常和中断号的查询根据编号查询预设好的中断描述符表Interrupt Descriptor Table简称IDT得到不同异常处理的入口地址并将CS/EIP等压栈。LoongArch将不同的异常进行编号其异常处理程序入口地址采用“入口页号与页内偏移进行按位逻辑或”的计算方式入口页号通过CSR.EENTRY配置每个普通异常处理程序入口页内偏移是其异常编号乘以一个可配置间隔通过CSR.ECFG的VS域配置。通过合理配置EENTRY和ECFG控制状态寄存器中相关的域可以使得不同异常处理程序入口地址不同。当然也可以通过配置使得所有异常处理程序入口为同一个地址但是实际使用中通常不这样处理。
3保存执行状态。在操作系统进行异常处理前软件要先保存被打断的程序状态通常至少需要将通用寄存器和程序状态字寄存器的值保存到栈中。
4处理异常。跳转到对应异常处理程序进行异常处理。
5恢复执行状态并返回。在异常处理返回前软件需要先将前面第3个步骤中保存的执行状态从栈中恢复出来在最后执行异常返回指令。之所以要采用专用的异常返回指令是因为该指令需要原子地完成恢复权限等级、恢复中断使能状态、跳转至异常返回目标等多个操作。在LoongArch中异常返回的指令是ERTN该指令会将CSR.PRMD的PPLV和PIE域分别回填至CSR.CRMD的PLV和IE域从而使得CPU的权限等级和全局中断响应状态恢复到异常发生时的状态同时该指令还会将CSR.ERA中的值作为目标地址跳转过去。X86的IRET指令有类似效果。
#### 异常嵌套
在异常处理的过程中,又有新的异常产生,这时就会出现异常嵌套的问题。当产生异常嵌套时,需要保存被打断的异常处理程序的状态,这会消耗一定的栈资源,因此无限的异常嵌套是无法容忍的。异常嵌套通常基于优先级,只有优先级更高的异常才能进行嵌套,低优先级或同优先级的异常只能等待当前异常处理完成,系统支持的优先级级数就是异常嵌套的最大层数。
在LoongArch指令系统中异常嵌套时被打断的异常处理程序的状态的保存和恢复主要交由软件处理这就需要保证异常处理程序在完成当前上下文的保存操作之前不会产生新的异常或者产生的新异常不会修改当前需要保存的上下文。这两方面要求仅通过异常处理程序开发人员的精心设计是无法完全保证的因为总有一些异常的产生原因是事先无法预知的如中断、机器错、TLB重填等。为此需要设计硬件机制以保证这些情况发生时不至于产生嵌套异常或即使产生嵌套异常也能保证软件可以获得所要保存上下文的正确内容。例如可以在跳转到异常入口的过程中关闭全局中断使能以禁止中断异常发生还可以在发生嵌套异常的时候将可能被破坏而软件又来不及保存的上下文信息由硬件暂存到指定的控制状态寄存器或内存区域。
### 中断
异常处理的流程是通用的,但有两类异常出现的机会确实比其他类型大很多。一类是地址转换异常,当片内从虚地址到物理地址的地址转换表不包含访问地址时,就会产生缺页异常,在\@ref(sec-memory-management)节中我们将进行详细介绍。另一类常见的异常就是中断中断在外部事件想要获得CPU注意时产生。由于外部事件的不可控性中断处理所用的时间较为关键。在嵌入式系统中CPU的主要作用之一就是处理外设相关事务因此中断发生的数量很多且非常重要。本节以LoongArch指令系统为例介绍中断相关的重要内容。
#### 中断传递机制
中断从系统中各个中断源传递到处理器主要有两种形式:中断线和消息中断。
用中断线传递是最简便直接的方式。当系统的中断源不多时,直接连到处理器引脚即可。若中断源较多,可使用中断控制器汇总后再与处理器引脚相连。由于连线会占用引脚资源,一般只在片上系统(System On Chip,简称SoC)中才会给每个外设连接单独的中断线板级的中断线一般采用共享的方式。比如PCI上有四根中断线供所有的设备共享。中断处理程序在定位到哪根中断线发生中断后逐个调用注册在该中断线的设备中断服务。
LoongArch指令系统支持中断线的中断传递机制共定义了13个中断分别是1个核间中断IPI1个定时器中断TI1个性能监测计数溢出中断PMI8个外部硬中断HWI0\~HWI72个软中断SWI0\~SWI1。其中所有中断线上的中断信号都采用电平中断且都是高电平有效。当有中断发生时这种高电平有效中断方式输入给处理器的中断线上将维持高电平状态直至中断被处理器响应处理。无论中断源来自处理器核外部还是内部是硬件还是软件置位这些中断信号都被不间断地采样并记录到CSR.ESTAT中IS域的对应比特位上。这些中断均为可屏蔽中断除了CSR.CRMD中的全局中断使能位IE外每个中断各自还有其局部中断使能控制位在CSR.ECFG的LIE域中。当CSR.ESTAT中IS域的某位为1且对应的局部中断使能和全局中断使能均有效时处理器就将响应该中断并进入中断处理程序入口处开始执行。
用中断线方式传递中断有一些限制。首先是扩展性不够强,在搭建较复杂的板级系统时会引入过多的共享,降低中断处理的效率。其次,中断处理过程需要通过查询中断控制器以及设备上的状态寄存器来确认中断和中断原因,中间有较长的延迟,同样不利于提高效率。在多处理器平台中,高性能外设(如万兆网卡)对中断处理的性能有更高的要求,需要实现多处理器的负载均衡、中断绑定等功能,传统的中断线方式难以做到。而这正好是消息中断的长处。
消息中断以数据的方式在总线上传递。发中断就是向指定的地址写一个指定的数。相比总线外增加专门的中断线的“带外”Side-Band)传输形式,消息中断在“带内”(In-Band传输。增加中断时不需要改动消息传递的数据通路因而有较高的扩展性和灵活性也为更高程度的优化提供了可能。比如一个设备可以申请更多的中断号使中断处理程序无须查询设备状态只根据中断号就能知道应当做什么处理。
#### 向量化中断
LoongArch指令系统默认支持向量化中断^[尽管将ECFG控制状态寄存器中的VS域置0后所有的异常处理程序入口地址将变为同一个此时中断不再是向量中断形式但这种模式并不是LoongArch指令系统推荐的方式。]其13个线中断各自具有独立的中断处理程序入口地址。在LoongArch指令系统中中断被视作一类特殊的异常进行处理因此在具体计算中断处理程序入口地址时将SWI0IPI这13个中断依次“视作”异常编号6476的异常用异常处理程序入口地址的统一计算方式进行计算。向量化中断的好处之一是省去了中断处理程序开头处识别具体中断源的开销可以进一步加速中断的处理。
X86指令系统支持的向量化中断方案更复杂一些其在地址空间的指定位置处存放中断向量表IVT实模式下默认为0地址或中断描述符表IDT保护模式中断向量表中存放中断入口地址的段地址和偏移量中断描述符表还包含权限等级和描述符类别的信息。X86的向量化中断机制最多可支持256个中断和异常0\~19号为系统预设的异常和NMI20\~31是Intel保留的编号32号开始可用于外部中断详细的实现可参考Intel相关手册。
#### 中断的优先级
在支持多个中断源输入的指令系统中,需要规范在多个中断同时触发的情况下,处理器是否区别不同来源的中断的优先级。当采用非向量中断模式的时候,处理器通常不区别中断优先级,此时若需要对中断进行优先级处理,可以通过软件方式予以实现,其通常的实现方案是:
1软件随时维护一个中断优先级IPL每个中断源都被赋予特定的优先级。
2正常状态下CPU运行在最低优先级此时任何中断都可触发。
3当处于最高中断优先级时任何中断都被禁止。
4更高优先级的中断发生时可以抢占低优先级的中断处理过程。
当采用向量中断模式的时候处理器通常不可避免地需要依照一套既定的优先级规则来从多个已生效的中断源中选择一个跳转到其对应的处理程序入口处。LoongArch指令系统实现的是向量中断采用固定优先级仲裁机制具体规则是硬件中断号越大优先级越高即IPI的优先级最高TI次之SWI0的优先级最低。
#### 中断使能控制位的原子修改
在中断处理程序中经常会涉及中断使能控制位的修改如关闭、开启全局中断使能。在大多数指令系统中这些中断使能控制位位于控制状态寄存器中因此软件在进行中断使能控制调整时必须关注修改的原子性问题。以LoongArch指令系统为例全局中断使能控制位IE位于CRMD控制寄存器的第2位。如果仅用CSRRD和CSRWR指令访问CRMD控制寄存器那么需要通过下面的一段程序才能完成开启中断使能的功能
```
li $t1, IE_BITMASK
csrrd $t0, CSR_CRMD
1:
andn $t0, $t0, $t1
or $t0, $t0, $t1
2:
csrwr $t0, CSR_CRMD
```
这段程序本身也可能被中断若在标号1和2之间被中断且中断处理程序修改了CRMD控制寄存器的值则在返回时该中断处理程序对CRMD控制寄存器的改写会被这段程序覆盖。若不想让这种情况发生就需要保证这段程序不会被打断更正式地说是保证这段程序的原子性。保证原子性的方法有很多种例如添加专门的位原子修改指令、在程序执行时禁用中断、不允许中断处理程序修改SR或者使用通用的方法保证程序段的原子性即将被访问的控制寄存器作为临界区来考虑。LoongArch指令系统中定义了按位掩码修改控制寄存器的指令CSRXCHG。使用该指令时上述开启全局中断使能的代码改写如下
```
li $t0, IE_BITMASK
csrxchg $t0, $t0, CSR_CRMD
```
上面的例子中CRMD寄存器的IE位置1的操作仅通过csrxchg一条指令完成所以自然确保了修改的原子性。
## 存储管理 {#sec-memory-management}
处理器的存储管理部件Memory Management Unit简称MMU支持虚实地址转换、多进程空间等功能是通用处理器体现“通用性”的重要单元也是处理器和操作系统交互最紧密的部分。
本节将介绍存储管理的作用、意义和一般性原理并以Linux/LoongArch系统为例重点介绍存储管理中TLB的结构、操作方式以及TLB地址翻译过程中所涉及异常的处理。
### 存储管理的原理
存储管理构建虚拟的内存地址并通过MMU进行虚拟地址到物理地址的转换。存储管理的作用和意义包括以下方面。
1隐藏和保护用户态程序只能访问受限内存区域的数据其他区域只能由核心态程序访问。引入存储管理后不同程序仿佛在使用独立的内存区域互相之间不会影响。此外分页的存储管理方法对每个页都有单独的写保护核心态的操作系统可防止用户程序随意修改自己的代码段。
2为程序分配连续的内存空间MMU可以由分散的物理页构建连续的虚拟内存空间以页为单元管理物理内存分配。
3扩展地址空间在32位系统中如果仅采用线性映射的虚实地址映射方式则至多访问4GB物理内存空间而通过MMU进行转换则可以访问更大的物理内存空间。
4节约物理内存程序可以通过合理的映射来节约物理内存。当操作系统中有相同程序的多个副本在同时运行时让这些副本使用相同的程序代码和只读数据是很直观的空间优化措施而通过存储管理可以轻松完成这些。此外在运行大型程序时操作系统无须将该程序所需的所有内存都分配好而是在确实需要使用特定页时再通过存储管理的相关异常处理来进行分配这种方法不但节约了物理内存还能提高程序初次加载的速度。
页式存储管理是一种常见而高效的方式操作系统将内存空间分为若干个固定大小的页并维护虚拟页地址和物理页地址的映射关系即页表。页大小涉及页分配的粒度和页表所占空间目前的操作系统常用4KB的页。此时虚拟内存地址可表示为虚拟页地址和页内偏移两部分在进行地址转换时通过查表的方式将虚拟页地址替换为物理页地址就可得到对应的物理内存地址。
在32位系统中采用4KB页时单个完整页表需要1M项对每个进程维护页表需要相当可观的空间代价因此页表只能放在内存中。若每次进行地址转换时都需要先查询内存则会对性能产生明显的影响。为了提高页表访问的速度现代处理器中通常包含一个转换后援缓冲器Translation Lookaside Buffer简称TLB来实现快速的虚实地址转换。TLB也称页表缓存或快表借由局部性原理存储当前处理器中最经常访问页的页表。一般TLB访问与Cache访问同时进行而TLB也可以被视为页表的Cache。TLB中存储的内容包括虚拟地址、物理地址和保护位可分别对应于Cache的Tag、Data和状态位。包含TLB的地址转换过程如图\@ref(fig:tlb-convert)所示。
```{r tlb-convert, echo=FALSE, fig.align='center', fig.cap="包含TLB的地址转换过程", out.width='50%'}
knitr::include_graphics("images/chapter3/tlb_convert.png")
```
处理器用地址空间标识符Address Space Identifier简称ASID和虚拟页号Virtual Page Number简称VPN在TLB中进行查找匹配若命中则读出其中的物理页号Physical Page Number简称PPN和标志位Flag。标志位用于判断该访问是否合法一般包括是否可读、是否可写、是否可执行等若非法则发出非法访问异常物理页号用于和页内偏移Offset拼接组成物理地址。若未在TLB中命中则需要将页表内容从内存中取出并填入TLB中这一过程通常称为TLB重填TLB Refill。TLB重填可由硬件或软件进行例如X86、ARM处理器采用硬件TLB重填即由硬件完成页表遍历Page Table Walker将所需的页表项填入TLB中而MIPS、LoongArch处理器默认采用软件TLB重填即查找TLB发现不命中时将触发TLB重填异常由异常处理程序进行页表遍历并进行TLB填入。
在计算机中外存、内存、Cache、通用寄存器可以组织成速度由慢到快的存储层次。TLB在存储层次中的位置和作用与Cache类似可视为页表这种特殊内存数据的专用Cache。
### TLB的结构和使用
#### 地址空间和地址翻译模式
在介绍LoongArch指令系统中TLB相关的存储管理的机制前首先简要了解一下LoongArch中地址空间和地址翻译模式的基本内容。LoongArch处理器支持的内存物理地址空间范围表示为0 - 2^PALEN^-1。在LA32架构下PALEN理论上是一个不超过36的正整数在LA64架构下PALEN理论上是一个不超过60的正整数。
LoongArch指令系统中的虚拟地址空间是线性平整的。对于PLV0级来说LA32架构下虚拟地址空间大小为2^32^字节LA64架构下虚拟地址空间大小为2^64^字节。不过对于LA64架构来说2^64^字节大小的虚拟地址空间并不都是合法的,可以认为存在一些虚拟地址的空洞。合法的虚拟地址空间与地址映射模式紧密相关。
LoongArch指令系统的MMU支持两种虚实地址翻译模式直接地址翻译模式和映射地址翻译模式。在直接地址翻译模式下物理地址默认直接等于虚拟地址高位不足补0、超出截断此时可以认为整个虚拟地址空间都是合法的。当CSR.CRMD中的DA域为1且PG域为0时CPU处于直接地址翻译模式。CPU复位结束后将进入直接地址翻译模式。
当CSR.CRMD中的DA域为0且PG域为1时CPU处于映射地址翻译模式。映射地址翻译模式又分为直接映射地址翻译模式简称“直接映射模式”和页表映射地址翻译模式简称“页表映射模式”两种。在映射地址翻译模式下地址翻译时将优先看其能否按照直接映射模式进行地址翻译无法进行后再通过页表映射模式进行翻译。
直接映射模式通过直接映射配置窗口机制完成虚实地址翻译简单来说就是将一大段连续的虚地址空间线性连续地映射至一段相同大小的物理地址空间。这里被翻译的一整段地址空间的大小通常远大于页表映射模式下所使用的页的大小因此需要的配置信息更少。LoongArch中将一对直接映射关系称为一个直接映射配置窗口共定义了四个直接映射配置窗口。四个窗口的配置信息存于CSR.DMW0~CSR.DMW3中每个窗口的配置信息包含该窗口对应的地址范围、该窗口在哪些权限等级下可用以及该窗口上的访存操作的存储访问类型。
LoongArch指令系统中的页表映射模式顾名思义通过页表映射完成虚实地址转换。在该模式下合法虚拟地址的[63:PALEN]位必须与[PALEN-1]位相同,即虚地址第[PALEN-1]位之上的所有位是该位的符号扩展。
#### TLB结构
页表映射模式存储管理的核心部件是TLB。LoongArch指令系统下TLB分为两个部分一个是所有表项的页大小相同的单一页大小TLBSingular-Page-Size TLB简称STLB另一个是支持不同表项的页大小可以不同的多重页大小TLBMultiple-Page-Size TLB简称MTLB。STLB的页大小可通过STLBPS控制寄存器进行配置。
在虚实地址转换过程中STLB和MTLB同时查找。相应地软件需保证不会出现MTLB和STLB同时命中的情况否则处理器行为将不可知。MTLB采用全相联查找表的组织形式STLB采用多路组相联的组织形式。对于STLB如果其有2^INDEX^组且配置的页大小为2^PS^字节那么硬件查询STLB的过程中是将虚地址的\[PS+INDEX:PS\]位作为索引值来访问各路信息的。接下来介绍LoongArch64指令系统中TLB单个表项的结构如图\@ref(fig:tlb-entry)所示。
```{r tlb-entry, echo=FALSE, fig.align='center', fig.cap="LoongArch64指令系统中TLB表项结构", out.width='100%'}
knitr::include_graphics("images/chapter3/tlb_entry.png")
```
在TLB表项中E表示该TLB表项是否存在E为0的项在进行TLB查找时将被视为无效项ASID标记该TLB表项属于哪个地址空间只有CPU中当前的ASID由CSR.ASID的ASID域决定与该域相同时才能命中ASID用于区分不同进程的页表G位域表示全局域为1时关闭ASID匹配表示该TLB表项适用于所有的地址空间PS表示该页表项中存放的页大小数值是页大小的2的幂指数有6比特宽因此LoongArch指令系统的页大小理论上可以任意变化处理器可以实现其中的一段范围VPPN表示虚双页号在LoongArch指令系统中TLB的每项把两个连续的虚拟页映射为两个物理页PPN为物理页号这个域的实际有效宽度取决于该处理器支持的物理内存空间的大小PLV表示该页表项对应的权限等级RPLV为受限权限等级使能当RPLV=0时该页表项可以被任何权限等级不低于PLV的程序访问否则该页表项仅可以被权限等级等于PLV的程序访问MAT控制落在该页表项所在地址空间上的访存操作的存储访问类型如是否可通过Cache缓存等NX为不可执行位为1表示该页表项所在地址空间上不允许执行取指操作NR为不可读位为1表示该页表项所在地址空间上不允许执行load操作D被称为“脏”Dirty为1表示该页表项所对应的地址范围内已有脏数据V为有效位为1表明该页表项是有效且被访问过的。
#### TLB虚实地址翻译过程
用TLB进行虚实地址翻译时首先要进行TLB查找将待查虚地址vaddr和CSR.ASID中ASID域的值asid一起与STLB中每一路的指定索引位置项以及MTLB中的所有项逐项进行比对。如果TLB表项的E位为1且vaddr对应的虚双页号vppn与TLB表项的VPPN相等该比较需要根据TLB表项对应的页大小只比较地址中属于虚页号的部分且TLB表项中的G位为1或者asid与TLB表项的ASID域的值相等那么TLB查找命中该TLB表项。如果没有命中项则触发TLB重填异常TLBR。如果查找到一个命中项那么根据命中项的页大小和待查虚地址确定vaddr具体落在双页中的哪一页从奇偶两个页表项取出对应页表项作为命中页表项。如果命中页表项的V等于0说明该页表项无效将触发页无效异常具体将根据访问类型触发对应的load操作页无效异常PIL、store操作页无效异常PIS或取指操作页无效异常PIF。如果命中页表项的V值等于1但是访问的权限等级不合规将触发页权限等级不合规异常PPI。权限等级不合规体现为该命中页表项的RPLV值等于0且CSR.CRMD中PLV域的值大于命中页表项中的PLV值或是该命中页表项的RPLV=1且CSR.CRMD中PLV域的值不等于命中页表项中的PLV值。如果上述检查都合规还要进一步根据访问类型进行检查。如果是一个load操作但是命中页表项中的NR值等于1,将触发页不可读异常PNR如果是一个store操作但是命中页表项中的D值等于0,将触发页修改异常PME如果是一个取指操作但是命中页表项中的NX值等于1,将触发页不可执行异常PNX。如果找到了命中项且经检查上述异常都没有触发那么命中项中的PPN值和MAT值将被取出前者用于和vaddr中提取的页内偏移拼合成物理地址paddr后者用于控制该访问操作的内存访问类型属性。
当触发TLB重填异常时除了更新CSR.CRMD外CSR.CRMD中PLV、IE域的旧值将被记录到CSR.TLBRPRMD的相关域中异常返回地址也将被记录到CSR.TLBRERA的PC域中 ^[PC域不包含指令地址的最低两位因为能触发TLB重填异常的指令的PC最低两位一定为0所以这两位不需要记录。]处理器还会将引发该异常的访存虚地址填入CSR.TLBRBAV的VAddr域并从该虚地址中提取虚双页号填入CSR.TLBREHI的VPPN域。当触发非TLB重填异常的其他TLB类异常时除了像普通异常发生时一样更新CRMD、PRMD和ERA这些控制状态寄存器的相关域外处理器还会将引发该异常的访存虚地址填入CSR.BADV的VAddr域并从该虚地址中提取虚双页号填入CSR.TLBEHI的VPPN域。
#### TLB相关控制状态寄存器
除了上面提到的TLB查找操作外LoongArch指令系统中定义了一系列用于访问和控制TLB的控制状态寄存器用于TLB内容的维护操作。
LoongArch指令系统中用于访问和控制TLB的控制状态寄存器大致可以分为三类第一类用于非TLB重填异常处理场景下的TLB访问和控制包括TLBIDX、TLBEHI、TLBELO0、TLBELO1、ASID和BADV第二类用于TLB重填异常处理场景包括此场景下TLB访问控制专用的TLBREHI、TLBRELO0、TLBRELO1和TLBRBADV以及此场景下保存上下文专用的TLBRPRMD、TLBRERA和TLBRSAVE第三类用于控制页表遍历过程包括PGDL、PGDH、PGD、PWCL和PWCH。三类寄存器的具体格式如图\@ref(fig:tlb-reg)所示。
```{r tlb-reg, echo=FALSE, fig.align='center', fig.cap="LoongArch指令系统TLB相关控制寄存器", out.width='100%'}
knitr::include_graphics("images/chapter3/tlb_reg.png")
```
上述寄存器中第二类专用于TLB重填异常处理场景CSR.TLBRERA的IsTLBR域值等于1的控制寄存器其设计目的是确保在非TLB重填异常处理程序执行过程中嵌套发生TLB重填异常处理后原有异常处理程序的上下文不被破坏。例如当发生TLB重填异常时其异常处理返回地址将填入CSR.TLBRERA而非CSR.ERA这样被嵌套的异常处理程序返回时所用的返回目标就不会被破坏。因硬件上只维护了这一套保存上下文专用的寄存器所以需要确保在TLB重填异常处理过程中不再触发TLB重填异常为此处理器因TLB重填异常触发而陷入异常处理后硬件会自动将虚实地址翻译模式调整为直接地址翻译模式从而确保TLB重填异常处理程序第一条指令的取指和访存^[如果第一条指令即为访存指令]一定不会触发TLB重填异常与此同时软件设计人员也要保证后续TLB重填异常处理返回前的所有指令的执行不会触发TLB重填异常。
在访问和控制TLB的控制状态寄存器中ASID中的ASID域、TLBEHI中的VPPN域、TLBELO0和TLBELO1中的所有域、TLBIDX中的PS和E域所构成的集合对应了一个TLB表项中的内容除了TLB表项中的G位域ASID中的ASID域、TLBREHI中的VPPN和PS域、TLBRELO0和TLBRELO1中的所有域所构成的集合也对应了一个TLB表项中的内容除了G位域和E位域。这两套控制状态寄存器都用来完成TLB表项的读写操作前一套用于非TLB重填异常处理场景而后一套仅用于TLB重填异常处理场景。写TLB时把上述寄存器中各个域存放的值写到TLB某一表项将TLBELO0和TLBELO1的G位域相与或者将TLBRELO0和TLBRELO1的G位域相与后写入TLB表项的G位域读TLB时将TLB表项读到并写入上述寄存器中的对应域将TLB表项的G位域的值同时填入TLBELO0和TLBELO1的G位域或者同时填入TLBRELO0和TLBRELO1的G位域
上述第三类寄存器的工作及使用方式将在后面\@ref(sec-tlb-ex)节中予以介绍。
#### TLB访问和控制指令
为了对TLB进行维护除了上面提到的TLB相关控制状态寄存器外LoongArch指令系统中还定义了一系列TLB访问和控制指令主要包括TLBRD、TLBWR、TLBFILL、TLBSRCH和INVTLB。
TLBRD是读TLB的指令其用CSR.TLBIDX中Index域的值作为索引读出指定TLB表项中的值并将其写入CSR.TLBEHI、CSR.TLBELO0、CSR.TLBELO1以及CSR.TLBIDX的对应域中。
TLBWR是写TLB的指令其用CSR.TLBIDX中Index域的值作为索引将CSR.TLBEHI、CSR.TLBELO0、CSR.TLBELO1以及CSR.TLBIDX相关域的值当处于TLB重填异常处理场景时这些值来自CSR.TLBREHI、CSR.TLBRELO0和CSR.TLBRELO1写到对应的TLB表项中。
TLBFILL是填入TLB的指令其将CSR.TLBEHI、CSR.TLBELO0、CSR.TLBELO1以及CSR.TLBIDX相关域的值当处于TLB重填异常处理场景时这些值来自CSR.TLBREHI、CSR.TLBRELO0和CSR.TLBRELO1填入TLB中的一个随机位置。该位置的具体确定过程是首先根据被填入页表项的页大小来决定是写入STLB还是MTLB。当被填入的页表项的页大小与STLB所配置的页大小由CSR.STLBPS中PS域的值决定相等时将被填入STLB否则将被填入MTLB。页表项被填入STLB的哪一路或者被填入MTLB的哪一项是由硬件随机选择的。
TLBSRCH为TLB查找指令其使用CSR.ASID中ASID域和CSR.TLBEHI中VPPN域的信息当处于TLB重填异常处理场景时这些值来自CSR.ASID和CSR.TLBREHI去查询TLB。如果有命中项那么将命中项的索引值写入CSR.TLBIDX的Index域同时将其NE位置为0如果没有命中项那么将该寄存器的NE位置1。
INVTLB指令用于无效TLB中符合条件的表项即从通用寄存器rj和rk得到用于比较的ASID和虚地址信息依照指令op立即数指示的无效规则对TLB中的表项逐一进行判定符合条件的TLB表项将被无效掉。
### TLB地址翻译相关异常的处理 {#sec-tlb-ex}
上一节介绍了LoongArch指令系统中与TLB相关的硬件规范这些设计为操作系统提供了必要的支持而存储管理则需要CPU和操作系统紧密配合CPU硬件在使用TLB进行地址翻译的过程中将产生相关异常再由操作系统介入进行异常处理。本节将重点讲述这些异常处理的过程。
#### 多级页表结构
Linux操作系统通常采用多级页表结构。对于64位的LoongArch处理器如果其有效虚地址位宽为48位那么当Linux操作系统采用16KB页大小时其页表为三级结构如图\@ref(fig:page-table)所示。33位的虚双页号VPPN分为三个部分最高11位作为一级页表页目录表PGD索引一级页表中每一项保存一个二级页表页目录表PMD的起始地址中间11位作为二级页表索引二级页表中每一项保存一个三级页表末级页表PTE的起始地址最低11位作为三级页表索引。每个三级页表包含2048个页表项每个页表项管理一个物理页大小为8字节包括RPLV、NX、NR、PPN、W、P、G、MAT、PLV、D、V的信息。“P”和“W”两个域分别代表物理页是否存在以及该页是否可写。这些信息虽然不填入TLB表项中但用于页表遍历的处理过程。每个进程的PGD表基地址放在进程上下文中内核进程进行切换时把PGD表的基地址写到CSR.PGDH的Base域中用户进程进行切换时把PGD表的基地址写到CSR.PGDL的Base域中。
```{r page-table, echo=FALSE, fig.align='center', fig.cap="Linux/LoongArch三级页表结构", out.width='100%'}
knitr::include_graphics("images/chapter3/page_table.png")
```
#### TLB重填异常处理
当TLB重填异常发生后其异常处理程序的主要处理流程是根据CSR.TLBRBADV中VAddr域记录的虚地址信息以及从CSR.PGD中得到的页目录表PGD的基址信息遍历发生TLB重填异常的进程的多级页表从内存中取回页表项信息填入CSR.TLBRELO0和CSR.TLBRELO1的相应域中最终用TLBFILL指令将页表项填入TLB。前面在讲述TLBFILL指令写操作过程时提到此时写入TLB的信息除了来自CSR.TLBRELO0和CSR.TLBRELO1的各个域之外还有来自CSR.ASID中ASID域和CSR.TLBREHI中VPPN域的信息。在TLB重填异常从发生到进行处理的过程中软硬件都没有修改CSR.ASID中的ASID域所以在执行TLBFILL指令时CSR.ASID中的ASID域记录的就是发生TLB重填异常的进程对应的ASID。至于CSR.TLBREHI中的VPPN域在TLB重填异常发生并进入异常入口时已经被硬件填入了触发该异常的虚地址中的虚双页号信息。
整个TLB重填异常处理过程中遍历多级页表是一个较为复杂的操作需要数十条普通访存、运算指令才能完成而且如果遍历的页表级数增加则需要更多的指令。LoongArch指令系统中定义了LDDIR和LDPTE指令以及与之配套的CSR.PWCL和CSR.PWCH来加速TLB重填异常处理中的页表遍历。LDDIR和LDPTE指令的功能简述如表\@ref(tab:lddir-ldpte)所示。
```{r lddir-ldpte, echo = FALSE, message=FALSE, tab.cap='LoongArch软件页表遍历指令', out.width='100%'}
autonum <- run_autonum(seq_id = "tab", bkm = "lddir-ldpte", bkm_all = TRUE)
data.frame('指令' = c('LDDIR rd, rj, level', 'LDPTE rj, seq'),
'描述' = c('将rj寄存器中的值作为当前页目录表的基地址同时根据CSR.TLBRBADV中VAddr域存放的TLB缺失地址以及PWCL、PWCH寄存器中定义的页目录表level索引的起始位置和位宽信息计算出当前目录页表的偏移量两者相加作为访存地址从内存中读取待访问页目录表/页表的基址写入rd寄存器中。',
'将rj寄存器中的值作为末级页表的基地址同时根据CSR.TLBRBADV中VAddr域存放的TLB缺失地址以及PWCL、PWCH寄存器中定义的末级页表索引的起始位置和位宽信息计算出末级页表的偏移量两者相加作为访存地址从内存中读取偶数号(seq=0)或奇数号(seq=1)页表项的内容将其写入到TLBRELO0或TLBRELO1寄存器中。')) %>%
flextable() %>%
set_caption(caption="LoongArch软件页表遍历指令", autonum = autonum) %>%
merge_v() %>%
width(j=1, width=1.5) %>%
width(j=2, width=4.5) %>%
theme_box()
```
CSR.PWCL和CSR.PWCH用来配置LDDIR和LDPTE指令所遍历页表的规格参数信息其中CSR.PWCL中定义了每个页表项的宽度PTEwidth域以及末级页表索引的起始位置和位宽PTbase和PTwidth域、页目录表1索引的起始位置和位宽Dir1_base和Dir1_width域、页目录表2索引的起始位置和位宽Dir2_base和Dir2_width域,CSR.PWCH中定义了页目录表3索引的起始位置和位宽Dir3_base和Dir3_width域、页目录表4索引的起始位置和位宽Dir4_base和Dir4_width域。在Linux/LoongArch64中当进行三级页表的遍历时通常用Dir1_base和Dir1_width域来配置页目录表PMD索引的起始位置和位宽用Dir3_base和Dir3_width域来配置页目录表PGD索引的起始位置和位宽Dir2_base和Dir2_width域、Dir4_base和Dir4_width域空闲不用。
使用上述指令TLB重填异常处理程序如下。可见遍历一个三级页表的处理过程只需要执行9条指令且每增加一级页表只需增加一条LDDIR指令即可。
```
csrwr $t0, CSR_TLBRSAVE
csrrd $t0, CSR_PGD
lddir $t0, $t0, 3 #访问页目录表PGD
lddir $t0, $t0, 1 #访问页目录表PMD
ldpte $t0, 0 #取回偶数号页表项
ldpte $t0, 1 #取回奇数号页表项
tlbfill
csrrd $t0, CSR_TLBRSAVE
ertn
```
#### 其它TLB地址翻译相关异常处理
除了TLB重填异常外LoongArch指令系统下常见的TLB类异常有取指操作页无效异常、load操作页无效异常、store操作页无效异常和页修改异常。这四种异常在Linux/LoongArch中处理的伪代码如下所示其中取指操作页无效异常和load操作页无效异常的处理流程一致。伪代码中的load pte函数遍历页表并取得页表项DO_FAULT函数在内存中分配物理页并把该页内容从对换区中取到内存_PAGE_PRESENT、_PAGE_READ和_PAGE_WRITE分别表示相应的物理页是否在内存中、是否可读、是否可写。
```
TLB modified exception:
(1)load pte;
(2)if(_PAGE_WRITE) set VALID|DIRTY, reload tlb, tlbwr;
else DO_FAULT(1);
TLB load exception:
(1)load pte;
(2)if(_PAGE_PRESENT && _PAGE_READ) set VALID, reload tlb, tlbwr;
else DO_FAULT(0);
TLB store exception:
(1)load pte;
(2)if(_PAGE_PRESENT && _PAGE_WRITE) set VALID|DIRTY, reload tlb, tlbwr;
else DO_FAULT(1);
```
下面通过一个例子来深入分析处理器、操作系统以及应用程序间的交互。图3.9是一个分配数组和对数组赋值的小程序。从程序员的角度看,这个程序很简单,但从结构和操作系统的角度看,这个程序的执行却涉及复杂的软硬件交互过程。
```
array=(int *)malloc(0x1000);
for(i=0;i<1024;i++) array[i]=0;
```
该用户程序首先调用内存分配函数malloc来分配大小为0x1000字节的空间假设返回一个虚地址0x450000。操作系统在进程的vma_struct链表里记录地址范围0x450000\~0x451000为已分配地址空间并且是可读、可写的。但操作系统只是分配了一个地址范围还没有真实分配内存的物理空间也没有在页表里建立页表项TLB里更没有——因为如果进程没有访问就不用真为其分配物理空间。接下来的for循环对数组array进行赋值用户程序写地址为0x450000的单元。store操作在完成地址运算后查找TLB由于TLB里面没有这一表项因此引起TLB重填异常。TLB重填异常处理程序从相应的页表位置取页表内容填入TLB但此时这个地址空间的页表还没有有效的页表项信息。当异常处理返回用户程序重新开始访问时TLB里面有了对应的虚地址但是还没有物理地址。因为还没有分配具体的物理空间所以引起store操作页无效异常。处理store操作页无效异常时操作系统需要查找vma_struct这个结构如果判断出这个地址已经分配处于可写状态这时操作系统才真正分配物理页面并分配物理页表将物理地址填入页表更新TLB相应的表项。store操作页无效异常处理完成之后返回store操作再次执行这次就成功了因为TLB里已经有了相应的表项并且是有效、可写的。由于分配的页面恰好为4KB大小且在同一页中因此后续的地址访问都会在TLB中命中不会再产生异常。产生两次异常而非一次完成所有操作的原因是保证TLB重填异常的处理速度。
## 本章小结
本章介绍了异常的类型和通用处理过程,并对中断这类特殊异常进行了探讨。在计算机系统中,处理器全速地执行指令,而异常与中断起到纽带的作用,使得运行级别、存储管理等机制有机结合,共同打造安全、高效、易用的系统。
本章首先介绍存储管理的意义并引出对页表进行硬件加速的结构TLB随后以LoongArch指令系统为例介绍TLB的结构和使用方法最后介绍TLB异常的类型和处理方法。存储管理在计算机系统中得到了广泛的应用为使存储管理系统流畅运行硬件设计、软件设计需紧密配合协同优化。
## 习题
1. 请说明LoongArch指令系统中为何要定义ERTN指令用于异常处理的返回。
2. 简述LoongArch与X86在异常处理过程中的区别。
3. 简述精确异常与非精确异常的区别,并在已有的处理器产品实现中找出一个非精确异常示例。
4. 在一台Linux/LoongArch机器上执行如下程序片段假设数组a和b的起始地址都是8KB边界对齐的操作系统仅支持4KB页大小。处理器中的TLB有32项采用LRU替换算法。如果在该程序片段开始执行前数组a和b均从未被访问过且程序片段执行过程中未发生中断同时忽略程序代码和局部变量i所占地址空间的影响请问执行该程序片段的过程中会发生多少次与TLB地址翻译相关的异常
```{r memcpy-program, echo=FALSE, fig.align='center', out.width='50%'}
knitr::include_graphics("images/chapter3/memcpy_program.png")
```
5. 请用C语言伪代码形式描述一台64位LoongArch机器上的TLB进行访存虚实地址转换的过程包含TLB地址翻译相关异常的判定过程提示①可以将TLB的每一项定义为一个结构体将整个TLB视作一个结构体数组②无须直接体现过程中电路的并发执行特性只需要确保最终逻辑状态一致即可。
\newpage

View File

@@ -0,0 +1,447 @@
# 软硬件协同
作为软硬件的界面指令系统结构不仅包含指令和相关硬件资源的定义还包含有关资源的使用方式约定。与二进制程序相关的约定被称为ABIApplication Binary Interface,应用程序二进制接口。ABI定义了应用程序二进制代码中相关数据结构和函数模块的格式及其访问方式它使得不同的二进制模块之间的交互成为可能。本章首先讲述ABI的基本概念和具体组成并举例说明了其中一些比较常见的内容。
在软硬件之间合理划分界面是指令系统设计的一项关键内容。计算机完成一项任务所需要的某个工作常常既可以选择用软件实现也可以选择用硬件实现设计者需要进行合理的权衡。第3章中LoongArch指令系统的TLB管理就是一个很好的软硬件协同实现案例。本章继续讲述对理解计算机系统的工作过程比较重要的一些软硬件协同案例包括函数调用、异常与中断、系统调用、进程、线程和虚拟机等六种不同的上下文切换场景以及同步机制的实现。
不另加说明的情况下本章的案例采用LoongArch指令系统。
## 应用程序二进制接口
ABI定义了应用程序二进制代码中数据结构和函数模块的格式及其访问方式它使得不同的二进制模块之间的交互成为可能。硬件上并不强制这些内容因此自成体系的软件可以不遵循部分或者全部ABI约定。但通常来说应用程序至少会依赖操作系统以及系统函数库因而必须遵循相关约定。
ABI包括但不限于如下内容
* 处理器基础数据类型的大小、布局和对齐要求等;
* 寄存器使用约定。它约定通用寄存器的使用方法、别名等;
* 函数调用约定。它约定参数如何传递给被调用的函数、结果如何返回、函数栈帧如何组织等;
* 目标文件和可执行文件格式;
* 程序装载和动态链接相关信息;
* 系统调用和标准库接口定义;
* 开发环境和执行环境等相关约定。
关心ABI细节的主要是编译工具链、操作系统和系统函数库的开发者但如果用到汇编语言或者想要实现跨语言的模块调用普通开发者也需要对它有所了解。从以上内容也可以看出了解ABI有助于深入理解计算机系统的工作原理。
同一个指令系统上可能存在多种不同的ABI。导致ABI差异的原因之一是操作系统差异。例如对于X86指令系统UNIX类操作系统普遍遵循System V ABI而Windows则有它自己的一套ABI约定。导致ABI差异的原因之二是应用领域差异有时针对不同的应用领域定制ABI可以达到更好的效果。例如ARM、PowerPC和MIPS都针对嵌入式领域的需求定义了EABI(Embedded Application Binary Interface它和通用领域的ABI有所不同。导致ABI差异的另外一种常见原因是软硬件的发展需要。例如MIPS早期系统多数采用O32 ABI它定义了四个寄存器用于函数调用参数后来的软件实践发现更多的传参寄存器有利于提升性能这促成了新的N32/N64 ABI的诞生。而指令集由32位发展到64位时也需要新的ABI。X86-64指令系统上有三种Sytem V ABI的变种分别是兼容32位X86的i386 ABI指针用32位、数据用64位的X32 ABI以及指针和数据都用64位的X86-64 ABI。操作系统可以只选择支持其中一种ABI也可以同时支持多种ABI。此外ABI的定义相对来说不如指令集本身完整和规范一个指令系统的ABI规范可能有很完备的、统一的文档描述也可能是依赖主流软件的事实标准由多个来源的非正式文档构成。
下面我们以一些具体的例子来说明ABI中一些比较常见的内容。
### 寄存器约定
本节列举MIPS和LoongArch指令系统的整数寄存器约定浮点寄存器也有相应约定在此不做讨论并对它们进行了简单的比较和讨论。MIPS和LoongArch都有32个整数通用寄存器除了0号寄存器始终为0外其他31个寄存器物理上没有区别。但系统人为添加了一些约定给了它们特定的名字和使用方式。
MIPS指令系统的流行ABI主要有以下三种
1)O32。来自传统的MIPS约定仍广泛用于嵌入式工具链和32位Linux中。
2)N64。在64位处理器编程中使用的新的正式ABI指针和long型整数的宽度扩展为64位并改变了寄存器使用的约定和参数传递的方式。
3)N32。在64位处理器上执行的32位程序与N64的区别在于指针和long型整数的宽度为32位。
表\@ref(tab:mips-reg)给出了MIPS O32和N32/N64对整数或称为定点通用寄存器的命名和使用约定。
```{r mips-reg, echo = FALSE, message=FALSE, tab.cap='MIPS整数通用寄存器约定'}
autonum <- run_autonum(seq_id = "tab", bkm = "mips-reg", bkm_all = TRUE)
readr::read_csv('./materials/chapter4/mips_reg.csv') %>%
flextable() %>%
set_caption(caption="MIPS整数通用寄存器约定", autonum = autonum) %>%
merge_h() %>%
theme_box() %>%
autofit()
```
这三个ABI中O32用一种寄存器约定N32/N64用另一种。可以看到两种寄存器约定的大部分内容是相同的主要差别在于O32只用了四个寄存器作为参数传递寄存器而N32/N64则用了八个相应地减少了暂存器。原因是现代程序越来越复杂很多函数的参数超过四个在O32中需要借助内存来传递多出的参数N32/N64的约定有助于提升性能。对参数少于八个的函数剩余的参数寄存器仍然可以当作暂存器使用不会浪费。为了和普通变量名区分这些助记符在汇编源代码中会加\$’前缀,例如\$sp或者\$r29表示29号寄存器。但在一些源代码如Linux内核源代码中也可能会看到直接使用不加\$前缀的助记符的情况,这是因为相关头文件用宏定义了这个名字,如\#define a0 \$r4。
LoongArch定义了三个ABI指针和数据都是64位的LP64指针32位、数据64位的LPX32指针和数据都是32位的LP32。但它们的寄存器约定都是一致的。对比表\@ref(tab:mips-reg)和表\@ref(tab:la-reg)我们可以看到LoongArch的约定比MIPS要更规整和简洁些主要有如下差别
- 取消了汇编暂存器(\$at。MIPS的一些汇编宏指令用多条硬件指令合成汇编暂存器用于数据周转。LoongArch指令系统的宏指令可以不用周转寄存器或者显式指定周转寄存器因而不再需要汇编暂存器。这可以增加编译器可用寄存器的数量。
- 取消了预留给内核的专用寄存器(\$k0/\$k1。MIPS预留两个寄存器的目的是支持高效异常处理在希望异常处理过程尽量快的时候可以用这两个寄存器省去保存上下文到内存中的开销。LoongArch指令系统提供了便签寄存器来高效暂存数据可以在不预留通用寄存器的情况下保持高效实现给编译器留下了更多的可用寄存器。
- 取消了\$gp寄存器。MIPS中用\$gp寄存器指向GOTGlobal Offset Table表以协助动态链接器计算可重定位的代码模块的相关符号位置。LoongArch指令集支持基于PC的运算指令能够用其他高效的方式实现动态链接不再需要额外花费一个通用寄存器。
- 复用参数寄存器和返回值寄存器,参数寄存器\$a0/\$a1也被用作返回值寄存器。这也是现代指令系统比较常见的做法它进一步增加了通用暂存器的数量。
- 增加了线程指针寄存器\$tp用于高效支持多线程实现。\$tp总是指向当前线程的TLSThread Local Storage区域。
```{r la-reg, echo = FALSE, message=FALSE, tab.cap='LoongArch整数通用寄存器约定'}
autonum <- run_autonum(seq_id = "tab", bkm = "la-reg", bkm_all = TRUE)
readr::read_csv('./materials/chapter4/la_reg.csv') %>%
flextable() %>%
set_caption(caption="LoongArch整数通用寄存器约定", autonum = autonum) %>%
theme_box() %>%
autofit()
```
以上几点都有助于提升编译器生成的代码的性能。曾有实验表明在完全相同的微结构和外部配置环境下LoongArch指令系统的SPEC CPU 2006基准程序平均性能比MIPS高15%左右其中部分性能来自指令集的优化部分性能来自更高效的ABI。
### 函数调用约定
LoongArch的函数调用规范如下略去了少量过于复杂且不常用的细节
**整型调用规范**
1.基本整型调用规范提供了8个参数寄存器\$a0-\$a7用于参数传递前两个参数寄存器\$a0和\$a1也用于返回值。
2.若一个标量宽度至多XLEN位对于LP32 ABIXLEN=32对于LPX32/LP64XLEN=64则它在单个参数寄存器中传递若没有可用的寄存器则在栈上传递。若一个标量宽度超过XLEN位不超过2\*XLEN位则可以在一对参数寄存器中传递低XLEN位在小编号寄存器中高XLEN位在大编号寄存器中若没有可用的参数寄存器则在栈上传递标量若只有一个寄存器可用则低XLEN位在寄存器中传递高XLEN位在栈上传递。若一个标量宽度大于2\*XLEN位则通过引用传递并在参数列表中用地址替换。用栈传递的标量会对齐到类型对齐(Type Alignment)和XLEN中的较大者但不会超过栈对齐要求。当整型参数传入寄存器或栈时小于XLEN位的整型标量根据其类型的符号扩展至32位然后符号扩展为XLEN位。当浮点型参数传入寄存器或栈时比XLEN位窄的浮点类型将被扩展为XLEN位而高位为未定义位。
3.若一个聚合体Struct或者Array的宽度不超过XLEN位则这个聚合体可以在寄存器中传递并且这个聚合体在寄存器中的字段布局同它在内存中的字段布局保持一致若没有可用的寄存器则在栈上传递。若一个聚合体的宽度超过XLEN位不超过2\*XLEN位则可以在一对寄存器中传递若只有一个寄存器可用则聚合体的前半部分在寄存器中传递后半部分在栈上传递若没有可用的寄存器则在栈上传递聚合体。由于填充(Padding)而未使用的位以及从聚合体的末尾至下一个对齐位置之间的位都是未定义的。若一个聚合体的宽度大于2\*XLEN位则通过引用传递并在参数列表中被替换为地址。传递到栈上的聚合体会对齐到类型对齐和XLEN中的较大者但不会超过栈对齐要求。
4.对于空的结构体Struct或联合体(Union)参数或返回值C编译器会认为它们是非标准扩展并忽略C++编译器则不是这样C++编译器要求它们必须是分配了大小的类型(Sized Type)。
5.位域(Bitfield)以小端顺序排列。跨越其整型类型的对齐边界的位域将从下一个对齐边界开始。例如:
struct {int x:10; int y:12;}是一个32位类型x为9-0位y为21-10位31-22位未定义。
struct {short x:10 ; short y:12;}是一个32位类型x为9-0位y为27-16位31-28位和15-10位未定义。
6.通过引用传递的实参可以由被调用方修改。
7.浮点实数的传递方式与相同大小的聚合体相同,浮点型复数的传递方式与包含两个浮点实数的结构体相同。(当整型调用规范与硬件浮点调用规范冲突时,以后者为准。)
8.在基本整型调用规范中可变参数的传递方式与命名参数相同但有一个例外。2\*XLEN位对齐的可变参数和至多2\*XLEN 位大小的可变参数通过一对对齐的寄存器传递(寄存器对中的第一个寄存器为偶数),如果没有可用的寄存器,则在栈上传递。当可变参数在栈上被传递后,所有之后的参数也将在栈上被传递(此时最后一个参数寄存器可能由于对齐寄存器对的规则而未被使用)。
9.返回值的传递方式与第一个同类型命名参数(Named Value)的传递方式相同。如果这样的实参是通过引用传递的,则调用者为返回值分配内存,并将其地址作为隐式的第一个参数传递。
10.栈向下增长(朝向更低的地址)栈指针应该对齐到一个16字节的边界上作为函数入口。在栈上传递的第一个实参位于函数入口的栈指针偏移量为零的地方后面的参数存储在更高的地址中。
11.在标准ABI中栈指针在整个函数执行过程中必须保持对齐。非标准ABI代码必须在调用标准ABI过程之前重新调整栈指针。操作系统在调用信号处理程序之前必须重新调整栈指针因此POSIX信号处理程序不需要重新调整栈指针。在服务中断的系统中使用被中断对象的栈如果连接到任何使用非标准栈对齐规则的代码中断服务例程必须重新调整栈指针。但如果所有代码都遵循标准ABI则不需要重新调整栈指针。
12.函数所依赖的数据必须位于函数栈帧范围之内。
13.被调用的函数应该负责保证寄存器\$s0-\$s8的值在返回时和入口处一致。
**硬件浮点调用规范**
1.浮点参数寄存器共8个为\$fa0-\$fa7其中\$fa0和\$fa1也用于传递返回值。需要传递的值在任何可能的情况下都可以传递到浮点寄存器中与整型参数寄存器\$a0-\$a7是否已经用完无关。
2.本节其他部分仅适用于命名参数,可变参数根据整型调用规范传递。
3.在本节中FLEN指的是ABI中的浮点寄存器的宽度。ABI的FLEN宽度不能比指令系统的标准宽。
4.若一个浮点实数参数不超过FLEN位宽并且至少有一个浮点参数寄存器可用则将这个浮点实数参数传递到浮点参数寄存器中否则它将根据整型调用规范传递。当一个比FLEN位更窄的浮点参数在浮点寄存器中传递时它从1扩展到FLEN位。
5.若一个结构体只包含一个浮点实数则这个结构体的传递方式同一个独立的浮点实数参数的传递方式一致。若一个结构体只包含两个浮点实数这两个浮点实数都不超过FLEN位宽并且至少有两个浮点参数寄存器可用(寄存器不必是对齐且成对的),则这个结构体被传递到两个浮点寄存器中,否则,它将根据整型调用规范传递。若一个结构体只包含一个浮点复数,则这个结构体的传递方式同一个只包含两个浮点实数的结构体的传递方式一致,这种传递方式同样适用于一个浮点复数参数的传递。若一个结构体只包含一个浮点实数和一个整型(或位域)无论次序则这个结构体通过一个浮点寄存器和一个整型寄存器传递的条件是整型不超过XLEN位宽且没有扩展至XLEN位浮点实数不超过FLEN位宽至少一个浮点参数寄存器和至少一个整型参数寄存器可用否则它将根据整型调用规范传递。
6.返回值的传递方式与传递第一个同类型命名参数的方式相同。
7.若浮点寄存器\$fs0-\$fs11的值不超过FLEN位宽那么在函数调用返回时应该保证它们的值和入口时一致。
可以看到函数调用约定包含许多细节。为了提高效率LoongArch的调用约定在参考MIPS的基础上做了较多优化。例如它最多能同时用8个定点和8个浮点寄存器传递16个参数而MIPS中能用定点或者浮点寄存器来传递的参数最多为8个。
我们来看几个例子。图\@ref(fig:fun-c)的程序用gcc -O2 fun.c -S得到汇编文件见图\@ref(fig:func-la-S)略有简化下同。可以看到对于第9个浮点参数已经没有浮点参数寄存器可用此时根据浮点调用规范第4条剩下的参数按整型调用规范传递。因此a9、a10、a11和a12分别用\$a0-\$a3这四个定点寄存器来传递虽然这段代码引用的a9和a11实际上是浮点数。
```{r fun-c, echo=FALSE, fig.align='center', fig.cap="fun.c源代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/fun.c.png")
```
```{r func-la-S, echo=FALSE, fig.align='center', fig.cap="fun.c对应的LoongArch汇编代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/fun_la.S.png")
```
这个程序在MIPS N64 ABI下的参数传递方式则有所不同。按MIPS ABI规则前八个参数仍然会使用浮点参数寄存器传递但是后四个参数将通过栈上的内存空间传递因此a9和a11会从栈中获取如图\@ref(fig:func-mips-S)所示。
```{r func-mips-S, echo=FALSE, fig.align='center', fig.cap="fun.c对应的MIPS汇编代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/fun_mips.S.png")
```
对于可变数量参数的情况,图\@ref(fig:varg-c)给出了一个测试案例,表\@ref(tab:varg-passing)是对应的参数传递表。可以看到,第一个固定参数是浮点参数,用\$fa0后续的可变参数根据浮点调用规范第2条全部按整型调用规范传递因此不管是浮点还是定点参数都使用定点寄存器。
```{r varg-c, echo=FALSE, fig.align='center', fig.cap="varg.c源代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/varg.c.png")
```
```{r varg-passing, echo = FALSE, message=FALSE, tab.cap='varg.c对应的LoongArch参数传递'}
autonum <- run_autonum(seq_id = "tab", bkm = "varg-passing", bkm_all = TRUE)
readr::read_csv('./materials/chapter4/varg_passing.csv') %>%
flextable() %>%
set_caption(caption="varg.c对应的LoongArch参数传递", autonum = autonum) %>%
theme_box() %>%
autofit()
```
### 进程虚拟地址空间
虚拟存储管理为每个进程提供了一个独立的虚拟地址空间指令系统、操作系统、工具链和应用程序会互相配合对其进行管理。首先指令系统和OS会决定哪些地址空间用户可以访问哪些只能操作系统访问哪些是连操作系统也不能访问的保留空间。然后工具链和应用程序根据不同的需要将用户可访问的地址空间分成几种不同的区域来管理。图\@ref(fig:address-space)展示了一个典型C程序运行时的用户态虚拟内存布局。
```{r address-space, echo=FALSE, fig.align='center', fig.cap="C程序的典型虚拟内存布局", out.width='80%'}
knitr::include_graphics("images/chapter4/address_space.png")
```
可以看到C程序的典型虚拟内存布局包括如下几部分
* 应用程序的代码、初始化数据和未初始化数据
* 堆
* 函数库的代码、初始化数据和未初始化数据
* 栈
应用程序的代码来自应用程序的二进制文件。工具链在编译链接应用程序时会将代码段地址默认设置为一个相对较低的地址但这个地址一般不会为0地址0在多数操作系统中都会被设为不可访问的地址以便捕获空指针访问。运行程序时操作系统中的装载器根据程序文件记录的内存段信息把代码和数据装入相应的虚拟内存地址。有初始值的全局变量和静态变量存放在文件的数据段中。未初始化的变量只需要在文件中记录其大小装载器会直接给它分配所需的内存空间然后清零。未初始化数据段之上是堆空间。堆用于管理程序运行过程中动态分配的内存C程序中用malloc分配的内存由堆来管理。接近用户最高可访问地址的一段空间被用作进程的栈。栈向下增长用先进后出的方式分配和释放。栈用作函数的临时工作空间存储C程序的局部变量、子函数参数和返回地址等函数执行完就可以抛弃的数据栈的详细管理情况参见下节。堆需要支持任意时刻分配和释放不同大小的内存块需要比较复杂的算法支持因此相应的分配和释放开销也比较大。而栈的分配和释放实质上只是调整一个通用寄存器\$sp开销很小但它只能按先进后出的分配次序操作。应用程序用到的动态函数库则由动态链接程序在空闲空间中寻找合适的地址装入通常是介于栈和堆之间。
图\@ref(fig:as-example)是64位Linux系统中一个简单C程序程序名为hello运行时的虚拟内存布局的具体案例。它基本符合上述典型情况。栈之上的三段额外空间是现代Linux系统的一些新特性引入的有兴趣的读者可以自行探究。
```{r as-example, echo=FALSE, fig.align='center', fig.cap="一个简单C程序的虚拟内存布局", out.width='100%'}
knitr::include_graphics("images/chapter4/as_example.png")
```
需要说明的是一般来说ABI并不包括进程地址空间的具体使用约定。事实上进程虚拟内存布局一般也不影响应用程序的功能。我们可以通过一些链接器参数来改变程序代码段的默认装载地址让它出现在更高的地址上也可以在任意空闲用户地址空间内映射动态链接库或者分配内容。这里介绍一些典型的情况是为了让读者更好地理解软硬件如何协同实现程序的数据管理及其装载和运行。
### 栈帧布局
像C/C++这样的高级语言通常会用栈来管理函数运行过程使用的一些信息,包括返回地址、参数和局部变量等。栈是一个大小可以动态调整的空间,在多数指令系统中是从高地址向下增长。如图\@ref(fig:stack-frame)所示,栈被组织成一个个栈帧(一段连续的内存地址空间每个函数都可以有一个自己的栈帧。调用一个子函数时栈增大产生一个新的栈帧函数返回时栈减小释放掉一个栈帧。栈帧的分配和释放在有些ABI中由调用函数负责在有些ABI中由被调用者负责。
我们以LoongArch LP64为例看看具体的案例。图\@ref(fig:stack-frame)是最完整的情况,它同时利用了\$sp和\$fp两个寄存器来维护栈帧。\$sp寄存器指向栈顶\$fp寄存器指向当前函数的栈帧开始处。编译器为函数在入口处生成一个函数头Prologue在返回处生成一个函数尾Epilogue它们负责调整\$sp和\$fp寄存器以生成新的栈帧或者释放一个栈帧并生成必要的寄存器保存和恢复代码。
```{r stack-frame, echo=FALSE, fig.align='center', fig.cap="使用帧指针寄存器的栈帧布局", out.width='50%'}
knitr::include_graphics("images/chapter4/stack_frame.png")
```
图\@ref(fig:simple-c)的简单函数用gcc -O2 -fno-omit-frame-pointer -S来编译会产生图\@ref(fig:simple-as)这样的汇编代码(为清晰起见,将形如\$rxx的寄存器名替换为约定的助记符下同)。
```{r simple-c, echo=FALSE, fig.align='center', fig.cap="一个简单的simple函数", out.width='100%'}
knitr::include_graphics("materials/chapter4/simple.c.png")
```
```{r simple-as, echo=FALSE, fig.align='center', fig.cap="simple函数的汇编代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/simple.S.png")
```
前3条指令属于函数头第一条指令设立了一个16字节的栈帧(LP64要求栈帧以16字节对齐)第二条指令在偏移8的位置保存了\$fp寄存器第三条指令则把\$fp指向刚进入函数时的\$sp。第4条和第7条指令属于函数尾分别负责恢复\$fp和释放栈帧。当然很容易看到对这么简单的情况维护栈帧完全是多余的因此如果不加-fno-omit-frame-pointer强制使用\$fp的话gcc -O2 -S生成的代码将会如图\@ref(fig:simple-nofp-as)所示,整个函数不再产生和释放栈帧。
```{r simple-nofp-as, echo=FALSE, fig.align='center', fig.cap="simple函数不保留栈帧指针的编译结果", out.width='100%'}
knitr::include_graphics("materials/chapter4/simple_nofp.S.png")
```
大部分函数可以只用\$sp来管理栈帧。如果在编译时能够确定函数的栈帧大小编译器可以在函数头分配所需的栈空间通过调整\$sp,这样在函数栈帧里的内容都有一个编译时确定的相对于$sp的偏移也就不需要帧指针\$fp了。例如图\@ref(fig:normal-c)中的normal函数用gcc -O2 -S编译的结果如图\@ref(fig:normal-as)所示。normal函数调用了一个有9个整数参数的外部函数这样它必须有栈帧来为调用的子函数准备参数。可以看到编译器生成了一个32字节的栈帧把最后一个浮点参数9保存到偏移0把返回地址\$ra保存到偏移24。
```{r normal-c, echo=FALSE, fig.align='center', fig.cap="normal函数代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/normal.c.png")
```
```{r normal-as, echo=FALSE, fig.align='center', fig.cap="normal函数的gcc -O2编译结果", out.width='100%'}
knitr::include_graphics("materials/chapter4/normal.S.png")
```
但有时候可能无法在编译时确定一个函数的栈帧大小。在某些语言中可以在运行时动态分配栈空间如C程序的alloca调用这会改变\$sp的值。这时函数头会使用\$fp寄存器将其设置为函数入口时的\$sp值函数的局部变量等栈帧上的值则用相对于\$fp的常量偏移来表示。图\@ref(fig:dynamic-c)中的函数用alloca动态分配栈空间导致编译器生成带栈帧指针的代码。如图\@ref(fig:dynamic-as)所示,\$fp指向函数入口时\$sp的值\$sp则先减32字节留出调用子函数的参数空间以及保存\$fp和\$ra的空间然后再为alloca(64)减去64以动态分配栈空间。
```{r dynamic-c, echo=FALSE, fig.align='center', fig.cap="dynamic函数源代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/dynamic.c.png")
```
```{r dynamic-as, echo=FALSE, fig.align='center', fig.cap="dynamic函数的汇编代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/dynamic.S.png")
```
## 六种常见的上下文切换场景
CPU运行指令的过程中根据应用或者操作系统的需要经常会改变指令的执行流同时根据需要在不同的上下文之间切换。本节讲述指令系统如何实现函数调用、中断与异常、系统调用、进程、线程以及虚拟机等上下文切换场景。
### 函数调用
函数调用是用户主动发起的指令流和上下文改变。普通的转移指令只改变指令流不改变上下文函数调用则通过ABI约定实现了一定的上下文变化。函数调用通常伴随着栈帧的变化此外部分寄存器也会发生变化。根据ABI的约定像\$s0-\$s8这样约定由被调用者保存(Callee Save)的寄存器在函数调用前后保持不变,而通用暂存器、参数寄存器等则不保证维持调用前的值。
不同指令系统实现函数调用的方式有所不同。LoongArch采用比较典型的RISC做法硬件仅仅提供一个机制bl或者jirl指令用于在改变指令流的同时保存一个返回地址到通用寄存器其余的都由软件来约定和实现。X86指令系统中则有比较复杂的硬件支持其函数调用指令call指令有多种形式硬件可以执行权限检查、保存返回地址到栈上、修改CS和IP寄存器、设置标志位等处理逻辑但是参数的传递方式还是由软件约定。Sparc指令系统则为了减少函数调用时寄存器准备的开销引入了体系结构可见的寄存器窗口机制。它的通用寄存器包括8个全局寄存器和2-32个窗口每个窗口包括16个寄存器。任意时刻指令可以访问8个全局寄存器、8个输入寄存器、8个局部寄存器、8个输出寄存器其中前两个由当前窗口提供输出寄存器由相邻窗口的输入寄存器提供。Sparc提供专门的save和restore指令来移动窗口调用函数执行save指令让当前函数的输出寄存器变成被调用函数的输入寄存器消除了多数情况下准备调用参数的过程函数返回时则执行restore指令恢复原窗口。这个技术看起来非常巧妙然而它会给寄存器重命名等现代流水线技术带来很大的实现困难现在常常被人们当作指令系统过度优化的反面案例。
### 异常和中断
上一章已经介绍了异常和中断的概念及其常规处理流程。通常异常和中断的处理对用户程序来说是透明的相关软硬件需要保证处理前后原来执行中的代码看到的CPU状态保持一致。这意味着开始异常和中断处理程序之前需要保存所有可能被破坏的、原上下文可见的CPU状态并在处理完返回原执行流之前恢复。需要保存的上下文包括异常处理代码的执行可能改变的寄存器如Linux内核自身不用浮点部件因此只需要处理通用整数寄存器而无须处理浮点寄存器、发生异常的地址、处理器状态寄存器、中断屏蔽位等现场信息以及特定异常的相关信息如触发存储访问异常的地址。异常和中断的处理代码通常在内核态执行如果它们触发前处理器处于用户态硬件会自动切换到内核态。这种情况下通常栈指针也会被重新设置为指向内核态代码所使用的栈以便隔离不同特权等级代码的运行信息。
对于非特别高频的异常或者中断操作系统往往会统一简化处理直接保存所有可能被内核修改的上下文状态然后调用相应的处理函数最后再恢复所有状态。因为大部分情况下处理函数的逻辑比较复杂所以算起开销比例来这么做的代价也可以接受。例如3A5000处理器的Linux内核中所有中断都采用统一的入口处理代码它的主要工作就是保存所有的通用整数寄存器和异常现场信息除此之外只有少量指令用于切换中断栈、调用实际中断处理函数等代码。入口处理的指令总共只有几十条而一个有实际用处的中断处理过程一般至少有数百条指令其中还包括一些延迟比较长的IO访问。例如看上去很简单的键盘中断处理在把输入作为一个事件报告到Linux内核的输入子系统之前就已经走过了如图\@ref(fig:keyboard-interrupt)所示那么多的函数。
```{r keyboard-interrupt, echo=FALSE, fig.align='center', fig.cap="键盘输入的中断处理部分路径", out.width='100%'}
knitr::include_graphics("materials/chapter4/keyboard_interrupt.txt.png")
```
except_vec_vi是Linux/LoongArch内核的向量中断入口处理代码之后它会用USB键盘对应的中断号为参数调用do_IRQ函数do_IRQ再经过一系列中断框架处理后调用usb的中断处理函数usb_hcd_irq读入相应的键码最后用input_event报告给输入子系统输入子系统再负责把输入事件传递给适当的应用程序。感兴趣的读者可以阅读Linux内核相关代码以更深入地理解这个过程在此不再展开。
对于发生频率很高的异常或者中断我们希望它的处理效率尽量高。从异常和中断处理的各个环节都可以设法降低开销。例如可以通过专用入口或者向量中断技术来降低确定异常来源和切换指令流的开销。此外不同的指令系统用不同的方法来降低上下文保存恢复的开销。例如TLB管理上一章中我们介绍了LoongArch中TLB重填的做法设置专门的异常入口利用便签寄存器来快速获得可用的通用寄存器以及提供两个专门的指令(lddir和ldpte来进一步加速从内存页表装入TLB表项的过程。X86指令系统选择完全用硬件来处理成功的情况不会发出异常。MIPS指令系统则采用预留两个通用寄存器的办法。TLB重填异常处理只用这两个寄存器因此没有额外的保存恢复代价但所有的应用程序都牺牲了两个宝贵的通用寄存器
### 系统调用
系统调用是操作系统内核为用户态程序实现的子程序。系统调用的上下文切换场景和函数调用比较类似和普通调用相比主要多了特权等级的切换。Linux操作系统中的部分系统调用如表\@ref(tab:syscall)所示。一些系统调用如gettimeofday系统调用只返回一些内核知道但用户程序不知道的信息。系统调用要满足安全性和兼容性两方面的要求。安全性方面在面对错误甚至恶意的应用时内核应该是健壮的应能保证自身的安全兼容性方面操作系统内核应该能够运行已有的应用程序这也要求系统调用应该是兼容的轻易移除一个系统调用是无法接受的。
```{r syscall, echo = FALSE, message=FALSE, tab.cap='Linux/LoongArch操作系统的部分系统调用'}
autonum <- run_autonum(seq_id = "tab", bkm = "syscall", bkm_all = TRUE)
readr::read_csv('./materials/chapter4/syscall.csv') %>%
flextable() %>%
set_caption(caption="Linux/LoongArch操作系统部分系统调用", autonum = autonum) %>%
merge_v() %>%
theme_box() %>%
autofit()
```
Linux内核中每个系统调用都被分配了一个整数编号称为调用号。调用号的定义与具体指令系统相关X86和MIPS对同一函数的调用号可能不同。Linux/LoongArch系统的调用号定义可以从内核源码include/uapi/asm-generic/unistd.h获得。
因为涉及特权等级的切换系统调用通常被当作一种用户发起的特殊异常来处理。例如在LoongArch指令系统中执行SYSCALL指令会触发系统调用异常。异常处理程序通过调用号查表找到内核中相应的实现函数。与所有异常一样系统调用在返回时使用ERTN指令来同时完成跳转用户地址和返回用户态的操作。
类似于一般的函数调用系统调用也需要进行参数的传递。应该尽可能使用寄存器进行传递这可以避免在核心态空间和用户态空间之间进行不必要的内容复制。在LoongArch指令系统中系统调用的参数传递有以下约定
1)调用号存放在\$a7寄存器中。
2)至多7个参数通过\$a0\~\$a6寄存器进行传递。
3)返回值存放在\$a0/\$a1寄存器。
4)系统调用保存\$s0-\$s8寄存器的内容不保证保持参数寄存器和暂存寄存器的内容。
为了保障安全性内核必须对用户程序传入的数组索引、指针和缓冲区长度等可能带来安全风险的参数进行检查。从用户空间复制数据时应用程序提供的指针可能是无效的直接在内核使用可能导致内核崩溃。因此Linux内核使用专用函数copy_to_user()和copy_from_user()来完成与用户空间相关的复制操作。它们为相应的访存操作提供了专门的异常处理代码,避免内核因为用户传入的非法值而发生崩溃。
图\@ref(fig:syscall-write)展示了一个汇编语言编写的write系统调用的例子。用gcc编译运行它会在屏幕上输出“Hello World!”字符串。当然通常情况下应用程序不用这样使用系统调用系统函数库会提供包装好的系统调用函数以及更高层的功能接口。比如glibc库函数write包装了write系统调用C程序直接用write(1,“Hello World!\\n”,14)或者用更高层的功能函数printf(“Hello World\\n”)就可以实现同样的功能。
```{r syscall-write, echo=FALSE, fig.align='center', fig.cap='调用write系统调用输出字符串', out.width='80%'}
knitr::include_graphics("materials/chapter4/syscall_write.S.png")
```
### 进程
为了支持多道程序并发执行操作系统引入了进程的概念。进程是程序在特定数据集合上的执行实例一般由程序、数据集合和进程控制块三部分组成。进程控制块包括很多信息它记录每个进程运行过程中虚拟内存地址、打开文件、锁和信号等资源的情况。操作系统通过分时复用、虚拟内存等技术让每个进程都觉得自己拥有一个独立的CPU和独立的内存地址空间。切换进程时需要切换进程上下文。进程上下文包括进程控制块记录的各种信息。
进程的上下文切换主要由软件来完成。发生切换的时机主要有两种一是进程主动调用某些系统调用时因出现无法继续运行的情况如等待IO完成或者获得锁而触发切换二是进程分配到的时间片用完了或者有更高优先级的就绪进程要抢占CPU导致的切换。切换工作的实质是实现对CPU硬件资源的分时复用。操作系统把当前进程的运行上下文信息保存到内存中再把选中的下一个进程的上下文信息装载到CPU中。特定时刻只能由一个进程使用的处理器状态信息包括通用寄存器、eflags等用户态的专有寄存器以及当前程序计数器PC、处理器模式和状态、页表基址例如X86指令系统的CR3寄存器和LoongArch的PGD寄存器等控制信息都需要被保存起来以便下次运行时恢复到同样的状态。如果一些不支持共享的硬件状态信息在内存里有最新备份切换时可以采用直接丢弃的方法。例如有些指令系统的TLB不能区分不同进程的页表项早期的X86指令系统就是如此那么在进程切换时需要把已有的表项设为无效避免被新的进程错误使用。而可以共享的硬件状态信息如Cache等以及用内存保存的上下文信息如页表等则不需要处理。由于篇幅限制这里不展开讨论具体的进程切换细节感兴趣的读者可以通过阅读Linux内核源代码或者相关操作系统书籍来进一步了解。
不同的硬件支持可能导致不同的效率。TLB是否可以区分来自不同进程的页表项就是一个例子。不能区分时每次切换进程的时候必须使所有的硬件TLB表项无效每次进程开始运行时都需要重新从内存获取页表项。而LoongArch等指令系统的TLB则支持用某种进程标记LoongArch中是ASID来区分不同进程的页表项可以避免这种开销。随着指令系统的发展需要切换的信息也在增加引发了一些新的硬件支持需求。例如除了常规的整数和浮点通用寄存器很多现代处理器增加了数十个位宽很大X86 AVX扩展可达512位的向量寄存器。由于无条件保存所有寄存器的代价比较大操作系统常常会采用某种按需保存的优化比如不为没有用到向量的进程保存向量状态。但这需要指令系统提供一定的支持。在MIPS和LoongArch指令系统中浮点和向量部件都可以通过控制寄存器来关闭在关闭部件后使用相关指令会触发异常这样操作系统就能有效地实现按需加载。
历史上也有些指令系统曾尝试为进程切换提供更多硬件支持。例如X86指令系统提供了专门的TSTask State段和硬件自动保存进程上下文的机制适当设置之后进程切换可以由硬件完成。但由于硬件机制不够灵活而且性能收益不明显包括Linux和Windows在内的多数操作系统都没有使用这个机制。
### 线程
线程是程序代码的一个执行路径。一个进程可以包含多个线程这些线程之间共享内存空间和打开文件等资源但逻辑上拥有独立的寄存器状态和栈。现代系统的线程一般也支持线程私有存储区Thread Local Storage,简称TLS。例如GCC编译器支持用__thread int number;这样的语句来定义一个线程私有的全局变量不同线程看到的number地址是不一样的。
线程可以由操作系统内核管理也可以由用户态的线程库管理或者两者混合。线程的实现方式对切换开销有很大的影响。例如Linux系统中最常用的线程库NPTL POSIX Thread Library采用内核和用户1:1的线程模型每个用户级线程对应一个内核线程。除了不切换地址空间线程的切换和进程的大部分流程一致都需要进入和退出核心态,经历至少两次用户态和核心态上下文的切换。因此对一些简单测试来说Linux中进程和线程切换的速度差异可能不太明显。而Go语言提供的goroutines可以被看作一种用户级实现的轻量级线程它的切换不需要通过内核一些测试表明其切换开销可比NPTL小一半以上^[https://eli.thegreenplace.net/2018/measuring-context-switching-and-memory-overheads-for-linux-threads/]。当然进程和线程切换不仅仅有执行切换代码的直接开销还有因为TLB、Cache等资源竞争导致的间接开销在数据集比较大的时候进程和线程的实际切换代价差异也可能较大。
同样适当的硬件支持也有助于提升线程切换效率。例如LoongArch的ABI将一个通用寄存器用作专门的\$tp寄存器用来高效访问TLS空间。切换线程时只需要将\$tp指向新线程的TLS访问TLS的变量时用\$tp和相应的偏移就能实现访问每个线程一份的变量。相比之下Linux/MIPS系统则依赖系统调用set_thread_area来设置当前线程的TLS指针将它保存到内核的线程数据结构中用户程序用rdhwr指令来读取当前的线程指针这个指令会产生一个异常来陷入内核读取TLS指针。相比之下这样的实现效率会低很多。
### 虚拟机
线程把一份CPU计算资源虚拟成多份独立的CPU计算资源进程把CPU和物理内存的组合虚拟成多份独立的虚拟CPU和虚拟内存组合。更进一步我们可以把一台物理计算机虚拟成多台含CPU、内存和各种外设的虚拟计算机。虚拟机可以更好地隔离不同的服务运行环境更充分地利用越来越丰富的物理机资源更方便地迁移和管理因此得到了广泛的应用成为云计算的基础技术。
虚拟机的运行上下文包括CPU、内存和外设的状态。在虚拟机内部会发生函数调用、中断和异常、线程和进程等各种内部的上下文切换它们的处理和物理机的相应场景类似。但在虚拟机无法独立处理的情况下会退出虚拟机运行状态借助宿主机的虚拟化管理软件来完成任务。虚拟机和宿主机之间的切换需要保存和恢复所有可能被修改的虚拟机相关状态信息。例如对于CPU的状态信息之前几种场景需要保存恢复的主要是用户可访问的寄存器而虚拟机切换时可能还需要保存各种特权态资源包括众多控制寄存器。如果系统支持在一台物理计算机上虚拟化出多个虚拟机在物理资源少于虚拟机个数的时候只能通过保存和恢复相关资源来维持每个虚拟机都独占资源的效果。
虚拟机可以完全由软件实现。例如开源的QEMU虚拟机软件能够虚拟出各种架构的CPU和众多设备如在一台龙芯电脑上虚拟出一台X86 PC设备并运行Windows操作系统。在宿主机指令系统和被模拟的客户机指令系统不同时QEMU采用二进制翻译技术把客户机应用动态翻译成等价功能的宿主机指令。不过这种情况下QEMU虚拟的客户机运行速度比较低一般不到宿主机的10%。
在客户机和宿主机指令系统相同时已经有一些成熟的技术可通过适当的硬件支持来大大提升虚拟化效率。龙芯和大部分现代的高性能处理器都支持虚拟机扩展在处理运行模式、系统态资源、内存虚拟化和IO虚拟化等方面提供硬件支持使得虚拟机可以实现和物理机相似的性能。例如关于处理器运行模式LoongArch引入一个客户机模式Guest Mode和一个主机模式Host Mode以区分当前CPU是在运行客户机还是宿主机。这两个模式和特权等级模式PV0-3是正交的也就是说客户机模式和主机模式下都有PV0-3四个特权等级。关于系统态资源如果只有一套那么在客户机和主机模式之间切换时就得通过保存恢复这些资源来复用。为了提高效率硬件上可以复制相关资源让客户机模式和主机模式使用专属的特权态资源如控制寄存器。在内存虚拟化方面通过硬件支持的两级地址翻译技术可以有效地提升客户操作系统的地址翻译效率。可将支持二级地址翻译的硬件看作有两个TLB一个保存客户机模式下的虚实地址映射关系另一个保存主机模式下的虚实地址映射关系。客户机模式下一个客户机虚拟地址首先通过前一个TLB查出客户机物理地址它是由主机模式的虚拟内存模拟的实际上是主机模式的虚拟地址然后CPU会自动用后一个TLB进行下一级的地址翻译找出真正的主机物理地址。在IO虚拟化方面,通过IOMMUInput-Output Memory Management Unit^[普通MMU为CPU提供物理内存的虚拟化IOMMU则为外设提供物理内存的虚拟化让外设访问内存时可通过虚实地址转换])、支持虚拟化的中断分派等硬件可以有效提升虚拟化效率。适当的硬件支持有助于降低上下文切换需要保存恢复的内容、有助于在客户机模式的程序和真实硬件之间建立直接通道,从而提升虚拟化性能。
### 六种上下文切换场景的对比
表\@ref(tab:context-switch)对以上六种上下文切换的场景进行了对比总结。函数调用和系统调用是用户主动发起的因此可以通过ABI约定来避免不必要的保存恢复。其他几种场景通常都要达到对应用程序透明的效果因此切换后可能被修改的状态都应该被保存和恢复。
```{r context-switch, echo = FALSE, message=FALSE, tab.cap='六种上下文切换场景'}
autonum <- run_autonum(seq_id = "tab", bkm = "context-switch", bkm_all = TRUE)
readr::read_csv('./materials/chapter4/context_switch.csv') %>%
flextable() %>%
set_caption(caption="六种上下文切换场景", autonum = autonum) %>%
theme_box() %>%
autofit()
```
## 同步机制
多任务是操作系统最为关键的特性之一现代操作系统中可能同时存在多个进程每个进程又可能包含多个同时执行的线程。在Linux操作系统中某个线程正在操作的数据很可能也在被另一个线程访问。并发访问的线程可能有以下来源
1)另一个CPU核上的线程。这是真正的多处理器系统。
2)处于中断上下文的线程。中断处理程序打断当前线程的执行。
3)因调度而抢占的另一线程。中断处理后调度而来的其他内核线程。
当线程之间出现资源访问的冲突时,需要有同步和通信的机制来保证并发数据访问的正确性。如在第\@ref(sec-exception)节中所提到的中断原子性线程之间的共享数据访问都应该实现原子性要么完全完成对数据的改动要么什么改变都没有发生。Linux中包含部分原子操作如atomic_inc()函数等这些操作在某些指令系统中可以有特定的实现方法如X86的lock类指令。同步机制通常包括基于互斥Mutual Exclusive和非阻塞Non-Blocking两类。
### 基于互斥的同步机制
为了使更复杂的操作具有原子性Linux使用了锁机制。锁是信号量机制的一种简单实现是对特定数据进行操作的“门票”访问同一数据的软件都要互相协作同一时刻只能有一个线程操作该数据任何访问被锁住数据的线程将被阻塞。
对数据进行原子操作的程序段叫作临界区,在临界区前后应该包含申请锁和释放锁的过程,申请锁失败的线程被阻塞,占有锁的进程在完成临界区操作后应该及时释放锁。
当确认竞争者在另一个CPU核上而且临界区程序很短时让等待锁的线程循环检查锁状态直至锁可用显然是合理的这也是Linux为SMPSymmetric Multi-Processing实现的自旋锁。但当竞争者都在同一个CPU核上时在不可抢占的内核下进行自旋可能导致死锁此时自旋锁将退化为空操作。
当自旋锁不可用时需要使用互斥锁的机制。当一个线程获取锁失败时会将自己阻塞并调用操作系统的调度器。在释放锁的时候还需要同时让其他等待锁的线程离开阻塞状态。挂起和唤醒线程的操作与指令系统无关但测试锁状态和设置锁的代码依赖于原子的“测试并设置”指令而LoongArch指令系统的实现方式是LL/SC指令对32位操作加.W后缀64位加.D后缀。LL指令设置LL bit并检测访问的物理地址是否被修改或可能被修改在检测到时将LL bit清除。在SMP中检测LL bit通常使用Cache一致性协议的监听逻辑来实现。在单处理器系统中异常处理会破坏LL bit。SC指令实现带条件的存储。当LL bit为0时SC不会完成存储操作而是把保存值的源操作数寄存器清零以指示失败。
Linux中的atomic_inc()原子操作函数可以使用LL/SC来实现如下图所示。
```
atomic_inc:
ll.w $t0, $a0, 0
addi.w $t0, $t0, 1
sc.w $t0, $a0, 0
beqz $t0, atomic_inc
add.w $a0, $t0, $zero
jr $ra
```
当SC失败时程序会自旋循环重试。由于程序很短上述程序自旋很多次的概率还是很低的。但当LL和SC之间的操作很多时LL bit就有较大可能被破坏因此单纯的LL/SC对复杂的操作并不适合。操作复杂时可以使用LL/SC来构造锁利用锁来完成线程间的同步和通信需求。LoongArch指令系统中的“测试并设置”和自旋锁指令的实现如下所示。“测试并设置”指令取回锁的旧值并设置新的锁值自旋锁指令反复自旋得到锁后再进入临界区。
```
la.local $a0, lock la.local $a0, lock
test_and_set: selfspin:
ll.w $v0, $a0, 0 ll.w $t0, $a0, 0
li $t0, 0x1 bnez $t0, selfspin
sc.w $t0, $a0, 0 li $t1, 0x1
beqz $t0, test_and_set sc.w $t1, $a0, 0
beqz $t1, selfspin
<Critical section>
st.w $zero, lock
...
```
### 非阻塞的同步机制
基于锁的资源保护和线程同步有以下缺点:
1)若持有锁的线程死亡、阻塞或死循环,则其他等待锁的线程可能永远等待下去。
2)即使冲突的情况非常少,锁机制也有获取锁和释放锁的代价。
3)锁导致的错误与时机有关,难以重现。
4)持有锁的线程因时间片中断或页错误而被取消调度时,其他线程需要等待。
一些非阻塞同步机制可以避免上述不足之处其中一种较为有名的就是事务内存Tran-sactional Memory。事务内存的核心思想是通过尝试性地执行事务代码在程序运行过程中动态检测事务间的冲突并根据冲突检测结果提交或取消事务。
可以发现事务内存的核心思想与LL/SC是一致的事实上LL/SC可以被视为事务内存的一种最基础的实现只不过LL/SC的局限在于其操作的数据与寄存器宽度相同只能用于很小的事务。
软件事务内存通过运行时库或专门的编程语言来提供支持但仍需要最小的硬件支持如“测试并设置”指令。虽然非常易于多线程编程但软件事务内存有相当可观的内存空间和执行速度的代价。同时软件事务内存不能用于无法取消的事务如多数对IO的访问。
近年来许多处理器增加了对事务内存的硬件支持。Sun公司在其Rock处理器中实现了硬件事务内存但在2009年被Oracle公司收购前取消了该处理器也没有实物发布。2011年IBM公司在其Blue Gene/Q中首先提供了对事务内存的支持并在后续的Power8中持续支持。Intel公司最早在Haswell处理器核中支持硬件事务内存其扩展叫作TSXTransactional Synchronization Extension
## 本章小结
本章首先介绍了应用程序二进制接口ABI的相关概念并用LoongArch等指令系统的具体例子说明寄存器约定、函数调用约定、参数传递、虚拟地址空间和栈帧布局等内容然后介绍了六种上下文切换场景的软硬件协同实现讨论了切换的具体内容以及指令系统的硬件支持对切换效率的影响最后简单介绍了同步机制包括基于互斥的同步机制和非阻塞的同步机制。
## 习题
1. 列出以下C程序中按照Linux/LoongArch64 ABI的函数调用约定调用nested函数时每个参数是如何传递的。
```
struct small {
char c;
int d;
} sm;
struct big {
long a1;
long a2;
long a3;
long a4;
} bg;
extern long nested(char a, short b, int c, long d, float e, double f, struct small g,struct
big h, long i);
long test (void){
return nested((char)0x61, (short)0xffff, 1, 2, 3.0, 4.0, sm, bg, 9);
}
```
2.
1). 用LoongArch汇编程序来举例并分析在未同步的线程之间进行共享数据访问出错的情况。
2). 用LL/SC指令改写你的程序使它们的共享数据访问正确。
3.
1). 写一段包含冒泡排序算法实现函数的C程序在你的机器上安装LoongArch交叉编译器通过编译-反汇编的方式提取该算法的汇编代码。
2). 改变编译的优化选项,记录算法汇编代码的变化,并分析不同优化选项的效果。
4. ABI中会包含对结构体中各元素的对齐和摆放方式的定义。
1). 在你的机器上用C语言编写一段包含不同类型含char、short、int、long、float、double和long double元素的结构体的程序并获得结构体总空间占用情况。
2). 调整结构体元素顺序,观察结构体总空间占用情况的变化,推测并分析结构体对齐的方式。
5. 用汇编或者带嵌入汇编的C语言编写一个程序通过直接调用系统调用让它从键盘输入一个字符并在屏幕打印出来。用调试器单步跟踪指令执行观察系统调用指令执行前后的寄存器变化情况对照相应平台的ABI给出解释。
\newpage

412
15-organization.Rmd Normal file
View File

@@ -0,0 +1,412 @@
# (PART) 计算机硬件结构 {-}
# 计算机组成原理和结构
前面章节介绍的计算机指令系统结构从软件的角度描述了计算机功能,从本章开始将介绍计算机组成结构,从硬件的角度来看看计算机是怎样构成的。
如果说图灵机是现代计算机的计算理论模型,冯·诺依曼结构就是现代计算机的结构理论模型。本章从冯·诺依曼的理论模型开始,介绍计算机系统的各个组成部分,并与现代计算机的具体实现相对应。
## 冯·诺依曼结构
现代计算机都采用存储程序结构又称为冯·诺依曼结构是1945年匈牙利籍数学家冯·诺依曼受宾夕法尼亚大学研制的ENIAC计算机结构的启发提出的是世界上第一个完整的计算机体系结构。
冯·诺依曼结构的主要特点是①计算机由存储器、运算器、控制器、输入设备和输出设备五部分组成其中运算器和控制器合称为中央处理器Central Processing Processor简称CPU。②存储器是按地址访问的线性编址的一维结构每个单元的位数固定。③采用存储程序方式即指令和数据不加区别混合存储在同一个存储器中。④控制器通过执行指令发出控制信号控制计算机的操作。指令在存储器中按其执行顺序存放由指令计数器指明要执行的指令所在的单元地址。指令计数器一般按顺序递增但执行顺序可按运算结果或当时的外界条件而改变。⑤以运算器为中心输入输出设备与存储器之间的数据传送都经过运算器。冯·诺依曼计算机的工作原理如图\@ref(fig:von-architecture)所示。
```{r von-architecture, echo=FALSE, fig.align='center', fig.cap="冯·诺依曼计算机体系结构", out.width='50%'}
knitr::include_graphics("images/chapter5/von_architecture.png")
```
随着技术的进步冯·诺依曼结构得到了持续的改进主要包括以下几个方面①由以运算器为中心改进为以存储器为中心。使数据的流向更加合理从而使运算器、存储器和输入输出设备能够并行工作。②由单一的集中控制改进为分散控制。计算机发展初期工作速度很低运算器、存储器、控制器和输入输出设备可以在同一个时钟信号的控制下同步工作。现在运算器、内存与输入输出设备的速度差异很大需要采用异步方式分散控制。③从基于串行算法改进为适应并行算法。出现了流水线处理器、超标量处理器、向量处理器、多核处理器、对称多处理器Symmetric Multiprocessor简称SMP、大规模并行处理机Massively Parallel Processing简称MPP和机群系统等。④出现为适应特殊需要的专用计算机如图形处理器Graphic Processing Unit简称GPU、数字信号处理器Digital Signal Processor简称DSP等。⑤在非冯·诺依曼计算机的研究方面也取得一些成果如依靠数据驱动的数据流计算机、图归约计算机等。
虽然经过了长期的发展,现代计算机系统占据主要地位的仍然是以存储程序和指令驱动执行为主要特点的冯·诺依曼结构。
作为冯·诺依曼结构的一个变种,哈佛结构把程序和数据分开存储。控制器使用两条独立的总线读取程序和访问数据,程序空间和数据空间完成分开。在通用计算机领域,由于应用软件的多样性,要求计算机不断地变化所执行的程序内容,并且频繁地对数据与程序占用的存储器资源进行重新分配,使用统一编址可以最大限度地利用资源。但是在嵌入式应用中,系统要执行的任务相对单一,程序一般是固化在硬件里的,同时嵌入式系统对安全性、可靠性的要求更高,哈佛结构独立的程序空间更有利于代码保护。因此,在嵌入式领域,哈佛结构得到了广泛应用。需要指出的是,哈佛结构并没有改变冯·诺依曼结构存储程序和指令驱动执行的本质,它只是冯·诺依曼结构的一个变种,并不是独立于冯·诺依曼结构的一种新型结构。
## 计算机的组成部件
本节对计算机的主要组成部件进行介绍。按照冯·诺依曼结构,计算机包含五大部分,即运算器、控制器、存储器、输入设备和输出设备。
### 运算器
运算器是计算机中负责计算包括算术计算和逻辑计算等的部件。运算器包括算术和逻辑运算部件Arithmetic Logic Units简称ALU、移位部件、浮点运算部件Floating Point Units简称FPU、向量运算部件、寄存器等。其中复杂运算如乘除法、开方及浮点运算可用程序实现或由运算器实现。寄存器既可用于保存数据也可用于保存地址。运算器还可设置条件码寄存器等专用寄存器条件码寄存器保存当前运算结果的状态如运算结果是正数、负数或零是否溢出等。
运算器支持的运算类型经历了从简单到复杂的过程。最初的运算器只有简单的定点加减和基本逻辑运算复杂运算如乘除通过加减、移位指令构成的数学库完成后来逐渐出现硬件定点乘法器和除法器。在早期的微处理器中浮点运算器以协处理器的形式出现在计算机中如Intel 8087协处理器包含二进制浮点数的加、减、乘、除等运算现代的通用微处理器则普遍包含完整的浮点运算部件。20世纪90年代开始微处理器中出现了单指令多数据Single Instruction Multiple Data简称SIMD的向量运算器部分处理器还实现了超越函数硬件运算单元如sin、cos、exp、log等。部分用于银行业务处理的计算机如IBM Power系列还实现了十进制定、浮点数的运算器。
随着晶体管集成度的不断提升处理器中所集成的运算器的数量也持续增加通常将具有相近属性的一类运算组织在一起构成一个运算单元。不同的处理器有不同的运算单元组织有的倾向于每个单元大而全有的倾向于每个单元的功能相对单一。处理器中包含的运算单元数目也逐渐增加从早期的单个运算单元逐渐增加到多个运算单元。由于运算单元都需要从寄存器中读取操作数并把结果写回寄存器因此处理器中运算单元的个数主要受限于寄存器堆读写端口个数。运算单元一般按照定点、浮点、访存、向量等大类来组织也有混合的如SIMD部件既能做定点也能做浮点运算定点部件也可以做访存地址计算等。
表\@ref(tab:alu)给出了几种经典处理器的运算器结构。其中Alpha 21264、MIPS R10000、HP PA8700、Ultra Sparc III、Power 4是20世纪90年代RISC处理器鼎盛时期经典的微处理器而Intel Skylake、AMD Zen、Power 8、龙芯3A5000则是最新处理器。
```{r alu, echo = FALSE, message=FALSE, tab.cap='经典处理器的运算器结构'}
autonum <- run_autonum(seq_id = "tab", bkm = "alu", bkm_all = TRUE)
readr::read_csv('./materials/chapter5/alu.csv') %>%
flextable() %>%
set_caption(caption="经典处理器的运算器结构", autonum = autonum) %>%
width(j=1, width=1.3) %>%
width(j=2, width=2.0) %>%
width(j=3, width=2.6) %>%
theme_box()
```
### 控制器
控制器是计算机中发出控制命令以控制计算机各部件自动、协调地工作的装置。控制器控制指令流和每条指令的执行内含程序计数器和指令寄存器等。程序计数器存放当前执行指令的地址指令寄存器存放当前正在执行的指令。指令通过译码产生控制信号用于控制运算器、存储器、IO设备的工作以及后续指令的获取。这些控制信号可以用硬连线逻辑产生也可以用微程序产生也可以两者结合产生。为了获得高指令吞吐率可以采用指令重叠执行的流水线技术以及同时执行多条指令的超标量技术。当遇到执行时间较长或条件不具备的指令时把条件具备的后续指令提前执行称为乱序执行可以提高流水线效率。控制器还产生一定频率的时钟脉冲用于计算机各组成部分的同步。
由于控制器和运算器的紧密耦合关系现代计算机通常把控制器和运算器集成在一起称为中央处理器即CPU。随着芯片集成度的不断提高现代CPU除了含有运算器和控制器外常常还集成了其他部件比如高速缓存Cache部件、内存控制器等。
计算机执行指令一般包含以下过程:从存储器取指令并对取回的指令进行译码,从存储器或寄存器读取指令执行需要的操作数,执行指令,把执行结果写回存储器或寄存器。上述过程称为一个指令周期。计算机不断重复指令周期直到完成程序的执行。体系结构研究的一个永恒主题就是不断加速上述指令执行周期,从而提高计算机运行程序的效率。由于控制器负责控制指令流和每条指令的执行,对提高指令执行效率起着至关重要的作用。
现代处理器的控制器都通过指令流水线技术来提高指令执行效率。指令流水线把一条指令的执行划分为若干阶段(如分为取指、译码、执行、访存、写回阶段)来减少每个时钟周期的工作量,从而提高主频;并允许多条指令的不同阶段重叠执行实现并行处理(如一条指令处于执行阶段时,另一条指令处于译码阶段)。虽然同一条指令的执行时间没有变短,但处理器在单位时间内执行的指令数增加了。
计算机中的取指部件、运算部件、访存部件都在流水线的调度下具体执行指令规定的操作。运算部件的个数和延迟,访存部件的存储层次、容量和带宽,以及取指部件的转移猜测算法等是决定微结构性能的重要因素。常见的提高流水线效率的技术包括转移预测技术、乱序执行技术、超标量(又称为多发射)技术等。
1转移预测技术。冯·诺依曼结构指令驱动执行的特点使转移指令成为提高流水线效率的瓶颈。典型应用程序平均每5-10条指令中就有一条转移指令而转移指令的后续指令需要等待转移指令执行结果确定后才能取指导致转移指令和后续指令之间不能重叠执行降低了流水线效率。随着主频的提高现代处理器流水线普遍在10-20级之间由于转移指令引起的流水线阻塞成为提高指令流水线效率的重要瓶颈。
转移预测技术可以消除转移指令引起的指令流水线阻塞。转移预测器根据当前转移指令或其他转移指令的历史行为,在转移指令的取指或译码阶段预测该转移指令的跳转方向和目标地址并进行后续指令的取指。转移指令执行后,根据已经确定的跳转方向和目标地址对预测结果进行修正。如果发生转移预测错误,还需要取消指令流水线中的后续指令。为了提高预测精度并降低预测错误时的流水线开销,现代高性能处理器采用了复杂的转移预测器。
例如可以在取指部件中设置一位标志记录上一条转移指令的跳转方向碰到转移指令不用等该转移指令执行结果就根据该标志猜测跳转方向进行取指。对于C语言中的`for (i=0,i<N,i++)`类的循环这种简单的转移猜测就可以达到N-1/N+1的准确度当N很大时准确度很高。
2乱序执行技术。如果指令i是条长延迟指令如除法指令或Cache不命中的访存指令那么在顺序指令流水线中指令i后面的指令需要在流水线中等待很长时间。乱序执行技术通过指令动态调度允许指令i后面的源操作数准备好的指令越过指令i执行需要使用指令i的运算结果的指令由于源操作数没有准备好不会越过指令i执行以提高指令流水线效率。为此在指令译码之后的读寄存器阶段应判断指令需要的操作数是否准备好。如果操作数已经准备好就进入执行阶段如果操作数没有准备好就进入称为保留站或者发射队列的队列中等待直到操作数准备好后再进入执行阶段。为了保证执行结果符合程序规定的要求乱序执行的指令需要有序结束。为此执行完的指令均进入一个称为重排序缓冲Re-Order Buffer简称ROB的队列并把执行结果临时写入重命名寄存器。ROB根据指令进入流水线的次序有序提交指令的执行结果到目标寄存器或存储器。CDC6600和IBM 360/91分别使用记分板和保留站最早实现了指令的动态调度。
就像保留站和重排序缓冲用来临时存储指令以使指令在流水线中流动更加通畅,重命名寄存器用来临时存储数据以使数据在流水线流动更加通畅。保留站、重排序缓冲、重命名寄存器都是微结构中的数据结构,程序员无法用指令来访问,是结构设计人员为了提高流水线效率而用来临时存储指令和数据的。其中,保留站把指令从有序变为无序以提高执行效率,重排序缓存把指令从无序重新变为有序以保证正确性,重命名寄存器则在乱序执行过程中临时存储数据。重命名寄存器与指令可以访问的结构寄存器(如通用寄存器、浮点寄存器)相对应。乱序执行流水线把指令执行结果写入重命名寄存器而不是结构寄存器,以避免破坏结构寄存器的内容,到顺序提交阶段再把重命名寄存器内容写入结构寄存器。两组执行不同运算但使用同一结构寄存器的指令可以使用不同的重命名寄存器,从而避免该结构寄存器成为串行化瓶颈,实现并行执行。
3超标量技术。工艺技术的发展使得在20世纪80年代后期出现了超标量处理器。超标量结构允许指令流水线的每一阶段同时处理多条指令。例如Alpha 21264处理器每拍可以取4条指令发射6条指令写回6条指令提交11条指令。如果把单发射结构比作单车道马路多发射结构就是多车道马路。
由于超标量结构的指令和数据通路都变宽了使得寄存器端口、保留站端口、ROB端口、功能部件数都需要增加例如Alpha 21264的寄存器堆有8个读端口和6个写端口数据Cache的RAM通过倍频支持一拍两次访问。现代超标量处理器一般包含两个以上访存部件两个以上定点运算部件以及两个以上浮点运算部件。超标量结构在指令译码或寄存器重命名时不仅要判断前后拍指令的数据相关还需要判断同一拍中多条指令间的数据相关。
### 存储器
存储器存储程序和数据又称主存储器或内存一般用动态随机访问存储器Dynamic Random Access Memory简称DRAM实现。CPU可以直接访问它IO设备也频繁地与它交换数据。存储器的存取速度往往满足不了CPU的快速要求容量也满足不了应用的需要为此将存储系统分为高速缓存Cache)、主存储器和辅助存储器三个层次。Cache存放当前CPU最频繁访问的部分主存储器内容可以采用比DRAM速度快但容量小的静态随机访问存储器Static Random Access Memory简称SRAM实现。数据和指令在Cache和主存储器之间的调动由硬件自动完成。为扩大存储器容量使用磁盘、磁带、光盘等能存储大量数据的存储器作为辅助存储器。计算机运行时所需的应用程序、系统软件和数据等都先存放在辅助存储器中在运行过程中分批调入主存储器。数据和指令在主存储器和辅助存储器之间的调动由操作系统完成。CPU访问存储器时面对的是一个高速接近于Cache的速度、大容量接近于辅助存储器的容量的存储器。现代计算机中还有少量只读存储器Read Only Memory简称ROM用来存放引导程序和基本输入输出系统Basic Input Output System简称BIOS等。现代计算机访问内存时采用虚拟地址操作系统负责维护虚拟地址和物理地址转换的页表集成在CPU中的存储管理部件Memory Management Unit简称MMU负责把虚拟地址转换为物理地址。
存储器的主要评价指标为存储容量和访问速度。存储容量越大,可以存放的程序和数据越多。访问速度越快,处理器访问的时间越短。对相同容量的存储器,速度越快的存储介质成本越高,而成本越低的存储介质则速度越低。目前人们发明的用于计算机系统的存储介质主要包括以下几类:
1磁性存储介质。如硬盘、磁带等特点是存储密度高、成本低、具有非易失性断电后数据可长期保存缺点是访问速度慢。磁带的访问速度在秒级磁盘的访问速度一般在毫秒级这样的访问速度显然不能满足现代处理器纳秒级周期的速度要求。
2闪存Flash Memory。同样是非易失性的存储介质与磁盘相比它们的访问速度快成本高容量小。随着闪存工艺技术的进步闪存芯片的集成度不断提高成本持续降低闪存正在逐步取代磁盘作为计算机尤其是终端的辅助存储器。
3动态随机访问存储器DRAM。属于易失性存储器断电后数据丢失。特点是存储密度较高存储一位数据只需一个晶体管需要周期性刷新访问速度较快。其访问速度一般在几十纳秒级。
4静态随机访问存储器SRAM。属于易失性存储器断电后数据丢失。存储密度不如DRAM高SRAM存储一位数据需要4-8个晶体管不用周期性刷新但访问速度比DRAM快可以达到纳秒级小容量时能够和处理器核工作在相同的时钟频率。
现代计算机中把上述不同的存储介质组成存储层次,以在成本合适的情况下降低存储访问延迟,如图\@ref(fig:storage-hierarchy)中所示,越往上的层级,速度越快,但成本越高,容量越小;越往下的层级,速度越慢,但成本越低,容量越大。图\@ref(fig:storage-hierarchy)所示存储层次中的寄存器和主存储器直接由指令访问Cache缓存主存储器的部分内容而非易失存储器既是辅助存储器又是输入输出设备非易失存储器的内容由操作系统负责调入调出主存储器。
```{r storage-hierarchy, echo=FALSE, fig.align='center', fig.cap="存储层次", out.width='50%'}
knitr::include_graphics("images/chapter5/storage_hierarchy.png")
```
存储层次的有效性依赖于程序的访存局部性原理包含两个方面一是时间局部性指的是如果一个数据被访问那么在短时间内很有可能被再次访问二是空间局部性指的是如果一个数据被访问那么它的邻近数据也很有可能被访问。利用局部性原理可以把程序近期可能用到的数据存放在靠上的层次把近期内不会用到的数据存放在靠下的层次。通过恰当地控制数据在层次间的移动使处理器需要访问的数据尽可能地出现在靠近处理器的存储层次可以大大提高处理器获得数据的速度从而近似达到用最快的存储器构建一个容量很大的单级存储的效果。现代计算机一般使用多端口寄存器堆实现寄存器使用SRAM来构建片上的高速缓存Cache使用DRAM来构建程序的主存储器也称为主存、内存使用磁盘或闪存来构建大容量的存储器。
1.高速缓存
随着工艺技术的发展处理器的运算速度和内存容量按摩尔定律的预测指数增加但内存速度提高非常缓慢与处理器速度的提高形成了“剪刀差”。工艺技术的上述特点使得访存延迟成为以存储器为中心的冯·诺依曼结构的主要瓶颈。Cache技术利用程序访问内存的时间局部性一个单元如果当前被访问则近期很有可能被访问和空间局部性一个单元被访问后与之相邻的单元也很有可能被访问使用速度较快、容量较小的Cache临时保存处理器常用的数据使得处理器的多数访存操作可以在Cache上快速进行只有少量访问Cache不命中的访存操作才访问内存。
Cache是内存的映像其内容是内存内容的子集处理器访问Cache和访问内存使用相同的地址。从20世纪80年代开始RISC处理器就开始在处理器芯片内集成KB级的小容量Cache。现代处理器则普遍在片内集成多级Cache典型的多核处理器的每个处理器核中一级指令Cache和数据Cache各几十KB二级Cache为几百KB而多核共享的三级Cache为几MB到几十MB。现代处理器访问寄存器时一拍可以同时读写多个数据访问一级Cache延迟为1-4拍访问二级Cache延迟为10-20拍访问三级Cache延迟为40-60拍访问内存延迟为100-200拍。
CPU执行一个程序的时间可以描述为程序中的指令数×IPC×时钟周期。其中IPCInstruction Per Cycle表示每个时钟周期执行的指令数可以细分为运算指令的IPC×运算指令的比例 + 访存指令的IPC×访存指令的比例。访存指令的IPC为平均访问延迟AMATAverage Memory Access Latency的倒数。在具有高速缓存的计算机中
$$AMAT = HitTime + MissRate * MissPenalty$$
其中HitTime表示高速缓存命中时的访问延迟MissRate表示高速缓存失效率MissPenalty表示高速缓存失效时额外的访问延迟。例如在某计算机系统中HitTime=1, MissRate=5%, MissPenalty=100则AMAT=1+5=6。
2.内存
主存储器又称为内存。内存的读写速度对计算机的整体性能影响重大。为了提升处理器的访存性能现代通用处理器都将内存控制器与CPU集成在同一芯片内以减小平均访存延迟。
现代计算机的内存一般都采用同步动态随机存储器SDRAM实现。DRAM的一个单元由MOS管T和电容C存储单元组成如图\@ref(fig:dram-cell)所示。电容C存储的电位决定存储单元的逻辑值。单元中的字线根据读写地址译码得到连接同一字的若干位单元中的位线把若干字的同一位链接在一起。进行读操作时先把位线预充到Vref=VCC/2然后字线打开T管C引起差分位线微小的电位差感应放大器读出读出后C中的电位被破坏需要把读出值重新写入C。进行写操作时先把位线预充成要写的值然后打开字线把位线的值写入C。C中的电容可能会漏掉因此DRAM需要周期刷新刷新可以通过读操作进行一般每行几十微秒刷新一次。
"
```{r dram-cell, echo=FALSE, fig.align='center', fig.cap="DRAM的单元读写原理", out.width='50%'}
knitr::include_graphics("images/chapter5/dram_cell.png")
```
SDRAM芯片一般采用行列地址线复用技术对SDRAM进行读写时需要先发送行地址打开一行再发送列地址读写需要访问的存储单元。为了提高访问的并发度SDRAM芯片一般包含多个Bank存储块这些Bank可以并行操作。图\@ref(fig:sdram-structure)显示了一个DDR2 SDRAM x8芯片的内部结构图。可以看到该SDRAM内部包含了8个Bank每个Bank对应一个存储阵列和一组感应放大器所有的Bank共用读锁存Read Latch和写FIFO。
对SDRAM进行写操作后由于必须等到写数据从IO引脚传送到对应Bank的感应放大器后才能进行后续的预充电操作针对相同Bank或者读操作针对所有Bank因此写操作会给后续的其他操作带来较大的延迟但连续的写操作却可以流水执行。为了降低写操作带来的开销内存控制器往往将多个写操作聚集在一起连续发送以分摊单个写操作的开销。
```{r sdram-structure, echo=FALSE, fig.align='center', fig.cap="SDRAM的功能结构图", out.width='100%'}
knitr::include_graphics("images/chapter5/sdram_structure.png")
```
影响SDRAM芯片读写速度的因素有两个行缓冲局部性Row Buffer Locality简称RBL和Bank级并行度Bank Level Parallelism简称BLP
1行缓冲局部性。如图\@ref(fig:sdram-structure)所示SDRAM芯片的一行数据在从存储体中读出后存储体中的值被破坏保存在对应的一组感应放大器中这组感应放大器也被称为行缓冲。如果下一个访存请求访问同一行的数据称为命中行缓冲可以直接从该感应放大器中读出而不需要重新访问存储体内部可以大大降低SDRAM的访问延迟。当然在行缓冲不命中的时候就需要首先将行缓冲中的数据写回存储体再将下一行读出到行缓冲中进行访问。由此对DRAM可以采用关行Close Page和开行Open Page两种策略。使用关行策略时每次读写完后先把行缓冲的内容写入存储体才能进行下一次读写每次读写的延迟是确定的。使用开行策略时每次读写完后不把行缓冲的内容写入存储体如果下一次读写时所读写的数据在行缓冲中称为行命中可以直接对行缓冲进行读写即可延迟最短如果下一次读写时所读写的数据不在行缓冲中则需要先将行缓冲中的数据写回对应的行再将新地址的数据读入行缓冲再进行读写延迟最长。因此如果内存访问的局部性好可以采用开行策略如果内存访问的局部性不好则可以采用关行策略。内存控制器可以通过对多个访存请求进行调度尽量把对同一行的访问组合在一起以增加内存访问的局部性。
2Bank级并行度。SDRAM芯片包含的多个Bank是相互独立的它们可以同时执行不同的操作比如对Bank 0激活的同时可以对Bank 1发出预充电操作因此访问不同Bank的多个操作可以并行执行。Bank级并行度可以降低冲突命令的等待时间容忍单个Bank访问的延迟。
利用内存的这两个特性,可以在内存控制器上对并发访问进行调度,尽可能降低读写访问的平均延迟,提高内存的有效带宽。内存控制器可以对十几甚至几十个访存请求进行调度,有效并发的访存请求数越多,可用于调度的空间就越大,可能得到的访存性能就更优。
### 输入/输出设备
输入/输出设备简称IO设备实现计算机与外部世界的信息交换。传统的IO设备有键盘、鼠标、打印机和显示器等新型的IO设备能进行语音、图像、影视的输入、输出和手写体文字输入并支持计算机之间通过网络进行通信。磁盘等辅助存储器在计算机中也当作IO设备来管理。
处理器通过读写IO设备控制器中的寄存器来访问及控制IO设备。高速IO设备可以在处理器安排下直接与主存储器成批交换数据称为直接存储器访问Directly Memory Access简称DMA。处理器可以通过查询设备控制器状态与IO设备进行同步也可以通过中断与IO设备进行同步。
下面以GPU、硬盘和闪存为例介绍典型的IO设备。
**1GPU**
GPUGraphics Processing Unit图形处理单元是与CPU联系最紧密的外设之一主要用来处理2D和3D的图形、图像和视频以支持基于视窗的操作系统、图形用户界面、视频游戏、可视化图像应用和视频播放等。
当我们在电脑上打开播放器观看电影时GPU负责将压缩后的视频信息解码为原始数据并通过显示控制器显示到屏幕上当我们拖动鼠标移动一个程序窗口时GPU负责计算移动过程中和移动后的图像内容当我们玩游戏时GPU负责计算并生成游戏画面。
GPU驱动提供OpenGL、DirectX等应用程序编程接口以方便图形编程。其中OpenGL是一个用于3D图形编程的开放标准DirectX是微软公司推出的一系列多媒体编程接口包括用于3D图形的Direct3D。通过这些应用程序接口软件人员可以很方便地实现功能强大的图形处理软件而不必关心底层的硬件细节。
GPU最早是作为一个独立的板卡出现的所以称为显卡。我们常说的独立显卡和集成显卡是指GPU是作为一个独立的芯片出现还是被集成在芯片组或处理器中。现代GPU内部包含了大量的计算单元可编程性越来越强除了用于图形图像处理外也越来越多地用作高性能计算的加速部件称为加速卡。
GPU与CPU之间存在大量的数据传输。CPU将需要显示的原始数据放在内存中让GPU通过DMA的方式读取数据经过解析和运算将结果写至显存中再由显示控制器读取显存中的数据并输出显示。将GPU与CPU集成至同一个处理器芯片时CPU与GPU内存一致性维护的开销和数据传递的延迟都会大幅降低。此时系统内存需要承担显存的任务访存压力也会大幅增加因为图形应用具有天生的并行性GPU可以轻松地耗尽有限的内存带宽。
GPU的作用是对图形API定义的流水线实现硬件加速主要包括以下几个阶段
- 顶点读入Vertex Fetch从内存或显存中取出顶点信息包括位置、颜色、纹理坐标、法向量等属性
- 顶点渲染Vertex Shader对每一个顶点进行坐标和各种属性的计算
- 图元装配Primitive Assembly将顶点组合成图元如点、线段、三角形等
- 光栅化Rasterization将矢量图形点阵化得到被图元覆盖的像素点并计算属性插值系数以及深度信息
- 像素渲染Fragment Shader进行属性插值计算每个像素的颜色
- 逐像素操作Per-Fragment Operation进行模板测试、深度测试、颜色混合和逻辑操作等并最终修改渲染缓冲区
在GPU中集成了专用的硬件电路来实现特定功能同时也集成了大量可编程的计算处理核心用于一些较为通用的功能实现。设计者根据每个功能使用的频率、方法以及性能要求选择不同的实现方式。大部分GPU中顶点读入、图元装配、光栅化及逐像素操作使用专用硬件电路实现而顶点渲染和像素渲染采用可编程的计算处理核心实现。由于现代GPU中集成了大量可编程的计算处理核心这种大规模并行的计算模式非常适合于科学计算应用所以在高性能计算机领域GPU常被用作计算加速单元配合CPU使用。
**2.硬盘**
计算机除了需要内存存放程序的中间数据外,还需要具有永久记忆功能的存储体来存放需要较长时间保存的信息。比如操作系统的内核代码、文件系统、应用程序和用户的文件数据等。该存储器除了容量必须足够大之外,价格还要足够便宜,同时速度还不能太慢。在计算机的发展历史上,磁性存储材料正好满足了以上要求。磁性材料具有断电记忆功能,可以长时间保存数据;磁性材料的存储密度高,可以搭建大容量存储系统;同时,磁性材料的成本很低。
人们目前使用的硬盘就是一种磁性存储介质。硬盘的构造原理为将磁性材料覆盖在圆形碟片或者说盘片通过一个读写头磁头悬浮在碟片表面来感知存储的数据。通过碟片的旋转和磁头的径向移动来读写碟片上任意位置的数据。碟片被划分为多个环形的轨道称为磁道Track来保存数据每个磁道又被分为多个等密度等密度数据的弧形扇区Sector作为存储的基本单元。磁盘的内部构造如图\@ref(fig:disk-structure)所示。硬盘在工作时,盘片是一直旋转的,当想要读取某个扇区的数据时,首先要将读写头移动到该扇区所在的磁道上,当想要读写的扇区旋转到读写头下时,读写头开始读写数据。
```{r disk-structure, echo=FALSE, fig.align='center', fig.cap="磁盘的内部结构图", out.width='50%'}
knitr::include_graphics("images/chapter5/disk_structure.png")
```
衡量磁盘性能的指标包括响应时间和吞吐量也就是延迟和带宽。磁头移动到目标磁道的时间称为寻道时间。当磁头移动到目标磁道后需要等待目标扇区旋转到磁头下面这段时间称为旋转时间。旋转时间与盘片的旋转速度有关磁盘的旋转速度用RPMRotation Per Minute转/分来表示我们常说的5400转、7200转就是指磁盘的旋转速度。扇区旋转到目标位置后传输这个扇区的数据同样需要时间称为传输时间。传输时间是扇区大小、旋转速度和磁道记录密度的函数。
磁盘是由磁盘控制器控制的。磁盘控制器控制磁头的移动、接触和分离以及磁盘和内存之间的数据传输。另外通过IO操作访问磁盘控制器又会引入新的时间。现在的磁盘内部一般都会包含一个数据缓冲读写磁盘时如果访问的数据正好在缓冲中命中则不需要访问磁盘扇区。还有当有多个命令读写磁盘时还需要考虑排队延迟。因此磁盘的访问速度计算起来相当复杂。一般来说磁盘的平均存取时间在几个毫秒的量级。
磁盘的密度一直在持续增加对于用户来说磁盘的容量一直在不断增大。磁盘的尺寸也经历了一个不断缩小的过程从最大的14英寸1英寸=0.0254米到最小的1.8英寸。目前市场上常见的磁盘尺寸包括应用于台式机的3.5英寸和应用于笔记本电脑的2.5英寸。
**3.闪存**
闪存Flash Storage是一种半导体存储器它和磁盘一样是非易失性的存储器但是它的访问延迟却只有磁盘的千分之一到百分之一而且它尺寸小、功耗低抗震性更好。常见的闪存有SD卡、U盘和SSD固态磁盘等。与磁盘相比闪存的每GB价格较高因此容量一般相对较小。目前闪存主要应用于移动设备中如移动电话、数码相机、MP3播放器主要原因在于它的体积较小。闪存在移动市场具有很强的应用需求工业界投入了大量财力推动闪存技术的发展。随着技术的发展闪存的价格在快速下降容量在快速增加因此SSD固态硬盘技术获得了快速发展。SSD固态硬盘是使用闪存构建的大容量存储设备它模拟硬盘接口可以直接通过硬盘的SATA总线与计算机相连。
最早出现的闪存被称为NOR型闪存因为它的存储单元与一个标准的或非门很像。NAND型闪存采用另一种技术它的存储密度更高每GB的成本更低因此NAND型闪存适合构建大容量的存储设备。前面所列的SD卡、U盘和SSD固态硬盘一般都是用NAND型闪存构建的。
使用闪存技术构建的永久存储器存在一个问题即闪存的存储单元随着擦写次数的增多存在损坏的风险。为了解决这个问题大多数NAND型闪存产品内部的控制器采用地址块重映射的方式来分布写操作目的是将写次数多的地址转移到写次数少的块中。该技术被称为磨损均衡Wear Leveling。闪存的平均擦写次数在10万次左右。这样通过磨损均衡技术移动电话、数码相机、MP3播放器等消费类产品在使用周期内就不太可能达到闪存的写次数限制。闪存产品内部的控制器还能屏蔽制造过程中损坏的块从而提高产品的良率。
## 计算机系统硬件结构发展
前面章节从冯·诺依曼结构出发,介绍了现代计算机的理论结构及其组成部分。随着应用需求的变化和工艺水平的不断提升,冯·诺依曼结构中的控制器和运算器逐渐演变为计算机系统中的中央处理器部分,而输入、输出设备统一通过北桥和南桥与中央处理器连接,中央处理器中的图形处理功能则从中央处理器中分化出来形成专用的图形处理器。因此,现代计算机系统的硬件结构主要包括了中央处理器、图形处理器、北桥及南桥等部分。
**中央处理器**Central Processing Unit简称CPU主要包含控制器和运算器在发展的过程中不断与其他部分融合。传统意义上的中央处理器在处理器芯片中更多地体现为处理器核现代的处理器芯片上往往集成多个处理器核。
**图形处理器**Graphic Processing Unit简称GPU是一种面向2D和3D图形、视频、可视化计算和显示优化的处理器。作为人机交互的重要界面GPU在计算机体系结构发展的过程中担任了越来越重要的角色。除了对图形处理本身之外还开始担负科学计算加速器的任务。
**北桥**North Bridge是离CPU最近的芯片主要负责控制显卡、内存与CPU之间的数据交换向上连接处理器向下连接南桥。
**南桥**South Bridge主要负责硬盘、键盘以及各种对带宽要求较低的IO接口与内存、CPU之间的数据交换。
### CPU-GPU-北桥-南桥四片结构
现代计算机的一种早期结构是CPU-GPU-北桥-南桥结构。在该结构中计算机系统包含四个主要芯片其中CPU处理器芯片、北桥芯片和南桥芯片一般是直接以芯片的形式安装或焊接在计算机主板上而GPU则以显卡的形式安装在计算机主板的插槽上。
在CPU-GPU-北桥-南桥四片结构中计算机的各个部件根据速度快慢以及与处理器交换数据的频繁程度被安排在北桥和南桥中。CPU通过处理器总线也称系统总线和北桥直接相连北桥再通过南北桥总线和南桥相连GPU一般以显卡的形式连接北桥。内存控制器集成在北桥芯片中硬盘接口、USB接口、网络接口、音频接口以及鼠标、键盘等接口放在南桥芯片中。此外在北桥上还会提供各种扩展接口用于其他功能卡的连接。采用该结构的微机系统如图\@ref(fig:structure-4part)所示。
```{r structure-4part, echo=FALSE, fig.align='center', fig.cap="CPU-GPU-北桥-南桥结构", out.width='50%'}
knitr::include_graphics("images/chapter5/structure_4part.png")
```
与英特尔奔腾处理器搭配的430HX芯片组就采用了这样的四片结构。其北桥芯片使用82439HX南桥芯片采用82371SB通过PCI总线扩展外接显卡与处理器组成四片结构作为计算机系统的主要部分。
### CPU-北桥-南桥三片结构
现代计算机的一种典型结构是CPU-北桥-南桥结构。在该结构中系统包含三个主要芯片分别为CPU芯片、北桥芯片和南桥芯片。三片结构与四片结构最大的区别是前者GPU功能被集成到北桥即一般所说的集成显卡。
在CPU-北桥-南桥三片结构中CPU通过处理器总线和北桥直接相连北桥再通过南北桥总线和南桥相连。内存控制器、显示功能以及高速IO接口如PCIE等集成在北桥芯片中硬盘接口、USB接口、网络接口、音频接口以及鼠标、键盘等接口部件放在南桥芯片中。随着计算机技术的发展更多的高速接口被引入计算机体系结构中在北桥上集成的IO接口的速率也不断提升。
采用该结构的微机系统如图\@ref(fig:structure-3part)所示。
```{r structure-3part, echo=FALSE, fig.align='center', fig.cap="CPU -北桥 -南桥结构", out.width='50%'}
knitr::include_graphics("images/chapter5/structure_3part.png")
```
英特尔845G芯片组就采用类似的三片结构。其北桥芯片使用82845G集成显示接口南桥芯片采用82801DB与处理器组成三片结构作为计算机系统的主要部分。
### CPU-弱北桥-南桥三片结构
随着工艺和设计水平的不断提高,芯片的集成度不断提高,单一芯片中能够实现的功能越来越复杂。内存接口的带宽需求超过了处理器与北桥之间连接的处理器总线接口,导致内存的实际访问性能受限于处理器总线的性能。而伴随着处理器核计算性能的大幅提升,存储器的性能提升却显得幅度较小,这两者的差异导致计算机系统性能受到存储器系统发展的制约,这就是存储墙问题。
因此对计算机系统性能影响显著的内存控制器开始被集成到CPU芯片中从而大幅降低了内存访问延迟提升了内存访问带宽这在一定程度上缓解了存储墙问题。
于是北桥的功能被弱化主要集成了GPU、显示接口、高速IO接口例如PCIE接口等
采用该结构的微机系统如图\@ref(fig:structure-3part-weaknb)所示。
```{r structure-3part-weaknb, echo=FALSE, fig.align='center', fig.cap="CPU -弱北桥 -南桥结构", out.width='50%'}
knitr::include_graphics("images/chapter5/structure_3part_weaknb.png")
```
相比英特尔AMD的处理器最早将内存控制器集成到处理器芯片中780E芯片组就采用上述三片结构北桥芯片使用RS780E集成HD3200 GPU南桥芯片使用SB710与处理器组成三片结构作为计算机系统的主要部分。
### CPU-南桥两片结构
在计算机系统不断发展的过程中,图形处理器性能也在飞速发展,其在系统中的作用也不断被开发出来。除了图形加速以外,对于一些科学计算类的应用,或者是一些特定的算法加速程序,图形处理器发挥着越来越大的作用,成为特定的运算加速器,其与中央处理器之间的数据共享也越来越频繁,联系越来越密切。
随着芯片集成度的进一步提高图形处理器也开始被集成到CPU芯片中于是北桥存在的必要性就进一步降低开始和南桥合二为一形成CPU-南桥结构,如图\@ref(fig:structure-2part)所示。
在这个结构中CPU芯片集成处理器核、内存控制器和GPU等主要部件对外提供显示接口、内存接口等并通过处理器总线和南桥相连。南桥芯片则包含硬盘、USB、网络控制器以及PCIE/PCI、LPC等总线接口。由于GPU和CPU都需要大量访问内存会带来一些访存冲突而且相对来说GPU对于实时性的要求更高即访存优先级会更高一些这在一定程度上会影响CPU的性能。实际上处理器中集成的GPU性能相比独立显卡中的GPU性能会稍弱。
当然也有一些两片结构是将GPU集成在南桥芯片中。这样在南桥上可以实现独立的显存供GPU使用这在某些条件下更有利于GPU性能的发挥且CPU升级时带来的开销会更小。
```{r structure-2part, echo=FALSE, fig.align='center', fig.cap="CPU - 南桥结构", out.width='50%'}
knitr::include_graphics("images/chapter5/structure_2part.png")
```
### SoC单片结构
片上系统System on Chip简称SoC是一种单片计算机系统解决方案它在单个芯片上集成了处理器、内存控制器、GPU以及硬盘、USB、网络等IO接口使得用户搭建计算机系统时只需要使用单个主要芯片即可如图\@ref(fig:soc)所示。
```{r soc, echo=FALSE, fig.align='center', fig.cap="SOC单片结构", out.width='50%'}
knitr::include_graphics("images/chapter5/soc.png")
```
目前SoC主要应用于移动处理器和工业控制领域相比上述几种多片结构单片SoC结构的集成度更高功耗控制方法更加灵活有利于系统的小型化和低功耗设计。但也因为全系统都在一个芯片上实现导致系统的扩展性没有多片结构好升级的开销也更大。随着技术的发展封装基板上的互连技术不断发展和成熟。越来越多的处理器利用多片封装技术在单个芯片上集成多个硅片以扩展芯片的计算能力或IO能力。例如AMD Ryzen系列处理器通过在封装上集成多个处理器硅片和IO硅片以提供针对不同应用领域的计算能力。龙芯3C5000L处理器则通过在封装上集成4个4核龙芯3A5000硅片来实现单片16核结构。
目前主流商用处理器中面向中高端领域的处理器普遍采用两片结构而面向中低端及嵌入式领域的处理器普遍采用单片结构。SoC单片结构最常见的是在手机等移动设备中。
## 处理器和IO设备间的通信
前面介绍了组成计算机系统的各个部分,在冯·诺依曼结构中,处理器(准确地说是内部的控制器)处于中心位置,需要控制其他各个部件的运行。
对存储器的控制是通过读写指令来完成的。存储器是存储单元阵列,对某个地址的读写不会影响其他存储单元。
而IO设备大都是具有特定功能的部件不能当作简单的存储阵列来处理。由于IO设备的底层控制相当复杂它们一般都是由一个设备控制器进行控制。设备控制器会提供一组寄存器接口寄存器的内容变化会引起设备控制器执行一系列复杂的动作。设备控制器的接口寄存器也被称为IO寄存器。处理器通过读写IO寄存器来访问设备。写入这些寄存器的数据会被设备控制器解析成命令因此有些情况下将处理器对IO寄存器的访问称为命令字。处理器对内存和IO的访问模式有所不同对访问的延迟和带宽需求也有较大差异。现代计算机系统的程序和数据都存放在内存中内存访问性能直接影响处理器流水线的执行效率也正是因为这样才导致了各个Cache层次的出现。对处理器的内存访问来说要求是高带宽和低延迟。IO设备一般用于外部交互而IO操作一般会要求顺序的访问控制从而导致执行效率低下访问带宽低延迟高只能通过IO的DMA操作来提升性能。IO的DMA操作也是访问内存因为DMA访存模式一般是大块的连续数据读写所以对带宽的需求远高于对延迟的需求。
### IO寄存器寻址
为了访问IO寄存器处理器必须能够寻址这些寄存器。IO寄存器的寻址方式有两种内存映射IO和特殊IO指令。
内存映射IO是把IO寄存器的地址映射到内存地址空间中这些寄存器和内存存储单元被统一编址。读写IO地址和读写内存地址使用相同的指令来执行。处理器需要通过它所处的状态来限制应用程序可以访问的地址空间使其不能直接访问IO地址空间从而保证应用程序不能直接操作IO设备。与内存映射IO不同特殊IO指令使用专用指令来执行IO操作。因此IO地址空间可以和内存地址空间重叠但实际指向不同的位置。操作系统可以通过禁止应用程序执行IO指令的方式来阻止应用程序直接访问IO设备。MIPS或LoongArch结构并没有特殊IO指令通过普通的访存指令访问特定的内存地址空间进行IO访问。而X86结构使用专门的IO指令来执行IO操作。
### 处理器和IO设备之间的同步
处理器和IO设备之间需要协同工作通过一系列软件程序来共同发挥设备功能。处理器和IO设备之间的同步有两种方式查询和中断。
处理器通过向IO寄存器写入命令字来控制IO设备。大部分的控制操作不是通过一次寄存器写入就能完成的处理器一般需要对IO寄存器进行多次访问才能完成一次任务。绝大多数设备的IO寄存器不是无条件写入的处理器在写入命令字之前先要获取设备的当前状态只有当设备进入特定的状态后处理器才能执行特定的操作这些特定的软件操作流程是在驱动程序中实现的。比如对于一台打印机打印机控制器会提供两个寄存器数据寄存器和状态寄存器。数据寄存器用来存放当前需要打印的数据状态寄存器用来指示打印机的状态它包含两个基本位完成位和错误位。完成位表示上一个字符打印完毕可以打印下一个字符错误位用来在打印机出现异常时指示出错的状态比如卡纸或者缺纸。处理器在打印一串数据时首先把数据写入数据寄存器然后不断读取状态寄存器的值当读出的完成位等于1时才能把下一个字符写入数据寄存器。同时处理器还需要检查错误位的值当发生错误时去执行对应的错误处理程序。
前面描述的打印过程就是查询方式的一个例子。当使用查询方式时处理器向IO设备发出访问请求后需要不断读取IO设备的状态寄存器所以查询方式也被称为轮询。由于IO设备的速度一般都较慢使用查询方式会浪费处理器的指令周期。而且执行轮询的处理器无法同时执行其他工作造成了性能的浪费。
为了解决查询方式效率较低的问题中断方式被引入计算机系统。在中断方式下处理器不需要轮询状态寄存器的值而是在等待设备完成某个操作时转去执行其他进程。当设备完成某个操作后自行产生一个中断信号来中断处理器的执行。处理器被中断后再去读取设备的状态寄存器。中断方式将处理器从等待IO中解放了出来大大提高了处理器的利用率因此现代计算机的绝大部分IO设备都支持中断方式。
中断本质上是IO设备对处理器发出的一个信号让处理器知道此时有数据传输需要或者已经发生数据传输。CPU收到中断信号后会暂停当前CPU的执行进程转去执行某个特定的程序。中断的一般过程为
1中断源发出中断信号到中断控制器
2中断控制器产生中断请求给CPU
3CPU发出中断响应并读取中断类型码
4CPU根据中断类型码执行对应的中断服务程序
5CPU从中断服务程序返回中断结束。
中断源即中断的源头比如用户敲击一下键盘单击一下鼠标或者DMA的一次传输完成了对应的控制器会产生一个中断信号。中断信号可以是一根信号线也可以是一个消息包。这个中断信息会传送到中断控制器中。中断控制器是负责中断汇集、记录和转发的硬件逻辑。中断控制器一般都具有可编程功能因此被称为可编程中断控制器Programmable Interrupt Controller简称PIC。典型的中断控制器如Intel的8259A。8259A支持中断嵌套和中断优先级可以支持8个中断源并可以通过级联的方式进行扩展。
8259A内部包含3个寄存器中断请求寄存器(Interrupt Request Register简称IRR),用来存放当前的中断请求;中断在服务寄存器(In-Service Register简称ISR)用来存放当前CPU正在服务的中断请求中断Mask寄存器(Interrupt Mask Register简称IMR),用来存放中断屏蔽位。
当中断源产生中断信号后会将中断请求寄存器的某一位设置为1如果该位没有被屏蔽则产生一个中断信号比如中断线给处理器。处理器检测到该中断信号并跳转到固定的地址执行中断服务例程。在中断服务例程中处理器通过读取8259A获得中断向量号进而调用对应的中断服务程序。在中断服务程序返回之前要保证本次中断的中断信号被清除掉否则CPU从中断服务程序返回之后会被再次触发中断。8259A在中断响应时会自动将IRR的对应位复位。对于电平触发的中断中断服务程序一般会读写中断源的相关寄存器从而保证在中断返回之前中断源的中断信号被撤掉这样8259A的中断请求寄存器的对应位不会被再次置位。对于脉冲触发的中断则不需要对设备IO寄存器进行处理。
### 存储器和IO设备之间的数据传送
存储器和IO设备之间需要进行大量的数据传输。例如系统在启动时需要把操作系统代码从硬盘搬运到内存中计算机想要输出图形时需要把准备显示的数据从内存搬运到显示控制器中。
那么存储器和IO设备之间是如何进行数据交换的呢
早期存储器和IO设备之间的数据传送都是由处理器来完成的。由于存储器和IO设备之间没有直接的数据通路当需要从存储器中搬运数据到IO设备时处理器首先从存储器中读数据到通用寄存器中再从通用寄存器写数据到IO设备中当需要从IO设备搬运数据到存储器中时处理器要先从IO设备中读数据到通用寄存器再从通用寄存器写入内存。这种方式称为PIOProgramming Input/Output模式。
由于IO访问的访问延迟一般较大而且IO访问之间需要严格的顺序关系因而PIO方式的带宽较低。PIO模式存在两种同步方式查询方式和中断方式。虽然中断方式可以降低处理器查询的开销但当进行大量数据传输时PIO模式仍然需要占用大量的处理器时间。使用中断方式每传送一定的数据后都要进入一次中断在中断服务程序中真正用于数据传送的时间可能并不多大量的时间被用于断点保护、中断向量查询、现场恢复、中断返回等辅助性工作。对于一些数据传送速率较快的设备PIO方式可能会因为处理器搬运数据速度较慢而降低数据的传送速度因此PIO方式一般用于键盘、鼠标等低速设备。
在PIO方式中数据要经过处理器内部的通用寄存器进行中转。中转不仅影响处理器的执行也降低了数据传送的速率。如果在存储器和IO设备之间开辟一条数据通道专门用于数据传输就可以将处理器从数据搬运中解放出来。这种方式就是DMADirect Memory Access直接存储器访问方式。DMA方式在存储器和外设之间开辟直接的数据传送通道数据传送由专门的硬件来控制。控制DMA数据传送的硬件被称为DMA控制器。
使用DMA进行传输的一般过程为
1处理器为DMA请求预先分配一段地址空间。
2处理器设置DMA控制器参数。这些参数包括设备标识、数据传送的方向、内存中用于数据传送的源地址或目标地址、传输的字节数量等。
3DMA控制器进行数据传输。DMA控制器发起对内存和设备的读写操作控制数据传输。DMA传输相当于用IO设备直接续写内存。
4DMA控制器向处理器发出一个中断通知处理器数据传送的结果成功或者出错以及错误信息
5处理器完成本次DMA请求可以开始新的DMA请求。
DMA方式对于存在大量数据传输的高速设备是一个很好的选择硬盘、网络、显示等设备普遍都采用DMA方式。一个计算机系统中通常包含多个DMA控制器比如有特定设备专用的SATA接口DMA控制器、USB接口DMA控制器等也有通用的DMA控制器用于可编程的源地址与目标地址之间的数据传输。
DMA控制器的功能可以很简单也可以很复杂。例如DMA控制器可以仅仅支持对一段连续地址空间的读写也可以支持对多段地址空间的读写以及执行其他的IO操作。不同的IO设备的DMA行为各不相同因此现代的IO控制器大多会实现专用的DMA控制器用于自身的数据传输。
表\@ref(tab:pio-vs-dma)举例说明了PIO和DMA两种数据传输方式的不同。
```{r pio-vs-dma, echo = FALSE, message=FALSE, tab.cap='PIO和DMA两种数据传输方式'}
autonum <- run_autonum(seq_id = "tab", bkm = "pio-vs-dma", bkm_all = TRUE)
readr::read_csv('./materials/chapter5/pio_vs_dma.csv') %>%
flextable() %>%
set_caption(caption="PIO和DMA两种数据传输方式", autonum = autonum) %>%
width(j=1, width=2.5) %>%
width(j=2, width=3.5) %>%
theme_box()
```
上面两个例子中可以看到PIO方式和DMA方式处理的流程一致区别在于首先键盘的数据是被记录在IO设备本身的而网卡的数据则直接由网卡写入内存之中其次CPU处理时对键盘是直接从IO寄存器读数据而对网卡则直接从内存读数据。
看起来似乎差别不大。但需要考虑的是IO访问相比内存访问慢很多而且对于内存访问CPU可以通过Cache、预取等方式进行加速IO访问则缺少这种有效的优化方式。在上面的例子中如果网卡采用PIO的方式使用CPU对网卡的包一个字一个字地进行读访问效率将非常低下。而对于键盘来说一次输入仅仅只有8位数据而且相比处理器的处理速度键盘输入的速度相当低采用PIO的处理方式能够很简单地完成数据输入任务。
### 龙芯3A3000+7A1000桥片系统中的CPU、GPU、DC通信
下面以龙芯3A3000+7A1000桥片中CPU、GPU、DC间的同步与通信为例说明处理器与IO间的通信。如图\@ref(fig:loongson-3A3000)所示龙芯3A3000处理器和龙芯7A1000桥片通过HyperTransport总线相连7A1000桥片中集成GPU、DC显示控制器以及专供GPU和DC使用的显存控制器。CPU可以通过PIO方式读写GPU中的控制寄存器、DC中的控制寄存器以及显存GPU和DC可以通过DMA方式读写内存GPU和DC还可以读写显存。
```{r loongson-3A3000, echo=FALSE, fig.align='center', fig.cap="龙芯3A3000+7A1000两片方案", out.width='50%'}
knitr::include_graphics("images/chapter5/loongson_3A3000.png")
```
CPU或GPU周期性地把要显示的数据写入帧缓存Frame BufferDC根据帧缓存的内容进行显示。帧缓存可以分配在内存中GPU和DC通过DMA方式访问内存中的帧缓存在独立显存的情况下帧缓存分配在独立显存中CPU直接把要显示的数据写入帧缓存或者GPU通过DMA方式从内存中读取数据并把计算结果写入帧缓存DC直接读取帧缓存的内容进行显示。根据是否由GPU完成图形计算以及帧缓存是否分配在内存中常见的显示模式有以下四种。
模式一不使用GPUCPU与DC共享内存。不使用桥片上的显存而在内存中分配一个区域专供显示使用这个区域称之为帧缓存framebuffer。需要显示时CPU通过正常内存访问将需要显示的内容写入内存中的帧缓存然后通过PIO方式读写DC中的控制寄存器启动DMADC通过DMA操作读内存中的帧缓存并进行显示如图\@ref(fig:3A3000-display)a所示。
模式二不使用GPUDC使用独立显存。DC使用桥片上的显存这个区域称之为帧缓存。需要显示时CPU将需要显示的内容从内存读出再通过PIO方式写入独立显存上的帧缓存然后通过PIO操作读写DC中的控制寄存器启动DMADC读显存上的帧缓存并进行显示如图\@ref(fig:3A3000-display)b所示。
模式三CPU与GPU/DC共享内存。需要显示时CPU在内存中分配GPU使用的空间并将相关数据填入然后CPU通过PIO读写GPU中的控制寄存器启动DMA操作GPU通过DMA读内存并将计算结果通过DMA写入内存中的帧缓存CPU通过PIO方式读写DC中的控制寄存器启动DMADC通过DMA方式读内存中的帧缓存并完成显示如图\@ref(fig:3A3000-display)c所示。
模式四GPU/DC使用独立显存。需要显示时CPU在内存中分配GPU使用的空间并将相关数据填入然后CPU通过PIO读写GPU中的控制寄存器启动DMA操作GPU通过DMA读内存并将计算结果写入显存中的帧缓存DC读显存中的帧缓存并完成显示如图\@ref(fig:3A3000-display)d所示。
```{r 3A3000-display, echo=FALSE, fig.align='center', fig.cap="3A3000+7A1000的不同显示方式", out.width='100%'}
knitr::include_graphics("images/chapter5/3A3000_display.png")
```
## 本章小结
本章介绍了计算机系统的基本原理和硬件组成结构。冯·诺依曼结构将计算机分为控制器、运算器、存储器、输入设备和输出设备五大部分这一章重点介绍了冯·诺依曼结构组成部分的结构及各部分之间的关系尤其是CPU、内存、IO之间的相互关系。
## 习题
1. 分别说明图\@ref(fig:structure-4part) \@ref(fig:structure-2part)中四种结构中每个芯片包含冯·诺依曼结构五个部分的哪部分功能。
2. 查阅资料比较Skylake处理器和Zen处理器的运算器结构。
3. 说明ROB、保留站发射队列、重命名寄存器在指令流水线中的作用并查阅资料比较Skylake处理器和Zen处理器的ROB、发射队列、重命名寄存器项数。
4. 假设A处理器有两级Cache一级Cache大小为32KB命中率为95%命中延迟为1拍二级Cache大小为1MB命中率为80%命中延迟为30拍失效延迟为150拍。B处理器有三级Cache一级Cache大小为32KB命中率为95%命中延迟为1拍二级Cache大小为256KB命中率为75%命中延迟为20拍三级Cache大小为4MB命中率为80%命中延迟为50拍失效延迟为150拍。比较两款处理器的平均访问延迟。
5. 假设某内存访问行关闭、打开、读写各需要两拍在行缓存命中率为70%和30%的情况下采用open page模式还是close page模式性能更高
6. 简要说明处理器和IO设备之间的两种通信方式的通信过程。
7. 简要说明处理器和IO设备之间的两种同步方式的同步过程。
8. 在一个两片系统中CPU含内存控制器桥片含GPU、DC和显存简要说明在PPT翻页过程中CPU、GPU、DC、显存、内存之间的同步和通信过程。
9. 调查目前市场主流光盘、硬盘、SSD盘、内存的价格并计算每GB存储容量的价格。
\newpage

454
16-bus.Rmd Normal file
View File

@@ -0,0 +1,454 @@
# 计算机总线接口技术
通过上一章的学习我们知道一个完整的计算机系统包含多种组成部件。这些组成部件一般不是由单个公司独立生产的而是由不同的公司共同生产完成的每个公司往往只能生产这些部件中的一种或者少数几种。按照经济学原理分工是促进社会生产力发展的重要原因和方法。现代计算机的飞速发展也同样得益于社会化的分工。分工就要求人们相互协调共同遵循一定的规则规范。计算机的生产也是如此计算机内部包含的多个部件往往是由不同的公司生产的。为了让这些不同的部件组合在一起可以正常工作必须制定一套大家共同遵守的规格和协议这就是接口或者总线。按照中文的含义接口是指两个对象连接的部分而总线是指对象之间传输信息的通道。本文对接口和总线的概念不做语义上的区分因为使用某种接口必然需要使用与之相对应的总线而总线也必然离不开接口否则就无法使用。本文使用总线时也包含与之相对应的接口使用接口时也包含与之相对应的总线。比如提到USB时既包含USB总线也包含USB接口。
总线的应用和标准化降低了计算机设计和应用的复杂度。有了标准化的接口厂家生产出来的产品只需要接口符合规范就可以直接与其他厂家生产的产品配合使用而不必设计所有的硬件。比如希捷公司可以只负责生产硬盘金士顿公司可以只负责生产内存条。总线的标准化促进了计算机行业的分工合作也极大地促进了计算机产业的发展。同时标准化的总线也降低了计算机使用的成本提高了用户使用的方便性。如果不同厂家生产的产品接口规格都不一样那么用户使用起来就会非常不方便。例如在手机还没有发展到智能机的时代人们可能需要一个用来打电话的手机一个听音乐的MP3以及一个拍照的卡片相机。由于没有统一的标准这些电子产品的充电接口往往是不一样的。在外出或者出差的时候人们就需要携带各种各样的充电器非常不方便。
总线技术涉及计算机的很多方面,除了物理链路外,还会涉及体系结构方面的内容。总线是不断演进发展的,目前应用的总线大都是在前代总线的基础上改进优化而来的,而且还在不断地改进。
本章首先对总线的概念进行一个简单介绍,然后对当代计算机使用的总线进行简单分类,并按照一种分类原则分别介绍几种常用总线。通过本章的学习,读者可以对计算机的常见总线有一个基本的了解。
## 总线概述
总线的本质作用是完成数据交换。总线用于将两个或两个以上的部件连接起来使得它们之间可以进行数据交换或者说通信。总线含义很广它不仅仅是指用于数据交换的通道有时也包含了软件硬件架构。比如PCI总线、USB总线它们不仅仅是指主板上的某个接口还包含了与之相对应的整套硬件模型、软件架构。
总线的含义可以分为以下几个层级:
1机械层。接口的外形、尺寸、信号排列、连接线的长度范围等。
2电气层。信号描述、电源电压、电平标准、信号质量等。
3协议层。信号时序、握手规范、命令格式、出错处理等。
4架构层。硬件模型、软件架构等。
不同的总线包含的内容也有所不同,有的总线包含以上所有的层级,有的总线可能只包含部分层级。
## 总线分类
可以从多个角度对总线进行分类。
按照数据传递的方向总线可以分为单向总线和双向总线。单向总线是指数据只能从一端传递到另一端而不能反向传递。单向总线也称为单工总线。双向总线是指数据可以在两个方向上传递既可以从A端传递到B端也可以从B端传递到A端。双向总线也称为双工总线。双工总线又可分为半双工总线和全双工总线。半双工总线是指在一个时间段内数据只能从一个方向传送到另一个方向数据不能同时在两个方向传递。全双工总线是指数据可以同时在两个方向传递。全双工总线包含两组数据线分别用于两个方向的数据传输。
按照总线使用的信号类型总线可以分为并行总线和串行总线。并行总线包含多位传输线在同一时刻可以传输多位数据而串行总线只使用一位传输线同一时刻只传输一位数据。并行总线的优点在于相同频率下总线的带宽更大但正因为采用了同一时刻并行传输多位数据的方法必须保证多位数据在同一时刻到达。这样就会对总线的宽度和频率产生限制同时也对主板设计提出了更高的要求。与并行总线相反一般串行总线只使用一位传输线同一时刻只能传输一位数据而且使用编码的方式将时钟频率信息编码在传输的数据之中。因此串行总线的传输频率可以大大提升。PCI总线、DDR总线等都是传统的并行总线而USB、SATA、PCIE等都是串行总线。以串行总线传输方式为基础使用多条串行总线进行数据传输的方式正在被广泛采用。以PCIE协议为例PCIE的接口规范中可以使用x1、x4、x8、x16等不同宽度的接口其中x16就是采用16对串行总线进行数据传输。多位串行总线与并行总线的根本差别在于多位串行总线的每一个数据通道都是相对独立传输的它们独立进行编解码在接收端恢复数据之后再进行并行数据之间的对齐。而并行总线使用同一个时钟对所有的数据线进行同时采样因此对数据传输线之间的对齐有非常严格的要求。
按照总线在计算机系统中所处的物理位置,总线可以分为片上总线、内存总线、系统总线和设备总线。下面将按照这个划分,分别举例介绍每种总线。
## 片上总线
片上总线是指芯片片内互连使用的总线。芯片在设计时常常要分为多个功能模块这些功能模块之间的连接即采用片上互连总线。例如一个高性能通用处理器在设计时常常会划分为处理器核、共享高速缓存、内存控制器等多个模块而一个SoCSystem on Chip片上系统芯片所包含的模块就更多了。图\@ref(fig:jz-m200)是一个嵌入式SoC芯片的内部结构可以看到里面包含了很多功能模块这些模块之间的连接就需要用到片上互连总线。这些模块形成了IP(Intellectual Property)一家公司在设计芯片时常常需要集成其他公司的IP。这些IP的接口使用大家共同遵守的标准时才能方便使用。因此芯片的片上互连总线也形成了一定的标准。目前业界公开的主流片上互连总线是ARM公司的AMBA系列总线。
```{r jz-m200, echo=FALSE, fig.align='center', fig.cap="君正M200芯片的结构图", out.width='100%'}
knitr::include_graphics("images/chapter6/jz_m200.png")
```
AMBAAdvanced Microcontroller Bus Architecture高级微控制器总线架构系列总线包括AXI、AHB、ASB、APB等总线。下面对AMBA总线的一些特点进行概括说明这些总线的详细内容可以参阅相关总线协议。
**1.AXI总线**
AXIAdvanced eXtensible Interface高级可扩展接口总线是一种高性能、高带宽、低延迟的片上总线。它的地址/控制和数据总线是分离的支持不对齐的数据传输同时在突发传输中只需要发送首地址即可。它使用分离的读写数据通道并支持乱序访问。AXI是AMBA 3.0规范中引入的一个新的高性能协议目标是满足超高性能和复杂的片上系统SoC的设计需求。
AXI总线主设备的主要信号定义如表\@ref(tab:axi)所示。可以看到AXI总线主要分为5个独立的通道分别为写请求、写数据、写响应、读请求、读响应。每个通道采用握手协议独立传输。
```{r axi, echo = FALSE, message=FALSE, tab.cap='AXI总线主要信号定义'}
autonum <- run_autonum(seq_id = "tab", bkm = "axi", bkm_all = TRUE)
readr::read_csv('./materials/chapter6/axi.csv') %>%
flextable() %>%
set_caption(caption="AXI总线主要信号定义", autonum = autonum) %>%
theme_box() %>%
autofit()
```
AXI协议包括以下特点
1单向通道体系结构。信息流只以单方向传输符合片内设计的要求。
2支持多项数据交换。AXI协议支持的数据宽度很宽最大可到1024位。通过并行执行突发Burst操作极大地提高了数据吞吐能力可在更短的时间内完成任务。
3独立的地址和数据通道。地址和数据通道分开便于对每一个通道进行单独优化可以根据需要很容易地插入流水级有利于实现高时钟频率。
1 AXI架构
AXI协议是一个主从协议每套总线的主设备和从设备是固定好的。只有主设备才可以发起读写命令。一套主从总线包含五个通道写地址通道、写数据通道、写响应通道、读地址通道、读返回通道。读/写地址通道用来传送读写目标地址、数据宽度、传输长度和其他控制信息。写数据通道用来由主设备向从设备传送写数据AXI支持带掩码的写操作可以指定有效数据所在的字节。写响应通道用来传送写完成信息。读返回通道用来传送从设备读出的数据以及响应信息。
AXI协议的一次完整读写过程称为一个总线事务Transaction传输一个周期的数据称为一次传输Transfer。AXI协议允许地址控制信息在数据传输之前发生并且支持多个并发访问同时进行它还允许读写事务的乱序完成。图\@ref(fig:read-structure)和\@ref(fig:write-structure)分别说明了读写事务是如何通过读写通道进行的。
```{r read-structure, echo=FALSE, fig.align='center', fig.cap="读事务架构", out.width='50%'}
knitr::include_graphics("images/chapter6/read_structure.png")
```
```{r write-structure, echo=FALSE, fig.align='center', fig.cap="写事务架构", out.width='50%'}
knitr::include_graphics("images/chapter6/write_structure.png")
```
AXI使用双向握手协议每次传输都需要主从双方给出确认信号。数据的来源方设置有效Valid信号数据的接收方设置准备好Ready信号。只有当有效信号和准备好信号同时有效时数据才会传输。读请求通道和写数据通道还各包含一个结束Last信号来指示一次突发传输的最后一个传输周期。
2互连架构
在一个使用AXI总线的处理器系统中一般都会包含多个主设备和从设备。这些设备之间使用互连总线进行连接如图\@ref(fig:axi-interface)所示。在该互连结构中任意一个主设备都可以访问所有的从设备。比如主设备2可以访问从设备1、2、3、4。
```{r axi-interface, echo=FALSE, fig.align='center', fig.cap="AXI设备的接口和互连", out.width='50%'}
knitr::include_graphics("images/chapter6/axi.png")
```
为了减少互连结构的信号线个数AXI的互连结构可以共享地址和数据通道或者共享地址通道但使用多个数据通道。当需要连接的主从设备个数较多时为了减少互连结构的信号线个数AXI协议还可以很方便地支持多层次的互连结构。
3高频设计
AXI协议的每个传输通道都只是单向的信息传递并且AXI协议对多个通道之间的数据传输没有规定特定的顺序关系多个通道之间没有同步关系。因此设计者可以很容易地在通道中插入寄存器缓冲这对于高频设计是很重要的。
4基本事务
下面简要介绍AXI的读写事务。AXI协议的主要特点是使用VALID和READY握手机制进行传输。地址和数据信息都只在VALID和READY信号同时为高的情况下才进行传输。
```{r burst-read, echo=FALSE, fig.align='center', fig.cap="突发读事务", out.width='100%'}
knitr::include_graphics("images/chapter6/burst_read.png")
```
图\@ref(fig:burst-read)显示了一个突发读事务的传输其中请求由主设备发往从设备响应由从设备发往主设备。地址信息在T2传输后主设备从T4时刻开始给出读数据READY信号从设备保持读数据VALID信号为低直到读数据准备好后才在T6时刻将读数据VALID信号拉高主设备在T6时刻接收读数据。当所有读数据传输完成后在T13时刻从设备将RLAST信号拉高表示该周期是最后一个数据传输。
```{r overlapped-read, echo=FALSE, fig.align='center', fig.cap="重叠的读事务", out.width='100%'}
knitr::include_graphics("images/chapter6/overlapped_read.png")
```
图\@ref(fig:overlapped-read)显示了一个重叠的读事务。在T4时刻事务A的读数据还没有完成传输从设备就已经接收了读事务B的地址信息。重叠事务使得从设备可以在前面的数据没有传输完成时就开始处理后面的事务从而降低后面事务的完成时间。AXI总线上通过ID对不同的事务加以区别。同一个读事务的请求与响应中ARID与RID相同同一个写事务的请求与响应中AWID、WID与BID相同。
```{r write-transaction, echo=FALSE, fig.align='center', fig.cap="写事务", out.width='100%'}
knitr::include_graphics("images/chapter6/write_transaction.png")
```
图\@ref(fig:write-transaction)是一个写事务的示例。主从设备在T2时刻传输写地址信息接着主设备将写数据发送给从设备在T9时刻所有的写数据传输完毕从设备在T10时刻给出写完成响应信号。
5读写事务顺序
AXI协议支持读写事务乱序完成。每一个读写事务都有一个ID标签该标签通过AXI信号的ID域进行传输。同ID的读事务或者同ID的写事务必须按照接收的顺序按序完成不同ID的事务可以乱序完成。以图\@ref(fig:overlapped-read)为例图中事务A的请求发生在事务B的请求之前从设备响应时事务A的数据同样发生在事务B的数据之前这就是顺序完成。如果事务A与事务B使用了不同的ID那么从设备就可以先返回事务B的数据再返回事务A的数据。
6AXI协议的其他特点
AXI协议使用分离的读写地址通道读事务和写事务都包含一个独立的地址通道用来传输地址和其他控制信息。
AXI协议支持下列传输机制
1不同的突发传输类型。AXI支持回绕Wrapping、顺序Incrementing和固定Fix三种传输方式。回绕传输适合高速缓存行传输顺序传输适合较长的内存访问固定传输则适合对外设FIFO的访问。
2传输长度可变。AXI协议支持1到16甚至更多个传输周期。
3传输数据宽度可变。支持8\~1024位数据宽度。
4支持原子操作。
5支持安全和特权访问。
6支持错误报告。
7支持不对齐访问。
**2.AHB、ASB、APB总线**
AHB、ASB、APB总线是在AXI总线之前推出的系统总线本书只对它们进行简要总结详细内容可参阅相关协议文档。
AHBAdvanced High-performance Bus总线是高性能系统总线它的读写操作共用命令和响应通道具有突发传输、事务分割、流水线操作、单周期总线主设备切换、非三态实现以及宽数据总线等特点。AHB协议允许8\~1024位的数据总线宽度但推荐的数据宽度最小为32位最大为256位。
ASBAdvanced System Bus是第一代AMBA系统总线同AHB相比它支持的数据宽度要小一些典型数据宽度为8位、16位、32位。它的主要特征有流水线方式数据突发传送多总线主设备内部有三态实现。
APBAdvanced Peripheral Bus是本地二级总线Local Secondary Bus通过桥和AHB/ASB相连。它主要是为了满足不需要高性能流水线接口或不需要高带宽接口的设备间的互连。其主要优点是接口简单、易实现。
基于AMBA总线的计算机系统的结构如图\@ref(fig:ahb-apb)和图\@ref(fig:axi-interconnect)所示。
```{r ahb-apb, echo=FALSE, fig.align='center', fig.cap="使用AHB和APB连接的微控制器系统", out.width='50%'}
knitr::include_graphics("images/chapter6/ahb_apb.png")
```
```{r axi-interconnect, echo=FALSE, fig.align='center', fig.cap="使用AXI总线互连的通用高性能处理器", out.width='50%'}
knitr::include_graphics("images/chapter6/axi_interconnect.png")
```
片上互连总线的最大特点是高并行性。由于片内走线的距离短,线宽细,因此可以实现高并行性。片上互连总线的设计需要考虑总线的通用性、可扩展性、性能以及总线接口逻辑的设计简单性等方面。
## 内存总线
内存总线用于连接处理器和主存储器。
前面章节我们介绍了目前使用的主存储器——DRAM芯片以及内存条、内存控制器的一些概念。内存控制器和内存芯片或者说内存条的接口就是内存总线。内存总线规范是由JEDECJoint Electron Device Engineering Council组织制定的它包含了一般总线的三个层级机械层、电气层和协议层。
在机械层JEDEC规定了内存芯片的封装方式、封装大小和引脚排布内存条生产厂家可以据此设计内存条PCB板可以使用不同DRAM厂家的芯片。同时JEDEC也制定了内存条和计算机主板连接的规范也就是内存插槽规范规定了内存条的引脚个数、排布和内存条的长度、厚度、机械形式。这样不同厂家的内存条就可以在同一块主板上使用。图\@ref(fig:ddr3)是台式机使用的DDR3内存条和对应的内存插槽的图片。DDR3内存条使用双列直插式设计每列分布了120个引脚共240个引脚。中间的缺口不是位于内存条的正中心目的是为了防止将内存条插反。图\@ref(fig:ddr2)是台式机使用的DDR2内存条的图片。DDR3内存条和DDR2内存条的长度相同但内存条上的缺口位置是不同的可以防止DDR2和DDR3内存条之间误插。
```{r ddr3, echo=FALSE, fig.align='center', fig.cap="台式机的DDR3内存条和内存插槽", out.width='100%'}
knitr::include_graphics("images/chapter6/ddr3.png")
```
```{r ddr2, echo=FALSE, fig.align='center', fig.cap="台式机的DDR2内存条", out.width='100%'}
knitr::include_graphics("images/chapter6/ddr2.png")
```
在电气层JEDEC组织规定了DRAM芯片的电气特性。例如DDR2内存使用1.8V电压而DDR3内存使用1.5V电压。另外,规范还规定输入电压高低电平的标准、信号斜率、时钟抖动的范围等信号电气特性。
在协议层JEDEC组织规定了DRAM芯片的操作时序。协议规定了DRAM芯片的上电和初始化过程、DRAM工作的几种状态、状态之间的转换以及低功耗控制等内容。比如DRAM初始化完成后进入空闲态通过激活Activate命令进入“打开一行”的激活态只有在激活态才可以读写DRAM的数据单纯的读写操作后DRAM仍会处于激活态等待下一次读写。如果想要读写其他行需要首先发送预充Precharge命令将DRAM转回空闲态然后再发送激活命令。这些命令不是在任意时刻都可以发送的需要满足协议规定的时序要求。图\@ref(fig:ddr2-state)给出了DDR2内存的状态转换图。
```{r ddr2-state, echo=FALSE, fig.align='center', fig.cap="DDR2内存各状态转换图", out.width='100%'}
knitr::include_graphics("images/chapter6/ddr2_state.png")
```
```{r ddr3-udimm, echo = FALSE, message=FALSE, tab.cap='双面DDR3 UDIMM内存条的接口信号列表'}
autonum <- run_autonum(seq_id = "tab", bkm = "ddr3-udimm", bkm_all = TRUE)
readr::read_csv('./materials/chapter6/ddr3_udimm.csv') %>%
flextable() %>%
delete_part(part='header') %>%
add_header_row(values=c('引脚名称', '描述', '引脚名称', '描述')) %>%
set_caption(caption="双面DDR3 UDIMM内存条的接口信号列表", autonum = autonum) %>%
theme_box() %>%
autofit()
```
DDR3内存条的接口信号见表\@ref(tab:ddr3-udimm)。内存条将多个DDR3 SDRAM存储芯片简单地并列在一起因此表中所列的信号主要是DDR3 SDRAM的信号。此外表中还包含了一组I2C总线信号SCL、SDA和I2C地址信号SA0\~SA2用来支持内存条的软件识别。内存条将自身的一些设计信息包括SDRAM类型、SDRAM的速度等级、数据宽度、容量以及机械尺寸标准等信息保存在一个EEPROM中该EEPROM可以通过I2C总线访问称为SPDSerial Present Detect。计算机系统可以通过I2C总线来读取内存条的信息从而自动匹配合适的控制参数并获取正确的系统内存容量。组装电脑时用户可以选用不同容量、品牌的内存条而无须修改软件或主板就离不开SPD的作用。值得一提的是表中给出的信号是按照双面内存条带ECC功能列出来的如果只有单面或者不带ECC校验功能只需将相应的引脚位置悬空。
DRAM存储单元是按照Bank、行、列来组织的因此对DRAM的寻址是通过bank地址、行地址和列地址来进行的。此外计算机系统中可以将多组DRAM串接在一起不同组之间通过片选CS信号来区分。在计算机系统中程序的地址空间是线性的处理器发出的内存访问地址也是线性的由内存控制器负责将地址转换为对应于DRAM的片选、Bank地址、行地址、列地址。
DDR3 SDRAM读操作时序如图\@ref(fig:ddr3-read)所示。图中命令Command简称CMD由RAS_n、CAS_n和WE_n三个信号组成。当RAS_n为高电平CAS_n为低电平WE_n为高电平时表示一个读命令。该图中列地址信号延迟 (CL) 等于5个时钟周期读延迟 (RL)等于5个时钟周期突发长度Burst Length,BL等于8。控制器发出读命令后经过5个时钟周期SDRAM开始驱动DQS和DQ总线输出数据。DQ数据信号和DQS信号是边沿对齐的。在DQS的起始、DQ传输数据之前DQS信号会有一个时钟周期长度的低电平称为读前导Read Preamble。读前导的作用是给内存控制器提供一个缓冲时间以开启一个信号采样窗口将有用的读数据采集到内部寄存器同时又不会采集到数据线上的噪声数据。
```{r ddr3-read, echo=FALSE, fig.align='center', fig.cap="DDR3 SDRAM读时序", out.width='100%'}
knitr::include_graphics("images/chapter6/ddr3_read.png")
```
```{r ddr3-write, echo=FALSE, fig.align='center', fig.cap="DDR3 SDRAM写时序", out.width='100%'}
knitr::include_graphics("images/chapter6/ddr3_write.png")
```
DDR3 SDRAM写操作的协议如图\@ref(fig:ddr3-write)所示。当RAS_n为高电平CAS_n为高电平WE_n为低电平时表示一个写操作。读写操作命令的区别是WE_n信号的电平不同读操作时该信号为高写操作时该信号为低。写操作使用额外的数据掩码Data MaskDM信号来标识数据是否有效。当DM为高时对应时钟沿的数据并不写入SDRAM当DM为低时对应时钟沿的数据才写入SDRAM。DM信号与DQ信号同步。在写操作时DQS信号和DQ信号是由内存控制器驱动的。同样在DQS的起始、DQ传输数据之前DQS信号也存在一个写前导Write Preamble。DDR3 SDRAM的写前导为一个周期的时钟信号DDR2 SDRAM的写前导为半个时钟周期的低电平信号。
前面讲过SDRAM的基本操作包括激活Activate、读写Read/Write和预充电Precharge。当SDRAM接收到一个操作后它需要在固定的时钟周期之后开始进行相应的动作并且这些动作是需要经过一定的时间才能完成的。因此对DRAM不同操作命令之间是有时间限制的。例如对于DDR3-1600内存来说当软件访问的两个地址正好位于内存的同一个Bank的不同行时内存控制器需要首先针对第一个访问地址发出激活操作经过13.75ns的时间才可以发出读写操作。如果第一个访问是读操作则需要经过至少7.5ns此外还需满足tRASmin的要求这里进行简化说明的时间才可以发送预充电操作。预充电操作发送后需要经过13.75ns的时间才可以针对第二个访问的行地址发送新的激活操作然后经过13.75ns的时间发送读写操作。因此对SDRAM的同一个Bank的不同行进行读写存在较大的访问延迟。为了掩盖访问延迟SDRAM允许针对不同Bank的操作并发执行。上述访问过程如图\@ref(fig:sdram-timing)所示。
```{r sdram-timing, echo=FALSE, fig.align='center', fig.cap="SDRAM的访问时序图", out.width='100%'}
knitr::include_graphics("images/chapter6/sdram_timing.png")
```
提高内存总线访问效率的两个主要手段是充分利用行缓冲局部性和Bank级并行度。行缓冲局部性是说当两个访存命令命中SDRAM的同一行时两个命令可以连续快速发送Bank级并行度是说针对SDRAM的不同Bank的操作可以并发执行从而降低后一个操作的访存延迟。下面以一个简单的例子来说明对SDRAM的不同访问序列的延迟的差别。
假定处理器发出了三个访存读命令地址分别命中SDRAM的第0个Bank的第0行列地址为0、第0个Bank的第1行和第0个Bank的第0行与第一个命令的列地址不同假定列地址为1。如果我们不改变访问的顺序直接将这三个命令转换为对应SDRAM的操作发送给内存。则需要的时间如图\@ref(fig:command-before)所示。图中,<B0,R0>表示第0个Bank的第0行<B0,R1>表示第0个Bank的第1行。每一个读命令都会转换出对应于SDRAM的<激活,读数据,预充电>序列。假定使用的是DDR2-800E规格的内存它对应的时序参数为tRCD=15nstRP=15ns,tRASmin=45ns,tRC=60ns,tRL=15ns,tRTP=7.5ns,tCCD=10ns(4个时钟周期)。则读数据分别在第30nstRCD+tRL、90ns(tRC+tRCD+tRL)和150nstRC+tRC+tRCD+tRL返回给处理器。
假定我们改变命令发给内存的顺序我们将第3个命令放到第1个命令之后发送将第2个命令最后发送则得到的访存序列如图\@ref(fig:command-after)所示。在该图中针对第0个Bank第0行第1列的命令不需要发送预充电和激活操作而是在针对第0个Bank第0行第0列的命令之后直接发送。则处理器得到读数据的时间变为第30ns、第40ns和第90ns。相比上一种访存序列第3个访存命令的读数据的访存延迟降低了110ns40ns相比于150ns
```{r command-before, echo=FALSE, fig.align='center', fig.cap="调度前的命令序列", out.width='100%'}
knitr::include_graphics("images/chapter6/command_before.png")
```
```{r command-after, echo=FALSE, fig.align='center', fig.cap="调度后的命令序列", out.width='100%'}
knitr::include_graphics("images/chapter6/command_after.png")
```
对内存总线的控制是由内存控制器实现的。内存控制器负责管理内存条的初始化、读写、低功耗控制等操作。内存控制器接收处理器发出的读写命令将其转化为内存芯片可以识别的DRAM操作并负责处理时序相关问题最终返回数据对于读命令或者返回一个响应对于写命令给处理器。内存控制器一般还包括命令调度功能以提高内存总线的访问效率。对于处理器来说它只需要发送读写命令给内存控制器就可以了而不必关心内存的状态以及内存是如何被读写的。
## 系统总线
系统总线通常用于处理器与桥片的连接,同时也作为多处理器间的连接以构成多路系统。
英特尔处理器所广泛采用的QPIQuick Path Interconnect接口及在QPI之前的FSBFront Side Bus还有AMD处理器所广泛采用的HTHyperTransport接口都属于系统总线。
系统总线是处理器与其他芯片进行数据交换的主要通道系统总线的数据传输能力对计算机整体性能影响很大。如果没有足够带宽的系统总线计算机系统的外设访问速度会明显受限类似于显示、存储、网络等设备的交互都会受到影响。随着计算机系统技术的不断进步微处理器与其他芯片间的数据传输性能成为制约系统性能进一步提升的一个重要因素。为了提升片间传输性能系统总线渐渐由并行总线发展为高速串行总线。下面以HyperTransport总线为例介绍系统总线。
### HyperTransport总线
HyperTransport总线简称HT总线是AMD公司提出的一种高速系统总线用于连接微处理器与配套桥片以及多个处理器之间的互连。HT总线提出后先后发展了HT1.0、HT2.0、HT3.0等几代标准目前最新的标准为HT3.1。
图\@ref(fig:ht-two-chips)是采用HT总线连接处理器与桥片的结构示意图。
```{r ht-two-chips, echo=FALSE, fig.align='center', fig.cap="CPU-南桥两片结构", out.width='50%'}
knitr::include_graphics("images/chapter6/ht_two_chips.png")
```
与并行总线不同的是串行总线通常采用点对点传输形式体现在计算机体系结构上就是一组串行总线只能连接两个芯片。以龙芯3A2000/3A3000为例在四路互连系统中一共采用了7组HT互连总线其中6组用于四个处理器间的全相联连接1组用于处理器与桥片的连接如图\@ref(fig:loongson-4way)所示。而作为对比PCI总线则可以在同一组信号上连接多个不同的设备如图\@ref(fig:pci-interconnect)所示。
```{r loongson-4way, echo=FALSE, fig.align='center', fig.cap="龙芯3A2000/3A3000四路系统结构示意图", out.width='50%'}
knitr::include_graphics("images/chapter6/loongson_4way.png")
```
```{r pci-interconnect, echo=FALSE, fig.align='center', fig.cap="PCI总线设备连接", out.width='50%'}
knitr::include_graphics("images/chapter6/pci_interconnect.png")
```
HT总线的软件架构与PCI总线协议基本相同都采用配置空间、IO空间和Memory空间的划分通过对配置寄存器的设置采用正向译码的方向对设备空间进行访问。基于PCI总线设计的设备驱动程序能够直接使用在HT总线的设备上。
但在电气特性及信号定义上HT总线与PCI总线却大相径庭HT由两组定义类似但方向不同的信号组成。其主要信号定义如表\@ref(tab:ht-signals)所示。
```{r ht-signals, echo = FALSE, message=FALSE, tab.cap='HT总线主要信号定义'}
autonum <- run_autonum(seq_id = "tab", bkm = "ht-signals", bkm_all = TRUE)
readr::read_csv('./materials/chapter6/ht_signals.csv') %>%
flextable() %>%
set_caption(caption="HT总线主要信号定义", autonum = autonum) %>%
theme_box() %>%
autofit()
```
```{r ht-interconnect, echo=FALSE, fig.align='center', fig.cap="HT总线连接", out.width='50%'}
knitr::include_graphics("images/chapter6/ht_interconnect.png")
```
可以看到,图\@ref(fig:ht-interconnect)中两个芯片通过定义相同的信号进行相互传输。与上一节介绍的DDR内存总线所不同的是HT总线上用于数据传输的信号并非双向信号而是由两组方向相反的单向信号各自传输。这种传输方式即通常所说的全双工传输。发送和接收两个方向的传输可以同时进行互不干扰。而采用双向信号的总线例如DDR内存总线或者PCI总线只能进行半双工传输其发送和接收不能同时进行。而且在较高频率下发送和接收两种模式需要进行切换时为了保证其数据传输的完整性还需要在切换过程中增加专门的空闲周期这样更加影响了总线传输效率。
PCI接口信号定义如图\@ref(fig:pci-signals)所示。PCI总线上使用起始信号FRAME#及相应的准备好信号TRDY#、IRDY#、停止信号STOP#来进行总线的握手控制总线传输。与PCI总线不同HT总线信号定义看起来非常简单没有类似PCI总线的握手信号。
```{r pci-signals, echo=FALSE, fig.align='center', fig.cap="PCI总线信号定义", out.width='100%'}
knitr::include_graphics("images/chapter6/pci_signals.png")
```
实际上HT总线的读写请求是通过包的形式传输的将预先定义好的读写包通过几个连续的时钟周期进行发送再由接收端进行解析处理。同时HT总线采用了流控机制替代了握手机制。
流控机制的原理并不复杂。简单来说在总线初始化完成后总线双方的发送端将自身的接收端能够接收的请求或响应数通过一种专用的流控包通知对方。总线双方各自维护一组计数器用于记录该信息。每需要发出请求或响应时先检查对应的计数器是否为0。如果为0表示另一方无法再接收这种请求或响应发送方需要等待如果不为0则将对应的计数器值减1再发出请求或响应。而接收端每处理完一个请求或响应后会再通过流控包通知对方对方根据这个信息来增加内部对应的计数器。正是通过这种方式有效消除了总线上的握手提升了总线传输的频率和效率。
这种传输模式对提升总线频率很有好处。PCI总线发展到PCI-X时频率能够达到133MHz宽度最高为64位总线峰值带宽为1064MB/s。而HT总线发展到3.1版本时频率能够达到3.2GHz使用双沿传输数据速率达到6.4Gb/s以常见的16位总线来说单向峰值带宽为12.8GB/s双向峰值带宽为25.6GB/s。即使去除地址命令传输周期其有效带宽也比PCI总线提升了一个数量级以上。
### HT包格式
HT总线的传输以包为单位。按照传输的类型首先分为控制包和数据包两种。控制包和数据包使用CTL信号区分当CTL信号为高时表示正在传输一个控制包当CTL信号为低时表示正在传输一个数据包。数据包依附于最近的一个带数据的控制包。
控制包根据传输的内容,再分为三种不同的包格式,分别为信息包、请求包和响应包。
信息包的作用是为互连的两端传递底层信息,本身不需要流控。这意味着对于信息包,无论何时都是可以被接收并处理的。流控信息就是一种典型的信息包。信息包的格式如表\@ref(tab:ht-packet-format)所示。
其中,“命令”域用于区分不同的包。对不同的命令,包的其他位置表示的内容之间有所不同。
HT也是采用DDR传输即双倍数据率传输在时钟的上升、下降沿各传一组数据。每种包大小都是4字节的倍数。图\@ref(fig:ht-transfer)是在总线上传输的时序示意图以8位的CAD总线为为例。在CTL为高电平的时候表示传输的是控制包而CTL为低时表示传输的是数据包。图中CAD信息上的数字对应包格式表中的具体拍数。
```{r ht-packet-format, echo = FALSE, message=FALSE, tab.cap='HT信息包格式'}
autonum <- run_autonum(seq_id = "tab", bkm = "ht-packet-format", bkm_all = TRUE)
readr::read_csv('./materials/chapter6/ht_packet_format.csv') %>%
flextable() %>%
set_caption(caption="HT信息包格式", autonum = autonum) %>%
width(j=1, width=1) %>%
width(j=2:9, width=0.6) %>%
merge_h() %>%
align(align='center',part='all') %>%
bg(i=1, j=2:3, bg='grey') %>%
bg(i=2:4,j=2:9, bg='grey') %>%
border_outer(fp_border()) %>%
border_inner(fp_border())
#theme_box() --> this will cancel alignment
```
```{r ht-transfer, echo=FALSE, fig.align='center', fig.cap="HT总线传输示意图", out.width='100%'}
knitr::include_graphics("images/chapter6/ht_transfer.png")
```
表\@ref(tab:ht-request)为请求包的格式。因为需要传输地址信息请求包最少需要8字节。当使用64位地址时请求包可以扩展至12字节。大部分请求包地址的[7:2]是存放在第3拍。因为数据的最小单位为4字节地址的[1:0]不需要进行传输。当传输的数据少于4字节时利用数据包的屏蔽位进行处理。
```{r ht-request, echo = FALSE, message=FALSE, tab.cap='HT请求包格式'}
autonum <- run_autonum(seq_id = "tab", bkm = "ht-request", bkm_all = TRUE)
readr::read_csv('./materials/chapter6/ht_request.csv') %>%
flextable() %>%
set_caption(caption="HT请求包格式", autonum = autonum) %>%
width(j=1, width=1) %>%
width(j=2:9, width=0.6) %>%
merge_h() %>%
align(align='center',part='all') %>%
bg(i=4:5,j=2:9, bg='grey') %>%
border_outer(fp_border()) %>%
border_inner(fp_border())
```
请求包主要是读请求和写请求。其中读请求不需要数据,而写请求需要跟随数据包。
表\@ref(tab:ht-response)是响应包的格式。响应包大小为4字节。与请求包类似写响应包不需要数据而读响应包需要跟随数据包。
```{r ht-response, echo = FALSE, message=FALSE, tab.cap='HT响应包格式'}
autonum <- run_autonum(seq_id = "tab", bkm = "ht-response", bkm_all = TRUE)
readr::read_csv('./materials/chapter6/ht_response.csv') %>%
flextable() %>%
set_caption(caption="HT响应包格式", autonum = autonum) %>%
width(j=1, width=1) %>%
width(j=2:9, width=0.6) %>%
merge_h() %>%
align(align='center',part='all') %>%
bg(i=1,j=2:3, bg='grey') %>%
bg(i=3,j=5:9, bg='grey') %>%
bg(i=4,j=8:9, bg='grey') %>%
border_outer(fp_border()) %>%
border_inner(fp_border())
```
### 设备总线
设备总线用于计算机系统中与IO设备的连接。
PCIPeripheral Component Interconnect总线是一种对计算机体系结构连接影响深远并广泛应用的设备总线。PCIEPCI Express可以被看作PCI总线的升级版本兼容PCI软件架构。PCIE总线被广泛地用作连接设备的通用总线在现有计算机系统中已经基本取代了PCI的位置。PCIE接口在系统中的位置如图\@ref(fig:pcie-location)所示一般与SATA、USB、显示等设备接口位于同样层次用于扩展外部设备。
```{r pcie-location, echo=FALSE, fig.align='center', fig.cap="PCIE接口位置示意图", out.width='50%'}
knitr::include_graphics("images/chapter6/pcie_location.png")
```
### PCIE总线
与HT类似PCIE总线也是串行总线。PCIE与设备进行连接的时候同样采用点对点的方式一组PCIE接口只能连接一个设备。为了连接多个设备就需要实现多个接口如图\@ref(fig:pcie-interconnect)所示。
与HT又有所不同两者在信号定义和接收发送方法上有很大差别。上一节介绍过HT总线主要包括三种信号分别为CLK、CTL、CAD其中CLK作为随路时钟使用用于传递总线的频率信息并用作数据恢复。
```{r pcie-interconnect, echo=FALSE, fig.align='center', fig.cap="PCIE接口连接示意图", out.width='100%'}
knitr::include_graphics("images/chapter6/pcie_interconnect.png")
```
PCIE的总线信号如表\@ref(tab:pcie-signals)所示。
```{r pcie-signals, echo = FALSE, message=FALSE, tab.cap='PCIE总线主要信号定义'}
autonum <- run_autonum(seq_id = "tab", bkm = "pcie-signals", bkm_all = TRUE)
readr::read_csv('./materials/chapter6/pcie_signals.csv') %>%
flextable() %>%
set_caption(caption="PCIE总线主要信号定义", autonum = autonum) %>%
theme_box() %>%
autofit()
```
可以看到PCIE接口上只有用于数据传输的信号。HT接口上CAD[n:0]通常是以8位为单位共用一组时钟信号总线宽度可以为8位、16位或32位。而PCIE接口上的各个TX信号之间相互独立最小单位为1位称之为通道Lane。常见的总线宽度有1位、4位、8位及16位。如千兆网卡、SATA扩展卡、USB扩展卡等总线宽度大多为1位而显卡、RAID卡等总线宽度通常为16位。
PCIE在进行传输时仅仅发送数据信号而没有发送时钟信号。在接收端通过总线初始化时约定好的数据序列恢复出与发送端同步的时钟并使用该时钟对接收到的数据信号进行采样得到原始数据。
### PCIE包格式
PCIE总线的传输同样以包事务层包Transaction Level Packet简称TLP为单位其包格式如图\@ref(fig:pcie-packet)所示。PCIE包主要分为TLP首部与数据负载两部分其作用与HT包类似可以对应到HT包中的控制包与数据包。PCIE包同样是以4字节为单位增长。
```{r pcie-packet, echo=FALSE, fig.align='center', fig.cap="PCIE总线包格式", out.width='100%'}
knitr::include_graphics("images/chapter6/pcie_packet.png")
```
对于具体包格式的定义PCIE与HT各有不同。尤其是PCIE包在协议上最多可以一次传输4KB的数据而HT包最多一次传输64字节。PCIE的具体包格式定义在此不再展开感兴趣的读者可以参考PCIE相关协议。
此外PCIE同样采用了流控机制来消除总线握手。
PCIE总线被广泛地用作连接设备的设备总线而HT总线则作为系统总线用于处理器与桥片之间的连接及多处理器间的互连。
这些使用上的差异是由总线接口特性所决定的。与HT总线不同的是PCIE接口在x1时只有一对发送信号线和一对接收信号线没有随之发送的时钟和控制信号。PCIE接口通过总线传输编码将时钟信息从总线上重新提取并恢复数据。PCIE总线的传输相比HT总线来说开销更大带来延迟的增大及总线带宽利用率的降低。
PCIE总线可以由多个数据通道组成每个通道只有一对发送信号和一对接收信号因此传输时每个通道所使用的信号线更少而且不同的通道之间相关性小目前使用的PCIE卡最多为16个数据通道。对于物理连接来说PCIE接口相比HT接口实现更为简单被广泛地用作可扩展设备连接逐渐替代了PCI总线。
## 本章小结
本章简单介绍了计算机中的总线技术。总线技术的应用简化了计算机的设计使得人们可以专注于部件的开发促进了分工合作。计算机在发展过程中形成了各种各样的总线有些总线发展为行业标准有专门的组织和结构去制定规范有些总线虽然没有明文规定却也成为事实上的标准。这些总线有的已经逐渐消失有的还在不断演进。随着计算机产业的发展未来还会不断出现新的总线。计算机总线的发展趋势是内部化、串行化和统一化。随着集成电路行业器件集成度的不断提高越来越多的功能被集成到单个芯片中因此许多外部总线逐渐被内部化。串行总线由于占用的引脚个数少总线速度高因此逐渐替代并行总线成为主流。在市场竞争中由于马太效应不同设备的接口逐渐向少数几种总线标准集中特别是在消费电子领域USB接口逐渐成为IO设备的标准接口总线接口越来越统一化。
## 习题
1. 找一台电脑,打开机箱,说明每条连线都是什么总线。(注意:一定要先切断电源。)
2. 说明总线包含哪些层次。
3. 假定一组AXI 3.0总线ID宽度为8数据宽度为64地址宽度为32请计算该组AXI总线的信号线数量。
4. 阅读AMBA APB总线协议并设计一个APB接口的GPIO模块。
5. DRAM的寻址包含哪几部分
6. 假设一个处理器支持两个DDR3内存通道每个通道为64位宽内存地址线个数为15片选个数为4计算该处理器实际支持的最大内存容量。
\newpage

659
17-boot.Rmd Normal file
View File

@@ -0,0 +1,659 @@
# 计算机启动过程分析
前面章节主要从计算机硬件的角度对构成计算机系统的各个主要部分进行了介绍。为了描述计算机硬件系统各部分之间的相互关系本章将对计算机从开机到点亮屏幕接收键盘输入再到引导并启动整个操作系统的具体过程进行探讨。与本书其他章节一样本章基于LoongArch架构进行介绍具体则以龙芯3号处理器的启动过程为例。
无论采用何种指令系统的处理器复位后的第一条指令都会从一个预先定义的特定地址取回。处理器的执行就从这条指令开始。处理器的启动过程实际上就是一个特定程序的执行过程。这个程序我们称之为固件又称为BIOSBasic Input Output System基本输入输出系统。对于LoongArch处理器复位后的第一条指令将固定从地址0x1C000000的位置获取。这个地址需要对应一个能够给处理器核提供指令的设备这个设备以前是各种ROM现在通常是闪存Flash。从获取第一条指令开始计算机系统的启动过程也就开始了。
为了使计算机达到一个最终可控和可用的状态,在启动过程中,需要对包括处理器核、内存、外设等在内的各个部分分别进行初始化,再对必要的外设进行驱动管理。本章的后续内容将对这些具体工作进行讨论。
## 处理器核初始化
在讨论这个过程之前先来定义什么叫作初始化。所谓初始化实际上是将计算机内部的各种寄存器状态从不确定设置为确定将一些模块状态从无序强制为有序的过程。简单来说就是通过load/store指令或其他方法将指定寄存器或结构设置为特定数值。
举例来说在MIPS和LoongArch结构中都只将0号寄存器的值强制规定为0而其他的通用寄存器值是没有要求的。在处理器复位后开始运行的时候这些寄存器的值可能是任意值。如果需要用到寄存器内容就需要先对其进行赋值将这个寄存器的内容设置为软件期望的值。这个赋值操作可以是加载立即数也可以是对内存或者其他特定地址进行load操作又可以是以其他已初始化的寄存器作为源操作数进行运算得到的结果。
这个过程相对来说比较容易理解,因为是对软件上需要用到的单元进行初始化。而另一种情况看起来就相对隐蔽一些。例如,在现代处理器支持的猜测执行、预取等微结构特性中,可能会利用某些通用寄存器的值或者高速缓存的内容进行猜测。如果整个处理器的状态并没有完全可控,或许会猜测出一些极不合理的值,导致处理器微结构上执行出错而引发死机。这样就需要对一些必要的单元结构进行初始化,防止这种情况发生。
举一个简单的例子。计算机系统中使用约定的ABIApplication Binary Interface应用程序二进制接口作为软件接口规范。LoongArch约定使用1号寄存器r1作为函数返回指针寄存器raReturn Address。函数返回时一般使用指令“jirl”。这条指令的格式为“jirl rd,rj,offset”其中rj与offset表示跳转的目标地址rd为计算得到的返回地址为当前PC+4用于函数调用返回。当不需要保存时可以指定为r0也就是0号寄存器。因此函数返回时一般可用“jirl r0,r1,0”来实现。这样一种可行的转移预测优化方法是在指令译码得到“jirl”指令时立即使用r1作为跳转地址进行猜测取指以加速后续的指令执行。
如果程序中没有使用“jirl r0,r1,0”而是采用了诸如“jirl r0,r2,0”这样的指令就会导致这个猜测机制出错。而如果此时r1的寄存器是一个随机值就有可能导致取指猜测错误发出一个对非法地址的猜测请求。如果此时处理器没有对猜测访问通路进行控制或初始化就可能会发生严重问题例如猜测访问落入地址空洞而失去响应并导致死机等。
为了防止这个问题,在处理器开始执行之后,一方面需要先对相关的寄存器内容进行初始化,设置为一个正常地址值,另一方面则需要对地址空间进行处理,防止出现一般情况下不可访问的地址空洞。这样即使发生了这种猜测访问,也可以得到响应,避免系统出错或死机。
### 处理器复位 {#sec-cpu-reset}
处理器的第一条指令实际上是由复位信号控制的但受限于各种其他因素复位信号并没有对处理器内部的所有部分进行控制例如TLB、Cache等复杂结构而是只保证从取指部件到BIOS取指令的通路畅通。如果把CPU比作一个大房间复位后的房间内部漆黑一片大门内存接口、窗户IO接口都是关着的只有微弱的灯光照亮了通向一扇小门BIOS接口的通路。
在LoongArch架构下处理器复位后工作在直接地址翻译模式下。该模式下的地址为虚实地址直接对应的关系也就是不经TLB映射也不经窗口映射。默认情况下无论是取指访问还是数据访问都是Uncache模式也即不经缓存。这样即使硬件不对TLB、Cache两个结构进行初始化处理器也能正常启动并通过软件在后续的执行中对这些结构进行初始化。尤其是早期的处理器设计由于对资源或时序的考虑出于简化硬件设计的目标将很多初始化工作交由软件进行。但现在大部分处理器在硬件上自动处理从而减轻软件负担缩短系统启动时间。例如龙芯3A1000和龙芯3B1500都没有实现硬件初始化功能只能通过软件对Cache进行初始化。本身Cache的初始化就需要运行在Uncache的空间上执行效率低下而且当Cache越来越大时所需要的执行时间就越来越长。从龙芯3A2000开始龙芯处理器也实现了TLB、各级Cache等结构的硬件初始化。硬件初始化的时机是在系统复位解除之后、取指访问开始之前以此来缩短BIOS的启动时间。
LoongArch处理器复位后的第一条指令将固定从地址0x1C000000的位置获取这个过程是由处理器的执行指针寄存器被硬件复位为0x1C000000而决定的。
对物理地址0x1C000000的取指请求会被处理器内部预先设定好的片上互连网络路由至某个预先存放着启动程序的存储设备。从第一条指令开始处理器核会依据软件的设计按序执行。
以龙芯3A5000处理器为例处理器得到的前几条指令通常如下。左框中为手工编写的代码右框中为编译器编译生成的汇编代码。其中的stack、_gp为在代码其他地址所定义的标号编译器编译时能够使用实际的地址对其进行替换。
:::: {.cols data-latex=""}
::: {.col .width48 data-latex="{0.48\textwidth}"}
```
dli t0, (0x7 << 16)
csrxchg zero, t0, 0x4
dli t0, 0x1c001000
csrwr t0, 0xc
dli t0, 0x1c001000
csrwr t0, 0x88
dli t0, (1 << 2)
csrxchg zero, t0, 0x0
la sp, stack
la gp, _gp
```
:::
::: {.col .width4 data-latex="{0.04\textwidth}"}
```
```
:::
::: {.col .width48 data-latex="{0.48\textwidth}"}
```
lu12i.w $r12, 0x70
csrxchg $r0, $r12, 0x4
lu12i.w $r12, 0x1c001
csrwr $r12, 0xc
lui12i.w $r12, 0x1c001
csrwr $r12, 0x88
ori $r12, $r0, 0x4
csrxchg $r0, $r12, 0x0
lu12i.w $r3, 0x90400
lu32i.d $r3, 0
lu52i.d $r3, $r3, 0x900
lu12i.w $r2, 0x90020
ori $r2, $r2, 0x900
lu32i.d $r2, 0
lu52i.d $r2, $r2, 0x900
```
:::
::::
这几条指令对处理器核的中断处理相关寄存器进行了初始化并对后续软件将使用的栈地址等进行了初始化。第一条csrxchg指令将例外配置寄存器0x4偏移中的比特1816设置为0以将除TLB外的所有例外和中断入口设置为同一个代码中的0x1C001000。第一条csrwr指令将该例外入口地址0xC号控制寄存器设置为0x1C001000第二条csrwr指令将TLB重填例外的入口地址0x88号控制寄存器也设置为0x1C001000。实际上BIOS并没有使用TLB地址映射一旦出现了TLB重填例外一定是使用的地址出现了错误。第二条csrxchg指令将模式信息寄存器0x0号控制寄存器中的比特2设置为0以禁用所有的中断。可以看到对于stack、_gp这些地址的装载所用的la指令在经过编译器编译之后最终产生了多条指令与之对应。其中lu12i.w用于将20位立即数符号扩展并装载到寄存器的比特6312lu32i.d用于将20位立即数符号扩展并装载到寄存器的比特6332lu52i.d用于将12位立即数装载到寄存器的比特6352ori用于将12位立即数与寄存器的内容进行或操作。
需要指出的是处理器复位后先是通过频率为几十兆赫兹MHz以下的低速设备取指令例如SPI或LPC等接口。一拍只能取出1比特SPI或4比特LPC而一条指令一般需要32比特。对于吉赫兹GHz的高性能处理器来说几千拍才能执行一条指令相当于在城市空荡荡的大街上只有一个人在行走这时候的指令很“孤独”。
```{r boot-flow, echo=FALSE, fig.align='center', fig.cap='系统复位到操作系统启动的简要流程图', out.width='100%'}
knitr::include_graphics("images/chapter7/boot_flow.png")
```
整个处理器由系统复位到操作系统启动的简要流程如图\@ref(fig:boot-flow)所示。其中第一列为处理器核初始化过程第二列为芯片核外部分初始化过程第三列为设备初始化过程第四列为内核加载过程第五列为多核芯片中的从核Slave Core独有的启动过程。
### 调试接口初始化
那么在启动过程中优先初始化的是什么呢首先是用于调试的接口部分。比如开机时听到的蜂鸣器响声或者在一些主板上看到的数码管显示都是最基本的调试用接口。对于龙芯3号处理器来说最先初始化的结构是芯片内集成的串口控制器。串口控制器作为一个人机交互的界面可以提供简单方便的调试手段以此为基础再进一步对计算机系统中其他更复杂的部分进行管理。
对串口的初始化操作实际上是处理器对串口执行一连串约定好的IO操作。在X86结构下IO地址空间与内存地址空间相互独立IO操作与访存操作是通过不同的指令实现的。MIPS和LoongArch等结构并不显式区分IO和内存地址而是采用全局编址使用地址空间将IO和内存隐式分离并通过地址空间或TLB映射对访问方式进行定序及缓存等的控制。只有理解IO与内存访问的区别才能很好地理解计算机启动中的各种初始化过程。
内存空间对应的是存储器存储器不会发生存储内容的自行更新。也就是说如果处理器核向存储单元A中写入了0x5a5a的数值除非有其他的主控设备例如其他的处理器核或是其他的设备DMA对它也进行写入操作否则这个0x5a5a的数值是不会发生变化的。
IO空间一般对应的是控制寄存器或状态寄存器是受IO设备的工作状态影响的。此时写入的数据与读出的数据可能会不一致而多次读出的数据也可能不一致其读出数据是受具体设备状态影响的。例如对串口的线路状态寄存器寄存器偏移0x5的读取在不同的情况下会产生不同的返回值。该寄存器定义如表\@ref(tab:serial-status)所示。
```{r serial-status, echo = FALSE, message=FALSE, tab.cap='串口线路状态寄存器定义'}
autonum <- run_autonum(seq_id = "tab", bkm = "serial-status", bkm_all = TRUE)
readr::read_csv('./materials/chapter7/serial_status.csv') %>%
flextable() %>%
set_caption(caption="串口线路状态寄存器定义", autonum = autonum) %>%
theme_box() %>%
autofit()
```
可以看到这个寄存器里的各个数据位都与当时的设备状态相关。例如当程序等待外部输入数据时就需要查询这个寄存器的第0位以确定是否收到数据再从FIFO寄存器中读取实际的数据。在这个轮询的过程中寄存器的第0位根据串口的工作状态由0变成1。
更有意思的是这个寄存器的某些位在读操作之后会产生自动清除的效果例如第7位错误表示位在一次读操作之后会自动清零。
从这个寄存器上可以看到IO访问与内存访问的一些区别。IO寄存器的行为与具体的设备紧密相关每种IO设备都有各自不同的寄存器说明需要按照其规定的访问方式进行读写而不像内存可以进行随意的读写操作。
前面提到在LoongArch结构下IO地址空间与内存地址空间统一编址那么IO操作和内存操作的差异如何体现呢处理器上运行的指令使用虚拟地址虚拟地址通过地址映射规则与物理地址相关联。基本的虚拟地址属性首先区分为经缓存Cache与不经缓存Uncache两种。对于内存操作现代高性能通用处理器都采用Cache方式进行访问以提升访存性能。Cache在离处理器更近的位置上利用访存局部性原理进行缓存以加速重复访存或者其他规则访存通过预取等手段。对于存储器来说在Cache中进行缓存是没有问题的因为存储器所存储的内容不会自行修改但可能会被其他核或设备所修改这个问题可以通过缓存一致性协议解决。但是对于IO设备来说因为其寄存器状态是随着工作状态的变化而变化的如果缓存在Cache中那么处理器核将无法得到状态的更新所以一般情况下不能对IO地址空间进行Cache访问需要使用Uncache访问。使用Uncache访问对IO进行操作还有另一个作用就是可以严格控制读写的访问顺序不会因为预取类的操作导致寄存器状态的丢失。例如前面提到的线路状态寄存器的第7位ERROR一旦被预取的读操作所访问就会自动清除而这个预取操作本身有可能会因为错误执行而被流水线取消这样就导致这个错误状态的丢失无法被软件观察到。
理解了IO操作与内存访问操作的区别串口初始化的过程就显得非常简单。串口初始化程序仅仅是对串口的通信速率及一些控制方法进行设置以使其很方便地通过一个串口交互主机进行字符的读写交互。
串口初始化的汇编代码和说明如下。对于串口设备各个寄存器的具体含义,感兴趣的读者可以在相关处理器的用户手册上查找。
```
LEAF(initserial)
# 加载串口设备基地址
li a0, GS3_UART_BASE
#线路控制寄存器写入0x80128表示后续的寄存器访问为分频寄存器访问
li t1, 128
sb.b t1, a0, 3
# 配置串口波特率分频当串口控制器输入频率为33MHz将串口通讯速率设置在115200
# 时分频方式为33,000,000 / 16 / 0x12 = 114583。 由于串口通信有固定的起始格式,
# 能够容忍传输两端一定的速率差异,只要将传输两端的速率保持在一定的范围之内就可
# 以保证传输的正确性
li t1, 0x12
sb.b t1, a0, 0
li t1, 0x0
sb.b t1, a0, 1
# 设置传输字符宽度为8同时设置后续访问常规寄存器
li t1, 3
sb.b t1, a0, 3
# 不使用中断模式
li t1, 0
sb.b t1, a0, 1
li t1, 71
sb t1, a0, 2
jirl ra
nop
END(initserial)
```
这里有一个值得注意的地方串口设备使用相同的地址映射了两套功能完全不同的寄存器通过线路控制寄存器的最高位就是串口寄存器中偏移为3的寄存器的最高位进行切换。因为其中一套寄存器主要用于串口波特率的设置只需要在初始化时进行访问在正常工作状态下完全不用再次读写所以能够将其访问地址与另一套正常工作用的寄存器相复用来节省地址空间。表\@ref(tab:reg-multiplex)中是两组不同寄存器的定义。
```{r reg-multiplex, echo = FALSE, message=FALSE, tab.cap='串口的部分地址复用寄存器'}
autonum <- run_autonum(seq_id = "tab", bkm = "reg-multiplex", bkm_all = TRUE)
readr::read_csv('./materials/chapter7/reg_multiplex.csv') %>%
flextable() %>%
set_caption(caption="串口的部分地址复用寄存器", autonum = autonum) %>%
merge_h() %>%
colformat_md(md_extensions = "+hard_line_breaks") %>%
width(j=1, width=0.6) %>%
width(j=2:3, width=2.7) %>%
theme_box()
```
其中比特7为分频控制访问使能该位为1时可以访问表中的“初始化设置”寄存器为0时可以访问表中的“工作模式”寄存器
在初始化时代码中先将0x3偏移寄存器的最高位设置为1以访问分频设置寄存器按照与连接设备协商好的波特率和字符宽度将初始化信息写入配置寄存器中。然后退出分频寄存器的访问模式进入正常工作模式。
在使用时,串口的对端是一个同样的串口,两个串口的发送端和接收端分别对连,通过双向的字符通信来实现被调试机的字符输出和字符输入功能。
在正常工作模式下当CPU需要通过串口对外发送和接收字符时执行的两个函数分别如下
```
字符输出
LEAF(tgt_putchar)
# 加载串口设备基地址
dli a1, GS3_UART_BASE
1:
# 读取线路状态寄存器中的发送FIFO空标志
ld.bu a2, a1, 0x5
andi a2, a2, 0x20
# FIFO非空时等待
beqz a2, 1b
# 写入字符
st.b a0, a1, 0
jirl zero, ra, 0
END(tgt_putchar)
```
```
字符输入
LEAF(tgt_getchar)
#加载串口设备基地址
dli a0, GS3_UART_BASE
1:
#读取线路状态寄存器中的接收FIFO有效标志
ld.bu a1, a0, 0x5
andi a1, a1, 0x1
#接收FIFO为空时等待
beqz a1, 1b
#FIFO非空时将数据读出放在a0寄存器中返回
ld.b a0, a0, 0
jirl zero, ra, 0
END(tgt_getchar)
```
可以看到串口通过数据FIFO作为软件数据接口并通过线路状态寄存器中的特定位来表示串口设备的工作状态。串口驱动函数通过观察状态确定是否能够进行数据的输入输出交互。
对于字符输出串口控制器实现的功能是将发送FIFO中的数据转换为协议的格式并按位通过tx引脚向外发送再按照发送FIFO的空满状态设置对应的状态寄存器。对于字符输入串口控制器实现的功能是将在rx引脚上收到的信号通过协议格式进行解析将解析得到的字符写入接收FIFO并按照接收FIFO的空满状态设置对应的状态寄存器。
串口是一个功能非常简单的设备通过硬件提供底层支持软件进行配合驱动来实现整个字符输入输出功能。再上到应用层面还需要更多的软件参与。例如当通过上位机的minicom或其他的串口工具对被调试机进行字符输入时我们看到自己输入的字符立即显示在minicom界面上看起来就像是键盘输入给了minicomminicom显示后通过串口进行发送但其真正的过程却更为复杂
1用户在上位机的minicom界面中敲击键盘输入字符A
2上位机的内核通过其键盘驱动获得字符A
3上位机的内核将字符A交给minicom进程
4minicom进程调用串口驱动发送字符A
5内核中的串口驱动将字符A通过串口发送给被调试机
6被调试机的软件发现串口接收FIFO状态非空并接收字符A
7被调试机将接收的字符A通过发送函数写入串口发送FIFO
8被调试机的串口将发送FIFO中的字符A发送给上位机
9上位机发现串口接收FIFO状态非空并接收字符A
10上位机将接收的字符A交给minicom进程minicom将其显示在界面上。
从CPU对串口的初始化过程可以看出当Load与Store指令访问IO设备时与访问内存“直来直去”的行为是完全不同的。
### TLB初始化
接下来对TLB进行初始化。TLB作为一个地址映射的管理模块主要负责操作系统里用户进程地址空间的管理用以支持多用户多任务并发。然而在处理器启动的过程中处理器核处于特权态整个BIOS都工作在统一的地址空间里并不需要对用户地址空间进行过多干预。此时TLB的作用更多是地址转换以映射更大的地址空间供程序使用。
下面具体来看看TLB在这一过程中的作用。
LoongArch结构采用了分段和分页两种不同的地址映射机制。分段机制将大段的地址空间与物理地址进行映射具体的映射方法在BIOS下使用窗口机制进行配置主要供系统软件使用。而分页机制通过TLB起作用主要由操作系统管理供用户程序使用。
BIOS一般映射两段其中0x90000000_00000000开始的地址空间被映射为经缓存的地址0x80000000_00000000开始的地址空间被映射为不经缓存的地址。根据地址空间的转换规则这两段转换为物理地址时直接抹除地址的高位分别对应整个物理地址空间仅仅在是否经过Cache缓存上有所区别。
由于分段机制是通过不同的虚拟地址来映射全部的物理地址空间并不适合用作用户程序的空间隔离和保护也不适合需要更灵活地址空间映射方法的场合。这些场景下就需要利用TLB机制。早期的处理器或者比较简单的处理器中没有实现硬件初始化TLB的逻辑在使用之前需要使用软件对TLB进行初始化。TLB的初始化主要是将全部表项初始化为无效项。
初始化为无效项就是将TLB的每项逐一清空以免程序中使用的地址被未初始化的TLB表项所错误映射。在没有硬件复位TLB逻辑的处理器里启动时TLB里可能会包含一些残留的或者随机的内容这部分内容可能会导致TLB映射空间的错误命中。因此在未实现硬件复位TLB的处理器中需要对整个TLB进行初始化操作。
可以利用正常的TLB表项写入指令例如LoongArch中的TLBWR指令通过一个循环将TLB中的表项一项一项地写为无效。也可以利用更高效的指令来将所有表项直接写为无效例如LoongArch中的INVTLB 0指令。
以下是使用TLBWR指令来进行TLB初始化的相关代码及相应说明。具体的TLB结构和原理可以参考第3章的介绍。通过下面这段代码可以看到初始化的过程实际上就是将整个TLB表项清0的过程。需要特别说明的是在LoongArch架构中实际上并不需要使用这样的指令来完成这个过程而可以直接使用INVTLB 0,r0,r0这一条指令由硬件完成类似的循环清空操作。
```
LEAF(CPU_TLBClear)
# 循环变量
dli a3, 0
# 设置页大小为4K31位为1表示无效
dli a0, (1<<31) | (12 << 24)
# TLB表项数
li a2, 64
1:
# 将表项写入编号为0x10的TLBIDX寄存器
csrwr a0, 0x10
增加TLBIDX中的索引号
addi.d a0, 1
# 增加循环变量
addi.d a3, 1
# 写TLB表项
tlbwr
bne a3, a2, 1b
jirl zero, ra, 0
END(CPU_TLBClear)
```
前面提到过越来越多的处理器已经实现了在芯片复位时由硬件进行TLB表项的初始化这样在BIOS代码中可以不用再使用类似的软件初始化流程比如从龙芯3A2000开始的桌面或服务器用的处理器就不再需要软件初始化这能够减少所需的启动时间。但是在一些嵌入式类的处理器上还是需要上面提到的软件初始化流程。
### Cache初始化
Cache在处理器内的作用在前面的章节已经介绍过了Cache的引入能够减小处理器执行和访存延迟之间的性能差异即缓解存储墙的问题。引入Cache结构能够大大提高处理器的整体运行效率。
在系统复位之后Cache同样也处于一个未经初始化的状态也就是说Cache里面可能包含残留的或随机的数据如果不经初始化对于Cache空间的访问也可能会导致错误的命中。
不同的处理器可能包含不同的Cache层次各级Cache的容量也可能各不相同。例如龙芯3A1000处理器包含私有一级指令Cache、私有一级数据Cache和共享二级Cache两个层次而龙芯3A5000处理器则包含私有一级指令Cache、私有一级数据Cache、私有二级替换Cache和共享三级Cache三个层次。在进行Cache初始化时要考虑所有需要的层次。
Cache的组织结构主要包含标签Tag和数据Data两个部分Tag用于保存Cache块状态、Cache块地址等信息Data则保存数据内容。大多数情况下对Cache的初始化就是对Tag的初始化只要将其中的Cache块状态设置为无效其他部分的随机数据就不会产生影响。
龙芯3A5000中一级数据Cache的组织如图\@ref(fig:l1-dcache)所示。
```{r l1-dcache, echo=FALSE, fig.align='center', fig.cap="龙芯3A5000的一级数据Cache组织", out.width='100%'}
knitr::include_graphics("images/chapter7/l1_dcache.png")
```
其中Tag上的cs位为0表示该Cache块为无效状态对该Cache的初始化操作就是使用Cache指令将Tag写为0。对应的ECC位会在Tag写入时自动生成不需要专门处理。
不同Cache层次中Tag的组织结构可能会略有区别初始化程序也会稍有不同在此不一一列举。以下仅以龙芯3A处理器内部的一级指令Cache的初始化为例进行说明。
```
LEAF(godson2_cache_init)
# 64KB/4路为Index的实际数量
li a2, (1<<14)
# a0表示当前的index
li a0, 0x0
1:
# 对4路Cache分别进行写TAG操作
CACOP 0x0, a0, 0x0
CACOP 0x0, a0, 0x1
CACOP 0x0, a0, 0x2
CACOP 0x0, a0, 0x3
# 每个cache行大小为64字节
addi.d a0, a0, 0x40
bne a0, a2, 1b
jirl ra
nop
END(godson2_cache_init)
```
CACOP为LoongArch指令集中定义的Cache指令其定义为CACOP code,rj,si12。其中code表示操作的对象和操作的类型0x0表示对一级指令Cache进行初始化操作StoreTag将指定Cache行的Tag写为0。rj用于表示指定的Cache行si12在这个操作中表示不同的Cache路数。
需要特别指出的是上述程序中的Cache指令为特权态指令只有运行在特权态时处理器才可以执行Cache指令这样可以避免用户程序利用某些Cache指令对Cache结构进行破坏。处理器在复位完成之后就处于最高特权态中完成各项初始化。在加载操作系统执行之后在操作系统中才会使用用户态对程序的运行加以限制以防止不同用户进程之间的相互干扰。
在完成所有Cache层次的初始化之后就可以跳转到Cache空间开始执行。此后程序的运行效率将会有数十倍的提升。以取指为例在使用Cache访问之前需要以指令宽度为单位龙芯3A5000中为4字节进行取指操作在使用Cache访问之后取指将以Cache行为单位龙芯3A5000中为64字节大大提升了取指的效率。
既然Cache的使用能够大大提高程序运行效率为什么不首先对Cache进行初始化呢在跳转到Cache空间执行后程序运行效率大大提升随之而来的是处理器内各种复杂猜测机制的使用。例如对取数操作的猜测执行可能导致一个落在TLB映射空间的访存操作如果此时TLB尚未完成初始化就可能会导致TLB异常的发生而TLB异常处理机制的缺失又会导致系统的崩溃。
实际上在跳转到Cache空间执行前BIOS中还会对一些处理器具体实现中的细节或功能进行初始化在保证执行基本安全的状态下才会跳转到Cache空间执行。这些初始化包括对各种地址窗口的设置、对一些特殊寄存器的初始化等。感兴趣的读者可以自行阅读相关的BIOS实现代码在此不再赘述。
得益于摩尔定律的持续生效片上Cache的容量越来越大由此却带来了初始化时间越来越长的问题。但同时在拥有越来越多可用片上资源的情况下TLB、Cache等结构的初始化也更多地开始使用硬件自动完成软件需要在这些初始化上耗费的时间也越来越少。例如从龙芯3A2000开始片上集成的TLB、各级Cache都已经在复位之后由专用的复位电路进行初始化不再由低效的Uncache程序来完成大大缩短了系统启动时间。
完成Cache空间的初始化并跳转至Cache空间运行也标志着处理器的核心部分或者说体系结构相关的初始化部分已经基本完成。接下来将对计算机系统所使用的内存总线和IO总线等外围部分进行初始化。
如果把CPU比作一个大房间完成对TLB、Cache等的初始化后房间内已是灯火通明但大门内存接口和窗口IO接口还是紧闭的。
## 总线接口初始化
在使用同一款处理器的不同系统上TLB、Cache这些体系结构紧密相关的芯片组成部分的初始化方法是基本一致的不需要进行特别的改动。与此不同的是内存和IO设备的具体组成在计算机系统中则可以比较灵活地搭配不同系统间的差异可能会比较大。
在计算机系统硬件设计中内存可以使用不同的总线类型例如DDR、DDR2、DDR3或者DDR4。在主板上使用时也可以灵活地增加或者减小内存容量甚至改变内存条种类例如将UDIMMUnbuffered DIMM非缓冲型内存模组改为RDIMMRegistered DIMM寄存型内存模组
IO总线的情况也比较类似在计算机系统硬件设计中可以搭配不同的桥片也可以在主板上根据实际情况改变某些接口上连接的设备例如增加PCIE网卡或者增加硬盘数量等。
这些不同的配置情况要求在计算机系统启动时能够进行有针对性的初始化。前面提到过初始化就是将某些寄存器通过load/store指令或是其他的方法设置为期望数值的过程。以下代码段就是龙芯3A处理器上对内存控制器进行初始化的程序片段。
```
ddr2_config:
# a2、s0为调用该程序时传入的参数它们的和表示初始化参数在Flash中的基地址
add.d a2, a2, s0
# t1用于表示内存参数的个数
dli t1, DDR_PARAM_NUM
# t8为调用该程序时传入的参数用于表示内存控制器的寄存器基地址
addi.d v0, t8, 0x0
1:
# 可以看到初始化的过程就是从Flash中取数再写入内存控制器中寄存器的过程
ld.d a1, 0x0(a2)
st.d a1, 0x0(v0)
addi.d t1, t1, -1
addi.d a2, a2, 0x8
addi.d v0, v0, 0x8
bnez t1, 1b
```
### 内存初始化
内存是计算机系统的重要组成部分。冯·诺依曼结构下计算机运行时的程序和数据都被保存在内存之中。相对复位时用于取指的ROM或是Flash器件来说内存的读写性能大幅提高。以SPI接口的Flash为例即使不考虑传输效率的损失当SPI接口运行在50MHz时其带宽最高也只有50MHz×2b=100Mb/s而一个DDR3-1600的接口在内存宽度为64位时其带宽可以达到最高1600MHz×64b=102400Mb/s。由此可见内存的使用对系统性能的重要程度。
越来越多的处理器已经集成内存控制器。因为内存的使用和设置与外接内存芯片的种类、配置相关,所以在计算机系统启动的过程中需要先获取内存的配置信息,再根据该信息对内存控制器进行配置。正如上一章对内存总线的介绍,这些信息包含内存条的类型、容量、行列地址数、运行频率等。
获取这些信息是程序通过I2C总线对外部内存条的SPD芯片进行读操作来完成的。SPD芯片也相当于一个Flash芯片专门用于存储内存条的配置信息。
如上面的程序片段所示对内存的初始化实际上就是根据内存配置信息对内存控制器进行初始化。与Cache初始化类似的是内存初始化并不涉及其存储的初始数据。与Cache又有所不同的地方在于Cache有专门的硬件控制位来表示Cache块是否有效而内存却并不需要这样的硬件控制位。内存的使用完全是由软件控制的软件知道其访问的每一个地址是否存在有效的数据。而Cache是一个硬件加速部件大多数情况下软件并不需要真正知道而且也不希望知道其存在Cache的硬件控制位正是为了掩盖内存访问延迟保证访存操作的正确性。因此内存初始化仅仅需要对内存控制器进行初始化并通过控制器对内存的状态进行初始化。在初始化完成以后如果是休眠唤醒程序可以使用内存中已有的数据来恢复系统状态如果是普通开机则程序可以完全不关心内存数据而随意使用。
内存控制器的初始化包括两个相对独立的部分,一是根据内存的行地址、列地址等对内存地址映射进行配置,二是根据协议对内存信号调整的支持方式对内存读写信号相关的寄存器进行训练,以保证传输时的数据完整性。
在内存初始化完成后,可能还需要根据内存的大小对系统可用的物理地址空间进行相应的调整和设置。
### IO总线初始化
前面提到受外围桥片搭配及可插拔设备变化的影响系统每次启动时需要对IO总线进行有针对性的初始化操作。
对于龙芯3A5000处理器对应的IO总线主要为HyperTransport总线。在IO总线初始化时做了三件事一是对IO总线的访问地址空间进行设置划定设备的配置访问空间、IO访问空间和Memory访问空间二是对IO设备的DMA访问空间进行规定对处理器能接收的DMA内存地址进行设置三是对HyperTransport总线进行升频从复位时1.0模式的200MHz升频到了3.0模式的2.0GHz并将总线宽度由8位升至16位。
完成了这三件事对IO总线的初始化就基本完成了。接着还将设置一些和桥片特性相关的配置寄存器以使桥片正常工作。
IO总线初始化的主要目的是将非通用的软硬件设置都预先配置好将与桥片特性相关的部分与后面的标准驱动加载程序完全分离出来以保证接下来的通用设备管理程序的运行。
如果把CPU比作一个大房间至此房间灯火通明门窗均已打开但门窗外还是漆黑一片。
完成内存与IO总线初始化后BIOS中基本硬件初始化的功能目标已经达到。但是为了加载操作系统还必须对系统中的一些设备进行配置和驱动。操作系统所需要的存储空间比较大通常无法保存在Flash这样的存储设备中一般保存在硬盘中并在使用时加载或者也可以通过网口、U盘等设备进行加载。为此就需要使用更复杂的软件协议来驱动系统中的各种设备以达到加载操作系统的最终目标。
在此之前的程序运行基本没有使用内存进行存数取数操作程序也是存放在Flash空间之中的只不过是经过了Cache的缓存加速。在此之后的程序使用的复杂数据结构和算法才会对内存进行大量的读写操作。为了进一步提高程序的运行效率先将程序复制到内存中再跳转到内存空间开始执行。
此后还需要对处理器的运行频率进行测量对BIOS中的计时函数进行校准以便在需要等待的位置进行精确的时间同步。在经过对各种软件结构必要的初始化之后BIOS将开始一个比较通用的设备枚举和驱动加载的过程。下一节将对这个标准的设备枚举和加载过程进行专门的说明。
## 设备的探测及驱动加载
PCI总线于20世纪90年代初提出发展到现在已经逐渐被PCIE等高速接口所替代但其软件配置结构却基本没有发生变化包括HyperTransport、PCIE等新一代高速总线都兼容PCI协议的软件框架。
在PCI软件框架下系统可以灵活地支持设备的自动识别和驱动的自动加载。下面对PCI的软件框架进行简要说明。
在PCI协议下IO的系统空间分为三个部分配置空间、IO空间和Memory空间。配置空间存储设备的基本信息主要用于设备的探测和发现IO空间比较小用于少量的设备寄存器访问Memory空间可映射的区域较大可以方便地映射设备所需要的大块物理地址空间。
对于X86架构来说IO空间的访问需要使用IO指令操作Memory空间的访问则需要使用通常的load/store指令操作。而对于MIPS或者LoongArch这种把设备和存储空间统一编址的体系结构来说IO空间和Memory空间没有太大区别都使用load/store指令操作。IO空间与Memory空间的区别仅在于所在的地址段不同对于某些设备的Memory访问可能可以采用更长的单次访问请求。例如对于IO空间可以限制为仅能使用字访问而对于Memory空间则可以任意地使用字、双字甚至更长的Cache行访问。
配置空间的地址偏移由总线号、设备号、功能号和寄存器号的组合得到,通过对这个组合的全部枚举,可以很方便地检测到系统中存在的所有设备。
以HyperTransport总线为例配置访问分为两种类型即Type0和Type1其区别在于基地址和对总线号的支持。如图\@ref(fig:bus-access-type)所示,只需要在图中总线号、设备号、功能号的位置上进行枚举,就可以遍历整个总线,检测到哪个地址上存在设备。
```{r bus-access-type, echo=FALSE, fig.align='center', fig.cap="HyperTransport总线配置访问的两种类型", out.width='100%'}
knitr::include_graphics("images/chapter7/bus_access_type.png")
```
通过这种方式,即使在某次上电前总线上的设备发生了变化,也可以在这个枚举的过程中被探测到。而每个设备都拥有唯一的识别号,即图\@ref(fig:config-reg)中的设备号和厂商号,通过加载这些识别号对应的驱动,就完成了设备的自动识别和驱动的自动加载。
图\@ref(fig:config-reg)为标准的设备配置空间寄存器分布。对于所有设备这个空间的分布都是一致的以保证PCI协议对其进行统一的检索。
```{r config-reg, echo=FALSE, fig.align='center', fig.cap="标准的设备配置空间寄存器分布", out.width='100%'}
knitr::include_graphics("images/chapter7/config_reg.png")
```
图\@ref(fig:config-reg)中的厂商识别号Vendor ID与设备识别号Device ID的组合是唯一的由专门的组织进行管理。每一个提供PCI设备的厂商都应该拥有唯一的厂商识别号以在设备枚举时正确地找到其对应的驱动程序。例如英特尔的厂商识别号为0x8086龙芯的厂商识别号为0x0014。设备识别号对于每一个设备提供商的设备来说应该是唯一的。这两个识别号的组合就可以在系统中唯一地指明正确的驱动程序。
除了通过厂商识别号与设备识别号对设备进行识别并加载驱动程序之外还可以通过设备配置空间寄存器中的类别代码Class Code对一些通用的设备进行识别并加载通用驱动。例如USB接口所使用的OHCIOpen Host Controller Interface用于USB2.0 Full Speed或其他接口、EHCIEnhanced Host Controller Interface用于USB2.0 High Speed、XHCIeXtensible Host Controller Interface用于USB3.0SATA接口所使用的AHCIAdvance Host Controller Interface用于SATA接口等。这一类通用接口控制器符合OHCI、EHCI、XHCI或AHCI规范所规定的标准接口定义和操作方法类似于处理器的指令集定义只要符合相应的规范即使真实的设备不同也能够运行标准的驱动程序。
所谓驱动程序就是一组函数包含用于初始化设备、关闭设备或是使用设备的各种相关操作。还是以最简单的串口设备为例如果在设备枚举时找到了一个PCI串口设备它的驱动程序里面可能包含哪些函数呢首先是初始化函数在找到设备后首先执行一次初始化函数以使设备到达可用状态。然后是发送数据函数和接收数据函数。在Linux内核中系统通过调用读写函数接口实现真正的设备操作。在发送数据函数和接收数据函数中需要将设备发送数据和接收数据的方法与读写函数的接口相配合这样在系统调用串口写函数时能够通过串口发送数据调用串口读函数时能够得到串口接收到的数据。此外还有中断处理函数当串口中断发生时让中断能够进入正确的处理函数通过读取正确的中断状态寄存器找到中断发生的原因再进行对应的处理。
当然为了实现所有设备的共同工作还需要其他PCI协议特性的支持。
首先就是对于设备所需IO空间和Memory空间的灵活设置。从图\@ref(fig:config-reg)可以看到在配置空间中并没有设备本身功能上所使用的寄存器。这些寄存器实际上是由可配置的IO空间或Memory空间来索引的。
图\@ref(fig:config-reg)的配置空间中存在6组独立的基址寄存器Base Address Registers简称BAR。这些BAR一方面用于告诉软件该设备所需要的地址空间类型及其大小另一方面用于接收软件给其配置的基地址。
BAR的寄存器定义如图\@ref(fig:bar-reg)所示其最低位表示该BAR是IO空间还是Memory空间。BAR中间有一部分只读位为0正是这些0的个数表示该BAR所映射空间的大小也就是说BAR所映射的空间为2的幂次方大小。BAR的高位是可写位用来存储软件设置的基地址。
```{r bar-reg, echo=FALSE, fig.align='center', fig.cap="BAR的寄存器定义", out.width='100%'}
knitr::include_graphics("images/chapter7/bar_reg.png")
```
在这种情况下对一个BAR的基地址配置方式首先是确定BAR所映射空间的大小再分配一个合适的空间给其高位基地址赋值。确定BAR空间大小的方法也很巧妙只要给这个寄存器先写入全1的值再读出来观察0的个数即可得到。
对PCI设备的探测和驱动加载是一个递归调用过程大致算法如下
1将初始总线号、初始设备号、初始功能号设为0。
2使用当前的总线号、设备号、功能号组成一个配置空间地址这个地址的构成如图\@ref(fig:bus-access-type)所示使用该地址访问其0号寄存器检查其设备号。
3如果读出全1或全0表示无设备。
4如果该设备为有效设备检查每个BAR所需的空间大小并收集相关信息。
5检测其是否为一个多功能设备如果是则将功能号加1再重复扫描执行第2步。
6如果该设备为桥设备则给该桥配置一个新的总线号再使用该总线号从设备号0、功能号0开始递归调用执行第2步。
7如果设备号非31则设备号加1继续执行第2步如果设备号为31且总线号为0表示扫描结束如果总线号非0则退回上一层递归调用。
通过这个递归调用就可以得到整个PCI总线上的所有设备及其所需要的所有空间信息。有了这些信息就可以使用排序的方法对所有的空间从大到小进行分配。最后利用分配的基地址和设备的ID信息加载相应的驱动就能够正常使用该设备。
下面是从龙芯3A处理器PCI初始化代码中抽取出的程序片段。通过这个片段可以比较清楚地看到整个软件处理过程。
```
void _pci_businit(int init)
{
……
/* 这里的pci_roots用于表示系统中有多少个根节点通常的计算机系统中都为1 */
for (i=0,pb=pci_head;i<pci_roots;i++,pb=pb->next) {
_pci_scan_dev(pb, i, 0, init);
}
……
/* 对地址窗口等进行配置 */
_setup_pcibuses(init);
}
static void _pci_scan_dev(struct pci_pdevice *dev, int bus, int device, int initialise)
{
/* 对本级总线扫描所有32个设备位置判断是否存在设备 */
for (;device<32; device++) {
_pci_query_dev(dev,bus,device,initialize);
}
}
static void _pci_query_dev(struct pci_device *dev, int bus, int device, int initialise)
{
……
misc = _pci_conf_read(tag, PCI_BHLC_REG);
/* 检测是否为多功能设备 */
if(PCI_HDRTYPE_MULTIFN(misc)){
for(function=0;function<8;function++){
tag = _pci_make_tag(bus,device,function);
id = _pci_conf_read(tag, PCI_ID_REG);
if(id==0 || id==0xFFFFFFFF){
continue;
}
_pci_query_dev_func(dev,tag,initialise);
}
} else {
_pci_query_dev_func(dev,tag,initialise);
}
}
void _pci_query_dev_func(struct pci_device *dev, pcitag tag, int initialise)
{
……
/* 读取配置头上的设备类别 */
class = _pci_conf_read(tag, PCI_CLASS_REG);
/* 读取配置头上的厂商ID和设备ID */
id = _pci_conf_read(tag, PCI_ID_REG);
……
/* 如果是桥设备,需要递归处理下级总线 */
if(PCI_ISCLASS(class,PCI_CLASS_BRIDGE,PCI_SUBCLASS_BRIDGE_PCI)){
/* 开始递归调用 */
……
pd->bridge.pribus_num = bus;
pd->bridge.secbus_num = ++_pci_nbus;
……
/* 收集整个下级总线所需要的资源信息 */
_pci_scan_dev(pd, pd->bridge.secbus_num, 0, initialise);
……
/*收集下级总线mem/IO空间信息*/
} else {
……
/*收集本设备mem/IO空间信息*/
}
}
```
假设Memory空间的起始地址为0x40000000在设备扫描过程中发现了USB控制器、显示控制器和网络控制器三个设备对于Memory空间的需求如表\@ref(tab:space-requirement)所示。
```{r space-requirement, echo = FALSE, message=FALSE, tab.cap='三个设备的空间需求'}
autonum <- run_autonum(seq_id = "tab", bkm = "space-requirement", bkm_all = TRUE)
readr::read_csv('./materials/chapter7/space_requirement.csv') %>%
flextable() %>%
set_caption(caption="三个设备的空间需求", autonum = autonum) %>%
merge_v(j=1:2) %>%
theme_box() %>%
autofit()
```
在得到以上信息后软件对各个设备的空间需求进行排序并依次从Memory空间的起始地址开始分配最终得到的设备地址空间分布如表\@ref(tab:space-allocation)所示。
```{r space-allocation, echo = FALSE, message=FALSE, tab.cap='三个设备的地址空间分布'}
autonum <- run_autonum(seq_id = "tab", bkm = "space-allocation", bkm_all = TRUE)
readr::read_csv('./materials/chapter7/space_allocation.csv') %>%
flextable() %>%
set_caption(caption="三个设备的地址空间分布", autonum = autonum) %>%
merge_v(j=1:2) %>%
theme_box() %>%
autofit()
```
经过这样的设备探测和驱动加载过程,可以将键盘、显卡、硬盘或者网卡等设备驱动起来,在这些设备上加载预存的操作系统,就完成了整个系统的正常启动。
如果把CPU比作一个大房间至此房间内灯火通明门窗均已打开门窗外四通八达。CPU及相关硬件处于就绪状态。
## 多核启动过程
上面几节主要讨论了从处理器核初始化、总线初始化、外设初始化到操作系统加载的启动过程。启动过程中多处理器核间的相互配合将在本节进行讨论。
实现不同处理器核之间相互同步与通信的一种手段是核间中断与通信信箱机制。在龙芯3号处理器中为每个处理器核实现了一组核间通信寄存器包括一组中断寄存器和一组信箱寄存器。这组核间通信寄存器也属于IO寄存器的一种。实际上信箱寄存器完全可以通过在内存中分配一块地址空间实现这样CPU访问延迟更短。而专门使用寄存器实现的信箱寄存器更多是为了在内存还没有初始化前就让不同的核间能够有效通信。
### 初始化时的多核协同
在BIOS启动过程中为了简化处理流程实际上并没有用到中断寄存器对于各种外设也没有使用中断机制都是依靠处理器的轮询来实现与其他设备的协同工作。
为了简化多核计算机系统的启动过程,我们将多核处理器中的一个核定为主核,其他核定为从核。主核除了对本处理器核进行初始化之外,还要负责对各种总线及外设进行初始化;而从核只需要对本处理器核的私有部件进行初始化,之后在启动过程中就可以保持空闲状态,直至进入操作系统再由主核进行调度。
从核需要初始化的工作包括哪些部分呢首先是从核私有的部分。所谓私有就是其他处理器核无法直接操纵的部件例如核内的私有寄存器、TLB、私有Cache等这些器件只能由每个核自己进行初始化而无法由其他核代为进行。其次还有为了加速整个启动过程由主核分配给从核做的工作例如当共享Cache的初始化操作非常耗时的时候可以将整个共享Cache分为多个部分由不同的核负责某一块共享Cache的初始化通过并行处理的方式进行加速。
主核的启动过程与前三节介绍的内容基本是一致的。但在一些重要的节点上则需要与从核进行同步与通信或者说通知从核系统已经到达了某种状态。为了实现这种通知机制可以将信箱寄存器中不同的位定义为不同的含义一旦主核完成了某些初始化阶段就可以给信箱寄存器写入相应的值。例如将信箱寄存器的第0位定义为“串口初始化完成”标志第1位定义为“共享Cache初始化完成”标志第2位定义为“内存初始化完成”标志。
在主核完成串口的初始化后可以向自己的信箱寄存器写入0x1。从核在第一次使用串口之前需要查询主核的信箱寄存器如果第0位为0则等待并轮询如果非0则表示串口已经初始化完成可以使用。
在主核完成了共享Cache的初始化后向自已的信箱寄存器写入0x3。而从核在初始化自己的私有Cache之后还不能直接跳转到Cache空间执行必须等待信号以确信主核已将全部的共享Cache初始化完成然后再开始Cache执行才是安全的。
在主核完成了内存初始化后其他核才能使用内存进行数据的读写操作。那么从核在第一次用到内存之前就必须等待表示内存初始化完成的0x7标志。
### 操作系统启动时的多核唤醒
当从核完成了自身的初始化之后如果没有其他工作需要进行就会跳转到一段等待唤醒的程序。在这个等待程序里从核定时查询自己的信箱寄存器。如果为0则表示没有得到唤醒标志。如果非0则表示主核开始唤醒从核此时从核还需要从其他几个信箱寄存器里得到唤醒程序的目标地址以及执行时的参数。然后从核将跳转到目标地址开始执行。
以下为龙芯3A5000的BIOS中从核等待唤醒的相关代码。
```
slave_main:
# NODE0_CORE0_BUF0为0号核的信箱寄存器地址其他核的信箱寄存器地址与之
# 相关,在此根据主核的核号,确定主核信箱寄存器的实际地址
dli t2, NODE0_CORE0_BUF0
dli t3, BOOTCORE_ID
sll.d t3, 8
or t2, t2, t3
# 等待主核写入初始化完成标志
wait_scache_allover:
ld.w t4, t2, FN_OFF
dli t5, SYSTEM_INIT_OK
bne t4, t5, wait_scache_allover
# 对每个核各自的信箱寄存器进行初始化
bl clear_mailbox
waitforinit:
li a0, 0x1000
idle1000:
addiu a0, -1
bnez a0, idle1000
# t2为各个核的信箱寄存器地址轮询等待
# 通过读取低32位确定是否写入再读取64位得到完整地址
ld.w t1, t2, FN_OFF
beqz t1, waitforinit
# 从信箱寄存器中的其他地方取回相关启动参数
ld.d t1, t2, FN_OFF
ld.d sp, t2, SP_OFF
ld.d gp, t2, GP_OFF
ld.d a1, t2, A1_OFF
# 转至唤醒地址,开始执行
move ra, t1
jirl zero, ra, 0x0
```
在操作系统中,主核在各种数据结构准备好的情况下就可以开始依次唤醒每一个从核。唤醒的过程也是串行的,主核唤醒从核之后也会进入一个等待过程,直到从核执行完毕再通知主核,再唤醒一个新的从核,如此往复,直至系统中所有的处理器核都被唤醒并交由操作系统管理。
### 核间同步与通信
操作系统启动之前利用信箱寄存器进行了大量的多核同步与通信操作但在操作系统启动之后除了休眠唤醒一类的操作却基本不会用到信箱寄存器。Linux内核中只需要使用核间中断就可以完成各种核间的同步与通信操作。
核间中断也是利用一组IO寄存器实现的。通过将目标核的核间中断寄存器置1来产生一个中断信号使目标核进入中断处理。中断处理的具体内容则是通过内存进行交互的。内核中为每个核维护一个队列内存中的一个数据结构当一个核想要中断其他核时它将需要处理的内容加入目标核的这个队列然后再向目标核发出核间中断设置其核间中断寄存器。当目标核被中断之后开始处理其核间通信队列如果其间还收到了更多的核间中断请求也会一并处理。
为什么Linux内核中的核间中断处理不通过信箱寄存器进行呢首先信箱寄存器只有一组也就是说如果通过信箱寄存器发送通信消息在这个消息没被处理之前是不能有其他核再向其发出新的核间中断的。这样无疑会导致核间中断发送方的阻塞。另外核间中断寄存器实际上是IO寄存器前面我们提到对于IO寄存器的访问是通过不经缓存这种严格访问序的方式进行的相比于Cache访问方式不经缓存读写效率极其低下本身延迟开销很大还可能会导致流水线的停顿。因此在实际的内核中只有类似休眠唤醒这种特定的同步操作才会利用信箱寄存器进行其他的同步通信操作则是利用内存传递信息并利用核间中断寄存器产生中断的方式共同完成的。
## 本章小结
本章的目的是使读者了解最基本的计算机软硬件协同。计算机系统从上电复位到引导操作系统启动的基本过程是从处理器核的初始化开始,经过芯片的各种接口总线的初始化,再到各种外围设备的初始化,最终完成了操作系统引导的准备工作。整个启动过程的大部分工作是串行的。对于多核处理器,启动过程中还会穿插着一些多核协同的处理工作。
系统启动的整个过程中,计算机系统在软件的控制下由无序到有序,所有的组成部分都由程序管理,按照程序的执行发挥各自的功能,最终将系统的控制权安全交到操作系统手中,完成整个启动过程。
## 习题
1. 什么情况下需要对Cache进行初始化LoongArch中Cache初始化过程中所使用的Cache指令Index Store Tag的作用是什么
2. Cache初始化和内存初始化的目的有什么不同系统如何识别内存的更换
3. 从HyperTransport配置地址空间的划分上计算整个系统能够支持的总线数量、设备数量及功能数量。
4. 根据PCI地址空间命中方法及BAR的配置方式给出地址空间命中公式。
5. 多核唤醒时,如果采用核间中断方式,从核的唤醒流程是怎样的?
6. 在一台Linux机器上通过“lspci -v”命令查看该机器的设备列表并列举其中三个设备的总线号、设备号和功能号通过其地址空间信息写出该设备BAR的实际内容。
\newpage

909
18-microarch.Rmd Normal file
View File

@@ -0,0 +1,909 @@
# (PART) Part IV CPU的微结构 {-}
# 运算器设计
## 二进制与逻辑电路
### 计算机中数的表示 {#sec-number-presentation}
人们使用计算机处理信息。无论被处理信息的实质形态如何千差万别,计算机内部只能处理离散化的编码后的数据。目前,计算机系统内部的所有数据均采用二进制编码。这样做的原因主要有两点。
1二进制只有“0”和“1”两个数其编码、计数和运算规则都很简单特别是其中的符号“0”和“1”恰好可以与逻辑命题的“假”和“真”两个值相对应因而能通过逻辑电路方便地实现算术运算。
2二进制只有两种基本状态使用有两个稳定状态的物理器件就能表示二进制数的每一位而制造有两个稳定状态的物理器件要比制造有多个稳定状态的物理器件容易得多。例如电压的“高” “低”磁极的“N”“S”脉冲的“正”“负”磁通量的“有”“无”。半导体工艺无论是TTL、ECL还是CMOS都是用电压的高低来表示二进制的两个基本状态。
所有数据在计算机内部最终都交由机器指令处理,被处理的数据分为数值数据和非数值数据两类。数值数据用于表示数量的多少;非数值数据就是一个二进制比特串,不表示数量多少。对于给定的一个二进制比特串,要确定其表达的数值,需要明确三个要素:进位制、定/浮点表示和编码规则。
1.二进制
二进制同人们日常使用的十进制原理是相同的,区别仅在于两者的基数不同。
一般地任意一个R进制数R是正整数
$$A=a_{n}a_{n-1}\ldots a_{1}a_{0}.a_{-1}a_{-2}\ldots a_{1-m}a_{-m} (m,n为正整数)$$
其值可以通过如下方式求得:
$$\begin{aligned}Value(A)&=a_{n}\times R^{n}+a_{n-1}\times R^{n-1}+\ldots +a_{1}\times R^{1}+a_{0}\times R^{0}+a_{-1}\times R^{-1}\\ &\ +a_{-2}\times R^{-2}+\ldots +a_{1-m}\times R^{1-m}+a_{-m}\times R^{-m}\end{aligned}$$
其中R称为基数代表每个数位上可以使用的不同数字符号的个数。$R_{i}$称为第i位上的权即采用“逢R进一”。
二进制即是上述一般性定义中R=2的具体情况。
上面的定义只回答了非负数或无符号整数的二进制表示问题。有关正负整数的表示问题会在\@ref(sec-number-presentation)节讨论。下面举例说明无符号二进制整数的表示和加法。
例用4位二进制编码计算5+9。
解5的4位二进制表示为$0101_{2}$9的4位二进制表示为$1001_{2}$。5+9列竖式计算如下
```{r vertical-calculation, echo = FALSE, message=FALSE}
readr::read_csv('./materials/chapter8/vertical_calculation.csv') %>%
flextable() %>%
border_remove() %>%
delete_part(part='header') %>%
align(i = NULL, j = NULL, align = "right", part = "all") %>%
hline(i = 2, j = NULL) %>%
#hline_top() %>%
autofit()
```
上面的竖式计算过程和人们日常的十进制竖式加法计算过程极为相似。所不同的仅在于,十进制是“逢十进一”,二进制是“逢二进一”。
计算机内部的所有数据都采用二进制编码表示但是在表示绝对值较大的数据时需要很多位不利于书写和阅读因此经常采用十六进制编码来记录数据。因为16恰为$2^4$所以二进制和十六进制相互转换时不会出现除不尽的情况可以非常快捷地进行两种进制的转换运算。具体做法是将一个数由二进制编码转换为十六进制编码时从小数点开始向左、向右两个方向每4个二进制位一组不足时小数点左侧的左补0小数点右侧的右补0直接替换为一个十六进制位。十六进制编码转换为二进制编码的方法类似只是每个十六进制位替换为4个二进制位。
2. 定点数的表示
常见的数有整数和实数之分整数的小数点固定在数的最右边通常省略不写而实数的小数点则不是固定的。但是计算机中只能表示0和1无法表示小数点因此计算机中表示数值数据必须要解决小数点的表示问题。我们通过约定小数点的位置来解决该问题。小数点位置约定在固定位置的数称为定点数小数点位置约定为可以浮动的数称为浮点数。其中浮点数的表示将在下面介绍。这里将介绍计算机中最常见的两种定点数表示方法原码和补码。
在明确了进位制和小数点位置的约定之后,整数在计算机中的表示还有一个正负号如何表示的问题要解决。针对这一问题,原码和补码这两种编码规则采用了不同的解决思路。
1原码
数的原码表示采用“符号-数值”的表示方式,即一个形如$A=a_{n-1}a_{n-2}\ldots a_{1}a_{0}$的原码表示,最高位$a_{n-1}$是符号位0表示正数1表示负数其余位$a_{n-2}\ldots a_{1}a_{0}$表示数值的绝对值。如果$a_{n-1}$是0则A表示正数$+a_{n-2}\ldots a_{1}a_{0}$;如果$a_{n-1}$是1则A表示负数$-a_{n-2}\ldots a_{1}a_{0}$。例如,对于+19和-19这两个数如果用8位二进制原码表示则+19的原码是$00010011_{2}$-19的原码是$10010011_{2}$。
原码表示有两大优点:
1与人们日常记录正负数的习惯接近与真实数值之间的对应关系直观利于与真实数值相互转换。
2原码实现乘除运算比较简便直接。
但是原码表示亦存在两个缺点:
1存在两个0即一个+0一个-0。这不仅有悖于人们的习惯也给使用带来不便。
2原码的加减运算规则复杂这对于逻辑实现的影响很大。在进行原码加减运算时需要首先判断是否为异号相加或同号相减的情况如果是的话则必须先根据两个数的绝对值的大小关系来决定结果的正负号再用绝对值大的数减去绝对值小的数。
权衡上述利弊,现代计算机中基本不使用原码来表示整数。原码仅在表示浮点数的尾数部分时采用。
2补码
补码是定点数的另一种表示方法。现代计算机中基本都是采用补码来表示整数。它最大的好处就是可以用加法来完成减法运算,实现加减运算的统一。这恰好解决了原码表示所存在的最大问题。
在补码表示中其最高位同原码一样也作为符号位0表示正数1表示负数。补码表示和原码表示的差异在于其数值的计算方法。对于一个形如$A=a_{n-1}a_{n-2}\ldots a_{1}a_{0}$的补码表示,其值等于$-2^{n-1}\times a_{n-1}+a_{n-2}\ldots a_{1}a_{0}$。如果$a_{n-1}$是0则补码和原码一样A表示正的$a_{n-2}\ldots a_{1}a_{0}$;如果$a_{n-1}$是1则A表示$a_{n-2}\ldots a_{1}a_{0}$减去$10\ldots 0_{2}$共n-1个0得到的数。
求一个数的补码是个取模运算。关于模运算系统的准确数学描述感兴趣的读者可以自行查阅相关资料。这里举一个最为常见的模运算系统的例子——时钟。这个模系统的模数为12。假定现在时钟指向6点需要将它拨向10点那么你有两种拨法一种是顺时针向前拨4个小时另一种是逆时针向后拨8个小时。这种相同的效果用数学的语言来说即4≡-8 (mod 12)。基于模运算系统的概念对于具有1位符号位和n-1位数值位的n位二进制整数的补码来说其补码的定义是
$$[X]_{\mbox{补}}=2^{n}+X (mod 2^{n})-2^{n-1}≤X<2^{n-1}$$
利用补码基于模运算的这个特点,可以把减法转换成加法来做,因此在计算机中不用把加法器和减法器分开,只要有加法器就可以做减法。
根据上述补码的定义并不容易写出一个数值的补码形式而前面提到的原码可以很直观地与其数值进行转换。这里介绍一个原码和补码之间的转换方法最高位为0时原码与补码相同最高位是1时原码的最高位不变其余位按位取反后末位加1。举个例子譬如+19这个数如果用8位二进制原码表示是$00010011_{2}$最高位是0所以其二进制补码也是$00010011_{2}$。那么对于-19这个数其原码就是把+19原码的最高位从0变为1即$10010011_{2}$。在求-19的补码时原码最高位的1保持不变原码余下的7位$0010011_{2}$按位取反得到$1101100_{2}$末位再加一个1得到$1101101_{2}$,最终得到-19的8位补码是$11101101_{2}$,这个值实际上是由+19的8位补码减去$10000000_{2}(128_{10})$得到的。
3溢出
无论采用原码表示还是补码表示当一个二进制数的位数确定后其能够覆盖的数值范围也就确定了。例如n位的二进制有符号数其原码表示范围是[$-2^{n-1}+1$ , $2^{n-1}-1$],其补码表示范围是[$-2^{n-1}$ , $2^{n-1}-1$]。当同符号数相加或异符号数相减时结果的数值就可能会超过该长度编码下可表示的范围称之为溢出。例如使用4位二进制编码计算-7+5-7的补码是$1001_{2}$+5的补码是$0101_{2}$,两者相加是$1110_{2}(-2_{10})$两异号数相加不会溢出。又比如使用4位二进制编码计算5+4+5的补码是$0101_{2}$+4的补码是$0100_{2}$,两者相加得到$1001_{2}(-7_{10})$,这显然是溢出,两个正数相加得到了一个负数。
加法溢出的判断方法是如果A和B的最高位一样但是A+B结果的最高位与A和B的最高位不一样表示溢出即两个正数相加得到负数或两个负数相加得到正数。减法溢出的判断方法类似即负数减正数结果是正数或正数减负数结果是负数这就表示溢出。
3. 浮点数的表示
计算机中用于数据存储、传输和运算的部件的位数都是有限的,所以采用定点数表示数值数据时有一个不足之处,就是表示范围有限,太大或太小的数都不能表示。同时定点数表示精度也有限,用定点做除法不精确。此外,定点数也无法表示数学中的实数。所以,计算机还定义了浮点数,用来表示实数并弥补定点数的不足。
1二进制的科学记数法
在具体介绍计算机浮点数表示规格前,我们先回忆一下日常书写实数时所采用的科学记数法。譬如$0.000000001_{10}$可以记为$1.0_{10}\times 10^{-9}$$-31576000_{10}$可以记为$-3.1576_{10}\times 10^{7}$。一个采用科学记数法表示的数,如果尾数没有前导零且小数点左边只有一位整数,则可称为规格化数。既然我们可以用科学记数法来表示十进制实数,也可以用科学记数法来表示二进制实数。其一般的表示形式为:
$$(-1)^{s}\times f\times 2^{e}$$
其中s表示符号f为尾数域的值e为指数域的值。
譬如二进制实数的科学记数法表示:$1.1_{2}\times 2^{4}=2.4_{10}\times 10^{1}$$-1.0_{2}\times 2^{-7}=-7.8125_{10}\times 10^{-3}$。
2IEEE 754浮点数标准
计算机中的浮点数表示沿用了科学记数法的表示方式即包含了符号、尾数和阶码三个域。符号用一位二进制码表示0为正1为负。然而在计算机内部位宽是有限的余下的尾数和阶码两者间存在一个此消彼长的关系需要设计者在两者间权衡增加尾数的位宽会提高表示的精度但是会减少表示的范围而增加阶码的位宽虽然扩大了表示的范围但是会降低表示的精度。因为浮点数规格的定义融入了设计者自身的考虑所以直到20世纪80年代初浮点数表示格式还没有统一标准不同厂商的计算机内部的浮点数表示格式存在差异。这导致在不同厂商计算机之间进行含有浮点数的数据传送或程序移植时必须进行数据格式的转换更为糟糕的是有时这种数据格式转换会带来运算结果不一致的问题。因此从20世纪70年代后期开始IEEE成立委员会着手制定统一的浮点数标准最终在1985年完成了浮点数标准IEEE 754的制定。该标准的主要起草者是美国加州大学伯克利分校数学系教授William Kahan他帮助Intel公司设计了8087浮点协处理器并以此为基础形成了IEEE 754标准他本人也因此获得了1987年的图灵奖。自IEEE 754标准颁布后目前几乎所有的计算机都遵循该标准来表示浮点数。在过去的几十年间IEEE 754标准也根据工业界在CPU研发过程中遇到的新需求、实现的新结构及时进行演进和完善。其中一个比较重要的版本是2008年更新的IEEE 754-2008。该版本中明确了有关融合乘加Fused Multiply-Add运算、半精度浮点数等方面的内容。本书仅介绍IEEE 754标准中涉及单精度、双精度浮点数表示的基本内容对其他内容感兴趣的读者可查阅相关文献。
3IEEE 754标准浮点数格式
IEEE 754标准中定义了两种基本的浮点数格式32位的单精度格式和64位的双精度格式如图\@ref(fig:IEEE754float)所示。
```{r IEEE754float, fig.cap='IEEE 754浮点数格式', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/IEEE754_float.png')
```
32位单精度格式中包含1位符号、8位阶码和23位尾数64位双精度格式中包含1位符号、11位阶码和52位尾数。两种格式下基数均隐含为2。
IEEE 754标准中尾数用原码表示。由于表示同一个数的时候尾数可以有多种表示例如$0.001_{2}$可以表示为$0.1_{2}\times 2^{-2}$,也可以表示成$1.0_{2}\times 2^{-3}$因此需要一个规格化的表示来使得表示唯一。IEEE 754标准中规格化尾数的表示统一为1.xxxx的形式。尾数规格化后第一位总为1因而可以在尾数中缺省这一位1。隐藏该位后尾数可以多一位表示精度提高一位。
IEEE 754标准中阶码是用减偏置常量的移码表示但是所用的偏置常量并不是通常n位移码所用的$2^{n-1}$,而是$(2^{n-1}-1)$因此单精度和双精度浮点数的偏置常量分别为127和1023。
IEEE 754标准对浮点数的一些情况做了特殊的规定总的来说可以分为5种情况主要用阶码进行区分表\@ref(tab:tabIEEE754float)给出了IEEE 754标准中单精度和双精度不同浮点数的表示。
```{r tabIEEE754float, echo = FALSE, message=FALSE, tab.cap='IEEE 754 浮点数格式'}
readr::read_csv('./materials/chapter8/IEEE754float.csv') %>%
flextable() %>%
colformat_md() %>%
#align(i = NULL, j = NULL, align = "center", part = "all") %>%
delete_part(part='header') %>%
add_header_row(values=c(' ', '符号','阶码','尾数','值', '符号','阶码','尾数','值')) %>%
add_header_row(values=c(' ', '单精度','单精度','单精度','单精度','双精度','双精度','双精度','双精度')) %>%
merge_v(part='header') %>%
merge_h(part='header') %>%
#align(i = NULL, j = NULL, align = "center", part = "all") %>%
theme_box() %>%
align(i = NULL, j = NULL, align = "center", part = "all") %>%
autofit()
```
1无穷大阶码全1尾数全0
引入无穷大是为了在出现浮点计算异常时保证程序能够继续执行下去,同时也为程序提供一种检测错误的途径。$+\infty$在数值上大于所有有限浮点数,$-\infty$在数值上小于所有有限浮点数。无穷大不仅可以是运算的结果也可以作为运算的源操作数。当无穷大作为源操作数时根据IEEE 754标准规定可以得到无穷大或非数的结果。
2非数NaN阶码全1尾数非0
非数NaN表示一个没有定义的数。引入非数的目的是检测非初始化值的使用而且在计算出现异常时程序能够继续执行下去。非数根据尾数的内容又可以分为发信号非数Signaling NaN和不发信号非数Quiet NaN两种。如果源操作数是Quiet NaN则运算结果还是Quiet NaN如果源操作数是Signaling NaN则会触发浮点异常。
3规格化非0数阶码非全0非全1
阶码e的值落在[1, 254](单精度)和[1, 2046]双精度范围内且尾数f是非0值的浮点数是规格化的非0数。其尾数经过规格化处理最高位的1被省略因此如果符号位是0则表示数值为$1.f\times 2^{e-127}$(单精度)和$1.f\times 2^{e-1023}$双精度如果符号位是1则表示数值为$-1.f\times 2^{e-127}$(单精度)和$-1.f\times 2^{e-1023}$(双精度)。
4非规格化非0数阶码全0尾数非0
在规格化非0数中能表示的浮点数的最小阶值是-126单精度和-1022双精度如果浮点数的绝对值小于$1.0\times 2^{-126}$(单精度)和$1.0\times 2^{-1022}$双精度该如何表示呢IEEE 754允许特别小的非规格化数此时阶码为0尾数的小数点前面的那个1就不再添加了。因此如果符号位是0则表示数值为$0.f\times 2^{e-126}$(单精度)和$0.f\times 2^{e-1022}$双精度如果符号位是1则表示数值为$-0.f\times 2^{e-126}$(单精度)和$-0.f\times 2^{e-1022}$双精度。非规格化数填补了最小的规格化数和0之间的一段空隙使得浮点数值可表示的精度进一步提升了很多。
5阶码全0尾数全0
根据符号位的取值,分为+0和-0。
### MOS晶体管工作原理 {#sec-MOS-principle}
从原理上看只要有一个二值系统并且系统中能够进行与、或、非这样的基本操作就能够搭建出一台计算机。最早期的电子计算机使用继电器或电子管实现二值系统而现代计算机中则采用晶体管来实现二值系统。晶体管可以根据控制端电压或电流的变化来实现“开启”和“关闭”的功能从而表达二进制。晶体管主要分为双极型晶体管Bipolar Junction Transistor和金属-氧化物半导体场效应晶体管Metal Oxide Semiconductor Field Effect Transistor简称MOSFET或MOS。当前绝大多数CPU都采用MOS晶体管实现其中又以CMOSComplementary Metal Oxide Semiconductor晶体管电路设计最为常见。
1. 半导体
MOS晶体管使用硅作为基本材料。在元素周期表中硅是IV族元素它的原子最外层有4个电子可以与相邻的4个硅原子的最外层电子配对形成共价键。图\@ref(fig:silicium)a给出了纯净硅中原子连接关系的一个简单二维平面示意实际上纯净硅中原子构成的是一个正四面体立体网格。通过与相邻原子形成的共价键纯净硅中所有原子的最外层都具有8个电子达到相对稳定所以纯净硅的导电性很弱。但是如果在纯净硅中掺杂少量5价的原子如磷这些原子将挤占原有硅原子的位置而由于这些原子的最外层有5个电子除了与原有硅原子形成共价键用掉4个电子外还多余一个处于游离状态的电子如图\@ref(fig:silicium)b所示。在电场的作用下处于游离状态的电子就会逆着电场方向流动形成负电流。这类材料被称为NNegative型材料。同样如果在纯净的硅中掺杂少量3价的原子如硼那么这些原子挤占原有硅原子的位置后其最外层还缺少一个电子和相邻的硅原子形成共价键形成空穴如图\@ref(fig:silicium)c所示。在电场的作用下周围的电子就会跑过来填补这个空穴从而留下一个新的空穴相当于空穴也在顺着电场方向流动形成正电流。这类材料被称为PPositive型材料。当非4价元素掺杂的含量较小时产生的电子和空穴也就比较少用—号表示当非4价元素掺杂的含量较大时产生的电子和空穴也就比较多用+号表示。因此P-表示掺杂浓度低的P型材料里面只有少量的空穴N+表示掺杂浓度高的N型材料里面有大量电子。
```{r silicium, fig.cap='半导体硅原子结构示意图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/sicicium_atomic_structure.png')
```
2. NMOS和PMOS晶体管
如图\@ref(fig:MOS-struc)所示MOS晶体管是由多层摞放在一起的导电和绝缘材料构建起来的。每个晶体管的底部叫作衬底是低浓度掺杂的半导体硅。晶体管的上部接出来3个信号端口分别称为源极Source、漏极Drain和栅极Gate。源极和漏极叫作有源区该区域内采用与衬底相反极性的高浓度掺杂。衬底是低浓度P型掺杂有源区是高浓度N型掺杂的MOS晶体管叫作NMOS晶体管衬底是低浓度N型掺杂有源区是高浓度P型掺杂的MOS晶体管叫作PMOS晶体管。无论是NMOS管还是PMOS管其栅极与衬底之间都存在一层绝缘体叫作栅氧层其成分通常是二氧化硅SiO~2~。最早期的MOS晶体管栅极由金属制成后来的栅极采用掺杂后的多晶硅制成。掺杂后的多晶硅尽管其电阻比金属大但却比半导体硅的电阻小很多可以作为电极。并且同普通金属相比多晶硅更耐受高温不至于在MOS晶体管生产过程中融化。不过最新的工艺又有重新采用金属栅极的。
```{r MOS-struc, fig.cap='MOS晶体管组成结构示意图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/MOS_structure.png')
```
上面简述了MOS晶体管的基本构成下面以NMOS晶体管为例介绍MOS晶体管的工作原理。如果单纯在源极、漏极之间加上电压两极之间是不会有电流流过的因为源极和漏极之间相当于有一对正反相对的PN结如图\@ref(fig:NMOS-work)a所示。如果先在栅极上加上电压因为栅氧层是绝缘的就会在P衬底里形成一个电场。栅极上的正电压会把P衬底里面的电子吸引到栅氧层的底部形成一个很薄的沟道电子层相当于在源极和漏极之间架起了一座导电的桥梁。此时如果再在源极、漏极之间加上电压那么两极之间的电流就能流过来了如图\@ref(fig:NMOS_work)b所示。NMOS的基本工作原理就是这样但是其实际的物理现象却很复杂。
```{r NMOS-work, fig.cap='NMOS晶体管工作原理示意图', fig.align='center', echo = FALSE, out.width='80%'}
knitr::include_graphics('./images/chapter8/NMOS_workingprinciple.png')
```
当我们屏蔽掉底层的物理现象细节对MOS晶体管的工作行为进行适度抽象后NMOS晶体管的工作行为就是在栅极上加上电就通不加电就断。PMOS晶体管的工作行为与NMOS晶体管的恰好相反加上电就断不加电就通。这样我们可以简单地把MOS晶体管当作开关。NMOS晶体管是栅极电压高时打开栅极电压低时关闭PMOS晶体管反过来栅极电压低时打开栅极电压高时关闭。如图\@ref(fig:MOS-switch)所示。随着工艺的发展MOS晶体管中栅氧层的厚度越来越薄使得开启所需的栅极电压不断降低。晶体管的工作电压从早期工艺的5.0V降到后来的2.5V、1.8V现在都是1V左右或更低。
```{r MOS-switch, fig.cap='MOS晶体管开关行为', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter8/MOS_switch.png')
```
尽管MOS晶体管可以表现出开关的行为但是单纯的PMOS晶体管或者NMOS晶体管都不是理想的开关。例如NMOS晶体管适合传输0而不适合传输1PMOS晶体管恰好相反适合传输1而不适合传输0。在后面讲述常见CMOS电路时将会论及如何解决这一问题。
### CMOS逻辑电路
在了解了MOS晶体管的组成和基本原理后我们接下来了解如何用MOS晶体管构建逻辑电路。
1. 数字逻辑电路
1布尔代数
数字逻辑基于的数学运算体系是布尔代数。布尔代数是在二元集合{0, 1}基础上定义的。最基本的逻辑运算有三种AND&、或OR|、非NOT~)。这三种逻辑关系定义如下:
```{r boolean, echo = FALSE, message=FALSE}
readr::read_csv('./materials/chapter8/boolean.csv') %>%
flextable() %>%
border_remove() %>%
align(i = NULL, j = NULL, align = "center", part = "all") %>%
vline(i = NULL, j = 2) %>%
hline_top() %>%
autofit()
```
常用的布尔代数运算定律有:
恒等律:$A \ |\ 0 = A,\ A \ \&\ 1 = A$
0/1律$A \ |\ 1 = 1,\ A \ \&\ 0 = 0$
互补律:$A \ |\ ( \sim A) = 1,\ A \ \&\ (\sim A) = 0$
交换律:$A \ |\ B = B \ |\ A,\ A \ \&\ B = B \ \&\ A$
结合律:$A \ |\ (B \ |\ C) = (A \ |\ B) \ |\ C,\ A \ \&\ (B \ \&\ C) = (A \ \&\ B) \ \&\ C$
分配律:$A \ \&\ (B \ |\ C) = (A \ \&\ B) \ |\ (A \ \&\ C),\ A \ |\ (B \ \&\ C) = (A \ |\ B) \ \&\ (A \ |\ C)$
德摩根DeMorgan定律$\sim (A \ \&\ B) = (\sim A) \ |\ (\sim B),\ \sim (A \ |\ B) = (\sim A) \ \&\ (\sim B)$。
上述定律虽然很简单,但使用起来变化无穷。
根据电路是否具有数据存储功能,可将数字逻辑电路分为组合逻辑电路和时序逻辑电路两类。
2组合逻辑
组合逻辑电路中没有数据存储单元电路的输出完全由当前的输入决定。在组合逻辑的各种表达方式中最简单的就是真值表即对每一种可能的输入组合给出输出值。显然一个N输入的电路就有$2^{N}$种不同的输入组合。常见的门级组合逻辑除了与门AND、或门OR、非门NOT还有与非门NAND、或非门NOR、异或门XOR。图\@ref(fig:logicgate)给出了这些常见的门逻辑符号及其真值表。
```{r logicgate, fig.cap='常用基本逻辑门电路', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/logic_gate.png')
```
利用基本逻辑门电路可以构成具有特定功能的更大规模的组合逻辑部件,如译码器、编码器、多路选择器、加法器等。加法器和乘法器两类运算逻辑电路我们将在后续章节中介绍。表\@ref(tab:3-8decoder)所示是3-8译码器真值表把3位信号译码成8位输出当输入000时8个输出里面最低位为1输入为001时次低位为1依次类推。表\@ref(tab:8-1selector)所示是一个8选1选择器的真值表当CBA为000的时候选择输出第0路$D_{0}$为001的时候选择输出第1路$D_{1}$,依次类推。可以看出选择器可以用译码器加上与门来实现。
```{r 3-8decoder, echo = FALSE, message=FALSE, tab.cap='3-8译码器真值表'}
readr::read_csv('./materials/chapter8/3-8decoder_true_table.csv') %>%
flextable() %>%
colformat_md() %>%
delete_part(part='header') %>%
add_header_row(values=c('输入', '输入','输入','输出','输出','输出','输出','输出','输出','输出','输出')) %>%
merge_h(part='header') %>%
vline(i = NULL, j = 3) %>%
hline_top(part='all') %>%
align(i = NULL, j = NULL, align = "center", part = "all") %>%
autofit()
```
```{r 8-1selector, echo = FALSE, message=FALSE, tab.cap='8选1选择器真值表'}
#readr::read_csv('./materials/chapter8/3-8decoder_true_table.csv') %>%
readr::read_csv('./materials/chapter8/8-1selector_true_table.csv') %>%
flextable() %>%
colformat_md() %>%
delete_part(part='header') %>%
add_header_row(values=c('输入', '输入','输入','输出')) %>%
merge_h(part='header') %>%
vline(i = NULL, j = 3) %>%
hline_top(part='all') %>%
align(i = NULL, j = NULL, align = "center", part = "all") %>%
autofit()
```
3时序逻辑
时序逻辑电路包含时钟信号和数据存储单元两个要素。时序逻辑电路的特点在于,其输出不但与当前输入的逻辑有关,而且与在此之前曾经输入过的逻辑值有关。
时钟信号是时序逻辑电路的基础,它用于确定时序逻辑元件中的状态在何时发生变化。如图\@ref(fig:clocksignal)所示时钟信号是具有固定周期的标准脉冲信号。每个时钟周期分为高、低电平两部分其中低电平向高电平变化的边沿称为上升沿高电平向低电平变化的边沿称为下降沿。在CPU设计中通常使用边沿触发方式来确定时序逻辑状态变化的时间点。所谓边沿触发就是将时钟信号的上升沿或下降沿作为采样的同步点在每个采样同步点对时序逻辑电路的状态进行采样并存储到数据存储单元中。
```{r clocksignal, fig.cap='时钟信号', fig.align='center', echo = FALSE, out.width='80%'}
knitr::include_graphics('./images/chapter8/clock_signal.png')
```
数据存储单元是时序逻辑电路中的核心。数据存储单元多由锁存器构成。首先介绍RS锁存器。图\@ref(fig:RSlatch)是RS锁存器的逻辑图和真值表。RS锁存器包含置位端SSet和复位端RReset两个输入端口R为0、S为1时置输出为1R为1、S为0时输出为0。在图\@ref(fig:RSlatch)中下面与非门的输出接到上面与非门的一个输入同样上面与非门的输出接到下面与非门的一个输入通过两个成蝶形连接的与非门构成RS锁存器。RS锁存器与组合逻辑的不同在于R, S的值从0, 11, 0变成1, 1时能够保持输出值的状态不变从而实现数据的存储。组合的输出只跟输入相关但是RS锁存器的输入变了它的输出还能保持原来的值。
```{r RSlatch, fig.cap='RS锁存器', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter8/RS_latch.png')
```
在RS锁存器前面连接上两个与非门再用时钟CClock控制D输入就构成了如图\@ref(fig:Dlatch)a所示的电路。当C=0时R和S都为1RS锁存器处于保持状态也就是说当时钟处于低电平时无论输入D怎样变化输出都保持原来的值。当C=1时输出Q与输入D值相同相当于直通。这就是D锁存器D Latch的原理通过时钟C的电平进行控制高电平输入低电平保持。
两个D锁存器以图\@ref(fig:Dlatch)b所示的方式串接起来就构成了一个D触发器D Flip-Flop。当C=0时第一个D锁存器直通第二个D锁存器保持当C=1时第一个D锁存器保持第二个D锁存器直通C从0变为1时D的值被锁存起来。这就是D触发器的基本原理它是通过时钟的边沿进行数据的锁存。
```{r Dlatch, fig.cap='D锁存器和D触发器', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/D_latch.png')
```
实际情况下由于器件中电流的速度是有限的并且电容充放电需要时间所以电路存在延迟。为了保证D触发器正常工作需要满足一定的延迟要求。例如为了把D的值通过时钟边沿锁存起来要求在时钟变化之前的某一段时间内D的值不能改变这个时间叫作建立时间Setup Time。另外在时钟跳变后的一段时间内D的值也不能变这个时间就是保持时间Hold Time。建立时间和保持时间可以是负数。此外D触发器还有一个重要的时间参数叫作“Clock-to-Q”时间也就是时钟边沿到来后Q端数据更新为新值的时间。D触发器整个的访问延迟是建立时间加上“Clock-to-Q”时间。图\@ref(fig:Dlatchtime)给出了上升沿触发的D触发器的建立时间、保持时间以及“Clock-to-Q”时间的示意。
```{r Dlatchtime, fig.cap='D触发器建立时间、保持时间和Clock-to-Q时间', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter8/Dlatch_timing.png')
```
2. 常见CMOS电路
本节通过若干具体示例讲述如何用MOS晶体管实现逻辑电路且所列举的电路都是CMOS电路。关于CMOS电路的基本特点将在“非门”示例之后予以说明。
1非门
```{r NOTgate, fig.cap='CMOS电路非门', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/CMOS_NOT_gate.png')
```
图\@ref(fig:NOTgate)a是非门也称作反相器的CMOS电路它由一个PMOS晶体管和一个NMOS晶体管组成其中PMOS晶体管以下简称“P管”的源极接电源NMOS晶体管以下简称“N管”的源极接地两管的漏极连在一起作为输出栅极连在一起作为输入。如果输入为0接地则P管导通N管关闭P管的电阻为0实际电路中P管其实有一定的电阻大约在几千欧姆N管的电阻无穷大输出端的电压就是电源电压$V_{dd}$,如图\@ref(fig:NOTgate)b所示。反之当输入为1的时候N管导通P管关闭N管的电阻为0实际电路中N管其实有一定的电阻大约在几千欧姆P管的电阻无穷大输出端与电源断开与地导通输出端电压为0如图\@ref(fig:NOTgate)c所示。这就是反相器CMOS电路的工作原理。
从反相器的工作原理可以看出CMOS电路的基本特征其关键就在“C”Complementary互补即由上下两个互补的部分组成电路上半部分由P管构成下半部分由N管构成。上半部分打开的时候下半部分一定关上下半部分打开的时候上半部分一定关闭。这种电路设计的好处是在稳定状态电路中总有一端是关死的几乎没有直流电流可以大幅度降低功耗。
2与非门
图\@ref(fig:NANDgate)所示的是一个两输入与非门的CMOS电路电路上面两个P管并联下面两个N管串联。两个P管并联后一头接电源另一头与两个串联的N管连接。两个N管串联后一头与并联的P管连接另一头接地。与非门的两个输入A和B分别连接到一个N管和一个P管输出端是Y。当A和B中有一个为0则上面的P管网络导通下面的N管网络断开输出端被连接到电源上即输出Y为1。
```{r NANDgate, fig.cap='CMOS电路与非门', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter8/CMOS_NAND_gate.png')
```
3或非门
```{r NORgate, fig.cap='CMOS电路或非门', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter8/CMOS_NOR_gate.png')
```
图\@ref(fig:NORgate)所示的是一个两输入或非门的CMOS电路电路上面两个P管串联下面两个N管并联。两个P管串联后一头接电源另一头与两个并联的N管连接。两个N管并联后一头与串联的P管连接另一头接地。或非门的两个输入A和B分别连接到一个N管和一个P管输出端是Y。当A和B中有一个为1则上面的P管网络断开下面的N管网络导通输出端与电源断开连通到地上即输出Y为0。
4传输门
```{r TRANSgate, fig.cap='CMOS电路传输门', fig.align='center', echo = FALSE, out.width='40%'}
knitr::include_graphics('./images/chapter8/CMOS_TRANS_gate.png')
```
前面提到过单纯的PMOS晶体管或是NMOS晶体管都不是理想的开关但是在设计电路时有时需要一个接近理想状态的开关该开关无论对于0还是1都可以传递得很好。解决的方式也很直观如图\@ref(fig:TRANSgate)所示一个P管和一个N管彼此源极连在一起漏极连在一起两者的栅极上接上一对极性相反的使能信号。当$EN$=0、$\overline{EN}$=1时P管和N管都关闭当$EN$=1、$\overline{EN}$=0时P管和N管都开启。当P管和N管都开启时无论信号是0还是1都可以通过最适合传递该信号的MOS晶体管从A端传递到B端。
5D触发器
在前面讲述逻辑电路时介绍过如何用逻辑门搭建D触发器在用CMOS电路实现D触发器时我们也可以利用CMOS的逻辑门搭建出RS锁存器进而搭建出D锁存器并最终得到D触发器。但是考虑到构建D触发器时我们其实真正需要的是开关电路和互锁电路所以这种构建D触发器的方式消耗的资源过多。图\@ref(fig:DFlipFlop)中给出现代计算机中常用的一种D触发器电路结构。该电路的左边虚线框内部可以视作一个去除了输出缓冲器的D锁存器该锁存器存储的值体现在其内部N1点的状态。当$CLK$=0$\overline{CLK}$=1时传输门G1开启、G2关闭D点的值经由反相器I1和传输门G1传递进来并通过反相器I2和三态反相器T1反馈至N1点使该点到达一个稳定状态。当$CLK$=1$\overline{CLK}$=0时传输门G1关闭、G2开启D点值的变化不再影响到内部N1点同时N1点的状态经由传输门G2并通过反相器I3和三态反相器T2反馈至N2点使N2点处于稳定状态并将该值传递至输出端Q。
```{r DFlipFlop, fig.cap='CMOS电路D触发器', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/CMOS_D_Flip-Flop.png')
```
3. CMOS电路延迟
前面在介绍MOS晶体管原理的时候曾经提到过真实世界中PMOS晶体管和NMOS晶体管即便是在导通状态下源极和漏极之间也是有电阻的栅极和地之间也存在寄生电容。因此CMOS电路工作时输入和输出之间存在延迟该延迟主要由电路中晶体管的RC参数来决定。
图\@ref(fig:inverter)a是一个CMOS反相器的示意图。其输出端有一个对地电容主要由本身P管和N管漏极的寄生电容、下一级电路的栅电容以及连线电容组成。反相器输出端从0到1变化时需要通过P管的电阻对该电容充电从1到0变化时该电容的电荷需要通过N管的电阻放电到地端。图\@ref(fig:inverter)b示意了输出电容的充放电过程其中左图代表充电过程右图代表放电过程。因此该反相器输出从0到1变化时的延迟由P管打开时的电阻和输出电容决定从1到0变化时的延迟由N管打开时的电阻和输出电容决定。图\@ref(fig:inverter)c示意了在该反相器输入端从0变到1、再变回到0的过程中图中虚线表示输出端值变化的过程。从中可以看出反相器从输入到输出的变化是有延迟的而且反相器的输出不是理想的矩形而是存在一定的斜率。
```{r inverter, fig.cap='CMOS反相器的延迟', fig.align='center', echo = FALSE, out.width='80%'}
knitr::include_graphics('./images/chapter8/CMOS_inverter_delay.png')
```
在芯片设计的时候,需要根据单元的电路结构建立每个单元的延迟模型。一般来说,一个单元的延迟由其本身延迟和负载延迟所构成,而负载延迟又与该单元的负载相关。需要指出的是,用早期工艺生成的晶体管,其负载延迟与负载呈线性关系,但对于深亚微米及纳米工艺,晶体管的负载延迟不再与负载呈线性关系。在工艺厂家给出的单元延迟模型中,通常通过一个二维的表来描述每个单元的延迟,其中一维是输入信号的斜率,另外一维是输出负载。即一个单元的延迟是由输入信号的斜率和输出负载两个值通过查表得到的。
## 简单运算器设计
在计算机发展的早期阶段运算部件指的就是算术逻辑单元Arithmetic Logic Unit,简称ALU。ALU可以做算术运算、逻辑运算、比较运算和移位运算。后来功能部件不断发展扩充可以执行乘法、除法、开方等运算。本节主要介绍定点补码加法器的设计。
加法是许多运算的基础。根据不同的性能和面积需求加法器有很多种实现方式。进位处理是加法器的核心。根据进位处理方法的不同常见的加法器包括行波进位加法器Ripple Carry Adder, 简称RCA先行进位加法器Carry Look-ahead Adder, 简称CLA跳跃进位加法器Carry Skip Adder, 简称CSKA进位选择加法器Carry Select Adder, 简称CSLA进位递增加法器Carry Increment Adder, 简称CIA等等。其中行波进位加法器最为简单直接而先行进位加法器使用较为广泛。
### 定点补码加法器
1.一位全加器
一位全加器是构成加法器的基本单元。一位全加器实现两位本地二进制数以及低位的进位位相加求得本地和以及向高位的进位。它有三个1位二进制数输入A、B和Cin其中A和B分别为本地的加数和被加数C~in~为低位来的进位。它有两个1位二进制数输出S和C~out~其中S是本地和C~out~是向高位的进位。一位全加器的真值表如表\@ref(tab:fulladder-truetable)所示。
根据表\@ref(tab:fulladder-truetable),可以写出全加器的逻辑表达式如下:
$$S=\sim A \ \& \ \sim B \ \& \ C_{in} \ | \ \sim A \ \& \ B \ \& \ \sim C_{in} \ | \ A \ \& \ \sim B \ \& \ \sim C_{in} \ | \ A \ \& \ B \ \& \ C_{in}$$
$$C_{out}=A \ \& \ B \ | \ A \ \& \ C_{in} \ | \ B \ \& \ C_{in}$$
上述表达式中,\~表示取反操作,&表示与操作,|表示或操作,其中~操作的优先级最高,&操作次之,|操作优先级最低。上述表达式还可以简单解释为当输入的三个数中有奇数个1时本地和为1当输入的三个数中有两个1时向高位的进位为1。
```{r fulladder-truetable, echo = FALSE, message=FALSE, tab.cap='一位全加器真值表'}
readr::read_csv('./materials/chapter8/fulladder_truetable.csv') %>%
flextable() %>%
vline(i = NULL, j = 3) %>%
align(i = NULL, j = NULL, align = "center", part = "all") %>%
colformat_md(part="all") %>%
autofit()
```
根据上面的逻辑表达式,图\@ref(fig:FULLadder)给出了用非门和与非门搭建的一位全加器的逻辑电路图及其示意图。如果我们不严格区分非门和与非门以及不同数目输入与非门之间的延迟差异则可近似认为每个一位全加器需要2或3级的门延迟。
```{r FULLadder, fig.cap='一位全加器逻辑电路图与示意图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/1bit_full_adder_circuit.png')
```
接下来将介绍如何用一位全加器构建一个NN>1位的带进位加法器。
2.行波进位加法器
构建N位带进位加法器的最简单的方法是将N个一位全加器逐个串接起来。图\@ref(fig:32bitRCA)给出了32位行波进位加法器的示意图。其中输入A=a~31~\ldots a~0~和B=b~31~\ldots b~0~分别是加数和被加数C~in~是最低位的进位输出加和S=s~31~\ldots s~0~以及最高位向上的进位C~out~。所谓“行波”是指每一级的一位全加器将来自低位的一位全加器的进位输出C~out~作为本级的进位输入C~in~如波浪一般层层递进下去。这种串行的进位传递方式与人们日常演算十进制加法时采用的进位方式原理一样非常直观。但是这种加法器的电路中每一位的数据相加都必须等待低位的进位产生之后才能完成即进位在各级之间是顺序传递的。回顾一下上一节关于一位全加器的延迟的大致估算可知一位全加器输入到S的最长延迟是3级门、输入到C~out~的最长延迟是2级门。因此32位行波进位加法器中从最低位的输入A~0~、B~0~、C~in~到最高位的进位输出C~out~存在一条进位链其总延迟为2×32=64级门从最低位的输入A~0~、B~0~、C~in~到最高位的进位输入C~in~的延迟为2×31=62级门所以从最低位的输入A~0~、B~0~、C~in~到最高位的加和S~31~的总延迟为62+3=65级门。从这个例子可以看出虽然行波进位加法器直观简单但是其延迟随着加法位数N的增加而线性增长N越大时行波进位加法器的延迟将越发显著。在CPU设计中加法器的延迟是决定其主频的一个重要参数指标如果加法器的延迟太长则CPU的主频就会降低。例如对于一个64位的高性能通用CPU来说在良好的流水线切分下每级流水的延迟应控制在20级门以内所以64位行波进位加法器高达129级门的延迟太长了。
```{r 32bitRCA, fig.cap='32位行波进位加法器', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/32bit_RCA.png')
```
3.先行进位加法器
为了改进行波进位加法器延迟随位数增加增长过快的缺点,人们提出了先行进位加法器的电路结构。其主要思想是先并行地计算每一位的进位,由于每一位的进位已经提前算出,这样计算每一个的结果只需要将本地和与进位相加即可。下面详细介绍先行进位(或者说并行进位)加法器的设计原理。
1并行进位逻辑
假设两个N位数A和B相加A记作a~N-1~a~N-2~$\ldots$a~i~a~i-1~\ldots a~1~a~0~B记作b~N-1~b~N-2~$\ldots$b~i~b~i-1~$\ldots$b~1~b~0~。定义第i位的进位输入为c~i~进位输出为c~i+1~且将加法器的输入C~in~记作c~0~以方便后面描述的统一。每一位进位输出c~i+1~的计算为:$$c_{i+1}=a_{i}\ \&\ b_{i}\ |\ a_{i}\ \&\ c_{i}\ |\ b_{i}\ \&\ c_{i}=a_{i}\ \&\ b_{i}\ |\ (a_{i}\ |\ b_{i}\ \&\ c_{i}$$
设$g_{i} = a_{i} \ \&\ b_{i}p_{i} = a_{i} \ |\ b_{i}$则c~i+1~的计算可以表达为:
$$c_{i+1}=g_{i} \ |\ p_{i} \ \& \ c_{i}$$
从上式可以看出当g~i~=1时在c~i+1~必定产生一个进位与c~i~无关当p~i~=1时如果c~i~有一个进位输入则该进位可以被传播至c~i+1~。我们称g~i~为第i位的进位生成因子p~i~为第i位的进位传递因子。
下面以4位加法器的进位传递为例根据公式$c_{i+1}=g_{i} \ |\ p_{i}\ \&\ c_{i}$逐级展开可得到:
$c_{1}=g_{0} \ |\ p_{0} \ \&\ c_{0}$
$c_{2}=g_{1}\ |\ p_{1}\ \&\ g_{0}\ |\ p_{1}\ \&\ p_{0}\ \&\ c_{0}$
$c_{3}=g_{2}\ |\ p_{2}\ \&\ g_{1}\ |\ p_{2}\ \&\ p_{1} \ \&\ g_{0} \ |\ p_{2}\ \&\ p_{1}\ \&\ p_{0}\ \&\ c_{0}$
$c_{4}=g_{3}\ |\ p_{3}\ \&\ g_{2}\ |\ p_{3}\ \&\ p_{2}\ \&\ g_{1}\ |\ p_{3}\ \&\ p_{2}\ \&\ p_{1}\ \&\ g_{0}\ |\ p_{3}\ \&\ p_{2}\ \&\ p_{1}\ \&\ p_{0}\ \&\ c_{0}$
扩展之后每一位的进位输出c~i+1~可以由仅使用本地信号生成的g和p直接得到不用依赖前一位的进位输入c~i~。图\@ref(fig:4bitCLA)给出了4位先行进位的逻辑电路图及其示意图。从图\@ref(fig:4bitCLA)中可以看出采用先行进位逻辑产生第4位的进位输出只需要2级门延迟而之前介绍的行波进位逻辑则需要8级门延迟先行进位逻辑的延迟显著地优于行波进位逻辑。当然这里为了电路逻辑的简洁以及计算的简便我们使用了四输入、五输入的与非门这些与非门的延迟比行波进位逻辑中采用的二输入、三输入的与非门的延迟要长但我们不再做进一步细致的区分均视作相同延迟。而且实际实现时也很少采用五输入的与非门其N管网络上串接5个NMOS管电阻值较大电路速度慢。
```{r 4bitCLA, fig.cap='块内并行的4位先行进位逻辑', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/4bit_CLA.png')
```
2块内并行、块间串行逻辑
理论上可以把上述并行进位方法扩展成更多位的情况,但那需要很多输入的逻辑门,在实现上是不现实的。实现更多位的加法器时通常采用分块的进位方法,将加法器分为若干个相同位数的块,块内通过先行进位的方法计算进位,块间通过行波进位的方法传递进位。图\@ref(fig:32bitCLA)给出了16位加法器中采用该方式构建的进位逻辑。由于块内并行产生进位只需要2级门延迟因此从p~i~和g~i~产生c~16~最多只需要18级门延迟而非行波进位逻辑的32级门延迟。
```{r 32bitCLA, fig.cap='块内并行块间串行的16位先行进位加法器的进位逻辑', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/32bit_CLA.png')
```
3块内并行、块间并行逻辑
为了进一步提升加法器的速度可以在块间也采用先行进位的方法即块内并行、块间也并行的进位实现方式。与前面类似对于块内进位定义其的进位生成因子为g和进位传递因子为p对于块间的进位传递定义其进位生成因子为G和块间进位传递因子为P则其表达式如下
$$P= p_{3}\ \&\ p_{2}\ \&\ p_{1}\ \&\ p_{0}$$
$$G= g_{3}\ |\ p_{3}\ \&\ g_{2}\ |\ p_{3}\ \&\ p_{2}\ \&\ g_{1}\ |\ p_{3}\ \&\ p_{2}\ \&\ p_{1}\ \&\ g_{0}$$
上面的表达式可以解释为当G为1时表示本块有进位输出生成当P为1时表示当本块有进位输入时该进位可以传播至该块的进位输出。图\@ref(fig:4bitCLAwithc)给出了包含块间进位生成因子和进位传递因子的4位先行进位的逻辑电路及其示意图。
定义上述的块间进位生成因子和进位传递因子是因为这种逻辑设计具有很好的层次扩展性即以层次化的方式构建进位传递逻辑把下一级的P和G输出作为上一级的p~i~和g~i~输入。图\@ref(fig:16bitCLA)给出了一个采用两层并行进位结构的16位先行进位逻辑采用了5块4位先行进位逻辑。其计算步骤是
1下层的4块4位先行进位逻辑根据各块所对应的p~i~和g~i~生成各自的块间进位生成因子G和块间进位传递因子P
2上层的4位先行进位逻辑把下层的先行进位逻辑生成的P和G作为本层的p~i~和g~i~输入生成块间的进位c~4~、c~8~
3下层的每块4位先行进位逻辑分别把c~0~以及上层计算出的c~4~、c~8~和c~12~作为各自块的进位输入c~0~再结合本地的p~i~和g~i~分别计算出本块内部所需要的每一位进位。
```{r 4bitCLAwithc, fig.cap='包含块间进位生成因子和进位传递因子的4位先行进位逻辑', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/4bit_CLA(include carry factor).png')
```
```{r 16bitCLA, fig.cap='块内并行且块间并行的16位先行进位逻辑', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/16bit_CLA.png')
```
可以看出从p~i~和g~i~生成下层各块的P、G需要2级门延迟上层根据自身p~i~和g~i~输入生成进位输出c~1~\~c~3~需要2级门延迟下层各块从c~0~输入至生成进位输出c~1~\~c~3~也需要2级门延迟。所以整体来看从p~i~和g~i~生成进位c~1~\~c~16~最长的路径也只需要6级门延迟这比前面介绍的块内并行但块间串行的电路结构更快。而且进一步分析可知块间并行的电路结构中最大的与非门的扇入为4而前面分析块间串行电路结构延迟时那个电路中最大的与非门的扇入为5。
这种块间并行的电路结构在设计更多位的加法器时只需要进一步进行层次化级联就可以。例如仍采用4位先行进位逻辑作为基本块通过3层的树状级联就可以得到64位加法器的进位生成逻辑其从p~i~和g~i~输入到所有进位输出的最长路径的延迟为10级门。感兴趣的读者可以自行推导一下其具体的结构和连接关系。
采用块内并行且块间并行的先行进位逻辑所构建的加法器其延迟随着加法位数的增加以对数的方式增长因而在高性能通用CPU设计中被广泛采用。
### 减法运算实现
在\@ref(sec-number-presentation)节中我们提到,现代通用计算机中定点数都是用补码表示的。补码表示的一个显著优点就是补码的减法可以通过补码加法来实现,即补码运算具有如下性质:
$$[A]_{\mbox{补}}-[B]_{\mbox{补}}=[A-B]_{\mbox{补}}=[A]_{\mbox{补}}+[-B]_{\mbox{补}}$$
而-B~补~可以通过将B~补~“按位取反末位加1”的法则进行计算。所以只需要将被减数直接接到加法器的A输入减数按位取反后接到加法器的B输入同时将加法器的进位输入C~in~置为1就可以用加法器完成A~补~-B~补~的计算了,如图\@ref(fig:SUBer)a所示。在此基础之上可以将加法和减法统一到一套电路中予以实现如图\@ref(fig:SUBer)b所示其中SUB作为加、减法的控制信号。当SUB信号为0时表示进行加法运算当SUB信号为1时表示进行减法运算。
```{r SUBer, fig.cap='利用加法器实现减法', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter8/subtracter.png')
```
### 比较运算实现
常见基本运算中除了加减法外还有比较运算。比较运算主要包含两种类型:一是判断两个数的相等情况,二是判断两个数的大小情况。
判断两个数相等所需要的逻辑电路比较简单,图\@ref(fig:comparer)给出了一个4位相等比较的逻辑电路及其示意图。电路首先采用异或逻辑逐位比较输入A和B的对应位是否相同所得到的结果中只要出现一个1则表示两者不相等输出结果为0否则结果为1。更多位数的相等比较的电路原理与所举的例子基本一致只是在实现时判断异或结果中是否有1需要多级逻辑完成以降低逻辑门的扇入数目。
```{r comparer, fig.cap='4位相等比较器逻辑电路及其示意图', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter8/4bit_comparer.png')
```
我们通过分析A-B的结果来比较A和B的大小。这里需要注意的是结果溢出的情况。如果减法操作没有发生溢出则减法结果的符号位为1时表示A < B如果发生溢出则结果符号位为0时才表示A < B。假设A和B是两个64位的有符号数A=a~63~$\ldots$a~0~B=b~63~$\ldots$b~0~A-B的结果为S=s~63~$\ldots$s~0~则A < B成立的条件可以表示为
$$\begin{aligned}Cond_{A< B}&=\ \sim Overflow \ \&\ s_{63} \ |\ Overflow \ \&\ s_{63} \\ &=\ a_{63}\ \&\ s_{63}\ |\ \sim b_{63}\ \&\ s_{63}\ |\ a_{63}\ \&\ \sim b_{63}\end{aligned}$$
当然最终的表示方式也可以直接得到即A < B成立的条件仅包括三种情况A是负数且B是非负数A是负数且B也是负数且结果是负数B是非负数且A是非负数且结果是负数。
由于能够通过减法来做大小的比较且相等比较的逻辑资源并不多所以在设计ALU时比较操作的实现并不会新增很多逻辑资源消耗。
### 移位器
常见基本运算中除了加减、比较运算外还有移位运算。移位运算不仅在进行位串处理时十分有用而且常用于计算乘以或除以2的幂次方。移位运算通常有四种逻辑左移、逻辑右移、算术右移和循环右移。其中左移、右移的概念如同其名字中的表述是直观明了的。逻辑右移和算术右移的区别在于前者从高位移入的是0后者从高位移入的是源操作数的符号位。算术右移之所以名字中使用“算术”这个词是因为当用移位操作计算有符号数补码表示除以2的幂次方时只有从高位移入符号位才能保证结果的正确。由此也可以知晓为什么没有定义“算术左移”这种移位操作。因为无论是有符号数还是无符号数其乘以2的幂次方都只需要在低位移入0就可以了。循环右移操作顾名思义右移时从最低位移出去的比特位并不被丢弃而是重新填入到结果的最高位。也正是因为这种循环移位的特点循环左移操作其实可以用循环右移操作来实现故不单独定义循环左移操作。例如5位二进制数11001其逻辑左移2位的结果是00100逻辑右移2位的结果是00110算术右移2位的结果是11110循环右移2位的结果是01110。
N位数的移位器实现实质上是N个N:1的多路选择器。图\@ref(fig:shifter)中依次给出了4位数的逻辑左移、逻辑右移、算术右移和循环右移的逻辑电路示意图。其中A=a~3~a~2~a~1~a~0~是被移位数shamt~1..0~是移位量Y=y~3~y~2~y~1~y~0~是移位结果。更多位数的移位器的实现原理与示例一致,只是选择器的规模更大。由于位数多时多路选择器消耗的电路资源较多,所以在实现时,可以将逻辑右移、算术右移和循环右移的电路糅合到一起,以尽可能复用多路选择器的资源。
```{r shifter, fig.cap='4位移位器逻辑', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/4bit_shifter.png')
```
## 定点补码乘法器
本节介绍定点补码乘法器的设计。乘法指令在科学计算程序中很常见矩阵运算、快速傅里叶变换操作中都有大量的定点或浮点乘法操作。在计算机发展的早期由于硬件集成度较低只通过ALU实现了加减法、移位等操作乘法这样的复杂操作需要由软件通过迭代的移位-累加操作来实现。随着处理器运算部件的升级,现代处理器已经使用硬件方式来实现定点和浮点乘法操作。
### 补码乘法器
对于定点乘法器而言,最简单的实现方式就是通过硬件来模拟软件的迭代操作,这种乘法实现方式被称为移位加。其逻辑结构如图\@ref(fig:iter-sourcecode-MUL)所示。
```{r iter-sourcecode-MUL, fig.cap='迭代式硬件原码乘法器', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter8/iterative_sourcecode_multipilier.png')
```
以两个8位数的乘法为例乘法器的输入包括一个8位的乘数和一个8位的被乘数输出则是16位的乘法结果。通过触发器将这3个数存储下来并执行以下步骤
1最初时将乘法结果设置为0。
2在每个时钟周期判断乘数的最低位如果值为1则将被乘数加到乘法结果如果值为0则不进行加法操作。此后将乘数右移1位将被乘数左移1位将参与运算的3个数锁存进入下一个时钟周期。
3执行8次操作得到正确的乘法结果。
实现上述移位加算法需要的硬件很简单组合逻辑延迟也较小缺点是完成一条乘法需要很多个时钟周期对于64位的乘法而言就需要64拍。但是上述算法是将操作数视为一个无符号二进制数来设计的如果计算的输入是补码形式那么就需要先根据输入的正负情况判断出结果的符号位随后将输入转换为其绝对值后进行上述迭代运算最后再根据结果符号位转换回补码形式。很显然这样操作略显复杂有没有直接根据补码形式进行运算的方法呢
在8.1.1小节中介绍过,现代处理器中的定点数都是按照补码形式来存储的,同时有[X]~补~+[Y]~补~=[X+Y]~补~的特性。那么,应该如何计算[X×Y]~补~呢?是否可以简单地将[X]~补~与[Y]~补~相乘得到呢?
还是以8位乘法为例。假定有8位定点数Y[Y]~补~的二进制格式写作y~7~y~6~y~5~y~4~y~3~y~2~y~1~y~0~根据补码定义Y的值等于
$$Y=-y_{7}\times 2^{7}+y_{6}\times 2^{6}+y_{5}\times 2^{5}+\ldots +y_{1}\times 2^{1}+y_{0}\times 2^{0}$$
由此推出:
$$\begin{aligned}[X\times Y]_{\mbox{补}}&=[X\times (-y_{7}\times 2^{7}+y_{6}\times 2^{6}+\ldots +y_{1}\times 2^{1}+y_{0}\times 2^{0})]_{\mbox{补}} \\ &=[X\times -y_{7}\times 2^{7}+X\times y_{6}\times 2^{6}+\ldots +X\times y_{1}\times 2^{1}+X\times y_{0}\times 2^{0}]_{\mbox{补}}\end{aligned}$$
根据补码加法具有的特性,有:
$$[X\times Y]_{\mbox{补}}=[X\times -y_{7}\times 2^{7}]_{\mbox{补}}+[X\times y_{6}\times 2^{6}]_{\mbox{补}}+\ldots +[X\times y_{0}\times 2^{0}]_{\mbox{补}}$$
需要注意这个公式中位于方括号外的加法操作为补码加法而之前两个公式中位于方括号内部的加法为算术加法。由于y~i~只能取值为0或者1再根据补码减法的规则继续推导公式
$$[X\times Y]_{\mbox{补}}=-y_{7}\times [X\times 2^{7}]_{\mbox{补}}+y_{6}\times [X\times 2^{6}]_{\mbox{补}}+\ldots +y_{0}\times [X\times 2^{0}]_{\mbox{补}}$$
公式中最开头的减号是补码减法操作。为了继续运算,需要引入一个定理:
$$[X\times 2^{n}]_{\mbox{补}}=[X]_{\mbox{补}}\times 2^{n}$$
该定理的证明可以较容易地根据补码的定义得出,留作本章的课后习题。据此定理,补码乘法的公式可以继续推导如下:
$$\begin{aligned}[X\times Y]_{\mbox{补}}&=-[X]_{\mbox{补}}\times y_{7}\times 2^{7}+[X]_{\mbox{补}}\times y_{6}\times 2^{6}+\ldots +[X]_{\mbox{补}}\times y_{0}\times 2^{0}\\ &=[X]_{\mbox{补}}\times -y_{7}\times 2^{7}+y_{6}\times 2^{6}+\ldots +y_{0}\times 2^{0}\end{aligned}$$
最后得到的公式与移位加算法的原理很类似,但是存在两个重要区别:第一,本公式中的加法、减法均为补码运算;第二,最后一次被累加的部分积需要使用补码减法来操作。这就意味着[X]~补~×[Y]~补~不等于[X×Y]~补~。图\@ref(fig:complement-MUL)给出两个4位补码相乘的例子。注意在补码加法运算中需要进行8位的符号位扩展并仅保留8位结果。
```{r complement-MUL, fig.cap='补码乘法计算示例', fig.align='center', echo = FALSE, out.width='70%'}
knitr::include_graphics('./images/chapter8/complement_multiplication.png')
```
简单地修改之前的迭代式硬件原码乘法器,就可以实现补码乘法,如图\@ref(fig:iter-complement-MUL)所示。
```{r iter-complement-MUL, fig.cap='迭代式硬件补码乘法器', fig.align='center', echo = FALSE, out.width='70%'}
knitr::include_graphics('./images/chapter8/iterative_complement_multipilier.png')
```
依此方法也可以计算32位数、64位数的补码乘法。运算数据更宽的乘法需要更多的时钟周期来完成。
### Booth乘法器
Booth乘法器由英国的Booth夫妇提出。按照8.3.1小节中的补码乘法算法需要特地挑出第N个部分积并使用补码减法操作这就需要实现一个额外的状态机来控制增加了硬件设计复杂度。因此他们对补码乘法公式进行变换试图找到更适合于硬件实现的算法。
Booth一位乘变换的公式推导如下
$$\begin{aligned}&\quad-y_{7}\times 2^{7}+y_{6}\times 2……{6}+\ldots +y_{1}\times 2^{1}+y_{0}\times 2^{0}\\
&=-y_{7}\times 2^{7}+y_{6}\times 2^{7}-y_{6}\times 2^{6}+y_{5}\times 2^{6}-y_{5}\times 2^{5}+\ldots + \\ &\quady_{1}\times 2^{2}-y_{1}\times 2^{1}+y_{0}\times 2^{1}-y_{0}\times 2^{0}+0\times 2^{0}\\
&=y_{6}-y_{7}\times 2^{7}+y_{5}-y_{6}\times 2^{6}+\ldots +y_{0}-y_{1}\times 2^{1}+y_{-1}-y_{0}\times 2^{0}\end{aligned}$$
其中y~-1~取值为0。经过变换公式变得更加规整不再需要专门对最后一次部分积采用补码减法更适合硬件实现。这个新公式被称为Booth一位乘算法。
为了实现Booth一位乘算法需要根据乘数的最末两位来确定如何将被乘数累加到结果中再将乘数和被乘数移一位。根据算法公式很容易得出它的规则如表\@ref(tab:booth-one-mul-rule)所示。
```{r booth-one-mul-rule, echo = FALSE, message=FALSE, tab.cap='Booth一位乘运算规则'}
readr::read_csv('./materials/chapter8/booth_one_rule.csv') %>%
flextable() %>%
colformat_md(part='all') %>%
merge_h(part='header') %>%
vline(i = NULL, j = 2) %>%
#hline_top(part='all') %>%
align(i = NULL, j = NULL, align = "center", part = "all") %>%
autofit()
```
注意算法开始时要隐含地在乘数最右侧补一个y-1的值。图\@ref(fig:Booth-1-mul)给出了Booth一位乘算法的示例。
```{r Booth-1-mul, fig.cap='Booth一位乘示例', fig.align='center', echo = FALSE, out.width='80%'}
knitr::include_graphics('./images/chapter8/booth_one_multiplication.png')
```
在Booth一位乘算法中为了计算N位的补码乘法依然需要N-1次加法。而数据宽度较大的补码加法器面积大、电路延迟长限制了硬件乘法器的计算速度因此重新对补码乘法公式进行变换得到Booth两位乘算法
$$\begin{aligned}&\quad-y_{7}\times 2^{7}+y_{6}\times 2^{6}+\ldots +y_{1}\times 2^{1}+y_{0}\times 2^{0}\\
&=-2\times y_{7}\times 2^{6}+y_{6}\times 2^{6}+y_{5}\times 2^{6}-2\times y_{5}\times 2^{4}+\ldots \\
&\quad +y_{1}\times 2^{2}-2\times y_{1}\times 2^{0}+y_{0}\times 2^{0}+y_{-1}\times 2^{0}\\
&=y_{5}+y_{6}-2y_{7}\times 2^{6}+y_{3}+y_{4}-2y_{5}\times 2^{4}+\ldots +y_{-1}+y_{0}-2y_{1}\times 2^{0}\end{aligned}$$
根据Booth两位乘算法需要每次扫描3位的乘数并在每次累加完成后将被乘数和乘数移2位。根据算法公式可以推导出操作的方式参见表\@ref(tab:booth-two-mul-rule)。注意被扫描的3位是当前操作阶数i加上其左右各1位。因此操作开始时需要在乘数最右侧隐含地补一个0。
```{r booth-two-mul-rule, echo = FALSE, message=FALSE, tab.cap='Booth两位乘运算规则'}
readr::read_csv('./materials/chapter8/booth_two_rule.csv') %>%
flextable() %>%
colformat_md(part='all') %>%
merge_h(part='header') %>%
vline(i = NULL, j = 3) %>%
#hline_top(part='all') %>%
align(i = NULL, j = NULL, align = "center", part = "all") %>%
autofit()
```
还是以4位补码乘法为例如图\@ref(fig:Booth-2-mul)所示。
```{r Booth-2-mul, fig.cap='Booth两位乘示例', fig.align='center', echo = FALSE, out.width='80%'}
knitr::include_graphics('./images/chapter8/Booth_two_multiplication.png')
```
如果使用Booth两位乘算法计算N位的补码乘法时只需要N/2-1次加法如果使用移位加策略则需要N/2个时钟周期来完成计算。龙芯处理器就采用了Booth两位乘算法来实现硬件补码乘法器大多数现代处理器也均采用该算法。
同理可以推导Booth三位乘算法、Booth四位乘算法。其中Booth三位乘算法的核心部分为
$$y_{i-1}+y_{i}+2y_{i+1}-4y_{i+2}\times 2^{i} (i=0, 每次循环i+3)$$
对于Booth三位乘而言在扫描乘数低位时有可能出现补码加3倍[X]~补~的操作。不同于2倍[X]~补~可以直接通过将[X]~补~左移1位来实现3倍[X]~补~的值很难直接获得需要在主循环开始之前进行预处理算出3倍[X]~补~的值并使用额外的触发器记录下来。对于越复杂的Booth算法需要的预处理过程也越复杂。所以相比之下Booth两位乘算法更适合硬件实现更为实用。本节接下来将介绍这个算法的电路实现方式。
Booth乘法的核心是部分积的生成共需要生成N/2个部分积。每个部分积与[X]~补~相关,总共有-X、-2X、+X、+2X和0 五种可能,而其中减去[X]~补~的操作,可以视作加上按位取反的[X]~补~再末位加1。为了硬件实现方便将这个末位加1的操作提取出来假设[X]~补~的二进制格式写作x~7~x~6~x~5~x~4~x~3~x~2~x~1~x~0~,再假设部分积P等于p~7~p~6~p~5~p~4~p~3~p~2~p~1~p~0~+c那么有
$$p_{i}=
\begin{cases}
\sim x_{i}& \text{选择-X}\\
\sim x_{i-1}& \text{选择-2X}\\
x_{i}& \text{选择+X}\\
x_{i-1}& \text{选择+2X}\\
0& \text{选择0}
\end{cases}$$
$$c=
\begin{cases}
1& \text{选择-X或-2X}\\
0& \text{选择+X或+2X或0}
\end{cases}$$
当部分积的选择为2X时可以视作X输入左移1位此时p~i~就与x~i-1~相等。如果部分积的选择是-X或者-2X则此处对x~i~或者x~i-1~取反并设置最后的末位进位c为1。
根据上述规则经过卡诺图分析可以得出每一位p~i~的逻辑表达式:
$$p_{i}=\sim (\sim (S_{-X}\ \&\ \sim x_{i})\ \&\ \sim (S_{-2X}\ \&\ \sim x_{i-1})\ \&\ \sim (S_{+X}\ \&\ x_{i})\ \&\ \sim (S_{+2X}\ \&\ x_{i-1}))$$
其中S~+x~信号在部分积选择为+X时为1其他情况为0另外三个S信号含义类似。画出p~i~的逻辑图,如图\@ref(fig:Booth-select-logic)所示。
```{r Booth-select-logic, fig.cap='Booth结果选择逻辑', fig.align='center', echo = FALSE, out.width='80%'}
knitr::include_graphics('./images/chapter8/Booth_select_logic.png')
```
下文将使用图中箭头右侧的小示意图来代表p~i~的生成逻辑。生成逻辑中需要使用部分积选择信号因此还需要考虑如何根据y~i-1~、y~i~和y~i+1~三个信号生成图\@ref(fig:Booth-selectsignal)用到的4个选择信号。根据表\@ref(tab:booth-two-mul-rule)中的规则,很容易通过卡诺图化简得到:
$$\begin{aligned}S_{-x}&=\sim (\simy_{i+1}\ \&\ y_{i}\ \&\ \sim y_{i-1})\ \&\ \sim (y_{i+1}\ \&\ \sim y_{i}\ \&\ y_{i-1})) \\ S_{+x}&=\sim (\sim\sim y_{i+1}\ \&\ y_{i}\ \&\ \sim y_{i-1})\ \&\ \sim (\sim y_{i+1}\ \&\ \sim y_{i}\ \&\ y_{i-1})) \\ S_{-2x}&=\sim (\sim (y_{i+1}\ \&\ \sim y_{i}\ \&\ \sim y_{i-1})) \\ S_{+2x}&=\sim (\sim (\sim y_{i+1}\ \&\ y_{i}\ \&\ y_{i-1}))\end{aligned}$$
画出选择信号生成部分的逻辑图,并得到如图\@ref(fig:Booth-selectsignal)所示的示意图。:
```{r Booth-selectsignal, fig.cap='Booth选择信号生成逻辑', fig.align='center', echo = FALSE, out.width='80%'}
knitr::include_graphics('./images/chapter8/Booth_selectsignal_logic.png')
```
将两部分组合起来形成每个Booth部分积的逻辑图并得到如图\@ref(fig:Booth-partial-product)所示的示意图。
```{r Booth-partial-product, fig.cap='Booth部分积生成逻辑', fig.align='center', echo = FALSE, out.width='80%'}
knitr::include_graphics('./images/chapter8/Booth_partial_product.png')
```
这个逻辑就是两位Booth乘法的核心逻辑。调用该逻辑并通过移位加策略实现两位Booth补码乘的结构如图\@ref(fig:Booth-SHIFT)所示。
```{r Booth-SHIFT, fig.cap='使用移位加实现Booth乘法', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter8/Booth_multiplication with shifter.png')
```
乘法操作开始时乘数右侧需要补1位的0而结果需要预置为全0。在每个时钟周期的计算结束后乘数算术右移2位而被乘数左移2位直到乘数为全0时乘法结束。对于N位数的补码乘法操作可以在N/2个时钟周期内完成并有可能提前结束。在这个结构中被乘数、结果、加法器和Booth核心的宽度都为2N位。
### 华莱士树
即使采用了Booth两位乘算法使用移位加策略来完成一个64位的乘法操作也需要32个时钟周期并且不支持流水操作即第一条乘法全部完成之后才能开始计算下一条。现代处理器通常可以实现全流水、4个时钟周期延迟的定点乘法指令其核心思想就是将各个部分积并行地加在一起而非串行迭代累加。
以64位数据的乘法为例共有32个部分积如果按照二叉树方式来搭建加法结构第一拍执行16个加法第二拍执行8个加法以此类推就可以在5个时钟周期内结束运算。这个设计还支持流水式操作当上一条乘法指令到达第二级此时第一级的16个加法器已经空闲可以用来服务下一条乘法指令了。
这种设计的硬件开销非常大其中128位宽度的加法器就需要31个而用于锁存中间结果的触发器更是接近4000个。本节将要介绍的华莱士树Wallace Tree结构可以大幅降低多个数相加的硬件开销和延迟。
```{r 1bit-full-adder, fig.cap='一位全加器示例', fig.align='center', echo = FALSE, out.width='30%'}
knitr::include_graphics('./images/chapter8/1bit_full_adder.png')
```
华莱士树由全加器搭建而成。根据8.2.1节的介绍,全加器的示例如图\@ref(fig:1bit-full-adder)所示。
$$\begin{aligned}S&=\sim A\ \&\ \sim B\ \&\ C\ |\ \sim A\ \&\ B\ \&\ \sim C\ |\ A\ \&\ \sim B\ \&\ \sim C\ |\ A\ \&\ B\ \&\ C \\ C&=A \ \&\ B\ |\ A\ \&\ C\ |\ B\ \&\ C\end{aligned}$$
全加器可以将3个1位数A、B、C的加法转换为两个1位数S和C的错位加法
A+B+C=S+(C≪1)
如果参与加法的数据较宽可以通过调用多个全加器将3个数的加法转换为两个数的加法。图\@ref(fig:4full-adder)给出了3个4位数相加的例子。
```{r 4full-adder, fig.cap='使用全加器实现3个4位数相加', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/FULL_adder for three 4bit number.png')
```
其中4位数A的二进制表示为A~3~A~2~A~1~A~0~,可以很容易得知:
$$\{A_{3}A_{2}A_{1}A_{0}\}+\{B_{3}B_{2}B_{1}B_{0}\}+\{D_{3}D_{2}D_{1}D_{0}\}=\{S_{3}S_{2}S_{1}S_{0}\}+\{C_{2}C_{1}C_{0}0\}$$
公式中所有加法都为补码加法操作宽度为4位结果也仅保留4位的宽度这也导致C~3~位没有被使用而是在C~0~右侧再补一个0参与补码加法运算。
那么问题来了如果需要相加的数有4个又应该如何呢很自然地想到可以先将其中3个数相加再调用一层全加器结构将刚得到的结果与第4个数相加即可。不过要注意全加器的C输出需要左移1位才能继续参与运算。如图\@ref(fig:full-adder-for4number)所示。
```{r full-adder-for4number, fig.cap='使用全加器实现4个4位数相加', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/FULL_adder for four 4bit number.png')
```
最后结果中最高位进位C~3~和C~3'~都不会被使用。第二级的最右侧全加器需要在其中一个输入位置补0参与运算。从图\@ref(fig:full-adder-for4number)中可以看出整个结构呈现重复特征提取出圆角矩形框选中的部分这部分称为一位华莱士树。准确地说图中灰色部分呈现的是4个数相加的一位华莱士树结构它除了输入的4个被加数、输出的C与S之外还有级联的进位信号。通过M个这样的一位华莱士树就可以实现4个M位数的相加。
可以简单地计算一下使用华莱士树进行相加的优势。根据图\@ref(fig:full-adder-for4number)的结构4个数相加的华莱士树需要两层全加器当前位的进位信号在第一层产生并接到了下一位的第二层这意味着C~out~与C~in~无关。全加器的S输出需要3级门延迟而C输出需要2级门延迟因此不论参与加法的数据宽度是多少位将4个数相加转换为两个数相加最多只需要6级门延迟最后把这两个数加起来还需要一个加法器。整套逻辑需要一个加法器的面积再加上两倍数据宽度个全加器的面积。如果不使用华莱士树而是先将四个数捉对相加再把结果相加计算的延迟就是两倍的加法器延迟面积则是3倍的加法器面积。对于64位或者更宽的加法器它的延迟肯定是远远超过6级门的面积也比64个全加器要大得多。
因此使用华莱士树进行多个数相加可以明显地降低计算延迟数据宽度越宽其效果越明显。通过本节后续的介绍可以归纳出使用华莱士树进行M个N位数相加可以大致降低延迟logN倍而每一层华莱士树包含的全加器个数为⌊2M'/3⌋M'是当前层次要加的数字个数)。
回到本节最开始的问题Booth乘法需要实现N/2个2N宽度的部分积相加如果可以先画出N/2个数的一位华莱士树结构通过2N次使用就可以达到这个要求。为了描述的简洁下面我们具体分析N=16即8个数相加情况下的一位华莱士树结构如图\@ref(fig:1bit-wallace-tree-for8)所示。
```{r 1bit-wallace-tree-for8, fig.cap='8个数相加的一位华莱士树', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/1bit_wallace_tree_for8.png')
```
从图\@ref(fig:1bit-wallace-tree-for8)中可以看出通过华莱士树可以用4级全加器即12级门的延迟把8个数转换成两个数相加。华莱士树的精髓在于通过连线实现进位传递从而避免了复杂的进位传递逻辑。不过需要指出的是在华莱士树中每一级全加器生成本地和以及向高位的进位因此在每一级华莱士树生成的结果中凡是由全加器的进位生成的部分连接到下一级时要连接到下一级的高位。像图\@ref(fig:wrong-wallace-tree)中左侧的搭建方法就是没有保证这一点,所以是错误的。
```{r wrong-wallace-tree, fig.cap='两种错误的8个数相加的一位华莱士树', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/WORONG_exaxple of walllace tree.png')
```
图\@ref(fig:wrong-wallace-tree)右侧的搭建方式修正了图左侧级间进位传递逻辑的错误,但是它的搭建方式依然存在问题。为了理解问题出在哪里,我们需要从整个乘法器的设计入手。
为了构成一个16位定点补码乘法器需要使用8个Booth编码器外加32个8个数相加的一位华莱士树再加上一个32位加法器。值得注意的是根据上一节提出的Booth乘法核心逻辑除了有8个部分积需要相加之外还有8个“末位加1”的信号。在华莱士树中最低位对应的华莱士树上有空闲的进位输入信号根据图\@ref(fig:1bit-wallace-tree-for8)的结构共有6个进位输入可以接上6个“末位加1”的信号。还剩下两个“末位加1”的信号只能去最后的加法器上想办法最后的加法器负责将华莱士树产生的2N位的C和S信号错位相加其中C信号左移一位低位补0。据此设计这两个“末位加1”的信号可以一个接到加法器的进位输入上另一个接到C左移后的补位上。分析到这里应该能够理解为什么说图\@ref(fig:1bit-wallace-tree-for8)中的华莱士树才是合适的因为这种搭建方法才能出现6个进位输入。
最终的乘法器示意图如图\@ref(fig:16bitmulti-unit)所示。
```{r 16bitmulti-unit, fig.cap='16位乘法器示意图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter8/16bit_multiplying_unit.png')
```
图中间标注为switch的部分负责收集8个Booth核心生成的8个32位数进行类似矩阵转置的操作重新组织为32组、8个一位数相加的格式输出给华莱士树并将Booth核心生成的8个“末位加1”信号从switch部分右侧接出提供给华莱士树最右侧的一位树及最后的加法器。此外图中没有画出的是被乘数X送到8个Booth编码器时需要先扩展到32位并按照编码器所处的位置进行不同偏移量的左移操作。
## 本章小结
本章首先回顾了计算机中数的二进制编码及定点数和浮点数的表示介绍了晶体管原理以及由晶体管构建的基本逻辑电路。然后介绍了CPU中简单运算器设计时常见的加法、减法、比较和移位运算的实现重点是补码加法器的设计实现。最后介绍了定点补码乘法器的设计重点是补码乘法的规则、两位Booth算法和华莱士树。
## 习题
1. 请将下列无符号数据在不同的进制表达间进行转换。
a) 二进制转换为十进制101011~2~、001101~2~、01011010~2~、0000111010000101~2~。
b) 十进制转换为二进制42~10~、79~10~、811~10~、374~10~。
c) 十六进制转换为十进制8AE~16~、C18D~16~、B379~16~、100~16~。
d) 十进制转换为十六进制81783~10~、1922~10~、345208~10~、5756~10~。
2. 请给出32位二进制数分别视作无符号数、原码、补码时所表示的数的范围。
3. 请将下列十进制数表示为8位原码和8位补码或者表明该数据会溢出45~10~、-59~10~、-128~10~、119~10~、127~10~、128~10~、0、-1~10~。
4. 请将下列数据分别视作原码和补码从8位扩展为16位00101100~2~、11010100~2~、10000001~2~、00010111~2~。
5. 请将下列浮点数在不同进制间进行转换。
a) 十进制数转换为单精度数0、116.25、-4.375。
b) 十进制数转换为双精度数:-0、116.25、-2049.5。
c) 单精度数转换为十进制数0xff800000、0x7fe00000。
d) 双精度数转换为十进制数0x8008000000000000、0x7065020000000000。
6. 请写出下图所示晶体管级电路图的真值表,并给出对应的逻辑表达式。
```{r hwCMOS, fig.align='center', echo = FALSE, out.width='40%'}
knitr::include_graphics('./images/chapter8/hwCMOS.png')
```
7. 请写出下图所示逻辑门电路图的真值表。
```{r hwlogicgate, fig.align='center', echo = FALSE, out.width='30%'}
knitr::include_graphics('./images/chapter8/hw logic gate.png')
```
8. 请用尽可能少的二输入NAND门搭建出一个具有二输入XOR功能的电路。
9. 请用D触发器和常见组合逻辑门搭建出一个具有同步复位为0功能的触发器的电路。
10. 证明[X+Y]~补~=[X]~补~+[Y]~补~。
11. 证明[X-Y]~补~=[X]~补~+[-Y]~补~。
12. 假设每个“非门”“与非门”“或非门”的扇入不超过4个且每个门的延迟为T请给出下列不同实现的32位加法器的延迟。
a) 行波进位加法器;
b) 4位一块且块内并行、块间串行的加法器
c) 4位一块且块内并行、块间并行的加法器。
13. 作为设计者,在什么情况下会使用行波进位加法器而非先行进位加法器?
14. 请利用图\@ref(fig:4bitCLAwithc)所示的4位先行进位逻辑组建出块内并行且块间并行的64位先行进位加法器的进位逻辑并证明其正确性。
15. 请举例说明[X×Y]~补~≠[X]~补~×[Y]~补~。
16. 请证明[X×2^n^]~补~=[X]~补~×2^n^。
17. 假设每个“非门” “与非门”“或非门”的扇入不超过4个且每个门的延迟为T请给出下列不同实现将4个16位数相加的延迟
a) 使用多个先行进位加法器;
b) 使用华莱士树及先行进位加法器。
18. 请系统描述采用两位Booth编码和华莱士树的补码乘法器是如何处理[-X]~补~和[-2X]~补~的部分积的。
19. 用Verilog语言设计一个32位输入宽度的定点补码乘法器要求使用Booth两位一乘和华莱士树。
20. 单精度和双精度浮点数能表示无理数$\pi$吗?为什么?

437
19-pipeline.Rmd Normal file
View File

@@ -0,0 +1,437 @@
# 指令流水线
本章介绍如何使用流水线来设计处理器。冯·诺依曼原理的计算机由控制器、运算器、存储器、输入设备和输出设备组成其中控制器和运算器合起来称为中央处理器俗称处理器或CPU。前一章重点介绍了ALU和乘法器的设计它们都属于运算器。本章介绍控制器并应用流水线技术搭建出高性能的处理器。
## 单周期处理器
本节先引入一个简单的CPU模型。这个CPU可以取指令并执行实现程序员的期望。根据第\@ref(sec-ISA)章的介绍指令系统按照功能可以分为运算指令、访存指令、转移指令和特殊指令四类。根据指令集的定义可以得知CPU的数据通路包括以下组成要素
1程序计数器又称PC指示当前指令的地址
2指令存储器按照指令地址存储指令码接收PC读出指令
3译码部件用于分析指令判定指令类别
4通用寄存器堆用于承载寄存器的值绝大多数指令都需要读取及修改寄存器
5运算器用于执行指令所指示的运算操作
6数据存储器按照地址存储数据主要用于访存指令。
将这些组成要素通过一定规则连接起来就形成了CPU的数据通路。图\@ref(fig:chapter9-simpleCPUdatapath)给出了这个简单CPU的数据通路。
```{r chapter9-simpleCPUdatapath, fig.cap='简单CPU的数据通路', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter9/simpleCPUdatapath.png')
```
数据通路上各组成要素间的具体连接规则如下根据PC从指令存储器中取出指令然后是译码部件解析出相关控制信号并读取通用寄存器堆运算器对通用寄存器堆读出的操作数进行计算得到计算指令的结果写回通用寄存器堆或者得到访存指令的地址或者得到转移指令的跳转目标load指令访问数据存储器后需要将结果写回通用寄存器堆。通用寄存器堆写入数据在计算结果和访存结果之间二选一。由于有控制流指令的存在因此新指令的PC既可能等于顺序下一条指令的PC当前指令PC加4也可能来自转移指令计算出的跳转目标。
译码部件在这个数据通路中有非常重要的作用。译码部件要识别不同的指令并根据指令要求控制读取哪些通用寄存器、执行何种运算、是否要读写数据存储器、写哪个通用寄存器以及根据控制流指令来决定PC的更新。这些信息从指令码中获得传递到整个处理器中控制了处理器的运行。根据LoongArch指令的编码格式可以将指令译码为op、src1、src2、src3、dest和imm几个部分示例见图\@ref(fig:chapter9-decode)。
```{r chapter9-decode, fig.cap='译码功能示意', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/decode.png')
```
图\@ref(fig:chapter9-datapathWithClk)展示了带控制逻辑的数据通路图中虚线是新加入的控制逻辑。此外还加入了时钟和复位信号。引入时钟是因为更新PC触发器、写通用寄存器以及store类访存指令写数据存储器时都需要时钟。而引入复位信号是为了确保处理器每次上电后都是从相同位置取回第一条指令。数据通路再加上这些逻辑就构成了处理器。
```{r chapter9-datapathWithClk, fig.cap='带有时序控制逻辑的数据通路', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/datapathWithClk.png')
```
简要描述一下这个处理器的执行过程:
1复位信号将复位的PC装载到PC触发器内之后的第一个时钟周期内使用PC取指、译码、执行、读数据存储器、生成结果
2当第二个时钟周期上升沿到来时根据时序逻辑的特性将新的PC锁存将上一个时钟周期的结果写入寄存器堆执行可能的数据存储器写操作
3第二个时钟周期内就可以执行第二条指令了同样按照上面两步来执行。
依此类推,由一系列指令构成的程序就在处理器中执行了。由于每条指令的执行基本在一拍内完成,因此这个模型被称为单周期处理器。
## 流水线处理器 {#sec-pipeline-cpu}
根据\@ref(sec-MOS-principle)小节给出的CMOS电路延迟的介绍当电路中组合逻辑部分延迟增大时整个电路的频率就会变低。在上一节的单周期处理器模型中每个时钟周期必须完成取指、译码、读寄存器、执行、访存等很多组合逻辑工作为了保证在下一个时钟上升沿到来之前准备好寄存器堆的写数据需要将每个时钟周期的间隔拉长导致处理器的主频无法提高。使用流水线技术可以提高处理器的主频。在引入流水线技术之前先介绍一下多周期处理器的概念。
在单周期处理器中每个时钟周期内执行的功能可以比较明显地进行划分。举例而言按照取指、译码并读寄存器、执行、访存和准备写回划分为5个阶段。如果我们在每段操作前后加上触发器看起来就能减少每个时钟周期的工作量提高处理器频率。在图\@ref(fig:chapter9-multicycle)中,加粗框线的是触发器。
```{r chapter9-multicycle, fig.cap='多周期处理器的结构图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/multicycle.png')
```
为了清晰图中省略了控制逻辑的部分连线没有画出通用寄存器和数据存储器的写入时钟。先将原始时钟接到所有的触发器按照这个示意图设计的处理器是否可以使用呢按照时序逻辑特性每个时钟上升沿触发器的值就会变成其驱动端D端口的新值因此推算一下
1在第1个时钟周期通过PC取出指令在第2个时钟上升沿锁存到指令码触发器R1
2在第2个时钟周期将R1译码并生成控制逻辑读取通用寄存器读出结果在第3个时钟上升沿锁存到触发器R2
3在第3个时钟周期使用控制逻辑和R2进行ALU运算。
推算到这里就会发现此时离控制逻辑的生成第2个时钟周期已经隔了一个时钟周期了怎么保证这时候控制逻辑还没有发生变化呢
使用分频时钟或门控时钟可以做到这一点。如图\@ref(fig:chapter9-multicycle)右下方所示将原始的时钟通过分频的方式产生出5个时钟分别控制图中PC、R1\~R4这5组触发器。这样在进行ALU运算时可以保证触发器R1没有接收到下一个时钟上升沿故不可能变化因此可以进行正确的ALU运算。同理包括写寄存器、执行访存等都受到正确的控制。
经过推算,可以将这种处理器执行指令时的指令-时钟周期的对照图画出来,如\@ref(fig:chapter9-multicycleflow)。这种图可以被称为处理器执行的时空图,也被称为流水线图。画出流水线图是分析处理器行为的直观、有效的方法。
```{r chapter9-multicycleflow, fig.cap='多周期处理器的流水线时空图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/multicycleflow.png')
```
这种增加触发器并采用分频时钟的处理器模型称为多周期处理器。多周期处理器设计可以提高运行频率但是每条指令的执行时间并不能降低考虑到触发器的Setup时间和Clk-to-Q延迟则执行时间会增加。我们可以将各个执行阶段以流水方式组织起来同一时刻不同指令的不同执行阶段流水线中的“阶段”也称为“级”重叠在一起进一步提高CPU执行效率。
从多周期处理器演进到流水线处理器,核心在于控制逻辑和数据通路对应关系维护机制的变化。多周期处理器通过使用分频时钟,可以确保在同一条指令的后面几个时钟周期执行时,控制逻辑因没有接收到下一个时钟上升沿所以不会发生变化。流水线处理器则通过另一个方法来保证这一点,就是在每级流水的触发器旁边,再添加一批用于存储控制逻辑的触发器。指令的控制逻辑藉由这些触发器沿着流水线逐级传递下去,从而保证了各阶段执行时使用的控制逻辑都是属于该指令的,如\@ref(fig:chapter9-pipelinestruct)所示。
```{r chapter9-pipelinestruct, fig.cap='流水线处理器的结构图', fig.align='center', echo = FALSE, out.width='80%'}
knitr::include_graphics('./images/chapter9/pipelinestruct.png')
```
从图\@ref(fig:chapter9-pipelinestruct)中的虚线可以看出控制运算器进行计算的信息来自控制逻辑2即锁存过一次的控制逻辑刚好与R2中存储的运算值同属一条指令。图中取消了R3阶段写通用寄存器的通路而是将R3的内容锁存一个时钟周期统一使用控制逻辑4和R4来写。
可以先设计几条简单指令,画出时空图,看看这个新的处理器是如何运行的。示例见图\@ref(fig:chapter9-pipelineflow)。
```{r chapter9-pipelineflow, fig.cap='流水线处理器的流水线时空图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/pipelineflow.png')
```
要记得图中R2、R3和R4实际上还包括各自对应的控制逻辑触发器所以到下一个时钟周期后当前部件及对应触发器已经不再需要给上一条指令服务新的指令才可以在下一个时钟周期立即占据当前的触发器。
如果从每个处理器部件的角度,也可以画出另一个时空图,见图\@ref(fig:chapter9-componentflow)。图中不同下标的I代表不同的指令。
```{r chapter9-componentflow, fig.cap='处理器部件时空图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/componentflow.png')
```
从这个角度看过去处理器的工作方式就像一个5人分工合作的加工厂每个工人做完自己的部分将自己手头的工作交给下一个工人并取得一个新的工作这样可以让每个工人都一直处于工作状态。这种工作方式被称为流水线采用这种模型的处理器被称为流水线处理器。
## 指令相关和流水线冲突 {#sec-hazard}
前面设计的流水线处理器在执行图\@ref(fig:chapter9-pipelineflow)中所示的简单指令序列时可以很顺畅每个时钟周期都能执行完一条指令。但是程序中的指令序列并不总是这么简单通常会存在指令间的相关这就有可能导致流水线处理器执行出错。举例来说对于“add.w \$r2, \$r1, \$r1; add.w \$r3, \$r2, \$r2”这个指令序列第1条指令将结果写入r2寄存器第2条指令再用r2寄存器的值进行计算。在前面设计的5级静态流水线处理器中第1条指令在第5级写回阶段才把结果写回到寄存器但是第2条指令在第2级译码阶段此时第1条指令尚在第3级执行阶段就已经在读寄存器的值了所以第2条指令读取的是r2寄存器的旧值从而造成了运算结果错误。因此本节将重点探讨如何在流水线处理器结构设计中处理好指令相关从而保证程序的正确执行。
指令间的相关可以分为3类数据相关、控制相关和结构相关。在程序中如果两条指令访问同一个寄存器或内存单元而且这两条指令中至少有1条是写该寄存器或内存单元的指令那么这两条指令之间就存在数据相关。上面举的例子就是一种数据相关。如果两条指令中一条是转移指令且另一条指令是否被执行取决于该转移指令的执行结果则这两条指令之间存在控制相关。如果两条指令使用同一份硬件资源则这两条指令之间存在结构相关。
在程序中,指令间的相关是普遍存在的。这些相关给指令增加了一个序关系,要求指令的执行必须满足这些序关系,否则执行的结果就会出错。为了保证程序的正确执行,处理器结构设计必须满足这些序关系。指令间的序关系有些是很容易满足的,例如两条相关的指令之间隔得足够远,后面的指令开始取指执行时前面的指令早就执行完了,那么处理器结构设计就不用做特殊处理。但是如果两条相关的指令挨得很近,尤其是都在指令流水线的不同阶段时,就需要用结构设计来保证这两条指令在执行时满足它们的相关关系。
相关的指令在一个具体的处理器结构中执行时可能会导致冲突hazard。例如本节开头所举例子中数据相关指令序列在5级静态流水线处理器中执行时碰到的读数时机早于写数的情况就是一个冲突。下面将具体分析5级静态流水线处理器中存在的冲突及其解决办法。
### 数据相关引发的冲突及解决办法
数据相关根据冲突访问读和写的次序可以分为3种。第1种是写后读Read After Write,简称RAW相关即后面指令要用到前面指令所写的数据也称为真相关。第2种是写后写Write After Write,简称WAW相关即两条指令写同一个单元也称为输出相关。第3种是读后写Write After Read,简称WAR相关即后面的指令覆盖前面指令所读的单元也称为反相关。在\@ref(sec-pipeline-cpu)节所介绍的5级简单流水线中只有RAW相关会引起流水线冲突WAR相关和WAW相关不会引起流水线冲突。但是在\@ref(sec-dynamic)节中将要介绍的乱序执行流水线中WAR相关和WAW相关也有可能引起流水线冲突。
下面重点分析RAW相关所引起的流水线冲突并讨论其解决方法。对于如下指令序列
add.w \$r2, \$r1, \$r1
add.w \$r3, \$r2, \$r2
ld.w \$r4, \$r3, 0
add.w \$r5, \$r4, \$r4
其中第1、2条指令间第2、3条指令间第3、4条指令间存在RAW相关。这3条指令在\@ref(sec-pipeline-cpu)节所介绍的5级简单流水线处理器中执行的流水线时空图如图\@ref(fig:chapter9-raw)所示。
```{r chapter9-raw, fig.cap='RAW数据相关的流水线时空图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/raw.png')
```
图\@ref(fig:chapter9-raw)中从第1条指令的写回阶段指向第2条指令的译码阶段的箭头以及从第2条指令的写回阶段指向第3条指令的译码阶段的箭头都表示RAW相关会引起冲突。这是因为如果第2条指令要使用第1条指令写回到寄存器的结果就必须保证第2条指令读取寄存器的时候第1条指令的结果已经写回到寄存器中了而现有的5级流水线结构如果不加控制第2条指令就会在第1条指令写回寄存器之前读取寄存器从而引发数据错误。为了保证执行的正确一种最直接的解决方式是让第2条指令在译码阶段等待阻塞3拍直到第1条指令将结果写入寄存器后才能读取寄存器进入后续的执行阶段。同样的方式亦适用于第2、3条指令之间和第3、4条指令之间。采用阻塞解决数据相关的流水线时空图如图\@ref(fig:chapter9-stallflow)所示。
```{r chapter9-stallflow, fig.cap='用阻塞解决数据相关的流水线时空图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/stallflow.png')
```
阻塞功能在处理器流水线中的具体电路实现是将被阻塞流水级所在的寄存器保持原值不变同时向被阻塞流水级的下一级流水级输入指令无效信号用流水线空泡Bubble填充。对于图\@ref(fig:chapter9-stallflow)所示的流水线阻塞,从每个处理器部件的角度所看到的时空图如图\@ref(fig:chapter9-componentflowWithStall)所示。
```{r chapter9-componentflowWithStall, fig.cap='有阻塞的处理器部件时空图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/componentflowWithStall.png')
```
#### 流水线前递技术
采用阻塞的方式虽然能够解决RAW相关所引发的流水线冲突但是阻塞势必引起流水线执行效率的降低为此需要更为高效的解决方式。继续分析前面所举的例子可以发现第2条指令位于译码阶段的时候虽然它所需要的第1条指令的结果还不在寄存器中但是这个值已经在流水线的执行阶段计算出来了那么何必非要等着这个值沿着流水线一级一级送下去写入寄存器后再从寄存器中读出呢直接把这个值取过来用不也是可行的吗顺着这个思路就产生了流水线前递Forwarding技术。其具体实现是在流水线中读取指令源操作数的地方通过多路选择器直接把前面指令的运算结果作为后面指令的输入。考虑到加法指令在执行级就完成了运算因此能够设计一条通路将这个结果前递至读寄存器的地方即有一条从执行级到译码级的前递通路。除此之外还可以依次添加从访存级、写回级到译码级的前递通路。新的流水线时空图如图\@ref(fig:chapter9-forwarding)所示。
```{r chapter9-forwarding, fig.cap='加入前递的数据相关时空图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/forwarding.png')
```
可以看出加入前递技术之后执行这4条指令的性能有大幅提高。
通过前面对于指令相关的分析,我们需要在处理器中加入阻塞流水线的控制逻辑以及前递通路。演进后的处理器结构如图\@ref(fig:chapter9-instHazardPipeline)所示。为了表达清晰,图中省略了时钟信号到每组触发器的连接线。
```{r chapter9-instHazardPipeline, fig.cap='处理指令相关的流水线结构图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/instHazardPipeline.png')
```
图\@ref(fig:chapter9-instHazardPipeline)中虚线框中是新加入的逻辑。为了解决数据相关加入了寄存器相关判断逻辑收集当前流水线中处于执行、访存及写回级的最多3条指令的目的寄存器信息与译码级的源寄存器比较并根据比较结果决定是否阻塞译码级R1为了解决控制相关加入了译码级和执行级能够修改PC级有效位的通路为了解决结构相关加入了译码级到PC级的阻塞控制逻辑为了支持前递加入了从执行级、访存级到译码级的数据通路并使用寄存器相关判断逻辑来控制如何前递。可以看出大多数机制都加在了前两级流水线上。
### 控制相关引发冲突及解决方法 {#sec-control-hazard}
控制相关引发的冲突本质上是对程序计数器PC的冲突访问引起的。图\@ref(fig:chapter9-ctrlHazard)中的箭头即表示控制相关所引发的冲突。
```{r chapter9-ctrlHazard, fig.cap='控制相关示意图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/ctrlHazard.png')
```
按照图\@ref(fig:chapter9-pipelinestruct)给出的处理器设计执行阶段R2触发器所存储的值经过计算之后才能给出转移指令的正确目标并在下一个时钟上升沿更新PC但是图\@ref(fig:chapter9-instHazardPipeline)中转移指令尚未执行结束时PC已经更新完毕并取指了从而可能导致取回了错误的指令。为了解决这个问题可以通过在取指阶段引入2拍的流水线阻塞来解决如图\@ref(fig:chapter9-ctrlHazardFlow)所示。
```{r chapter9-ctrlHazardFlow, fig.cap='解决控制相关的流水线结构图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/ctrlHazardFlow.png')
```
在单发射5级静态流水线中如果增加专用的运算资源将转移指令条件判断和计算下一条指令PC的处理调整到译码阶段那么转移指令后面的指令只需要在取指阶段等1拍。采用这种解决控制相关的方式继续改进流水线处理器结构得到如图\@ref(fig:chapter9-ctrlHazardStruct)所示的结构设计。
```{r chapter9-ctrlHazardStruct, fig.cap='处理控制相关的流水线结构图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/ctrlHazardStruct.png')
```
为更进一步减少由控制相关引起的阻塞可以采用转移指令的延迟槽技术在定义指令系统的时候就明确转移指令延迟槽指令的执行不依赖于转移指令的结果这样转移指令后面的指令在取指阶段1拍也不用等。总之在单发射5级静态流水线处理器中通过在译码阶段对转移指令进行处理和利用转移指令延迟槽技术就可以避免控制相关引起的流水线阻塞。但是这两项技术并不一定适用于其他结构后面\@ref(sec-branch-predict)小节讨论转移预测技术时将做进一步分析。
### 结构相关引发冲突及解决办法
结构相关引起冲突的原因是两条指令要同时访问流水线中的同一个功能部件。回顾前面图\@ref(fig:chapter9-stallflow)中所示的指令序列执行情况由于流水线中只有一个译码部件所以第3条指令因为结构相关在第7个时钟周期之前不能进入译码阶段否则就将覆盖第2条指令的信息导致第2条指令无法正确执行。同样可以看到不存在任何数据相关的第4条指令由于存在结构相关也被多次阻塞甚至被堵得还无法进入取指阶段。
## 流水线与异常处理 {#sec-precise-exception}
这里简要介绍一下如何在流水线处理器中支持异常处理。在第\@ref(sec-privileged-ISA)章曾介绍过,异常产生的来源包括:外部事件、指令执行中的错误、数据完整性问题、地址转换异常、系统调用和陷入以及需要软件修正的运算等。在流水线处理器中,这些不同类型的异常可能在流水线的不同阶段产生。例如访存地址错异常可以在取指阶段和访存阶段产生,保留指令异常和系统调用异常在译码阶段产生,整数溢出异常在执行阶段产生,而中断则可以在任何时候发生。
异常可以分为可恢复异常和不可恢复异常。不可恢复的异常通常发生在系统硬件出现了严重故障的时候,此时异常处理后系统通常面临重启,所以处理器响应不可恢复异常的机制很简单,只要立即终止当前的执行,记录软件所需的信息然后跳转到异常处理入口即可。但是,可恢复异常的处理就比较难,要求做得非常精确,这也就是常常提到的精确异常概念。精确异常要求处理完异常之后,回到产生异常的地方接着执行,还能执行正确,就好像没有发生过异常一样。要达成这个效果,要求在处理异常时,发生异常的指令前面的所有指令都执行完(修改了机器状态),而发生异常的指令及其后面的指令都没有执行(没有修改机器状态)。
在流水线处理器中,同时会有多条指令处于不同阶段,不同阶段都有发生异常的可能,那么如何实现精确异常呢?这里给出一种可行的设计方案。为什么说是可行的以及结构设计该如何修改,作为课后作业留给同学们思考。
1任何一级流水发生异常时在流水线中记录下发生异常的事件直到写回阶段再处理。
2如果在执行阶段要修改机器状态如状态寄存器保存下来直到写回阶段再修改。
3指令的PC值随指令流水前进到写回阶段为异常处理专用。
4将外部中断作为取指的异常处理。
5指定一个通用寄存器或一个专用寄存器为异常处理时保存PC值专用。
6当发生异常的指令处在写回阶段时保存该指令的PC及必需的其他状态置取指的PC值为异常处理程序入口地址。
在前面3节的介绍中由简至繁地搭建出一个可以正常执行各种指令的流水线处理器。回顾设计过程其中的设计要点有两个第一是通过加入大量触发器实现了流水线功能第二是通过加入大量控制逻辑解决了指令相关问题。
## 提高流水线效率的技术
我们通常以应用的执行时间来衡量一款处理器的性能。应用的执行时间等于指令数乘以CPICycles Per Instruction每指令执行周期数再乘以时钟周期。当算法、程序、指令系统、编译器都确定之后一个应用的指令数就确定下来了。时钟周期与结构设计、电路设计、生产工艺以及工作环境都有关系不作为这里讨论的重点。我们主要关注CPI的降低即如何通过结构设计提高流水线效率。上一节中提到指令相关容易引起流水线的阻塞。因此流水线处理器实际的CPI等于指令的理想执行周期数加上由于指令相关引起的阻塞周期数
流水线CPI=理想CPI+结构相关阻塞周期数+RAW阻塞周期数+WAR阻塞周期数+WAW阻塞周期数+控制相关阻塞周期数
从上面的公式可知要想提高流水线效率即降低Pipeline CPI可以从降低理想CPI和降低各类流水线阻塞这些方面入手。
### 多发射数据通路
我们首先讨论如何降低理想CPI。最直观的方法就是让处理器中每级流水线都可以同时处理更多的指令这被称为多发射数据通路技术。例如双发射流水线意味着每一拍用PC从指令存储器中取两条指令在译码级同时进行两条指令的译码、读源寄存器操作还能同时执行两条指令的运算操作和访存操作并同时写回两条指令的结果。那么双发射流水线的理想CPI就从单发射流水线的1降至0.5。
要在处理器中支持多发射,首先就要将处理器中的各种资源翻倍,包括采用支持双端口的存储器。其次还要增加额外的阻塞判断逻辑,当同一个时钟周期执行的两条指令存在指令相关时,也需要进行阻塞。包括数据相关、控制相关和结构相关在内的阻塞机制都需要改动。我们来观察几条简单指令在双发射流水线中的时空图,如图\@ref(fig:chapter9-dualIssue)所示。
```{r chapter9-dualIssue, fig.cap='双发射处理器的流水线时空图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/dualIssue.png')
```
在图\@ref(fig:chapter9-dualIssue)中为了流水线控制的简化只有同一级流水线的两条指令都不被更早的指令阻塞时才能让这两条指令一起继续执行所以第6条指令触发了陪同阻塞。
多发射数据通路技术虽然从理论上而言可以大幅度降低处理器的CPI但是由于各类相关所引起的阻塞影响其实际执行效率是要大打折扣的。所以我们还要进一步从减少各类相关引起的阻塞这个方面入手来提高流水线的执行效率。
### 动态调度 {#sec-dynamic}
如果我们用道路交通来类比的话,多发射数据通路就类似于把马路从单车道改造为多车道,但是这个多车道的马路有个奇怪的景象——速度快的车(如跑车)不能超过前面速度慢的车(如马车),即使马车前面的车道是空闲的。直觉上我们肯定觉得这样做效率低,只要车道有空闲,就应该允许后面速度快的车超过前面速度慢的车。这就是动态调度的基本出发点。用本领域的概念来描述动态的基本思想就是:把相关的解决尽量往后拖延,同时前面指令的等待不影响后面指令继续前进。下面我们通过一个例子来加深理解:假定现在有一个双发射流水线,所有的运算单元都有两份,那么在执行下列指令序列时:
div.w \$r3, \$r2, \$r1
add.w \$r5, \$r4, \$r3
sub.w \$r8, \$r7, \$r6
由于除法单元采用迭代算法实现所以div.w指令需要多个执行周期与它有RAW相关的add.w指令最早也只能等到div.w指令执行完毕后才能开始执行。但是sub.w指令何时可以开始执行呢可以看到sub.w指令与前两条指令没有任何相关采用动态调度的流水线就允许sub.w指令越过前面尚未执行完毕的div.w指令和add.w指令提前开始执行。因为sub.w是在流水线由于指令间的相关引起阻塞而空闲的情况下“见缝插针”地提前执行了所以这段程序整体的执行延迟就减少了。
要完成上述功能,需要对原有的流水线做一些改动。首先,要将原有的译码阶段拆分成“译码”和“读操作数”两个阶段。译码阶段进行指令译码并检查结构相关,随后在读操作数阶段则一直等待直至操作数可以读取。处在等待状态的指令不能一直停留在原有的译码流水级上,因为这样它后面的指令就没法前进甚至是进入流水线,更不用说什么提前执行了。所以我们会利用一个结构存放这些等待的指令,这个结构被称为保留站,有的文献中也称之为发射队列,这是动态调度中必需的核心部件。除了存储指令的功能,保留站还要负责控制其中的指令何时去执行,因此保留站中还会记录下描述指令间相关关系的信息,同时监测各条指令的执行状态。如果指令是在进入保留站前读取寄存器,那么保留站还需要监听每条结果总线,获得源操作数的最新值。
保留站在处理器中的大致位置如图\@ref(fig:chapter9-dynamic)中的虚线框所示。保留站按指令来组织,每个指令占据一项,每一项有多个域,存放这个指令所需要的所有信息,包括有效位、就绪位异常信息、控制信息和寄存器源操作数。译码并读寄存器的指令进入保留站,保留站会每个时钟周期选择一条没有被阻塞的指令,送往执行逻辑,并退出保留站,这个动作称为“发射”。
```{r chapter9-dynamic, fig.cap='动态调度流水线结构示意', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/dynamic.png')
```
保留站调度算法的核心在于“挑选没有被阻塞的指令”。从保留站在流水线所处的位置来看,保留站中的指令不可能因为控制相关而被阻塞。结构相关所引起的阻塞的判定条件也是直观的,即检查有没有空闲的执行部件和空闲的发射端口。但是在数据相关所引起的阻塞的处理上,存在着不同的设计思路。
为了讨论清楚保留站如何处理数据相关所引起的阻塞,先回顾一下\@ref(sec-hazard)节关于RAW、WAR、WAW三种数据相关的介绍在那里我们曾提到在5级简单流水线上WAR和WAW两种数据相关不会产生冲突但是在动态调度的情况下就可能会产生。下面来看两个例子。例如下面的指令序列
div.w \$r3, \$r2, \$r1
add.w \$r5, \$r4, \$r3
sub.w \$r4, \$r7, \$r6
add.w指令和sub.w指令之间存在WAR相关在乱序调度的情况下sub.w指令自身的源操作数不依赖于div.w和add.w指令可以读取操作数执行得到正确的结果。那么这个结果能否在执行结束后就立即写入寄存器呢回答是否定的。假设sub.w执行完毕的时候add.w指令因为等待div.w指令的结果还没有开始执行那么sub.w指令如果在这个时候就修改了\$r4寄存器的值那么等到add.w开始执行时就会产生错误的结果。
WAW相关的例子与上面WAR相关的例子很相似如下面的指令序列
div.w \$r3, \$r2, \$r1
add.w \$r5, \$r4, \$r3
sub.w \$r5, \$r7, \$r6
add.w指令和sub.w指令之间存在WAW相关在乱序调度的情况下sub.w指令可以先于add.w指令执行如果sub.w执行完毕的时候add.w指令因为等待div.w指令的结果还没有开始执行那么sub.w指令若是在这个时候就修改了\$r5寄存器的值那就会被add.w指令执行完写回的结果覆盖掉。从程序的角度看sub.w后面的指令读取\$r5寄存器会得到错误的结果。
上面的例子解释了WAR和WAW相关在动态调度流水线中是怎样产生冲突的。如何解决呢阻塞作为解决一切冲突的普适方法肯定是可行的。方法就是如果保留站判断出未发射的指令与前面尚未执行完毕的指令存在WAR和WAW相关就阻塞其发射直至冲突解决。历史上第一台采用动态调度流水线的CDC6000就是采用了这种解决思路称为记分板办法。
事实上WAR和WAW同RAW是有本质区别的它们并不是由程序中真正的数据依赖关系所引起的相关关系而仅仅是由于恰好使用具有同一个名字的寄存器所引起的名字相关。打个比方来说32项的寄存器文件就好比一个有32个储物格的储物柜每条指令把自己的结果数据放到一个储物格中然后后面的指令依照储物格的号寄存器名字从相应的格子中取出数据储物柜只是一个中转站问题的核心是要把数据从生产者传递到指定的消费者至于说这个数据通过哪个格子做中转并不是绝对的。WAR和WAW相关产生冲突意味着两对“生产者-消费者”之间恰好准备用同一个格子做中转,而且双方在“存放-取出”这个动作的操作时间上产生了重叠所以就引发了混乱。如果双方谁都不愿意等记分板的策略怎么办再找一个不受干扰的空闲格子后来的一方换用这个新格子做中转就不用等待了。这就是寄存器重命名技术。通过寄存器重命名技术可以消除WAR和WAW相关。例如存在WAR和WAW相关指令序列
div.w \$r3, \$r2, \$r1
add.w \$r5, \$r4, \$r3
sub.w \$r3, \$r7, \$r6
mul.w \$r9, \$r8, \$r3
可以通过寄存器重命名变为:
div.w \$r3,\$r2,\$r1
add.w \$r5,\$r4,\$r3
sub.w \$r3,\$r7,\$r6
mul.w \$r9,\$r8,\$r3
重命名之后就没有WAR和WAW相关了。
1966年,Robert Tomasulo在IBM 360/91中首次提出了对于动态调度处理器设计影响深远的Tomasulo算法。该算法在CDC6000记分板方法基础上做了进一步改进。面对RAW相关所引起的阻塞两者解决思路是一样的即将相关关系记录下来有相关的等待没有相关的尽早送到功能部件开始执行。但是Tomasulo算法实现了硬件的寄存器重命名从而消除了WAR和WAW相关也就自然不需要阻塞了。
在流水线中实现动态调度,还有最后一个需要考虑的问题——精确异常。回顾一下\@ref(sec-precise-exception)节中关于精确异常的描述,要求在处理异常时,发生异常的指令前面的所有指令都执行完(修改了机器状态),而发生异常的指令及其后面的指令都没有执行(没有修改机器状态)。那么在乱序调度的情况下,指令已经打破了原有的先后顺序在流水线中执行了,“前面”“后面”这样的顺序关系从哪里获得呢?还有一个问题,发生异常的指令后面的指令都不能修改机器的状态,但是这些指令说不定都已经越过发生异常的指令先去执行了,怎么办呢?
上面两个问题的解决方法是在流水线中添加一个重排序缓冲ROB来维护指令的有序结束同时在流水线中增加一个“提交”阶段。指令对机器状态的修改只有在到达提交阶段时才能生效软件可见处于写回阶段的指令不能真正地修改机器状态但可以更新并维护一个临时的软件不可见的机器状态。ROB是一个先进先出的有序队列所有指令在译码之后按程序顺序进入队列尾部所有执行完毕的执行从队列头部按序提交。提交时一旦发现有指令发生异常则ROB中该指令及其后面的指令都被清空。发生异常的指令出现在ROB头部时这条指令前面的指令都已经从ROB头部退出并提交了这些指令对于机器状态的修改都生效了异常指令和它后面的指令都因为清空而没有提交也就不会修改机器状态。这就满足了精确异常的要求。
总结一下实现动态调度后流水线各阶段的调整:
* 取指,不变。
* 译码, 译码拆分为译码和读操作数两个阶段。在读操作数阶段把操作队列的指令根据操作类型派送dispatch到保留站如果保留站以及ROB有空并在ROB中指定一项作为临时保存该指令结果之用保留站中的操作等待其所有源操作数就绪后即可以被挑选出来发射issue到功能部件执行发射过程中读寄存器的值和结果状态域如果结果状态域指出结果寄存器已被重命名到ROB则读ROB。
* 执行,不变。
* 写回。把结果送到结果总线释放保留站ROB根据结果总线修改相应项。
* 提交。如果队列中第一条指令的结果已经写回且没有发生异常把该指令的结果从ROB写回到寄存器或存储器释放ROB的相应项如果队列头的指令发生了异常或者转移指令猜测错误清除操作队列以及ROB等。
### 转移预测 {#sec-branch-predict}
因转移指令而引起的控制相关也会造成流水线的阻塞。在前面\@ref(sec-control-hazard)小节中曾指出通过将转移指令处理放到译码阶段和转移指令延迟槽两项技术可以在单发射5级静态流水线中无阻塞地解决控制相关所引起的冲突。但是这种解决控制相关所引起的冲突的方式并不是普适的。比如当为了提高处理器的主频而将取指阶段的流水级做进一步切分后或者是采用多发射数据通路设计后仅有1条延迟槽指令是无法消除流水线阻塞的。
正常的应用程序中转移指令出现十分频繁通常平均每5\~10条指令中就有一条是转移指令而且多发射结构进一步加速了流水线遇到转移指令的频率。例如假设一个程序平均8条指令中有一条转移指令那么在单发射情况下平均8拍才遇到1条转移指令而4发射情况下平均2拍就会遇到1条转移指令。而且随着流水线越来越深处理转移指令所需要的时钟周期数也越来越多。面对这些情况如果还是只能通过阻塞流水线的方式来避免控制相关引起的冲突将会极大地降低流水线处理器的性能。
现代处理器普遍采用硬件转移预测机制来解决转移指令引起的控制相关阻塞,其基本思路是在转移指令的取指或译码阶段预测出转移指令的方向和目标地址,并从预测的目标地址继续取指令执行,这样在猜对的情况下就不用阻塞流水线。既然是猜测,就有错误的可能。硬件转移预测的实现分为两个步骤:第一步是预测,即在取指或译码阶段预测转移指令是否跳转以及转移的目标地址,并根据预测结果进行后续指令的取指;第二步是确认,即在转移指令执行完毕后,比较最终确定的转移条件和转移目标与之前预测的结果是否相同,如果不同则需要取消预测后的指令执行,并从正确的目标重新取指执行。
下面通过一个简单的例子来说明转移预测对性能的影响。假设平均8条指令中有1条转移指令某处理器采用4发射结构在第10级流水计算出转移方向和目标这意味着转移预测失败将产生(10-1)×4=36个空泡。如果不进行转移预测而采用阻塞的方式那么取指带宽浪费36/(36+8)=82%如果进行简单的转移预测转移预测错误率为50%那么平均每16条指令预测错误一次指令带宽浪费36/(36+16)=75%如果使用误预测率为10%的转移预测器那么平均每80条指令预测错误一次指令带宽浪费降至36/(36+80)=31%如果使用误预测率为4%的转移预测器则平均每200条指令预测错误一次取指令带宽浪费进一步降至36/(36+200)=15%。
从上面的例子可以看出,在转移预测错误开销固定的情况下,提高转移预测的准确率有助于大幅度提升处理器性能。那么能否设计出具有很高预测正确率的转移预测器呢?通过对大量应用程序中转移指令的行为进行分析后,人们发现它具有两个非常好的特性:首先,转移指令有较好的局部性,即少数转移指令的执行次数占所有转移指令执行次数中的绝大部分,这意味着只要对少量高频次执行的转移指令做出准确的预测就能获得绝大多数的性能提升;其次,绝大多数转移指令具有可预测性,即能够通过对转移指令的行为进行分析学习得出其规律性。
转移指令的可预测性主要包括单条转移指令的重复性以及不同转移指令之间存在的方向相关、路径相关。单条转移指令的重复性主要与程序中的循环有关。例如for型循环中转移指令的模式为TT……TN成功n次后跟1次不成功while型循环中转移指令的模式为NN……NT不成功n次后跟1次成功。不同转移指令之间的相关性主要出现在“if…else…”结构中。图\@ref(fig:chapter9-brachRelation)(a)是转移指令之间存在方向相关的例子。两个分支的条件(完全或部分)基于相同或相关的信息,后面分支的结果基于前面分支的结果。图\@ref(fig:chapter9-brachRelation)(b)是转移指令之间存在路径相关的例子。如果一个分支是通向当前分支的前n条分支之一则称该分支处在当前分支的路径上处在当前分支的路径上的分支与当前分支结果之间的相关性称为路径相关。
```{r chapter9-brachRelation, fig.cap='转移指令之间的相关性', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/brachRelation.png')
```
下面具体介绍一种最基本的转移预测结构即根据单条转移指令的转移历史来进行转移预测。这种转移预测主要依据转移指令重复性的规律对于重复性特征明显的转移指令如循环可以取得很好的预测效果。例如对于循环语句for (i=0; i<10; i++) {…}可以假设其对应的汇编代码中是由一条回跳的条件转移指令来控制循环的执行。该转移指令前9次跳转第10次不跳转如果我们用1表示跳转0表示不跳转那么这个转移指令的转移模式就记为1111111110。这个转移模式的特点是如果上一次是跳转那么这一次也是跳转的概率比较大。这个特点启发我们将该转移指令的执行历史记录下来用于猜测该转移指令是否跳转。这种用于记录转移指令执行历史信息的表称为转移历史表Branch History Table, 简称BHT。最简单的BHT利用PC的低位进行索引每项只有1位记录索引到该项的转移指令上一次执行时的跳转情况1表示跳转0表示不跳转。由于存储的信息表征了转移的模式所以这种BHT又被称为转移模式历史表Pattern History Table, 简称PHT。利用这种1位PHT进行预测时首先根据转移指令的PC低位去索引PHT如果表项值为1则预测跳转否则预测不跳转其次要根据该转移指令实际的跳转情况来更新对应PHT的表项中的值。仍以前面的for循环为例假设PHT的表项初始值都为0那么转移指令第1次执行时读出的表项为0所以预测不跳转但这是一次错误的预测第1次执行结束时会根据实际是跳转的结果将对应的表项值更新为1转移指令第2次执行时从表项中读出1所以预测跳转这是一次正确的预测第2次执行结束时会根据实际是跳转的结果将对应的表项值更新为1……转移指令第10次执行时从表项中读出1所以预测跳转这是一次错误的预测第10次执行结束时会根据实际是不跳转的结果将对应的表项值更新为0。可以看到进入和退出循环都要猜错一次。这种PHT在应对不会多次执行的单层循环时或者循环次数特别多的循环时还比较有效。但是对于如下的两重循环
for (i=0; i<10; i++) for (j=0; j<10; j++) {…}
使用上述1位PHT则内外循环每次执行都会猜错2次这样总的转移预测正确率仅有80%。
为了提高上述情况下的转移预测正确率可以采用每项2位的PHT。这种PHT中每一项都是一个2位饱和计数器相应的转移指令每次执行跳转就加1加到3为止不跳转就减1减到0为止。预测时如果相应的PHT表项的高位为1计数器的值为2或3就预测跳转高位为0计数器的值为0或1就预测不跳转。也就是说只有连续两次猜错才会改变预测的方向。使用上述2位PHT后前面两重循环的例子中内层循环的预测正确率从80%提高到(7+81)/100=88%。图\@ref(fig:chapter9-PHT)给出了2位PHT转移预测机制的示意。
```{r chapter9-PHT, fig.cap='2位PHT原理', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/PHT.png')
```
<!-- 图不太对? BHT/PHT应该在前端 -->
还有很多技术可以提高分支预测的准确率。可以使用分支历史信息与PC进行哈希操作后再查预测表让分支历史影响预测结果可以使用多个预测器同时进行预测并预测哪个预测器的结果更准确这被称为锦标赛预测器。具体的实现方法以及更高级的分支预测技术可以参见本套系列教材中的硕士课程教材。
### 高速缓存
由于物理实现上存在差异自20世纪80年代以来CPU和内存的速度提升幅度一直存在差距而且这种差距随着时间的推移越来越大。例如DDR3内存的访问延迟约为50ns而高端处理器的时钟周期都在1ns以下相当于每访问一次DDR3都需要花费至少50个处理器的时钟周期如果程序有较多依赖访存结果的数据相关就会严重影响处理器的性能。处理器和内存的速度差距造就了存储层次离处理器流水线距离越近的地方使用存储密度小的电路结构牺牲存储容量来换取更快的访问速度离处理器流水线距离越远的地方使用存储密度大的电路结构牺牲访问速度来换取存储容量。目前计算机中常见的存储层次包括寄存器、高速缓存Cache、内存、IO这四个层次。本节主要讨论Cache的相关概念。
Cache为了追求访问速度容量通常较小其中存放的内容只是主存储器内容的一个子集。Cache是微体系结构的概念它没有程序上的意义没有独立的编址空间处理器访问Cache和访问存储器使用的是相同的地址因而Cache对于编程功能正确性而言是透明的。Cache在流水线中的位置大致如图\@ref(fig:chapter9-cache)所示这里为了避免共享Cache引入的结构相关采用了独立的指令Cache和数据Cache前者仅供取指后者仅供访存。
```{r chapter9-cache, fig.cap='Cache在流水线结构图中的示意', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/cache.png')
```
由于Cache没有独立的编址空间且只能存放一部分内存的内容所以一个Cache单元可能在不同时刻存储不同的内存单元的内容。这就需要一种机制来标明某个Cache单元当前存储的是哪个内存单元的内容。因此Cache的每一个单元不仅要存储数据还要存储该数据对应的内存地址称为Cache标签Tag以及在Cache中的状态如是否有效是否被改写等
处理器访问Cache时除了用其中的某些位进行索引外还要将访问地址与Cache中的Tag相比较。如果命中则直接对Cache中的内容进行访问如果不命中则该指令阻塞在取指或者访存阶段同时由Cache失效处理逻辑完成后续处理后才能继续执行如果是读访问那么就需要从下一层存储中取回所需读取的数据并放置在Cache中。
设计Cache结构主要考虑3方面问题
1Cache块索引的方式。Cache的容量远小于内存会涉及多个内存单元映射到同一个Cache单元的情况具体怎么映射需要考虑。通常分为3种索引方式直接相连、全相连和组相连。
2Cache与下一层存储的数据关系即写策略分为写穿透和写回两种。存数指令需要修改下一层存储的值如果将修改后的值暂时放在Cache中当Cache替换回下一层存储时再写回则称为写回Cache如果每条存数指令都要立即更新下一层存储的值则称为写穿透Cache。
3Cache的替换策略分为随机替换、LRU替换和FIFO替换。当发生Cache失效而需要取回想要的Cache行此时如果Cache满了则需要进行替换。进行Cache替换时如果有多个Cache行可供替换可以选择随机进行替换也可以替换掉最先进入Cache的Cache行FIFO替换或者替换掉最近最少使用的Cache行LRU替换
直接相联、全相联和组相联中内存和Cache的映射关系原理如图\@ref(fig:chapter9-cacheMap)所示。将内存和Cache都分为大小一样的块假设内存有32项Cache有8项。在直接相联方式中每个内存块只能放到Cache的一个位置上假设要把内存的第12号块放到Cache中因为Cache只有8项所以只能放在第(12 mod 8=4)项上其他地方都不能放由此可知第4、12、20、28号内存块都对应到Cache的第4项上如果冲突了就只能替换。这就是直接相联硬件简单但效率低如图\@ref(fig:chapter9-cacheMap)(a)所示。在全相联方式中每个内存块都可以放到Cache的任一位置上这样第4、12、20、28号内存块可以同时放入Cache中。这就是全相联硬件复杂但效率高如图\@ref(fig:chapter9-cacheMap)(b)所示。组相联是直接相联和全相联的折中。以两路组相联为例Cache中第0、2、4、6号位置为一路这里称为第0路第1、3、5、7为另一路这里称为第1路每路4个Cache块。对于内存的第12号块因为12除以4余数为0所以既可以把第12号块放到Cache第0路的第0号位置即Cache的第0号位置也可以放到第1路的第0号位置即Cache的第1号位置如图\@ref(fig:chapter9-cacheMap)(c)所示。
```{r chapter9-cacheMap, fig.cap='直接相联、全相联、组相联映射', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/cacheMap.png')
```
直接相联、全相联和组相联Cache的结构如图\@ref(fig:chapter9-cacheMapStruct)所示。从中可以看出访问Cache时地址可分为3个部分偏移(Offset)、索引(Index)和标签(Tag)。Offset是块内地址在地址的低位。因为Cache块一般比较大通常包含32字节或64字节而指令或数据访问往往没有这么宽需要通过Offset来指定访问对象在块内的具体位置。Index是用来索引Cache块的将其作为地址来访问Cache。地址的高位是访问Cache的Tag用于和Cache中保存的Tag进行比较如果相等就给出命中信号Hit。在直接相联结构中访问地址的Tag仅需要和Index索引的那个Cache块的Tag比较在全相联结构中Index位数为0访问地址的Tag需要和每个Cache块的Tag比较如果相等就给出命中信号Hit同时将命中项的Cache块的Data通过Mux多路选择器Multiplexer选出在组相联结构中访问地址的Tag需要和每一组中Index索引的那个Cache块的Tag比较生成Hit信号并选出命中项的Data。注意Offset位数只和Cache块大小相关但Tag和Index位数则和相联度相关。例如在32位处理器中如果Cache大小为16KB块大小为32字节则Offset为5位共有512个Cache块。采用直接相联结构Index为9位Tag为18位采用全相联结构Index为0位Tag为27位采用两路组相联结构Index为8位Tag为19位。
```{r chapter9-cacheMapStruct, fig.cap='直接相联、全相联、组相联Cache结构', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/cacheMapStruct.png')
```
在基本Cache结构的基础之上有着一系列围绕性能的优化技术具体可以参见本套系列教材中的硕士课程教材。
## 本章小结
本章从处理器的数据通路开始先引入流水线技术并逐渐增加设计复杂度最终搭建出了5级静态流水线处理器。本章还简要介绍了一些提高流水线效率的方法。
图\@ref(fig:chapter9-LS3A2000)是龙芯3A2000处理器的流水线示意图。
```{r chapter9-LS3A2000, fig.cap='龙芯3A2000流水线示意图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter9/LS3A2000.png')
```
可以看出现代处理器依然没有脱离教材中讲述的基础原理。图中左侧为PC级和译码级并加入了分支预测、指令Cache和指令TLB图的中间部分为重命名和提交单元重命名后指令进入保留站也称发射队列并在就绪后发射并执行图的右侧为访存执行单元需要访问数据Cache和数据TLB并有可能访问图下方的二级Cache。提交单元要负责将指令提交提交后指令就可以退出流水线了。
## 习题
1. 请给出下列程序在多周期处理器(如图\@ref(fig:chapter9-multicycle)所示)上执行所需要的时钟周期数,并给出前三次循环执行的时空图。
2. 请给出题1中的程序在单发射5级静态流水线处理器如图\@ref(fig:chapter9-pipelinestruct)所示)上执行所需要的时钟周期数,并给出前三次循环执行的流水线时空图。
3. 请给出题1中的程序在包含前递机制的单发射5级静态流水线处理器如图\@ref(fig:chapter9-instHazardPipeline)所示)上执行所需要的时钟周期数,并给出前三次循环执行的流水线时空图。
4. 请在图\@ref(fig:chapter9-instHazardPipeline)的基础上添加必要的逻辑,使其能够实现精确异常的功能。画出修改后的处理器结构图,并进行解释。
5. 请给出题1中的程序在包含前递机制的双发射5级静态流水线处理器如图\@ref(fig:chapter9-ctrlHazardStruct)所示)上执行所需要的时钟周期数,并给出前三次循环执行的流水线时空图。
6. 请问数据相关分为哪几种?静态流水线处理器是如何解决这几种相关的?采用寄存器重命名的动态流水线处理器是如何解决这几种相关的?
7. 假设在包含前递机制的单发射5级静态流水线处理器如图\@ref(fig:chapter9-instHazardPipeline所示的译码级添加了一个永远预测跳转的静态分支预测器那么题1中的程序在这个处理器上执行需要花费多少时钟周期
8. 对于程序段for(i=0; i<10; i++)
for(j=0; i<10; j++)
for(k=0; k<10; k++)
{…}
计算分别使用一位BHT表和使用两位BHT表进行转移猜测时三重循环的转移猜测准确率假设BHT表的初始值均为0。
9. 在一个32位处理器中实现一个Cache块大小为64字节、总容量为32KB的数据Cache该数据Cache仅使用32位物理地址访问。请问当分别采用直接映射、两路组相联和四路组相联的组织结构时Cache访问地址中Tag、Index和Offset三部分各自如何划分
10. 假设程序动态执行过程中load、store指令占40%。现在有两种数据Cache的设计方案其中第一种方案的Cache容量小于第二种方案因此采用第一种方案的Cache命中率为85%第二种方案的Cache命中率为95%但是采用第二种方案时处理器的主频会比第一种低10%。请问哪种设计方案性能更优假设Cache不命中情况下会阻塞流水线100个时钟周期。
\newpage

812
20-parallel-programming.Rmd Normal file
View File

@@ -0,0 +1,812 @@
# (PART) 并行处理结构 {-}
本部分介绍并行处理结构。要深入了解并行处理结构,必须要从系统设计(即软硬件协同设计)的角度入手。本部分重点介绍并行程序的编程基础,以及广泛应用的并行处理结构——多核处理器。
# 并行编程基础
## 程序的并行行为
人们对应用程序性能的追求是无止境的例如天气预报、药物设计、核武器模拟等应用。并行处理系统可以协同多个处理单元来解决同一个问题从而大幅度提升性能。评价一个并行处理系统主要看其执行程序的性能即程序在其上的执行时间。可以通过一些公认的并行测试程序集如SPLASH、NAS来进行评测。因此在讨论并行处理结构之前先来看一下程序的并行行为。程序的并行行为主要包括指令级并行性、数据级并行、任务级并行性。
### 指令级并行性
指令级并行性Instruction Level Parallelism简称ILP主要指指令之间的并行性当指令之间不存在相关时这些指令可以在处理器流水线上重叠起来并行执行。在程序运行中如果必须等前一条指令执行完成后才能执行后一条指令那么这两条指令是相关的。指令相关主要包括数据相关、控制相关和结构相关。数据相关包括写后读Read After Write简称RAW相关、读后写Write AfterRead简称WAR相关和写后写WriteAfter Write简称WAW相关。其中RAW相关是真正的数据相关因为存在真正的数据传递关系WAR相关和WAW相关又称为假相关或者名字相关指令之间实际不存在数据传递。控制相关主要是由于存在分支指令一条指令的执行取决于该分支指令的执行结果则这两条指令之间存在控制相关。结构相关是指两条指令同时需要流水线中的同一个功能部件。在这些相关中RAW数据相关和控制相关是真正制约指令级并行执行的相关。指令相关容易造成处理器流水线上的冲突引起流水线阻塞从而降低流水线效率。
现代处理器采用多种微结构设计技术挖掘指令级并行性包括指令流水线、多发射、动态调度、寄存器重命名、转移猜测等技术。指令流水线重叠执行多条不相关的指令多发射技术允许一个时钟周期执行多条指令类似于“多车道”动态调度允许后续指令越过前面被阻塞的指令继续被调度执行相当于允许“超车”寄存器重命名主要解决RAW和WAW的假相关问题转移猜测技术可以猜测分支指令的方向和目标在分支指令还未执行完之前获取更多可执行指令以减少控制相关造成的指令流水线阻塞。这方面的技术已经比较成熟。
### 数据级并行性
数据级并行性Data Level Parallelism简称DLP是指对集合或者数组中的元素同时执行相同的操作。这种并行性通常来源于程序中的循环语句。下列代码块所示的代码就是一个数据并行的例子。对于数组local中的元素local[i],执行相同的操作(i+0.5)*w。可以采用将不同的数据分布到不同的处理单元的方式来实现数据级并行。
```c
for(i = 0;i<N;i++){
local[i] = (i+0.5)*w;
}
```
数据级并行性是比较易于处理的可以在计算机体系结构的多个层次来利用数据级并行性。例如可以在处理器中设计向量功能部件采用SIMD设计方法如一个256位向量部件一次可以执行4个64位的操作设计专门的向量处理器如CRAY公司的CRAY-1、CRAY-2、X-MP、Y-MP等在多处理器中可以采用SPMDSingle Program Multi-Data的编程方式将数据分布到不同的处理器上执行同一个程序控制流。数据级并行性常见于科学和工程计算领域中例如大规模线性方程组的求解等。正是由于这个原因向量处理器在科学计算领域还是比较成功的。
### 任务级并行性
任务级并行性Task Level Parallelism是将不同的任务进程或者线程分布到不同的处理单元上执行。针对任务表现为进程或者线程任务级并行性可分为进程级并行性或者线程级并行性。下代码块是一个任务并行的代码示例。对于一个双处理器系统当处理器IDprocessor_ID为a时则执行任务A当处理器ID为b时则执行任务B。
```c
if(processor_ID=”a”) {
task A;
}else if (processor_ID=”b”){
Task B;
}
```
在并行处理系统中挖掘任务并行性就是让每个处理器执行不同的线程或进程来处理相同或者不同的数据。这些线程或者进程可以执行相同或者不同的代码。通常情况下不同线程或者进程之间还需要相互通信来协作完成整个程序的执行。任务级并行性常见于商业应用领域如大规模数据库的事务处理等。另外多道程序工作负载Multiprogramming Workload即在计算机系统上运行多道独立的程序也是任务级并行的重要来源。
## 并行编程模型
并行处理系统上如何编程是个难题目前并没有很好地解决。并行编程模型的目标是方便编程人员开发出能在并行处理系统上高效运行的并行程序。并行编程模型Parallel Programming Model是一种程序抽象的集合它给程序员提供了一幅计算机硬件/软件系统的抽象简图,程序员利用这些模型就可以为多核处理器、多处理器、机群等并行计算系统设计并行程序[26]。
### 单任务数据并行模型
数据并行Data Parallel模型是指对集合或者数组中的元素同时即并行执行相同操作。数据并行编程模型可以在SIMD计算机上实现为单任务数据并行也可以在SPMD计算机上实现为多任务数据并行。SIMD着重开发指令级细粒度的并行性SPMD着重开发子程序级中粒度的并行性。单任务数据并行编程模型具有以下特点
1)单线程Single Threading。从程序员的角度一个数据并行程序只由一个线程执行具有单一控制线就控制流而言一个数据并行程序就像一个顺序程序一样。
2同构并行。数据并行程序的一条语句同时作用在不同数组元素或者其他聚合数据结构在数据并行程序的每条语句之后均有一个隐式同步。
3全局命名空间Global Naming Space。数据并行程序中的所有变量均在单一地址空间内所有语句可访问任何变量而只要满足通常的变量作用域规则即可。
4隐式相互作用Implicit Interaction。因为数据并行程序的每条语句结束时存在一个隐含的栅障Barrier所以不需要显式同步通信可以由变量指派而隐含地完成。
5隐式数据分配Implicit Data Allocation。程序员没必要明确指定如何分配数据可将改进数据局部性和减少通信的数据分配方法提示给编译器。
### 多任务共享存储编程模型
在共享存储编程模型中,运行在各处理器上的进程(或者线程)可以通过读/写共享存储器中的共享变量来相互通信。它与单任务数据并行模型的相似之处在于有一个单一的全局名字空间。由于数据是在一个单一的共享地址空间中,因此不需要显式地分配数据,而工作负载则可以显式地分配也可以隐式地分配。通信通过共享的读/写变量隐式地完成而同步必须显式地完成以保持进程执行的正确顺序。共享存储编程模型如Pthreads和OpenMP等。
### 多任务消息传递编程模型
在消息传递编程模型中,在不同处理器节点上运行的进程,可以通过网络传递消息而相互通信。在消息传递并行程序中,用户必须明确为进程分配数据和负载,它比较适合开发大粒度的并行性,这些程序是多进程的和异步的,要求显式同步(如栅障等)以确保正确的执行顺序。然而这些进程均有独立的地址空间。
消息传递编程模型具有以下特点:
1多进程。消息传递并行程序由多个进程组成每个进程都有自己的控制流且可执行不同代码多程序多数据Multiple ProgramMultiple Data简称MPMD并行和单程序多数据SPMD并行均可支持。
2异步并行性Asynchronous Parallelism。消息传递并行程序的各进程彼此异步执行使用诸如栅障和阻塞通信等方式来同步各个进程。
3独立的地址空间Separate Address Space。消息传递并行程序的进程具有各自独立的地址空间一个进程的数据变量对其他进程是不可见的进程的相互作用通过执行特殊的消息传递操作来实现。
4显式相互作用Explicit Interaction。程序员必须解决包括数据映射、通信、同步和聚合等相互作用问题计算任务分配通过拥有者-计算Owner-Compute规则来完成即进程只能在其拥有的数据上进行计算。
5显式分配Explicit Allocation。计算任务和数据均由用户显式地分配给进程为了减少设计和编程的复杂性用户通常采用单一代码方法来编写SPMD程序。典型的消息传递编程模型包括MPI和PVM。
### 共享存储与消息传递编程模型的编程复杂度
采用共享存储与消息传递编程模型编写的并行程序是在多处理器并行处理系统上运行的。先了解一下多处理器的结构特点,可以更好地理解并行编程模型。从结构的角度看,多处理器系统可分为共享存储系统和消息传递系统两类。在共享存储系统中,所有处理器共享主存储器,每个处理器都可以把信息存入主存储器,或从中取出信息,处理器之间的通信通过访问共享存储器来实现。而在消息传递系统中,每个处理器都有一个只有它自己才能访问的局部存储器,处理器之间的通信必须通过显式的消息传递来进行。消息传递和共享存储系统的原理结构如图\@ref(fig:programming)所示。从图中可以看出在消息传递系统中每个处理器的存储器是单独编址的而在共享存储系统中所有存储器统一编址。典型的共享存储多处理器结构包括对称多处理器机Symmetric Multi-Processor简称SMP结构、高速缓存一致非均匀存储器访问Cache Coherent Non Uniform Memory Access简称CC-NUMA结构。
```{r programming, echo=FALSE, fig.align='center', fig.cap="消息传递(左)和共享存储系统(右)", out.width='100%'}
knitr::include_graphics("images/chapter10/Shared_storage_and_message_passing_programming.png")
```
在消息传递编程模型中程序员需要对计算任务和数据进行划分并安排并行程序执行过程中进程间的所有通信。在共享存储编程模型中由于程序的多进程或者线程之间存在一个统一编址的共享存储空间程序员只需进行计算任务划分不必进行数据划分也不用确切地知道并行程序执行过程中进程间的通信。MPPMassive Parallel Processing系统和机群系统往往是消息传递系统。消息传递系统的可伸缩性通常比共享存储系统要好可支持更多处理器。
从进程或者线程间通信的角度看消息传递并行10.6程序比共享存储并行程序复杂一些体现在时间管理和空间管理两方面。在空间管理方面发送数据的进程需要关心自己产生的数据被谁用到而接收数据的进程需要关心它用到了谁产生的数据在时间管理方面发送数据的进程通常需要在数据被接收后才能继续而接收数据的进程通常需要等到接收数据后才能继续。在共享存储并行程序中各进程间的通信通过访问共享存储器完成程序员只需考虑进程间同步不用考虑进程间通信。尤其是比较复杂的数据结构的通信如struct{int*paint* pbint*pc;},消息传递并行程序比共享存储并行程序复杂得多。此外,对于一些在编程时难以确切知道进程间通信的程序,用消息传递的方法很难进行并行化,如{for (i,j){ x=…; y=…; a[i]\[j]=b[x]\[y];}}。这段代码中,通信特征在程序运行时才能确定,编写代码时难以确定,改写成消息传递程序就比较困难。
从数据划分的角度看消息传递并行程序必须考虑诸如数组名称以及下标变换等因素在将一个串行程序改写成并行程序的过程中需要修改大量的程序代码。而在共享存储编程模型中进行串行程序的并行化改写时不用进行数组名称以及下标变换对代码的修改量少。虽说共享存储程序无须考虑数据划分但是在实际应用中为了获得更高的系统性能有时也需要考虑数据分布使得数据尽量分布在对其进行计算的处理器上例如OpenMP中就有进行数据分布的扩展指导。不过相对于消息传递程序中的数据划分考虑数据分布还是要简单得多。
总的来说共享存储编程像BBS应用一个人向BBS上发帖子其他人都看得见消息传递编程则像电子邮件(E-mail),你得想好给谁发邮件,发什么内容。
下面举两个共享存储和消息传递程序的例子。第一个例子是通过积分求圆周率。积分求圆周率的公式如下:
$$
\pi = 4\int_{0}^{1}{\frac{1}{1+x^2}}dx = \sum^{N}_{i=1}{\frac{4}{1+(\frac{i-0.5}{N})^2}\times{\frac{1}{N}}}
$$
在上式中N值越大误差越小。如果N值很大计算时间就很长。可以通过并行处理让每个进程计算其中的一部分最后把每个进程计算的值加在一起来减少运算时间。图\@ref(fig:get-pi)给出了计算圆周率的共享存储基于中科院计算所开发的JIAJIA虚拟共享存储系统和消息传递并行程序核心片段的算法示意。该并行程序采用SPMDSingle Program Multiple Data的模式即每个进程都运行同一个程序但处理不同的数据。在该程序中numprocs是参与运算的进程个数所有参与运算的进程都有相同的numprocs值myid是参与运算的进程的编号每个进程都有自己的编号一般并行编程系统都会提供接口函数让进程知道自己的编号。例如如果有4个进程参与运算则每个进程的numprocs都是4而每个进程的myid号分别为0、1、2、3。在共享存储并行程序中由jia_alloc()分配空间的变量pi是所有参与运算的进程共享的所有进程只有一份其他变量都是每个进程局部的每个进程都有一份每个进程根据numprocs和myid号分别计算部分的圆周率值最后通过一个临界区的机制把所有进程的计算结果加在一起。jia_lock()和jia_unlock()是一种临界区的锁机制保证每次只有一个进程进入这个临界区这样才能把所有进程的结果依次加在一起不会互相冲掉。在消息传递并行程序中由malloc()分配空间的变量每个进程都有独立的一份互相看不见。每个进程算完部分结果后通过归约操作reduce()把所有进程的mypi加到0号进程的pi中。
```{r get-pi, echo=FALSE, fig.align='center', fig.cap="积分求圆周率算法示意", out.width='100%'}
knitr::include_graphics("images/chapter10/积分求圆周率算法示意-01.png")
```
第二个例子是矩阵乘法。矩阵乘法的算法大家都很熟悉,这里就不介绍了。图\@ref(fig:get-martix-multi)给出了共享存储和消息传递并行程序。同样由jia_alloc()分配的变量所有进程共享一份而由malloc()分配的变量每个进程单独一份因此在这个程序中消息传递并行程序需要更多的内存。在共享存储并行程序中先由0号进程对A、B、C三个矩阵进行初始化而其他进程通过jia_barrier()语句等待。barrier是并行程序中常用的同步方式它要求所有进程都等齐后再前进。然后每个进程分别完成部分运算再通过jia_barrier()等齐后由0号进程统一打印结果。消息传递并行程序与共享存储并行程序的最大区别是需要通过显式的发送语句send和接收语句recv进行多个进程之间的通信。先由0号进程进行初始化后发送给其他进程每个进程分别算完后再发送给0号进程进行打印。在消息传递并行程序中要详细列出每次发送的数据大小和起始地址等信息0号进程接收的时候还要把从其他进程收到的数据拼接在一个矩阵中比共享存储并行程序麻烦不少。
```{r get-martix-multi, echo=FALSE, fig.align='center', fig.cap="矩阵乘法算法示意", out.width='100%'}
knitr::include_graphics("images/chapter10/矩阵乘法算法示意-01.png")
```
## 典型并行编程环境
本节主要介绍数据并行SIMD编程、早期的共享存储编程标准Pthreads、目前主流的共享存储编程标准OpenMP和消息传递编程模型MPI等。
### 数据并行SIMD编程
工业界广泛应用的单指令流多数据流(Single Instruction Multiple Data简称SIMD)并行就是典型的数据并行技术。相比于传统的标量处理器上的单指令流单数据流(Single Instruction Single Data简称SISD)指令一条SIMD指令可以同时对一组数据进行相同的计算。比如将两个数组SRC0[8]和SRC1[8]中的每个对应元素求和将结果放入数组RESULT中对于传统的标量处理器平台C语言实现如下
```c
for (i = 0; i < 8; i++)
RESULT[i] = SRC0[i] + SRC1[i];
```
也就是通过循环遍历需要求和的8组对应数据对SRC0和SRC1的各对应项求和将结果存入RESULT数组的对应项中。在龙芯处理器平台上用机器指令(汇编代码)实现该运算的代码如下(这里假设$src0、 $src1、 $result分别为存储了SRC0、 SRC1和RESULT数组起始地址的通用寄存器)
```assembly
li $4, 0x0
li $5, 0x8
1: daddu $src0, $4
daddu $src1, $4
daddu$result, $4
lb $6, 0x0($src0)
lb $7, 0x0($src1)
daddu $6, $6, $7
sb $6, 0x0($result)
daddiu $4, 0x1
blt $4, $5, 1b
nop
```
如果采用龙芯处理器的SIMD指令编写程序的话上述两个数组的求和只需要将上述两个源操作数数组SRC0[8]和SRC1[8]一次性加载到龙芯处理器的向量寄存器(龙芯向量寄存器复用了浮点寄存器)中然后只需要一条paddb指令就可以完成上述8个对应数组项的求和最后只需要一条store指令就可以将结果存回RESULT[8]数组所在的内存空间中。该实现的机器指令序列如下:
```assembly
gsldxc1 $f0, 0x0($src0, $0)
gsldxc1 $f2, 0x0($src1, $0)
paddb $f0, $f0, $f2
gssdxc1 $f0, 0x0($result, $0)
```
图\@ref(fig:SISD-SIMD)简要示意了采用传统SISD指令和SIMD指令实现上述8个对应数组项求和的执行控制流程。
```{r SISD-SIMD, echo=FALSE, fig.align='center', fig.cap="SISD和SIMD执行控制流示意图", out.width='100%'}
knitr::include_graphics("images/chapter10/SISD_SIMD.png")
```
### POSIX编程标准
POSIXPortable Operating System Interface属于早期的共享存储编程模型。POSIXThreads即Pthreads代表官方IEEE POSIX1003.1C_1995线程标准是由IEEE标准委员会所建立的主要包含线程管理、线程调度、同步等原语定义体现为C语言的一套函数库。下面只简介其公共性质。
1线程管理
线程库用于管理线程Pthreads中基本线程管理原语如下表所示。其中pthread_create()在进程内生成新线程新线程执行带有变元arg的myroutine,如果pthread_create()生成则返回0并将新线程之ID置入thread_id否则返回指明错误类型的错误代码pthread_exit()结束调用线程并执行清场处理pthread_self()返回调用线程的IDpthread_join()等待其他线程结束。
```{r thread, echo=FALSE, fig.align='center', fig.cap="线程管理", out.width='100%'}
knitr::include_graphics("images/chapter10/线程管理-01.png")
```
2线程调度
pthread_yield()的功能是使调用者将处理器让位于其他线程pthread_cancel()的功能是中止指定的线程。
3线程同步
Pthreads中的同步原语见下表。重点讨论互斥变量mutexMutual Exclusion和条件变量condConditional。前者类似于信号灯结构后者类似于事件结构。注意使用同步变量之前需被初始化生成用后应销毁。
如果mutex未被上锁pthread_mutex_lock()将锁住mutex如果mutex已被上锁调用线程一直被阻塞到mutex变成有效。pthead_mutex_trylock()的功能是尝试对mutex上锁。pthread_mutex_lock()和pthead_mutex_trylock()的区别是前者会阻塞等待另外一锁被解锁后者尝试去加锁如果不成功就返回非0如果成功返回0不会产生阻塞。pthead_mutex_unlock()解锁先前上锁的mutex,当mutex被解锁它就能由别的线程获取。
pthread_cond_wait()自动阻塞等待条件满足的现行线程并开锁mutex。pthread_cond_timedwait()与pthread_cond_wait()类似除了当等待时间达到时限它将解除阻塞外。pthread_cond_signal()解除一个等待条件满足的已被阻塞的线程的阻塞。pthread_cond_broadcast()将所有等待条件满足的已被阻塞的线程解除阻塞。
| 功能 | 含义 |
| ------------------------- | ------------------------------ |
| pthread_mutex_init(…) | 生成新的互斥变量 |
| pthread_mutex_destroy(…) | 销毁互斥变量 |
| pthread_mutex_lock(…) | 锁住互斥变量 |
| pthread_mutex_trylock(…) | 尝试锁住互斥变量 |
| pthread_mutex_unlock(…) | 解锁互斥变量 |
| pthread_cond_init(…) | 生成新的条件变量 |
| pthread_cond_destroy(…) | 销毁条件变量 |
| pthread_cond_wait(…) | 等待(阻塞)条件变量 |
| pthread_cond_timedwait(…) | 等待条件变量直至到达时限 |
| pthread_cond_signal(…) | 投递一个事件,解锁一个等待进程 |
| pthread_cond_broadcast(…) | 投递一个事件,解锁所有等待进程 |
4.示例
以下程序示例用数值积分法求π的近似值。
我们使用梯形规则来求解这个积分。其基本思想是用一系列矩形填充一条曲线下的区域,就是要求出在区间[0,1]内函数曲线4/1+x2下的面积此面积就是π的近似值。为此先将区间[0,1]划分成N个等间隔的子区间每个子区间的宽度为1.0/N然后计算出各子区间中点处的函数值再将各子区间面积相加就可得出π的近似值。N的值越大π值的误差越小。下代码块为进行π值计算的C语言描述的串行代码。为简化起见将积分中的迭代次数固定为1 000 000。
利用梯形规则计算π的C语言串行代码
```c
#include <stdio.h>
#include <math.h>
int main(){
int i
int num_steps=1000000;
double x,pi,step,sum=0.0;
step = 1.0/(double) num_steps;
for(i=0;i<num_steps;i++){
x=(i+0.5)*step;
sum = sum+4.0/(1.0+x*x);
}
pi = step*sum;
printf(“pi %1f\n”, pi);
return 0;
}
```
为采用Pthreads的并行化代码。
```c
#include <stdlib.h>
#include <stdio.h>
#include <pthread.h>
#define NUM_THREADS 4 //假设线程数目为4
int num_steps = 1000000;
double step = 0.0, sum = 0.0;
pthread_mutex_t mutex;
void *countPI(void *id) {
int index = (int ) id;
int start = index*(num_steps/NUM_THREADS);
int end;
double x = 0.0, y = 0.0;
if (index == NUM_THREADS-1)
end = num_steps;
else
end = start+(num_steps/NUM_THREADS);
for (int i=start; i<end; i++){
x=(i+0.5)*step;
y +=4.0/(1.0+x*x);
}
pthread_mutex_lock(&mutex);
sum += y;
pthread_mutex_unlock(&mutex);
}
int main() {
int i;
double pi;
step = 1.0 / num_steps;
sum = 0.0;
pthread_t tids[NUM_THREADS];
pthread_mutex_init(&mutex, NULL);
for(i=0; i<NUM_THREADS; i++) {
pthread_create(&tids[i], NULL, countPI, (void *) i);
}
for(i=0; i<NUM_THREADS; i++)
pthread_join(tids[i], NULL);
pthread_mutex_destroy(&mutex);
pi = step*sum;
printf("pi %1f\n", pi);
return 0;
}
```
下面举一个矩阵乘的Pthreads并行代码例子。该例子将两个n阶的方阵A和B相乘结果存放在方阵C中。
```c
#include <stdlib.h>
#include <stdio.h>
#include <pthread.h>
#define NUM_THREADS 4 //假设线程数目为4
#define n 1000
double *A,*B,*C;
void *matrixMult(void *id) {//计算矩阵乘
intmy_id = (int ) id;
inti,j,k,start,end;
//计算进程负责的部分
start = my_id*(n/NUM_THREADS);
if(my_id == NUMTHREADS-1)
end = n;
else
end = start+(n/NUM_THREADS);
for(i=start;i<end;i++)
for(j=0;j<n;j++) {
C[i*n+j] = 0;
for(k=0;k<n;k++)
C[i*n+j]+=A[i*n+k]*B[k*n+j];
}
}
int main() {
inti,j;
pthread_t tids[NUM_THREADS];
//分配数据空间
A = (double *)malloc(sizeof(double)*n*n);
B = (double *)malloc(sizeof(double)*n*n);
C = (double *)malloc(sizeof(double)*n*n);
//初始化数组
for(i=0;i<n;i++)
for(j=0;j<n;j++){
A[i*n+j] = 1.0;
B[i*n+j] = 1.0
}
for(i=0; i<NUM_THREADS; i++)
pthread_create(&tids[i], NULL, matrixMult, (void *) i);
for(i=0; i<NUM_THREADS; i++)
pthread_join(tids[i], NULL);
return 0;
}
```
### OpenMP标准
OpenMP是由OpenMP Architecture Review BoardARB结构审议委员会牵头提出的是一种用于共享存储并行系统的编程标准。最初的OpenMP标准形成于1997年2002年发布了OpenMP 2.0标准2008年发布了OpenMP3.0标准2013年发布了OpenMP 4.0标准。实际上OpenMP不是一种新语言是对基本编程语言进行编译制导Compiler Directive扩展支持C/C++和Fortran。由于OpenMP制导嵌入到C/C++、Fortran语言中所以就具体语言不同会有所区别本书介绍主要参考支持C/C++的OpenMP 4.0标准。
OpenMP标准中定义了制导指令、运行库和环境变量使得用户可以按照标准逐步将已有串行程序并行化。制导语句是对程序设计语言的扩展提供了对并行区域、工作共享、同步构造的支持运行库和环境变量使用户可以调整并行程序的执行环境。程序员通过在程序源代码中加入专用pragma制导语句以“#pragmaomp”字符串开头来指明自己的意图支持OpenMP标准的编译器可以自动将程序进行并行化并在必要之处加入同步互斥以及通信。当选择忽略这些pragma或者编译器不支持OpenMP时程序又可退化为普通程序(一般为串行),代码仍然可以正常运行,只是不能利用多线程来加速程序执行。
由于OpenMP标准具有简单、移植性好和可扩展等优点目前已被广泛接受主流处理器平台均支持OpenMP编译器如Intel、AMD、IBM、龙芯等。开源编译器GCC也支持OpenMP标准。
1.OpenMP的并行执行模型
OpenMP是一个基于线程的并行编程模型一个OpenMP进程由多个线程组成使用fork-join并行执行模型。OpenMP程序开始于一个单独的主线程Master Thread主线程串行执行遇到一个并行域Parallel Region开始并行执行。接下来的过程如下
1fork分叉。主线程派生出一队并行的线程并行域的代码在主线程和派生出的线程间并行执行。
2join合并。当派生线程在并行域中执行完后它们或被阻塞或被中断所计算的结果会被主线程收集最后只有主线程在执行。
实际上OpenMP的并行化都是使用嵌入到C/C++或者Fortran语言的制导语句来实现的。以下代码为OpenMP程序的并行结构。
```c
#include <omp.h>
main(){
int var1,var2,var3;
#pragma omp parallel private(var1,var2) shared(var3)
{
}
}
```
2.编译制导语句
下面介绍编译制导语句的格式。参看前面的OpenMP程序并行结构的例子在并行开始部分需要语句“#pragma omp parallel private(var1,var2) shared(var3)”。下表是编译制导语句的格式及解释。
```{r Compiler-guidance-language, echo=FALSE, fig.align='center', fig.cap="编译制导语言", out.width='100%'}
knitr::include_graphics("images/chapter10/编译制导语言-01.png")
```
3.并行域结构
一个并行域就是一个能被多个线程执行的程序块它是最基本的OpenMP并行结构。并行域的具体格式为
```C++
#pragma omp parallel [if(scalar_expression)| num_threads(integer-
expression|default(shared|none)|private(list)|firstprivate(list)|shared
(list)| copyin(list)|reduction(operator:list)|
proc_bind(master|close|spread)]
newline
```
当一个线程执行到parallel这个指令时线程就会生成一列线程线程号依次从0到n-1而它自己会成为主线程线程号为0。当并行域开始时程序代码就会被复制每个线程都会执行该代码。这就意味着到了并行域结束就会有一个栅障,且只有主线程能够通过这个栅障。
4.共享任务结构
共享任务结构将其内封闭的代码段划分给线程队列中的各线程执行。它不产生新的线程,在进入共享任务结构时不存在栅障,但是在共享任务结构结束时存在一个隐含的栅障。图\@ref(fig:shared-task)显示了3种典型的共享任务结构。其中do/for将循环分布到线程列中执行可看作是一种表达数据并行s的类型 sections把任务分割成多个各个部分section每个线程执行一个section可很好地表达任务并行single由线程队列中的一个线程串行执行。
```{r shared-task, echo=FALSE, fig.align='center', fig.cap='共享任务类型', out.width='100%'}
knitr::include_graphics("images/chapter10/shared_task.png")
```
下面具体来看一下。
1for编译制导语句。for语句即C/C++中的for语句表明若并行域已经初始化了后面的循环就在线程队列中并行执行否则就会顺序执行。语句格式如下
```c
#pragma omp for [private(list)|firstprivate(list)|lastprivate(list)|
reduction(reduction-identifier:list)|schedule(kind[,chunk_size])|colla
pse(n)|ordered| nowait]
newline
```
其中schedule子句描述如何在线程队列中划分循环。kind为static时将循环划分为chunk_size大小的循环块静态分配给线程执行若chunk_size没有声明则尽量将循环在线程队列中平分kind为dynamic时线程会动态请求循环块来执行执行完一个循环块后就申请下一个循环块直到所有循环都执行完循环块的大小为chunk_size若chunk_size没有声明则默认的块长度为1kind为guide时线程会动态请求循环块来执行循环块的大小为未调度的循环数除以线程数但循环块大小不能小于chunk_size除了最后一块若chunk_size没有声明则默认为1。
2sections编译制导语句。该语句是非循环的共享任务结构它表明内部的代码是被线程队列分割的。语句格式如下
```c
#pragma omp sections [private(list)|firstprivate(list)|lastprivate(list)|
reduction(reduction-identifier:list)|nowait]
newline
{
[#pragma omp section newline]
Structured_block
[#pragma omp section newline
Structured_block]
}
```
值得注意的是在没有nowait子句时sections后面有栅障。
3single编译制导语句。该语句表明内部的代码只由一个线程执行。语句格式如下
```c
#pragma omp single [private(list)|firstprivate(list)|
copyprivate(list)|nowait] newline
Structured_block
```
若没有nowait子句线程列中没有执行single语句的线程会一直等到代码栅障同步才会继续往下执行。
5.组合的并行共享任务结构
下面介绍两种将并行域制导和共享任务制导组合在一起的编译制导语句。
1parallel for编译制导语句。该语句表明一个并行域包含一个单独的for语句。语句格式如下
```c
#pragma omp parallel for [if(scalar_expression)|num_threads(integer-
expression|default(shared|none)|private(list)|firstprivate(list)|lastpriv
ate(list)|shared(list)|copyin(list)|reduction(Structured_block:list)|proc
_bind(master|close|spread)|schedule(kind[,chunk_size])|collapse(n)|ordere
d]
newline
For_loop
```
该语句的子句可以是parallel和for语句的任意子句组合除了nowait子句。
2parallel sections编译制导语句。该语句表明一个并行域包含单独的一个sections语句。语句格式如下
```c
#pragma omp parallel sections [if(scalar_expression)|num_threads(integer-
expression)|default(shared|none)|private(list)|firstprivate(list)|lastpri
vate(list)|shared(list)|copyin(list)|reduction(Structured_block:list)|pro
c_bind(master|close|spread)]
{
[#progma omp section newline]
Structured_block
[#progma omp section newline
Structured_block]
}
```
同样该语句的子句可以是parallel和for语句的任意子句组合除了nowait子句。
6.同步结构
OpenMP提供了多种同步结构来控制与其他线程相关的线程的执行。下面列出几种常用的同步编译制导语句。
1master编译制导语句。该语句表明一个只能被主线程执行的域。线程队列中所有其他线程必须跳过这部分代码的执行语句中没有栅障。语句格式如下
```c
#pragma omp master newline
```
2critical编译制导语句。该语句表明域中的代码一次只能由一个线程执行。语句格式如下
```c
#pragma omp critical[name] newline
```
3barrier编译指导语句。该语句同步线程队列中的所有线程。当有一个barrier语句时线程必须要等到所有的其他线程也到达这个栅障时才能继续执行。然后所有线程并行执行栅障之后的代码。语句格式如下
```c
#pragma omp barrier newline
```
4atomic编译制导语句。该语句表明一个特别的存储单元只能原子地更新而不允许让多个线程同时去写。语句格式如下
```c
#pragma omp atomic newline
```
另外还有flush、order等语句。
7.数据环境
OpenMP中提供了用来控制并行域在多线程队列中执行时的数据环境的制导语句和子句。下面选择主要的进行简介。
1threadprivate编译制导语句。该语句表明变量是复制的每个线程都有自己私有的备份。这条语句必须出现在变量序列定义之后。每个线程都复制这个变量块所以一个线程的写数据对其他线程是不可见的。语句格式如下
```c
#pragma omp threadprivate(list)
```
2数据域属性子句。OpenMP的数据域属性子句用来定义变量的范围它包括private、firstprivate、lastprivate、shared、default、reduction和copyin等。数据域变量与编译制导语句parallel、for、sections等配合使用可控制变量的范围。它们在并行结构执行过程中控制数据环境。例如哪些串行部分的数据变量被传到程序的并行部分以及如何传送哪些变量对所有的并行部分是可见的哪些变量是线程私有的等等。具体说明如下。
- private子句表示它列出的变量对于每个线程是局部的即线程私有的。其格式为
```c
private(list)
```
- shared子句表示它列出的变量被线程队列中的所有线程共享程序员可以使多线程对其进行读写例如通过critical语句。其格式为
```c
sharedlist
```
- default子句该子句让用户可以规定在并行域的词法范围内所有变量的一个默认属性如可以是private、shared、none。其格式为
```c
default(shared|none)
```
- firstprivate子句该子句包含private子句的操作并将其列出的变量的值初始化为并行域外同名变量的值。其格式为
```c
firstprivate(list)
```
- lastprivate子句该子句包含private子句的操作并将值复制给并行域外的同名变量。其格式为
```c
lastprivate(list)
```
- copyin子句该子句赋予线程中变量与主线程中threadprivate同名变量的值。其格式为
```c
copyin(list)
```
- reduction子句该子句用来归约其列表中出现的变量。归约操作可以是加、减、乘、与(and)、或(or)、相等(eqv)、不相等(neqv)、最大max、最小min等。其格式为
```c
reduction(reduction-identifier:list)
```
利用梯形规则计算π的OpenMP并行化的C语言代码示例
```c
#include <stdio.h>
#include <omp.h>
int main(){
int i
int num_steps=1000000;
double x,pi,step,sum=0.0;
step = 1.0/(double) num_steps;
# pragma omp parallel for private(i, x), reduction(+:sum)
for(i=0;i<num_steps;i++)
{
x=(i+0.5)*step;
sum = sum+4.0/(1.0+x*x);
}
pi = step*sum;
printf(“pi %1f\n”, pi);
return 0;
}
```
将两个n阶的方阵A和B相乘结果存放在方阵C中矩阵乘的OpenMP并行代码示例
```c
#include <stdio.h>
#include <omp.h>
#define n 1000
double A[n][n],B[n][n],C[n][n];
int main()
{
int i,j,k;
//初始化矩阵A和矩阵B
for(i=0;i<n;i++)
for(j=0;j<n;j++) {
A[i][j] = 1.0;
B[i][j] = 1.0;
}
//并行计算矩阵C
#pragma omp parallel for shared(A,B,C) private(i,j,k)
for(i=0;i<n;i++)
for(j=0;j<n;j++){
C[i][j] = 0;
for(k=0;k<n;k++)
C[i][j]+=A[i][k]*B[k][j];
}
Return 0;
}
```
### 消息传递编程接口
MPIMessage Passing Interface定义了一组消息传递函数库的编程接口标准。1994年发布了MPI第1版MPI-1,1997年发布了扩充版MPI-22012年发布了MPI-3标准。有多种支持MPI标准的函数库实现开源实现有MPICH由Argonne National Laboratory (ANL) 和Mississippi State University开发、Open MPI 和LAM/MPI由Ohio超算中心开发商业实现来自于Intel、Microsoft、HP公司等。MPI编译器用于编译和链接MPI程序支持C、C++、Fortran语言如mpicc支持C语言、mpic++支持C++语言、mpif90支持Fortran90。MPI具有高可移植性和易用性对运行的硬件要求简单是目前国际上最流行的并行编程环境之一。
在MPI编程模型中计算由一个或多个通过调用库函数进行消息收/发通信的进程所组成。在绝大部分MPI实现中一组固定的进程在程序初始化时生成在一个处理器核上通常只生成一个进程。这些进程可以执行相同或不同的程序相应地称为单程序多数据SPMD)或多程序多数据MPMD)模式。进程间的通信可以是点到点的或者集合Collective的。MPI只是为程序员提供了一个并行环境库程序员用标准串行语言编写代码并在其中调用MPI的库函数来实现消息通信进行并行处理。
1.最基本的MPI
MPI是个复杂的系统包括129个函数根据1994年发布的MPI标准)。事实上1997年修订的MPI-2标准中函数已超过200个目前最常用的也有约30个但只需要6个最基本的函数就能编写MPI程序求解许多问题如下表所示。
| 序号 | 函数名 | 用途 |
| ---- | ------------- | -------------------- |
| 1 | MPI_Init() | 初始化MPI执行环境 |
| 2 | MPI_Finalize | 结束MPI执行环境 |
| 3 | MPI_COMM_SIZE | 确定进程数 |
| 4 | MPI_COMM_RANK | 确定自己的进程标识符 |
| 5 | MPI_SEND | 发送一条消息 |
| 6 | MPI_RECV | 接收一条信息 |
下面的代码显示了这6个基本函数的功能及参数情况。其中标号IN表明函数使用但是不能修改参数OUT表明函数不使用但是可以修改参数INOUT表明函数既可以使用也可以修改参数。
```c
MPI_INIT(int *argc, char *** argv)
//初始化计算,其中argc,argv只在C语言程序中需要它们是main函数的参数
// MPI_FINALIZE()
//结束计算
MPI_COMM_SIZE(comm,size)
//确定通信域的进程数
IN comm communicator(handle)
OUT size number of processes in the group of comm(integer)
MPI_COMM_RANK(comm,pid)
//确定当前进程在通信域中的进程号
IN comm communicator(handle)
OUT pidrank of the calling process in group of comm(integer)
MPI_SENDbuf, count, datatype, dest, tag, comm
//发送消息
IN buf initial address of send buffer(choice)
IN count number of elements to send(integer≥0)
IN datatype datatype of each send buffer elements(handle)
IN dest rank of destination (integer)
IN tag message tag(integer)
IN comm communicator(handle)
MPI_RECVbuf, count, datatype, source, tag, comm, status
//接收消息
OUT buf initial address of receivebuffer(choice)
IN count number of elements in receivebuffer (integer≥0)
IN datatype datatype of eachreceive buffer elements(handle)
IN source rank of source or MPI_ANY_SOURCE (integer)
IN tag message tag or MPI_ANY_TAG (integer)
IN comm communicator(handle)
OUT status status object (Status)
```
以下代码是一个简单C语言的MPI程序的例子其中MPI_COMM_WORLD是一个缺省的进程组它指明所有的进程都参与计算。
```c
#include “mpi.h”
Int main(int argc,char *argv[])
{ int myid,count;
MPI_Init(&agrc,&argv); /*启动计算*/
MPI_Comm_size(MPI_COMM_WORLD,&count); /*获得进程总数*/
MPI_Comm_rank(MPI_COMM_WORLD, &myid);/*获得自己进程号*/
printf(“I am %d of %d\n)”, myid,count); /*打印消息*/
MPI_Finalize();/*结束计算*/
}
```
2.集体通信
并行程序中经常需要一些进程组间的集体通信(Collective Communication),包括:①栅障(MPI_BARRIER),同步所有进程;②广播(MPI_BCAST)从一个进程发送一条数据给所有进程③收集MPI_GATHER从所有进程收集数据到一个进程④散播MPI_SCATTER从一个进程散发多条数据给所有进程⑤归约(MPI_REDUCE、MPI_ALLREDUCE)包括求和、求积等。这些函数的功能及参数描述参见MPI3.0标准。不同于点对点通信,所有的进程都必须执行集体通信函数。集体通信函数不需要同步操作就能使所有进程同步,因此可能造成死锁。这意味着集体通信函数必须在所有进程上以相同的顺序执行。
3.通信域
通信域Communicator提供了MPI中独立的安全的消息传递。MPI通信域包含进程组Process Group和通信上下文Context。其中进程组是参加通信的一个有限并有序的进程集合如果一共有N个进程参加通信则进程的编号从0到N-1。通信上下文提供一个相对独立的通信区域消息总是在其被发送的上下文内被接收不同上下文的消息互不干涉。通信上下文可以将不同的通信区别开来。MPI提供了一个预定义的通信域MPI_COMM_WORLDMPI初始化后就会产生它包含了初始化时可得的全部进程进程由它们在MPI_COMM_WORLD组中的进\[程号所标识。
用户可以在原有通信域的基础上定义新的通信域。MPI提供的通信域函数概括①MPI_COMMDUP它生成一个新的通信域具有相同的进程组和新的上下文这可确保不同目的通信不会混淆②MPI_COMMSPLIT它生成一个新的通信域但只是给定进程组的子集这些进程可相互通信而不必担心与其他并发计算相冲突③MPI_INTERCOMMCREATE它构造一个进程组之间的通信域该通信域链接两组内的进程④MPI_COMMFREE它用来释放上述三个函数所生成的通信域。
4.MPI点对点通信
点到点通信Point-to-Point Communication是MPI中较复杂的部分其数据传送有阻塞Blocking和非阻塞(Non_blocking)两种机制。在阻塞方式中,它必须等到消息从本地送出之后才可以执行后续的语句,保证了缓冲区等资源可再用;对于非阻塞方式,它无须等到消息从本地送出就可执行后续的语句,从而允许通信和计算的重叠,但非阻塞调用的返回并不保证资源的可再用性。
阻塞和非阻塞有四种通信模式①标准模式包括阻塞发送MPI_SEND、阻塞接收MPI_RECV、非阻塞发送MPI_ISEND和非阻塞接收MPI_IRECV②缓冲模式包括阻塞缓冲发送MPI_BSEND和非阻塞缓冲发送MPI_IBSEND③同步模式包括阻塞同步发送MPI_SSEND非阻塞同步发送MPI_ISSEND④就绪模式包括阻塞就绪发送MPI_RSEND和非阻塞就绪发送MPI_IRSEND。在标准通信模式中MPI根据当前的状况选取其他三种模式或用户定义的其他模式缓冲模式在相匹配的接收未开始的情况下将送出的消息放在缓冲区内这样发送者可以很快地继续计算然后由系统处理放在缓冲区中的消息但这占用内存且多了一次内存拷贝在同步模式中只有相匹配的接收操作开始后发送才能返回在就绪模式下只有相匹配的接收操作启动后发送操作才能开始。
在点到点通信中发送和接收语句必须是匹配的。为了区分不同进程或同一进程发送来的不同消息在这些语句中采用了通信域Comm和标志位tag来实现成对语句的匹配。
上述函数中关于MPI_SEND和MPI_RECV的功能和定义可以参考下代码块其他函数的描述可参考MPI3.0标准。
以下代码是计算π的C语言MPI程序的例子。
```c
#include <stdio.h>
#include “mpi.h”
int main(int argc, char **argv){
int num_steps=1000000;
double x,pi,step,sum,sumallprocs;
int i,start, end,temp;
//进程编号及组中的进程数量, 进程编号的范围为0到num_procs-1
int ID,num_procs;
MPI_Status status;
//Initialize the MPI environment
MPI_Init(&argc,&argv);
MPI_Comm_rank(MPI_COMM_WORLD,&ID);//
MPI_Comm_size(MPI_COMM_WORLD,&num_procs);
//任务划分并计算
step = 1.0/num_steps;
start = ID *(num_steps/num_procs) ;
if (ID == num_procs-1)
end = num_steps;
else
end = start + num_steps/num_procs;
for(i=start; i<end;i++) {
x=(i+0.5)*step;
sum += 4.0/(1.0+x*x);
}
MPI_Barrier(MPI_COMM_WORLD);
MPI_Reduce(&sum,&sumallprocs,1,MPI_DOUBLE,MPI_SUM,0, MPI_COMM_WORLD);
if(ID==0) {
pi = sumallprocs*step;
printf(“pi %1f\n”, pi);
}
MPI_Finalize();
return 0;
}
```
以下代码是进行矩阵乘的C语言MPI程序的例子。该例子将两个n阶的方阵A和B相乘结果存放在方阵C中A、B、C都在节点0上采用主从进程的计算方法主进程将数据发送给从进程从进程将计算结果返回给主进程。
```c
#include <stdio.h>
#include “mpi.h”
#define n 1000
int main(int argc, char **argv)
{
double*A,*B,*C;
int i,j,k;
int ID,num_procs,line;
MPI_Status status
MPI_Init(&argc,&argv); //Initialize the MPI environment
MPI_Comm_rank(MPI_COMM_WORLD,&ID);//获取当前进程号
MPI_Comm_size(MPI_COMM_WORLD,&num_procs);//获取进程数目
//分配数据空间
A = (double *)malloc(sizeof(double)*n*n);
B = (double *)malloc(sizeof(double)*n*n);
C = (double *)malloc(sizeof(double)*n*n);
line = n/num_procs;//按进程数来划分数据
if(ID==0){ //节点0主进程
//初始化数组
for(i=0;i<n;i++)
for(j=0;j<n;j++){
A[i*n+j] = 1.0;
B[i*n+j] = 1.0
}
//将矩阵A、B的相应数据发送给从进程
for(i=1;i<num_procs;i++) {
MPI_Send(B,n*n,MPI_DOUBLE,i,0,MPI_COMM_WORLD);
MPI_Send(A+(i-1)*line*n,line*n,MPI_DOUBLE,i,1,MPI_COMM_WORLD);
}
//接收从进程计算结果
for(i=1;i<num_procs;i++)
MPI_Recv(C+(i-1)*line*n,line*n,MPI_DOUBLE,i,2,MPI_COMM_WORLD,&status);
//计算剩下的数据
for(i=(num_procs-1)*line;i<n;i++)
for(j=0;j<n;j++) {
C[i*n+j]=0;
for(k=0;k<n;k++)
C[i*n+j]+=A[i*n+k]*B[k*n+j];
}
```
## 习题
1. 请介绍MPI中阻塞发送MPI_SEND/阻塞接收MPI_RECV与非阻塞发送MPI_ISEND/非阻塞接收MPI_IRECV的区别。
2. 请介绍什么是归约Reduce操作MPI和OpenMP中分别采用何种函数或者子句来实现归约操作。
3. 请介绍什么是栅障Barrier操作MPI和OpenMP中分别采用何种函数或者命令来实现栅障。
4. 下面的MPI程序片段是否正确请说明理由。假定只有2个进程正在运行且mypid为每个进程的进程号。
```c
If(mypid==0) {
MPI_Bcast(buf0,count,type,0,comm,ierr);
MPI_Send(buf1,count,type,1,tag,comm,ierr);
} else {
MPI_Recv(buf1,count,type,0,tag,comm,ierr);
MPI_Bcast(buf0,count,type,0,comm,ierr);
}
```
5. 矩阵乘是数值计算中的重要运算。假设有一个m×p的矩阵A还有一个p×n的矩阵B。令C为矩阵A与B的乘积即C=AB。表示矩阵在i,j位置处的值则0≤i≤m-1, 0≤j≤n-1。请采用OpenMP将矩阵C的计算并行化。假设矩阵在存储器中按行存放。
6. 请采用MPI将上题中矩阵C的计算并行化并比较OpenMP与MPI并行程序的特点。
7. 分析一款GPU的存储层次。
\newpage

422
21-multicore.Rmd Normal file
View File

@@ -0,0 +1,422 @@
# 多核处理结构
多核处理器Multicore Processor在单芯片上集成多个处理器核也称为单片多处理器Chip Multi-Processor简称CMP广泛应用于个人移动设备Personal Mobile Device简称PMD、个人电脑PC、服务器、高性能计算机等领域。本章从结构角度对多核处理器进行分析。
## 多核处理器的发展演化
多核处理器在单芯片上集成多个处理器核通过聚合芯片上的多个处理器核的计算能力来提高应用程序执行性能。多核处理器大致可从以下方面进行分类从核的数量角度可分为多核处理器和众核处理器一般大于64核为众核处理器从处理器核的结构角度可分为同构和异构同构是指核结构是相同的而异构是指核结构是不同的从适用应用角度可分为面向桌面电脑、服务器等应用的通用多核处理器以及面向特定应用的多核/众核处理器如GPU可看作是一种特定的众核处理器具有很高的浮点峰值性能。
多核处理器主要在多处理器系统的研究基础上发展而来。多处理器系统的研究已经有几十年的历史。20世纪七、八十年代由于单个处理器的性能满足不了应用的需求开始出现多处理器系统。20世纪八、九十年代很多高档工作站都有2\~4个处理器用于科学计算的高性能计算机处理器个数更多。国际上对计算机性能有一个TOP500排名每6个月列出当时世界上最快的前500台计算机这些计算机都有成千上万个处理器。从20世纪90年代后期开始随着半导体工艺的发展单芯片上晶体管数目大幅增多多核处理器得到了很好的发展。学术界最早的多核处理器项目Hydra是由美国斯坦福大学于1994年研究的。在工业界IBM公司于2001年推出IBM Power4双核处理器AMD于2005年推出第一款X86架构双核处理器Intel于2006年推出第一款酷睿双核处理器国内于2009年推出了第一款四核龙芯3A处理器。
很明显可以看出,多核处理器是在多处理器系统上发展的,其发展的主要驱动力包括以下三个方面。
1半导体工艺发展
摩尔定律是过去40多年间描述半导体工艺发展的经验法则。1965年Gordon MooreIntel 公司联合创始人提出半导体芯片上集成的晶体管和电阻数量将每年增加一倍。1975年对摩尔定律进行了修正把“每年增加一倍”改为“每两年增加一倍”。现在摩尔定律流行的表述为集成电路芯片上所集成的晶体管数目每隔18个月就翻一倍。目前主流处理器工艺已经到14nm-7nm工艺在单芯片上集成数十亿甚至上百亿个晶体管。不过摩尔定律不可能永远延续2015年ITRSInternational Technology Roadmap for Semiconductors预测晶体管尺寸可能在2021年后停止缩小。目前工艺升级的速度已经从1-2年升级一代放慢到3-5年升级一代而且工艺升级带来的性能、成本、功耗的好处已经不大。
2功耗墙问题
功耗墙问题也是处理器从单核转到多核设计的一个非常重要的因素。面对单芯片上的大量晶体管,如何设计处理器有两种思路,一种是单芯片设计复杂的单处理器核,另一种是单芯片设计多个处理器核。理论来说采用后一种思路性能功耗比收益较大。芯片功耗主要由静态功耗和动态功耗组成,而动态功耗则由开关功耗和短路功耗组成。其中开关功耗是由芯片中电路信号翻转造成的,是芯片功耗的主体。下面给出了开关功耗的计算公式,其中$C_{load}$为电路的负载电容,$V$为电路的工作电压,$f$为电路的时钟频率。
$$ P_{switch} = \frac{1}{2} C_{load} V^{2} f $$
单芯片设计复杂单处理器核提高性能的主要方法包括通过微结构优化提高每个时钟周期发射执行的指令数以及通过提高主频来提高性能。微结构优化的方法由于受到程序固有指令级并行性以及微结构复杂性等因素限制在达到每个时钟周期发射执行4条指令后就很难有性能收益。提高电压和主频的方法导致功耗随着主频的提高超线性增长。例如通过电压提升10%可以使主频提升10%根据开关功耗计算公式开关功耗跟主频成正比跟电压的平方成正比即在一定范围内功耗与主频的三次方成正比主频提高10%导致功耗提高30%。
单芯片设计多个处理器核提高性能的方法通过增加处理器核的个数提升处理器并行处理的性能。当处理器核数目增加N倍时功耗也大致增加N倍性能也增加N倍此处性能主要指运行多个程序的吞吐率也就是说功耗随着性能的提高线性增长。
2005年以前单芯片设计复杂单处理器核以提高性能是微处理器发展的主流。以Intel公司由于功耗墙问题放弃4GHz的Pentium IV处理器研发为标志2005年之后单芯片设计多处理器核成为主流。
3并行结构的发展
多处理器系统经过长期发展,为研制多核处理器打下了很好的技术基础。例如,多处理器系统的并行处理结构、编程模型等可以直接应用于多核处理器上。因此有一种观点认为:将传统多处理器结构实现在单芯片上就是多核处理器。
在处理器内部、多个处理器之间以及多个计算机节点之间有多种不同的并行处理结构。
**1SIMD结构。** 指采用单指令同时处理一组数据的并行处理结构。采用SIMD结构的Cray系列向量机包含向量寄存器和向量功能部件单条向量指令可以处理一组数据。例如Cray-1的向量寄存器存储64个64位的数据CrayC-90的向量寄存器存储128个64位的数据。以Cray系列向量机为代表的向量机在20世纪70年代和80年代前期曾经是高性能计算机发展的主流在商业、金融、科学计算等领域发挥了重要作用其缺点是难以达到很高的并行度。如今虽然向量机不再是计算机发展的主流但目前的高性能处理器普遍通过SIMD结构的短向量部件来提高性能。例如Intel处理器的SIMD指令扩展实现不同宽度数据的处理如SSEStreaming SIMD Extensions扩展一条指令可实现128位数据计算可分为4个32位数据或者2个64位数据或者16个8位数据AVXAdvanced Vector Extensions扩展可实现256位或者512位数据计算。
**2对称多处理器Symmetric MultiProcessor简称SMP结构。** 指若干处理器通过共享总线或交叉开关等统一访问共享存储器的结构各个处理器具有相同的访问存储器性能。20世纪八九十年代DEC、SUN、SGI等公司的高档工作站多采用SMP结构。这种系统的可伸缩性也是有限的。SMP系统常被作为一个节点来构成更大的并行系统。多核处理器也常采用SMP结构往往支持数个到十多个处理器核。
**3高速缓存一致非均匀存储器访问Cache Coherent Non-Uniform Memory Access简称CC-NUMA结构。**CC-NUMA结构是一种分布式共享存储体系结构其共享存储器按模块分散在各处理器附近处理器访问本地存储器和远程存储器的延迟不同共享数据可进入处理器私有高速缓存并由系统保证同一数据的多个副本的一致性。CC-NUMA的可扩展性比SMP结构要好支持更多核共享存储但由于其硬件维护数据一致性导致复杂性高可扩展性也是有限的。典型的例子有斯坦福大学的DASH和FLASH以及20世纪90年代风靡全球的SGI的Origin 2000。IBM、HP的高端服务也采用CC-NUMA结构。Origin 2000可支持上千个处理器组成CC-NUMA系统。有些多核处理器也支持CC-NUMA扩展例如4片16核龙芯3C5000处理器通过系统总线互连直接形成64核的CC-NUMA系统。
**4MPPMassive Parallel Processing系统。**指在同一地点由大量处理单元构成的并行计算机系统。每个处理单元可以是单机也可以是SMP系统。处理单元之间通常由可伸缩的互连网络如Mesh、交叉开关网络等相连。MPP系统主要用于高性能计算。
**5机群系统。**指将大量服务器或工作站通过高速网络互连来构成廉价的高性能计算机系统。机群计算可以充分利用现有的计算、内存、文件等资源用较少的投资实现高性能计算也适用于云计算。随着互连网络的快速发展机群系统和MPP系统的界限越来越模糊。
从结构的角度看多处理器系统可分为共享存储系统和消息传递系统两类。SMP和CC-NUMA结构是典型的共享存储系统。在共享存储系统中所有处理器共享主存储器每个处理器都可以把信息存入主存储器或从中取出信息处理器之间的通信通过访问共享存储器来实现。MPP和机群系统往往是消息传递系统在消息传递系统中每个处理器都有一个只有它自己才能访问的局部存储器处理器之间的通信必须通过显式的消息传递来进行。
尽管消息传递的多处理器系统对发展多核处理器也很有帮助如GPU但是通用多核处理器主要是从共享存储的多处理器系统演化而来。多核处理器与早期SMP多路服务器系统在结构上并没有本质的区别。例如多路服务器共享内存通过总线或者交叉开关实现处理器间通信多核处理器共享最后一级Cache和内存通过片上总线、交叉开关或者Mesh网络等实现处理器核间通信。
通用多核处理器用于手持终端、桌面电脑和服务器,是最常见、最典型的多核处理器,通常采用共享存储结构,它的每个处理器核都能够读取和执行指令,可以很好地加速多线程程序的执行。本章主要以通用多核处理器为例来分析多核处理器结构。通用多核处理器结构设计与共享存储多处理器设计的主要内容相似,包括多核处理器的访存结构、多核处理器的互连结构、多核处理器的同步机制等。
## 多核处理器的访存结构
通用多核处理器采用共享存储结构,其设计存在如下关键问题:
1片上Cache如何组织与单核处理器类似多核处理器需要在片上设置大容量的Cache来缓解芯片计算能力与访存性能之间日益扩大的差距。片上Cache如何组织Cache结构采用私有还是共享集中式还是分布式这些是需要设计者考虑的问题。
2多个处理器核发出的访存指令次序如何约定各处理器核并行执行线程或者进程发出读/写load/store访存指令这些访问指令的执行次序如何约定使得应用程序员可以利用这些约定来推理程序的执行结果。存储一致性模型就是用来解决这方面问题的。
3如何维护Cache数据一致性一个数据可能同时在多个处理器核的私有Cache中和内存中存在备份如何保证数据一致性Cache一致性协议将解决Cache一致性问题。
### 通用多核处理器的片上Cache结构
片上Cache结构是通用多核处理器设计的重要内容。片上Cache的种类主要有私有Cache、片上共享Cache、片间共享Cache。图\@ref(fig:cache-structure)a是私有Cache结构示意图图\@ref(fig:cache-structure)b是片上共享Cache结构示意图由于一级Cache的访问速度对性能影响大通用多核处理器的一级Cache几乎都是私有的。私有Cache结构具有较快的访问速度但是具有较高的失效率。共享Cache结构的访问速度稍慢但具有失效率低的优点。多处理器芯片间共享Cache结构的访问速度慢且失效率高因此并不常用。
```{r cache-structure, fig.cap='Cache结构示意图', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter11/cache_structure.png')
```
<!-- 图11.1 Cache结构图 -->
目前主流多核处理器的典型Cache 结构是片内共享最后一级CacheLast Level Cache简称LLC片间共享内存。表\@ref(tab:cache-parameter)列出了典型商用多核处理器的Cache结构参数。处理器核的一级Cache和二级Cache私有三级CacheLLC共享。有些处理器甚至有片外的四级Cache例如Intel i7处理器。
```{r cache-parameter, echo = FALSE, message=FALSE, tab.cap='商用多核处理器主要参数示例'}
autonum <- run_autonum(seq_id = "tab", bkm = "cache-parameter", bkm_all = TRUE)
readr::read_csv('./materials/chapter11/cache_parameter.csv') %>%
flextable() %>%
set_caption(caption="商用多核处理器主要参数示例", autonum = autonum) %>%
theme_box() %>%
autofit()
```
在共享LLC结构中主要有UCAUniform Cache Access和NUCANon-Uniform Cache Access两种。图\@ref(fig:shared-llc)为共享LLC结构示意图假设二级Cache为LLC
```{r shared-llc, fig.cap='共享LLC结构示意图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter11/shared_llc.png')
```
UCA是一种集中式共享结构多个处理器核通过总线或者交叉开关连接LLC所有处理器核对LLC的访问延迟相同。这种集中式的共享LLC很容易随着处理器核数目的增加成为瓶颈。另外UCA结构由于使用总线或者交叉开关互连可扩展性受限。因此通常在处理器核数较少的多核处理器中采用UCA结构例如四核龙芯3号处理器。
NUCA是一种分布式共享结构每个处理器核拥有本地的LLC并通过片上互连访问其他处理器核的LLC。在NUCA结构中处理器核可以访问所有的LLC但是不同位置的LLC具有不同的访问延迟。当工作集较小时处理器核的本地Cache足够容纳工作集处理器核只使用本地Cache当工作集较大时本地Cache中放不下的数据可以放到远地Cache中。NUCA结构需要高效Cache查找和替换算法使得在使用远地Cache时不影响性能。NUCA结构中通常采用可扩展的片上互连如Mesh片上网络等采用基于目录的Cache一致性协议具有良好的可扩展性可以有效支持较多数目的处理器核。因此在具有较多核数的多核/众核处理器中通常采用NUCA结构如SPARC M7和龙芯3C5000等。
### 存储一致性模型
本节简要介绍常见的存储一致性模型。存储一致性模型最初是针对共享存储的多处理器设计提出来的,同样也可以适用于多核处理器设计。本节在介绍存储一致性模型时,处理器(处理机)和处理器核在概念是可以互用的。
下面举一个存储一致性问题的例子。如表\@ref(tab:shared-memory-program)所示寄存器R1为进程P2的内部寄存器R2和R3为P3的内部寄存器初始值均为0变量a,b为P1、P2和P3的共享变量初始值均为0。
```{r shared-memory-program, tab.cap='共享存储程序片段', echo = FALSE}
autonum <- run_autonum(seq_id = "tab", bkm = "shared-memory-program", bkm_all = TRUE)
dt <- data.frame('P1' = 'L11: STORE a, 1;',
'P2' = 'L21: LOAD R1, a;\nL22: STORE b, 1;',
'P3' = 'L31: LOAD R2, b;\nL32: LOAD R3, a;')
flextable(dt) %>%
set_caption(caption="共享存储程序片段", autonum = autonum) %>%
width(width=2.0) %>%
theme_box()
```
在表\@ref(tab:shared-memory-program)所示的程序中如果仅要求P1、P2及P3根据指令在程序中出现的次序来执行指令那么这个程序的访存事件可能按如下次序发生
1. P1发出存数操作L11
2. L11到达P2但由于网络堵塞等原因L11未到达P3
3. P2发出取数操作L21 取回a的新值
4. P2发出存数操作L22且其所存的b新值到达P3
5. P3发出取数操作L31取回b的新值
6. P3发出取数操作L32但由于L11 未到达P3故L32取回a的旧值
7. L11到达P3。
这是一个程序员难以接受的执行结果。因为从程序员的观点来看如果L21 和L31分别取回a 和b 的新值则说明存数操作L11 和L22 都已完成L32必然取回a 的新值。在此例中,即使每个处理器都根据指令在程序中出现的次序来执行指令,仍然会导致错误的结果。从这个例子可以看出,在共享存储系统中,需要对多处理器的访存操作的次序做出限制,才能保证程序执行的正确。
存储一致性模型是多处理器系统设计者与应用程序员之间的一种约定,它给出了正确编写程序的标准,使得程序员无须考虑具体访存次序就能编写正确程序,而系统设计者则可以根据这个约定来优化设计提高性能。系统设计者通过对各处理器的访存操作完成次序加以必要的约束来满足存储一致性模型的要求。
文献中常见的存储一致性模型包括:顺序一致性模型、处理器一致性模型、弱一致性模型、释放一致性模型等。这些存储一致性模型对访存事件次序的限制不同,因而对程序员的要求以及所能得到的性能也不一样。存储一致性模型对访存事件次序施加的限制越弱越有利于提高性能,但编程越难。下面介绍具体的存储一致性模型。
**1顺序一致性Sequential Consistency简称SC模型。**这种模型是程序员最乐于接受的存储一致性模型,最符合程序员的直觉。对于满足顺序一致性的多处理机中的任一执行,总可以找到同一程序在单机多进程环境下的一个执行与之对应,使得二者结果相等。
为了放松对访存事件次序的限制,人们提出了一系列弱存储一致性模型。 这些弱存储一致性模型的基本思想是:在顺序一致性模型中,虽然为了保证正确执行而对访存事件次序施加了严格的限制,但在大多数不会引起访存冲突的情况下,这些限制是多余的,极大地限制了系统优化空间进而影响了系统性能。因此可以让程序员承担部分执行正确性的责任,即在程序中指出需要维护一致性的访存操作,系统只保证在用户指出的需要保持一致性的地方维护数据一致性,而对用户未加说明的部分,可以不考虑处理器之间的数据相关。
**2处理器一致性Processor Consistency简称PC模型。**这种模型比顺序一致性模型弱故对于某些在顺序一致条件下能正确执行的程序在处理器一致条件下执行时可能会导致错误结果。处理器一致性模型对访存事件发生次序施加的限制是在任一取数操作load被允许执行之前所有在同一处理器中先于这一load的取数操作都已完成在任一存数操作store被允许执行之前所有在同一处理器中先于这一store的访存操作包括load和store都已完成。上述条件允许store之后的load越过store而执行在实现上很有意义在Cache命中的load指令写回之后但没有提交之前如果收到其他处理器对load所访问Cache行的无效请求load指令可以不用取消较大地简化了流水线的设计。多核龙芯3号处理器设计中就采用了处理器一致性。
**3弱一致性Weak Consistency简称WC模型。**这种模型的主要思想是把同步操作和普通访存操作区分开来,程序员必须用硬件可识别的同步操作把对可写共享单元的访问保护起来,以保证多个处理器对可写共享单元的访问是互斥的。弱一致性模型对访存事件发生次序做如下限制:同步操作的执行满足顺序一致性条件;在任一普通访存操作被允许执行之前,所有在同一处理器中先于这一访存操作的同步操作都已完成;在任一同步操作被允许执行之前,所有在同一处理器中先于这一同步操作的普通访存操作都已完成。上述条件允许在同步操作之间的普通访存操作执行时不用考虑进程之间的相关。虽然弱一致性模型增加了程序员的负担,但它能有效地提高性能。值得指出的是,即使是在顺序一致的共享存储并行程序中,同步操作也是难以避免的,否则程序的行为难以确定。因此,在弱一致性模型的程序中,专门为数据一致性而增加的同步操作不多。
**4释放一致性Release Consistency简称RC模型。**这种模型是对弱一致性模型的改进它把同步操作进一步分成获取操作acquire和释放操作release。acquire 用于获取对某些共享存储单元的独占性访问权而release 则用于释放这种访问权。释放一致性模型对访存事件发生次序做如下限制同步操作的执行满足顺序一致性条件在任一普通访存操作被允许执行之前所有在同一处理器中先于这一访存操作的acquire操作都已完成在任一release操作被允许执行之前所有在同一处理器中先于这一release的普通访存操作都已完成。
### Cache一致性协议
在共享存储的多核处理器中存在Cache一致性问题即如何使同一数据块在不同Cache以及主存中的多个备份保持数据一致的问题。具体来说一个数据块可能在主存和Cache之中保存多份而不同的处理器核有可能同时读取或者修改这个数据导致不同的处理器核观察到的数据的值是不同的。Cache一致性协议Cache Coherence Protocol是指在共享存储的多处理器或者多核处理器系统中一种用来保持多个Cache之间以及Cache与主存之间数据一致的机制。人们已经提出了若干Cache一致性协议来解决这个问题。
1.Cache一致性协议的分类
Cache一致性协议的具体作用就是把某个处理器核新写的值传播给其他处理器核以确保所有处理器核看到一致的共享存储内容。从如何传播新值的角度看Cache一致性协议可分为写无效Write-Invalidate也可称为写使无效协议与写更新Write-Update协议从新值将会传播给谁的角度看它可以分为侦听协议与目录协议。Cache一致性协议决定系统为维护一致性所做的具体动作因而直接影响系统性能。
**1写无效协议与写更新协议。**在写无效协议中,当根据一致性要求要把一个处理器核对某一单元所写的值传播给其他处理器核时,就使其他处理器核中该单元的备份无效;其他处理器核随后要用到该单元时,再获得该单元的新值。在写更新协议中,当根据一致性要求要把一个处理器核对某一单元所写的值传播给其他处理器核时,就把该单元的新值传播给所有拥有该单元备份的处理器核,对相应的备份进行更新。
写无效协议的优点是一旦某处理器核使某一变量在所有其他Cache中的备份无效后它就取得了对此变量的独占权随后它可以随意地更新此变量而不必告知其他处理器核直到其他处理器核请求访问此变量而导致独占权被剥夺。其缺点是当某变量在一处理器核中的备份变无效后此处理器核再读此变量时会引起Cache不命中在一个共享块被多个处理器核频繁访问的情况下会引起所谓的“乒乓”效应即处理器核之间频繁地互相剥夺对一个共享块的访问权而导致性能严重下降。写更新协议的优点是一旦某Cache缓存了某一变量它就一直持有此变量的最新备份除非此变量被替换掉。其缺点是写数的处理器核每次都得把所写的值传播给其他处理器核即使其他处理器核不再使用所写的共享块。写无效协议适用于顺序共享Sequential Sharing的程序即在较长时间内只有一个处理器核访问一个变量而写更新协议适用于紧密共享Tight Sharing的程序即多个处理器核在一段时间内频繁地访问同一变量。
**2侦听协议与目录协议。**侦听协议的基本思想是当一个处理器核对共享变量的访问不在Cache 命中或可能引起数据不一致时它就把这一事件广播到所有处理器核。系统中所有处理器核的Cache都侦听广播当拥有广播中涉及的共享变量的Cache侦听到广播后就采取相应的维持一致性的行动使本Cache的备份无效、向总线提供数据等。侦听协议实现较简单每个处理器核Cache只需要维护状态信息就可以了。侦听协议适合于通过总线互连的多核处理器因为总线是一种方便而快捷的广播媒介。在写使无效侦听协议中当一个Cache侦听到其他处理器核欲写某一单元且自己持有此单元的备份时就使这一备份无效以保持数据一致性在写更新侦听协议中当一个Cache侦听到自己持有备份的某一共享单元的内容被其他处理器核所更新时就根据侦听到的内容更新此备份的值。
由于侦听协议需要广播因此只适用于共享总线结构。总线是一种独占式资源且总线延迟随所连接的处理器核数目的增加而增加存在可伸缩性差的问题。在采用片上网络互连的多核处理器中通常使用基于目录的Cache一致性协议。目录协议的主要思想是为每一存储行维持一目录项该目录项记录所有当前持有此行备份的处理器核号以及此行是否已被改写等信息。当一个处理器核欲往某一存储行写数且可能引起数据不一致时它就根据目录的内容只向持有此行的备份的那些处理器核发出写使无效/写更新信号从而避免了广播。典型的目录组织方式为位向量目录。位向量目录中的每一目录项有一个n位的向量其中n是系统中处理器核的个数。位向量中第i位为“1”表示此存储行在第i个处理器核中有备份。每一目录项还有一改写位当改写位为“1”时表示某处理器核独占并已改写此行。位向量目录的缺点是所需的目录存储器容量随处理器核数n以及共享存储容量m的增加以O(m*n)的速度增加,有较大存储开销。
2.Cache 状态
Cache一致性协议的实现方式为在Cache中每一个Cache行设置一致性状态来记录该Cache行的读写状态确保Cache行不会被多个处理器核同时修改。Cache行的一致性状态的实现有多种具体形式如最简单的三状态ESI较为常见的MESI及其变种MOESI等。
ESI 是指Cache 行的三种一致性状态EExclusive独占SShared共享I Invalid无效。Invalid状态表示当前Cache行是无效的对其进行任何读写操作都会引发缓存缺失Cache Miss。Shared状态表明当前Cache行可能被多个处理器核共享只能读取不能写入对其写入也会引发缓存缺失。Exclusive状态表明对应Cache行被当前处理器核独占该处理器核可以任意读写这个Cache行而其他处理器核如果想读写这个Cache行需要请求占有这个Cache行的处理器核释放该Cache行。图\@ref(fig:esi-transit)给出了三个状态之间的转换关系。
```{r esi-transit, fig.cap='三状态Cache一致性协议状态转换图', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter11/esi_transit.png')
```
MESI 在ESI 的基础上增加了MModified修改状态。其中Shared状态和Invalid状态和ESI的完全一样而Exclusive 状态表示当前Cache块虽然被当前处理器核独占但是还没有被修改与内存中的数据保持一致如果处理器核想将其替换出去并不需要将该Cache行写回内存。Modified状态表示当前Cache行被当前处理器核独占并且已经被修改过了如果处理器核想替换该Cache行需要将该Cache行写回内存。与ESI协议相比增加一个Modified状态的优点是减少了Cache到内存的数据传输次数Cache只需要将Modified状态的Cache行写回内存。
下面通过一个写无效的位向量目录协议例子简单说明Cache一致性协议的工作原理。通常,一个Cache一致性协议应包括以下三方面的内容Cache行状态、存储行状态以及为保持Cache一致性的状态转化规则。
该协议采用ESI实现Cache的每一行都有三种状态无效状态INV、共享状态SHD以及独占状态EXC。在存储器中每一行都有一相应的目录项。每一目录项有一n位的向量其中n是系统中处理器核的个数。位向量中第i位为“1”表示此存储行在第i个处理器核Pi中有备份。此外每一目录项有一改写位当改写位为“1”时表示某处理器核独占并已改写此行相应的存储行处于DIRTY 状态否则相应的存储行处于CLEAN 状态。
当处理器核Pi发出一取数操作“LOAD x”时根据x在Cache 和存储器中的不同状态采取如下不同的操作若x在Pi的Cache中处于共享或独占状态则取数操作“LOAD x”在Cache 命中。若x在Pi的Cache 中处于无效状态那么这个处理器核向存储器发出一个读数请求read(x)。存储器在收到这个read(x)后查找与单元x相对应的目录项如果目录项的内容显示出x所在的存储行处于CLEAN 状态改写位为“0”即x在存储器的内容是有效的那么存储器向发出请求的处理器核Pi发出读数应答rdack(x)提供x所在行的一个有效备份并把目录项中位向量的第i位置为“1”如果目录项的内容显示出x所在的存储行已被某个处理器核Pk改写改写位为“1”那么存储器向Pk发出一个写回请求wtbk(x)Pk在收到wtbk(x)后把x在Cache的备份从独占状态EXC改为共享状态SHD并向存储器发出写回应答wback(x)提供x所在行的一个有效备份存储器收到来自Pk的wback(x)后向发出请求的处理器核Pi发出读数应答rdack(x)提供x所在行的一个有效备份把目录项中的改写位置为“0”并把位向量的第i位置为“1”。如果x不在Pi的Cache中那么Pi先从Cache中替换掉一行再向存储器发出一个读数请求read(x)。
当处理器核Pi发出一存数操作“STORE x”时根据x 在Cache和存储器中的不同状态采取如下不同的操作若x在Pi的Cache中处于独占状态则存数操作“STORE x”在Cache 命中。若x在Pi的Cache中处于共享状态那么这个处理器核向存储器发出一个写数请求write(x)存储器在收到这个write(x)后查找与单元x 相对应的目录项如果目录项的内容显示出x所在的存储行处于CLEAN 状态改写位为“0”并没有被其他处理器核所共享位向量中所有位都为“0”那么存储器向发出请求的处理器核Pi发出写数应答wtack(x)表示允许Pi独占x所在行把目录项中的改写位置为“1”并把位向量的第i位置为“1”如果目录项的内容显示出x所在的存储行处于CLEAN 状态改写位为“0”并且在其他处理器核中有共享备份位向量中有些位为“1”那么存储器根据位向量的内容向所有持有x的共享备份的处理器核发出一个使无效信号invld(x)持有x的有效备份的处理器核在收到invld(x)后把x在Cache的备份从共享状态SHD改为无效状态INV并向存储器发出使无效应答invack(x)存储器收到所有invack(x)后向发出请求的处理器核Pi发出写数应答wtack(x)把目录项中的改写位置为“1”并把位向量的第i位置为“1”其他位清“0”。若x在Pi的Cache中处于无效状态那么这个处理器核向存储器发出一个写数请求write(x)存储器在收到这个write(x)后查找与单元x相对应的目录项如果目录项的内容显示出x所在的存储行处于CLEAN 状态改写位为“0”并没有被其他处理器核所共享位向量中所有位都为“0”那么存储器向发出请求的处理器核Pi发出写数应答wtack(x)提供x所在行的一个有效备份把目录项中的改写位置为“1”并把位向量的第i位置为“1”如果目录项的内容显示出x所在的存储行处于CLEAN 状态改写位为“0”并且在其他处理器核中有共享备份位向量中有些位为“1”那么存储器根据位向量的内容向所有持有x的共享备份的处理器核发出一个使无效信号invld(x)持有x的有效备份的处理器核在收到invld(x)后把x在Cache 的备份从共享状态SHD改为无效状态INV并向存储器发出使无效应答invack(x)存储器收到所有invack(x)后向发出请求的处理器核Pi发出写数应答wtack(x)提供x所在行的一个有效备份把目录项中的改写位置为“1”并把位向量的第i 位置为“1”其他位清“0”如果目录项的内容显示出x所在的存储行已被某个处理器核Pk改写改写位为“1”位向量第k 位为“1”那么存储器向Pk发出一个使无效并写回请求invwb(x)Pk在收到invwb(x)后把x在Cache的备份从独占状态EXC改为无效状态INV并向存储器发出使无效并写回应答invwback(x)提供x所在行的有效备份存储器收到来自Pk 的invwback(x)后向发出请求的处理器核Pi发出写数应答wtack(x)提供x所在行的一个有效备份把目录项中的改写位置为“1”并把位向量的第i位置为“1”其他位清“0”。如果x不在Pi的Cache中那么Pi先从Cache中替换掉一行再向存储器发出一个写数请求write(x)。
如果某处理器核要替换一Cache行且被替换行处在EXC状态那么这个处理器核需要向存储器发出一个替换请求rep(x)把被替换掉的行写回存储器。
假设单元x 初始时在存储器中处于CLEAN状态(改写位为“0”)并被处理器核Pj和Pk所共享在Pj和Pk的Cache中处于SHD状态如图\@ref(fig:dir-invld)a所示。接着x被多个处理器核按如下次序访问处理器核Pi发出存数操作“STORE x”处理器核Pk发出存数操作“STORE x”处理器核Pi发出取数操作“LOAD x”处理器 Pj发出取数操作“LOAD x”。图\@ref(fig:dir-invld)b\~f显示出上述访问序列引起的一系列消息传递以及x在Cache及在存储器中的状态的转化过程。
```{r dir-invld, fig.cap='基于目录的写无效Cache一致性协议', fig.align='center', fig.show="hold", echo = FALSE, out.width='80%'}
knitr::include_graphics('./images/chapter11/dir_invld.png')
```
## 多核处理器的互连结构
多核处理器通过片上互连将处理器核、Cache、内存控制器、IO 接口等模块连接起来。图\@ref(fig:nuca-interconnect)为一个NUCA结构的多核处理器的片上互连示意图。常见的片上互连结构包括片上总线、交叉开关和片上网络。图\@ref(fig:interconnect-type)为三种结构的对比示意图。其中共享总线结构和交叉开关结构因可伸缩性差的原因主要用于小规模的多核处理器片上网络Network-on-Chip简称NOC具有可伸缩性好的优势适合于核数较多的多核/众核处理器。
```{r nuca-interconnect, fig.cap='NUCA架构多核处理器的片上互连', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter11/nuca_interconnect.png')
```
```{r interconnect-type, fig.cap='片上互连结构分类', fig.align='center', fig.show="hold", echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter11/interconnect.png')
```
**1.片上总线**
传统的计算机系统的总线通常由一组信号线把多功能模块连接在一起。通过信号线上的信号表示信息通过约定不同信号的先后次序约定操作如何实现。根据传输信息的种类不同可以划分为数据总线、地址总线和控制总线分别用来传输数据、数据地址和控制信号。标准化的总线可以方便各部件间互连因此出现了许多总线标准例如ISA、PCI、USB总线标准等。
片上总线主要用于多核处理器设计它是片上各个部件间通信的公共通路由一组线组成。片上总线标准通常包括总线位宽、总线时序、总线仲裁等。常见的片上总线标准包括IBM公司的CoreConnect、ARM公司的AMBA总线标准、Silicore公司的Wishbone片上总线协议等。
片上总线的优点是实现简单,在其上易于实现广播通信,其缺点主要是可伸缩性不好。片上总线是一种独占式资源,其总线延迟随所连接节点数的增加而增加,每个节点分得的总线带宽随连接节点数的增加而较少,导致可伸缩性不好。片上总线适合用在连接节点不多的场合,常用于处理器核不多的多核处理器中。
**2.交叉开关**
交叉开关可以看作一个以矩阵形式组织的开关集合。在一个M个输入、N个输出的交叉开关中每个输出端口都可以接任意输入端口。交叉开关有多个输入线和输出线这些线交叉连接在一起交叉点可以看作单个开关。当一个输入线与输出线的连接点处开关导通时则在输入线与输出线之间建立一个连接。交叉开关具有非阻塞Non-blocking特性可以建立多个输入与输出之间的连接在不存在冲突的情况下这些连接上的通信不会互相干扰。采用交叉开关通信的两个节点独享该连接的带宽当有多对节点之间建立连接进行通信时总带宽就会变大。
交叉开关的优点是高带宽多对输入与输出端口间可以并行通信且总带宽随所连接节点数的增加而增加。但缺点是随着连接节点数的增加交叉开关需要的交叉点数目增加较快物理实现代价较高复杂度为O(M*N)因此可伸缩性有限也不适合连接节点数多的情况。例如对于一个有M个输入端口和N个输出端口的交叉开关要增加成M输入端口和N个输出端口的交叉开关则需要增加M+N+1个交叉点。四核龙芯号处理器的设计即采用交叉开关来互连处理器核和共享二级Cache体。
**3.片上网络**
针对传统互连结构的局限C. Seitz和W. Dally在21世纪初首先提出了片上网络的概念。图\@ref(fig:noc-example)中有6个处理器核节点连接到网络中P0\~P5当节点P2与P5进行数据通信时它首先发送一个带有数据包的消息到网络中然后网络将这个消息传输给P5。片上网络借鉴了分布式网络的TCP/IP协议传输数据的方式将数据封装成数据包通过路由器之间的分组交换和对应的存储-转发机制来实现处理器核间的通信。在片上网络中片上多核处理器被抽象成节点、互连网络、网络接口Network Interface等元素。片上网络研究内容主要包括拓扑结构、路由算法、流量控制Flow Control、服务质量等。
```{r noc-example, fig.cap='片上网络示意图', fig.align='center', echo = FALSE, out.width='80%'}
knitr::include_graphics('./images/chapter11/noc_example.png')
```
**1拓扑结构。**片上网络是由节点和传输信道的集合构成的。片上网络的拓扑是指网络中节点和信道的排列方式。环Ring、网格Mesh拓扑结构为最常见的两种。如图\@ref(fig:topo)所示Mesh拓扑结构中包含16个节点编号为0到15每个节点与4条边相连但因为图中所示的边是双向的每一条边可以看作两条方向相反的有向边因此图中每个节点实际上是与8条信道线路相连。IBM CELL处理器和Intel SandyBridge处理器采用环连接Tilera公司的Tile64处理器采用Mesh互连。
```{r topo, fig.cap='拓扑结构示意图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter11/topo.png')
```
**2路由算法。**片上网络所采用的路由方法决定了数据包从源节点到目的节点的传输路径。路径是传输信道的集合,即$P = {c_1,c_2,…c_k}$, 其中当前信道$c_i$的输出节点与下一跳信道$c_{i+1}$的输入节点相同。在某些片上网络拓扑结构中如环从某个源节点出发到目的节点的路径只有唯一的一条对于某些片上网络拓扑结构来说如Mesh可能有多条路径。
路径的选择可以遵循很多原则针对如Mesh这样的网络拓扑结构最常见的最短路径选择有两种
- 维序路由Dimension-Order Routing,简称DOR。这是最简单、最直接的最短路径路由它的策略是首先选择一个维度方向传输当此维度走到目的地址相同维度方向后再改变到其他维度。比如对于网格结构的拓扑路径的选择可以是先沿X方向水平方向走到与目的地址一致的列再选择Y方向竖直方向
- 全局自适应路由Adaptive Routing。这是为了解决局部负载不均衡的情况而产生的路由方法简单来说就是在每个节点有多种方向选择时优先选择负载较轻的那一个节点方向作为路径。
**3路由器结构。**路由器由寄存器、交叉开关、功能单元和控制逻辑组成这些部件共同实现了路由计算和流控制为了存储和转发流控单元flit到它们的目的地节点所需的控制功能。这里主要介绍经典的路由器结构。图\@ref(fig:router-struct)所示为一个适用于Mesh结构的路由器结构。节点的每一个输入端口都有一个独立的缓冲区Buffer在数据包可以获得下一跳资源离开之前缓冲区将它们存储下来。交叉开关连接输入端的缓冲区和输出端口数据包通过交叉开关控制传输到它指定的输出端口。分配器包括路由计算、虚通道分配和交叉开关分配三种功能路由计算用来计算head flit的下一跳输出方向虚通道分配用来分配flit在缓冲队列的位置交叉开关分配用来仲裁竞争的flit中哪个可以获得资源传输到输出端口。
```{r router-struct, fig.cap='路由器结构图', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter11/router_struct.png')
```
**4流量控制。**流量控制用来组织每个处理器核节点中有限的共享资源片上网络的主要资源就是信道Channel和缓冲区Buffer。信道主要用来传输节点之间的数据包。缓冲区是节点上的存储装置比如寄存器、内存等它用来临时存储经过节点的数据包。当网络拥塞时数据包需要临时存在缓冲区中等待传输。为了充分实现拓扑结构和路由方法的性能当有空闲的信道可以使用时流量控制必须尽量避免资源的冲突发生。好的流量控制策略要求它保持公平性和无死锁不公平的流量控制极端情况会导致某些数据包陷入无限等待状态死锁是当一些数据包互相等待彼此释放资源而造成的无限阻塞的情况。片上网络为了可以有效执行一定要是无死锁的。
下面以经典的基于信用的流量控制为例介绍片上网络中流量控制方法。如图\@ref(fig:flow-control-method)所示每一个处理器核节点的输入端口有自己的缓冲区队列分别用来存取来自对应的上一跳节点的数据比如i+1号节点最左侧的Buffer用来存储来自i号节点的数据包。同时每个节点上对应其相邻的节点都有一个计数器分别是S[0]~S[3]用来记录相邻节点内缓冲区Buffer的使用情况。
举例来说对于处理器核节点i的每一个计数器的初始状态S[0-3]都设为0当它向相邻节点如i+1号节点发送flit时首先判断S[0]的值是否已达到Buffer的最大值如果没有则将S[0]的值加1然后将flit发送过去如果S[0]已经达到最大值则数据会被扣留在Buffer中直到右侧节点有足够的空间收留来自它的数据。同时对于i+1号节点每当它左侧的Buffer送走一个flit时它就向其左侧的节点发送一个Credit信号通知左侧节点此Buffer已多出一个空余位置当左侧节点收到此Credit信号后则会更新对应的S[0]减1。整个流程如图\@ref(fig:flow-control-method)所示。
```{r flow-control-method, fig.cap='基于信用的流量控制', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter11/flow_control_method.png')
```
## 多核处理器的同步机制
在介绍多核处理器的同步机制之前先来看一个同步问题的例子。有两个处理器核P0和P1分别对同一共享地址的变量A进行加1的操作。于是处理器P0先读取A的值然后加1最后将A写回内存。同样处理器核P1也进行一样的操作。然而如图\@ref(fig:different-results)所示实际的运算过程却有可能产生两种不一样的结果注意整个运算过程是完全符合Cache一致性协议规定的。所以A的值可能增加了1如图\@ref(fig:different-results)a所示也可能增加了2如图\@ref(fig:different-results)b所示。然而这样的结果对于软件设计人员来说是完全无法接受的。因此需要同步机制来协调多个处理器核对共享变量的访问。
```{r different-results, fig.cap='一个并行程序产生两种不同结果', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter11/different_results.png')
```
为了解决同步问题需要采用同步机制。常见的同步机制包括锁操作、栅障操作和事务内存。锁操作和事务内存主要用于保护临界区栅障操作用于实现全局同步。锁操作和栅障操作属于传统同步方法广泛用于并行系统中事务内存则是适应多核处理器设计需求的一种新同步机制。同步机制一般建立在用户级软件例程Routine而这些软件例程主要基于硬件提供的同步指令来实现。
**1.原子操作**
硬件设计人员在处理器中增加了一种特殊的机制支持多个操作之间的原子性Atomic也就是不可分割性。在硬件上实现满足不可分割性的原子操作有许多种方法既可以在寄存器或者存储单元中增加专门的硬件维护机制也可以在处理器的指令集中添加特定的原子指令。早期的处理器大多选择在存储单元中增加特殊的原子硬件维护机制而现代处理器大多使用原子指令方式。原子指令的实现方式可以分为两种其中一种是直接使用一条“读-改-写”Read-Modify-WriteRMW原子指令来完成另一种是使用一组原子指令对LL/SCLoad-Linked/Store-Conditional来完成指定的原子操作。
常见的“读-改-写”原子指令包括Test_and_Set、Compare_and_Swap、Fetch_and_Op等。Test_and_Set 指令取出内存中对应地址的值同时对该内存地址赋予一个新的值。Compare_and_Swap指令取出内存中对应地址的值和另一个给定值A进行比较如果相等则将另一个给定值B写入这个内存地址否则不进行写操作;指令应返回状态例如X86的cmpxchg指令设置eflags的zf位来指示是否进行了写操作。Fetch_and_Op指令在读取内存对应地址值的同时将该地址的值进行一定的运算再存回。根据运算操作Op的不同Fetch_and_Op指令又有许多种不同的实现形式。例如Fetch_and_Increment指令就是读取指定地址的值同时将该值加1并写回内存。可以看出“读-改-写”原子指令和内存的交互过程至少有两次,一次读内存,另一次写内存,而两次交互过程之间往往还有一些比较、加减之类的运算操作(改)。
使用原子指令对LL/SC实现原子操作方式的过程如下首先LL指令将对应地址的内存数据读入寄存器然后可以对该寄存器中的值进行任意的运算最后使用SC指令尝试将运算后的数据存回内存对应的地址。当且仅当LL指令完成之后没有其他对该地址内存数据的修改操作则SC指令执行成功并返回一个非零值运算后的数据顺利写回内存否则SC指令执行失败并返回值0修改后的数据不会被写回内存也不会产生任何对内存的改动。SC指令失败后一般需要重新执行上述过程直到SC指令成功为止。SC指令的成功说明了LL/SC指令之间没有其他对同一地址的写入操作也就保证了LL/SC指令之间的不可分割性。图\@ref(fig:ll-sc-atomic)的例子采用LL/SC指令实现了寄存器R1的内容与R3对应的内存位置的内容的原子交换。
```{r ll-sc-atomic, fig.cap='用LL/SC指令对实现原子交换操作', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter11/ll_sc_atomic.png')
```
LL/SC原子指令对的优点在于设计简单每条指令只需和内存交互一次且在LL指令和SC指令之间可以加入任意的运算指令可以灵活地实现类似于“读-改-写”的复杂原子操作。其缺点在于密集共享时SC不容易成功一种优化措施是LL访问时把相应Cache行置为EXC状态而不是SHD状态这样可以提高SC成功的概率。相对于Test_and_Set指令和Fetch_and_Op指令等实现复杂的单条原子指令LL/SC指令对成为目前最常见的原子指令被多种现代RISC指令系统所采用如MIPS、IBM Power、DEC Alpha和LoongArch等等。
**2.锁的软件实现方法**
Lock是并行程序中常用的对多个线程共享的临界区Critical Section进行保护的同步操作。自旋锁Spin Lock是锁操作的一种最基本的实现形式。Test_and_Set自旋锁是最简单的自旋锁通过使用Test_and_Set原子指令来完成锁的获取、等待和查询。Test_and_Set锁的基本步骤如图\@ref(fig:test-and-set)所示假设1表示锁被占用0表示锁空闲。处理器使用Test_and_Set原子指令读取锁变量的值同时将锁变量的值改为1。如果读取到锁的值为0说明锁空闲该处理器成功获得锁。由于Test_and_Set指令已经将锁的值同时改为了1所以其他处理器不可能同时获得这把锁。如果锁的值为1说明已经有其他处理器占用了这把锁则该处理器循环执行Test_and_Set指令自旋等待直到成功获得锁。由于当时锁的值已经是1了Test_and_Set指令再次将锁的值设为1实际上锁的值并没有发生变化所以不会影响到锁操作的正确性。当获得锁的处理器打算释放锁时只需要简单地执行一条普通的store指令将锁的值设置为0即可。由于一次只能有一个处理器核获得锁所以不用担心多个处理器核同时释放锁而引发访存冲突也就不需要使用原子指令来释放锁了。
```{r test-and-set, fig.cap='Test-and-Set自旋锁', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter11/test_and_set.png')
```
Test_and_Set自旋锁最主要的一个缺点就是对锁变量的访存冲突。当一个处理器核获得锁以后其他等待的处理器核会不断循环执行Test_and_Set指令访问锁变量试图获取锁权限从而在片上互连上产生大量的访存通信。一种简单的优化方法就是在Test_and_Set指令之间加入一定的延迟减少等待阶段Test_and_Set原子指令自旋执行的次数以减轻访存的压力。此外研究人员还提出了排队锁Ticket Lock、基于数组的队列锁Array-Based Queuing Lock、基于链表的队列锁List-Based Queuing Lock等优化机制。
**3.栅障软件实现方法**
栅障Barrier是并行程序中常用的同步操作。栅障要求处理器核等待一直到所有处理器核都到达栅障后才能释放所有处理器核继续执行。栅障有多种实现方式下面主要介绍比较简单的集中式栅障。集中式栅障就是在共享存储中设置一个共享的栅障变量。每当一个处理器核到达栅障以后就使用原子指令修改栅障值表示自己已经到达如将栅障的值加1然后对该栅障值进行自旋等待如图\@ref(fig:centralized-barrier)的伪代码所示。当栅障的值表明所有处理器核都已经到达(即栅障的值等于预计到达的总的处理器核的数量)时,栅障操作顺利完成,所有自旋等待的处理器核就可以继续往下执行了。集中式栅障的实现简单、灵活,可以支持各种类型的栅障,包括全局栅障和部分栅障,适用于可变处理器核数量的栅障操作。
在集中式栅障中每一个到达的处理器核都需要对同一个共享的栅障值进行一次修改以通告该处理器核到达栅障已到达栅障的处理器核会不断访问栅障值以判断栅障是否完成。由于Cache一致性协议的作用这个过程会在片上互连上产生许多无用的访存通信并且随着处理核数的增加栅障的时间和无用的访存数量都会快速增长所以集中式栅障的可扩展性不好。为了减少上述查询和无效的访存开销集中式栅障也可以采用类似于Test_and_Set锁的方式在查询操作之中增加一些延迟。加入延迟虽然可以减少一些网络带宽的浪费但是也可能降低栅障的性能。针对集中式栅障的弱点研究人员提出了软件合并树栅障等优化方法。
```{r centralized-barrier, fig.cap='集中式栅障伪代码', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter11/centralized_barrier.png')
```
**4.事务内存**
1993年Herlihy和Moss以事务概念为基础针对多核处理器中并行编程的同步效率问题提出了事务内存的概念。在事务内存中访问共享变量的代码区域声明为一个事务Transaction。事务具有原子性Atomicity即事务中的所有指令要么执行要么不执行一致性Consistency即任何时刻内存处于一致的状态隔离性Isolation即事务不能看见其他未提交事务涉及的内部对象状态。事务执行并原子地提交所有结果到内存如果事务成功或中止并取消所有的结果如果事务失败
事务内存实现的关键部分包括:冲突检测、冲突解决以及事务的提交和放弃。冲突检测就是确定事务并发执行过程中是否存在数据的冲突访问。冲突解决是指在发生冲突时决定继续或者放弃事务的执行。如果支持事务的暂停操作,可以暂停引起冲突的事务,直到被冲突的事务执行结束;如果不支持事务的暂停操作,就必须在引起冲突的事务中选择一个提交,同时放弃其他事务的执行。事务的提交或放弃是解决事务冲突的核心步骤,事务提交需要将结果数据更新到内存系统中,事务放弃需要将事务的结果数据全部丢弃。
事务内存实现方式主要有软件事务内存和硬件事务内存两种。软件事务内存在通过软件实现不需要底层硬件提供特殊的支持主要以库函数或者编程语言形式实现。例如RSTM、DSTM、Transactional Locking等以库函数实现线程访问共享对象时通过对应的库函数来更新事务执行的状态、检测冲突和处理等HSTM语言中扩展了事务原语AtomCaml在ObjectCaml语言中增加了对事务内存同步模型的支持等。硬件事务内存主要对多核处理器的Cache结构进行改造主要包括增加特定指令来标示事务的起止位置使用额外的事务Cache来跟踪事务中的所有读操作和写操作扩展Cache一致性协议来检测数据冲突。软件事务内存实现灵活更容易集成到现有系统中但性能开销大硬件事务内存需要修改硬件但是性能开销小程序整体执行性能高。Intel Haswell处理器和IBM Power8处理器中实现了对硬件事务内存的支持。下面来看一个具体的实现例子。
Intel TSXTransactional Synchronization Extensions是Intel公司针对事务内存的扩展实现提出了一个针对事务内存的指令集扩展主要包括3条新指令XBEGIN、XEND和XABORT。XBEGIN指令启动一个事务并提供了如果事务不能成功执行的回退地址信息XEND指令表示事务的结束XABORT指令立刻触发一个中止类似于事务提交不成功。硬件实现以Cache行为单位跟踪事务的读集Read-Set和写集Write-Set。如果事务读集中的一个Cache行被另一个线程写入或者事务的写集中的一个Cache行被另一个线程读取或写入则事务就遇到冲突Conflict通常导致事务中止。Intel Haswell处理器中实现了Intel TSX。
## 典型多核处理器
### 龙芯3A5000处理器
龙芯3A5000于2020年研制成功是龙芯中科技术股份有限公司研发的首款支持龙芯自主指令集LoongArch的通用多核处理器主要面向桌面计算机和服务器应用。龙芯3A5000片内集成4个64位GS464V高性能处理器核、16MB的分体共享三级Cache、2个DDR4内存控制器支持DDR4-3200、2个16位HTHyperTransport控制器、2个I2C、1个UART、1个SPI、16路GPIO接口等。龙芯3A5000中多个GS464V核及共享三级Cache模块通过AXI互连网络形成一个分布式共享片上末级Cache的多核结构。采用基于目录的Cache一致性协议来维护Cache一致性。另外龙芯3A5000还支持多片扩展将多个芯片的HT总线直接互连就形成更大规模的共享存储系统最多可支持16片互连
GS464V是支持Loongarch指令集的四发射64位高性能处理器核具有256位向量部件。GS464V的结构如图\@ref(fig:gs464v-uarch)所示主要特点如下四发射超标量结构具有四个定点、两个向量、两个访存部件支持寄存器重命名、动态调度、转移预测等乱序执行技术每个向量部件宽度为256位可支持8个双32位浮点乘加运算或4个64位浮点运算一级指令Cache和数据Cache大小各为64KB4路组相联牺牲者CacheVictim Cache作为私有二级Cache大小为256KB16路组相连支持非阻塞Non-blocking访问及装入猜测Load Speculation等访存优化技术支持标准的JTAG调试接口方便软硬件调试。
```{r gs464v-uarch, fig.cap='GS464V处理器核结构', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter11/gs464v_uarch.png')
```
龙芯3A5000芯片整体架构基于多级互连实现结构如图\@ref(fig:ls3a5000-arch)所示(图\@ref(fig:ls3a5000-layout)为芯片版图。第一级互连采用5x5的交叉开关用于连接四个GS464V核作为主设备、四个共享Cache模块作为从设备、以及一个IO端口连接IO-RING。IO端口使用一个Master和一个Slave。第二级互连采用5x3的交叉开关连接4个共享Cache模块作为主设备两个DDR3/4内存控制器、以及一个IO端口连接IO-RING。IO-RING连接包括4个HT控制器MISC模块SE模块与两级交叉开关。两个HT控制器lo/hi共用16位HT总线作为两个8位的HT总线使用也可以由lo独占16位HT总线。HT控制器内集成一个DMA控制器DMA控制器负责IO的DMA控制并负责片间一致性的维护。上述互连结构都采用读写分离的数据通道数据通道宽度为128bit与处理器核同频用以提供高速的片上数据传输。此外一级交叉开关连接4个处理器核与Scache的读数据通道为256位以提高片内处理器核访问Scache的读带宽。
龙芯3A5000主频可达2.5GHz,峰值浮点运算能力达到160GFLOPS。
```{r ls3a5000-arch, fig.cap='龙芯3A5000芯片结构', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter11/ls3a5000_arch.png')
```
```{r ls3a5000-layout, fig.cap='龙芯3A5000的版图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter11/ls3a5000_layout.png')
```
### Intel SandyBridge架构
Intel SandyBridge架构于2011年推出是Intel面向32nm工艺的新架构它是Core处理器架构的第二代架构。根据面向移动、桌面还是服务器应用有支持2~8核的不同处理器产品。
SandyBridge处理器主要包括五个组成部分处理器核、环连接Ring Interconnect、共享的三级Cache、系统代理System Agent和图形核心GPU。图\@ref(fig:sandybridge-arch)为Sandybridge处理器的结构示意图。它的处理器核心采用乱序执行技术支持双线程支持AVX向量指令集扩展。系统代理包括内存控制器、功耗控制单元Power Control Unit、PCIE接口、显示引擎和DMI等。存储层次包括每个核私有的一级和二级Cache、多核共享的LLC三级Cache。LLC分体实现在处理器核和图形核心、系统代理之间共享。
SandyBridge采用环连接来互连处理器核、图形核心、LLC和系统代理。环连接由请求Request、响应Acknowledge、侦听Snoop、数据Data四条独立的环组成。这四条环采用一个分布式的通信协议维护数据一致性和序Ordering实现了基于侦听的Cache一致性协议。环连接采用完全流水线设计以核心频率运行随着连接的节点数目增加带宽也随之增加在处理器核总数不太大的情况下有较好的伸缩性。另外由于环连接传递的消息具有天然的序使得Cache一致性协议的设计和验证比较简单。如图\@ref(fig:sandybridge-arch)所示SandyBridge的环有6个接口包括4个处理器核和三级Cache共享的接口一个图形核心的接口和1个系统代理的接口。
4核SandyBridge处理器的主频达到3GHz支持128位向量处理峰值性能达到96GFLOPS理论访存带宽达到25.6GB/s采用Stream测试程序集实测的访存带宽为14GB/s~16GB/s。
```{r sandybridge-arch, fig.cap='SandyBridge结构示意图', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter11/sandybridge_arch.png')
```
### IBM Cell处理器
Cell处理器由IBM、索尼和东芝联合研发并在2005年国际固态电路会议ISSCC上首次公开主要面向游戏、超级计算等领域。图\@ref(fig:cell-arch)为Cell处理器的结构示意图。Cell采用异构多核架构它由1个相对比较简单的支持同时双线程并行的双发射64位PowerPC内核称为PPE和8个SIMD型向量协处理器称为SPE构成。由一个高带宽的片上环状高速总线将PPE、SPE、RAM内存总线接口控制器BIC、FlexIO外部总线接口控制器连接起来。PPE主要负责控制并运行操作系统SPE完成主要的计算任务。SPE的SIMD执行部件是128位宽的从而可在一个时钟周期里完成4个32位的定点或浮点乘加运算。SPE里内置了256KB的SRAM作为局部存储器Local Storage简称LSLS与内存间的通信必须通过DMA 进行。SPE配置了较大的寄存器堆128个128位的寄存器来尽量减少对内存的访问。由于SPE不采用自动调配数据的Cache机制需要显式地将内存中的数据先搬到LS中供SPE计算为了减少数据搬运需要依赖高水平程序员或编译器的作用来获得高性能编程较为困难。
Cell处理器可在4GHz频率下工作峰值浮点运算速度为256GFLOPS理论访存带宽为25.6GB/s。由于存在编程及推广困难等原因目前Cell处理器已经停止研发。
```{r cell-arch, fig.cap='IBM Cell结构示意图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter11/cell_arch.png')
```
### NVIDIA GPU
GPUGraphics Processing Unit是进行快速图形处理的硬件单元现代GPU包括数百个并行浮点运算单元是典型的众核处理器架构。本节主要介绍NVIDIA公司的Fermi GPU体系结构。
第一个基于Fermi体系结构的GPU芯片有30亿个晶体管支持512个CUDA核心组织成16个流多处理器Stream Multiprocessor简称SM。SM结构如图\@ref(fig:sm-single)、\@ref(fig:sm-whole)所示。每个SM包含32个CUDA核心Core、16个load/store单元LD/ST、4个特殊处理单元Special Function Unit简称SFU、64KB的片上高速存储。每个CUDA核心支持一个全流水的定点算术逻辑单元ALU和浮点单元FPU如图\@ref(fig:cuda-core)所示每个时钟周期可以执行一条定点或者浮点指令。ALU支持所有指令的32位精度运算FPU实现了IEEE 754-2008浮点标准支持单精度和双精度浮点的融合乘加指令Fused Multiply-Add, 简称FMA。16个load/store单元可以每个时钟周期为16个线程计算源地址和目标地址实现对这些地址数据的读写。SFU支持超越函数的指令如sin、cos、平方根等。64KB片上高速存储是可配置的可配成48KB的共享存储和16KB一级Cache或者16KB共享存储和48KB一级Cache。片上共享存储使得同一个线程块的线程之间能进行高效通信可以减少片外通信以提高性能。
```{r sm-single, fig.cap='单个Fermi流多处理器结构图', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter11/sm_single.png')
```
```{r sm-whole, fig.cap='Fermi流多处理器整体结构图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter11/sm_whole.png')
```
```{r cuda-core, fig.cap='CUDA核结构', fig.align='center', echo = FALSE, out.width='30%'}
knitr::include_graphics('./images/chapter11/cuda_core.png')
```
1.Fermi的线程调度
Fermi体系结构使用两层分布式线程调度器。块调度器将线程块Thread Block调度到SM上 SM以线程组Warp为单位调度执行每个Warp包含32个并行线程这些线程以单指令多线程Single Instruction Multi Thread,简称SIMT的方式执行。SIMT类似于SIMD表示指令相同但处理的数据不同。每个SM有两个Warp调度器和两个指令分派单元允许两个Warp被同时发射和并发执行。双Warp调度器Dual Warp Scheduler选择两个Warp从每个Warp中发射一条指令到一个16个核构成的组、16个load/store单元或者4个SFU单元。大多数指令是能够双发射的例如两条定点指令、两条浮点指令或者是定点、浮点、load、store、SPU指令的混合。双精度浮点指令不支持与其他指令的双发射。
2.Fermi存储层次
Fermi体系结构的存储层次由每个SM的寄存器堆、每个SM的一级Cache、统一的二级Cache和全局存储组成。图\@ref(fig:fermi-mem-hierarchy)为Fermi存储层次示意图。具体如下:
1寄存器。每个SM有32K个32位寄存器每个线程可以访问自己的私有寄存器,随线程数目的不同每个线程可访问的私有寄存器数目在21~63间变化。
2一级Cache和共享存储。每个SM有片上高速存储主要用来缓存单线程的数据或者用于多线程间的共享数据可以在一级Cache和共享存储之间进行配置。
3L2 Cache。768KB统一的二级Cache在16个SM间共享服务于所有到全局内存中的load/store操作。
4全局存储。所有线程共享的片外存储。
Fermi体系结构采用CUDA编程环境可以采用类C语言开发应用程序。NVIDIA将所有形式的并行都定义为CUDA线程将这种最底层的并行作为编程原语编译器和硬件可以在GPU上将上千个CUDA线程聚集起来并行执行。这些线程被组织成线程块以32个为一组Warp来执行。Fermi体系结构可以看作GPU与CPU融合的架构具有强大的浮点计算能力除了用于图像处理外也可作为加速器用于高性能计算领域。采用Fermi体系结构的GeForce GTX 480包含480核主频700MHz单精度浮点峰值性能为1.536TFLOPS访存带宽为177.4GB/s。
```{r fermi-mem-hierarchy, fig.cap='Fermi的存储层次图', fig.align='center', echo = FALSE, out.width='50%'}
knitr::include_graphics('./images/chapter11/fermi_mem_hierarchy.png')
```
### Tile64处理器
Tile64是美国Tilera公司于2007年推出的64核处理器主要面向网络和视频处理等领域。图\@ref(fig:tile64-arch)为Tile64处理器的结构图。Tile64具有64个Tile瓦片组成8*8的Mesh结构每个Tile包含通用CPU核、Cache和路由器。Tile64的处理器核支持MIPS类VLIW指令集采用三发射按序短流水线结构支持两个定点功能部件和1个load/store访存部件。在互连结构方面Tile64采用Mesh互连结构通过路由器实现了5套低延迟的、不同用途的Mesh互连网络提供了足够的通信带宽。在访存结构方面每个Tile拥有私有一级Cache16KB和私有二级Cache64KB以及虚拟的三级Cache所有Tile的二级Cache聚合。Tile64采用邻居Neighborhood缓存机制实现片上分布式共享Cache每个虚拟地址对应一个HomeTile先访问该HomeTile的私有Cache如果不命中则访问内存数据只在它的Home Tile的私有Cache中缓存由Home Tile负责维护数据一致性。Tile64支持4个DDR2内存控制器2个10Gbit的以太网接口2个PCIE接口及其他一些接口。Tile64的运行主频为1GHz峰值性能为每秒192G个32位运算理论访存带宽为25GB/s。
```{r tile64-arch, fig.cap='Tile64处理器结构图', fig.align='center', echo = FALSE, out.width='100%'}
knitr::include_graphics('./images/chapter11/tile64_arch.png')
```
## 本章小结
可以从以下几个维度对多核处理器结构进行分析一是从处理器核及访存带宽的维度包括核的数量、大核还是小核、同构核还是异构核、通用核还是专用核等访存带宽与峰值计算能力之间的比例决定该多核处理器的通用性。二是从存储一致性模型的维度存储一致性模型对多个处理器核发出的访存指令次序进行约定包括顺序一致性模型、处理器一致性模型、弱一致性模型等。三是从Cache组织及一致性协议的维度包括有几级Cache、Cache容量、私有还是共享CacheCache一致性协议是把一个处理器核新写的值传播到其他处理器核的一种机制。四是从片上互连结构的维度即多个核处理器核间如何实现通信。五是多核之间的同步机制的维度如互斥锁操作lock、路障操作barrier等。
## 习题
1. 关于多核处理器的Cache结构请介绍UCA与NUCA的特点。
2. 有两个并行执行的线程在顺序一致性和弱一致性下它各有几种正确的执行顺序给出执行次序和最后的正确结果假设X、Y的初始值均为0
```{r parallel-example, echo = FALSE}
dt <- data.frame('P1' = 'X=1;\nprint Y;',
'P2' = 'Y=1;\nprint X;')
flextable(dt) %>%
width(width=2.0) %>%
theme_box()
```
3. 关于Cache一致性协议MESI协议比ESI协议增加了M状态请解释有什么好处。
4. 请分别采用Fetch_and_Increment和Compare_and_Swap原子指令编写实现自旋锁的代码并分析可能的性能改进措施。
5. 在共享存储的多处理器中经常会出现假共享现象。假共享是由于两个变量处于同一个Cache行中引起的会对性能造成损失。为了尽量减少假共享的发生程序员在写程序时应该注意什么
6. 请介绍片上网络路由器设计中的虚通道概念,并说明采用虚通道有什么好处。
7. 分析Fermi GPU的存储结构指出不同层次存储结构的带宽、延迟、是否共享。
\newpage

1240
22-perf-evaluation.Rmd Normal file

File diff suppressed because it is too large Load Diff

130
30-conclusion.Rmd Normal file
View File

@@ -0,0 +1,130 @@
# 总结 {-}
\markboth{总结}{总结}
经过本课程的学习,大家对计算机体系结构有了一个具体的了解,但要问起什么是计算机体系结构,多半答不上来。本章内容是笔者撰写的《中国大百科全书》计算机体系结构词条初稿,力求完整、准确地对计算机体系结构进行描述,作为本书的总结。
计算机体系结构Computer Architecture是描述计算机各组成部分及其相互关系的一组规则和方法是程序员所看到的计算机属性。计算机体系结构主要研究内容包括指令系统结构Instruction Set Architecture简称ISA和计算机组织结构Computer Organization。微体系结构Micro-architecture是微处理器的组织结构并行体系结构是并行计算机的组织结构。冯诺依曼结构的存储程序和指令驱动执行原理是现代计算机体系结构的基础。
计算机体系结构可以有不同层次和形式的表现方式。计算机体系结构通常用指令系统手册和结构框图来表示结构框图中的方块表示计算机的功能模块线条和箭头表示指令和数据在功能模块中的流动结构框图可以不断分解一直到门级或晶体管级。计算机体系结构也可以用高级语言如C语言来表示形成结构模拟器用于性能评估和分析。用硬件描述语言如Verilog描述的体系结构可以通过电子设计自动化Electronic Design Automation简称EDA工具进行功能验证和性能分析转换成门级及晶体管级网表并通过布局布线最终转换成版图用于芯片制造。
1、冯诺依曼结构及其基本原理
1945年匈牙利籍数学家冯诺伊曼结合EDVAC计算机的研制提出了世界上第一个完整的计算机体系结构被称为冯诺伊曼结构。冯诺依曼结构的主要特点是①计算机由存储器、运算器、控制器、输入设备、输出设备五部分组成其中运算器和控制器合称为中央处理器Central Processing Processor简称CPU或处理器。②存储器是按地址访问的线性编址的一维结构每个单元的位数固定。指令和数据不加区别混合存储在同一个存储器中。③控制器从存储器中取出指令并根据指令要求发出控制信号控制计算机的操作。控制器中的程序计数器指明要执行的指令所在的存储单元地址。程序计数器一般按顺序递增但可按指令要求而改变。④以运算器为中心输入输出Input/Output简称IO设备与存储器之间的数据传送都经过运算器。
随着技术的进步冯诺依曼结构得到了持续改进主要包括①以运算器为中心改进为以存储器为中心数据流向更加合理从而使运算器、存储器和IO设备能够并行工作。②由单一的集中控制改进为分散控制。早期的计算机工作速度低运算器、存储器、控制器和IO设备可以在同一个时钟信号的控制下同步工作。现在运算器、存储器与IO设备的速度差异很大需要异步分散控制。③从基于串行算法改进为适应并行算法出现了流水线处理器、超标量处理器、向量处理器、多核处理器、对称多处理机Symmetric Multiprocessor简称SMP、大规模并行处理机Massively Parallel Processing简称MPP和机群系统等。④出现了为适应特殊需要的专用计算机如图形处理器Graphic Processing Unit简称GPU、数字信号处理器Digital Signal Processor简称DSP等。
虽然经过了长期的发展,以存储程序和指令驱动执行为主要特点的冯诺伊曼结构仍是现代计算机的主流结构。非冯诺伊曼计算机的研究成果包括依靠数据驱动的数据流计算机、图约计算机等。
2、指令系统结构
计算机系统为软件编程提供不同层次的功能和逻辑抽象主要包括应用程序编程接口Application Programming Interface简称API、应用程序二进制接口Application Binary Interface简称ABI以及ISA三个层次。
API是应用程序的高级语言编程接口在编写程序的源代码时使用。常见的API包括C语言、Fortran语言、Java语言、Javascript语言、OpenGL图形编程接口等。使用一种API编写的应用程序经重新编译后可以在支持该API的不同计算机上运行。
ABI是应用程序访问计算机硬件及操作系统服务的接口由计算机的用户态指令和操作系统的系统调用组成。为了实现多进程访问共享资源的安全性处理器设有“用户态”与“核心态”。用户程序在用户态下执行操作系统向用户程序提供具有预定功能的系统调用函数来访问只有核心态才能访问的硬件资源。当用户程序调用系统调用函数时处理器进入核心态执行诸如访问IO设备、修改处理器状态等只有核心态才能执行的指令。处理完系统调用后处理器返回用户态执行用户代码。相同的应用程序二进制代码可以在相同ABI的不同计算机上运行。
ISA是计算机硬件的语言系统也叫机器语言是计算机软件和硬件的界面反映了计算机所拥有的基本功能。计算机硬件设计人员采用各种手段实现指令系统软件设计人员使用指令系统编制各种软件用这些软件来填补指令系统与人们习惯的计算机使用方式之间的语义差距。设计指令系统就是要选择应用程序和操作系统中一些基本操作应由硬件实现还是由软件通过一串指令实现然后具体确定指令系统的指令格式、类型、操作以及对操作数的访问方式。相同的应用程序及操作系统二进制代码可以在相同ISA的不同计算机上运行。
ISA通常由指令集合、处理器状态和例外三部分组成。
指令包含操作编码和操作数编码操作编码指明操作类型操作数编码指明操作对象。常见的指令编码方式包括复杂指令系统Complex Instruction Set Computer简称CISC精简指令系统Reduced Instruction Set Computer简称RISC和超长指令字Very Long Instruction Word简称VLIW等。
指令的操作主要包括:运算指令,如加减乘除、逻辑运算、移位等;数据传送指令,如取数和存数;程序控制指令,如条件和非条件转移、函数调用和返回等;处理器状态控制指令,如系统调用指令、调试指令、同步指令等。
指令的操作数包括立即数、寄存器、存储器、IO设备寄存器等。立即数是指令中直接给出的数据。寄存器用于保存处理器最常用的数据包括通用寄存器、浮点寄存器、控制寄存器等处理器访问寄存器时直接在指令中指明要访问的寄存器号。存储器是计算机中保存指令和数据的场所计算机取指令和存取数据都要先计算指令和数据所处的存储单元地址并根据地址来读写存储器。IO设备都有专门的设备控制器设备控制器向处理器提供一组IO设备寄存器处理器通过读写IO设备寄存器来获知IO设备状态并控制IO设备处理器写入IO设备寄存器的数据会被设备控制器解释成控制IO设备的命令。
指令需要明确操作数的数据表示、编址方式、寻址方式和定位方式等。数据表示给出指令系统可直接调用的数据类型包括整数、实数、布尔值、字符等。编址方式给出编址单位、编址方法和地址空间等编址单位有字编址、字节编址和位编址普遍使用的是字节编址常见的编址方法有大尾端Big Endian和小尾端Little Endian两种地址空间包括寄存器空间、存储器空间和IO设备空间有些ISA把存储器和IO设备统一编址有些ISA把寄存器、存储器和IO设备统一编址。主要寻址方式有立即数寻址、寄存器寻址、直接寻址、间接寻址、变址寻址包括相对寻址和基址寻址和堆栈寻址等。定位方式确定指令和数据的物理地址直接定位方式在程序装入主存储器之前确定指令和数据的物理地址静态定位方式在程序装入主存储器的过程中进行地址变换确定指令和数据的物理地址动态定位方式在程序执行过程中当访问到相应的指令或数据时才进行地址变换确定指令和数据的物理地址现代计算机多采用动态定位方式。
通用计算机至少要有两种工作状态核心态和用户态。两个状态下所能使用的指令和存储空间等硬件资源有差别。一般来说只有操作系统才能工作在核心态用户程序只能工作在用户态并可以通过例外和系统调用进入核心态。有些处理器有更多工作状态如核心态Kernel、监督态Hypervisor、管理态Supervisor、用户态User等。
例外Exception系统是现代计算机的重要组成部分除了管理外部设备之外还承担了包括故障处理、实时处理、分时操作系统、程序的跟踪调试、程序的监测、用户程序与操作系统的联系等任务。发生例外时处理器需要保存包括例外原因、例外指令的程序计数器内容等信息把处理器状态切换为核心态并跳转到事先指定的操作系统例外处理入口地址执行完例外处理程序后处理器状态切换回发生例外前的状态并跳转回发生例外的指令继续执行。指令系统要指明例外源的分类组织、例外系统的软硬件功能分配、例外现场的保存和恢复、例外优先级、例外响应方式和屏蔽方式等。
3、计算机组织结构
计算机组织结构指计算机的组成部分及各部分之间的互连实现。典型计算机的基本组成包括CPU、存储器、IO设备其中CPU包括运算器和控制器IO设备包括输入设备和输出设备。计算机从输入设备接收程序和数据存放在存储器中CPU运行程序处理数据最后将结果数据通过输出设备输出。
运算器包括算术和逻辑运算部件、移位部件、寄存器等。复杂运算如乘除法、开方及浮点运算可用程序实现或由运算器实现。寄存器既可用于保存数据,也可用于保存地址。运算器还可设置条件码寄存器等专用寄存器,条件码寄存器保存当前运算结果的状态,如运算结果是正数、负数或零,是否溢出等。
控制器控制指令流和每条指令的执行内含程序计数器和指令寄存器等。程序计数器存放当前执行指令的地址指令寄存器存放当前正在执行的指令。指令通过译码产生控制信号用于控制运算器、存储器、IO设备的工作。这些控制信号可以用硬连线逻辑产生也可以用微程序产生也可以两者结合产生。为了获得高指令吞吐率可以采用指令重叠执行的流水线技术以及同时执行多条指令的超标量技术。当遇到执行时间较长或条件不具备的指令时把条件具备的后续指令提前执行称为乱序执行可以提高流水线效率。控制器还产生一定频率的时钟脉冲用于计算机各组成部分的同步。
存储器存储程序和数据又称主存储器或内存一般用动态随机存储器Dynamic Random Access Memory简称DRAM实现。CPU可以直接访问它IO设备也频繁地和它交换数据。存储器的存取速度往往满足不了CPU的快速要求容量也满足不了应用的需要为此将存储系统分为高速缓存Cache)、主存储器和辅助存储器三个层次。Cache存放当前CPU最频繁访问的部分主存储器内容可以采用比DRAM速度快但容量小的静态随机存储器Static Random Access Memory简称SRAM实现。数据和指令在Cache和主存储器之间的调动由硬件自动完成。为扩大存储器容量使用磁盘、磁带、光盘等能存储大量数据的存储器作为辅助存储器。计算机运行时所需的应用程序、系统软件和数据等都先存放在辅助存储器中在运行过程中分批调入主存储器。数据和指令在主存储器和辅助存储器之间的调动由操作系统完成。CPU访问存储器时面对的是一个高速接近于Cache的速度、大容量接近于辅助存储器的容量的存储器。现代计算机中还有少量只读存储器Read Only Memory简称ROM用来存放引导程序和基本输入输出系统Basic Input Output System简称BIOS等。现代计算机访问内存时采用虚拟地址操作系统负责维护虚地址和物理地址转换的页表集成在CPU中的存储管理部件Memory Management Unit简称MMU负责把虚拟地址转换为物理地址。
IO设备实现计算机和外部世界的信息交换。传统的IO设备有键盘、鼠标、打印机和显示器等新型的IO设备能进行语音、图像、影视的输入输出和手写体文字输入并支持计算机之间通过网络进行通信磁盘等辅助存储器在计算机中也当作IO设备来管理。处理器通过读写IO设备控制器中的寄存器来访问及控制IO设备。高速IO设备可以在处理器安排下直接与主存储器成批交换数据称为直接存储器访问Directly Memory Access简称DMA。处理器可以通过查询设备控制器状态与IO设备进行同步也可以通过中断与IO设备进行同步。
由若干个CPU、存储器和IO设备可以构成比单机性能更高的并行处理系统。
现代计算机各部件之间采用总线互连。为了便于不同厂家生产的设备能在一起工作以及设备的扩充总线的标准化非常重要。常见的总线包括片上总线如AXI总线系统总线如QPI和HT总线内存总线如SDRAM总线IO总线如PCIE、SATA、USB总线等。
4、微体系结构
半导体工艺的发展允许在单个芯片内部集成CPU称为微处理器Microprocessor。微体系结构简称微结构是微处理器的组织结构描述处理器的组成部分及其互连关系以及这些组成部分及其互连如何实现指令系统的功能。对于同一个指令系统复杂的微结构性能高功耗和成本也高简单的微结构性能低功耗和成本也低。随着半导体工艺的不断发展实现相同指令系统的处理器微结构不断升级并不断提高性能。
计算机执行指令一般包含以下过程从存储器取指令并对取回的指令进行译码从存储器或寄存器读取指令执行需要的操作数执行指令把执行结果写回存储器或寄存器。上述过程称为一个指令周期。计算机不断重复指令周期直到完成程序的执行。体系结构研究的一个永恒主题就是不断加速上述指令执行周期从而提高计算机运行程序的效率。人们提出了很多提高指令执行效率的技术包括RISC技术、指令流水线技术、高速缓存技术、转移预测技术、乱序执行技术、超标量又称为多发射技术等。
RISC技术。自从1940年代发明电子计算机以来处理器结构和指令系统经历了一个由简单到复杂由复杂到简单又由简单到复杂的否定之否定过程。早期的处理器结构及其指令系统由于工艺技术的限制不可能做得很复杂。随着工艺技术的发展1960年代后流水线技术、动态调度技术、向量机技术被广泛使用处理器结构和指令系统变得复杂。1980年代提出的RISC技术通过减少指令数目、定长编码、降低编码密度等以简化指令的取指、译码、执行的逻辑以提高频率通过增加寄存器数目及load-store结构以提高效率。后来随着深度流水、超标量、乱序执行的实现RISC结构变得越来越复杂。
RISC指令采用load-store结构运算指令从寄存器读取操作数并把结果写回寄存器访存指令则负责在寄存器和存储器间交换数据运算指令和访存指令分别在不同的功能部件执行。在load-store结构中运算器只需比较指令的寄存器号来判断指令间的数据相关访存部件只需比较访存指令的地址来判断指令间的数据相关从而支持高效的流水线、多发射及乱序执行技术。X86系列从Pentium III开始把CISC指令翻译成若干RISC微操作以提高指令流水线效率如Haswell微结构最多允许192个内部微操作乱序执行。
指令流水线技术。指令流水线把一条指令的执行划分为若干阶段(如分为取指、译码、执行、访存、写回阶段)来减少每个时钟周期的工作量,从而提高主频;并允许多条指令的不同阶段重叠执行实现并行处理(如一条指令处于执行阶段时,另一条指令处于译码阶段)。虽然同一条指令的执行时间没有变短,但处理器在单位时间内执行的指令数增加了。
指令流水线的执行单元包括算术和逻辑运算部件Arithmetic Logic Units简称ALU、浮点运算部件Floating Point Units简称FPU、向量运算部件、访存部件、转移部件等。这些部件在流水线的调度下具体执行指令规定的操作。运算部件的个数和延迟访存部件的存储层次、容量和带宽以及转移部件的转移猜测算法是决定微结构性能的重要因素。
Cache技术。随着工艺技术的发展处理器的运算速度和内存容量按摩尔定律的预测指数增加但内存速度提高非常缓慢与处理器速度的提高形成了“剪刀差”。
工艺技术的上述特点使得访存延迟成为以存储器为中心的冯诺依曼结构的主要瓶颈。Cache技术利用程序访问内存的时间局部性一个单元如果当前被访问则近期很有可能被访问和空间局部性一个单元被访问后与之相邻的单元也很有可能被访问使用速度较快、容量较小的Cache临时保存处理器常用的数据使得处理器的多数访存操作可以在Cache上快速进行只有少量访问Cache不命中的访存操作才访问内存。Cache是内存的映像其内容是内存内容的子集处理器访问Cache和访问内存使用相同的地址。从1980年代开始RISC处理器就开始在处理器芯片内集成KB级的小容量Cache。现代处理器则普遍在片内集成多级Cache典型的多核处理器每个处理器核一级指令和数据Cache各几十KB二级Cache为几百KB而多核共享的三级Cache为几MB到几十MB。
Cache技术和指令流水线技术相得益彰。访问处理器片外内存的长延迟使流水线很难发挥作用使用片内Cache可以有效降低流水线的访存时间提高流水线效率。Cache容量越大则流水线效率越高处理器性能越高。
转移预测技术。冯诺依曼结构指令驱动执行的特点使转移指令成为提高流水线效率的瓶颈。典型应用程序平均每5-10条指令中就有一条转移指令而转移指令的后续指令需要等待转移指令执行结果确定后才能取指导致转移指令和后续指令之间不能重叠执行降低了流水线效率。随着主频的提高现代处理器流水线普遍在10-20级之间由于转移指令引起的流水线阻塞成为提高指令流水线效率的重要瓶颈。
转移预测技术可以消除转移指令引起的指令流水线阻塞。转移预测器根据当前转移指令或其它转移指令的历史行为,在转移指令的取指或译码阶段预测该转移指令的跳转方向和目标地址并进行后续指令的取指。转移指令执行后,根据已经确定的跳转方向和目标地址对预测结果进行修正。如果发生转移预测错误,还需要取消指令流水线中的后续指令。为了提高预测精度并降低预测错误时的流水线开销,现代高性能处理器采用了复杂的转移预测器。
乱序执行技术。如果指令i是条长延迟指令如除法指令或Cache不命中的访存指令那么在顺序指令流水线中指令i后面的指令需要在流水线中等待很长时间。乱序执行技术通过指令动态调度允许指令i后面的源操作数准备好的指令越过指令i执行需要使用指令i的运算结果的指令由于源操作数没有准备好不会越过指令i执行以提高指令流水线效率。为此在指令译码之后的读寄存器阶段判断指令需要的操作数是否准备好。如果操作数已经准备好就进入执行阶段如果操作数没有准备好就进入称为保留站或者发射队列的队列中等待直到操作数准备好后再进入执行阶段。为了保证执行结果符合程序规定的要求乱序执行的指令需要有序结束。为此执行完的指令均进入一个称为重排序缓存Reorder Buffer简称ROB的队列并把执行结果临时写入重命名寄存器。ROB根据指令进入流水线的次序有序提交指令的执行结果到目标寄存器或存储器。CDC6600和IBM 360/91分别使用计分板和保留站最早实现了指令的动态调度。
重命名寄存器与指令访问的结构寄存器相对应。为了避免多条指令访问同一个结构寄存器而使该寄存器成为串行化瓶颈,指令流水线可以把对该结构寄存器的访问定向到重命名寄存器。乱序执行流水线把指令执行结果写入重命名寄存器而不是结构寄存器,以避免破坏结构寄存器的内容,到顺序提交阶段再把重命名寄存器内容写入结构寄存器。两组执行不同运算但使用同一结构寄存器的指令可以使用不同的重命名寄存器,从而实现并行执行。
超标量。工艺技术的发展使得在1980年代后期出现了超标量处理器。超标量结构允许指令流水线的每一阶段同时处理多条指令。例如Alpha 21264处理器每拍可以取四条指令发射六条指令写回六条指令提交11条指令。如果把单发射结构比作单车道马路多发射结构就是多车道马路。
由于超标量结构的指令和数据通路都变宽了使得寄存器端口、保留站端口、ROB端口、功能部件数都需要增加例如Alpha 21264的寄存器堆有8个读端口和6个写端口数据Cache的RAM通过倍频支持一拍两次访问。现代超标量处理器一般包含两个以上访存部件两个以上定点运算部件以及两个以上浮点运算部件。超标量结构在指令译码或寄存器重命名时不仅要判断前后拍指令的数据相关还需要判断同一拍中多条指令间的数据相关。
5、并行体系结构
并行体系结构是并行计算机系统的组织结构通过把任务划分为多个进程或线程让不同的处理器并行运行不同的进程或线程来提高性能。此外随着处理器访存延迟的增加Cache失效导致流水线长时间堵塞处理器可以在一个线程等待长时间访存时快速切换到另一个线程执行以提高流水线效率。
多进程并行存在于多个操作系统之间或一个操作系统之内。用于高性能计算的MPI并行程序以及机群数据库是存在于多个操作系统之间的多进程并行的典型应用由操作系统调度的多道程序则是操作系统之内多进程并行的典型应用。多线程并行只存在于一个操作系统之内。线程的粒度比进程小线程的上下文也比进程简单。传统的多线程切换由操作系统调度并保留上下文现代处理器通过硬件实现多份线程上下文来支持单周期的多线程切换。同时多线程Simultaneous Multi-Threading简称SMT技术甚至允许超标量指令流水线的同一流水级同时运行来自不同线程的指令。现代处理器还通过硬件实现多份操作系统上下文来支持多个操作系统的快速切换以提高云计算虚拟机的效率。
并行处理结构普遍存在于传统的大型机、服务器和高端工作站中。包含2-8个CPU芯片的小规模并行服务器和工作站一直是事务处理市场的主流产品。包含16-1024个CPU芯片的大型计算机在大型企业的信息系统中比较普遍。用于科学和工程计算的高性能计算机则往往包含上万个CPU芯片。随着集成电路集成度的不断提高把多个CPU集成在单个芯片内部的多核CPU逐渐成为主流的CPU芯片产品。多核CPU芯片最早出现在嵌入式领域把多个比较简单的CPU集成在一个芯片上。2005年个人计算机CPU芯片开始集成两个CPU核。现在的市场主流个人计算机CPU芯片一般集成2-4个CPU核服务器CPU芯片则集成8-32个CPU核专用处理器如GPU则集成几百到上千个处理核心。
并行处理系统通过互连网络把多个处理器连接成一个整体。常见的互连网络包括总线、交叉开关、环状网络、树形网络、二维或更多维网格等。并行系统的多个处理器之间需要同步机制来协同多处理器工作。常见的同步机制包括锁Lock、栅栏Barrier以及事务内存Transaction Memory计算机指令系统通常要设置专用的同步指令。
在共享存储的并行处理系统中同一个内存单元一般以Cache 行为单位在不同的处理器中有多个备份需要通过存储一致性模型Memory Consistency Model规定多个处理器访问共享内存的一致性标准。典型的存储一致性模型包括顺序一致性Sequential Consistency处理器一致性Processor Consistency弱一致性Weak Consistency释放一致性Release Consistency等。高速缓存一致性协议Cache Coherence Protocol把一个处理器新写的值传播给其它处理器以达到存储一致性的目的。在侦听协议Snoopy Protocol写共享单元的处理器把写信息通过广播告知其它处理器在基于目录的协议Directory-based Protocol每个存储单元对应一个目录项记录拥有该存储单元的副本的那些处理器号写共享单元的处理器根据目录项的记录把写信息告知其它处理器。
6、体系结构的设计目标和方法
体系结构设计的主要指标包括性能、价格和功耗其它指标包括安全性、可靠性、使用寿命等。体系结构设计的主要目标经历了大型机时代一味追求性能Performance per Second到个人计算机时代追求性能价格比Performance per Dollar再到移动互联时代追求性能功耗比Performance per Watt的转变。性能是计算机体系结构的首要设计目标。
性能的最本质定义是“完成一个或多个任务所需要的时间”。完成一个任务所需要的时间由完成该任务需要的指令数、完成每条指令需要的拍数以及每拍需要的时间三个量相乘得到。完成任务需要的指令数与算法、编译器和指令的功能有关每条指令执行拍数Cycles Per Instruction简称CPI或每拍执行指令数Instructions Per Cycle简称IPC与编译、指令功能、微结构设计相关每拍需要的时间也就是时钟周期与微结构、电路设计、工艺等因素有关。
为了满足应用需求并不断提高性能,计算机体系结构在发展过程中遵循一些基本原则和方法,包括平衡性、局部性、并行性和虚拟化。
结构设计的第一个方法就是平衡设计。计算机是个复杂系统影响性能的因素很多。结构设计要统筹兼顾使各种影响性能的因素达到均衡。通用CPU设计有一个关于计算性能和访存带宽平衡的经验法则即峰值浮点运算速度MFLOPS和峰值访存带宽MB/s为1:1左右。计算机体系结构中有一个著名的阿姆达尔Amdahl定律。该定律指出通过使用某种较快的执行方式所获得的性能的提高受限于不可使用这种方式提高性能的执行时间所占总执行时间的百分比例如一个程序的并行加速比最终受限于不能被并行化的串行部分。
结构设计的第二个方法是利用局部性。当结构设计基本平衡以后性能优化要抓主要矛盾重点改进最频繁发生事件的执行效率。结构设计经常利用局部性加快经常性事件的速度。RISC指令系统利用指令的事件局部性对频繁发生的指令进行重点优化。硬件转移预测利用转移指令跳转方向的局部性即同一条转移指令在执行时经常往同一个方向跳转。Cache和预取利用访存的时间和空间局部性优化性能。
结构设计的第三个方法是开发并行性。计算机中可以开发三种层次的并行性。第一个层次的并行性是指令级并行包括时间并行即指令流水线以及空间并行即超标量技术。1980年代RISC出现后指令级并行开发达到了一个顶峰2010年后进一步挖掘指令级并行的空间已经不大。第二个层次的并行性是数据级并行主要指单指令流多数据流Single Instruction Multiple Data简称SIMD的向量结构。二十世纪七、八十年代以Cray为代表的向量机十分流行现代通用CPU普遍支持短向量运算如X86的AVX指令支持256位短向量运算。第三个层次的并行性是任务级并行包括进程级和线程级并行。上述三种并行性在现代计算机中都存在多核CPU运行线程级或进程级并行的程序每个核采用超标量流水线结构并支持SIMD向量指令。
结构设计的第四个方法是虚拟化。所谓虚拟化就是“用起来是这样的实际上是那样的”或者“逻辑上是这样的物理上是那样的”。结构设计者宁愿自己多费事也要尽量为用户提供一个友好的使用界面。如虚拟存储为每个进程提供独立的存储空间虚实地址转换和物理内存分配都由CPU和操作系统自动完成大大解放了程序员的生产力。多线程和虚拟机技术通过硬件支持多个线程上下文或操作系统上下文的快速切换在一个CPU上“同时”运行多个线程或操作系统把单个CPU虚拟成多个CPU。此外流水线和多发射技术在维持串行编程模型的情况下提高了速度Cache技术使程序员看到一个像Cache那么快像内存那么大的存储空间Cache一致性协议在分布式存储的情况下给程序员提供一个统一的存储空间这些都是虚拟化方法的体现。
\newpage

5
40-references.Rmd Normal file
View File

@@ -0,0 +1,5 @@
# 参考文献 {#references .unnumbered}
\markboth{参考文献}{参考文献}
<div id="refs"></div>

88
LICENSE Normal file
View File

@@ -0,0 +1,88 @@
Creative Commons Attribution-NonCommercial 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Section 1 Definitions.
Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
Licensor means the individual(s) or entity(ies) granting rights under this Public License.
NonCommercial means not primarily intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange.
Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
Section 2 Scope.
License grant.
Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and
produce, reproduce, and Share Adapted Material for NonCommercial purposes only.
Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
Term. The term of this Public License is specified in Section 6(a).
Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
Downstream recipients.
Offer from the Licensor Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
Other rights.
Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
Patent and trademark rights are not licensed under this Public License.
To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used other than for NonCommercial purposes.
Section 3 License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
Attribution.
If You Share the Licensed Material (including in modified form), You must:
retain the following if it is supplied by the Licensor with the Licensed Material:
identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
a copyright notice;
a notice that refers to this Public License;
a notice that refers to the disclaimer of warranties;
a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License.
Section 4 Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only;
if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
Section 5 Disclaimer of Warranties and Limitation of Liability.
Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.
To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.
The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
Section 6 Term and Termination.
This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
Section 7 Other Terms and Conditions.
The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
Section 8 Interpretation.
For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.

14
Makefile Normal file
View File

@@ -0,0 +1,14 @@
all:
r -e 'bookdown::render_book()'
pdf:
r -e 'bookdown::render_book("index.Rmd", "bookdown::pdf_book")'
doc:
r -e 'bookdown::render_book("index.Rmd", "bookdown::word_document2")'
serve:
r -e 'bookdown::serve_book()'
clean:
rm -rf _book _bookdown_files

8
README.md Normal file
View File

@@ -0,0 +1,8 @@
# 计算机体系结构基础
这是一本介绍计算机体系结构基础知识的开源教科书,主要目标读者是为计算机相关专业的本科学生,也可以供计算机领域其他相关技术人员参考。
关于本书的具体内容介绍,可以参考前言、自序和推荐序等。
本书采用[Creative Commons Attribution-NonCommercial 4.0 International Public License](https://creativecommons.org/licenses/by-nc/4.0/legalcode)开源具体条款可参考LICENSE文件。

11
_bookdown.yml Normal file
View File

@@ -0,0 +1,11 @@
book_filename: bookdown
clean: [bookdown.bbl]
delete_merged_file: true
language:
label:
fig: "图 "
tab: "表 "
ui:
edit: "编辑"
chapter_name: ["第 ", " 章"]
part_name: ["第 ", " 部分"]

34
_output.yml Normal file
View File

@@ -0,0 +1,34 @@
bookdown::gitbook:
css: css/style.css
config:
toc:
collapse: none
before: |
<li><a href="./">计算机体系结构基础</a></li>
after: |
<li><a href="http://www.loongson.cn" target="blank">本书电子版由龙芯中科赞助提供</a></li>
download: [pdf, epub]
edit: https://github.com/foxsen/archbase-book/edit/master/%s
sharing:
github: yes
facebook: no
bookdown::pdf_book:
includes:
in_header: latex/preamble.tex
before_body: latex/before_body.tex
after_body: latex/after_body.tex
keep_tex: yes
dev: "cairo_pdf"
latex_engine: xelatex
# citation_package: natbib
template: latex/template.tex
pandoc_args: "--top-level-division=chapter"
toc_depth: 3
toc_unnumbered: no
toc_appendix: yes
quote_footer: ["\\begin{flushright}", "\\end{flushright}"]
bookdown::epub_book:
stylesheet: css/style.css
bookdown::word_document2:
reference_docx: ./word/template.docx
toc: true

496
book.bib Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,213 @@
<?xml version="1.0" encoding="utf-8"?>
<style xmlns="http://purl.org/net/xbiblio/csl" class="in-text" version="1.0" demote-non-dropping-particle="sort-only" default-locale="zh-CN">
<info>
<title>Chinese Std GB/T 7714-2005 (numeric, Chinese)</title>
<id>http://www.zotero.org/styles/chinese-gb7714-2005-numeric</id>
<link href="http://www.zotero.org/styles/chinese-gb7714-2005-numeric" rel="self"/>
<link href="http://gradschool.ustc.edu.cn/ylb/material/xw/wdxz/19.pdf" rel="documentation"/>
<author>
<name>heromyth</name>
<email>zxpmyth@yahoo.com.cn</email>
</author>
<category citation-format="numeric"/>
<category field="engineering"/>
<category field="generic-base"/>
<category field="science"/>
<summary>This style just partly implemented what the Chinese GB/T 7714-2005 requires.</summary>
<updated>2018-02-18T21:20:55+00:00</updated>
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
</info>
<macro name="author">
<names variable="author">
<name initialize-with=" " name-as-sort-order="all" sort-separator=" " delimiter=", " delimiter-precedes-last="always">
<name-part name="family" text-case="uppercase"/>
</name>
</names>
</macro>
<macro name="recipient">
<names variable="recipient">
<name name-as-sort-order="all" sort-separator=" " delimiter=", " delimiter-precedes-last="always"/>
<label form="short" prefix=", " text-case="lowercase"/>
</names>
</macro>
<macro name="interviewer">
<names variable="interviewer">
<name name-as-sort-order="all" sort-separator=" " delimiter=", " delimiter-precedes-last="always"/>
<label form="short" prefix=", " text-case="lowercase"/>
</names>
</macro>
<macro name="composer">
<names variable="composer">
<name name-as-sort-order="all" sort-separator=" " delimiter=", " delimiter-precedes-last="always"/>
<label form="short" prefix=", " text-case="lowercase"/>
</names>
</macro>
<macro name="original-author">
<names variable="original-author">
<name name-as-sort-order="all" sort-separator=" " delimiter=", " delimiter-precedes-last="always"/>
<label form="short" prefix=", " text-case="lowercase"/>
</names>
</macro>
<macro name="title">
<text variable="title"/>
</macro>
<macro name="titleField">
<choose>
<if type="report">
<text macro="title" suffix="[R]. "/>
</if>
<else-if type="thesis">
<text macro="title" suffix="[D]. "/>
</else-if>
<else-if type="bill legislation" match="any">
<text variable="number" suffix=", "/>
<text macro="title" suffix="[S]"/>
</else-if>
<else-if type="bill book graphic legal_case legislation motion_picture report song" match="any">
<text macro="title" suffix="[M]. "/>
</else-if>
<else-if type="paper-conference">
<text macro="title" suffix="[C]//"/>
</else-if>
<else-if type="chapter paper-conference" match="any">
<text macro="title" suffix="[G]//"/>
</else-if>
<else-if type="webpage">
<text macro="title" suffix="[EB/OL]. "/>
</else-if>
<else-if type="patent">
<text macro="title"/>
<text variable="number" prefix=": 中国, " suffix="[P]. "/>
</else-if>
<else>
<text macro="title" suffix="[J]. "/>
</else>
</choose>
</macro>
<macro name="secondaryAuthor">
<names variable="editor">
<name initialize-with=" " name-as-sort-order="all" sort-separator=" " delimiter=", " delimiter-precedes-last="always">
<name-part name="family" text-case="uppercase"/>
</name>
</names>
<names variable="translator">
<name name-as-sort-order="all" sort-separator=" " delimiter=", " delimiter-precedes-last="always" suffix=", 译"/>
</names>
</macro>
<macro name="publisher">
<choose>
<if type="chapter paper-conference" match="any">
<text variable="container-title" suffix=". "/>
</if>
<else-if type="report">
<text variable="collection-title" suffix=", "/>
<text variable="number" suffix=", "/>
</else-if>
<else-if type="bill legislation" match="any">
<text variable="container-title" prefix=". "/>
</else-if>
<else>
<text variable="container-title" suffix=", "/>
</else>
</choose>
<!--
<text variable="event" suffix="event "/>
<text variable="event-place" suffix="event-place "/>
<text variable="original-title" suffix="original-title"/>
-->
<text variable="publisher-place" suffix=": "/>
<group delimiter=", ">
<text variable="publisher"/>
<choose>
<if type="webpage" variable="container-title" match="all">
<date variable="issued" suffix=". ">
<date-part name="year"/>
<date-part name="month" form="numeric-leading-zeros" prefix="-"/>
<date-part name="day" form="numeric-leading-zeros" prefix="-"/>
</date>
</if>
<else-if type="webpage"/>
<else-if type="patent">
<date variable="issued">
<date-part name="year"/>
<date-part name="month" form="numeric-leading-zeros" prefix="-"/>
<date-part name="day" form="numeric-leading-zeros" prefix="-"/>
</date>
</else-if>
<else-if variable="publisher">
<date variable="issued">
<date-part name="year"/>
</date>
</else-if>
<else-if type="bill legislation" match="any"/>
<else>
<date variable="issued">
<date-part name="year"/>
</date>
</else>
</choose>
</group>
<text variable="volume" prefix=", "/>
<text variable="issue" prefix="(" suffix=")"/>
</macro>
<macro name="pageField">
<text variable="page"/>
</macro>
<macro name="referenceDate">
<choose>
<if type="webpage">
<date variable="issued" prefix="(" suffix=")">
<date-part name="year"/>
<date-part name="month" form="numeric-leading-zeros" prefix="-"/>
<date-part name="day" form="numeric-leading-zeros" prefix="-"/>
</date>
<date variable="accessed" prefix="[" suffix="]">
<date-part name="year"/>
<date-part name="month" form="numeric-leading-zeros" prefix="-"/>
<date-part name="day" form="numeric-leading-zeros" prefix="-"/>
</date>
</if>
</choose>
</macro>
<macro name="access">
<choose>
<if variable="DOI">
<text variable="DOI" prefix="doi:"/>
</if>
<else-if variable="URL">
<text variable="URL"/>
</else-if>
</choose>
</macro>
<citation collapse="citation-number">
<sort>
<key variable="citation-number" sort="ascending"/>
</sort>
<layout vertical-align="sup" prefix="[" suffix="]" delimiter=",">
<text variable="citation-number"/>
</layout>
</citation>
<bibliography et-al-min="4" et-al-use-first="3" second-field-align="flush" entry-spacing="0">
<layout suffix=".">
<text variable="citation-number" prefix="[" suffix="]"/>
<text macro="author" suffix=". "/>
<text macro="titleField"/>
<text macro="secondaryAuthor" suffix=". "/>
<text variable="edition" prefix="第" suffix="版. "/>
<text macro="publisher"/>
<text macro="pageField" prefix=": "/>
<text macro="referenceDate"/>
<choose>
<if type="webpage" match="any">
<text macro="access" prefix=". "/>
</if>
</choose>
<text macro="recipient"/>
<text macro="interviewer"/>
<text macro="composer"/>
<text macro="original-author"/>
</layout>
</bibliography>
</style>

27
css/style.css Normal file
View File

@@ -0,0 +1,27 @@
p.caption {
color: #777;
margin-top: 10px;
}
p code {
white-space: inherit;
}
pre {
word-break: normal;
word-wrap: normal;
}
pre code {
white-space: inherit;
}
p.flushright {
text-align: right;
}
blockquote > p:last-child {
text-align: right;
}
blockquote > p:first-child {
text-align: inherit;
}
.cols {display: flex; }
.width48 {width: 48%; }
.width4 {width: 4%; }

BIN
images/by-nc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
images/chapter1/1-1.eps Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 KiB

BIN
images/chapter1/power.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because one or more lines are too long

6804
images/chapter10/线程同步.ai Executable file

File diff suppressed because one or more lines are too long

BIN
images/chapter10/线程同步.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

6669
images/chapter10/线程管理.ai Executable file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

File diff suppressed because one or more lines are too long

BIN
images/chapter11/11-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

BIN
images/chapter11/11-17.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

BIN
images/chapter11/11-18.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

BIN
images/chapter11/11-24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

BIN
images/chapter11/11-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
images/chapter11/topo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
images/chapter2/csr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
images/chapter2/lwl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
images/chapter2/page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
images/chapter2/segment.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
images/chapter3/crmd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
images/chapter3/csr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Some files were not shown because too many files have changed in this diff Show More